Best practices for protecting your software supply chain
Software supply chain attacks are no longer occasional cautionary tales, but a significant and realistic threat to software projects of all types and sizes. Software producers and consumers alike are increasingly willing to trade control for convenience, which expands the attack surface and opens up new possibilities for malicious actors to infiltrate systems and access business-critical data.
Specifically, open source software (OSS) packages, while extremely convenient to use as dependencies in software development, pose a security risk for multiple reasons. The most obvious one is the sheer amount of OSS packages available in different software communities and repositories. The open source ecosystem is constantly expanding, which makes it challenging for developers to keep track of packages and versions that are safe to use.
Attackers are aware of this. They exploit it by publishing malicious packages with similar names to popular ones, and by infiltrating legitimate OSS projects to compromise their code, among other tactics. That is why implementing a defensive governance policy is crucial for protecting your software supply chain. In practice, this means there should be no implicit, by-default trust in external software packages at any point in the software development lifecycle.
This guide provides basic advice for remediating and preventing software supply chain attacks in the following scopes:
Because npm is the largest and most popular OSS package repository, examples in the guide are focused on npm-compatible package managers (npm, pnpm, Bun).
What is software governance?โ
The concept of software governance broadly refers to decisions made by software producers and consumers on which software projects, packages, and versions to trust, and to what extent. This covers cases like adding new OSS dependencies to software during development, or installing an application on workstations in an enterprise environment.
To improve their chances of preventing a software supply chain attack, development teams should have a governance policy clearly defined as a set of rules. Those rules should be consistently enforced, and ideally automated through configuration files and checks in the development environment, version control system, and CI workflows. The two main outputs of a governance policy are the list of software packages that are trusted and approved to execute at install time, and the record of software packages that were actually installed at build time.
Generally, a software governance policy should:
Prevent implicit trust in broad package version ranges when adding third-party dependencies. Instead, it's recommended to specify a single package version, preferably one without high-risk vulnerabilities or any other security issues.
Define minimum release age for software packages to be included in the project as dependencies. This introduces a mandatory waiting period on the developer side while giving automated scanners, the community, and the registry enough time to detect malicious activity in a package. Scenarios where a package must be installed the same day when it is published are rare, and usually involve emergency security patches. Such exceptions to the policy should always be manually approved and documented.
Require SBOM generation as an audit trail that provides visibility into what your software contains at any given commit.
Source codeโ
In the context of source code, common risks include:
- Merging unreviewed code. If service accounts are given privileges to approve and merge code changes without human review, attackers who compromise such accounts can bypass code repository guardrails to inject malicious changes.
- Installing unsafe IDE extensions. Extensions usually have access to all files the developer can modify, which makes it possible for a malicious extension to exfiltrate code and other sensitive data.
- Leaking secrets. Sensitive information, such as various account credentials, may inadvertently remain hardcoded in configuration files or in source code. The risk is two-fold: attackers may gain access to a repository where those secrets are committed, or the secrets may end up committed to a public repository visible to anyone.
To protect your source code:
Follow version control best practices. Require signed commits, protect sensitive branches, and don't allow merging into protected branches without human review.
Enforce a policy for rotating credentials. Set up tools that scan source code for common secret patterns as pre-commit hooks.
Maintain an approved list of IDE extensions. When installing new development tools, require code signing and hash verification.
For npm-based projects:
Before adding any new software package, verify that it exists, has credible download numbers, and is not newly created.
Be cautious with packages:
- created in the last 30 days
- with fewer than 100 weekly downloads
- that have only one maintainer
- without links to a source repository
- with the name closely resembling another popular package
# Check package creation date and metadata
npm view <package-name> time.created --json
npm view <package-name> description maintainers dist-tags
Add lockfile diff review as a required step to your pull request (PR) policy.
Any PR that modifies a lockfile without a corresponding change to package.json should be immediately considered suspicious.
Third-party dependenciesโ
In the context of third-party software dependencies, common risks include:
- Dependency confusion and typosquatting. Malicious actors may publish packages with very similar names to popular packages in a community to take advantage of careless or inexperienced users.
- Transitive dependency compromise. As verifying a dependency chain can be time-consuming, malicious changes in transitive dependencies may go unnoticed for a long time, especially if attackers manage to produce software packages that look legitimate enough.
- Unmaintained dependencies with unpatched, actively exploited vulnerabilities. In some cases, software remains pinned to outdated or vulnerable dependencies because of high effort required to migrate to newer versions.
To ensure third-party dependencies are safe to use:
Monitor dependencies against known vulnerability databases to identify which ones have published CVEs and determine their status.
Pin dependencies to exact version numbers. One way to achieve this is with lockfiles, which can be committed to version control for improved visibility.
Configure package managers to install packages only from a private (internal) registry instead of public sources.
For npm-based projects:
Regularly audit your software project for unused dependencies that are safe to remove altogether. This can be done with tools like depcheck or knip.
Freeze the lockfile so that npm install, pnpm install, and bun install cannot resolve latest versions automatically or install anything that is not present in the lockfile.
Another approach is to disable lifecycle scripts globally and create an explicit allowlist of packages that genuinely require them.
Note that most script-blocking configurations may not cover non-standard paths like binding.gyp, so you may need additional configuration to detect its presence in packages without a legitimate reason to compile native code.
Prevent resolving package versions published less than N time units ago.
Note that package managers implement this differently, and require specifying package age in different time units (npm in days, pnpm in minutes, and bun in seconds).
If your software project uses any dependency-update bots (such as Dependabot or Renovate), you have to modify their configuration to match package manager settings because they evaluate package age independently.
Because npm audit cannot detect transitive dependencies resolved from non-standard sources, it's recommended to prevent resolving them from any source other than a configured registry.
Note that this setting is only supported in pnpm and limited to transitive dependencies.
This means that top-level package.json entries can still reference various sources.
Starting with pnpm 11, this setting is enabled by default.
Prefer scoped packages from your private registry and block any public package with the same name as an internal one.
- npm
- pnpm
- Bun
# Disable lifecycle scripts
ignore-scripts=true
# Prevent resolving package versions published less than 7 days ago
min-release-age=7
# Scope internal packages and point them to your private registry
@myorg:registry=https://registry.internal.example.com
always-auth=true
# Use `npm ci` instead of `npm install` to freeze the lockfile
# Disable lifecycle scripts - not required if they are already disabled in .npmrc
npm ci --ignore-scripts
# Prevent resolving package versions published less than 7 days ago
minimumReleaseAge: 10080
minimumReleaseAgeStrict: true
minimumReleaseAgeExclude:
- "@myorg/*"
- "@types/*"
# Lifecycle script allowlist
allowBuilds:
esbuild: true
sharp: true
"@swc/core": true
bcrypt: true
electron: true
# Prevent non-standard sources for transitive dependencies
blockExoticSubdeps: true
# Scope internal packages and point them to your private registry
registries:
"@myorg": "https://registry.internal.example.com"
# Freeze the lockfile
pnpm install --frozen-lockfile
[install]
# Prevent resolving package versions published less than 7 days ago
minimumReleaseAge = 604800
minimumReleaseAgeExcludes = [
"@types/bun",
"@myorg/internal-lib"
]
# Lifecycle script allowlist
trustedDependencies = [
"esbuild",
"sharp",
"@swc/core",
"bcrypt"
]
# Scope internal packages and point them to your private registry
[install.scopes]
"@myorg" = { token = "$NPM_TOKEN", url = "https://registry.internal.example.com" }
# Freeze the lockfile
bun install --frozen-lockfile
Build processโ
In the context of the software build process, common risks include:
- Compromised build system plugins.
- Unsigned or unverified downloads during the build process.
- Cache poisoning.
To protect your software builds:
Isolate build environments, use ephemeral runners, or eliminate non-deterministic elements (such as build timestamps and embedded absolute paths) to achieve reproducible builds.
Enforce the principle of least privilege for CI/CD credentials. The process running npm install should not have access to cloud credentials, npm publish tokens or repository write access in its scope.
Store all pipeline configurations in version control alongside source code and according to the same change management controls.
For npm-based projects:
Detect which software package versions are published without Sigstore/OIDC provenance attestation.
- npm
- pnpm
npm audit signatures
# Detect credential compromise via provenance downgrade
trustPolicy: no-downgrade
trustPolicyExclude:
# Specify packages with legitimate reasons for missing provenance
- "legacy-internal-tool@2.1.0"
pnpm audit signatures
Release artifactsโ
In the context of software release artifacts, common risks include:
- Attackers taking over accounts to publish malicious versions of software packages.
- Insufficient SBOM verification resulting in undeclared components.
- Missing signatures for published artifacts.
To protect your release artifacts:
Store the SBOM alongside the published artifact. Use SBOM generation tools that operate on the final artifact, and compare (diff) the resulting SBOM against the declared dependencies to identify any missing items.
Sign all published artifacts with a cryptographic key controlled by the build system, and require all software consumers to verify the signatures on their end.
Apply strict access rules to the artifact registry. Specifically, push access and delete/overwrite actions should be limited and require elevated privileges.
For npm-based projects:
Publish tokens should be granular and scoped to a single package name. By default, npm also supports OIDC-based trusted publishing, which allows publishing directly from CI/CD using workflow-specific credentials instead of long-lived npm tokens. In this case, npm automatically generates and publishes provenance attestations.
Two-factor authentication should be required for publishing packages at the organization level.
The publishConfig element in the package.json file should have the setting "access": "restricted" on private scoped packages to prevent them from being accidentally published as public.
Remediation stepsโ
Even with preventive measures and policies in place, your project or development team may still fall victim to a software supply chain attack. If that happens, the following steps and best practices may be helpful in assessing, containing, and repairing any damage.
- Find all instances in which the malicious package appears in your software project. Check files such as
package-lock.json,pnpm-lock.yaml, andyarn.lock. As many attacks spread through indirect dependencies, it's important to check the full dependency tree, not just the direct dependencies. If available, check SBOMs, artifact manifests and CI logs as well.
# Find out which package version is installed
npm list malicious-package --all
pnpm list malicious-package --depth=Infinity
bun pm ls | grep malicious-package
# Check the lockfile for the compromised version
grep "malicious-package" package-lock.json
grep "malicious-package" pnpm-lock.yaml
grep "malicious-package" bun.lock
- Determine which environments ran install scripts during the exposure window. If any of the builds included the malicious package version, treat all credentials available in that environment as compromised and rotate them immediately.
# Check all active tokens to audit
npm token list
# Revoke and recreate npm tokens
npm token revoke <token-id>
npm token create --read-only
If using private proxy registries and caches, check if the malicious package version is present there. Find out if it has been served to other teams in your organization.
In case the malicious package was installed anywhere, the affected system must be isolated as soon as possible. Export all relevant logs from the system before performing any cleanup. On CI/CD systems, suspend all workflows in progress and check if any artifacts have been produced after the malicious package was installed.
Remove the malicious package version from projects, lockfiles, and package caches. Pin the affected package to a previous version that is confirmed as safe. Perform clean reinstalls to ensure the safe package version is used everywhere.
- npm
- pnpm
- Bun
# Pin the package version
npm install safe-package@1.1.0 --save-exact
# Confirm the lockfile contains the safe version
cat package-lock.json | grep -A3 '"safe-package"'
# Rebuild with frozen lockfile
rm -rf node_modules
npm ci
# Pin the package version in pnpm-workspace.yaml
overrides:
safe-package: "1.1.0"
# Confirm that the store doesn't contain the malicious version
pnpm store status
# Rebuild with frozen lockfile
pnpm install --frozen-lockfile
# Pin the package version
bun add safe-package@1.1.0 --exact
# Confirm the lockfile contains the safe version
grep -A5 "safe-package" bun.lock
# Rebuild with frozen lockfile
rm -rf node_modules
bun install --frozen-lockfile