Togoder security

Guide

Scan your lockfile in CI

CI is where dependency changes become production. It is also where an install-time payload can steal the most valuable secrets you have. This guide shows how to harden dependency installation in CI and add an automated malware scan that runs whenever the lockfile changes.

CI/CD6 min readUpdated By Togoder Security

Key takeaways

  • Scanning the lockfile in CI catches malicious dependencies at the moment they are introduced, before they reach production.
  • npm ci installs exactly what package-lock.json specifies and fails if package.json and the lockfile disagree.
  • lockfile-lint can verify that every resolved URL in a lockfile points to an allowed registry over HTTPS, which blocks lockfile injection.
  • Running installs with --ignore-scripts and without secrets in the environment limits what an install-time payload can steal.
  • Triggering scans only when lockfiles change keeps CI fast and limits scan cost to new or updated packages.

A developer adds a dependency, or an automated PR bumps one. The lockfile changes. CI installs it, runs tests, builds artifacts, and often has access to deploy keys and registry tokens while doing so. If the new version is malicious, CI is both the first place it runs and the place it does the most damage. The good news: the lockfile diff is a precise, machine-readable record of exactly what changed, which makes CI the ideal place to check.

Step 1: Harden the install itself

Always use npm ci

npm ci --ignore-scripts

npm ci deletes node_modules, installs exactly the versions and integrity hashes in package-lock.json, and fails if the lockfile is missing or out of sync with package.json. It never rewrites the lockfile. The equivalents are pnpm install --frozen-lockfile, yarn install --immutable (Yarn Berry) and bun install --frozen-lockfile. Never run plain npm install in CI; it can resolve new versions within your semver ranges.

Disable install scripts, then allow what you need

--ignore-scripts stops preinstall and postinstall payloads. If a few packages need native builds, rebuild only those after install: npm rebuild esbuild sharp. pnpm 10 and Bun already block dependency scripts unless allow-listed. Details in npm install scripts security.

Keep secrets out of the install step

Do not set NPM_TOKEN, cloud credentials or deploy keys at the job level. Scope them to the single step that needs them. In GitHub Actions, set permissions: to the minimum (often contents: read) so the automatic GITHUB_TOKEN cannot push code or publish packages. The Shai-Hulud worm spread precisely by harvesting tokens available to install-time code.

Lint the lockfile

A malicious PR can edit package-lock.json directly so that a familiar package name resolves to a tarball on an attacker's server. Reviewers rarely read lockfile diffs. lockfile-lint catches this:

npx lockfile-lint \
  --path package-lock.json \
  --type npm \
  --allowed-hosts npm \
  --validate-https \
  --validate-integrity

Add your private registry host to --allowed-hosts if you use one.

Pin and delay

  • Commit lockfiles for applications. Consider save-exact=true in .npmrc so new dependencies are added without ^.
  • Pin GitHub Actions to full commit SHAs rather than tags, since tags can be moved.
  • Configure a minimum release age in Dependabot or Renovate so update PRs only propose versions that have been public for a few days. Most malicious versions are removed well within that time.

Step 2: Check known advisories

Cheap, fast and worth running on every PR:

npm audit --audit-level=high --omit=dev
# or, for multi-ecosystem repos
osv-scanner -r .

These catch known CVEs and already-reported malicious packages. They will not catch a version published this morning. See npm audit vs source code scanning for why.

Step 3: Scan the source of new dependencies

For unreported malware you need something that reads the code. Below is a GitHub Actions workflow that sends the lockfile to the Togoder Security API whenever a lockfile changes. It uses an API key from a monthly plan, stored as a repository secret. With a valid key the server skips the x402 payment step and returns the scan results as JSON; the full request and response format is in the API docs.

name: dependency-malware-scan

on:
  pull_request:
    paths:
      - "**/package-lock.json"
      - "**/pnpm-lock.yaml"
      - "**/yarn.lock"
      - "**/requirements*.txt"
      - "**/poetry.lock"
      - "**/Cargo.lock"
      - "**/go.sum"
      - "**/Gemfile.lock"
      - "**/composer.lock"

permissions:
  contents: read

jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4   # pin to a full commit SHA in production

      - name: Lint lockfile hosts
        run: npx --yes lockfile-lint --path package-lock.json --type npm --allowed-hosts npm --validate-https

      - name: Scan lockfile for malicious packages
        env:
          TOGODER_API_KEY: ${{ secrets.TOGODER_API_KEY }}
          SCAN_URL: https://security.togoder.click/api/paid-scan
        run: |
          set -euo pipefail
          curl --fail-with-body -sS \
            -H "Authorization: Bearer $TOGODER_API_KEY" \
            -F "file=@package-lock.json" \
            "$SCAN_URL" > scan-result.json
          cat scan-result.json

      - name: Upload scan report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: dependency-scan
          path: scan-result.json

Notes on the design:

  • Path filters mean the job only runs when a lockfile changes, so ordinary code PRs pay nothing.
  • No install happens in this job. The scan works from the lockfile; the service downloads the published artifacts itself, so no dependency code runs on your runner.
  • Cost scales with change. Files are cached by SHA-256 across all users, so packages anyone has scanned before are free, and in practice a lockfile bump only pays for genuinely new files. Parsing and quotes are free if you want to check the price before scanning.
  • Long scans may be asynchronous. Large lockfiles can take a while; if the API returns a job ID, poll the job status endpoint described in the API docs until it completes.

For AI agents and pay-as-you-go use without an account, the same scan is available via the x402 payment protocol: a request to POST /api/paid-scan returns HTTP 402 with a USDC price on Base, and an x402-capable client pays and retries automatically.

Failing the build

Decide what should block a merge. A reasonable policy:

  • Block on packages flagged as malicious with high confidence, and on any match in a known-malware advisory.
  • Warn (comment on the PR) on suspicious findings, so a human reads the flagged file before merging.
  • Never auto-merge a dependency update that has unresolved findings.

Parse the result with jq and exit non-zero when the policy is violated. The response fields are documented in /api-docs. AI review produces some false positives, especially on packages that legitimately download binaries, so keep a small reviewed allow-list instead of disabling the check.

Scanning only what changed

For large monorepos, you may want to scan only the delta. Get the lockfile from the base branch and compare:

git fetch origin "$GITHUB_BASE_REF" --depth=1
git show "origin/$GITHUB_BASE_REF:package-lock.json" > base-lock.json

# list name@version entries that are new in this PR
jq -r '.packages | to_entries[] | select(.key != "")
  | "\(.key | sub(".*node_modules/"; ""))@\(.value.version)"' base-lock.json | sort -u > base.txt
jq -r '.packages | to_entries[] | select(.key != "")
  | "\(.key | sub(".*node_modules/"; ""))@\(.value.version)"' package-lock.json | sort -u > head.txt
comm -13 base.txt head.txt > added.txt
wc -l added.txt

Print added.txt in the job summary so reviewers can see exactly which packages are new. With hash-based caching, sending the full lockfile costs about the same as sending only the delta, so the diff is mostly useful for human review and for tools without caching.

Other ecosystems

  • Python: pip install --require-hashes --only-binary :all: -r requirements.txt; lock with pip-tools, Poetry or uv.
  • Rust: commit Cargo.lock, build with cargo build --locked, and run cargo-deny for advisories and allowed sources.
  • Go: go mod verify checks downloaded modules against go.sum; leave GOSUMDB and GOFLAGS=-mod=readonly at safe defaults.
  • Ruby / PHP: bundle install --frozen (or BUNDLE_FROZEN=true), and composer install with a committed composer.lock and an explicit allow-plugins list.

CI hardening checklist

  1. Frozen installs only: npm ci, --frozen-lockfile, --immutable, --locked.
  2. Install scripts disabled, with a reviewed allow-list.
  3. No secrets in the install step; minimal GITHUB_TOKEN permissions.
  4. lockfile-lint for registry hosts, HTTPS and integrity.
  5. Actions pinned to commit SHAs.
  6. Advisory scan on every PR.
  7. Source-level malware scan whenever a lockfile changes.
  8. Minimum release age for automated updates.

To see what a source-level report looks like before wiring anything up, browse public reports like express or next, or upload a lockfile at /scan for a free parse and price quote. The methodology page describes what the review covers and where it can be wrong.

Frequently asked questions

Should I run npm audit or a malware scan in CI?

Both. npm audit catches known vulnerabilities and already-reported malware cheaply on every PR, while a source-level scan catches malicious versions that have not been reported yet.

What is the difference between npm ci and npm install?

npm ci installs exactly what the lockfile specifies, fails if it disagrees with package.json, and never modifies the lockfile. npm install can resolve newer versions within semver ranges and rewrite the lockfile.

Do I need to install dependencies to scan them?

No. Scanning from the lockfile lets the scanner fetch the published packages itself, so no dependency code runs on your CI runner.

How do I avoid scanning on every commit?

Use path filters so the scan job only runs when a lockfile changes. With hash-based caching, previously scanned files cost nothing anyway.

What is lockfile-lint for?

lockfile-lint checks that every resolved package URL in a lockfile points to an allowed registry over HTTPS, which detects lockfile injection where a PR silently redirects a package to an attacker-controlled tarball.