File size: 8,880 Bytes
f8b03c1 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 | """Pre-commit / CI guardrails for sensitive patterns and diff-based checks."""
from __future__ import annotations
import argparse
import ast
import re
import subprocess
import sys
from pathlib import Path
SOURCE_DIR_PREFIXES = (
"backend/apps/",
"backend/apiSystem/",
"backend/scripts/",
"scripts/",
)
FORBIDDEN_BINARY_EXTENSIONS = (".onnx", ".mp4", ".zip")
FORBIDDEN_PRIVATE_PATH_PREFIXES = (
"backend/apps/legal_research/services/sources/weike_api_private/",
"backend/apps/legal_research/services/sources/weike/api_private/",
)
ALLOWLIST_MARKERS = (
"pragma: allowlist secret",
"allowlist secret",
"# nosec",
)
PLACEHOLDER_PATTERN = re.compile(
r"(?i)(example|sample|dummy|placeholder|changeme|redacted|masked|测试|示例|\*\*\*|xxxx)"
)
CN_ID_PATTERN = re.compile(
r"\b[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[0-9Xx]\b"
)
SENSITIVE_LITERAL_ASSIGN_PATTERN = re.compile(
r"(?i)\b(?:password|passwd|pwd|token|access[_-]?token|refresh[_-]?token|api[_-]?key|secret(?:[_-]?key)?|authorization|cookie|sessionid)\b"
r"\s*[:=]\s*['\"][^'\"]{4,}['\"]"
)
BEARER_PATTERN = re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._-]{12,}\b")
CN_MOBILE_PATTERN = re.compile(r"(?<!\d)(?:\+?86[-\s]?)?1[3-9]\d{9}(?!\d)")
EMAIL_PATTERN = re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")
def _run_git(args: list[str]) -> str:
try:
result = subprocess.run(args, capture_output=True, text=True)
except OSError:
return ""
if result.returncode != 0:
return ""
return result.stdout
def _parse_added_lines(diff_text: str) -> list[tuple[int, str]]:
lines: list[tuple[int, str]] = []
current_line = 0
for line in diff_text.splitlines():
if line.startswith("@@"):
match = re.search(r"\+(\d+)", line)
if match:
current_line = int(match.group(1)) - 1
continue
if line.startswith("+") and not line.startswith("+++"):
current_line += 1
lines.append((current_line, line[1:]))
continue
if line.startswith("-"):
continue
current_line += 1
return lines
def _get_changed_files(mode: str, base: str | None, head: str) -> list[str]:
if mode == "staged":
output = _run_git(["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR"])
else:
if not base:
return []
output = _run_git(["git", "diff", "--name-only", "--diff-filter=ACMR", f"{base}..{head}"])
return [line.strip() for line in output.splitlines() if line.strip()]
def _get_added_lines(filepath: str, mode: str, base: str | None, head: str) -> list[tuple[int, str]]:
if mode == "staged":
output = _run_git(["git", "diff", "--cached", "-U0", "--", filepath])
else:
if not base:
return []
output = _run_git(["git", "diff", f"{base}..{head}", "-U0", "--", filepath])
return _parse_added_lines(output)
def _resolve_candidates(files: list[str], mode: str, base: str | None, head: str) -> list[str]:
if files:
return files
return _get_changed_files(mode, base, head)
def _check_todo(filepath: str, mode: str, base: str | None, head: str) -> list[str]:
errors: list[str] = []
for lineno, content in _get_added_lines(filepath, mode, base, head):
if "TODO" in content or "FIXME" in content:
errors.append(f"{filepath}:{lineno}: 新增了 TODO/FIXME 标记")
return errors
def _check_print(filepath: str, mode: str, base: str | None, head: str) -> list[str]:
errors: list[str] = []
try:
source = Path(filepath).read_text(encoding="utf-8")
tree = ast.parse(source, filename=filepath)
except (SyntaxError, OSError, UnicodeDecodeError):
return errors
added_linenos = {ln for ln, _ in _get_added_lines(filepath, mode, base, head)}
if not added_linenos:
return errors
for node in ast.walk(tree):
if isinstance(node, ast.Call):
func = node.func
name = ""
if isinstance(func, ast.Name):
name = func.id
elif isinstance(func, ast.Attribute):
name = func.attr
if name in ("print", "pprint") and node.lineno in added_linenos:
errors.append(f"{filepath}:{node.lineno}: 新增了 {name}() 调用,请改用 logger")
return errors
def _is_forbidden_binary_path(filepath: str) -> str | None:
normalized = filepath.replace("\\", "/")
if not any(normalized.startswith(prefix) for prefix in SOURCE_DIR_PREFIXES):
return None
lowered = normalized.lower()
for ext in FORBIDDEN_BINARY_EXTENSIONS:
if lowered.endswith(ext):
return ext
return None
def _check_binary_ext(files: list[str], mode: str, base: str | None, head: str) -> list[str]:
errors: list[str] = []
for filepath in _resolve_candidates(files, mode, base, head):
ext = _is_forbidden_binary_path(filepath)
if ext:
errors.append(f"{filepath}: 禁止在源码目录提交 {ext} 文件,请改用制品仓/对象存储")
return errors
def _check_private_paths(files: list[str], mode: str, base: str | None, head: str) -> list[str]:
errors: list[str] = []
for filepath in _resolve_candidates(files, mode, base, head):
normalized = filepath.replace("\\", "/").lower()
if any(normalized.startswith(prefix) for prefix in FORBIDDEN_PRIVATE_PATH_PREFIXES):
errors.append(f"{filepath}: 禁止提交威科私有实现目录(weike_api_private/api_private)")
return errors
def _has_allowlist_marker(content: str) -> bool:
lowered = content.lower()
return any(marker in lowered for marker in ALLOWLIST_MARKERS)
def _check_sensitive(files: list[str], mode: str, base: str | None, head: str) -> list[str]:
errors: list[str] = []
for filepath in _resolve_candidates(files, mode, base, head):
# 跳过 lock 文件(自动生成,包含大量 URL 和哈希值)
if filepath.endswith(('.lock', 'uv.lock', 'poetry.lock', 'Pipfile.lock')):
continue
for lineno, content in _get_added_lines(filepath, mode, base, head):
if _has_allowlist_marker(content):
continue
if CN_ID_PATTERN.search(content):
errors.append(f"{filepath}:{lineno}: 检测到疑似身份证号,请脱敏或移除")
continue
if CN_MOBILE_PATTERN.search(content):
if not PLACEHOLDER_PATTERN.search(content):
errors.append(f"{filepath}:{lineno}: 检测到疑似手机号,请脱敏或改为占位符")
continue
if EMAIL_PATTERN.search(content):
if not PLACEHOLDER_PATTERN.search(content):
errors.append(f"{filepath}:{lineno}: 检测到疑似邮箱地址,请脱敏或改为占位符")
continue
if BEARER_PATTERN.search(content):
errors.append(f"{filepath}:{lineno}: 检测到 Bearer Token 字符串,请移除")
continue
if SENSITIVE_LITERAL_ASSIGN_PATTERN.search(content):
if PLACEHOLDER_PATTERN.search(content):
continue
errors.append(f"{filepath}:{lineno}: 检测到密码/Token 关键词字面量赋值,请改为环境变量或密钥管理")
return errors
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--check", choices=["todo", "print", "binary-ext", "private-path", "sensitive"], required=True)
parser.add_argument("--mode", choices=["staged", "range"], default="staged")
parser.add_argument("--base")
parser.add_argument("--head", default="HEAD")
parser.add_argument("files", nargs="*")
args = parser.parse_args()
if args.mode == "range" and not args.base:
print("--mode range 必须提供 --base", file=sys.stderr)
sys.exit(2)
all_errors: list[str] = []
if args.check == "binary-ext":
all_errors.extend(_check_binary_ext(args.files, args.mode, args.base, args.head))
elif args.check == "private-path":
all_errors.extend(_check_private_paths(args.files, args.mode, args.base, args.head))
elif args.check == "sensitive":
all_errors.extend(_check_sensitive(args.files, args.mode, args.base, args.head))
else:
for filepath in args.files:
if args.check == "todo":
all_errors.extend(_check_todo(filepath, args.mode, args.base, args.head))
else:
all_errors.extend(_check_print(filepath, args.mode, args.base, args.head))
if all_errors:
for err in all_errors:
print(err)
sys.exit(1)
if __name__ == "__main__":
main()
|