@clear-capabilities/agentic-security-scanner 0.172.1 → 0.174.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/bin/agentic-security.js +37 -0
  3. package/dist/119.index.js +197 -0
  4. package/dist/128.index.js +129 -0
  5. package/dist/1293.index.js +5689 -0
  6. package/dist/1351.index.js +133 -0
  7. package/dist/1387.index.js +271 -0
  8. package/dist/1437.index.js +23 -0
  9. package/dist/1445.index.js +110 -0
  10. package/dist/1505.index.js +151 -0
  11. package/dist/1557.index.js +293 -0
  12. package/dist/1621.index.js +311 -0
  13. package/dist/1751.index.js +160 -0
  14. package/dist/1765.index.js +727 -0
  15. package/dist/1861.index.js +690 -0
  16. package/dist/1898.index.js +116 -0
  17. package/dist/1906.index.js +433 -0
  18. package/dist/1920.index.js +1 -291
  19. package/dist/208.index.js +1631 -0
  20. package/dist/2199.index.js +0 -239
  21. package/dist/2223.index.js +159 -0
  22. package/dist/2530.index.js +152 -0
  23. package/dist/2565.index.js +116 -0
  24. package/dist/2574.index.js +127 -0
  25. package/dist/2620.index.js +345 -0
  26. package/dist/2773.index.js +98 -0
  27. package/dist/2775.index.js +32 -0
  28. package/dist/2966.index.js +171 -0
  29. package/dist/2972.index.js +87 -0
  30. package/dist/2991.index.js +131 -0
  31. package/dist/3063.index.js +833 -0
  32. package/dist/3075.index.js +227 -0
  33. package/dist/3121.index.js +297 -0
  34. package/dist/3314.index.js +263 -0
  35. package/dist/3480.index.js +1822 -0
  36. package/dist/3481.index.js +24 -0
  37. package/dist/3506.index.js +268 -0
  38. package/dist/3533.index.js +591 -0
  39. package/dist/3653.index.js +450 -0
  40. package/dist/3822.index.js +131 -0
  41. package/dist/3868.index.js +292 -0
  42. package/dist/3917.index.js +152 -0
  43. package/dist/3918.index.js +192 -0
  44. package/dist/4090.index.js +288 -0
  45. package/dist/4121.index.js +1190 -0
  46. package/dist/4142.index.js +0 -239
  47. package/dist/4336.index.js +436 -0
  48. package/dist/4387.index.js +532 -0
  49. package/dist/4413.index.js +302 -0
  50. package/dist/4476.index.js +4510 -0
  51. package/dist/4491.index.js +133 -0
  52. package/dist/4538.index.js +185 -0
  53. package/dist/4684.index.js +319 -0
  54. package/dist/4740.index.js +165 -0
  55. package/dist/4780.index.js +209 -0
  56. package/dist/4802.index.js +225 -0
  57. package/dist/4812.index.js +379 -0
  58. package/dist/4835.index.js +126 -0
  59. package/dist/494.index.js +226 -0
  60. package/dist/4991.index.js +549 -0
  61. package/dist/5199.index.js +305 -0
  62. package/dist/5216.index.js +109 -0
  63. package/dist/5439.index.js +301 -0
  64. package/dist/5443.index.js +97 -0
  65. package/dist/5496.index.js +377 -0
  66. package/dist/550.index.js +2 -1
  67. package/dist/5801.index.js +588 -0
  68. package/dist/5858.index.js +223 -0
  69. package/dist/5933.index.js +143 -0
  70. package/dist/601.index.js +23 -0
  71. package/dist/6173.index.js +273 -0
  72. package/dist/6291.index.js +1871 -0
  73. package/dist/6312.index.js +666 -0
  74. package/dist/634.index.js +16182 -0
  75. package/dist/644.index.js +87 -0
  76. package/dist/6508.index.js +283 -0
  77. package/dist/6560.index.js +645 -0
  78. package/dist/6576.index.js +422 -0
  79. package/dist/6579.index.js +271 -0
  80. package/dist/6745.index.js +305 -0
  81. package/dist/6818.index.js +21642 -0
  82. package/dist/6972.index.js +167 -0
  83. package/dist/6995.index.js +163 -0
  84. package/dist/7249.index.js +191 -0
  85. package/dist/7372.index.js +1197 -0
  86. package/dist/7546.index.js +541 -0
  87. package/dist/76.index.js +448 -0
  88. package/dist/7879.index.js +163 -0
  89. package/dist/8025.index.js +130 -0
  90. package/dist/8054.index.js +248 -0
  91. package/dist/8097.index.js +201 -0
  92. package/dist/8126.index.js +191 -0
  93. package/dist/8178.index.js +78 -0
  94. package/dist/818.index.js +212 -0
  95. package/dist/8382.index.js +93 -0
  96. package/dist/8408.index.js +261 -0
  97. package/dist/8540.index.js +174 -0
  98. package/dist/8635.index.js +802 -0
  99. package/dist/8688.index.js +410 -0
  100. package/dist/8699.index.js +779 -0
  101. package/dist/8891.index.js +457 -0
  102. package/dist/8916.index.js +885 -0
  103. package/dist/9146.index.js +170 -0
  104. package/dist/9168.index.js +841 -0
  105. package/dist/9292.index.js +369 -0
  106. package/dist/9382.index.js +135 -11
  107. package/dist/9422.index.js +866 -0
  108. package/dist/9440.index.js +453 -0
  109. package/dist/9479.index.js +152 -0
  110. package/dist/9481.index.js +220 -0
  111. package/dist/9539.index.js +100 -0
  112. package/dist/9547.index.js +103 -0
  113. package/dist/9560.index.js +41 -61
  114. package/dist/9692.index.js +90 -0
  115. package/dist/9800.index.js +213 -1
  116. package/dist/9899.index.js +138 -0
  117. package/dist/9981.index.js +206 -0
  118. package/dist/999.index.js +117 -0
  119. package/dist/agentic-security.mjs +27 -27
  120. package/dist/agentic-security.mjs.sha256 +1 -1
  121. package/package.json +31 -6
  122. package/src/context-intelligence/candidates.js +2 -1
  123. package/src/context-intelligence/capabilities.js +2 -1
  124. package/src/context-intelligence/cli.js +2 -0
  125. package/src/context-intelligence/config.js +3 -1
  126. package/src/context-intelligence/controls.js +49 -1
  127. package/src/context-intelligence/counterexample.js +200 -0
  128. package/src/context-intelligence/cross-route.js +111 -0
  129. package/src/context-intelligence/findings.js +166 -0
  130. package/src/context-intelligence/invariants.js +197 -0
  131. package/src/context-intelligence/queue.js +6 -1
  132. package/src/context-intelligence/reachability.js +2 -2
  133. package/src/context-intelligence/review.js +68 -4
  134. package/src/dataflow/access-paths.js +11 -0
  135. package/src/dataflow/analysis-limits.js +66 -0
  136. package/src/dataflow/engine.js +282 -24
  137. package/src/dataflow/service-edges.js +174 -0
  138. package/src/dataflow/summaries.js +7 -1
  139. package/src/discovery/judge.js +2 -1
  140. package/src/engine.js +82 -14
  141. package/src/fix/apply-fix-service.js +55 -8
  142. package/src/fix/batch-journal.js +84 -0
  143. package/src/fix/exploit-replay.js +51 -0
  144. package/src/fix/footprint.js +65 -0
  145. package/src/fix/patch-policy.js +49 -0
  146. package/src/fix/secret-persistence.js +39 -0
  147. package/src/fix/test-commands.js +84 -0
  148. package/src/fix/verification-adapters.js +97 -0
  149. package/src/fix/verification-record.js +243 -0
  150. package/src/fix/verify-patch.js +89 -0
  151. package/src/ir/CLAUDE.md +5 -0
  152. package/src/ir/callgraph.js +23 -1
  153. package/src/ir/module-resolver.js +315 -0
  154. package/src/ir/parser-js.js +21 -2
  155. package/src/ir/semantic-graph.js +245 -0
  156. package/src/language/haskell-findings.js +2 -0
  157. package/src/language/haskell-guards.js +1 -1
  158. package/src/language/haskell-ir.js +3 -1
  159. package/src/language/haskell-models.js +94 -0
  160. package/src/language/haskell-security-rules.js +20 -0
  161. package/src/language/nixos-hardening.js +72 -0
  162. package/src/lineage/build-attested-correlation.js +63 -0
  163. package/src/lineage/deployment-identity.js +76 -0
  164. package/src/mcp/tools.js +13 -34
  165. package/src/pipeline/canonical-output.js +64 -0
  166. package/src/pipeline/detector-runner.js +7 -0
  167. package/src/pipeline/scan-health.js +14 -0
  168. package/src/pipeline/supervised-analyzers.js +125 -0
  169. package/src/pipeline/supervised-child.js +17 -0
  170. package/src/pipeline/telemetry.js +79 -0
  171. package/src/posture/accuracy-scorecard.js +98 -9
  172. package/src/posture/artifact-registry.js +5 -0
  173. package/src/posture/assurance/evidence-registry.js +215 -0
  174. package/src/posture/assurance/release-package-verify.js +135 -0
  175. package/src/posture/assurance/release-package.js +118 -0
  176. package/src/posture/assurance/release-verdict.js +94 -0
  177. package/src/posture/assurance/waiver.js +200 -0
  178. package/src/posture/compliance-truth.js +100 -0
  179. package/src/posture/evaluation/scan-child.js +3 -1
  180. package/src/posture/evaluation/why-missed.js +3 -1
  181. package/src/posture/exploitability-evidence.js +141 -0
  182. package/src/posture/fix-verify.js +4 -2
  183. package/src/posture/fleet/exposure-ranking.js +115 -0
  184. package/src/posture/fleet/service-chain-graph.js +150 -0
  185. package/src/posture/graph-reconciliation.js +154 -0
  186. package/src/posture/provenance/coverage.js +103 -0
  187. package/src/posture/provenance/git-evidence.js +84 -0
  188. package/src/posture/provenance/origin-resolver.js +7 -1
  189. package/src/posture/provenance/predicate-replay.js +14 -4
  190. package/src/posture/runtime-correlation.js +61 -15
  191. package/src/posture/runtime-explanation.js +116 -0
  192. package/src/posture/runtime-trace-ingest.js +194 -0
  193. package/src/posture/scan-checkpoint.js +38 -10
  194. package/src/posture/waf-ingest.js +7 -2
  195. package/src/report/index.js +4 -0
  196. package/src/report/oscal.js +38 -0
  197. package/src/report/perspectives.js +150 -0
  198. package/src/sast/_comment-strip.js +8 -1
  199. package/src/sast/ruby.js +26 -0
  200. package/src/sast/zip-slip.js +79 -0
  201. package/src/util/string-memo.js +38 -0
@@ -0,0 +1,174 @@
1
+ // Static service relationships (E2-07): which service calls, publishes to, or stores for which other service, read from the code.
2
+ //
3
+ // OPT-IN. Nothing here runs unless a caller passes the service roots; a single-service repository gets nothing, and no relationship is invented
4
+ // from a naming coincidence alone.
5
+ //
6
+ // WHAT AN EDGE CARRIES. Each edge says how it is known:
7
+ // observed both ends are literal in the code and agree (an HTTP client calling `http://orders/api/items` and an `orders` route `POST /api/items`;
8
+ // a producer and a consumer naming the same literal topic; two services naming the same table AND the project declaring one shared database)
9
+ // inferred the ends match but one rests on a weaker fact (two services name the same table with no declaration that they share a database;
10
+ // a client URL whose host comes from a configured base URL)
11
+ // unknown one end is dynamic (a computed topic, a URL assembled from user data) or has no counterpart: it is recorded and never connected
12
+ //
13
+ // WHAT IT NEVER SAYS. A chain is `static-observed` only when every hop is observed, `static-inferred` when the weakest hop is inferred, and
14
+ // `unproven` is reserved for a caller that supplies its own edges: this module never connects across an unknown one. No label is ever `proven`: this module reads text, it does not run the services, so ambiguity across
15
+ // a service boundary cannot be described as a demonstrated exploit chain. Traversal is bounded by hop count and a visited set, so a cycle ends.
16
+ //
17
+ // SCOPE. JavaScript, TypeScript and Python sources. HTTP (Express/Koa/Fastify-style routes, Flask/FastAPI decorators; fetch/axios/requests/got
18
+ // clients), messaging (Kafka/AMQP/Redis/NATS style publish and subscribe with a literal topic), persistence (SQL text and common collection calls).
19
+ // Serializer contracts (protobuf/JSON-schema sharing) are not read: recorded as a limit.
20
+ import { createHash } from 'node:crypto';
21
+
22
+ const EDGE_KINDS = Object.freeze(['http', 'messaging', 'persistence']);
23
+ const EVIDENCE = Object.freeze(['observed', 'inferred', 'unknown']);
24
+ export const MAX_HOPS = 6;
25
+ const RANK = { observed: 0, inferred: 1, unknown: 2 };
26
+ const id = (parts) => `svcedge:${createHash('sha256').update(JSON.stringify(parts)).digest('hex').slice(0, 16)}`;
27
+ const posix = (p) => String(p).replace(/\\/g, '/');
28
+ const lineOf = (code, index) => code.slice(0, index).split('\n').length;
29
+
30
+ function serviceOf(file, services) {
31
+ const f = posix(file);
32
+ let best = null;
33
+ for (const s of services) { const root = posix(s.root).replace(/\/$/, ''); if ((f === root || f.startsWith(`${root}/`)) && (!best || root.length > posix(best.root).length)) best = s; }
34
+ return best;
35
+ }
36
+
37
+ const norm = (p) => `/${String(p).replace(/^https?:\/\/[^/]+/, '').replace(/[?#].*$/, '').split('/').filter(Boolean).join('/')}`;
38
+ // `/items/:id`, `/items/{id}`, `/items/<id>`, `/items/${id}` all name the same shape
39
+ // a concrete segment (`/items/42`) matches a parameter segment of a route; two concrete segments must be equal
40
+ const matchesShape = (routePath, clientPath) => { const r = shape(routePath).split('/'); const c = shape(clientPath).split('/'); return r.length === c.length && r.every((seg, i) => seg === ':p' || seg === c[i]); };
41
+ const shape = (p) => norm(p).split('/').map((seg) => (/^(?::|\{|<|\$\{)/.test(seg) ? ':p' : seg)).join('/');
42
+
43
+ // ---- extraction -------------------------------------------------------------------------------------------------------------
44
+
45
+ const ROUTE_JS = /\b(?:app|router|server|fastify|api)\s*\.\s*(get|post|put|patch|delete|all)\s*\(\s*(['"`])(\/[^'"`]*)\2/g;
46
+ const ROUTE_PY = /@\s*(?:app|router|bp|blueprint)\s*\.\s*(get|post|put|patch|delete|route)\s*\(\s*(['"])(\/[^'"]*)\2([^)]*)\)/g;
47
+ const CLIENT_JS = /\b(fetch|axios(?:\s*\.\s*(?:get|post|put|patch|delete))?|got(?:\s*\.\s*(?:get|post|put|patch|delete))?)\s*\(\s*([`'"])([^`'"]*)\2/g;
48
+ const CLIENT_PY = /\brequests\s*\.\s*(get|post|put|patch|delete)\s*\(\s*(['"])([^'"]*)\2/g;
49
+ const CLIENT_DYN = /\b(?:fetch|axios(?:\s*\.\s*(?:get|post|put|patch|delete))?|requests\s*\.\s*(?:get|post|put|patch|delete)|got)\s*\(\s*([A-Za-z_$][\w$.]*)\s*[,)]/g;
50
+ const PRODUCE = /\b(?:producer|channel|publisher|pubsub|nats|client|redis|bus|queue)\s*\.\s*(?:send|publish|emit|produce|sendToQueue|xadd|lpush)\s*\(\s*(?:\{\s*topic\s*:\s*)?(['"`])([^'"`]+)\1/g;
51
+ const PRODUCE_DYN = /\b(?:producer|channel|publisher|pubsub|nats|bus|queue)\s*\.\s*(?:send|publish|emit|produce|sendToQueue)\s*\(\s*(?:\{\s*topic\s*:\s*)?([A-Za-z_$][\w$.]*)\s*[,})]/g;
52
+ const CONSUME = /\b(?:consumer|channel|subscriber|pubsub|nats|bus|queue|client)\s*\.\s*(?:subscribe|consume|on|psubscribe|xread|brpop)\s*\(\s*(?:\{\s*topic\s*:\s*)?(['"`])([^'"`]+)\1/g;
53
+ const SQL_WRITE = /\b(?:INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+[`"']?([A-Za-z_][\w.]*)/gi;
54
+ const SQL_READ = /\bSELECT\b[^;]{0,300}?\bFROM\s+[`"']?([A-Za-z_][\w.]*)/gi;
55
+ const COLL_WRITE = /\b(?:db|database|mongo)\s*\.\s*(?:collection\s*\(\s*['"`]([\w.-]+)['"`]\s*\)|([A-Za-z_]\w*))\s*\.\s*(?:insertOne|insertMany|updateOne|updateMany|deleteOne|deleteMany|save|create|insert|update|remove)\s*\(/g;
56
+ const COLL_READ = /\b(?:db|database|mongo)\s*\.\s*(?:collection\s*\(\s*['"`]([\w.-]+)['"`]\s*\)|([A-Za-z_]\w*))\s*\.\s*(?:find|findOne|findMany|findById|get|select|aggregate|count)\s*\(/g;
57
+ const DB_URL = /\b(DATABASE_URL|DB_URL|MONGO_URL|MONGODB_URI|POSTGRES_URL|REDIS_URL)\b/g;
58
+
59
+ function* matches(re, code) { re.lastIndex = 0; let m; while ((m = re.exec(code))) yield m; }
60
+
61
+ /** @returns {{routes:object[], clients:object[], produces:object[], consumes:object[], writes:object[], reads:object[], dbRefs:string[], unknown:object[]}} */
62
+ export function extractFacts(file, code) {
63
+ const out = { routes: [], clients: [], produces: [], consumes: [], writes: [], reads: [], dbRefs: [], unknown: [] };
64
+ const py = /\.py$/.test(file);
65
+ const at = (m) => lineOf(code, m.index);
66
+ for (const m of matches(py ? ROUTE_PY : ROUTE_JS, code)) {
67
+ let method = m[1].toUpperCase();
68
+ if (py && method === 'ROUTE') { const mm = /methods\s*=\s*\[\s*['"](\w+)['"]/.exec(m[4] || ''); method = mm ? mm[1].toUpperCase() : 'GET'; }
69
+ out.routes.push({ method, path: m[3], file, line: at(m) });
70
+ }
71
+ for (const m of matches(py ? CLIENT_PY : CLIENT_JS, code)) {
72
+ const target = py ? m[3] : m[3];
73
+ // `fetch(url, { method: 'POST' })` names its verb in the options object; `axios.post` and `requests.post` in the callee
74
+ const optionVerb = /\bmethod\s*:\s*['"`](\w+)['"`]/.exec(code.slice(m.index, m.index + 300));
75
+ const verb = py ? m[1].toUpperCase() : ((/\.\s*(get|post|put|patch|delete)/.exec(m[1]) || [null, optionVerb ? optionVerb[1] : 'GET'])[1]).toUpperCase();
76
+ if (/^https?:\/\//.test(target) || /^\$\{[^}]+\}\//.test(target)) out.clients.push({ method: verb, url: target, file, line: at(m), literalHost: /^https?:\/\/[^/${]+/.test(target) });
77
+ }
78
+ for (const m of matches(CLIENT_DYN, code)) out.unknown.push({ kind: 'http', file, line: at(m), reason: `client URL comes from \`${m[1]}\`, not a literal` });
79
+ for (const m of matches(PRODUCE, code)) out.produces.push({ topic: m[2], file, line: at(m) });
80
+ for (const m of matches(PRODUCE_DYN, code)) out.unknown.push({ kind: 'messaging', file, line: at(m), reason: `topic comes from \`${m[1]}\`, not a literal` });
81
+ for (const m of matches(CONSUME, code)) out.consumes.push({ topic: m[2], file, line: at(m) });
82
+ for (const m of matches(SQL_WRITE, code)) out.writes.push({ table: m[1].toLowerCase(), file, line: at(m) });
83
+ for (const m of matches(SQL_READ, code)) out.reads.push({ table: m[1].toLowerCase(), file, line: at(m) });
84
+ for (const m of matches(COLL_WRITE, code)) out.writes.push({ table: String(m[1] || m[2]).toLowerCase(), file, line: at(m) });
85
+ for (const m of matches(COLL_READ, code)) out.reads.push({ table: String(m[1] || m[2]).toLowerCase(), file, line: at(m) });
86
+ for (const m of matches(DB_URL, code)) out.dbRefs.push(m[1]);
87
+ return out;
88
+ }
89
+
90
+ /**
91
+ * @param {Record<string,string>} fileContents repository-relative path -> source
92
+ * @param {{services:{name:string, root:string, hosts?:string[]}[], sharedDatabases?:{name?:string, services:string[], tables?:string[]}[]}} opts
93
+ * `services` is REQUIRED: without it nothing is inferred. `hosts` are the names other services use to reach this one.
94
+ * `sharedDatabases` is the declaration that makes a persistence edge observed rather than inferred.
95
+ */
96
+ export function buildServiceEdges(fileContents, opts = {}) {
97
+ const services = (opts.services || []).filter((s) => s && s.name && s.root);
98
+ if (services.length < 2) return { edges: [], unknown: [], services: services.map((s) => s.name), limits: LIMITS, optedIn: false, note: 'fewer than two services declared: no service relationships are inferred' };
99
+ const facts = new Map(services.map((s) => [s.name, { routes: [], clients: [], produces: [], consumes: [], writes: [], reads: [], dbRefs: new Set(), unknown: [] }]));
100
+ for (const [file, code] of Object.entries(fileContents || {}).sort(([a], [b]) => (a < b ? -1 : 1))) {
101
+ if (typeof code !== 'string' || !/\.(?:[cm]?[jt]sx?|py)$/.test(file)) continue;
102
+ const svc = serviceOf(file, services);
103
+ if (!svc) continue;
104
+ const f = extractFacts(posix(file), code);
105
+ const acc = facts.get(svc.name);
106
+ for (const k of ['routes', 'clients', 'produces', 'consumes', 'writes', 'reads', 'unknown']) acc[k].push(...f[k].map((x) => ({ ...x, service: svc.name })));
107
+ for (const d of f.dbRefs) acc.dbRefs.add(d);
108
+ }
109
+ const hostsOf = (s) => new Set([s.name, ...(s.hosts || [])].map((h) => h.toLowerCase()));
110
+ const edges = []; const unknown = [];
111
+ for (const [name, f] of facts) for (const u of f.unknown) unknown.push({ service: name, ...u, evidence: 'unknown' });
112
+
113
+ // HTTP: a client URL names a host and a path; the host picks the service, the method and path shape pick the route
114
+ for (const [from, f] of facts) for (const c of f.clients) {
115
+ const hm = /^https?:\/\/([^/:${]+)/.exec(c.url);
116
+ const host = hm ? hm[1].toLowerCase() : null;
117
+ const target = host ? services.find((s) => s.name !== from && hostsOf(s).has(host)) : null;
118
+ if (!target) { unknown.push({ service: from, kind: 'http', file: c.file, line: c.line, reason: host ? `host ${host} is not a declared service` : 'the host is configured at run time', evidence: 'unknown' }); continue; }
119
+ const route = facts.get(target.name).routes.find((r) => (r.method === c.method || r.method === 'ALL') && matchesShape(r.path, c.url));
120
+ if (!route) { unknown.push({ service: from, kind: 'http', file: c.file, line: c.line, reason: `${target.name} has no ${c.method} route matching ${norm(c.url)}`, evidence: 'unknown' }); continue; }
121
+ edges.push({ id: id(['http', from, c.file, c.method, shape(c.url), target.name, route.file]), kind: 'http', from: { service: from, file: c.file, line: c.line }, to: { service: target.name, file: route.file, line: route.line }, via: { method: c.method, path: shape(route.path) }, evidence: 'observed', basis: 'a literal client URL and a route with the same method and path shape' });
122
+ }
123
+ // Messaging: the same literal topic on a producer and a consumer in different services
124
+ for (const [from, f] of facts) for (const p of f.produces) {
125
+ const consumers = [...facts].filter(([n]) => n !== from).flatMap(([n, g]) => g.consumes.filter((c) => c.topic === p.topic).map((c) => ({ ...c, service: n })));
126
+ if (!consumers.length) { unknown.push({ service: from, kind: 'messaging', file: p.file, line: p.line, reason: `no other service consumes topic "${p.topic}"`, evidence: 'unknown' }); continue; }
127
+ for (const c of consumers) edges.push({ id: id(['messaging', from, p.file, p.topic, c.service, c.file]), kind: 'messaging', from: { service: from, file: p.file, line: p.line }, to: { service: c.service, file: c.file, line: c.line }, via: { topic: p.topic }, evidence: 'observed', basis: 'the same literal topic is produced and consumed' });
128
+ }
129
+ // Persistence: one service writes a table another reads
130
+ const shared = (opts.sharedDatabases || []).map((d) => ({ services: new Set(d.services), tables: d.tables ? new Set(d.tables.map((t) => t.toLowerCase())) : null }));
131
+ for (const [from, f] of facts) for (const w of f.writes) for (const [to, g] of facts) {
132
+ if (to === from) continue;
133
+ for (const r of g.reads.filter((x) => x.table === w.table)) {
134
+ const declared = shared.some((d) => d.services.has(from) && d.services.has(to) && (!d.tables || d.tables.has(w.table)));
135
+ edges.push({ id: id(['persistence', from, w.file, w.table, to, r.file]), kind: 'persistence', from: { service: from, file: w.file, line: w.line }, to: { service: to, file: r.file, line: r.line }, via: { table: w.table }, evidence: declared ? 'observed' : 'inferred', basis: declared ? 'both services use the table and the project declares them as sharing a database' : 'both services name the table, but nothing declares that they share a database' });
136
+ }
137
+ }
138
+ edges.sort((a, b) => (a.id < b.id ? -1 : 1));
139
+ unknown.sort((a, b) => (a.service + a.file + a.line < b.service + b.file + b.line ? -1 : 1));
140
+ return { edges, unknown, services: services.map((s) => s.name), limits: LIMITS, optedIn: true };
141
+ }
142
+
143
+ const LIMITS = Object.freeze([
144
+ 'only JavaScript, TypeScript and Python sources are read',
145
+ 'a topic, URL host or table assembled at run time is recorded as unknown and never connected',
146
+ 'serializer contracts (protobuf, JSON schema) are not read, so a shared message shape is not an edge',
147
+ 'an edge says two services are related in the code; it does not say attacker data crosses it. That needs a finding on each side',
148
+ 'no chain is ever labelled proven: this module reads text and does not run the services',
149
+ ]);
150
+
151
+ /**
152
+ * Bounded breadth-first trace from a service. Each path carries the weakest evidence on it. A path through an inferred edge is
153
+ * `static-inferred`; the label `static-observed` needs every hop observed; nothing is `proven`.
154
+ */
155
+ export function traceFrom(graph, start, { maxHops = MAX_HOPS, maxPaths = 200 } = {}) {
156
+ const hops = Math.max(1, Math.min(MAX_HOPS, maxHops));
157
+ const byFrom = new Map();
158
+ for (const e of graph.edges) { if (!byFrom.has(e.from.service)) byFrom.set(e.from.service, []); byFrom.get(e.from.service).push(e); }
159
+ const out = []; let truncated = false;
160
+ const queue = [{ at: start, path: [], seen: new Set([start]) }];
161
+ while (queue.length) {
162
+ const cur = queue.shift();
163
+ if (cur.path.length >= hops) { if ((byFrom.get(cur.at) || []).length) truncated = true; continue; }
164
+ for (const e of byFrom.get(cur.at) || []) {
165
+ if (cur.seen.has(e.to.service)) continue;
166
+ const path = [...cur.path, e];
167
+ if (out.length >= maxPaths) { truncated = true; break; }
168
+ const weakest = path.reduce((w, x) => (RANK[x.evidence] > RANK[w] ? x.evidence : w), 'observed');
169
+ out.push({ services: [start, ...path.map((x) => x.to.service)], edges: path.map((x) => x.id), kinds: path.map((x) => x.kind), evidence: weakest, label: weakest === 'observed' ? 'static-observed' : weakest === 'inferred' ? 'static-inferred' : 'unproven' });
170
+ queue.push({ at: e.to.service, path, seen: new Set([...cur.seen, e.to.service]) });
171
+ }
172
+ }
173
+ return { start, paths: out, truncated, maxHops: hops };
174
+ }
@@ -116,9 +116,14 @@ export class SummaryCache {
116
116
  }
117
117
 
118
118
  get(qid, taintedParams, receiverType) {
119
- return this._cache.get(this._key(qid, taintedParams, receiverType));
119
+ const r = this._cache.get(this._key(qid, taintedParams, receiverType));
120
+ if (r === undefined) this._misses = (this._misses || 0) + 1; else this._hits = (this._hits || 0) + 1;
121
+ return r;
120
122
  }
121
123
 
124
+ /** Lookups that found and did not find a summary (E6-01). */
125
+ stats() { return { hits: this._hits || 0, misses: this._misses || 0, size: this._cache.size }; }
126
+
122
127
  set(qid, taintedParams, summary, receiverType) {
123
128
  const k = this._key(qid, taintedParams, receiverType);
124
129
  this._cache.set(k, summary);
@@ -163,6 +168,7 @@ export class SummaryCache {
163
168
  return { returnTainted: false, mutatedParams: new Set(), taintedGlobals: new Set(), findings: [], _recursive: true };
164
169
  }
165
170
  if (++this._iter > this._maxIter) {
171
+ this._budgetHits = (this._budgetHits || 0) + 1;
166
172
  return { returnTainted: false, mutatedParams: new Set(), taintedGlobals: new Set(), findings: [], _budgetExceeded: true };
167
173
  }
168
174
  this._stack.add(qid);
@@ -51,7 +51,8 @@ export function toFindingShape(candidate) {
51
51
  description: candidate.rationale
52
52
  ? `${candidate.rationale} (entry point: ${candidate.entryPoint || 'unstated'}; sink: ${candidate.sink || 'unstated'})`
53
53
  : `Proposed by the ${candidate.lens} lens; no rationale supplied.`,
54
- remediation: `Review ${candidate.file}:${candidate.line}. Confirm whether ${candidate.entryPoint || 'attacker-controlled input'} can reach ${candidate.sink || 'this operation'}, and constrain it at the boundary if so.`,
54
+ // a candidate that carries its own concrete remediation (a deterministic finding with a known fix shape) keeps it; every other candidate gets the generic review text
55
+ remediation: (typeof candidate.remediation === 'string' && candidate.remediation) ? candidate.remediation : `Review ${candidate.file}:${candidate.line}. Confirm whether ${candidate.entryPoint || 'attacker-controlled input'} can reach ${candidate.sink || 'this operation'}, and constrain it at the boundary if so.`,
55
56
  parser: 'DISCOVERY',
56
57
  family: candidate.family || 'other',
57
58
  ruleId: `discovery:${candidate.lens}`,
package/src/engine.js CHANGED
@@ -58,7 +58,8 @@ import { proofCoverage } from './posture/proof-coverage.js';
58
58
  import { computeScanHealth, applyFreshness } from './pipeline/scan-health.js';
59
59
  import { computeCoverageLedger, summarizeCoverageForScanHealth } from './pipeline/coverage-ledger.js';
60
60
  import { runAnnotatorAsync } from './pipeline/annotator-runner.js';
61
- import { runDetector } from './pipeline/detector-runner.js';
61
+ import { runDetector, setDetectorTimingSink } from './pipeline/detector-runner.js';
62
+ import { createTelemetry, attributeErrors } from './pipeline/telemetry.js';
62
63
  import { registerProducer } from './pipeline/producer-registry.js';
63
64
  import { collectProducerResult } from './pipeline/producer-collector.js';
64
65
  import { completeEnrichment } from './pipeline/enrichment-completion.js';
@@ -129,6 +130,8 @@ import { scanClientSide } from './sast/client-side.js';
129
130
  import { scanPromptFirewall } from './sast/prompt-firewall.js';
130
131
  import { scanLlmRedteam } from './posture/llm-redteam.js';
131
132
  import { assessPrivacyFramework, persistPrivacyFramework } from './posture/privacy-framework.js';
133
+ import { signLastScan } from './posture/integrity.js';
134
+ import { secretCanaryHit } from './posture/runtime-trace-ingest.js';
132
135
  import { safeWriteState as _safeWriteState, statePath, statePath as _statePath } from './posture/state-dir.js';
133
136
  import { scanContainer } from './sca/container.js';
134
137
  import { detectDepConfusion } from './sca/dep-confusion.js';
@@ -210,6 +213,7 @@ import { applyPathConstraints } from './posture/path-predicates.js';
210
213
  import { buildProjectIR, buildProjectIRAsync } from './ir/index.js';
211
214
  import _v8 from 'node:v8';
212
215
  import _vm from 'node:vm';
216
+ import { createStringMemo } from './util/string-memo.js';
213
217
  import { collectIrStats, irStatsTarget, writeIrStats } from './ir/ir-stats.js';
214
218
  import { runDeepAnalysis } from './dataflow/index.js';
215
219
  import { buildLineageGraph } from './lineage/index.js';
@@ -446,7 +450,11 @@ function _ruleAppliesIn(pat, ctx){
446
450
  // comments would put the second pass into a stuck string state, blanking the
447
451
  // rest of the file. Backtick `${...}` template expressions are preserved
448
452
  // verbatim because they contain real source/sink references.
449
- function stripNoiseAndStrings(code){
453
+ // Called by several detectors per file with the same text; the result depends only on the text, so it is computed once per distinct text
454
+ // (util/string-memo.js). AGENTIC_SECURITY_NO_STRING_MEMO=1 turns the memo off.
455
+ const _stripNoiseAndStringsMemo = createStringMemo((c) => _stripNoiseAndStringsRaw(c), { version: 'stripNoiseAndStrings@1', disableEnv: 'AGENTIC_SECURITY_NO_STRING_MEMO' });
456
+ function stripNoiseAndStrings(code){ return _stripNoiseAndStringsMemo(code); }
457
+ function _stripNoiseAndStringsRaw(code){
450
458
  const out = code.split('');
451
459
  const n = code.length;
452
460
  let i = 0;
@@ -3134,6 +3142,27 @@ function getProjectConstant(name){
3134
3142
  return hit ? hit.value : null;
3135
3143
  }
3136
3144
 
3145
+ // E6-04: a digest of every input OUTSIDE a single file that the per-file detector cascade reads: the project index (constants, properties, the
3146
+ // e-commerce flag), the Java tainted-method index, and the operator's rules file with its signature. A cached per-file result is reused only when this
3147
+ // is unchanged, so a change to a constant in one file cannot leave another file's cached answer standing.
3148
+ const _ANALYSIS_CACHE_NEVER_RE = /(?:^|[\\/])\.?mcp(?:\.[a-z]+)?\.json$|(?:^|[\\/])\.mcp\.json$/i;
3149
+ function _analysisContextDigest(scanRoot){
3150
+ const h = crypto.createHash('sha256');
3151
+ const mapEntries = (m) => [...(m || new Map()).entries()].map(([k, v]) => [k, v]).sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
3152
+ h.update(JSON.stringify({
3153
+ ecommerce: !!_projectIndex.hasEcommerceModel,
3154
+ byFile: mapEntries(_projectIndex.constantsByFile),
3155
+ byExport: mapEntries(_projectIndex.constantsByExport),
3156
+ props: mapEntries(_projectIndex.javaProperties),
3157
+ javaTainted: [..._GLOBAL_JAVA_TAINTED_METHODS].sort(),
3158
+ }));
3159
+ for (const name of ['rules.yml', 'rules.yaml', 'rules.json', 'rules.yml.sig', 'rules.yaml.sig', 'rules.json.sig']) {
3160
+ h.update(`\n${name}\0`);
3161
+ try { h.update(fs.readFileSync(statePath(scanRoot, name))); } catch { h.update('absent'); }
3162
+ }
3163
+ return h.digest('hex');
3164
+ }
3165
+
3137
3166
  const _COUPON_MUTATION_RE = /\b(?:apply|redeem|validate|use|deduct|decrement|increment|update|save|consume|create|destroy|remove|insert)\b/i;
3138
3167
  const _REAUTH_PRESENT_RE = /\b(?:bcrypt\.compare|crypto\.timingSafeEqual|currentPassword|recentLogin|verifyTotp|verifyMfa|mfaToken|requireRecentLogin|requireFreshSession|step[-_]?up|requireFreshAuth|verifyPassword)\b/;
3139
3168
 
@@ -8859,7 +8888,12 @@ function _gcHint(minHeapMiB) {
8859
8888
  _gcFn();
8860
8889
  }
8861
8890
 
8862
- async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null, resume=undefined, deep=undefined, deepInCi=undefined, provenance=true, completeScan=true, skipAnnotators=false, policyExcluded=[], sizeSkipped=[]}, setProgress=()=>{}){_resetSuppressions();_buildProjectIndex(fileContents);await _loadCustomRules(scanRoot);
8891
+ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null, resume=undefined, deep=undefined, deepInCi=undefined, provenance=true, completeScan=true, skipAnnotators=false, policyExcluded=[], sizeSkipped=[]}, setProgress=()=>{}){
8892
+ // E6-01: local, aggregate telemetry for this scan (see pipeline/telemetry.js). Nothing is sent anywhere; the snapshot is a field of the result.
8893
+ const _tel = createTelemetry({ deterministic: isDeterministic() });
8894
+ setDetectorTimingSink((name, ms) => _tel.detector(name, ms));
8895
+ _tel.sample();
8896
+ _resetSuppressions();_tel.time('index', () => _buildProjectIndex(fileContents));await _tel.timeAsync('custom-rules', () => _loadCustomRules(scanRoot));
8863
8897
  // Pre-pass: build cross-file Java tainted-method index so per-file taint
8864
8898
  // analysis can recognize calls to user-input-returning helper methods
8865
8899
  // defined in OTHER files (Juliet's DataflowThruInnerClass / Vector / Stream
@@ -8885,17 +8919,20 @@ function _deterministicFileTimings(timings) {
8885
8919
  }
8886
8920
 
8887
8921
  const _fileTimings = [];
8888
- let _filesSkipped = 0, _filesTimedOut = 0, _filesDenseSkipped = 0;
8922
+ let _filesSkipped = 0, _filesTimedOut = 0, _filesDenseSkipped = 0, _filesOversizeSkipped = 0;
8889
8923
  const files=Object.keys(fileContents).filter(f=>(shouldScan(f) || isKubernetesManifest(f, fileContents[f]) || isCloudFormationTemplate(f, fileContents[f]) || isInstructionFile(f)) && !_isPathIgnored(f));const fc={},pfr={};const aR=[],aF=[],aSrc=[],aSink=[],aSan=[],aLogic=[],aSupply=[],aSecrets=[],aCiphersRest=[],aCiphersTransit=[];const _detectorErrors=[];
8890
8924
  // ---- R8: opt-in per-file checkpointing (AGENTIC_SECURITY_RESUME=1, or
8891
8925
  // runScan({resume:true})). Default OFF, so existing behaviour is untouched.
8892
8926
  // Only this loop is checkpointed; every cross-file pass below re-runs, so
8893
8927
  // nothing that depends on the whole tree can be stale on resume.
8894
8928
  const _ckptEnabled = (resume === undefined ? process.env.AGENTIC_SECURITY_RESUME === '1' : !!resume) && !!scanRoot;
8929
+ // E6-04: opt-in cross-scan analysis cache (AGENTIC_SECURITY_ANALYSIS_CACHE=1). Same per-file record format as the resume checkpoint, but it is kept after
8930
+ // a completed scan, its records are authenticated with the install key, and the global key also covers the cross-file inputs a file's cascade reads.
8931
+ const _cacheEnabled = !_ckptEnabled && process.env.AGENTIC_SECURITY_ANALYSIS_CACHE === '1' && !!scanRoot;
8895
8932
  let _ckpt = null; const _ckptPayloads = new Map(); let _ckptDone = new Set(); let _ckptResumed = 0, _ckptWrites = 0;
8896
8933
  const _ckptAbortAfter = parseInt(process.env.AGENTIC_SECURITY_CHECKPOINT_ABORT_AFTER || '0', 10) || 0;
8897
8934
  let _ckptInvalidated = [];
8898
- if (_ckptEnabled) {
8935
+ if (_ckptEnabled || _cacheEnabled) {
8899
8936
  try {
8900
8937
  const _ckptIdentity = {
8901
8938
  engineVersion: _ENGINE_VERSION,
@@ -8909,8 +8946,9 @@ function _deterministicFileTimings(timings) {
8909
8946
  // instead, so changing one file no longer discards every other file's
8910
8947
  // already-completed work — see scan-checkpoint.js's module header.
8911
8948
  _ckpt = openCheckpoint(scanRoot, {
8912
- globalKey: computeGlobalKey(_ckptIdentity),
8949
+ globalKey: computeGlobalKey(_cacheEnabled ? { ..._ckptIdentity, extra: _analysisContextDigest(scanRoot) } : _ckptIdentity),
8913
8950
  meta: globalKeyMeta(_ckptIdentity),
8951
+ ...(_cacheEnabled ? { persistent: true, fileName: 'analysis-cache.jsonl', sign: signLastScan } : {}),
8914
8952
  // Haskell/Nix sources hash with their import closure, so an imported
8915
8953
  // module's change invalidates its importers' checkpointed work too.
8916
8954
  fileContents: withLanguageClosure(fileContents, depFileContents),
@@ -8939,6 +8977,17 @@ function _deterministicFileTimings(timings) {
8939
8977
  for (const x of (d.ciphersRest || [])) aCiphersRest.push(x);
8940
8978
  for (const x of (d.ciphersTransit || [])) aCiphersTransit.push(x);
8941
8979
  for (const x of (d.suppressions || [])) _suppressionLog.push(x);
8980
+ // A finding's `source` and `sink` ARE the entries of this file's source and sink lists (one object, two references), and later passes annotate them in
8981
+ // place (flowStatus, riskStatus, ...). Reading a record back from JSON gives each reference its own copy, so the annotation would reach the list entry
8982
+ // and not the finding. Re-pointing the finding at the matching list entry restores the sharing a fresh analysis has.
8983
+ {
8984
+ const srcByJson = new Map(); for (const x of aSrc.slice(s0)) srcByJson.set(JSON.stringify(x), x);
8985
+ const sinkByJson = new Map(); for (const x of aSink.slice(k0)) sinkByJson.set(JSON.stringify(x), x);
8986
+ for (const f of aF.slice(f0)) {
8987
+ if (f && f.source && typeof f.source === 'object') { const m = srcByJson.get(JSON.stringify(f.source)); if (m) f.source = m; }
8988
+ if (f && f.sink && typeof f.sink === 'object') { const m = sinkByJson.get(JSON.stringify(f.sink)); if (m) f.sink = m; }
8989
+ }
8990
+ }
8942
8991
  const ta = Object.assign({}, d.pfr || {});
8943
8992
  ta.findings = aF.slice(f0, f0 + (d.pfrFindings || 0));
8944
8993
  ta.sources = aSrc.slice(s0); ta.sinks = aSink.slice(k0); ta.sanitizers = aSan.slice(n0);
@@ -8947,9 +8996,10 @@ function _deterministicFileTimings(timings) {
8947
8996
  _ckptResumed++;
8948
8997
  return true;
8949
8998
  };
8999
+ let _cacheSecretSkips = 0;
8950
9000
  const _ckptRecord = (p, mk, ms, ta) => {
8951
9001
  if (!_ckpt || !_ckpt.enabled) return;
8952
- recordFileDone(_ckpt, p, {
9002
+ const _payload = {
8953
9003
  routes: aR.slice(mk.aR), findings: aF.slice(mk.aF),
8954
9004
  sources: aSrc.slice(mk.aSrc), sinks: aSink.slice(mk.aSink), sanitizers: aSan.slice(mk.aSan),
8955
9005
  logic: aLogic.slice(mk.aLogic), secrets: aSecrets.slice(mk.aSecrets),
@@ -8958,7 +9008,10 @@ function _deterministicFileTimings(timings) {
8958
9008
  pfr: _pfrMetaOnly(ta),
8959
9009
  pfrFindings: (ta && Array.isArray(ta.findings)) ? ta.findings.length : 0,
8960
9010
  ms,
8961
- });
9011
+ };
9012
+ // A cache that outlives the scan must not become a second copy of a secret: a file whose result contains a secret-shaped value is analysed afresh each time.
9013
+ if (_cacheEnabled) { let _txt = ''; try { _txt = JSON.stringify(_payload); } catch { /* not serializable: recordFileDone refuses it too */ } if (secretCanaryHit(_txt)) { _cacheSecretSkips++; return; } }
9014
+ recordFileDone(_ckpt, p, _payload);
8962
9015
  _ckptWrites++;
8963
9016
  // Fault injection for the resume test: a hard exit with no unwinding, which
8964
9017
  // is what the checkpoint format has to survive. Never set in normal use.
@@ -8978,12 +9031,13 @@ function _deterministicFileTimings(timings) {
8978
9031
  });
8979
9032
  }
8980
9033
  let i=0;
9034
+ const _tDet = Date.now();
8981
9035
  try {
8982
9036
  for(const p of files){i++;const _ft0=Date.now();setProgress({current:i,total:files.length,file:p.split("/").pop(),phase:"Scanning"});
8983
9037
  if(_ckptDone.has(p)&&_ckptReplay(p))continue;
8984
9038
  const _mk={aR:aR.length,aF:aF.length,aSrc:aSrc.length,aSink:aSink.length,aSan:aSan.length,aLogic:aLogic.length,aSecrets:aSecrets.length,aCR:aCiphersRest.length,aCT:aCiphersTransit.length,sup:_suppressionLog.length};
8985
- try{const c=fileContents[p];if(!c||c.length>500000){_filesSkipped++;continue;}const _avgLine=c.length/Math.max(c.split('\n').length,1);if(_avgLine>400&&c.length>10000){_filesDenseSkipped++;continue;}
8986
- let _delta;
9039
+ try{const c=fileContents[p];if(!c||c.length>500000){_filesSkipped++;if(c&&c.length>500000)_filesOversizeSkipped++;continue;}const _avgLine=c.length/Math.max(c.split('\n').length,1);if(_avgLine>400&&c.length>10000){_filesDenseSkipped++;continue;}
9040
+ let _delta; const _errsBefore = _detectorErrors.length;
8987
9041
  if (_cascadePool) {
8988
9042
  const _res = await _cascadePool.runFile(p, c, scanRoot, _detectorErrors, { timeoutMs: _perFileTimeoutMs });
8989
9043
  if (_res.ok) { _delta = _res.result; }
@@ -8997,7 +9051,9 @@ function _deterministicFileTimings(timings) {
8997
9051
  const _ftElapsed=Date.now()-_ft0;
8998
9052
  if(_ftElapsed>_perFileTimeoutMs){aF.push({id:`file-timeout:${p}`,file:p,line:0,vuln:`File analysis exceeded ${_perFileTimeoutMs}ms (${_ftElapsed}ms)`,severity:'info',parser:'ENGINE',confidence:0.5,_timeout:true});_filesTimedOut++;}
8999
9053
  _fileTimings.push({file:p,ms:_ftElapsed});
9000
- _ckptRecord(p,_mk,_ftElapsed,_delta.pfr);
9054
+ // The cache only keeps a file whose analysis finished cleanly: a detector that threw, a timeout, or a file whose analysis reads or writes state outside
9055
+ // itself (the MCP rug-pull baseline) is analysed afresh every time so health reporting and side effects stay exactly as they are.
9056
+ if (!_cacheEnabled || (_detectorErrors.length === _errsBefore && _ftElapsed <= _perFileTimeoutMs && !_ANALYSIS_CACHE_NEVER_RE.test(p))) _ckptRecord(p,_mk,_ftElapsed,_delta.pfr);
9001
9057
  }catch(_){_fileTimings.push({file:p,ms:Date.now()-_ft0,error:true});}
9002
9058
  if(i%5===0)await new Promise(r=>setTimeout(r,0));}
9003
9059
  } finally {
@@ -9146,6 +9202,8 @@ function _deterministicFileTimings(timings) {
9146
9202
  };
9147
9203
  filterArr(aF); filterArr(aLogic); filterArr(aSecrets); filterArr(aSupply);
9148
9204
  }
9205
+ _tel.record('detectors-per-file', Date.now() - _tDet);
9206
+ const _tLink = Date.now();
9149
9207
  setProgress({current:i,total:files.length,file:"Cross-file...",phase:"Linking"});const ii=buildImportGraph(fc);const cf=crossFileTaint(pfr,fc,ii);aF.push(...cf);
9150
9208
  setProgress({current:i,total:files.length,file:"Stored taint...",phase:"Linking"});const storedRegistry=buildStoredTaintRegistry(fc);const stf=crossStoredTaint(fc,storedRegistry);aF.push(...stf);
9151
9209
  setProgress({current:i,total:files.length,file:"Session taint...",phase:"Linking"});const sess=crossSessionTaint(fc);aF.push(...sess);
@@ -9153,6 +9211,7 @@ function _deterministicFileTimings(timings) {
9153
9211
  // R19 (PRD §5): cross-route BOLA/BFLA over the aggregated route inventory.
9154
9212
  // Convention deviation is PROJECT-scoped by design: the convention is a
9155
9213
  // property of the codebase, not of one file (see convention-deviation.js).
9214
+ _tel.record('cross-file-linking', Date.now() - _tLink);
9156
9215
  aF.push(...(runDetector(_detectorErrors,'<project>','scanConventionDeviationProject',()=>scanConventionDeviationProject(fc))||[]));
9157
9216
  aF.push(...(runDetector(_detectorErrors,'<project>','scanApiBrokenAuthz',()=>scanApiBrokenAuthz(aR))||[]));
9158
9217
  // R22 (PRD §5): cross-service edges inferred from code (client call → matched route).
@@ -9432,6 +9491,7 @@ function _deterministicFileTimings(timings) {
9432
9491
  const _deepEnabled = _deepRequested && (!_inCi || _deepInCiAllowed);
9433
9492
  let _deepCallGraph = null;
9434
9493
  let _deepFailure = null;
9494
+ let _deepIncomplete = null;
9435
9495
  if (_deepEnabled) {
9436
9496
  const budgetMs = parseInt(process.env.AGENTIC_SECURITY_DEEP_TIMEOUT_MS || '300000', 10);
9437
9497
  const t0 = Date.now();
@@ -9462,6 +9522,8 @@ function _deterministicFileTimings(timings) {
9462
9522
  }),
9463
9523
  });
9464
9524
  const elapsed = Date.now() - t0;
9525
+ _tel.record('deep-analysis', elapsed);
9526
+ if (irFindings.analysisIncomplete && irFindings._summaryCache && typeof irFindings._summaryCache.stats === 'function') { const st = irFindings._summaryCache.stats(); _tel.set('cache.summary.hits', st.hits); _tel.set('cache.summary.misses', st.misses); }
9465
9527
  if (elapsed > budgetMs) {
9466
9528
  // We exceeded budget — surface a single info finding so operators see it.
9467
9529
  aF.push({
@@ -9473,6 +9535,7 @@ function _deterministicFileTimings(timings) {
9473
9535
  confidence: 0.5,
9474
9536
  });
9475
9537
  }
9538
+ if (Array.isArray(irFindings.analysisIncomplete) && irFindings.analysisIncomplete.length) _deepIncomplete = irFindings.analysisIncomplete;
9476
9539
  for (const f of irFindings) {
9477
9540
  f.unvalidated = true;
9478
9541
  f.validator_verdict = 'unvalidated';
@@ -9514,6 +9577,8 @@ function _deterministicFileTimings(timings) {
9514
9577
  ? (_inCi ? 'requested, but running in CI without AGENTIC_SECURITY_DEEP_IN_CI=1' : 'unknown')
9515
9578
  : (_inCi ? 'not requested (deep analysis defaults to off in CI)' : 'not requested')),
9516
9579
  failure: _deepFailure,
9580
+ // E2-05: where the deep analysis stopped short (budget, deadline, function limit, a thrown analysis). Present only when something did.
9581
+ ...(_deepIncomplete ? { incomplete: _deepIncomplete } : {}),
9517
9582
  };
9518
9583
  // Sub-project E, increment 5 — Data Flow Explorer lineage graph.
9519
9584
  // DELIBERATELY INDEPENDENT of AGENTIC_SECURITY_DEEP/_deepEnabled above
@@ -9861,7 +9926,7 @@ function _deterministicFileTimings(timings) {
9861
9926
  let _annotatorIdx = 0;
9862
9927
  const _runAnnotator = (phase, fn) => {
9863
9928
  setProgress({ phase: 'Annotating', current: ++_annotatorIdx, total: _ANNOTATOR_TOTAL_ESTIMATE, file: phase });
9864
- return runAnnotatorAsync(_annotatorErrors, phase, fn);
9929
+ return _tel.timeAsync(`annotator:${phase}`, () => runAnnotatorAsync(_annotatorErrors, phase, fn));
9865
9930
  };
9866
9931
  await _runAnnotator('annotateStableIds', () => annotateStableIds(finalFindings));
9867
9932
  await _runAnnotator("clusterByRootCause", () => { finalFindings = clusterByRootCause(finalFindings); });
@@ -10740,7 +10805,7 @@ function _deterministicFileTimings(timings) {
10740
10805
  // seen when only N-of-those-candidates were actually analyzed.
10741
10806
  // checkpoint.total intentionally keeps files.length — that field means the
10742
10807
  // full candidate set for resume bookkeeping, a different, correct meaning.
10743
- _scanMeta={filesScanned:Object.keys(fc).length,filesSkipped:_filesSkipped,filesDenseSkipped:_filesDenseSkipped,filesTimedOut:_filesTimedOut,analysisTier:_analysisTier,unmodeledSinkCandidates:_unmodeledSinks,fileTimings:_deterministicFileTimings(_fileTimings),findingsBySeverity:{critical:finalFindings.filter(f=>f.severity==='critical').length,high:finalFindings.filter(f=>f.severity==='high').length,medium:finalFindings.filter(f=>f.severity==='medium').length,low:finalFindings.filter(f=>f.severity==='low').length,info:finalFindings.filter(f=>f.severity==='info').length},checkpoint:{enabled:!!(_ckpt&&_ckpt.enabled),resumed:_ckptResumed,total:files.length,discarded:!!(_ckpt&&_ckpt.discarded),discardReason:(_ckpt&&_ckpt.discarded)?(_ckpt.reason||null):null,invalidatedFileCount:_ckptInvalidated.length,invalidatedFiles:_ckptInvalidated.slice(0,20),invalidatedFilesTruncated:_ckptInvalidated.length>20}};
10808
+ _scanMeta={filesScanned:Object.keys(fc).length,filesSkipped:_filesSkipped,filesOversizeSkipped:_filesOversizeSkipped,filesDenseSkipped:_filesDenseSkipped,filesTimedOut:_filesTimedOut,analysisTier:_analysisTier,unmodeledSinkCandidates:_unmodeledSinks,fileTimings:_deterministicFileTimings(_fileTimings),findingsBySeverity:{critical:finalFindings.filter(f=>f.severity==='critical').length,high:finalFindings.filter(f=>f.severity==='high').length,medium:finalFindings.filter(f=>f.severity==='medium').length,low:finalFindings.filter(f=>f.severity==='low').length,info:finalFindings.filter(f=>f.severity==='info').length},checkpoint:{enabled:!!(_ckpt&&_ckpt.enabled),...(_cacheEnabled?{kind:'analysis-cache',secretSkips:_cacheSecretSkips}:{}),resumed:_ckptResumed,total:files.length,discarded:!!(_ckpt&&_ckpt.discarded),discardReason:(_ckpt&&_ckpt.discarded)?(_ckpt.reason||null):null,invalidatedFileCount:_ckptInvalidated.length,invalidatedFiles:_ckptInvalidated.slice(0,20),invalidatedFilesTruncated:_ckptInvalidated.length>20}};
10744
10809
  // R8: the scan completed, so the checkpoint has been fully consumed — remove
10745
10810
  // it. Anything that threw before this point leaves it in place to resume from.
10746
10811
  try { closeCheckpoint(_ckpt, { complete: true }); } catch (_) {}
@@ -11163,7 +11228,10 @@ function _deterministicFileTimings(timings) {
11163
11228
  compliance: _complianceReport ? { stale: _complianceReport.summary?.stale || 0 } : null,
11164
11229
  });
11165
11230
  } // end if (!skipAnnotators) — FR-PROV-029
11166
- return{entrypointInventory:_entrypointInventory,rootCauseSweep:_rootCauseSweep,proofCoverage:_proofCoverage,kevCatalog:kevCatalogMeta(),routes:dd(aR,r=>`${r.method}:${r.path}:${r.file}:${r.line}`),findings:finalFindings,sources:aSrc,sinks:aSink,sanitizers:aSan,filesScanned:files.length,linesScanned:Object.values(fc).reduce((_n,_c)=>_n+(typeof _c==='string'?_c.split("\n").length:0),0),crossFileCount:cf.length,logicVulns:aLogic,supplyChain,components:annotatedComponents,secrets:aSecrets,ciphers:{atRest:aCiphersRest,inTransit:aCiphersTransit},pfr,fc,depFileContents,suppressions:_getSuppressions(),...(_stageEvidenceOn()?{stageEvidence:{dedupe:[..._stageEvidence.dedupe],guard:[..._stageEvidence.guard]}}:{}),_v3,_scanMeta,_engineErrors:{cppDataflowParseErrors:_cppDataflowParseErrors.value},annotatorErrors:_annotatorErrors,detectorErrors:_detectorErrors,executionProof:_executionProofSummary,logicClaims:_logicClaims,vulnHistory:_vulnHistory,threatModel:_threatModel,privacyFramework:_privacyFramework,privacyIrBacked:_privacyIrBacked,privacyTaxonomyVersion:_privacyTaxonomyVersion,sbomDiff:_sbomDiff,complianceReport:_complianceReport,exploitBundles:_exploitBundles,pqcPlan:_pqcPlan,licenseGraph:_licenseGraph,attributions:_attributions,attackTaxonomy:_taxonomySummary,scanHealth:_scanHealth,coverageLedger:_coverageLedger,lineageGraph:_lineageGraph,lineageStatus:_lineageStatus,languageBridges:_languageBridges,languageBom:_languageBom,aiAssistance:_aiAssistance};}
11231
+ setDetectorTimingSink(null);
11232
+ attributeErrors(_tel, { annotatorErrors: _annotatorErrors, detectorErrors: (typeof _detectorErrors !== 'undefined' ? _detectorErrors : []), deepFailure: _deepFailure, filesTimedOut: (_scanMeta && _scanMeta.filesTimedOut) || 0 });
11233
+ _tel.set('files.scanned', (_scanMeta && _scanMeta.filesScanned) || 0); _tel.set('files.skipped', (_scanMeta && _scanMeta.filesSkipped) || 0);
11234
+ return{telemetry:_tel.snapshot(),entrypointInventory:_entrypointInventory,rootCauseSweep:_rootCauseSweep,proofCoverage:_proofCoverage,kevCatalog:kevCatalogMeta(),routes:dd(aR,r=>`${r.method}:${r.path}:${r.file}:${r.line}`),findings:finalFindings,sources:aSrc,sinks:aSink,sanitizers:aSan,filesScanned:files.length,linesScanned:Object.values(fc).reduce((_n,_c)=>_n+(typeof _c==='string'?_c.split("\n").length:0),0),crossFileCount:cf.length,logicVulns:aLogic,supplyChain,components:annotatedComponents,secrets:aSecrets,ciphers:{atRest:aCiphersRest,inTransit:aCiphersTransit},pfr,fc,depFileContents,suppressions:_getSuppressions(),...(_stageEvidenceOn()?{stageEvidence:{dedupe:[..._stageEvidence.dedupe],guard:[..._stageEvidence.guard]}}:{}),_v3,_scanMeta,_engineErrors:{cppDataflowParseErrors:_cppDataflowParseErrors.value},annotatorErrors:_annotatorErrors,detectorErrors:_detectorErrors,executionProof:_executionProofSummary,logicClaims:_logicClaims,vulnHistory:_vulnHistory,threatModel:_threatModel,privacyFramework:_privacyFramework,privacyIrBacked:_privacyIrBacked,privacyTaxonomyVersion:_privacyTaxonomyVersion,sbomDiff:_sbomDiff,complianceReport:_complianceReport,exploitBundles:_exploitBundles,pqcPlan:_pqcPlan,licenseGraph:_licenseGraph,attributions:_attributions,attackTaxonomy:_taxonomySummary,scanHealth:_scanHealth,coverageLedger:_coverageLedger,lineageGraph:_lineageGraph,lineageStatus:_lineageStatus,languageBridges:_languageBridges,languageBom:_languageBom,aiAssistance:_aiAssistance};}
11167
11235
 
11168
11236
  // Post-aggregation classification: every source becomes "unsafe"|"safe"; every sink becomes "confirmed"|"safe".
11169
11237
  // Orphans (no finding linkage) are bucketed by file-local heuristic so the UI shows binary states only.