@sentriflow/core 0.1.9 → 0.2.1

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.
@@ -1,6 +1,78 @@
1
1
  // packages/core/src/ip/extractor.ts
2
2
 
3
3
  import type { IPAddressType, IPSummary, IPCounts, ExtractOptions } from './types';
4
+ import { InputValidationError, DEFAULT_MAX_CONTENT_SIZE } from './types';
5
+
6
+ // ============================================================================
7
+ // Utility Functions
8
+ // ============================================================================
9
+
10
+ /**
11
+ * Strip zone ID from IPv6 address.
12
+ * e.g., "fe80::1%eth0" -> "fe80::1"
13
+ *
14
+ * @param ip - IPv6 address string (with or without zone ID)
15
+ * @returns IPv6 address without zone ID
16
+ */
17
+ function stripZoneId(ip: string): string {
18
+ const zoneIndex = ip.indexOf('%');
19
+ return zoneIndex !== -1 ? ip.substring(0, zoneIndex) : ip;
20
+ }
21
+
22
+ // ============================================================================
23
+ // Regex Factory Functions
24
+ // ============================================================================
25
+
26
+ /**
27
+ * Create IPv4 pattern regex.
28
+ * Using factory function prevents lastIndex state issues with global flag.
29
+ */
30
+ function createIPv4Pattern(): RegExp {
31
+ return /\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;
32
+ }
33
+
34
+ /**
35
+ * Create IPv4 CIDR pattern regex.
36
+ */
37
+ function createIPv4CidrPattern(): RegExp {
38
+ return /\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;
39
+ }
40
+
41
+ /**
42
+ * Create IPv4 + subnet mask pattern regex.
43
+ */
44
+ function createIPv4WithMaskPattern(): RegExp {
45
+ return /\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;
46
+ }
47
+
48
+ /**
49
+ * Create IPv4 + "mask" keyword + subnet mask pattern regex.
50
+ */
51
+ function createIPv4WithMaskKeywordPattern(): RegExp {
52
+ return /\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;
53
+ }
54
+
55
+ /**
56
+ * Create IPv4 + wildcard mask pattern regex.
57
+ */
58
+ function createIPv4WithWildcardPattern(): RegExp {
59
+ return /\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;
60
+ }
61
+
62
+ /**
63
+ * Create IPv6 pattern regex.
64
+ */
65
+ function createIPv6Pattern(): RegExp {
66
+ return /(?:[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;
67
+ }
68
+
69
+ /**
70
+ * Create IPv6 CIDR pattern regex.
71
+ * Uses negative lookahead (?!\d) to prevent matching partial prefixes like /12 from /129
72
+ */
73
+ function createIPv6CidrPattern(): RegExp {
74
+ return /(?:(?:[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])(?!\d)/g;
75
+ }
4
76
 
5
77
  // ============================================================================
6
78
  // Validation Functions
@@ -42,11 +114,7 @@ export function isValidIPv6(ip: string): boolean {
42
114
  if (!ip || typeof ip !== 'string') return false;
43
115
 
44
116
  // 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
- }
117
+ const addr = stripZoneId(ip);
50
118
 
51
119
  // Must have at least one colon
52
120
  if (!addr.includes(':')) return false;
@@ -144,14 +212,8 @@ export function normalizeIPv4(ip: string): string {
144
212
  * @returns Normalized IPv6 string (lowercase, no zone ID, fully expanded)
145
213
  */
146
214
  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();
215
+ // Strip zone ID and convert to lowercase
216
+ const addr = stripZoneId(ip).toLowerCase();
155
217
 
156
218
  // Handle :: expansion
157
219
  if (addr.includes('::')) {
@@ -199,11 +261,31 @@ export function normalizeIPv6(ip: string): string {
199
261
  // Comparison Functions
200
262
  // ============================================================================
201
263
 
264
+ /**
265
+ * Validate and clamp octet value to valid range.
266
+ * T019/T020/T021: Defensive bounds checking for IPv4 octets.
267
+ *
268
+ * @param n - Raw number value
269
+ * @returns Valid octet value (0-255), clamped if out of range
270
+ */
271
+ function clampOctet(n: number): number {
272
+ // T021: Check if value is an integer
273
+ if (!Number.isInteger(n)) {
274
+ return 0;
275
+ }
276
+ // T019/T020: Clamp to valid octet range
277
+ if (n < 0 || n > 255) {
278
+ return 0;
279
+ }
280
+ return n;
281
+ }
282
+
202
283
  /**
203
284
  * Convert IPv4 to 32-bit number for comparison.
285
+ * Uses defensive octet validation to handle malformed input.
204
286
  */
205
287
  function ipv4ToNumber(ip: string): number {
206
- const octets = ip.split('.').map(Number);
288
+ const octets = ip.split('.').map((n) => clampOctet(Number(n)));
207
289
  const o0 = octets[0] ?? 0;
208
290
  const o1 = octets[1] ?? 0;
209
291
  const o2 = octets[2] ?? 0;
@@ -227,12 +309,7 @@ export function compareIPv4(a: string, b: string): number {
227
309
  */
228
310
  function expandIPv6(ip: string): string[] {
229
311
  // Strip zone ID
230
- let addr = ip;
231
- const zoneIndex = ip.indexOf('%');
232
- if (zoneIndex !== -1) {
233
- addr = ip.substring(0, zoneIndex);
234
- }
235
-
312
+ const addr = stripZoneId(ip);
236
313
  const parts = addr.split(':');
237
314
  const result: string[] = [];
238
315
 
@@ -315,12 +392,37 @@ export function sortIPv6Addresses(ips: string[]): string[] {
315
392
 
316
393
  /**
317
394
  * Parse subnet into network and prefix.
395
+ * T016/T017/T018: Validates format before parsing.
396
+ *
397
+ * @param subnet - CIDR notation string (e.g., "10.0.0.0/24")
398
+ * @returns Parsed network address and prefix length
399
+ * @throws InputValidationError if format is invalid
318
400
  */
319
401
  function parseSubnet(subnet: string): { network: string; prefix: number } {
320
402
  const slashIndex = subnet.lastIndexOf('/');
403
+
404
+ // T016: Validate slash presence
405
+ if (slashIndex === -1) {
406
+ throw new InputValidationError(
407
+ `Invalid subnet format (missing /): ${subnet}`,
408
+ 'INVALID_FORMAT'
409
+ );
410
+ }
411
+
412
+ const prefixStr = subnet.substring(slashIndex + 1);
413
+ const prefix = parseInt(prefixStr, 10);
414
+
415
+ // T017: Validate prefix is a valid number
416
+ if (isNaN(prefix)) {
417
+ throw new InputValidationError(
418
+ `Invalid subnet prefix: ${prefixStr}`,
419
+ 'INVALID_FORMAT'
420
+ );
421
+ }
422
+
321
423
  return {
322
424
  network: subnet.substring(0, slashIndex),
323
- prefix: parseInt(subnet.substring(slashIndex + 1), 10),
425
+ prefix,
324
426
  };
325
427
  }
326
428
 
@@ -449,39 +551,6 @@ function wildcardToCidr(wildcard: string): number {
449
551
  return prefix;
450
552
  }
451
553
 
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
554
  /**
486
555
  * Create an empty IP summary.
487
556
  */
@@ -513,6 +582,15 @@ export function extractIPSummary(content: string, options: ExtractOptions = {}):
513
582
  return createEmptyIPSummary();
514
583
  }
515
584
 
585
+ // T011/T012: Validate content size to prevent DoS via memory exhaustion
586
+ const maxSize = options.maxContentSize ?? DEFAULT_MAX_CONTENT_SIZE;
587
+ if (content.length > maxSize) {
588
+ throw new InputValidationError(
589
+ `Content exceeds maximum size of ${maxSize} bytes`,
590
+ 'SIZE_LIMIT_EXCEEDED'
591
+ );
592
+ }
593
+
516
594
  const ipv4Set = new Set<string>();
517
595
  const ipv6Set = new Set<string>();
518
596
  const ipv4SubnetSet = new Set<string>();
@@ -527,7 +605,7 @@ export function extractIPSummary(content: string, options: ExtractOptions = {}):
527
605
  // Extract IPv4 subnets first (so we can exclude their network addresses from standalone IPs)
528
606
  if (!options.skipSubnets) {
529
607
  // Extract CIDR notation subnets (e.g., 10.0.0.0/24)
530
- const ipv4CidrMatches = content.matchAll(IPV4_CIDR_PATTERN);
608
+ const ipv4CidrMatches = content.matchAll(createIPv4CidrPattern());
531
609
  for (const match of ipv4CidrMatches) {
532
610
  const subnet = match[0];
533
611
  if (isValidSubnet(subnet)) {
@@ -540,7 +618,7 @@ export function extractIPSummary(content: string, options: ExtractOptions = {}):
540
618
 
541
619
  // Extract IP + mask pairs (e.g., "192.168.1.1 255.255.255.0")
542
620
  // These are interface addresses with their subnet info
543
- const ipMaskMatches = content.matchAll(IPV4_WITH_MASK_PATTERN);
621
+ const ipMaskMatches = content.matchAll(createIPv4WithMaskPattern());
544
622
  for (const match of ipMaskMatches) {
545
623
  const ip = match[1];
546
624
  const mask = match[2];
@@ -556,7 +634,7 @@ export function extractIPSummary(content: string, options: ExtractOptions = {}):
556
634
  }
557
635
 
558
636
  // 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);
637
+ const ipMaskKeywordMatches = content.matchAll(createIPv4WithMaskKeywordPattern());
560
638
  for (const match of ipMaskKeywordMatches) {
561
639
  const ip = match[1];
562
640
  const mask = match[2];
@@ -573,7 +651,7 @@ export function extractIPSummary(content: string, options: ExtractOptions = {}):
573
651
 
574
652
  // Extract IP + wildcard pairs (e.g., "192.168.1.0 0.0.0.255" in ACLs)
575
653
  // These define network ranges in access lists
576
- const ipWildcardMatches = content.matchAll(IPV4_WITH_WILDCARD_PATTERN);
654
+ const ipWildcardMatches = content.matchAll(createIPv4WithWildcardPattern());
577
655
  for (const match of ipWildcardMatches) {
578
656
  const ip = match[1];
579
657
  const wildcard = match[2];
@@ -590,7 +668,7 @@ export function extractIPSummary(content: string, options: ExtractOptions = {}):
590
668
  }
591
669
 
592
670
  // Extract IPv4 addresses (excluding those that are subnet network addresses)
593
- const ipv4Matches = content.matchAll(IPV4_PATTERN);
671
+ const ipv4Matches = content.matchAll(createIPv4Pattern());
594
672
  for (const match of ipv4Matches) {
595
673
  const ip = match[0];
596
674
  if (isValidIPv4(ip)) {
@@ -615,7 +693,7 @@ export function extractIPSummary(content: string, options: ExtractOptions = {}):
615
693
  if (!options.skipIPv6) {
616
694
  // Extract IPv6 subnets first
617
695
  if (!options.skipSubnets) {
618
- const ipv6CidrMatches = content.matchAll(IPV6_CIDR_PATTERN);
696
+ const ipv6CidrMatches = content.matchAll(createIPv6CidrPattern());
619
697
  for (const match of ipv6CidrMatches) {
620
698
  const subnet = match[0];
621
699
  if (isValidSubnet(subnet)) {
@@ -628,7 +706,7 @@ export function extractIPSummary(content: string, options: ExtractOptions = {}):
628
706
  }
629
707
 
630
708
  // Extract IPv6 addresses
631
- const ipv6Matches = content.matchAll(IPV6_PATTERN);
709
+ const ipv6Matches = content.matchAll(createIPv6Pattern());
632
710
  for (const match of ipv6Matches) {
633
711
  const ip = match[0];
634
712
  if (isValidIPv6(ip)) {
@@ -640,6 +718,18 @@ export function extractIPSummary(content: string, options: ExtractOptions = {}):
640
718
  }
641
719
  }
642
720
 
721
+ // If includeSubnetNetworks is true, add subnet network addresses to the address sets
722
+ if (options.includeSubnetNetworks) {
723
+ for (const subnet of ipv4SubnetSet) {
724
+ const { network } = parseSubnet(subnet);
725
+ ipv4Set.add(network);
726
+ }
727
+ for (const subnet of ipv6SubnetSet) {
728
+ const { network } = parseSubnet(subnet);
729
+ ipv6Set.add(network);
730
+ }
731
+ }
732
+
643
733
  // Convert sets to sorted arrays
644
734
  const ipv4Addresses = sortIPv4Addresses([...ipv4Set]);
645
735
  const ipv6Addresses = sortIPv6Addresses([...ipv6Set]);
package/src/ip/index.ts CHANGED
@@ -21,4 +21,7 @@ export type {
21
21
  IPSummary,
22
22
  IPCounts,
23
23
  ExtractOptions,
24
+ InputValidationErrorCode,
24
25
  } from './types';
26
+
27
+ export { InputValidationError, DEFAULT_MAX_CONTENT_SIZE } from './types';
package/src/ip/types.ts CHANGED
@@ -1,5 +1,42 @@
1
1
  // packages/core/src/ip/types.ts
2
2
 
3
+ // ============================================================================
4
+ // Constants
5
+ // ============================================================================
6
+
7
+ /**
8
+ * Default maximum content size for IP extraction (50MB).
9
+ * Prevents DoS attacks via memory exhaustion from processing very large files.
10
+ */
11
+ export const DEFAULT_MAX_CONTENT_SIZE = 50 * 1024 * 1024; // 50MB
12
+
13
+ // ============================================================================
14
+ // Error Types
15
+ // ============================================================================
16
+
17
+ /**
18
+ * Error codes for input validation failures.
19
+ */
20
+ export type InputValidationErrorCode = 'SIZE_LIMIT_EXCEEDED' | 'INVALID_FORMAT';
21
+
22
+ /**
23
+ * Error thrown when input validation fails.
24
+ * Used for size limits and malformed input detection.
25
+ */
26
+ export class InputValidationError extends Error {
27
+ constructor(
28
+ message: string,
29
+ public readonly code: InputValidationErrorCode
30
+ ) {
31
+ super(message);
32
+ this.name = 'InputValidationError';
33
+ }
34
+ }
35
+
36
+ // ============================================================================
37
+ // Core Types
38
+ // ============================================================================
39
+
3
40
  /**
4
41
  * Discriminator for IPv4 vs IPv6 addresses.
5
42
  */
@@ -97,4 +134,18 @@ export interface ExtractOptions {
97
134
  * @default false
98
135
  */
99
136
  skipSubnets?: boolean;
137
+
138
+ /**
139
+ * Include subnet network addresses in the addresses lists.
140
+ * When true, a subnet like 10.0.0.0/24 will add 10.0.0.0 to ipv4Addresses.
141
+ * @default false
142
+ */
143
+ includeSubnetNetworks?: boolean;
144
+
145
+ /**
146
+ * Maximum content size in bytes.
147
+ * Content exceeding this limit will throw InputValidationError.
148
+ * @default DEFAULT_MAX_CONTENT_SIZE (50MB)
149
+ */
150
+ maxContentSize?: number;
100
151
  }
@@ -35,12 +35,31 @@ import { getAllVendorModules } from '../helpers';
35
35
  * All rule helpers merged into a single object for injection.
36
36
  * This allows compiled check functions to access helpers by name.
37
37
  * Dynamically built from the helpers module.
38
+ *
39
+ * IMPORTANT: Vendor modules may have colliding helper names (e.g., both Cisco
40
+ * and Cumulus export `hasBgpNeighborPassword` with different signatures).
41
+ * To handle this:
42
+ * 1. Vendor namespaces are added (e.g., `cisco.hasBgpNeighborPassword`)
43
+ * 2. For flat/short names, FIRST vendor wins (no overwrites)
44
+ *
45
+ * Rules should use namespaced helpers for vendor-specific functions.
38
46
  */
39
47
  function buildAllHelpers(): Record<string, unknown> {
40
48
  const result: Record<string, unknown> = { ...helpers };
41
49
  const vendorModules = getAllVendorModules();
42
- for (const [_name, module] of Object.entries(vendorModules)) {
43
- Object.assign(result, module);
50
+
51
+ for (const [name, module] of Object.entries(vendorModules)) {
52
+ // Add the entire vendor module under its namespace
53
+ // e.g., result.cisco = { hasBgpNeighborPassword, getBgpNeighbors, ... }
54
+ result[name] = module;
55
+
56
+ // Add flat/short names ONLY if not already present (first vendor wins)
57
+ // This prevents Cumulus from overwriting Cisco's hasBgpNeighborPassword
58
+ for (const [key, value] of Object.entries(module as Record<string, unknown>)) {
59
+ if (!(key in result)) {
60
+ result[key] = value;
61
+ }
62
+ }
44
63
  }
45
64
  return result;
46
65
  }
@@ -215,9 +234,13 @@ export async function loadEncryptedPack(
215
234
  /**
216
235
  * Generate helper names list for destructuring.
217
236
  * Cached to avoid recomputing on every function compilation.
237
+ *
238
+ * Includes:
239
+ * - All function helpers (flat names)
240
+ * - All vendor namespace objects (e.g., cisco, cumulus)
218
241
  */
219
242
  const helperNames = Object.keys(allHelpers).filter(
220
- key => typeof allHelpers[key] === 'function'
243
+ key => typeof allHelpers[key] === 'function' || typeof allHelpers[key] === 'object'
221
244
  );
222
245
  const helperDestructure = helperNames.join(', ');
223
246
 
@@ -229,8 +252,10 @@ const helperDestructure = helperNames.join(', ');
229
252
  *
230
253
  * The function is wrapped to inject all rule helpers into scope, allowing
231
254
  * serialized check functions to use helpers like hasChildCommand, findStanza, etc.
255
+ *
256
+ * @public Exported for use by GRX2ExtendedLoader
232
257
  */
233
- function compileNativeCheckFunction(
258
+ export function compileNativeCheckFunction(
234
259
  source: string
235
260
  ): (node: ConfigNode, ctx: Context) => ReturnType<IRule['check']> {
236
261
  // The source is trusted (from authenticated encrypted pack)
@@ -328,6 +328,53 @@ export class SchemaAwareParser {
328
328
  }
329
329
  }
330
330
 
331
+ // FLAT-CONFIG-FIX: For flat configs (indent=0) with intentional multi-depth patterns,
332
+ // search up the parent stack to find the best valid ancestor.
333
+ // This enables correct nesting without relying on indentation.
334
+ // Only applies when the SAME pattern (identical regex) is defined at multiple depths.
335
+ if (isBlockStarter && currentLine.indent === 0) {
336
+ const multiDepthsForSamePattern =
337
+ this.getMultiDepthsForSamePattern(currentLine.sanitized);
338
+
339
+ if (multiDepthsForSamePattern.length > 1) {
340
+ // Cache block type extraction (first word) for sibling detection
341
+ // This avoids repeated regex splits on the same string
342
+ const currentBlockType = currentLine.sanitized.split(/\s+/)[0];
343
+
344
+ // Search stack from most recent to oldest for a valid parent
345
+ // Limited to MAX_NESTING_DEPTH to bound worst-case iteration
346
+ const maxSearch = Math.min(parentStack.length, MAX_NESTING_DEPTH);
347
+ for (let i = parentStack.length - 1; i >= parentStack.length - maxSearch; i--) {
348
+ const ancestor = parentStack[i];
349
+ if (
350
+ ancestor?.type === 'section' &&
351
+ ancestor.blockDepth !== undefined
352
+ ) {
353
+ // Sibling detection: Skip same block types - they should be siblings, not parent/child
354
+ // Example: address-family X and address-family Y should be siblings
355
+ // NOTE: Uses first word only, which works for most cases but may not handle
356
+ // commands like "router bgp" vs "router ospf" where both have "router" as first word.
357
+ // This is acceptable as those blocks typically don't nest.
358
+ const ancestorBlockType = ancestor.id.split(/\s+/)[0];
359
+ if (ancestorBlockType === currentBlockType) {
360
+ continue;
361
+ }
362
+
363
+ const ancestorDepth = ancestor.blockDepth;
364
+ // Find depths that would make us a valid child of this ancestor
365
+ const validChildDepths = multiDepthsForSamePattern.filter(
366
+ (d) => d > ancestorDepth
367
+ );
368
+ if (validChildDepths.length > 0) {
369
+ // Use the smallest valid depth (closest nesting level)
370
+ blockStarterDepth = Math.min(...validChildDepths);
371
+ break;
372
+ }
373
+ }
374
+ }
375
+ }
376
+ }
377
+
331
378
  const newNodeType: NodeType = isBlockStarter ? 'section' : 'command';
332
379
  const modifiedLine: ParsedLine = {
333
380
  ...currentLine,
@@ -429,6 +476,43 @@ export class SchemaAwareParser {
429
476
  return depths;
430
477
  }
431
478
 
479
+ /**
480
+ * Returns depths only for patterns where the SAME regex pattern is defined
481
+ * at multiple depth levels. This is used for flat config parsing where we need
482
+ * to detect intentional multi-depth patterns (like address-family at depth 1 AND 2)
483
+ * vs accidental overlaps (like vrf definition matching both vrf\s+definition and vrf\s+\S+).
484
+ *
485
+ * Example: address-family\s+\S+ at depth 1 AND depth 2 → returns [1, 2]
486
+ * Example: vrf definition X matching vrf\s+definition (depth 0) AND vrf\s+\S+ (depth 2) → returns []
487
+ */
488
+ private getMultiDepthsForSamePattern(sanitizedLine: string): number[] {
489
+ const patternToDepths = new Map<string, number[]>();
490
+
491
+ for (const def of this.vendor.blockStarters) {
492
+ if (def.pattern.test(sanitizedLine)) {
493
+ // Include regex flags in key to properly distinguish patterns
494
+ // e.g., /pattern/i and /pattern/ are different patterns
495
+ const key = `${def.pattern.source}|${def.pattern.flags}`;
496
+ if (!patternToDepths.has(key)) {
497
+ patternToDepths.set(key, []);
498
+ }
499
+ patternToDepths.get(key)!.push(def.depth);
500
+ }
501
+ }
502
+
503
+ // Return depths for the FIRST pattern that appears at multiple depths.
504
+ // This enables multi-depth nesting (e.g., address-family at depth 1 AND 2).
505
+ // Note: If multiple different patterns have multi-depth definitions, only
506
+ // the first one found is returned. This is sufficient for current use cases
507
+ // where we just need to know IF multi-depth nesting applies.
508
+ for (const depths of patternToDepths.values()) {
509
+ if (depths.length > 1) {
510
+ return depths;
511
+ }
512
+ }
513
+ return [];
514
+ }
515
+
432
516
  /**
433
517
  * Checks if a sanitized line matches any of the defined BlockEnders regexes.
434
518
  */
@@ -84,18 +84,32 @@ export const CiscoIOSSchema: VendorSchema = {
84
84
  { pattern: /^control-plane/i, depth: 0 },
85
85
  { pattern: /^ip\s+ips\s+signature-category/i, depth: 0 },
86
86
 
87
- // ============ DEPTH 1: Inside routing protocols ============
87
+ // ============ DEPTH 1: Inside routing protocols / policy-map ============
88
88
 
89
89
  { pattern: /^address-family\s+\S+/i, depth: 1 },
90
- { pattern: /^af-interface\s+\S+/i, depth: 1 },
91
- { pattern: /^topology\s+\S+/i, depth: 1 },
92
90
  { pattern: /^service-family\s+\S+/i, depth: 1 },
93
- { pattern: /^class\s+\S+/i, depth: 1 },
94
91
  { pattern: /^category\s+\S+/i, depth: 1 },
95
92
 
96
- // ============ DEPTH 2: Inside address-family ============
93
+ // Inside archive block
94
+ { pattern: /^log\s+config/i, depth: 1 },
95
+
96
+ // Inside key chain block
97
+ { pattern: /^key\s+\d+/i, depth: 1 },
98
+
99
+ // Inside policy-map (class block)
100
+ { pattern: /^class\s+\S+/i, depth: 1 },
97
101
 
102
+ // ============ DEPTH 2: Inside address-family / class ============
103
+
104
+ // Inside address-family (named EIGRP)
105
+ { pattern: /^af-interface\s+\S+/i, depth: 2 },
106
+ { pattern: /^topology\s+\S+/i, depth: 2 },
107
+
108
+ // Inside address-family (BGP vrf)
98
109
  { pattern: /^vrf\s+\S+/i, depth: 2 },
110
+
111
+ // Inside class (QoS policing)
112
+ { pattern: /^police\s+/i, depth: 2 },
99
113
  ],
100
114
 
101
115
  blockEnders: [
@@ -90,13 +90,21 @@ export const CiscoNXOSSchema: VendorSchema = {
90
90
 
91
91
  { pattern: /^address-family\s+\S+/i, depth: 1 },
92
92
  { pattern: /^vrf\s+member\s+\S+/i, depth: 1 },
93
+ // VRF sub-context inside router bgp (e.g., "vrf TENANT-A")
94
+ // Uses negative lookahead (?!member\s) to avoid matching "vrf member X"
95
+ // This removes the ordering dependency with the vrf member pattern above
96
+ { pattern: /^vrf\s+(?!member\s)\S+/i, depth: 1 },
93
97
  { pattern: /^template\s+peer\s+\S+/i, depth: 1 },
94
98
  { pattern: /^neighbor\s+\S+/i, depth: 1 },
95
99
  { pattern: /^class\s+\S+/i, depth: 1 },
96
100
 
97
- // ============ DEPTH 2: Inside address-family ============
101
+ // ============ DEPTH 2: Inside neighbor / address-family ============
98
102
 
99
- // Note: NX-OS uses different VRF nesting than IOS
103
+ // address-family inside neighbor block
104
+ { pattern: /^address-family\s+\S+/i, depth: 2 },
105
+
106
+ // Inside policy-map class (QoS)
107
+ { pattern: /^police\s+/i, depth: 2 },
100
108
  ],
101
109
 
102
110
  blockEnders: [