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/cli.mjs
CHANGED
|
@@ -101,6 +101,33 @@ export function run(argv) {
|
|
|
101
101
|
await dashboardCommand(opts);
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
+
program
|
|
105
|
+
.command('security-scan')
|
|
106
|
+
.description('Scan project lockfile against advisory snapshot (OSV.dev). Supply-chain guard (ADR-0005, opt-in).')
|
|
107
|
+
.option('--quick', 'Offline mode — use existing snapshot only (default behavior)')
|
|
108
|
+
.option('--update-db', 'Fetch fresh advisory snapshot from OSV.dev before scanning')
|
|
109
|
+
.option('--pre-install', 'Scan package.json without lockfile (OSV name-only + namespace fixture)')
|
|
110
|
+
.option('--offline', 'Skip network in --pre-install mode (fixture-only)')
|
|
111
|
+
.option('--json', 'Output machine-readable JSON')
|
|
112
|
+
.option('--install-hook', 'Wire supply-chain-scan.sh into .claude/settings.json PostToolUse (idempotent)')
|
|
113
|
+
.option('--uninstall-hook', 'Remove supply-chain-scan.sh entry from .claude/settings.json')
|
|
114
|
+
.action(async (opts) => {
|
|
115
|
+
if (opts.installHook || opts.uninstallHook) {
|
|
116
|
+
const { installHookInProject, uninstallHookFromProject } = await import('./lib/sc-install.mjs');
|
|
117
|
+
const r = opts.uninstallHook ? uninstallHookFromProject() : installHookInProject();
|
|
118
|
+
if (!r.ok) {
|
|
119
|
+
console.error(chalk.red(`✗ ${r.error}`));
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
if (r.action === 'added') console.log(chalk.green(`✓ Hook wired into ${r.path}`));
|
|
123
|
+
else if (r.action === 'removed') console.log(chalk.green(`✓ Removed ${r.count} entry from ${r.path}`));
|
|
124
|
+
else console.log(chalk.dim(` (no-op: ${r.reason})`));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const { securityScanCommand } = await import('./commands/security-scan.mjs');
|
|
128
|
+
await securityScanCommand(opts);
|
|
129
|
+
});
|
|
130
|
+
|
|
104
131
|
program
|
|
105
132
|
.command('claude-vn-fix')
|
|
106
133
|
.description('Patch Claude CLI to fix Vietnamese IME (local, with backup/restore)')
|
package/src/commands/doctor.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
4
4
|
import { header, ok, warn, err, info, log } from '../lib/ui.mjs';
|
|
5
5
|
import { loadConfig, getToolkitVersions } from '../lib/config.mjs';
|
|
6
6
|
import { detectPlatform, platformLabel } from '../lib/platform.mjs';
|
|
7
|
+
import { snapshotInfo } from '../lib/sc-sync.mjs';
|
|
7
8
|
|
|
8
9
|
const TOOLKIT_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..', '..');
|
|
9
10
|
|
|
@@ -150,6 +151,26 @@ export async function doctorCommand() {
|
|
|
150
151
|
}
|
|
151
152
|
}
|
|
152
153
|
|
|
154
|
+
info('Supply-Chain Guard (ADR-0005, opt-in)');
|
|
155
|
+
const sc = snapshotInfo(projectDir);
|
|
156
|
+
if (!sc.exists) {
|
|
157
|
+
log(' Advisory snapshot — not yet created');
|
|
158
|
+
log(' Run `dw security-scan --update-db` to fetch from OSV.dev (opt-in feature)');
|
|
159
|
+
} else {
|
|
160
|
+
if (!sc.schema_compatible) {
|
|
161
|
+
err(`Advisory snapshot schema mismatch — expected 1.0, got ${sc.schema_version || 'unknown'}`);
|
|
162
|
+
log(' Run `dw security-scan --update-db` to refresh');
|
|
163
|
+
issues++;
|
|
164
|
+
} else if (sc.stale) {
|
|
165
|
+
warn(`Advisory snapshot stale: ${sc.age_days.toFixed(1)} days old (>7d threshold)`);
|
|
166
|
+
log(` Source: ${sc.source} (${sc.ecosystem}), advisories=${sc.advisory_count}`);
|
|
167
|
+
log(' Run `dw security-scan --update-db` to refresh');
|
|
168
|
+
warnings++;
|
|
169
|
+
} else {
|
|
170
|
+
ok(`Advisory snapshot — ${sc.age_days.toFixed(1)}d old, ${sc.advisory_count} advisories (${sc.source})`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
153
174
|
console.log();
|
|
154
175
|
header('Diagnosis');
|
|
155
176
|
if (issues === 0 && warnings === 0) {
|
package/src/commands/init.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { banner, ok, warn, info, log, ask, choose } from '../lib/ui.mjs';
|
|
|
5
5
|
import { buildConfig, writeConfig } from '../lib/config.mjs';
|
|
6
6
|
import { copyDir, copyFile, ensureDir } from '../lib/copy.mjs';
|
|
7
7
|
import { detectPlatform, platformLabel } from '../lib/platform.mjs';
|
|
8
|
+
import { ensureDwGitignore, ensureClaudeGitignore } from '../lib/gitignore.mjs';
|
|
8
9
|
|
|
9
10
|
const TOOLKIT_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..', '..');
|
|
10
11
|
|
|
@@ -82,7 +83,7 @@ export async function initCommand(opts) {
|
|
|
82
83
|
ok(`Platform: ${platformLabel(adapter)}`);
|
|
83
84
|
|
|
84
85
|
info('Setting up project...');
|
|
85
|
-
await setupProject(projectDir, { projectName, depth, roles, language, adapter });
|
|
86
|
+
await setupProject(projectDir, { projectName, depth, roles, language, adapter, presetKey: opts.preset });
|
|
86
87
|
|
|
87
88
|
printSummary({ projectName, depth, roles, language, adapter });
|
|
88
89
|
}
|
|
@@ -135,7 +136,7 @@ function normalizeRolesForDepth(parsedRoles, depth) {
|
|
|
135
136
|
return merged;
|
|
136
137
|
}
|
|
137
138
|
|
|
138
|
-
async function setupProject(projectDir, { projectName, depth, roles, language, adapter }) {
|
|
139
|
+
async function setupProject(projectDir, { projectName, depth, roles, language, adapter, presetKey }) {
|
|
139
140
|
copyCoreDocs(projectDir);
|
|
140
141
|
copyConfig(projectDir, { projectName, depth, roles, language });
|
|
141
142
|
copyAdapterStructure(projectDir);
|
|
@@ -143,6 +144,7 @@ async function setupProject(projectDir, { projectName, depth, roles, language, a
|
|
|
143
144
|
if (adapter === 'claude-cli') {
|
|
144
145
|
copyClaudeFiles(projectDir);
|
|
145
146
|
createMinimalCLAUDEmd(projectDir, projectName);
|
|
147
|
+
await maybeInstallSupplyChainHook(projectDir, presetKey);
|
|
146
148
|
} else if (adapter === 'cursor') {
|
|
147
149
|
copyCursorFiles(projectDir);
|
|
148
150
|
copyGenericAdapter(projectDir);
|
|
@@ -152,6 +154,47 @@ async function setupProject(projectDir, { projectName, depth, roles, language, a
|
|
|
152
154
|
|
|
153
155
|
createRuntimeDirs(projectDir);
|
|
154
156
|
updateGitignore(projectDir);
|
|
157
|
+
writeScopedGitignores(projectDir, adapter);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function writeScopedGitignores(projectDir, adapter) {
|
|
161
|
+
try {
|
|
162
|
+
const dwR = ensureDwGitignore(projectDir);
|
|
163
|
+
if (dwR.action !== 'noop') ok(`.dw/.gitignore ${dwR.action}`);
|
|
164
|
+
if (adapter === 'claude-cli') {
|
|
165
|
+
const cR = ensureClaudeGitignore(projectDir);
|
|
166
|
+
if (cR.action !== 'noop') ok(`.claude/.gitignore ${cR.action}`);
|
|
167
|
+
}
|
|
168
|
+
} catch (e) {
|
|
169
|
+
warn(`Scoped gitignore: ${e.message}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function maybeInstallSupplyChainHook(projectDir, presetKey) {
|
|
174
|
+
const preset = presetKey ? PRESETS[presetKey] : null;
|
|
175
|
+
const { installHookInProject, uninstallHookFromProject } = await import('../lib/sc-install.mjs');
|
|
176
|
+
|
|
177
|
+
if (preset && preset.hooksProfile === 'safety-only') {
|
|
178
|
+
// Solo preset — explicitly uninstall hook even if template provided it (TW5: opt-in OFF)
|
|
179
|
+
const result = uninstallHookFromProject(projectDir);
|
|
180
|
+
if (result.ok && result.action === 'removed') {
|
|
181
|
+
log(' Supply-chain guard: hook removed from settings (solo preset — opt-in OFF per ADR-0005 TW5)');
|
|
182
|
+
} else {
|
|
183
|
+
log(' Supply-chain guard: skipped (solo preset — opt-in OFF per ADR-0005 TW5)');
|
|
184
|
+
}
|
|
185
|
+
log(' Enable later: `dw security-scan --install-hook`');
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const result = installHookInProject(projectDir);
|
|
190
|
+
if (!result.ok) {
|
|
191
|
+
warn(`Supply-chain hook wiring skipped: ${result.error}`);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (result.action === 'added') {
|
|
195
|
+
ok('Supply-chain guard hook wired (ADR-0005 — opt-in flag enabled)');
|
|
196
|
+
log(' First scan: `dw security-scan --update-db`');
|
|
197
|
+
}
|
|
155
198
|
}
|
|
156
199
|
|
|
157
200
|
function copyCoreDocs(projectDir) {
|
package/src/commands/metrics.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readEvents, summarize } from '../lib/telemetry.mjs';
|
|
2
|
-
import { analyze, THRESHOLDS } from '../lib/cut-analysis.mjs';
|
|
2
|
+
import { analyze, analyzeTaskDocs, THRESHOLDS, TASK_DOC_THRESHOLDS } from '../lib/cut-analysis.mjs';
|
|
3
3
|
import { banner, log, info, warn, ok, err } from '../lib/ui.mjs';
|
|
4
4
|
import chalk from 'chalk';
|
|
5
5
|
|
|
@@ -162,4 +162,24 @@ function cutAnalysisReport(opts) {
|
|
|
162
162
|
log(` Hook: avg_latency > ${THRESHOLDS.hook.maxAvgLatencyMs}ms OR fires/session > ${THRESHOLDS.hook.maxFiresPerSession}`);
|
|
163
163
|
log('');
|
|
164
164
|
info('Caveat: "devs" proxied by unique session hashes — undercounts real headcount.');
|
|
165
|
+
|
|
166
|
+
// Task doc health — invalidation trigger for 3→2 file consolidation (ADR-0001)
|
|
167
|
+
const td = analyzeTaskDocs(process.cwd());
|
|
168
|
+
if (td && td.totalTasks > 0) {
|
|
169
|
+
log('');
|
|
170
|
+
log(chalk.bold('Task Doc Health (ADR-0001 invalidation signal for 3→2 consolidation)'));
|
|
171
|
+
log(` Tasks total: ${td.totalTasks} (v2=${td.v2Count}, v1=${td.v1Count})`);
|
|
172
|
+
log(` tracking.md lines: avg=${td.avgTrackingLines} max=${td.maxTrackingLines}`);
|
|
173
|
+
log(` Tasks with ≥3 md files: ${td.extraFilesCount} (${td.extraFilesPct}%)`);
|
|
174
|
+
if (td.triggers.length === 0) {
|
|
175
|
+
ok('No task-doc invalidation triggers fired — 3→2 consolidation holding.');
|
|
176
|
+
} else {
|
|
177
|
+
for (const t of td.triggers) {
|
|
178
|
+
err(t);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
log('');
|
|
182
|
+
info('Task doc thresholds:');
|
|
183
|
+
log(` avg_tracking_lines > ${TASK_DOC_THRESHOLDS.trackingLinesWarn} OR pct_tasks_with_3plus_files > ${TASK_DOC_THRESHOLDS.extraFilesPctWarn}%`);
|
|
184
|
+
}
|
|
165
185
|
}
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { banner, log, info, warn, ok, err } from '../lib/ui.mjs';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import {
|
|
7
|
+
loadSnapshot,
|
|
8
|
+
snapshotInfo,
|
|
9
|
+
syncSnapshotForProject,
|
|
10
|
+
isStale,
|
|
11
|
+
isSchemaCompatible,
|
|
12
|
+
fetchOsvByName,
|
|
13
|
+
} from '../lib/sc-sync.mjs';
|
|
14
|
+
import {
|
|
15
|
+
scanProject,
|
|
16
|
+
severityRank,
|
|
17
|
+
worstSeverity,
|
|
18
|
+
parsePackageJson,
|
|
19
|
+
findPackageJson,
|
|
20
|
+
matchPackageByName,
|
|
21
|
+
matchNamespaceFixture,
|
|
22
|
+
} from '../lib/sc-scanner.mjs';
|
|
23
|
+
import { logEvent } from '../lib/telemetry.mjs';
|
|
24
|
+
|
|
25
|
+
const TOOLKIT_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..', '..');
|
|
26
|
+
const NAMESPACE_FIXTURE_REL = '.dw/security/ioc-namespaces.json';
|
|
27
|
+
|
|
28
|
+
export async function securityScanCommand(opts) {
|
|
29
|
+
const rootDir = process.cwd();
|
|
30
|
+
const mode = pickMode(opts);
|
|
31
|
+
|
|
32
|
+
if (mode === 'pre-install') {
|
|
33
|
+
return runPreInstallMode(rootDir, opts);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (opts.json) {
|
|
37
|
+
return runJsonMode(rootDir, mode, opts);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
banner('dw-kit Supply-Chain Scan');
|
|
41
|
+
|
|
42
|
+
if (mode === 'update-db' || opts.updateDb) {
|
|
43
|
+
info('Fetching fresh advisory snapshot from OSV.dev...');
|
|
44
|
+
try {
|
|
45
|
+
const start = Date.now();
|
|
46
|
+
const res = await syncSnapshotForProject(rootDir);
|
|
47
|
+
const elapsed = Date.now() - start;
|
|
48
|
+
ok(`Snapshot updated — ${res.advisoryCount} advisories for ${res.packageCount} packages (${elapsed}ms)`);
|
|
49
|
+
logEvent({ event: 'sc_guard', action: 'sync', advisories: res.advisoryCount, packages: res.packageCount, latency_ms: elapsed }, rootDir);
|
|
50
|
+
} catch (e) {
|
|
51
|
+
err(`Sync failed: ${e.message}`);
|
|
52
|
+
if (e.code === 'NO_LOCKFILE') warn('Run `npm install` first to create package-lock.json.');
|
|
53
|
+
process.exit(2);
|
|
54
|
+
}
|
|
55
|
+
if (mode === 'update-db') {
|
|
56
|
+
log('');
|
|
57
|
+
info('Snapshot ready. Run `dw security-scan` to scan project.');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
log('');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const info_ = snapshotInfo(rootDir);
|
|
64
|
+
if (!info_.exists) {
|
|
65
|
+
warn('No advisory snapshot found.');
|
|
66
|
+
log('Run `dw security-scan --update-db` to fetch from OSV.dev.');
|
|
67
|
+
log('Quick mode requires an existing snapshot.');
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const ageDays = info_.age_days;
|
|
72
|
+
const ageLabel = ageDays === Infinity ? 'unknown' : `${ageDays.toFixed(1)} days old`;
|
|
73
|
+
log(chalk.bold('Snapshot'));
|
|
74
|
+
log(` Source : ${info_.source} (${info_.ecosystem})`);
|
|
75
|
+
log(` Fetched : ${info_.fetched_at || '?'} (${ageLabel})`);
|
|
76
|
+
log(` Advisories : ${info_.advisory_count} Packages scanned previously: ${info_.package_count}`);
|
|
77
|
+
if (!info_.schema_compatible) {
|
|
78
|
+
err(` Schema mismatch — expected 1.0, got ${info_.schema_version || 'unknown'}`);
|
|
79
|
+
log(' Run `dw security-scan --update-db` to refresh.');
|
|
80
|
+
process.exit(2);
|
|
81
|
+
}
|
|
82
|
+
if (info_.stale) {
|
|
83
|
+
warn(` Snapshot is stale (>7 days). Run \`dw security-scan --update-db\` to refresh.`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
log('');
|
|
87
|
+
log(chalk.bold('Scanning project lockfile...'));
|
|
88
|
+
const snap = loadSnapshot(rootDir);
|
|
89
|
+
const start = Date.now();
|
|
90
|
+
const result = scanProject(rootDir, snap);
|
|
91
|
+
const elapsed = Date.now() - start;
|
|
92
|
+
|
|
93
|
+
if (result.error === 'no_lockfile') {
|
|
94
|
+
err('No lockfile found (expected package-lock.json).');
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
if (result.error === 'no_snapshot') {
|
|
98
|
+
err('Snapshot data unavailable.');
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
log(` Lockfile : ${result.lockfile}`);
|
|
103
|
+
log(` Packages scanned : ${result.packages_scanned}`);
|
|
104
|
+
log(` Elapsed : ${elapsed}ms`);
|
|
105
|
+
log('');
|
|
106
|
+
|
|
107
|
+
if (result.matches.length === 0) {
|
|
108
|
+
ok('No advisory matches — clean.');
|
|
109
|
+
logEvent({ event: 'sc_guard', action: 'scan_run', matches: 0, packages: result.packages_scanned, outcome: 'clean', latency_ms: elapsed }, rootDir);
|
|
110
|
+
log('');
|
|
111
|
+
advisoryFooter();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
log(chalk.bold(`Matches (${result.matches.length})`));
|
|
116
|
+
const sorted = result.matches.slice().sort((a, b) => severityRank(b.severity) - severityRank(a.severity));
|
|
117
|
+
for (const m of sorted) {
|
|
118
|
+
const tag = severityTag(m.severity);
|
|
119
|
+
log(` ${tag} ${m.package}@${m.version}`);
|
|
120
|
+
if (m.summary) log(chalk.dim(` ${m.summary}`));
|
|
121
|
+
log(chalk.dim(` advisory: ${m.advisory_id}`));
|
|
122
|
+
if (m.fix_versions.length) log(chalk.dim(` fix: ${m.fix_versions.join(', ')}`));
|
|
123
|
+
}
|
|
124
|
+
log('');
|
|
125
|
+
|
|
126
|
+
const worst = worstSeverity(result.matches);
|
|
127
|
+
const blockCount = result.matches.filter((m) => severityRank(m.severity) >= severityRank('high')).length;
|
|
128
|
+
const exitCode = blockCount > 0 ? 2 : 1;
|
|
129
|
+
|
|
130
|
+
logEvent(
|
|
131
|
+
{
|
|
132
|
+
event: 'sc_guard',
|
|
133
|
+
action: blockCount > 0 ? 'block' : 'allow',
|
|
134
|
+
matches: result.matches.length,
|
|
135
|
+
block_count: blockCount,
|
|
136
|
+
worst_severity: worst,
|
|
137
|
+
packages: result.packages_scanned,
|
|
138
|
+
latency_ms: elapsed,
|
|
139
|
+
snapshot_age_days: ageDays,
|
|
140
|
+
},
|
|
141
|
+
rootDir,
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
if (blockCount > 0) {
|
|
145
|
+
err(`${blockCount} HIGH+ severity match(es) — review before merging lockfile changes.`);
|
|
146
|
+
} else {
|
|
147
|
+
warn(`${result.matches.length} low/medium match(es) — review recommended.`);
|
|
148
|
+
}
|
|
149
|
+
log('');
|
|
150
|
+
advisoryFooter();
|
|
151
|
+
process.exit(exitCode);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function pickMode(opts) {
|
|
155
|
+
if (opts.preInstall) return 'pre-install';
|
|
156
|
+
if (opts.updateDb) return 'update-db';
|
|
157
|
+
return 'scan';
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function loadNamespaceFixture(rootDir) {
|
|
161
|
+
// Prefer project-local fixture, fall back to toolkit-bundled
|
|
162
|
+
const candidates = [
|
|
163
|
+
join(rootDir, NAMESPACE_FIXTURE_REL),
|
|
164
|
+
join(TOOLKIT_ROOT, NAMESPACE_FIXTURE_REL),
|
|
165
|
+
];
|
|
166
|
+
for (const p of candidates) {
|
|
167
|
+
if (existsSync(p)) {
|
|
168
|
+
try {
|
|
169
|
+
return { fixture: JSON.parse(readFileSync(p, 'utf-8')), path: p };
|
|
170
|
+
} catch {
|
|
171
|
+
// skip malformed
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return { fixture: null, path: null };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function runPreInstallMode(rootDir, opts) {
|
|
179
|
+
const useJson = !!opts.json;
|
|
180
|
+
const out = { mode: 'pre-install', ok: true, network_ok: false, packages: 0, osv_hits: [], namespace_hits: [] };
|
|
181
|
+
|
|
182
|
+
const pkgPath = findPackageJson(rootDir);
|
|
183
|
+
if (!pkgPath) {
|
|
184
|
+
if (useJson) {
|
|
185
|
+
out.ok = false;
|
|
186
|
+
out.error = { code: 'NO_PACKAGE_JSON', message: 'No package.json in current directory' };
|
|
187
|
+
process.stdout.write(JSON.stringify(out) + '\n');
|
|
188
|
+
process.exit(1);
|
|
189
|
+
}
|
|
190
|
+
err('No package.json in current directory.');
|
|
191
|
+
process.exit(1);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
let packages;
|
|
195
|
+
try {
|
|
196
|
+
packages = parsePackageJson(pkgPath);
|
|
197
|
+
} catch (e) {
|
|
198
|
+
if (useJson) {
|
|
199
|
+
out.ok = false;
|
|
200
|
+
out.error = { code: 'PARSE_FAILED', message: e.message };
|
|
201
|
+
process.stdout.write(JSON.stringify(out) + '\n');
|
|
202
|
+
process.exit(1);
|
|
203
|
+
}
|
|
204
|
+
err(`Failed to parse package.json: ${e.message}`);
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
out.packages = packages.size;
|
|
209
|
+
|
|
210
|
+
if (!useJson) {
|
|
211
|
+
banner('dw-kit Pre-Install Scan');
|
|
212
|
+
log(chalk.bold('package.json'));
|
|
213
|
+
log(` Path : ${pkgPath}`);
|
|
214
|
+
log(` Declared : ${packages.size} packages (across deps/devDeps/peerDeps/optionalDeps)`);
|
|
215
|
+
log('');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 1. Namespace fixture (offline, fast)
|
|
219
|
+
const fixtureBundle = loadNamespaceFixture(rootDir);
|
|
220
|
+
if (fixtureBundle.fixture) {
|
|
221
|
+
const hits = matchNamespaceFixture(packages, fixtureBundle.fixture);
|
|
222
|
+
out.namespace_hits = hits;
|
|
223
|
+
out.fixture_path = fixtureBundle.path;
|
|
224
|
+
if (!useJson) {
|
|
225
|
+
log(chalk.bold('Namespace IoC fixture'));
|
|
226
|
+
log(` Source : ${fixtureBundle.path}`);
|
|
227
|
+
log(` Entries : ${(fixtureBundle.fixture.namespaces || []).length} active namespace pattern(s)`);
|
|
228
|
+
if (hits.length === 0) {
|
|
229
|
+
ok(' No matches against active namespace patterns');
|
|
230
|
+
} else {
|
|
231
|
+
log('');
|
|
232
|
+
for (const h of hits) {
|
|
233
|
+
log(` ${severityTag(h.severity)} ${h.package} matches pattern "${h.namespace_pattern}"`);
|
|
234
|
+
if (h.reason) log(chalk.dim(` ${h.reason}`));
|
|
235
|
+
if (h.guidance) log(chalk.yellow(` guidance: ${h.guidance}`));
|
|
236
|
+
if (h.advisory_url) log(chalk.dim(` advisory: ${h.advisory_url}`));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
log('');
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// 2. OSV.dev name-only queries (network, optional)
|
|
244
|
+
if (!opts.offline) {
|
|
245
|
+
if (!useJson) log(chalk.bold('OSV.dev name-only query (per declared package)'));
|
|
246
|
+
const osvErrors = [];
|
|
247
|
+
let queried = 0;
|
|
248
|
+
for (const [name] of packages) {
|
|
249
|
+
try {
|
|
250
|
+
const result = await fetchOsvByName(name, 'npm', { timeoutMs: 5000 });
|
|
251
|
+
queried++;
|
|
252
|
+
const vulns = (result && result.vulns) || [];
|
|
253
|
+
if (vulns.length > 0) {
|
|
254
|
+
const hits = matchPackageByName(name, vulns);
|
|
255
|
+
for (const h of hits) {
|
|
256
|
+
out.osv_hits.push({ package: name, declared_range: packages.get(name), ...h });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
} catch (e) {
|
|
260
|
+
osvErrors.push({ package: name, error: e.message });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
out.network_ok = queried > 0;
|
|
264
|
+
out.osv_queried = queried;
|
|
265
|
+
out.osv_errors = osvErrors;
|
|
266
|
+
|
|
267
|
+
if (!useJson) {
|
|
268
|
+
log(` Queried : ${queried}/${packages.size} packages (${osvErrors.length} errors)`);
|
|
269
|
+
if (out.osv_hits.length === 0) {
|
|
270
|
+
ok(' No active advisories for declared packages');
|
|
271
|
+
} else {
|
|
272
|
+
const grouped = groupBy(out.osv_hits, 'package');
|
|
273
|
+
log('');
|
|
274
|
+
for (const [pkg, hits] of Object.entries(grouped)) {
|
|
275
|
+
const worst = hits.reduce(
|
|
276
|
+
(acc, h) => (severityRank(h.severity) > severityRank(acc) ? h.severity : acc),
|
|
277
|
+
'unknown',
|
|
278
|
+
);
|
|
279
|
+
log(` ${severityTag(worst)} ${pkg}@${packages.get(pkg)} — ${hits.length} active advisory(s)`);
|
|
280
|
+
for (const h of hits.slice(0, 3)) {
|
|
281
|
+
log(chalk.dim(` ${h.advisory_id}: ${h.summary || '(no summary)'}`));
|
|
282
|
+
if (h.fix_versions && h.fix_versions.length) {
|
|
283
|
+
log(chalk.dim(` fix: ${h.fix_versions.slice(0, 3).join(', ')}`));
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (hits.length > 3) log(chalk.dim(` ... and ${hits.length - 3} more`));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
log('');
|
|
290
|
+
}
|
|
291
|
+
} else if (!useJson) {
|
|
292
|
+
log(chalk.dim(' OSV.dev query: skipped (--offline)'));
|
|
293
|
+
log('');
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Summarize
|
|
297
|
+
const namespaceCrit = out.namespace_hits.length;
|
|
298
|
+
const osvHigh = out.osv_hits.filter((h) => severityRank(h.severity) >= severityRank('high')).length;
|
|
299
|
+
const exitCode = namespaceCrit > 0 ? 2 : (osvHigh > 0 ? 2 : (out.osv_hits.length > 0 ? 1 : 0));
|
|
300
|
+
|
|
301
|
+
logEvent(
|
|
302
|
+
{
|
|
303
|
+
event: 'sc_guard',
|
|
304
|
+
action: exitCode >= 2 ? 'block' : (exitCode === 1 ? 'allow' : 'scan_run'),
|
|
305
|
+
sub_mode: 'pre-install',
|
|
306
|
+
packages: packages.size,
|
|
307
|
+
namespace_hits: namespaceCrit,
|
|
308
|
+
osv_hits: out.osv_hits.length,
|
|
309
|
+
osv_high: osvHigh,
|
|
310
|
+
network_ok: out.network_ok,
|
|
311
|
+
},
|
|
312
|
+
rootDir,
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
if (useJson) {
|
|
316
|
+
out.summary = { namespace_hits: namespaceCrit, osv_hits: out.osv_hits.length, osv_high: osvHigh, exit_code: exitCode };
|
|
317
|
+
process.stdout.write(JSON.stringify(out) + '\n');
|
|
318
|
+
process.exit(exitCode);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
log(chalk.bold('Summary'));
|
|
322
|
+
if (exitCode === 0) {
|
|
323
|
+
ok(`Clean — ${packages.size} packages scanned, no matches`);
|
|
324
|
+
} else if (exitCode === 1) {
|
|
325
|
+
warn(`${out.osv_hits.length} low/medium advisor(y/ies) found — review recommended`);
|
|
326
|
+
} else {
|
|
327
|
+
err(`${namespaceCrit} namespace match(es) + ${osvHigh} HIGH+ advisory(s) — rotate credentials if already installed`);
|
|
328
|
+
}
|
|
329
|
+
log('');
|
|
330
|
+
advisoryFooter();
|
|
331
|
+
process.exit(exitCode);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function groupBy(arr, key) {
|
|
335
|
+
const out = {};
|
|
336
|
+
for (const item of arr) {
|
|
337
|
+
const k = item[key];
|
|
338
|
+
if (!out[k]) out[k] = [];
|
|
339
|
+
out[k].push(item);
|
|
340
|
+
}
|
|
341
|
+
return out;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function severityTag(label) {
|
|
345
|
+
switch (label) {
|
|
346
|
+
case 'critical':
|
|
347
|
+
return chalk.red.bold('[CRITICAL]'.padEnd(12));
|
|
348
|
+
case 'high':
|
|
349
|
+
return chalk.red('[HIGH] '.padEnd(12));
|
|
350
|
+
case 'medium':
|
|
351
|
+
return chalk.yellow('[MEDIUM] '.padEnd(12));
|
|
352
|
+
case 'low':
|
|
353
|
+
return chalk.blue('[LOW] '.padEnd(12));
|
|
354
|
+
default:
|
|
355
|
+
return chalk.dim('[?] '.padEnd(12));
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function advisoryFooter() {
|
|
360
|
+
info('ADVISORY OUTPUT — NOT a decision rule.');
|
|
361
|
+
log(' Threshold matrix in v14-evaluation-protocol.md is authoritative.');
|
|
362
|
+
log(' Use this scan as evidence-supplement only.');
|
|
363
|
+
log(' Public sunset commitment: 2026-08-12 (see ADR-0005).');
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function runJsonMode(rootDir, mode, opts) {
|
|
367
|
+
let out = { mode, ok: true };
|
|
368
|
+
|
|
369
|
+
if (mode === 'update-db' || opts.updateDb) {
|
|
370
|
+
try {
|
|
371
|
+
const start = Date.now();
|
|
372
|
+
const res = await syncSnapshotForProject(rootDir);
|
|
373
|
+
out.sync = { advisory_count: res.advisoryCount, package_count: res.packageCount, latency_ms: Date.now() - start };
|
|
374
|
+
logEvent({ event: 'sc_guard', action: 'sync', advisories: res.advisoryCount, packages: res.packageCount }, rootDir);
|
|
375
|
+
} catch (e) {
|
|
376
|
+
out.ok = false;
|
|
377
|
+
out.error = { code: e.code || 'SYNC_FAILED', message: e.message };
|
|
378
|
+
process.stdout.write(JSON.stringify(out) + '\n');
|
|
379
|
+
process.exit(2);
|
|
380
|
+
}
|
|
381
|
+
if (mode === 'update-db') {
|
|
382
|
+
process.stdout.write(JSON.stringify(out) + '\n');
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const info_ = snapshotInfo(rootDir);
|
|
388
|
+
out.snapshot = info_;
|
|
389
|
+
if (!info_.exists) {
|
|
390
|
+
out.ok = false;
|
|
391
|
+
out.error = { code: 'NO_SNAPSHOT', message: 'Run `dw security-scan --update-db`' };
|
|
392
|
+
process.stdout.write(JSON.stringify(out) + '\n');
|
|
393
|
+
process.exit(1);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const snap = loadSnapshot(rootDir);
|
|
397
|
+
const start = Date.now();
|
|
398
|
+
const result = scanProject(rootDir, snap);
|
|
399
|
+
out.scan = { ...result, elapsed_ms: Date.now() - start };
|
|
400
|
+
|
|
401
|
+
if (result.error) {
|
|
402
|
+
out.ok = false;
|
|
403
|
+
out.error = { code: result.error.toUpperCase(), message: result.error };
|
|
404
|
+
process.stdout.write(JSON.stringify(out) + '\n');
|
|
405
|
+
process.exit(1);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const worst = worstSeverity(result.matches);
|
|
409
|
+
const blockCount = result.matches.filter((m) => severityRank(m.severity) >= severityRank('high')).length;
|
|
410
|
+
out.summary = { matches: result.matches.length, block_count: blockCount, worst_severity: worst };
|
|
411
|
+
|
|
412
|
+
logEvent(
|
|
413
|
+
{
|
|
414
|
+
event: 'sc_guard',
|
|
415
|
+
action: blockCount > 0 ? 'block' : (result.matches.length > 0 ? 'allow' : 'scan_run'),
|
|
416
|
+
matches: result.matches.length,
|
|
417
|
+
block_count: blockCount,
|
|
418
|
+
worst_severity: worst,
|
|
419
|
+
packages: result.packages_scanned,
|
|
420
|
+
snapshot_age_days: info_.age_days,
|
|
421
|
+
},
|
|
422
|
+
rootDir,
|
|
423
|
+
);
|
|
424
|
+
|
|
425
|
+
process.stdout.write(JSON.stringify(out) + '\n');
|
|
426
|
+
process.exit(blockCount > 0 ? 2 : result.matches.length > 0 ? 1 : 0);
|
|
427
|
+
}
|
package/src/commands/upgrade.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
4
4
|
import { header, ok, warn, err, info, log, dry } from '../lib/ui.mjs';
|
|
5
5
|
import { loadConfig, writeConfig, getToolkitVersions } from '../lib/config.mjs';
|
|
6
6
|
import { diffDirs, copyDir, copyFile } from '../lib/copy.mjs';
|
|
7
|
+
import { ensureDwGitignore, ensureClaudeGitignore } from '../lib/gitignore.mjs';
|
|
7
8
|
|
|
8
9
|
const TOOLKIT_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..', '..');
|
|
9
10
|
|
|
@@ -57,6 +58,7 @@ export async function upgradeCommand(opts) {
|
|
|
57
58
|
|
|
58
59
|
upgradeScripts(projectDir, opts);
|
|
59
60
|
upgradeConfigSchema(projectDir, opts);
|
|
61
|
+
upgradeScopedGitignores(projectDir, opts);
|
|
60
62
|
|
|
61
63
|
if (!opts.dryRun && totalChanges > 0) {
|
|
62
64
|
updateVersionTracking(configPath, projectConfig, toolkitVersions);
|
|
@@ -169,6 +171,7 @@ function mergeSettingsJson(projectDir, opts) {
|
|
|
169
171
|
|
|
170
172
|
if (opts.dryRun) {
|
|
171
173
|
dry('merge .claude/settings.json');
|
|
174
|
+
dry('post-merge: wire supply-chain-scan.sh hook if missing (idempotent)');
|
|
172
175
|
return;
|
|
173
176
|
}
|
|
174
177
|
|
|
@@ -181,6 +184,57 @@ function mergeSettingsJson(projectDir, opts) {
|
|
|
181
184
|
} catch (e) {
|
|
182
185
|
warn(`settings.json merge failed: ${e.message}`);
|
|
183
186
|
}
|
|
187
|
+
|
|
188
|
+
// Post-merge: explicitly install supply-chain-scan hook (ADR-0005, idempotent).
|
|
189
|
+
// deepMerge replaces arrays, so user's PostToolUse array may not contain the new
|
|
190
|
+
// hook entry. We must add it explicitly. Respects existing wiring.
|
|
191
|
+
installSupplyChainHookOnUpgrade(projectDir, opts);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function upgradeScopedGitignores(projectDir, opts) {
|
|
195
|
+
info('Scoped .gitignore (.dw/, .claude/)');
|
|
196
|
+
if (opts.dryRun) {
|
|
197
|
+
dry('refresh .dw/.gitignore + .claude/.gitignore managed blocks');
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
const dwR = ensureDwGitignore(projectDir);
|
|
202
|
+
if (dwR.action === 'noop') log(' .dw/.gitignore: up to date');
|
|
203
|
+
else ok(`.dw/.gitignore: ${dwR.action}`);
|
|
204
|
+
|
|
205
|
+
if (existsSync(join(projectDir, '.claude'))) {
|
|
206
|
+
const cR = ensureClaudeGitignore(projectDir);
|
|
207
|
+
if (cR.action === 'noop') log(' .claude/.gitignore: up to date');
|
|
208
|
+
else ok(`.claude/.gitignore: ${cR.action}`);
|
|
209
|
+
}
|
|
210
|
+
} catch (e) {
|
|
211
|
+
warn(`Scoped gitignore: ${e.message}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function installSupplyChainHookOnUpgrade(projectDir, opts) {
|
|
216
|
+
if (opts.dryRun) return;
|
|
217
|
+
try {
|
|
218
|
+
const configPath = join(projectDir, '.dw', 'config', 'dw.config.yml');
|
|
219
|
+
if (existsSync(configPath)) {
|
|
220
|
+
const cfg = readFileSync(configPath, 'utf-8');
|
|
221
|
+
if (/depth:\s*quick/i.test(cfg) && !/roles:\s*\[?\s*dev\s*,/i.test(cfg)) {
|
|
222
|
+
// Heuristic: solo preset → skip per ADR-0005 TW5
|
|
223
|
+
log(' Supply-chain hook: skipped (solo-style preset detected)');
|
|
224
|
+
log(' Enable later: `dw security-scan --install-hook`');
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
} catch { /* fall through */ }
|
|
229
|
+
|
|
230
|
+
// Defer import (avoid loading at module top) for clean test isolation
|
|
231
|
+
import('../lib/sc-install.mjs').then((m) => {
|
|
232
|
+
const result = m.installHookInProject(projectDir);
|
|
233
|
+
if (result.ok && result.action === 'added') {
|
|
234
|
+
ok('Supply-chain guard hook wired (ADR-0005 — opt-in available since v1.3.5)');
|
|
235
|
+
log(' First scan: `dw security-scan --update-db`');
|
|
236
|
+
}
|
|
237
|
+
}).catch(() => { /* silent — installation is best-effort */ });
|
|
184
238
|
}
|
|
185
239
|
|
|
186
240
|
function deepMerge(base, override) {
|