Skip to content
Merged
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
57 changes: 53 additions & 4 deletions apps/github-app/src/__tests__/issues-opened.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,17 +80,17 @@ function makeEvent(overrides: Record<string, unknown> = {}): EmitterWebhookEvent
title: 'Bug: CopilotKit crashes on init',
body: 'When I call useCopilotKit() in my Next.js app, it crashes.',
html_url: 'https://github.com/CopilotKit/CopilotKit/issues/42',
...(overrides.issue as Record<string, unknown> ?? {}),
...((overrides.issue as Record<string, unknown>) ?? {}),
},
repository: {
full_name: 'CopilotKit/CopilotKit',
...(overrides.repository as Record<string, unknown> ?? {}),
...((overrides.repository as Record<string, unknown>) ?? {}),
},
sender: {
login: 'user123',
id: 999,
type: 'User',
...(overrides.sender as Record<string, unknown> ?? {}),
...((overrides.sender as Record<string, unknown>) ?? {}),
},
...overrides,
},
Expand All @@ -104,7 +104,9 @@ describe('handleIssueOpened', () => {
ticketId: 'ticket-internal-id',
plugin: 'github',
externalId: 'CopilotKit/CopilotKit#42',
} as ReturnType<typeof prisma.ticketExternalLink.create> extends Promise<infer T> ? T : never);
} as ReturnType<typeof prisma.ticketExternalLink.create> extends Promise<infer T>
? T
: never);
});

it('uses GitHubPlatformAdapter to parse the event', async () => {
Expand Down Expand Up @@ -171,4 +173,51 @@ describe('handleIssueOpened', () => {
expect(prisma.ticketExternalLink.create).not.toHaveBeenCalled();
expect(mockPostSystemMessage).not.toHaveBeenCalled();
});

// The gate has to sit HERE, before the ticket exists. Creating the ticket is
// what enqueues the AI job, and that job is what forwards the body verbatim
// into search-docs/search-code on mcp.copilotkit.ai and posts a public
// reply. A filter further down would still have paid for the relay.
it('ignores a link-spam issue without creating a ticket or relaying anything', async () => {
const event = makeEvent({
issue: {
body:
Array.from(
{ length: 8 },
(_, i) => `Read [our SEO guide ${i}](https://1rank.app/g-${i}). `,
).join('') + 'Search visibility wins customers. '.repeat(100),
author_association: 'NONE',
},
});

await handleIssueOpened(event);

expect(mockParseInboundEvent).not.toHaveBeenCalled();
expect(mockHandle).not.toHaveBeenCalled();
expect(prisma.ticketExternalLink.create).not.toHaveBeenCalled();
expect(mockPostResponse).not.toHaveBeenCalled();
});

// The other half of the same guarantee: a long, link-carrying bug report
// from a first-time reporter still gets answered.
it('still relays a long bug report from an untrusted author', async () => {
const event = makeEvent({
issue: {
body: [
'Repro: https://github.com/someone/repro',
'Docs: https://docs.copilotkit.ai/quickstart',
'```ts',
'useCopilotAction({ name: "x" });',
'```',
'Stack trace follows. '.repeat(200),
].join('\n'),
author_association: 'NONE',
},
});

await handleIssueOpened(event);

expect(mockHandle).toHaveBeenCalled();
expect(prisma.ticketExternalLink.create).toHaveBeenCalled();
});
});
144 changes: 144 additions & 0 deletions apps/github-app/src/__tests__/spam-filter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { describe, it, expect } from 'vitest';
import {
isLikelySpamIssue,
hasFencedCode,
maxLinksToOneThirdPartyHost,
} from '../lib/spam-filter.js';

/**
* Shaped after the 2026-09-10 campaign: ~3.3 KB of marketing prose, no code,
* the same third-party host linked over and over.
*/
function spamBody(host = '1rank.app', links = 8): string {
const para =
'Search visibility is the difference between a business that gets found and one ' +
'that does not. Modern buyers start with a query, and the page they click is the ' +
'page that answers it fastest. ';
const linked = Array.from(
{ length: links },
(_, i) => `Read more at [our guide ${i}](https://${host}/guide-${i}). `,
).join('');
let body = linked;
while (body.length < 3300) body += para;
return body;
}

/** A long, link-heavy, genuinely useful bug report — the class that must survive. */
function bugReportBody(): string {
return [
'### Reproduction',
'',
'Repro repo: https://github.com/someone/repro',
'Related: https://github.com/CopilotKit/CopilotKit/issues/1',
'Docs I followed: https://docs.copilotkit.ai/quickstart',
'Upstream bug: https://github.com/langchain-ai/langgraph/issues/9',
'Stackblitz: https://stackblitz.com/edit/a',
'Sandbox: https://codesandbox.io/s/b',
'',
'```ts',
'const { visibleMessages } = useCopilotChat();',
'console.log(visibleMessages);',
'```',
'',
'x'.repeat(3000),
].join('\n');
}

describe('maxLinksToOneThirdPartyHost', () => {
it('counts repeats of the same third-party host', () => {
expect(maxLinksToOneThirdPartyHost('a https://1rank.app/x b https://1rank.app/y')).toBe(2);
});

it('does not count our own hosts or GitHub, at any subdomain', () => {
const body = [
'https://github.com/a',
'https://github.com/b',
'https://raw.githubusercontent.com/c',
'https://docs.copilotkit.ai/d',
'https://copilotkit.ai/e',
'http://localhost:3000/f',
].join(' ');
expect(maxLinksToOneThirdPartyHost(body)).toBe(0);
});

it('normalises www. and a port so one host is not counted as three', () => {
expect(
maxLinksToOneThirdPartyHost(
'https://www.1rank.app/a https://1rank.app/b https://1rank.app:443/c',
),
).toBe(3);
});

it('returns the MAX for one host, not the total across hosts', () => {
// Six links, but spread three ways — nothing is being advertised.
const body =
'https://a.io/1 https://a.io/2 https://b.io/1 https://b.io/2 https://c.io/1 https://c.io/2';
expect(maxLinksToOneThirdPartyHost(body)).toBe(2);
});
});

describe('hasFencedCode', () => {
it('is true for an opened-and-closed block', () => {
expect(hasFencedCode('text\n```ts\ncode\n```\n')).toBe(true);
});

it('is false for a single stray fence', () => {
expect(hasFencedCode('text ``` more text')).toBe(false);
});
});

describe('isLikelySpamIssue', () => {
it('flags the campaign shape: untrusted, long, no code, one host repeated', () => {
expect(isLikelySpamIssue({ body: spamBody(), authorAssociation: 'NONE' })).toBe(true);
});

// Every AND term gets its own negative case: a gate that silently eats real
// reports is worse than the spam it was built for, because nothing on the
// issue records that a decision was made.
it('never flags a trusted author, whatever they write', () => {
for (const assoc of ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR']) {
expect(isLikelySpamIssue({ body: spamBody(), authorAssociation: assoc })).toBe(false);
}
});

it('matches author_association case-insensitively', () => {
expect(isLikelySpamIssue({ body: spamBody(), authorAssociation: 'member' })).toBe(false);
});

it('does not flag a long, link-heavy bug report that carries a repro', () => {
expect(isLikelySpamIssue({ body: bugReportBody(), authorAssociation: 'NONE' })).toBe(false);
});

it('does not flag a short body even if it is all links', () => {
expect(
isLikelySpamIssue({ body: spamBody().slice(0, 1200), authorAssociation: 'NONE' }),
).toBe(false);
});

it('does not flag a long body whose links are spread across hosts', () => {
const body =
Array.from({ length: 12 }, (_, i) => `https://host${i}.example/x `).join('') +
'y'.repeat(3000);
expect(isLikelySpamIssue({ body, authorAssociation: 'NONE' })).toBe(false);
});

it('does not flag a long body that only links GitHub and our docs', () => {
const body =
Array.from(
{ length: 12 },
(_, i) => `https://github.com/CopilotKit/x/issues/${i} `,
).join('') + 'y'.repeat(3000);
expect(isLikelySpamIssue({ body, authorAssociation: 'NONE' })).toBe(false);
});

it('handles an empty or missing body without throwing', () => {
expect(isLikelySpamIssue({ body: '', authorAssociation: 'NONE' })).toBe(false);
expect(isLikelySpamIssue({ body: null, authorAssociation: 'NONE' })).toBe(false);
expect(isLikelySpamIssue({ body: undefined, authorAssociation: undefined })).toBe(false);
});

it('treats a missing author_association as untrusted, not as trusted', () => {
// Fail closed on the spam side: an absent field must not become a bypass.
expect(isLikelySpamIssue({ body: spamBody(), authorAssociation: null })).toBe(true);
});
});
132 changes: 132 additions & 0 deletions apps/github-app/src/lib/spam-filter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* Link-spam gate for inbound GitHub issues.
*
* Why this exists. On 2026-09-10/11 two throwaway accounts
* (`sarahnicholas1327-lgtm`, `kaylaford203-beep`) filed 23 SEO backlink issues on
* CopilotKit/CopilotKit — ~3.3 KB of marketing prose each, every one carrying a
* dozen-odd links to `1rank.app` / `zagfro.com`. Outpost relayed each body
* VERBATIM into `search-docs` + `search-code` on mcp.copilotkit.ai within ~4
* seconds of creation, then answered the issue publicly. That is amplification:
* the spammer wrote once and got a machine-generated reply and 46 rows of
* retrieval traffic for free, and the blobs went on to pollute Pathfinder's Top
* Queries panel, the weekly Notion report, and the monthly gap-analysis LLM
* prompt.
*
* The gate is deliberately narrow. Its job is to drop a body that is a link
* advertisement, NOT to score quality — a bad bug report still deserves an
* answer, and a false positive here is silent (no ticket, no reply, no trace on
* the issue). All four conditions must hold.
*
* Measured on the FULL issue history of CopilotKit/CopilotKit (1,264 non-spam
* issues by 1,264-issue authorship, plus all 23 known spam issues):
*
* | rule | spam | legit |
* |-------------------------------------------------------------|-------|----------|
* | body >= 3000 chars AND >= 6 urls | 23/23 | 15/1264 |
* | + untrusted author | 23/23 | 14/1264 |
* | THIS RULE (untrusted, long, no code fence, one host >= 5x) | 23/23 | 0/1264 |
*
* A length-and-url rule alone is NOT sufficient: it eats real bug reports
* (#2667 is 40 KB with 16 urls, #3510 is 13 KB with 26). The two terms that buy
* the separation are the ones that describe an advertisement rather than a
* report: it contains no fenced code block, and its links point over and over at
* the SAME third-party host. A stack trace or a repro has code in it; a backlink
* campaign does not, because the links are the payload.
*
* Honest scope: this is fitted on one campaign and one repo's history. It is a
* discriminator, not a general spam classifier, and the next campaign may look
* different. The truncation and `X-Pathfinder-Source` changes in
* `packages/outpost/ai/src/pathfinder.ts` are the content-independent half of
* the defence and do not depend on this rule firing.
*/

/**
* `author_association` values that mean the author has a real relationship with
* the repo. A spam account is `NONE` by construction — it has never had a PR or
* a commit merged. Requiring untrusted authorship means a maintainer or a prior
* contributor can never be silenced by this gate, whatever they write.
*
* `CONTRIBUTOR` is included as trusted: it means GitHub has already seen a
* merged commit from this account in this repo.
*/
const TRUSTED_ASSOCIATIONS: ReadonlySet<string> = new Set([
'OWNER',
'MEMBER',
'COLLABORATOR',
'CONTRIBUTOR',
]);

/**
* Hosts that do not count towards "links at one third-party host". Our own docs
* and GitHub itself are what a legitimate issue links to repeatedly — a reporter
* citing eight `github.com/...` permalinks is doing exactly the right thing.
* Matched on the host or any subdomain of it.
*/
const FIRST_PARTY_HOSTS: readonly string[] = [
'github.com',
'githubusercontent.com',
'copilotkit.ai',
'ag-ui.com',
'localhost',
];

/** Minimum body length before the gate will consider anything. */
const MIN_BODY_CHARS = 1500;

/** Minimum links to ONE third-party host before the body reads as an ad. */
const MIN_LINKS_TO_ONE_HOST = 5;

function isFirstParty(host: string): boolean {
return FIRST_PARTY_HOSTS.some((h) => host === h || host.endsWith(`.${h}`));
}

/**
* The largest number of links in `body` pointing at a single third-party host.
*
* Counts raw URL occurrences rather than markdown links: the spam bodies mix
* `[anchor](url)` with bare urls and with the same domain written as plain text
* inside a sentence, and only the total is stable across those spellings.
*/
export function maxLinksToOneThirdPartyHost(body: string): number {
const counts = new Map<string, number>();
for (const match of body.matchAll(/https?:\/\/([^/\s)>\]"'`]+)/gi)) {
const host = match[1]
.toLowerCase()
.replace(/^www\./, '')
.replace(/:\d+$/, '');
if (isFirstParty(host)) continue;
counts.set(host, (counts.get(host) ?? 0) + 1);
}
let max = 0;
for (const n of counts.values()) max = Math.max(max, n);
return max;
}

/** Whether `body` contains at least one fenced code block. */
export function hasFencedCode(body: string): boolean {
// Two fence markers, i.e. an opened AND closed block. A single stray "```"
// is not evidence of a repro.
return (body.match(/```/g)?.length ?? 0) >= 2;
}

export interface SpamCandidate {
/** The issue body as filed. */
body: string | null | undefined;
/** The webhook payload's `issue.author_association`. */
authorAssociation: string | null | undefined;
}

/**
* Whether this issue is a link advertisement that must NOT be relayed.
*
* Returns false for anything it is not sure about: an empty body, a trusted
* author, a short body, a body with a repro in it, or a body whose links are
* spread across hosts.
*/
export function isLikelySpamIssue({ body, authorAssociation }: SpamCandidate): boolean {
if (!body) return false;
if (TRUSTED_ASSOCIATIONS.has((authorAssociation ?? '').toUpperCase())) return false;
if (body.length < MIN_BODY_CHARS) return false;
if (hasFencedCode(body)) return false;
return maxLinksToOneThirdPartyHost(body) >= MIN_LINKS_TO_ONE_HOST;
}
Loading
Loading