Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions grype/db/internal/provider/unmarshal/osvmodel/anchore.go
Comment thread
wagoodman marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package osvmodel

import "encoding/json"

// AnchoreAffected is the grype-owned overlay stamped onto an OSV
// affected[].database_specific by vunnel before grype-db consumes the record.
// Anything outside this namespace is vendor-defined and read by per-provider
// extension types in the transformer package.
type AnchoreAffected struct {
// Status carries a normalized disposition that the upstream OSV record
// does not express directly. Currently observed: "wont-fix" (the provider
// explicitly will not patch this on this release).
Status string `json:"status,omitempty"`
}

// AnchoreRange is the grype-owned overlay on an
// affected[].ranges[].database_specific. Today it only carries
// fix-availability metadata sourced from vunnel's fix-date tracking.
type AnchoreRange struct {
Fixes []AnchoreFix `json:"fixes,omitempty"`
}

// AnchoreFix is one entry in AnchoreRange.Fixes. Date is a string rather than
// time.Time so callers control the parse (some emit RFC3339, some date-only).
type AnchoreFix struct {
Version string `json:"version"`
Kind string `json:"kind"`
Date string `json:"date"`
}

// AffectedExtension returns the "anchore" key from an Affected's
// database_specific map as a typed view. Missing key or decode failure yields
// the zero value — both are treated as "no overlay" by the transformers.
func AffectedExtension(databaseSpecific map[string]any) AnchoreAffected {
var ext AnchoreAffected
DecodeNamespace(databaseSpecific, "anchore", &ext)
return ext
}

// RangeExtension returns the "anchore" key from a Range's database_specific
// map as a typed view. Missing key or decode failure yields the zero value.
func RangeExtension(databaseSpecific map[string]any) AnchoreRange {
var ext AnchoreRange
DecodeNamespace(databaseSpecific, "anchore", &ext)
return ext
}

// DecodeNamespace pulls a single key out of an extension-point map and
// decodes its JSON shape into the target. Errors are swallowed: the caller
// either gets a populated target or the zero value, with no way to tell the
// difference. That matches the transformers' "absence == no overlay" posture
// and avoids re-flagging vunnel write-side bugs at read time.
//
// Use this when the vendor namespaces their extension under a single key
// (the dominant shape, e.g. "anchore" or future "vendorname").
func DecodeNamespace(m map[string]any, key string, into any) {
raw, ok := m[key]
if !ok {
return
}
b, err := json.Marshal(raw)
if err != nil {
return
}
_ = json.Unmarshal(b, into)
}

// DecodeAll decodes an entire extension-point map into the target. Use this
// when a vendor sticks fields at the top level of database_specific or
// ecosystem_specific (e.g. alma's affected[].ecosystem_specific.rpm_modularity)
// rather than under a namespacing key.
func DecodeAll(m map[string]any, into any) {
if len(m) == 0 {
return
}
b, err := json.Marshal(m)
if err != nil {
return
}
_ = json.Unmarshal(b, into)
}
283 changes: 283 additions & 0 deletions grype/db/internal/provider/unmarshal/osvmodel/anchore_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
package osvmodel

import (
"reflect"
"testing"
)

// TestAffectedExtension covers the AnchoreAffected typed view over
// affected[].database_specific["anchore"].
//
// Every OSV strategy reads through this helper to decide fix disposition.
// The dominant shape today is the vunnel-stamped {"status": "wont-fix"}
// from the VEX overlay; missing-key and malformed cases must both yield
// the zero value so strategies stay tolerant of vunnel write-side bugs.
func TestAffectedExtension(t *testing.T) {
tests := []struct {
name string
in map[string]any
want AnchoreAffected
}{
{
name: "wont-fix status",
in: map[string]any{
"anchore": map[string]any{"status": "wont-fix"},
},
want: AnchoreAffected{Status: "wont-fix"},
},
{
name: "empty anchore object",
in: map[string]any{
"anchore": map[string]any{},
},
want: AnchoreAffected{},
},
{
name: "missing anchore key",
in: map[string]any{
"vendor": map[string]any{"status": "wont-fix"},
},
want: AnchoreAffected{},
},
{
name: "nil map",
in: nil,
want: AnchoreAffected{},
},
{
// future-compatible: vunnel might emit other status values; the
// decode succeeds, and downstream strategies decide what to do
// with unknown values via their own switch (see ubuntu).
name: "unknown status passes through",
in: map[string]any{
"anchore": map[string]any{"status": "some-future-value"},
},
want: AnchoreAffected{Status: "some-future-value"},
},
{
// type mismatch (status is an int, not a string) → swallowed
// silently per the helper contract. The transformer falls back
// to the default branch.
name: "type mismatch yields zero value",
in: map[string]any{
"anchore": map[string]any{"status": 42},
},
want: AnchoreAffected{},
},
{
// anchore key is the wrong shape entirely (string instead of object).
// Decode fails silently.
name: "anchore key with non-object value",
in: map[string]any{
"anchore": "not an object",
},
want: AnchoreAffected{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := AffectedExtension(tt.in)
if got != tt.want {
t.Errorf("AffectedExtension() = %+v, want %+v", got, tt.want)
}
})
}
}

// TestRangeExtension covers the AnchoreRange typed view over
// ranges[].database_specific["anchore"]. This is the fix-availability
// channel: vunnel stamps {"fixes": [{"version", "kind", "date"}]} per
// range, and grype's extractFixAvailability reads it through here.
func TestRangeExtension(t *testing.T) {
tests := []struct {
name string
in map[string]any
want AnchoreRange
}{
{
name: "single fix entry",
in: map[string]any{
"anchore": map[string]any{
"fixes": []any{
map[string]any{"version": "1.2.3", "kind": "advisory", "date": "2024-01-01"},
},
},
},
want: AnchoreRange{
Fixes: []AnchoreFix{{Version: "1.2.3", Kind: "advisory", Date: "2024-01-01"}},
},
},
{
name: "multiple fix entries preserved in order",
in: map[string]any{
"anchore": map[string]any{
"fixes": []any{
map[string]any{"version": "1.0", "kind": "first-observed", "date": "2024-01-01"},
map[string]any{"version": "1.1", "kind": "advisory", "date": "2024-02-01"},
},
},
},
want: AnchoreRange{
Fixes: []AnchoreFix{
{Version: "1.0", Kind: "first-observed", Date: "2024-01-01"},
{Version: "1.1", Kind: "advisory", Date: "2024-02-01"},
},
},
},
{
name: "missing anchore key",
in: map[string]any{"other": "data"},
want: AnchoreRange{},
},
{
name: "anchore present but no fixes key",
in: map[string]any{
"anchore": map[string]any{"status": "wont-fix"},
},
want: AnchoreRange{},
},
{
name: "empty fixes array",
in: map[string]any{
"anchore": map[string]any{"fixes": []any{}},
},
want: AnchoreRange{Fixes: []AnchoreFix{}},
},
{
name: "nil map",
in: nil,
want: AnchoreRange{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := RangeExtension(tt.in)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("RangeExtension() = %+v, want %+v", got, tt.want)
}
})
}
}

// TestDecodeNamespace exercises the namespaced-decode entry point directly.
// Used by AffectedExtension/RangeExtension and by per-vendor extension
// helpers that namespace under their own key.
func TestDecodeNamespace(t *testing.T) {
type sample struct {
Name string `json:"name"`
Age int `json:"age,omitempty"`
}

tests := []struct {
name string
m map[string]any
key string
want sample
}{
{
name: "namespaced value decodes",
m: map[string]any{"vendor": map[string]any{"name": "alice", "age": 30}},
key: "vendor",
want: sample{Name: "alice", Age: 30},
},
{
name: "missing key yields zero value",
m: map[string]any{"other": map[string]any{"name": "alice"}},
key: "vendor",
want: sample{},
},
{
name: "empty map yields zero value",
m: map[string]any{},
key: "vendor",
want: sample{},
},
{
name: "nil map yields zero value",
m: nil,
key: "vendor",
want: sample{},
},
{
// unrelated keys present alongside the target — only the target is decoded.
name: "ignores sibling keys",
m: map[string]any{
"vendor": map[string]any{"name": "alice"},
"other": map[string]any{"name": "bob"},
},
key: "vendor",
want: sample{Name: "alice"},
},
{
// JSON-incompatible value at the key → decode fails silently,
// caller gets zero value.
name: "type mismatch yields zero value",
m: map[string]any{"vendor": "not an object"},
key: "vendor",
want: sample{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var got sample
DecodeNamespace(tt.m, tt.key, &got)
if got != tt.want {
t.Errorf("DecodeNamespace() = %+v, want %+v", got, tt.want)
}
})
}
}

// TestDecodeAll exercises the top-level-decode entry point. Used when a
// vendor sticks their fields directly at the root of database_specific or
// ecosystem_specific (alma's rpm_modularity is the dominant example).
func TestDecodeAll(t *testing.T) {
type rpm struct {
RpmModularity string `json:"rpm_modularity,omitempty"`
}

tests := []struct {
name string
m map[string]any
want rpm
}{
{
name: "top-level field decodes",
m: map[string]any{"rpm_modularity": "nodejs:18"},
want: rpm{RpmModularity: "nodejs:18"},
},
{
name: "absent field yields zero value",
m: map[string]any{"other_field": "x"},
want: rpm{},
},
{
name: "empty map is a no-op (early return)",
m: map[string]any{},
want: rpm{},
},
{
name: "nil map is a no-op (early return)",
m: nil,
want: rpm{},
},
{
// extra keys are ignored at the json decode boundary
name: "extra keys ignored",
m: map[string]any{
"rpm_modularity": "nodejs:18",
"future_field": 42,
},
want: rpm{RpmModularity: "nodejs:18"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var got rpm
DecodeAll(tt.m, &got)
if got != tt.want {
t.Errorf("DecodeAll() = %+v, want %+v", got, tt.want)
}
})
}
}
Loading
Loading