Skip to content

Commit 2c328dc

Browse files
authored
fix(osv): surface degraded batch enrichment (#301)
Return cached partial enrichment alongside a contextual matcher error when the OSV batch API fails. This lets the engine record a non-fatal pipeline warning so safety gates can distinguish complete enrichment from degraded results.
1 parent a27f2ea commit 2c328dc

2 files changed

Lines changed: 33 additions & 7 deletions

File tree

internal/matchers/osv/matcher.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -288,15 +288,20 @@ func (a *Matcher) Match(_ context.Context, req sdk.MatchRequest) (sdk.MatchResul
288288
}
289289
results, err := a.client.QueryBatch(queries)
290290
if err != nil {
291-
// Non-fatal: return what we have with a warning.
291+
// Return partial enrichment together with the error. The engine
292+
// degrades matcher failures into pipeline warnings while preserving
293+
// any cache-backed evidence already collected.
292294
a.logger.Warn("osv: batch query failed", zap.Error(err))
293295
if a.config.Stderr != nil {
294296
if _, werr := fmt.Fprintf(a.config.Stderr, "warn: osv query failed: %v\n", err); werr != nil {
295-
return sdk.MatchResult{}, werr
297+
return sdk.MatchResult{}, fmt.Errorf("osv write query warning: %w", werr)
296298
}
297299
}
298300
applyPackageVulnerabilityEnrichment(req.Registry, deps, enriched)
299-
return sdk.MatchResult{Registry: req.Registry, MatcherStats: osvMatcherStats(enriched, stats.requestedPackages)}, nil
301+
return sdk.MatchResult{
302+
Registry: req.Registry,
303+
MatcherStats: osvMatcherStats(enriched, stats.requestedPackages),
304+
}, fmt.Errorf("osv batch query: %w", err)
300305
}
301306

302307
for i, result := range results {

internal/matchers/osv/matcher_test.go

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,7 @@ func TestAudit_CacheHit_NoHTTPCall(t *testing.T) {
267267

268268
// --- OSV API failure ---
269269

270-
func TestAudit_OSVFailure_NonFatal(t *testing.T) {
270+
func TestAudit_OSVFailure_ReturnsPartialResultAndWarningError(t *testing.T) {
271271
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
272272
w.WriteHeader(http.StatusInternalServerError)
273273
}))
@@ -288,19 +288,40 @@ func TestAudit_OSVFailure_NonFatal(t *testing.T) {
288288
PURL: "pkg:npm/lodash@4.17.15",
289289
Ecosystem: "npm"},
290290
})
291+
cachedDep := sdk.NewDependency(sdk.Dependency{Coordinates: sdk.Coordinates{Name: "cached",
292+
Version: "1.0.0",
293+
PURL: "pkg:npm/cached@1.0.0",
294+
Ecosystem: "npm"},
295+
})
296+
cachedPURL := sdk.CanonicalPackageURLFromDependency(cachedDep)
297+
if err := audcache.Set(aud.cache, audcache.NewKey(cachedPURL, "", "", ""), []Vulnerability{{
298+
ID: "OSV-CACHED",
299+
Summary: "cached evidence",
300+
}}); err != nil {
301+
t.Fatalf("seed cache: %v", err)
302+
}
291303
g := sdk.New()
292304
if err := g.AddNode(dep); err != nil {
293305
t.Fatalf("AddNode: %v", err)
294306
}
307+
if err := g.AddNode(cachedDep); err != nil {
308+
t.Fatalf("AddNode cached dependency: %v", err)
309+
}
295310

296311
result, err := aud.Match(context.Background(), sdk.MatchRequest{
297312
Graph: g,
298313
Registry: sdk.NewPackageRegistry(),
299314
})
300-
if err != nil {
301-
t.Fatalf("Match returned error on API failure (should be non-fatal): %v", err)
315+
if err == nil || !strings.Contains(err.Error(), "osv batch query") {
316+
t.Fatalf("Match error = %v, want contextual batch-query error", err)
317+
}
318+
if result.Registry == nil {
319+
t.Fatal("Match discarded the partial registry on API failure")
320+
}
321+
pkg, ok := result.Registry.Get(cachedPURL)
322+
if !ok || len(pkg.Vulnerabilities) != 1 || pkg.Vulnerabilities[0].ID != "OSV-CACHED" {
323+
t.Fatalf("cached enrichment was not preserved: %#v, found=%t", pkg, ok)
302324
}
303-
_ = result // partial results are acceptable
304325
}
305326

306327
// --- KEV enrichment ---

0 commit comments

Comments
 (0)