@namzu/sandbox 14.0.0 → 15.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/CHANGELOG.md +838 -0
  2. package/README.md +310 -14
  3. package/dist/backends/aci-standby-pool/index.d.ts.map +1 -1
  4. package/dist/backends/aci-standby-pool/index.js +13 -1
  5. package/dist/backends/aci-standby-pool/index.js.map +1 -1
  6. package/dist/backends/docker/index.d.ts.map +1 -1
  7. package/dist/backends/docker/index.js +19 -1
  8. package/dist/backends/docker/index.js.map +1 -1
  9. package/dist/backends/firecracker/index.d.ts.map +1 -1
  10. package/dist/backends/firecracker/index.js +12 -2
  11. package/dist/backends/firecracker/index.js.map +1 -1
  12. package/dist/backends/firecracker/protocol.d.ts +459 -8
  13. package/dist/backends/firecracker/protocol.d.ts.map +1 -1
  14. package/dist/backends/firecracker/protocol.js +136 -0
  15. package/dist/backends/firecracker/protocol.js.map +1 -1
  16. package/dist/backends/firecracker/transport.d.ts +539 -6
  17. package/dist/backends/firecracker/transport.d.ts.map +1 -1
  18. package/dist/backends/firecracker/transport.js +1171 -24
  19. package/dist/backends/firecracker/transport.js.map +1 -1
  20. package/dist/backends/kubernetes/egress-policy.d.ts +1088 -11
  21. package/dist/backends/kubernetes/egress-policy.d.ts.map +1 -1
  22. package/dist/backends/kubernetes/egress-policy.js +2173 -29
  23. package/dist/backends/kubernetes/egress-policy.js.map +1 -1
  24. package/dist/backends/kubernetes/identity.d.ts +193 -0
  25. package/dist/backends/kubernetes/identity.d.ts.map +1 -0
  26. package/dist/backends/kubernetes/identity.js +147 -0
  27. package/dist/backends/kubernetes/identity.js.map +1 -0
  28. package/dist/backends/kubernetes/index.d.ts +678 -33
  29. package/dist/backends/kubernetes/index.d.ts.map +1 -1
  30. package/dist/backends/kubernetes/index.js +1180 -95
  31. package/dist/backends/kubernetes/index.js.map +1 -1
  32. package/dist/backends/kubernetes/ingress-policy.d.ts +375 -0
  33. package/dist/backends/kubernetes/ingress-policy.d.ts.map +1 -0
  34. package/dist/backends/kubernetes/ingress-policy.js +1050 -0
  35. package/dist/backends/kubernetes/ingress-policy.js.map +1 -0
  36. package/dist/backends/kubernetes/k8s-client.d.ts +213 -4
  37. package/dist/backends/kubernetes/k8s-client.d.ts.map +1 -1
  38. package/dist/backends/kubernetes/k8s-client.js +359 -52
  39. package/dist/backends/kubernetes/k8s-client.js.map +1 -1
  40. package/dist/backends/kubernetes/lease.d.ts +40 -14
  41. package/dist/backends/kubernetes/lease.d.ts.map +1 -1
  42. package/dist/backends/kubernetes/lease.js +68 -18
  43. package/dist/backends/kubernetes/lease.js.map +1 -1
  44. package/dist/backends/kubernetes/objects.d.ts +423 -3
  45. package/dist/backends/kubernetes/objects.d.ts.map +1 -1
  46. package/dist/backends/kubernetes/objects.js +364 -2
  47. package/dist/backends/kubernetes/objects.js.map +1 -1
  48. package/dist/backends/kubernetes/per-sandbox-policy.d.ts +219 -0
  49. package/dist/backends/kubernetes/per-sandbox-policy.d.ts.map +1 -0
  50. package/dist/backends/kubernetes/per-sandbox-policy.js +407 -0
  51. package/dist/backends/kubernetes/per-sandbox-policy.js.map +1 -0
  52. package/dist/backends/kubernetes/rbac.d.ts +153 -0
  53. package/dist/backends/kubernetes/rbac.d.ts.map +1 -0
  54. package/dist/backends/kubernetes/rbac.js +177 -0
  55. package/dist/backends/kubernetes/rbac.js.map +1 -0
  56. package/dist/backends/kubernetes/sandbox.d.ts +81 -14
  57. package/dist/backends/kubernetes/sandbox.d.ts.map +1 -1
  58. package/dist/backends/kubernetes/sandbox.js +149 -15
  59. package/dist/backends/kubernetes/sandbox.js.map +1 -1
  60. package/dist/backends/kubernetes/transport.d.ts +935 -9
  61. package/dist/backends/kubernetes/transport.d.ts.map +1 -1
  62. package/dist/backends/kubernetes/transport.js +1958 -62
  63. package/dist/backends/kubernetes/transport.js.map +1 -1
  64. package/dist/backends/kubernetes/workspace.d.ts +1149 -18
  65. package/dist/backends/kubernetes/workspace.d.ts.map +1 -1
  66. package/dist/backends/kubernetes/workspace.js +2825 -186
  67. package/dist/backends/kubernetes/workspace.js.map +1 -1
  68. package/dist/backends/remote-execution-controller.d.ts +14 -0
  69. package/dist/backends/remote-execution-controller.d.ts.map +1 -1
  70. package/dist/backends/remote-execution-controller.js.map +1 -1
  71. package/dist/index.d.ts +231 -13
  72. package/dist/index.d.ts.map +1 -1
  73. package/dist/index.js +247 -5
  74. package/dist/index.js.map +1 -1
  75. package/dist/testing/sandbox-conformance.d.ts +39 -5
  76. package/dist/testing/sandbox-conformance.d.ts.map +1 -1
  77. package/dist/testing/sandbox-conformance.js +436 -5
  78. package/dist/testing/sandbox-conformance.js.map +1 -1
  79. package/package.json +3 -3
  80. package/src/backends/aci-standby-pool/index.ts +16 -1
  81. package/src/backends/docker/index.ts +22 -1
  82. package/src/backends/firecracker/index.ts +14 -2
  83. package/src/backends/firecracker/protocol.ts +514 -6
  84. package/src/backends/firecracker/transport.ts +1492 -40
  85. package/src/backends/kubernetes/egress-policy.ts +3064 -53
  86. package/src/backends/kubernetes/identity.ts +261 -0
  87. package/src/backends/kubernetes/index.ts +1785 -127
  88. package/src/backends/kubernetes/ingress-policy.ts +1344 -0
  89. package/src/backends/kubernetes/k8s-client.ts +444 -54
  90. package/src/backends/kubernetes/lease.ts +75 -19
  91. package/src/backends/kubernetes/objects.ts +626 -6
  92. package/src/backends/kubernetes/per-sandbox-policy.ts +542 -0
  93. package/src/backends/kubernetes/rbac.ts +192 -0
  94. package/src/backends/kubernetes/sandbox.ts +218 -20
  95. package/src/backends/kubernetes/transport.ts +2733 -124
  96. package/src/backends/kubernetes/workspace.ts +4476 -222
  97. package/src/backends/remote-execution-controller.ts +14 -0
  98. package/src/index.ts +595 -14
  99. package/src/testing/sandbox-conformance.ts +540 -5
@@ -0,0 +1,1050 @@
1
+ /**
2
+ * Ingress verification: refuse a sandbox whose agent port no applied policy
3
+ * closes.
4
+ *
5
+ * Sibling of `egress-policy.ts`, and deliberately shaped like its
6
+ * `verifyEgressPolicyApplied` half rather than its translation half — this
7
+ * module NEVER computes a manifest and never creates an object. Operators
8
+ * apply the boundary; this backend reads what the cluster actually holds and
9
+ * refuses when the boundary is not there. That is the same rule the egress
10
+ * path documents, for the same reason: the network boundary should be
11
+ * reviewed by whoever has cluster-admin, not written by whatever created the
12
+ * ServiceAccount token this backend runs with.
13
+ *
14
+ * ## Why this exists at all
15
+ *
16
+ * Two shipped comments call the ingress policy the boundary on the agent
17
+ * port — `workspace.ts`'s create path ("the NetworkPolicy rather than the
18
+ * bind token is the boundary on its agent port") and the guest agent's own
19
+ * source ("the network rule in front of the port is the boundary") — and
20
+ * until this module nothing checked one existed. The gap was not theoretical:
21
+ * measured on a managed cluster, sandbox pods this backend POSTed had
22
+ * enforcement on egress only, their agent port answered from every source
23
+ * tried (another namespace, another node, a host-network pod), and no request
24
+ * anywhere in the backend would have noticed.
25
+ *
26
+ * The reason it read as covered is worth keeping written down, because the
27
+ * manifests still carry the shape that produced it. A `SandboxTemplate`'s
28
+ * inline `networkPolicy` block is translated by the agent-sandbox controller
29
+ * into a policy selecting `agents.x-k8s.io/sandbox-template-ref-hash` — a
30
+ * label written only onto a Sandbox ADOPTED out of a `SandboxWarmPool`, never
31
+ * onto one this backend POSTs (see `objects.ts`'s
32
+ * {@link SANDBOX_TEMPLATE_LABEL_KEY} comment). Every workspace, and every
33
+ * pool-less task sandbox, is POSTed. So the policy that looked like coverage
34
+ * selected none of them, and the standalone manifest that DOES select them
35
+ * (`k8s/manifests/networkpolicy.yaml`, whose `podSelector` matches
36
+ * {@link SANDBOX_TEMPLATE_LABEL_KEY} by existence) was verified by nothing —
37
+ * an operator who skipped that one file got a silently open port.
38
+ *
39
+ * ## The decision, in one paragraph
40
+ *
41
+ * Policies UNION. Kubernetes admits a connection if ANY policy selecting the
42
+ * pod allows it, and a pod selected by no ingress-enforcing policy at all is
43
+ * allowed everything. So two things have to hold, not one: at least one
44
+ * policy that enforces ingress must select the pod, AND no policy selecting
45
+ * the pod may admit a wide-open peer on the agent port. One open rule opens
46
+ * the port however many closed ones sit beside it — that is the resource's
47
+ * semantics, not a heuristic this module chose.
48
+ *
49
+ * ## What it reads, and what it cannot see
50
+ *
51
+ * It LISTS the namespace's policies and evaluates their selectors against the
52
+ * pod's real labels. It never GETs a policy by name: an object with the right
53
+ * name proves the object exists, not that it selects this pod — a selector
54
+ * with a stale template value would pass a name check and cover nothing.
55
+ *
56
+ * Before the POST the only labels that exist are the ones the create body
57
+ * stamps; the agent-sandbox controller writes more of its own onto the object
58
+ * afterwards. For COVERAGE that is fail-closed — a policy selecting a
59
+ * controller-written label does not count, so the create is refused rather
60
+ * than admitted. For an OPENING it is not: a wide-open rule whose selector
61
+ * keys on such a label reads as not selecting this pod. The claim path has no
62
+ * such gap, because there the pod already exists and its own labels are read.
63
+ *
64
+ * What a namespaced Role cannot read is the honest limit, and it is a
65
+ * CONFIGURATION rather than a defect: a cluster-scoped policy (the clusterwide
66
+ * arm of the Cilium CRD is a different, cluster-scoped kind), a service mesh's
67
+ * own authorization layer, or a cloud-level security group can all close the
68
+ * port somewhere this check cannot look. Such a deployment sets
69
+ * `ingress: 'unverified'`, which reads no policy and issues no request. That
70
+ * is a supported configuration with a name, not a smell — what is NOT
71
+ * supported is a deployment that believes it is covered because nothing said
72
+ * otherwise.
73
+ *
74
+ * ## Why the refusal is on by default
75
+ *
76
+ * The operator who needs this check is precisely the one who does not know
77
+ * the port is open. An opt-in check would be read by the deployments that
78
+ * already closed the port and skipped by the ones that did not. Failing
79
+ * closed with a named opt-out is how the egress path already behaves — it
80
+ * refuses rather than degrades — and it is the only arrangement under which
81
+ * the measurement above turns into an error message instead of a quiet
82
+ * success.
83
+ */
84
+ import { KubernetesAlreadyGoneError, KubernetesCredentialError, } from './k8s-client.js';
85
+ import { SANDBOX_TEMPLATE_LABEL_KEY, ciliumNetworkPolicyCollectionPath, networkPolicyCollectionPath, } from './objects.js';
86
+ /** `true` when `ingress` is anything other than the `'unverified'` opt-out. */
87
+ export function ingressVerificationEnabled(ingress) {
88
+ return ingress !== 'unverified';
89
+ }
90
+ /**
91
+ * Which resources to enumerate, given both policy-shaped config fields.
92
+ *
93
+ * The default deliberately follows `config.egress.engine`: a deployment that
94
+ * already told this backend which policy engine its cluster runs should not
95
+ * have to say it twice, and the far more likely mistake is declaring it once
96
+ * and having the ingress check quietly read the wrong CRD.
97
+ */
98
+ export function resolveIngressEngine(ingress, egressEngine) {
99
+ if (ingress !== undefined && ingress !== 'unverified' && ingress.engine !== undefined) {
100
+ return ingress.engine;
101
+ }
102
+ return egressEngine ?? 'core';
103
+ }
104
+ /**
105
+ * The named refusal. Distinct from every other refusal this backend can
106
+ * raise on a create path, so a caller (or an operator reading a log line)
107
+ * can tell an unprotected agent port from a slow API server or an
108
+ * unenforceable egress policy without matching on a message.
109
+ *
110
+ * It carries the pod's labels and EVERY policy examined, with a verdict each,
111
+ * because that list is the operator's whole debugging session: the question
112
+ * "why does my policy not count?" is answered by the line that says it did
113
+ * not select these labels.
114
+ *
115
+ * Every sentence of the message is a claim the read actually supports. When a
116
+ * collection could not be enumerated it lands in {@link unread} and the
117
+ * message says so instead of describing a namespace nobody looked at — see
118
+ * {@link UnreadPolicySource}.
119
+ */
120
+ export class KubernetesIngressPolicyError extends Error {
121
+ refusal;
122
+ subject;
123
+ podLabels;
124
+ agentPort;
125
+ examined;
126
+ unread;
127
+ name = 'KubernetesIngressPolicyError';
128
+ constructor(refusal, subject, podLabels, agentPort, examined, summary,
129
+ /** Empty on every decision made from policies that WERE read. */
130
+ unread = []) {
131
+ super(`kubernetes: refusing ${subject} — ${summary} The pod's labels are ${formatLabels(podLabels)} and the agent port is TCP ${agentPort}. ${formatExamined(examined, unread)} Kubernetes UNIONS every policy selecting a pod, so the port is closed only when at least one ingress-enforcing policy selects it and none of them admits a wide-open peer on that port. ${formatRemedy(unread, examined)}`);
132
+ this.refusal = refusal;
133
+ this.subject = subject;
134
+ this.podLabels = podLabels;
135
+ this.agentPort = agentPort;
136
+ this.examined = examined;
137
+ this.unread = unread;
138
+ }
139
+ }
140
+ /**
141
+ * One label set, rendered for a refusal message. Exported for the egress
142
+ * union check's refusal, which has to render the identical thing.
143
+ */
144
+ export function formatLabels(labels) {
145
+ const entries = Object.entries(labels);
146
+ if (entries.length === 0)
147
+ return '(none)';
148
+ return entries
149
+ .map(([key, value]) => `${key}=${value}`)
150
+ .sort()
151
+ .join(', ');
152
+ }
153
+ /**
154
+ * The examined list, and — this is the whole point of the function — what an
155
+ * EMPTY one is allowed to say.
156
+ *
157
+ * "The namespace holds no policy" is a claim about the cluster, and only a
158
+ * list that came back empty supports it. A list that was refused or never
159
+ * served supports nothing at all, so `unread` and not the length picks the
160
+ * wording, and the kinds that went unread are named.
161
+ */
162
+ function formatExamined(examined, unread) {
163
+ let head;
164
+ if (examined.length > 0) {
165
+ head = `Policies examined: ${examined
166
+ .map((policy) => `${policy.kind}/${policy.name} [${policy.verdict}${policy.detail !== undefined ? `: ${policy.detail}` : ''}]`)
167
+ .join('; ')}.`;
168
+ }
169
+ else if (unread.length > 0) {
170
+ head =
171
+ 'Policies examined: none, and none could be read — nothing here is a claim about what this namespace holds.';
172
+ }
173
+ else {
174
+ head = 'Policies examined: (none — the namespace holds no policy of the kinds read).';
175
+ }
176
+ if (unread.length === 0)
177
+ return head;
178
+ return `${head} Not read: ${unread
179
+ .map((source) => `${source.resource} at ${source.path} (${source.reason})`)
180
+ .join('; ')}.`;
181
+ }
182
+ /**
183
+ * What the operator does next. It branches on {@link UnreadPolicySource}
184
+ * because "apply the missing policy" is the wrong instruction for a check that
185
+ * never got to look at one: the fix there is to make the list readable, or to
186
+ * declare that the boundary lives where this check cannot see it.
187
+ *
188
+ * It takes `examined` for the same reason {@link formatExamined} does, and it
189
+ * is the same claim: "this backend read no policy at all" is a sentence only
190
+ * an EMPTY examined list supports. One collection can go unread beside another
191
+ * that was read and named — a 404 on the Cilium CRD after the core list came
192
+ * back — and saying nothing was read three sentences after listing what was
193
+ * read is exactly the kind of unsupported sentence this module exists to
194
+ * delete.
195
+ */
196
+ function formatRemedy(unread, examined) {
197
+ const unverified = `If this deployment closes the port somewhere a namespaced Role cannot read — a cluster-scoped policy, a service mesh, a cloud security group — set ingress: 'unverified' on the backend config to say so explicitly; see docs/sdk/kubernetes-sandbox.md's ingress section.`;
198
+ if (unread.length === 0) {
199
+ return `This backend never creates the policy itself — apply k8s/manifests/networkpolicy.yaml (or your own equivalent selecting ${SANDBOX_TEMPLATE_LABEL_KEY}) and try again. ${unverified}`;
200
+ }
201
+ const actions = [];
202
+ const forbidden = unread.filter((source) => source.why === 'forbidden');
203
+ if (forbidden.length > 0) {
204
+ actions.push(`grant this ServiceAccount 'list' on ${forbidden
205
+ .map((source) => source.resource)
206
+ .join(' and ')} in this namespace, which k8s/manifests/rbac.yaml does`);
207
+ }
208
+ const absent = unread.filter((source) => source.why === 'absent');
209
+ if (absent.length > 0) {
210
+ actions.push(`point ingress.engine at a policy kind this cluster actually serves (${absent
211
+ .map((source) => source.resource)
212
+ .join(' and ')} answered as not served here)`);
213
+ }
214
+ const missing = unread.length === 1 ? 'one collection was not' : `${unread.length} collections were not`;
215
+ const preamble = examined.length === 0
216
+ ? 'This backend read no policy at all, so it can name none to fix:'
217
+ : `The policies named above were read; ${missing}, so what the rest of the cluster admits on the agent port is unknown:`;
218
+ return `${preamble} ${actions.join(', or ')}. ${unverified}`;
219
+ }
220
+ // ---------------------------------------------------------------------------
221
+ // Reading the wire.
222
+ //
223
+ // The shapes below are partial in the same way `objects.ts`'s are, and for the
224
+ // same reason: a full copy of two policy schemas would go stale on its own
225
+ // schedule. What they are NOT is a promise about what arrives — every reader
226
+ // below takes `unknown` and narrows, so the type declarations document the
227
+ // schema and the code cannot quietly assume it.
228
+ //
229
+ // The rule, and it holds with no exception: a field that is present as
230
+ // something other than what the schema declares contributes `'unknown'` —
231
+ // `not-evaluable`, a REFUSAL — and never a value in either direction. Reading
232
+ // an unreadable `spec.ingress` as "no rules" would report a policy as covering
233
+ // the agent port on the strength of a field nobody could read, which is the
234
+ // same shape of mistake as the shipped comments this module was written to
235
+ // delete.
236
+ //
237
+ // An ABSENT field is different from an unreadable one and each reader says
238
+ // what absent means there, because the resources themselves differ: an absent
239
+ // `ports` on an ingress rule means every port, an absent `policyTypes` is
240
+ // defaulted by the API server to include Ingress, and an absent `ingress` is
241
+ // no rules at all.
242
+ // ---------------------------------------------------------------------------
243
+ /**
244
+ * A JSON object, and not `null` and not an array.
245
+ *
246
+ * `typeof null === 'object'` and `typeof [] === 'object'` are the two ways a
247
+ * check meaning "is this an object" gets written and stays wrong.
248
+ */
249
+ // ---------------------------------------------------------------------------
250
+ // Shared with the egress direction
251
+ // ---------------------------------------------------------------------------
252
+ //
253
+ // The exported helpers from here to the end of "Selector evaluation", plus
254
+ // `listPolicies` in the I/O half below, are called by `egress-policy.ts`'s
255
+ // union check as well as by this module's own decision. There is ONE
256
+ // enumeration of the policies selecting a pod in this package and ONE reading
257
+ // of what a peer is, serving both directions: two would be two definitions of
258
+ // "wide open" and two ways to disagree with the cluster about which policies
259
+ // apply. The egress check asks a different QUESTION of the same material —
260
+ // "is this peer inside the configured translation" rather than "does this
261
+ // rule open the agent port" — and so keeps its own verdict types there.
262
+ export function isRecord(value) {
263
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
264
+ }
265
+ export function readList(value) {
266
+ if (value === undefined || value === null)
267
+ return undefined;
268
+ return Array.isArray(value) ? value : 'unreadable';
269
+ }
270
+ /** A policy's name, or a stable stand-in — never a non-string off the wire. */
271
+ export function policyName(item, index) {
272
+ const metadata = isRecord(item) ? item.metadata : undefined;
273
+ const name = isRecord(metadata) ? metadata.name : undefined;
274
+ return typeof name === 'string' && name !== '' ? name : `(unnamed #${index})`;
275
+ }
276
+ /**
277
+ * A policy that could not be read, as a document.
278
+ *
279
+ * {@link IngressPolicyDocument.unreadable} decides on its own, so the other
280
+ * fields are the inert ones: nothing downstream consults them.
281
+ */
282
+ function unreadablePolicy(kind, name, detail) {
283
+ return {
284
+ kind,
285
+ name,
286
+ selects: 'unknown',
287
+ enforcesIngress: false,
288
+ rules: [],
289
+ unreadable: detail,
290
+ };
291
+ }
292
+ // ---------------------------------------------------------------------------
293
+ // Selector evaluation
294
+ // ---------------------------------------------------------------------------
295
+ /**
296
+ * Is this a selector this check can read at all?
297
+ *
298
+ * An ABSENT selector is readable — the resource defines it as "everything".
299
+ * A present one that is not an object, or whose `matchLabels` /
300
+ * `matchExpressions` are not the shapes the API declares, is not, and every
301
+ * caller turns that into `'unknown'`. A selector that might match a pod might
302
+ * also be the one holding its port open, so neither "matches" nor "does not
303
+ * match" is available.
304
+ */
305
+ export function selectorIsReadable(selector) {
306
+ if (selector === undefined)
307
+ return true;
308
+ if (!isRecord(selector))
309
+ return false;
310
+ const matchLabels = selector.matchLabels;
311
+ if (matchLabels !== undefined) {
312
+ if (!isRecord(matchLabels))
313
+ return false;
314
+ for (const value of Object.values(matchLabels))
315
+ if (typeof value !== 'string')
316
+ return false;
317
+ }
318
+ const matchExpressions = selector.matchExpressions;
319
+ if (matchExpressions !== undefined && !Array.isArray(matchExpressions))
320
+ return false;
321
+ return true;
322
+ }
323
+ function selectorIsEmpty(selector) {
324
+ if (selector === undefined)
325
+ return true;
326
+ const labels = selector.matchLabels;
327
+ const expressions = selector.matchExpressions;
328
+ const hasLabels = labels !== undefined && Object.keys(labels).length > 0;
329
+ const hasExpressions = Array.isArray(expressions) && expressions.length > 0;
330
+ return !hasLabels && !hasExpressions;
331
+ }
332
+ /**
333
+ * A label selector against a known label set.
334
+ *
335
+ * An ABSENT or EMPTY selector matches everything — that is the resource's own
336
+ * default (`podSelector: {}` is how a `NetworkPolicy` selects every pod in
337
+ * its namespace), not a lenient reading.
338
+ *
339
+ * `normaliseKey` exists for the Cilium arm: that CRD's selectors carry a
340
+ * label SOURCE prefix (`k8s:app`, `any:app`), and an unprefixed key means
341
+ * `any:`. A prefix this module does not understand makes the whole selector
342
+ * `'unknown'` rather than "does not match", because a selector that might
343
+ * match a pod might also be the one holding its port open.
344
+ *
345
+ * Label values are read with `labelValue`, never by indexing: a label key is
346
+ * allowed to be spelled `constructor`, and a plain object would answer that
347
+ * one off `Object.prototype` — an `Exists` expression on it would then report
348
+ * a pod as selected by a policy that does not select it.
349
+ */
350
+ function labelValue(labels, key) {
351
+ return Object.hasOwn(labels, key) ? labels[key] : undefined;
352
+ }
353
+ export function matchesLabelSelector(selector, labels, normaliseKey = (key) => key) {
354
+ if (!selectorIsReadable(selector))
355
+ return 'unknown';
356
+ const readable = selector;
357
+ if (selectorIsEmpty(readable))
358
+ return 'yes';
359
+ for (const [rawKey, value] of Object.entries(readable?.matchLabels ?? {})) {
360
+ const key = normaliseKey(rawKey);
361
+ if (key === undefined)
362
+ return 'unknown';
363
+ if (labelValue(labels, key) !== value)
364
+ return 'no';
365
+ }
366
+ for (const expression of readable?.matchExpressions ?? []) {
367
+ if (!isRecord(expression))
368
+ return 'unknown';
369
+ const rawKey = expression.key;
370
+ if (typeof rawKey !== 'string' || rawKey === '')
371
+ return 'unknown';
372
+ const key = normaliseKey(rawKey);
373
+ if (key === undefined)
374
+ return 'unknown';
375
+ const actual = labelValue(labels, key);
376
+ const operator = expression.operator;
377
+ if (operator === 'Exists') {
378
+ if (actual === undefined)
379
+ return 'no';
380
+ continue;
381
+ }
382
+ if (operator === 'DoesNotExist') {
383
+ if (actual !== undefined)
384
+ return 'no';
385
+ continue;
386
+ }
387
+ if (operator !== 'In' && operator !== 'NotIn')
388
+ return 'unknown';
389
+ // `values` is what In and NotIn are ABOUT. Reading an unreadable one
390
+ // as the empty list would answer NotIn with "matches" — a pod
391
+ // reported as selected on the strength of a field nobody could read.
392
+ const values = readList(expression.values);
393
+ if (values === 'unreadable' || values === undefined)
394
+ return 'unknown';
395
+ if (operator === 'In') {
396
+ if (actual === undefined || !values.includes(actual))
397
+ return 'no';
398
+ continue;
399
+ }
400
+ // A pod that carries the key not at all satisfies NotIn, which is the
401
+ // API's own rule and the opposite of the intuitive read.
402
+ if (actual !== undefined && values.includes(actual))
403
+ return 'no';
404
+ }
405
+ return 'yes';
406
+ }
407
+ /**
408
+ * Strip the label SOURCE prefix off a Cilium selector key, or report that it
409
+ * is one this check cannot map onto a pod label.
410
+ *
411
+ * `k8s:` and `any:` both resolve to the pod's own labels; `reserved:` and the
412
+ * other sources name identities that are not pod labels at all, and guessing
413
+ * at one would be exactly the "trust the shape" mistake this module exists to
414
+ * avoid.
415
+ */
416
+ export function ciliumSelectorKey(key) {
417
+ const colon = key.indexOf(':');
418
+ if (colon < 0)
419
+ return key;
420
+ const source = key.slice(0, colon);
421
+ if (source === 'k8s' || source === 'any')
422
+ return key.slice(colon + 1);
423
+ return undefined;
424
+ }
425
+ /**
426
+ * The label set a Cilium selector is matched against: the pod's own labels
427
+ * plus the namespace, which that CRD's selectors name as
428
+ * `io.kubernetes.pod.namespace` and which every namespaced policy in the
429
+ * shipped examples carries.
430
+ */
431
+ export function ciliumIdentityLabels(podLabels, namespace) {
432
+ return { ...podLabels, 'io.kubernetes.pod.namespace': namespace };
433
+ }
434
+ // ---------------------------------------------------------------------------
435
+ // Port and peer evaluation
436
+ // ---------------------------------------------------------------------------
437
+ function coreRuleCoversPort(ports, agentPort) {
438
+ const entries = readList(ports);
439
+ if (entries === 'unreadable')
440
+ return 'unknown';
441
+ // Absent or empty `ports` on an ingress rule means EVERY port — the one
442
+ // shape most likely to be read as "no ports, so nothing".
443
+ if (entries === undefined || entries.length === 0)
444
+ return true;
445
+ let unknown = false;
446
+ for (const entry of entries) {
447
+ if (!isRecord(entry)) {
448
+ unknown = true;
449
+ continue;
450
+ }
451
+ const rawProtocol = entry.protocol;
452
+ if (rawProtocol !== undefined && typeof rawProtocol !== 'string') {
453
+ unknown = true;
454
+ continue;
455
+ }
456
+ if ((rawProtocol ?? 'TCP') !== 'TCP')
457
+ continue;
458
+ const endPort = entry.endPort;
459
+ if (endPort !== undefined && typeof endPort !== 'number') {
460
+ unknown = true;
461
+ continue;
462
+ }
463
+ const port = entry.port;
464
+ if (port === undefined)
465
+ return true;
466
+ const spansAgentPort = (start) => typeof endPort === 'number' && agentPort > start && agentPort <= endPort;
467
+ if (typeof port === 'number') {
468
+ if (port === agentPort || spansAgentPort(port))
469
+ return true;
470
+ continue;
471
+ }
472
+ if (typeof port === 'string') {
473
+ const parsed = Number(port);
474
+ if (Number.isInteger(parsed) && String(parsed) === port.trim()) {
475
+ if (parsed === agentPort || spansAgentPort(parsed))
476
+ return true;
477
+ continue;
478
+ }
479
+ // A NAMED container port. Resolving it needs the pod's own
480
+ // container spec, which this check does not have on every path
481
+ // (a claimed pool sandbox's spec is the pool's), so it is
482
+ // reported rather than assumed either way.
483
+ unknown = true;
484
+ continue;
485
+ }
486
+ unknown = true;
487
+ }
488
+ return unknown ? 'unknown' : false;
489
+ }
490
+ const WIDE_OPEN_CIDRS = new Set(['0.0.0.0/0', '::/0']);
491
+ export function corePeerIsWideOpen(peer) {
492
+ if (!isRecord(peer))
493
+ return 'unknown';
494
+ const { podSelector, namespaceSelector, ipBlock } = peer;
495
+ if (ipBlock !== undefined) {
496
+ if (!isRecord(ipBlock))
497
+ return 'unknown';
498
+ const cidr = ipBlock.cidr;
499
+ if (typeof cidr !== 'string')
500
+ return 'unknown';
501
+ // An `except` list carves a few addresses out of the whole internet
502
+ // and leaves the rest of it admitted, so it does not narrow this to
503
+ // anything worth calling closed.
504
+ return WIDE_OPEN_CIDRS.has(cidr.trim());
505
+ }
506
+ if (!selectorIsReadable(podSelector) || !selectorIsReadable(namespaceSelector))
507
+ return 'unknown';
508
+ if (namespaceSelector !== undefined) {
509
+ // `namespaceSelector: {}` is every namespace. Paired with a
510
+ // non-empty podSelector it is still a real constraint (that pod
511
+ // label, anywhere), so only the doubly-empty form is wide open.
512
+ return (selectorIsEmpty(namespaceSelector) &&
513
+ selectorIsEmpty(podSelector));
514
+ }
515
+ if (podSelector !== undefined) {
516
+ // Every pod in the policy's own namespace. Broad, and deliberately
517
+ // NOT called wide open: naming it so would refuse the legitimate
518
+ // "the host runs beside its sandboxes" deployment, and an evaluator
519
+ // that fires on a correct policy is one an operator switches off.
520
+ return false;
521
+ }
522
+ // A peer naming none of the three constrains nothing. The API server
523
+ // rejects it on admission, so reaching here means the object did not come
524
+ // from one.
525
+ return 'unknown';
526
+ }
527
+ function coreRuleVerdict(rule, agentPort) {
528
+ if (!isRecord(rule)) {
529
+ return { open: 'unknown', detail: 'an ingress rule that is not an object' };
530
+ }
531
+ const covers = coreRuleCoversPort(rule.ports, agentPort);
532
+ if (covers === 'unknown') {
533
+ return {
534
+ open: 'unknown',
535
+ detail: `a named port this check cannot resolve to TCP ${agentPort}, or a ports entry it cannot read`,
536
+ };
537
+ }
538
+ if (covers === false)
539
+ return { open: false };
540
+ const from = readList(rule.from);
541
+ if (from === 'unreadable') {
542
+ return { open: 'unknown', detail: "a 'from' that is not a list of peers" };
543
+ }
544
+ if (from === undefined || from.length === 0) {
545
+ // No `from` on an ingress rule means EVERY source.
546
+ return {
547
+ open: true,
548
+ detail: `no 'from' peers, so every source reaches TCP ${agentPort}`,
549
+ };
550
+ }
551
+ let unknown;
552
+ for (const peer of from) {
553
+ const open = corePeerIsWideOpen(peer);
554
+ if (open === true) {
555
+ return {
556
+ open: true,
557
+ detail: `a wide-open 'from' peer (${JSON.stringify(peer)}) on TCP ${agentPort}`,
558
+ };
559
+ }
560
+ if (open === 'unknown')
561
+ unknown ??= `a 'from' peer this check cannot read (${JSON.stringify(peer)})`;
562
+ }
563
+ if (unknown !== undefined)
564
+ return { open: 'unknown', detail: unknown };
565
+ return { open: false };
566
+ }
567
+ /** `all`, `cluster` and `world` each admit a peer set no host selector bounds. */
568
+ const WIDE_OPEN_ENTITIES = new Set(['all', 'cluster', 'world']);
569
+ function ciliumRuleCoversPort(rule, agentPort) {
570
+ const toPorts = readList(rule.toPorts);
571
+ if (toPorts === 'unreadable')
572
+ return 'unknown';
573
+ if (toPorts === undefined || toPorts.length === 0)
574
+ return true;
575
+ let unknown = false;
576
+ for (const entry of toPorts) {
577
+ if (!isRecord(entry)) {
578
+ unknown = true;
579
+ continue;
580
+ }
581
+ const ports = readList(entry.ports);
582
+ if (ports === 'unreadable') {
583
+ unknown = true;
584
+ continue;
585
+ }
586
+ if (ports === undefined || ports.length === 0)
587
+ return true;
588
+ for (const port of ports) {
589
+ if (!isRecord(port)) {
590
+ unknown = true;
591
+ continue;
592
+ }
593
+ const rawProtocol = port.protocol;
594
+ if (rawProtocol !== undefined && typeof rawProtocol !== 'string') {
595
+ unknown = true;
596
+ continue;
597
+ }
598
+ const protocol = (rawProtocol ?? 'ANY').toUpperCase();
599
+ if (protocol !== 'TCP' && protocol !== 'ANY')
600
+ continue;
601
+ const endPort = port.endPort;
602
+ if (endPort !== undefined && typeof endPort !== 'number') {
603
+ unknown = true;
604
+ continue;
605
+ }
606
+ const raw = port.port;
607
+ if (raw === undefined)
608
+ return true;
609
+ if (typeof raw !== 'number' && typeof raw !== 'string') {
610
+ unknown = true;
611
+ continue;
612
+ }
613
+ const parsed = typeof raw === 'number' ? raw : Number(raw);
614
+ if (!Number.isInteger(parsed)) {
615
+ unknown = true;
616
+ continue;
617
+ }
618
+ if (parsed === agentPort)
619
+ return true;
620
+ if (typeof endPort === 'number' && agentPort > parsed && agentPort <= endPort)
621
+ return true;
622
+ }
623
+ }
624
+ return unknown ? 'unknown' : false;
625
+ }
626
+ /** Every `from…` field the CRD declares. A rule naming none of them is port-only. */
627
+ const CILIUM_SOURCE_FIELDS = [
628
+ 'fromEndpoints',
629
+ 'fromEntities',
630
+ 'fromCIDR',
631
+ 'fromCIDRSet',
632
+ 'fromNodes',
633
+ 'fromGroups',
634
+ ];
635
+ function ciliumRuleVerdict(rule, agentPort) {
636
+ if (!isRecord(rule)) {
637
+ return { open: 'unknown', detail: 'an ingress rule that is not an object' };
638
+ }
639
+ const covers = ciliumRuleCoversPort(rule, agentPort);
640
+ if (covers === 'unknown') {
641
+ return {
642
+ open: 'unknown',
643
+ detail: `a toPorts entry this check cannot read against TCP ${agentPort}`,
644
+ };
645
+ }
646
+ if (covers === false)
647
+ return { open: false };
648
+ // Every source field is read BEFORE any of them is judged: one that is
649
+ // present as something other than a list could be the wide-open one, and
650
+ // skipping it would let the rule read as narrow on the strength of the
651
+ // fields that happened to parse.
652
+ const sources = new Map();
653
+ for (const field of CILIUM_SOURCE_FIELDS) {
654
+ const list = readList(rule[field]);
655
+ if (list === 'unreadable') {
656
+ return {
657
+ open: 'unknown',
658
+ detail: `a ${field} that is not a list of peers`,
659
+ };
660
+ }
661
+ if (list !== undefined)
662
+ sources.set(field, list);
663
+ }
664
+ for (const entity of sources.get('fromEntities') ?? []) {
665
+ if (typeof entity !== 'string') {
666
+ return {
667
+ open: 'unknown',
668
+ detail: 'a fromEntities entry that is not an entity name',
669
+ };
670
+ }
671
+ if (WIDE_OPEN_ENTITIES.has(entity)) {
672
+ return {
673
+ open: true,
674
+ detail: `fromEntities includes '${entity}' on TCP ${agentPort}`,
675
+ };
676
+ }
677
+ }
678
+ for (const cidr of sources.get('fromCIDR') ?? []) {
679
+ if (typeof cidr !== 'string') {
680
+ return {
681
+ open: 'unknown',
682
+ detail: 'a fromCIDR entry that is not a CIDR string',
683
+ };
684
+ }
685
+ if (WIDE_OPEN_CIDRS.has(cidr.trim())) {
686
+ return {
687
+ open: true,
688
+ detail: `fromCIDR includes ${cidr} on TCP ${agentPort}`,
689
+ };
690
+ }
691
+ }
692
+ for (const entry of sources.get('fromCIDRSet') ?? []) {
693
+ if (!isRecord(entry)) {
694
+ return {
695
+ open: 'unknown',
696
+ detail: 'a fromCIDRSet entry that is not an object',
697
+ };
698
+ }
699
+ const cidr = entry.cidr;
700
+ if (cidr !== undefined && typeof cidr !== 'string') {
701
+ return {
702
+ open: 'unknown',
703
+ detail: 'a fromCIDRSet cidr that is not a CIDR string',
704
+ };
705
+ }
706
+ if (typeof cidr === 'string' && WIDE_OPEN_CIDRS.has(cidr.trim())) {
707
+ return {
708
+ open: true,
709
+ detail: `fromCIDRSet includes ${cidr} on TCP ${agentPort}`,
710
+ };
711
+ }
712
+ }
713
+ for (const field of ['fromEndpoints', 'fromNodes']) {
714
+ for (const selector of sources.get(field) ?? []) {
715
+ if (!selectorIsReadable(selector)) {
716
+ return {
717
+ open: 'unknown',
718
+ detail: `a ${field} entry this check cannot read as a selector`,
719
+ };
720
+ }
721
+ }
722
+ }
723
+ const hasSource = CILIUM_SOURCE_FIELDS.some((field) => (sources.get(field)?.length ?? 0) > 0);
724
+ if (!hasSource) {
725
+ // A port-only ingress rule admits every source on those ports — the
726
+ // same shape as core's absent `from`, spelled differently.
727
+ return {
728
+ open: true,
729
+ detail: `a port-only ingress rule, so every source reaches TCP ${agentPort}`,
730
+ };
731
+ }
732
+ return { open: false };
733
+ }
734
+ // ---------------------------------------------------------------------------
735
+ // Normalisation
736
+ // ---------------------------------------------------------------------------
737
+ /** Every core `NetworkPolicy` in the list, reduced to {@link IngressPolicyDocument}. */
738
+ export function readCoreIngressPolicies(items, target) {
739
+ return items.map((item, index) => {
740
+ const name = policyName(item, index);
741
+ if (!isRecord(item)) {
742
+ return unreadablePolicy('NetworkPolicy', name, 'a list entry that is not a policy object');
743
+ }
744
+ const spec = item.spec;
745
+ if (!isRecord(spec)) {
746
+ return unreadablePolicy('NetworkPolicy', name, 'a spec that is not an object');
747
+ }
748
+ // An absent `policyTypes` is defaulted by the API server, and its
749
+ // default ALWAYS includes Ingress. Only an explicit list that leaves
750
+ // it out turns ingress enforcement off — and a `policyTypes` that is
751
+ // not a list says nothing about either.
752
+ const policyTypes = readList(spec.policyTypes);
753
+ if (policyTypes === 'unreadable') {
754
+ return unreadablePolicy('NetworkPolicy', name, 'a spec.policyTypes that is not a list');
755
+ }
756
+ const rules = readList(spec.ingress);
757
+ if (rules === 'unreadable') {
758
+ return unreadablePolicy('NetworkPolicy', name, 'a spec.ingress that is not a list of rules');
759
+ }
760
+ const enforcesIngress = policyTypes === undefined || policyTypes.includes('Ingress');
761
+ return {
762
+ kind: 'NetworkPolicy',
763
+ name,
764
+ selects: matchesLabelSelector(spec.podSelector, target.podLabels),
765
+ enforcesIngress,
766
+ // An `ingress` block under a `policyTypes` that leaves Ingress out
767
+ // is ignored by the API server itself, so it neither covers nor
768
+ // opens — it is not a rule of this cluster at all.
769
+ rules: enforcesIngress
770
+ ? (rules ?? []).map((rule) => coreRuleVerdict(rule, target.agentPort))
771
+ : [],
772
+ };
773
+ });
774
+ }
775
+ /** Every `CiliumNetworkPolicy` in the list, reduced the same way. */
776
+ export function readCiliumIngressPolicies(items, target) {
777
+ const identity = ciliumIdentityLabels(target.podLabels, target.namespace);
778
+ const documents = [];
779
+ for (const [index, item] of items.entries()) {
780
+ const name = policyName(item, index);
781
+ if (!isRecord(item)) {
782
+ documents.push(unreadablePolicy('CiliumNetworkPolicy', name, 'a list entry that is not a policy object'));
783
+ continue;
784
+ }
785
+ // The CRD carries EITHER one `spec` or a `specs` list, and a rule in
786
+ // either enforces. Reading only `spec` would miss a whole policy.
787
+ const specs = [];
788
+ if (item.spec !== undefined && item.spec !== null)
789
+ specs.push(item.spec);
790
+ const more = readList(item.specs);
791
+ if (more === 'unreadable') {
792
+ documents.push(unreadablePolicy('CiliumNetworkPolicy', name, 'a specs that is not a list of rule specs'));
793
+ continue;
794
+ }
795
+ for (const spec of more ?? [])
796
+ specs.push(spec);
797
+ if (specs.length === 0) {
798
+ documents.push(unreadablePolicy('CiliumNetworkPolicy', name, 'neither a spec nor a specs list'));
799
+ continue;
800
+ }
801
+ for (const spec of specs) {
802
+ const document = readCiliumRuleSpec(spec, name, identity, target.agentPort);
803
+ if (document !== undefined)
804
+ documents.push(document);
805
+ }
806
+ }
807
+ return documents;
808
+ }
809
+ /** One `spec`/`specs` entry. `undefined` when it is node-scoped — see below. */
810
+ function readCiliumRuleSpec(spec, name, identity, agentPort) {
811
+ const unreadable = (detail) => unreadablePolicy('CiliumNetworkPolicy', name, detail);
812
+ if (!isRecord(spec))
813
+ return unreadable('a rule spec that is not an object');
814
+ // A node-scoped rule selects nodes, never pods; it can neither cover nor
815
+ // open a pod's port.
816
+ if (spec.nodeSelector !== undefined)
817
+ return undefined;
818
+ const rules = readList(spec.ingress);
819
+ if (rules === 'unreadable') {
820
+ return unreadable('a spec.ingress that is not a list of rules');
821
+ }
822
+ const ingressDeny = readList(spec.ingressDeny);
823
+ if (ingressDeny === 'unreadable')
824
+ return unreadable('a spec.ingressDeny that is not a list of rules');
825
+ let enforcesIngress = rules !== undefined || ingressDeny !== undefined;
826
+ // Cilium 1.16 and later: `enableDefaultDeny.ingress: false` makes a rule
827
+ // ALLOW without putting the endpoint into ingress default-deny, so it
828
+ // closes nothing — the "looked like coverage and was not" shape of this
829
+ // whole module, one CRD version later. Its own rules are still evaluated,
830
+ // because what it admits it still admits; it simply cannot count as the
831
+ // policy that covers the port.
832
+ const enableDefaultDeny = spec.enableDefaultDeny;
833
+ if (enableDefaultDeny !== undefined) {
834
+ if (!isRecord(enableDefaultDeny)) {
835
+ return unreadable('an enableDefaultDeny that is not an object');
836
+ }
837
+ const forIngress = enableDefaultDeny.ingress;
838
+ if (forIngress !== undefined && typeof forIngress !== 'boolean') {
839
+ return unreadable('an enableDefaultDeny.ingress that is not a boolean');
840
+ }
841
+ if (forIngress === false)
842
+ enforcesIngress = false;
843
+ }
844
+ return {
845
+ kind: 'CiliumNetworkPolicy',
846
+ name,
847
+ selects: matchesLabelSelector(spec.endpointSelector, identity, ciliumSelectorKey),
848
+ enforcesIngress,
849
+ rules: (rules ?? []).map((rule) => ciliumRuleVerdict(rule, agentPort)),
850
+ };
851
+ }
852
+ /**
853
+ * The union rule, applied. Pure — no I/O, no client, no clock — so every
854
+ * shape that has to be refused can be asserted one per test, which is how
855
+ * the "admits any peer" reading stays a rule rather than a heuristic.
856
+ */
857
+ export function decideIngressCoverage(documents, target) {
858
+ const examined = [];
859
+ let covering = 0;
860
+ let open;
861
+ let undecided;
862
+ for (const document of documents) {
863
+ const base = { kind: document.kind, name: document.name };
864
+ if (document.unreadable !== undefined) {
865
+ const entry = {
866
+ ...base,
867
+ verdict: 'not-evaluable',
868
+ detail: document.unreadable,
869
+ };
870
+ examined.push(entry);
871
+ undecided ??= entry;
872
+ continue;
873
+ }
874
+ if (document.selects === 'no') {
875
+ examined.push({ ...base, verdict: 'does-not-select' });
876
+ continue;
877
+ }
878
+ if (document.selects === 'unknown') {
879
+ const entry = {
880
+ ...base,
881
+ verdict: 'not-evaluable',
882
+ detail: 'its selector uses something this check cannot evaluate against pod labels',
883
+ };
884
+ examined.push(entry);
885
+ undecided ??= entry;
886
+ continue;
887
+ }
888
+ // Every RULE is read before the enforcement question is asked, because a
889
+ // policy can admit a peer without default-denying anything — a Cilium
890
+ // rule with `enableDefaultDeny.ingress: false` is exactly that, and what
891
+ // it admits is admitted for real once something else default-denies the
892
+ // endpoint. Both halves of reading a rule move together: a rule that
893
+ // opens the port is the finding, and a rule nobody can read is a rule
894
+ // that MIGHT open it, so neither may sit behind the enforcement gate. A
895
+ // policy whose rules do not apply at all carries none of them, so this
896
+ // asks nothing of those.
897
+ const openRule = document.rules.find((rule) => rule.open === true);
898
+ if (openRule !== undefined) {
899
+ const entry = {
900
+ ...base,
901
+ verdict: 'opens-agent-port',
902
+ ...(openRule.detail !== undefined ? { detail: openRule.detail } : {}),
903
+ };
904
+ examined.push(entry);
905
+ open ??= entry;
906
+ continue;
907
+ }
908
+ const unknownRule = document.rules.find((rule) => rule.open === 'unknown');
909
+ if (unknownRule !== undefined) {
910
+ const entry = {
911
+ ...base,
912
+ verdict: 'not-evaluable',
913
+ ...(unknownRule.detail !== undefined ? { detail: unknownRule.detail } : {}),
914
+ };
915
+ examined.push(entry);
916
+ undecided ??= entry;
917
+ continue;
918
+ }
919
+ if (!document.enforcesIngress) {
920
+ examined.push({
921
+ ...base,
922
+ verdict: 'not-ingress-scoped',
923
+ detail: 'it selects the pod but default-denies nothing on ingress',
924
+ });
925
+ continue;
926
+ }
927
+ examined.push({ ...base, verdict: 'covers' });
928
+ covering += 1;
929
+ }
930
+ // Ordered by what an operator has to do first. A policy standing the door
931
+ // open is the finding even when another one closes it, because the union
932
+ // means the open one wins on the wire.
933
+ if (open !== undefined) {
934
+ return {
935
+ examined,
936
+ refusal: {
937
+ kind: 'port-open',
938
+ summary: `${open.kind}/${open.name} selects this pod and admits ${open.detail ?? `a wide-open peer on TCP ${target.agentPort}`}.`,
939
+ },
940
+ };
941
+ }
942
+ if (undecided !== undefined) {
943
+ return {
944
+ examined,
945
+ refusal: {
946
+ kind: 'not-evaluable',
947
+ summary: `${undecided.kind}/${undecided.name} contains ${undecided.detail ?? 'something this check cannot evaluate'}, so whether the agent port is closed cannot be decided from the cluster's own objects.`,
948
+ },
949
+ };
950
+ }
951
+ if (covering === 0) {
952
+ return {
953
+ examined,
954
+ refusal: {
955
+ kind: 'no-covering-policy',
956
+ summary: 'no applied policy enforces ingress on this pod, so every pod in the cluster can reach its agent port.',
957
+ },
958
+ };
959
+ }
960
+ return { examined };
961
+ }
962
+ // ---------------------------------------------------------------------------
963
+ // The I/O half
964
+ // ---------------------------------------------------------------------------
965
+ /**
966
+ * Internal: a collection that could not be enumerated. It carries what the
967
+ * refusal needs and never escapes this module — {@link verifyIngressPolicyApplied}
968
+ * turns it into a {@link KubernetesIngressPolicyError} that also reports
969
+ * whatever WAS read before it.
970
+ */
971
+ export class UnreadPolicyCollection extends Error {
972
+ source;
973
+ summary;
974
+ constructor(source, summary) {
975
+ super(summary);
976
+ this.source = source;
977
+ this.summary = summary;
978
+ }
979
+ }
980
+ /**
981
+ * Enumerate one policy collection, or report why it could not be read.
982
+ *
983
+ * Exported: `egress-policy.ts`'s union check lists exactly these two
984
+ * collections for exactly this reason, and a second enumerator would be a
985
+ * second answer to "which policies apply to this pod".
986
+ */
987
+ export async function listPolicies(client, path, resource, signal) {
988
+ // No `limit` is sent, and the API server truncates a collection only when
989
+ // one is — so this is the whole list, not a page of it. That matters more
990
+ // here than anywhere else in this backend: a truncated list could hide the
991
+ // one policy holding the port open.
992
+ try {
993
+ const list = await client.request('GET', path, undefined, signal);
994
+ return Array.isArray(list?.items) ? list.items : [];
995
+ }
996
+ catch (err) {
997
+ // A 404 on a COLLECTION means the resource itself is not served here.
998
+ // For the CRD that is a declared engine the cluster does not have; for
999
+ // core `networkpolicies`, which every API server serves, it is an
1000
+ // address that is not this cluster's. Either way the answer is "the
1001
+ // boundary could not be read", never "there are no policies".
1002
+ if (err instanceof KubernetesAlreadyGoneError) {
1003
+ throw new UnreadPolicyCollection({
1004
+ resource,
1005
+ path,
1006
+ why: 'absent',
1007
+ reason: 'the API server served no such collection',
1008
+ }, resource === 'ciliumnetworkpolicies'
1009
+ ? `this backend is configured with ingress.engine: 'cilium' but the cluster serves no ${resource} resource at ${path}.`
1010
+ : `the cluster served no ${resource} collection at ${path}, which every Kubernetes API server is supposed to serve.`);
1011
+ }
1012
+ if (err instanceof KubernetesCredentialError) {
1013
+ throw new UnreadPolicyCollection({ resource, path, why: 'forbidden', reason: err.message }, `the ServiceAccount this backend runs as may not 'list' ${resource} (${err.message}), so what the cluster admits on the agent port cannot be read.`);
1014
+ }
1015
+ throw err;
1016
+ }
1017
+ }
1018
+ /**
1019
+ * Verify-not-trust for ingress: list the namespace's policies, evaluate them
1020
+ * against the pod's real labels, and refuse unless the agent port is closed.
1021
+ *
1022
+ * Called BEFORE the POST on every path that creates a Sandbox, so a refusal
1023
+ * leaves no Sandbox and no PVC behind — and, on the claim path, after the
1024
+ * bind, where a refusal releases the claim through the acquire path's own
1025
+ * cleanup.
1026
+ */
1027
+ export async function verifyIngressPolicyApplied(client, target, signal) {
1028
+ const documents = [];
1029
+ try {
1030
+ documents.push(...readCoreIngressPolicies(await listPolicies(client, networkPolicyCollectionPath(target.namespace), 'networkpolicies', signal), target));
1031
+ if (target.engine === 'cilium') {
1032
+ documents.push(...readCiliumIngressPolicies(await listPolicies(client, ciliumNetworkPolicyCollectionPath(target.namespace), 'ciliumnetworkpolicies', signal), target));
1033
+ }
1034
+ }
1035
+ catch (err) {
1036
+ if (!(err instanceof UnreadPolicyCollection))
1037
+ throw err;
1038
+ // Whatever WAS read is still reported, with a verdict each: on the
1039
+ // cilium arm the core list has usually already been enumerated, and a
1040
+ // refusal that dropped it would tell the operator less than this
1041
+ // check actually knows. What it must not do is describe the list it
1042
+ // never got — hence `unread`.
1043
+ throw new KubernetesIngressPolicyError('not-evaluable', target.subject, target.podLabels, target.agentPort, decideIngressCoverage(documents, target).examined, err.summary, [err.source]);
1044
+ }
1045
+ const decision = decideIngressCoverage(documents, target);
1046
+ if (decision.refusal === undefined)
1047
+ return;
1048
+ throw new KubernetesIngressPolicyError(decision.refusal.kind, target.subject, target.podLabels, target.agentPort, decision.examined, decision.refusal.summary);
1049
+ }
1050
+ //# sourceMappingURL=ingress-policy.js.map