SecurityGENERALSCALE-SPECIFIC

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.

What actually happensHow to build it

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.

The question

What do you owe for the code you did not write but do ship?

The requirement

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.

The obvious build

Install what we need, keep a lockfile, and turn on the automated update bot. When the scanner is green we are fine.

Why it breaks

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.

How it breaks in production
  • 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.
RequirementAPI ContractApplication LogicData AccessExternal DepsConcurrencyFailureSecurityObservabilityDeploymentScale

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.

The production image build
Resolves at build time
COPY package.json .
RUN npm install          # resolves ranges now; lockfile ignored
COPY . .
CMD ["node", "server.js"]
Installs exactly what was committed
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.

ThreatWhat it looks likeWhat actually helpsWhat does not
Known vulnerability in a version you runAn advisory matching your lockfileShort time-to-patch, reachability triage, tests that make updates routineCounting advisories; treating unreachable ones as urgent
Malicious or compromised packageA new version that behaves differently, or a name close to one you meantLockfile with integrity hashes, --ignore-scripts, scoped internal names, delayed adoption of brand-new versionsA scanner — the package is not reported until someone notices
Build-system compromiseThe dependency is fine; the pipeline is notDigest-pinned actions and base images, least-privilege short-lived CI credentials, isolated install stepApplication-level scanning, which never looks at the pipeline
Unpatchable transitive pinA parent will not accept the fixed versionOverride/resolution mechanism, then upstream a fix or replace the parentWaiting; the advisory does not age out
Stale base imageApplication tree is clean, the OS layer is notScan the built image, rebuild on a scheduleScanning only the repository

Triage is a reachability question

SCALE-SPECIFICThis triage is affordable per advisory at small scale. Past a few dozen services it must be answered once centrally per advisory and distributed, or every team pays the same investigation cost independently.

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.

Does this advisory need action today?

Is there a path from untrusted input to the vulnerable code, in production?

Reachable from request input

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.

Reachable only from trusted internal input

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.

Present but not on any executed path

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.

Build or test only

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.

Unknown

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

Failure modes
  • 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.
Security
  • 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.
Misreads
  • "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

How you see it in production
  • 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.
What changes at 10x and 100x
  • 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.
What this costs
  • 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.

Domains that do not exist yet
  • Testing & Reliability Engineering — a test suite you trust is what makes a dependency update a five-minute change instead of a risk assessment.