Skip to content

fix(ci-security): #166 — pin the TruffleHog scanner by image digest - #402

Merged
KbWen merged 3 commits into
mainfrom
fix/166-trufflehog-scanner-pin
Aug 12, 2026
Merged

fix(ci-security): #166 — pin the TruffleHog scanner by image digest#402
KbWen merged 3 commits into
mainfrom
fix/166-trufflehog-scanner-pin

Conversation

@KbWen

@KbWen KbWen commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Closes backlog #166 (P1) and files #171. The TruffleHog SHA pin bound the wrapper action, not the binary that scans.

The defect

security.yml pinned trufflesecurity/trufflehog@<40-hex> and tests/ci/test_security_workflow.py machine-enforced that pin on AC-5's stated grounds — "semver tags are mutable and do not provide supply-chain immutability". But the step passed only extra_args, and the wrapper's action.yml declares version: default: "latest", threaded into docker run "${IMAGE}:${VERSION}".

The enforced control did not bind the artifact it was written to protect. Evidenced on main, not inferred — run 31288803917 at 44b2e33 (pre-bump) loaded the old action 00155c9d… and logged version: latestDigest: sha256:aa821cf4…"trufflehog_version": "3.96.0". main was executing a 3.96.0 scanner under a pin that read v3.95.8.

The fix: pinned by digest, not by tag

image: ghcr.io/trufflesecurity/trufflehog@sha256
version: "aa821cf4ace8861c7d096d83818cdf7bb9719028a52d37a52eaad44086a52577"

The wrapper composes "${IMAGE}:${VERSION}", so this split yields a content-addressed reference. Confirmed in this PR's own CI, not just asserted:

Status: Downloaded newer image for
  ghcr.io/trufflesecurity/trufflehog@sha256:aa821cf4ace8861c…
"trufflehog_version": "3.96.0"

test_ac5_trufflehog_scanner_pinned_by_digest reproduces that join and requires a digest form — immutability by construction, not agreement between two editable strings. Proven red by reverting to a tag reference. If upstream ever changes the join, the composed string stops being a valid reference and docker fails loudly rather than silently reverting to a tag.

An earlier design in this PR pinned an exact release tag (version: "3.96.0") guarded by a test asserting equality with the # vX.Y.Z comment. Withdrawn before merge — see below.

AC-3 was false too, and the root cause was upstream of it

AC-3 claimed a "full-history scan". The wrapper runs --since-commit <base> --branch <head> — push/PR range only. fetch-depth: 0 exists so the base commit resolves; it does not widen the scan, and test_ac3_checkout_full_depth only ever asserted the fetch depth.

Traced to its source rather than patched at the symptom: a Domain Decision whose rationale claimed the scan "catches pre-existing leaks introduced before the current PR". False when written, and it generated AC-3.

The false text is removed from the live spec, not struck through. Review argued that struck-through text still reads as live text to grep and to any agent loading the file — decisive in a repo whose primary consumer is an agent. Superseded wording now lives only in the new L2 log as [SUPERSEDED] entries: L1 carries current truth, L2 carries history. Two further contradictions the first pass missed are fixed the same way — a Non-goal that listed the PR-delta scan as out of scope when it is the shipped behaviour, and two claims that --only-verified bounds false positives.

Consequence now stated plainly: a credential introduced before the scanned range is not re-detected by CI.

The scanner blocked this PR — now backlog #171

The first push failed Secret Detection. Not because the pin was wrong, but because the scanner reported a verified Lob credential in the diff: the name of the test written to enforce the pin, which landed on exactly the 35 characters Lob's key pattern requires. The PR pinning the secret scanner was blocked by the secret scanner, on the name of the test enforcing the pin.

Three facts came out of it, all in #171:

  1. --only-verified did not bound the false positive, though AC-3 states that is its purpose.
  2. The scan walks each commit's diff across the range, not the net endpoint diff. Established by experiment: a follow-up rename left CI red though the three-dot diff was clean; only removing the introducing commit went green. That is why this branch is squashed. Commit messages are in scope too.
  3. 35 identifiers of the same shape already exist in the tree, latent because the scan is range-scoped. [Bug] Validator mis-flags ship-history archive as a Work Log missing Phase Summary #171 does not list them — quoting one would re-create the pattern in its own diff.

--exclude-detectors=lob is #171's preferred remedy and is deliberately not done here: weakening a detector does not belong in a PR whose subject is strengthening this scanner.

Reclassified quick-win → hotfix

state_machine.md:51 hard-blocks quick-win above 200 diff lines or 2 modules. This unit is 276 lines across 7 files and four modules, and its own Phase Summary said "three modules" while keeping the tier — which is the tell. Keeping quick-win is what let the review gate be skipped.

Reclassified through the documented mechanism: a structured Reclassification: record in the Drift Log plus a re-entered bootstrap receipt, which is what makes the second epoch legal (validate.sh:1541). Hotfix demands the REVIEWED + TESTED gates that were missing, without a handoff artifact that adds nothing to a bounded fix against an existing spec. The retroactive sequencing is recorded, not presented as a clean run.

Review

An independent review at 982ce7b returned NOT READY with eight findings — three P1. All eight adopted, none overruled. Two changed the shipped design; one is the governance violation above. The two that mattered most were established by experiment rather than argument:

  • a mutation swapping the wrapper SHA for the previous release's, leaving comment and input untouched, kept all 42 tests green — proving the comment-equality guard was not provenance;
  • a detached clone at the reviewed head produced validate.ps1 pass=100 warn=3 fail=0 skip=3, showing the recorded totals are machine-local. They are now labelled as such, with CI named as the replayable evidence.

Also corrected: the freshness tradeoff was mis-framed as "the trade AC-5 already claimed to have made" — withdrawn, since the deployed runtime resolved latest and was not paying it; it is newly incurred and now an ## Accepted Risks entry with owner, cadence and an emergency path. source_sha in the L2 log pointed at a commit where the file does not exist. #171 said 36 identifiers where the tree holds 35, with two mutually exclusive "preferred" remedies.

Scope

.github/workflows/security.yml · tests/ci/test_security_workflow.py · docs/specs/ci-security-scanning.md · docs/architecture/ci-security.log.md (new, AC-15 consolidation, owner-confirmed per ship.md:83 after verifying no lifecycle-frontmatter, token-ceiling or deploy-manifest impact) · docs/specs/_product-backlog.md

Evidence

  • CI green at head, including Secret Detection (TruffleHog), with the digest pull in the log.
  • 57 passed across the affected suites; digest assertion proven red by reverting to a tag.
  • validate.sh and validate.ps1 both pass=118 warn=4 fail=0 skip=2 on this machine — not reproducible from a clean checkout, by design of the active-Work-Log checks; CI is the replayable evidence.

Rollback: revert this PR. The pin returns to wrapper-only and the spec's corrected claims revert to their false forms, so a revert should be paired with re-filing #166.

🤖 Generated with Claude Code

…he wrapper

The AC-5 SHA pin bound the wrapper action, not the binary that scans. The
composite step runs `docker run <image>:${VERSION}` and the wrapper's `version`
input defaults to `latest`, so a SHA-pinned step still pulled a mutable image.
Evidenced on main before the last bump: run 31288803917 at 44b2e33 loaded the
old action 00155c9d and executed scanner 3.96.0 while the pin read v3.95.8.

* security.yml sets `version: "3.96.0"` on the TruffleHog step, with a comment
  stating what the SHA does and does not bind.

* Two tests. One asserts the pin exists and is an exact X.Y.Z. The other
  asserts it equals the `# vX.Y.Z` comment on the `uses:` line -- the drift
  guard. Dependabot bumps the SHA and the comment but has no mechanism to bump
  a `with:` input, so without that assertion the naive fix silently unpins on
  the next bump. Red/green proven with discrimination: removing the pin fails
  2 tests; drifting the version while leaving the comment fails only the
  equality guard.

* AC-3 corrected. It claimed a "full-history scan"; the wrapper runs
  `--since-commit <base> --branch <head>`, so it scans the push/PR range only.
  `fetch-depth: 0` stays -- the base commit must be resolvable -- and its test
  stays, now carrying a comment saying what it does and does not certify.

* The false claim was traced to its source rather than patched at the symptom:
  a Domain Decision whose rationale asserted the scan "catches pre-existing
  leaks introduced before the current PR". Struck through and corrected in
  situ, kept visible, and labelled as a live instance of the
  [spec-factual-claims] Global Lesson -- an unverified tool-behaviour claim in
  a rationale, which propagated into an AC and survived every later review
  because reviewers check AC compliance, not rationale accuracy.

* AC-15 consolidation created docs/architecture/ci-security.log.md, the first
  entry in that domain. Owner-confirmed per ship.md:83, after verifying no
  side effects: `.log.md` is excluded from the lifecycle-frontmatter check
  (14 PASS unchanged), `analyze_token_lifecycle.py` does not count
  docs/architecture at all, and test_deploy_tiering.py:604 documents the
  directory as capability-by-presence rather than a fixed set. AC-17 advisory
  lock checked first (none held).

Tradeoff recorded, not hidden: the scanner no longer picks up new detectors
between bumps. Detector freshness is traded for supply-chain immutability --
the trade AC-5 already claimed to have made. Image-digest pinning was rejected
because it decouples from the comment Dependabot maintains, making drift harder
to notice rather than easier.

Evidence: validate.sh and validate.ps1 both pass=118 warn=4 fail=0 skip=2,
exact parity. Self-archival cleared the shipped-log-in-work WARN (5 -> 4),
verified by delta. 55 passed across the two files that assert on security.yml;
scope chosen by grep -- test_deploy_tiering.py mentions it only in a comment,
so the 37-test slow module was deliberately not run locally. Backlog
validation 3 passed at 105 rows. Chain intact.

Squashed to a single commit. The first push gave the pin-enforcing test a
name of the form `test_` plus exactly 35 word characters, which matches Lob's key pattern `�((live|test)_[a-zA-Z0-9_]{35})�`
verbatim. TruffleHog's Lob verifier returned VERIFIED and failed the Secret
Detection job, so the PR pinning the secret scanner was blocked by the secret
scanner, on the name of the test that enforces the pin. Renaming in a
follow-up commit did NOT clear it: the run proved empirically that TruffleHog
walks each commit's diff across the range rather than the net endpoint diff,
so the introducing commit stayed in scope. History squashed so the string was
never committed on this branch; the net change is byte-identical either way.

Recorded, not silently absorbed: `--only-verified` is stated in AC-3 to bound
false positives and did not bound this one, and 36 identifiers of the same
shape already exist in the tree. Both go to a separate backlog row -- the
detector-exclusion decision does not belong in a PR whose subject is
strengthening this scanner.

Backlog #166 -> Shipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

The literal old identifier is deliberately not reproduced anywhere in this
message: it is itself the pattern, and git objects include commit messages.
@KbWen
KbWen force-pushed the fix/166-trufflehog-scanner-pin branch from a8bb7ac to 6c95c37 Compare August 12, 2026 10:22
…n test identifiers

Surfaced by this PR's own CI: the detector's key pattern matches a word-boundary
run of 'live' or 'test', an underscore, then exactly 35 characters from a class
that includes the underscore — so an ordinary snake_case function name can
satisfy it, and the verifier returned VERIFIED. AC-3 states --only-verified
exists to bound false positives; it did not bound this one.

Records two facts established empirically here and documented nowhere else:
the action walks each commit's diff across the range rather than the net
endpoint diff (a follow-up rename stayed red; removing the introducing commit
went green), and commit messages are in scope too.

36 pre-existing identifiers of the same shape are latent in the tree. They are
deliberately not enumerated in the row — quoting one would re-create the
pattern in the row's own diff. The row gives the reproduction instead, with a
note to read the regex from the pinned source rather than retyping it, because
a hand-written attempt omitted the underscore and found nothing.

Detector-exclusion options are listed, none taken: weakening a detector does
not belong in a PR whose subject is strengthening this scanner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@KbWen KbWen left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: NOT READY at 982ce7b. I independently assign three P1/blocking findings. The PR treats backlog #166 (P1) as resolved; I disagree because the image remains tag-addressed and the drift test proves comment agreement rather than artifact provenance. The PR treats #171 as P2/non-blocking; I agree with deferring detector exclusion, but not with its count or mutually exclusive preferred remedies. Fresh checks reproduced the focused 55-test pass and the claimed two-failure/one-failure red-green discrimination; the inline comments record the additional mutations and all discrepancies.

Comment thread .github/workflows/security.yml Outdated
Comment thread tests/ci/test_security_workflow.py Outdated
Comment thread .agentcortex/context/archive/fix-166-trufflehog-scanner-pin-20260812.md Outdated
Comment thread docs/specs/ci-security-scanning.md Outdated
Comment thread docs/specs/ci-security-scanning.md Outdated
Comment thread docs/architecture/ci-security.log.md Outdated
Comment thread docs/specs/_product-backlog.md Outdated
…ssify

Eight findings, three of them P1, all adopted; none overruled. Two changed the
shipped design rather than its wording, and one is a governance violation in
this unit's own classification.

P1 — an exact release tag is still mutable, so the immutability this fix
claimed was not achieved. A tag can be re-pointed; that is the same objection
AC-5 already makes against semver action refs, and the first design accepted it
for the image. The scanner is now pinned by DIGEST: `image` ends in `@sha256`
and `version` carries the 64-hex manifest digest, so the wrapper's own
`docker run "${IMAGE}:${VERSION}"` join composes a content-addressed reference.
If upstream changes that join the result stops being a valid reference and
docker fails loudly rather than silently falling back to a tag.

P1 — the comment-equality guard proved only that two editable strings agree.
The review demonstrated it by mutation: swapping the wrapper SHA for the
previous release's while leaving comment and input untouched kept all 42 tests
green. The guard is replaced by an assertion that the composed reference
matches a digest form — immutability by construction, not agreement between
two strings a hand edit can move together. A weaker release-comment check
remains, explicitly labelled as readability rather than provenance.

P1 — the quick-win classification bypassed a hard-block escalation.
state_machine.md:51 makes the reverse transition MANDATORY above 200 diff lines
or 2 modules; this unit is 276 lines across 7 files and four modules, and its
own Phase Summary said "three modules" while keeping the tier. Reclassified to
hotfix through the documented mechanism: a structured `Reclassification:` record
in the Drift Log plus a re-entered bootstrap receipt, which is what makes the
second epoch legal (validate.sh:1541). Hotfix demands the REVIEWED gate that
was skipped, without a handoff artifact that would add nothing here. The
retroactive sequencing is recorded rather than presented as a clean run.

P2 — the spec was internally false in three further places the first pass
missed: a Non-goal listing the PR-delta scan as out of scope when it is the
shipped behaviour, and two claims that `--only-verified` bounds false positives,
which #171 disproves. All corrected.

P2 — false text is no longer preserved in the live spec. The first attempt
struck it through and kept it; review argued that struck-through text still
reads as live text to grep and to any agent loading the file, which is decisive
in a repo whose primary consumer is an agent. Superseded wording now lives only
in the L2 log as `[SUPERSEDED]` entries. L1 carries current truth, L2 carries
history.

P2 — the freshness tradeoff was mis-framed as "the trade AC-5 already claimed
to have made". Withdrawn: the deployed runtime resolved `latest` and was not
paying that cost, so digest pinning newly incurs it. Now an `## Accepted Risks`
entry with owner, cadence, and an emergency path.

P2 — `source_sha` in the new L2 log pointed at the diff base, where the file
does not exist. Repointed, with the PR named as the durable reference since
squash-merge does not preserve branch SHAs.

P3 — the recorded validator totals are not reproducible from a clean checkout
(a reviewer's detached clone reported pass=100 warn=3 skip=3). Labelled as
machine-local rather than restated, with CI named as the replayable evidence.

P3 — backlog #171 said 36 identifiers where the committed tree holds 35, and
offered two "preferred" remedies that are mutually exclusive. Both corrected.

Evidence: 57 passed across the affected suites; the digest assertion proven red
by reverting to a tag reference. validate.sh and validate.ps1 both pass=118
warn=4 fail=0 skip=2 on this machine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@KbWen KbWen changed the title fix(ci-security): #166 — pin the TruffleHog scanner image, not just the wrapper fix(ci-security): #166 — pin the TruffleHog scanner by image digest Aug 12, 2026
@KbWen
KbWen merged commit f70b10b into main Aug 12, 2026
19 checks passed
@KbWen
KbWen deleted the fix/166-trufflehog-scanner-pin branch August 12, 2026 14:27
KbWen added a commit that referenced this pull request Aug 12, 2026
)

Records-only. Adds the SSoT Ship History entry that PR #402 should have
carried, ahead of the v1.8.20 release cut.

## Why this is not optional

`Update Sequence` stood at **147** with the newest Ship History entry
covering #399/#400. Neither #401 nor #402 was recorded, and `grep -c
'#402' current_state.md` returned **0** — so a **P1 security fix**, an
amendment to a shipped spec's acceptance criteria, and a newly created
L2 domain log were all absent from the record.

That propagates: **the release cut's CHANGELOG derives from Ship
History**, so v1.8.20 would have shipped release notes missing the most
significant item in the version.

**#401 legitimately skipped SSoT** — records-only, no feature shipped,
per the 2026-07-09 reconcile-note precedent reaffirmed by PR #397, and
that call survived independent review. **#402 does not qualify for the
same exemption.**

## What changed

- Ship History entry for
`Ship-fix-166-trufflehog-scanner-pin-2026-08-12`, covering the digest
pin, the AC-3/AC-5 corrections, the reclassification to `hotfix`, and
the Lob false-positive incident that became #171.
- `Update Sequence` 147 → 148, `Last Updated` refreshed.
- **Cap rotation**: Ship History was at 10/10, so the oldest entry
(`Ship-docs-repo-gotchas-14-worklog-archival-2026-07-27`) moved verbatim
into `archive/ship-history-2026.md` rather than being dropped.

Written with `guard_context_write.py --mode replace` under optimistic
locking (`--expected-sha`, receipt committed). Not `--mode append` —
that path lands at file end, and the entry belongs at the top of the
section.

## Process note

Classification was **measured before being assigned**: 21 changed lines
across 2 substantive modules, against `state_machine.md:51`'s 200-line /
2-module hard block. The immediately preceding unit classified first and
violated that block, which the independent review on #402 caught;
reversing the order is the fix, and it is recorded in the Work Log as
the reason the order matters.

## Evidence

- `check_ssot_caps.py` → `ssot caps OK — ship history 10/10, spec index
26/30` (10 entries before and after).
- `check_audit_chain.py` → `audit chain intact`.
- `validate.sh` **`pass=118 warn=4 fail=0 skip=2`**. Self-archival
verified by delta, not asserted: before it `warn=5` including `shipped
work logs still in active work/ directory: 1`; after, that line is gone.
The 4th WARN is an external reviewer's stale lock from PR #401 —
gitignored, outside this diff. Totals are machine-local; CI is the
replayable evidence.
- **Backlog #168 fired a third time during this ship**: after the branch
switch git re-materialised `INDEX.jsonl` fully CRLF and the pre-commit
normalise reported **153 → 0**. Third independent confirmation that
#168's fix needs the `*.jsonl text eol=lf` half, not just `O_BINARY`.

Rollback: revert this PR. The sequence returns to 147 and the rotated
entry returns to `current_state.md`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
KbWen added a commit that referenced this pull request Aug 12, 2026
…404)

Release cut for **v1.8.20**. Docs-only: version banners, CHANGELOG, and
the release's own Ship History entry. **No engine, test, or logic change
rides this PR** — everything substantive was already merged and
individually CI-green.

## What it packages

Ten commits sat unreleased on `main` while the banner still read 1.8.19.

| | |
|---|---|
| **PR #402** | backlog **#166 (P1)** — the TruffleHog pin bound the
*wrapper*, not the scanner. Now pinned by image digest; AC-3's false
"full-history scan" claim and the Domain Decision that generated it
corrected; new `docs/architecture/ci-security.log.md` |
| **PR #395** | backlog #163+#164 — audit-wave leftovers |
| **PRs #386/#377/#378** | three dependabot bumps, open 8–15 days |
| **PRs #397/#399/#400/#401/#403** | records wave, including the
archival of this repo's only different-vendor review artifact |

## What it changes here

Banners 1.8.19 → 1.8.20 across the canonical 7 (`deploy.sh`
`ACX_VERSION`, `CITATION.cff` version + `date-released`, Model Guide
EN/zh-TW, Testing Protocol EN/zh-TW, `antigravity-v5-runtime.md`),
CHANGELOG `[1.8.20]`, SSoT sequence 148 → 149 with the cap-10 rotation.

## Shipping a known state, not a quiet one

The CHANGELOG names **five defects filed and deliberately unfixed**
(#167#171) rather than omitting them — including **the scanner
false-positive class that blocked this release's own security PR**. Each
fix touches tool, workflow, or `.gitattributes` code a docs-only cut
must not carry, and #171's detector exclusion is a security-coverage
decision that deserves its own review.

It also records a governance failure rather than burying it: **PR #402
was classified `quick-win` at 276 lines across four modules**, against a
hard block at 200 lines / 2 modules — which is what let its review gate
be skipped. Independent review caught it; it was reclassified to
`hotfix` through the documented rollback mechanism, with the retroactive
sequencing stated plainly. The durable fix is procedural — *measure the
diff, then classify* — and the two units after it did so.

## Evidence

- **Banner sweep verified both directions**: each of the 7 replacements
asserted to match exactly once before writing, then `grep -rn
"1\.8\.19"` across the same 7 files → **no matches**.
- **Guarded SSoT write** under optimistic locking → `{"status": "ok"}`;
10 Ship History entries before and after; `check_ssot_caps.py` → `ship
history 10/10, spec index 26/30`.
- `check_audit_chain.py` → `audit chain intact`.
- `validate.sh` **`pass=118 warn=4 fail=0 skip=2`** — machine-local
totals (a clean checkout runs 18 fewer active-work-log checks); CI is
the replayable evidence.
- **A wrong fact caught before it landed**: the Ship History entry first
named the wrong rotated entry. Corrected in the staged content, so it
never reached `current_state.md`.

## Not done at merge

Per `repo-gotchas` #12 — and forgotten twice before — the release is
**not complete when this merges**. The lightweight `v1.8.20` tag and `gh
release create --latest` are separate manual steps, tracked to
completion in this session.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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