diff --git a/changelog.md b/changelog.md index 8cf4e40..4120ab2 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,12 @@ # Changelog +## 2026-04-26 - 4.7.0 - feat(ipintelligence) +add canonical RDAP CIDR coverage and derive CIDRs from IPv4 start/end ranges + +- Expose a new networkCidrs field alongside networkRange in IP intelligence results +- Convert RDAP IPv4 start/end ranges into canonical CIDR blocks, preserving legacy range formatting when multiple prefixes are required +- Add tests covering single-prefix and multi-prefix RDAP range parsing + ## 2026-04-13 - 4.6.0 - feat(domain-intelligence) add domain intelligence lookups with RDAP and DNS enrichment diff --git a/readme.md b/readme.md index c855504..ff839df 100644 --- a/readme.md +++ b/readme.md @@ -77,6 +77,7 @@ console.log(intel); // registrantOrg: 'Google LLC', // registrantCountry: 'United States', // networkRange: '8.8.8.0/24', +// networkCidrs: ['8.8.8.0/24'], // abuseContact: null, // country: null, // countryCode: 'US', @@ -109,7 +110,8 @@ interface IIpIntelligenceResult { // Registration (RDAP) registrantOrg: string | null; registrantCountry: string | null; - networkRange: string | null; // CIDR or range + networkRange: string | null; // primary CIDR, or legacy start-end range when multiple CIDRs are needed + networkCidrs: string[] | null; // canonical CIDR coverage for the RDAP network when available abuseContact: string | null; // abuse email from RDAP // Geolocation (MaxMind GeoLite2) diff --git a/test/test.ipintelligence.ts b/test/test.ipintelligence.ts index 31d6852..d8e73ce 100644 --- a/test/test.ipintelligence.ts +++ b/test/test.ipintelligence.ts @@ -41,6 +41,37 @@ tap.test('should get IP intelligence for 1.1.1.1 (Cloudflare)', async () => { // Note: 1.1.1.1 is anycast — city-level geo may be null in GeoLite2 }); +tap.test('should derive a single CIDR from RDAP start/end ranges', async () => { + const intelligence = new smartnetwork.IpIntelligence(); + const result = (intelligence as any).parseRdapNetworkInfo({ + startAddress: '203.0.113.0', + endAddress: '203.0.113.255', + }); + + expect(result).toEqual({ + networkRange: '203.0.113.0/24', + networkCidrs: ['203.0.113.0/24'], + }); +}); + +tap.test('should expose CIDRs for RDAP ranges that need multiple prefixes', async () => { + const intelligence = new smartnetwork.IpIntelligence(); + const result = (intelligence as any).parseRdapNetworkInfo({ + startAddress: '203.0.113.5', + endAddress: '203.0.113.10', + }); + + expect(result).toEqual({ + networkRange: '203.0.113.5 - 203.0.113.10', + networkCidrs: [ + '203.0.113.5/32', + '203.0.113.6/31', + '203.0.113.8/31', + '203.0.113.10/32', + ], + }); +}); + tap.test('should get IP intelligence for own public IP', async () => { const ips = await testSmartNetwork.getPublicIps(); if (ips.v4) { diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index e675185..2cba81e 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartnetwork', - version: '4.6.0', + version: '4.7.0', description: 'A toolkit for network diagnostics including speed tests, port availability checks, and more.' } diff --git a/ts/smartnetwork.classes.ipintelligence.ts b/ts/smartnetwork.classes.ipintelligence.ts index b8f3d51..2f15d92 100644 --- a/ts/smartnetwork.classes.ipintelligence.ts +++ b/ts/smartnetwork.classes.ipintelligence.ts @@ -30,6 +30,7 @@ export interface IIpIntelligenceResult { registrantOrg: string | null; registrantCountry: string | null; networkRange: string | null; + networkCidrs: string[] | null; abuseContact: string | null; // Geolocation (MaxMind GeoLite2 City) @@ -79,6 +80,11 @@ interface IBootstrapEntry { baseUrl: string; } +interface IRdapNetworkInfo { + networkRange: string | null; + networkCidrs: string[] | null; +} + /** * IpIntelligence provides IP address intelligence by combining three data sources: * - RDAP (direct to RIRs) for registration/org data @@ -120,6 +126,7 @@ export class IpIntelligence { registrantOrg: null, registrantCountry: null, networkRange: null, + networkCidrs: null, abuseContact: null, country: null, countryCode: null, @@ -143,6 +150,7 @@ export class IpIntelligence { result.registrantOrg = rdap.registrantOrg; result.registrantCountry = rdap.registrantCountry; result.networkRange = rdap.networkRange; + result.networkCidrs = rdap.networkCidrs; result.abuseContact = rdap.abuseContact; } @@ -257,6 +265,7 @@ export class IpIntelligence { registrantOrg: string | null; registrantCountry: string | null; networkRange: string | null; + networkCidrs: string[] | null; abuseContact: string | null; } | null> { await this.ensureBootstrap(); @@ -280,14 +289,7 @@ export class IpIntelligence { let registrantCountry: string | null = data.country || null; let abuseContact: string | null = null; - // Parse network range - let networkRange: string | null = null; - if (data.cidr0_cidrs && data.cidr0_cidrs.length > 0) { - const cidr = data.cidr0_cidrs[0]; - networkRange = `${cidr.v4prefix || cidr.v6prefix}/${cidr.length}`; - } else if (data.startAddress && data.endAddress) { - networkRange = `${data.startAddress} - ${data.endAddress}`; - } + const { networkRange, networkCidrs } = this.parseRdapNetworkInfo(data); // Parse entities if (data.entities && Array.isArray(data.entities)) { @@ -320,7 +322,7 @@ export class IpIntelligence { } } - return { registrantOrg, registrantCountry, networkRange, abuseContact }; + return { registrantOrg, registrantCountry, networkRange, networkCidrs, abuseContact }; } catch (err: any) { this.logger.debug?.(`RDAP query failed for ${ip}: ${err.message}`); return null; @@ -329,6 +331,40 @@ export class IpIntelligence { } } + private parseRdapNetworkInfo(data: any): IRdapNetworkInfo { + const cidrs = this.extractRdapCidrs(data); + if (cidrs.length > 0) { + return { + networkRange: cidrs[0], + networkCidrs: cidrs, + }; + } + + if (typeof data.startAddress === 'string' && typeof data.endAddress === 'string') { + const rangeCidrs = this.ipv4RangeToCidrs(data.startAddress, data.endAddress); + return { + networkRange: rangeCidrs.length === 1 + ? rangeCidrs[0] + : `${data.startAddress} - ${data.endAddress}`, + networkCidrs: rangeCidrs.length > 0 ? rangeCidrs : null, + }; + } + + return { networkRange: null, networkCidrs: null }; + } + + private extractRdapCidrs(data: any): string[] { + if (!Array.isArray(data.cidr0_cidrs)) return []; + return data.cidr0_cidrs + .map((cidr: any) => { + const prefix = cidr?.v4prefix || cidr?.v6prefix; + const length = Number(cidr?.length); + if (typeof prefix !== 'string' || !Number.isInteger(length)) return null; + return `${prefix}/${length}`; + }) + .filter(Boolean) as string[]; + } + /** * Extract the 'fn' (formatted name) from an entity's vcardArray */ @@ -573,4 +609,56 @@ export class IpIntelligence { parseInt(parts[3], 10)) >>> 0 ); } + + 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 parts = ip.trim().split('.'); + if (parts.length !== 4) return undefined; + let result = 0n; + for (const part of parts) { + if (!/^\d+$/.test(part)) return undefined; + const number = Number(part); + if (!Number.isInteger(number) || number < 0 || number > 255) return undefined; + result = (result * 256n) + BigInt(number); + } + return result; + } + + 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; + } }