@clear-capabilities/agentic-security-scanner 0.145.0 → 0.147.5
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.
- package/CHANGELOG.md +266 -0
- package/bin/agentic-security.js +3508 -69
- package/dist/1122.index.js +779 -0
- package/dist/{301.index.js → 1301.index.js} +2 -2
- package/dist/1379.index.js +591 -0
- package/dist/{444.index.js → 1444.index.js} +2 -2
- package/dist/{660.index.js → 1660.index.js} +2 -2
- package/dist/{700.index.js → 1700.index.js} +2 -2
- package/dist/{905.index.js → 1905.index.js} +3 -3
- package/dist/{920.index.js → 1920.index.js} +3 -3
- package/dist/{238.index.js → 2238.index.js} +3 -3
- package/dist/{271.index.js → 2271.index.js} +2 -2
- package/dist/{985.index.js → 2376.index.js} +1260 -340
- package/dist/2432.index.js +793 -0
- package/dist/2659.index.js +93 -0
- package/dist/{826.index.js → 2826.index.js} +2 -2
- package/dist/{830.index.js → 2830.index.js} +2 -2
- package/dist/2923.index.js +298 -0
- package/dist/{1.index.js → 3001.index.js} +5 -5
- package/dist/{117.index.js → 3117.index.js} +3 -3
- package/dist/3180.index.js +379 -0
- package/dist/3276.index.js +117 -0
- package/dist/{415.index.js → 3415.index.js} +2 -2
- package/dist/{499.index.js → 3499.index.js} +2 -2
- package/dist/3518.index.js +450 -0
- package/dist/{526.index.js → 3526.index.js} +5 -5
- package/dist/{736.index.js → 3736.index.js} +4 -4
- package/dist/{839.index.js → 3839.index.js} +4 -4
- package/dist/{113.index.js → 4113.index.js} +5 -5
- package/dist/{265.index.js → 4265.index.js} +2 -2
- package/dist/{384.index.js → 4384.index.js} +3 -3
- package/dist/4547.index.js +268 -0
- package/dist/4863.index.js +422 -0
- package/dist/{970.index.js → 4970.index.js} +2 -2
- package/dist/5051.index.js +841 -0
- package/dist/{144.index.js → 5144.index.js} +5 -5
- package/dist/{333.index.js → 5333.index.js} +3 -3
- package/dist/5343.index.js +185 -0
- package/dist/5350.index.js +866 -0
- package/dist/5561.index.js +436 -0
- package/dist/{637.index.js → 5637.index.js} +3 -3
- package/dist/{449.index.js → 5830.index.js} +2 -2
- package/dist/6626.index.js +532 -0
- package/dist/6662.index.js +297 -0
- package/dist/{675.index.js → 6675.index.js} +5 -5
- package/dist/{730.index.js → 6730.index.js} +6 -6
- package/dist/6829.index.js +225 -0
- package/dist/6944.index.js +130 -0
- package/dist/{178.index.js → 7178.index.js} +3 -3
- package/dist/{227.index.js → 7227.index.js} +2 -2
- package/dist/7310.index.js +520 -0
- package/dist/{552.index.js → 7552.index.js} +4 -4
- package/dist/7709.index.js +78 -0
- package/dist/8218.index.js +160 -0
- package/dist/{476.index.js → 8476.index.js} +4 -4
- package/dist/{513.index.js → 8513.index.js} +5 -5
- package/dist/{520.index.js → 8520.index.js} +2 -2
- package/dist/{718.index.js → 8718.index.js} +2 -2
- package/dist/{752.index.js → 8752.index.js} +2 -2
- package/dist/8846.index.js +100 -0
- package/dist/{435.index.js → 9091.index.js} +560 -189
- package/dist/{207.index.js → 9207.index.js} +2 -2
- package/dist/{220.index.js → 9220.index.js} +2 -2
- package/dist/9390.index.js +163 -0
- package/dist/{503.index.js → 9503.index.js} +2 -2
- package/dist/{801.index.js → 9801.index.js} +2 -2
- package/dist/{824.index.js → 9824.index.js} +2 -2
- package/dist/agentic-security.mjs +16 -16
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/dist/frontend/index.html +21 -0
- package/dist/frontend/src/app.js +176 -0
- package/dist/frontend/src/components/evidence-inspector.js +141 -0
- package/dist/frontend/src/components/filter-rail.js +119 -0
- package/dist/frontend/src/components/query-bar.js +126 -0
- package/dist/frontend/src/data/flagship-graph.js +1460 -0
- package/dist/frontend/src/export-entry.js +36 -0
- package/dist/frontend/src/lib/api-client.js +92 -0
- package/dist/frontend/src/lib/contrast.js +34 -0
- package/dist/frontend/src/lib/dom.js +24 -0
- package/dist/frontend/src/lib/escape-html.js +16 -0
- package/dist/frontend/src/lib/flow-path.js +40 -0
- package/dist/frontend/src/lib/focus-controls.js +149 -0
- package/dist/frontend/src/lib/protection-visual.js +46 -0
- package/dist/frontend/src/lib/query-language.js +240 -0
- package/dist/frontend/src/lib/row-filters.js +43 -0
- package/dist/frontend/src/lib/state.js +84 -0
- package/dist/frontend/src/main.js +83 -0
- package/dist/frontend/src/shell.js +184 -0
- package/dist/frontend/src/views/architecture-view.js +798 -0
- package/dist/frontend/src/views/inventory-view.js +292 -0
- package/dist/frontend/src/views/privacy-view.js +172 -0
- package/dist/frontend/src/views/trace-view.js +206 -0
- package/dist/frontend/styles/architecture-view.css +93 -0
- package/dist/frontend/styles/filter-rail.css +34 -0
- package/dist/frontend/styles/inspector.css +69 -0
- package/dist/frontend/styles/inventory-view.css +74 -0
- package/dist/frontend/styles/privacy-view.css +86 -0
- package/dist/frontend/styles/query-bar.css +107 -0
- package/dist/frontend/styles/shell.css +155 -0
- package/dist/frontend/styles/tokens.css +128 -0
- package/dist/frontend/styles/trace-view.css +95 -0
- package/package.json +13 -6
- package/src/dataflow/CLAUDE.md +1 -1
- package/src/dataflow/catalog.js +42 -0
- package/src/dataflow/orm-write-catalog.js +175 -0
- package/src/engine.js +92 -1
- package/src/ir/CLAUDE.md +1 -0
- package/src/ir/chrome-probe.mjs +150 -0
- package/src/ir/parser-js.js +94 -7
- package/src/lineage/CLAUDE.md +1203 -0
- package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
- package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
- package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
- package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
- package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
- package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
- package/src/lineage/DESIGN_REGISTRIES.md +880 -0
- package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
- package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
- package/src/lineage/classification.js +56 -0
- package/src/lineage/coverage.js +658 -0
- package/src/lineage/cross-repo-link.js +107 -0
- package/src/lineage/dataflow-graph.schema.json +184 -0
- package/src/lineage/decision-story.js +206 -0
- package/src/lineage/drift-policy.js +279 -0
- package/src/lineage/driver.js +135 -0
- package/src/lineage/engine.js +992 -0
- package/src/lineage/export-briefing.js +628 -0
- package/src/lineage/export-csv.js +62 -0
- package/src/lineage/export-json.js +238 -0
- package/src/lineage/export-privacy.js +258 -0
- package/src/lineage/federation-loader.js +111 -0
- package/src/lineage/field-identity.js +78 -0
- package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
- package/src/lineage/fixtures/flagship-graph.json +1453 -0
- package/src/lineage/flow-grade.js +221 -0
- package/src/lineage/governance-edit.js +169 -0
- package/src/lineage/graph-builder.js +1114 -0
- package/src/lineage/graph-diff.js +431 -0
- package/src/lineage/graph-snapshot.js +180 -0
- package/src/lineage/handling-analyzer.js +168 -0
- package/src/lineage/ids.js +349 -0
- package/src/lineage/impact-assessment.js +76 -0
- package/src/lineage/impact-engine.js +268 -0
- package/src/lineage/index.js +281 -0
- package/src/lineage/language-coverage-tiers.js +58 -0
- package/src/lineage/obligation-mapping.js +126 -0
- package/src/lineage/obligation-predicates.js +235 -0
- package/src/lineage/observation-adapters.js +282 -0
- package/src/lineage/observation-correlation.js +622 -0
- package/src/lineage/observation-store.js +497 -0
- package/src/lineage/path-query.js +410 -0
- package/src/lineage/path-store.js +400 -0
- package/src/lineage/protection.js +53 -0
- package/src/lineage/recipient-profile.js +192 -0
- package/src/lineage/recipient-registry.js +394 -0
- package/src/lineage/redact-graph.js +224 -0
- package/src/lineage/remediation.js +417 -0
- package/src/lineage/resolve-destination.js +91 -0
- package/src/lineage/runtime-observation.js +464 -0
- package/src/lineage/scenario-diff.js +84 -0
- package/src/lineage/scenario-engine.js +251 -0
- package/src/lineage/scenario.js +101 -0
- package/src/lineage/schema.js +167 -0
- package/src/lineage/sink-registry.js +427 -0
- package/src/lineage/source-registry.js +357 -0
- package/src/lineage/source-seeding.js +212 -0
- package/src/lineage/summaries.js +590 -0
- package/src/lineage/transform-catalog.js +397 -0
- package/src/lineage/transit-protection.js +150 -0
- package/src/lineage/validate.js +285 -0
- package/src/mcp/CLAUDE.md +7 -1
- package/src/mcp/dataflow-tools.js +160 -0
- package/src/mcp/server.js +1 -1
- package/src/mcp/tools.js +2 -1
- package/src/pipeline/scan-health.js +19 -1
- package/src/posture/CLAUDE.md +19 -0
- package/src/posture/artifact-registry.js +52 -0
- package/src/posture/auditor-walkthrough.js +76 -0
- package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/src/posture/obligation-evidence-pack.js +202 -0
- package/src/posture/remediation-ledger.js +337 -0
- package/src/server/CLAUDE.md +47 -0
- package/src/server/graph-loader.js +141 -0
- package/src/server/http-server.js +325 -0
- package/src/server/routes.js +129 -0
- package/src/server/security.js +111 -0
- package/src/server/static-assets.js +144 -0
- package/src/shared/frontend-root.js +52 -0
- package/dist/11.index.js +0 -353
- package/dist/259.index.js +0 -975
- package/dist/317.index.js +0 -300
- package/dist/609.index.js +0 -741
- 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
|
+
}
|