GTM-1590: scan renamed files and first-party vendor/, and state the denominator - #5
Merged
scott-wueschinski-GTMify merged 3 commits intoAug 31, 2026
Conversation
…enominator The pre-commit secret scan passed while reading a fraction of what was staged, and reported that fraction honestly enough that nobody read it. Two causes. Renamed files were never scanned. --diff-filter=ACM excluded R, and git classifies an edited-and-moved file as R once similarity clears the threshold, so the ordinary motion of editing a file and moving it in one commit rode through unscanned. Measured on gtmify-config: ba71968 staged four renames and one modification and reported "1 path(s) checked"; c5925f8 was a git mv plus an edit and reported "0 path(s) checked" from both gates. Reproduced from scratch before this change. The filter is now ACMRT, so renames and typechanges are read and only deletions are excluded, which is correct because a deleted path has no content left to scan. Everything under vendor/ was skipped by name. That is the dependency-directory convention, and in this estate it is false: gtmify/app/vendor is the vendor documentation mirror, twenty first-party trees that sessions actively author, and a worked API example is where a live key gets pasted. vendor/ is now qualified rather than named, skipped only when a package manager actually marked the tree vendored (vendor/modules.txt for Go, vendor/autoload.php for Composer). Neither marker exists anywhere in this estate. The verdict now carries its denominator. "N of M path(s) checked" with every skip enumerated by reason, and a warning when the parts do not add up to the total. A bare "0 path(s) checked" was read past twice in two days; "0 of 1 checked, 1 skipped: 1 deleted" cannot be. Un-skipping vendor/ first exposed the gate to seventeen false-positive lines in gtmify/app/vendor, every one of them documentation. Four narrow, measured refinements take that to one: Stripe's own published sample key as an exact literal, a guard for JSON object ids whose prefix collides with a credential prefix (a Stripe refund id matches the Resend detector), four placeholder terms the documents use to say the value is not real, and a lookahead so that assigning a secret-named variable FROM process.env stops reading as a leak, since that is the correct handling of a secret rather than a leak of one. None of them widens the gate against a value that is actually a credential. tests/secret_scan_gate_test.sh is new, 22 cases. Both causes are pinned as regressions that fail before this change, every false-positive class above is pinned as a MUST PASS so the gate cannot drift into refusing everything, and the legitimate skips are asserted by count rather than by absence of an error. self-test.yml gains a mutation step that restores the ACM filter and requires the suite to go red, plus an audit of this repo by its own secret scan. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The self-test mutated only --diff-filter=ACM, which proves the suite can see cause 1 and says nothing about cause 2. The vendor skip is the half that reaches first-party content, so it gets its own restore-and-go-red step rather than riding on the rename drill. Verified locally: reinstating vendor/ in SKIP_DIRS turns 9 of 22 assertions red. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…suite `mktemp -t ssg_paths` is a BSD spelling. macOS treats the argument as a prefix and appends its own randomness; GNU coreutils treats it as a template and refuses with "too few X's in template". The gate had only ever been run on macOS, so on any Linux host it produced an empty SSG_LIST, handed python an empty path, and died with IsADirectoryError. It failed closed, so nothing was ever let through by it, but a gate that refuses every commit on a platform is a gate that gets uninstalled there. An explicit XXXXXX template works on both, and an empty result is now an explicit error rather than a traceback. Pre-existing, not introduced here. It surfaced because this branch is the first thing to run the secret scan in CI at all: 1 passed, 21 failed on the first run of the new suite on ubuntu-latest. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
scott-wueschinski-GTMify
deleted the
worktree-gtm1590-secret-scan-renames-vendor
branch
August 31, 2026 08:07
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.
Closes GTM-1590.
The pre-commit secret scan passed while reading a fraction of what was staged. It reported that fraction honestly, and the honesty changed nothing: both shortfalls were in plain text and were read past. This is the gate the estate relies on after the config-repo compromise that put 16 credentials into rotation, so a hole in it is a hole in the thing that was supposed to catch that.
Premise re-verified before building
Reproduced from scratch on 2026-08-31, in a throwaway repo, against the unmodified gate. A
git mvplus an edit in one commit, carrying a synthetic AWS-shaped value:And a modification under
vendor/, same result. Then end to end through the real globalpre-commithook, with a GTMify-owned origin so the hook's allowlist applied, which is an exact reproduction of Scott'sc5925f8measurement:The commit landed with the synthetic value in it. The ticket is accurate and was not already fixed.
What changed
Cause 1, renamed files.
--diff-filter=ACMbecomesACMRT. ExcludingDis right, since a deleted path has no content left to scan; excludingRwas wrong, because git classifies an edited-and-moved file asRonce similarity clears the threshold and the content still ships.Tjoins for the same reason: a symlink becoming a regular file is new content arriving.--name-onlyprints a rename's destination and not its source, so the counts stay comparable.Cause 2,
vendor/. Removed from the unconditional skip list and qualified instead: skipped only where a package manager actually marked the tree vendored, meaningvendor/modules.txt(Go) orvendor/autoload.php(Composer). Neither marker exists anywhere in this estate, sogtmify/app/vendoris now scanned, and a repo that genuinely vendors its dependencies still gets the skip without needing to know this gate exists. This is the ticket's second option; I did not take the first, because dropping the name and leaning on.secret-scan-allowwould have meant allowlisting whole documentation files forever.The verdict states its denominator.
N of M path(s) checked, with every skip enumerated by reason, plus a warning if the parts do not add up to the total. Refusals now print coverage too, because knowing the gate found something still says nothing about how much of the commit it read.The part the ticket did not anticipate, and the measurement behind it
Un-skipping
vendor/exposed the gate to twenty trees of vendor documentation, and it fired on 17 lines ingtmify/app/vendor, every one of them documentation. Shipping that unaddressed would have earned this gate a permanent--no-verify, which is a worse outcome than the hole it closes. Four narrow refinements take 17 to 1, each pinned by a test:stripe-docsre_detector"id"field, a place a credential is never legitimately assignedprocess.envand friendsreplace-with-...,asdfasdf,tr_preview_1234567890)PLACEHOLDERlistNone of these widens the gate against a value that is actually a credential. Counts overlap because some lines matched two detectors.
🔴 The one remaining hit needs a one-line follow-up in the APP repo, which I could not make from here.
vendor/triggerdev-docs/pages/self-hosting/docker.md:192carriesTRIGGER_WORKER_TOKEN=tr_wgt_..., which I read in context: it is a verbatim mirror of Trigger.dev's public self-hosting docs showing example console output, not a GTMify credential. I deliberately did not addtr_wgt_to any placeholder list, because that is a real Trigger.dev token shape and a real one could leak. The app repo has no.secret-scan-allowat all today, so once this merges, a commit staging that one file will be refused. Remedy, inGTMify/GTMify:No other app path is affected, and no commit that does not touch that file is affected.
Proof, both directions
A gate that fires on everything is as broken as one that fires on nothing, so both directions are shown.
Refuses what it used to miss. Same staged index, two gates:
Same for
vendor/demo-docs/api.md. End to end through the realpre-commithook,GTMIFY_CI_WORKFLOWSpointed at this branch, the hook refused thegit mvcommit naming the detector.Still passes ordinary work. The same hook, same branch, a clean commit including a
vendor/path:Deliberate-failure cycle on the new suite, 22 cases, each defect reintroduced then restored:
--diff-filter=ACMRTback toACMvendor/back inSKIP_DIRSN path(s) checkedNo test was accepted without being observed to fail. Grepped for
DELIBERATE-FAILURE DRILLafterwards: no matches.shellcheck --severity=warningclean on both files, matching what CI runs.self-test.ymlgains a restore-and-go-red step for each cause, not one for both, plus an audit of this repo by its own secret scan.Two things worth a reviewer's attention
This gate now scans its own source, and that cost something. The installed gate refused my first commit, because
KNOWN_PUBLIC_SAMPLESheld the Stripe literal, and GitHub push protection then refused the push over the same literal in a test fixture. I did not use--no-verifyand did not click the allow-secret URL. Both are written as prefix plus body instead, split exactly at the boundary the detector matches, so the runtime comparison is unchanged and the literal is absent from the tree. The alternative was allowlistingscripts/secret_scan_gate.sh, which would have made the one file where a credential must never hide the one file nobody reads..secret-scan-allowis new in this repo and has exactly one entry,tests/secret_scan_gate_test.sh. A scanner's test suite has to contain strings matching every shape the scanner detects, including a PEM private key header, or it proves nothing. Every value in that file is synthetic.Out of scope, filed here so it is not lost:
junk_file_gate.sh:152has cause 1 too,--diff-filter=AM, which is why the junk gate also printed0 path(s) checkedon the rename commit above. It is a different gate reading names rather than content, so I left it alone rather than widening this PR.Gate
advisor()before the first edit: answered by the primary, cx-consult (Codex), read against the tree. It caught that my end-to-end proof would silently exercise the unchanged gate in the main clone unless I setGTMIFY_CI_WORKFLOWSand used a GTMify-owned origin, which is why the hook evidence above is real rather than accidental.advisor()at completion fell back: the primary was unavailable, and the verdict came from or-consult, x-ai/grok-4.6, which has no file access and saw only a condensed transcript. Its two substantive points are both addressed above and both were worth having: classify the last remaining app-repo hit rather than leaving it counted (done, with the follow-up named), and drill the vendor cause in CI rather than only the rename cause (added as a second commit). A reviewer should know the completion pass reviewed the plan and not the code.Lint baseline: not touched. This repo has none.
🤖 Generated with Claude Code
Added after the first CI run: this gate had never worked on Linux
The new suite's first run on
ubuntu-latestcame back 1 passed, 21 failed, and the cause was pre-existing rather than anything in this change.mktemp -t ssg_pathsis a BSD spelling: macOS treats the argument as a prefix and appends its own randomness, GNU coreutils treats it as a template and refuses withtoo few X's in template.$SSG_LISTwas therefore empty on every Linux host, python was handed an empty path, and the gate died withIsADirectoryError: Is a directory: '.'.It failed closed, so nothing was ever let through by it, and everything on this estate runs on Scott's Mac, which is why it went unseen. But a gate that refuses every commit on a platform is a gate that gets uninstalled there, and this PR is the first thing ever to run the secret scan in CI at all. Fixed with an explicit
XXXXXXtemplate, which both implementations accept, plus an explicit error instead of a traceback if the temp file cannot be made.~/.claude/hooks/git/pre-commit:166,mktemp -t register_staged, in the vendor-register drift check. That file lives ingtmify-configrather than here so I did not touch it. It is guarded byif git show ... >"$staged_blob", so on Linux it warns rather than crashing, but it is the same latent defect and worth a follow-up.CI evidence
Test and lint the gate scriptsis green on8dbfaa0, and the log shows the drills firing rather than just a green tick:Denominators: 22 in the new suite, 35 in the junk-gate suite, 8 in the frontmatter suite, all pre-existing counts unchanged. The audit line correctly reports the value layer as unavailable in CI, where there is no env file, which is the documented behaviour.