Skip to main content
ship it and sleep

Supply Chain Attacks: Detecting and Preventing Dependency Compromise

5 min read Chapter 12 of 66

Supply Chain Attacks

The Failure

The e-commerce platform uses an internal npm package, @acme/checkout-utils, published to a private npm registry. A researcher discovers that no package named checkout-utils (without the @acme/ scope) exists on the public npm registry. They register [email protected] on the public registry with a postinstall script that phones home.

When a developer runs npm install on a machine where the npm configuration does not properly scope @acme/ packages to the private registry, npm resolves checkout-utils from the public registry (higher version number wins). The postinstall script executes. This is dependency confusion.

In a CI environment without proper registry scoping, the same thing happens. The pipeline installs the attacker’s package, runs the postinstall script, and the attacker receives CI environment variables including the GITHUB_TOKEN.

The Mechanism

Dependency Confusion

npm (and pip, and most package managers) search multiple registries in order. If a private package name is not scoped to the private registry, the package manager might resolve it from the public registry. The attacker publishes a package with the same name and a higher version number on the public registry.

Prevention: use scoped packages (@acme/checkout-utils) and configure the scope to use the private registry exclusively:

# .npmrc in the repo root
@acme:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NPM_TOKEN}

Typosquatting

An attacker publishes lodasg (typo of lodash) on npm. A developer misspells the package name in package.json. The malicious package is installed and runs arbitrary code during installation.

Prevention: npm ci with a committed lock file catches this because lodasg will not be in the lock file. But it does not catch the initial mistake when the developer first runs npm install. Code review is the gate for catching typos in dependency names.

Compromised Maintainer Accounts

The maintainer of a popular package has their npm credentials stolen. The attacker publishes a new version with malicious code. The package signature does not change because npm does not yet enforce package provenance universally.

Prevention: dependency scanning tools (OSV Scanner, npm audit, Dependabot alerts) detect known compromises after they are reported. The window between compromise and detection is the risk. Lock files limit the exposure: the malicious version only enters the build when a developer explicitly updates the dependency.

The Implementation

Registry Scoping

# .npmrc committed to each service repo
# HARDENED: Scope internal packages to private registry
@acme:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NPM_TOKEN}
always-auth=true
ignore-scripts=true

The ignore-scripts=true line prevents postinstall scripts from running during npm ci. If a dependency needs a postinstall script (native module compilation), add it to an explicit allow list:

{
  "scripts": {
    "preinstall": "npx only-allow npm"
  },
  "allowScripts": {
    "bcrypt": true,
    "sharp": true
  }
}

OSV Scanner in the Pipeline

# HARDENED: Cross-ecosystem vulnerability scanning
jobs:
  supply-chain-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: OSV Scanner
        uses: google/osv-scanner-action/osv-scanner-action@v2
        with:
          scan-args: |-
            --lockfile=package-lock.json
            --format=json
            --output=osv-results.json
            .

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

      - name: Check for critical findings
        run: |
          criticals=$(jq '[.results[].packages[].vulnerabilities[] | select(.database_specific.severity == "CRITICAL")] | length' osv-results.json)
          if [ "$criticals" -gt 0 ]; then
            echo "::error::Found $criticals critical vulnerabilities"
            jq '.results[].packages[].vulnerabilities[] | select(.database_specific.severity == "CRITICAL") | .id' osv-results.json
            exit 1
          fi

OWASP Dependency-Check for Java Services

# HARDENED: OWASP Dependency-Check for the inventory service (Java)
jobs:
  dependency-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: OWASP Dependency-Check
        uses: dependency-check/Dependency-Check_Action@main
        with:
          project: inventory-service
          path: .
          format: JSON
          args: >-
            --failOnCVSS 7
            --suppression dependency-check-suppression.xml

      - name: Upload report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: dependency-check-report
          path: reports/

The --failOnCVSS 7 flag fails the scan when any dependency has a CVE with a CVSS score of 7.0 or higher. The --suppression file works like Trivy’s ignore file: documented exceptions with justifications.

The Gate

Three gates protect against supply chain attacks:

  1. Registry scoping prevents dependency confusion at the npm install level.
  2. ignore-scripts=true prevents malicious postinstall scripts from executing during the build.
  3. OSV Scanner / Dependency-Check detects known vulnerabilities in resolved dependencies.

Gates 1 and 2 are preventive. Gate 3 is detective. Together, they reduce the attack surface to the window between a compromise and its discovery in vulnerability databases.

The Recovery

When a dependency compromise is detected:

Immediate (within 1 hour):

  1. Pin to the last known good version in the lock file.
  2. Rotate all secrets that were available to CI runs during the exposure window.
  3. Check CI logs for signs of exfiltration: unexpected network calls, artifact modifications, API requests to unknown endpoints.

Short-term (within 24 hours): 4. Audit all builds that ran during the exposure window using the GitHub Actions API:

# Find all workflow runs in the exposure window
gh run list --repo acme/checkout-service \
  --created "2026-05-20..2026-05-21" \
  --json databaseId,conclusion,createdAt
  1. Check if compromised images were deployed to any environment. If yes, roll back using the GitOps revert (CH1-S1).
  2. Report the compromise to the package registry.

Long-term (within 1 week): 7. Review all dependency update PRs from the past 30 days for suspicious patterns. 8. Consider whether the dependency can be replaced with a smaller, better-maintained alternative. 9. Add the package to an internal watch list for future auditing.