Securing Your AI Supply Chain: How a Trusted Library Compromised a $10 Billion Startup and What It Means for Your Stack
The Mercor/LiteLLM incident exposed a class of vulnerability most engineering teams have not yet addressed: AI-specific dependency risk. This article covers dependency pinning, SBOM generation, automated vulnerability scanning in CI/CD, and the practical controls that would have caught the attack before it reached production.
In March 2026, a threat group called TeamPCP planted credential-harvesting code inside LiteLLM, the open-source library that routes API calls between your application and AI providers like OpenAI, Anthropic, and Cohere. The code was detected and removed within hours. But during that window, any application that ran npm install or pip install litellm without integrity verification pulled down a compromised package.
Mercor, a $10 billion AI recruiting startup that provides data training services to OpenAI, Anthropic, and Meta, confirmed the compromise. Up to four terabytes of data were potentially exposed: Slack communications, source code, database records, and confidential project information for their enterprise AI customers. The Lapsus$ group later published samples.
The uncomfortable part for most teams: Mercor wrote clean application code. They passed security reviews. The failure was upstream, in a dependency they treated as trusted.
This article is about the engineering controls that sit between “we use LiteLLM in production” and “we got compromised because LiteLLM was tampered with.” Specifically, it is about how to build those controls into your CI/CD pipeline so they run automatically on every dependency update, every PR merge, and every release build.
Why Standard Dependency Security Tooling Misses This
Most teams already run npm audit or dependabot. Those tools catch known CVEs in published packages. They do not catch:
- A package version that was published, pulled, and republished with different content (the LiteLLM pattern)
- New transitive dependencies added by a framework upgrade that make unexpected outbound network calls
- A legitimate-looking update that changes behavior without triggering any CVE because it has not been reported yet
The gap is between “this version number has a known CVE” and “the bytes installed on this machine match what was originally approved.” Standard tooling answers the first question. It does not answer the second.
For AI stacks specifically, this gap matters more than in general application code. LLM frameworks sit at the intersection of your API credentials, your prompt data, and your model provider routing. A compromised package at this layer does not just exfiltrate data. It can silently alter prompts, swap model routing through an attacker-controlled proxy, or leak the system prompts you have spent months engineering.
Galileo AI’s multi-agent failure simulations found that a single compromised agent poisoned 87% of downstream decision-making within four hours. In multi-agent architectures where agents share context and pass structured state between steps, a dependency-layer compromise propagates faster than any human operator can intervene.
What Your CI/CD Pipeline Needs to Do Differently
The controls you need are not new categories of tooling. They are specific configurations and integration points that most teams have not yet added to their AI-native stacks. There are four of them:
- Exact version pinning with lockfile enforcement
- Integrity hash verification against an approved baseline
- Software Bill of Materials generation and diff on every dependency change
- OSV vulnerability scanning as a required CI gate, not an advisory
These work together. A version bump that gets past pinning gets caught by integrity verification. A new transitive dependency that gets past integrity verification gets caught by the SBOM diff. A known vulnerability that gets past all of that gets caught by OSV scanning. The goal is defense in depth at the dependency layer.
Lockfile Enforcement and Exact Version Pinning
The first control is also the easiest to get wrong. Most package.json files use caret ranges (^1.2.3), which means npm install will resolve to the latest compatible minor version. This is convenient for general packages. For AI dependencies, it means the version installed in your CI environment may not match the version installed in your developer’s environment, and neither may match what was security-reviewed.
The fix has two parts.
First, pin exact versions in package.json for every AI framework dependency:
{
"dependencies": {
"litellm": "1.32.4",
"langchain": "0.2.17",
"llamaindex": "0.5.12",
"instructor": "0.5.2",
"@langchain/core": "0.2.31"
}
}
No carets, no tildes. The version that was reviewed is the version that runs. If you want a newer version, you open a PR, update the pin, and the change goes through your normal review process.
Second, enforce that npm install never resolves differently from the lockfile. In CI:
# .github/workflows/install.yml
- name: Install dependencies
run: npm ci
# npm ci is strict: fails if package-lock.json is out of sync with package.json,
# fails if node_modules exists and does not match lockfile,
# and never modifies the lockfile. Always use this in CI, not npm install.
This is not enough on its own. npm ci verifies that the installed versions match the lockfile. It does not verify that the lockfile has not been tampered with relative to your approved baseline.
Building an Integrity Baseline for AI Dependencies
The integrity hash in package-lock.json is the SHA-512 of the package tarball, in Subresource Integrity format. This is the closest thing Node.js has to a content-addressed package identifier. If a package is republished with different content under the same version number, the hash changes.
The workflow is: approve a dependency at a specific version, record its integrity hash, and fail any CI build where the installed hash does not match the recorded baseline.
Here is a TypeScript script that implements this as a CI step:
// scripts/verify-ai-deps.ts
// Run with: npx tsx scripts/verify-ai-deps.ts
// Exit code 1 if any integrity mismatch is found.
import { readFileSync } from "fs";
import { join } from "path";
interface ApprovedDependency {
name: string;
version: string;
integrity: string; // SRI format: sha512-<base64>
approvedAt: string;
notes: string;
}
// This file lives in source control and changes only via PR review.
// When you upgrade a dependency, update this file and the lockfile in the same PR.
const APPROVED_DEPS_PATH = join(process.cwd(), "ai-deps-baseline.json");
const LOCK_PATH = join(process.cwd(), "package-lock.json");
function loadBaseline(): ApprovedDependency[] {
const raw = readFileSync(APPROVED_DEPS_PATH, "utf-8");
return JSON.parse(raw) as ApprovedDependency[];
}
interface LockPackage {
version?: string;
integrity?: string;
resolved?: string;
}
interface PackageLock {
packages?: Record<string, LockPackage>;
dependencies?: Record<string, LockPackage>;
}
function getLockEntry(
lock: PackageLock,
name: string
): LockPackage | undefined {
// package-lock.json v3 uses "packages" with "node_modules/" prefix
return (
lock.packages?.[`node_modules/${name}`] ?? lock.dependencies?.[name]
);
}
function main() {
const baseline = loadBaseline();
const lock: PackageLock = JSON.parse(readFileSync(LOCK_PATH, "utf-8"));
const failures: string[] = [];
for (const dep of baseline) {
const entry = getLockEntry(lock, dep.name);
if (!entry) {
failures.push(
`[MISSING] ${dep.name} — not found in package-lock.json. ` +
`Is it still installed?`
);
continue;
}
if (entry.version !== dep.version) {
failures.push(
`[VERSION DRIFT] ${dep.name} — baseline expects ${dep.version}, ` +
`lockfile has ${entry.version}. Update baseline or revert version.`
);
continue;
}
if (!entry.integrity) {
failures.push(
`[NO INTEGRITY] ${dep.name}@${dep.version} — no integrity hash in lockfile. ` +
`This package cannot be verified.`
);
continue;
}
if (entry.integrity !== dep.integrity) {
failures.push(
`[INTEGRITY MISMATCH] ${dep.name}@${dep.version} — ` +
`expected: ${dep.integrity.slice(0, 20)}..., ` +
`found: ${entry.integrity.slice(0, 20)}... ` +
`Package content has changed since approval. Do not ship.`
);
continue;
}
console.log(`[OK] ${dep.name}@${dep.version}`);
}
if (failures.length > 0) {
console.error("\nIntegrity verification failed:");
for (const f of failures) {
console.error(` ${f}`);
}
process.exit(1);
}
console.log(`\nAll ${baseline.length} AI dependencies verified.`);
}
main();
The ai-deps-baseline.json file lives in source control:
[
{
"name": "litellm",
"version": "1.32.4",
"integrity": "sha512-abc123...base64hash...",
"approvedAt": "2026-04-10T14:00:00Z",
"notes": "Approved after security review. Grants credentials access to AI provider APIs."
},
{
"name": "langchain",
"version": "0.2.17",
"integrity": "sha512-def456...base64hash...",
"approvedAt": "2026-04-10T14:00:00Z",
"notes": "Core orchestration layer. Full prompt and context access."
}
]
Every dependency upgrade goes through this flow: update the pin in package.json, run npm install, get the new integrity hash from package-lock.json, update ai-deps-baseline.json, open a PR. A reviewer sees both the version change and the hash change. This is the human checkpoint the Mercor attack bypassed: no reviewer looked at the integrity of what was actually installed.
SBOM Generation and Diff-Based Change Detection
A Software Bill of Materials gives you a machine-readable inventory of every package in your dependency tree. By generating one on each PR and diffing it against the previous one, you make transitive dependency changes visible without requiring a human to read npm ls output.
The CycloneDX format is the current standard for this. It has tooling across every major language and integrates with GitHub’s dependency review action.
Install the generator:
npm install --save-dev @cyclonedx/cyclonedx-npm
Generate the SBOM as part of your CI pipeline:
# .github/workflows/ai-supply-chain.yml
name: AI Supply Chain Security
on:
pull_request:
paths:
- "package.json"
- "package-lock.json"
- "ai-deps-baseline.json"
push:
branches: [main]
jobs:
verify-ai-dependencies:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Verify AI dependency integrity
run: npx tsx scripts/verify-ai-deps.ts
- name: Generate SBOM
run: |
npx @cyclonedx/cyclonedx-npm \
--output-format JSON \
--output-file sbom-current.json \
--flatten-components
- name: Upload SBOM artifact
uses: actions/upload-artifact@v4
with:
name: sbom-${{ github.sha }}
path: sbom-current.json
retention-days: 90
- name: Run OSV vulnerability scan
uses: google/osv-scanner-action@v1
with:
scan-args: |-
--lockfile=package-lock.json
--format=json
--output=osv-results.json
continue-on-error: true
- name: Enforce OSV policy
run: npx tsx scripts/enforce-osv-policy.ts
The OSV scanner checks your lockfile against Google’s Open Source Vulnerabilities database, which aggregates from GitHub Advisory Database, NVD, and project-specific advisories. It catches known CVEs that npm audit would also catch, but it runs against the actual lockfile rather than querying the npm registry, which means it works in air-gapped environments and gives you a local result you can archive.
Writing a Policy Gate That Fails the Build
Advisory-only scanning produces output nobody reads. The control that actually changes behavior is a CI step that fails the build when policy is violated. Here is a TypeScript implementation that reads the OSV scanner output and applies a policy:
// scripts/enforce-osv-policy.ts
// Policy: any CRITICAL or HIGH severity finding in an AI framework dependency
// that does not have an active exception fails the build.
import { readFileSync } from "fs";
interface OsvFinding {
package: { name: string; version: string; ecosystem: string };
vulnerabilities: Array<{
id: string;
aliases?: string[];
severity?: Array<{ type: string; score: string }>;
database_specific?: { severity?: string };
}>;
}
interface OsvOutput {
results?: Array<{
source: { path: string };
packages?: OsvFinding[];
}>;
}
interface PolicyException {
vuln_id: string;
reason: string;
expires: string; // ISO date
approved_by: string;
}
// Exceptions are reviewed and time-bounded.
// A permanent exception is a policy failure waiting to happen.
const AI_FRAMEWORK_NAMES = new Set([
"litellm",
"langchain",
"@langchain/core",
"@langchain/community",
"llamaindex",
"llama-index",
"instructor",
"openai",
"anthropic",
"@anthropic-ai/sdk",
"langsmith",
"langgraph",
]);
const EXCEPTIONS_PATH = "ai-deps-exceptions.json";
function loadExceptions(): PolicyException[] {
try {
const raw = readFileSync(EXCEPTIONS_PATH, "utf-8");
return JSON.parse(raw) as PolicyException[];
} catch {
return [];
}
}
function isExcepted(vulnId: string, exceptions: PolicyException[]): boolean {
const now = new Date();
return exceptions.some((e) => {
if (e.vuln_id !== vulnId) return false;
const expires = new Date(e.expires);
if (expires < now) {
console.warn(`Exception for ${vulnId} expired on ${e.expires}. Treating as active finding.`);
return false;
}
return true;
});
}
function getSeverity(finding: OsvFinding["vulnerabilities"][number]): string {
// OSV severity can be in database_specific or in severity array
const dbSeverity = finding.database_specific?.severity?.toUpperCase();
if (dbSeverity) return dbSeverity;
const scoreSeverity = finding.severity?.find((s) => s.type === "CVSS_V3");
if (!scoreSeverity) return "UNKNOWN";
const score = parseFloat(scoreSeverity.score);
if (score >= 9.0) return "CRITICAL";
if (score >= 7.0) return "HIGH";
if (score >= 4.0) return "MEDIUM";
return "LOW";
}
function main() {
let osvOutput: OsvOutput;
try {
osvOutput = JSON.parse(readFileSync("osv-results.json", "utf-8")) as OsvOutput;
} catch {
console.log("No OSV results file found. Skipping policy enforcement.");
process.exit(0);
}
const exceptions = loadExceptions();
const violations: string[] = [];
for (const result of osvOutput.results ?? []) {
for (const pkg of result.packages ?? []) {
const packageName = pkg.package.name.toLowerCase();
if (!AI_FRAMEWORK_NAMES.has(packageName)) continue;
for (const vuln of pkg.vulnerabilities) {
const severity = getSeverity(vuln);
if (severity !== "CRITICAL" && severity !== "HIGH") continue;
const vulnId = vuln.id;
if (isExcepted(vulnId, exceptions)) {
console.log(`[EXCEPTED] ${packageName}@${pkg.package.version} — ${vulnId} (${severity})`);
continue;
}
violations.push(
`[${severity}] ${packageName}@${pkg.package.version} — ${vulnId}`
);
}
}
}
if (violations.length > 0) {
console.error("\nPolicy violations found in AI framework dependencies:");
for (const v of violations) {
console.error(` ${v}`);
}
console.error(
"\nAdd a time-bounded exception to ai-deps-exceptions.json if you need to ship with this vulnerability."
);
process.exit(1);
}
console.log("AI framework dependency policy check passed.");
}
main();
The exception mechanism matters. Blocking critical findings with no escape hatch means engineers will disable the gate or work around it. Time-bounded exceptions with a required approved_by field create a paper trail. The expiry forces revisiting the decision rather than letting exceptions accumulate silently.
Tradeoffs
| Control | What It Catches | What It Misses | Operational Cost |
|---|---|---|---|
| Exact version pinning | Version drift, accidental upgrades during install | Republished versions under same number | Low. One-time setup, then discipline on PRs |
| Integrity hash baseline | Same-version content tampering (the LiteLLM pattern) | Runtime behavior changes in unmodified code | Medium. Must update baseline on every legitimate upgrade |
| SBOM generation and diff | New transitive deps added by upstream packages | Transitive deps that are themselves compromised without changing the dep tree | Low once automated. High value for audit records |
| OSV/CVE scanning in CI | Known vulnerabilities in direct and transitive deps | Zero-day vulnerabilities, novel attack patterns | Low. OSV scanner runs in under 30 seconds on most stacks |
| Runtime network monitoring | Unexpected outbound connections at execution time | Attacks that use only approved hostnames | High. Requires instrumentation and ongoing alert triage |
The first four controls are worth implementing as a baseline. The fifth (runtime network monitoring) is addressed in a separate article focused on runtime security for AI applications. Start with pinning and integrity verification; they have the highest signal-to-cost ratio for the specific attack pattern the Mercor incident demonstrates.
Production Considerations
Keep the baseline file small and focused. The ai-deps-baseline.json file should cover your AI framework layer specifically: LiteLLM, LangChain, LlamaIndex, Instructor, the OpenAI and Anthropic SDKs, and anything that handles prompt assembly or model provider routing. Do not try to baseline your entire node_modules. You want reviewers to actually read the changes to this file, which requires that it be short enough to review.
SBOM artifacts are evidence, not just outputs. Retain SBOM artifacts for at least 90 days in your CI system. When you get a “were you affected by X?” question from a customer or security team, the answer is in your SBOM archive: run a diff against the commit window in question and the question answers itself. This is a significant operational advantage over teams that have no artifact trail.
Dependency updates should be deliberate, not automatic. Dependabot and Renovate are useful for security patches, but for AI framework dependencies they create a risk: an automated PR that bumps LiteLLM from 1.32.4 to 1.33.0 gets merged without anyone examining what changed in the package. For AI-specific dependencies, configure Dependabot to open PRs but require manual review and a baseline file update before they can be merged. The GitHub branch protection rule for ai-deps-baseline.json requiring a specific reviewer handles this.
Pin in production container images too. If your application is deployed in Docker, your Dockerfile should reference a specific base image digest, not a floating tag, and your build step should use npm ci (not npm install). The combination of npm ci with a verified lockfile and the integrity check script gives you a reproducible, verifiable build. A floating FROM node:20 tag plus npm install means your production container is not deterministic.
Treat AI library upgrades like database migrations. You would not merge a database migration without reviewing the SQL. A LiteLLM upgrade that changes how credentials are passed, what external endpoints are contacted, or how responses are streamed is a more consequential change than most application code changes. The review bar should reflect that.
The Pattern This Addresses
The Mercor incident fits a pattern that has appeared repeatedly in general OSS supply chain attacks: SolarWinds (2020), Codecov (2021), MOVEit (2023). In each case, a trusted, widely-used tool was compromised at the distribution layer rather than the application layer. The victims’ own code was clean.
What is new in the AI layer is the attack surface value. LLM framework dependencies have access to your most sensitive operational data: API credentials for every AI provider you use, the full content of every prompt your users send, and the system prompts that encode your application’s core behavior. General application dependencies might have access to user data. AI framework dependencies have access to the intelligence layer of your product.
The controls described here are not sophisticated. Pinning exact versions, verifying integrity hashes, generating SBOMs, and running a CVE scanner in CI are all established practices in other security-sensitive contexts. The gap is that most teams have not yet applied them to their AI dependency layer specifically.
The LiteLLM attack was detected and removed within hours. But the question is not whether your security team would catch it. The question is whether your CI pipeline would have rejected the tampered package before it ever ran in production. With the controls above, the answer is yes. Without them, you are relying on someone else’s incident response timeline.
More in DevOps
How Terraform Works Internally: HCL Parsing, Dependency Graph Execution, and the Provider Protocol Behind Infrastructure as Code
A deep dive into Terraform's internal architecture covering HCL parsing, the expression evaluation engine, DAG-based dependency resolution, the gRPC provider plugin protocol, state mechanics, and the plan/apply lifecycle.
How Docker Works Internally: Namespaces, Cgroups, Union Filesystems, and the OCI Runtime Behind Every Container
A deep dive into what happens when you run docker run: Linux namespaces for process isolation, cgroups v2 for resource enforcement, overlay2 union filesystem for layered images, the OCI runtime spec and how runc spawns containers, content-addressable storage, the containerd shim architecture, and networking with veth pairs.
How Kubernetes Works: Pods, Scheduling, and the Reconciliation Loop
A deep dive into Kubernetes internals covering the control plane architecture, etcd watch mechanics, the scheduler's filter and score phases, controller manager reconciliation loops, kubelet pod assignment, the CRI and CNI plugin models, and production considerations for resource requests, pod disruption budgets, and cluster autoscaler behavior.
How Docker Works: Namespaces, Cgroups, Union Filesystems, and the Container Runtime from Build to Execution
A deep dive into Docker internals covering Linux namespace isolation, cgroup resource constraints, OverlayFS copy-on-write layer mechanics, Dockerfile build cache invalidation rules, the containerd and runc OCI runtime stack, and production considerations for image hardening and startup latency.