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

@ -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
// ===========================================================================