@clear-capabilities/agentic-security-scanner 0.161.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.
- package/CHANGELOG.md +46 -0
- package/bin/agentic-security.js +98 -2
- package/dist/1340.index.js +1871 -0
- package/dist/3477.index.js +727 -0
- package/dist/3499.index.js +23 -3
- package/dist/4113.index.js +59 -8
- package/dist/5014.index.js +833 -0
- package/dist/5756.index.js +2 -2
- package/dist/6257.index.js +1 -1
- package/dist/6907.index.js +248 -0
- package/dist/6994.index.js +1 -1
- package/dist/7033.index.js +33 -5
- package/dist/7439.index.js +1822 -0
- package/dist/7838.index.js +38 -2
- package/dist/8798.index.js +1 -1
- package/dist/9207.index.js +5 -0
- package/dist/9560.index.js +2336 -27
- package/dist/957.index.js +1 -1
- package/dist/{1905.index.js → 9999.index.js} +306 -13
- package/dist/agentic-security.mjs +12 -12
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +39 -7
- package/src/capabilities/CLAUDE.md +114 -0
- package/src/capabilities/address.js +73 -0
- package/src/capabilities/attack-coverage.js +93 -0
- package/src/capabilities/decide.js +256 -0
- package/src/capabilities/delegate.js +69 -0
- package/src/capabilities/egress.js +20 -0
- package/src/capabilities/hook-advice.js +50 -0
- package/src/capabilities/index.js +32 -0
- package/src/capabilities/manifest.js +344 -0
- package/src/capabilities/outbound.js +189 -0
- package/src/capabilities/paths.js +57 -0
- package/src/capabilities/probes.js +211 -0
- package/src/capabilities/proxy.js +182 -0
- package/src/capabilities/reasons.js +67 -0
- package/src/capabilities/receipts.js +356 -0
- package/src/capabilities/records.js +58 -0
- package/src/capabilities/recovery.js +367 -0
- package/src/capabilities/report.js +81 -0
- package/src/capabilities/runner.js +236 -0
- package/src/capabilities/secrets.js +26 -0
- package/src/capabilities/tool-gate.js +119 -0
- package/src/capabilities/tool-registry.js +60 -0
- package/src/dataflow/CLAUDE.md +29 -0
- package/src/dataflow/catalog.js +21 -0
- package/src/dataflow/engine.js +20 -0
- package/src/dataflow/guard-dominance.js +352 -0
- package/src/discovery/CLAUDE.md +18 -0
- package/src/discovery/index.js +43 -1
- package/src/discovery/lenses.js +18 -0
- package/src/discovery/memory.js +7 -5
- package/src/engine.js +80 -15
- package/src/fix/apply-fix-service.js +33 -3
- package/src/integrations/index.js +5 -2
- package/src/ir/CLAUDE.md +14 -0
- package/src/ir/implicit-handler-params.js +74 -0
- package/src/ir/parser-cs.js +20 -0
- package/src/ir/parser-go.js +30 -13
- package/src/ir/parser-java.js +11 -0
- package/src/language/nix-eval-isolation.js +33 -5
- package/src/lineage/CLAUDE.md +31 -0
- package/src/lineage/deployment/adapter-cloud.js +202 -0
- package/src/lineage/deployment/adapter-compose.js +90 -0
- package/src/lineage/deployment/adapter-kit.js +142 -0
- package/src/lineage/deployment/adapter-kubernetes.js +247 -0
- package/src/lineage/deployment/attack-paths.js +443 -0
- package/src/lineage/deployment/boundaries-run.js +223 -0
- package/src/lineage/deployment/boundary-graph.js +567 -0
- package/src/lineage/deployment/cross-repo.js +253 -0
- package/src/lineage/deployment/drift.js +383 -0
- package/src/lineage/deployment/ingest.js +221 -0
- package/src/lineage/deployment/projection.js +244 -0
- package/src/lineage/deployment/trace-correlation.js +541 -0
- package/src/lineage/source-registry.js +19 -0
- package/src/mcp/CLAUDE.md +14 -3
- package/src/mcp/invariant-tools.js +64 -0
- package/src/mcp/portfolio-tools.js +79 -0
- package/src/mcp/redact.js +15 -0
- package/src/mcp/server.js +24 -3
- package/src/mcp/tools.js +30 -5
- package/src/posture/CLAUDE.md +505 -0
- package/src/posture/accuracy-scorecard.js +95 -4
- package/src/posture/assurance/baseline-inventory.js +218 -0
- package/src/posture/assurance/baseline.js +322 -0
- package/src/posture/assurance/bounded-io.js +145 -0
- package/src/posture/assurance/config.js +299 -0
- package/src/posture/assurance/contracts.js +315 -0
- package/src/posture/assurance/identity.js +62 -0
- package/src/posture/assurance/migrations.js +259 -0
- package/src/posture/assurance/rollback.js +214 -0
- package/src/posture/assurance/rollout.js +200 -0
- package/src/posture/assurance/schema-kit.js +111 -0
- package/src/posture/assurance/verification-record.js +258 -0
- package/src/posture/attestation.js +5 -0
- package/src/posture/autopilot.js +28 -1
- package/src/posture/business-logic.js +26 -4
- package/src/posture/evaluation/bakeoff.js +148 -0
- package/src/posture/evaluation/custody.js +200 -0
- package/src/posture/evaluation/deployment-ablation.js +416 -0
- package/src/posture/evaluation/dev-cases.js +65 -0
- package/src/posture/evaluation/economics.js +85 -0
- package/src/posture/evaluation/gates.js +161 -0
- package/src/posture/evaluation/grouping.js +101 -0
- package/src/posture/evaluation/interval.js +132 -0
- package/src/posture/evaluation/invariant-ablation.js +325 -0
- package/src/posture/evaluation/labels.js +200 -0
- package/src/posture/evaluation/protocol.js +308 -0
- package/src/posture/evaluation/release-gate.js +209 -0
- package/src/posture/evaluation/report.js +158 -0
- package/src/posture/evaluation/runner.js +273 -0
- package/src/posture/evaluation/scan-child.js +80 -0
- package/src/posture/evaluation/score.js +174 -0
- package/src/posture/evaluation/synthetic.js +90 -0
- package/src/posture/evaluation/why-missed.js +219 -0
- package/src/posture/evidence-bundle.js +93 -7
- package/src/posture/execution-proof.js +11 -1
- package/src/posture/fix-verify-loop.js +20 -6
- package/src/posture/fix-verify.js +37 -2
- package/src/posture/fleet.js +11 -2
- package/src/posture/invariants/cli.js +95 -0
- package/src/posture/invariants/coverage.js +164 -0
- package/src/posture/invariants/export.js +178 -0
- package/src/posture/invariants/expressions.js +46 -0
- package/src/posture/invariants/lifecycle.js +289 -0
- package/src/posture/invariants/project-input.js +55 -0
- package/src/posture/invariants/repair.js +340 -0
- package/src/posture/invariants/run.js +45 -0
- package/src/posture/invariants/scenarios.js +311 -0
- package/src/posture/invariants/schema.js +167 -0
- package/src/posture/invariants/shrink.js +407 -0
- package/src/posture/invariants/state-assertions.js +165 -0
- package/src/posture/logic-claims.js +12 -0
- package/src/posture/model-routing.js +40 -0
- package/src/posture/oracles/adapters.js +531 -0
- package/src/posture/oracles/conformance.js +276 -0
- package/src/posture/oracles/oracle.js +453 -0
- package/src/posture/oracles/registry.js +76 -0
- package/src/posture/oracles/scenario-classes.js +223 -0
- package/src/posture/portfolio/backend.js +279 -0
- package/src/posture/portfolio/bundle.js +314 -0
- package/src/posture/portfolio/cli.js +142 -0
- package/src/posture/portfolio/manifest.js +263 -0
- package/src/posture/portfolio/progress.js +267 -0
- package/src/posture/portfolio/resume.js +130 -0
- package/src/posture/portfolio/retention.js +207 -0
- package/src/posture/portfolio/scheduler.js +488 -0
- package/src/posture/portfolio/signing.js +155 -0
- package/src/posture/portfolio/wording.js +59 -0
- package/src/posture/portfolio/work-units.js +405 -0
- package/src/posture/replay/replay.js +250 -0
- package/src/posture/routing/adapter.js +140 -0
- package/src/posture/routing/calibration.js +278 -0
- package/src/posture/routing/canary.js +141 -0
- package/src/posture/routing/control.js +35 -0
- package/src/posture/routing/decide.js +211 -0
- package/src/posture/routing/drift.js +137 -0
- package/src/posture/routing/economics.js +185 -0
- package/src/posture/routing/feedback.js +207 -0
- package/src/posture/routing/index.js +31 -0
- package/src/posture/routing/outcomes.js +281 -0
- package/src/posture/routing/promotion.js +291 -0
- package/src/posture/routing/receipts.js +59 -0
- package/src/posture/routing/report.js +142 -0
- package/src/posture/routing/shadow.js +53 -0
- package/src/posture/specification-mining.js +26 -0
- package/src/posture/verification/advisory-state.js +71 -0
- package/src/posture/verification/emit.js +257 -0
- package/src/posture/verification/hypothesis-promotion.js +211 -0
- package/src/posture/verification/patch-negative.js +226 -0
- package/src/posture/verification/patch-promotion.js +113 -0
- package/src/posture/verification/projection.js +183 -0
- package/src/posture/verification/repair-records.js +124 -0
- package/src/posture/verifier.js +16 -0
- package/src/report/index.js +100 -1
- package/src/sandbox/CLAUDE.md +51 -0
- package/src/sandbox/backend-namespace.js +7 -0
- package/src/sandbox/backend-userspace.js +151 -34
- package/src/sandbox/control-probes.js +215 -0
- package/src/sandbox/supervise.js +201 -0
- package/src/sandbox/trust-boundary.js +113 -0
- package/src/sandbox/trust-domains.js +171 -0
|
@@ -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
|
+
};
|