6ab1e7c56c
gates / gates (push) Successful in 20s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
415 lines
19 KiB
Python
Executable File
415 lines
19 KiB
Python
Executable File
#!/usr/bin/python3
|
|
"""felhom-priv-apply — the ROOT half of every file the agent writes into a root-read place (R-861, `03` §3.1).
|
|
|
|
Install as /usr/local/sbin/felhom-priv-apply (0755 root:root) — it rides the signed config bundle (R-840).
|
|
|
|
WHY IT EXISTS. Until agent v0.146.0 the agent's sudoers let it `install` a file it had written itself into a place a
|
|
root program reads: a systemd .mount unit (a bind mount of an agent-owned directory over /etc/sudoers.d is a root
|
|
shell), a dnsmasq drop-in (`dhcp-script=` runs as root), the WireGuard config (`PostUp=` runs as root) and the OOB
|
|
sshd config (`AuthorizedKeysFile` + `StrictModes no`). A compromised agent PROCESS was therefore root on its host.
|
|
Now the agent stages the file and this wrapper — root-owned, delivered only by an operator-signed bundle — checks
|
|
the CONTENT against the exact grammar the agent's own renderers produce, and refuses anything else. The agent can no
|
|
longer name the destination: each verb has a fixed source and a fixed (or strictly named) destination.
|
|
|
|
Verbs (each one sudoers line, exact-match pattern):
|
|
unit <name> /var/lib/felhom-agent/units/<name> -> /etc/systemd/system/<name> (.mount | .automount)
|
|
dnsmasq <tmp> <name> /tmp/felhom-resolver-<digits>.conf -> /etc/dnsmasq.d/felhom-<...>.conf
|
|
wg /var/lib/felhom-agent/wg/wg-felhom.conf -> /etc/wireguard/wg-felhom.conf (0600)
|
|
sshd-config /var/lib/felhom-agent/felhom-sshd/sshd_config -> /etc/felhom-sshd/sshd_config
|
|
sshd-key /var/lib/felhom-agent/felhom-sshd/authorized_keys.felhom-op -> /etc/felhom-sshd/authorized_keys/felhom-op
|
|
--self-check prints "felhom-priv-apply ok verbs=..." (the bundle's self-check)
|
|
|
|
Exit codes: 0 installed (or already identical), 2 usage, 3 refused (content or source), 4 install failed.
|
|
Every refusal is logged to the journal (tag felhom-priv-apply) with its rule; file CONTENT is never logged.
|
|
Pinned by configs/test_felhom_priv_apply.py (one test per rule, red-proofs in the R-861 audit).
|
|
"""
|
|
import ipaddress
|
|
import os
|
|
import re
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
|
|
AGENT_USER = "felhom-agent"
|
|
STATE = "/var/lib/felhom-agent"
|
|
UNITS_SRC = STATE + "/units"
|
|
UNIT_DIR = "/etc/systemd/system"
|
|
DNSMASQ_DIR = "/etc/dnsmasq.d"
|
|
WG_SRC, WG_DEST = STATE + "/wg/wg-felhom.conf", "/etc/wireguard/wg-felhom.conf"
|
|
SSHD_SRC, SSHD_DEST = STATE + "/felhom-sshd/sshd_config", "/etc/felhom-sshd/sshd_config"
|
|
KEY_SRC, KEY_DEST = STATE + "/felhom-sshd/authorized_keys.felhom-op", "/etc/felhom-sshd/authorized_keys/felhom-op"
|
|
MAX_BYTES = 64 * 1024
|
|
VERBS = ("unit", "dnsmasq", "wg", "sshd-config", "sshd-key")
|
|
|
|
UNIT_NAME_RE = re.compile(r"^mnt-[A-Za-z0-9_.\\-]+\.(mount|automount)$")
|
|
DNSMASQ_TMP_RE = re.compile(r"^/tmp/felhom-resolver-[0-9]+\.conf$")
|
|
DNSMASQ_NAME_RE = re.compile(r"^felhom-[a-z0-9][a-z0-9._-]*\.conf$")
|
|
SEG = r"[A-Za-z0-9_-][A-Za-z0-9_.-]*"
|
|
WHERE_RE = re.compile(r"^/mnt/(felhom-drives/)?" + SEG + r"$")
|
|
UUID_RE = re.compile(r"^[A-Fa-f0-9]{4,}(-[A-Fa-f0-9]+){0,4}$")
|
|
HOST_RE = re.compile(r"^[A-Za-z0-9._:-]{1,255}$")
|
|
NET_PATH_RE = re.compile(r"^[A-Za-z0-9._/@+-]{1,512}$")
|
|
OPT_RE = re.compile(r"^[A-Za-z0-9_.:/@+-]+(=[A-Za-z0-9_.:/@+-]+)?$")
|
|
LOCAL_TYPES = {"ext4", "xfs", "btrfs", "exfat", "vfat", "ntfs3", "ntfs"}
|
|
NET_TYPES = {"nfs", "nfs4", "cifs"}
|
|
# Options that turn a device mount into something else, or let set-uid/device files act on the host.
|
|
FORBIDDEN_OPTS = {"bind", "rbind", "move", "rmove", "remount", "suid", "dev", "user", "users", "owner", "group",
|
|
"x-mount.mkdir", "helper"}
|
|
UNIT_TOKEN_RE = re.compile(r"^[A-Za-z0-9@_.\\:-]+$")
|
|
DESC_RE = re.compile(r"^[^\x00-\x1f\x7f]{0,200}$")
|
|
WG_KEY_RE = re.compile(r"^[A-Za-z0-9+/]{42}[AEIMQUYcgkosw480]=$")
|
|
KEY_LINE_RE = re.compile(r"^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp(256|384|521)|sk-ssh-ed25519@openssh\.com) "
|
|
r"[A-Za-z0-9+/]+={0,3}( [ -~]{0,200})?$")
|
|
|
|
|
|
class Refused(Exception):
|
|
def __init__(self, rule, reason):
|
|
super().__init__(reason)
|
|
self.rule, self.reason = rule, reason
|
|
|
|
|
|
class Host:
|
|
"""Every filesystem / process effect, so the tests can play the box in memory."""
|
|
|
|
def agent_uid(self):
|
|
import pwd
|
|
return pwd.getpwnam(AGENT_USER).pw_uid
|
|
|
|
def read_source(self, path):
|
|
"""The staged file: a REGULAR file owned by the agent, never a symlink, at most MAX_BYTES."""
|
|
try:
|
|
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
|
|
except OSError as e:
|
|
raise Refused("P1", f"cannot open the staged file {path}: {e.strerror}")
|
|
try:
|
|
st = os.fstat(fd)
|
|
if not stat.S_ISREG(st.st_mode):
|
|
raise Refused("P1", f"{path} is not a regular file")
|
|
if st.st_uid != self.agent_uid():
|
|
raise Refused("P1", f"{path} is not owned by {AGENT_USER}")
|
|
if st.st_size > MAX_BYTES:
|
|
raise Refused("P1", f"{path} is larger than {MAX_BYTES} bytes")
|
|
with os.fdopen(fd, "rb") as f:
|
|
fd = -1
|
|
return f.read(MAX_BYTES + 1)
|
|
finally:
|
|
if fd >= 0:
|
|
os.close(fd)
|
|
|
|
def read_dest(self, path):
|
|
try:
|
|
with open(path, "rb") as f:
|
|
return f.read()
|
|
except OSError:
|
|
return None
|
|
|
|
def install(self, dest, data, mode):
|
|
"""Atomic, root-owned: a temp file beside the destination, fsync, rename."""
|
|
d = os.path.dirname(dest)
|
|
os.makedirs(d, mode=0o755, exist_ok=True)
|
|
tmp = os.path.join(d, f".{os.path.basename(dest)}.felhom-new.{os.getpid()}")
|
|
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
|
try:
|
|
with os.fdopen(fd, "wb") as f:
|
|
f.write(data)
|
|
f.flush()
|
|
os.fchown(f.fileno(), 0, 0)
|
|
os.fchmod(f.fileno(), mode)
|
|
os.fsync(f.fileno())
|
|
os.replace(tmp, dest)
|
|
except BaseException:
|
|
try:
|
|
os.remove(tmp)
|
|
except OSError:
|
|
pass
|
|
raise
|
|
|
|
def log(self, line):
|
|
print(line, file=sys.stderr)
|
|
try:
|
|
subprocess.run(["logger", "-t", "felhom-priv-apply", "--", line], timeout=10, check=False)
|
|
except (OSError, subprocess.SubprocessError):
|
|
pass
|
|
|
|
|
|
def systemd_escape_path(path):
|
|
"""`systemd-escape --path`: strip the slashes at both ends, `/` -> `-`, every byte outside [A-Za-z0-9:_.] (and a
|
|
leading `.`) -> `\\xNN`."""
|
|
p = path.strip("/")
|
|
out = []
|
|
for i, ch in enumerate(p):
|
|
if ch == "/":
|
|
out.append("-")
|
|
elif (ch.isascii() and (ch.isalnum() or ch in ":_.")) and not (i == 0 and ch == "."):
|
|
out.append(ch)
|
|
else:
|
|
out.extend("\\x%02x" % b for b in ch.encode())
|
|
return "".join(out)
|
|
|
|
|
|
def text_of(data, what):
|
|
if len(data) > MAX_BYTES:
|
|
raise Refused("P1", f"{what} is too large")
|
|
try:
|
|
text = data.decode("utf-8")
|
|
except UnicodeDecodeError:
|
|
raise Refused("P2", f"{what} is not UTF-8 text")
|
|
if "\x00" in text or "\r" in text:
|
|
raise Refused("P2", f"{what} carries a NUL or CR byte")
|
|
return text
|
|
|
|
|
|
def parse_ini(text, what):
|
|
"""[Section] / Key=Value / comments / blank lines. A key outside a section or a repeated key is refused."""
|
|
sections, cur = {}, None
|
|
for n, raw in enumerate(text.split("\n"), 1):
|
|
line = raw.strip()
|
|
if not line or line.startswith("#") or line.startswith(";"):
|
|
continue
|
|
m = re.match(r"^\[([A-Za-z]+)\]$", line)
|
|
if m:
|
|
cur = m.group(1)
|
|
if cur in sections:
|
|
raise Refused("U2", f"{what}: section [{cur}] twice")
|
|
sections[cur] = {}
|
|
continue
|
|
if cur is None or "=" not in line:
|
|
raise Refused("U2", f"{what}: line {n} is not Key=Value inside a section")
|
|
k, v = line.split("=", 1)
|
|
k, v = k.strip(), v.strip()
|
|
if k in sections[cur]:
|
|
raise Refused("U2", f"{what}: {cur}.{k} given twice")
|
|
sections[cur][k] = v
|
|
return sections
|
|
|
|
|
|
# ---------- the unit verb ----------
|
|
def check_unit(name, text):
|
|
if not UNIT_NAME_RE.match(name) or "/" in name:
|
|
raise Refused("U1", f"unit name {name!r} is not mnt-<escaped path>.mount|.automount")
|
|
kind = "automount" if name.endswith(".automount") else "mount"
|
|
s = parse_ini(text, name)
|
|
body = "Automount" if kind == "automount" else "Mount"
|
|
allowed = {"Unit": {"Description", "After", "Before", "Wants", "Requires"},
|
|
body: {"Where", "TimeoutIdleSec"} if kind == "automount" else {"What", "Where", "Type", "Options"},
|
|
"Install": {"WantedBy"}}
|
|
for sec, keys in s.items():
|
|
if sec not in allowed:
|
|
raise Refused("U2", f"{name}: section [{sec}] is not allowed")
|
|
bad = set(keys) - allowed[sec]
|
|
if bad:
|
|
raise Refused("U2", f"{name}: [{sec}] key(s) {sorted(bad)} not allowed")
|
|
u = s.get("Unit", {})
|
|
if not DESC_RE.match(u.get("Description", "")):
|
|
raise Refused("U2", f"{name}: Description has control characters")
|
|
for k in ("After", "Before", "Wants", "Requires"):
|
|
if k in u and not all(UNIT_TOKEN_RE.match(t) for t in u[k].split()):
|
|
raise Refused("U2", f"{name}: {k}= names something that is not a unit")
|
|
inst = s.get("Install", {})
|
|
if inst and inst.get("WantedBy") != "multi-user.target":
|
|
raise Refused("U2", f"{name}: WantedBy must be multi-user.target")
|
|
m = s.get(body)
|
|
if not m or "Where" not in m:
|
|
raise Refused("U3", f"{name}: no [{body}] Where=")
|
|
where = m["Where"]
|
|
if not WHERE_RE.match(where):
|
|
raise Refused("U3", f"{name}: Where={where} is not /mnt/<name> or /mnt/felhom-drives/<name>")
|
|
if systemd_escape_path(where) + "." + kind != name:
|
|
raise Refused("U3", f"{name}: the unit name does not match Where={where}")
|
|
if kind == "automount":
|
|
t = m.get("TimeoutIdleSec", "")
|
|
if t and not re.match(r"^[0-9]{1,6}$", t):
|
|
raise Refused("U2", f"{name}: TimeoutIdleSec must be seconds")
|
|
return
|
|
what, typ = m.get("What", ""), m.get("Type", "")
|
|
opts = [o for o in m.get("Options", "").split(",") if o]
|
|
net = False
|
|
mu = re.match(r"^/dev/disk/by-uuid/(.+)$", what)
|
|
if mu:
|
|
if not UUID_RE.match(mu.group(1)):
|
|
raise Refused("U4", f"{name}: What= is not a filesystem UUID")
|
|
if typ and typ not in LOCAL_TYPES:
|
|
raise Refused("U4", f"{name}: Type={typ} is not a local filesystem")
|
|
else:
|
|
net = True
|
|
if typ not in NET_TYPES:
|
|
raise Refused("U4", f"{name}: What= is neither /dev/disk/by-uuid/<uuid> nor a network source with Type=nfs/nfs4/cifs")
|
|
if typ == "cifs":
|
|
mm = re.match(r"^//([^/]+)/(.+)$", what)
|
|
else:
|
|
mm = re.match(r"^([^/:][^:]*):(/.*)$", what)
|
|
if not mm or not HOST_RE.match(mm.group(1)) or not NET_PATH_RE.match(mm.group(2)) or ".." in mm.group(2).split("/"):
|
|
raise Refused("U4", f"{name}: What= is not a clean {typ} source")
|
|
if not where.startswith("/mnt/felhom-drives/"):
|
|
raise Refused("U3", f"{name}: a network share mounts only under /mnt/felhom-drives/")
|
|
for o in opts:
|
|
if not OPT_RE.match(o):
|
|
raise Refused("U5", f"{name}: mount option {o!r} has characters a mount option never needs")
|
|
if o.split("=", 1)[0].lower() in FORBIDDEN_OPTS or o.lower().startswith("x-mount."):
|
|
raise Refused("U5", f"{name}: mount option {o.split('=', 1)[0]!r} is not allowed")
|
|
if net and not {"nosuid", "nodev"} <= set(opts):
|
|
# A network server is outside the box: a set-uid file on it must never run as root here.
|
|
raise Refused("U5", f"{name}: a network share must carry nosuid,nodev")
|
|
|
|
|
|
# ---------- dnsmasq ----------
|
|
def _ip(v, v6=True):
|
|
try:
|
|
a = ipaddress.ip_address(v)
|
|
except ValueError:
|
|
return False
|
|
return v6 or a.version == 4
|
|
|
|
|
|
DOMAIN_RE = re.compile(r"^[A-Za-z0-9]([A-Za-z0-9-]{0,62})(\.[A-Za-z0-9]([A-Za-z0-9-]{0,62}))*$")
|
|
|
|
|
|
def check_dnsmasq(text):
|
|
for n, raw in enumerate(text.split("\n"), 1):
|
|
line = raw.strip()
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
if line in ("bind-interfaces", "no-resolv"):
|
|
continue
|
|
k, _, v = line.partition("=")
|
|
if k == "listen-address" and _ip(v, v6=False):
|
|
continue
|
|
if k == "server" and (_ip(v) or (v.count("#") == 1 and _ip(v.split("#")[0]) and v.split("#")[1].isdigit())):
|
|
continue
|
|
m = re.match(r"^/([^/]+)/$", v)
|
|
if k == "local" and m and DOMAIN_RE.match(m.group(1)):
|
|
continue
|
|
m = re.match(r"^/([^/]+)/([^/]+)$", v)
|
|
if k == "address" and m and DOMAIN_RE.match(m.group(1)) and _ip(m.group(2), v6=False):
|
|
continue
|
|
raise Refused("D1", f"dnsmasq line {n} ({k or line[:20]!r}) is not one the resolver writes")
|
|
|
|
|
|
# ---------- WireGuard ----------
|
|
def check_wg(text):
|
|
s = parse_ini(text, "wg-felhom.conf")
|
|
if set(s) != {"Interface", "Peer"}:
|
|
raise Refused("W1", "wg-felhom.conf must hold exactly [Interface] and [Peer]")
|
|
i, p = s["Interface"], s["Peer"]
|
|
if set(i) - {"PrivateKey", "Address", "MTU"} or set(p) - {"PublicKey", "Endpoint", "AllowedIPs", "PersistentKeepalive"}:
|
|
raise Refused("W1", "wg-felhom.conf carries a key the agent never writes (PostUp/PreUp/... run as root)")
|
|
if not WG_KEY_RE.match(i.get("PrivateKey", "")) or not WG_KEY_RE.match(p.get("PublicKey", "")):
|
|
raise Refused("W2", "a WireGuard key is not 32 bytes of base64")
|
|
try:
|
|
a = ipaddress.ip_network(i.get("Address", ""), strict=False)
|
|
if a.version != 4 or a.prefixlen != 32:
|
|
raise ValueError
|
|
if not (1280 <= int(i.get("MTU", "1280")) <= 1500):
|
|
raise ValueError
|
|
host, _, port = p.get("Endpoint", "").rpartition(":")
|
|
if ipaddress.ip_address(host).version != 4 or not (1 <= int(port) <= 65535):
|
|
raise ValueError
|
|
for n in p.get("AllowedIPs", "").split(","):
|
|
if ipaddress.ip_network(n.strip(), strict=True).prefixlen != 32:
|
|
raise ValueError
|
|
if not (0 <= int(p.get("PersistentKeepalive", "25")) <= 3600):
|
|
raise ValueError
|
|
except ValueError:
|
|
raise Refused("W2", "an Address/MTU/Endpoint/AllowedIPs/PersistentKeepalive value is not what the agent renders")
|
|
|
|
|
|
# ---------- OOB sshd ----------
|
|
def render_sshd(port):
|
|
"""Byte-identical to felhomsshd.renderConfig (internal/felhomsshd/config.go) — pinned by a Go test."""
|
|
return ("# felhom OOB sshd — agent-managed (H1); DO NOT EDIT\n"
|
|
f"Port {port}\n"
|
|
"ListenAddress 0.0.0.0\n"
|
|
"ListenAddress ::\n"
|
|
"HostKey /etc/felhom-sshd/ssh_host_ed25519_key\n"
|
|
"PidFile /run/felhom-sshd.pid\n"
|
|
"AuthorizedKeysFile /etc/felhom-sshd/authorized_keys/%u\n"
|
|
"PasswordAuthentication no\n"
|
|
"PermitRootLogin prohibit-password\n"
|
|
"PubkeyAuthentication yes\n"
|
|
"KbdInteractiveAuthentication no\n"
|
|
"UsePAM yes\n"
|
|
"AllowUsers root felhom-op\n"
|
|
"X11Forwarding no\n"
|
|
"Subsystem sftp internal-sftp\n")
|
|
|
|
|
|
def check_sshd(text):
|
|
m = re.search(r"^Port ([0-9]{1,5})$", text, re.M)
|
|
if not m or not (1 <= int(m.group(1)) <= 65535) or int(m.group(1)) == 22:
|
|
raise Refused("S1", "sshd_config has no Port (or claims :22, the household's sshd)")
|
|
if text != render_sshd(int(m.group(1))):
|
|
raise Refused("S1", "sshd_config differs from the one fixed template (only the Port may vary)")
|
|
|
|
|
|
def check_key(text):
|
|
lines = [l for l in text.split("\n") if l.strip()]
|
|
if len(lines) > 1:
|
|
raise Refused("S2", "felhom-op's authorized_keys holds more than one key")
|
|
if lines and not KEY_LINE_RE.match(lines[0]):
|
|
raise Refused("S2", "the key line is not a plain public key (no options such as command= or from=)")
|
|
|
|
|
|
# ---------- main ----------
|
|
def plan(argv):
|
|
"""(verb, source, dest, mode, checker) for an argv, or Refused("A1")."""
|
|
if not argv or argv[0] not in VERBS:
|
|
raise Refused("A1", "usage: felhom-priv-apply unit <name> | dnsmasq <tmp> <name> | wg | sshd-config | sshd-key")
|
|
v, rest = argv[0], argv[1:]
|
|
if v == "unit" and len(rest) == 1:
|
|
if not UNIT_NAME_RE.match(rest[0]):
|
|
raise Refused("U1", f"unit name {rest[0]!r} is not mnt-<escaped path>.mount|.automount")
|
|
return v, os.path.join(UNITS_SRC, rest[0]), os.path.join(UNIT_DIR, rest[0]), 0o644, lambda t: check_unit(rest[0], t)
|
|
if v == "dnsmasq" and len(rest) == 2:
|
|
if not DNSMASQ_TMP_RE.match(rest[0]) or not DNSMASQ_NAME_RE.match(rest[1]):
|
|
raise Refused("D2", "dnsmasq wants /tmp/felhom-resolver-<digits>.conf and felhom-<name>.conf")
|
|
return v, rest[0], os.path.join(DNSMASQ_DIR, rest[1]), 0o644, check_dnsmasq
|
|
if v == "wg" and not rest:
|
|
return v, WG_SRC, WG_DEST, 0o600, check_wg
|
|
if v == "sshd-config" and not rest:
|
|
return v, SSHD_SRC, SSHD_DEST, 0o644, check_sshd
|
|
if v == "sshd-key" and not rest:
|
|
return v, KEY_SRC, KEY_DEST, 0o644, check_key
|
|
raise Refused("A1", f"wrong arguments for {v}")
|
|
|
|
|
|
def main(argv, host=None):
|
|
host = host or Host()
|
|
if argv == ["--self-check"]:
|
|
print("felhom-priv-apply ok verbs=" + ",".join(VERBS))
|
|
return 0
|
|
if len(argv) >= 3 and argv[0] == "--check":
|
|
# CHECK ONLY (tests and the Go contract tests): `--check <verb> [<name>] <file>` validates <file> as that
|
|
# verb would and installs nothing. Not in sudoers. Prints OK or the rule; never the content.
|
|
verb, file = argv[1], argv[-1]
|
|
try:
|
|
_, _, _, _, checker = plan([verb] + argv[2:-1] if verb != "dnsmasq" else
|
|
[verb, "/tmp/felhom-resolver-1.conf", argv[2]])
|
|
with open(file, "rb") as f:
|
|
checker(text_of(f.read(), file))
|
|
except Refused as e:
|
|
print(f"REFUSED [{e.rule}] {e.reason}")
|
|
return 3
|
|
print("OK")
|
|
return 0
|
|
try:
|
|
verb, src, dest, mode, checker = plan(argv)
|
|
data = host.read_source(src)
|
|
checker(text_of(data, src))
|
|
except Refused as e:
|
|
host.log(f"felhom-priv-apply: REFUSED [{e.rule}] {' '.join(argv)[:160]}: {e.reason}")
|
|
return 2 if e.rule == "A1" else 3
|
|
if host.read_dest(dest) == data:
|
|
host.log(f"felhom-priv-apply: SAME {verb} {dest}")
|
|
return 0
|
|
try:
|
|
host.install(dest, data, mode)
|
|
except OSError as e:
|
|
host.log(f"felhom-priv-apply: FAILED {verb} {dest}: {e}")
|
|
return 4
|
|
host.log(f"felhom-priv-apply: INSTALLED {verb} {dest} ({len(data)} bytes)")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv[1:]))
|