ktg-plugin-marketplace/plugins/llm-security/tests/hooks/pre-install-supply-chain.test.mjs
Kjell Tore Guttormsen ad86f5031a feat(pre-install-supply-chain): E13 — npm scope-hopping MEDIUM advisory with allowlist
Adds a scope-hopping detector to the npm install gate. When a user
installs `@<scope>/<unscoped>`, the hook now emits a MEDIUM warning
on stderr (exit 0, never blocks) if:
  - `<unscoped>` matches a popular npm package (POPULAR_NPM, ~80
    names from knowledge/top-packages.json), AND
  - `<scope>` is not on NPM_OFFICIAL_SCOPES (built-in 22 entries) or
    on policy.json `supply_chain.allowed_scopes`.

Why: an attacker publishing `@evilcorp/lodash` cannot squat the bare
`lodash` name, but they can register an unrelated scope and rely on
typo or copy-paste to trick installs. NPM_OFFICIAL_SCOPES anchors the
known-good scopes (@types, @reduxjs, @nestjs, …) so legitimate
installs stay silent.

Implementation:
- `scanners/lib/supply-chain-data.mjs`: exports POPULAR_NPM,
  NPM_OFFICIAL_SCOPES, and `checkScopeHop(name, extraAllowedScopes)` —
  pure function, no policy/network dependency, fully unit-testable.
- `knowledge/typosquat-allowlist.json`: mirrors NPM_OFFICIAL_SCOPES as
  `npm_official_scopes`. A doc-consistency assertion ensures the two
  lists never drift.
- `hooks/scripts/pre-install-supply-chain.mjs`: imports checkScopeHop,
  reads `supply_chain.allowed_scopes` from policy, and pushes a
  warning before existing compromised/audit checks.

Tests:
- 9 new cases in tests/hooks/pre-install-supply-chain.test.mjs:
  TP @evilcorp/lodash, TP @attacker/express, allowlist @types,
  allowlist @reduxjs, allowlist @modelcontextprotocol, FP unscoped
  name not in top-100, bare unscoped name, policy override, defensive
  non-string input, NPM_OFFICIAL_SCOPES <-> typosquat-allowlist.json
  consistency.
2026-04-30 15:38:28 +02:00

208 lines
9.7 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 { resolve } from 'node:path';
import { runHook } 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',
);
});
});