diff --git a/changelog.md b/changelog.md index 7028ac8..9d5c611 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2026-04-26 - 13.25.0 - feat(security) +compile network ranges and CIDR arrays into edge firewall policies + +- add support for storing intelligence network CIDR arrays alongside single network ranges +- convert start-end IPv4 ranges into CIDR blocks when compiling security policies +- always return an explicit remote ingress firewall snapshot with a blockedIps array +- add tests covering range normalization, ASN-derived CIDRs, and empty firewall snapshots + ## 2026-04-26 - 13.24.0 - feat(security) add security policy management and IP intelligence operations to the ops UI diff --git a/package.json b/package.json index b41d069..4c5934b 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "@push.rocks/smartmetrics": "^3.0.3", "@push.rocks/smartmigration": "1.2.0", "@push.rocks/smartmta": "^5.3.3", - "@push.rocks/smartnetwork": "^4.6.0", + "@push.rocks/smartnetwork": "^4.7.0", "@push.rocks/smartpath": "^6.0.0", "@push.rocks/smartpromise": "^4.2.3", "@push.rocks/smartproxy": "^27.9.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9154efa..6d59283 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,8 +72,8 @@ importers: specifier: ^5.3.3 version: 5.3.3 '@push.rocks/smartnetwork': - specifier: ^4.6.0 - version: 4.6.0 + specifier: ^4.7.0 + version: 4.7.0 '@push.rocks/smartpath': specifier: ^6.0.0 version: 6.0.0 @@ -1257,8 +1257,8 @@ packages: '@push.rocks/smartmustache@3.0.2': resolution: {integrity: sha512-G3LyRXoJhyM+iQhkvP/MR/2WYMvC9U7zc2J44JxUM5tPdkQ+o3++FbfRtnZj6rz5X/A7q03//vsxPitVQwoi2Q==} - '@push.rocks/smartnetwork@4.6.0': - resolution: {integrity: sha512-ubaO/Qp8r30A+qwk33M/0+nQi+o8gNHEI9zq3jv1MwqiLxhiV1hnbr4CL9AUcvs4EhwUBiw0EswKjCJROwDqvQ==} + '@push.rocks/smartnetwork@4.7.0': + resolution: {integrity: sha512-WZ46pJlklDRcw1AqkyyBhmGSNSK3i7IYM9D9vcVJOUhlLmgUSai8o1NbpWlb7HvOkp1IhQ7iZeuJV2JiWLtl1g==} '@push.rocks/smartnftables@1.1.0': resolution: {integrity: sha512-7JNzerlW20HEl2wKMBIHltwneCQRpXiD2lJkXZZc02ctnfjgFejXVDIeWomhPx6PZ0Z6zmqdF6rrFDtDHyqqfA==} @@ -5163,7 +5163,7 @@ snapshots: '@push.rocks/smartjson': 6.0.0 '@push.rocks/smartlog': 3.2.2 '@push.rocks/smartmongo': 5.1.1(socks@2.8.7) - '@push.rocks/smartnetwork': 4.6.0 + '@push.rocks/smartnetwork': 4.7.0 '@push.rocks/smartpath': 6.0.0 '@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartrequest': 5.0.1 @@ -5966,7 +5966,7 @@ snapshots: '@push.rocks/smartdelay': 3.0.5 '@push.rocks/smartdns': 7.9.0 '@push.rocks/smartlog': 3.2.2 - '@push.rocks/smartnetwork': 4.6.0 + '@push.rocks/smartnetwork': 4.7.0 '@push.rocks/smartstring': 4.1.0 '@push.rocks/smarttime': 4.2.3 '@push.rocks/smartunique': 3.0.9 @@ -6433,7 +6433,7 @@ snapshots: dependencies: handlebars: 4.7.9 - '@push.rocks/smartnetwork@4.6.0': + '@push.rocks/smartnetwork@4.7.0': dependencies: '@push.rocks/smartdns': 7.9.0 '@push.rocks/smartrust': 1.3.2 @@ -6499,7 +6499,7 @@ snapshots: '@push.rocks/smartdelay': 3.0.5 '@push.rocks/smartfs': 1.5.0 '@push.rocks/smartjimp': 1.2.0 - '@push.rocks/smartnetwork': 4.6.0 + '@push.rocks/smartnetwork': 4.7.0 '@push.rocks/smartpath': 6.0.0 '@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartpuppeteer': 2.0.5(typescript@6.0.2) diff --git a/test/test.security-policy-manager.node.ts b/test/test.security-policy-manager.node.ts new file mode 100644 index 0000000..962333b --- /dev/null +++ b/test/test.security-policy-manager.node.ts @@ -0,0 +1,129 @@ +import { tap, expect } from '@git.zone/tstest/tapbundle'; +import * as plugins from '../ts/plugins.js'; +import { DcRouterDb, IpIntelligenceDoc, SecurityBlockRuleDoc, SecurityPolicyAuditDoc } from '../ts/db/index.js'; +import { SecurityPolicyManager } from '../ts/security/index.js'; + +const createTestDb = async () => { + const storagePath = plugins.path.join( + plugins.os.tmpdir(), + `dcrouter-security-policy-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + + DcRouterDb.resetInstance(); + const db = DcRouterDb.getInstance({ + storagePath, + dbName: `dcrouter-security-policy-${Date.now()}-${Math.random().toString(16).slice(2)}`, + }); + await db.start(); + await db.getDb().mongoDb.createCollection('__test_init'); + + return { + async cleanup() { + await db.stop(); + DcRouterDb.resetInstance(); + await plugins.fs.promises.rm(storagePath, { recursive: true, force: true }); + }, + }; +}; + +const testDbPromise = createTestDb(); + +const clearTestState = async () => { + for (const rule of await SecurityBlockRuleDoc.findAll()) { + await rule.delete(); + } + for (const record of await IpIntelligenceDoc.findAll()) { + await record.delete(); + } + for (const event of await SecurityPolicyAuditDoc.findRecent(1000)) { + await event.delete(); + } +}; + +tap.test('SecurityPolicyManager compiles start-end CIDR rules for edge firewall snapshots', async () => { + await testDbPromise; + await clearTestState(); + const manager = new SecurityPolicyManager(); + + await manager.createBlockRule({ + type: 'cidr', + value: '203.0.113.0 - 203.0.113.255', + reason: 'test range', + }); + + const policy = await manager.compilePolicy(); + expect(policy.blockedCidrs).toEqual(['203.0.113.0/24']); + + const firewall = await manager.compileRemoteIngressFirewall(); + expect(firewall.blockedIps).toEqual(['203.0.113.0/24']); +}); + +tap.test('SecurityPolicyManager compiles intelligence network ranges for ASN rules', async () => { + await testDbPromise; + await clearTestState(); + const manager = new SecurityPolicyManager(); + + const intelligenceDoc = new IpIntelligenceDoc(); + intelligenceDoc.ipAddress = '198.51.100.23'; + intelligenceDoc.asn = 64500; + intelligenceDoc.asnOrg = 'Example Network'; + intelligenceDoc.networkRange = '198.51.100.0 - 198.51.100.127'; + intelligenceDoc.firstSeenAt = Date.now(); + intelligenceDoc.lastSeenAt = Date.now(); + intelligenceDoc.updatedAt = Date.now(); + intelligenceDoc.seenCount = 1; + await intelligenceDoc.save(); + + await manager.createBlockRule({ + type: 'asn', + value: 'AS64500', + reason: 'test asn range', + }); + + const policy = await manager.compilePolicy(); + expect(policy.blockedCidrs).toEqual(['198.51.100.0/25']); +}); + +tap.test('SecurityPolicyManager compiles intelligence CIDR arrays for ASN rules', async () => { + await testDbPromise; + await clearTestState(); + const manager = new SecurityPolicyManager(); + + const intelligenceDoc = new IpIntelligenceDoc(); + intelligenceDoc.ipAddress = '198.51.100.130'; + intelligenceDoc.asn = 64501; + intelligenceDoc.asnOrg = 'Example Split Network'; + intelligenceDoc.networkRange = null; + intelligenceDoc.networkCidrs = ['198.51.100.128/25', '198.51.101.0/24']; + intelligenceDoc.firstSeenAt = Date.now(); + intelligenceDoc.lastSeenAt = Date.now(); + intelligenceDoc.updatedAt = Date.now(); + intelligenceDoc.seenCount = 1; + await intelligenceDoc.save(); + + await manager.createBlockRule({ + type: 'asn', + value: 'AS64501', + reason: 'test asn cidr array', + }); + + const policy = await manager.compilePolicy(); + expect(policy.blockedCidrs).toEqual(['198.51.100.128/25', '198.51.101.0/24']); +}); + +tap.test('SecurityPolicyManager returns an explicit empty edge firewall snapshot', async () => { + await testDbPromise; + await clearTestState(); + const manager = new SecurityPolicyManager(); + + const firewall = await manager.compileRemoteIngressFirewall(); + expect(firewall).toEqual({ blockedIps: [] }); +}); + +tap.test('cleanup security policy test db', async () => { + const dbHandle = await testDbPromise; + await clearTestState(); + await dbHandle.cleanup(); +}); + +export default tap.start(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 6d33f92..f8c8c38 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/dcrouter', - version: '13.24.0', + version: '13.25.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts/db/documents/classes.ip-intelligence.doc.ts b/ts/db/documents/classes.ip-intelligence.doc.ts index aed82d8..f2dc971 100644 --- a/ts/db/documents/classes.ip-intelligence.doc.ts +++ b/ts/db/documents/classes.ip-intelligence.doc.ts @@ -25,6 +25,9 @@ export class IpIntelligenceDoc extends plugins.smartdata.SmartDataDbDoc 0) { + for (const cidr of networkEntries) { + blockedCidrs.add(cidr); + } } else if (this.normalizeIp(doc.ipAddress)) { blockedIps.add(this.normalizeIp(doc.ipAddress)!); } @@ -231,13 +238,13 @@ export class SecurityPolicyManager { return await this.compilePolicy(); } - public async compileRemoteIngressFirewall(): Promise { + 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.length > 0 ? { blockedIps } : undefined; + return { blockedIps }; } private async matchesAnyReactiveRule(doc: IpIntelligenceDoc): Promise { @@ -287,6 +294,81 @@ export class SecurityPolicyManager { 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) { diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 6d33f92..f8c8c38 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/dcrouter', - version: '13.24.0', + version: '13.25.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' }