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:
Kjell Tore Guttormsen 2026-04-30 15:38:28 +02:00
commit ad86f5031a
4 changed files with 173 additions and 2 deletions

View file

@ -25,7 +25,7 @@ import {
NPM_COMPROMISED, PIP_COMPROMISED, CARGO_COMPROMISED, GEM_COMPROMISED,
DOCKER_SUSPICIOUS, POPULAR_PIP,
isCompromised, parseSpec, parsePipSpec, execSafe,
queryOSV, extractOSVSeverity,
queryOSV, extractOSVSeverity, checkScopeHop,
} from '../../scanners/lib/supply-chain-data.mjs';
import { normalizeBashExpansion } from '../../scanners/lib/bash-normalize.mjs';
import { getPolicyValue } from '../../scanners/lib/policy-loader.mjs';
@ -123,6 +123,21 @@ async function checkNpm() {
for (const spec of packages) {
const { name, version } = parseSpec(spec);
// E13: scope-hopping advisory — '@evilcorp/lodash' where '@evilcorp'
// is not on the official-scopes allowlist and 'lodash' matches a top
// npm package. MEDIUM advisory only, never blocks.
const allowedScopes = getPolicyValue('supply_chain', 'allowed_scopes', []);
const scopeHop = checkScopeHop(name, allowedScopes);
if (scopeHop) {
warnings.push(
`SCOPE-HOPPING SUSPECTED: "${scopeHop.spec}"\n` +
` Unscoped name "${scopeHop.unscoped}" matches a top-100 npm package, but\n` +
` scope "${scopeHop.scope}" is not on the official-scopes allowlist.\n` +
` Verify the publisher before installing. Add "${scopeHop.scope}" to\n` +
` policy.json supply_chain.allowed_scopes to silence this advisory.`
);
}
if (isCompromised(NPM_COMPROMISED, name, version) || POLICY_BLOCKED.has(name)) {
blocks.push(
`COMPROMISED: ${name}${version ? '@' + version : ''}\n` +

View file

@ -1,5 +1,5 @@
{
"_comment": "Known legitimate packages that trigger false positive typosquatting alerts due to short names or Levenshtein proximity to top packages. Normalized: lowercase, hyphens. Extended in v7.0.0 with short-named legit packages observed flagged against top-200 (knip vs knex, oxlint vs eslint, tsx vs nx, etc.).",
"_comment": "Known legitimate packages that trigger false positive typosquatting alerts due to short names or Levenshtein proximity to top packages. Normalized: lowercase, hyphens. Extended in v7.0.0 with short-named legit packages observed flagged against top-200 (knip vs knex, oxlint vs eslint, tsx vs nx, etc.). v7.3.0 adds npm_official_scopes — list of scopes whose scoped packages should NOT trigger E13 scope-hopping advisory. Kept in sync with NPM_OFFICIAL_SCOPES in scanners/lib/supply-chain-data.mjs (doc-consistency drift-guard).",
"npm": [
"ms",
"acorn",
@ -58,5 +58,29 @@
"rich",
"typer",
"anyio"
],
"npm_official_scopes": [
"@types",
"@reduxjs",
"@nestjs",
"@angular",
"@nrwl",
"@modelcontextprotocol",
"@babel",
"@testing-library",
"@aws-sdk",
"@azure",
"@google-cloud",
"@vue",
"@svelte",
"@nuxt",
"@sveltejs",
"@vitejs",
"@playwright",
"@storybook",
"@radix-ui",
"@reach",
"@emotion",
"@mui"
]
}

View file

@ -104,6 +104,66 @@ export const POPULAR_PIP = [
'discord.py', 'selenium', 'scrapy', 'lxml', 'pyyaml',
];
// Popular npm packages for scope-hop detection (E13). Subset of
// knowledge/top-packages.json npm list focused on names most attractive
// as a scope-hop lure. Kept hardcoded to keep hook startup synchronous.
export const POPULAR_NPM = [
'express', 'react', 'react-dom', 'lodash', 'axios', 'chalk', 'commander',
'debug', 'dotenv', 'eslint', 'jest', 'mocha', 'webpack', 'typescript',
'next', 'vue', 'angular', 'moment', 'dayjs', 'uuid', 'minimist', 'yargs',
'semver', 'mkdirp', 'fs-extra', 'cross-env', 'concurrently', 'nodemon',
'prettier', 'ts-node', 'rxjs', 'redux', 'react-redux', 'styled-components',
'tailwindcss', 'postcss', 'autoprefixer', 'sass', 'less', 'parcel',
'lerna', 'http-server', 'serve', 'cors', 'body-parser', 'cookie-parser',
'express-session', 'passport', 'jsonwebtoken', 'bcrypt', 'bcryptjs',
'mongoose', 'sequelize', 'prisma', 'typeorm', 'knex', 'pg', 'mysql2',
'sqlite3', 'ioredis', 'aws-sdk', 'firebase', 'graphql', 'apollo-server',
'socket.io', 'ws', 'puppeteer', 'playwright', 'cheerio', 'jsdom',
'sharp', 'jimp', 'multer', 'nodemailer', 'bull', 'cron', 'winston',
'pino', 'morgan', 'helmet', 'compression', 'joi', 'yup', 'ajv',
'validator', 'marked', 'three', 'chart.js', 'date-fns', 'underscore',
'ramda', 'immer', 'execa', 'shelljs', 'fast-glob', 'micromatch',
'inquirer', 'ora', 'boxen', 'node-fetch', 'got', 'supertest',
];
// Official npm scopes that publish well-known packages. A scoped install
// like `@types/lodash` whose unscoped name matches a popular package is
// only suspicious if `@types` is NOT on this list. Mirrored into
// knowledge/typosquat-allowlist.json as `npm_official_scopes` for the
// doc-consistency drift-guard test.
export const NPM_OFFICIAL_SCOPES = [
'@types', '@reduxjs', '@nestjs', '@angular', '@nrwl',
'@modelcontextprotocol', '@babel', '@testing-library',
'@aws-sdk', '@azure', '@google-cloud',
'@vue', '@svelte', '@nuxt', '@sveltejs', '@vitejs',
'@playwright', '@storybook', '@radix-ui', '@reach',
'@emotion', '@mui',
];
/**
* E13: scope-hopping detector. Returns null if `name` is not a scope-hop
* candidate, or `{ scope, unscoped, spec }` if it is. A scope-hop is a
* scoped npm name `@<scope>/<unscoped>` where:
* - `<scope>` is NOT on NPM_OFFICIAL_SCOPES,
* - `<scope>` is NOT on `extraAllowedScopes` (e.g. policy.json), and
* - `<unscoped>` matches a popular npm package (POPULAR_NPM).
*
* @param {string} name Full package name (`@scope/pkg` or bare)
* @param {string[]} [extraAllowedScopes=[]] Additional scopes to whitelist
* @returns {{ scope: string, unscoped: string, spec: string } | null}
*/
export function checkScopeHop(name, extraAllowedScopes = []) {
if (typeof name !== 'string') return null;
const m = name.match(/^(@[\w-]+)\/(.+)$/);
if (!m) return null;
const scope = m[1];
const unscoped = m[2];
if (NPM_OFFICIAL_SCOPES.includes(scope)) return null;
if (Array.isArray(extraAllowedScopes) && extraAllowedScopes.includes(scope)) return null;
if (!POPULAR_NPM.includes(unscoped)) return null;
return { scope, unscoped, spec: name };
}
// ===========================================================================
// Helper functions
// ===========================================================================

View file

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