diff --git a/config/doctor-remediation.yaml b/config/doctor-remediation.yaml index f20f405..0886ed5 100644 --- a/config/doctor-remediation.yaml +++ b/config/doctor-remediation.yaml @@ -44,3 +44,26 @@ layer2_probe: injector_smoke: remediation: "Ensure Chromium can launch headless — see docs/DOCTOR.md troubleshooting." blocker: false + +# --- F9 — Chrome binary integrity (4-layer) ------------------------------- +# See docs/DOCTOR.md §F9 and docs/VAULT_THREAT_MODEL.md §2.8 for rationale. + +f9_l1_codesign: + remediation: "OS-level signature did not verify. Re-install Chrome from its official vendor (Google / Brave / Microsoft / Mozilla) — do not use a binary from a third-party mirror." + blocker: true + +f9_l2_sha_pin: + remediation: "SHA-256 not in known-good list. Expected between Chrome releases — no action unless running --strict. To bump the list, open a PR adding the new SHA to pop_pay/data/chrome_known_good_sha256.json." + blocker: false + +f9_l3_fork: + remediation: "Vendor not on whitelist. Options: (1) switch to Google/Brave/Edge/Firefox, (2) add --permissive if you trust the detected vendor, (3) confirm the codesign chain is intact with 'codesign -dv --verbose=4 '." + blocker: true + +f9_l4_extensions: + remediation: "Audit installed extensions; uninstall any you did not install yourself. pop-pay does not block extensions — this row is informational." + blocker: false + +f9_l4_cdp_port: + remediation: "Port conflict — another process is on the CDP port. Stop it, or set POP_CDP_URL=http://localhost:. A persistent listener may indicate a hijack attempt; see docs/VAULT_THREAT_MODEL.md §2.9." + blocker: false diff --git a/docs/DOCTOR.md b/docs/DOCTOR.md index a5c4718..b327dd8 100644 --- a/docs/DOCTOR.md +++ b/docs/DOCTOR.md @@ -8,14 +8,16 @@ documentation; this file notes only the Python differences. ``` $ pop-pay doctor -$ pop-pay doctor --json -$ pop-pay-doctor # equivalent direct entry point +$ pop-pay doctor --json # machine-readable +$ pop-pay doctor --strict # F9: only Google Chrome, SHA must match known-good list +$ pop-pay doctor --permissive # F9: accept any Chrome-family binary with a valid code signature +$ pop-pay-doctor # equivalent direct entry point $ python -m pop_pay.cli_doctor ``` Exit codes match the TS version: `0` ok / `1` blocker failed / `2` doctor crashed. -## Checks (10 total) +## Checks (15 total) Same check set as TS, with `python_version` (≥3.10) replacing `node_version`: @@ -31,6 +33,28 @@ Same check set as TS, with `python_version` (≥3.10) replacing `node_version`: | `layer1_probe` | `pop_pay.engine` loads | yes | | `layer2_probe` | TCP reachability; no API request sent | no | | `injector_smoke` | `chrome --version` | no | +| `f9_l1_codesign` | Chrome binary has a valid OS code signature | **yes** | +| `f9_l2_sha_pin` | Chrome SHA-256 matches the in-repo known-good list | no (yes under `--strict`) | +| `f9_l3_fork` | Chrome vendor on fork whitelist | **yes** | +| `f9_l4_extensions` | Enumerate installed Chrome-family extensions (informational) | no | +| `f9_l4_cdp_port` | Warn if CDP port is already listening — possible hijack (see VAULT_THREAT_MODEL §2.9) | no | + +## F9 — Chrome binary integrity + +F9 closes the trust boundary between pop-pay and the Chrome binary it drives over CDP. See `docs/VAULT_THREAT_MODEL.md` §2.8 for the threat model. + +**Four layers** (identical semantics to the TS doctor): + +1. **OS codesign (primary, load-bearing).** macOS `codesign --verify` + parse of the `Authority=Developer ID Application: ()` line; Linux `dpkg -V` / `rpm -V`; Windows `Get-AuthenticodeSignature`. Failure blocks startup. +2. **Static SHA-256 pin (secondary).** Chrome executable hashed and compared against `pop_pay/data/chrome_known_good_sha256.json`. Manual PR bump per Chrome major release — the PR review step IS the supply-chain defense. Warn under default; **fail** under `--strict`. +3. **Fork whitelist (tertiary).** Default accepts Google / Brave / Microsoft / Mozilla. `--strict` accepts only Google LLC. `--permissive` accepts any binary with a valid code signature. +4. **Defense-in-depth (runtime).** Extension enumeration across Chrome/Brave/Edge profile dirs + CDP port hijack sniff. + +**Never live-fetches `dl.google.com`.** Six-reason rationale is in `VAULT_THREAT_MODEL.md` §2.8. + +**Deviation from spec:** macOS L1 uses `codesign --verify` (not `--strict`) because Chrome's `.app` bundle contains resource-fork metadata which `--strict` rejects despite a cryptographically valid signature. Documented in `pop_pay/doctor_f9.py` `_layer1_macos` and in VAULT_THREAT_MODEL §2.8. + +**Bumping the known-good list:** open a PR adding an entry to `pop_pay/data/chrome_known_good_sha256.json`. Required fields: `vendor`, `channel`, `version`, `platform`, `arch`, `sha256`. On macOS discover Team ID via `codesign -dv --verbose=4 /Applications/Google\ Chrome.app`. ## Privacy & safety @@ -50,6 +74,7 @@ Parsed by an inline minimal YAML-lite parser in `pop_pay/cli_doctor.py` - **`cdp_port`**: TCP probe only; cannot identify the owning process. - **`injector_smoke`**: `--version` only, does not boot a headless page. - **No CATEGORIES policy checks yet.** Gated on S0.2 B-class decision, arriving in S1.1. +- **F9 `f9_l2_sha_pin` lag.** The SHA list is bumped by PR and intentionally trails Chrome's auto-update cadence; a miss under default mode is a *warn* by design. Run `--strict` only in environments where the list can be kept fresh. ## Entry points diff --git a/pop_pay/cli_doctor.py b/pop_pay/cli_doctor.py index 28a1af6..21a8e30 100644 --- a/pop_pay/cli_doctor.py +++ b/pop_pay/cli_doctor.py @@ -18,6 +18,13 @@ from typing import Literal from urllib.parse import urlparse +from pop_pay.doctor_f9 import ( + F9CheckResult, + F9Options, + ForkMode, + run_f9_checks, +) + CheckStatus = Literal["pass", "warn", "fail"] @@ -385,7 +392,25 @@ def _render(checks: list[DoctorCheck]) -> None: print() -def run_doctor(as_json: bool = False) -> list[DoctorCheck]: +def _f9_to_doctor(r: F9CheckResult, cat: dict[str, dict]) -> DoctorCheck: + """Adapt an F9CheckResult onto the DoctorCheck surface for rendering. + + Any F9 failure is treated as a blocker at the doctor level (mirrors TS). + Under default mode, f9_l2_sha_pin returns warn (not fail), so only the + load-bearing layers (L1 codesign, L3 fork whitelist, and L2 under --strict) + actually surface as blocking fails. + """ + return _mk( + r.id, + r.name, + r.status, + r.detail, + cat, + blocker_override=(r.status == "fail"), + ) + + +def run_doctor(as_json: bool = False, fork_mode: ForkMode = "default") -> list[DoctorCheck]: cat = _load_remediation_catalog() checks = [ _check_python_version(cat), @@ -399,6 +424,11 @@ def run_doctor(as_json: bool = False) -> list[DoctorCheck]: _check_layer2_probe(cat), _check_injector_smoke(cat), ] + # F9 — Chrome binary integrity (4 layers; L4 emits two rows). Never + # live-fetches; see docs/VAULT_THREAT_MODEL.md §2.8. + f9 = run_f9_checks(F9Options(fork_mode=fork_mode, cdp_port=_cdp_port())) + for r in f9.checks: + checks.append(_f9_to_doctor(r, cat)) if as_json: print(json.dumps([asdict(c) for c in checks], indent=2)) else: @@ -406,9 +436,19 @@ def run_doctor(as_json: bool = False) -> list[DoctorCheck]: return checks +def _parse_fork_mode(argv: list[str]) -> ForkMode: + if "--strict" in argv: + return "strict" + if "--permissive" in argv: + return "permissive" + return "default" + + def main() -> int: - as_json = "--json" in sys.argv[1:] - checks = run_doctor(as_json=as_json) + argv = sys.argv[1:] + as_json = "--json" in argv + fork_mode = _parse_fork_mode(argv) + checks = run_doctor(as_json=as_json, fork_mode=fork_mode) return 1 if any(c.status == "fail" and c.blocker for c in checks) else 0 diff --git a/pop_pay/data/chrome_known_good_sha256.json b/pop_pay/data/chrome_known_good_sha256.json new file mode 100644 index 0000000..1b8ec0a --- /dev/null +++ b/pop_pay/data/chrome_known_good_sha256.json @@ -0,0 +1,31 @@ +{ + "$comment": "F9 Layer 2 — static SHA-256 pin list for Chrome-family binaries. Manual PR bump per Chrome major release; the PR review step IS the supply-chain defense (see VAULT_THREAT_MODEL.md §2.8). NOT auto-fetched. Keys are vendor/channel/version/platform/arch; values are hex SHA-256 of the Chrome executable (macOS: Contents/MacOS/; Linux: the google-chrome/chromium binary; Windows: chrome.exe). Layer 3 vendor whitelist matches the 'Authority=Developer ID Application: ()' string from codesign on macOS and the publisher CN on Windows; team-id map below is a nice-to-have confirmation seed, not a hard requirement — entries added here must be self-verified by PR reviewer.", + "schema_version": 1, + "updated": "2026-04-21", + "entries": [ + { + "vendor": "Google LLC", + "channel": "stable", + "version": "147.0.7727.56", + "platform": "darwin", + "arch": "universal", + "sha256": "0acf99fc08a6ea8b67505c507813c0e9bdc983b615fa4301a878f17aec5fb889", + "note": "Seed entry — discovered from operator install on 2026-04-21 (macOS universal binary, x86_64+arm64)." + } + ], + "platforms_pending_seed": [ + "linux/x64 (google-chrome-stable / chromium)", + "linux/arm64", + "win32/x64", + "win32/arm64" + ], + "vendors_accepted_default": [ + "Google LLC", + "Brave Software Inc.", + "Microsoft Corporation", + "Mozilla Foundation" + ], + "vendor_id_macos_known": { + "Google LLC": "EQHXZ8M8AV" + } +} diff --git a/pop_pay/doctor_f9.py b/pop_pay/doctor_f9.py new file mode 100644 index 0000000..d5ed639 --- /dev/null +++ b/pop_pay/doctor_f9.py @@ -0,0 +1,580 @@ +"""F9 — Chrome binary integrity (pop-pay doctor, Python mirror of TS). + +Four-layer defense-in-depth for the "is the Chrome you're attaching CDP to +a tampered binary?" gap. Closes the CDP injection trust boundary documented +in docs/VAULT_THREAT_MODEL.md §2.8. + +Layers: + L1 — OS codesign verify + vendor identity (load-bearing) + L2 — Static SHA-256 pin against in-repo known-good list + L3 — Fork whitelist (Google / Brave / MS / Mozilla) with + --strict / default / --permissive modes + L4 — Runtime defense-in-depth (extension enumeration + CDP port hijack sniff) + +NEVER live-fetches dl.google.com or any remote feed — by design, see +docs/VAULT_THREAT_MODEL.md §2.8 "Rationale — why not live-fetch". +""" + +from __future__ import annotations + +import hashlib +import json +import os +import pathlib +import platform +import re +import socket +import subprocess +from dataclasses import dataclass, field +from typing import Callable, Literal + +ForkMode = Literal["strict", "default", "permissive"] +CheckStatus = Literal["pass", "warn", "fail"] + + +@dataclass +class F9CheckResult: + id: str + name: str + status: CheckStatus + detail: str + vendor: str | None = None + team_id: str | None = None + sha256: str | None = None + version: str | None = None + fork_mode: ForkMode | None = None + extensions: list[dict[str, str]] = field(default_factory=list) + + +@dataclass +class F9Options: + chrome_path: str | None = None + fork_mode: ForkMode = "default" + cdp_port: int = 9222 + # Dependency-injection hooks for unit tests; production leaves these None. + exec_fn: Callable[[str, list[str]], tuple[int | None, str, str]] | None = None + read_fn: Callable[[str], str] | None = None + known_good_path: str | None = None + net_fn: Callable[[str, int, float], str] | None = None + list_extensions_fn: Callable[[], list[dict[str, str]]] | None = None + + +@dataclass +class F9RunResult: + checks: list[F9CheckResult] + chrome_path: str | None + executable_path: str | None + vendor: str | None = None + team_id: str | None = None + sha256: str | None = None + fork_mode: ForkMode = "default" + + +# --- Chrome path resolution ------------------------------------------------ + + +def resolve_chrome_path(override: str | None = None) -> str | None: + if override and pathlib.Path(override).exists(): + return override + env = os.environ.get("POP_CHROME_PATH") + if env and pathlib.Path(env).exists(): + return env + sysname = platform.system() + if sysname == "Darwin": + candidates = [ + "/Applications/Google Chrome.app", + "/Applications/Chromium.app", + "/Applications/Google Chrome Canary.app", + "/Applications/Brave Browser.app", + "/Applications/Microsoft Edge.app", + "/Applications/Firefox.app", + ] + elif sysname == "Windows": + candidates = [ + r"C:\Program Files\Google\Chrome\Application\chrome.exe", + r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe", + r"C:\Program Files\Microsoft\Edge\Application\msedge.exe", + r"C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe", + ] + else: + candidates = [ + "/usr/bin/google-chrome", + "/usr/bin/google-chrome-stable", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + "/snap/bin/chromium", + "/usr/bin/brave-browser", + "/usr/bin/microsoft-edge", + ] + for p in candidates: + if pathlib.Path(p).exists(): + return p + return None + + +def executable_path_for(chrome_path: str) -> str: + """Map /Applications/Foo.app → the binary inside Contents/MacOS on macOS.""" + if platform.system() == "Darwin" and chrome_path.endswith(".app"): + macos_dir = pathlib.Path(chrome_path) / "Contents" / "MacOS" + if macos_dir.exists(): + entries = list(macos_dir.iterdir()) + if entries: + return str(entries[0]) + return chrome_path + + +# --- Exec / network shims (test-friendly) ---------------------------------- + + +def _exec( + opts: F9Options, cmd: str, args: list[str] +) -> tuple[int | None, str, str]: + if opts.exec_fn is not None: + return opts.exec_fn(cmd, args) + try: + r = subprocess.run( + [cmd, *args], capture_output=True, text=True, timeout=5, check=False + ) + return r.returncode, r.stdout or "", r.stderr or "" + except Exception as e: # noqa: BLE001 + return 1, "", str(e) + + +# --- Layer 1 — OS codesign ------------------------------------------------- + + +def parse_macos_codesign(stderr: str) -> tuple[bool, str | None, str | None, str]: + """Extract (valid, vendor, team_id, detail) from `codesign -dv --verbose=4`.""" + m = re.search( + r"Authority=Developer ID Application:\s*([^\n(]+?)\s*\(([A-Z0-9]{10})\)", + stderr, + ) + if not m: + return False, None, None, "No Developer ID Authority line found" + vendor = m.group(1).strip() + team_id = m.group(2).strip() + return True, vendor, team_id, f"Signed by {vendor} (Team ID {team_id})" + + +def _layer1_macos(chrome_path: str, opts: F9Options) -> F9CheckResult: + # `codesign --verify` (NOT --strict; Chrome's .app bundle contains + # resource-fork metadata which --strict rejects even though the + # signature is cryptographically valid. Documented deviation from + # original spec; see VAULT_THREAT_MODEL.md §2.8.) + rc, _, stderr = _exec(opts, "codesign", ["--verify", chrome_path]) + if rc != 0: + return F9CheckResult( + id="f9_l1_codesign", + name="F9 Layer 1 — OS codesign", + status="fail", + detail=f"codesign --verify exit {rc}: {stderr.strip() or 'invalid signature'}", + ) + rc2, _, info = _exec(opts, "codesign", ["-dv", "--verbose=4", chrome_path]) + valid, vendor, team_id, detail = parse_macos_codesign(info) + if not valid: + return F9CheckResult( + id="f9_l1_codesign", + name="F9 Layer 1 — OS codesign", + status="fail", + detail=detail, + ) + return F9CheckResult( + id="f9_l1_codesign", + name="F9 Layer 1 — OS codesign", + status="pass", + detail=detail, + vendor=vendor, + team_id=team_id, + ) + + +def _package_vendor(pkg_name: str) -> str | None: + n = pkg_name.lower() + if "google-chrome" in n: + return "Google LLC" + if "chromium" in n: + return "Chromium" + if "brave" in n: + return "Brave Software Inc." + if "microsoft-edge" in n or "msedge" in n: + return "Microsoft Corporation" + if "firefox" in n: + return "Mozilla Foundation" + return None + + +def _layer1_linux(chrome_path: str, opts: F9Options) -> F9CheckResult: + # Debian/Ubuntu: dpkg -S identifies the package; dpkg -V checks + # integrity against the package's recorded checksums. Fall through + # to RPM, then warn. + rc, out, _ = _exec(opts, "dpkg", ["-S", chrome_path]) + if rc == 0 and out.strip(): + pkg = out.split(":")[0].strip() + rc2, out2, _ = _exec(opts, "dpkg", ["-V", pkg]) + if rc2 == 0 and not out2.strip(): + return F9CheckResult( + id="f9_l1_codesign", + name="F9 Layer 1 — OS codesign", + status="pass", + detail=f"dpkg integrity OK for {pkg}", + vendor=_package_vendor(pkg), + ) + return F9CheckResult( + id="f9_l1_codesign", + name="F9 Layer 1 — OS codesign", + status="fail", + detail=f"dpkg -V {pkg} reported changes: {out2.strip()}", + vendor=_package_vendor(pkg), + ) + rc3, out3, _ = _exec(opts, "rpm", ["-qf", chrome_path]) + if rc3 == 0 and out3.strip(): + pkg = out3.strip() + rc4, out4, _ = _exec(opts, "rpm", ["-V", pkg]) + if rc4 == 0: + return F9CheckResult( + id="f9_l1_codesign", + name="F9 Layer 1 — OS codesign", + status="pass", + detail=f"rpm integrity OK for {pkg}", + vendor=_package_vendor(pkg), + ) + return F9CheckResult( + id="f9_l1_codesign", + name="F9 Layer 1 — OS codesign", + status="fail", + detail=f"rpm -V {pkg} reported changes: {out4.strip()}", + vendor=_package_vendor(pkg), + ) + return F9CheckResult( + id="f9_l1_codesign", + name="F9 Layer 1 — OS codesign", + status="warn", + detail="No dpkg/rpm record for this Chrome path — cannot verify distro signature", + ) + + +def _layer1_windows(chrome_path: str, opts: F9Options) -> F9CheckResult: + # Get-AuthenticodeSignature → "|" + escaped = chrome_path.replace("'", "''") + rc, out, err = _exec( + opts, + "powershell.exe", + [ + "-NoProfile", + "-Command", + f"$s = Get-AuthenticodeSignature -FilePath '{escaped}'; " + "Write-Output ($s.Status.ToString() + '|' + $s.SignerCertificate.Subject)", + ], + ) + if rc != 0 or not out.strip(): + return F9CheckResult( + id="f9_l1_codesign", + name="F9 Layer 1 — OS codesign", + status="fail", + detail=f"Get-AuthenticodeSignature failed: {err.strip() or 'no output'}", + ) + parts = out.strip().split("|") + status = parts[0] if parts else "" + subject = parts[1] if len(parts) > 1 else "" + if status != "Valid": + return F9CheckResult( + id="f9_l1_codesign", + name="F9 Layer 1 — OS codesign", + status="fail", + detail=f"Authenticode status={status} subject={subject}", + ) + m = re.search(r'CN="?([^,"]+)"?', subject or "") + vendor = m.group(1).strip() if m else None + return F9CheckResult( + id="f9_l1_codesign", + name="F9 Layer 1 — OS codesign", + status="pass", + detail=f"Authenticode Valid, {subject}", + vendor=vendor, + ) + + +def layer1_codesign(chrome_path: str, opts: F9Options) -> F9CheckResult: + sysname = platform.system() + if sysname == "Darwin": + return _layer1_macos(chrome_path, opts) + if sysname == "Windows": + return _layer1_windows(chrome_path, opts) + return _layer1_linux(chrome_path, opts) + + +# --- Layer 2 — Static SHA-256 pin ------------------------------------------ + + +def hash_file(path: str) -> str: + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1 << 20), b""): + h.update(chunk) + return h.hexdigest() + + +def load_known_good(opts: F9Options) -> dict: + here = pathlib.Path(__file__).resolve().parent + candidates = [ + opts.known_good_path, + str(here / "data" / "chrome_known_good_sha256.json"), + str(here.parent / "data" / "chrome_known_good_sha256.json"), + ] + for p in candidates: + if not p: + continue + try: + if not pathlib.Path(p).exists(): + continue + raw = opts.read_fn(p) if opts.read_fn else pathlib.Path(p).read_text(encoding="utf-8") + return json.loads(raw) + except Exception: + continue + return {"entries": [], "vendors_accepted_default": [], "vendor_id_macos_known": {}} + + +def layer2_sha_pin( + exec_path: str, + opts: F9Options, + vendor: str | None = None, + version: str | None = None, +) -> F9CheckResult: + try: + sha = hash_file(exec_path) + except Exception as e: # noqa: BLE001 + return F9CheckResult( + id="f9_l2_sha_pin", + name="F9 Layer 2 — SHA-256 pin", + status="fail", + detail=f"Failed to hash {exec_path}: {e}", + ) + kg = load_known_good(opts) + for entry in kg.get("entries", []): + if entry.get("sha256") == sha: + return F9CheckResult( + id="f9_l2_sha_pin", + name="F9 Layer 2 — SHA-256 pin", + status="pass", + detail=( + f"SHA matches known-good {entry['vendor']} {entry['channel']} " + f"{entry['version']} ({entry['platform']}/{entry['arch']})" + ), + sha256=sha, + version=entry["version"], + ) + # SHA not in list — warn unless --strict (escalated by orchestrator). + return F9CheckResult( + id="f9_l2_sha_pin", + name="F9 Layer 2 — SHA-256 pin", + status="warn", + detail=( + f"SHA {sha[:16]}… not in known-good list " + f"(vendor={vendor or '?'}, version={version or '?'}). " + "Expected for Chrome updates between list bumps; " + "escalate to fail only under --strict." + ), + sha256=sha, + version=version, + ) + + +# --- Layer 3 — Fork whitelist ---------------------------------------------- + + +def layer3_fork_whitelist(vendor: str | None, opts: F9Options) -> F9CheckResult: + mode = opts.fork_mode + if mode == "permissive": + return F9CheckResult( + id="f9_l3_fork", + name="F9 Layer 3 — Fork whitelist", + status="pass", + detail=f"--permissive: any valid codesign accepted (detected vendor={vendor or 'unknown'})", + vendor=vendor, + fork_mode=mode, + ) + if vendor is None: + return F9CheckResult( + id="f9_l3_fork", + name="F9 Layer 3 — Fork whitelist", + status="fail", + detail="No vendor identity resolved from Layer 1 — cannot match whitelist", + fork_mode=mode, + ) + if mode == "strict": + if vendor == "Google LLC": + return F9CheckResult( + id="f9_l3_fork", + name="F9 Layer 3 — Fork whitelist", + status="pass", + detail="--strict: Google LLC only", + vendor=vendor, + fork_mode=mode, + ) + return F9CheckResult( + id="f9_l3_fork", + name="F9 Layer 3 — Fork whitelist", + status="fail", + detail=f"--strict rejects vendor {vendor}; only Google LLC accepted", + vendor=vendor, + fork_mode=mode, + ) + kg = load_known_good(opts) + accepted = kg.get("vendors_accepted_default", []) + matched = any(v == vendor or vendor.find(v) >= 0 for v in accepted) + if matched: + return F9CheckResult( + id="f9_l3_fork", + name="F9 Layer 3 — Fork whitelist", + status="pass", + detail=f"Vendor {vendor} on default whitelist", + vendor=vendor, + fork_mode=mode, + ) + return F9CheckResult( + id="f9_l3_fork", + name="F9 Layer 3 — Fork whitelist", + status="fail", + detail=( + f"Vendor {vendor} not on default whitelist ({', '.join(accepted)}). " + "Use --permissive to accept any valid codesign." + ), + vendor=vendor, + fork_mode=mode, + ) + + +# --- Layer 4 — Runtime defense-in-depth ------------------------------------ + + +def _extension_dirs() -> list[str]: + home = pathlib.Path.home() + sysname = platform.system() + if sysname == "Darwin": + return [ + str(home / "Library/Application Support/Google/Chrome/Default/Extensions"), + str(home / "Library/Application Support/BraveSoftware/Brave-Browser/Default/Extensions"), + str(home / "Library/Application Support/Microsoft Edge/Default/Extensions"), + ] + if sysname == "Windows": + local = os.environ.get("LOCALAPPDATA") or str(home / "AppData/Local") + return [ + f"{local}\\Google\\Chrome\\User Data\\Default\\Extensions", + f"{local}\\Microsoft\\Edge\\User Data\\Default\\Extensions", + ] + return [ + str(home / ".config/google-chrome/Default/Extensions"), + str(home / ".config/chromium/Default/Extensions"), + str(home / ".config/BraveSoftware/Brave-Browser/Default/Extensions"), + ] + + +_EXT_ID_RE = re.compile(r"^[a-p]{32}$") + + +def enumerate_extensions(opts: F9Options) -> list[dict[str, str]]: + if opts.list_extensions_fn is not None: + return opts.list_extensions_fn() + out: list[dict[str, str]] = [] + for d in _extension_dirs(): + p = pathlib.Path(d) + if not p.exists(): + continue + try: + for child in p.iterdir(): + if _EXT_ID_RE.match(child.name): + out.append({"id": child.name, "path": str(child)}) + except Exception: + continue + return out + + +def _probe_port(host: str, port: int, timeout: float) -> str: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + try: + s.connect((host, port)) + return "listening" + except Exception: + return "closed" + finally: + try: + s.close() + except Exception: + pass + + +def layer4_runtime(opts: F9Options) -> tuple[F9CheckResult, F9CheckResult]: + exts = enumerate_extensions(opts) + ext_check = F9CheckResult( + id="f9_l4_extensions", + name="F9 Layer 4a — Extension enumeration", + status="pass", + detail=("No Chrome extensions found" + if not exts + else f"{len(exts)} extension(s) across known browsers"), + extensions=exts, + ) + port = opts.cdp_port or 9222 + probe = ( + opts.net_fn("127.0.0.1", port, 0.5) + if opts.net_fn is not None + else _probe_port("127.0.0.1", port, 0.5) + ) + if probe == "listening": + port_check = F9CheckResult( + id="f9_l4_cdp_port", + name="F9 Layer 4b — CDP port hijack sniff", + status="warn", + detail=( + f"Port {port} already listening — pre-existing process could be " + "impersonating Chrome DevTools. Stop it before launching pop-pay, " + "or set POP_CDP_URL to a different port." + ), + ) + else: + port_check = F9CheckResult( + id="f9_l4_cdp_port", + name="F9 Layer 4b — CDP port hijack sniff", + status="pass", + detail=f"Port {port} unclaimed — safe to bind", + ) + return ext_check, port_check + + +# --- Orchestrator --------------------------------------------------------- + + +def run_f9_checks(opts: F9Options | None = None) -> F9RunResult: + opts = opts or F9Options() + chrome_path = resolve_chrome_path(opts.chrome_path) + if not chrome_path: + miss = F9CheckResult( + id="f9_l1_codesign", + name="F9 Layer 1 — OS codesign", + status="fail", + detail="No Chrome/Chromium binary found — cannot run F9 checks", + ) + return F9RunResult( + checks=[miss], + chrome_path=None, + executable_path=None, + fork_mode=opts.fork_mode, + ) + exec_path = executable_path_for(chrome_path) + l1 = layer1_codesign(chrome_path, opts) + l2 = layer2_sha_pin(exec_path, opts, l1.vendor, l1.version) + l3 = layer3_fork_whitelist(l1.vendor, opts) + ext_check, port_check = layer4_runtime(opts) + if opts.fork_mode == "strict" and l2.status == "warn": + l2.status = "fail" + l2.detail = f"--strict: {l2.detail}" + return F9RunResult( + checks=[l1, l2, l3, ext_check, port_check], + chrome_path=chrome_path, + executable_path=exec_path, + vendor=l1.vendor, + team_id=l1.team_id, + sha256=l2.sha256, + fork_mode=opts.fork_mode, + ) diff --git a/tests/test_cli_doctor_f9.py b/tests/test_cli_doctor_f9.py new file mode 100644 index 0000000..ef421ac --- /dev/null +++ b/tests/test_cli_doctor_f9.py @@ -0,0 +1,348 @@ +"""F9 Chrome binary integrity — unit tests (Python mirror of TS tests). + +24 cases. Matches vitest layout one-for-one. Platform-bound dispatch tests +use `if platform.system() != "Darwin": return` so the suite runs green on +any host while each OS-specific path is still covered on its native CI. + +Dependency injection: F9Options carries exec_fn / net_fn / list_extensions_fn +/ known_good_path so we never shell out to real codesign / dpkg / rpm / +PowerShell during tests. +""" +from __future__ import annotations + +import hashlib +import json +import platform +import tempfile +from pathlib import Path + +from pop_pay.doctor_f9 import ( + F9Options, + layer1_codesign, + layer2_sha_pin, + layer3_fork_whitelist, + layer4_runtime, + load_known_good, + parse_macos_codesign, + run_f9_checks, +) + + +# --- Layer 1 parse -------------------------------------------------------- + + +def test_layer1_parse_extracts_vendor_and_team_id(): + out = ( + "\nExecutable=/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\n" + "Identifier=com.google.Chrome\n" + "Authority=Developer ID Application: Google LLC (EQHXZ8M8AV)\n" + "Authority=Developer ID Certification Authority\n" + ) + valid, vendor, team_id, _ = parse_macos_codesign(out) + assert valid is True + assert vendor == "Google LLC" + assert team_id == "EQHXZ8M8AV" + + +def test_layer1_parse_rejects_unrelated_output(): + valid, *_ = parse_macos_codesign("some unrelated output\n") + assert valid is False + + +def test_layer1_parse_brave_microsoft_mozilla(): + cases = [ + ( + "Authority=Developer ID Application: Brave Software Inc. (KL8N8XSYF4)\n", + "Brave Software Inc.", + "KL8N8XSYF4", + ), + ( + "Authority=Developer ID Application: Microsoft Corporation (UBF8T346G9)\n", + "Microsoft Corporation", + "UBF8T346G9", + ), + ( + "Authority=Developer ID Application: Mozilla Foundation (43AQ936H96)\n", + "Mozilla Foundation", + "43AQ936H96", + ), + ] + for line, expected_vendor, expected_tid in cases: + _, vendor, team_id, _ = parse_macos_codesign(line) + assert vendor == expected_vendor + assert team_id == expected_tid + + +# --- Layer 1 dispatch (mocked exec) --------------------------------------- + + +def _mock_exec(responses: dict[str, tuple[int, str, str]]): + """Return an exec_fn that matches cmd+args by substring.""" + def _f(cmd: str, args: list[str]) -> tuple[int | None, str, str]: + key = " ".join([cmd, *args]) + for k, v in responses.items(): + if k in key: + return v + return (1, "", "no mock match") + return _f + + +def test_layer1_macos_passes_when_codesign_ok(): + if platform.system() != "Darwin": + return + opts = F9Options( + exec_fn=_mock_exec({ + "codesign --verify": (0, "", ""), + "codesign -dv --verbose=4": ( + 0, + "", + "Authority=Developer ID Application: Google LLC (EQHXZ8M8AV)\n", + ), + }) + ) + r = layer1_codesign("/Applications/Google Chrome.app", opts) + assert r.status == "pass" + assert r.vendor == "Google LLC" + assert r.team_id == "EQHXZ8M8AV" + + +def test_layer1_macos_fails_when_codesign_verify_nonzero(): + if platform.system() != "Darwin": + return + opts = F9Options( + exec_fn=_mock_exec({"codesign --verify": (1, "", "invalid signature")}) + ) + r = layer1_codesign("/Applications/Google Chrome.app", opts) + assert r.status == "fail" + + +def test_layer1_linux_passes_when_dpkg_clean(): + if platform.system() != "Linux": + return + opts = F9Options( + exec_fn=_mock_exec({ + "dpkg -S": (0, "google-chrome-stable: /usr/bin/google-chrome\n", ""), + "dpkg -V": (0, "", ""), + }) + ) + r = layer1_codesign("/usr/bin/google-chrome", opts) + assert r.status == "pass" + assert r.vendor == "Google LLC" + + +def test_layer1_linux_fails_when_dpkg_V_reports_changes(): + if platform.system() != "Linux": + return + opts = F9Options( + exec_fn=_mock_exec({ + "dpkg -S": (0, "google-chrome-stable: /usr/bin/google-chrome\n", ""), + "dpkg -V": (1, "..5...... /usr/bin/google-chrome\n", ""), + }) + ) + r = layer1_codesign("/usr/bin/google-chrome", opts) + assert r.status == "fail" + + +def test_layer1_windows_passes_when_authenticode_valid(): + if platform.system() != "Windows": + return + opts = F9Options( + exec_fn=_mock_exec({ + "Get-AuthenticodeSignature": ( + 0, + 'Valid|CN="Google LLC", O=Google LLC, L=Mountain View, S=California, C=US\n', + "", + ), + }) + ) + r = layer1_codesign( + r"C:\Program Files\Google\Chrome\Application\chrome.exe", opts + ) + assert r.status == "pass" + assert r.vendor == "Google LLC" + + +def test_layer1_windows_fails_when_not_valid(): + if platform.system() != "Windows": + return + opts = F9Options( + exec_fn=_mock_exec({ + "Get-AuthenticodeSignature": (0, "NotSigned|\n", ""), + }) + ) + r = layer1_codesign(r"C:\x\chrome.exe", opts) + assert r.status == "fail" + + +# --- Layer 2 SHA pin ------------------------------------------------------ + + +def _mock_known_good_file(sha: str) -> str: + d = Path(tempfile.mkdtemp(prefix="f9-kg-")) + body = { + "entries": [ + { + "vendor": "Google LLC", + "channel": "stable", + "version": "147.0.0", + "platform": platform.system().lower(), + "arch": "universal", + "sha256": sha, + } + ], + "vendors_accepted_default": [ + "Google LLC", + "Brave Software Inc.", + "Microsoft Corporation", + "Mozilla Foundation", + ], + "vendor_id_macos_known": {"Google LLC": "EQHXZ8M8AV"}, + } + p = d / "kg.json" + p.write_text(json.dumps(body), encoding="utf-8") + return str(p) + + +def test_layer2_matches_known_good_entry(): + d = Path(tempfile.mkdtemp(prefix="f9-bin-")) + bin_path = d / "chrome-bin" + payload = b"fake-chrome-binary" + bin_path.write_bytes(payload) + sha = hashlib.sha256(payload).hexdigest() + kg_path = _mock_known_good_file(sha) + r = layer2_sha_pin(str(bin_path), F9Options(known_good_path=kg_path), "Google LLC") + assert r.status == "pass" + assert r.sha256 == sha + + +def test_layer2_warns_when_sha_not_in_list_default_mode(): + d = Path(tempfile.mkdtemp(prefix="f9-bin-")) + bin_path = d / "chrome-bin" + bin_path.write_bytes(b"some-other-bytes") + kg_path = _mock_known_good_file("0" * 64) + r = layer2_sha_pin(str(bin_path), F9Options(known_good_path=kg_path), "Google LLC") + assert r.status == "warn" + + +def test_layer2_fails_when_binary_unreadable(): + r = layer2_sha_pin("/nonexistent/path/chrome", F9Options()) + assert r.status == "fail" + + +# --- Layer 3 fork whitelist ----------------------------------------------- + + +def test_layer3_default_passes_google(): + r = layer3_fork_whitelist("Google LLC", F9Options(fork_mode="default")) + assert r.status == "pass" + + +def test_layer3_default_passes_brave(): + r = layer3_fork_whitelist("Brave Software Inc.", F9Options(fork_mode="default")) + assert r.status == "pass" + + +def test_layer3_default_fails_off_list_vendor(): + r = layer3_fork_whitelist("Sketchy Forks Ltd.", F9Options(fork_mode="default")) + assert r.status == "fail" + + +def test_layer3_strict_passes_only_google(): + assert layer3_fork_whitelist("Google LLC", F9Options(fork_mode="strict")).status == "pass" + assert ( + layer3_fork_whitelist("Brave Software Inc.", F9Options(fork_mode="strict")).status == "fail" + ) + + +def test_layer3_permissive_passes_any_vendor(): + r = layer3_fork_whitelist("Anyone", F9Options(fork_mode="permissive")) + assert r.status == "pass" + + +def test_layer3_fails_when_vendor_is_none_under_default_and_strict(): + assert layer3_fork_whitelist(None, F9Options(fork_mode="default")).status == "fail" + assert layer3_fork_whitelist(None, F9Options(fork_mode="strict")).status == "fail" + + +# --- Layer 4 runtime ------------------------------------------------------ + + +def test_layer4_extensions_uses_injected_enumerator(): + exts = [{"id": "a" * 32, "path": "/mock/a"}] + ext_check, _ = layer4_runtime( + F9Options(list_extensions_fn=lambda: exts, net_fn=lambda *a: "closed") + ) + assert ext_check.status == "pass" + assert ext_check.extensions == exts + + +def test_layer4_cdp_port_warns_when_listening(): + _, port_check = layer4_runtime( + F9Options(list_extensions_fn=lambda: [], net_fn=lambda *a: "listening") + ) + assert port_check.status == "warn" + + +def test_layer4_cdp_port_passes_when_unclaimed(): + _, port_check = layer4_runtime( + F9Options(list_extensions_fn=lambda: [], net_fn=lambda *a: "closed") + ) + assert port_check.status == "pass" + + +# --- Orchestrator --------------------------------------------------------- + + +def test_orchestrator_escalates_l2_warn_to_fail_under_strict(): + d = Path(tempfile.mkdtemp(prefix="f9-orch-")) + bin_path = d / "bin" + bin_path.write_bytes(b"mismatch") + kg_dir = Path(tempfile.mkdtemp(prefix="f9-orch-kg-")) + kg_path = kg_dir / "kg.json" + kg_path.write_text( + json.dumps( + { + "entries": [], + "vendors_accepted_default": ["Google LLC"], + "vendor_id_macos_known": {}, + } + ), + encoding="utf-8", + ) + # Mock exec returns Authority line for codesign calls. On Linux/RPM the + # fallback branch runs (dpkg/rpm -S with empty stdout) and L1 becomes + # warn — L2 escalation is what we're asserting here regardless. + r = run_f9_checks( + F9Options( + chrome_path=str(bin_path), + fork_mode="strict", + known_good_path=str(kg_path), + exec_fn=lambda c, a: ( + 0, + "", + "Authority=Developer ID Application: Google LLC (EQHXZ8M8AV)\n", + ), + net_fn=lambda *a: "closed", + list_extensions_fn=lambda: [], + ) + ) + l2 = next(c for c in r.checks if c.id == "f9_l2_sha_pin") + assert l2.status == "fail" + + +# --- Data file integrity -------------------------------------------------- + + +def test_data_file_has_at_least_one_entry_and_default_vendors(): + kg = load_known_good(F9Options()) + assert len(kg.get("entries", [])) > 0 + assert "Google LLC" in kg.get("vendors_accepted_default", []) + + +def test_data_file_all_sha_values_are_lowercase_hex_64(): + import re + + pattern = re.compile(r"^[0-9a-f]{64}$") + kg = load_known_good(F9Options()) + for entry in kg.get("entries", []): + assert pattern.match(entry["sha256"]), entry["sha256"]