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