From 74d11b95a6fa85ccf4a76c7602ee296652fec40c Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Bajpai Date: Wed, 20 May 2026 09:32:01 +0530 Subject: [PATCH 1/2] feat: flag suspicious xp gain patterns --- src/app/(app)/maintainer/page.tsx | 60 +++++ src/app/actions/maintainer.ts | 114 +++++++++ src/app/api/inngest/route.ts | 8 +- src/inngest/functions/maintenance.ts | 187 +++++++++++++++ src/lib/db/schema.ts | 25 ++ src/lib/xp/suspicious-patterns.test.ts | 109 +++++++++ src/lib/xp/suspicious-patterns.ts | 217 ++++++++++++++++++ supabase/migrations/0012_flagged_accounts.sql | 29 +++ 8 files changed, 748 insertions(+), 1 deletion(-) create mode 100644 src/lib/xp/suspicious-patterns.test.ts create mode 100644 src/lib/xp/suspicious-patterns.ts create mode 100644 supabase/migrations/0012_flagged_accounts.sql diff --git a/src/app/(app)/maintainer/page.tsx b/src/app/(app)/maintainer/page.tsx index 4e5987c..0610ec8 100644 --- a/src/app/(app)/maintainer/page.tsx +++ b/src/app/(app)/maintainer/page.tsx @@ -7,7 +7,9 @@ import { getMaintainerPrQueue, getRepoHealthOverview, getStaleIssues, + getFlaggedAccounts, getTopContributors, + type FlaggedAccountRow, type MaintainerInstall, type MaintainerPrRow, type RepoHealthRow, @@ -82,6 +84,10 @@ export default async function MaintainerPage({ const contributorsRes = await getTopContributors(); const topContributors: ContributorRow[] = isOk(contributorsRes) ? contributorsRes.data : []; + const flaggedAccountsRes = await getFlaggedAccounts(); + const flaggedAccounts: FlaggedAccountRow[] = isOk(flaggedAccountsRes) + ? flaggedAccountsRes.data + : []; return (
@@ -159,6 +165,50 @@ export default async function MaintainerPage({

{activeInstall.accountLogin} ({activeInstall.permissionLevel.replace('_', ' ')})

+ {flaggedAccounts.length > 0 && ( +
+
+
+

Suspicious XP Signals

+

+ Daily detector output for maintainer review. +

+
+ + {flaggedAccounts.length} open + +
+ +
+ {flaggedAccounts.map((flag) => ( +
+
+
+

@{flag.githubHandle}

+

+ Level {flag.level} · {flag.xp} XP +

+
+ + {flag.severity} + +
+

{formatFlagReason(flag.reason)}

+

{flag.summary}

+

+ Detected {relativeTime(flag.detectedAt)} +

+
+ ))} +
+
+ )}

Repository Health

@@ -373,6 +423,16 @@ function NoInstalls() { ); } +function formatFlagReason(reason: string) { + const labels: Record = { + daily_xp_event_spike: 'Daily XP event spike', + rapid_merge_spike: 'Rapid merge spike', + reviewer_approval_concentration: 'Reviewer approval concentration', + }; + + return labels[reason] ?? 'Suspicious activity'; +} + function NotConfigured() { return (
diff --git a/src/app/actions/maintainer.ts b/src/app/actions/maintainer.ts index de0204c..29ef367 100644 --- a/src/app/actions/maintainer.ts +++ b/src/app/actions/maintainer.ts @@ -65,6 +65,18 @@ export type ContributorRow = { level: number; }; +export type FlaggedAccountRow = { + id: number; + githubHandle: string; + xp: number; + level: number; + reason: string; + severity: 'medium' | 'high'; + detectedAt: string; + summary: string; + count: number; +}; + const ISSUE_BUCKETS = new Set([ 'needs-triage', 'in-progress', @@ -821,3 +833,105 @@ export async function getTopContributors(): Promise> { })), ); } + +export async function getFlaggedAccounts(): Promise> { + const sb = getServerSupabase(); + + if (!sb) { + return err('not_configured', 'auth not configured'); + } + + const service = getServiceSupabase(); + + if (!service) { + return err('not_configured', 'service role missing'); + } + + const { + data: { user }, + } = await sb.auth.getUser(); + + if (!user) { + return err('not_authenticated', 'sign in first'); + } + + await rateLimit({ + namespace: 'maintainer', + key: user.id, + limit: 30, + windowSec: 60, + }); + + if (!(await isUserMaintainer(user.id))) { + return err('not_authorised', 'not a maintainer'); + } + + const { data: flags, error } = await service + .from('flagged_accounts') + .select('id, user_id, reason, severity, evidence, detected_at') + .eq('status', 'open') + .order('detected_at', { ascending: false }) + .limit(10); + + if (error) { + return err('query_failed', error.message); + } + + const userIds = Array.from(new Set((flags ?? []).map((flag) => flag.user_id).filter(Boolean))); + const { data: profiles, error: profilesError } = + userIds.length > 0 + ? await service + .from('profiles') + .select('id, github_handle, xp, level') + .in('id', userIds) + : { data: [], error: null }; + + if (profilesError) { + return err('query_failed', profilesError.message); + } + + const profilesById = new Map( + (profiles ?? []).map((profile) => [ + profile.id, + { + githubHandle: profile.github_handle ?? 'unknown', + xp: profile.xp ?? 0, + level: profile.level ?? 0, + }, + ]), + ); + + return ok( + (flags ?? []).map((flag) => { + const profile = profilesById.get(flag.user_id ?? ''); + const evidence = readFlagEvidence(flag.evidence); + + return { + id: flag.id, + githubHandle: profile?.githubHandle ?? 'unknown', + xp: profile?.xp ?? 0, + level: profile?.level ?? 0, + reason: flag.reason, + severity: flag.severity === 'high' ? 'high' : 'medium', + detectedAt: flag.detected_at, + summary: evidence.summary, + count: evidence.count, + }; + }), + ); +} + +function readFlagEvidence(evidence: unknown) { + if (!evidence || typeof evidence !== 'object') { + return { summary: 'Suspicious activity pattern detected.', count: 0 }; + } + + const record = evidence as Record; + return { + summary: + typeof record.summary === 'string' + ? record.summary + : 'Suspicious activity pattern detected.', + count: typeof record.count === 'number' ? record.count : 0, + }; +} diff --git a/src/app/api/inngest/route.ts b/src/app/api/inngest/route.ts index 5caeb7b..90b2c31 100644 --- a/src/app/api/inngest/route.ts +++ b/src/app/api/inngest/route.ts @@ -16,7 +16,12 @@ import { processMemberEvent, } from '@/inngest/functions/process-membership-events'; import { prBackfill } from '@/inngest/functions/pr-backfill'; -import { streakDetect, recsExpire, activityLogCleanup } from '@/inngest/functions/maintenance'; +import { + streakDetect, + recsExpire, + activityLogCleanup, + flagSuspiciousXpAccounts, +} from '@/inngest/functions/maintenance'; import { githubStatsSync } from '@/inngest/functions/github-stats-sync'; import { mentorPostComment } from '@/inngest/functions/mentor-post-comment'; import { processIssueEvent } from '@/inngest/functions/process-issue-event'; @@ -40,6 +45,7 @@ export const { GET, POST, PUT } = serve({ streakDetect, recsExpire, activityLogCleanup, + flagSuspiciousXpAccounts, githubStatsSync, mentorPostComment, processIssueEvent, diff --git a/src/inngest/functions/maintenance.ts b/src/inngest/functions/maintenance.ts index 079712e..7dabaf0 100644 --- a/src/inngest/functions/maintenance.ts +++ b/src/inngest/functions/maintenance.ts @@ -2,6 +2,12 @@ import { inngest } from '../client'; import { getServiceSupabase } from '@/lib/supabase/service'; import { insertXpEvent } from '@/lib/xp/events'; import { XP_REWARDS, XP_SOURCE, refIds } from '@/lib/xp/sources'; +import { + detectSuspiciousPatterns, + type SuspiciousMergedPr, + type SuspiciousReview, + type SuspiciousXpEvent, +} from '@/lib/xp/suspicious-patterns'; /** * Daily streak detection — gives +10 XP/day to users who had any qualifying @@ -83,3 +89,184 @@ export const activityLogCleanup = inngest.createFunction( }); }, ); + +/** + * Daily conservative fraud signal detection. This only flags accounts for + * maintainer review; it never changes XP, labels, or profile state. + */ +export const flagSuspiciousXpAccounts = inngest.createFunction( + { id: 'flag-suspicious-xp-accounts' }, + { cron: '30 0 * * *' }, // 00:30 UTC daily, after streaks and cleanup + async ({ step }) => { + return await step.run('detect-and-store-flags', async () => { + const sb = getServiceSupabase(); + if (!sb) throw new Error('service role missing'); + const service = sb; + + const dayEndDate = startOfUtcDay(new Date()); + const dayStartDate = new Date(dayEndDate.getTime() - 24 * 60 * 60 * 1000); + const weekStartDate = new Date(dayEndDate.getTime() - 7 * 24 * 60 * 60 * 1000); + + const dayStart = dayStartDate.toISOString(); + const dayEnd = dayEndDate.toISOString(); + const weekStart = weekStartDate.toISOString(); + const weekEnd = dayEnd; + + const { data: xpRows, error: xpError } = await service + .from('xp_events') + .select('user_id, source, ref_id, repo, xp_delta, created_at') + .gte('created_at', dayStart) + .lt('created_at', dayEnd); + if (xpError) throw xpError; + + const { data: mergedRows, error: mergedError } = await service + .from('pull_requests') + .select('id, repo_full_name, number, title, author_login, author_user_id, merged_at') + .eq('state', 'merged') + .gte('merged_at', dayStart) + .lt('merged_at', dayEnd); + if (mergedError) throw mergedError; + + const { data: reviewRows, error: reviewError } = await service + .from('pull_request_reviews') + .select('id, pr_id, reviewer_login, reviewer_user_id, state, submitted_at') + .eq('state', 'approved') + .gte('submitted_at', weekStart) + .lt('submitted_at', weekEnd); + if (reviewError) throw reviewError; + + const reviewPrIds = Array.from( + new Set((reviewRows ?? []).map((row) => Number(row.pr_id)).filter(Number.isFinite)), + ); + const reviewPrRows = await fetchPullRequestsById(reviewPrIds); + const mergedPullRequests = (mergedRows ?? []).map(mapPullRequestRow); + const pullRequestsById = new Map(); + for (const pr of [...mergedPullRequests, ...reviewPrRows]) { + pullRequestsById.set(pr.id, pr); + } + + const candidates = detectSuspiciousPatterns({ + xpEvents: (xpRows ?? []).map(mapXpEventRow), + mergedPullRequests, + reviews: (reviewRows ?? []).map(mapReviewRow), + pullRequestsById, + window: { dayStart, dayEnd, weekStart, weekEnd }, + }); + + if (candidates.length === 0) { + return { scanned: true, inserted: 0, candidates: 0 }; + } + + const { data: existingRows, error: existingError } = await service + .from('flagged_accounts') + .select('user_id, reason') + .eq('status', 'open') + .in( + 'user_id', + Array.from(new Set(candidates.map((candidate) => candidate.userId))), + ); + if (existingError) throw existingError; + + const existing = new Set( + (existingRows ?? []).map((row) => `${row.user_id}:${row.reason}`), + ); + const rowsToInsert = candidates + .filter((candidate) => !existing.has(`${candidate.userId}:${candidate.reason}`)) + .map((candidate) => ({ + user_id: candidate.userId, + reason: candidate.reason, + severity: candidate.severity, + status: 'open', + evidence: candidate.evidence, + })); + + if (rowsToInsert.length === 0) { + return { scanned: true, inserted: 0, candidates: candidates.length }; + } + + const { data: insertedRows, error: insertError } = await service + .from('flagged_accounts') + .insert(rowsToInsert) + .select('id'); + if (insertError) throw insertError; + + return { + scanned: true, + inserted: insertedRows?.length ?? 0, + candidates: candidates.length, + }; + + async function fetchPullRequestsById(ids: number[]) { + if (ids.length === 0) return []; + + const { data, error } = await service + .from('pull_requests') + .select('id, repo_full_name, number, title, author_login, author_user_id, merged_at') + .in('id', ids); + if (error) throw error; + + return (data ?? []).map(mapPullRequestRow); + } + }); + }, +); + +function startOfUtcDay(date: Date) { + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); +} + +function mapXpEventRow(row: { + user_id: string | null; + source: string | null; + ref_id: string | null; + repo: string | null; + xp_delta: number | null; + created_at: string; +}): SuspiciousXpEvent { + return { + userId: row.user_id, + source: row.source, + refId: row.ref_id, + repo: row.repo, + xpDelta: row.xp_delta, + createdAt: row.created_at, + }; +} + +function mapPullRequestRow(row: { + id: number; + repo_full_name: string; + number: number; + title: string; + author_login: string; + author_user_id: string | null; + merged_at: string | null; +}): SuspiciousMergedPr { + return { + id: row.id, + repoFullName: row.repo_full_name, + number: row.number, + title: row.title, + authorLogin: row.author_login, + authorUserId: row.author_user_id, + mergedAt: row.merged_at, + }; +} + +function mapReviewRow(row: { + id: number; + pr_id: number; + reviewer_login: string; + reviewer_user_id: string | null; + state: string; + submitted_at: string; +}): SuspiciousReview { + return { + id: row.id, + prId: row.pr_id, + reviewerLogin: row.reviewer_login, + reviewerUserId: row.reviewer_user_id, + state: row.state, + submittedAt: row.submitted_at, + }; +} diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index b84646b..d138cf4 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -325,6 +325,31 @@ export const activityLog = pgTable( }), ); +export const flaggedAccounts = pgTable( + 'flagged_accounts', + { + id: bigserial('id', { mode: 'number' }).primaryKey(), + userId: uuid('user_id').references(() => profiles.id, { onDelete: 'cascade' }), + reason: text('reason', { + enum: ['daily_xp_event_spike', 'rapid_merge_spike', 'reviewer_approval_concentration'], + }).notNull(), + severity: text('severity', { enum: ['medium', 'high'] }).notNull().default('medium'), + status: text('status', { enum: ['open', 'reviewed', 'dismissed'] }).notNull().default('open'), + evidence: jsonb('evidence').notNull().default({}), + detectedAt: timestamp('detected_at', { withTimezone: true }).notNull().defaultNow(), + resolvedAt: timestamp('resolved_at', { withTimezone: true }), + }, + (t) => ({ + uniqUserReasonStatus: uniqueIndex('flagged_accounts_user_reason_status_unique').on( + t.userId, + t.reason, + t.status, + ), + statusDetectedIdx: index('flagged_accounts_status_detected_idx').on(t.status, t.detectedAt), + userIdx: index('flagged_accounts_user_idx').on(t.userId), + }), +); + // ======================================================================== // Maintainer-side tables (migration 0005) // ======================================================================== diff --git a/src/lib/xp/suspicious-patterns.test.ts b/src/lib/xp/suspicious-patterns.test.ts new file mode 100644 index 0000000..61c967e --- /dev/null +++ b/src/lib/xp/suspicious-patterns.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; +import { + detectDailyXpEventSpikes, + detectRapidMergeSpikes, + detectReviewerApprovalConcentration, + type SuspiciousMergedPr, + type SuspiciousReview, + type SuspiciousXpEvent, +} from './suspicious-patterns'; + +const userId = '00000000-0000-0000-0000-000000000001'; +const reviewerId = '00000000-0000-0000-0000-000000000002'; + +describe('detectDailyXpEventSpikes', () => { + it('flags users with more than five XP events in a UTC day', () => { + const events: SuspiciousXpEvent[] = Array.from({ length: 6 }, (_, index) => ({ + userId, + source: 'merge', + refId: `pr:${index}`, + repo: 'org/repo', + xpDelta: 10, + createdAt: `2026-05-19T0${index}:00:00Z`, + })); + + const flags = detectDailyXpEventSpikes(events, { + dayStart: '2026-05-19T00:00:00.000Z', + dayEnd: '2026-05-20T00:00:00.000Z', + }); + + expect(flags).toHaveLength(1); + expect(flags[0]?.reason).toBe('daily_xp_event_spike'); + expect(flags[0]?.evidence.count).toBe(6); + }); + + it('does not flag exactly five events', () => { + const events: SuspiciousXpEvent[] = Array.from({ length: 5 }, (_, index) => ({ + userId, + source: 'merge', + refId: `pr:${index}`, + repo: 'org/repo', + xpDelta: 10, + createdAt: `2026-05-19T0${index}:00:00Z`, + })); + + expect( + detectDailyXpEventSpikes(events, { + dayStart: '2026-05-19T00:00:00.000Z', + dayEnd: '2026-05-20T00:00:00.000Z', + }), + ).toHaveLength(0); + }); +}); + +describe('detectRapidMergeSpikes', () => { + it('flags more than three merged PRs inside one hour', () => { + const prs: SuspiciousMergedPr[] = Array.from({ length: 4 }, (_, index) => ({ + id: index + 1, + repoFullName: 'org/repo', + number: index + 10, + title: `PR ${index}`, + authorLogin: 'contributor', + authorUserId: userId, + mergedAt: `2026-05-19T10:${String(index * 10).padStart(2, '0')}:00Z`, + })); + + const flags = detectRapidMergeSpikes(prs, { + dayStart: '2026-05-19T00:00:00.000Z', + dayEnd: '2026-05-20T00:00:00.000Z', + }); + + expect(flags).toHaveLength(1); + expect(flags[0]?.reason).toBe('rapid_merge_spike'); + }); +}); + +describe('detectReviewerApprovalConcentration', () => { + it('flags more than four approvals from one reviewer to one contributor in a week', () => { + const prs = new Map( + Array.from({ length: 5 }, (_, index) => [ + index + 1, + { + id: index + 1, + repoFullName: 'org/repo', + number: index + 20, + title: `PR ${index}`, + authorLogin: 'contributor', + authorUserId: userId, + mergedAt: null, + }, + ]), + ); + const reviews: SuspiciousReview[] = Array.from({ length: 5 }, (_, index) => ({ + id: index + 1, + prId: index + 1, + reviewerLogin: 'mentor', + reviewerUserId: reviewerId, + state: 'approved', + submittedAt: `2026-05-19T1${index}:00:00Z`, + })); + + const flags = detectReviewerApprovalConcentration(reviews, prs, { + weekStart: '2026-05-13T00:00:00.000Z', + weekEnd: '2026-05-20T00:00:00.000Z', + }); + + expect(flags).toHaveLength(1); + expect(flags[0]?.reason).toBe('reviewer_approval_concentration'); + }); +}); diff --git a/src/lib/xp/suspicious-patterns.ts b/src/lib/xp/suspicious-patterns.ts new file mode 100644 index 0000000..46614b6 --- /dev/null +++ b/src/lib/xp/suspicious-patterns.ts @@ -0,0 +1,217 @@ +export const SUSPICIOUS_XP_THRESHOLDS = { + dailyXpEvents: 5, + hourlyMerges: 3, + weeklyReviewerApprovals: 4, +} as const; + +export type SuspiciousXpEvent = { + userId: string | null; + source: string | null; + refId: string | null; + repo: string | null; + xpDelta: number | null; + createdAt: string; +}; + +export type SuspiciousMergedPr = { + id: number; + repoFullName: string; + number: number; + title: string; + authorLogin: string; + authorUserId: string | null; + mergedAt: string | null; +}; + +export type SuspiciousReview = { + id: number; + prId: number; + reviewerLogin: string; + reviewerUserId: string | null; + state: string; + submittedAt: string; +}; + +export type SuspiciousFlagCandidate = { + userId: string; + reason: 'daily_xp_event_spike' | 'rapid_merge_spike' | 'reviewer_approval_concentration'; + severity: 'medium' | 'high'; + evidence: { + summary: string; + windowStart: string; + windowEnd: string; + count: number; + items: Array>; + }; +}; + +type DetectionWindow = { + dayStart: string; + dayEnd: string; + weekStart: string; + weekEnd: string; +}; + +export function detectSuspiciousPatterns(args: { + xpEvents: SuspiciousXpEvent[]; + mergedPullRequests: SuspiciousMergedPr[]; + reviews: SuspiciousReview[]; + pullRequestsById: Map; + window: DetectionWindow; +}): SuspiciousFlagCandidate[] { + return [ + ...detectDailyXpEventSpikes(args.xpEvents, args.window), + ...detectRapidMergeSpikes(args.mergedPullRequests, args.window), + ...detectReviewerApprovalConcentration(args.reviews, args.pullRequestsById, args.window), + ]; +} + +export function detectDailyXpEventSpikes( + xpEvents: SuspiciousXpEvent[], + window: Pick, +): SuspiciousFlagCandidate[] { + const byUser = new Map(); + + for (const event of xpEvents) { + if (!event.userId) continue; + const bucket = byUser.get(event.userId) ?? []; + bucket.push(event); + byUser.set(event.userId, bucket); + } + + const candidates: SuspiciousFlagCandidate[] = []; + for (const [userId, events] of byUser) { + if (events.length <= SUSPICIOUS_XP_THRESHOLDS.dailyXpEvents) continue; + + const totalXp = events.reduce((sum, event) => sum + (event.xpDelta ?? 0), 0); + candidates.push({ + userId, + reason: 'daily_xp_event_spike', + severity: events.length >= SUSPICIOUS_XP_THRESHOLDS.dailyXpEvents * 2 ? 'high' : 'medium', + evidence: { + summary: `${events.length} XP events in one UTC day (${totalXp} XP total).`, + windowStart: window.dayStart, + windowEnd: window.dayEnd, + count: events.length, + items: events.slice(0, 20).map((event) => ({ + source: event.source, + refId: event.refId, + repo: event.repo, + xpDelta: event.xpDelta, + createdAt: event.createdAt, + })), + }, + }); + } + + return candidates; +} + +export function detectRapidMergeSpikes( + mergedPullRequests: SuspiciousMergedPr[], + window: Pick, +): SuspiciousFlagCandidate[] { + const byUser = new Map(); + + for (const pr of mergedPullRequests) { + if (!pr.authorUserId || !pr.mergedAt) continue; + const bucket = byUser.get(pr.authorUserId) ?? []; + bucket.push(pr); + byUser.set(pr.authorUserId, bucket); + } + + const candidates: SuspiciousFlagCandidate[] = []; + for (const [userId, prs] of byUser) { + const sorted = prs + .slice() + .sort((a, b) => Date.parse(a.mergedAt ?? '') - Date.parse(b.mergedAt ?? '')); + + for (let start = 0; start < sorted.length; start += 1) { + const startPr = sorted[start]; + if (!startPr?.mergedAt) continue; + + const startMs = Date.parse(startPr.mergedAt); + const oneHourLater = startMs + 60 * 60 * 1000; + const burst = sorted.filter((pr) => { + if (!pr.mergedAt) return false; + const mergedMs = Date.parse(pr.mergedAt); + return mergedMs >= startMs && mergedMs <= oneHourLater; + }); + + if (burst.length <= SUSPICIOUS_XP_THRESHOLDS.hourlyMerges) continue; + + candidates.push({ + userId, + reason: 'rapid_merge_spike', + severity: burst.length >= SUSPICIOUS_XP_THRESHOLDS.hourlyMerges + 3 ? 'high' : 'medium', + evidence: { + summary: `${burst.length} merged PRs landed inside one hour.`, + windowStart: startPr.mergedAt, + windowEnd: new Date(oneHourLater).toISOString(), + count: burst.length, + items: burst.slice(0, 20).map((pr) => ({ + repoFullName: pr.repoFullName, + number: pr.number, + title: pr.title, + authorLogin: pr.authorLogin, + mergedAt: pr.mergedAt, + })), + }, + }); + break; + } + } + + return candidates; +} + +export function detectReviewerApprovalConcentration( + reviews: SuspiciousReview[], + pullRequestsById: Map, + window: Pick, +): SuspiciousFlagCandidate[] { + const byPair = new Map(); + + for (const review of reviews) { + if (review.state !== 'approved' || !review.reviewerUserId) continue; + const pr = pullRequestsById.get(review.prId); + if (!pr?.authorUserId) continue; + + const key = `${pr.authorUserId}:${review.reviewerUserId}`; + const bucket = byPair.get(key) ?? { contributorId: pr.authorUserId, reviews: [] }; + bucket.reviews.push(review); + byPair.set(key, bucket); + } + + const candidates: SuspiciousFlagCandidate[] = []; + for (const { contributorId, reviews: pairReviews } of byPair.values()) { + if (pairReviews.length <= SUSPICIOUS_XP_THRESHOLDS.weeklyReviewerApprovals) continue; + + candidates.push({ + userId: contributorId, + reason: 'reviewer_approval_concentration', + severity: + pairReviews.length >= SUSPICIOUS_XP_THRESHOLDS.weeklyReviewerApprovals + 3 + ? 'high' + : 'medium', + evidence: { + summary: `${pairReviews.length} approvals from the same reviewer in one week.`, + windowStart: window.weekStart, + windowEnd: window.weekEnd, + count: pairReviews.length, + items: pairReviews.slice(0, 20).map((review) => { + const pr = pullRequestsById.get(review.prId); + return { + reviewerLogin: review.reviewerLogin, + repoFullName: pr?.repoFullName ?? null, + number: pr?.number ?? null, + title: pr?.title ?? null, + submittedAt: review.submittedAt, + }; + }), + }, + }); + } + + return candidates; +} diff --git a/supabase/migrations/0012_flagged_accounts.sql b/supabase/migrations/0012_flagged_accounts.sql new file mode 100644 index 0000000..9ebdd4e --- /dev/null +++ b/supabase/migrations/0012_flagged_accounts.sql @@ -0,0 +1,29 @@ +-- Suspicious XP / review pattern flags for maintainer review. +-- Detection rows are written by scheduled service-role jobs and read by +-- authenticated maintainers through server actions. + +create table if not exists flagged_accounts ( + id bigserial primary key, + user_id uuid references profiles(id) on delete cascade, + reason text not null check ( + reason in ( + 'daily_xp_event_spike', + 'rapid_merge_spike', + 'reviewer_approval_concentration' + ) + ), + severity text not null default 'medium' check (severity in ('medium', 'high')), + status text not null default 'open' check (status in ('open', 'reviewed', 'dismissed')), + evidence jsonb not null default '{}'::jsonb, + detected_at timestamptz not null default now(), + resolved_at timestamptz, + unique (user_id, reason, status) +); + +create index if not exists flagged_accounts_status_detected_idx + on flagged_accounts (status, detected_at desc); +create index if not exists flagged_accounts_user_idx + on flagged_accounts (user_id); + +alter table flagged_accounts enable row level security; +-- service-role only: no public policies by design. From 917c545d7afe1454234b70ca10e26d0489e22f84 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Bajpai Date: Thu, 21 May 2026 09:29:30 +0530 Subject: [PATCH 2/2] fix: paginate suspicious xp audit queries --- src/app/actions/maintainer.ts | 9 +- src/inngest/functions/maintenance.ts | 205 ++++++++++++++++++--------- src/lib/db/schema.ts | 8 +- 3 files changed, 149 insertions(+), 73 deletions(-) diff --git a/src/app/actions/maintainer.ts b/src/app/actions/maintainer.ts index 29ef367..ebd9035 100644 --- a/src/app/actions/maintainer.ts +++ b/src/app/actions/maintainer.ts @@ -880,10 +880,7 @@ export async function getFlaggedAccounts(): Promise> const userIds = Array.from(new Set((flags ?? []).map((flag) => flag.user_id).filter(Boolean))); const { data: profiles, error: profilesError } = userIds.length > 0 - ? await service - .from('profiles') - .select('id, github_handle, xp, level') - .in('id', userIds) + ? await service.from('profiles').select('id, github_handle, xp, level').in('id', userIds) : { data: [], error: null }; if (profilesError) { @@ -929,9 +926,7 @@ function readFlagEvidence(evidence: unknown) { const record = evidence as Record; return { summary: - typeof record.summary === 'string' - ? record.summary - : 'Suspicious activity pattern detected.', + typeof record.summary === 'string' ? record.summary : 'Suspicious activity pattern detected.', count: typeof record.count === 'number' ? record.count : 0, }; } diff --git a/src/inngest/functions/maintenance.ts b/src/inngest/functions/maintenance.ts index 7dabaf0..136c1c4 100644 --- a/src/inngest/functions/maintenance.ts +++ b/src/inngest/functions/maintenance.ts @@ -9,6 +9,47 @@ import { type SuspiciousXpEvent, } from '@/lib/xp/suspicious-patterns'; +const AUDIT_PAGE_SIZE = 1000; +const AUDIT_FILTER_CHUNK_SIZE = 500; + +type SupabasePage = { + data: T[] | null; + error: { message?: string } | null; +}; + +async function fetchAllAuditRows( + buildQuery: (from: number, to: number) => PromiseLike>, +): Promise { + const rows: T[] = []; + + for (let from = 0; ; from += AUDIT_PAGE_SIZE) { + const to = from + AUDIT_PAGE_SIZE - 1; + const { data, error } = await buildQuery(from, to); + if (error) throw new Error(error.message ?? 'Supabase audit query failed'); + + const page = data ?? []; + rows.push(...page); + + if (page.length < AUDIT_PAGE_SIZE) { + return rows; + } + } +} + +async function fetchChunkedAuditRows( + filters: TFilter[], + buildQuery: (chunk: TFilter[], from: number, to: number) => PromiseLike>, +): Promise { + const rows: T[] = []; + + for (let start = 0; start < filters.length; start += AUDIT_FILTER_CHUNK_SIZE) { + const chunk = filters.slice(start, start + AUDIT_FILTER_CHUNK_SIZE); + rows.push(...(await fetchAllAuditRows((from, to) => buildQuery(chunk, from, to)))); + } + + return rows; +} + /** * Daily streak detection — gives +10 XP/day to users who had any qualifying * activity yesterday, with a 10-day cap. @@ -112,43 +153,58 @@ export const flagSuspiciousXpAccounts = inngest.createFunction( const weekStart = weekStartDate.toISOString(); const weekEnd = dayEnd; - const { data: xpRows, error: xpError } = await service - .from('xp_events') - .select('user_id, source, ref_id, repo, xp_delta, created_at') - .gte('created_at', dayStart) - .lt('created_at', dayEnd); - if (xpError) throw xpError; - - const { data: mergedRows, error: mergedError } = await service - .from('pull_requests') - .select('id, repo_full_name, number, title, author_login, author_user_id, merged_at') - .eq('state', 'merged') - .gte('merged_at', dayStart) - .lt('merged_at', dayEnd); - if (mergedError) throw mergedError; - - const { data: reviewRows, error: reviewError } = await service - .from('pull_request_reviews') - .select('id, pr_id, reviewer_login, reviewer_user_id, state, submitted_at') - .eq('state', 'approved') - .gte('submitted_at', weekStart) - .lt('submitted_at', weekEnd); - if (reviewError) throw reviewError; + const [xpRows, mergedRows, reviewRows] = await Promise.all([ + fetchAllAuditRows( + (from, to) => + service + .from('xp_events') + .select('id, user_id, source, ref_id, repo, xp_delta, created_at') + .gte('created_at', dayStart) + .lt('created_at', dayEnd) + .order('created_at', { ascending: true }) + .order('id', { ascending: true }) + .range(from, to) as unknown as PromiseLike>, + ), + fetchAllAuditRows( + (from, to) => + service + .from('pull_requests') + .select('id, repo_full_name, number, title, author_login, author_user_id, merged_at') + .eq('state', 'merged') + .gte('merged_at', dayStart) + .lt('merged_at', dayEnd) + .order('merged_at', { ascending: true }) + .order('id', { ascending: true }) + .range(from, to) as unknown as PromiseLike>, + ), + fetchAllAuditRows( + (from, to) => + service + .from('pull_request_reviews') + .select('id, pr_id, reviewer_login, reviewer_user_id, state, submitted_at') + .eq('state', 'approved') + .gte('submitted_at', weekStart) + .lt('submitted_at', weekEnd) + .order('submitted_at', { ascending: true }) + .order('id', { ascending: true }) + .range(from, to) as unknown as PromiseLike>, + ), + ]); const reviewPrIds = Array.from( - new Set((reviewRows ?? []).map((row) => Number(row.pr_id)).filter(Number.isFinite)), + new Set(reviewRows.map((row) => Number(row.pr_id)).filter(Number.isFinite)), ); const reviewPrRows = await fetchPullRequestsById(reviewPrIds); - const mergedPullRequests = (mergedRows ?? []).map(mapPullRequestRow); + const mergedPullRequests = mergedRows.map(mapPullRequestRow); const pullRequestsById = new Map(); for (const pr of [...mergedPullRequests, ...reviewPrRows]) { pullRequestsById.set(pr.id, pr); } const candidates = detectSuspiciousPatterns({ - xpEvents: (xpRows ?? []).map(mapXpEventRow), + xpEvents: xpRows.map(mapXpEventRow), mergedPullRequests, - reviews: (reviewRows ?? []).map(mapReviewRow), + reviews: reviewRows.map(mapReviewRow), pullRequestsById, window: { dayStart, dayEnd, weekStart, weekEnd }, }); @@ -157,19 +213,21 @@ export const flagSuspiciousXpAccounts = inngest.createFunction( return { scanned: true, inserted: 0, candidates: 0 }; } - const { data: existingRows, error: existingError } = await service - .from('flagged_accounts') - .select('user_id, reason') - .eq('status', 'open') - .in( - 'user_id', - Array.from(new Set(candidates.map((candidate) => candidate.userId))), - ); - if (existingError) throw existingError; - - const existing = new Set( - (existingRows ?? []).map((row) => `${row.user_id}:${row.reason}`), + const candidateUserIds = Array.from(new Set(candidates.map((candidate) => candidate.userId))); + const existingRows = await fetchChunkedAuditRows( + candidateUserIds, + (chunk, from, to) => + service + .from('flagged_accounts') + .select('user_id, reason') + .eq('status', 'open') + .in('user_id', chunk) + .order('user_id', { ascending: true }) + .order('reason', { ascending: true }) + .range(from, to) as unknown as PromiseLike>, ); + + const existing = new Set(existingRows.map((row) => `${row.user_id}:${row.reason}`)); const rowsToInsert = candidates .filter((candidate) => !existing.has(`${candidate.userId}:${candidate.reason}`)) .map((candidate) => ({ @@ -199,13 +257,20 @@ export const flagSuspiciousXpAccounts = inngest.createFunction( async function fetchPullRequestsById(ids: number[]) { if (ids.length === 0) return []; - const { data, error } = await service - .from('pull_requests') - .select('id, repo_full_name, number, title, author_login, author_user_id, merged_at') - .in('id', ids); - if (error) throw error; - - return (data ?? []).map(mapPullRequestRow); + return ( + await fetchChunkedAuditRows( + ids, + (chunk, from, to) => + service + .from('pull_requests') + .select( + 'id, repo_full_name, number, title, author_login, author_user_id, merged_at', + ) + .in('id', chunk) + .order('id', { ascending: true }) + .range(from, to) as unknown as PromiseLike>, + ) + ).map(mapPullRequestRow); } }); }, @@ -215,14 +280,41 @@ function startOfUtcDay(date: Date) { return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); } -function mapXpEventRow(row: { +type XpEventAuditRow = { + id: number; user_id: string | null; source: string | null; ref_id: string | null; repo: string | null; xp_delta: number | null; created_at: string; -}): SuspiciousXpEvent { +}; + +type PullRequestAuditRow = { + id: number; + repo_full_name: string; + number: number; + title: string; + author_login: string; + author_user_id: string | null; + merged_at: string | null; +}; + +type ReviewAuditRow = { + id: number; + pr_id: number; + reviewer_login: string; + reviewer_user_id: string | null; + state: string; + submitted_at: string; +}; + +type FlaggedAccountAuditRow = { + user_id: string; + reason: string; +}; + +function mapXpEventRow(row: XpEventAuditRow): SuspiciousXpEvent { return { userId: row.user_id, source: row.source, @@ -233,15 +325,7 @@ function mapXpEventRow(row: { }; } -function mapPullRequestRow(row: { - id: number; - repo_full_name: string; - number: number; - title: string; - author_login: string; - author_user_id: string | null; - merged_at: string | null; -}): SuspiciousMergedPr { +function mapPullRequestRow(row: PullRequestAuditRow): SuspiciousMergedPr { return { id: row.id, repoFullName: row.repo_full_name, @@ -253,14 +337,7 @@ function mapPullRequestRow(row: { }; } -function mapReviewRow(row: { - id: number; - pr_id: number; - reviewer_login: string; - reviewer_user_id: string | null; - state: string; - submitted_at: string; -}): SuspiciousReview { +function mapReviewRow(row: ReviewAuditRow): SuspiciousReview { return { id: row.id, prId: row.pr_id, diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index d138cf4..6005e1e 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -333,8 +333,12 @@ export const flaggedAccounts = pgTable( reason: text('reason', { enum: ['daily_xp_event_spike', 'rapid_merge_spike', 'reviewer_approval_concentration'], }).notNull(), - severity: text('severity', { enum: ['medium', 'high'] }).notNull().default('medium'), - status: text('status', { enum: ['open', 'reviewed', 'dismissed'] }).notNull().default('open'), + severity: text('severity', { enum: ['medium', 'high'] }) + .notNull() + .default('medium'), + status: text('status', { enum: ['open', 'reviewed', 'dismissed'] }) + .notNull() + .default('open'), evidence: jsonb('evidence').notNull().default({}), detectedAt: timestamp('detected_at', { withTimezone: true }).notNull().defaultNow(), resolvedAt: timestamp('resolved_at', { withTimezone: true }),