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/README.md +151 -0
- package/bin/rex.js +193 -0
- package/package.json +56 -0
- package/vendor/LICENSE +21 -0
- package/vendor/rex.py +2885 -0
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("&", "&").replace("<", "<").replace(">", ">")
|
|
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 · {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())
|