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 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()