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.
This commit is contained in:
parent
0f4b0c5f2c
commit
ad86f5031a
4 changed files with 173 additions and 2 deletions
|
|
@ -134,3 +134,75 @@ describe('pre-install-supply-chain — bash evasion ALLOW (non-install)', () =>
|
|||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue