Skip to content

Commit 10b76b7

Browse files
cwayne18claude
andcommitted
Pick the right fix when an advisory patched several branches
A review of the fixed-version work in #4 turned up one behavioural bug and six places where the code and its own documentation had drifted apart. An OSV record routinely carries more than one `fixed` event, because a vendor maintaining several branches patches them all: GO-2022-0623 fixed Vault in 1.5.9, 1.6.5 and 1.7.2, and 60 of the 131 records returned for that module read the same way. The last event won, on the reading that OSV lists them ascending so the last is newest -- true, and irrelevant, because the newest of several maintained branches is not the upgrade target for someone on the oldest of them. A 1.5.4 install was told to move to 1.8.5 to close CVE-2021-43998, when 1.7.6 closes it. Advisory.Fixed becomes a list, Advisory.Ecosystem travels with it, and the overlay picks the lowest fix that is actually an upgrade. Picking needs a version order, so it is scoped to what this tool can order with confidence: Debian and Ubuntu through internal/debver, Go and npm through semver. PyPI is deliberately out, since PEP 440 sorts 1.0rc1 before 1.0 and semver sorts it after; so are the RPM distros, since rpmvercmp is not verrevcmp. Everything else keeps the old last-wins target, which overshoots rather than naming a version that may not contain the fix -- and --details now names the branches it did not choose either way, so the overshoot is visible. The rest, all from the same review: - Two doc comments lost their first line when the new code was spliced in above them. `go doc` was rendering writePriority's as "severity spread above it counts." and aliases's as "It exists for the VEX overlay." - The fix plan borrowed renderText's footer, which repeats the main report's section index -- naming AFFECTED and RULED OUT under a document whose only headings are UPGRADE and NO FIX YET. It gets its own footer: its own summary, and the caveats, which are about the scan and not about the view. - "clears 86 advisories; 154 with no fix yet" mixed units in one sentence. The 86 are advisories and the 154 are findings; both now say so. - The fix plan silently dropped findings a vendor VEX statement had answered and findings that were undetermined. A remediation view that omits rows is the one report whose shortness reads as good news, so they are counted. - writeRemediation's doc comment claimed the fix clause is skipped when nothing is fixable. An autofix commit had already changed that, correctly -- a fully-patched image is the case a reader most wants confirmed. The comment, the README and the test name now say what the code does. - fixed_version had omitempty, which erased the difference between "no patch has shipped" and a scan run before the field existed. The text report goes to the trouble of printing "no fix" rather than a blank for exactly this reason. - The --help example for --format fixplan used debian:12, which is fully patched and renders the degenerate case. Verified against debian:12.0: 138 of 292 fixable, 28 upgrade rows, 86 advisories cleared -- unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 2a8623e commit 10b76b7

13 files changed

Lines changed: 646 additions & 119 deletions

File tree

README.md

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -967,8 +967,38 @@ appears only when a section holds at least one row with a published fix, so a
967967
fully-patched image or an ecosystem that ships no fixed versions gets no column
968968
of blanks. `no fix` and an empty cell are kept distinct on purpose: `no fix` is
969969
data (the advisory is acknowledged and no patch has shipped), while a blank
970-
would read as missing data. The summary's `N fixable, M with no fix yet` clause
971-
is dropped entirely when nothing is fixable, so it never prints `0 fixable`.
970+
would read as missing data — and for the same reason `fixed_version` is one of
971+
the few JSON fields with no `omitempty`. The summary's `N fixable, M with no fix
972+
yet` clause is printed even when nothing is fixable, where it reads `154 with no
973+
fix yet`: a fully-patched image is the case a reader most wants confirmed, and
974+
silence in a summary reads as a missing measurement rather than a measured zero.
975+
It never phrases it as `0 fixable`.
976+
977+
One advisory often publishes more than one fix. A vendor maintaining several
978+
branches patches them all: `GO-2022-0623` fixed Vault in `1.5.9`, `1.6.5` **and**
979+
`1.7.2`, and 22 of the 110 records for that module read the same way. Those are
980+
alternatives, not a progression, so the target depends on the branch you are on
981+
— for a `1.5.4` install the answer is `1.5.9`, and naming `1.7.2` would prescribe
982+
two major versions of unrelated change to close one advisory. vexscan keeps every
983+
published fix, picks the lowest one that is actually an upgrade, and shows the
984+
rest under `--details`:
985+
986+
```
987+
fixed in: 1.5.9 (also fixed in 1.6.5, 1.7.2)
988+
```
989+
990+
Picking needs a version order, and where the tool has none it keeps the newest
991+
fix — the behaviour it had before it kept the list — and still discloses the
992+
alternatives, so an overshoot is visible rather than silent. The ordered
993+
ecosystems are Debian and Ubuntu (dpkg's own algorithm, `internal/debver`) and Go
994+
and npm (semver, which both databases publish by definition). PyPI is
995+
deliberately absent: PEP 440 sorts `1.0rc1` before `1.0` and semver sorts it
996+
after, so ordering Python fixes with semver would silently invert the pair. So
997+
are the RPM distros, because `rpmvercmp` is not dpkg's `verrevcmp` however
998+
similar they look. The asymmetry is the reason for the caution — too high a
999+
target is a bigger upgrade than necessary, while too low is a version that does
1000+
not contain the fix, reported as the version that does. Distro records are
1001+
single-branch, so on Debian and Ubuntu this almost never comes up.
9721002

9731003
`--format fixplan` reorganizes the same affected findings by the action that
9741004
clears them. Instead of one row per advisory, it is one row per **upgrade**
@@ -980,7 +1010,7 @@ $ vexscan --image debian:bookworm-20230919 --all --ecosystem os --format fixplan
9801010
vexscan report (image) for debian:bookworm-20230919
9811011
9821012
138 of 292 affected findings have a fix.
983-
upgrading 28 packages clears 86 advisories; 154 with no fix yet.
1013+
upgrading 28 packages clears 86 advisories; 154 findings have no fix yet.
9841014
9851015
UPGRADE (28) - apply these to clear the fixable findings
9861016
PACKAGE CURRENT FIXED IN CLEARS SEVERITY
@@ -1007,7 +1037,9 @@ revision), and findings stay split by their published fixed version rather than
10071037
risk naming the wrong target as newest. It is a view, not a filter: every
10081038
affected finding with no fix is still listed under `NO FIX YET`, because a
10091039
remediation plan that quietly dropped the un-fixable rows would read as
1010-
complete when it is not.
1040+
complete when it is not. The rows it genuinely has nothing to plan for — the
1041+
ones a vendor VEX statement already answered, and the undetermined ones — are
1042+
counted in the summary rather than left out of the arithmetic.
10111043

10121044
The rows are sorted worst-first — known-exploited, then severity, then the
10131045
upgrades that clear the most — so the first line is the one to do first.
@@ -1358,6 +1390,11 @@ The JSON is `schema_version: 2`:
13581390
"schema_version": 2,
13591391
"target": "...", "mode": "image", // or "rootfs", or "repo"
13601392
"findings": [ /* flat, sorted — jq '.findings[]' still works */ ],
1393+
// every finding carries "fixed_version", always present: "" is the "no patch
1394+
// has shipped" answer, so omitting it would hide the thing worth acting on
1395+
// "fixed_versions" joins it only when the advisory patched several branches,
1396+
// listing all of them so a consumer can pick differently than the report did
1397+
13611398
"ecosystems": [ { "id": "os", "components": 65, "error": "" } ],
13621399
"unreadable": { "count": 3, "paths": ["/opt/vendor"] }, // omitted when nothing was skipped
13631400
"vex_hubs": [ { "url": "...", "author": "...", "products": 1082, "matched": 3 } ], // only with --vexhub

fixplan.go

Lines changed: 65 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -38,15 +38,16 @@ func renderFixPlan(res *analyze.Result) string {
3838
// by whether a fix exists so the actionable rows lead and the rest are
3939
// still accounted for.
4040
var fixable, noFix []analyze.Finding
41-
undetermined := 0
41+
var s fixSummary
4242
for _, f := range res.Findings {
4343
if !f.Affected() {
4444
if f.Status != analyze.StatusNotPresent && f.Status != analyze.StatusNotInPath {
45-
undetermined++
45+
s.undetermined++
4646
}
4747
continue
4848
}
4949
if alreadyVexed(f) {
50+
s.vexed++
5051
continue
5152
}
5253
if f.FixedVersion != "" {
@@ -56,33 +57,13 @@ func renderFixPlan(res *analyze.Result) string {
5657
}
5758
}
5859

59-
total := len(fixable) + len(noFix)
60-
if total == 0 {
61-
b.WriteString("No affected findings to fix.\n")
62-
if undetermined > 0 {
63-
fmt.Fprintf(&b, "%d undetermined finding(s) are not shown here; run the default report to review them.\n", undetermined)
64-
}
65-
return b.String()
66-
}
67-
6860
plan := groupUpgrades(fixable, dpkgPlugins(res))
69-
cleared := uniqueAdvisories(fixable)
61+
s.fixable, s.noFix = len(fixable), len(noFix)
62+
s.upgrades, s.cleared = len(plan), uniqueAdvisories(fixable)
7063

71-
fmt.Fprintf(&b, " %d of %d affected findings have a fix.\n", len(fixable), total)
72-
switch {
73-
case len(plan) > 0:
74-
line := fmt.Sprintf(" upgrading %d %s clears %d %s",
75-
len(plan), plural(len(plan), "package", "packages"),
76-
cleared, plural(cleared, "advisory", "advisories"))
77-
if len(noFix) > 0 {
78-
line += fmt.Sprintf("; %d with no fix yet", len(noFix))
79-
}
80-
b.WriteString(line + ".\n")
81-
case len(noFix) > 0:
82-
fmt.Fprintf(&b, " no published fixes yet for any of the %d affected findings.\n", len(noFix))
83-
}
84-
if undetermined > 0 {
85-
fmt.Fprintf(&b, " (%d undetermined finding(s) not shown; run the default report to review them.)\n", undetermined)
64+
writeFixSummary(&b, s)
65+
if s.fixable+s.noFix == 0 {
66+
return b.String()
8667
}
8768
b.WriteString("\n")
8869

@@ -93,12 +74,68 @@ func renderFixPlan(res *analyze.Result) string {
9374
writeNoFix(&b, noFix)
9475
}
9576

77+
// The footer is the fix plan's own, not renderText's. writeFooter repeats
78+
// the main report's summary and its section index, which here would name
79+
// AFFECTED and RULED OUT under a document whose only headings are UPGRADE
80+
// and NO FIX YET -- an index of sections the reader cannot find. The
81+
// caveats still repeat verbatim, because that promise is about the scan
82+
// and not about the view.
9683
if strings.Count(b.String(), "\n") > footerThreshold {
97-
writeFooter(&b, res)
84+
writeCaveats(&b, res)
85+
writeFixSummary(&b, s)
9886
}
9987
return b.String()
10088
}
10189

90+
// fixSummary is the fix plan's count of itself, computed once and printed at
91+
// both ends of the report so the header and the footer cannot disagree.
92+
type fixSummary struct {
93+
fixable int // affected findings with a published fix
94+
noFix int // affected findings without one
95+
upgrades int // rows in the UPGRADE table
96+
cleared int // distinct advisories those upgrades clear
97+
vexed int // affected, but already answered by a vendor
98+
undetermined int // neither affected nor ruled out
99+
}
100+
101+
// writeFixSummary prints what the plan covers and, as importantly, what it does
102+
// not.
103+
//
104+
// Every count names its unit. An earlier cut read "clears 86 advisories; 154
105+
// with no fix yet", where the first number is advisories and the second is
106+
// findings -- two units in one sentence, presented as if they were comparable.
107+
// The vexed and undetermined lines are here for the same reason the NO FIX YET
108+
// table is: a remediation view that silently omits rows is the one kind of
109+
// report whose shortness reads as good news.
110+
func writeFixSummary(b *strings.Builder, s fixSummary) {
111+
total := s.fixable + s.noFix
112+
switch {
113+
case total == 0:
114+
b.WriteString(" no affected findings to fix.\n")
115+
default:
116+
fmt.Fprintf(b, " %d of %d affected findings have a fix.\n", s.fixable, total)
117+
}
118+
switch {
119+
case s.upgrades > 0:
120+
line := fmt.Sprintf(" upgrading %d %s clears %d %s",
121+
s.upgrades, plural(s.upgrades, "package", "packages"),
122+
s.cleared, plural(s.cleared, "advisory", "advisories"))
123+
if s.noFix > 0 {
124+
line += fmt.Sprintf("; %d %s no fix yet",
125+
s.noFix, plural(s.noFix, "finding has", "findings have"))
126+
}
127+
b.WriteString(line + ".\n")
128+
case s.noFix > 0:
129+
fmt.Fprintf(b, " no published fixes yet for any of the %d affected findings.\n", s.noFix)
130+
}
131+
if s.vexed > 0 {
132+
fmt.Fprintf(b, " (%d already answered by a vendor VEX statement, so not planned for.)\n", s.vexed)
133+
}
134+
if s.undetermined > 0 {
135+
fmt.Fprintf(b, " (%d undetermined finding(s) not shown; run the default report to review them.)\n", s.undetermined)
136+
}
137+
}
138+
102139
// upgrade is one action: move a package from its installed version to the
103140
// version that clears the vulnerabilities found on it. Several advisories
104141
// usually collapse into one upgrade, which is the whole point of the view.

fixplan_test.go

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
package main
22

33
import (
4+
"fmt"
45
"strings"
56
"testing"
67

78
"github.com/cwayne18/vexscan/internal/analyze"
89
"github.com/cwayne18/vexscan/internal/ecosystem"
10+
"github.com/cwayne18/vexscan/internal/target"
911
)
1012

1113
// fixplan renders a fix plan from a set of findings, telling the renderer which
@@ -82,7 +84,9 @@ func TestFixPlanSeparatesNoFix(t *testing.T) {
8284
t.Errorf("NO FIX YET should hold zlib1g only:\n%s", out)
8385
}
8486
lineWith(t, out, "1 of 2 affected findings have a fix.")
85-
lineWith(t, out, "upgrading 1 package clears 1 advisory; 1 with no fix yet.")
87+
// Every count names its unit: the 1 that is cleared is an advisory and the
88+
// 1 that is not is a finding, and the sentence has to say so.
89+
lineWith(t, out, "upgrading 1 package clears 1 advisory; 1 finding has no fix yet.")
8690
}
8791

8892
// TestFixPlanDoesNotCollapseUnorderableEcosystem is the safety rule: where the
@@ -120,6 +124,73 @@ func TestFixPlanNothingFixable(t *testing.T) {
120124
}
121125
}
122126

127+
// The fix plan's footer has to be its own. writeFooter repeats the main
128+
// report's summary and its section index, which named AFFECTED and RULED OUT
129+
// under a document whose only headings are UPGRADE and NO FIX YET -- an index
130+
// of sections the reader cannot find anywhere above it.
131+
func TestTheFixPlanFooterDoesNotIndexSectionsItDoesNotHave(t *testing.T) {
132+
var findings []analyze.Finding
133+
for i := 0; i < 40; i++ {
134+
findings = append(findings, linked(
135+
fmt.Sprintf("CVE-2023-%04d", i), fmt.Sprintf("pkg%02d", i),
136+
"1.0-1", "1.0-2", "HIGH"))
137+
}
138+
out := fixplan(t, map[string][]string{"os": {"Debian:12"}}, findings...)
139+
if n := strings.Count(out, "\n"); n <= footerThreshold {
140+
t.Fatalf("this test needs a report past the footer threshold, got %d lines", n)
141+
}
142+
if strings.Contains(out, "section(s)") {
143+
t.Errorf("the fix plan indexed sections it does not contain:\n%s", out)
144+
}
145+
// What it repeats instead is its own count of itself, at both ends.
146+
if n := strings.Count(out, "affected findings have a fix."); n != 2 {
147+
t.Errorf("want the fix summary at both ends, got %d:\n%s", n, out)
148+
}
149+
}
150+
151+
// The section index does not belong in a fix plan, but a caveat does. "Part of
152+
// the target could not be read" is a fact about the scan and not about the
153+
// view, and a long report hiding its own header is the entire reason the footer
154+
// exists -- so dropping writeFooter must not drop that with it.
155+
func TestTheFixPlanFooterRepeatsTheCaveats(t *testing.T) {
156+
var findings []analyze.Finding
157+
for i := 0; i < 40; i++ {
158+
findings = append(findings, linked(
159+
fmt.Sprintf("CVE-2023-%04d", i), fmt.Sprintf("pkg%02d", i),
160+
"1.0-1", "1.0-2", "HIGH"))
161+
}
162+
out := renderFixPlan(&analyze.Result{
163+
SchemaVersion: analyze.SchemaVersion,
164+
Target: "debian:12",
165+
Mode: "image",
166+
Ecosystems: []ecosystem.EcosystemResult{{ID: "os", Ecosystems: []string{"Debian:12"}, Components: 1}},
167+
Findings: findings,
168+
Unreadable: &target.Unreadable{Count: 3, Paths: []string{"/var/lib/private"}},
169+
})
170+
if n := strings.Count(out, "INCOMPLETE: 3 path(s) could not be read"); n != 2 {
171+
t.Errorf("want the INCOMPLETE banner at both ends, got %d:\n%s", n, out)
172+
}
173+
}
174+
175+
// A remediation view that silently omits rows is the one kind of report whose
176+
// shortness reads as good news. Everything the plan declines to plan for gets
177+
// counted somewhere.
178+
func TestTheFixPlanAccountsForWhatItDoesNotPlan(t *testing.T) {
179+
vexed := linked("CVE-2023-0001", "libc6", "2.36-9", "2.36-9+deb12u1", "HIGH")
180+
vexed.VEX = &ecosystem.VEXStatement{Status: "not_affected", Author: "SUSE"}
181+
undetermined := linked("CVE-2023-0002", "zlib1g", "1.2.13", "", "UNKNOWN")
182+
undetermined.Status = analyze.StatusUndetermined
183+
184+
out := fixplan(t, map[string][]string{"os": {"Debian:12"}},
185+
linked("CVE-2023-0003", "perl-base", "5.36.0-7", "5.36.0-7+deb12u3", "HIGH"),
186+
vexed, undetermined)
187+
188+
lineWith(t, out, "1 already answered by a vendor VEX statement")
189+
lineWith(t, out, "1 undetermined finding(s) not shown")
190+
// And neither is counted as work: the plan is the one remaining row.
191+
lineWith(t, out, "1 of 1 affected findings have a fix.")
192+
}
193+
123194
func containsRow(lines []string, want string) bool {
124195
for _, l := range lines {
125196
if strings.Contains(l, want) {

internal/analyze/analyze.go

Lines changed: 12 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1018,7 +1018,7 @@ func (r *advisoryResolver) severities() map[string]severity {
10181018
}
10191019

10201020
// fixedVersions flattens the fixed versions the resolver fetched into a lookup
1021-
// from advisory id to a per-package map of the version its patch lands in.
1021+
// from advisory id to a per-package set of the versions its patch landed in.
10221022
//
10231023
// It is a nested map because a fixed version is a claim about a package, not
10241024
// about an advisory: one CVE fixed in gcc-12 says nothing about the version
@@ -1027,10 +1027,14 @@ func (r *advisoryResolver) severities() map[string]severity {
10271027
// row sits; a fix cannot, so the package name is kept as a second key and the
10281028
// overlay joins on it.
10291029
//
1030+
// The OSV ecosystem is carried down with the versions rather than left behind,
1031+
// because it is the only thing that says which comparator can order them and
1032+
// the overlay works from Findings, which do not record it. See fixCandidates.
1033+
//
10301034
// Like severities() this costs no network traffic: every affected range was in
10311035
// the records already fetched to decide the findings existed.
1032-
func (r *advisoryResolver) fixedVersions() map[string]map[string]string {
1033-
out := map[string]map[string]string{}
1036+
func (r *advisoryResolver) fixedVersions() map[string]map[string]fixCandidates {
1037+
out := map[string]map[string]fixCandidates{}
10341038
for _, set := range r.cache {
10351039
for _, adv := range set {
10361040
if adv == nil || len(adv.Fixed) == 0 {
@@ -1042,18 +1046,21 @@ func (r *advisoryResolver) fixedVersions() map[string]map[string]string {
10421046
}
10431047
byPkg, ok := out[key]
10441048
if !ok {
1045-
byPkg = map[string]string{}
1049+
byPkg = map[string]fixCandidates{}
10461050
out[key] = byPkg
10471051
}
10481052
for pkg, fixed := range adv.Fixed {
1049-
byPkg[pkg] = fixed
1053+
byPkg[pkg] = fixCandidates{ecosystem: adv.Ecosystem, versions: fixed}
10501054
}
10511055
}
10521056
}
10531057
}
10541058
return out
10551059
}
10561060

1061+
// aliases maps every id an advisory is known by onto that advisory's whole set
1062+
// of ids.
1063+
//
10571064
// It exists for the VEX overlay. A finding names its advisory by whichever id
10581065
// its plugin was working from -- the Go plugin reports GO-2025-3547 -- while a
10591066
// hub files under whichever its own scanner used, which for every hub seen so
@@ -1201,40 +1208,6 @@ func severityOverlay(findings []Finding, sev map[string]severity) {
12011208
}
12021209
}
12031210

1204-
// fixedOverlay labels each finding with the version its advisory's patch lands
1205-
// in, in place. It runs beside severityOverlay for the same reason: a plugin
1206-
// answers a presence question and never sees the affected ranges the fix lives
1207-
// in, so the join has to happen where the advisories are.
1208-
//
1209-
// The join is on advisory id and then on package name. The package key is the
1210-
// finding's Package -- the source name OSV files its affected entry against,
1211-
// which is why two binaries built from one source (libgcc-s1, libstdc++6 from
1212-
// gcc-12) both resolve to the same fixed source version. A finding whose
1213-
// advisory published no fix for its package keeps an empty FixedVersion, which
1214-
// the renderer shows as "no fix" rather than a blank.
1215-
func fixedOverlay(findings []Finding, fixed map[string]map[string]string) {
1216-
for i := range findings {
1217-
f := &findings[i]
1218-
for _, key := range []string{f.CVE, f.ID, f.GoID} {
1219-
if key == "" {
1220-
continue
1221-
}
1222-
byPkg, ok := fixed[key]
1223-
if !ok {
1224-
continue
1225-
}
1226-
if v, ok := byPkg[f.Package]; ok {
1227-
f.FixedVersion = v
1228-
break
1229-
}
1230-
if v, ok := byPkg[f.Component()]; ok {
1231-
f.FixedVersion = v
1232-
break
1233-
}
1234-
}
1235-
}
1236-
}
1237-
12381211
// queryNames is the component's OSV names, primary first, deduplicated.
12391212
func queryNames(c ecosystem.Component) []string {
12401213
var out []string

0 commit comments

Comments
 (0)