@clear-capabilities/agentic-security-scanner 0.185.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 (167) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/bin/agentic-security.js +24 -1
  3. package/dist/1128.index.js +25 -21
  4. package/dist/2584.index.js +1 -1
  5. package/dist/4171.index.js +2 -2
  6. package/dist/5752.index.js +466 -0
  7. package/dist/7405.index.js +1 -1
  8. package/dist/8158.index.js +1713 -0
  9. package/dist/8798.index.js +1 -1
  10. package/dist/9382.index.js +2 -2
  11. package/dist/9579.index.js +1 -1
  12. package/dist/agentic-security.mjs +29 -29
  13. package/dist/agentic-security.mjs.sha256 +1 -1
  14. package/dist/{0b301d45b231f144a1b4.json → ca62ee93079be4c24eee.json} +7 -7
  15. package/package.json +7 -7
  16. package/src/dataflow/CLAUDE.md +6 -3
  17. package/src/dataflow/access-paths.js +12 -2
  18. package/src/dataflow/demand-query.js +279 -40
  19. package/src/dataflow/dep-fingerprint.js +1 -1
  20. package/src/dataflow/exploit-prover.js +6 -2
  21. package/src/dataflow/ir-cache.js +268 -0
  22. package/src/dataflow/privacy-taint.js +7 -0
  23. package/src/engine.js +62 -5
  24. package/src/ir/CLAUDE.md +4 -0
  25. package/src/ir/callgraph.js +36 -8
  26. package/src/ir/index.js +18 -4
  27. package/src/ir/parser-js.js +10 -0
  28. package/src/language/CLAUDE.md +13 -0
  29. package/src/language/nix-option-types.js +170 -0
  30. package/src/language/nix-script-taint.js +46 -3
  31. package/src/language/nix-secrets.js +20 -2
  32. package/src/lineage/CLAUDE.md +2 -1
  33. package/src/lineage/smt-orchestrator.js +47 -11
  34. package/src/lineage/smt-z3-backend.js +294 -0
  35. package/src/posture/CLAUDE.md +5 -1
  36. package/src/posture/business-logic.js +16 -7
  37. package/src/posture/dag-commands.js +442 -0
  38. package/src/posture/finding-defaults.js +19 -0
  39. package/src/posture/git-history.js +91 -1
  40. package/src/sast/CLAUDE.md +2 -0
  41. package/src/sast/_call-args.js +70 -0
  42. package/src/sast/init-scope.js +87 -0
  43. package/src/sast/listen-bind-address.js +49 -0
  44. package/src/sast/redirect-target.js +106 -0
  45. package/src/sast/reflected-input.js +76 -0
  46. package/src/sast/send-file-root.js +27 -0
  47. package/src/sast/sensitive-data-logging.js +38 -1
  48. package/dist/119.index.js +0 -197
  49. package/dist/128.index.js +0 -129
  50. package/dist/1293.index.js +0 -5689
  51. package/dist/1336.index.js +0 -16331
  52. package/dist/1351.index.js +0 -133
  53. package/dist/1387.index.js +0 -271
  54. package/dist/1437.index.js +0 -23
  55. package/dist/1445.index.js +0 -110
  56. package/dist/1505.index.js +0 -151
  57. package/dist/1557.index.js +0 -293
  58. package/dist/1621.index.js +0 -311
  59. package/dist/1751.index.js +0 -160
  60. package/dist/1765.index.js +0 -727
  61. package/dist/1861.index.js +0 -690
  62. package/dist/1906.index.js +0 -433
  63. package/dist/208.index.js +0 -1631
  64. package/dist/2223.index.js +0 -159
  65. package/dist/2238.index.js +0 -295
  66. package/dist/2530.index.js +0 -152
  67. package/dist/2565.index.js +0 -116
  68. package/dist/2574.index.js +0 -127
  69. package/dist/2620.index.js +0 -345
  70. package/dist/2773.index.js +0 -98
  71. package/dist/2775.index.js +0 -32
  72. package/dist/2966.index.js +0 -171
  73. package/dist/2972.index.js +0 -87
  74. package/dist/2991.index.js +0 -131
  75. package/dist/3063.index.js +0 -833
  76. package/dist/3075.index.js +0 -227
  77. package/dist/3121.index.js +0 -297
  78. package/dist/3314.index.js +0 -263
  79. package/dist/3480.index.js +0 -1822
  80. package/dist/3481.index.js +0 -24
  81. package/dist/3506.index.js +0 -268
  82. package/dist/3533.index.js +0 -591
  83. package/dist/3653.index.js +0 -450
  84. package/dist/3740.index.js +0 -722
  85. package/dist/3822.index.js +0 -131
  86. package/dist/3868.index.js +0 -292
  87. package/dist/3918.index.js +0 -192
  88. package/dist/4090.index.js +0 -288
  89. package/dist/4113.index.js +0 -1286
  90. package/dist/4121.index.js +0 -1190
  91. package/dist/4142.index.js +0 -690
  92. package/dist/4168.index.js +0 -509
  93. package/dist/4281.index.js +0 -993
  94. package/dist/4336.index.js +0 -436
  95. package/dist/4387.index.js +0 -532
  96. package/dist/4413.index.js +0 -302
  97. package/dist/4476.index.js +0 -4510
  98. package/dist/4491.index.js +0 -133
  99. package/dist/4538.index.js +0 -185
  100. package/dist/4684.index.js +0 -319
  101. package/dist/4740.index.js +0 -165
  102. package/dist/4780.index.js +0 -209
  103. package/dist/4802.index.js +0 -225
  104. package/dist/4812.index.js +0 -379
  105. package/dist/4835.index.js +0 -126
  106. package/dist/494.index.js +0 -226
  107. package/dist/4991.index.js +0 -549
  108. package/dist/5199.index.js +0 -305
  109. package/dist/5216.index.js +0 -109
  110. package/dist/5439.index.js +0 -301
  111. package/dist/5443.index.js +0 -97
  112. package/dist/5496.index.js +0 -377
  113. package/dist/5738.index.js +0 -240
  114. package/dist/5801.index.js +0 -588
  115. package/dist/5817.index.js +0 -714
  116. package/dist/5858.index.js +0 -223
  117. package/dist/5933.index.js +0 -143
  118. package/dist/601.index.js +0 -23
  119. package/dist/6173.index.js +0 -273
  120. package/dist/6291.index.js +0 -1871
  121. package/dist/6312.index.js +0 -666
  122. package/dist/634.index.js +0 -16182
  123. package/dist/644.index.js +0 -87
  124. package/dist/6508.index.js +0 -283
  125. package/dist/6560.index.js +0 -645
  126. package/dist/6576.index.js +0 -422
  127. package/dist/6579.index.js +0 -271
  128. package/dist/6655.index.js +0 -3221
  129. package/dist/6745.index.js +0 -305
  130. package/dist/6818.index.js +0 -21642
  131. package/dist/6932.index.js +0 -1190
  132. package/dist/6972.index.js +0 -167
  133. package/dist/6995.index.js +0 -163
  134. package/dist/7249.index.js +0 -191
  135. package/dist/7372.index.js +0 -1197
  136. package/dist/7546.index.js +0 -541
  137. package/dist/76.index.js +0 -448
  138. package/dist/7879.index.js +0 -163
  139. package/dist/8025.index.js +0 -130
  140. package/dist/8054.index.js +0 -248
  141. package/dist/808.index.js +0 -1330
  142. package/dist/8097.index.js +0 -201
  143. package/dist/8126.index.js +0 -191
  144. package/dist/8178.index.js +0 -78
  145. package/dist/8382.index.js +0 -93
  146. package/dist/8408.index.js +0 -261
  147. package/dist/8540.index.js +0 -174
  148. package/dist/8635.index.js +0 -802
  149. package/dist/8688.index.js +0 -410
  150. package/dist/8699.index.js +0 -779
  151. package/dist/8891.index.js +0 -457
  152. package/dist/8916.index.js +0 -885
  153. package/dist/9146.index.js +0 -170
  154. package/dist/9168.index.js +0 -841
  155. package/dist/9292.index.js +0 -369
  156. package/dist/9422.index.js +0 -866
  157. package/dist/9440.index.js +0 -453
  158. package/dist/9479.index.js +0 -152
  159. package/dist/9481.index.js +0 -220
  160. package/dist/9539.index.js +0 -100
  161. package/dist/9547.index.js +0 -103
  162. package/dist/9692.index.js +0 -90
  163. package/dist/9800.index.js +0 -462
  164. package/dist/9899.index.js +0 -138
  165. package/dist/9981.index.js +0 -206
  166. package/dist/999.index.js +0 -117
  167. package/dist/9999.index.js +0 -1698
@@ -0,0 +1,268 @@
1
+ // Cross-scan cache of parsed JavaScript / TypeScript IR (DAG-05, the stage the taint cache does not cover).
2
+ //
3
+ // The dependency-complete taint cache (incremental-session.js) removes most of the cost of the interprocedural
4
+ // engine on a re-scan, and what is left is dominated by parsing every file to IR again. This module keeps one
5
+ // record per source file so an unchanged file is read back instead of parsed. It changes the cost of building
6
+ // the IR and never its content:
7
+ //
8
+ // * A record is keyed by the SHA-256 of the file's path and text, the analyzer/parser digest (a change to any
9
+ // source under ir/, dataflow/, graph/ or language/, or to the bundle, invalidates every record) and the
10
+ // Babel versions the parse ran under. A different file text, parser or toolchain is a miss.
11
+ // * The IR numbers its CFG nodes from a process-global counter (`n123`). A restored file is given fresh ids
12
+ // from the same counter, in the same relative order, so an id is as unique across the process as one the
13
+ // parser made. Ids appear only in the fields remapped below; a file that holds an id anywhere else is
14
+ // never written.
15
+ // * A record is written only after the IR has been checked to survive JSON exactly: anything that would not
16
+ // (an undefined member, a non-finite number, a BigInt, a Map, an object with a foreign prototype, an id
17
+ // held in a field the remap does not know) is not cached, and that file is parsed every scan. Regular
18
+ // expressions, which JSON would turn into `{}`, are recorded by path and rebuilt on load.
19
+ // * Each record carries the SHA-256 of its payload; a truncated, edited or foreign file is a miss, never an
20
+ // error. Writes go to a temp file and are renamed into place.
21
+ //
22
+ // Scope: JavaScript and TypeScript only (the parser that owns the id counter). Every other language is parsed
23
+ // as before. Trust boundary: the same as the rest of the incremental state under .agentic-security/incremental
24
+ // (digests detect corruption; they are not a signature, so a hostile working tree that plants a consistent
25
+ // record could substitute an IR; a planted record must also match the file's exact text and the analyzer digest).
26
+
27
+ import * as crypto from 'node:crypto';
28
+ import * as fs from 'node:fs';
29
+ import * as path from 'node:path';
30
+ import { createRequire } from 'node:module';
31
+
32
+ import { reserveNodeIds } from '../ir/parser-js.js';
33
+ import { analyzerDigest } from './dep-fingerprint.js';
34
+ import { statePath, stateWritesEnabled } from '../posture/state-dir.js';
35
+
36
+ const IR_CACHE_FORMAT = 'ir-cache/1';
37
+ const ID_RE = /^n\d+$/;
38
+ const ID_NUM = (s) => Number(s.slice(1));
39
+
40
+ let _toolchain = null;
41
+ function toolchainDigest() {
42
+ if (_toolchain) return _toolchain;
43
+ const parts = [IR_CACHE_FORMAT, analyzerDigest()];
44
+ try {
45
+ const req = createRequire(import.meta.url);
46
+ for (const pkg of ['@babel/core', '@babel/preset-react', '@babel/preset-typescript', '@babel/parser']) {
47
+ try { parts.push(`${pkg}@${req(`${pkg}/package.json`).version}`); } catch { parts.push(`${pkg}@unresolved`); }
48
+ }
49
+ } catch { parts.push('toolchain@unresolved'); }
50
+ _toolchain = crypto.createHash('sha256').update(parts.join('\n')).digest('hex');
51
+ return _toolchain;
52
+ }
53
+
54
+ const sha = (s) => crypto.createHash('sha256').update(s).digest('hex');
55
+
56
+ // ── id handling ─────────────────────────────────────────────────────────────
57
+
58
+ /** Every node id of a file's IR, or null when the IR holds something this cache cannot place. */
59
+ function fileIds(ir) {
60
+ if (!ir || !Array.isArray(ir.functions)) return null;
61
+ const ids = [];
62
+ for (const fn of ir.functions) {
63
+ if (!fn || !fn.cfg || !fn.cfg.nodes || typeof fn.cfg.nodes !== 'object') return null;
64
+ for (const k of Object.keys(fn.cfg.nodes)) { if (!ID_RE.test(k)) return null; ids.push(k); }
65
+ }
66
+ return ids;
67
+ }
68
+
69
+ /**
70
+ * Give an IR read back from a record new ids. Throws when a reference names an id the file does not define:
71
+ * the caller treats that as a miss.
72
+ */
73
+ function remapIds(ir) {
74
+ const ids = fileIds(ir);
75
+ if (!ids) throw new Error('unplaceable ids');
76
+ ids.sort((a, b) => ID_NUM(a) - ID_NUM(b));
77
+ const base = reserveNodeIds(ids.length);
78
+ const map = new Map();
79
+ ids.forEach((id, i) => map.set(id, `n${base + i + 1}`));
80
+ const m = (id) => { const v = map.get(id); if (v === undefined) throw new Error(`dangling id ${id}`); return v; };
81
+ for (const fn of ir.functions) {
82
+ const cfg = fn.cfg;
83
+ cfg.entry = m(cfg.entry);
84
+ cfg.exit = m(cfg.exit);
85
+ const entries = Object.entries(cfg.nodes);
86
+ for (const [k, node] of entries) {
87
+ if (node.id !== k) throw new Error('node id differs from its key');
88
+ node.id = m(node.id);
89
+ if (!Array.isArray(node.succ) || !Array.isArray(node.pred)) throw new Error('node without edges');
90
+ node.succ = node.succ.map(m);
91
+ node.pred = node.pred.map(m);
92
+ }
93
+ cfg.nodes = Object.fromEntries(entries.map(([k, node]) => [m(k), node]));
94
+ fn.returns = (fn.returns || []).map(m);
95
+ for (const c of (fn.calls || [])) c.site = m(c.site);
96
+ for (const list of Object.values(fn.writes || {})) for (const w of list) w.node = m(w.node);
97
+ for (const list of Object.values(fn.reads || {})) { for (let i = 0; i < list.length; i++) list[i] = m(list[i]); }
98
+ }
99
+ return ir;
100
+ }
101
+
102
+ /**
103
+ * The IR as text with every node id replaced by its rank among the file's ids, and regular expressions kept
104
+ * distinct from `{}`. Two IRs that differ only in the ids they were numbered with give the same text. Used by
105
+ * the tests that prove a restored IR equals a fresh parse.
106
+ */
107
+ function canonical(ir) {
108
+ const ids = fileIds(ir);
109
+ if (!ids) return null;
110
+ ids.sort((a, b) => ID_NUM(a) - ID_NUM(b));
111
+ const rank = new Map(ids.map((id, i) => [id, `#${i}`]));
112
+ const walk = (v) => {
113
+ if (typeof v === 'string') return rank.has(v) ? rank.get(v) : v;
114
+ if (v instanceof RegExp) return { $re: [v.source, v.flags] };
115
+ if (Array.isArray(v)) return v.map(walk);
116
+ if (v && typeof v === 'object') {
117
+ const o = {};
118
+ for (const [k, x] of Object.entries(v)) o[rank.has(k) ? rank.get(k) : k] = walk(x);
119
+ return o;
120
+ }
121
+ return v;
122
+ };
123
+ return JSON.stringify(walk(ir));
124
+ }
125
+
126
+ /**
127
+ * One walk over a freshly parsed IR: is it exactly what JSON gives back (plus the regular expressions that are
128
+ * recorded by path), and does every node id sit in a field the remap knows? Returns { regexes } or null.
129
+ * `ids` is the set of the file's node ids.
130
+ */
131
+ function analyze(ir, ids) {
132
+ const regexes = [];
133
+ let idLike = 0;
134
+ const trail = [];
135
+ const walk = (v) => {
136
+ switch (typeof v) {
137
+ case 'string': if (ids.has(v)) idLike++; return true;
138
+ case 'boolean': return true;
139
+ case 'number': return Number.isFinite(v);
140
+ case 'object': break;
141
+ default: return false; // undefined, function, symbol, bigint
142
+ }
143
+ if (v === null) return true;
144
+ if (v instanceof RegExp) {
145
+ if (v.lastIndex !== 0) return false;
146
+ regexes.push([trail.slice(), v.source, v.flags]);
147
+ return true;
148
+ }
149
+ if (Array.isArray(v)) {
150
+ if (Object.getPrototypeOf(v) !== Array.prototype) return false;
151
+ for (let i = 0; i < v.length; i++) {
152
+ if (!(i in v)) return false; // a hole
153
+ trail.push(i);
154
+ const ok = walk(v[i]);
155
+ trail.pop();
156
+ if (!ok) return false;
157
+ }
158
+ return true;
159
+ }
160
+ if (Object.getPrototypeOf(v) !== Object.prototype) return false;
161
+ for (const k of Object.keys(v)) {
162
+ if (ids.has(k)) idLike++;
163
+ trail.push(k);
164
+ const ok = walk(v[k]);
165
+ trail.pop();
166
+ if (!ok) return false;
167
+ }
168
+ return Object.getOwnPropertySymbols(v).length === 0;
169
+ };
170
+ if (!walk(ir)) return null;
171
+ // The ids the remap handles: entry, exit, each node's key and id and edges, returns, call sites, writes, reads.
172
+ let known = 0;
173
+ for (const fn of ir.functions) {
174
+ known += 2 + (fn.returns || []).length + (fn.calls || []).length;
175
+ for (const node of Object.values(fn.cfg.nodes)) {
176
+ if (!Array.isArray(node.succ) || !Array.isArray(node.pred)) return null;
177
+ known += 2 + node.succ.length + node.pred.length;
178
+ }
179
+ for (const list of Object.values(fn.writes || {})) known += list.length;
180
+ for (const list of Object.values(fn.reads || {})) known += list.length;
181
+ }
182
+ return idLike === known ? { regexes } : null;
183
+ }
184
+
185
+ /** Put the recorded regular expressions back where JSON left `{}` (before ids are remapped: the paths use the old ids). */
186
+ function restoreRegexes(ir, regexes) {
187
+ for (const [trail, source, flags] of regexes) {
188
+ let holder = ir;
189
+ for (let i = 0; i < trail.length - 1; i++) holder = holder[trail[i]];
190
+ holder[trail[trail.length - 1]] = new RegExp(source, flags);
191
+ }
192
+ }
193
+
194
+ // ── the cache ───────────────────────────────────────────────────────────────
195
+
196
+ /**
197
+ * Open the cache under `<state>/incremental/ir`. `read` / `write` select what it may do (a scan with state
198
+ * writes off still reads). Returns an object with `load`, `store` and `stats`.
199
+ */
200
+ export function openIrCache(scanRoot, { read = true, write = stateWritesEnabled() } = {}) {
201
+ const dir = statePath(scanRoot, 'incremental', 'ir');
202
+ const stats = { hits: 0, misses: 0, stored: 0, skipped: 0, rejected: 0, loadMs: 0, storeMs: 0, bytesRead: 0, bytesWritten: 0 };
203
+ let tool = null;
204
+ const keyOf = (file, code) => sha(`${tool || (tool = toolchainDigest())}\0${file}\0${code}`);
205
+ const slotOf = (file) => path.join(dir, `${sha(file).slice(0, 40)}.ir.json`);
206
+
207
+ function readRecord(file, key) {
208
+ let text;
209
+ try { text = fs.readFileSync(slotOf(file), 'utf8'); } catch { return null; }
210
+ const nl = text.indexOf('\n');
211
+ if (nl < 0) return { bad: true };
212
+ let head;
213
+ try { head = JSON.parse(text.slice(0, nl)); } catch { return { bad: true }; }
214
+ if (!head || head.format !== IR_CACHE_FORMAT || head.key !== key) return null; // another file text or toolchain: a plain miss
215
+ const payload = text.slice(nl + 1);
216
+ stats.bytesRead += payload.length;
217
+ if (payload.length !== head.bytes || sha(payload) !== head.sha256 || !Array.isArray(head.regexes)) return { bad: true };
218
+ return { payload, regexes: head.regexes };
219
+ }
220
+
221
+ function load(file, code) {
222
+ if (!read) return undefined;
223
+ const t0 = performance.now();
224
+ try {
225
+ const key = keyOf(file, code);
226
+ const rec = readRecord(file, key);
227
+ if (!rec) { stats.misses++; return undefined; }
228
+ if (rec.bad) { stats.rejected++; return undefined; }
229
+ const parsed = JSON.parse(rec.payload);
230
+ restoreRegexes(parsed, rec.regexes);
231
+ const ir = remapIds(parsed);
232
+ if (!ir || ir.file !== file || !Array.isArray(ir.functions)) { stats.rejected++; return undefined; }
233
+ stats.hits++;
234
+ return ir;
235
+ } catch {
236
+ stats.rejected++;
237
+ return undefined;
238
+ } finally { stats.loadMs += performance.now() - t0; }
239
+ }
240
+
241
+ function store(file, code, ir) {
242
+ if (!write) return false;
243
+ const t0 = performance.now();
244
+ try {
245
+ const ids = fileIds(ir);
246
+ const found = ids ? analyze(ir, new Set(ids)) : null;
247
+ if (!found) { stats.skipped++; return false; }
248
+ const key = keyOf(file, code);
249
+ const payload = JSON.stringify(ir);
250
+ const head = JSON.stringify({ format: IR_CACHE_FORMAT, key, sha256: sha(payload), bytes: payload.length, file, regexes: found.regexes });
251
+ fs.mkdirSync(dir, { recursive: true });
252
+ const slot = slotOf(file);
253
+ const tmp = `${slot}.tmp-${process.pid}-${crypto.randomBytes(4).toString('hex')}`;
254
+ fs.writeFileSync(tmp, `${head}\n${payload}`, { mode: 0o600 });
255
+ fs.renameSync(tmp, slot);
256
+ stats.stored++;
257
+ stats.bytesWritten += payload.length;
258
+ return true;
259
+ } catch {
260
+ stats.skipped++;
261
+ return false;
262
+ } finally { stats.storeMs += performance.now() - t0; }
263
+ }
264
+
265
+ return { load, store, stats: () => ({ ...stats }), dir };
266
+ }
267
+
268
+ export const _internals = { remapIds, canonical, fileIds, analyze, restoreRegexes, toolchainDigest };
@@ -37,6 +37,7 @@ import {
37
37
  loadPrivacyTaxonomy, classifyFieldAgainst, severityForClasses,
38
38
  } from './privacy-taxonomy.js';
39
39
  import { loadPrivacySinkPolicy, isSinkPermitted, permittingRules } from './privacy-sink-policy.js';
40
+ import { isListenCallbackParam } from '../sast/listen-bind-address.js';
40
41
  import { GOVERNANCE_FIELDS, governanceRecordFor } from './privacy-governance.js';
41
42
 
42
43
  const _BUILTIN_COMPILED = compileTaxonomy(DEFAULT_TAXONOMY);
@@ -119,8 +120,14 @@ export function annotatePrivacyTaint(perFileIR, opts = {}) {
119
120
  // (evidence locations must cover both ends of a flow, not just the
120
121
  // sink) without re-deriving it from piiFields by proximity guessing.
121
122
  const declLineByName = new Map();
123
+ const lineStart = [];
124
+ { let off = 0; for (const l of lines) { lineStart.push(off); off += l.length + 1; } }
122
125
  for (const d of ir.decls || []) {
123
126
  const classes = classifyFieldAgainst(d.name, compiled);
127
+ // The name alone does not make a binding personal data: the `address` a `listen` callback
128
+ // receives is the bound socket address (see sast/listen-bind-address.js).
129
+ if (classes.length && Number.isInteger(d.line) && lineStart[d.line - 1] !== undefined
130
+ && isListenCallbackParam(ir._content, lineStart[d.line - 1] + Math.max(0, (lines[d.line - 1] || '').indexOf(d.name)), d.name)) continue;
124
131
  if (classes.length) {
125
132
  taintedVars.set(d.name, classes);
126
133
  declLineByName.set(d.name, d.line);
package/src/engine.js CHANGED
@@ -183,6 +183,10 @@ import { classifySecretCandidate as _entropyClassifySecret } from './sast/_secre
183
183
  // Phase 1 — precision-engineering posture modules.
184
184
  import { annotateConfidence, applyUnvalidatedPenalty } from './posture/confidence.js';
185
185
  import { backfillFindingDefaults } from './posture/finding-defaults.js';
186
+ import { redirectTargetPinsOriginAt, redirectArgumentPinsOrigin } from './sast/redirect-target.js';
187
+ import { responseReflectsRequestInputAt } from './sast/reflected-input.js';
188
+ import { sendFileConfinedToRootAt } from './sast/send-file-root.js';
189
+ import { insideFunctionBody } from './sast/init-scope.js';
186
190
  import { annotatePocs } from './posture/poc-generator.js';
187
191
  import { annotateExecutionProofs } from './posture/prove-findings.js';
188
192
  import { mineVulnHistory, annotateHistoricalRisk } from './posture/vuln-archaeology.js';
@@ -211,6 +215,7 @@ import { annotateNarration } from './posture/flow-narration.js';
211
215
  import { applyPathConstraints } from './posture/path-predicates.js';
212
216
  // Phase 3 (Sentinel-parity Layer 1 + 2) — IR + interprocedural taint engine.
213
217
  import { buildProjectIR, buildProjectIRAsync } from './ir/index.js';
218
+ import { openIrCache } from './dataflow/ir-cache.js';
214
219
  import _v8 from 'node:v8';
215
220
  import _vm from 'node:vm';
216
221
  import { createStringMemo } from './util/string-memo.js';
@@ -1721,12 +1726,43 @@ export function dropGuardedFindings(findings, fileContents, onDrop = null) {
1721
1726
  const dropped = (kind) => { if (onDrop) onDrop(f, { kind, line, evidence: ctx._guardEvidence || null }); return false; };
1722
1727
  if (cwe === 'CWE-918' && _hasSsrfHostGuard(ctx)) return dropped('ssrf-host-guard');
1723
1728
  if (cwe === 'CWE-22' && _hasPathGuard(ctx)) return dropped('path-containment-guard');
1729
+ if (cwe === 'CWE-22' && sendFileConfinedToRootAt(lines, line) === true) return dropped('send-file-root-confined');
1724
1730
  if (cwe === 'CWE-601' && _hasRedirectGuard(ctx)) return dropped('redirect-target-guard');
1731
+ if (cwe === 'CWE-601' && redirectTargetPinsOriginAt(lines, line) === true) return dropped('redirect-origin-pinned');
1725
1732
  if (isXss && _hasXssOutputEncoding(ctx)) return dropped('xss-output-encoding');
1726
1733
  return true;
1727
1734
  });
1728
1735
  }
1729
1736
 
1737
+ // Identifiers a sink call's argument text actually READS as variables. Two things in the raw text are not
1738
+ // variable reads and used to be counted as such, which let an unrelated source variable of the same name
1739
+ // "reach" the sink: (1) words inside string literals ('Viewing user ' + ...), and (2) property names after
1740
+ // a dot (req.user.name reads `req`, not a local called `user` or `name`). Template substitutions
1741
+ // (`${x}`) stay, since they are code. Spread (`...x`) is a read of x and stays.
1742
+ function _usedIdentifiers(args){
1743
+ const t=String(args);let code='';
1744
+ for(let i=0;i<t.length;i++){
1745
+ const c=t[i];
1746
+ if(c==='"'||c==="'"){const q=c;i++;while(i<t.length&&t[i]!==q){if(t[i]==='\\')i++;i++;}code+=' ';}
1747
+ else if(c==='`'){
1748
+ i++;
1749
+ while(i<t.length&&t[i]!=='`'){
1750
+ if(t[i]==='\\'){i+=2;continue;}
1751
+ if(t[i]==='$'&&t[i+1]==='{'){let d=1;i+=2;code+=' ';while(i<t.length&&d>0){if(t[i]==='{')d++;else if(t[i]==='}'){d--;if(d===0)break;}code+=t[i];i++;}i++;continue;}
1752
+ i++;
1753
+ }
1754
+ code+=' ';
1755
+ } else code+=c;
1756
+ }
1757
+ const out=new Set();
1758
+ const re=/\b[a-zA-Z_]\w*\b/g;let m;
1759
+ while((m=re.exec(code))){
1760
+ let j=m.index-1;while(j>=0&&/\s/.test(code[j]))j--;
1761
+ if(j>=0&&code[j]==='.'&&!(j>0&&code[j-1]==='.'))continue; // property name, not a variable read
1762
+ out.add(m[0]);
1763
+ }
1764
+ return [...out];
1765
+ }
1730
1766
  function _detectSafeSinkShape(vuln, args, ctx){
1731
1767
  // 1.1: Reflected XSS / SSRF / Path Traversal / Open Redirect / Code Injection
1732
1768
  // sinks with empty or whitespace-only args cannot reflect user input — skip
@@ -1766,7 +1802,10 @@ function _detectSafeSinkShape(vuln, args, ctx){
1766
1802
  if(/Open Redirect/.test(vuln)) {
1767
1803
  // Static redirect targets (env var or string literal) are not user-controlled
1768
1804
  if (/^\s*process\.env\b/.test(args)) return 'static-redirect-target';
1769
- if (/^\s*['"`]/.test(args)) return 'static-redirect-target';
1805
+ // A literal that fixes the origin ('/user/' + id) cannot be steered off-origin by the
1806
+ // appended part; a literal that does not ('/' + x, '//' + x, '' + x) can, so the mere
1807
+ // presence of a leading quote is not evidence of a static target.
1808
+ if (/^\s*['"`]/.test(args) && redirectArgumentPinsOrigin(args)) return 'static-redirect-target';
1770
1809
  }
1771
1810
  if(/IDOR/.test(vuln)) {
1772
1811
  if (_IDOR_OWNERSHIP_RE.test(args)) return 'ownership-clause';
@@ -1919,11 +1958,11 @@ function performRegexAnalysis(fp,raw){if(_INTENTIONAL_VULN_PATH_RE.test(fp.repla
1919
1958
  const line=lineAt(cleaned,m.index);const lt=lines[line-1]||"";
1920
1959
  // FP-7: per-pattern surrounding-context gate — required call must appear in nearby lines
1921
1960
  if(sp.contextRe){const surround=lines.slice(Math.max(0,line-5),Math.min(lines.length,line+2)).join("\n");if(!sp.contextRe.test(surround))continue;}
1922
- const af=raw.substring(m.index,Math.min(raw.length,m.index+500));const am=af.match(/\(((?:[^()]|\([^()]*\)){0,400})\)/);const args=am?am[1]:"";const uv=[...new Set((args.match(/\b[a-zA-Z_]\w*\b/g)||[]).filter(v=>!["true","false","null","undefined","const","let","var","function","return","if","else","new","this","async","await","typeof","instanceof","void"].includes(v)&&v.length>1))];const safeShape=_detectSafeSinkShape(sp.vuln,args,{lines,line});sinks.push({type:sp.type,severity:sp.severity,vuln:sp.vuln,cwe:sp.cwe,stride:sp.stride,line,file:fp,snippet:lt.trim(),usedVars:uv,args:args.trim(),safeShape});}}
1961
+ const af=raw.substring(m.index,Math.min(raw.length,m.index+500));const am=af.match(/\(((?:[^()]|\([^()]*\)){0,400})\)/);const args=am?am[1]:"";const uv=_usedIdentifiers(args).filter(v=>!["true","false","null","undefined","const","let","var","function","return","if","else","new","this","async","await","typeof","instanceof","void"].includes(v)&&v.length>1);const safeShape=_detectSafeSinkShape(sp.vuln,args,{lines,line});sinks.push({type:sp.type,severity:sp.severity,vuln:sp.vuln,cwe:sp.cwe,stride:sp.stride,line,file:fp,snippet:lt.trim(),usedVars:uv,args:args.trim(),safeShape});}}
1923
1962
  for(const sp of SANITIZER_PATTERNS){const re=new RegExp(sp.regex.source,sp.regex.flags);let m;while((m=re.exec(cleaned))){const line=lineAt(cleaned,m.index);const lt=lines[line-1]||"";const am=lt.match(/(?:const|let|var|)\s*(\w+)\s*=/)||lt.match(/(\w+)\s*=/);sanitizers.push({type:sp.type,line,file:fp,snippet:lt.trim(),outputVar:am?am[1]:null});}}
1924
1963
  const tv=new Map();for(const src of sources)if(src.variable)tv.set(src.variable,{source:src,path:[{type:"source",label:"Input: "+src.label,line:src.line,snippet:src.snippet}],sanitized:false,sanitizerType:null});
1925
1964
  for(let i=0;i<lines.length;i++){const lt=lines[i];const am=lt.match(/\b(?:const|let|var)\s+(\w+)(?:\s*:[^=]+?)?\s*=\s*(.+)/)||lt.match(/(?:const|let|var|)\s*(\w+)\s*=(?!=)\s*(.+)/);if(!am)continue;const[,dv,rhs]=am;if(tv.has(dv))continue;for(const[tn,ti]of tv){if(!new RegExp(`\\b${tn}\\b`).test(rhs))continue;let san=false,st=null;for(const s of sanitizers)if(s.line===i+1){san=true;st=s.type;break;}if(!san)for(const sp of SANITIZER_PATTERNS)if(new RegExp(sp.regex.source,sp.regex.flags).test(rhs)){san=true;st=sp.type;break;}tv.set(dv,{source:ti.source,path:[...ti.path,{type:san?"sanitizer":"propagation",label:san?`${st} on ${dv}`:`Assigned to "${dv}"`,line:i+1,snippet:lt.trim(),sanitized:san,sanitizerType:st}],sanitized:san,sanitizerType:st});break;}}
1926
- for(const sink of sinks){const safeShapeDowngrade=sink.safeShape?{isSan:true,sanType:sink.safeShape}:null;for(const src of sources){const sv=src.variable;let reached=false,pp=[],isSan=false,st=null;if(sv&&sink.usedVars.includes(sv)){const ti=tv.get(sv);if(ti){reached=true;pp=ti.path;isSan=!!ti.sanitized;st=ti.sanitizerType;}}if(!reached)for(const uv of sink.usedVars)if(tv.has(uv)){const ti=tv.get(uv);if(ti.source===src||ti.source.label.includes(src.label)){reached=true;pp=ti.path;isSan=!!ti.sanitized;st=ti.sanitizerType;break;}}if(!reached&&sv&&sink.line>=src.line&&sink.line-src.line<200&&((sink.args&&new RegExp(`\\b${sv}\\b`).test(sink.args))||lines.slice(Math.max(0,sink.line-10),sink.line+5).some(l=>{const re=new RegExp(`\\b${sv}\\b`);if(!re.test(l))return false;/* skip re-declarations: different binding, not a use */if(new RegExp(`\\b(?:const|let|var|function|def)\\s+${sv}\\b`).test(l))return false;return true;}))){reached=true;pp=[{type:"source",label:"Input: "+src.label,line:src.line,snippet:src.snippet}];
1965
+ for(const sink of sinks){const safeShapeDowngrade=sink.safeShape?{isSan:true,sanType:sink.safeShape}:null;for(const src of sources){const sv=src.variable;let reached=false,pp=[],isSan=false,st=null;if(sv&&sink.usedVars.includes(sv)){const ti=tv.get(sv);if(ti){reached=true;pp=ti.path;isSan=!!ti.sanitized;st=ti.sanitizerType;}}if(!reached)for(const uv of sink.usedVars)if(tv.has(uv)){const ti=tv.get(uv);if(ti.source===src||ti.source.label.includes(src.label)){reached=true;pp=ti.path;isSan=!!ti.sanitized;st=ti.sanitizerType;break;}}if(!reached&&sv&&sink.line>=src.line&&sink.line-src.line<200&&((sink.args&&sink.usedVars.includes(sv))||lines.slice(Math.max(0,sink.line-10),sink.line+5).some(l=>{if(!_usedIdentifiers(l).includes(sv))return false;/* skip re-declarations: different binding, not a use */if(new RegExp(`\\b(?:const|let|var|function|def)\\s+${sv}\\b`).test(l))return false;return true;}))){reached=true;pp=[{type:"source",label:"Input: "+src.label,line:src.line,snippet:src.snippet}];
1927
1966
  // FP-3: only credit a sanitizer here if it has a captured outputVar AND that var
1928
1967
  // is what reaches the sink. A bare `escape(s);` (return discarded) does NOT count.
1929
1968
  for(const san of sanitizers){
@@ -2055,6 +2094,9 @@ const STRUCTURAL_VULN_PATTERNS=[
2055
2094
  // ── XSS / Output Encoding ─────────────────────────────────────────────────
2056
2095
  {regex:/res\s*\.\s*(?:send|write|end)\s*\([^;)]*(?:req\.|\.body\.|\.query\.|\.params\.)[^;)]{0,200}\)/g,
2057
2096
  type:"HTTP Response",vuln:"Reflected XSS (User Input in Response)",severity:"high",cwe:"CWE-79",stride:"Tampering",
2097
+ // The call text mentions `req.`, which also carries session, user and app state. Fire only when the
2098
+ // arguments reflect raw request INPUT (body/query/params/headers/...) that is not inside an HTML escaper.
2099
+ predicate:(_m,ctx)=>{const r=responseReflectsRequestInputAt(ctx.lines,ctx.line);return r===false?{fire:false,reason:'no-raw-request-input'}:{fire:true};},
2058
2100
  fix:"Encode all user-supplied data with escapeHtml() before sending in responses"},
2059
2101
  {regex:/bypassSecurityTrust(?:Html|Script|Style|Url|ResourceUrl)\s*\(/g,
2060
2102
  type:"Angular Trust Bypass",vuln:"Angular DomSanitizer Bypass (XSS)",severity:"critical",cwe:"CWE-79",stride:"Tampering",
@@ -2292,6 +2334,9 @@ const STRUCTURAL_VULN_PATTERNS=[
2292
2334
  // ── #25: Synchronous blocking I/O in server context (DoS) ───────────────────
2293
2335
  {regex:/\b(?:fs\.readFileSync|fs\.writeFileSync|fs\.appendFileSync|fs\.readdirSync|fs\.statSync|fs\.existsSync|crypto\.pbkdf2Sync)\s*\(/g,
2294
2336
  type:"Blocking I/O",vuln:"Synchronous Blocking I/O (DoS Risk in Server Context)",severity:"medium",cwe:"CWE-400",stride:"Denial of Service",appliesTo:["server"],
2337
+ // Only a blocking call made while serving stalls other work. At module/initialisation level (reading a TLS
2338
+ // key or config once at startup) it delays startup once; nothing is waiting on the event loop yet.
2339
+ predicate:(_m,ctx)=>ctx.haystack&&Number.isInteger(ctx.index)&&!insideFunctionBody(ctx.haystack,ctx.index)?{fire:false,reason:'module-init-scope'}:{fire:true},
2295
2340
  fix:"Replace with async equivalents: fs.promises.readFile(), fs.promises.stat(), util.promisify(crypto.pbkdf2)()"},
2296
2341
  // ── #26: Body parser without size limit (DoS — large payload / Billion Laughs) ─
2297
2342
  {regex:/(?:app|router)\s*\.\s*use\s*\(\s*express\s*\.\s*(?:json|urlencoded)\s*\(\s*\)\s*\)/g,
@@ -2524,7 +2569,7 @@ function scanStructuralVulns(fp, raw, _suppOut) {
2524
2569
  // FP-8: per-pattern predicate gate. If `predicate` returns {fire:false},
2525
2570
  // the finding is suppressed (and logged for --include-suppressed).
2526
2571
  if (typeof pat.predicate === 'function') {
2527
- const verdict = pat.predicate(m[0], { file: fp, line, snippet, lines, raw, cleanedNoise });
2572
+ const verdict = pat.predicate(m[0], { file: fp, line, snippet, lines, raw, cleanedNoise, haystack, index: m.index });
2528
2573
  if (verdict && !verdict.fire) {
2529
2574
  _supp.push({vuln:pat.vuln, file:fp, line, snippet, reason:'predicate-pass:'+(verdict.reason||'ok')});
2530
2575
  continue;
@@ -2846,6 +2891,10 @@ function _pragmaSuppresses(pragma, f){
2846
2891
  // `line` property at all (the line survives only inside the id string, e.g.
2847
2892
  // `struct:app.js:22:Mass_Assignment`), so a false positive from one of those
2848
2893
  // cannot be silenced by a pragma and has to be fixed at the source instead.
2894
+ // UPDATE: `backfillFindingDefaults` now sets `line` from `sink.line` (then `source.line`) when a
2895
+ // detector left it off, so the line-less population is dependency and file-level findings. This
2896
+ // function already read `f.sink?.line`, so the backfill mostly makes the schema's `line` true rather
2897
+ // than newly enabling suppression.
2849
2898
  //
2850
2899
  // A file-scoped fallback was considered and REJECTED. Widening a line pragma to
2851
2900
  // a whole file would silently suppress findings the author never looked at, and
@@ -9466,7 +9515,15 @@ function _deterministicFileTimings(timings) {
9466
9515
  // import) for projects that contain no Java. `runFullScan` is already async,
9467
9516
  // so the await costs nothing structurally.
9468
9517
  const _hasJava = Object.keys(fc || {}).some(f => /\.java$/i.test(f));
9469
- const _buildIR = async () => (_hasJava ? await buildProjectIRAsync(fc) : buildProjectIR(fc));
9518
+ // DAG-05: with the cross-scan cache on, an unchanged JavaScript/TypeScript file is read back from the IR cache
9519
+ // instead of parsed again (dataflow/ir-cache.js; the content is the same, only the cost differs).
9520
+ const _buildIR = async () => {
9521
+ let irCache = null;
9522
+ if (process.env.AGENTIC_SECURITY_INCREMENTAL === '1' && scanRoot) {
9523
+ try { irCache = openIrCache(scanRoot); } catch { irCache = null; }
9524
+ }
9525
+ return _hasJava ? await buildProjectIRAsync(fc, { irCache }) : buildProjectIR(fc, { irCache });
9526
+ };
9470
9527
  const _irStatsTarget = irStatsTarget();
9471
9528
  if (_irStatsTarget) {
9472
9529
  try {
package/src/ir/CLAUDE.md CHANGED
@@ -267,6 +267,10 @@ down enterprise runners sometimes don't. Targets for retirement:
267
267
  interprocedural analysis, not just the older, vaguer "some constructs get
268
268
  dropped" cost.
269
269
 
270
+ ## Cross-scan IR cache and node ids
271
+
272
+ `buildProjectIR(files, { irCache })` and `buildProjectIRAsync` accept an optional cache object (`dataflow/ir-cache.js`, opened by `engine.js` only when the cross-scan cache is on): an unchanged JavaScript or TypeScript file is read back instead of parsed, and a new parse is stored. With no cache the behaviour is exactly as before. Node ids (`n123`) come from a process-global counter in `parser-js.js`; a restored file is renumbered from the same counter through `reserveNodeIds(count)` so ids stay unique across the process. The ids sit in `cfg.entry`, `cfg.exit`, the `cfg.nodes` keys and each node's `id`/`succ`/`pred`, `fn.returns`, `fn.calls[].site`, `fn.writes[*][].node` and `fn.reads[*][]`; if a parser change puts an id anywhere else, the store-time check refuses to cache that file (it counts as `skipped` in the cache's `stats()`, and the file is parsed on every scan), so a new id-bearing field costs speed, never correctness. Name resolution in `callgraph.js` is answered from a name-to-candidates index (`qidsByName`, built in the order the per-file tables were built), so a name that no file defines is a lookup, not a scan of every file.
273
+
270
274
  ## Semantic graph (`semantic-graph.js`, engineering-excellence E2-01)
271
275
 
272
276
  An additive, typed, versioned view over this IR: `Symbol`, `CallSite`, `Allocation`, `FieldAccess`, `ValueOrigin`, plus the schema-only lists `ServiceBoundary`, `StorageEdge`, `Sanitizer`, `Guard`. Ids are content-derived and never include a line, so a blank line above a function moves no id. It reads the CFG (the one structure every language's IR shares; `writes` exists for JavaScript only). Nothing on the scan path imports it, so findings and `chain[]` cannot change; a test pins that. Columns are `null` (the IR has lines only) and say `locationPrecision: "line"`. Contract and the measured per-language construct matrix: `docs/engineering-excellence/IR_CONTRACT.md` (regenerate with `node scripts/engineering-excellence/ir-matrix.mjs`). Do not add a `site` field: IR site ids come from a process-global counter and are not stable across builds.
@@ -272,6 +272,32 @@ export function buildCallGraph(perFileIR, fileContents) {
272
272
  // `File::read` in C++): a fabricated cross-language edge, reached through
273
273
  // these older, coarser lookups BEFORE the qname-specific gate further down
274
274
  // is ever consulted.
275
+ // Name -> candidate qids across all files, in the order the file tables were
276
+ // built. The permissive lookups below used to walk EVERY file's table for each
277
+ // name that was not found locally, which is every call to a library method
278
+ // (`push`, `get`, `map`): thousands of names times hundreds of files. Visiting
279
+ // only the tables that hold the name, in the same order, returns the same
280
+ // first usable candidate.
281
+ const qidsByName = new Map();
282
+ for (const m of byNameInFile.values()) {
283
+ for (const [nm, q] of m) {
284
+ let l = qidsByName.get(nm);
285
+ if (!l) { l = []; qidsByName.set(nm, l); }
286
+ l.push(q);
287
+ }
288
+ }
289
+ // Re-export entries that can match a name (the key ends with `::<name>`, or the
290
+ // source name equals it), by index into the insertion order.
291
+ const reexportEntries = [...reexportMap];
292
+ const reexportCandidates = new Map();
293
+ if (reexportEntries.length) {
294
+ const addCand = (nm, i) => { let l = reexportCandidates.get(nm); if (!l) { l = new Set(); reexportCandidates.set(nm, l); } l.add(i); };
295
+ reexportEntries.forEach(([key, v], i) => {
296
+ let at = key.indexOf('::');
297
+ while (at >= 0) { addCand(key.slice(at + 2), i); at = key.indexOf('::', at + 1); }
298
+ addCand(v.sourceName, i);
299
+ });
300
+ }
275
301
  function isCrossLanguageUnsafe(qid, callerFile) {
276
302
  const cand = qid && functions.get(qid);
277
303
  if (!cand || !cand.qname) return false;
@@ -339,23 +365,25 @@ export function buildCallGraph(perFileIR, fileContents) {
339
365
  if (viaThis) return viaThis;
340
366
  }
341
367
  }
342
- for (const m of byNameInFile.values()) {
343
- if (m.has(name) && !isCrossLanguageUnsafe(m.get(name), callerFile)) return m.get(name);
368
+ for (const q of qidsByName.get(name) || []) {
369
+ if (!isCrossLanguageUnsafe(q, callerFile)) return q;
344
370
  }
345
371
  if (classMethods.has(name) && !isCrossLanguageUnsafe(classMethods.get(name), callerFile)) {
346
372
  return classMethods.get(name);
347
373
  }
348
374
  if (allowTailGuess && name.includes('.')) {
349
375
  const tail = name.split('.').slice(-1)[0];
350
- for (const m of byNameInFile.values()) {
351
- if (m.has(tail) && !isCrossLanguageUnsafe(m.get(tail), callerFile)) return m.get(tail);
376
+ for (const q of qidsByName.get(tail) || []) {
377
+ if (!isCrossLanguageUnsafe(q, callerFile)) return q;
352
378
  }
353
379
  }
354
380
  // Follow re-exports: if name was re-exported from another file, resolve there
355
- for (const [key, { sourceName }] of reexportMap) {
356
- if (key.endsWith(`::${name}`) || (sourceName === name)) {
357
- for (const m of byNameInFile.values()) {
358
- if (m.has(sourceName) && !isCrossLanguageUnsafe(m.get(sourceName), callerFile)) return m.get(sourceName);
381
+ const reCands = reexportCandidates.get(name);
382
+ if (reCands) {
383
+ for (const i of [...reCands].sort((a, b) => a - b)) {
384
+ const { sourceName } = reexportEntries[i][1];
385
+ for (const q of qidsByName.get(sourceName) || []) {
386
+ if (!isCrossLanguageUnsafe(q, callerFile)) return q;
359
387
  }
360
388
  }
361
389
  }
package/src/ir/index.js CHANGED
@@ -132,10 +132,23 @@ function _parsePythonFiles(pyEntries) {
132
132
  return pyEntries.map(({ file, content }) => parsePythonFileRegex(file, content)).filter(Boolean);
133
133
  }
134
134
 
135
+ // JavaScript / TypeScript parse through the optional cross-scan IR cache (dataflow/ir-cache.js): an unchanged
136
+ // file is read back with fresh node ids instead of parsed again, and a new parse is stored. `irCache` is an
137
+ // injected object ({load, store}); with none, this is exactly parseJsFile.
138
+ function parseJsCached(file, code, irCache) {
139
+ if (!irCache) return parseJsFile(file, code);
140
+ const hit = irCache.load(file, code);
141
+ if (hit) return hit;
142
+ const ir = parseJsFile(file, code);
143
+ if (ir) irCache.store(file, code, ir);
144
+ return ir;
145
+ }
146
+
135
147
  // Synchronous default — JS/TS + Python only. Engine.js calls this directly.
136
148
  // Java IR requires async import of java-parser; callers who want it can use
137
149
  // buildProjectIRAsync instead.
138
- export function buildProjectIR(fileContents) {
150
+ export function buildProjectIR(fileContents, opts = {}) {
151
+ const irCache = opts && opts.irCache ? opts.irCache : null;
139
152
  const perFile = {};
140
153
  const pyBatch = [];
141
154
  const hsFiles = {};
@@ -148,7 +161,7 @@ export function buildProjectIR(fileContents) {
148
161
  // null — it does not zero out every other language's coverage.
149
162
  try {
150
163
  if (/\.(?:js|jsx|ts|tsx|mjs|cjs)$/i.test(file)) {
151
- const ir = parseJsFile(file, code);
164
+ const ir = parseJsCached(file, code, irCache);
152
165
  if (ir) perFile[file] = ir;
153
166
  } else if (/\.hs$/i.test(file)) {
154
167
  hsFiles[file] = code; // lowered together: Haskell resolution is whole-program (imports, re-exports)
@@ -238,7 +251,8 @@ function withHaskellCallGraph(cg, hsCg) {
238
251
  }
239
252
 
240
253
  // Async variant — includes Java IR via java-parser.
241
- export async function buildProjectIRAsync(fileContents) {
254
+ export async function buildProjectIRAsync(fileContents, opts = {}) {
255
+ const irCache = opts && opts.irCache ? opts.irCache : null;
242
256
  const perFile = {};
243
257
  const pyBatch = [];
244
258
  const hsFiles = {};
@@ -247,7 +261,7 @@ export async function buildProjectIRAsync(fileContents) {
247
261
  // file can't abort IR construction for the whole project.
248
262
  try {
249
263
  if (/\.(?:js|jsx|ts|tsx|mjs|cjs)$/i.test(file)) {
250
- const ir = parseJsFile(file, code);
264
+ const ir = parseJsCached(file, code, irCache);
251
265
  if (ir) perFile[file] = ir;
252
266
  } else if (/\.hs$/i.test(file)) {
253
267
  hsFiles[file] = code;
@@ -46,6 +46,16 @@ import presetTypescript from '@babel/preset-typescript';
46
46
 
47
47
  let _nodeIdSeq = 0;
48
48
  function nextNodeId() { return 'n' + (++_nodeIdSeq); }
49
+ /**
50
+ * Reserve `count` consecutive node ids and return the sequence value before them
51
+ * (the first reserved id is `n<base + 1>`). A cached IR is given fresh ids from
52
+ * here, so an id is as unique across the whole process as one the parser made.
53
+ */
54
+ export function reserveNodeIds(count) {
55
+ const base = _nodeIdSeq;
56
+ _nodeIdSeq += count;
57
+ return base;
58
+ }
49
59
 
50
60
  // Round-5 lineage-engine finding, generalized in round 6: resolve a Babel
51
61
  // object-property-shaped node's key (`ObjectProperty` from either an
@@ -96,6 +96,19 @@ Related, outside this directory: `dataflow/catalog-haskell.js`, `lineage/haskell
96
96
  - **Bounded**: `maxEvaluations`, `maxExprDepth`, `maxValueSize` are caps that surface as `truncated`, not as a guess.
97
97
  - Assumption stated, not proven: `lib` is nixpkgs' `lib` whenever the module declares a `lib` parameter and the file does not rebind it.
98
98
 
99
+ ## Declared option types (`nix-option-types.js`)
100
+
101
+ `buildOptionTypeIndex` reads `mkOption { type = ...; }`, `mkEnableOption` and `mkPackageOption` declarations from the files in scope and
102
+ records, per dotted option path, whether the declared type BOUNDS the value (bool, int and `ints.*`, port, float, number, package, a
103
+ literal-word `enum`, and `nullOr`/`either`/`uniq` of bounded types). `nix-script-taint.js` `Evaluator.refine` turns a reference to a
104
+ bounded option into a constant, so a shell interpolation of an `int` option is not an injection and a `bool` option called `forwardPass`
105
+ is not a credential. Rules to keep: only options DECLARED in scope are known (an upstream option keeps its origin, except that the
106
+ module-system convention for a package option, `package`/`finalPackage`/`fooPackage`, is a store path and a declared option of that name
107
+ with an unbounded type is NOT); two declarations of one option are bounded only if both are; `str`, `path`, `lines`, `listOf`,
108
+ `submodule` and anything computed are unbounded. `nix-secrets.js` skips documentation attributes of `mkOption` (`example`,
109
+ `description`, `defaultText`; `default` is configuration and is not skipped) and does not treat a set-valued binding as a credential.
110
+ Pinned both ways in `test/nix/nix-option-types.test.js`.
111
+
99
112
  ## Tests and benches
100
113
 
101
114
  | Scope | Command | Notes |