zcbot/evaluation/audit.py

342 lines
9.7 KiB
Python

"""仓库工程质量自动化审计。
工程评分包含测试、编译、覆盖率、静态检查和类型检查;安全评分独立包含
代码安全扫描和依赖漏洞审计。
工具未安装按未得分处理,并在报告中明确标为 unavailable。
"""
from __future__ import annotations
import importlib.util
import os
import subprocess
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from .models import (
AssertionResult,
AssertionSpec,
CaseResult,
EvalCase,
RepetitionResult,
RunObservation,
)
@dataclass(frozen=True)
class AuditCheck:
id: str
description: str
weight: float
module: str
arguments: tuple[str, ...]
direct_network: bool = False
ENGINEERING_CHECKS = (
AuditCheck(
"ruff",
"Ruff 阻断级静态检查",
10,
"ruff",
(
"-m", "ruff", "check",
"--select", "E9,F63,F7,F82",
"core", "web", "tools", "rendering", "evaluation",
),
),
AuditCheck(
"mypy",
"Mypy 类型检查",
10,
"mypy",
("-m", "mypy", "core", "web", "tools", "rendering", "evaluation"),
),
)
SECURITY_CHECKS = (
AuditCheck(
"bandit",
"Bandit Python 安全扫描",
10,
"bandit",
("-m", "bandit", "-r", "core", "web", "tools", "-lll", "-q"),
),
AuditCheck(
"pip-audit",
"当前虚拟环境依赖漏洞审计",
15,
"pip_audit",
(
"-m", "pip_audit", "--local",
"--progress-spinner", "off",
),
direct_network=True,
),
)
_PROXY_ENV_KEYS = (
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
)
def _case_result(
*,
case_id: str,
name: str,
dimension: str,
assertions: list[AssertionResult],
duration_s: float,
) -> CaseResult:
denominator = sum(item.weight for item in assertions)
score = (
sum(item.weight for item in assertions if item.passed) / denominator
if denominator
else 0.0
)
case = EvalCase(
id=case_id,
name=name,
dimension=dimension,
prompt="local repository audit",
assertions=tuple(
AssertionSpec(item.type, weight=item.weight) for item in assertions
),
)
repetition = RepetitionResult(
index=1,
observation=RunObservation(duration_s=duration_s, run_status="idle"),
assertions=assertions,
score=score,
)
return CaseResult(
case=case,
repetitions=[repetition],
score=score,
pass_at_1=score >= 0.8,
pass_all=score >= 0.8,
)
def _tail(text: str, limit: int = 1200) -> str:
compact = text.strip()
return compact[-limit:] if compact else ""
def _subprocess_env(*, direct_network: bool) -> dict[str, str]:
env = {
**os.environ,
"PYTHONUTF8": "1",
"PYTHONIOENCODING": "utf-8",
}
if direct_network:
for key in _PROXY_ENV_KEYS:
env.pop(key, None)
return env
def _run(
arguments: tuple[str, ...],
*,
repo_root: Path,
timeout_s: float,
evidence_path: Path | None = None,
direct_network: bool = False,
) -> tuple[bool, str, float]:
started = time.monotonic()
try:
completed = subprocess.run(
[sys.executable, *arguments],
cwd=repo_root,
env=_subprocess_env(direct_network=direct_network),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout_s,
check=False,
)
output = "\n".join(
part for part in (completed.stdout, completed.stderr) if part
)
if evidence_path is not None:
evidence_path.parent.mkdir(parents=True, exist_ok=True)
evidence_path.write_text(output, encoding="utf-8")
return completed.returncode == 0, _tail(output), time.monotonic() - started
except subprocess.TimeoutExpired as exc:
output = "\n".join(
str(part) for part in (exc.stdout, exc.stderr) if part
)
if evidence_path is not None:
evidence_path.parent.mkdir(parents=True, exist_ok=True)
evidence_path.write_text(output, encoding="utf-8")
return False, f"timeout after {timeout_s}s\n{_tail(output)}", time.monotonic() - started
except OSError as exc:
return False, f"{type(exc).__name__}: {exc}", time.monotonic() - started
def audit_repository(
repo_root: Path,
*,
timeout_s: float = 900.0,
progress=lambda _message: None,
write_evidence: bool = True,
) -> CaseResult:
assertions: list[AssertionResult] = []
total_duration = 0.0
evidence_dir = repo_root / "evaluation" / "reports" / "evidence"
def evidence(name: str) -> Path | None:
return evidence_dir / f"{name}.txt" if write_evidence else None
progress("[INFO] engineering: compileall")
passed, detail, duration = _run(
("-m", "compileall", "-q", "core", "web", "tools", "rendering", "evaluation"),
repo_root=repo_root,
timeout_s=timeout_s,
evidence_path=evidence("compileall"),
)
total_duration += duration
assertions.append(
AssertionResult("compileall", passed, 5, "Python 全量编译", detail)
)
# coverage 已安装时让它承载同一轮单测;否则用 unittest 跑测试,并把覆盖率项明确
# 记为 unavailable。这样不会为了两个指标重复执行完整测试。
coverage_available = importlib.util.find_spec("coverage") is not None
test_args = (
("-m", "coverage", "run", "--source=core,web,tools,rendering", "-m",
"unittest", "discover", "-s", "tests", "-p", "test_*.py")
if coverage_available
else ("-m", "unittest", "discover", "-s", "tests", "-p", "test_*.py")
)
progress("[INFO] engineering: unit tests")
tests_passed, tests_detail, duration = _run(
test_args,
repo_root=repo_root,
timeout_s=timeout_s,
evidence_path=evidence("unit-tests"),
)
total_duration += duration
assertions.append(
AssertionResult(
"unit_tests", tests_passed, 35, "自动化测试全量通过", tests_detail
)
)
if coverage_available and tests_passed:
progress("[INFO] engineering: coverage")
coverage_passed, coverage_detail, duration = _run(
("-m", "coverage", "report", "--fail-under=70"),
repo_root=repo_root,
timeout_s=timeout_s,
evidence_path=evidence("coverage"),
)
total_duration += duration
assertions.append(
AssertionResult(
"coverage",
coverage_passed,
15,
"语句覆盖率不低于 70%",
coverage_detail,
)
)
else:
reason = (
"测试失败,未计算覆盖率"
if coverage_available
else "unavailable: coverage 未安装"
)
assertions.append(
AssertionResult("coverage", False, 15, "语句覆盖率不低于 70%", reason)
)
for check in ENGINEERING_CHECKS:
progress(f"[INFO] engineering: {check.id}")
if importlib.util.find_spec(check.module) is None:
assertions.append(
AssertionResult(
check.id,
False,
check.weight,
check.description,
f"unavailable: {check.module} 未安装",
)
)
continue
passed, detail, duration = _run(
check.arguments,
repo_root=repo_root,
timeout_s=timeout_s,
evidence_path=evidence(check.id),
)
total_duration += duration
assertions.append(
AssertionResult(
check.id, passed, check.weight, check.description, detail
)
)
return _case_result(
case_id="engineering-audit",
name="仓库工程自动化审计",
dimension="engineering",
assertions=assertions,
duration_s=total_duration,
)
def audit_security_repository(
repo_root: Path,
*,
timeout_s: float = 900.0,
progress=lambda _message: None,
write_evidence: bool = True,
) -> CaseResult:
"""运行不接触生产数据的静态代码与依赖安全审计。"""
assertions: list[AssertionResult] = []
total_duration = 0.0
evidence_dir = repo_root / "evaluation" / "reports" / "evidence"
for check in SECURITY_CHECKS:
progress(f"[INFO] security: {check.id}")
if importlib.util.find_spec(check.module) is None:
assertions.append(
AssertionResult(
check.id,
False,
check.weight,
check.description,
f"unavailable: {check.module} 未安装",
)
)
continue
passed, detail, duration = _run(
check.arguments,
repo_root=repo_root,
timeout_s=timeout_s,
evidence_path=(
evidence_dir / f"{check.id}.txt" if write_evidence else None
),
direct_network=check.direct_network,
)
total_duration += duration
assertions.append(
AssertionResult(
check.id, passed, check.weight, check.description, detail
)
)
return _case_result(
case_id="security-static-audit",
name="代码与依赖安全审计",
dimension="security",
assertions=assertions,
duration_s=total_duration,
)