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/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