feat(tracking): Add device enrichment and government detection services to the collector's tracking system

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-04-06 14:21:44 -07:00
parent ced62782db
commit edcc35be03
4 changed files with 145 additions and 25 deletions

View file

@ -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>(DeviceEnrichmentService);

View file

@ -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;

View file

@ -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<void> {
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<GovEnrichment> {
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,
};
}
}

View file

@ -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 {}