los-bootstrap 0.9.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.
- los_bootstrap/__init__.py +3 -0
- los_bootstrap/adb.py +160 -0
- los_bootstrap/apply.py +187 -0
- los_bootstrap/audit/__init__.py +6 -0
- los_bootstrap/audit/checks.py +226 -0
- los_bootstrap/audit/models.py +34 -0
- los_bootstrap/bootstrap.py +33 -0
- los_bootstrap/camera/__init__.py +35 -0
- los_bootstrap/camera/models.py +38 -0
- los_bootstrap/camera/profiles.py +285 -0
- los_bootstrap/camera/report.py +64 -0
- los_bootstrap/cli.py +731 -0
- los_bootstrap/device.py +49 -0
- los_bootstrap/fetch.py +94 -0
- los_bootstrap/flash/__init__.py +83 -0
- los_bootstrap/flash/checks.py +115 -0
- los_bootstrap/flash/fastboot.py +109 -0
- los_bootstrap/flash/flash.py +105 -0
- los_bootstrap/flash/guide.py +313 -0
- los_bootstrap/flash/heimdall.py +81 -0
- los_bootstrap/flash/models.py +73 -0
- los_bootstrap/flash/plan.py +212 -0
- los_bootstrap/flash/report.py +150 -0
- los_bootstrap/harden/__init__.py +21 -0
- los_bootstrap/harden/checks.py +353 -0
- los_bootstrap/harden/interactive.py +101 -0
- los_bootstrap/harden/models.py +38 -0
- los_bootstrap/harden/report.py +103 -0
- los_bootstrap/location/__init__.py +26 -0
- los_bootstrap/location/checks.py +278 -0
- los_bootstrap/location/compat.py +137 -0
- los_bootstrap/location/models.py +36 -0
- los_bootstrap/location/report.py +131 -0
- los_bootstrap/logo.py +25 -0
- los_bootstrap/plan.py +225 -0
- los_bootstrap/profiles.py +190 -0
- los_bootstrap/profiles_data/camera.yml +32 -0
- los_bootstrap/profiles_data/max-tools.yml +177 -0
- los_bootstrap/profiles_data/messaging-light.yml +29 -0
- los_bootstrap/profiles_data/minimal.yml +28 -0
- los_bootstrap/profiles_data/privacy-default.yml +57 -0
- los_bootstrap/py.typed +0 -0
- los_bootstrap/report.py +118 -0
- los_bootstrap/wizard/__init__.py +9 -0
- los_bootstrap/wizard/menu.py +474 -0
- los_bootstrap/wizard/prompt.py +80 -0
- los_bootstrap/wizard/prose.py +396 -0
- los_bootstrap/wizard/render.py +141 -0
- los_bootstrap-0.9.0.dist-info/METADATA +1115 -0
- los_bootstrap-0.9.0.dist-info/RECORD +54 -0
- los_bootstrap-0.9.0.dist-info/WHEEL +5 -0
- los_bootstrap-0.9.0.dist-info/entry_points.txt +2 -0
- los_bootstrap-0.9.0.dist-info/licenses/LICENSE +674 -0
- los_bootstrap-0.9.0.dist-info/top_level.txt +1 -0
los_bootstrap/adb.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Thin, testable wrapper around the `adb` binary.
|
|
2
|
+
|
|
3
|
+
All ADB IO funnels through `Adb`. Tests inject a fake `runner` so no real
|
|
4
|
+
`adb` is required. Read-only methods are always available. Mutating
|
|
5
|
+
methods (`install_apk`, `setting_put`) are used by the Phase 2 applier
|
|
6
|
+
and only ever run after the user passes `--confirm`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import shlex
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Callable, Optional, Sequence
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
CommandRunner = Callable[[Sequence[str]], "AdbResult"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AdbNotFoundError(RuntimeError):
|
|
22
|
+
"""Raised when the `adb` binary cannot be located on PATH."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AdbCommandError(RuntimeError):
|
|
26
|
+
"""Raised when an `adb` invocation exits non-zero."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class AdbResult:
|
|
31
|
+
returncode: int
|
|
32
|
+
stdout: str
|
|
33
|
+
stderr: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class AdbDevice:
|
|
38
|
+
serial: str
|
|
39
|
+
state: str # "device", "unauthorized", "offline", ...
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def ready(self) -> bool:
|
|
43
|
+
return self.state == "device"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _default_runner(argv: Sequence[str]) -> AdbResult:
|
|
47
|
+
if shutil.which(argv[0]) is None:
|
|
48
|
+
raise AdbNotFoundError(f"`{argv[0]}` not found on PATH")
|
|
49
|
+
proc = subprocess.run(argv, capture_output=True, text=True, check=False)
|
|
50
|
+
return AdbResult(proc.returncode, proc.stdout, proc.stderr)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Adb:
|
|
54
|
+
"""Wrapper around `adb`. Inject `runner` in tests."""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
serial: Optional[str] = None,
|
|
59
|
+
runner: Optional[CommandRunner] = None,
|
|
60
|
+
binary: str = "adb",
|
|
61
|
+
) -> None:
|
|
62
|
+
self.serial = serial
|
|
63
|
+
self.binary = binary
|
|
64
|
+
self._run = runner or _default_runner
|
|
65
|
+
|
|
66
|
+
def _argv(self, *args: str) -> list[str]:
|
|
67
|
+
argv = [self.binary]
|
|
68
|
+
if self.serial:
|
|
69
|
+
argv += ["-s", self.serial]
|
|
70
|
+
argv += list(args)
|
|
71
|
+
return argv
|
|
72
|
+
|
|
73
|
+
def raw(self, *args: str) -> AdbResult:
|
|
74
|
+
return self._run(self._argv(*args))
|
|
75
|
+
|
|
76
|
+
def shell(self, command: str) -> str:
|
|
77
|
+
result = self.raw("shell", command)
|
|
78
|
+
if result.returncode != 0:
|
|
79
|
+
raise AdbCommandError(
|
|
80
|
+
f"adb shell `{command}` failed: {result.stderr.strip()}"
|
|
81
|
+
)
|
|
82
|
+
return result.stdout
|
|
83
|
+
|
|
84
|
+
def list_devices(self) -> list[AdbDevice]:
|
|
85
|
+
result = self.raw("devices")
|
|
86
|
+
if result.returncode != 0:
|
|
87
|
+
raise AdbCommandError(f"adb devices failed: {result.stderr.strip()}")
|
|
88
|
+
return parse_devices(result.stdout)
|
|
89
|
+
|
|
90
|
+
def getprop(self, key: str) -> str:
|
|
91
|
+
return self.shell(f"getprop {key}").strip()
|
|
92
|
+
|
|
93
|
+
def package_installed(self, package: str) -> bool:
|
|
94
|
+
# `pm list packages <pkg>` returns lines of form `package:<name>`.
|
|
95
|
+
out = self.shell(f"pm list packages {package}")
|
|
96
|
+
for line in out.splitlines():
|
|
97
|
+
if line.strip() == f"package:{package}":
|
|
98
|
+
return True
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
def setting_get(self, namespace: str, key: str) -> str:
|
|
102
|
+
"""Return the current value of `settings get <namespace> <key>`.
|
|
103
|
+
|
|
104
|
+
The `settings` CLI prints `null` when a key is unset; we
|
|
105
|
+
normalize that to an empty string for easier comparison.
|
|
106
|
+
"""
|
|
107
|
+
out = self.shell(f"settings get {namespace} {key}").strip()
|
|
108
|
+
return "" if out == "null" else out
|
|
109
|
+
|
|
110
|
+
def install_apk(self, apk_path: str, replace: bool = True) -> str:
|
|
111
|
+
"""Install an APK from a local path. Mutating; applier-only."""
|
|
112
|
+
args = ["install"]
|
|
113
|
+
if replace:
|
|
114
|
+
args.append("-r")
|
|
115
|
+
args.append(apk_path)
|
|
116
|
+
result = self.raw(*args)
|
|
117
|
+
if result.returncode != 0:
|
|
118
|
+
raise AdbCommandError(
|
|
119
|
+
f"adb install {apk_path} failed: "
|
|
120
|
+
f"{(result.stderr or result.stdout).strip()}"
|
|
121
|
+
)
|
|
122
|
+
return result.stdout
|
|
123
|
+
|
|
124
|
+
def setting_put(self, namespace: str, key: str, value: str) -> None:
|
|
125
|
+
"""Run `settings put <namespace> <key> <value>`. Mutating."""
|
|
126
|
+
self.shell(
|
|
127
|
+
f"settings put {namespace} {key} {shlex.quote(value)}"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def reboot(self, target: Optional[str] = None) -> None:
|
|
131
|
+
"""Reboot device, optionally to 'bootloader' or 'recovery'. Mutating."""
|
|
132
|
+
args = ["reboot"]
|
|
133
|
+
if target:
|
|
134
|
+
args.append(target)
|
|
135
|
+
result = self.raw(*args)
|
|
136
|
+
if result.returncode != 0:
|
|
137
|
+
raise AdbCommandError(f"adb reboot failed: {result.stderr.strip()}")
|
|
138
|
+
|
|
139
|
+
def sideload(self, zip_path: str) -> str:
|
|
140
|
+
"""Run `adb sideload <zip>`. Device must be in recovery sideload mode. Mutating."""
|
|
141
|
+
result = self.raw("sideload", zip_path)
|
|
142
|
+
if result.returncode != 0:
|
|
143
|
+
raise AdbCommandError(
|
|
144
|
+
f"adb sideload {zip_path} failed: "
|
|
145
|
+
f"{(result.stderr or result.stdout).strip()}"
|
|
146
|
+
)
|
|
147
|
+
return result.stdout
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def parse_devices(stdout: str) -> list[AdbDevice]:
|
|
151
|
+
"""Parse the output of `adb devices`."""
|
|
152
|
+
devices: list[AdbDevice] = []
|
|
153
|
+
for line in stdout.splitlines():
|
|
154
|
+
line = line.strip()
|
|
155
|
+
if not line or line.startswith("List of devices"):
|
|
156
|
+
continue
|
|
157
|
+
parts = line.split()
|
|
158
|
+
if len(parts) >= 2:
|
|
159
|
+
devices.append(AdbDevice(serial=parts[0], state=parts[1]))
|
|
160
|
+
return devices
|
los_bootstrap/apply.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Execute a `Plan`.
|
|
2
|
+
|
|
3
|
+
The applier only ever runs after the user passes `--confirm`. Each
|
|
4
|
+
executable step is written to stdout before it runs so the user has a
|
|
5
|
+
last chance to spot something they did not expect.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional, TextIO
|
|
16
|
+
|
|
17
|
+
from .adb import Adb, AdbCommandError
|
|
18
|
+
from . import fetch
|
|
19
|
+
from .fetch import FetchError
|
|
20
|
+
from .plan import Plan, PlanStep, StepKind
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class StepResult:
|
|
25
|
+
step: PlanStep
|
|
26
|
+
status: str # "ok" | "skipped" | "manual" | "missing_apk" | "error"
|
|
27
|
+
detail: str = ""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class ApplyResult:
|
|
32
|
+
profile_name: str
|
|
33
|
+
results: list[StepResult] = field(default_factory=list)
|
|
34
|
+
|
|
35
|
+
def counts(self) -> dict[str, int]:
|
|
36
|
+
out: dict[str, int] = {}
|
|
37
|
+
for r in self.results:
|
|
38
|
+
out[r.status] = out.get(r.status, 0) + 1
|
|
39
|
+
return out
|
|
40
|
+
|
|
41
|
+
def had_errors(self) -> bool:
|
|
42
|
+
return any(r.status == "error" for r in self.results)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _extract_apk_path(command: str) -> Optional[Path]:
|
|
46
|
+
# `adb install -r /abs/path/to.apk` — pull out the last token.
|
|
47
|
+
m = re.match(r"^adb install(?:\s+-r)?\s+(.+)$", command)
|
|
48
|
+
if not m:
|
|
49
|
+
return None
|
|
50
|
+
return Path(m.group(1))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _extract_setting(command: str) -> Optional[tuple[str, str, str]]:
|
|
54
|
+
m = re.match(
|
|
55
|
+
r"^adb shell settings put (\w+) (\S+) (.+)$",
|
|
56
|
+
command,
|
|
57
|
+
)
|
|
58
|
+
if not m:
|
|
59
|
+
return None
|
|
60
|
+
return m.group(1), m.group(2), m.group(3)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def apply_plan(
|
|
64
|
+
adb: Adb,
|
|
65
|
+
plan: Plan,
|
|
66
|
+
*,
|
|
67
|
+
dry_run: bool = False,
|
|
68
|
+
out: TextIO = sys.stdout,
|
|
69
|
+
apk_dir: Optional[Path] = None,
|
|
70
|
+
) -> ApplyResult:
|
|
71
|
+
"""Run the executable steps in `plan` against `adb`.
|
|
72
|
+
|
|
73
|
+
The caller is responsible for gating on `--confirm`. `dry_run`
|
|
74
|
+
prints the commands without running them. `apk_dir` is used as the
|
|
75
|
+
cache directory for downloaded APKs; a temp dir is created if needed.
|
|
76
|
+
"""
|
|
77
|
+
result = ApplyResult(profile_name=plan.profile_name)
|
|
78
|
+
_dl_dir: Optional[Path] = apk_dir # resolved on first download if still None
|
|
79
|
+
|
|
80
|
+
out.write(f"Applying profile: {plan.profile_name}\n")
|
|
81
|
+
if dry_run:
|
|
82
|
+
out.write("(dry run — no commands will be executed)\n")
|
|
83
|
+
out.write("\n")
|
|
84
|
+
|
|
85
|
+
for i, step in enumerate(plan.steps, 1):
|
|
86
|
+
prefix = f"[{i:2d}/{len(plan.steps)}]"
|
|
87
|
+
|
|
88
|
+
if step.kind == StepKind.SKIP:
|
|
89
|
+
out.write(f"{prefix} skip : {step.summary}\n")
|
|
90
|
+
result.results.append(
|
|
91
|
+
StepResult(step=step, status="skipped", detail=step.skipped_reason or "")
|
|
92
|
+
)
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
if step.kind == StepKind.MANUAL_INSTALL:
|
|
96
|
+
out.write(f"{prefix} manual : {step.summary}\n")
|
|
97
|
+
result.results.append(
|
|
98
|
+
StepResult(step=step, status="manual", detail="user must install via store")
|
|
99
|
+
)
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
if step.kind == StepKind.INSTALL_APK:
|
|
103
|
+
if step.download_url:
|
|
104
|
+
if dry_run:
|
|
105
|
+
out.write(f"{prefix} fetch : {step.download_url} (dry run)\n")
|
|
106
|
+
result.results.append(StepResult(step=step, status="ok", detail="dry-run"))
|
|
107
|
+
continue
|
|
108
|
+
if _dl_dir is None:
|
|
109
|
+
_dl_dir = Path(tempfile.mkdtemp())
|
|
110
|
+
out.write(f"note: downloading APKs to {_dl_dir}\n")
|
|
111
|
+
out.write(f"{prefix} fetch : {step.download_url}\n")
|
|
112
|
+
try:
|
|
113
|
+
apk_path = fetch.download_apk(step.download_url, _dl_dir)
|
|
114
|
+
except FetchError as exc:
|
|
115
|
+
out.write(f" error : {exc}\n")
|
|
116
|
+
result.results.append(StepResult(step=step, status="error", detail=str(exc)))
|
|
117
|
+
continue
|
|
118
|
+
out.write(f" run : adb install -r {apk_path}\n")
|
|
119
|
+
try:
|
|
120
|
+
stdout = adb.install_apk(str(apk_path))
|
|
121
|
+
result.results.append(StepResult(step=step, status="ok", detail=stdout.strip()))
|
|
122
|
+
except AdbCommandError as exc:
|
|
123
|
+
out.write(f" error : {exc}\n")
|
|
124
|
+
result.results.append(StepResult(step=step, status="error", detail=str(exc)))
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
if step.missing_apk_path is not None or step.command is None:
|
|
128
|
+
out.write(
|
|
129
|
+
f"{prefix} skip : {step.summary} "
|
|
130
|
+
f"(missing APK: {step.missing_apk_path})\n"
|
|
131
|
+
)
|
|
132
|
+
result.results.append(
|
|
133
|
+
StepResult(
|
|
134
|
+
step=step,
|
|
135
|
+
status="missing_apk",
|
|
136
|
+
detail=step.missing_apk_path or "no command",
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
continue
|
|
140
|
+
apk_path = _extract_apk_path(step.command)
|
|
141
|
+
out.write(f"{prefix} run : {step.command}\n")
|
|
142
|
+
if dry_run or apk_path is None:
|
|
143
|
+
result.results.append(
|
|
144
|
+
StepResult(step=step, status="ok", detail="dry-run")
|
|
145
|
+
)
|
|
146
|
+
continue
|
|
147
|
+
try:
|
|
148
|
+
stdout = adb.install_apk(str(apk_path))
|
|
149
|
+
result.results.append(
|
|
150
|
+
StepResult(step=step, status="ok", detail=stdout.strip())
|
|
151
|
+
)
|
|
152
|
+
except AdbCommandError as exc:
|
|
153
|
+
out.write(f" error : {exc}\n")
|
|
154
|
+
result.results.append(
|
|
155
|
+
StepResult(step=step, status="error", detail=str(exc))
|
|
156
|
+
)
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
if step.kind == StepKind.SET_SETTING:
|
|
160
|
+
if step.command is None:
|
|
161
|
+
continue
|
|
162
|
+
parsed = _extract_setting(step.command)
|
|
163
|
+
out.write(f"{prefix} run : {step.command}\n")
|
|
164
|
+
if dry_run or parsed is None:
|
|
165
|
+
result.results.append(
|
|
166
|
+
StepResult(step=step, status="ok", detail="dry-run")
|
|
167
|
+
)
|
|
168
|
+
continue
|
|
169
|
+
namespace, key, value = parsed
|
|
170
|
+
try:
|
|
171
|
+
adb.setting_put(namespace, key, value)
|
|
172
|
+
result.results.append(StepResult(step=step, status="ok"))
|
|
173
|
+
except AdbCommandError as exc:
|
|
174
|
+
out.write(f" error : {exc}\n")
|
|
175
|
+
result.results.append(
|
|
176
|
+
StepResult(step=step, status="error", detail=str(exc))
|
|
177
|
+
)
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
counts = result.counts()
|
|
181
|
+
out.write("\n")
|
|
182
|
+
out.write(
|
|
183
|
+
"Done: "
|
|
184
|
+
+ ", ".join(f"{v} {k}" for k, v in sorted(counts.items()))
|
|
185
|
+
+ ".\n"
|
|
186
|
+
)
|
|
187
|
+
return result
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Individual audit checks plus the orchestrator.
|
|
2
|
+
|
|
3
|
+
Each check takes the `Adb` wrapper plus already-collected `DeviceFacts`
|
|
4
|
+
and returns zero or more `AuditFinding`s. Checks must be read-only.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Callable, Iterable
|
|
10
|
+
|
|
11
|
+
from ..adb import Adb
|
|
12
|
+
from ..device import DeviceFacts
|
|
13
|
+
from .models import AuditFinding, AuditReport, Severity
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Packages we care about. Keep this list short and curated; long lists
|
|
17
|
+
# rot fast and add noise. Phase 2 can move this to a YAML profile.
|
|
18
|
+
GMS_PACKAGE = "com.google.android.gms"
|
|
19
|
+
GSF_PACKAGE = "com.google.android.gsf"
|
|
20
|
+
|
|
21
|
+
COMMON_GOOGLE_PACKAGES = (
|
|
22
|
+
"com.android.vending", # Play Store
|
|
23
|
+
"com.google.android.apps.maps", # Maps
|
|
24
|
+
"com.google.android.gm", # Gmail
|
|
25
|
+
"com.google.android.youtube", # YouTube
|
|
26
|
+
"com.google.android.inputmethod.latin", # Gboard
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
CheckFn = Callable[[Adb, DeviceFacts], Iterable[AuditFinding]]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def check_lineage_identity(_adb: Adb, facts: DeviceFacts) -> Iterable[AuditFinding]:
|
|
34
|
+
if facts.is_lineage:
|
|
35
|
+
yield AuditFinding(
|
|
36
|
+
check="rom.lineage",
|
|
37
|
+
title="LineageOS detected",
|
|
38
|
+
severity=Severity.INFO,
|
|
39
|
+
detail=f"ro.lineage.version = {facts.lineage_version!r}.",
|
|
40
|
+
)
|
|
41
|
+
else:
|
|
42
|
+
yield AuditFinding(
|
|
43
|
+
check="rom.lineage",
|
|
44
|
+
title="Not a LineageOS build",
|
|
45
|
+
severity=Severity.INFO,
|
|
46
|
+
detail=(
|
|
47
|
+
"No ro.lineage.version property found. los-bootstrap will "
|
|
48
|
+
"still work on AOSP-derived ROMs, but device-specific "
|
|
49
|
+
"guidance assumes LineageOS conventions."
|
|
50
|
+
),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def check_gms(adb: Adb, _facts: DeviceFacts) -> Iterable[AuditFinding]:
|
|
55
|
+
if adb.package_installed(GMS_PACKAGE):
|
|
56
|
+
yield AuditFinding(
|
|
57
|
+
check="gms.present",
|
|
58
|
+
title="Google Mobile Services is installed",
|
|
59
|
+
severity=Severity.WARN,
|
|
60
|
+
detail=(
|
|
61
|
+
f"{GMS_PACKAGE} is installed. This indicates a GApps build "
|
|
62
|
+
"or microG. If you intended a fully degoogled setup, "
|
|
63
|
+
"investigate which build was flashed."
|
|
64
|
+
),
|
|
65
|
+
recommendation=(
|
|
66
|
+
"If unintended: reflash a vanilla LineageOS build. If this "
|
|
67
|
+
"is microG, ignore this finding."
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
else:
|
|
71
|
+
yield AuditFinding(
|
|
72
|
+
check="gms.present",
|
|
73
|
+
title="No Google Mobile Services",
|
|
74
|
+
severity=Severity.OK,
|
|
75
|
+
detail=f"{GMS_PACKAGE} is not installed. Degoogled-friendly.",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def check_gsf(adb: Adb, _facts: DeviceFacts) -> Iterable[AuditFinding]:
|
|
80
|
+
if adb.package_installed(GSF_PACKAGE):
|
|
81
|
+
yield AuditFinding(
|
|
82
|
+
check="gsf.present",
|
|
83
|
+
title="Google Services Framework is installed",
|
|
84
|
+
severity=Severity.WARN,
|
|
85
|
+
detail=f"{GSF_PACKAGE} is installed.",
|
|
86
|
+
recommendation="See GMS guidance — same root cause.",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def check_common_google_packages(adb: Adb, _facts: DeviceFacts) -> Iterable[AuditFinding]:
|
|
91
|
+
found = [p for p in COMMON_GOOGLE_PACKAGES if adb.package_installed(p)]
|
|
92
|
+
if not found:
|
|
93
|
+
yield AuditFinding(
|
|
94
|
+
check="google.client_packages",
|
|
95
|
+
title="No common Google client packages installed",
|
|
96
|
+
severity=Severity.OK,
|
|
97
|
+
detail="None of the curated Google client packages were detected.",
|
|
98
|
+
)
|
|
99
|
+
return
|
|
100
|
+
yield AuditFinding(
|
|
101
|
+
check="google.client_packages",
|
|
102
|
+
title=f"{len(found)} Google client package(s) installed",
|
|
103
|
+
severity=Severity.WARN,
|
|
104
|
+
detail="Detected: " + ", ".join(found),
|
|
105
|
+
recommendation=(
|
|
106
|
+
"Consider replacements: F-Droid + Aurora Store, Organic Maps, "
|
|
107
|
+
"K-9 Mail / FairEmail, NewPipe / LibreTube, FlorisBoard / "
|
|
108
|
+
"HeliBoard."
|
|
109
|
+
),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def check_adb_tcp(_adb: Adb, facts: DeviceFacts) -> Iterable[AuditFinding]:
|
|
114
|
+
port = facts.adb_tcp_port
|
|
115
|
+
if port and port.strip() and port != "0":
|
|
116
|
+
yield AuditFinding(
|
|
117
|
+
check="adb.tcp",
|
|
118
|
+
title="ADB-over-network is enabled",
|
|
119
|
+
severity=Severity.HIGH,
|
|
120
|
+
detail=(
|
|
121
|
+
f"service.adb.tcp.port = {port!r}. The device is exposing "
|
|
122
|
+
"ADB on the network. Anyone on the same network can attempt "
|
|
123
|
+
"to connect."
|
|
124
|
+
),
|
|
125
|
+
recommendation=(
|
|
126
|
+
"Disable wireless ADB in Developer options unless you "
|
|
127
|
+
"actively need it on a trusted network."
|
|
128
|
+
),
|
|
129
|
+
)
|
|
130
|
+
else:
|
|
131
|
+
yield AuditFinding(
|
|
132
|
+
check="adb.tcp",
|
|
133
|
+
title="ADB-over-network is not enabled",
|
|
134
|
+
severity=Severity.OK,
|
|
135
|
+
detail="service.adb.tcp.port is unset.",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def check_private_dns(adb: Adb, _facts: DeviceFacts) -> Iterable[AuditFinding]:
|
|
140
|
+
mode = adb.shell("settings get global private_dns_mode").strip()
|
|
141
|
+
specifier = adb.shell("settings get global private_dns_specifier").strip()
|
|
142
|
+
if mode in ("", "null", "off"):
|
|
143
|
+
yield AuditFinding(
|
|
144
|
+
check="dns.private",
|
|
145
|
+
title="Private DNS is off",
|
|
146
|
+
severity=Severity.WARN,
|
|
147
|
+
detail=(
|
|
148
|
+
f"global private_dns_mode = {mode!r}. DNS lookups go to "
|
|
149
|
+
"whatever the network hands you, in cleartext."
|
|
150
|
+
),
|
|
151
|
+
recommendation=(
|
|
152
|
+
"Set Private DNS to a hostname (DoT) like dns.quad9.net "
|
|
153
|
+
"in Settings > Network > Private DNS."
|
|
154
|
+
),
|
|
155
|
+
)
|
|
156
|
+
return
|
|
157
|
+
if mode == "opportunistic":
|
|
158
|
+
yield AuditFinding(
|
|
159
|
+
check="dns.private",
|
|
160
|
+
title="Private DNS is opportunistic",
|
|
161
|
+
severity=Severity.INFO,
|
|
162
|
+
detail=(
|
|
163
|
+
"Android will use DoT when the upstream resolver supports "
|
|
164
|
+
"it, otherwise fall back to cleartext."
|
|
165
|
+
),
|
|
166
|
+
)
|
|
167
|
+
return
|
|
168
|
+
if mode == "hostname":
|
|
169
|
+
yield AuditFinding(
|
|
170
|
+
check="dns.private",
|
|
171
|
+
title="Private DNS is enforced (DoT)",
|
|
172
|
+
severity=Severity.OK,
|
|
173
|
+
detail=(
|
|
174
|
+
f"global private_dns_mode = 'hostname', specifier = "
|
|
175
|
+
f"{specifier or '(unset)'!r}."
|
|
176
|
+
),
|
|
177
|
+
)
|
|
178
|
+
return
|
|
179
|
+
yield AuditFinding(
|
|
180
|
+
check="dns.private",
|
|
181
|
+
title=f"Private DNS in unknown mode {mode!r}",
|
|
182
|
+
severity=Severity.INFO,
|
|
183
|
+
detail="Unrecognised private_dns_mode value; not interpreting.",
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def check_screen_lock(adb: Adb, _facts: DeviceFacts) -> Iterable[AuditFinding]:
|
|
188
|
+
# `lockscreen.disabled` true means no lock at all on many AOSP ROMs.
|
|
189
|
+
raw = adb.shell("settings get secure lockscreen.disabled").strip()
|
|
190
|
+
disabled = raw == "1"
|
|
191
|
+
if disabled:
|
|
192
|
+
yield AuditFinding(
|
|
193
|
+
check="lockscreen.present",
|
|
194
|
+
title="Screen lock is disabled",
|
|
195
|
+
severity=Severity.HIGH,
|
|
196
|
+
detail="settings secure lockscreen.disabled = 1.",
|
|
197
|
+
recommendation=(
|
|
198
|
+
"Set a PIN, password, or passphrase. Without a screen lock, "
|
|
199
|
+
"device encryption keys at rest are weakened."
|
|
200
|
+
),
|
|
201
|
+
)
|
|
202
|
+
else:
|
|
203
|
+
yield AuditFinding(
|
|
204
|
+
check="lockscreen.present",
|
|
205
|
+
title="Screen lock appears enabled",
|
|
206
|
+
severity=Severity.OK,
|
|
207
|
+
detail=f"settings secure lockscreen.disabled = {raw!r}.",
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
CHECKS: tuple[CheckFn, ...] = (
|
|
212
|
+
check_lineage_identity,
|
|
213
|
+
check_gms,
|
|
214
|
+
check_gsf,
|
|
215
|
+
check_common_google_packages,
|
|
216
|
+
check_adb_tcp,
|
|
217
|
+
check_private_dns,
|
|
218
|
+
check_screen_lock,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def run_audit(adb: Adb, facts: DeviceFacts) -> AuditReport:
|
|
223
|
+
findings: list[AuditFinding] = []
|
|
224
|
+
for check in CHECKS:
|
|
225
|
+
findings.extend(check(adb, facts))
|
|
226
|
+
return AuditReport(findings=tuple(findings))
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Data models for audit findings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Severity(str, Enum):
|
|
11
|
+
INFO = "info"
|
|
12
|
+
OK = "ok"
|
|
13
|
+
WARN = "warn"
|
|
14
|
+
HIGH = "high"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class AuditFinding:
|
|
19
|
+
check: str # short id, e.g. "gms.present"
|
|
20
|
+
title: str # human-readable
|
|
21
|
+
severity: Severity
|
|
22
|
+
detail: str # one-paragraph explanation
|
|
23
|
+
recommendation: Optional[str] = None # actionable hint for `recommend`
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class AuditReport:
|
|
28
|
+
findings: tuple[AuditFinding, ...] = field(default_factory=tuple)
|
|
29
|
+
|
|
30
|
+
def by_severity(self, sev: Severity) -> tuple[AuditFinding, ...]:
|
|
31
|
+
return tuple(f for f in self.findings if f.severity == sev)
|
|
32
|
+
|
|
33
|
+
def has_concerns(self) -> bool:
|
|
34
|
+
return any(f.severity in (Severity.WARN, Severity.HIGH) for f in self.findings)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Phase 1 bootstrap recommendations.
|
|
2
|
+
|
|
3
|
+
Phase 1 is non-binding: we surface suggestions derived from audit
|
|
4
|
+
findings. We do not install anything. Phase 2 turns this into an
|
|
5
|
+
applyable plan.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .audit.models import AuditReport, Severity
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
GENERIC_TIPS: tuple[str, ...] = (
|
|
14
|
+
"Install F-Droid (https://f-droid.org) — verify the APK signature.",
|
|
15
|
+
"Install Aurora Store from F-Droid for proprietary apps without a Google account.",
|
|
16
|
+
"Set up a non-Google DNS (e.g. NextDNS, Quad9) under Settings > Network > Private DNS.",
|
|
17
|
+
"Set automatic time/timezone source to NITZ-only or NTP, not Google.",
|
|
18
|
+
"Disable connectivity check or point it at a non-Google endpoint if your ROM supports it.",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def recommendations(report: AuditReport) -> list[str]:
|
|
23
|
+
"""Map findings to short, actionable suggestions."""
|
|
24
|
+
out: list[str] = []
|
|
25
|
+
seen: set[str] = set()
|
|
26
|
+
for finding in report.findings:
|
|
27
|
+
if finding.severity in (Severity.WARN, Severity.HIGH) and finding.recommendation:
|
|
28
|
+
if finding.check not in seen:
|
|
29
|
+
out.append(f"[{finding.severity.value}] {finding.title}: {finding.recommendation}")
|
|
30
|
+
seen.add(finding.check)
|
|
31
|
+
out.append("--- general tips ---")
|
|
32
|
+
out.extend(GENERIC_TIPS)
|
|
33
|
+
return out
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Camera / GCam port profiles — Phase 5.
|
|
2
|
+
|
|
3
|
+
Public surface:
|
|
4
|
+
CAMERA_PROFILES — tuple of CameraProfile
|
|
5
|
+
find_camera_profile(str) -> CameraProfile | None
|
|
6
|
+
render_profile_list() -> str
|
|
7
|
+
render_profile(profile) -> str
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from .models import CameraPort, CameraProfile, XmlConfig
|
|
15
|
+
from .profiles import CAMERA_PROFILES
|
|
16
|
+
from .report import render_profile, render_profile_list
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"CAMERA_PROFILES",
|
|
20
|
+
"CameraPort",
|
|
21
|
+
"CameraProfile",
|
|
22
|
+
"XmlConfig",
|
|
23
|
+
"find_camera_profile",
|
|
24
|
+
"render_profile",
|
|
25
|
+
"render_profile_list",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def find_camera_profile(codename: str) -> Optional[CameraProfile]:
|
|
30
|
+
"""Return the camera profile for the given device codename, or None."""
|
|
31
|
+
codename_lower = codename.lower()
|
|
32
|
+
for profile in CAMERA_PROFILES:
|
|
33
|
+
if profile.codename.lower() == codename_lower:
|
|
34
|
+
return profile
|
|
35
|
+
return None
|