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.

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.
# ❌ 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.jsonBut auditing known vulnerabilities only catches what has been reported. For zero-day supply chain attacks, you need deeper controls.
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.
{
"scripts": {
"preinstall": "npx only-allow pnpm"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild",
"sharp",
"bcrypt"
]
}
}// ❌ 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.
# .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.jsonSoftware 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.
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.
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.


