@clear-capabilities/agentic-security-scanner 0.184.0 → 0.186.1

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 (228) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/bin/agentic-security.js +111 -1
  3. package/dist/1128.index.js +912 -0
  4. package/dist/1665.index.js +828 -0
  5. package/dist/2584.index.js +1 -1
  6. package/dist/4171.index.js +2 -2
  7. package/dist/5752.index.js +466 -0
  8. package/dist/7405.index.js +1 -1
  9. package/dist/8158.index.js +1713 -0
  10. package/dist/8798.index.js +1 -1
  11. package/dist/9382.index.js +2 -2
  12. package/dist/9579.index.js +1 -1
  13. package/dist/agentic-security.mjs +29 -29
  14. package/dist/agentic-security.mjs.sha256 +1 -1
  15. package/dist/{dad58d1113a12c23a142.json → ca62ee93079be4c24eee.json} +29 -12
  16. package/dist/frontend/src/shell.js +12 -2
  17. package/package.json +29 -12
  18. package/src/dataflow/CLAUDE.md +46 -1
  19. package/src/dataflow/access-paths.js +12 -2
  20. package/src/dataflow/backward.js +42 -7
  21. package/src/dataflow/cross-service-taint.js +83 -1
  22. package/src/dataflow/demand-query.js +1168 -0
  23. package/src/dataflow/dep-fingerprint.js +718 -0
  24. package/src/dataflow/engine.js +48 -6
  25. package/src/dataflow/exploit-prover.js +6 -2
  26. package/src/dataflow/ifds-precise.js +22 -0
  27. package/src/dataflow/incremental-replay.js +118 -0
  28. package/src/dataflow/incremental-session.js +257 -0
  29. package/src/dataflow/incremental.js +237 -35
  30. package/src/dataflow/index.js +25 -94
  31. package/src/dataflow/ir-cache.js +268 -0
  32. package/src/dataflow/privacy-taint.js +7 -0
  33. package/src/dataflow/summaries.js +72 -6
  34. package/src/dataflow/tabulation.js +80 -24
  35. package/src/engine.js +113 -8
  36. package/src/graph/CLAUDE.md +31 -0
  37. package/src/graph/adapters/artifact.js +81 -0
  38. package/src/graph/adapters/crossrepo.js +75 -0
  39. package/src/graph/adapters/dataflow.js +76 -0
  40. package/src/graph/adapters/provenance.js +92 -0
  41. package/src/graph/adapters/semantic.js +89 -0
  42. package/src/graph/crosswalk-builders.js +152 -0
  43. package/src/graph/crosswalk.js +146 -0
  44. package/src/graph/index.js +15 -0
  45. package/src/graph/observation.js +57 -0
  46. package/src/graph/refs.js +105 -0
  47. package/src/graph/registry.js +129 -0
  48. package/src/graph/relations.js +81 -0
  49. package/src/graph/scc.js +241 -0
  50. package/src/graph/view.js +254 -0
  51. package/src/ir/CLAUDE.md +4 -0
  52. package/src/ir/callgraph.js +36 -8
  53. package/src/ir/index.js +18 -4
  54. package/src/ir/parser-js.js +10 -0
  55. package/src/language/CLAUDE.md +13 -0
  56. package/src/language/nix-option-types.js +170 -0
  57. package/src/language/nix-script-taint.js +46 -3
  58. package/src/language/nix-secrets.js +20 -2
  59. package/src/lineage/CLAUDE.md +72 -8
  60. package/src/lineage/DESIGN_GRAPH_BUILDER.md +1 -1
  61. package/src/lineage/DESIGN_PATH_PROVENANCE.md +6 -0
  62. package/src/lineage/boundary-contract.js +273 -0
  63. package/src/lineage/completion.js +205 -0
  64. package/src/lineage/continuation.js +140 -0
  65. package/src/lineage/control-credit.js +313 -0
  66. package/src/lineage/correlation.js +199 -0
  67. package/src/lineage/coverage.js +8 -1
  68. package/src/lineage/cross-repo-link.js +22 -0
  69. package/src/lineage/cut-planner.js +548 -0
  70. package/src/lineage/driver.js +219 -66
  71. package/src/lineage/engine.js +166 -21
  72. package/src/lineage/export-json.js +5 -0
  73. package/src/lineage/graph-builder.js +84 -7
  74. package/src/lineage/haskell-view.js +59 -0
  75. package/src/lineage/index.js +3 -0
  76. package/src/lineage/nix-view.js +35 -0
  77. package/src/lineage/owned-build.js +77 -0
  78. package/src/lineage/owned-worker-entry.js +22 -0
  79. package/src/lineage/owned-worker.js +95 -0
  80. package/src/lineage/path-evidence.js +123 -0
  81. package/src/lineage/path-predicates.js +529 -0
  82. package/src/lineage/path-query.js +189 -20
  83. package/src/lineage/path-store.js +191 -17
  84. package/src/lineage/scc-solver.js +186 -0
  85. package/src/lineage/smt-orchestrator.js +174 -0
  86. package/src/lineage/smt-z3-backend.js +294 -0
  87. package/src/lineage/source-seeding.js +36 -2
  88. package/src/lineage/summaries.js +66 -4
  89. package/src/lineage/value-versions.js +130 -0
  90. package/src/lineage/witness-selection.js +95 -0
  91. package/src/pipeline/scan-health.js +18 -0
  92. package/src/posture/CLAUDE.md +17 -0
  93. package/src/posture/business-logic.js +16 -7
  94. package/src/posture/dag-commands.js +442 -0
  95. package/src/posture/evidence-bundle.js +1 -1
  96. package/src/posture/finding-defaults.js +19 -0
  97. package/src/posture/git-history.js +91 -1
  98. package/src/posture/witness-bundle.js +573 -0
  99. package/src/posture/witness-runtime.js +276 -0
  100. package/src/report/index.js +2 -0
  101. package/src/sast/CLAUDE.md +2 -0
  102. package/src/sast/_call-args.js +70 -0
  103. package/src/sast/init-scope.js +87 -0
  104. package/src/sast/listen-bind-address.js +49 -0
  105. package/src/sast/redirect-target.js +106 -0
  106. package/src/sast/reflected-input.js +76 -0
  107. package/src/sast/send-file-root.js +27 -0
  108. package/src/sast/sensitive-data-logging.js +38 -1
  109. package/dist/119.index.js +0 -197
  110. package/dist/128.index.js +0 -129
  111. package/dist/1293.index.js +0 -5689
  112. package/dist/1336.index.js +0 -16182
  113. package/dist/1351.index.js +0 -133
  114. package/dist/1387.index.js +0 -271
  115. package/dist/1437.index.js +0 -23
  116. package/dist/1445.index.js +0 -110
  117. package/dist/1505.index.js +0 -151
  118. package/dist/1557.index.js +0 -293
  119. package/dist/1621.index.js +0 -311
  120. package/dist/1751.index.js +0 -160
  121. package/dist/1765.index.js +0 -727
  122. package/dist/1861.index.js +0 -690
  123. package/dist/1906.index.js +0 -433
  124. package/dist/208.index.js +0 -1631
  125. package/dist/2223.index.js +0 -159
  126. package/dist/2238.index.js +0 -295
  127. package/dist/2530.index.js +0 -152
  128. package/dist/2565.index.js +0 -116
  129. package/dist/2574.index.js +0 -127
  130. package/dist/2620.index.js +0 -345
  131. package/dist/2773.index.js +0 -98
  132. package/dist/2775.index.js +0 -32
  133. package/dist/2966.index.js +0 -171
  134. package/dist/2972.index.js +0 -87
  135. package/dist/2991.index.js +0 -131
  136. package/dist/3063.index.js +0 -833
  137. package/dist/3075.index.js +0 -227
  138. package/dist/3121.index.js +0 -297
  139. package/dist/3314.index.js +0 -263
  140. package/dist/3480.index.js +0 -1822
  141. package/dist/3481.index.js +0 -24
  142. package/dist/3506.index.js +0 -268
  143. package/dist/3533.index.js +0 -591
  144. package/dist/3653.index.js +0 -450
  145. package/dist/3740.index.js +0 -722
  146. package/dist/3822.index.js +0 -131
  147. package/dist/3868.index.js +0 -292
  148. package/dist/3918.index.js +0 -192
  149. package/dist/4090.index.js +0 -288
  150. package/dist/4113.index.js +0 -1286
  151. package/dist/4121.index.js +0 -1190
  152. package/dist/4142.index.js +0 -690
  153. package/dist/4168.index.js +0 -509
  154. package/dist/4281.index.js +0 -993
  155. package/dist/4336.index.js +0 -436
  156. package/dist/4387.index.js +0 -532
  157. package/dist/4413.index.js +0 -302
  158. package/dist/4476.index.js +0 -4510
  159. package/dist/4491.index.js +0 -133
  160. package/dist/4538.index.js +0 -185
  161. package/dist/4684.index.js +0 -319
  162. package/dist/4740.index.js +0 -165
  163. package/dist/4780.index.js +0 -209
  164. package/dist/4802.index.js +0 -225
  165. package/dist/4812.index.js +0 -379
  166. package/dist/4835.index.js +0 -126
  167. package/dist/494.index.js +0 -226
  168. package/dist/4991.index.js +0 -549
  169. package/dist/5199.index.js +0 -305
  170. package/dist/5216.index.js +0 -109
  171. package/dist/5439.index.js +0 -301
  172. package/dist/5443.index.js +0 -97
  173. package/dist/5496.index.js +0 -377
  174. package/dist/5738.index.js +0 -240
  175. package/dist/5801.index.js +0 -588
  176. package/dist/5817.index.js +0 -714
  177. package/dist/5858.index.js +0 -223
  178. package/dist/5933.index.js +0 -143
  179. package/dist/601.index.js +0 -23
  180. package/dist/6173.index.js +0 -273
  181. package/dist/6291.index.js +0 -1871
  182. package/dist/6312.index.js +0 -666
  183. package/dist/634.index.js +0 -16182
  184. package/dist/644.index.js +0 -87
  185. package/dist/6508.index.js +0 -283
  186. package/dist/6560.index.js +0 -645
  187. package/dist/6576.index.js +0 -422
  188. package/dist/6579.index.js +0 -271
  189. package/dist/6655.index.js +0 -3221
  190. package/dist/6745.index.js +0 -305
  191. package/dist/6818.index.js +0 -21642
  192. package/dist/6932.index.js +0 -1190
  193. package/dist/6972.index.js +0 -167
  194. package/dist/6995.index.js +0 -163
  195. package/dist/7249.index.js +0 -191
  196. package/dist/7372.index.js +0 -1197
  197. package/dist/7546.index.js +0 -541
  198. package/dist/76.index.js +0 -448
  199. package/dist/7879.index.js +0 -163
  200. package/dist/8025.index.js +0 -130
  201. package/dist/8054.index.js +0 -248
  202. package/dist/808.index.js +0 -1330
  203. package/dist/8097.index.js +0 -201
  204. package/dist/8126.index.js +0 -191
  205. package/dist/8178.index.js +0 -78
  206. package/dist/8382.index.js +0 -93
  207. package/dist/8408.index.js +0 -261
  208. package/dist/8540.index.js +0 -174
  209. package/dist/8635.index.js +0 -802
  210. package/dist/8688.index.js +0 -410
  211. package/dist/8699.index.js +0 -779
  212. package/dist/8891.index.js +0 -457
  213. package/dist/8916.index.js +0 -885
  214. package/dist/9146.index.js +0 -170
  215. package/dist/9168.index.js +0 -841
  216. package/dist/9292.index.js +0 -369
  217. package/dist/9422.index.js +0 -866
  218. package/dist/9440.index.js +0 -453
  219. package/dist/9479.index.js +0 -152
  220. package/dist/9481.index.js +0 -220
  221. package/dist/9539.index.js +0 -100
  222. package/dist/9547.index.js +0 -103
  223. package/dist/9692.index.js +0 -90
  224. package/dist/9800.index.js +0 -462
  225. package/dist/9899.index.js +0 -138
  226. package/dist/9981.index.js +0 -206
  227. package/dist/999.index.js +0 -117
  228. package/dist/9999.index.js +0 -1698
@@ -0,0 +1,276 @@
1
+ // Runtime support, feasibility and exploit-proof evidence for a path witness (DAG-10, R10.3 to R10.7).
2
+ //
3
+ // Pure: no fs, no network, never throws, never mutates its inputs. It READS the per-edge states that
4
+ // `lineage/runtime-edge-states.js` already derives and adds the one thing those states cannot say: whether
5
+ // the observed edges of a path belong to ONE execution.
6
+ //
7
+ // FOUR EVIDENCE STATUSES, NEVER MERGED
8
+ // ------------------------------------
9
+ // modeledPath the static analysis connected source to sink (complete or partial)
10
+ // feasibility a predicate / control check on the path (a nullable slot until DAG-04 supplies it)
11
+ // runtimeSupport what telemetry says about the path (this file)
12
+ // exploitProof explicit, execution-backed proof bound to this sink, value and configuration
13
+ // They are reported side by side. There is deliberately no function here (and the verifier rejects any bundle
14
+ // field) that combines them into a score, a level or a single confidence.
15
+ //
16
+ // WHAT RUNTIME SUPPORT MAY CLAIM
17
+ // ------------------------------
18
+ // * Several edges observed does NOT prove one end-to-end execution. `path_supported` needs ONE causal group
19
+ // (a trace or session the caller can name) that covers EVERY edge, in a compatible environment,
20
+ // deployment, time window and identity scope, with unambiguous edge mappings. Edges seen in unrelated
21
+ // traces are `edge_partial`, however many there are.
22
+ // * Low-confidence and ambiguous mappings stay CANDIDATES (`candidate_only`); they never count as observed.
23
+ // * No telemetry is not evidence of safety: `no_observation_in_window` and `not_consulted` are different
24
+ // answers and neither says "the path is not exercised".
25
+ // * A denied observation proves only that attempt was refused (`blocked_attempt`), not every execution.
26
+ //
27
+ // KNOWN LIMIT: the closed-world RuntimeObservation contract carries no trace or session token (by design:
28
+ // it is metadata-only). Causal groups are therefore supplied by the caller from a source that has one, and
29
+ // without them the strongest honest answer from the observation store alone is `edge_partial`.
30
+
31
+ import * as crypto from 'node:crypto';
32
+ import { redactString } from '../mcp/redact.js';
33
+ import { makeIdentityTokenizer } from '../lineage/identity-token.js';
34
+
35
+ export const RUNTIME_SUPPORT_STATUSES = Object.freeze([
36
+ 'not_consulted', 'no_observation_in_window', 'candidate_only', 'edge_partial', 'path_supported', 'blocked_attempt',
37
+ ]);
38
+ export const EXPLOIT_PROOF_STATUSES = Object.freeze(['none', 'bound', 'rejected']);
39
+ export const FEASIBILITY_STATUSES = Object.freeze(['not_assessed', 'feasible', 'infeasible', 'unknown']);
40
+ export const MODELED_PATH_STATUSES = Object.freeze(['connected_complete', 'connected_partial']);
41
+ export const EVIDENCE_STATUS_KEYS = Object.freeze(['modeledPath', 'feasibility', 'runtimeSupport', 'exploitProof']);
42
+
43
+ const DEFAULT_MAX_WINDOW_MS = 5 * 60 * 1000;
44
+ const TRUSTED_CONFIDENCE = new Set(['high', 'medium']);
45
+ const REF_RE = /^[A-Za-z0-9._:@/-]{1,160}$/;
46
+ const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
47
+ const arr = (v) => (Array.isArray(v) ? v : []);
48
+ const sha = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
49
+
50
+ /** A caller trace/session id becomes an opaque reference: comparable, never the raw value. */
51
+ function pseudonymizeSessionRef(raw) {
52
+ return typeof raw === 'string' && raw.length > 0 ? `sref_${sha(`session-ref:v1:${raw}`).slice(0, 16)}` : null;
53
+ }
54
+
55
+ function iso(v) {
56
+ if (typeof v !== 'string') return null;
57
+ const t = Date.parse(v);
58
+ return Number.isFinite(t) ? t : null;
59
+ }
60
+
61
+ /**
62
+ * Reduce caller-supplied causal groups to the closed set of fields the assessment reads. Unknown fields are
63
+ * dropped and only COUNTED (their names could themselves carry a value). Identities are tokenized with the
64
+ * existing keyed-hash convention; a raw identity with no tokenizer is dropped, never kept.
65
+ */
66
+ export function sanitizeCausalGroups(groups, { tokenize = makeIdentityTokenizer() } = {}) {
67
+ const out = []; const rejected = []; let droppedFields = 0;
68
+ const GROUP_KEYS = new Set(['id', 'sessionRef', 'sessionId', 'traceId', 'environment', 'deploymentRef', 'deploymentConfigDigest',
69
+ 'identity', 'identityToken', 'firstObservedAt', 'lastObservedAt', 'edgeObservations']);
70
+ const OBS_KEYS = new Set(['edgeId', 'firstObservedAt', 'lastObservedAt', 'matchConfidence', 'matchedEdgeIds', 'observationIds']);
71
+ arr(groups).forEach((g, i) => {
72
+ if (!isObj(g)) { rejected.push({ index: i, reason: 'not-an-object' }); return; }
73
+ droppedFields += Object.keys(g).filter((k) => !GROUP_KEYS.has(k)).length;
74
+ // Idempotent: an already-opaque reference is kept, so re-sanitizing a recorded group (replay) changes nothing.
75
+ const ref = typeof g.sessionRef === 'string' && /^sref_[0-9a-f]{16}$/.test(g.sessionRef)
76
+ ? g.sessionRef : pseudonymizeSessionRef(g.sessionRef ?? g.sessionId ?? g.traceId);
77
+ const token = typeof g.identityToken === 'string' && /^idt_[0-9a-f]{16}$/.test(g.identityToken)
78
+ ? g.identityToken : (typeof g.identity === 'string' ? tokenize(g.identity) : null);
79
+ const ident = (v) => (typeof v === 'string' && REF_RE.test(v) ? v : null);
80
+ const edgeObservations = [];
81
+ for (const o of arr(g.edgeObservations)) {
82
+ if (!isObj(o) || typeof o.edgeId !== 'string' || !REF_RE.test(o.edgeId)) continue;
83
+ droppedFields += Object.keys(o).filter((k) => !OBS_KEYS.has(k)).length;
84
+ edgeObservations.push({
85
+ edgeId: o.edgeId,
86
+ firstObservedAt: iso(o.firstObservedAt) === null ? null : o.firstObservedAt,
87
+ lastObservedAt: iso(o.lastObservedAt) === null ? null : o.lastObservedAt,
88
+ matchConfidence: ['high', 'medium', 'low', 'ambiguous'].includes(o.matchConfidence) ? o.matchConfidence : 'ambiguous',
89
+ matchedEdgeIds: arr(o.matchedEdgeIds).filter((x) => typeof x === 'string' && REF_RE.test(x)).sort(),
90
+ observationIds: arr(o.observationIds).filter((x) => typeof x === 'string' && REF_RE.test(x)).sort().slice(0, 8),
91
+ });
92
+ }
93
+ out.push({
94
+ id: ident(g.id) ?? `group-${i}`,
95
+ sessionRef: ref,
96
+ environment: ident(g.environment),
97
+ deploymentRef: ident(g.deploymentRef),
98
+ deploymentConfigDigest: ident(g.deploymentConfigDigest),
99
+ identityToken: token ?? null,
100
+ firstObservedAt: iso(g.firstObservedAt) === null ? null : g.firstObservedAt,
101
+ lastObservedAt: iso(g.lastObservedAt) === null ? null : g.lastObservedAt,
102
+ edgeObservations: edgeObservations.sort((a, b) => (a.edgeId < b.edgeId ? -1 : a.edgeId > b.edgeId ? 1 : 0)),
103
+ });
104
+ });
105
+ return { groups: out, rejected, droppedFields };
106
+ }
107
+
108
+ /** Why a single causal group cannot support the WHOLE path (empty list means it can). */
109
+ function groupIncompatibilities(group, pathEdgeIds, expected, maxWindowMs) {
110
+ const why = [];
111
+ if (!group.sessionRef) why.push('no-session-or-trace-reference');
112
+ const covered = new Map(group.edgeObservations.map((o) => [o.edgeId, o]));
113
+ const missing = pathEdgeIds.filter((id) => !covered.has(id));
114
+ if (missing.length) why.push(`edges-not-in-group:${missing.length}`);
115
+ if (!expected.environment) why.push('environment-unspecified');
116
+ else if (group.environment !== expected.environment) why.push('environment-mismatch');
117
+ if (!expected.deploymentRef) why.push('deployment-unspecified');
118
+ else if (group.deploymentRef !== expected.deploymentRef) why.push('deployment-mismatch');
119
+ if (expected.deploymentConfigDigest && group.deploymentConfigDigest && expected.deploymentConfigDigest !== group.deploymentConfigDigest) {
120
+ why.push('deployment-configuration-mismatch');
121
+ }
122
+ if (expected.identityToken && group.identityToken && expected.identityToken !== group.identityToken) why.push('identity-scope-mismatch');
123
+ const first = iso(group.firstObservedAt); const last = iso(group.lastObservedAt);
124
+ if (first === null || last === null) why.push('time-window-missing');
125
+ else {
126
+ if (last < first) why.push('time-window-inverted');
127
+ if (last - first > maxWindowMs) why.push('time-window-too-wide');
128
+ const w = expected.window;
129
+ if (w && (iso(w.start) !== null && first < iso(w.start) || iso(w.end) !== null && last > iso(w.end))) why.push('outside-requested-window');
130
+ }
131
+ // Edge-level mapping and order: each edge's own interval is inside the group window and the path order
132
+ // is not contradicted (an edge cannot have been last seen before its predecessor was first seen).
133
+ let prevFirst = null;
134
+ for (const id of pathEdgeIds) {
135
+ const o = covered.get(id);
136
+ if (!o) continue;
137
+ if (!TRUSTED_CONFIDENCE.has(o.matchConfidence) || o.matchedEdgeIds.length > 1) { why.push('ambiguous-edge-mapping'); break; }
138
+ const f = iso(o.firstObservedAt); const l = iso(o.lastObservedAt);
139
+ if (f === null || l === null) { why.push('edge-time-missing'); break; }
140
+ if (first !== null && last !== null && (f < first || l > last)) { why.push('edge-outside-group-window'); break; }
141
+ if (prevFirst !== null && l < prevFirst) { why.push('causal-order-violated'); break; }
142
+ prevFirst = f;
143
+ }
144
+ return why;
145
+ }
146
+
147
+ /**
148
+ * Assess what telemetry says about ONE path.
149
+ *
150
+ * @param {object} p
151
+ * @param {string[]} p.pathEdgeIds DataFlowGraph edge ids of the path, source first.
152
+ * @param {object|null} p.edgeStates the result of `deriveEdgeStates` (null or evaluated:false means no telemetry was consulted)
153
+ * @param {object[]} [p.causalGroups]
154
+ * @param {object} [p.expected] { environment, deploymentRef, deploymentConfigDigest, identityToken, window, maxWindowMs }
155
+ */
156
+ export function assessRuntimeSupport({ pathEdgeIds, edgeStates, causalGroups = [], expected = {}, tokenize } = {}) {
157
+ const ids = arr(pathEdgeIds).filter((x) => typeof x === 'string');
158
+ const base = {
159
+ pathEdgeCount: ids.length, observedEdgeIds: [], unobservedEdgeIds: [], candidateEdgeIds: [], blockedEdgeIds: [],
160
+ wholePath: { supported: false, groupId: null, reasons: [] }, groupsConsidered: 0, rejectedGroups: 0, droppedFields: 0,
161
+ statements: [],
162
+ };
163
+ if (!ids.length || !isObj(edgeStates) || !edgeStates.evaluated) {
164
+ return { ...base, status: 'not_consulted', statements: ['No telemetry was consulted for this path. That is not an observation of absence.'] };
165
+ }
166
+ const sanitized = sanitizeCausalGroups(causalGroups, { tokenize });
167
+ base.groupsConsidered = sanitized.groups.length; base.rejectedGroups = sanitized.rejected.length; base.droppedFields = sanitized.droppedFields;
168
+
169
+ for (const id of ids) {
170
+ const s = edgeStates.byEdge?.[id];
171
+ const states = arr(s?.states);
172
+ if (states.includes('RUNTIME_OBSERVED')) base.observedEdgeIds.push(id);
173
+ else if (states.includes('RUNTIME_BLOCKED')) base.blockedEdgeIds.push(id);
174
+ else {
175
+ base.unobservedEdgeIds.push(id);
176
+ if ((s?.uncertainObservations ?? 0) > 0) base.candidateEdgeIds.push(id);
177
+ }
178
+ // An edge observed AND denied keeps both facts.
179
+ if (states.includes('RUNTIME_OBSERVED') && states.includes('RUNTIME_BLOCKED')) base.blockedEdgeIds.push(id);
180
+ }
181
+
182
+ const allObserved = base.observedEdgeIds.length === ids.length;
183
+ if (allObserved) {
184
+ const maxWindowMs = Number.isFinite(expected.maxWindowMs) && expected.maxWindowMs > 0 ? expected.maxWindowMs : DEFAULT_MAX_WINDOW_MS;
185
+ let best = null;
186
+ for (const g of sanitized.groups) {
187
+ const why = groupIncompatibilities(g, ids, expected, maxWindowMs);
188
+ if (!why.length) { best = { g, why: [] }; break; }
189
+ if (!best || why.length < best.why.length) best = { g, why };
190
+ }
191
+ if (best && !best.why.length) {
192
+ base.wholePath = { supported: true, groupId: best.g.id, reasons: [] };
193
+ return { ...base, status: 'path_supported', statements: [
194
+ 'Every edge was observed inside ONE compatible trace or session. This supports that this path executed in that session; it does not prove the data was sensitive or that the path is exploitable.',
195
+ ] };
196
+ }
197
+ base.wholePath.reasons = best ? best.why : ['no-causal-group-supplied'];
198
+ } else {
199
+ base.wholePath.reasons = ['not-every-edge-observed'];
200
+ }
201
+
202
+ if (base.blockedEdgeIds.length && base.blockedEdgeIds.some((id) => !base.observedEdgeIds.includes(id))) {
203
+ return { ...base, status: 'blocked_attempt', statements: [
204
+ 'A denied observation proves only that this attempt was refused. It does not show every execution of this path is blocked.',
205
+ ] };
206
+ }
207
+ if (base.observedEdgeIds.length) {
208
+ return { ...base, status: 'edge_partial', statements: [
209
+ 'Some edges were observed, but not within one compatible trace or session. Independently observed edges do not establish an end-to-end execution.',
210
+ ] };
211
+ }
212
+ if (base.candidateEdgeIds.length) {
213
+ return { ...base, status: 'candidate_only', statements: [
214
+ 'Only low-confidence or ambiguous mappings were seen. They remain candidates and are not counted as observed.',
215
+ ] };
216
+ }
217
+ return { ...base, status: 'no_observation_in_window', statements: [
218
+ 'Telemetry was consulted and nothing mapped to this path in the window. That is not evidence that the path is unreachable or safe.',
219
+ ] };
220
+ }
221
+
222
+ /**
223
+ * R10.6: external proof references are bound to the sink, the value and the configuration. A reference that is
224
+ * stale or mismatched is REJECTED with its reasons; it is never re-attached to the changed graph.
225
+ */
226
+ export function bindExploitProofs(proofs, expected) {
227
+ const bound = []; const rejected = [];
228
+ for (const [i, p] of arr(proofs).entries()) {
229
+ const id = isObj(p) && typeof p.id === 'string' && REF_RE.test(p.id) ? p.id : `proof-${i}`;
230
+ if (!isObj(p)) { rejected.push({ id, reasons: ['not-an-object'] }); continue; }
231
+ const reasons = [];
232
+ if (!['proof', 'dnr-hunt', 'incident'].includes(p.kind)) reasons.push('unknown-kind');
233
+ if (!p.sinkRef || !p.dataElementId || !p.graphDigest || !p.analysisConfigDigest) reasons.push('unbound');
234
+ else {
235
+ if (p.sinkRef !== expected.sinkRef) reasons.push('sink-mismatch');
236
+ if (p.dataElementId !== expected.dataElementId) reasons.push('value-mismatch');
237
+ if (p.graphDigest !== expected.graphDigest) reasons.push('stale-graph');
238
+ if (p.analysisConfigDigest !== expected.analysisConfigDigest) reasons.push('configuration-mismatch');
239
+ }
240
+ if (reasons.length) rejected.push({ id, reasons });
241
+ else bound.push({ id, kind: p.kind, executionBacked: p.kind === 'proof', note: typeof p.note === 'string' ? redactString(p.note).slice(0, 200) : null });
242
+ }
243
+ bound.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
244
+ rejected.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
245
+ // Rejected references stay listed with their reasons whatever the status; they are never silently dropped.
246
+ return { status: bound.length ? 'bound' : rejected.length ? 'rejected' : 'none', bound, rejected };
247
+ }
248
+
249
+ /** Validate the nullable DAG-04 slot. Returns { ok, errors }. */
250
+ export function validateControlEvidence(ce, pathEdgeIds) {
251
+ if (ce === null) return { ok: true, errors: [] };
252
+ const errors = [];
253
+ if (!isObj(ce)) return { ok: false, errors: ['control evidence must be an object or null'] };
254
+ if (!FEASIBILITY_STATUSES.includes(ce.status) || ce.status === 'not_assessed') errors.push('control evidence status must be feasible, infeasible or unknown');
255
+ const set = new Set(pathEdgeIds);
256
+ for (const k of ['predicateRefs', 'controlRefs']) {
257
+ if (!Array.isArray(ce[k])) { errors.push(`${k} must be an array`); continue; }
258
+ for (const r of ce[k]) {
259
+ if (!isObj(r) || !set.has(r.edgeId) || typeof r.ref !== 'string' || !REF_RE.test(r.ref)) errors.push(`${k} entry must name an edge on this path and an identifier-shaped ref`);
260
+ }
261
+ }
262
+ return { ok: errors.length === 0, errors };
263
+ }
264
+
265
+ /**
266
+ * The four statuses, side by side. Returns exactly the four keys in EVIDENCE_STATUS_KEYS and nothing that
267
+ * ranks, scores or combines them.
268
+ */
269
+ export function buildEvidenceStatuses({ pathComplete, controlEvidence, runtime, exploit }) {
270
+ return {
271
+ modeledPath: { status: pathComplete ? 'connected_complete' : 'connected_partial' },
272
+ feasibility: { status: controlEvidence ? controlEvidence.status : 'not_assessed' },
273
+ runtimeSupport: { status: runtime.status },
274
+ exploitProof: { status: exploit.status },
275
+ };
276
+ }
@@ -339,6 +339,8 @@ export function normalizeFindings(scan){
339
339
  // (posture/proof-tier.js) actually attached it. Never synthesised here;
340
340
  // a finding with no proof backing simply omits these fields.
341
341
  ...(f.proofTier !== undefined ? { proofTier: f.proofTier } : {}),
342
+ // DAG-05 R05.7: whether this finding was replayed from the cross-scan cache or analysed in this scan; present only when the cache was in use.
343
+ ...(f.cacheProvenance !== undefined ? { cacheProvenance: f.cacheProvenance } : {}),
342
344
  ...(f.proofEvidence !== undefined ? { proofEvidence: f.proofEvidence } : {}),
343
345
  // X-206: the version-1 verification record and its shared projection, ADDITIVE and present only when the finding carries
344
346
  // a record (a plain scan output is byte-identical). `verificationReplay` is the typed prerequisite list of a replay attempt.
@@ -39,6 +39,8 @@ Both key on the SECRET-NESS of the identifier, never on the comparison or the ty
39
39
 
40
40
  **Bench-shape adapters (label-leakage, OFF by default)** — `bench-shape/` directory, plus `cpp-bench-extras.js`, `java-bench-extras.js`, `juliet-shape.js`, `primary-cwe-java.js`. These read Juliet / OWASP-Benchmark answer keys. Disabled unless `AGENTIC_SECURITY_BENCH_SHAPE=1`; `AGENTIC_SECURITY_BLIND_BENCH=1` overrides to force everything off. **Never use their emissions as a quality signal.**
41
41
 
42
+ **Precision guards (bench/dag).** Small, general recognisers that answer one semantic question each about the TEXT of a call, shared by every emitter that can fire on it. Each is tested as a pair (the safe construct stays quiet, the near-miss that changes the semantics keeps its verdict) in `test/dag-precision.test.js` and `bench/mutation/cases.mjs` (`DAG_PRECISION_CASES`). `_call-args.js` (quote- and bracket-aware argument splitter; a window that ends inside the call yields the arguments completed so far, flagged `partial`), `redirect-target.js` (CWE-601: a literal prefix that fixes the origin, `'/user/' + id` or `'https://host/' + p`, cannot be steered off-origin; `'/' + x`, `'//' + x` and a host with no closing slash can), `reflected-input.js` (CWE-79: only the properties of `req` that carry the client's input count, and not inside an HTML escaper; `req.session`/`req.user`/`req.app` are server state), `send-file-root.js` (CWE-22: `sendFile`/`download` with an inline `root` option confine the path), `init-scope.js` (CWE-400: is a position inside a function body, including expression-bodied arrows and class bodies, or at module load; a block is a function unless it is positively a control block or a literal, because the error that matters is hiding a finding), `listen-bind-address.js` (the `address` a `listen` callback receives is the bound socket address, not personal data). Wired in `../engine.js` (`dropGuardedFindings` for redirect and file serving, the `predicate` hook of the structural patterns for reflection and blocking I/O, `_detectSafeSinkShape`), `sensitive-data-logging.js` and `../dataflow/privacy-taint.js`. `engine.js` `_usedIdentifiers` is the same idea for the flow-pair detector: words inside string literals and property names after a dot are not reads of a variable of that name.
43
+
42
44
  **Helpers** — `_comment-strip.js` strips comments while preserving line numbers (so finding `line` stays accurate). `index.js` is the public re-export.
43
45
 
44
46
  ## Gotchas
@@ -0,0 +1,70 @@
1
+ // Argument splitting for guard recognisers that must look at WHAT a call is
2
+ // given, not at the text around it. Quote- and bracket-aware; handles template
3
+ // literal substitutions. Not a parser: it answers "where does each top-level
4
+ // argument start and end" for text that begins right after a call's '('.
5
+
6
+ const isQuote = (c) => c === '"' || c === "'" || c === '`';
7
+
8
+ /**
9
+ * Split the text that follows a call's opening paren into its top-level
10
+ * arguments (source text, quotes intact). Returns null when the closing paren
11
+ * is not in the text given, so a caller never reasons about a truncated call.
12
+ */
13
+ export function callArguments(text) {
14
+ const args = [];
15
+ let depth = 0;
16
+ let start = 0;
17
+ let i = 0;
18
+ while (i < text.length) {
19
+ const c = text[i];
20
+ if (isQuote(c)) {
21
+ const q = c;
22
+ i++;
23
+ while (i < text.length && text[i] !== q) {
24
+ if (text[i] === '\\') { i += 2; continue; }
25
+ if (q === '`' && text[i] === '$' && text[i + 1] === '{') {
26
+ let d = 1; i += 2;
27
+ while (i < text.length && d > 0) { if (text[i] === '{') d++; else if (text[i] === '}') d--; i++; }
28
+ continue;
29
+ }
30
+ i++;
31
+ }
32
+ i++;
33
+ continue;
34
+ }
35
+ if (c === '(' || c === '[' || c === '{') depth++;
36
+ else if (c === ')' || c === ']' || c === '}') {
37
+ if (depth === 0) {
38
+ const last = text.slice(start, i);
39
+ if (last.trim() !== '' || args.length > 0) args.push(last);
40
+ return args;
41
+ }
42
+ depth--;
43
+ } else if (c === ',' && depth === 0) {
44
+ args.push(text.slice(start, i));
45
+ start = i + 1;
46
+ }
47
+ i++;
48
+ }
49
+ // The window ended inside the call (a long callback, a wrapped call). The
50
+ // arguments completed so far are still exactly what the call was given; the
51
+ // unfinished last one is dropped rather than guessed. `partial` lets a caller
52
+ // that needs the whole call refuse.
53
+ if (args.length === 0) return null;
54
+ args.partial = true;
55
+ return args;
56
+ }
57
+
58
+ /**
59
+ * Find the first call on or just after `line` (1-based) whose callee matches
60
+ * `calleeRe` (which must end at the opening paren) and return its arguments.
61
+ * Looks at the finding line and the next three, because a call is routinely
62
+ * wrapped. Returns null when there is no such call or it is not closed.
63
+ */
64
+ export function callArgumentsAt(lines, line, calleeRe) {
65
+ if (!Array.isArray(lines) || !Number.isInteger(line) || line < 1) return null;
66
+ const text = lines.slice(line - 1, line + 3).join('\n');
67
+ const m = calleeRe.exec(text);
68
+ if (!m) return null;
69
+ return callArguments(text.slice(m.index + m[0].length));
70
+ }
@@ -0,0 +1,87 @@
1
+ // Is a source position inside a function body, or at module/initialisation
2
+ // level? Some hazards exist only while the process is serving work: a blocking
3
+ // call made once at module load (reading a TLS key, a config file) delays
4
+ // startup once, and nothing is waiting on the event loop yet. The same call in
5
+ // a request callback stalls every connection.
6
+ //
7
+ // A position is inside a function when it is inside
8
+ // * a `{ ... }` block opened by `=>`, a `function` header, a method or class
9
+ // member header (`name(params) {`), or an async/generator form,
10
+ // * the body of an expression-bodied arrow (`(p) => fs.readFileSync(p)`),
11
+ // which runs when the arrow is CALLED, not where it is written, or
12
+ // * a class body (field initialisers run per instance).
13
+ // Plain blocks (`if`, `for`, `try`, `else`) and object literals are not function scopes.
14
+ //
15
+ // Operates on a comment- and string-blanked view so braces in strings or
16
+ // comments never count. It is a lexical approximation (no parse). The one error
17
+ // that matters is calling a function body "module level", because that hides a
18
+ // finding, so a block is classed as a function unless it is positively a control
19
+ // block or a literal, and parameter lists are matched by bracket depth, not by a
20
+ // pattern that stops at a fixed nesting.
21
+
22
+ const CONTROL_KEYWORDS = new Set(['if', 'for', 'while', 'switch', 'catch', 'with', 'else', 'try', 'finally', 'do']);
23
+ const CLASS_HEAD = /\bclass\b\s*[\w$]*(?:\s+extends\s+[\w$.()]+)?\s*$/;
24
+
25
+ /** Index of the '(' matching the ')' at `closeIdx`, scanning backwards; -1 if unbalanced. */
26
+ function matchingOpenParen(text, closeIdx) {
27
+ let depth = 0;
28
+ for (let i = closeIdx; i >= 0; i--) {
29
+ const c = text[i];
30
+ if (c === ')') depth++;
31
+ else if (c === '(') { depth--; if (depth === 0) return i; }
32
+ }
33
+ return -1;
34
+ }
35
+
36
+ function opensFunction(text, braceIdx) {
37
+ let end = braceIdx;
38
+ while (end > 0 && /\s/.test(text[end - 1])) end--;
39
+ const before = text.slice(Math.max(0, end - 400), end);
40
+ if (/=>$/.test(before)) return true;
41
+ if (CLASS_HEAD.test(before)) return true;
42
+ // `else {`, `try {`, `finally {`, `do {`, `catch {` carry no parenthesised head
43
+ const bare = /(?:^|[^\w$.])([A-Za-z_$][\w$]*)$/.exec(before);
44
+ if (bare && CONTROL_KEYWORDS.has(bare[1])) return false;
45
+ // find the ')' that ends a parameter list, allowing a return type annotation `): Type {`
46
+ let closeIdx = end - 1;
47
+ if (text[closeIdx] !== ')') {
48
+ const m = /\)\s*:\s*[^(){};=]*$/.exec(before);
49
+ if (!m) return false;
50
+ closeIdx = end - before.length + m.index;
51
+ }
52
+ const openIdx = matchingOpenParen(text, closeIdx);
53
+ if (openIdx < 0) return true; // cannot match the head: keep findings, so call it a function
54
+ const head = text.slice(Math.max(0, openIdx - 80), openIdx).replace(/\s+$/, '');
55
+ const word = /([A-Za-z_$][\w$]*)$/.exec(head);
56
+ if (word) return !CONTROL_KEYWORDS.has(word[1]); // `if (` `while (` ...: control; any other name( ... ) { is a function or method
57
+ if (/\bfunction\s*\*?$/.test(head)) return true; // `function (` and `function* (`
58
+ return false;
59
+ }
60
+
61
+ /**
62
+ * @param {string} blanked source with comments and string bodies blanked
63
+ * @param {number} offset index into `blanked`
64
+ * @returns {boolean} true when `offset` is lexically inside a function body
65
+ */
66
+ export function insideFunctionBody(blanked, offset) {
67
+ // entries: 'F' function block, 'B' other block, 'P' paren/bracket, 'A' expression-bodied arrow
68
+ const stack = [];
69
+ const end = Math.min(offset, blanked.length);
70
+ for (let i = 0; i < end; i++) {
71
+ const c = blanked[i];
72
+ if (c === '{') stack.push(opensFunction(blanked, i) ? 'F' : 'B');
73
+ else if (c === '(' || c === '[') stack.push('P');
74
+ else if (c === '}' || c === ')' || c === ']') {
75
+ while (stack.length && stack[stack.length - 1] === 'A') stack.pop(); // an arrow body ends where its enclosing bracket closes
76
+ stack.pop();
77
+ } else if ((c === ',' || c === ';') && stack[stack.length - 1] === 'A') {
78
+ stack.pop(); // ... or at a separator at its own nesting level
79
+ } else if (c === '=' && blanked[i + 1] === '>') {
80
+ let j = i + 2;
81
+ while (j < blanked.length && /\s/.test(blanked[j])) j++;
82
+ if (blanked[j] !== '{') stack.push('A');
83
+ i++;
84
+ }
85
+ }
86
+ return stack.some((e) => e === 'F' || e === 'A');
87
+ }
@@ -0,0 +1,49 @@
1
+ // The `address` a `listen` callback receives.
2
+ //
3
+ // server.listen({ port }, (err, address) => console.log(`listening on ${address}`))
4
+ //
5
+ // `address` here is the socket address the server just bound (host and port),
6
+ // handed to the callback by the server. It is not a person's postal address, so a
7
+ // rule that classifies a value by the NAME `address` must not treat this binding
8
+ // as personal data. The rule is about the binding site, not about the word: the
9
+ // same name bound anywhere else (a form field, a column, a request property) is
10
+ // unaffected.
11
+
12
+ /** Index just past the paren that closes the one opened at `openIdx`, or -1 when the text ends first. */
13
+ function closeOfParen(text, openIdx) {
14
+ let depth = 0;
15
+ for (let i = openIdx; i < text.length; i++) {
16
+ const c = text[i];
17
+ if (c === '"' || c === "'" || c === '`') {
18
+ const q = c; i++;
19
+ while (i < text.length && text[i] !== q) { if (text[i] === '\\') i++; i++; }
20
+ } else if (c === '(') depth++;
21
+ else if (c === ')') { depth--; if (depth === 0) return i + 1; }
22
+ }
23
+ return -1;
24
+ }
25
+
26
+ /**
27
+ * Is the identifier at `idx` in `raw` a parameter declared by a callback passed
28
+ * to a `.listen(` call? `idx` is any position at or after the callback header
29
+ * (the use site); the header is searched backwards within a bounded window, and
30
+ * must sit inside the listen call's own parentheses, so a function declared after
31
+ * the call has closed does not count.
32
+ */
33
+ export function isListenCallbackParam(raw, idx, name) {
34
+ if (typeof raw !== 'string' || !name) return false;
35
+ const from = Math.max(0, idx - 800);
36
+ const at = raw.slice(from, idx).lastIndexOf('.listen(');
37
+ if (at < 0) return false;
38
+ const start = from + at;
39
+ const openIdx = start + '.listen'.length;
40
+ const windowEnd = idx + name.length + 80;
41
+ const close = closeOfParen(raw.slice(start, windowEnd), openIdx - start);
42
+ // the call closed before the use site: the use is not inside the callback
43
+ if (close !== -1 && start + close <= idx) return false;
44
+ const after = raw.slice(start, close === -1 ? windowEnd : Math.min(windowEnd, start + close));
45
+ const id = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
46
+ const arrow = new RegExp(String.raw`\([^)]*\b${id}\b[^)]*\)\s*=>|\b${id}\s*=>`);
47
+ const fn = new RegExp(String.raw`\bfunction\b[^(]*\([^)]*\b${id}\b[^)]*\)`);
48
+ return arrow.test(after) || fn.test(after);
49
+ }
@@ -0,0 +1,106 @@
1
+ // Open-redirect target shape (CWE-601): can the part of a redirect target the
2
+ // program does not control change the ORIGIN the browser is sent to?
3
+ //
4
+ // A redirect target is attacker-steerable to another origin only if the
5
+ // attacker controls the authority (scheme, host, port) of the resulting URL.
6
+ // A target that begins with a literal which already fixes the authority cannot
7
+ // be moved off-origin by whatever is appended after it:
8
+ //
9
+ // '/user/' + id path-absolute, second character is a name
10
+ // character: same origin, whatever `id` is
11
+ // 'https://app.example.com/' + p the whole authority is in the literal and a
12
+ // '/' closes it, so `p` is only path/query
13
+ //
14
+ // and these are NOT pinned, because the dynamic part decides the authority:
15
+ //
16
+ // '/' + x x = '/evil.example' gives '//evil.example'
17
+ // '//' + x, '/\\' + x protocol-relative (browsers treat '\' as '/')
18
+ // 'https://app.example.com' + x x = '.evil.example' or '@evil.example'
19
+ // '' + x, x, x + '/path' no literal prefix at all
20
+ //
21
+ // This is deliberately the ONLY thing decided here. It reads the text of the
22
+ // redirect call's first argument; it does not decide whether the dynamic part
23
+ // is attacker-controlled (that is the taint layer's question) and it never
24
+ // reads a file name, a fixture name or a label.
25
+
26
+ import { callArgumentsAt } from './_call-args.js';
27
+
28
+ const REDIRECT_CALL_RE = /\b(?:redirect|sendRedirect|Redirect|redirect_to|HttpResponseRedirect|RedirectResponse|redirectTo|permanentRedirect)\s*\(/;
29
+
30
+ /**
31
+ * Leading literal text of an expression, and whether anything dynamic follows.
32
+ * Handles 'a' + x, "a" + x + y, `a${x}`, and a lone literal.
33
+ * `literal` is the decoded-enough prefix (escapes kept verbatim, which is safe
34
+ * here because the checks below only look at slashes and the first characters).
35
+ */
36
+ export function leadingLiteral(expr) {
37
+ const s = String(expr).trim();
38
+ let i = 0;
39
+ let prefix = '';
40
+ let dynamic = false;
41
+ for (;;) {
42
+ while (i < s.length && /\s/.test(s[i])) i++;
43
+ if (i >= s.length) break;
44
+ const c = s[i];
45
+ if (c === '"' || c === "'") {
46
+ let j = i + 1; let body = '';
47
+ while (j < s.length && s[j] !== c) { if (s[j] === '\\') { body += s[j] + (s[j + 1] || ''); j += 2; } else body += s[j++]; }
48
+ if (j >= s.length) return { literal: prefix, dynamic: true, closed: false };
49
+ prefix += body; i = j + 1;
50
+ } else if (c === '`') {
51
+ let j = i + 1; let body = '';
52
+ while (j < s.length && s[j] !== '`') {
53
+ if (s[j] === '\\') { body += s[j] + (s[j + 1] || ''); j += 2; continue; }
54
+ if (s[j] === '$' && s[j + 1] === '{') { dynamic = true; break; }
55
+ body += s[j++];
56
+ }
57
+ prefix += body;
58
+ if (dynamic) return { literal: prefix, dynamic: true, closed: true };
59
+ if (j >= s.length) return { literal: prefix, dynamic: true, closed: false };
60
+ i = j + 1;
61
+ } else {
62
+ return { literal: prefix, dynamic: true, closed: true };
63
+ }
64
+ while (i < s.length && /\s/.test(s[i])) i++;
65
+ if (i >= s.length) return { literal: prefix, dynamic, closed: true };
66
+ if (s[i] === '+') { i++; continue; }
67
+ // anything else after a literal (a call suffix, `.concat`, `%`, ...): not a plain literal
68
+ return { literal: prefix, dynamic: true, closed: true };
69
+ }
70
+ return { literal: prefix, dynamic, closed: true };
71
+ }
72
+
73
+ /** A literal prefix that fixes the origin of the URL it begins. */
74
+ export function prefixPinsOrigin(prefix) {
75
+ // Path-absolute: '/' then a character that is neither '/' nor '\' (both are
76
+ // read as a second slash, turning the reference into a network-path one).
77
+ if (/^\/[^\/\\\s]/.test(prefix)) return true;
78
+ // Absolute URL whose authority is complete: scheme://authority/ .
79
+ if (/^[A-Za-z][A-Za-z0-9+.\-]*:\/\/[^\/?#\\\s]+\//.test(prefix)) return true;
80
+ return false;
81
+ }
82
+
83
+ /**
84
+ * Does the redirect argument keep the destination on a fixed origin?
85
+ * `arg` is the first argument of the redirect call. A numeric first argument
86
+ * (`res.redirect(302, url)`) is skipped by the caller, not here.
87
+ */
88
+ export function redirectArgumentPinsOrigin(arg) {
89
+ const { literal, dynamic, closed } = leadingLiteral(arg);
90
+ if (!closed) return false;
91
+ if (!dynamic) return literal.length > 0; // a fully static target
92
+ return prefixPinsOrigin(literal);
93
+ }
94
+
95
+ /**
96
+ * Inspect source lines starting at `line` (1-based) for a redirect call and
97
+ * decide whether its target pins the origin. Returns null when no redirect
98
+ * call is found (no opinion), else true/false.
99
+ */
100
+ export function redirectTargetPinsOriginAt(lines, line) {
101
+ const args = callArgumentsAt(lines, line, REDIRECT_CALL_RE);
102
+ if (!args || args.length === 0) return null;
103
+ // res.redirect(302, '/x'): the status code is not the target
104
+ const target = args.length > 1 && /^\s*\d{3}\s*$/.test(args[0]) ? args[1] : args[0];
105
+ return redirectArgumentPinsOrigin(target);
106
+ }