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/windows.py
ADDED
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
"""Windows outbound Block rules. No Allow rules. No RemoteAddress keywords.
|
|
2
|
+
|
|
3
|
+
A Block must not match 127.0.0.1:PORT or ::1:PORT. Loopback TCP uses dash
|
|
4
|
+
ranges and omits an empty range when the port is 1 or 65535.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import base64
|
|
8
|
+
import ipaddress
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from .errors import AgentLockError, Unsupported, UsageError
|
|
14
|
+
from .state import AGENT_USER
|
|
15
|
+
|
|
16
|
+
# Doctor rejects these. They are not written into rules.
|
|
17
|
+
REMOTE_KEYWORDS = (
|
|
18
|
+
"Internet",
|
|
19
|
+
"Intranet",
|
|
20
|
+
"IntranetRemoteAccess",
|
|
21
|
+
"LocalSubnet",
|
|
22
|
+
"DNS",
|
|
23
|
+
"DHCP",
|
|
24
|
+
"WINS",
|
|
25
|
+
"DefaultGateway",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
NON_LOOPBACK = (
|
|
29
|
+
("v4-below-loopback", "0.0.0.0-126.255.255.255", "Any"),
|
|
30
|
+
("v4-above-loopback", "128.0.0.0-255.255.255.255", "Any"),
|
|
31
|
+
("v4-linklocal", "169.254.0.0/16", "Any"),
|
|
32
|
+
("v4-multicast", "224.0.0.0/4", "Any"),
|
|
33
|
+
("v4-reserved", "240.0.0.0/4", "Any"),
|
|
34
|
+
("v6-linklocal", "fe80::/10", "Any"),
|
|
35
|
+
("v6-ula", "fc00::/7", "Any"),
|
|
36
|
+
("v6-multicast", "ff00::/8", "Any"),
|
|
37
|
+
("v6-unspec", "::/128", "Any"),
|
|
38
|
+
("v6-rest", "::2-ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", "Any"),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def user_action(exists, enabled):
|
|
43
|
+
"""'create' prompts once. 'keep' does not. Disabled accounts fail."""
|
|
44
|
+
if not exists:
|
|
45
|
+
return "create"
|
|
46
|
+
if enabled:
|
|
47
|
+
return "keep"
|
|
48
|
+
raise AgentLockError("agentnet exists but is disabled; refusing to reset its password", 1)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _holes(port, extra_tcp, host):
|
|
52
|
+
from .allow import parse_allow_tcp_value
|
|
53
|
+
holes = {int(port)}
|
|
54
|
+
for value in extra_tcp or []:
|
|
55
|
+
extra_host, extra_port = parse_allow_tcp_value(value)
|
|
56
|
+
if extra_host == host:
|
|
57
|
+
holes.add(extra_port)
|
|
58
|
+
return holes
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def blocked_ranges(holes):
|
|
62
|
+
"""Inclusive ranges inside 1..65535 that are not in holes. Empty ranges omitted."""
|
|
63
|
+
ordered = sorted(port for port in holes if 1 <= port <= 65535)
|
|
64
|
+
ranges = []
|
|
65
|
+
cursor = 1
|
|
66
|
+
for hole in ordered:
|
|
67
|
+
if hole > cursor:
|
|
68
|
+
ranges.append((cursor, hole - 1))
|
|
69
|
+
cursor = max(cursor, hole + 1)
|
|
70
|
+
if cursor <= 65535:
|
|
71
|
+
ranges.append((cursor, 65535))
|
|
72
|
+
return ranges
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _range_text(start, end):
|
|
76
|
+
return "{}-{}".format(start, end)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _sddl(sid):
|
|
80
|
+
return "D:(A;;CC;;;%s)" % sid
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _rule(suffix, remote_address, protocol, remote_port, sid):
|
|
84
|
+
rule = {
|
|
85
|
+
"name": "agent-lock-{}".format(suffix),
|
|
86
|
+
"direction": "Outbound",
|
|
87
|
+
"action": "Block",
|
|
88
|
+
"protocol": protocol,
|
|
89
|
+
"remote_address": remote_address,
|
|
90
|
+
"remote_port": remote_port,
|
|
91
|
+
"profile": "Any",
|
|
92
|
+
"enabled": True,
|
|
93
|
+
"local_user": _sddl(sid),
|
|
94
|
+
}
|
|
95
|
+
return rule
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def build_rules(port, extra_tcp, sid):
|
|
99
|
+
port = int(port)
|
|
100
|
+
if port < 1 or port > 65535:
|
|
101
|
+
raise AgentLockError("port out of range", 2)
|
|
102
|
+
rules = []
|
|
103
|
+
for suffix, address, protocol in NON_LOOPBACK:
|
|
104
|
+
rules.append(_rule(suffix, address, protocol, None, sid))
|
|
105
|
+
families = (
|
|
106
|
+
("lo", "127.0.0.0/8", "127.0.0.1"),
|
|
107
|
+
("lo6", "::1", "::1"),
|
|
108
|
+
)
|
|
109
|
+
for prefix, address, host in families:
|
|
110
|
+
ranges = blocked_ranges(_holes(port, extra_tcp, host))
|
|
111
|
+
plain = not extra_tcp
|
|
112
|
+
for start, end in ranges:
|
|
113
|
+
if plain and start == 1:
|
|
114
|
+
suffix = "{}-tcp-{}-low".format(prefix, port)
|
|
115
|
+
elif plain and end == 65535:
|
|
116
|
+
suffix = "{}-tcp-{}-high".format(prefix, port)
|
|
117
|
+
else:
|
|
118
|
+
suffix = "{}-tcp-{}-{}".format(prefix, start, end)
|
|
119
|
+
rules.append(_rule(suffix, address, "TCP", _range_text(start, end), sid))
|
|
120
|
+
rules.append(_rule("{}-udp".format(prefix), address, "UDP", None, sid))
|
|
121
|
+
icmp = "ICMPv4" if prefix == "lo" else "ICMPv6"
|
|
122
|
+
rules.append(_rule("{}-icmp".format(prefix), address, icmp, None, sid))
|
|
123
|
+
for rule in rules:
|
|
124
|
+
if rule["action"] != "Block":
|
|
125
|
+
raise AgentLockError("internal error: Allow rules are not permitted")
|
|
126
|
+
if rule_covers_proxy(rule, port):
|
|
127
|
+
raise AgentLockError("internal error: a Block covers the proxy port")
|
|
128
|
+
return rules
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def rule_names(rules):
|
|
132
|
+
return [rule["name"] for rule in rules]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def uninstall_commands(names):
|
|
136
|
+
"""Delete only the names stored in state. No display-name prefix sweep."""
|
|
137
|
+
return [["Remove-NetFirewallRule", "-Name", name] for name in names]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _split_ip_range(spec):
|
|
141
|
+
# One dash separates the two addresses. IPv6 text itself has no dash.
|
|
142
|
+
if "-" not in spec:
|
|
143
|
+
return None
|
|
144
|
+
left, right = spec.split("-", 1)
|
|
145
|
+
try:
|
|
146
|
+
return ipaddress.ip_address(left), ipaddress.ip_address(right)
|
|
147
|
+
except ValueError:
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def address_contains(spec, ip_text):
|
|
152
|
+
"""True when spec is any, a keyword, or a literal that includes ip_text."""
|
|
153
|
+
if spec is None:
|
|
154
|
+
return True
|
|
155
|
+
text = str(spec).strip()
|
|
156
|
+
if text == "" or text.lower() in ("any", "*"):
|
|
157
|
+
return True
|
|
158
|
+
if text in REMOTE_KEYWORDS:
|
|
159
|
+
return True
|
|
160
|
+
try:
|
|
161
|
+
target = ipaddress.ip_address(ip_text)
|
|
162
|
+
except ValueError:
|
|
163
|
+
return False
|
|
164
|
+
try:
|
|
165
|
+
if "/" in text:
|
|
166
|
+
network = ipaddress.ip_network(text, strict=False)
|
|
167
|
+
if network.version != target.version:
|
|
168
|
+
return False
|
|
169
|
+
return target in network
|
|
170
|
+
pair = _split_ip_range(text)
|
|
171
|
+
if pair is not None:
|
|
172
|
+
if pair[0].version != target.version:
|
|
173
|
+
return False
|
|
174
|
+
return pair[0] <= target <= pair[1]
|
|
175
|
+
other = ipaddress.ip_address(text)
|
|
176
|
+
if other.version != target.version:
|
|
177
|
+
return False
|
|
178
|
+
return other == target
|
|
179
|
+
except ValueError:
|
|
180
|
+
# Unparseable tokens are treated as dangerous: they might be a keyword.
|
|
181
|
+
return True
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def port_contains(spec, port):
|
|
185
|
+
if spec is None:
|
|
186
|
+
return True
|
|
187
|
+
text = str(spec).strip()
|
|
188
|
+
if text == "" or text.lower() in ("any", "*"):
|
|
189
|
+
return True
|
|
190
|
+
for part in text.split(","):
|
|
191
|
+
part = part.strip()
|
|
192
|
+
if not part:
|
|
193
|
+
continue
|
|
194
|
+
if "-" in part:
|
|
195
|
+
left, right = part.split("-", 1)
|
|
196
|
+
if int(left) <= port <= int(right):
|
|
197
|
+
return True
|
|
198
|
+
elif int(part) == port:
|
|
199
|
+
return True
|
|
200
|
+
return False
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def rule_covers_proxy(rule, port):
|
|
204
|
+
"""True when this Block matches TCP to 127.0.0.1:port or ::1:port."""
|
|
205
|
+
if str(rule.get("action", "")).lower() != "block":
|
|
206
|
+
return False
|
|
207
|
+
if rule.get("enabled") is False:
|
|
208
|
+
return False
|
|
209
|
+
protocol = str(rule.get("protocol", "")).upper()
|
|
210
|
+
if protocol not in ("ANY", "TCP"):
|
|
211
|
+
return False
|
|
212
|
+
if not port_contains(rule.get("remote_port"), int(port)):
|
|
213
|
+
return False
|
|
214
|
+
address = rule.get("remote_address")
|
|
215
|
+
return address_contains(address, "127.0.0.1") or address_contains(address, "::1")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def gpo_problem(profiles):
|
|
219
|
+
"""Fail when local firewall rules are not merged.
|
|
220
|
+
|
|
221
|
+
AllowLocalFirewallRules is read first. When that property is missing,
|
|
222
|
+
AllowLocalPolicyMerge 0 is a failure. Both names are required.
|
|
223
|
+
"""
|
|
224
|
+
for profile in profiles or []:
|
|
225
|
+
name = profile.get("name") or "profile"
|
|
226
|
+
allow = profile.get("AllowLocalFirewallRules", None)
|
|
227
|
+
if allow is False or allow == 0:
|
|
228
|
+
return "AllowLocalFirewallRules is false for {}".format(name)
|
|
229
|
+
if allow is None:
|
|
230
|
+
merge = profile.get("AllowLocalPolicyMerge", None)
|
|
231
|
+
if merge is False or merge == 0:
|
|
232
|
+
return "AllowLocalPolicyMerge is 0 for {}".format(name)
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _outbound_action(value):
|
|
237
|
+
"""Allow is 2, Block is 4. PowerShell 5.1 emits the enum as a number."""
|
|
238
|
+
if isinstance(value, bool) or value is None:
|
|
239
|
+
return ""
|
|
240
|
+
if isinstance(value, int):
|
|
241
|
+
return {2: "allow", 4: "block"}.get(value, "")
|
|
242
|
+
text = str(value).strip().lower()
|
|
243
|
+
if text.isdigit():
|
|
244
|
+
return {2: "allow", 4: "block"}.get(int(text), "")
|
|
245
|
+
if text in ("allow", "block"):
|
|
246
|
+
return text
|
|
247
|
+
return ""
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def default_outbound_problem(profiles):
|
|
251
|
+
for profile in profiles or []:
|
|
252
|
+
action = _outbound_action(profile.get("DefaultOutboundAction"))
|
|
253
|
+
if action == "block":
|
|
254
|
+
return "DefaultOutboundAction is Block for {}".format(profile.get("name") or "profile")
|
|
255
|
+
return None
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def require_supported(profiles):
|
|
259
|
+
problem = gpo_problem(profiles)
|
|
260
|
+
if problem:
|
|
261
|
+
raise Unsupported(problem)
|
|
262
|
+
problem = default_outbound_problem(profiles)
|
|
263
|
+
if problem:
|
|
264
|
+
raise Unsupported(problem)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def share_icacls(paths):
|
|
268
|
+
"""Shared credential directories. Modify, inherited. Not a traverse-only grant."""
|
|
269
|
+
return [
|
|
270
|
+
["icacls", path, "/grant", "{}:(OI)(CI)M".format(AGENT_USER)]
|
|
271
|
+
for path in paths
|
|
272
|
+
]
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _is_reparse(path):
|
|
276
|
+
if os.path.islink(path):
|
|
277
|
+
return True
|
|
278
|
+
if sys.platform != "win32" or not path or not os.path.exists(path):
|
|
279
|
+
return False
|
|
280
|
+
import ctypes
|
|
281
|
+
attrs = ctypes.windll.kernel32.GetFileAttributesW(str(path))
|
|
282
|
+
if attrs in (-1, 0xFFFFFFFF):
|
|
283
|
+
return False
|
|
284
|
+
return bool(int(attrs) & 0x400)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _traverse_ancestors(path, owner_home):
|
|
288
|
+
"""Directories between the path and the home. The home itself is excluded."""
|
|
289
|
+
home = os.path.abspath(owner_home)
|
|
290
|
+
current = os.path.dirname(os.path.abspath(path))
|
|
291
|
+
found = []
|
|
292
|
+
while current and os.path.normcase(current) != os.path.normcase(home):
|
|
293
|
+
parent = os.path.dirname(current)
|
|
294
|
+
if parent == current:
|
|
295
|
+
break
|
|
296
|
+
try:
|
|
297
|
+
if os.path.commonpath([current, home]) != home:
|
|
298
|
+
break
|
|
299
|
+
except ValueError:
|
|
300
|
+
break
|
|
301
|
+
found.append(current)
|
|
302
|
+
current = parent
|
|
303
|
+
found.reverse()
|
|
304
|
+
return found
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def icacls_commands(owner_home, trust_exec, directory_fn):
|
|
308
|
+
"""Home gets (X) only. A directory gets inherited RX. A file gets (RX) only."""
|
|
309
|
+
commands = [["icacls", owner_home, "/grant", "{}:(X)".format(AGENT_USER)]]
|
|
310
|
+
seen = {os.path.normcase(os.path.abspath(owner_home))}
|
|
311
|
+
for path in trust_exec or []:
|
|
312
|
+
if _is_reparse(path):
|
|
313
|
+
raise UsageError("refusing a symlink: {}".format(path))
|
|
314
|
+
if directory_fn(path):
|
|
315
|
+
grant = "{}:(OI)(CI)(RX)".format(AGENT_USER)
|
|
316
|
+
else:
|
|
317
|
+
grant = "{}:(RX)".format(AGENT_USER)
|
|
318
|
+
commands.append(["icacls", path, "/grant", grant])
|
|
319
|
+
for ancestor in _traverse_ancestors(path, owner_home):
|
|
320
|
+
key = os.path.normcase(os.path.abspath(ancestor))
|
|
321
|
+
if key in seen:
|
|
322
|
+
continue
|
|
323
|
+
seen.add(key)
|
|
324
|
+
commands.append(["icacls", ancestor, "/grant", "{}:(X)".format(AGENT_USER)])
|
|
325
|
+
return commands
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def state_acl_commands(path, owner_name):
|
|
329
|
+
"""SYSTEM and Administrators full control, installing user read. No Users ACE."""
|
|
330
|
+
if not owner_name or any(ch in owner_name for ch in "\\/\"\r\n\t &|<>"):
|
|
331
|
+
raise AgentLockError("installing user name is not safe for an ACL")
|
|
332
|
+
if owner_name.lower() in ("users", "agentnet", "everyone", "authenticated users"):
|
|
333
|
+
raise AgentLockError("refusing a state ACL for {}".format(owner_name))
|
|
334
|
+
return [
|
|
335
|
+
"icacls", path, "/inheritance:r", "/grant:r",
|
|
336
|
+
"*S-1-5-18:(F)",
|
|
337
|
+
"*S-1-5-32-544:(F)",
|
|
338
|
+
"{}:(R)".format(owner_name),
|
|
339
|
+
]
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def apply_state_acl(path, owner_name, runner=None):
|
|
343
|
+
"""Explicit DACL. A failed icacls leaves the inherited Users ACE, so it raises."""
|
|
344
|
+
command = state_acl_commands(path, owner_name)
|
|
345
|
+
if runner is None:
|
|
346
|
+
import subprocess
|
|
347
|
+
result = subprocess.run(
|
|
348
|
+
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
349
|
+
universal_newlines=True, check=False)
|
|
350
|
+
code = result.returncode
|
|
351
|
+
detail = (result.stderr or result.stdout or "").strip()
|
|
352
|
+
else:
|
|
353
|
+
result = runner(command)
|
|
354
|
+
code = getattr(result, "returncode", 1) if result else 1
|
|
355
|
+
detail = ""
|
|
356
|
+
if result:
|
|
357
|
+
detail = (getattr(result, "stderr", "") or getattr(result, "stdout", "") or "").strip()
|
|
358
|
+
if code != 0:
|
|
359
|
+
raise AgentLockError("state ACL was not applied: {}".format(detail or "icacls failed"))
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def create_profile_script(sid):
|
|
363
|
+
"""userenv!CreateProfile. Already-exists, or a profile directory, continues.
|
|
364
|
+
|
|
365
|
+
The SID is interpolated into PowerShell, so it has to be a literal SID.
|
|
366
|
+
"""
|
|
367
|
+
if not sid or any(ch not in "S-0123456789" for ch in sid) or not sid.startswith("S-1-"):
|
|
368
|
+
raise AgentLockError("could not read the agentnet SID")
|
|
369
|
+
return (
|
|
370
|
+
"$signature = @'\n"
|
|
371
|
+
"using System;\n"
|
|
372
|
+
"using System.Runtime.InteropServices;\n"
|
|
373
|
+
"using System.Text;\n"
|
|
374
|
+
"public class AgentLockUserEnv {{\n"
|
|
375
|
+
" [DllImport(\"userenv.dll\", CharSet = CharSet.Unicode, SetLastError = true)]\n"
|
|
376
|
+
" public static extern int CreateProfile(string pszUserSid, string pszUserName,\n"
|
|
377
|
+
" StringBuilder pszProfilePath, int cchProfilePath);\n"
|
|
378
|
+
"}}\n"
|
|
379
|
+
"'@\n"
|
|
380
|
+
"if (-not ('AgentLockUserEnv' -as [type])) {{ Add-Type -TypeDefinition $signature }}\n"
|
|
381
|
+
"$buf = New-Object System.Text.StringBuilder 260\n"
|
|
382
|
+
"$code = [AgentLockUserEnv]::CreateProfile('{sid}', '{user}', $buf, 260)\n"
|
|
383
|
+
"$path = $env:SystemDrive + '\\Users\\{user}'\n"
|
|
384
|
+
"# 0 is success. -2147024713 is HRESULT ERROR_ALREADY_EXISTS (0x800700B7).\n"
|
|
385
|
+
"if ($code -eq 0 -or $code -eq -2147024713 -or (Test-Path -LiteralPath $path)) {{ exit 0 }}\n"
|
|
386
|
+
"Write-Error ('CreateProfile failed: ' + $code)\n"
|
|
387
|
+
"exit 1\n"
|
|
388
|
+
).format(sid=sid, user=AGENT_USER)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def link_plan(links):
|
|
392
|
+
"""Junction argv lists. CreateProfile is a separate call, not a plan step."""
|
|
393
|
+
return [["cmd", "/c", "mklink", "/J", link, target] for link, target in links]
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def rule_phases(old_names, new_names):
|
|
397
|
+
"""Names to create, then stored names that are not in the new set."""
|
|
398
|
+
old_names = list(old_names or [])
|
|
399
|
+
new_names = list(new_names or [])
|
|
400
|
+
old_set = set(old_names)
|
|
401
|
+
new_set = set(new_names)
|
|
402
|
+
create = [name for name in new_names if name not in old_set]
|
|
403
|
+
remove = [name for name in old_names if name not in new_set]
|
|
404
|
+
return create, remove
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _ports_in_spec(spec):
|
|
408
|
+
if spec is None:
|
|
409
|
+
return set(range(1, 65536))
|
|
410
|
+
text = str(spec).strip()
|
|
411
|
+
if text == "" or text.lower() in ("any", "*"):
|
|
412
|
+
return set(range(1, 65536))
|
|
413
|
+
found = set()
|
|
414
|
+
for part in text.split(","):
|
|
415
|
+
part = part.strip()
|
|
416
|
+
if not part:
|
|
417
|
+
continue
|
|
418
|
+
if "-" in part:
|
|
419
|
+
left, right = part.split("-", 1)
|
|
420
|
+
found.update(range(int(left), int(right) + 1))
|
|
421
|
+
elif part.isdigit():
|
|
422
|
+
found.add(int(part))
|
|
423
|
+
return found
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def windows_observed(rules, port):
|
|
427
|
+
"""(blocks leave the proxy open, extra loopback TCP holes).
|
|
428
|
+
|
|
429
|
+
`None` extras means the ruleset was not parsed. An empty list means no extra hole.
|
|
430
|
+
"""
|
|
431
|
+
if not rules:
|
|
432
|
+
return False, None
|
|
433
|
+
port = int(port)
|
|
434
|
+
covers = any(rule_covers_proxy(rule, port) for rule in rules)
|
|
435
|
+
extras = []
|
|
436
|
+
families_blocked = True
|
|
437
|
+
for host in ("127.0.0.1", "::1"):
|
|
438
|
+
blocked = set()
|
|
439
|
+
saw = False
|
|
440
|
+
for rule in rules:
|
|
441
|
+
if str(rule.get("action", "")).lower() != "block":
|
|
442
|
+
continue
|
|
443
|
+
protocol = str(rule.get("protocol", "")).upper()
|
|
444
|
+
if protocol not in ("ANY", "TCP"):
|
|
445
|
+
continue
|
|
446
|
+
if rule.get("enabled") is False:
|
|
447
|
+
continue
|
|
448
|
+
if not address_contains(rule.get("remote_address"), host):
|
|
449
|
+
continue
|
|
450
|
+
saw = True
|
|
451
|
+
blocked.update(_ports_in_spec(rule.get("remote_port")))
|
|
452
|
+
if not saw:
|
|
453
|
+
families_blocked = False
|
|
454
|
+
for candidate in range(1, 65536):
|
|
455
|
+
if candidate == port or candidate in blocked:
|
|
456
|
+
continue
|
|
457
|
+
extras.append("{}:{}".format(host, candidate))
|
|
458
|
+
if len(extras) > 32:
|
|
459
|
+
break
|
|
460
|
+
return (not covers) and families_blocked and len(extras) <= 32, extras
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
_PROTO_NUMBERS = {
|
|
464
|
+
"1": "ICMPv4",
|
|
465
|
+
"6": "TCP",
|
|
466
|
+
"17": "UDP",
|
|
467
|
+
"58": "ICMPv6",
|
|
468
|
+
"256": "ANY",
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def normalize_firewall_rule(item):
|
|
473
|
+
proto = item.get("Protocol", item.get("protocol", ""))
|
|
474
|
+
if isinstance(proto, int):
|
|
475
|
+
proto = str(proto)
|
|
476
|
+
proto = _PROTO_NUMBERS.get(str(proto), str(proto))
|
|
477
|
+
address = item.get("RemoteAddress", item.get("remote_address"))
|
|
478
|
+
if isinstance(address, list):
|
|
479
|
+
address = address[0] if address else None
|
|
480
|
+
remote_port = item.get("RemotePort", item.get("remote_port"))
|
|
481
|
+
if isinstance(remote_port, list):
|
|
482
|
+
remote_port = ",".join(str(part) for part in remote_port)
|
|
483
|
+
enabled = item.get("Enabled", item.get("enabled", True))
|
|
484
|
+
return {
|
|
485
|
+
"name": item.get("Name", item.get("name")),
|
|
486
|
+
"action": item.get("Action", item.get("action", "Block")),
|
|
487
|
+
"protocol": proto,
|
|
488
|
+
"remote_address": address,
|
|
489
|
+
"remote_port": remote_port,
|
|
490
|
+
"enabled": enabled,
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def windows_firewall_report(profiles, rules, port):
|
|
495
|
+
"""gpo, outbound, firewall_ok, extra_tcp_observed from already-parsed objects."""
|
|
496
|
+
gpo = gpo_problem(profiles)
|
|
497
|
+
outbound = default_outbound_problem(profiles)
|
|
498
|
+
cleaned = []
|
|
499
|
+
for rule in rules or []:
|
|
500
|
+
if "remote_address" in rule or "protocol" in rule:
|
|
501
|
+
cleaned.append(rule)
|
|
502
|
+
else:
|
|
503
|
+
cleaned.append(normalize_firewall_rule(rule))
|
|
504
|
+
ok, extras = windows_observed(cleaned, port)
|
|
505
|
+
if gpo or outbound:
|
|
506
|
+
ok = False
|
|
507
|
+
return gpo, outbound, ok, extras
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def windows_profile_script():
|
|
511
|
+
return (
|
|
512
|
+
"Get-NetFirewallProfile | "
|
|
513
|
+
"Select-Object Name, AllowLocalFirewallRules, AllowLocalPolicyMerge, DefaultOutboundAction | "
|
|
514
|
+
"ConvertTo-Json -Compress"
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def windows_rule_script(sid):
|
|
519
|
+
"""Outbound Block rules for this SID. LocalUser lives on the security filter."""
|
|
520
|
+
if not sid or any(ch not in "S-0123456789" for ch in sid) or not sid.startswith("S-1-"):
|
|
521
|
+
raise AgentLockError("could not read the agentnet SID")
|
|
522
|
+
return (
|
|
523
|
+
"Get-NetFirewallRule -Direction Outbound -Action Block -Enabled True | "
|
|
524
|
+
"Where-Object { [string](($_ | Get-NetFirewallSecurityFilter).LocalUser) -like '*"
|
|
525
|
+
+ sid + "*' } | ForEach-Object { "
|
|
526
|
+
"$port = $_ | Get-NetFirewallPortFilter; "
|
|
527
|
+
"$addr = $_ | Get-NetFirewallAddressFilter; "
|
|
528
|
+
"[PSCustomObject]@{ Name=$_.Name; Action='Block'; Protocol=$port.Protocol; "
|
|
529
|
+
"RemoteAddress=$addr.RemoteAddress; RemotePort=$port.RemotePort; Enabled=$true } "
|
|
530
|
+
"} | ConvertTo-Json -Compress"
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def windows_user_script():
|
|
535
|
+
"""Name and Enabled. Does not read group membership."""
|
|
536
|
+
return (
|
|
537
|
+
"Get-LocalUser -Name 'agentnet' -ErrorAction SilentlyContinue | "
|
|
538
|
+
"Select-Object Name, Enabled | ConvertTo-Json -Compress\n"
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def windows_path_executable(text):
|
|
543
|
+
"""True when an icacls ACE for the agentnet account grants execute.
|
|
544
|
+
|
|
545
|
+
The account is `agentnet:`, not the word anywhere in the path. A path
|
|
546
|
+
that merely contains that word must not inherit another user's (F).
|
|
547
|
+
"""
|
|
548
|
+
pattern = re.compile(r"(?:^|[\s\\.])agentnet:((?:\([^)]*\))+)", re.IGNORECASE)
|
|
549
|
+
for line in (text or "").splitlines():
|
|
550
|
+
match = pattern.search(line)
|
|
551
|
+
if not match:
|
|
552
|
+
continue
|
|
553
|
+
grant = match.group(1)
|
|
554
|
+
if "(F)" in grant or "(M)" in grant:
|
|
555
|
+
return True
|
|
556
|
+
if re.search(r"\([^)]*X", grant):
|
|
557
|
+
return True
|
|
558
|
+
return False
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _ps_single(text):
|
|
562
|
+
return "'" + str(text).replace("'", "''") + "'"
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def parse_locked_batch(text):
|
|
566
|
+
"""Exit codes and captured streams from the agentnet probe batch."""
|
|
567
|
+
found = {}
|
|
568
|
+
for line in (text or "").splitlines():
|
|
569
|
+
if "=" not in line:
|
|
570
|
+
continue
|
|
571
|
+
key, value = line.split("=", 1)
|
|
572
|
+
found[key.strip()] = value
|
|
573
|
+
|
|
574
|
+
def decode(name):
|
|
575
|
+
raw = found.get(name, "")
|
|
576
|
+
if not raw:
|
|
577
|
+
return ""
|
|
578
|
+
try:
|
|
579
|
+
return base64.b64decode(raw).decode("utf-8", "replace")
|
|
580
|
+
except (ValueError, TypeError):
|
|
581
|
+
return ""
|
|
582
|
+
|
|
583
|
+
def code(name):
|
|
584
|
+
raw = found.get(name, "").strip()
|
|
585
|
+
if raw.lstrip("-").isdigit():
|
|
586
|
+
return int(raw)
|
|
587
|
+
return None
|
|
588
|
+
|
|
589
|
+
return {
|
|
590
|
+
"probe_code": code("PROBE_CODE"),
|
|
591
|
+
"probe_stdout": decode("PROBE_OUT_B64"),
|
|
592
|
+
"probe_stderr": decode("PROBE_ERR_B64"),
|
|
593
|
+
"proxy_code": code("PROXY_CODE"),
|
|
594
|
+
"proxy_stderr": decode("PROXY_ERR_B64"),
|
|
595
|
+
"noproxy_code": code("NOPROXY_CODE"),
|
|
596
|
+
"noproxy_stderr": decode("NOPROXY_ERR_B64"),
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def windows_locked_command(sid, python, probe_source, proxy_argv, noproxy_argv):
|
|
601
|
+
"""One PowerShell process that runs the probe and both curls as agentnet.
|
|
602
|
+
|
|
603
|
+
agent-lock.cmd exits 126 unless the caller is already that SID, so doctor
|
|
604
|
+
does not invoke it. When this process is already agentnet the payload
|
|
605
|
+
starts directly. Otherwise one Get-Credential logon starts the child;
|
|
606
|
+
the password is not written to state or to the command line. Exit 126
|
|
607
|
+
is not a result of this script.
|
|
608
|
+
|
|
609
|
+
The probe is `python -` with the source on that child's stdin. A script
|
|
610
|
+
file in the installing user's temp directory is not readable by agentnet.
|
|
611
|
+
"""
|
|
612
|
+
if not sid or any(ch not in "S-0123456789" for ch in sid) or not sid.startswith("S-1-"):
|
|
613
|
+
raise AgentLockError("could not read the agentnet SID")
|
|
614
|
+
if not proxy_argv or not noproxy_argv or not python or not probe_source:
|
|
615
|
+
raise AgentLockError("locked probe was not run as agentnet")
|
|
616
|
+
|
|
617
|
+
def arguments(argv):
|
|
618
|
+
return " ".join('"' + str(part).replace('"', '\\"') + '"' for part in argv[1:])
|
|
619
|
+
|
|
620
|
+
probe_args = arguments([python, "-"])
|
|
621
|
+
proxy_args = arguments(proxy_argv)
|
|
622
|
+
direct_args = arguments(noproxy_argv)
|
|
623
|
+
lines = [
|
|
624
|
+
"$ErrorActionPreference = 'Continue'",
|
|
625
|
+
"$sid = " + _ps_single(sid),
|
|
626
|
+
"$me = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value",
|
|
627
|
+
"$cred = $null",
|
|
628
|
+
"if ($me -ne $sid) {",
|
|
629
|
+
" if (-not [Environment]::UserInteractive) { exit 127 }",
|
|
630
|
+
" $cred = Get-Credential -UserName '.\\agentnet' -Message 'agent-lock doctor'",
|
|
631
|
+
" if (-not $cred) { exit 127 }",
|
|
632
|
+
"}",
|
|
633
|
+
"function Invoke-Locked([string]$Name, [string]$File, [string]$Arguments, [string]$Stdin) {",
|
|
634
|
+
" $psi = New-Object System.Diagnostics.ProcessStartInfo",
|
|
635
|
+
" $psi.FileName = $File",
|
|
636
|
+
" $psi.Arguments = $Arguments",
|
|
637
|
+
" $psi.UseShellExecute = $false",
|
|
638
|
+
" $psi.RedirectStandardOutput = $true",
|
|
639
|
+
" $psi.RedirectStandardError = $true",
|
|
640
|
+
" if ($Stdin) { $psi.RedirectStandardInput = $true }",
|
|
641
|
+
" if ($me -ne $sid) {",
|
|
642
|
+
" $psi.UserName = 'agentnet'",
|
|
643
|
+
" $psi.Domain = '.'",
|
|
644
|
+
" $psi.Password = $cred.Password",
|
|
645
|
+
" $psi.LoadUserProfile = $true",
|
|
646
|
+
" }",
|
|
647
|
+
" $proc = [System.Diagnostics.Process]::Start($psi)",
|
|
648
|
+
" if ($Stdin) {",
|
|
649
|
+
" $proc.StandardInput.Write($Stdin)",
|
|
650
|
+
" $proc.StandardInput.Close()",
|
|
651
|
+
" }",
|
|
652
|
+
" $out = $proc.StandardOutput.ReadToEnd()",
|
|
653
|
+
" $err = $proc.StandardError.ReadToEnd()",
|
|
654
|
+
" $proc.WaitForExit()",
|
|
655
|
+
" $outB = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([string]$out))",
|
|
656
|
+
" $errB = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([string]$err))",
|
|
657
|
+
" Write-Output ($Name + '_CODE=' + $proc.ExitCode)",
|
|
658
|
+
" Write-Output ($Name + '_OUT_B64=' + $outB)",
|
|
659
|
+
" Write-Output ($Name + '_ERR_B64=' + $errB)",
|
|
660
|
+
"}",
|
|
661
|
+
"Invoke-Locked 'PROBE' " + _ps_single(python) + " " + _ps_single(probe_args) + " " + _ps_single(probe_source),
|
|
662
|
+
"Invoke-Locked 'PROXY' " + _ps_single(proxy_argv[0]) + " " + _ps_single(proxy_args) + " ''",
|
|
663
|
+
"Invoke-Locked 'NOPROXY' " + _ps_single(noproxy_argv[0]) + " " + _ps_single(direct_args) + " ''",
|
|
664
|
+
"exit 0",
|
|
665
|
+
]
|
|
666
|
+
script = "\n".join(lines) + "\n"
|
|
667
|
+
return ["powershell", "-NoProfile", "-Command", script]
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def windows_admin_script():
|
|
671
|
+
return (
|
|
672
|
+
"$hit = Get-LocalGroupMember -Group 'Administrators' -ErrorAction SilentlyContinue | "
|
|
673
|
+
"Where-Object { $_.Name -eq 'agentnet' -or $_.Name -like '*\\agentnet' }\n"
|
|
674
|
+
"if ($hit) { 'yes' } else { 'no' }\n"
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def new_local_user_script():
|
|
679
|
+
"""Create the account. Does not add Administrators and does not save the password."""
|
|
680
|
+
return (
|
|
681
|
+
"$pw = Read-Host -AsSecureString -Prompt 'agentnet password'\n"
|
|
682
|
+
"New-LocalUser -Name '{user}' -Password $pw -PasswordNeverExpires "
|
|
683
|
+
"-UserMayNotChangePassword:$false | Out-Null\n"
|
|
684
|
+
).format(user=AGENT_USER)
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def render_rule_script(rules):
|
|
688
|
+
"""PowerShell that creates Block rules. RemotePort is omitted when empty."""
|
|
689
|
+
lines = []
|
|
690
|
+
for rule in rules:
|
|
691
|
+
parts = [
|
|
692
|
+
"New-NetFirewallRule",
|
|
693
|
+
"-Name '{}'".format(rule["name"]),
|
|
694
|
+
"-DisplayName '{}'".format(rule["name"]),
|
|
695
|
+
"-Direction {}".format(rule["direction"]),
|
|
696
|
+
"-Action {}".format(rule["action"]),
|
|
697
|
+
"-Protocol {}".format(rule["protocol"]),
|
|
698
|
+
"-RemoteAddress '{}'".format(rule["remote_address"]),
|
|
699
|
+
"-Profile {}".format(rule["profile"]),
|
|
700
|
+
"-Enabled True",
|
|
701
|
+
"-LocalUser '{}'".format(rule["local_user"]),
|
|
702
|
+
]
|
|
703
|
+
if rule.get("remote_port"):
|
|
704
|
+
parts.append("-RemotePort '{}'".format(rule["remote_port"]))
|
|
705
|
+
lines.append(" ".join(parts))
|
|
706
|
+
return "\n".join(lines) + ("\n" if lines else "")
|