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.
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.
$ 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 16The 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
- The three things that are hard to get at once
- What that looks like on a real pull request
- How this compares
- Why drift beats a score
- Install
- Quick start
- What a real finding looks like
- Failing closed
- Scoring, in the open
- CI integration
- Output formats
- Configuration
- Ecosystems
- Exit codes
- Limitations
- Design notes
- Development
- Project documents
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 |
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 explainsubcommand 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-warningsinput: 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.
- 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, orintroducing commit. (GitHub's code scanning product does track introducing commits — for source-code findings, on a different data model. That is not dependency blame.)
- 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--explainnames 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.
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 DaltonAn 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.
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-amd64Verified 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/scramRequires 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 baselineThen change a dependency and scan again:
./scram scan --epssThe 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.shIt takes about ten seconds, needs no Node toolchain, and prints the summary above along with the exit code the gate returned.
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.
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 runnerEvery 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: mediumThe 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: mediumTwo things about that line matter more than the number:
- A check that does not apply is excluded, not counted as zero. The
Scorecard returns
-1for "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.
The action ref must be a version tag. It installs the binary published under that same tag, so
uses: 0xsan7/SCRAM@v0.2.0gets exactly0.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: trueThat 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.0No 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.
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 tabFor --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.sarifscram scan --format json > scan.json
scram badge --in scan.jsonRun 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.
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.
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| 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.lockandpyproject.tomlis 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 namedpython.
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.
| 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".
See LIMITATIONS.md for the full list. The two most likely to matter to you:
- npm v1 lockfiles carry no
dev/optional/peerflags, 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.2requirement 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.
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.
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=1Benchmarks and the baseline they were measured against: BENCHMARKS.md. Decision log, including every bug the corpus and fuzzing found: DECISIONS.md.
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 |
Apache-2.0 — includes the explicit patent grant, which matters for a security tool enterprises will actually adopt.