dw-kit 1.3.0 → 1.3.5
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 +102 -0
- package/.claude/rules/dw.md +2 -0
- package/.claude/settings.json +13 -1
- package/.claude/skills/dw-execute/SKILL.md +30 -7
- package/.claude/skills/dw-handoff/SKILL.md +14 -3
- package/.claude/skills/dw-plan/SKILL.md +103 -6
- package/.claude/skills/dw-research/SKILL.md +18 -4
- package/.claude/skills/dw-retroactive/SKILL.md +84 -200
- package/.claude/skills/dw-task-init/SKILL.md +45 -33
- package/.dw/core/ROLES.md +257 -257
- package/.dw/security/ioc-namespaces.json +40 -0
- package/CLAUDE.md +3 -1
- package/MIGRATION-v1.3.md +5 -4
- package/README.md +14 -2
- package/package.json +2 -1
- package/src/cli.mjs +27 -0
- package/src/commands/doctor.mjs +21 -0
- package/src/commands/init.mjs +45 -2
- package/src/commands/metrics.mjs +21 -1
- package/src/commands/security-scan.mjs +427 -0
- package/src/commands/upgrade.mjs +54 -0
- package/src/lib/cut-analysis.mjs +79 -0
- package/src/lib/gitignore.mjs +86 -0
- package/src/lib/sc-install.mjs +93 -0
- package/src/lib/sc-scanner.mjs +272 -0
- package/src/lib/sc-sync.mjs +198 -0
- package/src/lib/telemetry.mjs +7 -0
package/src/lib/cut-analysis.mjs
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
// Cut Criteria Matrix — implements ADR-0001 v1.4 decision gate.
|
|
2
2
|
// Consumes telemetry events, emits cut candidates with evidence.
|
|
3
3
|
|
|
4
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
4
7
|
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
5
8
|
|
|
9
|
+
// Task doc thresholds — invalidation triggers for 3→2 file consolidation (ADR-0001)
|
|
10
|
+
export const TASK_DOC_THRESHOLDS = {
|
|
11
|
+
trackingLinesWarn: 400, // avg tracking.md lines → reopen 3→2 decision
|
|
12
|
+
extraFilesPctWarn: 30, // % tasks with ≥3 md files → tasks re-growing structure
|
|
13
|
+
};
|
|
14
|
+
|
|
6
15
|
// Never-cut: skills that are the workflow's load-bearing verbs.
|
|
7
16
|
export const CRITICAL_SKILLS = new Set([
|
|
8
17
|
'dw:flow', 'dw:task-init', 'dw:commit', 'dw:handoff',
|
|
@@ -133,6 +142,76 @@ function evaluateHook(name, events, totalSessions) {
|
|
|
133
142
|
return { name, type: 'hook', qualify, stats: s, reasons };
|
|
134
143
|
}
|
|
135
144
|
|
|
145
|
+
export function analyzeTaskDocs(rootDir = process.cwd()) {
|
|
146
|
+
const tasksDir = join(rootDir, '.dw', 'tasks');
|
|
147
|
+
if (!existsSync(tasksDir)) return null;
|
|
148
|
+
|
|
149
|
+
const entries = readdirSync(tasksDir).filter((name) => {
|
|
150
|
+
if (name === 'archive' || name === 'ACTIVE.md') return false;
|
|
151
|
+
try {
|
|
152
|
+
return statSync(join(tasksDir, name)).isDirectory();
|
|
153
|
+
} catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const tasks = [];
|
|
159
|
+
for (const name of entries) {
|
|
160
|
+
const dir = join(tasksDir, name);
|
|
161
|
+
let mdFiles;
|
|
162
|
+
try {
|
|
163
|
+
mdFiles = readdirSync(dir).filter((f) => f.endsWith('.md'));
|
|
164
|
+
} catch {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const hasSpec = mdFiles.includes('spec.md');
|
|
168
|
+
const hasTracking = mdFiles.includes('tracking.md');
|
|
169
|
+
const format = hasSpec && hasTracking ? 'v2' : mdFiles.some((f) => f.endsWith('-progress.md')) ? 'v1' : 'unknown';
|
|
170
|
+
|
|
171
|
+
let trackingLines = 0;
|
|
172
|
+
if (hasTracking) {
|
|
173
|
+
try {
|
|
174
|
+
trackingLines = readFileSync(join(dir, 'tracking.md'), 'utf8').split('\n').length;
|
|
175
|
+
} catch {
|
|
176
|
+
/* skip */
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
tasks.push({ name, format, mdFileCount: mdFiles.length, trackingLines });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const v2 = tasks.filter((t) => t.format === 'v2');
|
|
184
|
+
const avgTrackingLines =
|
|
185
|
+
v2.length > 0 ? Math.round(v2.reduce((s, t) => s + t.trackingLines, 0) / v2.length) : 0;
|
|
186
|
+
const maxTrackingLines = v2.length > 0 ? Math.max(...v2.map((t) => t.trackingLines)) : 0;
|
|
187
|
+
const extraFiles = tasks.filter((t) => t.mdFileCount >= 3);
|
|
188
|
+
const extraFilesPct = tasks.length > 0 ? Math.round((extraFiles.length / tasks.length) * 100) : 0;
|
|
189
|
+
|
|
190
|
+
const triggers = [];
|
|
191
|
+
if (avgTrackingLines > TASK_DOC_THRESHOLDS.trackingLinesWarn) {
|
|
192
|
+
triggers.push(
|
|
193
|
+
`avg_tracking_lines=${avgTrackingLines} > ${TASK_DOC_THRESHOLDS.trackingLinesWarn} — tracking.md phình → reopen 3→2 decision (ADR-0001)`
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
if (extraFilesPct > TASK_DOC_THRESHOLDS.extraFilesPctWarn) {
|
|
197
|
+
triggers.push(
|
|
198
|
+
`${extraFilesPct}% tasks have ≥3 md files > ${TASK_DOC_THRESHOLDS.extraFilesPctWarn}% — structure re-growing → reopen 3→2 decision (ADR-0001)`
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
totalTasks: tasks.length,
|
|
204
|
+
v2Count: v2.length,
|
|
205
|
+
v1Count: tasks.filter((t) => t.format === 'v1').length,
|
|
206
|
+
avgTrackingLines,
|
|
207
|
+
maxTrackingLines,
|
|
208
|
+
extraFilesCount: extraFiles.length,
|
|
209
|
+
extraFilesPct,
|
|
210
|
+
triggers,
|
|
211
|
+
tasks,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
136
215
|
export function analyze(events) {
|
|
137
216
|
const totalSessions = uniqueSessions(events);
|
|
138
217
|
const totalDays = coverageDays(events);
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const MARKER_START = '# >>> dw-kit managed >>>';
|
|
5
|
+
const MARKER_END = '# <<< dw-kit managed <<<';
|
|
6
|
+
|
|
7
|
+
const DW_GITIGNORE_BLOCK = [
|
|
8
|
+
MARKER_START,
|
|
9
|
+
'# dw-kit framework files — regenerated by `dw init` / `dw upgrade`.',
|
|
10
|
+
'# Do NOT commit. Update dw-kit regularly via `dw upgrade`.',
|
|
11
|
+
'adapters/',
|
|
12
|
+
'core/',
|
|
13
|
+
'security/',
|
|
14
|
+
'',
|
|
15
|
+
'# Config directory: ignore framework files, keep user dw.config.yml tracked',
|
|
16
|
+
'config/*',
|
|
17
|
+
'!config/dw.config.yml',
|
|
18
|
+
'!config/.gitignore',
|
|
19
|
+
'config/dw.config.local.yml',
|
|
20
|
+
'',
|
|
21
|
+
'# Local-only telemetry (machine-specific, has session hashes)',
|
|
22
|
+
'metrics/',
|
|
23
|
+
MARKER_END,
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const CLAUDE_GITIGNORE_BLOCK = [
|
|
27
|
+
MARKER_START,
|
|
28
|
+
'# dw-kit framework files — regenerated by `dw init` / `dw upgrade`.',
|
|
29
|
+
'# Do NOT commit. Update dw-kit regularly via `dw upgrade`.',
|
|
30
|
+
'agents/',
|
|
31
|
+
'hooks/',
|
|
32
|
+
'rules/',
|
|
33
|
+
'skills/',
|
|
34
|
+
'templates/',
|
|
35
|
+
'',
|
|
36
|
+
'# Local-only override (also in root .gitignore for safety)',
|
|
37
|
+
'settings.local.json',
|
|
38
|
+
MARKER_END,
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
function ensureDir(filepath) {
|
|
42
|
+
const dir = dirname(filepath);
|
|
43
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function applyManagedBlock(content, blockLines) {
|
|
47
|
+
const block = blockLines.join('\n') + '\n';
|
|
48
|
+
if (!content) return block;
|
|
49
|
+
|
|
50
|
+
// If markers already present, replace block in-place (idempotent + upgrade-friendly)
|
|
51
|
+
const startIdx = content.indexOf(MARKER_START);
|
|
52
|
+
const endIdx = content.indexOf(MARKER_END);
|
|
53
|
+
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
|
|
54
|
+
const before = content.slice(0, startIdx);
|
|
55
|
+
const after = content.slice(endIdx + MARKER_END.length);
|
|
56
|
+
return (before + block + after).replace(/\n{3,}/g, '\n\n');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Markers not present — append block
|
|
60
|
+
const sep = content.endsWith('\n') ? '\n' : '\n\n';
|
|
61
|
+
return content + sep + block;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function writeGitignore(targetPath, blockLines) {
|
|
65
|
+
ensureDir(targetPath);
|
|
66
|
+
const current = existsSync(targetPath) ? readFileSync(targetPath, 'utf-8') : '';
|
|
67
|
+
const updated = applyManagedBlock(current, blockLines);
|
|
68
|
+
if (updated === current) return { action: 'noop', path: targetPath };
|
|
69
|
+
writeFileSync(targetPath, updated, 'utf-8');
|
|
70
|
+
return { action: current ? 'updated' : 'created', path: targetPath };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function ensureDwGitignore(projectDir = process.cwd()) {
|
|
74
|
+
return writeGitignore(join(projectDir, '.dw', '.gitignore'), DW_GITIGNORE_BLOCK);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function ensureClaudeGitignore(projectDir = process.cwd()) {
|
|
78
|
+
return writeGitignore(join(projectDir, '.claude', '.gitignore'), CLAUDE_GITIGNORE_BLOCK);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function ensureBothGitignores(projectDir = process.cwd()) {
|
|
82
|
+
return {
|
|
83
|
+
dw: ensureDwGitignore(projectDir),
|
|
84
|
+
claude: ensureClaudeGitignore(projectDir),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const HOOK_COMMAND = 'bash "$CLAUDE_PROJECT_DIR/.claude/hooks/supply-chain-scan.sh"';
|
|
5
|
+
const MATCHER = 'Write|Edit';
|
|
6
|
+
|
|
7
|
+
export function settingsPath(rootDir = process.cwd()) {
|
|
8
|
+
return join(rootDir, '.claude', 'settings.json');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function isHookWired(settings) {
|
|
12
|
+
if (!settings?.hooks?.PostToolUse) return false;
|
|
13
|
+
for (const group of settings.hooks.PostToolUse) {
|
|
14
|
+
if (group.matcher !== MATCHER && !(group.matcher || '').split('|').includes('Write')) continue;
|
|
15
|
+
if (!Array.isArray(group.hooks)) continue;
|
|
16
|
+
for (const h of group.hooks) {
|
|
17
|
+
if (h.command && h.command.includes('supply-chain-scan.sh')) return true;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function wireHook(settings) {
|
|
24
|
+
settings.hooks = settings.hooks || {};
|
|
25
|
+
settings.hooks.PostToolUse = settings.hooks.PostToolUse || [];
|
|
26
|
+
|
|
27
|
+
let group = settings.hooks.PostToolUse.find((g) => g.matcher === MATCHER);
|
|
28
|
+
if (!group) {
|
|
29
|
+
group = { matcher: MATCHER, hooks: [] };
|
|
30
|
+
settings.hooks.PostToolUse.push(group);
|
|
31
|
+
}
|
|
32
|
+
group.hooks = group.hooks || [];
|
|
33
|
+
|
|
34
|
+
for (const h of group.hooks) {
|
|
35
|
+
if (h.command && h.command.includes('supply-chain-scan.sh')) {
|
|
36
|
+
return { action: 'noop', reason: 'already wired' };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
group.hooks.push({ type: 'command', command: HOOK_COMMAND });
|
|
41
|
+
return { action: 'added' };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function unwireHook(settings) {
|
|
45
|
+
if (!settings?.hooks?.PostToolUse) return { action: 'noop', reason: 'no PostToolUse' };
|
|
46
|
+
|
|
47
|
+
let removed = 0;
|
|
48
|
+
for (const group of settings.hooks.PostToolUse) {
|
|
49
|
+
if (!Array.isArray(group.hooks)) continue;
|
|
50
|
+
const before = group.hooks.length;
|
|
51
|
+
group.hooks = group.hooks.filter((h) => !(h.command && h.command.includes('supply-chain-scan.sh')));
|
|
52
|
+
removed += before - group.hooks.length;
|
|
53
|
+
}
|
|
54
|
+
return removed > 0 ? { action: 'removed', count: removed } : { action: 'noop', reason: 'not wired' };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function installHookInProject(rootDir = process.cwd()) {
|
|
58
|
+
const p = settingsPath(rootDir);
|
|
59
|
+
if (!existsSync(p)) {
|
|
60
|
+
return { ok: false, error: 'settings.json not found — run `dw init` first', path: p };
|
|
61
|
+
}
|
|
62
|
+
let settings;
|
|
63
|
+
try {
|
|
64
|
+
settings = JSON.parse(readFileSync(p, 'utf-8'));
|
|
65
|
+
} catch (e) {
|
|
66
|
+
return { ok: false, error: `failed to parse settings.json: ${e.message}`, path: p };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const result = wireHook(settings);
|
|
70
|
+
if (result.action === 'added') {
|
|
71
|
+
writeFileSync(p, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
|
72
|
+
}
|
|
73
|
+
return { ok: true, ...result, path: p };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function uninstallHookFromProject(rootDir = process.cwd()) {
|
|
77
|
+
const p = settingsPath(rootDir);
|
|
78
|
+
if (!existsSync(p)) {
|
|
79
|
+
return { ok: false, error: 'settings.json not found', path: p };
|
|
80
|
+
}
|
|
81
|
+
let settings;
|
|
82
|
+
try {
|
|
83
|
+
settings = JSON.parse(readFileSync(p, 'utf-8'));
|
|
84
|
+
} catch (e) {
|
|
85
|
+
return { ok: false, error: `failed to parse settings.json: ${e.message}`, path: p };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const result = unwireHook(settings);
|
|
89
|
+
if (result.action === 'removed') {
|
|
90
|
+
writeFileSync(p, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
|
91
|
+
}
|
|
92
|
+
return { ok: true, ...result, path: p };
|
|
93
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const LOCKFILE_NAMES = ['package-lock.json', 'npm-shrinkwrap.json'];
|
|
5
|
+
|
|
6
|
+
export function findLockfile(rootDir = process.cwd()) {
|
|
7
|
+
for (const name of LOCKFILE_NAMES) {
|
|
8
|
+
const p = join(rootDir, name);
|
|
9
|
+
if (existsSync(p)) return p;
|
|
10
|
+
}
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function parsePackageLockfile(filepath) {
|
|
15
|
+
const raw = readFileSync(filepath, 'utf-8');
|
|
16
|
+
const data = JSON.parse(raw);
|
|
17
|
+
|
|
18
|
+
const out = new Map();
|
|
19
|
+
|
|
20
|
+
if (data.packages && typeof data.packages === 'object') {
|
|
21
|
+
for (const [key, info] of Object.entries(data.packages)) {
|
|
22
|
+
if (!key || key === '') continue;
|
|
23
|
+
if (!info || !info.version) continue;
|
|
24
|
+
|
|
25
|
+
const name = info.name || key.replace(/^node_modules\//, '').replace(/.*\/node_modules\//, '');
|
|
26
|
+
if (!name) continue;
|
|
27
|
+
if (!out.has(name)) out.set(name, info.version);
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (data.dependencies && typeof data.dependencies === 'object') {
|
|
33
|
+
walkDeps(data.dependencies, out);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function walkDeps(deps, out) {
|
|
40
|
+
for (const [name, info] of Object.entries(deps)) {
|
|
41
|
+
if (info && info.version && !out.has(name)) {
|
|
42
|
+
out.set(name, info.version);
|
|
43
|
+
}
|
|
44
|
+
if (info && info.dependencies) walkDeps(info.dependencies, out);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function compareVersions(a, b) {
|
|
49
|
+
const norm = (v) => String(v).split('-')[0].split('+')[0].split('.').map((s) => parseInt(s, 10) || 0);
|
|
50
|
+
const pa = norm(a);
|
|
51
|
+
const pb = norm(b);
|
|
52
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
53
|
+
const x = pa[i] || 0;
|
|
54
|
+
const y = pb[i] || 0;
|
|
55
|
+
if (x !== y) return x - y;
|
|
56
|
+
}
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function versionInRange(version, range) {
|
|
61
|
+
if (!range || !range.events || !Array.isArray(range.events)) return false;
|
|
62
|
+
|
|
63
|
+
let affected = false;
|
|
64
|
+
let lastIntroduced = null;
|
|
65
|
+
let lastFixed = null;
|
|
66
|
+
|
|
67
|
+
const events = range.events.slice().sort((e1, e2) => {
|
|
68
|
+
const v1 = e1.introduced || e1.fixed || e1.last_affected || '0.0.0';
|
|
69
|
+
const v2 = e2.introduced || e2.fixed || e2.last_affected || '0.0.0';
|
|
70
|
+
return compareVersions(v1, v2);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
for (const evt of events) {
|
|
74
|
+
if (evt.introduced !== undefined) lastIntroduced = evt.introduced;
|
|
75
|
+
if (evt.fixed !== undefined) lastFixed = evt.fixed;
|
|
76
|
+
if (evt.last_affected !== undefined) lastFixed = evt.last_affected;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (lastIntroduced !== null && compareVersions(version, lastIntroduced) >= 0) {
|
|
80
|
+
affected = true;
|
|
81
|
+
}
|
|
82
|
+
if (affected && lastFixed !== null && compareVersions(version, lastFixed) >= 0) {
|
|
83
|
+
if (range.type === 'SEMVER' || range.type === 'ECOSYSTEM') affected = false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return affected;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function matchAdvisory(packageName, version, advisory) {
|
|
90
|
+
if (!advisory || !advisory.affected) return null;
|
|
91
|
+
|
|
92
|
+
for (const aff of advisory.affected) {
|
|
93
|
+
const affName = aff.package?.name || aff.package_name || aff.name;
|
|
94
|
+
if (affName !== packageName) continue;
|
|
95
|
+
|
|
96
|
+
if (Array.isArray(aff.versions) && aff.versions.includes(version)) {
|
|
97
|
+
return buildMatch(packageName, version, advisory, aff);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (Array.isArray(aff.ranges)) {
|
|
101
|
+
for (const range of aff.ranges) {
|
|
102
|
+
if (versionInRange(version, range)) {
|
|
103
|
+
return buildMatch(packageName, version, advisory, aff);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function buildMatch(packageName, version, advisory, aff) {
|
|
112
|
+
const fixVersions = [];
|
|
113
|
+
if (Array.isArray(aff.ranges)) {
|
|
114
|
+
for (const range of aff.ranges) {
|
|
115
|
+
if (Array.isArray(range.events)) {
|
|
116
|
+
for (const evt of range.events) {
|
|
117
|
+
if (evt.fixed) fixVersions.push(evt.fixed);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const severity = pickSeverity(advisory);
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
package: packageName,
|
|
127
|
+
version,
|
|
128
|
+
advisory_id: advisory.id,
|
|
129
|
+
summary: advisory.summary || '',
|
|
130
|
+
severity,
|
|
131
|
+
fix_versions: fixVersions,
|
|
132
|
+
references: (advisory.references || []).map((r) => r.url || r).filter(Boolean),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function pickSeverity(advisory) {
|
|
137
|
+
if (Array.isArray(advisory.severity) && advisory.severity.length > 0) {
|
|
138
|
+
const cvss = advisory.severity.find((s) => s.type && s.type.startsWith('CVSS'));
|
|
139
|
+
if (cvss && cvss.score) return cvssToLabel(cvss.score);
|
|
140
|
+
}
|
|
141
|
+
if (advisory.database_specific?.severity) return String(advisory.database_specific.severity).toLowerCase();
|
|
142
|
+
return 'unknown';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function cvssToLabel(score) {
|
|
146
|
+
if (typeof score === 'string') {
|
|
147
|
+
const m = score.match(/\d+(\.\d+)?/);
|
|
148
|
+
if (m) score = parseFloat(m[0]);
|
|
149
|
+
}
|
|
150
|
+
if (typeof score !== 'number') return 'unknown';
|
|
151
|
+
if (score >= 9.0) return 'critical';
|
|
152
|
+
if (score >= 7.0) return 'high';
|
|
153
|
+
if (score >= 4.0) return 'medium';
|
|
154
|
+
if (score > 0) return 'low';
|
|
155
|
+
return 'unknown';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function scanProject(rootDir, snapshot) {
|
|
159
|
+
const result = {
|
|
160
|
+
lockfile: null,
|
|
161
|
+
packages_scanned: 0,
|
|
162
|
+
matches: [],
|
|
163
|
+
snapshot_meta: snapshot ? { fetched_at: snapshot.fetched_at, source: snapshot.source } : null,
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const lockPath = findLockfile(rootDir);
|
|
167
|
+
if (!lockPath) {
|
|
168
|
+
result.error = 'no_lockfile';
|
|
169
|
+
return result;
|
|
170
|
+
}
|
|
171
|
+
result.lockfile = lockPath;
|
|
172
|
+
|
|
173
|
+
if (!snapshot || !Array.isArray(snapshot.advisories)) {
|
|
174
|
+
result.error = 'no_snapshot';
|
|
175
|
+
return result;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const packages = parsePackageLockfile(lockPath);
|
|
179
|
+
result.packages_scanned = packages.size;
|
|
180
|
+
|
|
181
|
+
for (const [name, version] of packages) {
|
|
182
|
+
for (const adv of snapshot.advisories) {
|
|
183
|
+
const m = matchAdvisory(name, version, adv);
|
|
184
|
+
if (m) result.matches.push(m);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return result;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function severityRank(label) {
|
|
192
|
+
return { critical: 4, high: 3, medium: 2, low: 1, unknown: 0 }[label] || 0;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function worstSeverity(matches) {
|
|
196
|
+
if (!matches || matches.length === 0) return null;
|
|
197
|
+
return matches.reduce((acc, m) => (severityRank(m.severity) > severityRank(acc) ? m.severity : acc), 'unknown');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ── Pre-install scan helpers (package.json without lockfile) ────────────────
|
|
201
|
+
|
|
202
|
+
export function parsePackageJson(filepath) {
|
|
203
|
+
const raw = readFileSync(filepath, 'utf-8');
|
|
204
|
+
const data = JSON.parse(raw);
|
|
205
|
+
const out = new Map();
|
|
206
|
+
for (const section of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
|
|
207
|
+
if (data[section] && typeof data[section] === 'object') {
|
|
208
|
+
for (const [name, range] of Object.entries(data[section])) {
|
|
209
|
+
if (typeof range === 'string' && !out.has(name)) out.set(name, range);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return out;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function findPackageJson(rootDir = process.cwd()) {
|
|
217
|
+
const p = join(rootDir, 'package.json');
|
|
218
|
+
return existsSync(p) ? p : null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function matchPackageByName(packageName, advisories) {
|
|
222
|
+
const hits = [];
|
|
223
|
+
for (const adv of advisories || []) {
|
|
224
|
+
if (!adv.affected) continue;
|
|
225
|
+
for (const aff of adv.affected) {
|
|
226
|
+
const affName = aff.package?.name || aff.package_name || aff.name;
|
|
227
|
+
if (affName !== packageName) continue;
|
|
228
|
+
|
|
229
|
+
const fixVersions = [];
|
|
230
|
+
if (Array.isArray(aff.ranges)) {
|
|
231
|
+
for (const range of aff.ranges) {
|
|
232
|
+
if (Array.isArray(range.events)) {
|
|
233
|
+
for (const evt of range.events) if (evt.fixed) fixVersions.push(evt.fixed);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
hits.push({
|
|
238
|
+
advisory_id: adv.id,
|
|
239
|
+
summary: adv.summary || '',
|
|
240
|
+
severity: pickSeverity(adv),
|
|
241
|
+
fix_versions: fixVersions,
|
|
242
|
+
references: (adv.references || []).map((r) => r.url || r).filter(Boolean),
|
|
243
|
+
});
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return hits;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function matchNamespaceFixture(packages, fixture) {
|
|
251
|
+
const now = new Date();
|
|
252
|
+
const hits = [];
|
|
253
|
+
for (const entry of fixture?.namespaces || []) {
|
|
254
|
+
if (entry.active_until && new Date(entry.active_until) < now) continue;
|
|
255
|
+
if (!entry.pattern) continue;
|
|
256
|
+
|
|
257
|
+
for (const [name] of packages) {
|
|
258
|
+
if (name.startsWith(entry.pattern) || name === entry.pattern) {
|
|
259
|
+
hits.push({
|
|
260
|
+
package: name,
|
|
261
|
+
namespace_pattern: entry.pattern,
|
|
262
|
+
reason: entry.reason,
|
|
263
|
+
advisory_url: entry.advisory,
|
|
264
|
+
active_until: entry.active_until,
|
|
265
|
+
guidance: entry.guidance,
|
|
266
|
+
severity: entry.severity || 'high',
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return hits;
|
|
272
|
+
}
|