diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml new file mode 100644 index 0000000..302f859 --- /dev/null +++ b/.pre-commit-hooks.yaml @@ -0,0 +1,18 @@ +- id: kingfisher-docker + name: kingfisher (docker) + description: Run Kingfisher in Docker against staged changes at the repository root. No local install required. + entry: ghcr.io/kingfisher-sec/kingfisher:latest + language: docker + args: ["scan", ".", "--staged", "--quiet", "--redact", "--only-valid", "--no-update-check"] + pass_filenames: false + stages: [commit] + +- id: kingfisher + name: kingfisher + description: Scan staged changes with the locally installed Kingfisher binary. + entry: kingfisher + language: system + args: ["scan", ".", "--staged", "--quiet", "--redact", "--only-valid", "--no-update-check"] + pass_filenames: false + types: [file] + stages: [commit] \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 31410c2..d52d8cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,8 @@ All notable changes to this project will be documented in this file. ## [v1.70.0] -- Added new rules for AWS Bedrock, Voyage.ai, Posthog +- Added `--staged` argument to support new `pre-commit` behavior and added integration coverage to ensure validated secrets block commits when used as pre-commit hook +- Added new rules for AWS Bedrock, Voyage.ai, Posthog, Atlassian - Added a `kingfisher view` subcommand that serves the bundled access-map HTML viewer from the binary so users can load JSON or JSONL reports passed on the CLI (or upload them in the browser) over a configurable local-only port. ## [v1.69.0] diff --git a/README.md b/README.md index 5a55354..97ac307 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,10 @@ See ([docs/COMPARISON.md](docs/COMPARISON.md)) - [Homebrew](#homebrew) - [Linux and macOS](#linux-and-macos) - [Windows](#windows) + - [Pre-commit hooks](#pre-commit-hooks) + - [macOS and Linux](#macos-and-linux) + - [Windows PowerShell](#windows-powershell) + - [Using the `pre-commit` framework](#using-the-pre-commit-framework) - [Compile](#compile) - [ Run Kingfisher in Docker](#-run-kingfisher-in-docker) - [🔐 Detection Rules at a Glance](#-detection-rules-at-a-glance) @@ -216,6 +220,133 @@ You can provide a custom destination using the `-InstallDir` parameter: +### Pre-commit hooks + +Install a Git pre-commit hook to block commits that introduce new secrets. + +The installer: + +- Preserves any existing `pre-commit` hook by chaining it **before** Kingfisher. +- Supports custom hook directories via `--hooks-path` (or Git’s `core.hooksPath`). +- Can be installed either **per-repository** or as a **global** hook. + +#### macOS and Linux + +
+ +Install a **per-repository** hook from the root of the repo you want to protect: + +```bash +curl --silent --location \ + https://raw.githubusercontent.com/mongodb/kingfisher/main/scripts/install-kingfisher-pre-commit.sh | \ + bash +``` + +Uninstall from that repository: + +```bash +curl --silent --location \ + https://raw.githubusercontent.com/mongodb/kingfisher/main/scripts/install-kingfisher-pre-commit.sh | \ + bash -s -- --uninstall +``` + +Install as a **global** pre-commit hook (using core.hooksPath): + +```bash +curl --silent --location \ + https://raw.githubusercontent.com/mongodb/kingfisher/main/scripts/install-kingfisher-pre-commit.sh | \ + bash -s -- --global +``` + +Uninstall the **global** hook: + +```bash +curl --silent --location \ + https://raw.githubusercontent.com/mongodb/kingfisher/main/scripts/install-kingfisher-pre-commit.sh | \ + bash -s -- --global --uninstall +``` + +
+ +#### Windows PowerShell + +
+ +Install a **per-repository** hook from the root of the target repo: + +```powershell +Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force +Invoke-WebRequest -Uri 'https://raw.githubusercontent.com/mongodb/kingfisher/main/scripts/install-kingfisher-pre-commit.ps1' -OutFile install-kingfisher-pre-commit.ps1 +./install-kingfisher-pre-commit.ps1 +``` + +Uninstall from that repository: + +```powershell +./install-kingfisher-pre-commit.ps1 -Uninstall +``` + +Install as a **global** hook (using core.hooksPath): + +```powershell +./install-kingfisher-pre-commit.ps1 -Global +``` + +Uninstall the **global** hook: +```powershell +./install-kingfisher-pre-commit.ps1 -Global -Uninstall +``` + +> The installer automatically runs any existing `pre-commit` hook first, then +> executes `kingfisher scan . --staged --quiet --redact --only-valid --no-update-check` +> against the staged diff (anchored to `HEAD` when no commits exist yet). + +
+ +#### Using the `pre-commit` framework + +Add Kingfisher as a hook in your `.pre-commit-config.yaml`: + +
+ +```yaml +repos: + - repo: https://github.com/mongodb/kingfisher + rev: + hooks: + # No local install required; runs Kingfisher from Docker at the repo root + - id: kingfisher-docker + + # Fastest when you already have Kingfisher installed locally + - id: kingfisher +``` + +Then install the hook via `pre-commit install`. Every hook now drives Kingfisher +directly with the built-in `--staged` flag: + +```bash +kingfisher scan . --staged --quiet --redact --only-valid --no-update-check +``` + +When `--staged` is set, Kingfisher snapshots the staged index into a temporary +commit, diffs it against `HEAD` (or an empty tree if no commits exist yet), and +scans only those staged changes. This mirrors how gitleaks and TruffleHog handle +pre-commit workflows while keeping everything inside the Kingfisher binary. + +> Exit codes: Kingfisher exits `0` when no findings are present and returns +> `205` when validated credentials are discovered (other findings use codes in +> the `200` range). The hook surfaces those exit codes directly to `pre-commit`, +> so no extra handling is required—the commit will fail automatically on +> non-zero exits. + +To trigger a hook in CI without installing to `.git/hooks`, run (for example): + +```bash +pre-commit run kingfisher-pre-commit --all-files +``` + +
+ ### Compile You may compile for your platform via `make` diff --git a/data/rules/ngrok.yml b/data/rules/ngrok.yml index be45f08..711648c 100644 --- a/data/rules/ngrok.yml +++ b/data/rules/ngrok.yml @@ -3,6 +3,7 @@ rules: id: kingfisher.ngrok.1 pattern: | (?xi) + \b ngrok (?:.|[\\n\r]){0,32}? (?:SECRET|PRIVATE|ACCESS|KEY|TOKEN) diff --git a/scripts/install-kingfisher-pre-commit.ps1 b/scripts/install-kingfisher-pre-commit.ps1 new file mode 100644 index 0000000..d14292d --- /dev/null +++ b/scripts/install-kingfisher-pre-commit.ps1 @@ -0,0 +1,161 @@ +<#! +.SYNOPSIS + Install or remove Kingfisher Git pre-commit hooks (local or global). + +.DESCRIPTION + Supports repo installs, global installs (via core.hooksPath), and + custom hook directories. Preserves existing hooks safely and provides + uninstall behavior mirroring the Bash installer. + +.PARAMETER Global + Install into the global Git hooks directory. + +.PARAMETER HooksPath + Manually override the hooks directory. + +.PARAMETER Uninstall + Remove the Kingfisher hook and restore a legacy hook if present. +#> + +[CmdletBinding()] +param( + [string]$HooksPath, + [switch]$Uninstall, + [switch]$Global +) + +function Ensure-InRepo { + if (-not $Global -and -not (git rev-parse --is-inside-work-tree 2>$null)) { + throw "This installer must be run inside a Git repository unless --Global is specified." + } +} + +function Resolve-HooksPath { + param([string]$Override, [switch]$Global) + + # Explicit override wins + if ($Override) { + return (Resolve-Path $Override).Path + } + + # Global mode + if ($Global) { + $p = git config --global core.hooksPath 2>$null + if (-not $p) { + # Default global hooks directory + $p = Join-Path $HOME ".git-hooks" + git config --global core.hooksPath $p + Write-Host "Configured global Git hooks at $p" + } + return $p + } + + # Repo mode + $repoHooks = git rev-parse --git-path hooks 2>$null + if (-not $repoHooks) { throw "Unable to resolve repository hooks path." } + return $repoHooks.Trim() +} + +function Uninstall-Kingfisher { + param( + [string]$PreCommit, + [string]$Legacy, + [string]$KFHook, + [string]$Marker + ) + + # Only try to inspect hook if it exists + if (Test-Path $PreCommit) { + # Only restore if this is our wrapper + if (Select-String -Quiet -SimpleMatch -Path $PreCommit -Pattern $Marker) { + if (Test-Path $Legacy) { + Move-Item -Force $Legacy $PreCommit + & chmod +x $PreCommit 2>$null | Out-Null + Write-Host "Restored previous pre-commit hook from $Legacy" + } + else { + Remove-Item -Force $PreCommit + Write-Host "Removed Kingfisher pre-commit wrapper." + } + } + } + + # Always clean up wrapper + legacy + Remove-Item -Force -ErrorAction SilentlyContinue $KFHook, $Legacy + Write-Host "Kingfisher pre-commit hook uninstalled." +} + +Ensure-InRepo + +# Determine hooks directory safely +$hooksDir = Resolve-HooksPath -Override $HooksPath -Global:$Global + +if (-not (Test-Path $hooksDir)) { + New-Item -ItemType Directory -Force -Path $hooksDir | Out-Null +} + +$preCommit = Join-Path $hooksDir "pre-commit" +$legacy = Join-Path $hooksDir "pre-commit.legacy.kingfisher" +$kfHook = Join-Path $hooksDir "kingfisher-pre-commit" +$marker = "# Kingfisher pre-commit wrapper" + +if ($Uninstall) { + Uninstall-Kingfisher -PreCommit $preCommit -Legacy $legacy -KFHook $kfHook -Marker $marker + return +} + +# ---- Kingfisher hook ---- +$kfContent = @" +#!/usr/bin/env bash +set -euo pipefail + +if ! command -v kingfisher >/dev/null 2>&1; then + echo "Kingfisher is not on PATH; skipping scan." >&2 + exit 0 +fi + +repo_root="\$(git rev-parse --show-toplevel)" +cd "\$repo_root" + +kingfisher scan . --staged --quiet --redact --only-valid --no-update-check +"@ + +# ---- Wrapper ---- +# Note: No dirname logic here — absolute paths only +$wrapper = @" +#!/usr/bin/env bash +$marker +set -euo pipefail + +legacy_hook="$legacy" +kingfisher_hook="$kfHook" + +if [[ -f "\$legacy_hook" && -x "\$legacy_hook" ]]; then + "\$legacy_hook" "\$@" +fi + +"\$kingfisher_hook" "\$@" +"@ + +# Write inner Kingfisher hook +Set-Content -Path $kfHook -Value $kfContent -NoNewline +& chmod +x $kfHook 2>$null | Out-Null + +# Preserve existing hook ONLY if it exists +if (Test-Path $preCommit) { + # And if it's not our wrapper + if (-not (Select-String -Quiet -SimpleMatch -Path $preCommit -Pattern $marker)) { + Move-Item -Force $preCommit $legacy + & chmod +x $legacy 2>$null + Write-Host "Existing pre-commit hook preserved at $legacy" + } +} + +# Write wrapper +Set-Content -Path $preCommit -Value $wrapper -NoNewline +& chmod +x $preCommit 2>$null | Out-Null + +Write-Host "Kingfisher pre-commit hook installed at $preCommit" +if (Test-Path $legacy) { + Write-Host "Existing hook will run first from $legacy" +} diff --git a/scripts/install-kingfisher-pre-commit.sh b/scripts/install-kingfisher-pre-commit.sh new file mode 100644 index 0000000..8c4f122 --- /dev/null +++ b/scripts/install-kingfisher-pre-commit.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: install-kingfisher-pre-commit.sh [--global] [--hooks-path PATH] [--uninstall] + +Installs a Git pre-commit hook that runs Kingfisher. + +Modes: + (default) Install in the current repo. + --global Install in the global Git hooks directory. + --hooks-path Override hooks directory (repo only). + --uninstall Remove the installed hook. + +USAGE +} + +GLOBAL=false +UNINSTALL=false +HOOKS_PATH="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --global) + GLOBAL=true + shift + ;; + --hooks-path) + HOOKS_PATH="$2" + shift 2 + ;; + --uninstall) + UNINSTALL=true + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage + exit 1 + ;; + esac +done + +# ------------------------------ +# Determine hooks directory +# ------------------------------ +if $GLOBAL; then + GLOBAL_PATH="$(git config --global core.hooksPath || true)" + if [[ -z "$GLOBAL_PATH" ]]; then + GLOBAL_PATH="$HOME/.git-hooks" + git config --global core.hooksPath "$GLOBAL_PATH" + echo "Configured global Git hooks at $GLOBAL_PATH" + fi + HOOKS_PATH="$GLOBAL_PATH" +else + if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "Error: must be run inside a Git repository unless using --global." >&2 + exit 1 + fi + if [[ -z "$HOOKS_PATH" ]]; then + HOOKS_PATH="$(git rev-parse --git-path hooks)" + fi +fi + +mkdir -p "$HOOKS_PATH" + +PRE_COMMIT="$HOOKS_PATH/pre-commit" +LEGACY="$HOOKS_PATH/pre-commit.legacy.kingfisher" +KF_HOOK="$HOOKS_PATH/kingfisher-pre-commit" +MARKER="# Kingfisher pre-commit wrapper" + +# ------------------------------ +# Uninstall +# ------------------------------ +uninstall() { + if [[ -f "$PRE_COMMIT" ]] && grep -q "$MARKER" "$PRE_COMMIT"; then + if [[ -f "$LEGACY" ]]; then + mv "$LEGACY" "$PRE_COMMIT" + chmod +x "$PRE_COMMIT" + echo "Restored previous pre-commit hook from $LEGACY" + else + rm -f "$PRE_COMMIT" + echo "Removed Kingfisher pre-commit wrapper." + fi + fi + + rm -f "$KF_HOOK" "$LEGACY" + echo "Kingfisher pre-commit hook uninstalled." +} + +if $UNINSTALL; then + uninstall + exit 0 +fi + +# ------------------------------ +# Create Kingfisher hook +# ------------------------------ +cat > "$KF_HOOK" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +if ! command -v kingfisher >/dev/null 2>&1; then + echo "Kingfisher is not on PATH; skipping scan." >&2 + exit 0 +fi + +repo_root="$(git rev-parse --show-toplevel)" +cd "$repo_root" + +kingfisher scan . --staged --quiet --redact --only-valid --no-update-check +EOF +chmod +x "$KF_HOOK" + +# ------------------------------ +# Preserve existing hook only if it exists +# ------------------------------ +if [[ -f "$PRE_COMMIT" ]]; then + if ! grep -q "$MARKER" "$PRE_COMMIT"; then + mv "$PRE_COMMIT" "$LEGACY" + chmod +x "$LEGACY" + echo "Existing pre-commit hook preserved at $LEGACY" + fi +fi + +# ------------------------------ +# Install wrapper +# ------------------------------ +cat > "$PRE_COMMIT" <, + /// Scan only staged changes by synthesizing a temporary commit and diffing it + /// against the current HEAD (or an empty tree when no commits exist). + #[arg( + long, + help_heading = "Git Options", + conflicts_with = "branch_root", + conflicts_with = "branch_root_commit" + )] + pub staged: bool, + /// Branch, tag, or commit to scan or compare against (defaults to HEAD) #[arg( long, diff --git a/src/lib.rs b/src/lib.rs index a2bb426..a7b3d39 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -65,6 +65,7 @@ pub struct GitDiffConfig { pub since_ref: Option, pub branch_ref: String, pub branch_root: Option, + pub staged: bool, } struct EnumeratorConfig { diff --git a/src/main.rs b/src/main.rs index 3d5e152..9352779 100644 --- a/src/main.rs +++ b/src/main.rs @@ -444,6 +444,7 @@ fn create_default_scan_args() -> cli::commands::scan::ScanArgs { branch: None, branch_root: false, branch_root_commit: None, + staged: false, }, extra_ignore_comments: Vec::new(), content_filtering_args: ContentFilteringArgs { diff --git a/src/reporter.rs b/src/reporter.rs index 36539b9..87c0205 100644 --- a/src/reporter.rs +++ b/src/reporter.rs @@ -917,6 +917,7 @@ mod tests { branch: None, branch_root: false, branch_root_commit: None, + staged: false, }, extra_ignore_comments: Vec::new(), content_filtering_args: ContentFilteringArgs { diff --git a/src/reporter/json_format.rs b/src/reporter/json_format.rs index 4be0701..3667bc6 100644 --- a/src/reporter/json_format.rs +++ b/src/reporter/json_format.rs @@ -162,6 +162,7 @@ mod tests { branch: None, branch_root: false, branch_root_commit: None, + staged: false, }, extra_ignore_comments: Vec::new(), content_filtering_args: ContentFilteringArgs { diff --git a/src/scanner/enumerate.rs b/src/scanner/enumerate.rs index eb32f7f..fa5679e 100644 --- a/src/scanner/enumerate.rs +++ b/src/scanner/enumerate.rs @@ -1,6 +1,7 @@ use std::{ marker::PhantomData, path::Path, + process::Command, sync::{ atomic::{AtomicBool, Ordering}, Arc, Mutex, @@ -63,10 +64,12 @@ pub fn enumerate_filesystem_inputs( let branch_root_enabled = args.input_specifier_args.branch_root || args.input_specifier_args.branch_root_commit.is_some(); - let diff_config = if args.input_specifier_args.since_commit.is_some() + let wants_git_diff = args.input_specifier_args.staged + || args.input_specifier_args.since_commit.is_some() || args.input_specifier_args.branch.is_some() - || branch_root_enabled - { + || branch_root_enabled; + + let diff_config = if wants_git_diff { let branch_arg = args.input_specifier_args.branch.clone(); let branch_root_commit = args.input_specifier_args.branch_root_commit.clone(); let (branch_ref, branch_root) = if branch_root_enabled { @@ -83,6 +86,7 @@ pub fn enumerate_filesystem_inputs( since_ref: args.input_specifier_args.since_commit.clone(), branch_ref, branch_root, + staged: args.input_specifier_args.staged, }) } else { None @@ -737,7 +741,26 @@ fn enumerate_git_diff_repo( exclude_globset: Option>, collect_commit_metadata: bool, ) -> Result { - let GitDiffConfig { since_ref, branch_ref, branch_root } = diff_cfg; + let GitDiffConfig { since_ref, branch_ref, branch_root, staged } = diff_cfg; + + let (branch_ref, since_ref, branch_root) = if staged { + if branch_root.is_some() { + bail!("--staged cannot be combined with --branch-root options"); + } + + let base_ref = match since_ref { + Some(explicit) => explicit, + None => detect_staged_base_ref(path)?, + }; + + let parent_ref = resolve_optional_diff_ref(&repository, path, &branch_ref) + .unwrap_or_else(|_| branch_ref.clone()); + let staged_commit = synthesize_staged_commit(path, parent_ref.as_str())?; + + (staged_commit, Some(base_ref), None) + } else { + (branch_ref, since_ref, branch_root) + }; let blobs = { let head_id = resolve_diff_ref(&repository, path, &branch_ref).with_context(|| { @@ -892,6 +915,64 @@ fn enumerate_git_diff_repo( Ok(GitRepoResult { repository, path: path.to_owned(), blobs }) } +fn synthesize_staged_commit(path: &Path, parent_ref: &str) -> Result { + let parent_arg: Vec<&str> = + if parent_ref.is_empty() { Vec::new() } else { vec!["-p", parent_ref] }; + + let staged_tree = + run_git_command(path, &["write-tree"], true)?.context("Failed to snapshot staged index")?; + + let mut args = vec!["commit-tree", &staged_tree, "-m", "kingfisher staged snapshot"]; + args.extend(parent_arg.iter().copied()); + + run_git_command(path, &args, true)?.context("Failed to create staged snapshot commit") +} + +fn detect_staged_base_ref(path: &Path) -> Result { + if let Some(head) = run_git_command(path, &["rev-parse", "--verify", "HEAD"], false)? { + return Ok(head); + } + + run_git_command(path, &["hash-object", "-t", "tree", "/dev/null"], true)? + .context("Failed to resolve an empty tree when no base ref was available") +} + +fn resolve_optional_diff_ref( + repository: &gix::Repository, + path: &Path, + reference: &str, +) -> Result { + resolve_diff_ref(repository, path, reference).map(|id| id.to_hex().to_string()) +} + +fn run_git_command(path: &Path, args: &[&str], bubble_up_error: bool) -> Result> { + let output = Command::new("git").arg("-C").arg(path).args(args).output()?; + + if !output.status.success() { + if bubble_up_error { + bail!( + "Git command failed ({}): git -C {} {}", + output.status, + path.display(), + args.join(" ") + ); + } + return Ok(None); + } + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if stdout.is_empty() { + Ok(None) + } else { + Ok(Some(stdout)) + } +} + +fn command_succeeds(path: &Path, args: &[&str]) -> Result { + let status = Command::new("git").arg("-C").arg(path).args(args).status()?; + Ok(status.success()) +} + fn resolve_diff_ref<'repo>( repository: &'repo gix::Repository, path: &Path, @@ -1064,6 +1145,7 @@ mod tests { since_ref: None, branch_ref: "featurefake".to_string(), branch_root: None, + staged: false, }, None, false, diff --git a/src/validation/httpvalidation.rs b/src/validation/httpvalidation.rs index 866d83a..2a23d65 100644 --- a/src/validation/httpvalidation.rs +++ b/src/validation/httpvalidation.rs @@ -533,10 +533,7 @@ mod tests { #[test] fn test_body_looks_like_html_trims_whitespace() { let mut headers = HeaderMap::new(); - headers.insert( - header::CONTENT_TYPE, - HeaderValue::from_static("text/html; charset=utf-8"), - ); + headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8")); let body = "\n\n \n\npage"; @@ -547,16 +544,13 @@ mod tests { fn test_html_response_rejected_when_not_allowed() { let matchers = vec![ResponseMatcher::StatusMatch { r#type: "status-match".to_string(), - status: vec![StatusCode::OK], + status: vec![StatusCode::OK.into()], match_all_status: false, negative: false, }]; let mut headers = HeaderMap::new(); - headers.insert( - header::CONTENT_TYPE, - HeaderValue::from_static("text/html; charset=utf-8"), - ); + headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8")); let body = "\nSign in"; diff --git a/tests/int_allowlist.rs b/tests/int_allowlist.rs index be83f57..4b35cc7 100644 --- a/tests/int_allowlist.rs +++ b/tests/int_allowlist.rs @@ -123,6 +123,7 @@ fn run_skiplist(skip_regex: Vec, skip_skipword: Vec) -> Result Result<()> { branch: None, branch_root: false, branch_root_commit: None, + staged: false, }, content_filtering_args: ContentFilteringArgs { max_file_size_mb: 25.0, diff --git a/tests/int_dedup.rs b/tests/int_dedup.rs index 99e60d2..dfb4c67 100644 --- a/tests/int_dedup.rs +++ b/tests/int_dedup.rs @@ -143,6 +143,7 @@ rules: branch: None, branch_root: false, branch_root_commit: None, + staged: false, }, content_filtering_args: ContentFilteringArgs { max_file_size_mb: 5.0, diff --git a/tests/int_github.rs b/tests/int_github.rs index 25ec8a1..cc2582b 100644 --- a/tests/int_github.rs +++ b/tests/int_github.rs @@ -130,6 +130,7 @@ fn test_github_remote_scan() -> Result<()> { branch: None, branch_root: false, branch_root_commit: None, + staged: false, }, content_filtering_args: ContentFilteringArgs { max_file_size_mb: 25.0, diff --git a/tests/int_gitlab.rs b/tests/int_gitlab.rs index 02a6fe5..5ad9edf 100644 --- a/tests/int_gitlab.rs +++ b/tests/int_gitlab.rs @@ -128,6 +128,7 @@ fn test_gitlab_remote_scan() -> Result<()> { branch: None, branch_root: false, branch_root_commit: None, + staged: false, }, extra_ignore_comments: Vec::new(), content_filtering_args: ContentFilteringArgs { @@ -283,6 +284,7 @@ fn test_gitlab_remote_scan_no_history() -> Result<()> { gcs_bucket: None, gcs_prefix: None, gcs_service_account: None, + staged: false, }, content_filtering_args: ContentFilteringArgs { max_file_size_mb: 25.0, diff --git a/tests/int_redact.rs b/tests/int_redact.rs index 5aefb26..ede2145 100644 --- a/tests/int_redact.rs +++ b/tests/int_redact.rs @@ -106,6 +106,7 @@ async fn test_redact_hashes_finding_values() -> Result<()> { branch: None, branch_root: false, branch_root_commit: None, + staged: false, }, content_filtering_args: ContentFilteringArgs { max_file_size_mb: 25.0, diff --git a/tests/int_slack.rs b/tests/int_slack.rs index 40f2d80..72fb1e6 100644 --- a/tests/int_slack.rs +++ b/tests/int_slack.rs @@ -114,6 +114,7 @@ impl TestContext { branch: None, branch_root: false, branch_root_commit: None, + staged: false, }, extra_ignore_comments: Vec::new(), content_filtering_args: ContentFilteringArgs { @@ -154,6 +155,7 @@ impl TestContext { #[tokio::test] async fn test_scan_slack_messages() -> Result<()> { + use std::env; let ctx = TestContext::new()?; let server = MockServer::start().await; @@ -256,6 +258,7 @@ async fn test_scan_slack_messages() -> Result<()> { branch: None, branch_root: false, branch_root_commit: None, + staged: false, }, content_filtering_args: ContentFilteringArgs { max_file_size_mb: 25.0, diff --git a/tests/int_validation_cache.rs b/tests/int_validation_cache.rs index 7cb7416..289ee29 100644 --- a/tests/int_validation_cache.rs +++ b/tests/int_validation_cache.rs @@ -186,6 +186,7 @@ async fn test_validation_cache_and_depvars() -> Result<()> { branch: None, branch_root: false, branch_root_commit: None, + staged: false, }, content_filtering_args: ContentFilteringArgs { max_file_size_mb: 25.0, diff --git a/tests/int_vulnerable_files.rs b/tests/int_vulnerable_files.rs index 86e5186..4ab35e0 100644 --- a/tests/int_vulnerable_files.rs +++ b/tests/int_vulnerable_files.rs @@ -129,6 +129,7 @@ impl TestContext { branch: None, branch_root: false, branch_root_commit: None, + staged: false, }, content_filtering_args: ContentFilteringArgs { max_file_size_mb: 25.0, @@ -259,6 +260,7 @@ impl TestContext { gcs_bucket: None, gcs_prefix: None, gcs_service_account: None, + staged: false, }, extra_ignore_comments: Vec::new(), content_filtering_args: ContentFilteringArgs { diff --git a/tests/pre_commit_installer.rs b/tests/pre_commit_installer.rs new file mode 100644 index 0000000..6acb437 --- /dev/null +++ b/tests/pre_commit_installer.rs @@ -0,0 +1,351 @@ +use assert_cmd::assert::OutputAssertExt; +use assert_cmd::Command; +use predicates::str::contains; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command as StdCommand; +use tempfile::TempDir; + +fn project_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +fn copy_scripts(dest: &Path) { + let scripts_dir = dest.join("scripts"); + fs::create_dir_all(&scripts_dir).unwrap(); + + let src = project_root().join("scripts").join("install-kingfisher-pre-commit.sh"); + let dst = scripts_dir.join("install-kingfisher-pre-commit.sh"); + fs::copy(src, dst).unwrap(); +} + +fn init_repo() -> (TempDir, PathBuf, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let repo = dir.path().to_path_buf(); + + copy_scripts(&repo); + + Command::new("git").arg("init").current_dir(&repo).assert().success(); + + let hooks_path = repo.join(".git/hooks"); + fs::create_dir_all(&hooks_path).unwrap(); + + (dir, repo.clone(), hooks_path) +} + +fn install(repo: &Path, hooks_path: &Path) { + Command::new("bash") + .arg(repo.join("scripts/install-kingfisher-pre-commit.sh")) + .arg("--hooks-path") + .arg(hooks_path) + .current_dir(repo) + .assert() + .success() + .stdout(contains("Kingfisher pre-commit hook installed")); +} + +// +// ===================================================== +// REPO-MODE TESTS (original ones, unchanged) +// ===================================================== +// + +#[test] +fn installs_wrapper_without_existing_hook() { + let (_tmp, repo, hooks_path) = init_repo(); + + install(&repo, &hooks_path); + + let pre_commit = hooks_path.join("pre-commit"); + let kf_wrapper = hooks_path.join("kingfisher-pre-commit"); + let legacy = hooks_path.join("pre-commit.legacy.kingfisher"); + + let wrapper = fs::read_to_string(&pre_commit).unwrap(); + let kf_script = fs::read_to_string(&kf_wrapper).unwrap(); + + assert!(wrapper.contains("# Kingfisher pre-commit wrapper")); + assert!(wrapper.contains("kingfisher-pre-commit")); + assert!(kf_script + .contains("kingfisher scan . --staged --quiet --redact --only-valid --no-update-check")); + assert!(!legacy.exists()); +} + +#[test] +fn preserves_existing_hook_and_runs_it_first() { + let (_tmp, repo, hooks_path) = init_repo(); + + let log = repo.join("hook.log"); + let legacy = hooks_path.join("pre-commit"); + fs::write(&legacy, format!("#!/usr/bin/env bash\necho legacy >> {}\n", log.display())).unwrap(); + StdCommand::new("chmod").args(["+x", legacy.to_str().unwrap()]).assert().success(); + + let bin_dir = repo.join("bin"); + fs::create_dir_all(&bin_dir).unwrap(); + + let fake_kingfisher = bin_dir.join("kingfisher"); + fs::write( + &fake_kingfisher, + format!("#!/usr/bin/env bash\necho \"kingfisher $*\" >> {}\n", log.display()), + ) + .unwrap(); + StdCommand::new("chmod").args(["+x", fake_kingfisher.to_str().unwrap()]).assert().success(); + + install(&repo, &hooks_path); + + // Execute wrapper + let wrapper = hooks_path.join("pre-commit"); + StdCommand::new(wrapper) + .current_dir(&repo) + .env("PATH", format!("{}:{}", bin_dir.display(), std::env::var("PATH").unwrap())) + .assert() + .success(); + + let log_contents = fs::read_to_string(&log).unwrap(); + let lines: Vec<_> = log_contents.lines().collect(); + assert_eq!(lines[0], "legacy"); + assert!(lines[1] + .contains("kingfisher scan . --staged --quiet --redact --only-valid --no-update-check")); + + assert!(hooks_path.join("pre-commit.legacy.kingfisher").exists()); +} + +#[test] +fn uninstall_restores_original_hook() { + let (_tmp, repo, hooks_path) = init_repo(); + + let legacy = hooks_path.join("pre-commit"); + fs::write(&legacy, "#!/usr/bin/env bash\necho legacy\n").unwrap(); + StdCommand::new("chmod").args(["+x", legacy.to_str().unwrap()]).assert().success(); + + install(&repo, &hooks_path); + + Command::new("bash") + .arg(repo.join("scripts/install-kingfisher-pre-commit.sh")) + .arg("--uninstall") + .arg("--hooks-path") + .arg(&hooks_path) + .current_dir(&repo) + .assert() + .success(); + + let restored = hooks_path.join("pre-commit"); + let restored_content = fs::read_to_string(&restored).unwrap(); + assert!(restored_content.contains("legacy")); + assert!(!restored_content.contains("Kingfisher pre-commit wrapper")); + assert!(!hooks_path.join("kingfisher-pre-commit").exists()); + assert!(!hooks_path.join("pre-commit.legacy.kingfisher").exists()); +} + +#[test] +fn uninstall_removes_wrapper_when_no_previous_hook() { + let (_tmp, repo, hooks_path) = init_repo(); + + install(&repo, &hooks_path); + + Command::new("bash") + .arg(repo.join("scripts/install-kingfisher-pre-commit.sh")) + .arg("--uninstall") + .arg("--hooks-path") + .arg(&hooks_path) + .current_dir(&repo) + .assert() + .success(); + + assert!(!hooks_path.join("pre-commit").exists()); + assert!(!hooks_path.join("kingfisher-pre-commit").exists()); + assert!(!hooks_path.join("pre-commit.legacy.kingfisher").exists()); +} + +#[test] +fn errors_outside_git_repository() { + let dir = tempfile::tempdir().unwrap(); + copy_scripts(dir.path()); + + Command::new("bash") + .arg(dir.path().join("scripts/install-kingfisher-pre-commit.sh")) + .current_dir(dir.path()) + .assert() + .failure() + .stderr(contains("must be run inside a Git repository")); +} + +#[test] +fn pre_commit_framework_invokes_kingfisher() { + let (_tmp, repo, hooks_path) = init_repo(); + + let log = repo.join("hook.log"); + let bin_dir = repo.join("bin"); + fs::create_dir_all(&bin_dir).unwrap(); + + let fake_kingfisher = bin_dir.join("kingfisher"); + fs::write(&fake_kingfisher, format!("#!/usr/bin/env bash\necho \"$@\" > {}\n", log.display())) + .unwrap(); + StdCommand::new("chmod").args(["+x", fake_kingfisher.to_str().unwrap()]).assert().success(); + + fs::write( + repo.join(".pre-commit-config.yaml"), + r#"repos: +- repo: local + hooks: + - id: kingfisher-local + name: kingfisher (local binary) + entry: kingfisher + language: system + args: ["scan", ".", "--staged", "--quiet", "--redact", "--only-valid", "--no-update-check"] + pass_filenames: false + always_run: true +"#, + ) + .unwrap(); + + fs::write(repo.join("README.md"), "demo").unwrap(); + + StdCommand::new("uv") + .args(["run", "--no-config", "--with", "pre-commit", "pre-commit", "run", "--all-files"]) + .current_dir(&repo) + .env("PATH", format!("{}:{}", bin_dir.display(), std::env::var("PATH").unwrap())) + .assert() + .success() + .stdout(contains("kingfisher (local binary)")); + + let log_contents = fs::read_to_string(&log).unwrap(); + assert!(log_contents.contains("scan")); + assert!(log_contents.contains("--staged")); + assert!(log_contents.contains("--quiet")); + assert!(log_contents.contains("--redact")); +} + +#[test] +fn installer_hook_executes_kingfisher_command() { + let (_tmp, repo, hooks_path) = init_repo(); + + fs::write(repo.join("canary.txt"), "secret").unwrap(); + StdCommand::new("git").args(["add", "canary.txt"]).current_dir(&repo).assert().success(); + + let log = repo.join("hook.log"); + let bin_dir = repo.join("bin"); + fs::create_dir_all(&bin_dir).unwrap(); + + let fake_kingfisher = bin_dir.join("kingfisher"); + fs::write( + &fake_kingfisher, + format!("#!/usr/bin/env bash\necho \"kingfisher $@\" >> {}\n", log.display()), + ) + .unwrap(); + StdCommand::new("chmod").args(["+x", fake_kingfisher.to_str().unwrap()]).assert().success(); + + install(&repo, &hooks_path); + + let wrapper = hooks_path.join("pre-commit"); + StdCommand::new(wrapper) + .current_dir(&repo) + .env("PATH", format!("{}:{}", bin_dir.display(), std::env::var("PATH").unwrap())) + .assert() + .success(); + + let log_contents = fs::read_to_string(&log).unwrap(); + assert!(log_contents + .contains("kingfisher scan . --staged --quiet --redact --only-valid --no-update-check")); +} + +// +// ===================================================== +// "GLOBAL" SEMANTICS TESTS USING --hooks-path +// (deterministic, no real global config) +// ===================================================== +// + +fn init_fake_global() -> (TempDir, PathBuf, PathBuf) { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().to_path_buf(); + let fake_global_hooks = root.join("fake-global-hooks"); + fs::create_dir_all(&fake_global_hooks).unwrap(); + + copy_scripts(&root); + + (tmp, root, fake_global_hooks) +} + +#[test] +fn global_semantics_installs_wrapper_and_inner_hook() { + let (_tmp, root, hooks) = init_fake_global(); + + Command::new("bash") + .arg(root.join("scripts/install-kingfisher-pre-commit.sh")) + .arg("--hooks-path") + .arg(&hooks) + .assert() + .success(); + + assert!(hooks.join("pre-commit").exists()); + assert!(hooks.join("kingfisher-pre-commit").exists()); +} + +#[test] +fn global_semantics_preserves_existing_hook_and_backup() { + let (_tmp, root, hooks) = init_fake_global(); + + let legacy = hooks.join("pre-commit"); + fs::write(&legacy, "#!/usr/bin/env bash\necho global-legacy\n").unwrap(); + StdCommand::new("chmod").args(["+x", legacy.to_str().unwrap()]).assert().success(); + + Command::new("bash") + .arg(root.join("scripts/install-kingfisher-pre-commit.sh")) + .arg("--hooks-path") + .arg(&hooks) + .assert() + .success(); + + assert!(hooks.join("pre-commit").exists()); + assert!(hooks.join("pre-commit.legacy.kingfisher").exists()); +} + +#[test] +fn global_semantics_uninstall_restores_or_removes() { + let (_tmp, root, hooks) = init_fake_global(); + + // case 1: with existing legacy + let legacy = hooks.join("pre-commit"); + fs::write(&legacy, "#!/usr/bin/env bash\necho global-legacy\n").unwrap(); + StdCommand::new("chmod").args(["+x", legacy.to_str().unwrap()]).assert().success(); + + Command::new("bash") + .arg(root.join("scripts/install-kingfisher-pre-commit.sh")) + .arg("--hooks-path") + .arg(&hooks) + .assert() + .success(); + + Command::new("bash") + .arg(root.join("scripts/install-kingfisher-pre-commit.sh")) + .arg("--uninstall") + .arg("--hooks-path") + .arg(&hooks) + .assert() + .success(); + + // After uninstall with legacy, pre-commit should exist and contain legacy content + let restored = fs::read_to_string(hooks.join("pre-commit")).unwrap(); + assert!(restored.contains("global-legacy")); + + // case 2: no existing legacy, fresh install then uninstall + let (_tmp2, root2, hooks2) = init_fake_global(); + Command::new("bash") + .arg(root2.join("scripts/install-kingfisher-pre-commit.sh")) + .arg("--hooks-path") + .arg(&hooks2) + .assert() + .success(); + + Command::new("bash") + .arg(root2.join("scripts/install-kingfisher-pre-commit.sh")) + .arg("--uninstall") + .arg("--hooks-path") + .arg(&hooks2) + .assert() + .success(); + + assert!(!hooks2.join("pre-commit").exists()); + assert!(!hooks2.join("kingfisher-pre-commit").exists()); + assert!(!hooks2.join("pre-commit.legacy.kingfisher").exists()); +}