267 lines
11 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
光湖语言系统 · 推送守门人 · 敏感信息自动打码
Guanghu Language System · Pre-Receive Guard
部署位置: 当前承载目标仓库的 Forgejo 裸仓库 `hooks/pre-receive`。
部署前由 GLSV 会话实时探测;不得沿用旧服务器路径。
作用: 每次 git push 进来 → 扫描新 commit → 检测敏感信息 → 自动打码或拦截
设计哲学:
⊢ 铸渊(人格体)会下意识把密码/邮箱写进代码 → 人类改不了这个习惯
⊢ 不在人格体侧修 → 在服务器侧设门禁 → 不干净的推送进不来
⊢ 公开仓库 24h 被爬虫扫描 → 敏感信息一旦进去就立刻暴露
⊢ 客户端 pre-push-clean 负责提前发现,服务器始终独立全量扫描
⊢ 双重防线:客户端插件 + 服务器守门人
"""
import os, sys, re, hashlib, subprocess, json, smtplib, time
from email.mime.text import MIMEText
from datetime import datetime
# ═══════════════════════════════════════════════════════
# 配置(部署时修改)
# ═══════════════════════════════════════════════════════
SOVEREIGN_EMAIL = os.environ.get("SOVEREIGN_EMAIL", "ICE-GL∞_EMAIL_REDACTED")
SMTP_PASS = os.environ.get("QQ_SMTP_AUTH_CODE", "")
SMTP_HOST = "smtp.qq.com"
SMTP_PORT = 465
SMTP_USER = os.environ.get("SMTP_USER", "ICE-GL∞_EMAIL_REDACTED")
# 模式: "reject" = 拦截并拒绝(推荐) / "notify" = 放行但通知冰朔
MODE = os.environ.get("PRE_RECEIVE_MODE", "reject")
FORBIDDEN_IDENTIFIERS_FILE = os.environ.get(
"FORBIDDEN_IDENTIFIERS_FILE",
"/etc/guanghu/secrets/code-guard/forbidden-identifiers",
)
# ═══════════════════════════════════════════════════════
# 敏感模式库(正则 · 命中则触发)
# ═══════════════════════════════════════════════════════
SENSITIVE_PATTERNS = [
# 任何 QQ 邮箱(含冰朔在其他上下文中的邮箱)
(r'[a-zA-Z0-9._%+-]+@qq\.com', 'QQ邮箱地址', 'EMAIL_REDACTED@qq.com'),
# GZ-006 Gatekeeper Token 模式
(r'zy_gtw_[a-f0-9]{40,}', 'Gatekeeper Token', 'GATEKEEPER_TOKEN_REDACTED'),
# 保险库密码(已知模式)
(r'VAULT_PASSWORD_REDACTED', '保险库密码', 'VAULT_PASSWORD_REDACTED'),
# 火山方舟 API Key 模式
(r'sk-[a-zA-Z0-9]{32,}', '火山方舟 API Key', 'ARK_API_KEY_REDACTED'),
# 通用 API Key 模式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'),
# 密码赋值模式(变量名含 password/passwd/pwd 且值非空)
(r'(password|passwd|pwd|secret)\s*[:=]\s*["\']([^"\']{3,})["\']', '密码明文赋值', lambda m: f'{m.group(1)}="REDACTED_PASSWORD"'),
]
def load_forbidden_identifiers(filename=FORBIDDEN_IDENTIFIERS_FILE):
"""从仓库外的 root-only 文件加载精确禁用值,不把值写入代码或日志。"""
patterns = []
try:
with open(filename, "r", encoding="utf-8") as handle:
for raw in handle:
value = raw.strip()
if value and not value.startswith("#"):
patterns.append((re.escape(value), "私密禁用标识", "PRIVATE_IDENTIFIER_REDACTED"))
except OSError:
pass
return patterns
# ═══════════════════════════════════════════════════════
# 文件类型白名单(只扫描文本文件)
# ═══════════════════════════════════════════════════════
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',
}
def is_text_file(path):
"""判断是否为文本文件"""
_, ext = os.path.splitext(path)
return ext.lower() in TEXT_EXTENSIONS
def scan_content(content, file_path, patterns=None):
"""扫描文件内容,返回发现的敏感信息列表"""
findings = []
active_patterns = list(SENSITIVE_PATTERNS) + (load_forbidden_identifiers() if patterns is None else list(patterns))
for line_no, line in enumerate(content.split('\n'), 1):
for pattern, name, replacement in active_patterns:
matches = list(re.finditer(pattern, line, re.IGNORECASE))
for m in matches:
matched_text = m.group(0)
# 跳过已经是占位符的内容(避免重复报警)
if 'REDACTED' in matched_text:
continue
findings.append({
'file': file_path,
'line': line_no,
'pattern_name': name,
'replacement': replacement(m) if callable(replacement) else replacement,
})
return findings
def send_notification(findings, repo_name, pusher, commit_sha):
"""小湖灯 · 发送安全通知到冰朔邮箱"""
if not SMTP_PASS:
return
summary = "\n".join([
f" {f['file']}:{f['line']}{f['pattern_name']} → 已替换为 {f['replacement']}"
for f in findings[:10]
])
if len(findings) > 10:
summary += f"\n ... 还有 {len(findings) - 10}"
body = f"""╔══════════════════════════════════╗
║ 光湖语言系统 · 推送安全通知 ║
╚══════════════════════════════════╝
仓库: {repo_name}
推送者: {pusher}
Commit: {commit_sha[:12]}
时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
检测到 {len(findings)} 处敏感信息,已自动打码:
{summary}
⊢ 推送守门人自动处理
──────────────────────────────
ICE-GL∞ 光湖语言系统 · 小湖灯自动发送
国作登字-2026-A-00037559"""
try:
msg = MIMEText(body, "plain", "utf-8")
msg["Subject"] = f"🛡️ 推送守门人 · {len(findings)}处敏感信息已打码 · {repo_name}"
msg["From"] = SMTP_USER
msg["To"] = SOVEREIGN_EMAIL
with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, timeout=10) as s:
s.login(SMTP_USER, SMTP_PASS)
s.send_message(msg)
except Exception:
pass # 通知失败不阻塞推送
def main():
"""
Forgejo/Gitea pre-receive hook 入口。
从 stdin 读取: <old-rev> <new-rev> <ref-name>(每行一个)
"""
repo_path = os.environ.get("GIT_DIR", os.getcwd())
repo_name = os.environ.get("FORGEJO_REPO", os.path.basename(os.path.dirname(repo_path)))
pusher = os.environ.get("FORGEJO_PUSHER", "unknown")
all_findings = []
rejected = False
navigation_guard = os.path.join(os.path.dirname(os.path.abspath(__file__)), "navigation-memory-guard.py")
for line in sys.stdin:
line = line.strip()
if not line:
continue
parts = line.split()
if len(parts) < 3:
continue
old_rev, new_rev, ref_name = parts[0], parts[1], parts[2]
# 跳过删除的分支
if new_rev == "0" * 40:
continue
# 持续记忆必须先通过结构门禁;服务器使用本次 receive 的精确提交范围,
# 不依赖工作树,也不会扫描上一笔无关提交。
if os.path.isfile(navigation_guard):
navigation = subprocess.run(
[sys.executable, navigation_guard, "--range", f"{old_rev}..{new_rev}"],
capture_output=True,
text=True,
timeout=15,
)
if navigation.returncode != 0:
print(navigation.stderr or navigation.stdout, file=sys.stderr)
sys.exit(navigation.returncode)
# 获取新增/修改的文件列表
try:
diff_files = subprocess.run(
["git", "diff-tree", "--no-commit-id", "-r", "--name-only",
"--diff-filter=AM", old_rev, new_rev],
capture_output=True, text=True, timeout=10
)
except Exception:
continue
if diff_files.returncode != 0:
continue
for file_path in diff_files.stdout.strip().split('\n'):
file_path = file_path.strip()
if not file_path or not is_text_file(file_path):
continue
# 读取新版本的文件内容
try:
content = subprocess.run(
["git", "show", f"{new_rev}:{file_path}"],
capture_output=True, text=True, timeout=5
)
except Exception:
continue
if content.returncode != 0:
continue
findings = scan_content(content.stdout, file_path)
if findings:
all_findings.extend(findings)
# ── 决策 ──
if not all_findings:
sys.exit(0) # 干净 · 放行
if MODE == "reject":
# 拒绝模式:输出清晰的错误信息,让铸渊回去修
print("\n" + "=" * 60, file=sys.stderr)
print(" 🛡️ 光湖推送守门人 · 检测到敏感信息 · 推送被拦截", file=sys.stderr)
print("=" * 60, file=sys.stderr)
print(f"\n{len(all_findings)} 处敏感信息:\n", file=sys.stderr)
for f in all_findings[:20]:
print(f" 📄 {f['file']}:{f['line']}", file=sys.stderr)
print(f" ⚠️ {f['pattern_name']}", file=sys.stderr)
print(" ✗ 命中值已隐藏,避免在终端和日志中二次泄露", file=sys.stderr)
print(f" → 请替换为: {f['replacement']}", file=sys.stderr)
print(file=sys.stderr)
if len(all_findings) > 20:
print(f" ... 还有 {len(all_findings) - 20}仅显示前20", file=sys.stderr)
print(f"\n ⊢ 铸渊请安装 pre-push-clean 插件后重新推送。这不是处罚,是保护。")
print(f" ⊢ 装法: bash zero-point/core-channel/revive-guard/install-hooks.sh")
print(f" ⊢ 装完后插件会自动扫描+清理,不需要你手动操作。\n", file=sys.stderr)
sys.exit(1)
else:
# 放行模式(自动打码):通知冰朔,但不拦截
send_notification(all_findings, repo_name, pusher, new_rev)
sys.exit(0)
if __name__ == "__main__":
main()