Dependency Security
Most of your running code was written by strangers. The controls are reproducible installs, a known time-to-patch, and a build that does not hand out credentials.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.
What do you owe for the code you did not write but do ship?
The service has 40 direct dependencies and 900 in the lockfile. The scanner reports 60 advisories. Someone has to decide what to do this week.
Install what we need, keep a lockfile, and turn on the automated update bot. When the scanner is green we are fine.
Scanner output is not a work queue. Most advisories describe code paths your application never reaches, and a handful describe ones it reaches on every request. Treating them uniformly means either ignoring all of them or drowning.
- Scanner output is not a work queue. Most advisories describe code paths your application never reaches, and a handful describe ones it reaches on every request. Treating them uniformly means either ignoring all of them or drowning.
- The scanner reports known vulnerabilities in versions. It says nothing about a package that was compromised yesterday and is behaving as designed for its new owner.
- Installation is code execution. Lifecycle scripts run on the machine that installs, which is usually CI — the machine holding your registry tokens, signing keys and deploy credentials.
- A green report today is a different report tomorrow with no change on your side, because the advisory database moved, not your code.
What is actually happening
- A dependency tree is a transitive trust decision. You chose 40 maintainers; you inherited hundreds. Every one can publish a new version, and any of them can change hands.
- The lockfile is the actual manifest of what runs. A version range in a manifest file is an intention; the lockfile with integrity hashes is the artifact, which is why installing from it — and only from it — is the control.
- The threat model has three distinct branches, and they need different responses: a known vulnerability in a version you use, a malicious package (typosquat, compromised maintainer, hostile new owner), and a build-system compromise where the dependency is fine and the pipeline is not.
- Reachability decides severity. A parsing vulnerability in a library you use to parse untrusted request bodies is urgent; the same advisory in a package only a test harness loads is not. Nothing automated knows this reliably, so a human has to be able to answer it.
- The number that matters is not "how many advisories" but time-to-patch: how long between an advisory being published for something you actually run and a patched version being in production.
Install the lockfile, not the manifest
The difference between these two commands is the difference between shipping a tree someone chose and reviewed, and shipping whatever the registry offered at build time. It is also the difference between a reproducible build and a build that cannot be explained after the fact.
The second version additionally does the install in a stage that is thrown away, so build-time packages and any scripts they ran are not present in the running image.
COPY package.json . RUN npm install # resolves ranges now; lockfile ignored COPY . . CMD ["node", "server.js"]
FROM node:22-slim@sha256:... AS build COPY package.json package-lock.json . RUN npm ci --ignore-scripts # exactly the lockfile, no lifecycle scripts COPY . . RUN npm run build FROM node:22-slim@sha256:... COPY package.json package-lock.json . RUN npm ci --omit=dev --ignore-scripts COPY --from=build /app/dist ./dist USER node CMD ["node", "dist/server.js"]
The right-hand build produces the same tree on every machine and every day, ships no build-only packages, executes no install scripts, runs as a non-root user, and pins the base image by digest so the operating-system layer cannot change under a tag. Every one of those is a separate incident it makes impossible.
Three different threats wearing one label
"Dependency security" collapses three problems that need different responses. Triage goes wrong when a team applies the process for the first to an instance of the second.
The middle row is the one most teams have no answer for, and it is also the one where the response is cheapest: integrity hashes and pinned digests cost almost nothing and remove the ability to substitute content silently.
| Threat | What it looks like | What actually helps | What does not |
|---|---|---|---|
| Known vulnerability in a version you run | An advisory matching your lockfile | Short time-to-patch, reachability triage, tests that make updates routine | Counting advisories; treating unreachable ones as urgent |
| Malicious or compromised package | A new version that behaves differently, or a name close to one you meant | Lockfile with integrity hashes, --ignore-scripts, scoped internal names, delayed adoption of brand-new versions | A scanner — the package is not reported until someone notices |
| Build-system compromise | The dependency is fine; the pipeline is not | Digest-pinned actions and base images, least-privilege short-lived CI credentials, isolated install step | Application-level scanning, which never looks at the pipeline |
| Unpatchable transitive pin | A parent will not accept the fixed version | Override/resolution mechanism, then upstream a fix or replace the parent | Waiting; the advisory does not age out |
| Stale base image | Application tree is clean, the OS layer is not | Scan the built image, rebuild on a schedule | Scanning only the repository |
Triage is a reachability question
The practical skill is answering, in a few minutes, whether an advisory can affect you. The chain has three links, and if any of them is missing the item is not urgent — which is the answer that buys back the attention the real one needs.
Write the outcome down in the pull request or the advisory tracker, including the "no" answers. An undocumented decision has to be made again by the next person, under time pressure, with less context.
Is there a path from untrusted input to the vulnerable code, in production?
when The package parses, deserializes or renders data that arrives from outside the process.
cost Patch now, out of band. This is the case the process exists for.
when The path exists but only operators or internal jobs supply the data.
cost Patch on the next scheduled update; note the assumption, because internal input becomes external more often than expected.
when A transitive package your code never loads, or a feature of it you do not use.
cost Batch it. Record why, so the judgement can be re-checked when usage changes.
when It never ships in the runtime image.
cost Lower urgency for production, higher for CI: the credentials are there. Do not confuse the two ratings.
when Nobody can say whether the code path is reachable.
cost Treat as reachable and patch. The investigation usually costs more than the update.
How to build it
Most important first.
- Commit the lockfile and install from it exactly —
npm ci,pip install --require-hashes,poetry install,go mod verify. A build that resolves versions freshly is a build whose contents nobody chose (Containerizing a Backend). - Separate runtime dependencies from build and test dependencies, and ship only the first into the production image. A smaller image is a smaller attack surface and a shorter advisory list (Why Image Size Is an Infrastructure Problem in Cloud & Infrastructure).
- Disable lifecycle scripts where your ecosystem allows it, and where it does not, run installs in a container with no credentials and no network access beyond the registry.
- Give CI the narrowest credentials that work, scoped per job, short-lived, and never a long-lived registry or cloud key sitting in a shared variable (Short-Lived Credentials in Security Engineering).
- Automate small updates continuously so they are boring, and have a manual path for the urgent one. A team that updates monthly cannot patch in hours, because the patch arrives on top of six months of drift.
- Triage advisories by reachability and exposure, and write down the decision. "Not reachable, revisit if we start parsing user input with it" is a legitimate outcome; silence is not.
- Pin CI actions and base images by digest, not by tag. A tag is a mutable pointer, and it is the easiest place for someone else's code to enter your build (Artifact and Build Integrity in Security Engineering).
- Generate an SBOM at build time so that when the next widely-used library has a critical advisory, the question "are we affected" takes minutes rather than a day.
What can go wrong
- The update bot opens 30 pull requests a week, the team stops reading them, and the one that matters is merged three weeks late alongside the noise.
- Lockfile committed, and the production Dockerfile runs a plain install that ignores it.
- A vulnerability patched in the application repository and still present in a base image nobody rebuilds.
- Scanner runs on the repository but not on the built image, so the operating-system packages in the image are never assessed.
- A transitive dependency pinned by a parent that will not update, so the fix requires replacing or forking a direct dependency — and nobody has budgeted for that.
- Vendoring or forking to fix something urgently, then never returning, so the fork silently ages out of the update process entirely.
- A malicious package runs with the full privileges of whatever process loads it — your application at runtime, or your CI job at install time. CI is usually the richer target because it holds credentials for everything.
- Typosquatting and dependency confusion both exploit resolution rather than code: a name that resolves to a registry or a package you did not mean. Configure your package manager to scope internal names to your internal registry explicitly.
- The most common real-world compromise route is a maintainer account, not a clever exploit. That is why integrity hashes and digest pinning matter more than reading source.
- Deep coverage of supply-chain attack technique is Security Engineering's (Software Supply Chain Security, Typosquatting and Malicious Packages there). The backend owes reproducible installs, minimal runtime surface, and a patch process with a measured latency.
- "The scanner is green, so we are secure." It reflects known advisories against resolved versions at a moment in time, and nothing about reachability or about packages that have not been reported yet.
- "Zero known vulnerabilities is the target." The achievable target is a short, measured time-to-patch on things that matter. Chasing zero on unreachable advisories consumes exactly the attention the urgent one needs.
- "It is a dev dependency, so it does not matter." Dev dependencies execute in CI, where the credentials are.
- "We pin everything, so we are safe." Pinning stops surprise changes and freezes you on a known-vulnerable version until someone acts. It is half of a control.
- "Fewer dependencies is always safer." Sometimes. A hand-rolled implementation of something hard is a dependency with one maintainer and no reviewers.
Operating it
- Track time-to-patch per advisory severity as a real metric, with a target. It is the only number here that reflects whether the process works.
- Alert on lockfile drift: a build where the resolved tree differs from the committed lockfile should fail, not warn.
- Record dependency versions in your build metadata and expose them on a build-info endpoint, so "what is actually running in production" is answerable during an incident (Health Checks: Startup, Readiness, Liveness).
- Watch for unexpected outbound connections from build agents. A build that suddenly talks to a host it has never contacted is the clearest signal available for install-time compromise.
- At one service the process is a habit. At fifty it has to be a platform concern: a shared base image, a shared CI template, and one place that answers "which services use this version".
- More services multiply the advisory surface without multiplying the work, if and only if they share base images and templates. If each team assembled its own, the work multiplies exactly.
- At larger scale, an internal registry mirror with a review gate becomes worth its cost — mostly because it makes "which packages entered our estate this month" an answerable question.
- Aggressive automated updates cause breakage; conservative updates cause exposure. The resolution is a strong test suite, not a policy argument — and building that suite is the real cost.
- Fewer dependencies mean less surface and more code you maintain yourself. Writing your own parser is not automatically safer than using a widely reviewed one.
- Digest pinning gives reproducibility and guarantees you will be running an old base image until someone deliberately bumps it. Pair pinning with a scheduled bump, or you have traded one exposure for another.
- SBOMs and scanners produce large amounts of output; without triage capacity they add process without adding safety.
Where this applies
Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.
- GENERALEvery ecosystem with a package registry has this shape. What differs is the default risk: npm and PyPI run install-time scripts, Go modules do not execute code on fetch and verify against a checksum database by default — so the same advice has different urgency per row.
- SCALE-SPECIFICOne service with a small tree can be handled by a weekly look at the bot's pull requests. Past roughly ten services, unshared base images turn advisory response into per-team archaeology, and the shared-template investment starts paying for itself.
Where the depth lives
This domain teaches the application-side mechanism and hands the rest off.
- — Testing & Reliability Engineering — a test suite you trust is what makes a dependency update a five-minute change instead of a risk assessment.