feat(lab): add isolated iPhone capture evidence
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import struct
|
||||
import subprocess
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pcapng import FileScanner
|
||||
from pcapng.blocks import EnhancedPacket
|
||||
from preflight import (
|
||||
pymobiledevice3_binary,
|
||||
pymobiledevice3_version,
|
||||
usb_device_count,
|
||||
)
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||
SESSION_ROOT = REPO_ROOT / "sessions" / "iphone-k1-observation"
|
||||
SAFE_LABEL = re.compile(r"^[a-z0-9][a-z0-9-]{0,31}$")
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def git_output(*args: str) -> bytes:
|
||||
completed = subprocess.run(
|
||||
["git", *args],
|
||||
cwd=REPO_ROOT,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
return completed.stdout
|
||||
|
||||
|
||||
def source_snapshot() -> dict[str, Any]:
|
||||
revision = git_output("rev-parse", "HEAD").decode().strip()
|
||||
status = git_output("status", "--porcelain=v1", "--untracked-files=all")
|
||||
dirty = bool(status.strip())
|
||||
digest = hashlib.sha256()
|
||||
digest.update(revision.encode())
|
||||
digest.update(b"\0")
|
||||
digest.update(status)
|
||||
if dirty:
|
||||
digest.update(git_output("diff", "--binary", "HEAD"))
|
||||
untracked = git_output("ls-files", "--others", "--exclude-standard", "-z")
|
||||
for raw_name in filter(None, untracked.split(b"\0")):
|
||||
relative = raw_name.decode("utf-8", errors="surrogateescape")
|
||||
path = REPO_ROOT / relative
|
||||
digest.update(raw_name)
|
||||
digest.update(b"\0")
|
||||
if path.is_file():
|
||||
digest.update(sha256_file(path).encode())
|
||||
return {
|
||||
"git_revision": revision,
|
||||
"source_tree_dirty": dirty,
|
||||
"source_tree_fingerprint_sha256": digest.hexdigest(),
|
||||
}
|
||||
|
||||
|
||||
def terminate(process: subprocess.Popen[bytes]) -> int:
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=5)
|
||||
return int(process.returncode or 0)
|
||||
|
||||
|
||||
def artifact_record(path: Path, kind: str, session_dir: Path) -> dict[str, Any] | None:
|
||||
if not path.is_file() or path.stat().st_size == 0:
|
||||
return None
|
||||
path.chmod(0o600)
|
||||
return {
|
||||
"kind": kind,
|
||||
"relative_path": str(path.relative_to(session_dir)),
|
||||
"size_bytes": path.stat().st_size,
|
||||
"sha256": sha256_file(path),
|
||||
}
|
||||
|
||||
|
||||
def convert_pcapng_to_pcap(source: Path, destination: Path) -> int:
|
||||
"""Create a classic Ethernet PCAP that macOS's bundled tcpdump can read."""
|
||||
packet_count = 0
|
||||
with source.open("rb") as source_stream, destination.open("xb") as destination_stream:
|
||||
destination_stream.write(
|
||||
struct.pack(
|
||||
"<IHHIIII",
|
||||
0xA1B2C3D4,
|
||||
2,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
262_144,
|
||||
1,
|
||||
)
|
||||
)
|
||||
for block in FileScanner(source_stream):
|
||||
if not isinstance(block, EnhancedPacket):
|
||||
continue
|
||||
packet_data = bytes(block.packet_data)
|
||||
captured_length = int(block.captured_len)
|
||||
packet_length = int(block.packet_len)
|
||||
if captured_length != len(packet_data) or packet_length < captured_length:
|
||||
raise ValueError("invalid enhanced-packet lengths")
|
||||
|
||||
timestamp = float(block.timestamp)
|
||||
seconds = int(timestamp)
|
||||
microseconds = int(round((timestamp - seconds) * 1_000_000))
|
||||
if microseconds >= 1_000_000:
|
||||
seconds += 1
|
||||
microseconds -= 1_000_000
|
||||
destination_stream.write(
|
||||
struct.pack(
|
||||
"<IIII",
|
||||
seconds,
|
||||
microseconds,
|
||||
captured_length,
|
||||
packet_length,
|
||||
)
|
||||
)
|
||||
destination_stream.write(packet_data)
|
||||
packet_count += 1
|
||||
destination.chmod(0o600)
|
||||
return packet_count
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Bounded, redacted USB iPhone capture for the Mission Core K1 lab"
|
||||
)
|
||||
parser.add_argument("--duration", type=int, default=120, help="capture seconds (5..600)")
|
||||
parser.add_argument("--label", default="lixelgo-observe", help="safe session label")
|
||||
parser.add_argument("--process", help="optional iOS process filter for network capture")
|
||||
parser.add_argument("--interface", help="optional iOS interface filter for network capture")
|
||||
parser.add_argument(
|
||||
"--bluetooth",
|
||||
action="store_true",
|
||||
help="also capture iPhone Bluetooth HCI; requires Apple's logging profile",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--acknowledge-ios-bluetooth-profile",
|
||||
action="store_true",
|
||||
help="confirm the operator already installed Apple's Bluetooth logging profile",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--allow-dirty",
|
||||
action="store_true",
|
||||
help="record an explicit dirty-tree fingerprint instead of refusing the capture",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
if not 5 <= args.duration <= 600:
|
||||
raise SystemExit("--duration must be between 5 and 600 seconds")
|
||||
if not SAFE_LABEL.fullmatch(args.label):
|
||||
raise SystemExit("--label must use lowercase ASCII letters, digits, and hyphens")
|
||||
if args.bluetooth and not args.acknowledge_ios_bluetooth_profile:
|
||||
raise SystemExit(
|
||||
"Bluetooth capture requires --acknowledge-ios-bluetooth-profile; "
|
||||
"the script never installs the profile"
|
||||
)
|
||||
|
||||
os.umask(0o077)
|
||||
binary = pymobiledevice3_binary()
|
||||
if usb_device_count(binary) != 1:
|
||||
raise SystemExit("exactly one trusted USB iPhone must be connected")
|
||||
snapshot = source_snapshot()
|
||||
if snapshot["source_tree_dirty"] and not args.allow_dirty:
|
||||
raise SystemExit(
|
||||
"repository is dirty; commit the milestone or pass --allow-dirty "
|
||||
"to record its fingerprint"
|
||||
)
|
||||
|
||||
session_id = (
|
||||
datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") + f"_{args.label}_{secrets.token_hex(2)}"
|
||||
)
|
||||
session_dir = SESSION_ROOT / session_id
|
||||
capture_dir = session_dir / "captures"
|
||||
capture_dir.mkdir(parents=True, mode=0o700)
|
||||
network_pcapng_path = capture_dir / "iphone-network.pcapng"
|
||||
network_pcap_path = capture_dir / "iphone-network.pcap"
|
||||
bluetooth_path = capture_dir / "iphone-bluetooth.pcapng"
|
||||
stderr_path = session_dir / "capture.stderr.log"
|
||||
manifest_path = session_dir / "manifest.redacted.json"
|
||||
|
||||
manifest: dict[str, Any] = {
|
||||
"schema": "missioncore.xgrids-k1/iphone-observation-manifest/v1alpha1",
|
||||
"session_id": session_id,
|
||||
"status": "starting",
|
||||
"started_at_utc": utc_now(),
|
||||
"started_monotonic_ns": time.monotonic_ns(),
|
||||
"requested_duration_seconds": args.duration,
|
||||
"capture_backend": "pymobiledevice3-9.36.0/com.apple.pcapd",
|
||||
"pymobiledevice3_version": pymobiledevice3_version(binary),
|
||||
"network_capture": True,
|
||||
"bluetooth_capture": args.bluetooth,
|
||||
"network_process_filter": args.process,
|
||||
"network_interface_filter": args.interface,
|
||||
"device_identity_recorded": False,
|
||||
"credentials_recorded_in_manifest": False,
|
||||
"source": snapshot,
|
||||
"artifacts": [],
|
||||
}
|
||||
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
||||
manifest_path.chmod(0o600)
|
||||
|
||||
network_command = [binary, "--no-color", "pcap", "--out", str(network_pcapng_path)]
|
||||
if args.process:
|
||||
network_command.extend(["--process", args.process])
|
||||
if args.interface:
|
||||
network_command.extend(["--interface", args.interface])
|
||||
commands = [("network", network_command)]
|
||||
if args.bluetooth:
|
||||
commands.append(
|
||||
(
|
||||
"bluetooth",
|
||||
[binary, "--no-color", "btlogger", str(bluetooth_path), "--format", "pcapng"],
|
||||
)
|
||||
)
|
||||
|
||||
processes: list[tuple[str, subprocess.Popen[bytes]]] = []
|
||||
exit_codes: dict[str, int] = {}
|
||||
interrupted = False
|
||||
failure: str | None = None
|
||||
with stderr_path.open("wb") as error_log:
|
||||
stderr_path.chmod(0o600)
|
||||
try:
|
||||
for name, command in commands:
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
cwd=REPO_ROOT,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=error_log,
|
||||
)
|
||||
processes.append((name, process))
|
||||
time.sleep(1)
|
||||
early = [name for name, process in processes if process.poll() is not None]
|
||||
if early:
|
||||
raise RuntimeError(f"capture service exited early: {', '.join(early)}")
|
||||
|
||||
deadline = time.monotonic() + args.duration
|
||||
while time.monotonic() < deadline:
|
||||
ended = [name for name, process in processes if process.poll() is not None]
|
||||
if ended:
|
||||
raise RuntimeError(f"capture service stopped unexpectedly: {', '.join(ended)}")
|
||||
time.sleep(min(0.25, deadline - time.monotonic()))
|
||||
except KeyboardInterrupt:
|
||||
interrupted = True
|
||||
except RuntimeError as exc:
|
||||
failure = str(exc)
|
||||
finally:
|
||||
for name, process in processes:
|
||||
exit_codes[name] = terminate(process)
|
||||
|
||||
network_packet_count: int | None = None
|
||||
if failure is None:
|
||||
try:
|
||||
network_packet_count = convert_pcapng_to_pcap(
|
||||
network_pcapng_path,
|
||||
network_pcap_path,
|
||||
)
|
||||
if network_packet_count == 0:
|
||||
failure = "network capture validation found no packets"
|
||||
except Exception as exc: # noqa: BLE001 - third-party parser errors vary by version
|
||||
failure = f"network capture validation failed: {type(exc).__name__}"
|
||||
|
||||
artifacts = [
|
||||
artifact_record(network_pcapng_path, "iphone-network-pcapng-raw", session_dir),
|
||||
artifact_record(network_pcap_path, "iphone-network-pcap-compatible", session_dir),
|
||||
artifact_record(bluetooth_path, "iphone-bluetooth-hci-pcapng", session_dir),
|
||||
artifact_record(stderr_path, "capture-stderr", session_dir),
|
||||
]
|
||||
manifest.update(
|
||||
{
|
||||
"status": "failed" if failure else "completed",
|
||||
"interrupted_by_operator": interrupted,
|
||||
"failure": failure,
|
||||
"ended_at_utc": utc_now(),
|
||||
"ended_monotonic_ns": time.monotonic_ns(),
|
||||
"process_exit_codes": exit_codes,
|
||||
"network_packet_count": network_packet_count,
|
||||
"artifacts": [item for item in artifacts if item is not None],
|
||||
}
|
||||
)
|
||||
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
||||
manifest_path.chmod(0o600)
|
||||
print(f"session: {session_dir}")
|
||||
print(f"status: {manifest['status']}")
|
||||
return 1 if failure else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user