dw-kit 1.3.5 → 1.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/hooks/supply-chain-scan.sh +16 -14
- package/.dw/security/advisory-snapshot.json +157 -0
- package/.dw/security/ioc-namespaces.json +20 -8
- package/CLAUDE.md +1 -1
- package/README.md +14 -2
- package/package.json +1 -1
- package/src/cli.mjs +5 -2
- package/src/commands/init.mjs +45 -1
- package/src/commands/security-scan.mjs +367 -52
- package/src/lib/gitignore.mjs +5 -1
- package/src/lib/npm-registry.mjs +159 -0
- package/src/lib/sc-heuristic.mjs +263 -0
- package/src/lib/sc-scanner.mjs +60 -11
- package/src/lib/sc-sync.mjs +98 -8
- package/src/lib/telemetry.mjs +20 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const REGISTRY_BASE = 'https://registry.npmjs.org';
|
|
5
|
+
const FETCH_TIMEOUT_MS = 5000;
|
|
6
|
+
const CACHE_REL = '.dw/security/npm-registry-cache.jsonl';
|
|
7
|
+
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour per ADR-0006
|
|
8
|
+
|
|
9
|
+
export function cachePath(rootDir = process.cwd()) {
|
|
10
|
+
return join(rootDir, CACHE_REL);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function ensureCacheDir(rootDir) {
|
|
14
|
+
const dir = dirname(cachePath(rootDir));
|
|
15
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function loadCache(rootDir = process.cwd()) {
|
|
19
|
+
const p = cachePath(rootDir);
|
|
20
|
+
if (!existsSync(p)) return new Map();
|
|
21
|
+
const map = new Map();
|
|
22
|
+
try {
|
|
23
|
+
const lines = readFileSync(p, 'utf-8').split('\n').filter(Boolean);
|
|
24
|
+
for (const line of lines) {
|
|
25
|
+
try {
|
|
26
|
+
const e = JSON.parse(line);
|
|
27
|
+
if (e && e.key) map.set(e.key, e);
|
|
28
|
+
} catch {
|
|
29
|
+
// skip malformed line
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
} catch {
|
|
33
|
+
// unreadable cache → ignore
|
|
34
|
+
}
|
|
35
|
+
return map;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function cacheGet(rootDir, key, ttlMs = CACHE_TTL_MS) {
|
|
39
|
+
const map = loadCache(rootDir);
|
|
40
|
+
const entry = map.get(key);
|
|
41
|
+
if (!entry) return null;
|
|
42
|
+
if (ttlMs <= 0) return null;
|
|
43
|
+
if (Date.now() - entry.cached_at > ttlMs) return null;
|
|
44
|
+
return entry.value;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function cachePut(rootDir, key, value) {
|
|
48
|
+
ensureCacheDir(rootDir);
|
|
49
|
+
const line = JSON.stringify({ key, cached_at: Date.now(), value }) + '\n';
|
|
50
|
+
appendFileSync(cachePath(rootDir), line, 'utf-8');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function pruneCache(rootDir, ttlMs = CACHE_TTL_MS) {
|
|
54
|
+
const p = cachePath(rootDir);
|
|
55
|
+
if (!existsSync(p)) return;
|
|
56
|
+
const map = loadCache(rootDir);
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
const fresh = [];
|
|
59
|
+
for (const e of map.values()) {
|
|
60
|
+
if (now - e.cached_at <= ttlMs) fresh.push(e);
|
|
61
|
+
}
|
|
62
|
+
ensureCacheDir(rootDir);
|
|
63
|
+
writeFileSync(p, fresh.map((e) => JSON.stringify(e)).join('\n') + (fresh.length ? '\n' : ''), 'utf-8');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function fetchJson(url, { timeoutMs = FETCH_TIMEOUT_MS } = {}) {
|
|
67
|
+
const controller = new AbortController();
|
|
68
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
69
|
+
try {
|
|
70
|
+
const res = await fetch(url, {
|
|
71
|
+
headers: { accept: 'application/json' },
|
|
72
|
+
signal: controller.signal,
|
|
73
|
+
});
|
|
74
|
+
if (!res.ok) {
|
|
75
|
+
const err = new Error(`npm registry HTTP ${res.status}`);
|
|
76
|
+
err.status = res.status;
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
return await res.json();
|
|
80
|
+
} finally {
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Fetch the full registry document for a package. Cached.
|
|
87
|
+
* Returns the raw npm registry response or null on hard failure.
|
|
88
|
+
*/
|
|
89
|
+
export async function fetchPackageMetadata(packageName, rootDir = process.cwd(), { useCache = true } = {}) {
|
|
90
|
+
const key = `pkg:${packageName}`;
|
|
91
|
+
if (useCache) {
|
|
92
|
+
const cached = cacheGet(rootDir, key);
|
|
93
|
+
if (cached) return cached;
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
const data = await fetchJson(`${REGISTRY_BASE}/${encodeURIComponent(packageName).replace('%40', '@')}`);
|
|
97
|
+
cachePut(rootDir, key, data);
|
|
98
|
+
return data;
|
|
99
|
+
} catch (e) {
|
|
100
|
+
if (e.status === 404) return null;
|
|
101
|
+
throw e;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Extract the signals needed by the heuristic scorer (ADR-0006 pillar 3).
|
|
107
|
+
* Pure function — call after fetchPackageMetadata returned a document.
|
|
108
|
+
*/
|
|
109
|
+
export function extractSignals(metadata, version) {
|
|
110
|
+
if (!metadata) return null;
|
|
111
|
+
const timeMap = metadata.time || {};
|
|
112
|
+
const publishIso = timeMap[version] || null;
|
|
113
|
+
const publishedAt = publishIso ? new Date(publishIso).getTime() : null;
|
|
114
|
+
const ageHours = publishedAt ? (Date.now() - publishedAt) / (1000 * 60 * 60) : null;
|
|
115
|
+
|
|
116
|
+
// Last modified time on the package as a whole — proxy for maintainer activity
|
|
117
|
+
const modifiedIso = timeMap.modified || null;
|
|
118
|
+
const modifiedAt = modifiedIso ? new Date(modifiedIso).getTime() : null;
|
|
119
|
+
|
|
120
|
+
// Maintainer set
|
|
121
|
+
const maintainers = Array.isArray(metadata.maintainers)
|
|
122
|
+
? metadata.maintainers.map((m) => (typeof m === 'string' ? m : m.name)).filter(Boolean)
|
|
123
|
+
: [];
|
|
124
|
+
|
|
125
|
+
// Latest version on dist-tags
|
|
126
|
+
const latestVersion = metadata['dist-tags']?.latest || null;
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
package: metadata.name,
|
|
130
|
+
version,
|
|
131
|
+
publish_iso: publishIso,
|
|
132
|
+
publish_age_hours: ageHours,
|
|
133
|
+
latest_version: latestVersion,
|
|
134
|
+
modified_iso: modifiedIso,
|
|
135
|
+
package_modified_age_days: modifiedAt ? (Date.now() - modifiedAt) / (1000 * 60 * 60 * 24) : null,
|
|
136
|
+
maintainer_count: maintainers.length,
|
|
137
|
+
maintainers,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Lightweight popularity probe via downloads API. Cached separately.
|
|
143
|
+
* Returns weekly downloads count or null on failure.
|
|
144
|
+
*/
|
|
145
|
+
export async function fetchWeeklyDownloads(packageName, rootDir = process.cwd(), { useCache = true } = {}) {
|
|
146
|
+
const key = `dl:${packageName}`;
|
|
147
|
+
if (useCache) {
|
|
148
|
+
const cached = cacheGet(rootDir, key);
|
|
149
|
+
if (cached) return cached;
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
const data = await fetchJson(`https://api.npmjs.org/downloads/point/last-week/${encodeURIComponent(packageName).replace('%40', '@')}`);
|
|
153
|
+
const count = typeof data?.downloads === 'number' ? data.downloads : null;
|
|
154
|
+
cachePut(rootDir, key, count);
|
|
155
|
+
return count;
|
|
156
|
+
} catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { parsePackageLockfile, findLockfile, compareVersions } from './sc-scanner.mjs';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_THRESHOLD = 50;
|
|
7
|
+
const DEFAULT_WEEKLY_DOWNLOADS_MIN = 10000;
|
|
8
|
+
const DEFAULT_RECENT_PUBLISH_HOURS = 72;
|
|
9
|
+
|
|
10
|
+
// Top-500 popular npm packages (small bundled list for typo-squat detection).
|
|
11
|
+
// Source: npm download counts, December 2025 snapshot. Pruned to common surface.
|
|
12
|
+
// Not exhaustive — typo-squat is one signal of many.
|
|
13
|
+
const POPULAR_PACKAGES = new Set([
|
|
14
|
+
'react', 'react-dom', 'vue', 'angular', 'svelte', 'next', 'nuxt',
|
|
15
|
+
'lodash', 'axios', 'express', 'fastify', 'koa', 'hapi',
|
|
16
|
+
'typescript', 'eslint', 'prettier', 'webpack', 'vite', 'rollup', 'esbuild',
|
|
17
|
+
'jest', 'mocha', 'vitest', 'playwright', 'cypress', 'puppeteer',
|
|
18
|
+
'chalk', 'commander', 'yargs', 'inquirer', 'enquirer',
|
|
19
|
+
'tailwindcss', 'postcss', 'sass', 'less',
|
|
20
|
+
'tanstack', '@tanstack/react-query', '@tanstack/react-router', '@tanstack/react-table',
|
|
21
|
+
'redux', '@reduxjs/toolkit', 'zustand', 'jotai', 'recoil',
|
|
22
|
+
'graphql', 'apollo-client', '@apollo/client', 'urql',
|
|
23
|
+
'rxjs', 'immer', 'lodash-es', 'date-fns', 'moment', 'dayjs', 'luxon',
|
|
24
|
+
'cors', 'helmet', 'jsonwebtoken', 'bcrypt', 'argon2',
|
|
25
|
+
'mongoose', 'prisma', '@prisma/client', 'sequelize', 'typeorm', 'knex',
|
|
26
|
+
'pg', 'mysql', 'mysql2', 'sqlite3', 'better-sqlite3',
|
|
27
|
+
'redis', 'ioredis',
|
|
28
|
+
'dotenv', 'uuid', 'nanoid', 'pino', 'winston', 'debug',
|
|
29
|
+
'zod', 'yup', 'joi', 'ajv',
|
|
30
|
+
'sharp', 'jimp', 'multer',
|
|
31
|
+
'socket.io', 'ws', 'engine.io',
|
|
32
|
+
'pm2', 'nodemon', 'concurrently', 'cross-env',
|
|
33
|
+
'firebase', 'firebase-admin', '@supabase/supabase-js',
|
|
34
|
+
'stripe', '@stripe/stripe-js',
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
export function loadHeuristicConfig(rootDir = process.cwd()) {
|
|
38
|
+
// Read .dw/config/dw.config.yml security.heuristic.* if present.
|
|
39
|
+
// Cheap inline YAML probe — no js-yaml dep here. Caller may pass overrides.
|
|
40
|
+
try {
|
|
41
|
+
const cfgPath = join(rootDir, '.dw/config/dw.config.yml');
|
|
42
|
+
if (!existsSync(cfgPath)) return defaultConfig();
|
|
43
|
+
const raw = readFileSync(cfgPath, 'utf-8');
|
|
44
|
+
const threshold = matchYamlNumber(raw, 'risk_threshold');
|
|
45
|
+
const weekly = matchYamlNumber(raw, 'weekly_downloads_min');
|
|
46
|
+
const recent = matchYamlNumber(raw, 'recent_publish_hours');
|
|
47
|
+
return {
|
|
48
|
+
risk_threshold: threshold ?? DEFAULT_THRESHOLD,
|
|
49
|
+
weekly_downloads_min: weekly ?? DEFAULT_WEEKLY_DOWNLOADS_MIN,
|
|
50
|
+
recent_publish_hours: recent ?? DEFAULT_RECENT_PUBLISH_HOURS,
|
|
51
|
+
};
|
|
52
|
+
} catch {
|
|
53
|
+
return defaultConfig();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function defaultConfig() {
|
|
58
|
+
return {
|
|
59
|
+
risk_threshold: DEFAULT_THRESHOLD,
|
|
60
|
+
weekly_downloads_min: DEFAULT_WEEKLY_DOWNLOADS_MIN,
|
|
61
|
+
recent_publish_hours: DEFAULT_RECENT_PUBLISH_HOURS,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function matchYamlNumber(yamlText, key) {
|
|
66
|
+
// Scoped inside security.heuristic.*
|
|
67
|
+
// Avoid full YAML parser dep; brittle but enough for top-level integer keys.
|
|
68
|
+
const re = new RegExp(`heuristic:[\\s\\S]*?\\b${key}\\s*:\\s*(\\d+)`, 'm');
|
|
69
|
+
const m = yamlText.match(re);
|
|
70
|
+
return m ? parseInt(m[1], 10) : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* List packages introduced or version-bumped since the last committed lockfile.
|
|
75
|
+
*
|
|
76
|
+
* Strategy:
|
|
77
|
+
* 1. If git available and HEAD has a previous lockfile → diff against HEAD
|
|
78
|
+
* 2. Otherwise → return ALL packages in current lockfile (cold-start path,
|
|
79
|
+
* caller can cap how many to actually probe)
|
|
80
|
+
*
|
|
81
|
+
* Returns Array<{ name, version, change: 'added' | 'bumped', from?: string }>.
|
|
82
|
+
*/
|
|
83
|
+
export function diffLockfilePackages(rootDir = process.cwd()) {
|
|
84
|
+
const lockPath = findLockfile(rootDir);
|
|
85
|
+
if (!lockPath) return [];
|
|
86
|
+
const currentPkgs = parsePackageLockfile(lockPath);
|
|
87
|
+
|
|
88
|
+
let previousPkgs = null;
|
|
89
|
+
try {
|
|
90
|
+
// Best-effort: read previous lockfile content from git HEAD
|
|
91
|
+
const lockRelPath = lockPath.startsWith(rootDir + '\\') || lockPath.startsWith(rootDir + '/')
|
|
92
|
+
? lockPath.slice(rootDir.length + 1).replace(/\\/g, '/')
|
|
93
|
+
: 'package-lock.json';
|
|
94
|
+
const previousContent = execSync(`git show HEAD:${lockRelPath}`, {
|
|
95
|
+
cwd: rootDir,
|
|
96
|
+
encoding: 'utf-8',
|
|
97
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
98
|
+
});
|
|
99
|
+
previousPkgs = parsePackageLockfileContent(previousContent);
|
|
100
|
+
} catch {
|
|
101
|
+
// No git, or HEAD has no lockfile → cold start
|
|
102
|
+
previousPkgs = null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const out = [];
|
|
106
|
+
if (!previousPkgs) {
|
|
107
|
+
// Cold start: caller decides how to handle a full lockfile
|
|
108
|
+
for (const [name, version] of currentPkgs) {
|
|
109
|
+
out.push({ name, version, change: 'cold-start' });
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for (const [name, version] of currentPkgs) {
|
|
115
|
+
if (!previousPkgs.has(name)) {
|
|
116
|
+
out.push({ name, version, change: 'added' });
|
|
117
|
+
} else {
|
|
118
|
+
const prev = previousPkgs.get(name);
|
|
119
|
+
if (prev !== version) {
|
|
120
|
+
out.push({ name, version, change: 'bumped', from: prev });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function parsePackageLockfileContent(content) {
|
|
128
|
+
const lock = JSON.parse(content);
|
|
129
|
+
const packages = new Map();
|
|
130
|
+
if (lock.packages) {
|
|
131
|
+
for (const [k, v] of Object.entries(lock.packages)) {
|
|
132
|
+
if (!k || !v?.version) continue;
|
|
133
|
+
const name = k.startsWith('node_modules/') ? k.slice('node_modules/'.length) : k.split('node_modules/').pop();
|
|
134
|
+
if (!name || name === '') continue;
|
|
135
|
+
packages.set(name, v.version);
|
|
136
|
+
}
|
|
137
|
+
} else if (lock.dependencies) {
|
|
138
|
+
function walk(deps) {
|
|
139
|
+
for (const [n, v] of Object.entries(deps)) {
|
|
140
|
+
if (v?.version) packages.set(n, v.version);
|
|
141
|
+
if (v?.dependencies) walk(v.dependencies);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
walk(lock.dependencies);
|
|
145
|
+
}
|
|
146
|
+
return packages;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Score one package's signals into a risk number + reason breakdown.
|
|
151
|
+
*
|
|
152
|
+
* Returns { score, reasons: [{signal, weight, detail}], blocking: bool }
|
|
153
|
+
*
|
|
154
|
+
* Signals (per ADR-0006):
|
|
155
|
+
* - very_recent_publish (<24h) +50
|
|
156
|
+
* - recent_publish (<72h) +30
|
|
157
|
+
* - popular_package (downloads≥10k) +20
|
|
158
|
+
* - maintainer_change_recent (<30d) +40
|
|
159
|
+
* - major_version_jump +15
|
|
160
|
+
* - typo_squat (Lev=1 of popular) +60
|
|
161
|
+
*/
|
|
162
|
+
export function scoreSignals(signals, weeklyDownloads, config = defaultConfig(), context = {}) {
|
|
163
|
+
if (!signals) return { score: 0, reasons: [] };
|
|
164
|
+
const reasons = [];
|
|
165
|
+
let score = 0;
|
|
166
|
+
|
|
167
|
+
const age = signals.publish_age_hours;
|
|
168
|
+
if (age !== null && age !== undefined) {
|
|
169
|
+
if (age < 24) {
|
|
170
|
+
score += 50;
|
|
171
|
+
reasons.push({ signal: 'very_recent_publish', weight: 50, detail: `${age.toFixed(1)}h since publish` });
|
|
172
|
+
} else if (age < config.recent_publish_hours) {
|
|
173
|
+
score += 30;
|
|
174
|
+
reasons.push({ signal: 'recent_publish', weight: 30, detail: `${age.toFixed(1)}h since publish (threshold ${config.recent_publish_hours}h)` });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (typeof weeklyDownloads === 'number' && weeklyDownloads >= config.weekly_downloads_min) {
|
|
179
|
+
score += 20;
|
|
180
|
+
reasons.push({ signal: 'popular_package', weight: 20, detail: `${weeklyDownloads.toLocaleString()} weekly downloads` });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Maintainer-change proxy: package-level "modified" time within 30d
|
|
184
|
+
if (signals.package_modified_age_days !== null && signals.package_modified_age_days !== undefined) {
|
|
185
|
+
if (signals.package_modified_age_days < 30) {
|
|
186
|
+
score += 40;
|
|
187
|
+
reasons.push({
|
|
188
|
+
signal: 'maintainer_change_recent',
|
|
189
|
+
weight: 40,
|
|
190
|
+
detail: `package modified ${signals.package_modified_age_days.toFixed(1)}d ago — possible maintainer/token change`,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Major-version-jump (caller passes context.from from diff)
|
|
196
|
+
if (context.change === 'bumped' && context.from && signals.version) {
|
|
197
|
+
try {
|
|
198
|
+
const fromMajor = parseInt(context.from.match(/\d+/)?.[0] || '0', 10);
|
|
199
|
+
const toMajor = parseInt(signals.version.match(/\d+/)?.[0] || '0', 10);
|
|
200
|
+
if (toMajor - fromMajor >= 1) {
|
|
201
|
+
score += 15;
|
|
202
|
+
reasons.push({
|
|
203
|
+
signal: 'major_version_jump',
|
|
204
|
+
weight: 15,
|
|
205
|
+
detail: `bumped from ${context.from} to ${signals.version}`,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
} catch {
|
|
209
|
+
// skip
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Typo-squat detection (offline, bundled popular list)
|
|
214
|
+
const ts = detectTypoSquat(signals.package);
|
|
215
|
+
if (ts) {
|
|
216
|
+
score += 60;
|
|
217
|
+
reasons.push({ signal: 'typo_squat', weight: 60, detail: `name within Lev=1 of popular "${ts}"` });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return { score, reasons };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function detectTypoSquat(name) {
|
|
224
|
+
if (!name) return null;
|
|
225
|
+
if (POPULAR_PACKAGES.has(name)) return null;
|
|
226
|
+
// Strip scope for comparison; typo-squats often target unscoped popular names
|
|
227
|
+
const bare = name.includes('/') ? name.split('/').pop() : name;
|
|
228
|
+
for (const pop of POPULAR_PACKAGES) {
|
|
229
|
+
const popBare = pop.includes('/') ? pop.split('/').pop() : pop;
|
|
230
|
+
if (Math.abs(bare.length - popBare.length) > 2) continue;
|
|
231
|
+
if (levenshtein(bare, popBare) === 1) return pop;
|
|
232
|
+
}
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function levenshtein(a, b) {
|
|
237
|
+
if (a === b) return 0;
|
|
238
|
+
if (!a.length) return b.length;
|
|
239
|
+
if (!b.length) return a.length;
|
|
240
|
+
const prev = new Array(b.length + 1);
|
|
241
|
+
const curr = new Array(b.length + 1);
|
|
242
|
+
for (let j = 0; j <= b.length; j++) prev[j] = j;
|
|
243
|
+
for (let i = 1; i <= a.length; i++) {
|
|
244
|
+
curr[0] = i;
|
|
245
|
+
for (let j = 1; j <= b.length; j++) {
|
|
246
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
247
|
+
curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
|
|
248
|
+
}
|
|
249
|
+
for (let j = 0; j <= b.length; j++) prev[j] = curr[j];
|
|
250
|
+
}
|
|
251
|
+
return prev[b.length];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function formatHeuristicHit(pkg, version, change, scoring) {
|
|
255
|
+
const lines = [];
|
|
256
|
+
const tag = scoring.score >= 80 ? '[HIGH-RISK]' : scoring.score >= 50 ? '[REVIEW]' : '[INFO]';
|
|
257
|
+
const changeLabel = change === 'bumped' ? `bumped to ${version}` : change === 'added' ? `added@${version}` : `${version}`;
|
|
258
|
+
lines.push(`${tag} ${pkg} ${changeLabel} — risk_score: ${scoring.score}`);
|
|
259
|
+
for (const r of scoring.reasons) {
|
|
260
|
+
lines.push(` · ${r.signal} (+${r.weight}): ${r.detail}`);
|
|
261
|
+
}
|
|
262
|
+
return lines.join('\n');
|
|
263
|
+
}
|
package/src/lib/sc-scanner.mjs
CHANGED
|
@@ -247,6 +247,17 @@ export function matchPackageByName(packageName, advisories) {
|
|
|
247
247
|
return hits;
|
|
248
248
|
}
|
|
249
249
|
|
|
250
|
+
function stripVersionPrefix(v) {
|
|
251
|
+
if (!v || typeof v !== 'string') return null;
|
|
252
|
+
const m = v.match(/(\d+\.\d+\.\d+[\w.\-+]*)/);
|
|
253
|
+
return m ? m[1] : null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function isRangeSpec(v) {
|
|
257
|
+
if (!v || typeof v !== 'string') return false;
|
|
258
|
+
return /^[\^~]|\s|^[<>=]/.test(v.trim());
|
|
259
|
+
}
|
|
260
|
+
|
|
250
261
|
export function matchNamespaceFixture(packages, fixture) {
|
|
251
262
|
const now = new Date();
|
|
252
263
|
const hits = [];
|
|
@@ -254,18 +265,56 @@ export function matchNamespaceFixture(packages, fixture) {
|
|
|
254
265
|
if (entry.active_until && new Date(entry.active_until) < now) continue;
|
|
255
266
|
if (!entry.pattern) continue;
|
|
256
267
|
|
|
257
|
-
for (const [name] of packages) {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
+
for (const [name, version] of packages) {
|
|
269
|
+
const prefixMatch = name.startsWith(entry.pattern) || name === entry.pattern;
|
|
270
|
+
if (!prefixMatch) continue;
|
|
271
|
+
|
|
272
|
+
// Version-aware gate (per ADR-0006):
|
|
273
|
+
// - Concrete version (1.2.3) → strict in-range check; skip on miss (no FP)
|
|
274
|
+
// - Range spec (^1.2.3, ~1.2.3, >=1.2.3) → ambiguity flag with downgrade
|
|
275
|
+
// (resolve-on-install could land in affected range; warn but don't block)
|
|
276
|
+
// - No affected_range → prefix-only, severity downgraded one tier
|
|
277
|
+
let versionCheck = 'no-range';
|
|
278
|
+
let severity = entry.severity || 'high';
|
|
279
|
+
if (entry.affected_range && version) {
|
|
280
|
+
const concrete = stripVersionPrefix(version);
|
|
281
|
+
if (concrete && !isRangeSpec(version)) {
|
|
282
|
+
if (versionInRange(concrete, entry.affected_range)) {
|
|
283
|
+
versionCheck = 'in-range';
|
|
284
|
+
} else {
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
} else if (concrete && isRangeSpec(version)) {
|
|
288
|
+
// package.json range — we don't know which version will resolve.
|
|
289
|
+
// Be cautious: flag with downgrade unless the range's lower bound is
|
|
290
|
+
// already beyond the fixed version (then certainly safe).
|
|
291
|
+
const fixedEvent = (entry.affected_range.events || []).find((e) => e.fixed);
|
|
292
|
+
if (fixedEvent && compareVersions(concrete, fixedEvent.fixed) >= 0) {
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
versionCheck = 'range-ambiguous';
|
|
296
|
+
if (severity === 'critical') severity = 'high';
|
|
297
|
+
} else {
|
|
298
|
+
versionCheck = 'unresolvable-range';
|
|
299
|
+
if (severity === 'critical') severity = 'high';
|
|
300
|
+
else if (severity === 'high') severity = 'medium';
|
|
301
|
+
}
|
|
302
|
+
} else if (!entry.affected_range) {
|
|
303
|
+
versionCheck = 'prefix-only';
|
|
304
|
+
if (severity === 'critical') severity = 'high';
|
|
268
305
|
}
|
|
306
|
+
|
|
307
|
+
hits.push({
|
|
308
|
+
package: name,
|
|
309
|
+
version: version || null,
|
|
310
|
+
namespace_pattern: entry.pattern,
|
|
311
|
+
reason: entry.reason,
|
|
312
|
+
advisory_url: entry.advisory,
|
|
313
|
+
active_until: entry.active_until,
|
|
314
|
+
guidance: entry.guidance,
|
|
315
|
+
severity,
|
|
316
|
+
version_check: versionCheck,
|
|
317
|
+
});
|
|
269
318
|
}
|
|
270
319
|
}
|
|
271
320
|
return hits;
|
package/src/lib/sc-sync.mjs
CHANGED
|
@@ -10,6 +10,13 @@ const OSV_BATCH_ENDPOINT = 'https://api.osv.dev/v1/querybatch';
|
|
|
10
10
|
const STALE_DAYS_DEFAULT = 7;
|
|
11
11
|
const FETCH_TIMEOUT_MS = 15000;
|
|
12
12
|
|
|
13
|
+
// OSV.dev /v1/querybatch hard cap is 1000 entries per request.
|
|
14
|
+
// Concurrency=2 + jittered backoff keeps us polite to a public free-tier API.
|
|
15
|
+
const OSV_BATCH_LIMIT = 1000;
|
|
16
|
+
const OSV_BATCH_CONCURRENCY = 2;
|
|
17
|
+
const OSV_BATCH_RETRY_MAX = 3;
|
|
18
|
+
const OSV_BATCH_RETRY_BASE_MS = 500;
|
|
19
|
+
|
|
13
20
|
export function snapshotPath(rootDir = process.cwd()) {
|
|
14
21
|
return join(rootDir, SECURITY_DIR, SNAPSHOT_FILE);
|
|
15
22
|
}
|
|
@@ -70,13 +77,49 @@ export async function fetchOsvBatch(queries, { timeoutMs = FETCH_TIMEOUT_MS } =
|
|
|
70
77
|
body: JSON.stringify({ queries }),
|
|
71
78
|
signal: controller.signal,
|
|
72
79
|
});
|
|
73
|
-
if (!res.ok)
|
|
80
|
+
if (!res.ok) {
|
|
81
|
+
const err = new Error(`OSV batch HTTP ${res.status}`);
|
|
82
|
+
err.status = res.status;
|
|
83
|
+
err.retryable = res.status === 429 || res.status === 503 || res.status === 502 || res.status === 504;
|
|
84
|
+
throw err;
|
|
85
|
+
}
|
|
74
86
|
return await res.json();
|
|
75
87
|
} finally {
|
|
76
88
|
clearTimeout(timer);
|
|
77
89
|
}
|
|
78
90
|
}
|
|
79
91
|
|
|
92
|
+
function chunkArray(arr, size) {
|
|
93
|
+
const out = [];
|
|
94
|
+
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function sleep(ms) {
|
|
99
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function fetchOsvBatchWithRetry(queries, chunkIndex, { timeoutMs = FETCH_TIMEOUT_MS } = {}) {
|
|
103
|
+
let lastErr;
|
|
104
|
+
for (let attempt = 0; attempt < OSV_BATCH_RETRY_MAX; attempt++) {
|
|
105
|
+
try {
|
|
106
|
+
return await fetchOsvBatch(queries, { timeoutMs });
|
|
107
|
+
} catch (e) {
|
|
108
|
+
lastErr = e;
|
|
109
|
+
if (!e.retryable || attempt === OSV_BATCH_RETRY_MAX - 1) break;
|
|
110
|
+
const jitter = Math.floor(Math.random() * 200);
|
|
111
|
+
const delay = OSV_BATCH_RETRY_BASE_MS * Math.pow(2, attempt) + jitter;
|
|
112
|
+
await sleep(delay);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// Wrap with chunk diagnostics for the synthesis layer
|
|
116
|
+
const wrapped = new Error(`OSV batch chunk ${chunkIndex} failed: ${lastErr.message}`);
|
|
117
|
+
wrapped.cause = lastErr;
|
|
118
|
+
wrapped.chunkIndex = chunkIndex;
|
|
119
|
+
wrapped.retryable = lastErr.retryable;
|
|
120
|
+
throw wrapped;
|
|
121
|
+
}
|
|
122
|
+
|
|
80
123
|
export async function fetchOsvByName(packageName, ecosystem = 'npm', { timeoutMs = FETCH_TIMEOUT_MS } = {}) {
|
|
81
124
|
const controller = new AbortController();
|
|
82
125
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
@@ -125,19 +168,48 @@ export async function syncSnapshotForProject(rootDir = process.cwd(), { ecosyste
|
|
|
125
168
|
if (queries.length === 0) {
|
|
126
169
|
const empty = buildEmptySnapshot(ecosystem);
|
|
127
170
|
saveSnapshot(empty, rootDir);
|
|
128
|
-
return { snapshot: empty, advisoryCount: 0, packageCount: 0 };
|
|
171
|
+
return { snapshot: empty, advisoryCount: 0, packageCount: 0, partial: false, chunks: { total: 0, succeeded: 0, failed: 0 } };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const chunks = chunkArray(queries, OSV_BATCH_LIMIT);
|
|
175
|
+
const chunkOutcomes = [];
|
|
176
|
+
// Process with bounded concurrency. allSettled so a single chunk failure
|
|
177
|
+
// does not discard sibling successes — critical for fail-soft behavior.
|
|
178
|
+
for (let i = 0; i < chunks.length; i += OSV_BATCH_CONCURRENCY) {
|
|
179
|
+
const slice = chunks.slice(i, i + OSV_BATCH_CONCURRENCY);
|
|
180
|
+
const settled = await Promise.allSettled(
|
|
181
|
+
slice.map((chunk, j) => fetchOsvBatchWithRetry(chunk, i + j))
|
|
182
|
+
);
|
|
183
|
+
for (let j = 0; j < settled.length; j++) {
|
|
184
|
+
chunkOutcomes.push({ index: i + j, ...settled[j] });
|
|
185
|
+
}
|
|
129
186
|
}
|
|
130
187
|
|
|
131
|
-
const batchResults = await fetchOsvBatch(queries);
|
|
132
188
|
const vulnIds = new Set();
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
189
|
+
const failedChunks = [];
|
|
190
|
+
for (const outcome of chunkOutcomes) {
|
|
191
|
+
if (outcome.status === 'fulfilled') {
|
|
192
|
+
const batchResults = outcome.value;
|
|
193
|
+
if (batchResults && Array.isArray(batchResults.results)) {
|
|
194
|
+
for (const r of batchResults.results) {
|
|
195
|
+
if (r && Array.isArray(r.vulns)) {
|
|
196
|
+
for (const v of r.vulns) if (v.id) vulnIds.add(v.id);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
137
199
|
}
|
|
200
|
+
} else {
|
|
201
|
+
failedChunks.push({ index: outcome.index, message: outcome.reason?.message || String(outcome.reason) });
|
|
138
202
|
}
|
|
139
203
|
}
|
|
140
204
|
|
|
205
|
+
// Hard fail only when ALL chunks failed — otherwise emit partial snapshot.
|
|
206
|
+
if (failedChunks.length === chunkOutcomes.length) {
|
|
207
|
+
const err = new Error(`All ${chunkOutcomes.length} OSV batch chunk(s) failed; first: ${failedChunks[0].message}`);
|
|
208
|
+
err.code = 'SYNC_ALL_CHUNKS_FAILED';
|
|
209
|
+
err.failedChunks = failedChunks;
|
|
210
|
+
throw err;
|
|
211
|
+
}
|
|
212
|
+
|
|
141
213
|
const advisories = [];
|
|
142
214
|
for (const id of vulnIds) {
|
|
143
215
|
try {
|
|
@@ -148,6 +220,7 @@ export async function syncSnapshotForProject(rootDir = process.cwd(), { ecosyste
|
|
|
148
220
|
}
|
|
149
221
|
}
|
|
150
222
|
|
|
223
|
+
const partial = failedChunks.length > 0;
|
|
151
224
|
const snapshot = {
|
|
152
225
|
schema_version: SCHEMA_VERSION,
|
|
153
226
|
fetched_at: new Date().toISOString(),
|
|
@@ -156,10 +229,23 @@ export async function syncSnapshotForProject(rootDir = process.cwd(), { ecosyste
|
|
|
156
229
|
package_count: queryIndex.length,
|
|
157
230
|
advisory_count: advisories.length,
|
|
158
231
|
advisories,
|
|
232
|
+
partial,
|
|
233
|
+
chunks: {
|
|
234
|
+
total: chunkOutcomes.length,
|
|
235
|
+
succeeded: chunkOutcomes.length - failedChunks.length,
|
|
236
|
+
failed: failedChunks.length,
|
|
237
|
+
failed_indices: failedChunks.map((c) => c.index),
|
|
238
|
+
},
|
|
159
239
|
};
|
|
160
240
|
|
|
161
241
|
saveSnapshot(snapshot, rootDir);
|
|
162
|
-
return {
|
|
242
|
+
return {
|
|
243
|
+
snapshot,
|
|
244
|
+
advisoryCount: advisories.length,
|
|
245
|
+
packageCount: queryIndex.length,
|
|
246
|
+
partial,
|
|
247
|
+
chunks: snapshot.chunks,
|
|
248
|
+
};
|
|
163
249
|
}
|
|
164
250
|
|
|
165
251
|
function buildEmptySnapshot(ecosystem) {
|
|
@@ -171,6 +257,8 @@ function buildEmptySnapshot(ecosystem) {
|
|
|
171
257
|
package_count: 0,
|
|
172
258
|
advisory_count: 0,
|
|
173
259
|
advisories: [],
|
|
260
|
+
partial: false,
|
|
261
|
+
chunks: { total: 0, succeeded: 0, failed: 0, failed_indices: [] },
|
|
174
262
|
};
|
|
175
263
|
}
|
|
176
264
|
|
|
@@ -194,5 +282,7 @@ export function snapshotInfo(rootDir = process.cwd()) {
|
|
|
194
282
|
age_days: snap ? snapshotAgeDays(snap) : Infinity,
|
|
195
283
|
stale: snap ? isStale(snap) : true,
|
|
196
284
|
schema_compatible: isSchemaCompatible(snap),
|
|
285
|
+
partial: snap?.partial === true,
|
|
286
|
+
chunks: snap?.chunks || null,
|
|
197
287
|
};
|
|
198
288
|
}
|