@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.
- package/CHANGELOG.md +47 -0
- package/README.md +20 -7
- package/dist/backends/firecracker/protocol.d.ts +46 -0
- package/dist/backends/firecracker/protocol.d.ts.map +1 -1
- package/dist/backends/firecracker/protocol.js +31 -0
- package/dist/backends/firecracker/protocol.js.map +1 -1
- package/dist/backends/firecracker/transport.d.ts +15 -1
- package/dist/backends/firecracker/transport.d.ts.map +1 -1
- package/dist/backends/firecracker/transport.js +16 -0
- package/dist/backends/firecracker/transport.js.map +1 -1
- package/dist/backends/kubernetes/allocation.d.ts +115 -0
- package/dist/backends/kubernetes/allocation.d.ts.map +1 -0
- package/dist/backends/kubernetes/allocation.js +130 -0
- package/dist/backends/kubernetes/allocation.js.map +1 -0
- package/dist/backends/kubernetes/egress-policy.d.ts +111 -35
- package/dist/backends/kubernetes/egress-policy.d.ts.map +1 -1
- package/dist/backends/kubernetes/egress-policy.js +81 -41
- package/dist/backends/kubernetes/egress-policy.js.map +1 -1
- package/dist/backends/kubernetes/identity.d.ts +129 -0
- package/dist/backends/kubernetes/identity.d.ts.map +1 -1
- package/dist/backends/kubernetes/identity.js +150 -0
- package/dist/backends/kubernetes/identity.js.map +1 -1
- package/dist/backends/kubernetes/index.d.ts +23 -1
- package/dist/backends/kubernetes/index.d.ts.map +1 -1
- package/dist/backends/kubernetes/index.js +22 -3
- package/dist/backends/kubernetes/index.js.map +1 -1
- package/dist/backends/kubernetes/objects.d.ts +76 -0
- package/dist/backends/kubernetes/objects.d.ts.map +1 -1
- package/dist/backends/kubernetes/objects.js +61 -0
- package/dist/backends/kubernetes/objects.js.map +1 -1
- package/dist/backends/kubernetes/per-sandbox-fence.d.ts +194 -0
- package/dist/backends/kubernetes/per-sandbox-fence.d.ts.map +1 -0
- package/dist/backends/kubernetes/per-sandbox-fence.js +522 -0
- package/dist/backends/kubernetes/per-sandbox-fence.js.map +1 -0
- package/dist/backends/kubernetes/per-sandbox-policy.d.ts +45 -3
- package/dist/backends/kubernetes/per-sandbox-policy.d.ts.map +1 -1
- package/dist/backends/kubernetes/per-sandbox-policy.js +98 -8
- package/dist/backends/kubernetes/per-sandbox-policy.js.map +1 -1
- package/dist/backends/kubernetes/sandbox.d.ts +4 -9
- package/dist/backends/kubernetes/sandbox.d.ts.map +1 -1
- package/dist/backends/kubernetes/sandbox.js +4 -9
- package/dist/backends/kubernetes/sandbox.js.map +1 -1
- package/dist/backends/kubernetes/transport.d.ts +108 -1
- package/dist/backends/kubernetes/transport.d.ts.map +1 -1
- package/dist/backends/kubernetes/transport.js +150 -6
- package/dist/backends/kubernetes/transport.js.map +1 -1
- package/dist/backends/kubernetes/workspace-allocate.d.ts +206 -0
- package/dist/backends/kubernetes/workspace-allocate.d.ts.map +1 -0
- package/dist/backends/kubernetes/workspace-allocate.js +332 -0
- package/dist/backends/kubernetes/workspace-allocate.js.map +1 -0
- package/dist/backends/kubernetes/workspace-observe.d.ts +187 -0
- package/dist/backends/kubernetes/workspace-observe.d.ts.map +1 -0
- package/dist/backends/kubernetes/workspace-observe.js +343 -0
- package/dist/backends/kubernetes/workspace-observe.js.map +1 -0
- package/dist/backends/kubernetes/workspace-per-sandbox.d.ts +94 -0
- package/dist/backends/kubernetes/workspace-per-sandbox.d.ts.map +1 -0
- package/dist/backends/kubernetes/workspace-per-sandbox.js +119 -0
- package/dist/backends/kubernetes/workspace-per-sandbox.js.map +1 -0
- package/dist/backends/kubernetes/workspace.d.ts +139 -5
- package/dist/backends/kubernetes/workspace.d.ts.map +1 -1
- package/dist/backends/kubernetes/workspace.js +434 -36
- package/dist/backends/kubernetes/workspace.js.map +1 -1
- package/dist/index.d.ts +83 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +100 -6
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/backends/firecracker/protocol.ts +47 -0
- package/src/backends/firecracker/transport.ts +31 -0
- package/src/backends/kubernetes/allocation.ts +199 -0
- package/src/backends/kubernetes/egress-policy.ts +165 -42
- package/src/backends/kubernetes/identity.ts +210 -0
- package/src/backends/kubernetes/index.ts +61 -2
- package/src/backends/kubernetes/objects.ts +98 -0
- package/src/backends/kubernetes/per-sandbox-fence.ts +800 -0
- package/src/backends/kubernetes/per-sandbox-policy.ts +152 -17
- package/src/backends/kubernetes/sandbox.ts +4 -9
- package/src/backends/kubernetes/transport.ts +203 -4
- package/src/backends/kubernetes/workspace-allocate.ts +607 -0
- package/src/backends/kubernetes/workspace-observe.ts +590 -0
- package/src/backends/kubernetes/workspace-per-sandbox.ts +179 -0
- package/src/backends/kubernetes/workspace.ts +655 -34
- 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
|
+
}
|