Skip to content

test: cover the behaviour that was shipping unverified - #90

Merged
pixincreate merged 14 commits into
masterfrom
test/ci-hardening
Sep 2, 2026
Merged

test: cover the behaviour that was shipping unverified#90
pixincreate merged 14 commits into
masterfrom
test/ci-hardening

Conversation

@pixincreate

Copy link
Copy Markdown
Owner

Stacked on #89 — top of the stack.

Summary

Closes the coverage gaps where a regression would have shipped green, and makes CI run KeyWatch against itself.

Changes

  • git_available() silently skipped 12 tests — every --staged and baseline integration test — if git were missing. Git is a hard dependency of those modes, so it now fails loudly instead of reporting green while covering nothing.
  • The staged git-config hardening had no test, despite being the fix for a real bypass. Deleting any of the five overrides made the scan report clean or attribute findings to a mangled path, and nothing failed. One test now sets color.ui=always, diff.mnemonicPrefix, diff.noprefix, core.quotePath and diff.relative simultaneously and asserts the path and line survive.
  • Newly covered: the scanner exits 2 outside a repository (what makes the hook fail closed); path operands actually narrow the staged diff; a config at the repo root is discovered (depends on candidate lookup preceding the .git stop check — both prior tests passed either way); and the prefilter selects both owners of overlapping keywords, without which Stripe detection silently stops running behind Adyen's shorter sk_.
  • CI never ran KeyWatch on KeyWatch, so a committed secret or a stale baseline would ship green. New self-scan job scans the repo, fails if the committed baseline has drifted, and checks the framework hook entry still uses scan --staged.

Tests

192 pass (was 177 at the base of this stack), clippy and fmt clean.

pixincreate added a commit that referenced this pull request Sep 1, 2026
git_available() grew into an assertion in #90 but still returned a bool,
leaving 23 'if !git_available() { return Ok(()) }' guards as dead code
that read like the tests could still skip. require_git() replaces it:
same loud failure, no dead branches, and the git-backed tests say so.
@pixincreate
pixincreate marked this pull request as ready for review September 1, 2026 17:57
Base automatically changed from chore/cleanup to master September 2, 2026 16:48
git_available() turned twelve tests into silent no-ops if git were
missing, including every --staged and baseline integration test; git is
a hard dependency of those modes, so it now fails loudly.

The staged scan's git-config hardening had no test at all, despite being
the fix for a bypass: deleting any of the overrides made the scan report
clean or attribute findings to a mangled path, and nothing failed. One
test now sets all five hostile settings at once and asserts the path and
line survive.

Also newly covered: the scanner exits 2 outside a repository, which is
what makes the hook fail closed; path operands actually narrow the
staged diff; a config at the repository root is discovered, which
depends on candidate lookup preceding the .git stop check; and the
prefilter selects both owners of overlapping keywords, without which
Stripe detection would silently stop running behind Adyen's shorter
keyword.

CI never ran KeyWatch against itself, so a committed secret or a stale
baseline would ship green.
…ust cwd

The trusted-mode guard compared the env-supplied detector config against
the process's current directory, so 'key-watch scan --no-config-discovery
<repo>' run from anywhere else still honored a detector set that the
scanned repository had planted inside itself. Trusted initialisation now
receives the untrusted roots: the enclosing repository root for git-backed
modes, each scan target's enclosing repository root for file scans, and
always the current directory.

Discovery also refuses a world-writable config file, not only one sitting
in a world-writable directory: a 0666 file can be rewritten in place even
inside a 0755 directory. The same refusal now covers discovered baselines,
which a world-writable attacker could pad with suppression entries.
Staged and history diffs emit repository-root-relative paths no matter
where the process runs, but baseline self-exclusion canonicalized them
against the current directory. Running 'scan --staged' from a
subdirectory therefore re-scanned the staged baseline file and flagged
its own stored hashes — the exact failure the self-exclusion exists to
prevent. Every mode now anchors resolution at the scanned tree's root.
…y entries

--prune-baseline rebuilt the baseline from whatever the scan found, so a
staged or path-narrowed scan deleted the entries for everything it never
scanned, and a bare --prune-baseline was silently ignored. It now
requires --update-baseline, conflicts with the partial-scan modes,
prints how many entries it dropped, and warns when explicit paths narrow
the scan.
Redaction was controlled by a process-global atomic that serialization
consulted invisibly: the flag appeared in no signature, and any future
test that set it would leak into every other report test running in
parallel. create_report now takes show_secrets and redacts eagerly.

Redaction also stops showing a prefix for matches shorter than eight
characters, where four visible characters would reveal most or all of
the secret.
The 0600 mode only applied at file creation, so re-running into an
existing world-readable report kept it world-readable while writing
potentially secret-bearing content into it. Permissions are now forced
on the open file handle before the write.
Files git renders as binary were never seen by the scanner, but history
mode listed them under excluded_files and staged mode listed unopenable
blobs there too — reading like operator-requested skips. After the
count-plus-sample change those entries are invisible, so the gap the
report describes no longer matched reality. A new unscannable field
carries what could not be read; excluded keeps what was deliberately
skipped.
A hunk header the parser could not read silently attributed its findings
to line 0; the fallback stays (scanning beats skipping) but now says so
on stderr. Quoted diff paths — git still C-quotes names containing
quotes or control characters even with core.quotePath=false — are
unescaped before attribution, including inside binary markers where a
quoted name containing ' and ' previously split the pair.

Findings are also sorted exactly once per mode: the staged parser sorted
its own output and the staged branch sorted again after appending blob
findings, and history relied on the parser's sort.
Lockfile checksums are the single largest source of noise in reports and
baselines — 91 of this repository's 231 baseline entries were Cargo.lock
checksums. They hold checksums and resolved URLs, never credentials, so
every filesystem-backed mode now skips them by basename, matching
gitleaks.

The committed baseline is regenerated without them; this also refreshes
line numbers that had drifted (the self-scan job moved the ci.yml
finding), which the CI drift check would otherwise have flagged.
…ccept gate

The credit-card pattern covered 6011 but not Discover's other prefixes,
so a 65-prefixed card passed Luhn and went unreported — a gap in a
detector set whose point is accuracy.

The allowlist/entropy/validator chain existed three times: in both
scanner loops and again in the detector tests' reported_by helper, free
to drift apart the next time a gate is added. Detector::accepts_match is
now that chain, used everywhere.
The broken-pipe fix made hook commands swallow every stdout error with
'let _ =', while the scan path propagates real I/O failures — two
policies over the same helper. emit_line already returns Ok for a closed
pipe, so propagating the rest loses nothing and hook install/uninstall
now surface disk-level failures. read_global_hooks_path also drops its
guard-arm match for a plain if/else.
Every ScanArgs field addition cost 32 mechanical one-line edits across
the test suites (show_secrets and prune_baseline each did exactly that).
The 32 hand-written literals now name only the fields under test and
fill the rest from Default.
git_available() grew into an assertion in #90 but still returned a bool,
leaving 23 'if !git_available() { return Ok(()) }' guards as dead code
that read like the tests could still skip. require_git() replaces it:
same loud failure, no dead branches, and the git-backed tests say so.
The credit-card detector's new 644-649/65 prefixes match two test
fixtures added with them; the committed baseline predates that change
and the self-scan job (correctly) refused to pass with un-baselined
findings.
@pixincreate
pixincreate merged commit 360658c into master Sep 2, 2026
7 checks passed
@pixincreate
pixincreate deleted the test/ci-hardening branch September 2, 2026 17:44
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