@dsh-enhanced/assistant-isolation 0.1.65 → 0.1.67
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +287 -9
- package/lib/audit-archive.d.ts +1 -1
- package/lib/audit-archive.d.ts.map +1 -1
- package/lib/audit-archive.js +31 -13
- package/lib/audit-archive.js.map +1 -1
- package/lib/cli.d.ts +7 -3
- package/lib/cli.d.ts.map +1 -1
- package/lib/cli.js +104 -23
- package/lib/cli.js.map +1 -1
- package/lib/config.d.ts +15 -2
- package/lib/config.d.ts.map +1 -1
- package/lib/config.js +49 -9
- package/lib/config.js.map +1 -1
- package/lib/diagnostics.js +2 -2
- package/lib/diagnostics.js.map +1 -1
- package/lib/index.d.ts +17 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +17 -1
- package/lib/index.js.map +1 -1
- package/lib/ledger.d.ts +19 -2
- package/lib/ledger.d.ts.map +1 -1
- package/lib/ledger.js +371 -44
- package/lib/ledger.js.map +1 -1
- package/lib/linux-cgroups.d.ts +75 -0
- package/lib/linux-cgroups.d.ts.map +1 -0
- package/lib/linux-cgroups.js +517 -0
- package/lib/linux-cgroups.js.map +1 -0
- package/lib/linux-execution-contract.d.ts +123 -0
- package/lib/linux-execution-contract.d.ts.map +1 -0
- package/lib/linux-execution-contract.js +279 -0
- package/lib/linux-execution-contract.js.map +1 -0
- package/lib/linux-execution.d.ts +123 -0
- package/lib/linux-execution.d.ts.map +1 -0
- package/lib/linux-execution.js +1264 -0
- package/lib/linux-execution.js.map +1 -0
- package/lib/linux-job-recovery.d.ts +5 -0
- package/lib/linux-job-recovery.d.ts.map +1 -0
- package/lib/linux-job-recovery.js +63 -0
- package/lib/linux-job-recovery.js.map +1 -0
- package/lib/linux-namespace.d.ts +80 -0
- package/lib/linux-namespace.d.ts.map +1 -0
- package/lib/linux-namespace.js +471 -0
- package/lib/linux-namespace.js.map +1 -0
- package/lib/linux-repository-admission.d.ts +26 -0
- package/lib/linux-repository-admission.d.ts.map +1 -0
- package/lib/linux-repository-admission.js +64 -0
- package/lib/linux-repository-admission.js.map +1 -0
- package/lib/linux-repository-runner.d.ts +54 -0
- package/lib/linux-repository-runner.d.ts.map +1 -0
- package/lib/linux-repository-runner.js +614 -0
- package/lib/linux-repository-runner.js.map +1 -0
- package/lib/linux-runner-state-migration.d.ts +94 -0
- package/lib/linux-runner-state-migration.d.ts.map +1 -0
- package/lib/linux-runner-state-migration.js +463 -0
- package/lib/linux-runner-state-migration.js.map +1 -0
- package/lib/linux-runner-state.d.ts +53 -0
- package/lib/linux-runner-state.d.ts.map +1 -0
- package/lib/linux-runner-state.js +332 -0
- package/lib/linux-runner-state.js.map +1 -0
- package/lib/linux-runtime-source.d.ts +28 -0
- package/lib/linux-runtime-source.d.ts.map +1 -0
- package/lib/linux-runtime-source.js +237 -0
- package/lib/linux-runtime-source.js.map +1 -0
- package/lib/linux-runtime.d.ts +37 -0
- package/lib/linux-runtime.d.ts.map +1 -0
- package/lib/linux-runtime.js +192 -0
- package/lib/linux-runtime.js.map +1 -0
- package/lib/linux-system-snapshot.d.ts +47 -0
- package/lib/linux-system-snapshot.d.ts.map +1 -0
- package/lib/linux-system-snapshot.js +704 -0
- package/lib/linux-system-snapshot.js.map +1 -0
- package/lib/linux-user-namespace.d.ts +79 -0
- package/lib/linux-user-namespace.d.ts.map +1 -0
- package/lib/linux-user-namespace.js +234 -0
- package/lib/linux-user-namespace.js.map +1 -0
- package/lib/linux-verifier-runner.d.ts +27 -0
- package/lib/linux-verifier-runner.d.ts.map +1 -0
- package/lib/linux-verifier-runner.js +346 -0
- package/lib/linux-verifier-runner.js.map +1 -0
- package/lib/native-cgroup-roots.d.ts +20 -0
- package/lib/native-cgroup-roots.d.ts.map +1 -0
- package/lib/native-cgroup-roots.js +121 -0
- package/lib/native-cgroup-roots.js.map +1 -0
- package/lib/native-resources.d.ts +44 -0
- package/lib/native-resources.d.ts.map +1 -0
- package/lib/native-resources.js +354 -0
- package/lib/native-resources.js.map +1 -0
- package/lib/probe.d.ts +10 -0
- package/lib/probe.d.ts.map +1 -1
- package/lib/probe.js +52 -2
- package/lib/probe.js.map +1 -1
- package/lib/service.d.ts +1 -1
- package/lib/service.d.ts.map +1 -1
- package/lib/service.js +122 -9
- package/lib/service.js.map +1 -1
- package/lib/storage.d.ts.map +1 -1
- package/lib/storage.js +36 -6
- package/lib/storage.js.map +1 -1
- package/lib/tools.js +1 -1
- package/lib/tools.js.map +1 -1
- package/lib/types.d.ts +5 -1
- package/lib/types.d.ts.map +1 -1
- package/lib/verifier-runner.d.ts +4 -2
- package/lib/verifier-runner.d.ts.map +1 -1
- package/lib/verifier-runner.js +22 -1
- package/lib/verifier-runner.js.map +1 -1
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +15 -10
- package/runtime/linux-candidate-entry.py +82 -0
- package/runtime/linux-namespace-worker.mjs +561 -0
- package/runtime/linux-seccomp-policy.LICENSE +202 -0
- package/runtime/linux-seccomp-policy.json +886 -0
- package/runtime/linux-seccomp.py +106 -0
- package/runtime/linux-userns-launcher.py +149 -0
- package/runtime/linux-userns-proof.py +299 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Compile the fixed, reviewed syscall policy for bubblewrap; never load it here.
|
|
2
|
+
|
|
3
|
+
The policy is the existing vendored Moby-derived source builder allowlist, with
|
|
4
|
+
its three explicit namespace extensions removed for ordinary jobs. libseccomp
|
|
5
|
+
is a system dependency, not a Docker dependency. Its public C ABI is documented
|
|
6
|
+
at https://github.com/seccomp/libseccomp/blob/main/include/seccomp.h.in .
|
|
7
|
+
"""
|
|
8
|
+
import ctypes
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import platform
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
POLICY_SHA256 = "b1e4b5b709578785bd2aff4a3a344301997571ad0e8ae5747aec176571ddc342"
|
|
16
|
+
ALLOW = 0x7fff0000
|
|
17
|
+
ERRNO = 0x00050000
|
|
18
|
+
ARCHES = {"x86_64": "amd64", "aarch64": "arm64"}
|
|
19
|
+
OPS = {"SCMP_CMP_NE": 1, "SCMP_CMP_EQ": 4, "SCMP_CMP_MASKED_EQ": 7}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Arg(ctypes.Structure):
|
|
23
|
+
_fields_ = [("arg", ctypes.c_uint), ("op", ctypes.c_int),
|
|
24
|
+
("datum_a", ctypes.c_uint64), ("datum_b", ctypes.c_uint64)]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def fail(message):
|
|
28
|
+
raise RuntimeError("Linux seccomp: " + message)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def selected(rule, architecture, kernel):
|
|
32
|
+
include = rule.get("includes", {})
|
|
33
|
+
exclude = rule.get("excludes", {})
|
|
34
|
+
# No host capability is granted by this policy. In particular, namespace-
|
|
35
|
+
# local CAP_SYS_ADMIN must not activate the broad host-capability allowlist.
|
|
36
|
+
if include.get("caps"):
|
|
37
|
+
return False
|
|
38
|
+
if "arches" in include and architecture not in include["arches"]:
|
|
39
|
+
return False
|
|
40
|
+
if architecture in exclude.get("arches", []):
|
|
41
|
+
return False
|
|
42
|
+
if "minKernel" in include and kernel < tuple(map(int, include["minKernel"].split("."))):
|
|
43
|
+
return False
|
|
44
|
+
return True
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def main():
|
|
48
|
+
if len(sys.argv) != 2 or sys.argv[1] not in ("ordinary", "repository"):
|
|
49
|
+
fail("expected ordinary or repository profile")
|
|
50
|
+
architecture = ARCHES.get(platform.machine())
|
|
51
|
+
if sys.platform != "linux" or architecture is None:
|
|
52
|
+
fail("unsupported platform")
|
|
53
|
+
kernel = tuple(map(int, platform.release().split(".")[:2]))
|
|
54
|
+
if kernel < (5, 4):
|
|
55
|
+
fail("Linux 5.4 or later is required")
|
|
56
|
+
with open(os.path.join(os.path.dirname(__file__), "linux-seccomp-policy.json"), "rb") as source:
|
|
57
|
+
content = source.read(65537)
|
|
58
|
+
if hashlib.sha256(content).hexdigest() != POLICY_SHA256:
|
|
59
|
+
fail("fixed syscall policy changed")
|
|
60
|
+
policy = json.loads(content)
|
|
61
|
+
if policy["defaultAction"] != "SCMP_ACT_ERRNO" or policy.get("defaultErrnoRet", 1) != 1:
|
|
62
|
+
fail("unexpected default action")
|
|
63
|
+
rules = policy["syscalls"]
|
|
64
|
+
if [rule["names"] for rule in rules[-3:]] != [["unshare"], ["clone"], ["mount", "umount2", "pivot_root"]]:
|
|
65
|
+
fail("namespace extension inventory changed")
|
|
66
|
+
if sys.argv[1] == "ordinary":
|
|
67
|
+
rules = rules[:-3]
|
|
68
|
+
lib = ctypes.CDLL("libseccomp.so.2")
|
|
69
|
+
lib.seccomp_init.argtypes = [ctypes.c_uint32]
|
|
70
|
+
lib.seccomp_init.restype = ctypes.c_void_p
|
|
71
|
+
lib.seccomp_release.argtypes = [ctypes.c_void_p]
|
|
72
|
+
lib.seccomp_syscall_resolve_name.argtypes = [ctypes.c_char_p]
|
|
73
|
+
lib.seccomp_syscall_resolve_name.restype = ctypes.c_int
|
|
74
|
+
lib.seccomp_rule_add_array.argtypes = [ctypes.c_void_p, ctypes.c_uint32, ctypes.c_int,
|
|
75
|
+
ctypes.c_uint, ctypes.POINTER(Arg)]
|
|
76
|
+
lib.seccomp_rule_add_array.restype = ctypes.c_int
|
|
77
|
+
lib.seccomp_export_bpf.argtypes = [ctypes.c_void_p, ctypes.c_int]
|
|
78
|
+
lib.seccomp_export_bpf.restype = ctypes.c_int
|
|
79
|
+
context = lib.seccomp_init(ERRNO | 1)
|
|
80
|
+
if not context:
|
|
81
|
+
fail("could not create filter")
|
|
82
|
+
try:
|
|
83
|
+
for rule in rules:
|
|
84
|
+
if not selected(rule, architecture, kernel):
|
|
85
|
+
continue
|
|
86
|
+
action = ALLOW if rule["action"] == "SCMP_ACT_ALLOW" else ERRNO | rule.get("errnoRet", 1)
|
|
87
|
+
args = rule.get("args", [])
|
|
88
|
+
comparisons = (Arg * len(args))(*(Arg(arg["index"], OPS[arg["op"]],
|
|
89
|
+
arg["value"], arg.get("valueTwo", 0)) for arg in args))
|
|
90
|
+
for name in rule["names"]:
|
|
91
|
+
number = lib.seccomp_syscall_resolve_name(name.encode("ascii"))
|
|
92
|
+
if number == -1:
|
|
93
|
+
# Names for other architectures and future syscalls remain
|
|
94
|
+
# denied by default; they never become unrestricted.
|
|
95
|
+
continue
|
|
96
|
+
result = lib.seccomp_rule_add_array(context, action, number, len(args), comparisons)
|
|
97
|
+
if result != 0:
|
|
98
|
+
fail("cannot encode rule for %s (%d)" % (name, result))
|
|
99
|
+
if lib.seccomp_export_bpf(context, sys.stdout.fileno()) != 0:
|
|
100
|
+
fail("cannot export filter")
|
|
101
|
+
finally:
|
|
102
|
+
lib.seccomp_release(context)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
if __name__ == "__main__":
|
|
106
|
+
main()
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Fixed repository keeper launcher. No candidate command executes here."""
|
|
2
|
+
import ctypes
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import select
|
|
7
|
+
import signal
|
|
8
|
+
import stat
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main():
|
|
15
|
+
if len(sys.argv) != 5:
|
|
16
|
+
raise RuntimeError("invalid user namespace launcher arguments")
|
|
17
|
+
mapping = json.loads(sys.argv[1])
|
|
18
|
+
deadline = int(sys.argv[2])
|
|
19
|
+
node, worker = sys.argv[3:]
|
|
20
|
+
owner, group = os.getuid(), os.getgid()
|
|
21
|
+
if (owner == 0 or mapping["schemaVersion"] != 1
|
|
22
|
+
or mapping["ownerUid"] != owner or mapping["ownerGid"] != group
|
|
23
|
+
or mapping["candidateUid"] in (0, owner)
|
|
24
|
+
or mapping["candidateGid"] in (0, group)):
|
|
25
|
+
raise RuntimeError("invalid keeper mapping")
|
|
26
|
+
current = os.stat("/proc/self/ns/user")
|
|
27
|
+
if mapping["hostNamespace"] != {"dev": current.st_dev, "ino": current.st_ino}:
|
|
28
|
+
raise RuntimeError("host user namespace changed")
|
|
29
|
+
for kind, path in (("uid", "/usr/bin/newuidmap"), ("gid", "/usr/bin/newgidmap")):
|
|
30
|
+
helper = mapping["helpers"][kind]
|
|
31
|
+
info = os.lstat(path)
|
|
32
|
+
if (helper["path"] != path or not stat.S_ISREG(info.st_mode)
|
|
33
|
+
or info.st_uid != 0 or info.st_nlink != 1
|
|
34
|
+
or info.st_mode & 0o022 or not info.st_mode & stat.S_ISUID
|
|
35
|
+
or info.st_size > 1048576):
|
|
36
|
+
raise RuntimeError("unsafe mapping helper")
|
|
37
|
+
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
|
|
38
|
+
try:
|
|
39
|
+
content = os.read(fd, 1048577)
|
|
40
|
+
stable = lambda value: (value.st_dev, value.st_ino, value.st_uid, value.st_mode,
|
|
41
|
+
value.st_size, value.st_mtime_ns, value.st_ctime_ns)
|
|
42
|
+
if (len(content) != info.st_size or stable(os.fstat(fd)) != stable(info)
|
|
43
|
+
or hashlib.sha256(content).hexdigest() != helper["sha256"]):
|
|
44
|
+
raise RuntimeError("mapping helper changed")
|
|
45
|
+
finally:
|
|
46
|
+
os.close(fd)
|
|
47
|
+
|
|
48
|
+
libc = ctypes.CDLL(None, use_errno=True)
|
|
49
|
+
def checked(result, operation):
|
|
50
|
+
if result != 0:
|
|
51
|
+
raise OSError(ctypes.get_errno(), operation)
|
|
52
|
+
def remaining():
|
|
53
|
+
value = (deadline - time.time() * 1000) / 1000
|
|
54
|
+
if value <= 0:
|
|
55
|
+
raise TimeoutError("namespace deadline ended")
|
|
56
|
+
return value
|
|
57
|
+
# If the Host disappears, neither this monitor nor the mapped keeper may
|
|
58
|
+
# survive it. The mapping handshake also fails closed on either pipe EOF.
|
|
59
|
+
checked(libc.prctl(1, signal.SIGKILL, 0, 0, 0), "launcher parent-death signal")
|
|
60
|
+
parent_pid = os.getppid()
|
|
61
|
+
if parent_pid == 1:
|
|
62
|
+
raise RuntimeError("Host already exited")
|
|
63
|
+
ready_r, ready_w = os.pipe()
|
|
64
|
+
start_r, start_w = os.pipe()
|
|
65
|
+
child = os.fork()
|
|
66
|
+
if child == 0:
|
|
67
|
+
try:
|
|
68
|
+
os.close(ready_r)
|
|
69
|
+
os.close(start_w)
|
|
70
|
+
checked(libc.prctl(1, signal.SIGKILL, 0, 0, 0), "keeper parent-death signal")
|
|
71
|
+
if os.getppid() == 1:
|
|
72
|
+
raise RuntimeError("launcher exited")
|
|
73
|
+
checked(libc.unshare(0x10000000), "unshare user namespace")
|
|
74
|
+
os.write(ready_w, b"R")
|
|
75
|
+
os.close(ready_w)
|
|
76
|
+
if not select.select([start_r], [], [], remaining())[0] or os.read(start_r, 1) != b"S":
|
|
77
|
+
raise RuntimeError("mapping was not authorized")
|
|
78
|
+
os.close(start_r)
|
|
79
|
+
os.setgroups([])
|
|
80
|
+
os.setresgid(0, 0, 0)
|
|
81
|
+
os.setresuid(0, 0, 0)
|
|
82
|
+
checked(libc.unshare(0x00020000 | 0x40000000 | 0x08000000 | 0x04000000), "keeper namespaces")
|
|
83
|
+
checked(libc.mount(None, b"/", None, 0x4000 | 0x40000, None), "private mounts")
|
|
84
|
+
checked(libc.prctl(1, signal.SIGKILL, 0, 0, 0), "mapped keeper parent-death signal")
|
|
85
|
+
# fd 4/5 are the bounded archive and binary channels. Python makes
|
|
86
|
+
# its own pipes non-inheritable; only these Host channels survive.
|
|
87
|
+
for fd in (0, 1, 2, 4, 5):
|
|
88
|
+
os.set_inheritable(fd, True)
|
|
89
|
+
os.execve(node, [node, worker], {"PATH": "/usr/bin:/bin", "LANG": "C", "LC_ALL": "C"})
|
|
90
|
+
except BaseException as error:
|
|
91
|
+
os.write(2, ("linux user namespace child: " + str(error) + "\n").encode())
|
|
92
|
+
os._exit(125)
|
|
93
|
+
os.close(ready_w)
|
|
94
|
+
os.close(start_r)
|
|
95
|
+
pidfd = os.pidfd_open(child)
|
|
96
|
+
try:
|
|
97
|
+
if not select.select([ready_r], [], [], min(5, remaining()))[0] or os.read(ready_r, 1) != b"R":
|
|
98
|
+
raise RuntimeError("keeper did not reach mapping gate")
|
|
99
|
+
for kind, outside in (("uid", mapping["candidateUid"]), ("gid", mapping["candidateGid"])):
|
|
100
|
+
helper = mapping["helpers"][kind]
|
|
101
|
+
subprocess.run([helper["path"], str(child), "0", str(owner if kind == "uid" else group),
|
|
102
|
+
"1", "65534", str(outside), "1"], check=True,
|
|
103
|
+
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
|
104
|
+
stderr=subprocess.PIPE, close_fds=True,
|
|
105
|
+
env={"PATH": "/usr/bin:/bin", "LANG": "C", "LC_ALL": "C"},
|
|
106
|
+
timeout=min(5, remaining()))
|
|
107
|
+
os.write(start_w, b"S")
|
|
108
|
+
os.close(start_w)
|
|
109
|
+
start_w = None
|
|
110
|
+
# Close monitor copies: EOF on Host disconnect belongs to the keeper,
|
|
111
|
+
# and EOF on fd 5 must not wait for an unrelated monitor descriptor.
|
|
112
|
+
for fd in (0, 4, 5):
|
|
113
|
+
os.close(fd)
|
|
114
|
+
while True:
|
|
115
|
+
waited, status = os.waitpid(child, os.WNOHANG)
|
|
116
|
+
if waited:
|
|
117
|
+
return os.waitstatus_to_exitcode(status)
|
|
118
|
+
if os.getppid() != parent_pid:
|
|
119
|
+
raise RuntimeError("Host disconnected")
|
|
120
|
+
time.sleep(min(0.02, remaining()))
|
|
121
|
+
finally:
|
|
122
|
+
os.close(ready_r)
|
|
123
|
+
if start_w is not None:
|
|
124
|
+
os.close(start_w)
|
|
125
|
+
try:
|
|
126
|
+
signal.pidfd_send_signal(pidfd, signal.SIGKILL)
|
|
127
|
+
except ProcessLookupError:
|
|
128
|
+
pass
|
|
129
|
+
os.close(pidfd)
|
|
130
|
+
# SIGKILL cannot be ignored. This bounded monitor never waits for
|
|
131
|
+
# candidate stdout or an inherited control descriptor during cleanup.
|
|
132
|
+
until = time.monotonic() + 2
|
|
133
|
+
while time.monotonic() < until:
|
|
134
|
+
try:
|
|
135
|
+
if os.waitpid(child, os.WNOHANG)[0]:
|
|
136
|
+
break
|
|
137
|
+
except ChildProcessError:
|
|
138
|
+
break
|
|
139
|
+
time.sleep(0.01)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
code = main()
|
|
144
|
+
sys.exit(code if code >= 0 else 128 - code)
|
|
145
|
+
except BaseException as error:
|
|
146
|
+
if isinstance(error, SystemExit):
|
|
147
|
+
raise
|
|
148
|
+
print("linux user namespace launcher: " + str(error), file=sys.stderr)
|
|
149
|
+
sys.exit(125)
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""Fixed Host-side kernel witnesses and pidfd cleanup for repository identities.
|
|
2
|
+
|
|
3
|
+
No candidate code is evaluated. Namespace FDs remain open across observation
|
|
4
|
+
and signaling; pidfds and start ticks prevent numeric PID reuse from granting
|
|
5
|
+
authority. The caller additionally owns the frozen cgroup and durable intent.
|
|
6
|
+
"""
|
|
7
|
+
import array
|
|
8
|
+
import fcntl
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import select
|
|
12
|
+
import signal
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
NS_GET_PARENT = 0xB702
|
|
16
|
+
NS_GET_OWNER_UID = 0xB704
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def fail(message):
|
|
20
|
+
raise RuntimeError("repository user namespace: " + message)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def identity(fd):
|
|
24
|
+
item = os.fstat(fd)
|
|
25
|
+
return {"dev": item.st_dev, "ino": item.st_ino}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def read(path, maximum=65536):
|
|
29
|
+
with open(path, encoding="ascii") as source:
|
|
30
|
+
value = source.read(maximum + 1)
|
|
31
|
+
if len(value) > maximum:
|
|
32
|
+
fail("kernel observation exceeds bound")
|
|
33
|
+
return value
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def boot():
|
|
37
|
+
return read("/proc/sys/kernel/random/boot_id").strip().lower()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def owner(fd):
|
|
41
|
+
value = array.array("I", [0])
|
|
42
|
+
fcntl.ioctl(fd, NS_GET_OWNER_UID, value, True)
|
|
43
|
+
return value[0]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parent(fd):
|
|
47
|
+
return fcntl.ioctl(fd, NS_GET_PARENT)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def mapping_rows(pid, kind):
|
|
51
|
+
return [[int(item) for item in line.split()]
|
|
52
|
+
for line in read("/proc/%d/%s_map" % (pid, kind)).splitlines()]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def exact_maps(pid, mapping):
|
|
56
|
+
for kind in ("uid", "gid"):
|
|
57
|
+
suffix = "Uid" if kind == "uid" else "Gid"
|
|
58
|
+
expected = [[0, mapping["owner" + suffix], 1],
|
|
59
|
+
[65534, mapping["candidate" + suffix], 1]]
|
|
60
|
+
if mapping_rows(pid, kind) != expected:
|
|
61
|
+
fail("kernel mapping differs")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def status(pid):
|
|
65
|
+
values = {}
|
|
66
|
+
for line in read("/proc/%d/status" % pid).splitlines():
|
|
67
|
+
key, separator, value = line.partition(":")
|
|
68
|
+
if separator:
|
|
69
|
+
values[key] = value.strip()
|
|
70
|
+
return values
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def ticks(pid):
|
|
74
|
+
value = read("/proc/%d/stat" % pid)
|
|
75
|
+
fields = value[value.rfind(") ") + 2:].split()
|
|
76
|
+
if len(fields) < 20 or not fields[19].isdigit():
|
|
77
|
+
fail("process start identity unavailable")
|
|
78
|
+
return fields[19]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def alive(fd):
|
|
82
|
+
poll = select.poll()
|
|
83
|
+
poll.register(fd, select.POLLIN)
|
|
84
|
+
return not poll.poll(0)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def checked_process(pid, expected_ticks, pidfd):
|
|
88
|
+
if not alive(pidfd) or ticks(pid) != expected_ticks:
|
|
89
|
+
fail("process identity changed")
|
|
90
|
+
return status(pid)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def same_current_namespace(pid, fd):
|
|
94
|
+
current = os.open("/proc/%d/ns/user" % pid, os.O_RDONLY)
|
|
95
|
+
try:
|
|
96
|
+
if identity(current) != identity(fd):
|
|
97
|
+
fail("process user namespace changed")
|
|
98
|
+
finally:
|
|
99
|
+
os.close(current)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def ids(values, kind):
|
|
103
|
+
result = [int(item) for item in values[kind].split()]
|
|
104
|
+
if len(result) != 4:
|
|
105
|
+
fail("incomplete process identity")
|
|
106
|
+
return result
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def check_host(mapping):
|
|
110
|
+
if os.getuid() != mapping["ownerUid"] or os.getgid() != mapping["ownerGid"]:
|
|
111
|
+
fail("Host owner differs")
|
|
112
|
+
fd = os.open("/proc/self/ns/user", os.O_RDONLY)
|
|
113
|
+
try:
|
|
114
|
+
if identity(fd) != mapping["hostNamespace"]:
|
|
115
|
+
fail("Host namespace differs")
|
|
116
|
+
finally:
|
|
117
|
+
os.close(fd)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def check_anchor_fd(fd, witness):
|
|
121
|
+
if identity(fd) != witness["namespace"] or owner(fd) != witness["ownerUid"]:
|
|
122
|
+
fail("namespace anchor differs")
|
|
123
|
+
up = parent(fd)
|
|
124
|
+
try:
|
|
125
|
+
if identity(up) != witness["parent"] or witness["parent"] != witness["mapping"]["hostNamespace"]:
|
|
126
|
+
fail("namespace parent differs")
|
|
127
|
+
finally:
|
|
128
|
+
os.close(up)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def anchor(mapping, process):
|
|
132
|
+
check_host(mapping)
|
|
133
|
+
current_boot = boot()
|
|
134
|
+
pid = process["pid"]
|
|
135
|
+
pidfd = os.pidfd_open(pid, 0)
|
|
136
|
+
nsfd = None
|
|
137
|
+
try:
|
|
138
|
+
values = checked_process(pid, process["startTicks"], pidfd)
|
|
139
|
+
if ids(values, "Uid") != [mapping["ownerUid"]] * 4 or ids(values, "Gid") != [mapping["ownerGid"]] * 4:
|
|
140
|
+
fail("keeper identity differs")
|
|
141
|
+
exact_maps(pid, mapping)
|
|
142
|
+
nsfd = os.open("/proc/%d/ns/user" % pid, os.O_RDONLY)
|
|
143
|
+
parentfd = parent(nsfd)
|
|
144
|
+
try:
|
|
145
|
+
result = {"schemaVersion": 1, "bootId": current_boot,
|
|
146
|
+
"mapping": mapping, "namespace": identity(nsfd),
|
|
147
|
+
"parent": identity(parentfd), "ownerUid": owner(nsfd),
|
|
148
|
+
"leader": process}
|
|
149
|
+
finally:
|
|
150
|
+
os.close(parentfd)
|
|
151
|
+
if result["parent"] != mapping["hostNamespace"] or result["ownerUid"] != mapping["ownerUid"]:
|
|
152
|
+
fail("keeper namespace is not owned directly by Host")
|
|
153
|
+
if result["namespace"] == result["parent"]:
|
|
154
|
+
fail("keeper did not enter a new user namespace")
|
|
155
|
+
checked_process(pid, process["startTicks"], pidfd)
|
|
156
|
+
exact_maps(pid, mapping)
|
|
157
|
+
same_current_namespace(pid, nsfd)
|
|
158
|
+
checked_process(pid, process["startTicks"], pidfd)
|
|
159
|
+
if boot() != current_boot:
|
|
160
|
+
fail("boot changed")
|
|
161
|
+
return result
|
|
162
|
+
finally:
|
|
163
|
+
if nsfd is not None:
|
|
164
|
+
os.close(nsfd)
|
|
165
|
+
os.close(pidfd)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def candidate(witness, process):
|
|
169
|
+
mapping = witness["mapping"]
|
|
170
|
+
check_host(mapping)
|
|
171
|
+
if boot() != witness["bootId"]:
|
|
172
|
+
fail("boot changed")
|
|
173
|
+
pid = process["pid"]
|
|
174
|
+
pidfd = os.pidfd_open(pid, 0)
|
|
175
|
+
nsfd = None
|
|
176
|
+
try:
|
|
177
|
+
values = checked_process(pid, process["startTicks"], pidfd)
|
|
178
|
+
if ids(values, "Uid") != [mapping["candidateUid"]] * 4 or ids(values, "Gid") != [mapping["candidateGid"]] * 4:
|
|
179
|
+
fail("candidate did not drop all identities")
|
|
180
|
+
if values.get("Groups") != "" or any(int(values[key], 16) != 0 for key in ("CapInh", "CapPrm", "CapEff", "CapBnd", "CapAmb")):
|
|
181
|
+
fail("candidate retained groups or capabilities")
|
|
182
|
+
if values.get("NoNewPrivs") != "1" or values.get("Seccomp") != "2" or int(values.get("Seccomp_filters", "0")) < 1:
|
|
183
|
+
fail("candidate privilege or syscall fence differs")
|
|
184
|
+
if len(values.get("NSpid", "").split()) < 2:
|
|
185
|
+
fail("candidate PID namespace missing")
|
|
186
|
+
exact_maps(pid, mapping)
|
|
187
|
+
nsfd = os.open("/proc/%d/ns/user" % pid, os.O_RDONLY)
|
|
188
|
+
check_anchor_fd(nsfd, witness)
|
|
189
|
+
result = {"schemaVersion": 1, "pid": pid, "startTicks": process["startTicks"],
|
|
190
|
+
"bootId": witness["bootId"], "namespace": identity(nsfd),
|
|
191
|
+
"uid": mapping["candidateUid"], "gid": mapping["candidateGid"],
|
|
192
|
+
"groups": [], "capabilities": "0", "noNewPrivileges": True, "seccomp": 2}
|
|
193
|
+
after = checked_process(pid, process["startTicks"], pidfd)
|
|
194
|
+
for key in ("Uid", "Gid", "Groups", "CapInh", "CapPrm", "CapEff", "CapBnd", "CapAmb", "NoNewPrivs", "Seccomp", "Seccomp_filters", "NSpid"):
|
|
195
|
+
if after.get(key) != values.get(key):
|
|
196
|
+
fail("candidate security state changed")
|
|
197
|
+
exact_maps(pid, mapping)
|
|
198
|
+
same_current_namespace(pid, nsfd)
|
|
199
|
+
checked_process(pid, process["startTicks"], pidfd)
|
|
200
|
+
return result
|
|
201
|
+
finally:
|
|
202
|
+
if nsfd is not None:
|
|
203
|
+
os.close(nsfd)
|
|
204
|
+
os.close(pidfd)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def descendant_anchor(fd, witness, held):
|
|
208
|
+
"""Keep each FD while walking; missing/replaced ancestry never grants kill."""
|
|
209
|
+
current = fd
|
|
210
|
+
for _ in range(33):
|
|
211
|
+
if identity(current) == witness["namespace"]:
|
|
212
|
+
check_anchor_fd(current, witness)
|
|
213
|
+
return
|
|
214
|
+
current = parent(current)
|
|
215
|
+
held.append(current)
|
|
216
|
+
fail("namespace ancestry exceeds bound")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def kill_members(request):
|
|
220
|
+
witness = request["witness"]
|
|
221
|
+
mapping = witness["mapping"]
|
|
222
|
+
check_host(mapping)
|
|
223
|
+
if boot() != witness["bootId"]:
|
|
224
|
+
fail("boot changed")
|
|
225
|
+
freezer = request["freezerPath"]
|
|
226
|
+
expected_suffix = request["cgroup"][1]
|
|
227
|
+
if request["cgroup"][0] != "freezer" or not freezer.startswith("/sys/fs/cgroup/") or not freezer.endswith(expected_suffix):
|
|
228
|
+
fail("freezer identity differs")
|
|
229
|
+
if read(freezer + "/freezer.state").strip() != "FROZEN":
|
|
230
|
+
fail("job group is not frozen")
|
|
231
|
+
held = []
|
|
232
|
+
namespaces = []
|
|
233
|
+
try:
|
|
234
|
+
for item in request["processes"]:
|
|
235
|
+
pid, expected_ticks = item
|
|
236
|
+
try:
|
|
237
|
+
pidfd = os.pidfd_open(pid, 0)
|
|
238
|
+
except ProcessLookupError:
|
|
239
|
+
continue
|
|
240
|
+
held.append(pidfd)
|
|
241
|
+
if not alive(pidfd):
|
|
242
|
+
continue
|
|
243
|
+
values = checked_process(pid, expected_ticks, pidfd)
|
|
244
|
+
memberships = [line.split(":", 2)[1:] for line in read("/proc/%d/cgroup" % pid).splitlines()]
|
|
245
|
+
if request["cgroup"] not in memberships:
|
|
246
|
+
fail("process escaped frozen group")
|
|
247
|
+
nsfd = os.open("/proc/%d/ns/user" % pid, os.O_RDONLY)
|
|
248
|
+
namespaces.append(nsfd)
|
|
249
|
+
outside = any(entry["pid"] == pid and entry["startTicks"] == expected_ticks for entry in request["outsideGates"])
|
|
250
|
+
if outside and identity(nsfd) == mapping["hostNamespace"]:
|
|
251
|
+
if ids(values, "Uid") != [mapping["ownerUid"]] * 4 or ids(values, "Gid") != [mapping["ownerGid"]] * 4:
|
|
252
|
+
fail("original keeper gate changed identity")
|
|
253
|
+
else:
|
|
254
|
+
if any(value not in (mapping["ownerUid"], mapping["candidateUid"]) for value in ids(values, "Uid")):
|
|
255
|
+
fail("foreign UID in job group")
|
|
256
|
+
if any(value not in (mapping["ownerGid"], mapping["candidateGid"]) for value in ids(values, "Gid")):
|
|
257
|
+
fail("foreign GID in job group")
|
|
258
|
+
descendant_anchor(nsfd, witness, namespaces)
|
|
259
|
+
if identity(nsfd) == witness["namespace"]:
|
|
260
|
+
exact_maps(pid, mapping)
|
|
261
|
+
same_current_namespace(pid, nsfd)
|
|
262
|
+
checked_process(pid, expected_ticks, pidfd)
|
|
263
|
+
# All members are independently checked before any signal is sent.
|
|
264
|
+
if read(freezer + "/freezer.state").strip() != "FROZEN":
|
|
265
|
+
fail("freezer barrier changed")
|
|
266
|
+
for fd in held:
|
|
267
|
+
try:
|
|
268
|
+
signal.pidfd_send_signal(fd, signal.SIGKILL, None, 0)
|
|
269
|
+
except ProcessLookupError:
|
|
270
|
+
pass
|
|
271
|
+
return {"signaled": len(held)}
|
|
272
|
+
finally:
|
|
273
|
+
for fd in namespaces:
|
|
274
|
+
os.close(fd)
|
|
275
|
+
for fd in held:
|
|
276
|
+
os.close(fd)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def main():
|
|
280
|
+
raw = sys.stdin.buffer.read(1048577)
|
|
281
|
+
if len(raw) > 1048576:
|
|
282
|
+
fail("request exceeds bound")
|
|
283
|
+
request = json.loads(raw)
|
|
284
|
+
operation = request.get("operation")
|
|
285
|
+
if operation == "anchor":
|
|
286
|
+
result = anchor(request["mapping"], request["identity"])
|
|
287
|
+
elif operation == "candidate":
|
|
288
|
+
result = candidate(request["witness"], request["identity"])
|
|
289
|
+
elif operation == "kill":
|
|
290
|
+
if not isinstance(request["processes"], list) or len(request["processes"]) > 16384 or len(request["outsideGates"]) > 1:
|
|
291
|
+
fail("process inventory exceeds bound")
|
|
292
|
+
result = kill_members(request)
|
|
293
|
+
else:
|
|
294
|
+
fail("unsupported operation")
|
|
295
|
+
print(json.dumps(result, separators=(",", ":")))
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
if __name__ == "__main__":
|
|
299
|
+
main()
|