qwen-code/scripts/tests/comment-attachment-guard-workflow.test.js
易良 3bf2d45403
ci: add suspicious comment attachment guard (#6599)
* 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>
2026-07-10 09:40:54 +00:00

280 lines
9.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @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/malwareexe'],
['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',
]);
});
});