531 lines
17 KiB
TypeScript
531 lines
17 KiB
TypeScript
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<void>;
|
|
}
|
|
|
|
export interface IRemoteIngressFirewallSnapshot {
|
|
blockedIps: string[];
|
|
}
|
|
|
|
const OBSERVED_IP_QUEUE_LIMIT = 512;
|
|
const OBSERVED_IP_BATCH_LIMIT = 20;
|
|
const OBSERVED_IP_QUEUE_CONCURRENCY = 2;
|
|
const OBSERVED_IP_REQUEUE_THROTTLE_MS = 60_000;
|
|
|
|
export class SecurityPolicyManager {
|
|
private readonly smartNetwork = new plugins.smartnetwork.SmartNetwork({
|
|
cacheTtl: 24 * 60 * 60 * 1000,
|
|
ipIntelligenceTimeout: 5_000,
|
|
});
|
|
private readonly intelligenceRefreshMs: number;
|
|
private readonly inFlightObservations = new Map<string, Promise<void>>();
|
|
private readonly queuedObservations = new Set<string>();
|
|
private readonly observationQueue: string[] = [];
|
|
private readonly lastQueuedAt = new Map<string, number>();
|
|
private activeQueuedObservations = 0;
|
|
private queueDrainScheduled = false;
|
|
private isStopping = false;
|
|
private readonly onPolicyChanged?: () => void | Promise<void>;
|
|
|
|
constructor(options: ISecurityPolicyManagerOptions = {}) {
|
|
this.intelligenceRefreshMs = options.intelligenceRefreshMs ?? 24 * 60 * 60 * 1000;
|
|
this.onPolicyChanged = options.onPolicyChanged;
|
|
}
|
|
|
|
public async start(): Promise<void> {
|
|
logger.log('info', 'SecurityPolicyManager started');
|
|
}
|
|
|
|
public async stop(): Promise<void> {
|
|
this.isStopping = true;
|
|
this.observationQueue.length = 0;
|
|
this.queuedObservations.clear();
|
|
await this.smartNetwork.stop();
|
|
}
|
|
|
|
public async observeIps(ips: string[]): Promise<void> {
|
|
const uniqueIps = [...new Set(ips.map((ip) => this.normalizeIp(ip)).filter(Boolean) as string[])];
|
|
await Promise.allSettled(uniqueIps.map((ip) => this.observeIp(ip)));
|
|
}
|
|
|
|
public queueObservedIps(ips: string[]): void {
|
|
if (this.isStopping) return;
|
|
|
|
const now = Date.now();
|
|
const uniqueIps = [...new Set(ips.map((ip) => this.normalizeIp(ip)).filter(Boolean) as string[])];
|
|
|
|
for (const ip of uniqueIps.slice(0, OBSERVED_IP_BATCH_LIMIT)) {
|
|
if (!this.isPublicIp(ip)) continue;
|
|
if (this.inFlightObservations.has(ip) || this.queuedObservations.has(ip)) continue;
|
|
|
|
const lastQueuedAt = this.lastQueuedAt.get(ip);
|
|
if (lastQueuedAt && now - lastQueuedAt < OBSERVED_IP_REQUEUE_THROTTLE_MS) continue;
|
|
|
|
if (this.observationQueue.length >= OBSERVED_IP_QUEUE_LIMIT) {
|
|
const droppedIp = this.observationQueue.shift();
|
|
if (droppedIp) this.queuedObservations.delete(droppedIp);
|
|
}
|
|
|
|
this.observationQueue.push(ip);
|
|
this.queuedObservations.add(ip);
|
|
this.lastQueuedAt.set(ip, now);
|
|
}
|
|
|
|
this.pruneQueuedIpMemory(now);
|
|
this.scheduleQueueDrain();
|
|
}
|
|
|
|
public async observeIp(ipAddress: string, options: { force?: boolean } = {}): Promise<void> {
|
|
const ip = this.normalizeIp(ipAddress);
|
|
if (!ip || !this.isPublicIp(ip)) {
|
|
return;
|
|
}
|
|
|
|
const existingObservation = this.inFlightObservations.get(ip);
|
|
if (existingObservation) {
|
|
await existingObservation;
|
|
if (!options.force) return;
|
|
}
|
|
|
|
const observationPromise = this.performObserveIp(ip, options).finally(() => {
|
|
if (this.inFlightObservations.get(ip) === observationPromise) {
|
|
this.inFlightObservations.delete(ip);
|
|
}
|
|
});
|
|
this.inFlightObservations.set(ip, observationPromise);
|
|
await observationPromise;
|
|
}
|
|
|
|
private async performObserveIp(ip: string, options: { force?: boolean } = {}): Promise<void> {
|
|
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}`);
|
|
}
|
|
}
|
|
|
|
public async listBlockRules(): Promise<ISecurityBlockRule[]> {
|
|
return (await SecurityBlockRuleDoc.findAll()).map((doc) => this.ruleFromDoc(doc));
|
|
}
|
|
|
|
public async listIpIntelligence(options: { ipAddresses?: string[]; limit?: number } = {}): Promise<IIpIntelligenceRecord[]> {
|
|
const limit = Number.isInteger(options.limit) && options.limit! > 0
|
|
? Math.min(options.limit!, 500)
|
|
: undefined;
|
|
|
|
let docs: IpIntelligenceDoc[];
|
|
if (options.ipAddresses?.length) {
|
|
const ips = [...new Set(options.ipAddresses.map((ip) => this.normalizeIp(ip)).filter(Boolean) as string[])];
|
|
const results = await Promise.all(ips.map((ip) => IpIntelligenceDoc.findByIp(ip)));
|
|
docs = results.filter(Boolean) as IpIntelligenceDoc[];
|
|
} else {
|
|
docs = await IpIntelligenceDoc.findAll();
|
|
}
|
|
|
|
const sortedDocs = docs.sort((a, b) => (b.lastSeenAt || 0) - (a.lastSeenAt || 0));
|
|
return (limit ? sortedDocs.slice(0, limit) : sortedDocs).map((doc) => this.intelligenceFromDoc(doc));
|
|
}
|
|
|
|
public async refreshIpIntelligence(ipAddress: string): Promise<IIpIntelligenceRecord | null> {
|
|
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;
|
|
}
|
|
|
|
private scheduleQueueDrain(): void {
|
|
if (this.queueDrainScheduled || this.isStopping) return;
|
|
this.queueDrainScheduled = true;
|
|
setTimeout(() => {
|
|
this.queueDrainScheduled = false;
|
|
this.drainObservationQueue();
|
|
}, 0);
|
|
}
|
|
|
|
private drainObservationQueue(): void {
|
|
if (this.isStopping) return;
|
|
|
|
while (
|
|
this.activeQueuedObservations < OBSERVED_IP_QUEUE_CONCURRENCY &&
|
|
this.observationQueue.length > 0
|
|
) {
|
|
const ip = this.observationQueue.shift()!;
|
|
this.queuedObservations.delete(ip);
|
|
this.activeQueuedObservations++;
|
|
void this.observeIp(ip)
|
|
.catch(() => undefined)
|
|
.finally(() => {
|
|
this.activeQueuedObservations--;
|
|
if (this.observationQueue.length > 0) {
|
|
this.scheduleQueueDrain();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
private pruneQueuedIpMemory(now: number): void {
|
|
if (this.lastQueuedAt.size <= OBSERVED_IP_QUEUE_LIMIT * 2) return;
|
|
for (const [ip, lastQueuedAt] of this.lastQueuedAt) {
|
|
if (now - lastQueuedAt > OBSERVED_IP_REQUEUE_THROTTLE_MS * 2) {
|
|
this.lastQueuedAt.delete(ip);
|
|
}
|
|
}
|
|
}
|
|
|
|
public async listAuditEvents(limit = 100): Promise<ISecurityPolicyAuditEvent[]> {
|
|
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<ISecurityBlockRule> {
|
|
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<Pick<ISecurityBlockRule, 'value' | 'matchMode' | 'reason' | 'enabled'>>, actor = 'system'): Promise<ISecurityBlockRule | null> {
|
|
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<boolean> {
|
|
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<ISecurityCompiledPolicy> {
|
|
const rules = await SecurityBlockRuleDoc.findEnabled();
|
|
const intelligenceDocs = await IpIntelligenceDoc.findAll();
|
|
const blockedIps = new Set<string>();
|
|
const blockedCidrs = new Set<string>();
|
|
|
|
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<ISecurityCompiledPolicy> {
|
|
return await this.compilePolicy();
|
|
}
|
|
|
|
public async compileRemoteIngressFirewall(): Promise<IRemoteIngressFirewallSnapshot> {
|
|
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<boolean> {
|
|
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 | null | undefined>): string[] {
|
|
const cidrs = new Set<string>();
|
|
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<string, unknown>): Promise<void> {
|
|
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<void> {
|
|
if (this.onPolicyChanged) {
|
|
await this.onPolicyChanged();
|
|
}
|
|
}
|
|
}
|