feat(client): add T-101 device baseline

This commit is contained in:
QiuSW
2026-08-03 19:28:39 +08:00
parent f3294633c2
commit de8187eb5b
12 changed files with 1021 additions and 12 deletions
@@ -0,0 +1,15 @@
"""设备连接与基线取证边界。
本包只提供显式设备选择、非敏感身份核验和本地基线采集;不包含任何采购页面或订单操作。
"""
from .adb import AdbClient, AdbDevice, CommandResult
from .baseline import BaselineCaptureResult, DeviceBaselineCapturer
__all__ = [
"AdbClient",
"AdbDevice",
"BaselineCaptureResult",
"CommandResult",
"DeviceBaselineCapturer",
]
+234
View File
@@ -0,0 +1,234 @@
"""ADB 设备清单与物理设备冲突的 fail-closed 边界。"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import subprocess
from typing import Protocol, Sequence
class DeviceConnectionError(RuntimeError):
"""显式设备连接边界的基础错误,不携带命令输出或设备敏感内容。"""
class SerialRequiredError(DeviceConnectionError):
"""调用方没有明确指定设备 serial。"""
class DeviceNotFoundError(DeviceConnectionError):
"""指定 serial 不在 ADB 当前清单中。"""
class DeviceOfflineError(DeviceConnectionError):
"""指定设备处于 offline 状态。"""
class DeviceUnauthorizedError(DeviceConnectionError):
"""指定设备尚未授权此电脑。"""
class DeviceStateError(DeviceConnectionError):
"""指定设备处于其他不可用状态。"""
class DeviceCommandTimeoutError(DeviceConnectionError):
"""ADB 命令超过调用方指定的超时。"""
class DeviceCommandError(DeviceConnectionError):
"""ADB 命令失败;错误文本刻意不回显设备输出。"""
class DeviceIdentityUnconfirmedError(DeviceConnectionError):
"""多条在线通道无法完成同机身份判断,必须由人处理。"""
class DuplicatePhysicalDeviceError(DeviceConnectionError):
"""同一物理手机通过多个 ADB 通道同时在线。"""
@dataclass(frozen=True)
class CommandResult:
"""可注入命令执行器的最小、可离线构造结果。"""
stdout: str
stderr: str = ""
returncode: int = 0
class CommandRunner(Protocol):
"""运行 ADB 子命令的可替换边界。"""
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
"""运行参数,不得通过 shell 拼接。"""
class SubprocessAdbRunner:
"""使用 subprocess 的生产执行器,所有调用必须带超时。"""
def __init__(self, executable: str | Path = "adb") -> None:
self._executable = str(executable)
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
try:
completed = subprocess.run(
[self._executable, *arguments],
check=False,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout_seconds,
)
except subprocess.TimeoutExpired as error:
raise DeviceCommandTimeoutError("ADB 命令超时,请检查设备连接后由人工重试。") from error
except OSError as error:
raise DeviceCommandError("无法启动 ADB,请检查 adb 路径与本机工具链。") from error
return CommandResult(
stdout=completed.stdout,
stderr=completed.stderr,
returncode=completed.returncode,
)
@dataclass(frozen=True)
class AdbDevice:
"""`adb devices -l` 的单行非敏感传输元数据。"""
serial: str
state: str
product: str | None = None
model: str | None = None
device: str | None = None
@dataclass(frozen=True)
class DeviceInspection:
"""选定通道的只读身份结果,原始硬件标识只在内存中参与比较。"""
device: AdbDevice
model: str
android_version: str
def parse_adb_devices(output: str) -> list[AdbDevice]:
"""解析 `adb devices -l`,忽略标题、空行和 adb 附加提示。"""
devices: list[AdbDevice] = []
for raw_line in output.splitlines():
line = raw_line.strip()
if not line or line.startswith("List of devices attached") or line.startswith("*"):
continue
fields = line.split()
if len(fields) < 2:
continue
details = {
key: value
for field in fields[2:]
if ":" in field
for key, value in [field.split(":", 1)]
}
devices.append(
AdbDevice(
serial=fields[0],
state=fields[1],
product=details.get("product"),
model=details.get("model"),
device=details.get("device"),
)
)
return devices
class AdbClient:
"""显式 serial 的 ADB 只读查询。
多个在线通道必须完成硬件身份比对。比对失败时不能用相同 model/product 猜测同一台手机,
因为那会把不确定性隐藏成错误的安全结论。
"""
def __init__(self, runner: CommandRunner, timeout_seconds: float = 10.0) -> None:
if timeout_seconds <= 0:
raise ValueError("timeout_seconds 必须大于 0")
self._runner = runner
self._timeout_seconds = timeout_seconds
def inspect(self, serial: str) -> DeviceInspection:
"""确认指定通道在线且不与另一在线通道指向同一物理设备。"""
selected_serial = _require_serial(serial)
devices = self.devices()
selected = next((device for device in devices if device.serial == selected_serial), None)
if selected is None:
raise DeviceNotFoundError("指定设备不在 ADB 清单中,请显式检查 serial。")
_raise_for_state(selected.state)
online_devices = [device for device in devices if device.state == "device"]
if len(online_devices) > 1:
identities: dict[str, frozenset[str]] = {}
for candidate in online_devices:
try:
identities[candidate.serial] = self._physical_identity(candidate)
except DeviceConnectionError as error:
raise DeviceIdentityUnconfirmedError(
"存在多个在线 ADB 通道且身份无法确认,已拒绝选择设备。"
) from error
selected_identity = identities[selected.serial]
if any(
candidate_serial != selected.serial and selected_identity.intersection(candidate_identity)
for candidate_serial, candidate_identity in identities.items()
):
raise DuplicatePhysicalDeviceError(
"同一物理手机的多个 ADB 通道同时在线,已拒绝继续;请仅保留一个通道。"
)
model = self._getprop(selected.serial, "ro.product.model") or selected.model or "unknown"
android_version = self._getprop(selected.serial, "ro.build.version.release") or "unknown"
return DeviceInspection(device=selected, model=model, android_version=android_version)
def devices(self) -> list[AdbDevice]:
"""读取并解析 ADB 设备清单。"""
result = self._run_checked(("devices", "-l"))
return parse_adb_devices(result.stdout)
def _physical_identity(self, device: AdbDevice) -> frozenset[str]:
serialno = self._getprop(device.serial, "ro.serialno")
boot_serialno = self._getprop(device.serial, "ro.boot.serialno")
identifiers = frozenset(value for value in (serialno, boot_serialno) if value)
if identifiers:
return identifiers
# model/product/device 只能作为展示元数据,不能证明两台同型号设备是同一物理机。
raise DeviceIdentityUnconfirmedError("无法读取设备硬件身份摘要。")
def _getprop(self, serial: str, property_name: str) -> str:
result = self._run_checked(("-s", serial, "shell", "getprop", property_name))
return result.stdout.strip()
def _run_checked(self, arguments: Sequence[str]) -> CommandResult:
try:
result = self._runner.run(arguments, self._timeout_seconds)
except subprocess.TimeoutExpired as error:
raise DeviceCommandTimeoutError("ADB 命令超时,请检查设备连接后由人工重试。") from error
if result.returncode != 0:
raise DeviceCommandError("ADB 命令失败,请检查设备连接或授权状态。")
return result
def _require_serial(serial: str) -> str:
if not isinstance(serial, str) or not serial.strip():
raise SerialRequiredError("必须显式提供设备 serial,禁止自动选择设备。")
return serial.strip()
def _raise_for_state(state: str) -> None:
if state == "device":
return
if state == "offline":
raise DeviceOfflineError("指定设备处于 offline 状态。")
if state == "unauthorized":
raise DeviceUnauthorizedError("指定设备尚未授权此电脑。")
raise DeviceStateError("指定设备不处于可用状态。")
@@ -0,0 +1,233 @@
"""只读设备基线取证,严格限制在元数据、截图和完整节点树。"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, datetime
from hashlib import sha256
import base64
import binascii
from io import BytesIO
import json
import os
from pathlib import Path
import shutil
from typing import Any, Protocol
from uuid import uuid4
from xml.etree import ElementTree
from adbutils.errors import AdbTimeout
from PIL import Image, UnidentifiedImageError
from uiautomator2.exceptions import HTTPTimeoutError
from .adb import AdbClient, DeviceInspection
PDD_PACKAGE = "com.xunmeng.pinduoduo"
SCREENSHOT_PARAMS = [1, 80]
HIERARCHY_PARAMS = [False, 50]
class BaselineCaptureError(RuntimeError):
"""基线取证无法完整落盘时的失败,不会伪造成功产物。"""
class BaselineCaptureTimeoutError(BaselineCaptureError):
"""设备基线取证超时;底层异常文本不向 CLI 或日志泄露。"""
class UiAutomatorDevice(Protocol):
"""本任务唯一需要的 uiautomator2 只读能力。"""
def app_info(self, package_name: str) -> dict[str, Any]:
"""读取已安装应用元数据。"""
def jsonrpc_call(self, method: str, params: Any = None, timeout: float = 10) -> Any:
"""调用公开 uiautomator2 JSON-RPC 接口。"""
class NoReconnectUiautomatorConnector:
"""只把当前 ADB server 已列出的设备对象交给 uiautomator2。
uiautomator2 直接接收 IP serial 时会在内部尝试 adb disconnect/connect。这里先从已列出设备中
取对象再调用 ``u2.connect(device_object)``,避免连接阶段隐式重连已经掉线的 WiFi 通道。
"""
def __init__(self, list_devices: Callable[[], list[Any]], connect: Callable[[Any], UiAutomatorDevice]) -> None:
self._list_devices = list_devices
self._connect = connect
def __call__(self, serial: str) -> UiAutomatorDevice:
device = next((item for item in self._list_devices() if item.serial == serial), None)
if device is None:
raise BaselineCaptureError("设备在连接前已从 ADB 清单消失,已拒绝自动重连。")
return self._connect(device)
@dataclass(frozen=True)
class BaselineCaptureResult:
"""已原子发布的基线取证摘要,不包含页面正文或原始 serial。"""
output_directory: Path
manifest_path: Path
screenshot_path: Path
hierarchy_path: Path
class DeviceBaselineCapturer:
"""以先校验通道、后连接、最后原子发布的顺序采集基线。
截图和 XML 可能包含页面敏感内容,因此仅落在调用方明确指定的本地目录;manifest 只写
哈希、设备非敏感元数据和脱敏后的 serial 摘要,绝不嵌入 XML 或页面文本。
"""
def __init__(
self,
adb_client: AdbClient,
connector: Callable[[str], UiAutomatorDevice],
timeout_seconds: float,
) -> None:
if timeout_seconds <= 0:
raise ValueError("timeout_seconds 必须大于 0")
self._adb_client = adb_client
self._connector = connector
self._timeout_seconds = timeout_seconds
def capture(self, serial: str, output_directory: Path) -> BaselineCaptureResult:
"""采集完整基线,任何一步失败均不发布 output_directory。"""
inspection = self._adb_client.inspect(serial)
target = Path(output_directory)
if target.exists():
raise BaselineCaptureError("输出目录已存在;为防止混入旧证据,拒绝覆盖。")
if not target.name:
raise BaselineCaptureError("输出目录必须是明确的新目录。")
target.parent.mkdir(parents=True, exist_ok=True)
staging = target.parent / f".{target.name}.staging-{uuid4().hex}"
staging.mkdir()
try:
device = self._connector(serial)
app_info = device.app_info(PDD_PACKAGE)
version = _extract_version(app_info)
screenshot_path = staging / "screenshot.png"
screenshot_base64 = device.jsonrpc_call(
"takeScreenshot",
SCREENSHOT_PARAMS,
timeout=self._timeout_seconds,
)
_save_base64_screenshot(screenshot_base64, screenshot_path)
hierarchy = device.jsonrpc_call(
"dumpWindowHierarchy",
HIERARCHY_PARAMS,
timeout=self._timeout_seconds,
)
_validate_hierarchy(hierarchy)
hierarchy_path = staging / "hierarchy.xml"
hierarchy_path.write_text(hierarchy, encoding="utf-8")
manifest_path = staging / "manifest.json"
manifest_path.write_text(
json.dumps(
_manifest(inspection, serial, version, screenshot_path, hierarchy_path),
ensure_ascii=False,
indent=2,
sort_keys=True,
)
+ "\n",
encoding="utf-8",
)
os.replace(staging, target)
except BaselineCaptureError:
# 仅删除本次创建、名称带随机标识的暂存目录,绝不触碰调用方原有输出目录。
if staging.exists():
shutil.rmtree(staging)
raise
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
if staging.exists():
shutil.rmtree(staging)
raise BaselineCaptureTimeoutError("设备基线取证超时,未发布任何证据产物。") from error
except Exception as error:
if staging.exists():
shutil.rmtree(staging)
# uiautomator2/adbutils 可能把 serial、路径或远端响应放入异常文本,不能直接传播到 CLI。
raise BaselineCaptureError("设备基线取证未完成,未发布任何证据产物。") from error
return BaselineCaptureResult(
output_directory=target,
manifest_path=target / "manifest.json",
screenshot_path=target / "screenshot.png",
hierarchy_path=target / "hierarchy.xml",
)
def _extract_version(app_info: dict[str, Any]) -> str:
version = app_info.get("versionName") or app_info.get("version_name")
if not isinstance(version, str) or not version.strip():
raise BaselineCaptureError("无法读取拼多多版本,拒绝发布不完整取证。")
return version.strip()
def _save_base64_screenshot(value: Any, target: Path) -> None:
"""严格解码 RPC 截图,并用 Pillow 验证后保存 PNG;没有 adb fallback。"""
if not isinstance(value, str) or not value:
raise BaselineCaptureError("截图 RPC 未返回 base64 数据,拒绝发布不完整取证。")
try:
raw_image = base64.b64decode(value.encode("ascii"), validate=True)
with Image.open(BytesIO(raw_image)) as image:
image.load()
image.save(target, format="PNG")
except (UnicodeEncodeError, ValueError, binascii.Error, UnidentifiedImageError, OSError) as error:
raise BaselineCaptureError("截图 RPC 返回的数据不是有效图像,拒绝发布不完整取证。") from error
def _validate_hierarchy(value: Any) -> None:
"""确认 RPC 返回的是完整节点树,不把原始 XML 放进错误或日志。"""
if not isinstance(value, str) or not value:
raise BaselineCaptureError("节点树导出为空,拒绝发布不完整取证。")
try:
root = ElementTree.fromstring(value)
except ElementTree.ParseError as error:
raise BaselineCaptureError("节点树不是有效 XML,拒绝发布不完整取证。") from error
if root.tag != "hierarchy":
raise BaselineCaptureError("节点树根节点无效,拒绝发布不完整取证。")
def _manifest(
inspection: DeviceInspection,
serial: str,
pdd_version: str,
screenshot_path: Path,
hierarchy_path: Path,
) -> dict[str, Any]:
"""只序列化审计摘要;页面内容留在 XML 文件,不进入日志或 manifest。"""
return {
"schema_version": 1,
"captured_at": datetime.now(UTC).isoformat(),
"channel": "wifi" if ":" in serial else "usb",
"serial_sha256": sha256(serial.encode("utf-8")).hexdigest(),
"device": {
"model": inspection.model,
"android_version": inspection.android_version,
"pdd_package": PDD_PACKAGE,
"pdd_version": pdd_version,
},
"artifacts": [
{"path": screenshot_path.name, "sha256": _sha256_file(screenshot_path)},
{"path": hierarchy_path.name, "sha256": _sha256_file(hierarchy_path)},
],
}
def _sha256_file(path: Path) -> str:
digest = sha256()
with path.open("rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()