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