fix(llm-security): supply-chain gate bypasses — npm/yarn blocklist + pip-audit (#14-#19,#48)

#18 dep-auditor called 'pip audit' (no such subcommand) so Python CVE detection was a permanent silent no-op; now spawns 'pip-audit' with an argv array. #19 it audited the scanner HOST's env with results mislabelled to the target's requirements.txt; now audits the target via -r and skips cleanly when absent.

#14 the offline npm blocklist was skipped for bare/range/tag installs because it used the (null) parsed-spec version; now re-checks the resolved version before the OSV network path. #15 non-hoisted nested lockfile keys derived the wrong package name (leading-only node_modules/ strip); now strips to the last segment. #48 lockfileVersion-1 nested dependency trees are now walked recursively.

#16/#17 the yarn.lock matcher paired two unassociated whole-file substrings with an unanchored pkg@ (false BLOCK of a legit package) and only matched Yarn Classic quoted versions (Berry known-malware allowed); rewritten as a per-entry parser that associates version to its own entry, anchors the name, and matches both Classic and Berry. Suite 1931/0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
This commit is contained in:
Kjell Tore Guttormsen 2026-07-18 10:14:51 +02:00
commit 196517f38a
4 changed files with 330 additions and 24 deletions

View file

@ -152,6 +152,18 @@ async function checkNpm() {
const resolvedVersion = meta.version;
// Bare/range/tag installs (`npm i axios`) parse no version, so the blocklist
// check above ran with version=null and no-oped for version-pinned entries.
// Re-check against the registry-resolved version so the offline blocklist
// still applies before any network-dependent checks.
if (isCompromised(NPM_COMPROMISED, name, resolvedVersion)) {
blocks.push(
`COMPROMISED: ${name}@${resolvedVersion} (registry-resolved)\n` +
` Known supply chain attack. See: https://socket.dev/npm/package/${name}`
);
continue;
}
// --- Advisory check (OSV.dev) — catches compromised established packages ---
const advisories = await queryOSV('npm', name, resolvedVersion);
if (advisories.critical.length > 0) {
@ -281,11 +293,28 @@ function scanNpmLockfile() {
if (existsSync(lockPath)) {
try {
const lock = JSON.parse(readFileSync(lockPath, 'utf-8'));
for (const [key, info] of Object.entries(lock.packages || lock.dependencies || {})) {
const name = key.replace(/^node_modules\//, '');
if (name && isCompromised(NPM_COMPROMISED, name, info.version)) {
findings.push({ name, version: info.version, source: 'package-lock.json' });
if (lock.packages) {
// lockfileVersion 2/3: flat map keyed by install path. Non-hoisted
// copies nest ("node_modules/a/node_modules/evil") — strip to the
// LAST node_modules/ segment to recover the package name.
for (const [key, info] of Object.entries(lock.packages)) {
const name = key.replace(/^.*node_modules\//, '');
if (name && isCompromised(NPM_COMPROMISED, name, info.version)) {
findings.push({ name, version: info.version, source: 'package-lock.json' });
}
}
} else if (lock.dependencies) {
// lockfileVersion 1: dependencies is a nested tree — recurse so a
// non-hoisted nested copy of a blocklisted package is still found.
const walk = (deps) => {
for (const [name, info] of Object.entries(deps)) {
if (info && isCompromised(NPM_COMPROMISED, name, info.version)) {
findings.push({ name, version: info.version, source: 'package-lock.json' });
}
if (info && info.dependencies) walk(info.dependencies);
}
};
walk(lock.dependencies);
}
} catch { /* ignore */ }
}
@ -294,10 +323,28 @@ function scanNpmLockfile() {
if (existsSync(yarnLock)) {
try {
const content = readFileSync(yarnLock, 'utf-8');
for (const [pkg, versions] of Object.entries(NPM_COMPROMISED)) {
for (const v of versions) {
if (v === '*' ? content.includes(`${pkg}@`) : content.includes(`version "${v}"`) && content.includes(`${pkg}@`)) {
findings.push({ name: pkg, version: v === '*' ? '(any)' : v, source: 'yarn.lock' });
// Parse per entry: an entry starts at column 0 with one or more specs
// ending in ':' ("pkg@range" / Berry '"pkg@npm:range"'), followed by an
// indented body carrying `version "x"` (Classic v1) or `version: x`
// (Berry v2+). The version is associated with ITS OWN entry, and the
// package name is matched exactly (no unanchored substring matching).
for (const entry of content.split(/\n(?=\S)/)) {
const nl = entry.indexOf('\n');
if (nl === -1) continue;
const header = entry.slice(0, nl).trim();
if (!header.endsWith(':') || header.startsWith('#')) continue;
const vMatch = entry.match(/^\s+version:?\s+"?([^"\s]+)"?\s*$/m);
const entryVersion = vMatch ? vMatch[1] : null;
const names = new Set(header.slice(0, -1).split(',').map(part => {
const spec = part.trim().replace(/^"|"$/g, '');
const at = spec.indexOf('@', spec.startsWith('@') ? 1 : 0);
return at > 0 ? spec.slice(0, at) : spec;
}));
for (const name of names) {
for (const v of NPM_COMPROMISED[name] || []) {
if (v === '*' || v === entryVersion) {
findings.push({ name, version: v === '*' ? '(any)' : v, source: 'yarn.lock' });
}
}
}
}