added dark mode for finding + access map viewer

This commit is contained in:
Mick Grove 2025-12-12 17:21:17 -08:00
commit 195f086afc
25 changed files with 284 additions and 193 deletions

View file

@ -3,7 +3,7 @@
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"]
args: ["scan", ".", "--staged", "--quiet", "--no-update-check"]
pass_filenames: false
stages: [commit]
@ -12,7 +12,7 @@
description: Scan staged changes with the locally installed Kingfisher binary.
entry: kingfisher
language: system
args: ["scan", ".", "--staged", "--quiet", "--redact", "--only-valid", "--no-update-check"]
args: ["scan", ".", "--staged", "--quiet", "--no-update-check"]
pass_filenames: false
types: [file]
stages: [commit]

View file

@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file.
- 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 an embedded web-based report and access-map viewer via `kingfisher view` subcommand that can load JSON or JSONL reports passed on the CLI (or upload them in the browser)
- Added a check for network connectivity via `online` crate before attempting validation.
## [v1.69.0]
- Reduced per-match memory usage by compacting stored source locations and interning repeated capture names.

View file

@ -276,7 +276,7 @@ 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
Invoke-WebRequest -Uri 'https://raw.githubusercontent.com/mongodb/kingfisher/development/scripts/install-kingfisher-pre-commit.ps1' -OutFile install-kingfisher-pre-commit.ps1
./install-kingfisher-pre-commit.ps1
```
@ -298,7 +298,7 @@ Uninstall the **global** hook:
```
> The installer automatically runs any existing `pre-commit` hook first, then
> executes `kingfisher scan . --staged --quiet --redact --only-valid --no-update-check`
> executes `kingfisher scan . --staged --quiet --no-update-check`
> against the staged diff (anchored to `HEAD` when no commits exist yet).
</details>
@ -325,7 +325,7 @@ 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
kingfisher scan . --staged --quiet --no-update-check
```
When `--staged` is set, Kingfisher snapshots the staged index into a temporary
@ -551,6 +551,7 @@ kingfisher scan /path/to/repo --format sarif --output findings.sarif
- Add `--access-map` to enrich JSON, JSONL, BSON, pretty, and SARIF reports with an `access_map` array containing providers, accounts/projects, resources, and the permissions available for each resource (grouped when identical).
- If you validated cloud credentials without `--access-map`, Kingfisher will remind you on stderr to rerun with the flag so the access map appears in the output.
- Use the access map functionality only when you are authorized to inspect the target account, as Kingfisher will issue additional network requests to determine what access the secret grants.
- Run `kingfisher view ./kingfisher.json` to explore a report locally in a local web UI
### View access-map reports locally

View file

@ -28,9 +28,9 @@ rules:
\b
(?:AWS|AMAZON|AMZN|A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)
(?:.|[\n\r]){0,64}?
\b
[^A-Za-z0-9_+!@\#$%^&*()\]./]
([A-Za-z0-9/+]{40})
\b
[^A-Za-z0-9_+!@\#$%^&*()\]./]
|
\b(?:AWS|AMAZON|AMZN|A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)
(?:.|[\n\r]){0,96}?
@ -43,7 +43,7 @@ rules:
\b
)
pattern_requirements:
min_digits: 2
min_digits: 3
ignore_if_contains:
- "EXAMPLE"
- "TEST"

View file

@ -1,16 +1,40 @@
<!doctype html>
<html lang="en">
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kingfisher Access Map Viewer</title>
<style>
:root {
color-scheme: dark;
--brand: #0e7c56;
--brand-dark: #0a5d40;
--brand-soft: #123025;
--bg: #0b1220;
--surface: #111827;
--surface-muted: #0f172a;
--text-main: #e5e7eb;
--text-muted: #9ca3af;
--border: #1f2937;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.15);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.25), 0 2px 4px -2px rgb(0 0 0 / 0.2);
--critical: #fca5a5;
--success: #34d399;
--radius: 8px;
--hover: #1f2937;
--table-header: #0f172a;
--code-bg: #0f172a;
--code-border: #1f2937;
}
:root[data-theme="light"] {
color-scheme: light;
--brand: #0e7c56;
--brand-dark: #07402c;
--brand-soft: #e6f4ed;
--bg: #f3f4f6;
--surface: #ffffff;
--surface-muted: #f9fafb;
--text-main: #111827;
--text-muted: #6b7280;
--border: #e5e7eb;
@ -18,7 +42,10 @@
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--critical: #dc2626;
--success: #059669;
--radius: 8px;
--hover: #e5e7eb;
--table-header: #f9fafb;
--code-bg: #0f172a;
--code-border: #1f2937;
}
* { box-sizing: border-box; }
@ -103,7 +130,7 @@
display: flex;
justify-content: space-between;
align-items: center;
background: #ffffff;
background: var(--surface);
}
.panel__title h3 { margin: 0; font-size: 16px; font-weight: 600; }
@ -113,7 +140,7 @@
.upload-area {
padding: 32px;
text-align: center;
background: #f9fafb;
background: var(--surface-muted);
cursor: pointer;
transition: all 0.2s ease;
border-bottom: 1px solid var(--border);
@ -169,7 +196,7 @@
.am-sidebar {
border-right: 1px solid var(--border);
background: #f9fafb;
background: var(--surface-muted);
overflow-y: auto;
padding: 16px;
}
@ -177,13 +204,13 @@
.am-main {
padding: 24px;
overflow-y: auto;
background: #ffffff;
background: var(--surface);
}
.am-search {
position: sticky;
top: 0;
background: #f9fafb;
background: var(--surface-muted);
padding-bottom: 10px;
margin-bottom: 8px;
border-bottom: 1px solid var(--border);
@ -196,6 +223,8 @@
border-radius: 6px;
border: 1px solid var(--border);
font-size: 13px;
background: var(--surface);
color: var(--text-main);
}
.am-search input:focus {
@ -237,7 +266,7 @@
}
.node-content:hover {
background: #e5e7eb;
background: var(--hover);
}
.node-icon {
@ -286,7 +315,7 @@
border-radius: 8px;
display: grid;
place-items: center;
background: var(--bg);
background: var(--surface-muted);
border: 1px solid var(--border);
font-size: 20px;
}
@ -341,7 +370,7 @@
.table th {
text-align: left;
padding: 12px 16px;
background: #f9fafb;
background: var(--table-header);
border-bottom: 1px solid var(--border);
color: var(--text-muted);
font-weight: 600;
@ -352,7 +381,7 @@
vertical-align: top;
}
.table tr:last-child td { border-bottom: none; }
.table tr:hover td { background: #f9fafb; cursor: pointer; }
.table tr:hover td { background: var(--surface-muted); cursor: pointer; }
.sortable {
cursor: pointer;
@ -375,11 +404,11 @@
}
.status-badge.active { background: #ecfdf5; color: #15803d; }
.status-badge.inactive { background: #fef2f2; color: #b91c1c; }
.status-badge.unknown { background: #f3f4f6; color: #6b7280; }
.status-badge.unknown { background: var(--surface-muted); color: var(--text-muted); }
/* Finding detail snippet */
.snippet-box {
background: #0f172a;
background: var(--code-bg);
color: #e5e7eb;
padding: 16px;
border-radius: 6px;
@ -387,7 +416,7 @@
font-size: 12px;
overflow-x: auto;
white-space: pre;
border: 1px solid #1f2937;
border: 1px solid var(--code-border);
}
#fd-validation-box {
@ -416,7 +445,7 @@
.path-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 12px;
background: #f3f4f6;
background: var(--surface-muted);
padding: 4px 8px;
border-radius: 4px;
display: inline-block;
@ -444,6 +473,8 @@
border-radius: 6px;
width: 220px;
font-size: 13px;
background: var(--surface);
color: var(--text-main);
}
.rows-label {
@ -455,8 +486,9 @@
padding: 6px 10px;
border-radius: 6px;
border: 1px solid var(--border);
background: #fff;
background: var(--surface);
font-size: 13px;
color: var(--text-main);
}
.pager {
@ -472,11 +504,12 @@
height: 28px;
border-radius: 999px;
border: 1px solid var(--border);
background: #fff;
background: var(--surface);
display: grid;
place-items: center;
cursor: pointer;
padding: 0;
color: var(--text-main);
}
.pager-btn:disabled {
@ -486,7 +519,7 @@
/* Buttons & loader */
.btn {
background: #ffffff;
background: var(--surface);
border: 1px solid var(--border);
padding: 8px 16px;
border-radius: 6px;
@ -496,8 +529,8 @@
transition: background 0.1s, border-color 0.1s;
}
.btn:hover {
background: #f3f4f6;
border-color: #cbd5e1;
background: var(--surface-muted);
border-color: var(--border);
}
.hidden { display: none !important; }
@ -516,7 +549,7 @@
width: 40px;
height: 40px;
border-radius: 999px;
border: 3px solid #e5e7eb;
border: 3px solid var(--border);
border-top-color: #0e7c56;
animation: spin 0.8s linear infinite;
}
@ -525,7 +558,7 @@
width: 220px;
height: 6px;
border-radius: 999px;
background: #e5e7eb;
background: var(--border);
overflow: hidden;
margin: 12px auto 0;
}
@ -548,7 +581,7 @@
<body>
<div id="loader" class="loading-overlay hidden">
<div style="background:#ffffff; padding:18px 22px; border-radius:10px; border:1px solid var(--border); box-shadow:var(--shadow-md); text-align:center; min-width:260px;">
<div style="background:var(--surface); padding:18px 22px; border-radius:10px; border:1px solid var(--border); box-shadow:var(--shadow-md); text-align:center; min-width:260px; color:var(--text-main);">
<div class="spinner" style="margin:0 auto 12px;"></div>
<div class="progress-track"><div class="progress-inner"></div></div>
<div id="loader-text" style="margin-top:10px; font-size:13px; color:var(--text-muted);">
@ -565,7 +598,8 @@
<span class="hero__subtitle">Access Map &amp; Findings</span>
</div>
</div>
<div style="display:flex; gap:10px;">
<div style="display:flex; gap:10px; align-items:center;">
<button class="btn" id="theme-toggle" type="button">Light Mode</button>
<button class="btn" id="reset-btn">Load New Report</button>
</div>
</header>
@ -811,6 +845,19 @@
const amContainer = document.getElementById("am-container");
const amToggle = document.getElementById("am-toggle");
const copyAccessMapButton = document.getElementById("copy-access-map");
const themeToggle = document.getElementById("theme-toggle");
const THEME_KEY = "access-map-viewer-theme";
function setTheme(theme) {
const normalized = theme === "light" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", normalized);
if (themeToggle) {
themeToggle.textContent = normalized === "dark" ? "Light Mode" : "Dark Mode";
}
}
setTheme(localStorage.getItem(THEME_KEY));
dropZone.addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", (e) => {
@ -893,6 +940,14 @@
downloadJsonBtn.addEventListener("click", downloadFindingsJson);
downloadCsvBtn.addEventListener("click", downloadFindingsCsv);
copyAccessMapButton.addEventListener("click", copyFilteredAccessMap);
if (themeToggle) {
themeToggle.addEventListener("click", () => {
const current = document.documentElement.getAttribute("data-theme") === "light" ? "light" : "dark";
const next = current === "dark" ? "light" : "dark";
localStorage.setItem(THEME_KEY, next);
setTheme(next);
});
}
loadCliReport();

View file

@ -1,17 +1,17 @@
<#!
.SYNOPSIS
Install or remove Kingfisher Git pre-commit hooks (local or global).
Install or remove Kingfisher Git pre-commit hooks (POSIX-safe).
.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.
Writes POSIX-compliant shell hooks that work on macOS, Linux,
and Windows (Git for Windows / sh).
Safely bootstraps ~/.git/hooks if needed.
.PARAMETER Global
Install into the global Git hooks directory.
.PARAMETER HooksPath
Manually override the hooks directory.
Manually override the hooks directory (repo only).
.PARAMETER Uninstall
Remove the Kingfisher hook and restore a legacy hook if present.
@ -24,135 +24,152 @@ param(
[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 Ensure-GlobalHooksPath {
$existing = git config --global --get core.hooksPath 2>$null
if ($existing) {
return $existing
}
# Git expands ~, PowerShell should not
$gitHooksPath = "~/.git/hooks"
$fsHooksPath = Join-Path $HOME ".git\hooks"
if (-not (Test-Path $fsHooksPath)) {
New-Item -ItemType Directory -Force -Path $fsHooksPath | Out-Null
}
git config --global core.hooksPath $gitHooksPath
Write-Host "Configured global Git hooks at $gitHooksPath"
return $gitHooksPath
}
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"
$resolved = Resolve-Path -LiteralPath $Override -ErrorAction SilentlyContinue
if ($resolved) {
return $resolved.Path
}
return $p
return [IO.Path]::GetFullPath($Override)
}
if ($Global) {
$configured = git config --global --get core.hooksPath 2>$null
if ($configured) {
return $configured
}
return Ensure-GlobalHooksPath
}
# Repo mode
$repoHooks = git rev-parse --git-path hooks 2>$null
if (-not $repoHooks) { throw "Unable to resolve repository hooks path." }
return $repoHooks.Trim()
if ($repoHooks) {
return $repoHooks.Trim()
}
$fallback = Join-Path (Get-Location) ".git\hooks"
Write-Host "Git repository not detected; using fallback hooks path $fallback"
return $fallback
}
function Uninstall-Kingfisher {
param(
[string]$PreCommit,
[string]$Legacy,
[string]$KFHook,
[string]$Marker
)
param($PreCommit, $Legacy, $KFHook, $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 {
} 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
# ------------------------------
# Resolve hooks directory
# ------------------------------
$hooksDir = Resolve-HooksPath -Override $HooksPath -Global:$Global
if (-not (Test-Path $hooksDir)) {
New-Item -ItemType Directory -Force -Path $hooksDir | Out-Null
# Convert ~/.git/hooks to filesystem path if needed
if ($hooksDir -eq "~/.git/hooks") {
$fsHooksDir = Join-Path $HOME ".git\hooks"
} else {
$fsHooksDir = $hooksDir
}
$preCommit = Join-Path $hooksDir "pre-commit"
$legacy = Join-Path $hooksDir "pre-commit.legacy.kingfisher"
$kfHook = Join-Path $hooksDir "kingfisher-pre-commit"
if (-not (Test-Path $fsHooksDir)) {
New-Item -ItemType Directory -Force -Path $fsHooksDir | Out-Null
}
$preCommit = Join-Path $fsHooksDir "pre-commit"
$legacy = Join-Path $fsHooksDir "pre-commit.legacy.kingfisher"
$kfHook = Join-Path $fsHooksDir "kingfisher-pre-commit"
$marker = "# Kingfisher pre-commit wrapper"
# ------------------------------
# Uninstall
# ------------------------------
if ($Uninstall) {
Uninstall-Kingfisher -PreCommit $preCommit -Legacy $legacy -KFHook $kfHook -Marker $marker
Uninstall-Kingfisher $preCommit $legacy $kfHook $marker
return
}
# ---- Kingfisher hook ----
$kfContent = @"
#!/usr/bin/env bash
set -euo pipefail
# ------------------------------
# Kingfisher hook (POSIX sh)
# ------------------------------
$kfContent = @'
#!/usr/bin/env sh
set -eu
if ! command -v kingfisher >/dev/null 2>&1; then
echo "Kingfisher is not on PATH; skipping scan." >&2
exit 0
fi
command -v kingfisher >/dev/null 2>&1 || exit 0
repo_root="\$(git rev-parse --show-toplevel)"
cd "\$repo_root"
repo_root="$(git rev-parse --show-toplevel)"
cd "$repo_root"
kingfisher scan . --staged --quiet --redact --only-valid --no-update-check
"@
kingfisher scan . --staged --quiet --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
Set-Content -Path $kfHook -Value $kfContent -NoNewline -Encoding ASCII
& chmod +x $kfHook 2>$null | Out-Null
# Preserve existing hook ONLY if it exists
# ------------------------------
# Preserve existing hook
# ------------------------------
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
& chmod +x $legacy 2>$null | Out-Null
Write-Host "Existing pre-commit hook preserved at $legacy"
}
}
# Write wrapper
Set-Content -Path $preCommit -Value $wrapper -NoNewline
# ------------------------------
# Wrapper (POSIX-safe)
# ------------------------------
$wrapper = @'
#!/usr/bin/env sh
# Kingfisher pre-commit wrapper
set -eu
hooks_dir="$(git rev-parse --git-path hooks)"
legacy_hook="$hooks_dir/pre-commit.legacy.kingfisher"
kf_hook="$hooks_dir/kingfisher-pre-commit"
if [ -f "$legacy_hook" ] && [ -x "$legacy_hook" ]; then
"$legacy_hook" "$@"
fi
"$kf_hook" "$@"
'@
Set-Content -Path $preCommit -Value $wrapper -NoNewline -Encoding ASCII
& chmod +x $preCommit 2>$null | Out-Null
Write-Host "Kingfisher pre-commit hook installed at $preCommit"

13
scripts/install-kingfisher-pre-commit.sh Normal file → Executable file
View file

@ -58,12 +58,13 @@ if $GLOBAL; then
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)"
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
HOOKS_PATH="$(git rev-parse --git-path hooks)"
else
HOOKS_PATH="$PWD/.git/hooks"
echo "Git repository not detected; using fallback hooks path $HOOKS_PATH"
fi
fi
fi
@ -113,7 +114,7 @@ fi
repo_root="$(git rev-parse --show-toplevel)"
cd "$repo_root"
kingfisher scan . --staged --quiet --redact --only-valid --no-update-check
kingfisher scan . --staged --quiet --no-update-check
EOF
chmod +x "$KF_HOOK"

View file

@ -12,11 +12,11 @@ use crate::util::get_writer_for_file_or_stdout;
#[command(next_help_heading = "Output Options")]
pub struct OutputArgs<Format: ValueEnum + Send + Sync + 'static> {
/// Write output to the specified path (stdout if not given)
#[arg(long, short, value_hint = ValueHint::FilePath)]
#[arg(global = true, long, short, value_hint = ValueHint::FilePath)]
pub output: Option<PathBuf>,
/// Output format (defaults to `pretty` if not specified)
#[arg(long, short, default_value = "pretty")]
#[arg(global = true, long, short, default_value = "pretty")]
pub format: Format,
}

View file

@ -62,7 +62,7 @@ fn default_scan_jobs() -> usize {
#[derive(Args, Debug, Clone)]
pub struct ScanArgs {
/// Number of parallel scanning threads
#[arg(long = "jobs", short = 'j', default_value_t = default_scan_jobs())]
#[arg(global = true, long = "jobs", short = 'j', default_value_t = default_scan_jobs())]
pub num_jobs: usize,
#[command(flatten)]
@ -75,21 +75,22 @@ pub struct ScanArgs {
pub content_filtering_args: ContentFilteringArgs,
/// Minimum confidence level for reporting findings
#[arg(long, short = 'c', default_value = "medium")]
#[arg(global = true, long, short = 'c', default_value = "medium")]
pub confidence: ConfidenceLevel,
/// Disable secret validation
#[arg(long, short = 'n', default_value_t = false)]
#[arg(global = true, long, short = 'n', default_value_t = false)]
pub no_validate: bool,
/// Map validated cloud credentials to their effective identities
#[arg(long, default_value_t = false)]
/// Map validated cloud credentials to their effective identities; use only when
/// authorized for the target account because this triggers additional network
/// requests to determine granted access
#[arg(global = true, long, default_value_t = false)]
pub access_map: bool,
/// Optional path to write a consolidated access-map HTML report
#[arg(long, value_name = "PATH")]
pub access_map_html: Option<PathBuf>,
// /// Optional path to write a consolidated access-map HTML report
// #[arg(long, value_name = "PATH")]
// pub access_map_html: Option<PathBuf>,
/// Display only validated findings
#[arg(long, default_value_t = false)]
pub only_valid: bool,
@ -103,58 +104,63 @@ pub struct ScanArgs {
pub rule_stats: bool,
/// Display every occurrence of a finding
#[arg(long, default_value_t = false)]
#[arg(global = true, long, default_value_t = false)]
pub no_dedup: bool,
/// Redact findings values using a secure hash
#[arg(long, short = 'r', default_value_t = false)]
#[arg(global = true, long, short = 'r', default_value_t = false)]
pub redact: bool,
/// Skip decoding Base64 blobs before scanning
#[arg(long, default_value_t = false)]
#[arg(global = true, long, default_value_t = false)]
pub no_base64: bool,
/// Timeout for Git repository scanning in seconds
#[arg(long, default_value_t = 1800, value_name = "SECONDS")]
#[arg(global = true, long, default_value_t = 1800, value_name = "SECONDS")]
pub git_repo_timeout: u64,
#[command(flatten)]
pub output_args: OutputArgs<ReportOutputFormat>,
/// Baseline file to filter known secrets
#[arg(long, value_name = "FILE")]
#[arg(global = true, long, value_name = "FILE")]
pub baseline_file: Option<std::path::PathBuf>,
/// Create or update the baseline file with current findings
#[arg(long, default_value_t = false)]
#[arg(global = true, long, default_value_t = false)]
pub manage_baseline: bool,
/// Regex patterns to allow-list secret matches (repeatable)
#[arg(long = "skip-regex", value_name = "PATTERN")]
#[arg(global = true, long = "skip-regex", value_name = "PATTERN")]
pub skip_regex: Vec<String>,
/// Skipwords to allow-list secret matches (case-insensitive, repeatable)
#[arg(long = "skip-word", value_name = "WORD")]
#[arg(global = true, long = "skip-word", value_name = "WORD")]
pub skip_word: Vec<String>,
/// AWS account IDs whose findings should skip live credential validation (repeatable)
#[arg(long = "skip-aws-account", value_name = "ACCOUNT_ID", value_delimiter = ',')]
#[arg(
global = true,
long = "skip-aws-account",
value_name = "ACCOUNT_ID",
value_delimiter = ','
)]
pub skip_aws_account: Vec<String>,
/// File containing AWS account IDs to skip (one per line, `#` comments ignored)
#[arg(long = "skip-aws-account-file", value_name = "FILE")]
#[arg(global = true, long = "skip-aws-account-file", value_name = "FILE")]
pub skip_aws_account_file: Option<PathBuf>,
/// Additional inline ignore directives to recognise (repeatable)
#[arg(long = "ignore-comment", value_name = "DIRECTIVE")]
#[arg(global = true, long = "ignore-comment", value_name = "DIRECTIVE")]
pub extra_ignore_comments: Vec<String>,
/// Disable inline ignore directives entirely
#[arg(long = "no-ignore", default_value_t = false)]
#[arg(global = true, long = "no-ignore", default_value_t = false)]
pub no_inline_ignore: bool,
/// Disable rule-level `ignore_if_contains` filtering for pattern requirements
#[arg(long = "no-ignore-if-contains", default_value_t = false)]
#[arg(global = true, long = "no-ignore-if-contains", default_value_t = false)]
pub no_ignore_if_contains: bool,
}
@ -432,10 +438,6 @@ impl ScanCommandArgs {
self.scan_args.no_dedup = true;
}
if self.scan_args.access_map_html.is_some() {
self.scan_args.access_map = true;
}
if self.scan_args.access_map && self.scan_args.no_validate {
bail!("--access-map cannot be used with --no-validate");
}

View file

@ -219,7 +219,11 @@ async fn async_main(args: CommandLineArgs) -> Result<()> {
scan_args.input_specifier_args.path_inputs = vec![stdin_file.into()];
}
let rules_db = Arc::new(load_and_record_rules(&scan_args, &datastore)?);
let rules_db = Arc::new(load_and_record_rules(
&scan_args,
&datastore,
global_args.use_progress(),
)?);
run_scan(
&global_args,
&scan_args,
@ -457,7 +461,6 @@ fn create_default_scan_args() -> cli::commands::scan::ScanArgs {
confidence: ConfidenceLevel::Medium,
no_validate: true,
access_map: false,
access_map_html: None,
rule_stats: false,
only_valid: false,
min_entropy: None,

View file

@ -657,9 +657,8 @@ fn filter_match<'b>(
// --- FIX IS HERE ---
//
// The `validate` function (and thus `{{ MATCH }}`) should *always*
// operate on the *full match* (group 0), not just the entropy bytes.
// This aligns the scan logic with the unit test's logic.
match char_reqs.validate(full_bytes, Some(context), respect_ignore_if_contains) {
// operate on the *match* (group 1), not the full match (group 0).
match char_reqs.validate(entropy_bytes, Some(context), respect_ignore_if_contains) {
//
// --- END FIX ---
PatternValidationResult::Passed => {}

View file

@ -930,7 +930,6 @@ mod tests {
confidence: ConfidenceLevel::Medium,
no_validate: false,
access_map: false,
access_map_html: None,
only_valid: false,
min_entropy: None,
rule_stats: false,

View file

@ -175,7 +175,6 @@ mod tests {
confidence: ConfidenceLevel::Medium,
no_validate: false,
access_map: false,
access_map_html: None,
rule_stats: false,
only_valid: false,
min_entropy: None,

View file

@ -85,7 +85,7 @@ pub async fn run_async_scan(
trace!("Args:\n{global_args:#?}\n{args:#?}");
let progress_enabled = global_args.use_progress();
initialize_environment()?;
initialize_environment(progress_enabled)?;
set_redaction_enabled(args.redact);
@ -636,7 +636,7 @@ pub async fn run_async_scan(
async fn finalize_access_map(
datastore: &Arc<Mutex<FindingsStore>>,
collector: AccessMapCollector,
args: &scan::ScanArgs,
_args: &scan::ScanArgs,
) -> Result<()> {
let requests = collector.into_requests();
@ -654,17 +654,6 @@ async fn finalize_access_map(
ds.set_access_map_results(results.clone());
}
if let Some(html_path) = &args.access_map_html {
access_map::write_reports(&results, html_path)?;
info!("wrote access-map HTML report to {}", html_path.display());
}
// if args.access_map_html.is_none() {
// eprintln!(
// "Tip: rerun with --access-map-html /path/to/report.html for an interactive access-map viewer."
// );
// }
Ok(())
}
@ -728,8 +717,9 @@ fn maybe_hint_access_map(datastore: &Arc<Mutex<FindingsStore>>, args: &scan::Sca
}
}
fn initialize_environment() -> Result<()> {
let init_progress = ProgressBar::new_spinner();
fn initialize_environment(use_progress: bool) -> Result<()> {
let init_progress =
if use_progress { ProgressBar::new_spinner() } else { ProgressBar::hidden() };
init_progress.set_message("Initializing thread pool...");
let num_threads = num_cpus::get();
// Attempt to initialize the global thread pool only if it hasn't been
@ -841,8 +831,10 @@ pub fn spawn_datastore_writer_thread(
pub fn load_and_record_rules(
args: &scan::ScanArgs,
datastore: &Arc<Mutex<findings_store::FindingsStore>>,
use_progress: bool,
) -> Result<RulesDatabase> {
let init_progress = ProgressBar::new_spinner();
let init_progress =
if use_progress { ProgressBar::new_spinner() } else { ProgressBar::hidden() };
// init_progress.set_message("Compiling rules...");
let rules_db = {
let loaded = RuleLoader::from_rule_specifiers(&args.rules)

View file

@ -292,6 +292,27 @@ mod gitlab {
.code(predicates::function::function(|code: &i32| *code == 0 || *code == 1));
}
#[test]
fn scan_gitlab_accepts_global_flags_after_subcommand() {
Command::new(assert_cmd::cargo::cargo_bin!("kingfisher"))
.args([
"scan",
"gitlab",
"--group",
"testgroup",
"--include-subgroups",
"--list-only",
"--quiet",
"--access-map",
"--no-update-check",
"--user-agent-suffix",
"cli-test",
"--verbose",
])
.assert()
.code(predicates::function::function(|code: &i32| *code != 2));
}
#[test]
fn scan_gitlab_with_exclude() {
Command::new(assert_cmd::cargo::cargo_bin!("kingfisher"))

View file

@ -136,7 +136,6 @@ fn run_skiplist(skip_regex: Vec<String>, skip_skipword: Vec<String>) -> Result<u
confidence: ConfidenceLevel::Low,
no_validate: true,
access_map: false,
access_map_html: None,
rule_stats: false,
only_valid: false,
min_entropy: Some(0.0),

View file

@ -135,7 +135,6 @@ fn test_bitbucket_remote_scan() -> Result<()> {
confidence: ConfidenceLevel::Medium,
no_validate: false,
access_map: false,
access_map_html: None,
rule_stats: false,
only_valid: false,
min_entropy: None,
@ -168,7 +167,8 @@ fn test_bitbucket_remote_scan() -> Result<()> {
let datastore = Arc::new(Mutex::new(FindingsStore::new(clone_dir)));
let runtime = Runtime::new()?;
let rules_db = Arc::new(load_and_record_rules(&scan_args, &datastore)?);
let rules_db =
Arc::new(load_and_record_rules(&scan_args, &datastore, global_args.use_progress())?);
let update_status = UpdateStatus::default();
runtime.block_on(async {

View file

@ -155,7 +155,6 @@ rules:
confidence: ConfidenceLevel::Low,
no_validate: true,
access_map: false,
access_map_html: None,
rule_stats: false,
only_valid: false,
min_entropy: Some(0.0),

View file

@ -142,7 +142,6 @@ fn test_github_remote_scan() -> Result<()> {
confidence: ConfidenceLevel::Medium,
no_validate: false,
access_map: false,
access_map_html: None,
rule_stats: false,
only_valid: false,
min_entropy: None,
@ -177,7 +176,8 @@ fn test_github_remote_scan() -> Result<()> {
// Create the runtime first
let runtime = Runtime::new().expect("Failed to create Tokio runtime");
// Load rules
let rules_db = Arc::new(load_and_record_rules(&scan_args, &datastore)?);
let rules_db =
Arc::new(load_and_record_rules(&scan_args, &datastore, global_args.use_progress())?);
let update_status = UpdateStatus::default();
// Run the scan using runtime.block_on
runtime.block_on(async {

View file

@ -141,7 +141,6 @@ fn test_gitlab_remote_scan() -> Result<()> {
confidence: ConfidenceLevel::Medium,
no_validate: false,
access_map: false,
access_map_html: None,
rule_stats: false,
only_valid: false,
min_entropy: None,
@ -174,7 +173,8 @@ fn test_gitlab_remote_scan() -> Result<()> {
let datastore = Arc::new(Mutex::new(FindingsStore::new(clone_dir)));
let rt = Runtime::new()?;
let rules_db = Arc::new(load_and_record_rules(&scan_args, &datastore)?);
let rules_db =
Arc::new(load_and_record_rules(&scan_args, &datastore, global_args.use_progress())?);
let update_status = UpdateStatus::default();
rt.block_on(async {
@ -296,7 +296,6 @@ fn test_gitlab_remote_scan_no_history() -> Result<()> {
confidence: ConfidenceLevel::Medium,
no_validate: false,
access_map: false,
access_map_html: None,
rule_stats: false,
only_valid: false,
min_entropy: None,
@ -330,7 +329,8 @@ fn test_gitlab_remote_scan_no_history() -> Result<()> {
let datastore = Arc::new(Mutex::new(FindingsStore::new(clone_dir)));
let rt = Runtime::new()?;
let rules_db = Arc::new(load_and_record_rules(&scan_args, &datastore)?);
let rules_db =
Arc::new(load_and_record_rules(&scan_args, &datastore, global_args.use_progress())?);
let update_status = UpdateStatus::default();
rt.block_on(async {

View file

@ -118,7 +118,6 @@ async fn test_redact_hashes_finding_values() -> Result<()> {
confidence: ConfidenceLevel::Low,
no_validate: true,
access_map: false,
access_map_html: None,
rule_stats: false,
only_valid: false,
min_entropy: Some(0.0),

View file

@ -127,7 +127,6 @@ impl TestContext {
confidence: ConfidenceLevel::Low,
no_validate: true,
access_map: false,
access_map_html: None,
rule_stats: false,
only_valid: false,
min_entropy: Some(0.0),
@ -270,7 +269,6 @@ async fn test_scan_slack_messages() -> Result<()> {
confidence: ConfidenceLevel::Low,
no_validate: true,
access_map: false,
access_map_html: None,
rule_stats: false,
only_valid: false,
min_entropy: Some(0.0),

View file

@ -198,7 +198,6 @@ async fn test_validation_cache_and_depvars() -> Result<()> {
confidence: ConfidenceLevel::Low,
no_validate: false,
access_map: false,
access_map_html: None,
rule_stats: false,
only_valid: false,
min_entropy: Some(0.0),

View file

@ -141,7 +141,6 @@ impl TestContext {
confidence: ConfidenceLevel::Low,
no_validate: true,
access_map: false,
access_map_html: None,
rule_stats: false,
only_valid: false,
min_entropy: Some(0.0),
@ -273,7 +272,6 @@ impl TestContext {
confidence: ConfidenceLevel::Low,
no_validate: true,
access_map: false,
access_map_html: None,
rule_stats: false,
only_valid: false,
min_entropy: Some(0.0),

View file

@ -65,8 +65,7 @@ fn installs_wrapper_without_existing_hook() {
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!(kf_script.contains("kingfisher scan . --staged --quiet --no-update-check"));
assert!(!legacy.exists());
}
@ -103,8 +102,7 @@ fn preserves_existing_hook_and_runs_it_first() {
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!(lines[1].contains("kingfisher scan . --staged --quiet --no-update-check"));
assert!(hooks_path.join("pre-commit.legacy.kingfisher").exists());
}
@ -171,17 +169,27 @@ fn errors_outside_git_repository() {
#[test]
fn pre_commit_framework_invokes_kingfisher() {
let (_tmp, repo, hooks_path) = init_repo();
// Skip this test if `pre-commit` is not available (e.g., in some CI images).
if StdCommand::new("pre-commit").arg("--version").output().is_err() {
eprintln!(
"skipping pre_commit_framework_invokes_kingfisher: `pre-commit` not found in PATH"
);
return;
}
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();
// Fake kingfisher binary that just logs its argv to hook.log
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();
// Local pre-commit config that uses `kingfisher` as the entry
fs::write(
repo.join(".pre-commit-config.yaml"),
r#"repos:
@ -198,10 +206,12 @@ fn pre_commit_framework_invokes_kingfisher() {
)
.unwrap();
// Something for pre-commit to see as a tracked file
fs::write(repo.join("README.md"), "demo").unwrap();
StdCommand::new("uv")
.args(["run", "--no-config", "--with", "pre-commit", "pre-commit", "run", "--all-files"])
// Run pre-commit directly, with our fake kingfisher at the front of PATH
Command::new("pre-commit")
.args(["run", "--all-files"])
.current_dir(&repo)
.env("PATH", format!("{}:{}", bin_dir.display(), std::env::var("PATH").unwrap()))
.assert()
@ -244,8 +254,7 @@ fn installer_hook_executes_kingfisher_command() {
.success();
let log_contents = fs::read_to_string(&log).unwrap();
assert!(log_contents
.contains("kingfisher scan . --staged --quiet --redact --only-valid --no-update-check"));
assert!(log_contents.contains("kingfisher scan . --staged --quiet --no-update-check"));
}
//