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
36 changes: 35 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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` |
Expand Down
51 changes: 40 additions & 11 deletions cmd/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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 {
Expand Down Expand Up @@ -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
}
48 changes: 40 additions & 8 deletions cmd/root.go
Original file line number Diff line number Diff line change
@@ -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{
Expand All @@ -30,19 +34,47 @@ 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() {
rootCmd.PersistentFlags().StringVar(&githubToken, "github-token", os.Getenv("GITHUB_TOKEN"), "GitHub token for API requests")
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
}
35 changes: 33 additions & 2 deletions cmd/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 != "" {
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading