@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentriflow/core",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "SentriFlow core engine for network configuration validation",
5
5
  "license": "Apache-2.0",
6
6
  "module": "src/index.ts",
package/src/index.ts CHANGED
@@ -31,3 +31,6 @@ export { VENDOR_NAMESPACES, type VendorNamespace, getAllVendorModules, getVendor
31
31
 
32
32
  // Re-export common helpers at top level for convenience
33
33
  export * from './helpers/common';
34
+
35
+ // IP/Subnet extraction module
36
+ export * from './ip';
@@ -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
+ }
@@ -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';
@@ -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
+ }