Dependency Awareness
🔑 Key Takeaway: Every dependency is code you did not write but are fully responsible for. Know exactly what is in your tree, pin versions for anything security-critical, enforce your lockfile in CI, and treat every update as a change that requires review.
Fundamentals
What Is a Dependency?
A dependency is any external code your project relies on to build or run. When you add a library to your project, you are making a trust decision: you are choosing to run someone else's code as if it were your own, with all the access and privileges that implies.
A modern Web3 project easily accumulates hundreds of dependencies. Most are never reviewed, many are maintained by a single developer, and some are actively targeted by attackers. A compromised package propagates to every project that depends on it, without any action required from the project owners. For details on specific attacks and how they exploited weak dependency practices, see Supply Chain Threats.
Direct and Transitive Dependencies
- Direct dependencies are the packages you explicitly add to your project (listed in your
package.json,Cargo.toml,go.mod,requirements.txt, etc.). - Transitive dependencies are the dependencies of your dependencies, packages you never chose that get pulled in automatically.
In most projects, transitive dependencies vastly outnumber direct ones. A single install command can bring in hundreds of packages you have never seen. This matters because:
- Vulnerabilities propagate silently. A vulnerability buried deep in your dependency chain still affects your application.
- Attackers target transitive dependencies precisely because they receive less scrutiny. Compromising a small, deeply nested utility package can affect thousands of downstream projects.
- You are responsible for all of them. Your users do not care whether a vulnerability was in code you wrote or in a package five levels down your dependency tree.
How Ecosystems Handle Dependency Locking
When you declare a dependency like "my-library": "^1.2.0", you are expressing a range of acceptable versions, not
a single one. Without any locking mechanism, every time you or your CI pipeline runs an install command, the package manager
resolves that range against whatever is currently published and may pick a different version. This means the same
project can produce different builds on different machines or at different times and if an attacker publishes a
malicious version within your accepted range, it gets pulled in silently.
Dependency locking solves this by recording the exact versions (and often cryptographic checksums) that were resolved at a specific point in time. Once locked, every subsequent install reproduces that exact set of dependencies rather than re-resolving from scratch. This gives you:
- Reproducibility: Every developer and every CI run gets the same dependency tree.
- Auditability: Changes to dependencies show up as diffs in version control, making them reviewable.
- Protection against silent substitution: A newly published malicious version cannot enter your project until someone explicitly updates the lock and that change is reviewed.
Not every language or package manager implements locking the same way. Some use lockfiles, some use checksums or vendoring, and some barely address the problem at all. Understanding how your ecosystem works is the first step to securing it.
| Ecosystem | Lockfile / Mechanism | What to Know |
|---|---|---|
| Node.js (npm/pnpm/yarn) | Three package managers, each with its own lockfile: package-lock.json (npm), pnpm-lock.yaml (pnpm), yarn.lock (yarn v1 and Berry). They are not interchangeable. | Switching package managers means regenerating the lockfile. Pick one and enforce it across the team. See Lockfile Integrity section below for how each one works. |
| Rust (Cargo) | Cargo.lock records exact versions and checksums for every dependency. Cargo verifies these checksums automatically against crates.io on every build. | For libraries, Cargo.lock is often .gitignored because downstream consumers resolve their own versions. For binaries and applications, always commit it. |
| Go | No traditional lockfile. go.sum stores cryptographic checksums for every dependency, and the public checksum database (sum.golang.org) lets you verify that everyone gets the same code for a given version. The module proxy caches modules so they remain available even if the original source disappears. | Go's approach is verification-first rather than lock-first. go.mod declares minimum versions, go.sum verifies integrity, and go mod tidy is the primary way updates enter the dependency tree. |
| Python (pip/Poetry/PDM) | pip has no built-in lockfile. You either generate a pinned requirements.txt with tools like pip-compile, or use Poetry (poetry.lock) or PDM (pdm.lock), which manage their own lockfile formats. | There is no single standard. Multiple competing tools solve this problem differently, and none of them are part of pip itself. Pick one approach for your project and enforce it across the team. |
| Java (Maven/Gradle) | Maven has no lockfile at all and re-resolves versions on every build. Gradle supports lockfiles (gradle.lockfile) but requires you to opt in explicitly. Both rely on artifact checksums from the repository for integrity. | The lack of a default lockfile in Maven means two builds of the same project can pull different versions. If you use Maven, consider the maven-enforcer-plugin to enforce reproducibility. |
| Ruby (Bundler) | Gemfile.lock records exact versions for every gem in the dependency tree. One of the earliest and most mature lockfile implementations across any ecosystem. | Commit it, and use bundle install --frozen in CI. |
| PHP (Composer) | composer.lock records exact versions for every package. Works the same way as Bundler. | Commit the lockfile, use composer install --no-update in CI. |
| Solidity (Foundry/Hardhat) | Foundry pins dependencies to specific git commits via submodules. Each submodule points at an exact SHA, so the version is locked by git itself. Hardhat uses npm and produces a standard package-lock.json. | Mixing both in the same project means managing two entirely different dependency systems. Review submodule updates with the same care as lockfile changes. |
The core principle is the same regardless of ecosystem: you need a reproducible, verifiable record of exactly what code gets pulled into your project. If your ecosystem provides a lockfile, commit it and enforce it. If it does not, look for checksum verification, vendoring, or commit-pinning as alternatives.
Lockfile Integrity
Node.js (npm / pnpm / yarn)
The Node.js ecosystem is the most common in Web3 development: frontends, tooling (Hardhat, Foundry's companion scripts), and most Web3 libraries (ethers.js, viem, wagmi) all live here. It is also one of the most targeted ecosystems for supply chain attacks due to its massive registry, deep dependency trees, and install-time script execution.
Each package manager handles installation differently:
- npm installs into a flat
node_moduleswhere all packages can see each other, regardless of which package declared them as a dependency. - pnpm uses a content-addressable store with symlinks, so packages can only access their declared dependencies. This stricter isolation catches undeclared dependency usage that would silently work under npm.
- yarn v1 uses a flat layout like npm. yarn Berry (v2+) removes
node_modulesentirely in favor of Plug'n'Play, which maps dependencies directly without anode_modulesfolder.
The lockfile (pnpm-lock.yaml, yarn.lock, or package-lock.json) is what actually determines which versions get
installed. Without it, the same package.json can produce different dependency trees on different machines and at
different times. An attacker could publish a malicious patch version, and any install without a lockfile would silently
pick it up.
-
Always commit your lockfile to version control.
-
Use frozen installs in CI. This ensures CI installs exactly what is in the lockfile, failing if there is any discrepancy:
pnpm install # pnpm uses --frozen-lockfile by default in CI npm ci # npm equivalent of frozen installs yarn install --frozen-lockfile # yarn v1 yarn install --immutable # yarn Berry (v2+)Note: pnpm automatically sets
--frozen-lockfiletotruewhen it detects a CI environment, so you do not need to pass the flag explicitly. npm and yarn require the explicit flag or command. yarn Berry replaced--frozen-lockfilewith--immutable. -
Review lockfile changes in PRs. Treat lockfile modifications with the same scrutiny as source code changes.
-
Watch for lockfile-only PRs. A PR that modifies only the lockfile without a corresponding change to
package.jsonis a meaningful signal worth investigating.
Cross-Ecosystem Reference
The same principles apply beyond Node.js, though the specific commands and mechanisms differ:
- Rust: Commit
Cargo.lockfor all binary and application projects. Cargo verifies checksums automatically, but reviewCargo.lockdiffs in PRs the same way you would a Node.js lockfile. - Go: Commit
go.sumand rungo mod verifyin CI to confirm that downloaded modules match their recorded checksums. Go's checksum database adds a layer of tamper detection that most ecosystems lack. - Python: If using Poetry or PDM, commit the lockfile and use
poetry install --no-updateorpdm install --frozen-lockfilein CI. If using pip, generate a fully pinnedrequirements.txt(viapip-compileorpip freeze) and install withpip install --require-hashes -r requirements.txtfor checksum verification. - Solidity (Foundry): Since Foundry uses git submodules, pin dependencies to specific commit SHAs rather than
branch names. Review submodule updates as carefully as you would lockfile changes. A submodule pointing at
mainis the equivalent of using"latest"in npm.
Install Scripts
npm packages can define lifecycle scripts (preinstall, postinstall, prepare) that run automatically during
installation. These scripts execute with the same permissions as the user running the install, which means a
malicious package can run arbitrary code on your machine or CI server the moment you install it, before you ever
import or use it. This is the primary execution mechanism behind most npm supply chain attacks, including
event-stream and
ua-parser-js.
-
Audit install scripts before adding new dependencies. Before installing a package, download it without executing anything (
npm pack <package>,pnpm pack <package>, oryarn pack <package>) and inspect itspackage.jsonforpreinstall,postinstall, orprepareentries. -
In high-assurance environments or hardened CI pipelines, disable lifecycle scripts by default and explicitly allow only reviewed packages that require them. All three package managers support disabling lifecycle scripts. Set this in your
.npmrc(read by both npm and pnpm) or.yarnrc.yml(yarn Berry):# .npmrc (npm and pnpm) ignore-scripts=true# .yarnrc.yml (yarn Berry) enableScripts: false -
Allowlist packages that need scripts. Some legitimate packages (native modules like
bcrypt, build tools likeesbuild) require install scripts to work. Rather than leaving all scripts enabled, keep them disabled globally and explicitly permit only the packages you have reviewed. pnpm supports this throughonlyBuiltDependenciesinpackage.json:{ "pnpm": { "onlyBuiltDependencies": ["bcrypt", "esbuild"] } } -
Never run the install command with elevated privileges. If a script requires
sudo, that is a red flag.
Version Pinning
How you declare a dependency version determines how much control you have over what gets installed. The syntax varies by ecosystem, but the concept is universal: the more flexibility you allow, the more trust you place in upstream maintainers.
Node.js (npm / pnpm / yarn)
| Strategy | Example | Risk Level | When to Use |
|---|---|---|---|
| Exact version | "1.2.3" | Lowest | Security-critical packages, wallet libraries, production dependencies |
| Patch range | "~1.2.3" | Low | General dependencies where you trust patch releases |
| Minor range | "^1.2.3" | Medium | Development dependencies, internal tools |
| Any version | "*" or "latest" | Highest | Never use this in production |
For any package that runs in a user's browser, interacts with a wallet, or handles signing, use exact versions and
review every update as a deliberate decision. For development dependencies that do not reach production, patch or minor
ranges are a reasonable tradeoff between security and maintenance overhead. "*" and "latest" have no place in a
production dependency manifest.
Important: Even with exact versions in your direct dependencies, transitive dependencies still resolve through semver ranges declared by the packages you depend on. This is why lockfile integrity matters: the lockfile is what actually pins the full tree.
Cross-Ecosystem Reference
Each ecosystem has its own version range syntax and defaults. The principles are the same: pin tightly for anything security-critical, allow ranges only where the tradeoff is justified.
| Ecosystem | How to Pin Exactly | How Flexible Ranges Work |
|---|---|---|
| Rust (Cargo) | =1.2.3 | By default, 1.2.3 allows compatible updates within the same major version. Use = for strict pinning. |
| Python (pip) | ==1.2.3 | ~=1.2.3 allows patch updates only. Omitting a version specifier accepts anything, so always specify one. |
| Go | Pinned via go.sum checksums | Go uses Minimum Version Selection (MVS): it always picks the oldest version that satisfies all requirements, not the newest. This is conservative by design but can delay security patches. |
| Java (Maven) | 1.2.3 (exact by default) | Range syntax like [1.2,1.3) is available but rarely used. Avoid LATEST and RELEASE in production. |
| Ruby (Bundler) | = 1.2.3 | ~> 1.2 is the "pessimistic" operator. It allows patch updates within 1.2.x but not 1.3.0. |
GitHub Actions SHA Pinning
GitHub Actions are themselves a supply chain dependency. When you reference an action by tag
(uses: actions/checkout@v4), the tag owner can move it to point at different code at any time. Pinning to a full
commit SHA ensures that the action you run today is the same one you reviewed:
# Instead of this (mutable tag):
- uses: actions/checkout@v4
# Use this (immutable commit SHA):
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1Trust and Verification
Lockfiles and version pinning ensure you get the code you expect, but they do not help you decide whether to trust a package in the first place. Trust and verification are about evaluating packages before they enter your dependency tree, and ensuring that the packages you receive were published by who you think they were.
Package Trust Signals
Before adding a new dependency, evaluate it. No single signal is definitive, but taken together they form a reasonable picture of risk:
- Maintainer activity: Is the package actively maintained? Are issues addressed? A package with no commits in two years and a pile of open issues may be abandoned, or it may be stable and complete. Context matters.
- Maintainer count: A package maintained by a single developer is a single point of failure. If their account is compromised, so is every project depending on their packages.
- Download trends and community adoption: Very low download counts for a supposedly popular package are a red flag. Compare against known-good alternatives.
- Scope and permissions: Does the package do what it claims and nothing more? A date-formatting library that requests network access should raise questions.
- Dependencies of the dependency: A minimal utility that itself pulls in 50 transitive dependencies introduces risk disproportionate to its value.
- Security posture: Does the project have a security policy (
SECURITY.md)? Do they use signed releases or provenance attestations? Do they assess known vulnerabilities quickly?
OIDC and Trusted Publishers
Traditional package publishing relies on long-lived API tokens stored in CI environments. If those tokens are leaked or the CI environment is compromised, an attacker can publish malicious versions of your package. OIDC-based Trusted Publishers eliminate this risk.
How it works: Instead of storing a static npm (or PyPI) access token in your CI secrets, you configure the registry to trust your specific CI workflow. When the workflow runs, the CI provider (GitHub Actions, GitLab CI) and the registry negotiate a short-lived, cryptographically signed token that is scoped to that specific workflow, repository, and environment. The token cannot be extracted or reused.
This approach was pioneered by PyPI and adopted by npm. If you maintain packages, this is one of the most impactful steps you can take:
- Configure Trusted Publishers for your registry to eliminate long-lived tokens from CI.
- Use isolated CI workflows for publishing. Separate build, verify, and publish into distinct jobs. Only the publish job should hold credentials, and those credentials should be short-lived OIDC tokens.
- Enable provenance attestation. npm's
--provenanceflag generates a signed record of how and where a package was built and published, allowing consumers to verify the package's origin. - Require 2FA for all publish actions. Prefer WebAuthn/passkeys over TOTP where supported. Enable it for the entire scope or organization, not just individual accounts.
For consumers: When evaluating a package, check whether it publishes with provenance. On npmjs.com, packages with provenance display a green checkmark linking to the specific CI workflow that built them. This is a strong trust signal: it means the package was built from a known source repository through a verifiable pipeline.
For an in-depth walkthrough of npm Trusted Publishers and secure publishing workflows, see How to npm and Avoid Getting Rekt by The Red Guild.
Typosquatting
Typosquatting attacks publish malicious packages with names deliberately close to popular ones, targeting developers who mistype a package name during installation.
- Double-check package names before installing. Verify the exact spelling, publisher, and scope (
@org/package). - Check download counts and publish history. Use
npm info <package>to inspect locally or look it up on npmjs.com. Red flags to look for are: very low download counts (for a supposedly popular package), a recent first publish date, no verified publisher, or a name that is one character off from a well-known package. - Use scoped packages when available. Scoped names (
@org/package) are harder to typosquat than unscoped ones. - Enable lockfile verification in CI to prevent unexpected package name changes.
- Consider
minimumReleaseAge(pnpm) or equivalent policies. Delaying installations of newly published versions by a configurable period gives the community time to detect malicious releases before they reach your project.
Vulnerability Scanning
Most ecosystems provide built-in or community-standard tools for checking dependencies against known vulnerability databases. Run these in CI and fail builds on high or critical findings.
| Ecosystem | Built-in / Standard Tool | Command |
|---|---|---|
| Node.js | npm audit / pnpm audit | pnpm audit --audit-level=high |
| Rust | cargo-audit | cargo audit |
| Python | pip-audit | pip-audit -r requirements.txt |
| Go | govulncheck | govulncheck ./... |
| Ruby | bundler-audit | bundle audit check --update |
| Java | OWASP Dependency-Check | Gradle/Maven plugin |
Cross-Ecosystem Tools
-
Dependabot is a GitHub built-in tool that supports npm, pip, Cargo, Go modules, Maven, Bundler, Composer, and more. It monitors your dependencies for known vulnerabilities, opens security alerts on your repository, and when a fix is available, creates a PR to bump the affected dependency. It can also open PRs for general version updates on a schedule you configure.
-
Snyk and Grype go beyond single-ecosystem advisory databases, covering transitive dependencies and scanning against multiple vulnerability sources across many languages and package managers.
-
OSV-Scanner by Google queries the OSV (Open Source Vulnerabilities) database, which aggregates advisories from multiple ecosystems into a single, consistent format.
Important: None of these methods or tools make you more secure by themselves. They make you aware of vulnerabilities and updates. The security value comes from reviewing those updates in depth, especially security patches.
Ecosystem-Specific Considerations
Smart Contract Dependencies
Smart contract dependencies carry unique risk because deployed code is immutable. Static analysis, testing strategies, and secure coding practices for Solidity are covered in depth in the Security Testing and Secure Software Development frameworks. For guidance on auditing contracts and their imported libraries, see External Security Reviews for Smart Contracts.
Common Pitfalls
- Blindly merging dependency update PRs. Always review what changed, especially across major versions. Check the changelog and release notes before merging.
- Using
*orlatestversion ranges. This hands control of your dependency tree to whoever publishes the next version. - Ignoring transitive dependencies. Your direct dependencies carry their own dependency trees, and a vulnerability three levels deep still affects your application. Vulnerability scanning tools surface these; running them in CI ensures the full tree is checked on every build.
- Installing from GitHub URLs instead of registries.
"my-package": "github:user/repo"bypasses registry integrity checks, removes version pinning, and pulls whatever is at the HEAD of a mutable branch at install time. Use published, versioned registry packages instead. - Not reviewing changelogs before updating. Major version bumps can introduce breaking changes or new dependencies. Review before updating.
- Publishing with long-lived tokens. If you maintain packages, use OIDC Trusted Publishers when possible instead of static tokens stored in CI secrets. Where OIDC is not available, rotate tokens regularly and scope them to the minimum permissions needed. A leaked token means anyone can publish as you.
- Trusting packages without verification. Download counts and GitHub stars are not security guarantees. Check provenance, maintainer history, and dependency footprint before adding a new package.
Further Reading
- npm Security Best Practices: Official npm security documentation
- How to npm and Avoid Getting Rekt by The Red Guild: Covers npm Trusted Publishers, provenance, and secure publishing workflows
- PyPI Trusted Publishers: OIDC-based publishing for PyPI (the model npm adopted)
- Dependabot Documentation: GitHub's cross-ecosystem dependency update tooling
- OSV (Open Source Vulnerabilities): Aggregated vulnerability database spanning multiple ecosystems
- Snyk Vulnerability Database: Searchable database of known vulnerabilities
- Cargo Security Advisories: Rust security advisory database and
cargo-audit - Go Vulnerability Database: Official Go vulnerability tracking and
govulncheck