Skip to content

Dependency Security: Managing Third-Party Risk

Lesson 8 of 10Advanced15 min readGit Security & DevSecOps · Supply Chain SecurityVerified: Package manager integrity and provenance behaviour; GitHub dependency tooling, September 2026

Most of the code you ship, you did not write. For a typical application the ratio is not close — hundreds to thousands of third-party packages, from hundreds of maintainers, most of them arriving transitively because something else needed them.

Dependabot and dependency review cover the part of this problem with a published advisory. This page covers the rest, which includes every attack that works precisely because there is no advisory yet.

Four controls do most of the work, and none of them requires an advisory to exist.

  1. Lock files with integrity hashes, committed. You get the exact bytes you resolved before, and a substituted artifact fails.
  2. Namespace control. Internal package names must never resolve to a public registry.
  3. A cooldown before adopting new releases. So you are not the person who discovers a compromised package.
  4. Reviewing what a change adds, including indirect dependencies, before it lands.

Everything else is refinement.

Direct, transitive and the resolution in between

Section titled “Direct, transitive and the resolution in between”

Direct dependencies you declared. You chose them, you can change them, and there are usually a manageable number.

Transitive dependencies arrived because something else needed them. They outnumber direct ones by roughly an order of magnitude, and they are where most vulnerabilities and most malicious packages land — because most of your tree is code somebody else’s dependency chose.

Resolution is the process that turns declarations into a concrete set of versions and artifacts. It is the step people do not think about and the one several attacks target: given a name and a constraint, which registry is consulted, in what order, and which version wins?

Terminal window
npm ls --all

What it doesPrints the full resolved dependency tree, direct and transitive.

Why we run itThe declared list and the resolved list are different documents, and the resolved one is what you ship.

Expected resultA tree substantially larger than your manifest. The exact command varies by ecosystem.

Running that once against a project you thought you understood is a useful shock. The gap between what you declared and what you resolved is the actual attack surface.

The single most effective dependency control, and it is a file you probably already have.

A manifest declares intent. “Any 4.x version of this library.”

A lock file records fact. “Version 4.18.2, with this integrity hash, plus these 340 transitive dependencies at these exact versions.”

The integrity hash is the part that matters most and gets the least attention. It records the expected content hash of each package artifact. If the registry serves different bytes for the same version — because it was compromised, because a maintainer republished, because something is intercepting the download — installation fails rather than proceeding.

That converts “trust the registry every time” into “trust the registry once, then verify”.

For Python specifically, --require-hashes is worth calling out: without it, requirements.txt with pinned versions still trusts whatever the index serves for that version. With it, the hash must match.

Different granularities, different trade-offs.

ApproachExampleGets updatesReproducible
Range^4.0.0AutomaticallyNo
Exact version4.18.2Only when you change itMostly
Version plus hash4.18.2 with integrityOnly when you change itYes
Commit or digestAn action by SHA, an image by digestOnly when you change itYes

Ranges in the manifest plus a lock file is the standard arrangement and it is a good one: the manifest expresses what you would accept, the lock file records what you got, and updates are a deliberate act that produces a diff.

Exact pinning without a lock file is weaker than it appears, because it constrains the version and not the bytes, and it says nothing about transitive dependencies.

Commit and digest pinning is for things fetched outside a package manager — GitHub Actions, base images, downloaded binaries. See Pinning actions.

The standing objection to pinning is staleness, and the answer is not to stop pinning. It is to automate the update so the pin moves deliberately and visibly: Dependabot for packages, actions and base images alike.

The category the vulnerability tooling structurally cannot help with, because there is nothing to report until somebody notices.

A package published under a name close to a real one — a transposition, a missing hyphen, a singular where the real name is plural. Installed by a typo, by a copied command from a bad tutorial, or by an AI-suggested import for a package that does not exist.

What helps: dependency review’s age and adoption signals; installing from lock files so a typo does not silently resolve; and being suspicious of a package with almost no downloads appearing in your tree.

The one worth understanding precisely, because no human error is involved.

If your build resolves from both a public registry and an internal one, and an internal package acme-utils exists only internally, an attacker publishes acme-utils to the public registry with a very high version number. A package manager configured to select the highest available version across configured sources fetches the attacker’s package.

Nobody made a mistake. The resolution logic did what it was configured to do.

What helps:

  • Scoped namespaces@acme/utils rather than acme-utils — with the scope reserved publicly
  • Registry configuration that never falls back to a public source for internal names
  • deny-groups in dependency review blocking your namespace from public sources
  • Publishing placeholder packages under your internal names publicly, so the names are taken

An account with publish rights is taken over, or a maintainer transitions. A version containing a payload is published under a name you already trust and already have pinned.

What helps: a cooldown before adopting new releases, so somebody else finds it first; publish protections on registries that support them; and package provenance where the ecosystem provides it.

A maintainer deliberately breaks or sabotages their own package, or abandons it entirely so a vulnerability is never fixed.

What helps: vendoring or forking critical dependencies; knowing which of your dependencies have a single maintainer; and OpenSSF Scorecard signals, which measure practices rather than code.

Underused, and one of the highest-value controls available.

Adopting a release the hour it publishes means that if it is compromised, you are among the first to run it. Waiting a few days means somebody else is.

.github/dependabot.yml
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
cooldown:
default-days: 7

The trade-off is a few days’ delay on non-security updates, which is almost always acceptable.

Note the deliberate exception: cooldown applies to version updates and not to security updates. Delaying a security fix would defeat the purpose. That also means a cooldown does not protect you from a compromised package published as a security fix — nothing in this feature set does, and it is one reason review of dependency changes still matters.

Risk depends less on which package is malicious than on when it runs, and the stages have very different consequences. Most discussion focuses on the least dangerous one.

StageRuns withTypical consequence
InstallYour build’s full environment and credentialsCredential exfiltration, build tampering
BuildThe sameThe artifact does not match the source
TestThe same, plus test fixturesAccess to test credentials and data
RuntimeThe application’s identity and network positionApplication compromise
Development onlyA developer’s machineLaptop compromise, and their credentials

Two rows are consistently underestimated.

Install-time execution is the most dangerous and the least discussed. A package that runs a script on install executes before any of your code, in an environment holding whatever your CI holds. --ignore-scripts is the direct mitigation, and where a package genuinely needs a script, that is worth knowing about it explicitly.

Development-only dependencies are treated as low risk because they are not shipped. They execute on developer machines and in CI — which is where the credentials are — and they receive less scrutiny precisely because “it is only a dev dependency”. A linter plugin with a malicious release has a straight path to every developer’s environment.

The practical conclusion: scope is not severity. A devDependency can be a worse incident than a runtime one, and the tooling’s default of failing only on runtime scopes reflects a different concern from yours.

Increasingly, ecosystems let publishers attach provenance: a signed statement that this package version was built by this workflow from this commit.

Where it exists, it converts “trust the maintainer’s machine” into “verify the build”. A package published with provenance from a public repository lets you check that the artifact corresponds to source you could read — which is a much stronger position than trusting a tarball uploaded by an account.

Two things to do about it:

Publish provenance for what you publish. In GitHub Actions this is usually a flag or a step, and it is the strongest signal you can give your consumers. See Artifact attestations.

Prefer dependencies that publish it, where you have a choice. This is a genuine differentiator between two otherwise-equivalent libraries, and treating it as one is how ecosystems improve.

Verifying consumed provenance is not yet routine in most ecosystems’ tooling, and it is coming. In the meantime, its presence is a signal about a project’s practices even when you do not check it mechanically.

A private registry or proxy in front of the public ones changes the risk in both directions.

What it helps with:

  • A single point to enforce policy — blocklists, allowlists, version constraints
  • Caching, so a package disappearing upstream does not break your builds
  • Namespace control, which is the dependency confusion defence
  • Audit, because you can see what was fetched and by whom

What it introduces:

  • A new critical system. If it is compromised, it serves whatever it likes to every build.
  • A false sense of review. A proxy that caches everything transparently has not reviewed anything.
  • Staleness, if it lags upstream on security updates.

The strongest configuration is a proxy that only serves what it has, with additions requiring a deliberate act. That is real friction, and for organisations where the friction is acceptable it closes most of this page’s attacks at once.

Threat. Third-party code you execute — at install time, at build time or at runtime — does something you did not intend.

Attack surface. The whole transitive tree. The registries it resolves from. The resolution logic that decides which artifact a name maps to. The maintainer accounts with publish rights. And any proxy or mirror in between.

Impact. Determined by where the code runs. A malicious package with an install script executes in your build with your build’s credentials — which for many pipelines means cloud access, registry access and the ability to publish. A malicious runtime dependency executes in production. The first is usually worse and gets less attention.

Control. Reduce what resolution can reach (namespaces, private registries), pin the bytes (lock files with hashes), delay adoption (cooldowns), review changes (dependency review), and reduce execution (--ignore-scripts where feasible).

Verification. Change a version in the lock file’s integrity field and confirm installation fails. Attempt to resolve an internal package name from a machine configured only for public registries and confirm it does not resolve. Both take minutes and both find real misconfigurations.

The control nobody mentions because it is not a tool: depend on less.

Every dependency is a maintainer to trust, a release stream to track, an advisory feed to watch and a transitive subtree to inherit. The security value of removing one is permanent and requires no ongoing effort, which is unusual among the controls on this page.

Three questions worth asking during review, none of which is about vulnerabilities:

Is this dependency doing something we could do in twenty lines? The classic case is a package wrapping a single standard-library call. The dependency has a maintainer, a supply chain and a transitive tree; the twenty lines have none.

Does this bring a large subtree? A package with sixty transitive dependencies is sixty new maintainers. Sometimes worth it, and worth knowing before agreeing.

Is this a development dependency that runs in CI? Development dependencies get less scrutiny and execute in the environment holding your credentials, which is exactly backwards.

The counter-argument is real and worth stating: reimplementing a well-maintained library usually produces worse code with its own bugs, and “write it yourself” is not a general security strategy. The judgement is about proportion — a cryptography library is worth depending on; a package that checks whether a value is an array is not.

Updating is itself a risk decision, and both extremes are wrong.

Update everything immediately. Maximum exposure to compromised releases, maximum churn, and a team that stops reading dependency pull requests because there are always twelve open.

Update nothing. Accumulating known vulnerabilities, and — the worse cost — losing the ability to move. A codebase four major versions behind cannot take an urgent security patch without a migration project.

The workable position between them:

  • Security updates: immediately, with CI validating them.
  • Patch and minor updates: batched weekly, grouped, after a cooldown.
  • Major updates: deliberately, scheduled as work, one at a time.
  • Build tooling and anything cryptographic: individually, never grouped, always reviewed.

The metric worth tracking is not “how many outdated dependencies do we have?” but “how long would it take to adopt a patch in our most critical dependency?” That number describes your response capacity, and it is the thing an incident actually tests.

Several checks are cheap, deterministic and rarely present.

- name: Fail if the lock file is not up to date
run: |
npm ci
git diff --exit-code package-lock.json

An install from a lock file should change nothing. If it does, the lock file and the manifest disagree, and the build is resolving something the lock file does not describe.

- name: Fail if any dependency is fetched over plain HTTP
run: |
if grep -qE '"resolved": *"http://' package-lock.json; then
echo "::error::A dependency resolves over plain HTTP"
exit 1
fi
- name: Fail on install scripts in newly added packages
run: npm ci --ignore-scripts

That last one deserves comment. Several ecosystems execute package-supplied scripts during installation — which is arbitrary code execution from a third party, in your build, with your build’s credentials. --ignore-scripts is safe for many projects and breaks those with packages that genuinely need a build step. Where it works, it removes an entire execution path.

It happens, it is publicly announced, and the first hour is spent on questions rather than on fixes. Having the answers pre-arranged is most of the response.

  1. Did we ever resolve the affected version? Your lock file history answers this precisely — git log -p on the lock file, searching for the version. Without a committed lock file, you are inferring from ranges and build dates.

  2. Which builds ran while it was in the tree? Provenance answers this directly. Without it, CI logs, if retained.

  3. What did the payload do? Usually published quickly, and it determines everything below. An install-time payload that exfiltrated environment variables is a credential incident. A runtime payload is an application incident.

  4. Rotate whatever the build could reach, if it executed at install or build time. The scope is whatever the build held — which is the practical argument for minimal build credentials and for OIDC over stored secrets.

  5. Pin away from the affected version and rebuild from a clean tree. Do not merely upgrade past it; make sure nothing in the tree can resolve back to it.

  6. Redeploy, and verify what is running by digest rather than by tag.

  7. Check whether you published anything built during the window. If you did, your consumers have the same problem and you are now upstream of it.

Step 7 is the one organisations forget, and it is the one that matters most to everybody else. If a compromised dependency was in a release you published, you have propagated it, and the same courtesy you wanted from your upstream is now owed downstream.

Installing with the wrong command. npm install in CI ignores what the lock file guaranteed.

No lock file. The graph reports ranges, builds are not reproducible, and integrity is unverified.

Pinning without automating updates. The pin is correct and becomes a known-vulnerable version nobody moves.

Unscoped internal package names. Dependency confusion is prevented by naming and configuration, not by vigilance.

Adopting releases immediately. Being first to install a compromised version is a choice you can avoid for the cost of a few days.

Trusting popularity. It means many users, and it is what makes a package worth attacking.

Assuming a proxy is a review. A transparent cache has reviewed nothing.

Ignoring install scripts. Third-party code executing in your build, at install time, with your credentials.

Treating advisories as the whole problem. The attacks on this page produce no advisory until somebody notices, which is after you installed it.

Assuming a development dependency is low risk. It executes where your credentials are, and it gets less review than anything you ship.

Every dependency is code you execute, chosen by somebody you have not met, delivered by a system you do not control. Security here is about reducing what you trust — pin the bytes, control the names, wait before adopting, and read what changes.

  • Transitive dependencies outnumber direct ones and are where most risk lands
  • A lock file’s integrity hashes convert “trust the registry” into “trust once, then verify”
  • Installing with the wrong command discards the guarantee: npm ci, --frozen, --locked, --require-hashes
  • Dependency confusion exploits resolution logic, and is prevented by namespaces and registry configuration
  • Typosquatting and maintainer compromise produce no advisory until somebody notices
  • Cooldowns mean somebody else discovers a compromised release first; they do not apply to security updates
  • Package provenance converts trusting a maintainer’s machine into verifying a build
  • A private registry adds policy and audit, and becomes a critical system of its own
  • Install scripts are third-party code execution in your build, and --ignore-scripts removes it where feasible
  • Lock file drift, plain-HTTP resolution and install scripts are all cheap CI checks

Use a real project of yours.

  1. Count declared dependencies, then run the full resolved tree command. Predict: what is the ratio?

  2. Check which install command your CI uses. Predict: does it honour the lock file?

  3. Run the lock file drift check. Predict: does the lock file change on install?

  4. Search the lock file for http://. Predict: any results?

  5. Try installing with --ignore-scripts. Predict: does the build still work? If not, which package needs a script, and what does it do?

  6. List your internal package names. Predict: could any of them resolve from a public registry? Check whether the names are taken publicly.

  7. Pick your most critical dependency and answer: how many maintainers, when was the last release, does it publish provenance?

  8. Add a cooldown to your Dependabot configuration and observe the effect over a few weeks.

GitHub Actions Security ChecklistToken permissions, fork pull requests, script injection and supply chain — with the attack each item prevents.

The repository security templates — secrets management and least-privilege token guides — are in the Professional Toolkit.