argusqa-os 9.8.0 → 9.9.0
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/README.md +32 -8
- package/glama.json +2 -2
- package/package.json +5 -3
- package/scripts/rehydrate-report.mjs +62 -0
- package/src/cli/init.js +3 -1
- package/src/cli/pr-validate.js +40 -5
- package/src/mcp-server.js +130 -16
- package/src/orchestration/dispatcher.js +28 -0
- package/src/orchestration/report-processor.js +22 -1
- package/src/utils/aegis-vault.js +251 -0
- package/src/utils/github-reporter.js +33 -4
- package/src/utils/html-reporter.js +26 -1
- package/src/utils/import-graph.js +9 -3
- package/src/utils/pr-diff-analyzer.js +0 -2
- package/src/utils/secret-patterns.js +428 -0
- package/src/utils/sensitivity-classifier.js +498 -0
- package/src/utils/session-persistence.js +6 -1
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aegis — sensitivity classifier + egress projector (Step 2 of
|
|
3
|
+
* REDACTION_BOUNDARY_MAX_PLAN.md).
|
|
4
|
+
*
|
|
5
|
+
* Two functions, one contract (plan §4.2):
|
|
6
|
+
* - classifySensitivity(report) — the DETECTOR. Runs once in the pipeline,
|
|
7
|
+
* tags each finding {sensitive, sensitivityReasons, localRef} on a shallow
|
|
8
|
+
* CLONE (never mutates the frozen original, §5.4), preserving full local
|
|
9
|
+
* detail. Idempotent. FAILS CLOSED per finding.
|
|
10
|
+
* - redactForEgress(findings) — the PROJECTOR. Pure. Returns BRAND-NEW
|
|
11
|
+
* objects carrying only the deny-by-default field allowlist (§5.2); a
|
|
12
|
+
* sensitive finding's detail becomes a 🔒 marker, a benign finding's message
|
|
13
|
+
* survives only after a mandatory scrubText() catch-all. FAILS CLOSED per
|
|
14
|
+
* finding (any error ⇒ a fully-redacted stub, never the raw finding).
|
|
15
|
+
*
|
|
16
|
+
* SECURITY POSTURE — fail CLOSED. Ambiguity, errors, and unknown shapes redact
|
|
17
|
+
* MORE, never less. The deny-by-default allowlist guarantees a finding field
|
|
18
|
+
* added next year leaks NOTHING until someone deliberately allowlists it.
|
|
19
|
+
*
|
|
20
|
+
* Layer 1 (category rules) lives here; Layers 2–5 (secrets/entropy/PII/context)
|
|
21
|
+
* come from secret-patterns.js.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { childLogger } from './logger.js';
|
|
25
|
+
import { findSensitiveSpans, scrubText, stripZeroWidth } from './secret-patterns.js';
|
|
26
|
+
import { ensureVaultWired } from './aegis-vault.js';
|
|
27
|
+
|
|
28
|
+
const logger = childLogger('aegis');
|
|
29
|
+
|
|
30
|
+
// ── Layer 1 — category rules ────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Non-`security_*` finding types treated as sensitive by category. (Every type
|
|
34
|
+
* beginning `security_` is caught by the prefix test in isSensitiveType.)
|
|
35
|
+
* @type {Set<string>}
|
|
36
|
+
*/
|
|
37
|
+
export const SENSITIVE_TYPES = new Set([
|
|
38
|
+
'cors_violation', 'cors_error', 'mixed_content', 'permission_policy_violation',
|
|
39
|
+
'cookie_attribute_missing', 'csp_violation', 'unclassified_devtools_issue',
|
|
40
|
+
'uncaught_exception', 'unhandled_rejection', 'sensitive_console',
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Fields whose mere presence on a finding marks it sensitive — a body/stack/
|
|
45
|
+
* header/cookie blob is exploit-or-secret-bearing regardless of the finding type
|
|
46
|
+
* (the Layer-1 catch-all that makes an unknown future type fail closed).
|
|
47
|
+
* @type {string[]}
|
|
48
|
+
*/
|
|
49
|
+
export const BODY_FIELDS = ['requestBody', 'responseBody', 'stack', 'headers', 'cookies'];
|
|
50
|
+
|
|
51
|
+
/** Free-text fields scanned by the content layers (2–5). */
|
|
52
|
+
const TEXT_FIELDS = ['message', 'evidence', 'snippet', 'payload', 'detail', 'title',
|
|
53
|
+
'url', 'requestUrl', 'source', 'element', 'property'];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Deny-by-default egress field allowlist (§5.2). ONLY these keys may ever appear
|
|
57
|
+
* in an external object; everything else is dropped. `url`/`requestUrl` pass
|
|
58
|
+
* through sanitizeUrl; a string `value` is scrubbed; `message` survives only for
|
|
59
|
+
* a non-sensitive finding and only after scrubText.
|
|
60
|
+
*/
|
|
61
|
+
export const EGRESS_ALLOWLIST = ['type', 'severity', 'route', 'url', 'requestUrl',
|
|
62
|
+
'selector', 'status', 'method', 'metric', 'value', 'budget', 'count', 'title',
|
|
63
|
+
'isNew', 'noisy', 'flaky', 'localRef'];
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {string} type
|
|
67
|
+
* @returns {boolean}
|
|
68
|
+
*/
|
|
69
|
+
export function isSensitiveType(type) {
|
|
70
|
+
if (typeof type !== 'string') return true; // unknown shape ⇒ fail closed
|
|
71
|
+
if (type.startsWith('security_')) return true;
|
|
72
|
+
return SENSITIVE_TYPES.has(type);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Detection ───────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Build a stable, secret-free key for a finding (the localRef fragment + audit
|
|
79
|
+
* key). `type::route` per the plan example (`security_no_https::/login`).
|
|
80
|
+
* @param {object} finding
|
|
81
|
+
* @returns {string}
|
|
82
|
+
*/
|
|
83
|
+
export function findingKey(finding) {
|
|
84
|
+
const type = finding?.type ?? 'unknown';
|
|
85
|
+
const route = finding?.route ?? routeFromUrl(finding?.url) ?? '';
|
|
86
|
+
return `${type}::${route}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Classify ONE finding across all five layers.
|
|
91
|
+
* L1 category/body-field · L2 secret regex · L3 entropy/token-efficiency ·
|
|
92
|
+
* L4 PII · L5 context (the latter four via findSensitiveSpans over its free
|
|
93
|
+
* text). Sensitive iff any layer fires.
|
|
94
|
+
*
|
|
95
|
+
* @param {object} finding
|
|
96
|
+
* @returns {{ sensitive: boolean, sensitivityReasons: string[] }}
|
|
97
|
+
*/
|
|
98
|
+
export function classifyFinding(finding) {
|
|
99
|
+
const reasons = [];
|
|
100
|
+
|
|
101
|
+
// Layer 1 — category + body-field catch-all.
|
|
102
|
+
if (isSensitiveType(finding?.type)) reasons.push(`category:${finding?.type ?? 'unknown'}`);
|
|
103
|
+
for (const f of BODY_FIELDS) {
|
|
104
|
+
const v = finding?.[f];
|
|
105
|
+
if (v !== undefined && v !== null && v !== '') { reasons.push(`category:has_${f}`); break; }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Layers 2–5 — content scan over every free-text + serialisable field.
|
|
109
|
+
let blob = '';
|
|
110
|
+
for (const f of TEXT_FIELDS) {
|
|
111
|
+
const v = finding?.[f];
|
|
112
|
+
if (typeof v === 'string' && v) blob += ' ' + v;
|
|
113
|
+
}
|
|
114
|
+
for (const f of BODY_FIELDS) {
|
|
115
|
+
const v = finding?.[f];
|
|
116
|
+
if (v === undefined || v === null) continue;
|
|
117
|
+
blob += ' ' + (typeof v === 'string' ? v : safeStringify(v));
|
|
118
|
+
}
|
|
119
|
+
const addSpan = (span) => {
|
|
120
|
+
const tag = span.kind === 'secret' ? `secret:${span.name}`
|
|
121
|
+
: span.kind === 'pii' ? `pii:${span.name}`
|
|
122
|
+
: 'entropy:high';
|
|
123
|
+
if (reasons.includes(tag)) return false;
|
|
124
|
+
reasons.push(tag);
|
|
125
|
+
return true;
|
|
126
|
+
};
|
|
127
|
+
for (const span of findSensitiveSpans(blob)) addSpan(span);
|
|
128
|
+
// Defeat zero-width-splice evasion: re-scan a boundary-preserving, zero-width-
|
|
129
|
+
// stripped variant so \b-anchored rules (email/private-host) and the secret
|
|
130
|
+
// regexes still fire after a U+200B/soft-hyphen splice. ADDITIVE + fail closed.
|
|
131
|
+
const zwStripped = stripZeroWidth(blob);
|
|
132
|
+
if (zwStripped !== blob) {
|
|
133
|
+
let addedNew = false;
|
|
134
|
+
for (const span of findSensitiveSpans(zwStripped)) { if (addSpan(span)) addedNew = true; }
|
|
135
|
+
if (addedNew) reasons.push('evasion:normalized');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { sensitive: reasons.length > 0, sensitivityReasons: reasons };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Walk every finding in the report, tag each with sensitivity on a SHALLOW CLONE
|
|
143
|
+
* (never mutating the frozen original), and attach a localRef pointer. Fails
|
|
144
|
+
* closed per finding: any classifier error ⇒ that finding is marked sensitive.
|
|
145
|
+
*
|
|
146
|
+
* @param {object} report
|
|
147
|
+
* @param {{ reportRef?: string }} [opts]
|
|
148
|
+
* @returns {{ sensitiveCount: number }}
|
|
149
|
+
*/
|
|
150
|
+
export function classifySensitivity(report, opts = {}) {
|
|
151
|
+
const reportRef = opts.reportRef || 'local-report';
|
|
152
|
+
let sensitiveCount = 0;
|
|
153
|
+
|
|
154
|
+
const tag = (finding) => {
|
|
155
|
+
try {
|
|
156
|
+
const result = classifyFinding(finding);
|
|
157
|
+
if (result.sensitive) sensitiveCount++;
|
|
158
|
+
// Shallow clone — works whether or not the source is Object.freeze'd, and
|
|
159
|
+
// never removes local detail (the local report stays pristine + now tagged).
|
|
160
|
+
// The spread can itself throw on a pathological getter, so it lives INSIDE
|
|
161
|
+
// the try: any failure falls through to the fail-closed stub below.
|
|
162
|
+
return {
|
|
163
|
+
...finding,
|
|
164
|
+
sensitive: result.sensitive,
|
|
165
|
+
sensitivityReasons: result.sensitivityReasons,
|
|
166
|
+
localRef: `${reportRef}#${findingKey(finding)}`,
|
|
167
|
+
};
|
|
168
|
+
} catch (err) {
|
|
169
|
+
logger.error(`[ARGUS] Aegis classify/clone threw — failing closed: ${err.message}`);
|
|
170
|
+
sensitiveCount++;
|
|
171
|
+
// Do NOT spread the finding (a getter may re-throw). Salvage safe scalars.
|
|
172
|
+
const type = safeGet(finding, 'type') ?? 'unknown';
|
|
173
|
+
const route = safeGet(finding, 'route') ?? routeFromUrl(safeGet(finding, 'url')) ?? '';
|
|
174
|
+
return { type, severity: safeGet(finding, 'severity') ?? 'critical',
|
|
175
|
+
sensitive: true, sensitivityReasons: ['classifier-error'],
|
|
176
|
+
localRef: `${reportRef}#${type}::${route}` };
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
for (const routeResult of (report?.routes ?? [])) {
|
|
181
|
+
if (Array.isArray(routeResult?.errors)) routeResult.errors = routeResult.errors.map(tag);
|
|
182
|
+
}
|
|
183
|
+
for (const flowResult of (report?.flows ?? [])) {
|
|
184
|
+
if (Array.isArray(flowResult?.findings)) flowResult.findings = flowResult.findings.map(tag);
|
|
185
|
+
}
|
|
186
|
+
if (Array.isArray(report?.codebase)) report.codebase = report.codebase.map(tag);
|
|
187
|
+
|
|
188
|
+
return { sensitiveCount };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── Projection ──────────────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Keep origin + path; strip the query string, fragment, and userinfo (tokens
|
|
195
|
+
* hide in query params and `user:pass@`). Plan §5.2.
|
|
196
|
+
* @param {*} u
|
|
197
|
+
* @returns {string}
|
|
198
|
+
*/
|
|
199
|
+
export function sanitizeUrl(u) {
|
|
200
|
+
if (typeof u !== 'string' || u === '') return '';
|
|
201
|
+
try {
|
|
202
|
+
const parsed = new URL(u);
|
|
203
|
+
return `${parsed.origin}${parsed.pathname}`; // origin excludes userinfo + query + hash
|
|
204
|
+
} catch {
|
|
205
|
+
// Relative / malformed — strip manually.
|
|
206
|
+
return u.replace(/^([a-z]+:\/\/)([^/\s@]+@)/i, '$1') // userinfo
|
|
207
|
+
.split('#')[0].split('?')[0];
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Logical route name for a finding: explicit `route`, else the URL pathname.
|
|
213
|
+
* @param {*} u
|
|
214
|
+
* @returns {string}
|
|
215
|
+
*/
|
|
216
|
+
export function routeFromUrl(u) {
|
|
217
|
+
if (typeof u !== 'string' || u === '') return '';
|
|
218
|
+
try {
|
|
219
|
+
return new URL(u).pathname || '/';
|
|
220
|
+
} catch {
|
|
221
|
+
return u.replace(/^[a-z]+:\/\/[^/]+/i, '').split('#')[0].split('?')[0] || u;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function routeOf(finding) {
|
|
226
|
+
return finding?.route ?? routeFromUrl(finding?.url) ?? '';
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function titleFor(finding) {
|
|
230
|
+
return `${finding?.type ?? 'finding'} on ${routeOf(finding) || '/'} (${finding?.severity ?? 'info'})`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export const REDACT_MARKER = '🔒 redacted — full detail in local report (localRef)';
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Project ONE finding to its egress-safe shape under the deny-by-default
|
|
237
|
+
* allowlist. Builds a brand-new object — never mutates the input.
|
|
238
|
+
* @param {object} finding
|
|
239
|
+
* @param {{ mode: string, failClosed: boolean }} ctx
|
|
240
|
+
* @returns {object}
|
|
241
|
+
*/
|
|
242
|
+
function projectFinding(finding, ctx) {
|
|
243
|
+
let sensitive = ctx.failClosed === true || finding?.sensitive === true;
|
|
244
|
+
if (!sensitive) sensitive = classifyFinding(finding).sensitive; // untagged fallback
|
|
245
|
+
|
|
246
|
+
const out = {
|
|
247
|
+
type: finding?.type ?? 'unknown',
|
|
248
|
+
severity: finding?.severity ?? 'info',
|
|
249
|
+
route: routeOf(finding),
|
|
250
|
+
title: titleFor(finding),
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
// url / requestUrl — sanitised (origin+path, no query/fragment/userinfo).
|
|
254
|
+
if (typeof finding?.url === 'string' && finding.url) out.url = sanitizeUrl(finding.url);
|
|
255
|
+
if (typeof finding?.requestUrl === 'string' && finding.requestUrl) out.requestUrl = sanitizeUrl(finding.requestUrl);
|
|
256
|
+
|
|
257
|
+
// Structural / short-enum passthroughs (only when present).
|
|
258
|
+
if (typeof finding?.selector === 'string') out.selector = finding.selector;
|
|
259
|
+
if (finding?.status !== undefined) out.status = finding.status;
|
|
260
|
+
if (typeof finding?.method === 'string') out.method = finding.method;
|
|
261
|
+
if (typeof finding?.metric === 'string') out.metric = finding.metric;
|
|
262
|
+
if (finding?.budget !== undefined && typeof finding.budget !== 'string') out.budget = finding.budget;
|
|
263
|
+
if (typeof finding?.count === 'number') out.count = finding.count;
|
|
264
|
+
// value: numeric passes through; a string value is free-text ⇒ scrub it.
|
|
265
|
+
if (typeof finding?.value === 'number') out.value = finding.value;
|
|
266
|
+
else if (typeof finding?.value === 'string') out.value = scrubText(finding.value, { mode: ctx.mode });
|
|
267
|
+
|
|
268
|
+
// Booleans needed for new/persisting/resolved reconciliation.
|
|
269
|
+
for (const b of ['isNew', 'noisy', 'flaky']) {
|
|
270
|
+
if (typeof finding?.[b] === 'boolean') out[b] = finding[b];
|
|
271
|
+
}
|
|
272
|
+
if (typeof finding?.localRef === 'string') out.localRef = finding.localRef;
|
|
273
|
+
|
|
274
|
+
if (sensitive) {
|
|
275
|
+
// Sensitive: the RAW message never crosses. The redaction marker is carried in BOTH
|
|
276
|
+
// `detail` (the explicit provenance marker, plan §5.2) AND `message` — the latter so the
|
|
277
|
+
// canonical finding contract's required `message` (golden findingSchema) is satisfied and
|
|
278
|
+
// every sink that renders f.message shows the notice without special-casing. The marker is
|
|
279
|
+
// a compile-time constant — provably secret-free (the fuzz/red-team "no secret substring"
|
|
280
|
+
// invariants hold unchanged).
|
|
281
|
+
out.detail = REDACT_MARKER; // detail withheld; pointer is localRef
|
|
282
|
+
out.message = REDACT_MARKER;
|
|
283
|
+
} else {
|
|
284
|
+
// Catch-all: a benign finding keeps its helpful message, but only after the
|
|
285
|
+
// mandatory secret/PII scrub (an accidental key in an innocuous message dies).
|
|
286
|
+
out.message = scrubText(typeof finding?.message === 'string' ? finding.message : '', { mode: ctx.mode });
|
|
287
|
+
}
|
|
288
|
+
return out;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The egress projector. Returns a NEW array of minimal, sanitised objects safe
|
|
293
|
+
* to cross any trust boundary. Honours the ARGUS_REDACT_SENSITIVE=0 opt-out
|
|
294
|
+
* (byte-identical passthrough) and fails closed per finding.
|
|
295
|
+
*
|
|
296
|
+
* @param {object[]} findings
|
|
297
|
+
* @param {{ mode?: string, failClosed?: boolean }} [opts]
|
|
298
|
+
* @returns {object[]}
|
|
299
|
+
*/
|
|
300
|
+
export function redactForEgress(findings, opts = {}) {
|
|
301
|
+
const list = Array.isArray(findings) ? findings : [];
|
|
302
|
+
// Opt-out: byte-identical to pre-Aegis (the sinks shipped raw findings).
|
|
303
|
+
if (process.env.ARGUS_REDACT_SENSITIVE === '0') return list.slice();
|
|
304
|
+
|
|
305
|
+
const mode = process.env.ARGUS_REDACT_MODE || opts.mode || 'mask';
|
|
306
|
+
const failClosed = opts.failClosed === true;
|
|
307
|
+
if (mode === 'token') ensureVaultWired(); // wire the reversible vault seam (Step 8) lazily
|
|
308
|
+
|
|
309
|
+
return list.map((finding) => {
|
|
310
|
+
try {
|
|
311
|
+
return projectFinding(finding, { mode, failClosed });
|
|
312
|
+
} catch (err) {
|
|
313
|
+
// Per-finding fail-closed: emit a fully-redacted stub, never the raw finding.
|
|
314
|
+
logger.error(`[ARGUS] Aegis redactForEgress threw — emitting redacted stub: ${err.message}`);
|
|
315
|
+
return {
|
|
316
|
+
type: finding?.type ?? 'unknown',
|
|
317
|
+
severity: finding?.severity ?? 'critical', // worst-case on uncertainty
|
|
318
|
+
route: '',
|
|
319
|
+
title: 'redacted',
|
|
320
|
+
detail: REDACT_MARKER,
|
|
321
|
+
localRef: typeof finding?.localRef === 'string' ? finding.localRef : null,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Aggregate counts for the "N sensitive finding(s) redacted" banners. Fails
|
|
329
|
+
* closed: a finding whose classification throws is counted as redacted.
|
|
330
|
+
*
|
|
331
|
+
* @param {object[]} findings
|
|
332
|
+
* @returns {{ total: number, redacted: number, byReason: Record<string, number> }}
|
|
333
|
+
*/
|
|
334
|
+
export function summarizeRedaction(findings) {
|
|
335
|
+
const list = Array.isArray(findings) ? findings : [];
|
|
336
|
+
let redacted = 0;
|
|
337
|
+
const byReason = Object.create(null);
|
|
338
|
+
for (const finding of list) {
|
|
339
|
+
let sensitive;
|
|
340
|
+
let reasons;
|
|
341
|
+
if (finding?.sensitive === true) {
|
|
342
|
+
sensitive = true;
|
|
343
|
+
reasons = Array.isArray(finding.sensitivityReasons) ? finding.sensitivityReasons : [];
|
|
344
|
+
} else if (finding?.sensitive === false) {
|
|
345
|
+
sensitive = false;
|
|
346
|
+
reasons = [];
|
|
347
|
+
} else {
|
|
348
|
+
try {
|
|
349
|
+
const r = classifyFinding(finding);
|
|
350
|
+
sensitive = r.sensitive;
|
|
351
|
+
reasons = r.sensitivityReasons;
|
|
352
|
+
} catch {
|
|
353
|
+
sensitive = true; // fail closed
|
|
354
|
+
reasons = ['classifier-error'];
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (sensitive) {
|
|
358
|
+
redacted++;
|
|
359
|
+
for (const reason of reasons) byReason[reason] = (byReason[reason] || 0) + 1;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return { total: list.length, redacted, byReason };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ── Egress riders + loose-object scrubbing (MCP/sink helpers, plan §6 Step 4) ──
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Build the `redaction` rider every guarded egress response carries: a secret-free
|
|
369
|
+
* summary of how much detail was withheld + a pointer to the full local report.
|
|
370
|
+
* Powers the "🔒 N redacted" banner. Plan §6 Step 4 / §9.1 [168i].
|
|
371
|
+
*
|
|
372
|
+
* @param {object[]} rawFindings the ORIGINAL (un-projected) findings
|
|
373
|
+
* @param {{ localReportPath?: string|null }} [opts]
|
|
374
|
+
* @returns {{ redacted: number, total: number, localReportPath: string|null, note: string }}
|
|
375
|
+
*/
|
|
376
|
+
export function buildRedactionRider(rawFindings, opts = {}) {
|
|
377
|
+
const list = Array.isArray(rawFindings) ? rawFindings : [];
|
|
378
|
+
const r = summarizeRedaction(list);
|
|
379
|
+
// Fail-closed (report-level _aegisFailClosed): EVERY finding was force-redacted, so the
|
|
380
|
+
// rider must say so rather than undercounting via the per-finding classifier.
|
|
381
|
+
const redacted = opts.failClosed === true ? list.length : r.redacted;
|
|
382
|
+
return {
|
|
383
|
+
redacted,
|
|
384
|
+
total: r.total,
|
|
385
|
+
localReportPath: opts.localReportPath ?? null,
|
|
386
|
+
note: 'Detail withheld at the trust boundary — full report retained on the local machine.',
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Keys whose string value is a URL → sanitised (origin+path) before scrubbing. */
|
|
391
|
+
const URL_KEYS = new Set(['url', 'requestUrl', 'documentUrl', 'documentURL', 'source',
|
|
392
|
+
'href', 'location', 'devUrl', 'stagingUrl', 'baseUrl', 'redirectUrl']);
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Recursively scrub an arbitrary LOOSE object/array (console messages, network
|
|
396
|
+
* requests, env-comparison diffs) for egress: every string runs through scrubText
|
|
397
|
+
* (secrets/PII masked); a URL-valued key is sanitised (query/userinfo stripped)
|
|
398
|
+
* first; numbers/booleans pass through; a throwing getter is dropped (fail closed).
|
|
399
|
+
* Bounded recursion depth. Returns a BRAND-NEW value — never mutates the input.
|
|
400
|
+
*
|
|
401
|
+
* Unlike redactForEgress (deny-by-default findings projection), deepScrub PRESERVES
|
|
402
|
+
* the object's key set — these payloads have no canonical allowlist, so the safety
|
|
403
|
+
* comes from scrubbing every string rather than from dropping unknown keys.
|
|
404
|
+
*
|
|
405
|
+
* @param {*} value
|
|
406
|
+
* @param {{ mode?: string }} [opts]
|
|
407
|
+
* @param {number} [depth]
|
|
408
|
+
* @returns {*}
|
|
409
|
+
*/
|
|
410
|
+
export function deepScrub(value, opts = {}, depth = 0) {
|
|
411
|
+
if (process.env.ARGUS_REDACT_SENSITIVE === '0') return value;
|
|
412
|
+
const mode = process.env.ARGUS_REDACT_MODE || opts.mode || 'mask';
|
|
413
|
+
if (mode === 'token' && depth === 0) ensureVaultWired(); // wire the vault seam once per top-level call
|
|
414
|
+
if (value === null || value === undefined) return value;
|
|
415
|
+
if (typeof value === 'string') return scrubText(value, { mode });
|
|
416
|
+
if (typeof value === 'number' || typeof value === 'boolean') return value;
|
|
417
|
+
if (depth >= 6) return undefined; // pathological nesting → drop (fail closed)
|
|
418
|
+
if (Array.isArray(value)) return value.map((v) => deepScrub(v, opts, depth + 1));
|
|
419
|
+
if (typeof value === 'object') {
|
|
420
|
+
const out = {};
|
|
421
|
+
for (const k of Object.keys(value)) {
|
|
422
|
+
let v;
|
|
423
|
+
try { v = value[k]; } catch { continue; } // throwing getter → drop the field
|
|
424
|
+
if (URL_KEYS.has(k) && typeof v === 'string') out[k] = scrubText(sanitizeUrl(v), { mode });
|
|
425
|
+
else out[k] = deepScrub(v, opts, depth + 1);
|
|
426
|
+
}
|
|
427
|
+
return out;
|
|
428
|
+
}
|
|
429
|
+
return undefined; // functions / symbols → dropped
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Project a REPORT-shaped object (argus_audit_full / argus_compare /
|
|
434
|
+
* argus_last_report) for egress: every finding array (routes[].errors,
|
|
435
|
+
* routes[].findings, flows[].findings, codebase[]) runs through redactForEgress;
|
|
436
|
+
* env-comparison routes[].diffs are deepScrub'd; a `redaction` rider is attached.
|
|
437
|
+
* Report metadata (summary, route urls, mode, timestamps) is PRESERVED so the
|
|
438
|
+
* golden response schemas still hold. Never mutates the input.
|
|
439
|
+
*
|
|
440
|
+
* Honours the report-level `_aegisFailClosed` flag (set by report-processor step 3c
|
|
441
|
+
* on a classifier error): when set, ALL findings are force-redacted.
|
|
442
|
+
*
|
|
443
|
+
* @param {object} report
|
|
444
|
+
* @param {{ localReportPath?: string|null, mode?: string, failClosed?: boolean }} [opts]
|
|
445
|
+
* @returns {object}
|
|
446
|
+
*/
|
|
447
|
+
export function redactReport(report, opts = {}) {
|
|
448
|
+
if (process.env.ARGUS_REDACT_SENSITIVE === '0') return report;
|
|
449
|
+
if (!report || typeof report !== 'object') return report;
|
|
450
|
+
|
|
451
|
+
const failClosed = report._aegisFailClosed === true || opts.failClosed === true;
|
|
452
|
+
const ro = { ...opts, failClosed };
|
|
453
|
+
const rawFindings = [];
|
|
454
|
+
const out = { ...report };
|
|
455
|
+
|
|
456
|
+
if (Array.isArray(report.routes)) {
|
|
457
|
+
out.routes = report.routes.map((route) => {
|
|
458
|
+
if (!route || typeof route !== 'object') return route;
|
|
459
|
+
const r = { ...route };
|
|
460
|
+
if (Array.isArray(route.errors)) { rawFindings.push(...route.errors); r.errors = redactForEgress(route.errors, ro); }
|
|
461
|
+
if (Array.isArray(route.findings)) { rawFindings.push(...route.findings); r.findings = redactForEgress(route.findings, ro); }
|
|
462
|
+
if (Array.isArray(route.diffs)) { r.diffs = route.diffs.map((d) => deepScrub(d, ro)); }
|
|
463
|
+
return r;
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
if (Array.isArray(report.flows)) {
|
|
467
|
+
out.flows = report.flows.map((flow) => {
|
|
468
|
+
if (flow && typeof flow === 'object' && Array.isArray(flow.findings)) {
|
|
469
|
+
rawFindings.push(...flow.findings);
|
|
470
|
+
return { ...flow, findings: redactForEgress(flow.findings, ro) };
|
|
471
|
+
}
|
|
472
|
+
return flow;
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
if (Array.isArray(report.codebase)) {
|
|
476
|
+
rawFindings.push(...report.codebase);
|
|
477
|
+
out.codebase = redactForEgress(report.codebase, ro);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (Array.isArray(report.routes) || Array.isArray(report.flows) || Array.isArray(report.codebase)) {
|
|
481
|
+
out.redaction = buildRedactionRider(rawFindings, { ...opts, failClosed });
|
|
482
|
+
}
|
|
483
|
+
return out;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Read a property without ever throwing (a pathological getter ⇒ undefined). */
|
|
487
|
+
function safeGet(obj, key) {
|
|
488
|
+
try { return obj?.[key]; } catch { return undefined; }
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** JSON.stringify that never throws (cyclic / exotic values ⇒ a short token). */
|
|
492
|
+
function safeStringify(v) {
|
|
493
|
+
try {
|
|
494
|
+
return JSON.stringify(v) ?? '';
|
|
495
|
+
} catch {
|
|
496
|
+
return '[unserialisable]';
|
|
497
|
+
}
|
|
498
|
+
}
|
|
@@ -112,8 +112,13 @@ export async function saveSession(browser, sessionFile) {
|
|
|
112
112
|
|
|
113
113
|
const tmpFile = `${sessionFile}.tmp`;
|
|
114
114
|
try {
|
|
115
|
-
|
|
115
|
+
// Session state holds cookies + Web Storage (auth material), so write it owner-only
|
|
116
|
+
// (0600) — never world-readable on shared / POSIX / CI hosts. Hardening for the Socket
|
|
117
|
+
// supply-chain note on this module. chmod after rename is best-effort (no-op on Windows,
|
|
118
|
+
// where file access is ACL-based rather than POSIX mode bits).
|
|
119
|
+
fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
116
120
|
fs.renameSync(tmpFile, sessionFile);
|
|
121
|
+
try { fs.chmodSync(sessionFile, 0o600); } catch { /* mode bits unsupported on this platform */ }
|
|
117
122
|
} catch (err) {
|
|
118
123
|
throw new Error(`[ARGUS] saveSession: failed to write session file "${sessionFile}": ${err.message}`);
|
|
119
124
|
}
|