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

498 lines
18 KiB
JavaScript
Raw Normal View History

#!/usr/bin/env node
// scripts/skyeye/pr-risk-check.js
// 天眼·合并膜 — PR合并前全系统审核引擎 (SkyEye Merge Membrane)
//
// ═══════════════════════════════════════════════
// 🔺 Sovereign: TCS-0002∞ | Root: SYS-GLW-0001
// 📜 Copyright: 国作登字-2026-A-00037559
// ═══════════════════════════════════════════════
//
// 核心原则(冰朔 2026-03-25 确认):
// 所有合并到 main 的 PR — 无论冰朔还是其他开发者
// 均全部启动天眼系统全局全仓库架构审核
// 不符合现有仓库整体系统结构 → 物理切断合并通道
// 不再区分身份放行 — 天眼对所有人一视同仁
//
// 架构背景:
// 两条完全分离的部署路径:
// Path A · 主站 (guanghulab.com) — 全PR审核物理阻塞
// Path B · 开发者门户 (dev-portal/) — 沙箱隔离,频道部署
//
// 风险检测维度全PR适用
// R1 · 关键文件覆盖检测
// R2 · 构建产物误入检测
// R3 · 大规模删除检测
// R4 · 天眼核心配置篡改检测仅代理PR触发 critical
// R5 · 工作流篡改检测仅代理PR触发 critical
// R6 · 开发者门户框架保护
// R7 · 仓库结构完整性验证全PR适用 — 核心审计维度)
//
// 输入环境变量:
// PR_AUTHOR, PR_BRANCH, PR_TITLE
// PR_FILES, PR_STATS, PR_COMMITS
//
// 输出:
// exit 0 → pass允许合并
// exit 1 → block物理层拒绝合并
'use strict';
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
// ━━━ 配置路径 ━━━
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 SECURITY_PROTOCOL_PATH = path.join(ROOT, '.github/persona-brain/security-protocol.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 PR_COMMITS_PATH = process.env.PR_COMMITS || '/tmp/pr_commits.txt';
const GITHUB_OUTPUT = process.env.GITHUB_OUTPUT || '/dev/null';
// ━━━ Copilot Agent 分支模式 ━━━
const AGENT_BRANCH_PATTERNS = [
/^copilot\//,
/^agent\//,
/^bot\//
];
// ━━━ 天眼核心配置 — 代理PR修改触发 critical ━━━
const SKYEYE_CORE_FILES = [
'.github/persona-brain/security-protocol.json',
'.github/persona-brain/gate-guard-config.json',
'.github/persona-brain/agent-registry.json',
'.github/persona-brain/ontology.json',
'.github/gate-guard-config.json',
'scripts/gate-guard.js',
'scripts/gate-guard-v2.js',
'scripts/skyeye/pr-risk-check.js'
];
// ━━━ 门户框架文件 — 不可被开发者修改 ━━━
const PORTAL_FRAMEWORK_FILES = [
'docs/dev-portal/index.html',
'docs/dev-portal/manifest.json'
];
// ━━━ 构建产物特征 ━━━
const BUILD_ARTIFACT_PATTERNS = [
/^docs\/assets\/index-[A-Za-z0-9_-]+\.(js|css)$/,
/^dist\//,
/^build\//,
/^\.next\//,
/^node_modules\//,
/\.chunk\.(js|css)$/,
/\.bundle\.(js|css)$/
];
// ━━━ 仓库必需目录 — R7 结构完整性检查 ━━━
const REQUIRED_DIRS = [
'.github/workflows',
'.github/persona-brain',
'scripts',
'scripts/skyeye',
'skyeye',
'skyeye/guards',
'skyeye/scripts',
'core',
'docs',
'data'
];
// ━━━ 仓库必需文件 — R7 结构完整性检查 ━━━
const REQUIRED_FILES = [
'.github/persona-brain/security-protocol.json',
'.github/persona-brain/gate-guard-config.json',
'.github/persona-brain/agent-registry.json',
'docs/index.html',
'docs/CNAME',
'README.md',
'package.json'
];
// ━━━ 大规模删除阈值 ━━━
const MASS_DELETE_THRESHOLD = 500;
// ━━━ 工具函数 ━━━
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;
}
}
function setOutput(key, value) {
try {
fs.appendFileSync(GITHUB_OUTPUT, `${key}=${value}\n`);
} catch (e) { /* silent */ }
}
function isAgentBranch(branchName) {
if (!branchName) return false;
return AGENT_BRANCH_PATTERNS.some(p => p.test(branchName));
}
function loadPRFiles() {
try {
if (!fs.existsSync(PR_FILES_PATH)) return [];
return fs.readFileSync(PR_FILES_PATH, 'utf8')
.split('\n').map(f => f.trim()).filter(f => f.length > 0);
} catch (e) { return []; }
}
function loadPRStats() {
try {
if (!fs.existsSync(PR_STATS_PATH)) return null;
const content = fs.readFileSync(PR_STATS_PATH, 'utf8').trim();
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 extractIdentity(commitsPath, prTitle) {
const identity = { isAgent: false, agentName: null, devId: null, source: 'unknown' };
if (prTitle) {
const devMatch = prTitle.match(/\b(DEV-\d{3})\b/i);
if (devMatch) { identity.devId = devMatch[1].toUpperCase(); identity.source = 'pr_title'; }
}
try {
if (fs.existsSync(commitsPath)) {
const content = fs.readFileSync(commitsPath, 'utf8');
if (content.includes('Agent-Logs-Url:')) { identity.isAgent = true; identity.source = 'agent_logs_url'; }
const coAuth = content.match(/Co-authored-by:\s*(.+?)\s*</);
if (coAuth) { identity.agentName = coAuth[1].trim(); identity.isAgent = true; }
if (!identity.devId) {
const devMatch = content.match(/\b(DEV-\d{3})\b/i);
if (devMatch) { identity.devId = devMatch[1].toUpperCase(); }
}
}
} catch (e) { /* non-fatal */ }
return identity;
}
// ━━━ R1 · 关键文件覆盖检测 ━━━
function checkCriticalFiles(files) {
const risks = [];
const criticalFiles = [
'docs/index.html', 'docs/CNAME', 'docs/.nojekyll',
'README.md', 'package.json', 'package-lock.json'
];
for (const file of files) {
if (criticalFiles.includes(file)) {
risks.push({ dimension: 'R1', severity: 'high', file,
detail: `关键文件被修改: ${file}` });
}
}
return risks;
}
// ━━━ R2 · 构建产物误入检测 ━━━
function checkBuildArtifacts(files) {
const risks = [];
for (const file of files) {
for (const pattern of BUILD_ARTIFACT_PATTERNS) {
if (pattern.test(file)) {
risks.push({ dimension: 'R2', severity: 'high', file,
detail: `构建产物不应提交到仓库: ${file}` });
break;
}
}
}
return risks;
}
// ━━━ R3 · 大规模删除检测 ━━━
function checkMassDeletion(stats) {
const risks = [];
if (stats && stats.totalDeletions > MASS_DELETE_THRESHOLD) {
risks.push({ dimension: 'R3', severity: 'high', file: '(multiple)',
detail: `大规模删除: ${stats.totalDeletions} 行被删除(阈值: ${MASS_DELETE_THRESHOLD}` });
}
return risks;
}
// ━━━ R4 · 天眼核心配置篡改检测代理PR = critical手动PR = high ━━━
function checkCoreConfigTampering(files, isAgent) {
const risks = [];
for (const file of files) {
if (SKYEYE_CORE_FILES.includes(file)) {
risks.push({ dimension: 'R4', severity: isAgent ? 'critical' : 'high', file,
detail: `天眼核心配置被修改: ${file}` });
}
}
return risks;
}
// ━━━ R5 · 工作流篡改检测代理PR = critical手动PR = high ━━━
function checkWorkflowTampering(files, isAgent) {
const risks = [];
for (const file of files) {
if (file.startsWith('.github/workflows/') && (file.endsWith('.yml') || file.endsWith('.yaml'))) {
risks.push({ dimension: 'R5', severity: isAgent ? 'critical' : 'high', file,
detail: `工作流文件被修改: ${file}` });
}
}
return risks;
}
// ━━━ R6 · 开发者门户框架保护 ━━━
function checkPortalFramework(files, isAgent) {
if (!isAgent) return []; // Owner can modify portal framework
const risks = [];
for (const file of files) {
if (PORTAL_FRAMEWORK_FILES.includes(file)) {
risks.push({ dimension: 'R6', severity: 'critical', file,
detail: `门户框架文件被修改: ${file} — 仅限天眼系统维护` });
}
}
return risks;
}
// ━━━ R7 · 仓库结构完整性验证(核心审计维度 — 对所有PR生效━━━
function checkStructuralIntegrity(files) {
const risks = [];
// 7a. Security protocol must remain intact
const protocol = readJSON(SECURITY_PROTOCOL_PATH);
if (!protocol) {
risks.push({ dimension: 'R7', severity: 'critical', file: '.github/persona-brain/security-protocol.json',
detail: '安全协议文件不存在或无法解析 — 仓库安全基础被破坏' });
} else {
if (!protocol.permanent || !protocol.cannot_be_overridden) {
risks.push({ dimension: 'R7', severity: 'critical', file: '.github/persona-brain/security-protocol.json',
detail: '安全协议 permanent/cannot_be_overridden 标记丢失' });
}
if (!protocol.root_rules || protocol.root_rules.length < 3) {
risks.push({ dimension: 'R7', severity: 'critical', file: '.github/persona-brain/security-protocol.json',
detail: '安全协议 root_rules 不完整(需 3 条根规则)' });
}
}
// 7b. Gate guard config must remain valid
const brainConfig = readJSON(BRAIN_CONFIG_PATH);
const ownerConfig = readJSON(OWNER_CONFIG_PATH);
if (!brainConfig && !ownerConfig) {
risks.push({ dimension: 'R7', severity: 'critical', file: '(gate-guard-config)',
detail: '门禁配置文件全部缺失 — 系统安全体系被破坏' });
}
// 7c. Check that required directories still exist
for (const dir of REQUIRED_DIRS) {
const dirPath = path.join(ROOT, dir);
if (!fs.existsSync(dirPath)) {
risks.push({ dimension: 'R7', severity: 'high', file: dir + '/',
detail: `必需目录缺失: ${dir}/` });
}
}
// 7d. Check that required files still exist
for (const file of REQUIRED_FILES) {
const filePath = path.join(ROOT, file);
if (!fs.existsSync(filePath)) {
risks.push({ dimension: 'R7', severity: 'high', file,
detail: `必需文件缺失: ${file}` });
}
}
// 7e. Check that PR doesn't DELETE required files
for (const file of files) {
if (REQUIRED_FILES.includes(file)) {
const filePath = path.join(ROOT, file);
// File is in the PR diff — check if it still exists in the working tree
// (if checkout has the merged version, missing = deleted)
if (!fs.existsSync(filePath)) {
risks.push({ dimension: 'R7', severity: 'critical', file,
detail: `PR删除了仓库必需文件: ${file}` });
}
}
}
// 7f. Run structural scan scripts if available
const scanScripts = [
{ name: 'scan-structure', path: path.join(ROOT, 'scripts/skyeye/scan-structure.js') },
{ name: 'scan-brain-health', path: path.join(ROOT, 'scripts/skyeye/scan-brain-health.js') },
{ name: 'scan-security-protocol', path: path.join(ROOT, 'scripts/skyeye/scan-security-protocol.js') }
];
for (const script of scanScripts) {
if (fs.existsSync(script.path)) {
try {
const output = execSync(`node "${script.path}" 2>&1`, {
cwd: ROOT, timeout: 30000, encoding: 'utf8'
});
// Check for failure indicators in output
if (output.includes('❌') || output.includes('FAIL') || output.includes('ERROR')) {
const failLines = output.split('\n').filter(l =>
l.includes('❌') || l.includes('FAIL') || l.includes('ERROR')
).slice(0, 3);
risks.push({ dimension: 'R7', severity: 'high', file: `(${script.name})`,
detail: `结构扫描 ${script.name} 报告问题: ${failLines.join('; ').slice(0, 200)}` });
}
} catch (e) {
// Script execution failure is itself a structural concern
const errMsg = (e.stderr || e.message || '').slice(0, 100);
risks.push({ dimension: 'R7', severity: 'high', file: `(${script.name})`,
detail: `结构扫描 ${script.name} 执行失败: ${errMsg}` });
}
}
}
return risks;
}
// ━━━ 风险决策 ━━━
function makeDecision(risks) {
const hasCritical = risks.some(r => r.severity === 'critical');
const highCount = risks.filter(r => r.severity === 'high').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' };
return { decision: 'pass', risk_level: 'low' };
}
// ━━━ 主逻辑 ━━━
function run() {
const author = process.env.PR_AUTHOR || '';
const branch = process.env.PR_BRANCH || '';
const prTitle = process.env.PR_TITLE || '';
console.log('═══════════════════════════════════════════════');
console.log('🛡️ 天眼 · 合并膜 (SkyEye Merge Membrane)');
console.log('═══════════════════════════════════════════════');
console.log(`PR作者(GitHub): ${author || '(unknown)'}`);
console.log(`分支: ${branch || '(unknown)'}`);
console.log(`标题: ${prTitle || '(unknown)'}`);
console.log(`时间: ${new Date().toISOString()}`);
console.log('');
console.log('⚖️ 审核模式: 全员全审 — 不区分身份,天眼对所有人一视同仁');
console.log('');
// ─── 身份识别(仅用于日志/上下文,不影响审核决策)───
const isAgent = isAgentBranch(branch);
const identity = extractIdentity(PR_COMMITS_PATH, prTitle);
const isAgentPR = isAgent || identity.isAgent;
if (isAgentPR) {
console.log('🤖 PR来源: 代理 (Copilot Agent)');
if (identity.devId) console.log(` 开发者编号: ${identity.devId}`);
if (identity.agentName) console.log(` 代理名称: ${identity.agentName}`);
} else {
console.log('👤 PR来源: 手动提交');
}
console.log('');
// ─── 加载变更文件 ───
const files = loadPRFiles();
if (files.length === 0) {
console.log('⚠️ 无变更文件或文件列表不可用');
console.log('❌ 无法获取变更文件列表 — 安全起见阻止合并');
setOutput('decision', 'block');
setOutput('risk_level', 'unknown');
setOutput('risk_summary', 'PR变更文件列表不可用');
process.exit(1);
}
console.log(`📂 变更文件数: ${files.length}`);
files.forEach(f => console.log(` · ${f}`));
console.log('');
const stats = loadPRStats();
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 天眼全系统审核 — 对所有PR生效无例外
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
console.log('━━━ 天眼全系统审核启动 ━━━');
console.log('');
// Phase 1: 风险维度检测 (R1-R6)
console.log('[Phase 1] 风险维度检测 (R1-R6)...');
const riskChecks = [
...checkCriticalFiles(files),
...checkBuildArtifacts(files),
...checkMassDeletion(stats),
...checkCoreConfigTampering(files, isAgentPR),
...checkWorkflowTampering(files, isAgentPR),
...checkPortalFramework(files, isAgentPR)
];
// Phase 2: 仓库结构完整性验证 (R7)
console.log('[Phase 2] 仓库结构完整性验证 (R7)...');
const structuralRisks = checkStructuralIntegrity(files);
// Combine all risks
const allRisks = [...riskChecks, ...structuralRisks];
// Deduplicate
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;
});
console.log('');
// ─── 结果报告 ───
if (risks.length === 0) {
console.log('✅ 天眼全系统审核通过');
console.log(' 风险维度检测: 无风险');
console.log(' 仓库结构完整性: 正常');
console.log('');
console.log('═══ 决策: PASS ═══');
setOutput('decision', 'pass');
setOutput('risk_level', 'low');
setOutput('risk_summary', '天眼全系统审核通过');
process.exit(0);
}
// ─── 输出风险报告 ───
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('❌ 天眼合并膜: 物理切断合并通道');
console.log(' 此PR不符合仓库整体系统结构要求');
process.exit(1);
} else if (decision === 'warn') {
console.log(`═══ 决策: WARN (风险等级: ${risk_level}) ═══`);
console.log('⚠️ 天眼合并膜: 存在风险项,建议审核后合并');
process.exit(0);
} else {
console.log(`═══ 决策: PASS (风险等级: ${risk_level}) ═══`);
process.exit(0);
}
}
run();