@bongos/core 1.19.257 → 1.19.258
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/.bongos-core.json +70 -25
- package/docs/adr/0022-secrets-policy.md +16 -12
- package/docs/file-map.md +1 -0
- package/docs/handoff-template.md +1 -1
- package/docs/recipes/README.md +1 -1
- package/docs/recipes/redteam-patrol.md +48 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/control-manifest.js +123 -0
- package/scripts/gds/fitness-ratchets.js +328 -0
- package/scripts/gds/fitness.js +4 -4
- package/scripts/gds/gen-session-index.js +8 -1
- package/scripts/gds/redteam-patrol.js +308 -0
- package/src/bongos/logger.js +118 -0
- package/src/bongos/middleware/audit.js +4 -3
- package/src/bongos/middleware/error-envelope.js +5 -4
- package/src/bongos/module-scope-map.js +1 -0
- package/src/bongos/serve-internal.js +2 -1
- package/src/module-api.js +3 -1
- package/src/platform-server.js +9 -2
- package/tests/auth_resolve_failure.mjs +1 -1
- package/tests/claude_materialize.mjs +28 -11
- package/tests/control_manifest.mjs +60 -0
- package/tests/fitness_ratchets.mjs +91 -0
- package/tests/logger.mjs +89 -0
- package/tests/publish_reconciler.mjs +142 -0
- package/tests/redteam_patrol.mjs +67 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/gds/fitness-ratchets.js — quality budgets that only tighten (task
|
|
3
|
+
// 1003129, goal 1000076; docs/audits/ai-dev-redteam-baseline.md §4).
|
|
4
|
+
//
|
|
5
|
+
// The audit's modularity finding was measurement without thresholds: sizes,
|
|
6
|
+
// dead code, and doc entropy were all TRACKED and nothing failed when they
|
|
7
|
+
// grew. This module gives each metric a committed BASELINE
|
|
8
|
+
// (config/fitness-baselines.json) with RATCHET semantics:
|
|
9
|
+
// value > baseline → violation (fitness hardFail — the number got worse)
|
|
10
|
+
// value < baseline → warning nudge to lower the baseline (an ordinary PR)
|
|
11
|
+
// GitClear's measured trend (AI-assisted code duplicates instead of reusing)
|
|
12
|
+
// is the reason the gate exists; the baseline records today's debt without
|
|
13
|
+
// bricking today's tree.
|
|
14
|
+
//
|
|
15
|
+
// Metrics (all hermetic — git + fs only, no network):
|
|
16
|
+
// oversized_file_count / excess_lines_total — tracked first-party .js files
|
|
17
|
+
// over OVERSIZE_LINES lines, and their total excess (growth anywhere over
|
|
18
|
+
// the threshold trips the sum even when the count is flat)
|
|
19
|
+
// duplicate_window_count — normalized WINDOW-line blocks appearing in 2+
|
|
20
|
+
// distinct files (the in-house jscpd: cross-file copy/paste)
|
|
21
|
+
// require_cycle_count — strongly-connected components (size >1) in the
|
|
22
|
+
// relative-require graph (the in-house madge)
|
|
23
|
+
// console_call_count — console.* call sites in SERVER paths (src/ + modules/
|
|
24
|
+
// minus their browser public/ trees); task 1003130 converts the core path
|
|
25
|
+
// to structured logging and shrinks this baseline
|
|
26
|
+
// doc_stale_links / doc_broken_anchors / doc_duplicate_prose — the
|
|
27
|
+
// docs-entropy scanner's three counts, reused as a library
|
|
28
|
+
//
|
|
29
|
+
// The knip dead-code ratchet needs node_modules + ~seconds, so it is NOT part
|
|
30
|
+
// of the in-suite check: CI runs `node scripts/gds/fitness-ratchets.js --knip`
|
|
31
|
+
// as its own step (fail-open when knip cannot run, fail-closed on regression).
|
|
32
|
+
//
|
|
33
|
+
// Refresh floors: node scripts/gds/fitness-ratchets.js --update-baselines
|
|
34
|
+
|
|
35
|
+
'use strict';
|
|
36
|
+
|
|
37
|
+
const fs = require('fs');
|
|
38
|
+
const path = require('path');
|
|
39
|
+
const crypto = require('crypto');
|
|
40
|
+
const { execFileSync, execSync } = require('child_process');
|
|
41
|
+
|
|
42
|
+
const ROOT = path.resolve(__dirname, '..', '..');
|
|
43
|
+
const BASELINE_PATH = path.join(ROOT, 'config', 'fitness-baselines.json');
|
|
44
|
+
|
|
45
|
+
const OVERSIZE_LINES = 1500;
|
|
46
|
+
const WINDOW = 30; // normalized lines per duplication window
|
|
47
|
+
|
|
48
|
+
// Vendored / generated / third-party JS the budgets must not count.
|
|
49
|
+
const EXCLUDE_RE = /(^|\/)(node_modules|public-docs)\/|\.min\.js$|redoc\.standalone\.js$|(^|\/)vendor\//;
|
|
50
|
+
|
|
51
|
+
function trackedJsFiles() {
|
|
52
|
+
const out = execFileSync('git', ['ls-files', '*.js'], { cwd: ROOT, encoding: 'utf8' });
|
|
53
|
+
return out.split('\n').map((s) => s.trim()).filter(Boolean).filter((f) => !EXCLUDE_RE.test(f));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readRepoFile(rel) {
|
|
57
|
+
return fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---------- size ----------
|
|
61
|
+
|
|
62
|
+
function sizeMetrics(files, read = readRepoFile) {
|
|
63
|
+
let count = 0;
|
|
64
|
+
let excess = 0;
|
|
65
|
+
for (const f of files) {
|
|
66
|
+
const lines = read(f).split('\n').length;
|
|
67
|
+
if (lines > OVERSIZE_LINES) {
|
|
68
|
+
count++;
|
|
69
|
+
excess += lines - OVERSIZE_LINES;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { oversized_file_count: count, excess_lines_total: excess };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------- duplication (in-house jscpd) ----------
|
|
76
|
+
|
|
77
|
+
function normalizeLines(text) {
|
|
78
|
+
return text
|
|
79
|
+
.split('\n')
|
|
80
|
+
.map((l) => l.trim())
|
|
81
|
+
.filter((l) => l.length > 0 && !l.startsWith('//') && !l.startsWith('*') && !l.startsWith('/*'));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// contentsByFile: Map(rel → text). Counts window-hashes seen in ≥2 DISTINCT files.
|
|
85
|
+
function duplicateWindowCount(contentsByFile, window = WINDOW) {
|
|
86
|
+
const filesByHash = new Map();
|
|
87
|
+
for (const [file, text] of contentsByFile) {
|
|
88
|
+
const lines = normalizeLines(text);
|
|
89
|
+
for (let i = 0; i + window <= lines.length; i++) {
|
|
90
|
+
const h = crypto.createHash('sha1').update(lines.slice(i, i + window).join('\n')).digest('base64');
|
|
91
|
+
let set = filesByHash.get(h);
|
|
92
|
+
if (!set) filesByHash.set(h, (set = new Set()));
|
|
93
|
+
set.add(file);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
let dupes = 0;
|
|
97
|
+
for (const set of filesByHash.values()) if (set.size >= 2) dupes++;
|
|
98
|
+
return dupes;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---------- require cycles (in-house madge) ----------
|
|
102
|
+
|
|
103
|
+
const REQUIRE_RE = /require\(\s*(['"])(\.{1,2}\/[^'"]+)\1\s*\)/g;
|
|
104
|
+
|
|
105
|
+
function resolveRelative(fromRel, spec, exists) {
|
|
106
|
+
const base = path.posix.normalize(path.posix.join(path.posix.dirname(fromRel), spec));
|
|
107
|
+
for (const cand of [base, `${base}.js`, `${base}/index.js`]) {
|
|
108
|
+
if (exists(cand)) return cand;
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// edges: Map(rel → [rel...]). Returns the number of SCCs with size > 1 (Tarjan).
|
|
114
|
+
function countCycles(edges) {
|
|
115
|
+
let index = 0;
|
|
116
|
+
const idx = new Map();
|
|
117
|
+
const low = new Map();
|
|
118
|
+
const onStack = new Set();
|
|
119
|
+
const stack = [];
|
|
120
|
+
let cycles = 0;
|
|
121
|
+
|
|
122
|
+
function strongconnect(v) {
|
|
123
|
+
idx.set(v, index); low.set(v, index); index++;
|
|
124
|
+
stack.push(v); onStack.add(v);
|
|
125
|
+
for (const w of edges.get(v) || []) {
|
|
126
|
+
if (!edges.has(w)) continue;
|
|
127
|
+
if (!idx.has(w)) { strongconnect(w); low.set(v, Math.min(low.get(v), low.get(w))); }
|
|
128
|
+
else if (onStack.has(w)) low.set(v, Math.min(low.get(v), idx.get(w)));
|
|
129
|
+
}
|
|
130
|
+
if (low.get(v) === idx.get(v)) {
|
|
131
|
+
let size = 0; let w;
|
|
132
|
+
do { w = stack.pop(); onStack.delete(w); size++; } while (w !== v);
|
|
133
|
+
if (size > 1) cycles++;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
for (const v of edges.keys()) if (!idx.has(v)) strongconnect(v);
|
|
137
|
+
return cycles;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function requireGraph(contentsByFile) {
|
|
141
|
+
const known = new Set(contentsByFile.keys());
|
|
142
|
+
const exists = (rel) => known.has(rel);
|
|
143
|
+
const edges = new Map();
|
|
144
|
+
for (const [file, text] of contentsByFile) {
|
|
145
|
+
const deps = [];
|
|
146
|
+
for (const m of text.matchAll(REQUIRE_RE)) {
|
|
147
|
+
const target = resolveRelative(file, m[2], exists);
|
|
148
|
+
if (target && target !== file) deps.push(target);
|
|
149
|
+
}
|
|
150
|
+
edges.set(file, deps);
|
|
151
|
+
}
|
|
152
|
+
return edges;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---------- console.* in server paths ----------
|
|
156
|
+
|
|
157
|
+
const CONSOLE_RE = /console\.(log|error|warn|info|debug)\s*\(/g;
|
|
158
|
+
|
|
159
|
+
function isServerPath(rel) {
|
|
160
|
+
if (!(rel.startsWith('src/') || rel.startsWith('modules/') || rel === 'server.js')) return false;
|
|
161
|
+
if (rel.includes('/public/')) return false; // browser surfaces are a different concern
|
|
162
|
+
if (rel.includes('/test')) return false;
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function consoleCallCount(contentsByFile) {
|
|
167
|
+
let n = 0;
|
|
168
|
+
for (const [file, text] of contentsByFile) {
|
|
169
|
+
if (!isServerPath(file)) continue;
|
|
170
|
+
n += (text.match(CONSOLE_RE) || []).length;
|
|
171
|
+
}
|
|
172
|
+
return n;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---------- doc entropy (reuse the scanner as a library) ----------
|
|
176
|
+
|
|
177
|
+
function docMetrics() {
|
|
178
|
+
const de = require('./docs-entropy.js');
|
|
179
|
+
const relFiles = de.collectMarkdownFiles();
|
|
180
|
+
const linkFindings = de.scanStaleLinks(relFiles);
|
|
181
|
+
return {
|
|
182
|
+
doc_stale_links: linkFindings.filter((f) => f.issue === 'missing_file').length,
|
|
183
|
+
doc_broken_anchors: linkFindings.filter((f) => f.issue === 'missing_anchor').length,
|
|
184
|
+
doc_duplicate_prose: de.scanDuplicateProse(relFiles).length,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---------- knip (CI-only, fail-open on infra) ----------
|
|
189
|
+
|
|
190
|
+
function knipIssueCount() {
|
|
191
|
+
let raw;
|
|
192
|
+
try {
|
|
193
|
+
raw = execSync('npx --no-install knip --reporter json --no-exit-code', {
|
|
194
|
+
cwd: ROOT, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'],
|
|
195
|
+
});
|
|
196
|
+
} catch (e) {
|
|
197
|
+
if (e && e.stdout) raw = e.stdout.toString(); else return null; // knip unavailable → skip
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
const j = JSON.parse(raw);
|
|
201
|
+
// knip's json reporter: { files: [...], issues: [{ file, owners, exports:[], types:[], ... }] }
|
|
202
|
+
const files = Array.isArray(j.files) ? j.files.length : 0;
|
|
203
|
+
let items = 0;
|
|
204
|
+
for (const issue of j.issues || []) {
|
|
205
|
+
for (const key of ['dependencies', 'devDependencies', 'optionalPeerDependencies', 'unlisted', 'binaries', 'unresolved', 'exports', 'types', 'nsExports', 'nsTypes', 'duplicates']) {
|
|
206
|
+
const v = issue[key];
|
|
207
|
+
items += Array.isArray(v) ? v.length : 0;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return files + items;
|
|
211
|
+
} catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---------- baseline + ratchet ----------
|
|
217
|
+
|
|
218
|
+
function readBaselines() {
|
|
219
|
+
try {
|
|
220
|
+
return JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf8')).metrics || {};
|
|
221
|
+
} catch {
|
|
222
|
+
return {};
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Pure ratchet verdict — exported for the hermetic test.
|
|
227
|
+
function compareToBaselines(metrics, baselines) {
|
|
228
|
+
const violations = [];
|
|
229
|
+
const warnings = [];
|
|
230
|
+
for (const [name, value] of Object.entries(metrics)) {
|
|
231
|
+
if (value === null || value === undefined) continue; // metric skipped (e.g. knip off-CI)
|
|
232
|
+
const floor = baselines[name];
|
|
233
|
+
if (floor === undefined) {
|
|
234
|
+
warnings.push(`no baseline for "${name}" (value ${value}) — run --update-baselines`);
|
|
235
|
+
} else if (value > floor) {
|
|
236
|
+
violations.push(`${name} regressed: ${value} > baseline ${floor} — fix the regression; never raise the baseline to pass`);
|
|
237
|
+
} else if (value < floor) {
|
|
238
|
+
warnings.push(`ratchet-down available: ${name} improved to ${value} (baseline ${floor}) — lower the baseline in config/fitness-baselines.json`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return { violations, warnings };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function computeMetrics({ withDocs = true } = {}) {
|
|
245
|
+
const files = trackedJsFiles();
|
|
246
|
+
const contents = new Map(files.map((f) => [f, readRepoFile(f)]));
|
|
247
|
+
return {
|
|
248
|
+
...sizeMetrics(files, (f) => contents.get(f)),
|
|
249
|
+
duplicate_window_count: duplicateWindowCount(contents),
|
|
250
|
+
require_cycle_count: countCycles(requireGraph(contents)),
|
|
251
|
+
console_call_count: consoleCallCount(contents),
|
|
252
|
+
...(withDocs ? docMetrics() : {}),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// The fitness.js check (in-suite, hermetic — everything except knip).
|
|
257
|
+
function checkQualityRatchets() {
|
|
258
|
+
const metrics = computeMetrics();
|
|
259
|
+
const { violations, warnings } = compareToBaselines(metrics, readBaselines());
|
|
260
|
+
return {
|
|
261
|
+
name: 'quality ratchets — size, duplication, cycles, console, doc entropy only tighten (task 1003129)',
|
|
262
|
+
hardFail: violations.length > 0,
|
|
263
|
+
violations,
|
|
264
|
+
warnings,
|
|
265
|
+
note: `budgets: ${Object.entries(metrics).map(([k, v]) => `${k}=${v}`).join(' · ')} vs config/fitness-baselines.json (knip runs as its own CI step)`,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function main() {
|
|
270
|
+
const update = process.argv.includes('--update-baselines');
|
|
271
|
+
const knipOnly = process.argv.includes('--knip');
|
|
272
|
+
|
|
273
|
+
if (update) {
|
|
274
|
+
const metrics = computeMetrics();
|
|
275
|
+
const knip = knipIssueCount();
|
|
276
|
+
if (knip !== null) metrics.knip_issue_count = knip;
|
|
277
|
+
const existing = readBaselines();
|
|
278
|
+
if (knip === null && existing.knip_issue_count !== undefined) metrics.knip_issue_count = existing.knip_issue_count;
|
|
279
|
+
fs.writeFileSync(
|
|
280
|
+
BASELINE_PATH,
|
|
281
|
+
JSON.stringify({ _comment: 'fitness-ratchets baselines (task 1003129): quality debt recorded at gate-adoption. Shrink-only — lower a value when the metric improves; raising one to pass CI is out of policy.', metrics }, null, 2) + '\n',
|
|
282
|
+
);
|
|
283
|
+
console.log(`fitness-ratchets: baselines written — ${Object.entries(metrics).map(([k, v]) => `${k}=${v}`).join(' · ')}`);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (knipOnly) {
|
|
288
|
+
const value = knipIssueCount();
|
|
289
|
+
if (value === null) {
|
|
290
|
+
console.warn('fitness-ratchets: WARN — knip not runnable here (no node_modules / npx failure); failing OPEN.');
|
|
291
|
+
process.exit(0);
|
|
292
|
+
}
|
|
293
|
+
const { violations, warnings } = compareToBaselines({ knip_issue_count: value }, readBaselines());
|
|
294
|
+
for (const w of warnings) console.warn(`fitness-ratchets: ${w}`);
|
|
295
|
+
if (violations.length) {
|
|
296
|
+
for (const v of violations) console.error(`fitness-ratchets: FAIL — ${v}`);
|
|
297
|
+
process.exit(1);
|
|
298
|
+
}
|
|
299
|
+
console.log(`fitness-ratchets: PASS — knip_issue_count=${value}.`);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const r = checkQualityRatchets();
|
|
304
|
+
for (const w of r.warnings) console.warn(`! ${w}`);
|
|
305
|
+
for (const v of r.violations) console.error(`✗ ${v}`);
|
|
306
|
+
console.log(`${r.hardFail ? 'FAIL' : 'PASS'} ${r.note}`);
|
|
307
|
+
process.exit(r.hardFail ? 1 : 0);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (require.main === module) main();
|
|
311
|
+
|
|
312
|
+
module.exports = {
|
|
313
|
+
checkQualityRatchets,
|
|
314
|
+
computeMetrics,
|
|
315
|
+
compareToBaselines,
|
|
316
|
+
duplicateWindowCount,
|
|
317
|
+
normalizeLines,
|
|
318
|
+
countCycles,
|
|
319
|
+
requireGraph,
|
|
320
|
+
resolveRelative,
|
|
321
|
+
sizeMetrics,
|
|
322
|
+
consoleCallCount,
|
|
323
|
+
isServerPath,
|
|
324
|
+
knipIssueCount,
|
|
325
|
+
OVERSIZE_LINES,
|
|
326
|
+
WINDOW,
|
|
327
|
+
BASELINE_PATH,
|
|
328
|
+
};
|
package/scripts/gds/fitness.js
CHANGED
|
@@ -1881,6 +1881,8 @@ const CHECKS = [
|
|
|
1881
1881
|
checkVocabularyWall, // Check 19 — ADR 0174 / goal 1000068 R07: "role" is a craft, never a rank
|
|
1882
1882
|
checkGoalMembershipVocabulary, // Check 19b — ADR 0177 / task 1003013: the FOURTH "role" (goal membership)
|
|
1883
1883
|
checkWriteRoutesValidated, // Check 18 — BV1 R12 / task 1999: every body-reading write route validates (ADR 0118)
|
|
1884
|
+
require('./fitness-ratchets.js').checkQualityRatchets, // Check 20 — task 1003129: quality budgets only tighten (mechanics + knip CI step live in fitness-ratchets.js)
|
|
1885
|
+
require('./control-manifest.js').checkControlsExist, // Check 21 — task 1003131: goal-1000076 controls assert their own existence (the ADR 0022 assurance-of-assurance)
|
|
1884
1886
|
];
|
|
1885
1887
|
|
|
1886
1888
|
function runAll() {
|
|
@@ -1909,10 +1911,8 @@ function main() {
|
|
|
1909
1911
|
if (require.main === module) main();
|
|
1910
1912
|
|
|
1911
1913
|
module.exports = {
|
|
1912
|
-
checkVocabularyWall,
|
|
1913
|
-
|
|
1914
|
-
checkGoalMembershipVocabulary,
|
|
1915
|
-
GOAL_VOCAB_SURFACE,
|
|
1914
|
+
checkVocabularyWall, VOCAB_ALLOW,
|
|
1915
|
+
checkGoalMembershipVocabulary, GOAL_VOCAB_SURFACE,
|
|
1916
1916
|
runAll,
|
|
1917
1917
|
resolvesToHostGame,
|
|
1918
1918
|
checkCoreHostBoundary,
|
|
@@ -128,7 +128,14 @@ function renderSnippet(entries, count = SNIPPET_COUNT) {
|
|
|
128
128
|
const recent = entries.slice(0, count);
|
|
129
129
|
const L = [];
|
|
130
130
|
L.push(BEGIN);
|
|
131
|
-
|
|
131
|
+
// NOT A MARKDOWN LINK, deliberately. docs/session-log-index.md is GITIGNORED
|
|
132
|
+
// (ADR 0110 — deploy-regenerated, never committed), so a repo-relative link to
|
|
133
|
+
// it dangles in every fresh checkout. This snippet is written into the TRACKED
|
|
134
|
+
// CLAUDE.md, so linking it here minted a stale link in CI on every run: that is
|
|
135
|
+
// what pushed doc_stale_links past the fitness ratchet baseline and turned the
|
|
136
|
+
// required `unit` check red on main, blocking all PR merges and (via publish.yml's
|
|
137
|
+
// workflow_run trigger) all releases. Name the file in a code span; never re-link it.
|
|
138
|
+
L.push(`**Recent sessions** (newest ${recent.length} — full history in \`docs/session-log-index.md\`):`);
|
|
132
139
|
L.push('');
|
|
133
140
|
for (const e of recent) L.push(line(e, 'docs/session-logs/'));
|
|
134
141
|
L.push(END);
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/gds/redteam-patrol.js — the weekly deterministic red-team patrol
|
|
3
|
+
// (task 1003128, goal 1000076; ADR 0180 §3, docs/audits/ai-dev-redteam-baseline.md).
|
|
4
|
+
//
|
|
5
|
+
// The patrol is the third enforcement layer (prevent → gate → PATROL): checks
|
|
6
|
+
// too slow, too networked, or too history-wide for the merge gate, run weekly
|
|
7
|
+
// by the .claude/scheduled-tasks/redteam-weekly routine. It is ADVISORY by
|
|
8
|
+
// doctrine (ADR 0162 — verdicts never self-execute): findings are FILED as
|
|
9
|
+
// security reports for Archon adjudication, never turned into gates here.
|
|
10
|
+
//
|
|
11
|
+
// Checks (all deterministic — no model reads anything):
|
|
12
|
+
// 1. history-secrets — full-git-history gitleaks scan (.gitleaks.toml config;
|
|
13
|
+
// the CI gate only scans the current tree, so a key deleted years ago but
|
|
14
|
+
// still live is THIS check's catch). Needs the gitleaks binary
|
|
15
|
+
// (GITLEAKS_BIN or on PATH) — absent means a loud degraded note.
|
|
16
|
+
// 2. dependency-review — npm audit delta vs config/audit-baseline.json
|
|
17
|
+
// (reuses audit-gate.js), release-age cooldown (<7 days — ADR 0180 §3),
|
|
18
|
+
// young direct deps (<90 days — the slopsquat shape), stale overrides.
|
|
19
|
+
// 3. action-pins — every workflow `uses:` must be pinned to a 40-hex SHA
|
|
20
|
+
// (the tj-actions lesson).
|
|
21
|
+
//
|
|
22
|
+
// Filing: DRY-RUN by default (prints what it would file). --file POSTs one
|
|
23
|
+
// report PER CLASS (max severity within the class, details in repro_steps_md)
|
|
24
|
+
// to /api/gds/security/reports. --canary proves the end-to-end pipe: plants a
|
|
25
|
+
// constructed token, asserts the scanner catches it, files a low "[canary]"
|
|
26
|
+
// report, and dispute-closes it so the queue stays clean.
|
|
27
|
+
|
|
28
|
+
'use strict';
|
|
29
|
+
|
|
30
|
+
const fs = require('fs');
|
|
31
|
+
const os = require('os');
|
|
32
|
+
const path = require('path');
|
|
33
|
+
const { execSync, execFileSync } = require('child_process');
|
|
34
|
+
|
|
35
|
+
const ROOT = path.resolve(__dirname, '..', '..');
|
|
36
|
+
const COOLDOWN_DAYS = 7; // ADR 0180 §3 — patrol-enforced until npm ≥ 11.10 in CI
|
|
37
|
+
const YOUNG_PKG_DAYS = 90; // a very new package is the slopsquat/typosquat shape
|
|
38
|
+
const MAX_ITEMS_PER_REPORT = 10;
|
|
39
|
+
|
|
40
|
+
const SEV_ORDER = { low: 0, medium: 1, high: 2, critical: 3 };
|
|
41
|
+
// npm audit says "moderate"; the bounty table says "medium".
|
|
42
|
+
function mapSeverity(s) {
|
|
43
|
+
const v = String(s || '').toLowerCase();
|
|
44
|
+
return v === 'moderate' ? 'medium' : (SEV_ORDER[v] !== undefined ? v : 'low');
|
|
45
|
+
}
|
|
46
|
+
function maxSeverity(findings) {
|
|
47
|
+
return findings.reduce((m, f) => (SEV_ORDER[f.severity] > SEV_ORDER[m] ? f.severity : m), 'low');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------- 1. full-history secret scan ----------
|
|
51
|
+
|
|
52
|
+
function gitleaksBin() {
|
|
53
|
+
const explicit = process.env.GITLEAKS_BIN;
|
|
54
|
+
if (explicit && fs.existsSync(explicit)) return explicit;
|
|
55
|
+
try {
|
|
56
|
+
execFileSync(process.platform === 'win32' ? 'where' : 'which', ['gitleaks'], { stdio: 'pipe' });
|
|
57
|
+
return 'gitleaks';
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Parse a gitleaks JSON report file into patrol findings.
|
|
64
|
+
function parseGitleaksReport(raw) {
|
|
65
|
+
let rows;
|
|
66
|
+
try { rows = JSON.parse(raw); } catch { return []; }
|
|
67
|
+
if (!Array.isArray(rows)) return [];
|
|
68
|
+
return rows.map((r) => ({
|
|
69
|
+
severity: 'high',
|
|
70
|
+
item: `${r.RuleID || r.ruleID || 'secret'} in ${r.File || r.file || '?'}${r.Commit ? ` @ ${String(r.Commit).slice(0, 8)}` : ''}`,
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function historySecretScan() {
|
|
75
|
+
const bin = gitleaksBin();
|
|
76
|
+
if (!bin) {
|
|
77
|
+
return { degraded: 'gitleaks binary not found (set GITLEAKS_BIN or `brew install gitleaks`) — history scan SKIPPED', findings: [] };
|
|
78
|
+
}
|
|
79
|
+
const report = path.join(os.tmpdir(), `gitleaks-patrol-${process.pid}.json`);
|
|
80
|
+
try {
|
|
81
|
+
execFileSync(bin, ['git', '.', '--config', '.gitleaks.toml', '--redact', '--no-banner',
|
|
82
|
+
'--report-format', 'json', '--report-path', report], { cwd: ROOT, stdio: 'pipe' });
|
|
83
|
+
} catch {
|
|
84
|
+
/* non-zero exit = leaks found; the report file is still written */
|
|
85
|
+
}
|
|
86
|
+
let findings = [];
|
|
87
|
+
try { findings = parseGitleaksReport(fs.readFileSync(report, 'utf8')); } catch { /* no report = clean */ }
|
|
88
|
+
try { fs.unlinkSync(report); } catch { /* already gone */ }
|
|
89
|
+
return { degraded: null, findings };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---------- 2. dependency review ----------
|
|
93
|
+
|
|
94
|
+
function npmViewJson(spec) {
|
|
95
|
+
try {
|
|
96
|
+
return JSON.parse(execSync(`npm view ${spec} --json`, { cwd: ROOT, stdio: 'pipe', timeout: 30000 }).toString());
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function ageDays(iso, now = Date.now()) {
|
|
103
|
+
const t = Date.parse(iso);
|
|
104
|
+
return Number.isFinite(t) ? (now - t) / 86400000 : null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function lockedVersion(lock, name) {
|
|
108
|
+
const entry = lock.packages && lock.packages[`node_modules/${name}`];
|
|
109
|
+
return entry && entry.version;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function dependencyReview() {
|
|
113
|
+
const findings = [];
|
|
114
|
+
const notes = [];
|
|
115
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
|
|
116
|
+
const lock = JSON.parse(fs.readFileSync(path.join(ROOT, 'package-lock.json'), 'utf8'));
|
|
117
|
+
|
|
118
|
+
// 2a — audit delta vs the committed baseline (same lens as the CI gate, but
|
|
119
|
+
// the patrol also reports RESOLVED entries so the baseline gets shrunk).
|
|
120
|
+
const auditGate = require('./audit-gate.js');
|
|
121
|
+
try {
|
|
122
|
+
let raw;
|
|
123
|
+
try { raw = execSync('npm audit --omit=dev --json', { cwd: ROOT, maxBuffer: 32 * 1024 * 1024, stdio: 'pipe' }).toString(); }
|
|
124
|
+
catch (e) { raw = e && e.stdout ? e.stdout.toString() : null; }
|
|
125
|
+
const current = auditGate.extractFindings(JSON.parse(raw));
|
|
126
|
+
const baseline = JSON.parse(fs.readFileSync(auditGate.BASELINE_PATH, 'utf8')).findings || [];
|
|
127
|
+
const verdict = auditGate.compare(current, baseline);
|
|
128
|
+
for (const f of [...verdict.newGating, ...verdict.newAdvisory]) {
|
|
129
|
+
findings.push({ severity: mapSeverity(f.severity), item: `new advisory: [${f.severity}] ${f.name} (${f.id}) ${f.title}` });
|
|
130
|
+
}
|
|
131
|
+
for (const f of verdict.resolved) notes.push(`baseline shrinkable: ${f.name} (${f.id}) no longer reproduces`);
|
|
132
|
+
} catch (e) {
|
|
133
|
+
notes.push(`audit delta SKIPPED (infra): ${e.message}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 2b — release-age cooldown + young packages, direct deps only (9 today).
|
|
137
|
+
for (const name of Object.keys(pkg.dependencies || {})) {
|
|
138
|
+
const version = lockedVersion(lock, name);
|
|
139
|
+
if (!version) continue;
|
|
140
|
+
const meta = npmViewJson(`${name} time`);
|
|
141
|
+
const time = meta && (meta.time || meta);
|
|
142
|
+
if (!time) { notes.push(`npm view failed for ${name} — cooldown check skipped`); continue; }
|
|
143
|
+
const vAge = ageDays(time[version]);
|
|
144
|
+
if (vAge !== null && vAge < COOLDOWN_DAYS) {
|
|
145
|
+
findings.push({ severity: 'medium', item: `cooldown breach: ${name}@${version} published ${vAge.toFixed(1)}d ago (<${COOLDOWN_DAYS}d — ADR 0180 §3)` });
|
|
146
|
+
}
|
|
147
|
+
const cAge = ageDays(time.created);
|
|
148
|
+
if (cAge !== null && cAge < YOUNG_PKG_DAYS) {
|
|
149
|
+
findings.push({ severity: 'medium', item: `young package: ${name} created ${cAge.toFixed(0)}d ago (<${YOUNG_PKG_DAYS}d — verify it is the intended package)` });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 2c — override staleness (the undici lesson: a pin nobody watches rots).
|
|
154
|
+
for (const [name, range] of Object.entries(pkg.overrides || {})) {
|
|
155
|
+
if (typeof range !== 'string') continue;
|
|
156
|
+
const latest = npmViewJson(`${name} dist-tags.latest`);
|
|
157
|
+
if (!latest) continue;
|
|
158
|
+
const pinnedMajor = parseInt(String(range).replace(/^[^0-9]*/, ''), 10);
|
|
159
|
+
const latestMajor = parseInt(String(latest), 10);
|
|
160
|
+
if (Number.isFinite(pinnedMajor) && Number.isFinite(latestMajor) && latestMajor > pinnedMajor) {
|
|
161
|
+
findings.push({ severity: 'medium', item: `stale override: ${name} pinned "${range}" while latest is ${latest} — re-justify or lift the pin` });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return { findings, notes };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------- 3. workflow action pins ----------
|
|
168
|
+
|
|
169
|
+
const USES_RE = /^\s*(?:-\s+)?uses:\s*(\S+)/;
|
|
170
|
+
const SHA_PINNED_RE = /@[0-9a-f]{40}(\s|$)/;
|
|
171
|
+
|
|
172
|
+
function unpinnedUses(yamlText) {
|
|
173
|
+
const out = [];
|
|
174
|
+
for (const line of yamlText.split('\n')) {
|
|
175
|
+
const m = USES_RE.exec(line);
|
|
176
|
+
if (!m) continue;
|
|
177
|
+
const ref = m[1];
|
|
178
|
+
if (ref.startsWith('./') || ref.startsWith('docker://')) continue; // local/composite + digest-pinnable images
|
|
179
|
+
if (!SHA_PINNED_RE.test(ref)) out.push(ref);
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function actionPinCheck() {
|
|
185
|
+
const dir = path.join(ROOT, '.github', 'workflows');
|
|
186
|
+
const findings = [];
|
|
187
|
+
if (!fs.existsSync(dir)) return { findings };
|
|
188
|
+
for (const f of fs.readdirSync(dir).filter((n) => n.endsWith('.yml') || n.endsWith('.yaml'))) {
|
|
189
|
+
for (const ref of unpinnedUses(fs.readFileSync(path.join(dir, f), 'utf8'))) {
|
|
190
|
+
findings.push({ severity: 'medium', item: `unpinned action in ${f}: "${ref}" — pin to a 40-hex commit SHA (tj-actions lesson)` });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return { findings };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---------- report assembly + filing ----------
|
|
197
|
+
|
|
198
|
+
function buildClassReport(className, findings, notes = []) {
|
|
199
|
+
const shown = findings.slice(0, MAX_ITEMS_PER_REPORT);
|
|
200
|
+
const body = [
|
|
201
|
+
...shown.map((f) => `- [${f.severity}] ${f.item}`),
|
|
202
|
+
...(findings.length > shown.length ? [`…and ${findings.length - shown.length} more`] : []),
|
|
203
|
+
...(notes.length ? ['', 'Notes:', ...notes.map((n) => `- ${n}`)] : []),
|
|
204
|
+
].join('\n');
|
|
205
|
+
return {
|
|
206
|
+
target: `redteam-patrol/${className}`,
|
|
207
|
+
severity: maxSeverity(findings),
|
|
208
|
+
title: `[patrol] ${className}: ${findings.length} finding(s)`,
|
|
209
|
+
description: `Weekly deterministic red-team patrol (task 1003128; advisory per ADR 0162 — adjudicate, then fix via ordinary tasks).`,
|
|
210
|
+
repro_steps_md: body,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function fileReport(report) {
|
|
215
|
+
const { apiCall } = require('./cli-lib.js');
|
|
216
|
+
const r = await apiCall('POST', '/api/gds/security/reports', report);
|
|
217
|
+
const row = (r.data && r.data.report) || {};
|
|
218
|
+
return row.id || null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function disputeReport(id, note) {
|
|
222
|
+
const { apiCall } = require('./cli-lib.js');
|
|
223
|
+
return apiCall('POST', `/api/gds/security/reports/${id}/dispute`, { note });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ---------- canary ----------
|
|
227
|
+
|
|
228
|
+
async function runCanary(file) {
|
|
229
|
+
const bin = gitleaksBin();
|
|
230
|
+
if (!bin) { console.error('canary FAIL: gitleaks binary unavailable'); process.exit(1); }
|
|
231
|
+
const token = 'ghp_' + 'Ab1Cd2Ef3Gh4Ij5Kl6Mn7Op8Qr9St0Uv1Wx2Y'; // constructed, never a literal shape
|
|
232
|
+
const canaryPath = path.join(ROOT, 'src', `patrol-canary-${process.pid}.js`);
|
|
233
|
+
fs.writeFileSync(canaryPath, `const t = "${token}";\n`);
|
|
234
|
+
let caught = false;
|
|
235
|
+
try {
|
|
236
|
+
const report = path.join(os.tmpdir(), `gitleaks-canary-${process.pid}.json`);
|
|
237
|
+
try {
|
|
238
|
+
execFileSync(bin, ['dir', '.', '--config', '.gitleaks.toml', '--redact', '--no-banner',
|
|
239
|
+
'--report-format', 'json', '--report-path', report], { cwd: ROOT, stdio: 'pipe' });
|
|
240
|
+
} catch { /* leaks found → non-zero, expected */ }
|
|
241
|
+
try {
|
|
242
|
+
caught = parseGitleaksReport(fs.readFileSync(report, 'utf8')).some((f) => f.item.includes('patrol-canary'));
|
|
243
|
+
fs.unlinkSync(report);
|
|
244
|
+
} catch { caught = false; }
|
|
245
|
+
} finally {
|
|
246
|
+
fs.unlinkSync(canaryPath);
|
|
247
|
+
}
|
|
248
|
+
if (!caught) { console.error('canary FAIL: planted token was not detected'); process.exit(1); }
|
|
249
|
+
console.log('canary: planted token detected by the scanner ✓');
|
|
250
|
+
if (file) {
|
|
251
|
+
const id = await fileReport({
|
|
252
|
+
target: 'redteam-patrol/canary',
|
|
253
|
+
severity: 'low',
|
|
254
|
+
title: '[canary] patrol self-test — planted token detected end-to-end',
|
|
255
|
+
description: 'Seeded finding proving the patrol can detect and FILE (task 1003128 done-when). Dispute-closed by the patrol itself; no action needed.',
|
|
256
|
+
repro_steps_md: '- plant constructed ghp_ token in src/ → gitleaks dir catches it → report filed → dispute-closed.',
|
|
257
|
+
});
|
|
258
|
+
if (!id) { console.error('canary FAIL: report did not file'); process.exit(1); }
|
|
259
|
+
console.log(`canary: filed security report ${id} ✓`);
|
|
260
|
+
await disputeReport(id, 'canary self-test (task 1003128) — auto-closed by the patrol');
|
|
261
|
+
console.log('canary: dispute-closed ✓ — end-to-end pipe proven');
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ---------- main ----------
|
|
266
|
+
|
|
267
|
+
async function main() {
|
|
268
|
+
if (process.argv.includes('--canary')) {
|
|
269
|
+
await runCanary(process.argv.includes('--file'));
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const doFile = process.argv.includes('--file');
|
|
273
|
+
|
|
274
|
+
const classes = [];
|
|
275
|
+
const secrets = historySecretScan();
|
|
276
|
+
if (secrets.findings.length) classes.push(buildClassReport('history-secrets', secrets.findings));
|
|
277
|
+
if (secrets.degraded) console.warn(`patrol: DEGRADED — ${secrets.degraded}`);
|
|
278
|
+
|
|
279
|
+
const deps = dependencyReview();
|
|
280
|
+
if (deps.findings.length) classes.push(buildClassReport('dependency-review', deps.findings, deps.notes));
|
|
281
|
+
else if (deps.notes.length) console.log(`patrol: dependency notes:\n${deps.notes.map((n) => ` - ${n}`).join('\n')}`);
|
|
282
|
+
|
|
283
|
+
const pins = actionPinCheck();
|
|
284
|
+
if (pins.findings.length) classes.push(buildClassReport('action-pins', pins.findings));
|
|
285
|
+
|
|
286
|
+
if (!classes.length) {
|
|
287
|
+
console.log('patrol: clean sweep — nothing to file.');
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
for (const report of classes) {
|
|
291
|
+
if (doFile) {
|
|
292
|
+
const id = await fileReport(report);
|
|
293
|
+
console.log(`patrol: filed [${report.severity}] ${report.title} → report ${id}`);
|
|
294
|
+
} else {
|
|
295
|
+
console.log(`patrol (dry-run): would file [${report.severity}] ${report.title}\n${report.repro_steps_md}\n`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (!doFile) console.log('patrol: dry run — pass --file to POST these as security reports.');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (require.main === module) {
|
|
302
|
+
main().catch((e) => { console.error(`patrol: ERROR ${e.message}`); process.exit(1); });
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
module.exports = {
|
|
306
|
+
parseGitleaksReport, unpinnedUses, ageDays, mapSeverity, maxSeverity,
|
|
307
|
+
buildClassReport, lockedVersion, COOLDOWN_DAYS, YOUNG_PKG_DAYS,
|
|
308
|
+
};
|