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