-
Notifications
You must be signed in to change notification settings - Fork 3
291 lines (252 loc) · 15 KB
/
auto-issue.yml
File metadata and controls
291 lines (252 loc) · 15 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
name: Admin Friend Link Issue Audit
on:
issues:
types: [opened, edited]
permissions:
contents: read
issues: write
jobs:
audit-issue:
runs-on: ubuntu-latest
if: contains(github.event.issue.title, '友链申请')
steps:
- name: 友链 Issue 自动化审核与 WAF 拦截转审
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const issue_number = context.payload.issue.number;
const issue_body = context.payload.issue.body || '';
const issue_title = context.payload.issue.title || '';
// ================= 常量定义区 =================
const MY_DOMAINS = ['mahiro.work'];
const LABELS = {
BASE: '友链申请',
RUNNING: '审核中',
PASSED: '审核通过',
NEEDS_MANUAL: '需人工审核',
NEED_FIX: '待修改',
SITE_OK: '站点正常',
SITE_ERR: '站点异常',
AVATAR_OK: '头像正常',
AVATAR_ERR: '头像异常',
LINK_OK: '反链有效',
LINK_ERR: '反链缺失'
};
const MSG = {
TITLE_ERR: '占位符拦截:系统检测到您的标题或 JSON 内容中依然包含默认的“您的网站名”,请务必将其修改为您自己的真实网站名称。',
JSON_MISSING_ERR: '未在 Issue 中找到合法的 JSON 代码块。请确保您的 JSON 数据包裹在 ```json 和 ``` 之间。',
JSON_PARSE_ERR: 'JSON 解析失败,请检查文件格式是否符合完全正确的 JSON 规范(如:不能有单引号,不能缺少逗号等)。',
JSON_KEY_ERR: 'JSON 缺少必填字段,必须包含:name, avatar, description, url, badge。',
BADGE_ERR: '字段校验未通过:badge 的值必须严格填写。仅允许填写 "好友", "友情" 或 "友情链接"。',
SSRF_BLOCK: '安全拦截:目标 URL 疑似指向内网或保留 IP。',
SUCCESS: `🎉 **预审通过!**\n\n感谢您提交的友链申请!系统已成功检测到本站双向链接并确认全部信息可达。\n\n由于 Issue 无法自动生成代码文件,我已打上 \`审核通过\` 标签。仓库管理员 @${owner} 稍后会人工将您的网站添加入库,请耐心等待。`,
NEEDS_MANUAL: `🤖 **自动化初审遇到了困难 (需人工干预)**\n\n您好!感谢提交申请。由于自动化审核工具(无头爬虫)在试图访问您的站点以检查连通性或反链时,遭到了网站安全策略(可能为防火墙 WAF、防爬虫策略或海外拦截)的拒绝。\n\n但这 **完全正常**!系统已自动为您打上 \`需人工审核\` 标签,并自动通知了仓库管理员 @${owner}。\n\n管理员将在近期通过普通浏览器手动访问您的网站复核确认,请耐心等待!`,
REJECT: `您好!感谢对本站的关注。\n\n您提交的内容存在 **严重的不可抗拒的格式问题**,导致自动化审核中断(详情请见上方审核报告)。鉴于此,系统已暂时 **关闭** 此 Issue。\n\n### 💡 修改建议:\n请参考上方报告修正您的配置。**完成后,请您在仓库重新提交一个新的 Issue 进行申请,不要在此 Issue 处修改!**`
};
// ================= 辅助函数 =================
async function createComment(body) {
const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`;
const footer = `\n\n---\n*🤖 该评论由 Action 自动化发送,无需回复。 [查看构建日志](${runUrl})*`;
await github.rest.issues.createComment({ owner, repo, issue_number, body: body + footer });
}
async function ensureLabel(name, color = 'ededed') {
try {
await github.rest.issues.getLabel({ owner, repo, name });
} catch (e) {
if (e.status === 404) {
await github.rest.issues.createLabel({ owner, repo, name, color });
}
}
}
function getLabelColor(name) {
const greenLabels = ['审核通过', '站点正常', '头像正常', '反链有效'];
const redLabels = ['待修改', '站点异常', '头像异常', '反链缺失'];
const orangeLabels = ['需人工审核'];
if (greenLabels.includes(name)) return '0e8a16';
if (redLabels.includes(name)) return 'd93f0b';
if (orangeLabels.includes(name)) return 'd93f0b'; // orange/red
return '1d76db';
}
let _labelCache = null;
async function getLabelCache() {
if (!_labelCache) {
const current = await github.rest.issues.get({ owner, repo, issue_number });
_labelCache = new Set((current.data.labels || []).map(l => l.name));
}
return _labelCache;
}
async function manageLabels(addList = [], removeList = []) {
const currentLabels = await getLabelCache();
for (const name of addList) {
if (!currentLabels.has(name)) {
await ensureLabel(name, getLabelColor(name));
await github.rest.issues.addLabels({ owner, repo, issue_number, labels: [name] });
currentLabels.add(name);
}
}
for (const name of removeList) {
if (currentLabels.has(name)) {
try {
await github.rest.issues.removeLabel({ owner, repo, issue_number, name });
currentLabels.delete(name);
} catch (e) { /* ignore */ }
}
}
}
function isSafeUrl(urlStr) {
try {
const u = new URL(urlStr);
if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
const h = u.hostname;
if (/^(localhost|127\.|10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[0-1])\.|::1)/i.test(h)) return false;
return true;
} catch { return false; }
}
async function fetchWithTimeout(url, timeoutMs = 10000, { headOnly = false } = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, {
method: headOnly ? 'HEAD' : 'GET',
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' },
signal: controller.signal,
redirect: 'follow'
});
const text = headOnly ? '' : await res.text();
return { ok: res.ok, status: res.status, text };
} catch (e) {
return { ok: false, error: e.message };
} finally {
clearTimeout(timer);
}
}
// ================= 核心执行逻辑 =================
await manageLabels([LABELS.BASE, LABELS.RUNNING], []);
const reportData = [];
// 致命格式错误(引发关闭)
let fatalError = false;
// 连通性/刮削受阻(仅转人工,不关闭)
let blockedByWaf = false;
try {
// 0. 标题防呆校验
if (issue_title.includes('您的网站名')) {
throw new Error(MSG.TITLE_ERR);
}
// 1. 提取 JSON 代码块
const jsonMatch = issue_body.match(/```json\s+([\s\S]+?)\s+```/i);
if (!jsonMatch || !jsonMatch[1]) {
throw new Error(MSG.JSON_MISSING_ERR);
}
const contentStr = jsonMatch[1].trim();
let json;
try { json = JSON.parse(contentStr); } catch { throw new Error(MSG.JSON_PARSE_ERR); }
if (json.name && json.name.includes('您的网站名')) {
throw new Error(MSG.TITLE_ERR);
}
const requiredKeys = ['name', 'avatar', 'description', 'url', 'badge'];
const missingKeys = requiredKeys.filter(k => !json[k] || typeof json[k] !== 'string');
if (missingKeys.length > 0) throw new Error(`${MSG.JSON_KEY_ERR} 缺失: ${missingKeys.join(', ')}`);
const allowedBadges = ['好友', '友情', '友情链接'];
if (!allowedBadges.includes(json.badge.trim())) {
throw new Error(MSG.BADGE_ERR);
}
reportData.push({ item: 'JSON 格式', status: '✅ 通关', detail: '字段校验完整且规范' });
// 2. 安全防护 SSRF
if (!isSafeUrl(json.url) || !isSafeUrl(json.avatar) || (json.backlink && !isSafeUrl(json.backlink))) {
throw new Error(MSG.SSRF_BLOCK);
}
// 3. 网络并发测试
const fetchTasks = [
fetchWithTimeout(json.avatar, 10000, { headOnly: true }), // [0] 测头像
fetchWithTimeout(json.url) // [1] 测主站存活
];
const hasCustomBacklink = typeof json.backlink === 'string' && json.backlink.trim() !== '';
if (hasCustomBacklink) {
fetchTasks.push(fetchWithTimeout(json.backlink.trim())); // [2] 去专属友链页扒反链
}
const results = await Promise.all(fetchTasks);
const avatarRes = results[0];
const siteRes = results[1];
const backlinkRes = hasCustomBacklink ? results[2] : siteRes;
const checkTargetUrl = hasCustomBacklink ? json.backlink.trim() : json.url;
// 验证头像
if (avatarRes.ok) {
reportData.push({ item: '头像连通性', status: '✅ 正常', detail: `状态码: ${avatarRes.status}` });
await manageLabels([LABELS.AVATAR_OK]);
} else {
blockedByWaf = true;
reportData.push({ item: '头像连通性', status: '⚠️ 疑似拦截', detail: avatarRes.error || `请求受阻,状态码: ${avatarRes.status} (WAF?)` });
await manageLabels([LABELS.AVATAR_ERR]);
}
// 验证站点
if (siteRes.ok) {
reportData.push({ item: '站点连通性', status: '✅ 正常', detail: `状态码: ${siteRes.status}` });
await manageLabels([LABELS.SITE_OK]);
} else {
blockedByWaf = true;
reportData.push({ item: '站点连通性', status: '⚠️ 疑似拦截', detail: siteRes.error || `请求受阻,状态码: ${siteRes.status} (WAF?)` });
await manageLabels([LABELS.SITE_ERR]);
}
// 验证反链
if (backlinkRes.ok) {
const html = backlinkRes.text;
let hasBacklink = false;
let isNofollow = false;
const domainPattern = MY_DOMAINS.map(d => d.replace(/\./g, '\\.')).join('|');
const linkRegex = new RegExp(`<a[^>]*href\\s*=\\s*["'][^"']*(${domainPattern})[^"']*["'][^>]*>`, 'gi');
let match;
while ((match = linkRegex.exec(html)) !== null) {
hasBacklink = true;
if (match[0].toLowerCase().includes('rel="nofollow"')) {
isNofollow = true;
}
}
if (hasBacklink) {
const nofollowTag = isNofollow ? ' *(注: 检测到 nofollow 属性)*' : '';
reportData.push({ item: '反链检测', status: '✅ 已找到', detail: `在 [目标页面](${checkTargetUrl}) 中发现本站链接${nofollowTag}` });
await manageLabels([LABELS.LINK_OK]);
} else {
blockedByWaf = true;
reportData.push({ item: '反链检测', status: '⚠️ 未找到', detail: `未在 [目标页面](${checkTargetUrl}) 提取到指向本站的超链接,可能被安全验证页阻隔` });
await manageLabels([LABELS.LINK_ERR]);
}
} else {
blockedByWaf = true;
reportData.push({ item: '反链检测', status: '⚠️ 疑似拦截', detail: `无法作为爬虫访问扒取反链的网页资源,状态码: ${backlinkRes.status}` });
await manageLabels([LABELS.LINK_ERR]);
}
} catch (err) {
fatalError = true;
reportData.push({ item: '前置校验拦截', status: '❌ 未通过', detail: err.message });
}
// ================= 报告生成与最终动作 =================
let markdownTable = `### 🔍 自动化审核报告\n\n| 检查项目 | 状态 | 详细说明 |\n| :--- | :--- | :--- |\n`;
reportData.forEach(r => { markdownTable += `| **${r.item}** | ${r.status} | ${r.detail} |\n`; });
// 无论如何,只要进行到这里,都要保证 Issue 是开启的(除了严重格式错误强制关停)
const ensureOpenIssue = async () => {
const currentIssue = await github.rest.issues.get({ owner, repo, issue_number });
if (currentIssue.data.state === 'closed') {
await github.rest.issues.update({ owner, repo, issue_number, state: 'open' });
}
};
if (fatalError) {
// 严重的格式问题 (JSON没写对, SSRF注入) -> 拒绝并关闭
await manageLabels([LABELS.NEED_FIX], [LABELS.RUNNING]);
await createComment(`${markdownTable}\n\n---\n\n${MSG.REJECT}`);
await github.rest.issues.update({ owner, repo, issue_number, state: 'closed' });
core.setFailed('前置安全/格式审核未通过,已关闭 Issue。');
} else if (blockedByWaf) {
// 被 WAF 盾拦截或只是轻度没扫出 -> 标注人工,不关闭
await ensureOpenIssue();
await manageLabels([LABELS.NEEDS_MANUAL], [LABELS.RUNNING, LABELS.PASSED, LABELS.NEED_FIX]);
await createComment(`${markdownTable}\n\n---\n\n${MSG.NEEDS_MANUAL}`);
core.notice('遭遇连通性障碍 (疑似 WAF 防护),已挂起并转交人工复核。');
} else {
// 完美命中 -> 直接通过
await ensureOpenIssue();
await manageLabels([LABELS.PASSED], [LABELS.RUNNING, LABELS.NEEDS_MANUAL, LABELS.NEED_FIX]);
await createComment(`${markdownTable}\n\n---\n\n${MSG.SUCCESS}`);
}