hardax 5.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hardax/__init__.py +2453 -0
- hardax/__main__.py +5 -0
- hardax/commands/adb_security.json +43 -0
- hardax/commands/apps.json +451 -0
- hardax/commands/attestation.json +219 -0
- hardax/commands/automotive.json +189 -0
- hardax/commands/binary_hardening.json +369 -0
- hardax/commands/bluetooth.json +1312 -0
- hardax/commands/boot_security.json +184 -0
- hardax/commands/certificate_audit.json +229 -0
- hardax/commands/cis_benchmark.json +192 -0
- hardax/commands/cryptography.json +195 -0
- hardax/commands/cve_indicators.json +185 -0
- hardax/commands/device_management.json +122 -0
- hardax/commands/forensic_indicators.json +209 -0
- hardax/commands/input.json +86 -0
- hardax/commands/malware.json +184 -0
- hardax/commands/medical.json +70 -0
- hardax/commands/network.json +584 -0
- hardax/commands/nfc_security.json +70 -0
- hardax/commands/partition.json +330 -0
- hardax/commands/pos_security.json +232 -0
- hardax/commands/privacy.json +449 -0
- hardax/commands/selinux.json +419 -0
- hardax/commands/storage.json +229 -0
- hardax/commands/system.json +832 -0
- hardax/commands/usb_security.json +154 -0
- hardax/templates/report.html +713 -0
- hardax-5.2.1.dist-info/METADATA +415 -0
- hardax-5.2.1.dist-info/RECORD +34 -0
- hardax-5.2.1.dist-info/WHEEL +5 -0
- hardax-5.2.1.dist-info/entry_points.txt +2 -0
- hardax-5.2.1.dist-info/licenses/LICENSE +21 -0
- hardax-5.2.1.dist-info/top_level.txt +1 -0
hardax/__init__.py
ADDED
|
@@ -0,0 +1,2453 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
HARDAX - Hardening Audit eXaminer
|
|
4
|
+
Android OS based Connected Devices Security Configuration Auditor
|
|
5
|
+
|
|
6
|
+
Android OS based Connected Devices Security Configuration Auditor | 3 Report Formats
|
|
7
|
+
Author : Mr-IoT (IOTSRG)
|
|
8
|
+
License: MIT
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
12
|
+
# IMPORTS
|
|
13
|
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import getpass
|
|
17
|
+
import base64
|
|
18
|
+
import collections
|
|
19
|
+
import csv
|
|
20
|
+
import html
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import shlex
|
|
25
|
+
import shutil
|
|
26
|
+
import signal
|
|
27
|
+
import subprocess
|
|
28
|
+
import sys
|
|
29
|
+
import time
|
|
30
|
+
from datetime import datetime
|
|
31
|
+
from string import Template
|
|
32
|
+
from typing import List, Dict, Any, Tuple, Optional
|
|
33
|
+
|
|
34
|
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
35
|
+
# PYTHON VERSION CHECK
|
|
36
|
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
37
|
+
|
|
38
|
+
if sys.version_info < (3, 11):
|
|
39
|
+
sys.exit(f"[ERROR] HARDAX requires Python 3.11 or higher. "
|
|
40
|
+
f"Detected: {sys.version_info.major}.{sys.version_info.minor}")
|
|
41
|
+
|
|
42
|
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
43
|
+
# VERSION & CONSTANTS
|
|
44
|
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
45
|
+
|
|
46
|
+
__version__ = "5.2.1"
|
|
47
|
+
|
|
48
|
+
REQUIRED_CHECK_KEYS = {"category", "label", "command", "safe_pattern", "level", "description"}
|
|
49
|
+
|
|
50
|
+
ADB_TRANSPORT_ERRORS = [
|
|
51
|
+
"device offline",
|
|
52
|
+
"device not found",
|
|
53
|
+
"device unauthorized",
|
|
54
|
+
"no devices/emulators found",
|
|
55
|
+
"no devices found",
|
|
56
|
+
"closed",
|
|
57
|
+
"protocol fault",
|
|
58
|
+
"device still authorizing",
|
|
59
|
+
"insufficient permissions",
|
|
60
|
+
"more than one device",
|
|
61
|
+
"adb: error:",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
# Maximum output length to consider as a transport/service error
|
|
65
|
+
_ADB_ERROR_MAX_LEN = 300
|
|
66
|
+
_SVC_ERROR_MAX_LEN = 150
|
|
67
|
+
|
|
68
|
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
69
|
+
# CLI ARGV SHIM - strip extra flags before argparse
|
|
70
|
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
_cleanArgv = [sys.argv[0]]
|
|
74
|
+
_idx = 1
|
|
75
|
+
while _idx < len(sys.argv):
|
|
76
|
+
flag = sys.argv[_idx]
|
|
77
|
+
if flag == "--net-debug":
|
|
78
|
+
os.environ["HARDAX_NET_DEBUG"] = "1"
|
|
79
|
+
elif flag == "--net-strict":
|
|
80
|
+
os.environ["HARDAX_NET_STRICT"] = "1"
|
|
81
|
+
elif flag == "--cert-debug":
|
|
82
|
+
os.environ["HARDAX_CERT_DEBUG"] = "1"
|
|
83
|
+
elif flag == "--cert-limit":
|
|
84
|
+
if _idx + 1 < len(sys.argv):
|
|
85
|
+
os.environ["HARDAX_CERT_LIMIT"] = sys.argv[_idx + 1]
|
|
86
|
+
_idx += 1
|
|
87
|
+
else:
|
|
88
|
+
_cleanArgv.append(flag)
|
|
89
|
+
_idx += 1
|
|
90
|
+
sys.argv = _cleanArgv
|
|
91
|
+
except Exception:
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
NET_DEBUG = bool(os.environ.get("HARDAX_NET_DEBUG"))
|
|
95
|
+
NET_STRICT = bool(os.environ.get("HARDAX_NET_STRICT"))
|
|
96
|
+
CERT_DEBUG = bool(os.environ.get("HARDAX_CERT_DEBUG"))
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
CERT_LIMIT = int(os.environ.get("HARDAX_CERT_LIMIT", "50"))
|
|
100
|
+
except Exception:
|
|
101
|
+
CERT_LIMIT = 50
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
105
|
+
# TERMINAL COLORS
|
|
106
|
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
107
|
+
|
|
108
|
+
class Colors:
|
|
109
|
+
"""ANSI escape codes for terminal output."""
|
|
110
|
+
RESET = "\033[0m"
|
|
111
|
+
BOLD = "\033[1m"
|
|
112
|
+
DIM = "\033[2m"
|
|
113
|
+
|
|
114
|
+
RED = "\033[31m"
|
|
115
|
+
GREEN = "\033[32m"
|
|
116
|
+
YELLOW = "\033[33m"
|
|
117
|
+
BLUE = "\033[34m"
|
|
118
|
+
MAGENTA = "\033[35m"
|
|
119
|
+
CYAN = "\033[36m"
|
|
120
|
+
WHITE = "\033[37m"
|
|
121
|
+
|
|
122
|
+
BRIGHT_RED = "\033[91m"
|
|
123
|
+
BRIGHT_GREEN = "\033[92m"
|
|
124
|
+
BRIGHT_YELLOW = "\033[93m"
|
|
125
|
+
BRIGHT_BLUE = "\033[94m"
|
|
126
|
+
BRIGHT_MAGENTA = "\033[95m"
|
|
127
|
+
BRIGHT_CYAN = "\033[96m"
|
|
128
|
+
BRIGHT_WHITE = "\033[97m"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def supportsColor() -> bool:
|
|
132
|
+
"""Check whether the terminal supports ANSI colour sequences."""
|
|
133
|
+
if not hasattr(sys.stdout, "isatty"):
|
|
134
|
+
return False
|
|
135
|
+
if not sys.stdout.isatty():
|
|
136
|
+
return False
|
|
137
|
+
if os.environ.get("TERM") == "dumb":
|
|
138
|
+
return False
|
|
139
|
+
return True
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
if not supportsColor():
|
|
143
|
+
for attr in dir(Colors):
|
|
144
|
+
if not attr.startswith("_"):
|
|
145
|
+
setattr(Colors, attr, "")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
149
|
+
# TERMINAL HELPERS (pure ANSI, no curses)
|
|
150
|
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
151
|
+
|
|
152
|
+
class Terminal:
|
|
153
|
+
"""Low-level terminal cursor / screen helpers using ANSI escape codes."""
|
|
154
|
+
|
|
155
|
+
@staticmethod
|
|
156
|
+
def hideCursor():
|
|
157
|
+
sys.stdout.write("\033[?25l")
|
|
158
|
+
sys.stdout.flush()
|
|
159
|
+
|
|
160
|
+
@staticmethod
|
|
161
|
+
def showCursor():
|
|
162
|
+
sys.stdout.write("\033[?25h")
|
|
163
|
+
sys.stdout.flush()
|
|
164
|
+
|
|
165
|
+
@staticmethod
|
|
166
|
+
def cursorUp(n: int):
|
|
167
|
+
if n > 0:
|
|
168
|
+
sys.stdout.write(f"\033[{n}A")
|
|
169
|
+
|
|
170
|
+
@staticmethod
|
|
171
|
+
def clearLine():
|
|
172
|
+
sys.stdout.write("\033[2K\r")
|
|
173
|
+
|
|
174
|
+
@staticmethod
|
|
175
|
+
def getSize() -> Tuple[int, int]:
|
|
176
|
+
"""Return (columns, rows) of the terminal."""
|
|
177
|
+
sz = shutil.get_terminal_size(fallback=(80, 24))
|
|
178
|
+
return sz.columns, sz.lines
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
182
|
+
# HUD TACTICAL DASHBOARD
|
|
183
|
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
184
|
+
|
|
185
|
+
# Regex to strip ANSI escape codes for visual-width calculations
|
|
186
|
+
_ANSI_RE = re.compile(r'\033\[[0-9;]*m')
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _vlen(s: str) -> int:
|
|
190
|
+
"""Visual length of a string, ignoring ANSI escape codes."""
|
|
191
|
+
return len(_ANSI_RE.sub('', s))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _vpad(s: str, width: int) -> str:
|
|
195
|
+
"""Right-pad a string with spaces to reach a target *visual* width."""
|
|
196
|
+
diff = width - _vlen(s)
|
|
197
|
+
return s + (' ' * diff) if diff > 0 else s
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _vtrunc(s: str, maxLen: int) -> str:
|
|
201
|
+
"""Truncate a plain string to maxLen visual chars, adding … if needed."""
|
|
202
|
+
if maxLen <= 0:
|
|
203
|
+
return ""
|
|
204
|
+
if len(s) <= maxLen:
|
|
205
|
+
return s
|
|
206
|
+
return s[:maxLen - 1] + "…"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class HUDDashboard:
|
|
210
|
+
"""Live split-panel HUD with categories on left, findings feed on right.
|
|
211
|
+
|
|
212
|
+
Every line is built by:
|
|
213
|
+
1. composing *plain text* segments first (known visual widths)
|
|
214
|
+
2. wrapping with ANSI colour
|
|
215
|
+
3. using _vpad() to pad to the exact column before adding borders
|
|
216
|
+
|
|
217
|
+
This guarantees the right-hand │ always aligns.
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
_STATUS_FMT = {
|
|
221
|
+
"SAFE": (Colors.GREEN, "✓"),
|
|
222
|
+
"CRITICAL": (Colors.BRIGHT_RED, "✗"),
|
|
223
|
+
"WARNING": (Colors.YELLOW, "⚠"),
|
|
224
|
+
"VERIFY": (Colors.BRIGHT_MAGENTA, "?"),
|
|
225
|
+
"INFO": (Colors.CYAN, "ℹ"),
|
|
226
|
+
"SKIPPED": (Colors.DIM, "⊘"),
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
# Fixed layout constants (inner widths, excluding the border chars)
|
|
230
|
+
LW = 34 # left-panel visual width (between │…│)
|
|
231
|
+
RW = 36 # right-panel visual width (between │…│)
|
|
232
|
+
# Total inner = LW + 1(sep│) + RW = 71 + 2 outer │ = 73 visual
|
|
233
|
+
# With 2-space indent: 75 chars per line - fits 80-col terminals.
|
|
234
|
+
|
|
235
|
+
def __init__(self, checks: List[Dict[str, Any]], deviceInfo: str = ""):
|
|
236
|
+
seen: Dict[str, int] = {}
|
|
237
|
+
for chk in checks:
|
|
238
|
+
cat = chk.get("category", "General")
|
|
239
|
+
seen[cat] = seen.get(cat, 0) + 1
|
|
240
|
+
|
|
241
|
+
self.categoryOrder = list(seen.keys())
|
|
242
|
+
self.categoryTotals = seen
|
|
243
|
+
self.categoryDone = {c: 0 for c in self.categoryOrder}
|
|
244
|
+
self.activeCategory = self.categoryOrder[0] if self.categoryOrder else ""
|
|
245
|
+
self.totalChecks = len(checks)
|
|
246
|
+
self.totalDone = 0
|
|
247
|
+
self.deviceInfo = deviceInfo
|
|
248
|
+
self.startTime = time.time()
|
|
249
|
+
|
|
250
|
+
self._maxFindings = len(self.categoryOrder)
|
|
251
|
+
self.findings: collections.deque = collections.deque(maxlen=self._maxFindings)
|
|
252
|
+
|
|
253
|
+
self.counts = {"safe": 0, "critical": 0, "warning": 0,
|
|
254
|
+
"verify": 0, "info": 0, "skipped": 0}
|
|
255
|
+
|
|
256
|
+
# Panel height = header(3) + cat rows + blankrow(1) + sep(1) + progress(1) + tally(1) + bottom(1) = cats+8
|
|
257
|
+
self._catRows = len(self.categoryOrder)
|
|
258
|
+
self.panelHeight = self._catRows + 8
|
|
259
|
+
|
|
260
|
+
W = self.LW + 1 + self.RW # full inner width (between outer │ │)
|
|
261
|
+
self._W = W
|
|
262
|
+
|
|
263
|
+
Terminal.hideCursor()
|
|
264
|
+
self._render(first=True)
|
|
265
|
+
|
|
266
|
+
# ── helpers ────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
@staticmethod
|
|
269
|
+
def _bar(done: int, total: int, width: int = 20) -> str:
|
|
270
|
+
"""█░ progress bar with colour gradient, exact *width* visual chars."""
|
|
271
|
+
if total == 0:
|
|
272
|
+
return Colors.DIM + "░" * width + Colors.RESET
|
|
273
|
+
frac = min(done / total, 1.0)
|
|
274
|
+
filled = int(frac * width)
|
|
275
|
+
col = Colors.BRIGHT_RED if frac < 0.33 else Colors.YELLOW if frac < 0.66 else Colors.GREEN
|
|
276
|
+
return col + "█" * filled + Colors.DIM + "░" * (width - filled) + Colors.RESET
|
|
277
|
+
|
|
278
|
+
def _eta(self) -> str:
|
|
279
|
+
if self.totalDone == 0:
|
|
280
|
+
return "--:--"
|
|
281
|
+
elapsed = time.time() - self.startTime
|
|
282
|
+
remaining = int((elapsed / self.totalDone) * (self.totalChecks - self.totalDone))
|
|
283
|
+
return f"{remaining // 60}m{remaining % 60:02d}s" if remaining >= 60 else f"{remaining}s"
|
|
284
|
+
|
|
285
|
+
# ── line builders (each returns a string with ANSI, exact visual width) ──
|
|
286
|
+
|
|
287
|
+
def _lineTopSafe(self) -> str:
|
|
288
|
+
"""Top border built with guaranteed alignment."""
|
|
289
|
+
D = Colors.DIM; R = Colors.RESET; B = Colors.BOLD
|
|
290
|
+
W = Colors.BRIGHT_WHITE; C = Colors.BRIGHT_CYAN; Y = Colors.YELLOW
|
|
291
|
+
dev = _vtrunc(self.deviceInfo, 20) if self.deviceInfo else ""
|
|
292
|
+
# plain text (no colour) to measure
|
|
293
|
+
plain = f"─── HARDAX v{__version__} ── {dev} ── {self.totalChecks} checks "
|
|
294
|
+
fill = max(1, self._W - len(plain))
|
|
295
|
+
coloured = (f"{D}───{R} {B}{W}HARDAX v{__version__}{R} "
|
|
296
|
+
f"{D}──{R} {C}{dev}{R} "
|
|
297
|
+
f"{D}──{R} {Y}{self.totalChecks} checks{R} "
|
|
298
|
+
f"{D}{'─' * fill}{R}")
|
|
299
|
+
return f" {D}┌{R}{coloured}{D}┐{R}"
|
|
300
|
+
|
|
301
|
+
def _lineHeaders(self) -> str:
|
|
302
|
+
D = Colors.DIM; R = Colors.RESET; B = Colors.BOLD; W = Colors.BRIGHT_WHITE
|
|
303
|
+
left = _vpad(f" {B}{W}CATEGORIES{R}", self.LW)
|
|
304
|
+
right = _vpad(f"{B}{W}LIVE FINDINGS{R}", self.RW)
|
|
305
|
+
return f" {D}│{R}{left}{D}│{R}{right}{D}│{R}"
|
|
306
|
+
|
|
307
|
+
def _lineSepInner(self) -> str:
|
|
308
|
+
"""Sub-header separator: │ ─────────│─────────│"""
|
|
309
|
+
D = Colors.DIM; R = Colors.RESET
|
|
310
|
+
return f" {D}│{'─' * self.LW}│{'─' * self.RW}│{R}"
|
|
311
|
+
|
|
312
|
+
def _lineCatRow(self, cat: str, findIdx: int) -> str:
|
|
313
|
+
"""One category row with left + right panels."""
|
|
314
|
+
D = Colors.DIM; R = Colors.RESET; B = Colors.BOLD
|
|
315
|
+
W = Colors.BRIGHT_WHITE; G = Colors.GREEN; C = Colors.BRIGHT_CYAN
|
|
316
|
+
done = self.categoryDone.get(cat, 0)
|
|
317
|
+
total = self.categoryTotals.get(cat, 0)
|
|
318
|
+
|
|
319
|
+
# Icon (1 visual char)
|
|
320
|
+
if done >= total > 0:
|
|
321
|
+
icon = f"{G}✓{R}"
|
|
322
|
+
elif cat == self.activeCategory and done > 0:
|
|
323
|
+
icon = f"{C}◈{R}"
|
|
324
|
+
elif cat == self.activeCategory:
|
|
325
|
+
icon = f"{C}▶{R}"
|
|
326
|
+
else:
|
|
327
|
+
icon = f"{D}·{R}"
|
|
328
|
+
|
|
329
|
+
# Name (16 visual chars)
|
|
330
|
+
name16 = _vtrunc(cat, 16)
|
|
331
|
+
if cat == self.activeCategory:
|
|
332
|
+
nameC = f"{B}{W}{name16}{R}"
|
|
333
|
+
elif done >= total > 0:
|
|
334
|
+
nameC = f"{G}{name16}{R}"
|
|
335
|
+
else:
|
|
336
|
+
nameC = f"{D}{name16}{R}"
|
|
337
|
+
nameC = _vpad(nameC, 16)
|
|
338
|
+
|
|
339
|
+
# Count: " 4/84 " (7 visual)
|
|
340
|
+
countPlain = f"{done:>3}/{total}"
|
|
341
|
+
countC = f"{W}{countPlain}{R}" if done > 0 else f"{D}{countPlain}{R}"
|
|
342
|
+
|
|
343
|
+
# Pct: "100%" or " --" (4 visual)
|
|
344
|
+
if total > 0 and done > 0:
|
|
345
|
+
pctPlain = f"{done * 100 // total:3d}%"
|
|
346
|
+
else:
|
|
347
|
+
pctPlain = " --"
|
|
348
|
+
pctC = f"{D}{pctPlain}{R}"
|
|
349
|
+
|
|
350
|
+
# left: icon(1) + sp(1) + name(16) + sp(1) + count(~7) + sp(1) + pct(4) + sp(~3)
|
|
351
|
+
# Total left visual = 1+1+16+1+7+1+4 = 31, pad to LW
|
|
352
|
+
leftContent = f"{icon} {nameC} {countC} {pctC}"
|
|
353
|
+
left = _vpad(leftContent, self.LW)
|
|
354
|
+
|
|
355
|
+
# Right panel: finding
|
|
356
|
+
right = self._findingCell(findIdx)
|
|
357
|
+
|
|
358
|
+
return f" {D}│{R}{left}{D}│{R}{right}{D}│{R}"
|
|
359
|
+
|
|
360
|
+
def _findingCell(self, idx: int) -> str:
|
|
361
|
+
"""Build right-panel cell for findings row idx, padded to RW."""
|
|
362
|
+
R = Colors.RESET; D = Colors.DIM
|
|
363
|
+
if idx < len(self.findings):
|
|
364
|
+
status, label = self.findings[idx]
|
|
365
|
+
col, _ = self._STATUS_FMT.get(status, (D, "·"))
|
|
366
|
+
tag = status[:4]
|
|
367
|
+
lbl = _vtrunc(label, self.RW - 8)
|
|
368
|
+
cell = f"{col}[{tag}]{R} {lbl}"
|
|
369
|
+
else:
|
|
370
|
+
cell = ""
|
|
371
|
+
return _vpad(cell, self.RW)
|
|
372
|
+
|
|
373
|
+
def _lineBlank(self) -> str:
|
|
374
|
+
D = Colors.DIM; R = Colors.RESET
|
|
375
|
+
return f" {D}│{' ' * self.LW}│{' ' * self.RW}│{R}"
|
|
376
|
+
|
|
377
|
+
def _lineSepFull(self) -> str:
|
|
378
|
+
"""Separator: ├──────┴──────┤"""
|
|
379
|
+
D = Colors.DIM; R = Colors.RESET
|
|
380
|
+
return f" {D}├{'─' * self.LW}┴{'─' * self.RW}┤{R}"
|
|
381
|
+
|
|
382
|
+
def _lineProgress(self) -> str:
|
|
383
|
+
D = Colors.DIM; R = Colors.RESET; W = Colors.BRIGHT_WHITE
|
|
384
|
+
barW = 20
|
|
385
|
+
bar = self._bar(self.totalDone, self.totalChecks, barW)
|
|
386
|
+
pct = (self.totalDone / self.totalChecks * 100) if self.totalChecks else 0
|
|
387
|
+
# plain: " " + bar(20) + " " + "NNN/NNN" + " " + "PPP.P%" + " ETA XXmXXs"
|
|
388
|
+
countPlain = f"{self.totalDone:>3}/{self.totalChecks}"
|
|
389
|
+
pctPlain = f"{pct:5.1f}%"
|
|
390
|
+
etaPlain = f"ETA {self._eta()}"
|
|
391
|
+
content = f" {bar} {W}{countPlain}{R} {D}{pctPlain}{R} {D}{etaPlain}{R}"
|
|
392
|
+
return f" {D}│{R}{_vpad(content, self._W)}{D}│{R}"
|
|
393
|
+
|
|
394
|
+
def _lineTally(self) -> str:
|
|
395
|
+
D = Colors.DIM; R = Colors.RESET
|
|
396
|
+
G = Colors.GREEN; RD = Colors.BRIGHT_RED; Y = Colors.YELLOW; C = Colors.CYAN
|
|
397
|
+
c = self.counts
|
|
398
|
+
tally = (f" {G}✓{c['safe']}{R} SAFE "
|
|
399
|
+
f"{RD}✗{c['critical']}{R} CRIT "
|
|
400
|
+
f"{Y}⚠{c['warning']}{R} WARN "
|
|
401
|
+
f"{D}⊘{c['skipped']}{R} SKIP "
|
|
402
|
+
f"{C}ℹ{c['info']}{R} INFO")
|
|
403
|
+
return f" {D}│{R}{_vpad(tally, self._W)}{D}│{R}"
|
|
404
|
+
|
|
405
|
+
def _lineBottom(self) -> str:
|
|
406
|
+
D = Colors.DIM; R = Colors.RESET
|
|
407
|
+
return f" {D}└{'─' * self._W}┘{R}"
|
|
408
|
+
|
|
409
|
+
# ── full frame ──────────────────────────────────────────
|
|
410
|
+
|
|
411
|
+
def _buildFrame(self) -> str:
|
|
412
|
+
lines = [
|
|
413
|
+
self._lineTopSafe(),
|
|
414
|
+
self._lineHeaders(),
|
|
415
|
+
self._lineSepInner(),
|
|
416
|
+
]
|
|
417
|
+
for i, cat in enumerate(self.categoryOrder):
|
|
418
|
+
lines.append(self._lineCatRow(cat, i))
|
|
419
|
+
lines.append(self._lineBlank())
|
|
420
|
+
lines.append(self._lineSepFull())
|
|
421
|
+
lines.append(self._lineProgress())
|
|
422
|
+
lines.append(self._lineTally())
|
|
423
|
+
lines.append(self._lineBottom())
|
|
424
|
+
return "\n".join(lines)
|
|
425
|
+
|
|
426
|
+
def _render(self, first: bool = False):
|
|
427
|
+
if first:
|
|
428
|
+
sys.stdout.write("\033[s") # save cursor position before first draw
|
|
429
|
+
else:
|
|
430
|
+
sys.stdout.write("\033[u") # restore to saved position before redraw
|
|
431
|
+
print(self._buildFrame())
|
|
432
|
+
sys.stdout.flush()
|
|
433
|
+
|
|
434
|
+
# ── public API ──────────────────────────────────────────
|
|
435
|
+
|
|
436
|
+
def onCheckComplete(self, category: str, label: str, status: str):
|
|
437
|
+
"""Called after each check completes. Updates stats and redraws."""
|
|
438
|
+
self.activeCategory = category
|
|
439
|
+
self.categoryDone[category] = self.categoryDone.get(category, 0) + 1
|
|
440
|
+
self.totalDone += 1
|
|
441
|
+
|
|
442
|
+
bucket = status.lower()
|
|
443
|
+
if bucket in self.counts:
|
|
444
|
+
self.counts[bucket] += 1
|
|
445
|
+
|
|
446
|
+
if status not in ("SAFE", "INFO"):
|
|
447
|
+
self.findings.append((status, label))
|
|
448
|
+
|
|
449
|
+
self._render()
|
|
450
|
+
|
|
451
|
+
def finish(self):
|
|
452
|
+
"""Restore cursor and print completion message."""
|
|
453
|
+
Terminal.showCursor()
|
|
454
|
+
elapsed = time.time() - self.startTime
|
|
455
|
+
mins = int(elapsed) // 60
|
|
456
|
+
secs = int(elapsed) % 60
|
|
457
|
+
print(f"\n {Colors.GREEN}✓{Colors.RESET} {Colors.BOLD}Audit complete{Colors.RESET} in {mins}m{secs:02d}s\n")
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
# Global dashboard reference for signal handler
|
|
461
|
+
_active_dashboard: Optional['HUDDashboard'] = None
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _signalHandler(signum, frame):
|
|
465
|
+
"""Restore terminal on Ctrl+C."""
|
|
466
|
+
Terminal.showCursor()
|
|
467
|
+
print(f"\n\n {Colors.YELLOW}⚠ Audit interrupted by user (Ctrl+C){Colors.RESET}\n")
|
|
468
|
+
sys.exit(130)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
472
|
+
# GENERAL UTILITIES
|
|
473
|
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
474
|
+
|
|
475
|
+
def which(prog: str) -> Optional[str]:
|
|
476
|
+
return shutil.which(prog)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def runLocal(cmd: List[str], timeout: Optional[int] = None) -> Tuple[int, str, str]:
|
|
480
|
+
"""Execute a local subprocess and return (returncode, stdout, stderr)."""
|
|
481
|
+
try:
|
|
482
|
+
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
483
|
+
out, err = proc.communicate(timeout=timeout)
|
|
484
|
+
return proc.returncode, out, err
|
|
485
|
+
except subprocess.TimeoutExpired:
|
|
486
|
+
proc.kill()
|
|
487
|
+
proc.communicate()
|
|
488
|
+
return -1, "", "timeout"
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def htmlEscape(s: str) -> str:
|
|
492
|
+
return html.escape(s, quote=True)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def normalizeForMatch(s: str) -> str:
|
|
496
|
+
"""Normalize line endings while preserving newlines for multi-line regex."""
|
|
497
|
+
return (s or "").replace("\r\n", "\n").replace("\r", "\n")
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def bucketFromLevel(level: str) -> str:
|
|
501
|
+
"""Map granular severity labels to the three evaluation buckets."""
|
|
502
|
+
lvl = (level or "").strip().lower()
|
|
503
|
+
if lvl in ("critical", "high"):
|
|
504
|
+
return "critical"
|
|
505
|
+
if lvl in ("warning", "medium"):
|
|
506
|
+
return "warning"
|
|
507
|
+
return "info"
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
SERVICE_ERRORS = [
|
|
511
|
+
"can't find service",
|
|
512
|
+
"service not found",
|
|
513
|
+
"failed to find service",
|
|
514
|
+
"does not exist",
|
|
515
|
+
"cmd: can't find",
|
|
516
|
+
]
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def isServiceError(output: str) -> bool:
|
|
520
|
+
"""Return True when *output* indicates a missing Android service."""
|
|
521
|
+
if not output:
|
|
522
|
+
return False
|
|
523
|
+
lower = output.lower().strip()
|
|
524
|
+
for indicator in SERVICE_ERRORS:
|
|
525
|
+
if indicator in lower and len(lower) < _SVC_ERROR_MAX_LEN:
|
|
526
|
+
return True
|
|
527
|
+
return False
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def isAdbTransportError(output: str) -> bool:
|
|
531
|
+
"""Return True when *output* looks like an ADB transport / connection error."""
|
|
532
|
+
if not output:
|
|
533
|
+
return False
|
|
534
|
+
lower = output.lower().strip()
|
|
535
|
+
if len(lower) > _ADB_ERROR_MAX_LEN:
|
|
536
|
+
return False
|
|
537
|
+
return any(sig in lower for sig in ADB_TRANSPORT_ERRORS)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
541
|
+
# ADB HELPERS
|
|
542
|
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
543
|
+
|
|
544
|
+
def listAdbDevices() -> list:
|
|
545
|
+
code, out, _ = runLocal(["adb", "devices", "-l"])
|
|
546
|
+
if code != 0:
|
|
547
|
+
return []
|
|
548
|
+
lines = [ln.strip() for ln in out.splitlines()[1:] if ln.strip()]
|
|
549
|
+
devices = []
|
|
550
|
+
for ln in lines:
|
|
551
|
+
parts = ln.split()
|
|
552
|
+
if not parts:
|
|
553
|
+
continue
|
|
554
|
+
serial = parts[0]
|
|
555
|
+
state = parts[1] if len(parts) > 1 else "unknown"
|
|
556
|
+
desc = " ".join(parts[2:]) if len(parts) > 2 else ""
|
|
557
|
+
devices.append({"serial": serial, "state": state, "desc": desc})
|
|
558
|
+
return devices
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def pickDefaultSerial(userSerial: Optional[str]) -> Optional[str]:
|
|
562
|
+
if userSerial:
|
|
563
|
+
return userSerial
|
|
564
|
+
devs = listAdbDevices()
|
|
565
|
+
healthy = [d for d in devs if d["state"] == "device"]
|
|
566
|
+
if len(healthy) == 1:
|
|
567
|
+
return healthy[0]["serial"]
|
|
568
|
+
return None
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def explainAdbDevicesAndExit(exitCode: int = 2):
|
|
572
|
+
devs = listAdbDevices()
|
|
573
|
+
if not devs:
|
|
574
|
+
msg = (
|
|
575
|
+
"No ADB devices detected.\n\n"
|
|
576
|
+
"Troubleshooting:\n"
|
|
577
|
+
" Enable Developer options and USB debugging on the device\n"
|
|
578
|
+
" Trust this computer on the device prompt\n"
|
|
579
|
+
" Run: adb kill-server && adb start-server\n"
|
|
580
|
+
" Check USB cable/port or try: adb tcpip 5555; adb connect <ip>:5555\n"
|
|
581
|
+
)
|
|
582
|
+
print(msg, file=sys.stderr)
|
|
583
|
+
sys.exit(exitCode)
|
|
584
|
+
lines = ["Detected ADB endpoints (use --serial <id>):"]
|
|
585
|
+
for d in devs:
|
|
586
|
+
lines.append(f" - {d['serial']:>24} {d['state']:<12} {d['desc']}")
|
|
587
|
+
lines.append("\nNotes:")
|
|
588
|
+
lines.append(" Only devices in state 'device' are usable.")
|
|
589
|
+
lines.append(" If you see 'unauthorized', unlock the phone and accept the RSA fingerprint dialog.")
|
|
590
|
+
lines.append(" If multiple 'device' entries exist, pass --serial <id>.")
|
|
591
|
+
print("\n".join(lines), file=sys.stderr)
|
|
592
|
+
sys.exit(exitCode)
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
596
|
+
# DEVICE INTERFACES
|
|
597
|
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
598
|
+
|
|
599
|
+
class Device:
|
|
600
|
+
"""Abstract shell runner - subclass for ADB or SSH."""
|
|
601
|
+
def shell(self, command: str) -> str:
|
|
602
|
+
raise NotImplementedError()
|
|
603
|
+
|
|
604
|
+
def idString(self) -> str:
|
|
605
|
+
raise NotImplementedError()
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
class AdbDevice(Device):
|
|
609
|
+
"""Execute commands on an Android device through ADB."""
|
|
610
|
+
|
|
611
|
+
def __init__(self, serial: Optional[str]):
|
|
612
|
+
self.serial = serial
|
|
613
|
+
|
|
614
|
+
def adbArgs(self) -> List[str]:
|
|
615
|
+
return ["adb"] + (["-s", self.serial] if self.serial else [])
|
|
616
|
+
|
|
617
|
+
def checkConnected(self) -> None:
|
|
618
|
+
code, _, _ = runLocal(self.adbArgs() + ["get-state"])
|
|
619
|
+
if code != 0:
|
|
620
|
+
_, devs, _ = runLocal(["adb", "devices", "-l"])
|
|
621
|
+
raise RuntimeError("No ADB device detected or unauthorized. Output:\n" + devs)
|
|
622
|
+
|
|
623
|
+
def shell(self, command: str) -> str:
|
|
624
|
+
code, out, err = runLocal(self.adbArgs() + ["shell", command])
|
|
625
|
+
txt = (out or "") + (("\n" + err) if err else "")
|
|
626
|
+
txt = txt.replace("\r", "").strip()
|
|
627
|
+
|
|
628
|
+
if isAdbTransportError(txt):
|
|
629
|
+
runLocal(self.adbArgs() + ["reconnect"])
|
|
630
|
+
time.sleep(2)
|
|
631
|
+
runLocal(self.adbArgs() + ["wait-for-device"], timeout=10)
|
|
632
|
+
time.sleep(1)
|
|
633
|
+
code2, out2, err2 = runLocal(self.adbArgs() + ["shell", command])
|
|
634
|
+
txt2 = ((out2 or "") + (("\n" + err2) if err2 else "")).replace("\r", "").strip()
|
|
635
|
+
return txt2
|
|
636
|
+
return txt
|
|
637
|
+
|
|
638
|
+
def idString(self) -> str:
|
|
639
|
+
return self.serial or "(unknown-serial)"
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
class SshDevice(Device):
|
|
643
|
+
"""Execute commands on a device over SSH (paramiko)."""
|
|
644
|
+
|
|
645
|
+
def __init__(self, host: str, port: int, user: str, password: str):
|
|
646
|
+
try:
|
|
647
|
+
import paramiko
|
|
648
|
+
except Exception:
|
|
649
|
+
print("ERROR: paramiko is required for SSH mode. Install with: pip install paramiko", file=sys.stderr)
|
|
650
|
+
sys.exit(1)
|
|
651
|
+
|
|
652
|
+
self.paramiko = paramiko
|
|
653
|
+
self.host = host
|
|
654
|
+
self.port = port
|
|
655
|
+
self.user = user
|
|
656
|
+
self.password = password
|
|
657
|
+
|
|
658
|
+
self.client = self.paramiko.SSHClient()
|
|
659
|
+
self.client.load_system_host_keys()
|
|
660
|
+
self.client.set_missing_host_key_policy(self.paramiko.RejectPolicy())
|
|
661
|
+
try:
|
|
662
|
+
self.client.connect(hostname=host, port=port, username=user,
|
|
663
|
+
password=password, look_for_keys=False,
|
|
664
|
+
allow_agent=False, timeout=20)
|
|
665
|
+
except (paramiko.AuthenticationException, paramiko.SSHException, OSError) as e:
|
|
666
|
+
print(f"ERROR: SSH connection failed: {e}", file=sys.stderr)
|
|
667
|
+
sys.exit(1)
|
|
668
|
+
|
|
669
|
+
def shell(self, command: str) -> str:
|
|
670
|
+
try:
|
|
671
|
+
# Wrap in sh -c so that pipes, redirects, &&, || all work and the
|
|
672
|
+
# remote shell's PATH (including /system/bin on Android) is active.
|
|
673
|
+
stdin, stdout, stderr = self.client.exec_command(
|
|
674
|
+
f"sh -c {shlex.quote(command)}", timeout=30
|
|
675
|
+
)
|
|
676
|
+
out = stdout.read().decode("utf-8", errors="replace")
|
|
677
|
+
err = stderr.read().decode("utf-8", errors="replace")
|
|
678
|
+
return (out + (("\n" + err) if err else "")).strip()
|
|
679
|
+
except Exception as e:
|
|
680
|
+
return f"[SSH Error] {e}"
|
|
681
|
+
|
|
682
|
+
def idString(self) -> str:
|
|
683
|
+
return f"{self.user}@{self.host}:{self.port}"
|
|
684
|
+
|
|
685
|
+
def close(self):
|
|
686
|
+
try:
|
|
687
|
+
self.client.close()
|
|
688
|
+
except Exception:
|
|
689
|
+
pass
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
# Common baud rates for UART auto-detection (ordered by popularity)
|
|
693
|
+
UART_COMMON_BAUDS = [115200, 9600, 38400, 57600, 19200, 230400, 460800, 921600, 4800, 1200]
|
|
694
|
+
|
|
695
|
+
class UartDevice(Device):
|
|
696
|
+
"""Execute commands on a device over UART serial console (pyserial)."""
|
|
697
|
+
|
|
698
|
+
def __init__(self, port: str, baud: int = 0):
|
|
699
|
+
try:
|
|
700
|
+
import serial
|
|
701
|
+
except ImportError:
|
|
702
|
+
print("ERROR: pyserial is required for UART mode. Install with: pip install pyserial",
|
|
703
|
+
file=sys.stderr)
|
|
704
|
+
sys.exit(1)
|
|
705
|
+
|
|
706
|
+
self.serial_mod = serial
|
|
707
|
+
self.port = port
|
|
708
|
+
self.baud = baud
|
|
709
|
+
self._shell_uid = None # cached after probe
|
|
710
|
+
|
|
711
|
+
if baud == 0:
|
|
712
|
+
self.baud = self._autoBaud()
|
|
713
|
+
|
|
714
|
+
try:
|
|
715
|
+
self.conn = self.serial_mod.Serial(
|
|
716
|
+
port=self.port,
|
|
717
|
+
baudrate=self.baud,
|
|
718
|
+
timeout=3,
|
|
719
|
+
write_timeout=3,
|
|
720
|
+
)
|
|
721
|
+
except self.serial_mod.SerialException as e:
|
|
722
|
+
print(f"ERROR: Cannot open UART port {port}: {e}", file=sys.stderr)
|
|
723
|
+
sys.exit(1)
|
|
724
|
+
|
|
725
|
+
# Flush any leftover data
|
|
726
|
+
time.sleep(0.3)
|
|
727
|
+
self.conn.reset_input_buffer()
|
|
728
|
+
self.conn.reset_output_buffer()
|
|
729
|
+
|
|
730
|
+
def _autoBaud(self) -> int:
|
|
731
|
+
"""Try common baud rates and return the first one that gives a coherent shell response."""
|
|
732
|
+
print(f"{Colors.BRIGHT_CYAN}⟳ Auto-detecting baud rate on {self.port}...{Colors.RESET}")
|
|
733
|
+
for rate in UART_COMMON_BAUDS:
|
|
734
|
+
try:
|
|
735
|
+
conn = self.serial_mod.Serial(
|
|
736
|
+
port=self.port, baudrate=rate, timeout=2, write_timeout=2
|
|
737
|
+
)
|
|
738
|
+
time.sleep(0.3)
|
|
739
|
+
conn.reset_input_buffer()
|
|
740
|
+
# Send a newline to trigger a prompt, then echo test
|
|
741
|
+
conn.write(b"\n")
|
|
742
|
+
time.sleep(0.3)
|
|
743
|
+
conn.reset_input_buffer()
|
|
744
|
+
conn.write(b"echo HARDAX_BAUD_OK\n")
|
|
745
|
+
time.sleep(1)
|
|
746
|
+
resp = conn.read(conn.in_waiting or 256).decode("utf-8", errors="replace")
|
|
747
|
+
conn.close()
|
|
748
|
+
if "HARDAX_BAUD_OK" in resp:
|
|
749
|
+
print(f" {Colors.GREEN}✓ Detected baud rate: {rate}{Colors.RESET}")
|
|
750
|
+
return rate
|
|
751
|
+
except Exception:
|
|
752
|
+
try:
|
|
753
|
+
conn.close()
|
|
754
|
+
except Exception:
|
|
755
|
+
pass
|
|
756
|
+
continue
|
|
757
|
+
print(f" {Colors.BRIGHT_RED}✗ Auto-detection failed. Defaulting to 115200.{Colors.RESET}")
|
|
758
|
+
print(f" {Colors.YELLOW} Tip: specify --baud manually if the device uses a non-standard rate.{Colors.RESET}")
|
|
759
|
+
return 115200
|
|
760
|
+
|
|
761
|
+
_UART_MAX_BUF = 4 * 1024 * 1024 # 4 MB max buffer per command
|
|
762
|
+
|
|
763
|
+
def _sendRecv(self, command: str, timeout: float = 10) -> str:
|
|
764
|
+
"""Send a command over UART and collect the output."""
|
|
765
|
+
# Use a unique marker to delimit command output (hex random to avoid collisions)
|
|
766
|
+
marker = f"__HARDAX_{os.urandom(8).hex()}__"
|
|
767
|
+
wrapped = f"{command}; echo {marker}\n"
|
|
768
|
+
|
|
769
|
+
self.conn.reset_input_buffer()
|
|
770
|
+
self.conn.write(wrapped.encode("utf-8"))
|
|
771
|
+
self.conn.flush()
|
|
772
|
+
|
|
773
|
+
buf = b""
|
|
774
|
+
marker_bytes = marker.encode("utf-8")
|
|
775
|
+
deadline = time.time() + timeout
|
|
776
|
+
while time.time() < deadline:
|
|
777
|
+
waiting = self.conn.in_waiting
|
|
778
|
+
if waiting:
|
|
779
|
+
buf += self.conn.read(waiting)
|
|
780
|
+
if marker_bytes in buf:
|
|
781
|
+
break
|
|
782
|
+
if len(buf) > self._UART_MAX_BUF:
|
|
783
|
+
break
|
|
784
|
+
else:
|
|
785
|
+
time.sleep(0.1)
|
|
786
|
+
|
|
787
|
+
text = buf.decode("utf-8", errors="replace")
|
|
788
|
+
|
|
789
|
+
# Strip the echoed command line and the marker line.
|
|
790
|
+
# Strategy: find the first line containing the marker echo command,
|
|
791
|
+
# then collect everything between that line and the marker output.
|
|
792
|
+
lines = text.splitlines()
|
|
793
|
+
out_lines = []
|
|
794
|
+
started = False
|
|
795
|
+
# Build a match string from the full wrapped command for exact echo detection
|
|
796
|
+
echo_sig = f"echo {marker}"
|
|
797
|
+
for ln in lines:
|
|
798
|
+
if not started:
|
|
799
|
+
# Look for the echoed command line (contains our unique marker echo)
|
|
800
|
+
if echo_sig in ln:
|
|
801
|
+
started = True
|
|
802
|
+
continue
|
|
803
|
+
continue
|
|
804
|
+
if marker in ln:
|
|
805
|
+
break
|
|
806
|
+
out_lines.append(ln)
|
|
807
|
+
return "\n".join(out_lines).strip()
|
|
808
|
+
|
|
809
|
+
def shell(self, command: str) -> str:
|
|
810
|
+
try:
|
|
811
|
+
return self._sendRecv(command)
|
|
812
|
+
except self.serial_mod.SerialException as e:
|
|
813
|
+
print(f"\n{Colors.BRIGHT_RED}✗ UART connection lost: {e}{Colors.RESET}", file=sys.stderr)
|
|
814
|
+
raise RuntimeError(f"UART connection lost: {e}") from e
|
|
815
|
+
except Exception as e:
|
|
816
|
+
return f"[UART Error] {e}"
|
|
817
|
+
|
|
818
|
+
def idString(self) -> str:
|
|
819
|
+
return f"uart:{self.port}@{self.baud}"
|
|
820
|
+
|
|
821
|
+
def probeShell(self) -> dict:
|
|
822
|
+
"""Probe the UART shell environment and return info dict."""
|
|
823
|
+
info = {}
|
|
824
|
+
info["uid"] = self._sendRecv("id 2>/dev/null").strip()
|
|
825
|
+
info["shell"] = self._sendRecv("which sh 2>/dev/null || command -v sh 2>/dev/null").strip()
|
|
826
|
+
info["bash"] = self._sendRecv("which bash 2>/dev/null || command -v bash 2>/dev/null").strip()
|
|
827
|
+
info["uname"] = self._sendRecv("uname -a 2>/dev/null").strip()
|
|
828
|
+
info["is_root"] = "uid=0(" in info.get("uid", "")
|
|
829
|
+
info["is_android"] = self._sendRecv(
|
|
830
|
+
"test -f /system/build.prop && echo YES 2>/dev/null"
|
|
831
|
+
).strip() == "YES"
|
|
832
|
+
info["has_getprop"] = self._sendRecv(
|
|
833
|
+
"command -v getprop >/dev/null 2>&1 && echo YES"
|
|
834
|
+
).strip() == "YES"
|
|
835
|
+
info["has_busybox"] = self._sendRecv(
|
|
836
|
+
"command -v busybox >/dev/null 2>&1 && echo YES"
|
|
837
|
+
).strip() == "YES"
|
|
838
|
+
info["has_toybox"] = self._sendRecv(
|
|
839
|
+
"command -v toybox >/dev/null 2>&1 && echo YES"
|
|
840
|
+
).strip() == "YES"
|
|
841
|
+
info["path"] = self._sendRecv("echo $PATH").strip()
|
|
842
|
+
self._shell_uid = info["uid"]
|
|
843
|
+
return info
|
|
844
|
+
|
|
845
|
+
def close(self):
|
|
846
|
+
try:
|
|
847
|
+
self.conn.close()
|
|
848
|
+
except Exception:
|
|
849
|
+
pass
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
853
|
+
# COMMAND EXECUTION ENGINE
|
|
854
|
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
855
|
+
|
|
856
|
+
def applyFilters(output: str, original: str) -> str:
|
|
857
|
+
"""
|
|
858
|
+
Emulate a shell pipeline (grep, head, tail) in Python so we can
|
|
859
|
+
re-apply filters on locally captured command output.
|
|
860
|
+
"""
|
|
861
|
+
if not output:
|
|
862
|
+
return output
|
|
863
|
+
|
|
864
|
+
lines = output.splitlines()
|
|
865
|
+
|
|
866
|
+
pipe = ""
|
|
867
|
+
if "|" in original:
|
|
868
|
+
pipe = original.split("|", 1)[1]
|
|
869
|
+
if not pipe:
|
|
870
|
+
return "\n".join(lines)
|
|
871
|
+
|
|
872
|
+
stages = [s.strip() for s in pipe.split("|") if s.strip()]
|
|
873
|
+
|
|
874
|
+
headN = None
|
|
875
|
+
tailN = None
|
|
876
|
+
greps = []
|
|
877
|
+
|
|
878
|
+
for st in stages:
|
|
879
|
+
if st.startswith("grep"):
|
|
880
|
+
mflags = re.search(r"(^|\s)-([iEvF]+)", st)
|
|
881
|
+
flags = set(mflags.group(2)) if mflags else set()
|
|
882
|
+
pm = re.search(r"""'(.*?)'|"(.*?)"|(\S+)$""", st)
|
|
883
|
+
if not pm:
|
|
884
|
+
continue
|
|
885
|
+
pattern = pm.group(1) or pm.group(2) or pm.group(3)
|
|
886
|
+
greps.append((flags, pattern))
|
|
887
|
+
elif st.startswith("head"):
|
|
888
|
+
m = re.search(r"head\s+-?(\d+)", st)
|
|
889
|
+
if m:
|
|
890
|
+
headN = int(m.group(1))
|
|
891
|
+
elif st.startswith("tail"):
|
|
892
|
+
m = re.search(r"tail\s+-?(\d+)", st)
|
|
893
|
+
if m:
|
|
894
|
+
tailN = int(m.group(1))
|
|
895
|
+
|
|
896
|
+
filtered = lines
|
|
897
|
+
for flags, pattern in greps:
|
|
898
|
+
ignoreCase = "i" in flags
|
|
899
|
+
invert = "v" in flags
|
|
900
|
+
fixed = "F" in flags
|
|
901
|
+
|
|
902
|
+
if fixed:
|
|
903
|
+
needle = pattern if not ignoreCase else pattern.lower()
|
|
904
|
+
def matchFn(s, _n=needle, _ic=ignoreCase):
|
|
905
|
+
h = s if not _ic else s.lower()
|
|
906
|
+
return _n in h
|
|
907
|
+
else:
|
|
908
|
+
try:
|
|
909
|
+
rx = re.compile(pattern, re.IGNORECASE if ignoreCase else 0)
|
|
910
|
+
def matchFn(s, _rx=rx):
|
|
911
|
+
return bool(_rx.search(s))
|
|
912
|
+
except re.error:
|
|
913
|
+
needle = pattern if not ignoreCase else pattern.lower()
|
|
914
|
+
def matchFn(s, _n=needle, _ic=ignoreCase):
|
|
915
|
+
h = s if not _ic else s.lower()
|
|
916
|
+
return _n in h
|
|
917
|
+
|
|
918
|
+
if invert:
|
|
919
|
+
filtered = [ln for ln in filtered if not matchFn(ln)]
|
|
920
|
+
else:
|
|
921
|
+
filtered = [ln for ln in filtered if matchFn(ln)]
|
|
922
|
+
|
|
923
|
+
if tailN is not None and tailN > 0:
|
|
924
|
+
filtered = filtered[-tailN:]
|
|
925
|
+
elif tailN == 0:
|
|
926
|
+
filtered = []
|
|
927
|
+
if headN is not None and headN >= 0:
|
|
928
|
+
filtered = filtered[:headN]
|
|
929
|
+
|
|
930
|
+
return "\n".join(filtered)
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def executeWithFallback(device: Device, command: str,
|
|
934
|
+
showCommands: bool = False,
|
|
935
|
+
isRooted: Optional[bool] = None,
|
|
936
|
+
rootMethod: str = "none") -> str:
|
|
937
|
+
"""
|
|
938
|
+
Smart execution for netstat/ss network commands.
|
|
939
|
+
Tries multiple strategies: root → non-root → drop -p → swap tool.
|
|
940
|
+
Non-network commands pass straight through.
|
|
941
|
+
"""
|
|
942
|
+
|
|
943
|
+
def isNetOrSs(cmd: str) -> bool:
|
|
944
|
+
cl = cmd.lower()
|
|
945
|
+
return ("netstat" in cl) or bool(re.search(r"\bss\b", cl))
|
|
946
|
+
|
|
947
|
+
def splitAlternatives(src: str) -> list:
|
|
948
|
+
s = re.sub(
|
|
949
|
+
r"^\s*(?:/system/bin/)?sh\s+-[a-z]*c\s+(['\"])(.*?)\1\s*$",
|
|
950
|
+
r"\2", src.strip(), flags=re.IGNORECASE,
|
|
951
|
+
)
|
|
952
|
+
s = s.replace("\r\n", "\n").replace("\r", "\n")
|
|
953
|
+
blocks = re.split(r"\n\s*\n+", s.strip())
|
|
954
|
+
return [b for b in blocks if isNetOrSs(b)]
|
|
955
|
+
|
|
956
|
+
def splitPipeline(block: str):
|
|
957
|
+
if "|" not in block:
|
|
958
|
+
return block.strip(), ""
|
|
959
|
+
base, rest = block.split("|", 1)
|
|
960
|
+
return base.strip(), ("|" + rest.strip())
|
|
961
|
+
|
|
962
|
+
def dropPidFlag(cmd: str) -> str:
|
|
963
|
+
def _rmP(m):
|
|
964
|
+
f = m.group(1)
|
|
965
|
+
f2 = f.replace("p", "")
|
|
966
|
+
return "-" + f2 if f2 else ""
|
|
967
|
+
return re.sub(r"\s-(\w+)", _rmP, cmd)
|
|
968
|
+
|
|
969
|
+
def swapTool(cmd: str):
|
|
970
|
+
if re.match(r"(?i)^\s*netstat\b", cmd):
|
|
971
|
+
return re.sub(r"(?i)^\s*netstat\b", "ss", cmd, count=1)
|
|
972
|
+
if re.match(r"(?i)^\s*ss\b", cmd):
|
|
973
|
+
return re.sub(r"(?i)^\s*ss\b", "netstat", cmd, count=1)
|
|
974
|
+
return None
|
|
975
|
+
|
|
976
|
+
def outputReason(txt: str):
|
|
977
|
+
if not txt or not txt.strip():
|
|
978
|
+
return False, "empty output"
|
|
979
|
+
lower = txt.lower()
|
|
980
|
+
for bad in ["not found", "invalid", "permission denied", "cannot open", "no such"]:
|
|
981
|
+
if bad in lower:
|
|
982
|
+
return False, bad
|
|
983
|
+
lines = [l for l in txt.strip().split("\n") if l.strip()]
|
|
984
|
+
if len(lines) <= 1 and lines and ("proto" in lines[0].lower() or "state" in lines[0].lower()):
|
|
985
|
+
return False, "header-only"
|
|
986
|
+
return True, "ok"
|
|
987
|
+
|
|
988
|
+
# Non-network commands go straight through
|
|
989
|
+
if not isNetOrSs(command):
|
|
990
|
+
if NET_DEBUG:
|
|
991
|
+
print("[net-debug] non-network command -> bypass executor")
|
|
992
|
+
return device.shell(command)
|
|
993
|
+
|
|
994
|
+
blocks = splitAlternatives(command)
|
|
995
|
+
if NET_DEBUG:
|
|
996
|
+
print("[net-debug] alternatives: %d block(s)" % len(blocks))
|
|
997
|
+
if not blocks:
|
|
998
|
+
return device.shell(command)
|
|
999
|
+
|
|
1000
|
+
_nativeSu = rootMethod not in ("uart-root", "ssh-root", "adbd-root")
|
|
1001
|
+
|
|
1002
|
+
def _suWrap(cmd: str) -> str:
|
|
1003
|
+
return "su -c %s" % shlex.quote(cmd)
|
|
1004
|
+
|
|
1005
|
+
for block in blocks:
|
|
1006
|
+
baseCmd, pipeline = splitPipeline(block)
|
|
1007
|
+
|
|
1008
|
+
candidates = []
|
|
1009
|
+
if _nativeSu and (isRooted or isRooted is None):
|
|
1010
|
+
candidates.append(_suWrap(baseCmd))
|
|
1011
|
+
candidates.append(baseCmd)
|
|
1012
|
+
|
|
1013
|
+
noP = dropPidFlag(baseCmd)
|
|
1014
|
+
if noP != baseCmd:
|
|
1015
|
+
if _nativeSu and (isRooted or isRooted is None):
|
|
1016
|
+
candidates.append(_suWrap(noP))
|
|
1017
|
+
candidates.append(noP)
|
|
1018
|
+
|
|
1019
|
+
swapped = swapTool(baseCmd)
|
|
1020
|
+
if swapped:
|
|
1021
|
+
if _nativeSu and (isRooted or isRooted is None):
|
|
1022
|
+
candidates.append(_suWrap(swapped))
|
|
1023
|
+
candidates.append(swapped)
|
|
1024
|
+
swappedNoP = dropPidFlag(swapped)
|
|
1025
|
+
if swappedNoP != swapped:
|
|
1026
|
+
if _nativeSu and (isRooted or isRooted is None):
|
|
1027
|
+
candidates.append(_suWrap(swappedNoP))
|
|
1028
|
+
candidates.append(swappedNoP)
|
|
1029
|
+
|
|
1030
|
+
if NET_DEBUG:
|
|
1031
|
+
print("[net-debug] candidates (%d):" % len(candidates))
|
|
1032
|
+
for c in candidates:
|
|
1033
|
+
print(" - %s" % c)
|
|
1034
|
+
|
|
1035
|
+
for cand in candidates:
|
|
1036
|
+
if showCommands:
|
|
1037
|
+
print(" -> Trying: %s" % cand)
|
|
1038
|
+
raw = device.shell(cand)
|
|
1039
|
+
ok, why = outputReason(raw)
|
|
1040
|
+
if not ok:
|
|
1041
|
+
if NET_DEBUG:
|
|
1042
|
+
print("[net-debug] reject: %s" % why)
|
|
1043
|
+
continue
|
|
1044
|
+
if NET_DEBUG:
|
|
1045
|
+
print("[net-debug] winner: %s" % cand)
|
|
1046
|
+
pipelineSrc = baseCmd + (" " + pipeline if pipeline else "")
|
|
1047
|
+
return applyFilters(raw, pipelineSrc)
|
|
1048
|
+
|
|
1049
|
+
return ""
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
1053
|
+
# ROOT DETECTION
|
|
1054
|
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
1055
|
+
|
|
1056
|
+
def detectRootStatus(device: Device) -> Tuple[bool, str]:
|
|
1057
|
+
"""
|
|
1058
|
+
Probe the device for root access.
|
|
1059
|
+
Returns (isRooted, method) where method is one of:
|
|
1060
|
+
uart-root | ssh-root | adbd-root | magisk | su | su-present-not-working | none
|
|
1061
|
+
"""
|
|
1062
|
+
|
|
1063
|
+
# 0a. UART sessions already running as root - no su needed
|
|
1064
|
+
if isinstance(device, UartDevice):
|
|
1065
|
+
out = device.shell("id 2>/dev/null").strip()
|
|
1066
|
+
if out and ("uid=0(" in out or out.split()[0:1] == ["uid=0"]):
|
|
1067
|
+
return True, "uart-root"
|
|
1068
|
+
# Not root over UART - fall through to su probing below
|
|
1069
|
+
|
|
1070
|
+
# 0b. SSH sessions already running as root - no su needed
|
|
1071
|
+
if isinstance(device, SshDevice):
|
|
1072
|
+
out = device.shell("id 2>/dev/null").strip()
|
|
1073
|
+
if out and ("uid=0(" in out or out.split()[0:1] == ["uid=0"]):
|
|
1074
|
+
return True, "ssh-root"
|
|
1075
|
+
# Not root over SSH - fall through to su probing below
|
|
1076
|
+
|
|
1077
|
+
# 1. Try ADBD root (eng / userdebug builds)
|
|
1078
|
+
try:
|
|
1079
|
+
if isinstance(device, AdbDevice):
|
|
1080
|
+
runLocal(["adb", "start-server"])
|
|
1081
|
+
runLocal(device.adbArgs() + ["root"])
|
|
1082
|
+
out = device.shell("id 2>/dev/null")
|
|
1083
|
+
if out and ("uid=0(" in out or out.strip() == "0"):
|
|
1084
|
+
return True, "adbd-root"
|
|
1085
|
+
except Exception:
|
|
1086
|
+
pass
|
|
1087
|
+
|
|
1088
|
+
# 2. Check for su binary existence
|
|
1089
|
+
suPath = device.shell("command -v su 2>/dev/null || which su 2>/dev/null").strip()
|
|
1090
|
+
hasSu = bool(suPath and "not found" not in suPath.lower())
|
|
1091
|
+
|
|
1092
|
+
# Feature-detect timeout and cut
|
|
1093
|
+
try:
|
|
1094
|
+
hasTimeout = "yes" in device.shell(
|
|
1095
|
+
"command -v timeout >/dev/null 2>&1 && echo yes || echo no"
|
|
1096
|
+
).strip().lower()
|
|
1097
|
+
except Exception:
|
|
1098
|
+
hasTimeout = False
|
|
1099
|
+
try:
|
|
1100
|
+
hasCut = "yes" in device.shell(
|
|
1101
|
+
"command -v cut >/dev/null 2>&1 && echo yes || echo no"
|
|
1102
|
+
).strip().lower()
|
|
1103
|
+
except Exception:
|
|
1104
|
+
hasCut = False
|
|
1105
|
+
|
|
1106
|
+
def suCmd(cmd: str, seconds: int = 2) -> str:
|
|
1107
|
+
q = shlex.quote(cmd)
|
|
1108
|
+
if hasTimeout:
|
|
1109
|
+
return device.shell(f"timeout {seconds} su -c {q} 2>/dev/null")
|
|
1110
|
+
return device.shell(f"su -c {q} 2>/dev/null")
|
|
1111
|
+
|
|
1112
|
+
if hasSu:
|
|
1113
|
+
# 3a. Proof by UID
|
|
1114
|
+
out = suCmd("id -u", 2).strip()
|
|
1115
|
+
if out == "0":
|
|
1116
|
+
ver = suCmd("magisk --version", 2).strip() or suCmd("magisk -v", 2).strip()
|
|
1117
|
+
return (True, "magisk") if ver else (True, "su")
|
|
1118
|
+
|
|
1119
|
+
# 3b. Proof by canonical id string
|
|
1120
|
+
idOut = suCmd("id", 2)
|
|
1121
|
+
if idOut and ("uid=0(" in idOut or "uid=0" in idOut):
|
|
1122
|
+
if "context=u:r:magisk:s0" in idOut:
|
|
1123
|
+
return True, "magisk"
|
|
1124
|
+
ver = suCmd("magisk --version", 2).strip() or suCmd("magisk -v", 2).strip()
|
|
1125
|
+
return (True, "magisk") if ver else (True, "su")
|
|
1126
|
+
|
|
1127
|
+
# 3c. Proof by cut-parsed username
|
|
1128
|
+
if hasCut:
|
|
1129
|
+
who = suCmd("id | cut -d'(' -f2 | cut -d')' -f1", 2).strip()
|
|
1130
|
+
if who.lower() == "root":
|
|
1131
|
+
if not idOut:
|
|
1132
|
+
idOut = suCmd("id", 2)
|
|
1133
|
+
if idOut and "context=u:r:magisk:s0" in idOut:
|
|
1134
|
+
return True, "magisk"
|
|
1135
|
+
ver = suCmd("magisk --version", 2).strip() or suCmd("magisk -v", 2).strip()
|
|
1136
|
+
return (True, "magisk") if ver else (True, "su")
|
|
1137
|
+
|
|
1138
|
+
return False, "su-present-not-working"
|
|
1139
|
+
|
|
1140
|
+
return False, "none"
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
1144
|
+
# DEVICE INFORMATION
|
|
1145
|
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
1146
|
+
|
|
1147
|
+
def _getPropFallback(device: Device, props: List[str]) -> str:
|
|
1148
|
+
for p in props:
|
|
1149
|
+
v = device.shell(f"getprop {p}").strip()
|
|
1150
|
+
if v:
|
|
1151
|
+
return v
|
|
1152
|
+
return "(unknown)"
|
|
1153
|
+
|
|
1154
|
+
|
|
1155
|
+
def _getPropWithCpuinfo(device: Device, props: List[str]) -> str:
|
|
1156
|
+
for p in props:
|
|
1157
|
+
v = device.shell(f"getprop {p}").strip()
|
|
1158
|
+
if v:
|
|
1159
|
+
return v
|
|
1160
|
+
cpuinfo = device.shell("cat /proc/cpuinfo")
|
|
1161
|
+
m = re.search(r"(?i)hardware\s*:\s*(.+)", cpuinfo)
|
|
1162
|
+
if m:
|
|
1163
|
+
return m.group(1).strip()
|
|
1164
|
+
return "(unknown)"
|
|
1165
|
+
|
|
1166
|
+
|
|
1167
|
+
def collectDeviceInfo(device: Device) -> Dict[str, str]:
|
|
1168
|
+
"""Pull essential device metadata for the report header."""
|
|
1169
|
+
model = _getPropFallback(device, ["ro.product.model", "ro.product.device", "ro.product.name"])
|
|
1170
|
+
brand = _getPropFallback(device, ["ro.product.brand", "ro.product.manufacturer"])
|
|
1171
|
+
manufacturer = _getPropFallback(device, ["ro.product.manufacturer", "ro.product.brand"])
|
|
1172
|
+
name = _getPropFallback(device, ["ro.product.name", "ro.product.model"])
|
|
1173
|
+
socManufacturer = _getPropFallback(device, ["ro.soc.manufacturer", "ro.board.platform", "ro.hardware"])
|
|
1174
|
+
socModel = _getPropWithCpuinfo(device, ["ro.soc.model", "ro.hardware", "ro.board.platform"])
|
|
1175
|
+
androidVersion = device.shell("getprop ro.build.version.release").strip()
|
|
1176
|
+
sdkLevel = device.shell("getprop ro.build.version.sdk").strip()
|
|
1177
|
+
buildId = device.shell("getprop ro.build.display.id").strip()
|
|
1178
|
+
fingerprint = device.shell("getprop ro.build.fingerprint").strip()
|
|
1179
|
+
serialno = device.shell("getprop ro.serialno").strip() or device.shell("getprop ro.boot.serialno").strip()
|
|
1180
|
+
timezone = device.shell("getprop persist.sys.timezone").strip()
|
|
1181
|
+
|
|
1182
|
+
return {
|
|
1183
|
+
"model": model,
|
|
1184
|
+
"brand": brand,
|
|
1185
|
+
"manufacturer": manufacturer,
|
|
1186
|
+
"name": name,
|
|
1187
|
+
"soc_manufacturer": socManufacturer,
|
|
1188
|
+
"soc_model": socModel,
|
|
1189
|
+
"android_version": androidVersion,
|
|
1190
|
+
"sdk_level": sdkLevel,
|
|
1191
|
+
"build_id": buildId,
|
|
1192
|
+
"fingerprint": fingerprint,
|
|
1193
|
+
"serialno": serialno,
|
|
1194
|
+
"timezone": timezone,
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
|
|
1198
|
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
1199
|
+
# JSON CHECK LOADING & VALIDATION
|
|
1200
|
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
1201
|
+
|
|
1202
|
+
def _loadChecksFromFile(path: str) -> List[Dict[str, Any]]:
|
|
1203
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
1204
|
+
data = json.load(f)
|
|
1205
|
+
if isinstance(data, list):
|
|
1206
|
+
checks = data
|
|
1207
|
+
elif isinstance(data, dict) and isinstance(data.get("checks"), list):
|
|
1208
|
+
checks = data["checks"]
|
|
1209
|
+
else:
|
|
1210
|
+
raise ValueError(f"{os.path.basename(path)} must be a list or an object with 'checks' array")
|
|
1211
|
+
|
|
1212
|
+
valid = []
|
|
1213
|
+
for i, c in enumerate(checks, start=1):
|
|
1214
|
+
if not isinstance(c, dict):
|
|
1215
|
+
continue
|
|
1216
|
+
if not REQUIRED_CHECK_KEYS.issubset(c.keys()):
|
|
1217
|
+
missing = REQUIRED_CHECK_KEYS - set(c.keys())
|
|
1218
|
+
raise ValueError(f"{os.path.basename(path)}: check #{i} missing keys: {', '.join(sorted(missing))}")
|
|
1219
|
+
valid.append(c)
|
|
1220
|
+
return valid
|
|
1221
|
+
|
|
1222
|
+
|
|
1223
|
+
def loadChecks(jsonPath: Optional[str], jsonDir: Optional[str]) -> List[Dict[str, Any]]:
|
|
1224
|
+
"""Load and merge security checks from JSON file(s)."""
|
|
1225
|
+
merged: List[Dict[str, Any]] = []
|
|
1226
|
+
|
|
1227
|
+
if jsonPath:
|
|
1228
|
+
if not os.path.isfile(jsonPath):
|
|
1229
|
+
print(f"ERROR: JSON file not found: {jsonPath}", file=sys.stderr)
|
|
1230
|
+
sys.exit(1)
|
|
1231
|
+
try:
|
|
1232
|
+
merged.extend(_loadChecksFromFile(jsonPath))
|
|
1233
|
+
except Exception as e:
|
|
1234
|
+
print(f"ERROR parsing {jsonPath}: {e}", file=sys.stderr)
|
|
1235
|
+
sys.exit(1)
|
|
1236
|
+
|
|
1237
|
+
if jsonDir:
|
|
1238
|
+
if not os.path.isdir(jsonDir):
|
|
1239
|
+
print(f"ERROR: JSON directory not found: {jsonDir}", file=sys.stderr)
|
|
1240
|
+
sys.exit(1)
|
|
1241
|
+
for fname in sorted(os.listdir(jsonDir)):
|
|
1242
|
+
if not fname.lower().endswith(".json"):
|
|
1243
|
+
continue
|
|
1244
|
+
fpath = os.path.join(jsonDir, fname)
|
|
1245
|
+
try:
|
|
1246
|
+
merged.extend(_loadChecksFromFile(fpath))
|
|
1247
|
+
except Exception as e:
|
|
1248
|
+
print(f"ERROR parsing {fpath}: {e}", file=sys.stderr)
|
|
1249
|
+
sys.exit(1)
|
|
1250
|
+
|
|
1251
|
+
if not merged:
|
|
1252
|
+
print("ERROR: No checks loaded. Provide --json or --json-dir.", file=sys.stderr)
|
|
1253
|
+
sys.exit(1)
|
|
1254
|
+
|
|
1255
|
+
# Surface checks whose safe_pattern is not a valid regex. Such checks
|
|
1256
|
+
# silently degrade to substring matching at run time, which can flip
|
|
1257
|
+
# SAFE/CRITICAL results — warn so authors can fix them.
|
|
1258
|
+
patternIssues: List[str] = []
|
|
1259
|
+
for chk in merged:
|
|
1260
|
+
patternIssues.extend(validateCheckPattern(chk))
|
|
1261
|
+
if patternIssues:
|
|
1262
|
+
print(f"{Colors.YELLOW}⚠ {len(patternIssues)} check(s) have invalid safe_pattern "
|
|
1263
|
+
f"regex (will fall back to substring match):{Colors.RESET}", file=sys.stderr)
|
|
1264
|
+
for msg in patternIssues[:10]:
|
|
1265
|
+
print(f" {msg}", file=sys.stderr)
|
|
1266
|
+
if len(patternIssues) > 10:
|
|
1267
|
+
print(f" ... and {len(patternIssues) - 10} more", file=sys.stderr)
|
|
1268
|
+
|
|
1269
|
+
return merged
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
def validateCheckPattern(check: Dict[str, Any]) -> List[str]:
|
|
1273
|
+
"""Validate safe_pattern regex for a single check definition."""
|
|
1274
|
+
issues = []
|
|
1275
|
+
pattern = check.get("safe_pattern", "")
|
|
1276
|
+
label = check.get("label", "unknown")
|
|
1277
|
+
if not pattern:
|
|
1278
|
+
return issues
|
|
1279
|
+
try:
|
|
1280
|
+
re.compile(pattern, re.IGNORECASE | re.MULTILINE | re.DOTALL)
|
|
1281
|
+
except re.error as e:
|
|
1282
|
+
issues.append(f"[{label}] Invalid regex: {e}")
|
|
1283
|
+
return issues
|
|
1284
|
+
|
|
1285
|
+
|
|
1286
|
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
1287
|
+
# CHECK EVALUATION ENGINE
|
|
1288
|
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
1289
|
+
|
|
1290
|
+
def isNullResponse(output: str) -> bool:
|
|
1291
|
+
"""Detect settings/getprop returning literal 'null'."""
|
|
1292
|
+
if not output:
|
|
1293
|
+
return False
|
|
1294
|
+
return output.lower().strip() in ["null", "none", "(null)", "(none)"]
|
|
1295
|
+
|
|
1296
|
+
|
|
1297
|
+
def isEmptyOrError(output: str) -> bool:
|
|
1298
|
+
"""Detect empty output or common error strings from the device."""
|
|
1299
|
+
if not output:
|
|
1300
|
+
return True
|
|
1301
|
+
if isAdbTransportError(output):
|
|
1302
|
+
return True
|
|
1303
|
+
lower = output.lower().strip()
|
|
1304
|
+
errorIndicators = [
|
|
1305
|
+
"not found", "no such", "error", "exception",
|
|
1306
|
+
"permission denied", "invalid", "failed",
|
|
1307
|
+
"inaccessible", "cmd: can't find", "can't find service",
|
|
1308
|
+
"not supported", "service not found", "does not exist", "no output",
|
|
1309
|
+
]
|
|
1310
|
+
if lower in ["", "(empty)", "unknown"]:
|
|
1311
|
+
return True
|
|
1312
|
+
for indicator in errorIndicators:
|
|
1313
|
+
if indicator in lower and len(lower) < 100:
|
|
1314
|
+
return True
|
|
1315
|
+
return False
|
|
1316
|
+
|
|
1317
|
+
|
|
1318
|
+
def runChecks(device: Device, checks: List[Dict[str, Any]],
|
|
1319
|
+
onProgress=None, showCommands: bool = False,
|
|
1320
|
+
isRooted: bool = False,
|
|
1321
|
+
rootMethod: str = "none",
|
|
1322
|
+
dashboard: Optional['HUDDashboard'] = None) -> Tuple[List[Dict[str, Any]], Dict[str, int]]:
|
|
1323
|
+
"""
|
|
1324
|
+
Execute every check against the device and classify the result.
|
|
1325
|
+
Returns (rows, counts) where rows is the full audit data.
|
|
1326
|
+
"""
|
|
1327
|
+
rows: List[Dict[str, Any]] = []
|
|
1328
|
+
counts = {"safe": 0, "warning": 0, "critical": 0, "info": 0, "verify": 0, "skipped": 0}
|
|
1329
|
+
total = len(checks)
|
|
1330
|
+
startTime = time.time()
|
|
1331
|
+
consecutiveAdbErrors = 0
|
|
1332
|
+
_lastCategory = None # tracks category for section headers
|
|
1333
|
+
|
|
1334
|
+
for idx, chk in enumerate(checks, start=1):
|
|
1335
|
+
category = chk.get("category", "General")
|
|
1336
|
+
label = chk.get("label", "Unnamed")
|
|
1337
|
+
command = chk.get("command", "")
|
|
1338
|
+
safePattern = chk.get("safe_pattern", "")
|
|
1339
|
+
level = chk.get("level", "info")
|
|
1340
|
+
desc = chk.get("description", "")
|
|
1341
|
+
emptyIsSafe = chk.get("empty_is_safe", False)
|
|
1342
|
+
requiresOutput = chk.get("requires_output", True)
|
|
1343
|
+
nullIsSafe = chk.get("null_is_safe", False)
|
|
1344
|
+
|
|
1345
|
+
raw = executeWithFallback(device, command, showCommands, isRooted=isRooted, rootMethod=rootMethod) if command else ""
|
|
1346
|
+
|
|
1347
|
+
# ADB transport error - SKIPPED
|
|
1348
|
+
if raw and isAdbTransportError(raw):
|
|
1349
|
+
consecutiveAdbErrors += 1
|
|
1350
|
+
status = "SKIPPED"
|
|
1351
|
+
counts["skipped"] += 1
|
|
1352
|
+
raw = f"[ADB ERROR] {raw.strip()}"
|
|
1353
|
+
matched = False
|
|
1354
|
+
needsVerification = False
|
|
1355
|
+
bucket = bucketFromLevel(level)
|
|
1356
|
+
outputIsNull = False
|
|
1357
|
+
|
|
1358
|
+
if consecutiveAdbErrors >= 5:
|
|
1359
|
+
|
|
1360
|
+
print(f"\n {Colors.BRIGHT_RED}✗ Device unresponsive after {consecutiveAdbErrors} consecutive ADB errors.{Colors.RESET}")
|
|
1361
|
+
print(f" {Colors.YELLOW} Attempting reconnect...{Colors.RESET}")
|
|
1362
|
+
if isinstance(device, AdbDevice):
|
|
1363
|
+
runLocal(device.adbArgs() + ["reconnect"])
|
|
1364
|
+
time.sleep(3)
|
|
1365
|
+
runLocal(device.adbArgs() + ["wait-for-device"], timeout=15)
|
|
1366
|
+
time.sleep(2)
|
|
1367
|
+
testCode, testOut, _ = runLocal(device.adbArgs() + ["shell", "echo HARDAX_ALIVE"], timeout=5)
|
|
1368
|
+
if "HARDAX_ALIVE" in (testOut or ""):
|
|
1369
|
+
print(f" {Colors.GREEN} ✓ Device reconnected! Resuming...{Colors.RESET}")
|
|
1370
|
+
consecutiveAdbErrors = 0
|
|
1371
|
+
else:
|
|
1372
|
+
print(f" {Colors.BRIGHT_RED} ✗ Device still offline. Skipping remaining checks.{Colors.RESET}")
|
|
1373
|
+
rows.append({
|
|
1374
|
+
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
1375
|
+
"category": category, "label": label, "level": level,
|
|
1376
|
+
"bucket": bucket, "status": "SKIPPED", "matched": "False",
|
|
1377
|
+
"command": command, "result": raw,
|
|
1378
|
+
"description": desc + " [⊘ Skipped - ADB connection lost]",
|
|
1379
|
+
"needs_verification": False,
|
|
1380
|
+
})
|
|
1381
|
+
for remainingChk in checks[idx:]:
|
|
1382
|
+
rows.append({
|
|
1383
|
+
"category": remainingChk.get("category", "General"),
|
|
1384
|
+
"label": remainingChk.get("label", "Unnamed"),
|
|
1385
|
+
"command": remainingChk.get("command", ""),
|
|
1386
|
+
"result": "[SKIPPED] Device offline - ADB connection lost",
|
|
1387
|
+
"status": "SKIPPED",
|
|
1388
|
+
"description": remainingChk.get("description", ""),
|
|
1389
|
+
})
|
|
1390
|
+
counts["skipped"] += 1
|
|
1391
|
+
break
|
|
1392
|
+
# Service not found error (e.g. "Can't find service: bluetooth_manager") - SKIPPED
|
|
1393
|
+
elif raw and isServiceError(raw):
|
|
1394
|
+
status = "SKIPPED"
|
|
1395
|
+
counts["skipped"] += 1
|
|
1396
|
+
raw = f"[SERVICE N/A] {raw.strip()}"
|
|
1397
|
+
matched = False
|
|
1398
|
+
needsVerification = False
|
|
1399
|
+
bucket = bucketFromLevel(level)
|
|
1400
|
+
outputIsNull = False
|
|
1401
|
+
else:
|
|
1402
|
+
consecutiveAdbErrors = 0
|
|
1403
|
+
normalized = normalizeForMatch(raw).strip()
|
|
1404
|
+
bucket = bucketFromLevel(level)
|
|
1405
|
+
matched = False
|
|
1406
|
+
needsVerification = False
|
|
1407
|
+
|
|
1408
|
+
outputEmpty = isEmptyOrError(raw)
|
|
1409
|
+
outputIsNull = isNullResponse(raw)
|
|
1410
|
+
|
|
1411
|
+
if safePattern:
|
|
1412
|
+
try:
|
|
1413
|
+
matched = bool(re.search(safePattern, normalized,
|
|
1414
|
+
re.IGNORECASE | re.MULTILINE | re.DOTALL))
|
|
1415
|
+
except re.error:
|
|
1416
|
+
matched = safePattern.lower() in normalized.lower()
|
|
1417
|
+
|
|
1418
|
+
# Determine status
|
|
1419
|
+
if outputIsNull:
|
|
1420
|
+
nullInPattern = safePattern and "null" in safePattern.lower()
|
|
1421
|
+
if nullIsSafe or nullInPattern:
|
|
1422
|
+
status = "SAFE"
|
|
1423
|
+
counts["safe"] += 1
|
|
1424
|
+
else:
|
|
1425
|
+
status = "VERIFY"
|
|
1426
|
+
counts["verify"] += 1
|
|
1427
|
+
needsVerification = True
|
|
1428
|
+
elif matched:
|
|
1429
|
+
status = "SAFE"
|
|
1430
|
+
counts["safe"] += 1
|
|
1431
|
+
elif outputEmpty:
|
|
1432
|
+
if emptyIsSafe:
|
|
1433
|
+
status = "SAFE"
|
|
1434
|
+
counts["safe"] += 1
|
|
1435
|
+
elif requiresOutput and bucket in ("critical", "warning"):
|
|
1436
|
+
status = "VERIFY"
|
|
1437
|
+
counts["verify"] += 1
|
|
1438
|
+
needsVerification = True
|
|
1439
|
+
else:
|
|
1440
|
+
status = "INFO"
|
|
1441
|
+
counts["info"] += 1
|
|
1442
|
+
else:
|
|
1443
|
+
if bucket == "critical":
|
|
1444
|
+
status = "CRITICAL"
|
|
1445
|
+
counts["critical"] += 1
|
|
1446
|
+
elif bucket == "warning":
|
|
1447
|
+
status = "WARNING"
|
|
1448
|
+
counts["warning"] += 1
|
|
1449
|
+
else:
|
|
1450
|
+
status = "INFO"
|
|
1451
|
+
counts["info"] += 1
|
|
1452
|
+
|
|
1453
|
+
# Live output
|
|
1454
|
+
try:
|
|
1455
|
+
elapsed = time.time() - startTime
|
|
1456
|
+
avgTime = elapsed / idx if idx > 0 else 0
|
|
1457
|
+
remaining = int(avgTime * (total - idx))
|
|
1458
|
+
etaStr = f"{remaining // 60}m{remaining % 60:02d}s" if remaining >= 60 else f"{remaining}s"
|
|
1459
|
+
percentage = (idx / total) * 100
|
|
1460
|
+
|
|
1461
|
+
sc, sym = HUDDashboard._STATUS_FMT.get(status, (Colors.CYAN, "ℹ"))
|
|
1462
|
+
|
|
1463
|
+
if showCommands:
|
|
1464
|
+
# Category header when section changes
|
|
1465
|
+
if category != _lastCategory:
|
|
1466
|
+
if _lastCategory is not None:
|
|
1467
|
+
print()
|
|
1468
|
+
catLabel = category.upper()
|
|
1469
|
+
# Count checks in this category
|
|
1470
|
+
catTotal = sum(1 for c in checks if c.get("category", "General") == category)
|
|
1471
|
+
print(f" {Colors.BRIGHT_CYAN}┌{'─' * 68}┐{Colors.RESET}")
|
|
1472
|
+
print(f" {Colors.BRIGHT_CYAN}│{Colors.RESET} {Colors.BOLD}{Colors.BRIGHT_WHITE}{catLabel}{Colors.RESET}"
|
|
1473
|
+
f"{Colors.DIM} ({catTotal} checks){Colors.RESET}"
|
|
1474
|
+
f"{'':>{max(1, 60 - len(catLabel) - len(str(catTotal)))}}"
|
|
1475
|
+
f"{Colors.BRIGHT_CYAN}│{Colors.RESET}")
|
|
1476
|
+
print(f" {Colors.BRIGHT_CYAN}└{'─' * 68}┘{Colors.RESET}")
|
|
1477
|
+
_lastCategory = category
|
|
1478
|
+
|
|
1479
|
+
# Per-check line with counter
|
|
1480
|
+
rPreview = (raw or "").strip().split("\n")[0].replace("\r", "")
|
|
1481
|
+
if len(rPreview) > 30:
|
|
1482
|
+
rPreview = rPreview[:29] + "…"
|
|
1483
|
+
|
|
1484
|
+
lbl = label[:40] + "…" if len(label) > 40 else label
|
|
1485
|
+
counter = f"[{idx:03d}/{total}]"
|
|
1486
|
+
print(
|
|
1487
|
+
f" {Colors.DIM}{counter}{Colors.RESET} "
|
|
1488
|
+
f"{sc}{sym}{Colors.RESET} "
|
|
1489
|
+
f"{Colors.BRIGHT_WHITE}{lbl:<41}{Colors.RESET} "
|
|
1490
|
+
f"{Colors.DIM}→ {Colors.RESET}{sc}{rPreview or '(empty)'}{Colors.RESET}"
|
|
1491
|
+
)
|
|
1492
|
+
|
|
1493
|
+
# For critical/warning show the command and remediation
|
|
1494
|
+
if status in ("CRITICAL", "WARNING") and command:
|
|
1495
|
+
cmdShort = command[:60] + "…" if len(command) > 60 else command
|
|
1496
|
+
print(f" {Colors.DIM}{'':>10} └─ $ {cmdShort}{Colors.RESET}")
|
|
1497
|
+
remText = chk.get("remediation", "")
|
|
1498
|
+
if remText:
|
|
1499
|
+
remShort = remText[:70] + "…" if len(remText) > 70 else remText
|
|
1500
|
+
print(f" {Colors.CYAN}{'':>10} └─ Fix: {remShort}{Colors.RESET}")
|
|
1501
|
+
|
|
1502
|
+
elif dashboard:
|
|
1503
|
+
# HUD Tactical Dashboard mode
|
|
1504
|
+
dashboard.onCheckComplete(category, label, status)
|
|
1505
|
+
|
|
1506
|
+
else:
|
|
1507
|
+
# Compact progress bar fallback (no dashboard, no --show-commands)
|
|
1508
|
+
barWidth = 24
|
|
1509
|
+
filled = int((idx / total) * barWidth)
|
|
1510
|
+
bar = "█" * filled + "░" * (barWidth - filled)
|
|
1511
|
+
sys.stdout.write(
|
|
1512
|
+
f"\r {Colors.BRIGHT_BLUE}[{bar}]{Colors.RESET} "
|
|
1513
|
+
f"{Colors.BRIGHT_WHITE}{idx:3d}/{total}{Colors.RESET} "
|
|
1514
|
+
f"{Colors.DIM}({percentage:4.1f}%){Colors.RESET} "
|
|
1515
|
+
f"{Colors.GREEN}✓{counts['safe']:<4}{Colors.RESET}"
|
|
1516
|
+
f"{Colors.BRIGHT_RED}✗{counts['critical']:<3}{Colors.RESET}"
|
|
1517
|
+
f"{Colors.YELLOW}⚠{counts['warning']:<3}{Colors.RESET}"
|
|
1518
|
+
f"{Colors.BRIGHT_MAGENTA}?{counts['verify']:<3}{Colors.RESET}"
|
|
1519
|
+
f" {Colors.DIM}ETA {etaStr}{Colors.RESET} "
|
|
1520
|
+
)
|
|
1521
|
+
sys.stdout.flush()
|
|
1522
|
+
|
|
1523
|
+
if onProgress:
|
|
1524
|
+
onProgress(idx, total)
|
|
1525
|
+
except Exception:
|
|
1526
|
+
pass
|
|
1527
|
+
|
|
1528
|
+
# Build row
|
|
1529
|
+
displayDesc = desc
|
|
1530
|
+
displayResult = raw
|
|
1531
|
+
if needsVerification:
|
|
1532
|
+
if outputIsNull:
|
|
1533
|
+
displayDesc = desc + " [⚠ Manual verification required - value is NULL]"
|
|
1534
|
+
displayResult = "null (Setting may not exist or is not configured)"
|
|
1535
|
+
else:
|
|
1536
|
+
displayDesc = desc + " [⚠ Manual verification required - empty/unsupported output]"
|
|
1537
|
+
if not raw.strip():
|
|
1538
|
+
displayResult = "(No output - command may not be supported on this device)"
|
|
1539
|
+
|
|
1540
|
+
rows.append({
|
|
1541
|
+
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
1542
|
+
"category": category,
|
|
1543
|
+
"label": label,
|
|
1544
|
+
"level": level,
|
|
1545
|
+
"bucket": bucket,
|
|
1546
|
+
"status": status,
|
|
1547
|
+
"matched": str(matched),
|
|
1548
|
+
"command": command,
|
|
1549
|
+
"result": displayResult,
|
|
1550
|
+
"description": displayDesc,
|
|
1551
|
+
"needs_verification": needsVerification,
|
|
1552
|
+
"remediation": chk.get("remediation", ""),
|
|
1553
|
+
})
|
|
1554
|
+
|
|
1555
|
+
print()
|
|
1556
|
+
return rows, counts
|
|
1557
|
+
|
|
1558
|
+
|
|
1559
|
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
1560
|
+
# Trusted Certificate Policy Audit (No limit version, same style)
|
|
1561
|
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
1562
|
+
|
|
1563
|
+
def _findCertFiles(device: Device) -> List[str]:
|
|
1564
|
+
"""Search standard Android CA directories for certificate files (deduped)."""
|
|
1565
|
+
candidates = [
|
|
1566
|
+
"/system/etc/security/cacerts",
|
|
1567
|
+
"/system/etc/security/cacerts_google",
|
|
1568
|
+
"/apex/com.android.conscrypt/cacerts",
|
|
1569
|
+
"/apex/com.android.conscrypt/etc/security/cacerts",
|
|
1570
|
+
"/vendor/etc/security/cacerts", # seen on some builds
|
|
1571
|
+
"/product/etc/security/cacerts", # seen on some builds
|
|
1572
|
+
|
|
1573
|
+
# User-installed CA stores
|
|
1574
|
+
"/data/misc/user/0/cacerts-added",
|
|
1575
|
+
|
|
1576
|
+
# Legacy stores
|
|
1577
|
+
"/data/misc/keychain/cacerts-added",
|
|
1578
|
+
"/data/misc/keychain",
|
|
1579
|
+
|
|
1580
|
+
# Keystore directories
|
|
1581
|
+
"/data/misc/keystore/user_0",
|
|
1582
|
+
]
|
|
1583
|
+
|
|
1584
|
+
files = []
|
|
1585
|
+
seen = set()
|
|
1586
|
+
for base in candidates:
|
|
1587
|
+
listing = device.shell(f"ls -1 {base} 2>/dev/null")
|
|
1588
|
+
names = [n.strip() for n in (listing.splitlines() if listing else []) if n.strip()]
|
|
1589
|
+
matched = []
|
|
1590
|
+
for n in names:
|
|
1591
|
+
if n.endswith(".0") or re.fullmatch(r"[0-9a-fA-F]{1,8}", n):
|
|
1592
|
+
full = f"{base}/{n}"
|
|
1593
|
+
if full not in seen:
|
|
1594
|
+
seen.add(full)
|
|
1595
|
+
matched.append(full)
|
|
1596
|
+
files.extend(matched)
|
|
1597
|
+
if CERT_DEBUG:
|
|
1598
|
+
print(f"[cert-debug] {base}: {len(matched)} files matched")
|
|
1599
|
+
|
|
1600
|
+
if CERT_DEBUG:
|
|
1601
|
+
print(f"[cert-debug] total unique cert files discovered: {len(files)}")
|
|
1602
|
+
|
|
1603
|
+
return files
|
|
1604
|
+
|
|
1605
|
+
|
|
1606
|
+
def _readCertBytes(device: Device, path: str):
|
|
1607
|
+
"""Read certificate bytes from device, trying PEM first then DER via base64."""
|
|
1608
|
+
# Try PEM first
|
|
1609
|
+
txt = device.shell(f"cat {path} 2>/dev/null")
|
|
1610
|
+
if txt and "-----BEGIN CERTIFICATE-----" in txt:
|
|
1611
|
+
return txt.encode("utf-8"), "PEM"
|
|
1612
|
+
|
|
1613
|
+
# Try DER by base64 from device (several common variants)
|
|
1614
|
+
candidates = [
|
|
1615
|
+
f"base64 {path} 2>/dev/null",
|
|
1616
|
+
f"toybox base64 {path} 2>/dev/null",
|
|
1617
|
+
f"busybox base64 {path} 2>/dev/null",
|
|
1618
|
+
f"dd if='{path}' bs=4096 2>/dev/null | base64 2>/dev/null",
|
|
1619
|
+
f"dd if='{path}' bs=4096 2>/dev/null | toybox base64 2>/dev/null",
|
|
1620
|
+
f"dd if='{path}' bs=4096 2>/dev/null | busybox base64 2>/dev/null",
|
|
1621
|
+
]
|
|
1622
|
+
for cmd in candidates:
|
|
1623
|
+
b64 = device.shell(cmd)
|
|
1624
|
+
if b64 and "not found" not in b64.lower() and b64.strip():
|
|
1625
|
+
try:
|
|
1626
|
+
cleaned = "".join(b64.strip().split())
|
|
1627
|
+
if cleaned:
|
|
1628
|
+
return base64.b64decode(cleaned, validate=False), "DER"
|
|
1629
|
+
except Exception:
|
|
1630
|
+
# try next variant
|
|
1631
|
+
pass
|
|
1632
|
+
|
|
1633
|
+
# Final lightweight hex fallback (if present on device)
|
|
1634
|
+
for cmd in [
|
|
1635
|
+
f"xxd -p {path} 2>/dev/null",
|
|
1636
|
+
f"hexdump -v -e '1/1 \"%02x\"' {path} 2>/dev/null",
|
|
1637
|
+
f"od -An -tx1 -v {path} 2>/dev/null | tr -d ' \\n' 2>/dev/null",
|
|
1638
|
+
]:
|
|
1639
|
+
hx = device.shell(cmd)
|
|
1640
|
+
if hx and re.fullmatch(r"[0-9a-fA-F]+", hx.strip()):
|
|
1641
|
+
try:
|
|
1642
|
+
return bytes.fromhex(hx.strip()), "DER"
|
|
1643
|
+
except Exception:
|
|
1644
|
+
pass
|
|
1645
|
+
|
|
1646
|
+
return None, None
|
|
1647
|
+
|
|
1648
|
+
|
|
1649
|
+
def auditCertificates(device: Device) -> List[Dict[str, Any]]:
|
|
1650
|
+
"""Pull and analyze system + user certificates from the device (no artificial limit)."""
|
|
1651
|
+
certs: List[Dict[str, Any]] = []
|
|
1652
|
+
import warnings
|
|
1653
|
+
from cryptography.utils import CryptographyDeprecationWarning
|
|
1654
|
+
warnings.filterwarnings('ignore', category=CryptographyDeprecationWarning)
|
|
1655
|
+
try:
|
|
1656
|
+
from cryptography import x509
|
|
1657
|
+
from cryptography.hazmat.backends import default_backend
|
|
1658
|
+
except Exception:
|
|
1659
|
+
if CERT_DEBUG:
|
|
1660
|
+
print("[cert-debug] cryptography not available; skipping cert parse")
|
|
1661
|
+
return []
|
|
1662
|
+
|
|
1663
|
+
certFiles = _findCertFiles(device)
|
|
1664
|
+
if CERT_DEBUG:
|
|
1665
|
+
print(f"[cert-debug] discovered total: {len(certFiles)}")
|
|
1666
|
+
today = datetime.now()
|
|
1667
|
+
|
|
1668
|
+
# IMPORTANT: No limit - process every discovered cert file
|
|
1669
|
+
for certPath in certFiles:
|
|
1670
|
+
try:
|
|
1671
|
+
rawBytes, kind = _readCertBytes(device, certPath)
|
|
1672
|
+
if not rawBytes:
|
|
1673
|
+
continue
|
|
1674
|
+
|
|
1675
|
+
cert = None
|
|
1676
|
+
if kind == "PEM":
|
|
1677
|
+
try:
|
|
1678
|
+
cert = x509.load_pem_x509_certificate(rawBytes, default_backend())
|
|
1679
|
+
except Exception:
|
|
1680
|
+
cert = None
|
|
1681
|
+
if cert is None:
|
|
1682
|
+
try:
|
|
1683
|
+
cert = x509.load_der_x509_certificate(rawBytes, default_backend())
|
|
1684
|
+
except Exception:
|
|
1685
|
+
cert = None
|
|
1686
|
+
if cert is None:
|
|
1687
|
+
continue
|
|
1688
|
+
|
|
1689
|
+
subject = getattr(cert, "subject", None)
|
|
1690
|
+
issuer = getattr(cert, "issuer", None)
|
|
1691
|
+
try:
|
|
1692
|
+
notBefore = getattr(cert, 'not_valid_before_utc')
|
|
1693
|
+
notAfter = getattr(cert, 'not_valid_after_utc')
|
|
1694
|
+
except Exception:
|
|
1695
|
+
notBefore = getattr(cert, 'not_valid_before', None)
|
|
1696
|
+
notAfter = getattr(cert, 'not_valid_after', None)
|
|
1697
|
+
if notBefore is None or notAfter is None:
|
|
1698
|
+
continue
|
|
1699
|
+
|
|
1700
|
+
# Normalize tz-awareness to naive
|
|
1701
|
+
nb = notBefore.replace(tzinfo=None)
|
|
1702
|
+
na = notAfter.replace(tzinfo=None)
|
|
1703
|
+
|
|
1704
|
+
try:
|
|
1705
|
+
subjectStr = subject.rfc4514_string() if subject else "Unknown"
|
|
1706
|
+
issuerStr = issuer.rfc4514_string() if issuer else "Unknown"
|
|
1707
|
+
except Exception:
|
|
1708
|
+
subjectStr = "Unknown"
|
|
1709
|
+
issuerStr = "Unknown"
|
|
1710
|
+
|
|
1711
|
+
daysOld = (today - nb).days
|
|
1712
|
+
daysUntilExpiry = (na - today).days
|
|
1713
|
+
|
|
1714
|
+
if daysUntilExpiry < 0:
|
|
1715
|
+
status, risk = "EXPIRED", "critical"
|
|
1716
|
+
elif daysUntilExpiry < 30:
|
|
1717
|
+
status, risk = "EXPIRING_SOON", "warning"
|
|
1718
|
+
elif daysUntilExpiry < 90:
|
|
1719
|
+
status, risk = "CHECK", "warning"
|
|
1720
|
+
else:
|
|
1721
|
+
status, risk = "VALID", "safe"
|
|
1722
|
+
|
|
1723
|
+
cn = "Unknown"
|
|
1724
|
+
for part in subjectStr.split(","):
|
|
1725
|
+
p = part.strip()
|
|
1726
|
+
if p.startswith("CN="):
|
|
1727
|
+
cn = p[3:]
|
|
1728
|
+
break
|
|
1729
|
+
|
|
1730
|
+
certs.append({
|
|
1731
|
+
"filename": certPath.split("/")[-1],
|
|
1732
|
+
"cn": cn[:50] + "..." if len(cn) > 50 else cn,
|
|
1733
|
+
"issuer": issuerStr[:50] + "..." if len(issuerStr) > 50 else issuerStr,
|
|
1734
|
+
"not_before": nb.strftime("%Y-%m-%d"),
|
|
1735
|
+
"not_after": na.strftime("%Y-%m-%d"),
|
|
1736
|
+
"days_old": daysOld,
|
|
1737
|
+
"days_until_expiry": daysUntilExpiry,
|
|
1738
|
+
"status": status,
|
|
1739
|
+
"risk": risk,
|
|
1740
|
+
})
|
|
1741
|
+
except Exception:
|
|
1742
|
+
# keep going on any single-file error
|
|
1743
|
+
continue
|
|
1744
|
+
|
|
1745
|
+
# User-installed certs across all profiles (filenames only; requires root to list others)
|
|
1746
|
+
try:
|
|
1747
|
+
userRoots = device.shell("ls -d /data/misc/user/*/cacerts-added 2>/dev/null")
|
|
1748
|
+
userDirs = [d.strip() for d in (userRoots.split("\n") if userRoots else []) if d.strip()]
|
|
1749
|
+
if not userDirs:
|
|
1750
|
+
userDirs = ["/data/misc/user/0/cacerts-added"]
|
|
1751
|
+
for d in userDirs:
|
|
1752
|
+
ulist = device.shell(f"ls -1 {d} 2>/dev/null")
|
|
1753
|
+
if ulist and ulist.strip():
|
|
1754
|
+
for cf in [x.strip() for x in ulist.split("\n") if x.strip()]:
|
|
1755
|
+
certs.append({
|
|
1756
|
+
"filename": cf,
|
|
1757
|
+
"cn": "USER INSTALLED CERT",
|
|
1758
|
+
"issuer": "Unknown - User Added",
|
|
1759
|
+
"not_before": "-",
|
|
1760
|
+
"not_after": "-",
|
|
1761
|
+
"days_old": 0,
|
|
1762
|
+
"days_until_expiry": 0,
|
|
1763
|
+
"status": "USER_CERT",
|
|
1764
|
+
"risk": "critical",
|
|
1765
|
+
})
|
|
1766
|
+
except Exception:
|
|
1767
|
+
pass
|
|
1768
|
+
|
|
1769
|
+
# Sort: critical first, then warning, then by days until expiry
|
|
1770
|
+
return sorted(certs, key=lambda x: (x["risk"] != "critical", x["risk"] != "warning", x["days_until_expiry"]))
|
|
1771
|
+
|
|
1772
|
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
1773
|
+
# REPORT GENERATION - TXT
|
|
1774
|
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
1775
|
+
|
|
1776
|
+
def writeTxtReport(path: str, deviceInfo: Dict[str, str],
|
|
1777
|
+
rows: List[Dict[str, Any]], counts: Dict[str, int],
|
|
1778
|
+
certs: List[Dict[str, Any]], deviceIdStr: str) -> None:
|
|
1779
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
1780
|
+
f.write(f"HARDAX - Hardening Audit eXaminer Report\nGenerated: {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n")
|
|
1781
|
+
f.write("Device Information\n" + "=" * 40 + "\n")
|
|
1782
|
+
for k in ["model", "brand", "manufacturer", "name", "soc_manufacturer", "soc_model",
|
|
1783
|
+
"android_version", "sdk_level", "build_id", "fingerprint", "serialno", "timezone"]:
|
|
1784
|
+
f.write(f"{k.replace('_', ' ').title()}: {deviceInfo.get(k, '')}\n")
|
|
1785
|
+
|
|
1786
|
+
if certs:
|
|
1787
|
+
f.write("\n" + "=" * 40 + "\nTrusted Certificate Policy Audit\n" + "=" * 40 + "\n")
|
|
1788
|
+
f.write(f"{'CN':<40} {'Valid From':<12} {'Valid Until':<12} {'Days Old':>10} {'Expiry':>10} {'Status':<15}\n")
|
|
1789
|
+
f.write("-" * 100 + "\n")
|
|
1790
|
+
for c in certs:
|
|
1791
|
+
daysOld = str(c["days_old"]) if isinstance(c["days_old"], int) else "-"
|
|
1792
|
+
daysExp = str(c["days_until_expiry"]) if isinstance(c["days_until_expiry"], int) else "-"
|
|
1793
|
+
f.write(f"{c['cn']:<40} {c['not_before']:<12} {c['not_after']:<12} {daysOld:>10} {daysExp:>10} {c['status']:<15}\n")
|
|
1794
|
+
|
|
1795
|
+
f.write("\n" + "=" * 40 + "\nFindings\n" + "=" * 40 + "\n")
|
|
1796
|
+
for r in rows:
|
|
1797
|
+
f.write(f"\n[{r['category']}] {r['label']}\n")
|
|
1798
|
+
f.write(f"Command: {r['command']}\n")
|
|
1799
|
+
f.write(f"Description: {r['description']}\n")
|
|
1800
|
+
f.write(f"Result: {r['result'][:500]}{'...' if len(r['result']) > 500 else ''}\n")
|
|
1801
|
+
f.write(f"Status: {r['status']}\n")
|
|
1802
|
+
if r.get("remediation") and r["status"] not in ("SAFE", "INFO"):
|
|
1803
|
+
f.write(f"Remediation: {r['remediation']}\n")
|
|
1804
|
+
f.write("-" * 40 + "\n")
|
|
1805
|
+
|
|
1806
|
+
f.write("\n" + "=" * 40 + "\n")
|
|
1807
|
+
f.write("AUDIT SUMMARY\n")
|
|
1808
|
+
f.write(f"Target: {deviceIdStr}\n")
|
|
1809
|
+
f.write(f"Safe: {counts['safe']} | Warnings: {counts['warning']} | Critical: {counts['critical']} | Info: {counts['info']} | Skipped: {counts.get('skipped', 0)}\n")
|
|
1810
|
+
if certs:
|
|
1811
|
+
expired = sum(1 for c in certs if c["status"] == "EXPIRED")
|
|
1812
|
+
userCerts = sum(1 for c in certs if c["status"] == "USER_CERT")
|
|
1813
|
+
f.write(f"Certificates: {len(certs)} total | {expired} expired | {userCerts} user-installed\n")
|
|
1814
|
+
f.write("=" * 40 + "\n")
|
|
1815
|
+
|
|
1816
|
+
|
|
1817
|
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
1818
|
+
# REPORT GENERATION - CSV
|
|
1819
|
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
1820
|
+
|
|
1821
|
+
def writeCsvReport(path: str, rows: List[Dict[str, Any]]) -> None:
|
|
1822
|
+
fieldnames = ["timestamp", "category", "label", "level", "bucket", "status",
|
|
1823
|
+
"matched", "command", "result", "description", "remediation"]
|
|
1824
|
+
with open(path, "w", newline="", encoding="utf-8") as f:
|
|
1825
|
+
w = csv.DictWriter(f, fieldnames=fieldnames)
|
|
1826
|
+
w.writeheader()
|
|
1827
|
+
for r in rows:
|
|
1828
|
+
w.writerow({k: r.get(k, "") for k in fieldnames})
|
|
1829
|
+
|
|
1830
|
+
|
|
1831
|
+
def writeJsonReport(path: str, deviceInfo: Dict[str, str],
|
|
1832
|
+
rows: List[Dict[str, Any]], counts: Dict[str, int],
|
|
1833
|
+
certs: Optional[List[Dict[str, Any]]],
|
|
1834
|
+
target: str) -> None:
|
|
1835
|
+
"""Write a machine-readable JSON report for CI / tooling consumption."""
|
|
1836
|
+
payload = {
|
|
1837
|
+
"version": __version__,
|
|
1838
|
+
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
1839
|
+
"target": target,
|
|
1840
|
+
"device": deviceInfo or {},
|
|
1841
|
+
"counts": counts,
|
|
1842
|
+
"checks": rows,
|
|
1843
|
+
"certificates": certs or [],
|
|
1844
|
+
}
|
|
1845
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
1846
|
+
json.dump(payload, f, indent=2, default=str)
|
|
1847
|
+
|
|
1848
|
+
|
|
1849
|
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
1850
|
+
# REPORT GENERATION - HTML (Hacker Aesthetic)
|
|
1851
|
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
1852
|
+
|
|
1853
|
+
def writeHtmlReport(htmlPath: str, deviceInfo: Dict[str, str],
|
|
1854
|
+
rows: List[Dict[str, Any]], counts: Dict[str, int],
|
|
1855
|
+
certs: Optional[List[Dict[str, Any]]] = None) -> None:
|
|
1856
|
+
"""Generate an interactive HTML report with hacker aesthetic and severity toggles."""
|
|
1857
|
+
|
|
1858
|
+
# Certificate section
|
|
1859
|
+
certRowsHtml = ""
|
|
1860
|
+
expiredCount = expiringCount = userCount = validCount = 0
|
|
1861
|
+
|
|
1862
|
+
if certs:
|
|
1863
|
+
certParts = []
|
|
1864
|
+
for c in certs:
|
|
1865
|
+
riskClass = c["risk"]
|
|
1866
|
+
statusEmoji = {"EXPIRED": "🔴", "EXPIRING_SOON": "🟡", "CHECK": "🟡",
|
|
1867
|
+
"USER_CERT": "⚠️", "VALID": "🟢"}.get(c["status"], "⚪")
|
|
1868
|
+
daysInfo = f"{c['days_old']:,}" if isinstance(c["days_old"], int) else "-"
|
|
1869
|
+
expiryInfo = f"{c['days_until_expiry']:,}" if isinstance(c["days_until_expiry"], int) else "-"
|
|
1870
|
+
certParts.append(
|
|
1871
|
+
f'<tr class="cert-row {riskClass}" data-status="{riskClass.upper()}" data-search="{htmlEscape(c["cn"].lower())} {htmlEscape(c["issuer"].lower())}">'
|
|
1872
|
+
f'<td>{htmlEscape(c["cn"])}</td>'
|
|
1873
|
+
f'<td>{htmlEscape(c["not_before"])}</td>'
|
|
1874
|
+
f'<td>{htmlEscape(c["not_after"])}</td>'
|
|
1875
|
+
f'<td class="mono-right">{daysInfo}</td>'
|
|
1876
|
+
f'<td class="mono-right">{expiryInfo}</td>'
|
|
1877
|
+
f'<td><span class="cert-status {riskClass}">{statusEmoji} {c["status"]}</span></td>'
|
|
1878
|
+
f"</tr>"
|
|
1879
|
+
)
|
|
1880
|
+
certRowsHtml = "\n".join(certParts)
|
|
1881
|
+
expiredCount = sum(1 for c in certs if c["status"] == "EXPIRED")
|
|
1882
|
+
expiringCount = sum(1 for c in certs if c["status"] in ("EXPIRING_SOON", "CHECK"))
|
|
1883
|
+
userCount = sum(1 for c in certs if c["status"] == "USER_CERT")
|
|
1884
|
+
validCount = sum(1 for c in certs if c["status"] == "VALID")
|
|
1885
|
+
|
|
1886
|
+
certTableHtml = (
|
|
1887
|
+
f'<div class="category-section cert-section" id="cert_section">'
|
|
1888
|
+
f' <div class="cat-header" onclick="toggleCat(\'cert_section\')">'
|
|
1889
|
+
f' <div class="cat-title">'
|
|
1890
|
+
f' <span class="toggle-arrow">▶</span>'
|
|
1891
|
+
f' <span class="cat-name"> Trusted Certificate Policy Audit</span>'
|
|
1892
|
+
f' <span class="cat-count">({len(certs) if certs else 0} certificates)</span>'
|
|
1893
|
+
f' </div>'
|
|
1894
|
+
f' <div class="cat-badges">'
|
|
1895
|
+
f' <span class="badge critical">{expiredCount} Expired</span>'
|
|
1896
|
+
f' <span class="badge warning">{expiringCount} Expiring</span>'
|
|
1897
|
+
f' <span class="badge critical">{userCount} User Installed</span>'
|
|
1898
|
+
f' <span class="badge safe">{validCount} Valid</span>'
|
|
1899
|
+
f' </div>'
|
|
1900
|
+
f' </div>'
|
|
1901
|
+
f' <div class="cat-body">'
|
|
1902
|
+
f' <div class="cert-table-wrap">'
|
|
1903
|
+
f' <table class="cert-table">'
|
|
1904
|
+
f' <thead><tr>'
|
|
1905
|
+
f' <th>Common Name (CN)</th><th>Valid From</th><th>Valid Until</th>'
|
|
1906
|
+
f' <th>Days Old</th><th>Days to Expiry</th><th>Status</th>'
|
|
1907
|
+
f' </tr></thead>'
|
|
1908
|
+
f' <tbody>'
|
|
1909
|
+
f' {certRowsHtml if certs else "<tr><td colspan=6 class=empty-note>No certificates parsed.</td></tr>"}'
|
|
1910
|
+
f' </tbody>'
|
|
1911
|
+
f' </table>'
|
|
1912
|
+
f' </div>'
|
|
1913
|
+
f' </div>'
|
|
1914
|
+
f'</div>'
|
|
1915
|
+
)
|
|
1916
|
+
|
|
1917
|
+
# Group rows by category
|
|
1918
|
+
categories = {}
|
|
1919
|
+
for r in rows:
|
|
1920
|
+
cat = r["category"]
|
|
1921
|
+
if cat not in categories:
|
|
1922
|
+
categories[cat] = {"rows": [], "stats": {"CRITICAL": 0, "WARNING": 0, "VERIFY": 0, "SAFE": 0, "INFO": 0, "SKIPPED": 0}}
|
|
1923
|
+
categories[cat]["rows"].append(r)
|
|
1924
|
+
st = r["status"]
|
|
1925
|
+
if st in categories[cat]["stats"]:
|
|
1926
|
+
categories[cat]["stats"][st] += 1
|
|
1927
|
+
else:
|
|
1928
|
+
categories[cat]["stats"]["INFO"] += 1
|
|
1929
|
+
|
|
1930
|
+
# Build category sections
|
|
1931
|
+
categorySections = []
|
|
1932
|
+
for catIdx, (catName, catData) in enumerate(sorted(categories.items())):
|
|
1933
|
+
stats = catData["stats"]
|
|
1934
|
+
catRows = catData["rows"]
|
|
1935
|
+
|
|
1936
|
+
badges = []
|
|
1937
|
+
for key, cls in [("CRITICAL", "critical"), ("WARNING", "warning"), ("VERIFY", "verify"),
|
|
1938
|
+
("SAFE", "safe"), ("INFO", "info"), ("SKIPPED", "skipped")]:
|
|
1939
|
+
if stats[key] > 0:
|
|
1940
|
+
badges.append(f'<span class="badge {cls}">{stats[key]} {key.title()}</span>')
|
|
1941
|
+
badgesHtml = " ".join(badges)
|
|
1942
|
+
|
|
1943
|
+
itemsHtml = []
|
|
1944
|
+
for r in catRows:
|
|
1945
|
+
cmdEsc = htmlEscape(r["command"])
|
|
1946
|
+
resEsc = htmlEscape(r["result"])
|
|
1947
|
+
descEsc = htmlEscape(r["description"])
|
|
1948
|
+
labelEsc = htmlEscape(r["label"])
|
|
1949
|
+
st = r["status"]
|
|
1950
|
+
cssClass = {"SAFE": "safe", "WARNING": "warning", "CRITICAL": "critical",
|
|
1951
|
+
"VERIFY": "verify", "SKIPPED": "skipped"}.get(st, "info")
|
|
1952
|
+
|
|
1953
|
+
remediationHtml = ""
|
|
1954
|
+
remText = r.get("remediation", "")
|
|
1955
|
+
if remText and st not in ("SAFE", "INFO"):
|
|
1956
|
+
remediationHtml = (
|
|
1957
|
+
f'\n <div class="detail-group remediation-group">'
|
|
1958
|
+
f'\n <span class="detail-tag remediation-tag">Remediation</span>'
|
|
1959
|
+
f'\n <p class="remediation-text">{htmlEscape(remText)}</p>'
|
|
1960
|
+
f'\n </div>'
|
|
1961
|
+
)
|
|
1962
|
+
|
|
1963
|
+
itemsHtml.append(f'''
|
|
1964
|
+
<div class="check-item {cssClass}" data-status="{st}" data-search="{htmlEscape(r['label'].lower())} {htmlEscape(r['description'].lower())}">
|
|
1965
|
+
<div class="check-head">
|
|
1966
|
+
<span class="check-label">{labelEsc}</span>
|
|
1967
|
+
<span class="status-pill {cssClass}">{st}</span>
|
|
1968
|
+
</div>
|
|
1969
|
+
<p class="check-desc">{descEsc}</p>
|
|
1970
|
+
<div class="check-detail">
|
|
1971
|
+
<div class="detail-group">
|
|
1972
|
+
<span class="detail-tag">Command</span>
|
|
1973
|
+
<pre><code>{cmdEsc}</code></pre>
|
|
1974
|
+
</div>
|
|
1975
|
+
<div class="detail-group">
|
|
1976
|
+
<span class="detail-tag">Output</span>
|
|
1977
|
+
<pre><code>{resEsc if resEsc else "(empty)"}</code></pre>
|
|
1978
|
+
</div>{remediationHtml}
|
|
1979
|
+
</div>
|
|
1980
|
+
</div>''')
|
|
1981
|
+
|
|
1982
|
+
itemsJoined = "\n".join(itemsHtml)
|
|
1983
|
+
categorySections.append(f'''
|
|
1984
|
+
<div class="category-section" id="cat_{catIdx}">
|
|
1985
|
+
<div class="cat-header" onclick="toggleCat('cat_{catIdx}')">
|
|
1986
|
+
<div class="cat-title">
|
|
1987
|
+
<span class="toggle-arrow">▶</span>
|
|
1988
|
+
<span class="cat-name">{htmlEscape(catName)}</span>
|
|
1989
|
+
<span class="cat-count">({len(catRows)} checks)</span>
|
|
1990
|
+
</div>
|
|
1991
|
+
<div class="cat-badges">{badgesHtml}</div>
|
|
1992
|
+
</div>
|
|
1993
|
+
<div class="cat-body">{itemsJoined}</div>
|
|
1994
|
+
</div>''')
|
|
1995
|
+
|
|
1996
|
+
categoriesHtml = "\n".join(categorySections)
|
|
1997
|
+
|
|
1998
|
+
# Device info
|
|
1999
|
+
deviceItems = []
|
|
2000
|
+
for key, label in [("model", "Model"), ("brand", "Brand"), ("manufacturer", "Manufacturer"),
|
|
2001
|
+
("android_version", "Android"), ("sdk_level", "SDK"), ("build_id", "Build"),
|
|
2002
|
+
("serialno", "Serial"), ("soc_model", "SoC")]:
|
|
2003
|
+
val = deviceInfo.get(key, "")
|
|
2004
|
+
if val and val != "(unknown)":
|
|
2005
|
+
deviceItems.append(f'<div class="dev-item"><span class="dev-label">{label}</span><span class="dev-value">{htmlEscape(val)}</span></div>')
|
|
2006
|
+
deviceHtml = "\n".join(deviceItems)
|
|
2007
|
+
|
|
2008
|
+
totalChecks = len(rows)
|
|
2009
|
+
|
|
2010
|
+
# ━━━ FULL HTML DOCUMENT ━━━
|
|
2011
|
+
_tmplPath = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates", "report.html")
|
|
2012
|
+
with open(_tmplPath, "r", encoding="utf-8") as _f:
|
|
2013
|
+
_tmpl = Template(_f.read())
|
|
2014
|
+
|
|
2015
|
+
doc = _tmpl.safe_substitute(
|
|
2016
|
+
VERSION=__version__,
|
|
2017
|
+
COUNT_CRITICAL=counts.get("critical", 0),
|
|
2018
|
+
COUNT_WARNING=counts.get("warning", 0),
|
|
2019
|
+
COUNT_VERIFY=counts.get("verify", 0),
|
|
2020
|
+
COUNT_SAFE=counts.get("safe", 0),
|
|
2021
|
+
COUNT_INFO=counts.get("info", 0),
|
|
2022
|
+
COUNT_SKIPPED=counts.get("skipped", 0),
|
|
2023
|
+
TOTAL_CHECKS=totalChecks,
|
|
2024
|
+
DEVICE_HTML=deviceHtml,
|
|
2025
|
+
CERT_TABLE_HTML=certTableHtml,
|
|
2026
|
+
CATEGORIES_HTML=categoriesHtml,
|
|
2027
|
+
TIMESTAMP=time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
2028
|
+
)
|
|
2029
|
+
|
|
2030
|
+
with open(htmlPath, "w", encoding="utf-8") as f:
|
|
2031
|
+
f.write(doc)
|
|
2032
|
+
|
|
2033
|
+
|
|
2034
|
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
2035
|
+
# CLI BANNER
|
|
2036
|
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
2037
|
+
|
|
2038
|
+
def printBanner(idLine: Optional[str], checkCount: int = 0, categoryCount: int = 0) -> None:
|
|
2039
|
+
"""Print the ASCII art banner with terminal colours."""
|
|
2040
|
+
checksStr = f"[{checkCount} Checks]" if checkCount else "[--- Checks]"
|
|
2041
|
+
catsStr = f"[{categoryCount} Categories]" if categoryCount else "[--- Categories]"
|
|
2042
|
+
print(f"""
|
|
2043
|
+
{Colors.BRIGHT_CYAN}┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
|
2044
|
+
┃ {Colors.BRIGHT_WHITE}██ ██ █████ ██████ ██████ █████ ██ ██{Colors.BRIGHT_CYAN} ┃
|
|
2045
|
+
┃ {Colors.BRIGHT_WHITE}██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██{Colors.BRIGHT_CYAN} ┃
|
|
2046
|
+
┃ {Colors.BRIGHT_WHITE}███████ ███████ ██████ ██ ██ ███████ ███{Colors.BRIGHT_CYAN} ┃
|
|
2047
|
+
┃ {Colors.BRIGHT_WHITE}██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██{Colors.BRIGHT_CYAN} ┃
|
|
2048
|
+
┃ {Colors.BRIGHT_WHITE}██ ██ ██ ██ ██ ██ ██████ ██ ██ ██ ██{Colors.BRIGHT_CYAN} ┃
|
|
2049
|
+
┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
|
2050
|
+
┃ {Colors.BOLD}Hardening Audit eXaminer{Colors.RESET}{Colors.BRIGHT_CYAN} v{__version__} ┃
|
|
2051
|
+
┃ {Colors.DIM}Android OS based Connected Devices Security Configuration Auditor{Colors.BRIGHT_CYAN}┃
|
|
2052
|
+
┃ {Colors.YELLOW}{checksStr}{Colors.RESET} {Colors.GREEN}{catsStr}{Colors.BRIGHT_CYAN}{' ' * max(1, 53 - _vlen(checksStr) - _vlen(catsStr))} ┃
|
|
2053
|
+
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛{Colors.RESET}
|
|
2054
|
+
""")
|
|
2055
|
+
if idLine:
|
|
2056
|
+
print(f"{Colors.BRIGHT_WHITE}📱 Target Device: {Colors.BOLD}{Colors.BRIGHT_CYAN}{idLine}{Colors.RESET}\n")
|
|
2057
|
+
|
|
2058
|
+
|
|
2059
|
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
2060
|
+
# MAIN ENTRY POINT
|
|
2061
|
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
2062
|
+
|
|
2063
|
+
def main():
|
|
2064
|
+
ap = argparse.ArgumentParser(
|
|
2065
|
+
description="HARDAX - Hardening Audit eXaminer for Android OS based Connected Devices",
|
|
2066
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
2067
|
+
epilog="""
|
|
2068
|
+
Examples:
|
|
2069
|
+
%(prog)s --json-dir ./commands
|
|
2070
|
+
%(prog)s --json-dir ./commands --serial DEVICE123
|
|
2071
|
+
%(prog)s --mode ssh --host 192.168.1.100 --ssh-user root --ssh-pass password
|
|
2072
|
+
%(prog)s --mode uart --uart-port /dev/ttyUSB0
|
|
2073
|
+
%(prog)s --mode uart --uart-port /dev/ttyUSB0 --baud 115200
|
|
2074
|
+
""",
|
|
2075
|
+
)
|
|
2076
|
+
ap.add_argument("--version", action="version", version=f"HARDAX v{__version__}")
|
|
2077
|
+
ap.add_argument("--mode", choices=["adb", "ssh", "uart"], default="adb", help="Connection mode (default: adb)")
|
|
2078
|
+
ap.add_argument("--json", help="Path to a single commands JSON file")
|
|
2079
|
+
ap.add_argument("--json-dir", help="Folder containing *.json check files to merge")
|
|
2080
|
+
ap.add_argument("--serial", default=os.environ.get("ANDROID_SERIAL", ""), help="ADB device serial")
|
|
2081
|
+
ap.add_argument("--host", help="SSH hostname/IP")
|
|
2082
|
+
ap.add_argument("--port", type=int, default=22, help="SSH port (or overridden by UART)")
|
|
2083
|
+
ap.add_argument("--ssh-user", help="SSH username")
|
|
2084
|
+
ap.add_argument("--ssh-pass", help="SSH password")
|
|
2085
|
+
ap.add_argument("--uart-port", help="UART serial port (e.g. /dev/ttyUSB0, /dev/ttyS0, COM3)")
|
|
2086
|
+
ap.add_argument("--baud", type=int, default=0, help="UART baud rate (0 = auto-detect, common: 115200, 9600)")
|
|
2087
|
+
ap.add_argument("--out", default="hardax_output", help="Output directory")
|
|
2088
|
+
ap.add_argument("--progress-numbers", action="store_true", help="Show numeric progress counter")
|
|
2089
|
+
ap.add_argument("--show-commands", action="store_true", help="Print each command as it runs")
|
|
2090
|
+
ap.add_argument("--skip-certs", action="store_true", help="Skip certificate audit")
|
|
2091
|
+
ap.add_argument("--category", default="",
|
|
2092
|
+
help="Comma-separated category names to include (case-insensitive, e.g. SYSTEM,NETWORK)")
|
|
2093
|
+
ap.add_argument("--severity", default="",
|
|
2094
|
+
help="Comma-separated levels to include: info,warning,critical")
|
|
2095
|
+
ap.add_argument("--json-out", action="store_true",
|
|
2096
|
+
help="Also write a machine-readable JSON report alongside TXT/CSV/HTML")
|
|
2097
|
+
ap.add_argument("--exit-code", action="store_true",
|
|
2098
|
+
help="Set process exit code by findings: 0=clean, 1=warning, 2=critical")
|
|
2099
|
+
|
|
2100
|
+
args = ap.parse_args()
|
|
2101
|
+
|
|
2102
|
+
# Auto-detect commands/ directory
|
|
2103
|
+
if not args.json and not args.json_dir:
|
|
2104
|
+
scriptDir = os.path.dirname(os.path.abspath(__file__))
|
|
2105
|
+
defaultCmdDir = os.path.join(scriptDir, "commands")
|
|
2106
|
+
if os.path.isdir(defaultCmdDir):
|
|
2107
|
+
args.json_dir = defaultCmdDir
|
|
2108
|
+
|
|
2109
|
+
# Load checks
|
|
2110
|
+
checks = loadChecks(args.json, args.json_dir)
|
|
2111
|
+
|
|
2112
|
+
# Optional filters: --category and --severity
|
|
2113
|
+
if args.category:
|
|
2114
|
+
wanted = {c.strip().lower() for c in args.category.split(",") if c.strip()}
|
|
2115
|
+
before = len(checks)
|
|
2116
|
+
checks = [c for c in checks if c.get("category", "").lower() in wanted]
|
|
2117
|
+
print(f"{Colors.BRIGHT_CYAN}⟳ Category filter: {before} → {len(checks)} checks{Colors.RESET}")
|
|
2118
|
+
if args.severity:
|
|
2119
|
+
wanted = {s.strip().lower() for s in args.severity.split(",") if s.strip()}
|
|
2120
|
+
before = len(checks)
|
|
2121
|
+
checks = [c for c in checks if c.get("level", "").lower() in wanted]
|
|
2122
|
+
print(f"{Colors.BRIGHT_CYAN}⟳ Severity filter: {before} → {len(checks)} checks{Colors.RESET}")
|
|
2123
|
+
if not checks:
|
|
2124
|
+
print("ERROR: No checks remain after filtering.", file=sys.stderr)
|
|
2125
|
+
sys.exit(1)
|
|
2126
|
+
|
|
2127
|
+
# Build device connection
|
|
2128
|
+
device: Device
|
|
2129
|
+
if args.mode == "adb":
|
|
2130
|
+
if which("adb") is None:
|
|
2131
|
+
print("ERROR: 'adb' not found in PATH.", file=sys.stderr)
|
|
2132
|
+
sys.exit(1)
|
|
2133
|
+
runLocal(["adb", "start-server"])
|
|
2134
|
+
|
|
2135
|
+
serial = (args.serial or "").strip() or None
|
|
2136
|
+
serial = pickDefaultSerial(serial)
|
|
2137
|
+
if not serial:
|
|
2138
|
+
explainAdbDevicesAndExit(exitCode=2)
|
|
2139
|
+
|
|
2140
|
+
adbDev = AdbDevice(serial)
|
|
2141
|
+
try:
|
|
2142
|
+
adbDev.checkConnected()
|
|
2143
|
+
except RuntimeError as e:
|
|
2144
|
+
print(str(e), file=sys.stderr)
|
|
2145
|
+
explainAdbDevicesAndExit(exitCode=3)
|
|
2146
|
+
device = adbDev
|
|
2147
|
+
|
|
2148
|
+
elif args.mode == "ssh":
|
|
2149
|
+
missing = []
|
|
2150
|
+
if not args.host:
|
|
2151
|
+
missing.append("--host")
|
|
2152
|
+
if not args.ssh_user:
|
|
2153
|
+
missing.append("--ssh-user")
|
|
2154
|
+
if missing:
|
|
2155
|
+
print("ERROR: For --mode ssh you must provide: " + ", ".join(missing), file=sys.stderr)
|
|
2156
|
+
sys.exit(1)
|
|
2157
|
+
ssh_pass = (
|
|
2158
|
+
args.ssh_pass
|
|
2159
|
+
or os.environ.get("HARDAX_SSH_PASS")
|
|
2160
|
+
or getpass.getpass(f"SSH password for {args.ssh_user}@{args.host}: ")
|
|
2161
|
+
)
|
|
2162
|
+
device = SshDevice(args.host, args.port, args.ssh_user, ssh_pass)
|
|
2163
|
+
|
|
2164
|
+
else: # uart
|
|
2165
|
+
if not args.uart_port:
|
|
2166
|
+
print("ERROR: For --mode uart you must provide --uart-port (e.g. /dev/ttyUSB0)", file=sys.stderr)
|
|
2167
|
+
sys.exit(1)
|
|
2168
|
+
device = UartDevice(args.uart_port, args.baud)
|
|
2169
|
+
|
|
2170
|
+
# Banner (dynamic counts)
|
|
2171
|
+
_catSet = {c.get("category", "General") for c in checks}
|
|
2172
|
+
printBanner(device.idString(), checkCount=len(checks), categoryCount=len(_catSet))
|
|
2173
|
+
|
|
2174
|
+
# Progress callback
|
|
2175
|
+
def _progress(idx: int, total: int):
|
|
2176
|
+
if args.progress_numbers:
|
|
2177
|
+
sys.stdout.write("\r" + f"{idx}/{total}")
|
|
2178
|
+
sys.stdout.flush()
|
|
2179
|
+
|
|
2180
|
+
# Output paths
|
|
2181
|
+
timestamp = time.strftime("%Y%m%d_%H%M%S")
|
|
2182
|
+
txtDir = os.path.join(args.out, f"txt_report_{timestamp}")
|
|
2183
|
+
htmlDir = os.path.join(args.out, f"html_report_{timestamp}")
|
|
2184
|
+
os.makedirs(txtDir, exist_ok=True)
|
|
2185
|
+
os.makedirs(htmlDir, exist_ok=True)
|
|
2186
|
+
txtFile = os.path.join(txtDir, "audit_report.txt")
|
|
2187
|
+
htmlFile = os.path.join(htmlDir, "audit_report.html")
|
|
2188
|
+
csvFile = os.path.join(htmlDir, "audit_report.csv")
|
|
2189
|
+
jsonFile = os.path.join(htmlDir, "audit_report.json") if args.json_out else None
|
|
2190
|
+
|
|
2191
|
+
# Root detection
|
|
2192
|
+
print(f"\n{Colors.BRIGHT_CYAN}🔍 Starting security audit with {len(checks)} checks...{Colors.RESET}\n")
|
|
2193
|
+
|
|
2194
|
+
isRooted, rootMethod = detectRootStatus(device)
|
|
2195
|
+
if isRooted:
|
|
2196
|
+
if rootMethod in ("ssh-root", "uart-root"):
|
|
2197
|
+
print(f"{Colors.GREEN}✓ Root detected ({rootMethod}) - running as root directly, no su needed{Colors.RESET}")
|
|
2198
|
+
else:
|
|
2199
|
+
print(f"{Colors.GREEN}✓ Root detected ({rootMethod}) - will use su for privileged commands{Colors.RESET}")
|
|
2200
|
+
else:
|
|
2201
|
+
print(f"{Colors.YELLOW}⚠ Device not rooted - some checks may have limited output{Colors.RESET}")
|
|
2202
|
+
print()
|
|
2203
|
+
|
|
2204
|
+
# Device info
|
|
2205
|
+
deviceInfo = collectDeviceInfo(device)
|
|
2206
|
+
|
|
2207
|
+
# ADB pre-flight
|
|
2208
|
+
if isinstance(device, AdbDevice):
|
|
2209
|
+
preflight = device.shell("echo HARDAX_PREFLIGHT_OK")
|
|
2210
|
+
if "HARDAX_PREFLIGHT_OK" not in preflight:
|
|
2211
|
+
print(f"{Colors.BRIGHT_RED}✗ ADB pre-flight check failed!{Colors.RESET}")
|
|
2212
|
+
print(f" Response: {preflight}")
|
|
2213
|
+
print(f" {Colors.YELLOW}Attempting reconnect...{Colors.RESET}")
|
|
2214
|
+
runLocal(device.adbArgs() + ["reconnect"])
|
|
2215
|
+
time.sleep(3)
|
|
2216
|
+
runLocal(device.adbArgs() + ["wait-for-device"], timeout=15)
|
|
2217
|
+
time.sleep(2)
|
|
2218
|
+
preflight2 = device.shell("echo HARDAX_PREFLIGHT_OK")
|
|
2219
|
+
if "HARDAX_PREFLIGHT_OK" not in preflight2:
|
|
2220
|
+
print(f" {Colors.BRIGHT_RED}✗ Device still not responding. Please check:{Colors.RESET}")
|
|
2221
|
+
print(f" • USB cable connected and device unlocked")
|
|
2222
|
+
print(f" • Run: adb kill-server && adb start-server")
|
|
2223
|
+
print(f" • Accept USB debugging prompt on device")
|
|
2224
|
+
sys.exit(1)
|
|
2225
|
+
print(f" {Colors.GREEN}✓ Reconnected successfully!{Colors.RESET}")
|
|
2226
|
+
else:
|
|
2227
|
+
print(f"{Colors.GREEN}✓ ADB connection verified{Colors.RESET}")
|
|
2228
|
+
print()
|
|
2229
|
+
|
|
2230
|
+
# SSH pre-flight
|
|
2231
|
+
elif isinstance(device, SshDevice):
|
|
2232
|
+
preflight = device.shell("echo HARDAX_PREFLIGHT_OK")
|
|
2233
|
+
if "HARDAX_PREFLIGHT_OK" not in preflight:
|
|
2234
|
+
print(f"{Colors.BRIGHT_RED}✗ SSH pre-flight check failed!{Colors.RESET}")
|
|
2235
|
+
print(f" Response: {preflight!r}")
|
|
2236
|
+
print(f" Check that the SSH server on {device.host}:{device.port} allows command execution.")
|
|
2237
|
+
sys.exit(1)
|
|
2238
|
+
print(f"{Colors.GREEN}✓ SSH connection verified ({device.host}:{device.port}){Colors.RESET}")
|
|
2239
|
+
|
|
2240
|
+
# Probe shell environment so the user knows what will work
|
|
2241
|
+
shellPath = device.shell("which sh 2>/dev/null || command -v sh 2>/dev/null").strip()
|
|
2242
|
+
bashPath = device.shell("which bash 2>/dev/null || command -v bash 2>/dev/null").strip()
|
|
2243
|
+
idOut = device.shell("id 2>/dev/null").strip()
|
|
2244
|
+
unameOut = device.shell("uname -a 2>/dev/null").strip()
|
|
2245
|
+
isAndroid = bool(device.shell("test -f /system/build.prop && echo YES 2>/dev/null").strip() == "YES")
|
|
2246
|
+
hasGetprop = bool(device.shell("command -v getprop >/dev/null 2>&1 && echo YES").strip() == "YES")
|
|
2247
|
+
hasBusybox = bool(device.shell("command -v busybox >/dev/null 2>&1 && echo YES").strip() == "YES")
|
|
2248
|
+
hasToybox = bool(device.shell("command -v toybox >/dev/null 2>&1 && echo YES").strip() == "YES")
|
|
2249
|
+
pathOut = device.shell("echo $PATH").strip()
|
|
2250
|
+
|
|
2251
|
+
print(f" Shell : {shellPath or '(not found)'}"
|
|
2252
|
+
+ (f" | bash: {bashPath}" if bashPath else ""))
|
|
2253
|
+
print(f" Identity : {idOut or '(unknown)'}")
|
|
2254
|
+
print(f" Kernel : {unameOut or '(unknown)'}")
|
|
2255
|
+
print(f" PATH : {pathOut or '(empty)'}")
|
|
2256
|
+
tools = []
|
|
2257
|
+
if isAndroid: tools.append("Android/getprop" if hasGetprop else "Android(no getprop)")
|
|
2258
|
+
if hasBusybox: tools.append("busybox")
|
|
2259
|
+
if hasToybox: tools.append("toybox")
|
|
2260
|
+
if tools:
|
|
2261
|
+
print(f" Tools : {', '.join(tools)}")
|
|
2262
|
+
if not isAndroid:
|
|
2263
|
+
print(f" {Colors.YELLOW}⚠ /system/build.prop not found - device may not be Android."
|
|
2264
|
+
f" Android-specific checks will return empty results.{Colors.RESET}")
|
|
2265
|
+
print()
|
|
2266
|
+
|
|
2267
|
+
# UART pre-flight
|
|
2268
|
+
elif isinstance(device, UartDevice):
|
|
2269
|
+
preflight = device.shell("echo HARDAX_PREFLIGHT_OK")
|
|
2270
|
+
if "HARDAX_PREFLIGHT_OK" not in preflight:
|
|
2271
|
+
print(f"{Colors.BRIGHT_RED}✗ UART pre-flight check failed!{Colors.RESET}")
|
|
2272
|
+
print(f" Response: {preflight!r}")
|
|
2273
|
+
print(f" {Colors.YELLOW}Check that:{Colors.RESET}")
|
|
2274
|
+
print(f" • UART TX/RX/GND are wired correctly")
|
|
2275
|
+
print(f" • Baud rate is correct (current: {device.baud})")
|
|
2276
|
+
print(f" • The device has a shell on this UART port")
|
|
2277
|
+
print(f" • Try pressing Enter on the serial console first")
|
|
2278
|
+
sys.exit(1)
|
|
2279
|
+
print(f"{Colors.GREEN}✓ UART connection verified ({device.port} @ {device.baud} baud){Colors.RESET}")
|
|
2280
|
+
|
|
2281
|
+
# Probe shell environment
|
|
2282
|
+
uart_info = device.probeShell()
|
|
2283
|
+
|
|
2284
|
+
shellType = "root shell" if uart_info["is_root"] else "user shell"
|
|
2285
|
+
print(f" {Colors.BRIGHT_WHITE}Shell type : {Colors.BOLD}{shellType}{Colors.RESET}")
|
|
2286
|
+
print(f" Shell : {uart_info['shell'] or '(not found)'}"
|
|
2287
|
+
+ (f" | bash: {uart_info['bash']}" if uart_info["bash"] else ""))
|
|
2288
|
+
print(f" Identity : {uart_info['uid'] or '(unknown)'}")
|
|
2289
|
+
print(f" Kernel : {uart_info['uname'] or '(unknown)'}")
|
|
2290
|
+
print(f" PATH : {uart_info['path'] or '(empty)'}")
|
|
2291
|
+
tools = []
|
|
2292
|
+
if uart_info["is_android"]:
|
|
2293
|
+
tools.append("Android/getprop" if uart_info["has_getprop"] else "Android(no getprop)")
|
|
2294
|
+
if uart_info["has_busybox"]: tools.append("busybox")
|
|
2295
|
+
if uart_info["has_toybox"]: tools.append("toybox")
|
|
2296
|
+
if tools:
|
|
2297
|
+
print(f" Tools : {', '.join(tools)}")
|
|
2298
|
+
|
|
2299
|
+
if uart_info["is_root"]:
|
|
2300
|
+
print(f" {Colors.BRIGHT_RED}⚠ UART drops directly into ROOT shell - no authentication!{Colors.RESET}")
|
|
2301
|
+
if not uart_info["is_android"]:
|
|
2302
|
+
print(f" {Colors.YELLOW}⚠ /system/build.prop not found - device may not be Android."
|
|
2303
|
+
f" Android-specific checks will return empty results.{Colors.RESET}")
|
|
2304
|
+
print()
|
|
2305
|
+
|
|
2306
|
+
# Set up HUD dashboard (only in default mode, not --show-commands)
|
|
2307
|
+
global _active_dashboard
|
|
2308
|
+
_hud = None
|
|
2309
|
+
useShowCommands = args.show_commands
|
|
2310
|
+
|
|
2311
|
+
if not useShowCommands and sys.stdout.isatty():
|
|
2312
|
+
# HUD Tactical dashboard mode
|
|
2313
|
+
signal.signal(signal.SIGINT, _signalHandler)
|
|
2314
|
+
_hud = HUDDashboard(checks, deviceInfo=device.idString())
|
|
2315
|
+
_active_dashboard = _hud
|
|
2316
|
+
|
|
2317
|
+
# Run all checks
|
|
2318
|
+
rows, counts = runChecks(
|
|
2319
|
+
device, checks,
|
|
2320
|
+
onProgress=_progress,
|
|
2321
|
+
showCommands=useShowCommands,
|
|
2322
|
+
isRooted=isRooted,
|
|
2323
|
+
rootMethod=rootMethod,
|
|
2324
|
+
dashboard=_hud,
|
|
2325
|
+
)
|
|
2326
|
+
|
|
2327
|
+
if _hud:
|
|
2328
|
+
_hud.finish()
|
|
2329
|
+
_active_dashboard = None
|
|
2330
|
+
|
|
2331
|
+
if args.progress_numbers:
|
|
2332
|
+
print()
|
|
2333
|
+
|
|
2334
|
+
# Certificate audit
|
|
2335
|
+
# Works over both ADB and SSH - cert paths (/system/etc/security/cacerts, etc.)
|
|
2336
|
+
# are filesystem-level reads that the shell can perform on any Android device.
|
|
2337
|
+
certs = []
|
|
2338
|
+
if not args.skip_certs:
|
|
2339
|
+
certs = auditCertificates(device)
|
|
2340
|
+
|
|
2341
|
+
# Generate reports
|
|
2342
|
+
writeTxtReport(txtFile, deviceInfo, rows, counts, certs, device.idString())
|
|
2343
|
+
writeCsvReport(csvFile, rows)
|
|
2344
|
+
writeHtmlReport(htmlFile, deviceInfo, rows, counts, certs)
|
|
2345
|
+
if jsonFile:
|
|
2346
|
+
writeJsonReport(jsonFile, deviceInfo, rows, counts, certs, device.idString())
|
|
2347
|
+
|
|
2348
|
+
# Close SSH / UART if used
|
|
2349
|
+
if isinstance(device, (SshDevice, UartDevice)):
|
|
2350
|
+
device.close()
|
|
2351
|
+
|
|
2352
|
+
# Summary panel
|
|
2353
|
+
total_checks = sum(counts.values())
|
|
2354
|
+
|
|
2355
|
+
# ── Visual‑width helpers (ANSI safe) ─────────────────────────────────
|
|
2356
|
+
def _lpad_vis(s: str, width: int) -> str:
|
|
2357
|
+
need = width - _vlen(s)
|
|
2358
|
+
return s if need <= 0 else (' ' * need) + s
|
|
2359
|
+
|
|
2360
|
+
# ── Box geometry + safe printers ─────────────────────────────────────
|
|
2361
|
+
C = Colors.BRIGHT_CYAN
|
|
2362
|
+
R = Colors.RESET
|
|
2363
|
+
B = Colors.BOLD
|
|
2364
|
+
D = Colors.DIM
|
|
2365
|
+
|
|
2366
|
+
FRAME_WIDTH = 68 # number of '═' between corners (kept as-is)
|
|
2367
|
+
INNER_WIDTH = FRAME_WIDTH # inner content width between the two ║
|
|
2368
|
+
|
|
2369
|
+
def _box_top():
|
|
2370
|
+
print(f"\n{C} ╔{'═' * FRAME_WIDTH}╗{R}")
|
|
2371
|
+
def _box_sep():
|
|
2372
|
+
print(f"{C} ╠{'═' * FRAME_WIDTH}╣{R}")
|
|
2373
|
+
|
|
2374
|
+
def _box_bottom():
|
|
2375
|
+
print(f"{C} ╚{'═' * FRAME_WIDTH}╝{R}\n")
|
|
2376
|
+
def _box_line(content: str):
|
|
2377
|
+
print(f"{C} ║{R}{_vpad(content, INNER_WIDTH)}{C}║{R}")
|
|
2378
|
+
|
|
2379
|
+
# ── Fixed-width progress bar (exact visual width) ────────────────────
|
|
2380
|
+
def _bar_fixed(n: int, tot: int, width: int = 16, col: str = Colors.GREEN) -> str:
|
|
2381
|
+
filled = int(round((n / tot) * width)) if tot else 0
|
|
2382
|
+
filled = max(0, min(width, filled))
|
|
2383
|
+
filled_seg = f"{col}{'█' * filled}{R}" if filled else ""
|
|
2384
|
+
empty_seg = f"{D}{'░' * (width - filled)}{R}" if width - filled > 0 else ""
|
|
2385
|
+
bar = filled_seg + empty_seg
|
|
2386
|
+
return _vpad(bar, width)
|
|
2387
|
+
|
|
2388
|
+
# ── Header lines (left title + right checks) ─────────────────────────
|
|
2389
|
+
title_left = f" {B}{Colors.BRIGHT_WHITE}HARDAX AUDIT COMPLETE{R}"
|
|
2390
|
+
title_right = f"{D}{total_checks} checks{R}"
|
|
2391
|
+
|
|
2392
|
+
used = _vlen(title_left) + _vlen(title_right)
|
|
2393
|
+
mid_spaces = max(0, INNER_WIDTH - used)
|
|
2394
|
+
_box_top()
|
|
2395
|
+
_box_line(title_left + (" " * mid_spaces) + title_right)
|
|
2396
|
+
|
|
2397
|
+
# target line
|
|
2398
|
+
target_line = f" {D}target{R} {Colors.BRIGHT_WHITE}{device.idString()}{R}"
|
|
2399
|
+
_box_line(target_line)
|
|
2400
|
+
|
|
2401
|
+
_box_sep()
|
|
2402
|
+
|
|
2403
|
+
# ── Summary rows: exact column plan ──────────────────────────────────
|
|
2404
|
+
# Layout (visual widths):
|
|
2405
|
+
# 2 spaces + sym(1) + space(1) + label(8 L) + 2 + count(4 R) + 2 + pct(6 R) + 2 + bar(16)
|
|
2406
|
+
def _summary_row(col, sym, lbl, cnt, tot):
|
|
2407
|
+
pct = f"{(cnt / tot * 100):5.1f}%" if tot else " 0.0%"
|
|
2408
|
+
bar = _bar_fixed(cnt, tot, width=16, col=col)
|
|
2409
|
+
|
|
2410
|
+
part1 = " " + f"{col}{sym}{R}" + " "
|
|
2411
|
+
part2 = _vpad(f"{col}{lbl}{R}", 8)
|
|
2412
|
+
part3 = " " + _lpad_vis(f"{B}{col}{cnt}{R}", 4)
|
|
2413
|
+
part4 = " " + _lpad_vis(pct, 6)
|
|
2414
|
+
part5 = " " + _vpad(bar, 16)
|
|
2415
|
+
|
|
2416
|
+
_box_line(part1 + part2 + part3 + part4 + part5)
|
|
2417
|
+
|
|
2418
|
+
summaryRows = [
|
|
2419
|
+
(Colors.BRIGHT_RED, '✗', 'CRITICAL', counts['critical']),
|
|
2420
|
+
(Colors.YELLOW, '⚠', 'WARNING', counts['warning']),
|
|
2421
|
+
(Colors.BRIGHT_MAGENTA, '?', 'VERIFY', counts['verify']),
|
|
2422
|
+
(Colors.GREEN, '✓', 'SAFE', counts['safe']),
|
|
2423
|
+
(Colors.CYAN, 'ℹ', 'INFO', counts['info']),
|
|
2424
|
+
]
|
|
2425
|
+
if counts.get('skipped', 0):
|
|
2426
|
+
summaryRows.append((D, '⊘', 'SKIPPED', counts['skipped']))
|
|
2427
|
+
|
|
2428
|
+
for col, sym, lbl, cnt in summaryRows:
|
|
2429
|
+
_summary_row(col, sym, lbl, cnt, total_checks)
|
|
2430
|
+
|
|
2431
|
+
_box_sep()
|
|
2432
|
+
|
|
2433
|
+
# footer file paths
|
|
2434
|
+
_box_line(f" {D}TXT {R} {D}{txtFile}{R}")
|
|
2435
|
+
_box_line(f" {D}HTML{R} {D}{htmlFile}{R}")
|
|
2436
|
+
_box_line(f" {D}CSV {R} {D}{csvFile}{R}")
|
|
2437
|
+
if jsonFile:
|
|
2438
|
+
_box_line(f" {D}JSON{R} {D}{jsonFile}{R}")
|
|
2439
|
+
|
|
2440
|
+
_box_bottom()
|
|
2441
|
+
|
|
2442
|
+
# Opt-in: map findings to process exit code for CI integration.
|
|
2443
|
+
# 0 = clean (no critical, no warning), 1 = warnings only, 2 = critical present.
|
|
2444
|
+
# Off by default to avoid breaking existing scripts that rely on exit 0.
|
|
2445
|
+
if args.exit_code:
|
|
2446
|
+
if counts.get("critical", 0) > 0:
|
|
2447
|
+
sys.exit(2)
|
|
2448
|
+
if counts.get("warning", 0) > 0:
|
|
2449
|
+
sys.exit(1)
|
|
2450
|
+
sys.exit(0)
|
|
2451
|
+
|
|
2452
|
+
if __name__ == "__main__":
|
|
2453
|
+
main()
|