seccompy 0.1.0__py3-none-any.whl

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.
seccompy/__init__.py ADDED
@@ -0,0 +1,87 @@
1
+ # SPDX-License-Identifier: 0BSD
2
+ """Python bindings for Linux seccomp-BPF syscall filtering.
3
+
4
+ seccompy lets unprivileged processes sandbox themselves by installing
5
+ classic BPF filters that the kernel evaluates on every syscall entry.
6
+ Filters are built in pure Python, checked against the native
7
+ architecture and loaded through seccomp(2) with no compiler or libbpf.
8
+
9
+ Kernel reference: https://docs.kernel.org/userspace-api/seccomp_filter.html
10
+ """
11
+
12
+ import errno
13
+
14
+ from . import _syscall, testing
15
+ from .errors import SeccompError, UnsupportedError
16
+ from .filter import Action, Filter, FilterFlag
17
+ from .syscalls import (
18
+ AUDIT_ARCH_AARCH64,
19
+ AUDIT_ARCH_X86_64,
20
+ arch,
21
+ audit_arch,
22
+ syscall_name,
23
+ syscall_nr,
24
+ )
25
+
26
+ __version__ = "0.1.0"
27
+
28
+ __all__ = [
29
+ "AUDIT_ARCH_AARCH64",
30
+ "AUDIT_ARCH_X86_64",
31
+ "Action",
32
+ "Filter",
33
+ "FilterFlag",
34
+ "SeccompError",
35
+ "UnsupportedError",
36
+ "__version__",
37
+ "action_supported",
38
+ "arch",
39
+ "audit_arch",
40
+ "flag_supported",
41
+ "supported",
42
+ "syscall_name",
43
+ "syscall_nr",
44
+ "testing",
45
+ ]
46
+
47
+
48
+ def supported() -> bool:
49
+ """Return whether the running kernel can install seccomp filters.
50
+
51
+ Probes SECCOMP_GET_ACTION_AVAIL first; kernels older than 4.8 lack
52
+ that operation but still support filters, so an EINVAL answer also
53
+ counts as supported when the seccomp syscall itself exists.
54
+ """
55
+ try:
56
+ _syscall.action_avail(int(Action.ALLOW))
57
+ except UnsupportedError:
58
+ try:
59
+ return _syscall.probe_flag(0)
60
+ except UnsupportedError:
61
+ return False
62
+ except SeccompError as exc:
63
+ if exc.errno == errno.EINVAL:
64
+ return True
65
+ raise
66
+ return True
67
+
68
+
69
+ def action_supported(action: Action | int) -> bool:
70
+ """Return whether the kernel accepts a SECCOMP_RET_* action."""
71
+ try:
72
+ _syscall.action_avail(int(action))
73
+ except UnsupportedError:
74
+ return False
75
+ except SeccompError as exc:
76
+ if exc.errno == errno.EINVAL:
77
+ return False
78
+ raise
79
+ return True
80
+
81
+
82
+ def flag_supported(flag: FilterFlag) -> bool:
83
+ """Return whether the kernel accepts a SECCOMP_FILTER_FLAG_* flag."""
84
+ try:
85
+ return _syscall.probe_flag(int(flag))
86
+ except UnsupportedError:
87
+ return False
seccompy/_syscall.py ADDED
@@ -0,0 +1,124 @@
1
+ # SPDX-License-Identifier: 0BSD
2
+ """Raw ctypes bindings for the seccomp syscall and prctl.
3
+
4
+ All calls go through libc's syscall(2) wrapper, so no libffi or compiler
5
+ is needed. The seccomp syscall number differs per architecture; the
6
+ number for the running architecture is chosen at call time.
7
+
8
+ Kernel reference: https://docs.kernel.org/userspace-api/seccomp_filter.html
9
+ """
10
+
11
+ import ctypes
12
+ import ctypes.util
13
+ import errno
14
+ import os
15
+ import platform
16
+ import sys
17
+
18
+ from .errors import SeccompError, UnsupportedError
19
+
20
+ PR_SET_NO_NEW_PRIVS = 38
21
+
22
+ SECCOMP_SET_MODE_FILTER = 1
23
+ SECCOMP_GET_ACTION_AVAIL = 2
24
+ SECCOMP_GET_NOTIF_SIZES = 3
25
+
26
+ _SYS_SECCOMP = {
27
+ "x86_64": 317,
28
+ "amd64": 317,
29
+ "aarch64": 277,
30
+ "arm64": 277,
31
+ }
32
+
33
+
34
+ class SockFprog(ctypes.Structure):
35
+ """struct sock_fprog: a u16 instruction count and a filter pointer."""
36
+
37
+ _fields_ = [
38
+ ("len", ctypes.c_ushort),
39
+ ("filter", ctypes.c_void_p),
40
+ ]
41
+
42
+
43
+ _libc: ctypes.CDLL | None = None
44
+
45
+
46
+ def _get_libc() -> ctypes.CDLL:
47
+ global _libc
48
+ if _libc is None:
49
+ if sys.platform != "linux":
50
+ raise UnsupportedError("seccomp is only available on Linux")
51
+ name = ctypes.util.find_library("c")
52
+ _libc = ctypes.CDLL(name or None, use_errno=True)
53
+ _libc.syscall.restype = ctypes.c_long
54
+ _libc.prctl.restype = ctypes.c_int
55
+ return _libc
56
+
57
+
58
+ def _seccomp_nr() -> int:
59
+ machine = platform.machine().lower()
60
+ try:
61
+ return _SYS_SECCOMP[machine]
62
+ except KeyError:
63
+ raise UnsupportedError(f"no seccomp syscall number for {machine}") from None
64
+
65
+
66
+ def _call(*args: object) -> int:
67
+ ret = int(_get_libc().syscall(*args))
68
+ if ret != -1:
69
+ return ret
70
+ err = ctypes.get_errno()
71
+ if err in (errno.ENOSYS, errno.EOPNOTSUPP):
72
+ raise UnsupportedError(err, os.strerror(err))
73
+ raise SeccompError(err, os.strerror(err))
74
+
75
+
76
+ def _seccomp(op: int, flags: int, uargs: object) -> int:
77
+ return _call(_seccomp_nr(), op, flags, uargs)
78
+
79
+
80
+ def action_avail(action: int) -> int:
81
+ """Check whether the kernel supports a seccomp return action."""
82
+ val = ctypes.c_uint32(action)
83
+ return _seccomp(SECCOMP_GET_ACTION_AVAIL, 0, ctypes.byref(val))
84
+
85
+
86
+ def probe_flag(flag: int) -> bool:
87
+ """Return whether a SECCOMP_FILTER_FLAG_* is supported.
88
+
89
+ A NULL filter program is passed, so the kernel validates the flag and
90
+ then fails on the bad pointer without installing anything. EINVAL
91
+ means the flag is unknown; EFAULT or EACCES mean it passed flag
92
+ validation.
93
+ """
94
+ try:
95
+ _seccomp(SECCOMP_SET_MODE_FILTER, flag, None)
96
+ except UnsupportedError:
97
+ raise
98
+ except SeccompError as exc:
99
+ if exc.errno == errno.EINVAL:
100
+ return False
101
+ if exc.errno in (errno.EFAULT, errno.EACCES):
102
+ return True
103
+ raise
104
+ return True
105
+
106
+
107
+ def set_no_new_privs() -> None:
108
+ """Set the no_new_privs attribute on the calling thread via prctl."""
109
+ ret = _get_libc().prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)
110
+ if ret == -1:
111
+ err = ctypes.get_errno()
112
+ raise SeccompError(err, os.strerror(err))
113
+
114
+
115
+ def set_mode_filter(program: bytes, flags: int = 0) -> int:
116
+ """Install a classic BPF filter via SECCOMP_SET_MODE_FILTER."""
117
+ if len(program) % 8 != 0 or not program:
118
+ raise ValueError("program must be a non-empty multiple of 8 bytes")
119
+ insns = len(program) // 8
120
+ if insns > 4096:
121
+ raise ValueError("program exceeds the kernel limit of 4096 instructions")
122
+ buf = (ctypes.c_char * len(program)).from_buffer_copy(program)
123
+ fprog = SockFprog(len=insns, filter=ctypes.addressof(buf))
124
+ return _seccomp(SECCOMP_SET_MODE_FILTER, flags, ctypes.byref(fprog))
seccompy/bpf.py ADDED
@@ -0,0 +1,248 @@
1
+ # SPDX-License-Identifier: 0BSD
2
+ """Classic BPF instruction model and assembler for seccomp filters.
3
+
4
+ A seccomp filter is a classic BPF program run over struct seccomp_data.
5
+ This module lets callers describe programs as instruction objects with
6
+ symbolic jump labels; assemble() resolves the labels into the 8-bit
7
+ relative offsets struct sock_filter requires. Classic BPF only jumps
8
+ forward, and conditional jumps are limited to 255 instructions, so a
9
+ conditional jump whose target lies farther away is expanded into a short
10
+ conditional hop over an unconditional 32-bit JA.
11
+
12
+ The result is packed as little-endian struct sock_filter records of
13
+ 8 bytes each: u16 code, u8 jt, u8 jf, u32 k.
14
+
15
+ Kernel reference: https://docs.kernel.org/userspace-api/seccomp_filter.html
16
+ """
17
+
18
+ import struct
19
+ from dataclasses import dataclass
20
+ from typing import Final
21
+
22
+ __all__ = [
23
+ "BPF_JA",
24
+ "BPF_JEQ",
25
+ "BPF_JGEQ",
26
+ "BPF_JGT",
27
+ "BPF_JSET",
28
+ "BPF_LD_ABS_W",
29
+ "BPF_RET_K",
30
+ "Insn",
31
+ "Ja",
32
+ "Jump",
33
+ "Load",
34
+ "Ret",
35
+ "assemble",
36
+ ]
37
+
38
+ BPF_LD_ABS_W: Final = 0x20
39
+ """BPF_LD | BPF_W | BPF_ABS: load the 32-bit word at byte offset k."""
40
+
41
+ BPF_RET_K: Final = 0x06
42
+ """BPF_RET | BPF_K: terminate the program with result k."""
43
+
44
+ BPF_JA: Final = 0x05
45
+ """BPF_JMP | BPF_JA: unconditional jump forward by k instructions."""
46
+
47
+ BPF_JEQ: Final = 0x15
48
+ """BPF_JMP | BPF_JEQ | BPF_K: jump if A equals k."""
49
+
50
+ BPF_JGT: Final = 0x25
51
+ """BPF_JMP | BPF_JGT | BPF_K: jump if A is greater than k."""
52
+
53
+ BPF_JGEQ: Final = 0x35
54
+ """BPF_JMP | BPF_JGEQ | BPF_K: jump if A is greater than or equal to k."""
55
+
56
+ BPF_JSET: Final = 0x45
57
+ """BPF_JMP | BPF_JSET | BPF_K: jump if A has any bit of k set."""
58
+
59
+ _JUMP_OPS: Final = frozenset({BPF_JEQ, BPF_JGT, BPF_JGEQ, BPF_JSET})
60
+
61
+ _MAX_COND_JUMP: Final = 255
62
+
63
+
64
+ class Insn:
65
+ """Base class for BPF instructions."""
66
+
67
+ __slots__ = ()
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class Load(Insn):
72
+ """Load the 32-bit word at byte offset k of the packet data."""
73
+
74
+ k: int
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class Jump(Insn):
79
+ """Compare A against k and jump to the jt or jf label.
80
+
81
+ A label of None means fall through to the next instruction.
82
+ """
83
+
84
+ op: int
85
+ k: int
86
+ jt: str | None = None
87
+ jf: str | None = None
88
+
89
+ def __post_init__(self) -> None:
90
+ if self.op not in _JUMP_OPS:
91
+ raise ValueError(f"not a conditional jump opcode: {self.op:#x}")
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class Ja(Insn):
96
+ """Jump unconditionally to a label."""
97
+
98
+ target: str
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class Ret(Insn):
103
+ """Terminate the program with result k."""
104
+
105
+ k: int
106
+
107
+
108
+ def _positions(sizes: list[int]) -> list[int]:
109
+ """Instruction start positions plus the position past the end."""
110
+ pos = [0] * (len(sizes) + 1)
111
+ for i in range(1, len(sizes) + 1):
112
+ pos[i] = pos[i - 1] + sizes[i - 1]
113
+ return pos
114
+
115
+
116
+ def _expanded_size(jump: Jump) -> int:
117
+ if jump.jt is None:
118
+ return 2
119
+ return 3 if jump.jf is not None else 2
120
+
121
+
122
+ def assemble(insns: list[Insn], labels: dict[str, int]) -> bytes:
123
+ """Resolve labels and pack insns into struct sock_filter bytes.
124
+
125
+ labels maps each symbolic name to an instruction index, which may be
126
+ len(insns) to name the position just past the last instruction.
127
+ Raises ValueError for unknown labels, backward jumps or jumps that
128
+ still cannot be encoded.
129
+ """
130
+ _validate_labels(insns, labels)
131
+ sizes = _resolve_sizes(insns, labels)
132
+ pos = _positions(sizes)
133
+ out = bytearray()
134
+ for i, insn in enumerate(insns):
135
+ out += _emit(insn, labels, pos, i, sizes[i])
136
+ return bytes(out)
137
+
138
+
139
+ def _validate_labels(insns: list[Insn], labels: dict[str, int]) -> None:
140
+ for insn in insns:
141
+ for target in _targets(insn):
142
+ if target not in labels:
143
+ raise ValueError(f"undefined label: {target}")
144
+ if labels[target] > len(insns):
145
+ raise ValueError(f"label out of range: {target}")
146
+
147
+
148
+ def _resolve_sizes(insns: list[Insn], labels: dict[str, int]) -> list[int]:
149
+ """Size each instruction, expanding conditional jumps that resolve
150
+ to offsets beyond 255 into a hop plus JA trampolines. Expansion
151
+ only grows programs, so iterating to a fixed point terminates."""
152
+ sizes = [1] * len(insns)
153
+ for _ in range(len(insns) + 1):
154
+ pos = _positions(sizes)
155
+ changed = False
156
+ for i, insn in enumerate(insns):
157
+ if not isinstance(insn, Jump) or sizes[i] > 1:
158
+ continue
159
+ if _needs_expansion(insn, labels, pos, i):
160
+ sizes[i] = _expanded_size(insn)
161
+ changed = True
162
+ if not changed:
163
+ return sizes
164
+ raise ValueError("jump resolution did not converge")
165
+
166
+
167
+ def _targets(insn: Insn) -> list[str]:
168
+ if isinstance(insn, Jump):
169
+ return [t for t in (insn.jt, insn.jf) if t is not None]
170
+ if isinstance(insn, Ja):
171
+ return [insn.target]
172
+ return []
173
+
174
+
175
+ def _offset(
176
+ target: str | None, labels: dict[str, int], pos: list[int], i: int
177
+ ) -> int | None:
178
+ if target is None:
179
+ return None
180
+ off = pos[labels[target]] - pos[i] - 1
181
+ if off < 0:
182
+ raise ValueError(f"backward jump to label: {target}")
183
+ return off
184
+
185
+
186
+ def _needs_expansion(
187
+ jump: Jump, labels: dict[str, int], pos: list[int], i: int
188
+ ) -> bool:
189
+ for target in (jump.jt, jump.jf):
190
+ off = _offset(target, labels, pos, i)
191
+ if off is not None and off > _MAX_COND_JUMP:
192
+ return True
193
+ return False
194
+
195
+
196
+ def _emit(
197
+ insn: Insn, labels: dict[str, int], pos: list[int], i: int, size: int
198
+ ) -> bytes:
199
+ if isinstance(insn, Load):
200
+ return struct.pack("<HBBI", BPF_LD_ABS_W, 0, 0, insn.k)
201
+ if isinstance(insn, Ja):
202
+ off = _offset(insn.target, labels, pos, i)
203
+ if off is None or off > 0xFFFFFFFF:
204
+ raise ValueError(f"JA target out of range: {insn.target}")
205
+ return struct.pack("<HBBI", BPF_JA, 0, 0, off)
206
+ if isinstance(insn, Ret):
207
+ return struct.pack("<HBBI", BPF_RET_K, 0, 0, insn.k)
208
+ if isinstance(insn, Jump):
209
+ jt = _offset(insn.jt, labels, pos, i)
210
+ jf = _offset(insn.jf, labels, pos, i)
211
+ if size == 1:
212
+ if (jt is not None and jt > _MAX_COND_JUMP) or (
213
+ jf is not None and jf > _MAX_COND_JUMP
214
+ ):
215
+ raise ValueError("conditional jump out of range")
216
+ return struct.pack("<HBBI", insn.op, jt or 0, jf or 0, insn.k)
217
+ return _emit_expanded(insn, labels, pos, i)
218
+ raise TypeError(f"unknown instruction: {insn!r}")
219
+
220
+
221
+ def _emit_expanded(jump: Jump, labels: dict[str, int], pos: list[int], i: int) -> bytes:
222
+ """Encode a far conditional jump as a hop plus JA trampolines.
223
+
224
+ jt None: jXX jt=1 jf=0; JA jf (true falls past the JA)
225
+ jf None: jXX jt=0 jf=1; JA jt (false falls past the JA)
226
+ both: jXX jt=0 jf=1; JA jt; JA jf
227
+ """
228
+ out = bytearray()
229
+ if jump.jt is None:
230
+ out += struct.pack("<HBBI", jump.op, 1, 0, jump.k)
231
+ out += _ja_bytes(jump.jf, labels, pos, pos[i] + 1)
232
+ else:
233
+ out += struct.pack("<HBBI", jump.op, 0, 1, jump.k)
234
+ out += _ja_bytes(jump.jt, labels, pos, pos[i] + 1)
235
+ if jump.jf is not None:
236
+ out += _ja_bytes(jump.jf, labels, pos, pos[i] + 2)
237
+ return bytes(out)
238
+
239
+
240
+ def _ja_bytes(
241
+ target: str | None, labels: dict[str, int], pos: list[int], pc: int
242
+ ) -> bytes:
243
+ if target is None:
244
+ raise ValueError("JA without a target")
245
+ off = pos[labels[target]] - pc - 1
246
+ if off < 0 or off > 0xFFFFFFFF:
247
+ raise ValueError(f"JA target out of range: {target}")
248
+ return struct.pack("<HBBI", BPF_JA, 0, 0, off)
seccompy/errors.py ADDED
@@ -0,0 +1,10 @@
1
+ # SPDX-License-Identifier: 0BSD
2
+ """Exception types raised by seccompy."""
3
+
4
+
5
+ class SeccompError(OSError):
6
+ """A seccomp-related syscall failed."""
7
+
8
+
9
+ class UnsupportedError(SeccompError):
10
+ """The running kernel does not support seccomp or the requested feature."""