🛡️ 天眼PR合并风险检查引擎 — 弥补PR级安全缺口
Co-authored-by: qinfendebingshuo <207279273+qinfendebingshuo@users.noreply.github.com> Agent-Logs-Url: https://github.com/qinfendebingshuo/guanghulab/sessions/fbf443ce-2f3e-4a23-a3e4-515cc2dda5a7
This commit is contained in:
parent
2f0c1ab086
commit
aac467ffb7
|
|
@ -1217,6 +1217,16 @@
|
||||||
"owner": "ICE-0002∞",
|
"owner": "ICE-0002∞",
|
||||||
"registered": "2026-03-25",
|
"registered": "2026-03-25",
|
||||||
"description": "每次系统执行后自动更新仪表盘数据 + README + GitHub Pages 实时仪表盘"
|
"description": "每次系统执行后自动更新仪表盘数据 + README + GitHub Pages 实时仪表盘"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "AG-ZY-095",
|
||||||
|
"name": "PR合并风险检查引擎",
|
||||||
|
"type": "workflow",
|
||||||
|
"file": ".github/workflows/skyeye-pr-risk-check.yml",
|
||||||
|
"parent_sys": "SYS-GLW-0001",
|
||||||
|
"owner": "ICE-0002∞",
|
||||||
|
"registered": "2026-03-25",
|
||||||
|
"description": "天眼系统PR级合并前风险评估 — R1关键文件覆盖/R2受保护路径入侵/R3构建产物/R4权限/R5大规模删除"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"note": "铸渊已扫描所有workflow文件,按此格式逐一注册,编号从AG-ZY-001开始顺序分配",
|
"note": "铸渊已扫描所有workflow文件,按此格式逐一注册,编号从AG-ZY-001开始顺序分配",
|
||||||
|
|
@ -1234,4 +1244,4 @@
|
||||||
"registered_by": "TCS-0002∞"
|
"registered_by": "TCS-0002∞"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
# ═══════════════════════════════════════════════
|
||||||
|
# 🔺 Sovereign: TCS-0002∞ | Root: SYS-GLW-0001
|
||||||
|
# 📜 Copyright: 国作登字-2026-A-00037559
|
||||||
|
# ═══════════════════════════════════════════════
|
||||||
|
#
|
||||||
|
# 天眼 · PR 合并风险检查
|
||||||
|
# SkyEye PR Merge Risk Check
|
||||||
|
#
|
||||||
|
# 弥补天眼系统在 PR 合并环节的安全缺口:
|
||||||
|
# gate-guard v1/v2 保护 push 事件
|
||||||
|
# 本工作流保护 pull_request 事件(合并前风险评估)
|
||||||
|
#
|
||||||
|
# 风险检测维度:
|
||||||
|
# R1 · 关键文件覆盖检测(docs/index.html 等)
|
||||||
|
# R2 · 受保护路径入侵检测
|
||||||
|
# R3 · 构建产物误入检测
|
||||||
|
# R4 · 开发者路径权限检测
|
||||||
|
# R5 · 大规模删除检测
|
||||||
|
#
|
||||||
|
name: 天眼 · PR风险检查
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
types: [opened, synchronize, reopened]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
risk-check:
|
||||||
|
name: 🛡️ PR合并风险评估
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Collect PR changed files
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
# Get the list of files changed in this PR
|
||||||
|
gh pr diff ${{ github.event.pull_request.number }} --name-only > /tmp/pr_files.txt || true
|
||||||
|
|
||||||
|
# Get diff stats
|
||||||
|
gh pr diff ${{ github.event.pull_request.number }} --stat > /tmp/pr_stats_raw.txt || true
|
||||||
|
|
||||||
|
# Parse stats into numstat-like format for the script
|
||||||
|
# Extract additions/deletions from the stat output
|
||||||
|
git diff --numstat origin/${{ github.base_ref }}...origin/${{ github.head_ref }} > /tmp/pr_stats.txt 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "📂 Changed files:"
|
||||||
|
cat /tmp/pr_files.txt
|
||||||
|
echo ""
|
||||||
|
echo "📊 Stats:"
|
||||||
|
cat /tmp/pr_stats.txt
|
||||||
|
|
||||||
|
- name: 🔍 天眼 PR 风险检查
|
||||||
|
id: risk_check
|
||||||
|
env:
|
||||||
|
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||||
|
PR_FILES: /tmp/pr_files.txt
|
||||||
|
PR_STATS: /tmp/pr_stats.txt
|
||||||
|
run: node scripts/skyeye/pr-risk-check.js
|
||||||
|
|
||||||
|
- name: 📝 Write risk report comment
|
||||||
|
if: always()
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const fs = require('fs');
|
||||||
|
const decision = '${{ steps.risk_check.outcome }}';
|
||||||
|
const riskLevel = '${{ steps.risk_check.outputs.risk_level }}' || 'unknown';
|
||||||
|
const riskSummary = '${{ steps.risk_check.outputs.risk_summary }}' || '';
|
||||||
|
const author = '${{ github.event.pull_request.user.login }}';
|
||||||
|
|
||||||
|
let body = '## 🛡️ 天眼 · PR合并风险检查报告\n\n';
|
||||||
|
body += `**PR作者:** ${author}\n`;
|
||||||
|
body += `**检查时间:** ${new Date().toISOString()}\n\n`;
|
||||||
|
|
||||||
|
if (decision === 'success') {
|
||||||
|
body += '### ✅ 风险检查通过\n\n';
|
||||||
|
body += `风险等级: **${riskLevel}**\n\n`;
|
||||||
|
if (riskSummary && riskSummary !== '仓库主人 PR,直接放行' && riskSummary !== '白名单用户 PR,直接放行') {
|
||||||
|
body += '> ⚠️ 注意事项:\n';
|
||||||
|
body += `> ${riskSummary}\n\n`;
|
||||||
|
}
|
||||||
|
body += '此 PR 可以安全合并。\n';
|
||||||
|
} else {
|
||||||
|
body += '### ❌ 风险检查未通过\n\n';
|
||||||
|
body += `风险等级: **${riskLevel}**\n\n`;
|
||||||
|
body += '**检测到的风险:**\n';
|
||||||
|
if (riskSummary) {
|
||||||
|
const items = riskSummary.split(' | ');
|
||||||
|
for (const item of items) {
|
||||||
|
body += `- 🔴 ${item}\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body += '\n⛔ **此 PR 已被天眼系统阻止合并。**\n';
|
||||||
|
body += '请联系仓库管理员 (TCS-0002∞) 审核。\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
body += '\n---\n';
|
||||||
|
body += '> 天眼 · PR合并风险检查引擎 · SkyEye PR Risk Check';
|
||||||
|
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
body: body
|
||||||
|
});
|
||||||
|
|
@ -27,7 +27,6 @@ jobs:
|
||||||
|
|
||||||
- name: Run contract check
|
- name: Run contract check
|
||||||
id: contract
|
id: contract
|
||||||
continue-on-error: true
|
|
||||||
run: node scripts/contract-check.js 2>&1 | tee /tmp/contract-result.txt
|
run: node scripts/contract-check.js 2>&1 | tee /tmp/contract-result.txt
|
||||||
|
|
||||||
- name: Run route alignment
|
- name: Run route alignment
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,422 @@
|
||||||
|
#!/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();
|
||||||
Loading…
Reference in New Issue