Originally published on tamiz.pro.
Modern applications are assembled more than written. A typical service ships a few thousand lines of first-party code alongside hundreds of transitive packages, build plugins, container base layers, and GitHub Actions. The uncomfortable question is not "did we write this?" but "can we explain why it is here, what it can do, and how we would remove it?" This guide treats dependency trust as an engineering system rather than a feeling: inventory what you import, classify it by blast radius, verify it automatically and by hand, constrain what it can execute, and rehearse recovery before an incident forces you to improvise.
For practical engineering notes on delivery pipelines and security controls, I often point teams to Tamiz's Insights. The same mindset applies here: trust is not a binary property of a package, it is a set of controls you can inspect, test, and improve.
Table of Contents
- 1. Define Ownership Before You Audit
- 2. Build a Threat Model for Glue Code
- 3. Inventory Every Dependency, Including the Invisible Ones
- 4. Classify Dependencies by Blast Radius
- 5. Automate the Boring, High-Signal Checks
- 6. Read Package Code Like a Reviewer, Not a User
- 7. Pin, Hash, and Reproduce the Supply Chain
- 8. Constrain Execution and Network Access
- 9. Design Update and Rollback Workflows
- 10. Prove Trust with Runtime Evidence
- 11. Incident Response for a Compromised Dependency
- 12. A Reference Pipeline You Can Copy
- Frequently Asked Questions
1. Define Ownership Before You Audit
Ownership in software is not authorship; it is accountability. If a compromised transitive package exfiltrates environment variables during CI, your customers will not care that you did not write it. Your team still owns the outcome because you chose the dependency, the version, the installation path, and the privileges under which it ran.
This distinction matters because many teams audit only direct dependencies. Direct dependencies are the packages named in package.json, requirements.in, Cargo.toml, or go.mod. Transitive dependencies are everything those packages pull in. Build-time dependencies compile or bundle your code. Dev dependencies run tests and linters. Native modules contain compiled binaries. Vendored code is copied into your repository and updated manually. All of these can execute code, alter build output, or change runtime behavior.
| Dependency class | Examples | Where it matters | Review depth |
|---|---|---|---|
| Direct runtime | express, fastapi, sqlalchemy | Request and data path | High |
| Transitive runtime | Resolved by lockfile | Often invisible until an incident | Medium, plus reachability |
| Build-time | vite, webpack, babel, esbuild | Artifact integrity | High |
| Dev and test | jest, pytest, eslint | CI and developer machines | Medium |
| Native or binary | sharp, psycopg2, bcrypt | OS-level execution | Very high |
| Vendored | Copied source trees | No upstream update path | Manual and explicit |
Start by writing a one-page dependency ownership statement. It should answer four questions: which dependency classes are allowed in production, who can add or upgrade them, what evidence is required before merge, and what the rollback path is. Without this, every pull request becomes a debate about taste instead of a check against policy.
A useful rule is that the review burden should scale with blast radius. A pure TypeScript utility with no lifecycle scripts deserves less scrutiny than a package that parses untrusted input, handles authentication tokens, or runs during installation. The goal is not to read every line of every package. The goal is to make high-impact changes expensive and low-impact changes cheap.
2. Build a Threat Model for Glue Code
Most dependency incidents are not exotic zero-days. They are boring failures of process: a maintainer account is phished, a package name is typo-squatted, a trusted library ships a malicious minor release, an abandoned package is taken over, or a vulnerable function happens to be reachable in your code. The controls in this article map to those realities.
| Threat | Typical signal | Primary control |
|---|---|---|
| Compromised maintainer account | New release from an unexpected publisher or unusual tarball | Provenance, signatures, review release diffs |
| Typosquatting or dependency confusion | Package name close to an internal package | Scoped names, private registry, lockfile review |
| Malicious update in a trusted package | New lifecycle script, network call, or obfuscated code | Diff versions, disable install scripts, sandbox builds |
| Accidental vulnerability | CVE in a reachable code path | SCA scanning plus reachability analysis |
| Abandoned or transferred package | No releases, new maintainer, stale repository | Ownership policy and periodic review |
| Excessive privileges | Package runs as root or has broad filesystem access | Containers, least privilege, runtime sandboxing |
| License or compliance risk | Incompatible license in production bundle | License allowlists in CI |
Real incidents illustrate why this needs to be systematic. event-stream became a cautionary tale because a new maintainer gained trust and published a malicious transitive dependency. colors.js showed that protestware can be shipped through a normal release channel. Log4Shell showed that a ubiquitous logging library can turn a string into remote code execution. The 2024 xz backdoor showed that a patient attacker can spend years earning maintainer trust and then hide malicious logic in build scripts. ua-parser-js and similar account compromises showed how quickly a popular package can be weaponized.
None of these examples mean you should avoid open source. They mean you should stop treating open source as a moral category and start treating it as an untrusted input that must be verified, constrained, and updated on purpose.
3. Inventory Every Dependency, Including the Invisible Ones
You cannot audit what you cannot enumerate. A lockfile is the first inventory, but it is not a complete picture of risk. It does not tell you which dependencies are production-reachable, which are build-only, which include native binaries, or which packages are installed on developer laptops but never deployed.
Generate inventories from multiple angles. From the package manager, get the resolved graph. From the container image, get the installed operating-system packages and language packages. From CI, capture the exact versions used to build the artifact. From the browser or runtime, capture loaded modules where possible.
# Node: resolved dependency graph npm ls --all --json > dependency-inventory-node.json npm explain some-package # pnpm pnpm list --depth Infinity --json > dependency-inventory-pnpm.json # Python pipdeptree --json-tree > dependency-inventory-python.json # Container or image syft packages:my-image:latest -o cyclonedx-json > sbom.json npm explain is particularly useful because it answers "why is this here?" in terms of the dependency path. In a large graph, that question is more important than the package's popularity. A vulnerable package that is only used by a dev-only formatter has a different risk profile than the same package on the request path.
For production risk, separate runtime dependencies from everything else:
npm ls --prod --all --depth=Infinity npm ls --dev --all --depth=Infinity For Python, generate a requirements file with hashes and inspect it as a lockfile rather than trusting requirements.in:
pip-compile --generate-hashes requirements.in The output becomes both an inventory and an integrity artifact. If someone changes a transitive version, the hash changes and review is forced.
A good inventory process also detects drift. Add a CI check that fails when package-lock.json, pnpm-lock.yaml, Cargo.lock, poetry.lock, or go.sum changes without a corresponding dependency policy exception. Lockfile changes should be reviewed like code changes because they are code changes.
4. Classify Dependencies by Blast Radius
Not every package deserves the same review. A flat policy produces two bad outcomes: engineers bypass slow processes for trivial packages, or trivial packages receive no scrutiny because everything is marked critical. Classify dependencies by what they can affect.
| Tier | Typical packages | Risk question | Policy |
|---|---|---|---|
| Tier 0 | Auth, crypto, parsing, networking, serialization, database drivers | Can it expose data or execute code? | Two reviewers, provenance required, no auto-merge |
| Tier 1 | Web frameworks, routers, ORMs, template engines | Can it change request handling or render output? | One security-aware reviewer, staged rollout |
| Tier 2 | Utilities, date libraries, styling, non-critical SDKs | Does it touch production runtime? | Automated tests plus normal review |
| Tier 3 | Linters, formatters, test frameworks, build tooling | Is it confined to CI or developer machines? | Automated updates allowed with tests |
The tier is not the package's brand. It is the role it plays in your system. lodash in a browser bundle is different from lodash in a server-side template path. A date library used only in a report generator is different from one used to validate token expiry.
Write the policy in a machine-readable form so tooling can enforce it. A simple example:
dependencyPolicy: tiers: tier0: approvers: 2 allowAutoMerge: false maxUpdateAgeDays: 7 allowedLicenses: [MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC] tier3: approvers: 1 allowAutoMerge: true maxUpdateAgeDays: 30 allowedLicenses: [MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC, 0BSD] The policy should also define exceptions. Exceptions are normal, but they must expire. A permanent exception is just a policy you do not want to write down. Record the reason, owner, and expiry date. If the expiry passes, CI fails until someone renews or fixes it.
5. Automate the Boring, High-Signal Checks
Automation cannot prove a package is safe, but it can remove large classes of risk and force humans to spend time where it matters. The mistake is treating npm audit as a complete security program. It is a vulnerability feed lookup against your lockfile, useful but noisy and often unable to tell whether vulnerable code is reachable.
A better automation stack has four layers: known vulnerabilities, license compliance, malware and behavior heuristics, and pull-request review gates.
{ "scripts": { "audit:deps": "npm audit --audit-level=high", "audit:osv": "osv-scanner --lockfile=package-lock.json", "licenses": "license-checker --production --summary --onlyAllow 'MIT;Apache-2.0;BSD-2-Clause;BSD-3-Clause;ISC'", "ci:deps": "npm run audit:deps && npm run audit:osv && npm run licenses" } } Language-specific tools matter. Use pip-audit for Python, cargo audit and cargo deny for Rust, govulncheck for Go, and bundler-audit for Ruby. Container images should be scanned with Trivy or Grype because vulnerabilities often come from the base image rather than your application dependencies.
Behavioral tools are the next layer. Traditional SCA asks "is this version in a vulnerability database?" Behavioral tools ask "did this package gain a lifecycle script, make a network call, or change its published files?" That question catches compromised packages before a CVE exists. Socket, Phylum, and StepSecurity are examples of tools in this category. You do not need a specific vendor, but you do need at least one signal beyond CVE matching.
Dependency review in pull requests is where automation becomes enforceable. GitHub's dependency review action can block newly introduced vulnerable or incompatible dependencies before they merge. Renovate and Dependabot can group low-risk updates and open pull requests with changelogs. Grouping matters because fifty individual update PRs create fatigue, and fatigue creates auto-merge without review.
A minimal Dependabot configuration:
version: 2 updates: - package-ecosystem: npm directory: / schedule: interval: weekly open-pull-requests-limit: 10 groups: minor-and-patch: update-types: [minor, patch] dev-dependencies: dependency-type: development The most important automation rule is simple: a high or critical finding blocks merge unless an approved exception exists. If exceptions are informal, your policy is a suggestion.
6. Read Package Code Like a Reviewer, Not a User
There are times when automation is not enough: a new Tier 0 dependency, a package with a suspicious release, a maintainer change, or a large version jump. Then you read the code. The trick is to avoid drowning in implementation details and focus on capabilities.
First, download the exact published tarball without installing it. Do not run npm install to inspect a suspicious package. Use the registry tarball directly:
package_name=express package_version=4.19.2 tarball_url=$(npm view "$package_name@$package_version" dist.tarball) curl -fsSL "$tarball_url" -o "$package_name.tgz" mkdir -p inspected tar -xzf "$package_name.tgz" -C inspected Then inspect the package metadata and search for dangerous capabilities:
cat inspected/package/package.json grep -RInE 'child_process|exec(Sync)?\(|spawn|eval\(|new Function|fetch\(|https?://|process\.env|fs\.(write|read|unlink|rm)' inspected/package || true find inspected/package -type f -size +500k -print Review the package's scripts, bin, main, exports, and files fields. A package can do work during installation through preinstall, install, or postinstall. It can expose a CLI that runs when another tool calls it. It can include prebuilt binaries. It can ship minified files that are hard to review. None of these are automatically malicious, but each is a reason to slow down.
Compare the release with the source repository. A healthy package has a linked repository, a changelog, and a release tag that matches the published version. For higher assurance, check whether the published artifact has provenance or signatures:
npm view express@4.19.2 --json | jq '{name, version, maintainers, repository, dist}' npm audit signatures Provenance is not a guarantee of safety, but it narrows the gap between "the maintainer published this" and "someone with a stolen token published this from a laptop."
A practical review checklist:
- Has the maintainer or publisher changed recently?
- Is the release diff unusually large or unusually tiny?
- Are there new lifecycle scripts?
- Are there new dependencies?
- Are there network calls, environment variable reads, or filesystem writes?
- Are there minified or obfuscated files?
- Are there native binaries or WebAssembly modules?
- Does the published tarball match the source repository?
- Does the package have a plausible reason to need each capability?
Popularity is not a control. A package with ten million weekly downloads can still be compromised for six hours, which is plenty of time for a worm to propagate. Treat download counts as context, not evidence.
7. Pin, Hash, and Reproduce the Supply Chain
"Latest" is not a version; it is a request to be surprised. Production builds should use exact versions and lockfiles. CI should install from the lockfile without mutating it. Artifacts should be reproducible enough that a rollback is a redeployment of a known digest, not a fresh resolution.
For npm, commit package-lock.json and use npm ci:
npm ci Configure the project to prefer exact versions and stricter behavior:
# .npmrc save-exact=true engine-strict=true ignore-scripts=true audit-level=high ignore-scripts=true is one of the highest-value settings in a Node stack. It prevents lifecycle scripts from running during install. The trade-off is that packages with native bindings may need an explicit, reviewed rebuild step. That trade-off is usually worth it because install scripts are a primary malware delivery mechanism.
For pnpm, commit pnpm-lock.yaml and use:
pnpm install --frozen-lockfile For Yarn, commit yarn.lock and use:
yarn install --immutable For Python, commit hashed requirements and install with hash checking:
pip-compile --generate-hashes requirements.in pip install --require-hashes -r requirements.txt For Go and Rust, commit go.sum and Cargo.lock respectively and use the language's verification commands. For container builds, pin base images by digest rather than tag:
FROM node:22-alpine@sha256:example-digest AS build WORKDIR /app COPY package*.json ./ RUN npm ci --ignore-scripts COPY . . RUN npm run build Dependency confusion is another reason to pin and scope. If an internal package is named utils, an attacker can publish a public utils with a higher version. Prevent this by using scoped names such as @mycompany/utils, configuring the scope to your private registry, and never allowing a private scope to fall back to the public registry:
# .npmrc @mycompany:registry=https://npm.pkg.github.com/ //npm.pkg.github.com/:_authToken=${GITHUB_TOKEN} registry=https://registry.npmjs.org/ Reproducibility also means capturing the build environment. Record the Node, Python, or Go version; the package manager version; and the lockfile hash. A release artifact without that context is hard to audit later.
8. Constrain Execution and Network Access
Pinning prevents unexpected changes, but it does not stop a compromised version you have already approved. Containment limits what the package can do if it turns malicious. This is where many teams underinvest because it requires platform work rather than a single configuration flag.
Start with CI. Run dependency installation and tests in ephemeral runners with no long-lived secrets. Use least-privilege workflow permissions:
permissions: contents: read Pin third-party GitHub Actions by commit SHA, not by floating tag. Prefer OIDC to cloud providers instead of stored credentials. If a dependency's install script runs in CI, assume it can read every secret available to that job. Design jobs so install-time code has no access to deployment credentials.
Build in isolated environments. A multi-stage Docker build separates the build toolchain from the runtime image. Use read-only filesystems, non-root users, and dropped capabilities in the runtime container:
FROM node:22-alpine AS runtime WORKDIR /app USER node COPY --from=build /app/dist ./dist COPY --from=build /app/node_modules ./node_modules CMD ["node", "dist/server.js"] For higher assurance, run builds in sandboxed environments such as gVisor or Firecracker-based runners. At minimum, disable network access during tests unless a test explicitly needs it. A package that phones home during npm test should fail the build.
Node's experimental permission model can restrict filesystem and network access at runtime:
node --permission --allow-fs-read=/app --allow-net=api.example.com dist/server.js Runtime containment should also include browser controls. When loading third-party scripts from a CDN, use Subresource Integrity and a strict Content Security Policy:
<script src="https://cdn.example.com/lib.js" integrity="sha384-example-hash" crossorigin="anonymous"></script> At the cluster level, use network policies to restrict egress from application pods. An API service usually needs a small set of outbound destinations. If a compromised dependency tries to exfiltrate data to an unknown host, the network policy should make that attempt visible or impossible:
apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: api-egress spec: podSelector: matchLabels: app: api policyTypes: - Egress egress: - to: - ipBlock: cidr: 10.0.0.0/8 ports: - protocol: TCP port: 443 Containment is not a substitute for auditing, but it converts many compromises from "full environment takeover" into "failed network connection that we can detect."
9. Design Update and Rollback Workflows
The worst dependency strategy is either "never update" or "update everything automatically and hope tests catch it." Never updating accumulates known vulnerabilities and makes future updates harder. Blind auto-merging turns every dependency into a potential unreviewed production change.
Use tiered update cadence. Tier 0 packages get manual review within seven days of a release. Tier 1 within fourteen days. Tier 2 and 3 can be grouped and auto-merged when tests pass. Security advisories override the cadence: if a CVE is published and your version is affected, triage immediately.
Renovate can express this policy directly:
{ "extends": ["config:recommended"], "packageRules": [ { "matchUpdateTypes": ["minor", "patch"], "automerge": true, "automergeType": "branch" }, { "matchDepTypes": ["devDependencies"], "automerge": true }, { "matchPackagePatterns": ["^@mycompany/"], "groupName": "internal-packages" } ] } Treat major updates as migrations. Read the changelog, check breaking changes, run the full test suite, and deploy to staging first. If the package is Tier 0, require a rollback plan before merge.
Rollback must be rehearsed. Store the lockfile and SBOM with every release artifact. If a dependency update breaks production, revert the commit that changed the lockfile, redeploy the previous artifact, and then investigate. Do not rebuild from "latest" during an incident because you may pull a different version than the one you just rolled away from.
# Revert the dependency change git revert <commit-sha> # Reinstall exactly what the previous lockfile specifies npm ci Keep a kill switch for high-risk features that depend on newly updated libraries. If you cannot disable the feature quickly, you cannot safely update its dependencies quickly.
10. Prove Trust with Runtime Evidence
A dependency list from last week is not evidence; it is a snapshot. For production trust, attach an SBOM to every release artifact and record the exact dependency versions that were built and deployed. This turns "we think we are not affected" into a query.
syft packages:my-image@sha256:example-digest -o cyclonedx-json > release.sbom.json cosign attach sbom --sbom release.sbom.json --type cyclonedx <image> Runtime evidence also helps reduce noise. Most vulnerability scanners report every dependency in the lockfile, including dev dependencies and code paths that are never executed. Reachability analysis can tell you whether the vulnerable function is actually called. Use it to prioritize, but do not use it to ignore findings without review. Reachability tools can miss dynamic imports, reflection, and framework magic.
Add behavioral monitoring around dependencies. In Node, unexpected child processes, shell spawns, or outbound connections from the application process are worth alerting on. A Falco rule can detect a shell spawned by Node:
- rule: Unexpected shell spawned by node desc: Detect shell execution from the Node.js process condition: spawned_process and proc.pname = "node" and proc.name in ("sh", "bash", "dash") output: "Shell spawned by node (user=%user.name command=%proc.cmdline)" priority: WARNING For browser applications, runtime evidence includes SRI failures, CSP violations, and unexpected script sources. These are not just security telemetry; they are dependency integrity signals. If a CDN script changes hash, something upstream changed and you should know before users do.
Finally, maintain a dependency dashboard. Track number of direct and transitive dependencies, outdated Tier 0 packages, open exceptions, time since last review, and SBOM coverage. What gets measured gets fixed, and dependency debt is otherwise invisible until it becomes an incident.
11. Incident Response for a Compromised Dependency
When a dependency is compromised, speed and precision matter more than perfect certainty. Assume the package executed in your environment until proven otherwise. The response should be organized around containment, scoping, eradication, and recovery.
- Identify scope. Search lockfiles, SBOMs, and deployed images for the affected package and version.
- Determine reachability. Check whether the vulnerable or malicious code path can run in your application or build pipeline.
- Contain execution. Stop affected jobs, quarantine runners, and block the package version in your registry mirror or package manager configuration.
- Eradicate. Pin a known-good version, regenerate the lockfile, rebuild artifacts, and redeploy.
- Rotate. If install scripts or runtime code executed, rotate secrets, tokens, and signing keys available to that environment.
- Verify. Re-scan the rebuilt artifact and compare its SBOM to the previous known-good release.
- Communicate and learn. Notify stakeholders, document the timeline, and update policy or automation to catch the same class of issue earlier.
Useful scoping commands:
# Find every occurrence in a Node lockfile grep -n '"malicious-package"' package-lock.json # Show dependency paths npm ls malicious-package # Inspect available versions npm view malicious-package versions --json If you use a private registry or mirror, block the bad version there first. This prevents other teams and CI jobs from pulling it while you investigate. Then pin the last known-good version explicitly:
npm install malicious-package@1.2.3 --save-exact npm ci Do not rely on "the fixed version" until you have verified the publisher, changelog, and tarball. In fast-moving incidents, attackers sometimes publish fake fixes or maintainers rush out incomplete patches. Review the replacement with the same rigor as any Tier 0 update, but do it quickly.
The most important cultural lesson is to rehearse. Run a tabletop exercise where a popular package is declared compromised. Ask how long it takes to answer "are we affected?" If the answer is more than an hour, your inventory and SBOM process needs work.
12. A Reference Pipeline You Can Copy
The following GitHub Actions workflow combines dependency review, vulnerability scanning, license checks, deterministic installation, build and test, container scanning, and SBOM generation. It is not a finished security program, but it is a strong baseline for a Node-based service.
name: supply-chain on: pull_request: push: branches: [main] permissions: contents: read jobs: dependency-review: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/dependency-review-action@v4 audit: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci --ignore-scripts - run: npm audit --audit-level=high - run: npx osv-scanner --lockfile=package-lock.json - run: npx license-checker --production --summary build-and-test: runs-on: ubuntu-latest needs: [audit] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci --ignore-scripts - run: npm run build - run: npm test scan-image: runs-on: ubuntu-latest needs: [build-and-test] steps: - uses: actions/checkout@v4 - run: docker build -t app:${{ github.sha }} . - run: docker run --rm aquasec/trivy:latest image --severity HIGH,CRITICAL --exit-code 1 app:${{ github.sha }} sbom: runs-on: ubuntu-latest needs: [build-and-test] steps: - uses: actions/checkout@v4 - run: docker build -t app:${{ github.sha }} . - run: syft packages:app:${{ github.sha }} -o cyclonedx-json > sbom.json - uses: actions/upload-artifact@v4 with: name: sbom path: sbom.json If you adopt only one part of this guide, adopt deterministic installs plus a lockfile review gate. That single change removes an entire class of "it worked on my machine" and "a dependency changed overnight" failures. Everything else can be layered in over time.
For more on building resilient delivery pipelines, see tamiz.pro and the engineering notes at Tamiz's Insights.
Frequently Asked Questions
Is npm audit enough to trust my dependencies?
No. npm audit checks your lockfile against known vulnerability advisories. It does not detect malicious packages before a CVE exists, compromised maintainer accounts, typosquatting, excessive permissions, or whether vulnerable code is reachable. Use it as one signal in a broader process that includes provenance checks, behavioral scanning, containment, and incident response.
Should I vendor all dependencies into my repository?
Usually no. Vendoring gives you control and offline builds, but it also makes updates manual, obscures provenance, and can bloat your repository. A better default is to pin exact versions with hashes in a lockfile, use a private mirror or registry cache for availability, and vendor only critical Tier 0 libraries where you need to inspect or patch source directly.
How often should I update dependencies?
Use a tiered cadence. Security advisories should be triaged immediately. Tier 0 dependencies should be reviewed within about seven days of a release. Tier 1 within two weeks. Tier 2 and 3 can be grouped into weekly or monthly automated pull requests. The important part is that updates are scheduled, tested, and reversible, not that they happen on a specific calendar day.

