Skip to content

Lane A (A.7-A.13): collectors, and a host boundary that now holds - #11

Merged
tom-snyder merged 3 commits into
mainfrom
feat/phase3-lane-a-collectors
Aug 9, 2026
Merged

Lane A (A.7-A.13): collectors, and a host boundary that now holds#11
tom-snyder merged 3 commits into
mainfrom
feat/phase3-lane-a-collectors

Conversation

@tom-snyder

Copy link
Copy Markdown
Member

Summary

The poller, bulk-archive bootstrap, host collector, repo SCA collector, warm-start accelerator, and the two security critics. Both critics returned FAIL; two adversarial rounds followed.

The host read-only boundary

S7: the host agent is read-only — no package manager in a mutating mode, not behind a flag — and §0.2 reserved the strongest reviewer for it because a miss is unrecoverable on a server someone depends on.

The first guard matched callees by source spelling, and the critic proved it by compiling two host-mutating exec sites into the package that no analyser saw. That's the third time on this project a check has lost by matching names instead of resolving identity.

It now resolves by import path, treats a reference to a spawning function as a spawn site, and fails closed on what it cannot read — dot imports, blank imports, assembly bodies, go:linkname, any .s/.c/.syso. The import set became an allowlist, because the old denylist let a fourth file import anything nobody listed.

An adversary then compiled a mutating exec eight ways — alias, dot import, function value, in-package wrapper, method value, interface call — and every one was caught, most by 3–4 independent guards. All 18 mutating command lines refused. The systemd guard now covers every Exec* directive and every occurrence, and strips the +/!/!! prefixes that run outside the sandbox the unit's whole argument rests on.

The filesystem guard lost the same way and was fixed the same way: a hand-enumerated denylist permitted os.OpenRoot, the *os.Root methods, os.CopyFS and os.FindProcess().Kill(). A read-only collector that can kill processes is not read-only. Now an allowlist of eight operations, none of which writes, with handle tracking so os.OpenRootroot.WriteFile is two findings rather than none.

And "no package this one may import can spawn a process" was documented, enforced nowhere, and false (os exports StartProcess). Deleted rather than softened — the rule this project reached after four revisions of an overstated compliance document — and replaced with a transitive import-graph check measured across four platforms with CGO on and off.

rpm -qa is not read-only, and the doc now says so

On a Berkeley-DB-backed rpmdb — RHEL/CentOS 7 and 8, SLES, Amazon Linux 2 — opening the database creates and updates /var/lib/rpm/__db.001..003 when the caller can write. No rpm flag suppresses it.

So the sentence claiming this collector never mutates the host was deleted, not qualified, and replaced with the precise statement: which distros, which backends, and the mitigation — which is not in this package at all. It's the unit's DynamicUser=yes plus ProtectSystem=strict, and removing either re-opens it.

The accelerator

Three blockers, all closed: the un-redistributable Trivy DB written into mirror/tier2 on Windows (byte-exact path comparison); the cache directory inside the git work tree matched by no ignore rule; and exchangeToken letting the registry choose where the operator's credential was sent, by parsing realm out of a header the registry controls — credential exfiltration driven by untrusted input, in a tool built to run against untrusted things.

Then a Windows directory junction (mklink /J, no elevation) defeated the fixed path check, because os.Lstat reports a junction as ModeIrregular and filepath.EvalSymlinks walks straight through. Fixed at configure time and write time, since a directory can be replaced by a junction between the two.

The reason that survived matters more than the bug

The one test that would have caught it skipped four times on this host and the package still printed ok. A.12 had independently found the same failure mode on a different guard. Twice is a pattern.

internal/SKIPPED-CONTROLS.md now inventories all 19 t.Skip sites, measured by running the suite rather than reading conditions: 11 legitimate, 9 hazards, 8 fixed. Among them a catch-all skip on the S7 "verified fixed" integrity gate that swallowed any fixture error, and two skips that fired when a checked-in file failed to load.

The ninth is closed here by me, because it needed a CI change: after the fix, the "a real scanner does not report a vulnerable fixture clean" control was honest but ran on no machine. There's now a trivy-e2e job that installs a pinned Trivy, runs it, then hides the binary and fails if the test still passes — a job that proves a control must itself be proved.

Testing

gofmt / vet / build   clean
go test -count=1      green across 17 packages

-race can't run on this Windows host; CI runs it on Linux.

For you, not fixed here

The accelerator's critic found a share-alike laundering path: the Trivy DB aggregates exactly the CC-BY-SA-4.0/ODbL upstreams mirror/tier2 quarantines, so a finding derived from that cache can reach publication without the quarantine ever being consulted — nothing crossed a tiered path. The directory rule works correctly and is answering a question that no longer covers the case. It may be a reason not to ship the accelerator at all.

The poller, the bulk-archive bootstrap, the host collector, the repo SCA
collector, the warm-start accelerator, and the two security critics. Both
critics returned FAIL; two adversarial rounds followed.

The host read-only boundary

S7 says the host agent is READ-ONLY -- no package manager in a mutating mode,
NOT BEHIND A FLAG -- and section 0.2 reserved the strongest available reviewer
for it because a miss is unrecoverable on a server someone depends on.

The first guard matched callees by SOURCE SPELLING, and the critic proved it by
compiling two host-mutating exec sites into the package that no analyser saw: an
import alias and a function value. That is the third time on this project a
check has lost by matching NAMES instead of resolving IDENTITY.

It now resolves by import path, treats a reference to a spawning function as a
spawn site, and FAILS CLOSED on what it cannot read -- dot imports, blank
imports, assembly bodies, go:linkname, and any .s/.c/.syso in the package. The
import set became an ALLOWLIST, because the old denylist let a fourth file
import anything nobody had listed.

An adversary then compiled a mutating exec in EIGHT shapes -- alias, dot import,
function value, in-package wrapper, method value, interface call -- and every
one was caught, most by three or four independent guards. All 18 mutating
command lines are refused. The systemd guard now covers every Exec* directive
and every occurrence, not ExecStart alone, and strips the +/!/!! prefixes that
run outside the sandbox.

The filesystem guard lost the same way and was fixed the same way: it was a
hand-enumerated denylist that permitted os.OpenRoot, the *os.Root methods,
os.CopyFS and os.FindProcess().Kill(). A read-only collector that can kill
processes is not read-only. It is now an allowlist of eight operations, none of
which writes, with handle tracking so os.OpenRoot -> root.WriteFile is two
findings rather than none.

And the claim "no package this one may import can spawn a process" was
documented and enforced nowhere -- and was also FALSE, since os exports
StartProcess. Deleted rather than softened, per the rule this project arrived at
after four revisions of an overstated compliance document, and replaced with a
transitive import-graph check measured across linux/amd64, linux/arm64,
windows/amd64 and darwin/arm64 with CGO on and off.

rpm -qa is not read-only, and the doc now says so

On a Berkeley-DB-backed rpmdb -- RHEL/CentOS 7 and 8, SLES, Amazon Linux 2 --
opening the database creates and updates /var/lib/rpm/__db.001..003 when the
caller can write. No rpm flag suppresses it. So the sentence claiming this
collector never mutates the host was DELETED, not qualified, and replaced with
the precise statement: which distros, which backends, and the mitigation, which
is not in this package at all -- it is the unit's DynamicUser=yes plus
ProtectSystem=strict, and removing either re-opens it.

The accelerator

Three blockers, all closed: a full warm start wrote the un-redistributable Trivy
DB into mirror/tier2 on Windows because the quarantine check compared paths
byte-exactly; the cache directory sat inside the git work tree matched by no
ignore rule; and exchangeToken let THE REGISTRY choose where the operator's
credential was sent, by parsing realm out of a header the registry controls.
That last one is credential exfiltration driven by untrusted input, in a tool
built to run against untrusted things.

Then a Windows directory JUNCTION -- mklink /J, which needs no elevation --
defeated the fixed path check, because os.Lstat reports a junction as
ModeIrregular and filepath.EvalSymlinks walks straight through it. Fixed at both
configure time and write time, since a directory can be replaced by a junction
between the two.

The reason that survived matters more than the bug

The one test that would have caught it SKIPPED four times on this host and the
package still printed ok. A.12 had independently found the same failure mode on
a different guard. Twice is a pattern.

So internal/SKIPPED-CONTROLS.md inventories all 19 t.Skip sites in the
repository, measured by running the suite rather than by reading conditions: 11
legitimate, 9 hazards, 8 now fixed. Among them a catch-all skip on the S7
"verified fixed" integrity gate that swallowed ANY fixture error, and two skips
that fired on a CHECKED-IN file failing to load.

The ninth is closed here, by me, because it needed a CI change: after the fix,
the "a real scanner does not report a vulnerable fixture clean" control was
honest but ran on NO MACHINE. There is now a trivy-e2e job that installs a
pinned Trivy, runs it, and then HIDES THE BINARY and fails if the test still
passes -- because a job that proves the control exists must itself be proved.

Evidence: gofmt, vet, build clean; go test -count=1 ./... green across 17
packages. -race cannot run on this Windows host; CI runs it on Linux.
The trivy-e2e job 404ed. I wrote TRIVY_VERSION=0.58.2 from memory and it is not
a release; the asset NAME pattern was right and the version was invented. That
is the same assert-from-memory failure this project keeps catching in its own
code, committed by me in the same PR that adds a job whose whole purpose is
proving a control is real.

Now v0.73.0, read from the GitHub releases API, and pinned by DIGEST as well as
version because a tag can be moved and a version pin alone does not survive
that. sha256sum -c runs before the archive is unpacked, so a substituted
artifact fails the job rather than installing.

Stated in the comment rather than left implicit: the digest is GitHub's,
computed over the stored bytes. That is a primary source but it is NOT an
independent re-hash -- the same distinction M0.7 flagged about its own opengrep
pins, and worth keeping honest in both places.
The trivy-e2e job installed Trivy correctly (digest verified) and then the test
failed with FATAL "--skip-db-update cannot be specified on the first run".

That is not a CI quirk, it is the control doing its job. The collector invokes
Trivy with --skip-db-update --offline-scan, which is the RIGHT posture -- S0
says Anvil never browses the live web at inference time -- but the consequence
is that Trivy refuses to run at all until a database already exists locally. No
test against recorded Trivy output could have surfaced that, because recorded
output presupposes a run that succeeded.

In production the database is what A.11's warm-start accelerator supplies. Here
it is seeded the ordinary way, so the E2E control exercises the real invocation
rather than a softened one.

Worth stating because it sharpens an existing open question: this is a second,
independent reason the accelerator matters. Its critic already flagged that
consuming the Trivy DB may launder share-alike data past the licence
quarantine, which is an argument against shipping it. This is an argument that
something must fill that role regardless -- an offline scanner with no database
is not a scanner.
@tom-snyder
tom-snyder merged commit 761143d into main Aug 9, 2026
5 checks passed
@tom-snyder
tom-snyder deleted the feat/phase3-lane-a-collectors branch August 9, 2026 21:41
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