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:
parent
ced62782db
commit
edcc35be03
4 changed files with 145 additions and 25 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
78
services/collector/src/tracking/gov-detection.service.ts
Normal file
78
services/collector/src/tracking/gov-detection.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue