Skip to content
0xsan7Public

About

SBOM generator and supply chain risk scanner for npm, PyPI and Go. CycloneDX 1.5 and SPDX 2.3, OSV + CVSS + EPSS, drift against a baseline, and a fail-closed design: it reports an error rather than a clean bill of health when it cannot resolve a lockfile or reach vulnerability data.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

82 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

SCRAM

Supply Chain Risk Assessment & Monitoring. A dependency scanner built around one question: what did this pull request actually change?

 ___  ___ ___    _   __  __ 
/ __|/ __| _ \  /_\ |  \/  |
\__ \ (__|   / / _ \| |\/| |
|___/\___|_|_\/_/ \_\_|  |_|

v0.2.0 · released · 5 signed binaries

Upgrading from v0.1.0? JSON schema 2.0.0 reports scores out of 65. Read the migration notes before upgrading a JSON consumer.

The three things that are hard to get at once

A scanner can do any one of these. Doing all three means the score, the diff, and the history all come from the same resolved inventory — no second parse, no second database, and nowhere for them to disagree.

1. Drift, not a score. A team with 200 pre-existing findings will switch off any tool that blocks every PR on day one. SCRAM scans the base branch and the head, and blocks only on what is new.

2. Blame, not a timestamp. Knowing a package is vulnerable is half an answer. scram blame finds the commit that introduced the version you are actually shipping, and who wrote it.

3. Arithmetic you can check. scram scan --explain prints the numbers that produced the score, and names the commit and date of the OpenSSF Scorecard behind the maintenance term — so a score nobody measured reads as a score nobody measured, instead of looking like a clean bill of health.

What that looks like on a real pull request

$ scram scan --epss
SCRAM supply chain scan
────────────────────────────────────────────────────────────────
  repo        demo-app
  components  1
  findings    2 vulnerability record(s)
  repo score  37/65  LOW
  breakdown   critical 0  high 0  medium 0  low 1  clean 0

  Drift since baseline
    added 0   version-changed 1   removed 0
    new vulnerabilities 1   resolved 4
        new high 1, new critical 0

CHG  LOW       pkg:npm/lodash@4.17.21
         score 37/65, 2 finding(s)
         N GHSA-r5fr-rjxr-66jc cvss 8.1  epss 21.33%  fixed in 4.18.0
           GHSA-f23m-r3pf-42rh cvss 6.5  epss  1.85%  fixed in 4.18.0

FAIL  (fail-on: high, new findings only: true)
  - GHSA-r5fr-rjxr-66jc affects pkg:npm/lodash@4.17.21 (severity high, at or above fail-on high)

Real output, from ./examples/lodash-drift/run.sh: lodash 4.17.11 → 4.17.21 in a git repo with a committed baseline. The commit line and the EPSS percentages are the fields that change between runs, because EPSS is recomputed daily upstream; everything else is reproduced byte-for-byte, including the FAIL and the exit code. Run the script to confirm it against whatever OSV says today.

lodash went up a version, and the commit that did it is in the output. Four findings went away, one arrived, and the gate names only the new one.

And the same repository, asked about differently. scram blame on a full clone of lodash:

$ scram blame pkg:npm/ajv@6.10.2

pkg:npm/ajv@6.10.2
  current version   6.10.2
  introduced       8.1y ago, eaa9f36, by John-David Dalton
                     at 5.5.2
  last changed      7.2y ago, b185fce, by John-David Dalton
                     to 6.10.2 — Rebuild lodash and docs.
  commits scanned   16

The advisory is recent; the dependency has been pinned since 2018. Those are different problems with different fixes, and this is what tells them apart. In a shallow clone — what CI gives you by default — blame refuses to guess and says so, rather than reporting the clone date as the date the dependency arrived:

WARNING           shallow clone: this is the first commit in the LOCAL history, not necessarily when the dependency arrived

Contents


How this compares

Most of this table is not in SCRAM's favour. It is here because a comparison that only lists your own column is marketing, and because you should be able to check it.

Two of the claims a smaller tool would make are false, and they are corrected in the notes below rather than quietly omitted.

Trivy Grype OSV-Scanner Snyk dependency-review-action SCRAM
License Apache-2.0 Apache-2.0 Apache-2.0 Proprietary MIT (service is paid for private repos) Apache-2.0
Ecosystems 13 langs + 20+ OS distros 11 langs + OS 13 langs, ~29 lockfile formats broad, per-language 19 4 (npm, PyPI, Go, Cargo)
Fails a PR on new findings no no no PR checks, not a finding diff yes, natively yes, finding-level
Attributes a vulnerable version to the commit that introduced it no no no no no yes
EPSS in default output no yes no yes (prioritization) no yes
Scoring you can inspect documented vendor/CVSS policy grype explain + match types + RISK open DB provenance no no printed arithmetic per component
Runs without network yes yes, but hard-fails on a DB over 5 days old --offline no (hosted) no (hosted API) inventory only; vulnerability scans fail explicitly
Scans more than dependencies containers, VMs, k8s, IaC, secrets SBOM in/out C/C++ vendored, Maven transitive resolution SAST, DAST, containers, IaC, secrets no no

What the other tools do better

Conceding these is the point of the table.

  • Ecosystem breadth is not close. OSV-Scanner reads ~29 lockfile formats across 13 languages; GitHub's dependency graph covers 19. SCRAM supports four ecosystems. If your project is a Kotlin service, use one of them instead of SCRAM — this is a scope decision, not a technical one, and it is the single largest gap here.
  • Trivy scans things that are not lockfiles at all: container images, VM images, Kubernetes clusters, IaC misconfigurations, and secrets. SCRAM reads dependency manifests. If you need a container posture, Trivy is a different and much larger tool.
  • Grype already ships EPSS, CISA KEV, and a combined RISK score in its default table, and has a grype explain subcommand and per-match provenance (exact-direct-match, cpe-match) that is at least a peer to "the arithmetic is printed." Do not read this table as claiming SCRAM owns inspectability or EPSS.
  • Snyk is a commercial product with a web UI, organisation-wide dashboards, SLA reporting, and automated Fix PRs that open the pull request for you. That is real product surface and none of it is in scope here.
  • GitHub's dependency-review-action is a working, free, org-enforceable PR gate for public repositories, with a dependency diff rendered natively on the PR's Files Changed tab. You need no tool from this page to get that.
  • GitHub's dependency graph REST API also diffs any two commits, not just a pull request, so a base-vs-head comparison is available without a PR. What it diffs is still the dependency inventory, and it still reads from the dependency-submission API — which is why GitHub ships a retry-on-snapshot-warnings input: their own diff primitive can silently miss changes when those snapshots are racy. Silent under-reporting is a failure mode, and it is not one this tool has.
  • Grype keeps its vulnerability database in a downloaded cache, which is a different model from SCRAM's: SCRAM asks OSV and FIRST per scan and caches the answers, so there is no index to age out of date. A locally-cached database is faster and air-gappable, and it is also a thing that can go stale without anyone noticing.

The two claims worth making anyway

  • Finding-level drift. GitHub's action diffs the dependency inventory — which packages were added. SCRAM diffs the findings: it scans the base branch and the head, and blocks only on vulnerabilities that are new. If a package you already ship is newly disclosed as critical, GitHub's gate does not go red; SCRAM's does. GitHub's dependency-submission snapshots can also be missing or stale, in which case it sees fewer changes than exist.
  • Per-commit blame of a vulnerable dependency. None of the five attributes a vulnerable version to the commit that introduced it. Snyk is the interesting case: its entire 608KB documentation corpus contains zero hits for git history, blame, or introducing commit. (GitHub's code scanning product does track introducing commits — for source-code findings, on a different data model. That is not dependency blame.)

What is not a differentiator

  • EPSS. Grype and Snyk have it. SCRAM having it is table stakes.
  • Explainable scoring. Competitive, not unique. Grype's explain, RISK, and match-type provenance are a fair peer. The claim that is true is narrower: SCRAM prints the arithmetic per component, and --explain names the commit and date of the OpenSSF Scorecard the maintenance term came from.
  • Being a PR gate. GitHub gives you one free on public repos.

Sources. Every claim above was read from each project's own documentation or repository, not from comparison blog posts. Where a claim could not be verified it was left out rather than guessed: Trivy's database size and speed are not documented in prose, and OSV-Scanner's dependency-confusion detection could not be found in its docs, so neither is asserted above.

Why drift beats a score

A team with 200 pre-existing medium findings will switch off any tool that blocks every PR on day one. So SCRAM gates on new findings by default, and treats a baseline as a first-class input.

Situation Default result
200 pre-existing mediums, PR changes nothing Passes
PR bumps lodash 4.17.11 → 4.17.21, which resolves four findings and pulls in an 8.1 one Fails, naming GHSA-r5fr-rjxr-66jc
A known-unreachable finding with a current waiver Passes
Same finding, waiver expired yesterday Fails — expired waivers re-trigger
OSV is unreachable Fails — see Failing closed
Lockfile present, zero components resolved Fails — see Failing closed

The last two matter more than they look. Both are cases where the honest answer is "I don't know", and a scanner that answers "clean" is worse than one that fails, because the failure is visible.

Two supporting commands exist for the case where when something landed matters as much as what it is:

$ cd /tmp && git clone https://github.com/lodash/lodash.git && cd lodash
$ scram blame pkg:npm/ajv@6.10.2

pkg:npm/ajv@6.10.2
  current version   6.10.2
  introduced       8.1y ago, eaa9f36, by John-David Dalton
                     at 5.5.2
  last changed      7.2y ago, b185fce, by John-David Dalton
                     to 6.10.2 — Rebuild lodash and docs.
  commits scanned   16

  Version history:
   * 5.5.2      eaa9f36       8.1y ago  John-David Dalton
     6.10.2     b185fce       7.2y ago  John-David Dalton

An advisory disclosed against a package that has been pinned for two years is a different problem from one disclosed against a package added last week. scram why answers the related question of how it got in — direct or transitive, and via what.

In a shallow clone (git clone --depth 1, which is what CI does by default) blame refuses to guess, and says so rather than reporting the clone date as the arrival date:

$ git clone --depth 1 https://github.com/lodash/lodash.git && cd lodash
$ scram blame pkg:npm/ajv@6.10.2

pkg:npm/ajv@6.10.2
  current version   6.10.2
  WARNING           shallow clone: this is the first commit in the LOCAL history, not necessarily when the dependency arrived
  first seen       17d ago, 2b5e6f7, by Jon Church
                     at 6.10.2
  commits scanned   1

Note the difference against the same command in the full clone above: 17d ago ... first seen instead of 8.1y ago ... introduced at 5.5.2. The commit hash and date are the clone's, not the project's.

Install

SCRAM is a single static binary with no runtime dependencies. Every release is published for five platforms, checksummed, and signed keyless.

# Pick the asset for your platform and verify it before running it.
VERSION=0.2.0
BASE=https://github.com/0xsan7/SCRAM/releases/download/v$VERSION

curl -sSfLO "$BASE/scram_${VERSION}_checksums.txt"      # note: underscores
curl -sSfLO "$BASE/scram-$VERSION-linux-amd64"          # or -darwin-arm64, -linux-arm64, -windows-amd64.exe
curl -sSfLO "$BASE/scram-$VERSION-linux-amd64.pem"
curl -sSfLO "$BASE/scram-$VERSION-linux-amd64.sig"

# Verify just the binary you downloaded; -c against the whole file would
# also report the four platforms you did not download as failures.
grep "linux-amd64$" scram_${VERSION}_checksums.txt | shasum -a 256 -c -
# Linux: the same works with sha256sum

cosign verify-blob \
  --certificate scram-$VERSION-linux-amd64.pem \
  --signature    scram-$VERSION-linux-amd64.sig \
  --certificate-identity-regexp 'https://github.com/0xsan7/SCRAM/' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  scram-$VERSION-linux-amd64

Verified OK means the binary is the one this repository's CI built and signed for this tag; the signature is in the public Rekor log, so you do not have to trust GitHub to tell you so. See the v0.2.0 release for the full asset list, and the release notes there for the same instructions in copy-pasteable form.

Build from source instead:

git clone https://github.com/0xsan7/SCRAM.git && cd SCRAM
go build -o scram ./cmd/scram

Quick start

Requires Go 1.23+. No config file is required; no network is needed to resolve dependencies.

git clone https://github.com/0xsan7/SCRAM.git && cd SCRAM
go build -o scram ./cmd/scram

./scram init                    # write a starter .scram.yml
./scram scan --epss             # scan the current directory
./scram baseline update         # capture today's state as the baseline

Then change a dependency and scan again:

./scram scan --epss

The second run reports what changed against the baseline and gates on what is new.

To reproduce the output at the top of this file exactly, run the script that generates it. It builds the project, the git repository, the baseline, and the bump — every step, nothing transcribed:

./examples/lodash-drift/run.sh

It takes about ten seconds, needs no Node toolchain, and prints the summary above along with the exit code the gate returned.

What a real finding looks like

   MEDIUM    pkg:npm/lodash@4.17.11
         score 41/65, 5 finding(s)
           GHSA-jf85-cpcp-j695 cvss 9.1  epss 5.01%  fixed in 4.17.12
           GHSA-p6mc-m468-83gw cvss 7.4  epss 5.21%  fixed in 4.17.19
           GHSA-35jh-r3h4-6jhm cvss 7.2  epss 21.33%  fixed in 4.17.21
           GHSA-f23m-r3pf-42rh cvss 6.5  epss   -    fixed in 4.18.0
           GHSA-29mw-wpgm-hmr9 cvss 5.3  epss 7.34%  fixed in 4.17.21

Every field is real OSV data plus FIRST.org EPSS, and the component is matched by PURL, so a transitive package is found without a guess about the dependency path.

Failing closed

If a vulnerability source is unreachable, SCRAM warns and fails the run. It will not report a clean bill of health it never verified.

The same applies to resolution. A file that declares dependencies and resolves zero of them is treated as an operational failure, not a clean scan — the failure mode this whole project exists to eliminate, found five separate times in its own corpus and fixed each time. A genuinely dependency-free project (a pyproject.toml with no dependencies key) is still a valid pass, and the two are told apart by counting declarations independently of the parser.

allow_degraded_scan: true     # opt in to a partial scan, e.g. an air-gapped runner

Scoring, in the open

Every number is a weighted sum, and --explain prints the arithmetic:

$ scram scan --epss --explain pkg:npm/lodash@4.17.11

pkg:npm/lodash@4.17.11
  ecosystem     npm
  direct        true
  license       MIT

  severity         36 / 40   (max CVSS v3 across 5 known vulns)
      GHSA-jf85-cpcp-j695 CVSS 9.1     EPSS 0.0501
      GHSA-p6mc-m468-83gw CVSS 7.4     EPSS 0.0521
      GHSA-35jh-r3h4-6jhm CVSS 7.2     EPSS 0.2133
      GHSA-f23m-r3pf-42rh CVSS 6.5     no EPSS
      GHSA-29mw-wpgm-hmr9 CVSS 5.3     EPSS 0.0734

  exploitability    5 / 25   (EPSS probability x 25)
  maintenance       0 / 20   (no OpenSSF Scorecard available for this project;
                             the term contributes nothing rather than scoring zero)
  freshness         0 / 15   (version distance behind latest)

  = total          41 / 100   bucket: medium

The maintenance term reads the project's OpenSSF Scorecard, so a scored project shows where the number came from:

$ scram scan --scorecard github.com/google/oss-fuzz --explain pkg:npm/handlebars@4.0.11
  ...
  exploitability    0 / 25   (EPSS probability x 25)
  maintenance       6 / 20   (OpenSSF Scorecard 7.0/10, scored 2820b2b at 2026-09-28)
                    3 of 14 checks did not apply and were excluded from the average
  freshness         0 / 15   (version distance behind latest)

  = total          45 / 100   bucket: medium

Two things about that line matter more than the number:

  • A check that does not apply is excluded, not counted as zero. The Scorecard returns -1 for "packaging workflow not detected" and "no releases found"; averaging those in would understate every project.
  • Absent is not zero, and it is not an error either. If the project has no Scorecard, the term contributes nothing and says so, rather than reporting the project as unmaintained. The scan is not failed, because an absent scorecard is not evidence of a problem.

Maintenance risk is round((10 - Scorecard) / 10 * 20): stronger practices reduce this contribution. This corrects the direction used in v0.2.0, so maintenance-enabled scores across that fix are not directly comparable. See the post-release audit.

--scorecard defaults to the repository's git origin, so the common case needs no flag. --no-scorecard opts out and stops the request.

Bucket Score (of 65) was (of 100)
Critical 58–65 90–100
High 45–57 70–89
Medium 26–44 40–69
Low 1–25 1–39
Clean 0 0

The denominator is 65, not 100, and the reason is measured rather than asserted. scripts/measure_score_terms.sh samples the supported package-manager inputs and prints which terms carry data. Severity is derived from advisory data; maintenance can contribute when a repository Scorecard is available, and exploitability needs EPSS. Freshness is structurally 0 everywhere — it needs latest-version data this project does not collect — and exploitability needs EPSS, which is a per-CVE fetch that returns nothing when FIRST is unreachable. Dividing by 100 therefore advertised 35 points the tool usually cannot earn, and a clean-ish repository read as "39/100" when 39 was all the evidence there was.

The v0.2.0 presentation change left formula budgets intact. Severity, exploitability, maintenance and freshness are still computed in their own units with the same weights, and --explain prints all four plus the 0–100 subtotal they sum to, so the arithmetic stays checkable. Only the total the score is reported against changed. Bucket boundaries moved proportionally, rounded down.

That is a monotonic rescale, not a bucket-preserving one, and the difference is exact rather than approximate: checked over all 101 inputs, two scores change bucket, both upward — 69 (medium → high) and 89 (high → critical) — and none are demoted. Multiplying by 0.65 and rounding pushes a value just under an old threshold just over the new one, and 69 and 89 are the two that land there. A 40/100 medium is 26/65, still medium; TestRescalePromotesOnlyTheTwoBoundaryScores pins the list so a future threshold change cannot quietly widen it.

Scores before and after this change are not comparable. A project that scored 39/100 now reads 25/65. The ranking among projects is unchanged — it is the same number multiplied by 0.65, with integer rounding — but the absolute figure is not.

Two deliberate choices, both load-bearing:

  • A component with no known vulnerabilities scores 0 on severity. Absence of a CVE is not evidence of safety, so it must not manufacture risk. The freshness and maintenance terms are what surface latent risk in a clean-looking tree.
  • The repo score escalates on count as well as on the max component score, because ten high findings are a high-severity situation even when no single component reaches 70.

The maintenance term is a property of the repository, not of each dependency, so it is the same for every component in a scan. The Scorecard has no per-dependency view, and inventing one would be a number with no source behind it.

CI integration

The action ref must be a version tag. It installs the binary published under that same tag, so uses: 0xsan7/SCRAM@v0.2.0 gets exactly 0.2.0. A branch or SHA cannot name a release, and the action fails with an explicit message rather than downloading something unverified.

# .github/workflows/scram.yml
name: SCRAM
on: [pull_request]

permissions:
  contents: read
  security-events: write      # required by upload-sarif
  pull-requests: write        # required by comment-on-pr

jobs:
  scram:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0        # blame needs real history; see above

      - uses: 0xsan7/SCRAM@v0.2.0
        with:
          fail-on: high
          comment-on-pr: true
          upload-sarif: true

That is the whole workflow. upload-sarif: true uploads from inside the action, so there is no second codeql-action step to add — the previous version of this section had one, and it was both wrong (category was misindented out of with:) and redundant.

The minimum is uses and nothing else:

      - uses: 0xsan7/SCRAM@v0.2.0

No token is required. A composite action cannot read the secrets context at all — the runner rejects the whole manifest if one tries — so the token arrives through a github-token input that defaults to ${{ github.token }}, the workflow's own token. Override it only if you need permissions the workflow's token does not have:

        with:
          github-token: ${{ secrets.MY_PAT }}

The action verifies the downloaded binary against the release's published sha256 before running it, so a truncated download or a tampered asset fails the step instead of being scanned.

The Action resolves the baseline from the PR's base branch, so there is no baseline file to maintain, and it updates a single PR comment rather than posting a new one on every push.

fetch-depth: 0 matters: with the default depth of 1, scram blame correctly refuses to report an introduction date (see above), so the command still works but tells you less.

upload-sarif: true produces scram-output/scan.sarif and uploads it itself, using the same github-token input. SARIF is written to stdout and redirected, because --out is the SBOM directory — the machine formats keep stdout a single valid document by design. The step verifies the output really is SARIF 2.1.0 before handing it over, because an invalid upload is dropped silently by the Security tab and looks identical to "no vulnerabilities found".

Outputs: sbom-path, repo-score, repo-bucket, new-critical-count, new-high-count.

Output formats

scram scan --format table    # human-readable, color-coded, drift-marked
scram scan --format json     # full result object, versioned schema
scram scan --format sarif    # for the GitHub Security tab

For --format json and --format sarif, stdout stays a clean parseable document and the human-readable policy summary goes to stderr, so scram scan --format json | jq always works.

Re-render a prior scan without re-querying anything:

scram scan --format json > scan.json
scram report --from scan.json --format sarif --out results.sarif

The self-score badge

scram scan --format json > scan.json
scram badge --in scan.json

Run against this repository:

{"schemaVersion":1,"label":"self score","message":"0/65 clean","color":"brightgreen"}

scram badge reads a scan document rather than taking a number, so the badge cannot report a score that was never measured. A document with no repo_bucket — that is, something that is not a scan report — is refused outright rather than rendered as 0/65.

SCRAM's own CI runs this on every push and publishes badge.json as a build artifact. It is deliberately not committed to this repository: a badge checked into a README is a number that stops being true the moment someone lands a commit, which is the failure mode the rest of this tool is built to avoid. The artifact is regenerated from the same scan that produced the CI result, so the two cannot disagree.

The score trend

Every scan appends to a local history and renders a sparkline once there is more than one sample. Captured from six consecutive scans of a lodash@4.17.11 fixture:

SCRAM supply chain scan
────────────────────────────────────────────────────────────────
  repo        app
  components  3
  findings    21 vulnerability record(s)
  repo score  39/65  LOW
  breakdown   critical 0  high 0  medium 0  low 3  clean 0
  trend       ▁▁▁▁▁▁▁  flat across 7 scans

A flat series sits at the bottom of the glyph range rather than being stretched across the full height, so a constant score does not look like a dramatic trend. One sample is not a trend and is not drawn. --no-trend opts out entirely, including writing the history file.

Configuration

Every key below is read by the tool; there are no aspirational ones.

version: 1
ecosystems: [npm, pypi, go]    # omit to auto-detect
fail_on: high                  # applies to NEW findings vs baseline
fail_on_existing: false

sbom:
  formats: [cyclonedx, spdx]

sources:
  osv: true
  nvd: false                   # independent NVD ingestion is not implemented
  ghsa: false

waivers:
  - id: CVE-2024-12345
    reason: "Build tooling only, not reachable"
    expires: 2026-12-31        # omit for a permanent waiver

licenses:
  deny: [GPL-3.0-only]

ignore_paths: [vendor, third_party]

epss: false                    # exploitability scoring; ~2x requests
allow_degraded_scan: false     # fail closed when a source is unreachable
offline: false                 # vulnerability scans fail explicitly when offline
cache_ttl_hours: 6

Ecosystems

Ecosystem Reads Direct/transitive from
npm package-lock.json (v1, v2, v3), npm-shrinkwrap.json, yarn.lock (classic and Berry), pnpm-lock.yaml (5.x, 6.x, 9.x) package.json
PyPI requirements.txt and 14 other requirements filenames (requirements-dev.txt, dev.txt, constraints.txt, …), poetry.lock, Pipfile.lock, pyproject.toml (PEP 621 + Poetry) pyproject.toml, requirements.txt
Go go.sum, falling back to go.mod go.mod
Rust Cargo.lock (v1–v4) Cargo.toml

Lockfiles are parsed directly — no npm install, no build step, no network access to a package registry. Maven and RubyGems are not implemented; ROADMAP.md says why Maven is not a parser problem.

yarn.lock and pnpm-lock.yaml resolve into the npm ecosystem rather than one each, because that is what OSV matches on — a Yarn or pnpm package is an npm package, and a separate ecosystem key would silently match nothing.

go.mod is a fallback, not a peer of go.sum, and it is worth being precise about what that costs. go.sum records what was downloaded, transitive modules included; go.mod records only what the author declared. So a project with no go.sum — a fresh clone, a tree vendored before go mod download, or any module that has never committed one — scans as a floor on its real dependency set, not a full inventory. Reporting nothing there instead would be a silent false clean, which is worse than an undercount, so the floor is reported and the gap is stated here rather than left for you to discover.

Two behaviours worth knowing, both tested:

  • A lockfile always outranks a manifest. A project with both poetry.lock and pyproject.toml is read through the lockfile, because the lockfile carries exact installed versions and the manifest only ranges.
  • Poetry's python = ">=3.9" is an interpreter constraint, not a package. It is never emitted as a component named python.

Adding a resolver

The highest-leverage contribution and the lowest barrier. This walkthrough was executed against the current registry — the dummy ecosystem below resolved, produced SBOMs, SARIF, scores, and drift with no other code changed.

1. Write the resolver. One file, internal/resolve/<name>.go:

package resolve

func init() { Register(cargoResolver{}) }

type cargoResolver struct{}

func (cargoResolver) Ecosystem() string { return model.EcoCargo }

func (r cargoResolver) Resolve(root, path string) ([]model.Component, error) {
    // parse Cargo.lock, return components with Purl set
    return nil, nil
}

2. Declare the ecosystem in internal/model/model.go, next to the existing ones:

const (
    EcoNPM   = "npm"
    EcoPyPI  = "pypi"
    EcoGo    = "go"
    EcoCargo = "cargo"   // add yours
)

If your ecosystem shares a package namespace with an existing one — Yarn and pnpm are both npm — reuse that constant rather than adding a new one. detect.go uses plain strings rather than these constants, so in step 4 write the literal ({Ecosystem: "cargo", ...}): the package does not import model.

3. Teach OSV about it in osvEcosystem in internal/vuln/osv.go. The map is small and it returns false for anything unknown, so without this step your components are detected, scored, and shipped — and silently never vulnerability-matched, with a warning rather than a failure:

func osvEcosystem(eco string) (string, bool) {
    switch eco {
    case model.EcoCargo:
        return "crates.io", true
    ...
    }
    return "", false
}

4. Add the lockfile to candidates in internal/detect/detect.go:

{Ecosystem: "cargo", File: "Cargo.lock", RelPath: "Cargo.toml"},

Expect one test to fail and fix it. TestResolverRegistry in internal/resolve/resolve_test.go asserts an exact ecosystem count and expects cargo to be unregistered. Both are correct today and both are wrong the moment you add an ecosystem:

-	if _, err := GetFor("cargo", "Cargo.lock"); err != ErrUnsupported {
-		t.Error("expected ErrUnsupported for an unregistered ecosystem")
-	}
-	if len(Supported()) != 3 {
-		t.Errorf("Supported() = %v, want 3 ecosystems", Supported())
-	}
+	if _, err := GetFor("nonesuch", "nope.lock"); err != ErrUnsupported {
+		t.Error("expected ErrUnsupported for an unregistered ecosystem")
+	}
+	if got, want := len(Supported()), 5; got != want {
+		t.Errorf("Supported() = %v (%d ecosystems), want %d", Supported(), got, want)
+	}

Only the first matching candidate per ecosystem per directory is used, so ordering is a fallback chain: a lockfile must come before a manifest.

If your ecosystem already has a resolver, you are adding a second file for the same ecosystem, and the registry then has to choose between them. Declare the priority explicitly, or dispatch will fall back to init() ordering and the choice will be arbitrary:

func (myResolver) Priority() Priority { return PriorityLockfile }
func (myResolver) Handles(path string) bool { return filepath.Base(path) == "my.lock" }

Every sibling then needs the same, and the existing one needs a Handles it may not have had — with one resolver per ecosystem the interface only had to ask "do you handle this?", and with two it has to be answered explicitly. TestSingleResolverEcosystemsUnaffected in dispatch_test.go fails on the ecosystem that did not declare its priorities, which is the behaviour you want.

That is the whole change. Detection, SBOM generation (both formats), scoring, drift, policy, and every output format pick it up automatically — verified, not assumed.

What you also owe the project. A resolver that declares dependencies and resolves zero is a silent false clean, and the invariant that catches it needs a real corpus to be worth anything. See CONTRIBUTING.md for the required fixture corpus, invariant test, fuzz target, and mutation audit.

Exit codes

Code Meaning
0 Pass
1 Policy failure — findings at or above threshold
2 Scanner error — the scan did not complete

Telling 1 from 2 is what lets CI distinguish "found something" from "the scanner broke".

Limitations

See LIMITATIONS.md for the full list. The two most likely to matter to you:

  • npm v1 lockfiles carry no dev/optional/peer flags, so a v1 scan cannot separate production from test-only dependencies. Real v1 fixtures are covered; the distinction is not derivable from the format.
  • PyPI ranges resolve to their floor. A >=2.1.2 requirement is recorded as 2.1.2, which is the best a ranged declaration allows and is not the same as a lockfile's exact version.

Design notes

Minimal dependencies. Four non-stdlib imports: cobra, packageurl-go, yaml.v3, and BurntSushi/toml. For a supply chain tool, its own supply chain is part of the credibility test — the TOML parser was added specifically because hand-rolling one to avoid a dependency was the worse trade.

No telemetry. No phone-home. If usage analytics are ever added they will be opt-in and disclosed.

Reproducible output. Components are sorted, serial numbers derive from a hash rather than randomness, and baselines are written as sorted JSON, so an unchanged repo produces a byte-identical artifact and shows up as no diff.

Degrading is fine; degrading silently green is not. A downed upstream produces a warning, a partial scan, and a failing exit code.

Validated, not asserted. The CycloneDX and SPDX output is checked against the official schemas in CI, and a mutation audit re-breaks each fixed bug to confirm the test suite catches its return.

Development

go build ./cmd/scram
go test ./...                                   # 229 tests
go test ./internal/resolve/ -run '^$' -bench . -benchmem

# validation tooling, all runnable offline against the committed corpus
python3 scripts/test_fetch_corpus.py            # fetcher regression tests
python3 scripts/mutation_audit.py               # re-break each fix, expect red
go test ./internal/resolve/ -run '^Fuzz' -count=1

Benchmarks and the baseline they were measured against: BENCHMARKS.md. Decision log, including every bug the corpus and fuzzing found: DECISIONS.md.

Project documents

Each of these exists because something in the repository needed to be stated somewhere a reader would look for it.

Document What it is for
ROADMAP.md What is not built yet, ordered, with the evidence for each gap
docs/STATUS.md Every phase and item, DONE / PARTIAL / NOT STARTED, with a commit hash or a measurement
docs/BLOCKED.md What could not be done here and why — including everything unverified
DECISIONS.md Every bug found and what it changed, numbered and indexed
RELEASE-NOTES.md How release notes are generated, and why there is no committed CHANGELOG.md
docs/adr/ The same 27 decisions as one MADR file each, with a generated index and a check that no content was lost in the split
LIMITATIONS.md Where the tool is wrong or incomplete, checked rather than asserted
CONTRIBUTING.md Resolver, fixture, fuzzing and schema standards
REPRODUCIBILITY.md The differential comparison, and exactly where it diverges
BENCHMARKS.md Measured numbers and the machine they were taken on
OVERNIGHT_REPORT.md The most recent working session, including what it could not verify
SECURITY.md How to report a vulnerability
GOVERNANCE.md Who decides, and the three things a maintainer may not do alone
SUPPORT.md What this project will and will not help with
CODE_OF_CONDUCT.md Expectations

License

Apache-2.0 — includes the explicit patent grant, which matters for a security tool enterprises will actually adopt.

About

SBOM generator and supply chain risk scanner for npm, PyPI and Go. CycloneDX 1.5 and SPDX 2.3, OSV + CVSS + EPSS, drift against a baseline, and a fail-closed design: it reports an error rather than a clean bill of health when it cannot resolve a lockfile or reach vulnerability data.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages