Skip to content

Commit 92cdaa4

Browse files
committed
feat(scan): per-CVE reachability assessment tracking + scan summary line
- Track ReachabilityAssessed / ReachabilityQueryHashes per EnrichedVuln so the CLI knows which CVEs tree-sitter actually evaluated (vs. no-data) - Only forward UNREACHABLE rows to the snapshot when reachability was actually assessed; stop manufacturing UNREACHABLE/SEMANTIC rows with no evidence - Attach query hashes and routine/file/module evidence JSON to forwarded rows - Extract pure, unit-tested buildReachabilityPayloads from postReachabilityToSnapshot - Print a "Reachability: N assessed, R reachable, ..." line in the pretty scan summary when reachability ran
1 parent 79dcd82 commit 92cdaa4

4 files changed

Lines changed: 243 additions & 26 deletions

File tree

cmd/cli_sca.go

Lines changed: 57 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"context"
1414
"crypto/sha256"
1515
"encoding/hex"
16+
"encoding/json"
1617
"fmt"
1718
"os"
1819
"path/filepath"
@@ -307,22 +308,9 @@ func introducedViaChain(p scan.ScopedPackage, key string, manifestGroups []scan.
307308
//
308309
// Memory-yaml VEX hints are attached so user-authored decisions win over the
309310
// auto-computed verdict.
310-
func postReachabilityToSnapshot(client *vdb.Client, env vdb.CliEnv, snapshot *vdb.CliIngestionSnapshot, persisted []vdb.CliFindingResult, enriched []scan.EnrichedVuln, gitCtx *gitctx.GitContext) {
311-
if snapshot == nil || len(enriched) == 0 {
312-
return
313-
}
314-
315-
// Map (cveId|packageName|packageVer) → findingUuid for fast lookup.
316-
findingByKey := make(map[string]vdb.CliFindingResult, len(persisted))
317-
for _, f := range persisted {
318-
k := f.FindingID + "|" + f.PackageName + "|" + f.PackageVersion
319-
findingByKey[k] = f
320-
}
321-
322-
// Memory-yaml lookup — best-effort. The CLI already merges this into
323-
// enriched but we need the raw status fields to forward upstream.
324-
memRecords := loadMemoryRecords(gitCtx)
325-
311+
// buildReachabilityPayloads turns enriched vulns into CliReachabilityPayload
312+
// rows. It is pure (no side effects, no network) so it is unit-testable.
313+
func buildReachabilityPayloads(enriched []scan.EnrichedVuln, findingByKey map[string]vdb.CliFindingResult, memRecords map[string]memory.FindingRecord) []vdb.CliReachabilityPayload {
326314
payloads := make([]vdb.CliReachabilityPayload, 0, len(enriched)*2)
327315
for _, ev := range enriched {
328316
fkey := ev.CveID + "|" + ev.PackageName + "|" + ev.PackageVer
@@ -349,21 +337,19 @@ func postReachabilityToSnapshot(client *vdb.Client, env vdb.CliEnv, snapshot *vd
349337

350338
switch ev.Reachability {
351339
case "direct", "transitive":
352-
// Tree-sitter verdict — emit one row per affected routine if any.
353340
row := base
354341
row.Source = "TREE_SITTER"
355342
row.Verdict = strings.ToUpper(ev.Reachability)
356-
if len(ev.AffectedSymbols.Routines) > 0 {
343+
if ev.AffectedSymbols != nil && len(ev.AffectedSymbols.Routines) > 0 {
357344
row.MatchedRoutine = ev.AffectedSymbols.Routines[0]
358345
}
346+
if len(ev.ReachabilityQueryHashes) > 0 {
347+
row.QueryHash = ev.ReachabilityQueryHashes[0]
348+
}
359349
payloads = append(payloads, row)
360350
case "semantic":
361-
// Symbol-fallback hits — one row per match.
362351
if len(ev.SemanticMatches) == 0 {
363-
row := base
364-
row.Source = "SYMBOL_FALLBACK"
365-
row.Verdict = "SEMANTIC"
366-
payloads = append(payloads, row)
352+
break
367353
}
368354
for _, m := range ev.SemanticMatches {
369355
row := base
@@ -374,15 +360,47 @@ func postReachabilityToSnapshot(client *vdb.Client, env vdb.CliEnv, snapshot *vd
374360
row.MatchStartLine = m.Line
375361
payloads = append(payloads, row)
376362
}
377-
default:
378-
// No reachability verdict — treat as UNREACHABLE so the server
379-
// records a not_affected/code_not_reachable VEX statement.
363+
case "unreachable":
364+
if !ev.ReachabilityAssessed {
365+
break
366+
}
380367
row := base
381368
row.Source = "TREE_SITTER"
382369
row.Verdict = "UNREACHABLE"
370+
if len(ev.ReachabilityQueryHashes) > 0 {
371+
row.QueryHash = ev.ReachabilityQueryHashes[0]
372+
}
373+
if ev.AffectedSymbols != nil && (len(ev.AffectedSymbols.Routines) > 0 || len(ev.AffectedSymbols.Files) > 0 || len(ev.AffectedSymbols.Modules) > 0) {
374+
evidence := map[string]any{
375+
"routines": ev.AffectedSymbols.Routines,
376+
"files": ev.AffectedSymbols.Files,
377+
"modules": ev.AffectedSymbols.Modules,
378+
}
379+
if b, err := json.Marshal(evidence); err == nil {
380+
row.EvidenceJSON = string(b)
381+
}
382+
}
383383
payloads = append(payloads, row)
384+
default:
385+
// Empty / unassessed — emit no row.
384386
}
385387
}
388+
return payloads
389+
}
390+
391+
func postReachabilityToSnapshot(client *vdb.Client, env vdb.CliEnv, snapshot *vdb.CliIngestionSnapshot, persisted []vdb.CliFindingResult, enriched []scan.EnrichedVuln, gitCtx *gitctx.GitContext) {
392+
if snapshot == nil || len(enriched) == 0 {
393+
return
394+
}
395+
396+
findingByKey := make(map[string]vdb.CliFindingResult, len(persisted))
397+
for _, f := range persisted {
398+
k := f.FindingID + "|" + f.PackageName + "|" + f.PackageVersion
399+
findingByKey[k] = f
400+
}
401+
402+
memRecords := loadMemoryRecords(gitCtx)
403+
payloads := buildReachabilityPayloads(enriched, findingByKey, memRecords)
386404

387405
if len(payloads) == 0 {
388406
return
@@ -608,6 +626,19 @@ func runReachabilityForFindings(hits []vdb.CliReachabilityHit, enriched []scan.E
608626

609627
for i := range enriched {
610628
cve := enriched[i].CveID
629+
if evaluatedCVEs[cve] {
630+
enriched[i].ReachabilityAssessed = true
631+
}
632+
// Collect query hashes for this CVE
633+
var hashes []string
634+
for _, e := range byHash {
635+
if e.cves[cve] {
636+
hashes = append(hashes, e.query.QueryHash)
637+
}
638+
}
639+
if len(hashes) > 0 {
640+
enriched[i].ReachabilityQueryHashes = hashes
641+
}
611642
switch {
612643
case reachableCVEs[cve]:
613644
enriched[i].Reachability = "transitive"

cmd/cli_sca_reachability_test.go

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package cmd
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/vulnetix/cli/v3/internal/memory"
8+
"github.com/vulnetix/cli/v3/internal/scan"
9+
"github.com/vulnetix/cli/v3/pkg/vdb"
10+
)
11+
12+
func TestBuildReachabilityPayloads_EmptyReachabilityEmitsNoRow(t *testing.T) {
13+
enriched := []scan.EnrichedVuln{
14+
{VulnFinding: scan.VulnFinding{CveID: "CVE-2024-0001", PackageName: "lodash", PackageVer: "4.17.20"}, Reachability: ""},
15+
}
16+
payloads := buildReachabilityPayloads(enriched, nil, nil)
17+
assert.Empty(t, payloads, "empty reachability should emit no row")
18+
}
19+
20+
func TestBuildReachabilityPayloads_UnreachableAssessedEmitsRowWithQueryHash(t *testing.T) {
21+
enriched := []scan.EnrichedVuln{
22+
{
23+
VulnFinding: scan.VulnFinding{CveID: "CVE-2024-0001", PackageName: "lodash", PackageVer: "4.17.20"},
24+
Reachability: "unreachable", ReachabilityAssessed: true,
25+
ReachabilityQueryHashes: []string{"abc123"},
26+
},
27+
}
28+
payloads := buildReachabilityPayloads(enriched, nil, nil)
29+
assert.Len(t, payloads, 1)
30+
assert.Equal(t, "UNREACHABLE", payloads[0].Verdict)
31+
assert.Equal(t, "TREE_SITTER", payloads[0].Source)
32+
assert.Equal(t, "abc123", payloads[0].QueryHash)
33+
}
34+
35+
func TestBuildReachabilityPayloads_UnreachableNotAssessedEmitsNoRow(t *testing.T) {
36+
enriched := []scan.EnrichedVuln{
37+
{
38+
VulnFinding: scan.VulnFinding{CveID: "CVE-2024-0001", PackageName: "lodash", PackageVer: "4.17.20"},
39+
Reachability: "unreachable", ReachabilityAssessed: false,
40+
},
41+
}
42+
payloads := buildReachabilityPayloads(enriched, nil, nil)
43+
assert.Empty(t, payloads, "unreachable without assessed flag should emit no row")
44+
}
45+
46+
func TestBuildReachabilityPayloads_MemoryVexWithEmptyReachabilityEmitsNoRow(t *testing.T) {
47+
enriched := []scan.EnrichedVuln{
48+
{
49+
VulnFinding: scan.VulnFinding{CveID: "CVE-2024-0001", PackageName: "lodash", PackageVer: "4.17.20"},
50+
Reachability: "",
51+
},
52+
}
53+
memRecords := map[string]memory.FindingRecord{
54+
"CVE-2024-0001": {Status: "not_affected", Justification: "vulnerable_code_not_present", Package: "lodash"},
55+
}
56+
payloads := buildReachabilityPayloads(enriched, nil, memRecords)
57+
assert.Empty(t, payloads, "memory VEX + empty reachability should not manufacture UNREACHABLE row")
58+
}
59+
60+
func TestBuildReachabilityPayloads_SemanticWithMatchesEmitsRows(t *testing.T) {
61+
enriched := []scan.EnrichedVuln{
62+
{
63+
VulnFinding: scan.VulnFinding{CveID: "CVE-2024-0001", PackageName: "lodash", PackageVer: "4.17.20"},
64+
Reachability: "semantic",
65+
SemanticMatches: []scan.SemanticMatch{
66+
{File: "src/app.js", Line: 42, Symbol: "merge", Kind: "routine"},
67+
},
68+
},
69+
}
70+
payloads := buildReachabilityPayloads(enriched, nil, nil)
71+
assert.Len(t, payloads, 1)
72+
assert.Equal(t, "SEMANTIC", payloads[0].Verdict)
73+
assert.Equal(t, "SEMANTIC_GREP", payloads[0].Source)
74+
assert.Equal(t, "src/app.js", payloads[0].MatchedFile)
75+
assert.Equal(t, "merge", payloads[0].MatchedRoutine)
76+
assert.Equal(t, 42, payloads[0].MatchStartLine)
77+
}
78+
79+
func TestBuildReachabilityPayloads_SemanticEmptyMatchesEmitsNoRow(t *testing.T) {
80+
enriched := []scan.EnrichedVuln{
81+
{
82+
VulnFinding: scan.VulnFinding{CveID: "CVE-2024-0001", PackageName: "lodash", PackageVer: "4.17.20"},
83+
Reachability: "semantic",
84+
SemanticMatches: []scan.SemanticMatch{},
85+
},
86+
}
87+
payloads := buildReachabilityPayloads(enriched, nil, nil)
88+
assert.Empty(t, payloads, "semantic with no matches should emit no row")
89+
}
90+
91+
func TestBuildReachabilityPayloads_DirectEmitsRow(t *testing.T) {
92+
enriched := []scan.EnrichedVuln{
93+
{
94+
VulnFinding: scan.VulnFinding{CveID: "CVE-2024-0001", PackageName: "lodash", PackageVer: "4.17.20"},
95+
Reachability: "direct",
96+
ReachabilityAssessed: true,
97+
ReachabilityQueryHashes: []string{"hash1"},
98+
AffectedSymbols: &scan.AffectedSymbols{
99+
Routines: []string{"merge"},
100+
},
101+
},
102+
}
103+
payloads := buildReachabilityPayloads(enriched, nil, nil)
104+
assert.Len(t, payloads, 1)
105+
assert.Equal(t, "DIRECT", payloads[0].Verdict)
106+
assert.Equal(t, "TREE_SITTER", payloads[0].Source)
107+
assert.Equal(t, "hash1", payloads[0].QueryHash)
108+
assert.Equal(t, "merge", payloads[0].MatchedRoutine)
109+
}
110+
111+
func TestBuildReachabilityPayloads_FindingUuidLookup(t *testing.T) {
112+
enriched := []scan.EnrichedVuln{
113+
{VulnFinding: scan.VulnFinding{CveID: "CVE-2024-0001", PackageName: "lodash", PackageVer: "4.17.20"}, Reachability: "transitive", ReachabilityAssessed: true},
114+
}
115+
findingByKey := map[string]vdb.CliFindingResult{
116+
"CVE-2024-0001|lodash|4.17.20": {FindingUuid: "find-uuid-1", Purl: "pkg:npm/lodash@4.17.20"},
117+
}
118+
payloads := buildReachabilityPayloads(enriched, findingByKey, nil)
119+
assert.Len(t, payloads, 1)
120+
assert.Equal(t, "find-uuid-1", payloads[0].FindingUuid)
121+
assert.Equal(t, "pkg:npm/lodash@4.17.20", payloads[0].Purl)
122+
}
123+
124+
func TestBuildReachabilityPayloads_MemoryVexForwardedOnRow(t *testing.T) {
125+
enriched := []scan.EnrichedVuln{
126+
{
127+
VulnFinding: scan.VulnFinding{CveID: "CVE-2024-0001", PackageName: "lodash", PackageVer: "4.17.20"},
128+
Reachability: "transitive", ReachabilityAssessed: true,
129+
},
130+
}
131+
memRecords := map[string]memory.FindingRecord{
132+
"CVE-2024-0001": {Status: "not_affected", Justification: "vulnerable_code_not_present", ActionResponse: "upgrade", Package: "lodash"},
133+
}
134+
payloads := buildReachabilityPayloads(enriched, nil, memRecords)
135+
assert.Len(t, payloads, 1)
136+
assert.Equal(t, "not_affected", payloads[0].MemoryVexStatus)
137+
assert.Equal(t, "vulnerable_code_not_present", payloads[0].MemoryVexJustification)
138+
assert.Equal(t, "upgrade", payloads[0].MemoryVexAction)
139+
}

cmd/scan.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2347,6 +2347,13 @@ func printPrettyScanSummary(
23472347
totalPkgs := len(countUniqueMap(allPackages))
23482348
summary := fmt.Sprintf(" %d packages | %s", totalPkgs, pluralise("vulnerability", totalVulns))
23492349
fmt.Fprintln(os.Stdout, display.Bold(t, summary))
2350+
2351+
// Reachability summary (only when reachability ran).
2352+
if anyReachabilityAssessed(enrichedVulns) {
2353+
assessed, reachable, notReachable, notAssessable := countReachability(enrichedVulns)
2354+
fmt.Fprintf(os.Stdout, " Reachability: %d assessed, %d reachable, %d not reachable, %d not assessable/no data\n",
2355+
assessed, reachable, notReachable, notAssessable)
2356+
}
23502357
fmt.Fprintln(os.Stdout)
23512358

23522359
// Artefact paths.
@@ -2361,6 +2368,38 @@ func printPrettyScanSummary(
23612368
fmt.Fprintln(os.Stdout)
23622369
}
23632370

2371+
// anyReachabilityAssessed returns true if any vuln has ReachabilityAssessed set.
2372+
func anyReachabilityAssessed(vulns []scan.EnrichedVuln) bool {
2373+
for _, v := range vulns {
2374+
if v.ReachabilityAssessed {
2375+
return true
2376+
}
2377+
}
2378+
return false
2379+
}
2380+
2381+
// countReachability returns (assessed, reachable, notReachable, notAssessable)
2382+
// across all vulns. A vuln is "assessed" when ReachabilityAssessed is true and
2383+
// the verdict is reachable or unreachable. Unassessed counts as notAssessable.
2384+
func countReachability(vulns []scan.EnrichedVuln) (assessed, reachable, notReachable, notAssessable int) {
2385+
for _, v := range vulns {
2386+
if !v.ReachabilityAssessed {
2387+
notAssessable++
2388+
continue
2389+
}
2390+
assessed++
2391+
switch v.Reachability {
2392+
case "direct", "transitive", "semantic":
2393+
reachable++
2394+
case "unreachable":
2395+
notReachable++
2396+
default:
2397+
notAssessable++
2398+
}
2399+
}
2400+
return
2401+
}
2402+
23642403
// printCollapsedActions deduplicates actions and collapses groups that share a
23652404
// common prefix (e.g., "Apply Red Hat patch RHSA-2024:XXXX" × 33 → one line
23662405
// listing all advisory IDs).

internal/scan/enrich.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,14 @@ type EnrichedVuln struct {
7272
// empty — no analysis was performed (no data to compare).
7373
Reachability string
7474

75+
// ReachabilityAssessed is true when tree-sitter queries actually ran
76+
// for this CVE (i.e. the CVE was in the evaluated set).
77+
ReachabilityAssessed bool
78+
79+
// ReachabilityQueryHashes are the query hashes that ran for this CVE.
80+
// Populated when ReachabilityAssessed is true.
81+
ReachabilityQueryHashes []string
82+
7583
// AffectedSymbols is the symbol-level fallback returned by the API for
7684
// every tier. Populated whether or not tree-sitter queries existed.
7785
AffectedSymbols *AffectedSymbols

0 commit comments

Comments
 (0)