Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion __tests__/integration/ai-review.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -387,12 +387,42 @@ describe('ai-review', () => {
expect(result).toContain('advisory only');
});

it('sanitizes the AI response', () => {
it('sanitizes workflow commands in the AI response', () => {
const result = formatReviewComment('Good code\n::set-output name=x::hack', 10);
expect(result).toContain('[sanitized command]');
expect(result).not.toContain('::set-output');
});

// Bug #29 — widen sanitiser
it('escapes HTML angle brackets so injected <img> / <script> render as text', () => {
const result = formatReviewComment(
'<img src="x" onerror="alert(1)"><script>evil()</script>',
11
);
expect(result).not.toMatch(/<img/i);
expect(result).not.toMatch(/<script/i);
expect(result).toContain('&lt;img');
expect(result).toContain('&lt;script');
});

it('de-fangs @mentions so they render but do not notify', () => {
const result = formatReviewComment(
'cc @octocat please review, also @pulseengine/maintainers',
12
);
// The @ remains visible to humans but the username is preceded by a
// zero-width space (\u200B), preventing GitHub from creating a mention.
expect(result).toContain('@\u200Boctocat');
expect(result).toContain('@\u200Bpulseengine/maintainers');
expect(result).not.toMatch(/(?<!\u200B)@octocat/);
});

it('preserves email-like text that is not a GitHub mention', () => {
// `foo@example.com` is not a GitHub username pattern; let it through.
const result = formatReviewComment('Contact foo@example.com', 13);
expect(result).toContain('foo');
});

it('includes local AI model attribution', () => {
const result = formatReviewComment('Review text', 1);
expect(result).toContain('local AI model');
Expand Down
36 changes: 34 additions & 2 deletions src/ai-review.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,41 @@ function isLocalEndpoint(endpoint) {
}
}

/**
* Strip patterns that an attacker-influenced PR diff could exploit through
* the AI model's output. Wave-1 LLM and Security agents flagged the
* previous one-pattern version (workflow-commands only) as too narrow —
* Bug #29 in `docs/agent-fleet/bugs.md`.
*
* Concretely, attacker patterns we now neutralise:
*
* 1. GitHub Actions workflow commands (`::set-output ::`, `::error ::`)
* — could escape into a runner that consumes bot comments.
* 2. HTML elements that GitHub renders inside Markdown comments —
* `<img>`, `<script>`, `<iframe>`, `<style>`, `<form>`,
* `<a href="javascript:…">`. Neutralised by escaping `<` and `>` to
* HTML entities so they render as text, not markup.
* 3. Mass `@mention` payloads that could ping reviewers / teams /
* org-wide. Replaced `@something` with `@\u200Bsomething` (zero-width
* space) so the @ sigil renders but doesn't activate notifications.
* 4. Fake "Approved" / "LGTM" / "Approve" verdict-mimicking sentences —
* left as-is *visually* but the strict-JSON `verdict` is computed by
* `computeVerdict` from filtered findings; the model's free-text
* review never decides the verdict, so this is defence-in-depth only.
*
* The output is intended to be safe to embed *inside a Markdown blockquote
* or code block* on a GitHub comment. Callers SHOULD wrap it that way too.
*/
function sanitizeAIOutput(text) {
// Strip GitHub Actions workflow commands that could be injected
return text.replace(/::[\w-]+(\s+[\w-]+=[\w-]+)*::.*/g, '[sanitized command]');
if (typeof text !== 'string') return '';
return text
// 1. Workflow commands
.replace(/::[\w-]+(\s+[\w-]+=[\w-]+)*::.*/g, '[sanitized command]')
// 2. HTML angle brackets — render as text, not markup
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// 3. Mention de-fanging (zero-width space after @)
.replace(/@([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}[A-Za-z0-9])?(?:\/[A-Za-z0-9](?:[A-Za-z0-9-]{0,38}[A-Za-z0-9])?)?)/g, '@\u200B$1');
}

/**
Expand Down
Loading