Updated precommit behavior and docs

This commit is contained in:
Mick Grove 2025-12-09 12:56:55 -08:00
commit f1a77a736c
23 changed files with 933 additions and 14 deletions

18
.pre-commit-hooks.yaml Normal file
View file

@ -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]

View file

@ -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]

131
README.md
View file

@ -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:
</details>
### 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 Gits `core.hooksPath`).
- Can be installed either **per-repository** or as a **global** hook.
#### macOS and Linux
<details>
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
```
</details>
#### Windows PowerShell
<details>
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).
</details>
#### Using the `pre-commit` framework
Add Kingfisher as a hook in your `.pre-commit-config.yaml`:
<details>
```yaml
repos:
- repo: https://github.com/mongodb/kingfisher
rev: <version-or-commit>
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
```
</details>
### Compile
You may compile for your platform via `make`

View file

@ -3,6 +3,7 @@ rules:
id: kingfisher.ngrok.1
pattern: |
(?xi)
\b
ngrok
(?:.|[\\n\r]){0,32}?
(?:SECRET|PRIVATE|ACCESS|KEY|TOKEN)

View file

@ -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"
}

View file

@ -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" <<EOF
#!/usr/bin/env bash
$MARKER
set -euo pipefail
legacy_hook="$LEGACY"
kf_hook="$KF_HOOK"
if [[ -f "\$legacy_hook" && -x "\$legacy_hook" ]]; then
"\$legacy_hook" "\$@"
fi
"\$kf_hook" "\$@"
EOF
chmod +x "$PRE_COMMIT"
echo "Kingfisher pre-commit hook installed at $PRE_COMMIT"
if [[ -f "$LEGACY" ]]; then
echo "Existing hook will run first from $LEGACY"
fi

View file

@ -323,6 +323,16 @@ pub struct InputSpecifierArgs {
#[arg(long = "since-commit", value_name = "GIT-REF", help_heading = "Git Options")]
pub since_commit: Option<String>,
/// 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,

View file

@ -65,6 +65,7 @@ pub struct GitDiffConfig {
pub since_ref: Option<String>,
pub branch_ref: String,
pub branch_root: Option<String>,
pub staged: bool,
}
struct EnumeratorConfig {

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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<std::sync::Arc<globset::GlobSet>>,
collect_commit_metadata: bool,
) -> Result<GitRepoResult> {
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<String> {
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<String> {
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<String> {
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<Option<String>> {
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<bool> {
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,

View file

@ -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<!DOCTYPE html>\n<html lang=\"en\"><body>page</body></html>";
@ -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 = "\n<html><body>Sign in</body></html>";

View file

@ -123,6 +123,7 @@ fn run_skiplist(skip_regex: Vec<String>, skip_skipword: Vec<String>) -> Result<u
branch: None,
branch_root: false,
branch_root_commit: None,
staged: false,
},
extra_ignore_comments: Vec::new(),
content_filtering_args: ContentFilteringArgs {

View file

@ -123,6 +123,7 @@ fn test_bitbucket_remote_scan() -> Result<()> {
branch: None,
branch_root: false,
branch_root_commit: None,
staged: false,
},
content_filtering_args: ContentFilteringArgs {
max_file_size_mb: 25.0,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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 {

View file

@ -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());
}