traceveil 0.1.0__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.
- traceveil/__init__.py +12 -0
- traceveil/__main__.py +5 -0
- traceveil/adb.py +142 -0
- traceveil/audit.py +212 -0
- traceveil/cli.py +139 -0
- traceveil/config.py +94 -0
- traceveil/egress.py +92 -0
- traceveil/netenv.py +229 -0
- traceveil/report.py +61 -0
- traceveil/shizuku.py +42 -0
- traceveil-0.1.0.dist-info/METADATA +156 -0
- traceveil-0.1.0.dist-info/RECORD +16 -0
- traceveil-0.1.0.dist-info/WHEEL +5 -0
- traceveil-0.1.0.dist-info/entry_points.txt +2 -0
- traceveil-0.1.0.dist-info/licenses/LICENSE +21 -0
- traceveil-0.1.0.dist-info/top_level.txt +1 -0
traceveil/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Traceveil developer CLI: drives an iQOO/Android device over adb and reports network-environment signals."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
# Process exit codes, also reported in the JSON verdict.
|
|
6
|
+
EXIT_PASS = 0
|
|
7
|
+
EXIT_FINDINGS = 1
|
|
8
|
+
EXIT_INCONCLUSIVE = 2
|
|
9
|
+
EXIT_DEVICE = 3
|
|
10
|
+
EXIT_USAGE = 4
|
|
11
|
+
EXIT_POLICY = 5
|
|
12
|
+
EXIT_TIMEOUT = 6
|
traceveil/__main__.py
ADDED
traceveil/adb.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""adb discovery and a thin device wrapper. WSL2 cannot see USB phones, so the Windows adb.exe is a first-class target."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import glob
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AdbError(Exception):
|
|
13
|
+
"""Raised for problems the user can fix (no adb, no device, unauthorized)."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AdbDisconnected(AdbError):
|
|
17
|
+
"""The phone dropped off adb mid-command (cable, sleep, adb restart). Usually recoverable by reconnecting."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_GONE_MARKERS = ("device offline", "no devices/emulators", "not found", "closed", "protocol fault", "unauthorized")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def candidates(explicit: str | None = None, env: dict | None = None, which=shutil.which, globber=glob.glob) -> list[tuple[str, bool]]:
|
|
24
|
+
"""(adb path, pinned) in preference order. Pinned entries (--adb / $ADB) are never replaced by a fallback."""
|
|
25
|
+
env = os.environ if env is None else env
|
|
26
|
+
for pinned in (explicit, env.get("ADB")):
|
|
27
|
+
if pinned:
|
|
28
|
+
return [(pinned, True)]
|
|
29
|
+
found = []
|
|
30
|
+
path_adb = which("adb")
|
|
31
|
+
if path_adb:
|
|
32
|
+
found.append((path_adb, False))
|
|
33
|
+
found += [(w, False) for w in sorted(globber("/mnt/c/Users/*/AppData/Local/Android/Sdk/platform-tools/adb.exe"))]
|
|
34
|
+
return found
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def find_adb(explicit: str | None = None, env: dict | None = None, which=shutil.which, globber=glob.glob) -> str:
|
|
38
|
+
env = os.environ if env is None else env
|
|
39
|
+
for candidate in (explicit, env.get("ADB")):
|
|
40
|
+
if candidate:
|
|
41
|
+
return candidate
|
|
42
|
+
found = which("adb")
|
|
43
|
+
if found:
|
|
44
|
+
return found
|
|
45
|
+
wsl = sorted(globber("/mnt/c/Users/*/AppData/Local/Android/Sdk/platform-tools/adb.exe"))
|
|
46
|
+
if wsl:
|
|
47
|
+
return wsl[0]
|
|
48
|
+
raise AdbError("adb not found: pass --adb, set $ADB, or install Android platform-tools")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class DeviceEntry:
|
|
53
|
+
serial: str
|
|
54
|
+
state: str
|
|
55
|
+
model: str = ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def parse_devices(text: str) -> list[DeviceEntry]:
|
|
59
|
+
out = []
|
|
60
|
+
for raw in text.replace("\r", "").splitlines():
|
|
61
|
+
line = raw.strip()
|
|
62
|
+
if not line or line.startswith("List of devices") or line.startswith("*"):
|
|
63
|
+
continue
|
|
64
|
+
parts = line.split()
|
|
65
|
+
if len(parts) < 2:
|
|
66
|
+
continue
|
|
67
|
+
model = next((p.split(":", 1)[1] for p in parts[2:] if p.startswith("model:")), "")
|
|
68
|
+
out.append(DeviceEntry(parts[0], parts[1], model))
|
|
69
|
+
return out
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def select_device(entries: list[DeviceEntry], serial: str | None) -> DeviceEntry:
|
|
73
|
+
if serial:
|
|
74
|
+
match = [e for e in entries if e.serial == serial]
|
|
75
|
+
if not match:
|
|
76
|
+
raise AdbError(f"device {serial} not found; attached: {[e.serial for e in entries] or 'none'}")
|
|
77
|
+
entry = match[0]
|
|
78
|
+
else:
|
|
79
|
+
ready = [e for e in entries if e.state == "device"]
|
|
80
|
+
if len(entries) > 1 and len(ready) != 1:
|
|
81
|
+
raise AdbError("several devices attached; choose one with --serial")
|
|
82
|
+
if not entries:
|
|
83
|
+
raise AdbError("no device attached: connect the phone and enable USB debugging")
|
|
84
|
+
entry = ready[0] if ready else entries[0]
|
|
85
|
+
if entry.state == "unauthorized":
|
|
86
|
+
raise AdbError("device is unauthorized: unlock the phone and tap Allow on the USB debugging prompt")
|
|
87
|
+
if entry.state != "device":
|
|
88
|
+
raise AdbError(f"device {entry.serial} is {entry.state}; reconnect the cable or restart adb")
|
|
89
|
+
return entry
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class Device:
|
|
93
|
+
def __init__(self, adb: str, serial: str, timeout: float = 20.0, runner=subprocess.run):
|
|
94
|
+
self.adb, self.serial, self.timeout, self._run = adb, serial, timeout, runner
|
|
95
|
+
|
|
96
|
+
def _cmd(self, *args: str) -> list[str]:
|
|
97
|
+
return [self.adb, "-s", self.serial, *args]
|
|
98
|
+
|
|
99
|
+
def shell(self, command: str) -> str:
|
|
100
|
+
try:
|
|
101
|
+
proc = self._run(self._cmd("shell", command), capture_output=True, text=True, timeout=self.timeout)
|
|
102
|
+
except subprocess.TimeoutExpired as e:
|
|
103
|
+
raise AdbError(f"adb timed out running: {command}") from e
|
|
104
|
+
return proc.stdout.replace("\r", "")
|
|
105
|
+
|
|
106
|
+
def shell_checked(self, command: str) -> str:
|
|
107
|
+
"""Like [shell] but raises [AdbDisconnected] when adb says the device is gone. Remote command failures
|
|
108
|
+
(for example `cat` of a missing file) are returned as text, not raised."""
|
|
109
|
+
try:
|
|
110
|
+
proc = self._run(self._cmd("shell", command), capture_output=True, text=True, timeout=self.timeout)
|
|
111
|
+
except subprocess.TimeoutExpired as e:
|
|
112
|
+
raise AdbDisconnected(f"adb timed out running: {command}") from e
|
|
113
|
+
err = (proc.stderr or "").lower()
|
|
114
|
+
if proc.returncode != 0 and err.startswith("error:") and any(m in err for m in _GONE_MARKERS):
|
|
115
|
+
raise AdbDisconnected((proc.stderr or "").strip())
|
|
116
|
+
return (proc.stdout + ("" if proc.returncode == 0 else proc.stderr or "")).replace("\r", "")
|
|
117
|
+
|
|
118
|
+
def getprop(self, key: str) -> str:
|
|
119
|
+
return self.shell(f"getprop {key}").strip()
|
|
120
|
+
|
|
121
|
+
def setting(self, namespace: str, key: str) -> str | None:
|
|
122
|
+
value = self.shell(f"settings get {namespace} {key}").strip()
|
|
123
|
+
return None if value in ("", "null") else value
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def connect(explicit_adb: str | None, serial: str | None, timeout: float = 20.0, runner=subprocess.run,
|
|
127
|
+
cands: list[tuple[str, bool]] | None = None) -> Device:
|
|
128
|
+
"""Tries each adb candidate until one lists a device. Under WSL2 the Linux adb on PATH sees no USB phones, so the
|
|
129
|
+
Windows adb.exe is tried next."""
|
|
130
|
+
cands = cands if cands is not None else candidates(explicit_adb)
|
|
131
|
+
if not cands:
|
|
132
|
+
raise AdbError("adb not found: pass --adb, set $ADB, or install Android platform-tools")
|
|
133
|
+
last = None
|
|
134
|
+
for adb, _pinned in cands:
|
|
135
|
+
proc = runner([adb, "devices", "-l"], capture_output=True, text=True, timeout=timeout)
|
|
136
|
+
entries = parse_devices(proc.stdout)
|
|
137
|
+
if entries:
|
|
138
|
+
entry = select_device(entries, serial or os.environ.get("SERIAL"))
|
|
139
|
+
return Device(adb, entry.serial, timeout, runner)
|
|
140
|
+
last = adb
|
|
141
|
+
raise AdbError(f"no device attached (tried {', '.join(a for a, _ in cands)}): connect the phone and enable USB debugging; "
|
|
142
|
+
"under WSL2 use the Windows adb.exe via --adb")
|
traceveil/audit.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""`traceveil audit`: trigger a debug-build audit on the phone over adb, wait, fetch and validate its JSON report."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import secrets
|
|
8
|
+
import shlex
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Callable
|
|
15
|
+
|
|
16
|
+
from . import EXIT_DEVICE, EXIT_FINDINGS, EXIT_INCONCLUSIVE, EXIT_PASS, EXIT_TIMEOUT, EXIT_USAGE, adb
|
|
17
|
+
from .netenv import OWN_PACKAGE
|
|
18
|
+
|
|
19
|
+
ACTION = "com.dpdpxray.app.debug.RUN_AUDIT"
|
|
20
|
+
RECEIVER = f"{OWN_PACKAGE}/.cli.DebugAuditReceiver"
|
|
21
|
+
REMOTE_REPORTS = f"/sdcard/Android/data/{OWN_PACKAGE}/files/reports"
|
|
22
|
+
SCHEMA_VERSION = 1
|
|
23
|
+
SEVERITY_ORDER = ["info", "low", "medium", "high", "critical"]
|
|
24
|
+
REQUIRED_KEYS = ("schemaVersion", "requestId", "auditId", "target", "score", "band", "findings", "sessions")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Io:
|
|
29
|
+
sleep: Callable[[float], None] = time.sleep
|
|
30
|
+
clock: Callable[[], float] = time.monotonic
|
|
31
|
+
out: object = None
|
|
32
|
+
err: object = None
|
|
33
|
+
interactive: bool = False
|
|
34
|
+
color: bool = False
|
|
35
|
+
prompt: Callable[[str], str] = input
|
|
36
|
+
poll_s: float = 2.0
|
|
37
|
+
|
|
38
|
+
def __post_init__(self):
|
|
39
|
+
self.out = self.out or sys.stdout
|
|
40
|
+
self.err = self.err or sys.stderr
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def paint(text: str, code: str, enabled: bool) -> str:
|
|
44
|
+
return f"\x1b[{code}m{text}\x1b[0m" if enabled else text
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def new_request_id() -> str:
|
|
48
|
+
return f"cli-{datetime.now(timezone.utc):%Y%m%dT%H%M%SZ}-{secrets.token_hex(4)}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _remote(request_id: str, name: str) -> str:
|
|
52
|
+
return f"{REMOTE_REPORTS}/{request_id}/{name}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _fail(io: Io, msg: str, code: int) -> int:
|
|
56
|
+
print(paint(f"error: {msg}", "1;31", io.color), file=io.err)
|
|
57
|
+
return code
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _json_or_none(text: str):
|
|
61
|
+
try:
|
|
62
|
+
return json.loads(text)
|
|
63
|
+
except ValueError:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def trigger(dev, request_id: str, package: str, plans: str | None, auto_drive: bool, reset: bool) -> tuple[bool, str]:
|
|
68
|
+
"""Sends the debug broadcast. Returns (accepted, reason)."""
|
|
69
|
+
extras = f"--es request_id {shlex.quote(request_id)} --es package {shlex.quote(package)}"
|
|
70
|
+
if plans:
|
|
71
|
+
extras += f" --es plans {shlex.quote(plans)}"
|
|
72
|
+
if auto_drive:
|
|
73
|
+
extras += " --es auto_drive true"
|
|
74
|
+
if reset:
|
|
75
|
+
extras += " --es reset true"
|
|
76
|
+
out = dev.shell_checked(f"am broadcast -a {ACTION} -n {RECEIVER} {extras}")
|
|
77
|
+
m = re.search(r"result=(\d+)", out)
|
|
78
|
+
code = int(m.group(1)) if m else 0
|
|
79
|
+
if code == 1:
|
|
80
|
+
return True, "accepted"
|
|
81
|
+
if code == 2:
|
|
82
|
+
d = re.search(r'data="([^"]*)"', out)
|
|
83
|
+
return False, d.group(1) if d else "the app rejected the request"
|
|
84
|
+
return False, ("no debug trigger answered: install a debug build of Traceveil and open it once (a never-opened or "
|
|
85
|
+
"force-stopped app does not receive broadcasts); release builds have no trigger by design")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def validate_report(text: str, request_id: str) -> tuple[dict | None, str]:
|
|
89
|
+
rep = _json_or_none(text)
|
|
90
|
+
if not isinstance(rep, dict):
|
|
91
|
+
return None, "report.json is not valid JSON"
|
|
92
|
+
missing = [k for k in REQUIRED_KEYS if k not in rep]
|
|
93
|
+
if missing:
|
|
94
|
+
return None, f"report.json is missing {', '.join(missing)}"
|
|
95
|
+
if rep["schemaVersion"] != SCHEMA_VERSION:
|
|
96
|
+
return None, f"report.json schemaVersion {rep['schemaVersion']!r} is not supported (expected {SCHEMA_VERSION})"
|
|
97
|
+
if rep["requestId"] != request_id:
|
|
98
|
+
return None, "report.json belongs to a different request"
|
|
99
|
+
if not isinstance(rep["findings"], list) or not isinstance(rep["sessions"], list):
|
|
100
|
+
return None, "report.json findings/sessions are not lists"
|
|
101
|
+
return rep, ""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def exit_for_report(rep: dict, fail_on: str, min_score: int) -> tuple[int, list[str]]:
|
|
105
|
+
if rep.get("score") is None or rep.get("band") == "INCONCLUSIVE":
|
|
106
|
+
return EXIT_INCONCLUSIVE, ["audit result is inconclusive (no score)"]
|
|
107
|
+
reasons = []
|
|
108
|
+
if rep["score"] < min_score:
|
|
109
|
+
reasons.append(f"score {rep['score']} is below min_score {min_score}")
|
|
110
|
+
if fail_on in SEVERITY_ORDER:
|
|
111
|
+
bar = SEVERITY_ORDER.index(fail_on)
|
|
112
|
+
hits = [f for f in rep["findings"] if isinstance(f, dict) and str(f.get("severity", "")).lower() in SEVERITY_ORDER
|
|
113
|
+
and SEVERITY_ORDER.index(str(f["severity"]).lower()) >= bar]
|
|
114
|
+
if hits:
|
|
115
|
+
reasons.append(f"{len(hits)} finding(s) at or above {fail_on}")
|
|
116
|
+
return (EXIT_FINDINGS if reasons else EXIT_PASS), reasons
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def summary(rep: dict, reasons: list[str], saved: Path) -> str:
|
|
120
|
+
t = rep["target"]
|
|
121
|
+
lines = [f"Traceveil audit {t.get('label', '?')} ({t.get('package', '?')}) score {rep['score']} / {rep['band']} findings {len(rep['findings'])}"]
|
|
122
|
+
for f in rep["findings"]:
|
|
123
|
+
lines.append(f" {f.get('check', '?'):<4} {f.get('severity', '?'):<8} {f.get('title', '')}")
|
|
124
|
+
for r in reasons:
|
|
125
|
+
lines.append(f" policy: {r}")
|
|
126
|
+
lines.append(f" report saved: {saved}")
|
|
127
|
+
return "\n".join(lines)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _wait_for_device(dev, io: Io, deadline: list[float], request_id: str) -> bool:
|
|
131
|
+
"""Called after adb dropped. Tells the user to reconnect; True once adb answers again, False to give up."""
|
|
132
|
+
print(paint(f"Device disconnected. Reconnect the cable, unlock the phone and accept USB debugging if asked. The audit keeps "
|
|
133
|
+
f"running on the phone; resume later with: traceveil audit --resume {request_id}", "1;33", io.color), file=io.err)
|
|
134
|
+
while True:
|
|
135
|
+
if io.interactive:
|
|
136
|
+
started = io.clock()
|
|
137
|
+
answer = io.prompt(paint("Press Enter to retry, or type q to quit: ", "1;36", io.color)).strip().lower()
|
|
138
|
+
deadline[0] += io.clock() - started # time the user spent at the prompt does not count against --wait
|
|
139
|
+
if answer == "q":
|
|
140
|
+
return False
|
|
141
|
+
else:
|
|
142
|
+
if io.clock() >= deadline[0]:
|
|
143
|
+
return False
|
|
144
|
+
io.sleep(io.poll_s)
|
|
145
|
+
try:
|
|
146
|
+
dev.shell_checked("echo ok")
|
|
147
|
+
print(paint("Device reconnected, continuing.", "1;32", io.color), file=io.err)
|
|
148
|
+
return True
|
|
149
|
+
except adb.AdbDisconnected:
|
|
150
|
+
if not io.interactive and io.clock() >= deadline[0]:
|
|
151
|
+
return False
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def run_audit(dev, cfg: dict, io: Io, *, package: str, plans: str | None, auto_drive: bool, reset: bool, wait_s: float,
|
|
155
|
+
resume: str | None, as_json: bool, out_file: str | None, request_id: str | None = None,
|
|
156
|
+
fail_on_override: str | None = None) -> int:
|
|
157
|
+
request_id = resume or request_id or new_request_id()
|
|
158
|
+
package = package or cfg["target"]["package"]
|
|
159
|
+
if not resume and not package:
|
|
160
|
+
return _fail(io, "no target app: pass --package or set target.package in traceveil.toml", EXIT_USAGE)
|
|
161
|
+
try:
|
|
162
|
+
if not resume:
|
|
163
|
+
if f"package:{OWN_PACKAGE}" not in dev.shell_checked(f"pm list packages {OWN_PACKAGE}").split():
|
|
164
|
+
return _fail(io, f"{OWN_PACKAGE} is not installed on the device (install the debug APK)", EXIT_DEVICE)
|
|
165
|
+
ok, reason = trigger(dev, request_id, package, plans, auto_drive, reset)
|
|
166
|
+
if not ok:
|
|
167
|
+
return _fail(io, f"audit was not started: {reason}", EXIT_DEVICE)
|
|
168
|
+
print(f"audit started: {request_id}", file=io.err)
|
|
169
|
+
except adb.AdbDisconnected as e:
|
|
170
|
+
return _fail(io, f"device disconnected before the audit started ({e}); reconnect and run again", EXIT_DEVICE)
|
|
171
|
+
|
|
172
|
+
deadline = [io.clock() + wait_s]
|
|
173
|
+
last_step = ""
|
|
174
|
+
while True:
|
|
175
|
+
try:
|
|
176
|
+
raw = dev.shell_checked(f"cat {shlex.quote(_remote(request_id, 'status.json'))}")
|
|
177
|
+
state = _json_or_none(raw)
|
|
178
|
+
if isinstance(state, dict):
|
|
179
|
+
last_step = state.get("step") or last_step
|
|
180
|
+
if state.get("state") == "failed":
|
|
181
|
+
return _fail(io, f"audit failed on the device: {state.get('error') or 'no reason given'}", EXIT_INCONCLUSIVE)
|
|
182
|
+
if state.get("state") == "done":
|
|
183
|
+
break
|
|
184
|
+
except adb.AdbDisconnected:
|
|
185
|
+
if not _wait_for_device(dev, io, deadline, request_id):
|
|
186
|
+
return _fail(io, f"device not reachable; audit may still be running. Resume with: traceveil audit --resume {request_id}", EXIT_DEVICE)
|
|
187
|
+
continue
|
|
188
|
+
if io.clock() >= deadline[0]:
|
|
189
|
+
return _fail(io, f"timed out after {wait_s:.0f}s (last step: {last_step or 'unknown'}). The audit was not cancelled; "
|
|
190
|
+
f"fetch it later with: traceveil audit --resume {request_id}", EXIT_TIMEOUT)
|
|
191
|
+
io.sleep(io.poll_s)
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
text = dev.shell_checked(f"cat {shlex.quote(_remote(request_id, 'report.json'))}")
|
|
195
|
+
except adb.AdbDisconnected as e:
|
|
196
|
+
return _fail(io, f"device disconnected while fetching the report ({e}); resume with: traceveil audit --resume {request_id}", EXIT_DEVICE)
|
|
197
|
+
if "No such file" in text:
|
|
198
|
+
return _fail(io, "audit finished but report.json is missing on the device", EXIT_INCONCLUSIVE)
|
|
199
|
+
rep, problem = validate_report(text, request_id)
|
|
200
|
+
if rep is None:
|
|
201
|
+
return _fail(io, problem, EXIT_INCONCLUSIVE)
|
|
202
|
+
|
|
203
|
+
out_dir = Path(cfg["output"]["dir"])
|
|
204
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
205
|
+
saved = out_dir / f"{request_id}.json"
|
|
206
|
+
saved.write_text(json.dumps(rep, indent=2), encoding="utf-8")
|
|
207
|
+
if out_file:
|
|
208
|
+
Path(out_file).parent.mkdir(parents=True, exist_ok=True)
|
|
209
|
+
Path(out_file).write_text(json.dumps(rep, indent=2), encoding="utf-8")
|
|
210
|
+
code, reasons = exit_for_report(rep, fail_on_override or cfg["audit"]["fail_on"], int(cfg["audit"]["min_score"]))
|
|
211
|
+
print(json.dumps(rep, indent=2) if as_json else summary(rep, reasons, saved), file=io.out)
|
|
212
|
+
return code
|
traceveil/cli.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""traceveil command line: doctor, env, init, audit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import (audit, EXIT_DEVICE, EXIT_PASS, EXIT_POLICY, EXIT_USAGE, __version__, adb, config, egress, netenv, report, shizuku)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _parser() -> argparse.ArgumentParser:
|
|
14
|
+
p = argparse.ArgumentParser(prog="traceveil", description="Traceveil developer CLI (iQOO/Android over adb).")
|
|
15
|
+
p.add_argument("--version", action="version", version=f"traceveil {__version__}")
|
|
16
|
+
p.add_argument("--config", help="path to traceveil.toml / traceveil.json")
|
|
17
|
+
p.add_argument("--serial", help="device serial (default: the only attached device, or $SERIAL)")
|
|
18
|
+
p.add_argument("--adb", help="adb binary (default: $ADB, PATH, then Windows adb.exe under WSL)")
|
|
19
|
+
p.add_argument("--timeout", type=float, default=20.0, help="seconds per adb command")
|
|
20
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
21
|
+
|
|
22
|
+
d = sub.add_parser("doctor", help="check adb, device and Traceveil readiness (starts Shizuku over adb if it is down)")
|
|
23
|
+
d.add_argument("--no-start-shizuku", action="store_true", help="only report the Shizuku server state; do not start it")
|
|
24
|
+
sub.add_parser("init", help="write a traceveil.toml with defaults").add_argument("--force", action="store_true")
|
|
25
|
+
|
|
26
|
+
a = sub.add_parser("audit", help="run an audit on the phone (debug build), wait, and save its JSON report")
|
|
27
|
+
a.add_argument("--package", help="app to audit (default: target.package)")
|
|
28
|
+
a.add_argument("--plans", help="comma list of silent,reject,accept (default: all three)")
|
|
29
|
+
a.add_argument("--auto-drive", action="store_true", help="let Traceveil tap Accept/Reject itself (default: you tap on the phone)")
|
|
30
|
+
a.add_argument("--reset", action="store_true", help="clear the app's data first; only for developer-owned test apps; needs Shizuku")
|
|
31
|
+
a.add_argument("--wait", type=float, default=900.0, help="seconds to wait for the audit (default 900)")
|
|
32
|
+
a.add_argument("--resume", metavar="REQUEST_ID", help="fetch the result of an earlier request instead of starting a new audit")
|
|
33
|
+
a.add_argument("--json", action="store_true", help="print the JSON report instead of the summary")
|
|
34
|
+
a.add_argument("--out", help="also write the JSON report to this file")
|
|
35
|
+
|
|
36
|
+
e = sub.add_parser("env", help="network-environment snapshot (VPN/proxy/geography signals)")
|
|
37
|
+
e.add_argument("--check-vpn", choices=config.CHECK_VPN_MODES, help="off | warn | fail (exit 5 on a likely VPN/proxy)")
|
|
38
|
+
e.add_argument("--online-ip", action="store_true", help="one cached lookup to network.endpoint; sends the public IP to that service")
|
|
39
|
+
e.add_argument("--egress-from", choices=("phone", "host"), default="phone",
|
|
40
|
+
help="where the lookup runs: phone (device curl, default) or host (this machine's network)")
|
|
41
|
+
e.add_argument("--keep-ip", action="store_true", help="store the full public IP instead of a redacted prefix")
|
|
42
|
+
e.add_argument("--include-own-vpn", action="store_true", help="count Traceveil's own capture VPN (debugging aid)")
|
|
43
|
+
e.add_argument("--json", action="store_true", help="print the JSON report instead of the summary")
|
|
44
|
+
e.add_argument("--out", help="also write the JSON report to this file")
|
|
45
|
+
return p
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _device_info(dev: adb.Device) -> dict:
|
|
49
|
+
return {"model": dev.getprop("ro.product.model"), "androidVersion": dev.getprop("ro.build.version.release"),
|
|
50
|
+
"serialHash": report.serial_hash(dev.serial)}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def cmd_doctor(args, cfg, dev: adb.Device) -> int:
|
|
54
|
+
pkgs = dev.shell("pm list packages").replace("package:", "")
|
|
55
|
+
installed = lambda name: f"\n{name}\n" in f"\n{pkgs}\n" # noqa: E731
|
|
56
|
+
rows = [("adb", True, dev.adb), ("device", True, f"{dev.getprop('ro.product.brand')} {dev.getprop('ro.product.model')}, Android {dev.getprop('ro.build.version.release')}"),
|
|
57
|
+
("Traceveil installed", installed(netenv.OWN_PACKAGE), netenv.OWN_PACKAGE),
|
|
58
|
+
("Shizuku installed", installed(shizuku.PACKAGE), "needed for fresh-install resets"),
|
|
59
|
+
("USB install allowed", (dev.setting("secure", "install_non_market_apps") or "") == "1", "Developer options > Install via USB")]
|
|
60
|
+
if installed(shizuku.PACKAGE):
|
|
61
|
+
if args.no_start_shizuku:
|
|
62
|
+
running, note = shizuku.is_running(dev), "not started (--no-start-shizuku)"
|
|
63
|
+
else:
|
|
64
|
+
running, note = shizuku.start(dev)
|
|
65
|
+
rows.append(("Shizuku server running", running, note))
|
|
66
|
+
pdns = dev.setting("global", "private_dns_mode")
|
|
67
|
+
# An unset mode is Android's "Automatic": encrypted DNS can be used, which hides lookups from capture.
|
|
68
|
+
rows.append(("Private DNS off", pdns == "off", f"mode={pdns or 'unset (Automatic)'}; set Private DNS to Off so lookups stay visible"))
|
|
69
|
+
bad = 0
|
|
70
|
+
for name, ok, note in rows:
|
|
71
|
+
bad += 0 if ok else 1
|
|
72
|
+
print(f" [{'ok' if ok else '!!'}] {name:<22} {note}")
|
|
73
|
+
print(" note: VPN permission, accessibility service and Shizuku grant are app-side and are checked from the Traceveil home screen.")
|
|
74
|
+
return EXIT_PASS if bad == 0 else EXIT_DEVICE
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def cmd_env(args, cfg, dev: adb.Device) -> int:
|
|
78
|
+
net = cfg["network"]
|
|
79
|
+
mode = args.check_vpn or net["check_vpn"]
|
|
80
|
+
snap = netenv.collect(dev)
|
|
81
|
+
signals = netenv.derive_signals(snap, exclude_own_vpn=not args.include_own_vpn)
|
|
82
|
+
vpn = netenv.check_vpn_proxy(snap, signals)
|
|
83
|
+
eg = None
|
|
84
|
+
if args.online_ip or net["online_ip"]:
|
|
85
|
+
if not net["endpoint"]:
|
|
86
|
+
print("error: --online-ip needs network.endpoint in traceveil.toml (no default service is baked in)", file=sys.stderr)
|
|
87
|
+
return EXIT_USAGE
|
|
88
|
+
redact = net["redact_ip"] and not args.keep_ip
|
|
89
|
+
fetch = (lambda url, t: egress.phone_fetch(dev, url, t)) if args.egress_from == "phone" else None
|
|
90
|
+
eg = egress.lookup(net["endpoint"], Path(".traceveil/cache.json"), net["cache_ttl_hours"], redact,
|
|
91
|
+
fetch=fetch, vantage=args.egress_from)
|
|
92
|
+
geo = netenv.check_geo(snap, eg["country"] if eg else None, vpn, args.egress_from)
|
|
93
|
+
policy_hit = mode == "fail" and vpn.status == "likely"
|
|
94
|
+
verdict = {"status": "fail" if policy_hit else "pass",
|
|
95
|
+
"reasons": ["VPN/proxy signal present and --check-vpn=fail"] if policy_hit else [],
|
|
96
|
+
"exitCode": EXIT_POLICY if policy_hit else EXIT_PASS}
|
|
97
|
+
rep = report.build_env_report(_device_info(dev), snap, signals, [vpn, geo], eg, verdict)
|
|
98
|
+
print(report.dumps(rep) if args.json else report.summary(rep))
|
|
99
|
+
if args.out:
|
|
100
|
+
Path(args.out).parent.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
Path(args.out).write_text(report.dumps(rep), encoding="utf-8")
|
|
102
|
+
return verdict["exitCode"]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def cmd_audit(args, cfg, dev: adb.Device) -> int:
|
|
106
|
+
io = audit.Io(interactive=sys.stdin.isatty() and sys.stdout.isatty(), color=sys.stderr.isatty() and not os.environ.get("NO_COLOR"))
|
|
107
|
+
plans = args.plans or None
|
|
108
|
+
return audit.run_audit(dev, cfg, io, package=args.package or "", plans=plans, auto_drive=args.auto_drive, reset=args.reset,
|
|
109
|
+
wait_s=args.wait, resume=args.resume, as_json=args.json, out_file=args.out)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def cmd_init(args, cfg, dev=None) -> int:
|
|
113
|
+
target = Path("traceveil.toml")
|
|
114
|
+
if target.exists() and not args.force:
|
|
115
|
+
print("traceveil.toml already exists; use --force to overwrite", file=sys.stderr)
|
|
116
|
+
return EXIT_USAGE
|
|
117
|
+
target.write_text(config.TEMPLATE, encoding="utf-8")
|
|
118
|
+
gi = Path(".gitignore")
|
|
119
|
+
existing = gi.read_text(encoding="utf-8") if gi.exists() else ""
|
|
120
|
+
if ".traceveil/" not in existing.splitlines():
|
|
121
|
+
gi.write_text(existing + ("" if existing.endswith("\n") or not existing else "\n") + ".traceveil/\n", encoding="utf-8")
|
|
122
|
+
print("wrote traceveil.toml and ignored .traceveil/")
|
|
123
|
+
return EXIT_PASS
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def main(argv: list[str] | None = None) -> int:
|
|
127
|
+
args = _parser().parse_args(argv)
|
|
128
|
+
try:
|
|
129
|
+
cfg = config.load(args.config)
|
|
130
|
+
if args.command == "init":
|
|
131
|
+
return cmd_init(args, cfg)
|
|
132
|
+
dev = adb.connect(args.adb or cfg["device"]["adb"] or None, args.serial or cfg["device"]["serial"] or None, args.timeout)
|
|
133
|
+
return {"doctor": cmd_doctor, "env": cmd_env, "audit": cmd_audit}[args.command](args, cfg, dev)
|
|
134
|
+
except config.ConfigError as e:
|
|
135
|
+
print(f"config error: {e}", file=sys.stderr)
|
|
136
|
+
return EXIT_USAGE
|
|
137
|
+
except adb.AdbError as e:
|
|
138
|
+
print(f"device error: {e}", file=sys.stderr)
|
|
139
|
+
return EXIT_DEVICE
|
traceveil/config.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""traceveil.toml loading. Precedence: flags > config file > defaults."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
import tomllib
|
|
10
|
+
except ModuleNotFoundError: # Python < 3.11
|
|
11
|
+
tomllib = None
|
|
12
|
+
|
|
13
|
+
DEFAULTS = {
|
|
14
|
+
"device": {"serial": "", "adb": ""},
|
|
15
|
+
"target": {"package": "", "apk_glob": "app/build/outputs/apk/debug/*.apk"},
|
|
16
|
+
"audit": {"plans": ["silent", "reject", "accept"], "min_score": 0, "fail_on": "high"},
|
|
17
|
+
"network": {"check_vpn": "warn", "online_ip": False, "endpoint": "", "cache_ttl_hours": 24, "redact_ip": True},
|
|
18
|
+
"output": {"dir": ".traceveil/reports", "formats": ["json", "summary"]},
|
|
19
|
+
}
|
|
20
|
+
CHECK_VPN_MODES = ("off", "warn", "fail")
|
|
21
|
+
|
|
22
|
+
TEMPLATE = """# Traceveil CLI configuration. Command-line flags override these values.
|
|
23
|
+
[device]
|
|
24
|
+
serial = "" # leave empty when one phone is attached
|
|
25
|
+
adb = "" # leave empty to auto-detect (WSL: Windows adb.exe)
|
|
26
|
+
|
|
27
|
+
[target]
|
|
28
|
+
package = ""
|
|
29
|
+
apk_glob = "app/build/outputs/apk/debug/*.apk"
|
|
30
|
+
|
|
31
|
+
[audit]
|
|
32
|
+
plans = ["silent", "reject", "accept"]
|
|
33
|
+
min_score = 0
|
|
34
|
+
fail_on = "high"
|
|
35
|
+
|
|
36
|
+
[network]
|
|
37
|
+
check_vpn = "warn" # off | warn | fail
|
|
38
|
+
online_ip = false # true makes one cached lookup to `endpoint`; that service sees the public IP of the phone (or laptop with --egress-from host)
|
|
39
|
+
endpoint = "" # https URL returning JSON with a 2-letter "country"/"country_code" (and optionally "ip"), e.g. https://ipapi.co/json/
|
|
40
|
+
cache_ttl_hours = 24
|
|
41
|
+
redact_ip = true
|
|
42
|
+
|
|
43
|
+
[output]
|
|
44
|
+
dir = ".traceveil/reports"
|
|
45
|
+
formats = ["json", "summary"]
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ConfigError(Exception):
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _merge(base: dict, over: dict) -> dict:
|
|
54
|
+
out = {k: dict(v) if isinstance(v, dict) else v for k, v in base.items()}
|
|
55
|
+
for key, val in over.items():
|
|
56
|
+
if isinstance(val, dict) and isinstance(out.get(key), dict):
|
|
57
|
+
out[key].update(val)
|
|
58
|
+
else:
|
|
59
|
+
out[key] = val
|
|
60
|
+
return out
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def load(path: str | None = None, cwd: Path | None = None) -> dict:
|
|
64
|
+
cwd = cwd or Path.cwd()
|
|
65
|
+
candidates = [Path(path)] if path else [cwd / "traceveil.toml", cwd / "traceveil.json"]
|
|
66
|
+
for p in candidates:
|
|
67
|
+
if not p.is_file():
|
|
68
|
+
if path:
|
|
69
|
+
raise ConfigError(f"config file not found: {p}")
|
|
70
|
+
continue
|
|
71
|
+
try:
|
|
72
|
+
if p.suffix == ".toml":
|
|
73
|
+
if tomllib is None:
|
|
74
|
+
raise ConfigError("traceveil.toml needs Python 3.11+; use traceveil.json instead")
|
|
75
|
+
data = tomllib.loads(p.read_text(encoding="utf-8"))
|
|
76
|
+
else:
|
|
77
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
78
|
+
except (ValueError, OSError) as e:
|
|
79
|
+
raise ConfigError(f"cannot read {p}: {e}") from e
|
|
80
|
+
cfg = _merge(DEFAULTS, data)
|
|
81
|
+
validate(cfg)
|
|
82
|
+
return cfg
|
|
83
|
+
return _merge(DEFAULTS, {})
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def validate(cfg: dict) -> None:
|
|
87
|
+
mode = cfg["network"].get("check_vpn")
|
|
88
|
+
if mode not in CHECK_VPN_MODES:
|
|
89
|
+
raise ConfigError(f"network.check_vpn must be one of {CHECK_VPN_MODES}, got {mode!r}")
|
|
90
|
+
if cfg["network"].get("online_ip") and not cfg["network"].get("endpoint"):
|
|
91
|
+
raise ConfigError("network.online_ip is true but network.endpoint is empty (no default vendor is baked in)")
|
|
92
|
+
endpoint = cfg["network"].get("endpoint")
|
|
93
|
+
if endpoint and not str(endpoint).lower().startswith("https://"):
|
|
94
|
+
raise ConfigError("network.endpoint must be an https:// URL")
|
traceveil/egress.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Opt-in egress-country lookup. Never runs unless the caller passes an explicit https endpoint; results are cached.
|
|
2
|
+
|
|
3
|
+
Expected response: a JSON object with a 2-letter ISO country in `country`, `country_code` or `countryCode` (first
|
|
4
|
+
valid one wins; full country names are ignored) and optionally the caller's address in `ip` or `query`. A body with a
|
|
5
|
+
truthy `error` is a provider failure. The public IP is redacted before anything is cached or returned.
|
|
6
|
+
|
|
7
|
+
Vantage matters: `phone` runs curl on the device under audit (what the audit actually sees); `host` runs on this
|
|
8
|
+
machine and only describes the laptop's network.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import ipaddress
|
|
15
|
+
import json
|
|
16
|
+
import shlex
|
|
17
|
+
import time
|
|
18
|
+
import urllib.request
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
COUNTRY_KEYS = ("country", "country_code", "countryCode")
|
|
22
|
+
IP_KEYS = ("ip", "query")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def redact_ip(ip: str | None) -> str | None:
|
|
26
|
+
"""IPv4 -> /24, IPv6 -> /48. Returns None for anything unparsable."""
|
|
27
|
+
if not ip:
|
|
28
|
+
return None
|
|
29
|
+
try:
|
|
30
|
+
addr = ipaddress.ip_address(ip.strip())
|
|
31
|
+
except ValueError:
|
|
32
|
+
return None
|
|
33
|
+
prefix = 24 if addr.version == 4 else 48
|
|
34
|
+
return str(ipaddress.ip_network(f"{addr}/{prefix}", strict=False))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _load(path: Path) -> dict:
|
|
38
|
+
try:
|
|
39
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
40
|
+
except (OSError, ValueError):
|
|
41
|
+
return {}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def host_fetch(endpoint: str, timeout: float, opener=urllib.request.urlopen) -> str:
|
|
45
|
+
with opener(endpoint, timeout=timeout) as resp:
|
|
46
|
+
return resp.read().decode("utf-8", "replace")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def phone_fetch(device, endpoint: str, timeout: float) -> str:
|
|
50
|
+
"""Runs the device's own curl. The endpoint is shell-quoted; only https URLs reach this point."""
|
|
51
|
+
return device.shell(f"curl -sS --max-time {int(timeout)} {shlex.quote(endpoint)}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_response(body: str) -> tuple[str | None, str | None]:
|
|
55
|
+
"""Returns (country, ip). Raises ValueError for malformed or error responses."""
|
|
56
|
+
data = json.loads(body)
|
|
57
|
+
if not isinstance(data, dict):
|
|
58
|
+
raise ValueError("response is not a JSON object")
|
|
59
|
+
if data.get("error"):
|
|
60
|
+
raise ValueError("provider reported an error")
|
|
61
|
+
country = next((v.strip().upper() for k in COUNTRY_KEYS
|
|
62
|
+
if isinstance(v := data.get(k), str) and len(v.strip()) == 2 and v.strip().isalpha()), None)
|
|
63
|
+
ip = next((v for k in IP_KEYS if isinstance(v := data.get(k), str)), None)
|
|
64
|
+
return country, ip
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def lookup(endpoint: str, cache_path: Path, ttl_hours: float, redact: bool = True, now=time.time,
|
|
68
|
+
opener=urllib.request.urlopen, timeout: float = 8.0, fetch=None, vantage: str = "host") -> dict:
|
|
69
|
+
"""Returns {country, network, cached, error, vantage}. `network` is the redacted IP prefix (or None)."""
|
|
70
|
+
base = {"vantage": vantage}
|
|
71
|
+
if not endpoint.lower().startswith("https://"):
|
|
72
|
+
return {**base, "country": None, "network": None, "cached": False, "error": "EndpointMustBeHttps"}
|
|
73
|
+
key = hashlib.sha256(f"{vantage}|{endpoint}".encode()).hexdigest()[:16]
|
|
74
|
+
cache = _load(cache_path)
|
|
75
|
+
hit = cache.get(key)
|
|
76
|
+
if hit and now() - hit.get("at", 0) < ttl_hours * 3600:
|
|
77
|
+
return {**base, **hit["value"], "cached": True, "error": None}
|
|
78
|
+
try:
|
|
79
|
+
body = fetch(endpoint, timeout) if fetch else host_fetch(endpoint, timeout, opener)
|
|
80
|
+
country, ip = parse_response(body)
|
|
81
|
+
except Exception as e: # network, TLS, adb and JSON errors all mean "unknown", never a guess
|
|
82
|
+
return {**base, "country": None, "network": None, "cached": False, "error": type(e).__name__}
|
|
83
|
+
value = {"country": country, "network": redact_ip(ip) if redact else (ip if isinstance(ip, str) else None)}
|
|
84
|
+
if country is None: # a response without a usable country is not cached as a success
|
|
85
|
+
return {**base, **value, "cached": False, "error": "NoCountryInResponse"}
|
|
86
|
+
cache[key] = {"at": now(), "value": value}
|
|
87
|
+
try:
|
|
88
|
+
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
cache_path.write_text(json.dumps(cache), encoding="utf-8")
|
|
90
|
+
except OSError:
|
|
91
|
+
pass
|
|
92
|
+
return {**base, **value, "cached": False, "error": None}
|
traceveil/netenv.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Network-environment signals: VPN, proxy and geography hints. Signals only, never proof.
|
|
2
|
+
|
|
3
|
+
Parsing rule learned from a real iQOO dump: `dumpsys connectivity` contains hundreds of NetworkRequest lines that
|
|
4
|
+
mention VPN (`NOT_VPN`, `Transports: ...|VPN`). Only NetworkAgentInfo lines under "Current Networks:" describe real
|
|
5
|
+
networks, so everything else is ignored. Raw lines are never stored because they carry SSID/BSSID/MAC.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
LIMITATIONS = (
|
|
14
|
+
"IP geolocation is approximate and not proof of physical location. "
|
|
15
|
+
"VPN/proxy detection can miss split-tunnel, per-app and in-app proxies, and can false-positive on carrier or enterprise networks."
|
|
16
|
+
)
|
|
17
|
+
TUNNEL_PREFIXES = ("tun", "wg", "ppp", "ipsec", "tap")
|
|
18
|
+
OWN_PACKAGE = "com.dpdpxray.app"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Network:
|
|
23
|
+
network_id: str
|
|
24
|
+
kind: str
|
|
25
|
+
transports: list[str]
|
|
26
|
+
interface: str
|
|
27
|
+
owner_uid: int | None
|
|
28
|
+
proxy: str | None
|
|
29
|
+
is_default: bool
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def parse_networks(dump: str) -> list[Network]:
|
|
33
|
+
default = re.search(r"Active default network:\s*(\d+)", dump)
|
|
34
|
+
default_id = default.group(1) if default else None
|
|
35
|
+
out, inside = [], False
|
|
36
|
+
for line in dump.replace("\r", "").splitlines():
|
|
37
|
+
if line.startswith("Current Networks:"):
|
|
38
|
+
inside = True
|
|
39
|
+
continue
|
|
40
|
+
if inside and line and not line.startswith(" "):
|
|
41
|
+
break
|
|
42
|
+
if not (inside and "NetworkAgentInfo{" in line):
|
|
43
|
+
continue
|
|
44
|
+
nid = re.search(r"network\{(\d+)\}", line)
|
|
45
|
+
kind = re.search(r"ni\{(\w+)", line)
|
|
46
|
+
transports = re.search(r"Transports:\s*([A-Z_|]+)", line)
|
|
47
|
+
iface = re.search(r"InterfaceName:\s*(\S+)", line)
|
|
48
|
+
owner = re.search(r"OwnerUid:\s*(-?\d+)", line)
|
|
49
|
+
proxy = re.search(r"HttpProxy:\s*(\[[^\]]*\]\s*\d+|\S+://\S+)", line)
|
|
50
|
+
out.append(Network(
|
|
51
|
+
network_id=nid.group(1) if nid else "?",
|
|
52
|
+
kind=kind.group(1) if kind else "?",
|
|
53
|
+
transports=transports.group(1).split("|") if transports else [],
|
|
54
|
+
interface=iface.group(1) if iface else "",
|
|
55
|
+
owner_uid=int(owner.group(1)) if owner else None,
|
|
56
|
+
proxy=proxy.group(1) if proxy else None,
|
|
57
|
+
is_default=bool(nid) and nid.group(1) == default_id,
|
|
58
|
+
))
|
|
59
|
+
return out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class Signal:
|
|
64
|
+
name: str
|
|
65
|
+
strength: str # strong | medium | weak
|
|
66
|
+
detail: str
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class Snapshot:
|
|
71
|
+
"""What the device reported. A None field means unknown, which is never treated as 'absent'."""
|
|
72
|
+
networks: list[Network] | None = None
|
|
73
|
+
own_uid: int | None = None
|
|
74
|
+
global_proxy: str | None = None
|
|
75
|
+
always_on_vpn: str | None = None
|
|
76
|
+
always_on_lockdown: str | None = None
|
|
77
|
+
private_dns_mode: str | None = None
|
|
78
|
+
interfaces: list[str] | None = None
|
|
79
|
+
sim_country: str | None = None
|
|
80
|
+
network_country: str | None = None
|
|
81
|
+
timezone: str | None = None
|
|
82
|
+
locale: str | None = None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _country(value: str | None) -> str | None:
|
|
86
|
+
if not value:
|
|
87
|
+
return None
|
|
88
|
+
first = value.split(",")[0].strip().upper()
|
|
89
|
+
return first if len(first) == 2 and first.isalpha() else None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def parse_interfaces(ip_addr: str) -> list[str]:
|
|
93
|
+
names = []
|
|
94
|
+
for line in ip_addr.replace("\r", "").splitlines():
|
|
95
|
+
m = re.match(r"\s*\d+:\s+([^\s:@]+)", line)
|
|
96
|
+
if m and m.group(1) not in names:
|
|
97
|
+
names.append(m.group(1))
|
|
98
|
+
return names
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def collect(device) -> Snapshot:
|
|
102
|
+
"""Gather a snapshot through adb; every field degrades to None on failure."""
|
|
103
|
+
def safe(fn):
|
|
104
|
+
try:
|
|
105
|
+
return fn()
|
|
106
|
+
except Exception:
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
snap = Snapshot()
|
|
110
|
+
dump = safe(lambda: device.shell("dumpsys connectivity"))
|
|
111
|
+
if dump and "Current Networks:" in dump:
|
|
112
|
+
snap.networks = parse_networks(dump)
|
|
113
|
+
uid_line = safe(lambda: device.shell(f"pm list packages -U {OWN_PACKAGE}"))
|
|
114
|
+
m = re.search(r"uid:(\d+)", uid_line or "")
|
|
115
|
+
snap.own_uid = int(m.group(1)) if m else None
|
|
116
|
+
snap.global_proxy = safe(lambda: device.setting("global", "http_proxy"))
|
|
117
|
+
snap.always_on_vpn = safe(lambda: device.setting("secure", "always_on_vpn_app"))
|
|
118
|
+
snap.always_on_lockdown = safe(lambda: device.setting("secure", "always_on_vpn_lockdown"))
|
|
119
|
+
snap.private_dns_mode = safe(lambda: device.setting("global", "private_dns_mode"))
|
|
120
|
+
ip = safe(lambda: device.shell("ip -o addr"))
|
|
121
|
+
snap.interfaces = parse_interfaces(ip) if ip else None
|
|
122
|
+
snap.sim_country = _country(safe(lambda: device.getprop("gsm.sim.operator.iso-country")))
|
|
123
|
+
snap.network_country = _country(safe(lambda: device.getprop("gsm.operator.iso-country")))
|
|
124
|
+
snap.timezone = safe(lambda: device.getprop("persist.sys.timezone")) or None
|
|
125
|
+
snap.locale = safe(lambda: device.getprop("persist.sys.locale")) or None
|
|
126
|
+
return snap
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def derive_signals(snap: Snapshot, exclude_own_vpn: bool = True) -> list[Signal]:
|
|
130
|
+
signals: list[Signal] = []
|
|
131
|
+
for net in snap.networks or []:
|
|
132
|
+
is_vpn = "VPN" in net.transports or net.kind == "VPN"
|
|
133
|
+
own = exclude_own_vpn and snap.own_uid is not None and net.owner_uid == snap.own_uid
|
|
134
|
+
if is_vpn and not own:
|
|
135
|
+
signals.append(Signal("vpn_transport", "strong", f"network {net.network_id} has VPN transport"
|
|
136
|
+
+ (" (active default)" if net.is_default else "")))
|
|
137
|
+
if net.proxy:
|
|
138
|
+
signals.append(Signal("link_proxy", "strong", f"network {net.network_id} carries an HTTP proxy"))
|
|
139
|
+
if snap.global_proxy:
|
|
140
|
+
signals.append(Signal("global_proxy", "strong", "global HTTP proxy is configured"))
|
|
141
|
+
if snap.always_on_vpn:
|
|
142
|
+
signals.append(Signal("always_on_vpn", "strong", "an always-on VPN app is configured"))
|
|
143
|
+
tunnels = [i for i in snap.interfaces or [] if i.startswith(TUNNEL_PREFIXES)]
|
|
144
|
+
if tunnels and not _tunnels_explained_by_own_vpn(snap, tunnels):
|
|
145
|
+
signals.append(Signal("tunnel_interface", "medium", f"tunnel-like interface present: {', '.join(tunnels)}"))
|
|
146
|
+
if snap.private_dns_mode != "off":
|
|
147
|
+
mode = snap.private_dns_mode or "unset (Automatic)"
|
|
148
|
+
signals.append(Signal("private_dns", "weak", f"Private DNS mode is {mode}: encrypted DNS can limit capture (not a VPN)"))
|
|
149
|
+
return signals
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _tunnels_explained_by_own_vpn(snap: Snapshot, tunnels: list[str]) -> bool:
|
|
153
|
+
"""Our own capture VPN shows up as tun0. If an own-uid VPN network exists and is the only VPN, don't flag it."""
|
|
154
|
+
nets = snap.networks or []
|
|
155
|
+
own_vpn = [n for n in nets if ("VPN" in n.transports) and n.owner_uid == snap.own_uid and snap.own_uid is not None]
|
|
156
|
+
other_vpn = [n for n in nets if ("VPN" in n.transports) and n not in own_vpn]
|
|
157
|
+
return bool(own_vpn) and not other_vpn and len(tunnels) <= len(own_vpn)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# Minimal timezone->country map for the zones that matter in practice; unknown zones stay unknown.
|
|
161
|
+
TZ_COUNTRY = {"Asia/Kolkata": "IN", "Asia/Calcutta": "IN", "America/New_York": "US", "America/Chicago": "US",
|
|
162
|
+
"America/Denver": "US", "America/Los_Angeles": "US", "Europe/London": "GB", "Europe/Berlin": "DE",
|
|
163
|
+
"Europe/Paris": "FR", "Asia/Singapore": "SG", "Asia/Dubai": "AE", "Asia/Tokyo": "JP",
|
|
164
|
+
"Australia/Sydney": "AU", "Asia/Dhaka": "BD", "Asia/Karachi": "PK", "Asia/Colombo": "LK",
|
|
165
|
+
"Asia/Kathmandu": "NP"}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@dataclass
|
|
169
|
+
class Check:
|
|
170
|
+
id: str
|
|
171
|
+
status: str # none | possible | likely | unknown
|
|
172
|
+
confidence: str # low | medium | high
|
|
173
|
+
signals: list[str] = field(default_factory=list)
|
|
174
|
+
reasons: list[str] = field(default_factory=list)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def check_vpn_proxy(snap: Snapshot, signals: list[Signal]) -> Check:
|
|
178
|
+
relevant = [s for s in signals if s.name != "private_dns"]
|
|
179
|
+
strong = [s for s in relevant if s.strength == "strong"]
|
|
180
|
+
if strong:
|
|
181
|
+
return Check("vpn_proxy", "likely", "high" if len(strong) > 1 or snap.networks else "medium",
|
|
182
|
+
[s.detail for s in relevant], ["a strong signal was reported by Android itself"])
|
|
183
|
+
if relevant:
|
|
184
|
+
return Check("vpn_proxy", "possible", "low", [s.detail for s in relevant], ["only indirect signals"])
|
|
185
|
+
if snap.networks is None and snap.interfaces is None:
|
|
186
|
+
return Check("vpn_proxy", "unknown", "low", [], ["connectivity data could not be read"])
|
|
187
|
+
return Check("vpn_proxy", "none", "medium" if snap.networks is not None else "low", [],
|
|
188
|
+
["no VPN/proxy signal seen; this does not rule out split-tunnel or in-app proxies"])
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def device_countries(snap: Snapshot) -> dict[str, str]:
|
|
192
|
+
out = {}
|
|
193
|
+
if snap.sim_country:
|
|
194
|
+
out["sim"] = snap.sim_country
|
|
195
|
+
if snap.network_country:
|
|
196
|
+
out["network"] = snap.network_country
|
|
197
|
+
tz = TZ_COUNTRY.get(snap.timezone or "")
|
|
198
|
+
if tz:
|
|
199
|
+
out["timezone"] = tz
|
|
200
|
+
return out
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def check_geo(snap: Snapshot, egress_country: str | None, vpn: Check, vantage: str = "phone") -> Check:
|
|
204
|
+
check = _check_geo(snap, egress_country, vpn)
|
|
205
|
+
if vantage == "host" and check.status in ("none", "possible", "likely"):
|
|
206
|
+
# The laptop's egress says nothing certain about the phone (it may be on cellular or a VPN).
|
|
207
|
+
check.status = "possible" if check.status != "none" else "none"
|
|
208
|
+
check.confidence = "low"
|
|
209
|
+
check.reasons.append("egress was measured from the host machine, not the phone")
|
|
210
|
+
return check
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _check_geo(snap: Snapshot, egress_country: str | None, vpn: Check) -> Check:
|
|
214
|
+
ctx = device_countries(snap)
|
|
215
|
+
if egress_country is None:
|
|
216
|
+
return Check("geo_mismatch", "unknown", "low", [],
|
|
217
|
+
["no egress country (offline by default; use --online-ip to enable one lookup)"])
|
|
218
|
+
if not ctx:
|
|
219
|
+
return Check("geo_mismatch", "unknown", "low", [], ["no SIM/network/timezone country available to compare"])
|
|
220
|
+
egress = egress_country.upper()
|
|
221
|
+
disagree = {k: v for k, v in ctx.items() if v != egress}
|
|
222
|
+
if not disagree:
|
|
223
|
+
return Check("geo_mismatch", "none", "medium", [f"egress {egress} agrees with {', '.join(ctx)}"], [])
|
|
224
|
+
sigs = [f"egress {egress} differs from {k} {v}" for k, v in disagree.items()]
|
|
225
|
+
if len(disagree) == len(ctx) and len(ctx) >= 2 and vpn.status == "likely":
|
|
226
|
+
return Check("geo_mismatch", "likely", "medium", sigs, ["VPN signal plus multiple device-context countries disagree with egress"])
|
|
227
|
+
conf = "medium" if vpn.status in ("likely", "possible") else "low"
|
|
228
|
+
return Check("geo_mismatch", "possible", conf, sigs,
|
|
229
|
+
["travel, roaming, corporate networks and CDNs can also explain this"])
|
traceveil/report.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""JSON envelope and terminal summary for the network-environment check."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import asdict
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .netenv import LIMITATIONS, Check, Signal, Snapshot, device_countries
|
|
11
|
+
|
|
12
|
+
SCHEMA_VERSION = 1
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def serial_hash(serial: str) -> str:
|
|
16
|
+
return hashlib.sha256(serial.encode()).hexdigest()[:12]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def build_env_report(device_info: dict, snap: Snapshot, signals: list[Signal], checks: list[Check],
|
|
20
|
+
egress: dict | None, verdict: dict) -> dict:
|
|
21
|
+
return {
|
|
22
|
+
"schemaVersion": SCHEMA_VERSION,
|
|
23
|
+
"tool": {"name": "traceveil", "version": __version__},
|
|
24
|
+
"device": device_info,
|
|
25
|
+
"networkEnvironment": {
|
|
26
|
+
"checks": [asdict(c) for c in checks],
|
|
27
|
+
"signals": [asdict(s) for s in signals],
|
|
28
|
+
"deviceCountries": device_countries(snap),
|
|
29
|
+
"egress": egress,
|
|
30
|
+
"privateDnsMode": snap.private_dns_mode,
|
|
31
|
+
"limitations": LIMITATIONS,
|
|
32
|
+
},
|
|
33
|
+
"verdict": verdict,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def dumps(report: dict) -> str:
|
|
38
|
+
return json.dumps(report, indent=2, sort_keys=False)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def summary(report: dict) -> str:
|
|
42
|
+
env = report["networkEnvironment"]
|
|
43
|
+
dev = report["device"]
|
|
44
|
+
lines = [f"Traceveil network environment ({dev.get('model', '?')}, Android {dev.get('androidVersion', '?')})"]
|
|
45
|
+
for c in env["checks"]:
|
|
46
|
+
lines.append(f" {c['id']:<13} {c['status']:<9} confidence {c['confidence']}")
|
|
47
|
+
for s in c["signals"]:
|
|
48
|
+
lines.append(f" - {s}")
|
|
49
|
+
for r in c["reasons"]:
|
|
50
|
+
lines.append(f" · {r}")
|
|
51
|
+
if env["deviceCountries"]:
|
|
52
|
+
lines.append(" device context: " + ", ".join(f"{k}={v}" for k, v in env["deviceCountries"].items()))
|
|
53
|
+
eg = env["egress"]
|
|
54
|
+
if eg:
|
|
55
|
+
lines.append(f" egress ({eg.get('vantage', '?')}): {eg.get('country') or 'unknown'}"
|
|
56
|
+
+ (f" ({eg['network']})" if eg.get("network") else "")
|
|
57
|
+
+ (f" [lookup failed: {eg['error']}]" if eg.get("error") else ""))
|
|
58
|
+
v = report["verdict"]
|
|
59
|
+
lines.append(f" verdict: {v['status']}" + (f" - {'; '.join(v['reasons'])}" if v["reasons"] else ""))
|
|
60
|
+
lines.append(f" note: {env['limitations']}")
|
|
61
|
+
return "\n".join(lines)
|
traceveil/shizuku.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Shizuku server detection and adb start. The manager app process is NOT the server: the server runs as
|
|
2
|
+
`shell` (or root) with the process name `shizuku_server`, which is what we look for."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
PACKAGE = "moe.shizuku.privileged.api"
|
|
10
|
+
SERVER_NAME = "shizuku_server"
|
|
11
|
+
ABI_DIR = {"arm64-v8a": "arm64", "armeabi-v7a": "arm", "armeabi": "arm", "x86_64": "x86_64", "x86": "x86"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def is_running(device) -> bool:
|
|
15
|
+
out = device.shell("ps -A -o USER,NAME")
|
|
16
|
+
return any(len(p) >= 2 and p[0] in ("shell", "root") and p[-1] == SERVER_NAME
|
|
17
|
+
for p in (line.split() for line in out.splitlines()))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def starter_path(device) -> str | None:
|
|
21
|
+
"""Path of Shizuku's own starter binary inside its install dir, or None if Shizuku isn't installed."""
|
|
22
|
+
m = re.search(r"package:(/\S+)/base\.apk", device.shell(f"pm path {PACKAGE}"))
|
|
23
|
+
if not m:
|
|
24
|
+
return None
|
|
25
|
+
abi = ABI_DIR.get(device.getprop("ro.product.cpu.abi"), "arm64")
|
|
26
|
+
return f"{m.group(1)}/lib/{abi}/libshizuku.so"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def start(device, wait: float = 6.0, sleep=time.sleep) -> tuple[bool, str]:
|
|
30
|
+
"""Starts the server over adb if needed. Returns (running, message). Never raises for 'not installed'."""
|
|
31
|
+
if is_running(device):
|
|
32
|
+
return True, "already running"
|
|
33
|
+
path = starter_path(device)
|
|
34
|
+
if path is None:
|
|
35
|
+
return False, f"{PACKAGE} is not installed"
|
|
36
|
+
device.shell(path)
|
|
37
|
+
deadline = max(1, int(wait / 0.5))
|
|
38
|
+
for _ in range(deadline):
|
|
39
|
+
sleep(0.5)
|
|
40
|
+
if is_running(device):
|
|
41
|
+
return True, "started over adb"
|
|
42
|
+
return False, f"ran {path} but no {SERVER_NAME} process appeared; open Shizuku and use its adb start, or check USB debugging (Security settings)"
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: traceveil
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Developer CLI that runs Traceveil privacy/consent audits on an Android phone over adb and saves JSON reports.
|
|
5
|
+
Author: Traceveil contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/agrimsri/IQOO-Kernel-Panic
|
|
8
|
+
Keywords: android,adb,privacy,consent,dpdp,audit
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Security
|
|
18
|
+
Classifier: Topic :: Software Development :: Testing
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# Traceveil CLI
|
|
25
|
+
|
|
26
|
+
Run a Traceveil privacy audit on your Android phone from your laptop, and get a JSON report back. Everything goes over USB (adb); nothing is uploaded.
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
laptop phone (debug build of Traceveil)
|
|
30
|
+
traceveil audit --adb broadcast--> DebugAuditReceiver -> runs the normal audit
|
|
31
|
+
^ |
|
|
32
|
+
| adb shell cat v
|
|
33
|
+
+-------- report.json <--- /sdcard/Android/data/com.dpdpxray.app/files/reports/<id>/
|
|
34
|
+
saves .traceveil/reports/<id>.json
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The trigger exists **only in debug builds**. Release builds have no way to start an audit from adb.
|
|
38
|
+
|
|
39
|
+
## 1. One-time setup
|
|
40
|
+
|
|
41
|
+
You need: Python 3.11+, JDK 17, Android platform-tools (adb), a phone with USB debugging on.
|
|
42
|
+
|
|
43
|
+
Install the CLI (from PyPI once published, or from a checkout):
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pipx install traceveil # or: pip install traceveil
|
|
47
|
+
pip install -e . # from a checkout; `python3 -m traceveil` also works
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The examples below use `python3 -m traceveil`; the installed `traceveil` command is identical.
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
# 1. Build and install the debug app (run from the repo root)
|
|
54
|
+
bash ./gradlew :app:assembleDebug :leakyshop:assembleLeakyDebug
|
|
55
|
+
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
|
56
|
+
adb install -r leakyshop/build/outputs/apk/leaky/debug/leakyshop-leaky-debug.apk # demo app to audit
|
|
57
|
+
|
|
58
|
+
# 2. Check the phone is ready
|
|
59
|
+
python3 -m traceveil doctor
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Under **WSL2** the Linux `adb` cannot see USB phones. Use the Windows one for every command:
|
|
63
|
+
`--adb /mnt/c/Users/<you>/AppData/Local/Android/Sdk/platform-tools/adb.exe` (or `export ADB=...`).
|
|
64
|
+
Install the APK with that adb too, from a Windows path.
|
|
65
|
+
|
|
66
|
+
On the phone, once:
|
|
67
|
+
1. **Open Traceveil** and allow the **VPN** permission (Android only delivers the trigger to an app that has been opened).
|
|
68
|
+
2. Turn on the **Traceveil accessibility service** (Settings > Accessibility).
|
|
69
|
+
3. Set **Private DNS = Off** (otherwise lookups are hidden from the audit).
|
|
70
|
+
|
|
71
|
+
## 2. Run an audit
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
python3 -m traceveil audit --package com.leakyshop.demo
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
With several phones attached add `--serial <serial>` (`adb devices` lists them).
|
|
78
|
+
|
|
79
|
+
What happens, step by step:
|
|
80
|
+
|
|
81
|
+
1. The CLI checks Traceveil is installed and sends the trigger. You see `audit started: cli-<time>-<id>`.
|
|
82
|
+
2. The phone runs the audit (about 1 to 3 minutes for three plans). By default **you tap Accept / Reject on the phone** when asked. Add `--auto-drive` to let Traceveil tap.
|
|
83
|
+
3. The CLI waits (default 15 min, change with `--wait <seconds>`), then reads `report.json` from the phone and checks it is valid.
|
|
84
|
+
4. It saves the report to `.traceveil/reports/<request_id>.json` and prints a summary:
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
Traceveil audit LeakyShop (com.leakyshop.demo) score 21 / AT_RISK findings 5
|
|
88
|
+
C1 HIGH Trackers contacted before consent
|
|
89
|
+
...
|
|
90
|
+
policy: 3 finding(s) at or above high
|
|
91
|
+
report saved: .traceveil/reports/cli-20261010T071219Z-0f9239c5.json
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Useful options:
|
|
95
|
+
|
|
96
|
+
| Option | What it does |
|
|
97
|
+
|---|---|
|
|
98
|
+
| `--plans silent,reject,accept` | Pick plans (default all three; one plan is quick but often gives an inconclusive score) |
|
|
99
|
+
| `--json` | Print the full JSON instead of the summary |
|
|
100
|
+
| `--out report.json` | Also write the JSON to a file you choose |
|
|
101
|
+
| `--auto-drive` | Traceveil taps Accept/Reject itself |
|
|
102
|
+
| `--reset` | Clear the app's data first. **Only for your own test apps**; needs Shizuku |
|
|
103
|
+
| `--resume <request_id>` | Fetch the result of an earlier run (after a timeout or unplug) |
|
|
104
|
+
|
|
105
|
+
Settings you use often can live in `traceveil.toml` (`python3 -m traceveil init` creates it): `target.package`, `audit.fail_on`, `audit.min_score`, `output.dir`.
|
|
106
|
+
|
|
107
|
+
## 3. The report
|
|
108
|
+
|
|
109
|
+
```json
|
|
110
|
+
{
|
|
111
|
+
"schemaVersion": 1,
|
|
112
|
+
"requestId": "cli-20261010T071219Z-0f9239c5",
|
|
113
|
+
"auditId": "audit-1791616339835",
|
|
114
|
+
"target": { "package": "com.leakyshop.demo", "label": "LeakyShop" },
|
|
115
|
+
"device": { "model": "vivo I2501", "androidVersion": "16" },
|
|
116
|
+
"score": 21,
|
|
117
|
+
"band": "AT_RISK",
|
|
118
|
+
"findingCount": 5,
|
|
119
|
+
"findings": [
|
|
120
|
+
{ "check": "C1", "severity": "HIGH", "title": "Trackers contacted before consent", "confidence": 1.0 }
|
|
121
|
+
],
|
|
122
|
+
"sessions": [ { "plan": "REJECT", "netEvents": 12, "consentChoice": "REJECT", "notes": [] } ]
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`score` is `null` (never 0) when the result is inconclusive. The report is a technical assessment, not legal advice.
|
|
127
|
+
|
|
128
|
+
## 4. Exit codes (for scripts and CI)
|
|
129
|
+
|
|
130
|
+
| Code | Meaning |
|
|
131
|
+
|---|---|
|
|
132
|
+
| 0 | Finished, policy passed |
|
|
133
|
+
| 1 | Policy failed (score below `min_score`, or a finding at/above `fail_on`) |
|
|
134
|
+
| 2 | Inconclusive, app-reported failure, or missing/malformed report |
|
|
135
|
+
| 3 | Device or trigger problem (no phone, app missing, VPN/accessibility off, another audit running) |
|
|
136
|
+
| 4 | Bad arguments |
|
|
137
|
+
| 6 | Timed out (the audit keeps running; use `--resume`) |
|
|
138
|
+
|
|
139
|
+
## 5. If something goes wrong
|
|
140
|
+
|
|
141
|
+
- **Phone unplugged mid-audit**: the CLI prints a yellow message. Reconnect, unlock the phone, press Enter to retry (or `q` to quit). The audit keeps running on the phone; `--resume <request_id>` picks it up later.
|
|
142
|
+
- **"no debug trigger answered"**: install the *debug* APK and open Traceveil once. A force-stopped app does not receive the trigger.
|
|
143
|
+
- **"VPN permission not granted" / "accessibility service is off"**: do steps 1 and 2 of the phone setup.
|
|
144
|
+
- **Timeout**: raise `--wait`, or run again with `--resume <id>`.
|
|
145
|
+
- **Device "unauthorized"**: unlock the phone and tap Allow on the USB debugging prompt.
|
|
146
|
+
|
|
147
|
+
Safety: audit only your own test apps (like the LeakyShop demos). The CLI never force-stops anything and never uploads data.
|
|
148
|
+
|
|
149
|
+
## 6. Tests
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
pip install -e . && python3 -m unittest tools.tests.test_traceveil_audit tools.tests.test_traceveil_cli # CLI, no phone needed
|
|
153
|
+
bash ./gradlew :core:test :app:testDebugUnitTest # Android side
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
More detail (design, limits, device evidence): `docs/CLI_AUDIT.md`.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
traceveil/__init__.py,sha256=8tYaJ8lYY0iaXJVOGrgpIfmS2nSeifFy6Hu9UkA-bY8,310
|
|
2
|
+
traceveil/__main__.py,sha256=E6Gls0DNz8GQK2K-kOUIx8cYhgANW_CH54VKrfCfs14,52
|
|
3
|
+
traceveil/adb.py,sha256=xfDBwjE10GsRRKxonQKYc08BVctWaC6NrV_ivL1g0Ig,6152
|
|
4
|
+
traceveil/audit.py,sha256=H57DsEt2-PmC11liLduawyGKlryL_hKKfJzz-Vc7ryg,9680
|
|
5
|
+
traceveil/cli.py,sha256=fZxnZfiJcGJyMnYCpDa1ORjb8CqpFWe6JYQ2n7j31WA,8450
|
|
6
|
+
traceveil/config.py,sha256=R47Doi84LDwciBm2UOuW_bqpv2MIxd6c6drs6mVLAq8,3380
|
|
7
|
+
traceveil/egress.py,sha256=V9v8BzbeGEHZ4O81UHzi3zqtzvECEilXEleA_RWQkXg,4135
|
|
8
|
+
traceveil/netenv.py,sha256=5b6NoScfjiwvFLMD-stzze2Bjd_Rro7zNOgnbEFNg1M,10498
|
|
9
|
+
traceveil/report.py,sha256=5ExCKKrsGvr7e8WY7Ziy65GpFKlfXkXxL2fZ8LUG91Y,2279
|
|
10
|
+
traceveil/shizuku.py,sha256=BZ_99ln1XQ_RqKbHr_Ed3IiNKMHyd2ngjyT2wYJZLO8,1741
|
|
11
|
+
traceveil-0.1.0.dist-info/licenses/LICENSE,sha256=SdN9vt9m2LFCGvu6EYCKAKmu4sYkbdp1kkqHVSxiaBo,1082
|
|
12
|
+
traceveil-0.1.0.dist-info/METADATA,sha256=v4CCJADHmSUeYWTG4tx60T7DUsSGVx0lIzKje34GcKg,6790
|
|
13
|
+
traceveil-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
14
|
+
traceveil-0.1.0.dist-info/entry_points.txt,sha256=q41iIPTV5MIFhsPf173xeKw8uJEOBdJQAj3InDNXQ5I,49
|
|
15
|
+
traceveil-0.1.0.dist-info/top_level.txt,sha256=8VfIrQXukOZOLgcMB1RG9CGajpLEjNpDQ82hLaLUBEY,10
|
|
16
|
+
traceveil-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 nitya-prakash-pandey-2005
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
traceveil
|