@namzu/sandbox 15.0.0 → 17.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +324 -0
  2. package/README.md +223 -0
  3. package/dist/backends/aci-standby-pool/index.d.ts +22 -4
  4. package/dist/backends/aci-standby-pool/index.d.ts.map +1 -1
  5. package/dist/backends/aci-standby-pool/index.js +31 -7
  6. package/dist/backends/aci-standby-pool/index.js.map +1 -1
  7. package/dist/backends/docker/index.d.ts +408 -26
  8. package/dist/backends/docker/index.d.ts.map +1 -1
  9. package/dist/backends/docker/index.js +1173 -168
  10. package/dist/backends/docker/index.js.map +1 -1
  11. package/dist/backends/firecracker/transport.d.ts +156 -1
  12. package/dist/backends/firecracker/transport.d.ts.map +1 -1
  13. package/dist/backends/firecracker/transport.js +223 -29
  14. package/dist/backends/firecracker/transport.js.map +1 -1
  15. package/dist/backends/http-worker-client.d.ts +64 -2
  16. package/dist/backends/http-worker-client.d.ts.map +1 -1
  17. package/dist/backends/http-worker-client.js +78 -7
  18. package/dist/backends/http-worker-client.js.map +1 -1
  19. package/dist/backends/kubernetes/egress-policy.d.ts +193 -102
  20. package/dist/backends/kubernetes/egress-policy.d.ts.map +1 -1
  21. package/dist/backends/kubernetes/egress-policy.js +321 -146
  22. package/dist/backends/kubernetes/egress-policy.js.map +1 -1
  23. package/dist/backends/kubernetes/per-sandbox-policy.d.ts +6 -6
  24. package/dist/backends/kubernetes/per-sandbox-policy.d.ts.map +1 -1
  25. package/dist/backends/kubernetes/per-sandbox-policy.js +21 -53
  26. package/dist/backends/kubernetes/per-sandbox-policy.js.map +1 -1
  27. package/dist/backends/kubernetes/transport.d.ts +7 -0
  28. package/dist/backends/kubernetes/transport.d.ts.map +1 -1
  29. package/dist/backends/kubernetes/transport.js.map +1 -1
  30. package/dist/egress/proxy.d.ts +47 -2
  31. package/dist/egress/proxy.d.ts.map +1 -1
  32. package/dist/egress/proxy.js +31 -7
  33. package/dist/egress/proxy.js.map +1 -1
  34. package/dist/index.d.ts +130 -6
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +55 -5
  37. package/dist/index.js.map +1 -1
  38. package/package.json +4 -4
  39. package/src/backends/aci-standby-pool/index.ts +37 -7
  40. package/src/backends/docker/index.ts +1475 -196
  41. package/src/backends/firecracker/transport.ts +387 -36
  42. package/src/backends/http-worker-client.ts +89 -5
  43. package/src/backends/kubernetes/egress-policy.ts +455 -187
  44. package/src/backends/kubernetes/per-sandbox-policy.ts +21 -66
  45. package/src/backends/kubernetes/transport.ts +7 -0
  46. package/src/egress/proxy.ts +65 -8
  47. package/src/index.ts +162 -5
@@ -596,9 +596,9 @@ export class KubernetesEgressNarrowingUnsupportedError extends Error {
596
596
  }
597
597
  }
598
598
  /**
599
- * The grammar a host refusal normally closes with: what an `allowedHosts`
600
- * entry is. Stated only where the translation actually implements it — see
601
- * `stateHostnameGrammar` on {@link KubernetesNetworkPolicyHostError}.
599
+ * The grammar a host refusal closes with: what an `allowedHosts` entry is.
600
+ * Every translation in this module implements it, which is why nothing turns
601
+ * it off — see {@link KubernetesNetworkPolicyHostError}.
602
602
  */
603
603
  const HOSTNAME_GRAMMAR_SENTENCE = "Entries are hostnames: 'api.example.com' for one host, '.example.com' for that domain and its subdomains.";
604
604
  /**
@@ -613,26 +613,101 @@ const HOSTNAME_GRAMMAR_SENTENCE = "Entries are hostnames: 'api.example.com' for
613
613
  * `CiliumNetworkPolicy` carrying one is an object the API server rejects on
614
614
  * apply — or, worse, accepts as a name that resolves to nothing, which reads
615
615
  * from the outside exactly like a policy that is working.
616
- *
617
- * `stateHostnameGrammar` is the one part of the message a caller can turn
618
- * off, because NOT every refusal can make that claim. It is false for the
619
- * config-level refusal of a leading-dot entry under `tlsServerNames`, whose
620
- * body has just said that the entry reaches the object as a literal
621
- * `matchName` admitting nothing with the option on or off: closing that
622
- * message with "'.example.com' for that domain and its subdomains" would
623
- * re-assert, as implemented, the very grammar whose absence is why the entry
624
- * is refused. Every other refusal keeps the sentence, the expanding branch of
625
- * the same one included.
626
616
  */
627
617
  export class KubernetesNetworkPolicyHostError extends Error {
628
618
  host;
629
619
  name = 'KubernetesNetworkPolicyHostError';
630
- constructor(host, reason, options = {}) {
631
- const grammar = options.stateHostnameGrammar === false ? '' : ` ${HOSTNAME_GRAMMAR_SENTENCE}`;
632
- super(`kubernetes: the allowedHosts entry ${JSON.stringify(host)} is refused: it ${reason}.${grammar} Nothing was written.`);
620
+ constructor(host, reason) {
621
+ super(`kubernetes: the allowedHosts entry ${JSON.stringify(host)} is refused: it ${reason}. ${HOSTNAME_GRAMMAR_SENTENCE} Nothing was written.`);
633
622
  this.host = host;
634
623
  }
635
624
  }
625
+ /** A DNS name, lowercase, no scheme, no port, no wildcard. */
626
+ const DNS_NAME = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$/;
627
+ /**
628
+ * The one grammar an `allowedHosts` entry has to satisfy, on EVERY path that
629
+ * emits one.
630
+ *
631
+ * It lives here, beside the class it throws, rather than in the per-sandbox
632
+ * module that first needed it: it is called from {@link
633
+ * buildCiliumEgressManifest} — the single builder both translations go
634
+ * through — so the config-level allowlist and a `setNetworkPolicy` list are
635
+ * refused the SAME entries, by name, with nothing emitted. That is the
636
+ * contract {@link KubernetesNetworkPolicyHostError} already states — it
637
+ * names entries rather than a config field, for exactly this reason — and a
638
+ * grammar only ONE of the two translations enforces is not one the other has:
639
+ * that is how the config-level translation came to pass `'.com'`, `'.'`,
640
+ * `'..example.com'`, `'*'` and an IP literal straight into the emitted
641
+ * `toFQDNs`, where the per-sandbox path refused every one of them.
642
+ *
643
+ * The per-sandbox writer still calls it too — {@link normalizeHost}, which
644
+ * lowercases and then calls this — because there it also CANONICALISES the
645
+ * bytes, before the fence is read and before anything is queued.
646
+ */
647
+ export function assertUsableHost(entry) {
648
+ // The two reasons the per-sandbox writer's own type check used to give
649
+ // first, kept apart here so an untyped caller meets the same sentence
650
+ // whichever path reached this — see the note on lowercasing below.
651
+ if (typeof entry !== 'string') {
652
+ throw new KubernetesNetworkPolicyHostError(String(entry), 'is not a string');
653
+ }
654
+ if (entry === '') {
655
+ throw new KubernetesNetworkPolicyHostError(entry, 'is empty');
656
+ }
657
+ const bare = entry.startsWith('.') ? entry.slice(1) : entry;
658
+ if (bare === '') {
659
+ throw new KubernetesNetworkPolicyHostError(entry, 'names no domain after its leading dot');
660
+ }
661
+ if (entry.includes('*')) {
662
+ throw new KubernetesNetworkPolicyHostError(entry, "contains a glob; a domain and its subdomains are written with a leading dot ('.example.com'), which becomes matchName plus matchPattern");
663
+ }
664
+ if (!DNS_NAME.test(bare)) {
665
+ throw new KubernetesNetworkPolicyHostError(entry, '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)');
666
+ }
667
+ if (bare.length > 253) {
668
+ throw new KubernetesNetworkPolicyHostError(entry, 'is longer than a DNS name may be');
669
+ }
670
+ // A leading-dot entry becomes `matchPattern: '*.<domain>'`, and a
671
+ // single-label domain there is a whole public suffix — `.com`, `.org`.
672
+ // That is not an allowlist entry: it admits every name under a registry
673
+ // the caller does not control. Cilium would match it if it were applied,
674
+ // which is why this is a refusal rather than a lenient pass, and the
675
+ // shipped admission fence refuses the same pattern for the per-sandbox
676
+ // writes it binds (its `matchConditions` scope it to the sandbox host's
677
+ // ServiceAccount, so an operator applying the config-level object is not
678
+ // covered by it — one more reason the refusal has to come from here).
679
+ if (entry.startsWith('.') && !bare.includes('.')) {
680
+ throw new KubernetesNetworkPolicyHostError(entry, "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");
681
+ }
682
+ // An IPv4 literal passes the grammar above — every label is digits, and
683
+ // digits are legal in a DNS label. It is still not a hostname: a DNS
684
+ // top-level label is never all-numeric, and Cilium's `matchName` is
685
+ // compared against names the DNS proxy SAW, which an address never is. A
686
+ // policy carrying one is admitted and matches nothing, which reads from
687
+ // outside exactly like a policy that is working.
688
+ const lastLabel = bare.slice(bare.lastIndexOf('.') + 1);
689
+ if (/^[0-9]+$/.test(lastLabel)) {
690
+ throw new KubernetesNetworkPolicyHostError(entry, '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)');
691
+ }
692
+ }
693
+ /**
694
+ * Every entry of one allowlist, judged by {@link assertUsableHost} — called
695
+ * from {@link buildCiliumEgressManifest} before anything is emitted, so BOTH
696
+ * translations refuse the same entries.
697
+ *
698
+ * The entry is lowercased for the DECISION and not for the bytes. The
699
+ * per-sandbox writer canonicalises before it validates (`normalizeHost`), so
700
+ * judging the lowercased form is what makes the two paths agree on WHICH
701
+ * entries are refused — while the emitted `matchName` stays the string the
702
+ * caller wrote, exactly as every release before this one emitted it. This
703
+ * call refuses what no translation can express, not what one of them would
704
+ * spell differently.
705
+ */
706
+ function assertHostsAreUsable(allowedHosts) {
707
+ for (const host of allowedHosts) {
708
+ assertUsableHost(typeof host === 'string' ? host.toLowerCase() : host);
709
+ }
710
+ }
636
711
  /**
637
712
  * Every entry in `narrowing.ports`, `narrowing.hostPorts` and
638
713
  * `narrowing.tlsPorts` is a port the API server will actually accept, none of
@@ -932,8 +1007,8 @@ function ciliumPortEntry(port) {
932
1007
  return { port: String(port), protocol: 'TCP' };
933
1008
  }
934
1009
  /**
935
- * The refusal context of the PER-SANDBOX translation — the one that expands,
936
- * and so the one `expandDomains: true` builds.
1010
+ * The refusal context of the PER-SANDBOX translation — `egress.perSandbox`,
1011
+ * whose allowlist comes from a host's own `setNetworkPolicy` call.
937
1012
  *
938
1013
  * Shared deliberately: the per-sandbox writer refuses a host by this context
939
1014
  * before it reads the fence, and {@link buildCiliumEgressManifest} refuses the
@@ -943,15 +1018,13 @@ function ciliumPortEntry(port) {
943
1018
  */
944
1019
  export const PER_SANDBOX_NARROWING_REFUSAL = {
945
1020
  fieldPath: 'config.egress.perSandbox.narrowing',
946
- expandsDottedEntries: true,
947
1021
  };
948
1022
  /**
949
1023
  * The refusal context of the config-level translation — `config.egress.policy`
950
- * and its `config.egress.ciliumNarrowing`, which expands nothing.
1024
+ * and its `config.egress.ciliumNarrowing`.
951
1025
  */
952
1026
  export const CONFIG_LEVEL_NARROWING_REFUSAL = {
953
1027
  fieldPath: 'config.egress.ciliumNarrowing',
954
- expandsDottedEntries: false,
955
1028
  };
956
1029
  /**
957
1030
  * Refuse the one allowlist entry a narrowing option cannot express.
@@ -959,42 +1032,32 @@ export const CONFIG_LEVEL_NARROWING_REFUSAL = {
959
1032
  * `tlsServerNames` puts the entry on the rule as a TLS server name — one
960
1033
  * exact SNI value a handshake presents — and a `.domain` entry is a set of
961
1034
  * names no single SNI value means. Either way the emitted object would be
962
- * admitted by the shipped fence, read back deep-equal to what was sent, and
963
- * deny what the caller asked to allow — the failure every other refusal in
964
- * this module exists to prevent. Refused rather than translated another way,
1035
+ * applied without complaint — nothing refuses an operator's apply: the
1036
+ * shipped admission fence's `matchConditions` scope it to the sandbox host's
1037
+ * ServiceAccount, so the config-level object is not matched by it — read back
1038
+ * deep-equal to what was sent, and deny what the caller asked to allow — the
1039
+ * failure every other refusal in this module exists to prevent. Refused
1040
+ * rather than translated another way,
965
1041
  * because both other ways are guesses: dropping the subdomains silently
966
1042
  * narrows what the caller asked for, and a wildcard SNI value is not
967
1043
  * something the Cilium versions these manifests are written against are
968
1044
  * known here to match — an SNI that matches nothing denies just as
969
1045
  * completely, and more quietly.
970
1046
  *
971
- * The entry is refused for a DIFFERENT reason on each of the two paths, and
972
- * the message says which one the caller is on, because the same sentence
973
- * cannot be true of both:
974
- *
975
- * - `expandDomains` — the per-sandbox writer's translation, which turns
976
- * `.domain` into `matchName: domain` PLUS `matchPattern: '*.domain'`. No
977
- * single SNI value means that pair: `domain` alone denies every subdomain
978
- * the pattern admits, and the entry as written is not a name any handshake
979
- * presents at all.
980
- * - unexpanded — the config-level translation, which expands nothing. There
981
- * the entry reaches the object as the literal `matchName: '.domain'`,
982
- * which no DNS answer carries, so it already admits nothing; `serverNames`
983
- * on top of it is a second, independent denial. Telling this caller to
984
- * "leave tlsServerNames off for a domain list" would name a repair that is
985
- * not one — the config-level `.domain` entry denies the domain and every
986
- * subdomain with or without the option (a pre-existing defect of this
987
- * translation, deferred to its own change) — so it is not offered here.
988
- *
989
- * `context` is WHICH translation the refusal is being spelled for, as one
990
- * value — see {@link HostsFitNarrowingContext}. A reader has to be sent to
991
- * the field they actually set, and the sentence they are sent by has to be
992
- * true of the translation they are on, and both follow from that one fact:
993
- * passed separately, an expanding caller gets the per-sandbox remedy attached
994
- * to the config-level field name, a message whose only repair is a field the
995
- * entry never came from. Called from BOTH — the translation itself, so every
996
- * caller is covered, and the per-sandbox writer, which refuses earlier still,
997
- * before it reads the fence.
1047
+ * One sentence, because one entry now means one thing: both translations turn
1048
+ * `.domain` into `matchName: domain` PLUS `matchPattern: '*.domain'` — see
1049
+ * {@link ciliumFqdnEntries} — and no single SNI value means that pair.
1050
+ * `domain` alone denies every subdomain the pattern admits, and the entry as
1051
+ * written is not a name any handshake presents at all.
1052
+ *
1053
+ * `context` is the field the refusal sends the reader to — see
1054
+ * {@link HostsFitNarrowingContext}. A reader has to be sent to the field they
1055
+ * actually set: passed as a loose string, an expanding caller was told to
1056
+ * repair `config.egress.ciliumNarrowing` with the remedy that belongs to
1057
+ * `config.egress.perSandbox.narrowing`, a message whose only repair is a field
1058
+ * the entry never came from. Called from BOTH — the translation itself, so
1059
+ * every caller is covered, and the per-sandbox writer, which refuses earlier
1060
+ * still, before it reads the fence.
998
1061
  */
999
1062
  export function assertHostsFitNarrowing(allowedHosts, narrowing, context) {
1000
1063
  if (narrowing?.tlsServerNames !== true)
@@ -1002,16 +1065,7 @@ export function assertHostsFitNarrowing(allowedHosts, narrowing, context) {
1002
1065
  for (const host of allowedHosts) {
1003
1066
  if (!host.startsWith('.'))
1004
1067
  continue;
1005
- throw new KubernetesNetworkPolicyHostError(host, context.expandsDottedEntries
1006
- ? `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`
1007
- : `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`,
1008
- // The closing grammar rides the same input as the sentence, and for
1009
- // the same reason: the unexpanded body has just said this entry
1010
- // admits nothing as written, so a tail asserting that
1011
- // `.example.com` means the domain and its subdomains would state,
1012
- // as implemented, the grammar the refusal exists because this
1013
- // translation does not apply to it.
1014
- { stateHostnameGrammar: context.expandsDottedEntries });
1068
+ throw new KubernetesNetworkPolicyHostError(host, `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`);
1015
1069
  }
1016
1070
  }
1017
1071
  /**
@@ -1037,18 +1091,25 @@ function narrowedHostPorts(host, narrowing) {
1037
1091
  * host's TLS ports and a plain entry for whatever is left, when
1038
1092
  * `tlsServerNames` is on.
1039
1093
  *
1040
- * `serverNames` carries the allowlist ENTRY as written, which is the same
1041
- * string as the `toFQDNs` entry only while nothing expanded it. A TLS server
1042
- * name is one exact SNI value a handshake presents, and a `.example.com`
1043
- * entry — whose `toFQDNs` half is `example.com` PLUS `*.example.com` — has no
1044
- * single SNI value meaning that set: `example.com` would deny every subdomain
1045
- * the pattern admits, and `.example.com` is not a name any handshake ever
1046
- * presents. So {@link assertHostsFitNarrowing} refuses that one combination
1047
- * before this rule is built, from `buildCiliumEgressManifest` for every
1048
- * caller and from the per-sandbox writer before it reads the fence — which is
1049
- * why this function can take `host` and the entry as one string.
1094
+ * `fqdns` is {@link ciliumFqdnEntries} of the host's allowlist entry, passed
1095
+ * in rather than derived here because the entry and the host are not always
1096
+ * the same string: `.example.com` becomes `example.com` plus
1097
+ * `*.example.com`. It is REQUIRED, with no `[{ matchName: host }]` default —
1098
+ * a caller that omitted it would emit the entry unexpanded, which is the
1099
+ * exact object this translation stopped producing.
1100
+ *
1101
+ * `serverNames` carries the allowlist ENTRY as written while the `toFQDNs`
1102
+ * half carries the expansion of it. A TLS server name is one exact SNI value
1103
+ * a handshake presents, and a `.example.com` entry — whose `toFQDNs` half is
1104
+ * `example.com` PLUS `*.example.com` — has no single SNI value meaning that
1105
+ * set: `example.com` would deny every subdomain the pattern admits, and
1106
+ * `.example.com` is not a name any handshake ever presents. So
1107
+ * {@link assertHostsFitNarrowing} refuses that one combination before this
1108
+ * rule is built, from `buildCiliumEgressManifest` for every caller and from
1109
+ * the per-sandbox writer before it reads the fence — which is why this
1110
+ * function can still take `host` and the entry as one string.
1050
1111
  */
1051
- function narrowedHostFqdnRule(host, narrowing, fqdns = [{ matchName: host }]) {
1112
+ function narrowedHostFqdnRule(host, narrowing, fqdns) {
1052
1113
  const ports = narrowedHostPorts(host, narrowing);
1053
1114
  if (ports === undefined)
1054
1115
  return { toFQDNs: fqdns };
@@ -1072,18 +1133,20 @@ function narrowedHostFqdnRule(host, narrowing, fqdns = [{ matchName: host }]) {
1072
1133
  /**
1073
1134
  * One allowlist entry, expanded to the `toFQDNs` entries it means.
1074
1135
  *
1075
- * `SandboxNetworkPolicy.allowedHosts`'s own grammar, which the SDK states and
1076
- * the docker backend implements: `api.example.com` is that host, and
1077
- * `.example.com` is the domain AND its subdomains. Cilium's `matchName` is an
1078
- * exact name and does not match across a `.`, so the domain form needs the
1079
- * name plus a `matchPattern` — `*.example.com` alone would admit
1080
- * `a.example.com` and not `example.com` itself.
1081
- *
1082
- * Used by the PER-SANDBOX policy only. The config-level translation
1083
- * (`config.egress.policy`) deliberately does not expand anything: what it
1084
- * emits for a given config is pinned byte-for-byte, because verification of
1085
- * the named object is an exact match and a changed translation fails every
1086
- * `create()` on every deployment that already applied a policy.
1136
+ * `SandboxNetworkPolicy.allowedHosts`'s own grammar, which the SDK states —
1137
+ * `packages/sdk/src/types/sandbox/index.ts`'s `allowedHosts` — and which
1138
+ * `src/egress/allowlist.ts` implements for the docker backend: `api.example.com`
1139
+ * is that host, and `.example.com` is the domain AND its subdomains. Cilium's
1140
+ * `matchName` is an exact name and does not match across a `.`, so the domain
1141
+ * form needs the name plus a `matchPattern` — `*.example.com` alone would
1142
+ * admit `a.example.com` and not `example.com` itself.
1143
+ *
1144
+ * Used by BOTH translations. The config-level one used to emit a leading-dot
1145
+ * entry verbatim instead, which no DNS answer carries and so denied the
1146
+ * domain and every subdomain it was asked to allow, after a create that
1147
+ * reported success — but "the config-level bytes are pinned" was never a
1148
+ * contract the pinned bytes honoured, and one entry has to mean one thing on
1149
+ * every backend.
1087
1150
  */
1088
1151
  export function ciliumFqdnEntries(entry) {
1089
1152
  if (!entry.startsWith('.'))
@@ -1092,12 +1155,18 @@ export function ciliumFqdnEntries(entry) {
1092
1155
  return [{ matchName: domain }, { matchPattern: `*.${domain}` }];
1093
1156
  }
1094
1157
  /**
1095
- * The DNS-visibility rule narrowed to an exact `matchName` per allowed host
1096
- * plus the host under every search suffix, replacing
1158
+ * The DNS-visibility rule narrowed to the names an allowlist entry admits:
1159
+ * an exact `matchName` per allowed host plus the host under every search
1160
+ * suffix, and — for an entry that admits subdomains — the `matchPattern` that
1161
+ * admits them, under the bare name and under every suffix as well. Replaces
1097
1162
  * {@link CILIUM_DNS_VISIBILITY_RULE}'s `matchPattern: '*'`. See
1098
1163
  * {@link KubernetesCiliumDnsNarrowing}.
1164
+ *
1165
+ * Both halves follow from {@link ciliumFqdnEntries}: the DNS proxy has to see
1166
+ * exactly what the `toFQDNs` rule admits, or the half that learns addresses
1167
+ * from a lookup never sees one the other half allows.
1099
1168
  */
1100
- function narrowedDnsVisibilityRule(allowedHosts, fallbackNamespace, dnsNames, expandDomains = false) {
1169
+ function narrowedDnsVisibilityRule(allowedHosts, fallbackNamespace, dnsNames) {
1101
1170
  const namespace = dnsNames.namespace ?? fallbackNamespace;
1102
1171
  const clusterDomain = dnsNames.clusterDomain ?? 'cluster.local';
1103
1172
  const suffixes = [
@@ -1110,11 +1179,8 @@ function narrowedDnsVisibilityRule(allowedHosts, fallbackNamespace, dnsNames, ex
1110
1179
  for (const entry of allowedHosts) {
1111
1180
  // A `.domain` entry resolves through its subdomains as well, so the
1112
1181
  // DNS proxy has to be allowed to SEE those lookups or `toFQDNs` never
1113
- // learns the addresses they resolve to. Off for the config-level
1114
- // translation, whose emitted bytes are pinned: `expandDomains` is
1115
- // false there and `host === entry`, so this loop is what it always
1116
- // was.
1117
- const wildcard = expandDomains && entry.startsWith('.');
1182
+ // learns the addresses they resolve to.
1183
+ const wildcard = entry.startsWith('.');
1118
1184
  const host = wildcard ? entry.slice(1) : entry;
1119
1185
  matchNames.push({ matchName: host });
1120
1186
  if (wildcard)
@@ -1125,9 +1191,9 @@ function narrowedDnsVisibilityRule(allowedHosts, fallbackNamespace, dnsNames, ex
1125
1191
  // with fewer dots than the cluster's `ndots` tries the search
1126
1192
  // suffixes FIRST, and a lookup the DNS proxy refuses is not an
1127
1193
  // NXDOMAIN the resolver walks past — it can fail the whole
1128
- // resolution. An EXPANDED entry admits subdomains, so its
1129
- // subdomains need the same treatment, or `a.example.com` fails on
1130
- // its first search-suffix attempt under a rule that allows it.
1194
+ // resolution. An entry that admits subdomains needs the pattern
1195
+ // under each suffix too, or `a.example.com` fails on its first
1196
+ // search-suffix attempt under a rule that allows it.
1131
1197
  if (wildcard)
1132
1198
  matchNames.push({ matchPattern: `*.${host}.${suffix}` });
1133
1199
  }
@@ -1143,29 +1209,40 @@ function narrowedDnsVisibilityRule(allowedHosts, fallbackNamespace, dnsNames, ex
1143
1209
  };
1144
1210
  }
1145
1211
  export function buildCiliumEgressManifest(options) {
1146
- const { narrowing, allowedHosts, expandDomains = false } = options;
1147
- // Before anything is emitted. A leading-dot entry under `tlsServerNames`
1212
+ const { narrowing, allowedHosts, refusalContext } = options;
1213
+ // Before anything is emitted, and for EVERY caller — which is the point:
1214
+ // an entry this backend will not translate is one whose object would be
1215
+ // either rejected on apply or, worse, accepted as a name that matches
1216
+ // nothing while the create reports success. The config-level allowlist
1217
+ // used to reach this builder unvalidated, so `['.com']`, `['.']`,
1218
+ // `['..example.com']`, `['*']` and an address were each emitted into
1219
+ // `toFQDNs` — the first three as a `matchPattern` no fence covers on that
1220
+ // path (the shipped one is scoped to the sandbox host's ServiceAccount),
1221
+ // which turned a fail-closed no-op into a silent grant of every name under
1222
+ // a public suffix. See {@link assertHostsAreUsable}.
1223
+ assertHostsAreUsable(allowedHosts);
1224
+ // A leading-dot entry under `tlsServerNames`
1148
1225
  // would become `serverNames: ['.domain']` — not a name any handshake
1149
1226
  // presents — and `['domain']` would deny every subdomain the `toFQDNs`
1150
- // half of the same rule admits; the object is admitted by the shipped
1151
- // fence and reads back deep-equal to what was sent, so nothing downstream
1152
- // would ever report it.
1227
+ // half of the same rule admits; the object goes through with nothing
1228
+ // objecting to it, and reads back deep-equal to what was sent, so nothing
1229
+ // downstream would ever report it. (On the config-level path nothing
1230
+ // objects to it at all: the shipped fence is scoped to the sandbox host's
1231
+ // ServiceAccount and an operator's apply is not matched by it.)
1153
1232
  //
1154
1233
  // What this call guarantees, per caller shape: the per-sandbox writer
1155
1234
  // refuses the same hosts earlier still, by name and by the same context
1156
1235
  // (`per-sandbox-policy.ts`), and this call covers them again if that check
1157
1236
  // is ever reached later or skipped; the config-level translation has no
1158
1237
  // earlier check of its own and relies on this one entirely; and a direct
1159
- // call to this function is covered here too, whatever its `expandDomains`.
1238
+ // call to this function is covered here too, whatever context it passes.
1160
1239
  //
1161
- // ONE input decides all of it. `expandDomains` is not a formatting flag —
1162
- // it is which translation this is, and so which field a refused caller
1163
- // actually set, which sentence is true of the entry on that path, and
1164
- // whether the translation applies the hostname grammar at all. Deriving
1165
- // the field path from anything else is how an expanding caller came to be
1166
- // sent to the config-level field for a repair that only exists under
1167
- // `perSandbox.narrowing`.
1168
- assertHostsFitNarrowing(allowedHosts, narrowing, expandDomains ? PER_SANDBOX_NARROWING_REFUSAL : CONFIG_LEVEL_NARROWING_REFUSAL);
1240
+ // The context decides ONE thing, because one thing is left to decide —
1241
+ // which field the refusal names. It is required rather than derived, since
1242
+ // nothing here can see the caller's config, and derived from the bytes is
1243
+ // exactly how an expanding caller came to be sent to the config-level
1244
+ // field for a repair that only exists under `perSandbox.narrowing`.
1245
+ assertHostsFitNarrowing(allowedHosts, narrowing, refusalContext);
1169
1246
  // Unnarrowed is the exact shape every release before #490 emitted — kept
1170
1247
  // as its own branch, untouched, rather than folded into the narrowed one
1171
1248
  // with every option defaulted off, so the byte-identical guarantee does
@@ -1173,12 +1250,11 @@ export function buildCiliumEgressManifest(options) {
1173
1250
  const narrowed = ciliumNarrowingIsActive(narrowing);
1174
1251
  const activeDns = narrowed ? activeDnsNarrowing(narrowing.dnsNames) : undefined;
1175
1252
  const dnsRule = activeDns !== undefined
1176
- ? narrowedDnsVisibilityRule(allowedHosts, options.namespace, activeDns, expandDomains)
1253
+ ? narrowedDnsVisibilityRule(allowedHosts, options.namespace, activeDns)
1177
1254
  : CILIUM_DNS_VISIBILITY_RULE;
1178
- const entriesFor = (entry) => expandDomains ? ciliumFqdnEntries(entry) : [{ matchName: entry }];
1179
1255
  const hostRules = narrowed
1180
- ? allowedHosts.map((host) => narrowedHostFqdnRule(host, narrowing, entriesFor(host)))
1181
- : [{ toFQDNs: allowedHosts.flatMap(entriesFor) }];
1256
+ ? allowedHosts.map((host) => narrowedHostFqdnRule(host, narrowing, ciliumFqdnEntries(host)))
1257
+ : [{ toFQDNs: allowedHosts.flatMap(ciliumFqdnEntries) }];
1182
1258
  return {
1183
1259
  kind: 'CiliumNetworkPolicy',
1184
1260
  policyKind: options.policyKind,
@@ -1211,6 +1287,7 @@ function buildCiliumNetworkPolicy(target, policyKind, allowedHosts, narrowing) {
1211
1287
  allowedHosts,
1212
1288
  policyKind,
1213
1289
  ...(narrowing !== undefined ? { narrowing } : {}),
1290
+ refusalContext: CONFIG_LEVEL_NARROWING_REFUSAL,
1214
1291
  });
1215
1292
  }
1216
1293
  /**
@@ -1302,8 +1379,16 @@ export class KubernetesEgressPolicyMismatchError extends Error {
1302
1379
  * `../docker/index.ts`'s `assertNetworkCarriesThePolicy`, which inspects the
1303
1380
  * daemon's own `{{.Internal}}` flag instead of trusting a network's name.
1304
1381
  *
1305
- * Checks exactly three things, each named separately in a mismatch so an
1306
- * operator sees which one to fix:
1382
+ * Checks four things, each named separately in a mismatch so an operator sees
1383
+ * which one to fix:
1384
+ * - the object carries NO `specs` list. A `CiliumNetworkPolicy` carries
1385
+ * EITHER one `spec` or a `specs` list and a rule in either one enforces,
1386
+ * while every check below reads `spec` alone — so an object carrying both
1387
+ * would be compared on half of what it enforces. This is refused and not
1388
+ * read, because a comparison that accepts rules it never looked at is the
1389
+ * one answer this function must not give; the shipped admission fence
1390
+ * refuses a `specs` list for the same reason. The translation never emits
1391
+ * one, so its presence is drift and not an alternative spelling;
1307
1392
  * - the selector (`podSelector` for `NetworkPolicy`, `endpointSelector` for
1308
1393
  * `CiliumNetworkPolicy`) carries the expected template label:
1309
1394
  * - `NetworkPolicy` additionally declares `policyTypes` including
@@ -1336,6 +1421,24 @@ export async function verifyEgressPolicyApplied(client, translated, signal) {
1336
1421
  const expectedSpec = translated.manifest.spec;
1337
1422
  const actualSpec = resource?.spec ?? {};
1338
1423
  const selectorKey = translated.kind === 'CiliumNetworkPolicy' ? 'endpointSelector' : 'podSelector';
1424
+ // First, and before anything is compared: an object carrying a `specs`
1425
+ // list is refused rather than read. Everything below reads `spec`, and a
1426
+ // `CiliumNetworkPolicy` rule in a `specs` entry enforces exactly as one in
1427
+ // `spec` does — so comparing `spec` and reporting a match would be
1428
+ // accepting rules this function never looked at, which is the one answer it
1429
+ // must not give. `readCiliumEgressPolicies` reads both spellings and
1430
+ // `decideEgressUnion` refuses a `specs` entry that widens; this is the
1431
+ // other half of the same rule, for the callers that run this check ALONE —
1432
+ // `verify: 'named-object-only'`, and the per-sandbox read-back — and it is
1433
+ // what makes the named-object exemption in `decideEgressUnion` sound rather
1434
+ // than merely narrower. The translation never emits a `specs` list, and the
1435
+ // shipped admission fence refuses one for the same reason.
1436
+ const actualSpecs = resource?.specs;
1437
+ if (actualSpecs !== undefined && actualSpecs !== null) {
1438
+ throw new KubernetesEgressPolicyMismatchError(translated.kind, path, `specs is ${JSON.stringify(actualSpecs)}, and the translation never emits one — ${translated.kind === 'CiliumNetworkPolicy'
1439
+ ? 'a CiliumNetworkPolicy carries EITHER one spec or a specs list, and a rule in either one enforces'
1440
+ : 'a NetworkPolicy has no specs field at all'}, while this check reads spec.${selectorKey}${translated.kind === 'NetworkPolicy' ? ', spec.policyTypes' : ''} and spec.egress and nothing else — so anything a specs list enforces is egress this comparison never read`);
1441
+ }
1339
1442
  if (!isDeepStrictEqual(actualSpec[selectorKey], expectedSpec[selectorKey])) {
1340
1443
  throw new KubernetesEgressPolicyMismatchError(translated.kind, path, `spec.${selectorKey} is ${JSON.stringify(actualSpec[selectorKey])}, expected ${JSON.stringify(expectedSpec[selectorKey])} — the label every Sandbox this backend creates carries`);
1341
1444
  }
@@ -1627,6 +1730,7 @@ export function egressAllowance(translated) {
1627
1730
  permitsEverything: false,
1628
1731
  permitsNothing: false,
1629
1732
  policyKind: translated.policyKind,
1733
+ namedObject: { kind: translated.kind, name: translated.name },
1630
1734
  };
1631
1735
  }
1632
1736
  const destinations = [];
@@ -1663,6 +1767,7 @@ export function egressAllowance(translated) {
1663
1767
  permitsEverything,
1664
1768
  permitsNothing: rules.length === 0,
1665
1769
  policyKind: translated.policyKind,
1770
+ namedObject: { kind: translated.kind, name: translated.name },
1666
1771
  };
1667
1772
  }
1668
1773
  // ---------------------------------------------------------------------------
@@ -2148,6 +2253,31 @@ function unreadableEgressPolicy(kind, name, detail) {
2148
2253
  unreadable: detail,
2149
2254
  };
2150
2255
  }
2256
+ /**
2257
+ * Is this the object `config.egress` named — see
2258
+ * {@link EgressPolicyDocument.namedObject}.
2259
+ *
2260
+ * Read off the allowance rather than passed in, so every caller of
2261
+ * {@link readCoreEgressPolicies}/{@link readCiliumEgressPolicies} that built
2262
+ * its allowance with {@link egressAllowance} gets the marking without having
2263
+ * to remember a second argument, and the readers stay a function of "(items,
2264
+ * this pod, what the translation allows)".
2265
+ *
2266
+ * For a core `NetworkPolicy` the name is the whole answer. For a
2267
+ * `CiliumNetworkPolicy` it is not: the CRD carries EITHER one `spec` or a
2268
+ * `specs` list, `verifyEgressPolicyApplied` reads the former and refuses an
2269
+ * object carrying the latter, so the reader marks only the document built
2270
+ * from `item.spec` and lets a `specs` entry be judged by the union rule like
2271
+ * any other object's rules — see {@link EgressPolicyDocument.namedObject}.
2272
+ *
2273
+ * Deliberately NOT applied to an unreadable document: an object at the named
2274
+ * name that could not be read keeps its `not-evaluable` verdict, which refuses
2275
+ * — a fail-closed answer for a shape whose exact comparison already refused it
2276
+ * earlier on the create path.
2277
+ */
2278
+ function isNamedObject(allowance, kind, name) {
2279
+ return allowance.namedObject.kind === kind && allowance.namedObject.name === name;
2280
+ }
2151
2281
  /** Every core `NetworkPolicy` in the list, reduced to {@link EgressPolicyDocument}. */
2152
2282
  export function readCoreEgressPolicies(items, target, allowance) {
2153
2283
  return items.map((item, index) => {
@@ -2175,6 +2305,7 @@ export function readCoreEgressPolicies(items, target, allowance) {
2175
2305
  return {
2176
2306
  kind: 'NetworkPolicy',
2177
2307
  name,
2308
+ ...(isNamedObject(allowance, 'NetworkPolicy', name) ? { namedObject: true } : {}),
2178
2309
  selects: matchesLabelSelector(spec.podSelector, target.podLabels),
2179
2310
  enforcesEgress,
2180
2311
  // An `egress` block under a `policyTypes` that leaves Egress out is
@@ -2197,30 +2328,47 @@ export function readCiliumEgressPolicies(items, target, allowance) {
2197
2328
  }
2198
2329
  // The CRD carries EITHER one `spec` or a `specs` list, and a rule in
2199
2330
  // either enforces. Reading only `spec` would miss a whole policy.
2200
- const specs = [];
2201
- if (item.spec !== undefined && item.spec !== null)
2202
- specs.push(item.spec);
2331
+ //
2332
+ // The two spellings are NOT equal where the named-object exemption is
2333
+ // concerned — see {@link EgressPolicyDocument.namedObject}. The exact
2334
+ // comparison the exemption leans on reads `spec` and refuses an object
2335
+ // carrying a `specs` list, so `spec` is the one document that earns the
2336
+ // marking; a `specs` entry is read as an ordinary policy's rules and is
2337
+ // judged by the union rule, which is what refuses one that widens.
2338
+ const named = isNamedObject(allowance, 'CiliumNetworkPolicy', name);
2339
+ const specDocuments = [];
2340
+ if (item.spec !== undefined && item.spec !== null) {
2341
+ specDocuments.push({ spec: item.spec, isTheSpec: true });
2342
+ }
2203
2343
  const more = readList(item.specs);
2204
2344
  if (more === 'unreadable') {
2205
2345
  documents.push(unreadableEgressPolicy('CiliumNetworkPolicy', name, 'a specs that is not a list of rule specs'));
2206
2346
  continue;
2207
2347
  }
2208
2348
  for (const spec of more ?? [])
2209
- specs.push(spec);
2210
- if (specs.length === 0) {
2349
+ specDocuments.push({ spec, isTheSpec: false });
2350
+ if (specDocuments.length === 0) {
2211
2351
  documents.push(unreadableEgressPolicy('CiliumNetworkPolicy', name, 'neither a spec nor a specs list'));
2212
2352
  continue;
2213
2353
  }
2214
- for (const spec of specs) {
2215
- const document = readCiliumEgressRuleSpec(spec, name, identity, allowance);
2354
+ for (const { spec, isTheSpec } of specDocuments) {
2355
+ const document = readCiliumEgressRuleSpec(spec, name, identity, allowance, named && isTheSpec);
2216
2356
  if (document !== undefined)
2217
2357
  documents.push(document);
2218
2358
  }
2219
2359
  }
2220
2360
  return documents;
2221
2361
  }
2222
- /** One `spec`/`specs` entry. `undefined` when it is node-scoped — see below. */
2223
- function readCiliumEgressRuleSpec(spec, name, identity, allowance) {
2362
+ /**
2363
+ * One `spec`/`specs` entry. `undefined` when it is node-scoped — see below.
2364
+ *
2365
+ * `isTheNamedSpec` is true only for the document built from the named
2366
+ * object's own `spec` — the one {@link verifyEgressPolicyApplied} compared to
2367
+ * the translation. A `specs` entry never is; see
2368
+ * {@link EgressPolicyDocument.namedObject} for why that distinction is
2369
+ * load-bearing rather than bookkeeping.
2370
+ */
2371
+ function readCiliumEgressRuleSpec(spec, name, identity, allowance, isTheNamedSpec) {
2224
2372
  const unreadable = (detail) => unreadableEgressPolicy('CiliumNetworkPolicy', name, detail);
2225
2373
  if (!isRecord(spec))
2226
2374
  return unreadable('a rule spec that is not an object');
@@ -2248,6 +2396,7 @@ function readCiliumEgressRuleSpec(spec, name, identity, allowance) {
2248
2396
  return {
2249
2397
  kind: 'CiliumNetworkPolicy',
2250
2398
  name,
2399
+ ...(isTheNamedSpec ? { namedObject: true } : {}),
2251
2400
  selects: matchesLabelSelector(spec.endpointSelector, identity, ciliumSelectorKey),
2252
2401
  enforcesEgress,
2253
2402
  // `egressDeny` rules are not read: a deny rule can only narrow what
@@ -2266,6 +2415,15 @@ function readCiliumEgressRuleSpec(spec, name, identity, allowance) {
2266
2415
  * translation is the finding however many narrower ones sit beside it, and a
2267
2416
  * pod no policy default-denies has no egress boundary at all whatever the
2268
2417
  * named object says.
2418
+ *
2419
+ * The named object's `spec` is the one document whose rules are not judged
2420
+ * here — see {@link EgressPolicyDocument.namedObject} — and it was compared to
2421
+ * the translation by {@link verifyEgressPolicyApplied} on the same create path
2422
+ * just before. Its SELECTOR and its egress-scoping still decide, which is what
2423
+ * keeps "nothing bounds this pod" answerable for a deployment whose only
2424
+ * policy is that one; and a document built from a `specs` entry is judged here
2425
+ * like any other object's, because the exempting comparison refuses an object
2426
+ * carrying a `specs` list rather than reading one.
2269
2427
  */
2270
2428
  export function decideEgressUnion(documents, allowance) {
2271
2429
  const examined = [];
@@ -2298,27 +2456,39 @@ export function decideEgressUnion(documents, allowance) {
2298
2456
  undecided ??= entry;
2299
2457
  continue;
2300
2458
  }
2301
- const beyondRule = document.rules.find((rule) => rule.beyond === true);
2302
- if (beyondRule !== undefined) {
2303
- const entry = {
2304
- ...base,
2305
- verdict: 'widens-egress',
2306
- ...(beyondRule.detail !== undefined ? { detail: beyondRule.detail } : {}),
2307
- };
2308
- examined.push(entry);
2309
- widening ??= entry;
2310
- continue;
2311
- }
2312
- const unknownRule = document.rules.find((rule) => rule.beyond === 'unknown');
2313
- if (unknownRule !== undefined) {
2314
- const entry = {
2315
- ...base,
2316
- verdict: 'not-evaluable',
2317
- ...(unknownRule.detail !== undefined ? { detail: unknownRule.detail } : {}),
2318
- };
2319
- examined.push(entry);
2320
- undecided ??= entry;
2321
- continue;
2459
+ // The named object's `spec` rules were just compared to the translation
2460
+ // by `verifyEgressPolicyApplied`, field by field, which is the one
2461
+ // judgement about them that a `toFQDNs`-by-`matchName` allowance cannot
2462
+ // reproduce — see {@link EgressPolicyDocument.namedObject}. So the union
2463
+ // rule does not judge them; judging them here is what made the check
2464
+ // refuse the object it had just told an operator to apply. Everything
2465
+ // below still applies, which is how a pod whose only policy is the named
2466
+ // one is still known to be in egress default-deny. Only that `spec`
2467
+ // document carries the marking: a `specs` entry is judged here like any
2468
+ // other rules.
2469
+ if (document.namedObject !== true) {
2470
+ const beyondRule = document.rules.find((rule) => rule.beyond === true);
2471
+ if (beyondRule !== undefined) {
2472
+ const entry = {
2473
+ ...base,
2474
+ verdict: 'widens-egress',
2475
+ ...(beyondRule.detail !== undefined ? { detail: beyondRule.detail } : {}),
2476
+ };
2477
+ examined.push(entry);
2478
+ widening ??= entry;
2479
+ continue;
2480
+ }
2481
+ const unknownRule = document.rules.find((rule) => rule.beyond === 'unknown');
2482
+ if (unknownRule !== undefined) {
2483
+ const entry = {
2484
+ ...base,
2485
+ verdict: 'not-evaluable',
2486
+ ...(unknownRule.detail !== undefined ? { detail: unknownRule.detail } : {}),
2487
+ };
2488
+ examined.push(entry);
2489
+ undecided ??= entry;
2490
+ continue;
2491
+ }
2322
2492
  }
2323
2493
  if (!document.enforcesEgress) {
2324
2494
  examined.push({
@@ -2428,7 +2598,12 @@ function formatEgressRemedy(refusal, unread) {
2428
2598
  * Verify-not-trust, widened from one object to the union: list the
2429
2599
  * namespace's policies, evaluate every one that selects this pod against the
2430
2600
  * configured translation, and refuse unless nothing lets out more than
2431
- * `config.egress` says.
2601
+ * `config.egress` says. The named object's `spec` document is the one
2602
+ * exception, and it was compared to the translation by
2603
+ * {@link verifyEgressPolicyApplied} on this same create path just before this
2604
+ * one — see {@link EgressPolicyDocument.namedObject}: a Cilium object that
2605
+ * carries a `specs` list is refused there outright, so a `specs` entry is
2606
+ * judged HERE, like any other object's rules.
2432
2607
  *
2433
2608
  * Runs beside the ingress check on every create path — before the POST for a
2434
2609
  * directly created Sandbox, where the labels are known and a refusal leaves