-
Notifications
You must be signed in to change notification settings - Fork 9
129 lines (113 loc) · 6.19 KB
/
Copy pathmoderate-issue-spam.yml
File metadata and controls
129 lines (113 loc) · 6.19 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
name: Moderate first-time spam links
# Redacts links posted by first-time contributors to any host outside a small
# allowlist (GitHub, image/video hosts), then pings the maintainer. Allowlist
# beats a blocklist here: an unknown throwaway host is flagged by default, so the
# "here's a fix in a zip" malware pattern (issue #221) can't evade it with a new
# domain or an extension-less link. See issue #222.
on:
issue_comment:
types: [created, edited]
issues:
types: [opened, edited]
permissions:
issues: write
jobs:
moderate:
runs-on: ubuntu-latest
# Skip anything a bot posted, including this workflow's own edits/comments,
# so it cannot loop on the edits it makes.
if: github.event.sender.type != 'Bot'
steps:
- name: Redact first-time download links
uses: actions/github-script@v7
with:
script: |
const REDACTION = '«link removed by spam moderation»';
const MAINTAINER = 'pliablepixels';
const UNTRUSTED = new Set(['FIRST_TIME_CONTRIBUTOR', 'FIRST_TIMER', 'NONE', 'MANNEQUIN']);
// Hosts a first-time contributor may legitimately link. Suffix-matched,
// so 'github.com' also covers gist.github.com, raw.githubusercontent.com,
// etc. Deliberately excludes text-paste and file-locker hosts (pastebin,
// 0x0.st, mega, dropbox, drive.google, ...): those are the payload
// channel these bots use, and unknown hosts are flagged by default.
const HOST_ALLOWLIST = [
'github.com', 'githubusercontent.com', 'github.io',
'imgur.com',
'youtube.com', 'youtu.be', 'streamable.com',
'zoneminder.com',
];
const hostOf = (url) => {
try { return new URL(url).hostname.toLowerCase().replace(/^www\./, ''); }
catch { return ''; }
};
const isAllowed = (host) =>
!!host && HOST_ALLOWLIST.some((h) => host === h || host.endsWith('.' + h));
// Any http(s) link whose host is not on the allowlist is flaggable.
const findFlaggable = (body) => {
if (!body || body.includes(REDACTION)) return [];
const hits = [];
const seen = new Set();
const md = /\[([^\]]*)\]\(\s*(https?:\/\/[^\s)]+)\s*\)/gi;
let m;
while ((m = md.exec(body)) !== null) {
const [raw, label, url] = m;
if (!isAllowed(hostOf(url))) { hits.push({ raw, url, label }); seen.add(url); }
}
const bare = /(https?:\/\/[^\s<>()\[\]]+)/gi;
while ((m = bare.exec(body)) !== null) {
const url = m[1].replace(/[)\].,'"]+$/, '');
if (seen.has(url)) continue;
if (!isAllowed(hostOf(url))) hits.push({ raw: m[1], url, label: '' });
}
return hits;
};
const isComment = !!context.payload.comment;
const target = isComment ? context.payload.comment : context.payload.issue;
if (!target) { core.info('No comment/issue in payload.'); return; }
const association = target.author_association || '';
if (!UNTRUSTED.has(association)) {
core.info(`Skip: author_association=${association} is trusted.`);
return;
}
const body = target.body || '';
let hits = findFlaggable(body);
if (hits.length === 0) { core.info('No flaggable download links.'); return; }
const owner = context.repo.owner;
const repo = context.repo.repo;
const issueNumber = context.payload.issue.number;
const author = target.user.login;
// A first-timer quoting or re-pasting a link a trusted user already
// posted in this thread is not spam (issue #268): drop those hits.
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: issueNumber, per_page: 100,
});
const trustedText = comments
.filter((c) => c.user.type !== 'Bot' && !UNTRUSTED.has(c.author_association || ''))
.map((c) => c.body || '')
.concat(UNTRUSTED.has(context.payload.issue.author_association || '') ? [] : [context.payload.issue.body || ''])
.join('\n');
hits = hits.filter((h) => !trustedText.includes(h.url));
if (hits.length === 0) { core.info('All flagged links already posted by trusted users.'); return; }
let newBody = body;
for (const h of hits) newBody = newBody.split(h.raw).join(REDACTION);
if (isComment) {
await github.rest.issues.updateComment({ owner, repo, comment_id: target.id, body: newBody });
} else {
await github.rest.issues.update({ owner, repo, issue_number: issueNumber, body: newBody });
}
const where = isComment ? `comment (${target.html_url})` : 'issue body';
const list = hits
.map((h) => `- \`${h.label || '(bare link)'}\` → ${hostOf(h.url) || h.url}`)
.join('\n');
const historyOf = isComment ? "comment's" : "issue's";
const message = [
`Hi \`@${author}\`, links from new accounts are automatically held for review. The following off-site link was removed from your ${isComment ? 'comment' : 'issue'} for now; a maintainer will restore it shortly if it is legitimate:`,
'',
list,
'',
`Please do not download or run anything from links posted by new accounts until a maintainer confirms them.`,
'',
`@${MAINTAINER} first-time contributor (\`@${author}\`, \`${association}\`) posted an off-allowlist link in the ${where}. It stays in the ${historyOf} edit history, so restore it if this is a false positive; hide/delete and report the account if it is spam.`,
].join('\n');
await github.rest.issues.createComment({ owner, repo, issue_number: issueNumber, body: message });
core.info(`Redacted ${hits.length} link(s) from ${where} by @${author}.`);