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
|
|
@ -9,8 +9,10 @@
|
|||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { resolve } from 'node:path';
|
||||
import { runHook } from './hook-helper.mjs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { runHook, runHookWithEnv } from './hook-helper.mjs';
|
||||
|
||||
const SCRIPT = resolve(import.meta.dirname, '../../hooks/scripts/pre-install-supply-chain.mjs');
|
||||
|
||||
|
|
@ -206,3 +208,167 @@ describe('pre-install-supply-chain — E13 scope-hopping (checkScopeHop)', () =>
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// v7.8.3 supply-chain gate fixes (#14, #15, #48, #16, #17)
|
||||
// All deterministic + offline: an `npm` shim on PATH answers `npm view`
|
||||
// (fixed registry metadata) and `npm audit` (empty JSON), so no network
|
||||
// call is ever needed. CLAUDE_WORKING_DIR points at a per-test tmp project.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Create a PATH shim dir whose `npm` answers view/audit deterministically. */
|
||||
function makeNpmShim(viewJson) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'llmsec-supply-npmshim-'));
|
||||
writeFileSync(join(dir, 'npm'), [
|
||||
'#!/bin/sh',
|
||||
'if [ "$1" = "view" ]; then',
|
||||
` echo '${viewJson}'`,
|
||||
'else',
|
||||
" echo '{}'",
|
||||
'fi',
|
||||
'',
|
||||
].join('\n'), { mode: 0o755 });
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe('pre-install-supply-chain — v7.8.3 gate fixes', () => {
|
||||
it('#14 blocks bare `npm install event-stream` when the registry resolves to compromised 3.3.6', async () => {
|
||||
// parseSpec yields version=null for a bare install, so the blocklist must
|
||||
// be re-checked against the registry-resolved version (meta.version).
|
||||
const shimDir = makeNpmShim('{"name":"event-stream","version":"3.3.6"}');
|
||||
try {
|
||||
const result = await runHookWithEnv(SCRIPT, bashPayload('npm install event-stream'), {
|
||||
PATH: `${shimDir}:${process.env.PATH}`,
|
||||
});
|
||||
assert.equal(result.code, 2, `Expected exit 2, got ${result.code}. stderr: ${result.stderr}`);
|
||||
assert.match(result.stderr, /COMPROMISED: event-stream@3\.3\.6/);
|
||||
} finally {
|
||||
rmSync(shimDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('#15 blocks bare install when lockfileVersion 3 holds a non-hoisted nested compromised copy', async () => {
|
||||
const shimDir = makeNpmShim('{}');
|
||||
const projDir = mkdtempSync(join(tmpdir(), 'llmsec-supply-proj-'));
|
||||
writeFileSync(join(projDir, 'package-lock.json'), JSON.stringify({
|
||||
name: 'sample', lockfileVersion: 3, packages: {
|
||||
'': { name: 'sample' },
|
||||
'node_modules/a': { version: '1.0.0' },
|
||||
'node_modules/a/node_modules/event-stream': { version: '3.3.6' },
|
||||
},
|
||||
}));
|
||||
try {
|
||||
const result = await runHookWithEnv(SCRIPT, bashPayload('npm install'), {
|
||||
PATH: `${shimDir}:${process.env.PATH}`,
|
||||
CLAUDE_WORKING_DIR: projDir,
|
||||
});
|
||||
assert.equal(result.code, 2, `Expected exit 2, got ${result.code}. stderr: ${result.stderr}`);
|
||||
assert.match(result.stderr, /event-stream@3\.3\.6/);
|
||||
assert.match(result.stderr, /package-lock\.json/);
|
||||
} finally {
|
||||
rmSync(shimDir, { recursive: true, force: true });
|
||||
rmSync(projDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('#48 blocks bare install when lockfileVersion 1 nests a compromised copy below top level', async () => {
|
||||
const shimDir = makeNpmShim('{}');
|
||||
const projDir = mkdtempSync(join(tmpdir(), 'llmsec-supply-proj-'));
|
||||
writeFileSync(join(projDir, 'package-lock.json'), JSON.stringify({
|
||||
name: 'sample', lockfileVersion: 1, dependencies: {
|
||||
a: {
|
||||
version: '1.0.0',
|
||||
dependencies: {
|
||||
'event-stream': { version: '3.3.6' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
try {
|
||||
const result = await runHookWithEnv(SCRIPT, bashPayload('npm install'), {
|
||||
PATH: `${shimDir}:${process.env.PATH}`,
|
||||
CLAUDE_WORKING_DIR: projDir,
|
||||
});
|
||||
assert.equal(result.code, 2, `Expected exit 2, got ${result.code}. stderr: ${result.stderr}`);
|
||||
assert.match(result.stderr, /event-stream@3\.3\.6/);
|
||||
assert.match(result.stderr, /package-lock\.json/);
|
||||
} finally {
|
||||
rmSync(shimDir, { recursive: true, force: true });
|
||||
rmSync(projDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('#16 does NOT block yarn install for a clean yarn.lock (ansi-colors + unrelated version "1.4.1")', async () => {
|
||||
// Old matcher: unanchored `colors@` matched inside `ansi-colors@`, and the
|
||||
// compromised colors version "1.4.1" matched a DIFFERENT entry's version
|
||||
// line — two unassociated whole-file substrings caused a spurious BLOCK.
|
||||
const projDir = mkdtempSync(join(tmpdir(), 'llmsec-supply-proj-'));
|
||||
writeFileSync(join(projDir, 'yarn.lock'), [
|
||||
'# yarn lockfile v1',
|
||||
'',
|
||||
'ansi-colors@^4.1.1:',
|
||||
' version "4.1.3"',
|
||||
' resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.3.tgz"',
|
||||
'',
|
||||
'harmless-pkg@^1.4.0:',
|
||||
' version "1.4.1"',
|
||||
' resolved "https://registry.yarnpkg.com/harmless-pkg/-/harmless-pkg-1.4.1.tgz"',
|
||||
'',
|
||||
].join('\n'));
|
||||
try {
|
||||
const result = await runHookWithEnv(SCRIPT, bashPayload('yarn install'), {
|
||||
CLAUDE_WORKING_DIR: projDir,
|
||||
});
|
||||
assert.equal(result.code, 0, `Expected exit 0 (no block), got ${result.code}. stderr: ${result.stderr}`);
|
||||
} finally {
|
||||
rmSync(projDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('#17 blocks yarn install when a Berry yarn.lock pins compromised event-stream (version: x format)', async () => {
|
||||
const projDir = mkdtempSync(join(tmpdir(), 'llmsec-supply-proj-'));
|
||||
writeFileSync(join(projDir, 'yarn.lock'), [
|
||||
'# This file is generated by running "yarn install" inside your project.',
|
||||
'',
|
||||
'__metadata:',
|
||||
' version: 8',
|
||||
'',
|
||||
'"event-stream@npm:^3.3.6":',
|
||||
' version: 3.3.6',
|
||||
' resolution: "event-stream@npm:3.3.6"',
|
||||
'',
|
||||
].join('\n'));
|
||||
try {
|
||||
const result = await runHookWithEnv(SCRIPT, bashPayload('yarn install'), {
|
||||
CLAUDE_WORKING_DIR: projDir,
|
||||
});
|
||||
assert.equal(result.code, 2, `Expected exit 2, got ${result.code}. stderr: ${result.stderr}`);
|
||||
assert.match(result.stderr, /event-stream@3\.3\.6/);
|
||||
assert.match(result.stderr, /yarn\.lock/);
|
||||
} finally {
|
||||
rmSync(projDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('#16/#17 still blocks yarn install for a Classic yarn.lock pinning compromised event-stream', async () => {
|
||||
const projDir = mkdtempSync(join(tmpdir(), 'llmsec-supply-proj-'));
|
||||
writeFileSync(join(projDir, 'yarn.lock'), [
|
||||
'# yarn lockfile v1',
|
||||
'',
|
||||
'event-stream@^3.3.5:',
|
||||
' version "3.3.6"',
|
||||
' resolved "https://registry.yarnpkg.com/event-stream/-/event-stream-3.3.6.tgz"',
|
||||
'',
|
||||
].join('\n'));
|
||||
try {
|
||||
const result = await runHookWithEnv(SCRIPT, bashPayload('yarn install'), {
|
||||
CLAUDE_WORKING_DIR: projDir,
|
||||
});
|
||||
assert.equal(result.code, 2, `Expected exit 2, got ${result.code}. stderr: ${result.stderr}`);
|
||||
assert.match(result.stderr, /event-stream@3\.3\.6/);
|
||||
assert.match(result.stderr, /yarn\.lock/);
|
||||
} finally {
|
||||
rmSync(projDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue