@clear-capabilities/agentic-security-scanner 0.172.1 → 0.174.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/CHANGELOG.md +26 -0
- package/bin/agentic-security.js +37 -0
- package/dist/119.index.js +197 -0
- package/dist/128.index.js +129 -0
- package/dist/1293.index.js +5689 -0
- package/dist/1351.index.js +133 -0
- package/dist/1387.index.js +271 -0
- package/dist/1437.index.js +23 -0
- package/dist/1445.index.js +110 -0
- package/dist/1505.index.js +151 -0
- package/dist/1557.index.js +293 -0
- package/dist/1621.index.js +311 -0
- package/dist/1751.index.js +160 -0
- package/dist/1765.index.js +727 -0
- package/dist/1861.index.js +690 -0
- package/dist/1898.index.js +116 -0
- package/dist/1906.index.js +433 -0
- package/dist/1920.index.js +1 -291
- package/dist/208.index.js +1631 -0
- package/dist/2199.index.js +0 -239
- package/dist/2223.index.js +159 -0
- package/dist/2530.index.js +152 -0
- package/dist/2565.index.js +116 -0
- package/dist/2574.index.js +127 -0
- package/dist/2620.index.js +345 -0
- package/dist/2773.index.js +98 -0
- package/dist/2775.index.js +32 -0
- package/dist/2966.index.js +171 -0
- package/dist/2972.index.js +87 -0
- package/dist/2991.index.js +131 -0
- package/dist/3063.index.js +833 -0
- package/dist/3075.index.js +227 -0
- package/dist/3121.index.js +297 -0
- package/dist/3314.index.js +263 -0
- package/dist/3480.index.js +1822 -0
- package/dist/3481.index.js +24 -0
- package/dist/3506.index.js +268 -0
- package/dist/3533.index.js +591 -0
- package/dist/3653.index.js +450 -0
- package/dist/3822.index.js +131 -0
- package/dist/3868.index.js +292 -0
- package/dist/3917.index.js +152 -0
- package/dist/3918.index.js +192 -0
- package/dist/4090.index.js +288 -0
- package/dist/4121.index.js +1190 -0
- package/dist/4142.index.js +0 -239
- package/dist/4336.index.js +436 -0
- package/dist/4387.index.js +532 -0
- package/dist/4413.index.js +302 -0
- package/dist/4476.index.js +4510 -0
- package/dist/4491.index.js +133 -0
- package/dist/4538.index.js +185 -0
- package/dist/4684.index.js +319 -0
- package/dist/4740.index.js +165 -0
- package/dist/4780.index.js +209 -0
- package/dist/4802.index.js +225 -0
- package/dist/4812.index.js +379 -0
- package/dist/4835.index.js +126 -0
- package/dist/494.index.js +226 -0
- package/dist/4991.index.js +549 -0
- package/dist/5199.index.js +305 -0
- package/dist/5216.index.js +109 -0
- package/dist/5439.index.js +301 -0
- package/dist/5443.index.js +97 -0
- package/dist/5496.index.js +377 -0
- package/dist/550.index.js +2 -1
- package/dist/5801.index.js +588 -0
- package/dist/5858.index.js +223 -0
- package/dist/5933.index.js +143 -0
- package/dist/601.index.js +23 -0
- package/dist/6173.index.js +273 -0
- package/dist/6291.index.js +1871 -0
- package/dist/6312.index.js +666 -0
- package/dist/634.index.js +16182 -0
- package/dist/644.index.js +87 -0
- package/dist/6508.index.js +283 -0
- package/dist/6560.index.js +645 -0
- package/dist/6576.index.js +422 -0
- package/dist/6579.index.js +271 -0
- package/dist/6745.index.js +305 -0
- package/dist/6818.index.js +21642 -0
- package/dist/6972.index.js +167 -0
- package/dist/6995.index.js +163 -0
- package/dist/7249.index.js +191 -0
- package/dist/7372.index.js +1197 -0
- package/dist/7546.index.js +541 -0
- package/dist/76.index.js +448 -0
- package/dist/7879.index.js +163 -0
- package/dist/8025.index.js +130 -0
- package/dist/8054.index.js +248 -0
- package/dist/8097.index.js +201 -0
- package/dist/8126.index.js +191 -0
- package/dist/8178.index.js +78 -0
- package/dist/818.index.js +212 -0
- package/dist/8382.index.js +93 -0
- package/dist/8408.index.js +261 -0
- package/dist/8540.index.js +174 -0
- package/dist/8635.index.js +802 -0
- package/dist/8688.index.js +410 -0
- package/dist/8699.index.js +779 -0
- package/dist/8891.index.js +457 -0
- package/dist/8916.index.js +885 -0
- package/dist/9146.index.js +170 -0
- package/dist/9168.index.js +841 -0
- package/dist/9292.index.js +369 -0
- package/dist/9382.index.js +135 -11
- package/dist/9422.index.js +866 -0
- package/dist/9440.index.js +453 -0
- package/dist/9479.index.js +152 -0
- package/dist/9481.index.js +220 -0
- package/dist/9539.index.js +100 -0
- package/dist/9547.index.js +103 -0
- package/dist/9560.index.js +41 -61
- package/dist/9692.index.js +90 -0
- package/dist/9800.index.js +213 -1
- package/dist/9899.index.js +138 -0
- package/dist/9981.index.js +206 -0
- package/dist/999.index.js +117 -0
- package/dist/agentic-security.mjs +27 -27
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +31 -6
- package/src/context-intelligence/candidates.js +2 -1
- package/src/context-intelligence/capabilities.js +2 -1
- package/src/context-intelligence/cli.js +2 -0
- package/src/context-intelligence/config.js +3 -1
- package/src/context-intelligence/controls.js +49 -1
- package/src/context-intelligence/counterexample.js +200 -0
- package/src/context-intelligence/cross-route.js +111 -0
- package/src/context-intelligence/findings.js +166 -0
- package/src/context-intelligence/invariants.js +197 -0
- package/src/context-intelligence/queue.js +6 -1
- package/src/context-intelligence/reachability.js +2 -2
- package/src/context-intelligence/review.js +68 -4
- package/src/dataflow/access-paths.js +11 -0
- package/src/dataflow/analysis-limits.js +66 -0
- package/src/dataflow/engine.js +282 -24
- package/src/dataflow/service-edges.js +174 -0
- package/src/dataflow/summaries.js +7 -1
- package/src/discovery/judge.js +2 -1
- package/src/engine.js +82 -14
- package/src/fix/apply-fix-service.js +55 -8
- package/src/fix/batch-journal.js +84 -0
- package/src/fix/exploit-replay.js +51 -0
- package/src/fix/footprint.js +65 -0
- package/src/fix/patch-policy.js +49 -0
- package/src/fix/secret-persistence.js +39 -0
- package/src/fix/test-commands.js +84 -0
- package/src/fix/verification-adapters.js +97 -0
- package/src/fix/verification-record.js +243 -0
- package/src/fix/verify-patch.js +89 -0
- package/src/ir/CLAUDE.md +5 -0
- package/src/ir/callgraph.js +23 -1
- package/src/ir/module-resolver.js +315 -0
- package/src/ir/parser-js.js +21 -2
- package/src/ir/semantic-graph.js +245 -0
- package/src/language/haskell-findings.js +2 -0
- package/src/language/haskell-guards.js +1 -1
- package/src/language/haskell-ir.js +3 -1
- package/src/language/haskell-models.js +94 -0
- package/src/language/haskell-security-rules.js +20 -0
- package/src/language/nixos-hardening.js +72 -0
- package/src/lineage/build-attested-correlation.js +63 -0
- package/src/lineage/deployment-identity.js +76 -0
- package/src/mcp/tools.js +13 -34
- package/src/pipeline/canonical-output.js +64 -0
- package/src/pipeline/detector-runner.js +7 -0
- package/src/pipeline/scan-health.js +14 -0
- package/src/pipeline/supervised-analyzers.js +125 -0
- package/src/pipeline/supervised-child.js +17 -0
- package/src/pipeline/telemetry.js +79 -0
- package/src/posture/accuracy-scorecard.js +98 -9
- package/src/posture/artifact-registry.js +5 -0
- package/src/posture/assurance/evidence-registry.js +215 -0
- package/src/posture/assurance/release-package-verify.js +135 -0
- package/src/posture/assurance/release-package.js +118 -0
- package/src/posture/assurance/release-verdict.js +94 -0
- package/src/posture/assurance/waiver.js +200 -0
- package/src/posture/compliance-truth.js +100 -0
- package/src/posture/evaluation/scan-child.js +3 -1
- package/src/posture/evaluation/why-missed.js +3 -1
- package/src/posture/exploitability-evidence.js +141 -0
- package/src/posture/fix-verify.js +4 -2
- package/src/posture/fleet/exposure-ranking.js +115 -0
- package/src/posture/fleet/service-chain-graph.js +150 -0
- package/src/posture/graph-reconciliation.js +154 -0
- package/src/posture/provenance/coverage.js +103 -0
- package/src/posture/provenance/git-evidence.js +84 -0
- package/src/posture/provenance/origin-resolver.js +7 -1
- package/src/posture/provenance/predicate-replay.js +14 -4
- package/src/posture/runtime-correlation.js +61 -15
- package/src/posture/runtime-explanation.js +116 -0
- package/src/posture/runtime-trace-ingest.js +194 -0
- package/src/posture/scan-checkpoint.js +38 -10
- package/src/posture/waf-ingest.js +7 -2
- package/src/report/index.js +4 -0
- package/src/report/oscal.js +38 -0
- package/src/report/perspectives.js +150 -0
- package/src/sast/_comment-strip.js +8 -1
- package/src/sast/ruby.js +26 -0
- package/src/sast/zip-slip.js +79 -0
- package/src/util/string-memo.js +38 -0
|
@@ -84,6 +84,9 @@ const FORMAT = 'agentic-security-scan-checkpoint/2';
|
|
|
84
84
|
// what it would find.
|
|
85
85
|
const RUN_KEY_ENV_EXCLUDE = new Set([
|
|
86
86
|
'AGENTIC_SECURITY_RESUME',
|
|
87
|
+
// E6-04: these choose WHETHER a cache is used, or only how fast shared helpers run; neither changes what any file's analysis produces
|
|
88
|
+
'AGENTIC_SECURITY_ANALYSIS_CACHE',
|
|
89
|
+
'AGENTIC_SECURITY_NO_STRING_MEMO',
|
|
87
90
|
'AGENTIC_SECURITY_CHECKPOINT_ABORT_AFTER',
|
|
88
91
|
'AGENTIC_SECURITY_HMAC_KEY',
|
|
89
92
|
]);
|
|
@@ -126,7 +129,7 @@ export function bundleShaForRunKey() {
|
|
|
126
129
|
*/
|
|
127
130
|
export function computeRunKey({
|
|
128
131
|
engineVersion, rulesetVersion, bundleSha,
|
|
129
|
-
fileContents = {}, depFileContents = {}, env = process.env,
|
|
132
|
+
fileContents = {}, depFileContents = {}, env = process.env, extra = null,
|
|
130
133
|
} = {}) {
|
|
131
134
|
const h = crypto.createHash('sha256');
|
|
132
135
|
h.update(FORMAT); h.update('\n');
|
|
@@ -142,6 +145,8 @@ export function computeRunKey({
|
|
|
142
145
|
h.update('\n');
|
|
143
146
|
}
|
|
144
147
|
}
|
|
148
|
+
// E6-04: cross-file inputs a single file's analysis reads (project constants, custom rules, ...). Absent means the key is unchanged from before.
|
|
149
|
+
if (extra !== null && extra !== undefined) { h.update('x'); h.update(String(extra)); h.update('\n'); }
|
|
145
150
|
const envKeys = Object.keys(env || {})
|
|
146
151
|
.filter(k => k.startsWith('AGENTIC_SECURITY_') && !RUN_KEY_ENV_EXCLUDE.has(k))
|
|
147
152
|
.sort();
|
|
@@ -160,8 +165,8 @@ export function computeRunKey({
|
|
|
160
165
|
* no per-file granularity to offer there; any manifest change invalidates
|
|
161
166
|
* everything, same as before this change.
|
|
162
167
|
*/
|
|
163
|
-
export function computeGlobalKey({ engineVersion, rulesetVersion, bundleSha, depFileContents = {}, env = process.env } = {}) {
|
|
164
|
-
return computeRunKey({ engineVersion, rulesetVersion, bundleSha, fileContents: {}, depFileContents, env });
|
|
168
|
+
export function computeGlobalKey({ engineVersion, rulesetVersion, bundleSha, depFileContents = {}, env = process.env, extra = null } = {}) {
|
|
169
|
+
return computeRunKey({ engineVersion, rulesetVersion, bundleSha, fileContents: {}, depFileContents, env, extra });
|
|
165
170
|
}
|
|
166
171
|
|
|
167
172
|
/**
|
|
@@ -228,11 +233,16 @@ function _emptyHandle(reason) {
|
|
|
228
233
|
enabled: false, file: null, fd: null, globalKey: null,
|
|
229
234
|
recovered: new Map(), order: [], written: new Set(),
|
|
230
235
|
discarded: false, reason, invalidatedFiles: [], fileContents: {},
|
|
236
|
+
// E6-04: persistent (cross-scan) mode. `sign` authenticates records with the install key; `lines` keeps the latest verified line per file for compaction.
|
|
237
|
+
persistent: false, sign: null, lines: new Map(),
|
|
231
238
|
};
|
|
232
239
|
}
|
|
233
240
|
|
|
234
|
-
function
|
|
235
|
-
|
|
241
|
+
function _headerMac(sign, globalKey) { return sign ? sign(`hdr\0${FORMAT}\0${globalKey}`) : undefined; }
|
|
242
|
+
function _recordMac(sign, f, h, globalKey, d) { return sign ? sign(`rec\0${f}\0${h ?? ''}\0${globalKey}\0${d}`) : _sha(d); }
|
|
243
|
+
|
|
244
|
+
function _headerLine(globalKey, meta, sign = null) {
|
|
245
|
+
return JSON.stringify({ v: FORMAT, gk: globalKey, meta: meta || {}, ...(sign ? { hm: _headerMac(sign, globalKey) } : {}) }) + '\n';
|
|
236
246
|
}
|
|
237
247
|
|
|
238
248
|
// Read forward from a byte offset, keeping records while they verify. Returns
|
|
@@ -258,6 +268,9 @@ function _recover(handle, file, globalKey, meta, fileContents) {
|
|
|
258
268
|
try { header = JSON.parse(text.slice(0, nl)); } catch { return { offset: 0, discardReason: 'corrupt checkpoint header' }; }
|
|
259
269
|
if (!header || header.v !== FORMAT) return { offset: 0, discardReason: 'checkpoint format changed' };
|
|
260
270
|
if (header.gk !== globalKey) return { offset: 0, discardReason: _explainGlobalMismatch(header.meta, meta) };
|
|
271
|
+
// E6-04: a persistent cache is read back on later scans, possibly in a checkout someone else prepared. Only a header made with THIS install's key counts;
|
|
272
|
+
// a file carried in from elsewhere (or planted in a repository) is discarded whole, never trusted.
|
|
273
|
+
if (handle.sign && header.hm !== _headerMac(handle.sign, globalKey)) return { offset: 0, discardReason: 'cache was not written by this installation (header signature does not verify)' };
|
|
261
274
|
|
|
262
275
|
let offset = Buffer.byteLength(text.slice(0, nl + 1), 'utf8');
|
|
263
276
|
let cursor = nl + 1;
|
|
@@ -270,7 +283,7 @@ function _recover(handle, file, globalKey, meta, fileContents) {
|
|
|
270
283
|
let rec;
|
|
271
284
|
try { rec = JSON.parse(line); } catch { break; }
|
|
272
285
|
if (!rec || typeof rec.f !== 'string' || typeof rec.d !== 'string') break;
|
|
273
|
-
if (rec.c !==
|
|
286
|
+
if (rec.c !== _recordMac(handle.sign, rec.f, rec.h ?? null, globalKey, rec.d)) break; // tampered or torn-then-patched (or, with a key, not ours)
|
|
274
287
|
let payload;
|
|
275
288
|
try { payload = JSON.parse(rec.d); } catch { break; }
|
|
276
289
|
// Structurally intact and parseable — advance past it regardless of what
|
|
@@ -294,6 +307,7 @@ function _recover(handle, file, globalKey, meta, fileContents) {
|
|
|
294
307
|
}
|
|
295
308
|
if (!handle.recovered.has(rec.f)) handle.order.push(rec.f);
|
|
296
309
|
handle.recovered.set(rec.f, payload);
|
|
310
|
+
handle.lines.set(rec.f, line + '\n');
|
|
297
311
|
}
|
|
298
312
|
return { offset, discardReason: null };
|
|
299
313
|
}
|
|
@@ -315,7 +329,7 @@ function _recover(handle, file, globalKey, meta, fileContents) {
|
|
|
315
329
|
* `meta`: a plaintext summary of the inputs behind `globalKey` (see
|
|
316
330
|
* `globalKeyMeta`), stored in the header purely to explain a mismatch later.
|
|
317
331
|
*/
|
|
318
|
-
export function openCheckpoint(scanRoot, { globalKey, meta = {}, fileContents = {} } = {}) {
|
|
332
|
+
export function openCheckpoint(scanRoot, { globalKey, meta = {}, fileContents = {}, persistent = false, fileName = null, sign = null } = {}) {
|
|
319
333
|
if (!scanRoot || !globalKey) return _emptyHandle('no-global-key');
|
|
320
334
|
// A read-only scan cannot checkpoint, and must not try. Resume is purely an
|
|
321
335
|
// optimisation — without it the scan recomputes, which is slower and
|
|
@@ -328,9 +342,11 @@ export function openCheckpoint(scanRoot, { globalKey, meta = {}, fileContents =
|
|
|
328
342
|
try {
|
|
329
343
|
const dir = path.join(scanRoot, STATE_DIR);
|
|
330
344
|
fs.mkdirSync(dir, { recursive: true });
|
|
331
|
-
const file = checkpointPath(scanRoot);
|
|
345
|
+
const file = fileName ? path.join(dir, fileName) : checkpointPath(scanRoot);
|
|
332
346
|
handle.file = file;
|
|
333
347
|
handle.globalKey = globalKey;
|
|
348
|
+
handle.persistent = !!persistent; handle.sign = typeof sign === 'function' ? sign : null;
|
|
349
|
+
handle.meta = meta || {};
|
|
334
350
|
|
|
335
351
|
const { offset: keepBytes, discardReason } = _recover(handle, file, globalKey, meta, handle.fileContents);
|
|
336
352
|
if (keepBytes <= 0) {
|
|
@@ -340,9 +356,10 @@ export function openCheckpoint(scanRoot, { globalKey, meta = {}, fileContents =
|
|
|
340
356
|
handle.recovered.clear();
|
|
341
357
|
handle.order.length = 0;
|
|
342
358
|
handle.invalidatedFiles = [];
|
|
359
|
+
handle.lines.clear();
|
|
343
360
|
handle.discarded = keepBytes === 0;
|
|
344
361
|
handle.reason = keepBytes === 0 ? discardReason : null;
|
|
345
|
-
fs.writeFileSync(file, _headerLine(globalKey, meta));
|
|
362
|
+
fs.writeFileSync(file, _headerLine(globalKey, meta, handle.sign));
|
|
346
363
|
} else {
|
|
347
364
|
// Drop any torn tail so appends land after the last verified record.
|
|
348
365
|
try {
|
|
@@ -381,10 +398,11 @@ export function recordFileDone(handle, relPath, findings) {
|
|
|
381
398
|
if (typeof d !== 'string') return false;
|
|
382
399
|
const src = handle.fileContents ? handle.fileContents[relPath] : undefined;
|
|
383
400
|
const h = typeof src === 'string' ? _sha(src) : null;
|
|
384
|
-
const line = JSON.stringify({ f: relPath, h, c:
|
|
401
|
+
const line = JSON.stringify({ f: relPath, h, c: _recordMac(handle.sign, relPath, h, handle.globalKey, d), d }) + '\n';
|
|
385
402
|
fs.writeSync(handle.fd, line);
|
|
386
403
|
fs.fsyncSync(handle.fd);
|
|
387
404
|
handle.written.add(relPath);
|
|
405
|
+
handle.lines.set(relPath, line);
|
|
388
406
|
return true;
|
|
389
407
|
} catch {
|
|
390
408
|
return false;
|
|
@@ -430,6 +448,16 @@ export function closeCheckpoint(handle, { complete = false } = {}) {
|
|
|
430
448
|
try { if (handle.fd !== null) fs.closeSync(handle.fd); } catch { ok = false; }
|
|
431
449
|
handle.fd = null;
|
|
432
450
|
handle.enabled = false;
|
|
451
|
+
if (complete && handle.file && handle.persistent) {
|
|
452
|
+
// A persistent cache is kept for the next scan, compacted to one line per file still in scope so it cannot grow without bound.
|
|
453
|
+
try {
|
|
454
|
+
const keep = [...handle.lines.entries()].filter(([f]) => typeof handle.fileContents[f] === 'string').map(([, l]) => l);
|
|
455
|
+
const tmp = `${handle.file}.${process.pid}.tmp`;
|
|
456
|
+
fs.writeFileSync(tmp, _headerLine(handle.globalKey, handle.meta || {}, handle.sign) + keep.join(''));
|
|
457
|
+
fs.renameSync(tmp, handle.file);
|
|
458
|
+
} catch { ok = false; }
|
|
459
|
+
return ok;
|
|
460
|
+
}
|
|
433
461
|
if (complete && handle.file) {
|
|
434
462
|
try { fs.rmSync(handle.file, { force: true }); } catch { ok = false; }
|
|
435
463
|
}
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
//
|
|
34
34
|
// AWS WAF list-rules JSON: same expression-based heuristic.
|
|
35
35
|
|
|
36
|
+
import { createHash } from 'node:crypto';
|
|
36
37
|
import * as fs from 'node:fs';
|
|
37
38
|
import * as path from 'node:path';
|
|
38
39
|
|
|
@@ -81,9 +82,13 @@ function parseNormalized(obj) {
|
|
|
81
82
|
}));
|
|
82
83
|
}
|
|
83
84
|
|
|
85
|
+
// A rule with no id of its own gets one derived from its content, so the same export always yields the same ids (a random suffix made every
|
|
86
|
+
// ingestion of an unnamed rule a different rule).
|
|
87
|
+
function _contentId(r) { return createHash('sha256').update(JSON.stringify(r)).digest('hex').slice(0, 8); }
|
|
88
|
+
|
|
84
89
|
function parseCloudflare(arr) {
|
|
85
90
|
return arr.filter(r => r && (r.action === 'block' || r.action === 'managed_challenge')).map((r) => ({
|
|
86
|
-
id: r.id || r.ref || 'cf-' +
|
|
91
|
+
id: r.id || r.ref || 'cf-' + _contentId(r),
|
|
87
92
|
pattern: r.description || r.expression || '',
|
|
88
93
|
families: inferFamilyFromExpression(r.expression || r.description || ''),
|
|
89
94
|
vendor: 'cloudflare',
|
|
@@ -93,7 +98,7 @@ function parseCloudflare(arr) {
|
|
|
93
98
|
function parseAwsWaf(obj) {
|
|
94
99
|
const rules = obj.Rules || obj.rules || [];
|
|
95
100
|
return rules.map((r) => ({
|
|
96
|
-
id: r.Name || r.name || 'aws-' +
|
|
101
|
+
id: r.Name || r.name || 'aws-' + _contentId(r),
|
|
97
102
|
pattern: JSON.stringify(r.Statement || r.statement || {}).slice(0, 200),
|
|
98
103
|
families: inferFamilyFromExpression(JSON.stringify(r.Statement || r.statement || {})),
|
|
99
104
|
vendor: 'aws-waf',
|
package/src/report/index.js
CHANGED
|
@@ -391,6 +391,8 @@ export function normalizeFindings(scan){
|
|
|
391
391
|
// introduced this finding. A different question, and a different field,
|
|
392
392
|
// from the two lines above it (see the import comment at the top).
|
|
393
393
|
findingProvenance: _normalizedProvenance(f),
|
|
394
|
+
// E5-07: only present when a caller attached a runtime explanation, so findings without one keep exactly their previous shape
|
|
395
|
+
...(f.runtimeExplanationSummary && typeof f.runtimeExplanationSummary === 'object' ? { runtimeExplanationSummary: f.runtimeExplanationSummary } : {}),
|
|
394
396
|
typeNarrowed: f.typeNarrowed || null,
|
|
395
397
|
strideCategory: f.strideCategory || null,
|
|
396
398
|
personaScores: f.personaScores || null,
|
|
@@ -1191,6 +1193,8 @@ export function toSARIF(scan, meta={}){
|
|
|
1191
1193
|
...(f.unvalidated ? { unvalidated: true } : {}),
|
|
1192
1194
|
...(f.cross_language ? { crossLanguage: true } : {}),
|
|
1193
1195
|
...(f.family ? { family: f.family } : {}),
|
|
1196
|
+
// E5-07: compact static/runtime explanation (digest-tied to the full object); present only when a caller attached one
|
|
1197
|
+
...(f.runtimeExplanationSummary ? { runtimeExplanation: f.runtimeExplanationSummary } : {}),
|
|
1194
1198
|
// Premortem 3R-6 + 4R-10: emit the single signatureStatus tri-state
|
|
1195
1199
|
// (verified | unsigned | pass-through). The legacy bool flags are
|
|
1196
1200
|
// emitted alongside for one release of grace so existing dashboards
|
package/src/report/oscal.js
CHANGED
|
@@ -442,7 +442,45 @@ export function complianceRowsFromPrivacy(assessment) {
|
|
|
442
442
|
}));
|
|
443
443
|
}
|
|
444
444
|
|
|
445
|
+
/**
|
|
446
|
+
* Adapter for `posture/compliance-truth.js#assessFrameworkTruth`: six states, of which only `assessed_satisfied` and `assessed_not_satisfied` become OSCAL
|
|
447
|
+
* findings. Every other state becomes an observation with its own remark, so an incomplete, unverifiable or human-owned control never carries a verdict.
|
|
448
|
+
*/
|
|
449
|
+
const TRUTH_DECISION = Object.freeze({
|
|
450
|
+
assessed_satisfied: 'satisfied',
|
|
451
|
+
assessed_not_satisfied: 'not-satisfied',
|
|
452
|
+
not_assessed: 'unassessed',
|
|
453
|
+
not_applicable: 'unassessed',
|
|
454
|
+
requires_human_review: 'unassessed',
|
|
455
|
+
insufficient_evidence: 'unassessed',
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
export function complianceRowsFromTruth(assessment) {
|
|
459
|
+
return ((assessment && assessment.controls) || []).map(c => ({
|
|
460
|
+
id: c.id,
|
|
461
|
+
title: c.title || '',
|
|
462
|
+
decision: TRUTH_DECISION[c.state] || 'unassessed',
|
|
463
|
+
statusLabel: c.state,
|
|
464
|
+
known: Object.prototype.hasOwnProperty.call(TRUTH_DECISION, c.state),
|
|
465
|
+
observations: [...(c.reasons || []), ...(c.observations || [])],
|
|
466
|
+
props: [
|
|
467
|
+
_prop('truth-state', c.state),
|
|
468
|
+
...(c.codeTestable ? [_prop('code-testable', c.codeTestable)] : []),
|
|
469
|
+
...(c.evaluatorStatus ? [_prop('evaluator-status', c.evaluatorStatus)] : []),
|
|
470
|
+
],
|
|
471
|
+
}));
|
|
472
|
+
}
|
|
473
|
+
|
|
445
474
|
const UNASSESSED_REMARK = {
|
|
475
|
+
not_assessed:
|
|
476
|
+
'Not assessed: nothing examined this control. The publisher rates it checkable by code but this tool has no check for it, or the evaluator reported a status this model does not recognise. '
|
|
477
|
+
+ 'This is a gap in the tool, not a finding about the system.',
|
|
478
|
+
not_applicable:
|
|
479
|
+
'Declared not applicable by a named person with a reason. The tool never infers this; the declaration is on the observation.',
|
|
480
|
+
requires_human_review:
|
|
481
|
+
'Requires human review: the control needs judgement or organisational evidence the scanner cannot supply. Automated signals that cleared are context, not a verdict.',
|
|
482
|
+
insufficient_evidence:
|
|
483
|
+
'Insufficient evidence: signals exist for this control but cannot carry a verdict (an incomplete scan, an unverifiable mapping or weak evidence). No satisfied or not-satisfied claim is made.',
|
|
446
484
|
unrecognised:
|
|
447
485
|
'Not assessed: the evaluator reported a status this exporter does not recognise, so no satisfied/'
|
|
448
486
|
+ 'not-satisfied claim is made. The raw status is on the assessment-status property. This is a defect '
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Three views of one release (E7-07): what a developer needs, what a CISO needs, what an auditor needs, all built from the SAME canonical list of findings so they
|
|
2
|
+
// cannot disagree.
|
|
3
|
+
//
|
|
4
|
+
// developer where the problem is, why it is one, how to fix it, and whether a fix was verified
|
|
5
|
+
// CISO what is proven and what is not, what changed since the previous release, how risky and how complete
|
|
6
|
+
// auditor for each assertion and each unknown, the evidence it rests on (by digest) and why anything is unknown; the issuer and what the signature does not prove
|
|
7
|
+
//
|
|
8
|
+
// One list, three projections. `canonicalFindings` is computed once from the release package; every view only selects and arranges its fields, and each entry
|
|
9
|
+
// carries the same finding id, status, evidence hash and confidence wherever it appears. Nothing is recomputed per view.
|
|
10
|
+
//
|
|
11
|
+
// The package is DATA from a repository we do not control: every string is escaped before it reaches HTML, the page loads nothing from anywhere and runs no script, and a
|
|
12
|
+
// content security policy says so.
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
import { releaseVerdict } from '../posture/assurance/release-verdict.js';
|
|
15
|
+
|
|
16
|
+
const esc = (v) => String(v ?? '').replace(/[&<>"'`]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', '`': '`' }[c]));
|
|
17
|
+
const sevRank = { critical: 4, high: 3, medium: 2, low: 1, info: 0 };
|
|
18
|
+
|
|
19
|
+
function confidenceOf(f) {
|
|
20
|
+
if (typeof f.confidence === 'number') return f.confidence >= 0.8 ? 'high' : f.confidence >= 0.5 ? 'medium' : 'low';
|
|
21
|
+
return typeof f.confidenceTier === 'string' ? f.confidenceTier : 'unknown';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {object} pkg a release package (E7-03). It is assumed to have been verified by the caller; the views carry `verification` as the caller states it.
|
|
26
|
+
* @param {{previous?:object, ranking?:object, verification?:{ok:boolean, errors?:object[]}}} opts `previous` is the previous release's package; `ranking` is a result of rankFleetExposure
|
|
27
|
+
*/
|
|
28
|
+
export function buildCanonicalFindings(pkg) {
|
|
29
|
+
const entries = Array.isArray(pkg && pkg.evidence) ? pkg.evidence : [];
|
|
30
|
+
const byKind = (k) => entries.filter((e) => e.ref && e.ref.kind === k);
|
|
31
|
+
const fixes = byKind('fix-verification');
|
|
32
|
+
const out = [];
|
|
33
|
+
for (const e of byKind('finding')) {
|
|
34
|
+
const f = e.payload || {};
|
|
35
|
+
const id = e.ref.payloadId || f.stableId || f.id;
|
|
36
|
+
const fixesFor = fixes.filter((x) => x.payload && (x.payload.findingStableId === id || x.payload.findingId === id || x.payload.findingId === f.id) );
|
|
37
|
+
const fix = fixesFor.find((x) => x.payload.verifiedFull === true) || fixesFor[0] || null;
|
|
38
|
+
const status = !fix ? 'open' : fix.payload.verifiedFull === true ? 'fix-verified' : 'fix-unverified';
|
|
39
|
+
out.push({
|
|
40
|
+
id, evidenceHash: e.ref.digest, status, confidence: confidenceOf(f), severity: f.severity || 'info', proofTier: typeof f.proofTier === 'string' ? f.proofTier : 'unproven',
|
|
41
|
+
where: { file: f.file ?? null, line: Number.isInteger(f.line) ? f.line : null }, title: f.vuln || f.title || 'finding', cwe: f.cwe || null,
|
|
42
|
+
why: f.description || f.reason || null, how: f.remediation || null,
|
|
43
|
+
fix: fix ? { evidenceHash: fix.ref.digest, verifiedFull: fix.payload.verifiedFull === true, completeness: fix.payload.fixCompleteness || null, degradedLegs: Array.isArray(fix.payload.degradedLegs) ? fix.payload.degradedLegs.map((d) => (typeof d === 'string' ? d : d && d.leg) ).filter(Boolean) : [] } : null,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return out.sort((a, b) => (sevRank[b.severity] - sevRank[a.severity]) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function buildViews(pkg, { previous = null, ranking = null, verification = null } = {}) {
|
|
50
|
+
const findings = buildCanonicalFindings(pkg);
|
|
51
|
+
const packageDigest = `sha256:${createHash('sha256').update(JSON.stringify(pkg && pkg.lineageOrder || [])).digest('hex')}`;
|
|
52
|
+
const prev = previous ? buildCanonicalFindings(previous) : null;
|
|
53
|
+
const prevById = prev ? new Map(prev.map((f) => [f.id, f])) : null;
|
|
54
|
+
const ids = new Set(findings.map((f) => f.id));
|
|
55
|
+
const change = prev ? {
|
|
56
|
+
new: findings.filter((f) => !prevById.has(f.id)).map((f) => f.id),
|
|
57
|
+
resolved: prev.filter((f) => !ids.has(f.id)).map((f) => f.id),
|
|
58
|
+
statusChanged: findings.filter((f) => prevById.has(f.id) && prevById.get(f.id).status !== f.status).map((f) => ({ id: f.id, from: prevById.get(f.id).status, to: f.status })),
|
|
59
|
+
unchanged: findings.filter((f) => prevById.has(f.id) && prevById.get(f.id).status === f.status).length,
|
|
60
|
+
} : null;
|
|
61
|
+
const rankById = ranking ? new Map(ranking.ranked.map((r) => [r.id, r])) : null;
|
|
62
|
+
const tier = (t) => findings.filter((f) => f.proofTier === t).length;
|
|
63
|
+
const completeness = pkg && pkg.completeness ? { verdict: pkg.completeness.verdict, scanStatus: pkg.completeness.scanStatus, conditions: pkg.completeness.conditions || [] } : { verdict: 'unknown', scanStatus: 'unknown', conditions: [] };
|
|
64
|
+
const unknowns = [];
|
|
65
|
+
if (completeness.verdict !== 'complete') for (const c of completeness.conditions.length ? completeness.conditions : ['the scan did not report that it finished']) unknowns.push({ about: 'scan completeness', reason: c, evidence: pkg.completeness && pkg.completeness.source ? [pkg.completeness.source] : [] });
|
|
66
|
+
for (const a of (pkg && pkg.assertions) || []) if (a.status !== 'supported') unknowns.push({ about: a.text, reason: a.reason || `the assertion is ${a.status}`, evidence: a.evidence || [] });
|
|
67
|
+
for (const f of findings) if (f.proofTier !== 'execution-proven') unknowns.push({ about: `exploitability of ${f.id}`, reason: `proof tier is ${f.proofTier}: it has not been shown to be exploitable`, evidence: [f.evidenceHash] });
|
|
68
|
+
|
|
69
|
+
const developer = findings.map((f) => ({ id: f.id, evidenceHash: f.evidenceHash, status: f.status, confidence: f.confidence, severity: f.severity, where: f.where, why: f.why, how: f.how, title: f.title, cwe: f.cwe, fix: f.fix }));
|
|
70
|
+
const verdict = releaseVerdict(pkg);
|
|
71
|
+
const ciso = {
|
|
72
|
+
verdict: { verdict: verdict.verdict, statement: verdict.statement, blockers: verdict.blockers.map((b) => b.message), unknowns: verdict.unknowns.map((u) => u.message) },
|
|
73
|
+
verified: verification ? verification.ok === true : null,
|
|
74
|
+
scan: completeness,
|
|
75
|
+
proven: { executionProven: tier('execution-proven'), taintProven: tier('taint-proven'), unproven: findings.length - tier('execution-proven') - tier('taint-proven'), total: findings.length },
|
|
76
|
+
bySeverity: Object.fromEntries(['critical', 'high', 'medium', 'low', 'info'].map((s) => [s, findings.filter((f) => f.severity === s).length])),
|
|
77
|
+
fixes: { verified: findings.filter((f) => f.status === 'fix-verified').length, unverified: findings.filter((f) => f.status === 'fix-unverified').length, open: findings.filter((f) => f.status === 'open').length },
|
|
78
|
+
changeSinceLast: change,
|
|
79
|
+
risk: ranking ? { byPriority: ranking.summary.byPriority, incompleteCoverage: ranking.summary.incompleteCoverage, top: ranking.ranked.slice(0, 5).map((r) => ({ id: r.id, priority: r.priority, rule: r.rule })) } : null,
|
|
80
|
+
findings: findings.map((f) => ({ id: f.id, evidenceHash: f.evidenceHash, status: f.status, confidence: f.confidence, severity: f.severity, proofTier: f.proofTier, priority: rankById && rankById.get(f.id) ? rankById.get(f.id).priority : null })),
|
|
81
|
+
};
|
|
82
|
+
const auditor = {
|
|
83
|
+
package: { lineageDigest: packageDigest, snapshot: pkg && pkg.snapshot ? { commit: pkg.snapshot.commit, immutable: pkg.snapshot.immutable, digest: pkg.snapshot.digest } : null },
|
|
84
|
+
issuer: pkg && pkg.issuance ? { trustBasis: pkg.issuance.trustBasis, independentlyCertified: pkg.issuance.independentlyCertified, keyFingerprint: pkg.issuance.issuer && pkg.issuance.issuer.keyFingerprint } : null,
|
|
85
|
+
verification: verification ? { ok: verification.ok === true, errors: (verification.errors || []).map((e) => `${e.code}: ${e.message}`) } : { ok: null, errors: ['no verification was performed by this view'] },
|
|
86
|
+
proves: pkg && pkg.proves || null, doesNotProve: pkg && pkg.doesNotProve || null,
|
|
87
|
+
assertions: ((pkg && pkg.assertions) || []).map((a) => ({ id: a.id, text: a.text, status: a.status, evidence: a.evidence || [], reason: a.reason || null })),
|
|
88
|
+
unknowns,
|
|
89
|
+
findings: findings.map((f) => ({ id: f.id, evidenceHash: f.evidenceHash, status: f.status, confidence: f.confidence, proofTier: f.proofTier, fixEvidence: f.fix ? f.fix.evidenceHash : null })),
|
|
90
|
+
policyDecisions: (pkg && pkg.policy && pkg.policy.decisions) || [],
|
|
91
|
+
};
|
|
92
|
+
return { canonical: findings, developer, ciso, auditor };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---- text ------------------------------------------------------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
export function renderText(views, view = 'all') {
|
|
98
|
+
const L = [];
|
|
99
|
+
const dev = () => { L.push('DEVELOPER VIEW'); for (const f of views.developer) { L.push(` [${f.severity}] ${f.title} ${f.where.file ?? '?'}:${f.where.line ?? '?'} id=${f.id} status=${f.status} confidence=${f.confidence} evidence=${f.evidenceHash.slice(0, 19)}`); if (f.why) L.push(` why: ${String(f.why).slice(0, 300)}`); if (f.how) L.push(` fix: ${String(f.how).slice(0, 300)}`); if (f.fix) L.push(` fix check: ${f.fix.verifiedFull ? 'verified' : 'not fully verified'}${f.fix.degradedLegs.length ? ` (not passed: ${f.fix.degradedLegs.join(', ')})` : ''}`); } if (!views.developer.length) L.push(' no findings'); };
|
|
100
|
+
const ciso = () => { const c = views.ciso; L.push('CISO VIEW'); L.push(` release verdict: ${c.verdict.verdict}. ${c.verdict.statement}`); for (const b of c.verdict.blockers) L.push(` blocks: ${b}`); for (const u of c.verdict.unknowns) L.push(` unknown: ${u}`); L.push(` scan: ${c.scan.verdict} (${c.scan.scanStatus})${c.scan.conditions.length ? ' - ' + c.scan.conditions.join('; ') : ''}`); L.push(` proven: ${c.proven.executionProven} executed, ${c.proven.taintProven} taint path, ${c.proven.unproven} unproven, of ${c.proven.total}`); L.push(` fixes: ${c.fixes.verified} verified, ${c.fixes.unverified} unverified, ${c.fixes.open} open`); if (c.changeSinceLast) L.push(` since last release: ${c.changeSinceLast.new.length} new, ${c.changeSinceLast.resolved.length} resolved, ${c.changeSinceLast.statusChanged.length} changed status`); else L.push(' since last release: no previous release was supplied'); if (c.risk) L.push(` risk: ${JSON.stringify(c.risk.byPriority)}; ${c.risk.incompleteCoverage} with incomplete coverage`); L.push(` verification: ${c.verified === null ? 'not performed here' : c.verified ? 'signature and contents verified' : 'FAILED'}`); };
|
|
101
|
+
const aud = () => { const a = views.auditor; L.push('AUDITOR VIEW'); L.push(` snapshot: ${a.package.snapshot ? `${a.package.snapshot.commit ?? 'no commit'} (${a.package.snapshot.immutable ? 'immutable' : 'NOT immutable'})` : 'none'}`); L.push(` issuer: ${a.issuer ? `${a.issuer.trustBasis}, independently certified: ${a.issuer.independentlyCertified}` : 'none'}`); L.push(` verification: ${a.verification.ok === null ? 'not performed here' : a.verification.ok ? 'ok' : 'FAILED: ' + a.verification.errors.join('; ')}`); for (const x of a.assertions) L.push(` assertion ${x.id} [${x.status}]: ${x.text} evidence=${x.evidence.map((d) => d.slice(0, 19)).join(',') || 'none'}`); for (const u of a.unknowns) L.push(` unknown: ${u.about} - ${u.reason}`); L.push(` does not prove: ${a.doesNotProve}`); };
|
|
102
|
+
if (view === 'developer' || view === 'all') dev();
|
|
103
|
+
if (view === 'ciso' || view === 'all') ciso();
|
|
104
|
+
if (view === 'auditor' || view === 'all') aud();
|
|
105
|
+
return L.join('\n') + '\n';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---- html ------------------------------------------------------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
/** One self-contained page, no script, nothing loaded from elsewhere. Every value is escaped. */
|
|
111
|
+
export function renderHtml(views, { title = 'Release assurance report' } = {}) {
|
|
112
|
+
const c = views.ciso; const a = views.auditor;
|
|
113
|
+
const row = (f, cols) => `<tr data-finding="${esc(f.id)}" data-evidence="${esc(f.evidenceHash)}" data-status="${esc(f.status)}" data-confidence="${esc(f.confidence)}">${cols.map((x) => `<td>${x}</td>`).join('')}</tr>`;
|
|
114
|
+
return `<!doctype html>
|
|
115
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
116
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:">
|
|
117
|
+
<title>${esc(title)}</title>
|
|
118
|
+
<style>
|
|
119
|
+
:root{--bg:#fff;--fg:#1a1a1a;--muted:#555;--line:#d0d0d0;--bad:#a40000;--ok:#0a6b2b}
|
|
120
|
+
@media (prefers-color-scheme: dark){:root{--bg:#14161a;--fg:#e8e8e8;--muted:#a0a0a0;--line:#3a3d44;--bad:#ff8080;--ok:#6fd48e}}
|
|
121
|
+
body{background:var(--bg);color:var(--fg);font:15px/1.5 system-ui,sans-serif;margin:0 auto;max-width:72rem;padding:1rem 16px}
|
|
122
|
+
h1,h2{line-height:1.2}table{border-collapse:collapse;width:100%;overflow-wrap:anywhere}th,td{border:1px solid var(--line);padding:.35rem .5rem;text-align:left;vertical-align:top}
|
|
123
|
+
code{font-family:ui-monospace,monospace;font-size:.9em}.muted{color:var(--muted)}.bad{color:var(--bad)}.ok{color:var(--ok)}details{margin:1rem 0}summary{cursor:pointer;font-weight:600}
|
|
124
|
+
</style></head><body>
|
|
125
|
+
<h1>${esc(title)}</h1>
|
|
126
|
+
<p class="muted">Snapshot ${esc(a.package.snapshot ? a.package.snapshot.commit : 'none')} (${a.package.snapshot && a.package.snapshot.immutable ? 'immutable' : 'not immutable'}). Verification: <span class="${a.verification.ok === true ? 'ok' : 'bad'}">${esc(a.verification.ok === null ? 'not performed by this page' : a.verification.ok ? 'passed' : 'FAILED')}</span>.</p>
|
|
127
|
+
<details open><summary>Developer: where, why, how to fix</summary>
|
|
128
|
+
<table><thead><tr><th>Finding</th><th>Where</th><th>Why</th><th>How to fix</th><th>Status</th><th>Confidence</th></tr></thead><tbody>
|
|
129
|
+
${views.developer.map((f) => row(f, [`<code>${esc(f.id)}</code> ${esc(f.title)}<br><span class="muted">${esc(f.severity)}${f.cwe ? ' ' + esc(f.cwe) : ''}</span>`, `<code>${esc(f.where.file ?? '?')}:${esc(f.where.line ?? '?')}</code>`, esc(f.why ?? ''), esc(f.how ?? ''), esc(f.status), esc(f.confidence)])).join('\n') || '<tr><td colspan="6">No findings.</td></tr>'}
|
|
130
|
+
</tbody></table></details>
|
|
131
|
+
<details open><summary>CISO: what is proven, what changed, risk and coverage</summary>
|
|
132
|
+
<p><strong>Release verdict: ${esc(c.verdict.verdict)}.</strong> ${esc(c.verdict.statement)}</p>
|
|
133
|
+
<ul>${c.verdict.blockers.map((b) => `<li class="bad">Blocks: ${esc(b)}</li>`).join('')}${c.verdict.unknowns.map((u) => `<li>Unknown: ${esc(u)}</li>`).join('')}</ul>
|
|
134
|
+
<p>Scan: <strong>${esc(c.scan.verdict)}</strong> (${esc(c.scan.scanStatus)}). ${c.scan.conditions.map((x) => esc(x)).join('; ')}</p>
|
|
135
|
+
<p>Proven: ${esc(c.proven.executionProven)} by execution, ${esc(c.proven.taintProven)} by a taint path, ${esc(c.proven.unproven)} unproven, of ${esc(c.proven.total)}. Fixes: ${esc(c.fixes.verified)} verified, ${esc(c.fixes.unverified)} unverified, ${esc(c.fixes.open)} open.</p>
|
|
136
|
+
<p>${c.changeSinceLast ? `Since the previous release: ${esc(c.changeSinceLast.new.length)} new, ${esc(c.changeSinceLast.resolved.length)} resolved, ${esc(c.changeSinceLast.statusChanged.length)} changed status.` : 'No previous release was supplied, so no change is reported.'}</p>
|
|
137
|
+
<table><thead><tr><th>Finding</th><th>Severity</th><th>Proof</th><th>Priority</th><th>Status</th><th>Confidence</th></tr></thead><tbody>
|
|
138
|
+
${c.findings.map((f) => row(f, [`<code>${esc(f.id)}</code>`, esc(f.severity), esc(f.proofTier), esc(f.priority ?? 'not ranked'), esc(f.status), esc(f.confidence)])).join('\n') || '<tr><td colspan="6">No findings.</td></tr>'}
|
|
139
|
+
</tbody></table></details>
|
|
140
|
+
<details open><summary>Auditor: evidence behind each statement, and what is unknown</summary>
|
|
141
|
+
<p>Issuer: ${esc(a.issuer ? a.issuer.trustBasis : 'none')}; independently certified: ${esc(a.issuer ? a.issuer.independentlyCertified : 'n/a')}.</p>
|
|
142
|
+
<p class="muted">Proves: ${esc(a.proves)}<br>Does not prove: ${esc(a.doesNotProve)}</p>
|
|
143
|
+
<table><thead><tr><th>Finding</th><th>Evidence hash</th><th>Fix evidence</th><th>Status</th><th>Confidence</th></tr></thead><tbody>
|
|
144
|
+
${a.findings.map((f) => row(f, [`<code>${esc(f.id)}</code>`, `<code>${esc(f.evidenceHash)}</code>`, `<code>${esc(f.fixEvidence ?? 'none')}</code>`, esc(f.status), esc(f.confidence)])).join('\n') || '<tr><td colspan="5">No findings.</td></tr>'}
|
|
145
|
+
</tbody></table>
|
|
146
|
+
<h2>Assertions</h2><ul>${a.assertions.map((x) => `<li><strong>${esc(x.status)}</strong>: ${esc(x.text)} <span class="muted">(${x.evidence.length ? x.evidence.map((d) => `<code>${esc(d)}</code>`).join(', ') : 'no evidence cited'}${x.reason ? '; ' + esc(x.reason) : ''})</span></li>`).join('') || '<li>None.</li>'}</ul>
|
|
147
|
+
<h2>Unknowns</h2><ul>${a.unknowns.map((u) => `<li>${esc(u.about)}: ${esc(u.reason)}</li>`).join('') || '<li>None.</li>'}</ul>
|
|
148
|
+
</details></body></html>
|
|
149
|
+
`;
|
|
150
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createStringMemo } from '../util/string-memo.js';
|
|
2
|
+
|
|
1
3
|
// Replace comments with same-length whitespace (newlines preserved) so that
|
|
2
4
|
// character indices in the returned string match the original source one-to-one.
|
|
3
5
|
// Required by detectors that emit `line = lineOf(raw, m.index)` after running
|
|
@@ -37,7 +39,7 @@
|
|
|
37
39
|
// and only the comment runs (a small minority of any real file) are rewritten,
|
|
38
40
|
// via a native regex for the newline-preserving cases. Characters are scanned;
|
|
39
41
|
// they are no longer individually concatenated.
|
|
40
|
-
|
|
42
|
+
function blankCommentsUncached(s, lang) {
|
|
41
43
|
const parts = [];
|
|
42
44
|
let verbatimFrom = 0; // start of the run of input not yet emitted as-is
|
|
43
45
|
let inS = null;
|
|
@@ -122,3 +124,8 @@ export function blankComments(s, lang) {
|
|
|
122
124
|
if (verbatimFrom < s.length) parts.push(s.slice(verbatimFrom));
|
|
123
125
|
return parts.join('');
|
|
124
126
|
}
|
|
127
|
+
|
|
128
|
+
// Every detector that wants a comment-free view of a file asks for it again; the engine alone asks about fifteen times per file. The output depends
|
|
129
|
+
// only on (text, lang), so it is computed once per distinct pair. AGENTIC_SECURITY_NO_STRING_MEMO=1 turns the memo off (used to measure it).
|
|
130
|
+
export const blankComments = createStringMemo((s, lang) => blankCommentsUncached(s, lang === '' ? undefined : lang), { version: 'blankComments@1', disableEnv: 'AGENTIC_SECURITY_NO_STRING_MEMO' });
|
|
131
|
+
export const _blankCommentsUncached = blankCommentsUncached;
|
package/src/sast/ruby.js
CHANGED
|
@@ -208,6 +208,28 @@ function _splitArgs(s) {
|
|
|
208
208
|
return out;
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
const RB_PATH_METHOD = /(?:^|_)(?:path|file|filename|filepath|dir|folder)s?(?:_for|_of)?\??$|^path_for$/;
|
|
212
|
+
const RB_TAIL_CLOSERS = /^(?:end|else|elsif|rescue|ensure|when|in)\b/;
|
|
213
|
+
/** Is the File.join call at [start, end) the returned value of a method whose name says it resolves a path? */
|
|
214
|
+
function _returnedFromPathResolver(code, start, end) {
|
|
215
|
+
let name = null; let params = '';
|
|
216
|
+
for (const dm of code.slice(0, start).matchAll(/^[ \t]*def[ \t]+(?:self\.)?([A-Za-z_][\w]*[?!]?)[ \t]*(?:\(([^)\n]*)\)|([^\n]*))/gm)) { name = dm[1]; params = dm[2] ?? dm[3] ?? ''; }
|
|
217
|
+
if (!name || !RB_PATH_METHOD.test(name)) return false;
|
|
218
|
+
// the value the caller controls must come in through the method's own parameters: the last component is a parameter (or the first name bound from one)
|
|
219
|
+
const paramNames = new Set(params.split(',').map((x) => x.trim().replace(/^[*&]+/, '').replace(/[:=].*$/, '').trim()).filter((x) => /^[A-Za-z_]\w*$/.test(x)));
|
|
220
|
+
const lastArg = _splitArgs(code.slice(start, end).replace(/^[^(]*\(/, '').replace(/\)$/, '')).pop() || '';
|
|
221
|
+
const lastName = (lastArg.trim().match(/^[A-Za-z_]\w*/) || [''])[0];
|
|
222
|
+
if (!paramNames.has(lastName)) return false;
|
|
223
|
+
const lineStart = code.lastIndexOf('\n', start - 1) + 1;
|
|
224
|
+
const head = code.slice(lineStart, start);
|
|
225
|
+
if (!/^[ \t]*(?:return[ \t]+)?$/.test(head)) return false; // the join begins its own statement
|
|
226
|
+
const lineEnd = code.indexOf('\n', end) < 0 ? code.length : code.indexOf('\n', end);
|
|
227
|
+
if (code.slice(end, lineEnd).trim() !== '') return false; // nothing chained onto it
|
|
228
|
+
if (/return[ \t]+$/.test(head)) return true;
|
|
229
|
+
const next = code.slice(lineEnd).split('\n').map((l) => l.trim()).find((l) => l !== '');
|
|
230
|
+
return next === undefined || RB_TAIL_CLOSERS.test(next); // last expression of its branch or method
|
|
231
|
+
}
|
|
232
|
+
|
|
211
233
|
/** File.join(...) whose last component is variable and which reaches the filesystem. */
|
|
212
234
|
export function scanRubyPathJoin(fp, raw) {
|
|
213
235
|
if (!/\.rb$/i.test(fp)) return [];
|
|
@@ -250,6 +272,10 @@ export function scanRubyPathJoin(fp, raw) {
|
|
|
250
272
|
reaches = RB_FS_OP.test(after) && use.test(after);
|
|
251
273
|
}
|
|
252
274
|
}
|
|
275
|
+
// A path RESOLVER hands its result to a caller (`def path_for(key); File.join(root, tenant, key); end`, the Active Storage disk service shape of
|
|
276
|
+
// basecamp/activerecord-tenanted, GHSA-pmwx-rm49-xv39). Nothing in the file touches the filesystem with it, because the caller does. It counts as reaching the
|
|
277
|
+
// filesystem only when BOTH hold: the enclosing method is named for a path, and the join is the value that method returns.
|
|
278
|
+
if (!reaches && !/(?:^|\/)(?:spec|test|tests)\/|_(?:spec|test)\.rb$/.test(fp) && _returnedFromPathResolver(code, m.index, i)) reaches = true;
|
|
253
279
|
if (!reaches) continue;
|
|
254
280
|
|
|
255
281
|
// Containment guard anywhere in the enclosing window — that IS the fix.
|
package/src/sast/zip-slip.js
CHANGED
|
@@ -38,12 +38,91 @@ import { blankComments } from './_comment-strip.js';
|
|
|
38
38
|
|
|
39
39
|
function _lineOf(raw, idx) { return raw.substring(0, idx).split('\n').length; }
|
|
40
40
|
|
|
41
|
+
// Go (archive/zip, archive/tar). The entry name comes from the archive, so `filepath.Join(dest, f.Name)` is attacker-chosen unless the joined path is checked
|
|
42
|
+
// against `dest`. The check is looked for in the SAME FUNCTION as the use, and it must name the joined path (or the entry name), because a `HasPrefix` on some
|
|
43
|
+
// other value in the same function proves nothing about this one.
|
|
44
|
+
const GO_ARCHIVE_IMPORT_RE = /["`](?:archive\/(?:zip|tar)|github\.com\/(?:klauspost\/compress\/zip|mholt\/archiver(?:\/v\d+)?|mholt\/archives|bodgit\/sevenzip|nwaples\/rardecode(?:\/v\d+)?))["`]/;
|
|
45
|
+
const GO_ZIP_RANGE_RE = /\bfor\s+(?:\w+\s*,\s*)?(\w+)\s*:=\s*range\s+[\w.()]+?\.File\b/g;
|
|
46
|
+
const GO_TAR_NEXT_RE = /\b(\w+)\s*,\s*\w+\s*:?=\s*[\w.]+\.Next\s*\(\s*\)/g;
|
|
47
|
+
const GO_WRITE_SINKS = 'os\\.(?:Create|OpenFile|MkdirAll|Mkdir|WriteFile|Rename|Symlink|Link|Chmod|Remove|RemoveAll)|ioutil\\.WriteFile|afero\\.WriteFile|fs\\.(?:Create|OpenFile|MkdirAll|WriteFile)';
|
|
48
|
+
const GO_GUARD_CALL_RE = /\b(?:strings\.HasPrefix|filepath\.IsLocal|filepath\.Rel|filepath\.IsAbs|securejoin\.SecureJoin|SecureJoin|os\.OpenRoot|OpenInRoot)\s*\(|\bstrings\.Contains\s*\([^)]*[\"`]\.\.[\"`]/;
|
|
49
|
+
|
|
50
|
+
/** The text of the Go function around `idx`, by brace matching from the nearest preceding `func`. */
|
|
51
|
+
function _goFunctionAround(code, idx) {
|
|
52
|
+
const start = code.lastIndexOf('\nfunc ', idx);
|
|
53
|
+
const from = start === -1 ? 0 : start + 1;
|
|
54
|
+
const open = code.indexOf('{', from);
|
|
55
|
+
if (open === -1 || open > idx) return code.slice(from, Math.min(code.length, idx + 2000));
|
|
56
|
+
let depth = 0; let inS = null;
|
|
57
|
+
for (let i = open; i < code.length; i++) {
|
|
58
|
+
const c = code[i];
|
|
59
|
+
if (inS) { if (c === '\\' && inS !== '`') { i++; continue; } if (c === inS) inS = null; continue; }
|
|
60
|
+
if (c === '"' || c === '`') { inS = c; continue; }
|
|
61
|
+
if (c === "'") { const e = code.indexOf("'", i + 2); if (e !== -1 && e - i <= 4) { i = e; continue; } }
|
|
62
|
+
if (c === '{') depth++;
|
|
63
|
+
else if (c === '}') { depth--; if (depth === 0) return code.slice(from, i + 1); }
|
|
64
|
+
}
|
|
65
|
+
return code.slice(from);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function _scanGoArchive(fp, raw, push) {
|
|
69
|
+
const code = blankComments(raw);
|
|
70
|
+
if (!GO_ARCHIVE_IMPORT_RE.test(code)) return;
|
|
71
|
+
// the names that hold an archive entry: `for _, f := range r.File`, `hdr, err := tr.Next()`
|
|
72
|
+
const entries = new Set();
|
|
73
|
+
for (const re of [GO_ZIP_RANGE_RE, GO_TAR_NEXT_RE]) { const r = new RegExp(re.source, re.flags); let m; while ((m = r.exec(code))) entries.add(m[1]); }
|
|
74
|
+
if (!entries.size) return;
|
|
75
|
+
const ent = `(?:${[...entries].map((e) => e.replace(/[$]/g, '\\$')).join('|')})`;
|
|
76
|
+
const nameRef = `${ent}\\s*\\.\\s*Name\\b`;
|
|
77
|
+
// a path built from the entry name: filepath.Join(x, f.Name) / path.Join / dest + "/" + f.Name
|
|
78
|
+
const builtRe = new RegExp(`(\\w+)\\s*:?=\\s*(?:(?:filepath|path)\\.(?:Join|Clean)\\s*\\([^\\n]*?${nameRef}[^\\n]*\\)|[^\\n]*?\\+\\s*${nameRef}[^\\n]*|[^\\n]*?${nameRef}\\s*\\+[^\\n]*)`, 'g');
|
|
79
|
+
const sinkRe = new RegExp(`\\b(?:${GO_WRITE_SINKS})\\s*\\(([^\\n]*)`, 'g');
|
|
80
|
+
const lineOfIdx = (i) => _lineOf(raw, i);
|
|
81
|
+
const flag = (idx, how) => {
|
|
82
|
+
const line = lineOfIdx(idx);
|
|
83
|
+
push({
|
|
84
|
+
id: `zip-slip:${fp}:${line}:go`, file: fp, line,
|
|
85
|
+
vuln: `Zip Slip: an archive entry name reaches a file operation (${how}) without a check that the result stays inside the extraction directory`,
|
|
86
|
+
severity: 'high', cwe: 'CWE-22', stride: 'Tampering',
|
|
87
|
+
snippet: (raw.split('\n')[line - 1] || '').trim().slice(0, 200),
|
|
88
|
+
remediation: 'An archive entry may be named `../../x` or be absolute. After joining, clean the path and refuse it unless it is inside the destination: `target := filepath.Join(dest, f.Name); if !strings.HasPrefix(target, filepath.Clean(dest)+string(os.PathSeparator)) { return error }` (or `filepath.IsLocal(f.Name)` on Go 1.20+, or `os.Root` on Go 1.24+).',
|
|
89
|
+
confidence: 0.8, parser: 'ZIP-SLIP',
|
|
90
|
+
});
|
|
91
|
+
};
|
|
92
|
+
const handled = new Set();
|
|
93
|
+
// 1) a variable built from the entry name, then used by a write
|
|
94
|
+
let b;
|
|
95
|
+
while ((b = builtRe.exec(code))) {
|
|
96
|
+
const v = b[1];
|
|
97
|
+
const fn = _goFunctionAround(code, b.index);
|
|
98
|
+
const fnStart = code.indexOf(fn);
|
|
99
|
+
// is the variable checked in this function? a guard call must mention it (or the entry name)
|
|
100
|
+
const mention = new RegExp(`\\b${v}\\b|${nameRef}`);
|
|
101
|
+
const guarded = fn.split('\n').some((l) => GO_GUARD_CALL_RE.test(l) && mention.test(l));
|
|
102
|
+
if (guarded) { handled.add(b.index); continue; }
|
|
103
|
+
const useRe = new RegExp(`\\b(?:${GO_WRITE_SINKS})\\s*\\([^\\n]*\\b${v}\\b`, 'g');
|
|
104
|
+
useRe.lastIndex = 0;
|
|
105
|
+
const use = useRe.exec(fn);
|
|
106
|
+
if (use) { handled.add(b.index); flag(fnStart + use.index, 'built from the entry name'); }
|
|
107
|
+
}
|
|
108
|
+
// 2) the entry name used directly in a write
|
|
109
|
+
let s;
|
|
110
|
+
while ((s = sinkRe.exec(code))) {
|
|
111
|
+
if (!new RegExp(nameRef).test(s[1])) continue;
|
|
112
|
+
const fn = _goFunctionAround(code, s.index);
|
|
113
|
+
const guarded = fn.split('\n').some((l) => GO_GUARD_CALL_RE.test(l) && new RegExp(nameRef).test(l));
|
|
114
|
+
if (!guarded) flag(s.index, 'the entry name itself');
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
41
118
|
export function scanZipSlip(fp, raw) {
|
|
42
119
|
if (!raw || raw.length > 500_000) return [];
|
|
43
120
|
const findings = [];
|
|
44
121
|
const seen = new Set();
|
|
45
122
|
const push = (f) => { if (!seen.has(f.id)) { seen.add(f.id); findings.push(f); } };
|
|
46
123
|
|
|
124
|
+
if (/\.go$/i.test(fp) && !/_test\.go$/i.test(fp)) _scanGoArchive(fp, raw, push);
|
|
125
|
+
|
|
47
126
|
if (/\.(?:java|kt|kts|scala|groovy)$/i.test(fp)) {
|
|
48
127
|
const code = blankComments(raw);
|
|
49
128
|
// File-wide suppression: canonical path + startsWith pair present
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// A small, bounded memo for pure functions of (source text, tag) (E6-03).
|
|
2
|
+
//
|
|
3
|
+
// Profiling a deep scan (see docs/engineering-excellence/PARSE_REUSE.md) showed the same source text being comment-blanked and string-stripped again
|
|
4
|
+
// by every detector that wants a clean view of it: `blankComments` alone was 7% of scan CPU, called about fifteen times per file. The function is
|
|
5
|
+
// pure (same text and language, same output), so the output is computed once per distinct text and reused.
|
|
6
|
+
//
|
|
7
|
+
// BOUNDED: at most `maxEntries` entries and `maxChars` characters of retained output; the least recently used entry is dropped first. A text longer
|
|
8
|
+
// than `maxChars / 4` is not cached at all, so one huge file cannot evict everything else or hold memory.
|
|
9
|
+
// VERSIONED: the cache key includes `version`, so a change to the function that forgets to be reflected in the memo cannot serve stale output
|
|
10
|
+
// after the version is bumped. It is also off when the named environment variable is "1", which is how the measurement compares with and without.
|
|
11
|
+
// CORRECT BY CONSTRUCTION ONLY IF THE FUNCTION IS PURE and its result is never mutated: strings are immutable in JavaScript, and a test checks the
|
|
12
|
+
// memoized output equals the direct output over many inputs.
|
|
13
|
+
export function createStringMemo(fn, { version, maxEntries = 64, maxChars = 24_000_000, disableEnv = null } = {}) {
|
|
14
|
+
// text -> { size, byTag: Map<tag, output> }; insertion order is recency order (a hit re-inserts)
|
|
15
|
+
const bySrc = new Map();
|
|
16
|
+
let chars = 0; let hits = 0; let misses = 0; let skipped = 0;
|
|
17
|
+
const evict = () => {
|
|
18
|
+
while (bySrc.size > maxEntries || chars > maxChars) {
|
|
19
|
+
const oldest = bySrc.keys().next();
|
|
20
|
+
if (oldest.done) break;
|
|
21
|
+
chars -= bySrc.get(oldest.value).size; bySrc.delete(oldest.value);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
const memo = function memoized(src, tag = '') {
|
|
25
|
+
if (typeof src !== 'string' || (disableEnv && process.env[disableEnv] === '1') || src.length > maxChars / 4) { skipped++; return fn(src, tag); }
|
|
26
|
+
let e = bySrc.get(src);
|
|
27
|
+
if (e) { bySrc.delete(src); bySrc.set(src, e); if (e.byTag.has(tag)) { hits++; return e.byTag.get(tag); } }
|
|
28
|
+
misses++;
|
|
29
|
+
const out = fn(src, tag);
|
|
30
|
+
if (!e) { e = { size: src.length, byTag: new Map() }; bySrc.set(src, e); chars += src.length; }
|
|
31
|
+
e.byTag.set(tag, out); e.size += out.length; chars += out.length;
|
|
32
|
+
evict();
|
|
33
|
+
return out;
|
|
34
|
+
};
|
|
35
|
+
memo.stats = () => ({ version, entries: bySrc.size, chars, hits, misses, skipped });
|
|
36
|
+
memo.reset = () => { bySrc.clear(); chars = 0; hits = 0; misses = 0; skipped = 0; };
|
|
37
|
+
return memo;
|
|
38
|
+
}
|