Skip to content

feat(cli): Implement report baseline comparison and suppression features - #324

Draft
misonijnik wants to merge 29 commits into
misonijnik/cli-improvementsfrom
misonijnik/supressions
Draft

feat(cli): Implement report baseline comparison and suppression features#324
misonijnik wants to merge 29 commits into
misonijnik/cli-improvementsfrom
misonijnik/supressions

Conversation

@misonijnik

Copy link
Copy Markdown
Member

No description provided.

@misonijnik
misonijnik force-pushed the misonijnik/supressions branch from 82d35fc to 5cbbc0c Compare July 30, 2026 11:17
@misonijnik
misonijnik marked this pull request as draft July 30, 2026 11:18
@misonijnik
misonijnik force-pushed the misonijnik/supressions branch 2 times, most recently from e8b01aa to 4e8e975 Compare August 1, 2026 21:33
@misonijnik
misonijnik force-pushed the misonijnik/supressions branch 3 times, most recently from efee5a1 to b9b7f28 Compare August 24, 2026 02:48
@misonijnik
misonijnik changed the base branch from main to misonijnik/cli-improvements August 24, 2026 08:30
@misonijnik
misonijnik force-pushed the misonijnik/supressions branch from b9b7f28 to 1f5eeca Compare August 24, 2026 08:58
@misonijnik
misonijnik force-pushed the misonijnik/supressions branch from 1f5eeca to 3818a3c Compare August 25, 2026 12:35
Adds the report-level engine for baselines and suppressions:

- identity.go: fingerprint-key resolution (source/sink hash by default),
  identity lookup and git-style prefix resolution
- baseline.go: new/unchanged/updated/absent classification, baselineState
  and baselineGuid application, run GUID stamping
- suppress.go: the read rule for suppression status, accept/defer/unsuppress,
  verbatim inheritance from a baseline, and summary counters
- save.go: atomic report writing

PropertyBag now preserves unknown keys as raw JSON. It previously modelled
only "tags", so any read-modify-write of a report silently dropped the rest
of every property bag — which the round-trip test caught.
internal/triage applies one pass of baseline inheritance, accept/defer
decisions and baseline comparison over a report, and is the single
implementation behind the triage command, scan's annotation step and
summary's read-only view. Gate decides whether findings fail the build:
suppressed findings never count, and with a baseline only new (or
uncomparable) ones do.

The summary grows Baseline and Suppressions subsections, rendered only
when they apply, and the finding listing hides suppressed results unless
asked for them.
- opentaint triage: compare a report against a baseline, record accept
  ("won't fix") and defer ("not fixing for now") decisions by fingerprint
  prefix, and write the annotated report
- scan: --baseline, --baseline-state, --error-on-findings,
  --error-on-severity, --fingerprint-key; the report the analyzer wrote is
  rewritten only when triage actually changed it
- summary: --baseline, --baseline-state, --suppressed; runs the same engine
  read-only so the file is never touched

Read-only mode still annotates the in-memory report, otherwise
--baseline-state could not filter on a state nobody had written.
Rule lists are scan-time rule selection, not suppression: an excluded
rule
never loads, so it produces nothing to suppress. The analyzer only
supports
inclusion, so exclusion is implemented by enumerating every rule id in
the
ruleset and subtracting, then re-expanding join refs so that excluding a
library rule cannot silently break a rule that joins against it.

Resolution happens before the --dry-run bail-out, so a list that selects
nothing is reported by --dry-run rather than after a full compile. The
--rule-id flag continues to win over the config file.
Adds the opentaint triage reference, the baseline/gating flags on scan
and
summary, a Baselines and suppressions section explaining that presence
in a
baseline is not acceptance, and the rules.only/rules.exclude
configuration
keys. Suggestion builders learn the new flags so rebuilt commands keep
them.
- rename TestGateCountsUpdatedFindingsAsNew: the body asserts the
opposite
  (updated findings do not trip the gate)
- Gate.inScope reuses sarif.MatchesSeverity (exported) instead of
  re-implementing the case-insensitive level match
- fingerprintValue delegates to Identity instead of duplicating the
lookup
- scan loads the baseline once, before compile, and passes it through to
  the triage step instead of reading the file a second time
Rule exclusion was config-only (rules.exclude); this adds the flag
analogue
on scan. It overrides the configured exclude list, and composes with
--rule-id — both were asked for explicitly, so exclusion subtracts from
the
selection instead of one flag silently winning. Emptying the selection
is
an error either way, caught by --dry-run before any compile.
…matcher

matchesPattern in rules/select.go had invented a fourth match form,
globbing
the bare leaf name, which summary's --rule-id filter does not support.
Export
the string-level matcher as sarif.MatchesRuleID and delegate rule
selection
(rules.only/rules.exclude, --exclude-rule-id) to it, so one grammar
holds
everywhere: exact full path.yaml:id, exact bare name, or a doublestar
glob
over the full id only. Docs examples updated off the removed leaf-glob
form.
Verifying that --exclude-rule-id reaches the analyzer as concrete
--semgrep-rule-id args surfaced a silence: excluding a typo'd rule id
matched nothing and looked effective. Selection.Unmatched reports
patterns
that select no rule, and scan warns for each — on both the config-list
path
and the --rule-id/--exclude-rule-id flag path. The configuration example
also used a rule id that does not exist in the shipped ruleset; replaced
with a real one.
…-exclude

An exclusion-only selection lowers to just the excluded concrete ids, passed
via --semgrep-rule-id-exclude, instead of expanding into the ~150-rule
inclusion complement. rules.Select now returns Resolved{Include, Exclude};
the allow-list path keeps CLI-side subtraction and join-ref re-expansion,
since inclusion must be exact ids anyway. --rule-id composed with
--exclude-rule-id still subtracts CLI-side (tiny explicit lists).

Requires the analyzer's --semgrep-rule-id-exclude option (separate core
branch); older jars reject it, so exclusion fails fast until the analyzer
version is bumped.
… filter

The single --baseline-state name carried two grammars: a boolean on
scan/triage that persisted result.baselineState into the file, and a
value
list on summary that filtered the listing. Rename the scan/triage switch
to
--write-baseline-state (matching its WriteBaselineState field and its
intent),
leaving --baseline-state <state> exclusively as the summary filter. The
filter
help now spells out the accepted values (new | unchanged | updated |
absent).
No behavior change beyond the flag name; --error-on-severity is
unchanged.
The flag validated each repeatable value as a single token, so the
natural
--error-on-severity error,warning was rejected with "invalid severity".
Split
each value on commas so the comma form and the repeated-flag form are
equivalent. Purely CLI-side (the gate never reaches the analyzer); the
docs
already used the comma form, which now works.
A dedicated task-oriented guide for the new capabilities: the two-axis
mental
model (baseline state vs. suppression), the full lifecycle from
establishing a
baseline through triage inheritance to gating, and reference tables for
baseline
states, suppression statuses, fingerprint identity, the failure gate +
exit
codes, and rule selection. Includes copy-paste GitHub Actions
(actions/cache to
carry the baseline) and GitLab recipes, plus the SARIF 2.1.0 conformance
notes.

Linked from the docs index (Guides + CI/CD) and the usage guide, whose
long
inline section is trimmed to a concise pointer keeping the flag tables.
…ounts

Field-testing baselines on three Stirling-PDF releases surfaced six defects.

The fingerprint summary printed was not the one triage resolved: the listing
showed vulnerabilityWithTraceHash/v1 while triage --accept matched
vulnerabilitySourceSinkHash/v1, so the documented copy-paste workflow always
failed. One --fingerprint-key now governs baseline matching, the printed
value, --partial-fingerprint, and the triage prefix;
--partial-fingerprint-key is a deprecated alias.

summary --baseline-state with no states to read printed "0 findings" and
exited 0 — a clean bill of health for a report nobody compared. It now reads
persisted states (so it works standalone on a --write-baseline-state report)
and errors when there are none. --baseline-state absent could never match,
since fixed findings live only in the baseline; it now lists them.

Excluding a rule reported its baseline findings as Fixed, indistinguishable
from real fixes. Findings whose rule did not run in the current scan are
counted separately as "Rule not run".

Under a display filter the Baseline and Suppressions groups still described
the whole report; the counts are now recomputed over what was printed.

A repeated --justification silently applied the last one to every decision in
the run. It is now an error. --error-on-severity reported "invalid
--severity"; it names the flag the user typed.
Nine runs of one unchanged project show that vulnerabilitySourceSinkHash/v1
changes between runs: 28 of 36 fingerprints stay the same. The report gives
the cause chain from the graph construction to the fingerprint, the
measurements at each step, the two causes the tests exclude, the one question
that stays open, and the recommended change.

Written in ASD-STE100 Simplified Technical English.
Analyzer 908e924 emits a third fingerprint, vulnerabilitySinkHash/v1, which
hashes the sink statement; 06c8d25 puts the rule id into it, so it is now the
"rule and sink only" identity that docs/reports/fingerprint-stability.md asked
for. The CLI accepts it, so a decision can be made about a vulnerable statement
rather than about one route into it: it survives a change to where the untrusted
data comes from, and one entry then covers every caller.

--fingerprint-key also takes short names now — trace, source-sink, sink — so a
user does not have to type a versioned SARIF key to choose an identity. Any
other value still passes through unchanged, because a report may carry
fingerprints this build does not know.

All three keys hash the rule id, so matching stays a plain fingerprint
comparison: no key can make two rules that fire on one statement look like one
finding.

The default stays source-sink. The stability report recommended promoting the
sink hash to default, but that recommendation assumed the source/sink hash was
unstable, and 908e924 fixed that; the report now carries a status section
saying so.
The sink hash becomes the default --fingerprint-key. It names the vulnerable
statement and the rule, and nothing else, so a baseline entry or a triage
decision survives every edit to how the untrusted data reaches the sink.

It costs no precision. The analyzer reports one finding per rule and sink
(VulnerabilityIdentity in TaintAnalysisUnitStorage.kt), so the coarsest key is
still one fingerprint per finding: on the reproduction project, 180 findings and
180 distinct sink hashes. docs/reports/fingerprint-stability.md measured the
same on Stirling-PDF, 36 of 36, and asked for exactly this change.

A coarser identity leaves more underneath it, so a matched finding now reports
what moved. SARIF has one word for all of it, "updated", which does not
distinguish a sink that acquired a new source from a call path that was
refactored — the first deserves a look, the second usually does not. The
summary now reads "Updated, source changed" and "Updated, path changed", while
result.baselineState stays "updated" for both, so --baseline-state keeps
selecting either.

The refinement follows the key ladder sink -> source-sink -> trace, so it also
answers for a non-default identity: under source-sink only a moved path is
reportable, because a moved source is already a different finding, and under
trace nothing refines further. The coarsest difference wins, since a source that
moves drags its trace along and the source is the more meaningful of the two.
The status section said the cause was repaired. It is not, and the commit was
never meant to repair it. Measured on the reproduction project, 3 runs each:
278/300/314 statements with differing facts before #336, 268/392/364 after, out
of 5244.

The measurement that produced the wrong claim compared a probe hash that mixes
in a processed-event counter. That counter varies between runs of both builds,
so it could never separate them; the delayed-analyzer composition it was meant
to sense is in fact identical in both.

The reproduction project also turns out not to answer the fingerprint question
at all: its fingerprints are stable before and after, because each of its
findings has a single route from source to sink. Reproducing the drift needs a
project where a finding has several.

This makes the sink hash the mitigation rather than a convenience, which is
noted where it is documented as the default.
The line is derived from vulnerabilityWithTraceHash/v1, and measurement on
Stirling-PDF says that fingerprint is not reproducible: over 20 runs of the same
analyzer on the same code, 19 disagree with the modal set, and ~1540 of ~1850
trace fingerprints are absent from at least one run. Comparing two such runs
reports ~131 findings as "Updated, path changed" when nothing changed at all.

So the line was close to 100% false positives, and it presented a known-unstable
value as if it were a property of the code. Those findings now fall through to
the plain "Updated" count, which is honest: something below the identity moved,
and we cannot yet say what.

ChangeSource keeps being reported. It comes from the source/sink hash, which is
stable once duplicate sources are excluded from the digest.

The classification itself is kept, with the reason it is unreported recorded on
the constant, so restoring the line is a one-word change once the trace hash is
deterministic.
… is noise"

This reverts commit cbf058ceb127c0c06d488cff069eaff3fee6171a.
triage gets the structured Long (prose paragraphs, positional-arg sentence,
cross-command pointers, exit-code block) and a cobra Example section instead
of inline Examples. scan documents exit code 2 via a gate-aware exit-codes
helper and gains a baseline-gating example; summary explains --baseline and
--baseline-state in its Long and examples.

Flag help is normalized to the house grammar: comma lists instead of pipes,
'; defaults to X' instead of '(default X)', and the --fingerprint-key help no
longer claims source-sink is the default when sink is. docs/usage.md tables
are synced with the changed strings.
Same rule as on the base branch: no semicolons in written prose. Semicolon
joins in the triage/scan/summary help text, flag descriptions, error
messages, comments, and the two docs pages are rewritten as separate
sentences, commas, or parentheticals. Go statement syntax is untouched.
…baseline safety

Rule selection: a ruleset root that is a single YAML file now produces
the ':<id>' ids the analyzer matches (it names a file root by the empty
relative path), so exclusions and only-lists work for file rulesets. The
built-in rules are fetched before an active selection resolves, so a
fresh install no longer fails with 'no rules were found'.

Identity: the sink/source-sink/trace aliases are expanded in summary and
triage before the listing and filter paths consume them, so the
fingerprint on screen is always the one a decision resolves. Results
sharing one fingerprint value resolve together — one decision covers
every duplicate — instead of being permanently ambiguous.

Baseline safety: triage refuses an --output that would overwrite the
baseline, scan validates the baseline carries the identity key before
compiling instead of failing after the analysis, and --baseline-state
absent without --baseline is an error rather than a silently empty
listing. A filtered summary keeps the source-changed/path-changed
attribution, recovered by identity value since filtered results are
copies. Suggested follow-up commands no longer repeat the deprecated
--partial-fingerprint-key next to --fingerprint-key.
golangci-lint (errcheck) flags the unchecked os.Remove and tmp.Close on
the error paths of the atomic write. Both are deliberate best-effort
calls whose errors carry no signal, so they are discarded explicitly.
The triage Long follows the ASD-STE100 style of the base branch: short
sentences, one instruction per sentence, and simple words. The examples
gain two recipes, the decision-by-decision triage loop and the baseline
roll-forward after a release. The baseline paragraphs that this branch
adds to the scan and summary help follow the same style, and scan gains
a CI-gate recipe.
… of it

An absent baseline finding was always summarized as Fixed, but a
fingerprint disappears whenever the code it hashes moves, so a gone hash
does not prove a gone finding. The comparison now looks for what remains
before the summary makes that claim:

- a current result matching under a coarser fingerprint proves the sink
  is still reported, shown as "Gone, sink still reported"
- a new result of the same rule in the same file hints the finding moved
  with its hash, shown as "Gone, possibly moved"
- only absences with nothing left behind keep the Fixed label

The listing prints the qualifier next to the absent state, and updated
findings get their source/path-changed note there too.
…int-key

The identity of a finding is now always the sink hash. The finer hashes
(source-sink, trace) no longer ever decide identity: the comparison
reads
them only to describe what happened to a finding — the existing
"Updated, source changed" and "Updated, path changed" attribution.
A configurable identity bought little and cost a lot: under a finer key
every source or path change split one finding into a new + absent pair,
and decisions silently detached from findings that never went away.

With the key fixed, the absent-finding attribution also simplifies, and
its labels change per review:
- "Fixed" becomes "Absent": the finding is gone and nothing points at
  it, but the summary no longer claims someone fixed it
- "Gone, possibly moved" becomes "Possibly drifted": a new finding of
  the same rule in the same file hints the sink hash moved with the code
- "Gone, sink still reported" is gone with the finer keys that made it
  possible

Removed flags: --fingerprint-key (scan, triage, summary) and the
deprecated --partial-fingerprint-key. --partial-fingerprint still
filters, by the identity value. A baseline without sink hashes is now
rejected with a pointer at the analyzer being too old.
@misonijnik
misonijnik force-pushed the misonijnik/supressions branch from cec50d1 to 74a9538 Compare September 4, 2026 19:04
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