Skip to content

fix(audit): a scan that could not run is not a scan that found nothing - #1547

Merged
Ndevu12 merged 8 commits into
mainfrom
fix/audit-unrunnable-verify-scan
Sep 2, 2026
Merged

fix(audit): a scan that could not run is not a scan that found nothing#1547
Ndevu12 merged 8 commits into
mainfrom
fix/audit-unrunnable-verify-scan

Conversation

@Ndevu12

@Ndevu12 Ndevu12 commented Sep 1, 2026

Copy link
Copy Markdown
Owner

saw audit --verify is the operator asking for the harder look on a module that already
looks modified. Every failure of that scan was answered with the empty marker list a clean
scan returns, so the run fell back to the weaker finding — whose own advice is to run
--verify. The operator was told to run the thing they had just run, over a scan that never
happened, and the rotation verdict counted the module as settled.

Measured on an interpreter missing one of the engine's dependencies: the import raises and
every module reads as scanned-and-clean.

What changes for an operator

  • A scan that could not run is reported as exactly that, with the reason, and it names the
    modules it did not settle.
  • That state withholds the credential-rotation all-clear until the scan can run, because a
    scan that did not happen cannot say there is no live foothold.
  • A module that was scanned and came back clean now says so, instead of repeating the advice
    to run the scan that had just cleared it.
  • Nothing changes for a default audit, for a confirmed finding, or on a host where the scan
    runs normally.

Shape of the fix

The hole is a separate item from the observation. Folding both into one unknown made the
run headline read "no findings" over a module that looks modified — the renderer treats
unknown as the absence of a look, not as something found, and this is both. It is one item
per run with each distinct cause named once, not a copy per module.

KeyboardInterrupt is no longer caught here. This runs once per module, so swallowing it
cost one interrupt per file to stop an audit, and everything the run went on to report
carried scans nobody performed. The layer above already states that rule.

Verification

  • 39 tests in test_app_bundle, 10 mutations against the new pins, all killed — including
    the two that only showed up under mutation: a clean scan withholding its own negative
    result, and the per-run item repeating one cause once per module inside a single item.
  • Full suite run locally; the only new failure was the finding word budget, which the
    remediation now meets outright rather than joining the ratchet.
  • The test that needs the content-scan engine now skips with a reason where it cannot load,
    instead of failing as though the tool were broken.

@Ndevu12 Ndevu12 added the pin-bump-deferred Engine change intentionally defers the worm-guard sentinel-ref bump (passes the pin-freshness gate) label Sep 1, 2026
@Ndevu12 Ndevu12 self-assigned this Sep 1, 2026
`--verify` is the operator asking for the harder look. Every failure of
the content scan was answered with the empty marker list a clean scan
returns, so the run fell back to the weaker finding — whose own advice
is to run `--verify`. The operator was told to run the thing they had
just run, over a scan that never happened, and the rotation verdict
counted the module as settled.

Measured: on an interpreter without one of the engine's dependencies
the import raises and every module reads as scanned-and-clean.

The scan now reports three states rather than two. A blocked one keeps
the module at its `info` grade for what was actually observed and adds
the hole as its own item, because something found and a look that did
not happen are different claims and the report groups them differently
— folding both into one `unknown` made the run headline "no findings"
over a module that looks modified. The hole withholds the rotation
all-clear, since a scan that did not run cannot say there is no live
foothold, and it is one item for the run with each distinct cause named
once rather than a copy per module.

A scan that ran and came back clean now says so, instead of repeating
the advice to run the scan that had just cleared it.

`KeyboardInterrupt` is no longer swallowed here: this runs once per
module, so catching it cost one interrupt per file to stop an audit and
everything the run went on to report carried scans nobody performed.
`run_probe` already states that rule for the layer above.

The test that needs the engine now skips with a reason where it cannot
load, rather than failing as though the tool were broken.
The first version of this fix only closed the exception channel. The
engine almost never raises — it reports a tree it could not fully read
IN its verdict, and its own docstring says the caller must not render
that as clean. Reading the marker list alone turned every one of those
into "scanned, found nothing", including the two that return before the
scanner runs at all: a directory too large to scan, and one holding a
pipe or device the scan must not open.

That made this worse than what it replaced. The old sentence claimed
nothing; the new one affirmatively stated a clean scan. And the second
case is reachable: something that has already appended to a bundle
module can create one pipe in that directory and buy a printed
statement that a content scan found no worm markers, with the
credential-rotation all-clear intact.

Only a scan that read the whole directory and found no markers clears a
module now. Anything else keeps the module unsettled and carries the
scan's own reason through to the operator. The sibling consumer in
`host_artifacts` already reads every field of that verdict; this asks
the same questions rather than a narrower one.

The tests could not have caught it: they drove the clean path with a
`Mock(markers=[])`, which answers that one attribute and stubs away the
fields the verdict uses to say it did not finish. They use a stand-in
that mirrors the real shape, held to it by a check that runs wherever
the engine can be imported.
The round on the previous commit found the class surviving below the
content scan and beside it.

A module the host would not let this read answered exactly as one that
was read and held nothing. It then fell through with no finding, no
count and no mention anywhere in the report — the tier that decides
whether the content scan runs at all, saying nothing when it could not
decide. Two files with a byte-identical payload, one readable and one
not, produced one finding. It is now its own grade, and it withholds the
rotation all-clear: MEASURED first, 22477 modules under 44 enumerated
roots on an ordinary host, none unreadable and none anything but a
regular file. The bounds this tool chooses for itself stay an `info`
note — a location the host refused is a different claim.

Reading a file that is not a regular file has no timeout, so anything
able to append to an application's module could also stop the audit by
creating a pipe named like one beside it. The engine guards exactly this
before its own read; this side did not.

`scanned_clean` is CONFIRMED-only — the engine drops its weaker tiers
before answering — so "found no worm markers" was narrower than an
operator reads it. The sibling attaches a caution to that same field for
that reason, and the previous commit claimed to ask the same questions
while dropping it.

The sibling itself had both defects this branch exists to fix. It threw
away the reason a content scan failed, so `--verify` over an engine that
cannot load printed output byte-identical to not passing the flag —
advice to run the command just run — and kept the rotation verdict at
"safe pending a check". And it described a directory where the scanner
never ran as one where a content scan of the rest found no markers.

The test that pinned the unreadable case asserted no findings at all.
Its contract is that the run survives; the silence was how that happened
to be expressed, and it made the silence the spec.

Also: the `DirVerdict` stand-in added last commit was missing the
derived read the other consumer asks for first, and the check holding it
to the real shape compared only fields. It compares the public surface.
Measured against `origin/main` for the same files: comment-plus-docstring
to code had gone from 0.12 to 0.25 in app_bundle.py, doc lines from 26 to
83, and the test file had picked up docstrings where the convention is a
name that says it.

What is left carries a measurement, a rejected alternative or a trap.
The narrative each one was retelling is in the commit that made the
change, which is where it belongs.
Three constants carried a comment each, which is a name that failed. Two
of them said which surface the id belongs to — so the name says it now:
`APP_BUNDLE_SCAN_BLOCKED_ID`, `APP_BUNDLE_MODULE_UNREADABLE_ID`,
`HOST_ARTIFACT_SCAN_BLOCKED_ID`. The third restated a measurement the
function that builds the finding already carries.

Two comments sat above a local, which reads the same way. Both were
about the branch under them rather than the value, and moved into the
function that applies the rule.
…assertion

CI, on the modules this host cannot import.

`test_the_lone_indicator_path_keeps_its_finding` is the spec for the
thing this branch changes: an aborted scan must not take the weak
finding with it. It still does not — the finding is kept — but the run
now also reports the scan that did not happen, so the exact-list
assertion no longer holds. It asserts the contract it is named for, plus
the item, rather than a list that forbids adding one.

The stand-in equivalence check compared `dir()`, and a field given a
default here to keep the fixtures short becomes a class attribute the
real verdict does not have. It compares the fields and the properties,
which is where `has_markers` actually went missing.
@Ndevu12
Ndevu12 force-pushed the fix/audit-unrunnable-verify-scan branch from 10e252b to c22ff8f Compare September 2, 2026 10:44
@Ndevu12
Ndevu12 marked this pull request as ready for review September 2, 2026 10:59
…uld not read

The third round measured the asymmetry this branch had created. Locking
a module made the run fail closed; locking the DIRECTORY holding it made
it fail open — `os.walk` with no `onerror` drops the whole subtree, no
finding and no count. Anything able to write a module can chmod its
directory, so the new counter bought nothing against the case it was for
while handing an attacker a cheap way to raise a gate. Both directions
answer the same way now, and so does a base that exists and will not
list, which `Path.glob` reports as a base holding nothing.

The finding NAMES what could not be read. A count alone raises a gate
with no way to act on it, which is alarm fatigue against a gate whose
whole purpose is to be believed; the sibling unknown-tier finding names
its locations for that reason.

A dangling link is no longer a hole. Nothing is behind it to read, which
is what the engine states for the same case, and Electron cache trees
churn `.js` while an audit runs.

A bound reached on the last module of a directory never ran the inner
break, so it was reported as nothing — measured at roughly one
truncation in five on a real application tree.

Also: one of my own tests pinned the dangling-link grade the round
refuted, and a `sed` rename silently did nothing because BSD `sed` has
no `\b` — the reference count it printed was of matches it had not made.
CI. The placeholder for a walk error carrying no filename was `"?"`,
which is the render vocabulary's unknown-severity marker — a string
literal leading with the glyph a reader decodes as severity, which
`test_no_surface_hardcodes_a_glyph_in_MARKER_POSITION` exists to stop
drifting. The root being walked is both allowed and more use: it names
where the hole is instead of standing in for a name.

Found by CI, not locally, because a repo-wide contract test scans every
surface file and so is affected by any string literal added anywhere —
which the module suites I had been running cannot show.
@Ndevu12
Ndevu12 merged commit 5f531f0 into main Sep 2, 2026
11 checks passed
@Ndevu12
Ndevu12 deleted the fix/audit-unrunnable-verify-scan branch September 2, 2026 11:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pin-bump-deferred Engine change intentionally defers the worm-guard sentinel-ref bump (passes the pin-freshness gate)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant