@clear-capabilities/agentic-security-scanner 0.161.0 → 0.163.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 (182) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/bin/agentic-security.js +98 -2
  3. package/dist/1340.index.js +1871 -0
  4. package/dist/3477.index.js +727 -0
  5. package/dist/3499.index.js +23 -3
  6. package/dist/4113.index.js +59 -8
  7. package/dist/5014.index.js +833 -0
  8. package/dist/5756.index.js +2 -2
  9. package/dist/6257.index.js +1 -1
  10. package/dist/6907.index.js +248 -0
  11. package/dist/6994.index.js +1 -1
  12. package/dist/7033.index.js +33 -5
  13. package/dist/7439.index.js +1822 -0
  14. package/dist/7838.index.js +38 -2
  15. package/dist/8798.index.js +1 -1
  16. package/dist/9207.index.js +5 -0
  17. package/dist/9560.index.js +2336 -27
  18. package/dist/957.index.js +1 -1
  19. package/dist/{1905.index.js → 9999.index.js} +306 -13
  20. package/dist/agentic-security.mjs +12 -12
  21. package/dist/agentic-security.mjs.sha256 +1 -1
  22. package/package.json +39 -7
  23. package/src/capabilities/CLAUDE.md +114 -0
  24. package/src/capabilities/address.js +73 -0
  25. package/src/capabilities/attack-coverage.js +93 -0
  26. package/src/capabilities/decide.js +256 -0
  27. package/src/capabilities/delegate.js +69 -0
  28. package/src/capabilities/egress.js +20 -0
  29. package/src/capabilities/hook-advice.js +50 -0
  30. package/src/capabilities/index.js +32 -0
  31. package/src/capabilities/manifest.js +344 -0
  32. package/src/capabilities/outbound.js +189 -0
  33. package/src/capabilities/paths.js +57 -0
  34. package/src/capabilities/probes.js +211 -0
  35. package/src/capabilities/proxy.js +182 -0
  36. package/src/capabilities/reasons.js +67 -0
  37. package/src/capabilities/receipts.js +356 -0
  38. package/src/capabilities/records.js +58 -0
  39. package/src/capabilities/recovery.js +367 -0
  40. package/src/capabilities/report.js +81 -0
  41. package/src/capabilities/runner.js +236 -0
  42. package/src/capabilities/secrets.js +26 -0
  43. package/src/capabilities/tool-gate.js +119 -0
  44. package/src/capabilities/tool-registry.js +60 -0
  45. package/src/dataflow/CLAUDE.md +29 -0
  46. package/src/dataflow/catalog.js +21 -0
  47. package/src/dataflow/engine.js +20 -0
  48. package/src/dataflow/guard-dominance.js +352 -0
  49. package/src/discovery/CLAUDE.md +18 -0
  50. package/src/discovery/index.js +43 -1
  51. package/src/discovery/lenses.js +18 -0
  52. package/src/discovery/memory.js +7 -5
  53. package/src/engine.js +80 -15
  54. package/src/fix/apply-fix-service.js +33 -3
  55. package/src/integrations/index.js +5 -2
  56. package/src/ir/CLAUDE.md +14 -0
  57. package/src/ir/implicit-handler-params.js +74 -0
  58. package/src/ir/parser-cs.js +20 -0
  59. package/src/ir/parser-go.js +30 -13
  60. package/src/ir/parser-java.js +11 -0
  61. package/src/language/nix-eval-isolation.js +33 -5
  62. package/src/lineage/CLAUDE.md +31 -0
  63. package/src/lineage/deployment/adapter-cloud.js +202 -0
  64. package/src/lineage/deployment/adapter-compose.js +90 -0
  65. package/src/lineage/deployment/adapter-kit.js +142 -0
  66. package/src/lineage/deployment/adapter-kubernetes.js +247 -0
  67. package/src/lineage/deployment/attack-paths.js +443 -0
  68. package/src/lineage/deployment/boundaries-run.js +223 -0
  69. package/src/lineage/deployment/boundary-graph.js +567 -0
  70. package/src/lineage/deployment/cross-repo.js +253 -0
  71. package/src/lineage/deployment/drift.js +383 -0
  72. package/src/lineage/deployment/ingest.js +221 -0
  73. package/src/lineage/deployment/projection.js +244 -0
  74. package/src/lineage/deployment/trace-correlation.js +541 -0
  75. package/src/lineage/source-registry.js +19 -0
  76. package/src/mcp/CLAUDE.md +14 -3
  77. package/src/mcp/invariant-tools.js +64 -0
  78. package/src/mcp/portfolio-tools.js +79 -0
  79. package/src/mcp/redact.js +15 -0
  80. package/src/mcp/server.js +24 -3
  81. package/src/mcp/tools.js +30 -5
  82. package/src/posture/CLAUDE.md +505 -0
  83. package/src/posture/accuracy-scorecard.js +95 -4
  84. package/src/posture/assurance/baseline-inventory.js +218 -0
  85. package/src/posture/assurance/baseline.js +322 -0
  86. package/src/posture/assurance/bounded-io.js +145 -0
  87. package/src/posture/assurance/config.js +299 -0
  88. package/src/posture/assurance/contracts.js +315 -0
  89. package/src/posture/assurance/identity.js +62 -0
  90. package/src/posture/assurance/migrations.js +259 -0
  91. package/src/posture/assurance/rollback.js +214 -0
  92. package/src/posture/assurance/rollout.js +200 -0
  93. package/src/posture/assurance/schema-kit.js +111 -0
  94. package/src/posture/assurance/verification-record.js +258 -0
  95. package/src/posture/attestation.js +5 -0
  96. package/src/posture/autopilot.js +28 -1
  97. package/src/posture/business-logic.js +26 -4
  98. package/src/posture/evaluation/bakeoff.js +148 -0
  99. package/src/posture/evaluation/custody.js +200 -0
  100. package/src/posture/evaluation/deployment-ablation.js +416 -0
  101. package/src/posture/evaluation/dev-cases.js +65 -0
  102. package/src/posture/evaluation/economics.js +85 -0
  103. package/src/posture/evaluation/gates.js +161 -0
  104. package/src/posture/evaluation/grouping.js +101 -0
  105. package/src/posture/evaluation/interval.js +132 -0
  106. package/src/posture/evaluation/invariant-ablation.js +325 -0
  107. package/src/posture/evaluation/labels.js +200 -0
  108. package/src/posture/evaluation/protocol.js +308 -0
  109. package/src/posture/evaluation/release-gate.js +209 -0
  110. package/src/posture/evaluation/report.js +158 -0
  111. package/src/posture/evaluation/runner.js +273 -0
  112. package/src/posture/evaluation/scan-child.js +80 -0
  113. package/src/posture/evaluation/score.js +174 -0
  114. package/src/posture/evaluation/synthetic.js +90 -0
  115. package/src/posture/evaluation/why-missed.js +219 -0
  116. package/src/posture/evidence-bundle.js +93 -7
  117. package/src/posture/execution-proof.js +11 -1
  118. package/src/posture/fix-verify-loop.js +20 -6
  119. package/src/posture/fix-verify.js +37 -2
  120. package/src/posture/fleet.js +11 -2
  121. package/src/posture/invariants/cli.js +95 -0
  122. package/src/posture/invariants/coverage.js +164 -0
  123. package/src/posture/invariants/export.js +178 -0
  124. package/src/posture/invariants/expressions.js +46 -0
  125. package/src/posture/invariants/lifecycle.js +289 -0
  126. package/src/posture/invariants/project-input.js +55 -0
  127. package/src/posture/invariants/repair.js +340 -0
  128. package/src/posture/invariants/run.js +45 -0
  129. package/src/posture/invariants/scenarios.js +311 -0
  130. package/src/posture/invariants/schema.js +167 -0
  131. package/src/posture/invariants/shrink.js +407 -0
  132. package/src/posture/invariants/state-assertions.js +165 -0
  133. package/src/posture/logic-claims.js +12 -0
  134. package/src/posture/model-routing.js +40 -0
  135. package/src/posture/oracles/adapters.js +531 -0
  136. package/src/posture/oracles/conformance.js +276 -0
  137. package/src/posture/oracles/oracle.js +453 -0
  138. package/src/posture/oracles/registry.js +76 -0
  139. package/src/posture/oracles/scenario-classes.js +223 -0
  140. package/src/posture/portfolio/backend.js +279 -0
  141. package/src/posture/portfolio/bundle.js +314 -0
  142. package/src/posture/portfolio/cli.js +142 -0
  143. package/src/posture/portfolio/manifest.js +263 -0
  144. package/src/posture/portfolio/progress.js +267 -0
  145. package/src/posture/portfolio/resume.js +130 -0
  146. package/src/posture/portfolio/retention.js +207 -0
  147. package/src/posture/portfolio/scheduler.js +488 -0
  148. package/src/posture/portfolio/signing.js +155 -0
  149. package/src/posture/portfolio/wording.js +59 -0
  150. package/src/posture/portfolio/work-units.js +405 -0
  151. package/src/posture/replay/replay.js +250 -0
  152. package/src/posture/routing/adapter.js +140 -0
  153. package/src/posture/routing/calibration.js +278 -0
  154. package/src/posture/routing/canary.js +141 -0
  155. package/src/posture/routing/control.js +35 -0
  156. package/src/posture/routing/decide.js +211 -0
  157. package/src/posture/routing/drift.js +137 -0
  158. package/src/posture/routing/economics.js +185 -0
  159. package/src/posture/routing/feedback.js +207 -0
  160. package/src/posture/routing/index.js +31 -0
  161. package/src/posture/routing/outcomes.js +281 -0
  162. package/src/posture/routing/promotion.js +291 -0
  163. package/src/posture/routing/receipts.js +59 -0
  164. package/src/posture/routing/report.js +142 -0
  165. package/src/posture/routing/shadow.js +53 -0
  166. package/src/posture/specification-mining.js +26 -0
  167. package/src/posture/verification/advisory-state.js +71 -0
  168. package/src/posture/verification/emit.js +257 -0
  169. package/src/posture/verification/hypothesis-promotion.js +211 -0
  170. package/src/posture/verification/patch-negative.js +226 -0
  171. package/src/posture/verification/patch-promotion.js +113 -0
  172. package/src/posture/verification/projection.js +183 -0
  173. package/src/posture/verification/repair-records.js +124 -0
  174. package/src/posture/verifier.js +16 -0
  175. package/src/report/index.js +100 -1
  176. package/src/sandbox/CLAUDE.md +51 -0
  177. package/src/sandbox/backend-namespace.js +7 -0
  178. package/src/sandbox/backend-userspace.js +151 -34
  179. package/src/sandbox/control-probes.js +215 -0
  180. package/src/sandbox/supervise.js +201 -0
  181. package/src/sandbox/trust-boundary.js +113 -0
  182. package/src/sandbox/trust-domains.js +171 -0
package/src/engine.js CHANGED
@@ -243,6 +243,7 @@ import { applyLearnedCalibration } from './posture/triage-learning.js';
243
243
  import { annotateFormalVerification } from './dataflow/formal-verify.js';
244
244
  import { annotateProofGate } from './dataflow/proof-gate.js';
245
245
  import { applySanitizerGate } from './dataflow/sanitizer-gate.js';
246
+ import { guardDominatesSink } from './dataflow/guard-dominance.js';
246
247
  import { annotateFalsification } from './posture/falsification.js';
247
248
  import { routeModelForFinding } from './posture/model-routing.js';
248
249
  import { buildEntrypointInventory } from './posture/entrypoint-inventory.js';
@@ -1468,10 +1469,38 @@ function _guardWindow(ctx, before = 25, after = 5) {
1468
1469
  const line = ctx.line || 1;
1469
1470
  const lo = Math.max(0, line - before);
1470
1471
  const hi = Math.min(ctx.lines.length, line + after);
1471
- return ctx.lines.slice(lo, hi).join('\n')
1472
- .replace(/\/\*[\s\S]*?\*\//g, ' ')
1473
- .replace(/\/\/[^\n]*/g, ' ')
1474
- .replace(/(^|[^\w'"`])#[^\n]*/g, '$1 ');
1472
+ return _stripCommentsKeepLines(ctx.lines.slice(lo, hi).join('\n'));
1473
+ }
1474
+
1475
+ // Comment removal that is aware of string literals and keeps every newline, so a line index in the result is a line index in the file.
1476
+ // The previous regex chain treated the `//` in `"https://"` as the start of a comment and erased the rest of the line, which is where the
1477
+ // allow-list check usually is (`in_array("https://" . $host, $allowed)`): a guard written on that line disappeared from the window and a
1478
+ // neighbouring declaration happened to stand in for it. Strings never span lines here; a stray apostrophe therefore costs at most one line.
1479
+ function _stripCommentsKeepLines(text) {
1480
+ let out = '';
1481
+ let i = 0; const n = text.length;
1482
+ let quote = null;
1483
+ while (i < n) {
1484
+ const c = text[i]; const d = text[i + 1];
1485
+ if (c === '\n') { quote = null; out += c; i++; continue; }
1486
+ if (quote) {
1487
+ out += c;
1488
+ if (c === '\\' && i + 1 < n && text[i + 1] !== '\n') { out += text[i + 1]; i += 2; continue; }
1489
+ if (c === quote) quote = null;
1490
+ i++; continue;
1491
+ }
1492
+ if (c === '"' || c === "'" || c === '`') { quote = c; out += c; i++; continue; }
1493
+ if (c === '/' && d === '*') {
1494
+ const end = text.indexOf('*/', i + 2);
1495
+ const body = text.slice(i, end < 0 ? n : end + 2);
1496
+ out += body.replace(/[^\n]/g, ' ');
1497
+ i += body.length; continue;
1498
+ }
1499
+ if (c === '/' && d === '/') { while (i < n && text[i] !== '\n') { out += ' '; i++; } continue; }
1500
+ if (c === '#' && (i === 0 || !/[\w'"`]/.test(text[i - 1]))) { while (i < n && text[i] !== '\n') { out += ' '; i++; } continue; }
1501
+ out += c; i++;
1502
+ }
1503
+ return out;
1475
1504
  }
1476
1505
 
1477
1506
  // PRD R15: a guard-shaped token anywhere in the -25/+5 window used to be
@@ -1524,7 +1553,20 @@ function _guardMatchNearSinkIdentifier(ctx, guardRe, span = 2) {
1524
1553
  const w = _guardWindow(ctx);
1525
1554
  const re = new RegExp(guardRe.source, guardRe.flags.includes('g') ? guardRe.flags : guardRe.flags + 'g');
1526
1555
  const ids = _sinkLineIdentifiers(ctx);
1527
- if (!ids.size) return re.test(w); // can't correlate — fall back to the old shape-only check
1556
+ // QA-005.AC02: text that LOOKS like a guard is not enough; it must dominate the sink. `_guardWindow` starts at `winLo` in the file.
1557
+ const winLo = Math.max(0, (ctx.line || 1) - 25);
1558
+ const sinkIdx = (ctx.line || 1) - 1;
1559
+ const dominates = (windowLineIdx) => {
1560
+ const v = guardDominatesSink({ lines: ctx.lines, guardIdx: winLo + windowLineIdx, sinkIdx, file: ctx.file });
1561
+ if (v.dominates) ctx._guardEvidence = { guardLine: winLo + windowLineIdx + 1, dominance: v.reason };
1562
+ else if (_stageEvidenceOn()) _stageEvidence.guard.push({ file: ctx.file || null, sinkLine: sinkIdx + 1, guardLine: winLo + windowLineIdx + 1, rejectedBecause: v.reason });
1563
+ return v.dominates;
1564
+ };
1565
+ if (!ids.size) { // can't correlate by identifier: shape-only, but the shape must still dominate
1566
+ let nm; const probe = new RegExp(re.source, re.flags);
1567
+ while ((nm = probe.exec(w))) { if (dominates(w.slice(0, nm.index).split('\n').length - 1)) return true; if (nm.index === probe.lastIndex) probe.lastIndex++; }
1568
+ return false;
1569
+ }
1528
1570
  const wLines = w.split('\n');
1529
1571
  const sinkLineText = (ctx.lines && ctx.lines[(ctx.line || 1) - 1]) || '';
1530
1572
  let m;
@@ -1547,7 +1589,7 @@ function _guardMatchNearSinkIdentifier(ctx, guardRe, span = 2) {
1547
1589
  const hi = Math.min(wLines.length, guardLineIdx + span + 1);
1548
1590
  const local = wLines.slice(lo, hi).filter((l) => l !== sinkLineText).join('\n');
1549
1591
  for (const id of ids) {
1550
- if (new RegExp(`\\b${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).test(local)) return true;
1592
+ if (new RegExp(`\\b${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).test(local) && dominates(guardLineIdx)) return true;
1551
1593
  }
1552
1594
  }
1553
1595
  return false;
@@ -1555,6 +1597,16 @@ function _guardMatchNearSinkIdentifier(ctx, guardRe, span = 2) {
1555
1597
 
1556
1598
  function _hasSsrfHostGuard(ctx) { return _guardMatchNearSinkIdentifier(ctx, _SSRF_HOST_GUARD_RE); }
1557
1599
 
1600
+ // An open-redirect (CWE-601) target guard: a framework's local-URL / safe-redirect predicate, a relative-path check (`startsWith("/")`),
1601
+ // or a host allow-list. QA-006: once the taint layer sees a handler's implicit request parameters, a redirect that is validated
1602
+ // by the framework's own predicate (`LocalRedirect(Url.IsLocalUrl(next) ? next : "/")`) reaches this pass like any other guarded
1603
+ // sink, and is cleared by the same rule as SSRF and path guards: the guard must name the sink's identifier AND dominate the sink.
1604
+ const _REDIRECT_GUARD_RE = new RegExp(
1605
+ String.raw`\b(?:IsLocalUrl|is_safe_url|url_has_allowed_host_and_scheme|safe_redirect|safeRedirect|isSafeRedirect|isRelativeUrl|is_relative_url|validateRedirect|validate_redirect|SafeRedirect)\b`
1606
+ + String.raw`|\.\s*(?:startsWith|startswith|StartsWith|HasPrefix)\s*\(\s*['"]/['"]\s*\)|\bstr_starts_with\s*\(|\bstrpos\s*\(\s*\$?\w+\s*,\s*['"]/['"]\s*\)`
1607
+ + '|' + _SSRF_HOST_GUARD_RE.source, _SSRF_HOST_GUARD_RE.flags);
1608
+ function _hasRedirectGuard(ctx) { return _guardMatchNearSinkIdentifier(ctx, _REDIRECT_GUARD_RE); }
1609
+
1558
1610
  // A path-traversal containment guard near the file sink: a basename/strip
1559
1611
  // helper that removes directory components, a framework safe-join, or a
1560
1612
  // canonicalize-then-startsWith containment check.
@@ -1609,7 +1661,7 @@ function _hasXssOutputEncoding(ctx) {
1609
1661
  // detector produced them, using the same comment-stripped window guards.
1610
1662
  // Recall-safe: a genuinely-unguarded sink has no guard in its window (the
1611
1663
  // cve-replay vulnerable-tier TP count is the invariant that proves this).
1612
- export function dropGuardedFindings(findings, fileContents) {
1664
+ export function dropGuardedFindings(findings, fileContents, onDrop = null) {
1613
1665
  if (!Array.isArray(findings)) return findings;
1614
1666
  const cache = new Map();
1615
1667
  const linesOf = (file) => {
@@ -1641,7 +1693,7 @@ export function dropGuardedFindings(findings, fileContents) {
1641
1693
  if (f.family === 'sibling-guard-omission') return true;
1642
1694
  const cwe = f.cwe || '';
1643
1695
  const isXss = cwe === 'CWE-79' && /reflected xss/i.test(f.vuln || '');
1644
- if (cwe !== 'CWE-918' && cwe !== 'CWE-22' && !isXss) return true;
1696
+ if (cwe !== 'CWE-918' && cwe !== 'CWE-22' && cwe !== 'CWE-601' && !isXss) return true;
1645
1697
  // Flow-pair findings (REGEX parser) carry the line on sink/source, not at
1646
1698
  // the top level; fall back to those before parsing it out of the id.
1647
1699
  let line = f.line;
@@ -1655,10 +1707,12 @@ export function dropGuardedFindings(findings, fileContents) {
1655
1707
  if (typeof line !== 'number') return true;
1656
1708
  const lines = linesOf(f.file);
1657
1709
  if (!lines) return true;
1658
- const ctx = { lines, line };
1659
- if (cwe === 'CWE-918' && _hasSsrfHostGuard(ctx)) return false;
1660
- if (cwe === 'CWE-22' && _hasPathGuard(ctx)) return false;
1661
- if (isXss && _hasXssOutputEncoding(ctx)) return false;
1710
+ const ctx = { lines, line, file: f.file };
1711
+ const dropped = (kind) => { if (onDrop) onDrop(f, { kind, line, evidence: ctx._guardEvidence || null }); return false; };
1712
+ if (cwe === 'CWE-918' && _hasSsrfHostGuard(ctx)) return dropped('ssrf-host-guard');
1713
+ if (cwe === 'CWE-22' && _hasPathGuard(ctx)) return dropped('path-containment-guard');
1714
+ if (cwe === 'CWE-601' && _hasRedirectGuard(ctx)) return dropped('redirect-target-guard');
1715
+ if (isXss && _hasXssOutputEncoding(ctx)) return dropped('xss-output-encoding');
1662
1716
  return true;
1663
1717
  });
1664
1718
  }
@@ -2693,7 +2747,14 @@ const _PROTOCOL_CONSTANT_VAL_RE = new RegExp('^(?:'
2693
2747
  }
2694
2748
  // Module-level suppression log; cleared at the start of each runFullScan invocation.
2695
2749
  const _suppressionLog = [];
2696
- function _resetSuppressions(){ _suppressionLog.length = 0; }
2750
+ function _resetSuppressions(){ _suppressionLog.length = 0; _stageEvidence.dedupe.length = 0; _stageEvidence.guard.length = 0; }
2751
+ // QA-005: stage-level diagnostics for the evaluation's why-missed analysis. DIAGNOSTIC ONLY: it records what the filter and
2752
+ // dedupe stages did and never changes a finding (test/posture-level pin: findings are identical with it on and off). Collected
2753
+ // only when AGENTIC_SECURITY_STAGE_EVIDENCE=1, because a normal scan has no reader for it.
2754
+ // guard: guard-shaped text the recognizer found but that did NOT dominate its sink, so did not clear the finding
2755
+ // dedupe: findings collapsed into a winner, with the loser's own location kept (matchable identity)
2756
+ const _stageEvidence = { dedupe: [], guard: [] };
2757
+ const _stageEvidenceOn = () => process.env.AGENTIC_SECURITY_STAGE_EVIDENCE === '1';
2697
2758
  // R8: the per-file taint result minus the four arrays that are also appended
2698
2759
  // wholesale to the aggregates. Resume rebuilds those from the aggregate slices
2699
2760
  // so pfr[p] and the aggregates share object identity, exactly as in a normal run.
@@ -6587,6 +6648,7 @@ function dedupeFindingsWithEvidence(findings){
6587
6648
  : (SEV_RANK[f.severity]??9) < (SEV_RANK[kept.severity]??9);
6588
6649
  const winner = keepNew ? f : kept;
6589
6650
  const loser = keepNew ? kept : f;
6651
+ if(_stageEvidenceOn())_stageEvidence.dedupe.push({file,family:fam,winner:{id:winner.id||null,file:winner.file||null,line:winner.line??null,parser:winner.parser||null},loser:{id:loser.id||null,file:loser.file||null,line:loser.line??null,parser:loser.parser||null}});
6590
6652
  if(!winner.evidence)winner.evidence=[winner.parser||"UNKNOWN"];
6591
6653
  if(loser.parser&&!winner.evidence.includes(loser.parser))winner.evidence.push(loser.parser);
6592
6654
  if(loser.vuln&&loser.vuln!==winner.vuln){
@@ -9602,7 +9664,10 @@ function _deterministicFileTimings(timings) {
9602
9664
  // #1 — centralized SSRF/path guard recognition: drop CWE-918/CWE-22 findings
9603
9665
  // on code hardened by a host allow/deny check or a path containment guard,
9604
9666
  // regardless of which detector emitted them. Opt out: AGENTIC_SECURITY_NO_GUARD_RECOGNITION=1.
9605
- if(process.env.AGENTIC_SECURITY_NO_GUARD_RECOGNITION!=='1'){try{finalFindings=dropGuardedFindings(finalFindings,fc);}catch(_){}}
9667
+ if(process.env.AGENTIC_SECURITY_NO_GUARD_RECOGNITION!=='1'){try{finalFindings=dropGuardedFindings(finalFindings,fc,(f,why)=>{
9668
+ // QA-005.AC01: a guard-recognized drop is a suppression and goes in the ledger with the identity a later analysis needs to match it.
9669
+ _suppressionLog.push({vuln:f.vuln,file:f.file,line:why.line,snippet:f.snippet||'',reason:'guard-recognized:'+why.kind+(why.evidence?':dominates@'+why.evidence.guardLine+':'+why.evidence.dominance:''),id:f.id||null,cwe:f.cwe||null,family:f.family||null});
9670
+ });}catch(_){}}
9606
9671
  // 0.34.6: filter out Java FPs where a sanitizer pattern (argv-form ProcessBuilder,
9607
9672
  // parameterized prepareStatement, constant-folded dead-branch) is present.
9608
9673
  // applyJavaBenchSuppressions is a no-op on non-.java files.
@@ -11098,7 +11163,7 @@ function _deterministicFileTimings(timings) {
11098
11163
  compliance: _complianceReport ? { stale: _complianceReport.summary?.stale || 0 } : null,
11099
11164
  });
11100
11165
  } // end if (!skipAnnotators) — FR-PROV-029
11101
- return{entrypointInventory:_entrypointInventory,rootCauseSweep:_rootCauseSweep,proofCoverage:_proofCoverage,kevCatalog:kevCatalogMeta(),routes:dd(aR,r=>`${r.method}:${r.path}:${r.file}:${r.line}`),findings:finalFindings,sources:aSrc,sinks:aSink,sanitizers:aSan,filesScanned:files.length,linesScanned:Object.values(fc).reduce((_n,_c)=>_n+(typeof _c==='string'?_c.split("\n").length:0),0),crossFileCount:cf.length,logicVulns:aLogic,supplyChain,components:annotatedComponents,secrets:aSecrets,ciphers:{atRest:aCiphersRest,inTransit:aCiphersTransit},pfr,fc,depFileContents,suppressions:_getSuppressions(),_v3,_scanMeta,_engineErrors:{cppDataflowParseErrors:_cppDataflowParseErrors.value},annotatorErrors:_annotatorErrors,detectorErrors:_detectorErrors,executionProof:_executionProofSummary,logicClaims:_logicClaims,vulnHistory:_vulnHistory,threatModel:_threatModel,privacyFramework:_privacyFramework,privacyIrBacked:_privacyIrBacked,privacyTaxonomyVersion:_privacyTaxonomyVersion,sbomDiff:_sbomDiff,complianceReport:_complianceReport,exploitBundles:_exploitBundles,pqcPlan:_pqcPlan,licenseGraph:_licenseGraph,attributions:_attributions,attackTaxonomy:_taxonomySummary,scanHealth:_scanHealth,coverageLedger:_coverageLedger,lineageGraph:_lineageGraph,lineageStatus:_lineageStatus,languageBridges:_languageBridges,languageBom:_languageBom,aiAssistance:_aiAssistance};}
11166
+ return{entrypointInventory:_entrypointInventory,rootCauseSweep:_rootCauseSweep,proofCoverage:_proofCoverage,kevCatalog:kevCatalogMeta(),routes:dd(aR,r=>`${r.method}:${r.path}:${r.file}:${r.line}`),findings:finalFindings,sources:aSrc,sinks:aSink,sanitizers:aSan,filesScanned:files.length,linesScanned:Object.values(fc).reduce((_n,_c)=>_n+(typeof _c==='string'?_c.split("\n").length:0),0),crossFileCount:cf.length,logicVulns:aLogic,supplyChain,components:annotatedComponents,secrets:aSecrets,ciphers:{atRest:aCiphersRest,inTransit:aCiphersTransit},pfr,fc,depFileContents,suppressions:_getSuppressions(),...(_stageEvidenceOn()?{stageEvidence:{dedupe:[..._stageEvidence.dedupe],guard:[..._stageEvidence.guard]}}:{}),_v3,_scanMeta,_engineErrors:{cppDataflowParseErrors:_cppDataflowParseErrors.value},annotatorErrors:_annotatorErrors,detectorErrors:_detectorErrors,executionProof:_executionProofSummary,logicClaims:_logicClaims,vulnHistory:_vulnHistory,threatModel:_threatModel,privacyFramework:_privacyFramework,privacyIrBacked:_privacyIrBacked,privacyTaxonomyVersion:_privacyTaxonomyVersion,sbomDiff:_sbomDiff,complianceReport:_complianceReport,exploitBundles:_exploitBundles,pqcPlan:_pqcPlan,licenseGraph:_licenseGraph,attributions:_attributions,attackTaxonomy:_taxonomySummary,scanHealth:_scanHealth,coverageLedger:_coverageLedger,lineageGraph:_lineageGraph,lineageStatus:_lineageStatus,languageBridges:_languageBridges,languageBom:_languageBom,aiAssistance:_aiAssistance};}
11102
11167
 
11103
11168
  // Post-aggregation classification: every source becomes "unsafe"|"safe"; every sink becomes "confirmed"|"safe".
11104
11169
  // Orphans (no finding linkage) are bucketed by file-local heuristic so the UI shows binary states only.
@@ -42,6 +42,7 @@ import { verifyLastScan } from '../posture/integrity.js';
42
42
  import { stateDir } from '../posture/state-dir.js';
43
43
  import { applyFix as applyFixHistory, revertEntryById as revertFixEntry } from '../posture/fix-history.js';
44
44
  import { classifyFixMaterialRisk } from '../posture/material-change.js';
45
+ import { appendRepairRecord } from '../posture/verification/repair-records.js';
45
46
  import { loadApproverRegistry, verifyApprover, requiredRolesFor, checkSeparationOfDuties } from './approver-registry.js';
46
47
 
47
48
  const RESERVED_WRITE_PREFIXES = [
@@ -191,6 +192,12 @@ async function getVerifyFixCore() {
191
192
  * .agentic-security/authorized-approvers.json is configured, `approvedBy` is ALSO checked
192
193
  * against it (fix/approver-registry.js) — an approvedBy the registry doesn't recognize, or one
193
194
  * missing a role a touched category requires, is refused. A no-op with no registry configured.
195
+ * @param {object} [opts.patchNegative] - X-204: `{ original: {files, entry, oracleId, inputs}, functional: {inputs}, commit?, ... }`,
196
+ * the exploit scenario and functional cases the patch must satisfy. Honoured only when the `patch-negative-verification`
197
+ * feature (and `verification-oracles`) is enabled; otherwise ignored and this function behaves exactly as before. When it
198
+ * runs, a patch is written only if it is `verified-fix`, and the result carries `patchNegative` plus `repairRecords`: SEPARATE
199
+ * records for the proposal, its promotion or rejection, its application and any rollback.
200
+ * @param {object} [opts.assuranceConfig] - resolved assurance config (defaults to the process environment)
194
201
  * @param {boolean} [opts.dryRun]
195
202
  * @param {boolean} [opts.skipVerification] - escape hatch for callers that have ALREADY verified
196
203
  * (none currently use this — present so a future caller cannot be forced into double verification
@@ -202,7 +209,7 @@ async function getVerifyFixCore() {
202
209
  * configured, tests when a runner is detected — genuinely ran and passed, not silently skipped.
203
210
  * `verified:true, verifiedFull:false` is a real, honest state: applied, but on a degraded pass.
204
211
  */
205
- export async function applyVerifiedFix({ scanRoot, finding, files, fixMeta = null, dryRun = false, skipVerification = false } = {}) {
212
+ export async function applyVerifiedFix({ scanRoot, finding, files, fixMeta = null, dryRun = false, skipVerification = false, patchNegative = null, assuranceConfig } = {}) {
206
213
  if (!scanRoot) return { ok: false, applied: false, reason: 'scanRoot required' };
207
214
  if (!finding) return { ok: false, applied: false, reason: 'finding required' };
208
215
  if (!files || typeof files !== 'object' || !Object.keys(files).length) {
@@ -316,6 +323,9 @@ export async function applyVerifiedFix({ scanRoot, finding, files, fixMeta = nul
316
323
  }
317
324
 
318
325
  let verify = null;
326
+ let pn = null;
327
+ let repairRecords = [];
328
+ const pnView = (r) => ({ status: r.status, verifiedFix: r.verifiedFix, incompleteStep: r.incompleteStep, failureCode: r.failureCode, reason: r.reason, steps: r.steps, patchDigest: r.patchDigest, summary: r.summary, verificationRecord: r.verificationRecord });
319
329
  if (!skipVerification) {
320
330
  if (!finding.stableId) {
321
331
  return { ok: false, applied: false, reason: 'finding has no stableId — cannot verify a candidate fix against it' };
@@ -328,22 +338,31 @@ export async function applyVerifiedFix({ scanRoot, finding, files, fixMeta = nul
328
338
  originalFindingStableId: finding.stableId,
329
339
  files: Object.fromEntries(Object.entries(confined).map(([rel, v]) => [rel, v.content])),
330
340
  fixMeta,
341
+ ...(patchNegative ? { patchNegative, assuranceConfig } : {}),
331
342
  });
332
343
  } catch (e) {
333
344
  return { ok: false, applied: false, reason: `verification failed: ${e.message}` };
334
345
  }
335
346
  verify = verdict;
347
+ // X-204: present only when the caller asked and the feature is on. `pn` stays null on every legacy path.
348
+ const pnRun = verdict.patchNegative && verdict.patchNegative.status !== 'disabled' ? verdict.patchNegative : null;
349
+ if (pnRun) { pn = pnRun; repairRecords = pnRun.repairRecords || []; }
336
350
  if (!verdict.ok) {
351
+ // the patch-negative leg may have passed while another leg did not: the rejection is still its own record
352
+ if (pn && pn.verifiedFix) {
353
+ repairRecords = appendRepairRecord(repairRecords, { kind: 'rejected', hypothesisId: finding.stableId, revision: pn.promotion?.revision ?? null, diffDigest: pn.patchDigest, step: 'other-verification-legs', reason: `rejected by verifier: ${String(verdict.summary || 'did not verify').slice(0, 200)}` }).records;
354
+ }
337
355
  return {
338
356
  ok: false, applied: false,
339
357
  reason: `rejected by verifier: ${verdict.summary || verdict.rescan?.reason || 'did not verify'}`,
340
358
  verify: { rescan: verdict.rescan, lint: verdict.lint ? { runner: verdict.lint.runner, ok: verdict.lint.ok } : null, honesty: verdict.honesty || null },
359
+ ...(pn ? { patchNegative: pnView(pn), repairRecords } : {}),
341
360
  };
342
361
  }
343
362
  }
344
363
 
345
364
  if (dryRun) {
346
- return { ok: true, applied: false, dryRun: true, verified: !skipVerification, files: Object.keys(confined), verify, materialClassification };
365
+ return { ok: true, applied: false, dryRun: true, verified: !skipVerification, files: Object.keys(confined), verify, materialClassification, ...(pn ? { patchNegative: pnView(pn), repairRecords } : {}) };
347
366
  }
348
367
 
349
368
  // FR-304: re-check every file against its pre-verification baseline hash,
@@ -381,11 +400,21 @@ export async function applyVerifiedFix({ scanRoot, finding, files, fixMeta = nul
381
400
  for (const w of written) {
382
401
  try { await revertFixEntry(scanRoot, w.historyId); } catch { /* best-effort; original error still propagates below */ }
383
402
  }
403
+ // X-204: the rollback is its own record, after the promotion it undoes (a write that failed part-way)
404
+ if (pn) {
405
+ const rolled = appendRepairRecord(repairRecords, { kind: 'rolled-back', hypothesisId: finding.stableId, revision: pn.promotion?.revision ?? null, diffDigest: pn.patchDigest, reason: `the write failed and every file written in this batch was restored: ${String(e?.message || e).slice(0, 160)}` });
406
+ repairRecords = rolled.records;
407
+ }
384
408
  if (e && e.name === 'FixAttemptBudgetExceededError') {
385
- return { ok: false, applied: false, reason: `budget-exceeded: ${e.message}`, budgetExceeded: true, attempts: e.attempts, maxAttempts: e.max, key: e.key };
409
+ return { ok: false, applied: false, reason: `budget-exceeded: ${e.message}`, budgetExceeded: true, attempts: e.attempts, maxAttempts: e.max, key: e.key, ...(pn ? { patchNegative: pnView(pn), repairRecords } : {}) };
386
410
  }
411
+ if (pn && e && typeof e === 'object') e.repairRecords = repairRecords;
387
412
  throw e;
388
413
  }
414
+ // X-204: application is a separate record from the promotion that allowed it
415
+ if (pn) {
416
+ repairRecords = appendRepairRecord(repairRecords, { kind: 'applied', hypothesisId: finding.stableId, revision: pn.promotion?.revision ?? null, diffDigest: pn.patchDigest, reason: `written to ${written.length} file(s) after verified-fix promotion` }).records;
417
+ }
389
418
 
390
419
  // FR-305: `verified: true` only ever meant "verification was attempted",
391
420
  // not "every required leg genuinely ran and passed" — a caller that
@@ -400,5 +429,6 @@ export async function applyVerifiedFix({ scanRoot, finding, files, fixMeta = nul
400
429
  written,
401
430
  verify: verify ? { summary: verify.summary, verifiedFull: verify.verifiedFull, degradedLegs: verify.degradedLegs || [] } : null,
402
431
  materialClassification,
432
+ ...(pn ? { patchNegative: pnView(pn), repairRecords } : {}),
403
433
  };
404
434
  }
@@ -14,6 +14,7 @@ import * as fs from 'node:fs';
14
14
  import * as path from 'node:path';
15
15
  import * as yaml from '../util/yaml.js';
16
16
  import { statePath } from '../posture/state-dir.js';
17
+ import { portfolioAssuranceEnabled, HEADLINE, BLOCKED_HEADLINE } from '../posture/portfolio/wording.js';
17
18
 
18
19
  function _configPath(scanRoot) {
19
20
  return statePath(scanRoot, 'integrations.yml');
@@ -36,12 +37,14 @@ export function buildSlackDigest(findings, summary, options = {}) {
36
37
  const project = options.project || 'project';
37
38
  const status = (summary.critical || 0) === 0 && (summary.high || 0) === 0
38
39
  ? '✅ safe to deploy' : '❌ not safe to deploy';
40
+ const statusLine = portfolioAssuranceEnabled({ option: options.portfolioAssurance })
41
+ ? (status.startsWith('✅') ? `✅ ${HEADLINE}` : `❌ ${BLOCKED_HEADLINE}`) + ' (completed supported checks only)' : status;
39
42
  const top = (findings || []).slice(0, 3);
40
43
 
41
44
  const lines = [
42
45
  `*🛡 agentic-security daily — ${new Date().toISOString().slice(0,10)}*`,
43
46
  `Project: \`${project}\``,
44
- `Status: ${status}`,
47
+ `Status: ${statusLine}`,
45
48
  '',
46
49
  `*Findings:* ${summary.critical || 0} critical · ${summary.high || 0} high · ${summary.medium || 0} medium`,
47
50
  ];
@@ -66,7 +69,7 @@ export function buildDiscordDigest(findings, summary, options = {}) {
66
69
  embeds: [{
67
70
  title: `🛡 agentic-security — ${project}`,
68
71
  color: safe ? 0x2ecc71 : 0xe74c3c,
69
- description: safe ? '✅ safe to deploy' : '❌ not safe to deploy',
72
+ description: portfolioAssuranceEnabled({ option: options.portfolioAssurance }) ? (safe ? `✅ ${HEADLINE} (completed supported checks only)` : `❌ ${BLOCKED_HEADLINE}`) : (safe ? '✅ safe to deploy' : '❌ not safe to deploy'),
70
73
  fields: [
71
74
  { name: 'Critical', value: String(summary.critical || 0), inline: true },
72
75
  { name: 'High', value: String(summary.high || 0), inline: true },
package/src/ir/CLAUDE.md CHANGED
@@ -34,6 +34,20 @@ consumed by `scanner/src/dataflow/` for taint analysis.
34
34
  | Rust | `parser-rust.js` | Hand-rolled, the `parser-go.js`/`parser-cs.js` pattern (preprocess: comments blanked, `::` paths flattened to dots, turbofish/`?`/`.await` stripped; then statement-split + recursive CFG build). **SARD 80% F1 push, first-class from day one — no prior IR existed for Rust at all** (only the opt-in, WASM-based `tree-sitter-loader.js` long-tail path below, which nothing in the taint engine consumes). Models: `fn` items (free/impl/trait, qualifiers, generics, `impl Type` methods named `Type.method`), `let`/destructuring/compound-assign, method chains (args accumulated innermost-first, so `Command::new("sh").arg("-c").arg(x)` has the literal program name at arg 0 regardless of the chain's terminal method), macros (`format!`/`write!`/etc. lower to a template with inline `{ident}` and positional args; `vec!` to an array; every other macro keeps its own `name!` callee so `sqlx::query!` — the compile-checked, parameterized macro — never collides with the `query` sink); `if`/`if let`/`while`/`while let`/`loop`/`for`/`match` (pattern bindings assigned from the scrutinee) including expression-position control flow (`let x = match … {}` yields a `union` of the branch tails); trailing closures inlined with their parameter bound to the receiver; extractor-typed parameters (`Query(q): Query<P>`) and route-attributed handlers (`#[get("/<x>")]`) emit `fn.paramAnnotations`. **`.unwrap()`/`.expect(msg)` are popped from the chain, not just taint-transparent** — leaving them in shifts the terminal segment away from the real sink name (`sqlx::query(&sql).fetch_all(pool).await.unwrap()` would otherwise end in `.unwrap`, never `.fetch_all`, and no catalog entry could ever match it), the same "terminal segment shift" defect class documented for Go/Java/Kotlin above; a macro-only statement whose sole continuation is a popped `.unwrap()` (`write!(w, "…", x).unwrap();`) still counts as a call, tracked via a separate `_baseIsCall` flag so it doesn't fall through to "no call happened here" once the passthrough is gone. Three self-scan-flagged regexes (`(?:#\[[^\]]*\]\s*)+`, the attribute-skip pattern, appearing at three call sites) match the "optional/quantified group flanked by `\s*`" ReDoS heuristic shape but are linear by construction — each iteration is bounded by the mandatory literal `#[`…`]`, no shared character class exists for the engine to backtrack across — confirmed by direct timing (50 000 back-to-back attributes, 200 000 non-matching whitespace chars: both single-digit milliseconds), not merely asserted; see the comment at `_parseParams` in `parser-rust.js`. Guarded by `test/parser-rust.test.js` (CFG shape) and `test/rust-taint-flow.test.js` (end-to-end taint flow + precision cases). **Measured `bench/layer-recall` impact: 0 → 5/5 (100%) whole-corpus, 0 → 3/3 (100%) deep-tier** on first measurement — the corpus is small and every entry is annotation-sourced (axum/actix/rocket extractor parameters route directly into `paramAnnotations`), so 100% reflects the corpus's own composition, not general Rust ecosystem coverage; see `docs/METRICS.md`'s own disclosure. |
35
35
  | Long-tail (solidity/go/swift/dart) | `tree-sitter-loader.js` | **Optional** `web-tree-sitter` + `tree-sitter-wasms` (ABI-pinned 0.20.8 ↔ 0.1.13), lazy + degrades when absent. Powers `sast/tree-sitter-sinks.js` (opt-in via `AGENTIC_SECURITY_TREE_SITTER=1`). Marked `--external` in the build so the committed bundle never embeds WASM. Rust's own tree-sitter grammar remains loadable here too (unaffected by `parser-rust.js` existing), but the hand-rolled parser above is what feeds the taint engine — this path is now redundant for Rust specifically, kept only because removing a language from `tree-sitter-sinks.js`'s opt-in surface is out of scope for this change. |
36
36
 
37
+ ## Implicit handler parameters and Go line attribution (QA-006)
38
+
39
+ `implicit-handler-params.js` is the pure decision "is this parameter request-bound without any annotation". `parser-cs.js` emits a
40
+ `paramAnnotations` entry with decorator `ImplicitMvcActionParam` for a string-shaped parameter of a public, non-static, non-`[NonAction]` method of a
41
+ controller class (derived from Controller/ControllerBase/ApiController, or named `*Controller`) unless it carries `[FromServices]` or an explicit
42
+ `[From*]`; `parser-java.js` emits `ImplicitSpringMappedParam` for a plain `String` parameter of a method annotated `@GetMapping`/`@PostMapping`/
43
+ `@RequestMapping`/... The catalog turns those two names into sources; the IR states the fact and the catalog decides what is a source. Nothing in the
44
+ module reads a path, label or environment variable.
45
+
46
+ `parser-go.js` line attribution: `FUNC_RE`'s leading `\s*` swallows blank lines, so the function used to be placed at the first newline of the run above
47
+ it, and `_splitStatements` dropped blank and comment lines so every later statement was pulled upward by their count. Statement lines are now start-offset
48
+ based (`_splitStatements(...).lineOffsets`, `_buildCfg` takes the line the body BEGINS on), a `//` comment no longer swallows the newline that ends its
49
+ line, and nested if/for/closure bodies take their base from the real brace position. Tests: `test/evaluation/engine-mechanisms.test.js`.
50
+
37
51
  ## Python parser — dual-path with auto fallback
38
52
 
39
53
  The Python pipeline has two implementations. The dispatcher in `index.js`
@@ -0,0 +1,74 @@
1
+ // Implicit request-bound handler parameters (QA-006, mechanism "framework entry points").
2
+ //
3
+ // A web framework binds the parameters of a request-handler method from the
4
+ // request WITHOUT any annotation on the parameter itself:
5
+ // - ASP.NET MVC / Core: a public action method of a controller binds `string next`
6
+ // from the route, query string or form unless it carries `[FromServices]`.
7
+ // - Spring MVC: a method carrying `@GetMapping` / `@RequestMapping` / ... binds a plain
8
+ // `String q` parameter from the request parameters (`@RequestParam` is optional).
9
+ //
10
+ // The taint engine's annotation channel (`fn.paramAnnotations`, matched by the catalog's
11
+ // `type: 'annotation'` sources) only ever saw the EXPLICIT forms (`[FromQuery]`,
12
+ // `@RequestParam`), so a handler written in the equally common implicit form had no
13
+ // source at all and the flow was invisible to the taint layer. This module is the pure
14
+ // decision of "is this parameter implicitly request-bound", shared by the C# and Java
15
+ // frontends; each frontend turns a positive answer into a `paramAnnotations` entry whose
16
+ // decorator is one of the two names below, which the catalog binds to a source.
17
+ //
18
+ // Scope, stated so nothing is over-read:
19
+ // - ONLY string-shaped parameters. A numeric or Guid parameter cannot carry an injection
20
+ // payload, and a complex model parameter's field-level taint is out of scope here.
21
+ // - ONLY inside a class the framework would treat as a handler (a Controller-derived
22
+ // class, or any class whose name ends in `Controller`; for Spring, the MAPPING
23
+ // annotation on the method is what makes it a handler, not the class).
24
+ // - A DI-injected parameter is not request-bound: `[FromServices]` opts a parameter out.
25
+ // - Nothing here looks at a file name, path, comment or label.
26
+
27
+ export const IMPLICIT_MVC_PARAM = 'ImplicitMvcActionParam';
28
+ export const IMPLICIT_SPRING_PARAM = 'ImplicitSpringMappedParam';
29
+
30
+ const CS_CONTROLLER_BASE = /^(?:Controller|ControllerBase|ApiController|AsyncController)$/;
31
+ const CS_STRINGISH = /^(?:(?:System\.)?[Ss]tring\??(?:\[\])?|(?:List|IList|IEnumerable|ICollection|IReadOnlyList)<\s*(?:System\.)?[Ss]tring\??\s*>)$/;
32
+ const CS_DI_OPT_OUT = new Set(['FromServices', 'FromKeyedServices']);
33
+
34
+ /** True when a C# class record (`{ name, bases }`) is one the framework treats as a controller. */
35
+ function isCsControllerClass(range) {
36
+ if (!range) return false;
37
+ if (/Controller$/.test(range.name || '')) return true;
38
+ return (range.bases || []).some((b) => CS_CONTROLLER_BASE.test(b));
39
+ }
40
+
41
+ /**
42
+ * Should a C# parameter be treated as implicitly request-bound?
43
+ * @param {object} o
44
+ * @param {string} o.modifiers the method's modifier text (the declaration prefix), e.g. 'public async'
45
+ * @param {string} o.rawType the parameter's declared type text, e.g. 'string', 'List<string>'
46
+ * @param {string[]} o.decorators the parameter's attribute names
47
+ * @param {boolean} o.nonAction the method carries [NonAction]
48
+ * @param {object} o.classRange the enclosing class record
49
+ */
50
+ export function isImplicitCsActionParam({ modifiers = '', rawType = '', decorators = [], nonAction = false, classRange = null }) {
51
+ if (nonAction) return false;
52
+ if (!isCsControllerClass(classRange)) return false;
53
+ if (!/\bpublic\b/.test(modifiers) || /\bstatic\b/.test(modifiers)) return false;
54
+ if (decorators.some((d) => CS_DI_OPT_OUT.has(d))) return false;
55
+ // An explicit binding attribute is handled by the explicit annotation sources.
56
+ if (decorators.some((d) => /^From/.test(d))) return false;
57
+ return CS_STRINGISH.test(String(rawType).trim());
58
+ }
59
+
60
+ const SPRING_MAPPING = /^(?:RequestMapping|GetMapping|PostMapping|PutMapping|DeleteMapping|PatchMapping)$/;
61
+ const SPRING_DI_OR_BINDING = /^(?:RequestParam|PathVariable|RequestBody|RequestHeader|CookieValue|ModelAttribute|RequestAttribute|SessionAttribute|RequestPart|Value|Autowired)$/;
62
+
63
+ /**
64
+ * Should a Java parameter be treated as implicitly request-bound?
65
+ * @param {string[]} o.methodAnnotations annotation simple names on the method
66
+ * @param {string} o.paramType the parameter's simple type name ('String')
67
+ * @param {string[]} o.decorators annotation names already on the parameter
68
+ */
69
+ export function isImplicitSpringMappedParam({ methodAnnotations = [], paramType = '', decorators = [] }) {
70
+ if (!methodAnnotations.some((a) => SPRING_MAPPING.test(a))) return false;
71
+ if (paramType !== 'String') return false;
72
+ if (decorators.some((d) => SPRING_DI_OR_BINDING.test(d))) return false;
73
+ return true;
74
+ }
@@ -45,6 +45,7 @@
45
45
  // This is a v1. Promoted to a Roslyn-backed CST parser (analogous to
46
46
  // parser-py-cst.js) once we have a dotnet capability probe.
47
47
 
48
+ import { IMPLICIT_MVC_PARAM, isImplicitCsActionParam } from './implicit-handler-params.js';
48
49
  import * as crypto from 'node:crypto';
49
50
  import { callSitesFromCfg } from './call-sites.js';
50
51
  import { matchBalancedCall } from './balanced-call.js';
@@ -270,6 +271,16 @@ function _extractClassFields(code, range) {
270
271
  // Innermost class whose range contains `pos` (a method declaration's match
271
272
  // offset), or null when the method sits outside any class (top-level
272
273
  // statements — legal but rare).
274
+ function _enclosingClassRange(ranges, pos) {
275
+ let best = null;
276
+ for (const r of ranges) {
277
+ if (pos >= r.start && pos < r.end) {
278
+ if (!best || (r.end - r.start) < (best.end - best.start)) best = r;
279
+ }
280
+ }
281
+ return best;
282
+ }
283
+
273
284
  function _enclosingClassName(ranges, pos) {
274
285
  let best = null;
275
286
  for (const r of ranges) {
@@ -1537,7 +1548,12 @@ export function parseCSharpFile(file, code) {
1537
1548
  while ((m = METHOD_RE.exec(code)) !== null) {
1538
1549
  const name = m[2];
1539
1550
  const className = _enclosingClassName(classRanges, m.index);
1551
+ const classRange = _enclosingClassRange(classRanges, m.index);
1540
1552
  const paramsText = m[3] || '';
1553
+ // [NonAction] sits in the attribute region just before the declaration.
1554
+ // Only the attribute region of THIS member counts: cut at the previous statement or block end.
1555
+ const attrRegion = code.slice(Math.max(0, m.index - 300), m.index + 1);
1556
+ const nonAction = /\bNonAction\b/.test(attrRegion.slice(Math.max(attrRegion.lastIndexOf('}'), attrRegion.lastIndexOf(';')) + 1));
1541
1557
  const paramAnnotations = [];
1542
1558
  const paramTypes = {};
1543
1559
  // `keptIdx` tracks the parameter's position in the FILTERED array — the
@@ -1616,6 +1632,10 @@ export function parseCSharpFile(file, code) {
1616
1632
  paramAnnotations.push({ index: keptIdx, name: paramName, decorator });
1617
1633
  }
1618
1634
  if (paramType) paramTypes[paramName] = paramType;
1635
+ // QA-006: a controller action's plain string parameter is bound from the request with no attribute at all.
1636
+ if (isImplicitCsActionParam({ modifiers: m[0], rawType: nameTokens.filter(w => !/^(?:ref|out|params|in|this)$/.test(w)).join(' '), decorators, nonAction, classRange })) {
1637
+ paramAnnotations.push({ index: keptIdx, name: paramName, decorator: IMPLICIT_MVC_PARAM });
1638
+ }
1619
1639
  keptIdx++;
1620
1640
  }
1621
1641
  return paramName;
@@ -34,6 +34,10 @@ const FUNC_RE = new RegExp(
34
34
 
35
35
  function _splitStatements(body) {
36
36
  const out = [];
37
+ // Index (in `body`) of each statement's first character, so a caller can recover its real line. Blank lines and comment-only
38
+ // lines yield no statement, so counting statements undercounts lines wherever a function body has either.
39
+ const starts = [];
40
+ let bufStart = 0;
37
41
  let buf = '';
38
42
  let depth = 0;
39
43
  let inStr = null;
@@ -57,19 +61,24 @@ function _splitStatements(body) {
57
61
  if (c === '`') { inRaw = true; buf += c; continue; }
58
62
  if (c === '/' && body[i + 1] === '/') {
59
63
  while (i < body.length && body[i] !== '\n') i++;
64
+ // Leave the newline for the loop to see: swallowing it meant a comment line never ended its statement, so the next line was
65
+ // glued to whatever came before the comment and its line number was counted from the wrong place.
66
+ i--;
60
67
  continue;
61
68
  }
62
69
  if (c === '{' || c === '(' || c === '[') depth++;
63
70
  if (c === '}' || c === ')' || c === ']') depth--;
64
71
  if ((c === '\n' || c === ';') && depth === 0) {
65
72
  const t = buf.trim();
66
- if (t) out.push(t);
73
+ if (t) { out.push(t); starts.push(bufStart + (buf.length - buf.trimStart().length)); }
67
74
  buf = '';
75
+ bufStart = i + 1;
68
76
  continue;
69
77
  }
70
78
  buf += c;
71
79
  }
72
- if (buf.trim()) out.push(buf.trim());
80
+ if (buf.trim()) { out.push(buf.trim()); starts.push(bufStart + (buf.length - buf.trimStart().length)); }
81
+ out.lineOffsets = starts.map((idx) => _countNewlinesUpTo(body, idx));
73
82
  return out;
74
83
  }
75
84
 
@@ -399,17 +408,21 @@ function _link(nodes, src, dst) {
399
408
  if (!nodes[dst].pred.includes(src)) nodes[dst].pred.push(src);
400
409
  }
401
410
 
411
+ // `startLine` is the line on which `bodyText` BEGINS (the line of its opening brace); each statement's line is that plus the
412
+ // newlines before the statement, so blank lines and comments inside the body no longer pull later statements upward.
402
413
  function _buildCfg(bodyText, nodes, prevId, startLine) {
403
414
  const stmts = _splitStatements(bodyText);
404
415
  let prev = prevId;
405
416
  let line = startLine;
406
- for (const stmt of stmts) {
417
+ for (let si = 0; si < stmts.length; si++) {
418
+ const stmt = stmts[si];
419
+ line = startLine + (stmts.lineOffsets ? stmts.lineOffsets[si] : 0);
407
420
  const s = stmt.trim();
408
421
  if (!s || s.startsWith('//')) { line++; continue; }
409
422
 
410
423
  // if statement with brace body
411
- const ifMatch = s.match(/^if\s+([\s\S]+?)\s*\{([\s\S]*)\}(?:\s*else\s*\{([\s\S]*)\})?\s*$/s) ||
412
- s.match(/^if\s+([\s\S]+?)\s*\{([\s\S]*)\}\s*$/s);
424
+ const ifMatch = s.match(/^if\s+([\s\S]+?)\s*\{([\s\S]*)\}(?:\s*else\s*\{([\s\S]*)\})?\s*$/sd) ||
425
+ s.match(/^if\s+([\s\S]+?)\s*\{([\s\S]*)\}\s*$/sd);
413
426
  if (ifMatch) {
414
427
  const condText = ifMatch[1].replace(/;[^;]*$/, '').trim();
415
428
  const thenBody = ifMatch[2];
@@ -417,10 +430,10 @@ function _buildCfg(bodyText, nodes, prevId, startLine) {
417
430
  const ifNode = _addNode(nodes, { kind: 'if', cond: _lowerExpr(condText), line });
418
431
  _link(nodes, prev, ifNode);
419
432
  const join = _addNode(nodes, { kind: 'noop', line });
420
- const thenTail = _buildCfg(thenBody, nodes, ifNode, line + 1);
433
+ const thenTail = _buildCfg(thenBody, nodes, ifNode, line + _countNewlinesUpTo(s, ifMatch.indices[2][0]));
421
434
  _link(nodes, thenTail, join);
422
435
  if (elseBody) {
423
- const elseTail = _buildCfg(elseBody, nodes, ifNode, line + 1);
436
+ const elseTail = _buildCfg(elseBody, nodes, ifNode, line + _countNewlinesUpTo(s, ifMatch.indices[3][0]));
424
437
  _link(nodes, elseTail, join);
425
438
  } else {
426
439
  _link(nodes, ifNode, join);
@@ -431,7 +444,7 @@ function _buildCfg(bodyText, nodes, prevId, startLine) {
431
444
  }
432
445
 
433
446
  // for loop with brace body
434
- const forMatch = s.match(/^for\s+([\s\S]*?)\s*\{([\s\S]*)\}\s*$/s);
447
+ const forMatch = s.match(/^for\s+([\s\S]*?)\s*\{([\s\S]*)\}\s*$/sd);
435
448
  if (forMatch) {
436
449
  const header = _addNode(nodes, { kind: 'loop-header', line });
437
450
  _link(nodes, prev, header);
@@ -446,7 +459,7 @@ function _buildCfg(bodyText, nodes, prevId, startLine) {
446
459
  _link(nodes, header, assignId);
447
460
  bodyPrev = assignId;
448
461
  }
449
- const bodyTail = _buildCfg(loopBody, nodes, bodyPrev, line + 1);
462
+ const bodyTail = _buildCfg(loopBody, nodes, bodyPrev, line + _countNewlinesUpTo(s, forMatch.indices[2][0]));
450
463
  _link(nodes, bodyTail, header);
451
464
  const join = _addNode(nodes, { kind: 'noop', line });
452
465
  _link(nodes, header, join);
@@ -476,8 +489,9 @@ function _buildCfg(bodyText, nodes, prevId, startLine) {
476
489
  const callId = _addNode(nodes, { kind: 'call', line, callee: closureCall.callee, args: outerArgs });
477
490
  _link(nodes, prev, callId);
478
491
  const closureOffset = s.indexOf(closure.closureText);
479
- const bodyStartLine = closureOffset >= 0
480
- ? line + _countNewlinesUpTo(s, closureOffset)
492
+ const closureBodyIdx = closureOffset >= 0 ? s.indexOf(closure.body, closureOffset) : -1;
493
+ const bodyStartLine = closureBodyIdx >= 0
494
+ ? line + _countNewlinesUpTo(s, closureBodyIdx)
481
495
  : line;
482
496
  prev = _buildCfg(closure.body, nodes, callId, bodyStartLine);
483
497
  line += (s.match(/\n/g) || []).length + 1;
@@ -517,11 +531,14 @@ export function parseGoFile(file, code) {
517
531
  if (braceIdx < 0) continue;
518
532
  const extracted = _extractBody(code, braceIdx);
519
533
  if (!extracted) continue;
520
- const startLine = _lineAt(code, m.index);
534
+ // FUNC_RE's leading boundary is a newline and its `\s*` then swallows every blank line before `func`, so m.index can sit
535
+ // several lines ABOVE the declaration (a blank line before a function is the gofmt norm). Every line below, the function's
536
+ // own and each statement's, is counted from here, so the keyword's offset is what must be measured.
537
+ const startLine = _lineAt(code, m.index + Math.max(0, m[0].indexOf('func')));
521
538
  const nodes = {};
522
539
  const entry = _addNode(nodes, { kind: 'entry', line: startLine });
523
540
  const exit = _addNode(nodes, { kind: 'exit', line: startLine });
524
- const tail = _buildCfg(extracted.body, nodes, entry, startLine + 1);
541
+ const tail = _buildCfg(extracted.body, nodes, entry, _lineAt(code, braceIdx + 1));
525
542
  _link(nodes, tail, exit);
526
543
  const cfg = { entry, exit, nodes };
527
544
  functions.push({
@@ -23,6 +23,7 @@
23
23
 
24
24
  import { blankComments } from '../sast/_comment-strip.js';
25
25
  import { callSitesFromCfg } from './call-sites.js';
26
+ import { IMPLICIT_SPRING_PARAM, isImplicitSpringMappedParam } from './implicit-handler-params.js';
26
27
 
27
28
  let _nodeIdSeq = 0;
28
29
  function nextNodeId() { return 'jn' + (++_nodeIdSeq); }
@@ -1082,6 +1083,11 @@ export async function parseJavaFile(file, raw) {
1082
1083
  || md.children?.methodDeclarator?.[0]?.children?.formalParameterList?.[0];
1083
1084
  const paramAnnotations = [];
1084
1085
  const paramTypes = {};
1086
+ // QA-006: annotation simple names on the METHOD itself (`@GetMapping`), read from its modifiers.
1087
+ const methodAnnotations = (md.children?.methodModifier || []).map((mm) => {
1088
+ const ids = mm.children?.annotation?.[0]?.children?.typeName?.[0]?.children?.Identifier;
1089
+ return ids?.length ? ids[ids.length - 1]?.image : null;
1090
+ }).filter(Boolean);
1085
1091
  const params = (fpl?.children?.formalParameter || []).map((fp, idx) => {
1086
1092
  // Regular parameters nest under `variableParaRegularParameter`.
1087
1093
  // Varargs (`String... args`) instead use a distinct
@@ -1119,6 +1125,7 @@ export async function parseJavaFile(file, raw) {
1119
1125
  // the first, or a stacked annotation is silently dropped (the
1120
1126
  // same class of bug Task 3's C# regex needed a fix round for).
1121
1127
  const variableModifiers = vp?.children?.variableModifier || [];
1128
+ const ownDecorators = [];
1122
1129
  for (const vm of variableModifiers) {
1123
1130
  const ann = vm.children?.annotation?.[0];
1124
1131
  // `typeName.children.Identifier` is an array of ALL
@@ -1133,9 +1140,13 @@ export async function parseJavaFile(file, raw) {
1133
1140
  const identifiers = ann?.children?.typeName?.[0]?.children?.Identifier;
1134
1141
  const decoratorName = identifiers?.length ? identifiers[identifiers.length - 1]?.image : undefined;
1135
1142
  if (decoratorName && paramName) {
1143
+ ownDecorators.push(decoratorName);
1136
1144
  paramAnnotations.push({ index: idx, name: paramName, decorator: decoratorName });
1137
1145
  }
1138
1146
  }
1147
+ if (paramName && isImplicitSpringMappedParam({ methodAnnotations, paramType, decorators: ownDecorators })) {
1148
+ paramAnnotations.push({ index: idx, name: paramName, decorator: IMPLICIT_SPRING_PARAM });
1149
+ }
1139
1150
  return paramName;
1140
1151
  }).filter(Boolean);
1141
1152
  const body = md.children?.methodBody?.[0]?.children?.block?.[0];