Files
matrix-screen-controller/核桃派软件源代码/scripts/check_repository_hygiene.py
T

328 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import argparse
import ipaddress
import json
import os
import re
import subprocess
import sys
from pathlib import Path, PurePosixPath
SCRIPT_PATH = Path(__file__).resolve()
WORKSPACE_ROOT = SCRIPT_PATH.parents[2]
PRIVATE_CREDENTIAL = PurePosixPath("测试相关资料/核桃派的用户名和密码和ip/用户名密码ip.txt")
EXAMPLE_CREDENTIAL = PurePosixPath(
"测试相关资料/核桃派的用户名和密码和ip/用户名密码ip.example.txt"
)
PRIVATE_MOBILE_REGISTRATION = PurePosixPath(
"移动端相关内容/安卓app/如何安卓测试/测试设备登记/测试设备.local.json"
)
EXAMPLE_MOBILE_REGISTRATION = PRIVATE_MOBILE_REGISTRATION.with_name("测试设备.example.json")
BINARY_EXTENSIONS = {
".apk",
".aab",
".jar",
".deb",
".dll",
".docx",
".exe",
".gif",
".gz",
".img",
".jpeg",
".jpg",
".otf",
".ota",
".pdf",
".png",
".rar",
".so",
".ttf",
".whl",
".woff",
".woff2",
".zip",
}
PRIVATE_IP_ALLOWED_PREFIXES = (
"整体开发需求/",
"测试相关资料/如何测试/",
"核桃派软件源代码/",
"发布更新相关/其他依赖/",
)
SAFE_SECRET_VALUES = {
"changeme",
"example",
"example123",
"fake",
"fake-secret",
"password",
"secret123",
"test",
"test-password",
}
# The password is a high-confidence private marker. Addresses, usernames and
# hostnames are checked structurally because common fixture values also occur in
# provisioning source and tests.
CURRENT_DEVICE_KEYS = ("密码",)
PUBLIC_IMAGE_KEYS = (
"镜像默认用户名",
"镜像默认账户密码",
"镜像默认WiFi SSID",
"镜像默认WiFi密码",
)
class HygieneError(RuntimeError):
"""Repository state cannot be audited safely."""
def _git(*arguments: str, check: bool = True) -> subprocess.CompletedProcess[bytes]:
return subprocess.run(
("git", *arguments),
cwd=WORKSPACE_ROOT,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=check,
)
def _decode_paths(payload: bytes) -> list[PurePosixPath]:
return [
PurePosixPath(item.decode("utf-8", errors="surrogateescape"))
for item in payload.split(b"\0")
if item
]
def candidate_paths(staged: bool) -> list[PurePosixPath]:
if staged:
result = _git("diff", "--cached", "--name-only", "--diff-filter=ACMR", "-z")
return _decode_paths(result.stdout)
tracked = _decode_paths(_git("ls-files", "-z").stdout)
untracked = _decode_paths(_git("ls-files", "--others", "--exclude-standard", "-z").stdout)
return sorted(set((*tracked, *untracked)), key=str)
def _is_binary(path: Path) -> bool:
if path.suffix.casefold() in BINARY_EXTENSIONS:
return True
try:
return b"\0" in path.read_bytes()[:8192]
except OSError as error:
raise HygieneError(f"无法读取候选文件:{path.relative_to(WORKSPACE_ROOT)}") from error
def _host_text_patterns() -> list[re.Pattern[str]]:
windows_prefix = r"[A-Za-z]:[\\/]" + r"(?:Users|Documents and Settings)[\\/]"
mac_prefix = r"/" + r"Users/[A-Za-z0-9._-]+/"
linux_home = r"/" + r"home/[A-Za-z0-9._-]+/"
patterns = [re.compile(windows_prefix, re.IGNORECASE), re.compile(mac_prefix), re.compile(linux_home)]
for value in (Path.home().name, os.environ.get("COMPUTERNAME", "")):
if value and len(value) >= 4:
patterns.append(re.compile(re.escape(value), re.IGNORECASE))
return patterns
def _private_value_markers() -> tuple[str, ...]:
path = WORKSPACE_ROOT.joinpath(*PRIVATE_CREDENTIAL.parts)
if not path.is_file():
return _mobile_value_markers()
fields: dict[str, str] = {}
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
separator = ":" if ":" in line else ":" if ":" in line else None
if separator is None:
continue
key, value = (part.strip() for part in line.split(separator, 1))
fields[key] = value
public_values = {fields.get(key, "") for key in PUBLIC_IMAGE_KEYS}
return _mobile_value_markers() + tuple(
value
for key in CURRENT_DEVICE_KEYS
if len(value := fields.get(key, "")) >= 4 and value not in public_values
)
def _mobile_value_markers() -> tuple[str, ...]:
path = WORKSPACE_ROOT.joinpath(*PRIVATE_MOBILE_REGISTRATION.parts)
if not path.is_file():
return ()
try:
document = json.loads(path.read_text(encoding="utf-8"))
devices = document["devices"]
if not isinstance(devices, list):
raise ValueError
values = []
for device in devices:
if not isinstance(device, dict):
raise ValueError
serial = device.get("serial", "")
if not isinstance(serial, str):
raise ValueError
if serial and "<" not in serial and len(serial) >= 4:
values.append(serial)
return tuple(values)
except (OSError, UnicodeError, ValueError, KeyError, TypeError):
raise HygieneError("移动端真实登记损坏,无法安全提取私有标识;未输出内容。") from None
def _binary_markers(private_values: tuple[str, ...] = ()) -> list[bytes]:
markers: list[bytes] = []
host_values = {
str(Path.home()),
str(WORKSPACE_ROOT),
Path.home().name,
os.environ.get("COMPUTERNAME", ""),
}
for value in (*host_values, *private_values):
if value and len(value) >= 4:
variants = {value, value.replace("\\", "/")}
for variant in variants:
markers.extend((variant.encode("utf-8"), variant.encode("utf-16le")))
return markers
def _binary_contains_forbidden_marker(path: Path, private_values: tuple[str, ...]) -> bool:
markers = _binary_markers(private_values)
overlap = max(map(len, markers)) - 1
tail = b""
with path.open("rb") as stream:
while chunk := stream.read(8 * 1024 * 1024):
sample = tail + chunk
lowered_sample = sample.lower()
if any(marker.lower() in lowered_sample for marker in markers):
return True
tail = sample[-overlap:] if overlap else b""
return False
def _text_issues(
relative: PurePosixPath,
text: str,
private_values: tuple[str, ...] = (),
) -> list[str]:
issues: list[str] = []
if any(pattern.search(text) for pattern in _host_text_patterns()):
issues.append("包含开发电脑专属路径、用户名或主机名")
if any(value in text for value in private_values):
issues.append("包含当前设备私有凭据或测试手机标识")
key_header = re.compile("-----BEGIN " + r"(?:OPENSSH |RSA |EC |DSA )?PRIVATE KEY-----")
if key_header.search(text):
issues.append("包含私钥正文")
relative_string = relative.as_posix()
if not (
relative_string.startswith("核桃派软件源代码/tests/")
or PurePosixPath(relative_string).name.startswith("test_")
or relative == EXAMPLE_CREDENTIAL
):
assignment = re.compile(
r"(?i)(?:password|passwd|token|secret|api[_-]?key|密码)\s*[:=]\s*[\"']([^\"']{4,})[\"']"
)
for match in assignment.finditer(text):
if match.group(1).casefold() not in SAFE_SECRET_VALUES:
issues.append("包含疑似硬编码秘密")
break
if not (
relative_string.startswith(PRIVATE_IP_ALLOWED_PREFIXES)
or "/tests/" in f"/{relative_string}"
):
for token in re.findall(r"(?<![0-9])(?:[0-9]{1,3}\.){3}[0-9]{1,3}(?![0-9])", text):
try:
address = ipaddress.ip_address(token)
except ValueError:
continue
if address.is_private and not address.is_loopback and not address.is_unspecified:
issues.append("归档或普通文档包含私有 IPv4 地址")
break
return issues
def audit_paths(paths: list[PurePosixPath], *, scan_binary: bool) -> list[tuple[str, str]]:
findings: list[tuple[str, str]] = []
private_values = _private_value_markers()
for relative in paths:
if relative in (PRIVATE_CREDENTIAL, PRIVATE_MOBILE_REGISTRATION):
findings.append((relative.as_posix(), "真实凭据进入 Git 候选集合"))
continue
if relative.suffix.lower() in {".jks", ".keystore"}:
findings.append((relative.as_posix(), "签名密钥文件进入 Git 候选集合"))
continue
path = WORKSPACE_ROOT.joinpath(*relative.parts)
if not path.is_file():
continue
if _is_binary(path):
if scan_binary and _binary_contains_forbidden_marker(path, private_values):
findings.append((relative.as_posix(), "二进制包含开发电脑标识、主目录路径或当前设备秘密"))
continue
try:
text = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
findings.append((relative.as_posix(), "文本候选不是有效 UTF-8"))
continue
findings.extend(
(relative.as_posix(), issue) for issue in _text_issues(relative, text, private_values)
)
return findings
def _check_repository_contract(paths: list[PurePosixPath], staged: bool) -> list[tuple[str, str]]:
findings: list[tuple[str, str]] = []
ignored = _git("check-ignore", "--quiet", "--", PRIVATE_CREDENTIAL.as_posix(), check=False)
if ignored.returncode != 0:
findings.append((PRIVATE_CREDENTIAL.as_posix(), "真实凭据未被 .gitignore 精确排除"))
mobile_ignored = _git("check-ignore", "--quiet", "--", PRIVATE_MOBILE_REGISTRATION.as_posix(), check=False)
if mobile_ignored.returncode != 0:
findings.append((PRIVATE_MOBILE_REGISTRATION.as_posix(), "真实手机登记未被 .gitignore 排除"))
if staged:
mobile_example = _git("cat-file", "-e", f":{EXAMPLE_MOBILE_REGISTRATION.as_posix()}", check=False)
if mobile_example.returncode != 0:
findings.append((EXAMPLE_MOBILE_REGISTRATION.as_posix(), "手机空白登记示例不在 Git 索引"))
elif EXAMPLE_MOBILE_REGISTRATION not in paths:
findings.append((EXAMPLE_MOBILE_REGISTRATION.as_posix(), "手机空白登记示例不在 Git 候选集合"))
if staged:
example_in_index = _git("cat-file", "-e", f":{EXAMPLE_CREDENTIAL.as_posix()}", check=False)
if example_in_index.returncode != 0:
findings.append((EXAMPLE_CREDENTIAL.as_posix(), "示例凭据没有进入本次提交"))
elif EXAMPLE_CREDENTIAL not in paths:
findings.append((EXAMPLE_CREDENTIAL.as_posix(), "示例凭据不在 Git 候选集合"))
return findings
def main() -> int:
parser = argparse.ArgumentParser(description="检查 Git 候选文件中的凭据、主机路径和秘密")
parser.add_argument("--staged", action="store_true", help="只检查已暂存的新建或修改文件")
parser.add_argument(
"--skip-binary-scan",
action="store_true",
help="跳过大体积二进制字节扫描,仅供快速诊断",
)
args = parser.parse_args()
try:
paths = candidate_paths(args.staged)
findings = _check_repository_contract(paths, args.staged)
findings.extend(audit_paths(paths, scan_binary=not args.skip_binary_scan))
except (HygieneError, OSError, subprocess.CalledProcessError) as error:
print(f"仓库卫生检查无法完成:{error}", file=sys.stderr)
return 2
if findings:
print("仓库卫生检查失败;以下输出只包含文件路径和问题类型:", file=sys.stderr)
for path, issue in findings:
print(f"- {path}: {issue}", file=sys.stderr)
return 1
print(f"仓库卫生检查通过:已检查 {len(paths)} 个 Git 候选文件,未输出任何秘密值。")
return 0
if __name__ == "__main__":
raise SystemExit(main())