mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-31 20:04:41 +00:00
* ci: add suspicious comment attachment guard Resolves #6597 * ci: reduce attachment guard false positives * ci: harden comment attachment guard * ci: tighten attachment extension matching * ci: avoid false attachment removal summary * ci: avoid markdown link attachment false positives * ci: avoid country-code attachment false positives * ci: harden attachment URL parsing * ci: reduce attachment guard false positives Updates #6597 * ci: harden malformed attachment URLs Updates #6597 * ci: reduce attachment guard overmatching Updates #6597 * ci: scan attachment path segments Updates #6597 * ci: cover review attachment summaries * ci: harden attachment link detection * fix: address critical bypass vectors in comment attachment guard - Remove break in decodeTarget catch block so malformed percent sequences (e.g., %ZZ) don't prematurely exit the decode loop, allowing double-encoded extensions to be fully detected - Strip zero-width characters (U+200B-U+200D, U+FEFF, U+00AD, U+2060, U+180E) before NFKC normalization to prevent invisible-character evasion of extension matching - Add protocol-relative URL (//) support to linkPattern and highRiskTarget, normalizing to https: before parsing - Add tests for all three bypass vectors (39 total, all passing) --------- Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
280 lines
9.7 KiB
JavaScript
280 lines
9.7 KiB
JavaScript
/**
|
||
* @license
|
||
* Copyright 2025 Qwen Team
|
||
* SPDX-License-Identifier: Apache-2.0
|
||
*/
|
||
|
||
import { readFileSync } from 'node:fs';
|
||
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { describe, expect, it } from 'vitest';
|
||
|
||
const repoRoot = path.resolve(
|
||
path.dirname(fileURLToPath(import.meta.url)),
|
||
'../..',
|
||
);
|
||
|
||
describe('comment attachment guard workflow', () => {
|
||
const workflow = readFileSync(
|
||
path.join(repoRoot, '.github/workflows/comment-attachment-guard.yml'),
|
||
'utf8',
|
||
);
|
||
const script = workflow
|
||
.split('\n')
|
||
.slice(
|
||
workflow.split('\n').findIndex((line) => line.trim() === 'script: |') + 1,
|
||
)
|
||
.filter((line) => line.startsWith(' ') || line.trim() === '')
|
||
.map((line) => (line.startsWith(' ') ? line.slice(12) : ''))
|
||
.join('\n');
|
||
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
|
||
const runScript = new AsyncFunction('github', 'context', 'core', script);
|
||
|
||
async function runGuard(body, options = {}) {
|
||
const calls = [];
|
||
const failures = [];
|
||
const warnings = [];
|
||
const summaries = [];
|
||
const github = {
|
||
rest: {
|
||
issues: {
|
||
deleteComment: async (args) => {
|
||
calls.push(['issue', args.comment_id]);
|
||
if (options.deleteThrows) {
|
||
throw Object.assign(new Error(options.deleteMessage ?? 'gone'), {
|
||
status: options.deleteStatus ?? 404,
|
||
});
|
||
}
|
||
},
|
||
},
|
||
pulls: {
|
||
deleteReviewComment: async (args) => {
|
||
calls.push(['review', args.comment_id]);
|
||
},
|
||
},
|
||
},
|
||
graphql: async (_query, variables) => {
|
||
calls.push(['review-summary', variables.id]);
|
||
},
|
||
};
|
||
const core = {
|
||
info() {},
|
||
setFailed: (message) => failures.push(message),
|
||
warning: (message) => warnings.push(message),
|
||
summary: {
|
||
addHeading(value) {
|
||
summaries.push(['heading', value]);
|
||
return this;
|
||
},
|
||
addTable(value) {
|
||
summaries.push(['table', value]);
|
||
return this;
|
||
},
|
||
async write() {},
|
||
},
|
||
};
|
||
const comment = {
|
||
id: 123,
|
||
node_id: options.nodeId ?? 'PRR_123',
|
||
body,
|
||
author_association: options.association ?? 'NONE',
|
||
user: options.commentUser ?? { login: 'attacker' },
|
||
};
|
||
const context = {
|
||
eventName: options.eventName ?? 'issue_comment',
|
||
repo: { owner: 'QwenLM', repo: 'qwen-code' },
|
||
payload: {
|
||
action: options.action ?? 'created',
|
||
sender: options.sender ?? { type: 'User', login: 'attacker' },
|
||
...(options.eventName === 'pull_request_review'
|
||
? { review: comment }
|
||
: { comment }),
|
||
},
|
||
};
|
||
|
||
await runScript(github, context, core);
|
||
|
||
return { calls, failures, summaries, warnings };
|
||
}
|
||
|
||
it('stops risky extensions only on alphanumeric continuation', () => {
|
||
expect(workflow).toContain('(?![a-zA-Z0-9])');
|
||
});
|
||
|
||
it('checks markdown link URLs instead of display text', () => {
|
||
expect(workflow).toContain('const url = mdMatch ? mdMatch[1] : snippet;');
|
||
expect(workflow).toContain(
|
||
'return highRiskExtension.test(highRiskTarget(url));',
|
||
);
|
||
});
|
||
|
||
it('listens for PR review summaries', () => {
|
||
expect(workflow).toContain('pull_request_review:\n types:');
|
||
expect(workflow).toContain("- 'submitted'");
|
||
});
|
||
|
||
it('checks URL paths instead of country-code TLD hosts', () => {
|
||
expect(workflow).toContain('const parsedUrl = new URL(');
|
||
expect(workflow).toContain('/^www\\./i.test(url)');
|
||
expect(workflow).toContain('...parsedUrl.pathname.split');
|
||
expect(workflow).toContain(
|
||
'.find((segment) => highRiskExtension.test(segment))',
|
||
);
|
||
expect(workflow).not.toContain('|sh|');
|
||
expect(workflow).not.toContain('|so)');
|
||
});
|
||
|
||
it('skips comments edited by a different user', () => {
|
||
expect(workflow).toContain("const action = context.payload.action ?? '';");
|
||
expect(workflow).toContain("comment.user?.login ?? 'ghost'");
|
||
expect(workflow).toContain("action === 'edited'");
|
||
expect(workflow).toContain('sender.login !== commentAuthor');
|
||
});
|
||
|
||
it('does not scan fenced code blocks or inline code spans', () => {
|
||
expect(workflow).toContain("replace(/```[\\s\\S]*?```/g, '')");
|
||
expect(workflow).toContain("replace(/`[^`]*`/g, '')");
|
||
expect(workflow).toContain('const linkSnippets = scanBody.match');
|
||
});
|
||
|
||
it('does not throw on malformed URL-like links', () => {
|
||
expect(workflow).toContain('} catch {\n targets = [url];');
|
||
});
|
||
|
||
it('decodes escaped risky extensions in URL paths', () => {
|
||
expect(workflow).toContain('const next = decodeURIComponent(decoded);');
|
||
expect(workflow).toContain(".normalize('NFKC')");
|
||
expect(workflow).toContain('Number.parseInt(match.slice(1), 16)');
|
||
});
|
||
|
||
it('strips zero-width characters before extension matching', () => {
|
||
expect(workflow).toContain('[\\u200B-\\u200D\\uFEFF\\u00AD\\u2060\\u180E]');
|
||
});
|
||
|
||
it('detects protocol-relative URLs', () => {
|
||
expect(workflow).toContain('www\\.|\\/\\/');
|
||
expect(workflow).toContain('/^\\/\\//.test(url)');
|
||
});
|
||
|
||
it('keeps parenthesized URL segments in link matches', () => {
|
||
expect(workflow).toContain(
|
||
String.raw`/(?:https?:\/\/|www\.|\/\/)[^\s"'<>\]]+|\[[^\]]+\]\((?:[^()\s]|\([^()\s]*\))+\)/gi;`,
|
||
);
|
||
});
|
||
|
||
it('keeps diagnostics when deletion or summary writing fails', () => {
|
||
expect(workflow).toContain(
|
||
'Failed to ${moderationVerb} suspicious comment ${comment.id}',
|
||
);
|
||
expect(workflow).toContain('Failed to write suspicious comment summary');
|
||
});
|
||
|
||
it('records which moderation action ran', () => {
|
||
expect(workflow).toContain("let actionTaken = '';");
|
||
expect(workflow).toContain(
|
||
"actionTaken ||\n (eventName === 'pull_request_review'",
|
||
);
|
||
expect(workflow).toContain(".addHeading('Suspicious attachment detected')");
|
||
});
|
||
|
||
it.each([
|
||
['path subsegment', 'https://evil.com/malware.exe/readme.txt'],
|
||
['trailing slash', 'https://evil.com/malware.exe/'],
|
||
['encoded extension', 'https://evil.com/file.e%78e'],
|
||
['double encoded extension', 'https://evil.com/file%252ezip'],
|
||
['query parameter filename', 'https://evil.com/download?file=malware.zip'],
|
||
['fullwidth dot', 'https://evil.com/malware.exe'],
|
||
['trailing punctuation', 'https://evil.com/malware.exe.'],
|
||
['pipe delimiter', 'https://evil.com/malware.exe|note'],
|
||
['equals delimiter', 'https://evil.com/malware.exe=1'],
|
||
['plus delimiter', 'https://evil.com/malware.exe+1'],
|
||
['malformed percent fallback', 'https://evil.com/file.e%78e%ZZ'],
|
||
['markdown parentheses', '[patch](https://evil.com/file(1).exe)'],
|
||
['www autolink', 'www.evil.com/malware.exe'],
|
||
['protocol-relative URL', '//evil.com/malware.exe'],
|
||
['zero-width space in extension', 'https://evil.com/malware.\u200Bexe'],
|
||
['zero-width joiner in extension', 'https://evil.com/malware.\u200Dzip'],
|
||
])('deletes risky links with %s', async (_name, body) => {
|
||
const { calls } = await runGuard(body);
|
||
|
||
expect(calls).toEqual([['issue', 123]]);
|
||
});
|
||
|
||
it.each([
|
||
['alphanumeric continuation', 'https://example.com/run.execution'],
|
||
['common .sh repository path', 'https://github.com/nvm-sh/nvm.sh/issues/1'],
|
||
['www .zip TLD host', 'www.example.zip/download'],
|
||
['inline code', '`https://evil.com/malware.exe`'],
|
||
['fenced code', '```txt\nhttps://evil.com/malware.exe\n```'],
|
||
])('keeps benign or quoted links with %s', async (_name, body) => {
|
||
const { calls } = await runGuard(body);
|
||
|
||
expect(calls).toEqual([]);
|
||
});
|
||
|
||
it('uses the review-comment delete API for PR review comments', async () => {
|
||
const { calls } = await runGuard('https://evil.com/malware.exe', {
|
||
eventName: 'pull_request_review_comment',
|
||
});
|
||
|
||
expect(calls).toEqual([['review', 123]]);
|
||
});
|
||
|
||
it('minimizes risky PR review summaries', async () => {
|
||
const { calls, summaries } = await runGuard('www.evil.com/malware.exe', {
|
||
action: 'submitted',
|
||
eventName: 'pull_request_review',
|
||
nodeId: 'PRR_test',
|
||
});
|
||
|
||
expect(calls).toEqual([['review-summary', 'PRR_test']]);
|
||
expect(JSON.stringify(summaries)).toContain('minimized');
|
||
});
|
||
|
||
it('skips edited comments when the editor is not the author', async () => {
|
||
const { calls } = await runGuard('https://evil.com/malware.exe', {
|
||
action: 'edited',
|
||
sender: { type: 'User', login: 'maintainer' },
|
||
});
|
||
|
||
expect(calls).toEqual([]);
|
||
});
|
||
|
||
it('keeps the audit summary when deleting fails', async () => {
|
||
const { calls, failures, summaries, warnings } = await runGuard(
|
||
'https://evil.com/malware.exe',
|
||
{ deleteThrows: true },
|
||
);
|
||
|
||
expect(calls).toEqual([['issue', 123]]);
|
||
expect(warnings).toEqual([
|
||
'Failed to delete suspicious comment 123: 404 gone',
|
||
]);
|
||
expect(summaries).toContainEqual([
|
||
'heading',
|
||
'Suspicious attachment detected',
|
||
]);
|
||
expect(JSON.stringify(summaries)).toContain('delete failed');
|
||
expect(failures).toEqual([]);
|
||
});
|
||
|
||
it('fails the job after summary when deleting fails unexpectedly', async () => {
|
||
const { calls, failures, summaries, warnings } = await runGuard(
|
||
'https://evil.com/malware.exe',
|
||
{ deleteThrows: true, deleteStatus: 500, deleteMessage: 'server error' },
|
||
);
|
||
|
||
expect(calls).toEqual([['issue', 123]]);
|
||
expect(warnings).toEqual([
|
||
'Failed to delete suspicious comment 123: 500 server error',
|
||
]);
|
||
expect(summaries).toContainEqual([
|
||
'heading',
|
||
'Suspicious attachment detected',
|
||
]);
|
||
expect(JSON.stringify(summaries)).toContain('delete failed');
|
||
expect(failures).toEqual([
|
||
'Failed to delete suspicious comment 123: 500 server error',
|
||
]);
|
||
});
|
||
});
|