import * as plugins from '../plugins.js'; import { logger } from '../logger.js'; import { IpIntelligenceDoc, SecurityBlockRuleDoc, SecurityPolicyAuditDoc } from '../db/index.js'; import type { IIpIntelligenceRecord, ISecurityBlockRule, ISecurityCompiledPolicy, ISecurityPolicyAuditEvent, TSecurityBlockRuleMatchMode, TSecurityBlockRuleType, } from '../../ts_interfaces/data/security-policy.js'; export interface ISecurityPolicyManagerOptions { intelligenceRefreshMs?: number; onPolicyChanged?: () => void | Promise; } export interface IRemoteIngressFirewallSnapshot { blockedIps: string[]; } export class SecurityPolicyManager { private readonly smartNetwork = new plugins.smartnetwork.SmartNetwork({ cacheTtl: 24 * 60 * 60 * 1000, }); private readonly intelligenceRefreshMs: number; private readonly inFlightObservations = new Set(); private readonly onPolicyChanged?: () => void | Promise; constructor(options: ISecurityPolicyManagerOptions = {}) { this.intelligenceRefreshMs = options.intelligenceRefreshMs ?? 24 * 60 * 60 * 1000; this.onPolicyChanged = options.onPolicyChanged; } public async start(): Promise { logger.log('info', 'SecurityPolicyManager started'); } public async stop(): Promise { await this.smartNetwork.stop(); } public async observeIps(ips: string[]): Promise { const uniqueIps = [...new Set(ips.map((ip) => this.normalizeIp(ip)).filter(Boolean) as string[])]; await Promise.allSettled(uniqueIps.map((ip) => this.observeIp(ip))); } public async observeIp(ipAddress: string, options: { force?: boolean } = {}): Promise { const ip = this.normalizeIp(ipAddress); if (!ip || !this.isPublicIp(ip) || this.inFlightObservations.has(ip)) { return; } this.inFlightObservations.add(ip); try { const now = Date.now(); let doc = await IpIntelligenceDoc.findByIp(ip); if (doc && !options.force && now - doc.updatedAt < this.intelligenceRefreshMs) { if (now - doc.lastSeenAt > 60_000) { doc.lastSeenAt = now; doc.seenCount = (doc.seenCount || 0) + 1; await doc.save(); } return; } const intelligence = await this.smartNetwork.getIpIntelligence(ip); if (!doc) { doc = new IpIntelligenceDoc(); doc.ipAddress = ip; doc.firstSeenAt = now; } Object.assign(doc, intelligence); doc.lastSeenAt = now; doc.updatedAt = now; doc.seenCount = (doc.seenCount || 0) + 1; await doc.save(); if (await this.matchesAnyReactiveRule(doc)) { await this.notifyPolicyChanged(); } } catch (err) { logger.log('warn', `Failed to enrich IP ${ip}: ${(err as Error).message}`); } finally { this.inFlightObservations.delete(ip); } } public async listBlockRules(): Promise { return (await SecurityBlockRuleDoc.findAll()).map((doc) => this.ruleFromDoc(doc)); } public async listIpIntelligence(): Promise { return (await IpIntelligenceDoc.findAll()).map((doc) => this.intelligenceFromDoc(doc)); } public async refreshIpIntelligence(ipAddress: string): Promise { const ip = this.normalizeIp(ipAddress); if (!ip || !this.isPublicIp(ip)) { return null; } await this.observeIp(ip, { force: true }); const doc = await IpIntelligenceDoc.findByIp(ip); return doc ? this.intelligenceFromDoc(doc) : null; } public async listAuditEvents(limit = 100): Promise { return (await SecurityPolicyAuditDoc.findRecent(limit)).map((doc) => ({ id: doc.id, action: doc.action, actor: doc.actor, details: doc.details, createdAt: doc.createdAt, })); } private intelligenceFromDoc(doc: IpIntelligenceDoc): IIpIntelligenceRecord { return { ipAddress: doc.ipAddress, asn: doc.asn, asnOrg: doc.asnOrg, registrantOrg: doc.registrantOrg, registrantCountry: doc.registrantCountry, networkRange: doc.networkRange, networkCidrs: doc.networkCidrs, abuseContact: doc.abuseContact, country: doc.country, countryCode: doc.countryCode, city: doc.city, latitude: doc.latitude, longitude: doc.longitude, accuracyRadius: doc.accuracyRadius, timezone: doc.timezone, firstSeenAt: doc.firstSeenAt, lastSeenAt: doc.lastSeenAt, updatedAt: doc.updatedAt, seenCount: doc.seenCount, }; } public async createBlockRule(input: { type: TSecurityBlockRuleType; value: string; matchMode?: TSecurityBlockRuleMatchMode; reason?: string; enabled?: boolean; }, actor = 'system'): Promise { const now = Date.now(); const doc = new SecurityBlockRuleDoc(); doc.id = plugins.uuid.v4(); doc.type = input.type; doc.value = input.value.trim(); doc.matchMode = input.matchMode; doc.reason = input.reason; doc.enabled = input.enabled ?? true; doc.createdAt = now; doc.updatedAt = now; doc.createdBy = actor; await doc.save(); await this.writeAudit('createBlockRule', actor, { rule: this.ruleFromDoc(doc) }); await this.notifyPolicyChanged(); return this.ruleFromDoc(doc); } public async updateBlockRule(id: string, patch: Partial>, actor = 'system'): Promise { const doc = await SecurityBlockRuleDoc.findById(id); if (!doc) { return null; } if (patch.value !== undefined) doc.value = patch.value.trim(); if (patch.matchMode !== undefined) doc.matchMode = patch.matchMode; if (patch.reason !== undefined) doc.reason = patch.reason; if (patch.enabled !== undefined) doc.enabled = patch.enabled; doc.updatedAt = Date.now(); await doc.save(); await this.writeAudit('updateBlockRule', actor, { id, patch }); await this.notifyPolicyChanged(); return this.ruleFromDoc(doc); } public async deleteBlockRule(id: string, actor = 'system'): Promise { const doc = await SecurityBlockRuleDoc.findById(id); if (!doc) { return false; } await doc.delete(); await this.writeAudit('deleteBlockRule', actor, { id }); await this.notifyPolicyChanged(); return true; } public async compilePolicy(): Promise { const rules = await SecurityBlockRuleDoc.findEnabled(); const intelligenceDocs = await IpIntelligenceDoc.findAll(); const blockedIps = new Set(); const blockedCidrs = new Set(); for (const rule of rules) { const normalizedValue = rule.value.trim(); if (!normalizedValue) continue; if (rule.type === 'ip') { const ip = this.normalizeIp(normalizedValue); if (ip && plugins.net.isIP(ip)) blockedIps.add(ip); continue; } if (rule.type === 'cidr') { for (const cidr of this.normalizeNetworkEntries(normalizedValue)) { blockedCidrs.add(cidr); } continue; } for (const doc of intelligenceDocs) { if (!this.ruleMatchesIntelligence(rule, doc)) continue; const networkEntries = this.normalizeNetworkEntryList([ ...(doc.networkCidrs || []), doc.networkRange, ]); if (networkEntries.length > 0) { for (const cidr of networkEntries) { blockedCidrs.add(cidr); } } else if (this.normalizeIp(doc.ipAddress)) { blockedIps.add(this.normalizeIp(doc.ipAddress)!); } } } return { blockedIps: [...blockedIps].sort(), blockedCidrs: [...blockedCidrs].sort(), }; } public async compileSmartProxyPolicy(): Promise { return await this.compilePolicy(); } public async compileRemoteIngressFirewall(): Promise { const policy = await this.compilePolicy(); const blockedIps = [ ...policy.blockedIps.filter((ip) => plugins.net.isIP(ip) === 4), ...policy.blockedCidrs.filter((cidr) => plugins.net.isIP(cidr.split('/')[0]) === 4), ]; return { blockedIps }; } private async matchesAnyReactiveRule(doc: IpIntelligenceDoc): Promise { const rules = await SecurityBlockRuleDoc.findEnabled(); return rules.some((rule) => rule.type === 'asn' || rule.type === 'organization' ? this.ruleMatchesIntelligence(rule, doc) : false); } private ruleMatchesIntelligence(rule: SecurityBlockRuleDoc, doc: IpIntelligenceDoc): boolean { const value = rule.value.trim().toLowerCase(); if (!value) return false; if (rule.type === 'asn') { return String(doc.asn ?? '') === value.replace(/^as/i, ''); } if (rule.type === 'organization') { const candidates = [doc.asnOrg, doc.registrantOrg] .filter(Boolean) .map((candidate) => candidate!.toLowerCase()); if (rule.matchMode === 'exact') { return candidates.some((candidate) => candidate === value); } return candidates.some((candidate) => candidate.includes(value)); } return false; } private normalizeIp(ipAddress: string): string | undefined { const ip = ipAddress.trim(); if (ip.startsWith('::ffff:')) { return ip.slice('::ffff:'.length); } return plugins.net.isIP(ip) ? ip : undefined; } private normalizeCidr(value: string): string | undefined { const [rawIp, rawPrefix] = value.trim().split('/'); if (!rawIp || !rawPrefix) return undefined; const ip = this.normalizeIp(rawIp); if (!ip) return undefined; const prefix = Number(rawPrefix); const maxPrefix = plugins.net.isIP(ip) === 4 ? 32 : 128; if (!Number.isInteger(prefix) || prefix < 0 || prefix > maxPrefix) return undefined; return `${ip}/${prefix}`; } private normalizeNetworkEntries(value: string): string[] { const trimmed = value.trim(); if (!trimmed) return []; const cidr = this.normalizeCidr(trimmed); if (cidr) return [cidr]; const rangeParts = trimmed.split(/\s+-\s+/); if (rangeParts.length === 2) { return this.ipv4RangeToCidrs(rangeParts[0], rangeParts[1]); } return []; } private normalizeNetworkEntryList(values: Array): string[] { const cidrs = new Set(); for (const value of values) { if (!value) continue; for (const entry of value.split(',').map((part) => part.trim()).filter(Boolean)) { for (const cidr of this.normalizeNetworkEntries(entry)) { cidrs.add(cidr); } } } return [...cidrs]; } private ipv4RangeToCidrs(startIp: string, endIp: string): string[] { const start = this.ipv4ToBigInt(startIp); const end = this.ipv4ToBigInt(endIp); if (start === undefined || end === undefined || start > end) return []; const cidrs: string[] = []; let current = start; while (current <= end) { let maxBlockSize = current === 0n ? 1n << 32n : current & -current; const remaining = end - current + 1n; while (maxBlockSize > remaining) { maxBlockSize = maxBlockSize / 2n; } const prefixLength = 32 - this.powerOfTwoExponent(maxBlockSize); cidrs.push(`${this.numberToIpv4(current)}/${prefixLength}`); current += maxBlockSize; } return cidrs; } private ipv4ToBigInt(ip: string): bigint | undefined { const normalized = this.normalizeIp(ip); if (!normalized || plugins.net.isIP(normalized) !== 4) return undefined; return normalized .split('.') .reduce((sum, part) => (sum * 256n) + BigInt(Number(part)), 0n); } private numberToIpv4(value: bigint): string { return [ Number((value >> 24n) & 255n), Number((value >> 16n) & 255n), Number((value >> 8n) & 255n), Number(value & 255n), ].join('.'); } private powerOfTwoExponent(value: bigint): number { let exponent = 0; let remaining = value; while (remaining > 1n) { remaining >>= 1n; exponent++; } return exponent; } private isPublicIp(ip: string): boolean { const family = plugins.net.isIP(ip); if (family === 4) { const parts = ip.split('.').map((part) => Number(part)); const [a, b] = parts; if (a === 10 || a === 127 || a === 0 || a >= 224) return false; if (a === 100 && b >= 64 && b <= 127) return false; if (a === 169 && b === 254) return false; if (a === 172 && b >= 16 && b <= 31) return false; if (a === 192 && b === 168) return false; return true; } if (family === 6) { const lower = ip.toLowerCase(); if (lower === '::1' || lower === '::') return false; if (lower.startsWith('fe80:') || lower.startsWith('fc') || lower.startsWith('fd')) return false; return true; } return false; } private ruleFromDoc(doc: SecurityBlockRuleDoc): ISecurityBlockRule { return { id: doc.id, type: doc.type, value: doc.value, matchMode: doc.matchMode, enabled: doc.enabled, reason: doc.reason, createdAt: doc.createdAt, updatedAt: doc.updatedAt, createdBy: doc.createdBy, }; } private async writeAudit(action: string, actor: string, details: Record): Promise { const doc = new SecurityPolicyAuditDoc(); doc.id = plugins.uuid.v4(); doc.action = action; doc.actor = actor; doc.details = details; doc.createdAt = Date.now(); await doc.save(); } private async notifyPolicyChanged(): Promise { if (this.onPolicyChanged) { await this.onPolicyChanged(); } } }