diff --git a/README.md b/README.md index 856e0b4..5e4c2ef 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,8 @@ Every post-incident guide from CrowdStrike, Wiz, Snyk, and Microsoft tells you t - **Remote scanning** — scan any public GitHub repo without cloning: `abom scan github.com/org/repo` - **Advisory database** — built-in + auto-updated database of known-compromised actions - **Standard BOM formats** — output as CycloneDX 1.5 or SPDX 2.3 for integration with Dependency-Track, Grype, and other tooling -- **CI gate** — exits with code `1` when compromised actions are found +- **SHA verification** — optionally verify that pinned SHAs are actually reachable from the upstream repo, catching fork-sourced and force-pushed-away commits (`--verify-shas`) +- **CI gate** — exits non-zero when compromised actions are found or (with `--fail-on-warnings`) when any advisory warning is emitted - **Fast** — caches resolved actions locally, uses `raw.githubusercontent.com` to avoid API rate limits ## Installation @@ -106,6 +107,37 @@ Use as a CI gate: run: abom scan . --check ``` +Block on fork-sourced SHA pins as well: + +```yaml +- name: Check Actions supply chain + run: abom scan . --check --verify-shas --fail-on-warnings + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +``` + +## Verifying pinned SHAs + +Pinning an action to a SHA (e.g. `actions/checkout@a1b2c3...`) is the recommended defense against tag-swap attacks. But GitHub's object store is shared across a repo and its forks — so a SHA that exists only on a fork (or was force-pushed out of the upstream's history) will still resolve successfully when a workflow runs. The pin protects you from tag mutation, but not from a commit that was never in the upstream's ref graph. + +`--verify-shas` hits the GitHub commits API for each SHA-pinned reference and emits a warning when the SHA isn't reachable from the claimed repo's refs. It doesn't change resolution behavior — ABOM still builds the same dependency tree GitHub would — it just surfaces the discrepancy. + +```bash +abom scan . --verify-shas --github-token $GITHUB_TOKEN +``` + +Combine with `--fail-on-warnings` to block CI on the finding: + +```bash +abom scan . --verify-shas --fail-on-warnings --github-token $GITHUB_TOKEN +``` + +**What a warning means:** the SHA is not reachable from `owner/repo`'s refs. That may be a fork-only commit, a force-pushed-away commit, or a mistaken pin. It does **not** necessarily mean the SHA was tampered with. + +**Exit codes:** `0` clean, `1` compromised action (or runtime error), `2` warnings emitted with `--fail-on-warnings`. When both conditions hold, exit `1` wins. + +**Rate limit caveat:** `--verify-shas` makes an extra API call per unique SHA. Anonymous requests are capped at 60/hour — set `--github-token` (or `GITHUB_TOKEN`) for a realistic 5000/hour budget. + ## How detection works `abom` finds compromised dependencies through three layers that grep will never reach: @@ -146,6 +178,8 @@ Current advisories: | `--file` | `-f` | Write output to file instead of stdout | stdout | | `--check` | | Flag known-compromised actions | `false` | | `--depth` | `-d` | Max recursion depth for transitive deps | `10` | +| `--verify-shas` | | Verify pinned SHAs are reachable from upstream repo refs | `false` | +| `--fail-on-warnings` | | Exit `2` if any warnings were emitted | `false` | | `--github-token` | | GitHub token for API requests (also reads `GITHUB_TOKEN`) | | | `--no-network` | | Skip resolving transitive dependencies (local parsing only) | `false` | | `--offline` | | Use built-in advisory data only, skip remote fetch | `false` | diff --git a/cmd/check.go b/cmd/check.go index 5c4efde..5b4b872 100644 --- a/cmd/check.go +++ b/cmd/check.go @@ -8,6 +8,8 @@ import ( "github.com/julietsecurity/abom/pkg/advisory" "github.com/julietsecurity/abom/pkg/model" + "github.com/julietsecurity/abom/pkg/resolver" + "github.com/julietsecurity/abom/pkg/warnings" "github.com/spf13/cobra" ) @@ -25,6 +27,19 @@ func init() { } func runCheck(cmd *cobra.Command, args []string) error { + if verifyShas && offline { + return fmt.Errorf("--verify-shas requires network; remove --offline") + } + + col := &warnings.Collector{} + + if verifyShas && githubToken == "" { + col.Emit(warnings.Warning{ + Category: warnings.CategoryRateLimit, + Message: "--verify-shas running anonymously; 60 API calls/hour, set --github-token for realistic limits", + }) + } + var r io.Reader if useStdin { @@ -55,22 +70,36 @@ func runCheck(cmd *cobra.Command, args []string) error { abom.CollectActions() - if abom.Summary.Compromised == 0 { - fmt.Println("No compromised actions found.") - return nil + if verifyShas { + if !quiet { + fmt.Fprintln(os.Stderr, "Verifying pinned SHAs against upstream refs...") + } + resolver.VerifyABOMShas(&abom, resolver.NewGitHubSHAVerifier(githubToken), col) } - fmt.Fprintf(os.Stderr, "Found %d compromised action(s):\n\n", abom.Summary.Compromised) - for _, ref := range abom.Actions { - if ref.Compromised { - fmt.Fprintf(os.Stdout, " %s (%s)\n", ref.Raw, ref.Advisory) - for _, by := range ref.ReferencedBy { - fmt.Fprintf(os.Stdout, " referenced by: %s\n", by) + if abom.Summary.Compromised == 0 { + fmt.Println("No compromised actions found.") + } else { + fmt.Fprintf(os.Stderr, "Found %d compromised action(s):\n\n", abom.Summary.Compromised) + for _, ref := range abom.Actions { + if ref.Compromised { + fmt.Fprintf(os.Stdout, " %s (%s)\n", ref.Raw, ref.Advisory) + for _, by := range ref.ReferencedBy { + fmt.Fprintf(os.Stdout, " referenced by: %s\n", by) + } } } } - // Exit code 1 if compromised actions found - os.Exit(1) + if col.Count() > 0 { + col.Print(os.Stderr) + } + + if abom.Summary.Compromised > 0 { + return &exitError{code: 1} + } + if failOnWarnings && col.Count() > 0 { + return &exitError{code: 2} + } return nil } diff --git a/cmd/root.go b/cmd/root.go index dd3bc78..a8e8197 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,17 +1,21 @@ package cmd import ( + "errors" + "fmt" "os" "github.com/spf13/cobra" ) var ( - githubToken string - quiet bool - noCache bool - offline bool - version = "dev" + githubToken string + quiet bool + noCache bool + offline bool + verifyShas bool + failOnWarnings bool + version = "dev" ) var rootCmd = &cobra.Command{ @@ -30,13 +34,39 @@ Quick start: abom scan . -o json Output as JSON abom scan . -o cyclonedx-json Output as CycloneDX 1.5 abom scan . -o spdx-json Output as SPDX 2.3 - abom check abom.json Check a saved ABOM against advisories`, + abom check abom.json Check a saved ABOM against advisories + +Exit codes: + 0 success + 1 compromised action found, or runtime error + 2 warnings emitted with --fail-on-warnings (and no compromised actions)`, + SilenceUsage: true, + SilenceErrors: true, +} + +// exitError signals a process exit code from RunE back to Execute(). Any +// diagnostic output should already have been written to stderr before this +// is returned — Execute() will not print anything additional for exitError. +type exitError struct { + code int } +func (e *exitError) Error() string { return fmt.Sprintf("exit code %d", e.code) } + +// ExitCode returns the desired process exit code. +func (e *exitError) ExitCode() int { return e.code } + func Execute() { - if err := rootCmd.Execute(); err != nil { - os.Exit(1) + err := rootCmd.Execute() + if err == nil { + return + } + var ee *exitError + if errors.As(err, &ee) { + os.Exit(ee.code) } + fmt.Fprintln(os.Stderr, "Error:", err.Error()) + os.Exit(1) } func init() { @@ -44,5 +74,7 @@ func init() { rootCmd.PersistentFlags().BoolVarP(&quiet, "quiet", "q", false, "Suppress progress output") rootCmd.PersistentFlags().BoolVar(&noCache, "no-cache", false, "Force fresh advisory fetch, skip cache") rootCmd.PersistentFlags().BoolVar(&offline, "offline", false, "Skip advisory fetch, use built-in data only") + rootCmd.PersistentFlags().BoolVar(&verifyShas, "verify-shas", false, "Verify SHA-pinned actions are reachable from upstream repo refs (requires --github-token for realistic rate limits; requires network)") + rootCmd.PersistentFlags().BoolVar(&failOnWarnings, "fail-on-warnings", false, "Exit 2 if any warnings were emitted during the run") rootCmd.Version = version } diff --git a/cmd/scan.go b/cmd/scan.go index 9bf5889..8c64ca7 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -9,6 +9,7 @@ import ( "github.com/julietsecurity/abom/pkg/output" "github.com/julietsecurity/abom/pkg/parser" "github.com/julietsecurity/abom/pkg/resolver" + "github.com/julietsecurity/abom/pkg/warnings" "github.com/spf13/cobra" ) @@ -43,6 +44,22 @@ func init() { func runScan(cmd *cobra.Command, args []string) error { target := args[0] + if verifyShas && offline { + return fmt.Errorf("--verify-shas requires network; remove --offline") + } + if verifyShas && noNetwork { + return fmt.Errorf("--verify-shas requires network; remove --no-network") + } + + col := &warnings.Collector{} + + if verifyShas && githubToken == "" { + col.Emit(warnings.Warning{ + Category: warnings.CategoryRateLimit, + Message: "--verify-shas running anonymously; 60 API calls/hour, set --github-token for realistic limits", + }) + } + if !quiet { fmt.Fprintf(os.Stderr, "Scanning %s...\n", target) } @@ -128,6 +145,13 @@ func runScan(cmd *cobra.Command, args []string) error { abom.CollectActions() + if verifyShas { + if !quiet { + fmt.Fprintln(os.Stderr, "Verifying pinned SHAs against upstream refs...") + } + resolver.VerifyABOMShas(abom, resolver.NewGitHubSHAVerifier(githubToken), col) + } + // Write output w := os.Stdout if outputFile != "" { @@ -161,9 +185,16 @@ func runScan(cmd *cobra.Command, args []string) error { return formatErr } - // Exit code 1 if compromised actions found (usable as CI gate) + if col.Count() > 0 { + col.Print(os.Stderr) + } + + // Exit code precedence: compromised > warnings > clean. if checkAdvisory && abom.Summary.Compromised > 0 { - os.Exit(1) + return &exitError{code: 1} + } + if failOnWarnings && col.Count() > 0 { + return &exitError{code: 2} } return nil diff --git a/pkg/resolver/verify.go b/pkg/resolver/verify.go new file mode 100644 index 0000000..e09718a --- /dev/null +++ b/pkg/resolver/verify.go @@ -0,0 +1,165 @@ +package resolver + +import ( + "fmt" + "net/http" + "time" + + "github.com/julietsecurity/abom/pkg/model" + "github.com/julietsecurity/abom/pkg/warnings" +) + +// SHAVerifier verifies that a pinned commit SHA is reachable from a given +// repo's refs (and thus isn't an orphaned fork-only or force-pushed-away +// commit). +type SHAVerifier interface { + // VerifyCommit returns (true, nil) if the SHA is reachable from owner/repo's + // refs, (false, nil) if not found (404), and (false, err) for other + // failures. Rate-limit responses should be returned as a distinct error so + // callers can degrade gracefully. + VerifyCommit(owner, repo, sha string) (exists bool, err error) +} + +// ErrVerifyRateLimit is returned by verifiers when GitHub responds with 403 +// or 429. Callers should treat this as "verification unavailable" rather than +// "SHA unreachable." +var ErrVerifyRateLimit = fmt.Errorf("rate limited") + +// GitHubSHAVerifier calls the GitHub commits API to check whether a SHA is +// reachable from a repo's refs. Uses HEAD requests — cheaper than GET and +// the status code is all we need. +type GitHubSHAVerifier struct { + client *http.Client + token string +} + +func NewGitHubSHAVerifier(token string) *GitHubSHAVerifier { + return &GitHubSHAVerifier{ + client: &http.Client{Timeout: 30 * time.Second}, + token: token, + } +} + +func (v *GitHubSHAVerifier) VerifyCommit(owner, repo, sha string) (bool, error) { + url := fmt.Sprintf("https://api.github.com/repos/%s/%s/commits/%s", owner, repo, sha) + + req, err := http.NewRequest("HEAD", url, nil) + if err != nil { + return false, err + } + req.Header.Set("Accept", "application/vnd.github.v3+json") + if v.token != "" { + req.Header.Set("Authorization", "token "+v.token) + } + + resp, err := v.client.Do(req) + if err != nil { + return false, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusOK: + return true, nil + case http.StatusNotFound, http.StatusUnprocessableEntity: + // 404: commit unknown to this repo; 422: SHA malformed or unreachable. + return false, nil + case http.StatusForbidden, http.StatusTooManyRequests: + return false, ErrVerifyRateLimit + default: + return false, fmt.Errorf("unexpected status %d", resp.StatusCode) + } +} + +// VerifyABOMShas iterates the deduplicated action list produced by +// ABOM.CollectActions() and verifies each SHA-pinned reference. Results go +// into the collector; resolution behavior is unchanged. +// +// Dedup is keyed on owner/repo@sha so subdirectory variants of the same SHA +// don't cause redundant API calls. +// +// Once the verifier observes a rate-limit response, subsequent verifications +// are skipped for the remainder of the run to avoid stderr flooding. +func VerifyABOMShas(abom *model.ABOM, v SHAVerifier, col *warnings.Collector) { + if abom == nil || v == nil || col == nil { + return + } + + seen := make(map[string]struct{}) + var rateLimited bool + + for _, ref := range abom.Actions { + if ref.RefType != model.RefTypeSHA { + continue + } + switch ref.ActionType { + case model.ActionTypeDocker, model.ActionTypeLocal: + continue + } + if ref.Owner == "" || ref.Repo == "" || ref.Ref == "" { + continue + } + + // Short SHAs (7–39 hex chars) can't be reliably verified — the commits + // API accepts them but resolution is ambiguous on large repos. + if len(ref.Ref) < 40 { + key := fmt.Sprintf("%s/%s@%s", ref.Owner, ref.Repo, ref.Ref) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + col.Emit(warnings.Warning{ + Category: warnings.CategorySHAUnreachable, + Subject: subjectFor(ref), + Message: "short SHA cannot be reliably verified against upstream refs", + }) + continue + } + + key := fmt.Sprintf("%s/%s@%s", ref.Owner, ref.Repo, ref.Ref) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + + if rateLimited { + continue + } + + exists, err := v.VerifyCommit(ref.Owner, ref.Repo, ref.Ref) + if err != nil { + if err == ErrVerifyRateLimit { + rateLimited = true + col.Emit(warnings.Warning{ + Category: warnings.CategoryRateLimit, + Message: "GitHub rate limit hit during SHA verification; remaining SHAs skipped", + Err: err, + }) + continue + } + // Network / 5xx / other: advisory warning, NOT unreachable (avoids + // false positives under flaky network). + col.Emit(warnings.Warning{ + Category: warnings.CategoryRateLimit, + Subject: subjectFor(ref), + Message: "SHA verification failed (treat as advisory)", + Err: err, + }) + continue + } + if !exists { + col.Emit(warnings.Warning{ + Category: warnings.CategorySHAUnreachable, + Subject: subjectFor(ref), + Message: fmt.Sprintf("SHA not reachable from %s/%s refs (may be fork-only or force-pushed away)", ref.Owner, ref.Repo), + }) + } + } +} + +func subjectFor(ref *model.ActionRef) string { + if ref.Owner != "" && ref.Repo != "" && ref.Ref != "" { + return fmt.Sprintf("%s/%s@%s", ref.Owner, ref.Repo, ref.Ref) + } + return ref.Raw +} diff --git a/pkg/resolver/verify_test.go b/pkg/resolver/verify_test.go new file mode 100644 index 0000000..208ad60 --- /dev/null +++ b/pkg/resolver/verify_test.go @@ -0,0 +1,270 @@ +package resolver + +import ( + "fmt" + "strings" + "testing" + + "github.com/julietsecurity/abom/pkg/model" + "github.com/julietsecurity/abom/pkg/warnings" +) + +// mockVerifier records calls and returns canned outcomes per SHA. +type mockVerifier struct { + // Keyed on "owner/repo@sha". Value of 200/404/-1 (rate limit). + responses map[string]int + calls map[string]int + err error // global error (transport) +} + +func newMockVerifier() *mockVerifier { + return &mockVerifier{ + responses: make(map[string]int), + calls: make(map[string]int), + } +} + +func (m *mockVerifier) VerifyCommit(owner, repo, sha string) (bool, error) { + key := fmt.Sprintf("%s/%s@%s", owner, repo, sha) + m.calls[key]++ + if m.err != nil { + return false, m.err + } + code, ok := m.responses[key] + if !ok { + // Default to 200 if not configured + return true, nil + } + switch code { + case 200: + return true, nil + case 404: + return false, nil + case -1: + return false, ErrVerifyRateLimit + default: + return false, fmt.Errorf("mock error code %d", code) + } +} + +const fullSHA1 = "abcdef1234567890abcdef1234567890abcdef12" +const fullSHA2 = "0123456789abcdef0123456789abcdef01234567" +const fullSHA3 = "fedcba0987654321fedcba0987654321fedcba09" + +func newSHAAction(owner, repo, path, sha string) *model.ActionRef { + raw := owner + "/" + repo + if path != "" { + raw += "/" + path + } + raw += "@" + sha + return &model.ActionRef{ + Raw: raw, + Owner: owner, + Repo: repo, + Path: path, + Ref: sha, + RefType: model.RefTypeSHA, + ActionType: model.ActionTypeStandard, + } +} + +func TestVerify_ReachableSHA_NoWarning(t *testing.T) { + mv := newMockVerifier() + mv.responses[fmt.Sprintf("actions/checkout@%s", fullSHA1)] = 200 + + abom := &model.ABOM{ + Actions: []*model.ActionRef{newSHAAction("actions", "checkout", "", fullSHA1)}, + } + + col := &warnings.Collector{} + VerifyABOMShas(abom, mv, col) + + if col.Count() != 0 { + t.Errorf("expected 0 warnings, got %d: %+v", col.Count(), col.All()) + } +} + +func TestVerify_UnreachableSHA_Warning(t *testing.T) { + mv := newMockVerifier() + mv.responses[fmt.Sprintf("actions/checkout@%s", fullSHA1)] = 404 + + abom := &model.ABOM{ + Actions: []*model.ActionRef{newSHAAction("actions", "checkout", "", fullSHA1)}, + } + + col := &warnings.Collector{} + VerifyABOMShas(abom, mv, col) + + if col.Count() != 1 { + t.Fatalf("expected 1 warning, got %d", col.Count()) + } + w := col.All()[0] + if w.Category != warnings.CategorySHAUnreachable { + t.Errorf("expected SHAUnreachable, got %s", w.Category) + } + if !strings.Contains(w.Subject, fullSHA1) { + t.Errorf("expected subject to contain SHA, got %q", w.Subject) + } +} + +func TestVerify_DedupSameSHA_OneCall(t *testing.T) { + mv := newMockVerifier() + mv.responses[fmt.Sprintf("actions/checkout@%s", fullSHA1)] = 200 + + // Same SHA, two different raw refs (one as standard, one as subdirectory). + a := newSHAAction("actions", "checkout", "", fullSHA1) + b := newSHAAction("actions", "checkout", "sub", fullSHA1) + b.ActionType = model.ActionTypeSubdirectory + + abom := &model.ABOM{Actions: []*model.ActionRef{a, b}} + col := &warnings.Collector{} + VerifyABOMShas(abom, mv, col) + + key := fmt.Sprintf("actions/checkout@%s", fullSHA1) + if mv.calls[key] != 1 { + t.Errorf("expected 1 API call, got %d", mv.calls[key]) + } + if col.Count() != 0 { + t.Errorf("expected 0 warnings, got %d", col.Count()) + } +} + +func TestVerify_NonSHARef_Skipped(t *testing.T) { + mv := newMockVerifier() + + ref := &model.ActionRef{ + Raw: "actions/checkout@v4", + Owner: "actions", + Repo: "checkout", + Ref: "v4", + RefType: model.RefTypeTag, + ActionType: model.ActionTypeStandard, + } + abom := &model.ABOM{Actions: []*model.ActionRef{ref}} + + col := &warnings.Collector{} + VerifyABOMShas(abom, mv, col) + + if len(mv.calls) != 0 { + t.Errorf("expected no API calls for non-SHA ref, got %d", len(mv.calls)) + } + if col.Count() != 0 { + t.Errorf("expected 0 warnings, got %d", col.Count()) + } +} + +func TestVerify_DockerAndLocal_Skipped(t *testing.T) { + mv := newMockVerifier() + + docker := &model.ActionRef{ + Raw: "docker://alpine:3.18", + RefType: model.RefTypeSHA, // intentionally set to SHA to prove action type filter runs + ActionType: model.ActionTypeDocker, + } + local := &model.ActionRef{ + Raw: "./local-action", + RefType: model.RefTypeSHA, + ActionType: model.ActionTypeLocal, + } + abom := &model.ABOM{Actions: []*model.ActionRef{docker, local}} + + col := &warnings.Collector{} + VerifyABOMShas(abom, mv, col) + + if len(mv.calls) != 0 { + t.Errorf("expected no API calls for docker/local, got %d", len(mv.calls)) + } + if col.Count() != 0 { + t.Errorf("expected 0 warnings, got %d", col.Count()) + } +} + +func TestVerify_ShortSHA_WarnWithoutAPICall(t *testing.T) { + mv := newMockVerifier() + + short := "abcdef1" // 7 chars + ref := newSHAAction("actions", "checkout", "", short) + + abom := &model.ABOM{Actions: []*model.ActionRef{ref}} + col := &warnings.Collector{} + VerifyABOMShas(abom, mv, col) + + if len(mv.calls) != 0 { + t.Errorf("expected no API calls for short SHA, got %d", len(mv.calls)) + } + if col.Count() != 1 { + t.Fatalf("expected 1 warning, got %d", col.Count()) + } + w := col.All()[0] + if w.Category != warnings.CategorySHAUnreachable { + t.Errorf("expected SHAUnreachable, got %s", w.Category) + } + if !strings.Contains(w.Message, "short SHA") { + t.Errorf("expected short SHA message, got %q", w.Message) + } +} + +func TestVerify_TransportError_RateLimitCategory(t *testing.T) { + mv := newMockVerifier() + mv.err = fmt.Errorf("network unreachable") + + abom := &model.ABOM{ + Actions: []*model.ActionRef{newSHAAction("actions", "checkout", "", fullSHA1)}, + } + col := &warnings.Collector{} + VerifyABOMShas(abom, mv, col) + + if col.Count() != 1 { + t.Fatalf("expected 1 warning, got %d", col.Count()) + } + w := col.All()[0] + if w.Category != warnings.CategoryRateLimit { + t.Errorf("expected RateLimit (for transport errors), got %s", w.Category) + } +} + +func TestVerify_MidRunRateLimit_OneWarningOnly(t *testing.T) { + mv := newMockVerifier() + // First SHA succeeds; second hits rate limit; third would 200 but should be + // skipped because rateLimited flag is set. + mv.responses[fmt.Sprintf("a/b@%s", fullSHA1)] = 200 + mv.responses[fmt.Sprintf("c/d@%s", fullSHA2)] = -1 + mv.responses[fmt.Sprintf("e/f@%s", fullSHA3)] = 200 + + abom := &model.ABOM{ + Actions: []*model.ActionRef{ + newSHAAction("a", "b", "", fullSHA1), + newSHAAction("c", "d", "", fullSHA2), + newSHAAction("e", "f", "", fullSHA3), + }, + } + col := &warnings.Collector{} + VerifyABOMShas(abom, mv, col) + + // Exactly one rate-limit warning (for the c/d request). + if col.Count() != 1 { + t.Fatalf("expected 1 warning, got %d: %+v", col.Count(), col.All()) + } + if col.All()[0].Category != warnings.CategoryRateLimit { + t.Errorf("expected RateLimit, got %s", col.All()[0].Category) + } + + // e/f should not have been called. + if mv.calls[fmt.Sprintf("e/f@%s", fullSHA3)] != 0 { + t.Errorf("e/f should be skipped after rate limit, but was called") + } + // a/b and c/d should have each been called once. + if mv.calls[fmt.Sprintf("a/b@%s", fullSHA1)] != 1 { + t.Errorf("a/b should be called once, got %d", mv.calls[fmt.Sprintf("a/b@%s", fullSHA1)]) + } + if mv.calls[fmt.Sprintf("c/d@%s", fullSHA2)] != 1 { + t.Errorf("c/d should be called once, got %d", mv.calls[fmt.Sprintf("c/d@%s", fullSHA2)]) + } +} + +func TestVerify_NilCollector_NoPanic(t *testing.T) { + mv := newMockVerifier() + abom := &model.ABOM{Actions: []*model.ActionRef{newSHAAction("a", "b", "", fullSHA1)}} + // Should not panic. + VerifyABOMShas(abom, mv, nil) +} diff --git a/pkg/warnings/warnings.go b/pkg/warnings/warnings.go new file mode 100644 index 0000000..27eb354 --- /dev/null +++ b/pkg/warnings/warnings.go @@ -0,0 +1,64 @@ +// Package warnings collects non-fatal diagnostics emitted during an abom run. +// +// Warnings are runtime signals (e.g. a pinned SHA that can't be verified +// against the upstream repo's refs), not part of the BOM artifact itself. +// They're printed to stderr and can optionally gate the exit code via +// --fail-on-warnings. +package warnings + +import ( + "fmt" + "io" +) + +type Category string + +const ( + CategorySHAUnreachable Category = "sha-unreachable" + CategoryRateLimit Category = "rate-limit" +) + +type Warning struct { + Category Category + Subject string + Message string + Err error +} + +// Collector accumulates warnings during a run. +// +// The resolver is single-goroutine, so no mutex is needed. If concurrency is +// ever introduced in the resolve/verify path, this assumption must be +// revisited. +type Collector struct { + warnings []Warning +} + +func (c *Collector) Emit(w Warning) { + c.warnings = append(c.warnings, w) +} + +func (c *Collector) Count() int { + return len(c.warnings) +} + +func (c *Collector) All() []Warning { + return c.warnings +} + +// Print writes all collected warnings to w, one per line. +func (c *Collector) Print(w io.Writer) { + for _, warn := range c.warnings { + line := fmt.Sprintf("Warning [%s]", warn.Category) + if warn.Subject != "" { + line += " " + warn.Subject + } + if warn.Message != "" { + line += ": " + warn.Message + } + if warn.Err != nil { + line += " (" + warn.Err.Error() + ")" + } + fmt.Fprintln(w, line) + } +} diff --git a/pkg/warnings/warnings_test.go b/pkg/warnings/warnings_test.go new file mode 100644 index 0000000..59970b3 --- /dev/null +++ b/pkg/warnings/warnings_test.go @@ -0,0 +1,81 @@ +package warnings + +import ( + "bytes" + "errors" + "strings" + "testing" +) + +func TestCollector_EmptyByDefault(t *testing.T) { + c := &Collector{} + if c.Count() != 0 { + t.Errorf("Count() = %d, want 0", c.Count()) + } + if len(c.All()) != 0 { + t.Errorf("All() length = %d, want 0", len(c.All())) + } +} + +func TestCollector_EmitAndCount(t *testing.T) { + c := &Collector{} + c.Emit(Warning{Category: CategorySHAUnreachable, Subject: "actions/checkout@abc123", Message: "not in upstream refs"}) + c.Emit(Warning{Category: CategoryRateLimit, Message: "rate limited"}) + + if c.Count() != 2 { + t.Fatalf("Count() = %d, want 2", c.Count()) + } + + all := c.All() + if all[0].Category != CategorySHAUnreachable { + t.Errorf("first category = %s, want %s", all[0].Category, CategorySHAUnreachable) + } + if all[1].Category != CategoryRateLimit { + t.Errorf("second category = %s, want %s", all[1].Category, CategoryRateLimit) + } +} + +func TestCollector_Print(t *testing.T) { + c := &Collector{} + c.Emit(Warning{ + Category: CategorySHAUnreachable, + Subject: "actions/checkout@abc123", + Message: "SHA not reachable from actions/checkout refs", + }) + c.Emit(Warning{ + Category: CategoryRateLimit, + Message: "rate limited by GitHub", + Err: errors.New("403 Forbidden"), + }) + + var buf bytes.Buffer + c.Print(&buf) + out := buf.String() + + if !strings.Contains(out, "sha-unreachable") { + t.Errorf("output missing category: %q", out) + } + if !strings.Contains(out, "actions/checkout@abc123") { + t.Errorf("output missing subject: %q", out) + } + if !strings.Contains(out, "rate-limit") { + t.Errorf("output missing rate-limit category: %q", out) + } + if !strings.Contains(out, "403 Forbidden") { + t.Errorf("output missing wrapped error: %q", out) + } + + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + if len(lines) != 2 { + t.Errorf("expected 2 lines, got %d: %q", len(lines), out) + } +} + +func TestCollector_PrintEmpty(t *testing.T) { + c := &Collector{} + var buf bytes.Buffer + c.Print(&buf) + if buf.Len() != 0 { + t.Errorf("empty collector should produce no output, got %q", buf.String()) + } +}