83 lines
3.0 KiB
Python
83 lines
3.0 KiB
Python
|
|
#!/usr/bin/env python3
|
|||
|
|
"""轻量、零依赖的 Git 暂存/推送密钥扫描器。"""
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import argparse
|
|||
|
|
import os
|
|||
|
|
import re
|
|||
|
|
import subprocess
|
|||
|
|
import sys
|
|||
|
|
from pathlib import Path
|
|||
|
|
|
|||
|
|
FORBIDDEN_PATH = re.compile(
|
|||
|
|
r"(^|/)(\.env(?:\..*)?|secrets?/|.*\.(?:pem|key|p12|pfx))$", re.I
|
|||
|
|
)
|
|||
|
|
ASSIGNMENT = re.compile(
|
|||
|
|
r"\b(?:[A-Z][A-Z0-9_]*(?:API_KEY|TOKEN|SECRET|PASSWORD|PRIVATE_KEY|ACCESS_KEY|CLIENT_SECRET)|"
|
|||
|
|
r"(?:api[_-]?key|token|secret|password|private[_-]?key|access[_-]?key|client[_-]?secret))"
|
|||
|
|
r"\s*[:=]\s*['\"]([^'\"\r\n]{16,})['\"]",
|
|||
|
|
re.I,
|
|||
|
|
)
|
|||
|
|
BEARER = re.compile(r"\bBearer\s+([A-Za-z0-9_\-]{20,})", re.I)
|
|||
|
|
PROVIDER = re.compile(r"\b(?:sk|rk|ghp|github_pat|AIza)[A-Za-z0-9_\-]{16,}\b", re.I)
|
|||
|
|
PLACEHOLDER = re.compile(r"(?:^|[^a-z])(xxx|example|your[_-]?|replace|changeme|dummy|redacted|<[^>]+>|\.\.\.)(?:$|[^a-z])", re.I)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def run_git(*args: str) -> bytes:
|
|||
|
|
return subprocess.check_output(["git", *args])
|
|||
|
|
|
|||
|
|
|
|||
|
|
def looks_real(value: str) -> bool:
|
|||
|
|
return not PLACEHOLDER.search(value)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def scan_text(text: str) -> list[tuple[int, str]]:
|
|||
|
|
hits: list[tuple[int, str]] = []
|
|||
|
|
for line_no, line in enumerate(text.splitlines(), 1):
|
|||
|
|
if not line.startswith("+") or line.startswith("+++"):
|
|||
|
|
continue
|
|||
|
|
for pattern, kind in ((ASSIGNMENT, "疑似明文密钥赋值"), (BEARER, "疑似 Bearer 令牌"), (PROVIDER, "疑似服务商令牌")):
|
|||
|
|
for match in pattern.finditer(line):
|
|||
|
|
value = match.group(1) if match.lastindex else match.group(0)
|
|||
|
|
if looks_real(value):
|
|||
|
|
hits.append((line_no, kind))
|
|||
|
|
break
|
|||
|
|
return hits
|
|||
|
|
|
|||
|
|
|
|||
|
|
def main() -> int:
|
|||
|
|
os.chdir(Path(__file__).resolve().parents[1])
|
|||
|
|
parser = argparse.ArgumentParser()
|
|||
|
|
parser.add_argument("--staged", action="store_true")
|
|||
|
|
parser.add_argument("--range")
|
|||
|
|
args = parser.parse_args()
|
|||
|
|
|
|||
|
|
if args.staged:
|
|||
|
|
raw_names = run_git("diff", "--staged", "--name-only", "-z")
|
|||
|
|
names = [n.decode("utf-8", "replace") for n in raw_names.split(b"\0") if n]
|
|||
|
|
patch = run_git("diff", "--staged", "--no-ext-diff", "--unified=0").decode("utf-8", "replace")
|
|||
|
|
elif args.range:
|
|||
|
|
names = run_git("diff", "--name-only", args.range).decode("utf-8", "replace").splitlines()
|
|||
|
|
patch = run_git("diff", "--no-ext-diff", "--unified=0", args.range).decode("utf-8", "replace")
|
|||
|
|
else:
|
|||
|
|
parser.error("choose --staged or --range")
|
|||
|
|
|
|||
|
|
failures = []
|
|||
|
|
for name in names:
|
|||
|
|
if FORBIDDEN_PATH.search(name):
|
|||
|
|
failures.append(f"禁止提交本地密钥路径: {name}")
|
|||
|
|
for line_no, kind in scan_text(patch):
|
|||
|
|
failures.append(f"{kind}(补丁行 {line_no})")
|
|||
|
|
|
|||
|
|
if failures:
|
|||
|
|
print("\n密钥保护已阻止本次 Git 操作:", file=sys.stderr)
|
|||
|
|
for failure in failures:
|
|||
|
|
print(f"- {failure}", file=sys.stderr)
|
|||
|
|
print("移除密钥,改用苍耳本机仓库外的环境变量/密钥文件后再试。", file=sys.stderr)
|
|||
|
|
return 1
|
|||
|
|
return 0
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
raise SystemExit(main())
|