Compute the transitive closure of everything a repository pulls in — packages, container images, CI actions, toolchains — over both the resolution that installs today and the space of resolutions its version ranges permit.
deepdep scan --mode will . # what installs today
deepdep scan --mode can . # what a future install could pull
On a manifest declaring one dependency (is-string: ^1.0.0), against the live
npm registry:
| mode | nodes | edges |
|---|---|---|
will |
19 | 28 |
can |
66 | 280 |
Existing tools (syft, trivy, osv-scanner, cyclonedx-gomod) answer one question: what is installed right now, given the lockfiles present. They stop in three places that matter.
They stop at package managers. A .pre-commit-config.yaml pins git repos
that execute on every commit. A Dockerfile pulls a base image and runs
apt-get install / curl | sh. A CI workflow pulls third-party actions,
container images, and reusable workflows that call further workflows. None of it
appears in an SBOM.
They stop at the resolved graph. Manifests declare ranges. Every version satisfying a range has its own dependency set, so what a future install can pull is strictly larger than what it will pull today.
They silently omit what they cannot see. RUN make install is statically
undecidable. Reporting a clean SBOM that quietly dropped it is a wrong answer,
not a partial one.
Conflating any two of these produces confident nonsense, so they are separate fields on every node.
Completeness — how well do we know this?
resolved · declared · inferred · opaque
State — will it land on disk?
installed · possible · unknown
Pinning — what is holding it there?
pinned · locked · floating
The third is the one people miss. These two packages install the same version and carry completely different exposure:
pyproject: ">4.5.0" + lock 4.6.1 -> locked regenerate the lock and it moves
pyproject: "==4.6.1" -> pinned regenerating changes nothing
Supply-chain questions have two independent time axes, because a CVE published today applies to a version published years ago.
--as-of |
--known-at |
question |
|---|---|---|
| now | now | what are we exposed to today? |
| release T | T | was this a known problem when we shipped? |
| release T | now | what do we now know was wrong with what we shipped? |
Rows 2 and 3 together separate negligence from bad luck. A third axis, --at,
recomputes the closure at any point in the repository's own history:
deepdep scan --at v1.2.0 . # the closure as it stood at that tag
deepdep history . # when each dependency changed, and to what
history distinguishes a range change from a lockfile-only bump — the latter
moves what installs while the manifest stands still, and a range diff misses it
entirely.
| axis | retroactive? | why |
|---|---|---|
| repo state at T | yes | git history |
| version existence at T | yes | registry publish times |
| advisory existence at T | yes | OSV published / withdrawn |
| advisory content at T | no | modified is destructive |
| tag → SHA at T | no | no API exposes it; record it or lose it |
| OpenSSF Scorecard at T | no | deps.dev serves only the newest; no history endpoint |
The last two rows are why every scan writes observed SHAs and digests to
ref_obs, and every risk run appends to depsdev_obs / scorecard_obs, from
the first run. deepdep risk --known-at therefore errors rather than
returning today's posture under a historical flag.
deepdep reports supply-chain files it saw and could not expand, as
declared / no-extractor frontiers. A scanner that silently omits a Dockerfile
reads as "this repo has none".
deepdep tools # 95 tool/category pairs recognised
Categories are ordered by leverage: hook first, because those execute on an
ordinary commit or install, with your credentials, before any review.
| extract | resolve | effective | |
|---|---|---|---|
| npm | package.json |
registry | package-lock.json |
| PyPI | pyproject.toml, requirements*.txt |
PyPI JSON | uv.lock |
| GitHub Actions | workflows | — | — |
| GitLab CI | .gitlab-ci.yml, includes, components |
— | — |
| pnpm | — | — | pnpm-lock.yaml |
Enrichment: OSV (advisories, bitemporal) and deps.dev + OpenSSF Scorecard (posture, current-records only).
Everything else in the catalogue — Dockerfile, pre-commit, mise, ansible, Cargo, Maven, Helm, Terraform — is detected and reported as a frontier.
deepdep scan --mode will --offline . # index what is installed
deepdep audit # check it against OSV
audit checks the installed set by default — what is really there — not the
can-closure, because equating a hypothetical exposure with a real one is the
mistake this tool exists to avoid. It is bitemporal: --known-at replays the
advisories that existed at an instant, so a stored run can be re-audited against
any point in time without rescanning.
Two-stage against OSV, which is what the API offers: querybatch returns ids
1000 at a time, then each distinct advisory is fetched once. Packages share
advisories, so the second stage is far smaller than the first.
deepdep risk # deps.dev + OpenSSF Scorecard
deepdep risk --signal deprecated --limit 0
A different question from audit. An advisory says this version has a known
flaw. A posture signal says this is how the code got here, and what a future
compromise would cost: the package is deprecated and nobody will patch it, its
releases carry no provenance, its repo merges without review, its CI hands out
write-all tokens.
Reported as named signals with counts, never a 0-100 score — averaging a deprecated package against a missing fuzzing harness destroys the only distinction that makes the output actionable.
Counts are given as versions / distinct source projects, both. A Scorecard finding is a property of a project: rollup ships 25 per-platform binary packages from one repo, so a version-only count turns three upstream problems into twenty-eight.
Three things about the deps.dev API that silently corrupt a naive client, all verified and all pinned by tests:
| behaviour | consequence |
|---|---|
purlbatch echoes a normalised purl (annotated_types → annotated-types, 5.0 → 5.0.0) |
correlate by index, never by the echo, or facts cross between packages |
purlbatch caps at 100 and returns a nextPageToken instead of an error |
chunk at 100; a short response is treated as a hard error |
Scorecard score: -1 means the check did not run ("no releases found") |
never a finding; only >= 0 scores are evaluated |
A package deps.dev has never seen is unlisted — unexamined, not clean.
Internal packages, private-index packages and typo'd names all land there.
risk also cross-checks its own advisory ids against audit's OSV results over
identical inputs. A delta is a lead, not a verdict — the common benign case is an
OSS-Fuzz record attached to an upstream project by a GIT commit range, which says
nothing about which published artifact shipped it.
- Never executes analysed code. No
npm install, nopip install, nodocker build. Resolution is registry-API-only. An analyser that runs untrusted postinstall scripts to learn a dependency graph is a liability. - Version semantics are per-ecosystem. npm ranges and PEP 440 specifiers
disagree about ordering, about
~, and about pre-releases. The npm scheme is validated against node-semver's own fixture corpus; PEP 440 is validated differentially against Python'spackaginglibrary (644 cases). - Identity is deduplicated, provenance is not. One node per package version;
every edge kept.
PathsToanswers "why is this here?" with every chain. - Advisory counts are never materialised. They are a function of
known_at, a query parameter. Storing them would un-bitemporalise the design at the last step. - Bounds are named, never silent. Depth, node count and timeout all mark their frontier with a machine-readable reason.
Runs persist to SQLite (modernc.org/sqlite, pure Go, no cgo). Indexed adjacency
answers "why is this here?" in milliseconds; the flat package list sorts and
paginates. Packument observations make a re-scan incremental — 2.78s to 0.12s on
a repeat run.
SELECT name, versions_installed, path_count, worst_completeness
FROM package_rollup ORDER BY path_count DESC LIMIT 10;Working, tested, and honest about its limits. Not yet built: a graph UI,
deepdep diff, hoisting simulation for lockfile-less repos, and the remaining
ecosystem extractors. The schema for the UI is already in place, because
knowledge-time, tag→SHA and scorecard history cannot be reconstructed after the
fact.
Known limitation: can mode expands each declared range independently, so a
package with a hard pin from one parent and a wider range from another still
lists the wider range's versions as possible. A real resolver would intersect
them away. It over-approximates, which is the safe direction for a security
report, but it is imprecise.
MIT