// 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', ); }); });