@namzu/sandbox 14.0.0 → 16.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 (100) hide show
  1. package/CHANGELOG.md +924 -0
  2. package/README.md +369 -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 +169 -6
  7. package/dist/backends/docker/index.d.ts.map +1 -1
  8. package/dist/backends/docker/index.js +499 -85
  9. package/dist/backends/docker/index.js.map +1 -1
  10. package/dist/backends/firecracker/index.d.ts.map +1 -1
  11. package/dist/backends/firecracker/index.js +12 -2
  12. package/dist/backends/firecracker/index.js.map +1 -1
  13. package/dist/backends/firecracker/protocol.d.ts +459 -8
  14. package/dist/backends/firecracker/protocol.d.ts.map +1 -1
  15. package/dist/backends/firecracker/protocol.js +136 -0
  16. package/dist/backends/firecracker/protocol.js.map +1 -1
  17. package/dist/backends/firecracker/transport.d.ts +539 -6
  18. package/dist/backends/firecracker/transport.d.ts.map +1 -1
  19. package/dist/backends/firecracker/transport.js +1171 -24
  20. package/dist/backends/firecracker/transport.js.map +1 -1
  21. package/dist/backends/kubernetes/egress-policy.d.ts +1181 -13
  22. package/dist/backends/kubernetes/egress-policy.d.ts.map +1 -1
  23. package/dist/backends/kubernetes/egress-policy.js +2350 -31
  24. package/dist/backends/kubernetes/egress-policy.js.map +1 -1
  25. package/dist/backends/kubernetes/identity.d.ts +193 -0
  26. package/dist/backends/kubernetes/identity.d.ts.map +1 -0
  27. package/dist/backends/kubernetes/identity.js +147 -0
  28. package/dist/backends/kubernetes/identity.js.map +1 -0
  29. package/dist/backends/kubernetes/index.d.ts +678 -33
  30. package/dist/backends/kubernetes/index.d.ts.map +1 -1
  31. package/dist/backends/kubernetes/index.js +1180 -95
  32. package/dist/backends/kubernetes/index.js.map +1 -1
  33. package/dist/backends/kubernetes/ingress-policy.d.ts +375 -0
  34. package/dist/backends/kubernetes/ingress-policy.d.ts.map +1 -0
  35. package/dist/backends/kubernetes/ingress-policy.js +1050 -0
  36. package/dist/backends/kubernetes/ingress-policy.js.map +1 -0
  37. package/dist/backends/kubernetes/k8s-client.d.ts +213 -4
  38. package/dist/backends/kubernetes/k8s-client.d.ts.map +1 -1
  39. package/dist/backends/kubernetes/k8s-client.js +359 -52
  40. package/dist/backends/kubernetes/k8s-client.js.map +1 -1
  41. package/dist/backends/kubernetes/lease.d.ts +40 -14
  42. package/dist/backends/kubernetes/lease.d.ts.map +1 -1
  43. package/dist/backends/kubernetes/lease.js +68 -18
  44. package/dist/backends/kubernetes/lease.js.map +1 -1
  45. package/dist/backends/kubernetes/objects.d.ts +423 -3
  46. package/dist/backends/kubernetes/objects.d.ts.map +1 -1
  47. package/dist/backends/kubernetes/objects.js +364 -2
  48. package/dist/backends/kubernetes/objects.js.map +1 -1
  49. package/dist/backends/kubernetes/per-sandbox-policy.d.ts +219 -0
  50. package/dist/backends/kubernetes/per-sandbox-policy.d.ts.map +1 -0
  51. package/dist/backends/kubernetes/per-sandbox-policy.js +375 -0
  52. package/dist/backends/kubernetes/per-sandbox-policy.js.map +1 -0
  53. package/dist/backends/kubernetes/rbac.d.ts +153 -0
  54. package/dist/backends/kubernetes/rbac.d.ts.map +1 -0
  55. package/dist/backends/kubernetes/rbac.js +177 -0
  56. package/dist/backends/kubernetes/rbac.js.map +1 -0
  57. package/dist/backends/kubernetes/sandbox.d.ts +81 -14
  58. package/dist/backends/kubernetes/sandbox.d.ts.map +1 -1
  59. package/dist/backends/kubernetes/sandbox.js +149 -15
  60. package/dist/backends/kubernetes/sandbox.js.map +1 -1
  61. package/dist/backends/kubernetes/transport.d.ts +935 -9
  62. package/dist/backends/kubernetes/transport.d.ts.map +1 -1
  63. package/dist/backends/kubernetes/transport.js +1958 -62
  64. package/dist/backends/kubernetes/transport.js.map +1 -1
  65. package/dist/backends/kubernetes/workspace.d.ts +1149 -18
  66. package/dist/backends/kubernetes/workspace.d.ts.map +1 -1
  67. package/dist/backends/kubernetes/workspace.js +2825 -186
  68. package/dist/backends/kubernetes/workspace.js.map +1 -1
  69. package/dist/backends/remote-execution-controller.d.ts +14 -0
  70. package/dist/backends/remote-execution-controller.d.ts.map +1 -1
  71. package/dist/backends/remote-execution-controller.js.map +1 -1
  72. package/dist/index.d.ts +294 -18
  73. package/dist/index.d.ts.map +1 -1
  74. package/dist/index.js +280 -10
  75. package/dist/index.js.map +1 -1
  76. package/dist/testing/sandbox-conformance.d.ts +39 -5
  77. package/dist/testing/sandbox-conformance.d.ts.map +1 -1
  78. package/dist/testing/sandbox-conformance.js +436 -5
  79. package/dist/testing/sandbox-conformance.js.map +1 -1
  80. package/package.json +3 -3
  81. package/src/backends/aci-standby-pool/index.ts +16 -1
  82. package/src/backends/docker/index.ts +617 -100
  83. package/src/backends/firecracker/index.ts +14 -2
  84. package/src/backends/firecracker/protocol.ts +514 -6
  85. package/src/backends/firecracker/transport.ts +1492 -40
  86. package/src/backends/kubernetes/egress-policy.ts +3334 -55
  87. package/src/backends/kubernetes/identity.ts +261 -0
  88. package/src/backends/kubernetes/index.ts +1785 -127
  89. package/src/backends/kubernetes/ingress-policy.ts +1344 -0
  90. package/src/backends/kubernetes/k8s-client.ts +444 -54
  91. package/src/backends/kubernetes/lease.ts +75 -19
  92. package/src/backends/kubernetes/objects.ts +626 -6
  93. package/src/backends/kubernetes/per-sandbox-policy.ts +497 -0
  94. package/src/backends/kubernetes/rbac.ts +192 -0
  95. package/src/backends/kubernetes/sandbox.ts +218 -20
  96. package/src/backends/kubernetes/transport.ts +2733 -124
  97. package/src/backends/kubernetes/workspace.ts +4476 -222
  98. package/src/backends/remote-execution-controller.ts +14 -0
  99. package/src/index.ts +668 -19
  100. package/src/testing/sandbox-conformance.ts +540 -5
@@ -0,0 +1,1344 @@
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
+
85
+ import {
86
+ KubernetesAlreadyGoneError,
87
+ type KubernetesClient,
88
+ KubernetesCredentialError,
89
+ } from './k8s-client.js'
90
+ import {
91
+ SANDBOX_TEMPLATE_LABEL_KEY,
92
+ ciliumNetworkPolicyCollectionPath,
93
+ networkPolicyCollectionPath,
94
+ } from './objects.js'
95
+
96
+ /**
97
+ * Which policy resources the check enumerates.
98
+ *
99
+ * - `'core'` (the default) lists `NetworkPolicy` — every cluster serves it,
100
+ * and a cluster running a CNI with its own CRD still enforces plain
101
+ * `NetworkPolicy` objects too, so this is never the wrong list, only
102
+ * sometimes an incomplete one.
103
+ * - `'cilium'` lists `CiliumNetworkPolicy` AS WELL — not instead. A cluster
104
+ * running that CNI typically carries both kinds, and an open rule in
105
+ * either one opens the port.
106
+ */
107
+ export type KubernetesIngressEngine = 'core' | 'cilium'
108
+
109
+ /**
110
+ * The config-level ingress hook on `KubernetesBackendConfig`.
111
+ *
112
+ * `undefined` means VERIFY with the default engine — this is the one config
113
+ * field in this backend whose absent value is the strict one, because the
114
+ * deployments that need the check are the ones that would never have set it.
115
+ * `'unverified'` is the explicit opt-out for a deployment whose boundary
116
+ * lives somewhere a namespaced Role cannot read; it issues no request at all.
117
+ */
118
+ export type KubernetesIngressConfig =
119
+ | {
120
+ /** Defaults to `config.egress?.engine ?? 'core'`. See the type doc. */
121
+ readonly engine?: KubernetesIngressEngine
122
+ }
123
+ | 'unverified'
124
+
125
+ /** `true` when `ingress` is anything other than the `'unverified'` opt-out. */
126
+ export function ingressVerificationEnabled(ingress: KubernetesIngressConfig | undefined): boolean {
127
+ return ingress !== 'unverified'
128
+ }
129
+
130
+ /**
131
+ * Which resources to enumerate, given both policy-shaped config fields.
132
+ *
133
+ * The default deliberately follows `config.egress.engine`: a deployment that
134
+ * already told this backend which policy engine its cluster runs should not
135
+ * have to say it twice, and the far more likely mistake is declaring it once
136
+ * and having the ingress check quietly read the wrong CRD.
137
+ */
138
+ export function resolveIngressEngine(
139
+ ingress: KubernetesIngressConfig | undefined,
140
+ egressEngine: KubernetesIngressEngine | undefined,
141
+ ): KubernetesIngressEngine {
142
+ if (ingress !== undefined && ingress !== 'unverified' && ingress.engine !== undefined) {
143
+ return ingress.engine
144
+ }
145
+ return egressEngine ?? 'core'
146
+ }
147
+
148
+ /** The pod the check is about, and the port that has to be closed on it. */
149
+ export interface IngressVerificationTarget {
150
+ readonly namespace: string
151
+ /**
152
+ * The pod's REAL labels — for a directly created Sandbox the labels the
153
+ * create body stamps (known before the POST, so a refusal leaves no
154
+ * Sandbox and no PVC behind), for a claimed one the bound pod's own
155
+ * `metadata.labels`. Never a policy name: a name proves an object exists,
156
+ * a label is what a selector actually matches.
157
+ */
158
+ readonly podLabels: Readonly<Record<string, string>>
159
+ readonly agentPort: number
160
+ readonly engine: KubernetesIngressEngine
161
+ /** How the refusal names the thing being created, e.g. `Sandbox namzu-ws-demo`. */
162
+ readonly subject: string
163
+ }
164
+
165
+ /** What one examined policy turned out to be. One line of the refusal. */
166
+ export type IngressPolicyVerdict =
167
+ /** Selects the pod, enforces ingress, admits nothing wide open on the port. */
168
+ | 'covers'
169
+ /** Selects the pod and admits a wide-open peer on the agent port. */
170
+ | 'opens-agent-port'
171
+ /** Its selector does not match the pod's labels. */
172
+ | 'does-not-select'
173
+ /** Selects the pod but does not enforce ingress, so it neither covers nor opens. */
174
+ | 'not-ingress-scoped'
175
+ /** Contains something this check cannot decide — see {@link IngressPolicyRefusal}. */
176
+ | 'not-evaluable'
177
+
178
+ /** One policy, as the refusal reports it. */
179
+ export interface ExaminedIngressPolicy {
180
+ readonly kind: 'NetworkPolicy' | 'CiliumNetworkPolicy'
181
+ readonly name: string
182
+ readonly verdict: IngressPolicyVerdict
183
+ /** Why, for every verdict that is not a plain match or non-match. */
184
+ readonly detail?: string
185
+ }
186
+
187
+ /**
188
+ * Which of the three refusals this is. They are three different operator
189
+ * actions, which is why the error carries them apart rather than folding
190
+ * them into one message:
191
+ *
192
+ * - `no-covering-policy` — apply the missing policy.
193
+ * - `port-open` — fix or delete the policy that is standing the door open.
194
+ * - `not-evaluable` — this check cannot decide; grant the missing verb, or
195
+ * declare `ingress: 'unverified'` because the boundary is somewhere it
196
+ * cannot look.
197
+ */
198
+ export type IngressPolicyRefusal = 'no-covering-policy' | 'port-open' | 'not-evaluable'
199
+
200
+ /**
201
+ * A policy collection a check could NOT read, and why.
202
+ *
203
+ * It exists because an empty `examined` list means two different things and
204
+ * nothing else tells them apart: the namespace holds no policy of the kinds
205
+ * read, which is a fact about the CLUSTER, or no list was read at all, which
206
+ * is a fact about this CHECK. Reporting the first when the second happened is
207
+ * the defect this whole module exists to delete, one layer down.
208
+ *
209
+ * Shared with the EGRESS direction — `egress-policy.ts`'s union check reads
210
+ * the same two collections through the same {@link listPolicies} and reports
211
+ * the same failure the same way. See "Shared with the egress direction".
212
+ */
213
+ export interface UnreadPolicySource {
214
+ readonly resource: 'networkpolicies' | 'ciliumnetworkpolicies'
215
+ readonly path: string
216
+ /**
217
+ * - `'absent'` — the API server served no such collection here.
218
+ * - `'forbidden'` — it refused the read.
219
+ *
220
+ * They are different operator actions, which is why the remedy sentence
221
+ * branches on this rather than on the message.
222
+ */
223
+ readonly why: 'absent' | 'forbidden'
224
+ /** The failure in its own terms, for the reader of the message. */
225
+ readonly reason: string
226
+ }
227
+
228
+ /**
229
+ * @deprecated Renamed to {@link UnreadPolicySource} when the egress union
230
+ * check began reporting the same record; this alias keeps the old name
231
+ * working and will be removed in a later major.
232
+ */
233
+ export type UnreadIngressPolicySource = UnreadPolicySource
234
+
235
+ /**
236
+ * The named refusal. Distinct from every other refusal this backend can
237
+ * raise on a create path, so a caller (or an operator reading a log line)
238
+ * can tell an unprotected agent port from a slow API server or an
239
+ * unenforceable egress policy without matching on a message.
240
+ *
241
+ * It carries the pod's labels and EVERY policy examined, with a verdict each,
242
+ * because that list is the operator's whole debugging session: the question
243
+ * "why does my policy not count?" is answered by the line that says it did
244
+ * not select these labels.
245
+ *
246
+ * Every sentence of the message is a claim the read actually supports. When a
247
+ * collection could not be enumerated it lands in {@link unread} and the
248
+ * message says so instead of describing a namespace nobody looked at — see
249
+ * {@link UnreadPolicySource}.
250
+ */
251
+ export class KubernetesIngressPolicyError extends Error {
252
+ override readonly name = 'KubernetesIngressPolicyError'
253
+
254
+ constructor(
255
+ readonly refusal: IngressPolicyRefusal,
256
+ readonly subject: string,
257
+ readonly podLabels: Readonly<Record<string, string>>,
258
+ readonly agentPort: number,
259
+ readonly examined: readonly ExaminedIngressPolicy[],
260
+ summary: string,
261
+ /** Empty on every decision made from policies that WERE read. */
262
+ readonly unread: readonly UnreadPolicySource[] = [],
263
+ ) {
264
+ super(
265
+ `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)}`,
266
+ )
267
+ }
268
+ }
269
+
270
+ /**
271
+ * One label set, rendered for a refusal message. Exported for the egress
272
+ * union check's refusal, which has to render the identical thing.
273
+ */
274
+ export function formatLabels(labels: Readonly<Record<string, string>>): string {
275
+ const entries = Object.entries(labels)
276
+ if (entries.length === 0) return '(none)'
277
+ return entries
278
+ .map(([key, value]) => `${key}=${value}`)
279
+ .sort()
280
+ .join(', ')
281
+ }
282
+
283
+ /**
284
+ * The examined list, and — this is the whole point of the function — what an
285
+ * EMPTY one is allowed to say.
286
+ *
287
+ * "The namespace holds no policy" is a claim about the cluster, and only a
288
+ * list that came back empty supports it. A list that was refused or never
289
+ * served supports nothing at all, so `unread` and not the length picks the
290
+ * wording, and the kinds that went unread are named.
291
+ */
292
+ function formatExamined(
293
+ examined: readonly ExaminedIngressPolicy[],
294
+ unread: readonly UnreadPolicySource[],
295
+ ): string {
296
+ let head: string
297
+ if (examined.length > 0) {
298
+ head = `Policies examined: ${examined
299
+ .map(
300
+ (policy) =>
301
+ `${policy.kind}/${policy.name} [${policy.verdict}${policy.detail !== undefined ? `: ${policy.detail}` : ''}]`,
302
+ )
303
+ .join('; ')}.`
304
+ } else if (unread.length > 0) {
305
+ head =
306
+ 'Policies examined: none, and none could be read — nothing here is a claim about what this namespace holds.'
307
+ } else {
308
+ head = 'Policies examined: (none — the namespace holds no policy of the kinds read).'
309
+ }
310
+ if (unread.length === 0) return head
311
+ return `${head} Not read: ${unread
312
+ .map((source) => `${source.resource} at ${source.path} (${source.reason})`)
313
+ .join('; ')}.`
314
+ }
315
+
316
+ /**
317
+ * What the operator does next. It branches on {@link UnreadPolicySource}
318
+ * because "apply the missing policy" is the wrong instruction for a check that
319
+ * never got to look at one: the fix there is to make the list readable, or to
320
+ * declare that the boundary lives where this check cannot see it.
321
+ *
322
+ * It takes `examined` for the same reason {@link formatExamined} does, and it
323
+ * is the same claim: "this backend read no policy at all" is a sentence only
324
+ * an EMPTY examined list supports. One collection can go unread beside another
325
+ * that was read and named — a 404 on the Cilium CRD after the core list came
326
+ * back — and saying nothing was read three sentences after listing what was
327
+ * read is exactly the kind of unsupported sentence this module exists to
328
+ * delete.
329
+ */
330
+ function formatRemedy(
331
+ unread: readonly UnreadPolicySource[],
332
+ examined: readonly ExaminedIngressPolicy[],
333
+ ): string {
334
+ 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.`
335
+ if (unread.length === 0) {
336
+ 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}`
337
+ }
338
+ const actions: string[] = []
339
+ const forbidden = unread.filter((source) => source.why === 'forbidden')
340
+ if (forbidden.length > 0) {
341
+ actions.push(
342
+ `grant this ServiceAccount 'list' on ${forbidden
343
+ .map((source) => source.resource)
344
+ .join(' and ')} in this namespace, which k8s/manifests/rbac.yaml does`,
345
+ )
346
+ }
347
+ const absent = unread.filter((source) => source.why === 'absent')
348
+ if (absent.length > 0) {
349
+ actions.push(
350
+ `point ingress.engine at a policy kind this cluster actually serves (${absent
351
+ .map((source) => source.resource)
352
+ .join(' and ')} answered as not served here)`,
353
+ )
354
+ }
355
+ const missing =
356
+ unread.length === 1 ? 'one collection was not' : `${unread.length} collections were not`
357
+ const preamble =
358
+ examined.length === 0
359
+ ? 'This backend read no policy at all, so it can name none to fix:'
360
+ : `The policies named above were read; ${missing}, so what the rest of the cluster admits on the agent port is unknown:`
361
+ return `${preamble} ${actions.join(', or ')}. ${unverified}`
362
+ }
363
+
364
+ // ---------------------------------------------------------------------------
365
+ // Reading the wire.
366
+ //
367
+ // The shapes below are partial in the same way `objects.ts`'s are, and for the
368
+ // same reason: a full copy of two policy schemas would go stale on its own
369
+ // schedule. What they are NOT is a promise about what arrives — every reader
370
+ // below takes `unknown` and narrows, so the type declarations document the
371
+ // schema and the code cannot quietly assume it.
372
+ //
373
+ // The rule, and it holds with no exception: a field that is present as
374
+ // something other than what the schema declares contributes `'unknown'` —
375
+ // `not-evaluable`, a REFUSAL — and never a value in either direction. Reading
376
+ // an unreadable `spec.ingress` as "no rules" would report a policy as covering
377
+ // the agent port on the strength of a field nobody could read, which is the
378
+ // same shape of mistake as the shipped comments this module was written to
379
+ // delete.
380
+ //
381
+ // An ABSENT field is different from an unreadable one and each reader says
382
+ // what absent means there, because the resources themselves differ: an absent
383
+ // `ports` on an ingress rule means every port, an absent `policyTypes` is
384
+ // defaulted by the API server to include Ingress, and an absent `ingress` is
385
+ // no rules at all.
386
+ // ---------------------------------------------------------------------------
387
+
388
+ /**
389
+ * A JSON object, and not `null` and not an array.
390
+ *
391
+ * `typeof null === 'object'` and `typeof [] === 'object'` are the two ways a
392
+ * check meaning "is this an object" gets written and stays wrong.
393
+ */
394
+ // ---------------------------------------------------------------------------
395
+ // Shared with the egress direction
396
+ // ---------------------------------------------------------------------------
397
+ //
398
+ // The exported helpers from here to the end of "Selector evaluation", plus
399
+ // `listPolicies` in the I/O half below, are called by `egress-policy.ts`'s
400
+ // union check as well as by this module's own decision. There is ONE
401
+ // enumeration of the policies selecting a pod in this package and ONE reading
402
+ // of what a peer is, serving both directions: two would be two definitions of
403
+ // "wide open" and two ways to disagree with the cluster about which policies
404
+ // apply. The egress check asks a different QUESTION of the same material —
405
+ // "is this peer inside the configured translation" rather than "does this
406
+ // rule open the agent port" — and so keeps its own verdict types there.
407
+
408
+ export function isRecord(value: unknown): value is Record<string, unknown> {
409
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
410
+ }
411
+
412
+ /**
413
+ * How a field the schema declares as a LIST reads.
414
+ *
415
+ * - `undefined` — absent (`null` included: that is how a serialiser spells
416
+ * absent, and the API server never sends it otherwise). What absent means
417
+ * is the caller's to decide.
418
+ * - the array — present and readable.
419
+ * - `'unreadable'` — present as something that is not a list.
420
+ */
421
+ export type ReadList = readonly unknown[] | undefined | 'unreadable'
422
+
423
+ export function readList(value: unknown): ReadList {
424
+ if (value === undefined || value === null) return undefined
425
+ return Array.isArray(value) ? value : 'unreadable'
426
+ }
427
+
428
+ /** A policy's name, or a stable stand-in — never a non-string off the wire. */
429
+ export function policyName(item: unknown, index: number): string {
430
+ const metadata = isRecord(item) ? item.metadata : undefined
431
+ const name = isRecord(metadata) ? metadata.name : undefined
432
+ return typeof name === 'string' && name !== '' ? name : `(unnamed #${index})`
433
+ }
434
+
435
+ /**
436
+ * A policy that could not be read, as a document.
437
+ *
438
+ * {@link IngressPolicyDocument.unreadable} decides on its own, so the other
439
+ * fields are the inert ones: nothing downstream consults them.
440
+ */
441
+ function unreadablePolicy(
442
+ kind: IngressPolicyDocument['kind'],
443
+ name: string,
444
+ detail: string,
445
+ ): IngressPolicyDocument {
446
+ return {
447
+ kind,
448
+ name,
449
+ selects: 'unknown',
450
+ enforcesIngress: false,
451
+ rules: [],
452
+ unreadable: detail,
453
+ }
454
+ }
455
+
456
+ interface LabelSelector {
457
+ readonly matchLabels?: Readonly<Record<string, string>>
458
+ readonly matchExpressions?: readonly {
459
+ readonly key?: string
460
+ readonly operator?: string
461
+ readonly values?: readonly string[]
462
+ }[]
463
+ }
464
+
465
+ interface PolicyListResource {
466
+ readonly items?: unknown
467
+ }
468
+
469
+ /**
470
+ * One policy, normalised to the three questions the decision actually asks:
471
+ * does it select this pod, does it enforce ingress on it, and what does it
472
+ * let in. Both resource kinds collapse onto this, so the union rule is
473
+ * implemented once.
474
+ */
475
+ export interface IngressPolicyDocument {
476
+ readonly kind: 'NetworkPolicy' | 'CiliumNetworkPolicy'
477
+ readonly name: string
478
+ readonly selects: SelectorMatch
479
+ /**
480
+ * Does this policy put the pod into ingress DEFAULT-DENY — which is the
481
+ * only thing that makes it count as coverage. A core policy whose
482
+ * `policyTypes` leaves Ingress out does not, and neither does a Cilium
483
+ * rule carrying `enableDefaultDeny.ingress: false`.
484
+ */
485
+ readonly enforcesIngress: boolean
486
+ /**
487
+ * Per-rule: does this rule admit a wide-open peer on the agent port.
488
+ *
489
+ * Only rules that APPLY are carried, which is not the same question as
490
+ * {@link enforcesIngress}: a core policy that leaves Ingress out of
491
+ * `policyTypes` has its ingress block ignored by the API server outright
492
+ * and so carries no rules at all, while a Cilium rule that disables
493
+ * default-deny still admits everything it names — it simply cannot be the
494
+ * policy that closes the port.
495
+ */
496
+ readonly rules: readonly IngressRuleVerdict[]
497
+ /**
498
+ * Set when the OBJECT could not be read — a field the schema declares one
499
+ * way arriving as another. It decides alone: a document carrying it is
500
+ * `not-evaluable` whatever the fields that did parse happen to say,
501
+ * because a policy that cannot be read cannot be shown to close a port.
502
+ */
503
+ readonly unreadable?: string
504
+ }
505
+
506
+ /** `'unknown'` is never guessed either way — see {@link IngressPolicyRefusal}. */
507
+ export type SelectorMatch = 'yes' | 'no' | 'unknown'
508
+
509
+ export interface IngressRuleVerdict {
510
+ readonly open: boolean | 'unknown'
511
+ readonly detail?: string
512
+ }
513
+
514
+ // ---------------------------------------------------------------------------
515
+ // Selector evaluation
516
+ // ---------------------------------------------------------------------------
517
+
518
+ /**
519
+ * Is this a selector this check can read at all?
520
+ *
521
+ * An ABSENT selector is readable — the resource defines it as "everything".
522
+ * A present one that is not an object, or whose `matchLabels` /
523
+ * `matchExpressions` are not the shapes the API declares, is not, and every
524
+ * caller turns that into `'unknown'`. A selector that might match a pod might
525
+ * also be the one holding its port open, so neither "matches" nor "does not
526
+ * match" is available.
527
+ */
528
+ export function selectorIsReadable(selector: unknown): boolean {
529
+ if (selector === undefined) return true
530
+ if (!isRecord(selector)) return false
531
+ const matchLabels = selector.matchLabels
532
+ if (matchLabels !== undefined) {
533
+ if (!isRecord(matchLabels)) return false
534
+ for (const value of Object.values(matchLabels)) if (typeof value !== 'string') return false
535
+ }
536
+ const matchExpressions = selector.matchExpressions
537
+ if (matchExpressions !== undefined && !Array.isArray(matchExpressions)) return false
538
+ return true
539
+ }
540
+
541
+ function selectorIsEmpty(selector: LabelSelector | undefined): boolean {
542
+ if (selector === undefined) return true
543
+ const labels = selector.matchLabels
544
+ const expressions = selector.matchExpressions
545
+ const hasLabels = labels !== undefined && Object.keys(labels).length > 0
546
+ const hasExpressions = Array.isArray(expressions) && expressions.length > 0
547
+ return !hasLabels && !hasExpressions
548
+ }
549
+
550
+ /**
551
+ * A label selector against a known label set.
552
+ *
553
+ * An ABSENT or EMPTY selector matches everything — that is the resource's own
554
+ * default (`podSelector: {}` is how a `NetworkPolicy` selects every pod in
555
+ * its namespace), not a lenient reading.
556
+ *
557
+ * `normaliseKey` exists for the Cilium arm: that CRD's selectors carry a
558
+ * label SOURCE prefix (`k8s:app`, `any:app`), and an unprefixed key means
559
+ * `any:`. A prefix this module does not understand makes the whole selector
560
+ * `'unknown'` rather than "does not match", because a selector that might
561
+ * match a pod might also be the one holding its port open.
562
+ *
563
+ * Label values are read with `labelValue`, never by indexing: a label key is
564
+ * allowed to be spelled `constructor`, and a plain object would answer that
565
+ * one off `Object.prototype` — an `Exists` expression on it would then report
566
+ * a pod as selected by a policy that does not select it.
567
+ */
568
+ function labelValue(labels: Readonly<Record<string, string>>, key: string): string | undefined {
569
+ return Object.hasOwn(labels, key) ? labels[key] : undefined
570
+ }
571
+
572
+ export function matchesLabelSelector(
573
+ selector: unknown,
574
+ labels: Readonly<Record<string, string>>,
575
+ normaliseKey: (key: string) => string | undefined = (key) => key,
576
+ ): SelectorMatch {
577
+ if (!selectorIsReadable(selector)) return 'unknown'
578
+ const readable = selector as LabelSelector | undefined
579
+ if (selectorIsEmpty(readable)) return 'yes'
580
+ for (const [rawKey, value] of Object.entries(readable?.matchLabels ?? {})) {
581
+ const key = normaliseKey(rawKey)
582
+ if (key === undefined) return 'unknown'
583
+ if (labelValue(labels, key) !== value) return 'no'
584
+ }
585
+ for (const expression of readable?.matchExpressions ?? []) {
586
+ if (!isRecord(expression)) return 'unknown'
587
+ const rawKey = expression.key
588
+ if (typeof rawKey !== 'string' || rawKey === '') return 'unknown'
589
+ const key = normaliseKey(rawKey)
590
+ if (key === undefined) return 'unknown'
591
+ const actual = labelValue(labels, key)
592
+ const operator = expression.operator
593
+ if (operator === 'Exists') {
594
+ if (actual === undefined) return 'no'
595
+ continue
596
+ }
597
+ if (operator === 'DoesNotExist') {
598
+ if (actual !== undefined) return 'no'
599
+ continue
600
+ }
601
+ if (operator !== 'In' && operator !== 'NotIn') return 'unknown'
602
+ // `values` is what In and NotIn are ABOUT. Reading an unreadable one
603
+ // as the empty list would answer NotIn with "matches" — a pod
604
+ // reported as selected on the strength of a field nobody could read.
605
+ const values = readList(expression.values)
606
+ if (values === 'unreadable' || values === undefined) return 'unknown'
607
+ if (operator === 'In') {
608
+ if (actual === undefined || !values.includes(actual)) return 'no'
609
+ continue
610
+ }
611
+ // A pod that carries the key not at all satisfies NotIn, which is the
612
+ // API's own rule and the opposite of the intuitive read.
613
+ if (actual !== undefined && values.includes(actual)) return 'no'
614
+ }
615
+ return 'yes'
616
+ }
617
+
618
+ /**
619
+ * Strip the label SOURCE prefix off a Cilium selector key, or report that it
620
+ * is one this check cannot map onto a pod label.
621
+ *
622
+ * `k8s:` and `any:` both resolve to the pod's own labels; `reserved:` and the
623
+ * other sources name identities that are not pod labels at all, and guessing
624
+ * at one would be exactly the "trust the shape" mistake this module exists to
625
+ * avoid.
626
+ */
627
+ export function ciliumSelectorKey(key: string): string | undefined {
628
+ const colon = key.indexOf(':')
629
+ if (colon < 0) return key
630
+ const source = key.slice(0, colon)
631
+ if (source === 'k8s' || source === 'any') return key.slice(colon + 1)
632
+ return undefined
633
+ }
634
+
635
+ /**
636
+ * The label set a Cilium selector is matched against: the pod's own labels
637
+ * plus the namespace, which that CRD's selectors name as
638
+ * `io.kubernetes.pod.namespace` and which every namespaced policy in the
639
+ * shipped examples carries.
640
+ */
641
+ export function ciliumIdentityLabels(
642
+ podLabels: Readonly<Record<string, string>>,
643
+ namespace: string,
644
+ ): Readonly<Record<string, string>> {
645
+ return { ...podLabels, 'io.kubernetes.pod.namespace': namespace }
646
+ }
647
+
648
+ // ---------------------------------------------------------------------------
649
+ // Port and peer evaluation
650
+ // ---------------------------------------------------------------------------
651
+
652
+ function coreRuleCoversPort(ports: unknown, agentPort: number): boolean | 'unknown' {
653
+ const entries = readList(ports)
654
+ if (entries === 'unreadable') return 'unknown'
655
+ // Absent or empty `ports` on an ingress rule means EVERY port — the one
656
+ // shape most likely to be read as "no ports, so nothing".
657
+ if (entries === undefined || entries.length === 0) return true
658
+ let unknown = false
659
+ for (const entry of entries) {
660
+ if (!isRecord(entry)) {
661
+ unknown = true
662
+ continue
663
+ }
664
+ const rawProtocol = entry.protocol
665
+ if (rawProtocol !== undefined && typeof rawProtocol !== 'string') {
666
+ unknown = true
667
+ continue
668
+ }
669
+ if ((rawProtocol ?? 'TCP') !== 'TCP') continue
670
+ const endPort = entry.endPort
671
+ if (endPort !== undefined && typeof endPort !== 'number') {
672
+ unknown = true
673
+ continue
674
+ }
675
+ const port = entry.port
676
+ if (port === undefined) return true
677
+ const spansAgentPort = (start: number): boolean =>
678
+ typeof endPort === 'number' && agentPort > start && agentPort <= endPort
679
+ if (typeof port === 'number') {
680
+ if (port === agentPort || spansAgentPort(port)) return true
681
+ continue
682
+ }
683
+ if (typeof port === 'string') {
684
+ const parsed = Number(port)
685
+ if (Number.isInteger(parsed) && String(parsed) === port.trim()) {
686
+ if (parsed === agentPort || spansAgentPort(parsed)) return true
687
+ continue
688
+ }
689
+ // A NAMED container port. Resolving it needs the pod's own
690
+ // container spec, which this check does not have on every path
691
+ // (a claimed pool sandbox's spec is the pool's), so it is
692
+ // reported rather than assumed either way.
693
+ unknown = true
694
+ continue
695
+ }
696
+ unknown = true
697
+ }
698
+ return unknown ? 'unknown' : false
699
+ }
700
+
701
+ const WIDE_OPEN_CIDRS = new Set(['0.0.0.0/0', '::/0'])
702
+
703
+ export function corePeerIsWideOpen(peer: unknown): boolean | 'unknown' {
704
+ if (!isRecord(peer)) return 'unknown'
705
+ const { podSelector, namespaceSelector, ipBlock } = peer
706
+ if (ipBlock !== undefined) {
707
+ if (!isRecord(ipBlock)) return 'unknown'
708
+ const cidr = ipBlock.cidr
709
+ if (typeof cidr !== 'string') return 'unknown'
710
+ // An `except` list carves a few addresses out of the whole internet
711
+ // and leaves the rest of it admitted, so it does not narrow this to
712
+ // anything worth calling closed.
713
+ return WIDE_OPEN_CIDRS.has(cidr.trim())
714
+ }
715
+ if (!selectorIsReadable(podSelector) || !selectorIsReadable(namespaceSelector)) return 'unknown'
716
+ if (namespaceSelector !== undefined) {
717
+ // `namespaceSelector: {}` is every namespace. Paired with a
718
+ // non-empty podSelector it is still a real constraint (that pod
719
+ // label, anywhere), so only the doubly-empty form is wide open.
720
+ return (
721
+ selectorIsEmpty(namespaceSelector as LabelSelector) &&
722
+ selectorIsEmpty(podSelector as LabelSelector | undefined)
723
+ )
724
+ }
725
+ if (podSelector !== undefined) {
726
+ // Every pod in the policy's own namespace. Broad, and deliberately
727
+ // NOT called wide open: naming it so would refuse the legitimate
728
+ // "the host runs beside its sandboxes" deployment, and an evaluator
729
+ // that fires on a correct policy is one an operator switches off.
730
+ return false
731
+ }
732
+ // A peer naming none of the three constrains nothing. The API server
733
+ // rejects it on admission, so reaching here means the object did not come
734
+ // from one.
735
+ return 'unknown'
736
+ }
737
+
738
+ function coreRuleVerdict(rule: unknown, agentPort: number): IngressRuleVerdict {
739
+ if (!isRecord(rule)) {
740
+ return { open: 'unknown', detail: 'an ingress rule that is not an object' }
741
+ }
742
+ const covers = coreRuleCoversPort(rule.ports, agentPort)
743
+ if (covers === 'unknown') {
744
+ return {
745
+ open: 'unknown',
746
+ detail: `a named port this check cannot resolve to TCP ${agentPort}, or a ports entry it cannot read`,
747
+ }
748
+ }
749
+ if (covers === false) return { open: false }
750
+ const from = readList(rule.from)
751
+ if (from === 'unreadable') {
752
+ return { open: 'unknown', detail: "a 'from' that is not a list of peers" }
753
+ }
754
+ if (from === undefined || from.length === 0) {
755
+ // No `from` on an ingress rule means EVERY source.
756
+ return {
757
+ open: true,
758
+ detail: `no 'from' peers, so every source reaches TCP ${agentPort}`,
759
+ }
760
+ }
761
+ let unknown: string | undefined
762
+ for (const peer of from) {
763
+ const open = corePeerIsWideOpen(peer)
764
+ if (open === true) {
765
+ return {
766
+ open: true,
767
+ detail: `a wide-open 'from' peer (${JSON.stringify(peer)}) on TCP ${agentPort}`,
768
+ }
769
+ }
770
+ if (open === 'unknown')
771
+ unknown ??= `a 'from' peer this check cannot read (${JSON.stringify(peer)})`
772
+ }
773
+ if (unknown !== undefined) return { open: 'unknown', detail: unknown }
774
+ return { open: false }
775
+ }
776
+
777
+ /** `all`, `cluster` and `world` each admit a peer set no host selector bounds. */
778
+ const WIDE_OPEN_ENTITIES = new Set(['all', 'cluster', 'world'])
779
+
780
+ function ciliumRuleCoversPort(
781
+ rule: Readonly<Record<string, unknown>>,
782
+ agentPort: number,
783
+ ): boolean | 'unknown' {
784
+ const toPorts = readList(rule.toPorts)
785
+ if (toPorts === 'unreadable') return 'unknown'
786
+ if (toPorts === undefined || toPorts.length === 0) return true
787
+ let unknown = false
788
+ for (const entry of toPorts) {
789
+ if (!isRecord(entry)) {
790
+ unknown = true
791
+ continue
792
+ }
793
+ const ports = readList(entry.ports)
794
+ if (ports === 'unreadable') {
795
+ unknown = true
796
+ continue
797
+ }
798
+ if (ports === undefined || ports.length === 0) return true
799
+ for (const port of ports) {
800
+ if (!isRecord(port)) {
801
+ unknown = true
802
+ continue
803
+ }
804
+ const rawProtocol = port.protocol
805
+ if (rawProtocol !== undefined && typeof rawProtocol !== 'string') {
806
+ unknown = true
807
+ continue
808
+ }
809
+ const protocol = (rawProtocol ?? 'ANY').toUpperCase()
810
+ if (protocol !== 'TCP' && protocol !== 'ANY') continue
811
+ const endPort = port.endPort
812
+ if (endPort !== undefined && typeof endPort !== 'number') {
813
+ unknown = true
814
+ continue
815
+ }
816
+ const raw = port.port
817
+ if (raw === undefined) return true
818
+ if (typeof raw !== 'number' && typeof raw !== 'string') {
819
+ unknown = true
820
+ continue
821
+ }
822
+ const parsed = typeof raw === 'number' ? raw : Number(raw)
823
+ if (!Number.isInteger(parsed)) {
824
+ unknown = true
825
+ continue
826
+ }
827
+ if (parsed === agentPort) return true
828
+ if (typeof endPort === 'number' && agentPort > parsed && agentPort <= endPort) return true
829
+ }
830
+ }
831
+ return unknown ? 'unknown' : false
832
+ }
833
+
834
+ /** Every `from…` field the CRD declares. A rule naming none of them is port-only. */
835
+ const CILIUM_SOURCE_FIELDS = [
836
+ 'fromEndpoints',
837
+ 'fromEntities',
838
+ 'fromCIDR',
839
+ 'fromCIDRSet',
840
+ 'fromNodes',
841
+ 'fromGroups',
842
+ ] as const
843
+
844
+ function ciliumRuleVerdict(rule: unknown, agentPort: number): IngressRuleVerdict {
845
+ if (!isRecord(rule)) {
846
+ return { open: 'unknown', detail: 'an ingress rule that is not an object' }
847
+ }
848
+ const covers = ciliumRuleCoversPort(rule, agentPort)
849
+ if (covers === 'unknown') {
850
+ return {
851
+ open: 'unknown',
852
+ detail: `a toPorts entry this check cannot read against TCP ${agentPort}`,
853
+ }
854
+ }
855
+ if (covers === false) return { open: false }
856
+
857
+ // Every source field is read BEFORE any of them is judged: one that is
858
+ // present as something other than a list could be the wide-open one, and
859
+ // skipping it would let the rule read as narrow on the strength of the
860
+ // fields that happened to parse.
861
+ const sources = new Map<(typeof CILIUM_SOURCE_FIELDS)[number], readonly unknown[]>()
862
+ for (const field of CILIUM_SOURCE_FIELDS) {
863
+ const list = readList(rule[field])
864
+ if (list === 'unreadable') {
865
+ return {
866
+ open: 'unknown',
867
+ detail: `a ${field} that is not a list of peers`,
868
+ }
869
+ }
870
+ if (list !== undefined) sources.set(field, list)
871
+ }
872
+
873
+ for (const entity of sources.get('fromEntities') ?? []) {
874
+ if (typeof entity !== 'string') {
875
+ return {
876
+ open: 'unknown',
877
+ detail: 'a fromEntities entry that is not an entity name',
878
+ }
879
+ }
880
+ if (WIDE_OPEN_ENTITIES.has(entity)) {
881
+ return {
882
+ open: true,
883
+ detail: `fromEntities includes '${entity}' on TCP ${agentPort}`,
884
+ }
885
+ }
886
+ }
887
+ for (const cidr of sources.get('fromCIDR') ?? []) {
888
+ if (typeof cidr !== 'string') {
889
+ return {
890
+ open: 'unknown',
891
+ detail: 'a fromCIDR entry that is not a CIDR string',
892
+ }
893
+ }
894
+ if (WIDE_OPEN_CIDRS.has(cidr.trim())) {
895
+ return {
896
+ open: true,
897
+ detail: `fromCIDR includes ${cidr} on TCP ${agentPort}`,
898
+ }
899
+ }
900
+ }
901
+ for (const entry of sources.get('fromCIDRSet') ?? []) {
902
+ if (!isRecord(entry)) {
903
+ return {
904
+ open: 'unknown',
905
+ detail: 'a fromCIDRSet entry that is not an object',
906
+ }
907
+ }
908
+ const cidr = entry.cidr
909
+ if (cidr !== undefined && typeof cidr !== 'string') {
910
+ return {
911
+ open: 'unknown',
912
+ detail: 'a fromCIDRSet cidr that is not a CIDR string',
913
+ }
914
+ }
915
+ if (typeof cidr === 'string' && WIDE_OPEN_CIDRS.has(cidr.trim())) {
916
+ return {
917
+ open: true,
918
+ detail: `fromCIDRSet includes ${cidr} on TCP ${agentPort}`,
919
+ }
920
+ }
921
+ }
922
+ for (const field of ['fromEndpoints', 'fromNodes'] as const) {
923
+ for (const selector of sources.get(field) ?? []) {
924
+ if (!selectorIsReadable(selector)) {
925
+ return {
926
+ open: 'unknown',
927
+ detail: `a ${field} entry this check cannot read as a selector`,
928
+ }
929
+ }
930
+ }
931
+ }
932
+
933
+ const hasSource = CILIUM_SOURCE_FIELDS.some((field) => (sources.get(field)?.length ?? 0) > 0)
934
+ if (!hasSource) {
935
+ // A port-only ingress rule admits every source on those ports — the
936
+ // same shape as core's absent `from`, spelled differently.
937
+ return {
938
+ open: true,
939
+ detail: `a port-only ingress rule, so every source reaches TCP ${agentPort}`,
940
+ }
941
+ }
942
+ return { open: false }
943
+ }
944
+
945
+ // ---------------------------------------------------------------------------
946
+ // Normalisation
947
+ // ---------------------------------------------------------------------------
948
+
949
+ /** Every core `NetworkPolicy` in the list, reduced to {@link IngressPolicyDocument}. */
950
+ export function readCoreIngressPolicies(
951
+ items: readonly unknown[],
952
+ target: IngressVerificationTarget,
953
+ ): IngressPolicyDocument[] {
954
+ return items.map((item, index) => {
955
+ const name = policyName(item, index)
956
+ if (!isRecord(item)) {
957
+ return unreadablePolicy('NetworkPolicy', name, 'a list entry that is not a policy object')
958
+ }
959
+ const spec = item.spec
960
+ if (!isRecord(spec)) {
961
+ return unreadablePolicy('NetworkPolicy', name, 'a spec that is not an object')
962
+ }
963
+ // An absent `policyTypes` is defaulted by the API server, and its
964
+ // default ALWAYS includes Ingress. Only an explicit list that leaves
965
+ // it out turns ingress enforcement off — and a `policyTypes` that is
966
+ // not a list says nothing about either.
967
+ const policyTypes = readList(spec.policyTypes)
968
+ if (policyTypes === 'unreadable') {
969
+ return unreadablePolicy('NetworkPolicy', name, 'a spec.policyTypes that is not a list')
970
+ }
971
+ const rules = readList(spec.ingress)
972
+ if (rules === 'unreadable') {
973
+ return unreadablePolicy('NetworkPolicy', name, 'a spec.ingress that is not a list of rules')
974
+ }
975
+ const enforcesIngress = policyTypes === undefined || policyTypes.includes('Ingress')
976
+ return {
977
+ kind: 'NetworkPolicy',
978
+ name,
979
+ selects: matchesLabelSelector(spec.podSelector, target.podLabels),
980
+ enforcesIngress,
981
+ // An `ingress` block under a `policyTypes` that leaves Ingress out
982
+ // is ignored by the API server itself, so it neither covers nor
983
+ // opens — it is not a rule of this cluster at all.
984
+ rules: enforcesIngress
985
+ ? (rules ?? []).map((rule) => coreRuleVerdict(rule, target.agentPort))
986
+ : [],
987
+ }
988
+ })
989
+ }
990
+
991
+ /** Every `CiliumNetworkPolicy` in the list, reduced the same way. */
992
+ export function readCiliumIngressPolicies(
993
+ items: readonly unknown[],
994
+ target: IngressVerificationTarget,
995
+ ): IngressPolicyDocument[] {
996
+ const identity = ciliumIdentityLabels(target.podLabels, target.namespace)
997
+ const documents: IngressPolicyDocument[] = []
998
+ for (const [index, item] of items.entries()) {
999
+ const name = policyName(item, index)
1000
+ if (!isRecord(item)) {
1001
+ documents.push(
1002
+ unreadablePolicy('CiliumNetworkPolicy', name, 'a list entry that is not a policy object'),
1003
+ )
1004
+ continue
1005
+ }
1006
+ // The CRD carries EITHER one `spec` or a `specs` list, and a rule in
1007
+ // either enforces. Reading only `spec` would miss a whole policy.
1008
+ const specs: unknown[] = []
1009
+ if (item.spec !== undefined && item.spec !== null) specs.push(item.spec)
1010
+ const more = readList(item.specs)
1011
+ if (more === 'unreadable') {
1012
+ documents.push(
1013
+ unreadablePolicy('CiliumNetworkPolicy', name, 'a specs that is not a list of rule specs'),
1014
+ )
1015
+ continue
1016
+ }
1017
+ for (const spec of more ?? []) specs.push(spec)
1018
+ if (specs.length === 0) {
1019
+ documents.push(
1020
+ unreadablePolicy('CiliumNetworkPolicy', name, 'neither a spec nor a specs list'),
1021
+ )
1022
+ continue
1023
+ }
1024
+ for (const spec of specs) {
1025
+ const document = readCiliumRuleSpec(spec, name, identity, target.agentPort)
1026
+ if (document !== undefined) documents.push(document)
1027
+ }
1028
+ }
1029
+ return documents
1030
+ }
1031
+
1032
+ /** One `spec`/`specs` entry. `undefined` when it is node-scoped — see below. */
1033
+ function readCiliumRuleSpec(
1034
+ spec: unknown,
1035
+ name: string,
1036
+ identity: Readonly<Record<string, string>>,
1037
+ agentPort: number,
1038
+ ): IngressPolicyDocument | undefined {
1039
+ const unreadable = (detail: string) => unreadablePolicy('CiliumNetworkPolicy', name, detail)
1040
+ if (!isRecord(spec)) return unreadable('a rule spec that is not an object')
1041
+ // A node-scoped rule selects nodes, never pods; it can neither cover nor
1042
+ // open a pod's port.
1043
+ if (spec.nodeSelector !== undefined) return undefined
1044
+ const rules = readList(spec.ingress)
1045
+ if (rules === 'unreadable') {
1046
+ return unreadable('a spec.ingress that is not a list of rules')
1047
+ }
1048
+ const ingressDeny = readList(spec.ingressDeny)
1049
+ if (ingressDeny === 'unreadable')
1050
+ return unreadable('a spec.ingressDeny that is not a list of rules')
1051
+ let enforcesIngress = rules !== undefined || ingressDeny !== undefined
1052
+ // Cilium 1.16 and later: `enableDefaultDeny.ingress: false` makes a rule
1053
+ // ALLOW without putting the endpoint into ingress default-deny, so it
1054
+ // closes nothing — the "looked like coverage and was not" shape of this
1055
+ // whole module, one CRD version later. Its own rules are still evaluated,
1056
+ // because what it admits it still admits; it simply cannot count as the
1057
+ // policy that covers the port.
1058
+ const enableDefaultDeny = spec.enableDefaultDeny
1059
+ if (enableDefaultDeny !== undefined) {
1060
+ if (!isRecord(enableDefaultDeny)) {
1061
+ return unreadable('an enableDefaultDeny that is not an object')
1062
+ }
1063
+ const forIngress = enableDefaultDeny.ingress
1064
+ if (forIngress !== undefined && typeof forIngress !== 'boolean') {
1065
+ return unreadable('an enableDefaultDeny.ingress that is not a boolean')
1066
+ }
1067
+ if (forIngress === false) enforcesIngress = false
1068
+ }
1069
+ return {
1070
+ kind: 'CiliumNetworkPolicy',
1071
+ name,
1072
+ selects: matchesLabelSelector(spec.endpointSelector, identity, ciliumSelectorKey),
1073
+ enforcesIngress,
1074
+ rules: (rules ?? []).map((rule) => ciliumRuleVerdict(rule, agentPort)),
1075
+ }
1076
+ }
1077
+
1078
+ // ---------------------------------------------------------------------------
1079
+ // The decision
1080
+ // ---------------------------------------------------------------------------
1081
+
1082
+ export interface IngressDecision {
1083
+ readonly examined: readonly ExaminedIngressPolicy[]
1084
+ /** Absent when the port is covered and nothing opens it. */
1085
+ readonly refusal?: {
1086
+ readonly kind: IngressPolicyRefusal
1087
+ readonly summary: string
1088
+ }
1089
+ }
1090
+
1091
+ /**
1092
+ * The union rule, applied. Pure — no I/O, no client, no clock — so every
1093
+ * shape that has to be refused can be asserted one per test, which is how
1094
+ * the "admits any peer" reading stays a rule rather than a heuristic.
1095
+ */
1096
+ export function decideIngressCoverage(
1097
+ documents: readonly IngressPolicyDocument[],
1098
+ target: IngressVerificationTarget,
1099
+ ): IngressDecision {
1100
+ const examined: ExaminedIngressPolicy[] = []
1101
+ let covering = 0
1102
+ let open: ExaminedIngressPolicy | undefined
1103
+ let undecided: ExaminedIngressPolicy | undefined
1104
+
1105
+ for (const document of documents) {
1106
+ const base = { kind: document.kind, name: document.name } as const
1107
+ if (document.unreadable !== undefined) {
1108
+ const entry: ExaminedIngressPolicy = {
1109
+ ...base,
1110
+ verdict: 'not-evaluable',
1111
+ detail: document.unreadable,
1112
+ }
1113
+ examined.push(entry)
1114
+ undecided ??= entry
1115
+ continue
1116
+ }
1117
+ if (document.selects === 'no') {
1118
+ examined.push({ ...base, verdict: 'does-not-select' })
1119
+ continue
1120
+ }
1121
+ if (document.selects === 'unknown') {
1122
+ const entry: ExaminedIngressPolicy = {
1123
+ ...base,
1124
+ verdict: 'not-evaluable',
1125
+ detail: 'its selector uses something this check cannot evaluate against pod labels',
1126
+ }
1127
+ examined.push(entry)
1128
+ undecided ??= entry
1129
+ continue
1130
+ }
1131
+ // Every RULE is read before the enforcement question is asked, because a
1132
+ // policy can admit a peer without default-denying anything — a Cilium
1133
+ // rule with `enableDefaultDeny.ingress: false` is exactly that, and what
1134
+ // it admits is admitted for real once something else default-denies the
1135
+ // endpoint. Both halves of reading a rule move together: a rule that
1136
+ // opens the port is the finding, and a rule nobody can read is a rule
1137
+ // that MIGHT open it, so neither may sit behind the enforcement gate. A
1138
+ // policy whose rules do not apply at all carries none of them, so this
1139
+ // asks nothing of those.
1140
+ const openRule = document.rules.find((rule) => rule.open === true)
1141
+ if (openRule !== undefined) {
1142
+ const entry: ExaminedIngressPolicy = {
1143
+ ...base,
1144
+ verdict: 'opens-agent-port',
1145
+ ...(openRule.detail !== undefined ? { detail: openRule.detail } : {}),
1146
+ }
1147
+ examined.push(entry)
1148
+ open ??= entry
1149
+ continue
1150
+ }
1151
+ const unknownRule = document.rules.find((rule) => rule.open === 'unknown')
1152
+ if (unknownRule !== undefined) {
1153
+ const entry: ExaminedIngressPolicy = {
1154
+ ...base,
1155
+ verdict: 'not-evaluable',
1156
+ ...(unknownRule.detail !== undefined ? { detail: unknownRule.detail } : {}),
1157
+ }
1158
+ examined.push(entry)
1159
+ undecided ??= entry
1160
+ continue
1161
+ }
1162
+ if (!document.enforcesIngress) {
1163
+ examined.push({
1164
+ ...base,
1165
+ verdict: 'not-ingress-scoped',
1166
+ detail: 'it selects the pod but default-denies nothing on ingress',
1167
+ })
1168
+ continue
1169
+ }
1170
+ examined.push({ ...base, verdict: 'covers' })
1171
+ covering += 1
1172
+ }
1173
+
1174
+ // Ordered by what an operator has to do first. A policy standing the door
1175
+ // open is the finding even when another one closes it, because the union
1176
+ // means the open one wins on the wire.
1177
+ if (open !== undefined) {
1178
+ return {
1179
+ examined,
1180
+ refusal: {
1181
+ kind: 'port-open',
1182
+ summary: `${open.kind}/${open.name} selects this pod and admits ${open.detail ?? `a wide-open peer on TCP ${target.agentPort}`}.`,
1183
+ },
1184
+ }
1185
+ }
1186
+ if (undecided !== undefined) {
1187
+ return {
1188
+ examined,
1189
+ refusal: {
1190
+ kind: 'not-evaluable',
1191
+ 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.`,
1192
+ },
1193
+ }
1194
+ }
1195
+ if (covering === 0) {
1196
+ return {
1197
+ examined,
1198
+ refusal: {
1199
+ kind: 'no-covering-policy',
1200
+ summary:
1201
+ 'no applied policy enforces ingress on this pod, so every pod in the cluster can reach its agent port.',
1202
+ },
1203
+ }
1204
+ }
1205
+ return { examined }
1206
+ }
1207
+
1208
+ // ---------------------------------------------------------------------------
1209
+ // The I/O half
1210
+ // ---------------------------------------------------------------------------
1211
+
1212
+ /**
1213
+ * Internal: a collection that could not be enumerated. It carries what the
1214
+ * refusal needs and never escapes this module — {@link verifyIngressPolicyApplied}
1215
+ * turns it into a {@link KubernetesIngressPolicyError} that also reports
1216
+ * whatever WAS read before it.
1217
+ */
1218
+ export class UnreadPolicyCollection extends Error {
1219
+ constructor(
1220
+ readonly source: UnreadPolicySource,
1221
+ readonly summary: string,
1222
+ ) {
1223
+ super(summary)
1224
+ }
1225
+ }
1226
+
1227
+ /**
1228
+ * Enumerate one policy collection, or report why it could not be read.
1229
+ *
1230
+ * Exported: `egress-policy.ts`'s union check lists exactly these two
1231
+ * collections for exactly this reason, and a second enumerator would be a
1232
+ * second answer to "which policies apply to this pod".
1233
+ */
1234
+ export async function listPolicies(
1235
+ client: KubernetesClient,
1236
+ path: string,
1237
+ resource: UnreadPolicySource['resource'],
1238
+ signal?: AbortSignal,
1239
+ ): Promise<readonly unknown[]> {
1240
+ // No `limit` is sent, and the API server truncates a collection only when
1241
+ // one is — so this is the whole list, not a page of it. That matters more
1242
+ // here than anywhere else in this backend: a truncated list could hide the
1243
+ // one policy holding the port open.
1244
+ try {
1245
+ const list = await client.request<PolicyListResource>('GET', path, undefined, signal)
1246
+ return Array.isArray(list?.items) ? list.items : []
1247
+ } catch (err) {
1248
+ // A 404 on a COLLECTION means the resource itself is not served here.
1249
+ // For the CRD that is a declared engine the cluster does not have; for
1250
+ // core `networkpolicies`, which every API server serves, it is an
1251
+ // address that is not this cluster's. Either way the answer is "the
1252
+ // boundary could not be read", never "there are no policies".
1253
+ if (err instanceof KubernetesAlreadyGoneError) {
1254
+ throw new UnreadPolicyCollection(
1255
+ {
1256
+ resource,
1257
+ path,
1258
+ why: 'absent',
1259
+ reason: 'the API server served no such collection',
1260
+ },
1261
+ resource === 'ciliumnetworkpolicies'
1262
+ ? `this backend is configured with ingress.engine: 'cilium' but the cluster serves no ${resource} resource at ${path}.`
1263
+ : `the cluster served no ${resource} collection at ${path}, which every Kubernetes API server is supposed to serve.`,
1264
+ )
1265
+ }
1266
+ if (err instanceof KubernetesCredentialError) {
1267
+ throw new UnreadPolicyCollection(
1268
+ { resource, path, why: 'forbidden', reason: err.message },
1269
+ `the ServiceAccount this backend runs as may not 'list' ${resource} (${err.message}), so what the cluster admits on the agent port cannot be read.`,
1270
+ )
1271
+ }
1272
+ throw err
1273
+ }
1274
+ }
1275
+
1276
+ /**
1277
+ * Verify-not-trust for ingress: list the namespace's policies, evaluate them
1278
+ * against the pod's real labels, and refuse unless the agent port is closed.
1279
+ *
1280
+ * Called BEFORE the POST on every path that creates a Sandbox, so a refusal
1281
+ * leaves no Sandbox and no PVC behind — and, on the claim path, after the
1282
+ * bind, where a refusal releases the claim through the acquire path's own
1283
+ * cleanup.
1284
+ */
1285
+ export async function verifyIngressPolicyApplied(
1286
+ client: KubernetesClient,
1287
+ target: IngressVerificationTarget,
1288
+ signal?: AbortSignal,
1289
+ ): Promise<void> {
1290
+ const documents: IngressPolicyDocument[] = []
1291
+ try {
1292
+ documents.push(
1293
+ ...readCoreIngressPolicies(
1294
+ await listPolicies(
1295
+ client,
1296
+ networkPolicyCollectionPath(target.namespace),
1297
+ 'networkpolicies',
1298
+ signal,
1299
+ ),
1300
+ target,
1301
+ ),
1302
+ )
1303
+ if (target.engine === 'cilium') {
1304
+ documents.push(
1305
+ ...readCiliumIngressPolicies(
1306
+ await listPolicies(
1307
+ client,
1308
+ ciliumNetworkPolicyCollectionPath(target.namespace),
1309
+ 'ciliumnetworkpolicies',
1310
+ signal,
1311
+ ),
1312
+ target,
1313
+ ),
1314
+ )
1315
+ }
1316
+ } catch (err) {
1317
+ if (!(err instanceof UnreadPolicyCollection)) throw err
1318
+ // Whatever WAS read is still reported, with a verdict each: on the
1319
+ // cilium arm the core list has usually already been enumerated, and a
1320
+ // refusal that dropped it would tell the operator less than this
1321
+ // check actually knows. What it must not do is describe the list it
1322
+ // never got — hence `unread`.
1323
+ throw new KubernetesIngressPolicyError(
1324
+ 'not-evaluable',
1325
+ target.subject,
1326
+ target.podLabels,
1327
+ target.agentPort,
1328
+ decideIngressCoverage(documents, target).examined,
1329
+ err.summary,
1330
+ [err.source],
1331
+ )
1332
+ }
1333
+
1334
+ const decision = decideIngressCoverage(documents, target)
1335
+ if (decision.refusal === undefined) return
1336
+ throw new KubernetesIngressPolicyError(
1337
+ decision.refusal.kind,
1338
+ target.subject,
1339
+ target.podLabels,
1340
+ target.agentPort,
1341
+ decision.examined,
1342
+ decision.refusal.summary,
1343
+ )
1344
+ }