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:
parent
76f190cf78
commit
196517f38a
4 changed files with 330 additions and 24 deletions
|
|
@ -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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue