Skip to content
Merged
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
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ Every post-incident guide from CrowdStrike, Wiz, Snyk, and Microsoft tells you t
- **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
- **SHA verification** — optionally verify that pinned SHAs are actually reachable from the upstream repo, catching fork-sourced and force-pushed-away commits (`--verify-shas`)
- **Ref resolution** — optionally resolve tag and branch refs to the commit SHA they point to at BOM-generation time, turning a mutable-tag BOM into a stable evidentiary record (`--resolve-refs`)
- **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

Expand Down Expand Up @@ -138,6 +139,33 @@ abom scan . --verify-shas --fail-on-warnings --github-token $GITHUB_TOKEN

**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.

## Resolving tag and branch refs

Git tags are mutable. A workflow pinned to `actions/checkout@v4` today may resolve to a different commit next week if the maintainer re-points the tag. This means a BOM with tag refs is only a semi-reliable record of what actually ran. Branches (`main`, `master`) are even more mutable.

For teams generating BOMs as audit or compliance evidence, `--resolve-refs` calls the GitHub commits API to look up the commit SHA each tag or branch currently points to and records it in `resolved_sha` alongside the original ref. The original pinning is preserved so contributor intent stays visible.

```bash
abom scan . --resolve-refs --github-token $GITHUB_TOKEN
```

Output (JSON) gets a stable record of what was actually resolved at generation time:

```json
{
"uses": "actions/checkout@v4",
"ref": "v4",
"ref_type": "tag",
"resolved_sha": "34e114876b0b11c390a56381ad16ebd13914f8d5"
}
```

**Scope:** tag and branch refs only. SHA-pinned refs are already immutable and skipped. Docker and local actions are also skipped.

**Interaction with `--verify-shas`:** orthogonal. `--verify-shas` walks SHA-pinned refs; `--resolve-refs` populates `resolved_sha` for tag and branch refs. Running both together populates both signals.

**Rate limit caveat:** same as `--verify-shas` — one API call per unique tag or branch ref, so a token is effectively required for any workflow with more than a handful of tagged actions.

## How detection works

`abom` finds compromised dependencies through three layers that grep will never reach:
Expand Down Expand Up @@ -179,6 +207,7 @@ Current advisories:
| `--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` |
| `--resolve-refs` | | Resolve tag and branch refs to current commit SHAs | `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` |
Expand Down
16 changes: 16 additions & 0 deletions cmd/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ func runCheck(cmd *cobra.Command, args []string) error {
if verifyShas && offline {
return fmt.Errorf("--verify-shas requires network; remove --offline")
}
if resolveRefs && offline {
return fmt.Errorf("--resolve-refs requires network; remove --offline")
}

col := &warnings.Collector{}

Expand All @@ -39,6 +42,12 @@ func runCheck(cmd *cobra.Command, args []string) error {
Message: "--verify-shas running anonymously; 60 API calls/hour, set --github-token for realistic limits",
})
}
if resolveRefs && githubToken == "" {
col.Emit(warnings.Warning{
Category: warnings.CategoryRateLimit,
Message: "--resolve-refs running anonymously; 60 API calls/hour, set --github-token for realistic limits",
})
}

var r io.Reader

Expand Down Expand Up @@ -70,6 +79,13 @@ func runCheck(cmd *cobra.Command, args []string) error {

abom.CollectActions()

if resolveRefs {
if !quiet {
fmt.Fprintln(os.Stderr, "Resolving tag and branch refs to commit SHAs...")
}
resolver.ResolveABOMRefs(&abom, resolver.NewGitHubRefResolver(githubToken), col)
}

if verifyShas {
if !quiet {
fmt.Fprintln(os.Stderr, "Verifying pinned SHAs against upstream refs...")
Expand Down
2 changes: 2 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ var (
noCache bool
offline bool
verifyShas bool
resolveRefs bool
failOnWarnings bool
version = "dev"
)
Expand Down Expand Up @@ -75,6 +76,7 @@ func init() {
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(&resolveRefs, "resolve-refs", false, "Resolve tag and branch refs to the commit SHA they currently point to, stored alongside the original ref (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
}
19 changes: 19 additions & 0 deletions cmd/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ func runScan(cmd *cobra.Command, args []string) error {
if verifyShas && noNetwork {
return fmt.Errorf("--verify-shas requires network; remove --no-network")
}
if resolveRefs && offline {
return fmt.Errorf("--resolve-refs requires network; remove --offline")
}
if resolveRefs && noNetwork {
return fmt.Errorf("--resolve-refs requires network; remove --no-network")
}

col := &warnings.Collector{}

Expand All @@ -59,6 +65,12 @@ func runScan(cmd *cobra.Command, args []string) error {
Message: "--verify-shas running anonymously; 60 API calls/hour, set --github-token for realistic limits",
})
}
if resolveRefs && githubToken == "" {
col.Emit(warnings.Warning{
Category: warnings.CategoryRateLimit,
Message: "--resolve-refs running anonymously; 60 API calls/hour, set --github-token for realistic limits",
})
}

if !quiet {
fmt.Fprintf(os.Stderr, "Scanning %s...\n", target)
Expand Down Expand Up @@ -145,6 +157,13 @@ func runScan(cmd *cobra.Command, args []string) error {

abom.CollectActions()

if resolveRefs {
if !quiet {
fmt.Fprintln(os.Stderr, "Resolving tag and branch refs to commit SHAs...")
}
resolver.ResolveABOMRefs(abom, resolver.NewGitHubRefResolver(githubToken), col)
}

if verifyShas {
if !quiet {
fmt.Fprintln(os.Stderr, "Verifying pinned SHAs against upstream refs...")
Expand Down
159 changes: 159 additions & 0 deletions pkg/resolver/resolve_refs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package resolver

import (
"encoding/json"
"fmt"
"io"
"net/http"
"time"

"github.com/julietsecurity/abom/pkg/model"
"github.com/julietsecurity/abom/pkg/warnings"
)

// RefResolver resolves a tag or branch reference to the commit SHA it
// currently points to.
type RefResolver interface {
// ResolveRef returns the commit SHA for owner/repo@ref. Returns an error
// for network failures, 404, rate limiting, etc.
ResolveRef(owner, repo, ref string) (sha string, err error)
}

// ErrResolveRateLimit signals that GitHub returned 403 or 429. Callers should
// stop issuing further resolve calls.
var ErrResolveRateLimit = fmt.Errorf("rate limited")

// GitHubRefResolver resolves refs via the GitHub commits API. The commits
// endpoint accepts tags, branches, and SHAs, and returns the resolved commit
// object, so one call handles all ref types.
type GitHubRefResolver struct {
client *http.Client
token string
}

func NewGitHubRefResolver(token string) *GitHubRefResolver {
return &GitHubRefResolver{
client: &http.Client{Timeout: 30 * time.Second},
token: token,
}
}

func (r *GitHubRefResolver) ResolveRef(owner, repo, ref string) (string, error) {
url := fmt.Sprintf("https://api.github.com/repos/%s/%s/commits/%s", owner, repo, ref)

req, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", err
}
req.Header.Set("Accept", "application/vnd.github.v3+json")
if r.token != "" {
req.Header.Set("Authorization", "token "+r.token)
}

resp, err := r.client.Do(req)
if err != nil {
return "", fmt.Errorf("HTTP request failed: %w", err)
}
defer resp.Body.Close()

switch resp.StatusCode {
case http.StatusOK:
// parse body
case http.StatusNotFound, http.StatusUnprocessableEntity:
return "", fmt.Errorf("ref not found")
case http.StatusForbidden, http.StatusTooManyRequests:
return "", ErrResolveRateLimit
default:
return "", fmt.Errorf("unexpected status %d", resp.StatusCode)
}

body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", fmt.Errorf("reading response: %w", err)
}

var payload struct {
SHA string `json:"sha"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return "", fmt.Errorf("parsing commit response: %w", err)
}
if payload.SHA == "" {
return "", fmt.Errorf("no sha in commit response")
}
return payload.SHA, nil
}

// ResolveABOMRefs iterates the deduplicated action list and resolves each
// tag- or branch-pinned reference to its current commit SHA. Stores the
// result in ActionRef.ResolvedSHA.
//
// Dedup is keyed on owner/repo@ref so subdirectory variants of the same
// action collapse into a single API call.
//
// Once the resolver observes a rate-limit response, subsequent resolutions
// are skipped for the remainder of the run.
func ResolveABOMRefs(abom *model.ABOM, r RefResolver, col *warnings.Collector) {
if abom == nil || r == nil || col == nil {
return
}

type cacheKey struct {
owner, repo, ref string
}
cache := make(map[cacheKey]string)
var rateLimited bool

for _, ref := range abom.Actions {
if ref.RefType != model.RefTypeTag && ref.RefType != model.RefTypeBranch {
continue
}
switch ref.ActionType {
case model.ActionTypeDocker, model.ActionTypeLocal:
continue
}
if ref.Owner == "" || ref.Repo == "" || ref.Ref == "" {
continue
}

key := cacheKey{ref.Owner, ref.Repo, ref.Ref}
if sha, ok := cache[key]; ok {
ref.ResolvedSHA = sha
continue
}

if rateLimited {
continue
}

sha, err := r.ResolveRef(ref.Owner, ref.Repo, ref.Ref)
if err != nil {
if err == ErrResolveRateLimit {
rateLimited = true
col.Emit(warnings.Warning{
Category: warnings.CategoryRateLimit,
Message: "GitHub rate limit hit during ref resolution; remaining refs skipped",
Err: err,
})
continue
}
col.Emit(warnings.Warning{
Category: warnings.CategoryRefResolve,
Subject: refResolveSubject(ref),
Message: "could not resolve ref to a commit SHA",
Err: err,
})
continue
}

cache[key] = sha
ref.ResolvedSHA = sha
}
}

func refResolveSubject(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
}
Loading
Loading