@sentriflow/core 0.1.0
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/LICENSE +190 -0
- package/README.md +86 -0
- package/package.json +60 -0
- package/src/constants.ts +77 -0
- package/src/engine/RuleExecutor.ts +256 -0
- package/src/engine/Runner.ts +312 -0
- package/src/engine/SandboxedExecutor.ts +208 -0
- package/src/errors.ts +88 -0
- package/src/helpers/arista/helpers.ts +1220 -0
- package/src/helpers/arista/index.ts +12 -0
- package/src/helpers/aruba/helpers.ts +637 -0
- package/src/helpers/aruba/index.ts +13 -0
- package/src/helpers/cisco/helpers.ts +534 -0
- package/src/helpers/cisco/index.ts +11 -0
- package/src/helpers/common/helpers.ts +265 -0
- package/src/helpers/common/index.ts +5 -0
- package/src/helpers/common/validation.ts +280 -0
- package/src/helpers/cumulus/helpers.ts +676 -0
- package/src/helpers/cumulus/index.ts +12 -0
- package/src/helpers/extreme/helpers.ts +422 -0
- package/src/helpers/extreme/index.ts +12 -0
- package/src/helpers/fortinet/helpers.ts +892 -0
- package/src/helpers/fortinet/index.ts +12 -0
- package/src/helpers/huawei/helpers.ts +790 -0
- package/src/helpers/huawei/index.ts +11 -0
- package/src/helpers/index.ts +53 -0
- package/src/helpers/juniper/helpers.ts +756 -0
- package/src/helpers/juniper/index.ts +12 -0
- package/src/helpers/mikrotik/helpers.ts +722 -0
- package/src/helpers/mikrotik/index.ts +12 -0
- package/src/helpers/nokia/helpers.ts +856 -0
- package/src/helpers/nokia/index.ts +11 -0
- package/src/helpers/paloalto/helpers.ts +939 -0
- package/src/helpers/paloalto/index.ts +12 -0
- package/src/helpers/vyos/helpers.ts +429 -0
- package/src/helpers/vyos/index.ts +12 -0
- package/src/index.ts +30 -0
- package/src/json-rules/ExpressionEvaluator.ts +292 -0
- package/src/json-rules/HelperRegistry.ts +177 -0
- package/src/json-rules/JsonRuleCompiler.ts +339 -0
- package/src/json-rules/JsonRuleValidator.ts +371 -0
- package/src/json-rules/index.ts +97 -0
- package/src/json-rules/schema.json +350 -0
- package/src/json-rules/types.ts +303 -0
- package/src/pack-loader/PackLoader.ts +332 -0
- package/src/pack-loader/index.ts +17 -0
- package/src/pack-loader/types.ts +135 -0
- package/src/parser/IncrementalParser.ts +527 -0
- package/src/parser/Sanitizer.ts +104 -0
- package/src/parser/SchemaAwareParser.ts +504 -0
- package/src/parser/VendorSchema.ts +72 -0
- package/src/parser/vendors/arista-eos.ts +206 -0
- package/src/parser/vendors/aruba-aoscx.ts +123 -0
- package/src/parser/vendors/aruba-aosswitch.ts +113 -0
- package/src/parser/vendors/aruba-wlc.ts +173 -0
- package/src/parser/vendors/cisco-ios.ts +110 -0
- package/src/parser/vendors/cisco-nxos.ts +107 -0
- package/src/parser/vendors/cumulus-linux.ts +161 -0
- package/src/parser/vendors/extreme-exos.ts +154 -0
- package/src/parser/vendors/extreme-voss.ts +167 -0
- package/src/parser/vendors/fortinet-fortigate.ts +217 -0
- package/src/parser/vendors/huawei-vrp.ts +192 -0
- package/src/parser/vendors/index.ts +1521 -0
- package/src/parser/vendors/juniper-junos.ts +230 -0
- package/src/parser/vendors/mikrotik-routeros.ts +274 -0
- package/src/parser/vendors/nokia-sros.ts +251 -0
- package/src/parser/vendors/paloalto-panos.ts +264 -0
- package/src/parser/vendors/vyos-vyos.ts +454 -0
- package/src/types/ConfigNode.ts +72 -0
- package/src/types/DeclarativeRule.ts +158 -0
- package/src/types/IRule.ts +270 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// packages/core/src/parser/Sanitizer.ts
|
|
2
|
+
|
|
3
|
+
import { MAX_LINE_LENGTH } from '../constants';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Control characters to remove (M-3 fix: Insufficient Input Sanitization).
|
|
7
|
+
* Includes ASCII control characters except tab (\x09) which is valid whitespace.
|
|
8
|
+
* - 0x00-0x08: NUL, SOH, STX, ETX, EOT, ENQ, ACK, BEL, BS
|
|
9
|
+
* - 0x0B: VT (Vertical Tab)
|
|
10
|
+
* - 0x0C: FF (Form Feed)
|
|
11
|
+
* - 0x0E-0x1F: SO, SI, DLE, DC1-DC4, NAK, SYN, ETB, CAN, EM, SUB, ESC, FS, GS, RS, US
|
|
12
|
+
* - 0x7F: DEL
|
|
13
|
+
*/
|
|
14
|
+
const CONTROL_CHARS = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Unicode whitespace characters to normalize to ASCII space.
|
|
18
|
+
* - \u00A0: No-Break Space
|
|
19
|
+
* - \u2000-\u200A: Various width spaces (En Quad through Hair Space)
|
|
20
|
+
* - \u202F: Narrow No-Break Space
|
|
21
|
+
* - \u205F: Medium Mathematical Space
|
|
22
|
+
* - \u3000: Ideographic Space
|
|
23
|
+
*/
|
|
24
|
+
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Sanitizes input text by:
|
|
28
|
+
* 1. Removing control characters (security hardening)
|
|
29
|
+
* 2. Replacing Unicode space characters with standard ASCII space
|
|
30
|
+
* 3. Trimming leading/trailing whitespace
|
|
31
|
+
*
|
|
32
|
+
* This function is crucial for ensuring consistent parsing of configuration files
|
|
33
|
+
* that might contain non-standard characters, which could otherwise lead to
|
|
34
|
+
* parsing errors, security issues, or inconsistent matching by regexes.
|
|
35
|
+
*
|
|
36
|
+
* @param text The input string potentially containing various control/whitespace characters.
|
|
37
|
+
* @returns The sanitized string with uniform ASCII spaces and no leading/trailing whitespace.
|
|
38
|
+
*/
|
|
39
|
+
export function sanitizeText(text: string): string {
|
|
40
|
+
if (typeof text !== 'string') {
|
|
41
|
+
// Handle non-string input gracefully, though type-checking should prevent this.
|
|
42
|
+
return String(text).trim();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return text
|
|
46
|
+
.replace(CONTROL_CHARS, '') // Remove control characters (M-3 fix)
|
|
47
|
+
.replace(UNICODE_SPACES, ' ') // Normalize Unicode spaces
|
|
48
|
+
.trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Parses a line into parameters, handling quoted strings properly.
|
|
53
|
+
* This addresses M-3 where simple split(/\s+/) breaks on quoted passwords/descriptions.
|
|
54
|
+
*
|
|
55
|
+
* SEC-006: Added explicit length check to prevent DoS via very long strings.
|
|
56
|
+
* While upstream MAX_LINE_LENGTH provides protection, direct calls could bypass it.
|
|
57
|
+
*
|
|
58
|
+
* @param line The sanitized line to parse into parameters.
|
|
59
|
+
* @returns An array of parameter strings.
|
|
60
|
+
*/
|
|
61
|
+
export function parseParameters(line: string): string[] {
|
|
62
|
+
// SEC-006: Explicit length check to prevent DoS on direct calls
|
|
63
|
+
// that might bypass upstream line length validation
|
|
64
|
+
if (line.length > MAX_LINE_LENGTH) {
|
|
65
|
+
// Return truncated line as single parameter rather than processing
|
|
66
|
+
return [line.slice(0, MAX_LINE_LENGTH)];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const params: string[] = [];
|
|
70
|
+
let current = '';
|
|
71
|
+
let inQuote = false;
|
|
72
|
+
let quoteChar = '';
|
|
73
|
+
|
|
74
|
+
for (let i = 0; i < line.length; i++) {
|
|
75
|
+
const char = line[i];
|
|
76
|
+
if (!char) continue;
|
|
77
|
+
|
|
78
|
+
if (!inQuote && (char === '"' || char === "'")) {
|
|
79
|
+
// Start of quoted string
|
|
80
|
+
inQuote = true;
|
|
81
|
+
quoteChar = char;
|
|
82
|
+
} else if (inQuote && char === quoteChar) {
|
|
83
|
+
// End of quoted string
|
|
84
|
+
inQuote = false;
|
|
85
|
+
quoteChar = '';
|
|
86
|
+
} else if (!inQuote && /\s/.test(char)) {
|
|
87
|
+
// Whitespace outside quotes - end of parameter
|
|
88
|
+
if (current.length > 0) {
|
|
89
|
+
params.push(current);
|
|
90
|
+
current = '';
|
|
91
|
+
}
|
|
92
|
+
} else {
|
|
93
|
+
// Regular character - add to current parameter
|
|
94
|
+
current += char;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Don't forget the last parameter
|
|
99
|
+
if (current.length > 0) {
|
|
100
|
+
params.push(current);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return params;
|
|
104
|
+
}
|
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
// packages/core/src/parser/SchemaAwareParser.ts
|
|
2
|
+
|
|
3
|
+
import type { ConfigNode, NodeType } from '../types/ConfigNode';
|
|
4
|
+
import type { VendorSchema, BlockStarterDef } from './VendorSchema';
|
|
5
|
+
import { defaultVendor } from './vendors';
|
|
6
|
+
import { sanitizeText, parseParameters } from './Sanitizer';
|
|
7
|
+
import {
|
|
8
|
+
MAX_LINE_LENGTH,
|
|
9
|
+
MAX_CONFIG_SIZE,
|
|
10
|
+
MAX_NESTING_DEPTH,
|
|
11
|
+
MAX_LINE_COUNT,
|
|
12
|
+
} from '../constants';
|
|
13
|
+
import { SentriflowParseError, SentriflowSizeLimitError } from '../errors';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Options for the SchemaAwareParser.
|
|
17
|
+
*/
|
|
18
|
+
export interface ParserOptions {
|
|
19
|
+
/**
|
|
20
|
+
* The starting line number for the input text, useful for snippets.
|
|
21
|
+
* @default 0
|
|
22
|
+
*/
|
|
23
|
+
startLine?: number;
|
|
24
|
+
/**
|
|
25
|
+
* Whether the input is a full configuration ('base') or a partial snippet ('snippet').
|
|
26
|
+
* @default 'base'
|
|
27
|
+
*/
|
|
28
|
+
source?: 'base' | 'snippet';
|
|
29
|
+
/**
|
|
30
|
+
* Vendor schema to use for parsing. If not specified, defaults to Cisco IOS.
|
|
31
|
+
* Use detectVendor() to auto-detect the vendor from config content.
|
|
32
|
+
*/
|
|
33
|
+
vendor?: VendorSchema;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Represents a line processed during parsing, including its content and indentation level.
|
|
38
|
+
*/
|
|
39
|
+
interface ParsedLine {
|
|
40
|
+
original: string;
|
|
41
|
+
sanitized: string;
|
|
42
|
+
lineNumber: number;
|
|
43
|
+
indent: number;
|
|
44
|
+
isBlockStarter: boolean;
|
|
45
|
+
blockStarterDepth: number;
|
|
46
|
+
isBlockEnder: boolean;
|
|
47
|
+
hasBraceOpen: boolean;
|
|
48
|
+
hasBraceClose: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Implements a permissive parser that can interpret hierarchical configuration
|
|
53
|
+
* structures even from flattened text or snippets, using both indentation
|
|
54
|
+
* and schema-aware block starters.
|
|
55
|
+
*
|
|
56
|
+
* Supports both indentation-based hierarchy (Cisco IOS) and brace-based
|
|
57
|
+
* hierarchy (Juniper JunOS) through the VendorSchema system.
|
|
58
|
+
*/
|
|
59
|
+
export class SchemaAwareParser {
|
|
60
|
+
private readonly options: Required<Omit<ParserOptions, 'vendor'>>;
|
|
61
|
+
private readonly vendor: VendorSchema;
|
|
62
|
+
|
|
63
|
+
constructor(options?: ParserOptions) {
|
|
64
|
+
this.options = {
|
|
65
|
+
startLine: options?.startLine ?? 0,
|
|
66
|
+
source: options?.source ?? 'base',
|
|
67
|
+
};
|
|
68
|
+
this.vendor = options?.vendor ?? defaultVendor;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Get the vendor schema being used by this parser instance.
|
|
73
|
+
*/
|
|
74
|
+
public getVendor(): VendorSchema {
|
|
75
|
+
return this.vendor;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Parses the input configuration text into an Abstract Syntax Tree (AST) of ConfigNodes.
|
|
80
|
+
* It attempts to infer hierarchy using indentation and predefined block-starting keywords.
|
|
81
|
+
*
|
|
82
|
+
* For brace-based vendors (Juniper), tracks brace depth to determine hierarchy.
|
|
83
|
+
* For indentation-based vendors (Cisco), uses schema-defined block starters and depth.
|
|
84
|
+
*
|
|
85
|
+
* Security: Validates input size and line lengths to prevent DoS attacks.
|
|
86
|
+
*
|
|
87
|
+
* @param configText The raw configuration text to parse.
|
|
88
|
+
* @returns An array of top-level ConfigNodes representing the parsed configuration.
|
|
89
|
+
* @throws SentriflowSizeLimitError if input exceeds size limits
|
|
90
|
+
*/
|
|
91
|
+
public parse(configText: string): ConfigNode[] {
|
|
92
|
+
if (configText.length > MAX_CONFIG_SIZE) {
|
|
93
|
+
throw new SentriflowSizeLimitError(
|
|
94
|
+
`Configuration exceeds maximum size of ${
|
|
95
|
+
MAX_CONFIG_SIZE / 1024 / 1024
|
|
96
|
+
}MB`
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const lines = configText.split('\n');
|
|
101
|
+
|
|
102
|
+
if (lines.length > MAX_LINE_COUNT) {
|
|
103
|
+
throw new SentriflowSizeLimitError(
|
|
104
|
+
`Configuration exceeds maximum line count of ${MAX_LINE_COUNT}`
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Use brace-based parsing for Juniper-style configs
|
|
109
|
+
if (this.vendor.useBraceHierarchy) {
|
|
110
|
+
return this.parseBraceHierarchy(lines);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Use indentation-based parsing for Cisco-style configs
|
|
114
|
+
return this.parseIndentationHierarchy(lines);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Parses configuration using brace-based hierarchy (Juniper JunOS style).
|
|
119
|
+
* Tracks opening and closing braces to determine block depth.
|
|
120
|
+
*/
|
|
121
|
+
private parseBraceHierarchy(lines: string[]): ConfigNode[] {
|
|
122
|
+
const rootNodes: ConfigNode[] = [];
|
|
123
|
+
const parentStack: ConfigNode[] = [];
|
|
124
|
+
let braceDepth = 0;
|
|
125
|
+
|
|
126
|
+
for (let i = 0; i < lines.length; i++) {
|
|
127
|
+
const originalLine = lines[i];
|
|
128
|
+
if (!originalLine) continue;
|
|
129
|
+
|
|
130
|
+
if (originalLine.length > MAX_LINE_LENGTH) {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const sanitizedLine = sanitizeText(originalLine);
|
|
135
|
+
|
|
136
|
+
// Skip empty lines and comments
|
|
137
|
+
if (sanitizedLine.length === 0 || this.isComment(sanitizedLine)) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Check for braces in the line
|
|
142
|
+
const hasBraceOpen = sanitizedLine.includes('{');
|
|
143
|
+
const hasBraceClose = sanitizedLine.includes('}');
|
|
144
|
+
|
|
145
|
+
// Handle closing brace - pop from stack
|
|
146
|
+
if (hasBraceClose) {
|
|
147
|
+
const closeCount = (sanitizedLine.match(/\}/g) || []).length;
|
|
148
|
+
for (let j = 0; j < closeCount; j++) {
|
|
149
|
+
if (parentStack.length > 0) {
|
|
150
|
+
parentStack.pop();
|
|
151
|
+
}
|
|
152
|
+
braceDepth = Math.max(0, braceDepth - 1);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// If line is just a closing brace, continue to next line
|
|
156
|
+
if (sanitizedLine.trim() === '}') {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Process content (before opening brace if present)
|
|
162
|
+
let contentLine = sanitizedLine;
|
|
163
|
+
if (hasBraceOpen) {
|
|
164
|
+
// Extract content before the brace
|
|
165
|
+
contentLine = sanitizedLine.replace(/\s*\{.*$/, '').trim();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Skip if no meaningful content
|
|
169
|
+
if (!contentLine || contentLine === '}') {
|
|
170
|
+
// Handle opening brace on its own line or after content
|
|
171
|
+
if (hasBraceOpen) {
|
|
172
|
+
const openCount = (sanitizedLine.match(/\{/g) || []).length;
|
|
173
|
+
braceDepth += openCount;
|
|
174
|
+
}
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Remove trailing semicolon for cleaner node ID
|
|
179
|
+
const nodeId = contentLine.replace(/;$/, '').trim();
|
|
180
|
+
|
|
181
|
+
// Determine node type - if followed by brace or matches block starter, it's a section
|
|
182
|
+
const blockStarterDepth = this.getBlockStarterDepth(contentLine);
|
|
183
|
+
const isSection = hasBraceOpen || blockStarterDepth >= 0;
|
|
184
|
+
const nodeType: NodeType = isSection ? 'section' : 'command';
|
|
185
|
+
|
|
186
|
+
// Prevent excessive nesting
|
|
187
|
+
if (parentStack.length >= MAX_NESTING_DEPTH) {
|
|
188
|
+
while (parentStack.length >= MAX_NESTING_DEPTH) {
|
|
189
|
+
parentStack.pop();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const newNode = this.createConfigNode(
|
|
194
|
+
{
|
|
195
|
+
original: originalLine,
|
|
196
|
+
sanitized: nodeId,
|
|
197
|
+
lineNumber: this.options.startLine + i,
|
|
198
|
+
indent: originalLine.search(/\S|$/),
|
|
199
|
+
isBlockStarter: isSection,
|
|
200
|
+
blockStarterDepth:
|
|
201
|
+
blockStarterDepth >= 0 ? blockStarterDepth : braceDepth,
|
|
202
|
+
isBlockEnder: false,
|
|
203
|
+
hasBraceOpen,
|
|
204
|
+
hasBraceClose,
|
|
205
|
+
},
|
|
206
|
+
nodeType
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
// Add to parent or root
|
|
210
|
+
const currentParent = parentStack.at(-1);
|
|
211
|
+
if (currentParent) {
|
|
212
|
+
currentParent.children.push(newNode);
|
|
213
|
+
} else {
|
|
214
|
+
rootNodes.push(newNode);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// If this starts a new block (has opening brace), push to stack
|
|
218
|
+
if (hasBraceOpen && isSection) {
|
|
219
|
+
parentStack.push(newNode);
|
|
220
|
+
const openCount = (sanitizedLine.match(/\{/g) || []).length;
|
|
221
|
+
braceDepth += openCount;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return this.applyVirtualContext(rootNodes);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Parses configuration using indentation-based hierarchy (Cisco IOS style).
|
|
230
|
+
* Uses schema-defined block starters and depth for nesting.
|
|
231
|
+
*/
|
|
232
|
+
private parseIndentationHierarchy(lines: string[]): ConfigNode[] {
|
|
233
|
+
const lineContexts: ParsedLine[] = [];
|
|
234
|
+
|
|
235
|
+
for (let i = 0; i < lines.length; i++) {
|
|
236
|
+
const originalLine = lines[i];
|
|
237
|
+
if (!originalLine) continue;
|
|
238
|
+
|
|
239
|
+
if (originalLine.length > MAX_LINE_LENGTH) {
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const sanitizedLine = sanitizeText(originalLine);
|
|
244
|
+
|
|
245
|
+
// Skip empty lines and comments
|
|
246
|
+
if (sanitizedLine.length === 0 || this.isComment(sanitizedLine)) {
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const blockStarterDepth = this.getBlockStarterDepth(sanitizedLine);
|
|
251
|
+
lineContexts.push({
|
|
252
|
+
original: originalLine,
|
|
253
|
+
sanitized: sanitizedLine,
|
|
254
|
+
lineNumber: this.options.startLine + i,
|
|
255
|
+
indent: originalLine.search(/\S|$/),
|
|
256
|
+
isBlockStarter: blockStarterDepth >= 0,
|
|
257
|
+
blockStarterDepth,
|
|
258
|
+
isBlockEnder: this.isSchemaBlockEnder(sanitizedLine),
|
|
259
|
+
hasBraceOpen: false,
|
|
260
|
+
hasBraceClose: false,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const rootNodes: ConfigNode[] = [];
|
|
265
|
+
const parentStack: ConfigNode[] = [];
|
|
266
|
+
|
|
267
|
+
for (const currentLine of lineContexts) {
|
|
268
|
+
// SEC-FIX: Context-aware block starter override (CUMULUS_FIX.md)
|
|
269
|
+
// If a depth-0 pattern matches but the line is indented AND we're inside
|
|
270
|
+
// an iface/auto block, treat it as a child command, not a new block.
|
|
271
|
+
// This fixes the issue where "vrf mgmt" inside "iface eth0" was incorrectly
|
|
272
|
+
// parsed as a new top-level section instead of a child command.
|
|
273
|
+
let isBlockStarter = currentLine.isBlockStarter;
|
|
274
|
+
let blockStarterDepth = currentLine.blockStarterDepth;
|
|
275
|
+
|
|
276
|
+
if (isBlockStarter && currentLine.indent > 0) {
|
|
277
|
+
let sectionParent: ConfigNode | undefined;
|
|
278
|
+
for (let i = parentStack.length - 1; i >= 0; i--) {
|
|
279
|
+
const candidate = parentStack[i];
|
|
280
|
+
if (candidate?.blockDepth !== undefined) {
|
|
281
|
+
sectionParent = candidate;
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (sectionParent) {
|
|
287
|
+
const parentType = sectionParent.id.split(/\s+/)[0];
|
|
288
|
+
|
|
289
|
+
// For iface/auto blocks specifically, depth-0 patterns should be commands
|
|
290
|
+
// This must be checked FIRST before multi-depth logic
|
|
291
|
+
if (
|
|
292
|
+
(parentType === 'iface' || parentType === 'auto') &&
|
|
293
|
+
blockStarterDepth === 0
|
|
294
|
+
) {
|
|
295
|
+
// Override: treat as child command, not new block
|
|
296
|
+
isBlockStarter = false;
|
|
297
|
+
blockStarterDepth = -1;
|
|
298
|
+
} else {
|
|
299
|
+
if (currentLine.indent > sectionParent.indent) {
|
|
300
|
+
const uniqueDepths = [
|
|
301
|
+
...new Set(
|
|
302
|
+
this.getAllBlockStarterDepths(currentLine.sanitized)
|
|
303
|
+
),
|
|
304
|
+
];
|
|
305
|
+
const parentDepth = sectionParent.blockDepth ?? 0;
|
|
306
|
+
const expectedChildDepth = parentDepth + 1;
|
|
307
|
+
|
|
308
|
+
if (uniqueDepths.length > 1) {
|
|
309
|
+
if (uniqueDepths.includes(expectedChildDepth)) {
|
|
310
|
+
blockStarterDepth = expectedChildDepth;
|
|
311
|
+
} else {
|
|
312
|
+
const deeperDepth = Math.min(
|
|
313
|
+
...uniqueDepths.filter((depth) => depth > parentDepth)
|
|
314
|
+
);
|
|
315
|
+
if (Number.isFinite(deeperDepth)) {
|
|
316
|
+
blockStarterDepth = deeperDepth;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
} else if (
|
|
320
|
+
sectionParent.blockDepth !== undefined &&
|
|
321
|
+
blockStarterDepth <= parentDepth
|
|
322
|
+
) {
|
|
323
|
+
// Support nested Fortinet config/edit tables by forcing child depth
|
|
324
|
+
blockStarterDepth = expectedChildDepth;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const newNodeType: NodeType = isBlockStarter ? 'section' : 'command';
|
|
332
|
+
const modifiedLine: ParsedLine = {
|
|
333
|
+
...currentLine,
|
|
334
|
+
isBlockStarter,
|
|
335
|
+
blockStarterDepth,
|
|
336
|
+
};
|
|
337
|
+
const newNode = this.createConfigNode(modifiedLine, newNodeType);
|
|
338
|
+
|
|
339
|
+
if (parentStack.length >= MAX_NESTING_DEPTH) {
|
|
340
|
+
while (parentStack.length >= MAX_NESTING_DEPTH) {
|
|
341
|
+
parentStack.pop();
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
while (parentStack.length > 0) {
|
|
346
|
+
const topOfStack = parentStack.at(-1)!;
|
|
347
|
+
|
|
348
|
+
if (isBlockStarter) {
|
|
349
|
+
if (topOfStack.type !== 'section') {
|
|
350
|
+
parentStack.pop();
|
|
351
|
+
} else {
|
|
352
|
+
const parentDepth = topOfStack.blockDepth ?? 0;
|
|
353
|
+
const currentDepth = blockStarterDepth;
|
|
354
|
+
|
|
355
|
+
if (currentDepth > parentDepth) {
|
|
356
|
+
break;
|
|
357
|
+
} else {
|
|
358
|
+
parentStack.pop();
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
} else if (currentLine.isBlockEnder) {
|
|
362
|
+
if (topOfStack.type === 'section') {
|
|
363
|
+
parentStack.pop();
|
|
364
|
+
break;
|
|
365
|
+
} else {
|
|
366
|
+
parentStack.pop();
|
|
367
|
+
}
|
|
368
|
+
} else {
|
|
369
|
+
if (topOfStack.type === 'section') {
|
|
370
|
+
break;
|
|
371
|
+
} else if (currentLine.indent <= topOfStack.indent) {
|
|
372
|
+
parentStack.pop();
|
|
373
|
+
} else {
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const currentParent = parentStack.at(-1);
|
|
380
|
+
|
|
381
|
+
if (currentParent) {
|
|
382
|
+
currentParent.children.push(newNode);
|
|
383
|
+
} else {
|
|
384
|
+
rootNodes.push(newNode);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
parentStack.push(newNode);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return this.applyVirtualContext(rootNodes);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Checks if a line is a comment according to the vendor's comment patterns.
|
|
395
|
+
*/
|
|
396
|
+
private isComment(sanitizedLine: string): boolean {
|
|
397
|
+
return this.vendor.commentPatterns.some((pattern) =>
|
|
398
|
+
pattern.test(sanitizedLine)
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Returns the block starter depth for a line, or -1 if not a block starter.
|
|
404
|
+
*
|
|
405
|
+
* Returns the FIRST (shallowest) matching pattern. For context-aware decisions
|
|
406
|
+
* (e.g., preferring depth-1 over depth-0 based on parent), see the override logic
|
|
407
|
+
* in parseIndentationHierarchy().
|
|
408
|
+
*/
|
|
409
|
+
private getBlockStarterDepth(sanitizedLine: string): number {
|
|
410
|
+
for (const def of this.vendor.blockStarters) {
|
|
411
|
+
if (def.pattern.test(sanitizedLine)) {
|
|
412
|
+
return def.depth;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return -1;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Returns ALL matching block starter depths for a line.
|
|
420
|
+
* Used for context-aware parsing where multiple depths may match.
|
|
421
|
+
*/
|
|
422
|
+
private getAllBlockStarterDepths(sanitizedLine: string): number[] {
|
|
423
|
+
const depths: number[] = [];
|
|
424
|
+
for (const def of this.vendor.blockStarters) {
|
|
425
|
+
if (def.pattern.test(sanitizedLine)) {
|
|
426
|
+
depths.push(def.depth);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return depths;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Checks if a sanitized line matches any of the defined BlockEnders regexes.
|
|
434
|
+
*/
|
|
435
|
+
private isSchemaBlockEnder(sanitizedLine: string): boolean {
|
|
436
|
+
return this.vendor.blockEnders.some((regex) => regex.test(sanitizedLine));
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Creates a ConfigNode object from a ParsedLine.
|
|
441
|
+
*/
|
|
442
|
+
private createConfigNode(parsedLine: ParsedLine, type: NodeType): ConfigNode {
|
|
443
|
+
const params = parseParameters(parsedLine.sanitized);
|
|
444
|
+
const id = parsedLine.sanitized;
|
|
445
|
+
|
|
446
|
+
const node: ConfigNode = {
|
|
447
|
+
id,
|
|
448
|
+
type,
|
|
449
|
+
rawText: parsedLine.original,
|
|
450
|
+
params,
|
|
451
|
+
children: [],
|
|
452
|
+
source: this.options.source,
|
|
453
|
+
loc: {
|
|
454
|
+
startLine: parsedLine.lineNumber,
|
|
455
|
+
endLine: parsedLine.lineNumber,
|
|
456
|
+
},
|
|
457
|
+
indent: parsedLine.indent,
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
if (type === 'section' && parsedLine.blockStarterDepth >= 0) {
|
|
461
|
+
node.blockDepth = parsedLine.blockStarterDepth;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return node;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Logic to detect "Orphan" commands and wrap them in a `virtual_root`.
|
|
469
|
+
*/
|
|
470
|
+
private applyVirtualContext(nodes: ConfigNode[]): ConfigNode[] {
|
|
471
|
+
const processedNodes: ConfigNode[] = [];
|
|
472
|
+
let currentVirtualRoot: ConfigNode | null = null;
|
|
473
|
+
|
|
474
|
+
for (const node of nodes) {
|
|
475
|
+
if (node.type === 'command') {
|
|
476
|
+
if (!currentVirtualRoot) {
|
|
477
|
+
currentVirtualRoot = {
|
|
478
|
+
id: `virtual_root_line_${node.loc.startLine}`,
|
|
479
|
+
type: 'virtual_root',
|
|
480
|
+
rawText: 'virtual_root',
|
|
481
|
+
params: ['virtual_root'],
|
|
482
|
+
children: [],
|
|
483
|
+
source: this.options.source,
|
|
484
|
+
loc: {
|
|
485
|
+
startLine: node.loc.startLine,
|
|
486
|
+
endLine: node.loc.endLine,
|
|
487
|
+
},
|
|
488
|
+
indent: 0,
|
|
489
|
+
};
|
|
490
|
+
processedNodes.push(currentVirtualRoot);
|
|
491
|
+
}
|
|
492
|
+
currentVirtualRoot!.children.push(node);
|
|
493
|
+
currentVirtualRoot!.loc.endLine = Math.max(
|
|
494
|
+
currentVirtualRoot!.loc.endLine,
|
|
495
|
+
node.loc.endLine
|
|
496
|
+
);
|
|
497
|
+
} else {
|
|
498
|
+
currentVirtualRoot = null;
|
|
499
|
+
processedNodes.push(node);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return processedNodes;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// packages/core/src/parser/VendorSchema.ts
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Represents a block-starting pattern with its nesting depth.
|
|
5
|
+
* Depth determines where a block can appear in the hierarchy:
|
|
6
|
+
* - depth 0: Top-level blocks (interface, router, vlan, system, interfaces)
|
|
7
|
+
* - depth 1: Must be inside a depth-0 block (address-family inside router, ge-0/0/0 inside interfaces)
|
|
8
|
+
* - depth 2: Must be inside a depth-1 block (vrf inside address-family, unit inside interface)
|
|
9
|
+
* - depth 3: Deeply nested (family inet inside unit)
|
|
10
|
+
*/
|
|
11
|
+
export interface BlockStarterDef {
|
|
12
|
+
pattern: RegExp;
|
|
13
|
+
depth: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Defines a vendor's configuration syntax schema.
|
|
18
|
+
* Each vendor (Cisco IOS, Juniper JunOS, etc.) has different
|
|
19
|
+
* block structures, comment styles, and hierarchy rules.
|
|
20
|
+
*
|
|
21
|
+
* This abstraction allows the parser to handle multiple vendor
|
|
22
|
+
* configuration formats using the same parsing engine.
|
|
23
|
+
*/
|
|
24
|
+
export interface VendorSchema {
|
|
25
|
+
/**
|
|
26
|
+
* Unique vendor identifier.
|
|
27
|
+
* Used for programmatic lookup and configuration.
|
|
28
|
+
* @example 'cisco-ios', 'cisco-nxos', 'juniper-junos', 'arista-eos'
|
|
29
|
+
*/
|
|
30
|
+
id: string;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Human-readable display name.
|
|
34
|
+
* @example 'Cisco IOS/IOS-XE', 'Juniper JunOS'
|
|
35
|
+
*/
|
|
36
|
+
name: string;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Block starters with depth information for this vendor.
|
|
40
|
+
* Order matters: more specific patterns should come before generic ones.
|
|
41
|
+
*/
|
|
42
|
+
blockStarters: BlockStarterDef[];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Block enders (exit commands or closing braces).
|
|
46
|
+
* These patterns indicate the end of a configuration block.
|
|
47
|
+
* @example Cisco: /^exit$/i, /^exit-address-family$/i
|
|
48
|
+
* @example Junos: /^\}$/
|
|
49
|
+
*/
|
|
50
|
+
blockEnders: RegExp[];
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Comment patterns for this vendor.
|
|
54
|
+
* Lines matching these patterns are treated as comments and skipped.
|
|
55
|
+
* @example Cisco: /^!/, Junos: /^#/, /^\/\*.*\*\/$/
|
|
56
|
+
*/
|
|
57
|
+
commentPatterns: RegExp[];
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Optional section delimiter character.
|
|
61
|
+
* Used to separate logical sections in the configuration.
|
|
62
|
+
* @example Cisco: '!', Junos: '}'
|
|
63
|
+
*/
|
|
64
|
+
sectionDelimiter?: string;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Whether this vendor uses braces for hierarchy.
|
|
68
|
+
* - true: Juniper JunOS style with { } blocks
|
|
69
|
+
* - false: Cisco style with indentation-based blocks
|
|
70
|
+
*/
|
|
71
|
+
useBraceHierarchy: boolean;
|
|
72
|
+
}
|