A tool for parsing traffic on the jetstream and applying a moderation workstream based on regexp based rules

Added functionality to detect accounts created specifically to harrass, spam, or astroturf

Changed files
+649
src
+1
.env.example
··· 7 7 PORT=4000 8 8 METRICS_PORT=4001 9 9 FIREHOSE_URL= 10 + PLC_URL=plc.wtf 10 11 CURSOR_UPDATE_INTERVAL=10000 11 12 LABEL_LIMIT=2900 * 1000 12 13 LABEL_LIMIT_WAIT=300 * 1000
+170
src/account/age.ts
··· 1 + import { agent, isLoggedIn } from "../agent.js"; 2 + import { logger } from "../logger.js"; 3 + import { createAccountLabel } from "../moderation.js"; 4 + import { ACCOUNT_AGE_CHECKS } from "./ageConstants.js"; 5 + import { PLC_URL } from "../config.js"; 6 + 7 + interface ReplyContext { 8 + replyToDid: string; 9 + replyingDid: string; 10 + atURI: string; 11 + time: number; 12 + } 13 + 14 + /** 15 + * Gets the account creation date from a DID 16 + * Uses the plc directory to get DID document creation timestamp 17 + */ 18 + export const getAccountCreationDate = async ( 19 + did: string, 20 + ): Promise<Date | null> => { 21 + try { 22 + await isLoggedIn; 23 + 24 + // For plc DIDs, try to extract creation from the DID document 25 + if (did.startsWith("did:plc:")) { 26 + try { 27 + const response = await fetch(`https://${PLC_URL}/${did}`); 28 + if (response.ok) { 29 + const didDoc = await response.json(); 30 + 31 + // The plc directory returns an array of operations, first one is creation 32 + if (Array.isArray(didDoc) && didDoc.length > 0) { 33 + const createdAt = didDoc[0].createdAt; 34 + if (createdAt) { 35 + return new Date(createdAt); 36 + } 37 + } 38 + } else { 39 + logger.debug( 40 + { process: "ACCOUNT_AGE", did }, 41 + "Failed to fetch DID document, trying profile fallback", 42 + ); 43 + } 44 + } catch (plcError) { 45 + logger.debug( 46 + { process: "ACCOUNT_AGE", did }, 47 + "Error fetching from plc directory, trying profile fallback", 48 + ); 49 + } 50 + } 51 + 52 + // Fallback: try getting profile for any DID type 53 + try { 54 + const profile = await agent.getProfile({ actor: did }); 55 + if (profile.data.indexedAt) { 56 + return new Date(profile.data.indexedAt); 57 + } 58 + } catch (profileError) { 59 + logger.debug( 60 + { process: "ACCOUNT_AGE", did }, 61 + "Failed to get profile", 62 + ); 63 + } 64 + 65 + logger.warn( 66 + { process: "ACCOUNT_AGE", did }, 67 + "Could not determine account creation date", 68 + ); 69 + return null; 70 + } catch (error) { 71 + logger.error( 72 + { process: "ACCOUNT_AGE", did, error }, 73 + "Error fetching account creation date", 74 + ); 75 + return null; 76 + } 77 + }; 78 + 79 + /** 80 + * Calculates the age of an account in days at a specific reference date 81 + */ 82 + export const calculateAccountAge = ( 83 + creationDate: Date, 84 + referenceDate: Date, 85 + ): number => { 86 + const diffMs = referenceDate.getTime() - creationDate.getTime(); 87 + return Math.floor(diffMs / (1000 * 60 * 60 * 24)); 88 + }; 89 + 90 + /** 91 + * Checks if a reply meets age criteria and applies labels accordingly 92 + */ 93 + export const checkAccountAge = async ( 94 + context: ReplyContext, 95 + ): Promise<void> => { 96 + // Skip if no checks configured 97 + if (ACCOUNT_AGE_CHECKS.length === 0) { 98 + return; 99 + } 100 + 101 + // Check each configuration 102 + for (const check of ACCOUNT_AGE_CHECKS) { 103 + // Check if this reply is to a monitored DID 104 + if (!check.monitoredDIDs.includes(context.replyToDid)) { 105 + continue; 106 + } 107 + 108 + logger.debug( 109 + { 110 + process: "ACCOUNT_AGE", 111 + replyingDid: context.replyingDid, 112 + replyToDid: context.replyToDid, 113 + }, 114 + "Checking account age for reply to monitored DID", 115 + ); 116 + 117 + // Get account creation date 118 + const creationDate = await getAccountCreationDate(context.replyingDid); 119 + if (!creationDate) { 120 + logger.warn( 121 + { 122 + process: "ACCOUNT_AGE", 123 + replyingDid: context.replyingDid, 124 + }, 125 + "Could not determine creation date, skipping", 126 + ); 127 + continue; 128 + } 129 + 130 + // Calculate age at anchor date 131 + const anchorDate = new Date(check.anchorDate); 132 + const accountAge = calculateAccountAge(creationDate, anchorDate); 133 + 134 + logger.debug( 135 + { 136 + process: "ACCOUNT_AGE", 137 + replyingDid: context.replyingDid, 138 + creationDate: creationDate.toISOString(), 139 + anchorDate: check.anchorDate, 140 + accountAge, 141 + threshold: check.maxAgeDays, 142 + }, 143 + "Account age calculated", 144 + ); 145 + 146 + // Check if account is too new 147 + if (accountAge < check.maxAgeDays) { 148 + logger.info( 149 + { 150 + process: "ACCOUNT_AGE", 151 + replyingDid: context.replyingDid, 152 + replyToDid: context.replyToDid, 153 + accountAge, 154 + threshold: check.maxAgeDays, 155 + atURI: context.atURI, 156 + }, 157 + "Labeling new account replying to monitored DID", 158 + ); 159 + 160 + await createAccountLabel( 161 + context.replyingDid, 162 + check.label, 163 + `${context.time}: ${check.comment} - Account age: ${accountAge} days (threshold: ${check.maxAgeDays} days) - Reply: ${context.atURI}`, 164 + ); 165 + 166 + // Only apply one label per reply 167 + return; 168 + } 169 + } 170 + };
+25
src/account/ageConstants.ts
··· 1 + import { AccountAgeCheck } from "../types.js"; 2 + 3 + /** 4 + * Account age monitoring configurations 5 + * 6 + * Each configuration monitors replies to specified DIDs and labels accounts 7 + * that are newer than the threshold relative to the anchor date. 8 + * 9 + * Example use case: 10 + * - Monitor replies to high-profile accounts during harassment campaigns 11 + * - Flag sock puppet accounts created to participate in coordinated harassment 12 + */ 13 + export const ACCOUNT_AGE_CHECKS: AccountAgeCheck[] = [ 14 + // Example configuration (disabled by default) 15 + // { 16 + // monitoredDIDs: [ 17 + // "did:plc:example123", // High-profile account 1 18 + // "did:plc:example456", // High-profile account 2 19 + // ], 20 + // anchorDate: "2025-01-15", // Date when harassment campaign started 21 + // maxAgeDays: 7, // Flag accounts less than 7 days old 22 + // label: "new-account-reply", 23 + // comment: "New account replying to monitored user during campaign", 24 + // }, 25 + ];
+399
src/account/tests/age.test.ts
··· 1 + import { describe, it, expect, vi, beforeEach } from "vitest"; 2 + import { 3 + calculateAccountAge, 4 + checkAccountAge, 5 + getAccountCreationDate, 6 + } from "../age.js"; 7 + import { ACCOUNT_AGE_CHECKS } from "../ageConstants.js"; 8 + 9 + // Mock dependencies 10 + vi.mock("../../agent.js", () => ({ 11 + agent: { 12 + getProfile: vi.fn(), 13 + }, 14 + isLoggedIn: Promise.resolve(true), 15 + })); 16 + 17 + vi.mock("../../logger.js", () => ({ 18 + logger: { 19 + info: vi.fn(), 20 + debug: vi.fn(), 21 + warn: vi.fn(), 22 + error: vi.fn(), 23 + }, 24 + })); 25 + 26 + vi.mock("../../moderation.js", () => ({ 27 + createAccountLabel: vi.fn(), 28 + })); 29 + 30 + // Mock fetch for DID document lookups 31 + global.fetch = vi.fn(); 32 + 33 + import { agent } from "../../agent.js"; 34 + import { logger } from "../../logger.js"; 35 + import { createAccountLabel } from "../../moderation.js"; 36 + 37 + describe("Account Age Module", () => { 38 + beforeEach(() => { 39 + vi.clearAllMocks(); 40 + }); 41 + 42 + describe("calculateAccountAge", () => { 43 + it("should calculate age in days correctly", () => { 44 + const creationDate = new Date("2025-01-01"); 45 + const referenceDate = new Date("2025-01-08"); 46 + 47 + const age = calculateAccountAge(creationDate, referenceDate); 48 + 49 + expect(age).toBe(7); 50 + }); 51 + 52 + it("should return 0 for same day", () => { 53 + const date = new Date("2025-01-15"); 54 + 55 + const age = calculateAccountAge(date, date); 56 + 57 + expect(age).toBe(0); 58 + }); 59 + 60 + it("should handle accounts created before reference date", () => { 61 + const creationDate = new Date("2024-01-01"); 62 + const referenceDate = new Date("2025-01-01"); 63 + 64 + const age = calculateAccountAge(creationDate, referenceDate); 65 + 66 + expect(age).toBe(366); // 2024 is leap year 67 + }); 68 + 69 + it("should return negative for accounts created after reference date", () => { 70 + const creationDate = new Date("2025-01-15"); 71 + const referenceDate = new Date("2025-01-01"); 72 + 73 + const age = calculateAccountAge(creationDate, referenceDate); 74 + 75 + expect(age).toBe(-14); 76 + }); 77 + }); 78 + 79 + describe("getAccountCreationDate", () => { 80 + it("should fetch creation date from plc directory for plc DIDs", async () => { 81 + const mockDidDoc = [ 82 + { 83 + createdAt: "2025-01-10T12:00:00.000Z", 84 + }, 85 + ]; 86 + 87 + (global.fetch as any).mockResolvedValueOnce({ 88 + ok: true, 89 + json: async () => mockDidDoc, 90 + }); 91 + 92 + const result = await getAccountCreationDate("did:plc:test123"); 93 + 94 + expect(global.fetch).toHaveBeenCalledWith( 95 + "https://plc.wtf/did:plc:test123", 96 + ); 97 + expect(result).toEqual(new Date("2025-01-10T12:00:00.000Z")); 98 + }); 99 + 100 + it("should fall back to profile.indexedAt if plc lookup fails", async () => { 101 + (global.fetch as any).mockResolvedValueOnce({ 102 + ok: false, 103 + }); 104 + 105 + (agent.getProfile as any).mockResolvedValueOnce({ 106 + data: { 107 + indexedAt: "2025-01-12T10:00:00.000Z", 108 + }, 109 + }); 110 + 111 + const result = await getAccountCreationDate("did:plc:test456"); 112 + 113 + expect(result).toEqual(new Date("2025-01-12T10:00:00.000Z")); 114 + }); 115 + 116 + it("should return null if both plc and profile fail", async () => { 117 + (global.fetch as any).mockResolvedValueOnce({ 118 + ok: false, 119 + }); 120 + 121 + (agent.getProfile as any).mockResolvedValueOnce({ 122 + data: {}, 123 + }); 124 + 125 + const result = await getAccountCreationDate("did:plc:unknown"); 126 + 127 + expect(result).toBeNull(); 128 + expect(logger.warn).toHaveBeenCalled(); 129 + }); 130 + 131 + it("should handle errors gracefully", async () => { 132 + (global.fetch as any).mockRejectedValueOnce(new Error("Network error")); 133 + 134 + (agent.getProfile as any).mockResolvedValueOnce({ 135 + data: {}, 136 + }); 137 + 138 + const result = await getAccountCreationDate("did:plc:error"); 139 + 140 + expect(result).toBeNull(); 141 + // Should log debug/warn when handling expected errors, not error 142 + expect(logger.debug).toHaveBeenCalled(); 143 + }); 144 + }); 145 + 146 + describe("checkAccountAge", () => { 147 + const TEST_TIME = Date.now() * 1000; 148 + const TEST_REPLY_URI = "at://did:plc:replier123/app.bsky.feed.post/xyz"; 149 + 150 + beforeEach(() => { 151 + // Clear the ACCOUNT_AGE_CHECKS array and add test config 152 + ACCOUNT_AGE_CHECKS.length = 0; 153 + }); 154 + 155 + it("should skip if no checks configured", async () => { 156 + await checkAccountAge({ 157 + replyToDid: "did:plc:monitored", 158 + replyingDid: "did:plc:replier", 159 + atURI: TEST_REPLY_URI, 160 + time: TEST_TIME, 161 + }); 162 + 163 + expect(createAccountLabel).not.toHaveBeenCalled(); 164 + }); 165 + 166 + it("should skip if reply is not to monitored DID", async () => { 167 + ACCOUNT_AGE_CHECKS.push({ 168 + monitoredDIDs: ["did:plc:monitored1"], 169 + anchorDate: "2025-01-15", 170 + maxAgeDays: 7, 171 + label: "new-account-reply", 172 + comment: "New account reply", 173 + }); 174 + 175 + await checkAccountAge({ 176 + replyToDid: "did:plc:other", 177 + replyingDid: "did:plc:replier", 178 + atURI: TEST_REPLY_URI, 179 + time: TEST_TIME, 180 + }); 181 + 182 + expect(createAccountLabel).not.toHaveBeenCalled(); 183 + }); 184 + 185 + it("should label account if too new", async () => { 186 + ACCOUNT_AGE_CHECKS.push({ 187 + monitoredDIDs: ["did:plc:monitored"], 188 + anchorDate: "2025-01-15", 189 + maxAgeDays: 7, 190 + label: "new-account-reply", 191 + comment: "New account replying during campaign", 192 + }); 193 + 194 + // Mock account created on Jan 12 noon (2.5 days before anchor at midnight = 2 days floored) 195 + const mockDidDoc = [ 196 + { 197 + createdAt: "2025-01-12T12:00:00.000Z", 198 + }, 199 + ]; 200 + 201 + (global.fetch as any).mockResolvedValueOnce({ 202 + ok: true, 203 + json: async () => mockDidDoc, 204 + }); 205 + 206 + await checkAccountAge({ 207 + replyToDid: "did:plc:monitored", 208 + replyingDid: "did:plc:newaccount", 209 + atURI: TEST_REPLY_URI, 210 + time: TEST_TIME, 211 + }); 212 + 213 + expect(createAccountLabel).toHaveBeenCalledWith( 214 + "did:plc:newaccount", 215 + "new-account-reply", 216 + expect.stringContaining("Account age: 2 days"), 217 + ); 218 + }); 219 + 220 + it("should not label account if old enough", async () => { 221 + ACCOUNT_AGE_CHECKS.push({ 222 + monitoredDIDs: ["did:plc:monitored"], 223 + anchorDate: "2025-01-15", 224 + maxAgeDays: 7, 225 + label: "new-account-reply", 226 + comment: "New account reply", 227 + }); 228 + 229 + // Mock account created on Jan 5 (10 days before anchor) 230 + const mockDidDoc = [ 231 + { 232 + createdAt: "2025-01-05T12:00:00.000Z", 233 + }, 234 + ]; 235 + 236 + (global.fetch as any).mockResolvedValueOnce({ 237 + ok: true, 238 + json: async () => mockDidDoc, 239 + }); 240 + 241 + await checkAccountAge({ 242 + replyToDid: "did:plc:monitored", 243 + replyingDid: "did:plc:oldaccount", 244 + atURI: TEST_REPLY_URI, 245 + time: TEST_TIME, 246 + }); 247 + 248 + expect(createAccountLabel).not.toHaveBeenCalled(); 249 + }); 250 + 251 + it("should handle multiple monitored DIDs", async () => { 252 + ACCOUNT_AGE_CHECKS.push({ 253 + monitoredDIDs: ["did:plc:monitored1", "did:plc:monitored2"], 254 + anchorDate: "2025-01-15", 255 + maxAgeDays: 7, 256 + label: "new-account-reply", 257 + comment: "New account reply", 258 + }); 259 + 260 + const mockDidDoc = [ 261 + { 262 + createdAt: "2025-01-14T12:00:00.000Z", 263 + }, 264 + ]; 265 + 266 + (global.fetch as any).mockResolvedValueOnce({ 267 + ok: true, 268 + json: async () => mockDidDoc, 269 + }); 270 + 271 + await checkAccountAge({ 272 + replyToDid: "did:plc:monitored2", 273 + replyingDid: "did:plc:newaccount", 274 + atURI: TEST_REPLY_URI, 275 + time: TEST_TIME, 276 + }); 277 + 278 + expect(createAccountLabel).toHaveBeenCalledOnce(); 279 + }); 280 + 281 + it("should handle multiple check configurations", async () => { 282 + ACCOUNT_AGE_CHECKS.push( 283 + { 284 + monitoredDIDs: ["did:plc:monitored1"], 285 + anchorDate: "2025-01-15", 286 + maxAgeDays: 7, 287 + label: "new-account-campaign1", 288 + comment: "Campaign 1", 289 + }, 290 + { 291 + monitoredDIDs: ["did:plc:monitored2"], 292 + anchorDate: "2025-02-01", 293 + maxAgeDays: 14, 294 + label: "new-account-campaign2", 295 + comment: "Campaign 2", 296 + }, 297 + ); 298 + 299 + const mockDidDoc = [ 300 + { 301 + createdAt: "2025-01-20T12:00:00.000Z", 302 + }, 303 + ]; 304 + 305 + (global.fetch as any).mockResolvedValueOnce({ 306 + ok: true, 307 + json: async () => mockDidDoc, 308 + }); 309 + 310 + // Reply to monitored2 - account created Jan 20 noon, checked against Feb 1 midnight (11.5 days = 11 floored) 311 + await checkAccountAge({ 312 + replyToDid: "did:plc:monitored2", 313 + replyingDid: "did:plc:newaccount", 314 + atURI: TEST_REPLY_URI, 315 + time: TEST_TIME, 316 + }); 317 + 318 + expect(createAccountLabel).toHaveBeenCalledWith( 319 + "did:plc:newaccount", 320 + "new-account-campaign2", 321 + expect.stringContaining("Account age: 11 days"), 322 + ); 323 + }); 324 + 325 + it("should skip if creation date cannot be determined", async () => { 326 + ACCOUNT_AGE_CHECKS.push({ 327 + monitoredDIDs: ["did:plc:monitored"], 328 + anchorDate: "2025-01-15", 329 + maxAgeDays: 7, 330 + label: "new-account-reply", 331 + comment: "New account reply", 332 + }); 333 + 334 + (global.fetch as any).mockResolvedValueOnce({ 335 + ok: false, 336 + }); 337 + 338 + (agent.getProfile as any).mockResolvedValueOnce({ 339 + data: {}, 340 + }); 341 + 342 + await checkAccountAge({ 343 + replyToDid: "did:plc:monitored", 344 + replyingDid: "did:plc:unknown", 345 + atURI: TEST_REPLY_URI, 346 + time: TEST_TIME, 347 + }); 348 + 349 + expect(createAccountLabel).not.toHaveBeenCalled(); 350 + expect(logger.warn).toHaveBeenCalled(); 351 + }); 352 + 353 + it("should only apply one label per reply", async () => { 354 + // Add overlapping configurations 355 + ACCOUNT_AGE_CHECKS.push( 356 + { 357 + monitoredDIDs: ["did:plc:monitored"], 358 + anchorDate: "2025-01-15", 359 + maxAgeDays: 7, 360 + label: "label1", 361 + comment: "First check", 362 + }, 363 + { 364 + monitoredDIDs: ["did:plc:monitored"], 365 + anchorDate: "2025-01-15", 366 + maxAgeDays: 14, 367 + label: "label2", 368 + comment: "Second check", 369 + }, 370 + ); 371 + 372 + const mockDidDoc = [ 373 + { 374 + createdAt: "2025-01-14T12:00:00.000Z", 375 + }, 376 + ]; 377 + 378 + (global.fetch as any).mockResolvedValueOnce({ 379 + ok: true, 380 + json: async () => mockDidDoc, 381 + }); 382 + 383 + await checkAccountAge({ 384 + replyToDid: "did:plc:monitored", 385 + replyingDid: "did:plc:newaccount", 386 + atURI: TEST_REPLY_URI, 387 + time: TEST_TIME, 388 + }); 389 + 390 + // Should only call once (first matching check) 391 + expect(createAccountLabel).toHaveBeenCalledOnce(); 392 + expect(createAccountLabel).toHaveBeenCalledWith( 393 + "did:plc:newaccount", 394 + "label1", 395 + expect.any(String), 396 + ); 397 + }); 398 + }); 399 + });
+1
src/config.ts
··· 12 12 : 4101; // Left this intact from the code I adapted this from 13 13 export const FIREHOSE_URL = 14 14 process.env.FIREHOSE_URL ?? "wss://jetstream.atproto.tools/subscribe"; 15 + export const PLC_URL = process.env.PLC_URL ?? "plc.wtf"; 15 16 export const WANTED_COLLECTION = [ 16 17 "app.bsky.feed.post", 17 18 "app.bsky.actor.defs",
+16
src/main.ts
··· 19 19 import { checkHandle } from "./checkHandles.js"; 20 20 import { checkDescription, checkDisplayName } from "./checkProfiles.js"; 21 21 import { checkFacetSpam } from "./rules/facets/facets.js"; 22 + import { checkAccountAge } from "./account/age.js"; 22 23 23 24 let cursor = 0; 24 25 let cursorUpdateInterval: NodeJS.Timeout; ··· 112 113 const hasText = event.commit.record.hasOwnProperty("text"); 113 114 114 115 const tasks: Promise<void>[] = []; 116 + 117 + // Check account age for replies to monitored DIDs 118 + if (event.commit.record.reply) { 119 + const parentUri = event.commit.record.reply.parent.uri; 120 + const replyToDid = parentUri.split("/")[2]; // Extract DID from at://did/... 121 + 122 + tasks.push( 123 + checkAccountAge({ 124 + replyToDid, 125 + replyingDid: event.did, 126 + atURI, 127 + time: event.time_us, 128 + }), 129 + ); 130 + } 115 131 116 132 // Check if the record has facets 117 133 if (hasFacets) {
+28
src/rules/facets/tests/facets.test.ts
··· 153 153 expect(createAccountLabel).not.toHaveBeenCalled(); 154 154 expect(logger.info).not.toHaveBeenCalled(); 155 155 }); 156 + 157 + it("should not label when DID is on allowlist", async () => { 158 + // Add test DID to allowlist temporarily 159 + FACET_SPAM_ALLOWLIST.push(TEST_DID); 160 + 161 + const facets: Facet[] = [ 162 + { 163 + index: { byteStart: 0, byteEnd: 1 }, 164 + features: [{ $type: "app.bsky.richtext.facet#mention", did: "did:plc:user1" }], 165 + }, 166 + { 167 + index: { byteStart: 0, byteEnd: 1 }, 168 + features: [{ $type: "app.bsky.richtext.facet#mention", did: "did:plc:user2" }], 169 + }, 170 + ]; 171 + 172 + await checkFacetSpam(TEST_DID, TEST_TIME, TEST_URI, facets); 173 + 174 + // Should not trigger - allowlisted 175 + expect(createAccountLabel).not.toHaveBeenCalled(); 176 + expect(logger.debug).toHaveBeenCalledWith( 177 + { process: "FACET_SPAM", did: TEST_DID, atURI: TEST_URI }, 178 + "Allowlisted DID" 179 + ); 180 + 181 + // Clean up 182 + FACET_SPAM_ALLOWLIST.pop(); 183 + }); 156 184 }); 157 185 158 186 describe("when spam is detected", () => {
+9
src/types.ts
··· 58 58 index: FacetIndex; 59 59 features: Array<{ $type: string; [key: string]: any }>; 60 60 } 61 + 62 + export interface AccountAgeCheck { 63 + monitoredDIDs: string[]; // DIDs to monitor for replies 64 + anchorDate: string; // ISO 8601 date string (e.g., "2025-01-15") 65 + maxAgeDays: number; // Maximum account age in days 66 + label: string; // Label to apply if account is too new 67 + comment: string; // Comment for the label 68 + } 69 +