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

655 lines
22 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合并前风险检查引擎 (SkyEye Merge Membrane)
//
// ═══════════════════════════════════════════════
// 🔺 Sovereign: TCS-0002∞ | Root: SYS-GLW-0001
// 📜 Copyright: 国作登字-2026-A-00037559
// ═══════════════════════════════════════════════
//
// 架构背景:
// 所有开发者共用 qinfendebingshuo 账号(企业权限单人仓库)
// 代理Copilot Agent以 copilot/* 分支开发,通过 PR 合并
// 无法通过 GitHub username 区分身份 → 改用分支名/commit元数据识别
//
// 沙箱隔离原则:
// 开发者在各自分支自由开发(沙箱)→ 天眼不干预
// 合并到 main 时 → 天眼合并膜启动,全系统审核
// 天眼审核不通过 → 物理层拒绝合并exit 1 = GitHub Status Check 失败)
//
// 身份识别(不依赖 GitHub username
// I1 · 分支名模式匹配copilot/* = 代理PR
// I2 · Commit 签名提取Co-authored-by, Agent-Logs-Url
// I3 · PR 标题/描述中的开发者编号DEV-XXX, TCS-GL-XX
//
// 风险检测维度:
// R1 · 关键文件覆盖检测docs/index.html 等核心文件)
// R2 · 天眼系统包裹区域入侵检测(.github/, scripts/, skyeye/ 等)
// R3 · 构建产物误入检测Vite/Webpack hash文件
// R4 · 天眼核心配置篡改检测security-protocol, gate-guard-config 等)
// R5 · 大规模删除检测(>500行删除
// R6 · 工作流篡改检测(.github/workflows/ 修改)
//
// 输入环境变量:
// PR_AUTHOR — PR 作者 GitHub username
// PR_BRANCH — PR 源分支名
// PR_TITLE — PR 标题
// PR_FILES — 变更文件列表路径(默认 /tmp/pr_files.txt
// PR_STATS — 变更统计路径(默认 /tmp/pr_stats.txt
// PR_COMMITS — PR commit 信息路径(默认 /tmp/pr_commits.txt
//
// 输出:
// exit 0 → pass允许合并
// exit 1 → block物理层拒绝合并
// GITHUB_OUTPUT → risk_level, risk_summary, decision, identity_source
'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 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';
// ━━━ 仓库主人 ━━━
const REPO_OWNER = 'qinfendebingshuo';
// ━━━ Copilot Agent 分支模式 ━━━
const AGENT_BRANCH_PATTERNS = [
/^copilot\//, // GitHub Copilot agent branches
/^agent\//, // Generic agent branches
/^bot\// // Bot branches
];
// ━━━ 天眼系统包裹区域 — 这些路径构成天眼保护的核心系统 ━━━
const SKYEYE_WRAPPED_PATHS = [
'.github/workflows/',
'.github/persona-brain/',
'.github/brain/',
'.github/gate-guard-config.json',
'scripts/',
'skyeye/',
'core/',
'connectors/',
'backend/api-server/',
'docs/index.html',
'docs/CNAME',
'docs/.nojekyll',
'docs/js/',
'docs/css/',
'docs/dashboard/',
'data/system-health.json',
'data/bulletin-board.json',
'README.md',
'package.json',
'package-lock.json'
];
// ━━━ 天眼核心配置 — 最高保护等级,任何修改都触发 block ━━━
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 BUILD_ARTIFACT_PATTERNS = [
/^docs\/assets\/index-[A-Za-z0-9_-]+\.(js|css)$/,
/^dist\//,
/^build\//,
/^\.next\//,
/^node_modules\//,
/\.chunk\.(js|css)$/,
/\.bundle\.(js|css)$/
];
// ━━━ 大规模删除阈值 ━━━
const MASS_DELETE_THRESHOLD = 500;
// ━━━ 安全读取 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
}
}
// ━━━ I1 · 检测是否为代理AgentPR ━━━
function isAgentBranch(branchName) {
if (!branchName) return false;
return AGENT_BRANCH_PATTERNS.some(p => p.test(branchName));
}
// ━━━ I2 · 从 commit 元数据提取代理身份 ━━━
function extractAgentIdentity(commitsPath, prTitle) {
const identity = {
isAgent: false,
agentName: null,
devId: null,
devName: null,
source: 'unknown'
};
// Check PR title for dev identifiers
if (prTitle) {
const devMatch = prTitle.match(/\b(DEV-\d{3})\b/i);
const tcsMatch = prTitle.match(/\b(TCS-GL-\d{2})\b/i);
if (devMatch) {
identity.devId = devMatch[1].toUpperCase();
identity.source = 'pr_title';
}
if (tcsMatch) {
identity.devId = tcsMatch[1].toUpperCase();
identity.source = 'pr_title';
}
}
// Check commit messages
try {
if (fs.existsSync(commitsPath)) {
const content = fs.readFileSync(commitsPath, 'utf8');
// Agent-Logs-Url indicates Copilot agent
if (content.includes('Agent-Logs-Url:')) {
identity.isAgent = true;
identity.source = 'agent_logs_url';
}
// Co-authored-by pattern
const coAuthorMatch = content.match(/Co-authored-by:\s*(.+?)\s*</);
if (coAuthorMatch) {
identity.agentName = coAuthorMatch[1].trim();
identity.isAgent = true;
if (identity.source === 'unknown') identity.source = 'co_authored_by';
}
// Persona signature in commit messages [PER-XXX] [TCS-XXX]
const sigMatch = content.match(/^\[([A-Z]+-[A-Z0-9\-∞]+)\]/m);
if (sigMatch) {
identity.devId = identity.devId || sigMatch[1];
if (identity.source === 'unknown') identity.source = 'commit_signature';
}
// DEV-XXX in commit messages
const devInCommit = content.match(/\b(DEV-\d{3})\b/i);
if (devInCommit && !identity.devId) {
identity.devId = devInCommit[1].toUpperCase();
if (identity.source === 'unknown') identity.source = 'commit_message';
}
}
} catch (e) {
// Non-fatal - continue without commit analysis
}
return identity;
}
// ━━━ 加载门禁配置 ━━━
function loadConfig() {
const brainConfig = readJSON(BRAIN_CONFIG_PATH);
const ownerConfig = readJSON(OWNER_CONFIG_PATH);
if (!brainConfig && !ownerConfig) {
console.error('⚠️ 门禁配置缺失,使用最小安全配置');
return {
whitelist: ['github-actions[bot]', 'zhuyuan-bot'],
system_protected_paths: ['.github/', 'scripts/', 'docs/', 'data/', 'core/', 'connectors/'],
developers: {}
};
}
return {
whitelist: (brainConfig?.whitelist_actors || ownerConfig?.whitelist || [])
.filter(u => u !== REPO_OWNER), // Do NOT whitelist owner for agent PRs
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();
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;
}
}
// ━━━ 根据 devId 查找注册开发者 ━━━
function findDeveloperByDevId(devId, config) {
if (!devId) return null;
if (config.developers_brain) {
for (const [id, dev] of Object.entries(config.developers_brain)) {
if (id === devId) {
return { devId: id, name: dev.name, allowed_paths: dev.allowed_paths || [] };
}
}
}
if (config.developers_owner) {
for (const [, dev] of Object.entries(config.developers_owner)) {
if (dev.dev_id === devId) {
return { devId: dev.dev_id, name: dev.name, allowed_paths: dev.allowed_paths || [] };
}
}
}
return null;
}
// ━━━ 检查文件是否在天眼包裹区域内 ━━━
function isInSkyeyeZone(file) {
return SKYEYE_WRAPPED_PATHS.some(p => file === p || file.startsWith(p));
}
// ━━━ 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 checkSkyeyeZoneIntrusion(files, developer) {
const risks = [];
for (const file of files) {
if (!isInSkyeyeZone(file)) continue;
// If developer is identified, check allowed_paths
if (developer) {
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 identity touching SkyEye zone
risks.push({
dimension: 'R2',
severity: 'high',
file,
detail: `代理PR修改天眼包裹区域: ${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 checkCoreConfigTampering(files) {
const risks = [];
for (const file of files) {
if (SKYEYE_CORE_FILES.includes(file)) {
risks.push({
dimension: 'R4',
severity: 'critical',
file,
detail: `天眼核心配置被修改: ${file} — 此文件仅限 TCS-0002∞ 主权者修改`
});
}
}
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;
}
// ━━━ R6 · 工作流篡改检测 ━━━
function checkWorkflowTampering(files) {
const risks = [];
for (const file of files) {
if (file.startsWith('.github/workflows/') && (file.endsWith('.yml') || file.endsWith('.yaml'))) {
risks.push({
dimension: 'R6',
severity: 'critical',
file,
detail: `工作流文件被修改: ${file} — 代理PR不应修改自动化管线`
});
}
}
return risks;
}
// ━━━ 天眼系统完整性验证 ━━━
function verifySkyeyeIntegrity() {
const issues = [];
// Check security protocol
const protocol = readJSON(SECURITY_PROTOCOL_PATH);
if (!protocol) {
issues.push('security-protocol.json 不存在或无法读取');
} else {
if (!protocol.permanent) issues.push('security-protocol.json: permanent 标记缺失');
if (!protocol.root_rules || protocol.root_rules.length < 3) {
issues.push('security-protocol.json: root_rules 不完整');
}
}
// Check gate-guard configs
if (!readJSON(BRAIN_CONFIG_PATH) && !readJSON(OWNER_CONFIG_PATH)) {
issues.push('门禁配置文件全部缺失');
}
return issues;
}
// ━━━ 风险评估决策 ━━━
function makeDecision(risks, touchesSkyeyeZone) {
const hasCritical = risks.some(r => r.severity === 'critical');
const highCount = risks.filter(r => r.severity === 'high').length;
// Any critical risk = immediate block
if (hasCritical) {
return { decision: 'block', risk_level: 'critical' };
}
// Multiple high risks = block
if (highCount >= 2) {
return { decision: 'block', risk_level: 'high' };
}
// Single high risk = warn (allow merge but notify)
if (highCount === 1) {
return { decision: 'warn', risk_level: 'high' };
}
// No SkyEye zone touched = safe pass
if (!touchesSkyeyeZone) {
return { decision: 'pass', risk_level: 'low' };
}
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('');
// ─── 检测 PR 来源类型 ───
const fromAgent = isAgentBranch(branch);
const identity = extractAgentIdentity(PR_COMMITS_PATH, prTitle);
const isAgentPR = fromAgent || identity.isAgent;
if (isAgentPR) {
console.log('🤖 代理PR检测: 是');
console.log(` 分支匹配: ${fromAgent ? '是' : '否'}`);
console.log(` Agent标记: ${identity.isAgent ? '是' : '否'}`);
if (identity.agentName) console.log(` 代理名称: ${identity.agentName}`);
if (identity.devId) console.log(` 开发者编号: ${identity.devId}`);
console.log(` 身份来源: ${identity.source}`);
} else {
// Non-agent PR from repo owner — this is the owner's own manual work
console.log('👤 主权者手动PR — 天眼合并膜放行');
console.log(' (非代理分支无Agent标记)');
setOutput('decision', 'pass');
setOutput('risk_level', 'none');
setOutput('identity_source', 'owner_manual');
setOutput('risk_summary', '主权者手动PR天眼合并膜放行');
process.exit(0);
}
console.log('');
// ─── 天眼系统完整性预检 ───
console.log('━━━ 天眼系统完整性预检 ━━━');
const integrityIssues = verifySkyeyeIntegrity();
if (integrityIssues.length > 0) {
console.log('⚠️ 天眼系统完整性问题:');
integrityIssues.forEach(i => console.log(` · ${i}`));
} else {
console.log('✅ 天眼系统完整性: 正常');
}
console.log('');
// ─── 加载配置和文件 ───
const config = loadConfig();
const files = loadPRFiles();
if (files.length === 0) {
console.log('⚠️ 无变更文件或文件列表不可用');
// For agent PRs with no file info, we block to be safe
console.log('❌ 代理PR无法获取变更文件列表 — 安全起见阻止合并');
setOutput('decision', 'block');
setOutput('risk_level', 'unknown');
setOutput('identity_source', identity.source);
setOutput('risk_summary', '代理PR变更文件列表不可用');
process.exit(1);
}
console.log(`📂 变更文件数: ${files.length}`);
files.forEach(f => console.log(` · ${f}`));
console.log('');
// ─── 查找注册开发者(通过 devId ───
const developer = identity.devId ? findDeveloperByDevId(identity.devId, config) : null;
if (developer) {
console.log(`👤 已识别开发者: ${developer.name} (${developer.devId})`);
} else if (identity.devId) {
console.log(`⚠️ 开发者编号 ${identity.devId} 未在门禁系统注册`);
} else {
console.log('⚠️ 无法识别开发者身份');
}
console.log('');
// ─── 分析天眼区域接触情况 ───
const skyeyeZoneFiles = files.filter(f => isInSkyeyeZone(f));
const nonSkyeyeFiles = files.filter(f => !isInSkyeyeZone(f));
const touchesSkyeyeZone = skyeyeZoneFiles.length > 0;
console.log(`🛡️ 天眼包裹区域文件: ${skyeyeZoneFiles.length}/${files.length}`);
if (touchesSkyeyeZone) {
console.log(' ⚠️ 此PR修改了天眼系统包裹区域 — 启动完整审核');
skyeyeZoneFiles.forEach(f => console.log(` 🔒 ${f}`));
} else {
console.log(' ✅ 此PR未触及天眼包裹区域 — 沙箱内操作');
}
if (nonSkyeyeFiles.length > 0) {
console.log(` 📦 沙箱区域文件: ${nonSkyeyeFiles.length}`);
}
console.log('');
// ─── 如果完全在沙箱内且有注册身份 → 放行 ───
if (!touchesSkyeyeZone && developer) {
console.log('✅ 沙箱内操作 + 已注册开发者 — 天眼合并膜放行');
setOutput('decision', 'pass');
setOutput('risk_level', 'low');
setOutput('identity_source', identity.source);
setOutput('risk_summary', `${developer.name}(${developer.devId}) 沙箱内操作,未触及天眼区域`);
process.exit(0);
}
// ─── 加载统计 ───
const stats = loadPRStats();
// ─── 风险检测 ───
console.log('━━━ 天眼风险检测启动 ━━━');
const allRisks = [
...checkCriticalFiles(files),
...checkSkyeyeZoneIntrusion(files, developer),
...checkBuildArtifacts(files),
...checkCoreConfigTampering(files),
...checkMassDeletion(stats),
...checkWorkflowTampering(files)
];
// 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 && !touchesSkyeyeZone) {
console.log('✅ 未检测到风险(沙箱内操作)');
console.log('');
console.log('═══ 决策: PASS ═══');
setOutput('decision', 'pass');
setOutput('risk_level', 'low');
setOutput('identity_source', identity.source);
setOutput('risk_summary', '沙箱内操作,未检测到风险');
process.exit(0);
}
if (risks.length === 0 && touchesSkyeyeZone) {
// Touches SkyEye zone but no specific risks detected
// This could happen if a registered developer touches their allowed paths
console.log('✅ 天眼区域变更已验证 — 在授权范围内');
console.log('');
console.log('═══ 决策: PASS ═══');
setOutput('decision', 'pass');
setOutput('risk_level', 'low');
setOutput('identity_source', identity.source);
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, touchesSkyeyeZone);
const summary = risks.map(r => `[${r.dimension}] ${r.detail}`).join(' | ');
setOutput('decision', decision);
setOutput('risk_level', risk_level);
setOutput('identity_source', identity.source);
setOutput('risk_summary', summary.slice(0, 500));
if (decision === 'block') {
console.log(`═══ 决策: BLOCK (风险等级: ${risk_level}) ═══`);
console.log('❌ 天眼合并膜: 物理层拒绝合并');
console.log(' 此PR的变更触及天眼系统包裹区域且未通过审核');
console.log(' 请联系主权者 TCS-0002∞ 审核后手动合并');
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();