Skip to content

Supply Chain Security: Dependencies, Builds and Trust

A complete guide to securing your software supply chain: dependency auditing, lockfile integrity, build hardening, SBOM generation and artifact provenance.

4 min read
A chain of linked locks representing the software supply chain from source code through build to deployment

The Attack Surface You Inherit

Every npm install pulls in hundreds of packages you did not write, did not review, and cannot vouch for. A single compromised dependency can inject cryptominers, exfiltrate environment variables, or open a reverse shell. Supply chain attacks have become the preferred vector because they bypass every defense developers build around their own code.

Securing the supply chain means treating every external dependency, build step, and artifact as potentially hostile until proven otherwise.

Dependency Auditing and Lockfile Integrity

The lockfile is the first line of defense. It pins exact versions and records integrity hashes so that npm ci installs exactly what was committed, not whatever the registry currently serves.

shbash
# ❌ Using npm install in CI — resolves versions dynamically
npm install
 
# ✅ Using npm ci — installs exactly from lockfile
npm ci --ignore-scripts
 
# Audit dependencies for known vulnerabilities
npm audit --audit-level=high
 
# Generate a detailed report
npm audit --json > audit-report.json

But auditing known vulnerabilities only catches what has been reported. For zero-day supply chain attacks, you need deeper controls.

tstypescript
import { execSync } from "child_process";
import { readFileSync } from "fs";
import { createHash } from "crypto";
 
interface LockfileCheck {
  valid: boolean;
  issues: string[];
}
 
function verifyLockfileIntegrity(lockfilePath: string): LockfileCheck {
  const issues: string[] = [];
 
  // Verify lockfile exists and isn't empty
  const content = readFileSync(lockfilePath, "utf-8");
  if (!content.trim()) {
    return { valid: false, issues: ["Lockfile is empty"] };
  }
 
  const lockfile = JSON.parse(content);
 
  // Check that every package has an integrity hash
  for (const [name, info] of Object.entries(lockfile.packages || {})) {
    const pkg = info as { integrity?: string; resolved?: string };
    if (name === "") continue; // root package
 
    if (!pkg.integrity) {
      issues.push(`Missing integrity hash: ${name}`);
    }
 
    // Flag packages resolved from non-registry URLs
    if (
      pkg.resolved &&
      !pkg.resolved.startsWith("https://registry.npmjs.org")
    ) {
      issues.push(`Non-registry resolution: ${name} → ${pkg.resolved}`);
    }
  }
 
  return { valid: issues.length === 0, issues };
}

Restricting Install Scripts

Many supply chain attacks execute during npm install via lifecycle scripts like postinstall. Legitimate packages use these scripts for native compilation, but malicious packages use them for code execution at install time.

jsonjson
{
  "scripts": {
    "preinstall": "npx only-allow pnpm"
  },
  "pnpm": {
    "onlyBuiltDependencies": [
      "esbuild",
      "sharp",
      "bcrypt"
    ]
  }
}
tstypescript
// ❌ Running all install scripts blindly
// npm install  (runs postinstall for every package)
 
// ✅ Explicitly allowlisting packages that need install scripts
interface SecurityPolicy {
  allowedInstallScripts: string[];
  blockedPatterns: RegExp[];
  requireIntegrityHashes: boolean;
}
 
const policy: SecurityPolicy = {
  allowedInstallScripts: [
    "esbuild",
    "sharp",
    "@prisma/client",
    "bcrypt",
  ],
  blockedPatterns: [
    /eval\s*\(/,
    /child_process/,
    /https?:\/\/(?!registry\.npmjs\.org)/,
  ],
  requireIntegrityHashes: true,
};
 
function auditInstallScripts(
  packageJsonPath: string,
  policy: SecurityPolicy
): string[] {
  const violations: string[] = [];
  const pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
 
  const scriptKeys = [
    "preinstall",
    "install",
    "postinstall",
    "prepare",
  ];
 
  for (const key of scriptKeys) {
    if (pkg.scripts?.[key]) {
      violations.push(
        `Root package has ${key} script: "${pkg.scripts[key]}"`
      );
    }
  }
 
  return violations;
}

Build Pipeline Hardening

The CI/CD pipeline is a high-value target. An attacker who compromises the build can inject code into every artifact without touching the source repository.

ymlyaml
# .github/workflows/secure-build.yml
name: Secure Build
on:
  push:
    branches: [main]
 
permissions:
  contents: read
  id-token: write
 
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          persist-credentials: false
 
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          registry-url: "https://registry.npmjs.org"
 
      # Install from lockfile only
      - run: npm ci --ignore-scripts
      
      # Run only allowlisted install scripts
      - run: npx --yes allow-scripts
 
      # Verify no lockfile modifications
      - name: Check lockfile integrity
        run: |
          git diff --exit-code package-lock.json || \
            (echo "Lockfile modified during install" && exit 1)
 
      # Build with minimal environment
      - name: Build
        run: npm run build
        env:
          NODE_ENV: production
 
      # Generate SBOM
      - name: Generate SBOM
        run: npx @cyclonedx/cyclonedx-npm --output-file sbom.json
 
      # Sign artifact with Sigstore
      - name: Sign artifact
        uses: sigstore/cosign-installer@v3
      - run: cosign sign-blob --yes --bundle build.bundle sbom.json

Software Bill of Materials

An SBOM lists every component in your built artifact. When the next Log4Shell happens, an SBOM tells you in seconds whether you are affected. Without one, you are running grep across repositories hoping you find every instance.

tstypescript
interface SBOMEntry {
  name: string;
  version: string;
  license: string;
  purl: string; // Package URL standard
  hashes: Record<string, string>;
  dependencies: string[];
}
 
interface SBOM {
  bomFormat: "CycloneDX";
  specVersion: string;
  serialNumber: string;
  version: number;
  components: SBOMEntry[];
  metadata: {
    timestamp: string;
    tools: Array<{ vendor: string; name: string; version: string }>;
    component: { name: string; version: string };
  };
}
 
function analyzeSBOM(sbom: SBOM): {
  totalComponents: number;
  licenseBreakdown: Record<string, number>;
  directDeps: number;
  transitiveDeps: number;
  riskFlags: string[];
} {
  const licenseBreakdown: Record<string, number> = {};
  const riskFlags: string[] = [];
 
  for (const component of sbom.components) {
    const license = component.license || "UNKNOWN";
    licenseBreakdown[license] = (licenseBreakdown[license] || 0) + 1;
 
    if (license === "UNKNOWN") {
      riskFlags.push(`Unknown license: ${component.name}@${component.version}`);
    }
 
    // Check for known problematic licenses
    if (["AGPL-3.0", "GPL-3.0", "SSPL-1.0"].includes(license)) {
      riskFlags.push(
        `Copyleft license ${license}: ${component.name}@${component.version}`
      );
    }
  }
 
  return {
    totalComponents: sbom.components.length,
    licenseBreakdown,
    directDeps: sbom.components.filter((c) => c.dependencies.length === 0).length,
    transitiveDeps: sbom.components.filter((c) => c.dependencies.length > 0).length,
    riskFlags,
  };
}

Runtime Dependency Monitoring

Static analysis catches known issues at build time. Runtime monitoring catches behavioral anomalies—packages that suddenly start making network requests, accessing the filesystem in unexpected ways, or reading environment variables they should not need.

tstypescript
interface DependencyBehavior {
  packageName: string;
  networkRequests: string[];
  fileSystemAccess: string[];
  envVarsRead: string[];
  childProcesses: string[];
}
 
function createBehaviorPolicy(
  packageName: string,
  expectedBehavior: Partial<DependencyBehavior>
): DependencyBehavior {
  return {
    packageName,
    networkRequests: expectedBehavior.networkRequests || [],
    fileSystemAccess: expectedBehavior.fileSystemAccess || [],
    envVarsRead: expectedBehavior.envVarsRead || [],
    childProcesses: expectedBehavior.childProcesses || [],
  };
}
 
// Define expected behavior for critical dependencies
const policies: DependencyBehavior[] = [
  createBehaviorPolicy("express", {
    networkRequests: ["listen:*"],
    fileSystemAccess: [],
    envVarsRead: ["PORT", "NODE_ENV"],
    childProcesses: [],
  }),
  createBehaviorPolicy("prisma", {
    networkRequests: ["localhost:5432"],
    fileSystemAccess: ["node_modules/.prisma"],
    envVarsRead: ["DATABASE_URL"],
    childProcesses: ["prisma-engines/*"],
  }),
];
 
function detectAnomaly(
  observed: DependencyBehavior,
  policy: DependencyBehavior
): string[] {
  const anomalies: string[] = [];
 
  for (const request of observed.networkRequests) {
    if (!policy.networkRequests.some((p) => matchPattern(request, p))) {
      anomalies.push(
        `${policy.packageName}: unexpected network request to ${request}`
      );
    }
  }
 
  for (const envVar of observed.envVarsRead) {
    if (!policy.envVarsRead.includes(envVar)) {
      anomalies.push(
        `${policy.packageName}: unexpected env var access: ${envVar}`
      );
    }
  }
 
  return anomalies;
}

Key Takeaways

Software supply chain security requires defense at every stage: dependency selection, installation, build, and runtime. Lock your dependencies with integrity hashes and verify the lockfile in CI. Restrict install scripts to an explicit allowlist—most packages do not need them.

Harden the build pipeline by using npm ci, checking for lockfile mutations, generating SBOMs, and signing artifacts. An SBOM is not optional—when the next critical vulnerability drops, you need to know within minutes whether you are affected.

At runtime, monitor dependency behavior against defined policies. A package that starts making unexpected network requests or reading environment variables it never needed before is a signal worth investigating. The supply chain is only as strong as its weakest link, and with hundreds of transitive dependencies, that link is smaller than you think.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX