diff --git a/plugins/llm-security/hooks/scripts/pre-install-supply-chain.mjs b/plugins/llm-security/hooks/scripts/pre-install-supply-chain.mjs index e8d8c07..0c8e381 100644 --- a/plugins/llm-security/hooks/scripts/pre-install-supply-chain.mjs +++ b/plugins/llm-security/hooks/scripts/pre-install-supply-chain.mjs @@ -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` + diff --git a/plugins/llm-security/knowledge/typosquat-allowlist.json b/plugins/llm-security/knowledge/typosquat-allowlist.json index 3560fcd..1d3afa9 100644 --- a/plugins/llm-security/knowledge/typosquat-allowlist.json +++ b/plugins/llm-security/knowledge/typosquat-allowlist.json @@ -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" ] } diff --git a/plugins/llm-security/scanners/lib/supply-chain-data.mjs b/plugins/llm-security/scanners/lib/supply-chain-data.mjs index 433b19f..8be3539 100644 --- a/plugins/llm-security/scanners/lib/supply-chain-data.mjs +++ b/plugins/llm-security/scanners/lib/supply-chain-data.mjs @@ -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 `@/` where: + * - `` is NOT on NPM_OFFICIAL_SCOPES, + * - `` is NOT on `extraAllowedScopes` (e.g. policy.json), and + * - `` 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 // =========================================================================== diff --git a/plugins/llm-security/tests/hooks/pre-install-supply-chain.test.mjs b/plugins/llm-security/tests/hooks/pre-install-supply-chain.test.mjs index 5d238b9..cb063de 100644 --- a/plugins/llm-security/tests/hooks/pre-install-supply-chain.test.mjs +++ b/plugins/llm-security/tests/hooks/pre-install-supply-chain.test.mjs @@ -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', + ); + }); +});