Skip to content

Add --verify-shas and --fail-on-warnings#4

Merged
TooFastTooCurious merged 2 commits intomainfrom
verify-shas
Apr 15, 2026
Merged

Add --verify-shas and --fail-on-warnings#4
TooFastTooCurious merged 2 commits intomainfrom
verify-shas

Conversation

@TooFastTooCurious
Copy link
Copy Markdown
Contributor

Summary

Closes #2.

Adds two new flags that let ABOM detect SHA-pinned action references that aren't actually reachable from their claimed upstream repo — the real class of supply-chain risk SHA pinning is supposed to protect against.

  • --verify-shas (opt-in) hits api.github.com/repos/{owner}/{repo}/commits/{sha} for each unique SHA-pinned reference and emits a warning when the SHA isn't reachable from the claimed repo's refs.
  • --fail-on-warnings exits 2 if any warnings were emitted. Generic on purpose so future warning categories (deprecated refs, suspicious renames, missing metadata, etc.) compose without growing the CLI surface.

Resolution behavior is unchanged. ABOM still mirrors GitHub's actual fetch path; --verify-shas only adds advisory signal on top.

What --verify-shas actually catches

                GitHub fork-network object store
                ┌──────────────────────────────┐
                │                              │
   owner/repo ──┤  commits reachable via refs  │──► :white_check_mark: resolves via raw.githubusercontent.com
                │       (main, tags, PRs)      │    :white_check_mark: reachable from owner/repo's refs
                │                              │
  owner/forkA ──┤                              │──► :white_check_mark: resolves via raw.githubusercontent.com
                │  commits only in forks       │    :x: NOT reachable from owner/repo's refs
                │   (orphaned, force-pushed,   │         ^^^^ this is what --verify-shas catches
                │    deleted from upstream)    │
                └──────────────────────────────┘

A pin like actions/checkout@<sha> succeeds even when <sha> was only ever committed to a fork. GitHub's object store doesn't care which fork originated the commit — the raw content endpoint serves it regardless. The commits API, scoped to owner/repo, is the first endpoint that distinguishes "reachable from this repo's refs" from "known to the object store."

Pipeline integration

   abom scan [target]
        │
        ▼
   ┌─────────────┐
   │ parse YAML  │
   └──────┬──────┘
          ▼
   ┌──────────────────────┐
   │ resolve transitive   │ (raw.githubusercontent.com)
   │    dependencies      │
   └──────┬───────────────┘
          ▼
   ┌──────────────────────┐
   │ advisory.CheckAll    │ (if --check)
   └──────┬───────────────┘
          ▼
   ┌──────────────────────┐
   │ abom.CollectActions  │ (dedup + summary)
   └──────┬───────────────┘
          ▼
   ┌──────────────────────┐     ┌──────────────────────┐
   │ VerifyABOMShas       │────►│ warnings.Collector   │
   │ (if --verify-shas)   │     │  - SHAUnreachable    │
   └──────┬───────────────┘     │  - RateLimit         │
          ▼                     └──────────────────────┘
   ┌──────────────────────┐              │
   │ format output        │              │
   └──────┬───────────────┘              ▼
          ▼                     ┌──────────────────────┐
   ┌──────────────────────┐     │ col.Print(stderr)    │
   │ exit code precedence │◄────┤                      │
   └──────────────────────┘     └──────────────────────┘

Verification runs as a post-pass over the deduplicated action list (not during resolution). Dedup keys on owner/repo@sha, so subdirectory variants of the same SHA collapse into a single API call.

Verifier response handling

   HEAD api.github.com/repos/{owner}/{repo}/commits/{sha}
                       │
        ┌──────────────┴──────────────┐
        │                             │
        ▼                             ▼
    status=200                    status=404 or 422
        │                             │
        ▼                             ▼
    :white_check_mark: reachable                   :warning:  SHAUnreachable warning
    (no warning)                  "SHA not reachable from
                                   owner/repo refs"

   status=403 or 429  ────►  :warning:  RateLimit (one warning only,
                                subsequent verifications skipped)

   transport / 5xx    ────►  :warning:  RateLimit (generic — never
                                SHAUnreachable, avoids false
                                positives under flaky network)

   short SHA (<40)    ────►  :warning:  SHAUnreachable ("short SHA
   [no API call]           cannot be reliably verified")

Exit code contract

                  ┌─────────────────── compromised found? ──────────────────┐
                  │                                                         │
                  ▼                                                         ▼
                 NO                                                       YES
                  │                                                         │
      ┌───── warnings? ─────┐                                         exit 1
      │                     │                                    (overrides warnings)
      ▼                     ▼
     NO                    YES
      │                     │
   exit 0        ┌─ --fail-on-warnings? ─┐
                 │                       │
                 ▼                       ▼
                NO                      YES
                 │                       │
              exit 0                  exit 2
           (warnings on                 (CI gate)
            stderr only)
Compromised action Warnings --fail-on-warnings Exit
any 1
any 1
2
0
any 0

Previously the only exit signal was 1 on compromised actions — existing CI pipelines continue to work identically when neither new flag is set. Exit 2 is a new signal and only engages when --fail-on-warnings is explicitly requested.

CI usage

- name: Check Actions supply chain
  run: abom scan . --check --verify-shas --fail-on-warnings
  env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

GITHUB_TOKEN is essentially required — anonymous API requests are capped at 60/hour; authenticated requests get 5000/hour. A startup warning fires (and is caught by --fail-on-warnings) when --verify-shas runs without a token.

Files changed

cmd/
  root.go      +48 / -8    Add persistent flags; exitError type; Execute() unwraps
  scan.go      +35 / -2    Wire verification post-pass; layered exit logic
  check.go     +51 / -15   Mirror scan wiring; replace os.Exit(1) with exitError

pkg/warnings/
  warnings.go          +65  Collector, Warning, Category
  warnings_test.go     +79  Emit/Count/All/Print round-trip

pkg/resolver/
  verify.go            +140 SHAVerifier, GitHubSHAVerifier, VerifyABOMShas
  verify_test.go       +242 mockVerifier + 9 test cases

README.md      +36 / -1    New section, flag table, exit code contract

Total: ~700 insertions, 22 deletions. No pre-existing tests modified.

Test plan

Automated (all passing locally):

  • go test ./... — new pkg/warnings (4 tests) and pkg/resolver/verify_test.go (9 tests) plus all pre-existing tests
  • go vet ./...
  • go build ./...

Verify test matrix:

  • Reachable SHA (200) → no warning
  • Unreachable SHA (404) → exactly one SHAUnreachable warning
  • Dedup: same owner/repo@sha across multiple action entries → one API call
  • Non-SHA refs (tag/branch) → skipped, no API call
  • Docker / local action types → skipped
  • Short SHA (7-39 hex) → warning without API call
  • Transport error → RateLimit category (not SHAUnreachable — avoids false positives)
  • Mid-run 403 → single RateLimit warning, subsequent verifications suppressed
  • Nil collector → no panic

Manual smoke:

  • --verify-shas --offline → fails fast with "--verify-shas requires network"
  • --verify-shas --no-network → fails fast
  • abom --help lists both new flags plus the exit code contract

Live integration against api.github.com was not included — anonymous rate limits make it flaky in CI, and the mock-based tests exhaustively cover the response-code matrix. Happy to add a tagged integration test if desired.

Design notes for reviewers

  • Why a separate pkg/warnings package instead of attaching to ABOM? Warnings are runtime diagnostics against a live API, not a property of the workflows scanned. Keeping them out of the serialized BOM preserves artifact reproducibility. If consumers ever want per-ref status in JSON output, an optional VerifiedSHA field on ActionRef is easy to add as a follow-up.
  • Why HEAD instead of GET? Cheaper and we only need the status code.
  • Why a post-pass instead of inline during resolve? Dedup happens naturally via CollectActions(), so the post-pass avoids redundant verification of the same SHA appearing in multiple composite actions. It also means --verify-shas works identically under abom check on a pre-generated ABOM.
  • Why single-goroutine collector? The existing resolver is a sequential DFS. The Collector comment makes this assumption explicit — if concurrency is introduced later, the comment forces the decision to be revisited.

Follow-ups not included

  • Existing ad-hoc fmt.Fprintf(os.Stderr, "Warning: ...") sites in pkg/resolver/remote.go and elsewhere still bypass the collector. Migrating them would give --fail-on-warnings uniform coverage. Intentionally deferred to keep this PR focused.
  • Optional ActionRef.VerifiedSHA *bool field for per-ref status in JSON output. Not added to avoid changing the serialized BOM schema in this PR.
  • Persistent cross-run verification cache. Probably premature — a single run has few enough unique SHAs that an in-memory map is sufficient.

Verify that SHA-pinned action references are actually reachable from
their upstream repo's refs. GitHub's fork-network object store means a
workflow pinned to owner/repo@sha will still resolve and execute even
if the SHA originated from a fork, not the claimed repo — defeating the
supply-chain protection that SHA pinning is supposed to provide.

--verify-shas (opt-in) issues HEAD requests to
api.github.com/repos/{owner}/{repo}/commits/{sha} for each unique
SHA-pinned ref and emits a warning on 404. Resolution behavior is
unchanged — ABOM still mirrors GitHub's actual fetch path.

--fail-on-warnings is a generic exit gate chosen over per-warning flags
so future warning categories (deprecated refs, suspicious renames,
etc.) compose without growing CLI surface.

New packages:
- pkg/warnings: Collector for non-fatal runtime diagnostics
- pkg/resolver/verify.go: SHAVerifier interface + GitHub impl

Exit code contract: 0 clean, 1 compromised advisory (existing), 2
warnings-only with --fail-on-warnings. Compromised wins when both
apply. Cmd RunE paths now return a structured exitError so logic is
unit-testable instead of calling os.Exit directly.

Edge cases handled:
- Short SHAs (7-39 hex) warned without API call (ambiguous resolution)
- Transport errors emit RateLimit (advisory), not SHAUnreachable (false positive)
- Mid-run 403/429 emits one warning, suppresses remaining verifications
- Dedup keyed on owner/repo@sha (subdirectory variants collapse)
- Docker and local actions skipped
- --verify-shas --offline / --verify-shas --no-network fail fast
Comment thread cmd/check.go Outdated
Comment thread cmd/scan.go Outdated
@TooFastTooCurious TooFastTooCurious merged commit 47b612d into main Apr 15, 2026
2 checks passed
@TooFastTooCurious TooFastTooCurious deleted the verify-shas branch April 15, 2026 13:49
digorgonzola added a commit to digorgonzola/abom that referenced this pull request Apr 23, 2026
Expand the comment to explain why tag resolution is gated on --check
independently of --verify-shas: git ls-remote doesn't consume REST API
quota, making it safe to run without explicit opt-in to SHA verification.

Addresses PR review item JulietSecurity#4.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
TooFastTooCurious pushed a commit that referenced this pull request Apr 23, 2026
…omised (#8)

* fix: resolve SHA-pinned refs to upstream tags before advisory matching

SHA-pinned action refs (e.g., `owner/repo@<sha>`) that match an
advisory's affected package are currently flagged as compromised with
"verify manually", even when the SHA corresponds to a fixed version.
This is because SHAs cannot be ordinally compared against version
ranges, so `matchAffected` short-circuits with `"verify-sha"`.

This change adds a tag resolution step that uses the GitHub API to
determine which tag a verified SHA actually points to. Once the
upstream tag is known, the advisory checker can compare it against
affected version ranges and correctly clear the compromised flag
for refs pinned to fixed versions.

Changes:
- Add `ResolvedTag` field to `ActionRef` model
- Add `TagResolver` interface and `GitHubTagResolver` implementation
  that resolves a SHA to its tag via the GitHub tags API
- Add `ResolveABOMTags` function to populate `ResolvedTag` for all
  SHA-pinned actions after SHA verification
- Update `matchAffected` to use `ResolvedTag` for version comparison
  when available, falling back to `"verify-sha"` only when the tag
  is unknown
- Add `RecheckSHARefs` method to re-evaluate previously flagged SHA
  refs after tag resolution, clearing false positives
- Wire tag resolution and re-checking into the scan pipeline after
  `--verify-shas`

Fixes false positives where `--verify-shas --check` flags SHA-pinned
refs at fixed versions (e.g., setup-trivy@<sha> # v0.2.6).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: skip detected-tool match for actions with resolved tag outside affected range

The detected-tool pathway in Check() was re-flagging SHA-pinned actions
(like setup-trivy) even after matchAffected correctly determined they
were outside the affected version range via ResolvedTag. This happened
because detected-tool matching only checks tool names, not versions.

Also fix tag propagation: CollectActions deduplicates by Raw, so when
the same action appears in multiple workflows, only one ActionRef gets
ResolvedTag set by ResolveABOMTags. RecheckSHARefs now propagates
resolved tags to all duplicate workflow-level refs before rechecking.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: only resolve tags for advisory-flagged SHA refs

ResolveABOMTags was calling the GitHub tags API for every SHA-pinned
action (~20-30 per repo), when only the 0-2 refs flagged by advisory
checking actually need tag resolution. This triggered GitHub's
secondary rate limit in CI, causing tag resolution to be skipped
entirely and leaving false positives unresolved.

Add a Compromised filter to ResolveABOMTags so it only resolves tags
for refs that CheckAll flagged with "verify manually". This reduces
API calls from ~N to ~0-2, avoiding rate limits in practice.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: run tag resolution before SHA verification to avoid rate limits

Move advisory-flagged tag resolution ahead of VerifyABOMShas in the
scan pipeline. SHA verification makes ~30 HEAD requests in a tight
loop and can trigger GitHub's secondary rate limit, leaving no API
budget for the 1-2 tag resolution calls that clear false positives.

Also decouple tag resolution from --verify-shas: it now runs whenever
--check is used, since advisory false positives need clearing
regardless of whether full SHA verification was requested.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use git ls-remote for tag resolution instead of REST API

The GitHub REST API tags endpoint shares rate limit quota with all
other API calls. In CI environments, the GITHUB_TOKEN budget is often
exhausted by other workflow steps or concurrent runs, causing tag
resolution to fail with 403 even on its very first request.

Switch GitHubTagResolver to use `git ls-remote --tags` which uses the
git protocol instead of the REST API. This doesn't consume API rate
limit quota and works in CI where git credentials are already
configured by actions/checkout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: clean up dead code and auth path in GitHubTagResolver.ResolveTag

Remove fake GIT_ASKPASS_TOKEN env var (not a real git variable), eliminate
the confusing cmd.Args rebuild pattern, and set GIT_TERMINAL_PROMPT=0
unconditionally. Document the http.extraHeader trade-off (token visible
in process list).

Addresses PR review blocker #1.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove unreachable ErrVerifyRateLimit branch from ResolveABOMTags

git ls-remote never returns ErrVerifyRateLimit (only GitHubSHAVerifier
does, for HTTP 403/429). Remove the dead rateLimited flag and sentinel
check, keeping the general error path for actual git ls-remote failures.

Addresses PR review blocker #2.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: eliminate duplicate advisory DB load in runScan

Hoist the Database instance above both advisory blocks so CheckAll and
RecheckSHARefs share a single load. The second NewDatabase call was
re-fetching from remote (NoCache: true skips cache, not uses it) and
could return a different advisory version than the first.

Addresses PR review blocker #3.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: clarify that tag resolution runs with --check, not --verify-shas

Expand the comment to explain why tag resolution is gated on --check
independently of --verify-shas: git ls-remote doesn't consume REST API
quota, making it safe to run without explicit opt-in to SHA verification.

Addresses PR review item #4.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove redundant second loop in RecheckSHARefs

CollectActions populates abom.Actions from the workflow walk, so every
entry is already reached by the first loop. The second loop rechecked
the same refs with a misleading comment about unreachable entries.

Addresses PR review item #6.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Clarify on behavior when using pinned SHA1s from forks

2 participants