aegis-rex 1.5.1

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.
package/vendor/rex.py ADDED
@@ -0,0 +1,2885 @@
1
+ #!/usr/bin/env python3
2
+ # ============================================================
3
+ # ÆGIS Security Audit — rex.py
4
+ # Cross-platform: Windows · macOS · Linux
5
+ # ============================================================
6
+ #
7
+ # Resilience contract: rex is an audit tool that must never abort a whole run
8
+ # because one probe failed. Each check is therefore allowed to swallow its own
9
+ # failure and report it as a finding instead of raising. That intent is why
10
+ # `except Exception` (BLE001), the deliberate `pass`/`continue` handlers
11
+ # (S110/S112) and the local-time stamps (DTZ) below carry targeted noqa
12
+ # directives rather than being narrowed to exception classes — narrowing them
13
+ # would convert a reported finding into an unhandled traceback.
14
+
15
+ import datetime
16
+ import json
17
+ import math
18
+ import os
19
+ import platform
20
+ import random
21
+ import re
22
+ import shlex
23
+ import shutil
24
+ import socket
25
+ import stat
26
+ import subprocess
27
+ import sys
28
+ import urllib.error
29
+ import urllib.request
30
+ from concurrent.futures import ThreadPoolExecutor, as_completed
31
+
32
+ try:
33
+ import psutil
34
+ except ImportError:
35
+ # Agent/CI containers routinely ship a bare python3. psutil is only
36
+ # needed by 4 of the 16 audit sections, so a missing lib must not
37
+ # make the CLI or the JSON contract unimportable.
38
+ psutil = None
39
+
40
+ # ── Qt is optional ───────────────────────────────────────────
41
+ # rex is two things: a GUI, and an audit/fix engine. An AI agent running in
42
+ # a container has no display and no PyQt6, so a missing Qt must not be fatal
43
+ # at import time — `import rex`, the CLI, and the JSON contract all have to
44
+ # work on a bare python3. A hard sys.exit(1) here would make rex undriveable
45
+ # by exactly the thing it is supposed to be driveable by.
46
+ QT_MISSING_MSG = (
47
+ "PyQt6 is required for the GUI.\n"
48
+ "Install it with: pip install PyQt6 psutil\n"
49
+ "Or drive rex headlessly: rex.py --audit --json "
50
+ "(see rex.py --capabilities)"
51
+ )
52
+
53
+ HAS_QT = False
54
+ try:
55
+ from PyQt6.QtCore import Qt, QThread, QTimer, pyqtSignal
56
+ from PyQt6.QtGui import QColor, QPainter, QPalette, QPen
57
+ from PyQt6.QtWidgets import (
58
+ QApplication,
59
+ QDialog,
60
+ QFileDialog,
61
+ QFrame,
62
+ QHBoxLayout,
63
+ QLabel,
64
+ QLineEdit,
65
+ QListWidget,
66
+ QListWidgetItem,
67
+ QMainWindow,
68
+ QProgressBar,
69
+ QPushButton,
70
+ QTextEdit,
71
+ QVBoxLayout,
72
+ QWidget,
73
+ )
74
+ HAS_QT = True
75
+ except ImportError:
76
+ # Placeholders. The GUI classes below still *parse and import*, so that
77
+ # one file can serve both front-ends; they raise the moment anything
78
+ # actually tries to build a window.
79
+ class _QtUnavailable:
80
+ def __init__(self, *a, **k):
81
+ raise RuntimeError(QT_MISSING_MSG)
82
+
83
+ QApplication = QMainWindow = QWidget = QVBoxLayout = QHBoxLayout = \
84
+ QPushButton = QLabel = QTextEdit = QFrame = QListWidget = \
85
+ QListWidgetItem = QProgressBar = QFileDialog = QDialog = QLineEdit = \
86
+ QThread = QTimer = _QtUnavailable
87
+
88
+ class _InertSignal:
89
+ """pyqtSignal stand-in: connect()/emit() are no-ops, not AttributeError."""
90
+ def connect(self, *a, **k):
91
+ pass
92
+
93
+ def emit(self, *a, **k):
94
+ pass
95
+
96
+ def pyqtSignal(*a, **k):
97
+ return _InertSignal()
98
+
99
+ class _Permissive:
100
+ """Absorbs Qt.* / QColor(...) attribute chains harmlessly."""
101
+ def __getattr__(self, name):
102
+ return _Permissive()
103
+
104
+ def __call__(self, *a, **k):
105
+ return _Permissive()
106
+
107
+ Qt = QColor = QPen = QPalette = QPainter = _Permissive()
108
+
109
+
110
+ # ============================================================
111
+ # PLATFORM DETECTION
112
+ # ============================================================
113
+ _OS = platform.system()
114
+ IS_WIN = _OS == "Windows"
115
+ IS_MAC = _OS == "Darwin"
116
+ IS_LINUX = _OS == "Linux"
117
+
118
+ # ============================================================
119
+ # KONFIGURATION
120
+ # ============================================================
121
+ VERSION = "1.5.1"
122
+ APP_NAME = "ÆGIS Security Audit"
123
+
124
+ # Lines emitted by find(1)/stat(1) *about* a path rather than *as a result*.
125
+ # _cmd() merges stderr into stdout, so an unreadable directory arrives mixed in
126
+ # with real hits — without this split, "Permission denied" is counted as a
127
+ # world-writable file that was never actually found.
128
+ _FIND_NOISE = re.compile(
129
+ r"^(?:find|stat|ls|du|grep|getfacl)\s*:\s", re.IGNORECASE
130
+ )
131
+
132
+ # ============================================================
133
+ # AI PROVIDERS
134
+ # ============================================================
135
+ # DeepSeek exposes an OpenAI-compatible /chat/completions endpoint.
136
+ DEEPSEEK_URL = "https://api.deepseek.com/v1"
137
+ DEEPSEEK_MODEL = "deepseek-flash"
138
+
139
+ # Remediation replies are a handful of shell commands, but the DeepSeek
140
+ # reasoning models bill hidden chain-of-thought against max_tokens — a small
141
+ # budget can be consumed entirely by reasoning and return an empty answer.
142
+ AI_MAX_TOKENS = 8192
143
+
144
+ # Single source of truth for the AI-provider dropdown: the combo index must
145
+ # match the position of the provider id here, and the saved config value.
146
+ PROVIDER_IDS = ["ollama", "claude", "deepseek"]
147
+
148
+ COLORS = {
149
+ "bg": "#07070f",
150
+ "bg2": "#0d0d1a",
151
+ "bg3": "#12121f",
152
+ "border": "#1a1a2e",
153
+ "cyan": "#00e5c0",
154
+ "cyan_dim": "#00b89a",
155
+ "text": "#e0e0f0",
156
+ "text2": "#9090b0",
157
+ "text3": "#505070",
158
+ "green": "#39ff14",
159
+ "red": "#ff4444",
160
+ "orange": "#ffaa00",
161
+ "white": "#ffffff",
162
+ }
163
+
164
+ AUDIT_SECTIONS = [
165
+ "System Info",
166
+ "CPU & Memory",
167
+ "Disk",
168
+ "Network",
169
+ "Open Ports",
170
+ "Running Processes",
171
+ "Startup Services",
172
+ "Firewall",
173
+ "Users & Groups",
174
+ "Sudo / Privileges",
175
+ "SSH Config",
176
+ "Scheduled Tasks",
177
+ "SUID/SGID Files",
178
+ "World-Writable Files",
179
+ "Environment Secrets",
180
+ "Sensitive File Permissions",
181
+ ]
182
+
183
+ VIRUS_SECTION = "Virus Scan (ClamAV)"
184
+
185
+ PSUTIL_MISSING = (
186
+ "psutil not installed - CPU & Memory, Disk, Network and Running "
187
+ "Processes cannot be assessed.\nInstall: pip install psutil"
188
+ )
189
+ SECTIONS = AUDIT_SECTIONS + [VIRUS_SECTION]
190
+
191
+ CONFIG_PATH = os.path.join(os.path.expanduser("~"), ".aegis_config.json")
192
+
193
+ def _load_config() -> dict:
194
+ try:
195
+ with open(CONFIG_PATH) as f:
196
+ return json.load(f)
197
+ except Exception: # noqa: BLE001
198
+ return {}
199
+
200
+ def _save_config(data: dict):
201
+ try:
202
+ existing = _load_config()
203
+ existing.update(data)
204
+ with open(CONFIG_PATH, "w") as f:
205
+ json.dump(existing, f, indent=2)
206
+ if not IS_WIN:
207
+ os.chmod(CONFIG_PATH, 0o600)
208
+ except Exception: # noqa: BLE001, S110
209
+ pass
210
+
211
+ # ============================================================
212
+ # LATTICE BAKGRUND (diamond lattice — upgraded)
213
+ # ============================================================
214
+ class LatticeWidget(QWidget):
215
+ """Diamond lattice with breathing lines, traveling spark particles,
216
+ and drifting intersection nodes."""
217
+
218
+ _SPACING = 40
219
+
220
+ def __init__(self, parent=None):
221
+ super().__init__(parent)
222
+ self._offset = 0.0
223
+ self._frame = 0
224
+ # sparks: [progress 0→1, line_index, direction +1/-1]
225
+ self._sparks: list = []
226
+ self._timer = QTimer(self)
227
+ self._timer.timeout.connect(self._tick)
228
+ self._timer.start(33) # ~30 FPS
229
+
230
+ def _tick(self):
231
+ s = self._SPACING
232
+ self._offset = (self._offset + 0.9) % s
233
+ self._frame += 1
234
+ # Spawn a new spark roughly every 55 frames (max 5 simultaneous)
235
+ if self._frame % 55 == 0 and len(self._sparks) < 5:
236
+ w = max(self.width(), 1)
237
+ h = max(self.height(), 1)
238
+ n_lines = (w + h * 2) // s + 4
239
+ self._sparks.append([0.0, random.randint(0, n_lines - 1),
240
+ random.choice([-1, 1])])
241
+ # Advance sparks; cull finished ones
242
+ self._sparks = [[p + 0.011, i, d] for p, i, d in self._sparks if p < 1.0]
243
+ self.update()
244
+
245
+ def paintEvent(self, event):
246
+ super().paintEvent(event)
247
+ p = QPainter(self)
248
+ p.setRenderHint(QPainter.RenderHint.Antialiasing)
249
+ w, h = self.width(), self.height()
250
+ s = self._SPACING
251
+ off = self._offset
252
+ frame = self._frame
253
+
254
+ origins = list(range(-w - int(h * 1.5), w + int(h * 1.5), s))
255
+
256
+ # ── Per-line breathing alpha (sine wave, each line offset) ───────────
257
+ for idx, i in enumerate(origins):
258
+ a1 = max(2, int(5 + 5 * math.sin(frame * 0.018 + idx * 0.45)))
259
+ a2 = max(2, int(5 + 5 * math.sin(frame * 0.018 + idx * 0.45 + 1.1)))
260
+ p.setPen(QPen(QColor(0, 229, 192, a1), 1))
261
+ p.drawLine(int(i + off), 0, int(i + h + off), h)
262
+ p.setPen(QPen(QColor(0, 229, 192, a2), 1))
263
+ p.drawLine(int(i - off), h, int(i + h - off), 0)
264
+
265
+ # ── Drifting intersection nodes ──────────────────────────────────────
266
+ dot_off = int(off) % s
267
+ p.setPen(Qt.PenStyle.NoPen)
268
+ for nx in range(-s + dot_off, w + s, s):
269
+ for ny in range(-s + dot_off, h + s, s):
270
+ p.setBrush(QColor(0, 229, 192, 20))
271
+ p.drawEllipse(nx - 1, ny - 1, 3, 3)
272
+
273
+ # ── Traveling spark particles ────────────────────────────────────────
274
+ for progress, line_idx, direction in self._sparks:
275
+ if line_idx >= len(origins):
276
+ continue
277
+ i = origins[line_idx]
278
+ t = progress
279
+ if direction == 1: # \ direction
280
+ sx, sy = int(i + off + t * h), int(t * h)
281
+ else: # / direction
282
+ sx, sy = int(i + h - off - t * h), int(t * h)
283
+ if not (0 <= sx <= w and 0 <= sy <= h):
284
+ continue
285
+ # Layered glow: outer halo → bright core
286
+ for radius, alpha in ((10, 6), (5, 25), (2, 110), (1, 220)):
287
+ p.setPen(Qt.PenStyle.NoPen)
288
+ p.setBrush(QColor(0, 229, 192, alpha))
289
+ p.drawEllipse(sx - radius, sy - radius, radius * 2, radius * 2)
290
+
291
+ p.end()
292
+
293
+
294
+ # ============================================================
295
+ # AUDIT WORKER (bakgrundstråd)
296
+ # ============================================================
297
+ class AuditEngine:
298
+ """The audit itself — no Qt anywhere.
299
+
300
+ Split out of AuditWorker so the same section code runs in the GUI, in the
301
+ CLI, and under `import rex`. One engine means a model applying fixes sees
302
+ byte-identical findings to the ones the GUI shows: no second implementation
303
+ to drift.
304
+ """
305
+
306
+ def __init__(self, sections=None, scan_path=None):
307
+ self.sections = list(sections) if sections else list(SECTIONS)
308
+ if scan_path is None:
309
+ scan_path = os.path.expanduser("~")
310
+ self.scan_path = os.path.realpath(scan_path)
311
+ self._stop = False
312
+ # ClamAV progress hook. The GUI bridges this to its Qt signal; headless
313
+ # callers pass their own callback (or nothing).
314
+ self._on_progress = lambda label, count: None
315
+
316
+ def stop(self):
317
+ self._stop = True
318
+
319
+ def _run_safe(self, section):
320
+ try:
321
+ return self._run_section(section)
322
+ except Exception as e: # noqa: BLE001
323
+ return "error", str(e)
324
+
325
+ def collect(self, sections=None, progress=None):
326
+ """Run sections, return {section: (status, output)}. Blocking, no Qt."""
327
+ if progress is not None:
328
+ self._on_progress = progress
329
+ out = {}
330
+ for section in (sections if sections is not None else self.sections):
331
+ if self._stop:
332
+ break
333
+ out[section] = self._run_safe(section)
334
+ return out
335
+
336
+ def collect_parallel(self, sections=None, workers=8):
337
+ """Same result, threaded. Order-independent — keyed by section."""
338
+ todo = list(sections if sections is not None else self.sections)
339
+ out = {}
340
+ with ThreadPoolExecutor(max_workers=min(workers, max(1, len(todo)))) as ex:
341
+ futures = {ex.submit(self._run_safe, s): s for s in todo if not self._stop}
342
+ for future in as_completed(futures):
343
+ section = futures[future]
344
+ try:
345
+ out[section] = future.result()
346
+ except Exception as e: # noqa: BLE001
347
+ out[section] = ("error", str(e))
348
+ return out
349
+
350
+ # ── helpers ──────────────────────────────────────────────
351
+ @staticmethod
352
+ def _cmd(args, timeout=10):
353
+ """Safe command runner — no shell=True."""
354
+ if isinstance(args, str):
355
+ args = shlex.split(args)
356
+ try:
357
+ r = subprocess.run(
358
+ args,
359
+ shell=False,
360
+ check=False,
361
+ capture_output=True,
362
+ text=True,
363
+ timeout=timeout,
364
+ )
365
+ return (r.stdout + r.stderr).strip()
366
+ except FileNotFoundError:
367
+ return "[not found]"
368
+ except subprocess.TimeoutExpired:
369
+ return "[timeout]"
370
+ except Exception as e: # noqa: BLE001
371
+ return f"[error: {e}]"
372
+
373
+ @staticmethod
374
+ def _split_noise(out):
375
+ """Split merged command output into (findings, diagnostics).
376
+
377
+ Returns the lines that are actual results and the lines that merely
378
+ report a skipped path. Callers decide status from the findings only.
379
+ """
380
+ findings, noise = [], []
381
+ for line in (out or "").splitlines():
382
+ if _FIND_NOISE.match(line.strip()):
383
+ noise.append(line.strip())
384
+ elif line.strip():
385
+ findings.append(line)
386
+ return findings, noise
387
+
388
+ @staticmethod
389
+ def _na(feature):
390
+ return "ok", f"[N/A on {_OS}] {feature} is not applicable on this platform."
391
+
392
+ # ── section dispatcher ────────────────────────────────────
393
+ def _run_section(self, section):
394
+ cmd = self._cmd
395
+
396
+ # ── System Info (all platforms) ───────────────────────
397
+ if section == "System Info":
398
+ lines = [
399
+ f"Hostname : {socket.gethostname()}",
400
+ f"OS : {platform.platform()}",
401
+ f"Kernel : {platform.release()}",
402
+ f"Arch : {platform.machine()}",
403
+ f"Python : {platform.python_version()}",
404
+ f"User : {os.getenv('USERNAME') or os.getenv('USER', 'unknown')}",
405
+ f"Date/Time : {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", # noqa: DTZ005
406
+ ]
407
+ return "ok", "\n".join(lines)
408
+
409
+ # ── CPU & Memory (all platforms via psutil) ───────────
410
+ elif section == "CPU & Memory":
411
+ if psutil is None:
412
+ # Absence of evidence, not a finding: the check never ran.
413
+ return "error", PSUTIL_MISSING
414
+ cpu_pct = psutil.cpu_percent(interval=1)
415
+ cpu_cnt = psutil.cpu_count(logical=True)
416
+ mem = psutil.virtual_memory()
417
+ swap = psutil.swap_memory()
418
+ lines = [
419
+ f"CPU Usage : {cpu_pct}%",
420
+ f"CPU Cores : {cpu_cnt}",
421
+ f"RAM Total : {mem.total // (1024**2)} MB",
422
+ f"RAM Used : {mem.used // (1024**2)} MB ({mem.percent}%)",
423
+ f"RAM Free : {mem.available // (1024**2)} MB",
424
+ f"Swap Total : {swap.total // (1024**2)} MB",
425
+ f"Swap Used : {swap.used // (1024**2)} MB ({swap.percent}%)",
426
+ ]
427
+ status = "warn" if mem.percent > 85 or swap.percent > 50 else "ok"
428
+ return status, "\n".join(lines)
429
+
430
+ # ── Disk (all platforms via psutil) ───────────────────
431
+ elif section == "Disk":
432
+ if psutil is None:
433
+ # Absence of evidence, not a finding: the check never ran.
434
+ return "error", PSUTIL_MISSING
435
+ out = []
436
+ warn = False
437
+ for part in psutil.disk_partitions():
438
+ try:
439
+ usage = psutil.disk_usage(part.mountpoint)
440
+ pct = usage.percent
441
+ out.append(
442
+ f"{part.device} → {part.mountpoint} "
443
+ f"{usage.used // (1024**3)}G / {usage.total // (1024**3)}G ({pct}%)"
444
+ )
445
+ if pct > 90:
446
+ warn = True
447
+ except Exception: # noqa: BLE001, S110
448
+ pass
449
+ return ("warn" if warn else "ok"), "\n".join(out) or "No partitions found"
450
+
451
+ # ── Network (all platforms via psutil) ────────────────
452
+ elif section == "Network":
453
+ if psutil is None:
454
+ # Absence of evidence, not a finding: the check never ran.
455
+ return "error", PSUTIL_MISSING
456
+ addrs = psutil.net_if_addrs()
457
+ stats = psutil.net_if_stats()
458
+ out = []
459
+ for iface, addr_list in addrs.items():
460
+ st = stats.get(iface)
461
+ up = "UP" if st and st.isup else "DOWN"
462
+ for a in addr_list:
463
+ if a.family == socket.AF_INET:
464
+ out.append(f"{iface} [{up}] {a.address}")
465
+ return "ok", "\n".join(out) or "No network interfaces"
466
+
467
+ # ── Open Ports ────────────────────────────────────────
468
+ elif section == "Open Ports":
469
+ suspicious = ["4444", "1337", "31337", "6666", "9999"]
470
+ if IS_WIN:
471
+ out = cmd(["netstat", "-ano"], timeout=10)
472
+ elif IS_MAC:
473
+ out = cmd(["netstat", "-an", "-p", "tcp"], timeout=10)
474
+ if not out or "[not found]" in out:
475
+ out = cmd(["lsof", "-iTCP", "-sTCP:LISTEN", "-n", "-P"], timeout=10)
476
+ else: # Linux
477
+ out = cmd(["ss", "-tlnp"], timeout=8)
478
+ if not out or "[not found]" in out:
479
+ out = cmd(["netstat", "-tlnp"], timeout=8)
480
+ status = "warn" if any(p in out for p in suspicious) else "ok"
481
+ return status, out or "No open ports found"
482
+
483
+ # ── Running Processes (psutil — all platforms) ────────
484
+ elif section == "Running Processes":
485
+ if psutil is None:
486
+ # Absence of evidence, not a finding: the check never ran.
487
+ return "error", PSUTIL_MISSING
488
+ procs = []
489
+ for p in psutil.process_iter(["pid", "name", "username", "cpu_percent"]):
490
+ try:
491
+ procs.append(
492
+ f"{p.info['pid']:>6} {p.info['username'] or ''!s:<15} {p.info['name']}"
493
+ )
494
+ except Exception: # noqa: BLE001, S110
495
+ pass
496
+ suffix = "\n[truncated...]" if len(procs) > 60 else ""
497
+ return "ok", "\n".join(procs[:60]) + suffix
498
+
499
+ # ── Startup Services ──────────────────────────────────
500
+ elif section == "Startup Services":
501
+ if IS_WIN:
502
+ out = cmd(
503
+ ["sc", "query", "type=", "all", "state=", "running"],
504
+ timeout=12,
505
+ )
506
+ if not out or "[not found]" in out:
507
+ out = cmd(
508
+ ["powershell", "-NoProfile", "-Command",
509
+ ("Get-Service | Where-Object {$_.StartType -eq 'Automatic'} | "
510
+ "Select-Object Name,Status | Format-Table -AutoSize")],
511
+ timeout=15,
512
+ )
513
+ elif IS_MAC:
514
+ out = cmd(["launchctl", "list"], timeout=10)
515
+ if out:
516
+ out = "\n".join(out.splitlines()[:50])
517
+ else:
518
+ out = cmd(
519
+ ["systemctl", "list-unit-files", "--type=service", "--state=enabled"],
520
+ timeout=10,
521
+ )
522
+ if out:
523
+ out = "\n".join(out.splitlines()[:40])
524
+ return "ok", out or "Could not retrieve startup services"
525
+
526
+ # ── Firewall ──────────────────────────────────────────
527
+ elif section == "Firewall":
528
+ if IS_WIN:
529
+ out = cmd(
530
+ ["netsh", "advfirewall", "show", "allprofiles"],
531
+ timeout=10,
532
+ )
533
+ status = "ok"
534
+ if "State OFF" in out:
535
+ status = "warn"
536
+ return status, out or "Could not query Windows Firewall"
537
+
538
+ elif IS_MAC:
539
+ # Application-level firewall (socketfilterfw)
540
+ fw_bin = "/usr/libexec/ApplicationFirewall/socketfilterfw"
541
+ if os.path.exists(fw_bin):
542
+ state = cmd([fw_bin, "--getglobalstate"], timeout=6)
543
+ blocks = cmd([fw_bin, "--getblockall"], timeout=6)
544
+ stealth = cmd([fw_bin, "--getstealthmode"], timeout=6)
545
+ out = "\n".join([
546
+ f"Global state : {state}",
547
+ f"Block all : {blocks}",
548
+ f"Stealth mode : {stealth}",
549
+ ])
550
+ status = "warn" if "disabled" in state.lower() else "ok"
551
+ return status, out
552
+ # Fallback: pf
553
+ out = cmd(["pfctl", "-s", "rules"], timeout=8)
554
+ return "ok", out or "pf rules empty or permission denied"
555
+
556
+ else: # Linux
557
+ ufw = cmd(["ufw", "status", "verbose"], timeout=8)
558
+ if "inactive" in ufw.lower() or not ufw or "[not found]" in ufw or "[error" in ufw:
559
+ iptables = cmd(["iptables", "-L", "-n", "--line-numbers"], timeout=8)
560
+ if iptables:
561
+ iptables = "\n".join(iptables.splitlines()[:30])
562
+ status = "warn" if ufw and "inactive" in ufw.lower() else "ok"
563
+ return status, (ufw + "\n\niptables:\n" + iptables).strip()
564
+ return "ok", ufw
565
+
566
+ # ── Users & Groups ────────────────────────────────────
567
+ elif section == "Users & Groups":
568
+ if IS_WIN:
569
+ out = cmd(["net", "user"], timeout=8)
570
+ return "ok", out or "Could not list users"
571
+
572
+ elif IS_MAC:
573
+ out = cmd(["dscl", ".", "-list", "/Users"], timeout=8)
574
+ if out and "[not found]" not in out:
575
+ # filter hidden system users (start with _)
576
+ filtered = [line for line in out.splitlines() if not line.startswith("_")]
577
+ return "ok", "\n".join(filtered)
578
+ # fallback
579
+ try:
580
+ lines = []
581
+ with open("/etc/passwd") as f:
582
+ for line in f:
583
+ parts = line.strip().split(":")
584
+ if len(parts) >= 7 and parts[6] not in ("/usr/bin/false", "/sbin/nologin"):
585
+ lines.append(f"{parts[0]:<20} uid={parts[2]:<6} home={parts[5]}")
586
+ return "ok", "\n".join(lines) or "No login users found"
587
+ except Exception as e: # noqa: BLE001
588
+ return "error", str(e)
589
+
590
+ else: # Linux
591
+ try:
592
+ lines = []
593
+ with open("/etc/passwd") as f:
594
+ for line in f:
595
+ parts = line.strip().split(":")
596
+ if len(parts) >= 7 and parts[6] not in (
597
+ "/usr/sbin/nologin", "/bin/false", "/sbin/nologin"
598
+ ):
599
+ lines.append(f"{parts[0]:<20} uid={parts[2]:<6} home={parts[5]}")
600
+ return "ok", "\n".join(lines) or "No login users found"
601
+ except Exception as e: # noqa: BLE001
602
+ return "error", f"Could not read /etc/passwd: {e}"
603
+
604
+ # ── Sudo / Privileges ─────────────────────────────────
605
+ elif section == "Sudo / Privileges":
606
+ if IS_WIN:
607
+ # Show token privileges and whether we're in an elevated context
608
+ out = cmd(["whoami", "/all"], timeout=8)
609
+ if not out or "[not found]" in out:
610
+ out = cmd(["whoami", "/priv"], timeout=8)
611
+ status = "warn" if "SeDebugPrivilege" in out or "Enabled" in out else "ok"
612
+ return status, out or "Could not query privileges"
613
+
614
+ else: # Linux + macOS
615
+ out = cmd(["sudo", "-l"], timeout=8)
616
+ if not out or "[error" in out or "[not found]" in out:
617
+ return "warn", "Could not retrieve sudo rules (sudo -l failed)"
618
+ status = "warn" if "NOPASSWD" in out else "ok"
619
+ return status, out
620
+
621
+ # ── SSH Config ────────────────────────────────────────
622
+ elif section == "SSH Config":
623
+ if IS_WIN:
624
+ paths = [
625
+ r"C:\ProgramData\ssh\sshd_config",
626
+ os.path.expandvars(r"%WINDIR%\System32\OpenSSH\sshd_config"),
627
+ ]
628
+ else:
629
+ paths = ["/etc/ssh/sshd_config"]
630
+
631
+ cfg_path = next((p for p in paths if os.path.exists(p)), None)
632
+ if cfg_path is None:
633
+ return "ok", "sshd_config not found — SSH server may not be installed"
634
+ try:
635
+ with open(cfg_path) as f:
636
+ raw = f.read()
637
+ active = [line for line in raw.splitlines() if line.strip() and not line.strip().startswith("#")]
638
+ out = "\n".join(active)
639
+ except PermissionError:
640
+ return "warn", "Permission denied reading sshd_config (run as admin/root for full audit)"
641
+ except Exception as e: # noqa: BLE001
642
+ return "error", str(e)
643
+
644
+ warnings = []
645
+ if "PermitRootLogin yes" in out:
646
+ warnings.append("⚠ PermitRootLogin yes")
647
+ if "PasswordAuthentication yes" in out:
648
+ warnings.append("⚠ PasswordAuthentication yes")
649
+ if "PermitEmptyPasswords yes" in out:
650
+ warnings.append("⚠ PermitEmptyPasswords yes")
651
+ status = "warn" if warnings else "ok"
652
+ header = "\n".join(warnings) + "\n\n" if warnings else ""
653
+ return status, (header + out).strip() or "SSH config empty"
654
+
655
+ # ── Scheduled Tasks / Cron ────────────────────────────
656
+ elif section == "Scheduled Tasks":
657
+ if IS_WIN:
658
+ out = cmd(["schtasks", "/query", "/fo", "LIST", "/v"], timeout=15)
659
+ if out:
660
+ # Trim to first 60 lines to avoid wall of text
661
+ out = "\n".join(out.splitlines()[:60])
662
+ if len(out.splitlines()) == 60:
663
+ out += "\n[truncated...]"
664
+ return "ok", out or "No scheduled tasks found"
665
+
666
+ elif IS_MAC:
667
+ parts = []
668
+ user_cron = cmd(["crontab", "-l"], timeout=5)
669
+ if user_cron and "[error" not in user_cron and "[not found]" not in user_cron:
670
+ parts.append("=== User crontab ===\n" + user_cron)
671
+ # LaunchAgents
672
+ for d in [
673
+ os.path.expanduser("~/Library/LaunchAgents"),
674
+ "/Library/LaunchAgents",
675
+ "/Library/LaunchDaemons",
676
+ ]:
677
+ if os.path.isdir(d):
678
+ files = os.listdir(d)
679
+ if files:
680
+ parts.append(f"=== {d} ===\n" + "\n".join(files))
681
+ return "ok", "\n\n".join(parts) or "No scheduled tasks found"
682
+
683
+ else: # Linux
684
+ parts = []
685
+ user_cron = cmd(["crontab", "-l"], timeout=5)
686
+ if user_cron and "[error" not in user_cron and "[not found]" not in user_cron:
687
+ parts.append("=== User crontab ===\n" + user_cron)
688
+ cron_d = cmd(["ls", "/etc/cron.d"], timeout=5)
689
+ if cron_d and "[error" not in cron_d:
690
+ parts.append("=== /etc/cron.d ===\n" + cron_d)
691
+ try:
692
+ with open("/etc/crontab") as f:
693
+ active = [line for line in f.read().splitlines() if line.strip() and not line.startswith("#")]
694
+ if active:
695
+ parts.append("=== /etc/crontab ===\n" + "\n".join(active))
696
+ except Exception: # noqa: BLE001, S110
697
+ pass
698
+ return "ok", "\n\n".join(parts) or "No cron jobs found"
699
+
700
+ # ── SUID/SGID Files ───────────────────────────────────
701
+ elif section == "SUID/SGID Files":
702
+ if IS_WIN:
703
+ # Windows equivalent: files with Everyone:FullControl
704
+ out = cmd(
705
+ ["powershell", "-NoProfile", "-Command",
706
+ (r"Get-ChildItem 'C:\Windows\System32' -File | "
707
+ r"ForEach-Object { $acl = Get-Acl $_.FullName; "
708
+ r"$acl.Access | Where-Object {$_.IdentityReference -match 'Everyone' "
709
+ r"-and $_.FileSystemRights -match 'FullControl'} | "
710
+ r"ForEach-Object { $_.Path } } | Select-Object -First 30")],
711
+ timeout=30,
712
+ )
713
+ status = "warn" if out and "[error" not in out and out.strip() else "ok"
714
+ return status, out or "No world-accessible binaries found in System32"
715
+
716
+ else: # Linux + macOS
717
+ safe_paths = ["/usr", "/bin", "/sbin", "/lib", "/lib64", "/opt"]
718
+ if IS_MAC:
719
+ safe_paths = ["/usr", "/bin", "/sbin", "/opt"]
720
+ existing = [p for p in safe_paths if os.path.isdir(p)]
721
+ out = cmd(["find"] + existing + ["-perm", "/6000", "-type", "f"], timeout=20)
722
+ real, noise = self._split_noise(out)
723
+ if real:
724
+ lines = real[:30]
725
+ out = "\n".join(lines)
726
+ if len(lines) == 30:
727
+ out += "\n[truncated at 30 results]"
728
+ if noise:
729
+ out += f"\n({len(noise)} path(s) not readable, skipped)"
730
+ return "warn", out
731
+ note = "No SUID/SGID files found in system dirs"
732
+ if noise:
733
+ note += f" ({len(noise)} path(s) not readable, skipped)"
734
+ return "ok", note
735
+
736
+ # ── World-Writable Files ──────────────────────────────
737
+ elif section == "World-Writable Files":
738
+ if IS_WIN:
739
+ # Check world-writable dirs in common system paths
740
+ out = cmd(
741
+ ["icacls", r"C:\Windows\Temp"],
742
+ timeout=10,
743
+ )
744
+ return "ok", out or "Could not check world-writable paths"
745
+
746
+ else: # Linux + macOS
747
+ check_paths = ["/etc", "/usr", "/bin", "/sbin"]
748
+ if IS_MAC:
749
+ check_paths = ["/etc", "/usr", "/bin"]
750
+ out = cmd(
751
+ ["find"] + check_paths + ["-perm", "-o+w", "-type", "f"],
752
+ timeout=15,
753
+ )
754
+ real, noise = self._split_noise(out)
755
+ if real:
756
+ out = "\n".join(real[:20])
757
+ if noise:
758
+ out += f"\n({len(noise)} path(s) not readable, skipped)"
759
+ return "warn", out
760
+ note = "No world-writable files found in critical dirs"
761
+ if noise:
762
+ note += f" ({len(noise)} path(s) not readable, skipped)"
763
+ return "ok", note
764
+
765
+ # ── Virus Scan (ClamAV) ───────────────────────────────
766
+ elif section == "Virus Scan (ClamAV)":
767
+ scan_path = os.path.realpath(self.scan_path)
768
+ if not os.path.exists(scan_path):
769
+ return "error", f"Scan path does not exist: {scan_path}"
770
+ if not os.access(scan_path, os.R_OK):
771
+ return "error", f"No read permission for: {scan_path}"
772
+
773
+ clamscan = shutil.which("clamscan")
774
+ if IS_WIN and not clamscan:
775
+ # Try common Windows install path
776
+ win_path = r"C:\Program Files\ClamAV\clamscan.exe"
777
+ if os.path.exists(win_path):
778
+ clamscan = win_path
779
+
780
+ if not clamscan:
781
+ if IS_WIN:
782
+ install_hint = "winget install ClamAV or choco install clamav"
783
+ elif IS_MAC:
784
+ install_hint = "brew install clamav && sudo freshclam"
785
+ else:
786
+ install_hint = "sudo apt install clamav && sudo freshclam"
787
+ return "warn", f"ClamAV not installed.\nInstall: {install_hint}"
788
+
789
+ files_scanned = 0
790
+ threats = []
791
+ last_file = ""
792
+ try:
793
+ proc = subprocess.Popen(
794
+ [
795
+ clamscan,
796
+ "--recursive",
797
+ "--stdout",
798
+ "--no-summary",
799
+ "--max-filesize=100M",
800
+ "--max-scansize=500M",
801
+ scan_path,
802
+ ],
803
+ stdout=subprocess.PIPE,
804
+ stderr=subprocess.DEVNULL,
805
+ text=True,
806
+ bufsize=1,
807
+ )
808
+ for line in proc.stdout:
809
+ line = line.strip()
810
+ if not line:
811
+ continue
812
+ if "FOUND" in line:
813
+ threats.append(line)
814
+ # every non-empty line is a scanned file path
815
+ files_scanned += 1
816
+ last_file = line.split(":")[0]
817
+ short = last_file[-40:] if len(last_file) > 40 else last_file
818
+ self._on_progress(
819
+ f"Scanning {files_scanned} files… {short}", files_scanned
820
+ )
821
+ proc.wait()
822
+ if threats:
823
+ summary = f"⚠ {len(threats)} THREAT(S) FOUND\n\n" + "\n".join(threats)
824
+ return "critical", summary
825
+ return "ok", f"✓ Clean — {files_scanned} files scanned\nPath: {scan_path}"
826
+ except Exception as e: # noqa: BLE001
827
+ return "error", str(e)
828
+
829
+ # ── Environment Secrets ───────────────────────────────
830
+ elif section == "Environment Secrets":
831
+ SECRET_NAME = re.compile(
832
+ r"(key|token|secret|password|passwd|pwd|credential|auth|"
833
+ r"apikey|api_key|access_key|private|bearer|jwt|oauth|webhook)",
834
+ re.IGNORECASE,
835
+ )
836
+ SECRET_VAL = re.compile(
837
+ r"^[A-Za-z0-9+/]{32,}$" # base64-like long string
838
+ r"|^[a-f0-9]{32,}$" # hex hash
839
+ r"|^sk-[A-Za-z0-9]{20,}" # OpenAI-style
840
+ r"|^ghp_|^ghs_|^github_pat_" # GitHub tokens
841
+ r"|^xox[bpoas]-", # Slack tokens
842
+ re.IGNORECASE,
843
+ )
844
+ suspicious, clean = [], []
845
+ for k, v in os.environ.items():
846
+ if SECRET_NAME.search(k) or (v and SECRET_VAL.search(v)):
847
+ masked = (v[:4] + "****" + v[-2:]) if len(v) > 6 else "****"
848
+ suspicious.append(f"⚠ {k} = {masked}")
849
+ else:
850
+ clean.append(k)
851
+ out_parts = []
852
+ if suspicious:
853
+ out_parts.append(
854
+ f"=== Potential secrets ({len(suspicious)}) ===\n"
855
+ + "\n".join(suspicious[:40])
856
+ + ("\n[truncated…]" if len(suspicious) > 40 else "")
857
+ )
858
+ out_parts.append(
859
+ f"=== Clean variables ({len(clean)}) ===\n"
860
+ + ("None detected." if not clean else f"{len(clean)} variables — no suspicious names or values.")
861
+ )
862
+ return ("warn" if suspicious else "ok"), "\n\n".join(out_parts)
863
+
864
+ # ── Sensitive File Permissions ────────────────────────
865
+ elif section == "Sensitive File Permissions":
866
+ if IS_WIN:
867
+ return "ok", "[N/A on Windows] Unix file-mode checks not applicable."
868
+ HOME = os.path.expanduser("~")
869
+ checks = [
870
+ (os.path.join(HOME, ".ssh"), 0o700, "~/.ssh/"),
871
+ (os.path.join(HOME, ".ssh", "id_rsa"), 0o600, "~/.ssh/id_rsa"),
872
+ (os.path.join(HOME, ".ssh", "id_ed25519"), 0o600, "~/.ssh/id_ed25519"),
873
+ (os.path.join(HOME, ".ssh", "authorized_keys"), 0o600, "~/.ssh/authorized_keys"),
874
+ (os.path.join(HOME, ".ssh", "config"), 0o600, "~/.ssh/config"),
875
+ (os.path.join(HOME, ".gnupg"), 0o700, "~/.gnupg/"),
876
+ (os.path.join(HOME, ".aws", "credentials"), 0o600, "~/.aws/credentials"),
877
+ (os.path.join(HOME, ".netrc"), 0o600, "~/.netrc"),
878
+ ]
879
+ # Scan home dir (depth ≤ 2) for .env / credentials files
880
+ for root, dirs, files in os.walk(HOME):
881
+ depth = root[len(HOME):].count(os.sep)
882
+ if depth > 2:
883
+ dirs[:] = []
884
+ continue
885
+ for fname in files:
886
+ if fname in (".env", ".env.local", ".env.production",
887
+ "credentials.json", "secrets.json", ".envrc"):
888
+ checks.append((os.path.join(root, fname), 0o600, fname))
889
+ issues, ok_items = [], []
890
+ for path, required, label in checks:
891
+ if not os.path.exists(path):
892
+ continue
893
+ try:
894
+ mode = stat.S_IMODE(os.stat(path).st_mode)
895
+ if mode & ~required:
896
+ issues.append(
897
+ f"⚠ {label}: {oct(mode)} (should be ≤ {oct(required)})"
898
+ )
899
+ else:
900
+ ok_items.append(f"✓ {label}: {oct(mode)}")
901
+ except Exception as e: # noqa: BLE001
902
+ issues.append(f"? {label}: {e}")
903
+ out = ("\n".join(issues) + "\n\n" if issues else "") + "\n".join(ok_items)
904
+ return ("warn" if issues else "ok"), out.strip() or "No sensitive files found"
905
+
906
+ return "ok", "Section not implemented"
907
+
908
+ class AuditWorker(QThread, AuditEngine):
909
+ """Qt front-end for AuditEngine: same code, emits signals as it goes."""
910
+
911
+ section_done = pyqtSignal(str, str, str)
912
+ scan_progress = pyqtSignal(str, int)
913
+ all_done = pyqtSignal()
914
+
915
+ def __init__(self, sections, scan_path=None):
916
+ QThread.__init__(self)
917
+ AuditEngine.__init__(self, sections, scan_path)
918
+ # Bridge the Qt-free progress hook onto the Qt signal.
919
+ self._on_progress = lambda label, count: self.scan_progress.emit(label, count)
920
+
921
+ def run(self):
922
+ max_workers = min(8, len(self.sections))
923
+ with ThreadPoolExecutor(max_workers=max_workers) as ex:
924
+ futures = {ex.submit(self._run_safe, s): s
925
+ for s in self.sections if not self._stop}
926
+ for future in as_completed(futures):
927
+ if self._stop:
928
+ break
929
+ section = futures[future]
930
+ try:
931
+ status, output = future.result()
932
+ except Exception as e: # noqa: BLE001
933
+ status, output = "error", str(e)
934
+ self.section_done.emit(section, status, output)
935
+ self.all_done.emit()
936
+
937
+
938
+
939
+
940
+ # ============================================================
941
+ # OLLAMA REMEDIATION
942
+ # ============================================================
943
+ REMEDIATION_PROMPT = """You are a {os} security hardening expert. A security audit found this issue:
944
+
945
+ Section: {section}
946
+ Finding:
947
+ {finding}
948
+
949
+ Output ONLY the exact shell commands needed to fix this specific finding. Follow these rules strictly:
950
+ - Raw commands only — no explanation, no markdown, no code fences, no backticks, no bullets, no numbers
951
+ - One command per line
952
+ - Commands must be real, standard {os} commands that exist on this system
953
+ - Do NOT invent commands — only use well-known tools (chmod, chown, systemctl, ufw, sysctl, sed, etc.)
954
+ - Do NOT include ssh-agent, xauth, dbus, session management or unrelated daemon commands
955
+ - If the fix requires editing a config file, use sed or echo with a redirect
956
+ - If nothing can be fixed with a shell command, output exactly: NO_FIX_AVAILABLE
957
+ - Do NOT output anything else"""
958
+
959
+
960
+
961
+ # ============================================================
962
+ # AI PROVIDER CORE (Qt-free)
963
+ # ============================================================
964
+ # Everything below runs with no PyQt6 and no display. The GUI dialogs and the
965
+ # CLI both call these, so a fix generated via `--fix` is produced by the exact
966
+ # same prompt and parsing as a fix generated by the AI Fix button. Two
967
+ # implementations would drift; there is one.
968
+ #
969
+ # Defaults mirror the RemediationDialog signature defaults. A test asserts
970
+ # they stay in sync.
971
+
972
+ OLLAMA_URL = "http://localhost:11434"
973
+ OLLAMA_MODEL = "llama3.2"
974
+ CLAUDE_URL = "https://api.anthropic.com/v1/messages"
975
+ CLAUDE_MODEL = "claude-haiku-4-5-20251001"
976
+
977
+
978
+ def build_prompt(section: str, finding: str) -> str:
979
+ """The exact remediation prompt. `--print-prompt` hands this to a model
980
+ that would rather do the reasoning itself than have rex call an API."""
981
+ return REMEDIATION_PROMPT.format(os=_OS, section=section, finding=finding)
982
+
983
+
984
+ # ── Ollama ───────────────────────────────────────────────────
985
+ def ollama_model_exists(model: str, base_url: str = OLLAMA_URL) -> bool:
986
+ try:
987
+ url = base_url.rstrip("/") + "/api/tags"
988
+ with urllib.request.urlopen(url, timeout=10) as resp:
989
+ data = json.loads(resp.read())
990
+ names = [m.get("name", "").split(":")[0] for m in data.get("models", [])]
991
+ return model.split(":")[0] in names
992
+ except Exception: # noqa: BLE001
993
+ return False
994
+
995
+
996
+ def ollama_pull_model(model: str, base_url: str = OLLAMA_URL, on_status=None):
997
+ """Pull `model` if absent. Returns (ok, message)."""
998
+ status = on_status or (lambda m: None)
999
+ status(f"Pulling model '{model}'… (first run only)")
1000
+ url = base_url.rstrip("/") + "/api/pull"
1001
+ payload = json.dumps({"name": model, "stream": False}).encode()
1002
+ req = urllib.request.Request(
1003
+ url, data=payload, headers={"Content-Type": "application/json"}
1004
+ )
1005
+ try:
1006
+ with urllib.request.urlopen(req, timeout=300) as resp:
1007
+ data = json.loads(resp.read())
1008
+ if data.get("status") == "success":
1009
+ return True, ""
1010
+ return False, f"[Pull returned unexpected status: {data.get('status')}]"
1011
+ except Exception as e: # noqa: BLE001
1012
+ return False, f"[Pull failed: {e}]"
1013
+
1014
+
1015
+ def _sse_lines(resp):
1016
+ """Yield the payload of each `data: ` line from an SSE stream."""
1017
+ for raw_line in resp:
1018
+ line = raw_line.decode("utf-8", errors="replace").strip()
1019
+ if not line or not line.startswith("data: "):
1020
+ continue
1021
+ data = line[6:]
1022
+ if data == "[DONE]":
1023
+ return
1024
+ yield data
1025
+
1026
+
1027
+ def provider_stream(provider: str, prompt: str, cfg: dict,
1028
+ on_token=None, on_status=None):
1029
+ """Stream a remediation suggestion.
1030
+
1031
+ Returns (text, error). error is None on success — so a caller gets a
1032
+ machine-checkable failure instead of having to sniff for "[...]" in prose.
1033
+ The GUI renders `error or text`, which preserves its previous output
1034
+ byte-for-byte.
1035
+ """
1036
+ emit = on_token or (lambda t: None)
1037
+ status = on_status or (lambda m: None)
1038
+
1039
+ if provider == "ollama":
1040
+ model = cfg.get("ollama_model") or OLLAMA_MODEL
1041
+ base = cfg.get("ollama_url") or OLLAMA_URL
1042
+ if not ollama_model_exists(model, base):
1043
+ ok, msg = ollama_pull_model(model, base, status)
1044
+ if not ok:
1045
+ return "", msg
1046
+ status(f"Querying {model}…")
1047
+ url = base.rstrip("/") + "/api/generate"
1048
+ payload = json.dumps({
1049
+ "model": model,
1050
+ "prompt": prompt,
1051
+ "stream": True,
1052
+ }).encode()
1053
+ req = urllib.request.Request(
1054
+ url, data=payload, headers={"Content-Type": "application/json"}
1055
+ )
1056
+ try:
1057
+ full = []
1058
+ with urllib.request.urlopen(req, timeout=120) as resp:
1059
+ for raw_line in resp:
1060
+ line = raw_line.strip()
1061
+ if not line:
1062
+ continue
1063
+ try:
1064
+ chunk = json.loads(line)
1065
+ except Exception: # noqa: BLE001, S112
1066
+ continue
1067
+ token = chunk.get("response", "")
1068
+ if token:
1069
+ full.append(token)
1070
+ emit(token)
1071
+ if chunk.get("done"):
1072
+ break
1073
+ return "".join(full).strip(), None
1074
+ except urllib.error.URLError as e:
1075
+ return "", f"[Ollama unreachable: {e.reason}]"
1076
+ except Exception as e: # noqa: BLE001
1077
+ return "", f"[Error: {e}]"
1078
+
1079
+ if provider == "claude":
1080
+ model = cfg.get("claude_model") or CLAUDE_MODEL
1081
+ key = cfg.get("claude_api_key") or ""
1082
+ if not key:
1083
+ return "", "[Claude API error: no API key configured]"
1084
+ status(f"Querying {model}…")
1085
+ payload = json.dumps({
1086
+ "model": model,
1087
+ "max_tokens": 1024,
1088
+ "stream": True,
1089
+ "messages": [{"role": "user", "content": prompt}],
1090
+ }).encode()
1091
+ req = urllib.request.Request(CLAUDE_URL, data=payload, headers={
1092
+ "Content-Type": "application/json",
1093
+ "x-api-key": key,
1094
+ "anthropic-version": "2023-06-01",
1095
+ })
1096
+ try:
1097
+ full = []
1098
+ with urllib.request.urlopen(req, timeout=120) as resp:
1099
+ for data in _sse_lines(resp):
1100
+ try:
1101
+ chunk = json.loads(data)
1102
+ except Exception: # noqa: BLE001, S112
1103
+ continue
1104
+ if chunk.get("type") == "content_block_delta":
1105
+ token = chunk.get("delta", {}).get("text", "")
1106
+ if token:
1107
+ full.append(token)
1108
+ emit(token)
1109
+ elif chunk.get("type") == "message_stop":
1110
+ break
1111
+ return "".join(full).strip(), None
1112
+ except urllib.error.HTTPError as e:
1113
+ body = e.read().decode("utf-8", errors="replace")
1114
+ return "", f"[Claude API error {e.code}: {body[:300]}]"
1115
+ except urllib.error.URLError as e:
1116
+ return "", f"[Claude unreachable: {e.reason}]"
1117
+ except Exception as e: # noqa: BLE001
1118
+ return "", f"[Error: {e}]"
1119
+
1120
+ if provider == "deepseek":
1121
+ model = cfg.get("deepseek_model") or DEEPSEEK_MODEL
1122
+ key = cfg.get("deepseek_api_key") or ""
1123
+ base = cfg.get("deepseek_url") or DEEPSEEK_URL
1124
+ if not key:
1125
+ return "", "[DeepSeek API error 401: no API key configured]"
1126
+ status(f"Querying {model}…")
1127
+ url = base.rstrip("/") + "/chat/completions"
1128
+ payload = json.dumps({
1129
+ "model": model,
1130
+ "max_tokens": AI_MAX_TOKENS,
1131
+ "stream": True,
1132
+ "messages": [
1133
+ {"role": "system",
1134
+ "content": "You output only raw shell commands. Never explain, "
1135
+ "never use markdown or code fences."},
1136
+ {"role": "user", "content": prompt},
1137
+ ],
1138
+ }).encode()
1139
+ req = urllib.request.Request(url, data=payload, headers={
1140
+ "Content-Type": "application/json",
1141
+ "Authorization": f"Bearer {key}",
1142
+ "Accept": "text/event-stream",
1143
+ })
1144
+ try:
1145
+ full = []
1146
+ with urllib.request.urlopen(req, timeout=180) as resp:
1147
+ for data in _sse_lines(resp):
1148
+ try:
1149
+ chunk = json.loads(data)
1150
+ except Exception: # noqa: BLE001, S112
1151
+ continue
1152
+ choices = chunk.get("choices") or []
1153
+ if not choices:
1154
+ continue
1155
+ delta = choices[0].get("delta") or {}
1156
+ # reasoning_content is the hidden chain-of-thought — the
1157
+ # caller wants the commands, not the working-out.
1158
+ token = delta.get("content") or ""
1159
+ if token:
1160
+ full.append(token)
1161
+ emit(token)
1162
+ if choices[0].get("finish_reason"):
1163
+ break
1164
+ text = "".join(full).strip()
1165
+ if not text:
1166
+ return "", ("[DeepSeek returned no content — the model may have "
1167
+ "spent the entire token budget on reasoning. "
1168
+ "Try deepseek-v4-pro.]")
1169
+ return text, None
1170
+ except urllib.error.HTTPError as e:
1171
+ if e.code == 401:
1172
+ return "", "[DeepSeek API error 401: invalid API key]"
1173
+ if e.code == 402:
1174
+ return "", "[DeepSeek API error 402: insufficient balance]"
1175
+ body = e.read().decode("utf-8", errors="replace")
1176
+ return "", f"[DeepSeek API error {e.code}: {body[:300]}]"
1177
+ except urllib.error.URLError as e:
1178
+ return "", f"[DeepSeek unreachable: {e.reason}]"
1179
+ except Exception as e: # noqa: BLE001
1180
+ return "", f"[Error: {e}]"
1181
+
1182
+ return "", f"[Unknown provider: {provider}]"
1183
+
1184
+
1185
+ def load_provider_config(provider: str | None = None, **overrides) -> dict:
1186
+ """Config file, then environment, then explicit overrides.
1187
+
1188
+ Same store the GUI writes, so a key entered in the Settings panel is
1189
+ visible to the CLI and vice versa.
1190
+ """
1191
+ cfg = _load_config()
1192
+ out = {
1193
+ "ollama_url": cfg.get("ollama_url") or OLLAMA_URL,
1194
+ "ollama_model": cfg.get("ollama_model") or OLLAMA_MODEL,
1195
+ "claude_model": cfg.get("claude_model") or CLAUDE_MODEL,
1196
+ "deepseek_model": cfg.get("deepseek_model") or DEEPSEEK_MODEL,
1197
+ "deepseek_url": cfg.get("deepseek_url") or DEEPSEEK_URL,
1198
+ "claude_api_key": cfg.get("claude_api_key") or os.environ.get("ANTHROPIC_API_KEY", ""),
1199
+ "deepseek_api_key": cfg.get("deepseek_api_key") or os.environ.get("DEEPSEEK_API_KEY", ""),
1200
+ }
1201
+ if provider:
1202
+ out["provider"] = provider
1203
+ for k, v in overrides.items():
1204
+ if v:
1205
+ out[k] = v
1206
+ return out
1207
+
1208
+
1209
+ # ============================================================
1210
+ # COMMAND SAFETY (Qt-free — shared by the dialog and --apply)
1211
+ # ============================================================
1212
+ # Patterns that indicate a command is too dangerous to auto-run.
1213
+ # Each entry is (regex_pattern, human_reason).
1214
+ DANGER_PATTERNS = [
1215
+ (r"sudoers", "modifies sudoers — use visudo"),
1216
+ (r"/etc/shadow", "modifies shadow password file"),
1217
+ (r"/etc/passwd", "modifies passwd file"),
1218
+ (r"rm\s+-[a-z]*r[a-z]*f?\s+/(?!\S)", "rm -rf on root"),
1219
+ (r"rm\s+-[a-z]*f?[a-z]*r\s+/(?!\S)", "rm -rf on root"),
1220
+ (r">\s*/dev/sd[a-z]", "overwrites block device"),
1221
+ (r"dd\s+.*of=/dev/", "dd to block device"),
1222
+ (r"mkfs", "formats a filesystem"),
1223
+ (r":\s*\(\)\s*\{", "fork bomb"),
1224
+ (r"chmod\s+[0-9]*[0-7][0-7][0-7]\s+/etc/(?:passwd|shadow|sudoers|ssh)", "unsafe chmod on critical file"),
1225
+ (r">\s*/etc/(?:passwd|shadow|sudoers|crontab|hosts)(?:\s|$)", "overwrites critical config"),
1226
+ # Any download piped straight into a shell — not just the "-O -" spelling.
1227
+ # `wget -qO- url | bash` is the same attack as `curl url | sh`.
1228
+ (r"(?:wget|curl)\b[^|]*\|\s*(?:ba|z|da|k)?sh\b", "remote code execution via pipe"),
1229
+ (r"base64\s+.*\|\s*(?:ba)?sh\b", "obfuscated remote execution"),
1230
+ (r"!!!", "invalid sudoers syntax"),
1231
+ ]
1232
+
1233
+ # Too dangerous to auto-execute even with sudo — always leave to a human.
1234
+ BLOCKED_PATHS = (
1235
+ "/etc/sudoers",
1236
+ "/etc/sudoers.d/",
1237
+ "/etc/shadow",
1238
+ "/etc/passwd",
1239
+ "/etc/ssh/sshd_config",
1240
+ "/etc/crontab",
1241
+ "/etc/cron.d/",
1242
+ "/etc/hosts",
1243
+ )
1244
+
1245
+ # Need root but safe to auto-sudo (`sudo -n`, so it never hangs on a prompt).
1246
+ SUDO_PATHS = (
1247
+ "/etc/",
1248
+ "/usr/",
1249
+ "/var/",
1250
+ "/sys/",
1251
+ "/boot/",
1252
+ "/lib/",
1253
+ "/lib64/",
1254
+ "/sbin/",
1255
+ "/bin/",
1256
+ )
1257
+
1258
+
1259
+ def danger_reason(cmd: str) -> str:
1260
+ """Return a human-readable reason if cmd is dangerous, else empty string."""
1261
+ if not cmd or cmd.startswith("#"):
1262
+ return ""
1263
+ for pattern, reason in DANGER_PATTERNS:
1264
+ if re.search(pattern, cmd, re.IGNORECASE):
1265
+ return reason
1266
+ return ""
1267
+
1268
+
1269
+ def clean_commands(raw: str) -> list:
1270
+ """Strip LLM formatting noise and return executable command lines."""
1271
+ cmds = []
1272
+ for line in raw.splitlines():
1273
+ line = line.strip()
1274
+ if not line:
1275
+ continue
1276
+ # skip parenthetical notes and explanations
1277
+ if line.startswith(("(", "#")):
1278
+ continue
1279
+ # strip leading "1." "2." "- " bullet formatting
1280
+ line = re.sub(r"^\d+\.\s*", "", line)
1281
+ line = re.sub(r"^[-*]\s*", "", line)
1282
+ # strip surrounding backticks
1283
+ line = line.strip("`").strip()
1284
+ if not line or line == "NO_FIX_AVAILABLE":
1285
+ continue
1286
+
1287
+ # Fix unquoted echo content that contains shell metacharacters.
1288
+ # Example: echo neo ALL=(ALL) NOPASSWD: ... >> /etc/sudoers
1289
+ # → echo 'neo ALL=(ALL) NOPASSWD: ...' >> /etc/sudoers
1290
+ # /bin/sh (dash) treats bare (...) in argument position as a
1291
+ # compound command — wrapping in single quotes prevents this.
1292
+ echo_m = re.match(r'^(echo\s+)(.*?)(\s*>>?\s*\S+.*)$', line, re.DOTALL)
1293
+ if echo_m:
1294
+ prefix, content, redirect = echo_m.groups()
1295
+ already_quoted = (
1296
+ (content.startswith("'") and content.endswith("'")) or
1297
+ (content.startswith('"') and content.endswith('"'))
1298
+ )
1299
+ if not already_quoted and re.search(r"[()!$`\\]", content):
1300
+ # Escape any single quotes inside content, then wrap
1301
+ content = "'" + content.replace("'", "'\\''") + "'"
1302
+ line = prefix + content + redirect
1303
+
1304
+ cmds.append(line)
1305
+ return cmds
1306
+
1307
+
1308
+ def classify_commands(cmds) -> dict:
1309
+ """Split commands into executable / blocked-before-sudo / needs-sudo.
1310
+
1311
+ This is the decision a model needs to make before applying anything, so it
1312
+ is exposed as data rather than only as coloured text in a dialog.
1313
+ """
1314
+ executable, blocked, need_sudo = [], [], []
1315
+ for cmd in cmds:
1316
+ reason = danger_reason(cmd)
1317
+ hit = reason or next((f"sensitive path {p}" for p in BLOCKED_PATHS if p in cmd), "")
1318
+ if hit:
1319
+ blocked.append({"command": cmd, "reason": hit})
1320
+ continue
1321
+ entry = {"command": cmd}
1322
+ if not cmd.startswith("sudo ") and any(p in cmd for p in SUDO_PATHS):
1323
+ entry["run_as"] = "sudo -n " + cmd
1324
+ need_sudo.append(cmd)
1325
+ executable.append(entry)
1326
+ return {"executable": executable, "blocked": blocked, "needs_sudo": need_sudo}
1327
+
1328
+
1329
+ def apply_commands(cmds, on_progress=None, timeout=30,
1330
+ blocked_paths=None, sudo_paths=None) -> tuple:
1331
+ """Execute commands with the same guard the GUI applies. Returns
1332
+ (all_ok, results) where results is a list of per-command dicts."""
1333
+ blocked_paths = BLOCKED_PATHS if blocked_paths is None else blocked_paths
1334
+ sudo_paths = SUDO_PATHS if sudo_paths is None else sudo_paths
1335
+ progress = on_progress or (lambda line: None)
1336
+ results = []
1337
+ all_ok = True
1338
+ for cmd in cmds:
1339
+ # Hard safety net — catches anything that slipped past the validator
1340
+ reason = danger_reason(cmd)
1341
+ if reason or any(p in cmd for p in blocked_paths):
1342
+ all_ok = False
1343
+ results.append({
1344
+ "command": cmd, "executed": False, "ok": False,
1345
+ "blocked": reason or "sensitive path", "output": "",
1346
+ })
1347
+ continue
1348
+
1349
+ run_cmd = cmd
1350
+ if not cmd.startswith("sudo ") and any(p in cmd for p in sudo_paths):
1351
+ run_cmd = "sudo -n " + cmd # -n: fail immediately if password needed
1352
+
1353
+ progress(f"$ {cmd}")
1354
+ try:
1355
+ r = subprocess.run(
1356
+ run_cmd, shell=True, check=False,
1357
+ capture_output=True, text=True, timeout=timeout,
1358
+ )
1359
+ out = (r.stdout + r.stderr).strip() or "(no output)"
1360
+ ok = r.returncode == 0
1361
+ if not ok:
1362
+ all_ok = False
1363
+ entry = {
1364
+ "command": cmd, "executed": True, "ok": ok,
1365
+ "exit_code": r.returncode, "output": out,
1366
+ "ran_as": "sudo -n" if run_cmd != cmd else "shell",
1367
+ }
1368
+ if not ok and ("password is required" in out or "a password is required" in out):
1369
+ entry["needs_manual_sudo"] = f"sudo {cmd}"
1370
+ results.append(entry)
1371
+ except Exception as e: # noqa: BLE001
1372
+ all_ok = False
1373
+ results.append({
1374
+ "command": cmd, "executed": True, "ok": False,
1375
+ "error": str(e), "output": "",
1376
+ })
1377
+ return all_ok, results
1378
+
1379
+
1380
+ # ============================================================
1381
+ # MACHINE CONTRACT
1382
+ # ============================================================
1383
+ # Stable identifiers so an agent can assert on them across rex versions.
1384
+ SCHEMA_AUDIT = "aegis.rex.audit/1"
1385
+ SCHEMA_FIX = "aegis.rex.fix/1"
1386
+ SCHEMA_CAPABILITIES = "aegis.rex.capabilities/1"
1387
+
1388
+ EXIT_CLEAN = 0 # audit ran, nothing at or above the --fail-on threshold
1389
+ EXIT_FINDINGS = 1 # audit ran, findings at or above the threshold
1390
+ EXIT_USAGE = 2 # bad arguments / bad input file
1391
+ EXIT_PROVIDER = 3 # AI provider unreachable, unauthorized, or out of budget
1392
+ EXIT_APPLY = 4 # --apply ran and at least one command failed
1393
+
1394
+ SEVERITY_BY_STATUS = {
1395
+ "critical": "critical",
1396
+ "error": "high",
1397
+ "warn": "medium",
1398
+ "ok": "none",
1399
+ "info": "none",
1400
+ }
1401
+
1402
+ # Matches SecurityAuditWindow's export: these hold credentials and account data.
1403
+ REDACTED_SECTIONS = ("Sudo / Privileges", "SSH Config", "Users & Groups")
1404
+
1405
+ # The one placeholder the report/export uses in place of a sensitive section's
1406
+ # output, so an agent can detect redaction by string comparison.
1407
+ REDACTED_SENSITIVE = "[REDACTED — sensitive data omitted from report]"
1408
+
1409
+ # The GUI enables AI Fix for exactly these (see btn_ai_fix.setEnabled).
1410
+ FIXABLE_STATUSES = ("warn", "critical", "error")
1411
+
1412
+
1413
+ def section_id(section: str) -> str:
1414
+ """Stable slug for a section name: 'SSH Config' -> 'ssh-config'."""
1415
+ return re.sub(r"[^a-z0-9]+", "-", section.lower()).strip("-")
1416
+
1417
+
1418
+ def audit_score(results: dict) -> dict:
1419
+ """Same formula the GUI's summary panel uses, so the CLI and the window
1420
+ never disagree about whether a host passed."""
1421
+ warnings = [(s, st) for s, (st, _out) in results.items()
1422
+ if st in FIXABLE_STATUSES]
1423
+ warn_count = sum(1 for _, st in warnings if st == "warn")
1424
+ crit_count = sum(1 for _, st in warnings if st in ("critical", "error"))
1425
+ score = max(0, 100 - warn_count * 5 - crit_count * 15)
1426
+ grade = "GOOD" if score >= 80 else "FAIR" if score >= 50 else "POOR"
1427
+ return {"score": score, "grade": grade,
1428
+ "warnings": warn_count, "critical": crit_count}
1429
+
1430
+
1431
+ def build_report(results: dict, scan_path: str | None = None, include_output: bool = True,
1432
+ redact: bool = True) -> dict:
1433
+ """Turn {section: (status, output)} into the stable audit document."""
1434
+ findings = []
1435
+ for idx, section in enumerate(SECTIONS):
1436
+ if section not in results:
1437
+ continue
1438
+ status, output = results[section]
1439
+ output = output or ""
1440
+ is_redacted = redact and section in REDACTED_SECTIONS
1441
+ findings.append({
1442
+ "id": section_id(section),
1443
+ "section": section,
1444
+ "index": idx,
1445
+ "status": status,
1446
+ "severity": SEVERITY_BY_STATUS.get(status, "unknown"),
1447
+ "fixable": status in FIXABLE_STATUSES,
1448
+ "redacted": is_redacted,
1449
+ # "[N/A on Windows] ..." means the check does not apply here, which
1450
+ # is different from passing — models must not read it as a pass.
1451
+ "applicable": not output.startswith("[N/A on"),
1452
+ "line_count": len(output.splitlines()),
1453
+ "output": (REDACTED_SENSITIVE
1454
+ if is_redacted else (output if include_output else None)),
1455
+ })
1456
+
1457
+ score = audit_score(results)
1458
+ return {
1459
+ "schema": SCHEMA_AUDIT,
1460
+ "tool": APP_NAME,
1461
+ "version": VERSION,
1462
+ "platform": _OS,
1463
+ "platform_detail": platform.platform(),
1464
+ "timestamp": datetime.datetime.now().isoformat(), # noqa: DTZ005
1465
+ "hostname": socket.gethostname(),
1466
+ "scan_path": scan_path,
1467
+ "summary": {
1468
+ "sections_expected": len(SECTIONS),
1469
+ "sections_run": len(results),
1470
+ "clean": sum(1 for f in findings if f["status"] == "ok"),
1471
+ "fixable": sum(1 for f in findings if f["fixable"]),
1472
+ "not_applicable": sum(1 for f in findings if not f["applicable"]),
1473
+ "unassessed": sum(1 for f in findings if f["status"] == "error"),
1474
+ **score,
1475
+ },
1476
+ "redacted_sections": list(REDACTED_SECTIONS) if redact else [],
1477
+ # Ids whose status is "error": the check did not complete, so their
1478
+ # absence from the clean count is not a pass. Cause is usually a
1479
+ # missing module (pip install psutil) or an unreadable file.
1480
+ "unassessed": [f["id"] for f in findings if f["status"] == "error"],
1481
+ "findings": findings,
1482
+ }
1483
+
1484
+
1485
+ def capabilities() -> dict:
1486
+ """Self-description. This is the contract an agent reads first: it can
1487
+ discover every command, schema, exit code and provider without scraping
1488
+ --help prose or reading this file's source."""
1489
+ return {
1490
+ "schema": SCHEMA_CAPABILITIES,
1491
+ "tool": APP_NAME,
1492
+ "version": VERSION,
1493
+ "entrypoint": "python3 rex.py",
1494
+ "headless": True,
1495
+ "gui_available": HAS_QT,
1496
+ "requires": {
1497
+ "python": ">=3.10",
1498
+ "modules": {"psutil": "optional - without it 4 of 16 sections report status error",
1499
+ "PyQt6": "GUI only - not needed for --audit"},
1500
+ "display": "not required for any -- command",
1501
+ },
1502
+ "commands": [
1503
+ {"flag": "--capabilities", "summary": "Print this document as JSON and exit.",
1504
+ "args": [], "exit_codes": [EXIT_CLEAN]},
1505
+ {"flag": "--list-checks", "summary": "List audit sections and their stable ids.",
1506
+ "args": ["--json"], "exit_codes": [EXIT_CLEAN]},
1507
+ {"flag": "--audit", "summary": "Run the audit and emit a report.",
1508
+ "args": ["--json", "--sections ID,ID", "--scan-path DIR", "--output FILE",
1509
+ "--fail-on none|warn|critical", "--sequential"],
1510
+ "emits": SCHEMA_AUDIT,
1511
+ "exit_codes": [EXIT_CLEAN, EXIT_FINDINGS, EXIT_USAGE]},
1512
+ {"flag": "--fix", "summary": "Ask a provider for the fix to one finding. "
1513
+ "Never applies anything without --apply.",
1514
+ "args": ["SECTION_OR_ID", "--provider", "--model", "--report FILE",
1515
+ "--print-prompt", "--apply", "--yes", "--json"],
1516
+ "emits": SCHEMA_FIX,
1517
+ "exit_codes": [EXIT_CLEAN, EXIT_FINDINGS, EXIT_USAGE, EXIT_PROVIDER, EXIT_APPLY]},
1518
+ ],
1519
+ "schemas": {"audit": SCHEMA_AUDIT, "fix": SCHEMA_FIX,
1520
+ "capabilities": SCHEMA_CAPABILITIES},
1521
+ "exit_codes": {
1522
+ str(EXIT_CLEAN): "success — audit ran (or nothing met an explicit --fail-on threshold)",
1523
+ str(EXIT_FINDINGS): "audit ran and findings met the explicit --fail-on threshold",
1524
+ str(EXIT_USAGE): "bad arguments or unreadable input",
1525
+ str(EXIT_PROVIDER): "provider unreachable/unauthorized/out of budget",
1526
+ str(EXIT_APPLY): "a fix was applied and at least one command failed",
1527
+ },
1528
+ "default_fail_on": "none",
1529
+ "status_vocabulary": {
1530
+ "ok": "check passed",
1531
+ "warn": "issue worth fixing",
1532
+ "critical": "active threat (ClamAV found malware)",
1533
+ "error": "check could not complete — absence of evidence, not a pass",
1534
+ "info": "informational",
1535
+ },
1536
+ "severity_map": SEVERITY_BY_STATUS,
1537
+ "fixable_statuses": list(FIXABLE_STATUSES),
1538
+ "checks": [{"id": section_id(s), "section": s, "index": i}
1539
+ for i, s in enumerate(SECTIONS)],
1540
+ "providers": {
1541
+ "ollama": {"requires": ["ollama serve"],
1542
+ "config_keys": ["ollama_url", "ollama_model"],
1543
+ "env": [], "default_model": OLLAMA_MODEL,
1544
+ "auto_pulls_model": True},
1545
+ "claude": {"requires": ["api key"],
1546
+ "config_keys": ["claude_api_key", "claude_model"],
1547
+ "env": ["ANTHROPIC_API_KEY"], "default_model": CLAUDE_MODEL},
1548
+ "deepseek": {"requires": ["api key"],
1549
+ "config_keys": ["deepseek_api_key", "deepseek_model", "deepseek_url"],
1550
+ "env": ["DEEPSEEK_API_KEY"], "default_model": DEEPSEEK_MODEL,
1551
+ "note": "reasoning models bill hidden chain-of-thought against "
1552
+ f"max_tokens ({AI_MAX_TOKENS}); an empty reply means the "
1553
+ "budget went on reasoning."},
1554
+ },
1555
+ "config_file": CONFIG_PATH,
1556
+ "safety": {
1557
+ "applies_by_default": False,
1558
+ "apply_requires": "--apply --yes",
1559
+ "blocked_paths": list(BLOCKED_PATHS),
1560
+ "danger_patterns": [{"pattern": p, "reason": r} for p, r in DANGER_PATTERNS],
1561
+ "sensitive_sections_redacted_in_reports": list(REDACTED_SECTIONS),
1562
+ },
1563
+ "examples": [
1564
+ "rex.py --capabilities",
1565
+ "rex.py --audit --json",
1566
+ "rex.py --audit --json --sections ssh-config,firewall --fail-on critical",
1567
+ "rex.py --audit --json --output /tmp/report.json",
1568
+ "rex.py --fix ssh-config --provider deepseek --json",
1569
+ "rex.py --fix 'SSH Config' --provider deepseek --print-prompt --json",
1570
+ "rex.py --fix ssh-config --report /tmp/report.json --provider deepseek --json",
1571
+ ],
1572
+ }
1573
+
1574
+
1575
+ # ============================================================
1576
+ # CLI
1577
+ # ============================================================
1578
+ def _resolve_section(name: str):
1579
+ """Accept a stable id ('ssh-config') or an exact/partial section name."""
1580
+ if not name:
1581
+ return None
1582
+ slug = section_id(name)
1583
+ for s in SECTIONS:
1584
+ if section_id(s) == slug:
1585
+ return s
1586
+ lowered = name.strip().lower()
1587
+ matches = [s for s in SECTIONS if lowered in s.lower()]
1588
+ if len(matches) == 1:
1589
+ return matches[0]
1590
+ if len(matches) > 1:
1591
+ raise ValueError(f"ambiguous section {name!r} matches: {matches}")
1592
+ return None
1593
+
1594
+
1595
+ def _build_argparser():
1596
+ import argparse
1597
+ p = argparse.ArgumentParser(
1598
+ prog="rex.py",
1599
+ description=f"{APP_NAME} v{VERSION} — security audit + AI remediation.",
1600
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1601
+ epilog="Run with no arguments for the GUI.\n"
1602
+ "Machine contract: rex.py --capabilities",
1603
+ )
1604
+ p.add_argument("--gui", action="store_true", help="force the GUI")
1605
+ p.add_argument("--version", action="version", version=f"{APP_NAME} {VERSION}")
1606
+ p.add_argument("--capabilities", action="store_true",
1607
+ help="print the machine-readable capability document and exit")
1608
+ p.add_argument("--list-checks", action="store_true",
1609
+ help="list audit sections (ids usable with --sections/--fix)")
1610
+ p.add_argument("--audit", action="store_true", help="run a headless audit")
1611
+ p.add_argument("--json", action="store_true",
1612
+ help="emit JSON on stdout (stable; see --capabilities)")
1613
+ p.add_argument("--sections", metavar="ID,ID",
1614
+ help="comma-separated section ids or names (default: all)")
1615
+ p.add_argument("--scan-path", metavar="DIR",
1616
+ help="directory for the malware scan (default: home directory)")
1617
+ p.add_argument("--output", metavar="FILE", help="write the report to FILE as well")
1618
+ p.add_argument("--fail-on", choices=["none", "warn", "critical"], default="none",
1619
+ help="exit-code threshold for CI gating (default: none — the audit "
1620
+ "always exits 0 when it ran; pass --fail-on warn|critical to "
1621
+ "exit 1 when findings meet the threshold)")
1622
+ p.add_argument("--sequential", action="store_true",
1623
+ help="run checks one at a time (easier to follow in a log)")
1624
+ p.add_argument("--fix", metavar="SECTION",
1625
+ help="ask a provider for the remediation of one finding")
1626
+ p.add_argument("--provider", choices=PROVIDER_IDS, help="AI provider for --fix")
1627
+ p.add_argument("--model", help="override the provider's model")
1628
+ p.add_argument("--api-key", help="override the provider's API key")
1629
+ p.add_argument("--ollama-url", help=f"Ollama base URL (default: {OLLAMA_URL})")
1630
+ p.add_argument("--report", metavar="FILE",
1631
+ help="reuse findings from a previous --audit --json report")
1632
+ p.add_argument("--print-prompt", action="store_true",
1633
+ help="emit the exact remediation prompt instead of calling a provider")
1634
+ p.add_argument("--apply", action="store_true",
1635
+ help="execute the suggested commands (dry-run without --yes)")
1636
+ p.add_argument("--yes", action="store_true",
1637
+ help="confirm execution for --apply")
1638
+ return p
1639
+
1640
+
1641
+ def _emit(payload, as_json: bool, text: str = ""):
1642
+ if as_json:
1643
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
1644
+ elif text:
1645
+ print(text)
1646
+
1647
+
1648
+ def _provider_for(args, cfg) -> str:
1649
+ provider = args.provider or cfg.get("provider") or _load_config().get("ai_provider")
1650
+ if provider not in PROVIDER_IDS:
1651
+ provider = "ollama"
1652
+ return provider
1653
+
1654
+
1655
+ def _cli_main(argv) -> int:
1656
+ args = _build_argparser().parse_args(argv)
1657
+
1658
+ # ── discovery ────────────────────────────────────────────
1659
+ if args.capabilities:
1660
+ _emit(capabilities(), True)
1661
+ return EXIT_CLEAN
1662
+
1663
+ if args.list_checks:
1664
+ checks = [{"id": section_id(s), "section": s,
1665
+ "index": i, "platform": _OS} for i, s in enumerate(SECTIONS)]
1666
+ text = "\n".join(f"{c['id']:<28} {c['section']}" for c in checks)
1667
+ _emit({"schema": SCHEMA_CAPABILITIES, "version": VERSION, "checks": checks},
1668
+ args.json, text)
1669
+ return EXIT_CLEAN
1670
+
1671
+ if not (args.audit or args.fix):
1672
+ print("Nothing to do. Try --audit, --fix, --capabilities, or run without "
1673
+ "arguments for the GUI.", file=sys.stderr)
1674
+ return EXIT_USAGE
1675
+
1676
+ # ── section selection ────────────────────────────────────
1677
+ sections = list(SECTIONS)
1678
+ if args.sections:
1679
+ sections = []
1680
+ for raw in args.sections.split(","):
1681
+ raw = raw.strip()
1682
+ if not raw:
1683
+ continue
1684
+ resolved = _resolve_section(raw)
1685
+ if resolved is None:
1686
+ print(f"unknown section: {raw!r} (see --list-checks)", file=sys.stderr)
1687
+ return EXIT_USAGE
1688
+ if resolved not in sections:
1689
+ sections.append(resolved)
1690
+
1691
+ scan_path = args.scan_path or os.path.expanduser("~")
1692
+
1693
+ # ── audit ────────────────────────────────────────────────
1694
+ results = None
1695
+ if args.audit:
1696
+ engine = AuditEngine(sections, scan_path=scan_path)
1697
+ if args.sequential:
1698
+ results = engine.collect(progress=None)
1699
+ else:
1700
+ results = engine.collect_parallel()
1701
+ report = build_report(results, scan_path=scan_path)
1702
+
1703
+ if args.output:
1704
+ try:
1705
+ with open(args.output, "w", encoding="utf-8") as f:
1706
+ json.dump(report, f, indent=2, ensure_ascii=False)
1707
+ if not IS_WIN:
1708
+ os.chmod(args.output, 0o600)
1709
+ except OSError as e:
1710
+ print(f"could not write {args.output}: {e}", file=sys.stderr)
1711
+ return EXIT_USAGE
1712
+
1713
+ if args.json:
1714
+ print(json.dumps(report, indent=2, ensure_ascii=False))
1715
+ else:
1716
+ s = report["summary"]
1717
+ print(f"{APP_NAME} v{VERSION} — {report['hostname']} ({report['platform']})")
1718
+ print(f"score {s['score']} ({s['grade']}) — {s['fixable']} finding(s), "
1719
+ f"{s['clean']} clean, {s['not_applicable']} n/a")
1720
+ for f in report["findings"]:
1721
+ if f["fixable"]:
1722
+ print(f" {f['severity']:<8} {f['id']:<28} {f['section']}")
1723
+
1724
+ # ── fix ──────────────────────────────────────────────────
1725
+ if args.fix:
1726
+ section = _resolve_section(args.fix)
1727
+ if section is None:
1728
+ print(f"unknown section: {args.fix!r} (see --list-checks)", file=sys.stderr)
1729
+ return EXIT_USAGE
1730
+
1731
+ finding = ""
1732
+ redacted = False
1733
+ if args.report:
1734
+ try:
1735
+ with open(args.report, encoding="utf-8") as f:
1736
+ prior = json.load(f)
1737
+ except (OSError, ValueError) as e:
1738
+ print(f"could not read report {args.report}: {e}", file=sys.stderr)
1739
+ return EXIT_USAGE
1740
+ hit = next((f for f in prior.get("findings", [])
1741
+ if f.get("id") == section_id(section)), None)
1742
+ if hit is None:
1743
+ print(f"section {section!r} not present in {args.report}",
1744
+ file=sys.stderr)
1745
+ return EXIT_USAGE
1746
+ finding = hit.get("output") or ""
1747
+ redacted = bool(hit.get("redacted"))
1748
+ if redacted and not args.yes:
1749
+ print(f"{section!r} is redacted in that report — re-run without "
1750
+ f"--report to audit it live, or pass --yes to send the "
1751
+ f"placeholder text.", file=sys.stderr)
1752
+ return EXIT_USAGE
1753
+ else:
1754
+ engine = AuditEngine([section], scan_path=scan_path)
1755
+ _status, finding = engine.collect([section])[section]
1756
+
1757
+ prompt = build_prompt(section, finding)
1758
+ cfg = load_provider_config(
1759
+ args.provider,
1760
+ ollama_url=args.ollama_url,
1761
+ deepseek_api_key=args.api_key if args.provider == "deepseek" else None,
1762
+ claude_api_key=args.api_key if args.provider == "claude" else None,
1763
+ deepseek_model=args.model if args.provider == "deepseek" else None,
1764
+ claude_model=args.model if args.provider == "claude" else None,
1765
+ ollama_model=args.model if args.provider == "ollama" else None,
1766
+ )
1767
+ provider = _provider_for(args, cfg)
1768
+ model = cfg.get(f"{provider}_model")
1769
+
1770
+ if args.print_prompt:
1771
+ payload = {
1772
+ "schema": SCHEMA_FIX, "tool": APP_NAME, "version": VERSION,
1773
+ "section": section, "section_id": section_id(section),
1774
+ "provider": provider, "model": model,
1775
+ "redacted_finding": redacted, "prompt": prompt,
1776
+ "error": None, "commands": [], "blocked": [], "needs_sudo": [],
1777
+ # No provider was called and nothing was executed, but the key
1778
+ # is present so every fix/1 document has the same shape.
1779
+ "applied": False, "apply_results": None,
1780
+ }
1781
+ _emit(payload, args.json, prompt)
1782
+ return EXIT_CLEAN
1783
+
1784
+ def _status(msg):
1785
+ if not args.json:
1786
+ print(msg, file=sys.stderr)
1787
+
1788
+ def _token(tok):
1789
+ if not args.json:
1790
+ sys.stdout.write(tok)
1791
+ sys.stdout.flush()
1792
+
1793
+ text, err = provider_stream(provider, prompt, cfg,
1794
+ on_token=_token, on_status=_status)
1795
+ if not args.json and text:
1796
+ print()
1797
+
1798
+ plan = classify_commands(clean_commands(text or ""))
1799
+ payload = {
1800
+ "schema": SCHEMA_FIX, "tool": APP_NAME, "version": VERSION,
1801
+ "section": section, "section_id": section_id(section),
1802
+ "provider": provider, "model": model,
1803
+ "status": (results or {}).get(section, ("", ""))[0] if results else None,
1804
+ "redacted_finding": redacted,
1805
+ "error": err,
1806
+ "raw_response": text or "",
1807
+ "commands": [c["command"] for c in plan["executable"]],
1808
+ "blocked": plan["blocked"],
1809
+ "needs_sudo": plan["needs_sudo"],
1810
+ "applied": False,
1811
+ "apply_results": None,
1812
+ }
1813
+
1814
+ if err:
1815
+ if args.json:
1816
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
1817
+ else:
1818
+ print(err, file=sys.stderr)
1819
+ return EXIT_PROVIDER
1820
+
1821
+ if args.apply:
1822
+ if not args.yes:
1823
+ payload["applied"] = False
1824
+ payload["dry_run"] = True
1825
+ _emit(payload, args.json,
1826
+ "Dry run — re-run with --apply --yes to execute.")
1827
+ return EXIT_CLEAN
1828
+ ok, applied = apply_commands(
1829
+ [c["command"] for c in plan["executable"]],
1830
+ on_progress=_status,
1831
+ )
1832
+ payload["applied"] = True
1833
+ payload["apply_results"] = applied
1834
+ payload["apply_ok"] = ok
1835
+ payload["apply_summary"] = ("all commands succeeded" if ok
1836
+ else "some commands failed or were blocked")
1837
+ _emit(payload, args.json, payload["apply_summary"])
1838
+ return EXIT_CLEAN if ok else EXIT_APPLY
1839
+
1840
+ _emit(payload, args.json, "\n".join(payload["commands"])
1841
+ or "No executable commands in the model's reply.")
1842
+ return EXIT_CLEAN
1843
+
1844
+ # ── exit code from the audit ─────────────────────────────
1845
+ if results is None:
1846
+ return EXIT_CLEAN
1847
+ threshold = {"none": (), "warn": ("warn", "critical", "error"),
1848
+ "critical": ("critical",)}[args.fail_on]
1849
+ hit = any(st in threshold for st, _ in results.values())
1850
+ return EXIT_FINDINGS if hit else EXIT_CLEAN
1851
+
1852
+ class _ProviderWorker(QThread):
1853
+ """Qt wrapper around provider_stream(). The Qt-free function does the work;
1854
+ this only turns callbacks into signals, so the dialog and the CLI run the
1855
+ same prompt through the same parser."""
1856
+
1857
+ result_ready = pyqtSignal(str) # final full text (or error string)
1858
+ token_ready = pyqtSignal(str) # each streaming token
1859
+ status_update = pyqtSignal(str)
1860
+
1861
+ def __init__(self, prompt: str, provider: str, cfg: dict):
1862
+ QThread.__init__(self)
1863
+ self.prompt = prompt
1864
+ self.provider = provider
1865
+ self.cfg = cfg
1866
+ self._abort = False
1867
+
1868
+ def stop(self):
1869
+ self._abort = True
1870
+
1871
+ def run(self):
1872
+ text, err = provider_stream(
1873
+ self.provider, self.prompt, self.cfg,
1874
+ on_token=lambda t: None if self._abort else self.token_ready.emit(t),
1875
+ on_status=self.status_update.emit,
1876
+ )
1877
+ self.result_ready.emit(err or text)
1878
+
1879
+
1880
+ class OllamaWorker(_ProviderWorker):
1881
+ def __init__(self, prompt: str, model: str, base_url: str):
1882
+ super().__init__(prompt, "ollama",
1883
+ {"ollama_model": model, "ollama_url": base_url})
1884
+
1885
+
1886
+ class ClaudeWorker(_ProviderWorker):
1887
+ def __init__(self, prompt: str, model: str, api_key: str):
1888
+ super().__init__(prompt, "claude",
1889
+ {"claude_model": model, "claude_api_key": api_key})
1890
+
1891
+
1892
+ class DeepSeekWorker(_ProviderWorker):
1893
+ """Streams a remediation suggestion from DeepSeek's OpenAI-compatible API."""
1894
+
1895
+ def __init__(self, prompt: str, model: str, api_key: str,
1896
+ base_url: str = DEEPSEEK_URL):
1897
+ super().__init__(prompt, "deepseek",
1898
+ {"deepseek_model": model, "deepseek_api_key": api_key,
1899
+ "deepseek_url": base_url})
1900
+
1901
+
1902
+
1903
+ class ApplyWorker(QThread):
1904
+ progress = pyqtSignal(str)
1905
+ finished = pyqtSignal(str, str) # summary, full output
1906
+
1907
+ def __init__(self, cmds, blocked_paths, sudo_paths):
1908
+ QThread.__init__(self)
1909
+ self.cmds = cmds
1910
+ self.blocked_paths = blocked_paths
1911
+ self.sudo_paths = sudo_paths
1912
+
1913
+ def run(self):
1914
+ ok, results = apply_commands(
1915
+ self.cmds,
1916
+ on_progress=self.progress.emit,
1917
+ blocked_paths=self.blocked_paths,
1918
+ sudo_paths=self.sudo_paths,
1919
+ )
1920
+ lines = []
1921
+ for r in results:
1922
+ if r.get("blocked"):
1923
+ lines.append(f"⛔ $ {r['command']}\n"
1924
+ f"[Blocked: {r['blocked']} — not executed]")
1925
+ elif r.get("needs_manual_sudo"):
1926
+ lines.append(f"⚠ $ {r['command']}\n"
1927
+ f"[Needs sudo password — run manually:\n"
1928
+ f" {r['needs_manual_sudo']}]")
1929
+ elif r.get("error"):
1930
+ lines.append(f"✖ $ {r['command']}\nError: {r['error']}")
1931
+ else:
1932
+ mark = "✓" if r["ok"] else f"✖ (exit {r.get('exit_code')})"
1933
+ disp = (("sudo " + r["command"]) if r.get("ran_as") == "sudo -n"
1934
+ else r["command"])
1935
+ lines.append(f"{mark} $ {disp}\n{r['output']}")
1936
+ summary = ("✓ All commands succeeded" if ok
1937
+ else "⚠ Some commands need manual review")
1938
+ self.finished.emit(summary, "\n\n".join(lines))
1939
+
1940
+
1941
+
1942
+ class RemediationDialog(QDialog):
1943
+ def __init__(self, parent, section: str, finding: str,
1944
+ provider: str = "ollama",
1945
+ ollama_url: str = "http://localhost:11434",
1946
+ ollama_model: str = "llama3.2",
1947
+ claude_api_key: str = "",
1948
+ claude_model: str = "claude-haiku-4-5-20251001",
1949
+ deepseek_api_key: str = "",
1950
+ deepseek_model: str = DEEPSEEK_MODEL,
1951
+ deepseek_url: str = DEEPSEEK_URL):
1952
+ super().__init__(parent)
1953
+ self.setWindowTitle(f"AI Fix — {section}")
1954
+ self.resize(700, 520)
1955
+ self.setStyleSheet(f"""
1956
+ QDialog {{ background:{COLORS['bg2']}; color:{COLORS['text']}; }}
1957
+ QLabel {{ color:{COLORS['text']}; font-size:12px; }}
1958
+ QTextEdit {{ background:{COLORS['bg3']}; color:{COLORS['text']};
1959
+ border:1px solid {COLORS['border']}; border-radius:4px;
1960
+ font-family:'JetBrains Mono','Fira Mono',monospace;
1961
+ font-size:12px; padding:8px; }}
1962
+ QPushButton {{ background:{COLORS['bg3']}; color:{COLORS['cyan']};
1963
+ border:1px solid {COLORS['border']}; border-radius:4px;
1964
+ padding:7px 18px; font-size:12px; }}
1965
+ QPushButton#apply {{ background:{COLORS['cyan']}; color:{COLORS['bg']};
1966
+ font-weight:bold; }}
1967
+ QPushButton:hover {{ border-color:{COLORS['cyan_dim']}; }}
1968
+ QPushButton:disabled {{ color:{COLORS['text3']}; border-color:{COLORS['bg3']}; }}
1969
+ """)
1970
+
1971
+ self.applied_output = None
1972
+ layout = QVBoxLayout(self)
1973
+ layout.setSpacing(10)
1974
+ layout.setContentsMargins(16, 16, 16, 16)
1975
+
1976
+
1977
+ layout.addWidget(QLabel(f"<b style='color:{COLORS['cyan']}'>Section:</b> {section}"))
1978
+
1979
+ lbl_f = QLabel("Finding:")
1980
+ lbl_f.setStyleSheet(f"color:{COLORS['text2']};")
1981
+ layout.addWidget(lbl_f)
1982
+ finding_view = QTextEdit()
1983
+ finding_view.setReadOnly(True)
1984
+ finding_view.setPlainText(finding[:900] + ("…" if len(finding) > 900 else ""))
1985
+ finding_view.setFixedHeight(120)
1986
+ layout.addWidget(finding_view)
1987
+
1988
+ provider_label = {
1989
+ "claude": "Claude", "deepseek": "DeepSeek",
1990
+ }.get(provider, "Ollama")
1991
+ lbl_fix = QLabel(f"🤖 {provider_label} suggestion (you can edit before applying):")
1992
+ lbl_fix.setStyleSheet(f"color:{COLORS['cyan']};font-weight:bold;")
1993
+ layout.addWidget(lbl_fix)
1994
+
1995
+ self.lbl_ai_status = QLabel(f"⏳ Connecting to {provider_label}…")
1996
+ self.lbl_ai_status.setStyleSheet(f"color:{COLORS['text2']};font-size:11px;")
1997
+ layout.addWidget(self.lbl_ai_status)
1998
+
1999
+ self.fix_view = QTextEdit()
2000
+ self.fix_view.setPlaceholderText("Response will appear here…")
2001
+ layout.addWidget(self.fix_view)
2002
+
2003
+ lbl_warn = QLabel("⚠ Always review the command before applying.")
2004
+ lbl_warn.setStyleSheet(f"color:{COLORS['orange']};font-size:11px;")
2005
+ layout.addWidget(lbl_warn)
2006
+
2007
+ btn_row = QHBoxLayout()
2008
+ self.btn_apply = QPushButton("▶ Apply Fix")
2009
+ self.btn_apply.setObjectName("apply")
2010
+ self.btn_apply.setEnabled(False)
2011
+ btn_skip = QPushButton("Close")
2012
+ btn_row.addWidget(self.btn_apply)
2013
+ btn_row.addStretch()
2014
+ btn_row.addWidget(btn_skip)
2015
+ layout.addLayout(btn_row)
2016
+
2017
+ self.btn_apply.clicked.connect(self._apply)
2018
+ btn_skip.clicked.connect(self.reject)
2019
+
2020
+ # Start AI query in background QThread
2021
+ prompt = REMEDIATION_PROMPT.format(
2022
+ os=_OS, section=section, finding=finding[:1200]
2023
+ )
2024
+ if provider == "claude":
2025
+ self._worker = ClaudeWorker(prompt, claude_model, claude_api_key)
2026
+ elif provider == "deepseek":
2027
+ self._worker = DeepSeekWorker(prompt, deepseek_model,
2028
+ deepseek_api_key, deepseek_url)
2029
+ else:
2030
+ self._worker = OllamaWorker(prompt, ollama_model, ollama_url)
2031
+ self._worker.status_update.connect(self._on_status)
2032
+ self._worker.token_ready.connect(self._on_token)
2033
+ self._worker.result_ready.connect(self._on_result)
2034
+ self._worker.start()
2035
+
2036
+ def _on_status(self, msg: str):
2037
+ self.lbl_ai_status.setText(f"⏳ {msg}")
2038
+
2039
+ def _on_token(self, token: str):
2040
+ self.lbl_ai_status.setText("✍ Generating…")
2041
+ cursor = self.fix_view.textCursor()
2042
+ cursor.movePosition(cursor.MoveOperation.End)
2043
+ cursor.insertText(token)
2044
+ self.fix_view.setTextCursor(cursor)
2045
+
2046
+ def _on_result(self, text: str):
2047
+ if text.startswith("["):
2048
+ self.lbl_ai_status.setText(f"✖ {text}")
2049
+ self.fix_view.setPlainText(text)
2050
+ return
2051
+
2052
+ if not text or text == "NO_FIX_AVAILABLE":
2053
+ self.lbl_ai_status.setText("ℹ No automated fix available for this finding.")
2054
+ self.fix_view.setPlainText(text or "NO_FIX_AVAILABLE")
2055
+ return
2056
+
2057
+ # Validate every command and annotate dangerous ones in-place
2058
+ lines = text.splitlines()
2059
+ annotated = []
2060
+ has_danger = False
2061
+ for line in lines:
2062
+ danger = self._check_danger(line.strip())
2063
+ if danger:
2064
+ has_danger = True
2065
+ annotated.append(f"# ⛔ BLOCKED — {danger}\n# {line}")
2066
+ else:
2067
+ annotated.append(line)
2068
+
2069
+ self.fix_view.setPlainText("\n".join(annotated))
2070
+
2071
+ if has_danger:
2072
+ self.lbl_ai_status.setText(
2073
+ "⚠ Dangerous commands detected (blocked with # ⛔) — review before applying"
2074
+ )
2075
+ self.lbl_ai_status.setStyleSheet(f"color:{COLORS['orange']};font-size:11px;font-weight:bold;")
2076
+ else:
2077
+ self.lbl_ai_status.setText("✓ Done — review and edit before applying")
2078
+ self.btn_apply.setEnabled(True)
2079
+
2080
+ # Kept as class attributes for compatibility. The definitions live at
2081
+ # module level so that `--apply` and this dialog cannot disagree about
2082
+ # what is safe to run — one classifier, two front-ends.
2083
+ _DANGER_PATTERNS = DANGER_PATTERNS
2084
+ _BLOCKED_PATHS = BLOCKED_PATHS
2085
+ _SUDO_PATHS = SUDO_PATHS
2086
+
2087
+ @classmethod
2088
+ def _check_danger(cls, cmd: str) -> str:
2089
+ """Return a human-readable reason if cmd is dangerous, else empty string."""
2090
+ return danger_reason(cmd)
2091
+
2092
+ @staticmethod
2093
+ def _clean_commands(raw: str) -> list:
2094
+ """Strip LLM formatting noise and return executable command lines."""
2095
+ return clean_commands(raw)
2096
+
2097
+
2098
+ def _apply(self):
2099
+ raw = self.fix_view.toPlainText().strip()
2100
+ if not raw:
2101
+ return
2102
+ cmds = self._clean_commands(raw)
2103
+ if not cmds:
2104
+ self.applied_output = "No executable commands found in suggestion."
2105
+ self.accept()
2106
+ return
2107
+
2108
+ self.btn_apply.setEnabled(False)
2109
+ self.btn_apply.setText("⏳ Running…")
2110
+ self.lbl_ai_status.setText("⏳ Applying fix…")
2111
+
2112
+ self._apply_worker = ApplyWorker(cmds, self._BLOCKED_PATHS, self._SUDO_PATHS)
2113
+ self._apply_worker.progress.connect(self._on_apply_progress)
2114
+ self._apply_worker.finished.connect(self._on_apply_done)
2115
+ self._apply_worker.start()
2116
+
2117
+ def _on_apply_progress(self, line: str):
2118
+ self.lbl_ai_status.setText(f"⏳ {line}")
2119
+
2120
+ def _on_apply_done(self, summary: str, output: str):
2121
+ self.applied_output = f"{summary}\n\n{output}"
2122
+ self.accept()
2123
+
2124
+
2125
+ # ============================================================
2126
+ # HUVUD FÖNSTER
2127
+ # ============================================================
2128
+ class SecurityAuditWindow(QMainWindow):
2129
+ def __init__(self):
2130
+ super().__init__()
2131
+ self.setWindowTitle(f"{APP_NAME} v{VERSION}")
2132
+ self.resize(1200, 800)
2133
+ self.results = {}
2134
+ self.worker = None
2135
+ self._apply_theme()
2136
+ self._build_ui()
2137
+ self._restore_config()
2138
+
2139
+ def _restore_config(self):
2140
+ cfg = _load_config()
2141
+ if cfg.get("ollama_url"):
2142
+ self.ollama_url.setText(cfg["ollama_url"])
2143
+ if cfg.get("ollama_model"):
2144
+ self.ollama_model.setText(cfg["ollama_model"])
2145
+ if cfg.get("claude_api_key"):
2146
+ self.claude_api_key.setText(cfg["claude_api_key"])
2147
+ if cfg.get("claude_model"):
2148
+ self.claude_model.setText(cfg["claude_model"])
2149
+ if cfg.get("deepseek_api_key"):
2150
+ self.deepseek_api_key.setText(cfg["deepseek_api_key"])
2151
+ elif os.environ.get("DEEPSEEK_API_KEY"):
2152
+ # Convenience: a key already exported in the shell just works.
2153
+ self.deepseek_api_key.setText(os.environ["DEEPSEEK_API_KEY"])
2154
+ if cfg.get("deepseek_model"):
2155
+ self.deepseek_model.setText(cfg["deepseek_model"])
2156
+ if cfg.get("ai_provider") in PROVIDER_IDS:
2157
+ self.provider_combo.setCurrentIndex(PROVIDER_IDS.index(cfg["ai_provider"]))
2158
+
2159
+ def _apply_theme(self):
2160
+ self.setStyleSheet(f"""
2161
+ QMainWindow, QWidget {{
2162
+ background-color: {COLORS['bg']};
2163
+ color: {COLORS['text']};
2164
+ font-family: 'JetBrains Mono', 'Fira Mono', 'Courier New', monospace;
2165
+ font-size: 12px;
2166
+ }}
2167
+ QScrollBar:vertical {{
2168
+ background: {COLORS['bg2']};
2169
+ width: 6px;
2170
+ border: none;
2171
+ }}
2172
+ QScrollBar::handle:vertical {{
2173
+ background: {COLORS['border']};
2174
+ min-height: 20px;
2175
+ border-radius: 3px;
2176
+ }}
2177
+ QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }}
2178
+ QListWidget {{
2179
+ background: {COLORS['bg2']};
2180
+ border: 1px solid {COLORS['border']};
2181
+ border-radius: 4px;
2182
+ outline: none;
2183
+ }}
2184
+ QListWidget::item {{
2185
+ padding: 6px 10px;
2186
+ border-bottom: 1px solid {COLORS['bg3']};
2187
+ }}
2188
+ QListWidget::item:selected {{
2189
+ background: #1a1a2e;
2190
+ }}
2191
+ QListWidget::item:hover {{
2192
+ background: {COLORS['bg3']};
2193
+ }}
2194
+ QPushButton {{
2195
+ background: {COLORS['bg3']};
2196
+ color: {COLORS['cyan']};
2197
+ border: 1px solid {COLORS['border']};
2198
+ border-radius: 4px;
2199
+ padding: 6px 14px;
2200
+ font-size: 11px;
2201
+ }}
2202
+ QPushButton:hover {{
2203
+ background: {COLORS['border']};
2204
+ border-color: {COLORS['cyan_dim']};
2205
+ }}
2206
+ QPushButton:disabled {{
2207
+ color: {COLORS['text3']};
2208
+ border-color: {COLORS['bg3']};
2209
+ }}
2210
+ QPushButton#btn_run {{
2211
+ background: {COLORS['cyan']};
2212
+ color: {COLORS['bg']};
2213
+ font-weight: bold;
2214
+ }}
2215
+ QPushButton#btn_run:hover {{
2216
+ background: {COLORS['cyan_dim']};
2217
+ }}
2218
+ QTextEdit {{
2219
+ background: {COLORS['bg2']};
2220
+ color: {COLORS['text']};
2221
+ border: 1px solid {COLORS['border']};
2222
+ border-radius: 4px;
2223
+ padding: 10px;
2224
+ font-family: 'JetBrains Mono', 'Fira Mono', 'Courier New', monospace;
2225
+ font-size: 13px;
2226
+ line-height: 1.5;
2227
+ }}
2228
+ QProgressBar {{
2229
+ background: {COLORS['bg3']};
2230
+ border: 1px solid {COLORS['border']};
2231
+ border-radius: 3px;
2232
+ height: 6px;
2233
+ text-align: center;
2234
+ }}
2235
+ QProgressBar::chunk {{
2236
+ background: {COLORS['cyan']};
2237
+ border-radius: 3px;
2238
+ }}
2239
+ QLabel#title {{
2240
+ color: {COLORS['cyan']};
2241
+ font-size: 16px;
2242
+ font-weight: bold;
2243
+ letter-spacing: 2px;
2244
+ }}
2245
+ QLabel#subtitle {{
2246
+ color: {COLORS['text3']};
2247
+ font-size: 10px;
2248
+ }}
2249
+ QFrame#sidebar {{
2250
+ background: {COLORS['bg2']};
2251
+ border-right: 1px solid {COLORS['border']};
2252
+ }}
2253
+ """)
2254
+
2255
+ def _build_ui(self):
2256
+ central = LatticeWidget()
2257
+ central.setAutoFillBackground(True)
2258
+ pal = central.palette()
2259
+ pal.setColor(QPalette.ColorRole.Window, QColor(COLORS["bg"]))
2260
+ central.setPalette(pal)
2261
+ self.setCentralWidget(central)
2262
+
2263
+ root = QHBoxLayout(central)
2264
+ root.setContentsMargins(0, 0, 0, 0)
2265
+ root.setSpacing(0)
2266
+
2267
+ # ── SIDEBAR ──────────────────────────────────────────
2268
+ sidebar = QFrame()
2269
+ sidebar.setObjectName("sidebar")
2270
+ sidebar.setFixedWidth(220)
2271
+ sb = QVBoxLayout(sidebar)
2272
+ sb.setContentsMargins(12, 16, 12, 12)
2273
+ sb.setSpacing(8)
2274
+
2275
+ lbl_title = QLabel("ÆGIS")
2276
+ lbl_title.setObjectName("title")
2277
+ sb.addWidget(lbl_title)
2278
+
2279
+ lbl_sub = QLabel(f"Security Audit v{VERSION} [{_OS}]")
2280
+ lbl_sub.setObjectName("subtitle")
2281
+ sb.addWidget(lbl_sub)
2282
+
2283
+ sb.addSpacing(12)
2284
+
2285
+ self.section_list = QListWidget()
2286
+ for s in SECTIONS:
2287
+ item = QListWidgetItem(f" {s}")
2288
+ item.setForeground(QColor(COLORS["text2"]))
2289
+ self.section_list.addItem(item)
2290
+ self.section_list.currentRowChanged.connect(self._on_section_select)
2291
+ sb.addWidget(self.section_list)
2292
+
2293
+ sb.addSpacing(8)
2294
+
2295
+ self.progress_bar = QProgressBar()
2296
+ self.progress_bar.setRange(0, len(AUDIT_SECTIONS))
2297
+ self.progress_bar.setValue(0)
2298
+ self.progress_bar.setTextVisible(False)
2299
+ sb.addWidget(self.progress_bar)
2300
+
2301
+ self.lbl_status = QLabel("Ready")
2302
+ self.lbl_status.setStyleSheet(f"color: {COLORS['text3']}; font-size: 10px;")
2303
+ sb.addWidget(self.lbl_status)
2304
+
2305
+ sb.addSpacing(8)
2306
+
2307
+ btn_run = QPushButton("▶ Run Audit")
2308
+ btn_run.setObjectName("btn_run")
2309
+ btn_run.clicked.connect(self._run_audit)
2310
+ sb.addWidget(btn_run)
2311
+ self.btn_run = btn_run
2312
+
2313
+ btn_export = QPushButton("↓ Export JSON")
2314
+ btn_export.clicked.connect(self._export)
2315
+ btn_export.setEnabled(False)
2316
+ sb.addWidget(btn_export)
2317
+ self.btn_export = btn_export
2318
+
2319
+ btn_stop = QPushButton("■ Stop")
2320
+ btn_stop.clicked.connect(self._stop_audit)
2321
+ btn_stop.setEnabled(False)
2322
+ sb.addWidget(btn_stop)
2323
+ self.btn_stop = btn_stop
2324
+
2325
+ sb.addSpacing(8)
2326
+
2327
+ # ── AI Provider settings ──────────────────────────────
2328
+ from PyQt6.QtWidgets import QComboBox
2329
+
2330
+ lbl_ai = QLabel("AI Provider")
2331
+ lbl_ai.setStyleSheet(f"color:{COLORS['text3']};font-size:10px;letter-spacing:1px;")
2332
+ sb.addWidget(lbl_ai)
2333
+
2334
+ self.provider_combo = QComboBox()
2335
+ self.provider_combo.addItems(["Ollama", "Claude", "DeepSeek"])
2336
+ self.provider_combo.setStyleSheet(
2337
+ f"background:{COLORS['bg3']};color:{COLORS['text2']};border:1px solid {COLORS['border']};"
2338
+ f"border-radius:4px;padding:3px 6px;font-size:10px;"
2339
+ )
2340
+ sb.addWidget(self.provider_combo)
2341
+
2342
+ # Ollama fields
2343
+ self._ollama_widget = QWidget()
2344
+ ollama_layout = QVBoxLayout(self._ollama_widget)
2345
+ ollama_layout.setContentsMargins(0, 0, 0, 0)
2346
+ ollama_layout.setSpacing(4)
2347
+
2348
+ self.ollama_url = QLineEdit("http://localhost:11434")
2349
+ self.ollama_url.setPlaceholderText("Ollama URL")
2350
+ self.ollama_url.setStyleSheet(
2351
+ f"background:{COLORS['bg3']};color:{COLORS['text2']};border:1px solid {COLORS['border']};"
2352
+ f"border-radius:4px;padding:3px 6px;font-size:10px;"
2353
+ )
2354
+ ollama_layout.addWidget(self.ollama_url)
2355
+
2356
+ self.ollama_model = QLineEdit("llama3.2")
2357
+ self.ollama_model.setPlaceholderText("model name")
2358
+ self.ollama_model.setStyleSheet(
2359
+ f"background:{COLORS['bg3']};color:{COLORS['text2']};border:1px solid {COLORS['border']};"
2360
+ f"border-radius:4px;padding:3px 6px;font-size:10px;"
2361
+ )
2362
+ ollama_layout.addWidget(self.ollama_model)
2363
+ sb.addWidget(self._ollama_widget)
2364
+
2365
+ # Claude fields
2366
+ self._claude_widget = QWidget()
2367
+ claude_layout = QVBoxLayout(self._claude_widget)
2368
+ claude_layout.setContentsMargins(0, 0, 0, 0)
2369
+ claude_layout.setSpacing(4)
2370
+
2371
+ self.claude_api_key = QLineEdit()
2372
+ self.claude_api_key.setPlaceholderText("sk-ant-… API key")
2373
+ self.claude_api_key.setEchoMode(QLineEdit.EchoMode.Password)
2374
+ self.claude_api_key.setStyleSheet(
2375
+ f"background:{COLORS['bg3']};color:{COLORS['text2']};border:1px solid {COLORS['border']};"
2376
+ f"border-radius:4px;padding:3px 6px;font-size:10px;"
2377
+ )
2378
+ claude_layout.addWidget(self.claude_api_key)
2379
+
2380
+ self.claude_model = QLineEdit("claude-haiku-4-5-20251001")
2381
+ self.claude_model.setPlaceholderText("Claude model")
2382
+ self.claude_model.setStyleSheet(
2383
+ f"background:{COLORS['bg3']};color:{COLORS['text2']};border:1px solid {COLORS['border']};"
2384
+ f"border-radius:4px;padding:3px 6px;font-size:10px;"
2385
+ )
2386
+ claude_layout.addWidget(self.claude_model)
2387
+ self._claude_widget.setVisible(False)
2388
+ sb.addWidget(self._claude_widget)
2389
+
2390
+ # DeepSeek fields
2391
+ self._deepseek_widget = QWidget()
2392
+ deepseek_layout = QVBoxLayout(self._deepseek_widget)
2393
+ deepseek_layout.setContentsMargins(0, 0, 0, 0)
2394
+ deepseek_layout.setSpacing(4)
2395
+
2396
+ self.deepseek_api_key = QLineEdit()
2397
+ self.deepseek_api_key.setPlaceholderText("sk-… DeepSeek API key")
2398
+ self.deepseek_api_key.setEchoMode(QLineEdit.EchoMode.Password)
2399
+ self.deepseek_api_key.setStyleSheet(
2400
+ f"background:{COLORS['bg3']};color:{COLORS['text2']};border:1px solid {COLORS['border']};"
2401
+ f"border-radius:4px;padding:3px 6px;font-size:10px;"
2402
+ )
2403
+ deepseek_layout.addWidget(self.deepseek_api_key)
2404
+
2405
+ self.deepseek_model = QLineEdit(DEEPSEEK_MODEL)
2406
+ self.deepseek_model.setPlaceholderText("DeepSeek model")
2407
+ self.deepseek_model.setStyleSheet(
2408
+ f"background:{COLORS['bg3']};color:{COLORS['text2']};border:1px solid {COLORS['border']};"
2409
+ f"border-radius:4px;padding:3px 6px;font-size:10px;"
2410
+ )
2411
+ deepseek_layout.addWidget(self.deepseek_model)
2412
+
2413
+ lbl_ds_hint = QLabel("falls back to $DEEPSEEK_API_KEY")
2414
+ lbl_ds_hint.setStyleSheet(f"color:{COLORS['text3']};font-size:9px;")
2415
+ deepseek_layout.addWidget(lbl_ds_hint)
2416
+
2417
+ self._deepseek_widget.setVisible(False)
2418
+ sb.addWidget(self._deepseek_widget)
2419
+
2420
+ self.provider_combo.currentIndexChanged.connect(self._on_provider_change)
2421
+
2422
+ btn_ai_fix = QPushButton("🤖 AI Fix")
2423
+ btn_ai_fix.clicked.connect(self._ai_fix_current)
2424
+ btn_ai_fix.setEnabled(False)
2425
+ btn_ai_fix.setToolTip("Ask AI to suggest a fix for the selected section")
2426
+ sb.addWidget(btn_ai_fix)
2427
+ self.btn_ai_fix = btn_ai_fix
2428
+
2429
+ sb.addStretch()
2430
+ root.addWidget(sidebar)
2431
+
2432
+ # ── MAIN AREA ─────────────────────────────────────────
2433
+ main_area = QWidget()
2434
+ main_layout = QVBoxLayout(main_area)
2435
+ main_layout.setContentsMargins(16, 16, 16, 16)
2436
+ main_layout.setSpacing(8)
2437
+
2438
+ self.lbl_section = QLabel("Select a section →")
2439
+ self.lbl_section.setStyleSheet(
2440
+ f"color: {COLORS['cyan']}; font-size: 14px; font-weight: bold;"
2441
+ )
2442
+ main_layout.addWidget(self.lbl_section)
2443
+
2444
+ self.lbl_section_status = QLabel("")
2445
+ self.lbl_section_status.setStyleSheet(f"color: {COLORS['text3']}; font-size: 10px;")
2446
+ main_layout.addWidget(self.lbl_section_status)
2447
+
2448
+ # ── Virus scan toolbar (only visible for Virus Scan) ──
2449
+
2450
+ self.scan_toolbar = QWidget()
2451
+ scan_tb_layout = QHBoxLayout(self.scan_toolbar)
2452
+ scan_tb_layout.setContentsMargins(0, 0, 0, 4)
2453
+ scan_tb_layout.setSpacing(6)
2454
+
2455
+ lbl_path = QLabel("Path:")
2456
+ lbl_path.setStyleSheet(f"color:{COLORS['text2']};font-size:12px;")
2457
+ scan_tb_layout.addWidget(lbl_path)
2458
+
2459
+ self.scan_path_input = QLineEdit(os.path.expanduser("~"))
2460
+ self.scan_path_input.setStyleSheet(
2461
+ f"background:{COLORS['bg3']};color:{COLORS['text']};border:1px solid {COLORS['border']};"
2462
+ f"border-radius:4px;padding:5px 8px;font-size:12px;"
2463
+ )
2464
+ scan_tb_layout.addWidget(self.scan_path_input, stretch=1)
2465
+
2466
+ btn_browse = QPushButton("Browse…")
2467
+ btn_browse.setFixedWidth(80)
2468
+ btn_browse.clicked.connect(self._browse_scan_path)
2469
+ scan_tb_layout.addWidget(btn_browse)
2470
+
2471
+ self.btn_run_scan = QPushButton("▶ Run Virus Scan")
2472
+ self.btn_run_scan.setObjectName("btn_run")
2473
+ self.btn_run_scan.setFixedWidth(150)
2474
+ self.btn_run_scan.clicked.connect(self._run_virus_scan)
2475
+ scan_tb_layout.addWidget(self.btn_run_scan)
2476
+
2477
+ self.btn_stop_scan = QPushButton("■ Stop")
2478
+ self.btn_stop_scan.setFixedWidth(70)
2479
+ self.btn_stop_scan.setEnabled(False)
2480
+ self.btn_stop_scan.clicked.connect(self._stop_virus_scan)
2481
+ scan_tb_layout.addWidget(self.btn_stop_scan)
2482
+
2483
+ self.scan_toolbar.setVisible(False)
2484
+ main_layout.addWidget(self.scan_toolbar)
2485
+
2486
+ self.output_view = QTextEdit()
2487
+ self.output_view.setReadOnly(True)
2488
+ self.output_view.setPlaceholderText("Run audit to populate results...")
2489
+ main_layout.addWidget(self.output_view)
2490
+
2491
+ root.addWidget(main_area)
2492
+
2493
+ self.section_list.setCurrentRow(0)
2494
+
2495
+ def _on_provider_change(self, index: int):
2496
+ self._ollama_widget.setVisible(index == 0)
2497
+ self._claude_widget.setVisible(index == 1)
2498
+ self._deepseek_widget.setVisible(index == 2)
2499
+ _save_config({"ai_provider": PROVIDER_IDS[index]})
2500
+
2501
+ def _browse_scan_path(self):
2502
+ path = QFileDialog.getExistingDirectory(self, "Select scan directory",
2503
+ self.scan_path_input.text())
2504
+ if path:
2505
+ self.scan_path_input.setText(path)
2506
+
2507
+ def _on_section_select(self, row):
2508
+ if row < 0:
2509
+ return
2510
+ section = SECTIONS[row]
2511
+ self.lbl_section.setText(section)
2512
+ is_virus = section == VIRUS_SECTION
2513
+ self.scan_toolbar.setVisible(is_virus)
2514
+ if section in self.results:
2515
+ status, output = self.results[section]
2516
+ self._show_output(status, output)
2517
+ self.btn_ai_fix.setEnabled(status in ("warn", "critical", "error"))
2518
+ else:
2519
+ self.output_view.clear()
2520
+ if is_virus:
2521
+ self.lbl_section_status.setText("Choose a path and click Run Virus Scan")
2522
+ else:
2523
+ self.lbl_section_status.setText("Not yet scanned")
2524
+ self.btn_ai_fix.setEnabled(False)
2525
+
2526
+ def _show_output(self, status, output):
2527
+ colors = {
2528
+ "ok": COLORS["green"],
2529
+ "warn": COLORS["orange"],
2530
+ "critical": COLORS["red"],
2531
+ "error": COLORS["red"],
2532
+ }
2533
+ labels = {
2534
+ "ok": "✓ OK",
2535
+ "warn": "⚠ WARNING",
2536
+ "critical": "✖ CRITICAL",
2537
+ "error": "✖ ERROR",
2538
+ }
2539
+ c = colors.get(status, COLORS["text2"])
2540
+ label = labels.get(status, status.upper())
2541
+ self.lbl_section_status.setText(label)
2542
+ self.lbl_section_status.setStyleSheet(f"color:{c};font-size:11px;font-weight:bold;")
2543
+ escaped = output.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
2544
+ html_lines = []
2545
+ for line in escaped.splitlines():
2546
+ if line.startswith("⚠") or "WARNING" in line or "FOUND" in line:
2547
+ html_lines.append(f'<span style="color:{COLORS["orange"]}">{line}</span>')
2548
+ elif line.startswith("✖") or "ERROR" in line or "CRITICAL" in line:
2549
+ html_lines.append(f'<span style="color:{COLORS["red"]}">{line}</span>')
2550
+ elif line.startswith("✓") or "OK" in line or "Clean" in line:
2551
+ html_lines.append(f'<span style="color:{COLORS["green"]}">{line}</span>')
2552
+ else:
2553
+ html_lines.append(f'<span style="color:{COLORS["text"]}">{line}</span>')
2554
+ html = (
2555
+ f'<div style="font-family:\'JetBrains Mono\',\'Fira Mono\',monospace;'
2556
+ f'font-size:13px;line-height:1.7;background:{COLORS["bg2"]};'
2557
+ f'color:{COLORS["text"]};padding:4px">'
2558
+ + "<br>".join(html_lines)
2559
+ + "</div>"
2560
+ )
2561
+ self.output_view.setHtml(html)
2562
+
2563
+ def _run_audit(self):
2564
+ self.results = {}
2565
+ self._scan_log = []
2566
+ self.progress_bar.setRange(0, len(AUDIT_SECTIONS))
2567
+ self.progress_bar.setValue(0)
2568
+ self._sections_done = 0
2569
+ for i, s in enumerate(SECTIONS):
2570
+ item = self.section_list.item(i)
2571
+ item.setText(f" {s}")
2572
+ item.setForeground(QColor(COLORS["text3"]))
2573
+ item.setBackground(QColor(COLORS["bg2"]))
2574
+ self.btn_run.setEnabled(False)
2575
+ self.btn_stop.setEnabled(True)
2576
+ self.btn_export.setEnabled(False)
2577
+ self.btn_ai_fix.setEnabled(False)
2578
+ self.lbl_status.setText("Running...")
2579
+ self.worker = AuditWorker(AUDIT_SECTIONS)
2580
+ self.worker.section_done.connect(self._on_section_done)
2581
+ self.worker.all_done.connect(self._on_all_done)
2582
+ self.worker.start()
2583
+
2584
+ def _run_virus_scan(self):
2585
+ scan_path = self.scan_path_input.text().strip() or os.path.expanduser("~")
2586
+ self._scan_log = []
2587
+ self.output_view.clear()
2588
+ self.lbl_section_status.setText("Scanning…")
2589
+ self.lbl_section_status.setStyleSheet(f"color:{COLORS['cyan']};font-size:10px;")
2590
+ self.btn_run_scan.setEnabled(False)
2591
+ self.btn_stop_scan.setEnabled(True)
2592
+ self.scan_worker = AuditWorker([VIRUS_SECTION], scan_path=scan_path)
2593
+ self.scan_worker.section_done.connect(self._on_virus_scan_done)
2594
+ self.scan_worker.scan_progress.connect(self._on_scan_progress)
2595
+ self.scan_worker.start()
2596
+
2597
+ def _stop_virus_scan(self):
2598
+ if hasattr(self, "scan_worker"):
2599
+ self.scan_worker.stop()
2600
+ self.btn_run_scan.setEnabled(True)
2601
+ self.btn_stop_scan.setEnabled(False)
2602
+ self.lbl_section_status.setText("Stopped")
2603
+
2604
+ def _on_virus_scan_done(self, section, status, output):
2605
+ self.results[section] = (status, output)
2606
+ self.btn_run_scan.setEnabled(True)
2607
+ self.btn_stop_scan.setEnabled(False)
2608
+ self._show_output(status, output)
2609
+ self.btn_ai_fix.setEnabled(status in ("warn", "critical", "error"))
2610
+
2611
+ def _stop_audit(self):
2612
+ if self.worker:
2613
+ self.worker.stop()
2614
+ self.btn_stop.setEnabled(False)
2615
+ self.lbl_status.setText("Stopped")
2616
+
2617
+ def _on_section_done(self, section, status, output):
2618
+ self.results[section] = (status, output)
2619
+ self._sections_done += 1
2620
+ self.progress_bar.setValue(self._sections_done)
2621
+ idx = SECTIONS.index(section)
2622
+ item = self.section_list.item(idx)
2623
+
2624
+ fg_map = {
2625
+ "ok": COLORS["green"],
2626
+ "warn": COLORS["orange"],
2627
+ "critical": COLORS["red"],
2628
+ "error": COLORS["red"],
2629
+ }
2630
+ bg_map = {
2631
+ "ok": "#0a1a0a",
2632
+ "warn": "#1f1200",
2633
+ "critical": "#1f0000",
2634
+ "error": "#1f0000",
2635
+ }
2636
+ prefix_map = {
2637
+ "ok": "✓ ",
2638
+ "warn": "⚠ ",
2639
+ "critical": "✖ ",
2640
+ "error": "✖ ",
2641
+ }
2642
+ prefix = prefix_map.get(status, " ")
2643
+ item.setText(f"{prefix}{section}")
2644
+ item.setForeground(QColor(fg_map.get(status, COLORS["text"])))
2645
+ item.setBackground(QColor(bg_map.get(status, COLORS["bg2"])))
2646
+
2647
+ if self.section_list.currentRow() == idx:
2648
+ self.lbl_section.setText(section)
2649
+ self._show_output(status, output)
2650
+ self.lbl_status.setText(f"{self._sections_done}/{len(AUDIT_SECTIONS)} done")
2651
+
2652
+ def _on_scan_progress(self, label, count):
2653
+ self.lbl_status.setText(label)
2654
+ self._scan_log.append(label)
2655
+ idx = SECTIONS.index(VIRUS_SECTION)
2656
+ if self.section_list.currentRow() == idx:
2657
+ # Trim to last 300 lines smoothly
2658
+ if len(self._scan_log) > 300:
2659
+ self._scan_log = self._scan_log[-300:]
2660
+ self.output_view.setPlainText("\n".join(self._scan_log))
2661
+ else:
2662
+ self.output_view.append(label)
2663
+ sb = self.output_view.verticalScrollBar()
2664
+ sb.setValue(sb.maximum())
2665
+
2666
+ def _on_all_done(self):
2667
+ self.btn_run.setEnabled(True)
2668
+ self.btn_stop.setEnabled(False)
2669
+ self.btn_export.setEnabled(True)
2670
+ warnings = [(s, st, out) for s, (st, out) in self.results.items()
2671
+ if st in ("warn", "critical", "error")]
2672
+ if warnings:
2673
+ self.lbl_status.setText(f"Done — {len(warnings)} warning(s)")
2674
+ self.lbl_status.setStyleSheet(f"color:{COLORS['orange']};font-size:10px;")
2675
+ self.btn_ai_fix.setEnabled(True)
2676
+ self._show_summary(warnings)
2677
+ else:
2678
+ self.lbl_status.setText("Audit complete — no issues found")
2679
+ self.lbl_status.setStyleSheet(f"color:{COLORS['green']};font-size:10px;")
2680
+ self._show_summary([])
2681
+
2682
+ def _show_summary(self, warnings):
2683
+ self.lbl_section.setText("Audit Summary")
2684
+ self.lbl_section_status.setText("")
2685
+ self.scan_toolbar.setVisible(False)
2686
+
2687
+ # ── Audit score ──────────────────────────────────────────────
2688
+ total = len(AUDIT_SECTIONS)
2689
+ warn_count = sum(1 for _, st, _ in warnings if st == "warn")
2690
+ critical_count = sum(1 for _, st, _ in warnings if st in ("critical", "error"))
2691
+ score = max(0, 100 - warn_count * 5 - critical_count * 15)
2692
+ bar_filled = round(score / 5) # 20-block bar, each block = 5 pts
2693
+ bar = "█" * bar_filled + "░" * (20 - bar_filled)
2694
+ if score >= 80:
2695
+ score_color = COLORS["green"]
2696
+ grade = "GOOD"
2697
+ elif score >= 50:
2698
+ score_color = COLORS["orange"]
2699
+ grade = "FAIR"
2700
+ else:
2701
+ score_color = COLORS["red"]
2702
+ grade = "POOR"
2703
+
2704
+ score_html = (
2705
+ f'<div style="margin-bottom:16px;padding:12px 14px;'
2706
+ f'background:{COLORS["bg3"]};border-radius:6px;'
2707
+ f'border:1px solid {score_color}33">'
2708
+ f'<div style="color:{COLORS["text2"]};font-size:11px;margin-bottom:6px">'
2709
+ f'AUDIT SCORE</div>'
2710
+ f'<div style="display:flex;align-items:baseline;gap:12px">'
2711
+ f'<span style="color:{score_color};font-size:28px;font-weight:bold">{score}</span>'
2712
+ f'<span style="color:{score_color};font-size:12px;font-weight:bold">{grade}</span>'
2713
+ f'<span style="color:{COLORS["text2"]};font-size:11px">'
2714
+ f'/ 100 &nbsp;·&nbsp; {total - len(warnings)}/{total} sections clean</span>'
2715
+ f'</div>'
2716
+ f'<div style="color:{score_color};font-size:13px;letter-spacing:1px;margin-top:6px">'
2717
+ f'{bar}</div>'
2718
+ f'</div>'
2719
+ )
2720
+
2721
+ if not warnings:
2722
+ self.output_view.setHtml(
2723
+ f'<div style="font-family:\'JetBrains Mono\',\'Fira Mono\',monospace;'
2724
+ f'background:{COLORS["bg2"]};color:{COLORS["text"]};padding:4px">'
2725
+ + score_html
2726
+ + f'<div style="color:{COLORS["green"]};font-size:13px;font-weight:bold">'
2727
+ f'✓ All sections clean</div></div>'
2728
+ )
2729
+ return
2730
+
2731
+ icon = {"warn": "⚠", "critical": "✖", "error": "✖"}
2732
+ color = {"warn": COLORS["orange"], "critical": COLORS["red"], "error": COLORS["red"]}
2733
+
2734
+ lines = [
2735
+ score_html,
2736
+ (f'<div style="color:{COLORS["orange"]};font-size:12px;font-weight:bold;'
2737
+ f'margin-bottom:12px">{len(warnings)} issue(s) found — click a section for details</div>'),
2738
+ ]
2739
+
2740
+ for section, status, output in warnings:
2741
+ c = color.get(status, COLORS["orange"])
2742
+ ic = icon.get(status, "⚠")
2743
+ preview_lines = [line for line in output.splitlines() if line.strip()][:6]
2744
+ preview = "<br>".join(
2745
+ f'<span style="color:{COLORS["text"]}">{line}</span>'
2746
+ for line in preview_lines
2747
+ )
2748
+ lines.append(
2749
+ f'<div style="margin-bottom:14px;padding:10px 12px;'
2750
+ f'background:{COLORS["bg3"]};border-left:3px solid {c};border-radius:4px">'
2751
+ f'<div style="color:{c};font-weight:bold;font-size:13px;margin-bottom:6px">'
2752
+ f'{ic} {section}</div>'
2753
+ f'<div style="font-size:12px;line-height:1.6">{preview}</div>'
2754
+ f'</div>'
2755
+ )
2756
+
2757
+ self.output_view.setHtml(
2758
+ f'<div style="font-family:\'JetBrains Mono\',\'Fira Mono\',monospace;'
2759
+ f'background:{COLORS["bg2"]};color:{COLORS["text"]};padding:4px">'
2760
+ + "".join(lines) + "</div>"
2761
+ )
2762
+
2763
+ def _ai_fix_current(self):
2764
+ from PyQt6.QtWidgets import QMessageBox
2765
+ row = self.section_list.currentRow()
2766
+ if row < 0:
2767
+ QMessageBox.information(self, "AI Fix", "Select a warning section first.")
2768
+ return
2769
+ section = SECTIONS[row]
2770
+ if section not in self.results:
2771
+ QMessageBox.information(self, "AI Fix", f"No results yet for: {section}")
2772
+ return
2773
+ status, output = self.results[section]
2774
+ if status not in ("warn", "critical", "error"):
2775
+ QMessageBox.information(self, "AI Fix",
2776
+ f"'{section}' has no issues to fix (status: {status}).\n"
2777
+ "Click on a ⚠ or ✖ section in the list first.")
2778
+ return
2779
+ try:
2780
+ provider = PROVIDER_IDS[self.provider_combo.currentIndex()]
2781
+ if provider == "claude":
2782
+ _save_config({
2783
+ "claude_api_key": self.claude_api_key.text().strip(),
2784
+ "claude_model": self.claude_model.text().strip(),
2785
+ })
2786
+ elif provider == "deepseek":
2787
+ _save_config({
2788
+ "deepseek_api_key": self.deepseek_api_key.text().strip(),
2789
+ "deepseek_model": self.deepseek_model.text().strip(),
2790
+ })
2791
+ dlg = RemediationDialog(
2792
+ self, section, output,
2793
+ provider=provider,
2794
+ ollama_url=self.ollama_url.text().strip(),
2795
+ ollama_model=self.ollama_model.text().strip(),
2796
+ claude_api_key=self.claude_api_key.text().strip(),
2797
+ claude_model=self.claude_model.text().strip(),
2798
+ deepseek_api_key=self.deepseek_api_key.text().strip(),
2799
+ deepseek_model=self.deepseek_model.text().strip() or DEEPSEEK_MODEL,
2800
+ )
2801
+ dlg.exec()
2802
+ if dlg.applied_output:
2803
+ self.results[section] = (status, output + "\n\n── Applied fix ──\n" + dlg.applied_output)
2804
+ self._show_output(status, self.results[section][1])
2805
+ except Exception as e: # noqa: BLE001
2806
+ QMessageBox.critical(self, "AI Fix Error", str(e))
2807
+
2808
+ def _export(self):
2809
+ path, _ = QFileDialog.getSaveFileName(
2810
+ self,
2811
+ "Export Audit Report",
2812
+ f"aegis_audit_{datetime.date.today()}.json", # noqa: DTZ011
2813
+ "JSON Files (*.json)",
2814
+ )
2815
+ if not path:
2816
+ return
2817
+
2818
+ SENSITIVE = {"Sudo / Privileges", "SSH Config", "Users & Groups"}
2819
+ results_export = {}
2820
+ for s, (st, out) in self.results.items():
2821
+ if s in SENSITIVE:
2822
+ results_export[s] = {
2823
+ "status": st,
2824
+ "output": "[REDACTED — sensitive data omitted from export]",
2825
+ }
2826
+ else:
2827
+ results_export[s] = {"status": st, "output": out}
2828
+
2829
+ export_data = {
2830
+ "tool": APP_NAME,
2831
+ "version": VERSION,
2832
+ "platform": _OS,
2833
+ "timestamp": datetime.datetime.now().isoformat(), # noqa: DTZ005
2834
+ "hostname": socket.gethostname(),
2835
+ "note": "Sensitive sections (Sudo/Privileges, SSH Config, Users & Groups) are redacted.",
2836
+ "results": results_export,
2837
+ }
2838
+ try:
2839
+ with open(path, "w") as f:
2840
+ json.dump(export_data, f, indent=2)
2841
+ # Restrict file permissions (Unix only — no-op on Windows)
2842
+ if not IS_WIN:
2843
+ os.chmod(path, 0o600)
2844
+ self.lbl_status.setText(f"Exported → {os.path.basename(path)} (chmod 600)")
2845
+ else:
2846
+ self.lbl_status.setText(f"Exported → {os.path.basename(path)}")
2847
+ except Exception as e: # noqa: BLE001
2848
+ self.lbl_status.setText(f"Export failed: {e}")
2849
+
2850
+
2851
+ # ============================================================
2852
+ # ENTRY POINT
2853
+ # ============================================================
2854
+ def main(argv=None):
2855
+ argv = list(sys.argv[1:] if argv is None else argv)
2856
+
2857
+ # Any argument means CLI; no argument keeps the original GUI behaviour.
2858
+ if argv and "--gui" not in argv:
2859
+ try:
2860
+ return _cli_main(argv)
2861
+ except KeyboardInterrupt:
2862
+ print("\ninterrupted", file=sys.stderr)
2863
+ return EXIT_USAGE
2864
+ except Exception as e: # noqa: BLE001 — never a bare traceback
2865
+ print(f"rex: {type(e).__name__}: {e}", file=sys.stderr)
2866
+ return EXIT_USAGE
2867
+
2868
+ if psutil is None:
2869
+ print("rex: warning - " + PSUTIL_MISSING, file=sys.stderr)
2870
+
2871
+ if not HAS_QT:
2872
+ print(QT_MISSING_MSG, file=sys.stderr)
2873
+ return EXIT_USAGE
2874
+
2875
+ app = QApplication(sys.argv)
2876
+ app.setApplicationName(APP_NAME)
2877
+ app.setApplicationVersion(VERSION)
2878
+ win = SecurityAuditWindow()
2879
+ win.show()
2880
+ return app.exec()
2881
+
2882
+
2883
+
2884
+ if __name__ == "__main__":
2885
+ sys.exit(main())