Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 3bba26f860 ci: relay edited issue comments with truncated bodies
Forward issue_comment edited events and include comment identity,
association, timestamps, and bodies clipped to 4000 characters.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-19 08:04:08 +00:00
Cursor Agentand陈大猫 281f55a99a ci: hide only risky download links in issue comments
Stop deleting comments that merely mention an archive or executable
name. Match http(s) URLs whose path contains a risky extension, skip
the issue author as well as maintainers and bots, and minimize matches
as spam instead of deleting them.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-19 08:04:08 +00:00
4 changed files with 329 additions and 158 deletions

No files matched your search

+43 -9
View File
@@ -4,7 +4,7 @@ on:
issues:
types: [opened, reopened, edited]
issue_comment:
types: [created]
types: [created, edited]
permissions: {}
@@ -24,14 +24,48 @@ jobs:
echo "Grok Bot webhook secrets not set; skipping."
exit 0
fi
jq --arg event "$GITHUB_EVENT_NAME" '{
event: $event,
action: .action,
repository: .repository.full_name,
sender: .sender.login,
issue: {number: .issue.number, title: .issue.title, author: .issue.user.login, url: .issue.html_url},
comment: (if .comment then {id: .comment.id, author: .comment.user.login, url: .comment.html_url} else null end)
}' "$GITHUB_EVENT_PATH" \
jq --arg event "$GITHUB_EVENT_NAME" '
def clip:
if type == "string" then .[0:4000] else . end;
{
event: $event,
action: .action,
repository: .repository.full_name,
sender: .sender.login,
issue: (
{
number: .issue.number,
title: .issue.title,
author: .issue.user.login,
url: .issue.html_url
} + (
if $event == "issues" then {
author_association: .issue.author_association,
body: (.issue.body | clip)
} else {} end
)
),
comment: (
if .comment then
{
id: .comment.id,
author: .comment.user.login,
url: .comment.html_url,
node_id: .comment.node_id,
author_association: .comment.author_association,
user_type: .comment.user.type,
created_at: .comment.created_at,
updated_at: .comment.updated_at,
body: (.comment.body | clip)
} + (
if .action == "edited" then {
previous_body: (.changes.body.from | clip)
} else {} end
)
else null end
)
}
' "$GITHUB_EVENT_PATH" \
| curl -fsS --max-time 20 -X POST \
-H "Authorization: Bearer $WEBHOOK_KEY" \
-H "Content-Type: application/json" \
+44 -11
View File
@@ -2,7 +2,7 @@ name: spam-comments
on:
issue_comment:
types: [created]
types: [created, edited]
permissions:
contents: read
@@ -10,13 +10,13 @@ permissions:
pull-requests: write
jobs:
delete-spam:
hide-spam:
runs-on: ubuntu-latest
steps:
- name: Checkout filter rules
uses: actions/checkout@v7
- name: Delete suspected malware spam
- name: Hide suspected malware spam
uses: actions/github-script@v9
with:
script: |
@@ -25,26 +25,59 @@ jobs:
path.join(process.env.GITHUB_WORKSPACE, 'scripts/spam-comment-filter.cjs')
);
const comment = context.payload.comment;
const comment = context.payload.comment || {};
const issue = context.payload.issue || {};
const login = (comment.user && comment.user.login) || 'unknown';
const result = detectSpamComment({
body: comment.body || '',
authorAssociation: comment.author_association,
userType: comment.user && comment.user.type,
commentAuthor: comment.user && comment.user.login,
issueAuthor: issue.user && issue.user.login,
});
core.info(JSON.stringify(result, null, 2));
if (!result.spam) {
core.info('Comment did not match the zip/dangerous-file spam filter.');
core.info('Comment did not match the risky-download spam filter.');
return;
}
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: comment.id,
});
const matchedUrl = result.matchedUrls.join(', ');
if (!comment.node_id) {
core.setFailed(
`Comment ${comment.id} by @${login} on #${issue.number} matched ${matchedUrl} but has no node_id.`
);
return;
}
try {
await github.graphql(
`mutation($input: MinimizeCommentInput!) {
minimizeComment(input: $input) {
minimizedComment {
isMinimized
}
}
}`,
{
input: {
subjectId: comment.node_id,
classifier: 'SPAM',
},
}
);
} catch (error) {
const message = String((error && error.message) || error);
if (/already minimized/i.test(message)) {
core.info(
`Comment ${comment.id} by @${login} on #${issue.number} is already minimized. Matched URL: ${matchedUrl}`
);
return;
}
throw error;
}
core.notice(
`Deleted comment with zip/dangerous attachment by @${comment.user.login} on #${context.payload.issue.number}: ${result.reasons.join('; ')}`
`Hid comment ${comment.id} by @${login} on #${issue.number} as spam. Matched URL: ${matchedUrl}`
);
+103 -50
View File
@@ -6,21 +6,32 @@ const TRUSTED_AUTHOR_ASSOCIATIONS = new Set([
"COLLABORATOR",
]);
const DANGEROUS_FILE_EXTENSION =
"(?:zip|7z|rar|tar\\.gz|tgz|exe|msi|dmg|pkg|deb|rpm|appimage|bat|cmd|ps1|scr|vbs)";
const RISKY_EXTENSIONS = [
".tar.gz",
".tar.xz",
".appimage",
".msix",
".zip",
".7z",
".rar",
".tgz",
".exe",
".msi",
".dmg",
".pkg",
".deb",
".rpm",
".bat",
".cmd",
".ps1",
".scr",
".vbs",
".jar",
".iso",
".lnk",
];
const dangerousFilePattern = new RegExp(
`(?:^|[\\s([<"'=])([^\\s()[\\]<>\"']+\\.${DANGEROUS_FILE_EXTENSION})(?=$|[\\s)\\]>\"']|[.,!?;:](?:$|\\s))`,
"gi"
);
const zipFilePattern = new RegExp(
`(?:^|[\\s([<"'=])([^\\s()[\\]<>\"']+\\.zip)(?=$|[\\s)\\]>\"']|[.,!?;:](?:$|\\s))`,
"gi"
);
const githubUserAttachmentPattern =
/^https:\/\/github\.com\/user-attachments\/files\/\d+\//i;
const URL_PATTERN = /https?:\/\/[^\s<>"'`]+/gi;
function normalizeAssociation(authorAssociation) {
return String(authorAssociation || "").trim().toUpperCase();
@@ -30,65 +41,107 @@ function isTrustedAuthor(authorAssociation) {
return TRUSTED_AUTHOR_ASSOCIATIONS.has(normalizeAssociation(authorAssociation));
}
function extractMatches(body, pattern) {
const files = [];
for (const match of body.matchAll(pattern)) {
files.push(match[1]);
function isSameLogin(left, right) {
const a = String(left || "").trim().toLowerCase();
const b = String(right || "").trim().toLowerCase();
return a.length > 0 && a === b;
}
function trimUrlCandidate(raw) {
let value = String(raw || "");
while (value.length > 0) {
const last = value[value.length - 1];
if (".,;:!?\"']".includes(last)) {
value = value.slice(0, -1);
continue;
}
if (last === ")") {
const opens = (value.match(/\(/g) || []).length;
const closes = (value.match(/\)/g) || []).length;
if (closes > opens) {
value = value.slice(0, -1);
continue;
}
}
break;
}
return [...new Set(files)];
return value;
}
function extractDangerousFiles(body) {
return extractMatches(body, dangerousFilePattern);
function hasRiskyExtension(segment) {
const name = String(segment || "").toLowerCase();
if (!name || name === "." || name === "..") return false;
return RISKY_EXTENSIONS.some((extension) => name.endsWith(extension));
}
function extractZipFiles(body) {
return extractMatches(body, zipFilePattern);
function urlHasRiskyPath(raw) {
let parsed;
try {
parsed = new URL(raw);
} catch {
return false;
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
let pathname = parsed.pathname || "";
try {
pathname = decodeURIComponent(pathname);
} catch {
// Keep the raw pathname when it is not valid percent-encoding.
}
return pathname.split("/").some((segment) => hasRiskyExtension(segment));
}
function isGitHubUserAttachment(file) {
return githubUserAttachmentPattern.test(file);
function extractRiskyUrls(body) {
const text = String(body || "");
const found = [];
const seen = new Set();
for (const match of text.matchAll(URL_PATTERN)) {
const raw = trimUrlCandidate(match[0]);
if (!raw || seen.has(raw) || !urlHasRiskyPath(raw)) continue;
seen.add(raw);
found.push(raw);
}
return found;
}
function detectSpamComment({ body, authorAssociation, userType } = {}) {
const normalizedBody = String(body || "").replace(/\s+/g, " ").trim();
const dangerousFiles = extractDangerousFiles(normalizedBody);
const zipFiles = extractZipFiles(normalizedBody);
function detectSpamComment({
body,
authorAssociation,
userType,
commentAuthor,
issueAuthor,
} = {}) {
const trustedAuthor = isTrustedAuthor(authorAssociation);
const botAuthor = String(userType || "").toLowerCase() === "bot";
const botAuthor = String(userType || "").trim().toLowerCase() === "bot";
const authorIsIssueAuthor = isSameLogin(commentAuthor, issueAuthor);
const matchedUrls = extractRiskyUrls(body);
const reasons = [];
let score = 0;
if (zipFiles.length > 0) {
score += 10;
reasons.push(`zip attachment or link: ${zipFiles.join(", ")}`);
} else if (dangerousFiles.length > 0) {
score += 10;
reasons.push(`dangerous downloadable file: ${dangerousFiles.join(", ")}`);
}
// Hard rule: any zip (or other dangerous downloadable) from an untrusted
// human is deleted. Maintainers and bots are exempt.
// Hard rule: hide when an untrusted human who is not the issue author posts
// an http(s) URL whose path contains a risky archive or executable.
// Maintainers, bots, and the issue author are exempt. Bare filenames and
// local paths are not links, so they do not match.
const spam =
!trustedAuthor &&
!botAuthor &&
(zipFiles.length > 0 || dangerousFiles.length > 0);
!authorIsIssueAuthor &&
matchedUrls.length > 0;
return {
spam,
score: spam ? score : 0,
reasons: spam ? reasons : [],
dangerousFiles: zipFiles.length > 0 ? zipFiles : dangerousFiles,
score: spam ? 10 : 0,
reasons: spam ? [`risky download URL: ${matchedUrls.join(", ")}`] : [],
matchedUrls,
trustedAuthor,
botAuthor,
authorIsIssueAuthor,
};
}
module.exports = {
detectSpamComment,
extractDangerousFiles,
extractZipFiles,
isGitHubUserAttachment,
extractRiskyUrls,
isTrustedAuthor,
RISKY_EXTENSIONS,
};
+139 -88
View File
@@ -2,113 +2,164 @@
const assert = require("node:assert/strict");
const test = require("node:test");
const {
detectSpamComment,
extractDangerousFiles,
extractZipFiles,
isGitHubUserAttachment,
} = require("./spam-comment-filter.cjs");
const { detectSpamComment, extractRiskyUrls } = require("./spam-comment-filter.cjs");
test("flags the fake Netcatty patch spam pattern", () => {
test("issue author pasting a local SecureCRT.exe path is not hidden", () => {
const result = detectSpamComment({
authorAssociation: "NONE",
userType: "User",
body: "[netcatty_patch.zip](https://example.com/netcatty_patch.zip)\n\nMan, that terminal rendering bug is such a pain. It looks like the sftp module is tripping over the encoding when it tries to sync the current path. I found a quick patch that fixes the character mapping in the backend so those black boxes finally disappear.",
});
assert.equal(result.spam, true);
});
test("flags fake patch spam when the filename ends a sentence", () => {
const result = detectSpamComment({
authorAssociation: "NONE",
userType: "User",
body: "Download this patch.zip. It fixes the backend encoding so the terminal rendering black boxes disappear.",
});
assert.equal(result.spam, true);
assert.deepEqual(result.dangerousFiles, ["patch.zip"]);
});
test("flags any zip from an outside user, including hash-named soft bait", () => {
const result = detectSpamComment({
authorAssociation: "NONE",
userType: "User",
body: "[63bf1862b52422e38b8cb170.zip](https://github.com/user-attachments/files/29784176/63bf1862b52422e38b8cb170.zip)\nI'd start with the docs inside",
});
assert.equal(result.spam, true);
assert.ok(
result.dangerousFiles.some((file) => file.includes("63bf1862b52422e38b8cb170.zip"))
);
});
test("flags ordinary zip log attachments from outside users", () => {
const result = detectSpamComment({
authorAssociation: "FIRST_TIME_CONTRIBUTOR",
userType: "User",
body: "[debug-logs.zip](https://github.com/user-attachments/files/29784176/debug-logs.zip)\nI attached logs from a failed connection. The app hangs after I click connect, and the logs show the SSH handshake timing out.",
});
assert.equal(result.spam, true);
});
test("flags other dangerous archives from outside users", () => {
const result = detectSpamComment({
authorAssociation: "NONE",
userType: "User",
body: "See hotfix.dmg for the workaround.",
});
assert.equal(result.spam, true);
assert.deepEqual(result.dangerousFiles, ["hotfix.dmg"]);
});
test("does not flag trusted maintainers even when sharing zip archives", () => {
const result = detectSpamComment({
authorAssociation: "OWNER",
userType: "User",
body: "Try this temporary netcatty_patch.zip while I prepare the signed release. It fixes the rendering issue.",
commentAuthor: "reporter",
issueAuthor: "reporter",
body: "It crashes here: C:\\Program Files\\SecureCRT\\SecureCRT.exe",
});
assert.equal(result.spam, false);
assert.deepEqual(result.matchedUrls, []);
});
test("does not flag bot comments that mention zip files", () => {
const result = detectSpamComment({
authorAssociation: "NONE",
userType: "Bot",
body: "Uploaded build-artifacts.zip for CI.",
});
assert.equal(result.spam, false);
});
test("does not flag comments without downloadable archives", () => {
test("bare Windows path without a URL is not hidden even from a third party", () => {
const result = detectSpamComment({
authorAssociation: "NONE",
userType: "User",
body: "I attached screenshots of the hang after connect. The SSH handshake times out.",
commentAuthor: "mallory",
issueAuthor: "reporter",
body: "Try Files\\SecureCRT\\SecureCRT.exe and also hotfix.dmg.",
});
assert.equal(result.spam, false);
assert.deepEqual(result.matchedUrls, []);
});
test("extracts risky file names from markdown links and plain text", () => {
assert.deepEqual(
extractDangerousFiles("[fix.zip](https://example.com/fix.zip) also hotfix.dmg."),
["fix.zip", "https://example.com/fix.zip", "hotfix.dmg"]
);
assert.deepEqual(extractZipFiles("[fix.zip](https://example.com/fix.zip) also hotfix.dmg."), [
"fix.zip",
"https://example.com/fix.zip",
test("third party github user-attachments zip URL is hidden", () => {
const result = detectSpamComment({
authorAssociation: "NONE",
userType: "User",
commentAuthor: "mallory",
issueAuthor: "reporter",
body: "Grab https://github.com/user-attachments/files/123/fix.zip and run it.",
});
assert.equal(result.spam, true);
assert.deepEqual(result.matchedUrls, [
"https://github.com/user-attachments/files/123/fix.zip",
]);
});
test("identifies GitHub user attachment URLs", () => {
assert.equal(
isGitHubUserAttachment("https://github.com/user-attachments/files/29784176/netcatty_fix.zip"),
true
test("third party mediafire zip URL is hidden when the extension is a path segment", () => {
const result = detectSpamComment({
authorAssociation: "NONE",
userType: "User",
commentAuthor: "mallory",
issueAuthor: "reporter",
body: "Download https://www.mediafire.com/file/abc/netcatty_fix.zip/file",
});
assert.equal(result.spam, true);
assert.deepEqual(result.matchedUrls, [
"https://www.mediafire.com/file/abc/netcatty_fix.zip/file",
]);
});
test("maintainer posting a release dmg link is not hidden", () => {
const result = detectSpamComment({
authorAssociation: "OWNER",
userType: "User",
commentAuthor: "maintainer",
issueAuthor: "reporter",
body: "Signed build: https://github.com/binaricat/Netcatty/releases/download/v1.2.3/Netcatty.dmg",
});
assert.equal(result.spam, false);
assert.equal(result.trustedAuthor, true);
assert.deepEqual(result.matchedUrls, [
"https://github.com/binaricat/Netcatty/releases/download/v1.2.3/Netcatty.dmg",
]);
});
test("issue author uploading their own repro zip is not hidden", () => {
const result = detectSpamComment({
authorAssociation: "NONE",
userType: "User",
commentAuthor: "Reporter",
issueAuthor: "reporter",
body: "[repro.zip](https://github.com/user-attachments/files/99/repro.zip)",
});
assert.equal(result.spam, false);
assert.equal(result.authorIsIssueAuthor, true);
assert.deepEqual(result.matchedUrls, [
"https://github.com/user-attachments/files/99/repro.zip",
]);
});
test("query strings do not block a risky path, and a query-only filename does not match", () => {
assert.deepEqual(
extractRiskyUrls("https://example.com/dropper.exe?download=1&arch=win"),
["https://example.com/dropper.exe?download=1&arch=win"]
);
assert.equal(isGitHubUserAttachment("https://example.com/netcatty_fix.zip"), false);
assert.deepEqual(
extractRiskyUrls("https://example.com/download?file=fix.zip"),
[]
);
});
test("matches newly added extensions and ignores non-risky links", () => {
const body = [
"https://example.com/a.jar",
"https://example.com/b.iso",
"https://example.com/c.msix",
"https://example.com/d.lnk",
"https://example.com/e.tar.xz",
"https://example.com/f.tar.gz",
"https://github.com/user-attachments/assets/e1302660-dece-46be-9857-1678a9b13d00",
"https://example.com/package.json",
"https://example.com/notes.zip.txt",
].join("\n");
assert.deepEqual(extractRiskyUrls(body), [
"https://example.com/a.jar",
"https://example.com/b.iso",
"https://example.com/c.msix",
"https://example.com/d.lnk",
"https://example.com/e.tar.xz",
"https://example.com/f.tar.gz",
]);
});
test("member and collaborator exemptions still apply to http links", () => {
for (const authorAssociation of ["MEMBER", "COLLABORATOR"]) {
const result = detectSpamComment({
authorAssociation,
userType: "User",
commentAuthor: "maintainer",
issueAuthor: "reporter",
body: "Mirror: http://example.com/setup.msi",
});
assert.equal(result.spam, false);
assert.deepEqual(result.matchedUrls, ["http://example.com/setup.msi"]);
}
});
test("does not flag bot comments that include a risky URL", () => {
const result = detectSpamComment({
authorAssociation: "NONE",
userType: "Bot",
commentAuthor: "netcatty[bot]",
issueAuthor: "reporter",
body: "Uploaded https://example.com/build-artifacts.zip for CI.",
});
assert.equal(result.spam, false);
assert.equal(result.botAuthor, true);
});
test("strips markdown and sentence punctuation around the URL", () => {
assert.deepEqual(
extractRiskyUrls("See [patch](https://example.com/patch.zip). Thanks!"),
["https://example.com/patch.zip"]
);
assert.deepEqual(
extractRiskyUrls("<https://example.com/tool.EXE>"),
["https://example.com/tool.EXE"]
);
});