@clear-capabilities/agentic-security-scanner 0.195.22 → 0.195.24

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 (45) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/3596.index.js +1 -1
  3. package/dist/550.index.js +9 -8
  4. package/dist/5620.index.js +59 -10
  5. package/dist/5937.index.js +88 -16
  6. package/dist/7838.index.js +32 -5
  7. package/dist/9441.index.js +50 -7
  8. package/dist/9824.index.js +19 -13
  9. package/dist/agentic-security.mjs +9 -9
  10. package/dist/agentic-security.mjs.sha256 +1 -1
  11. package/dist/{c1e282247b06d51c8ffb.json → daefda0819bb18309b75.json} +3 -3
  12. package/package.json +3 -3
  13. package/src/discovery/detector-spec-synthesis.js +3 -3
  14. package/src/discovery/llm-invoke.js +3 -3
  15. package/src/egress/transport.js +148 -0
  16. package/src/lineage/CLAUDE.md +12 -0
  17. package/src/lineage/DESIGN_GRAPH_BUILDER.md +23 -0
  18. package/src/lineage/driver.js +19 -2
  19. package/src/lineage/engine.js +24 -1
  20. package/src/lineage/graph-builder.js +3 -1
  21. package/src/lineage/lineage-source-catalog.js +143 -0
  22. package/src/lineage/source-seeding.js +142 -16
  23. package/src/lineage/summaries.js +21 -5
  24. package/src/llm-validator/index.js +4 -2
  25. package/src/llm-validator/local-endpoint.js +4 -1
  26. package/src/llm-validator/ollama-provider.js +8 -6
  27. package/src/posture/adversary-agent.js +27 -10
  28. package/src/posture/custom-rules.js +13 -1
  29. package/src/posture/fleet/cli.js +2 -4
  30. package/src/posture/fleet/fleet-manifest.js +4 -1
  31. package/src/posture/fleet/job-runner.js +28 -4
  32. package/src/posture/fleet/org-health.js +23 -0
  33. package/src/posture/fleet/scan-cache.js +1 -1
  34. package/src/posture/flow-narration.js +10 -8
  35. package/src/posture/llm-redteam.js +3 -1
  36. package/src/posture/provenance/coordinator.js +8 -0
  37. package/src/posture/provenance/origin-resolver.js +17 -4
  38. package/src/posture/rule-compiler/cli.js +56 -8
  39. package/src/posture/rule-compiler/corpus.js +10 -2
  40. package/src/posture/rule-compiler/ir.js +10 -1
  41. package/src/posture/rule-compiler/promotion.js +12 -2
  42. package/src/posture/rule-compiler/regression.js +1 -1
  43. package/src/posture/rule-compiler/store.js +27 -7
  44. package/src/posture/strategic-metrics.js +50 -7
  45. package/src/sca/llm-function-extract.js +17 -13
@@ -0,0 +1,143 @@
1
+ //
2
+ // lineage-source-catalog.js — JavaScript data collection points that are
3
+ // lineage sources but NOT taint sources (GA plan, GA-03.1: "address
4
+ // source-category omissions without broadening the main taint catalog
5
+ // incorrectly").
6
+ //
7
+ // Four of the fourteen reachable `SOURCE_CATEGORIES` had no `language: 'js'`
8
+ // entry in `dataflow/catalog.js` at all: `cli-argument` (only `py-sys-argv`
9
+ // and the argparse entries), `http-upload` (only Django and Symfony),
10
+ // `storage-read` (only `py-open-read` and the C `read`/`fread`/`fgets`
11
+ // entries) and `user-input` (only `py-input`, `gets`, `scanf`). The lineage
12
+ // corpus therefore could not mint a source node for any of them from a
13
+ // JavaScript fixture, and the four corpus entries that name them were
14
+ // written in Python syntax on purpose, as a disclosed limitation.
15
+ //
16
+ // Why these entries live HERE and not in `dataflow/catalog.js`'s `CATALOG`:
17
+ // that catalog is the taint engine's source list, and every entry in it
18
+ // becomes an attacker-controlled input for every SAST and taint finding the
19
+ // engine emits. A file's contents (`fs.readFileSync`) and a console prompt
20
+ // (`prompt()`, `readlineSync.question()`) are places personal data ENTERS a
21
+ // program, which is what a lineage graph records, but treating them as
22
+ // attacker-controlled would turn every configuration read into a taint
23
+ // source and flood the SAST output with findings nobody asked for. The
24
+ // Data Flow Explorer already keeps a second, isolated catalog for the same
25
+ // reason (`dataflow/privacy-catalog.js`, never merged into `CATALOG`); this
26
+ // module follows that precedent on the source side. `test/lineage/
27
+ // lineage-source-catalog.test.js` pins that `dataflow/catalog.js`'s own
28
+ // `matchSource` returns null for every shape listed here, so adding an
29
+ // entry here can never widen the taint engine by accident.
30
+ //
31
+ // Reuse boundary: imports nothing from `dataflow/`. The matcher below is a
32
+ // deliberately small structural match over the parser's expression shape
33
+ // (`ident`/`member`/`call`), not a copy of `catalog.js`'s index machinery;
34
+ // an entry that needs receiver types, keyword arguments or a language family
35
+ // other than JavaScript belongs in the main catalog, with the taint
36
+ // consequences weighed there.
37
+
38
+ import { reclassifySource } from './source-registry.js';
39
+
40
+ const JS_FILE = /\.(?:[cm]?js|jsx|tsx?)$/i;
41
+
42
+ /**
43
+ * Each entry mirrors the main catalog's source shape (`kind`, `id`,
44
+ * `language`, `framework`, `match`, `label`, `provenance`) so
45
+ * `reclassifySource` reads it unchanged: `provenance` is the key the
46
+ * registry's `PROVENANCE_MAP` resolves to a `SOURCE_CATEGORIES` value.
47
+ * `lineageOnly: true` is the marker a reader greps for; nothing consults it.
48
+ */
49
+ export const LINEAGE_SOURCE_CATALOG = Object.freeze([
50
+ {
51
+ kind: 'source', id: 'js-process-argv', language: 'js', framework: 'node', lineageOnly: true,
52
+ match: { type: 'member', object: 'process', prop: 'argv' }, label: 'process.argv', provenance: 'cli',
53
+ why: 'a positional command-line argument; the value is whatever the operator typed, not attacker-controlled in the taint sense, so it is a collection point for lineage only',
54
+ },
55
+ {
56
+ kind: 'source', id: 'js-multer-req-file', language: 'js', framework: 'express', lineageOnly: true,
57
+ match: { type: 'member', object: 'req', prop: 'file' }, label: 'req.file', provenance: 'upload',
58
+ why: 'the single uploaded file multer attaches to the request (name, type, buffer, path)',
59
+ },
60
+ {
61
+ kind: 'source', id: 'js-multer-req-files', language: 'js', framework: 'express', lineageOnly: true,
62
+ match: { type: 'member', object: 'req', prop: 'files' }, label: 'req.files', provenance: 'upload',
63
+ why: 'the uploaded files multer attaches to the request, keyed by field name',
64
+ },
65
+ {
66
+ kind: 'source', id: 'js-fs-readFileSync', language: 'js', framework: 'node', lineageOnly: true,
67
+ match: { type: 'call', callee: 'readFileSync' }, label: 'fs.readFileSync()', provenance: 'file-read',
68
+ why: 'the contents of a file; the callback form `fs.readFile(p, cb)` delivers its contents to a callback and is not seeded (its path argument is already a sink in the main catalog)',
69
+ },
70
+ {
71
+ kind: 'source', id: 'js-window-prompt', language: 'js', framework: 'browser', lineageOnly: true,
72
+ match: { type: 'call', callee: 'prompt', bare: true }, label: 'prompt()', provenance: 'stdin',
73
+ why: 'text a person typed into the browser prompt dialog; only the bare global, never `x.prompt()` (inquirer and friends return a promise of an answers object)',
74
+ },
75
+ {
76
+ kind: 'source', id: 'js-readline-sync-question', language: 'js', framework: 'node', lineageOnly: true,
77
+ match: { type: 'call', callee: 'question', receiver: 'readlineSync' }, label: 'readlineSync.question()', provenance: 'stdin',
78
+ why: 'text a person typed at the console; the readline module\'s own `rl.question(q, cb)` delivers the answer to a callback and is not seeded',
79
+ },
80
+ ]);
81
+
82
+ /** The receiver chain of a member-call callee, outermost first (`a.b.c()` -> ['a', 'b']). */
83
+ function receiverSegments(callee) {
84
+ const segs = [];
85
+ let cur = callee && callee.kind === 'member' ? callee.object : null;
86
+ let depth = 0;
87
+ while (cur && depth++ < 8) {
88
+ if (cur.kind === 'ident') { segs.unshift(cur.name); break; }
89
+ if (cur.kind === 'member') { if (typeof cur.prop === 'string') segs.unshift(cur.prop); cur = cur.object; continue; }
90
+ break;
91
+ }
92
+ return segs;
93
+ }
94
+
95
+ function calleeName(callee) {
96
+ if (!callee) return null;
97
+ if (typeof callee === 'string') return callee.slice(callee.lastIndexOf('.') + 1);
98
+ if (callee.kind === 'ident') return callee.name ?? null;
99
+ if (callee.kind === 'member') return typeof callee.prop === 'string' ? callee.prop : null;
100
+ return null;
101
+ }
102
+
103
+ function entryMatches(entry, expr) {
104
+ const m = entry.match;
105
+ if (m.type === 'member') {
106
+ return expr.kind === 'member' && expr.object?.kind === 'ident' && expr.object.name === m.object && expr.prop === m.prop;
107
+ }
108
+ if (m.type === 'call') {
109
+ if (expr.kind !== 'call') return false;
110
+ if (calleeName(expr.callee) !== m.callee) return false;
111
+ if (m.bare) return expr.callee && expr.callee.kind === 'ident';
112
+ if (m.receiver) return receiverSegments(expr.callee).includes(m.receiver);
113
+ return true;
114
+ }
115
+ return false;
116
+ }
117
+
118
+ /**
119
+ * The lineage-only counterpart of `dataflow/catalog.js`'s `matchSource`:
120
+ * returns the first catalog entry whose `match` fits `expr`, or null.
121
+ * Gated to JavaScript and TypeScript files (an entry here is JavaScript by
122
+ * construction); a missing `file` is permissive, matching `matchSource`.
123
+ * Never throws on a malformed expression.
124
+ */
125
+ export function matchLineageSource(expr, file) {
126
+ if (!expr || typeof expr !== 'object') return null;
127
+ if (file && !JS_FILE.test(file)) return null;
128
+ for (const entry of LINEAGE_SOURCE_CATALOG) {
129
+ if (entryMatches(entry, expr)) return entry;
130
+ }
131
+ return null;
132
+ }
133
+
134
+ /** The registry decision for every entry, computed once: proves at load time that each `provenance` resolves. */
135
+ export const LINEAGE_SOURCE_DECISIONS = Object.freeze(Object.fromEntries(
136
+ LINEAGE_SOURCE_CATALOG.map((e) => {
137
+ const d = reclassifySource(e);
138
+ if (!d || !d.category || d.coverageStatus === 'unsupported') {
139
+ throw new Error(`lineage-source-catalog: ${e.id} has a provenance (${e.provenance}) the source registry cannot classify`);
140
+ }
141
+ return [e.id, d];
142
+ }),
143
+ ));
@@ -30,6 +30,25 @@ import { emptyState, addIdentity } from './field-identity.js';
30
30
  import { reclassifySource } from './source-registry.js';
31
31
  import { classifyDataElementName } from './classification.js';
32
32
  import { dataElementId } from './ids.js';
33
+ import { matchLineageSource } from './lineage-source-catalog.js';
34
+
35
+ // ── Seed kinds (GA-03.1, 0.195.24) ──────────────────────────────────────────
36
+ //
37
+ // `entry`: the identity is placed in the function's ENTRY state at
38
+ // `seedPath` (a member-shaped source such as `req.body.card_number`, whose
39
+ // path is stable for the whole function). This is §3's original mechanism.
40
+ //
41
+ // `after-node`: the identity is placed at `seedPath` in the state leaving
42
+ // CFG node `nodeId`, by `engine.js`'s worklist through `ctx.seedAfterNode`.
43
+ // Used for a CALL-shaped source assigned straight to a variable
44
+ // (`const record = fs.readFileSync(p)`, `const ssn = prompt('SSN')`,
45
+ // `const data = res.json()`): the call has no access path, so an entry seed
46
+ // has nowhere to land, and the value only exists in the target variable
47
+ // AFTER the assignment (an entry seed at the target would be killed by the
48
+ // assignment's own strong update). Before 0.195.24 every such match was
49
+ // recorded in `unseedable[]`; a call-shaped source anywhere else (nested
50
+ // inside another call's arguments, returned directly) still is.
51
+ export const SEED_KINDS = Object.freeze(['entry', 'after-node']);
33
52
 
34
53
  // ── §3.1: where the matched expressions come from ──────────────────────────
35
54
  //
@@ -169,6 +188,26 @@ export function planSeeds(callGraph, { repository, maxSeeds, maxNodes, signal, d
169
188
  if (seeds.length >= seedCap) { stopReason = 'seeding-limit'; continue; }
170
189
  if (nodesVisited >= nodeCap) { stopReason = 'seeding-limit'; continue; }
171
190
  functionsScanned += 1;
191
+ const fnSeeds = [];
192
+ const mint = ({ nid, node, entry, decision, seedPath, seedKind }) => {
193
+ const canonicalName = seedPath.slice(seedPath.lastIndexOf('.') + 1);
194
+ const seed = {
195
+ file: fn.file, qid: fn.qid, nodeId: nid, line: node.line ?? null,
196
+ entryId: entry.id, seedPath, canonicalName, seedKind,
197
+ category: decision.category, coverageStatus: decision.coverageStatus,
198
+ externality: decision.externality, reason: decision.reason,
199
+ // §3.4's minting rule. `canonicalName` alone is forbidden by
200
+ // PRD §10.4; the discriminator carries the system proxy
201
+ // (repository + file), the access path, and the category.
202
+ // Function scope (`qid`) is deliberately NOT in the
203
+ // discriminator (§3.4).
204
+ dataElementId: dataElementId(canonicalName, [repository, fn.file, seedPath, decision.category ?? '']),
205
+ dataClasses: classifyDataElementName(canonicalName).classes,
206
+ };
207
+ seeds.push(seed);
208
+ fnSeeds.push(seed);
209
+ return seed;
210
+ };
172
211
  for (const [nid, node] of Object.entries(fn.cfg?.nodes ?? {})) {
173
212
  nodesVisited += 1;
174
213
  if (seeds.length >= seedCap) { stopReason = 'seeding-limit'; break; }
@@ -176,10 +215,21 @@ export function planSeeds(callGraph, { repository, maxSeeds, maxNodes, signal, d
176
215
  for (const root of exprRoots(node)) walkExpr(root, (e, p) => { if (p) parentOf.set(e, p); });
177
216
  for (const root of exprRoots(node)) {
178
217
  walkExpr(root, (e) => {
179
- const entry = matchSource(e, fn.file);
218
+ // The main taint catalog first (unchanged), then the lineage-only
219
+ // catalog for the JavaScript collection points the main catalog
220
+ // deliberately does not treat as taint sources.
221
+ const entry = matchSource(e, fn.file) ?? matchLineageSource(e, fn.file);
180
222
  if (!entry) return;
181
223
  const decision = reclassifySource(entry);
182
- const seedPath = seedPathFor(e, parentOf);
224
+ let seedPath = seedPathFor(e, parentOf);
225
+ let seedKind = 'entry';
226
+ if (!seedPath && node.kind === 'assign' && node.source === e && typeof node.target === 'string') {
227
+ // A call-shaped source that IS the whole right-hand side of an
228
+ // assignment: the value lands in the target, so seed the target
229
+ // once the node has run (see SEED_KINDS).
230
+ seedPath = node.target;
231
+ seedKind = 'after-node';
232
+ }
183
233
  if (!seedPath) {
184
234
  unseedable.push({
185
235
  file: fn.file, qid: fn.qid, nodeId: nid, line: node.line ?? null,
@@ -187,23 +237,24 @@ export function planSeeds(callGraph, { repository, maxSeeds, maxNodes, signal, d
187
237
  });
188
238
  return;
189
239
  }
190
- const canonicalName = seedPath.slice(seedPath.lastIndexOf('.') + 1);
191
- seeds.push({
192
- file: fn.file, qid: fn.qid, nodeId: nid, line: node.line ?? null,
193
- entryId: entry.id, seedPath, canonicalName,
194
- category: decision.category, coverageStatus: decision.coverageStatus,
195
- externality: decision.externality, reason: decision.reason,
196
- // §3.4's minting rule. `canonicalName` alone is forbidden by
197
- // PRD §10.4; the discriminator carries the system proxy
198
- // (repository + file), the access path, and the category.
199
- // Function scope (`qid`) is deliberately NOT in the
200
- // discriminator (§3.4).
201
- dataElementId: dataElementId(canonicalName, [repository, fn.file, seedPath, decision.category ?? '']),
202
- dataClasses: classifyDataElementName(canonicalName).classes,
203
- });
240
+ mint({ nid, node, entry, decision, seedPath, seedKind });
204
241
  });
205
242
  }
206
243
  }
244
+ // GA-03.1 (0.195.24): a container seed read through an ALIAS. §3.2's
245
+ // rule extends a match outward only through member accesses enclosing
246
+ // it in the SAME statement, so `const body = req.body;` seeds the
247
+ // container `req.body` (data element `body`, no classes) and the later
248
+ // `body.card_number` read is a field nothing seeded. Follow the alias
249
+ // within the function: an assignment of a pure access path that starts
250
+ // at a container seed (or at an earlier alias of one) names an alias,
251
+ // and every outermost pure member read rooted at an alias mints the
252
+ // field seed on the ORIGINAL container path. The alias assignment's own
253
+ // byPath forwarding (`engine.js`, the structure-preserving `ident` and
254
+ // `member` cases) then carries the field identity through the hop.
255
+ // Bounded: the alias table is rebuilt at most four rounds (one per
256
+ // hop of chained aliases) and a wildcard segment is never seeded.
257
+ if (fnSeeds.length) extendThroughAliases(fn, fnSeeds, mint);
207
258
  }
208
259
  const notScanned = functionsTotal - functionsScanned;
209
260
  const completion = {
@@ -219,6 +270,61 @@ export function planSeeds(callGraph, { repository, maxSeeds, maxNodes, signal, d
219
270
  return { seeds, unseedable, completion };
220
271
  }
221
272
 
273
+ const MAX_ALIAS_ROUNDS = 4;
274
+
275
+ /** `(fn, fnSeeds, mint)`: see the comment at the call site in `planSeeds`. */
276
+ function extendThroughAliases(fn, fnSeeds, mint) {
277
+ const nodes = Object.entries(fn.cfg?.nodes ?? {});
278
+ const containers = fnSeeds.filter((s) => s.seedKind === 'entry');
279
+ if (!containers.length) return;
280
+ const containerFor = (p) => containers.find((s) => p === s.seedPath || p.startsWith(`${s.seedPath}.`)) ?? null;
281
+ // ident -> { basePath, seed }: the container path the alias stands for.
282
+ const aliases = new Map();
283
+ const resolve = (p) => {
284
+ if (!p || p.includes('*')) return null;
285
+ const root = p.slice(0, p.indexOf('.') === -1 ? p.length : p.indexOf('.'));
286
+ const a = aliases.get(root);
287
+ if (a) return { basePath: a.basePath + p.slice(root.length), seed: a.seed };
288
+ const c = containerFor(p);
289
+ return c ? { basePath: p, seed: c } : null;
290
+ };
291
+ for (let round = 0; round < MAX_ALIAS_ROUNDS; round++) {
292
+ let changed = false;
293
+ for (const [, node] of nodes) {
294
+ if (node.kind !== 'assign' || typeof node.target !== 'string' || node.target.includes('.') || node.target.includes('*')) continue;
295
+ if (aliases.has(node.target)) continue;
296
+ const r = resolve(accessPathOf(node.source));
297
+ if (!r) continue;
298
+ aliases.set(node.target, r);
299
+ changed = true;
300
+ }
301
+ if (!changed) break;
302
+ }
303
+ if (!aliases.size) return;
304
+ const minted = new Set(fnSeeds.map((s) => s.seedPath));
305
+ for (const [, node] of nodes) {
306
+ const parentOf = new Map();
307
+ for (const root of exprRoots(node)) walkExpr(root, (e, p) => { if (p) parentOf.set(e, p); });
308
+ for (const root of exprRoots(node)) {
309
+ walkExpr(root, (e) => {
310
+ if (e.kind !== 'member') return;
311
+ const parent = parentOf.get(e);
312
+ if (parent && parent.kind === 'member' && parent.object === e) return; // not outermost
313
+ const p = accessPathOf(e);
314
+ if (!p || p.includes('*')) return;
315
+ const root = p.slice(0, p.indexOf('.'));
316
+ const a = aliases.get(root);
317
+ if (!a) return;
318
+ const fieldPath = a.basePath + p.slice(root.length);
319
+ if (fieldPath === a.seed.seedPath || minted.has(fieldPath)) return;
320
+ minted.add(fieldPath);
321
+ const site = fn.cfg.nodes[a.seed.nodeId];
322
+ mint({ nid: a.seed.nodeId, node: site ?? node, entry: { id: a.seed.entryId }, decision: a.seed, seedPath: fieldPath, seedKind: 'entry' });
323
+ });
324
+ }
325
+ }
326
+ }
327
+
222
328
  // ── §3.5: seedEntryStateFactory — the driver.js opts.seedEntryState hook ──
223
329
 
224
330
  /**
@@ -233,6 +339,7 @@ export function planSeeds(callGraph, { repository, maxSeeds, maxNodes, signal, d
233
339
  export function seedEntryStateFactory(seeds) {
234
340
  const byQid = new Map();
235
341
  for (const s of seeds) {
342
+ if (s.seedKind === 'after-node') continue;
236
343
  if (!byQid.has(s.qid)) byQid.set(s.qid, []);
237
344
  byQid.get(s.qid).push(s);
238
345
  }
@@ -244,3 +351,22 @@ export function seedEntryStateFactory(seeds) {
244
351
  return st;
245
352
  };
246
353
  }
354
+
355
+ /**
356
+ * Builds the `ctx.seedAfterNode(qid, nodeId) -> [{path, id}] | undefined`
357
+ * function `engine.js`'s worklist consults after every CFG node (GA-03.1,
358
+ * 0.195.24): the `after-node` seeds of that function, grouped by node.
359
+ * Keyed by `qid` rather than bound to one function so the same function
360
+ * works for a callee analysed through `createCallSummaryResolver`.
361
+ */
362
+ export function seedAfterNodeFactory(seeds) {
363
+ const byKey = new Map();
364
+ for (const s of seeds) {
365
+ if (s.seedKind !== 'after-node') continue;
366
+ const k = `${s.qid}\u0000${s.nodeId}`;
367
+ if (!byKey.has(k)) byKey.set(k, []);
368
+ byKey.get(k).push({ path: s.seedPath, id: s.dataElementId });
369
+ }
370
+ if (!byKey.size) return undefined;
371
+ return (qid, nodeId) => byKey.get(`${qid}\u0000${nodeId}`);
372
+ }
@@ -1,4 +1,4 @@
1
- import { hashState, emptyState, addIdentity } from './field-identity.js';
1
+ import { hashState, emptyState, addIdentity, joinStates } from './field-identity.js';
2
2
  import {
3
3
  resolveExprIdentities, residualFlat, analyzeFunctionFieldIdentity,
4
4
  argumentContext, callInvocationId, calleeRecorder,
@@ -425,7 +425,8 @@ export function applyAtCallSite(summary, paramNames, callArgs) {
425
425
  // integration will pass a resolver backed by `scanner/src/ir/callgraph.js`
426
426
  // instead, without this function (or `resolveExprIdentities`) needing to
427
427
  // change at all.
428
- export function createCallSummaryResolver(cache, lookupCallee) {
428
+ export function createCallSummaryResolver(cache, lookupCallee, resolverOpts = {}) {
429
+ const seedEntryState = typeof resolverOpts.seedEntryState === 'function' ? resolverOpts.seedEntryState : null;
429
430
  return function resolveCallSummary(calleeExpr, callArgs, callerState, ctx) {
430
431
  const resolved = lookupCallee(calleeExpr);
431
432
  if (!resolved) return null;
@@ -435,7 +436,18 @@ export function createCallSummaryResolver(cache, lookupCallee) {
435
436
  // own header comment) — this call site forwards the caller's `ctx`
436
437
  // unmodified; the hazard cannot reappear here because the strip
437
438
  // happens one level down, not at each call site.
438
- const entryState = entryStateFromCall(fn.params, callArgs, callerState, ctx);
439
+ const boundState = entryStateFromCall(fn.params, callArgs, callerState, ctx);
440
+ // GA-03.1 (0.195.24): the callee's OWN source seeds are facts about
441
+ // expressions in its body (`req.body.card_number` is a source wherever
442
+ // `extractCard` is called from), so they hold under every entry context,
443
+ // not only the driver's top-level one. Join them onto the call-derived
444
+ // state. The cache key is the JOINED state, so a clean call site and the
445
+ // driver's own analysis of the callee share one key and one summary;
446
+ // DESIGN_GRAPH_BUILDER.md §3.6's rule stands: no seeded summary is ever
447
+ // stored under the empty-entry key. The bind hops below enumerate only
448
+ // the call-derived half (`boundState`): a seed is not an argument.
449
+ const ownSeeds = seedEntryState ? seedEntryState(fn) : null;
450
+ const entryState = ownSeeds ? joinStates(boundState, ownSeeds) : boundState;
439
451
  // §13.2's first half: the callee's own entry context, computed once so
440
452
  // both the bind hop below and the return-direction wrapper at the
441
453
  // bottom of this function can reference the exact same value.
@@ -454,7 +466,7 @@ export function createCallSummaryResolver(cache, lookupCallee) {
454
466
  // C4 would collide this binding's target with any caller-local
455
467
  // variable of the same name (Decision 5's bug class).
456
468
  if (ctx?.recordHop) {
457
- for (const [path, ids] of entryState) {
469
+ for (const [path, ids] of boundState) {
458
470
  for (const id of ids) {
459
471
  ctx.recordHop({
460
472
  kind: 'write-out', subKind: 'call-arg-bind',
@@ -508,6 +520,10 @@ export function createCallSummaryResolver(cache, lookupCallee) {
508
520
  ...(ctx.correlationVersion === 2 ? { correlationVersion: 2 } : null),
509
521
  }
510
522
  : { resolveCallSummary };
523
+ // GA-03.1 (0.195.24): the callee's after-node seeds are body facts
524
+ // too (source-seeding.js, SEED_KINDS); the hook is keyed by qid, so
525
+ // forwarding the caller's is exact.
526
+ if (ctx?.seedAfterNode) calleeCtx.seedAfterNode = ctx.seedAfterNode;
511
527
  const result = analyzeFunctionFieldIdentity(fn, es, calleeCtx);
512
528
  return summaryFromAnalysisResult(result);
513
529
  });
@@ -524,7 +540,7 @@ export function createCallSummaryResolver(cache, lookupCallee) {
524
540
  // under §2.2's rule (or, when there is no path-shaped argument, as
525
541
  // edge-forming in its own right) — never dropped either way.
526
542
  if (summary?.degradedReason && ctx?.recordHop) {
527
- for (const [, ids] of entryState) {
543
+ for (const [, ids] of boundState) {
528
544
  for (const id of ids) {
529
545
  ctx.recordHop({
530
546
  kind: 'production', subKind: 'call-resolved',
@@ -77,6 +77,7 @@ import { localEndpointConfig } from './local-endpoint.js';
77
77
  import { resolveProvider, buildProviderRequest, providerMatrix } from './providers.js';
78
78
  import { evaluateEgress } from '../egress/policy.js';
79
79
  import { recordEgressCall, payloadMetrics } from '../egress/audit.js';
80
+ import { modelFetch } from '../egress/transport.js';
80
81
  import { MODEL_STATUS, summarizeModelStatus } from './model-status.js';
81
82
  import { callOllamaChat } from './ollama-provider.js';
82
83
 
@@ -408,12 +409,13 @@ async function callEndpoint(endpoint, apiKey, model, prompt, preset = null, shap
408
409
  // A model that accepts the connection and never answers must not hold a scan open forever: every call is bounded.
409
410
  // The result is an UNAVAILABLE finding (kept, unvalidated), never a silent pass.
410
411
  const ms = Number(process.env.AGENTIC_SECURITY_LLM_TIMEOUT_MS) > 0 ? Number(process.env.AGENTIC_SECURITY_LLM_TIMEOUT_MS) : 60_000;
411
- const r = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify(body), signal: AbortSignal.timeout(ms) });
412
+ const r = await modelFetch(endpoint, { method: 'POST', headers, body: JSON.stringify(body) }, { timeoutMs: ms });
412
413
  if (!r.ok) return { ok: false, error: `HTTP ${r.status}` };
413
414
  const j = await r.json().catch(() => null);
414
415
  return { ok: true, text: String(extractText(j) || ''), usage: extractUsage ? extractUsage(j) : null };
415
416
  } catch (e) {
416
- return { ok: false, error: e && e.name === 'TimeoutError' ? 'timeout' : e.message };
417
+ // a redirect, an oversized body or a spent budget is an unavailable validator, named by its transport code, never a pass
418
+ return { ok: false, error: e && e.code === 'timeout' ? 'timeout' : (e && e.code ? `${e.code}: ${e.message}` : e.message) };
417
419
  }
418
420
  }
419
421
 
@@ -22,7 +22,10 @@
22
22
  // The connect timeout is short for the same reason — a dead port must not hang
23
23
  // a scan.
24
24
 
25
- const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]', '0.0.0.0']);
25
+ // `localhost` is the one name accepted; `[::1]` is how the URL parser spells the IPv6 loopback (a bare `::1` never reaches this set).
26
+ // `0.0.0.0` is NOT here (GA-06.3): it is the unspecified address, and a connect to it reaches a local interface only by an operating
27
+ // system convention, not by the address's meaning. The promise is "literal loopback", so it is refused like `[::]` and `http://0/`.
28
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
26
29
 
27
30
  export const DEFAULT_LOCAL_ENDPOINT = 'http://127.0.0.1:11434/v1/chat/completions';
28
31
  export const DEFAULT_LOCAL_TIMEOUT_MS = 20000;
@@ -32,6 +32,7 @@
32
32
  // never to this module's error path. See ollama-offline-egress.test.js.
33
33
 
34
34
  import { isLoopbackUrl } from './local-endpoint.js';
35
+ import { modelFetch } from '../egress/transport.js';
35
36
  import { recordOOMEvent } from './oom-feedback.js';
36
37
 
37
38
  export const DEFAULT_OLLAMA_HOST = 'http://127.0.0.1:11434';
@@ -199,16 +200,17 @@ export function parseOllamaChatResponse(json, model) {
199
200
  * "slow to generate" without a lower-level HTTP client.
200
201
  */
201
202
  async function _fetchOllama(url, init, { connectTimeoutMs, requestTimeoutMs }) {
202
- const controller = new AbortController();
203
- const totalTimer = setTimeout(() => controller.abort('total-timeout'), requestTimeoutMs);
203
+ void connectTimeoutMs; // see the note above: the client exposes no connect-only phase to bound separately
204
204
  try {
205
- const res = await fetch(url, { ...init, signal: controller.signal });
205
+ // the shared transport: no redirect is followed, the reply is capped while it is read, the deadline is the total timeout
206
+ const res = await modelFetch(url, init, { timeoutMs: requestTimeoutMs });
206
207
  return { ok: true, res };
207
208
  } catch (e) {
208
- if (controller.signal.aborted) return _err('ollama-timeout', `Ollama request timed out after ${requestTimeoutMs}ms.`);
209
+ if (e?.code === 'timeout') return _err('ollama-timeout', `Ollama request timed out after ${requestTimeoutMs}ms.`);
210
+ if (e?.code === 'redirect') return _err('ollama-unreachable', `Ollama answered with a redirect, which is never followed: ${e.message}`);
211
+ if (e?.code === 'response-too-large') return _err('ollama-malformed-response', e.message);
212
+ if (e?.code === 'budget-exhausted') return _err('ollama-timeout', e.message);
209
213
  return _err('ollama-unreachable', e?.message || String(e));
210
- } finally {
211
- clearTimeout(totalTimer);
212
214
  }
213
215
  }
214
216
 
@@ -26,6 +26,8 @@
26
26
 
27
27
  import * as crypto from 'node:crypto';
28
28
  import { evaluateEgress } from '../egress/policy.js';
29
+ import { modelFetch } from '../egress/transport.js';
30
+ import { resolveProvider } from '../llm-validator/providers.js';
29
31
 
30
32
  const MAX_CALLS_DEFAULT = 50;
31
33
  const MAX_WALL_MS_DEFAULT = 15 * 60 * 1000;
@@ -96,11 +98,21 @@ export function isExceeded(transcript, budget) {
96
98
  // and parses the response as a single tool-call request. The endpoint is
97
99
  // expected to follow an OpenAI-style chat-completions shape; we extract a
98
100
  // JSON object of `{ tool, args }` from the model's reply.
101
+ // GA-06.3: the provider is resolved like the validator's, so the `local` preset's loopback refusal applies here, and a vendor key is
102
+ // sent only with that vendor's own preset (a bare OPENAI_API_KEY is never attached to an arbitrary endpoint). Ollama's protocol is not
103
+ // implemented here, so that preset counts as not configured for this agent.
104
+ export function _agentProvider(env = process.env) {
105
+ const r = resolveProvider({ role: 'verify', env });
106
+ if (!r.ok || r.config.provider === 'ollama') return null;
107
+ return r.config;
108
+ }
109
+
99
110
  export async function defaultLlmInvoke(transcript) {
100
- const endpoint = process.env.AGENTIC_SECURITY_LLM_ENDPOINT;
101
- if (!endpoint) return null;
102
- const model = process.env.AGENTIC_SECURITY_LLM_MODEL || 'gpt-4o-mini';
103
- const apiKey = process.env.AGENTIC_SECURITY_LLM_API_KEY || process.env.OPENAI_API_KEY || '';
111
+ const provider = _agentProvider();
112
+ if (!provider) return null;
113
+ const { endpoint } = provider;
114
+ const model = provider.model || 'gpt-4o-mini';
115
+ const apiKey = provider.apiKey || '';
104
116
  const headers = { 'Content-Type': 'application/json' };
105
117
  if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
106
118
  const tools = [...TOOL_ACL].map(t => ({ tool: t, description: TOOL_HELP[t] || '' }));
@@ -115,7 +127,7 @@ export async function defaultLlmInvoke(transcript) {
115
127
  };
116
128
  let res;
117
129
  try {
118
- res = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify(body) });
130
+ res = await modelFetch(endpoint, { method: 'POST', headers, body: JSON.stringify(body) });
119
131
  } catch (e) {
120
132
  return null;
121
133
  }
@@ -136,6 +148,9 @@ const TOOL_HELP = {
136
148
  'record_outcome': 'Terminate the run with a verdict (outcome: one of OUTCOMES)',
137
149
  };
138
150
 
151
+ /** Bounds for a tool probe against the target: a reply is kept to 4000 characters anyway, so 64 KiB and 30 s are generous. */
152
+ const PROBE_BOUNDS = Object.freeze({ timeoutMs: 30_000, maxResponseBytes: 64 * 1024 });
153
+
139
154
  // Default executeTool — wraps http.get / http.post against `transcript.target`.
140
155
  // Falls back to refusal when the tool is db.read_sandbox_copy (we do not ship
141
156
  // a sandboxed DB by default; the caller must supply one).
@@ -144,7 +159,8 @@ export async function defaultExecuteTool(call, transcript) {
144
159
  if (call.tool === 'http.get') {
145
160
  const url = (transcript?.target || '').replace(/\/$/, '') + (call.args?.path || '/');
146
161
  try {
147
- const r = await fetch(url, { method: 'GET', headers: call.args?.headers || {} });
162
+ // the probe goes through the shared transport too: no redirect off the target, a capped reply, a deadline
163
+ const r = await modelFetch(url, { method: 'GET', headers: call.args?.headers || {} }, PROBE_BOUNDS);
148
164
  const body = await r.text();
149
165
  return { status: r.status, headers: Object.fromEntries(r.headers), body: body.slice(0, 4000) };
150
166
  } catch (e) { return { error: String(e?.message || e) }; }
@@ -152,11 +168,11 @@ export async function defaultExecuteTool(call, transcript) {
152
168
  if (call.tool === 'http.post') {
153
169
  const url = (transcript?.target || '').replace(/\/$/, '') + (call.args?.path || '/');
154
170
  try {
155
- const r = await fetch(url, {
171
+ const r = await modelFetch(url, {
156
172
  method: 'POST',
157
173
  headers: { 'Content-Type': 'application/json', ...(call.args?.headers || {}) },
158
174
  body: typeof call.args?.body === 'string' ? call.args.body : JSON.stringify(call.args?.body || {}),
159
- });
175
+ }, PROBE_BOUNDS);
160
176
  const body = await r.text();
161
177
  return { status: r.status, headers: Object.fromEntries(r.headers), body: body.slice(0, 4000) };
162
178
  } catch (e) { return { error: String(e?.message || e) }; }
@@ -176,8 +192,9 @@ export async function runAgent(finding, opts = {}) {
176
192
  // called later in the loop below) never runs at all.
177
193
  let egressDecision = null;
178
194
  let llmInvoke = opts.llmInvoke || null;
179
- if (!llmInvoke && process.env.AGENTIC_SECURITY_LLM_ENDPOINT) {
180
- egressDecision = evaluateEgress({ scanRoot: opts.scanRoot, purpose: 'adversary-agent', endpoint: process.env.AGENTIC_SECURITY_LLM_ENDPOINT });
195
+ const provider = llmInvoke ? null : _agentProvider();
196
+ if (!llmInvoke && provider) {
197
+ egressDecision = evaluateEgress({ scanRoot: opts.scanRoot, purpose: 'adversary-agent', endpoint: provider.endpoint });
181
198
  if (egressDecision.allowed) llmInvoke = defaultLlmInvoke;
182
199
  }
183
200
  const executeTool = opts.executeTool || (transcript.target ? (call) => defaultExecuteTool(call, transcript) : null);
@@ -39,6 +39,7 @@ import * as path from 'node:path';
39
39
  import * as yaml from '../util/yaml.js';
40
40
  import { globFiles } from '../util/glob.js';
41
41
  import { loadTrustedKeys, verifyRulePack } from './rule-pack-signing.js';
42
+ import { isUnsafeRegex } from '../sast/redos-nfa.js';
42
43
 
43
44
  import { statePath, safeWriteState } from './state-dir.js';
44
45
  const LANG_EXTS = {
@@ -156,8 +157,19 @@ export function loadCustomRules(scanRoot) {
156
157
  // Catches (a+)+, (a*)*, (.+)+, (\w+)+, (a|b)+, (a+|b)* etc.
157
158
  // Not exhaustive but eliminates the vast majority of catastrophic patterns.
158
159
  const _REDOS_NESTED_RE = /\([^)]*[+*][^)]*\)[+*?{]/;
160
+ // The textual heuristic above misses an ambiguous alternation under a quantifier (`(a|a)*b`) and the polynomial shapes; since
161
+ // 0.195.23 (GA-07) the NFA-based detector the SAST layer already ships (`sast/redos-nfa.js`) is consulted as well, so a custom or
162
+ // compiled rule is held to the same bar as the code the scanner flags for CWE-1333.
159
163
  function _isSafePattern(p) {
160
- return !_REDOS_NESTED_RE.test(p);
164
+ if (_REDOS_NESTED_RE.test(p)) return false;
165
+ return !isUnsafeRegex(p).unsafe;
166
+ }
167
+ /** Why a pattern is refused, for the loader's message and the rule compiler's validation. Null when it is safe. */
168
+ export function unsafePatternReason(p) {
169
+ if (typeof p !== 'string') return 'not a string';
170
+ if (_REDOS_NESTED_RE.test(p)) return 'nested quantifiers';
171
+ const r = isUnsafeRegex(p);
172
+ return r.unsafe ? (r.reason || 'superlinear matching') : null;
161
173
  }
162
174
 
163
175
  /** The loader's own rule normalizer, exported so anything that scores or validates a rule uses the SAME one the scanner loads
@@ -15,7 +15,7 @@ import { rollupFleet, renderFleetSummary } from '../fleet.js';
15
15
  import { statePath, ensureStateDir, safeWriteState } from '../state-dir.js';
16
16
  import { createSqliteJobStore } from './job-store.js';
17
17
  import { planRun, runJobs, loadArtifacts, reclaimDeadWorkers, childScan } from './job-runner.js';
18
- import { computeOrgHealth, renderOrgHealth } from './org-health.js';
18
+ import { computeOrgHealth, renderOrgHealth, applyArtifactProblems } from './org-health.js';
19
19
  import { discoverEdges, splitDeclared } from './discover-edges.js';
20
20
  import { buildEnterpriseGraph, finalizeGraph, validateEnterpriseGraph, attackPaths, vulnerableSet, componentBlastRadius, renderBlastRadius, INTERNET } from './enterprise-graph.js';
21
21
 
@@ -90,9 +90,7 @@ function runOfJob(store, id) {
90
90
  async function finishRun({ args, root, store, runId, manifest, manifestFile, interrupted }) {
91
91
  const { artifacts, problems } = loadArtifacts({ store, runId, root });
92
92
  const jobs = store.listJobs(runId);
93
- const health = computeOrgHealth({ manifest, jobs });
94
- for (const p of problems) { const r = health.repositories.find((x) => x.id === p.repoId); if (r) { r.state = 'failed'; r.error = p.problem; } }
95
- if (problems.length) { health.counts.complete -= problems.filter((p) => health.repositories.find((x) => x.id === p.repoId)).length; health.counts.failed += problems.length; if (health.assurance !== 'red' && manifest.repositories.some((r) => r.required && problems.some((p) => p.repoId === r.id))) health.assurance = 'red'; health.reasons.push(`${problems.length} result(s) could not be verified: ${problems.map((p) => `${p.repoId} (${p.problem})`).join(', ')}`); }
93
+ const health = applyArtifactProblems(computeOrgHealth({ manifest, jobs }), manifest, problems);
96
94
 
97
95
  let previous = null;
98
96
  const rollupFile = statePath(root, ROLLUP_FILE);