Diego Betto
Photo by Kaffeebart on Unsplash

Diego Betto · September 29, 2026 · 12 min di lettura

Software Supply Chain Security in Node.js and TypeScript: Dependency Hardening and CI/CD Pipelines

How to protect a Node.js project from supply chain attacks: install scripts, typosquatting, lockfiles, automated audits, GitHub Actions, provenance, CSP and SRI.

Condividi:XLinkedInFacebookWhatsApp

A mid-sized Node.js project easily has over a thousand packages in node_modules, written by hundreds of maintainers you’ve never met. Every one of them, at npm install time, can run arbitrary code on your machine and on your CI runner — with access to the file system, environment variables, and whatever tokens happen to be lying around. The code you write is only a small part of what ends up in production.

This isn’t a theoretical scenario. In September 2025, the account of a maintainer of packages like chalk and debug — billions of combined weekly downloads — was compromised through a phishing email, and malicious versions sat on the registry for a few hours. A few days later, the Shai-Hulud worm started spreading on its own: an install script stole the victim’s npm and GitHub tokens and used them to publish infected versions of the same maintainer’s other packages. In March of that year it was tj-actions/changed-files, a GitHub Action used by tens of thousands of repositories, modified to dump CI secrets into the logs.

No single measure protects you from everything. But a set of concrete steps drastically reduces both the chance of installing malicious code and the damage it can do if it happens.

Attack vectors at a glance

Vector How it works Main countermeasure
Install scripts preinstall/postinstall run code as soon as the package is installed Disable scripts, explicit allowlist
Typosquatting Packages with near-identical names (expres, lodahs, cross-env.js) Review new dependencies, lockfile
Account takeover The legitimate maintainer is compromised and publishes a malicious version Delay on new versions, provenance
Transitive dependencies The vulnerability sits three levels down, in a package you never chose Automated audits, continuous updates
CI/CD pipeline Compromised actions, secrets exposed to untrusted code SHA pinning, least privilege, isolated secrets
Runtime CDN An external script changes after deploy (the 2024 polyfill.io case) Subresource Integrity and CSP

Let’s go through them one by one, starting with the best cost/benefit ratio.

1. Disable install scripts

The vast majority of packages don’t need to run code on install. The ones that do are few and easy to spot: native binaries (esbuild, sharp, better-sqlite3, @tailwindcss/oxide), a handful of tools that download an executable. For everything else, a postinstall script is useless at best and, at worst, exactly the vector Shai-Hulud used.

With npm, you disable scripts globally in .npmrc:

# .npmrc
ignore-scripts=true

and re-enable them only for the packages that actually need them:

npm ci
npm rebuild esbuild sharp --ignore-scripts=false

⚠ Warning

ignore-scripts=true also disables your own package.json install lifecycle scripts (prepare, postinstall) — for example the hook that sets up Husky. Commands you run explicitly with npm run keep working normally.

pnpm goes further: since version 10 it does not run dependency install scripts by default. Packages that need them must be explicitly approved. This is the actual configuration of this blog’s repository:

# pnpm-workspace.yaml
onlyBuiltDependencies:
  - "@tailwindcss/oxide"
  - esbuild
  - sharp

When you add a package that wants to run a script, pnpm flags it and pnpm approve-builds asks you to decide. That’s the right model: denied by default, allowed by choice. Bun follows the same philosophy with trustedDependencies, Yarn Berry with enableScripts: false.

2. Lockfiles and reproducible installs

The lockfile is what guarantees that CI installs exactly the versions you tested, with the same integrity hashes. Two rules:

  • Always commit it, and treat changes to it as code: in a pull request, a lockfile that changes hundreds of lines to “add a small utility” deserves a look.
  • In CI, use the command that fails when the lockfile doesn’t match package.json, instead of silently updating it:
npm ci                          # npm
pnpm install --frozen-lockfile  # pnpm (the default when it detects CI)
yarn install --immutable        # Yarn Berry

3. Don’t install freshly published versions

Almost every account-takeover attack is detected and pulled from the registry within hours or a few days. If your project never installs a version published less than a few days ago, you effectively sidestep the whole category.

pnpm supports this natively:

# pnpm-workspace.yaml
# Minutes: 4320 = 3 days
minimumReleaseAge: 4320

Yarn has an equivalent option (npmMinimalAgeGate), Bun has minimumReleaseAge in bunfig.toml. With npm you can get a similar effect with --before, which resolves dependencies as if it were a given date. The same logic should apply to update bots — more on that below.

💬 My take

Three days is a good trade-off for almost every project. The cost is getting security patches a few days late; for critical vulnerabilities you can always update by hand and add an exception for that package (minimumReleaseAgeExclude in pnpm).

4. Before adding a dependency

The best time to stop a malicious package is before it lands in the lockfile. A few quick checks:

npm view package-name          # maintainers, publish date, repository
npm view package-name versions # a single version published yesterday? suspicious
npm view package-name scripts  # does it have a postinstall? why?

Red flags: a name very close to a popular package, a missing or mismatched GitHub repository, very few downloads but a README copied from a well-known project, install scripts that download something from a URL. Tools like Socket analyze these signals automatically on pull requests.

And the most effective question of all: do I actually need it? A ten-line function you wrote yourself has no maintainer to compromise.

5. Automated vulnerability audits

npm audit --omit=dev --audit-level=high
pnpm audit --prod --audit-level high

--omit=dev/--prod limits the check to dependencies that ship to production, and --audit-level makes the command fail only above a given severity: without these two filters the audit turns into noise the team learns to ignore.

For a second data source independent of the npm registry, Google’s OSV-Scanner uses the OSV database and reads lockfiles directly:

osv-scanner scan source -r .

And to verify that installed packages are really the ones signed by the registry — plus, where available, the provenance attestations linking them to the repository and build that produced them:

npm audit signatures

6. Automated updates, with a delay

Dependabot and Renovate keep dependencies current, but opening a PR five minutes after a compromised version is published is exactly what you don’t want. Both support a waiting period:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "weekly"
    cooldown:
      default-days: 3
    groups:
      minor-and-patch:
        update-types: ["minor", "patch"]
// renovate.json
{
  "extends": ["config:recommended"],
  "minimumReleaseAge": "3 days",
  "internalChecksFilter": "strict"
}

Grouping minor and patch bumps into a single weekly PR cuts review fatigue — which is the real reason automated updates so often get merged without a second look.

7. Lock down the CI/CD pipeline

CI is the juiciest target: it holds the tokens to publish packages, deploy, and reach the cloud. A reasonable GitHub Actions workflow:

# .github/workflows/ci.yml
name: CI
on:
  pull_request:
  push:
    branches: [main]

# Least privilege by default: no job can write to the repository
permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      # Actions pinned to a commit SHA, not a tag (tags can be moved)
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
        with:
          persist-credentials: false
      - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
        with:
          node-version: 24
          cache: npm
      - run: npm ci --ignore-scripts
      - run: npm rebuild esbuild --ignore-scripts=false
      - run: npm audit --omit=dev --audit-level=high
      - run: npm test

The key points:

  • permissions: contents: read at the top level. The default GITHUB_TOKEN may have write permissions: if a compromised dependency reads it, it can push commits or tamper with releases. Jobs that need to write declare exactly what they need.
  • SHA-pinned actions. The tj-actions/changed-files attack worked because version tags (@v45) were moved to a malicious commit. A full SHA is immutable. Dependabot can update SHAs too, keeping the version comment.
  • persist-credentials: false keeps the token from being stored in the workspace’s git config, where any later process — including a dependency’s script — could read it.
  • No secrets in the install step. Pass secrets as env only to the steps that actually use them, never at the job or workflow level.
  • Beware of pull_request_target. It runs with the target repository’s permissions and secrets: checking out and running a fork’s PR code in that context hands the keys to anyone who opens a PR.

8. Publish packages safely

If you maintain packages on npm, your publish token is what an attacker wants most. After the 2025 incidents, npm retired the old “classic” tokens and pushes trusted publishing: CI authenticates to the registry via OIDC, with a short-lived credential valid only for that run. No long-lived token to steal.

# .github/workflows/publish.yml
on:
  release:
    types: [published]

permissions:
  contents: read
  id-token: write # required for OIDC

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
      - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
        with:
          node-version: 24
          registry-url: "https://registry.npmjs.org"
      - run: npm ci --ignore-scripts
      - run: npm run build
      - run: npm publish

The link between package and workflow is configured once in the package settings on npmjs.com. With trusted publishing, npm also generates the provenance attestation automatically: anyone installing the package can verify which repository and which build produced it. Add two-factor authentication to the account, and require 2FA for manual publishing.

9. Isolate execution

Even with all of the above, assume that at some point untrusted code will run. The goal becomes limiting what it can see.

Install dependencies in a throwaway container, with no access to your home directory, SSH keys, or the ~/.npmrc holding your tokens:

docker run --rm -v "$PWD":/app -w /app node:24 npm ci --ignore-scripts

For development, a dev container (VS Code, JetBrains) gets the same effect permanently: node_modules lives inside the container, your secrets outside.

At runtime, the Node.js Permission Model lets you restrict what the process can do:

node --permission \
  --allow-fs-read=/app \
  --allow-fs-write=/app/tmp \
  dist/server.js

Without explicit flags, the process can’t write to disk, spawn child processes (--allow-child-process), create workers (--allow-worker), or load native addons.

ℹ️ Note

The Node.js docs are explicit: the Permission Model is a “seat belt” against mistakes and unexpected behavior, not a sandbox against deliberately malicious code. It adds an obstacle; it doesn’t replace container- or OS-level isolation.

10. Protect the browser runtime: SRI and CSP

The supply chain doesn’t end at build time. If your page loads scripts from a CDN, that script’s content can change after deploy without you touching a line. That’s exactly what happened in 2024 with polyfill.io: the domain changed hands and started serving malicious code to hundreds of thousands of sites.

Subresource Integrity ties the script to an exact hash of its content. If the file changes, the browser refuses to run it:

<script
  src="https://cdn.jsdelivr.net/npm/[email protected]/dist/lib.min.js"
  integrity="sha384-..."
  crossorigin="anonymous"
></script>

Generate the hash like this:

curl -s https://cdn.jsdelivr.net/npm/[email protected]/dist/lib.min.js \
  | openssl dgst -sha384 -binary | openssl base64 -A

SRI only makes sense with versioned URLs (@3.2.1, not @latest): a file that changes legitimately would break your site.

Content Security Policy completes the picture by restricting where scripts can come from and, above all, where they can send data. A strict connect-src stops a compromised package that made it into your bundle from exfiltrating data to an attacker’s domain:

Content-Security-Policy: script-src 'self' https://cdn.jsdelivr.net; connect-src 'self' https://api.example.com

I covered the setup in depth in Content Security Policy: a practical guide and in the advanced follow-up on nonces, strict-dynamic and Trusted Types.

Summary checklist

  • Install scripts disabled by default, explicit allowlist for native packages
  • Lockfile committed, npm ci / --frozen-lockfile in CI
  • Minimum age for new versions (minimumReleaseAge, Dependabot/Renovate cooldown)
  • Every new dependency reviewed before it’s added
  • npm audit and/or OSV-Scanner in CI with a severity threshold
  • npm audit signatures to verify signatures and provenance
  • GitHub Actions with minimal permissions, SHA-pinned actions, persist-credentials: false
  • Secrets passed only to the steps that use them
  • Publishing via trusted publishing (OIDC), 2FA on accounts
  • Installs in containers or dev containers
  • SRI on every external script, CSP with strict script-src and connect-src

Frequently asked questions

❓ Will --ignore-scripts break my project?

Only for packages that genuinely need an install script, usually those with native binaries (esbuild, sharp, bcrypt, sqlite). You’ll spot them because they fail at startup or build time: just add them to the allowlist (npm rebuild <package> with npm, onlyBuiltDependencies with pnpm).

❓ Is npm audit enough?

No. npm audit finds known, already-reported vulnerabilities: it won’t catch a malicious package published an hour ago, or a typosquat. It’s useful but reactive, and should sit alongside preventive measures (disabled scripts, a delay on new versions, dependency review).

❓ What is npm package provenance?

A signed attestation linking a published version to the source repository, commit, and CI workflow that produced it. It lets you verify the package was built from that code rather than uploaded by hand by someone with a stolen token. Check it with npm audit signatures.

❓ Why pin GitHub Actions by SHA instead of by version?

Because a tag like @v4 can be moved to a different commit by anyone with write access to the action’s repository — including an attacker. A full commit SHA is immutable: you always run exactly the code you reviewed.

Conclusion

Supply chain security isn’t solved by a single tool, but by a set of more cautious defaults: third-party code that doesn’t run until you allow it, versions that don’t get in until they have a few days of history, pipelines with no more permissions than they use, credentials that expire on their own. One by one they’re small changes — most are a single line of config. Together, they turn npm install from an act of faith into a controlled operation.

If you work with Node.js, keep an eye on vulnerabilities in the runtime itself too: I wrote about them in the article on the Node.js January 2026 security releases.

Condividi:XLinkedInFacebookWhatsApp
Diego Betto

Written by

Diego Betto

Co-Founder & CTO at PAPION. Senior full-stack engineer specializing in React, TypeScript, Node.js, and application security.