Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions src/data/popular-packages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// Curated list of high-download packages — the targets typosquatters imitate.
// Names must be lowercase; PyPI names in PEP 503 normalized form (hyphens).
// Extend via PRs; a fetch-based update script is tracked separately.

export const POPULAR_NPM_PACKAGES: ReadonlySet<string> = new Set([
// Core utilities
'lodash', 'underscore', 'ramda', 'async', 'bluebird', 'tslib', 'core-js',
'moment', 'dayjs', 'date-fns', 'luxon', 'uuid', 'nanoid', 'slugify',
'semver', 'minimist', 'yargs', 'commander', 'inquirer', 'chalk', 'debug',
'dotenv', 'classnames', 'prop-types', 'immer', 'qs', 'query-string',
'picocolors', 'kleur', 'colors', 'color', 'ansi-colors', 'strip-ansi',
'string-width', 'wrap-ansi', 'supports-color', 'ora', 'boxen', 'figlet',
'execa', 'shelljs', 'cross-spawn', 'cross-env', 'concurrently', 'npm-run-all',
'rimraf', 'mkdirp', 'glob', 'globby', 'fast-glob', 'minimatch', 'micromatch',
'picomatch', 'ignore', 'chokidar', 'fs-extra', 'del', 'open', 'tar',
'archiver', 'jszip', 'adm-zip', 'extract-zip', 'unzipper',
// Frameworks & view layers
'react', 'react-dom', 'react-native', 'next', 'vue', 'nuxt', 'svelte',
'angular', 'rxjs', 'redux', 'react-redux', 'zustand', 'mobx', 'reselect',
'react-router', 'react-router-dom', 'expo', 'electron', 'ionic', 'cordova',
'jquery', 'bootstrap', 'styled-components', 'tailwindcss', 'postcss',
'autoprefixer', 'sass', 'less', 'stylus',
// Servers & networking
'express', 'koa', 'fastify', 'hapi', 'axios', 'node-fetch', 'got',
'superagent', 'request', 'undici', 'ws', 'socket.io', 'cors', 'body-parser',
'cookie-parser', 'express-session', 'multer', 'formidable', 'busboy',
'form-data', 'mime', 'mime-types', 'file-type', 'http-server', 'serve',
'json-server', 'nodemailer', 'helmet', 'morgan', 'compression',
// Auth & crypto
'passport', 'jsonwebtoken', 'bcrypt', 'bcryptjs', 'crypto-js', 'argon2',
// Logging
'winston', 'pino', 'bunyan', 'log4js', 'signale', 'consola', 'loglevel',
// Databases & ORMs
'mongoose', 'mongodb', 'mysql', 'mysql2', 'pg', 'sqlite3', 'better-sqlite3',
'redis', 'ioredis', 'sequelize', 'typeorm', 'prisma', 'knex', 'level',
// GraphQL & validation
'graphql', 'apollo-server', 'ajv', 'joi', 'yup', 'zod', 'validator',
'class-validator',
// Build tools & compilers
'typescript', 'webpack', 'webpack-cli', 'webpack-dev-server', 'vite',
'rollup', 'esbuild', 'parcel', 'babel-loader', 'ts-node', 'tsx', 'swc',
'terser', 'uglify-js', 'source-map', 'source-map-support', 'acorn',
// Linting & formatting
'eslint', 'prettier', 'stylelint', 'husky', 'lint-staged', 'standard',
'eslint-plugin-react', 'eslint-plugin-import', 'eslint-config-prettier',
// Testing
'jest', 'vitest', 'mocha', 'chai', 'sinon', 'supertest', 'cypress',
'playwright', 'puppeteer', 'jsdom', 'cheerio', 'karma', 'jasmine', 'ava',
'nyc', 'c8', 'nodemon', 'faker',
// Data & files
'yaml', 'js-yaml', 'toml', 'ini', 'xml2js', 'fast-xml-parser', 'csv-parse',
'csv-parser', 'papaparse', 'xlsx', 'exceljs', 'pdfkit', 'pdf-lib', 'sharp',
'jimp', 'canvas', 'marked', 'markdown-it', 'js-beautify',
// Cloud & APIs
'aws-sdk', 'firebase', 'firebase-admin', 'stripe', 'twilio', 'openai',
'langchain', 'ethers', 'web3', 'discord.js', 'telegraf', 'octokit',
'simple-git', 'pm2',
// Config & misc
'cosmiconfig', 'configstore', 'conf', 'rc', 'update-notifier', 'zx',
'eventemitter3', 'readable-stream', 'safe-buffer', 'buffer', 'events',
'node-gyp', 'bindings', 'node-addon-api', 'progress', 'cli-progress',
'cli-table3', 'table', 'listr2', 'regenerator-runtime', 'whatwg-fetch',
'isomorphic-fetch', 'abort-controller', 'path-to-regexp', 'url-parse',
'big.js', 'decimal.js', 'mathjs', 'numeral',
// Popular scoped packages
'@babel/core', '@babel/cli', '@babel/preset-env', '@babel/preset-react',
'@babel/preset-typescript', '@babel/runtime', '@types/node', '@types/react',
'@types/react-dom', '@types/express', '@types/lodash', '@types/jest',
'@typescript-eslint/parser', '@typescript-eslint/eslint-plugin',
'@apollo/client', '@aws-sdk/client-s3', '@sendgrid/mail', '@slack/web-api',
'@octokit/rest', '@changesets/cli', '@anthropic-ai/sdk',
]);

export const POPULAR_PYPI_PACKAGES: ReadonlySet<string> = new Set([
// Core / packaging
'pip', 'setuptools', 'wheel', 'virtualenv', 'pipenv', 'poetry', 'build',
'twine', 'packaging', 'typing-extensions', 'importlib-metadata', 'zipp',
'filelock', 'platformdirs', 'six', 'cython',
// HTTP & networking
'requests', 'urllib3', 'httpx', 'aiohttp', 'websockets', 'certifi', 'idna',
'charset-normalizer', 'requests-oauthlib', 'oauthlib',
// Data science & ML
'numpy', 'pandas', 'scipy', 'matplotlib', 'seaborn', 'scikit-learn',
'scikit-image', 'tensorflow', 'torch', 'torchvision', 'keras',
'transformers', 'datasets', 'tokenizers', 'huggingface-hub', 'openai',
'anthropic', 'langchain', 'tiktoken', 'nltk', 'spacy', 'gensim', 'numba',
'joblib', 'plotly', 'bokeh', 'streamlit', 'gradio', 'xgboost', 'lightgbm',
// Imaging & media
'pillow', 'opencv-python', 'imageio', 'moviepy', 'yt-dlp',
// Web frameworks
'flask', 'django', 'fastapi', 'starlette', 'uvicorn', 'gunicorn',
'tornado', 'sanic', 'bottle', 'celery', 'jinja2', 'markupsafe', 'werkzeug',
'itsdangerous', 'blinker',
// Databases
'sqlalchemy', 'alembic', 'psycopg2', 'psycopg2-binary', 'pymysql',
'mysqlclient', 'pymongo', 'motor', 'redis', 'elasticsearch', 'peewee',
'asyncpg',
// CLI & terminal
'click', 'typer', 'rich', 'tqdm', 'colorama', 'termcolor', 'tabulate',
'fire', 'prompt-toolkit',
// Parsing & scraping
'beautifulsoup4', 'lxml', 'html5lib', 'soupsieve', 'scrapy', 'selenium',
'playwright', 'feedparser', 'markdown', 'pyyaml', 'toml', 'tomli',
'jsonschema', 'xmltodict', 'regex', 'chardet',
// Validation & serialization
'pydantic', 'marshmallow', 'attrs', 'cattrs', 'orjson', 'ujson',
'simplejson', 'msgpack', 'protobuf', 'grpcio',
// Auth & crypto
'cryptography', 'pyjwt', 'pyopenssl', 'paramiko', 'bcrypt', 'passlib',
// Testing & QA
'pytest', 'pytest-cov', 'pytest-asyncio', 'pytest-mock', 'tox', 'nox',
'coverage', 'hypothesis', 'faker', 'factory-boy', 'mock', 'responses',
'freezegun',
// Linting & formatting
'black', 'flake8', 'pylint', 'isort', 'mypy', 'ruff', 'autopep8', 'yapf',
'bandit', 'pre-commit',
// Docs
'sphinx', 'mkdocs', 'mkdocs-material',
// Date & time
'python-dateutil', 'pytz', 'tzdata', 'arrow', 'pendulum', 'dateparser',
'humanize', 'croniter',
// Files & office
'openpyxl', 'xlrd', 'xlsxwriter', 'python-docx', 'pypdf', 'pypdf2',
'reportlab', 'pdfminer-six',
// Cloud & APIs
'boto3', 'botocore', 'awscli', 's3transfer', 'azure-storage-blob',
'google-cloud-storage', 'google-api-python-client', 'firebase-admin',
'stripe', 'twilio', 'sendgrid', 'slack-sdk', 'discord-py', 'tweepy',
'kafka-python', 'pika', 'paho-mqtt',
// Config & env
'python-dotenv', 'environs', 'dynaconf', 'watchdog', 'schedule',
'apscheduler', 'loguru', 'structlog', 'sentry-sdk', 'psutil',
// Text & misc
'python-slugify', 'unidecode', 'validators', 'phonenumbers',
'email-validator', 'rapidfuzz', 'fuzzywuzzy', 'more-itertools',
'cachetools', 'diskcache', 'dill', 'cloudpickle', 'jupyter', 'notebook',
'jupyterlab', 'ipython', 'ipykernel', 'nbconvert',
]);
57 changes: 57 additions & 0 deletions src/decision.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest';
import { makeDecision, makeFullDecision, type Vulnerability } from './decision.js';

function vuln(severity: Vulnerability['severity'], name = 'some-pkg'): Vulnerability {
return { name, severity, version: '1.0.0' };
}

describe('makeDecision', () => {
it('allows when there are no vulnerabilities', () => {
expect(makeDecision([]).decision).toBe('allow');
});

it('denies on CRITICAL', () => {
expect(makeDecision([vuln('CRITICAL')]).decision).toBe('deny');
});

it('denies on HIGH', () => {
expect(makeDecision([vuln('HIGH')]).decision).toBe('deny');
});

it('asks on MODERATE', () => {
expect(makeDecision([vuln('MODERATE')]).decision).toBe('ask');
});

it('asks on UNKNOWN severity instead of allowing', () => {
// Regression: advisories without a CVSS score (e.g. MAL-* malware
// entries before they are mapped to CRITICAL, or fresh unscored
// reports) must never silently pass.
const result = makeDecision([vuln('UNKNOWN')]);
expect(result.decision).toBe('ask');
expect(result.reason).toContain('UNKNOWN severity');
});

it('allows on LOW only', () => {
const result = makeDecision([vuln('LOW')]);
expect(result.decision).toBe('allow');
});

it('deny takes priority over UNKNOWN in mixed results', () => {
expect(makeDecision([vuln('UNKNOWN'), vuln('CRITICAL')]).decision).toBe('deny');
});
});

describe('makeFullDecision', () => {
it('escalates UNKNOWN-severity vulnerabilities to ask without supply chain signals', () => {
const result = makeFullDecision([vuln('UNKNOWN')], []);
expect(result.decision).toBe('ask');
});

it('keeps deny when supply chain signals are also present', () => {
const result = makeFullDecision([vuln('CRITICAL')], [
{ type: 'new-package', severity: 'HIGH', detail: 'Package created 2 days ago.' },
]);
expect(result.decision).toBe('deny');
expect(result.reason).toContain('Package created 2 days ago.');
});
});
10 changes: 8 additions & 2 deletions src/decision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { SupplyChainSignal } from './registry.js';

export interface Vulnerability {
name: string;
severity: 'CRITICAL' | 'HIGH' | 'MODERATE' | 'LOW' | 'NONE';
severity: 'CRITICAL' | 'HIGH' | 'MODERATE' | 'LOW' | 'UNKNOWN' | 'NONE';
version: string;
fixVersion?: string;
}
Expand All @@ -26,14 +26,17 @@ export function makeDecision(vulnerabilities: Vulnerability[]): DecisionResult {

if (severities.includes('CRITICAL') || severities.includes('HIGH')) {
decision = 'deny';
} else if (severities.includes('MODERATE')) {
} else if (severities.includes('MODERATE') || severities.includes('UNKNOWN')) {
// UNKNOWN severity means the advisory exists but carries no score —
// never treat that as harmless; require user approval.
decision = 'ask';
}

if (decision === 'deny' || decision === 'ask') {
const criticalCount = severities.filter(s => s === 'CRITICAL').length;
const highCount = severities.filter(s => s === 'HIGH').length;
const moderateCount = severities.filter(s => s === 'MODERATE').length;
const unknownCount = severities.filter(s => s === 'UNKNOWN').length;

const parts = [];
if (criticalCount > 0) {
Expand All @@ -45,6 +48,9 @@ export function makeDecision(vulnerabilities: Vulnerability[]): DecisionResult {
if (moderateCount > 0) {
parts.push(`${moderateCount} MODERATE`);
}
if (unknownCount > 0) {
parts.push(`${unknownCount} UNKNOWN severity`);
}

const vuln = vulnerabilities[0];
const fixVersion = vuln.fixVersion ? `, recommended fix: ${vuln.fixVersion}` : '';
Expand Down
13 changes: 11 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { makeDecision, makeFullDecision, type Vulnerability } from './decision.j
import { parseInstallCommand } from './parser.js';
import { checkPackageVulnerabilities, type Vulnerability as OSVVulnerability, type CheckResult } from './osv.js';
import { checkRegistryMetadata, type SupplyChainSignal } from './registry.js';
import { checkTyposquat } from './typosquat.js';

// Map OSV severity to decision engine severity
function mapSeverity(osvSeverity: OSVVulnerability['severity']): Vulnerability['severity'] {
Expand All @@ -32,8 +33,10 @@ function mapSeverity(osvSeverity: OSVVulnerability['severity']): Vulnerability['
case 'HIGH': return 'HIGH';
case 'MEDIUM': return 'MODERATE';
case 'LOW': return 'LOW';
case 'UNKNOWN': return 'NONE';
default: return 'NONE';
// UNKNOWN must not collapse to NONE: advisories without a CVSS score
// (common for malware and fresh reports) would silently pass as "allow".
case 'UNKNOWN': return 'UNKNOWN';
default: return 'UNKNOWN';
}
}

Expand Down Expand Up @@ -210,6 +213,12 @@ async function main() {
}
}

// Typosquat detection runs offline against the embedded popular-package list
for (const pkg of checkablePackages) {
const squat = checkTyposquat(pkg.name, pkg.ecosystem);
if (squat) allSignals.push(squat);
}

// Make decision based on CVE vulnerabilities + supply chain signals
let { decision, reason } = makeFullDecision(allVulnerabilities, allSignals);

Expand Down
55 changes: 55 additions & 0 deletions src/osv.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,61 @@ function pypiResponse(version: string) {
return { ok: true, status: 200, json: () => Promise.resolve({ info: { version } }) };
}

describe('checkPackageVulnerabilities — malware advisories (MAL-*)', () => {
function osvRawResponse(vulns: unknown[]) {
return { ok: true, status: 200, json: () => Promise.resolve({ vulns }) };
}

it('treats MAL-* advisories without any severity data as CRITICAL', async () => {
mockFetch.mockImplementation((url: string) => {
if (url === 'https://api.osv.dev/v1/query') {
// Real-world shape: MAL entries typically have no severity array
// and no database_specific.severity.
return Promise.resolve(
osvRawResponse([{ id: 'MAL-2026-1234', summary: 'Malicious code in evil-pkg (npm)' }])
);
}
throw new Error(`unexpected fetch: ${url}`);
});

const result = await checkPackageVulnerabilities('evil-pkg', '1.0.0', 'npm');

expect(result.status).toBe('success');
if (result.status !== 'success') return;
expect(result.vulnerabilities).toHaveLength(1);
expect(result.vulnerabilities[0].severity).toBe('CRITICAL');
expect(result.vulnerabilities[0].id).toBe('MAL-2026-1234');
});

it('treats advisories with a MAL-* alias as CRITICAL even when the id is a GHSA', async () => {
mockFetch.mockImplementation(() =>
Promise.resolve(
osvRawResponse([
{ id: 'GHSA-xxxx-yyyy-zzzz', aliases: ['MAL-2026-9999'], summary: 'malware' },
])
)
);

const result = await checkPackageVulnerabilities('evil-pkg', '1.0.0', 'npm');

expect(result.status).toBe('success');
if (result.status !== 'success') return;
expect(result.vulnerabilities[0].severity).toBe('CRITICAL');
});

it('keeps non-MAL advisories without severity data as UNKNOWN (not CRITICAL)', async () => {
mockFetch.mockImplementation(() =>
Promise.resolve(osvRawResponse([{ id: 'GHSA-aaaa-bbbb-cccc', summary: 'unscored' }]))
);

const result = await checkPackageVulnerabilities('some-pkg', '1.0.0', 'npm');

expect(result.status).toBe('success');
if (result.status !== 'success') return;
expect(result.vulnerabilities[0].severity).toBe('UNKNOWN');
});
});

describe('checkPackageVulnerabilities — version resolution', () => {
it('resolves npm latest from registry when no version given, then queries OSV with that version', async () => {
const calls: Array<{ url: string; body?: string }> = [];
Expand Down
12 changes: 12 additions & 0 deletions src/osv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,19 @@ function labelFromCvssScore(scoreNum: number): Vulnerability['severity'] {
return 'UNKNOWN';
}

// OpenSSF malicious-package advisories (MAL-*) identify known malware but
// usually carry no CVSS score. Check the original id and aliases, not the
// CVE-preferring chooseId() result.
function isMalwareAdvisory(v: OSVVulnerability): boolean {
if (typeof v.id === 'string' && v.id.startsWith('MAL-')) return true;
const aliases = Array.isArray(v.aliases) ? v.aliases : [];
return aliases.some(a => typeof a === 'string' && a.startsWith('MAL-'));
}

function coerceSeverity(v: OSVVulnerability): Vulnerability['severity'] {
// Known malware is always critical, regardless of missing severity data.
if (isMalwareAdvisory(v)) return 'CRITICAL';

if (Array.isArray(v.severity) && v.severity.length > 0) {
let best: Vulnerability['severity'] = 'UNKNOWN';
const order: Record<Vulnerability['severity'], number> = {
Expand Down
2 changes: 1 addition & 1 deletion src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const LOW_DOWNLOAD_THRESHOLD = 100;
const REGISTRY_TIMEOUT_MS = 3000;

export interface SupplyChainSignal {
type: 'version-quarantine' | 'new-package' | 'low-downloads';
type: 'version-quarantine' | 'new-package' | 'low-downloads' | 'typosquat';
severity: 'HIGH' | 'MEDIUM';
detail: string;
suggestion?: string;
Expand Down
Loading
Loading