From 7373fc4b6f1ec58ec474f9e8a9bc24904abdfa10 Mon Sep 17 00:00:00 2001 From: RissRIce Date: Mon, 10 Aug 2026 22:07:20 -0600 Subject: [PATCH] feat(modules): add Feodo Tracker C2 feed --- modules/feodo-tracker/.gitignore | 2 + modules/feodo-tracker/LICENSE | 21 ++ modules/feodo-tracker/README.md | 69 ++++++ .../feodo-tracker/config/example.conf.toml | 6 + modules/feodo-tracker/mod.toml | 22 ++ modules/feodo-tracker/package.json | 45 ++++ .../feodo-tracker/src/__tests__/index.test.ts | 64 +++++ modules/feodo-tracker/src/index.ts | 227 ++++++++++++++++++ modules/feodo-tracker/tsconfig.json | 16 ++ pnpm-lock.yaml | 16 ++ 10 files changed, 488 insertions(+) create mode 100644 modules/feodo-tracker/.gitignore create mode 100644 modules/feodo-tracker/LICENSE create mode 100644 modules/feodo-tracker/README.md create mode 100644 modules/feodo-tracker/config/example.conf.toml create mode 100644 modules/feodo-tracker/mod.toml create mode 100644 modules/feodo-tracker/package.json create mode 100644 modules/feodo-tracker/src/__tests__/index.test.ts create mode 100644 modules/feodo-tracker/src/index.ts create mode 100644 modules/feodo-tracker/tsconfig.json diff --git a/modules/feodo-tracker/.gitignore b/modules/feodo-tracker/.gitignore new file mode 100644 index 0000000..1eae0cf --- /dev/null +++ b/modules/feodo-tracker/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ diff --git a/modules/feodo-tracker/LICENSE b/modules/feodo-tracker/LICENSE new file mode 100644 index 0000000..4e4d35b --- /dev/null +++ b/modules/feodo-tracker/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 rissrice2105-agent + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/modules/feodo-tracker/README.md b/modules/feodo-tracker/README.md new file mode 100644 index 0000000..f403eed --- /dev/null +++ b/modules/feodo-tracker/README.md @@ -0,0 +1,69 @@ +# Feodo Tracker + +A defensive ThreatCrush module that polls the public +[abuse.ch Feodo Tracker](https://feodotracker.abuse.ch/) botnet C2 blocklist and +emits structured `ThreatEvent` records for newly observed command-and-control +servers. + +## Features + +- Uses the official Feodo Tracker CSV feed; no API key is required. +- Emits active C2 indicators by default and can optionally include offline ones. +- Persists indicator keys to avoid repeating events after a restart. +- Validates IP addresses and ports before emitting an event. +- Requires HTTPS for custom feed URLs and limits events per poll. +- Includes parser and event-mapping tests. + +This module consumes threat intelligence only. It does not scan, contact, or +attempt to exploit any listed server. + +## Install + +```bash +threatcrush modules install feodo-tracker +``` + +For local development in the ThreatCrush monorepo: + +```bash +pnpm install +pnpm --filter threatcrush-module-feodo-tracker build +pnpm --filter threatcrush-module-feodo-tracker test +``` + +## Configuration + +Copy `config/example.conf.toml` into the ThreatCrush module configuration and +adjust these values if needed: + +| Setting | Default | Description | +| --- | --- | --- | +| `poll_interval_seconds` | `900` | Poll interval, with a runtime minimum of 60 seconds. | +| `feed_url` | Official Feodo CSV URL | HTTPS feed endpoint. | +| `emit_offline` | `false` | Emit inactive historical C2 entries too. | +| `max_events_per_poll` | `100` | Maximum new events emitted in one poll. | + +## Event shape + +Active C2 servers produce a high-severity network event: + +```json +{ + "module": "feodo-tracker", + "category": "network", + "severity": "high", + "message": "Feodo Tracker: QakBot C2 203.0.113.8:443 is online", + "source_ip": "203.0.113.8", + "details": { + "destination_ip": "203.0.113.8", + "destination_port": 443, + "c2_status": "online", + "malware": "QakBot" + } +} +``` + +## Data source and license + +The module code is MIT licensed. Feodo Tracker data remains subject to the +[abuse.ch terms of use](https://feodotracker.abuse.ch/blocklist/). diff --git a/modules/feodo-tracker/config/example.conf.toml b/modules/feodo-tracker/config/example.conf.toml new file mode 100644 index 0000000..ca28d6e --- /dev/null +++ b/modules/feodo-tracker/config/example.conf.toml @@ -0,0 +1,6 @@ +[modules.feodo-tracker] +enabled = true +poll_interval_seconds = 900 +feed_url = "https://feodotracker.abuse.ch/downloads/ipblocklist.csv" +emit_offline = false +max_events_per_poll = 100 diff --git a/modules/feodo-tracker/mod.toml b/modules/feodo-tracker/mod.toml new file mode 100644 index 0000000..7857c1b --- /dev/null +++ b/modules/feodo-tracker/mod.toml @@ -0,0 +1,22 @@ +[module] +name = "feodo-tracker" +version = "0.1.0" +description = "Monitors the abuse.ch Feodo Tracker feed for active botnet command-and-control servers" +author = "rissrice2105-agent" +license = "MIT" +homepage = "https://github.com/profullstack/threatcrush/tree/master/modules/feodo-tracker" + +[module.pricing] +type = "free" + +[module.requirements] +threatcrush = ">=0.2.0" +os = ["linux", "darwin", "win32"] +capabilities = ["network:outbound", "threat-intel:feed"] + +[module.config.defaults] +enabled = true +poll_interval_seconds = 900 +feed_url = "https://feodotracker.abuse.ch/downloads/ipblocklist.csv" +emit_offline = false +max_events_per_poll = 100 diff --git a/modules/feodo-tracker/package.json b/modules/feodo-tracker/package.json new file mode 100644 index 0000000..8847e99 --- /dev/null +++ b/modules/feodo-tracker/package.json @@ -0,0 +1,45 @@ +{ + "name": "threatcrush-module-feodo-tracker", + "version": "0.1.0", + "description": "Emits defensive threat-intelligence events for active botnet C2 indicators from Feodo Tracker", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "license": "MIT", + "author": "rissrice2105-agent", + "homepage": "https://threatcrush.com/store/feodo-tracker", + "repository": { + "type": "git", + "url": "git+https://github.com/profullstack/threatcrush.git", + "directory": "modules/feodo-tracker" + }, + "keywords": [ + "threatcrush", + "threatcrush-module", + "feodo-tracker", + "botnet", + "command-and-control", + "threat-intelligence" + ], + "files": [ + "dist", + "mod.toml", + "config", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "vitest run", + "test:watch": "vitest", + "clean": "rm -rf dist" + }, + "dependencies": { + "@threatcrush/sdk": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.9.3", + "vitest": "^3.0.0" + } +} diff --git a/modules/feodo-tracker/src/__tests__/index.test.ts b/modules/feodo-tracker/src/__tests__/index.test.ts new file mode 100644 index 0000000..cc8eb4b --- /dev/null +++ b/modules/feodo-tracker/src/__tests__/index.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; + +import { + indicatorKey, + parseFeodoCsv, + toThreatEvent, +} from '../index.js'; + +const FEED = ` +################################################################ +# abuse.ch Feodo Tracker Botnet C2 IP Blocklist (CSV) +"first_seen_utc","dst_ip","dst_port","c2_status","last_online","malware" +"2026-02-10 12:00:00","203.0.113.8","443","online","2026-08-10","QakBot" +"2026-02-11 12:00:00","198.51.100.4","8080","offline","2026-08-01","Emotet" +# END 2 entries +`; + +describe('parseFeodoCsv', () => { + it('parses valid data rows and skips metadata', () => { + const indicators = parseFeodoCsv(FEED); + + expect(indicators).toHaveLength(2); + expect(indicators[0]).toEqual({ + firstSeenUtc: '2026-02-10 12:00:00', + destinationIp: '203.0.113.8', + destinationPort: 443, + status: 'online', + lastOnline: '2026-08-10', + malware: 'QakBot', + }); + }); + + it('rejects invalid IP addresses and ports', () => { + const invalid = ` +"2026-02-10 12:00:00","999.0.0.1","443","online","2026-08-10","QakBot" +"2026-02-10 12:00:00","203.0.113.8","70000","online","2026-08-10","QakBot" +`; + + expect(parseFeodoCsv(invalid)).toEqual([]); + }); +}); + +describe('event mapping', () => { + it('builds a stable key for a re-observed endpoint', () => { + const [first] = parseFeodoCsv(FEED); + + expect(indicatorKey(first)).toBe( + '203.0.113.8:443:2026-02-10 12:00:00', + ); + }); + + it('creates a high-severity network event for active C2', () => { + const [first] = parseFeodoCsv(FEED); + const event = toThreatEvent(first); + + expect(event).toMatchObject({ + module: 'feodo-tracker', + category: 'network', + severity: 'high', + source_ip: '203.0.113.8', + }); + expect(event.timestamp.toISOString()).toBe('2026-02-10T12:00:00.000Z'); + }); +}); diff --git a/modules/feodo-tracker/src/index.ts b/modules/feodo-tracker/src/index.ts new file mode 100644 index 0000000..24bfc40 --- /dev/null +++ b/modules/feodo-tracker/src/index.ts @@ -0,0 +1,227 @@ +import { isIP } from 'node:net'; + +import type { + ModuleContext, + ThreatCrushModule, + ThreatEvent, +} from '@threatcrush/sdk'; + +export interface FeodoIndicator { + firstSeenUtc: string; + destinationIp: string; + destinationPort: number; + status: string; + lastOnline: string; + malware: string; +} + +export const DEFAULT_FEED_URL = + 'https://feodotracker.abuse.ch/downloads/ipblocklist.csv'; +const STATE_KEY = 'seen_indicators'; +const MAX_STORED_INDICATORS = 5000; + +export function indicatorKey(indicator: FeodoIndicator): string { + return `${indicator.destinationIp}:${indicator.destinationPort}:${indicator.firstSeenUtc}`; +} + +export function parseFeodoCsv(csv: string): FeodoIndicator[] { + const indicators: FeodoIndicator[] = []; + + for (const rawLine of csv.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + + const columns = splitCsvRow(line); + if (columns[0] === 'first_seen_utc') continue; + if (columns.length < 6) continue; + + const destinationPort = Number.parseInt(columns[2], 10); + if (!columns[0] || !isIpAddress(columns[1]) || !isValidPort(destinationPort)) { + continue; + } + + indicators.push({ + firstSeenUtc: columns[0], + destinationIp: columns[1], + destinationPort, + status: columns[3].toLowerCase(), + lastOnline: columns[4], + malware: columns[5] || 'unknown', + }); + } + + return indicators; +} + +export function toThreatEvent( + indicator: FeodoIndicator, + source = DEFAULT_FEED_URL, +): ThreatEvent { + const isOnline = indicator.status === 'online'; + return { + timestamp: parseUtcDate(indicator.firstSeenUtc), + module: 'feodo-tracker', + category: 'network', + severity: isOnline ? 'high' : 'medium', + message: `Feodo Tracker: ${indicator.malware} C2 ${indicator.destinationIp}:${indicator.destinationPort} is ${indicator.status}`, + source_ip: indicator.destinationIp, + details: { + destination_ip: indicator.destinationIp, + destination_port: indicator.destinationPort, + c2_status: indicator.status, + first_seen_utc: indicator.firstSeenUtc, + last_online: indicator.lastOnline, + malware: indicator.malware, + source, + }, + }; +} + +export default class FeodoTrackerModule implements ThreatCrushModule { + name = 'feodo-tracker'; + version = '0.1.0'; + description = + 'Monitors Feodo Tracker for active botnet command-and-control servers'; + + private context!: ModuleContext; + private timer: NodeJS.Timeout | null = null; + private polling = false; + + async init(context: ModuleContext): Promise { + this.context = context; + this.context.logger.info('[%s] initialized', this.name); + } + + async start(): Promise { + const intervalSeconds = Math.max( + 60, + readNumber(this.context.config.poll_interval_seconds, 900), + ); + + await this.poll(); + this.timer = setInterval(() => { + void this.poll(); + }, intervalSeconds * 1000); + } + + async stop(): Promise { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + this.context.logger.info('[%s] stopped', this.name); + } + + private async poll(): Promise { + if (this.polling) return; + this.polling = true; + + try { + const feedUrl = readString(this.context.config.feed_url, DEFAULT_FEED_URL); + const emitOffline = this.context.config.emit_offline === true; + const maxEvents = Math.max( + 1, + Math.floor(readNumber(this.context.config.max_events_per_poll, 100)), + ); + const indicators = await fetchIndicators(feedUrl); + const previousKeys = readStoredKeys(this.context.getState(STATE_KEY)); + const seen = new Set(previousKeys); + const fresh = indicators + .filter((indicator) => emitOffline || indicator.status === 'online') + .filter((indicator) => !seen.has(indicatorKey(indicator))) + .slice(-maxEvents); + + for (const indicator of fresh) { + this.context.emit(toThreatEvent(indicator, feedUrl)); + } + + const currentKeys = indicators.map(indicatorKey); + const storedKeys = [ + ...new Set([...currentKeys.reverse(), ...previousKeys]), + ].slice(0, MAX_STORED_INDICATORS); + this.context.setState(STATE_KEY, storedKeys); + this.context.logger.info( + '[%s] processed %d indicators, emitted %d', + this.name, + indicators.length, + fresh.length, + ); + } catch (error) { + this.context.logger.error('[%s] poll failed: %s', this.name, String(error)); + } finally { + this.polling = false; + } + } +} + +export async function fetchIndicators(url: string): Promise { + const parsed = new URL(url); + if (parsed.protocol !== 'https:') { + throw new Error('feed_url must use HTTPS'); + } + + const response = await fetch(parsed, { + headers: { + Accept: 'text/csv', + 'User-Agent': 'threatcrush-feodo-tracker/0.1.0', + }, + signal: AbortSignal.timeout(15_000), + }); + if (!response.ok) { + throw new Error(`Feodo Tracker feed returned HTTP ${response.status}`); + } + return parseFeodoCsv(await response.text()); +} + +function readStoredKeys(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.filter((item): item is string => typeof item === 'string'); +} + +function readString(value: unknown, fallback: string): string { + return typeof value === 'string' && value.trim() ? value : fallback; +} + +function readNumber(value: unknown, fallback: number): number { + const parsed = typeof value === 'number' ? value : Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function parseUtcDate(value: string): Date { + const normalized = value.includes('T') ? value : `${value.replace(' ', 'T')}Z`; + const date = new Date(normalized); + return Number.isNaN(date.getTime()) ? new Date() : date; +} + +function isValidPort(value: number): boolean { + return Number.isInteger(value) && value > 0 && value <= 65_535; +} + +function isIpAddress(value: string): boolean { + return isIP(value) !== 0; +} + +function splitCsvRow(line: string): string[] { + const columns: string[] = []; + let current = ''; + let quoted = false; + + for (let index = 0; index < line.length; index += 1) { + const character = line[index]; + if (character === '"') { + if (quoted && line[index + 1] === '"') { + current += '"'; + index += 1; + } else { + quoted = !quoted; + } + } else if (character === ',' && !quoted) { + columns.push(current); + current = ''; + } else { + current += character; + } + } + columns.push(current); + return columns; +} diff --git a/modules/feodo-tracker/tsconfig.json b/modules/feodo-tracker/tsconfig.json new file mode 100644 index 0000000..461e2d4 --- /dev/null +++ b/modules/feodo-tracker/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["src/__tests__/**"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e5cf54..17d9a99 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -415,6 +415,22 @@ importers: specifier: ^3.0.0 version: 3.2.7(@types/debug@4.1.13)(@types/node@22.19.17)(jiti@2.6.1)(jsdom@29.1.1)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.3) + modules/feodo-tracker: + dependencies: + '@threatcrush/sdk': + specifier: workspace:* + version: link:../../apps/sdk + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.19.17 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.7(@types/debug@4.1.13)(@types/node@22.19.17)(jiti@2.6.1)(jsdom@29.1.1)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.3) + modules/spend-guard: dependencies: '@threatcrush/sdk':