@clear-capabilities/agentic-security-scanner 0.136.2 → 0.137.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 (117) hide show
  1. package/CHANGELOG.md +880 -0
  2. package/bin/agentic-security.js +189 -37
  3. package/dist/113.index.js +13 -4
  4. package/dist/178.index.js +1 -1
  5. package/dist/207.index.js +5 -4
  6. package/dist/238.index.js +1 -1
  7. package/dist/317.index.js +36 -6
  8. package/dist/384.index.js +1 -1
  9. package/dist/435.index.js +192 -15
  10. package/dist/444.index.js +20 -11
  11. package/dist/449.index.js +8 -1
  12. package/dist/526.index.js +3 -3
  13. package/dist/637.index.js +1 -1
  14. package/dist/agentic-security.mjs +15 -15
  15. package/dist/agentic-security.mjs.sha256 +1 -1
  16. package/dist/compliance-frameworks/nist-privacy-1-1.json +2 -2
  17. package/dist/compliance-frameworks/owasp-asvs-5.json +1 -1
  18. package/package.json +21 -13
  19. package/src/dataflow/CLAUDE.md +12 -4
  20. package/src/dataflow/builtin-summaries.js +1 -1
  21. package/src/dataflow/catalog-expanded.js +1 -0
  22. package/src/dataflow/catalog.js +157 -31
  23. package/src/dataflow/engine.js +639 -112
  24. package/src/dataflow/implicit-flow.js +68 -36
  25. package/src/dataflow/incremental.js +18 -3
  26. package/src/dataflow/index.js +17 -1
  27. package/src/dataflow/points-to.js +19 -6
  28. package/src/dataflow/proven-clean.js +41 -0
  29. package/src/dataflow/sanitizer-gate.js +35 -9
  30. package/src/dataflow/sanitizer-proof.js +21 -3
  31. package/src/dataflow/stub-aware-filter.js +36 -13
  32. package/src/dataflow/summaries.js +21 -2
  33. package/src/engine.js +430 -196
  34. package/src/ir/CLAUDE.md +16 -2
  35. package/src/ir/balanced-call.js +55 -0
  36. package/src/ir/class-hierarchy.js +57 -11
  37. package/src/ir/index.js +14 -2
  38. package/src/ir/parser-cs.js +513 -40
  39. package/src/ir/parser-go.js +29 -11
  40. package/src/ir/parser-java.js +300 -20
  41. package/src/ir/parser-js.js +300 -22
  42. package/src/ir/parser-kt.js +436 -18
  43. package/src/ir/parser-php.js +631 -38
  44. package/src/ir/parser-py.helper.py +32 -2
  45. package/src/ir/parser-py.js +31 -4
  46. package/src/ir/parser-rb.js +161 -26
  47. package/src/ir/ssa.js +6 -1
  48. package/src/lsp/server.js +35 -3
  49. package/src/mcp/CLAUDE.md +9 -2
  50. package/src/mcp/redact.js +26 -0
  51. package/src/mcp/tools.js +164 -15
  52. package/src/posture/CLAUDE.md +19 -7
  53. package/src/posture/accuracy-scorecard.js +9 -1
  54. package/src/posture/aibom.js +12 -8
  55. package/src/posture/auditor-walkthrough.js +102 -3
  56. package/src/posture/autopilot.js +8 -1
  57. package/src/posture/calibration-drift.js +11 -5
  58. package/src/posture/calibration.js +24 -2
  59. package/src/posture/clustering.js +12 -1
  60. package/src/posture/compliance-frameworks/nist-privacy-1-1.json +2 -2
  61. package/src/posture/compliance-frameworks/owasp-asvs-5.json +1 -1
  62. package/src/posture/compliance-policy.js +33 -1
  63. package/src/posture/confidence.js +44 -10
  64. package/src/posture/corpus-enroll.js +9 -5
  65. package/src/posture/corpus-match.js +19 -0
  66. package/src/posture/csharp-analysis.js +62 -3
  67. package/src/posture/deploy-platform.js +4 -1
  68. package/src/posture/drift.js +7 -1
  69. package/src/posture/epss.js +13 -1
  70. package/src/posture/evidence-bundle.js +36 -6
  71. package/src/posture/exploitability-probability.js +13 -1
  72. package/src/posture/falsification.js +23 -2
  73. package/src/posture/fix-metrics.js +1 -1
  74. package/src/posture/fix-verify-loop.js +10 -1
  75. package/src/posture/iac-reachability.js +14 -8
  76. package/src/posture/integrity.js +25 -7
  77. package/src/posture/model-rescan.js +65 -0
  78. package/src/posture/mttr.js +5 -0
  79. package/src/posture/poc-inprocess.js +27 -8
  80. package/src/posture/regression-test-gen.js +23 -8
  81. package/src/posture/reverse-blast-radius.js +5 -1
  82. package/src/posture/risk-dollars.js +18 -1
  83. package/src/posture/sbom.js +2 -2
  84. package/src/posture/secret-history.js +20 -11
  85. package/src/posture/security-trend.js +7 -1
  86. package/src/posture/stack-playbook.js +22 -1
  87. package/src/posture/threat-model-grounding.js +2 -2
  88. package/src/posture/validator-metrics.js +10 -3
  89. package/src/posture/verifier.js +32 -57
  90. package/src/report/index.js +183 -14
  91. package/src/runScan.js +1 -1
  92. package/src/sast/_comment-strip.js +15 -4
  93. package/src/sast/_secret-entropy.js +1 -1
  94. package/src/sast/authz.js +6 -4
  95. package/src/sast/bench-shape/index.js +2 -7
  96. package/src/sast/claude-md-prompt-injection.js +14 -3
  97. package/src/sast/cloud-iam.js +60 -7
  98. package/src/sast/cpp-bench-extras.js +1 -1
  99. package/src/sast/csrf.js +7 -5
  100. package/src/sast/env-hygiene.js +5 -2
  101. package/src/sast/iac-terraform.js +25 -0
  102. package/src/sast/java-bench-extras.js +1 -1
  103. package/src/sast/java-constant-fold.js +5 -5
  104. package/src/sast/llm-owasp.js +4 -2
  105. package/src/sast/mcp-audit.js +7 -0
  106. package/src/sast/pipeline.js +8 -0
  107. package/src/sast/prompt-template.js +8 -6
  108. package/src/sast/prototype-pollution.js +6 -2
  109. package/src/sast/redos-nfa.js +6 -6
  110. package/src/sast/secret-concat.js +13 -2
  111. package/src/sast/ssrf-cloud-metadata.js +6 -3
  112. package/src/sast/xss-reflected-multilang.js +1 -1
  113. package/src/sast/xxe.js +1 -1
  114. package/src/sca/CLAUDE.md +3 -4
  115. package/src/sca/container.js +35 -3
  116. package/src/sca/dep-confusion.js +7 -0
  117. package/src/sca/sarif-ingest.js +0 -187
@@ -24,7 +24,12 @@ function riskNote(f) {
24
24
  const et = String(f.exploitabilityTier || '').toLowerCase();
25
25
  if (et === 'minimal' || et === 'low') return `likely lower risk — ${et} exploitability`;
26
26
  const ct = String(f.confidenceTier || '').toLowerCase();
27
- if (ct === 'low' || (typeof f.confidence === 'number' && f.confidence > 0 && f.confidence < 0.5)) {
27
+ // `f.confidence` is clamped to [0,1] by posture/confidence.js and 0 is a
28
+ // real, reachable value (unset confidence normalizes to `null`, not 0) —
29
+ // the single worst score this note exists to catch. A `> 0` lower bound
30
+ // exempted exactly that value from the downgrade note a 0.05 finding
31
+ // correctly got.
32
+ if (ct === 'low' || (typeof f.confidence === 'number' && f.confidence < 0.5)) {
28
33
  return 'lower confidence — verify before prioritising';
29
34
  }
30
35
  return null;
@@ -83,6 +88,25 @@ function fingerprint(f){
83
88
  return crypto.createHash('sha256').update(s).digest('hex').slice(0, 16);
84
89
  }
85
90
 
91
+ // CMP-3: the findings schema requires `remediation` (root CLAUDE.md), and
92
+ // most detectors set it, but normalizeFindings only ever read the older
93
+ // `fix` STRING field that a minority of detectors use — so for every
94
+ // detector using the documented schema field, the SARIF fixes[]/
95
+ // fullDescription, the Markdown Fix column, and the CLI inline "fix:" line
96
+ // all rendered empty. `fix` still wins when both are set, matching the
97
+ // existing precedence in explainParts() above.
98
+ // Exported (Stage 6) so other raw-finding consumers outside this module —
99
+ // mcp/tools.js's scan_diff, lsp/server.js — that read scan.findings BEFORE
100
+ // normalizeFindings ever touches it can apply the same fix-string-vs-
101
+ // remediation-field precedence instead of re-implementing it ad hoc (and
102
+ // getting it wrong the way both of those did — reading only `.remediation`,
103
+ // which ~127 of engine.js's own detectors never set).
104
+ export function _remediationOf(f) {
105
+ if (f && typeof f.fix === 'string') return f.fix;
106
+ if (typeof f?.remediation === 'string') return f.remediation;
107
+ return null;
108
+ }
109
+
86
110
  export function normalizeFindings(scan){
87
111
  const out = [];
88
112
  // Feat-4: filter findings via custom suppressions, recording the suppression
@@ -109,11 +133,13 @@ export function normalizeFindings(scan){
109
133
  file: f.file,
110
134
  line: f.line || f.source?.line || f.sink?.line || 0,
111
135
  snippet: f.snippet || f.source?.snippet || f.sink?.snippet || '',
112
- fix: f.fix ? { description: f.fix, code: f.code || '' } : null,
136
+ fix: _remediationOf(f) ? { description: _remediationOf(f), code: f.code || '' } : null,
137
+ remediation: _remediationOf(f),
113
138
  reachable: f.reachable ?? null,
114
139
  triage: f.triageScore ?? null,
115
140
  dataClasses: f.dataClasses || [],
116
141
  chain: Array.isArray(f.chain) ? f.chain : null,
142
+ sourceProvenance: f.sourceProvenance || null,
117
143
  confidence: typeof f.confidence === 'number' ? f.confidence : null,
118
144
  // R17: corroboration ("one issue, many signals") — count of independent
119
145
  // analyses that agreed, and which ones.
@@ -237,6 +263,65 @@ export function normalizeFindings(scan){
237
263
  predictedBountyUsd: f.predictedBountyUsd || null,
238
264
  bountyConfidence: f.bountyConfidence || null,
239
265
  attackPlaybook: f.attackPlaybook || null,
266
+ // posture/git-history.js#annotateGitHistory — git blame + commit
267
+ // context, including AI-authorship detection via the Claude
268
+ // co-author trailer. Wired in engine.js but previously dropped here.
269
+ introducedBy: f.introducedBy || null,
270
+ introducedIn: f.introducedIn || null,
271
+ introducedAt: f.introducedAt || null,
272
+ introducedInMessage: f.introducedInMessage || null,
273
+ originatingPrompt: f.originatingPrompt || null,
274
+ aiAuthored: f.aiAuthored === true,
275
+ // posture/risk-dollars.js#annotateRiskDollars,
276
+ // posture/time-to-fix.js#annotateTimeToFix — same class of gap.
277
+ riskDollars: f.riskDollars || null,
278
+ estimatedFixHours: typeof f.estimatedFixHours === 'number' ? f.estimatedFixHours : null,
279
+ estimatedFixHoursSource: f.estimatedFixHoursSource || null,
280
+ // posture/composite-risk.js#annotateCompositeRisk — the module's own
281
+ // header calls this "the canonical sort key" for agents/UI; toProTable
282
+ // silently fell back to the older `triage` field because this was
283
+ // never in the allowlist.
284
+ compositeRisk: typeof f.compositeRisk === 'number' ? f.compositeRisk : null,
285
+ compositeRiskTier: f.compositeRiskTier || null,
286
+ compositeRiskFactors: Array.isArray(f.compositeRiskFactors) ? f.compositeRiskFactors : null,
287
+ // posture/relevance.js#annotateRelevance — entrypoint-reachability
288
+ // verdict + audit trail. The re-ranked `exploitability` it also sets
289
+ // did survive normalization; the verdict fields explaining WHY did not.
290
+ entrypointReachable: f.entrypointReachable ?? null,
291
+ relevance: typeof f.relevance === 'number' ? f.relevance : null,
292
+ relevanceTier: f.relevanceTier || null,
293
+ relevanceFactors: Array.isArray(f.relevanceFactors) ? f.relevanceFactors : null,
294
+ // posture/attack-taxonomy.js#annotateAttackTaxonomy — default-on;
295
+ // toProTable's `capec`/`mitre` columns read these and rendered `—`
296
+ // for every finding because they were never in this allowlist.
297
+ attck: f.attck || null,
298
+ attckName: f.attckName || null,
299
+ attckTactic: f.attckTactic || null,
300
+ atlas: f.atlas || null,
301
+ atlasName: f.atlasName || null,
302
+ d3fend: f.d3fend || null,
303
+ capec: f.capec || null,
304
+ // posture/falsification.js#annotateFalsification, which records its
305
+ // verdict via posture/verification-separation.js — recall-preserving
306
+ // (never removes a finding, never touches severity), but the verdict
307
+ // itself needs to survive to output or a quarantined finding ships
308
+ // indistinguishable from one nobody contested.
309
+ falsification: f.falsification || null,
310
+ quarantined: f.quarantined === true,
311
+ // The canonical schema (scanner/CLAUDE.md) documents `description` as
312
+ // a required field distinct from `vuln` (headline) and `remediation`
313
+ // (fix instructions) — ~47 SAST detectors set finding-specific "why
314
+ // this fired" prose here. It was never in this allowlist, so it was
315
+ // silently dropped between the detector and every report format.
316
+ description: f.description || null,
317
+ // posture/threat-model-grounding.js#applyThreatModel — crown-jewel /
318
+ // out-of-scope / compliance-regime / attacker-model tags. The severity
319
+ // bump/demotion it also performs mutates `severity` directly (so that
320
+ // half already survived normalization); this object itself did not.
321
+ threatModel: f.threatModel || null,
322
+ verification: f.verification || null,
323
+ // posture/pattern-propagation.js#annotateCrossRepoSignals — default-on.
324
+ crossRepoSignal: f.crossRepoSignal || null,
240
325
  });
241
326
  }
242
327
  for (const s of (scan.secrets||[])) {
@@ -250,11 +335,19 @@ export function normalizeFindings(scan){
250
335
  stride: s.stride || 'Information Disclosure',
251
336
  file: s.file, line: s.line, snippet: s.snippet || '',
252
337
  masked: s.masked || null,
253
- fix: s.fix ? { description: s.fix, code: s.code || '' } : null,
338
+ fix: _remediationOf(s) ? { description: _remediationOf(s), code: s.code || '' } : null,
339
+ remediation: _remediationOf(s),
254
340
  blastRadius: s.blastRadius || null,
255
341
  // Premortem #8: parser/family for downstream confidence + calibration.
256
342
  parser: s.parser || 'SECRETS',
257
343
  family: s.family || 'hardcoded-secret',
344
+ // secret-history.js sets these on a git-history-sweep finding (the
345
+ // commit it was found in, and a flag distinguishing it from a
346
+ // working-tree finding) — carried through so a consumer doesn't have
347
+ // to parse the commit sha back out of the synthetic `file` value.
348
+ commit: s.commit || null,
349
+ historical: s._historical === true,
350
+ description: s.description || null,
258
351
  });
259
352
  }
260
353
  for (const lv of (scan.logicVulns||[])) {
@@ -267,11 +360,21 @@ export function normalizeFindings(scan){
267
360
  cwe: lv.cwe || null,
268
361
  stride: lv.stride || null,
269
362
  file: lv.file, line: lv.line, snippet: lv.snippet || '',
270
- fix: lv.fix ? { description: lv.fix, code: lv.code || '' } : null,
363
+ fix: _remediationOf(lv) ? { description: _remediationOf(lv), code: lv.code || '' } : null,
364
+ remediation: _remediationOf(lv),
271
365
  blastRadius: lv.blastRadius || null,
272
366
  // Premortem #8.
273
367
  parser: lv.parser || 'LOGIC',
274
368
  family: lv.family || null,
369
+ // evaluateLicensePolicy (kind:'license') sets these so a consumer can
370
+ // identify WHICH component/license triggered the finding without
371
+ // regex-parsing the prose `vuln` string. Harmless no-op (all null)
372
+ // for every other logicVulns kind that doesn't set them.
373
+ package: lv.package || null,
374
+ version: lv.version || null,
375
+ ecosystem: lv.ecosystem || null,
376
+ license: lv.license || null,
377
+ description: lv.description || null,
275
378
  });
276
379
  }
277
380
  for (const sc of (scan.supplyChain||[])) {
@@ -281,6 +384,12 @@ export function normalizeFindings(scan){
281
384
  out.push({
282
385
  id: fingerprint(sc),
283
386
  kind: 'sca',
387
+ // sc.type discriminates vulnerable_dep | unpinned_dep | no_lockfile
388
+ // (src/sca/CLAUDE.md). Every MCP SCA-upgrade tool checks this field
389
+ // on findings looked up from the PERSISTED (toJSON-serialized) scan —
390
+ // dropping it here made synthesize_sca_upgrade/apply_sca_upgrade
391
+ // refuse every real SCA finding unconditionally.
392
+ type: sc.type || 'vulnerable_dep',
284
393
  severity: sc.severity || 'high',
285
394
  vuln: scVuln,
286
395
  cwe: sc.cwe || null,
@@ -421,6 +530,22 @@ export function toJSON(scan, meta={}, opts={}){
421
530
  // signature proves who asked for it, not that the results are absent.
422
531
  suppressedRules: scan.suppressedRules || null,
423
532
  _scanMeta: scan._scanMeta || null,
533
+ // S7: engine.js computes these on every scan with components, and their
534
+ // own findings already flow into `findings` above — but the structured
535
+ // summary objects (per-component license map, drift counts, "first
536
+ // scan, no baseline yet") were previously dropped here, so they never
537
+ // reached last-scan.json (written from this function's return value)
538
+ // or any --format output at all.
539
+ licenseGraph: scan.licenseGraph || null,
540
+ sbomDiff: scan.sbomDiff || null,
541
+ entrypointInventory: scan.entrypointInventory || null,
542
+ // S7: same class of gap as the three above — computed on every scan
543
+ // (rootCauseSweep unconditionally; attackTaxonomy/privacyFramework each
544
+ // default-on unless their own AGENTIC_SECURITY_NO_*/opt-in env var says
545
+ // otherwise) but never reached last-scan.json or any --format output.
546
+ rootCauseSweep: scan.rootCauseSweep || null,
547
+ attackTaxonomy: scan.attackTaxonomy || null,
548
+ privacyFramework: scan.privacyFramework || null,
424
549
  };
425
550
  if (opts.includeSuppressed) out.suppressed = scan.suppressions||[];
426
551
  return out;
@@ -462,7 +587,10 @@ export function toSTIX(scan, meta = {}) {
462
587
  created: now,
463
588
  modified: now,
464
589
  name: `${f.vuln || 'Security finding'} at ${f.file || '?'}:${f.line || '?'}`,
465
- description: f.fix?.description || f.vuln || '',
590
+ // Same precedence fix as toSARIF's fullDescription/message: the
591
+ // detector's own description of the finding beats remediation text,
592
+ // which beats degrading to the bare vuln title.
593
+ description: f.description || f.fix?.description || f.vuln || '',
466
594
  external_references: cweExt,
467
595
  labels: [f.severity || 'unknown'],
468
596
  // x_* extension fields — STIX 2.1 allows custom properties prefixed
@@ -615,7 +743,14 @@ export function toSARIF(scan, meta={}){
615
743
  id: f.vuln.replace(/[^a-zA-Z0-9]/g, '_'),
616
744
  name: f.vuln,
617
745
  shortDescription: { text: f.vuln },
618
- fullDescription: { text: f.fix?.description || f.vuln },
746
+ // Prefer the detector's own explanation of the finding (`description`)
747
+ // over remediation text — `fix?.description` is fix instructions, not
748
+ // an account of the vulnerability. Falling back to remediation (rather
749
+ // than degrading to the bare rule title, already shown in
750
+ // shortDescription/name) when no description was set is intentional —
751
+ // see CMP-3's test asserting a remediation-only finding still gets a
752
+ // non-degenerate fullDescription.
753
+ fullDescription: { text: f.description || f.fix?.description || f.vuln },
619
754
  helpUri: f.cwe ? `https://cwe.mitre.org/data/definitions/${f.cwe.replace(/[^0-9]/g,'')}.html` : undefined,
620
755
  properties: { tags: [f.cwe, f.stride].filter(Boolean) },
621
756
  });
@@ -680,7 +815,7 @@ export function toSARIF(scan, meta={}){
680
815
  return {
681
816
  ruleId: f.vuln ? f.vuln.replace(/[^a-zA-Z0-9]/g, '_') : 'unknown',
682
817
  level: SEV_TO_SARIF[f.severity] || 'warning',
683
- message: { text: f.fix?.description || f.vuln || 'Security finding' },
818
+ message: { text: f.description || f.fix?.description || f.vuln || 'Security finding' },
684
819
  locations: [{ physicalLocation: { artifactLocation: { uri: f.file }, region: { startLine: Math.max(1, f.line||1) } } }],
685
820
  ...(codeFlows ? { codeFlows } : {}),
686
821
  ...(fixes ? { fixes } : {}),
@@ -792,7 +927,12 @@ export function toHTML(scan, meta = {}) {
792
927
  for (const f of findings) byFile[f.file] = (byFile[f.file] || 0) + 1;
793
928
  const hotspots = Object.entries(byFile).sort((a,b)=>b[1]-a[1]).slice(0, 10);
794
929
  const data = JSON.stringify(findings).replace(/</g, '\\u003c');
795
- const generatedAt = new Date().toISOString();
930
+ // Every other emitter falls back to meta.startedAt (which
931
+ // posture/deterministic.js forces to a fixed value under --deterministic)
932
+ // before minting a fresh timestamp — toHTML never consulted it, so it was
933
+ // the one format that stayed non-deterministic run-to-run even under
934
+ // --deterministic.
935
+ const generatedAt = meta.startedAt || new Date().toISOString();
796
936
  const SEV_HEX = { critical: '#ff2d55', high: '#ff6b35', medium: '#ffb800', low: '#34d058', info: '#82aaff' };
797
937
  const sevBars = Object.entries(counts).map(([k, v]) =>
798
938
  `<div class="sev-row"><span class="sev-tag" style="background:${SEV_HEX[k]}22;color:${SEV_HEX[k]}">${k}</span><span class="sev-bar" style="width:${Math.min(100, v * 4)}%;background:${SEV_HEX[k]}"></span><span class="sev-num">${v}</span></div>`
@@ -1098,8 +1238,29 @@ export function toShipVerdict(scan, options = {}) {
1098
1238
  const profile = options.profile || { confidenceMin: CONF_DEFAULT_VIB, showTaxonomy: false };
1099
1239
  const color = options.color !== false;
1100
1240
  const c = (s, code) => color ? `${code}${s}${RESET}` : s;
1101
- const findings = _withConfidence(normalizeFindings(scan), profile.confidenceMin ?? CONF_DEFAULT_VIB);
1102
- const actionable = findings.filter(f => /critical|high/.test(f.severity));
1241
+ // CMP-4 (Stage-0 audit, 2026): the safety headline (Safe/Not-safe-to-deploy)
1242
+ // and the critical/high counts that drive it MUST come from the FULL
1243
+ // finding set, matching exitCodeFor — confidence filtering must never be
1244
+ // able to make a real critical/high invisible to the verdict. Before this
1245
+ // fix, `findings` was confidence-filtered BEFORE the severity split, so a
1246
+ // critical at confidence 0.85 (below the 0.9 vibecoder floor) made the
1247
+ // verdict print "Safe to deploy" while exitCodeFor — reading the same scan
1248
+ // unfiltered — returned 3. Same scan, contradictory answers depending on
1249
+ // which one you read.
1250
+ //
1251
+ // Confidence filtering KEEPS its legitimate purpose for low/medium/info
1252
+ // findings — those never gate the safety verdict, so hiding noisy
1253
+ // low-signal ones from a non-expert reader is a reasonable UX choice, not a
1254
+ // security decision. What changes is that low/medium/info findings hidden
1255
+ // this way are now DISCLOSED by count rather than silently vanishing,
1256
+ // matching the project's no-silent-truncation convention.
1257
+ const allFindings = normalizeFindings(scan);
1258
+ const min = profile.confidenceMin ?? CONF_DEFAULT_VIB;
1259
+ const criticalOrHighAll = allFindings.filter(f => /critical|high/.test(f.severity));
1260
+ const restFiltered = _withConfidence(allFindings.filter(f => !/critical|high/.test(f.severity)), min);
1261
+ const findings = [...criticalOrHighAll, ...restFiltered];
1262
+ const filteredOutCount = allFindings.length - findings.length;
1263
+ const actionable = criticalOrHighAll;
1103
1264
  const advisoryCount = findings.length - actionable.length;
1104
1265
  const sev = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
1105
1266
  for (const f of findings) sev[f.severity] = (sev[f.severity] || 0) + 1;
@@ -1164,6 +1325,12 @@ export function toShipVerdict(scan, options = {}) {
1164
1325
  } else if (advisoryCount > 0) {
1165
1326
  lines.push(c(` ${advisoryCount} advisory item${advisoryCount === 1 ? '' : 's'} — run /security-scan-all --firehose to see them.`, DIM));
1166
1327
  }
1328
+ // No-silent-truncation: a low/medium/info finding hidden by the confidence
1329
+ // floor must be disclosed by count. Critical/high are never in this count —
1330
+ // see filteredOutCount's computation above.
1331
+ if (filteredOutCount > 0) {
1332
+ lines.push(c(` ${filteredOutCount} more below your confidence threshold — run /security-scan-all --firehose to see them.`, DIM));
1333
+ }
1167
1334
  // Discoverability: the depth (per-finding explanation) and the shareable report
1168
1335
  // exist but aren't obvious from the one-screen verdict — point to them.
1169
1336
  if (findings.length > 0) {
@@ -1201,10 +1368,12 @@ export function toProTable(scan, options = {}) {
1201
1368
  const columns = options.columns || 'standard'; // 'standard' | 'mitre' | 'capec' | 'owasp'
1202
1369
  const findings = _withConfidence(normalizeFindings(scan), profile.confidenceMin ?? CONF_DEFAULT_PRO);
1203
1370
 
1204
- // Rank by triage score (or severity rank if absent).
1371
+ // Rank by compositeRisk (the canonical priority key, per composite-risk.js's
1372
+ // own header) when present, falling back to the older triage score, then
1373
+ // severity rank.
1205
1374
  findings.sort((a, b) => {
1206
- const ea = a.triage ?? (1 - (SEV_RANK[a.severity] || 0) / 4);
1207
- const eb = b.triage ?? (1 - (SEV_RANK[b.severity] || 0) / 4);
1375
+ const ea = a.compositeRisk ?? (a.triage != null ? a.triage * 100 : (1 - (SEV_RANK[a.severity] || 0) / 4) * 100);
1376
+ const eb = b.compositeRisk ?? (b.triage != null ? b.triage * 100 : (1 - (SEV_RANK[b.severity] || 0) / 4) * 100);
1208
1377
  return eb - ea;
1209
1378
  });
1210
1379
 
@@ -1229,7 +1398,7 @@ export function toProTable(scan, options = {}) {
1229
1398
  const cwe = (f.cwe || '—').padEnd(10);
1230
1399
  const cvss = (f.cvss || f.cvssV3?.score || '—').toString().padEnd(5);
1231
1400
  const owasp = (f.owasp || f.owaspCategory || '—').padEnd(10);
1232
- const mitre = (f.mitreAttack || f.attckTechnique || '—').padEnd(20);
1401
+ const mitre = (f.attck || '—').padEnd(20);
1233
1402
  const capec = (f.capec || '—').padEnd(10);
1234
1403
  const conf = (f.confidence == null ? '—' : f.confidence.toFixed(2));
1235
1404
  const vuln = (f.vuln || '').slice(0, 60);
package/src/runScan.js CHANGED
@@ -120,7 +120,7 @@ export async function runScan(rootDir, opts = {}) {
120
120
 
121
121
  // R8: `resume` is opt-in. Left undefined here, runFullScan falls back to the
122
122
  // AGENTIC_SECURITY_RESUME=1 env var, which is off by default.
123
- const scan = await runFullScan({ fileContents, depFileContents, scanRoot: root, resume: opts.resume }, opts.onProgress || (()=>{}));
123
+ const scan = await runFullScan({ fileContents, depFileContents, scanRoot: root, resume: opts.resume, deep: opts.deep, deepInCi: opts.deepInCi }, opts.onProgress || (()=>{}));
124
124
  // Premortem 2R4.2: stamp ruleset version + source on the scan result, and
125
125
  // notify if the operator pinned a different version than what's installed.
126
126
  try { stampScan(root, scan); } catch {}
@@ -7,16 +7,25 @@
7
7
  // - JS/TS/Java/Go/C/C++/Rust line comments // ...
8
8
  // - JS/TS/Java/Go/C/C++/Rust block comments /* ... */
9
9
  // - Python line comments # ...
10
+ // - PHP: all three of the above — `//`, `/* */`, AND `#` are all valid
11
+ // PHP line/block comment forms simultaneously (unlike Python, which
12
+ // only has `#`), so PHP needs its own mode rather than reusing 'py'
13
+ // (which would strip `#` but silently leave `//`/`/* */` PHP comments
14
+ // unstripped — a source of false positives on commented-out code).
10
15
  //
11
16
  // Skips comment-like content inside string literals (single/double/backtick).
12
17
  //
13
- // The `lang` parameter is optional; pass 'py' to treat `#` as a line comment.
18
+ // The `lang` parameter is optional; pass 'py' to treat `#` as a line comment
19
+ // (and skip `//`/`/* */`), or 'php' to strip all three comment forms.
14
20
 
15
21
  export function blankComments(s, lang) {
16
22
  let out = '';
17
23
  let inS = null;
18
24
  let i = 0;
19
25
  const isPy = lang === 'py';
26
+ const isPhp = lang === 'php';
27
+ const stripSlashForms = !isPy || isPhp;
28
+ const stripHash = isPy || isPhp;
20
29
  while (i < s.length) {
21
30
  const c = s[i];
22
31
  if (inS) {
@@ -26,17 +35,19 @@ export function blankComments(s, lang) {
26
35
  i++; continue;
27
36
  }
28
37
  if (c === "'" || c === '"' || c === '`') { inS = c; out += c; i++; continue; }
29
- if (!isPy && c === '/' && s[i+1] === '/') {
38
+ if (stripSlashForms && c === '/' && s[i+1] === '/') {
30
39
  while (i < s.length && s[i] !== '\n') { out += ' '; i++; }
31
40
  continue;
32
41
  }
33
- if (!isPy && c === '/' && s[i+1] === '*') {
42
+ if (stripSlashForms && c === '/' && s[i+1] === '*') {
34
43
  const end = s.indexOf('*/', i + 2);
35
44
  const stop = end < 0 ? s.length : end + 2;
36
45
  while (i < stop) { out += (s[i] === '\n' ? '\n' : ' '); i++; }
37
46
  continue;
38
47
  }
39
- if (isPy && c === '#') {
48
+ // PHP 8 attributes (`#[Route(...)]`) use the same `#` prefix as a line
49
+ // comment — `#[` is never a comment, so don't blank it.
50
+ if (stripHash && c === '#' && s[i+1] !== '[') {
40
51
  while (i < s.length && s[i] !== '\n') { out += ' '; i++; }
41
52
  continue;
42
53
  }
@@ -63,7 +63,7 @@ const JWT_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
63
63
  // Configurable thresholds. Defaults tuned against the Juliet Java
64
64
  // 468-FP / 1-TP collapse — these settings drop FPs to ~30 without
65
65
  // losing the AWS-key-shaped TP.
66
- export const DEFAULT_OPTIONS = {
66
+ const DEFAULT_OPTIONS = {
67
67
  // Empirical floor for *non-dictionary* credentials. Lower than the
68
68
  // 3.5 ceiling Shannon-quoted for "true randomness" because real test
69
69
  // fixtures and rotated secrets often use repetitive base alphabets
package/src/sast/authz.js CHANGED
@@ -145,10 +145,12 @@ export function scanAuthZ(fp, raw) {
145
145
  const m2 = ln.match(JWT_HARDCODED_SECRET_RE);
146
146
  if (m2) {
147
147
  const val = m2[1];
148
- // Suppress only template/env placeholders
149
- if (!/process\.env|\$\{|<.*?>|^\s*$/.test(val) && !/\bsecret\b|\bchange.?me\b|^example$/i.test(val) === false || val.length >= 4) {
150
- // We still flag well-known placeholders ("secret", "changeme") because they
151
- // are the most common production foot-gun.
148
+ // Suppress only template/env placeholders. We still flag well-known
149
+ // placeholders ("secret", "changeme", "example") because they are the
150
+ // most common production foot-gun.
151
+ const looksLikePlaceholder = /process\.env|\$\{|<.*?>|^\s*$/.test(val);
152
+ const isKnownBadPlaceholder = /\bsecret\b|\bchange.?me\b|^example$/i.test(val);
153
+ if (!looksLikePlaceholder || isKnownBadPlaceholder) {
152
154
  push(_emit(fp, i + 1,
153
155
  'AuthZ: hardcoded JWT secret in source',
154
156
  'critical', 'CWE-798', ln.replace(val, '<redacted>'),
@@ -21,7 +21,7 @@
21
21
  // set) AND strips the marker comments from the corpus before scanning, so the
22
22
  // engine's true detection capability is measured.
23
23
 
24
- export function isBenchShape() {
24
+ function isBenchShape() {
25
25
  return process.env.AGENTIC_SECURITY_BENCH_SHAPE === '1';
26
26
  }
27
27
 
@@ -38,11 +38,6 @@ export {
38
38
  applyJavaBenchSuppressions,
39
39
  } from '../java-bench-extras.js';
40
40
 
41
- // Re-export the cpp-bench-extras suppressor — gated at call sites.
42
- export {
43
- applyJulietCppSuppressions as applyJulietCppFamilySuppressions,
44
- } from '../cpp-bench-extras.js';
45
-
46
41
  // OWASP Benchmark @WebServlet route-category extractor.
47
42
  // Returns the canonical vuln family (e.g. 'sql-injection') for files whose
48
43
  // @WebServlet URL encodes the test category, or null.
@@ -54,7 +49,7 @@ const _OWASP_BENCH_CATEGORY_MAP = {
54
49
  'weakrand': 'weak-rng', 'trustbound': 'trust-boundary',
55
50
  'securecookie': 'header-hardening',
56
51
  };
57
- export function benchShapeWebServletCategory(cleaned) {
52
+ function benchShapeWebServletCategory(cleaned) {
58
53
  if (!isBenchShape()) return null;
59
54
  const m = cleaned.match(/@WebServlet\s*\(\s*(?:value\s*=\s*)?["'](?:[^"']*\/)?(\w+?)-\d+\//);
60
55
  if (!m) return null;
@@ -64,14 +64,25 @@ function _line(raw, idx) {
64
64
  return raw.slice(0, idx).split('\n').length;
65
65
  }
66
66
 
67
+ // Zero-width / invisible Unicode characters: zero-width space, ZWNJ, ZWJ,
68
+ // word joiner, BOM/zero-width no-break space. Interspersing these between
69
+ // every character renders/executes identically to a human or LLM reader
70
+ // (most tokenizers treat them as no-ops) while breaking literal-word regex
71
+ // matching — a documented real-world prompt-injection evasion technique
72
+ // against exactly this kind of pattern-matching defense. None of these are
73
+ // '\n', so removing them never shifts _line()'s line-count computation.
74
+ const _ZERO_WIDTH_RE = /[​‌‍⁠]/g;
75
+
67
76
  export function scanClaudeMdPromptInjection(file, raw) {
68
77
  if (!file || !raw || typeof raw !== 'string') return [];
69
78
  if (!_INSTRUCTION_FILE_RE.test(file)) return [];
70
79
  if (raw.length > 1_000_000) return [];
71
80
 
81
+ const cleaned = raw.replace(_ZERO_WIDTH_RE, '');
82
+
72
83
  // Strip fenced code blocks so example snippets in docs don't trip the
73
84
  // detector. Replace with same-length whitespace to preserve line offsets.
74
- const stripped = raw.replace(/```[\s\S]*?```/g, (m) => m.replace(/[^\n]/g, ' '));
85
+ const stripped = cleaned.replace(/```[\s\S]*?```/g, (m) => m.replace(/[^\n]/g, ' '));
75
86
 
76
87
  const findings = [];
77
88
 
@@ -149,12 +160,12 @@ export function scanClaudeMdPromptInjection(file, raw) {
149
160
 
150
161
  // Hardcoded credentials in CLAUDE.md / AGENTS.md.
151
162
  for (const { re, label } of _CRED_RE) {
152
- const m = re.exec(raw);
163
+ const m = re.exec(cleaned);
153
164
  if (!m) continue;
154
165
  findings.push({
155
166
  id: `claude-md:hardcoded-cred:${file}:${m.index}`,
156
167
  file,
157
- line: _line(raw, m.index),
168
+ line: _line(cleaned, m.index),
158
169
  vuln: `Instruction file contains a hardcoded ${label}`,
159
170
  severity: 'critical',
160
171
  family: 'harness-config-secrets',
@@ -90,20 +90,68 @@ function _actionList(a) {
90
90
  return Array.isArray(a) ? a : [a];
91
91
  }
92
92
 
93
+ // Finds every balanced {...} substring in `raw` that parses as valid JSON
94
+ // and itself contains a "Statement" key. Recovers the case _isAwsPolicy
95
+ // already promises to route here: an AWS policy document embedded as a
96
+ // JSON-quoted string inside a non-JSON host file — most commonly a
97
+ // Terraform heredoc (`policy = <<EOF ... EOF`). Does not attempt to parse
98
+ // native-HCL jsonencode({...}) blocks (bare identifiers, no quotes) — that
99
+ // shape doesn't match _isAwsPolicy's own quoted-key detection regex either,
100
+ // so it's already outside this detector's claimed scope.
101
+ function _extractJsonPolicyBlocks(raw) {
102
+ const blocks = [];
103
+ for (let i = 0; i < raw.length; i++) {
104
+ if (raw[i] !== '{') continue;
105
+ let depth = 0, inStr = false, strCh = '', esc = false, j = i;
106
+ for (; j < raw.length; j++) {
107
+ const c = raw[j];
108
+ if (inStr) {
109
+ if (esc) esc = false;
110
+ else if (c === '\\') esc = true;
111
+ else if (c === strCh) inStr = false;
112
+ continue;
113
+ }
114
+ if (c === '"') { inStr = true; strCh = c; continue; }
115
+ if (c === '{') depth++;
116
+ else if (c === '}') { depth--; if (depth === 0) break; }
117
+ }
118
+ if (depth !== 0 || j >= raw.length) continue;
119
+ const candidate = raw.slice(i, j + 1);
120
+ if (!/"Statement"\s*:/.test(candidate)) continue;
121
+ try { blocks.push(JSON.parse(candidate)); } catch { /* not valid JSON on its own */ }
122
+ }
123
+ return blocks;
124
+ }
125
+
93
126
  function detectAws(file, raw, out, seen) {
94
- let parsed;
95
- try { parsed = JSON.parse(raw); } catch { return; }
96
- const ss = _statements(parsed);
127
+ let ss;
128
+ try {
129
+ ss = _statements(JSON.parse(raw));
130
+ } catch {
131
+ // Non-JSON host file (e.g. Terraform HCL) — fall back to locating
132
+ // embedded JSON policy-document blocks instead of giving up outright.
133
+ ss = _extractJsonPolicyBlocks(raw).flatMap(_statements);
134
+ if (!ss.length) return;
135
+ }
136
+ // _line() takes a raw-text offset, not a substring — a monotonically
137
+ // advancing cursor lets successive statements (JSON.parse preserves
138
+ // source order) each locate their OWN occurrence of a shared marker
139
+ // like `"Principal"` instead of every statement resolving to the same
140
+ // (first) occurrence in the file.
141
+ let _cursor = 0;
97
142
  for (const s of ss) {
98
143
  if ((s.Effect || 'Allow') !== 'Allow') continue;
99
144
  const actions = _actionList(s.Action);
100
145
  const isStarAction = actions.includes('*') || actions.some(a => /^[a-z]+:\*$/.test(a));
101
146
  const hasCondition = s.Condition && Object.keys(s.Condition).length > 0;
102
147
  const principalStar = _principalIsWildcard(s.Principal);
148
+ let _advanceTo = _cursor;
103
149
 
104
150
  // aws-public-s3-policy
105
151
  if (principalStar && actions.some(a => /^s3:/.test(a))) {
106
- const ln = _line(raw, `"Principal"`);
152
+ const idx = raw.indexOf('"Principal"', _cursor);
153
+ const ln = _line(raw, idx >= 0 ? idx : _cursor);
154
+ if (idx >= 0) _advanceTo = Math.max(_advanceTo, idx + 1);
107
155
  const id = `aws-public-s3-policy:${file}:${ln}`;
108
156
  if (!seen.has(id)) {
109
157
  seen.add(id);
@@ -118,7 +166,9 @@ function detectAws(file, raw, out, seen) {
118
166
 
119
167
  // aws-public-trust-policy (Principal:* on AssumeRole)
120
168
  if (principalStar && actions.includes('sts:AssumeRole')) {
121
- const ln = _line(raw, 'AssumeRole');
169
+ const idx = raw.indexOf('AssumeRole', _cursor);
170
+ const ln = _line(raw, idx >= 0 ? idx : _cursor);
171
+ if (idx >= 0) _advanceTo = Math.max(_advanceTo, idx + 1);
122
172
  const id = `aws-public-trust-policy:${file}:${ln}`;
123
173
  if (!seen.has(id)) {
124
174
  seen.add(id);
@@ -136,7 +186,9 @@ function detectAws(file, raw, out, seen) {
136
186
  if (_HIGH_RISK_AWS_ACTIONS.includes(a) || a === '*') {
137
187
  const conditionStr = JSON.stringify(s.Condition || {});
138
188
  if (!/MultiFactorAuthPresent|MultiFactorAuthAge/.test(conditionStr)) {
139
- const ln = _line(raw, `"${a}"`);
189
+ const idx = raw.indexOf(`"${a}"`, _cursor);
190
+ const ln = _line(raw, idx >= 0 ? idx : _cursor);
191
+ if (idx >= 0) _advanceTo = Math.max(_advanceTo, idx + 1);
140
192
  const id = `aws-no-mfa-condition:${file}:${a}:${ln}`;
141
193
  if (!seen.has(id)) {
142
194
  seen.add(id);
@@ -153,12 +205,13 @@ function detectAws(file, raw, out, seen) {
153
205
 
154
206
  // (iam:PassRole with Resource:* is detected by posture/iam-policy.js —
155
207
  // not duplicated here.)
208
+ _cursor = _advanceTo;
156
209
  }
157
210
 
158
211
  // aws-overbroad-managed-policy
159
212
  if (/"AdministratorAccess"|"PowerUserAccess"/.test(raw) && !/root\b|Bootstrap\b/.test(raw)) {
160
213
  const m = /"(AdministratorAccess|PowerUserAccess)"/.exec(raw);
161
- const ln = _line(raw, m[0]);
214
+ const ln = _line(raw, m.index);
162
215
  const id = `aws-overbroad-managed-policy:${file}:${ln}`;
163
216
  if (!seen.has(id)) {
164
217
  seen.add(id);
@@ -82,7 +82,7 @@ function familyOf(finding) {
82
82
  }
83
83
 
84
84
  // Return the primary-CWE family for a Juliet C/C++ test path, or null.
85
- export function julietPrimaryFamily(file) {
85
+ function julietPrimaryFamily(file) {
86
86
  const m = JULIET_DIR_RE.exec(String(file).replace(/\\/g, '/'));
87
87
  if (!m) return null;
88
88
  return CWE_TO_FAMILY[parseInt(m[1], 10)] || null;
package/src/sast/csrf.js CHANGED
@@ -63,11 +63,13 @@ export function scanCSRF(fp, raw) {
63
63
  if (!langSel) return [];
64
64
 
65
65
  const code = blankComments(raw, (ext === 'py' || ext === 'rb') ? 'py' : undefined);
66
- // Project-wide-ish: if the file shows CSRF defence anywhere or only handles
67
- // token-authenticated routes, we suppress.
68
- const csrfInScope = CSRF_DEFENCE_RE.test(code);
69
- const tokenAuthInScope = TOKEN_AUTH_RE.test(code);
70
- if (csrfInScope || tokenAuthInScope) return [];
66
+ // Per-route defence is checked below (±15-line window around each route).
67
+ // There used to also be a whole-file gate here ("if the file shows CSRF
68
+ // defence ANYWHERE, suppress every route in it") — by regex-substring
69
+ // monotonicity that gate always fires whenever the per-route window would
70
+ // have matched too (the window is a substring of the file), so it made the
71
+ // per-route check provably unreachable: one protected route silently
72
+ // suppressed every OTHER route in the same file, however unprotected.
71
73
 
72
74
  const findings = [];
73
75
  const seen = new Set();
@@ -4,12 +4,15 @@
4
4
  // NEXT_PUBLIC_ vars that expose private values, .env.example with real
5
5
  // credentials, and hardcoded fallback values that look real.
6
6
  //
7
- // Findings:
7
+ // Findings (4 implemented; ENV_MISSING_GITIGNORE was documented here but has
8
+ // no implementation anywhere in this file — found via Stage-0 doc audit,
9
+ // 2026. Either implement it or drop it from this list; left as a gap, not
10
+ // silently removed, so the missing coverage stays visible):
8
11
  // ENV_NEXT_PUBLIC_SECRET — NEXT_PUBLIC_ variable whose name implies it's secret
9
12
  // ENV_EXAMPLE_REAL_VALUE — .env.example / .env.sample with a real-looking value
10
13
  // ENV_HARDCODED_FALLBACK — process.env.X || "looks-real" fallback in source
11
- // ENV_MISSING_GITIGNORE — .env / .env.local present but not in .gitignore
12
14
  // ENV_DOTENV_IN_SOURCE — .env file content loaded via require/import in prod code
15
+ // NOT IMPLEMENTED: ENV_MISSING_GITIGNORE — .env / .env.local present but not in .gitignore
13
16
 
14
17
  const _ENV_FILE_RE = /^\.env(?:\.(?:local|development|production|test|staging))?$/i;
15
18
  const _NONPROD_RE = /(?:^|\/)(?:tests?|__tests__|spec|fixtures?|examples?|node_modules)\//i;