-
Notifications
You must be signed in to change notification settings - Fork 815
Add OSV transformer for Ubuntu records #3468
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
willmurphyscode
wants to merge
1
commit into
anchore:main
Choose a base branch
from
willmurphyscode:feat-ubuntu-osv-transformer
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
283
grype/db/internal/provider/unmarshal/osvmodel/anchore_test.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| } | ||
| }) | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.