@namzu/sandbox 26.0.0 → 27.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 (83) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +20 -7
  3. package/dist/backends/firecracker/protocol.d.ts +46 -0
  4. package/dist/backends/firecracker/protocol.d.ts.map +1 -1
  5. package/dist/backends/firecracker/protocol.js +31 -0
  6. package/dist/backends/firecracker/protocol.js.map +1 -1
  7. package/dist/backends/firecracker/transport.d.ts +15 -1
  8. package/dist/backends/firecracker/transport.d.ts.map +1 -1
  9. package/dist/backends/firecracker/transport.js +16 -0
  10. package/dist/backends/firecracker/transport.js.map +1 -1
  11. package/dist/backends/kubernetes/allocation.d.ts +115 -0
  12. package/dist/backends/kubernetes/allocation.d.ts.map +1 -0
  13. package/dist/backends/kubernetes/allocation.js +130 -0
  14. package/dist/backends/kubernetes/allocation.js.map +1 -0
  15. package/dist/backends/kubernetes/egress-policy.d.ts +111 -35
  16. package/dist/backends/kubernetes/egress-policy.d.ts.map +1 -1
  17. package/dist/backends/kubernetes/egress-policy.js +81 -41
  18. package/dist/backends/kubernetes/egress-policy.js.map +1 -1
  19. package/dist/backends/kubernetes/identity.d.ts +129 -0
  20. package/dist/backends/kubernetes/identity.d.ts.map +1 -1
  21. package/dist/backends/kubernetes/identity.js +150 -0
  22. package/dist/backends/kubernetes/identity.js.map +1 -1
  23. package/dist/backends/kubernetes/index.d.ts +23 -1
  24. package/dist/backends/kubernetes/index.d.ts.map +1 -1
  25. package/dist/backends/kubernetes/index.js +22 -3
  26. package/dist/backends/kubernetes/index.js.map +1 -1
  27. package/dist/backends/kubernetes/objects.d.ts +76 -0
  28. package/dist/backends/kubernetes/objects.d.ts.map +1 -1
  29. package/dist/backends/kubernetes/objects.js +61 -0
  30. package/dist/backends/kubernetes/objects.js.map +1 -1
  31. package/dist/backends/kubernetes/per-sandbox-fence.d.ts +194 -0
  32. package/dist/backends/kubernetes/per-sandbox-fence.d.ts.map +1 -0
  33. package/dist/backends/kubernetes/per-sandbox-fence.js +522 -0
  34. package/dist/backends/kubernetes/per-sandbox-fence.js.map +1 -0
  35. package/dist/backends/kubernetes/per-sandbox-policy.d.ts +45 -3
  36. package/dist/backends/kubernetes/per-sandbox-policy.d.ts.map +1 -1
  37. package/dist/backends/kubernetes/per-sandbox-policy.js +98 -8
  38. package/dist/backends/kubernetes/per-sandbox-policy.js.map +1 -1
  39. package/dist/backends/kubernetes/sandbox.d.ts +4 -9
  40. package/dist/backends/kubernetes/sandbox.d.ts.map +1 -1
  41. package/dist/backends/kubernetes/sandbox.js +4 -9
  42. package/dist/backends/kubernetes/sandbox.js.map +1 -1
  43. package/dist/backends/kubernetes/transport.d.ts +108 -1
  44. package/dist/backends/kubernetes/transport.d.ts.map +1 -1
  45. package/dist/backends/kubernetes/transport.js +150 -6
  46. package/dist/backends/kubernetes/transport.js.map +1 -1
  47. package/dist/backends/kubernetes/workspace-allocate.d.ts +206 -0
  48. package/dist/backends/kubernetes/workspace-allocate.d.ts.map +1 -0
  49. package/dist/backends/kubernetes/workspace-allocate.js +332 -0
  50. package/dist/backends/kubernetes/workspace-allocate.js.map +1 -0
  51. package/dist/backends/kubernetes/workspace-observe.d.ts +187 -0
  52. package/dist/backends/kubernetes/workspace-observe.d.ts.map +1 -0
  53. package/dist/backends/kubernetes/workspace-observe.js +343 -0
  54. package/dist/backends/kubernetes/workspace-observe.js.map +1 -0
  55. package/dist/backends/kubernetes/workspace-per-sandbox.d.ts +94 -0
  56. package/dist/backends/kubernetes/workspace-per-sandbox.d.ts.map +1 -0
  57. package/dist/backends/kubernetes/workspace-per-sandbox.js +119 -0
  58. package/dist/backends/kubernetes/workspace-per-sandbox.js.map +1 -0
  59. package/dist/backends/kubernetes/workspace.d.ts +139 -5
  60. package/dist/backends/kubernetes/workspace.d.ts.map +1 -1
  61. package/dist/backends/kubernetes/workspace.js +434 -36
  62. package/dist/backends/kubernetes/workspace.js.map +1 -1
  63. package/dist/index.d.ts +83 -7
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +100 -6
  66. package/dist/index.js.map +1 -1
  67. package/package.json +3 -3
  68. package/src/backends/firecracker/protocol.ts +47 -0
  69. package/src/backends/firecracker/transport.ts +31 -0
  70. package/src/backends/kubernetes/allocation.ts +199 -0
  71. package/src/backends/kubernetes/egress-policy.ts +165 -42
  72. package/src/backends/kubernetes/identity.ts +210 -0
  73. package/src/backends/kubernetes/index.ts +61 -2
  74. package/src/backends/kubernetes/objects.ts +98 -0
  75. package/src/backends/kubernetes/per-sandbox-fence.ts +800 -0
  76. package/src/backends/kubernetes/per-sandbox-policy.ts +152 -17
  77. package/src/backends/kubernetes/sandbox.ts +4 -9
  78. package/src/backends/kubernetes/transport.ts +203 -4
  79. package/src/backends/kubernetes/workspace-allocate.ts +607 -0
  80. package/src/backends/kubernetes/workspace-observe.ts +590 -0
  81. package/src/backends/kubernetes/workspace-per-sandbox.ts +179 -0
  82. package/src/backends/kubernetes/workspace.ts +655 -34
  83. package/src/index.ts +193 -3
@@ -0,0 +1,800 @@
1
+ /**
2
+ * What the per-sandbox egress fence is checked FOR, and the operator-pinned
3
+ * host set that tightens it.
4
+ *
5
+ * `per-sandbox-policy.ts` used to prove the fence by GETting the
6
+ * `ValidatingAdmissionPolicy` and its binding and stopping at "the request
7
+ * succeeded". An object that exists is not an object that fences: a binding
8
+ * whose `validationActions` is `[Audit]` or `[Warn]` logs and admits, a
9
+ * binding that names another policy attaches nothing to this one, a policy
10
+ * with `failurePolicy: Ignore` admits whatever its expressions cannot
11
+ * evaluate, a binding whose namespace selector excludes the sandboxes'
12
+ * namespace never runs there, and a policy whose `matchConditions` name a
13
+ * different user never matches this host. Each reads, from the outside,
14
+ * exactly like an enforced fence, which is the failure the `no-network`
15
+ * baseline's check exists to prevent for its own object: that one compares the
16
+ * named object to the translation field by field, and refuses on any
17
+ * difference.
18
+ *
19
+ * This module is the same standard applied to the fence. It reads what the
20
+ * cluster stored and compares it, field by field, to what the writer relies
21
+ * on, and refuses with a named error carrying the field that failed — before
22
+ * anything is written.
23
+ *
24
+ * ## What it can and cannot prove
25
+ *
26
+ * It proves the CONFIGURATION of the fence: that the binding attaches this
27
+ * policy, denies, fails closed, covers the namespace and the verbs, and that
28
+ * the policy matches the configured identity. It does not evaluate the
29
+ * policy's CEL: whether the shipped expressions refuse what they claim is
30
+ * measured against an API server (see the header of
31
+ * `validatingadmissionpolicy-cilium.yaml`), not derivable from the stored
32
+ * object. The one exception is the companion policy below, whose expressions
33
+ * this module owns and compares verbatim.
34
+ *
35
+ * ## The operator-pinned host set
36
+ *
37
+ * The shipped fence bounds the SHAPE of what a host may write, not the names:
38
+ * any host holding the write verbs can grant any syntactically valid
39
+ * `toFQDNs` entry. `config.egress.perSandbox.permittedHostsParam` names a
40
+ * `ConfigMap` an operator maintains — the reviewed hostnames and ports — and
41
+ * a COMPANION policy (`validatingadmissionpolicy-cilium-permitted-hosts.yaml`)
42
+ * refuses, at admission, any `toFQDNs` name or port outside it. The backend
43
+ * reads the same ConfigMap and refuses locally, with a precise error instead
44
+ * of an opaque 403, but the admission rule stays the authority: the local
45
+ * check is a courtesy and a second reader, never the boundary.
46
+ *
47
+ * A companion rather than a change to the shipped policy because the shipped
48
+ * policy is already applied in clusters, and a host that does not configure
49
+ * the parameter must see byte-for-byte what it saw before. The companion
50
+ * covers CREATE and UPDATE only: a DELETE never widens, and a missing
51
+ * parameter must not make `setNetworkPolicy([])` — the revoke — impossible.
52
+ */
53
+
54
+ import { isDeepStrictEqual } from 'node:util'
55
+
56
+ import {
57
+ type KubernetesPerSandboxEgressConfig,
58
+ type KubernetesPermittedHostsParam,
59
+ assertUsableHost,
60
+ } from './egress-policy.js'
61
+ import { isRecord, matchesLabelSelector, readList } from './ingress-policy.js'
62
+ import {
63
+ KubernetesAlreadyGoneError,
64
+ type KubernetesClient,
65
+ KubernetesCredentialError,
66
+ } from './k8s-client.js'
67
+ import { configMapPath } from './objects.js'
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Errors
71
+ // ---------------------------------------------------------------------------
72
+
73
+ /** The admission object a fence finding is about. */
74
+ export type FenceObjectKind = 'ValidatingAdmissionPolicy' | 'ValidatingAdmissionPolicyBinding'
75
+
76
+ /**
77
+ * Raised when the fence EXISTS but would not enforce what this backend relies
78
+ * on — `validationActions` without `Deny`, a binding that names another
79
+ * policy, `failurePolicy: Ignore`, a namespace selector that leaves the
80
+ * sandboxes' namespace out, a `matchConditions` that names another identity,
81
+ * a companion policy whose expressions are not the ones this version ships,
82
+ * or a binding whose parameter is not the configured one.
83
+ *
84
+ * Its own class rather than a reuse of
85
+ * `KubernetesAdmissionFenceMissingError`, because the operator's action
86
+ * differs: that error says to apply a manifest, this one says an applied
87
+ * object is wrong and names the field. `field` is the dotted path inside
88
+ * `resourceKind`, `expected` what the writer needs and `actual` what the
89
+ * cluster holds.
90
+ */
91
+ export class KubernetesAdmissionFenceInertError extends Error {
92
+ override readonly name = 'KubernetesAdmissionFenceInertError'
93
+
94
+ constructor(
95
+ readonly resourceKind: FenceObjectKind,
96
+ readonly objectName: string,
97
+ readonly field: string,
98
+ readonly expected: string,
99
+ readonly actual: string,
100
+ ) {
101
+ super(
102
+ `kubernetes: config.egress.perSandbox is configured but the ${resourceKind} named ${objectName} would not fence what this host writes: ${field} is ${actual}, expected ${expected}. An applied admission object that does not enforce reads exactly like one that does, and the host holds create/patch/delete on every CiliumNetworkPolicy in the namespace, so nothing was written. Correct the object (the shipped k8s/manifests/validatingadmissionpolicy-cilium.yaml and validatingadmissionpolicy-cilium-permitted-hosts.yaml are the references) and call again.`,
103
+ )
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Raised when `permittedHostsParam` is configured and the ConfigMap it names
109
+ * cannot be used: absent, unreadable, or not in the shape the companion
110
+ * policy reads. Every `setNetworkPolicy` call that grants a host is refused
111
+ * with it, and nothing is written.
112
+ */
113
+ export class KubernetesPermittedHostsParamError extends Error {
114
+ override readonly name = 'KubernetesPermittedHostsParamError'
115
+
116
+ constructor(
117
+ readonly param: KubernetesPermittedHostsParam,
118
+ reason: string,
119
+ cause?: unknown,
120
+ ) {
121
+ super(
122
+ `kubernetes: config.egress.perSandbox.permittedHostsParam names ConfigMap ${param.namespace}/${param.name}, which ${reason}. The admission policy that pins the permitted hosts reads the same object and denies every grant while it is missing or malformed, so this host refuses first, with nothing written. The expected shape is data.hosts (exact hostnames), data.patterns (*.domain patterns) and data.ports (numbers), each one entry per line; see k8s/manifests/validatingadmissionpolicy-cilium-permitted-hosts.yaml.`,
123
+ cause === undefined ? undefined : { cause },
124
+ )
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Raised when a `setNetworkPolicy` call names a host or a port the
130
+ * operator-pinned set does not contain. Local, named and before any write; the
131
+ * admission policy would refuse the same body with an opaque 403.
132
+ *
133
+ * `hosts` are the `allowedHosts` entries as written; `ports` are the numbers
134
+ * outside the set, and `unrestrictedPortsFor` the hosts whose rule carries no
135
+ * port restriction at all — every port is outside any list.
136
+ */
137
+ export class KubernetesPermittedHostsError extends Error {
138
+ override readonly name = 'KubernetesPermittedHostsError'
139
+
140
+ constructor(
141
+ readonly param: KubernetesPermittedHostsParam,
142
+ readonly hosts: readonly string[],
143
+ readonly ports: readonly number[],
144
+ readonly unrestrictedPortsFor: readonly string[],
145
+ ) {
146
+ const parts: string[] = []
147
+ if (hosts.length > 0) {
148
+ parts.push(`the host(s) ${hosts.map((h) => JSON.stringify(h)).join(', ')} are not in it`)
149
+ }
150
+ if (ports.length > 0) {
151
+ parts.push(`the port(s) ${ports.join(', ')} are not in it`)
152
+ }
153
+ if (unrestrictedPortsFor.length > 0) {
154
+ parts.push(
155
+ `${unrestrictedPortsFor.map((h) => JSON.stringify(h)).join(', ')} would be granted on EVERY port, because config.egress.perSandbox.narrowing sets no ports for it, and every port is outside any list (set narrowing.ports or narrowing.hostPorts)`,
156
+ )
157
+ }
158
+ super(
159
+ `kubernetes: refusing setNetworkPolicy: ConfigMap ${param.namespace}/${param.name} pins the hosts and ports this host may grant, and ${parts.join('; ')}. The shipped admission policy refuses the same body, so it would be a 403 after the fact. Nothing was written.`,
160
+ )
161
+ }
162
+ }
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Reading an admission object
166
+ // ---------------------------------------------------------------------------
167
+
168
+ function describe(value: unknown): string {
169
+ if (value === undefined) return 'absent'
170
+ const text = JSON.stringify(value)
171
+ return text === undefined ? String(value) : text.length > 200 ? `${text.slice(0, 200)}…` : text
172
+ }
173
+
174
+ /** One expression, whitespace-collapsed — a folded YAML scalar and a one-liner compare equal. */
175
+ function normalizeExpression(value: unknown): string {
176
+ return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : ''
177
+ }
178
+
179
+ const CILIUM_GROUP = 'cilium.io'
180
+ const CILIUM_VERSION = 'v2'
181
+ const CILIUM_RESOURCE = 'ciliumnetworkpolicies'
182
+
183
+ /** The verbs of a `CiliumNetworkPolicy` write the shipped main policy covers. */
184
+ export const FENCE_OPERATIONS = ['CREATE', 'UPDATE', 'DELETE'] as const
185
+ /** The verbs the companion covers: a DELETE never widens, see the module header. */
186
+ export const COMPANION_OPERATIONS = ['CREATE', 'UPDATE'] as const
187
+
188
+ type Operation = 'CREATE' | 'UPDATE' | 'DELETE'
189
+
190
+ function listOf(value: unknown): readonly unknown[] | undefined {
191
+ const read = readList(value)
192
+ return read === 'unreadable' ? undefined : (read ?? [])
193
+ }
194
+
195
+ /** Does one admission `NamedRuleWithOperations` cover this operation on the CRD? */
196
+ function ruleCovers(rule: unknown, operation: Operation): boolean {
197
+ if (!isRecord(rule)) return false
198
+ const has = (field: string, wanted: string): boolean => {
199
+ const list = listOf(rule[field])
200
+ return list !== undefined && (list.includes(wanted) || list.includes('*'))
201
+ }
202
+ // A rule naming `resourceNames` narrows to those objects, which cannot
203
+ // include the ones this host creates under names it generates.
204
+ const names = listOf(rule.resourceNames)
205
+ if (names === undefined || names.length > 0) return false
206
+ return (
207
+ has('apiGroups', CILIUM_GROUP) &&
208
+ has('apiVersions', CILIUM_VERSION) &&
209
+ has('resources', CILIUM_RESOURCE) &&
210
+ has('operations', operation)
211
+ )
212
+ }
213
+
214
+ /** Does any rule of the list cover the operation? `undefined` when the list is not a list. */
215
+ function rulesCover(rules: unknown, operation: Operation): boolean | undefined {
216
+ const list = listOf(rules)
217
+ if (list === undefined) return undefined
218
+ return list.some((rule) => ruleCovers(rule, operation))
219
+ }
220
+
221
+ /**
222
+ * `matchConstraints` (policy) / `matchResources` (binding): both are the same
223
+ * `MatchResources` shape. Proves that, between them, the writes in
224
+ * `operations` reach the validations — resource rules cover them, no exclude
225
+ * rule removes them, and neither selector can be satisfied by an object the
226
+ * host chooses the labels of.
227
+ */
228
+ function assertMatchResources(
229
+ kind: FenceObjectKind,
230
+ objectName: string,
231
+ path: 'spec.matchConstraints' | 'spec.matchResources',
232
+ match: unknown,
233
+ operations: readonly Operation[],
234
+ namespace: string | undefined,
235
+ /** A policy MUST state its resource rules; a binding may leave them to the policy. */
236
+ rulesRequired: boolean,
237
+ ): void {
238
+ const fail = (field: string, expected: string, actual: unknown): never => {
239
+ throw new KubernetesAdmissionFenceInertError(
240
+ kind,
241
+ objectName,
242
+ `${path}.${field}`,
243
+ expected,
244
+ describe(actual),
245
+ )
246
+ }
247
+ if (match === undefined || match === null) {
248
+ if (rulesRequired)
249
+ fail('resourceRules', 'rules covering cilium.io ciliumnetworkpolicies', match)
250
+ return
251
+ }
252
+ if (!isRecord(match)) {
253
+ throw new KubernetesAdmissionFenceInertError(
254
+ kind,
255
+ objectName,
256
+ path,
257
+ 'an object',
258
+ describe(match),
259
+ )
260
+ }
261
+ if (match.resourceRules !== undefined || rulesRequired) {
262
+ for (const operation of operations) {
263
+ const covered = rulesCover(match.resourceRules, operation)
264
+ if (covered !== true) {
265
+ fail(
266
+ 'resourceRules',
267
+ `a rule covering ${operation} of cilium.io/${CILIUM_VERSION} ${CILIUM_RESOURCE} (a binding that narrows the policy must still reach every write the host issues)`,
268
+ match.resourceRules,
269
+ )
270
+ }
271
+ }
272
+ }
273
+ const excluded = match.excludeResourceRules
274
+ if (excluded !== undefined) {
275
+ for (const operation of operations) {
276
+ if (rulesCover(excluded, operation) !== false) {
277
+ fail(
278
+ 'excludeResourceRules',
279
+ `no rule excluding ${operation} of ${CILIUM_RESOURCE}`,
280
+ excluded,
281
+ )
282
+ }
283
+ }
284
+ }
285
+ // An object selector is matched against the OBJECT's labels, which the
286
+ // writing host chooses: a fence that applies only to objects carrying a
287
+ // label the host can simply leave off fences nothing.
288
+ const objectSelector = match.objectSelector
289
+ if (objectSelector !== undefined && objectSelector !== null) {
290
+ const empty =
291
+ isRecord(objectSelector) &&
292
+ Object.keys(isRecord(objectSelector.matchLabels) ? objectSelector.matchLabels : {}).length ===
293
+ 0 &&
294
+ (listOf(objectSelector.matchExpressions) ?? [null]).length === 0
295
+ if (!empty)
296
+ fail(
297
+ 'objectSelector',
298
+ "absent or empty (the host chooses its objects' labels)",
299
+ objectSelector,
300
+ )
301
+ }
302
+ const namespaceSelector = match.namespaceSelector
303
+ if (namespace !== undefined && namespaceSelector !== undefined && namespaceSelector !== null) {
304
+ // Matched against the one label every namespace is guaranteed to carry.
305
+ // A selector on any other label is not decidable from the stored
306
+ // objects, and an unprovable fence is an unproven one.
307
+ const verdict = matchesLabelSelector(namespaceSelector, {
308
+ 'kubernetes.io/metadata.name': namespace,
309
+ })
310
+ if (verdict !== 'yes') {
311
+ fail(
312
+ 'namespaceSelector',
313
+ `a selector that selects namespace ${namespace} (decided from the automatic kubernetes.io/metadata.name label, the only one this host can rely on; a selector on other labels is refused as unprovable)`,
314
+ namespaceSelector,
315
+ )
316
+ }
317
+ }
318
+ }
319
+
320
+ /** The conditions the host identity is checked against, when one is configured. */
321
+ function assertHostIdentity(
322
+ policyName: string,
323
+ policy: Record<string, unknown>,
324
+ hostIdentity: string | undefined,
325
+ ): void {
326
+ if (hostIdentity === undefined) return
327
+ const expected = `request.userInfo.username == '${hostIdentity}'`
328
+ const conditions = listOf(policy.matchConditions)
329
+ const names = (conditions ?? []).map((c) =>
330
+ isRecord(c) ? normalizeExpression(c.expression) : '',
331
+ )
332
+ // Exactly that one condition: a second one can only narrow, and one that
333
+ // narrows to nothing fences nothing.
334
+ if (conditions === undefined || names.length !== 1 || names[0] !== expected) {
335
+ throw new KubernetesAdmissionFenceInertError(
336
+ 'ValidatingAdmissionPolicy',
337
+ policyName,
338
+ 'spec.matchConditions',
339
+ `exactly one condition, ${expected}`,
340
+ describe(names.length === 0 ? undefined : names),
341
+ )
342
+ }
343
+ }
344
+
345
+ function assertFailClosed(policyName: string, policy: Record<string, unknown>): void {
346
+ if (policy.failurePolicy !== 'Fail') {
347
+ throw new KubernetesAdmissionFenceInertError(
348
+ 'ValidatingAdmissionPolicy',
349
+ policyName,
350
+ 'spec.failurePolicy',
351
+ '"Fail" (an expression that errors must refuse the write, not admit it)',
352
+ describe(policy.failurePolicy),
353
+ )
354
+ }
355
+ }
356
+
357
+ function specOf(
358
+ kind: FenceObjectKind,
359
+ objectName: string,
360
+ object: unknown,
361
+ ): Record<string, unknown> {
362
+ const spec = isRecord(object) ? object.spec : undefined
363
+ if (!isRecord(spec)) {
364
+ throw new KubernetesAdmissionFenceInertError(
365
+ kind,
366
+ objectName,
367
+ 'spec',
368
+ 'an object',
369
+ describe(spec),
370
+ )
371
+ }
372
+ return spec
373
+ }
374
+
375
+ function assertBindingAttaches(
376
+ bindingName: string,
377
+ spec: Record<string, unknown>,
378
+ policyName: string,
379
+ ): void {
380
+ if (spec.policyName !== policyName) {
381
+ throw new KubernetesAdmissionFenceInertError(
382
+ 'ValidatingAdmissionPolicyBinding',
383
+ bindingName,
384
+ 'spec.policyName',
385
+ JSON.stringify(policyName),
386
+ describe(spec.policyName),
387
+ )
388
+ }
389
+ const actions = listOf(spec.validationActions)
390
+ if (actions === undefined || !actions.includes('Deny')) {
391
+ throw new KubernetesAdmissionFenceInertError(
392
+ 'ValidatingAdmissionPolicyBinding',
393
+ bindingName,
394
+ 'spec.validationActions',
395
+ 'a list including "Deny" (Audit and Warn log and admit)',
396
+ describe(spec.validationActions),
397
+ )
398
+ }
399
+ }
400
+
401
+ // ---------------------------------------------------------------------------
402
+ // The shipped fence
403
+ // ---------------------------------------------------------------------------
404
+
405
+ /** What the writer needs of the shipped main fence, read from the cluster. */
406
+ export interface FenceObjects {
407
+ readonly policyName: string
408
+ readonly policy: unknown
409
+ readonly bindingName: string
410
+ readonly binding: unknown
411
+ }
412
+
413
+ /**
414
+ * The main fence: attached, denying, fail-closed, covering the namespace and
415
+ * every verb the host issues, and — when configured — matching the host.
416
+ */
417
+ export function assertFenceEnforces(
418
+ fence: FenceObjects,
419
+ namespace: string,
420
+ perSandbox: Pick<KubernetesPerSandboxEgressConfig, 'hostIdentity'>,
421
+ ): void {
422
+ assertPolicyAndBinding(fence, namespace, FENCE_OPERATIONS, perSandbox.hostIdentity)
423
+ }
424
+
425
+ function assertPolicyAndBinding(
426
+ fence: FenceObjects,
427
+ namespace: string,
428
+ operations: readonly Operation[],
429
+ hostIdentity: string | undefined,
430
+ ): { policy: Record<string, unknown>; binding: Record<string, unknown> } {
431
+ const policy = specOf('ValidatingAdmissionPolicy', fence.policyName, fence.policy)
432
+ assertFailClosed(fence.policyName, policy)
433
+ assertMatchResources(
434
+ 'ValidatingAdmissionPolicy',
435
+ fence.policyName,
436
+ 'spec.matchConstraints',
437
+ policy.matchConstraints,
438
+ operations,
439
+ undefined,
440
+ true,
441
+ )
442
+ const validations = listOf(policy.validations)
443
+ if (validations === undefined || validations.length === 0) {
444
+ throw new KubernetesAdmissionFenceInertError(
445
+ 'ValidatingAdmissionPolicy',
446
+ fence.policyName,
447
+ 'spec.validations',
448
+ 'at least one validation (a policy with none validates nothing)',
449
+ describe(policy.validations),
450
+ )
451
+ }
452
+ assertHostIdentity(fence.policyName, policy, hostIdentity)
453
+
454
+ const binding = specOf('ValidatingAdmissionPolicyBinding', fence.bindingName, fence.binding)
455
+ assertBindingAttaches(fence.bindingName, binding, fence.policyName)
456
+ assertMatchResources(
457
+ 'ValidatingAdmissionPolicyBinding',
458
+ fence.bindingName,
459
+ 'spec.matchResources',
460
+ binding.matchResources,
461
+ operations,
462
+ namespace,
463
+ false,
464
+ )
465
+ return { policy, binding }
466
+ }
467
+
468
+ // ---------------------------------------------------------------------------
469
+ // The companion: the operator-pinned host set
470
+ // ---------------------------------------------------------------------------
471
+
472
+ /** The companion policy's name: the main policy's, with this appended. */
473
+ export const PERMITTED_HOSTS_POLICY_SUFFIX = '-hosts'
474
+
475
+ /**
476
+ * The companion's names, derived rather than configured: one more name to
477
+ * keep in step with an applied object is one more way for the check to read
478
+ * the wrong object.
479
+ */
480
+ export function permittedHostsFenceNames(perSandbox: KubernetesPerSandboxEgressConfig): {
481
+ readonly policyName: string
482
+ readonly bindingName: string
483
+ } {
484
+ const policyName = `${perSandbox.admissionPolicyName}${PERMITTED_HOSTS_POLICY_SUFFIX}`
485
+ return { policyName, bindingName: `${policyName}-binding` }
486
+ }
487
+
488
+ /** The companion's `spec.paramKind`. */
489
+ export const PERMITTED_HOSTS_PARAM_KIND = { apiVersion: 'v1', kind: 'ConfigMap' } as const
490
+
491
+ /**
492
+ * The companion's variables and validation, VERBATIM. Owned here, compared
493
+ * against the cluster's copy by {@link assertPermittedHostsFenceEnforces} and
494
+ * against the shipped YAML by a test, so the three cannot drift: an applied
495
+ * companion with an edited expression is a fence this backend cannot vouch
496
+ * for, and is refused as one.
497
+ *
498
+ * Read as: every egress rule that carries `toFQDNs` must carry `toPorts`, every
499
+ * port of which is in `data.ports`; and every `toFQDNs` entry is a `matchName`
500
+ * in `data.hosts` or a `matchPattern` in `data.patterns`. Lists are
501
+ * newline-separated strings because a ConfigMap holds strings; a key that is
502
+ * missing makes the expression error, and `failurePolicy: Fail` makes an
503
+ * error a refusal.
504
+ */
505
+ export const PERMITTED_HOSTS_VARIABLES: readonly {
506
+ readonly name: string
507
+ readonly expression: string
508
+ }[] = [
509
+ {
510
+ name: 'egress',
511
+ expression: 'has(object.spec) && has(object.spec.egress) ? object.spec.egress : []',
512
+ },
513
+ { name: 'names', expression: "params.data.hosts.split('\\n')" },
514
+ { name: 'patterns', expression: "params.data.patterns.split('\\n')" },
515
+ { name: 'ports', expression: "params.data.ports.split('\\n')" },
516
+ ]
517
+
518
+ export const PERMITTED_HOSTS_VALIDATIONS: readonly { readonly expression: string }[] = [
519
+ {
520
+ expression:
521
+ 'variables.egress.all(r, !has(r.toFQDNs) || (has(r.toPorts) && size(r.toPorts) > 0 && ' +
522
+ 'r.toPorts.all(p, has(p.ports) && size(p.ports) > 0 && ' +
523
+ 'p.ports.all(q, q.port in variables.ports)) && ' +
524
+ 'r.toFQDNs.all(f, (has(f.matchName) && f.matchName in variables.names) || ' +
525
+ '(has(f.matchPattern) && f.matchPattern in variables.patterns))))',
526
+ },
527
+ ]
528
+
529
+ /**
530
+ * The companion policy and its binding, compared to what this version ships.
531
+ * Everything {@link assertFenceEnforces} proves for the main fence, over
532
+ * CREATE and UPDATE, plus: the parameter kind, the variables and validation
533
+ * verbatim, and a binding whose `paramRef` is THIS ConfigMap, by name, and
534
+ * denies when it is missing — a binding with no parameter, or one that lets a
535
+ * missing parameter through, pins nothing.
536
+ */
537
+ export function assertPermittedHostsFenceEnforces(
538
+ fence: FenceObjects,
539
+ namespace: string,
540
+ perSandbox: Pick<KubernetesPerSandboxEgressConfig, 'hostIdentity'>,
541
+ param: KubernetesPermittedHostsParam,
542
+ ): void {
543
+ const { policy, binding } = assertPolicyAndBinding(
544
+ fence,
545
+ namespace,
546
+ COMPANION_OPERATIONS,
547
+ perSandbox.hostIdentity,
548
+ )
549
+ const policyFail = (field: string, expected: string, actual: unknown): never => {
550
+ throw new KubernetesAdmissionFenceInertError(
551
+ 'ValidatingAdmissionPolicy',
552
+ fence.policyName,
553
+ field,
554
+ expected,
555
+ describe(actual),
556
+ )
557
+ }
558
+ if (!isDeepStrictEqual(policy.paramKind, PERMITTED_HOSTS_PARAM_KIND)) {
559
+ policyFail('spec.paramKind', describe(PERMITTED_HOSTS_PARAM_KIND), policy.paramKind)
560
+ }
561
+ const variables = (listOf(policy.variables) ?? []).map((v) =>
562
+ isRecord(v) ? { name: v.name, expression: normalizeExpression(v.expression) } : v,
563
+ )
564
+ const expectedVariables = PERMITTED_HOSTS_VARIABLES.map((v) => ({
565
+ name: v.name,
566
+ expression: normalizeExpression(v.expression),
567
+ }))
568
+ if (!isDeepStrictEqual(variables, expectedVariables)) {
569
+ policyFail('spec.variables', describe(expectedVariables), variables)
570
+ }
571
+ const validations = (listOf(policy.validations) ?? []).map((v) =>
572
+ isRecord(v) ? { expression: normalizeExpression(v.expression) } : v,
573
+ )
574
+ const expectedValidations = PERMITTED_HOSTS_VALIDATIONS.map((v) => ({
575
+ expression: normalizeExpression(v.expression),
576
+ }))
577
+ if (!isDeepStrictEqual(validations, expectedValidations)) {
578
+ policyFail('spec.validations', describe(expectedValidations), validations)
579
+ }
580
+
581
+ const bindingFail = (field: string, expected: string, actual: unknown): never => {
582
+ throw new KubernetesAdmissionFenceInertError(
583
+ 'ValidatingAdmissionPolicyBinding',
584
+ fence.bindingName,
585
+ field,
586
+ expected,
587
+ describe(actual),
588
+ )
589
+ }
590
+ const paramRef = binding.paramRef
591
+ if (!isRecord(paramRef)) {
592
+ bindingFail(
593
+ 'spec.paramRef',
594
+ `a reference to ConfigMap ${param.namespace}/${param.name} (a binding with no parameter pins no hosts)`,
595
+ paramRef,
596
+ )
597
+ return
598
+ }
599
+ if (paramRef.name !== param.name) {
600
+ bindingFail('spec.paramRef.name', JSON.stringify(param.name), paramRef.name)
601
+ }
602
+ if (paramRef.namespace !== param.namespace) {
603
+ bindingFail('spec.paramRef.namespace', JSON.stringify(param.namespace), paramRef.namespace)
604
+ }
605
+ if (paramRef.selector !== undefined) {
606
+ bindingFail(
607
+ 'spec.paramRef.selector',
608
+ 'absent (the parameter is named, not selected)',
609
+ paramRef.selector,
610
+ )
611
+ }
612
+ if (paramRef.parameterNotFoundAction !== 'Deny') {
613
+ bindingFail(
614
+ 'spec.paramRef.parameterNotFoundAction',
615
+ '"Deny" (an allow here admits every grant while the ConfigMap is missing)',
616
+ paramRef.parameterNotFoundAction,
617
+ )
618
+ }
619
+ }
620
+
621
+ // ---------------------------------------------------------------------------
622
+ // The parameter, and the local check against it
623
+ // ---------------------------------------------------------------------------
624
+
625
+ /** The permitted set, as read from the ConfigMap. */
626
+ export interface PermittedHosts {
627
+ readonly hosts: ReadonlySet<string>
628
+ readonly patterns: ReadonlySet<string>
629
+ readonly ports: ReadonlySet<number>
630
+ }
631
+
632
+ /**
633
+ * Split one key into its entries. The admission policy splits the same string
634
+ * on `\n` and compares entries byte for byte, so an entry with a stray space
635
+ * or a `\r` would be accepted here and never match there; it is refused as
636
+ * malformed instead. Empty lines are dropped, which is what lets a value end
637
+ * in a newline.
638
+ */
639
+ function entriesOf(
640
+ param: KubernetesPermittedHostsParam,
641
+ data: Record<string, unknown>,
642
+ key: 'hosts' | 'patterns' | 'ports',
643
+ ): readonly string[] {
644
+ const value = data[key]
645
+ if (typeof value !== 'string') {
646
+ throw new KubernetesPermittedHostsParamError(
647
+ param,
648
+ `has no data.${key} string (found ${describe(value)}); the policy's expression reads it, so a missing key denies every grant`,
649
+ )
650
+ }
651
+ const entries = value.split('\n').filter((line) => line !== '')
652
+ for (const entry of entries) {
653
+ if (entry.trim() !== entry) {
654
+ throw new KubernetesPermittedHostsParamError(
655
+ param,
656
+ `has a data.${key} entry with surrounding whitespace or a carriage return (${JSON.stringify(entry)}); the policy compares entries byte for byte, so it would never match`,
657
+ )
658
+ }
659
+ }
660
+ return entries
661
+ }
662
+
663
+ /**
664
+ * Parse the ConfigMap's `data`, with the same grammar the writer's hosts go
665
+ * through ({@link assertUsableHost}), so a parameter cannot permit a name the
666
+ * writer would refuse to emit.
667
+ */
668
+ export function parsePermittedHosts(
669
+ param: KubernetesPermittedHostsParam,
670
+ configMap: unknown,
671
+ ): PermittedHosts {
672
+ const data = isRecord(configMap) ? configMap.data : undefined
673
+ if (!isRecord(data)) {
674
+ throw new KubernetesPermittedHostsParamError(param, 'has no data')
675
+ }
676
+ const bad = (what: string, entry: string, why: string): never => {
677
+ throw new KubernetesPermittedHostsParamError(
678
+ param,
679
+ `lists ${what} ${JSON.stringify(entry)}, which ${why}`,
680
+ )
681
+ }
682
+ const hosts = new Set<string>()
683
+ for (const entry of entriesOf(param, data, 'hosts')) {
684
+ if (entry.startsWith('.')) {
685
+ bad(
686
+ 'host',
687
+ entry,
688
+ 'is a domain entry; data.hosts holds exact names, and a domain is its name here plus a *.domain line in data.patterns',
689
+ )
690
+ }
691
+ try {
692
+ assertUsableHost(entry)
693
+ } catch (err) {
694
+ bad(
695
+ 'host',
696
+ entry,
697
+ `is not a usable hostname (${err instanceof Error ? err.message : String(err)})`,
698
+ )
699
+ }
700
+ if (entry !== entry.toLowerCase())
701
+ bad('host', entry, 'is not lowercase; Cilium compares the lowercase name a lookup returned')
702
+ hosts.add(entry)
703
+ }
704
+ const patterns = new Set<string>()
705
+ for (const entry of entriesOf(param, data, 'patterns')) {
706
+ if (!entry.startsWith('*.')) bad('pattern', entry, 'is not of the form *.<domain>')
707
+ const domain = entry.slice(2)
708
+ try {
709
+ assertUsableHost(`.${domain}`)
710
+ } catch (err) {
711
+ bad(
712
+ 'pattern',
713
+ entry,
714
+ `is not a usable domain pattern (${err instanceof Error ? err.message : String(err)})`,
715
+ )
716
+ }
717
+ if (domain !== domain.toLowerCase()) bad('pattern', entry, 'is not lowercase')
718
+ patterns.add(entry)
719
+ }
720
+ const ports = new Set<number>()
721
+ for (const entry of entriesOf(param, data, 'ports')) {
722
+ const port = Number(entry)
723
+ if (!/^[1-9][0-9]{0,4}$/.test(entry) || port > 65535) {
724
+ bad(
725
+ 'port',
726
+ entry,
727
+ 'is not a port number (1 to 65535, written without a sign or leading zero)',
728
+ )
729
+ }
730
+ ports.add(port)
731
+ }
732
+ return { hosts, patterns, ports }
733
+ }
734
+
735
+ /** GET and parse the parameter. A missing or unreadable ConfigMap refuses. */
736
+ export async function readPermittedHosts(
737
+ client: KubernetesClient,
738
+ param: KubernetesPermittedHostsParam,
739
+ signal?: AbortSignal,
740
+ ): Promise<PermittedHosts> {
741
+ let configMap: unknown
742
+ try {
743
+ configMap = await client.request(
744
+ 'GET',
745
+ configMapPath(param.namespace, param.name),
746
+ undefined,
747
+ signal,
748
+ )
749
+ } catch (err) {
750
+ if (err instanceof KubernetesAlreadyGoneError) {
751
+ throw new KubernetesPermittedHostsParamError(
752
+ param,
753
+ 'does not exist, so the admission policy bound to it denies every grant',
754
+ )
755
+ }
756
+ if (err instanceof KubernetesCredentialError) {
757
+ throw new KubernetesPermittedHostsParamError(
758
+ param,
759
+ 'cannot be read by this host (grant it get on that one ConfigMap; the companion manifest includes the Role)',
760
+ err,
761
+ )
762
+ }
763
+ throw err
764
+ }
765
+ return parsePermittedHosts(param, configMap)
766
+ }
767
+
768
+ /**
769
+ * Refuse, locally, any host or port outside the permitted set. `hosts` are the
770
+ * canonical `allowedHosts` entries and `portsFor` the port list the writer's
771
+ * narrowing will emit for an entry (`undefined`: no restriction).
772
+ *
773
+ * A `.domain` entry becomes a `matchName` plus a `matchPattern`, and the
774
+ * admission rule checks each against its own list, so both must be present.
775
+ */
776
+ export function assertWithinPermittedHosts(
777
+ param: KubernetesPermittedHostsParam,
778
+ permitted: PermittedHosts,
779
+ hosts: readonly string[],
780
+ portsFor: (host: string) => readonly number[] | undefined,
781
+ ): void {
782
+ const outside: string[] = []
783
+ const outsidePorts = new Set<number>()
784
+ const unrestricted: string[] = []
785
+ for (const host of hosts) {
786
+ const exact = host.startsWith('.') ? host.slice(1) : host
787
+ const allowed =
788
+ permitted.hosts.has(exact) && (!host.startsWith('.') || permitted.patterns.has(`*.${exact}`))
789
+ if (!allowed) outside.push(host)
790
+ const ports = portsFor(host)
791
+ if (ports === undefined) {
792
+ unrestricted.push(host)
793
+ continue
794
+ }
795
+ for (const port of ports) if (!permitted.ports.has(port)) outsidePorts.add(port)
796
+ }
797
+ if (outside.length > 0 || outsidePorts.size > 0 || unrestricted.length > 0) {
798
+ throw new KubernetesPermittedHostsError(param, outside, [...outsidePorts], unrestricted)
799
+ }
800
+ }