@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.
Files changed (201) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/bin/agentic-security.js +37 -0
  3. package/dist/119.index.js +197 -0
  4. package/dist/128.index.js +129 -0
  5. package/dist/1293.index.js +5689 -0
  6. package/dist/1351.index.js +133 -0
  7. package/dist/1387.index.js +271 -0
  8. package/dist/1437.index.js +23 -0
  9. package/dist/1445.index.js +110 -0
  10. package/dist/1505.index.js +151 -0
  11. package/dist/1557.index.js +293 -0
  12. package/dist/1621.index.js +311 -0
  13. package/dist/1751.index.js +160 -0
  14. package/dist/1765.index.js +727 -0
  15. package/dist/1861.index.js +690 -0
  16. package/dist/1898.index.js +116 -0
  17. package/dist/1906.index.js +433 -0
  18. package/dist/1920.index.js +1 -291
  19. package/dist/208.index.js +1631 -0
  20. package/dist/2199.index.js +0 -239
  21. package/dist/2223.index.js +159 -0
  22. package/dist/2530.index.js +152 -0
  23. package/dist/2565.index.js +116 -0
  24. package/dist/2574.index.js +127 -0
  25. package/dist/2620.index.js +345 -0
  26. package/dist/2773.index.js +98 -0
  27. package/dist/2775.index.js +32 -0
  28. package/dist/2966.index.js +171 -0
  29. package/dist/2972.index.js +87 -0
  30. package/dist/2991.index.js +131 -0
  31. package/dist/3063.index.js +833 -0
  32. package/dist/3075.index.js +227 -0
  33. package/dist/3121.index.js +297 -0
  34. package/dist/3314.index.js +263 -0
  35. package/dist/3480.index.js +1822 -0
  36. package/dist/3481.index.js +24 -0
  37. package/dist/3506.index.js +268 -0
  38. package/dist/3533.index.js +591 -0
  39. package/dist/3653.index.js +450 -0
  40. package/dist/3822.index.js +131 -0
  41. package/dist/3868.index.js +292 -0
  42. package/dist/3917.index.js +152 -0
  43. package/dist/3918.index.js +192 -0
  44. package/dist/4090.index.js +288 -0
  45. package/dist/4121.index.js +1190 -0
  46. package/dist/4142.index.js +0 -239
  47. package/dist/4336.index.js +436 -0
  48. package/dist/4387.index.js +532 -0
  49. package/dist/4413.index.js +302 -0
  50. package/dist/4476.index.js +4510 -0
  51. package/dist/4491.index.js +133 -0
  52. package/dist/4538.index.js +185 -0
  53. package/dist/4684.index.js +319 -0
  54. package/dist/4740.index.js +165 -0
  55. package/dist/4780.index.js +209 -0
  56. package/dist/4802.index.js +225 -0
  57. package/dist/4812.index.js +379 -0
  58. package/dist/4835.index.js +126 -0
  59. package/dist/494.index.js +226 -0
  60. package/dist/4991.index.js +549 -0
  61. package/dist/5199.index.js +305 -0
  62. package/dist/5216.index.js +109 -0
  63. package/dist/5439.index.js +301 -0
  64. package/dist/5443.index.js +97 -0
  65. package/dist/5496.index.js +377 -0
  66. package/dist/550.index.js +2 -1
  67. package/dist/5801.index.js +588 -0
  68. package/dist/5858.index.js +223 -0
  69. package/dist/5933.index.js +143 -0
  70. package/dist/601.index.js +23 -0
  71. package/dist/6173.index.js +273 -0
  72. package/dist/6291.index.js +1871 -0
  73. package/dist/6312.index.js +666 -0
  74. package/dist/634.index.js +16182 -0
  75. package/dist/644.index.js +87 -0
  76. package/dist/6508.index.js +283 -0
  77. package/dist/6560.index.js +645 -0
  78. package/dist/6576.index.js +422 -0
  79. package/dist/6579.index.js +271 -0
  80. package/dist/6745.index.js +305 -0
  81. package/dist/6818.index.js +21642 -0
  82. package/dist/6972.index.js +167 -0
  83. package/dist/6995.index.js +163 -0
  84. package/dist/7249.index.js +191 -0
  85. package/dist/7372.index.js +1197 -0
  86. package/dist/7546.index.js +541 -0
  87. package/dist/76.index.js +448 -0
  88. package/dist/7879.index.js +163 -0
  89. package/dist/8025.index.js +130 -0
  90. package/dist/8054.index.js +248 -0
  91. package/dist/8097.index.js +201 -0
  92. package/dist/8126.index.js +191 -0
  93. package/dist/8178.index.js +78 -0
  94. package/dist/818.index.js +212 -0
  95. package/dist/8382.index.js +93 -0
  96. package/dist/8408.index.js +261 -0
  97. package/dist/8540.index.js +174 -0
  98. package/dist/8635.index.js +802 -0
  99. package/dist/8688.index.js +410 -0
  100. package/dist/8699.index.js +779 -0
  101. package/dist/8891.index.js +457 -0
  102. package/dist/8916.index.js +885 -0
  103. package/dist/9146.index.js +170 -0
  104. package/dist/9168.index.js +841 -0
  105. package/dist/9292.index.js +369 -0
  106. package/dist/9382.index.js +135 -11
  107. package/dist/9422.index.js +866 -0
  108. package/dist/9440.index.js +453 -0
  109. package/dist/9479.index.js +152 -0
  110. package/dist/9481.index.js +220 -0
  111. package/dist/9539.index.js +100 -0
  112. package/dist/9547.index.js +103 -0
  113. package/dist/9560.index.js +41 -61
  114. package/dist/9692.index.js +90 -0
  115. package/dist/9800.index.js +213 -1
  116. package/dist/9899.index.js +138 -0
  117. package/dist/9981.index.js +206 -0
  118. package/dist/999.index.js +117 -0
  119. package/dist/agentic-security.mjs +27 -27
  120. package/dist/agentic-security.mjs.sha256 +1 -1
  121. package/package.json +31 -6
  122. package/src/context-intelligence/candidates.js +2 -1
  123. package/src/context-intelligence/capabilities.js +2 -1
  124. package/src/context-intelligence/cli.js +2 -0
  125. package/src/context-intelligence/config.js +3 -1
  126. package/src/context-intelligence/controls.js +49 -1
  127. package/src/context-intelligence/counterexample.js +200 -0
  128. package/src/context-intelligence/cross-route.js +111 -0
  129. package/src/context-intelligence/findings.js +166 -0
  130. package/src/context-intelligence/invariants.js +197 -0
  131. package/src/context-intelligence/queue.js +6 -1
  132. package/src/context-intelligence/reachability.js +2 -2
  133. package/src/context-intelligence/review.js +68 -4
  134. package/src/dataflow/access-paths.js +11 -0
  135. package/src/dataflow/analysis-limits.js +66 -0
  136. package/src/dataflow/engine.js +282 -24
  137. package/src/dataflow/service-edges.js +174 -0
  138. package/src/dataflow/summaries.js +7 -1
  139. package/src/discovery/judge.js +2 -1
  140. package/src/engine.js +82 -14
  141. package/src/fix/apply-fix-service.js +55 -8
  142. package/src/fix/batch-journal.js +84 -0
  143. package/src/fix/exploit-replay.js +51 -0
  144. package/src/fix/footprint.js +65 -0
  145. package/src/fix/patch-policy.js +49 -0
  146. package/src/fix/secret-persistence.js +39 -0
  147. package/src/fix/test-commands.js +84 -0
  148. package/src/fix/verification-adapters.js +97 -0
  149. package/src/fix/verification-record.js +243 -0
  150. package/src/fix/verify-patch.js +89 -0
  151. package/src/ir/CLAUDE.md +5 -0
  152. package/src/ir/callgraph.js +23 -1
  153. package/src/ir/module-resolver.js +315 -0
  154. package/src/ir/parser-js.js +21 -2
  155. package/src/ir/semantic-graph.js +245 -0
  156. package/src/language/haskell-findings.js +2 -0
  157. package/src/language/haskell-guards.js +1 -1
  158. package/src/language/haskell-ir.js +3 -1
  159. package/src/language/haskell-models.js +94 -0
  160. package/src/language/haskell-security-rules.js +20 -0
  161. package/src/language/nixos-hardening.js +72 -0
  162. package/src/lineage/build-attested-correlation.js +63 -0
  163. package/src/lineage/deployment-identity.js +76 -0
  164. package/src/mcp/tools.js +13 -34
  165. package/src/pipeline/canonical-output.js +64 -0
  166. package/src/pipeline/detector-runner.js +7 -0
  167. package/src/pipeline/scan-health.js +14 -0
  168. package/src/pipeline/supervised-analyzers.js +125 -0
  169. package/src/pipeline/supervised-child.js +17 -0
  170. package/src/pipeline/telemetry.js +79 -0
  171. package/src/posture/accuracy-scorecard.js +98 -9
  172. package/src/posture/artifact-registry.js +5 -0
  173. package/src/posture/assurance/evidence-registry.js +215 -0
  174. package/src/posture/assurance/release-package-verify.js +135 -0
  175. package/src/posture/assurance/release-package.js +118 -0
  176. package/src/posture/assurance/release-verdict.js +94 -0
  177. package/src/posture/assurance/waiver.js +200 -0
  178. package/src/posture/compliance-truth.js +100 -0
  179. package/src/posture/evaluation/scan-child.js +3 -1
  180. package/src/posture/evaluation/why-missed.js +3 -1
  181. package/src/posture/exploitability-evidence.js +141 -0
  182. package/src/posture/fix-verify.js +4 -2
  183. package/src/posture/fleet/exposure-ranking.js +115 -0
  184. package/src/posture/fleet/service-chain-graph.js +150 -0
  185. package/src/posture/graph-reconciliation.js +154 -0
  186. package/src/posture/provenance/coverage.js +103 -0
  187. package/src/posture/provenance/git-evidence.js +84 -0
  188. package/src/posture/provenance/origin-resolver.js +7 -1
  189. package/src/posture/provenance/predicate-replay.js +14 -4
  190. package/src/posture/runtime-correlation.js +61 -15
  191. package/src/posture/runtime-explanation.js +116 -0
  192. package/src/posture/runtime-trace-ingest.js +194 -0
  193. package/src/posture/scan-checkpoint.js +38 -10
  194. package/src/posture/waf-ingest.js +7 -2
  195. package/src/report/index.js +4 -0
  196. package/src/report/oscal.js +38 -0
  197. package/src/report/perspectives.js +150 -0
  198. package/src/sast/_comment-strip.js +8 -1
  199. package/src/sast/ruby.js +26 -0
  200. package/src/sast/zip-slip.js +79 -0
  201. 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 _headerLine(globalKey, meta) {
235
- return JSON.stringify({ v: FORMAT, gk: globalKey, meta: meta || {} }) + '\n';
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 !== _sha(rec.d)) break; // tampered or torn-then-patched
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: _sha(d), d }) + '\n';
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-' + Math.random().toString(36).slice(2, 8),
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-' + Math.random().toString(36).slice(2, 8),
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',
@@ -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
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;', '`': '&#96;' }[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
- export function blankComments(s, lang) {
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.
@@ -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
+ }