@clear-capabilities/agentic-security-scanner 0.162.0 → 0.163.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 (153) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/bin/agentic-security.js +87 -0
  3. package/dist/1340.index.js +1871 -0
  4. package/dist/3477.index.js +727 -0
  5. package/dist/3499.index.js +23 -3
  6. package/dist/4113.index.js +1 -1
  7. package/dist/5014.index.js +833 -0
  8. package/dist/5756.index.js +2 -2
  9. package/dist/6257.index.js +1 -1
  10. package/dist/6907.index.js +248 -0
  11. package/dist/6994.index.js +1 -1
  12. package/dist/7033.index.js +33 -5
  13. package/dist/7439.index.js +1822 -0
  14. package/dist/7838.index.js +1 -1
  15. package/dist/9560.index.js +2318 -23
  16. package/dist/957.index.js +1 -1
  17. package/dist/9999.index.js +43 -7
  18. package/dist/agentic-security.mjs +12 -12
  19. package/dist/agentic-security.mjs.sha256 +1 -1
  20. package/package.json +33 -6
  21. package/src/capabilities/CLAUDE.md +114 -0
  22. package/src/capabilities/address.js +73 -0
  23. package/src/capabilities/attack-coverage.js +93 -0
  24. package/src/capabilities/decide.js +256 -0
  25. package/src/capabilities/delegate.js +69 -0
  26. package/src/capabilities/egress.js +20 -0
  27. package/src/capabilities/hook-advice.js +50 -0
  28. package/src/capabilities/index.js +32 -0
  29. package/src/capabilities/manifest.js +344 -0
  30. package/src/capabilities/outbound.js +189 -0
  31. package/src/capabilities/paths.js +57 -0
  32. package/src/capabilities/probes.js +211 -0
  33. package/src/capabilities/proxy.js +182 -0
  34. package/src/capabilities/reasons.js +67 -0
  35. package/src/capabilities/receipts.js +356 -0
  36. package/src/capabilities/records.js +58 -0
  37. package/src/capabilities/recovery.js +367 -0
  38. package/src/capabilities/report.js +81 -0
  39. package/src/capabilities/runner.js +236 -0
  40. package/src/capabilities/secrets.js +26 -0
  41. package/src/capabilities/tool-gate.js +119 -0
  42. package/src/capabilities/tool-registry.js +60 -0
  43. package/src/dataflow/CLAUDE.md +29 -0
  44. package/src/dataflow/catalog.js +21 -0
  45. package/src/dataflow/engine.js +20 -0
  46. package/src/dataflow/guard-dominance.js +352 -0
  47. package/src/discovery/CLAUDE.md +9 -0
  48. package/src/discovery/index.js +15 -1
  49. package/src/discovery/lenses.js +18 -0
  50. package/src/engine.js +80 -15
  51. package/src/integrations/index.js +5 -2
  52. package/src/ir/CLAUDE.md +14 -0
  53. package/src/ir/implicit-handler-params.js +74 -0
  54. package/src/ir/parser-cs.js +20 -0
  55. package/src/ir/parser-go.js +30 -13
  56. package/src/ir/parser-java.js +11 -0
  57. package/src/language/nix-eval-isolation.js +33 -5
  58. package/src/lineage/CLAUDE.md +31 -0
  59. package/src/lineage/deployment/adapter-cloud.js +202 -0
  60. package/src/lineage/deployment/adapter-compose.js +90 -0
  61. package/src/lineage/deployment/adapter-kit.js +142 -0
  62. package/src/lineage/deployment/adapter-kubernetes.js +247 -0
  63. package/src/lineage/deployment/attack-paths.js +443 -0
  64. package/src/lineage/deployment/boundaries-run.js +223 -0
  65. package/src/lineage/deployment/boundary-graph.js +567 -0
  66. package/src/lineage/deployment/cross-repo.js +253 -0
  67. package/src/lineage/deployment/drift.js +383 -0
  68. package/src/lineage/deployment/ingest.js +221 -0
  69. package/src/lineage/deployment/projection.js +244 -0
  70. package/src/lineage/deployment/trace-correlation.js +541 -0
  71. package/src/lineage/source-registry.js +19 -0
  72. package/src/mcp/CLAUDE.md +5 -2
  73. package/src/mcp/invariant-tools.js +64 -0
  74. package/src/mcp/portfolio-tools.js +79 -0
  75. package/src/mcp/redact.js +15 -0
  76. package/src/mcp/server.js +24 -3
  77. package/src/mcp/tools.js +13 -1
  78. package/src/posture/CLAUDE.md +332 -1
  79. package/src/posture/accuracy-scorecard.js +95 -4
  80. package/src/posture/assurance/rollback.js +214 -0
  81. package/src/posture/assurance/rollout.js +200 -0
  82. package/src/posture/business-logic.js +26 -4
  83. package/src/posture/evaluation/bakeoff.js +148 -0
  84. package/src/posture/evaluation/custody.js +200 -0
  85. package/src/posture/evaluation/deployment-ablation.js +416 -0
  86. package/src/posture/evaluation/dev-cases.js +65 -0
  87. package/src/posture/evaluation/economics.js +85 -0
  88. package/src/posture/evaluation/gates.js +161 -0
  89. package/src/posture/evaluation/grouping.js +101 -0
  90. package/src/posture/evaluation/interval.js +132 -0
  91. package/src/posture/evaluation/invariant-ablation.js +325 -0
  92. package/src/posture/evaluation/labels.js +200 -0
  93. package/src/posture/evaluation/protocol.js +308 -0
  94. package/src/posture/evaluation/release-gate.js +209 -0
  95. package/src/posture/evaluation/report.js +158 -0
  96. package/src/posture/evaluation/runner.js +273 -0
  97. package/src/posture/evaluation/scan-child.js +80 -0
  98. package/src/posture/evaluation/score.js +174 -0
  99. package/src/posture/evaluation/synthetic.js +90 -0
  100. package/src/posture/evaluation/why-missed.js +219 -0
  101. package/src/posture/fleet.js +11 -2
  102. package/src/posture/invariants/cli.js +95 -0
  103. package/src/posture/invariants/coverage.js +164 -0
  104. package/src/posture/invariants/export.js +178 -0
  105. package/src/posture/invariants/expressions.js +46 -0
  106. package/src/posture/invariants/lifecycle.js +289 -0
  107. package/src/posture/invariants/project-input.js +55 -0
  108. package/src/posture/invariants/repair.js +340 -0
  109. package/src/posture/invariants/run.js +45 -0
  110. package/src/posture/invariants/scenarios.js +311 -0
  111. package/src/posture/invariants/schema.js +167 -0
  112. package/src/posture/invariants/shrink.js +407 -0
  113. package/src/posture/invariants/state-assertions.js +165 -0
  114. package/src/posture/logic-claims.js +12 -0
  115. package/src/posture/model-routing.js +40 -0
  116. package/src/posture/oracles/adapters.js +172 -1
  117. package/src/posture/oracles/conformance.js +3 -2
  118. package/src/posture/oracles/oracle.js +23 -3
  119. package/src/posture/oracles/registry.js +1 -1
  120. package/src/posture/portfolio/backend.js +279 -0
  121. package/src/posture/portfolio/bundle.js +314 -0
  122. package/src/posture/portfolio/cli.js +142 -0
  123. package/src/posture/portfolio/manifest.js +263 -0
  124. package/src/posture/portfolio/progress.js +267 -0
  125. package/src/posture/portfolio/resume.js +130 -0
  126. package/src/posture/portfolio/retention.js +207 -0
  127. package/src/posture/portfolio/scheduler.js +488 -0
  128. package/src/posture/portfolio/signing.js +155 -0
  129. package/src/posture/portfolio/wording.js +59 -0
  130. package/src/posture/portfolio/work-units.js +405 -0
  131. package/src/posture/replay/replay.js +1 -0
  132. package/src/posture/routing/adapter.js +140 -0
  133. package/src/posture/routing/calibration.js +278 -0
  134. package/src/posture/routing/canary.js +141 -0
  135. package/src/posture/routing/control.js +35 -0
  136. package/src/posture/routing/decide.js +211 -0
  137. package/src/posture/routing/drift.js +137 -0
  138. package/src/posture/routing/economics.js +185 -0
  139. package/src/posture/routing/feedback.js +207 -0
  140. package/src/posture/routing/index.js +31 -0
  141. package/src/posture/routing/outcomes.js +281 -0
  142. package/src/posture/routing/promotion.js +291 -0
  143. package/src/posture/routing/receipts.js +59 -0
  144. package/src/posture/routing/report.js +142 -0
  145. package/src/posture/routing/shadow.js +53 -0
  146. package/src/posture/specification-mining.js +26 -0
  147. package/src/posture/verification/patch-negative.js +16 -4
  148. package/src/posture/verification/patch-promotion.js +10 -2
  149. package/src/report/index.js +51 -1
  150. package/src/sandbox/CLAUDE.md +10 -0
  151. package/src/sandbox/backend-userspace.js +91 -13
  152. package/dist/5613.index.js +0 -500
  153. package/dist/6239.index.js +0 -469
@@ -0,0 +1,1871 @@
1
+ export const id = 1340;
2
+ export const ids = [1340];
3
+ export const modules = {
4
+
5
+ /***/ 1340:
6
+ /***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
7
+
8
+
9
+ // EXPORTS
10
+ __webpack_require__.d(__webpack_exports__, {
11
+ renderBoundariesText: () => (/* binding */ renderBoundariesText),
12
+ runBoundaries: () => (/* binding */ runBoundaries)
13
+ });
14
+
15
+ // UNUSED EXPORTS: BINDINGS_FILE, BOUNDARIES_REPORT_SCHEMA, BOUNDARIES_REPORT_VERSION, IDENTITIES_FILE, MAX_FINDINGS, TRACE_FILES, listConfigFiles, readScanFindings
16
+
17
+ // EXTERNAL MODULE: external "node:fs"
18
+ var external_node_fs_ = __webpack_require__(73024);
19
+ // EXTERNAL MODULE: external "node:path"
20
+ var external_node_path_ = __webpack_require__(76760);
21
+ // EXTERNAL MODULE: ./src/posture/assurance/config.js
22
+ var assurance_config = __webpack_require__(90385);
23
+ // EXTERNAL MODULE: ./src/egress/policy.js
24
+ var policy = __webpack_require__(45712);
25
+ // EXTERNAL MODULE: ./src/egress/redact.js
26
+ var redact = __webpack_require__(11723);
27
+ // EXTERNAL MODULE: ./src/egress/audit.js
28
+ var audit = __webpack_require__(37355);
29
+ ;// CONCATENATED MODULE: ./src/posture/assurance/bounded-io.js
30
+ // Bounded I/O and the single route for new model/network calls (CORE-004).
31
+ //
32
+ // There is deliberately NO HTTP client in this module. `guardedModelCall` takes
33
+ // the transport as an argument (`call`), so it cannot become a way around the
34
+ // existing egress layer: before `call` is ever invoked, the request is
35
+ // 1. gated by the feature configuration (disabled, killed, unsupported),
36
+ // 2. checked for a configured endpoint (never defaulted, never a cloud fallback),
37
+ // 3. evaluated by egress/policy.js evaluateEgress(), BEFORE any payload is built,
38
+ // 4. redacted by egress/redact.js redactPayload(), so `call` only ever sees
39
+ // redacted text,
40
+ // 5. size-checked against the finite request limit.
41
+ // Only then is it run under a deadline with a bounded retry count, and its output
42
+ // is capped. Every decision is appended to the existing egress audit chain.
43
+ //
44
+ // Every limit named here is enforced by a function in this file; limits that this
45
+ // layer cannot enforce are disclosed as such by config.js, not claimed.
46
+
47
+
48
+
49
+
50
+
51
+
52
+
53
+ /** Read a file, refusing anything larger than `maxBytes`. Reads at most maxBytes+1, so a file that grows after the size check cannot exhaust memory. */
54
+ function readFileBounded(filePath, maxBytes) {
55
+ if (!Number.isInteger(maxBytes) || maxBytes < 1) return (0,assurance_config/* typed */.nE)('blocked', 'invalid-config', 'maxBytes must be a positive integer');
56
+ let fd;
57
+ try {
58
+ fd = external_node_fs_.openSync(filePath, 'r');
59
+ const st = external_node_fs_.fstatSync(fd);
60
+ if (!st.isFile()) return (0,assurance_config/* typed */.nE)('blocked', 'limit-exceeded', 'not a regular file');
61
+ if (st.size > maxBytes) return (0,assurance_config/* typed */.nE)('blocked', 'limit-exceeded', `file is ${st.size} bytes, over the ${maxBytes} byte limit`, { bytes: st.size });
62
+ const buf = Buffer.alloc(maxBytes + 1);
63
+ const n = external_node_fs_.readSync(fd, buf, 0, maxBytes + 1, 0);
64
+ if (n > maxBytes) return (0,assurance_config/* typed */.nE)('blocked', 'limit-exceeded', `file grew past the ${maxBytes} byte limit while reading`);
65
+ return (0,assurance_config/* typed */.nE)('ok', null, 'read', { text: buf.subarray(0, n).toString('utf8'), bytes: n });
66
+ } catch (e) {
67
+ return (0,assurance_config/* typed */.nE)('blocked', 'missing-dependency', `cannot read ${filePath}: ${e.code || e.message}`);
68
+ } finally {
69
+ if (fd !== undefined) { try { external_node_fs_.closeSync(fd); } catch { /* already closed */ } }
70
+ }
71
+ }
72
+
73
+ /** Cap a string to `maxBytes` of UTF-8 without splitting a character. */
74
+ function capOutput(text, maxBytes) {
75
+ const s = typeof text === 'string' ? text : String(text ?? '');
76
+ const bytes = Buffer.byteLength(s, 'utf8');
77
+ if (bytes <= maxBytes) return { text: s, truncated: false, bytes };
78
+ let cut = Buffer.from(s, 'utf8').subarray(0, maxBytes).toString('utf8');
79
+ if (cut.endsWith('�')) cut = cut.slice(0, -1); // a multi-byte character was split at the boundary
80
+ return { text: cut, truncated: true, bytes };
81
+ }
82
+
83
+ /** Run `fn(signal)` under a hard deadline. The timer is always cleared; a timeout aborts the signal and resolves to a typed result. */
84
+ async function withDeadline(fn, ms) {
85
+ const ac = new AbortController();
86
+ let timer;
87
+ const timeout = new Promise((resolve) => {
88
+ timer = setTimeout(() => { ac.abort(); resolve(typed('blocked', 'timeout', `no result within ${ms} ms`)); }, ms);
89
+ });
90
+ try {
91
+ const run = Promise.resolve().then(() => fn(ac.signal)).then(
92
+ (value) => ({ ok: true, value }),
93
+ (error) => ({ ok: false, error }),
94
+ );
95
+ const winner = await Promise.race([run, timeout]);
96
+ if (winner && winner.ok === true) return typed('ok', null, 'completed', { value: winner.value });
97
+ if (winner && winner.ok === false) return { status: 'error', error: winner.error };
98
+ return winner;
99
+ } finally { clearTimeout(timer); }
100
+ }
101
+
102
+ /** At most `retries` extra attempts, and only for failures the callee marks `retryable`. */
103
+ async function retryBounded(fn, { retries, backoffMs = 0 } = {}) {
104
+ const max = Math.max(0, Math.min(Number.isInteger(retries) ? retries : 0, 10));
105
+ let attempts = 0;
106
+ for (;;) {
107
+ attempts += 1;
108
+ const r = await fn(attempts);
109
+ if (r.status !== 'error' || !r.error?.retryable || attempts > max) return { ...r, attempts };
110
+ if (backoffMs > 0) await new Promise(res => setTimeout(res, Math.min(backoffMs * attempts, 5000)));
111
+ }
112
+ }
113
+
114
+ /**
115
+ * The only way a new feature makes a model or network call.
116
+ *
117
+ * @param {object} p
118
+ * @param {object} p.config resolveAssuranceConfig() result
119
+ * @param {string} p.featureId
120
+ * @param {string} p.scanRoot
121
+ * @param {string|null} p.endpoint MUST come from operator configuration; null yields `missing-provider`
122
+ * @param {string} p.purpose
123
+ * @param {string} p.text the prompt/payload, BEFORE redaction
124
+ * @param {string|null} [p.filePath]
125
+ * @param {string|null} [p.model]
126
+ * @param {Array} [p.requirements] extra requirements (e.g. a credential), evaluated before any call
127
+ * @param {(req:{endpoint:string,text:string,signal:AbortSignal,timeoutMs:number})=>Promise<string>} p.call injected transport
128
+ */
129
+ async function guardedModelCall({ config, featureId, scanRoot, endpoint, purpose, text, filePath = null, model = null, requirements = [], call }) {
130
+ const gate = featureStatus(config, featureId);
131
+ if (gate.status !== 'ok') return gate;
132
+ const limits = limitValues(config);
133
+
134
+ if (!endpoint || typeof endpoint !== 'string') {
135
+ return typed('blocked', 'missing-provider', `${featureId} has no model endpoint configured; there is no default and no cloud fallback`, { feature: featureId });
136
+ }
137
+ const { missingRequired } = evaluateRequirements(requirements);
138
+ if (missingRequired.length) {
139
+ const m = missingRequired[0];
140
+ return typed('blocked', m.code, `${featureId} needs ${m.kind} '${m.name}', which is not available`, { feature: featureId, missing: missingRequired });
141
+ }
142
+ if (typeof call !== 'function') return typed('blocked', 'missing-dependency', 'no transport supplied', { feature: featureId });
143
+
144
+ // egress policy BEFORE the prompt is built or any client is touched
145
+ const ctx = { scanRoot, purpose, endpoint, model, path: filePath };
146
+ const decision = evaluateEgress(ctx);
147
+ if (!decision.allowed) {
148
+ recordEgressCall({ scanRoot, decision, ctx });
149
+ return typed('blocked', 'egress-denied', decision.reason || 'egress policy denied the call', { feature: featureId, decision });
150
+ }
151
+
152
+ // redaction BEFORE request construction
153
+ const redacted = redactPayload({ text: String(text ?? ''), filePath, scanRoot });
154
+ const metrics = payloadMetrics(redacted.text);
155
+ if (metrics.byteCount > limits.maxRequestBytes) {
156
+ recordEgressCall({ scanRoot, decision: { ...decision, allowed: false, decision: 'deny', reason: 'request over maxRequestBytes' }, ctx });
157
+ return typed('blocked', 'limit-exceeded', `request is ${metrics.byteCount} bytes, over the ${limits.maxRequestBytes} byte limit`, { feature: featureId });
158
+ }
159
+ recordEgressCall({ scanRoot, decision, ctx, metrics });
160
+
161
+ const run = await retryBounded(
162
+ () => withDeadline((signal) => call({ endpoint, text: redacted.text, signal, timeoutMs: limits.timeoutMs }), limits.timeoutMs),
163
+ { retries: limits.retries },
164
+ );
165
+ if (run.status === 'error') {
166
+ return typed('blocked', 'missing-dependency', `model call failed: ${String(run.error?.message || run.error)}`, { feature: featureId, attempts: run.attempts });
167
+ }
168
+ if (run.status !== 'ok') return { ...run, feature: featureId, attempts: run.attempts };
169
+ const out = capOutput(run.value, limits.maxOutputBytes);
170
+ const status = out.truncated ? 'degraded' : 'ok';
171
+ return typed(status, out.truncated ? 'limit-exceeded' : null, out.truncated ? `output truncated to ${limits.maxOutputBytes} bytes` : 'completed', {
172
+ feature: featureId, text: out.text, truncated: out.truncated, attempts: run.attempts, redactions: redacted.redactions,
173
+ });
174
+ }
175
+
176
+ // EXTERNAL MODULE: ./src/posture/state-dir.js
177
+ var state_dir = __webpack_require__(31174);
178
+ // EXTERNAL MODULE: ./src/posture/assurance/identity.js
179
+ var identity = __webpack_require__(41877);
180
+ // EXTERNAL MODULE: ./src/lineage/deployment/boundary-graph.js
181
+ var boundary_graph = __webpack_require__(8283);
182
+ // EXTERNAL MODULE: ./src/util/yaml.js + 1 modules
183
+ var yaml = __webpack_require__(82340);
184
+ ;// CONCATENATED MODULE: ./src/lineage/deployment/adapter-kit.js
185
+ // adapter-kit.js: shared plumbing for the deployment configuration adapters
186
+ // (X-302). Every adapter is a pure function from text to `{ nodes, edges, gaps }`
187
+ // built through one context, so that three things hold for every adapter
188
+ // without each one remembering them:
189
+ //
190
+ // - every edge a static adapter emits is `static-config` provenance and
191
+ // carries the exact file reference, digest, parser name and parser version
192
+ // it came from (X-302.AC03);
193
+ // - environment, tenant and owning repository are stamped uniformly, so two
194
+ // environments never share a node;
195
+ // - a reference that cannot be resolved becomes an unresolved node plus a gap,
196
+ // never a guessed edge to a resolved one.
197
+ //
198
+ // Nothing here evaluates configuration. YAML is read with the scanner's safe
199
+ // loader (unknown tags are refused), JSON with JSON.parse, and policy strings
200
+ // are JSON.parse'd, never evaluated.
201
+
202
+
203
+
204
+
205
+ const ANCHOR_LIMIT = 100;
206
+
207
+ /**
208
+ * Parse one YAML document, or several separated by `---`. Never throws.
209
+ * Returns `{ docs }` or `{ gap }` where the gap is a typed unsupported or
210
+ * malformed result. YAML merge keys are not expanded by the loader, so a
211
+ * document that uses `<<` is reported by the adapter that needs the key.
212
+ */
213
+ function parseYamlDocuments(text, file) {
214
+ const aliasCount = (text.match(/(^|[\s\[{,:-])\*[A-Za-z0-9_-]+/g) || []).length;
215
+ if (aliasCount > ANCHOR_LIMIT) {
216
+ return { gap: { code: 'unsupported-syntax', subject: file, file, message: `YAML uses ${aliasCount} aliases, over the limit of ${ANCHOR_LIMIT}; the file is not read` } };
217
+ }
218
+ const chunks = text.split(/^---[ \t]*$/m);
219
+ const docs = [];
220
+ for (const chunk of chunks) {
221
+ let doc;
222
+ try { doc = (0,yaml/* load */.Hh)(chunk); } catch (e) {
223
+ return { gap: { code: 'malformed-input', subject: file, file, message: `not valid YAML (${String(e.message).split('\n')[0]})` } };
224
+ }
225
+ if (doc !== undefined && doc !== null) docs.push(doc);
226
+ }
227
+ return { docs };
228
+ }
229
+
230
+ function isObject(v) { return v !== null && typeof v === 'object' && !Array.isArray(v); }
231
+ function asArray(v) { return Array.isArray(v) ? v : []; }
232
+ function str(v) { return typeof v === 'string' && v.length > 0 ? v : null; }
233
+
234
+ /** True when every entry of `selector` is present with the same value in `labels`. An empty selector selects nothing. */
235
+ function selectorMatches(selector, labels) {
236
+ if (!isObject(selector) || !isObject(labels)) return false;
237
+ const entries = Object.entries(selector);
238
+ if (entries.length === 0) return false;
239
+ return entries.every(([k, v]) => Object.hasOwn(labels, k) && String(labels[k]) === String(v));
240
+ }
241
+
242
+ /**
243
+ * @param {object} p
244
+ * @param {string} p.file path as supplied, relative to the ingest root
245
+ * @param {string} p.digest sha256 of the text that was parsed
246
+ * @param {string} p.environment deployment environment these files describe
247
+ * @param {string|null} p.repository
248
+ * @param {string|null} p.revision exact commit, when known
249
+ * @param {string} p.parser
250
+ * @param {string} p.parserVersion
251
+ */
252
+ function adapterContext({ file, digest, environment, repository, revision, parser, parserVersion }) {
253
+ const nodes = [];
254
+ const edges = [];
255
+ const gaps = [];
256
+ const ref = { file, digest };
257
+ const source = { file, digest, parser, parserVersion };
258
+
259
+ const kit = {
260
+ nodes, edges, gaps, environment, repository, file,
261
+
262
+ /** Declare a node this file defines. Adds the structural environment, repository and tenant edges. */
263
+ node(kind, name, o = {}) {
264
+ const tenant = o.tenant ?? null;
265
+ const n = {
266
+ kind, name, environment, tenant, repository: o.resolved === false ? null : repository,
267
+ trustZone: o.trustZone ?? 'unknown', resolved: o.resolved !== false, unresolvedReason: o.unresolvedReason ?? null,
268
+ attrs: o.attrs ?? {}, declaredBy: o.resolved === false ? [] : [ref],
269
+ };
270
+ nodes.push(n);
271
+ if (o.resolved !== false) {
272
+ const env = { kind: 'environment', name: environment, environment, tenant: null, repository: null, trustZone: 'unknown', resolved: true, attrs: {}, declaredBy: [ref] };
273
+ nodes.push(env);
274
+ kit.structural('deployed-in', n, env);
275
+ if (repository) {
276
+ const repo = { kind: 'repository', name: repository, environment: null, tenant: null, repository, trustZone: 'unknown', resolved: true, attrs: {}, declaredBy: [ref] };
277
+ nodes.push(repo);
278
+ kit.structural('defined-in', n, repo);
279
+ }
280
+ if (tenant) {
281
+ const t = { kind: 'tenant', name: tenant, environment, tenant, repository: null, trustZone: 'unknown', resolved: true, attrs: {}, declaredBy: [ref] };
282
+ nodes.push(t);
283
+ kit.structural('scoped-to', n, t);
284
+ }
285
+ }
286
+ return n;
287
+ },
288
+
289
+ /** A reference this file makes to something it does not define and cannot resolve. */
290
+ unresolved(kind, name, reason, o = {}) {
291
+ return kit.node(kind, name, { ...o, resolved: false, unresolvedReason: reason });
292
+ },
293
+
294
+ structural(relation, from, to) {
295
+ edges.push(kit.baseEdge(relation, from, to, { effect: 'none', confidence: 'high' }));
296
+ },
297
+
298
+ baseEdge(relation, from, to, o = {}) {
299
+ return {
300
+ relation, from: idOf(from), to: idOf(to), environment: from.environment ?? to.environment ?? null, tenant: o.tenant ?? null,
301
+ effect: o.effect ?? 'none', provenance: 'static-config', confidence: o.confidence ?? 'medium',
302
+ pathState: o.effect === 'deny' ? 'blocked' : (o.pathState ?? 'possible'),
303
+ observationInterval: null, completeness: null, sourceRevision: revision ?? null, source,
304
+ discriminator: o.discriminator ?? null, crossRepo: null, attrs: o.attrs ?? {},
305
+ _fromNode: from, _toNode: to,
306
+ };
307
+ },
308
+
309
+ /** Add a relation between two nodes from this file. */
310
+ edge(relation, from, to, o = {}) {
311
+ edges.push(kit.baseEdge(relation, from, to, o));
312
+ },
313
+
314
+ gap(code, subject, message) { gaps.push({ code, subject, file, message }); },
315
+
316
+ result() {
317
+ // Drop the private endpoint references now that the ids are computed.
318
+ return { nodes, edges: edges.map(({ _fromNode, _toNode, ...e }) => e), gaps };
319
+ },
320
+ };
321
+ return kit;
322
+ }
323
+
324
+ function idOf(node) {
325
+ return (0,boundary_graph/* nodeIdOf */.$z)({ kind: node.kind, name: node.name, environment: node.kind === 'environment' ? node.name : (node.environment ?? null), tenant: node.kind === 'tenant' ? node.name : (node.tenant ?? null) });
326
+ }
327
+
328
+ ;// CONCATENATED MODULE: ./src/lineage/deployment/adapter-kubernetes.js
329
+ // adapter-kubernetes.js: Kubernetes manifests, Ingress, Gateway API HTTPRoute,
330
+ // RBAC and NetworkPolicy into deployment boundary nodes and edges (X-302).
331
+ //
332
+ // Reads only the fields that name a topology relation. Container environment
333
+ // variables, Secret objects, ConfigMap data and image references are never
334
+ // read, so no credential or payload can reach the graph through this adapter.
335
+ //
336
+ // Two passes: the first indexes every object in the supplied files, the second
337
+ // emits nodes and edges, so a Service can be matched to a workload declared
338
+ // later in the same bundle. A reference to something not in the supplied
339
+ // files (a ServiceAccount, a Role, a backend Service, a selector with no
340
+ // matching workload) becomes an unresolved node and a typed gap.
341
+
342
+
343
+
344
+ const KUBERNETES_PARSER = 'kubernetes-manifest';
345
+ const KUBERNETES_PARSER_VERSION = '1';
346
+
347
+ const WORKLOAD_KINDS = new Set(['Deployment', 'StatefulSet', 'DaemonSet', 'ReplicaSet', 'Job', 'CronJob', 'Pod']);
348
+ const SKIPPED_KINDS = new Set(['Secret', 'ConfigMap']);
349
+
350
+ function ns(o) { return str(o?.metadata?.namespace) ?? 'default'; }
351
+ function nm(o) { return str(o?.metadata?.name); }
352
+
353
+ function podTemplate(o) {
354
+ if (o.kind === 'Pod') return { spec: o.spec, labels: o.metadata?.labels };
355
+ if (o.kind === 'CronJob') return { spec: o.spec?.jobTemplate?.spec?.template?.spec, labels: o.spec?.jobTemplate?.spec?.template?.metadata?.labels };
356
+ return { spec: o.spec?.template?.spec, labels: o.spec?.template?.metadata?.labels };
357
+ }
358
+
359
+ /**
360
+ * Does a NetworkPolicy podSelector pick this workload? An empty selector picks
361
+ * every workload in the namespace; matchExpressions are not evaluated, so a
362
+ * selector that uses them selects nothing (and the caller reports a gap).
363
+ */
364
+ function podSelectorPicks(sel, labels) {
365
+ if (!isObject(sel)) return false;
366
+ const keys = Object.keys(sel);
367
+ if (keys.length === 0) return true;
368
+ if (keys.includes('matchExpressions')) return false;
369
+ const ml = sel.matchLabels;
370
+ if (!isObject(ml) || Object.keys(ml).length === 0) return keys.every(k => k === 'matchLabels');
371
+ return selectorMatches(ml, labels);
372
+ }
373
+
374
+ function tenantOf(...labelMaps) {
375
+ for (const m of labelMaps) if (isObject(m) && typeof m.tenant === 'string' && /^[A-Za-z0-9._-]{1,64}$/.test(m.tenant)) return m.tenant;
376
+ return null;
377
+ }
378
+
379
+ /** @returns {{nodes: object[], edges: object[], gaps: object[]}} */
380
+ function parseKubernetes(text, fileCtx) {
381
+ const kit = adapterContext({ ...fileCtx, parser: KUBERNETES_PARSER, parserVersion: KUBERNETES_PARSER_VERSION });
382
+ const parsed = parseYamlDocuments(text, fileCtx.file);
383
+ if (parsed.gap) { kit.gaps.push(parsed.gap); return kit.result(); }
384
+
385
+ const objects = [];
386
+ for (const d of parsed.docs) {
387
+ if (!isObject(d) || !str(d.kind) || !str(d.apiVersion)) {
388
+ kit.gap('unsupported-format', fileCtx.file, 'a YAML document without apiVersion and kind is not a Kubernetes object and is skipped');
389
+ continue;
390
+ }
391
+ if (SKIPPED_KINDS.has(d.kind)) continue; // never read secret-bearing kinds
392
+ if (!nm(d)) { kit.gap('malformed-input', `${d.kind}`, `a ${d.kind} without metadata.name is skipped`); continue; }
393
+ objects.push(d);
394
+ }
395
+
396
+ // ---- pass 1: index
397
+ const workloads = [];
398
+ const serviceAccounts = new Set();
399
+ const services = new Map();
400
+ const roles = new Map();
401
+ for (const o of objects) {
402
+ if (WORKLOAD_KINDS.has(o.kind)) {
403
+ const t = podTemplate(o);
404
+ workloads.push({ obj: o, namespace: ns(o), name: nm(o), labels: isObject(t.labels) ? t.labels : {}, spec: isObject(t.spec) ? t.spec : {} });
405
+ } else if (o.kind === 'ServiceAccount') serviceAccounts.add(`${ns(o)}/${nm(o)}`);
406
+ else if (o.kind === 'Service') services.set(`${ns(o)}/${nm(o)}`, o);
407
+ else if (o.kind === 'Role') roles.set(`Role:${ns(o)}/${nm(o)}`, o);
408
+ else if (o.kind === 'ClusterRole') roles.set(`ClusterRole:${nm(o)}`, o);
409
+ }
410
+
411
+ // NetworkPolicy: which workloads are isolated, and which peers may reach them.
412
+ const policies = objects.filter(o => o.kind === 'NetworkPolicy');
413
+ const isolation = new Map(); // workload key -> 'deny-all' | 'restricted'
414
+ const wkey = (w) => `${w.namespace}/${w.name}`;
415
+ for (const p of policies) {
416
+ const types = asArray(p.spec?.policyTypes);
417
+ const selects = workloads.filter(w => w.namespace === ns(p) && podSelectorPicks(p.spec?.podSelector, w.labels));
418
+ if (selects.length === 0) { kit.gap('unresolved-identity', `NetworkPolicy ${ns(p)}/${nm(p)}`, 'the policy selects no workload in the supplied manifests'); continue; }
419
+ const hasIngress = types.includes('Ingress') || (types.length === 0 && Array.isArray(p.spec?.ingress));
420
+ for (const w of selects) {
421
+ if (!hasIngress) continue;
422
+ const rules = asArray(p.spec?.ingress);
423
+ const prior = isolation.get(wkey(w));
424
+ isolation.set(wkey(w), rules.length === 0 ? (prior ?? 'deny-all') : 'restricted');
425
+ }
426
+ }
427
+
428
+ // ---- pass 2: emit
429
+ const wNodes = new Map();
430
+ for (const w of workloads) {
431
+ const tenant = tenantOf(w.labels, w.obj.metadata?.labels);
432
+ const attrs = { workloadKind: w.obj.kind, namespace: w.namespace };
433
+ const iso = isolation.get(wkey(w));
434
+ if (iso) attrs.ingressPolicy = iso;
435
+ if (w.spec.hostNetwork === true) attrs.hostNetwork = true;
436
+ const node = kit.node('service', wkey(w), { tenant, trustZone: 'internal', attrs });
437
+ wNodes.set(wkey(w), node);
438
+ const saName = str(w.spec.serviceAccountName) ?? 'default';
439
+ const saKey = `${w.namespace}/${saName}`;
440
+ let ident;
441
+ if (serviceAccounts.has(saKey) || saName === 'default') {
442
+ ident = kit.node('identity', `sa/${saKey}`, { trustZone: 'internal', attrs: saName === 'default' && !serviceAccounts.has(saKey) ? { implicit: true } : {} });
443
+ } else {
444
+ ident = kit.unresolved('identity', `sa/${saKey}`, 'ServiceAccount is not in the supplied manifests', { trustZone: 'unknown' });
445
+ kit.gap('unresolved-identity', wkey(w), `serviceAccountName '${saName}' is not declared in the supplied manifests`);
446
+ }
447
+ kit.edge('assumes', node, ident, { confidence: 'high' });
448
+ }
449
+ for (const key of serviceAccounts) kit.node('identity', `sa/${key}`, { trustZone: 'internal' });
450
+
451
+ // RBAC
452
+ for (const b of objects.filter(o => o.kind === 'RoleBinding' || o.kind === 'ClusterRoleBinding')) {
453
+ const refKind = b.roleRef?.kind, refName = str(b.roleRef?.name);
454
+ if (!refName || (refKind !== 'Role' && refKind !== 'ClusterRole')) { kit.gap('malformed-input', `${b.kind} ${nm(b)}`, 'roleRef is missing or has an unsupported kind'); continue; }
455
+ const cluster = b.kind === 'ClusterRoleBinding';
456
+ const roleKey = refKind === 'ClusterRole' ? `ClusterRole:${refName}` : `Role:${ns(b)}/${refName}`;
457
+ const role = roles.get(roleKey);
458
+ const scope = cluster ? 'cluster' : `ns:${ns(b)}`;
459
+ for (const s of asArray(b.subjects)) {
460
+ let ident;
461
+ if (s?.kind === 'ServiceAccount' && str(s.name)) ident = kit.node('identity', `sa/${str(s.namespace) ?? ns(b)}/${s.name}`, { trustZone: 'internal' });
462
+ else if ((s?.kind === 'User' || s?.kind === 'Group') && str(s.name)) ident = kit.node('identity', `${s.kind.toLowerCase()}/${s.name}`, { trustZone: 'internal' });
463
+ else { kit.gap('malformed-input', `${b.kind} ${nm(b)}`, 'a subject without kind and name is skipped'); continue; }
464
+ if (!role) {
465
+ const target = kit.unresolved('resource', `k8s/${scope}/role:${refName}`, `${refKind} '${refName}' is not in the supplied manifests`, { trustZone: 'unknown' });
466
+ kit.gap('unresolved-identity', `${b.kind} ${nm(b)}`, `${refKind} '${refName}' is not in the supplied manifests, so the permissions it grants are unknown`);
467
+ kit.edge('grants', ident, target, { effect: 'allow', confidence: 'low', discriminator: `role:${refName}` });
468
+ continue;
469
+ }
470
+ for (const rule of asArray(role.rules)) {
471
+ const verbs = asArray(rule?.verbs).filter(v => typeof v === 'string').sort();
472
+ for (const res of asArray(rule?.resources).filter(r => typeof r === 'string')) {
473
+ const target = kit.node('resource', `k8s/${scope}/${res}`, { trustZone: 'internal', attrs: res === '*' ? { wildcard: true } : {} });
474
+ kit.edge('grants', ident, target, { effect: 'allow', confidence: 'high', discriminator: verbs.join(',') || 'none', attrs: { verbs: verbs.join(',') || 'none' } });
475
+ }
476
+ }
477
+ }
478
+ }
479
+
480
+ // Services and what they route to
481
+ const svcNodes = new Map();
482
+ const svcNode = (nsName, name) => {
483
+ const key = `${nsName}/${name}`;
484
+ if (svcNodes.has(key)) return svcNodes.get(key);
485
+ const obj = services.get(key);
486
+ let node;
487
+ if (obj) {
488
+ const type = str(obj.spec?.type) ?? 'ClusterIP';
489
+ const internalLb = obj.metadata?.annotations && Object.entries(obj.metadata.annotations).some(([k, v]) => /internal/i.test(k) && String(v) === 'true');
490
+ const zone = type === 'LoadBalancer' ? (internalLb ? 'internal' : 'public') : type === 'NodePort' ? 'edge' : 'internal';
491
+ node = kit.node('route', `svc/${key}`, { tenant: tenantOf(obj.metadata?.labels), trustZone: zone, attrs: { serviceType: type, namespace: nsName } });
492
+ if (type === 'ExternalName' && str(obj.spec?.externalName)) {
493
+ const ext = kit.unresolved('resource', `external/${obj.spec.externalName}`, 'ExternalName target is outside the supplied manifests', { trustZone: 'public' });
494
+ kit.edge('routes-to', node, ext, { confidence: 'low' });
495
+ kit.gap('unresolved-identity', `Service ${key}`, `ExternalName '${obj.spec.externalName}' is not resolved`);
496
+ } else {
497
+ const sel = obj.spec?.selector;
498
+ const matched = workloads.filter(w => w.namespace === nsName && selectorMatches(sel, w.labels));
499
+ for (const w of matched) kit.edge('routes-to', node, wNodes.get(wkey(w)), { confidence: 'medium' });
500
+ if (matched.length === 0) {
501
+ const miss = kit.unresolved('service', `${key}#selector`, 'the Service selector matches no workload in the supplied manifests', { trustZone: 'unknown' });
502
+ kit.edge('routes-to', node, miss, { confidence: 'low' });
503
+ kit.gap('unresolved-identity', `Service ${key}`, 'the selector matches no workload in the supplied manifests');
504
+ }
505
+ }
506
+ } else {
507
+ node = kit.unresolved('route', `svc/${key}`, 'the Service is not in the supplied manifests', { trustZone: 'unknown' });
508
+ kit.gap('unresolved-identity', `Service ${key}`, 'a route targets a Service that is not in the supplied manifests');
509
+ }
510
+ svcNodes.set(key, node);
511
+ return node;
512
+ };
513
+ for (const key of services.keys()) { const [n, ...rest] = key.split('/'); svcNode(n, rest.join('/')); }
514
+
515
+ // Ingress
516
+ for (const ing of objects.filter(o => o.kind === 'Ingress')) {
517
+ const cls = String(ing.spec?.ingressClassName ?? ing.metadata?.annotations?.['kubernetes.io/ingress.class'] ?? '');
518
+ const zone = /internal/i.test(cls) ? 'internal' : 'public';
519
+ const tls = asArray(ing.spec?.tls).length > 0;
520
+ const rules = asArray(ing.spec?.rules);
521
+ const targets = [];
522
+ for (const r of rules) {
523
+ for (const p of asArray(r?.http?.paths)) targets.push({ host: str(r.host) ?? '*', path: str(p.path) ?? '/', backend: str(p.backend?.service?.name) });
524
+ }
525
+ const def = str(ing.spec?.defaultBackend?.service?.name);
526
+ if (def) targets.push({ host: '*', path: '/', backend: def });
527
+ if (targets.length === 0) kit.gap('malformed-input', `Ingress ${ns(ing)}/${nm(ing)}`, 'the Ingress has no routable rule');
528
+ for (const t of targets) {
529
+ const route = kit.node('route', `ingress/${ns(ing)}/${nm(ing)}:${t.host}${t.path}`, { tenant: tenantOf(ing.metadata?.labels), trustZone: zone, attrs: { host: t.host, path: t.path, tls } });
530
+ if (!t.backend) { kit.gap('malformed-input', `Ingress ${ns(ing)}/${nm(ing)}`, 'a rule without a backend service is skipped'); continue; }
531
+ kit.edge('routes-to', route, svcNode(ns(ing), t.backend), { confidence: 'high' });
532
+ }
533
+ }
534
+
535
+ // Gateway API HTTPRoute
536
+ for (const hr of objects.filter(o => o.kind === 'HTTPRoute')) {
537
+ const hosts = asArray(hr.spec?.hostnames).filter(h => typeof h === 'string');
538
+ const gateways = asArray(hr.spec?.parentRefs).map(p => str(p?.name)).filter(Boolean).sort().join(',');
539
+ for (const rule of asArray(hr.spec?.rules)) {
540
+ const paths = asArray(rule?.matches).map(m => str(m?.path?.value) ?? '/');
541
+ const pathList = paths.length ? paths : ['/'];
542
+ for (const host of hosts.length ? hosts : ['*']) for (const path of pathList) {
543
+ const route = kit.node('route', `httproute/${ns(hr)}/${nm(hr)}:${host}${path}`, { tenant: tenantOf(hr.metadata?.labels), trustZone: 'public', attrs: { host, path, gateways: gateways || 'none' } });
544
+ for (const ref of asArray(rule?.backendRefs)) {
545
+ const b = str(ref?.name);
546
+ if (b) kit.edge('routes-to', route, svcNode(str(ref.namespace) ?? ns(hr), b), { confidence: 'medium' });
547
+ }
548
+ }
549
+ }
550
+ }
551
+
552
+ // NetworkPolicy allows
553
+ for (const p of policies) {
554
+ const selected = workloads.filter(w => w.namespace === ns(p) && podSelectorPicks(p.spec?.podSelector, w.labels));
555
+ for (const rule of asArray(p.spec?.ingress)) {
556
+ for (const from of asArray(rule?.from)) {
557
+ const sources = [];
558
+ if (isObject(from.podSelector)) {
559
+ const pods = workloads.filter(w => (from.namespaceSelector ? true : w.namespace === ns(p)) && podSelectorPicks(from.podSelector, w.labels) && Object.keys(from.podSelector).length > 0);
560
+ for (const w of pods) sources.push(wNodes.get(wkey(w)));
561
+ if (pods.length === 0) kit.gap('unresolved-identity', `NetworkPolicy ${ns(p)}/${nm(p)}`, 'a peer selector matches no workload in the supplied manifests');
562
+ } else if (isObject(from.ipBlock) && str(from.ipBlock.cidr)) {
563
+ const cidr = from.ipBlock.cidr;
564
+ sources.push(kit.node('route', `cidr/${cidr}`, { trustZone: /^0\.0\.0\.0\/0$|^::\/0$/.test(cidr) ? 'public' : 'edge', attrs: { cidr } }));
565
+ } else if (isObject(from.namespaceSelector)) {
566
+ kit.gap('unresolved-identity', `NetworkPolicy ${ns(p)}/${nm(p)}`, 'a namespace-only peer selector cannot be resolved to workloads from the supplied manifests');
567
+ }
568
+ for (const src of sources) for (const w of selected) {
569
+ if (src && src !== wNodes.get(wkey(w))) kit.edge('network-allows', src, wNodes.get(wkey(w)), { confidence: 'medium', discriminator: `policy:${nm(p)}` });
570
+ }
571
+ }
572
+ }
573
+ }
574
+ return kit.result();
575
+ }
576
+
577
+ ;// CONCATENATED MODULE: ./src/lineage/deployment/adapter-compose.js
578
+ // adapter-compose.js: container composition files into deployment boundary
579
+ // nodes and edges (X-302).
580
+ //
581
+ // A service is a node. A published port is an entry route into it, public
582
+ // unless it is bound to loopback. `depends_on` and `links` are declared
583
+ // dependencies: they are recorded as `depends-on`, never as `calls`, because a
584
+ // dependency is a startup ordering or a reachability hint and says nothing
585
+ // about a call actually being made. Environment variables, env files, secrets
586
+ // and build arguments are never read, so a credential in a compose file cannot
587
+ // reach the graph.
588
+
589
+
590
+
591
+ const COMPOSE_PARSER = 'compose-file';
592
+ const COMPOSE_PARSER_VERSION = '1';
593
+
594
+ function parsePort(p) {
595
+ if (typeof p === 'number') return { published: String(p), target: String(p), hostIp: null };
596
+ if (typeof p === 'string') {
597
+ let t = p.trim().replace(/\/(tcp|udp|sctp)$/i, '');
598
+ let hostIp = null;
599
+ const v6 = /^\[([^\]]+)\]:(.*)$/.exec(t);
600
+ if (v6) { hostIp = `[${v6[1]}]`; t = v6[2]; }
601
+ const parts = t.split(':');
602
+ const port = /^\d+(-\d+)?$/;
603
+ if (parts.some(x => !port.test(x) && !(parts.length === 3 && x === parts[0] && /^[0-9.]+$/.test(x)))) return null;
604
+ if (parts.length === 1) return { published: null, target: parts[0], hostIp };
605
+ if (parts.length === 2) return { published: parts[0], target: parts[1], hostIp };
606
+ if (parts.length === 3 && !hostIp) return { published: parts[1], target: parts[2], hostIp: parts[0] };
607
+ return null;
608
+ }
609
+ if (isObject(p)) {
610
+ const target = p.target != null ? String(p.target) : null;
611
+ if (!target) return null;
612
+ return { published: p.published != null ? String(p.published) : null, target, hostIp: str(p.host_ip) };
613
+ }
614
+ return null;
615
+ }
616
+
617
+ function parseCompose(text, fileCtx) {
618
+ const kit = adapterContext({ ...fileCtx, parser: COMPOSE_PARSER, parserVersion: COMPOSE_PARSER_VERSION });
619
+ const parsed = parseYamlDocuments(text, fileCtx.file);
620
+ if (parsed.gap) { kit.gaps.push(parsed.gap); return kit.result(); }
621
+ const doc = parsed.docs[0];
622
+ if (!isObject(doc) || !isObject(doc.services)) {
623
+ kit.gap('unsupported-format', fileCtx.file, 'no top-level services mapping; not a compose file');
624
+ return kit.result();
625
+ }
626
+ const declared = new Set(Object.keys(doc.services));
627
+ const nodes = new Map();
628
+ const nodeFor = (name) => nodes.get(name);
629
+ const networks = isObject(doc.networks) ? doc.networks : {};
630
+
631
+ for (const [name, svc] of Object.entries(doc.services)) {
632
+ if (!isObject(svc)) { kit.gap('malformed-input', name, 'a service that is not a mapping is skipped'); continue; }
633
+ if (Object.hasOwn(svc, '<<')) kit.gap('unsupported-syntax', name, "YAML merge key '<<' is not expanded; fields inherited through it are not read");
634
+ const tenant = isObject(svc.labels) && typeof svc.labels.tenant === 'string' && /^[A-Za-z0-9._-]{1,64}$/.test(svc.labels.tenant) ? svc.labels.tenant : null;
635
+ const attrs = {};
636
+ const nets = (Array.isArray(svc.networks) ? svc.networks : isObject(svc.networks) ? Object.keys(svc.networks) : []).filter(n => typeof n === 'string').sort();
637
+ if (nets.length) attrs.networks = nets.join(',');
638
+ if (nets.length && nets.every(n => isObject(networks[n]) && networks[n].internal === true)) attrs.networkInternal = true;
639
+ if (svc.network_mode === 'host') attrs.networkMode = 'host';
640
+ nodes.set(name, kit.node('service', name, { tenant, trustZone: 'internal', attrs }));
641
+ }
642
+
643
+ for (const [name, svc] of Object.entries(doc.services)) {
644
+ const node = nodeFor(name);
645
+ if (!node) continue;
646
+ for (const raw of asArray(svc.ports)) {
647
+ const port = parsePort(raw);
648
+ if (!port) { kit.gap('malformed-input', name, 'a port mapping that cannot be read is skipped'); continue; }
649
+ if (port.published === null) continue; // container port only, not published to the host
650
+ const loop = port.hostIp !== null && (/^127\./.test(port.hostIp) || port.hostIp === '[::1]');
651
+ const route = kit.node('route', `port/${name}:${port.published}`, { tenant: node.tenant, trustZone: loop ? 'internal' : 'public', attrs: { published: port.published, target: port.target, bind: port.hostIp ?? 'all-interfaces' } });
652
+ kit.edge('routes-to', route, node, { confidence: 'high' });
653
+ }
654
+ const deps = Array.isArray(svc.depends_on) ? svc.depends_on : isObject(svc.depends_on) ? Object.keys(svc.depends_on) : [];
655
+ const links = [...asArray(svc.links), ...asArray(svc.external_links)].map(l => (typeof l === 'string' ? l.split(':')[0] : null));
656
+ for (const d of [...deps, ...links]) {
657
+ if (typeof d !== 'string' || !d) continue;
658
+ if (declared.has(d) && nodeFor(d)) kit.edge('depends-on', node, nodeFor(d), { confidence: 'medium' });
659
+ else {
660
+ const miss = kit.unresolved('service', d, 'the dependency is not defined in this compose file', { trustZone: 'unknown' });
661
+ kit.edge('depends-on', node, miss, { confidence: 'low' });
662
+ kit.gap('unresolved-identity', name, `depends on '${d}', which this compose file does not define`);
663
+ }
664
+ }
665
+ }
666
+ return kit.result();
667
+ }
668
+
669
+ ;// CONCATENATED MODULE: ./src/lineage/deployment/adapter-cloud.js
670
+ // adapter-cloud.js: Terraform plan JSON and IAM policy documents into
671
+ // deployment boundary nodes and edges (X-302).
672
+ //
673
+ // Input is a plan that someone already produced (`terraform show -json`); this
674
+ // adapter never runs terraform, never evaluates HCL and never contacts a
675
+ // provider. Values the plan itself marks as unknown after apply are not
676
+ // guessed: the relation becomes an unresolved node plus a gap. Policy
677
+ // documents are read with JSON.parse; `NotAction` / `NotResource` statements
678
+ // and conditions it cannot interpret are reported, not approximated.
679
+
680
+
681
+
682
+ const TERRAFORM_PARSER = 'terraform-plan-json';
683
+ const TERRAFORM_PARSER_VERSION = '1';
684
+ const IAM_PARSER = 'iam-policy-json';
685
+ const IAM_PARSER_VERSION = '1';
686
+
687
+ const MAX_STATEMENTS = 200;
688
+ const MAX_RESOURCES_PER_STATEMENT = 50;
689
+
690
+ function toList(v) { return typeof v === 'string' ? [v] : Array.isArray(v) ? v.filter(x => typeof x === 'string') : []; }
691
+
692
+ /** Emit grants edges for every statement of a policy document. Returns the number of statements read. */
693
+ function applyPolicy(kit, identity, policy, subject) {
694
+ const statements = Array.isArray(policy?.Statement) ? policy.Statement : isObject(policy?.Statement) ? [policy.Statement] : null;
695
+ if (!statements) { kit.gap('malformed-input', subject, 'policy document has no Statement'); return 0; }
696
+ let read = 0;
697
+ for (const st of statements.slice(0, MAX_STATEMENTS)) {
698
+ if (!isObject(st)) { kit.gap('malformed-input', subject, 'a policy statement that is not an object is skipped'); continue; }
699
+ if (st.NotAction !== undefined || st.NotResource !== undefined || st.NotPrincipal !== undefined) {
700
+ kit.gap('unsupported-syntax', subject, 'NotAction, NotResource and NotPrincipal statements are not interpreted; the statement is skipped');
701
+ continue;
702
+ }
703
+ const effect = st.Effect === 'Allow' ? 'allow' : st.Effect === 'Deny' ? 'deny' : null;
704
+ if (!effect) { kit.gap('malformed-input', subject, `statement Effect must be Allow or Deny, got ${JSON.stringify(st.Effect)}`); continue; }
705
+ const actions = toList(st.Action).sort();
706
+ const resources = toList(st.Resource);
707
+ if (actions.length === 0 || resources.length === 0) { kit.gap('malformed-input', subject, 'a statement without Action and Resource is skipped'); continue; }
708
+ const conditioned = isObject(st.Condition) && Object.keys(st.Condition).length > 0;
709
+ read += 1;
710
+ if (resources.length > MAX_RESOURCES_PER_STATEMENT) kit.gap('limit-exceeded', subject, `a statement lists ${resources.length} resources; only the first ${MAX_RESOURCES_PER_STATEMENT} are read`);
711
+ for (const r of resources.slice(0, MAX_RESOURCES_PER_STATEMENT)) {
712
+ const target = kit.node('resource', `aws/${r}`, { trustZone: 'internal', attrs: r === '*' ? { wildcard: true } : {} });
713
+ const attrs = { actions: actions.join(',').slice(0, 250) };
714
+ if (conditioned) attrs.conditioned = true;
715
+ kit.edge('grants', identity, target, {
716
+ effect, confidence: conditioned ? 'low' : 'high', discriminator: `${actions.join(',')}${conditioned ? ';cond' : ''}`.slice(0, 250), attrs,
717
+ });
718
+ }
719
+ }
720
+ if (statements.length > MAX_STATEMENTS) kit.gap('limit-exceeded', subject, `policy has ${statements.length} statements; only the first ${MAX_STATEMENTS} are read`);
721
+ return read;
722
+ }
723
+
724
+ function parseIamPolicy(text, fileCtx) {
725
+ const kit = adapterContext({ ...fileCtx, parser: IAM_PARSER, parserVersion: IAM_PARSER_VERSION });
726
+ let doc;
727
+ try { doc = JSON.parse(text); } catch (e) {
728
+ kit.gap('malformed-input', fileCtx.file, `not valid JSON (${String(e.message).split('\n')[0]})`);
729
+ return kit.result();
730
+ }
731
+ const identityName = str(fileCtx.identity);
732
+ let identity;
733
+ if (identityName) identity = kit.node('identity', `iam/${identityName}`, { trustZone: 'internal' });
734
+ else {
735
+ identity = kit.unresolved('identity', `iam/policy:${fileCtx.file}`, 'the policy document is not attached to a named identity', { trustZone: 'unknown' });
736
+ kit.gap('unresolved-identity', fileCtx.file, 'the policy document is not attached to a named identity; supply the identity it belongs to');
737
+ }
738
+ applyPolicy(kit, identity, doc, fileCtx.file);
739
+ return kit.result();
740
+ }
741
+
742
+ function roleNameFromArn(arn) {
743
+ const m = /^arn:[^:]*:iam::[^:]*:role\/(?:.*\/)?([^/]+)$/.exec(arn ?? '');
744
+ return m ? m[1] : null;
745
+ }
746
+
747
+ function parseTerraformPlan(text, fileCtx) {
748
+ const kit = adapterContext({ ...fileCtx, parser: TERRAFORM_PARSER, parserVersion: TERRAFORM_PARSER_VERSION });
749
+ let plan;
750
+ try { plan = JSON.parse(text); } catch (e) {
751
+ kit.gap('malformed-input', fileCtx.file, `not valid JSON (${String(e.message).split('\n')[0]})`);
752
+ return kit.result();
753
+ }
754
+ if (!isObject(plan) || !str(plan.format_version) || !Array.isArray(plan.resource_changes)) {
755
+ kit.gap('unsupported-format', fileCtx.file, 'not a terraform plan in JSON form (needs format_version and resource_changes)');
756
+ return kit.result();
757
+ }
758
+ const resources = new Map();
759
+ for (const rc of plan.resource_changes) {
760
+ if (!isObject(rc) || !str(rc.address) || !isObject(rc.change)) continue;
761
+ const actions = asArray(rc.change.actions);
762
+ if (actions.includes('delete') && !actions.includes('create')) continue;
763
+ resources.set(rc.address, { type: rc.type, name: rc.name, after: isObject(rc.change.after) ? rc.change.after : {}, unknown: isObject(rc.change.after_unknown) ? rc.change.after_unknown : {} });
764
+ }
765
+ const config = new Map();
766
+ for (const r of asArray(plan.configuration?.root_module?.resources)) {
767
+ if (isObject(r) && str(r.address)) config.set(r.address, isObject(r.expressions) ? r.expressions : {});
768
+ }
769
+ const refTarget = (address, field, wantType) => {
770
+ const refs = asArray(config.get(address)?.[field]?.references);
771
+ for (const ref of refs) {
772
+ const m = /^([a-z0-9_]+\.[A-Za-z0-9_-]+)(?:\.|$)/.exec(ref);
773
+ if (m && resources.get(m[1])?.type === wantType) return m[1];
774
+ }
775
+ return null;
776
+ };
777
+
778
+ const identityNodes = new Map();
779
+ const roleNode = (address) => {
780
+ if (identityNodes.has(address)) return identityNodes.get(address);
781
+ const r = resources.get(address);
782
+ const name = str(r?.after?.name);
783
+ let node;
784
+ if (name && r.unknown.name !== true) node = kit.node('identity', `iam-role/${name}`, { trustZone: 'internal' });
785
+ else {
786
+ node = kit.unresolved('identity', `iam-role/${address}`, 'the role name is only known after apply', { trustZone: 'unknown' });
787
+ kit.gap('unresolved-identity', address, 'the role name is only known after apply');
788
+ }
789
+ identityNodes.set(address, node);
790
+ return node;
791
+ };
792
+ const roleByArn = (arn, subject) => {
793
+ const n = roleNameFromArn(arn);
794
+ if (!n) { kit.gap('unresolved-identity', subject, 'the role reference is not a literal role ARN and has no resolvable reference'); return kit.unresolved('identity', `iam-role/${subject}`, 'unresolved role reference', { trustZone: 'unknown' }); }
795
+ return kit.unresolved('identity', `iam-role/${n}`, 'the role is not defined in this plan', { trustZone: 'unknown' });
796
+ };
797
+
798
+ const handled = new Set(['aws_iam_role', 'aws_iam_policy', 'aws_iam_role_policy', 'aws_iam_role_policy_attachment', 'aws_lambda_function', 'aws_s3_bucket', 'aws_security_group']);
799
+ const unsupportedTypes = new Set();
800
+ const policies = new Map();
801
+ for (const [address, r] of resources) {
802
+ if (r.type === 'aws_iam_policy' || r.type === 'aws_iam_role_policy') {
803
+ let doc = null;
804
+ if (typeof r.after.policy === 'string' && r.unknown.policy !== true) { try { doc = JSON.parse(r.after.policy); } catch { doc = null; } }
805
+ policies.set(address, doc);
806
+ }
807
+ if (!handled.has(r.type)) unsupportedTypes.add(r.type);
808
+ }
809
+
810
+ for (const [address, r] of resources) {
811
+ switch (r.type) {
812
+ case 'aws_iam_role': roleNode(address); break;
813
+ case 'aws_iam_role_policy': {
814
+ const roleAddr = refTarget(address, 'role', 'aws_iam_role');
815
+ const ident = roleAddr ? roleNode(roleAddr) : (str(r.after.role) && r.unknown.role !== true ? kit.node('identity', `iam-role/${r.after.role}`, { trustZone: 'internal' }) : kit.unresolved('identity', `iam-role/${address}`, 'the role is only known after apply', { trustZone: 'unknown' }));
816
+ const doc = policies.get(address);
817
+ if (!doc) kit.gap('unresolved-identity', address, 'the policy document is only known after apply or is not valid JSON; its grants are unknown');
818
+ else applyPolicy(kit, ident, doc, address);
819
+ break;
820
+ }
821
+ case 'aws_iam_role_policy_attachment': {
822
+ const roleAddr = refTarget(address, 'role', 'aws_iam_role');
823
+ const polAddr = refTarget(address, 'policy_arn', 'aws_iam_policy');
824
+ const ident = roleAddr ? roleNode(roleAddr) : (str(r.after.role) && r.unknown.role !== true ? kit.node('identity', `iam-role/${r.after.role}`, { trustZone: 'internal' }) : kit.unresolved('identity', `iam-role/${address}`, 'the role is only known after apply', { trustZone: 'unknown' }));
825
+ if (!polAddr) {
826
+ kit.gap('unresolved-identity', address, `attached policy ${typeof r.after.policy_arn === 'string' ? r.after.policy_arn : '(known after apply)'} is not defined in this plan, so what it grants is unknown`);
827
+ const target = kit.unresolved('resource', `aws/policy:${typeof r.after.policy_arn === 'string' ? r.after.policy_arn : address}`, 'the attached policy is not defined in this plan', { trustZone: 'unknown' });
828
+ kit.edge('grants', ident, target, { effect: 'allow', confidence: 'low', discriminator: 'attached-policy' });
829
+ } else if (!policies.get(polAddr)) {
830
+ kit.gap('unresolved-identity', polAddr, 'the policy document is only known after apply or is not valid JSON; its grants are unknown');
831
+ } else applyPolicy(kit, ident, policies.get(polAddr), address);
832
+ break;
833
+ }
834
+ case 'aws_lambda_function': {
835
+ const name = str(r.after.function_name);
836
+ const node = name && r.unknown.function_name !== true
837
+ ? kit.node('service', `lambda/${name}`, { trustZone: 'internal' })
838
+ : kit.unresolved('service', `lambda/${address}`, 'the function name is only known after apply', { trustZone: 'unknown' });
839
+ const roleAddr = refTarget(address, 'role', 'aws_iam_role');
840
+ const ident = roleAddr ? roleNode(roleAddr) : (typeof r.after.role === 'string' ? roleByArn(r.after.role, address) : roleByArn(null, address));
841
+ kit.edge('assumes', node, ident, { confidence: roleAddr ? 'high' : 'low' });
842
+ break;
843
+ }
844
+ case 'aws_s3_bucket': {
845
+ const b = str(r.after.bucket);
846
+ if (b && r.unknown.bucket !== true) kit.node('resource', `s3/${b}`, { trustZone: r.after.acl === 'public-read' || r.after.acl === 'public-read-write' ? 'public' : 'internal', attrs: { acl: typeof r.after.acl === 'string' ? r.after.acl : 'unspecified' } });
847
+ else kit.unresolved('resource', `s3/${address}`, 'the bucket name is only known after apply', { trustZone: 'unknown' });
848
+ break;
849
+ }
850
+ case 'aws_security_group': {
851
+ const name = str(r.after.name) ?? address;
852
+ const sg = kit.node('resource', `sg/${name}`, { trustZone: 'internal' });
853
+ for (const rule of asArray(r.after.ingress)) {
854
+ const cidrs = asArray(rule?.cidr_blocks).filter(c => typeof c === 'string');
855
+ const ports = `${rule?.protocol ?? '-1'}:${rule?.from_port ?? 0}-${rule?.to_port ?? 0}`;
856
+ for (const cidr of cidrs) {
857
+ const src = kit.node('route', `cidr/${cidr}`, { trustZone: cidr === '0.0.0.0/0' ? 'public' : 'edge', attrs: { cidr } });
858
+ kit.edge('network-allows', src, sg, { confidence: 'high', discriminator: ports, attrs: { ports } });
859
+ }
860
+ }
861
+ break;
862
+ }
863
+ default: break;
864
+ }
865
+ }
866
+ if (unsupportedTypes.size) {
867
+ const list = [...unsupportedTypes].sort();
868
+ kit.gap('unsupported-format', fileCtx.file, `resource types not interpreted by this adapter: ${list.slice(0, 10).join(', ')}${list.length > 10 ? `, and ${list.length - 10} more` : ''}`);
869
+ }
870
+ return kit.result();
871
+ }
872
+
873
+ ;// CONCATENATED MODULE: ./src/lineage/deployment/ingest.js
874
+ // ingest.js: turn customer-supplied deployment files into a boundary graph
875
+ // (X-302), and invalidate it when a source file changes.
876
+ //
877
+ // What this module will and will not do:
878
+ // - It reads only files the caller names, under one root, as bounded UTF-8
879
+ // text. It follows no symlink out of the root, opens no socket, spawns no
880
+ // process, reads no credential and calls no cloud API. The pure entry point
881
+ // (`ingestDeploymentConfig`) takes text and cannot touch the file system at
882
+ // all; the file entry point (`ingestDeploymentFiles`) is gated by the
883
+ // `deployment-boundaries` feature so it is inert unless an operator turns
884
+ // it on.
885
+ // - It does not execute configuration. Terraform HCL, nginx style
886
+ // configuration and other languages are reported as typed unsupported
887
+ // syntax, with a hint where a supported equivalent exists, and contribute
888
+ // no edges.
889
+ // - An adapter that throws on hostile input is contained: the file becomes a
890
+ // typed gap and the other files are still ingested.
891
+ //
892
+ // Every edge an adapter emits carries the file, the sha256 of the exact text
893
+ // parsed, the parser name and the parser version. `invalidateChangedSources`
894
+ // uses those digests to drop what a changed or removed file used to support,
895
+ // so a stale relation does not outlive its evidence.
896
+
897
+
898
+
899
+
900
+
901
+
902
+
903
+
904
+
905
+
906
+
907
+ const DEPLOYMENT_FEATURE = 'deployment-boundaries';
908
+ const MAX_INGEST_FILES = 200;
909
+
910
+ const ADAPTERS = Object.freeze({
911
+ kubernetes: { parse: parseKubernetes, parser: KUBERNETES_PARSER, version: KUBERNETES_PARSER_VERSION },
912
+ compose: { parse: parseCompose, parser: COMPOSE_PARSER, version: COMPOSE_PARSER_VERSION },
913
+ 'terraform-plan': { parse: parseTerraformPlan, parser: TERRAFORM_PARSER, version: TERRAFORM_PARSER_VERSION },
914
+ 'iam-policy': { parse: parseIamPolicy, parser: IAM_PARSER, version: IAM_PARSER_VERSION },
915
+ });
916
+ const ADAPTER_NAMES = Object.freeze(Object.keys(ADAPTERS));
917
+
918
+ const UNSUPPORTED_SYNTAX = {
919
+ '.tf': 'Terraform HCL is not interpreted (it would need evaluation); supply the JSON from `terraform show -json <plan>`',
920
+ '.hcl': 'HCL is not interpreted; supply a supported JSON or YAML form',
921
+ '.tfvars': 'Terraform variable files carry values, not topology, and are not read',
922
+ '.conf': 'proxy and server configuration syntax is not supported by any adapter',
923
+ '.toml': 'TOML configuration is not supported by any adapter',
924
+ '.xml': 'XML configuration is not supported by any adapter',
925
+ };
926
+
927
+ /** Decide which adapter reads a file, or why none does. Pure. */
928
+ function detectFormat(file, text) {
929
+ const ext = external_node_path_.extname(file).toLowerCase();
930
+ if (UNSUPPORTED_SYNTAX[ext]) return { adapter: null, code: 'unsupported-syntax', reason: UNSUPPORTED_SYNTAX[ext] };
931
+ if (ext === '.json') {
932
+ let doc;
933
+ try { doc = JSON.parse(text); } catch (e) { return { adapter: null, code: 'malformed-input', reason: `not valid JSON (${String(e.message).split('\n')[0]})` }; }
934
+ if (doc && typeof doc === 'object' && !Array.isArray(doc)) {
935
+ if (typeof doc.format_version === 'string' && Array.isArray(doc.resource_changes)) return { adapter: 'terraform-plan' };
936
+ if (Object.hasOwn(doc, 'Statement')) return { adapter: 'iam-policy' };
937
+ }
938
+ return { adapter: null, code: 'unsupported-format', reason: 'JSON that is neither a terraform plan nor an IAM policy document' };
939
+ }
940
+ if (ext === '.yaml' || ext === '.yml') {
941
+ if (/^services:\s*(#.*)?$/m.test(text)) return { adapter: 'compose' };
942
+ if (/^apiVersion:\s*\S/m.test(text) && /^kind:\s*\S/m.test(text)) return { adapter: 'kubernetes' };
943
+ return { adapter: null, code: 'unsupported-format', reason: 'YAML that is neither a compose file nor a Kubernetes manifest' };
944
+ }
945
+ return { adapter: null, code: 'unsupported-format', reason: `no adapter reads '${ext || 'files without an extension'}'` };
946
+ }
947
+
948
+ const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
949
+ const REV = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
950
+
951
+ /**
952
+ * Ingest already-read files. Pure: no file system, no network, no clock.
953
+ *
954
+ * @param {object} p
955
+ * @param {Array<{path: string, text: string}>} p.files
956
+ * @param {string} p.environment the deployment environment these files describe
957
+ * @param {string|null} [p.repository]
958
+ * @param {string|null} [p.revision] exact commit these files were read at, when known
959
+ * @param {Record<string,string>} [p.identities] file path -> identity a policy document belongs to
960
+ * @returns {{status: 'ok'|'invalid-input'|'invalid-graph', graph: object|null, files: object[], errors: object[]}}
961
+ */
962
+ function ingestDeploymentConfig({ files, environment, repository = null, revision = null, identities = {} } = {}) {
963
+ if (typeof environment !== 'string' || !NAME.test(environment)) return { status: 'invalid-input', graph: null, files: [], errors: [{ code: 'BAD_TYPE', path: 'environment', message: 'environment must be a name such as prod or staging' }] };
964
+ if (repository !== null && (typeof repository !== 'string' || !NAME.test(repository))) return { status: 'invalid-input', graph: null, files: [], errors: [{ code: 'BAD_TYPE', path: 'repository', message: 'repository must be null or a name' }] };
965
+ if (revision !== null && !(typeof revision === 'string' && REV.test(revision))) return { status: 'invalid-input', graph: null, files: [], errors: [{ code: 'BAD_TYPE', path: 'revision', message: 'revision must be null or an exact 40/64 hex commit' }] };
966
+ if (!Array.isArray(files)) return { status: 'invalid-input', graph: null, files: [], errors: [{ code: 'BAD_TYPE', path: 'files', message: 'files must be an array' }] };
967
+
968
+ const nodes = [], edges = [], gaps = [], sources = [], report = [];
969
+ const sorted = [...files].filter(f => f && typeof f.path === 'string' && typeof f.text === 'string').sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
970
+ if (files.length > MAX_INGEST_FILES) gaps.push({ code: 'limit-exceeded', subject: 'files', file: null, message: `${files.length} files supplied; only the first ${MAX_INGEST_FILES} are read` });
971
+ for (const f of sorted.slice(0, MAX_INGEST_FILES)) {
972
+ const bytes = Buffer.from(f.text, 'utf8');
973
+ const digest = (0,identity/* digestOfBytes */.by)(bytes);
974
+ const fmt = detectFormat(f.path, f.text);
975
+ if (!fmt.adapter) {
976
+ gaps.push({ code: fmt.code, subject: f.path, file: f.path, message: fmt.reason });
977
+ report.push({ file: f.path, adapter: null, status: fmt.code });
978
+ continue;
979
+ }
980
+ const a = ADAPTERS[fmt.adapter];
981
+ let out;
982
+ try {
983
+ out = a.parse(f.text, { file: f.path, digest, environment, repository, revision, identity: identities[f.path] ?? null });
984
+ } catch (e) {
985
+ gaps.push({ code: 'malformed-input', subject: f.path, file: f.path, message: `the ${fmt.adapter} adapter could not read this file (${String(e?.message ?? e).split('\n')[0]})` });
986
+ report.push({ file: f.path, adapter: fmt.adapter, status: 'malformed-input' });
987
+ continue;
988
+ }
989
+ nodes.push(...out.nodes); edges.push(...out.edges); gaps.push(...out.gaps);
990
+ sources.push({ file: f.path, digest, parser: a.parser, parserVersion: a.version, bytes: bytes.length });
991
+ report.push({ file: f.path, adapter: fmt.adapter, status: out.nodes.length || out.edges.length ? 'ingested' : (out.gaps[0]?.code ?? 'empty') });
992
+ }
993
+ const built = (0,boundary_graph/* buildBoundaryGraph */.v4)({ repository, revision, nodes, edges, gaps, sources });
994
+ if (!built.ok) return { status: 'invalid-graph', graph: null, files: report, errors: built.errors };
995
+ return { status: 'ok', graph: built.graph, files: report, errors: [] };
996
+ }
997
+
998
+ // ------------------------------------------------------------ file entry point
999
+
1000
+ /** Resolve `rel` under `root`, refusing absolute paths, traversal and symlinks. Returns the real path or null. */
1001
+ function resolveUnderRoot(root, rel) {
1002
+ if (typeof rel !== 'string' || rel === '' || external_node_path_.isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) return null;
1003
+ let realRoot;
1004
+ try { realRoot = external_node_fs_.realpathSync(root); } catch { return null; }
1005
+ const full = external_node_path_.join(realRoot, rel);
1006
+ let st;
1007
+ try { st = external_node_fs_.lstatSync(full); } catch { return null; }
1008
+ if (st.isSymbolicLink() || !st.isFile()) return null;
1009
+ let real;
1010
+ try { real = external_node_fs_.realpathSync(full); } catch { return null; }
1011
+ return real === full && (real === realRoot || real.startsWith(realRoot + external_node_path_.sep)) ? real : null;
1012
+ }
1013
+
1014
+ /**
1015
+ * Read named files under `root` and ingest them. Gated by the
1016
+ * `deployment-boundaries` feature (off by default; the environment, an explicit
1017
+ * option or the operator's config turn it on). Returns a typed result and never
1018
+ * throws.
1019
+ *
1020
+ * @param {object} p
1021
+ * @param {object} p.config resolveAssuranceConfig() result
1022
+ * @param {string} p.root
1023
+ * @param {string[]} p.paths relative paths under root
1024
+ */
1025
+ function ingestDeploymentFiles({ config, root, paths, environment, repository = null, revision = null, identities = {} } = {}) {
1026
+ const gate = (0,assurance_config/* featureStatus */.FX)(config, DEPLOYMENT_FEATURE);
1027
+ if (gate.status !== 'ok') return gate;
1028
+ if (!Array.isArray(paths)) return (0,assurance_config/* typed */.nE)('blocked', 'invalid-config', 'paths must be an array', { feature: DEPLOYMENT_FEATURE });
1029
+ const maxBytes = (0,assurance_config/* limitValues */.OH)(config).maxFileBytes;
1030
+ const files = [];
1031
+ const readGaps = [];
1032
+ for (const rel of paths.slice(0, MAX_INGEST_FILES)) {
1033
+ const real = resolveUnderRoot(root, rel);
1034
+ if (!real) { readGaps.push({ code: 'malformed-input', subject: String(rel), file: null, message: 'path is outside the root, is a symlink, or is not a regular file; it is not read' }); continue; }
1035
+ const r = readFileBounded(real, maxBytes);
1036
+ if (r.status !== 'ok') { readGaps.push({ code: r.code === 'limit-exceeded' ? 'limit-exceeded' : 'malformed-input', subject: String(rel), file: String(rel), message: r.reason }); continue; }
1037
+ files.push({ path: rel.split(external_node_path_.sep).join('/'), text: r.text });
1038
+ }
1039
+ const out = ingestDeploymentConfig({ files, environment, repository, revision, identities });
1040
+ if (out.status !== 'ok') return (0,assurance_config/* typed */.nE)('blocked', 'invalid-config', out.errors.map(e => e.message).join('; ') || 'invalid ingest input', { feature: DEPLOYMENT_FEATURE, ...out });
1041
+ if (readGaps.length) {
1042
+ // Re-build with the read gaps included so the graph tells the whole story.
1043
+ const again = (0,boundary_graph/* buildBoundaryGraph */.v4)({ ...out.graph, repository, revision, gaps: [...out.graph.gaps, ...readGaps] });
1044
+ if (again.ok) out.graph = again.graph;
1045
+ }
1046
+ return (0,assurance_config/* typed */.nE)('ok', null, 'ingested', { feature: DEPLOYMENT_FEATURE, ...out });
1047
+ }
1048
+
1049
+ // ------------------------------------------------------------ invalidation
1050
+
1051
+ /**
1052
+ * Drop what changed or removed source files used to support (X-302.AC03).
1053
+ *
1054
+ * `currentDigests` maps file -> the sha256 of the file as it is NOW (a missing
1055
+ * entry means the file is gone). Static edges sourced from a file whose digest
1056
+ * differs are removed; a node that only a stale file declared stops being
1057
+ * resolved; nodes nothing references any more are dropped; a gap records each
1058
+ * invalidated file. Runtime and inferred edges are untouched because they do
1059
+ * not claim a file as evidence. Returns a rebuilt, validated graph.
1060
+ */
1061
+ function invalidateChangedSources(graph, currentDigests) {
1062
+ const cur = currentDigests instanceof Map ? currentDigests : new Map(Object.entries(currentDigests ?? {}));
1063
+ const stale = new Map(); // file -> 'changed' | 'missing'
1064
+ for (const s of graph.sources) {
1065
+ const now = cur.get(s.file);
1066
+ if (now === undefined) stale.set(s.file, 'missing');
1067
+ else if (now !== s.digest) stale.set(s.file, 'changed');
1068
+ }
1069
+ if (stale.size === 0) return { ok: true, graph, invalidatedEdgeIds: [], staleFiles: [], errors: [] };
1070
+
1071
+ const isStale = (ref) => ref && stale.has(ref.file) && cur.get(ref.file) !== ref.digest;
1072
+ const keptEdges = [], invalidated = [];
1073
+ for (const e of graph.edges) {
1074
+ if (e.source && isStale(e.source)) invalidated.push(e.id); else keptEdges.push(e);
1075
+ }
1076
+ const referenced = new Set();
1077
+ for (const e of keptEdges) { referenced.add(e.from); referenced.add(e.to); }
1078
+ const nodes = [];
1079
+ for (const n of graph.nodes) {
1080
+ const declaredBy = n.declaredBy.filter(d => !isStale(d));
1081
+ const lostDeclaration = declaredBy.length < n.declaredBy.length;
1082
+ if (declaredBy.length === 0 && !referenced.has(n.id)) continue; // nothing supports it any more
1083
+ if (lostDeclaration && declaredBy.length === 0) nodes.push({ ...n, declaredBy, resolved: false, unresolvedReason: 'the file that declared it changed or was removed', repository: null });
1084
+ else nodes.push({ ...n, declaredBy });
1085
+ }
1086
+ const gaps = [...graph.gaps];
1087
+ for (const [file, why] of [...stale].sort()) {
1088
+ gaps.push({ code: why === 'missing' ? 'source-missing' : 'source-changed', subject: file, file, message: `${file} ${why === 'missing' ? 'is no longer present' : 'has changed since it was ingested'}; the relations it supported were dropped and must be re-ingested` });
1089
+ }
1090
+ const sources = graph.sources.filter(s => !stale.has(s.file));
1091
+ const built = buildBoundaryGraph({ repository: graph.repository, revision: graph.revision, nodes, edges: keptEdges, gaps, sources });
1092
+ if (!built.ok) return { ok: false, graph: null, invalidatedEdgeIds: invalidated, staleFiles: [...stale.keys()].sort(), errors: built.errors };
1093
+ return { ok: true, graph: built.graph, invalidatedEdgeIds: invalidated.sort(), staleFiles: [...stale.keys()].sort(), errors: [] };
1094
+ }
1095
+
1096
+ // EXTERNAL MODULE: ./src/lineage/runtime-observation.js
1097
+ var runtime_observation = __webpack_require__(61995);
1098
+ ;// CONCATENATED MODULE: ./src/lineage/deployment/trace-correlation.js
1099
+ // trace-correlation.js: sanitize runtime traces and correlate them with the
1100
+ // static deployment boundary graph (X-303).
1101
+ //
1102
+ // The import side:
1103
+ // - A trace line is a JSON object. Only an allowlist of topology metadata is
1104
+ // kept (timestamp, environment, tenant, source and destination service,
1105
+ // host and port, request method, route host and path, outcome, sampled
1106
+ // flag, repeat count). Every other field is removed, never stored, and only
1107
+ // counted, split into credential-shaped, payload-shaped and other. A field
1108
+ // name is never kept, so a header or a body cannot reach the artifact even
1109
+ // as a key.
1110
+ // - Values that are kept are held to an identifier grammar. A request path
1111
+ // loses its query string and fragment, and any segment that looks like a
1112
+ // number, a uuid, a hash, a token or a long opaque string becomes `:id`.
1113
+ // A record whose kept value is not identifier-shaped is rejected whole,
1114
+ // never partially kept.
1115
+ // - A record for another environment is not relabelled and not stored: the
1116
+ // import is for one environment and counts the rest as rejected. A record
1117
+ // with no environment cannot be placed and is rejected too.
1118
+ // - Storage is bounded: input bytes, line length, line count, distinct
1119
+ // observations. Duplicates (same normalized key) merge into one observation
1120
+ // with a summed count and a widened interval. Past the observation cap the
1121
+ // oldest are evicted and the eviction is reported.
1122
+ //
1123
+ // The correlation side matches observations to graph nodes by explicit,
1124
+ // numbered rules (R1 exact service pair, R2 route, R3 identity, R4 naming
1125
+ // convention). R1-R3 produce `runtime-observation` edges; R4 produces an
1126
+ // `inferred` edge because a naming convention is a guess about identity, not
1127
+ // an observation of it. A static edge is never edited: observed and inferred
1128
+ // edges are separate edges, so the three kinds of evidence stay separate.
1129
+ //
1130
+ // What coverage means here: an observed edge is evidence that the path
1131
+ // carried traffic during the window. It is never evidence that the window held
1132
+ // all traffic. `trafficCoverage` is therefore always 'not-established'; sampled,
1133
+ // stale and missing observations are listed separately; and a static edge with
1134
+ // no matching observation stays `possible`, never `blocked` (absence in a
1135
+ // sampled or unknown-completeness trace cannot establish a block).
1136
+
1137
+
1138
+
1139
+
1140
+
1141
+ const OBSERVATION_SET_SCHEMA = 'agentic-security/deployment-observations';
1142
+ const OBSERVATION_SET_VERSION = '1.0.0';
1143
+
1144
+ const TRACE_LIMITS = Object.freeze({
1145
+ maxInputBytes: 4 * 1024 * 1024,
1146
+ maxLineBytes: 8 * 1024,
1147
+ maxLines: 50_000,
1148
+ maxObservations: 2_000,
1149
+ maxCount: 1_000_000_000,
1150
+ });
1151
+ const DEFAULT_STALE_AFTER_MS = 7 * 24 * 3600 * 1000;
1152
+
1153
+ const CORRELATION_RULES = Object.freeze({
1154
+ R1: 'exact service pair: source and destination service names match service nodes of the same environment and tenant',
1155
+ R2: 'route: request host and path fall under a route node reached from the destination service',
1156
+ R3: 'identity: the source identity matches the identity the source service is configured to assume',
1157
+ R4: 'naming convention: a DNS style host or an unqualified service name resolves to exactly one service (inferred, never observed)',
1158
+ });
1159
+
1160
+ const OUTCOMES = ['ok', 'denied', 'error'];
1161
+ const METHODS = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'];
1162
+ const TOP_KEYS = ['ts', 'environment', 'tenant', 'source', 'destination', 'route', 'outcome', 'sampled', 'count'];
1163
+ const SOURCE_KEYS = ['service', 'identity', 'tenant'];
1164
+ const DEST_KEYS = ['service', 'host', 'port', 'tenant'];
1165
+ const ROUTE_KEYS = ['method', 'host', 'path'];
1166
+
1167
+ const SVC = /^[A-Za-z0-9][A-Za-z0-9._\/-]{0,127}$/;
1168
+ const HOST = /^[A-Za-z0-9*]([A-Za-z0-9._-]{0,251}[A-Za-z0-9])?$/;
1169
+ const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
1170
+ const ENV = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
1171
+
1172
+ const CREDENTIAL_KEY = /auth|token|secret|passw|cookie|bearer|credential|session|api[-_]?key|signature|jwt/i;
1173
+ const PAYLOAD_KEY = /body|payload|request|response|header|query|param|message|data|content|text|input|output|record|prompt/i;
1174
+
1175
+ function emptySanitization() {
1176
+ return { recordsWithRemovedFields: 0, removedFieldCount: 0, credentialShaped: 0, payloadShaped: 0, other: 0 };
1177
+ }
1178
+
1179
+ function classifyRemoved(key, s) {
1180
+ s.removedFieldCount += 1;
1181
+ if (CREDENTIAL_KEY.test(key)) s.credentialShaped += 1;
1182
+ else if (PAYLOAD_KEY.test(key)) s.payloadShaped += 1;
1183
+ else s.other += 1;
1184
+ }
1185
+
1186
+ /** Strip a path to its route shape: no query, no fragment, opaque segments become ':id'. Returns null if nothing usable remains. */
1187
+ function normalizePath(raw) {
1188
+ if (typeof raw !== 'string') return null;
1189
+ const cut = raw.split(/[?#]/)[0];
1190
+ if (!cut.startsWith('/')) return null;
1191
+ const segs = cut.split('/').slice(1, 13);
1192
+ // Keep only plain words and versions; numbers, ids, hashes and tokens all carry digits or length.
1193
+ const out = segs.map((seg) => {
1194
+ if (seg === '') return '';
1195
+ if (/^[A-Za-z]+([_-][A-Za-z]+)*$/.test(seg) && seg.length <= 32) return seg;
1196
+ if (/^v\d{1,3}$/.test(seg)) return seg;
1197
+ return ':id';
1198
+ });
1199
+ const p = out.join('/');
1200
+ const rebuilt = `/${p.startsWith('/') ? p.slice(1) : p}`.replace(/\/{2,}/g, '/');
1201
+ return rebuilt.length <= 200 ? rebuilt : null;
1202
+ }
1203
+
1204
+ function sanitizeObject(obj, allowed, stats) {
1205
+ const kept = {};
1206
+ let removed = 0;
1207
+ for (const k of Object.keys(obj)) {
1208
+ if (allowed.includes(k)) kept[k] = obj[k]; else { classifyRemoved(k, stats); removed += 1; }
1209
+ }
1210
+ return { kept, removed };
1211
+ }
1212
+
1213
+ /** Normalize one parsed line. Returns `{ obs }` or `{ reject }`. */
1214
+ function normalizeRecord(rec, stats) {
1215
+ if (rec === null || typeof rec !== 'object' || Array.isArray(rec)) return { reject: 'not-an-object' };
1216
+ let removedTotal = 0;
1217
+ const top = sanitizeObject(rec, TOP_KEYS, stats); removedTotal += top.removed;
1218
+ const r = top.kept;
1219
+ const sub = (v, keys) => { if (v === undefined) return {}; if (v === null || typeof v !== 'object' || Array.isArray(v)) return null; const s = sanitizeObject(v, keys, stats); removedTotal += s.removed; return s.kept; };
1220
+ const src = sub(r.source, SOURCE_KEYS), dst = sub(r.destination, DEST_KEYS), rt = sub(r.route, ROUTE_KEYS);
1221
+ if (removedTotal > 0) stats.recordsWithRemovedFields += 1;
1222
+ if (src === null || dst === null || rt === null) return { reject: 'bad-structure' };
1223
+
1224
+ if (typeof r.ts !== 'string' || !ISO.test(r.ts) || !Number.isFinite(Date.parse(r.ts))) return { reject: 'bad-timestamp' };
1225
+ if (typeof r.environment !== 'string' || !ENV.test(r.environment)) return { reject: 'missing-environment' };
1226
+ if (r.tenant !== undefined && r.tenant !== null && (typeof r.tenant !== 'string' || !ENV.test(r.tenant))) return { reject: 'bad-tenant' };
1227
+ for (const t of [src.tenant, dst.tenant]) if (t !== undefined && t !== null && (typeof t !== 'string' || !ENV.test(t))) return { reject: 'bad-tenant' };
1228
+ if (src.service !== undefined && (typeof src.service !== 'string' || !SVC.test(src.service))) return { reject: 'bad-source-service' };
1229
+ if (src.identity !== undefined && (typeof src.identity !== 'string' || !SVC.test(src.identity))) return { reject: 'bad-identity' };
1230
+ if (dst.service !== undefined && (typeof dst.service !== 'string' || !SVC.test(dst.service))) return { reject: 'bad-destination-service' };
1231
+ if (dst.host !== undefined && (typeof dst.host !== 'string' || !HOST.test(dst.host))) return { reject: 'bad-destination-host' };
1232
+ if (dst.port !== undefined && !(Number.isInteger(dst.port) && dst.port >= 1 && dst.port <= 65535)) return { reject: 'bad-destination-port' };
1233
+ if (rt.method !== undefined && !METHODS.includes(rt.method)) return { reject: 'bad-method' };
1234
+ if (rt.host !== undefined && (typeof rt.host !== 'string' || !HOST.test(rt.host))) return { reject: 'bad-route-host' };
1235
+ let path = null;
1236
+ if (rt.path !== undefined) { path = normalizePath(rt.path); if (path === null) return { reject: 'bad-route-path' }; }
1237
+ if (r.outcome !== undefined && !OUTCOMES.includes(r.outcome)) return { reject: 'bad-outcome' };
1238
+ if (r.sampled !== undefined && typeof r.sampled !== 'boolean') return { reject: 'bad-sampled' };
1239
+ if (r.count !== undefined && !(Number.isInteger(r.count) && r.count >= 1 && r.count <= TRACE_LIMITS.maxCount)) return { reject: 'bad-count' };
1240
+ if (!src.service && !src.identity && !dst.service && !dst.host && !(rt.host || path)) return { reject: 'no-topology-fields' };
1241
+
1242
+ const o = {
1243
+ environment: r.environment,
1244
+ tenant: r.tenant ?? null,
1245
+ from: { service: src.service ?? null, identity: src.identity ?? null, tenant: src.tenant ?? r.tenant ?? null },
1246
+ to: { service: dst.service ?? null, host: dst.host ?? null, port: dst.port ?? null, tenant: dst.tenant ?? r.tenant ?? null },
1247
+ route: (rt.method || rt.host || path) ? { method: rt.method ?? null, host: rt.host ?? null, path } : null,
1248
+ outcome: r.outcome ?? 'ok',
1249
+ sampled: r.sampled === true,
1250
+ eventCount: r.count ?? 1,
1251
+ firstObservedAt: r.ts,
1252
+ lastObservedAt: r.ts,
1253
+ };
1254
+ o.id = observationIdOf(o);
1255
+ return { obs: o };
1256
+ }
1257
+
1258
+ const OBS_ID_FIELDS = ['environment', 'tenant', 'from', 'to', 'route', 'outcome', 'sampled'];
1259
+ function observationIdOf(o) { return (0,identity/* semanticId */.YN)('bobs', o, OBS_ID_FIELDS); }
1260
+
1261
+ function mergeInto(a, b) {
1262
+ a.eventCount = Math.min(TRACE_LIMITS.maxCount, a.eventCount + b.eventCount);
1263
+ if (Date.parse(b.firstObservedAt) < Date.parse(a.firstObservedAt)) a.firstObservedAt = b.firstObservedAt;
1264
+ if (Date.parse(b.lastObservedAt) > Date.parse(a.lastObservedAt)) a.lastObservedAt = b.lastObservedAt;
1265
+ }
1266
+
1267
+ function eventCountBand(n) {
1268
+ if (n <= 1) return runtime_observation/* EVENT_COUNT_BANDS */.Ws[0];
1269
+ if (n <= 10) return runtime_observation/* EVENT_COUNT_BANDS */.Ws[1];
1270
+ if (n <= 100) return runtime_observation/* EVENT_COUNT_BANDS */.Ws[2];
1271
+ if (n <= 1000) return runtime_observation/* EVENT_COUNT_BANDS */.Ws[3];
1272
+ return runtime_observation/* EVENT_COUNT_BANDS */.Ws[4];
1273
+ }
1274
+
1275
+ function computeSetDigest(set) { return (0,identity/* digestOf */.ol)({ ...set, digest: null }); }
1276
+
1277
+ function finalizeSet(environment, byId, stats, sanitization, limits) {
1278
+ let list = [...byId.values()];
1279
+ let evicted = 0;
1280
+ if (list.length > limits.maxObservations) {
1281
+ list.sort((a, b) => Date.parse(b.lastObservedAt) - Date.parse(a.lastObservedAt) || (a.id < b.id ? -1 : 1));
1282
+ evicted = list.length - limits.maxObservations;
1283
+ list = list.slice(0, limits.maxObservations);
1284
+ }
1285
+ list.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
1286
+ const times = list.flatMap(o => [Date.parse(o.firstObservedAt), Date.parse(o.lastObservedAt)]);
1287
+ const set = {
1288
+ schema: OBSERVATION_SET_SCHEMA,
1289
+ schemaVersion: OBSERVATION_SET_VERSION,
1290
+ environment,
1291
+ window: times.length ? { start: new Date(Math.min(...times)).toISOString(), end: new Date(Math.max(...times)).toISOString() } : null,
1292
+ observations: list,
1293
+ sanitization,
1294
+ stats: { ...stats, evicted },
1295
+ digest: '',
1296
+ };
1297
+ set.digest = computeSetDigest(set);
1298
+ return set;
1299
+ }
1300
+
1301
+ /**
1302
+ * Import runtime trace lines for ONE environment. Never throws, never reads a
1303
+ * file, never stores a removed field or a rejected record.
1304
+ *
1305
+ * @param {string} text JSON Lines
1306
+ * @param {object} p
1307
+ * @param {string} p.environment the only environment this import accepts
1308
+ */
1309
+ function importTraceLines(text, { environment, limits: overrides = {} } = {}) {
1310
+ const limits = { ...TRACE_LIMITS, ...overrides };
1311
+ if (typeof environment !== 'string' || !ENV.test(environment)) return { status: 'invalid-input', reason: 'environment must be a name such as prod', set: null };
1312
+ if (typeof text !== 'string') return { status: 'invalid-input', reason: 'trace input must be text', set: null };
1313
+ if (Buffer.byteLength(text, 'utf8') > limits.maxInputBytes) return { status: 'limit-exceeded', reason: `trace input is over the ${limits.maxInputBytes} byte limit; nothing was imported`, set: null };
1314
+
1315
+ const sanitization = emptySanitization();
1316
+ const stats = { linesRead: 0, accepted: 0, duplicates: 0, rejected: 0, rejectedEnvironment: 0, truncatedLines: false, rejectReasons: {} };
1317
+ const byId = new Map();
1318
+ const lines = text.split('\n');
1319
+ for (const raw of lines) {
1320
+ const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
1321
+ if (line.trim() === '') continue;
1322
+ if (stats.linesRead >= limits.maxLines) { stats.truncatedLines = true; break; }
1323
+ stats.linesRead += 1;
1324
+ const reject = (why) => { stats.rejected += 1; stats.rejectReasons[why] = (stats.rejectReasons[why] ?? 0) + 1; };
1325
+ if (Buffer.byteLength(line, 'utf8') > limits.maxLineBytes) { reject('line-too-long'); continue; }
1326
+ let rec;
1327
+ try { rec = JSON.parse(line); } catch { reject('not-json'); continue; }
1328
+ const n = normalizeRecord(rec, sanitization);
1329
+ if (n.reject) { reject(n.reject); continue; }
1330
+ if (n.obs.environment !== environment) { stats.rejectedEnvironment += 1; stats.rejected += 1; stats.rejectReasons['other-environment'] = (stats.rejectReasons['other-environment'] ?? 0) + 1; continue; }
1331
+ const prev = byId.get(n.obs.id);
1332
+ if (prev) { mergeInto(prev, n.obs); stats.duplicates += 1; } else byId.set(n.obs.id, n.obs);
1333
+ stats.accepted += 1;
1334
+ }
1335
+ const set = finalizeSet(environment, byId, stats, sanitization, limits);
1336
+ return { status: 'ok', reason: null, set };
1337
+ }
1338
+
1339
+ /**
1340
+ * Merge a newer import into stored observations of the SAME environment,
1341
+ * bounded. Different environments are refused (never merged). Returns a new set.
1342
+ */
1343
+ function mergeObservationSets(existing, incoming, { maxObservations = TRACE_LIMITS.maxObservations } = {}) {
1344
+ if (!existing || !incoming) return { status: 'invalid-input', reason: 'two observation sets are required', set: null };
1345
+ if (existing.environment !== incoming.environment) return { status: 'environment-mismatch', reason: `refusing to merge '${incoming.environment}' observations into '${existing.environment}'`, set: null };
1346
+ const byId = new Map(existing.observations.map(o => [o.id, { ...o }]));
1347
+ let duplicates = 0;
1348
+ for (const o of incoming.observations) {
1349
+ const prev = byId.get(o.id);
1350
+ if (prev) { mergeInto(prev, o); duplicates += 1; } else byId.set(o.id, { ...o });
1351
+ }
1352
+ const stats = { linesRead: 0, accepted: byId.size, duplicates, rejected: 0, rejectedEnvironment: 0, truncatedLines: false, rejectReasons: {} };
1353
+ const sanitization = {
1354
+ recordsWithRemovedFields: existing.sanitization.recordsWithRemovedFields + incoming.sanitization.recordsWithRemovedFields,
1355
+ removedFieldCount: existing.sanitization.removedFieldCount + incoming.sanitization.removedFieldCount,
1356
+ credentialShaped: existing.sanitization.credentialShaped + incoming.sanitization.credentialShaped,
1357
+ payloadShaped: existing.sanitization.payloadShaped + incoming.sanitization.payloadShaped,
1358
+ other: existing.sanitization.other + incoming.sanitization.other,
1359
+ };
1360
+ return { status: 'ok', reason: null, set: finalizeSet(existing.environment, byId, stats, sanitization, { maxObservations }) };
1361
+ }
1362
+
1363
+ // ------------------------------------------------------------ persistence
1364
+
1365
+ const SET_FIELDS = (/* unused pure expression or super */ null && (['schema', 'schemaVersion', 'environment', 'window', 'observations', 'sanitization', 'stats', 'digest']));
1366
+ const OBS_FIELDS = (/* unused pure expression or super */ null && (['id', 'environment', 'tenant', 'from', 'to', 'route', 'outcome', 'sampled', 'eventCount', 'firstObservedAt', 'lastObservedAt']));
1367
+
1368
+ /** Deterministic text form of an observation set (for the caller to store). */
1369
+ function exportObservationSet(set) { return `${canonicalize(set)}\n`; }
1370
+
1371
+ /**
1372
+ * Read a stored observation set back. Closed world, bounded, digest-checked:
1373
+ * a set that was edited, padded with extra fields, or grown past the cap is
1374
+ * refused, never partially accepted.
1375
+ */
1376
+ function parseObservationSet(text, { maxObservations = TRACE_LIMITS.maxObservations, maxBytes = 2 * TRACE_LIMITS.maxInputBytes } = {}) {
1377
+ if (typeof text !== 'string' || Buffer.byteLength(text, 'utf8') > maxBytes) return { status: 'malformed', reason: 'not text, or over the size limit', set: null };
1378
+ let set;
1379
+ try { set = JSON.parse(text); } catch { return { status: 'malformed', reason: 'not valid JSON', set: null }; }
1380
+ const bad = (reason) => ({ status: 'invalid', reason, set: null });
1381
+ if (!set || typeof set !== 'object' || Array.isArray(set)) return bad('not an object');
1382
+ for (const k of Object.keys(set)) if (!SET_FIELDS.includes(k)) return bad(`unknown field '${k}'`);
1383
+ if (set.schema !== OBSERVATION_SET_SCHEMA) return bad('wrong schema');
1384
+ if (typeof set.environment !== 'string' || !ENV.test(set.environment)) return bad('bad environment');
1385
+ if (!Array.isArray(set.observations) || set.observations.length > maxObservations) return bad(`observations must be an array of at most ${maxObservations}`);
1386
+ const ids = new Set();
1387
+ for (const o of set.observations) {
1388
+ if (!o || typeof o !== 'object' || Array.isArray(o)) return bad('observation is not an object');
1389
+ for (const k of Object.keys(o)) if (!OBS_FIELDS.includes(k)) return bad(`observation has unknown field '${k}'`);
1390
+ if (o.environment !== set.environment) return bad('observation environment differs from the set environment');
1391
+ if (!ISO.test(o.firstObservedAt ?? '') || !ISO.test(o.lastObservedAt ?? '') || Date.parse(o.firstObservedAt) > Date.parse(o.lastObservedAt)) return bad('observation interval is invalid');
1392
+ if (!OUTCOMES.includes(o.outcome) || typeof o.sampled !== 'boolean') return bad('observation outcome or sampled flag is invalid');
1393
+ if (!Number.isInteger(o.eventCount) || o.eventCount < 1 || o.eventCount > TRACE_LIMITS.maxCount) return bad('observation count is invalid');
1394
+ for (const [f, keys] of [['from', SOURCE_KEYS], ['to', DEST_KEYS]]) {
1395
+ if (!o[f] || typeof o[f] !== 'object') return bad(`observation.${f} is missing`);
1396
+ for (const k of Object.keys(o[f])) if (!keys.includes(k)) return bad(`observation.${f} has unknown field '${k}'`);
1397
+ }
1398
+ for (const v of [o.from?.service, o.from?.identity, o.to?.service]) if (v !== null && v !== undefined && !SVC.test(v)) return bad('observation has a non identifier-shaped name');
1399
+ for (const v of [o.tenant, o.from?.tenant, o.to?.tenant]) if (v !== null && v !== undefined && !ENV.test(v)) return bad('observation has a bad tenant');
1400
+ if (o.to.host !== null && o.to.host !== undefined && !HOST.test(o.to.host)) return bad('observation has a bad host');
1401
+ if (o.route !== null) {
1402
+ if (typeof o.route !== 'object') return bad('observation.route is invalid');
1403
+ for (const k of Object.keys(o.route)) if (!ROUTE_KEYS.includes(k)) return bad(`observation.route has unknown field '${k}'`);
1404
+ if (o.route.path !== null && o.route.path !== undefined && (typeof o.route.path !== 'string' || !/^\/[A-Za-z0-9._:\/-]*$/.test(o.route.path) || o.route.path.length > 200)) return bad('observation has a bad route path');
1405
+ }
1406
+ if (observationIdOf(o) !== o.id) return bad('observation id does not match its content');
1407
+ if (ids.has(o.id)) return bad('duplicate observation id');
1408
+ ids.add(o.id);
1409
+ }
1410
+ if (set.digest !== computeSetDigest(set)) return bad('digest does not match the content');
1411
+ return { status: 'ok', reason: null, set };
1412
+ }
1413
+
1414
+ // ------------------------------------------------------------ correlation
1415
+
1416
+ const COMM_RELATIONS = new Set(['calls', 'depends-on', 'network-allows', 'routes-to']);
1417
+
1418
+ function serviceIndex(graph, environment) {
1419
+ const exact = new Map(), bySuffix = new Map();
1420
+ for (const n of graph.nodes) {
1421
+ if (n.kind !== 'service' || n.environment !== environment) continue;
1422
+ exact.set(`${n.tenant ?? ''}\n${n.name}`, n);
1423
+ const base = n.name.includes('/') ? n.name.slice(n.name.lastIndexOf('/') + 1) : n.name;
1424
+ const k = `${n.tenant ?? ''}\n${base}`;
1425
+ if (!bySuffix.has(k)) bySuffix.set(k, []);
1426
+ bySuffix.get(k).push(n);
1427
+ }
1428
+ return { exact, bySuffix };
1429
+ }
1430
+
1431
+ function dnsToService(host) {
1432
+ const m = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?)\.([a-z0-9]([a-z0-9-]*[a-z0-9])?)(\.svc(\.cluster\.local)?)?$/.exec(host ?? '');
1433
+ return m ? `${m[3]}/${m[1]}` : null;
1434
+ }
1435
+
1436
+ function resolveService(idx, tenant, name, host) {
1437
+ const t = tenant ?? '';
1438
+ if (name) {
1439
+ const hit = idx.exact.get(`${t}\n${name}`);
1440
+ if (hit) return { node: hit, rule: 'R1', basis: 'exact-name' };
1441
+ if (!name.includes('/')) {
1442
+ const c = idx.bySuffix.get(`${t}\n${name}`) ?? [];
1443
+ if (c.length === 1) return { node: c[0], rule: 'R4', basis: 'unqualified-name' };
1444
+ if (c.length > 1) return { node: null, reason: 'ambiguous-service-name' };
1445
+ }
1446
+ return { node: null, reason: 'no-service-node' };
1447
+ }
1448
+ if (host) {
1449
+ const dns = dnsToService(host);
1450
+ if (dns) {
1451
+ const hit = idx.exact.get(`${t}\n${dns}`);
1452
+ if (hit) return { node: hit, rule: 'R4', basis: 'dns-name' };
1453
+ }
1454
+ const c = idx.bySuffix.get(`${t}\n${host}`) ?? [];
1455
+ if (c.length === 1) return { node: c[0], rule: 'R4', basis: 'host-as-service-name' };
1456
+ return { node: null, reason: c.length > 1 ? 'ambiguous-service-name' : 'no-service-node' };
1457
+ }
1458
+ return { node: null, reason: 'no-destination' };
1459
+ }
1460
+
1461
+ function pathUnder(prefix, path) {
1462
+ if (typeof prefix !== 'string' || typeof path !== 'string') return false;
1463
+ if (prefix === '/') return true;
1464
+ return path === prefix || path.startsWith(prefix.endsWith('/') ? prefix : `${prefix}/`);
1465
+ }
1466
+
1467
+ /**
1468
+ * Correlate an observation set with a static graph. Pure: the clock is the
1469
+ * explicit `now` argument. Returns the correlation record; use
1470
+ * `applyCorrelation` to fold its edges into a graph.
1471
+ *
1472
+ * @param {object} graph a valid boundary graph
1473
+ * @param {object} set an observation set from importTraceLines
1474
+ * @param {object} p
1475
+ * @param {string} p.now ISO timestamp the freshness test uses
1476
+ * @param {number} [p.staleAfterMs]
1477
+ * @param {boolean} [p.createObservedOnlyNodes] default true
1478
+ */
1479
+ function correlateObservations(graph, set, { now, staleAfterMs = DEFAULT_STALE_AFTER_MS, createObservedOnlyNodes = true } = {}) {
1480
+ if (!graph || !set || set.schema !== OBSERVATION_SET_SCHEMA) return { status: 'invalid-input', reason: 'a boundary graph and an observation set are required' };
1481
+ const nowMs = Date.parse(now);
1482
+ if (!Number.isFinite(nowMs)) return { status: 'invalid-input', reason: 'now must be an ISO timestamp' };
1483
+ const env = set.environment;
1484
+ const idx = serviceIndex(graph, env);
1485
+ const nodesById = new Map(graph.nodes.map(n => [n.id, n]));
1486
+ const outStatic = new Map();
1487
+ for (const e of graph.edges) {
1488
+ if (e.provenance !== 'static-config') continue;
1489
+ if (!outStatic.has(e.from)) outStatic.set(e.from, []);
1490
+ outStatic.get(e.from).push(e);
1491
+ }
1492
+ const staticBetween = (a, b) => graph.edges.filter(e => e.provenance === 'static-config' && e.from === a && e.to === b && COMM_RELATIONS.has(e.relation));
1493
+
1494
+ const newNodes = new Map();
1495
+ const unmatched = [];
1496
+ const conflicts = [];
1497
+ const acc = new Map(); // observed/inferred edge key -> accumulator
1498
+ const touchedStatic = new Set();
1499
+ const outcomes = { ok: 0, denied: 0, error: 0 };
1500
+ const deniedStatic = new Set();
1501
+
1502
+ const observedOnly = (name, tenant) => {
1503
+ if (!createObservedOnlyNodes || !name) return null;
1504
+ const n = { kind: 'service', name, environment: env, tenant: tenant ?? null, repository: null, trustZone: 'unknown', resolved: false, unresolvedReason: 'observed at runtime, not present in the static configuration', attrs: {}, declaredBy: [] };
1505
+ newNodes.set(`${n.tenant ?? ''}\n${n.name}`, n);
1506
+ return n;
1507
+ };
1508
+ const idOf = (n) => n.id ?? (0,boundary_graph/* nodeIdOf */.$z)(n);
1509
+
1510
+ const bump = (key, init, o) => {
1511
+ let a = acc.get(key);
1512
+ if (!a) { a = { ...init, first: o.firstObservedAt, last: o.lastObservedAt, allSampled: true, count: 0 }; acc.set(key, a); }
1513
+ if (Date.parse(o.firstObservedAt) < Date.parse(a.first)) a.first = o.firstObservedAt;
1514
+ if (Date.parse(o.lastObservedAt) > Date.parse(a.last)) a.last = o.lastObservedAt;
1515
+ a.allSampled = a.allSampled && o.sampled;
1516
+ a.count = Math.min(TRACE_LIMITS.maxCount, a.count + o.eventCount);
1517
+ return a;
1518
+ };
1519
+
1520
+ for (const o of set.observations) {
1521
+ outcomes[o.outcome] += 1;
1522
+ const srcR = o.from.service ? resolveService(idx, o.from.tenant, o.from.service, null) : { node: null, reason: 'no-source' };
1523
+ const dstR = (o.to.service || o.to.host) ? resolveService(idx, o.to.tenant, o.to.service, o.to.host) : { node: null, reason: 'no-destination' };
1524
+ let srcNode = srcR.node, dstNode = dstR.node;
1525
+ let matchedAny = false;
1526
+
1527
+ // R1/R4: service pair
1528
+ if (o.from.service && (o.to.service || o.to.host)) {
1529
+ if (!srcNode && srcR.reason === 'no-service-node') srcNode = observedOnly(o.from.service, o.from.tenant);
1530
+ if (!dstNode && dstR.reason === 'no-service-node') dstNode = observedOnly(o.to.service ?? o.to.host, o.to.tenant);
1531
+ if (srcNode && dstNode) {
1532
+ const srcId = idOf(srcNode), dstId = idOf(dstNode);
1533
+ const inferred = srcR.rule === 'R4' || dstR.rule === 'R4';
1534
+ const stat = staticBetween(srcId, dstId);
1535
+ if (o.outcome === 'ok') {
1536
+ const basis = inferred ? (srcR.rule === 'R4' ? srcR.basis : dstR.basis) : null;
1537
+ const key = `${inferred ? `I:${basis}` : 'O'}\n${srcId}\n${dstId}`;
1538
+ const a = bump(key, { kind: inferred ? 'inferred' : 'runtime', relation: 'calls', from: srcId, to: dstId, rule: inferred ? 'R4' : 'R1', basis, corroborates: new Set(), contradicts: new Set() }, o);
1539
+ for (const e of stat) { if (e.pathState !== 'blocked') { a.corroborates.add(e.id); touchedStatic.add(e.id); } }
1540
+ for (const e of graph.edges.filter(x => x.provenance === 'static-config' && x.from === srcId && x.to === dstId && x.pathState === 'blocked')) { a.contradicts.add(e.id); conflicts.push({ code: 'observed-over-blocked-path', edgeId: e.id, observationId: o.id }); }
1541
+ } else {
1542
+ for (const e of stat) deniedStatic.add(e.id);
1543
+ }
1544
+ matchedAny = true;
1545
+ }
1546
+ }
1547
+
1548
+ // R3: identity
1549
+ if (o.from.identity && srcNode && o.outcome === 'ok') {
1550
+ const srcId = idOf(srcNode);
1551
+ const assumes = (outStatic.get(srcId) ?? []).filter(e => e.relation === 'assumes');
1552
+ const named = assumes.find(e => { const n = nodesById.get(e.to); return n && (n.name === o.from.identity || n.name.endsWith(`/${o.from.identity}`)); });
1553
+ if (named) {
1554
+ const a = bump(`O\n${srcId}\n${named.to}\nassumes`, { kind: 'runtime', relation: 'assumes', from: srcId, to: named.to, rule: 'R3', basis: null, corroborates: new Set(), contradicts: new Set() }, o);
1555
+ a.corroborates.add(named.id); touchedStatic.add(named.id);
1556
+ matchedAny = true;
1557
+ } else if (assumes.length) {
1558
+ conflicts.push({ code: 'identity-mismatch', service: srcNode.name, observedIdentity: o.from.identity, configured: assumes.map(e => nodesById.get(e.to)?.name).sort(), observationId: o.id });
1559
+ matchedAny = true;
1560
+ }
1561
+ }
1562
+
1563
+ // R2: route
1564
+ if (o.route && (o.route.host || o.route.path) && dstNode && o.outcome === 'ok') {
1565
+ const dstId = idOf(dstNode);
1566
+ const routes = graph.nodes.filter(n => n.kind === 'route' && n.environment === env && (n.tenant === null || n.tenant === (o.to.tenant ?? null)) && (!o.route.host || n.attrs.host === o.route.host || n.attrs.host === '*') && (o.route.path === null || pathUnder(n.attrs.path, o.route.path)));
1567
+ for (const r of routes) {
1568
+ const chain = (0,boundary_graph/* findPath */.Hh)(outStatic, r.id, dstId, { accept: (e) => e.relation === 'routes-to' && e.pathState !== 'blocked', maxHops: 4 });
1569
+ if (chain) {
1570
+ for (const e of chain) {
1571
+ const a = bump(`O\n${e.from}\n${e.to}\nroutes-to`, { kind: 'runtime', relation: 'routes-to', from: e.from, to: e.to, rule: 'R2', basis: null, corroborates: new Set(), contradicts: new Set() }, o);
1572
+ a.corroborates.add(e.id); touchedStatic.add(e.id);
1573
+ }
1574
+ matchedAny = true;
1575
+ }
1576
+ }
1577
+ }
1578
+ if (!matchedAny) {
1579
+ const reason = !o.to.service && !o.to.host && !o.route ? 'no-destination' : (srcR.reason && !srcNode ? srcR.reason : dstR.reason) ?? 'no-rule-matched';
1580
+ unmatched.push({ observationId: o.id, reason });
1581
+ }
1582
+ }
1583
+
1584
+ // Build observed / inferred edges
1585
+ const edges = [];
1586
+ for (const [key, a] of [...acc.entries()].sort((x, y) => (x[0] < y[0] ? -1 : 1))) {
1587
+ const stale = nowMs - Date.parse(a.last) > staleAfterMs;
1588
+ const e = {
1589
+ relation: a.relation, from: a.from, to: a.to, environment: env, tenant: null, effect: 'none',
1590
+ provenance: a.kind === 'runtime' ? 'runtime-observation' : 'inferred',
1591
+ confidence: a.kind === 'inferred' ? 'low' : (a.allSampled || stale ? 'medium' : 'high'),
1592
+ pathState: 'possible', observationInterval: null, completeness: null, sourceRevision: null, source: null,
1593
+ discriminator: a.kind === 'inferred' ? `R4:${a.basis}` : a.rule, crossRepo: null,
1594
+ attrs: { rule: a.rule, countBand: eventCountBand(a.count), ...(a.basis ? { basis: a.basis } : {}), ...(a.kind === 'runtime' && stale ? { stale: true } : {}) },
1595
+ };
1596
+ if (a.kind === 'runtime') {
1597
+ e.pathState = 'runtime-supported';
1598
+ e.observationInterval = { start: a.first, end: a.last };
1599
+ e.completeness = a.allSampled ? 'sampled' : 'unknown'; // never 'complete'
1600
+ }
1601
+ e.id = (0,boundary_graph/* edgeIdOf */.aI)(e);
1602
+ edges.push({ edge: e, acc: a, stale });
1603
+ }
1604
+
1605
+ const staticCandidates = graph.edges.filter(e => e.provenance === 'static-config' && e.environment === env && COMM_RELATIONS.has(e.relation) && e.pathState !== 'blocked' && e.effect !== 'deny');
1606
+ const missingEdgeIds = staticCandidates.filter(e => !touchedStatic.has(e.id)).map(e => e.id).sort();
1607
+
1608
+ return {
1609
+ status: 'ok',
1610
+ environment: env,
1611
+ rules: CORRELATION_RULES,
1612
+ window: set.window,
1613
+ evaluatedAt: new Date(nowMs).toISOString(),
1614
+ staleAfterMs,
1615
+ observedNodes: [...newNodes.values()],
1616
+ edges: edges.map(x => x.edge),
1617
+ corroboration: Object.fromEntries(edges.map(x => [x.edge.id, { corroborates: [...x.acc.corroborates].sort(), contradicts: [...x.acc.contradicts].sort() }])),
1618
+ conflicts,
1619
+ unmatched,
1620
+ outcomes,
1621
+ coverage: {
1622
+ // Observation is evidence of traffic, never proof that the window held all traffic.
1623
+ trafficCoverage: 'not-established',
1624
+ observed: edges.filter(x => x.edge.provenance === 'runtime-observation' && !x.acc.allSampled && !x.stale).map(x => x.edge.id).sort(),
1625
+ sampled: edges.filter(x => x.acc.allSampled && x.edge.provenance === 'runtime-observation').map(x => x.edge.id).sort(),
1626
+ stale: edges.filter(x => x.stale && x.edge.provenance === 'runtime-observation').map(x => x.edge.id).sort(),
1627
+ missingStaticEdgeIds: missingEdgeIds,
1628
+ deniedObservedStaticEdgeIds: [...deniedStatic].sort(),
1629
+ },
1630
+ };
1631
+ }
1632
+
1633
+ /** Fold a correlation into a graph as a new validated graph. Static edges are untouched. */
1634
+ function applyCorrelation(graph, correlation) {
1635
+ if (!correlation || correlation.status !== 'ok') return { ok: false, graph: null, errors: [{ code: 'RULE_VIOLATION', path: '', message: 'correlation is not usable' }] };
1636
+ const nodes = [...graph.nodes, ...correlation.observedNodes];
1637
+ const edges = [...graph.edges, ...correlation.edges];
1638
+ return (0,boundary_graph/* buildBoundaryGraph */.v4)({ repository: graph.repository, revision: graph.revision, nodes, edges, gaps: graph.gaps, sources: graph.sources });
1639
+ }
1640
+
1641
+ // EXTERNAL MODULE: ./src/lineage/deployment/projection.js + 1 modules
1642
+ var projection = __webpack_require__(73596);
1643
+ ;// CONCATENATED MODULE: ./src/lineage/deployment/boundaries-run.js
1644
+ // boundaries-run.js: the operator entry point that builds a boundary context for scanned findings (X-308 wiring).
1645
+ //
1646
+ // Nothing in the default scan builds a boundary context. This is the one place that does, and it runs only when an operator
1647
+ // asks for it (`agentic-security boundaries`) AND the `deployment-boundaries` feature is on (off by default). Rules it keeps:
1648
+ // - Local files only. It reads the directory it is pointed at, bounded, with no symlink followed, and optionally one scan
1649
+ // result file. It opens no socket, spawns no process, reads no credential and executes no configuration.
1650
+ // - Read-only with respect to the project. The only write is the report file the operator names with `out`, refused when the
1651
+ // target is a symlink.
1652
+ // - Off means off. With the feature off nothing is read: the result is a typed `disabled` and no file is opened.
1653
+ // - Everything that was not established is carried through: ingest gaps, unsupported syntax, files not read, trace sampling
1654
+ // and staleness, findings with no service binding. A result never says safe.
1655
+ //
1656
+ // Reserved names inside the directory: `service-bindings.json` (finding path prefix to service), `identities.json` (policy file
1657
+ // to the identity it belongs to) and `traces.jsonl` / `traces.ndjson` (sanitized runtime trace lines). They are inputs, never
1658
+ // ingested as configuration.
1659
+
1660
+
1661
+
1662
+
1663
+
1664
+
1665
+
1666
+
1667
+
1668
+
1669
+ const BOUNDARIES_REPORT_SCHEMA = 'agentic-security/boundaries-report';
1670
+ const BOUNDARIES_REPORT_VERSION = '1.0.0';
1671
+ const BINDINGS_FILE = 'service-bindings.json';
1672
+ const IDENTITIES_FILE = 'identities.json';
1673
+ const TRACE_FILES = Object.freeze(['traces.jsonl', 'traces.ndjson']);
1674
+ const MAX_FINDINGS = 5000;
1675
+
1676
+ const RESERVED = new Set([BINDINGS_FILE, IDENTITIES_FILE, ...TRACE_FILES]);
1677
+ const CONFIG_EXTENSIONS = new Set(['.yaml', '.yml', '.json', '.tf', '.hcl', '.tfvars', '.conf', '.toml', '.xml']);
1678
+ const SKIP_DIRS = new Set(['.git', 'node_modules', state_dir/* STATE_DIR_NAME */.Ky]);
1679
+ const MAX_WALK_ENTRIES = 5000;
1680
+
1681
+ /** List candidate configuration files under `dir`, relative and sorted. Symlinks are never followed or listed. */
1682
+ function listConfigFiles(dir) {
1683
+ const found = [];
1684
+ const skipped = { symlinks: 0, overLimit: false };
1685
+ let seen = 0;
1686
+ const walk = (d, rel) => {
1687
+ let entries;
1688
+ try { entries = external_node_fs_.readdirSync(d, { withFileTypes: true }); } catch { return; }
1689
+ for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
1690
+ if (++seen > MAX_WALK_ENTRIES) { skipped.overLimit = true; return; }
1691
+ const r = rel ? `${rel}/${e.name}` : e.name;
1692
+ if (e.isSymbolicLink()) { skipped.symlinks += 1; continue; }
1693
+ if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name)) walk(external_node_path_.join(d, e.name), r); continue; }
1694
+ if (!e.isFile()) continue;
1695
+ if (!rel && RESERVED.has(e.name)) continue;
1696
+ if (CONFIG_EXTENSIONS.has(external_node_path_.extname(e.name).toLowerCase())) found.push(r);
1697
+ }
1698
+ };
1699
+ walk(dir, '');
1700
+ return { files: found.sort(), skipped };
1701
+ }
1702
+
1703
+ function readJsonFile(file, maxBytes, what) {
1704
+ const r = readFileBounded(file, maxBytes);
1705
+ if (r.status !== 'ok') return { ok: false, reason: `${what}: ${r.reason}` };
1706
+ try { return { ok: true, value: JSON.parse(r.text) }; } catch (e) { return { ok: false, reason: `${what} is not valid JSON (${String(e.message).split('\n')[0]})` }; }
1707
+ }
1708
+
1709
+ /**
1710
+ * Read the findings of a scan result file. A sibling `.sig` that exists must verify; a result with no signature is read and
1711
+ * labelled unverified.
1712
+ */
1713
+ function readScanFindings(file, { maxBytes, verify } = {}) {
1714
+ const r = readFileBounded(file, maxBytes);
1715
+ if (r.status !== 'ok') return { ok: false, reason: `scan result: ${r.reason}`, findings: [], integrity: 'unreadable' };
1716
+ let integrity = 'unsigned';
1717
+ if (typeof verify === 'function') {
1718
+ const v = verify(r.text, `${file}.sig`);
1719
+ if (v === false) return { ok: false, reason: 'the scan result has a signature that does not verify; it is not used', findings: [], integrity: 'signature-mismatch' };
1720
+ if (v === true) integrity = 'verified';
1721
+ }
1722
+ let doc;
1723
+ try { doc = JSON.parse(r.text); } catch (e) { return { ok: false, reason: `scan result is not valid JSON (${String(e.message).split('\n')[0]})`, findings: [], integrity }; }
1724
+ const list = Array.isArray(doc) ? doc : doc?.findings;
1725
+ if (!Array.isArray(list)) return { ok: false, reason: 'scan result has no findings array', findings: [], integrity };
1726
+ return { ok: true, reason: null, findings: list.filter((f) => f && typeof f === 'object' && !Array.isArray(f)), integrity };
1727
+ }
1728
+
1729
+ /**
1730
+ * Build a boundary graph from a directory of deployment sources and attach a context to each finding.
1731
+ *
1732
+ * @param {object} o
1733
+ * @param {object} o.config resolveAssuranceConfig() result (the feature gate)
1734
+ * @param {string} o.from directory with configuration, optional traces, bindings and identities
1735
+ * @param {string} [o.environment] default 'prod'
1736
+ * @param {string|null} [o.repository]
1737
+ * @param {string|null} [o.revision] exact 40/64 hex commit the files were read at
1738
+ * @param {string|null} [o.findingsFile] a scan result (last-scan.json)
1739
+ * @param {object[]|null} [o.findings] findings already in memory (an evaluation passes these instead of a file)
1740
+ * @param {Function} [o.verifySignature] (body, sigFile) => true | false | null
1741
+ * @param {string} [o.now] ISO time used to judge trace staleness; defaults to the current time
1742
+ * @returns {object} typed result; `status` is ok | disabled | blocked | unsupported | error. Never throws.
1743
+ */
1744
+ function runBoundaries({ config, from, environment = 'prod', repository = null, revision = null, findingsFile = null, findings: suppliedFindings = null, verifySignature, now } = {}) {
1745
+ const gate = (0,assurance_config/* featureStatus */.FX)(config, DEPLOYMENT_FEATURE);
1746
+ if (gate.status !== 'ok') return { ...gate, report: null };
1747
+ const limits = (0,assurance_config/* limitValues */.OH)(config);
1748
+ const fail = (code, reason) => (0,assurance_config/* typed */.nE)('error', code, reason, { feature: DEPLOYMENT_FEATURE, report: null });
1749
+
1750
+ if (typeof from !== 'string' || !from) return fail('invalid-input', '--from names the directory with deployment configuration');
1751
+ let root;
1752
+ try { root = external_node_fs_.realpathSync(from); if (!external_node_fs_.statSync(root).isDirectory()) throw new Error('not a directory'); } catch { return fail('invalid-input', `--from '${from}' is not a readable directory`); }
1753
+
1754
+ const clock = now ?? new Date().toISOString();
1755
+ const notes = [];
1756
+ const listing = listConfigFiles(root);
1757
+ if (listing.skipped.symlinks) notes.push(`${listing.skipped.symlinks} symbolic link(s) were not followed`);
1758
+ if (listing.skipped.overLimit) notes.push(`the directory walk stopped at ${MAX_WALK_ENTRIES} entries; later files were not listed`);
1759
+ let paths = listing.files;
1760
+ if (paths.length > MAX_INGEST_FILES) { notes.push(`${paths.length - MAX_INGEST_FILES} configuration file(s) beyond the ${MAX_INGEST_FILES} file limit were not read`); paths = paths.slice(0, MAX_INGEST_FILES); }
1761
+ if (!paths.length) return fail('invalid-input', `no configuration files (${[...CONFIG_EXTENSIONS].join(', ')}) were found under '${from}'`);
1762
+
1763
+ let identities = {};
1764
+ const idFile = external_node_path_.join(root, IDENTITIES_FILE);
1765
+ if (external_node_fs_.existsSync(idFile)) {
1766
+ const j = readJsonFile(idFile, limits.maxFileBytes, IDENTITIES_FILE);
1767
+ if (!j.ok) return fail('invalid-input', j.reason);
1768
+ if (!j.value || typeof j.value !== 'object' || Array.isArray(j.value)) return fail('invalid-input', `${IDENTITIES_FILE} must be an object mapping a file path to an identity`);
1769
+ identities = Object.fromEntries(Object.entries(j.value).filter(([, v]) => typeof v === 'string'));
1770
+ }
1771
+ let bindings = [];
1772
+ const bindFile = external_node_path_.join(root, BINDINGS_FILE);
1773
+ if (external_node_fs_.existsSync(bindFile)) {
1774
+ const j = readJsonFile(bindFile, limits.maxFileBytes, BINDINGS_FILE);
1775
+ if (!j.ok) return fail('invalid-input', j.reason);
1776
+ if (!Array.isArray(j.value)) return fail('invalid-input', `${BINDINGS_FILE} must be an array of { pathPrefix, service } objects`);
1777
+ bindings = j.value;
1778
+ } else notes.push(`no ${BINDINGS_FILE}: no finding can be tied to a service, so every finding reports exposure as not assessed`);
1779
+
1780
+ const ingested = ingestDeploymentFiles({ config, root, paths, environment, repository, revision, identities });
1781
+ if (ingested.status !== 'ok') return fail(ingested.code ?? 'invalid-config', ingested.reason ?? 'ingest failed');
1782
+ let graph = ingested.graph;
1783
+
1784
+ const observation = { supplied: false, accepted: 0, rejected: 0, status: 'not-supplied', coverage: null };
1785
+ const traceName = TRACE_FILES.find((n) => external_node_fs_.existsSync(external_node_path_.join(root, n)));
1786
+ if (traceName) {
1787
+ observation.supplied = true;
1788
+ const t = readFileBounded(external_node_path_.join(root, traceName), limits.maxFileBytes);
1789
+ if (t.status !== 'ok') { observation.status = 'unreadable'; notes.push(`${traceName} was not read: ${t.reason}`); }
1790
+ else {
1791
+ const imp = importTraceLines(t.text, { environment });
1792
+ if (imp.status !== 'ok') { observation.status = imp.status; notes.push(`${traceName} was not imported: ${imp.reason}`); }
1793
+ else {
1794
+ observation.accepted = imp.set.stats.accepted; observation.rejected = imp.set.stats.rejected;
1795
+ const corr = correlateObservations(graph, imp.set, { now: clock });
1796
+ if (corr.status !== 'ok') { observation.status = corr.status; notes.push(`traces were not correlated: ${corr.reason}`); }
1797
+ else {
1798
+ const applied = applyCorrelation(graph, corr);
1799
+ if (applied.ok) { graph = applied.graph; observation.status = 'correlated'; observation.coverage = corr.coverage; }
1800
+ else { observation.status = 'invalid-graph'; notes.push('correlated traces produced an invalid graph and were not applied'); }
1801
+ }
1802
+ }
1803
+ }
1804
+ }
1805
+
1806
+ let findings = [];
1807
+ let integrity = 'not-supplied';
1808
+ if (Array.isArray(suppliedFindings)) {
1809
+ integrity = 'in-memory';
1810
+ findings = suppliedFindings.filter((f) => f && typeof f === 'object' && !Array.isArray(f));
1811
+ if (findings.length > MAX_FINDINGS) { notes.push(`${findings.length - MAX_FINDINGS} finding(s) beyond the ${MAX_FINDINGS} limit were not given a context`); findings = findings.slice(0, MAX_FINDINGS); }
1812
+ } else if (findingsFile) {
1813
+ const s = readScanFindings(findingsFile, { maxBytes: limits.maxFileBytes, verify: verifySignature });
1814
+ if (!s.ok) return fail('invalid-input', s.reason);
1815
+ integrity = s.integrity;
1816
+ findings = s.findings;
1817
+ if (findings.length > MAX_FINDINGS) { notes.push(`${findings.length - MAX_FINDINGS} finding(s) beyond the ${MAX_FINDINGS} limit were not given a context`); findings = findings.slice(0, MAX_FINDINGS); }
1818
+ if (integrity === 'unsigned') notes.push('the scan result carries no signature, so its integrity was not verified');
1819
+ }
1820
+
1821
+ const attached = (0,projection/* attachBoundaryContext */.Nu)({ config, findings, graph, bindings, now: clock });
1822
+ if (attached.status !== 'ok') return { ...attached, report: null };
1823
+ const contexts = attached.findings.map((f) => f.boundaryContext).filter(Boolean);
1824
+ const rows = attached.findings.map((f) => ({
1825
+ id: f.id ?? null, stableId: f.stableId ?? null, file: f.file ?? null, line: Number.isInteger(f.line) ? f.line : null, vuln: f.vuln ?? null,
1826
+ ...(f.boundaryContext ? (0,projection/* boundaryFields */.Q)(f.boundaryContext) : { boundaryContext: null, boundaryView: null, notAnalyzed: 'the context could not be computed for this finding' }),
1827
+ }));
1828
+ const report = {
1829
+ schema: BOUNDARIES_REPORT_SCHEMA, schemaVersion: BOUNDARIES_REPORT_VERSION,
1830
+ feature: DEPLOYMENT_FEATURE, environment, repository, revision,
1831
+ graph: {
1832
+ digest: graph.digest, nodeCount: graph.nodes.length, edgeCount: graph.edges.length, gapCount: graph.gaps.length,
1833
+ unresolvedNodeCount: graph.nodes.filter((n) => !n.resolved).length,
1834
+ gaps: graph.gaps.map((g) => ({ code: g.code, subject: g.subject, file: g.file ?? null, message: g.message })),
1835
+ files: ingested.files, sourceCount: graph.sources.length,
1836
+ },
1837
+ observation,
1838
+ scanResult: { supplied: Boolean(findingsFile) || integrity === 'in-memory', integrity, findingCount: findings.length },
1839
+ coverage: (0,projection/* boundaryCoverage */.V2)(contexts),
1840
+ findings: rows,
1841
+ errors: attached.errors,
1842
+ notes,
1843
+ statement: 'Configured relationships were not exercised and runtime traffic coverage is not established; a path that is not listed has not been shown to be absent.',
1844
+ };
1845
+ return (0,assurance_config/* typed */.nE)('ok', null, 'boundary contexts built from local files', { feature: DEPLOYMENT_FEATURE, report, graph });
1846
+ }
1847
+
1848
+ /** Text form of a report. Reuses each context's own projected text; adds nothing about safety. */
1849
+ function renderBoundariesText(report) {
1850
+ const L = [];
1851
+ L.push(`Deployment boundaries (environment ${report.environment}${report.repository ? `, repository ${report.repository}` : ''})`);
1852
+ L.push(` Graph: ${report.graph.nodeCount} node(s), ${report.graph.edgeCount} edge(s), ${report.graph.gapCount} gap(s), ${report.graph.unresolvedNodeCount} unresolved; digest ${report.graph.digest.slice(0, 12)}`);
1853
+ for (const f of report.graph.files) L.push(` ${f.file}: ${f.adapter ?? 'not read'} (${f.status})`);
1854
+ for (const g of report.graph.gaps.slice(0, 20)) L.push(` gap ${g.code}: ${g.subject}`);
1855
+ if (report.graph.gaps.length > 20) L.push(` and ${report.graph.gaps.length - 20} more gap(s)`);
1856
+ L.push(` Traces: ${report.observation.status}${report.observation.supplied ? ` (${report.observation.accepted} accepted, ${report.observation.rejected} rejected)` : ''}`);
1857
+ L.push(` Scan result: ${report.scanResult.supplied ? `${report.scanResult.findingCount} finding(s), integrity ${report.scanResult.integrity}` : 'not supplied, so no finding was analyzed'}`);
1858
+ for (const f of report.findings) {
1859
+ L.push(` ${f.id ?? f.stableId ?? 'finding'} ${f.file ?? ''}${f.line ? `:${f.line}` : ''} ${f.vuln ?? ''}`.trimEnd());
1860
+ for (const t of f.boundaryView?.text ?? [f.notAnalyzed ?? 'not analyzed']) L.push(` ${t}`);
1861
+ }
1862
+ for (const line of report.coverage.lines) L.push(line);
1863
+ for (const n of report.notes) L.push(` Note: ${n}`);
1864
+ L.push(` ${report.statement}`);
1865
+ return `${L.join('\n')}\n`;
1866
+ }
1867
+
1868
+
1869
+ /***/ })
1870
+
1871
+ };