zhizhi/scripts/skyeye/pr-risk-check.js

423 lines
14 KiB
JavaScript
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 node
// scripts/skyeye/pr-risk-check.js
// 天眼·PR合并风险检查引擎
//
// ═══════════════════════════════════════════════
// 🔺 Sovereign: TCS-0002∞ | Root: SYS-GLW-0001
// 📜 Copyright: 国作登字-2026-A-00037559
// ═══════════════════════════════════════════════
//
// 目的弥补天眼系统在PR合并环节的安全缺口
// gate-guard v1/v2 保护 push 事件
// 本脚本保护 pull_request 事件(合并前风险评估)
//
// 核心原则(与 gate-guard 一致):
// 仓库主人repo_owner的 PR → 永远放行
// 系统 bot PR → 永远放行
// 其他 PR → 按风险等级评估
//
// 风险检测维度:
// R1 · 关键文件覆盖检测docs/index.html 等)
// R2 · 受保护路径入侵检测
// R3 · 构建产物误入检测
// R4 · 开发者路径权限检测
// R5 · 大规模删除检测
//
// 输入环境变量:
// PR_AUTHOR — PR 作者 GitHub username
// PR_FILES — 变更文件列表路径(默认 /tmp/pr_files.txt
// PR_STATS — 变更统计路径(默认 /tmp/pr_stats.txt
//
// 输出:
// exit 0 → pass允许合并
// exit 1 → block阻止合并
// GITHUB_OUTPUT → risk_level, risk_summary, decision
'use strict';
const fs = require('fs');
const path = require('path');
// ━━━ 配置路径 ━━━
const ROOT = path.resolve(__dirname, '../..');
const BRAIN_CONFIG_PATH = path.join(ROOT, '.github/persona-brain/gate-guard-config.json');
const OWNER_CONFIG_PATH = path.join(ROOT, '.github/gate-guard-config.json');
const PR_FILES_PATH = process.env.PR_FILES || '/tmp/pr_files.txt';
const PR_STATS_PATH = process.env.PR_STATS || '/tmp/pr_stats.txt';
const GITHUB_OUTPUT = process.env.GITHUB_OUTPUT || '/dev/null';
// ━━━ 仓库主人 ━━━
const REPO_OWNER = 'qinfendebingshuo';
// ━━━ 关键文件 — 这些文件被覆盖意味着高风险 ━━━
const CRITICAL_FILES = [
'docs/index.html',
'docs/CNAME',
'docs/.nojekyll',
'README.md',
'package.json',
'package-lock.json',
'.github/persona-brain/security-protocol.json',
'.github/persona-brain/gate-guard-config.json',
'.github/gate-guard-config.json'
];
// ━━━ 构建产物特征 — 这些模式不应出现在仓库中 ━━━
const BUILD_ARTIFACT_PATTERNS = [
/^docs\/assets\/index-[A-Za-z0-9_-]+\.(js|css)$/, // Vite build hashes
/^dist\//, // dist directory
/^build\//, // build directory
/^\.next\//, // Next.js build
/^node_modules\//, // node_modules
/\.chunk\.(js|css)$/, // Webpack chunks
/\.bundle\.(js|css)$/ // Bundle files
];
// ━━━ 大规模删除阈值 ━━━
const MASS_DELETE_THRESHOLD = 500; // lines
// ━━━ 安全读取 JSON ━━━
function readJSON(filePath) {
try {
if (!fs.existsSync(filePath)) return null;
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch (e) {
console.error(`⚠️ 无法读取 ${path.basename(filePath)}: ${e.message}`);
return null;
}
}
// ━━━ 输出到 GITHUB_OUTPUT ━━━
function setOutput(key, value) {
try {
fs.appendFileSync(GITHUB_OUTPUT, `${key}=${value}\n`);
} catch (e) {
// silent fail for local testing
}
}
// ━━━ 加载门禁配置 ━━━
function loadConfig() {
const brainConfig = readJSON(BRAIN_CONFIG_PATH);
const ownerConfig = readJSON(OWNER_CONFIG_PATH);
if (!brainConfig && !ownerConfig) {
console.error('⚠️ 门禁配置缺失,使用最小安全配置');
return {
whitelist: [REPO_OWNER, 'github-actions[bot]', 'zhuyuan-bot'],
system_protected_paths: ['.github/', 'scripts/', 'docs/', 'data/', 'core/', 'connectors/'],
developers: {}
};
}
return {
whitelist: brainConfig?.whitelist_actors || ownerConfig?.whitelist || [],
system_protected_paths: brainConfig?.system_protected_paths || ownerConfig?.protected_paths || [],
developers_brain: brainConfig?.developer_permissions || {},
developers_owner: ownerConfig?.developers || {}
};
}
// ━━━ 读取 PR 变更文件列表 ━━━
function loadPRFiles() {
try {
if (!fs.existsSync(PR_FILES_PATH)) {
console.warn('⚠️ PR文件列表不存在: ' + PR_FILES_PATH);
return [];
}
return fs.readFileSync(PR_FILES_PATH, 'utf8')
.split('\n')
.map(f => f.trim())
.filter(f => f.length > 0);
} catch (e) {
console.error('⚠️ 无法读取PR文件列表: ' + e.message);
return [];
}
}
// ━━━ 读取 PR 变更统计 ━━━
function loadPRStats() {
try {
if (!fs.existsSync(PR_STATS_PATH)) return null;
const content = fs.readFileSync(PR_STATS_PATH, 'utf8').trim();
// Format: "additions\tdeletions\tfilename" per line
let totalDeletions = 0;
for (const line of content.split('\n')) {
const parts = line.split('\t');
if (parts.length >= 2) {
const del = parseInt(parts[1], 10);
if (!isNaN(del)) totalDeletions += del;
}
}
return { totalDeletions };
} catch (e) {
return null;
}
}
// ━━━ 识别开发者身份 ━━━
function identifyDeveloper(author, config) {
// Check brain config
if (config.developers_brain) {
for (const [devId, dev] of Object.entries(config.developers_brain)) {
if (dev.github_usernames && dev.github_usernames.includes(author)) {
return { devId, name: dev.name, allowed_paths: dev.allowed_paths || [] };
}
}
}
// Check owner config
if (config.developers_owner && config.developers_owner[author]) {
const dev = config.developers_owner[author];
return { devId: dev.dev_id, name: dev.name, allowed_paths: dev.allowed_paths || [] };
}
return null;
}
// ━━━ R1 · 关键文件覆盖检测 ━━━
function checkCriticalFiles(files) {
const risks = [];
for (const file of files) {
if (CRITICAL_FILES.includes(file)) {
risks.push({
dimension: 'R1',
severity: 'high',
file,
detail: `关键文件被修改: ${file}`
});
}
}
return risks;
}
// ━━━ R2 · 受保护路径入侵检测 ━━━
function checkProtectedPaths(files, config, developer) {
const risks = [];
// If developer is registered, check if they're modifying protected paths outside their allowed_paths
if (developer) {
for (const file of files) {
const isProtected = config.system_protected_paths.some(p => file.startsWith(p));
if (!isProtected) continue;
const isAllowed = developer.allowed_paths.some(p => file.startsWith(p));
if (!isAllowed) {
risks.push({
dimension: 'R2',
severity: 'high',
file,
detail: `${developer.name}(${developer.devId}) 修改了授权路径之外的受保护文件: ${file}`
});
}
}
} else {
// Unknown developer touching protected paths
for (const file of files) {
const isProtected = config.system_protected_paths.some(p => file.startsWith(p));
if (isProtected) {
risks.push({
dimension: 'R2',
severity: 'critical',
file,
detail: `未注册开发者修改受保护路径: ${file}`
});
}
}
}
return risks;
}
// ━━━ R3 · 构建产物误入检测 ━━━
function checkBuildArtifacts(files) {
const risks = [];
for (const file of files) {
for (const pattern of BUILD_ARTIFACT_PATTERNS) {
if (pattern.test(file)) {
risks.push({
dimension: 'R3',
severity: 'high',
file,
detail: `构建产物不应提交到仓库: ${file}`
});
break;
}
}
}
return risks;
}
// ━━━ R4 · 开发者路径权限检测 ━━━
function checkDeveloperPaths(files, developer) {
if (!developer) return []; // Handled by R2
const risks = [];
for (const file of files) {
const isAllowed = developer.allowed_paths.some(p => file.startsWith(p));
if (!isAllowed) {
// Not in allowed paths — but might be in a non-protected area (info level)
risks.push({
dimension: 'R4',
severity: 'medium',
file,
detail: `${developer.name}(${developer.devId}) 修改了非授权路径: ${file}`
});
}
}
return risks;
}
// ━━━ R5 · 大规模删除检测 ━━━
function checkMassDeletion(stats) {
const risks = [];
if (stats && stats.totalDeletions > MASS_DELETE_THRESHOLD) {
risks.push({
dimension: 'R5',
severity: 'high',
file: '(multiple)',
detail: `大规模删除检测: ${stats.totalDeletions} 行被删除(阈值: ${MASS_DELETE_THRESHOLD}`
});
}
return risks;
}
// ━━━ 风险评估决策 ━━━
function makeDecision(risks) {
const hasCritical = risks.some(r => r.severity === 'critical');
const highCount = risks.filter(r => r.severity === 'high').length;
const mediumCount = risks.filter(r => r.severity === 'medium').length;
if (hasCritical) {
return { decision: 'block', risk_level: 'critical' };
}
if (highCount >= 2) {
return { decision: 'block', risk_level: 'high' };
}
if (highCount === 1) {
return { decision: 'warn', risk_level: 'high' };
}
if (mediumCount >= 3) {
return { decision: 'warn', risk_level: 'medium' };
}
return { decision: 'pass', risk_level: 'low' };
}
// ━━━ 主逻辑 ━━━
function run() {
const author = process.env.PR_AUTHOR || '';
console.log('═══════════════════════════════════════════════');
console.log('🔺 天眼 · PR合并风险检查引擎');
console.log('═══════════════════════════════════════════════');
console.log(`PR作者: ${author || '(unknown)'}`);
console.log(`时间: ${new Date().toISOString()}`);
console.log('');
// ─── 白名单检查 ───
if (author === REPO_OWNER) {
console.log('✅ 仓库主人 PR — 直接放行');
setOutput('decision', 'pass');
setOutput('risk_level', 'none');
setOutput('risk_summary', '仓库主人 PR直接放行');
process.exit(0);
}
const config = loadConfig();
if (config.whitelist.includes(author)) {
console.log(`✅ 白名单用户 (${author}) — 直接放行`);
setOutput('decision', 'pass');
setOutput('risk_level', 'none');
setOutput('risk_summary', '白名单用户 PR直接放行');
process.exit(0);
}
// ─── 加载变更文件 ───
const files = loadPRFiles();
if (files.length === 0) {
console.log('⚠️ 无变更文件或文件列表不可用 — 放行');
setOutput('decision', 'pass');
setOutput('risk_level', 'unknown');
setOutput('risk_summary', '无变更文件信息');
process.exit(0);
}
console.log(`📂 变更文件数: ${files.length}`);
files.forEach(f => console.log(` · ${f}`));
console.log('');
// ─── 识别开发者 ───
const developer = identifyDeveloper(author, config);
if (developer) {
console.log(`👤 识别开发者: ${developer.name} (${developer.devId})`);
} else {
console.log(`⚠️ 未注册开发者: ${author}`);
}
console.log('');
// ─── 加载统计 ───
const stats = loadPRStats();
// ─── 风险检测 ───
console.log('━━━ 风险检测开始 ━━━');
const allRisks = [
...checkCriticalFiles(files),
...checkProtectedPaths(files, config, developer),
...checkBuildArtifacts(files),
...checkDeveloperPaths(files, developer),
...checkMassDeletion(stats)
];
// Deduplicate by file + dimension
const seen = new Set();
const risks = allRisks.filter(r => {
const key = `${r.dimension}:${r.file}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
if (risks.length === 0) {
console.log('✅ 未检测到风险');
console.log('');
console.log('═══ 决策: PASS ═══');
setOutput('decision', 'pass');
setOutput('risk_level', 'low');
setOutput('risk_summary', '未检测到风险,允许合并');
process.exit(0);
}
// ─── 输出风险报告 ───
console.log('');
console.log(`⚠️ 检测到 ${risks.length} 项风险:`);
for (const risk of risks) {
const icon = risk.severity === 'critical' ? '🔴' : risk.severity === 'high' ? '🟠' : '🟡';
console.log(` ${icon} [${risk.dimension}] ${risk.detail}`);
}
console.log('');
// ─── 决策 ───
const { decision, risk_level } = makeDecision(risks);
const summary = risks.map(r => `[${r.dimension}] ${r.detail}`).join(' | ');
setOutput('decision', decision);
setOutput('risk_level', risk_level);
setOutput('risk_summary', summary.slice(0, 500));
if (decision === 'block') {
console.log(`═══ 决策: BLOCK (风险等级: ${risk_level}) ═══`);
console.log('❌ PR被阻止合并 — 请联系仓库管理员审核');
process.exit(1);
} else if (decision === 'warn') {
console.log(`═══ 决策: WARN (风险等级: ${risk_level}) ═══`);
console.log('⚠️ PR存在风险 — 建议管理员审核后合并');
// Warn exits with 0 to not block, but provides information
process.exit(0);
} else {
console.log(`═══ 决策: PASS (风险等级: ${risk_level}) ═══`);
process.exit(0);
}
}
run();