agent-lock-cli 0.1.1__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.
- agent_lock/__init__.py +3 -0
- agent_lock/__main__.py +5 -0
- agent_lock/allow.py +40 -0
- agent_lock/cli.py +181 -0
- agent_lock/completion.py +414 -0
- agent_lock/darwin.py +276 -0
- agent_lock/discover.py +542 -0
- agent_lock/doctor.py +1347 -0
- agent_lock/errors.py +22 -0
- agent_lock/install.py +1024 -0
- agent_lock/linux.py +336 -0
- agent_lock/state.py +275 -0
- agent_lock/windows.py +706 -0
- agent_lock/wrapper.py +259 -0
- agent_lock_cli-0.1.1.dist-info/METADATA +139 -0
- agent_lock_cli-0.1.1.dist-info/RECORD +20 -0
- agent_lock_cli-0.1.1.dist-info/WHEEL +5 -0
- agent_lock_cli-0.1.1.dist-info/entry_points.txt +2 -0
- agent_lock_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
- agent_lock_cli-0.1.1.dist-info/top_level.txt +1 -0
agent_lock/darwin.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""macOS pf anchor. The enable token is released with pfctl -X. pf is never disabled."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from .errors import AgentLockError
|
|
6
|
+
from .state import AGENT_USER, DARWIN_AGENT_HOME
|
|
7
|
+
|
|
8
|
+
PF_CONF = "/etc/pf.conf"
|
|
9
|
+
PF_BACKUP = "/etc/pf.conf.agent-lock-bak"
|
|
10
|
+
ANCHOR_PATH = "/etc/pf.anchors/agentlock"
|
|
11
|
+
ANCHOR_NAME = "agentlock"
|
|
12
|
+
BEGIN = "# agent-lock begin"
|
|
13
|
+
END = "# agent-lock end"
|
|
14
|
+
APPLE_ANCHOR = 'anchor "com.apple/*"'
|
|
15
|
+
ACL_ENTRY = (
|
|
16
|
+
"agentnet allow read,write,execute,delete,add_file,add_subdirectory,"
|
|
17
|
+
"file_inherit,directory_inherit"
|
|
18
|
+
)
|
|
19
|
+
HOME_ACL_ENTRY = "agentnet allow execute"
|
|
20
|
+
|
|
21
|
+
TCC_TOP = ("Desktop", "Documents", "Downloads")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PfRefuse(AgentLockError):
|
|
25
|
+
"""A pass quick rule already sits before the only legal insertion point."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, message):
|
|
28
|
+
super(PfRefuse, self).__init__(message, 1)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def anchor_block():
|
|
32
|
+
return (
|
|
33
|
+
"# agent-lock begin\n"
|
|
34
|
+
'anchor "agentlock"\n'
|
|
35
|
+
'load anchor "agentlock" from "/etc/pf.anchors/agentlock"\n'
|
|
36
|
+
"# agent-lock end\n"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def render_anchor(port, extra_tcp):
|
|
41
|
+
"""Anchor body. Extra pass rules only when extra_tcp is non-empty."""
|
|
42
|
+
from .allow import parse_allow_tcp_value
|
|
43
|
+
port = int(port)
|
|
44
|
+
lines = [
|
|
45
|
+
"# agent-lock anchor. Safe to delete this file on uninstall.",
|
|
46
|
+
"pass out quick inet proto tcp to 127.0.0.1 port {0} user {1} keep state".format(
|
|
47
|
+
port, AGENT_USER),
|
|
48
|
+
"pass out quick inet6 proto tcp to ::1 port {0} user {1} keep state".format(
|
|
49
|
+
port, AGENT_USER),
|
|
50
|
+
]
|
|
51
|
+
for value in extra_tcp or []:
|
|
52
|
+
host, extra_port = parse_allow_tcp_value(value)
|
|
53
|
+
family = "inet" if host == "127.0.0.1" else "inet6"
|
|
54
|
+
lines.append(
|
|
55
|
+
"pass out quick {family} proto tcp to {host} port {port} user {user} keep state".format(
|
|
56
|
+
family=family, host=host, port=extra_port, user=AGENT_USER))
|
|
57
|
+
lines.append(
|
|
58
|
+
"block drop out quick proto { tcp, udp, icmp, ipv6-icmp } user %s" % AGENT_USER)
|
|
59
|
+
lines.append("")
|
|
60
|
+
return "\n".join(lines)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _lines(text):
|
|
64
|
+
if text == "":
|
|
65
|
+
return []
|
|
66
|
+
# Keep the newline on every line except possibly the last empty split.
|
|
67
|
+
return text.splitlines(keepends=True)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _join(lines):
|
|
71
|
+
return "".join(lines)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _apple_index(lines):
|
|
75
|
+
for index, line in enumerate(lines):
|
|
76
|
+
if line.strip() == APPLE_ANCHOR:
|
|
77
|
+
return index
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _is_pass_quick(line):
|
|
82
|
+
stripped = line.strip()
|
|
83
|
+
if not stripped or stripped.startswith("#"):
|
|
84
|
+
return False
|
|
85
|
+
return "pass" in stripped and "quick" in stripped
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _pass_quick_before(lines, marker_index):
|
|
89
|
+
for line in lines[:marker_index]:
|
|
90
|
+
if _is_pass_quick(line):
|
|
91
|
+
return True
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _marker_span(lines):
|
|
96
|
+
begin = None
|
|
97
|
+
end = None
|
|
98
|
+
for index, line in enumerate(lines):
|
|
99
|
+
if line.strip() == BEGIN:
|
|
100
|
+
begin = index
|
|
101
|
+
elif line.strip() == END and begin is not None and end is None:
|
|
102
|
+
end = index
|
|
103
|
+
break
|
|
104
|
+
if begin is None and end is None:
|
|
105
|
+
return None
|
|
106
|
+
if begin is None or end is None or end < begin:
|
|
107
|
+
raise PfRefuse("agent-lock pf markers are incomplete")
|
|
108
|
+
return begin, end
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def patch_pf_conf(text):
|
|
112
|
+
"""Insert or replace the anchor block before anchor "com.apple/*".
|
|
113
|
+
|
|
114
|
+
If that line is missing, the block goes at the start of the file, not the end.
|
|
115
|
+
A pass quick rule before the block is a refusal, not a reason to append.
|
|
116
|
+
"""
|
|
117
|
+
lines = _lines(text)
|
|
118
|
+
span = _marker_span(lines)
|
|
119
|
+
block_lines = _lines(anchor_block())
|
|
120
|
+
if span is None:
|
|
121
|
+
apple = _apple_index(lines)
|
|
122
|
+
if apple is None:
|
|
123
|
+
lines = block_lines + lines
|
|
124
|
+
else:
|
|
125
|
+
lines = lines[:apple] + block_lines + lines[apple:]
|
|
126
|
+
else:
|
|
127
|
+
begin, end = span
|
|
128
|
+
lines = lines[:begin] + block_lines + lines[end + 1:]
|
|
129
|
+
span = _marker_span(lines)
|
|
130
|
+
if span is None:
|
|
131
|
+
raise PfRefuse("agent-lock pf markers were not written")
|
|
132
|
+
if _pass_quick_before(lines, span[0]):
|
|
133
|
+
raise PfRefuse(
|
|
134
|
+
"refusing to install: a pass quick rule is before the agent-lock anchor")
|
|
135
|
+
apple = _apple_index(lines)
|
|
136
|
+
if apple is not None and span[0] > apple:
|
|
137
|
+
raise PfRefuse('agent-lock anchor is not before anchor "com.apple/*"')
|
|
138
|
+
rendered = _join(lines)
|
|
139
|
+
if text.endswith("\n") or text == "":
|
|
140
|
+
if not rendered.endswith("\n"):
|
|
141
|
+
rendered += "\n"
|
|
142
|
+
return rendered
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def unpatch_pf_conf(text):
|
|
146
|
+
"""Drop the agent-lock anchor block. Leave every other line in place."""
|
|
147
|
+
lines = _lines(text)
|
|
148
|
+
span = _marker_span(lines)
|
|
149
|
+
if span is None:
|
|
150
|
+
return text
|
|
151
|
+
begin, end = span
|
|
152
|
+
return _join(lines[:begin] + lines[end + 1:])
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def user_plan():
|
|
156
|
+
"""Commands that create the account. The caller skips them when it already exists."""
|
|
157
|
+
return [
|
|
158
|
+
[
|
|
159
|
+
"sysadminctl", "-addUser", AGENT_USER, "-fullName", AGENT_USER,
|
|
160
|
+
"-password", "-", "-home", DARWIN_AGENT_HOME, "-shell", "/usr/bin/false",
|
|
161
|
+
],
|
|
162
|
+
["dscl", ".", "-create", "/Users/%s" % AGENT_USER, "IsHidden", "1"],
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def is_tcc_path(path, home):
|
|
167
|
+
"""Desktop, Documents, Downloads, and iCloud container trees are refused."""
|
|
168
|
+
path = os.path.abspath(path)
|
|
169
|
+
home = os.path.abspath(home)
|
|
170
|
+
relative = os.path.relpath(path, home)
|
|
171
|
+
if relative.startswith(".."):
|
|
172
|
+
return False
|
|
173
|
+
parts = [] if relative == "." else relative.split(os.sep)
|
|
174
|
+
if parts and parts[0] in TCC_TOP:
|
|
175
|
+
return True
|
|
176
|
+
if len(parts) >= 2 and parts[0] == "Library" and parts[1] == "Mobile Documents":
|
|
177
|
+
return True
|
|
178
|
+
return False
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def acl_commands(directories, owner_home):
|
|
182
|
+
"""NFSv4 ACL. -a then +a keeps a single entry. No launcher chown."""
|
|
183
|
+
commands = [
|
|
184
|
+
["chmod", "-a", HOME_ACL_ENTRY, owner_home],
|
|
185
|
+
["chmod", "+a", HOME_ACL_ENTRY, owner_home],
|
|
186
|
+
]
|
|
187
|
+
for directory in directories:
|
|
188
|
+
commands.append(["chmod", "-a", ACL_ENTRY, directory])
|
|
189
|
+
commands.append(["chmod", "+a", ACL_ENTRY, directory])
|
|
190
|
+
return commands
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def parse_enable_token(output):
|
|
194
|
+
"""Pull the token printed by pfctl -E. Empty output yields an empty token."""
|
|
195
|
+
token = ""
|
|
196
|
+
for line in (output or "").splitlines():
|
|
197
|
+
stripped = line.strip()
|
|
198
|
+
if stripped.lower().startswith("token"):
|
|
199
|
+
_label, _sep, value = stripped.partition(":")
|
|
200
|
+
value = value.strip()
|
|
201
|
+
if value:
|
|
202
|
+
token = value
|
|
203
|
+
return token
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def enable_argv():
|
|
207
|
+
return ["pfctl", "-E"]
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def release_token_argv(token):
|
|
211
|
+
"""None when there is no token. Never disables the packet filter."""
|
|
212
|
+
if not token:
|
|
213
|
+
return None
|
|
214
|
+
return ["pfctl", "-X", token]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def check_conf_argv(path=PF_CONF):
|
|
218
|
+
return ["pfctl", "-nf", path]
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def load_conf_argv(path=PF_CONF):
|
|
222
|
+
return ["pfctl", "-f", path]
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def load_anchor_argv(path=ANCHOR_PATH):
|
|
226
|
+
return ["pfctl", "-a", ANCHOR_NAME, "-f", path]
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
PF_DYNAMIC_ANCHOR_WARNING = (
|
|
230
|
+
"warning: pfctl -f drops anchors that were inserted after the ruleset was parsed\n"
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def pf_conf_problem(text):
|
|
235
|
+
"""None when the marker is before the Apple anchor, or at the start if it is absent."""
|
|
236
|
+
try:
|
|
237
|
+
lines = _lines(text or "")
|
|
238
|
+
span = _marker_span(lines)
|
|
239
|
+
except PfRefuse as exc:
|
|
240
|
+
return str(exc)
|
|
241
|
+
if span is None:
|
|
242
|
+
return "pf.conf has no agent-lock anchor"
|
|
243
|
+
if _pass_quick_before(lines, span[0]):
|
|
244
|
+
return "a pass quick rule is before the agent-lock anchor"
|
|
245
|
+
apple = _apple_index(lines)
|
|
246
|
+
if apple is not None and span[0] > apple:
|
|
247
|
+
return 'agent-lock anchor is not before anchor "com.apple/*"'
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def anchor_observed(text, port):
|
|
252
|
+
"""(main v4 and v6 passes plus a block, extra host:port allows)."""
|
|
253
|
+
import re
|
|
254
|
+
port = int(port)
|
|
255
|
+
has_v4 = False
|
|
256
|
+
has_v6 = False
|
|
257
|
+
has_block = False
|
|
258
|
+
extras = []
|
|
259
|
+
for line in (text or "").splitlines():
|
|
260
|
+
stripped = line.strip()
|
|
261
|
+
if stripped.startswith("block "):
|
|
262
|
+
has_block = True
|
|
263
|
+
match = re.search(r"\bto (127\.0\.0\.1|::1) port (\d+)\b", stripped)
|
|
264
|
+
if not match or not stripped.startswith("pass "):
|
|
265
|
+
continue
|
|
266
|
+
host = match.group(1)
|
|
267
|
+
found = int(match.group(2))
|
|
268
|
+
if found == port and host == "127.0.0.1":
|
|
269
|
+
has_v4 = True
|
|
270
|
+
elif found == port and host == "::1":
|
|
271
|
+
has_v6 = True
|
|
272
|
+
elif found != port:
|
|
273
|
+
value = "{}:{}".format(host, found)
|
|
274
|
+
if value not in extras:
|
|
275
|
+
extras.append(value)
|
|
276
|
+
return has_v4 and has_v6 and has_block, extras
|