#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
374 lines
16 KiB
JavaScript
374 lines
16 KiB
JavaScript
// pre-install-supply-chain.test.mjs — Tests for hooks/scripts/pre-install-supply-chain.mjs
|
|
// Zero external dependencies: node:test + node:assert only.
|
|
//
|
|
// IMPORTANT: This hook makes network calls for unknown packages (npm view, PyPI API, OSV.dev).
|
|
// We ONLY test deterministic behavior:
|
|
// 1. Non-install commands that exit immediately (no network)
|
|
// 2. Known-compromised packages from the hardcoded blocklist (no network needed)
|
|
// Any test requiring a network response is excluded.
|
|
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
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');
|
|
|
|
function bashPayload(command) {
|
|
return { tool_name: 'Bash', tool_input: { command } };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ALLOW cases — non-install commands exit immediately without network calls
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('pre-install-supply-chain — ALLOW (non-install commands)', () => {
|
|
it('allows ls -la immediately because it is not a package install command', async () => {
|
|
const result = await runHook(SCRIPT, bashPayload('ls -la'));
|
|
assert.equal(result.code, 0);
|
|
});
|
|
|
|
it('allows npm run build immediately because it is not an install command', async () => {
|
|
const result = await runHook(SCRIPT, bashPayload('npm run build'));
|
|
assert.equal(result.code, 0);
|
|
});
|
|
|
|
it('allows git status immediately because it is not a package install command', async () => {
|
|
const result = await runHook(SCRIPT, bashPayload('git status'));
|
|
assert.equal(result.code, 0);
|
|
});
|
|
|
|
it('exits 0 gracefully when stdin is not valid JSON', async () => {
|
|
const result = await runHook(SCRIPT, 'not json {{{');
|
|
assert.equal(result.code, 0);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// BLOCK cases — known-compromised packages from hardcoded blocklist
|
|
// These are deterministic: no network call is needed because the name/version
|
|
// matches the in-memory NPM_COMPROMISED or PIP_COMPROMISED map.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('pre-install-supply-chain — BLOCK (hardcoded compromised blocklist)', () => {
|
|
it('blocks npm install event-stream@3.3.6 (NPM_COMPROMISED — known supply chain attack)', async () => {
|
|
const result = await runHook(SCRIPT, bashPayload('npm install event-stream@3.3.6'));
|
|
assert.equal(result.code, 2);
|
|
assert.match(result.stderr, /BLOCKED|COMPROMISED/i);
|
|
assert.match(result.stderr, /event-stream/);
|
|
});
|
|
|
|
it('blocks npm install ua-parser-js@0.7.29 (NPM_COMPROMISED — known supply chain attack)', async () => {
|
|
const result = await runHook(SCRIPT, bashPayload('npm install ua-parser-js@0.7.29'));
|
|
assert.equal(result.code, 2);
|
|
assert.match(result.stderr, /BLOCKED|COMPROMISED/i);
|
|
assert.match(result.stderr, /ua-parser-js/);
|
|
});
|
|
|
|
it('blocks pip install jeIlyfish (PIP_COMPROMISED — homoglyph typosquat of jellyfish)', async () => {
|
|
const result = await runHook(SCRIPT, bashPayload('pip install jeIlyfish'));
|
|
assert.equal(result.code, 2);
|
|
assert.match(result.stderr, /BLOCKED|COMPROMISED/i);
|
|
assert.match(result.stderr, /jeIlyfish/);
|
|
});
|
|
|
|
it('blocks pip install python3-dateutil (PIP_COMPROMISED — python-dateutil typosquat)', async () => {
|
|
const result = await runHook(SCRIPT, bashPayload('pip install python3-dateutil'));
|
|
assert.equal(result.code, 2);
|
|
assert.match(result.stderr, /BLOCKED|COMPROMISED/i);
|
|
assert.match(result.stderr, /python3-dateutil/);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// BASH EVASION — obfuscated package install commands that should be caught
|
|
// after normalizeBashExpansion deobfuscates them.
|
|
// Single-char ${x} evasion uses variable name = intended character.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('pre-install-supply-chain — bash evasion BLOCK cases', () => {
|
|
it('blocks n""pm install event-stream@3.3.6 (empty quote evasion)', async () => {
|
|
const result = await runHook(SCRIPT, bashPayload('n""pm install event-stream@3.3.6'));
|
|
assert.equal(result.code, 2);
|
|
assert.match(result.stderr, /BLOCKED|COMPROMISED/i);
|
|
assert.match(result.stderr, /event-stream/);
|
|
});
|
|
|
|
it('blocks n${p}m install ua-parser-js@0.7.29 (single-char expansion: p=p)', async () => {
|
|
const result = await runHook(SCRIPT, bashPayload('n${p}m install ua-parser-js@0.7.29'));
|
|
assert.equal(result.code, 2);
|
|
assert.match(result.stderr, /BLOCKED|COMPROMISED/i);
|
|
assert.match(result.stderr, /ua-parser-js/);
|
|
});
|
|
|
|
it("blocks p''ip install jeIlyfish (single quote evasion)", async () => {
|
|
const result = await runHook(SCRIPT, bashPayload("p''ip install jeIlyfish"));
|
|
assert.equal(result.code, 2);
|
|
assert.match(result.stderr, /BLOCKED|COMPROMISED/i);
|
|
assert.match(result.stderr, /jeIlyfish/);
|
|
});
|
|
|
|
it('blocks p${i}p install python3-dateutil (single-char expansion: i=i)', async () => {
|
|
const result = await runHook(SCRIPT, bashPayload('p${i}p install python3-dateutil'));
|
|
assert.equal(result.code, 2);
|
|
assert.match(result.stderr, /BLOCKED|COMPROMISED/i);
|
|
assert.match(result.stderr, /python3-dateutil/);
|
|
});
|
|
|
|
it("blocks y''arn add event-stream@3.3.6 (yarn with quote evasion)", async () => {
|
|
const result = await runHook(SCRIPT, bashPayload("y''arn add event-stream@3.3.6"));
|
|
assert.equal(result.code, 2);
|
|
assert.match(result.stderr, /BLOCKED|COMPROMISED/i);
|
|
assert.match(result.stderr, /event-stream/);
|
|
});
|
|
});
|
|
|
|
describe('pre-install-supply-chain — bash evasion ALLOW (non-install)', () => {
|
|
it('allows l""s -la (non-install command, even with evasion)', async () => {
|
|
const result = await runHook(SCRIPT, bashPayload('l""s -la'));
|
|
assert.equal(result.code, 0);
|
|
});
|
|
|
|
it('allows g${i}t status (non-install command)', async () => {
|
|
const result = await runHook(SCRIPT, bashPayload('g${i}t status'));
|
|
assert.equal(result.code, 0);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// E13 — npm scope-hopping detector (unit-level coverage)
|
|
// `checkScopeHop()` is a pure function; we test it directly to avoid
|
|
// network calls from the hook's npm-view path.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('pre-install-supply-chain — E13 scope-hopping (checkScopeHop)', () => {
|
|
it('flags @evilcorp/lodash as scope-hop (lodash is popular, @evilcorp is not official)', async () => {
|
|
const { checkScopeHop } = await import('../../scanners/lib/supply-chain-data.mjs');
|
|
const hit = checkScopeHop('@evilcorp/lodash');
|
|
assert.deepEqual(hit, { scope: '@evilcorp', unscoped: 'lodash', spec: '@evilcorp/lodash' });
|
|
});
|
|
|
|
it('flags @attacker/express as scope-hop', async () => {
|
|
const { checkScopeHop } = await import('../../scanners/lib/supply-chain-data.mjs');
|
|
const hit = checkScopeHop('@attacker/express');
|
|
assert.ok(hit && hit.scope === '@attacker' && hit.unscoped === 'express');
|
|
});
|
|
|
|
it('does NOT flag @types/lodash (allowlisted scope)', async () => {
|
|
const { checkScopeHop } = await import('../../scanners/lib/supply-chain-data.mjs');
|
|
assert.equal(checkScopeHop('@types/lodash'), null);
|
|
});
|
|
|
|
it('does NOT flag @reduxjs/toolkit (allowlisted scope)', async () => {
|
|
const { checkScopeHop } = await import('../../scanners/lib/supply-chain-data.mjs');
|
|
assert.equal(checkScopeHop('@reduxjs/toolkit'), null);
|
|
});
|
|
|
|
it('does NOT flag @modelcontextprotocol/sdk (allowlisted scope)', async () => {
|
|
const { checkScopeHop } = await import('../../scanners/lib/supply-chain-data.mjs');
|
|
assert.equal(checkScopeHop('@modelcontextprotocol/sdk'), null);
|
|
});
|
|
|
|
it('does NOT flag @evilcorp/notreally-popular (unscoped name not in top-100)', async () => {
|
|
const { checkScopeHop } = await import('../../scanners/lib/supply-chain-data.mjs');
|
|
assert.equal(checkScopeHop('@evilcorp/notreally-popular-package-xyz'), null);
|
|
});
|
|
|
|
it('does NOT flag bare unscoped lodash (no @scope/ prefix)', async () => {
|
|
const { checkScopeHop } = await import('../../scanners/lib/supply-chain-data.mjs');
|
|
assert.equal(checkScopeHop('lodash'), null);
|
|
});
|
|
|
|
it('respects extraAllowedScopes argument (policy-loaded allowlist)', async () => {
|
|
const { checkScopeHop } = await import('../../scanners/lib/supply-chain-data.mjs');
|
|
assert.equal(checkScopeHop('@evilcorp/lodash', ['@evilcorp']), null);
|
|
});
|
|
|
|
it('returns null on non-string input (defensive)', async () => {
|
|
const { checkScopeHop } = await import('../../scanners/lib/supply-chain-data.mjs');
|
|
assert.equal(checkScopeHop(null), null);
|
|
assert.equal(checkScopeHop(undefined), null);
|
|
assert.equal(checkScopeHop(42), null);
|
|
});
|
|
|
|
it('NPM_OFFICIAL_SCOPES export matches knowledge/typosquat-allowlist.json', async () => {
|
|
// This guards the v7.3.0 doc-consistency contract: the runtime list
|
|
// (used by checkScopeHop) and the documentation list must stay aligned.
|
|
const { NPM_OFFICIAL_SCOPES } = await import('../../scanners/lib/supply-chain-data.mjs');
|
|
const { readFileSync } = await import('node:fs');
|
|
const { resolve: resolvePath } = await import('node:path');
|
|
const allowlistPath = resolvePath(import.meta.dirname, '../../knowledge/typosquat-allowlist.json');
|
|
const data = JSON.parse(readFileSync(allowlistPath, 'utf8'));
|
|
assert.deepEqual(
|
|
[...NPM_OFFICIAL_SCOPES].sort(),
|
|
[...data.npm_official_scopes].sort(),
|
|
'NPM_OFFICIAL_SCOPES must match knowledge/typosquat-allowlist.json npm_official_scopes',
|
|
);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 });
|
|
}
|
|
});
|
|
});
|