feat: pre-commit secrets scanner (v3.5.0)#123
Merged
Conversation
added 18 commits
July 8, 2026 20:03
14-task, 9-phase test-first plan for the pre-commit secrets scanner (ROADMAP v3.5.0). D1-D5 open decisions resolved at the human checkpoint: dual Rust+TS scanner (parity-locked), hook via npx @gitwand/cli scan, on-by-default non-blocking in-app + opt-in blocking hook, duplicated pattern catalogs, entropy threshold 4.0.
Adds the `secrets` config surface (enabled, patterns[], ignore[], entropyThreshold) to GitWandrcConfig/parseGitwandrc, tolerant-validated like the existing llmFallback/refactoringAware blocks. Part of the v3.5.0 pre-commit secrets scanner (Task 1/14).
Adds SecretPattern/ScanFileInput/SecretsScanConfig/SecretFinding types and the built-in detector catalog (AWS, GCP, Azure, GitHub, GitLab, Slack, Stripe, OpenAI, Anthropic, private keys, JWT). Regexes are restricted to the Rust+JS common subset (no lookaround/backrefs) so they can be mirrored 1:1 by the Rust scanner (Task 4). Part of the v3.5.0 pre-commit secrets scanner (Task 2/14).
Adds scanSecrets(files, config) over built-in + .gitwandrc extraPatterns, a Shannon-entropy pass for high-entropy tokens (skips tokens already covered by a regex hit on the same line), glob/value-regex ignore support, and middle-masking redaction so no raw secret value is ever returned. Re-exports the scanner from packages/core index.ts. Also fixes an implicit-any tsc error in config.ts surfaced by the build check (parseGitwandrc secrets.patterns mapper). Part of the v3.5.0 pre-commit secrets scanner (Task 3/14).
Adds the pure scan_lines(files, config) engine in commands/secrets.rs, mirroring packages/core/src/secrets/scanner.ts + patterns.ts exactly (same 14-id built-in catalog, entropy math, redaction, and glob/value-regex ignore semantics). Uses the `regex` crate (linear-time, no catastrophic backtracking) so user-supplied .gitwandrc patterns can't hang the app; malformed user regexes are skipped, never fatal. Adds SecretFinding/SecretPatternInput/ SecretsScanConfig serde structs to types.rs. scan_lines is kept separate from the (not-yet-added) async Tauri command so it is unit-testable without a Tokio runtime. Part of the v3.5.0 pre-commit secrets scanner (Task 4/14).
Adds extract_staged_added_lines() (a git diff --cached --unified=0 state-machine parser that keeps only `+`-prefixed content lines, tracking file/line via the +++ / @@ headers per the AGENTS.md diff gotcha) and scan_staged(cwd, config), the sync core wrapped by the new async #[tauri::command] scan_secrets. Registered in lib.rs generate_handler![]. Real-temp-repo integration tests cover: a secret on a newly staged added line, nothing staged, and a pre-existing committed secret NOT being reported (added-lines-only). Part of the v3.5.0 pre-commit secrets scanner (Task 5/14).
Adds the typed scanSecrets(cwd, config) wrapper in backend.ts (Tauri invoke + dev-server POST fallback, SecretFinding/ SecretsScanConfig types re-exported from @gitwand/core to avoid drift), and a real POST /api/scan-secrets route in dev-server.mjs that extracts staged added lines with the same header/hunk state machine as the Rust command, then scans them with the pure @gitwand/core engine — not a stub. Part of the v3.5.0 pre-commit secrets scanner (Task 6/14).
Adds a "scan-secrets" arm to parity_probe.rs (via a new scan_secrets_parity(cwd, config: Value) wrapper in lib.rs, which deserializes the pub(crate) SecretsScanConfig internally so the probe binary never needs to name a crate-private type), a stable (file, line, patternId) sort in normalize.mjs for the finding-set comparison, and tests/parity/scan-secrets.test.mjs covering a multi-pattern file, an ignore-glob case, an entropy case, and a no-staged-changes case. All 4 cases pass — Rust and the TS engine agree byte-for-byte on every fixture. Part of the v3.5.0 pre-commit secrets scanner (Task 7/14).
…nel) Adds secretsScannerEnabled (default true) and secretsEntropyThreshold (default 4.0) to both AppSettings (useSettings.ts) and the local Settings interface (SettingsPanel.vue), plus a toggle + numeric input in the Git tab. Full structural i18n typing means all 5 locales must stay in sync at every compile (not just at the Task 14 wrap-up), so this commit adds the 4 new settings.secrets* keys with real translations (not TODO placeholders) to fr/es/pt-BR/zh-CN alongside en. Part of the v3.5.0 pre-commit secrets scanner (Task 8/14).
Adds the business-logic composable behind the commit-area badge and findings modal: findings/scanning state, activeFindings (minus session dismissals), a debounced scan(cwd, settings) trigger wired to staged-set changes (no setInterval), and ignorePattern(cwd, patternId) which appends every currently-affected file path to .gitwandrc secrets.ignore[] via writeGitwandrc, then clears those findings locally. Deviation from the plan's literal wording: ignorePattern suppresses by file-path glob rather than "the pattern (or a value-regex for high_entropy)" — the .gitwandrc secrets.ignore schema only supports path-globs and value-regexes (no patternId-based ignore), and SecretFinding intentionally never carries the raw matched value (Task 3's redaction contract), so a value-regex can't be reconstructed from a redacted excerpt without either leaking partial secret material or being unreliable. File-level suppression is the safe, structurally supported option for both regular and high_entropy findings. Also fixed: the resolved-.gitwandrc cache is now scoped per composable instance (was a module-level singleton) so concurrent/sequential useSecretsScanner() instances — and tests — don't leak stale config across each other. Part of the v3.5.0 pre-commit secrets scanner (Task 9/14).
Adds SecretsFindingsModal.vue (BaseModal-based: severity chip, pattern id, file:line, redacted excerpt — never the raw value — per-finding Dismiss and per-pattern Ignore, footer Commit anyway/Cancel; .bm-btn kept at (0,1,0) specificity, rounded-square buttons only). Adds an orange (--color-warning) badge to RepoSidebar's commit-actions area, shown when secretFindingsCount > 0, emitting openSecrets on click. Adds the top-level `secrets` i18n group (badge tooltip, modal copy, severity labels, confirm text) to all 5 locales. Part of the v3.5.0 pre-commit secrets scanner (Task 10/14).
Instantiates useSecretsScanner in App.vue: secretFindingsCount feeds RepoSidebar's badge, a watch on [repoFolderPath, repoStats.staged] (no setInterval) triggers the debounced scan() on staged-set changes and repo switches, and the RepoSidebar commit listener is wrapped in handleCommitRequest() so an active finding first shows a non-blocking askConfirm (danger style, "Commit anyway" label) before delegating to doCommit — the user can always proceed. The findings modal (opened via the badge's openSecrets event) wires Dismiss/Ignore to the composable and its own "Commit anyway" footer action to the same doCommit path, reusing the last-attempted commit trailers. Part of the v3.5.0 pre-commit secrets scanner (Task 11/14). Not yet done: the plan's "manual smoke in pnpm dev:web" verification step (stage a fake token, click through the badge/modal/confirm in a running app) was not performed in this session — vue-tsc and the full vitest suite are green, but an interactive click-through is left for the verifier.
Adds `gitwand scan [--json] [--strict]`: extracts git diff --cached
--unified=0 with the same header/hunk state machine as the Rust
command and dev-server route, resolves .gitwandrc secrets via
@gitwand/core's parseGitwandrc, and scans with the same pure
scanSecrets() engine. Non-blocking by default (exit 0); --strict sets
exit code 1 when findings exist — this is what the opt-in pre-commit
hook (Task 13) will use to actually block a commit. --json prints a
machine-readable { findings: SecretFinding[] } payload. Redacted
excerpts only — stdout never contains a raw secret value (verified by
test). Real-temp-repo tests cover a staged finding, --json shape,
--strict exit-code semantics, and the no-staged-changes case. Exports
findGitRoot() from llm-config.ts for reuse. Part of the v3.5.0
pre-commit secrets scanner (Task 12/14).
… Hooks Adds a dedicated "Secrets pre-commit hook" section in HooksPanel.vue, separate from the generic hook creator: an Installed/Not installed badge, a one-click Install (behind an askConfirm explaining the npx @gitwand/cli scan shell-out and the --no-verify bypass) and Remove. The generated script is self-identifying via a marker comment (buildSecretsHookScript/isSecretsHookScript in utils/secretsHook.ts, unit-tested); install state is detected by reading .git/hooks/pre-commit and checking for that marker, not a separate persisted flag. Deviation from the plan's literal wording: detection reads the full hook file via the existing readFile(cwd, ".git/hooks/pre-commit") command rather than HookEntry.preview from gitHookList — preview is only the first non-empty line (the shebang), so it can never contain our marker comment, which must live on line 2 for the shebang to stay executable. i18n: hooks.secrets* keys added to all 5 locales with real translations. Part of the v3.5.0 pre-commit secrets scanner (Task 13/14).
Adds the v3.5.0 pre-commit secrets scanner summary under CHANGELOG.md [Unreleased] (root changelog only — versioned section, website changelog, and roadmap move are deferred to /release time per plan). i18n was synced incrementally per-task rather than in one batch here, since the locale files are structurally type-checked against en.ts on every build (vue-tsc fails immediately if any of fr/es/pt-BR/zh-CN drifts) — Tasks 8, 10, and 13 each added their keys with real translations in the same commit as the feature. Full green gate: pnpm -r run test (core 1028, cli 33, desktop 432, mcp 12, all passing), apps/desktop pnpm test:parity (14/14), cargo test --lib (157/157), cargo build clean. Grepped every suite's output for the planted test secrets — zero hits, confirming no raw value ever surfaces in logs, findings, or CLI stdout. Part of the v3.5.0 pre-commit secrets scanner (Task 14/14) — feature complete.
D5 promised "generated-file globs excluded" but no default ignore list existed anywhere — secrets.ignore was 100% user-supplied, so a routine pnpm install lockfile bump (integrity: sha512-...) tripped a high_entropy finding on nearly every real commit. Adds DEFAULT_IGNORE_GLOBS (*.lock, package-lock.json, pnpm-lock.yaml, yarn.lock, Cargo.lock, *.min.js, *.min.css, dist/**, build/**, node_modules/**, *.map), applied inside the pure scanSecrets()/ scan_lines() engines themselves (TS and Rust respectively) so every caller — dev-server route, CLI's loadSecretsConfig, and whatever useSecretsScanner.ts passes — inherits them automatically without duplicating the list at each call site. User .gitwandrc secrets.ignore[] entries stay additive on top, never replaced. Extends the scan-secrets parity test with a case that stages the same high-entropy content in both a pnpm-lock.yaml and a plain source file in one repo: if either engine's default list drifts from the other, the two finding sets stop matching and the test fails. Tests: packages/core scanner.test.ts (13 new cases, 37/37 passing), apps/desktop-src-tauri commands::secrets::tests (5 new cases, 21/21 passing), packages/cli scan.test.ts (1 new case, 8/8 passing). Full suites green: core 1042, cli 33 (via full run)/41 (this file), desktop 432, cargo test --lib 161/161, test:parity 15/15.
ignorePattern() writes literal file paths into .gitwandrc secrets.ignore[], so clicking "Ignore" on one low-severity finding silently suppressed EVERY pattern in that file, permanently, with no warning. The mechanism is unchanged (the schema deviation from Task 9 is accepted), but the UX now tells the truth before it happens: - Add filesForPattern(patternId) to useSecretsScanner.ts, exposing the same file list ignorePattern() already computes internally, so callers can state the real scope before acting. ignorePattern() itself is refactored to reuse it (no behavior change, no duplicate filter logic). - App.vue's onSecretsIgnore now gates the write behind askConfirm (never native confirm()) with a message naming the affected file(s) and stating the suppression is file-wide and permanent, mirroring the HooksPanel install/remove confirm pattern. No-ops (no confirm shown) when no known finding carries that pattern, same as ignorePattern()'s own guard. - Relabeled the button/action from "Ignore" to "Ignore file" and added secrets.ignoreConfirmTitle / secrets.ignoreConfirmMessage, with real translations in all 5 locales (en/fr/es/pt-BR/zh-CN). vue-tsc clean, proving locale key parity. Tests: useSecretsScanner.test.ts +1 case (filesForPattern), full desktop suite 433/433 passing (was 432), vue-tsc clean.
.sfm-item__actions .bm-btn { ... } was the exact selector shape
AGENTS.md forbids — it raises specificity above .bm-btn--ghost /
.bm-btn--danger and can make those modifiers silently lose. Moves the
padding/font-size to a dedicated .sfm-btn-compact class applied
alongside bm-btn on the per-finding Dismiss/Ignore buttons (the
footer buttons were never affected by the old ancestor rule and are
unchanged).
Tests: SecretsFindingsModal.test.ts 6/6, full desktop suite 433/433,
vue-tsc clean.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
v3.5.0 — Safety Bundle: pre-commit secrets scanner
Implements the ROADMAP v3.5.0 item: a fully local secrets scanner over the staged diff — zero network, non-blocking UX, extensible via
.gitwandrc, with an opt-in blocking pre-commit hook.What ships
ghp_/github_pat_, GitLabglpat-, Slack, Stripe, OpenAI, Anthropic, RSA/OpenSSH/EC private keys, JWT) + Shannon-entropy detector (default 4.0 bits/char over ≥20-char tokens)regexcrate) is the desktop production path (scan_secretsTauri command overgit diff --cached --unified=0); a pure browser-safe TS mirror in@gitwand/corebacks the dev-server route and the CLI. Catalogs match 1:1 and the parity suite fails on any one-sided driftaskConfirminterception on commit — the user can always commit anyway*.min.*,dist/**,node_modules/**,*.map…), user.gitwandrcsecrets.ignore[]additive on top;secrets.patterns[]for custom patternssecretsScannerEnabled(default on) +secretsEntropyThreshold, in bothuseSettings.tsandSettingsPanel.vuegitwand scan [--json] [--strict]; non-blocking by default,--strictexits 1 on findingsnpx @gitwand/cli scan --strictand blocks (documentedgit commit --no-verifybypass)vue-tsc)Security invariants
Findings never carry raw secret values — redacted excerpts only (≤3+…+≤3 chars), asserted by tests in both engines and the CLI. No Node.js modules added to
packages/core. Git invocations are argument arrays only.Process
Plan (committed in
docs/superpowers/plans/2026-07-08-v3.5.0-secrets-scanner.md, decisions D1–D5 resolved at the human checkpoint) → 14 test-first tasks → independent adversarial verification → 3-finding fix pass (default ignore globs; honest file-wide-scope confirm on "Ignore";.bm-btnspecificity nit) → re-verification PASS.Tests
@gitwand/core1042/1042 · desktop 433/433 · CLI 34/34 · MCP 12/12 (vitest)cargo test --lib161/161scan-secretssuite (multi-pattern, ignore-glob, entropy, empty repo, default-globs)vue-tsc --noEmitcleanNote: the branch required GitHub push-protection allowlisting for three obviously-fake test fixtures (
AKIAABCDEFGHIJKLMNOPet al.) — the scanner's own test corpus trips GitHub's scanner, as it should.