landlockpy 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.
landlockpy/__init__.py ADDED
@@ -0,0 +1,81 @@
1
+ # SPDX-License-Identifier: 0BSD
2
+ """Python bindings for the Landlock Linux security module.
3
+
4
+ Landlock lets unprivileged processes sandbox themselves with filesystem,
5
+ network and IPC restrictions enforced by the kernel. Rulesets are scoped to
6
+ what the running kernel supports, so applications get best-effort protection
7
+ across kernel versions.
8
+
9
+ Kernel reference: https://docs.kernel.org/userspace-api/landlock.html
10
+ Project site: https://landlock.io/
11
+ """
12
+
13
+ import errno
14
+
15
+ from . import _syscall
16
+ from .errors import LandlockError, UnsupportedError
17
+ from .flags import (
18
+ AccessFS,
19
+ AccessNet,
20
+ RestrictFlag,
21
+ Scope,
22
+ fs_for_abi,
23
+ net_for_abi,
24
+ restrict_for_abi,
25
+ scope_for_abi,
26
+ )
27
+ from .ruleset import Ruleset
28
+
29
+ __version__ = "0.1.0"
30
+
31
+ __all__ = [
32
+ "AccessFS",
33
+ "AccessNet",
34
+ "LandlockError",
35
+ "RestrictFlag",
36
+ "Ruleset",
37
+ "Scope",
38
+ "UnsupportedError",
39
+ "__version__",
40
+ "abi_version",
41
+ "errata",
42
+ "fs_for_abi",
43
+ "net_for_abi",
44
+ "restrict_for_abi",
45
+ "scope_for_abi",
46
+ "supported",
47
+ ]
48
+
49
+
50
+ def abi_version() -> int:
51
+ """Return the Landlock ABI version of the running kernel, or 0.
52
+
53
+ A return value of 0 means the kernel is too old (ENOSYS) or Landlock is
54
+ disabled at boot time (EOPNOTSUPP).
55
+ """
56
+ try:
57
+ return _syscall.abi_version()
58
+ except LandlockError as exc:
59
+ if exc.errno in (errno.ENOSYS, errno.EOPNOTSUPP):
60
+ return 0
61
+ raise
62
+
63
+
64
+ def errata() -> int:
65
+ """Return the errata bitmask for the current ABI version, or 0.
66
+
67
+ Bit N set means erratum N is fixed in the running kernel. Older kernels
68
+ without the errata mechanism report 0. Most applications should not
69
+ check errata; best-effort enforcement is the safer default.
70
+ """
71
+ try:
72
+ return _syscall.errata()
73
+ except LandlockError as exc:
74
+ if exc.errno in (errno.ENOSYS, errno.EOPNOTSUPP, errno.EINVAL):
75
+ return 0
76
+ raise
77
+
78
+
79
+ def supported() -> bool:
80
+ """Return whether the running kernel supports Landlock."""
81
+ return abi_version() >= 1
landlockpy/_syscall.py ADDED
@@ -0,0 +1,124 @@
1
+ # SPDX-License-Identifier: 0BSD
2
+ """Raw ctypes bindings for the Landlock syscalls and prctl.
3
+
4
+ The Landlock syscall numbers 444-446 are shared by every architecture that
5
+ implements them. All calls go through libc's syscall(2) wrapper, so no libffi
6
+ or compiler is needed.
7
+ """
8
+
9
+ import ctypes
10
+ import ctypes.util
11
+ import errno
12
+ import os
13
+ import sys
14
+
15
+ from .errors import LandlockError, UnsupportedError
16
+
17
+ SYS_CREATE_RULESET = 444
18
+ SYS_ADD_RULE = 445
19
+ SYS_RESTRICT_SELF = 446
20
+
21
+ PR_SET_NO_NEW_PRIVS = 38
22
+
23
+ CREATE_RULESET_VERSION = 1 << 0
24
+ CREATE_RULESET_ERRATA = 1 << 1
25
+
26
+ ADD_RULE_QUIET = 1 << 0
27
+
28
+ RULE_PATH_BENEATH = 1
29
+ RULE_NET_PORT = 2
30
+
31
+
32
+ class RulesetAttr(ctypes.Structure):
33
+ """struct landlock_ruleset_attr. The quiet fields need ABI 10."""
34
+
35
+ _fields_ = [
36
+ ("handled_access_fs", ctypes.c_uint64),
37
+ ("handled_access_net", ctypes.c_uint64),
38
+ ("scoped", ctypes.c_uint64),
39
+ ("quiet_access_fs", ctypes.c_uint64),
40
+ ("quiet_access_net", ctypes.c_uint64),
41
+ ("quiet_scoped", ctypes.c_uint64),
42
+ ]
43
+
44
+
45
+ class PathBeneathAttr(ctypes.Structure):
46
+ """struct landlock_path_beneath_attr. Packed, no trailing padding."""
47
+
48
+ _pack_ = 1
49
+ _fields_ = [
50
+ ("allowed_access", ctypes.c_uint64),
51
+ ("parent_fd", ctypes.c_int32),
52
+ ]
53
+
54
+
55
+ class NetPortAttr(ctypes.Structure):
56
+ """struct landlock_net_port_attr."""
57
+
58
+ _fields_ = [
59
+ ("allowed_access", ctypes.c_uint64),
60
+ ("port", ctypes.c_uint64),
61
+ ]
62
+
63
+
64
+ _libc: ctypes.CDLL | None = None
65
+
66
+
67
+ def _get_libc() -> ctypes.CDLL:
68
+ global _libc
69
+ if _libc is None:
70
+ if sys.platform != "linux":
71
+ raise UnsupportedError("Landlock is only available on Linux")
72
+ name = ctypes.util.find_library("c")
73
+ _libc = ctypes.CDLL(name or None, use_errno=True)
74
+ _libc.syscall.restype = ctypes.c_long
75
+ _libc.prctl.restype = ctypes.c_int
76
+ return _libc
77
+
78
+
79
+ def _call(nr: int, *args: object) -> int:
80
+ ret = int(_get_libc().syscall(nr, *args))
81
+ if ret != -1:
82
+ return ret
83
+ err = ctypes.get_errno()
84
+ if err in (errno.ENOSYS, errno.EOPNOTSUPP):
85
+ raise UnsupportedError(err, os.strerror(err))
86
+ raise LandlockError(err, os.strerror(err))
87
+
88
+
89
+ def abi_version() -> int:
90
+ """Return the highest Landlock ABI version supported by the kernel."""
91
+ return _call(SYS_CREATE_RULESET, None, 0, CREATE_RULESET_VERSION)
92
+
93
+
94
+ def errata() -> int:
95
+ """Return the errata bitmask for the current ABI version."""
96
+ return _call(SYS_CREATE_RULESET, None, 0, CREATE_RULESET_ERRATA)
97
+
98
+
99
+ def create_ruleset(attr: RulesetAttr) -> int:
100
+ """Create a ruleset and return its file descriptor."""
101
+ return _call(SYS_CREATE_RULESET, ctypes.byref(attr), ctypes.sizeof(attr), 0)
102
+
103
+
104
+ def add_path_beneath(ruleset_fd: int, attr: PathBeneathAttr, flags: int = 0) -> None:
105
+ """Add a LANDLOCK_RULE_PATH_BENEATH rule to a ruleset."""
106
+ _call(SYS_ADD_RULE, ruleset_fd, RULE_PATH_BENEATH, ctypes.byref(attr), flags)
107
+
108
+
109
+ def add_net_port(ruleset_fd: int, attr: NetPortAttr, flags: int = 0) -> None:
110
+ """Add a LANDLOCK_RULE_NET_PORT rule to a ruleset."""
111
+ _call(SYS_ADD_RULE, ruleset_fd, RULE_NET_PORT, ctypes.byref(attr), flags)
112
+
113
+
114
+ def set_no_new_privs() -> None:
115
+ """Set the no_new_privs attribute on the calling thread via prctl."""
116
+ ret = _get_libc().prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)
117
+ if ret == -1:
118
+ err = ctypes.get_errno()
119
+ raise LandlockError(err, os.strerror(err))
120
+
121
+
122
+ def restrict_self(ruleset_fd: int, flags: int = 0) -> None:
123
+ """Enforce a ruleset on the calling thread and its future children."""
124
+ _call(SYS_RESTRICT_SELF, ruleset_fd, flags)
landlockpy/errors.py ADDED
@@ -0,0 +1,10 @@
1
+ # SPDX-License-Identifier: 0BSD
2
+ """Exception types raised by landlockpy."""
3
+
4
+
5
+ class LandlockError(OSError):
6
+ """A Landlock syscall failed."""
7
+
8
+
9
+ class UnsupportedError(LandlockError):
10
+ """The running kernel does not support Landlock or the requested feature."""
landlockpy/flags.py ADDED
@@ -0,0 +1,168 @@
1
+ # SPDX-License-Identifier: 0BSD
2
+ """Access rights, scope flags and restrict flags for Landlock rulesets.
3
+
4
+ Flag values mirror the constants in linux/landlock.h. Each flag records the
5
+ oldest Landlock ABI version that supports it; use the for_abi helpers to
6
+ compute the subset usable on a given kernel.
7
+
8
+ Kernel reference: https://docs.kernel.org/userspace-api/landlock.html
9
+ """
10
+
11
+ from enum import IntFlag
12
+ from typing import TypeVar
13
+
14
+ __all__ = [
15
+ "LATEST_ABI",
16
+ "AccessFS",
17
+ "AccessNet",
18
+ "RestrictFlag",
19
+ "Scope",
20
+ "fs_for_abi",
21
+ "net_for_abi",
22
+ "restrict_for_abi",
23
+ "scope_for_abi",
24
+ ]
25
+
26
+ LATEST_ABI = 11
27
+ """Newest Landlock ABI version known to this library."""
28
+
29
+
30
+ class AccessFS(IntFlag):
31
+ """Filesystem access rights, mirroring LANDLOCK_ACCESS_FS_*.
32
+
33
+ Used in the handled_access_fs field of a ruleset and in the
34
+ allowed_access field of path rules. See "Filesystem flags" in the
35
+ kernel documentation.
36
+ """
37
+
38
+ NONE = 0
39
+ EXECUTE = 1 << 0
40
+ WRITE_FILE = 1 << 1
41
+ READ_FILE = 1 << 2
42
+ READ_DIR = 1 << 3
43
+ REMOVE_DIR = 1 << 4
44
+ REMOVE_FILE = 1 << 5
45
+ MAKE_CHAR = 1 << 6
46
+ MAKE_DIR = 1 << 7
47
+ MAKE_REG = 1 << 8
48
+ MAKE_SOCK = 1 << 9
49
+ MAKE_FIFO = 1 << 10
50
+ MAKE_BLOCK = 1 << 11
51
+ MAKE_SYM = 1 << 12
52
+ REFER = 1 << 13
53
+ TRUNCATE = 1 << 14
54
+ IOCTL_DEV = 1 << 15
55
+ RESOLVE_UNIX = 1 << 16
56
+
57
+
58
+ class AccessNet(IntFlag):
59
+ """Network access rights, mirroring LANDLOCK_ACCESS_NET_*.
60
+
61
+ Used in the handled_access_net field of a ruleset and in the
62
+ allowed_access field of port rules. See "Network flags" in the
63
+ kernel documentation.
64
+ """
65
+
66
+ NONE = 0
67
+ BIND_TCP = 1 << 0
68
+ CONNECT_TCP = 1 << 1
69
+ BIND_UDP = 1 << 2
70
+ CONNECT_SEND_UDP = 1 << 3
71
+
72
+
73
+ class Scope(IntFlag):
74
+ """Scope flags, mirroring LANDLOCK_SCOPE_*.
75
+
76
+ Set on a ruleset to isolate the domain from resources outside it,
77
+ such as abstract UNIX sockets or signals. See "Scope flags" in the
78
+ kernel documentation.
79
+ """
80
+
81
+ NONE = 0
82
+ ABSTRACT_UNIX_SOCKET = 1 << 0
83
+ SIGNAL = 1 << 1
84
+
85
+
86
+ class RestrictFlag(IntFlag):
87
+ """Flags accepted by landlock_restrict_self, mirroring
88
+ LANDLOCK_RESTRICT_SELF_*. See "Enforcing a ruleset" in the kernel
89
+ documentation.
90
+ """
91
+
92
+ NONE = 0
93
+ LOG_SAME_EXEC_OFF = 1 << 0
94
+ LOG_NEW_EXEC_ON = 1 << 1
95
+ LOG_SUBDOMAINS_OFF = 1 << 2
96
+ TSYNC = 1 << 3
97
+ NO_NEW_PRIVS = 1 << 4
98
+
99
+
100
+ _F = TypeVar("_F", bound=IntFlag)
101
+
102
+ _FS_MIN_ABI: dict[AccessFS, int] = {
103
+ AccessFS.EXECUTE: 1,
104
+ AccessFS.WRITE_FILE: 1,
105
+ AccessFS.READ_FILE: 1,
106
+ AccessFS.READ_DIR: 1,
107
+ AccessFS.REMOVE_DIR: 1,
108
+ AccessFS.REMOVE_FILE: 1,
109
+ AccessFS.MAKE_CHAR: 1,
110
+ AccessFS.MAKE_DIR: 1,
111
+ AccessFS.MAKE_REG: 1,
112
+ AccessFS.MAKE_SOCK: 1,
113
+ AccessFS.MAKE_FIFO: 1,
114
+ AccessFS.MAKE_BLOCK: 1,
115
+ AccessFS.MAKE_SYM: 1,
116
+ AccessFS.REFER: 2,
117
+ AccessFS.TRUNCATE: 3,
118
+ AccessFS.IOCTL_DEV: 5,
119
+ AccessFS.RESOLVE_UNIX: 9,
120
+ }
121
+
122
+ _NET_MIN_ABI: dict[AccessNet, int] = {
123
+ AccessNet.BIND_TCP: 4,
124
+ AccessNet.CONNECT_TCP: 4,
125
+ AccessNet.BIND_UDP: 10,
126
+ AccessNet.CONNECT_SEND_UDP: 10,
127
+ }
128
+
129
+ _SCOPE_MIN_ABI: dict[Scope, int] = {
130
+ Scope.ABSTRACT_UNIX_SOCKET: 6,
131
+ Scope.SIGNAL: 6,
132
+ }
133
+
134
+ _RESTRICT_MIN_ABI: dict[RestrictFlag, int] = {
135
+ RestrictFlag.LOG_SAME_EXEC_OFF: 7,
136
+ RestrictFlag.LOG_NEW_EXEC_ON: 7,
137
+ RestrictFlag.LOG_SUBDOMAINS_OFF: 7,
138
+ RestrictFlag.TSYNC: 8,
139
+ RestrictFlag.NO_NEW_PRIVS: 11,
140
+ }
141
+
142
+
143
+ def _for_abi(table: dict[_F, int], flag_type: type[_F], abi: int) -> _F:
144
+ mask = flag_type(0)
145
+ for flag, min_abi in table.items():
146
+ if abi >= min_abi:
147
+ mask |= flag
148
+ return mask
149
+
150
+
151
+ def fs_for_abi(abi: int) -> AccessFS:
152
+ """Return the filesystem rights supported by the given ABI version."""
153
+ return _for_abi(_FS_MIN_ABI, AccessFS, abi)
154
+
155
+
156
+ def net_for_abi(abi: int) -> AccessNet:
157
+ """Return the network rights supported by the given ABI version."""
158
+ return _for_abi(_NET_MIN_ABI, AccessNet, abi)
159
+
160
+
161
+ def scope_for_abi(abi: int) -> Scope:
162
+ """Return the scope flags supported by the given ABI version."""
163
+ return _for_abi(_SCOPE_MIN_ABI, Scope, abi)
164
+
165
+
166
+ def restrict_for_abi(abi: int) -> RestrictFlag:
167
+ """Return the restrict flags supported by the given ABI version."""
168
+ return _for_abi(_RESTRICT_MIN_ABI, RestrictFlag, abi)
landlockpy/py.typed ADDED
File without changes
landlockpy/ruleset.py ADDED
@@ -0,0 +1,265 @@
1
+ # SPDX-License-Identifier: 0BSD
2
+ """Ruleset construction and enforcement for Landlock."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import contextlib
7
+ import os
8
+ import types
9
+
10
+ from . import _syscall
11
+ from .errors import UnsupportedError
12
+ from .flags import (
13
+ LATEST_ABI,
14
+ AccessFS,
15
+ AccessNet,
16
+ RestrictFlag,
17
+ Scope,
18
+ fs_for_abi,
19
+ net_for_abi,
20
+ restrict_for_abi,
21
+ scope_for_abi,
22
+ )
23
+
24
+ __all__ = ["Ruleset"]
25
+
26
+
27
+ class Ruleset:
28
+ """A Landlock ruleset under construction.
29
+
30
+ A ruleset declares which access rights it handles. Handled rights are
31
+ denied by default once the ruleset is enforced; allow_path() and
32
+ allow_port() then grant back specific rights for specific objects:
33
+
34
+ with Ruleset() as ruleset:
35
+ ruleset.allow_path("/usr", AccessFS.READ_FILE | AccessFS.READ_DIR)
36
+ ruleset.allow_port(443, AccessNet.CONNECT_TCP)
37
+ ruleset.restrict()
38
+
39
+ Rights the running kernel does not support are dropped in best-effort
40
+ mode (the default), or rejected with UnsupportedError when best_effort
41
+ is False.
42
+
43
+ The ruleset owns a kernel file descriptor. Use it as a context manager
44
+ or call close() to release it.
45
+
46
+ Kernel reference: https://docs.kernel.org/userspace-api/landlock.html
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ *,
52
+ handled_fs: AccessFS | None = None,
53
+ handled_net: AccessNet | None = None,
54
+ scoped: Scope = Scope.NONE,
55
+ quiet_fs: AccessFS = AccessFS.NONE,
56
+ quiet_net: AccessNet = AccessNet.NONE,
57
+ quiet_scoped: Scope = Scope.NONE,
58
+ best_effort: bool = True,
59
+ ) -> None:
60
+ self._abi = _syscall.abi_version()
61
+
62
+ req_fs = fs_for_abi(LATEST_ABI) if handled_fs is None else AccessFS(handled_fs)
63
+ req_net = (
64
+ net_for_abi(LATEST_ABI) if handled_net is None else AccessNet(handled_net)
65
+ )
66
+ req_scoped = Scope(scoped)
67
+
68
+ if best_effort:
69
+ self._handled_fs = req_fs & fs_for_abi(self._abi)
70
+ self._handled_net = req_net & net_for_abi(self._abi)
71
+ self._scoped = req_scoped & scope_for_abi(self._abi)
72
+ else:
73
+ self._reject_unsupported(req_fs, fs_for_abi(self._abi), "filesystem")
74
+ self._reject_unsupported(req_net, net_for_abi(self._abi), "network")
75
+ self._reject_unsupported(req_scoped, scope_for_abi(self._abi), "scope")
76
+ self._handled_fs = req_fs
77
+ self._handled_net = req_net
78
+ self._scoped = req_scoped
79
+
80
+ quiet_fs, quiet_net, quiet_scoped = (
81
+ AccessFS(quiet_fs),
82
+ AccessNet(quiet_net),
83
+ Scope(quiet_scoped),
84
+ )
85
+ if self._abi < 10 and (quiet_fs or quiet_net or quiet_scoped):
86
+ if not best_effort:
87
+ raise UnsupportedError("quiet rules require ABI 10")
88
+ quiet_fs, quiet_net, quiet_scoped = (
89
+ AccessFS.NONE,
90
+ AccessNet.NONE,
91
+ Scope.NONE,
92
+ )
93
+ if quiet_fs & ~self._handled_fs:
94
+ raise ValueError("quiet_fs must be a subset of handled_fs")
95
+ if quiet_net & ~self._handled_net:
96
+ raise ValueError("quiet_net must be a subset of handled_net")
97
+ if quiet_scoped & ~self._scoped:
98
+ raise ValueError("quiet_scoped must be a subset of scoped")
99
+
100
+ attr = _syscall.RulesetAttr(
101
+ handled_access_fs=int(self._handled_fs),
102
+ handled_access_net=int(self._handled_net),
103
+ scoped=int(self._scoped),
104
+ quiet_access_fs=int(quiet_fs),
105
+ quiet_access_net=int(quiet_net),
106
+ quiet_scoped=int(quiet_scoped),
107
+ )
108
+ self._fd = _syscall.create_ruleset(attr)
109
+ os.set_inheritable(self._fd, False)
110
+ self._closed = False
111
+ self._enforced = False
112
+
113
+ @staticmethod
114
+ def _reject_unsupported(requested: int, supported: int, kind: str) -> None:
115
+ missing = requested & ~supported
116
+ if missing:
117
+ raise UnsupportedError(
118
+ f"kernel does not support requested {kind} rights: {missing:#x}"
119
+ )
120
+
121
+ @property
122
+ def abi_version(self) -> int:
123
+ """Landlock ABI version of the running kernel."""
124
+ return self._abi
125
+
126
+ @property
127
+ def handled_fs(self) -> AccessFS:
128
+ """Filesystem rights this ruleset denies by default."""
129
+ return self._handled_fs
130
+
131
+ @property
132
+ def handled_net(self) -> AccessNet:
133
+ """Network rights this ruleset denies by default."""
134
+ return self._handled_net
135
+
136
+ @property
137
+ def scoped(self) -> Scope:
138
+ """Scope isolation flags applied to the domain."""
139
+ return self._scoped
140
+
141
+ @property
142
+ def enforced(self) -> bool:
143
+ """Whether restrict() has been called successfully."""
144
+ return self._enforced
145
+
146
+ @property
147
+ def closed(self) -> bool:
148
+ """Whether the ruleset file descriptor has been closed."""
149
+ return self._closed
150
+
151
+ def fileno(self) -> int:
152
+ """Return the underlying ruleset file descriptor."""
153
+ return self._fd
154
+
155
+ def _check_mutable(self) -> None:
156
+ if self._closed:
157
+ raise RuntimeError("ruleset is closed")
158
+ if self._enforced:
159
+ raise RuntimeError("ruleset is already enforced")
160
+
161
+ def allow_path(
162
+ self, path: str | os.PathLike[str], access: AccessFS, *, quiet: bool = False
163
+ ) -> AccessFS:
164
+ """Grant filesystem access rights on a file hierarchy.
165
+
166
+ The path can point to a file or a directory; a directory rule covers
167
+ its whole hierarchy. Access is masked against the handled filesystem
168
+ rights. Returns the rights actually granted, which is empty if none
169
+ of the requested rights are handled and no rule was added.
170
+
171
+ quiet marks the rule with LANDLOCK_ADD_RULE_QUIET, suppressing audit
172
+ logs for accesses the ruleset declared quiet (requires ABI 10).
173
+ See "Extending a ruleset" in the kernel documentation.
174
+ """
175
+ self._check_mutable()
176
+ granted = AccessFS(access) & self._handled_fs
177
+ if not granted:
178
+ return AccessFS.NONE
179
+ parent_fd = os.open(path, os.O_PATH | os.O_CLOEXEC)
180
+ try:
181
+ attr = _syscall.PathBeneathAttr(
182
+ allowed_access=int(granted), parent_fd=parent_fd
183
+ )
184
+ flags = _syscall.ADD_RULE_QUIET if quiet else 0
185
+ _syscall.add_path_beneath(self._fd, attr, flags)
186
+ finally:
187
+ os.close(parent_fd)
188
+ return granted
189
+
190
+ def allow_port(
191
+ self, port: int, access: AccessNet, *, quiet: bool = False
192
+ ) -> AccessNet:
193
+ """Grant network access rights on a TCP or UDP port.
194
+
195
+ Port 0 covers the ephemeral port range used by auto-bound sockets.
196
+ Access is masked against the handled network rights. Returns the
197
+ rights actually granted, which is empty if none of the requested
198
+ rights are handled and no rule was added.
199
+
200
+ A LandlockError with errno EAFNOSUPPORT means the kernel lacks
201
+ TCP/IP support; the operation is impossible anyway and the error
202
+ can safely be ignored. See "Extending a ruleset" in the kernel
203
+ documentation.
204
+ """
205
+ self._check_mutable()
206
+ if not 0 <= port <= 65535:
207
+ raise ValueError(f"port out of range: {port}")
208
+ granted = AccessNet(access) & self._handled_net
209
+ if not granted:
210
+ return AccessNet.NONE
211
+ attr = _syscall.NetPortAttr(allowed_access=int(granted), port=port)
212
+ flags = _syscall.ADD_RULE_QUIET if quiet else 0
213
+ _syscall.add_net_port(self._fd, attr, flags)
214
+ return granted
215
+
216
+ def restrict(
217
+ self, flags: RestrictFlag = RestrictFlag.NONE, *, no_new_privs: bool = True
218
+ ) -> None:
219
+ """Enforce the ruleset on the calling thread and its future children.
220
+
221
+ Flags unsupported by the running kernel are dropped. With
222
+ no_new_privs, the thread is also prevented from gaining privileges
223
+ through suid or file-capability binaries. On ABI 11 and newer this
224
+ is set atomically with enforcement; on older kernels a
225
+ prctl(PR_SET_NO_NEW_PRIVS) call is made first.
226
+
227
+ Enforcement is irreversible and per-thread. Without the TSYNC flag
228
+ (ABI 8), only the calling thread and its future children are
229
+ restricted; sibling threads keep their own policy. See "Enforcing
230
+ a ruleset" in the kernel documentation.
231
+ """
232
+ if self._closed:
233
+ raise RuntimeError("ruleset is closed")
234
+ if self._enforced:
235
+ raise RuntimeError("ruleset is already enforced")
236
+ effective = RestrictFlag(flags) & restrict_for_abi(self._abi)
237
+ if no_new_privs:
238
+ if self._abi >= 11:
239
+ effective |= RestrictFlag.NO_NEW_PRIVS
240
+ else:
241
+ _syscall.set_no_new_privs()
242
+ _syscall.restrict_self(self._fd, int(effective))
243
+ self._enforced = True
244
+
245
+ def close(self) -> None:
246
+ """Close the ruleset file descriptor. Safe to call twice."""
247
+ if not self._closed:
248
+ os.close(self._fd)
249
+ self._closed = True
250
+
251
+ def __enter__(self) -> Ruleset:
252
+ return self
253
+
254
+ def __exit__(
255
+ self,
256
+ exc_type: type[BaseException] | None,
257
+ exc: BaseException | None,
258
+ tb: types.TracebackType | None,
259
+ ) -> None:
260
+ self.close()
261
+
262
+ def __del__(self) -> None:
263
+ # __del__ must never raise
264
+ with contextlib.suppress(Exception):
265
+ self.close()
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.5
2
+ Name: landlockpy
3
+ Version: 0.1.0
4
+ Summary: Python bindings for the Landlock Linux security module
5
+ Project-URL: Homepage, https://quad4.io
6
+ Project-URL: Repository, https://github.com/Quad4-Software/landlockpy
7
+ Project-URL: Documentation, https://docs.kernel.org/userspace-api/landlock.html
8
+ Author: Quad4
9
+ License-Expression: 0BSD
10
+ License-File: LICENSE
11
+ Keywords: landlock,linux,lsm,sandbox,security
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: BSD License
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Security
18
+ Classifier: Topic :: System :: Operating System Kernels :: Linux
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+
23
+ # landlockpy
24
+
25
+ [![CI](https://github.com/Quad4-Software/landlockpy/actions/workflows/ci.yml/badge.svg)](https://github.com/Quad4-Software/landlockpy/actions/workflows/ci.yml)
26
+ [![CodeQL](https://github.com/Quad4-Software/landlockpy/actions/workflows/codeql.yml/badge.svg)](https://github.com/Quad4-Software/landlockpy/actions/workflows/codeql.yml)
27
+ [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/Quad4-Software/landlockpy/badge)](https://securityscorecards.dev/viewer/?uri=github.com/Quad4-Software/landlockpy)
28
+ [![License: 0BSD](https://img.shields.io/badge/license-0BSD-blue)](LICENSE)
29
+
30
+ Dependency-free Python bindings for the Landlock Linux security module.
31
+ Landlock lets unprivileged processes sandbox themselves with filesystem,
32
+ network and IPC restrictions enforced by the kernel. Supports ABI versions
33
+ 1 through 11 with best-effort degradation on older kernels.
34
+
35
+ Requires Python 3.10+ and Linux 5.13+ with Landlock enabled in the LSM list.
36
+
37
+ ## Install
38
+
39
+ pip install landlockpy
40
+
41
+ ## Usage
42
+
43
+ ```python
44
+ from landlockpy import AccessFS, AccessNet, Ruleset
45
+
46
+ with Ruleset() as ruleset:
47
+ ruleset.allow_path(
48
+ "/usr", AccessFS.READ_FILE | AccessFS.READ_DIR | AccessFS.EXECUTE
49
+ )
50
+ ruleset.allow_port(443, AccessNet.CONNECT_TCP)
51
+ ruleset.restrict()
52
+
53
+ # everything not granted above is now denied for this thread and its children
54
+ ```
55
+
56
+ `landlockpy.supported()` reports whether the running kernel has Landlock,
57
+ and `landlockpy.abi_version()` returns its ABI version.
58
+
59
+ ## Documentation
60
+
61
+ - API: docstrings in `src/landlockpy/`, mostly `ruleset.py`
62
+ - Landlock API reference: https://docs.kernel.org/userspace-api/landlock.html
63
+ - Project site: https://landlock.io/
64
+
65
+ License: 0BSD.
@@ -0,0 +1,10 @@
1
+ landlockpy/__init__.py,sha256=WyQ9tjIUkPVnn6E5OJmUB8mHHJmu4rvmY2x9WxUAWYY,2049
2
+ landlockpy/_syscall.py,sha256=AK4LeBMF543qaMwNoDSb__VjswUFJfbFCo1SD29lykU,3600
3
+ landlockpy/errors.py,sha256=a9zosmfdz8G7nYyfUFt_fsjI7YfW5nS5GDE8gGctNPc,267
4
+ landlockpy/flags.py,sha256=j_g1Y-HbFlyMiNwVV_sDlSACNq-ep9_aC1u0fTfO2Vs,4267
5
+ landlockpy/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ landlockpy/ruleset.py,sha256=1bEm_1oWRLbjP83teE-ZxFb3puDQmm4E1nxQSRVoiPw,9514
7
+ landlockpy-0.1.0.dist-info/METADATA,sha256=MLg59Kbd4f0fBJOU1_GCb4nY6ZErIO0Ckg9yufkdDts,2552
8
+ landlockpy-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
9
+ landlockpy-0.1.0.dist-info/licenses/LICENSE,sha256=3Hnwsz5EXuTC9iTlGjzA171dKQtIVsgjFoF5DEMRl48,633
10
+ landlockpy-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,12 @@
1
+ Copyright (c) 2026 Quad4
2
+
3
+ Permission to use, copy, modify, and/or distribute this software for any purpose
4
+ with or without fee is hereby granted.
5
+
6
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
7
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
8
+ FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
9
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
10
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
11
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
12
+ PERFORMANCE OF THIS SOFTWARE.