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