316 lines
10 KiB
TypeScript
316 lines
10 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,
|
||
|
|
TSecurityBlockRuleMatchMode,
|
||
|
|
TSecurityBlockRuleType,
|
||
|
|
} from '../../ts_interfaces/data/security-policy.js';
|
||
|
|
|
||
|
|
export interface ISecurityPolicyManagerOptions {
|
||
|
|
intelligenceRefreshMs?: number;
|
||
|
|
onPolicyChanged?: () => void | Promise<void>;
|
||
|
|
}
|
||
|
|
|
||
|
|
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<string>();
|
||
|
|
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> {
|
||
|
|
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 async observeIp(ipAddress: string): Promise<void> {
|
||
|
|
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 && 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<ISecurityBlockRule[]> {
|
||
|
|
return (await SecurityBlockRuleDoc.findAll()).map((doc) => this.ruleFromDoc(doc));
|
||
|
|
}
|
||
|
|
|
||
|
|
public async listIpIntelligence(): Promise<IIpIntelligenceRecord[]> {
|
||
|
|
return (await IpIntelligenceDoc.findAll()).map((doc) => ({
|
||
|
|
ipAddress: doc.ipAddress,
|
||
|
|
asn: doc.asn,
|
||
|
|
asnOrg: doc.asnOrg,
|
||
|
|
registrantOrg: doc.registrantOrg,
|
||
|
|
registrantCountry: doc.registrantCountry,
|
||
|
|
networkRange: doc.networkRange,
|
||
|
|
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') {
|
||
|
|
const cidr = this.normalizeCidr(normalizedValue);
|
||
|
|
if (cidr) blockedCidrs.add(cidr);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
for (const doc of intelligenceDocs) {
|
||
|
|
if (!this.ruleMatchesIntelligence(rule, doc)) continue;
|
||
|
|
const cidr = this.normalizeCidr(doc.networkRange || '');
|
||
|
|
if (cidr) {
|
||
|
|
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 | undefined> {
|
||
|
|
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.length > 0 ? { blockedIps } : undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
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 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();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|