Most of what we write about is other people's systems. This post is about ours. Vulnera's platform is built from a Next.js front end, a Scala API, a Go engine and a handful of container images, and every one of those pulls in code we did not write. A penetration-testing firm that ships a compromised dependency has failed in a very particular way, so we treat the supply chain as part of the attack surface.
Everything below describes controls that exist in our repository today. The figures are counted from the files themselves, and we say where a control stops short.
## 1. Know exactly what you depend on
The first control is boring on purpose. Every direct dependency, in every ecosystem, has a line in a single reviewed file, `allowlist.tsv`, with an exact version and a written justification for why it exists. Today that file holds 171 rows: 33 npm packages, 25 Maven artefacts, 25 container images, 32 apt and 21 apk packages, 7 Python packages for developer machines only, 4 Go modules, 2 sbt plugins, plus reviewed overrides and exceptions.
A script, `check-deps.py`, runs as the first step of our test run and fails the build if a dependency is missing from the list, differs in version, or is a range instead of an exact pin. It also fails on unpinned images or Actions and on any loosening of the package-manager settings below.
**Threat:** a dependency slipping in unnoticed, or a version drifting underneath us. **Cost:** every addition is a two-file diff and a sentence of justification, which slows down casual "just add a library" decisions. That is the point. **Limit:** the list proves we reviewed a package once, not that it stays safe.
## 2. Make the tree smaller
The cheapest dependency to secure is the one you removed. Our npm lockfile fell from 1,054 package versions to 757 in one pass and then to 411 after a second round of trims, mostly by replacing tools with built-in features or with code we own. A linter pulling in about 240 packages, including a telemetry install script, became a digest-pinned container image.
**Cost:** replacement work measured in days, not minutes. **Limit:** Next.js and React are not going anywhere; we considered leaving the npm ecosystem entirely and costed it at three to six months of work for one developer, with the risk moving to other registries rather than disappearing.
## 3. Exact versions and a 14-day cooldown
Every version is pinned exactly (`save-exact`), and our pnpm configuration sets `minimumReleaseAge` to 20,160 minutes: a release must have been public for 14 days before our tooling will resolve it. Malicious releases are usually noticed and pulled within hours or days, so waiting is a cheap filter.
The cooldown paid for itself on its first run. It caught a lockfile that had picked up a package version published 36 hours earlier, and we pinned back to the previous release until the new one had aged.
There is one deliberate exception. A version that fixes a published advisory may be installed after 7 days, but only with an exact-version entry, the advisory named and the date recorded, and the entry has to come out again once the release is 14 days old.
**Threat:** a freshly published malicious release. **Cost:** we run behind the newest versions by at least two weeks, and sometimes feel it. **Limit:** a malicious release that survives the cooldown unnoticed gets through.
## 4. No install scripts
Lifecycle scripts are how most npm malware executes: code that runs the moment you install. We disable them with `ignore-scripts=true`, set `onlyBuiltDependencies` to an empty list, and turn on `strictDepBuilds`, so a new package that tries to bring a build script fails the install until a person has read it. Only one package on the ignore list, esbuild, has such a script, and its prebuilt binary ships separately. We also set `blockExoticSubdeps`, which refuses git, tarball-URL and file references deep in the tree.
**Cost:** occasionally a package needs a manual decision. **Limit:** this stops code running at install time, not code that runs when your application imports it.
## 5. Refuse weaker provenance
Setting `trustPolicy: no-downgrade` tells the package manager to reject a version whose publishing evidence is weaker than that of an earlier release of the same package. That is the shape of a stolen maintainer token: a release pushed from someone's laptop where earlier ones came from a CI pipeline.
Our runbook is blunt about what to do when it fires: treat it as a possible account takeover until shown otherwise. We carry four reviewed exceptions, all maintenance-line releases of well-known packages, each with its reason written beside it.
## 6. Verify signatures and provenance offline
Pinning a version does not prove who published it. So we keep, in the repository, a signature record for every npm package in our lockfiles (540 rows, matching the 540 package versions across the two lockfiles) and verify each against registry keys we have pinned. For the packages named in our provenance policy (eleven rows), which must include Next.js, React and React DOM, we also verify the Sigstore provenance bundle: the signature, the certificate chain, the transparency-log entry and the repository and workflow that built it.
This check runs with no network, before anything is installed, and a failure stops the run. It is a precondition, not a report.
**Cost:** the largest piece of upkeep we carry. Many older packages are signed only by an npm key that expired in January 2025 and publish no provenance, so we accept them through a short, dated list of reviewed exceptions rather than pretending they are verified. **Limit:** that list is exactly the weak spot it looks like. We have accepted the risk in writing instead of hiding it.
## 7. A private mirror with no fallback
Installs do not talk to the public registry. They go to a self-hosted mirror that has no uplink and is read-only while serving. A tarball enters it only through a short-lived seeding step that fetches exactly what the lockfiles name, after which the storage is checked against the lockfiles. If the mirror is down, the install fails; there is deliberately no fallback. A drill in our full QA run corrupts a throwaway mirror on purpose and expects it to be refused.
Our package-manager wrapper refuses registry overrides, global installs and one-shot executors such as `dlx`. It cannot stop code already running as the developer's own user, and our runbook says so.
## 8. Pin the other ecosystems
The same thinking applies beyond npm. The 364 JVM jars the API needs are pinned by SHA-256 in a lock file and checked before compilation, and the build resolves from one repository only. Go modules are covered by `go.sum` and `go mod verify`, with the toolchain pinned so nothing is downloaded behind our back. Every container image is pinned by digest, and every GitHub Action by commit hash.
**Limit:** Maven and Go are not mirrored. Their hash locks are the control, and they do not give the offline signature checks npm gets.
## 9. Scan, then gate
Pinning tells you what you have; scanning tells you whether it is known to be bad. We generate CycloneDX software bills of materials for the source tree, the JVM jars, the shipped npm closure and each built image, then check them against the OSV database. A runtime advisory scoring CVSS 7 or higher fails the release unless it is triaged in `osv-ignore.tsv` with a reason and an expiry no more than 90 days out. At the time of writing that file has no active rows. A separate Trivy gate fails any built image with a High or Critical finding unless a dated row in the Trivy ignore file covers it, and that file is kept as short as we can manage.
**Cost:** the scans need network access and add time to every release, and expiring rows need re-triage. **Limit:** a scanner only knows published advisories.
## What this adds up to
None of this is exotic, and no single control would have saved us from every incident. The value is in the layering and in the fact that the rules are enforced by scripts that fail the build rather than by good intentions. If you take one idea from this post, take the allow-list: a file that makes adding a dependency a visible, reviewed decision changes behaviour more than any scanner.
We are preparing the kit for open-source release under the MIT licence, so other teams can adopt the same checks and tell us where they fall short.
## Further reading
-
[npm trusted publishing and provenance](https://docs.npmjs.com/generating-provenance-statements): the npm documentation on provenance statements, which underpins the checks in section 6.
-
[OSV.dev](https://osv.dev): the open vulnerability database we query for the advisory gate.
