@namzu/sandbox 15.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 +86 -0
- package/README.md +59 -0
- 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 +480 -84
- package/dist/backends/docker/index.js.map +1 -1
- package/dist/backends/kubernetes/egress-policy.d.ts +193 -102
- package/dist/backends/kubernetes/egress-policy.d.ts.map +1 -1
- package/dist/backends/kubernetes/egress-policy.js +321 -146
- package/dist/backends/kubernetes/egress-policy.js.map +1 -1
- package/dist/backends/kubernetes/per-sandbox-policy.d.ts +6 -6
- package/dist/backends/kubernetes/per-sandbox-policy.d.ts.map +1 -1
- package/dist/backends/kubernetes/per-sandbox-policy.js +21 -53
- package/dist/backends/kubernetes/per-sandbox-policy.js.map +1 -1
- package/dist/index.d.ts +63 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +33 -5
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/backends/docker/index.ts +595 -99
- package/src/backends/kubernetes/egress-policy.ts +455 -187
- package/src/backends/kubernetes/per-sandbox-policy.ts +21 -66
- package/src/index.ts +73 -5
|
@@ -170,12 +170,16 @@ export type KubernetesEgressPolicy = EgressPolicy | KubernetesOnlyEgressPolicy
|
|
|
170
170
|
*
|
|
171
171
|
* - `'union'` (the default) reads the NAMED object exactly as before AND
|
|
172
172
|
* enumerates every `NetworkPolicy` — and, under `engine: 'cilium'`, every
|
|
173
|
-
* `CiliumNetworkPolicy` — in the namespace, refusing when any policy
|
|
174
|
-
* selects the pod allows egress the configured translation does not.
|
|
175
|
-
* is
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
173
|
+
* `CiliumNetworkPolicy` — in the namespace, refusing when any OTHER policy
|
|
174
|
+
* that selects the pod allows egress the configured translation does not.
|
|
175
|
+
* The named object is left to the exact comparison, which is stronger than
|
|
176
|
+
* anything this rule could conclude about it — see
|
|
177
|
+
* {@link EgressPolicyDocument.namedObject} — and still counts as the policy
|
|
178
|
+
* that default-denies, so it is the enumeration's subject rather than an
|
|
179
|
+
* exception to it. That is not belt-and-braces: the API server UNIONS every
|
|
180
|
+
* policy selecting a pod, so a second policy widens egress however exactly
|
|
181
|
+
* the named one matches, and a `SandboxTemplate`'s own `networkPolicy`
|
|
182
|
+
* block becomes exactly such a policy.
|
|
179
183
|
* - `'named-object-only'` is the documented opt-out, and restores the
|
|
180
184
|
* previous behaviour exactly: one GET of the named object, memoized for
|
|
181
185
|
* the backend's lifetime, and no enumeration. For a deployment whose
|
|
@@ -1111,9 +1115,9 @@ export class KubernetesEgressNarrowingUnsupportedError extends Error {
|
|
|
1111
1115
|
}
|
|
1112
1116
|
|
|
1113
1117
|
/**
|
|
1114
|
-
* The grammar a host refusal
|
|
1115
|
-
*
|
|
1116
|
-
*
|
|
1118
|
+
* The grammar a host refusal closes with: what an `allowedHosts` entry is.
|
|
1119
|
+
* Every translation in this module implements it, which is why nothing turns
|
|
1120
|
+
* it off — see {@link KubernetesNetworkPolicyHostError}.
|
|
1117
1121
|
*/
|
|
1118
1122
|
const HOSTNAME_GRAMMAR_SENTENCE =
|
|
1119
1123
|
"Entries are hostnames: 'api.example.com' for one host, '.example.com' for that domain and its subdomains."
|
|
@@ -1130,16 +1134,6 @@ const HOSTNAME_GRAMMAR_SENTENCE =
|
|
|
1130
1134
|
* `CiliumNetworkPolicy` carrying one is an object the API server rejects on
|
|
1131
1135
|
* apply — or, worse, accepts as a name that resolves to nothing, which reads
|
|
1132
1136
|
* from the outside exactly like a policy that is working.
|
|
1133
|
-
*
|
|
1134
|
-
* `stateHostnameGrammar` is the one part of the message a caller can turn
|
|
1135
|
-
* off, because NOT every refusal can make that claim. It is false for the
|
|
1136
|
-
* config-level refusal of a leading-dot entry under `tlsServerNames`, whose
|
|
1137
|
-
* body has just said that the entry reaches the object as a literal
|
|
1138
|
-
* `matchName` admitting nothing with the option on or off: closing that
|
|
1139
|
-
* message with "'.example.com' for that domain and its subdomains" would
|
|
1140
|
-
* re-assert, as implemented, the very grammar whose absence is why the entry
|
|
1141
|
-
* is refused. Every other refusal keeps the sentence, the expanding branch of
|
|
1142
|
-
* the same one included.
|
|
1143
1137
|
*/
|
|
1144
1138
|
export class KubernetesNetworkPolicyHostError extends Error {
|
|
1145
1139
|
override readonly name = 'KubernetesNetworkPolicyHostError'
|
|
@@ -1147,15 +1141,114 @@ export class KubernetesNetworkPolicyHostError extends Error {
|
|
|
1147
1141
|
constructor(
|
|
1148
1142
|
readonly host: string,
|
|
1149
1143
|
reason: string,
|
|
1150
|
-
options: { readonly stateHostnameGrammar?: boolean } = {},
|
|
1151
1144
|
) {
|
|
1152
|
-
const grammar = options.stateHostnameGrammar === false ? '' : ` ${HOSTNAME_GRAMMAR_SENTENCE}`
|
|
1153
1145
|
super(
|
|
1154
|
-
`kubernetes: the allowedHosts entry ${JSON.stringify(host)} is refused: it ${reason}
|
|
1146
|
+
`kubernetes: the allowedHosts entry ${JSON.stringify(host)} is refused: it ${reason}. ${HOSTNAME_GRAMMAR_SENTENCE} Nothing was written.`,
|
|
1155
1147
|
)
|
|
1156
1148
|
}
|
|
1157
1149
|
}
|
|
1158
1150
|
|
|
1151
|
+
/** A DNS name, lowercase, no scheme, no port, no wildcard. */
|
|
1152
|
+
const DNS_NAME = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$/
|
|
1153
|
+
|
|
1154
|
+
/**
|
|
1155
|
+
* The one grammar an `allowedHosts` entry has to satisfy, on EVERY path that
|
|
1156
|
+
* emits one.
|
|
1157
|
+
*
|
|
1158
|
+
* It lives here, beside the class it throws, rather than in the per-sandbox
|
|
1159
|
+
* module that first needed it: it is called from {@link
|
|
1160
|
+
* buildCiliumEgressManifest} — the single builder both translations go
|
|
1161
|
+
* through — so the config-level allowlist and a `setNetworkPolicy` list are
|
|
1162
|
+
* refused the SAME entries, by name, with nothing emitted. That is the
|
|
1163
|
+
* contract {@link KubernetesNetworkPolicyHostError} already states — it
|
|
1164
|
+
* names entries rather than a config field, for exactly this reason — and a
|
|
1165
|
+
* grammar only ONE of the two translations enforces is not one the other has:
|
|
1166
|
+
* that is how the config-level translation came to pass `'.com'`, `'.'`,
|
|
1167
|
+
* `'..example.com'`, `'*'` and an IP literal straight into the emitted
|
|
1168
|
+
* `toFQDNs`, where the per-sandbox path refused every one of them.
|
|
1169
|
+
*
|
|
1170
|
+
* The per-sandbox writer still calls it too — {@link normalizeHost}, which
|
|
1171
|
+
* lowercases and then calls this — because there it also CANONICALISES the
|
|
1172
|
+
* bytes, before the fence is read and before anything is queued.
|
|
1173
|
+
*/
|
|
1174
|
+
export function assertUsableHost(entry: string): void {
|
|
1175
|
+
// The two reasons the per-sandbox writer's own type check used to give
|
|
1176
|
+
// first, kept apart here so an untyped caller meets the same sentence
|
|
1177
|
+
// whichever path reached this — see the note on lowercasing below.
|
|
1178
|
+
if (typeof entry !== 'string') {
|
|
1179
|
+
throw new KubernetesNetworkPolicyHostError(String(entry), 'is not a string')
|
|
1180
|
+
}
|
|
1181
|
+
if (entry === '') {
|
|
1182
|
+
throw new KubernetesNetworkPolicyHostError(entry, 'is empty')
|
|
1183
|
+
}
|
|
1184
|
+
const bare = entry.startsWith('.') ? entry.slice(1) : entry
|
|
1185
|
+
if (bare === '') {
|
|
1186
|
+
throw new KubernetesNetworkPolicyHostError(entry, 'names no domain after its leading dot')
|
|
1187
|
+
}
|
|
1188
|
+
if (entry.includes('*')) {
|
|
1189
|
+
throw new KubernetesNetworkPolicyHostError(
|
|
1190
|
+
entry,
|
|
1191
|
+
"contains a glob; a domain and its subdomains are written with a leading dot ('.example.com'), which becomes matchName plus matchPattern",
|
|
1192
|
+
)
|
|
1193
|
+
}
|
|
1194
|
+
if (!DNS_NAME.test(bare)) {
|
|
1195
|
+
throw new KubernetesNetworkPolicyHostError(
|
|
1196
|
+
entry,
|
|
1197
|
+
'is not a DNS name (a scheme, a path, a port suffix and an IP address all land here; letter case is canonicalised before this check, so it is never the cause)',
|
|
1198
|
+
)
|
|
1199
|
+
}
|
|
1200
|
+
if (bare.length > 253) {
|
|
1201
|
+
throw new KubernetesNetworkPolicyHostError(entry, 'is longer than a DNS name may be')
|
|
1202
|
+
}
|
|
1203
|
+
// A leading-dot entry becomes `matchPattern: '*.<domain>'`, and a
|
|
1204
|
+
// single-label domain there is a whole public suffix — `.com`, `.org`.
|
|
1205
|
+
// That is not an allowlist entry: it admits every name under a registry
|
|
1206
|
+
// the caller does not control. Cilium would match it if it were applied,
|
|
1207
|
+
// which is why this is a refusal rather than a lenient pass, and the
|
|
1208
|
+
// shipped admission fence refuses the same pattern for the per-sandbox
|
|
1209
|
+
// writes it binds (its `matchConditions` scope it to the sandbox host's
|
|
1210
|
+
// ServiceAccount, so an operator applying the config-level object is not
|
|
1211
|
+
// covered by it — one more reason the refusal has to come from here).
|
|
1212
|
+
if (entry.startsWith('.') && !bare.includes('.')) {
|
|
1213
|
+
throw new KubernetesNetworkPolicyHostError(
|
|
1214
|
+
entry,
|
|
1215
|
+
"names a whole top-level domain ('.com' means every name under it); a domain entry needs at least two labels, as in '.example.com', and the '*.com' pattern this would emit is one the shipped admission policy refuses for the per-sandbox writes it binds",
|
|
1216
|
+
)
|
|
1217
|
+
}
|
|
1218
|
+
// An IPv4 literal passes the grammar above — every label is digits, and
|
|
1219
|
+
// digits are legal in a DNS label. It is still not a hostname: a DNS
|
|
1220
|
+
// top-level label is never all-numeric, and Cilium's `matchName` is
|
|
1221
|
+
// compared against names the DNS proxy SAW, which an address never is. A
|
|
1222
|
+
// policy carrying one is admitted and matches nothing, which reads from
|
|
1223
|
+
// outside exactly like a policy that is working.
|
|
1224
|
+
const lastLabel = bare.slice(bare.lastIndexOf('.') + 1)
|
|
1225
|
+
if (/^[0-9]+$/.test(lastLabel)) {
|
|
1226
|
+
throw new KubernetesNetworkPolicyHostError(
|
|
1227
|
+
entry,
|
|
1228
|
+
'ends in an all-numeric label, so it is an address rather than a hostname; toFQDNs matches names a DNS lookup returned, and an address is never one of them (use config.egress.policy for address-based egress)',
|
|
1229
|
+
)
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/**
|
|
1234
|
+
* Every entry of one allowlist, judged by {@link assertUsableHost} — called
|
|
1235
|
+
* from {@link buildCiliumEgressManifest} before anything is emitted, so BOTH
|
|
1236
|
+
* translations refuse the same entries.
|
|
1237
|
+
*
|
|
1238
|
+
* The entry is lowercased for the DECISION and not for the bytes. The
|
|
1239
|
+
* per-sandbox writer canonicalises before it validates (`normalizeHost`), so
|
|
1240
|
+
* judging the lowercased form is what makes the two paths agree on WHICH
|
|
1241
|
+
* entries are refused — while the emitted `matchName` stays the string the
|
|
1242
|
+
* caller wrote, exactly as every release before this one emitted it. This
|
|
1243
|
+
* call refuses what no translation can express, not what one of them would
|
|
1244
|
+
* spell differently.
|
|
1245
|
+
*/
|
|
1246
|
+
function assertHostsAreUsable(allowedHosts: readonly string[]): void {
|
|
1247
|
+
for (const host of allowedHosts) {
|
|
1248
|
+
assertUsableHost(typeof host === 'string' ? host.toLowerCase() : host)
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1159
1252
|
/**
|
|
1160
1253
|
* Every entry in `narrowing.ports`, `narrowing.hostPorts` and
|
|
1161
1254
|
* `narrowing.tlsPorts` is a port the API server will actually accept, none of
|
|
@@ -1491,17 +1584,23 @@ function ciliumPortEntry(port: number): Readonly<Record<string, unknown>> {
|
|
|
1491
1584
|
}
|
|
1492
1585
|
|
|
1493
1586
|
/**
|
|
1494
|
-
* Which
|
|
1587
|
+
* Which field a host refusal sends the reader to — the ONE thing about a
|
|
1588
|
+
* refusal that is still a fact about the caller rather than about the entry.
|
|
1589
|
+
*
|
|
1590
|
+
* It was two facts while the two translations differed in what they emitted:
|
|
1591
|
+
* the sentence, which followed from whether the translation expanded a
|
|
1592
|
+
* leading-dot entry, and the field path, which followed from which field the
|
|
1593
|
+
* caller set. The config-level translation used to expand nothing, so it was
|
|
1594
|
+
* both a different sentence and a different field; it now expands exactly as
|
|
1595
|
+
* the per-sandbox one does, one entry no longer means two things, and the
|
|
1596
|
+
* sentence is the same one on every path — so the field path is all that is
|
|
1597
|
+
* left to get right, which is why it is the only field here.
|
|
1495
1598
|
*
|
|
1496
|
-
*
|
|
1497
|
-
*
|
|
1498
|
-
*
|
|
1499
|
-
*
|
|
1500
|
-
*
|
|
1501
|
-
* told to repair `config.egress.ciliumNarrowing` with the remedy that belongs
|
|
1502
|
-
* to `config.egress.perSandbox.narrowing` — a message sending a reader to a
|
|
1503
|
-
* field the entry never came from. Neither half means anything without the
|
|
1504
|
-
* other, so they travel together.
|
|
1599
|
+
* It still travels as one value rather than as a loose string argument,
|
|
1600
|
+
* because there are exactly two legal ones and both are named below: a refusal
|
|
1601
|
+
* is raised from the translation AND, earlier still, from the per-sandbox
|
|
1602
|
+
* writer before it reads the fence, and the two have to name the same field
|
|
1603
|
+
* for one entry however the earlier check is reached or skipped.
|
|
1505
1604
|
*/
|
|
1506
1605
|
export interface HostsFitNarrowingContext {
|
|
1507
1606
|
/**
|
|
@@ -1509,19 +1608,11 @@ export interface HostsFitNarrowingContext {
|
|
|
1509
1608
|
* — the full path, as every other refusal in this module spells one.
|
|
1510
1609
|
*/
|
|
1511
1610
|
readonly fieldPath: string
|
|
1512
|
-
/**
|
|
1513
|
-
* Whether this translation turns a leading-dot entry into a `matchName`
|
|
1514
|
-
* plus a `matchPattern` — {@link ciliumFqdnEntries}. `true` for the
|
|
1515
|
-
* per-sandbox translation, `false` for the config-level one, whose emitted
|
|
1516
|
-
* bytes are pinned. Decides the refusal's sentence and its closing grammar
|
|
1517
|
-
* as well as the bytes, and all three are the same fact about one entry.
|
|
1518
|
-
*/
|
|
1519
|
-
readonly expandsDottedEntries: boolean
|
|
1520
1611
|
}
|
|
1521
1612
|
|
|
1522
1613
|
/**
|
|
1523
|
-
* The refusal context of the PER-SANDBOX translation —
|
|
1524
|
-
*
|
|
1614
|
+
* The refusal context of the PER-SANDBOX translation — `egress.perSandbox`,
|
|
1615
|
+
* whose allowlist comes from a host's own `setNetworkPolicy` call.
|
|
1525
1616
|
*
|
|
1526
1617
|
* Shared deliberately: the per-sandbox writer refuses a host by this context
|
|
1527
1618
|
* before it reads the fence, and {@link buildCiliumEgressManifest} refuses the
|
|
@@ -1531,16 +1622,14 @@ export interface HostsFitNarrowingContext {
|
|
|
1531
1622
|
*/
|
|
1532
1623
|
export const PER_SANDBOX_NARROWING_REFUSAL: HostsFitNarrowingContext = {
|
|
1533
1624
|
fieldPath: 'config.egress.perSandbox.narrowing',
|
|
1534
|
-
expandsDottedEntries: true,
|
|
1535
1625
|
}
|
|
1536
1626
|
|
|
1537
1627
|
/**
|
|
1538
1628
|
* The refusal context of the config-level translation — `config.egress.policy`
|
|
1539
|
-
* and its `config.egress.ciliumNarrowing
|
|
1629
|
+
* and its `config.egress.ciliumNarrowing`.
|
|
1540
1630
|
*/
|
|
1541
1631
|
export const CONFIG_LEVEL_NARROWING_REFUSAL: HostsFitNarrowingContext = {
|
|
1542
1632
|
fieldPath: 'config.egress.ciliumNarrowing',
|
|
1543
|
-
expandsDottedEntries: false,
|
|
1544
1633
|
}
|
|
1545
1634
|
|
|
1546
1635
|
/**
|
|
@@ -1549,42 +1638,32 @@ export const CONFIG_LEVEL_NARROWING_REFUSAL: HostsFitNarrowingContext = {
|
|
|
1549
1638
|
* `tlsServerNames` puts the entry on the rule as a TLS server name — one
|
|
1550
1639
|
* exact SNI value a handshake presents — and a `.domain` entry is a set of
|
|
1551
1640
|
* names no single SNI value means. Either way the emitted object would be
|
|
1552
|
-
*
|
|
1553
|
-
*
|
|
1554
|
-
*
|
|
1641
|
+
* applied without complaint — nothing refuses an operator's apply: the
|
|
1642
|
+
* shipped admission fence's `matchConditions` scope it to the sandbox host's
|
|
1643
|
+
* ServiceAccount, so the config-level object is not matched by it — read back
|
|
1644
|
+
* deep-equal to what was sent, and deny what the caller asked to allow — the
|
|
1645
|
+
* failure every other refusal in this module exists to prevent. Refused
|
|
1646
|
+
* rather than translated another way,
|
|
1555
1647
|
* because both other ways are guesses: dropping the subdomains silently
|
|
1556
1648
|
* narrows what the caller asked for, and a wildcard SNI value is not
|
|
1557
1649
|
* something the Cilium versions these manifests are written against are
|
|
1558
1650
|
* known here to match — an SNI that matches nothing denies just as
|
|
1559
1651
|
* completely, and more quietly.
|
|
1560
1652
|
*
|
|
1561
|
-
*
|
|
1562
|
-
*
|
|
1563
|
-
*
|
|
1564
|
-
*
|
|
1565
|
-
*
|
|
1566
|
-
*
|
|
1567
|
-
*
|
|
1568
|
-
*
|
|
1569
|
-
*
|
|
1570
|
-
*
|
|
1571
|
-
*
|
|
1572
|
-
*
|
|
1573
|
-
*
|
|
1574
|
-
*
|
|
1575
|
-
* not one — the config-level `.domain` entry denies the domain and every
|
|
1576
|
-
* subdomain with or without the option (a pre-existing defect of this
|
|
1577
|
-
* translation, deferred to its own change) — so it is not offered here.
|
|
1578
|
-
*
|
|
1579
|
-
* `context` is WHICH translation the refusal is being spelled for, as one
|
|
1580
|
-
* value — see {@link HostsFitNarrowingContext}. A reader has to be sent to
|
|
1581
|
-
* the field they actually set, and the sentence they are sent by has to be
|
|
1582
|
-
* true of the translation they are on, and both follow from that one fact:
|
|
1583
|
-
* passed separately, an expanding caller gets the per-sandbox remedy attached
|
|
1584
|
-
* to the config-level field name, a message whose only repair is a field the
|
|
1585
|
-
* entry never came from. Called from BOTH — the translation itself, so every
|
|
1586
|
-
* caller is covered, and the per-sandbox writer, which refuses earlier still,
|
|
1587
|
-
* before it reads the fence.
|
|
1653
|
+
* One sentence, because one entry now means one thing: both translations turn
|
|
1654
|
+
* `.domain` into `matchName: domain` PLUS `matchPattern: '*.domain'` — see
|
|
1655
|
+
* {@link ciliumFqdnEntries} — and no single SNI value means that pair.
|
|
1656
|
+
* `domain` alone denies every subdomain the pattern admits, and the entry as
|
|
1657
|
+
* written is not a name any handshake presents at all.
|
|
1658
|
+
*
|
|
1659
|
+
* `context` is the field the refusal sends the reader to — see
|
|
1660
|
+
* {@link HostsFitNarrowingContext}. A reader has to be sent to the field they
|
|
1661
|
+
* actually set: passed as a loose string, an expanding caller was told to
|
|
1662
|
+
* repair `config.egress.ciliumNarrowing` with the remedy that belongs to
|
|
1663
|
+
* `config.egress.perSandbox.narrowing`, a message whose only repair is a field
|
|
1664
|
+
* the entry never came from. Called from BOTH — the translation itself, so
|
|
1665
|
+
* every caller is covered, and the per-sandbox writer, which refuses earlier
|
|
1666
|
+
* still, before it reads the fence.
|
|
1588
1667
|
*/
|
|
1589
1668
|
export function assertHostsFitNarrowing(
|
|
1590
1669
|
allowedHosts: readonly string[],
|
|
@@ -1596,16 +1675,7 @@ export function assertHostsFitNarrowing(
|
|
|
1596
1675
|
if (!host.startsWith('.')) continue
|
|
1597
1676
|
throw new KubernetesNetworkPolicyHostError(
|
|
1598
1677
|
host,
|
|
1599
|
-
context.
|
|
1600
|
-
? `names a domain and its subdomains while ${context.fieldPath}.tlsServerNames is on; a TLS server name is one exact SNI value a handshake presents, and this entry becomes a name plus a '*.domain' pattern, which no single value means — list the exact hosts, or leave tlsServerNames off for a domain list`
|
|
1601
|
-
: `names a domain and its subdomains while ${context.fieldPath}.tlsServerNames is on; a TLS server name is one exact SNI value a handshake presents, and this translation does not expand a leading-dot entry — it reaches the object as the literal matchName ${JSON.stringify(host)}, which no DNS answer carries — so the entry admits nothing as written, and serverNames on top of it is a second, independent denial. List the exact hosts this policy should allow; leaving tlsServerNames off does not repair the entry here`,
|
|
1602
|
-
// The closing grammar rides the same input as the sentence, and for
|
|
1603
|
-
// the same reason: the unexpanded body has just said this entry
|
|
1604
|
-
// admits nothing as written, so a tail asserting that
|
|
1605
|
-
// `.example.com` means the domain and its subdomains would state,
|
|
1606
|
-
// as implemented, the grammar the refusal exists because this
|
|
1607
|
-
// translation does not apply to it.
|
|
1608
|
-
{ stateHostnameGrammar: context.expandsDottedEntries },
|
|
1678
|
+
`names a domain and its subdomains while ${context.fieldPath}.tlsServerNames is on; a TLS server name is one exact SNI value a handshake presents, and this entry becomes a name plus a '*.domain' pattern, which no single value means — list the exact hosts, or leave tlsServerNames off for a domain list`,
|
|
1609
1679
|
)
|
|
1610
1680
|
}
|
|
1611
1681
|
}
|
|
@@ -1635,21 +1705,28 @@ function narrowedHostPorts(
|
|
|
1635
1705
|
* host's TLS ports and a plain entry for whatever is left, when
|
|
1636
1706
|
* `tlsServerNames` is on.
|
|
1637
1707
|
*
|
|
1638
|
-
* `
|
|
1639
|
-
*
|
|
1640
|
-
*
|
|
1641
|
-
*
|
|
1642
|
-
*
|
|
1643
|
-
*
|
|
1644
|
-
*
|
|
1645
|
-
*
|
|
1646
|
-
*
|
|
1647
|
-
*
|
|
1708
|
+
* `fqdns` is {@link ciliumFqdnEntries} of the host's allowlist entry, passed
|
|
1709
|
+
* in rather than derived here because the entry and the host are not always
|
|
1710
|
+
* the same string: `.example.com` becomes `example.com` plus
|
|
1711
|
+
* `*.example.com`. It is REQUIRED, with no `[{ matchName: host }]` default —
|
|
1712
|
+
* a caller that omitted it would emit the entry unexpanded, which is the
|
|
1713
|
+
* exact object this translation stopped producing.
|
|
1714
|
+
*
|
|
1715
|
+
* `serverNames` carries the allowlist ENTRY as written while the `toFQDNs`
|
|
1716
|
+
* half carries the expansion of it. A TLS server name is one exact SNI value
|
|
1717
|
+
* a handshake presents, and a `.example.com` entry — whose `toFQDNs` half is
|
|
1718
|
+
* `example.com` PLUS `*.example.com` — has no single SNI value meaning that
|
|
1719
|
+
* set: `example.com` would deny every subdomain the pattern admits, and
|
|
1720
|
+
* `.example.com` is not a name any handshake ever presents. So
|
|
1721
|
+
* {@link assertHostsFitNarrowing} refuses that one combination before this
|
|
1722
|
+
* rule is built, from `buildCiliumEgressManifest` for every caller and from
|
|
1723
|
+
* the per-sandbox writer before it reads the fence — which is why this
|
|
1724
|
+
* function can still take `host` and the entry as one string.
|
|
1648
1725
|
*/
|
|
1649
1726
|
function narrowedHostFqdnRule(
|
|
1650
1727
|
host: string,
|
|
1651
1728
|
narrowing: KubernetesCiliumEgressNarrowing,
|
|
1652
|
-
fqdns: readonly Readonly<Record<string, string>>[]
|
|
1729
|
+
fqdns: readonly Readonly<Record<string, string>>[],
|
|
1653
1730
|
): Readonly<Record<string, unknown>> {
|
|
1654
1731
|
const ports = narrowedHostPorts(host, narrowing)
|
|
1655
1732
|
if (ports === undefined) return { toFQDNs: fqdns }
|
|
@@ -1673,18 +1750,20 @@ function narrowedHostFqdnRule(
|
|
|
1673
1750
|
/**
|
|
1674
1751
|
* One allowlist entry, expanded to the `toFQDNs` entries it means.
|
|
1675
1752
|
*
|
|
1676
|
-
* `SandboxNetworkPolicy.allowedHosts`'s own grammar, which the SDK states
|
|
1677
|
-
*
|
|
1678
|
-
*
|
|
1679
|
-
*
|
|
1680
|
-
* name
|
|
1681
|
-
*
|
|
1682
|
-
*
|
|
1683
|
-
*
|
|
1684
|
-
*
|
|
1685
|
-
*
|
|
1686
|
-
*
|
|
1687
|
-
*
|
|
1753
|
+
* `SandboxNetworkPolicy.allowedHosts`'s own grammar, which the SDK states —
|
|
1754
|
+
* `packages/sdk/src/types/sandbox/index.ts`'s `allowedHosts` — and which
|
|
1755
|
+
* `src/egress/allowlist.ts` implements for the docker backend: `api.example.com`
|
|
1756
|
+
* is that host, and `.example.com` is the domain AND its subdomains. Cilium's
|
|
1757
|
+
* `matchName` is an exact name and does not match across a `.`, so the domain
|
|
1758
|
+
* form needs the name plus a `matchPattern` — `*.example.com` alone would
|
|
1759
|
+
* admit `a.example.com` and not `example.com` itself.
|
|
1760
|
+
*
|
|
1761
|
+
* Used by BOTH translations. The config-level one used to emit a leading-dot
|
|
1762
|
+
* entry verbatim instead, which no DNS answer carries and so denied the
|
|
1763
|
+
* domain and every subdomain it was asked to allow, after a create that
|
|
1764
|
+
* reported success — but "the config-level bytes are pinned" was never a
|
|
1765
|
+
* contract the pinned bytes honoured, and one entry has to mean one thing on
|
|
1766
|
+
* every backend.
|
|
1688
1767
|
*/
|
|
1689
1768
|
export function ciliumFqdnEntries(entry: string): readonly Readonly<Record<string, string>>[] {
|
|
1690
1769
|
if (!entry.startsWith('.')) return [{ matchName: entry }]
|
|
@@ -1693,16 +1772,21 @@ export function ciliumFqdnEntries(entry: string): readonly Readonly<Record<strin
|
|
|
1693
1772
|
}
|
|
1694
1773
|
|
|
1695
1774
|
/**
|
|
1696
|
-
* The DNS-visibility rule narrowed to
|
|
1697
|
-
* plus the host under every search
|
|
1775
|
+
* The DNS-visibility rule narrowed to the names an allowlist entry admits:
|
|
1776
|
+
* an exact `matchName` per allowed host plus the host under every search
|
|
1777
|
+
* suffix, and — for an entry that admits subdomains — the `matchPattern` that
|
|
1778
|
+
* admits them, under the bare name and under every suffix as well. Replaces
|
|
1698
1779
|
* {@link CILIUM_DNS_VISIBILITY_RULE}'s `matchPattern: '*'`. See
|
|
1699
1780
|
* {@link KubernetesCiliumDnsNarrowing}.
|
|
1781
|
+
*
|
|
1782
|
+
* Both halves follow from {@link ciliumFqdnEntries}: the DNS proxy has to see
|
|
1783
|
+
* exactly what the `toFQDNs` rule admits, or the half that learns addresses
|
|
1784
|
+
* from a lookup never sees one the other half allows.
|
|
1700
1785
|
*/
|
|
1701
1786
|
function narrowedDnsVisibilityRule(
|
|
1702
1787
|
allowedHosts: readonly string[],
|
|
1703
1788
|
fallbackNamespace: string,
|
|
1704
1789
|
dnsNames: KubernetesCiliumDnsNarrowing,
|
|
1705
|
-
expandDomains = false,
|
|
1706
1790
|
): Readonly<Record<string, unknown>> {
|
|
1707
1791
|
const namespace = dnsNames.namespace ?? fallbackNamespace
|
|
1708
1792
|
const clusterDomain = dnsNames.clusterDomain ?? 'cluster.local'
|
|
@@ -1716,11 +1800,8 @@ function narrowedDnsVisibilityRule(
|
|
|
1716
1800
|
for (const entry of allowedHosts) {
|
|
1717
1801
|
// A `.domain` entry resolves through its subdomains as well, so the
|
|
1718
1802
|
// DNS proxy has to be allowed to SEE those lookups or `toFQDNs` never
|
|
1719
|
-
// learns the addresses they resolve to.
|
|
1720
|
-
|
|
1721
|
-
// false there and `host === entry`, so this loop is what it always
|
|
1722
|
-
// was.
|
|
1723
|
-
const wildcard = expandDomains && entry.startsWith('.')
|
|
1803
|
+
// learns the addresses they resolve to.
|
|
1804
|
+
const wildcard = entry.startsWith('.')
|
|
1724
1805
|
const host = wildcard ? entry.slice(1) : entry
|
|
1725
1806
|
matchNames.push({ matchName: host })
|
|
1726
1807
|
if (wildcard) matchNames.push({ matchPattern: `*.${host}` })
|
|
@@ -1730,9 +1811,9 @@ function narrowedDnsVisibilityRule(
|
|
|
1730
1811
|
// with fewer dots than the cluster's `ndots` tries the search
|
|
1731
1812
|
// suffixes FIRST, and a lookup the DNS proxy refuses is not an
|
|
1732
1813
|
// NXDOMAIN the resolver walks past — it can fail the whole
|
|
1733
|
-
// resolution. An
|
|
1734
|
-
//
|
|
1735
|
-
//
|
|
1814
|
+
// resolution. An entry that admits subdomains needs the pattern
|
|
1815
|
+
// under each suffix too, or `a.example.com` fails on its first
|
|
1816
|
+
// search-suffix attempt under a rule that allows it.
|
|
1736
1817
|
if (wildcard) matchNames.push({ matchPattern: `*.${host}.${suffix}` })
|
|
1737
1818
|
}
|
|
1738
1819
|
}
|
|
@@ -1751,13 +1832,18 @@ function narrowedDnsVisibilityRule(
|
|
|
1751
1832
|
* Everything a `CiliumNetworkPolicy` for a hostname allowlist is built from.
|
|
1752
1833
|
*
|
|
1753
1834
|
* ONE builder, two callers: the config-level translation below, whose
|
|
1754
|
-
* selector is the template (and profile) label and
|
|
1755
|
-
*
|
|
1756
|
-
*
|
|
1757
|
-
*
|
|
1758
|
-
*
|
|
1759
|
-
*
|
|
1760
|
-
*
|
|
1835
|
+
* selector is the template (and profile) label, and the per-sandbox policy in
|
|
1836
|
+
* `per-sandbox-policy.ts`, whose selector is one per-sandbox label and which
|
|
1837
|
+
* additionally carries an `ownerReferences` entry so the cluster
|
|
1838
|
+
* garbage-collects it. A second builder would be a second answer to what a
|
|
1839
|
+
* namzu egress policy looks like, and the read-back comparator would then be
|
|
1840
|
+
* verifying one of them against the other's shape.
|
|
1841
|
+
*
|
|
1842
|
+
* The two callers now differ in nothing this interface cannot spell out: the
|
|
1843
|
+
* same entries become the same bytes and a refusal uses the same sentence on
|
|
1844
|
+
* both, so what is left of "which caller this is" is
|
|
1845
|
+
* {@link CiliumEgressManifestOptions.refusalContext} — the field a refusal
|
|
1846
|
+
* names.
|
|
1761
1847
|
*/
|
|
1762
1848
|
export interface CiliumEgressManifestOptions {
|
|
1763
1849
|
readonly namespace: string
|
|
@@ -1771,49 +1857,56 @@ export interface CiliumEgressManifestOptions {
|
|
|
1771
1857
|
/** `metadata.ownerReferences`. Absent ⇒ the metadata is what it always was. */
|
|
1772
1858
|
readonly ownerReferences?: readonly KubernetesOwnerReference[]
|
|
1773
1859
|
/**
|
|
1774
|
-
*
|
|
1775
|
-
* {@link
|
|
1776
|
-
*
|
|
1860
|
+
* The field a refusal for one of these hosts sends its reader to — see
|
|
1861
|
+
* {@link HostsFitNarrowingContext}. REQUIRED, not defaulted: there is no
|
|
1862
|
+
* way to tell from inside this function which config the caller's hosts
|
|
1863
|
+
* came from, and a refusal that names a field the caller never set is a
|
|
1864
|
+
* remedy that does not exist for them.
|
|
1777
1865
|
*
|
|
1778
|
-
*
|
|
1779
|
-
*
|
|
1780
|
-
*
|
|
1781
|
-
* {@link HostsFitNarrowingContext}. There is no way to ask for one without
|
|
1782
|
-
* the others, and none should be added.
|
|
1866
|
+
* The bytes are not this option's business any more, and neither is the
|
|
1867
|
+
* sentence a refusal uses: there is one translation of an allowlist entry,
|
|
1868
|
+
* so both are the same for every caller whatever this says.
|
|
1783
1869
|
*/
|
|
1784
|
-
readonly
|
|
1870
|
+
readonly refusalContext: HostsFitNarrowingContext
|
|
1785
1871
|
}
|
|
1786
1872
|
|
|
1787
1873
|
export function buildCiliumEgressManifest(
|
|
1788
1874
|
options: CiliumEgressManifestOptions,
|
|
1789
1875
|
): KubernetesTranslatedEgressPolicy {
|
|
1790
|
-
const { narrowing, allowedHosts,
|
|
1791
|
-
// Before anything is emitted
|
|
1876
|
+
const { narrowing, allowedHosts, refusalContext } = options
|
|
1877
|
+
// Before anything is emitted, and for EVERY caller — which is the point:
|
|
1878
|
+
// an entry this backend will not translate is one whose object would be
|
|
1879
|
+
// either rejected on apply or, worse, accepted as a name that matches
|
|
1880
|
+
// nothing while the create reports success. The config-level allowlist
|
|
1881
|
+
// used to reach this builder unvalidated, so `['.com']`, `['.']`,
|
|
1882
|
+
// `['..example.com']`, `['*']` and an address were each emitted into
|
|
1883
|
+
// `toFQDNs` — the first three as a `matchPattern` no fence covers on that
|
|
1884
|
+
// path (the shipped one is scoped to the sandbox host's ServiceAccount),
|
|
1885
|
+
// which turned a fail-closed no-op into a silent grant of every name under
|
|
1886
|
+
// a public suffix. See {@link assertHostsAreUsable}.
|
|
1887
|
+
assertHostsAreUsable(allowedHosts)
|
|
1888
|
+
// A leading-dot entry under `tlsServerNames`
|
|
1792
1889
|
// would become `serverNames: ['.domain']` — not a name any handshake
|
|
1793
1890
|
// presents — and `['domain']` would deny every subdomain the `toFQDNs`
|
|
1794
|
-
// half of the same rule admits; the object
|
|
1795
|
-
//
|
|
1796
|
-
// would ever report it.
|
|
1891
|
+
// half of the same rule admits; the object goes through with nothing
|
|
1892
|
+
// objecting to it, and reads back deep-equal to what was sent, so nothing
|
|
1893
|
+
// downstream would ever report it. (On the config-level path nothing
|
|
1894
|
+
// objects to it at all: the shipped fence is scoped to the sandbox host's
|
|
1895
|
+
// ServiceAccount and an operator's apply is not matched by it.)
|
|
1797
1896
|
//
|
|
1798
1897
|
// What this call guarantees, per caller shape: the per-sandbox writer
|
|
1799
1898
|
// refuses the same hosts earlier still, by name and by the same context
|
|
1800
1899
|
// (`per-sandbox-policy.ts`), and this call covers them again if that check
|
|
1801
1900
|
// is ever reached later or skipped; the config-level translation has no
|
|
1802
1901
|
// earlier check of its own and relies on this one entirely; and a direct
|
|
1803
|
-
// call to this function is covered here too, whatever
|
|
1902
|
+
// call to this function is covered here too, whatever context it passes.
|
|
1804
1903
|
//
|
|
1805
|
-
//
|
|
1806
|
-
//
|
|
1807
|
-
//
|
|
1808
|
-
//
|
|
1809
|
-
//
|
|
1810
|
-
|
|
1811
|
-
// `perSandbox.narrowing`.
|
|
1812
|
-
assertHostsFitNarrowing(
|
|
1813
|
-
allowedHosts,
|
|
1814
|
-
narrowing,
|
|
1815
|
-
expandDomains ? PER_SANDBOX_NARROWING_REFUSAL : CONFIG_LEVEL_NARROWING_REFUSAL,
|
|
1816
|
-
)
|
|
1904
|
+
// The context decides ONE thing, because one thing is left to decide —
|
|
1905
|
+
// which field the refusal names. It is required rather than derived, since
|
|
1906
|
+
// nothing here can see the caller's config, and derived from the bytes is
|
|
1907
|
+
// exactly how an expanding caller came to be sent to the config-level
|
|
1908
|
+
// field for a repair that only exists under `perSandbox.narrowing`.
|
|
1909
|
+
assertHostsFitNarrowing(allowedHosts, narrowing, refusalContext)
|
|
1817
1910
|
// Unnarrowed is the exact shape every release before #490 emitted — kept
|
|
1818
1911
|
// as its own branch, untouched, rather than folded into the narrowed one
|
|
1819
1912
|
// with every option defaulted off, so the byte-identical guarantee does
|
|
@@ -1822,13 +1915,11 @@ export function buildCiliumEgressManifest(
|
|
|
1822
1915
|
const activeDns = narrowed ? activeDnsNarrowing(narrowing.dnsNames) : undefined
|
|
1823
1916
|
const dnsRule =
|
|
1824
1917
|
activeDns !== undefined
|
|
1825
|
-
? narrowedDnsVisibilityRule(allowedHosts, options.namespace, activeDns
|
|
1918
|
+
? narrowedDnsVisibilityRule(allowedHosts, options.namespace, activeDns)
|
|
1826
1919
|
: CILIUM_DNS_VISIBILITY_RULE
|
|
1827
|
-
const entriesFor = (entry: string): readonly Readonly<Record<string, string>>[] =>
|
|
1828
|
-
expandDomains ? ciliumFqdnEntries(entry) : [{ matchName: entry }]
|
|
1829
1920
|
const hostRules = narrowed
|
|
1830
|
-
? allowedHosts.map((host) => narrowedHostFqdnRule(host, narrowing,
|
|
1831
|
-
: [{ toFQDNs: allowedHosts.flatMap(
|
|
1921
|
+
? allowedHosts.map((host) => narrowedHostFqdnRule(host, narrowing, ciliumFqdnEntries(host)))
|
|
1922
|
+
: [{ toFQDNs: allowedHosts.flatMap(ciliumFqdnEntries) }]
|
|
1832
1923
|
|
|
1833
1924
|
return {
|
|
1834
1925
|
kind: 'CiliumNetworkPolicy',
|
|
@@ -1868,6 +1959,7 @@ function buildCiliumNetworkPolicy(
|
|
|
1868
1959
|
allowedHosts,
|
|
1869
1960
|
policyKind,
|
|
1870
1961
|
...(narrowing !== undefined ? { narrowing } : {}),
|
|
1962
|
+
refusalContext: CONFIG_LEVEL_NARROWING_REFUSAL,
|
|
1871
1963
|
})
|
|
1872
1964
|
}
|
|
1873
1965
|
|
|
@@ -1974,8 +2066,16 @@ export class KubernetesEgressPolicyMismatchError extends Error {
|
|
|
1974
2066
|
* `../docker/index.ts`'s `assertNetworkCarriesThePolicy`, which inspects the
|
|
1975
2067
|
* daemon's own `{{.Internal}}` flag instead of trusting a network's name.
|
|
1976
2068
|
*
|
|
1977
|
-
* Checks
|
|
1978
|
-
*
|
|
2069
|
+
* Checks four things, each named separately in a mismatch so an operator sees
|
|
2070
|
+
* which one to fix:
|
|
2071
|
+
* - the object carries NO `specs` list. A `CiliumNetworkPolicy` carries
|
|
2072
|
+
* EITHER one `spec` or a `specs` list and a rule in either one enforces,
|
|
2073
|
+
* while every check below reads `spec` alone — so an object carrying both
|
|
2074
|
+
* would be compared on half of what it enforces. This is refused and not
|
|
2075
|
+
* read, because a comparison that accepts rules it never looked at is the
|
|
2076
|
+
* one answer this function must not give; the shipped admission fence
|
|
2077
|
+
* refuses a `specs` list for the same reason. The translation never emits
|
|
2078
|
+
* one, so its presence is drift and not an alternative spelling;
|
|
1979
2079
|
* - the selector (`podSelector` for `NetworkPolicy`, `endpointSelector` for
|
|
1980
2080
|
* `CiliumNetworkPolicy`) carries the expected template label:
|
|
1981
2081
|
* - `NetworkPolicy` additionally declares `policyTypes` including
|
|
@@ -2004,6 +2104,7 @@ export async function verifyEgressPolicyApplied(
|
|
|
2004
2104
|
let resource:
|
|
2005
2105
|
| {
|
|
2006
2106
|
readonly spec?: Readonly<Record<string, unknown>>
|
|
2107
|
+
readonly specs?: unknown
|
|
2007
2108
|
readonly metadata?: Readonly<Record<string, unknown>>
|
|
2008
2109
|
}
|
|
2009
2110
|
| undefined
|
|
@@ -2020,6 +2121,33 @@ export async function verifyEgressPolicyApplied(
|
|
|
2020
2121
|
const actualSpec = resource?.spec ?? {}
|
|
2021
2122
|
const selectorKey = translated.kind === 'CiliumNetworkPolicy' ? 'endpointSelector' : 'podSelector'
|
|
2022
2123
|
|
|
2124
|
+
// First, and before anything is compared: an object carrying a `specs`
|
|
2125
|
+
// list is refused rather than read. Everything below reads `spec`, and a
|
|
2126
|
+
// `CiliumNetworkPolicy` rule in a `specs` entry enforces exactly as one in
|
|
2127
|
+
// `spec` does — so comparing `spec` and reporting a match would be
|
|
2128
|
+
// accepting rules this function never looked at, which is the one answer it
|
|
2129
|
+
// must not give. `readCiliumEgressPolicies` reads both spellings and
|
|
2130
|
+
// `decideEgressUnion` refuses a `specs` entry that widens; this is the
|
|
2131
|
+
// other half of the same rule, for the callers that run this check ALONE —
|
|
2132
|
+
// `verify: 'named-object-only'`, and the per-sandbox read-back — and it is
|
|
2133
|
+
// what makes the named-object exemption in `decideEgressUnion` sound rather
|
|
2134
|
+
// than merely narrower. The translation never emits a `specs` list, and the
|
|
2135
|
+
// shipped admission fence refuses one for the same reason.
|
|
2136
|
+
const actualSpecs = resource?.specs
|
|
2137
|
+
if (actualSpecs !== undefined && actualSpecs !== null) {
|
|
2138
|
+
throw new KubernetesEgressPolicyMismatchError(
|
|
2139
|
+
translated.kind,
|
|
2140
|
+
path,
|
|
2141
|
+
`specs is ${JSON.stringify(actualSpecs)}, and the translation never emits one — ${
|
|
2142
|
+
translated.kind === 'CiliumNetworkPolicy'
|
|
2143
|
+
? 'a CiliumNetworkPolicy carries EITHER one spec or a specs list, and a rule in either one enforces'
|
|
2144
|
+
: 'a NetworkPolicy has no specs field at all'
|
|
2145
|
+
}, while this check reads spec.${selectorKey}${
|
|
2146
|
+
translated.kind === 'NetworkPolicy' ? ', spec.policyTypes' : ''
|
|
2147
|
+
} and spec.egress and nothing else — so anything a specs list enforces is egress this comparison never read`,
|
|
2148
|
+
)
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2023
2151
|
if (!isDeepStrictEqual(actualSpec[selectorKey], expectedSpec[selectorKey])) {
|
|
2024
2152
|
throw new KubernetesEgressPolicyMismatchError(
|
|
2025
2153
|
translated.kind,
|
|
@@ -2284,6 +2412,23 @@ export interface EgressAllowance {
|
|
|
2284
2412
|
readonly permitsNothing: boolean
|
|
2285
2413
|
/** The configured kind, for the refusal message. */
|
|
2286
2414
|
readonly policyKind: KubernetesEgressPolicy['kind']
|
|
2415
|
+
/**
|
|
2416
|
+
* The object this allowance is the allowance OF — `translated.kind` and
|
|
2417
|
+
* `translated.name`, the object `config.egress` translates to and an
|
|
2418
|
+
* operator applies.
|
|
2419
|
+
*
|
|
2420
|
+
* The union rule reads every OTHER policy in the namespace against this
|
|
2421
|
+
* allowance, and this pair is what says which of the listed objects is the
|
|
2422
|
+
* named one, so {@link decideEgressUnion} can leave that object's `spec`
|
|
2423
|
+
* document to {@link verifyEgressPolicyApplied} instead of judging it here.
|
|
2424
|
+
* See {@link EgressPolicyDocument.namedObject} for what that comparison
|
|
2425
|
+
* reads, which documents it covers, and why the exemption is not an
|
|
2426
|
+
* optimisation.
|
|
2427
|
+
*/
|
|
2428
|
+
readonly namedObject: {
|
|
2429
|
+
readonly kind: 'NetworkPolicy' | 'CiliumNetworkPolicy'
|
|
2430
|
+
readonly name: string
|
|
2431
|
+
}
|
|
2287
2432
|
}
|
|
2288
2433
|
|
|
2289
2434
|
function readPortRanges(ports: unknown, defaultProtocol: string | undefined): PortSet {
|
|
@@ -2418,6 +2563,7 @@ export function egressAllowance(translated: KubernetesTranslatedEgressPolicy): E
|
|
|
2418
2563
|
permitsEverything: false,
|
|
2419
2564
|
permitsNothing: false,
|
|
2420
2565
|
policyKind: translated.policyKind,
|
|
2566
|
+
namedObject: { kind: translated.kind, name: translated.name },
|
|
2421
2567
|
}
|
|
2422
2568
|
}
|
|
2423
2569
|
const destinations: AllowedDestination[] = []
|
|
@@ -2449,6 +2595,7 @@ export function egressAllowance(translated: KubernetesTranslatedEgressPolicy): E
|
|
|
2449
2595
|
permitsEverything,
|
|
2450
2596
|
permitsNothing: rules.length === 0,
|
|
2451
2597
|
policyKind: translated.policyKind,
|
|
2598
|
+
namedObject: { kind: translated.kind, name: translated.name },
|
|
2452
2599
|
}
|
|
2453
2600
|
}
|
|
2454
2601
|
|
|
@@ -3023,6 +3170,50 @@ export interface EgressPolicyDocument {
|
|
|
3023
3170
|
readonly rules: readonly EgressRuleVerdict[]
|
|
3024
3171
|
/** Set when the OBJECT could not be read. It decides alone. */
|
|
3025
3172
|
readonly unreadable?: string
|
|
3173
|
+
/**
|
|
3174
|
+
* This document is the one {@link verifyEgressPolicyApplied} compared to
|
|
3175
|
+
* the translation on this same create path, before this check runs: the
|
|
3176
|
+
* document built from the named object's `spec`, and nothing else.
|
|
3177
|
+
*
|
|
3178
|
+
* Marked so {@link decideEgressUnion} does not judge its RULES, because
|
|
3179
|
+
* the translation's own allowance cannot express one of them.
|
|
3180
|
+
* `egressAllowance` records a `toFQDNs` entry by its `matchName`, while a
|
|
3181
|
+
* `.domain` entry the translation emits is a `matchName` PLUS a
|
|
3182
|
+
* `matchPattern` — so the pattern half read as widening and the check
|
|
3183
|
+
* refused the very object it had just told the operator to apply,
|
|
3184
|
+
* permanently and on every `create()`.
|
|
3185
|
+
*
|
|
3186
|
+
* What the exempting comparison actually reads, because this marking is
|
|
3187
|
+
* only as sound as it is: `spec.podSelector`/`spec.endpointSelector`,
|
|
3188
|
+
* `spec.policyTypes` (core), `spec.egress`, and any `metadata.ownerReferences`
|
|
3189
|
+
* the translation carries. It is NOT a comparison of the whole object, so
|
|
3190
|
+
* the marking is scoped to what it covers:
|
|
3191
|
+
*
|
|
3192
|
+
* - Only the document built from `item.spec` is marked. A document built
|
|
3193
|
+
* from an `item.specs` entry is judged by {@link decideEgressUnion} like
|
|
3194
|
+
* any other object's — a rule in either spelling enforces, so a `specs`
|
|
3195
|
+
* entry that allows more than the translation is refused by name.
|
|
3196
|
+
* - {@link verifyEgressPolicyApplied} REFUSES a live object carrying a
|
|
3197
|
+
* `specs` list at all, which is what makes the first point airtight
|
|
3198
|
+
* rather than merely narrower: the translation never emits one, and half
|
|
3199
|
+
* of what such an object enforces would be rules that comparison never
|
|
3200
|
+
* read.
|
|
3201
|
+
*
|
|
3202
|
+
* What is NOT redundant, and the reason the document stays in the
|
|
3203
|
+
* enumeration at all, is whether it puts this pod in egress default-deny:
|
|
3204
|
+
* dropping it would report a deployment whose only applied policy is the
|
|
3205
|
+
* named one as having no boundary at all, which is a deployment that
|
|
3206
|
+
* verifies today.
|
|
3207
|
+
*
|
|
3208
|
+
* The cost is stated rather than hidden: the named check is memoized on
|
|
3209
|
+
* success for the boundary's lifetime, so a named object that drifts WIDER
|
|
3210
|
+
* after its own check has passed is no longer caught by this one — it is
|
|
3211
|
+
* caught by the named comparison at the next construction. Before this
|
|
3212
|
+
* exemption there was a second net under a five-minute cache; the
|
|
3213
|
+
* alternative is a check that refuses the object it has just told an
|
|
3214
|
+
* operator to apply.
|
|
3215
|
+
*/
|
|
3216
|
+
readonly namedObject?: boolean
|
|
3026
3217
|
}
|
|
3027
3218
|
|
|
3028
3219
|
function unreadableEgressPolicy(
|
|
@@ -3040,6 +3231,36 @@ function unreadableEgressPolicy(
|
|
|
3040
3231
|
}
|
|
3041
3232
|
}
|
|
3042
3233
|
|
|
3234
|
+
/**
|
|
3235
|
+
* Is this the object `config.egress` named — see
|
|
3236
|
+
* {@link EgressPolicyDocument.namedObject}.
|
|
3237
|
+
*
|
|
3238
|
+
* Read off the allowance rather than passed in, so every caller of
|
|
3239
|
+
* {@link readCoreEgressPolicies}/{@link readCiliumEgressPolicies} that built
|
|
3240
|
+
* its allowance with {@link egressAllowance} gets the marking without having
|
|
3241
|
+
* to remember a second argument, and the readers stay a function of "(items,
|
|
3242
|
+
* this pod, what the translation allows)".
|
|
3243
|
+
*
|
|
3244
|
+
* For a core `NetworkPolicy` the name is the whole answer. For a
|
|
3245
|
+
* `CiliumNetworkPolicy` it is not: the CRD carries EITHER one `spec` or a
|
|
3246
|
+
* `specs` list, `verifyEgressPolicyApplied` reads the former and refuses an
|
|
3247
|
+
* object carrying the latter, so the reader marks only the document built
|
|
3248
|
+
* from `item.spec` and lets a `specs` entry be judged by the union rule like
|
|
3249
|
+
* any other object's rules — see {@link EgressPolicyDocument.namedObject}.
|
|
3250
|
+
*
|
|
3251
|
+
* Deliberately NOT applied to an unreadable document: an object at the named
|
|
3252
|
+
* name that could not be read keeps its `not-evaluable` verdict, which refuses
|
|
3253
|
+
* — a fail-closed answer for a shape whose exact comparison already refused it
|
|
3254
|
+
* earlier on the create path.
|
|
3255
|
+
*/
|
|
3256
|
+
function isNamedObject(
|
|
3257
|
+
allowance: EgressAllowance,
|
|
3258
|
+
kind: EgressPolicyDocument['kind'],
|
|
3259
|
+
name: string,
|
|
3260
|
+
): boolean {
|
|
3261
|
+
return allowance.namedObject.kind === kind && allowance.namedObject.name === name
|
|
3262
|
+
}
|
|
3263
|
+
|
|
3043
3264
|
/** Every core `NetworkPolicy` in the list, reduced to {@link EgressPolicyDocument}. */
|
|
3044
3265
|
export function readCoreEgressPolicies(
|
|
3045
3266
|
items: readonly unknown[],
|
|
@@ -3080,6 +3301,7 @@ export function readCoreEgressPolicies(
|
|
|
3080
3301
|
return {
|
|
3081
3302
|
kind: 'NetworkPolicy',
|
|
3082
3303
|
name,
|
|
3304
|
+
...(isNamedObject(allowance, 'NetworkPolicy', name) ? { namedObject: true } : {}),
|
|
3083
3305
|
selects: matchesLabelSelector(spec.podSelector, target.podLabels),
|
|
3084
3306
|
enforcesEgress,
|
|
3085
3307
|
// An `egress` block under a `policyTypes` that leaves Egress out is
|
|
@@ -3113,8 +3335,18 @@ export function readCiliumEgressPolicies(
|
|
|
3113
3335
|
}
|
|
3114
3336
|
// The CRD carries EITHER one `spec` or a `specs` list, and a rule in
|
|
3115
3337
|
// either enforces. Reading only `spec` would miss a whole policy.
|
|
3116
|
-
|
|
3117
|
-
|
|
3338
|
+
//
|
|
3339
|
+
// The two spellings are NOT equal where the named-object exemption is
|
|
3340
|
+
// concerned — see {@link EgressPolicyDocument.namedObject}. The exact
|
|
3341
|
+
// comparison the exemption leans on reads `spec` and refuses an object
|
|
3342
|
+
// carrying a `specs` list, so `spec` is the one document that earns the
|
|
3343
|
+
// marking; a `specs` entry is read as an ordinary policy's rules and is
|
|
3344
|
+
// judged by the union rule, which is what refuses one that widens.
|
|
3345
|
+
const named = isNamedObject(allowance, 'CiliumNetworkPolicy', name)
|
|
3346
|
+
const specDocuments: Array<{ readonly spec: unknown; readonly isTheSpec: boolean }> = []
|
|
3347
|
+
if (item.spec !== undefined && item.spec !== null) {
|
|
3348
|
+
specDocuments.push({ spec: item.spec, isTheSpec: true })
|
|
3349
|
+
}
|
|
3118
3350
|
const more = readList(item.specs)
|
|
3119
3351
|
if (more === 'unreadable') {
|
|
3120
3352
|
documents.push(
|
|
@@ -3126,27 +3358,36 @@ export function readCiliumEgressPolicies(
|
|
|
3126
3358
|
)
|
|
3127
3359
|
continue
|
|
3128
3360
|
}
|
|
3129
|
-
for (const spec of more ?? [])
|
|
3130
|
-
if (
|
|
3361
|
+
for (const spec of more ?? []) specDocuments.push({ spec, isTheSpec: false })
|
|
3362
|
+
if (specDocuments.length === 0) {
|
|
3131
3363
|
documents.push(
|
|
3132
3364
|
unreadableEgressPolicy('CiliumNetworkPolicy', name, 'neither a spec nor a specs list'),
|
|
3133
3365
|
)
|
|
3134
3366
|
continue
|
|
3135
3367
|
}
|
|
3136
|
-
for (const spec of
|
|
3137
|
-
const document = readCiliumEgressRuleSpec(spec, name, identity, allowance)
|
|
3368
|
+
for (const { spec, isTheSpec } of specDocuments) {
|
|
3369
|
+
const document = readCiliumEgressRuleSpec(spec, name, identity, allowance, named && isTheSpec)
|
|
3138
3370
|
if (document !== undefined) documents.push(document)
|
|
3139
3371
|
}
|
|
3140
3372
|
}
|
|
3141
3373
|
return documents
|
|
3142
3374
|
}
|
|
3143
3375
|
|
|
3144
|
-
/**
|
|
3376
|
+
/**
|
|
3377
|
+
* One `spec`/`specs` entry. `undefined` when it is node-scoped — see below.
|
|
3378
|
+
*
|
|
3379
|
+
* `isTheNamedSpec` is true only for the document built from the named
|
|
3380
|
+
* object's own `spec` — the one {@link verifyEgressPolicyApplied} compared to
|
|
3381
|
+
* the translation. A `specs` entry never is; see
|
|
3382
|
+
* {@link EgressPolicyDocument.namedObject} for why that distinction is
|
|
3383
|
+
* load-bearing rather than bookkeeping.
|
|
3384
|
+
*/
|
|
3145
3385
|
function readCiliumEgressRuleSpec(
|
|
3146
3386
|
spec: unknown,
|
|
3147
3387
|
name: string,
|
|
3148
3388
|
identity: Readonly<Record<string, string>>,
|
|
3149
3389
|
allowance: EgressAllowance,
|
|
3390
|
+
isTheNamedSpec: boolean,
|
|
3150
3391
|
): EgressPolicyDocument | undefined {
|
|
3151
3392
|
const unreadable = (detail: string) => unreadableEgressPolicy('CiliumNetworkPolicy', name, detail)
|
|
3152
3393
|
if (!isRecord(spec)) return unreadable('a rule spec that is not an object')
|
|
@@ -3171,6 +3412,7 @@ function readCiliumEgressRuleSpec(
|
|
|
3171
3412
|
return {
|
|
3172
3413
|
kind: 'CiliumNetworkPolicy',
|
|
3173
3414
|
name,
|
|
3415
|
+
...(isTheNamedSpec ? { namedObject: true } : {}),
|
|
3174
3416
|
selects: matchesLabelSelector(spec.endpointSelector, identity, ciliumSelectorKey),
|
|
3175
3417
|
enforcesEgress,
|
|
3176
3418
|
// `egressDeny` rules are not read: a deny rule can only narrow what
|
|
@@ -3203,6 +3445,15 @@ export interface EgressUnionDecision {
|
|
|
3203
3445
|
* translation is the finding however many narrower ones sit beside it, and a
|
|
3204
3446
|
* pod no policy default-denies has no egress boundary at all whatever the
|
|
3205
3447
|
* named object says.
|
|
3448
|
+
*
|
|
3449
|
+
* The named object's `spec` is the one document whose rules are not judged
|
|
3450
|
+
* here — see {@link EgressPolicyDocument.namedObject} — and it was compared to
|
|
3451
|
+
* the translation by {@link verifyEgressPolicyApplied} on the same create path
|
|
3452
|
+
* just before. Its SELECTOR and its egress-scoping still decide, which is what
|
|
3453
|
+
* keeps "nothing bounds this pod" answerable for a deployment whose only
|
|
3454
|
+
* policy is that one; and a document built from a `specs` entry is judged here
|
|
3455
|
+
* like any other object's, because the exempting comparison refuses an object
|
|
3456
|
+
* carrying a `specs` list rather than reading one.
|
|
3206
3457
|
*/
|
|
3207
3458
|
export function decideEgressUnion(
|
|
3208
3459
|
documents: readonly EgressPolicyDocument[],
|
|
@@ -3239,27 +3490,39 @@ export function decideEgressUnion(
|
|
|
3239
3490
|
undecided ??= entry
|
|
3240
3491
|
continue
|
|
3241
3492
|
}
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3493
|
+
// The named object's `spec` rules were just compared to the translation
|
|
3494
|
+
// by `verifyEgressPolicyApplied`, field by field, which is the one
|
|
3495
|
+
// judgement about them that a `toFQDNs`-by-`matchName` allowance cannot
|
|
3496
|
+
// reproduce — see {@link EgressPolicyDocument.namedObject}. So the union
|
|
3497
|
+
// rule does not judge them; judging them here is what made the check
|
|
3498
|
+
// refuse the object it had just told an operator to apply. Everything
|
|
3499
|
+
// below still applies, which is how a pod whose only policy is the named
|
|
3500
|
+
// one is still known to be in egress default-deny. Only that `spec`
|
|
3501
|
+
// document carries the marking: a `specs` entry is judged here like any
|
|
3502
|
+
// other rules.
|
|
3503
|
+
if (document.namedObject !== true) {
|
|
3504
|
+
const beyondRule = document.rules.find((rule) => rule.beyond === true)
|
|
3505
|
+
if (beyondRule !== undefined) {
|
|
3506
|
+
const entry: ExaminedEgressPolicy = {
|
|
3507
|
+
...base,
|
|
3508
|
+
verdict: 'widens-egress',
|
|
3509
|
+
...(beyondRule.detail !== undefined ? { detail: beyondRule.detail } : {}),
|
|
3510
|
+
}
|
|
3511
|
+
examined.push(entry)
|
|
3512
|
+
widening ??= entry
|
|
3513
|
+
continue
|
|
3248
3514
|
}
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3515
|
+
const unknownRule = document.rules.find((rule) => rule.beyond === 'unknown')
|
|
3516
|
+
if (unknownRule !== undefined) {
|
|
3517
|
+
const entry: ExaminedEgressPolicy = {
|
|
3518
|
+
...base,
|
|
3519
|
+
verdict: 'not-evaluable',
|
|
3520
|
+
...(unknownRule.detail !== undefined ? { detail: unknownRule.detail } : {}),
|
|
3521
|
+
}
|
|
3522
|
+
examined.push(entry)
|
|
3523
|
+
undecided ??= entry
|
|
3524
|
+
continue
|
|
3259
3525
|
}
|
|
3260
|
-
examined.push(entry)
|
|
3261
|
-
undecided ??= entry
|
|
3262
|
-
continue
|
|
3263
3526
|
}
|
|
3264
3527
|
if (!document.enforcesEgress) {
|
|
3265
3528
|
examined.push({
|
|
@@ -3379,7 +3642,12 @@ function formatEgressRemedy(
|
|
|
3379
3642
|
* Verify-not-trust, widened from one object to the union: list the
|
|
3380
3643
|
* namespace's policies, evaluate every one that selects this pod against the
|
|
3381
3644
|
* configured translation, and refuse unless nothing lets out more than
|
|
3382
|
-
* `config.egress` says.
|
|
3645
|
+
* `config.egress` says. The named object's `spec` document is the one
|
|
3646
|
+
* exception, and it was compared to the translation by
|
|
3647
|
+
* {@link verifyEgressPolicyApplied} on this same create path just before this
|
|
3648
|
+
* one — see {@link EgressPolicyDocument.namedObject}: a Cilium object that
|
|
3649
|
+
* carries a `specs` list is refused there outright, so a `specs` entry is
|
|
3650
|
+
* judged HERE, like any other object's rules.
|
|
3383
3651
|
*
|
|
3384
3652
|
* Runs beside the ingress check on every create path — before the POST for a
|
|
3385
3653
|
* directly created Sandbox, where the labels are known and a refusal leaves
|