From 6b00b88832bf683bec433acbb8be4d1f163e8d15 Mon Sep 17 00:00:00 2001 From: TooFastTooCurious Date: Wed, 15 Apr 2026 17:39:50 -0400 Subject: [PATCH] Add OSV 1.7.5 parsing support Rewrite pkg/advisory to consume OSV-shaped advisory data rather than the legacy ABOM format. Advisory, Affected, Range, Event, and related structs match the OSV schema. ABOM-specific signal lives in ecosystem_specific.abom (tool_names, affected_period) and database_specific.abom (indicators, recommended_actions). Matching logic walks range events (introduced/fixed/last_affected) instead of parsing a tag_range string. Builtin advisories snapshot updated to OSV format to match JulietSecurity/abom-advisories#6. Agreed approach in #3. --- pkg/advisory/advisory.go | 376 +++++++++++++++------------ pkg/advisory/advisory_test.go | 148 +++++++++-- pkg/advisory/builtin_advisories.json | 176 +++++++++---- 3 files changed, 472 insertions(+), 228 deletions(-) diff --git a/pkg/advisory/advisory.go b/pkg/advisory/advisory.go index 00d219e..1129894 100644 --- a/pkg/advisory/advisory.go +++ b/pkg/advisory/advisory.go @@ -14,58 +14,112 @@ import ( ) const ( - remoteURL = "https://raw.githubusercontent.com/JulietSecurity/abom-advisories/main/db/advisories.json" - cacheTTL = 1 * time.Hour + remoteURL = "https://raw.githubusercontent.com/JulietSecurity/abom-advisories/main/db/advisories.json" + cacheTTL = 1 * time.Hour fetchTimeout = 5 * time.Second + + ecosystemGitHubActions = "GitHub Actions" ) -// AdvisoryDB is the top-level JSON structure for the advisory database. +// AdvisoryDB is the top-level structure for the advisory database file. +// Individual advisories conform to the OSV schema (https://ossf.github.io/osv-schema/). type AdvisoryDB struct { - SchemaVersion string `json:"schema_version"` - LastUpdated string `json:"last_updated"` - Advisories []Advisory `json:"advisories"` + LastUpdated string `json:"last_updated"` + Advisories []Advisory `json:"advisories"` } -// Advisory describes a known-compromised action. +// Advisory is an OSV-shaped advisory entry with ABOM extensions. type Advisory struct { - ID string `json:"id"` - Title string `json:"title"` - CVE string `json:"cve,omitempty"` - CVSS float64 `json:"cvss,omitempty"` - Published string `json:"published"` - Updated string `json:"updated"` - Status string `json:"status"` - Description string `json:"description"` - References []string `json:"references,omitempty"` - AffectedActions []AffectedAction `json:"affected_actions"` - Indicators *Indicators `json:"indicators,omitempty"` - RecommendedActions []string `json:"recommended_actions,omitempty"` + SchemaVersion string `json:"schema_version"` + ID string `json:"id"` + Modified string `json:"modified"` + Published string `json:"published,omitempty"` + Withdrawn string `json:"withdrawn,omitempty"` + Aliases []string `json:"aliases,omitempty"` + Summary string `json:"summary,omitempty"` + Details string `json:"details,omitempty"` + Severity []Severity `json:"severity,omitempty"` + Affected []Affected `json:"affected"` + References []Reference `json:"references,omitempty"` + DatabaseSpecific DatabaseSpecific `json:"database_specific,omitempty"` +} + +// Severity follows OSV's severity shape. For CVSS_V3 / CVSS_V4, Score is the +// full vector string. +type Severity struct { + Type string `json:"type"` + Score string `json:"score"` +} + +// Affected describes a single affected package within an advisory. +type Affected struct { + Package Package `json:"package"` + Ranges []Range `json:"ranges,omitempty"` + Versions []string `json:"versions,omitempty"` + EcosystemSpecific EcosystemSpecific `json:"ecosystem_specific,omitempty"` +} + +// Package identifies the affected artifact. +type Package struct { + Ecosystem string `json:"ecosystem"` + Name string `json:"name"` +} + +// Range describes an affected version range via a sequence of events. +type Range struct { + Type string `json:"type"` + Events []Event `json:"events"` +} + +// Event is a single point in a range's timeline. Exactly one of the fields +// below is set per event. +type Event struct { + Introduced string `json:"introduced,omitempty"` + Fixed string `json:"fixed,omitempty"` + LastAffected string `json:"last_affected,omitempty"` + Limit string `json:"limit,omitempty"` +} + +// Reference is a typed URL, per the OSV references spec. +type Reference struct { + Type string `json:"type"` + URL string `json:"url"` } -// AffectedAction describes an action affected by an advisory. -type AffectedAction struct { - Uses string `json:"uses"` - AffectedRefs AffectedRefs `json:"affected_refs"` - AffectedPeriod struct { - From string `json:"from"` - To *string `json:"to"` - } `json:"affected_period"` - ToolNames []string `json:"tool_names,omitempty"` +// EcosystemSpecific holds per-package, ecosystem-scoped extensions. +type EcosystemSpecific struct { + ABOM *ABOMEcosystemFields `json:"abom,omitempty"` } -// AffectedRefs describes which refs of an action are affected. -type AffectedRefs struct { - Tags []string `json:"tags,omitempty"` - TagRange string `json:"tag_range,omitempty"` - SafeTags []string `json:"safe_tags,omitempty"` - SafeSHAs []string `json:"safe_shas,omitempty"` +// ABOMEcosystemFields are GitHub-Actions-specific ABOM extensions. +type ABOMEcosystemFields struct { + ToolNames []string `json:"tool_names,omitempty"` + AffectedPeriod *AffectedPeriod `json:"affected_period,omitempty"` +} + +// AffectedPeriod is an incident time window. The To field is intentionally +// string (not *string) — an empty string denotes an ongoing incident. +type AffectedPeriod struct { + From string `json:"from"` + To string `json:"to,omitempty"` +} + +// DatabaseSpecific holds advisory-wide, database-scoped extensions. +type DatabaseSpecific struct { + ABOM *ABOMDatabaseFields `json:"abom,omitempty"` +} + +// ABOMDatabaseFields are ABOM-wide extensions to the OSV schema. +type ABOMDatabaseFields struct { + Indicators *Indicators `json:"indicators,omitempty"` + RecommendedActions []string `json:"recommended_actions,omitempty"` } // Indicators contains IOCs for an advisory. type Indicators struct { - DockerImages []string `json:"docker_images,omitempty"` - ReposToCheck []string `json:"repos_to_check,omitempty"` - Notes string `json:"notes,omitempty"` + DockerImages []string `json:"docker_images,omitempty"` + ReposToCheck []string `json:"repos_to_check,omitempty"` + Notes string `json:"notes,omitempty"` } // LoadOptions configures how the advisory database is loaded. @@ -85,7 +139,6 @@ type Database struct { // NewDatabase loads the advisory database using the fallback chain: // remote -> cache -> builtin. func NewDatabase(opts LoadOptions) *Database { - // Try remote first (unless offline) if !opts.Offline { if db, err := loadRemote(opts); err == nil { if !opts.NoCache { @@ -95,7 +148,6 @@ func NewDatabase(opts LoadOptions) *Database { } } - // Try cache (unless no-cache) if !opts.NoCache && !opts.Offline { if db, fresh, err := loadCache(); err == nil { if !fresh && !opts.Quiet { @@ -105,7 +157,6 @@ func NewDatabase(opts LoadOptions) *Database { } } - // Fall back to builtin db, _ := parseAdvisoryDB(builtinData) if !opts.Offline && !opts.Quiet { fmt.Fprintf(os.Stderr, "Warning: using built-in advisory data from %s. Run with network access for latest advisories.\n", db.LastUpdated) @@ -113,36 +164,43 @@ func NewDatabase(opts LoadOptions) *Database { return &Database{db: *db, source: "builtin"} } -// Check returns the first matching advisory for an action ref, or nil. -// If the ref is a SHA not in safe_shas, returns the advisory with a -// "verify manually" note. +// Check returns the first matching advisory for an action ref, along with a +// match reason: "compromised" (version in affected range), "verify-sha" +// (SHA-pinned and can't be version-compared), or "detected-tool" (matched via +// wrapper detection / IoC). func (d *Database) Check(ref *model.ActionRef) (*Advisory, string) { for i := range d.db.Advisories { adv := &d.db.Advisories[i] - if adv.Status == "withdrawn" { + if adv.Withdrawn != "" { continue } - for _, aa := range adv.AffectedActions { - if matchResult := matchAction(ref, &aa); matchResult != "" { - return adv, matchResult + + for _, aff := range adv.Affected { + if result := matchAffected(ref, &aff); result != "" { + return adv, result } } - // Check docker image indicators - if ref.ActionType == model.ActionTypeDocker && adv.Indicators != nil { - for _, img := range adv.Indicators.DockerImages { + + if ref.ActionType == model.ActionTypeDocker && adv.DatabaseSpecific.ABOM != nil && adv.DatabaseSpecific.ABOM.Indicators != nil { + ind := adv.DatabaseSpecific.ABOM.Indicators + for _, img := range ind.DockerImages { if strings.HasPrefix(ref.Path, img) || strings.HasPrefix(ref.Path, strings.Split(img, ":")[0]) { return adv, "compromised" } } } - // Check detected tools — actions that wrap a compromised tool if len(ref.DetectedTools) > 0 { - for _, aa := range adv.AffectedActions { - toolNames := aa.ToolNames - // Infer tool name from uses if tool_names not set + for _, aff := range adv.Affected { + if aff.Package.Ecosystem != ecosystemGitHubActions { + continue + } + var toolNames []string + if aff.EcosystemSpecific.ABOM != nil { + toolNames = aff.EcosystemSpecific.ABOM.ToolNames + } if len(toolNames) == 0 { - toolNames = inferToolNames(aa.Uses) + toolNames = inferToolNames(aff.Package.Name) } for _, tool := range toolNames { for _, detected := range ref.DetectedTools { @@ -187,11 +245,13 @@ func (d *Database) checkAction(ref *model.ActionRef) { } } -// matchAction checks if an ActionRef matches an AffectedAction entry. -// Returns "compromised", "verify-sha", or "" (no match). -func matchAction(ref *model.ActionRef, aa *AffectedAction) string { - // Parse uses into owner/repo - parts := strings.SplitN(aa.Uses, "/", 2) +// matchAffected checks if an ActionRef matches an Affected entry. +func matchAffected(ref *model.ActionRef, aff *Affected) string { + if aff.Package.Ecosystem != ecosystemGitHubActions { + return "" + } + + parts := strings.SplitN(aff.Package.Name, "/", 2) if len(parts) != 2 { return "" } @@ -201,56 +261,118 @@ func matchAction(ref *model.ActionRef, aa *AffectedAction) string { return "" } - // Check safe_tags first - for _, safe := range aa.AffectedRefs.SafeTags { - if ref.Ref == safe { - return "" + // SHA-pinned refs can't be ordinally compared against tag ranges. + // Flag as "verify manually" so users know to check against the + // affected_period or upstream history. + if ref.RefType == model.RefTypeSHA { + return "verify-sha" + } + + // Explicit version list (if present) is a direct equality check. + for _, v := range aff.Versions { + if ref.Ref == v { + return "compromised" } } - // Check safe_shas - for _, safe := range aa.AffectedRefs.SafeSHAs { - if strings.EqualFold(ref.Ref, safe) { - return "" + // Walk ranges. Any match means the ref is affected. + for i := range aff.Ranges { + if matchesRange(ref.Ref, &aff.Ranges[i]) { + return "compromised" } } - // If ref is a SHA and not in safe_shas, flag as "verify manually" - if ref.RefType == model.RefTypeSHA { - return "verify-sha" + return "" +} + +// matchesRange walks events in declaration order and toggles affected state. +// Each introduced starts an affected window; each fixed or last_affected +// closes it. Returns true if version lands inside any affected window. +func matchesRange(version string, rng *Range) bool { + if rng.Type != "ECOSYSTEM" && rng.Type != "SEMVER" { + return false } - // Check explicit tag list - if len(aa.AffectedRefs.Tags) > 0 { - for _, tag := range aa.AffectedRefs.Tags { - if ref.Ref == tag { - return "compromised" + affected := false + for _, ev := range rng.Events { + switch { + case ev.Introduced != "": + // "0" is the OSV sentinel meaning "from the beginning of time." + if ev.Introduced == "0" { + affected = true + } else if compareVersions(version, ev.Introduced) >= 0 { + affected = true + } + case ev.Fixed != "": + if compareVersions(version, ev.Fixed) >= 0 { + affected = false + } + case ev.LastAffected != "": + if compareVersions(version, ev.LastAffected) > 0 { + affected = false } } } + return affected +} - // Check tag range - if aa.AffectedRefs.TagRange != "" { - if matchesTagRange(ref.Ref, aa.AffectedRefs.TagRange) { - return "compromised" +// compareVersions compares two tag-like version strings. Returns -1, 0, or 1. +// Only works for semver-shaped strings (optionally v-prefixed). Non-numeric +// strings (e.g. "main") compare as less than any valid version. +func compareVersions(a, b string) int { + an := normalizeVersion(a) + bn := normalizeVersion(b) + switch { + case an == "" && bn == "": + return 0 + case an == "": + return -1 + case bn == "": + return 1 + case an < bn: + return -1 + case an > bn: + return 1 + } + return 0 +} + +// normalizeVersion strips the leading 'v' and zero-pads each component for +// lexicographic comparison. Returns "" for non-numeric strings. +func normalizeVersion(v string) string { + v = strings.TrimPrefix(v, "v") + if v == "" { + return "" + } + parts := strings.Split(v, ".") + var normalized []string + for _, p := range parts { + for _, c := range p { + if c < '0' || c > '9' { + return "" + } } + for len(p) < 5 { + p = "0" + p + } + normalized = append(normalized, p) } - - return "" + for len(normalized) < 3 { + normalized = append(normalized, "00000") + } + return strings.Join(normalized, ".") } -// inferToolNames extracts likely tool names from an action uses field. +// inferToolNames extracts likely tool names from an action package name. // e.g., "aquasecurity/trivy-action" -> ["trivy"] // e.g., "aquasecurity/setup-trivy" -> ["trivy"] -func inferToolNames(uses string) []string { - parts := strings.SplitN(uses, "/", 2) +func inferToolNames(pkgName string) []string { + parts := strings.SplitN(pkgName, "/", 2) if len(parts) != 2 { return nil } repo := strings.ToLower(parts[1]) - // Common patterns: {tool}-action, setup-{tool}, ghaction-{tool}-scan, etc. - // Extract candidate tool names by removing common suffixes/prefixes candidates := []string{repo} for _, prefix := range []string{"setup-", "ghaction-", "action-"} { if strings.HasPrefix(repo, prefix) { @@ -263,18 +385,10 @@ func inferToolNames(uses string) []string { } } - // Known tool name mappings - knownTools := map[string]string{ - "trivy": "trivy", - "grype": "grype", - "snyk": "snyk", - "cosign": "cosign", - "syft": "syft", - } + knownTools := []string{"trivy", "grype", "snyk", "cosign", "syft"} var tools []string for _, candidate := range candidates { - // Check if any known tool name appears in the candidate for _, toolName := range knownTools { if strings.Contains(candidate, toolName) { found := false @@ -294,74 +408,6 @@ func inferToolNames(uses string) []string { return tools } -// matchesTagRange parses a range like ">=v0.0.1 <=v0.34.2" and checks if -// the given version falls within it. -func matchesTagRange(version, rangeStr string) bool { - // Parse range components (e.g., ">=v0.0.1 <=v0.34.2") - parts := strings.Fields(rangeStr) - v := normalizeVersion(version) - if v == "" { - return false - } - - for _, part := range parts { - if strings.HasPrefix(part, ">=") { - min := normalizeVersion(strings.TrimPrefix(part, ">=")) - if v < min { - return false - } - } else if strings.HasPrefix(part, "<=") { - max := normalizeVersion(strings.TrimPrefix(part, "<=")) - if v > max { - return false - } - } else if strings.HasPrefix(part, ">") { - min := normalizeVersion(strings.TrimPrefix(part, ">")) - if v <= min { - return false - } - } else if strings.HasPrefix(part, "<") { - max := normalizeVersion(strings.TrimPrefix(part, "<")) - if v >= max { - return false - } - } - } - return true -} - -// normalizeVersion strips the leading 'v' and zero-pads each component -// for lexicographic comparison. -func normalizeVersion(v string) string { - v = strings.TrimPrefix(v, "v") - if v == "" { - return "" - } - parts := strings.Split(v, ".") - var normalized []string - for _, p := range parts { - // Check if it's numeric - isNumeric := true - for _, c := range p { - if c < '0' || c > '9' { - isNumeric = false - break - } - } - if !isNumeric { - return "" // not a version string - } - for len(p) < 5 { - p = "0" + p - } - normalized = append(normalized, p) - } - for len(normalized) < 3 { - normalized = append(normalized, "00000") - } - return strings.Join(normalized, ".") -} - // --- Loading functions --- func parseAdvisoryDB(data []byte) (*AdvisoryDB, error) { @@ -393,7 +439,7 @@ func loadRemote(opts LoadOptions) (*AdvisoryDB, error) { return nil, fmt.Errorf("HTTP %d", resp.StatusCode) } - data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1 MB limit + data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return nil, err } diff --git a/pkg/advisory/advisory_test.go b/pkg/advisory/advisory_test.go index d91c4a9..9c58918 100644 --- a/pkg/advisory/advisory_test.go +++ b/pkg/advisory/advisory_test.go @@ -7,7 +7,6 @@ import ( ) func testDB() *Database { - // Load from builtin data directly, skip network return NewDatabase(LoadOptions{Offline: true, Quiet: true}) } @@ -45,7 +44,7 @@ func TestCheck_TrivyActionVulnerable(t *testing.T) { wantResult: "compromised", }, { - name: "trivy-action max boundary", + name: "trivy-action max vulnerable", ref: &model.ActionRef{ Owner: "aquasecurity", Repo: "trivy-action", @@ -57,7 +56,7 @@ func TestCheck_TrivyActionVulnerable(t *testing.T) { wantResult: "compromised", }, { - name: "trivy-action safe tag (above range and in safe_tags)", + name: "trivy-action fixed boundary", ref: &model.ActionRef{ Owner: "aquasecurity", Repo: "trivy-action", @@ -68,7 +67,7 @@ func TestCheck_TrivyActionVulnerable(t *testing.T) { wantHit: false, }, { - name: "trivy-action above range but not in safe_tags", + name: "trivy-action above fixed", ref: &model.ActionRef{ Owner: "aquasecurity", Repo: "trivy-action", @@ -91,7 +90,7 @@ func TestCheck_TrivyActionVulnerable(t *testing.T) { wantResult: "compromised", }, { - name: "setup-trivy safe tag", + name: "setup-trivy fixed", ref: &model.ActionRef{ Owner: "aquasecurity", Repo: "setup-trivy", @@ -101,7 +100,7 @@ func TestCheck_TrivyActionVulnerable(t *testing.T) { }, }, { - name: "setup-trivy above range", + name: "setup-trivy above fixed", ref: &model.ActionRef{ Owner: "aquasecurity", Repo: "setup-trivy", @@ -159,6 +158,28 @@ func TestCheck_TrivyActionVulnerable(t *testing.T) { Path: "./local-action", }, }, + { + name: "tj-actions vulnerable", + ref: &model.ActionRef{ + Owner: "tj-actions", + Repo: "changed-files", + Ref: "v45", + RefType: model.RefTypeTag, + ActionType: model.ActionTypeStandard, + }, + wantHit: true, + wantResult: "compromised", + }, + { + name: "tj-actions fixed", + ref: &model.ActionRef{ + Owner: "tj-actions", + Repo: "changed-files", + Ref: "v46.0.1", + RefType: model.RefTypeTag, + ActionType: model.ActionTypeStandard, + }, + }, } for _, tt := range tests { @@ -233,26 +254,91 @@ func TestCheckAll(t *testing.T) { } } -func TestMatchesTagRange(t *testing.T) { +func TestMatchesRange(t *testing.T) { + rng := &Range{ + Type: "ECOSYSTEM", + Events: []Event{ + {Introduced: "v0.0.1"}, + {Fixed: "v0.35.0"}, + }, + } + tests := []struct { version string - rangeS string want bool }{ - {"v0.20.0", ">=v0.0.1 <=v0.34.2", true}, - {"v0.0.1", ">=v0.0.1 <=v0.34.2", true}, - {"v0.34.2", ">=v0.0.1 <=v0.34.2", true}, - {"v0.35.0", ">=v0.0.1 <=v0.34.2", false}, - {"v0.0.0", ">=v0.0.1 <=v0.34.2", false}, - {"main", ">=v0.0.1 <=v0.34.2", false}, - {"v1.0.0", ">=v0.0.1 <=v0.34.2", false}, + {"v0.20.0", true}, + {"v0.0.1", true}, + {"v0.34.2", true}, + {"v0.35.0", false}, + {"v0.0.0", false}, + {"v1.0.0", false}, + {"main", false}, // non-numeric returns empty, falls before introduced } for _, tt := range tests { t.Run(tt.version, func(t *testing.T) { - got := matchesTagRange(tt.version, tt.rangeS) + got := matchesRange(tt.version, rng) if got != tt.want { - t.Errorf("matchesTagRange(%q, %q) = %v, want %v", tt.version, tt.rangeS, got, tt.want) + t.Errorf("matchesRange(%q, range) = %v, want %v", tt.version, got, tt.want) + } + }) + } +} + +func TestMatchesRange_IntroducedZero(t *testing.T) { + // "0" is the OSV sentinel for "from the beginning." + rng := &Range{ + Type: "ECOSYSTEM", + Events: []Event{ + {Introduced: "0"}, + {Fixed: "v2.0.0"}, + }, + } + + tests := []struct { + version string + want bool + }{ + {"v0.0.1", true}, + {"v1.5.0", true}, + {"v2.0.0", false}, + {"v3.0.0", false}, + } + + for _, tt := range tests { + t.Run(tt.version, func(t *testing.T) { + if got := matchesRange(tt.version, rng); got != tt.want { + t.Errorf("got %v, want %v", got, tt.want) + } + }) + } +} + +func TestMatchesRange_LastAffected(t *testing.T) { + rng := &Range{ + Type: "ECOSYSTEM", + Events: []Event{ + {Introduced: "v1.0.0"}, + {LastAffected: "v1.5.0"}, + }, + } + + tests := []struct { + version string + want bool + }{ + {"v0.9.0", false}, + {"v1.0.0", true}, + {"v1.5.0", true}, + {"v1.5.1", false}, + {"v2.0.0", false}, + } + + for _, tt := range tests { + t.Run(tt.version, func(t *testing.T) { + if got := matchesRange(tt.version, rng); got != tt.want { + t.Errorf("got %v, want %v", got, tt.want) } }) } @@ -263,10 +349,34 @@ func TestBuiltinDataLoads(t *testing.T) { if len(db.db.Advisories) == 0 { t.Fatal("builtin data should contain at least one advisory") } - if db.db.Advisories[0].ID != "ABOM-2026-001" { - t.Errorf("first advisory ID = %q, want ABOM-2026-001", db.db.Advisories[0].ID) + ids := make(map[string]bool) + for _, adv := range db.db.Advisories { + ids[adv.ID] = true + } + if !ids["ABOM-2026-001"] { + t.Error("builtin data missing ABOM-2026-001") + } + if !ids["ABOM-2026-002"] { + t.Error("builtin data missing ABOM-2026-002") } if db.source != "builtin" { t.Errorf("source = %q, want builtin", db.source) } } + +func TestBuiltinDataIsOSV(t *testing.T) { + db := testDB() + for _, adv := range db.db.Advisories { + if adv.SchemaVersion == "" { + t.Errorf("%s missing schema_version", adv.ID) + } + if len(adv.Affected) == 0 { + t.Errorf("%s has no affected packages", adv.ID) + } + for _, aff := range adv.Affected { + if aff.Package.Ecosystem == "" { + t.Errorf("%s has affected entry with no ecosystem", adv.ID) + } + } + } +} diff --git a/pkg/advisory/builtin_advisories.json b/pkg/advisory/builtin_advisories.json index ba48dd7..f0f9c4d 100644 --- a/pkg/advisory/builtin_advisories.json +++ b/pkg/advisory/builtin_advisories.json @@ -1,61 +1,149 @@ { - "schema_version": "1.0.0", - "last_updated": "2026-03-26T00:00:00Z", + "last_updated": "2026-04-15T00:00:00Z", "advisories": [ { - "id": "ABOM-2026-001", - "title": "Trivy GitHub Actions supply chain compromise", - "cve": "CVE-2026-33634", - "cvss": 9.4, - "published": "2026-03-19", - "updated": "2026-03-26", - "status": "active", - "description": "Compromised credentials from a non-atomic credential rotation allowed an attacker to force-push malicious payloads to 76 of 77 trivy-action tags and all setup-trivy tags. The injected code was an infostealer that exfiltrated CI secrets, SSH keys, cloud credentials, and tokens via encrypted channels. Exposure window was approximately 12 hours for trivy-action and 4 hours for setup-trivy starting 2026-03-19 ~17:43 UTC.", + "schema_version": "1.7.5", + "id": "ABOM-2026-002", + "modified": "2026-04-11T00:00:00Z", + "published": "2026-04-11T00:00:00Z", + "aliases": ["CVE-2025-30066", "GHSA-mrrh-fwg8-r2c3"], + "summary": "tj-actions/changed-files compromise", + "details": "Allows for information disclosure of secrets including, but not limited to, valid access keys, GitHub Personal Access Tokens (PATs), npm tokens, and private RSA keys.", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N" + } + ], + "affected": [ + { + "package": { + "ecosystem": "GitHub Actions", + "name": "tj-actions/changed-files" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + {"introduced": "v0.0.1"}, + {"fixed": "v46.0.1"} + ] + } + ], + "ecosystem_specific": { + "abom": { + "affected_period": { + "from": "2025-03-14T00:00:00Z", + "to": "2025-03-16T00:00:00Z" + } + } + } + } + ], "references": [ - "https://github.com/aquasecurity/trivy/security/advisories/GHSA-69fq-xp46-6x23", - "https://nvd.nist.gov/vuln/detail/CVE-2026-33634", - "https://github.com/aquasecurity/trivy/discussions/10425" + {"type": "ADVISORY", "url": "https://www.cisa.gov/news-events/alerts/2025/03/18/supply-chain-compromise-third-party-tj-actionschanged-files-cve-2025-30066-and-reviewdogaction"}, + {"type": "ADVISORY", "url": "https://github.com/advisories/GHSA-mrrh-fwg8-r2c3"}, + {"type": "ADVISORY", "url": "https://nvd.nist.gov/vuln/detail/CVE-2025-30066"} ], - "affected_actions": [ + "database_specific": { + "abom": { + "recommended_actions": [ + "Pin to commit SHA or update to safe tag (changed-files v46.0.1)", + "Rotate all secrets accessible to affected CI pipelines immediately", + "Audit downstream artifacts built during the exposure window" + ] + } + } + }, + { + "schema_version": "1.7.5", + "id": "ABOM-2026-001", + "modified": "2026-03-26T00:00:00Z", + "published": "2026-03-19T00:00:00Z", + "aliases": ["CVE-2026-33634", "GHSA-69fq-xp46-6x23"], + "summary": "Trivy GitHub Actions supply chain compromise", + "details": "Compromised credentials from a non-atomic credential rotation allowed an attacker to force-push malicious payloads to 76 of 77 trivy-action tags and all setup-trivy tags. The injected code was an infostealer that exfiltrated CI secrets, SSH keys, cloud credentials, and tokens via encrypted channels. Exposure window was approximately 12 hours for trivy-action and 4 hours for setup-trivy starting 2026-03-19 ~17:43 UTC.", + "severity": [ + { + "type": "CVSS_V3", + "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H" + } + ], + "affected": [ { - "uses": "aquasecurity/trivy-action", - "tool_names": ["trivy"], - "affected_refs": { - "tag_range": ">=v0.0.1 <=v0.34.2", - "safe_tags": ["v0.35.0"], - "safe_shas": [] + "package": { + "ecosystem": "GitHub Actions", + "name": "aquasecurity/trivy-action" }, - "affected_period": { - "from": "2026-03-19T17:43:00Z", - "to": "2026-03-20T05:40:00Z" + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + {"introduced": "v0.0.1"}, + {"fixed": "v0.35.0"} + ] + } + ], + "ecosystem_specific": { + "abom": { + "tool_names": ["trivy"], + "affected_period": { + "from": "2026-03-19T17:43:00Z", + "to": "2026-03-20T05:40:00Z" + } + } } }, { - "uses": "aquasecurity/setup-trivy", - "tool_names": ["trivy"], - "affected_refs": { - "tag_range": ">=v0.2.0 <=v0.2.6", - "safe_tags": ["v0.2.6"], - "safe_shas": [] + "package": { + "ecosystem": "GitHub Actions", + "name": "aquasecurity/setup-trivy" }, - "affected_period": { - "from": "2026-03-19T17:43:00Z", - "to": "2026-03-19T21:44:00Z" + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + {"introduced": "v0.2.0"}, + {"fixed": "v0.2.6"} + ] + } + ], + "ecosystem_specific": { + "abom": { + "tool_names": ["trivy"], + "affected_period": { + "from": "2026-03-19T17:43:00Z", + "to": "2026-03-19T21:44:00Z" + } + } } } ], - "indicators": { - "docker_images": ["aquasec/trivy:0.69.4", "aquasec/trivy:0.69.5", "aquasec/trivy:0.69.6"], - "repos_to_check": ["tpcp-docs"], - "notes": "If exfiltration failed and INPUT_GITHUB_PAT was set, the payload created a public repo named tpcp-docs on the victim's GitHub account and uploaded stolen data as a release asset. Check your org for unexpected repos with this name." - }, - "recommended_actions": [ - "Pin to commit SHA or update to safe tag (trivy-action v0.35.0, setup-trivy v0.2.6)", - "Rotate all secrets accessible to affected CI pipelines immediately", - "Audit downstream artifacts built during the exposure window", - "Check for unauthorized repos named tpcp-docs in your GitHub org", - "Verify trivy binary integrity — safe versions are v0.69.2 and v0.69.3" - ] + "references": [ + {"type": "ADVISORY", "url": "https://github.com/aquasecurity/trivy/security/advisories/GHSA-69fq-xp46-6x23"}, + {"type": "ADVISORY", "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-33634"}, + {"type": "DISCUSSION", "url": "https://github.com/aquasecurity/trivy/discussions/10425"} + ], + "database_specific": { + "abom": { + "indicators": { + "docker_images": [ + "aquasec/trivy:0.69.4", + "aquasec/trivy:0.69.5", + "aquasec/trivy:0.69.6" + ], + "repos_to_check": ["tpcp-docs"], + "notes": "If exfiltration failed and INPUT_GITHUB_PAT was set, the payload created a public repo named tpcp-docs on the victim's GitHub account and uploaded stolen data as a release asset. Check your org for unexpected repos with this name." + }, + "recommended_actions": [ + "Pin to commit SHA or update to safe tag (trivy-action v0.35.0, setup-trivy v0.2.6)", + "Rotate all secrets accessible to affected CI pipelines immediately", + "Audit downstream artifacts built during the exposure window", + "Check for unauthorized repos named tpcp-docs in your GitHub org", + "Verify trivy binary integrity (safe versions are v0.69.2 and v0.69.3)" + ] + } + } } ] }