Skip to content

feat: pre-commit secrets scanner (v3.5.0)#123

Merged
devlint merged 18 commits into
mainfrom
feat/secrets-scanner
Jul 10, 2026
Merged

feat: pre-commit secrets scanner (v3.5.0)#123
devlint merged 18 commits into
mainfrom
feat/secrets-scanner

Conversation

@devlint

@devlint devlint commented Jul 8, 2026

Copy link
Copy Markdown
Owner

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

  • Staged-diff scanner — 14 built-in patterns (AWS access/secret keys, GCP, Azure, GitHub ghp_/github_pat_, GitLab glpat-, Slack, Stripe, OpenAI, Anthropic, RSA/OpenSSH/EC private keys, JWT) + Shannon-entropy detector (default 4.0 bits/char over ≥20-char tokens)
  • Dual engine, parity-locked — Rust (regex crate) is the desktop production path (scan_secrets Tauri command over git diff --cached --unified=0); a pure browser-safe TS mirror in @gitwand/core backs the dev-server route and the CLI. Catalogs match 1:1 and the parity suite fails on any one-sided drift
  • Non-blocking commit UX — orange badge + finding count in the commit area when the staged diff has findings; findings modal (severity, pattern, file:line, redacted excerpt); askConfirm interception on commit — the user can always commit anyway
  • Default noise floor — built-in generated-file ignore globs (lockfiles, *.min.*, dist/**, node_modules/**, *.map…), user .gitwandrc secrets.ignore[] additive on top; secrets.patterns[] for custom patterns
  • SettingssecretsScannerEnabled (default on) + secretsEntropyThreshold, in both useSettings.ts and SettingsPanel.vue
  • CLIgitwand scan [--json] [--strict]; non-blocking by default, --strict exits 1 on findings
  • Opt-in pre-commit hook — installer in Settings > Hooks; the hook shells out to npx @gitwand/cli scan --strict and blocks (documented git commit --no-verify bypass)
  • i18n — all strings in en / fr / es / pt-BR / zh-CN (key parity enforced by 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-btn specificity nit) → re-verification PASS.

Tests

  • @gitwand/core 1042/1042 · desktop 433/433 · CLI 34/34 · MCP 12/12 (vitest)
  • Rust cargo test --lib 161/161
  • Parity Rust ↔ dev-server 7 suites / 15 tests, incl. the new scan-secrets suite (multi-pattern, ignore-glob, entropy, empty repo, default-globs)
  • vue-tsc --noEmit clean
  • Manual UI smoke test (badge, modal, commit intercept) performed on a staged fixture file

Note: the branch required GitHub push-protection allowlisting for three obviously-fake test fixtures (AKIAABCDEFGHIJKLMNOP et al.) — the scanner's own test corpus trips GitHub's scanner, as it should.

Laurent Guitton 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.
@devlint devlint requested review from t1gu1 and removed request for t1gu1 July 9, 2026 10:09
@devlint devlint merged commit 30ac0ad into main Jul 10, 2026
4 checks passed
@devlint devlint deleted the feat/secrets-scanner branch July 10, 2026 14:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant