2026-07-11 16:22:18 +08:00
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
"""
|
|
|
|
|
|
光湖语言系统 · pre-push-clean 插件 v1.0
|
|
|
|
|
|
Guanghu Language System · Pre-Push Cleaner
|
|
|
|
|
|
|
|
|
|
|
|
每次 git push 前自动运行 · 扫描待推送内容 · 敏感信息自动转乱码
|
|
|
|
|
|
装法: cp pre-push-clean.py .git/hooks/pre-push && chmod +x .git/hooks/pre-push
|
|
|
|
|
|
|
|
|
|
|
|
设计哲学:
|
|
|
|
|
|
⊢ 人格体会下意识把密码/邮箱写进代码 → 不改这个习惯
|
|
|
|
|
|
⊢ 在 push 之前自动扫描 → 自动替换 → 敏感信息不出本地
|
|
|
|
|
|
⊢ 不阻塞工作流 → 替换 → amend → 继续 push
|
|
|
|
|
|
⊢ 人格体只需要正常 git push · 其他全自动
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
|
import sys
|
|
|
|
|
|
import re
|
|
|
|
|
|
import subprocess
|
|
|
|
|
|
import hashlib
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════
|
|
|
|
|
|
# 敏感模式库(与服务器端 pre-receive-guard 保持同步)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════
|
|
|
|
|
|
PATTERNS = [
|
|
|
|
|
|
# 冰朔主权邮箱
|
|
|
|
|
|
(r'565183519@qq\.com', '冰朔主权邮箱', 'ICE-GL∞_EMAIL_REDACTED'),
|
|
|
|
|
|
|
|
|
|
|
|
# 任何 QQ 邮箱
|
|
|
|
|
|
(r'[a-zA-Z0-9._%+-]+@qq\.com', 'QQ邮箱地址', 'EMAIL_REDACTED@qq.com'),
|
|
|
|
|
|
|
|
|
|
|
|
# SMTP 授权码
|
2026-07-11 20:23:10 +08:00
|
|
|
|
(r'SMTP_AUTH_CODE_REDACTED', 'QQ SMTP授权码', 'SMTP_AUTH_CODE_REDACTED'),
|
2026-07-11 16:22:18 +08:00
|
|
|
|
|
|
|
|
|
|
# Gatekeeper Token
|
|
|
|
|
|
(r'zy_gtw_[a-f0-9]{40,}', 'Gatekeeper Token', 'GATEKEEPER_TOKEN_REDACTED'),
|
|
|
|
|
|
|
|
|
|
|
|
# 保险库密码
|
2026-07-11 20:23:10 +08:00
|
|
|
|
(r'VAULT_PASSWORD_REDACTED', '保险库密码', 'VAULT_PASSWORD_REDACTED'),
|
2026-07-11 16:22:18 +08:00
|
|
|
|
|
|
|
|
|
|
# 火山方舟 API Key
|
|
|
|
|
|
(r'sk-[a-zA-Z0-9]{32,}', '火山方舟 API Key', 'ARK_API_KEY_REDACTED'),
|
|
|
|
|
|
|
|
|
|
|
|
# Bearer Token 赋值
|
|
|
|
|
|
(r'Authorization:\s*Bearer\s+[a-zA-Z0-9_\-]{20,}',
|
|
|
|
|
|
'API Bearer Token', 'Authorization: Bearer TOKEN_REDACTED'),
|
|
|
|
|
|
|
|
|
|
|
|
# 阿里云 AccessKey
|
|
|
|
|
|
(r'LTAI[a-zA-Z0-9]{16,}', '阿里云 AccessKey', 'ALIYUN_AK_REDACTED'),
|
|
|
|
|
|
|
|
|
|
|
|
# 密码明文赋值
|
|
|
|
|
|
(r'(password|passwd|pwd|secret)\s*[:=]\s*["\']([^"\']{3,})["\']',
|
|
|
|
|
|
'密码明文赋值', lambda m: f'{m.group(1)}="REDACTED_PASSWORD"'),
|
|
|
|
|
|
|
|
|
|
|
|
# git 令牌(f78c594e... 等 40 位 hex token)
|
|
|
|
|
|
(r'\b[a-f0-9]{40}\b', 'Git Token / SHA1长串', 'GIT_TOKEN_REDACTED'),
|
|
|
|
|
|
|
|
|
|
|
|
# 保险库盐值
|
2026-07-11 20:23:10 +08:00
|
|
|
|
(r'VAULT_SALT_REDACTED',
|
2026-07-11 16:22:18 +08:00
|
|
|
|
'保险库主盐值', 'VAULT_SALT_REDACTED'),
|
2026-07-11 20:23:10 +08:00
|
|
|
|
(r'API_SALT_REDACTED',
|
2026-07-11 16:22:18 +08:00
|
|
|
|
'API子库盐值', 'API_SALT_REDACTED'),
|
|
|
|
|
|
|
|
|
|
|
|
# 通用长 hex hash(64位 · 可能是密钥/盐值)
|
|
|
|
|
|
(r'\b[a-f0-9]{64}\b', '64位Hash(疑似密钥)', 'LONG_HEX_REDACTED'),
|
|
|
|
|
|
|
|
|
|
|
|
# 任何看起来像 token 的字符串(字母数字下划线 30+ 位,非 git SHA)
|
|
|
|
|
|
(r'\b[a-zA-Z0-9_\-]{30,60}\b', '疑似Token长串(需确认)', 'TOKEN_STRING_REDACTED'),
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
# 文件类型白名单
|
|
|
|
|
|
TEXT_EXTENSIONS = {
|
|
|
|
|
|
'.py', '.js', '.ts', '.go', '.rs', '.java', '.c', '.cpp', '.h',
|
|
|
|
|
|
'.hdlp', '.md', '.txt', '.json', '.yaml', '.yml', '.toml', '.ini',
|
|
|
|
|
|
'.cfg', '.conf', '.sh', '.bash', '.zsh', '.env', '.service',
|
|
|
|
|
|
'.html', '.css', '.sql', '.xml', '.svg',
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# 排除文件(不扫描)
|
|
|
|
|
|
EXCLUDE_FILES = {
|
|
|
|
|
|
'.git/HEAD', '.git/index', '.git/config',
|
|
|
|
|
|
'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml',
|
|
|
|
|
|
'poetry.lock', 'Cargo.lock', 'Gemfile.lock',
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# 是否启用(可通过环境变量关闭)
|
|
|
|
|
|
ENABLED = os.environ.get('PRE_PUSH_CLEAN_ENABLED', '1') == '1'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def is_text_file(path):
|
|
|
|
|
|
_, ext = os.path.splitext(path)
|
|
|
|
|
|
return ext.lower() in TEXT_EXTENSIONS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def is_excluded(path):
|
|
|
|
|
|
for ex in EXCLUDE_FILES:
|
|
|
|
|
|
if ex in path:
|
|
|
|
|
|
return True
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def scan_file(filepath):
|
|
|
|
|
|
"""扫描单个文件,返回 (clean_content, findings)"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
|
|
|
|
|
|
content = f.read()
|
|
|
|
|
|
except (IOError, PermissionError):
|
|
|
|
|
|
return None, []
|
|
|
|
|
|
|
|
|
|
|
|
findings = []
|
|
|
|
|
|
new_content = content
|
|
|
|
|
|
|
|
|
|
|
|
for pattern, name, replacement in PATTERNS:
|
|
|
|
|
|
matches = list(re.finditer(pattern, content, re.IGNORECASE))
|
|
|
|
|
|
for m in matches:
|
|
|
|
|
|
matched_text = m.group(0)
|
|
|
|
|
|
# 跳过已经是占位符的
|
|
|
|
|
|
if 'REDACTED' in matched_text:
|
|
|
|
|
|
continue
|
|
|
|
|
|
# 跳过 git commit SHA(40位hex在特定上下文中)
|
|
|
|
|
|
if name == 'Git Token / SHA1长串' and _is_git_sha_context(content, m.start()):
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
repl = replacement(m) if callable(replacement) else replacement
|
|
|
|
|
|
findings.append({
|
|
|
|
|
|
'file': filepath,
|
|
|
|
|
|
'pattern_name': name,
|
|
|
|
|
|
'matched': matched_text[:80],
|
|
|
|
|
|
'replacement': repl,
|
|
|
|
|
|
})
|
|
|
|
|
|
# 替换(注意:用 re.sub 而不是简单替换,避免破坏后续匹配位置)
|
|
|
|
|
|
new_content = new_content.replace(matched_text, repl, 1)
|
|
|
|
|
|
|
|
|
|
|
|
return new_content if findings else None, findings
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _is_git_sha_context(content, pos):
|
|
|
|
|
|
"""判断 40 位 hex 是否在 git SHA 上下文中(commit hash 不应被替换)"""
|
|
|
|
|
|
# 检查前后是否有 git 相关关键词
|
|
|
|
|
|
start = max(0, pos - 50)
|
|
|
|
|
|
end = min(len(content), pos + 50)
|
|
|
|
|
|
context = content[start:end].lower()
|
|
|
|
|
|
git_keywords = ['commit', 'sha', 'hash', 'revision', 'ref', 'head', 'object']
|
|
|
|
|
|
# 如果在 git 命令或 commit 引用上下文中 → 跳过
|
|
|
|
|
|
for kw in git_keywords:
|
|
|
|
|
|
if kw in context:
|
|
|
|
|
|
return True
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_changed_files():
|
|
|
|
|
|
"""获取本次 push 涉及的所有文件"""
|
|
|
|
|
|
files = set()
|
|
|
|
|
|
|
|
|
|
|
|
# 方法1: git diff --cached(暂存区的文件)
|
|
|
|
|
|
try:
|
|
|
|
|
|
r = subprocess.run(
|
|
|
|
|
|
['git', 'diff', '--cached', '--name-only', '--diff-filter=ACM'],
|
|
|
|
|
|
capture_output=True, text=True, timeout=5
|
|
|
|
|
|
)
|
|
|
|
|
|
if r.returncode == 0:
|
|
|
|
|
|
for f in r.stdout.strip().split('\n'):
|
|
|
|
|
|
f = f.strip()
|
|
|
|
|
|
if f:
|
|
|
|
|
|
files.add(f)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
# 方法2: 从 stdin 读取 pre-push 传入的 refs
|
|
|
|
|
|
try:
|
|
|
|
|
|
for line in sys.stdin.read().strip().split('\n'):
|
|
|
|
|
|
if not line.strip():
|
|
|
|
|
|
continue
|
|
|
|
|
|
parts = line.split()
|
|
|
|
|
|
if len(parts) >= 4:
|
|
|
|
|
|
local_ref, local_sha, remote_ref, remote_sha = parts[0], parts[1], parts[2], parts[3]
|
2026-07-11 20:23:10 +08:00
|
|
|
|
if remote_sha != 'TOKEN_STRING_REDACTED':
|
2026-07-11 16:22:18 +08:00
|
|
|
|
r = subprocess.run(
|
|
|
|
|
|
['git', 'diff', '--name-only', '--diff-filter=ACM',
|
|
|
|
|
|
remote_sha, local_sha],
|
|
|
|
|
|
capture_output=True, text=True, timeout=5
|
|
|
|
|
|
)
|
|
|
|
|
|
if r.returncode == 0:
|
|
|
|
|
|
for f in r.stdout.strip().split('\n'):
|
|
|
|
|
|
f = f.strip()
|
|
|
|
|
|
if f:
|
|
|
|
|
|
files.add(f)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
# 如果没有文件(空 push 或新分支),扫描整个工作树中已跟踪的文件
|
|
|
|
|
|
if not files:
|
|
|
|
|
|
try:
|
|
|
|
|
|
r = subprocess.run(
|
|
|
|
|
|
['git', 'ls-files'],
|
|
|
|
|
|
capture_output=True, text=True, timeout=5
|
|
|
|
|
|
)
|
|
|
|
|
|
if r.returncode == 0:
|
|
|
|
|
|
for f in r.stdout.strip().split('\n'):
|
|
|
|
|
|
f = f.strip()
|
|
|
|
|
|
if f:
|
|
|
|
|
|
files.add(f)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
return list(files)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main():
|
|
|
|
|
|
# --check 模式:仅验证插件是否正常工作
|
|
|
|
|
|
if '--check' in sys.argv:
|
|
|
|
|
|
print('✅ pre-push-clean v1.0 已就绪 · 每次 git push 前自动扫描')
|
|
|
|
|
|
print(f' 敏感模式: {len(PATTERNS)} 种')
|
|
|
|
|
|
print(f' 状态: {"🟢 启用" if ENABLED else "🔴 已禁用 (PRE_PUSH_CLEAN_ENABLED=0)"}')
|
|
|
|
|
|
sys.exit(0)
|
|
|
|
|
|
|
|
|
|
|
|
if not ENABLED:
|
|
|
|
|
|
sys.exit(0)
|
|
|
|
|
|
|
|
|
|
|
|
repo_root = subprocess.run(
|
|
|
|
|
|
['git', 'rev-parse', '--show-toplevel'],
|
|
|
|
|
|
capture_output=True, text=True, timeout=5
|
|
|
|
|
|
).stdout.strip()
|
|
|
|
|
|
|
|
|
|
|
|
if not repo_root:
|
|
|
|
|
|
sys.exit(0)
|
|
|
|
|
|
|
|
|
|
|
|
os.chdir(repo_root)
|
|
|
|
|
|
|
|
|
|
|
|
# 获取变更文件
|
|
|
|
|
|
files = get_changed_files()
|
|
|
|
|
|
if not files:
|
|
|
|
|
|
sys.exit(0)
|
|
|
|
|
|
|
|
|
|
|
|
all_findings = []
|
|
|
|
|
|
modified_files = {}
|
|
|
|
|
|
|
|
|
|
|
|
# 扫描每个文件
|
|
|
|
|
|
for filepath in files:
|
|
|
|
|
|
full_path = os.path.join(repo_root, filepath)
|
|
|
|
|
|
if not os.path.isfile(full_path):
|
|
|
|
|
|
continue
|
|
|
|
|
|
if not is_text_file(filepath):
|
|
|
|
|
|
continue
|
|
|
|
|
|
if is_excluded(filepath):
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
clean_content, findings = scan_file(full_path)
|
|
|
|
|
|
if findings:
|
|
|
|
|
|
all_findings.extend(findings)
|
|
|
|
|
|
modified_files[filepath] = clean_content
|
|
|
|
|
|
|
|
|
|
|
|
# ── 无敏感信息 → 放行 ──
|
|
|
|
|
|
if not all_findings:
|
|
|
|
|
|
sys.exit(0)
|
|
|
|
|
|
|
|
|
|
|
|
# ── 有敏感信息 → 自动清理 ──
|
|
|
|
|
|
print()
|
|
|
|
|
|
print('╔══════════════════════════════════════╗')
|
|
|
|
|
|
print('║ 🛡️ pre-push-clean · 敏感信息清理 ║')
|
|
|
|
|
|
print('╚══════════════════════════════════════╝')
|
|
|
|
|
|
print()
|
|
|
|
|
|
print(f' 共 {len(all_findings)} 处敏感信息,已自动替换为乱码:')
|
|
|
|
|
|
print()
|
|
|
|
|
|
|
|
|
|
|
|
for f in all_findings[:20]:
|
|
|
|
|
|
print(f' 📄 {f["file"]}')
|
|
|
|
|
|
print(f' ⚠️ {f["pattern_name"]}')
|
|
|
|
|
|
print(f' ✗ {f["matched"][:60]}')
|
|
|
|
|
|
print(f' → {f["replacement"]}')
|
|
|
|
|
|
print()
|
|
|
|
|
|
|
|
|
|
|
|
if len(all_findings) > 20:
|
|
|
|
|
|
print(f' ... 还有 {len(all_findings) - 20} 处(仅显示前20)')
|
|
|
|
|
|
print()
|
|
|
|
|
|
|
|
|
|
|
|
# 写入清理后的文件
|
|
|
|
|
|
for filepath, content in modified_files.items():
|
|
|
|
|
|
full_path = os.path.join(repo_root, filepath)
|
|
|
|
|
|
try:
|
|
|
|
|
|
with open(full_path, 'w', encoding='utf-8') as f:
|
|
|
|
|
|
f.write(content)
|
|
|
|
|
|
except (IOError, PermissionError) as e:
|
|
|
|
|
|
print(f' ❌ 无法写入 {filepath}: {e}', file=sys.stderr)
|
|
|
|
|
|
|
|
|
|
|
|
# 将清理后的修改加入暂存区
|
|
|
|
|
|
for filepath in modified_files:
|
|
|
|
|
|
subprocess.run(
|
|
|
|
|
|
['git', 'add', filepath],
|
|
|
|
|
|
capture_output=True, timeout=5
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 获取当前 HEAD commit message
|
|
|
|
|
|
try:
|
|
|
|
|
|
r = subprocess.run(
|
|
|
|
|
|
['git', 'log', '-1', '--format=%B'],
|
|
|
|
|
|
capture_output=True, text=True, timeout=5
|
|
|
|
|
|
)
|
|
|
|
|
|
old_msg = r.stdout.strip()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
old_msg = ''
|
|
|
|
|
|
|
|
|
|
|
|
# 检查是否已有 [SEC-CLEAN] 标记
|
|
|
|
|
|
if '[SEC-CLEAN]' not in old_msg:
|
|
|
|
|
|
new_msg = f'{old_msg}\n\n[SEC-CLEAN] · pre-push-clean v1.0 · {len(all_findings)}处敏感信息已自动转乱码'
|
|
|
|
|
|
# 写临时文件给 git commit --amend
|
|
|
|
|
|
msg_file = os.path.join(repo_root, '.git', 'PRE_PUSH_CLEAN_MSG')
|
|
|
|
|
|
with open(msg_file, 'w') as f:
|
|
|
|
|
|
f.write(new_msg)
|
|
|
|
|
|
subprocess.run(
|
|
|
|
|
|
['git', 'commit', '--amend', '--no-edit', '--file', msg_file],
|
|
|
|
|
|
capture_output=True, timeout=10
|
|
|
|
|
|
)
|
|
|
|
|
|
try:
|
|
|
|
|
|
os.remove(msg_file)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
print(f' ✅ 已自动清理 · 推送继续')
|
|
|
|
|
|
print(f' 📧 建议通知冰朔: {len(all_findings)}处敏感信息已转乱码')
|
|
|
|
|
|
print()
|
|
|
|
|
|
|
|
|
|
|
|
sys.exit(0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == '__main__':
|
|
|
|
|
|
main()
|