Audit what you ship¶
Forge activity tells you whether a project is alive. It does not tell you what you are shipping. Ospobox handles both through the same machinery: an SBOM and an advisory feed are evidence sources like any other, and dependency exposure is a scorer like any other.
What you get today¶
Point it at a CycloneDX document and an advisory feed, and you get a scored breakdown of what a repository depends on:
SBOM: 48 new evidence records
Components: 47
Advisories: 3 new records
dependency-exposure: 74.5/100
known-advisories 64.0 (weight 0.7) 3 advisories across 2 of 47 components (1 critical, 1 high)
clean-components 95.7 (weight 0.3) 45 of 47 components have no advisory on file
Both components explain themselves, as every scorer must.
Running it¶
uv run python demo/supply_chain.py \
--repo https://git.sr.ht/~sfermigier/ospobox \
--sbom demo/sbom/ospobox.cdx.json
The SBOM in demo/sbom/ is Ospobox's own dependency tree, so the example scores this project's supply chain. Point both arguments at your own to score yours.
It does three things in order:
- Ingests the SBOM. One
sbom.documentrecord for the repository listing the package URLs it found, and onesbom.componentrecord per package, keyed by the package's own purl so that anything downstream can attach facts to a package directly. - Queries an advisory provider for each component, storing advisories as evidence against those purls. OSV runs by default;
--provider vulnerablecodeuses AboutCode's aggregator instead.--offlineskips the queries when you want the loop without the network, and--advisories FILEreplays a set collected earlier, which is how the demo runs in a second instead of the few minutes a hundred live queries take. - Scores the repository's exposure over what it just collected.
Nothing is re-collected: an unchanged SBOM adds no records on a second run, and an advisory already on file is not duplicated.
Two advisory providers, one socket¶
osv and vulnerablecode are separate plugins producing the same advisory evidence type about the same purl subjects, so the exposure scorer reads either without knowing which ran. Swapping them is an argument on the command line.
VulnerableCode is AboutCode's aggregator: some 300,000 advisories drawn from around thirty upstream sources, keyed by package URL. Public instances require an API key, and a self-hosted one works just as well:
VULNERABLECODE_API_KEY=your-key # the public instance
VULNERABLECODE_URL=https://vc.internal # or your own
With neither set the plugin is not registered, so the source list shows only what can actually be reached.
Generating an SBOM¶
Ospobox consumes SBOMs. Any CycloneDX JSON works, from whichever tool your ecosystem uses:
# Python: the installed environment
uv run cyclonedx-py environment > bom.json
# Node
npx @cyclonedx/cyclonedx-npm --output-file bom.json
# Containers, source trees, and most things else
syft dir:. -o cyclonedx-json > bom.json
To resolve a manifest into a concrete package set, ScanCode.io's resolve_dependencies pipeline runs python-inspector over a requirements file and emits CycloneDX:
scanpipe create-project ospobox \
--pipeline resolve_dependencies:DynamicResolver \
--input-file requirements.txt --execute
scanpipe output --project ospobox --format cyclonedx:1.6 --print
The example in demo/sbom/ was generated this way, by ScanCode.io 38.0.0, and its metadata.tools records that. demo/scancodeio/compose.yml holds a one-shot stack for running it, and demo/README.md the caveats: no arm64 image, a resolver group that has to be named explicitly, and the embedded package descriptions stripped from the committed copy.
Three tools, three different questions. cyclonedx-py environment enumerates what is installed. resolve_dependencies resolves what is declared. ScanCode Toolkit's --cyclonedx detects manifests and leaves them unresolved, so this repository's pyproject.toml and uv.lock yield one component where the pipeline yields ninety-six. On this project the first two land within one package of each other, and the pipeline additionally gives every component a purl and a hash.
Components without a package URL are skipped. Without one there is no subject to attach an advisory, a licence or an attestation to.
Planned
Uploading an SBOM through the dashboard, and attaching one to a repository so it refreshes on a schedule. The evidence and scoring paths behind it already run; only the surface is missing.
The limits of the reference plugins¶
They are naive, and meant to be replaced:
- One OSV query per package, without caching or batching.
- No reachability analysis. An advisory against a package you import once in a test fixture counts the same as one in your request path.
- No version-range evaluation beyond what OSV returns for the exact purl.
- No transitive resolution. The SBOM's declared components are the ones scored.
They prove that a non-forge evidence type flows through the same store, scorers and rules as everything else, with no change to the chassis. If you need better, the extension points are the way in, and a real connector replacing osv changes nothing outside its own package.
Research
Transitive reachability, exploitability in context, provenance and build attestations, and post-quantum migration exposure. The counting above is where that work starts.
The shape of the limitation¶
The evidence store is a table, and not a graph. The repository→component relationship lives inside the sbom.document payload as a list of purls, because there is nowhere else to put it.
That serves for scoring one repository's dependencies. It breaks down on "which of our repositories depend on this compromised package" across an estate, a query that wants edges. See Architecture for where that goes.