glovebox-hosts 0.4.0__tar.gz
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.
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
.worktrees
|
|
2
|
+
.claude/worktrees/
|
|
3
|
+
.agents/
|
|
4
|
+
.codex/
|
|
5
|
+
|
|
6
|
+
# vm agent-entrypoint boot trace — a runtime log appended into the workspace by
|
|
7
|
+
# gb_boot_trace; a diagnostic artifact, never committed.
|
|
8
|
+
.gb-agent-boot-trace
|
|
9
|
+
|
|
10
|
+
# Dependencies
|
|
11
|
+
# Tool-managed dirs use the BARE name (no trailing slash). A `foo/` pattern
|
|
12
|
+
# matches only a *directory*, so a symlink named `foo` — e.g. one linked into a
|
|
13
|
+
# git worktree to run tests — slips past it and `git add -A` stages it (how a
|
|
14
|
+
# node_modules symlink twice reached CI this repo's history). The bare name
|
|
15
|
+
# matches the dir AND a same-named symlink/file, so tool output can't be
|
|
16
|
+
# committed by accident. Only applied to names that are never a legitimately
|
|
17
|
+
# tracked file (dependency/venv/cache/report dirs); generic names like dist/ or
|
|
18
|
+
# build/ keep the trailing slash so they don't over-ignore a real file.
|
|
19
|
+
node_modules
|
|
20
|
+
.pnpm-store
|
|
21
|
+
|
|
22
|
+
# Build outputs
|
|
23
|
+
dist/
|
|
24
|
+
build/
|
|
25
|
+
out/
|
|
26
|
+
|
|
27
|
+
# Environment files
|
|
28
|
+
.env
|
|
29
|
+
.env.local
|
|
30
|
+
.env.*.local
|
|
31
|
+
|
|
32
|
+
# IDE
|
|
33
|
+
.idea/
|
|
34
|
+
.vscode/
|
|
35
|
+
*.swp
|
|
36
|
+
*.swo
|
|
37
|
+
|
|
38
|
+
# OS
|
|
39
|
+
.DS_Store
|
|
40
|
+
Thumbs.db
|
|
41
|
+
|
|
42
|
+
# Logs
|
|
43
|
+
*.log
|
|
44
|
+
npm-debug.log*
|
|
45
|
+
pnpm-debug.log*
|
|
46
|
+
|
|
47
|
+
# Coverage
|
|
48
|
+
coverage/
|
|
49
|
+
coverage.json
|
|
50
|
+
.c8-output
|
|
51
|
+
.coverage
|
|
52
|
+
.coverage.*
|
|
53
|
+
.py-coverage.lock
|
|
54
|
+
htmlcov
|
|
55
|
+
|
|
56
|
+
# Mutation testing (Stryker): sandbox, HTML report, and the incremental cache
|
|
57
|
+
.stryker-tmp
|
|
58
|
+
reports/
|
|
59
|
+
|
|
60
|
+
# Generated LaunchAgent (rendered from the .template by setup.bash at install time)
|
|
61
|
+
launchagents/*.generated.plist
|
|
62
|
+
|
|
63
|
+
# Python
|
|
64
|
+
__pycache__
|
|
65
|
+
*.pyc
|
|
66
|
+
.venv
|
|
67
|
+
.uv
|
|
68
|
+
.pytest_cache
|
|
69
|
+
|
|
70
|
+
# `uv run` inside inspect-glovebox/, glovebox-monitor/ or glovebox-driver/ writes a lock
|
|
71
|
+
# beside that pyproject.toml. Each wheel is a library, so it pins nothing; committing one
|
|
72
|
+
# adds a `uv.lock -merge` path that scripts/lib-regen-rules.mjs owns no rule for, which
|
|
73
|
+
# reds the JS suite.
|
|
74
|
+
/inspect-glovebox/uv.lock
|
|
75
|
+
/glovebox-monitor/uv.lock
|
|
76
|
+
/glovebox-driver/uv.lock
|
|
77
|
+
# pytest scratch (repo-relative --basetemp): throwaway fixture files, including
|
|
78
|
+
# fake executables the suite writes (keyprobe, docker stubs). Ignored so they never
|
|
79
|
+
# surface as uncommitted changes or get captured onto a session-end review branch.
|
|
80
|
+
.pytest-tmp
|
|
81
|
+
|
|
82
|
+
# Fuzzer crash reproducers (jazzer.js / atheris drop these in the repo root, which
|
|
83
|
+
# is their cwd). Anchored with a leading slash: an unanchored `crash-*` matches at
|
|
84
|
+
# ANY depth, so it silently swallowed bin/checks/vm/crash-resilience-int.bash — a
|
|
85
|
+
# `git add` of a legitimately-named source file that reports success and stages
|
|
86
|
+
# nothing.
|
|
87
|
+
/crash-*
|
|
88
|
+
/timeout-*
|
|
89
|
+
/oom-*
|
|
90
|
+
/leak-*
|
|
91
|
+
|
|
92
|
+
# History working files — each lives on the perf-history or ci-timings branch and is
|
|
93
|
+
# seeded into the checkout by persist-perf-history.sh read or record-setup-timing.sh;
|
|
94
|
+
# never committed to main or a feature branch. A glob, not a list: a tool that walks
|
|
95
|
+
# "tracked plus untracked-not-ignored" takes a seeded file for a repository file, and
|
|
96
|
+
# a per-metric list leaves the next metric's file out of the pattern.
|
|
97
|
+
.github/*-history.json
|
|
98
|
+
.github/*-history.jsonl
|
|
99
|
+
|
|
100
|
+
# pytest --basetemp droppings (used where /tmp is noexec)
|
|
101
|
+
.pytest-exec-tmp
|
|
102
|
+
|
|
103
|
+
# Hypothesis property-testing example database (regenerated locally; never committed)
|
|
104
|
+
.hypothesis/
|
|
105
|
+
|
|
106
|
+
# CI timing maps that balance the shard fan-outs. They live in R2 (uploaded only by
|
|
107
|
+
# main runs, fetched best-effort by CI — see .github/ci-durations.json) and are never
|
|
108
|
+
# committed; ignored so a CI fetch or a local `uv run pytest --store-durations` /
|
|
109
|
+
# vm-live run can't accidentally stage them.
|
|
110
|
+
tests/.gb-test-durations.json
|
|
111
|
+
tests/.gb-test-uninstrumented-durations.json
|
|
112
|
+
tests/.gb-kcov-durations.json
|
|
113
|
+
tests/.gb-drvfs-durations.json
|
|
114
|
+
tests/.gb-macos-durations.json
|
|
115
|
+
# The plan job's published shard assignment, one per leg (keyed by that leg's
|
|
116
|
+
# duration-map name): derived from the fetched map, shipped to the shards in the
|
|
117
|
+
# same artifact, never committed.
|
|
118
|
+
tests/.gb-*-shard-assignment.json
|
|
119
|
+
tests/.gb-select-maps.json
|
|
120
|
+
.github/vm-live/durations.json
|
|
121
|
+
.github/kata-live/durations.json
|
|
122
|
+
|
|
123
|
+
# The pinned scanner binary that .github/scripts/python-deps-vuln-scan.sh downloads
|
|
124
|
+
# into the repo root (~56 MB). Running that script locally to reproduce a red
|
|
125
|
+
# osv-scanner check is the documented response to one, so the artifact lands in
|
|
126
|
+
# every contributor's tree; ignored so it can't be staged by a `git add -A`.
|
|
127
|
+
/osv-scanner
|
|
128
|
+
|
|
129
|
+
# esbuild output — every artifact scripts/build-hook-bundles.mjs or
|
|
130
|
+
# scripts/build-vm-dispatcher.mjs writes, wherever its home. Built once per tree by
|
|
131
|
+
# `pnpm install`'s postinstall, by setup.bash at install time, in the nix buildPhase and
|
|
132
|
+
# in the image's own bundler stage, so the reviewable surface is the sources plus
|
|
133
|
+
# pnpm-lock.yaml. Suffix, not a directory or a member list: a bundle whose home the
|
|
134
|
+
# pattern misses leaves every tree that builds it permanently dirty.
|
|
135
|
+
*.bundle.mjs
|
|
136
|
+
|
|
137
|
+
# Sharded mutation configs are deterministic build output. The planner and
|
|
138
|
+
# `pnpm install` render them from config/mutation-sessions.json.
|
|
139
|
+
/tools/mutation/*of*.toml
|
|
140
|
+
|
|
141
|
+
# The pinned kcov binary that tests/install-kcov-local.sh builds into the repo
|
|
142
|
+
# root — the same path .github/actions/install-kcov caches to, so tests/run-kcov.sh
|
|
143
|
+
# finds a local build with no PATH edit. Reproducing a red "Bash coverage (kcov)"
|
|
144
|
+
# check locally is the documented response to one, so the artifact lands in every
|
|
145
|
+
# contributor's tree; ignored so it can't be staged by a `git add -A`.
|
|
146
|
+
/kcov-bin/
|
|
147
|
+
.claude/.hookpin-*/
|
|
148
|
+
|
|
149
|
+
# synced-deps.test.mjs's untracked-file probe, mkdtemp'd under a synced path on
|
|
150
|
+
# purpose. Ignored so a concurrent `git add -A` elsewhere in this checkout can't
|
|
151
|
+
# stage it mid-test: staged, it would pass trackedUnder()'s check and leave an
|
|
152
|
+
# AD entry in the shared index once the test deletes it from disk.
|
|
153
|
+
.claude/consumer-probe-*/
|
|
154
|
+
|
|
155
|
+
# `ct settings pull` writes this per-checkout pin cache into the repo root. The pin this
|
|
156
|
+
# harness measures lives in evals/control_tower/ct-settings-pin.txt; a committed copy here
|
|
157
|
+
# would be a second one that drifts.
|
|
158
|
+
.settings.local.yml
|
|
159
|
+
|
|
160
|
+
# `gh` writes its per-machine state here when it runs with the repo root as the
|
|
161
|
+
# XDG state home — a device identifier, which no checkout should carry.
|
|
162
|
+
/.local/
|
|
163
|
+
|
|
164
|
+
# Generated doc pages, published to the CDN instead of committed
|
|
165
|
+
# (scripts/render-doc-site.py, .github/workflows/docs-publish.yaml). A committed
|
|
166
|
+
# page is re-derived by every branch that touches its generator's inputs, which
|
|
167
|
+
# made these the busiest files in the history and conflicted branches on bytes
|
|
168
|
+
# nobody typed. Their generators still run, and still refuse a bad input; only
|
|
169
|
+
# the output stays out of the tree.
|
|
170
|
+
/docs/architecture-callgraph.md
|
|
171
|
+
/docs/ci-map.generated.md
|
|
172
|
+
/docs/inspect-evals-coverage.generated.md
|
|
173
|
+
/docs/tla/configs.md
|
|
174
|
+
/docs/tla/reachable-subsets.md
|
|
175
|
+
/docs/tla/diagrams/
|
|
176
|
+
/docs/tla/modules/
|
|
177
|
+
# TLC's own scratch metadir, written here by a run given no -metadir.
|
|
178
|
+
/docs/tla/states/
|
|
179
|
+
|
|
180
|
+
# The eval time series live on the `metrics-history` branch, which `main` never
|
|
181
|
+
# merges (.github/scripts/eval-series.sh). A tracker or a local run materializes its
|
|
182
|
+
# own working copy here; committing one would put the series back on the merge path.
|
|
183
|
+
metrics/monitor-eval.jsonl
|
|
184
|
+
metrics/sabotage-eval.jsonl
|
|
185
|
+
metrics/breakout-ctf.jsonl
|
|
186
|
+
|
|
187
|
+
# `nix build` links its output here.
|
|
188
|
+
/result
|
|
189
|
+
/result-*
|
|
190
|
+
|
|
191
|
+
# go build output of the guest network daemon
|
|
192
|
+
bin/lib/kata/netd/netd
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: glovebox-hosts
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: The one hostname spelling and host-matching rules every glovebox component applies
|
|
5
|
+
Project-URL: Homepage, https://github.com/AlexanderMattTurner/agent-glovebox
|
|
6
|
+
Project-URL: Source, https://github.com/AlexanderMattTurner/agent-glovebox
|
|
7
|
+
Author: AlexanderMattTurner
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
Keywords: egress,glovebox,hostname,sandbox
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Security
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Requires-Dist: tldextract==5.3.2
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# glovebox-hosts
|
|
19
|
+
|
|
20
|
+
The one answer to "which host is this" that every part of glovebox uses: the egress proxy's verdict service, the launcher, the WebFetch hook, the session driver and the evals.
|
|
21
|
+
|
|
22
|
+
A host is compared in one spelling: ASCII only, surrounding whitespace removed, lowercased, and an IP literal in its standard form. A name that is not ASCII, or that starts or ends with a dot, is refused with `HostError` rather than folded, so two readers can never disagree about it.
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
>>> from glovebox_hosts import canonical_host, split_authority, granting_entries
|
|
26
|
+
>>> split_authority("[2001:DB8::1]:443")
|
|
27
|
+
('2001:db8::1', 443)
|
|
28
|
+
>>> granting_entries("API.example.com", ["example.com"])
|
|
29
|
+
['example.com']
|
|
30
|
+
>>> granting_entries("a.b.example.com", ["example.com"])
|
|
31
|
+
[]
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
A grant reaches the entry and one label below it, the depth one certificate wildcard covers. A bar (`constraining_entries`) reaches any depth. A grant may not name a `*.` wildcard over a public suffix, or over a name with one below it (`suffix_below`): `grant_name_refusal` says why, reading the Public Suffix List that `tldextract` bundles. A grant on a public suffix itself is allowed, and `grant_is_exact` tells the policy render to hold it to that one host. That is the package's one dependency, and it is imported only when a grant name is judged.
|
|
35
|
+
|
|
36
|
+
`pod_grants` reads a Kata pod's `glovebox.dev/egress-grants` annotation as every node reads it, so an admission webhook can refuse a pod at submission for the same reason the node would refuse it later. It returns the granted hosts at their tiers and the address grants, and raises `GrantRefused` with the node's own message for the first entry the node refuses. How wide an address grant may be is each node's own setting, so it is not checked here.
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
>>> from glovebox_hosts import GrantRefused, pod_grants
|
|
40
|
+
>>> pod_grants('["httpbin.org", {"host": "203.0.113.5:8443", "rw": true}]').hosts
|
|
41
|
+
{'httpbin.org': 'ro'}
|
|
42
|
+
>>> pod_grants('["*.amazonaws.com"]')
|
|
43
|
+
Traceback (most recent call last):
|
|
44
|
+
...
|
|
45
|
+
glovebox_hosts.GrantRefused: glovebox.dev/egress-grants: '*.amazonaws.com' is a wildcard over a name with the public suffix ...
|
|
46
|
+
```
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# glovebox-hosts
|
|
2
|
+
|
|
3
|
+
The one answer to "which host is this" that every part of glovebox uses: the egress proxy's verdict service, the launcher, the WebFetch hook, the session driver and the evals.
|
|
4
|
+
|
|
5
|
+
A host is compared in one spelling: ASCII only, surrounding whitespace removed, lowercased, and an IP literal in its standard form. A name that is not ASCII, or that starts or ends with a dot, is refused with `HostError` rather than folded, so two readers can never disagree about it.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
>>> from glovebox_hosts import canonical_host, split_authority, granting_entries
|
|
9
|
+
>>> split_authority("[2001:DB8::1]:443")
|
|
10
|
+
('2001:db8::1', 443)
|
|
11
|
+
>>> granting_entries("API.example.com", ["example.com"])
|
|
12
|
+
['example.com']
|
|
13
|
+
>>> granting_entries("a.b.example.com", ["example.com"])
|
|
14
|
+
[]
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
A grant reaches the entry and one label below it, the depth one certificate wildcard covers. A bar (`constraining_entries`) reaches any depth. A grant may not name a `*.` wildcard over a public suffix, or over a name with one below it (`suffix_below`): `grant_name_refusal` says why, reading the Public Suffix List that `tldextract` bundles. A grant on a public suffix itself is allowed, and `grant_is_exact` tells the policy render to hold it to that one host. That is the package's one dependency, and it is imported only when a grant name is judged.
|
|
18
|
+
|
|
19
|
+
`pod_grants` reads a Kata pod's `glovebox.dev/egress-grants` annotation as every node reads it, so an admission webhook can refuse a pod at submission for the same reason the node would refuse it later. It returns the granted hosts at their tiers and the address grants, and raises `GrantRefused` with the node's own message for the first entry the node refuses. How wide an address grant may be is each node's own setting, so it is not checked here.
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
>>> from glovebox_hosts import GrantRefused, pod_grants
|
|
23
|
+
>>> pod_grants('["httpbin.org", {"host": "203.0.113.5:8443", "rw": true}]').hosts
|
|
24
|
+
{'httpbin.org': 'ro'}
|
|
25
|
+
>>> pod_grants('["*.amazonaws.com"]')
|
|
26
|
+
Traceback (most recent call last):
|
|
27
|
+
...
|
|
28
|
+
glovebox_hosts.GrantRefused: glovebox.dev/egress-grants: '*.amazonaws.com' is a wildcard over a name with the public suffix ...
|
|
29
|
+
```
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "glovebox-hosts"
|
|
3
|
+
version = "0.4.0"
|
|
4
|
+
description = "The one hostname spelling and host-matching rules every glovebox component applies"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.9"
|
|
7
|
+
license = "Apache-2.0"
|
|
8
|
+
authors = [{ name = "AlexanderMattTurner" }]
|
|
9
|
+
keywords = ["glovebox", "sandbox", "egress", "hostname"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"Intended Audience :: Science/Research",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Topic :: Security",
|
|
15
|
+
]
|
|
16
|
+
# Imported only when a grant name is judged, so the guest image and the Lima VM still
|
|
17
|
+
# import this module from a bare PYTHONPATH. Pinned: the bundled list decides the verdict.
|
|
18
|
+
dependencies = ["tldextract==5.3.2"]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://github.com/AlexanderMattTurner/agent-glovebox"
|
|
22
|
+
Source = "https://github.com/AlexanderMattTurner/agent-glovebox"
|
|
23
|
+
|
|
24
|
+
[build-system]
|
|
25
|
+
# scripts/write-python-pins.mjs writes this version from the root pyproject's dev pin;
|
|
26
|
+
# an edit here is overwritten by the next `pnpm install` or commit.
|
|
27
|
+
requires = ["hatchling==1.32.0"]
|
|
28
|
+
build-backend = "hatchling.build"
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
packages = ["src/glovebox_hosts"]
|
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
"""The one spelling of a hostname, and the host rules every glovebox reader applies.
|
|
2
|
+
|
|
3
|
+
PROBLEM CLASS — the launcher, the host proxy's verdict service, the WebFetch hook, the
|
|
4
|
+
driver's evidence reader and the evals each ask "which host is this". Answered twice, a
|
|
5
|
+
host one layer admits reads as another host to the next. Its import needs the standard
|
|
6
|
+
library only, so the guest, the Lima VM and a published package all import this one file.
|
|
7
|
+
Judging a grant name imports tldextract, which only a host that renders a policy carries.
|
|
8
|
+
|
|
9
|
+
The WebFetch hook imports it under the host's own python3, which is 3.9 on a stock Mac,
|
|
10
|
+
so every `X | None` annotation is quoted: evaluated at definition time, it raises there.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import functools
|
|
15
|
+
import ipaddress
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
import string
|
|
19
|
+
import sys
|
|
20
|
+
from collections.abc import Callable, Iterable, Iterator
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from typing import TYPE_CHECKING, NamedTuple, overload
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
import tldextract
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class HostError(ValueError):
|
|
29
|
+
"""A string that names no host this tree can rule on, or no readable authority."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def canonical_host(host: str) -> str:
|
|
33
|
+
"""HOST as the one spelling every comparison uses: ASCII, stripped, lowercased.
|
|
34
|
+
|
|
35
|
+
INVARIANT: a name two readers could fold apart is refused, never folded. `str.lower`
|
|
36
|
+
turns U+212A KELVIN SIGN into `k`, and `str.strip` removes U+00A0, so the ASCII read
|
|
37
|
+
runs first. A trailing dot is refused because no allowlist entry carries one.
|
|
38
|
+
"""
|
|
39
|
+
if not host.isascii():
|
|
40
|
+
raise HostError(f"{host!r} is not an ASCII hostname")
|
|
41
|
+
host = host.strip().lower()
|
|
42
|
+
if not host or host.startswith(".") or host.endswith("."):
|
|
43
|
+
raise HostError(f"{host!r} is empty or starts or ends with a dot")
|
|
44
|
+
try:
|
|
45
|
+
return str(ipaddress.ip_address(host))
|
|
46
|
+
except ValueError:
|
|
47
|
+
return host
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@overload
|
|
51
|
+
def split_authority(authority: str, default_port: int) -> tuple[str, int]: ...
|
|
52
|
+
@overload
|
|
53
|
+
def split_authority(
|
|
54
|
+
authority: str, default_port: None = None
|
|
55
|
+
) -> "tuple[str, int | None]": ...
|
|
56
|
+
def split_authority(
|
|
57
|
+
authority: str, default_port: "int | None" = None
|
|
58
|
+
) -> "tuple[str, int | None]":
|
|
59
|
+
"""The canonical host and the port of `host`, `host:port` or `[v6]:port`.
|
|
60
|
+
|
|
61
|
+
Userinfo, a path, a query, a fragment, any whitespace, an unbalanced bracket and a
|
|
62
|
+
port outside 1-65535 are refused: each hands the host rule one name while a client
|
|
63
|
+
dials another (`user@gateway` reads as `gateway`).
|
|
64
|
+
"""
|
|
65
|
+
if any(mark in authority for mark in "@?#/") or any(
|
|
66
|
+
character.isspace() for character in authority
|
|
67
|
+
):
|
|
68
|
+
raise HostError(
|
|
69
|
+
f"invalid authority: {authority!r} carries userinfo, a path or whitespace"
|
|
70
|
+
)
|
|
71
|
+
if authority.startswith("["):
|
|
72
|
+
host, closer, rest = authority[1:].partition("]")
|
|
73
|
+
if not closer or (rest and not rest.startswith(":")):
|
|
74
|
+
raise HostError(
|
|
75
|
+
f"invalid authority: {authority!r} is not a bracketed address"
|
|
76
|
+
)
|
|
77
|
+
# INVARIANT: brackets hold only an IPv6 literal with no zone ID, so `[[]`,
|
|
78
|
+
# `[1.2.3.4]` or `[fe80::1%[]` never reaches the host rule as another name.
|
|
79
|
+
try:
|
|
80
|
+
zoned = ipaddress.IPv6Address(host).scope_id is not None
|
|
81
|
+
except ValueError as exc:
|
|
82
|
+
raise HostError(
|
|
83
|
+
f"invalid authority: {authority!r} is not a bracketed IPv6 literal"
|
|
84
|
+
) from exc
|
|
85
|
+
if zoned:
|
|
86
|
+
raise HostError(f"invalid authority: {authority!r} names an IPv6 zone")
|
|
87
|
+
port = rest[1:]
|
|
88
|
+
elif "[" in authority or "]" in authority:
|
|
89
|
+
raise HostError(f"invalid authority: {authority!r} is not a bracketed address")
|
|
90
|
+
else:
|
|
91
|
+
host, _, port = authority.partition(":")
|
|
92
|
+
if not host:
|
|
93
|
+
raise HostError(f"invalid authority: {authority!r} names no host")
|
|
94
|
+
host = canonical_host(host)
|
|
95
|
+
if not port:
|
|
96
|
+
return host, default_port
|
|
97
|
+
# `isascii` before `isdigit`: `²` is a digit to str.isdigit() and not to int().
|
|
98
|
+
if not port.isascii() or not port.isdigit() or not 1 <= int(port) <= 65535:
|
|
99
|
+
raise HostError(f"invalid authority: {authority!r} names no port in 1-65535")
|
|
100
|
+
return host, int(port)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def render_authority(host: str, port: "int | None") -> str:
|
|
104
|
+
"""HOST as an authority, PORT appended when not None, an IPv6 literal bracketed.
|
|
105
|
+
|
|
106
|
+
`split_authority` strips the brackets, and `2001:db8::1:443` names neither the
|
|
107
|
+
address nor the port, so every authority this tree writes goes through here.
|
|
108
|
+
"""
|
|
109
|
+
literal = f"[{host}]" if ":" in host else host
|
|
110
|
+
return literal if port is None else f"{literal}:{port}"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _without_port(authority: str) -> str:
|
|
114
|
+
"""AUTHORITY less a trailing `:digits` port and the brackets round the host left."""
|
|
115
|
+
try:
|
|
116
|
+
ipaddress.ip_address(authority)
|
|
117
|
+
except ValueError:
|
|
118
|
+
head, colon, tail = authority.rpartition(":")
|
|
119
|
+
if colon and tail.isascii() and tail.isdigit():
|
|
120
|
+
return head[1:-1] if head.startswith("[") and head.endswith("]") else head
|
|
121
|
+
return authority
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def record_authority(authority: str) -> "tuple[str, int | None]":
|
|
125
|
+
"""The canonical host and port a policy-log entry's AUTHORITY names.
|
|
126
|
+
|
|
127
|
+
A refused authority is what the agent typed. One `split_authority` rejects (a bare IPv6
|
|
128
|
+
literal, a port past 65535) keeps its host with the port dropped. Only an ASCII host is
|
|
129
|
+
lowercased, because `str.lower` folds U+212A KELVIN SIGN to `k`; ASCII space is stripped.
|
|
130
|
+
"""
|
|
131
|
+
# ASCII whitespace only: a Unicode space stripped here would fold a name the
|
|
132
|
+
# verdict service refuses into the ASCII host it would have admitted.
|
|
133
|
+
authority = authority.strip(string.whitespace)
|
|
134
|
+
try:
|
|
135
|
+
return split_authority(authority)
|
|
136
|
+
except HostError:
|
|
137
|
+
pass
|
|
138
|
+
authority = _without_port(authority)
|
|
139
|
+
try:
|
|
140
|
+
return canonical_host(authority), None
|
|
141
|
+
except HostError:
|
|
142
|
+
return (authority.lower() if authority.isascii() else authority), None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# Every matcher below re-canonicalises its whole entry table per host, and the failed
|
|
146
|
+
# `ip_address` parse each hostname pays made a policy render quadratic. Only entries reach
|
|
147
|
+
# this cache, never a request host, so no stream of request hosts can evict the table.
|
|
148
|
+
# A call that raises is never cached, so a malformed entry raises on every call.
|
|
149
|
+
_canonical_entry = functools.lru_cache(maxsize=4096)(canonical_host)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _canonical_entries(entries: Iterable[str]) -> Iterator[tuple[str, str]]:
|
|
153
|
+
"""Each non-blank entry as spelled, beside its canonical form.
|
|
154
|
+
|
|
155
|
+
A blank entry is skipped because an empty suffix matches every host. A malformed
|
|
156
|
+
entry raises, because a matcher that quietly skipped it would miss a bar.
|
|
157
|
+
"""
|
|
158
|
+
for entry in entries:
|
|
159
|
+
if entry.strip():
|
|
160
|
+
yield entry, _canonical_entry(entry)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def is_wildcard(entry: str) -> bool:
|
|
164
|
+
"""Whether ENTRY is spelled `*.S`, the marker of a grant over every name below S."""
|
|
165
|
+
return entry.startswith("*.")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _below_wildcard(host: str, canonical: str) -> bool:
|
|
169
|
+
"""Whether CANONICAL is a `*.S` entry and HOST sits any depth below S, never at S."""
|
|
170
|
+
# allow-affix-match: `canonical[1:]` is `.S`, so the leading dot anchors a label boundary.
|
|
171
|
+
return is_wildcard(canonical) and host.endswith(canonical[1:])
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def granting_entries(host: str, entries: Iterable[str]) -> list[str]:
|
|
175
|
+
"""Every entry whose GRANT reaches HOST — the entry itself, or one label below.
|
|
176
|
+
|
|
177
|
+
A named grant reaches as far as one certificate wildcard covers: `example.com`
|
|
178
|
+
names `api.example.com` and not `api.eu.example.com`. A `*.S` entry reaches every
|
|
179
|
+
depth below S and never S itself. A caller needing one entry takes the LONGEST.
|
|
180
|
+
A policy row marked exact narrows its entry to itself; the render marks a public
|
|
181
|
+
suffix's row so (`grant_is_exact`), and every row reader honours the mark.
|
|
182
|
+
"""
|
|
183
|
+
host = canonical_host(host)
|
|
184
|
+
parent = host.partition(".")[2]
|
|
185
|
+
return [
|
|
186
|
+
entry
|
|
187
|
+
for entry, canonical in _canonical_entries(entries)
|
|
188
|
+
if canonical in (host, parent) or _below_wildcard(host, canonical)
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def constraining_entries(host: str, entries: Iterable[str]) -> list[str]:
|
|
193
|
+
"""Every entry whose BAR reaches HOST — the entry itself, or any depth below it.
|
|
194
|
+
|
|
195
|
+
A bar someone can step under bars nothing, so this rule runs the whole subtree.
|
|
196
|
+
"""
|
|
197
|
+
host = canonical_host(host)
|
|
198
|
+
return [
|
|
199
|
+
entry
|
|
200
|
+
for entry, canonical in _canonical_entries(entries)
|
|
201
|
+
if host == canonical
|
|
202
|
+
# allow-affix-match: the leading dot anchors the match at a label boundary.
|
|
203
|
+
or host.endswith("." + canonical)
|
|
204
|
+
or _below_wildcard(host, canonical)
|
|
205
|
+
]
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def governing_entry(
|
|
209
|
+
host: str,
|
|
210
|
+
entries: Iterable[str],
|
|
211
|
+
reach: Callable[[str, Iterable[str]], list[str]],
|
|
212
|
+
) -> "str | None":
|
|
213
|
+
"""The LONGEST entry whose REACH covers HOST, or None when none does.
|
|
214
|
+
|
|
215
|
+
REACH is required: `granting_entries` and `constraining_entries` reach differently.
|
|
216
|
+
Longest by the canonical spelling less a `*.` marker, a named entry winning a tie, so
|
|
217
|
+
neither padding nor entry order picks the answer. Every longest-match rule names it here.
|
|
218
|
+
"""
|
|
219
|
+
matches = reach(host, entries)
|
|
220
|
+
if not matches:
|
|
221
|
+
return None
|
|
222
|
+
return max(matches, key=_entry_depth)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _entry_depth(entry: str) -> tuple[int, bool]:
|
|
226
|
+
canonical = _canonical_entry(entry)
|
|
227
|
+
return len(wildcard_suffix(canonical)), not is_wildcard(canonical)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def family_tier(host: str, tiers: dict[str, str]) -> str:
|
|
231
|
+
"""The tier of the entry in TIERS that governs HOST, or "".
|
|
232
|
+
|
|
233
|
+
TIERS naming one host under two spellings is refused, as the gateway refuses it: a
|
|
234
|
+
tier chosen by key order could grant what the host proxy denies.
|
|
235
|
+
"""
|
|
236
|
+
spelled: dict[str, str] = {}
|
|
237
|
+
for entry, canonical in _canonical_entries(tiers):
|
|
238
|
+
if canonical in spelled:
|
|
239
|
+
raise HostError(f"{entry!r} and {spelled[canonical]!r} are one host")
|
|
240
|
+
spelled[canonical] = entry
|
|
241
|
+
governing = governing_entry(host, tiers, granting_entries)
|
|
242
|
+
return "" if governing is None else tiers[governing]
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
_LABEL = re.compile(r"[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?")
|
|
246
|
+
_PLAIN = re.compile(r"[A-Za-z0-9._-]*")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def valid_domain_name(name: str) -> bool:
|
|
250
|
+
"""Whether NAME is a bare DNS hostname a workspace or a grant may name.
|
|
251
|
+
|
|
252
|
+
Dot-separated labels of 1-63 letters, digits and hyphens, each edged with a letter
|
|
253
|
+
or digit, 253 characters at most, and at least one dot. The last label starts with
|
|
254
|
+
a letter, which refuses every IPv4 spelling inet_aton(3) dials, octal and hex
|
|
255
|
+
included (`0177.0.0.1` reaches 127.0.0.1). A URL, a port, a wildcard, whitespace
|
|
256
|
+
and a trailing dot all fail, so no such value seeds an allowlist entry.
|
|
257
|
+
"""
|
|
258
|
+
if len(name) > 253 or not name.isascii():
|
|
259
|
+
return False
|
|
260
|
+
labels = name.split(".")
|
|
261
|
+
return (
|
|
262
|
+
len(labels) > 1
|
|
263
|
+
and labels[-1][:1].isalpha()
|
|
264
|
+
and all(len(label) <= 63 and _LABEL.fullmatch(label) for label in labels)
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def wildcard_suffix(name: str) -> str:
|
|
269
|
+
"""NAME less a leading `*.`: the suffix S a `*.S` grant names, or NAME itself."""
|
|
270
|
+
return name.removeprefix("*.")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# Names judged public suffixes whatever the bundled list says. Each value names what the
|
|
274
|
+
# label below the suffix belongs to. The first is pinned so the refusal does not depend on
|
|
275
|
+
# upstream list churn; the bundled snapshot carries it today. The other two the list lacks.
|
|
276
|
+
_PUBLIC_SUFFIX_SUPPLEMENT = {
|
|
277
|
+
"blob.core.windows.net": "an Azure storage account",
|
|
278
|
+
"web.core.windows.net": "an Azure storage account's static site",
|
|
279
|
+
"modal.run": "a Modal workspace",
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@functools.cache
|
|
284
|
+
def _suffix_of() -> "Callable[[str], str]":
|
|
285
|
+
"""The public suffix of a host, read from the Public Suffix List tldextract bundles.
|
|
286
|
+
|
|
287
|
+
Offline: `suffix_list_urls=()` and `cache_dir=None` read the snapshot and write
|
|
288
|
+
nothing. `include_psl_private_domains=True` admits the list's private section, where
|
|
289
|
+
`github.io` and `blob.core.windows.net` sit. ImportError where tldextract is absent.
|
|
290
|
+
"""
|
|
291
|
+
extract = _extractor()
|
|
292
|
+
return lambda host: extract(host).suffix
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@functools.cache
|
|
296
|
+
def _extractor() -> "tldextract.TLDExtract":
|
|
297
|
+
"""The offline tldextract reader `_suffix_of` and `suffix_below` share."""
|
|
298
|
+
import tldextract # pylint: disable=import-outside-toplevel
|
|
299
|
+
|
|
300
|
+
return tldextract.TLDExtract(
|
|
301
|
+
suffix_list_urls=(), include_psl_private_domains=True, cache_dir=None
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@functools.cache
|
|
306
|
+
def _suffix_rules() -> tuple[str, ...]:
|
|
307
|
+
"""Every public suffix rule, folded and sorted, a `*.S` rule kept as written. An
|
|
308
|
+
exception rule (`!host`) names a host that is NOT a suffix, so none is kept."""
|
|
309
|
+
rules = {rule.lower() for rule in _extractor().tlds if not rule.startswith("!")}
|
|
310
|
+
# The list spells an internationalized rule in Unicode, and a grant names it in
|
|
311
|
+
# punycode, so each rule is kept in both spellings.
|
|
312
|
+
spelled = rules | {_ascii_rule(rule) for rule in rules}
|
|
313
|
+
return tuple(sorted(spelled | set(_PUBLIC_SUFFIX_SUPPLEMENT)))
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _ascii_rule(rule: str) -> str:
|
|
317
|
+
"""RULE with each non-ASCII label in its punycode spelling, or RULE unchanged where
|
|
318
|
+
a label has none."""
|
|
319
|
+
try:
|
|
320
|
+
return ".".join(
|
|
321
|
+
label if label.isascii() else label.encode("idna").decode("ascii")
|
|
322
|
+
for label in rule.split(".")
|
|
323
|
+
)
|
|
324
|
+
except UnicodeError:
|
|
325
|
+
return rule
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def suffix_below(host: str) -> "str | None":
|
|
329
|
+
"""A public suffix rule strictly below HOST, or None. A `*.S` grant reaches every
|
|
330
|
+
depth below S, so one over such a host also reaches the names strangers register
|
|
331
|
+
under that suffix: `*.amazonaws.com` reaches `bucket.s3.amazonaws.com`.
|
|
332
|
+
ImportError where tldextract is absent."""
|
|
333
|
+
dotted = "." + wildcard_suffix(host.strip().lower())
|
|
334
|
+
return next((rule for rule in _suffix_rules() if rule.endswith(dotted)), None)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def is_public_suffix(host: str) -> bool:
|
|
338
|
+
"""True when HOST's grant reaches a name a stranger registers a label below.
|
|
339
|
+
|
|
340
|
+
Folded, as every host rule folds, so `Modal.run` is judged as `modal.run`. The probe
|
|
341
|
+
label is what reads the list's WILDCARD rules: `*.compute.amazonaws.com` leaves
|
|
342
|
+
`compute.amazonaws.com` looking like an ordinary domain while the one label below it
|
|
343
|
+
is a different stranger's host each time. A `*.S` entry is read as S.
|
|
344
|
+
"""
|
|
345
|
+
folded = wildcard_suffix(host.strip().lower())
|
|
346
|
+
if folded in _PUBLIC_SUFFIX_SUPPLEMENT:
|
|
347
|
+
return True
|
|
348
|
+
reached = f"a.{folded}"
|
|
349
|
+
return _suffix_of()(reached) in (folded, reached)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def grant_name_refusal(name: str) -> "str | None":
|
|
353
|
+
"""Why NAME is no host a grant may name, as a phrase that follows the name, or None.
|
|
354
|
+
|
|
355
|
+
A grant names a `valid_domain_name`, or `*.S` for one. `*.S` reaches every name
|
|
356
|
+
below S, so it is refused when S is a public suffix, or when one lies below S
|
|
357
|
+
(`suffix_below`): either way it hands the grant to strangers. A plain public suffix
|
|
358
|
+
such as `httpbin.org` may be granted, because `grant_is_exact` holds its grant to
|
|
359
|
+
that one host instead of one label below it.
|
|
360
|
+
|
|
361
|
+
INVARIANT: without tldextract every name is refused, because a name this python cannot
|
|
362
|
+
judge may be a public suffix, and a grant over one reaches strangers.
|
|
363
|
+
"""
|
|
364
|
+
if not valid_domain_name(wildcard_suffix(name)):
|
|
365
|
+
return (
|
|
366
|
+
"is not a valid hostname (letters, digits, dots and hyphens, with at least"
|
|
367
|
+
" one dot) or a `*.` wildcard over one"
|
|
368
|
+
)
|
|
369
|
+
try:
|
|
370
|
+
public = is_public_suffix(name)
|
|
371
|
+
below = suffix_below(name) if is_wildcard(name) and not public else None
|
|
372
|
+
except ImportError:
|
|
373
|
+
return (
|
|
374
|
+
f"cannot be judged: {sys.executable} cannot import tldextract to tell"
|
|
375
|
+
" whether it is, or spans, a public suffix. Install tldextract==5.3.2 into"
|
|
376
|
+
" that python"
|
|
377
|
+
)
|
|
378
|
+
if public and is_wildcard(name):
|
|
379
|
+
return (
|
|
380
|
+
"is a wildcard over a public suffix, so it reaches every name a stranger"
|
|
381
|
+
" registers below it. Name the one host you meant"
|
|
382
|
+
)
|
|
383
|
+
if below is not None:
|
|
384
|
+
return (
|
|
385
|
+
f"is a wildcard over a name with the public suffix {below!r} below it, so"
|
|
386
|
+
" it reaches every name a stranger registers there. Name the one host you"
|
|
387
|
+
" meant"
|
|
388
|
+
)
|
|
389
|
+
return None
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def grant_is_exact(name: str) -> bool:
|
|
393
|
+
"""Whether a grant on NAME reaches NAME alone, never the one label below it that a
|
|
394
|
+
plain grant reaches: NAME is a plain public suffix, whose labels strangers register.
|
|
395
|
+
|
|
396
|
+
ImportError where tldextract is absent: an answer of False there would let a public
|
|
397
|
+
suffix reach the strangers below it, so the caller must refuse rather than guess.
|
|
398
|
+
"""
|
|
399
|
+
if is_wildcard(name):
|
|
400
|
+
return False
|
|
401
|
+
return is_public_suffix(name)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def valid_grant_name(name: str) -> bool:
|
|
405
|
+
"""Whether NAME is a host a grant may name: `grant_name_refusal` finds no fault.
|
|
406
|
+
|
|
407
|
+
`*.S` reaches every name below S and never S itself. S keeps the one-dot floor, so
|
|
408
|
+
`*`, `*.com`, `**.example.com` and `a.*.example.com` all fail, and so does `*.co.uk`.
|
|
409
|
+
`co.uk` itself passes, as a grant `grant_is_exact` holds to that one host.
|
|
410
|
+
"""
|
|
411
|
+
return grant_name_refusal(name) is None
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def lookalike(name: str) -> bool:
|
|
415
|
+
"""Whether NAME carries an `xn--` label or a character outside `[A-Za-z0-9._-]`.
|
|
416
|
+
|
|
417
|
+
Those are the shapes a homoglyph of an allowlisted host hides behind. The punycode
|
|
418
|
+
test ignores case, because DNS does: `XN--80AK6AA92E.com` is the same host. A
|
|
419
|
+
leading `*.` is a wildcard grant's marker, so the test reads the suffix below it.
|
|
420
|
+
"""
|
|
421
|
+
name = wildcard_suffix(name)
|
|
422
|
+
return "xn--" in name.lower() or not _PLAIN.fullmatch(name)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
# The annotation a Kata pod names its own egress grants in, and the keys one grant object
|
|
426
|
+
# may carry. A reader would ignore any other key, so a key meant to narrow a grant would
|
|
427
|
+
# widen it unread: the grant is refused instead.
|
|
428
|
+
ANNOTATION_GRANTS = "glovebox.dev/egress-grants"
|
|
429
|
+
GRANT_KEYS = frozenset({"host", "rw"})
|
|
430
|
+
DEFAULT_PORT = 443 # the port an address grant opens when it names none
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
class GrantRefused(ValueError):
|
|
434
|
+
"""A grant this tree will not open, with the sentence that says why."""
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
@dataclass(frozen=True)
|
|
438
|
+
class AddressGrant:
|
|
439
|
+
"""One address grant as written: a network and the one port it opens."""
|
|
440
|
+
|
|
441
|
+
network: "ipaddress.IPv4Network | ipaddress.IPv6Network"
|
|
442
|
+
port: int
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def address_grant(entry: str) -> "AddressGrant | None":
|
|
446
|
+
"""ENTRY as an address grant, None when it names a host, GrantRefused when malformed.
|
|
447
|
+
|
|
448
|
+
`ADDR`, `ADDR/PREFIX`, either with `:PORT`; IPv6 takes `[ADDR[/PREFIX]]:PORT` for a
|
|
449
|
+
port. The port defaults to DEFAULT_PORT. Host bits set under the prefix are refused.
|
|
450
|
+
How wide a network one grant may name is the caller's to judge.
|
|
451
|
+
"""
|
|
452
|
+
text = entry.strip()
|
|
453
|
+
port_text: str | None = None
|
|
454
|
+
if text.startswith("["):
|
|
455
|
+
inner, closer, rest = text[1:].partition("]")
|
|
456
|
+
if not closer or (rest and not rest.startswith(":")):
|
|
457
|
+
raise GrantRefused(f"{entry!r} is not a bracketed IPv6 grant")
|
|
458
|
+
text, port_text = inner, rest[1:] if rest else None
|
|
459
|
+
elif text.count(":") == 1:
|
|
460
|
+
text, _, port_text = text.partition(":")
|
|
461
|
+
try:
|
|
462
|
+
network = ipaddress.ip_network(text, strict=True)
|
|
463
|
+
except ValueError as bad:
|
|
464
|
+
if "/" in text or ":" in text or entry.strip().startswith("["):
|
|
465
|
+
raise GrantRefused(f"{entry!r} is not an address grant: {bad}") from bad
|
|
466
|
+
return None
|
|
467
|
+
return AddressGrant(network=network, port=grant_port(port_text, entry))
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def grant_port(text: "str | None", entry: str) -> int:
|
|
471
|
+
"""TEXT as a port; None, a port the entry never wrote, is DEFAULT_PORT. A port written
|
|
472
|
+
empty (`ADDR:`) is refused, never read as the default."""
|
|
473
|
+
if text is None:
|
|
474
|
+
return DEFAULT_PORT
|
|
475
|
+
if not (text.isascii() and text.isdigit() and 1 <= int(text) <= 65535):
|
|
476
|
+
raise GrantRefused(f"{entry!r} names no port in 1-65535")
|
|
477
|
+
return int(text)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def grant_host_refusal(host: str) -> "str | None":
|
|
481
|
+
"""Why HOST is no name a pod's grant may carry, as a whole phrase, or None: the grant
|
|
482
|
+
name rules plus `lookalike`. The forbidden-host bar applies at the render."""
|
|
483
|
+
fault = grant_name_refusal(host)
|
|
484
|
+
if fault is not None:
|
|
485
|
+
return f"{host!r} {fault}"
|
|
486
|
+
if lookalike(host):
|
|
487
|
+
return f"{host!r} carries punycode or non-ASCII characters"
|
|
488
|
+
return None
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
class PodGrants(NamedTuple):
|
|
492
|
+
"""What a pod's grants annotation names: each host at `ro` or `rw`, and each address."""
|
|
493
|
+
|
|
494
|
+
hosts: dict[str, str]
|
|
495
|
+
addresses: list[AddressGrant]
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _as_text(value: object) -> str:
|
|
499
|
+
"""A grant field as text. A non-string renders as JSON, so a boolean reads `true`."""
|
|
500
|
+
return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _grant_entries(text: str) -> list[tuple[str, str]]:
|
|
504
|
+
"""Each `(entry, tier)` TEXT names: `"host"`, or `{"host": ..., "rw": ...}` where an
|
|
505
|
+
absent or false `rw` reads as read-only, the weaker."""
|
|
506
|
+
try:
|
|
507
|
+
listed = json.loads(text)
|
|
508
|
+
except (ValueError, RecursionError) as bad:
|
|
509
|
+
# RecursionError: an annotation nested deeper than the parser's stack.
|
|
510
|
+
raise GrantRefused(f"{ANNOTATION_GRANTS} is not JSON: {bad}") from bad
|
|
511
|
+
if not isinstance(listed, list):
|
|
512
|
+
raise GrantRefused(f"{ANNOTATION_GRANTS} is not a JSON list of hosts")
|
|
513
|
+
for raw in listed:
|
|
514
|
+
unknown = sorted(set(raw) - GRANT_KEYS) if isinstance(raw, dict) else []
|
|
515
|
+
if unknown:
|
|
516
|
+
raise GrantRefused(
|
|
517
|
+
f"{ANNOTATION_GRANTS}: a grant carries {unknown[0]!r}; "
|
|
518
|
+
f"a grant's keys are {' and '.join(sorted(GRANT_KEYS))}"
|
|
519
|
+
)
|
|
520
|
+
entries: list[tuple[str, str]] = []
|
|
521
|
+
for raw in listed:
|
|
522
|
+
grant = raw if isinstance(raw, dict) else {"host": raw}
|
|
523
|
+
rw_value = grant.get("rw")
|
|
524
|
+
rw = _as_text(False if rw_value is None or rw_value is False else rw_value)
|
|
525
|
+
host = _as_text(grant.get("host"))
|
|
526
|
+
if rw not in ("true", "false"):
|
|
527
|
+
raise GrantRefused(
|
|
528
|
+
f"{ANNOTATION_GRANTS}: {host}'s rw is {rw}, not a boolean"
|
|
529
|
+
)
|
|
530
|
+
entries.append((host, "rw" if rw == "true" else "ro"))
|
|
531
|
+
return entries
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def pod_grants(text: "str | None") -> PodGrants:
|
|
535
|
+
"""What a Kata pod's `glovebox.dev/egress-grants` annotation TEXT grants, as every
|
|
536
|
+
node and admission webhook must read it. None, no annotation, grants nothing.
|
|
537
|
+
|
|
538
|
+
INVARIANT: the one reader of this annotation. GrantRefused names the first entry the
|
|
539
|
+
node would refuse the whole pod for. An address must carry `"rw": true`, since a
|
|
540
|
+
tunnel's bytes go unread. How wide an address may be is the node's own setting.
|
|
541
|
+
"""
|
|
542
|
+
if text is None:
|
|
543
|
+
return PodGrants(hosts={}, addresses=[])
|
|
544
|
+
hosts: dict[str, str] = {}
|
|
545
|
+
addresses: list[AddressGrant] = []
|
|
546
|
+
for entry, tier in _grant_entries(text):
|
|
547
|
+
if entry != entry.strip():
|
|
548
|
+
# address_grant would strip it, and the name rules refuse it: judge one
|
|
549
|
+
# spelling, so a padded address is refused as a padded name is.
|
|
550
|
+
raise GrantRefused(
|
|
551
|
+
f"{ANNOTATION_GRANTS}: {entry!r} carries surrounding whitespace"
|
|
552
|
+
)
|
|
553
|
+
try:
|
|
554
|
+
grant = address_grant(entry)
|
|
555
|
+
except GrantRefused as bad:
|
|
556
|
+
raise GrantRefused(f"{ANNOTATION_GRANTS}: {bad}") from bad
|
|
557
|
+
if grant is not None:
|
|
558
|
+
if tier != "rw":
|
|
559
|
+
raise GrantRefused(
|
|
560
|
+
f"{ANNOTATION_GRANTS}: {entry} is an address, which opens a raw "
|
|
561
|
+
'tunnel; grant it with "rw": true'
|
|
562
|
+
)
|
|
563
|
+
addresses.append(grant)
|
|
564
|
+
continue
|
|
565
|
+
refusal = grant_host_refusal(entry)
|
|
566
|
+
if refusal is not None:
|
|
567
|
+
raise GrantRefused(f"{ANNOTATION_GRANTS}: {refusal}")
|
|
568
|
+
hosts[entry] = tier
|
|
569
|
+
return PodGrants(hosts=hosts, addresses=addresses)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _name_check(args: argparse.Namespace) -> None:
|
|
573
|
+
"""Print `malformed`, `lookalike` or `ok` for NAME, the first fault found.
|
|
574
|
+
|
|
575
|
+
A shell caller maps the word to its own message and refuses on any other output,
|
|
576
|
+
so an interpreter that cannot run refuses too.
|
|
577
|
+
"""
|
|
578
|
+
if not args.lookalike_only and not valid_domain_name(args.name):
|
|
579
|
+
print("malformed")
|
|
580
|
+
elif lookalike(args.name):
|
|
581
|
+
print("lookalike")
|
|
582
|
+
else:
|
|
583
|
+
print("ok")
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _constraining(args: argparse.Namespace) -> None:
|
|
587
|
+
"""Print each host on stdin that the denylist file bars, in one batch per launch."""
|
|
588
|
+
with open(args.entries, encoding="utf-8") as handle:
|
|
589
|
+
entries = handle.read().splitlines()
|
|
590
|
+
# A blank line is the batch's framing, as it is in the entry file; a host that is
|
|
591
|
+
# not blank and has no canonical spelling still raises.
|
|
592
|
+
for host in filter(str.strip, sys.stdin.read().splitlines()):
|
|
593
|
+
if constraining_entries(host, entries):
|
|
594
|
+
print(host)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _tier(args: argparse.Namespace) -> None:
|
|
598
|
+
"""Print `{"tier": ..., "error": ...}` for HOST under the allowlist's `domains`,
|
|
599
|
+
plus the API hosts of the `--agent` the caller runs inside."""
|
|
600
|
+
with open(args.allowlist, encoding="utf-8") as handle:
|
|
601
|
+
document = json.load(handle)
|
|
602
|
+
domains = document["domains"]
|
|
603
|
+
if args.agent:
|
|
604
|
+
domains = {
|
|
605
|
+
**domains,
|
|
606
|
+
**document["agents"][args.agent]["api_host_allowlist_entries"],
|
|
607
|
+
}
|
|
608
|
+
try:
|
|
609
|
+
canonical_host(args.host)
|
|
610
|
+
except HostError as exc:
|
|
611
|
+
print(json.dumps({"tier": None, "error": str(exc)}))
|
|
612
|
+
return
|
|
613
|
+
# An entry that names no host is the allowlist's defect, so it raises past here.
|
|
614
|
+
print(json.dumps({"tier": family_tier(args.host, domains) or None, "error": None}))
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def main(argv: list[str]) -> None:
|
|
618
|
+
"""`constraining ENTRIES`, `tier [--agent NAME] ALLOWLIST HOST` and `name-check [--lookalike-only]
|
|
619
|
+
NAME`, for callers outside Python."""
|
|
620
|
+
parser = argparse.ArgumentParser(prog="glovebox_hosts")
|
|
621
|
+
commands = parser.add_subparsers(required=True)
|
|
622
|
+
constraining = commands.add_parser("constraining")
|
|
623
|
+
constraining.add_argument("entries")
|
|
624
|
+
constraining.set_defaults(run=_constraining)
|
|
625
|
+
tier = commands.add_parser("tier")
|
|
626
|
+
tier.add_argument("--agent", default="")
|
|
627
|
+
tier.add_argument("allowlist")
|
|
628
|
+
tier.add_argument("host")
|
|
629
|
+
tier.set_defaults(run=_tier)
|
|
630
|
+
check = commands.add_parser("name-check")
|
|
631
|
+
check.add_argument("--lookalike-only", action="store_true")
|
|
632
|
+
check.add_argument("name")
|
|
633
|
+
check.set_defaults(run=_name_check)
|
|
634
|
+
args = parser.parse_args(argv)
|
|
635
|
+
args.run(args)
|