feat(ipintelligence): add canonical RDAP CIDR coverage and derive CIDRs from IPv4 start/end ranges
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.'
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user