Skip to content

Report what the scanner skipped, filtered, and could not read, in every format - #4

Merged
thebenignhacker merged 12 commits into
mainfrom
fix/version-output-and-silent-skips
Jul 28, 2026
Merged

Report what the scanner skipped, filtered, and could not read, in every format#4
thebenignhacker merged 12 commits into
mainfrom
fix/version-output-and-silent-skips

Conversation

@thebenignhacker

Copy link
Copy Markdown
Member

What this fixes

The v1.3.0 release test failed on five blockers. All five are fixed here, along
with everything seven review rounds found on top of them.

A scan could report a clean result for input it never read. An unparseable
package.json was dropped during discovery, before any parser ran, so a tree
holding a good and a corrupt manifest scanned only the good one and printed a
clean summary at exit 0. A manifest broken by a bad merge became invisible and CI
went green. Unreadable manifests are now named, with the reason, in every format,
and they exit 2.

A scan that examined nothing said so as though it had looked. A
requirements.txt whose every dependency was absent from the database printed
"No cryptographic usage detected" while the JSON for the same scan reported three
packages not in the database. The three cases (nothing declared, nothing
examined, examined and clean) are now worded differently, through one classifier
that every formatter calls.

--risk and --min-severity did nothing. Both were stored and never read,
so every value, including a misspelt one, produced byte-identical output. They
filter now, they validate their argument, and a filtered report says how much it
withheld rather than reading as complete.

Three surfaces reported the wrong tool version. One binary gave four answers:
version said 1.3.0, SARIF and CBOM both claimed 1.0.0, and JSON carried none.
A new pkg/version is the single source of truth, fed from the values GoReleaser
already injects.

Every SARIF result pointed at a literal path "multiple". Every alert landed
on a file that does not exist. Results now carry their own project's manifest,
relative to the scan root and declared through originalUriBaseIds.

Also: emoji removed from the table output in favour of the ASCII markers the
section headers already use, output ordering made stable across runs in all five
formats, and runtime errors no longer print the thirty-line flag list on top of
the message that explains the failure.

What the review rounds added

Seven adversarial passes ran over this branch. Each of the first six found real
defects in the commit before it, and the pattern was the same every time: a
correct-sounding fix applied one layer, or one context, away from where the
decision is actually made. The ones worth knowing about as a reviewer:

  • A scanned repository could write its own lines into the report. Every
    filesystem path reached the table and markdown formats uninterpolated, and the
    bundled Action publishes the markdown. A directory named with embedded newlines
    and ## Scan result: CLEAN put that heading in the report, above the corrupt
    manifest the report exists to disclose. The first fix escaped control
    characters, backticks and pipes, which is the right set for the inside of a
    code span, and then interpolated into a bare ## heading where all markup is
    live, so **CLEAN** and [no findings](https://...) still rendered. Every
    untrusted string now sits inside a code span, and the escape set covers the
    Unicode line separators, the bidi overrides and the zero-width characters as
    well as ASCII controls. The three machine-readable formats were never affected.

  • A polyglot repository exited 2. The first silent-skip fix made any skip an
    analysis error, so a Cargo.toml beside a go.mod masked the exit 1 that two
    real findings had earned. Narrowing discovery to fix that made a build.gradle
    full of crypto dependencies vanish from all five formats at exit 0, which is
    the silent skip this branch exists to remove. The answer was a distinction the
    data model did not have: unreadable versus unsupported. Only the first means
    the scan is incomplete, and only the first exits 2.

  • A filtered or incomplete scan read as clean in the formats nobody was
    looking at.
    Fixes for the verdict reached the table while markdown, SARIF and
    CBOM kept reporting a clean bill of health, three separate times, in three
    different shapes. Coverage is now judged once, per project, and every formatter
    renders that judgement.

  • The same run named a manifest differently in different formats. Only CBOM
    and SARIF expressed paths relative to the scan root; the table compared the
    root as typed against absolutized paths with a string prefix, and markdown did
    not relativize at all. Every surface now agrees, with one absolute anchor per
    document.

  • cryptodeps status reported 1731 packages for a database of 901. The index
    files each package under two keys so a lookup succeeds with or without a
    version, and the count was of index entries.

Founder decisions recorded here

  • --no-color and NO_COLOR are deliberately not added. The tool emits no
    ANSI at all (reconfirmed at gate time: zero escape bytes in all five formats at
    runtime), so once the emoji were gone the flag would control nothing. Shipping
    an inert flag is the defect class this branch fixes. One line to add if
    cryptodeps ever gains colour, in the same change that adds it.
  • The copyright notice is Copyright 2025-2026 CyberSecurity NonProfit (CSNP), corrected from 2024-2025 on 55 files; the repository's first commit
    is 2025-12-26.
  • The status count fix is folded in rather than deferred, because a wrong
    headline number does not belong in a release about outputs telling the truth.

Breaking change

output.PrintSkipped takes the scan root as its second argument, so it can name
a manifest the same way the rest of the report does. It is exported from an
importable package.

Verification

  • Finding sets diffed against the pre-branch binary at 89c986f on four real
    trees carrying 51, 16, 9 and 3 findings: zero lost, zero gained.
  • Every regression test confirmed to fail at runtime against the code it guards,
    with the new API backported into a detached worktree where the old semantics
    needed preserving. Tests that pass against the old code are labelled as
    preservation guards rather than counted as regression tests, and each is proven
    by mutating the behaviour away and watching it go red.
  • Ordering stable across runs in all five formats, on output confirmed non-empty
    first.
  • SARIF and CBOM documents validated against the official SARIF 2.1.0 and
    CycloneDX 1.6 schemas, resolved with their jsf-0.82 and spdx references,
    using jsonschema 4.26.0 rather than the tool's own tests.
  • go test -race, go vet and gofmt clean.

Deferred, with evidence, in roadmap unit 137

SARIF results carry no region and JSON location.file/line are still empty,
which needs declaration line numbers from each parser. performReachabilityAnalysis
writes through a pointer into the shared database. The table's unlabeled effort
column reads as severity and inverts it. The CBOM now carries no absolute anchor,
so a consumer cannot resolve its relative paths; the CycloneDX answer is a
metadata.component describing the subject. And the shipped database attributes
RSA, ECDSA, Ed25519 and X25519 to @noble/post-quantum at high confidence, which
is database curation rather than code and needs a verified source before anyone
edits it.

A fresh-user release test on the 1.3.0 candidate found five blocking defects.
The tag was never created. Each is fixed here with a regression test confirmed
to fail against the pre-fix code.

Version provenance. One binary gave four answers: the version command said
1.3.0 while SARIF and CBOM both claimed 1.0.0 and JSON carried none. SARIF and
CBOM are provenance artifacts, so a stale literal there is a false record of
what produced the document. pkg/version is now the single source of truth, fed
once from the values the linker injects into main.

Silently skipped manifests. A tree with a good and a corrupt package.json
scanned only the good one and reported a clean summary, so a manifest broken by
a bad merge became invisible and CI went green. The cause was not the
parse-error path: validateManifest rejected the file during discovery, before
any parser ran, so no parse error ever existed. Unreadable manifests are now
named with their reason in every output format and always exit 2.

False clean verdicts. A scan where every dependency was absent from the
database reported "No cryptographic usage detected" having examined nothing.
The three cases are now distinguished, and the --deep hints the analyzer always
generated are finally printed.

Inert filters. --risk and --min-severity were stored and never read, so every
value including a misspelt one produced byte-identical output. They now filter,
unknown values are rejected, and the summary and exit code are derived from
what survives. Filtering to empty reports the exclusion rather than a clean
scan, which is the same false verdict reached from a different direction.

SARIF locations. Multi-project runs flattened every project into one synthetic
result whose manifest was the string "multiple", so every alert pointed at a
path that does not exist. Results now carry their own manifest, relative to the
scan root and declared through SRCROOT.

Also: output is deterministic across all five formats (map iteration and a
non-total sort were shuffling it), emoji are replaced by the ASCII markers the
section headers already use, and a runtime error no longer buries itself under
the flag list.
The header claimed 2024-2025 on every Go file and 2024 in LICENSE. The repo's
first commit is 2025-12-26 and it has been edited through 2026, so the start
year predated the work and the end year was stale. A notice covers the years a
work was authored, not the year the organisation was founded.

Also standardises the entity string on "CyberSecurity NonProfit (CSNP)", which
is the spelling already used in the sibling tool repos and their LICENSE files.
An adversarial review of the previous two commits found that the false-clean
fix reached one of five output formats, and that the new skip handling had
turned every polyglot repository into an analysis error. Both are the defect
class the branch set out to remove, reintroduced through a different door.

A repository containing an unsupported manifest type always exited 2.
Discovery recognised Cargo.toml, Gemfile, composer.json and the Gradle files,
but no parser exists for any of them, so each became a reported skip, and a
skip forces exit 2. A tree with a go.mod beside a Cargo.toml reported an
analysis error instead of the exit 1 its two real quantum-vulnerable findings
had earned. SupportedManifests has never listed those names, so the tool was
erroring on files it never claimed to read. Discovery is now driven by which
names actually have a parser.

A filtered scan reported clean everywhere except the table. Markdown still
printed the exact sentence the table fix removed, SARIF asserted
executionSuccessful over an empty result set, and CBOM emitted no components
and said nothing, so a consumer of any of them read a clean bill of health
from a scan that had withheld every finding. All five formats now classify
through one shared function in pkg/output/verdict.go, so a case cannot reach
one format and miss the others. SARIF records coverage as
toolExecutionNotifications and CBOM as metadata.properties; both still
validate against their published schemas.

AggregateResults summed nine summary fields and not FilteredOut, so a
workspace scan reported no withheld findings in the totals a reader actually
looks at while the per-project summaries reported dozens.

--min-severity discarded findings whose severity was not upper case. The rank
map is keyed by the upper-case constants and a Go map returns zero for an
absent key, so an unrecognised severity ranked as INFO and any higher
threshold dropped it without counting it. Records arrive from a remote feed
with no normalisation, so a record carrying "critical" was discarded by the
very filter a user reaches for to see critical findings. Ranking is now
case-insensitive and an unrankable severity is reported rather than withheld:
it has not been shown to be below the threshold.

analyze <manifest-file> emitted SARIF whose SRCROOT base was the manifest
itself, so every result resolved to the literal ".", the same unusable-literal
defect as the "multiple" path it replaced.

The markdown remediation table was the one format still ranging over a map
after the determinism work; ten runs of one scan produced ten documents.

The GitHub Action read .summary from JSON while workspace discovery is the
default and emits .totalSummary, so its published vulnerable-count was always
0. Its SARIF step also treated any non-zero exit as a step failure, skipping
the upload for exactly the incomplete scans most worth reporting.

Every fix has a regression test confirmed to fail against the previous commit
at runtime, alongside guards that fail if the fix is achieved by disabling the
check. Zero findings lost or gained against the previous binary on four real
trees. All five formats remain deterministic and free of emoji and ANSI.

TestDiscoveryOrderIsStable claimed to guard a nondeterminism that never
existed and used a fixture that never engaged the workspace layer. Its comment
now says what it actually guards, and its fixture declares a workspace.

Also corrects the README package count, which claimed 1,100+ where the shipped
database holds 901, and formats the tree with gofmt.
A second adversarial pass found that the previous commit fixed the reported
defects and introduced four more. Recording them plainly, because the shape
repeats: each was a correct-sounding fix applied at the wrong layer.

SARIF and CBOM did not call classifyNoFindings. The previous commit said they
did. They re-derived the conditions inline, over the whole run rather than per
project, and without checking whether the project had findings at all. Two
consequences, in opposite directions. A --deep scan that found MD5 and SHA-1 in
packages absent from the database emitted both findings AND a notice saying no
conclusion about cryptographic usage could be drawn, so the document contradicted
itself. A workspace with one analysed project and one entirely unexamined
emitted no coverage statement at all, because the aggregate no longer satisfied
notInDatabase == total, while the table reported it plainly. Coverage is now a
coverageNote produced by one function from the shared classification, per
project, only for projects with no findings, and both formats render it.

Narrowing discovery to parsable names stopped the spurious exit 2 by making
those files disappear. A build.gradle declaring bouncycastle vanished from all
five formats at exit 0. That is the silent skip this branch exists to remove,
and it contradicted the tool's own words in PrintSkipped: a silently skipped
manifest is how a scanner reports a clean tree it never read. Unsupported
ecosystems are discovered and reported again, marked Unsupported, and only a
manifest that should have been readable and was not marks the scan incomplete.

The withheld-findings test asserted strings.Contains(out, "9"). The CBOM's
random v4 serialNumber and the JSON scanDate satisfy that by accident, so the
test passed against an implementation with the CBOM property and the JSON
aggregation both disabled, and was flaky besides. It now parses each format and
asserts the actual field or sentence. A count was the wrong assertion here in
the same way a count is the wrong assertion for a set.

The discovery-parser invariant was guarded in one direction only, so flipping
pom.xml out of the parsable set disabled all Maven scanning with the suite
green. It now checks both directions and that every name SupportedManifests
advertises is reachable from discovery.

classifyNoFindings documents its ordering as the safety property and nothing
tested it; demoting the filter case below the all-unknown case left the suite
green while a real --deep scan stopped reporting two withheld findings. Now
pinned.

The action's SARIF steps could never run: the analysis step ends with
exit $EXIT_CODE and its default threshold exits 1 on any vulnerable finding, so
a composite step's success() default skipped both. They now carry always().

Verified: all six findings reproduced before fixing and re-checked after. Every
new guard confirmed to catch the mutation it exists for, including the ordering
demotion and both directions of the parser invariant. Ten SARIF and CBOM
documents covering plain, skipped, unsupported, filtered, mixed-workspace and
--deep scans validate against the official upstream schemas. Zero findings lost
or gained against the previous binary on four real trees. All five formats
deterministic, emoji-free and ANSI-free.
Third adversarial pass. It fixed the six from pass two and introduced two more,
in the same shape as both previous rounds: a condition evaluated one layer away
from where it belongs.

Withheld findings vanished from SARIF and CBOM as soon as one finding survived
the filter. FilteredOut was routed through classifyNoFindings, which by contract
only speaks about scans that produced nothing, so a partially filtered scan said
nothing at all: 28 withheld beside 38 reported, and neither machine-readable
format mentioned it. Withholding is a property of the scan, not of an empty
report, so it is now asked before and independently of the no-findings
classification, and attributed per manifest.

An unsupported manifest that was also empty or unreadable forced exit 2 again.
Unsupported was set at the parser-lookup site, but validateManifest runs earlier
in discovery, so an empty Cargo.toml was classified as an unread manifest and
undid the polyglot fix for exactly the trees that fix was written for. Whether a
skip is unsupported is a property of the FILE, so every skip site now goes
through one newSkip constructor that asks getParserForPath, the same function
discovery uses. Asking a parallel predicate would have got requirements/base.txt
wrong, which is parsable only because of its parent directory.

Also from the same pass. A tree whose only manifest is unsupported claimed the
file "could not be read", which is the precise claim IncompleteScan exists to
gate and which was false. Table and markdown had never learned the distinction,
so they counted an unreadable manifest and an unsupported one together and
disagreed numerically with the CBOM for the same run. CBOM coverage notes
carried absolute local paths, publishing the operator's home directory into a
document meant to be shared. Every no-findings note was a SARIF warning, so a
healthy npm workspace produced three warnings saying a package.json declares no
dependencies and none saying findings were withheld.

The action's SARIF steps used always(), which GitHub documents as running even
on cancellation, so a cancelled run would still push a report to the Security
tab. Now !cancelled(), wrapped in ${{ }}: a bare ! is a YAML tag indicator and
the file did not parse.

Tests. The CBOM withheld assertion still passed against an implementation
multiplying the count by 100, and the SARIF one asserted no count at all; both
now pin the exact figure. The per-project test had one note-producing project,
so an implementation returning after the first note passed it; the fixture now
has three and asserts each is attributed. Added the case the whole filtered-scan
fix missed: findings withheld while others survive.

Verified: both HIGH findings reproduced before fixing and re-checked after.
Eleven SARIF and CBOM documents covering plain, skipped, unsupported,
fully-filtered, partially-filtered and all-unknown scans validate against the
official upstream schemas. No absolute path reaches any CBOM. Zero findings lost
or gained against the pre-branch binary on real trees. All five formats
deterministic.
…ip split

The fourth adversarial pass over d64a907 found one behavioural defect and six
places where a behaviour the branch depends on had no test at all.

The defect is the same shape as the three before it. The CBOM stopped publishing
the operator's home directory by relativizing manifest paths against the scan
root, but it compared an absolutized manifest path against the root exactly as
the user typed it. `cryptodeps analyze /abs/path` produced repository-relative
paths; `cryptodeps analyze .`, which is the default invocation and the one the
GitHub Action runs, found no common prefix and fell through to the absolute
path. The fix reached the rendering site instead of the point where the two
forms diverge.

SARIF had always absolutized the root before comparing, so the same run produced
repository-relative SARIF and absolute CBOM. Both normalizations now live in one
place, scanRootDir and relativeToRoot in pkg/output/paths.go, which both
formatters call. That also gives CBOM the root-is-a-file handling SARIF had, and
replaces its HasPrefix(rel, "..") test, which would have rejected a directory
legitimately named something like "..config".

The guard gaps, each closed and each confirmed to catch the mutation it exists
for: deleting the unsupported-ecosystem section from the table and from markdown
outright left the whole suite green, which is how a build.gradle full of crypto
dependencies could go back to vanishing from the report at exit 0; conflating
the unread and unsupported counts in the table and in the CBOM left it green;
levelling every coverage note as a warning left it green; and removing the
wording branch that stops a healthy Cargo.toml being called unreadable left it
green.

TestWithheldFindingsAreReportedEvenWhenSomeSurvive asserted
strings.Contains(out, "7"), which the scan timestamp satisfies on its own. It
passed against an implementation reporting no withheld findings at all, which is
the entire defect it was written for. This is the third instance of that
assertion shape on this branch. Every withheld assertion is now the named field
of the format it belongs to, bound to the real count.

Verified: TestCBOMPathsAreRelativeUnderARelativeScanRoot fails against d64a907 at
runtime with the new API backported and d64a907's semantics left in place. The
other new tests pass there, because they guard behaviour d64a907 already had;
each is proven by mutating that behaviour away and watching the test go red.
Finding sets are identical to the pre-branch binary at 89c986f on four real
trees carrying 51, 16, 9 and 3 findings: zero lost, zero gained. Table output is
byte-identical to d64a907 on every fixture, and the CBOM differs only where the
scan root is relative. All five formats deterministic, ANSI-free and emoji-free.
Fourteen SARIF and CBOM documents, including a real twelve-project tree scanned
as ".", validate against the official SARIF 2.1.0 and CycloneDX 1.6 schemas.
The pre-push gate found that every filesystem path reaches the table and
markdown reports uninterpolated, and a path is attacker-controlled input. The
tool is pointed at repositories it does not trust, and the bundled Action
publishes the markdown report, so the tree being judged could write the
judgement.

Reproduced end to end. A directory named with embedded newlines and the text
"## Scan result: CLEAN" put exactly that heading in the markdown report, on its
own line, above the corrupt manifest the report exists to disclose. The same
name broke the table report into free-standing lines. A directory named "a|b"
shifted the Reason column into a third cell of the "Not analyzed" table, because
GitHub-flavoured markdown splits a cell on an unescaped pipe even inside a code
span. The three machine-readable formats were never affected: encoding/json
escapes what it emits.

The vector predates this branch, through the markdown project list and the Root
Path row, which is why it is fixed here rather than left for the release it
would ship in. This branch had added two more interpolation sites to it.

Every path and every skip reason in the two human formats now goes through one
pair of helpers. A path carrying a control character, a backtick or a pipe is
rendered as a Go-quoted string: single-line, unambiguous, and reversible, so the
file is still named rather than dropped or truncated. Markdown additionally
escapes the backtick and the pipe, which a code span and a table cell need even
though the quoting has already made the string safe to print.

Verified: the four new tests fail against the previous commit at runtime with
reportSafe reduced to the identity function, and the json, cbom and sarif
subtests correctly stay green there, because those formats were never
vulnerable. Escaping is invisible for ordinary paths, including paths with
spaces and Windows separators, so all five formats are byte-identical to
777e1f7 on every fixture.
…changes

The changelog stopped at the output-format work and never recorded the two
newest fixes: the CBOM publishing absolute local paths for the default
invocation, and a scanned repository being able to inject its own headings into
the markdown report.

The roadmap called v1.2 the current release and listed a v1.3 of four features
that this release does not contain, so tagging v1.3.0 against it would have
promised work that has not been done. v1.3 now lists what it actually is, which
is correctness of what the tool reports rather than new surfaces, and the four
feature items moved to v1.4.
…t has no fallback

Two findings from the pre-push gate's adversarial pass, both reproduced before
being acted on.

The path consolidation reached CBOM and SARIF and stopped there. The table kept
getRelativePath, a third implementation of the same comparison, and markdown did
not relativize at all, so the previous commit's claim that the two formats
"cannot disagree again" was true only of those two. Measured on three real trees
scanned as ".", the operator's absolute path appeared 18 times in the table and
18 times in markdown while the CBOM carried none. The markdown report is the one
the bundled Action publishes, so the privacy rationale applied to it most.

getRelativePath was also wrong on its own terms. It tested a string prefix, so
getRelativePath("/repo", "/repository/go.mod") returned "./sitory/go.mod", a path
that does not exist. It now asks relativeToRoot like everything else.

Every surface of one run now names a manifest the same way: one absolute anchor
per document, and every path relative to it. The scan root travels into the
per-project render as an argument rather than on the formatter, matching what
cbom.go already did, so a standalone single-project report still prints the
absolute path, which is the only anchor it has. Table and markdown go from 18
absolute lines to one, their declared root. JSON stays absolute throughout and
self-anchored on rootPath.

The second finding is a test-depth gap of the same family as the assertion this
branch has already fixed twice. assertWithheldSentence accepted any one of four
sentences, and markdown states the withheld count twice in a workspace report, so
either statement could be deleted with the suite green. That is not cosmetic:
the per-project line is the only one that exists in the single-project renderer
that --no-workspaces uses, and it had no test at all. Deleting it made a
--no-workspaces markdown scan read as complete while findings were withheld.
Each format now asserts every statement it is supposed to make, and the
single-project renderer has its own case.

Verified: the two mutations the adversarial pass found surviving are now caught,
as are three new ones covering the relativization in the table and in markdown.
Finding sets unchanged against the pre-branch binary at 89c986f on four real
trees. All five formats deterministic, twelve SARIF and CBOM documents validate
against the official schemas, and the forged-heading fixture still produces no
injected heading in any format.

Known and not addressed: a CBOM now carries no absolute path at all, so a
consumer has nothing to resolve its relative paths against. Declaring the root
would reintroduce exactly what was removed; the CycloneDX answer is a
metadata.component describing the subject, which is its own change.
`cryptodeps status` announced 1731 packages for a database holding 901, and
every per-ecosystem number was wrong in the same way: go 28 against 21, maven
121 against 68, npm 1526 against 773, pypi 56 against 39.

addToIndex deliberately files every package under two keys, "name@version" and
"name", so that a lookup succeeds whether or not the caller has a version. Stats
reported the length of that index, so every versioned package counted twice. The
two keys collapse into one for a package with no version, which is why the
inflation was not a clean doubling and why 1731 was plausible enough to survive
until someone compared it with the file.

Verified against two independent sources: the shipped database's own stats block
(901; go 21, maven 68, npm 773, pypi 39) and a direct count of its records
(901). The status command now agrees with both.

The ecosystem list was also printed straight out of a map, so it came out in a
different order on almost every run. That is the nondeterminism the five output
formats were already fixed for, on a surface the fix had missed.
…y like any other

A sixth adversarial pass found that the previous two commits, both written to fix
layer errors, each contained one.

The injection fix escaped characters instead of closing the context. It handled
control characters, backticks and pipes, which is the right set for the inside of
a code span, and then interpolated the result into a bare `##` heading and a bare
table cell, where every markdown construct is live. Verified against GitHub's own
renderer by the reviewer: a directory named `**CLEAN**` rendered as bold in the
report heading, one named `[no findings](https://...)` rendered as a link, and an
`<img src=...>` survived the sanitizer as a beacon that fires when the report is
viewed. Every untrusted string in the markdown report now sits inside a code
span, which leaves exactly two active characters for markdownSafe to handle. The
escape set also grew: U+2028 and U+2029 are line breaks to a renderer and injected
lines just as \n did, the bidi overrides reverse the visible order of a filename
so the report displays a file it is not talking about, and the zero-width
characters make two different paths render identically.

The path consolidation missed the most common repository shape. table.go's
FormatMulti short-circuits a one-project workspace straight to the single-project
renderer, which takes no scan root, so a repository with exactly one manifest
printed an absolute path in the table while markdown printed a relative one for
the same run, and the skip list printed above it in the same document was
relative too. Measured on a real single-manifest tree before and after: the table
now says ./package.json where it said the operator's absolute path.

Two claims added to the README last night were false, which is the defect this
release is about. "Every output format reports the tool version" is true of the
three machine-readable formats only; the table and markdown mention no version at
all. "Byte-identical output across runs of the same scan, in all five formats" is
true of three: the JSON scanDate and the CBOM serialNumber necessarily differ,
the latter by a deliberate change on this branch. Both corrected to what is
actually true, and the changelog now records the table and markdown
relativization and the breaking change to the exported PrintSkipped.

Six mutations the reviewer found surviving are now caught, along with three more
covering the markdown contexts. The Stats doc comment said it counts distinct
packages when it counts distinct records, which is the right identity for
agreeing with the database's own stats block but not what the comment claimed;
the database holds two records each for PGPy and PyNaCl. cmd/gendb still printed
its ecosystem summary straight out of a map, the same nondeterminism the status
command was fixed for.

Verified: finding sets identical to the pre-branch binary at 89c986f on four real
trees carrying 51, 16, 9 and 3 findings, zero lost and zero gained; ordering
stable across runs in all five formats, on output confirmed non-empty first;
twelve SARIF and CBOM documents validate against the official schemas; the
forged-heading fixture still produces no injected heading; suite green under
-race.
The seventh adversarial pass found that the report-forgery fix covered one of the
two channels into the same document, and that the test certifying it killed
almost nothing.

A dependency name and version come from the manifest under scan, so they are
attacker-controlled in exactly the way a path is, and they are a strictly easier
channel: no filesystem write, no directory named with embedded newlines, just an
entry in a package.json. The database lookup falls back from "name@version" to
the name alone, so a real package with a version of
"1.3.1\n\n## Scan result: CLEAN" still resolved, reached the findings table, and
put that heading in the markdown report seven times, between the rows of findings
that contradict it. Present at 89c986f as well, so this is not a regression
repair, but the changelog and the tests on this branch claimed the vector was
closed when it was closed for paths only.

Both formats now render the dependency label through the same code spans every
path uses, built once in dependencyLabel rather than assembled separately in each.

The escaping is now split by context, which is what the previous commit's own
reasoning argued for and did not do. markdownCode escapes the backtick, which is
all that is active inside a code span. markdownCell adds the pipe escape, which
is a GitHub-flavoured markdown table rule and is consumed only in a table row:
applied to a bullet or a heading the backslash rendered literally, so the report
displayed a name the filesystem does not have and the quoted form no longer
round-tripped through strconv.Unquote. needsEscaping now asks strconv.IsPrint
instead of enumerating the non-ASCII controls by hand, which the reviewer showed
had missed U+061C, U+2060, U+180E, the tag block and U+00A0, all members of the
classes the enumeration named.

TestMarkdownRendersUntrustedPathsInertly selected lines by the code span it was
supposed to be testing for, so removing a span removed the line from the sample
and the assertion never ran: five of its six mutations survived. It now finds the
hostile bytes wherever they land and requires them to be inside a span, and it
fails if the fixture reaches fewer than all six contexts. TestMarkdownTableCells
SurviveAPipeInAPath asserted a cell count, which broke as soon as a four-column
table also gained a code span and would not have caught a pipe in the fourth
column; it now asserts the invariant, that no code span in a table row carries an
unescaped pipe.

The single-project table report had lost its only absolute path when the manifest
became relative, leaving a reader nothing to resolve it against while the
changelog claimed one anchor per document. It prints the scan root again.

Two more claims corrected: the changelog said all five formats are byte-identical
across runs, where the JSON scan timestamp and the CBOM serial number necessarily
differ, and the escaping paragraph described paths only.

Verified: eleven mutations caught, including the two for the dependency channel
and the five markdown contexts the reviewer proved unguarded. Zero forged
headings in all five formats from both the path and the dependency channel.
Finding sets identical to the pre-branch binary at 89c986f on four real trees
carrying 51, 16, 9 and 3 findings, on outputs confirmed non-empty. Fourteen SARIF
and CBOM documents validate against the official schemas. Ordering stable across
runs in all five formats. Suite green under -race.
@thebenignhacker
thebenignhacker merged commit 4dd7f34 into main Jul 28, 2026
3 checks passed
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