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 +81 -0
- landlockpy/_syscall.py +124 -0
- landlockpy/errors.py +10 -0
- landlockpy/flags.py +168 -0
- landlockpy/py.typed +0 -0
- landlockpy/ruleset.py +265 -0
- landlockpy-0.1.0.dist-info/METADATA +65 -0
- landlockpy-0.1.0.dist-info/RECORD +10 -0
- landlockpy-0.1.0.dist-info/WHEEL +4 -0
- landlockpy-0.1.0.dist-info/licenses/LICENSE +12 -0
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
|
+
[](https://github.com/Quad4-Software/landlockpy/actions/workflows/ci.yml)
|
|
26
|
+
[](https://github.com/Quad4-Software/landlockpy/actions/workflows/codeql.yml)
|
|
27
|
+
[](https://securityscorecards.dev/viewer/?uri=github.com/Quad4-Software/landlockpy)
|
|
28
|
+
[](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,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.
|