Command Palette

Search for a command to run...

Hectal
Lab 1.2·Supply ChainCRITICAL

Code that runs when you type npm install

Packages can run arbitrary scripts at install time — on developer laptops and CI runners full of tokens — before a single test runs.

Weakness
CWE-506 · Embedded malicious code · CICD-SEC-3
Target
sec-lab/app dependency installs, locally and in CI
lifecycle scriptsnpm query to audit scriptsignore-scripts and allowlistslockfile disciplineisolating installs from secrets

Run these techniques only against the lab you own. Using them on systems you don't have permission to test is illegal.

The threat

  1. 01How malicious packages get in

    Three routes, all real: TYPOSQUATTING (a package named one letter away from a popular one), DEPENDENCY CONFUSION (a public package with the same name as your internal one, preferred by a misconfigured resolver), and COMPROMISED MAINTAINERS. The last is the scariest: in 2018 event-stream, in 2021 ua-parser-js, and in September 2025 widely used packages including chalk and debug briefly shipped malicious versions after maintainer accounts were phished. The same month, a self-spreading npm worm dubbed 'Shai-Hulud' stole tokens from developer machines and CI and republished infected packages.

  2. 02Why install scripts are the sharp edge

    npm runs a package's preinstall, install, and postinstall scripts automatically during installation, with the full permissions and environment of whoever ran the install. On a CI runner that environment often contains registry tokens and cloud credentials. Many legitimate packages use install scripts (to compile native code), which is why they're on by default, and why they're so attractive to attackers.

What's at risk

  • Arbitrary code execution on every developer machine and CI runner that installs the package.
  • Theft of whatever secrets are in that environment, including tokens that allow publishing more malicious packages.

Detect

  1. 01Inventory which dependencies have install scripts

    npm query selects packages in the installed tree with CSS-like selectors. Most projects have only a handful of packages that genuinely need install scripts. Every other entry on this list is attack surface you're accepting for nothing.

    terminal
    $ npm query ':attr(scripts, [postinstall]), :attr(scripts, [install]), :attr(scripts, [preinstall])' | jq -r '.[] | "\(.name)@\(.version) \(.scripts.postinstall // .scripts.install // .scripts.preinstall)"'
    ── output ──
    esbuild@0.25.4 node install.js
    bcrypt@5.1.1 node-pre-gyp install --fallback-to-build
    core-js@3.41.0 node -e "try{require('./postinstall')}catch(e){}"
  2. 02Review new dependencies before they land

    Dependency changes deserve the same review as code: a PR adding a package should say why. Tools like Socket or OpenSSF's package-analysis flag behaviour such as install scripts, network access, and obfuscated code in new or updated packages. GitHub's dependency review action shows new packages and their advisories in the PR itself.

    .github/workflows/dependency-review.ymlwhole fileyaml
    on: pull_request
    permissions: { contents: read }
    jobs:
      review:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@<sha> # v4
          - uses: actions/dependency-review-action@<sha> # v4
            with:
              fail-on-severity: high

Defend

  1. 011. Don't run install scripts by default

    With ignore-scripts=true, npm installs files without executing lifecycle scripts. Packages that really need a build step are rebuilt explicitly. pnpm goes further: since v10 it doesn't run dependencies' install scripts unless they're listed in onlyBuiltDependencies, which is an allowlist you review.

    Vulnerable

    .npmrcwhole fileini
    # (empty: every dependency's install scripts run automatically)

    Hardened

    .npmrc (npm) · package.json (pnpm)whole fileini
    # .npmrc
    ignore-scripts=true
    # then, only for the packages that need it:
    #   npm rebuild esbuild bcrypt
    
    # package.json (pnpm >= 10)
    # "pnpm": { "onlyBuiltDependencies": ["esbuild", "bcrypt"] }
  2. 022. Install without secrets in the environment

    Split CI so the dependency install runs in a step (or job) that has no deploy or cloud credentials. Registry auth, if needed, is a read-only token scoped to installs. Publishing uses npm's trusted publishing via OIDC from one protected workflow, not a long-lived token sitting in every job.

    Vulnerable

    .github/workflows/ci.ymladd to fileyaml
    env:                                  # every step, including npm install, sees these
      NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
      AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_KEY }}
    steps:
      - run: npm install

    Hardened

    .github/workflows/ci.ymladd to fileyaml
    permissions: { contents: read }
    steps:
      - run: npm ci --ignore-scripts     # no secrets in this environment
      - run: npm rebuild esbuild bcrypt  # the reviewed allowlist
      - run: npm test
    # deploys happen in a separate job/environment, using OIDC
  3. 033. Lockfile discipline and a registry you control

    Commit the lockfile and use npm ci, so installs never silently pick up a new version. Configure scoped registries so internal package names can't resolve to the public registry (dependency confusion). Many organisations proxy the public registry through Artifactory, Nexus, or GitHub Packages, which lets them block known-malicious versions centrally and add a minimum-age delay before new releases are usable.

    Hardened

    .npmrcadd to fileini
    ignore-scripts=true
    @acme:registry=https://npm.pkg.github.com/     # internal scope never resolves publicly
    registry=https://artifactory.acme.internal/api/npm/npm-remote/

Verify

  1. 01Install and test with scripts disabled

    The app builds and tests pass with only the allowlisted rebuilds. If something breaks, you've discovered a dependency that genuinely needs a script. Review it and add it to the allowlist deliberately.

    terminal
    $ rm -rf node_modules && npm ci --ignore-scripts && npm rebuild esbuild bcrypt && npm test
    ── output ──
    added 214 packages in 3s
    rebuilt dependencies successfully
    2 passing

The concepts

Trust is transitive

Adding one dependency trusts its maintainers, all ITS dependencies' maintainers, their accounts' security, and their build pipelines. A single compromised account anywhere in that graph can reach you. Fewer, well-maintained dependencies; pinned versions; delayed adoption of brand-new releases; and not running their code at install time all shrink that trust.

SLSA and provenance

SLSA (Supply-chain Levels for Software Artifacts) is a framework for proving how an artifact was built: from which source, by which build system, with what inputs. npm's provenance statements (published with --provenance from CI) let consumers verify a package was built from a given repo and workflow, which makes it much harder to publish a malicious version from a stolen laptop token.

Your turn

01

How can you check whether an installed npm package was published with provenance?

02

What is dependency confusion and what single config change prevents it for npm?

Interview questions

01

How do you reduce the risk of malicious open-source packages?

0/4 · 0%