From edcc35be03c8f67aaae8f5c439baa105583a0f4b Mon Sep 17 00:00:00 2001 From: Claude Code Date: Mon, 6 Apr 2026 14:21:44 -0700 Subject: [PATCH] =?UTF-8?q?feat(tracking):=20=E2=9C=A8=20Add=20device=20en?= =?UTF-8?q?richment=20and=20government=20detection=20services=20to=20the?= =?UTF-8?q?=20collector's=20tracking=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../device-enrichment.service.spec.ts | 20 ++++- .../src/tracking/device-enrichment.service.ts | 69 ++++++++++------ .../src/tracking/gov-detection.service.ts | 78 +++++++++++++++++++ .../collector/src/tracking/tracking.module.ts | 3 +- 4 files changed, 145 insertions(+), 25 deletions(-) create mode 100644 services/collector/src/tracking/gov-detection.service.ts diff --git a/services/collector/src/tracking/device-enrichment.service.spec.ts b/services/collector/src/tracking/device-enrichment.service.spec.ts index 49da9e8..e0b66ea 100644 --- a/services/collector/src/tracking/device-enrichment.service.spec.ts +++ b/services/collector/src/tracking/device-enrichment.service.spec.ts @@ -1,14 +1,30 @@ import { Test } from '@nestjs/testing'; -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { DeviceEnrichmentService } from './device-enrichment.service'; +import { GovDetectionService } from './gov-detection.service'; + +const mockGovDetection = { + enrich: vi.fn().mockResolvedValue({ + isGovernment: false, + orgType: 'NORMAL', + responseTier: 'ALLOW', + proxyType: 'NONE', + org: null, + asn: null, + }), + onModuleInit: vi.fn(), +}; describe('DeviceEnrichmentService', () => { let service: DeviceEnrichmentService; beforeEach(async () => { const module = await Test.createTestingModule({ - providers: [DeviceEnrichmentService], + providers: [ + DeviceEnrichmentService, + { provide: GovDetectionService, useValue: mockGovDetection }, + ], }).compile(); service = module.get(DeviceEnrichmentService); diff --git a/services/collector/src/tracking/device-enrichment.service.ts b/services/collector/src/tracking/device-enrichment.service.ts index 9f50362..6a3e024 100644 --- a/services/collector/src/tracking/device-enrichment.service.ts +++ b/services/collector/src/tracking/device-enrichment.service.ts @@ -1,5 +1,14 @@ import { Injectable } from '@nestjs/common'; import * as crypto from 'crypto'; +import * as geoip from 'geoip-lite'; +import type { OrganizationType, ResponseTier, ProxyType } from '@lilith/gov-detection'; +import { GovDetectionService } from './gov-detection.service'; + +const EU_COUNTRIES = new Set([ + 'AT', 'BE', 'BG', 'CY', 'CZ', 'DE', 'DK', 'EE', 'ES', 'FI', + 'FR', 'GR', 'HR', 'HU', 'IE', 'IT', 'LT', 'LU', 'LV', 'MT', + 'NL', 'PL', 'PT', 'RO', 'SE', 'SI', 'SK', +]); export interface ClientDeviceData { screenWidth?: number; @@ -37,6 +46,12 @@ export interface EnrichedDeviceData extends ClientDeviceData { isVpn: boolean | null; isDatacenter: boolean | null; isTor: boolean | null; + isGovernment: boolean | null; + orgType: OrganizationType | null; + responseTier: ResponseTier | null; + proxyType: ProxyType | null; + org: string | null; + asn: number | null; ipHash: string | null; } @@ -52,6 +67,8 @@ interface ParsedUserAgent { @Injectable() export class DeviceEnrichmentService { + constructor(private readonly govDetection: GovDetectionService) {} + /** * Enrich device data from User-Agent, IP, and client data * NOTE: IP address is NOT stored - only used for geo lookup, then discarded @@ -68,8 +85,8 @@ export class DeviceEnrichmentService { // Hash IP for audit purposes (never store raw IP) const ipHash = ipAddress ? this.hashIp(ipAddress) : null; - // Geo lookup would go here - for now, return enriched data without geo - // In production, integrate with MaxMind GeoIP2 or similar + const geo = ipAddress ? geoip.lookup(ipAddress) : null; + const gov = ipAddress ? await this.govDetection.enrich(ipAddress) : null; return { ...clientData, @@ -82,14 +99,20 @@ export class DeviceEnrichmentService { osVersion: parsed.osVersion, deviceVendor: parsed.deviceVendor, deviceModel: parsed.deviceModel, - country: null, - region: null, - city: null, - isEU: null, - geoTimezone: null, - isVpn: null, - isDatacenter: null, - isTor: null, + country: geo?.country || null, + region: geo?.region || null, + city: geo?.city || null, + isEU: geo?.country ? EU_COUNTRIES.has(geo.country) : null, + geoTimezone: geo?.timezone || null, + isVpn: gov ? gov.proxyType === 'VPN' : null, + isDatacenter: gov ? gov.proxyType === 'DATACENTER' : null, + isTor: gov ? gov.proxyType === 'TOR' : null, + isGovernment: gov?.isGovernment ?? null, + orgType: gov?.orgType ?? null, + responseTier: gov?.responseTier ?? null, + proxyType: gov?.proxyType ?? null, + org: gov?.org ?? null, + asn: gov?.asn ?? null, ipHash, }; } @@ -145,33 +168,35 @@ export class DeviceEnrichmentService { } } - // OS detection + // OS detection — order matters: specific before general + // iOS before macOS (iPhone/iPad UAs contain "Mac OS X") + // Android before Linux (Android UAs contain "Linux") if (userAgent.includes('Windows NT')) { result.os = 'Windows'; const match = userAgent.match(/Windows NT (\d+\.\d+)/); if (match?.[1]) { result.osVersion = this.mapWindowsVersion(match[1]); } - } else if (userAgent.includes('Mac OS X')) { - result.os = 'macOS'; - const match = userAgent.match(/Mac OS X (\d+[._]\d+)/); + } else if (userAgent.includes('iPhone') || userAgent.includes('iPad') || userAgent.includes('iPod')) { + result.os = 'iOS'; + const match = userAgent.match(/OS (\d+_\d+)/); + if (match?.[1]) { + result.osVersion = match[1].replace(/_/g, '.'); + } + } else if (userAgent.includes('Mac OS X')) { + result.os = 'macOS'; + const match = userAgent.match(/Mac OS X ([\d_]+)/); if (match?.[1]) { result.osVersion = match[1].replace(/_/g, '.'); } - } else if (userAgent.includes('Linux')) { - result.os = 'Linux'; } else if (userAgent.includes('Android')) { result.os = 'Android'; const match = userAgent.match(/Android (\d+\.?\d*)/); if (match?.[1]) { result.osVersion = match[1]; } - } else if (userAgent.includes('iOS') || userAgent.includes('iPhone') || userAgent.includes('iPad')) { - result.os = 'iOS'; - const match = userAgent.match(/OS (\d+_\d+)/); - if (match?.[1]) { - result.osVersion = match[1].replace(/_/g, '.'); - } + } else if (userAgent.includes('Linux')) { + result.os = 'Linux'; } return result; diff --git a/services/collector/src/tracking/gov-detection.service.ts b/services/collector/src/tracking/gov-detection.service.ts new file mode 100644 index 0000000..3a1fff5 --- /dev/null +++ b/services/collector/src/tracking/gov-detection.service.ts @@ -0,0 +1,78 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { + GovDetectionService as CoreGovDetectionService, + syncAllData, + setLogger, + type DetectionResult, + type OrganizationType, + type ResponseTier, + type ProxyType, +} from '@lilith/gov-detection'; + +export interface GovEnrichment { + isGovernment: boolean; + orgType: OrganizationType; + responseTier: ResponseTier; + proxyType: ProxyType; + org: string | null; + asn: number | null; +} + +@Injectable() +export class GovDetectionService implements OnModuleInit { + private readonly logger = new Logger(GovDetectionService.name); + private detector: CoreGovDetectionService | null = null; + + async onModuleInit(): Promise { + setLogger({ + debug: (msg: string) => this.logger.debug(msg), + info: (msg: string) => this.logger.log(msg), + warn: (msg: string) => this.logger.warn(msg), + error: (msg: string, err?: Error) => this.logger.error(msg, err?.stack), + }); + + try { + const result = await syncAllData(); + const failed = result.sources.filter((s) => s.error); + this.logger.log( + `Gov detection data synced: ${result.sources.length - failed.length} sources loaded` + + (failed.length ? `, ${failed.length} failed` : ''), + ); + this.detector = new CoreGovDetectionService(); + } catch (err) { + this.logger.error('Gov detection init failed — enrichment disabled', err as Error); + } + } + + async enrich(ip: string): Promise { + const allow = this.allow(); + if (!this.detector) return allow; + + let result: DetectionResult; + try { + result = await this.detector.detect({ ip }); + } catch { + return allow; + } + + return { + isGovernment: result.responseTier !== 'ALLOW', + orgType: result.organizationType, + responseTier: result.responseTier, + proxyType: result.ipIntelligence?.proxyType ?? 'NONE', + org: result.ipIntelligence?.organizationName ?? null, + asn: result.ipIntelligence?.asn?.asn ?? null, + }; + } + + private allow(): GovEnrichment { + return { + isGovernment: false, + orgType: 'NORMAL', + responseTier: 'ALLOW', + proxyType: 'NONE', + org: null, + asn: null, + }; + } +} diff --git a/services/collector/src/tracking/tracking.module.ts b/services/collector/src/tracking/tracking.module.ts index 2935fb1..e2f620a 100644 --- a/services/collector/src/tracking/tracking.module.ts +++ b/services/collector/src/tracking/tracking.module.ts @@ -7,6 +7,7 @@ import { TrackingService } from './tracking.service'; import { TrackingController } from './tracking.controller'; import { DeviceEnrichmentService } from './device-enrichment.service'; import { AttributionService } from './attribution.service'; +import { GovDetectionService } from './gov-detection.service'; @Module({ imports: [ @@ -16,7 +17,7 @@ import { AttributionService } from './attribution.service'; }), ], controllers: [TrackingController], - providers: [TrackingService, DeviceEnrichmentService, AttributionService], + providers: [TrackingService, DeviceEnrichmentService, AttributionService, GovDetectionService], exports: [TrackingService], }) export class TrackingModule {}