@clear-capabilities/agentic-security-scanner 0.162.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 (153) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/bin/agentic-security.js +87 -0
  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 +1 -1
  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 +1 -1
  15. package/dist/9560.index.js +2318 -23
  16. package/dist/957.index.js +1 -1
  17. package/dist/9999.index.js +43 -7
  18. package/dist/agentic-security.mjs +12 -12
  19. package/dist/agentic-security.mjs.sha256 +1 -1
  20. package/package.json +33 -6
  21. package/src/capabilities/CLAUDE.md +114 -0
  22. package/src/capabilities/address.js +73 -0
  23. package/src/capabilities/attack-coverage.js +93 -0
  24. package/src/capabilities/decide.js +256 -0
  25. package/src/capabilities/delegate.js +69 -0
  26. package/src/capabilities/egress.js +20 -0
  27. package/src/capabilities/hook-advice.js +50 -0
  28. package/src/capabilities/index.js +32 -0
  29. package/src/capabilities/manifest.js +344 -0
  30. package/src/capabilities/outbound.js +189 -0
  31. package/src/capabilities/paths.js +57 -0
  32. package/src/capabilities/probes.js +211 -0
  33. package/src/capabilities/proxy.js +182 -0
  34. package/src/capabilities/reasons.js +67 -0
  35. package/src/capabilities/receipts.js +356 -0
  36. package/src/capabilities/records.js +58 -0
  37. package/src/capabilities/recovery.js +367 -0
  38. package/src/capabilities/report.js +81 -0
  39. package/src/capabilities/runner.js +236 -0
  40. package/src/capabilities/secrets.js +26 -0
  41. package/src/capabilities/tool-gate.js +119 -0
  42. package/src/capabilities/tool-registry.js +60 -0
  43. package/src/dataflow/CLAUDE.md +29 -0
  44. package/src/dataflow/catalog.js +21 -0
  45. package/src/dataflow/engine.js +20 -0
  46. package/src/dataflow/guard-dominance.js +352 -0
  47. package/src/discovery/CLAUDE.md +9 -0
  48. package/src/discovery/index.js +15 -1
  49. package/src/discovery/lenses.js +18 -0
  50. package/src/engine.js +80 -15
  51. package/src/integrations/index.js +5 -2
  52. package/src/ir/CLAUDE.md +14 -0
  53. package/src/ir/implicit-handler-params.js +74 -0
  54. package/src/ir/parser-cs.js +20 -0
  55. package/src/ir/parser-go.js +30 -13
  56. package/src/ir/parser-java.js +11 -0
  57. package/src/language/nix-eval-isolation.js +33 -5
  58. package/src/lineage/CLAUDE.md +31 -0
  59. package/src/lineage/deployment/adapter-cloud.js +202 -0
  60. package/src/lineage/deployment/adapter-compose.js +90 -0
  61. package/src/lineage/deployment/adapter-kit.js +142 -0
  62. package/src/lineage/deployment/adapter-kubernetes.js +247 -0
  63. package/src/lineage/deployment/attack-paths.js +443 -0
  64. package/src/lineage/deployment/boundaries-run.js +223 -0
  65. package/src/lineage/deployment/boundary-graph.js +567 -0
  66. package/src/lineage/deployment/cross-repo.js +253 -0
  67. package/src/lineage/deployment/drift.js +383 -0
  68. package/src/lineage/deployment/ingest.js +221 -0
  69. package/src/lineage/deployment/projection.js +244 -0
  70. package/src/lineage/deployment/trace-correlation.js +541 -0
  71. package/src/lineage/source-registry.js +19 -0
  72. package/src/mcp/CLAUDE.md +5 -2
  73. package/src/mcp/invariant-tools.js +64 -0
  74. package/src/mcp/portfolio-tools.js +79 -0
  75. package/src/mcp/redact.js +15 -0
  76. package/src/mcp/server.js +24 -3
  77. package/src/mcp/tools.js +13 -1
  78. package/src/posture/CLAUDE.md +332 -1
  79. package/src/posture/accuracy-scorecard.js +95 -4
  80. package/src/posture/assurance/rollback.js +214 -0
  81. package/src/posture/assurance/rollout.js +200 -0
  82. package/src/posture/business-logic.js +26 -4
  83. package/src/posture/evaluation/bakeoff.js +148 -0
  84. package/src/posture/evaluation/custody.js +200 -0
  85. package/src/posture/evaluation/deployment-ablation.js +416 -0
  86. package/src/posture/evaluation/dev-cases.js +65 -0
  87. package/src/posture/evaluation/economics.js +85 -0
  88. package/src/posture/evaluation/gates.js +161 -0
  89. package/src/posture/evaluation/grouping.js +101 -0
  90. package/src/posture/evaluation/interval.js +132 -0
  91. package/src/posture/evaluation/invariant-ablation.js +325 -0
  92. package/src/posture/evaluation/labels.js +200 -0
  93. package/src/posture/evaluation/protocol.js +308 -0
  94. package/src/posture/evaluation/release-gate.js +209 -0
  95. package/src/posture/evaluation/report.js +158 -0
  96. package/src/posture/evaluation/runner.js +273 -0
  97. package/src/posture/evaluation/scan-child.js +80 -0
  98. package/src/posture/evaluation/score.js +174 -0
  99. package/src/posture/evaluation/synthetic.js +90 -0
  100. package/src/posture/evaluation/why-missed.js +219 -0
  101. package/src/posture/fleet.js +11 -2
  102. package/src/posture/invariants/cli.js +95 -0
  103. package/src/posture/invariants/coverage.js +164 -0
  104. package/src/posture/invariants/export.js +178 -0
  105. package/src/posture/invariants/expressions.js +46 -0
  106. package/src/posture/invariants/lifecycle.js +289 -0
  107. package/src/posture/invariants/project-input.js +55 -0
  108. package/src/posture/invariants/repair.js +340 -0
  109. package/src/posture/invariants/run.js +45 -0
  110. package/src/posture/invariants/scenarios.js +311 -0
  111. package/src/posture/invariants/schema.js +167 -0
  112. package/src/posture/invariants/shrink.js +407 -0
  113. package/src/posture/invariants/state-assertions.js +165 -0
  114. package/src/posture/logic-claims.js +12 -0
  115. package/src/posture/model-routing.js +40 -0
  116. package/src/posture/oracles/adapters.js +172 -1
  117. package/src/posture/oracles/conformance.js +3 -2
  118. package/src/posture/oracles/oracle.js +23 -3
  119. package/src/posture/oracles/registry.js +1 -1
  120. package/src/posture/portfolio/backend.js +279 -0
  121. package/src/posture/portfolio/bundle.js +314 -0
  122. package/src/posture/portfolio/cli.js +142 -0
  123. package/src/posture/portfolio/manifest.js +263 -0
  124. package/src/posture/portfolio/progress.js +267 -0
  125. package/src/posture/portfolio/resume.js +130 -0
  126. package/src/posture/portfolio/retention.js +207 -0
  127. package/src/posture/portfolio/scheduler.js +488 -0
  128. package/src/posture/portfolio/signing.js +155 -0
  129. package/src/posture/portfolio/wording.js +59 -0
  130. package/src/posture/portfolio/work-units.js +405 -0
  131. package/src/posture/replay/replay.js +1 -0
  132. package/src/posture/routing/adapter.js +140 -0
  133. package/src/posture/routing/calibration.js +278 -0
  134. package/src/posture/routing/canary.js +141 -0
  135. package/src/posture/routing/control.js +35 -0
  136. package/src/posture/routing/decide.js +211 -0
  137. package/src/posture/routing/drift.js +137 -0
  138. package/src/posture/routing/economics.js +185 -0
  139. package/src/posture/routing/feedback.js +207 -0
  140. package/src/posture/routing/index.js +31 -0
  141. package/src/posture/routing/outcomes.js +281 -0
  142. package/src/posture/routing/promotion.js +291 -0
  143. package/src/posture/routing/receipts.js +59 -0
  144. package/src/posture/routing/report.js +142 -0
  145. package/src/posture/routing/shadow.js +53 -0
  146. package/src/posture/specification-mining.js +26 -0
  147. package/src/posture/verification/patch-negative.js +16 -4
  148. package/src/posture/verification/patch-promotion.js +10 -2
  149. package/src/report/index.js +51 -1
  150. package/src/sandbox/CLAUDE.md +10 -0
  151. package/src/sandbox/backend-userspace.js +91 -13
  152. package/dist/5613.index.js +0 -500
  153. package/dist/6239.index.js +0 -469
@@ -0,0 +1,352 @@
1
+ // Does a guard actually DOMINATE the sink it is said to protect? (QA-005.AC02)
2
+ //
3
+ // `dropGuardedFindings` and the safe-sink-shape check recognise a guard by shape inside a window of source lines
4
+ // around the sink, then correlate on a shared identifier. That answers "is there guard-looking text near this sink",
5
+ // which is not the question that justifies dropping a finding: "is every path that reaches the sink forced through
6
+ // this guard". Text can be near a sink without protecting it:
7
+ // - a guard written AFTER the sink (the window reaches several lines below it);
8
+ // - a guard in a DIFFERENT function that happens to sit above;
9
+ // - a guard inside a block that has already closed (`if (dev) { check(x); } fetch(x)`);
10
+ // - an `if (allowed(x)) { log() }` whose failing branch does not stop anything, followed by an unconditional sink;
11
+ // - a membership test whose boolean result is computed and never branched on.
12
+ // This module decides that, over the same source lines, with no new dependency. It is deliberately conservative toward
13
+ // KEEPING a finding: when it cannot establish dominance it says so, and the caller leaves the finding in place. The
14
+ // cost of being wrong in that direction is a reviewable false positive; the other direction deletes a real defect.
15
+ //
16
+ // Pure: lines in, verdict out. It reads no file name beyond an extension, no label and no answer key, and works on text alone.
17
+ // It is NOT a control-flow graph: it recognises the structured shapes real handlers use (straight-line statements,
18
+ // early-exit checks, a sink nested in the guarded branch) and reports `unknown-shape` for anything else it cannot
19
+ // place, which counts as "not proven".
20
+
21
+ const TERMINATOR = /\b(?:return|throw|raise|continue|break|exit|abort|die|panic|halt|fail|next)\b|\b(?:sys\.exit|os\._exit|process\.exit|http\.Error|t\.Fatal|log\.Fatal)\b|\.\s*(?:status|sendStatus|abort|AbortWithStatus|AbortWithStatusJSON|Error)\s*\(/;
22
+ const CONDITIONAL_HEAD = /^\s*(?:\}\s*)?(?:else\s+)?(?:if|unless|elif|elsif|while|until|when|guard|switch)\b/;
23
+ const MODIFIER_EXIT = /^\s*(?:return|raise|throw|next|break|continue|abort|halt|fail|die)\b[^\n]*\b(?:if|unless)\b|\b(?:or|\|\|)\s+(?:raise|die|throw|return|abort|exit)\b/;
24
+ // A function or method DECLARATION line, across the languages this engine reads. A guard above one is in another function.
25
+ const NOT_A_DECL_HEAD = /^\s*(?:return|await|new|throw|else|yield|typeof|delete|case|if|for|while|switch|catch|try|do|elif|elsif|unless|until|when|guard|print|echo)\b/;
26
+ const MODIFIER = /^(?:public|private|protected|internal|static|final|abstract|override|async|export|default|virtual|sealed|extern|pub|suspend)\s+/;
27
+ const ANNOTATION = /^@\w+(?:\([^)]*\))?\s*/;
28
+ const DECL_START = /^(?:function\s*\*?\s*\w+\s*\(|def\s+\w+|fn\s+\w+|fun\s+\w+|func\s+(?:\([^)]*\)\s*)?\w+\s*\(|sub\s+\w+)/;
29
+ // A typed method header (`public String find(String q) throws E {`) or a bare class-method shorthand (`handle(req, res) {`): the head before the
30
+ // parenthesis is a few words, the opening brace ends the line. Written without nested quantifiers and capped in length, because these run per line.
31
+ const BRACE_METHOD = /^([^(){};=]{0,120}?)\(([^;)]{0,300})\)\s*(?:throws\s+[\w.,\s]{1,120})?\s*\{\s*$/;
32
+ const MAX_DECL_LINE = 400;
33
+ function isFunctionBoundary(line) {
34
+ if (line.length > MAX_DECL_LINE || NOT_A_DECL_HEAD.test(line)) return false;
35
+ // peel annotations and modifiers one at a time (a loop, not a repeated group)
36
+ let rest = line.trimStart();
37
+ for (let n = 0; n < 8; n++) { const m = ANNOTATION.exec(rest); if (!m) break; rest = rest.slice(m[0].length); }
38
+ for (let n = 0; n < 8; n++) { const m = MODIFIER.exec(rest); if (!m) break; rest = rest.slice(m[0].length); }
39
+ if (DECL_START.test(rest)) return true;
40
+ const m = BRACE_METHOD.exec(line);
41
+ if (!m) return false;
42
+ const words = m[1].trim().split(/\s+/).filter(Boolean);
43
+ // the last word is the method name; any earlier words are modifiers or a return type
44
+ return words.length >= 1 && words.length <= 6 && /^\w+$/.test(words[words.length - 1]) && !/^(?:if|for|while|switch|catch|else|function|return)$/.test(words[words.length - 1]);
45
+ }
46
+ // A pure DATA declaration: a name bound to a literal, a collection or a constructor (`const ALLOWED = new Set([...])`, `DENY = ["a"]`,
47
+ // `static final Set<String> ALLOW = Set.of(...)`, `var allowed = map[string]bool{...}`). It defines the allow-list; it does not CHECK anything
48
+ // against it, so it cannot guard a sink however guard-shaped its name is.
49
+ const DATA_DECL = /^\s*(?:(?:export|public|private|protected|internal|static|final|readonly|const|let|var|val|def|pub|mut)\s+)*(?:[\w$.<>\[\],?:&* ]+?\s+)?[A-Za-z_$][\w$]*\s*(?::[^=\n]+)?(?::=|=)\s*(?:new\b|\[|\{|%[wi]|Set\.of|List\.of|Map\.of|Arrays\.asList|listOf|setOf|mapOf|hashSetOf|set\(|frozenset\(|list\(|dict\(|tuple\(|vec!|HashSet|map\[|[A-Z]\w*\{|["'`])(?!.*(?:=>|->|\bfunction\b))/;
50
+ // An assignment whose value is a membership/prefix/equality TEST: a boolean, which protects nothing until something branches on it.
51
+ const BOOLEAN_ASSIGN = /^\s*(?:(?:const|let|var|val|final|bool|boolean|auto)\s+)?([A-Za-z_$][\w$]*)\s*(?::=|=)\s*(?!.*\b(?:basename|abspath|realpath|resolve|normalize|secure_filename|safe_join|sanitize\w*|escape\w*)\b)[^=\n]*(?:\.\s*(?:has|includes|contains|startsWith|startswith|StartsWith|endsWith|HasPrefix|starts_with|match|test|matches)\s*\(|\bin\s|===|!==|==|!=)/;
52
+
53
+ /** Source line with string literals and trailing line comments blanked, so brace counting is not fooled by either. */
54
+ function code(line) {
55
+ return String(line ?? '')
56
+ .replace(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`/g, '""')
57
+ .replace(/\/\/.*$/, '')
58
+ .replace(/(^|\s)#.*$/, '$1');
59
+ }
60
+
61
+ const indentOf = (line) => { const m = /^[ \t]*/.exec(line)[0]; return m.replace(/\t/g, ' ').length; };
62
+ const countBraces = (s) => ({ open: (s.match(/\{/g) || []).length, close: (s.match(/\}/g) || []).length });
63
+ const blank = (s) => !/\S/.test(s);
64
+
65
+ // Block style of the file: indentation (Python, Ruby) or braces. The extension decides when known; otherwise the whole file votes, never just
66
+ // the few lines between guard and sink (two brace-free lines of JavaScript are not an indentation language).
67
+ function looksIndented(lines, file) {
68
+ if (/\.(?:py|pyi|rb|rake|ru)$/i.test(file || '')) return true;
69
+ if (/\.[A-Za-z0-9]+$/.test(file || '')) return false;
70
+ let brace = 0; let colon = 0;
71
+ for (const l of lines) {
72
+ const c = code(l);
73
+ if (/[{}]\s*$/.test(c)) brace++;
74
+ else if (/^\s*(?:def|if|elif|else|for|while|class|try|except|finally|with)\b.*:\s*$/.test(c)) colon++;
75
+ }
76
+ return colon > brace;
77
+ }
78
+
79
+ function result(dominates, reason) { return { dominates, reason }; }
80
+
81
+ /**
82
+ * @param {object} o
83
+ * @param {string[]} o.lines the file's source lines (original text)
84
+ * @param {number} o.guardIdx 0-based index of the line the guard-shaped text matched on
85
+ * @param {number} o.sinkIdx 0-based index of the sink line
86
+ * @param {string} [o.file] path, used ONLY to pick the block style (indentation vs braces) from its extension
87
+ * @returns {{dominates: boolean, reason: string}}
88
+ */
89
+ export function guardDominatesSink({ lines, guardIdx, sinkIdx, file = null, depth = 0 }) {
90
+ if (!Array.isArray(lines) || !Number.isInteger(guardIdx) || !Number.isInteger(sinkIdx)) return result(false, 'unknown-shape');
91
+ if (guardIdx < 0 || sinkIdx < 0 || guardIdx >= lines.length || sinkIdx >= lines.length) return result(false, 'unknown-shape');
92
+ if (guardIdx > sinkIdx) return result(false, 'guard-after-sink');
93
+
94
+ const indentStyle = looksIndented(lines, file);
95
+ // Which named function holds the guard? A guard in a function that has ENDED before the sink protects the sink only through a call to
96
+ // that function (a validator helper); a guard at module level does not reach into a function declared after it.
97
+ const fn = enclosingFunction(lines, guardIdx, indentStyle);
98
+ if (fn && sinkIdx > fn.endIdx) return calleeGuardDominates({ lines, guardIdx, sinkIdx, fn, file, depth });
99
+ if (!fn) {
100
+ for (let i = guardIdx + 1; i <= sinkIdx; i++) if (isFunctionBoundary(code(lines[i]))) return result(false, 'different-function');
101
+ }
102
+ const gl = code(lines[guardIdx]);
103
+
104
+ // Same line: `if (!ok(x)) return; fetch(x)` or `x = safe(x); use(x)`. Order within the line is not tracked, but a guard that
105
+ // shares the sink's line is positioned by the author as part of the same statement run.
106
+ if (guardIdx === sinkIdx) return result(true, 'same-line');
107
+
108
+ // A line that only DEFINES the allow-list is not a check.
109
+ if (DATA_DECL.test(gl) && !/\.\s*(?:has|includes|contains|startsWith|startswith|StartsWith)\s*\(/.test(gl)) return result(false, 'data-declaration-not-a-check');
110
+
111
+ // A membership/equality result that is computed and never branched on protects nothing.
112
+ const boolAssign = BOOLEAN_ASSIGN.exec(gl);
113
+ if (boolAssign && !CONDITIONAL_HEAD.test(gl)) {
114
+ const v = boolAssign[1].replace(/[$]/g, '\\$');
115
+ const used = new RegExp(`(?:\\b(?:if|unless|while|assert|require|ensure|guard)\\b|&&|\\|\\||\\?|\\bnot\\b|!)[^\\n]*\\b${v}\\b|\\b${v}\\b[^\\n]*(?:&&|\\|\\||\\?)`);
116
+ let branched = false;
117
+ for (let i = guardIdx + 1; i <= sinkIdx && !branched; i++) branched = used.test(code(lines[i]));
118
+ if (!branched) return result(false, 'result-never-branched-on');
119
+ }
120
+
121
+ if (indentStyle) return indentDominates(lines, guardIdx, sinkIdx, gl);
122
+ return braceDominates(lines, guardIdx, sinkIdx, gl);
123
+ }
124
+
125
+ function declaredName(declLine) {
126
+ const c = code(declLine);
127
+ const kw = /(?:function\s*\*?\s*|def\s+|fn\s+|fun\s+|func\s+(?:\([^)]*\)\s*)?|sub\s+)([A-Za-z_$][\w$]*)/.exec(c);
128
+ if (kw) return kw[1];
129
+ const typed = /([A-Za-z_$][\w$]*)\s*\(/.exec(c);
130
+ return typed ? typed[1] : null;
131
+ }
132
+
133
+ /** The named function that contains line `idx`, as `{ declIdx, endIdx }`, or null at module level. */
134
+ function enclosingFunction(lines, idx, indentStyle) {
135
+ if (indentStyle) return enclosingFunctionByIndent(lines, idx, indentOf(lines[idx]));
136
+ let need = 0;
137
+ for (let i = idx - 1; i >= 0; i--) {
138
+ const c = code(lines[i]);
139
+ for (let k = c.length - 1; k >= 0; k--) {
140
+ if (c[k] === '}') need++;
141
+ else if (c[k] === '{') {
142
+ if (need > 0) { need--; continue; }
143
+ const head = isFunctionBoundary(c) ? i : (/^\s*\{\s*$/.test(c) && i > 0 && isFunctionBoundary(code(lines[i - 1])) ? i - 1 : -1);
144
+ if (head >= 0) {
145
+ let depth = 0; let end = lines.length - 1;
146
+ scan: for (let m = i; m < lines.length; m++) {
147
+ const t = code(lines[m]);
148
+ for (const ch of t) { if (ch === '{') depth++; else if (ch === '}') { depth--; if (depth === 0) { end = m; break scan; } } }
149
+ }
150
+ return { declIdx: head, endIdx: end };
151
+ }
152
+ }
153
+ }
154
+ }
155
+ return null;
156
+ }
157
+
158
+ function enclosingFunctionByIndent(lines, idx, ind) {
159
+ for (let i = idx - 1; i >= 0; i--) {
160
+ if (blank(lines[i])) continue;
161
+ const cur = indentOf(lines[i]);
162
+ if (cur >= ind) continue;
163
+ if (isFunctionBoundary(code(lines[i]))) {
164
+ let end = i;
165
+ for (let k = i + 1; k < lines.length; k++) { if (blank(lines[k])) continue; if (indentOf(lines[k]) <= cur) break; end = k; }
166
+ return { declIdx: i, endIdx: end };
167
+ }
168
+ ind = cur;
169
+ }
170
+ return null;
171
+ }
172
+
173
+ /** The guard is in a helper function that ended before the sink. It protects the sink only if the sink's code calls that helper first and the guard cannot be skipped inside it. */
174
+ function calleeGuardDominates({ lines, guardIdx, sinkIdx, fn, file, depth }) {
175
+ if (depth >= 3) return result(false, 'call-chain-too-deep');
176
+ const name = declaredName(lines[fn.declIdx]);
177
+ if (!name) return result(false, 'different-function');
178
+ const call = new RegExp(`\\b${name.replace(/[$]/g, '\\$')}\\s*\\(`);
179
+ let callIdx = -1;
180
+ for (let i = fn.endIdx + 1; i <= sinkIdx; i++) { if (call.test(code(lines[i])) && !isFunctionBoundary(code(lines[i]))) { callIdx = i; break; } }
181
+ if (callIdx < 0) return result(false, 'different-function');
182
+ // 1. inside the helper the guard must be on every path to the helper's exit: at the function's own level, and either a straight-line
183
+ // statement or a check whose failing branch leaves. (The helper's exit is not a "sink", so this is judged on its own.)
184
+ const forced = guardForcedBeforeExit(lines, guardIdx, fn, looksIndented(lines, file));
185
+ if (!forced.ok) return result(false, `callee-guard-not-forced:${forced.reason}`);
186
+ // 2. in the sink's code the call must itself run before the sink
187
+ const outside = guardDominatesSink({ lines, guardIdx: callIdx, sinkIdx, file, depth: depth + 1 });
188
+ if (!outside.dominates) return result(false, `callee-not-forced-before-sink:${outside.reason}`);
189
+ return result(true, 'validator-helper-called-before-sink');
190
+ }
191
+
192
+ /** Is the guard on every path to the end of its own function? */
193
+ function guardForcedBeforeExit(lines, guardIdx, fn, indentStyle) {
194
+ const gl = code(lines[guardIdx]);
195
+ if (DATA_DECL.test(gl)) return { ok: false, reason: 'data-declaration-not-a-check' };
196
+ // The guard must sit at the function body's own nesting level, not inside an `if`, loop or `try` of its own.
197
+ if (indentStyle) {
198
+ let bodyIndent = -1;
199
+ for (let i = fn.declIdx + 1; i <= fn.endIdx; i++) if (!blank(lines[i])) { bodyIndent = indentOf(lines[i]); break; }
200
+ if (indentOf(lines[guardIdx]) !== bodyIndent) return { ok: false, reason: 'guard-nested-in-helper' };
201
+ } else {
202
+ let depth = 0; let opened = false;
203
+ for (let i = fn.declIdx; i < guardIdx; i++) {
204
+ for (const ch of code(lines[i])) { if (ch === '{') { depth++; opened = true; } else if (ch === '}') depth--; }
205
+ }
206
+ if (!opened || depth !== 1) return { ok: false, reason: 'guard-nested-in-helper' };
207
+ }
208
+ const conditional = CONDITIONAL_HEAD.test(gl);
209
+ if (!conditional) return { ok: true, reason: 'straight-line-in-helper' };
210
+ if (branchExits(lines, guardIdx, indentStyle)) return { ok: true, reason: 'failing-branch-exits' };
211
+ return { ok: false, reason: 'failing-branch-does-not-exit' };
212
+ }
213
+
214
+ const LITERAL_ASSIGN = /^\s*(?:[\w$.]+\s+)?([A-Za-z_$][\w$]*(?:\.[\w$]+)*)\s*=\s*(?:'[^']*'|"[^"]*"|`[^`]*`|""|null|None|nil|undefined|false|0)\s*;?\s*$/;
215
+ const identsOf = (text) => new Set((String(text).match(/[A-Za-z_$][\w$]*/g) || []).map((x) => x.replace(/^\$/, '')));
216
+
217
+ /**
218
+ * Does the failing branch REPLACE the value the condition checked with a literal (`if (bad($next)) { $next = "/"; }`)? Then no path reaches
219
+ * the sink carrying the unchecked value, which is what a guard is for. The assigned name must be one the condition names.
220
+ */
221
+ function branchNeutralizes(lines, guardIdx, indentStyle) {
222
+ const gl = code(lines[guardIdx]);
223
+ const checked = identsOf(gl.replace(/^\s*(?:\}\s*)?(?:else\s+)?(?:if|unless|elif|elsif|while|until|when|guard)\b/, ''));
224
+ const bodyIdx = [];
225
+ if (indentStyle) {
226
+ const gi = indentOf(lines[guardIdx]);
227
+ for (let i = guardIdx + 1; i < lines.length; i++) { if (blank(lines[i])) continue; if (indentOf(lines[i]) <= gi) break; bodyIdx.push(i); }
228
+ } else {
229
+ const block = branchRange(lines, guardIdx);
230
+ if (block) for (let i = block.start; i <= block.end; i++) bodyIdx.push(i);
231
+ }
232
+ for (const i of bodyIdx) {
233
+ const stmt = code(lines[i]).replace(/^[^{]*\{/, '').replace(/\}\s*$/, '');
234
+ for (const part of stmt.split(';')) {
235
+ const m = LITERAL_ASSIGN.exec(part.trim());
236
+ if (m && checked.has(m[1].replace(/^\$/, '').split('.')[0])) return true;
237
+ }
238
+ }
239
+ return false;
240
+ }
241
+
242
+ /** Does the branch a conditional guard opens leave the function (throw, return, exit, abort)? Polarity is not judged. */
243
+ function branchExits(lines, guardIdx, indentStyle) {
244
+ if (branchNeutralizes(lines, guardIdx, indentStyle)) return true;
245
+ const gl = code(lines[guardIdx]);
246
+ if (indentStyle) {
247
+ if (MODIFIER_EXIT.test(gl)) return true;
248
+ const gi = indentOf(lines[guardIdx]);
249
+ if (/:\s*\S/.test(gl) && TERMINATOR.test(gl.slice(gl.lastIndexOf(':') + 1))) return true;
250
+ const body = [];
251
+ for (let i = guardIdx + 1; i < lines.length; i++) { if (blank(lines[i])) continue; if (indentOf(lines[i]) <= gi) break; body.push(i); }
252
+ const inner = body.length ? indentOf(lines[body[0]]) : -1;
253
+ return body.some((i) => indentOf(lines[i]) === inner && TERMINATOR.test(code(lines[i])));
254
+ }
255
+ const block = branchRange(lines, guardIdx);
256
+ const afterCond = gl.replace(/^\s*(?:\}\s*)?(?:else\s+)?(?:if|unless|elif|elsif|while|until|when|guard)\b/, '');
257
+ if (!block || block.start === guardIdx) {
258
+ if (TERMINATOR.test(afterCond.replace(/^\s*\([^)]*\)/, '')) || /\breturn\b|\bthrow\b/.test(afterCond)) return true;
259
+ }
260
+ return !!(block && branchTerminates(lines, block));
261
+ }
262
+
263
+ function braceDominates(lines, guardIdx, sinkIdx, gl) {
264
+ // Depth at the START of each line, relative to the guard line's own start.
265
+ const depthStart = new Array(sinkIdx - guardIdx + 2).fill(0);
266
+ let depth = 0;
267
+ for (let i = guardIdx; i <= sinkIdx; i++) {
268
+ depthStart[i - guardIdx] = depth;
269
+ const c = code(lines[i]);
270
+ const { open, close } = countBraces(c);
271
+ depth += open - close;
272
+ }
273
+ // The guard's enclosing block must still be open at the sink: nothing between may dip below the guard's own level.
274
+ for (let i = guardIdx + 1; i <= sinkIdx; i++) {
275
+ if (depthStart[i - guardIdx] < 0) return result(false, 'guard-block-closed-before-sink');
276
+ }
277
+
278
+ if (!CONDITIONAL_HEAD.test(gl) && !/\?[^:]*:/.test(gl)) return result(true, 'straight-line-before-sink');
279
+ if (/\?[^:]*:/.test(gl) && !CONDITIONAL_HEAD.test(gl)) return result(false, 'unknown-shape');
280
+
281
+ // A conditional guard. Case 1: the sink sits INSIDE the branch the condition opens.
282
+ const block = branchRange(lines, guardIdx);
283
+ if (block && sinkIdx > block.start && sinkIdx <= block.end) return result(true, 'sink-inside-guarded-branch');
284
+ // Case 2: the condition guards an early exit and the sink comes after it.
285
+ const afterCond = gl.replace(/^\s*(?:\}\s*)?(?:else\s+)?(?:if|unless|elif|elsif|while|until|when|guard)\b/, '');
286
+ if (!block || block.start === guardIdx) {
287
+ // Single-line form: `if (!allowed(h)) return;` : an exit on the same line, after the condition.
288
+ if (TERMINATOR.test(afterCond.replace(/^\s*\([^)]*\)/, '')) || /\breturn\b|\bthrow\b/.test(afterCond)) return result(true, 'early-exit-on-guard-line');
289
+ }
290
+ if (block && branchTerminates(lines, block)) return result(true, 'failing-branch-exits');
291
+ if (branchNeutralizes(lines, guardIdx, false)) return result(true, 'failing-branch-neutralizes-value');
292
+ return result(false, 'failing-branch-does-not-exit');
293
+ }
294
+
295
+ /** [start, end] line indices of the `{ ... }` block opened at or after `idx`, or null. */
296
+ function branchRange(lines, idx) {
297
+ let depth = 0; let start = -1;
298
+ for (let i = idx; i < Math.min(lines.length, idx + 40); i++) {
299
+ const c = code(lines[i]);
300
+ for (const ch of c) {
301
+ if (ch === '{') { if (start < 0) start = i; depth++; }
302
+ else if (ch === '}') { depth--; if (start >= 0 && depth === 0) return { start, end: i }; }
303
+ }
304
+ // A brace-less single statement ends the search at the next line that is not part of the condition.
305
+ if (start < 0 && i > idx && !/[(&|,]\s*$/.test(code(lines[i - 1]))) return null;
306
+ }
307
+ return null;
308
+ }
309
+
310
+ function branchTerminates(lines, block) {
311
+ let depth = 0;
312
+ for (let i = block.start; i <= block.end; i++) {
313
+ const c = code(lines[i]);
314
+ // Only a statement at the branch's own top level counts: a `return` buried in a nested `if` inside it does not stop the branch.
315
+ const startDepth = depth;
316
+ depth += countBraces(c).open - countBraces(c).close;
317
+ const topLevel = i === block.start ? true : startDepth === 1;
318
+ if (topLevel && TERMINATOR.test(i === block.start ? c.slice(c.indexOf('{') + 1) : c)) return true;
319
+ }
320
+ return false;
321
+ }
322
+
323
+ function indentDominates(lines, guardIdx, sinkIdx, gl) {
324
+ const gi = indentOf(lines[guardIdx]);
325
+ // The guard's block must still enclose the sink: no non-blank line between may be LESS indented than the guard.
326
+ for (let i = guardIdx + 1; i <= sinkIdx; i++) {
327
+ if (blank(lines[i])) continue;
328
+ if (indentOf(lines[i]) < gi) return result(false, 'guard-block-closed-before-sink');
329
+ }
330
+ if (MODIFIER_EXIT.test(gl)) return result(true, 'early-exit-on-guard-line');
331
+ const head = CONDITIONAL_HEAD.test(gl) || /\bif\b|\bunless\b/.test(gl) && /:\s*$/.test(gl);
332
+ if (!head) return result(true, 'straight-line-before-sink');
333
+
334
+ // A conditional: collect the indented block that follows the header.
335
+ const bodyLines = [];
336
+ for (let i = guardIdx + 1; i < lines.length; i++) {
337
+ if (blank(lines[i])) continue;
338
+ if (indentOf(lines[i]) <= gi) break;
339
+ bodyLines.push(i);
340
+ }
341
+ if (bodyLines.includes(sinkIdx) || (bodyLines.length && sinkIdx > bodyLines[0] && sinkIdx <= bodyLines[bodyLines.length - 1])) {
342
+ return result(true, 'sink-inside-guarded-branch');
343
+ }
344
+ // One-liner `if not ok(x): raise ...`.
345
+ const afterColon = gl.slice(gl.lastIndexOf(':') + 1);
346
+ if (/:\s*\S/.test(gl) && TERMINATOR.test(afterColon)) return result(true, 'early-exit-on-guard-line');
347
+ // Direct statements of the block (one indent level in) that exit.
348
+ const inner = bodyLines.length ? indentOf(lines[bodyLines[0]]) : -1;
349
+ if (bodyLines.some((i) => indentOf(lines[i]) === inner && TERMINATOR.test(code(lines[i])))) return result(true, 'failing-branch-exits');
350
+ if (branchNeutralizes(lines, guardIdx, true)) return result(true, 'failing-branch-neutralizes-value');
351
+ return result(false, 'failing-branch-does-not-exit');
352
+ }
@@ -53,6 +53,15 @@ panel · `judge.js` shapes findings and dedupes against the prior scan ·
53
53
  one area), merged areas are capped, and hunter prompts for Haskell and Nix are redacted (split literals joined first) before they
54
54
  leave the process. `hunt` includes `.hs`, `.lhs` and `.nix`. With no endpoint every run is degraded and says so.
55
55
 
56
+ ## Invariant proposals (X-402)
57
+
58
+ `lenses.js` `invariantProposalFor(candidate)` maps a candidate from a state-oriented lens (`authz`, `business-logic`, and
59
+ `feature-abuse` when it is about tenants) to a PROPOSED invariant skeleton (`posture/invariants/lifecycle.js`): authored by the
60
+ model, with the candidate as source evidence and a high uncertainty. `runDiscovery` reports them as `invariantProposals` only when
61
+ `opts.invariantProposals = { config }` is passed AND the `invariant-scenarios` feature is enabled in that config; nothing else in the
62
+ report changes. A proposal is a hypothesis: it can be run as a scenario and its violations are reported as candidates, but only a
63
+ human reviewer's signed approval in the invariant ledger makes it a requirement, so a hunter never establishes its own ground truth.
64
+
56
65
  ## Advisory/gating separation (X-207)
57
66
 
58
67
  Hunt output stays advisory in code, not just by convention. `memory.js` writes only through
@@ -8,7 +8,7 @@
8
8
  // pass that half failed and reports "no findings" is indistinguishable from a
9
9
  // clean codebase unless it says so.
10
10
  import { partitionCallGraph, partitionNixFiles } from './partition.js';
11
- import { LENSES, lensByKey } from './lenses.js';
11
+ import { LENSES, lensByKey, invariantProposalFor } from './lenses.js';
12
12
  import { runHunter } from './hunter.js';
13
13
  import { confirmAll } from './confirm.js';
14
14
  import { disprovePanel } from './disprove.js';
@@ -16,6 +16,7 @@ import { judgeCandidates } from './judge.js';
16
16
  import { loadMemory, saveMemory, rememberRun, previouslyRefuted, nextWavePlan } from './memory.js';
17
17
  import { emitVerification, headCommit } from '../posture/verification/emit.js';
18
18
  import { promoteHypothesis } from '../posture/verification/hypothesis-promotion.js';
19
+ import { featureStatus } from '../posture/assurance/config.js';
19
20
 
20
21
  // Bridge a candidate to the deterministic layer. A taint finding at or within
21
22
  // two lines of the candidate corroborates it; a modelled sink on the line
@@ -295,6 +296,18 @@ export async function runDiscovery(ctx = {}, opts = {}) {
295
296
  if (e.ok) verificationRecords.push(e.record);
296
297
  }
297
298
 
299
+ // X-402: opt-in invariant proposals. A candidate from a state-oriented lens can suggest a contract; the suggestion is advisory,
300
+ // `proposed`, authored by the model and never approved here. Off unless `opts.invariantProposals = { config }` and the
301
+ // `invariant-scenarios` feature is enabled in that config.
302
+ let invariantProposals = null;
303
+ if (opts.invariantProposals && featureStatus(opts.invariantProposals.config, 'invariant-scenarios').status === 'ok') {
304
+ invariantProposals = [];
305
+ for (const f of fresh) {
306
+ const inv = invariantProposalFor({ lens: f.discovery?.lens, title: f.vuln, description: f.description, file: f.file, line: f.line });
307
+ if (inv) invariantProposals.push({ hypothesisId: f.stableId, invariant: inv });
308
+ }
309
+ }
310
+
298
311
  // X-207: opt-in promotion. A hypothesis crosses into a finding ONLY through `promoteHypothesis` (trusted verifier receipt,
299
312
  // explicit policy, audit record). `fresh` is unchanged; promoted findings and every decision's audit record are reported
300
313
  // separately, and nothing here writes `last-scan.json`. `opts.promote = { policy, verify(hypothesis) -> runOracle result }`.
@@ -316,6 +329,7 @@ export async function runDiscovery(ctx = {}, opts = {}) {
316
329
  fresh,
317
330
  verificationRecords,
318
331
  ...(promotions ? { promotions } : {}),
332
+ ...(invariantProposals ? { invariantProposals } : {}),
319
333
  duplicates,
320
334
  suppressed,
321
335
  // `refutedCandidates` holds RAW candidates straight from `disprovePanel`,
@@ -8,6 +8,7 @@
8
8
  // no single prompt reaches. `wildcard` exists because a fixed taxonomy is a
9
9
  // ceiling, and the classes worth finding are the ones not on the list.
10
10
  import { redactPayload } from '../egress/redact.js';
11
+ import { inferredInvariant } from '../posture/invariants/lifecycle.js';
11
12
  export const LENSES = Object.freeze([
12
13
  { key: 'injection', title: 'Injection', family: 'injection', cwe: 'CWE-74',
13
14
  brief: 'Untrusted input reaching an interpreter: SQL, shell, template, XPath, LDAP, or deserialization. Follow the value, not the function name.' },
@@ -25,6 +26,23 @@ export const LENSES = Object.freeze([
25
26
  brief: 'Anything the other lenses do not cover. Prefer the surprising and specific over the generic; report nothing rather than something already obvious.' },
26
27
  ]);
27
28
 
29
+ // X-402: the lenses that reason about who may do what to whose data can propose an INVARIANT for a candidate they raise. The
30
+ // proposal is a skeleton contract authored by the model (`proposed`, with the candidate as source evidence and a high uncertainty),
31
+ // advisory until a reviewer approves it in the signed ledger. A lens whose candidates are not about state (injection, crypto) has
32
+ // no mapping and proposes nothing.
33
+ const TENANT_HINT = /tenant|idor|ownership|object reference|cross[- ]?(?:account|user|org)|leak|another user|other user/i;
34
+ const WORKFLOW_HINT = /refund|order|status|workflow|state machine|out[- ]of[- ]order|step|approval|checkout|paid|ship/i;
35
+
36
+ export function invariantProposalFor(candidate) {
37
+ const text = `${candidate?.title || ''} ${candidate?.description || ''} ${candidate?.rationale || ''}`;
38
+ let cls = null;
39
+ if (candidate?.lens === 'authz') cls = TENANT_HINT.test(text) ? 'tenant-isolation' : 'privilege-constraint';
40
+ else if (candidate?.lens === 'feature-abuse' && TENANT_HINT.test(text)) cls = 'tenant-isolation';
41
+ else if (candidate?.lens === 'business-logic' && WORKFLOW_HINT.test(text)) cls = 'workflow-order';
42
+ if (!cls || typeof candidate.file !== 'string') return null;
43
+ return inferredInvariant({ class: cls, source: 'discovery-lens', evidence: { file: candidate.file, line: Number.isInteger(candidate.line) ? candidate.line : null, lens: candidate.lens }, uncertainty: 0.85 });
44
+ }
45
+
28
46
  export function lensByKey(key) {
29
47
  if (typeof key !== 'string') return null;
30
48
  return LENSES.find(l => l.key === key) || null;
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.