From 7004956dd80e58edf7e7e0ad75f9e57f58a598f6 Mon Sep 17 00:00:00 2001 From: Rinjani Analytics Date: Wed, 17 Jun 2026 10:12:11 +0700 Subject: [PATCH] feat(feeds): ScamSniffer community scam-address feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #3 of the free on-chain roadmap. ScamSniffer's openly-published blacklist of crypto scam/phishing/drainer addresses (~2.5k EVM) — active-fraud coverage alongside OFAC's sanctioned set, hardening on-chain attribution without paying Arkham. Where OFAC is legal ground-truth, ScamSniffer is the scam wallets analysts actually hit. Plain JSON fetch (no deps), dual-sink mirroring ofac.ts: - iocs — type `crypto-address`, tagged `scam` → Landscape shift band. - wallets — entityType `scam`, attributionSource `scamsniffer`, confidence 75 (community intel, not authoritative like OFAC's 100). The wallet upsert is confidence-preserving: a scam label (75) never clobbers a higher-confidence attribution (OFAC sanctioned=100, or an analyst's manual one) — GREATEST(confidence) + CASE-guarded fields — so OFAC always wins regardless of feed run order. Registered as `scamsniffer`; daily 03:30 UTC. No migration (reuses iocs + wallets). Verified live against local DB: 2530 addresses → 2530 iocs + 2530 wallets, 0 failed; gateway tsc + api tests (16-feed registry) green. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/src/__tests__/feed-registry.test.ts | 7 +- apps/api/src/queues/scheduler.ts | 13 ++ .../src/services/feedSync/additionalFeeds.ts | 6 + .../api/src/services/feedSync/feedRegistry.ts | 6 +- apps/worker/src/feeds/scamsniffer.ts | 175 ++++++++++++++++++ 5 files changed, 203 insertions(+), 4 deletions(-) create mode 100644 apps/worker/src/feeds/scamsniffer.ts diff --git a/apps/api/src/__tests__/feed-registry.test.ts b/apps/api/src/__tests__/feed-registry.test.ts index 75e720a..02c699d 100644 --- a/apps/api/src/__tests__/feed-registry.test.ts +++ b/apps/api/src/__tests__/feed-registry.test.ts @@ -18,11 +18,12 @@ describe('Feed Registry', () => { // `ofac` joined as the free, authoritative on-chain attribution feed // (OFAC SDN sanctioned crypto addresses; dual-sinks iocs + wallets). // `aiid` joined as the AI-threat-landscape feed (AI Incident Database; - // sinks to ai_incidents). + // sinks to ai_incidents). `scamsniffer` joined as the community + // scam-address feed (dual-sinks iocs + wallets, like ofac). const EXPECTED_FEEDS = [ 'otx', 'cisa', 'cveorg', 'nvd', 'abusessl', 'threatfox', - 'urlhaus', 'malwarebazaar', 'openphish', 'ofac', 'aiid', 'mitre', 'mispgalaxy', - 'epss', 'hibp', + 'urlhaus', 'malwarebazaar', 'openphish', 'ofac', 'aiid', 'scamsniffer', + 'mitre', 'mispgalaxy', 'epss', 'hibp', ]; describe('getRegisteredFeeds', () => { diff --git a/apps/api/src/queues/scheduler.ts b/apps/api/src/queues/scheduler.ts index 9812c0b..fe5949a 100644 --- a/apps/api/src/queues/scheduler.ts +++ b/apps/api/src/queues/scheduler.ts @@ -149,6 +149,19 @@ export const JOB_REGISTRY: ScheduledJobRegistration[] = [ queue: feedSyncQueue, payload: { source: 'openphish' }, }, + { + // ScamSniffer scam-address blacklist (community Web3 anti-scam intel). + // Active-fraud on-chain coverage alongside OFAC's sanctioned set. A + // small JSON fetch (~2.5k addresses) that ScamSniffer updates often, + // so daily at 03:30 UTC (30 min after OFAC). Idempotent dual-sink. + key: 'scamsnifferSync', + jobId: 'scheduled-scamsniffer-sync', + name: 'scamsniffer-sync', + description: 'Sync ScamSniffer community scam-address blacklist', + defaultCron: '30 3 * * *', + queue: feedSyncQueue, + payload: { source: 'scamsniffer' }, + }, { // AI Incident Database (incidentdatabase.ai). The live AI-threat // landscape signal — real-world AI harm/failure incidents. Paged from diff --git a/apps/api/src/services/feedSync/additionalFeeds.ts b/apps/api/src/services/feedSync/additionalFeeds.ts index f503f7f..d63bb53 100644 --- a/apps/api/src/services/feedSync/additionalFeeds.ts +++ b/apps/api/src/services/feedSync/additionalFeeds.ts @@ -74,6 +74,12 @@ export async function syncAIIncidentsFeed(): Promise { return normalise(await syncAIIncidents()); } +export async function syncScamSnifferFeed(): Promise { + // @ts-ignore — worker scripts outside rootDir, resolved at runtime + const { syncScamSniffer } = await import('../../../../worker/src/feeds/scamsniffer'); + return normalise(await syncScamSniffer()); +} + export async function syncMITREFeed(): Promise { try { // @ts-ignore diff --git a/apps/api/src/services/feedSync/feedRegistry.ts b/apps/api/src/services/feedSync/feedRegistry.ts index 84574b9..d692fb6 100644 --- a/apps/api/src/services/feedSync/feedRegistry.ts +++ b/apps/api/src/services/feedSync/feedRegistry.ts @@ -20,7 +20,7 @@ import { syncCveOrgFeed } from './cveOrgSync'; import { syncAbuseSSLFeed, syncThreatFoxFeed, syncURLhausFeed, syncMalwareBazaarFeed, syncOpenPhishFeed, syncMITREFeed, syncMISPGalaxyFeed, - syncEPSSFeed, syncOFACFeed, syncAIIncidentsFeed, + syncEPSSFeed, syncOFACFeed, syncAIIncidentsFeed, syncScamSnifferFeed, } from './additionalFeeds'; import { syncHibpBreaches } from './hibpSync'; import { FeedManifest as FeedManifestSchema } from '@rinjani/feed-engine'; @@ -55,6 +55,10 @@ const FEED_REGISTRY: Record = { // (incidentdatabase.ai). The live AI-threat-landscape signal; sinks to // the dedicated ai_incidents table. aiid: () => syncAIIncidentsFeed(), + // ScamSniffer community scam-address blacklist — active-fraud on-chain + // coverage. Dual-sinks to iocs (tag `scam`) + wallets (entityType `scam`), + // mirroring OFAC. Community intel, confidence 75 (vs OFAC's 100). + scamsniffer: () => syncScamSnifferFeed(), mitre: () => syncMITREFeed(), mispgalaxy: () => syncMISPGalaxyFeed(), // EPSS — FIRST.org's daily exploit-prediction score. Pairs with the diff --git a/apps/worker/src/feeds/scamsniffer.ts b/apps/worker/src/feeds/scamsniffer.ts new file mode 100644 index 0000000..860d00a --- /dev/null +++ b/apps/worker/src/feeds/scamsniffer.ts @@ -0,0 +1,175 @@ +/** + * ScamSniffer scam-address blacklist — community-reported crypto scam wallets. + * + * #3 of the free on-chain roadmap. ScamSniffer (a well-known Web3 anti-scam + * project) publishes an openly-licensed blacklist of addresses tied to + * phishing/drainer/scam campaigns. Where OFAC gives *legal* ground-truth + * (sanctioned), ScamSniffer gives *active fraud* coverage — the scam wallets + * analysts actually run into. Together they harden the on-chain attribution + * layer without paying Arkham. + * + * Source: https://raw.githubusercontent.com/scamsniffer/scam-database/main/ + * blacklist/address.json (no key — a flat JSON array of addresses, + * currently ~2.5k, all EVM/0x). Updated frequently upstream. + * + * NOT authoritative like OFAC: community/heuristic intel, so confidence is 75 + * (vs OFAC's 100), entity_type `scam` (vs `sanctioned`). + * + * DUAL SINK (mirrors ofac.ts): + * 1. iocs — type `crypto-address`, tagged `scam` → Landscape shift band. + * 2. wallets — entityType `scam`, attributionSource `scamsniffer` → on-chain + * attribution layer (shows on the /onchain page). + */ + +import { db, sql } from '@rinjani/db'; +import { iocs, wallets } from '@rinjani/db/schema'; +import type { NewWallet } from '@rinjani/db/schema'; +import { createLogger } from '../lib/logger'; + +const log = createLogger('ScamSniffer'); + +const SCAMSNIFFER_URL = process.env.SCAMSNIFFER_BLACKLIST_URL + ?? 'https://raw.githubusercontent.com/scamsniffer/scam-database/main/blacklist/address.json'; +const BATCH_SIZE = 250; +// Community/heuristic intel — reputable but not legal ground-truth like OFAC. +const SCAM_CONFIDENCE = 75; + +interface SyncResult { + processed: number; + failed: number; + errors: string[]; +} + +/** EVM address shape — the blacklist is currently all 0x/42-char. */ +function isEvmAddress(a: string): boolean { + return /^0x[a-fA-F0-9]{40}$/.test(a); +} + +async function fetchBlacklist(): Promise { + const res = await fetch(SCAMSNIFFER_URL, { headers: { Accept: 'application/json' } }); + if (!res.ok) throw new Error(`ScamSniffer fetch failed: ${res.status} ${res.statusText}`); + const body = await res.json() as unknown; + if (!Array.isArray(body)) throw new Error('ScamSniffer blacklist is not a JSON array'); + // Normalise + de-dupe + keep only well-formed EVM addresses. + const seen = new Set(); + const out: string[] = []; + for (const raw of body) { + const addr = String(raw).trim().toLowerCase(); + if (!isEvmAddress(addr) || seen.has(addr)) continue; + seen.add(addr); + out.push(addr); + } + return out; +} + +export async function syncScamSniffer(): Promise { + log.info('Starting sync', { feedUrl: SCAMSNIFFER_URL }); + const result: SyncResult = { processed: 0, failed: 0, errors: [] }; + + let addresses: string[]; + try { + addresses = await fetchBlacklist(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + log.error('Fetch failed', err as Error); + result.errors.push(`Fetch error: ${msg}`); + result.failed = 1; + return result; + } + log.info('Fetched scam addresses', { count: addresses.length }); + + for (let i = 0; i < addresses.length; i += BATCH_SIZE) { + const slice = addresses.slice(i, i + BATCH_SIZE); + try { + await writeBatch(slice); + result.processed += slice.length; + } catch (err) { + result.failed += slice.length; + const msg = err instanceof Error ? err.message : String(err); + if (result.errors.length < 10) result.errors.push(`Batch insert failed: ${msg}`); + log.error('Batch insert error', err as Error); + } + } + + log.info('Sync completed', { processed: result.processed, failed: result.failed }); + return result; +} + +async function writeBatch(batch: string[]): Promise { + const now = new Date(); + + // --- IOC sink: surfaces in Landscape shift via the `scam` tag --- + const iocRows = batch.map((address) => ({ + type: 'crypto-address', + value: address, + source: 'scamsniffer', + threatType: 'scam', + confidence: SCAM_CONFIDENCE, + severity: 'high', + firstSeen: now, + lastSeen: now, + tags: ['scam', 'scamsniffer', 'phishing', 'eth'], + metadata: { chain: 'eth', feed_source: 'scamsniffer' }, + })); + + await db.insert(iocs) + .values(iocRows) + .onConflictDoUpdate({ + target: iocs.value, + set: { lastSeen: now, updatedAt: now }, + }); + + // --- Wallet sink: on-chain attribution (shows on /onchain) --- + const walletRows: NewWallet[] = batch.map((address) => ({ + refId: `eth:${address}`, + address, + chain: 'eth', + name: 'Reported scam address', + entityLabel: 'Reported scam address', + entityType: 'scam', + confidence: SCAM_CONFIDENCE, + attributionSource: 'scamsniffer', + riskTags: ['scam', 'scamsniffer', 'phishing'], + externalReferences: [{ + source_name: 'ScamSniffer', + url: 'https://github.com/scamsniffer/scam-database', + }], + })); + + await db.insert(wallets) + .values(walletRows) + .onConflictDoUpdate({ + target: wallets.refId, + set: { + // Don't clobber a higher-confidence attribution (e.g. an OFAC + // sanctioned label, or an analyst's manual one) with the scam + // tag — only upgrade when the existing confidence is lower. + name: sql`CASE WHEN ${wallets.confidence} <= ${SCAM_CONFIDENCE} THEN excluded.name ELSE ${wallets.name} END`, + entityLabel: sql`CASE WHEN ${wallets.confidence} <= ${SCAM_CONFIDENCE} THEN excluded.entity_label ELSE ${wallets.entityLabel} END`, + entityType: sql`CASE WHEN ${wallets.confidence} <= ${SCAM_CONFIDENCE} THEN excluded.entity_type ELSE ${wallets.entityType} END`, + confidence: sql`GREATEST(${wallets.confidence}, excluded.confidence)`, + attributionSource: sql`CASE WHEN ${wallets.confidence} <= ${SCAM_CONFIDENCE} THEN excluded.attribution_source ELSE ${wallets.attributionSource} END`, + updatedAt: now, + }, + }); +} + +/** Standalone runner — `tsx apps/worker/src/feeds/scamsniffer.ts`. */ +export async function runScamSnifferSync(): Promise { + log.info('Starting full sync'); + try { + const result = await syncScamSniffer(); + log.info('Full sync completed', { processed: result.processed, failed: result.failed }); + } catch (error) { + log.error('Sync failed', error as Error); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + runScamSnifferSync() + .then(() => process.exit(0)) + .catch((error) => { + console.error(error); + process.exit(1); + }); +}