@sentriflow/core 0.1.7 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/index.ts +3 -0
- package/src/ip/extractor.ts +665 -0
- package/src/ip/index.ts +24 -0
- package/src/ip/types.ts +100 -0
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
// packages/core/src/ip/extractor.ts
|
|
2
|
+
|
|
3
|
+
import type { IPAddressType, IPSummary, IPCounts, ExtractOptions } from './types';
|
|
4
|
+
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// Validation Functions
|
|
7
|
+
// ============================================================================
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Validate an IPv4 address string.
|
|
11
|
+
* Rejects leading zeros (e.g., 192.168.01.1 is invalid).
|
|
12
|
+
*
|
|
13
|
+
* @param ip - String to validate
|
|
14
|
+
* @returns true if valid IPv4 address
|
|
15
|
+
*/
|
|
16
|
+
export function isValidIPv4(ip: string): boolean {
|
|
17
|
+
if (!ip || typeof ip !== 'string') return false;
|
|
18
|
+
|
|
19
|
+
const octets = ip.split('.');
|
|
20
|
+
if (octets.length !== 4) return false;
|
|
21
|
+
|
|
22
|
+
for (const octet of octets) {
|
|
23
|
+
// Must be a number without leading zeros (except "0" itself)
|
|
24
|
+
if (!/^\d+$/.test(octet)) return false;
|
|
25
|
+
if (octet.length > 1 && octet.startsWith('0')) return false;
|
|
26
|
+
|
|
27
|
+
const num = parseInt(octet, 10);
|
|
28
|
+
if (isNaN(num) || num < 0 || num > 255) return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Validate an IPv6 address string.
|
|
36
|
+
* Handles compressed notation (::) and strips zone IDs (%eth0).
|
|
37
|
+
*
|
|
38
|
+
* @param ip - String to validate (with or without zone ID)
|
|
39
|
+
* @returns true if valid IPv6 address
|
|
40
|
+
*/
|
|
41
|
+
export function isValidIPv6(ip: string): boolean {
|
|
42
|
+
if (!ip || typeof ip !== 'string') return false;
|
|
43
|
+
|
|
44
|
+
// Strip zone ID if present (e.g., fe80::1%eth0)
|
|
45
|
+
let addr = ip;
|
|
46
|
+
const zoneIndex = ip.indexOf('%');
|
|
47
|
+
if (zoneIndex !== -1) {
|
|
48
|
+
addr = ip.substring(0, zoneIndex);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Must have at least one colon
|
|
52
|
+
if (!addr.includes(':')) return false;
|
|
53
|
+
|
|
54
|
+
// Triple colons are invalid
|
|
55
|
+
if (addr.includes(':::')) return false;
|
|
56
|
+
|
|
57
|
+
// Count double colons - only one allowed
|
|
58
|
+
const doubleColonCount = (addr.match(/::/g) || []).length;
|
|
59
|
+
if (doubleColonCount > 1) return false;
|
|
60
|
+
|
|
61
|
+
// Split by colon
|
|
62
|
+
const parts = addr.split(':');
|
|
63
|
+
|
|
64
|
+
// Handle :: compression
|
|
65
|
+
if (doubleColonCount === 1) {
|
|
66
|
+
// With ::, we need to have fewer than 8 parts total
|
|
67
|
+
// The empty strings from :: are counted
|
|
68
|
+
const nonEmptyParts = parts.filter((p) => p !== '');
|
|
69
|
+
|
|
70
|
+
// Each non-empty part must be valid hex (1-4 digits)
|
|
71
|
+
for (const part of nonEmptyParts) {
|
|
72
|
+
if (!/^[0-9a-fA-F]{1,4}$/.test(part)) return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// With ::, we can have at most 7 non-empty parts (8 - 1 for the zero run)
|
|
76
|
+
if (nonEmptyParts.length > 7) return false;
|
|
77
|
+
} else {
|
|
78
|
+
// No ::, must have exactly 8 parts
|
|
79
|
+
if (parts.length !== 8) return false;
|
|
80
|
+
|
|
81
|
+
for (const part of parts) {
|
|
82
|
+
if (!/^[0-9a-fA-F]{1,4}$/.test(part)) return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Validate a CIDR subnet notation.
|
|
91
|
+
*
|
|
92
|
+
* @param subnet - String in format "IP/prefix"
|
|
93
|
+
* @returns true if valid CIDR notation
|
|
94
|
+
*/
|
|
95
|
+
export function isValidSubnet(subnet: string): boolean {
|
|
96
|
+
if (!subnet || typeof subnet !== 'string') return false;
|
|
97
|
+
|
|
98
|
+
const slashIndex = subnet.lastIndexOf('/');
|
|
99
|
+
if (slashIndex === -1) return false;
|
|
100
|
+
|
|
101
|
+
const ip = subnet.substring(0, slashIndex);
|
|
102
|
+
const prefixStr = subnet.substring(slashIndex + 1);
|
|
103
|
+
|
|
104
|
+
// Prefix must be a number
|
|
105
|
+
if (!/^\d+$/.test(prefixStr)) return false;
|
|
106
|
+
const prefix = parseInt(prefixStr, 10);
|
|
107
|
+
|
|
108
|
+
// Check if IPv4 or IPv6
|
|
109
|
+
if (isValidIPv4(ip)) {
|
|
110
|
+
return prefix >= 0 && prefix <= 32;
|
|
111
|
+
} else if (isValidIPv6(ip)) {
|
|
112
|
+
return prefix >= 0 && prefix <= 128;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ============================================================================
|
|
119
|
+
// Normalization Functions
|
|
120
|
+
// ============================================================================
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Normalize IPv4 address to canonical form.
|
|
124
|
+
* Removes leading zeros from octets.
|
|
125
|
+
*
|
|
126
|
+
* @param ip - Valid IPv4 address
|
|
127
|
+
* @returns Normalized IPv4 string
|
|
128
|
+
*/
|
|
129
|
+
export function normalizeIPv4(ip: string): string {
|
|
130
|
+
return ip
|
|
131
|
+
.split('.')
|
|
132
|
+
.map((octet) => parseInt(octet, 10).toString())
|
|
133
|
+
.join('.');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Normalize IPv6 address to canonical form.
|
|
138
|
+
* - Lowercase hex digits
|
|
139
|
+
* - Removes leading zeros from groups
|
|
140
|
+
* - Zone ID removed
|
|
141
|
+
* - Fully expanded to 8 groups
|
|
142
|
+
*
|
|
143
|
+
* @param ip - Valid IPv6 address
|
|
144
|
+
* @returns Normalized IPv6 string (lowercase, no zone ID, fully expanded)
|
|
145
|
+
*/
|
|
146
|
+
export function normalizeIPv6(ip: string): string {
|
|
147
|
+
// Strip zone ID
|
|
148
|
+
let addr = ip;
|
|
149
|
+
const zoneIndex = ip.indexOf('%');
|
|
150
|
+
if (zoneIndex !== -1) {
|
|
151
|
+
addr = ip.substring(0, zoneIndex);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
addr = addr.toLowerCase();
|
|
155
|
+
|
|
156
|
+
// Handle :: expansion
|
|
157
|
+
if (addr.includes('::')) {
|
|
158
|
+
const sides = addr.split('::');
|
|
159
|
+
const left = sides[0] ? sides[0].split(':').filter((p) => p !== '') : [];
|
|
160
|
+
const right = sides[1] ? sides[1].split(':').filter((p) => p !== '') : [];
|
|
161
|
+
|
|
162
|
+
// Calculate how many zeros we need
|
|
163
|
+
const zerosNeeded = 8 - left.length - right.length;
|
|
164
|
+
|
|
165
|
+
const expanded: string[] = [];
|
|
166
|
+
|
|
167
|
+
// Add left parts
|
|
168
|
+
for (const part of left) {
|
|
169
|
+
expanded.push(parseInt(part, 16).toString(16));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Add zeros
|
|
173
|
+
for (let i = 0; i < zerosNeeded; i++) {
|
|
174
|
+
expanded.push('0');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Add right parts
|
|
178
|
+
for (const part of right) {
|
|
179
|
+
expanded.push(parseInt(part, 16).toString(16));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return expanded.join(':');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// No ::, just normalize each part
|
|
186
|
+
const parts = addr.split(':');
|
|
187
|
+
const result: string[] = [];
|
|
188
|
+
|
|
189
|
+
for (const part of parts) {
|
|
190
|
+
if (part !== '') {
|
|
191
|
+
result.push(parseInt(part, 16).toString(16));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return result.join(':');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ============================================================================
|
|
199
|
+
// Comparison Functions
|
|
200
|
+
// ============================================================================
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Convert IPv4 to 32-bit number for comparison.
|
|
204
|
+
*/
|
|
205
|
+
function ipv4ToNumber(ip: string): number {
|
|
206
|
+
const octets = ip.split('.').map(Number);
|
|
207
|
+
const o0 = octets[0] ?? 0;
|
|
208
|
+
const o1 = octets[1] ?? 0;
|
|
209
|
+
const o2 = octets[2] ?? 0;
|
|
210
|
+
const o3 = octets[3] ?? 0;
|
|
211
|
+
return ((o0 << 24) >>> 0) + (o1 << 16) + (o2 << 8) + o3;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Compare two IPv4 addresses numerically.
|
|
216
|
+
*
|
|
217
|
+
* @returns -1 if a < b, 0 if equal, 1 if a > b
|
|
218
|
+
*/
|
|
219
|
+
export function compareIPv4(a: string, b: string): number {
|
|
220
|
+
const numA = ipv4ToNumber(a);
|
|
221
|
+
const numB = ipv4ToNumber(b);
|
|
222
|
+
return numA < numB ? -1 : numA > numB ? 1 : 0;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Expand IPv6 to full form (8 groups) for comparison.
|
|
227
|
+
*/
|
|
228
|
+
function expandIPv6(ip: string): string[] {
|
|
229
|
+
// Strip zone ID
|
|
230
|
+
let addr = ip;
|
|
231
|
+
const zoneIndex = ip.indexOf('%');
|
|
232
|
+
if (zoneIndex !== -1) {
|
|
233
|
+
addr = ip.substring(0, zoneIndex);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const parts = addr.split(':');
|
|
237
|
+
const result: string[] = [];
|
|
238
|
+
|
|
239
|
+
for (let i = 0; i < parts.length; i++) {
|
|
240
|
+
if (parts[i] === '' && i > 0 && i < parts.length - 1) {
|
|
241
|
+
// Middle ::, expand
|
|
242
|
+
const nonEmpty = parts.filter((p) => p !== '').length;
|
|
243
|
+
const zeros = 8 - nonEmpty;
|
|
244
|
+
for (let j = 0; j < zeros; j++) {
|
|
245
|
+
result.push('0');
|
|
246
|
+
}
|
|
247
|
+
} else if (parts[i] !== '') {
|
|
248
|
+
result.push(parts[i] ?? '0');
|
|
249
|
+
} else if (i === 0 && parts[1] === '') {
|
|
250
|
+
// Leading ::
|
|
251
|
+
const nonEmpty = parts.filter((p) => p !== '').length;
|
|
252
|
+
const zeros = 8 - nonEmpty;
|
|
253
|
+
for (let j = 0; j < zeros; j++) {
|
|
254
|
+
result.push('0');
|
|
255
|
+
}
|
|
256
|
+
} else if (i === parts.length - 1 && parts[i - 1] === '') {
|
|
257
|
+
// Trailing :: already handled
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Pad to 8 if needed
|
|
262
|
+
while (result.length < 8) {
|
|
263
|
+
result.push('0');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return result.slice(0, 8);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Convert IPv6 to BigInt for comparison.
|
|
271
|
+
*/
|
|
272
|
+
function ipv6ToBigInt(ip: string): bigint {
|
|
273
|
+
const parts = expandIPv6(ip);
|
|
274
|
+
let result = 0n;
|
|
275
|
+
for (const part of parts) {
|
|
276
|
+
result = (result << 16n) + BigInt(parseInt(part, 16) || 0);
|
|
277
|
+
}
|
|
278
|
+
return result;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Compare two IPv6 addresses numerically.
|
|
283
|
+
*
|
|
284
|
+
* @returns -1 if a < b, 0 if equal, 1 if a > b
|
|
285
|
+
*/
|
|
286
|
+
export function compareIPv6(a: string, b: string): number {
|
|
287
|
+
const bigA = ipv6ToBigInt(a);
|
|
288
|
+
const bigB = ipv6ToBigInt(b);
|
|
289
|
+
return bigA < bigB ? -1 : bigA > bigB ? 1 : 0;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ============================================================================
|
|
293
|
+
// Sorting Functions
|
|
294
|
+
// ============================================================================
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Sort IPv4 addresses numerically.
|
|
298
|
+
*
|
|
299
|
+
* @param ips - Array of valid IPv4 addresses
|
|
300
|
+
* @returns New sorted array (original unchanged)
|
|
301
|
+
*/
|
|
302
|
+
export function sortIPv4Addresses(ips: string[]): string[] {
|
|
303
|
+
return [...ips].sort(compareIPv4);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Sort IPv6 addresses numerically.
|
|
308
|
+
*
|
|
309
|
+
* @param ips - Array of valid IPv6 addresses
|
|
310
|
+
* @returns New sorted array (original unchanged)
|
|
311
|
+
*/
|
|
312
|
+
export function sortIPv6Addresses(ips: string[]): string[] {
|
|
313
|
+
return [...ips].sort(compareIPv6);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Parse subnet into network and prefix.
|
|
318
|
+
*/
|
|
319
|
+
function parseSubnet(subnet: string): { network: string; prefix: number } {
|
|
320
|
+
const slashIndex = subnet.lastIndexOf('/');
|
|
321
|
+
return {
|
|
322
|
+
network: subnet.substring(0, slashIndex),
|
|
323
|
+
prefix: parseInt(subnet.substring(slashIndex + 1), 10),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Sort subnets by network address, then prefix length.
|
|
329
|
+
*
|
|
330
|
+
* @param subnets - Array of CIDR strings
|
|
331
|
+
* @param type - 'ipv4' or 'ipv6'
|
|
332
|
+
* @returns New sorted array (original unchanged)
|
|
333
|
+
*/
|
|
334
|
+
export function sortSubnets(subnets: string[], type: IPAddressType): string[] {
|
|
335
|
+
const compare = type === 'ipv4' ? compareIPv4 : compareIPv6;
|
|
336
|
+
|
|
337
|
+
return [...subnets].sort((a, b) => {
|
|
338
|
+
const subA = parseSubnet(a);
|
|
339
|
+
const subB = parseSubnet(b);
|
|
340
|
+
|
|
341
|
+
// First compare by network address
|
|
342
|
+
const netCompare = compare(subA.network, subB.network);
|
|
343
|
+
if (netCompare !== 0) return netCompare;
|
|
344
|
+
|
|
345
|
+
// Then by prefix length (ascending)
|
|
346
|
+
return subA.prefix - subB.prefix;
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ============================================================================
|
|
351
|
+
// Main Extraction Function
|
|
352
|
+
// ============================================================================
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Check if an IPv4 address looks like a subnet mask.
|
|
356
|
+
* Common masks: 255.0.0.0, 255.255.0.0, 255.255.255.0, 255.255.255.252, etc.
|
|
357
|
+
* Valid masks have contiguous 1-bits followed by contiguous 0-bits.
|
|
358
|
+
*/
|
|
359
|
+
function isSubnetMask(ip: string): boolean {
|
|
360
|
+
// Quick check for masks starting with 255
|
|
361
|
+
if (!ip.startsWith('255.')) return false;
|
|
362
|
+
|
|
363
|
+
const octets = ip.split('.').map(Number);
|
|
364
|
+
|
|
365
|
+
// Convert to 32-bit number
|
|
366
|
+
const num =
|
|
367
|
+
((octets[0] ?? 0) << 24) +
|
|
368
|
+
((octets[1] ?? 0) << 16) +
|
|
369
|
+
((octets[2] ?? 0) << 8) +
|
|
370
|
+
(octets[3] ?? 0);
|
|
371
|
+
|
|
372
|
+
// Valid subnet mask: when we invert and add 1, result should be power of 2
|
|
373
|
+
// e.g., 255.255.255.0 -> inverted = 255 -> 255 + 1 = 256 = 2^8
|
|
374
|
+
const inverted = ~num >>> 0;
|
|
375
|
+
return inverted === 0 || (inverted & (inverted + 1)) === 0;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Check if an IPv4 address looks like a wildcard mask (inverse subnet mask).
|
|
380
|
+
* Common wildcards: 0.0.0.255, 0.0.255.255, 0.255.255.255, etc.
|
|
381
|
+
* Valid wildcards have contiguous 0-bits followed by contiguous 1-bits.
|
|
382
|
+
*/
|
|
383
|
+
function isWildcardMask(ip: string): boolean {
|
|
384
|
+
// Quick check - wildcards typically start with 0
|
|
385
|
+
if (!ip.startsWith('0.')) return false;
|
|
386
|
+
|
|
387
|
+
const octets = ip.split('.').map(Number);
|
|
388
|
+
|
|
389
|
+
// Convert to 32-bit number
|
|
390
|
+
const num =
|
|
391
|
+
((octets[0] ?? 0) << 24) +
|
|
392
|
+
((octets[1] ?? 0) << 16) +
|
|
393
|
+
((octets[2] ?? 0) << 8) +
|
|
394
|
+
(octets[3] ?? 0);
|
|
395
|
+
|
|
396
|
+
// Valid wildcard mask: num + 1 should be power of 2
|
|
397
|
+
// e.g., 0.0.0.255 -> 255 + 1 = 256 = 2^8
|
|
398
|
+
return num === 0 || ((num + 1) & num) === 0;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Convert a subnet mask to CIDR prefix length.
|
|
403
|
+
* e.g., 255.255.255.0 -> 24, 255.255.0.0 -> 16
|
|
404
|
+
* Returns -1 if not a valid subnet mask.
|
|
405
|
+
*/
|
|
406
|
+
function maskToCidr(mask: string): number {
|
|
407
|
+
if (!isSubnetMask(mask)) return -1;
|
|
408
|
+
|
|
409
|
+
const octets = mask.split('.').map(Number);
|
|
410
|
+
const num =
|
|
411
|
+
((octets[0] ?? 0) << 24) +
|
|
412
|
+
((octets[1] ?? 0) << 16) +
|
|
413
|
+
((octets[2] ?? 0) << 8) +
|
|
414
|
+
(octets[3] ?? 0);
|
|
415
|
+
|
|
416
|
+
// Count leading 1-bits
|
|
417
|
+
let prefix = 0;
|
|
418
|
+
let n = num >>> 0; // Ensure unsigned
|
|
419
|
+
while (n & 0x80000000) {
|
|
420
|
+
prefix++;
|
|
421
|
+
n = (n << 1) >>> 0;
|
|
422
|
+
}
|
|
423
|
+
return prefix;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Convert a wildcard mask to CIDR prefix length.
|
|
428
|
+
* e.g., 0.0.0.255 -> 24 (matches /24 network), 0.0.255.255 -> 16
|
|
429
|
+
* Returns -1 if not a valid wildcard mask.
|
|
430
|
+
*/
|
|
431
|
+
function wildcardToCidr(wildcard: string): number {
|
|
432
|
+
if (!isWildcardMask(wildcard)) return -1;
|
|
433
|
+
|
|
434
|
+
const octets = wildcard.split('.').map(Number);
|
|
435
|
+
const num =
|
|
436
|
+
((octets[0] ?? 0) << 24) +
|
|
437
|
+
((octets[1] ?? 0) << 16) +
|
|
438
|
+
((octets[2] ?? 0) << 8) +
|
|
439
|
+
(octets[3] ?? 0);
|
|
440
|
+
|
|
441
|
+
// Wildcard is inverse of mask, so count leading 0-bits
|
|
442
|
+
// 0.0.0.255 (00000000.00000000.00000000.11111111) = 24 leading zeros = /24
|
|
443
|
+
let prefix = 0;
|
|
444
|
+
let n = num >>> 0;
|
|
445
|
+
while (prefix < 32 && !(n & 0x80000000)) {
|
|
446
|
+
prefix++;
|
|
447
|
+
n = (n << 1) >>> 0;
|
|
448
|
+
}
|
|
449
|
+
return prefix;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// IPv4 pattern: 4 octets, each 0-255, no leading zeros
|
|
453
|
+
const IPV4_PATTERN =
|
|
454
|
+
/\b(?:(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\b/g;
|
|
455
|
+
|
|
456
|
+
// IPv4 CIDR pattern
|
|
457
|
+
const IPV4_CIDR_PATTERN =
|
|
458
|
+
/\b(?:(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\/(?:3[0-2]|[12]?[0-9])\b/g;
|
|
459
|
+
|
|
460
|
+
// IPv4 + subnet mask pattern (e.g., "ip address 192.168.1.1 255.255.255.0")
|
|
461
|
+
// Captures IP address followed by space and subnet mask
|
|
462
|
+
const IPV4_WITH_MASK_PATTERN =
|
|
463
|
+
/\b((?:(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9]))\s+(255\.(?:(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){2}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9]))\b/g;
|
|
464
|
+
|
|
465
|
+
// IPv4 + "mask" keyword + subnet mask pattern (e.g., "network 192.168.1.0 mask 255.255.255.0" in BGP)
|
|
466
|
+
// Captures IP address followed by "mask" keyword and subnet mask
|
|
467
|
+
const IPV4_WITH_MASK_KEYWORD_PATTERN =
|
|
468
|
+
/\b((?:(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9]))\s+mask\s+(255\.(?:(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){2}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9]))\b/gi;
|
|
469
|
+
|
|
470
|
+
// IPv4 + wildcard mask pattern (e.g., "access-list 10 permit 192.168.1.0 0.0.0.255")
|
|
471
|
+
// Captures network address followed by space and wildcard mask
|
|
472
|
+
const IPV4_WITH_WILDCARD_PATTERN =
|
|
473
|
+
/\b((?:(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9]))\s+(0\.(?:(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){2}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9]))\b/g;
|
|
474
|
+
|
|
475
|
+
// IPv6 pattern - matches most common formats
|
|
476
|
+
// Order matters: longer patterns first to prevent partial matching
|
|
477
|
+
const IPV6_PATTERN =
|
|
478
|
+
/(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|:(?::[0-9a-fA-F]{1,4}){1,7}|(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,5}(?::[0-9a-fA-F]{1,4}){1,2}|(?:[0-9a-fA-F]{1,4}:){1,4}(?::[0-9a-fA-F]{1,4}){1,3}|(?:[0-9a-fA-F]{1,4}:){1,3}(?::[0-9a-fA-F]{1,4}){1,4}|(?:[0-9a-fA-F]{1,4}:){1,2}(?::[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:(?::[0-9a-fA-F]{1,4}){1,6}|(?:[0-9a-fA-F]{1,4}:){1,7}:|::/g;
|
|
479
|
+
|
|
480
|
+
// IPv6 CIDR pattern - matches IPv6/prefix notation
|
|
481
|
+
// Order matters: longer patterns first
|
|
482
|
+
const IPV6_CIDR_PATTERN =
|
|
483
|
+
/(?:(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|:(?::[0-9a-fA-F]{1,4}){1,7}|(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,5}(?::[0-9a-fA-F]{1,4}){1,2}|(?:[0-9a-fA-F]{1,4}:){1,4}(?::[0-9a-fA-F]{1,4}){1,3}|(?:[0-9a-fA-F]{1,4}:){1,3}(?::[0-9a-fA-F]{1,4}){1,4}|(?:[0-9a-fA-F]{1,4}:){1,2}(?::[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:(?::[0-9a-fA-F]{1,4}){1,6}|(?:[0-9a-fA-F]{1,4}:){1,7}:|::)\/(?:12[0-8]|1[01][0-9]|[1-9]?[0-9])/g;
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Create an empty IP summary.
|
|
487
|
+
*/
|
|
488
|
+
function createEmptyIPSummary(): IPSummary {
|
|
489
|
+
return {
|
|
490
|
+
ipv4Addresses: [],
|
|
491
|
+
ipv6Addresses: [],
|
|
492
|
+
ipv4Subnets: [],
|
|
493
|
+
ipv6Subnets: [],
|
|
494
|
+
counts: {
|
|
495
|
+
ipv4: 0,
|
|
496
|
+
ipv6: 0,
|
|
497
|
+
ipv4Subnets: 0,
|
|
498
|
+
ipv6Subnets: 0,
|
|
499
|
+
total: 0,
|
|
500
|
+
},
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Extract all IP addresses and subnets from configuration text.
|
|
506
|
+
*
|
|
507
|
+
* @param content - Raw configuration file content
|
|
508
|
+
* @param options - Optional extraction settings
|
|
509
|
+
* @returns IPSummary with deduplicated, sorted addresses and subnets
|
|
510
|
+
*/
|
|
511
|
+
export function extractIPSummary(content: string, options: ExtractOptions = {}): IPSummary {
|
|
512
|
+
if (!content || typeof content !== 'string') {
|
|
513
|
+
return createEmptyIPSummary();
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const ipv4Set = new Set<string>();
|
|
517
|
+
const ipv6Set = new Set<string>();
|
|
518
|
+
const ipv4SubnetSet = new Set<string>();
|
|
519
|
+
const ipv6SubnetSet = new Set<string>();
|
|
520
|
+
|
|
521
|
+
// Track subnet network addresses to avoid double-counting
|
|
522
|
+
const subnetNetworks = new Set<string>();
|
|
523
|
+
|
|
524
|
+
// Track IP addresses paired with masks (so we don't double-count them)
|
|
525
|
+
const ipsWithMasks = new Set<string>();
|
|
526
|
+
|
|
527
|
+
// Extract IPv4 subnets first (so we can exclude their network addresses from standalone IPs)
|
|
528
|
+
if (!options.skipSubnets) {
|
|
529
|
+
// Extract CIDR notation subnets (e.g., 10.0.0.0/24)
|
|
530
|
+
const ipv4CidrMatches = content.matchAll(IPV4_CIDR_PATTERN);
|
|
531
|
+
for (const match of ipv4CidrMatches) {
|
|
532
|
+
const subnet = match[0];
|
|
533
|
+
if (isValidSubnet(subnet)) {
|
|
534
|
+
const { network } = parseSubnet(subnet);
|
|
535
|
+
const normalizedNetwork = normalizeIPv4(network);
|
|
536
|
+
ipv4SubnetSet.add(`${normalizedNetwork}/${parseSubnet(subnet).prefix}`);
|
|
537
|
+
subnetNetworks.add(normalizedNetwork);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Extract IP + mask pairs (e.g., "192.168.1.1 255.255.255.0")
|
|
542
|
+
// These are interface addresses with their subnet info
|
|
543
|
+
const ipMaskMatches = content.matchAll(IPV4_WITH_MASK_PATTERN);
|
|
544
|
+
for (const match of ipMaskMatches) {
|
|
545
|
+
const ip = match[1];
|
|
546
|
+
const mask = match[2];
|
|
547
|
+
if (ip && mask && isValidIPv4(ip) && isSubnetMask(mask)) {
|
|
548
|
+
const normalizedIP = normalizeIPv4(ip);
|
|
549
|
+
const prefix = maskToCidr(mask);
|
|
550
|
+
if (prefix >= 0) {
|
|
551
|
+
// Add as subnet with the host IP (interface address)
|
|
552
|
+
ipv4SubnetSet.add(`${normalizedIP}/${prefix}`);
|
|
553
|
+
ipsWithMasks.add(normalizedIP);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Extract IP + "mask" + mask pairs (e.g., "network 10.0.0.0 mask 255.0.0.0" in BGP)
|
|
559
|
+
const ipMaskKeywordMatches = content.matchAll(IPV4_WITH_MASK_KEYWORD_PATTERN);
|
|
560
|
+
for (const match of ipMaskKeywordMatches) {
|
|
561
|
+
const ip = match[1];
|
|
562
|
+
const mask = match[2];
|
|
563
|
+
if (ip && mask && isValidIPv4(ip) && isSubnetMask(mask)) {
|
|
564
|
+
const normalizedIP = normalizeIPv4(ip);
|
|
565
|
+
const prefix = maskToCidr(mask);
|
|
566
|
+
if (prefix >= 0) {
|
|
567
|
+
// Add as subnet
|
|
568
|
+
ipv4SubnetSet.add(`${normalizedIP}/${prefix}`);
|
|
569
|
+
ipsWithMasks.add(normalizedIP);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Extract IP + wildcard pairs (e.g., "192.168.1.0 0.0.0.255" in ACLs)
|
|
575
|
+
// These define network ranges in access lists
|
|
576
|
+
const ipWildcardMatches = content.matchAll(IPV4_WITH_WILDCARD_PATTERN);
|
|
577
|
+
for (const match of ipWildcardMatches) {
|
|
578
|
+
const ip = match[1];
|
|
579
|
+
const wildcard = match[2];
|
|
580
|
+
if (ip && wildcard && isValidIPv4(ip) && isWildcardMask(wildcard)) {
|
|
581
|
+
const normalizedIP = normalizeIPv4(ip);
|
|
582
|
+
const prefix = wildcardToCidr(wildcard);
|
|
583
|
+
if (prefix >= 0) {
|
|
584
|
+
// Add as subnet (network address from ACL)
|
|
585
|
+
ipv4SubnetSet.add(`${normalizedIP}/${prefix}`);
|
|
586
|
+
ipsWithMasks.add(normalizedIP);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Extract IPv4 addresses (excluding those that are subnet network addresses)
|
|
593
|
+
const ipv4Matches = content.matchAll(IPV4_PATTERN);
|
|
594
|
+
for (const match of ipv4Matches) {
|
|
595
|
+
const ip = match[0];
|
|
596
|
+
if (isValidIPv4(ip)) {
|
|
597
|
+
const normalized = normalizeIPv4(ip);
|
|
598
|
+
// Only add if:
|
|
599
|
+
// - Not a subnet network address already captured from CIDR notation
|
|
600
|
+
// - Not already captured as part of IP+mask or IP+wildcard pair
|
|
601
|
+
// - Not a subnet mask (255.x.x.x)
|
|
602
|
+
// - Not a wildcard mask (0.x.x.x patterns)
|
|
603
|
+
if (
|
|
604
|
+
!subnetNetworks.has(normalized) &&
|
|
605
|
+
!ipsWithMasks.has(normalized) &&
|
|
606
|
+
!isSubnetMask(normalized) &&
|
|
607
|
+
!isWildcardMask(normalized)
|
|
608
|
+
) {
|
|
609
|
+
ipv4Set.add(normalized);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Extract IPv6 if not skipped
|
|
615
|
+
if (!options.skipIPv6) {
|
|
616
|
+
// Extract IPv6 subnets first
|
|
617
|
+
if (!options.skipSubnets) {
|
|
618
|
+
const ipv6CidrMatches = content.matchAll(IPV6_CIDR_PATTERN);
|
|
619
|
+
for (const match of ipv6CidrMatches) {
|
|
620
|
+
const subnet = match[0];
|
|
621
|
+
if (isValidSubnet(subnet)) {
|
|
622
|
+
const { network, prefix } = parseSubnet(subnet);
|
|
623
|
+
const normalizedNetwork = normalizeIPv6(network);
|
|
624
|
+
ipv6SubnetSet.add(`${normalizedNetwork}/${prefix}`);
|
|
625
|
+
subnetNetworks.add(normalizedNetwork);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Extract IPv6 addresses
|
|
631
|
+
const ipv6Matches = content.matchAll(IPV6_PATTERN);
|
|
632
|
+
for (const match of ipv6Matches) {
|
|
633
|
+
const ip = match[0];
|
|
634
|
+
if (isValidIPv6(ip)) {
|
|
635
|
+
const normalized = normalizeIPv6(ip);
|
|
636
|
+
if (!subnetNetworks.has(normalized)) {
|
|
637
|
+
ipv6Set.add(normalized);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Convert sets to sorted arrays
|
|
644
|
+
const ipv4Addresses = sortIPv4Addresses([...ipv4Set]);
|
|
645
|
+
const ipv6Addresses = sortIPv6Addresses([...ipv6Set]);
|
|
646
|
+
const ipv4Subnets = sortSubnets([...ipv4SubnetSet], 'ipv4');
|
|
647
|
+
const ipv6Subnets = sortSubnets([...ipv6SubnetSet], 'ipv6');
|
|
648
|
+
|
|
649
|
+
// Calculate counts
|
|
650
|
+
const counts: IPCounts = {
|
|
651
|
+
ipv4: ipv4Addresses.length,
|
|
652
|
+
ipv6: ipv6Addresses.length,
|
|
653
|
+
ipv4Subnets: ipv4Subnets.length,
|
|
654
|
+
ipv6Subnets: ipv6Subnets.length,
|
|
655
|
+
total: ipv4Addresses.length + ipv6Addresses.length + ipv4Subnets.length + ipv6Subnets.length,
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
return {
|
|
659
|
+
ipv4Addresses,
|
|
660
|
+
ipv6Addresses,
|
|
661
|
+
ipv4Subnets,
|
|
662
|
+
ipv6Subnets,
|
|
663
|
+
counts,
|
|
664
|
+
};
|
|
665
|
+
}
|
package/src/ip/index.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// packages/core/src/ip/index.ts
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
extractIPSummary,
|
|
5
|
+
isValidIPv4,
|
|
6
|
+
isValidIPv6,
|
|
7
|
+
isValidSubnet,
|
|
8
|
+
normalizeIPv4,
|
|
9
|
+
normalizeIPv6,
|
|
10
|
+
compareIPv4,
|
|
11
|
+
compareIPv6,
|
|
12
|
+
sortIPv4Addresses,
|
|
13
|
+
sortIPv6Addresses,
|
|
14
|
+
sortSubnets,
|
|
15
|
+
} from './extractor';
|
|
16
|
+
|
|
17
|
+
export type {
|
|
18
|
+
IPAddressType,
|
|
19
|
+
IPAddress,
|
|
20
|
+
Subnet,
|
|
21
|
+
IPSummary,
|
|
22
|
+
IPCounts,
|
|
23
|
+
ExtractOptions,
|
|
24
|
+
} from './types';
|
package/src/ip/types.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// packages/core/src/ip/types.ts
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Discriminator for IPv4 vs IPv6 addresses.
|
|
5
|
+
*/
|
|
6
|
+
export type IPAddressType = 'ipv4' | 'ipv6';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Represents a validated IP address (standalone, not a subnet).
|
|
10
|
+
*/
|
|
11
|
+
export interface IPAddress {
|
|
12
|
+
/** The IP address string (normalized form) */
|
|
13
|
+
value: string;
|
|
14
|
+
|
|
15
|
+
/** Address type discriminator */
|
|
16
|
+
type: IPAddressType;
|
|
17
|
+
|
|
18
|
+
/** Optional: Line number where this IP was found (1-based) */
|
|
19
|
+
line?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Represents a network address with CIDR prefix length.
|
|
24
|
+
*/
|
|
25
|
+
export interface Subnet {
|
|
26
|
+
/** The network address (normalized form) */
|
|
27
|
+
network: string;
|
|
28
|
+
|
|
29
|
+
/** CIDR prefix length (0-32 for IPv4, 0-128 for IPv6) */
|
|
30
|
+
prefix: number;
|
|
31
|
+
|
|
32
|
+
/** Address type discriminator */
|
|
33
|
+
type: IPAddressType;
|
|
34
|
+
|
|
35
|
+
/** Optional: Line number where this subnet was found (1-based) */
|
|
36
|
+
line?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Summary statistics for the extraction.
|
|
41
|
+
*/
|
|
42
|
+
export interface IPCounts {
|
|
43
|
+
/** Total unique IPv4 addresses */
|
|
44
|
+
ipv4: number;
|
|
45
|
+
|
|
46
|
+
/** Total unique IPv6 addresses */
|
|
47
|
+
ipv6: number;
|
|
48
|
+
|
|
49
|
+
/** Total unique IPv4 subnets */
|
|
50
|
+
ipv4Subnets: number;
|
|
51
|
+
|
|
52
|
+
/** Total unique IPv6 subnets */
|
|
53
|
+
ipv6Subnets: number;
|
|
54
|
+
|
|
55
|
+
/** Grand total of all unique IPs and subnets */
|
|
56
|
+
total: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Aggregated extraction results for a configuration file.
|
|
61
|
+
*/
|
|
62
|
+
export interface IPSummary {
|
|
63
|
+
/** Standalone IPv4 addresses (sorted numerically) */
|
|
64
|
+
ipv4Addresses: string[];
|
|
65
|
+
|
|
66
|
+
/** Standalone IPv6 addresses (sorted numerically) */
|
|
67
|
+
ipv6Addresses: string[];
|
|
68
|
+
|
|
69
|
+
/** IPv4 subnets in CIDR notation (sorted by network, then prefix) */
|
|
70
|
+
ipv4Subnets: string[];
|
|
71
|
+
|
|
72
|
+
/** IPv6 subnets in CIDR notation (sorted by network, then prefix) */
|
|
73
|
+
ipv6Subnets: string[];
|
|
74
|
+
|
|
75
|
+
/** Summary counts */
|
|
76
|
+
counts: IPCounts;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Options for IP extraction.
|
|
81
|
+
*/
|
|
82
|
+
export interface ExtractOptions {
|
|
83
|
+
/**
|
|
84
|
+
* Include line numbers in results.
|
|
85
|
+
* @default false
|
|
86
|
+
*/
|
|
87
|
+
includeLineNumbers?: boolean;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Skip extraction of IPv6 addresses (performance optimization).
|
|
91
|
+
* @default false
|
|
92
|
+
*/
|
|
93
|
+
skipIPv6?: boolean;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Skip extraction of subnets.
|
|
97
|
+
* @default false
|
|
98
|
+
*/
|
|
99
|
+
skipSubnets?: boolean;
|
|
100
|
+
}
|