From 45dfc077894f38b859162be5b375614311e48d3e Mon Sep 17 00:00:00 2001 From: Ogunmodede Joel Taiwo Date: Sun, 21 Jun 2026 15:36:02 +0100 Subject: [PATCH] feat(detection): add known malicious address detection --- .../malicious-addresses.interface.ts | 64 ++++++ .../malicious-addresses.module.ts | 23 ++ .../malicious-addresses.service.spec.ts | 216 ++++++++++++++++++ .../malicious-addresses.service.ts | 200 ++++++++++++++++ 4 files changed, 503 insertions(+) create mode 100644 src/modules/detection/malicious-addresses/interfaces/malicious-addresses.interface.ts create mode 100644 src/modules/detection/malicious-addresses/malicious-addresses.module.ts create mode 100644 src/modules/detection/malicious-addresses/malicious-addresses.service.spec.ts create mode 100644 src/modules/detection/malicious-addresses/malicious-addresses.service.ts diff --git a/src/modules/detection/malicious-addresses/interfaces/malicious-addresses.interface.ts b/src/modules/detection/malicious-addresses/interfaces/malicious-addresses.interface.ts new file mode 100644 index 0000000..e56ea78 --- /dev/null +++ b/src/modules/detection/malicious-addresses/interfaces/malicious-addresses.interface.ts @@ -0,0 +1,64 @@ +/** + * Represents a flagged malicious address entry in the detection database. + */ +export interface MaliciousAddressRecord { + /** The blockchain address that has been flagged. */ + address: string; + /** The name of the intelligence feed or source that flagged the address. */ + feedName: string; + /** The reason why this address was flagged as malicious. */ + reason: string; + /** Severity level associated with this malicious actor. */ + severity: 'low' | 'medium' | 'high' | 'critical'; + /** ISO-8601 timestamp when the address was added/flagged. */ + flaggedAt: string; +} + +/** + * Result of checking a specific address against the detection database. + */ +export interface AddressMatchResult { + /** True if the address was found in the flagged address database. */ + isMatched: boolean; + /** The address that was verified. */ + address: string; + /** The matching record detailing why the address was flagged, if matched. */ + matchedRecord?: MaliciousAddressRecord; +} + +/** + * An alert generated on detecting a transaction or interaction with a malicious address. + */ +export interface MaliciousAddressAlert { + /** Unique alert identifier. */ + id: string; + /** Brief title for the alert. */ + title: string; + /** Detailed description of the interaction. */ + description: string; + /** Alert severity, matching the flagged address's severity. */ + severity: 'low' | 'medium' | 'high' | 'critical'; + /** ISO-8601 timestamp of when the alert was generated. */ + timestamp: string; + /** Structured context/metadata about the interaction. */ + metadata: { + /** The specific flagged address involved. */ + matchedAddress: string; + /** The feed source that flagged the address. */ + feedName: string; + /** The reason the address was flagged. */ + reason: string; + /** Raw transaction or event details, if provided during checking. */ + transactionDetails?: Record; + }; +} + +/** + * Payload for updating or syncing a malicious address feed. + */ +export interface FeedUpdate { + /** Name of the threat feed being updated. */ + feedName: string; + /** List of addresses and their attributes provided by this feed update. */ + addresses: Omit[]; +} diff --git a/src/modules/detection/malicious-addresses/malicious-addresses.module.ts b/src/modules/detection/malicious-addresses/malicious-addresses.module.ts new file mode 100644 index 0000000..8f03f17 --- /dev/null +++ b/src/modules/detection/malicious-addresses/malicious-addresses.module.ts @@ -0,0 +1,23 @@ +import { MaliciousAddressesService } from './malicious-addresses.service'; + +/** + * Module wrapper for malicious address detection. + * Provides static helper to instantiate the service. + */ +export class MaliciousAddressesModule { + /** + * Create and configure a MaliciousAddressesService instance. + * @param useDefaults If true, initializes with default flagged addresses. + */ + static create(useDefaults = true): MaliciousAddressesService { + return new MaliciousAddressesService(useDefaults); + } +} + +/** + * Factory helper function to instantiate the MaliciousAddressesService. + * @param useDefaults If true, initializes with default flagged addresses. + */ +export function createMaliciousAddressesService(useDefaults = true): MaliciousAddressesService { + return new MaliciousAddressesService(useDefaults); +} diff --git a/src/modules/detection/malicious-addresses/malicious-addresses.service.spec.ts b/src/modules/detection/malicious-addresses/malicious-addresses.service.spec.ts new file mode 100644 index 0000000..2dfe727 --- /dev/null +++ b/src/modules/detection/malicious-addresses/malicious-addresses.service.spec.ts @@ -0,0 +1,216 @@ +import { MaliciousAddressesService } from './malicious-addresses.service'; +import { FeedUpdate, MaliciousAddressAlert } from './interfaces/malicious-addresses.interface'; + +describe('MaliciousAddressesService', () => { + let service: MaliciousAddressesService; + + beforeEach(() => { + // Create service without default addresses for custom test setups + service = new MaliciousAddressesService(false); + }); + + describe('Address Normalization and Matching', () => { + it('should match exact addresses', () => { + service.addAddresses('TestFeed', [ + { + address: 'GA5Z3J2ABCDEFGHIJKLMNO1234567890STUVWXYZ', + reason: 'Phishing', + severity: 'high', + flaggedAt: '2026-06-21T00:00:00.000Z', + }, + ]); + + const match = service.matchAddress('GA5Z3J2ABCDEFGHIJKLMNO1234567890STUVWXYZ'); + expect(match.isMatched).toBe(true); + expect(match.address).toBe('GA5Z3J2ABCDEFGHIJKLMNO1234567890STUVWXYZ'); + expect(match.matchedRecord?.feedName).toBe('TestFeed'); + expect(match.matchedRecord?.severity).toBe('high'); + }); + + it('should normalize and match EVM hex addresses case-insensitively', () => { + service.addAddresses('TestFeed', [ + { + address: '0x95222290DD7278Aa3Ddd389Cc1E1d165CC4BAfe5', + reason: 'Drainer', + severity: 'critical', + flaggedAt: '2026-06-21T00:00:00.000Z', + }, + ]); + + // Check with lowercase + const matchLower = service.matchAddress('0x95222290dd7278aa3ddd389cc1e1d165cc4bafe5'); + expect(matchLower.isMatched).toBe(true); + + // Check with uppercase + const matchUpper = service.matchAddress('0x95222290DD7278AA3DDD389CC1E1D165CC4BAFE5'); + expect(matchUpper.isMatched).toBe(true); + }); + + it('should return isMatched false for unknown addresses', () => { + const match = service.matchAddress('0x0000000000000000000000000000000000000000'); + expect(match.isMatched).toBe(false); + expect(match.matchedRecord).toBeUndefined(); + }); + + it('should check a batch of addresses', () => { + service.addAddresses('TestFeed', [ + { + address: '0x123', + reason: 'Bad', + severity: 'low', + flaggedAt: '2026-06-21T00:00:00.000Z', + }, + ]); + + const results = service.checkAddresses(['0x123', '0x456']); + expect(results.length).toBe(2); + expect(results[0].isMatched).toBe(true); + expect(results[1].isMatched).toBe(false); + }); + }); + + describe('Default Addresses Initialization', () => { + it('should initialize default addresses when useDefaults is true', () => { + const defaultService = new MaliciousAddressesService(true); + const flagged = defaultService.getFlaggedAddresses(); + expect(flagged.length).toBeGreaterThan(0); + + // Check default EVM address + const evmMatch = defaultService.matchAddress('0x95222290dd7278aa3ddd389cc1e1d165cc4bafe5'); + expect(evmMatch.isMatched).toBe(true); + expect(evmMatch.matchedRecord?.feedName).toBe('PhishGuard'); + + // Check default Stellar address + const stellarMatch = defaultService.matchAddress('GA5Z3J2ABCDEFGHIJKLMNO1234567890STUVWXYZ'); + expect(stellarMatch.isMatched).toBe(true); + expect(stellarMatch.matchedRecord?.feedName).toBe('StellarThreatFeed'); + }); + }); + + describe('Feed Integration and Syncing', () => { + it('should update and overwrite feed addresses', () => { + // First, add initial feed records + service.addAddresses('MyFeed', [ + { + address: '0x111', + reason: 'Reason 1', + severity: 'medium', + flaggedAt: '2026-06-21T00:00:00.000Z', + }, + ]); + + expect(service.matchAddress('0x111').isMatched).toBe(true); + + // Now sync feed update that replaces '0x111' with '0x222' + const feedUpdate: FeedUpdate = { + feedName: 'MyFeed', + addresses: [ + { + address: '0x222', + reason: 'Reason 2', + severity: 'high', + flaggedAt: '2026-06-21T01:00:00.000Z', + }, + ], + }; + + service.updateFeed(feedUpdate); + + // Old address should be cleared + expect(service.matchAddress('0x111').isMatched).toBe(false); + // New address should be added + expect(service.matchAddress('0x222').isMatched).toBe(true); + expect(service.matchAddress('0x222').matchedRecord?.reason).toBe('Reason 2'); + }); + + it('should clear all addresses associated with a feed', () => { + service.addAddresses('FeedA', [ + { address: '0xaaa', reason: 'A', severity: 'low', flaggedAt: '' }, + ]); + service.addAddresses('FeedB', [ + { address: '0xbbb', reason: 'B', severity: 'low', flaggedAt: '' }, + ]); + + expect(service.getFlaggedAddresses().length).toBe(2); + + service.clearFeed('FeedA'); + + expect(service.matchAddress('0xaaa').isMatched).toBe(false); + expect(service.matchAddress('0xbbb').isMatched).toBe(true); + expect(service.getFlaggedAddresses().length).toBe(1); + }); + }); + + describe('Transaction Checking and Alert Generation', () => { + beforeEach(() => { + service.addAddresses('ThreatFeed', [ + { + address: '0xbadaddress', + reason: 'Exploiter contract', + severity: 'critical', + flaggedAt: '2026-06-21T00:00:00.000Z', + }, + ]); + }); + + it('should detect a flagged address in the "from" field', () => { + const tx = { from: '0xbadaddress', to: '0xsafeaddress', hash: 'tx-001' }; + const alert = service.checkTransaction(tx); + + expect(alert).not.toBeNull(); + expect(alert?.severity).toBe('critical'); + expect(alert?.metadata.matchedAddress).toBe('0xbadaddress'); + expect(alert?.metadata.feedName).toBe('ThreatFeed'); + expect(alert?.metadata.transactionDetails?.hash).toBe('tx-001'); + expect(alert?.metadata.transactionDetails?.field).toBe('from'); + }); + + it('should detect a flagged address in the "to" field', () => { + const tx = { from: '0xsafeaddress', to: '0xbadaddress', hash: 'tx-002' }; + const alert = service.checkTransaction(tx); + + expect(alert).not.toBeNull(); + expect(alert?.metadata.matchedAddress).toBe('0xbadaddress'); + expect(alert?.metadata.transactionDetails?.field).toBe('to'); + }); + + it('should detect a flagged address in the "contractAddress" field', () => { + const tx = { + from: '0xsafe1', + to: '0xsafe2', + contractAddress: '0xbadaddress', + hash: 'tx-003', + }; + const alert = service.checkTransaction(tx); + + expect(alert).not.toBeNull(); + expect(alert?.metadata.matchedAddress).toBe('0xbadaddress'); + expect(alert?.metadata.transactionDetails?.field).toBe('contractAddress'); + }); + + it('should return null if no flagged addresses are involved', () => { + const tx = { from: '0xsafe1', to: '0xsafe2', contractAddress: '0xsafe3' }; + const alert = service.checkTransaction(tx); + + expect(alert).toBeNull(); + }); + + it('should dispatch generated alerts to subscribers', () => { + const alertList: MaliciousAddressAlert[] = []; + const unsubscribe = service.onAlert(alert => { + alertList.push(alert); + }); + + const tx = { from: '0xbadaddress', to: '0xsafeaddress' }; + service.checkTransaction(tx); + + expect(alertList.length).toBe(1); + expect(alertList[0].metadata.matchedAddress).toBe('0xbadaddress'); + + // Test unsubscribe + unsubscribe(); + service.checkTransaction(tx); + expect(alertList.length).toBe(1); // Length should not increase + }); + }); +}); diff --git a/src/modules/detection/malicious-addresses/malicious-addresses.service.ts b/src/modules/detection/malicious-addresses/malicious-addresses.service.ts new file mode 100644 index 0000000..e4cd704 --- /dev/null +++ b/src/modules/detection/malicious-addresses/malicious-addresses.service.ts @@ -0,0 +1,200 @@ +import { + MaliciousAddressRecord, + AddressMatchResult, + MaliciousAddressAlert, + FeedUpdate, +} from './interfaces/malicious-addresses.interface'; + +/** + * Service to detect and alert on interactions with known malicious addresses. + * Supports real-time address matching, feed updates, and subscriber alerts. + */ +export class MaliciousAddressesService { + private addressRecords = new Map(); + private alertCallbacks: Array<(alert: MaliciousAddressAlert) => void> = []; + + constructor(useDefaults = true) { + if (useDefaults) { + this.loadDefaultMaliciousAddresses(); + } + } + + /** + * Helper to normalize blockchain addresses for comparison. + * EVM/Hex addresses (starting with 0x) are case-insensitive, so we lowercase them. + * Stellar and other addresses are trimmed. + */ + private normalizeAddress(address: string): string { + const trimmed = address.trim(); + if (trimmed.toLowerCase().startsWith('0x')) { + return trimmed.toLowerCase(); + } + return trimmed; + } + + /** + * Initialize the service with some known malicious default addresses. + */ + private loadDefaultMaliciousAddresses(): void { + const defaults: MaliciousAddressRecord[] = [ + { + address: '0x95222290dd7278aa3ddd389cc1e1d165cc4bafe5', + feedName: 'PhishGuard', + reason: 'Known EVM wallet drainer involved in multiple phishing campaigns', + severity: 'critical', + flaggedAt: new Date().toISOString(), + }, + { + address: 'GA5Z3J2ABCDEFGHIJKLMNO1234567890STUVWXYZ', + feedName: 'StellarThreatFeed', + reason: 'Malicious Stellar account associated with fake asset distribution', + severity: 'high', + flaggedAt: new Date().toISOString(), + }, + ]; + + for (const record of defaults) { + this.addressRecords.set(this.normalizeAddress(record.address), record); + } + } + + /** + * Check if a single address matches any flagged address. + */ + public matchAddress(address: string): AddressMatchResult { + if (!address) { + return { isMatched: false, address: '' }; + } + const normalized = this.normalizeAddress(address); + const matchedRecord = this.addressRecords.get(normalized); + + return { + isMatched: !!matchedRecord, + address, + matchedRecord, + }; + } + + /** + * Batch check multiple addresses. + */ + public checkAddresses(addresses: string[]): AddressMatchResult[] { + return addresses.map(addr => this.matchAddress(addr)); + } + + /** + * Scan a transaction object's standard fields (from, to, contractAddress) + * for interactions with malicious addresses. If found, generates and emits an alert. + */ + public checkTransaction(tx: { + from?: string; + to?: string; + contractAddress?: string; + hash?: string; + network?: string; + [key: string]: unknown; + }): MaliciousAddressAlert | null { + const addressesToCheck = [ + { field: 'from', value: tx.from }, + { field: 'to', value: tx.to }, + { field: 'contractAddress', value: tx.contractAddress }, + ].filter((item): item is { field: string; value: string } => !!item.value); + + for (const { field, value } of addressesToCheck) { + const match = this.matchAddress(value); + if (match.isMatched && match.matchedRecord) { + const record = match.matchedRecord; + const alert: MaliciousAddressAlert = { + id: `alert-ma-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + title: 'Malicious Address Interaction Detected', + description: `Transaction interacts with flagged address ${value} (${field}) from feed ${record.feedName}. Reason: ${record.reason}`, + severity: record.severity, + timestamp: new Date().toISOString(), + metadata: { + matchedAddress: value, + feedName: record.feedName, + reason: record.reason, + transactionDetails: { + field, + hash: tx.hash, + network: tx.network, + ...tx, + }, + }, + }; + + this.emitAlert(alert); + return alert; + } + } + + return null; + } + + /** + * Sync/update a feed. Clears old records for this feed and replaces them with new ones. + */ + public updateFeed(feedUpdate: FeedUpdate): void { + // Clear existing records from this feed + this.clearFeed(feedUpdate.feedName); + + // Add the new records + this.addAddresses(feedUpdate.feedName, feedUpdate.addresses); + } + + /** + * Add new flagged addresses under a specific feed. + */ + public addAddresses(feedName: string, records: Omit[]): void { + for (const record of records) { + const normalized = this.normalizeAddress(record.address); + this.addressRecords.set(normalized, { + ...record, + feedName, + }); + } + } + + /** + * Clear all flagged addresses associated with a specific feed. + */ + public clearFeed(feedName: string): void { + for (const [key, record] of this.addressRecords.entries()) { + if (record.feedName === feedName) { + this.addressRecords.delete(key); + } + } + } + + /** + * Returns all currently stored malicious address records. + */ + public getFlaggedAddresses(): MaliciousAddressRecord[] { + return Array.from(this.addressRecords.values()); + } + + /** + * Subscribe to alerts generated by this service. + * Returns an unsubscribe function. + */ + public onAlert(callback: (alert: MaliciousAddressAlert) => void): () => void { + this.alertCallbacks.push(callback); + return () => { + this.alertCallbacks = this.alertCallbacks.filter(cb => cb !== callback); + }; + } + + /** + * Emit an alert to all registered subscribers. + */ + private emitAlert(alert: MaliciousAddressAlert): void { + for (const callback of this.alertCallbacks) { + try { + callback(alert); + } catch (error) { + // Prevent one faulty callback from aborting others + console.error('Error in malicious address alert callback:', error); + } + } + } +}