Files
ctms/backend/scripts/storage_persistence_audit.py
T
2026-05-21 15:48:19 +08:00

170 lines
6.2 KiB
Python
Executable File

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import re
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Iterable
REPO_ROOT = Path(__file__).resolve().parents[2]
FRONTEND_SRC = REPO_ROOT / "frontend" / "src"
FILE_GLOBS = ("**/*.ts", "**/*.tsx", "**/*.vue")
CALL_PATTERNS = {
"localStorage.getItem": re.compile(r"localStorage\.getItem\(([^)]+)\)"),
"localStorage.setItem": re.compile(r"localStorage\.setItem\(([^,)]+)"),
"localStorage.removeItem": re.compile(r"localStorage\.removeItem\(([^)]+)\)"),
"sessionStorage.getItem": re.compile(r"sessionStorage\.getItem\(([^)]+)\)"),
"sessionStorage.setItem": re.compile(r"sessionStorage\.setItem\(([^,)]+)"),
"sessionStorage.removeItem": re.compile(r"sessionStorage\.removeItem\(([^)]+)\)"),
}
BANNED_KEY_MARKERS = ("audit_local_",)
@dataclass
class Finding:
file: str
line: int
call: str
key_expr: str
severity: str
category: str
note: str
def iter_source_files() -> Iterable[Path]:
for pattern in FILE_GLOBS:
for path in FRONTEND_SRC.glob(pattern):
if path.is_file():
yield path
def classify(file_path: str, key_expr: str) -> tuple[str, str, str]:
normalized = key_expr.replace('"', "").replace("'", "").strip()
lowered = normalized.lower()
file_lower = file_path.lower()
if "projectdetail.vue" in file_lower:
if "publishedprojectsignaturekey" in lowered:
return ("medium", "publish-helper", "发布比对签名缓存,属于辅助数据")
if "storagekey" in lowered or "projectdraftstoragekey" in lowered:
return ("high", "business-draft", "立项配置草稿本地兜底,需显式提示未落库")
if "audit_local_" in lowered:
return ("high", "audit-data", "审计日志本地缓存,不能作为正式审计数据源")
if "ctms_setup_config_draft_" in lowered or "ctms_setup_project_draft_" in lowered:
return ("high", "business-draft", "立项配置草稿本地兜底,需显式提示未落库")
if "ctms_setup_published_project_sig_" in lowered:
return ("medium", "publish-helper", "发布比对签名缓存,属于辅助数据")
if any(k in lowered for k in ("token_key", "last_login_email_key", "logout_reason_storage_key")):
return ("low", "auth-session", "认证/会话数据(非业务主数据)")
if any(k in lowered for k in ("ctms_token", "ctms_last_login_email", "credential", "auth")):
return ("low", "auth-session", "认证/会话数据(非业务主数据)")
if any(k in lowered for k in ("ctms_sidebar_collapsed", "study", "site", "role")):
return ("low", "ui-preference", "UI偏好/上下文缓存")
return ("medium", "unknown", "未命中规则,建议人工复核是否为业务关键数据")
def scan_findings() -> list[Finding]:
findings: list[Finding] = []
for path in iter_source_files():
try:
content = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
content = path.read_text(encoding="utf-8", errors="ignore")
lines = content.splitlines()
rel = str(path.relative_to(REPO_ROOT))
for idx, line in enumerate(lines, start=1):
for call_name, pattern in CALL_PATTERNS.items():
for match in pattern.finditer(line):
key_expr = match.group(1).strip()
severity, category, note = classify(rel, key_expr)
findings.append(
Finding(
file=rel,
line=idx,
call=call_name,
key_expr=key_expr,
severity=severity,
category=category,
note=note,
)
)
return findings
def markdown_report(findings: list[Finding]) -> str:
counts: dict[str, int] = {"high": 0, "medium": 0, "low": 0}
for item in findings:
counts[item.severity] = counts.get(item.severity, 0) + 1
lines = [
"# 存储落库核查报告",
"",
f"- 扫描目录: `{FRONTEND_SRC}`",
f"- 总发现数: `{len(findings)}`",
f"- 高风险: `{counts['high']}` / 中风险: `{counts['medium']}` / 低风险: `{counts['low']}`",
"",
"## 明细",
"",
"| 风险 | 分类 | 文件 | 行号 | 调用 | key表达式 | 说明 |",
"| --- | --- | --- | --- | --- | --- | --- |",
]
for item in sorted(findings, key=lambda x: (x.severity, x.file, x.line)):
lines.append(
f"| {item.severity} | {item.category} | `{item.file}` | {item.line} | `{item.call}` | `{item.key_expr}` | {item.note} |"
)
return "\n".join(lines)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Scan frontend storage usage and classify persistence risks.")
parser.add_argument("--format", choices=("markdown", "json"), default="markdown")
parser.add_argument("--output", default="", help="Optional output file path.")
parser.add_argument(
"--fail-on-banned",
action="store_true",
help="Exit with code 2 when banned local key markers are detected.",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
findings = scan_findings()
banned_hits = []
if args.fail_on_banned:
for item in findings:
expr = item.key_expr.lower().replace('"', "").replace("'", "")
if any(marker in expr for marker in BANNED_KEY_MARKERS):
banned_hits.append(item)
if args.format == "json":
output_text = json.dumps([asdict(item) for item in findings], ensure_ascii=False, indent=2)
else:
output_text = markdown_report(findings)
if args.output:
out = Path(args.output)
if not out.is_absolute():
out = REPO_ROOT / out
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(output_text + "\n", encoding="utf-8")
else:
print(output_text)
if banned_hits:
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main())