@bongos/core 1.19.256 → 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 +86 -26
- package/docs/adr/0022-secrets-policy.md +16 -12
- package/docs/adr/0180-dependency-intake-and-drift-policy.md +36 -0
- package/docs/adr/README.md +1 -0
- 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/audit-gate.js +121 -0
- 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 +2 -1
- package/src/bongos/serve-internal.js +2 -1
- package/src/module-api.js +5 -1
- package/src/platform-server.js +9 -2
- package/tests/audit_gate.mjs +89 -0
- 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,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
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// src/bongos/logger.js — the core structured logger (task 1003130, goal 1000076;
|
|
2
|
+
// docs/audits/ai-dev-redteam-baseline.md §5).
|
|
3
|
+
//
|
|
4
|
+
// The audit's logging finding: zero logging libraries, ~635 console.* calls in
|
|
5
|
+
// server paths, no request correlation, and NO redaction — a key echoed into a
|
|
6
|
+
// log line had no scrubber between it and journald. This is the fix for the
|
|
7
|
+
// CORE server path: one pino instance, request-correlation via
|
|
8
|
+
// AsyncLocalStorage, and redaction of the ADR 0022 secret-bearing header/field
|
|
9
|
+
// shapes. Modules migrate off console incrementally under the no-console
|
|
10
|
+
// ratchet (fitness-ratchets console_call_count); this module is what they
|
|
11
|
+
// migrate ONTO.
|
|
12
|
+
//
|
|
13
|
+
// Why pino: the Node structured-logging standard — JSON to stdout, low
|
|
14
|
+
// overhead, first-class redaction. It carries NO install scripts, so it is
|
|
15
|
+
// safe under the `.npmrc ignore-scripts=true` policy (ADR 0180 §2).
|
|
16
|
+
//
|
|
17
|
+
// The logger writes newline-delimited JSON to stdout; the process manager
|
|
18
|
+
// (systemd/journald) owns rotation and shipping. Level is LOG_LEVEL (default
|
|
19
|
+
// 'info'); set 'debug' to see the request-start lines.
|
|
20
|
+
|
|
21
|
+
'use strict';
|
|
22
|
+
|
|
23
|
+
const { AsyncLocalStorage } = require('node:async_hooks');
|
|
24
|
+
const crypto = require('node:crypto');
|
|
25
|
+
const pino = require('pino');
|
|
26
|
+
|
|
27
|
+
// One request-scoped store holding { reqId }. A pino mixin reads it so EVERY
|
|
28
|
+
// line emitted while handling a request carries the id, without threading it
|
|
29
|
+
// through call signatures.
|
|
30
|
+
const requestContext = new AsyncLocalStorage();
|
|
31
|
+
|
|
32
|
+
// Redaction — the secret-bearing shapes from ADR 0022 that realistically reach
|
|
33
|
+
// a log line via a logged request/header/body object. pino's `redact` replaces
|
|
34
|
+
// the value with "[Redacted]"; paths are static (never built from input — the
|
|
35
|
+
// injection caveat in pino's docs). This is defense in depth behind the
|
|
36
|
+
// scrubber and the prevent hook, not the only guard.
|
|
37
|
+
const REDACT_PATHS = [
|
|
38
|
+
'req.headers.authorization',
|
|
39
|
+
'req.headers.cookie',
|
|
40
|
+
'req.headers["x-gds-session"]',
|
|
41
|
+
'req.headers["x-api-key"]',
|
|
42
|
+
'headers.authorization',
|
|
43
|
+
'headers.cookie',
|
|
44
|
+
// top-level field names (a bare path; pino's `*.x` matches one level DOWN, not the root)
|
|
45
|
+
'authorization',
|
|
46
|
+
'token',
|
|
47
|
+
'password',
|
|
48
|
+
'secret',
|
|
49
|
+
'apiKey',
|
|
50
|
+
'api_key',
|
|
51
|
+
'sessionToken',
|
|
52
|
+
'githubToken',
|
|
53
|
+
// …and the same names one level under any key
|
|
54
|
+
'*.authorization',
|
|
55
|
+
'*.token',
|
|
56
|
+
'*.password',
|
|
57
|
+
'*.secret',
|
|
58
|
+
'*.apiKey',
|
|
59
|
+
'*.api_key',
|
|
60
|
+
'*.sessionToken',
|
|
61
|
+
'*.githubToken',
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
// Exported so a test can build a logger with the IDENTICAL config onto a
|
|
65
|
+
// capture stream — pino writes through sonic-boom straight to the fd, so the
|
|
66
|
+
// config, not the singleton's stdout, is what a hermetic test asserts against.
|
|
67
|
+
const pinoOptions = {
|
|
68
|
+
level: process.env.LOG_LEVEL || 'info',
|
|
69
|
+
// A leaked secret is a compromise; a redaction false-positive in a log is
|
|
70
|
+
// cosmetic — so redaction is broad, mirroring scrubSecrets' recall-first posture.
|
|
71
|
+
redact: { paths: REDACT_PATHS, censor: '[Redacted]' },
|
|
72
|
+
base: undefined, // drop pid/hostname noise; the process manager adds host context
|
|
73
|
+
timestamp: pino.stdTimeFunctions.isoTime,
|
|
74
|
+
mixin() {
|
|
75
|
+
const store = requestContext.getStore();
|
|
76
|
+
return store && store.reqId ? { reqId: store.reqId } : {};
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const logger = pino(pinoOptions);
|
|
81
|
+
|
|
82
|
+
// Run `fn` inside a fresh request context stamped with `reqId` (a caller-supplied
|
|
83
|
+
// inbound id, or a fresh short hex). Everything logged inside — including the
|
|
84
|
+
// error middleware — carries the same reqId.
|
|
85
|
+
function runWithRequestId(reqId, fn) {
|
|
86
|
+
return requestContext.run({ reqId: reqId || crypto.randomBytes(8).toString('hex') }, fn);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function currentRequestId() {
|
|
90
|
+
const store = requestContext.getStore();
|
|
91
|
+
return store ? store.reqId : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Express middleware: adopt an inbound correlation id (or mint one), expose it
|
|
95
|
+
// on req/res + the `x-request-id` response header, and run the rest of the
|
|
96
|
+
// request inside the AsyncLocalStorage context so every log line correlates.
|
|
97
|
+
function requestIdMiddleware(req, res, next) {
|
|
98
|
+
const inbound = req.headers['x-request-id'];
|
|
99
|
+
const reqId = (typeof inbound === 'string' && inbound.length > 0 && inbound.length <= 128)
|
|
100
|
+
? inbound
|
|
101
|
+
: crypto.randomBytes(8).toString('hex');
|
|
102
|
+
req.reqId = reqId;
|
|
103
|
+
res.setHeader('x-request-id', reqId);
|
|
104
|
+
requestContext.run({ reqId }, () => next());
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// requestContext stays module-private (nothing outside needs the raw ALS).
|
|
108
|
+
// pinoOptions/REDACT_PATHS/runWithRequestId/currentRequestId are the logging
|
|
109
|
+
// toolkit — used by tests today (via dynamic require, invisible to knip's static
|
|
110
|
+
// scan) and by future non-HTTP correlation callers.
|
|
111
|
+
module.exports = {
|
|
112
|
+
logger,
|
|
113
|
+
pinoOptions,
|
|
114
|
+
requestIdMiddleware,
|
|
115
|
+
runWithRequestId,
|
|
116
|
+
currentRequestId,
|
|
117
|
+
REDACT_PATHS,
|
|
118
|
+
};
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
// view useful; that information is lost at the SQL layer.
|
|
41
41
|
|
|
42
42
|
const db = require('../db');
|
|
43
|
+
const { logger } = require('../logger');
|
|
43
44
|
|
|
44
45
|
const REDACTION_TOKENS = ['token', 'password', 'key', 'secret'];
|
|
45
46
|
const REDACTED = '<redacted>';
|
|
@@ -205,7 +206,7 @@ function auditMiddleware(opts = {}) {
|
|
|
205
206
|
} catch (err) {
|
|
206
207
|
// redaction failures are themselves audit-failures: log + continue.
|
|
207
208
|
// We still attach the finish handler so a row goes in WITH an empty body.
|
|
208
|
-
|
|
209
|
+
logger.error({ err }, '[audit] redact_failed');
|
|
209
210
|
snapshotBody = { _redact_failed: true };
|
|
210
211
|
}
|
|
211
212
|
const ip = clientIp(req);
|
|
@@ -251,12 +252,12 @@ function auditMiddleware(opts = {}) {
|
|
|
251
252
|
.catch((err) => {
|
|
252
253
|
// SWALLOW. Audit failures must not surface to the caller and must
|
|
253
254
|
// not affect future requests. Console-error is the only effect.
|
|
254
|
-
|
|
255
|
+
logger.error({
|
|
255
256
|
route,
|
|
256
257
|
method,
|
|
257
258
|
status,
|
|
258
259
|
err: err && err.message ? err.message : String(err),
|
|
259
|
-
});
|
|
260
|
+
}, '[audit] insert_failed');
|
|
260
261
|
});
|
|
261
262
|
});
|
|
262
263
|
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
'use strict';
|
|
24
24
|
|
|
25
25
|
const { ApiError, isApiError, envelope, statusForCode } = require('../api-errors');
|
|
26
|
+
const { logger } = require('../logger');
|
|
26
27
|
|
|
27
28
|
// attachFail — decorate res with fail(). Mounted once, before the routers.
|
|
28
29
|
function attachFail(req, res, next) {
|
|
@@ -83,10 +84,10 @@ function errorEnvelopeHandler(err, req, res, next) {
|
|
|
83
84
|
return res.status(framework.status).json(envelope(framework.code));
|
|
84
85
|
}
|
|
85
86
|
|
|
86
|
-
// Anything else is an unexpected server fault. Log
|
|
87
|
-
//
|
|
88
|
-
// message/stack is never sent to the client.
|
|
89
|
-
|
|
87
|
+
// Anything else is an unexpected server fault. Log structured (auto-correlated
|
|
88
|
+
// to the request via the logger's reqId mixin, redaction applied) and answer a
|
|
89
|
+
// leak-free internal envelope — the raw message/stack is never sent to the client.
|
|
90
|
+
logger.error({ method: req && req.method, url: req && req.originalUrl, err }, '[gds] unhandled');
|
|
90
91
|
return res.status(500).json(envelope('internal_error'));
|
|
91
92
|
}
|
|
92
93
|
|
|
@@ -310,7 +310,8 @@ const MODULE_GLOBS = {
|
|
|
310
310
|
'.npmrc', '.gitleaks.toml',
|
|
311
311
|
'package.json', 'package-lock.json',
|
|
312
312
|
'scripts/gds/fitness.js', 'scripts/gds/docs-entropy.js',
|
|
313
|
-
'scripts/gds/redteam-patrol.js',
|
|
313
|
+
'scripts/gds/redteam-patrol.js', 'scripts/gds/audit-gate.js',
|
|
314
|
+
'scripts/gds/fitness-ratchets.js', 'scripts/gds/control-manifest.js',
|
|
314
315
|
],
|
|
315
316
|
};
|
|
316
317
|
|
|
@@ -39,6 +39,7 @@ const express = require('express');
|
|
|
39
39
|
const { buildGdsRouter } = require('./routes');
|
|
40
40
|
const { attachFail } = require('./middleware/error-envelope');
|
|
41
41
|
const metrics = require('./metrics');
|
|
42
|
+
const { logger } = require('./logger');
|
|
42
43
|
const gdsAuth = require('./auth');
|
|
43
44
|
const { buildStampedHtml } = require('../asset-version');
|
|
44
45
|
const { buildInfo } = require('../build-info');
|
|
@@ -289,7 +290,7 @@ function resolveOpenapiJson({ candidates = [], generate } = {}) {
|
|
|
289
290
|
const out = generate();
|
|
290
291
|
if (out) return out;
|
|
291
292
|
} catch (e) {
|
|
292
|
-
|
|
293
|
+
logger.error({ err: e && e.message }, '[docs] in-process OpenAPI generation failed');
|
|
293
294
|
}
|
|
294
295
|
}
|
|
295
296
|
return '';
|
package/src/module-api.js
CHANGED
|
@@ -939,8 +939,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
939
939
|
// landed since 1.19.254 with no explicit bump. run 32207967928. (task 1002620)
|
|
940
940
|
// 1.19.256 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
941
941
|
// landed since 1.19.255 with no explicit bump. run 32208475455. (task 1002620)
|
|
942
|
+
// 1.19.257 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
943
|
+
// landed since 1.19.256 with no explicit bump. run 32209295358. (task 1002620)
|
|
944
|
+
// 1.19.258 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
945
|
+
// landed since 1.19.257 with no explicit bump. run 32284962866. (task 1002620)
|
|
942
946
|
// ---------------------------------------------------------------------------
|
|
943
|
-
const CORE_VERSION = '1.19.
|
|
947
|
+
const CORE_VERSION = '1.19.258'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
944
948
|
|
|
945
949
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
946
950
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/src/platform-server.js
CHANGED
|
@@ -49,6 +49,7 @@ const { mountInternalSurfaces, isStatusHost, isBuildersHost, injectBrandingGloba
|
|
|
49
49
|
const { isApiShapedPath, apiNotFoundBody } = require('./bongos/api-path-404');
|
|
50
50
|
const moduleLoader = require('./module-loader/loader');
|
|
51
51
|
const { isModuleEnabled } = require('./modules');
|
|
52
|
+
const { logger, requestIdMiddleware } = require('./bongos/logger');
|
|
52
53
|
|
|
53
54
|
const PORT = Number(process.env.PORT) || 3000;
|
|
54
55
|
const HOST = process.env.HOST || '127.0.0.1';
|
|
@@ -157,6 +158,12 @@ function buildPlatformApp() {
|
|
|
157
158
|
// the OAuth callbacks the GDS auth router performs.
|
|
158
159
|
app.set('trust proxy', 1);
|
|
159
160
|
|
|
161
|
+
// Request correlation FIRST (task 1003130): adopt/mint an x-request-id and run
|
|
162
|
+
// the rest of the request inside the AsyncLocalStorage context, so every
|
|
163
|
+
// structured log line — including the central error handler's — carries the
|
|
164
|
+
// same reqId. Mounted before any surface so nothing logs uncorrelated.
|
|
165
|
+
app.use(requestIdMiddleware);
|
|
166
|
+
|
|
160
167
|
// The whole internal surface — shared verbatim with server.js (ADR 0062 R51).
|
|
161
168
|
mountInternalSurfaces(app);
|
|
162
169
|
|
|
@@ -263,7 +270,7 @@ if (require.main === module) {
|
|
|
263
270
|
const app = buildPlatformApp();
|
|
264
271
|
const httpServer = http.createServer(app);
|
|
265
272
|
httpServer.listen(PORT, HOST, () => {
|
|
266
|
-
|
|
273
|
+
logger.info({ host: HOST, port: PORT }, 'Cloud Bongos platform (GDS-only, no game; ADR 0062 R51) listening');
|
|
267
274
|
startBackgroundServices();
|
|
268
275
|
});
|
|
269
276
|
|
|
@@ -288,7 +295,7 @@ if (require.main === module) {
|
|
|
288
295
|
// up with a logged error strictly dominates dying. A rejection that leaves genuinely
|
|
289
296
|
// corrupt state is not a shape this codebase produces — the DB work is transactional.
|
|
290
297
|
process.on('unhandledRejection', (reason) => {
|
|
291
|
-
|
|
298
|
+
logger.error({ reason }, '[platform] unhandled promise rejection (staying up; this is a BUG — the owning await needs its own catch)');
|
|
292
299
|
});
|
|
293
300
|
}
|
|
294
301
|
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// tests/audit_gate.mjs — the dependency audit gate's pure core (task 1003127,
|
|
2
|
+
// goal 1000076; ADR 0180). Hermetic: exercises extractFindings + compare on
|
|
3
|
+
// fixture JSON — the npm/network half is CI-only by design (fail-open there).
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import { test } from 'node:test';
|
|
6
|
+
import { createRequire } from 'node:module';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
12
|
+
const gate = require(path.join(ROOT, 'scripts', 'gds', 'audit-gate.js'));
|
|
13
|
+
|
|
14
|
+
const AUDIT_FIXTURE = {
|
|
15
|
+
vulnerabilities: {
|
|
16
|
+
// parent rollup whose via is a STRING (another package) — contributes nothing
|
|
17
|
+
'discord.js': { severity: 'moderate', via: ['undici'] },
|
|
18
|
+
// real advisory objects — these are the findings
|
|
19
|
+
undici: {
|
|
20
|
+
severity: 'moderate',
|
|
21
|
+
via: [
|
|
22
|
+
{ source: 101, name: 'undici', severity: 'moderate', title: 'CRLF injection' },
|
|
23
|
+
{ source: 102, name: 'undici', severity: 'moderate', title: 'response desync' },
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
sharp: {
|
|
27
|
+
severity: 'high',
|
|
28
|
+
via: [{ source: 200, name: 'sharp', severity: 'high', title: 'libvips CVEs' }],
|
|
29
|
+
},
|
|
30
|
+
nanoid: {
|
|
31
|
+
severity: 'high',
|
|
32
|
+
// duplicate of the same advisory via two chains — must dedupe
|
|
33
|
+
via: [
|
|
34
|
+
{ source: 300, name: 'nanoid', severity: 'high', title: 'predictable ids' },
|
|
35
|
+
{ source: 300, name: 'nanoid', severity: 'high', title: 'predictable ids' },
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
test('extractFindings flattens advisory objects, skips string vias, dedupes by advisory×package', () => {
|
|
42
|
+
const f = gate.extractFindings(AUDIT_FIXTURE);
|
|
43
|
+
assert.equal(f.length, 4, `expected 4 unique findings, got ${JSON.stringify(f)}`);
|
|
44
|
+
assert.ok(f.every((x) => typeof x.id === 'string' && x.name && x.severity));
|
|
45
|
+
assert.equal(f.filter((x) => x.name === 'nanoid').length, 1, 'duplicate advisory chains collapse');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('a baseline-covered tree passes: no newGating, no newAdvisory', () => {
|
|
49
|
+
const f = gate.extractFindings(AUDIT_FIXTURE);
|
|
50
|
+
const v = gate.compare(f, f);
|
|
51
|
+
assert.equal(v.newGating.length, 0);
|
|
52
|
+
assert.equal(v.newAdvisory.length, 0);
|
|
53
|
+
assert.equal(v.resolved.length, 0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('a NEW high advisory gates; a new moderate only advises', () => {
|
|
57
|
+
const baseline = gate.extractFindings(AUDIT_FIXTURE);
|
|
58
|
+
const current = [
|
|
59
|
+
...baseline,
|
|
60
|
+
{ id: '999', name: 'express', severity: 'high', title: 'fresh high' },
|
|
61
|
+
{ id: '998', name: 'pg', severity: 'moderate', title: 'fresh moderate' },
|
|
62
|
+
];
|
|
63
|
+
const v = gate.compare(current, baseline);
|
|
64
|
+
assert.deepEqual(v.newGating.map((f) => f.id), ['999']);
|
|
65
|
+
assert.deepEqual(v.newAdvisory.map((f) => f.id), ['998']);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('a fixed advisory surfaces as a ratchet-down nudge, never a failure', () => {
|
|
69
|
+
const baseline = gate.extractFindings(AUDIT_FIXTURE);
|
|
70
|
+
const current = baseline.filter((f) => f.name !== 'sharp');
|
|
71
|
+
const v = gate.compare(current, baseline);
|
|
72
|
+
assert.equal(v.newGating.length, 0);
|
|
73
|
+
assert.deepEqual(v.resolved.map((f) => f.name), ['sharp']);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('empty/missing baseline means every high gates (the fresh-repo case)', () => {
|
|
77
|
+
const v = gate.compare(gate.extractFindings(AUDIT_FIXTURE), []);
|
|
78
|
+
assert.equal(v.newGating.length, 2, 'sharp + nanoid gate');
|
|
79
|
+
assert.equal(v.newAdvisory.length, 2, 'the two undici moderates advise');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('the committed baseline file parses and only carries known severities', () => {
|
|
83
|
+
const b = require(path.join(ROOT, 'config', 'audit-baseline.json'));
|
|
84
|
+
assert.ok(Array.isArray(b.findings) && b.findings.length > 0);
|
|
85
|
+
for (const f of b.findings) {
|
|
86
|
+
assert.ok(['low', 'moderate', 'high', 'critical', 'info', 'unknown'].includes(f.severity), `odd severity ${f.severity}`);
|
|
87
|
+
assert.ok(f.id && f.name, 'baseline entries carry advisory id + package');
|
|
88
|
+
}
|
|
89
|
+
});
|
|
@@ -140,7 +140,7 @@ test('a process-level unhandledRejection backstop is registered and does not exi
|
|
|
140
140
|
assert.match(src, /process\.on\('unhandledRejection'/, 'the backstop must exist');
|
|
141
141
|
const handler = src.slice(src.indexOf("process.on('unhandledRejection'"));
|
|
142
142
|
const body = handler.slice(0, handler.indexOf('});') + 3);
|
|
143
|
-
assert.match(body, /console\.error/, 'it must log');
|
|
143
|
+
assert.match(body, /console\.error|logger\.error/, 'it must log (console or the structured logger — task 1003130 moved this to logger.error)');
|
|
144
144
|
assert.doesNotMatch(body, /process\.exit/,
|
|
145
145
|
'it must NOT exit — one process serves every request, so staying up dominates dying');
|
|
146
146
|
});
|