Skip to content

Commit a27f2ea

Browse files
authored
test(engine): strengthen vulnerability consolidation assurance (#299)
Exercise matcher-order independence, conservative evidence merging, package isolation, and deterministic tie-breaking. Add a bounded native fuzz target and scaled benchmark, and include the target in the nightly fuzz matrix.
1 parent fbcbaa3 commit a27f2ea

3 files changed

Lines changed: 342 additions & 0 deletions

File tree

dev-docs/CI.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ Bomly runs native Go fuzzing nightly and on demand through the `Fuzz` workflow.
110110

111111
- Node lockfile parsers for npm, pnpm, and Yarn
112112
- SPDX/CycloneDX SBOM JSON detection and decoding
113+
- Cross-matcher vulnerability alias consolidation and evidence preservation
113114
- SDK package URL canonicalization and transport JSON
114115
- Managed plugin archive-name and relative-path sanitizers
115116

internal/engine/vulnerability_consolidation_test.go

Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
package engine
22

33
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"slices"
8+
"strings"
49
"testing"
510

611
"github.com/bomly-dev/bomly-cli/sdk"
@@ -76,6 +81,341 @@ func TestConsolidateVulnerabilitiesUsesTransitiveAliasesButNotRelatedIDs(t *test
7681
}
7782
}
7883

84+
func TestEngineMatchConsolidationIsIndependentOfMatcherOrder(t *testing.T) {
85+
const purl = "pkg:golang/example.test/module@v1.0.0"
86+
contributions := []sdk.Vulnerability{
87+
{
88+
ID: "GHSA-aaaa-bbbb-cccc", Aliases: []string{" CVE-2026-0001 "},
89+
Source: "first", FixedVersions: []string{"1.0.1"},
90+
},
91+
{
92+
ID: "GO-2026-0001", Aliases: []string{"cve-2026-0001", "GHSA-aaaa-bbbb-cccc"},
93+
Source: "second", Title: "Detailed advisory", Summary: "A detailed summary",
94+
Details: "A detailed description that makes this the canonical record.",
95+
ParsedSeverity: sdk.SeverityHigh, FixState: sdk.FixStateNotFixed,
96+
},
97+
{
98+
ID: "CVE-2026-0001", Aliases: []string{"GO-2026-0001"},
99+
Source: "third", References: []sdk.Reference{{URL: "https://example.test/advisory"}},
100+
KEVExploited: true,
101+
},
102+
}
103+
104+
var canonical []byte
105+
for _, order := range permutations(len(contributions)) {
106+
components := newTestRegistry()
107+
for matcherIndex, contributionIndex := range order {
108+
contribution := contributions[contributionIndex].Clone()
109+
components.registerMatcher(fakeMatcher{
110+
name: fmt.Sprintf("matcher-%d", matcherIndex),
111+
run: func(registry *sdk.PackageRegistry) {
112+
pkg := registry.Ensure(purl)
113+
pkg.Vulnerabilities = append(pkg.Vulnerabilities, contribution.Clone())
114+
},
115+
})
116+
}
117+
result, err := NewEngine(components).Match(context.Background(), MatchRequest{
118+
Graph: sdk.New(), Registry: sdk.NewPackageRegistry(),
119+
})
120+
if err != nil {
121+
t.Fatalf("order %v: Match() error = %v", order, err)
122+
}
123+
pkg, ok := result.Registry.Get(purl)
124+
if !ok || len(pkg.Vulnerabilities) != 1 {
125+
t.Fatalf("order %v: package vulnerabilities = %#v", order, pkg)
126+
}
127+
if result.VulnerabilitiesConsolidated != 2 {
128+
t.Fatalf("order %v: consolidated count = %d, want 2", order, result.VulnerabilitiesConsolidated)
129+
}
130+
encoded, err := json.Marshal(pkg.Vulnerabilities)
131+
if err != nil {
132+
t.Fatal(err)
133+
}
134+
if canonical == nil {
135+
canonical = encoded
136+
continue
137+
}
138+
if string(encoded) != string(canonical) {
139+
t.Fatalf("matcher order changed consolidation:\nfirst: %s\norder %v: %s", canonical, order, encoded)
140+
}
141+
}
142+
}
143+
144+
func TestConsolidateVulnerabilitiesPreservesAllEvidenceConservatively(t *testing.T) {
145+
hops := 1
146+
values := []sdk.Vulnerability{
147+
{
148+
ID: "GO-2026-0002",
149+
Aliases: []string{"CVE-2026-0002"},
150+
Related: []string{"CVE-2026-9999"},
151+
Summary: "rich summary",
152+
Details: "rich details",
153+
Published: "2026-01-01",
154+
Modified: "2026-02-01",
155+
Source: "osv",
156+
DataSource: "https://example.test/osv",
157+
Namespace: "go",
158+
Title: "rich title",
159+
Reasons: []string{"reason one"},
160+
Severity: []sdk.Severity{{
161+
Type: sdk.SeverityTypeCVSSV3, Score: "CVSS:3.1/example",
162+
}},
163+
Affected: []sdk.Affected{{
164+
Versions: []string{"1.0.0"},
165+
Ranges: []sdk.VersionRange{{
166+
Type: sdk.VersionRangeTypeSemver,
167+
Events: []sdk.RangeEvent{{Introduced: "0"}, {Fixed: "1.0.1"}},
168+
}},
169+
DatabaseSpecific: map[string]any{"range": "primary"},
170+
}},
171+
References: []sdk.Reference{{URL: "https://example.test/one", Type: sdk.ReferenceTypeAdvisory}},
172+
ParsedSeverity: sdk.SeverityHigh,
173+
SeveritySource: "osv",
174+
CVSS: []sdk.CVSSScore{{Vector: "vector-one", Score: 8.1}},
175+
EPSS: []sdk.EPSSScore{{CVE: "CVE-2026-0002", EPSS: 0.4}},
176+
CWEs: []sdk.CWE{{ID: "CWE-20"}},
177+
KnownExploited: []sdk.KnownExploited{{CVE: "CVE-2026-0002", URLs: []string{"https://example.test/kev"}}},
178+
RiskScore: 70,
179+
FixState: sdk.FixStateFixed,
180+
FixedIn: "1.0.1",
181+
FixedVersions: []string{"1.0.1"},
182+
FixAvailable: []sdk.FixAvailable{{Version: "1.0.1", Kind: sdk.FixAvailableFirstObserved}},
183+
AffectedVersionRange: "< 1.0.1",
184+
CPEs: []string{"cpe:/a:example:module"},
185+
AffectedSymbols: []sdk.AffectedSymbol{{
186+
Symbol: "Parse", Kind: sdk.SymbolKindFunction,
187+
Definition: &sdk.SourcePosition{File: "parse.go", Line: 10},
188+
}},
189+
DatabaseSpecific: map[string]any{"primary": true},
190+
Reachability: &sdk.Reachability{
191+
Status: sdk.ReachabilityUnreachable, Tier: sdk.TierSymbol,
192+
Analyzer: "go", Hops: &hops,
193+
},
194+
},
195+
{
196+
ID: "GHSA-dddd-eeee-ffff",
197+
Aliases: []string{"cve-2026-0002"},
198+
Related: []string{"CVE-2026-8888"},
199+
Withdrawn: "2026-03-01",
200+
Reasons: []string{"reason two"},
201+
References: []sdk.Reference{{URL: "https://example.test/two"}},
202+
ParsedSeverity: sdk.SeverityCritical,
203+
CVSS: []sdk.CVSSScore{{Vector: "vector-two", Score: 9.8}},
204+
EPSS: []sdk.EPSSScore{{CVE: "CVE-2026-0002", EPSS: 0.9}},
205+
CWEs: []sdk.CWE{{ID: "CWE-787"}},
206+
KEVExploited: true,
207+
RiskScore: 95,
208+
FixState: sdk.FixStateWontFix,
209+
FixedVersions: []string{"1.0.2"},
210+
FixAvailable: []sdk.FixAvailable{{Version: "1.0.2"}},
211+
CPEs: []string{"cpe:/a:example:other"},
212+
DatabaseSpecific: map[string]any{
213+
"secondary": true,
214+
"primary": false,
215+
},
216+
Reachability: &sdk.Reachability{Status: sdk.ReachabilityReachable, Tier: sdk.TierPackage},
217+
},
218+
}
219+
before, err := json.Marshal(values)
220+
if err != nil {
221+
t.Fatal(err)
222+
}
223+
224+
got := consolidateVulnerabilities(values)
225+
if len(got) != 1 {
226+
t.Fatalf("consolidated vulnerabilities = %d, want 1", len(got))
227+
}
228+
merged := got[0]
229+
if merged.ID != "GO-2026-0002" || merged.Source != "osv" {
230+
t.Fatalf("canonical record = %#v", merged)
231+
}
232+
if merged.Withdrawn != "2026-03-01" {
233+
t.Fatalf("missing supplementary scalar evidence: %#v", merged)
234+
}
235+
if merged.ParsedSeverity != sdk.SeverityCritical || merged.RiskScore != 95 ||
236+
merged.FixState != sdk.FixStateWontFix || !merged.KEVExploited {
237+
t.Fatalf("non-conservative conflict result: %#v", merged)
238+
}
239+
if merged.Reachability == nil || merged.Reachability.Status != sdk.ReachabilityReachable {
240+
t.Fatalf("reachability = %#v, want reachable", merged.Reachability)
241+
}
242+
for name, count := range map[string]int{
243+
"related": len(merged.Related), "reasons": len(merged.Reasons),
244+
"references": len(merged.References), "cvss": len(merged.CVSS),
245+
"epss": len(merged.EPSS), "cwes": len(merged.CWEs),
246+
"fixed_versions": len(merged.FixedVersions), "fix_available": len(merged.FixAvailable),
247+
"cpes": len(merged.CPEs),
248+
} {
249+
if count != 2 {
250+
t.Errorf("%s count = %d, want 2", name, count)
251+
}
252+
}
253+
if len(merged.Severity) != 1 || len(merged.Affected) != 1 ||
254+
len(merged.KnownExploited) != 1 || len(merged.AffectedSymbols) != 1 {
255+
t.Fatalf("structured evidence was lost: %#v", merged)
256+
}
257+
if merged.DatabaseSpecific["primary"] != true || merged.DatabaseSpecific["secondary"] != true {
258+
t.Fatalf("database_specific merge = %#v", merged.DatabaseSpecific)
259+
}
260+
261+
merged.Aliases[0] = "mutated"
262+
merged.Affected[0].Versions[0] = "mutated"
263+
merged.KnownExploited[0].URLs[0] = "mutated"
264+
merged.AffectedSymbols[0].Definition.File = "mutated"
265+
merged.DatabaseSpecific["primary"] = "mutated"
266+
merged.Reachability.Status = sdk.ReachabilityUnknown
267+
after, err := json.Marshal(values)
268+
if err != nil {
269+
t.Fatal(err)
270+
}
271+
if string(after) != string(before) {
272+
t.Fatalf("consolidation output aliases input:\nbefore: %s\nafter: %s", before, after)
273+
}
274+
}
275+
276+
func TestConsolidateRegistryVulnerabilitiesKeepsPackagesAndVersionsIsolated(t *testing.T) {
277+
registry := sdk.NewPackageRegistry()
278+
for _, purl := range []string{
279+
"pkg:npm/example@1.0.0",
280+
"pkg:npm/example@2.0.0",
281+
"pkg:npm/other@1.0.0",
282+
} {
283+
registry.Ensure(purl).Vulnerabilities = []sdk.Vulnerability{
284+
{ID: "CVE-2026-0003", Aliases: []string{"GHSA-1111-2222-3333"}},
285+
{ID: "GHSA-1111-2222-3333"},
286+
}
287+
}
288+
289+
before, after := consolidateRegistryVulnerabilities(registry)
290+
if before != 6 || after != 3 {
291+
t.Fatalf("registry counts = %d -> %d, want 6 -> 3", before, after)
292+
}
293+
for _, pkg := range registry.All() {
294+
if len(pkg.Vulnerabilities) != 1 {
295+
t.Fatalf("%s vulnerabilities = %#v", pkg.ID, pkg.Vulnerabilities)
296+
}
297+
}
298+
}
299+
300+
func TestConsolidateVulnerabilitiesUsesDeterministicRichnessTieBreakers(t *testing.T) {
301+
values := []sdk.Vulnerability{
302+
{ID: "Z-ADVISORY", Aliases: []string{"SHARED"}, Source: "z-source", Summary: "z"},
303+
{ID: "A-ADVISORY", Aliases: []string{"shared"}, Source: "a-source", Summary: "a"},
304+
}
305+
for _, order := range [][]sdk.Vulnerability{values, {values[1], values[0]}} {
306+
got := consolidateVulnerabilities(order)
307+
if len(got) != 1 || got[0].ID != "A-ADVISORY" || got[0].Summary != "a" {
308+
t.Fatalf("tie-break result = %#v", got)
309+
}
310+
}
311+
}
312+
313+
func FuzzConsolidateVulnerabilities(f *testing.F) {
314+
for _, seed := range [][]sdk.Vulnerability{
315+
{{ID: "ADV-1", Aliases: []string{"ALIAS-1"}}, {ID: "ADV-2", Aliases: []string{"ADV-1"}}},
316+
{{ID: "ADV-1", Related: []string{"ADV-2"}}, {ID: "ADV-2"}},
317+
{{ID: " A "}, {ID: "a", Aliases: []string{"B"}}, {ID: "B"}},
318+
} {
319+
data, err := json.Marshal(seed)
320+
if err != nil {
321+
f.Fatal(err)
322+
}
323+
f.Add(data)
324+
}
325+
326+
f.Fuzz(func(t *testing.T, data []byte) {
327+
if len(data) > 64<<10 {
328+
return
329+
}
330+
var values []sdk.Vulnerability
331+
if err := json.Unmarshal(data, &values); err != nil || len(values) > 64 {
332+
return
333+
}
334+
before, err := json.Marshal(values)
335+
if err != nil {
336+
return
337+
}
338+
got := consolidateVulnerabilities(values)
339+
if len(got) > len(values) {
340+
t.Fatalf("consolidation grew input from %d to %d", len(values), len(got))
341+
}
342+
for left := range got {
343+
for right := left + 1; right < len(got); right++ {
344+
if identitySetsOverlap(vulnerabilityIdentities(got[left]), vulnerabilityIdentities(got[right])) {
345+
t.Fatalf("output groups overlap: %#v and %#v", got[left], got[right])
346+
}
347+
}
348+
}
349+
again := consolidateVulnerabilities(got)
350+
firstJSON, firstErr := json.Marshal(got)
351+
secondJSON, secondErr := json.Marshal(again)
352+
if firstErr != nil || secondErr != nil || string(firstJSON) != string(secondJSON) {
353+
t.Fatalf("consolidation is not idempotent:\nfirst: %s\nsecond: %s", firstJSON, secondJSON)
354+
}
355+
after, err := json.Marshal(values)
356+
if err != nil {
357+
t.Fatal(err)
358+
}
359+
if string(after) != string(before) {
360+
t.Fatal("consolidation mutated its input")
361+
}
362+
})
363+
}
364+
365+
func BenchmarkConsolidateVulnerabilities(b *testing.B) {
366+
for _, size := range []int{10, 100, 1000} {
367+
values := benchmarkVulnerabilities(size)
368+
b.Run(fmt.Sprintf("records-%d", size), func(b *testing.B) {
369+
b.ReportAllocs()
370+
for range b.N {
371+
result := consolidateVulnerabilities(values)
372+
if len(result) != (size+1)/2 {
373+
b.Fatalf("result count = %d, want %d", len(result), (size+1)/2)
374+
}
375+
}
376+
})
377+
}
378+
}
379+
380+
func benchmarkVulnerabilities(size int) []sdk.Vulnerability {
381+
values := make([]sdk.Vulnerability, 0, size)
382+
for idx := range size {
383+
component := idx / 2
384+
id := fmt.Sprintf("ADV-%05d-%d", component, idx%2)
385+
alias := fmt.Sprintf("CVE-2026-%05d", component)
386+
values = append(values, sdk.Vulnerability{
387+
ID: id, Aliases: []string{alias}, Source: fmt.Sprintf("source-%d", idx%3),
388+
Summary: strings.Repeat("x", idx%17),
389+
})
390+
}
391+
return values
392+
}
393+
394+
func permutations(size int) [][]int {
395+
if size == 0 {
396+
return [][]int{{}}
397+
}
398+
var out [][]int
399+
var visit func([]int, []int)
400+
visit = func(prefix, remaining []int) {
401+
if len(remaining) == 0 {
402+
out = append(out, slices.Clone(prefix))
403+
return
404+
}
405+
for idx, value := range remaining {
406+
next := append(slices.Clone(prefix), value)
407+
tail := append(slices.Clone(remaining[:idx]), remaining[idx+1:]...)
408+
visit(next, tail)
409+
}
410+
}
411+
remaining := make([]int, size)
412+
for idx := range size {
413+
remaining[idx] = idx
414+
}
415+
visit(nil, remaining)
416+
return out
417+
}
418+
79419
func sameStrings(left, right []string) bool {
80420
if len(left) != len(right) {
81421
return false

scripts/run-fuzz.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ targets=(
88
"github.com/bomly-dev/bomly-cli/internal/detectors/node/pnpm FuzzDepGraphFromPNPMLockfile"
99
"github.com/bomly-dev/bomly-cli/internal/detectors/node/yarn FuzzDepGraphFromYarnLockfile"
1010
"github.com/bomly-dev/bomly-cli/internal/sbom FuzzUnmarshalAutoJSON"
11+
"github.com/bomly-dev/bomly-cli/internal/engine FuzzConsolidateVulnerabilities"
1112
"github.com/bomly-dev/bomly-cli/sdk FuzzCanonicalizePackageURL"
1213
"github.com/bomly-dev/bomly-cli/sdk FuzzGraphJSON"
1314
"github.com/bomly-dev/bomly-cli/sdk FuzzPackageRegistryJSON"

0 commit comments

Comments
 (0)