@clear-capabilities/agentic-security-scanner 0.144.0 → 0.147.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 (209) hide show
  1. package/CHANGELOG.md +390 -0
  2. package/bin/agentic-security.js +3813 -83
  3. package/dist/1122.index.js +702 -0
  4. package/dist/{301.index.js → 1301.index.js} +2 -2
  5. package/dist/1379.index.js +591 -0
  6. package/dist/{444.index.js → 1444.index.js} +13 -4
  7. package/dist/{660.index.js → 1660.index.js} +2 -2
  8. package/dist/{700.index.js → 1700.index.js} +2 -2
  9. package/dist/{905.index.js → 1905.index.js} +3 -3
  10. package/dist/{920.index.js → 1920.index.js} +3 -3
  11. package/dist/{238.index.js → 2238.index.js} +3 -3
  12. package/dist/2271.index.js +165 -0
  13. package/dist/{985.index.js → 2376.index.js} +1260 -340
  14. package/dist/2432.index.js +793 -0
  15. package/dist/2659.index.js +93 -0
  16. package/dist/{826.index.js → 2826.index.js} +2 -2
  17. package/dist/{830.index.js → 2830.index.js} +2 -2
  18. package/dist/2923.index.js +298 -0
  19. package/dist/{1.index.js → 3001.index.js} +5 -5
  20. package/dist/{117.index.js → 3117.index.js} +3 -3
  21. package/dist/3180.index.js +307 -0
  22. package/dist/3276.index.js +117 -0
  23. package/dist/{415.index.js → 3415.index.js} +2 -2
  24. package/dist/{499.index.js → 3499.index.js} +2 -2
  25. package/dist/3518.index.js +450 -0
  26. package/dist/{526.index.js → 3526.index.js} +14 -6
  27. package/dist/{736.index.js → 3736.index.js} +4 -4
  28. package/dist/{839.index.js → 3839.index.js} +4 -4
  29. package/dist/{113.index.js → 4113.index.js} +14 -6
  30. package/dist/{265.index.js → 4265.index.js} +2 -2
  31. package/dist/{384.index.js → 4384.index.js} +3 -3
  32. package/dist/4547.index.js +268 -0
  33. package/dist/4863.index.js +422 -0
  34. package/dist/{970.index.js → 4970.index.js} +67 -3
  35. package/dist/5051.index.js +770 -0
  36. package/dist/{144.index.js → 5144.index.js} +5 -5
  37. package/dist/{333.index.js → 5333.index.js} +3 -3
  38. package/dist/5343.index.js +185 -0
  39. package/dist/5350.index.js +866 -0
  40. package/dist/5561.index.js +436 -0
  41. package/dist/{637.index.js → 5637.index.js} +29 -7
  42. package/dist/{449.index.js → 5830.index.js} +78 -14
  43. package/dist/6626.index.js +532 -0
  44. package/dist/6662.index.js +297 -0
  45. package/dist/{675.index.js → 6675.index.js} +5 -5
  46. package/dist/{730.index.js → 6730.index.js} +6 -6
  47. package/dist/6829.index.js +225 -0
  48. package/dist/6944.index.js +130 -0
  49. package/dist/{178.index.js → 7178.index.js} +26 -8
  50. package/dist/{227.index.js → 7227.index.js} +2 -2
  51. package/dist/7310.index.js +520 -0
  52. package/dist/{552.index.js → 7552.index.js} +4 -4
  53. package/dist/7709.index.js +78 -0
  54. package/dist/8218.index.js +160 -0
  55. package/dist/{476.index.js → 8476.index.js} +4 -4
  56. package/dist/{513.index.js → 8513.index.js} +5 -5
  57. package/dist/{520.index.js → 8520.index.js} +2 -2
  58. package/dist/{718.index.js → 8718.index.js} +2 -2
  59. package/dist/{752.index.js → 8752.index.js} +2 -2
  60. package/dist/8846.index.js +100 -0
  61. package/dist/{435.index.js → 9091.index.js} +580 -187
  62. package/dist/{207.index.js → 9207.index.js} +2 -2
  63. package/dist/{220.index.js → 9220.index.js} +2 -2
  64. package/dist/9390.index.js +163 -0
  65. package/dist/{503.index.js → 9503.index.js} +2 -2
  66. package/dist/{801.index.js → 9801.index.js} +2 -2
  67. package/dist/{824.index.js → 9824.index.js} +2 -2
  68. package/dist/agentic-security.mjs +16 -16
  69. package/dist/agentic-security.mjs.sha256 +1 -1
  70. package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
  71. package/package.json +23 -10
  72. package/src/compare.js +6 -1
  73. package/src/dataflow/CLAUDE.md +2 -2
  74. package/src/dataflow/catalog.js +42 -0
  75. package/src/dataflow/orm-write-catalog.js +175 -0
  76. package/src/engine.js +580 -30
  77. package/src/fix/apply-fix-service.js +1 -0
  78. package/src/history-scan.js +22 -5
  79. package/src/ir/CLAUDE.md +2 -1
  80. package/src/ir/chrome-probe.mjs +150 -0
  81. package/src/ir/parser-js.js +94 -7
  82. package/src/lineage/CLAUDE.md +1203 -0
  83. package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
  84. package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
  85. package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
  86. package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
  87. package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
  88. package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
  89. package/src/lineage/DESIGN_REGISTRIES.md +880 -0
  90. package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
  91. package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
  92. package/src/lineage/classification.js +56 -0
  93. package/src/lineage/coverage.js +658 -0
  94. package/src/lineage/cross-repo-link.js +107 -0
  95. package/src/lineage/dataflow-graph.schema.json +184 -0
  96. package/src/lineage/decision-story.js +206 -0
  97. package/src/lineage/drift-policy.js +279 -0
  98. package/src/lineage/driver.js +135 -0
  99. package/src/lineage/engine.js +992 -0
  100. package/src/lineage/export-briefing.js +628 -0
  101. package/src/lineage/export-csv.js +62 -0
  102. package/src/lineage/export-json.js +238 -0
  103. package/src/lineage/export-privacy.js +258 -0
  104. package/src/lineage/federation-loader.js +111 -0
  105. package/src/lineage/field-identity.js +78 -0
  106. package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
  107. package/src/lineage/fixtures/flagship-graph.json +1453 -0
  108. package/src/lineage/flow-grade.js +221 -0
  109. package/src/lineage/governance-edit.js +169 -0
  110. package/src/lineage/graph-builder.js +1114 -0
  111. package/src/lineage/graph-diff.js +431 -0
  112. package/src/lineage/graph-snapshot.js +180 -0
  113. package/src/lineage/handling-analyzer.js +168 -0
  114. package/src/lineage/ids.js +349 -0
  115. package/src/lineage/impact-assessment.js +76 -0
  116. package/src/lineage/impact-engine.js +268 -0
  117. package/src/lineage/index.js +281 -0
  118. package/src/lineage/language-coverage-tiers.js +58 -0
  119. package/src/lineage/obligation-mapping.js +126 -0
  120. package/src/lineage/obligation-predicates.js +235 -0
  121. package/src/lineage/observation-adapters.js +282 -0
  122. package/src/lineage/observation-correlation.js +622 -0
  123. package/src/lineage/observation-store.js +497 -0
  124. package/src/lineage/path-query.js +410 -0
  125. package/src/lineage/path-store.js +400 -0
  126. package/src/lineage/protection.js +53 -0
  127. package/src/lineage/recipient-profile.js +192 -0
  128. package/src/lineage/recipient-registry.js +394 -0
  129. package/src/lineage/redact-graph.js +224 -0
  130. package/src/lineage/remediation.js +417 -0
  131. package/src/lineage/resolve-destination.js +91 -0
  132. package/src/lineage/runtime-observation.js +464 -0
  133. package/src/lineage/scenario-diff.js +84 -0
  134. package/src/lineage/scenario-engine.js +251 -0
  135. package/src/lineage/scenario.js +101 -0
  136. package/src/lineage/schema.js +167 -0
  137. package/src/lineage/sink-registry.js +427 -0
  138. package/src/lineage/source-registry.js +357 -0
  139. package/src/lineage/source-seeding.js +212 -0
  140. package/src/lineage/summaries.js +590 -0
  141. package/src/lineage/transform-catalog.js +397 -0
  142. package/src/lineage/transit-protection.js +150 -0
  143. package/src/lineage/validate.js +285 -0
  144. package/src/lsp/server.js +49 -2
  145. package/src/mcp/CLAUDE.md +7 -1
  146. package/src/mcp/dataflow-tools.js +160 -0
  147. package/src/mcp/server.js +1 -1
  148. package/src/mcp/tools.js +22 -1
  149. package/src/pipeline/assurance-mode.js +64 -1
  150. package/src/pipeline/finding-schema.js +8 -1
  151. package/src/pipeline/scan-health.js +19 -1
  152. package/src/posture/CLAUDE.md +140 -0
  153. package/src/posture/accuracy-scorecard.js +60 -0
  154. package/src/posture/artifact-registry.js +76 -0
  155. package/src/posture/auditor-walkthrough.js +192 -13
  156. package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
  157. package/src/posture/compliance-policy.js +12 -2
  158. package/src/posture/cross-repo-memory.js +7 -2
  159. package/src/posture/fix-history.js +25 -2
  160. package/src/posture/fix-verify.js +9 -1
  161. package/src/posture/fleet.js +0 -0
  162. package/src/posture/git-history.js +13 -5
  163. package/src/posture/material-change.js +21 -2
  164. package/src/posture/mttr.js +75 -12
  165. package/src/posture/obligation-evidence-pack.js +202 -0
  166. package/src/posture/pre-incident-archaeology.js +39 -7
  167. package/src/posture/privacy-framework.js +14 -0
  168. package/src/posture/provenance/ai-authorship.js +68 -0
  169. package/src/posture/provenance/branch-entry.js +80 -0
  170. package/src/posture/provenance/cache.js +143 -0
  171. package/src/posture/provenance/confidence.js +36 -0
  172. package/src/posture/provenance/coordinator.js +786 -0
  173. package/src/posture/provenance/dag-walk.js +249 -0
  174. package/src/posture/provenance/evidence-attribution.js +59 -0
  175. package/src/posture/provenance/git-evidence.js +310 -0
  176. package/src/posture/provenance/lifecycle.js +208 -0
  177. package/src/posture/provenance/missing-control-resolver.js +137 -0
  178. package/src/posture/provenance/origin-resolver.js +342 -0
  179. package/src/posture/provenance/predicate-replay.js +133 -0
  180. package/src/posture/provenance/providers/config.js +39 -0
  181. package/src/posture/provenance/providers/github.js +62 -0
  182. package/src/posture/provenance/providers/gitlab.js +58 -0
  183. package/src/posture/provenance/repo-lineage.js +74 -0
  184. package/src/posture/provenance/sca-origin.js +139 -0
  185. package/src/posture/provenance/schema.js +255 -0
  186. package/src/posture/provenance/transitive-sca.js +147 -0
  187. package/src/posture/provenance/validate.js +30 -0
  188. package/src/posture/provenance-evidence-bundle.js +144 -0
  189. package/src/posture/remediation-ledger.js +337 -0
  190. package/src/posture/sbom-diff.js +15 -2
  191. package/src/posture/secret-history.js +10 -2
  192. package/src/posture/state-dir.js +38 -14
  193. package/src/posture/vuln-archaeology.js +8 -2
  194. package/src/pr-delta.js +25 -4
  195. package/src/report/index.js +197 -3
  196. package/src/runScan.js +34 -5
  197. package/src/sast/rate-limit.js +33 -3
  198. package/src/server/CLAUDE.md +47 -0
  199. package/src/server/graph-loader.js +141 -0
  200. package/src/server/http-server.js +325 -0
  201. package/src/server/routes.js +129 -0
  202. package/src/server/security.js +111 -0
  203. package/src/server/static-assets.js +139 -0
  204. package/src/util/git-hardening.js +128 -0
  205. package/dist/11.index.js +0 -353
  206. package/dist/259.index.js +0 -975
  207. package/dist/317.index.js +0 -300
  208. package/dist/609.index.js +0 -741
  209. package/dist/838.index.js +0 -152
@@ -0,0 +1,622 @@
1
+ //
2
+ // observation-correlation.js — M5 deliverable #7b (Runtime-Corroborated
3
+ // Digital Twin, "runtime-observed" half only; "7a", config-declared edges,
4
+ // is out of scope for this whole sub-project — see the scoping doc §4.0),
5
+ // FR-505, AC-29 clauses 1-4 ("Runtime observation remains metadata-only
6
+ // and non-exclusionary" — the Milestone 5 exit-gate clause this module is
7
+ // gated on). Pure by contract: no fs, no I/O, never throws.
8
+ //
9
+ // Task 1 (`runtime-observation.js`) shipped the closed-world
10
+ // `RuntimeObservation` structural contract. This module is the SECOND
11
+ // half: the actual graph-ID matching (`matchObservationToGraph`) and the
12
+ // per-flow correlation layer (`correlateObservations`) that make AC-29
13
+ // clauses 1-4 true, not merely documented.
14
+ //
15
+ // ── Why `observation-correlation.js` and not `runtime-correlation.js` ──
16
+ //
17
+ // `scanner/src/posture/runtime-correlation.js` already exists and is
18
+ // LIVE-WIRED into every scan (`engine.js` imports it and calls
19
+ // `annotateRuntimeCorrelation` inside `_runAnnotator`). This codebase has
20
+ // already been bitten once by two same-named-but-different annotators
21
+ // coexisting under confusable names — see the root `CLAUDE.md`'s
22
+ // `annotateGitProvenance` naming rule, which exists for the identical
23
+ // reason. Naming this file `runtime-correlation.js` would recreate that
24
+ // exact hazard for the second time in this package's own history.
25
+ //
26
+ // ── What is reused from `posture/runtime-correlation.js`, and what isn't ──
27
+ //
28
+ // The TECHNIQUE is reused: a most-specific-identifier-first match ladder,
29
+ // falling back to an honest no-match; a window filter over observed
30
+ // events. The SCHEMA is not. `posture/runtime-correlation.js` is
31
+ // code-execution-shaped (`qid`/`fileRel`/`line`,
32
+ // `kind: 'function-call' | 'route-hit' | 'syscall' | 'file-touch'`) — it
33
+ // answers "was this LINE OF CODE executed." This module is
34
+ // destination-shaped (`node.destination.literalValue` /
35
+ // `node.storeDetail.table` / `node.queueDetail.topic`) — it answers "was
36
+ // this DESTINATION contacted." Nothing is imported from that module, and
37
+ // nothing here should ever be made to look like it is.
38
+ //
39
+ // ── Clause 3 ("non-exclusionary") is structural, not disciplinary ──────
40
+ //
41
+ // `correlateObservations` returns annotations keyed by id — it never
42
+ // filters, removes, reorders, or mutates a graph entity. Both flows in
43
+ // AC-29's own two-flow scenario stay present in `graph.nodes`/`.edges`/
44
+ // `.flows` and in this function's own `byFlow` map, in every evaluated
45
+ // state, always. "Both statically possible paths remain visible even when
46
+ // only one shows runtime corroboration" is therefore true BY
47
+ // CONSTRUCTION: a future refactor that starts returning a FILTERED flow
48
+ // list (e.g. "only flows with runtime evidence") falsifies AC-29 clause 3
49
+ // directly, not just this module's own test suite — there is no discipline
50
+ // to violate, only a return shape to change.
51
+ //
52
+ // ── Clause 2 ("three-valued") ───────────────────────────────────────────
53
+ //
54
+ // `not_evaluated` (the store was never consulted — `observations` was
55
+ // `null`/`undefined`) and `not_observed_in_window` (the store WAS
56
+ // consulted, genuinely found nothing for this flow/window/environment) are
57
+ // two GENUINELY DIFFERENT answers, never collapsed into one "no runtime
58
+ // evidence" bucket. PRD line 2098 states this directly: an unobserved flow
59
+ // may simply be outside the telemetry window, not absent from the system.
60
+ // `evaluated: false` and `evaluated: true` with an empty observation list
61
+ // must serialize to two different JSON documents (a store never consulted
62
+ // is not the same fact as a store consulted and found empty) — pinned as
63
+ // literal JSON by this module's own test suite (`OC/5`).
64
+ //
65
+ // ── Correction 4's node-granularity boundary (wording corrected, I4) ────
66
+ //
67
+ // A RuntimeObservation corroborates that a DESTINATION NODE was contacted
68
+ // — never which of several flows ENDING AT that node actually produced the
69
+ // traffic. This is NOT the same claim as "these flows share a real
70
+ // destination": a graph node is a REGISTRY DECISION
71
+ // (`graph-builder.js`'s §6.1), and more than one distinct real-world
72
+ // destination can collapse onto one node (a pre-existing, disclosed
73
+ // coarsening) — so an observation of ONE real destination can
74
+ // over-attribute corroboration to a sibling flow whose real destination
75
+ // was never observed at all, not merely a flow that happens to share the
76
+ // same destination. FR-505's own "cannot prove field-level identity"
77
+ // applies here at the granularity the evidence genuinely has: when a
78
+ // matched sink node is the endpoint of more than one flow, every one of
79
+ // those sibling flows is demoted to `matchConfidence: 'ambiguous'` for
80
+ // THAT FLOW'S OWN per-flow answer — the underlying observation record's
81
+ // own `high`/`medium`/`low` confidence is never rewritten (the demotion is
82
+ // a property of the per-flow READING, not a mutation of the evidence).
83
+ // `OC/8` proves this both ways: demoted when siblings exist, undemoted
84
+ // when the sink has exactly one flow.
85
+ //
86
+ // ── `byFlow` is a plain object, never a `Map` ───────────────────────────
87
+ //
88
+ // This result is persisted inside a signed `lineage-graph.json` artifact
89
+ // (Correction 2). A `Map` serializes to `{}` under `JSON.stringify` —
90
+ // silently losing every entry — so `byFlow` is a plain, JSON-serializable
91
+ // object keyed by flow id, the same convention every other §10.10
92
+ // extension-contract module in this package already follows.
93
+ //
94
+ // ── `correlateObservations` never re-runs the match ladder ─────────────
95
+ //
96
+ // The match ladder runs exactly once, at IMPORT time, inside
97
+ // `matchObservationToGraph`. `correlateObservations` reads a record's
98
+ // ALREADY-RECORDED `matchedFlowIds` — it never calls
99
+ // `matchObservationToGraph` itself. This is deliberate: it keeps a stored
100
+ // observation record honest about what it was actually correlated
101
+ // against at import time, and it means a graph rebuilt after a code
102
+ // change never silently re-attributes old runtime evidence to new graph
103
+ // entities it was never actually checked against. The consequence — an
104
+ // older imported record's `matchedFlowIds` naming a flow id that no longer
105
+ // exists in a rebuilt graph — is handled defensively: each record's own
106
+ // `matchedFlowIds` is filtered against the graph's own real flow-id set
107
+ // before folding, and a dropped stale id is disclosed in `limitations`,
108
+ // never silently ignored and never silently promoted.
109
+
110
+ import {
111
+ OBSERVATION_LAYERS,
112
+ RUNTIME_MATCH_METHODS,
113
+ RUNTIME_MATCH_CONFIDENCE,
114
+ validateRuntimeObservation,
115
+ } from './runtime-observation.js';
116
+
117
+ export const CORRELATION_VERSION = '1.0.0';
118
+
119
+ const [LAYER_RUNTIME_OBSERVED, LAYER_NOT_OBSERVED_IN_WINDOW, LAYER_NOT_EVALUATED] = OBSERVATION_LAYERS;
120
+
121
+ // Mirrors `runtime-observation.js`'s own `EVENT_COUNT_BANDS` ORDER,
122
+ // deliberately without importing it — this module's interfaces-consumed
123
+ // list is exactly `OBSERVATION_LAYERS`/`RUNTIME_MATCH_METHODS`/
124
+ // `RUNTIME_MATCH_CONFIDENCE`/`validateRuntimeObservation`, and the import
125
+ // specifier list must stay exactly `['./runtime-observation.js']` either
126
+ // way — but keeping the CONSUMED-NAMES list itself minimal is deliberate
127
+ // too, so a future reviewer never has to wonder whether this module reads
128
+ // anything beyond what it documents reading.
129
+ const _EVENT_COUNT_BAND_ORDER = Object.freeze(['1', '2-10', '11-100', '101-1k', '1k+']);
130
+
131
+ const UNMATCHED_MATCH_RESULT = Object.freeze({
132
+ matchedNodeIds: [], matchedEdgeIds: [], matchedFlowIds: [], matchMethod: 'unmatched', matchConfidence: 'low',
133
+ });
134
+
135
+ function _isPlainObject(v) {
136
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
137
+ }
138
+
139
+ function _sortUnique(arr) {
140
+ return [...new Set(arr)].sort();
141
+ }
142
+
143
+ /**
144
+ * Parses a literal destination URL into `{host, port, pathname}`, all
145
+ * lowercased — `port` is `''` when the URL carries no explicit port.
146
+ * Never regexes a URL by hand; returns `null` on anything unparseable.
147
+ */
148
+ function _hostOf(literal) {
149
+ if (typeof literal !== 'string' || literal.length === 0) return null;
150
+ try {
151
+ const u = new URL(literal);
152
+ return { host: u.hostname.toLowerCase(), port: u.port, pathname: u.pathname.toLowerCase() };
153
+ } catch {
154
+ return null;
155
+ }
156
+ }
157
+
158
+ // Rung 1: destination_literal / high. Only a node whose destination is
159
+ // genuinely `resolutionStatus: 'literal'` is eligible — matching on a
160
+ // `blockingExpression` (a dynamic/unresolved destination) would be
161
+ // matching on raw source text, not on a real resolved destination.
162
+ function _rung1(nodes, attrs) {
163
+ const obsHostRaw = attrs['destination.host'];
164
+ if (typeof obsHostRaw !== 'string' || obsHostRaw.length === 0) return [];
165
+ const obsHost = obsHostRaw.toLowerCase();
166
+ const obsPortRaw = attrs['destination.port'];
167
+ const hasObsPort = obsPortRaw !== undefined && obsPortRaw !== null;
168
+ const obsPort = hasObsPort ? String(obsPortRaw) : null;
169
+
170
+ const matched = [];
171
+ for (const n of nodes) {
172
+ const dest = n?.destination;
173
+ if (!dest || typeof dest !== 'object' || dest.resolutionStatus !== 'literal') continue;
174
+ const parsed = _hostOf(dest.literalValue);
175
+ if (!parsed || parsed.host !== obsHost) continue;
176
+ // A port is ignored on BOTH sides when the observation supplies none.
177
+ // When it does supply one, it must match the literal's own EXPLICIT
178
+ // port — a literal carrying no explicit port is a mismatch, never a
179
+ // wildcard match, since resolving an implicit default port needs
180
+ // scheme knowledge this module does not attempt.
181
+ if (hasObsPort && (!parsed.port || parsed.port !== obsPort)) continue;
182
+ matched.push(n.id);
183
+ }
184
+ return matched;
185
+ }
186
+
187
+ // Rung 2: store_table / medium. `destination.service` OR `schema.name`
188
+ // (either alone suffices) matched case-insensitively against
189
+ // `node.storeDetail.table`.
190
+ function _rung2(nodes, attrs) {
191
+ const svc = typeof attrs['destination.service'] === 'string' ? attrs['destination.service'].toLowerCase() : null;
192
+ const schemaName = typeof attrs['schema.name'] === 'string' ? attrs['schema.name'].toLowerCase() : null;
193
+ if (svc === null && schemaName === null) return [];
194
+
195
+ const matched = [];
196
+ for (const n of nodes) {
197
+ const table = n?.storeDetail?.table;
198
+ if (typeof table !== 'string' || table.length === 0) continue;
199
+ const t = table.toLowerCase();
200
+ if ((svc !== null && t === svc) || (schemaName !== null && t === schemaName)) matched.push(n.id);
201
+ }
202
+ return matched;
203
+ }
204
+
205
+ // Rung 3: queue_topic / medium. EXACT `destination.service` equality
206
+ // against `node.queueDetail.topic` — a substring/partial topic is
207
+ // deliberately NOT a match: substring matching over a topic identifier is
208
+ // exactly how a false positive gets in (e.g. `'orders'` matching a topic
209
+ // literally named `'...amazonaws.com/1/orders-archive'`).
210
+ function _rung3(nodes, attrs) {
211
+ const topic = typeof attrs['destination.service'] === 'string' ? attrs['destination.service'] : null;
212
+ if (topic === null || topic.length === 0) return [];
213
+ const matched = [];
214
+ for (const n of nodes) {
215
+ if (n?.queueDetail?.topic === topic) matched.push(n.id);
216
+ }
217
+ return matched;
218
+ }
219
+
220
+ /**
221
+ * `matchObservationToGraph(graph, draft) -> {matchedNodeIds, matchedEdgeIds,
222
+ * matchedFlowIds, matchMethod, matchConfidence}` — runs once per record at
223
+ * IMPORT time. `draft` is any object with an `attributes` field. Never
224
+ * throws; a malformed graph or draft yields the honest all-empty
225
+ * `'unmatched'`/`'low'` answer.
226
+ *
227
+ * The ladder tries rung 1, then rung 2, then rung 3, stopping at the FIRST
228
+ * rung that produces any match — a graph containing both a rung-1 and a
229
+ * rung-2 candidate for one observation is reported at rung 1 only.
230
+ * Confidence is the rung's own (`'high'`/`'medium'`/`'medium'`) UNLESS
231
+ * more than one node matched at the winning rung, in which case
232
+ * `'ambiguous'` — an ambiguous observation must remain a candidate
233
+ * (neither id dropped), never a silently-arbitrary pick between them
234
+ * (mirrors `runtime-observation.js`'s own RO/6f cross-field rule).
235
+ */
236
+ export function matchObservationToGraph(graph, draft) {
237
+ const attrs = draft && typeof draft === 'object' ? draft.attributes : undefined;
238
+ if (!graph || typeof graph !== 'object' || !Array.isArray(graph.nodes) || !_isPlainObject(attrs)) {
239
+ return { ...UNMATCHED_MATCH_RESULT };
240
+ }
241
+
242
+ const nodes = graph.nodes.filter((n) => n && typeof n === 'object' && typeof n.id === 'string');
243
+
244
+ let matchedNodeIds = _rung1(nodes, attrs);
245
+ let matchMethod = 'destination_literal';
246
+ let baseConfidence = 'high';
247
+ if (matchedNodeIds.length === 0) {
248
+ matchedNodeIds = _rung2(nodes, attrs);
249
+ matchMethod = 'store_table';
250
+ baseConfidence = 'medium';
251
+ }
252
+ if (matchedNodeIds.length === 0) {
253
+ matchedNodeIds = _rung3(nodes, attrs);
254
+ matchMethod = 'queue_topic';
255
+ baseConfidence = 'medium';
256
+ }
257
+ if (matchedNodeIds.length === 0) {
258
+ return { ...UNMATCHED_MATCH_RESULT };
259
+ }
260
+
261
+ matchedNodeIds = _sortUnique(matchedNodeIds);
262
+ const matchConfidence = matchedNodeIds.length > 1 ? 'ambiguous' : baseConfidence;
263
+
264
+ const nodeIdSet = new Set(matchedNodeIds);
265
+ const edges = Array.isArray(graph.edges) ? graph.edges : [];
266
+ const flows = Array.isArray(graph.flows) ? graph.flows : [];
267
+ const matchedEdgeIds = _sortUnique(
268
+ edges.filter((e) => e && typeof e === 'object' && nodeIdSet.has(e.to)).map((e) => e.id),
269
+ );
270
+ const matchedFlowIds = _sortUnique(
271
+ flows.filter((f) => f && typeof f === 'object' && nodeIdSet.has(f.sink)).map((f) => f.id),
272
+ );
273
+
274
+ return { matchedNodeIds, matchedEdgeIds, matchedFlowIds, matchMethod, matchConfidence };
275
+ }
276
+
277
+ function _minIso(list) {
278
+ let best = null;
279
+ let bestT = Infinity;
280
+ for (const s of list) {
281
+ const t = Date.parse(s);
282
+ if (Number.isFinite(t) && t < bestT) { bestT = t; best = s; }
283
+ }
284
+ return best;
285
+ }
286
+
287
+ function _maxIso(list) {
288
+ let best = null;
289
+ let bestT = -Infinity;
290
+ for (const s of list) {
291
+ const t = Date.parse(s);
292
+ if (Number.isFinite(t) && t > bestT) { bestT = t; best = s; }
293
+ }
294
+ return best;
295
+ }
296
+
297
+ function _highestBand(bands) {
298
+ let best = null;
299
+ let bestIdx = -1;
300
+ for (const b of bands) {
301
+ const idx = _EVENT_COUNT_BAND_ORDER.indexOf(b);
302
+ if (idx > bestIdx) { bestIdx = idx; best = b; }
303
+ }
304
+ return best;
305
+ }
306
+
307
+ // Worst (last-in-order) confidence across a set of contributing
308
+ // observations — a risk-precedence reduction mirroring `protection.js`'s
309
+ // own `aggregateVerdicts()` convention (worst wins).
310
+ function _worstConfidence(confs) {
311
+ let best = null;
312
+ let bestIdx = -1;
313
+ for (const c of confs) {
314
+ const idx = RUNTIME_MATCH_CONFIDENCE.indexOf(c);
315
+ if (idx > bestIdx) { bestIdx = idx; best = c; }
316
+ }
317
+ return best;
318
+ }
319
+
320
+ // The contribution whose OWN confidence is strongest (lowest index in
321
+ // RUNTIME_MATCH_CONFIDENCE), ties broken by RUNTIME_MATCH_METHODS order —
322
+ // `matchMethod`/`environment`/`windowStart`/`windowEnd` on a multi-
323
+ // observation flow entry are all read off this SAME representative, so
324
+ // the fields describe one real, consistent, deterministic contributor.
325
+ function _pickRepresentative(contribs) {
326
+ let best = null;
327
+ for (const c of contribs) {
328
+ if (!best) { best = c; continue; }
329
+ const cConf = RUNTIME_MATCH_CONFIDENCE.indexOf(c.matchConfidence);
330
+ const bConf = RUNTIME_MATCH_CONFIDENCE.indexOf(best.matchConfidence);
331
+ if (cConf < bConf) { best = c; continue; }
332
+ if (cConf === bConf) {
333
+ const cMeth = RUNTIME_MATCH_METHODS.indexOf(c.matchMethod);
334
+ const bMeth = RUNTIME_MATCH_METHODS.indexOf(best.matchMethod);
335
+ if (cMeth < bMeth) best = c;
336
+ }
337
+ }
338
+ return best;
339
+ }
340
+
341
+ function _emptyByFlowEntry(layer) {
342
+ return {
343
+ layer,
344
+ observationIds: [],
345
+ matchMethod: null,
346
+ matchConfidence: null,
347
+ environment: null,
348
+ windowStart: null,
349
+ windowEnd: null,
350
+ firstObservedAt: null,
351
+ lastObservedAt: null,
352
+ eventCountBand: null,
353
+ siblingFlowCount: 0,
354
+ // I2 (final review): an empty array (never null) for a flow with no
355
+ // contributions at all — keeps the field's type consistent whether or
356
+ // not the flow has evidence, mirroring every other array-valued field
357
+ // on this shape.
358
+ contributingEnvironments: [],
359
+ };
360
+ }
361
+
362
+ /**
363
+ * `correlateObservations(graph, observations, opts) -> CorrelationResult`
364
+ * — runs at READ time, over an already-imported observation set.
365
+ * `observations` is `RuntimeObservation[]`, or `null`/`undefined` meaning
366
+ * NO STORE WAS CONSULTED (the three-valued layer's `not_evaluated` case).
367
+ * `opts` is `{environment, windowStart, windowEnd}`, each nullable. Never
368
+ * throws; never mutates `graph`.
369
+ */
370
+ export function correlateObservations(graph, observations, opts) {
371
+ const o = opts && typeof opts === 'object' ? opts : {};
372
+ const environment = o.environment ?? null;
373
+ const windowStart = o.windowStart ?? null;
374
+ const windowEnd = o.windowEnd ?? null;
375
+
376
+ const flows = (Array.isArray(graph?.flows) ? graph.flows : [])
377
+ .filter((f) => f && typeof f === 'object' && typeof f.id === 'string');
378
+
379
+ const evaluated = observations !== null && observations !== undefined;
380
+ const defaultLayer = evaluated ? LAYER_NOT_OBSERVED_IN_WINDOW : LAYER_NOT_EVALUATED;
381
+
382
+ const byFlow = {};
383
+ for (const f of flows) byFlow[f.id] = _emptyByFlowEntry(defaultLayer);
384
+
385
+ const result = {
386
+ version: CORRELATION_VERSION,
387
+ evaluated,
388
+ environment,
389
+ windowStart,
390
+ windowEnd,
391
+ observedNodeIds: [],
392
+ observedEdgeIds: [],
393
+ observedFlowIds: [],
394
+ notObservedFlowIds: [],
395
+ notEvaluatedFlowIds: [],
396
+ byFlow,
397
+ consideredObservationIds: [],
398
+ outOfWindowObservationIds: [],
399
+ otherEnvironmentObservationIds: [],
400
+ unmatchedObservationIds: [],
401
+ invalidObservationIds: [],
402
+ limitations: [],
403
+ };
404
+
405
+ if (!evaluated) {
406
+ result.notEvaluatedFlowIds = _sortUnique(flows.map((f) => f.id));
407
+ result.limitations = [
408
+ 'No runtime observation store was consulted for this correlation — every flow is reported '
409
+ + 'not_evaluated, never not_observed_in_window; the two are deliberately different answers '
410
+ + '(PRD line 2098).',
411
+ ];
412
+ return result;
413
+ }
414
+
415
+ const obsArray = Array.isArray(observations) ? observations : [];
416
+ const flowIdSet = new Set(flows.map((f) => f.id));
417
+ const nodeIdSet = new Set(
418
+ (Array.isArray(graph?.nodes) ? graph.nodes : [])
419
+ .filter((n) => n && typeof n === 'object' && typeof n.id === 'string')
420
+ .map((n) => n.id),
421
+ );
422
+ const edgeIdSet = new Set(
423
+ (Array.isArray(graph?.edges) ? graph.edges : [])
424
+ .filter((e) => e && typeof e === 'object' && typeof e.id === 'string')
425
+ .map((e) => e.id),
426
+ );
427
+
428
+ // Sibling count per matched sink node, computed ONCE from the graph's
429
+ // own flow list, before the fold below.
430
+ const flowsBySink = new Map();
431
+ for (const f of flows) {
432
+ const arr = flowsBySink.get(f.sink) ?? [];
433
+ arr.push(f.id);
434
+ flowsBySink.set(f.sink, arr);
435
+ }
436
+ const siblingCountByFlowId = new Map();
437
+ for (const fids of flowsBySink.values()) {
438
+ for (const fid of fids) siblingCountByFlowId.set(fid, fids.length - 1);
439
+ }
440
+
441
+ const consideredObservationIds = [];
442
+ const outOfWindowObservationIds = [];
443
+ const otherEnvironmentObservationIds = [];
444
+ const unmatchedObservationIds = [];
445
+ const invalidObservationIds = [];
446
+ const observedNodeIds = new Set();
447
+ const observedEdgeIds = new Set();
448
+ const flowContribs = new Map();
449
+ let droppedStaleFlowIds = false;
450
+
451
+ for (const obs of obsArray) {
452
+ const { valid } = validateRuntimeObservation(obs);
453
+ if (!valid) {
454
+ const idLabel = obs && typeof obs === 'object' && typeof obs.id === 'string' ? obs.id : '(no id)';
455
+ invalidObservationIds.push(idLabel);
456
+ continue;
457
+ }
458
+
459
+ // Environment filter (AC-29's own operator-scoping requirement):
460
+ // exact, case-sensitive comparison — an operator's environment names
461
+ // are theirs, and fuzzy-matching them would silently merge two
462
+ // genuinely distinct environments (e.g. a customer's own
463
+ // "Production" vs. this codebase's own "production").
464
+ if (environment !== null && obs.environment !== environment) {
465
+ otherEnvironmentObservationIds.push(obs.id);
466
+ continue;
467
+ }
468
+
469
+ // Window filter: INTERVAL OVERLAP against the requested window, not
470
+ // containment — an observation window merely straddling the
471
+ // requested boundary still counts. `windowStart`/`windowEnd` each
472
+ // null means an open-ended (half-open) bound on that side.
473
+ const obsStart = Date.parse(obs.windowStart);
474
+ const obsEnd = Date.parse(obs.windowEnd);
475
+ const reqStart = windowStart !== null ? Date.parse(windowStart) : -Infinity;
476
+ const reqEnd = windowEnd !== null ? Date.parse(windowEnd) : Infinity;
477
+ const overlaps = obsStart <= reqEnd && obsEnd >= reqStart;
478
+ if (!overlaps) {
479
+ outOfWindowObservationIds.push(obs.id);
480
+ continue;
481
+ }
482
+
483
+ consideredObservationIds.push(obs.id);
484
+
485
+ for (const id of obs.matchedNodeIds) if (nodeIdSet.has(id)) observedNodeIds.add(id);
486
+ for (const id of obs.matchedEdgeIds) if (edgeIdSet.has(id)) observedEdgeIds.add(id);
487
+
488
+ // Never re-run the match ladder here — read the record's own
489
+ // already-recorded matchedFlowIds, filtered against THIS graph's
490
+ // real flow-id set (a stale import naming a since-removed flow is
491
+ // dropped, disclosed, never attributed).
492
+ const filteredFlowIds = obs.matchedFlowIds.filter((id) => flowIdSet.has(id));
493
+ if (filteredFlowIds.length < obs.matchedFlowIds.length) droppedStaleFlowIds = true;
494
+
495
+ if (filteredFlowIds.length === 0) {
496
+ unmatchedObservationIds.push(obs.id);
497
+ continue;
498
+ }
499
+
500
+ for (const fid of filteredFlowIds) {
501
+ const arr = flowContribs.get(fid) ?? [];
502
+ arr.push({
503
+ obsId: obs.id,
504
+ matchMethod: obs.matchMethod,
505
+ matchConfidence: obs.matchConfidence,
506
+ environment: obs.environment,
507
+ windowStart: obs.windowStart,
508
+ windowEnd: obs.windowEnd,
509
+ firstObservedAt: obs.firstObservedAt,
510
+ lastObservedAt: obs.lastObservedAt,
511
+ eventCountBand: obs.eventCountBand,
512
+ });
513
+ flowContribs.set(fid, arr);
514
+ }
515
+ }
516
+
517
+ let anySiblingDemotion = false;
518
+ let anyMultiEnvironment = false;
519
+ for (const [fid, contribs] of flowContribs) {
520
+ const entry = byFlow[fid];
521
+ if (!entry) continue; // defensive — fid is always a real flow id (filtered above)
522
+ const rep = _pickRepresentative(contribs);
523
+ const siblingFlowCount = siblingCountByFlowId.get(fid) ?? 0;
524
+ if (siblingFlowCount > 0) anySiblingDemotion = true;
525
+
526
+ // I2 (final review): the aggregated fields below (firstObservedAt/
527
+ // lastObservedAt/eventCountBand/matchConfidence) used to be computed
528
+ // across ALL contributors, while matchMethod/environment/windowStart/
529
+ // windowEnd came from the representative's OWN environment alone — a
530
+ // self-contradictory mix whenever more than one environment
531
+ // contributed (e.g. a "production" window shown next to a "staging"
532
+ // event-count band). Scoped to the representative's OWN environment
533
+ // only, so every field on one entry now describes ONE real,
534
+ // consistent, deterministic environment — with contributingEnvironments
535
+ // disclosing the rest, never silently discarding them.
536
+ const repEnvironmentContribs = contribs.filter((c) => c.environment === rep.environment);
537
+ const contributingEnvironments = _sortUnique(contribs.map((c) => c.environment));
538
+ if (contributingEnvironments.length > 1) anyMultiEnvironment = true;
539
+
540
+ entry.layer = LAYER_RUNTIME_OBSERVED;
541
+ entry.observationIds = _sortUnique(contribs.map((c) => c.obsId));
542
+ entry.matchMethod = rep.matchMethod;
543
+ // Correction 4's node-granularity boundary: sharing a sink node with
544
+ // ANY other flow demotes this flow's own confidence to 'ambiguous',
545
+ // regardless of every contributing observation's own confidence —
546
+ // the observation records themselves are never rewritten. Scoped to
547
+ // repEnvironmentContribs per I2 above (never cross-environment).
548
+ entry.matchConfidence = siblingFlowCount > 0 ? 'ambiguous' : _worstConfidence(repEnvironmentContribs.map((c) => c.matchConfidence));
549
+ entry.environment = rep.environment;
550
+ entry.windowStart = rep.windowStart;
551
+ entry.windowEnd = rep.windowEnd;
552
+ entry.firstObservedAt = _minIso(repEnvironmentContribs.map((c) => c.firstObservedAt));
553
+ entry.lastObservedAt = _maxIso(repEnvironmentContribs.map((c) => c.lastObservedAt));
554
+ entry.eventCountBand = _highestBand(repEnvironmentContribs.map((c) => c.eventCountBand));
555
+ entry.siblingFlowCount = siblingFlowCount;
556
+ entry.contributingEnvironments = contributingEnvironments;
557
+ }
558
+
559
+ const observedFlowIds = [];
560
+ const notObservedFlowIds = [];
561
+ for (const f of flows) {
562
+ if (byFlow[f.id].layer === LAYER_RUNTIME_OBSERVED) observedFlowIds.push(f.id);
563
+ else notObservedFlowIds.push(f.id);
564
+ }
565
+
566
+ const limitations = [];
567
+ if (droppedStaleFlowIds) {
568
+ limitations.push(
569
+ 'One or more runtime observations referenced matchedFlowIds no longer present in this graph '
570
+ + '(a stale import) — those references were dropped rather than attributed to a flow they were '
571
+ + 'never actually re-checked against.',
572
+ );
573
+ }
574
+ if (observedFlowIds.length === 0) {
575
+ limitations.push(
576
+ 'No runtime observation matched any flow in the requested environment/window — the absence of '
577
+ + 'a runtime observation is not evidence a flow did not occur (PRD line 2098).',
578
+ );
579
+ }
580
+ if (anySiblingDemotion) {
581
+ limitations.push(
582
+ // I4 (final review): the prior wording ("never which of several
583
+ // flows sharing that node produced the traffic") reads as "these
584
+ // flows share a real destination" — false in the case this fires
585
+ // for. A graph NODE is a registry decision (graph-builder.js §6.1)
586
+ // that can represent more than one distinct real-world destination
587
+ // collapsing onto it (a pre-existing, disclosed coarsening,
588
+ // Correction 4/M2-A1) — an observation of one real destination
589
+ // over-attributes corroboration to a sibling flow whose real
590
+ // destination was never observed at all, not merely "shared" with
591
+ // the one that was.
592
+ 'A runtime observation corroborates that a matched destination NODE was contacted — but a graph '
593
+ + 'node can represent more than one distinct real-world destination that happens to collapse onto '
594
+ + "it (a pre-existing graph-projection coarsening), not just several flows sharing one real "
595
+ + 'destination. Every flow ending at that node is reported at matchConfidence \'ambiguous\' for '
596
+ + 'exactly this reason (node-granularity boundary, Correction 4).',
597
+ );
598
+ }
599
+ if (anyMultiEnvironment) {
600
+ limitations.push(
601
+ 'One or more flows had contributing observations from more than one environment — each such '
602
+ + "flow's own reported fields (matchMethod/matchConfidence/environment/window/firstObservedAt/"
603
+ + 'lastObservedAt/eventCountBand) describe only its representative (strongest-confidence) '
604
+ + "environment; see that flow's own contributingEnvironments for the full set of environments "
605
+ + 'that actually contributed (I2).',
606
+ );
607
+ }
608
+
609
+ result.observedNodeIds = _sortUnique([...observedNodeIds]);
610
+ result.observedEdgeIds = _sortUnique([...observedEdgeIds]);
611
+ result.observedFlowIds = _sortUnique(observedFlowIds);
612
+ result.notObservedFlowIds = _sortUnique(notObservedFlowIds);
613
+ result.notEvaluatedFlowIds = [];
614
+ result.consideredObservationIds = _sortUnique(consideredObservationIds);
615
+ result.outOfWindowObservationIds = _sortUnique(outOfWindowObservationIds);
616
+ result.otherEnvironmentObservationIds = _sortUnique(otherEnvironmentObservationIds);
617
+ result.unmatchedObservationIds = _sortUnique(unmatchedObservationIds);
618
+ result.invalidObservationIds = _sortUnique(invalidObservationIds);
619
+ result.limitations = limitations;
620
+
621
+ return result;
622
+ }