@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,339 @@
|
|
|
1
|
+
// packages/core/src/json-rules/JsonRuleCompiler.ts
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* JSON Rule Compiler
|
|
5
|
+
*
|
|
6
|
+
* Compiles JSON rule definitions into executable IRule objects.
|
|
7
|
+
* Supports all check types including helper invocation and expression evaluation.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ConfigNode } from '../types/ConfigNode';
|
|
11
|
+
import type { IRule, Context, RuleResult } from '../types/IRule';
|
|
12
|
+
import type { JsonRule, JsonCheck, JsonArgValue } from './types';
|
|
13
|
+
import {
|
|
14
|
+
type HelperRegistry,
|
|
15
|
+
type HelperFunction,
|
|
16
|
+
getHelperRegistry,
|
|
17
|
+
resolveHelper,
|
|
18
|
+
} from './HelperRegistry';
|
|
19
|
+
import { ExpressionEvaluator, createExpressionEvaluator } from './ExpressionEvaluator';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Options for the JSON rule compiler.
|
|
23
|
+
*/
|
|
24
|
+
export interface JsonRuleCompilerOptions {
|
|
25
|
+
/** Custom helper registry (uses default if not provided) */
|
|
26
|
+
registry?: HelperRegistry;
|
|
27
|
+
/** Pre-compile expressions at rule load time (default: true) */
|
|
28
|
+
precompileExpressions?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Compiles JSON rules into executable IRule objects.
|
|
33
|
+
*/
|
|
34
|
+
export class JsonRuleCompiler {
|
|
35
|
+
private readonly registry: HelperRegistry;
|
|
36
|
+
private readonly evaluator: ExpressionEvaluator;
|
|
37
|
+
private readonly precompileExpressions: boolean;
|
|
38
|
+
private readonly regexCache: Map<string, RegExp> = new Map();
|
|
39
|
+
|
|
40
|
+
constructor(options: JsonRuleCompilerOptions = {}) {
|
|
41
|
+
this.registry = options.registry ?? getHelperRegistry();
|
|
42
|
+
this.evaluator = createExpressionEvaluator(this.registry);
|
|
43
|
+
this.precompileExpressions = options.precompileExpressions ?? true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Get a cached regex or create and cache a new one.
|
|
48
|
+
*/
|
|
49
|
+
private getRegex(pattern: string, flags?: string): RegExp {
|
|
50
|
+
const key = `${pattern}::${flags ?? ''}`;
|
|
51
|
+
let regex = this.regexCache.get(key);
|
|
52
|
+
if (!regex) {
|
|
53
|
+
regex = new RegExp(pattern, flags);
|
|
54
|
+
this.regexCache.set(key, regex);
|
|
55
|
+
}
|
|
56
|
+
return regex;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Format a message template by replacing placeholders.
|
|
61
|
+
*/
|
|
62
|
+
private formatMessage(template: string, nodeId: string, ruleId: string): string {
|
|
63
|
+
return template.replaceAll('{nodeId}', nodeId).replaceAll('{ruleId}', ruleId);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Get children matching a selector (case-insensitive prefix match).
|
|
68
|
+
*/
|
|
69
|
+
private getMatchingChildren(node: ConfigNode, selector: string): ConfigNode[] {
|
|
70
|
+
const selectorLower = selector.toLowerCase();
|
|
71
|
+
return node.children.filter((child) =>
|
|
72
|
+
child.id.toLowerCase().startsWith(selectorLower)
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Compile a JSON rule into an executable IRule.
|
|
78
|
+
*
|
|
79
|
+
* @param jsonRule The JSON rule definition
|
|
80
|
+
* @returns An executable IRule object
|
|
81
|
+
*/
|
|
82
|
+
compile(jsonRule: JsonRule): IRule {
|
|
83
|
+
// Pre-compile expressions if enabled
|
|
84
|
+
if (this.precompileExpressions) {
|
|
85
|
+
this.precompileCheckExpressions(jsonRule.check);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
id: jsonRule.id,
|
|
90
|
+
selector: jsonRule.selector,
|
|
91
|
+
vendor: jsonRule.vendor,
|
|
92
|
+
metadata: jsonRule.metadata,
|
|
93
|
+
check: (node: ConfigNode, _ctx: Context): RuleResult => {
|
|
94
|
+
// Check defines failure conditions - invert to get pass status
|
|
95
|
+
const passed = !this.evaluateCheck(jsonRule.check, node);
|
|
96
|
+
|
|
97
|
+
// Format message with placeholders
|
|
98
|
+
const template = passed
|
|
99
|
+
? (jsonRule.successMessage ?? `${jsonRule.id}: Check passed`)
|
|
100
|
+
: (jsonRule.failureMessage ?? jsonRule.metadata.description ?? `${jsonRule.id}: Check failed`);
|
|
101
|
+
const message = this.formatMessage(template, node.id, jsonRule.id);
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
passed,
|
|
105
|
+
message,
|
|
106
|
+
ruleId: jsonRule.id,
|
|
107
|
+
nodeId: node.id,
|
|
108
|
+
level: passed ? 'info' : jsonRule.metadata.level,
|
|
109
|
+
loc: node.loc,
|
|
110
|
+
remediation: passed ? undefined : jsonRule.metadata.remediation,
|
|
111
|
+
};
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Compile multiple JSON rules.
|
|
118
|
+
*
|
|
119
|
+
* @param jsonRules Array of JSON rule definitions
|
|
120
|
+
* @returns Array of executable IRule objects
|
|
121
|
+
*/
|
|
122
|
+
compileAll(jsonRules: JsonRule[]): IRule[] {
|
|
123
|
+
return jsonRules.map((rule) => this.compile(rule));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Pre-compile all expressions in a check tree.
|
|
128
|
+
*/
|
|
129
|
+
private precompileCheckExpressions(check: JsonCheck): void {
|
|
130
|
+
switch (check.type) {
|
|
131
|
+
case 'expr':
|
|
132
|
+
this.evaluator.precompile(check.expr);
|
|
133
|
+
break;
|
|
134
|
+
case 'and':
|
|
135
|
+
case 'or':
|
|
136
|
+
for (const condition of check.conditions) {
|
|
137
|
+
this.precompileCheckExpressions(condition);
|
|
138
|
+
}
|
|
139
|
+
break;
|
|
140
|
+
case 'not':
|
|
141
|
+
this.precompileCheckExpressions(check.condition);
|
|
142
|
+
break;
|
|
143
|
+
// Other types don't have expressions to pre-compile
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Evaluate a check condition against a node.
|
|
149
|
+
*/
|
|
150
|
+
private evaluateCheck(check: JsonCheck, node: ConfigNode): boolean {
|
|
151
|
+
switch (check.type) {
|
|
152
|
+
case 'match':
|
|
153
|
+
return this.evaluateMatch(check.pattern, check.flags, node);
|
|
154
|
+
|
|
155
|
+
case 'not_match':
|
|
156
|
+
return !this.evaluateMatch(check.pattern, check.flags, node);
|
|
157
|
+
|
|
158
|
+
case 'contains':
|
|
159
|
+
return node.id.toLowerCase().includes(check.text.toLowerCase());
|
|
160
|
+
|
|
161
|
+
case 'not_contains':
|
|
162
|
+
return !node.id.toLowerCase().includes(check.text.toLowerCase());
|
|
163
|
+
|
|
164
|
+
case 'child_exists':
|
|
165
|
+
return this.hasMatchingChild(node, check.selector);
|
|
166
|
+
|
|
167
|
+
case 'child_not_exists':
|
|
168
|
+
return !this.hasMatchingChild(node, check.selector);
|
|
169
|
+
|
|
170
|
+
case 'child_matches':
|
|
171
|
+
return this.childMatches(node, check.selector, check.pattern, check.flags);
|
|
172
|
+
|
|
173
|
+
case 'child_contains':
|
|
174
|
+
return this.childContains(node, check.selector, check.text);
|
|
175
|
+
|
|
176
|
+
case 'helper':
|
|
177
|
+
return this.evaluateHelper(check, node);
|
|
178
|
+
|
|
179
|
+
case 'expr':
|
|
180
|
+
return this.evaluator.evaluate(check.expr, node);
|
|
181
|
+
|
|
182
|
+
case 'and':
|
|
183
|
+
return check.conditions.every((c) => this.evaluateCheck(c, node));
|
|
184
|
+
|
|
185
|
+
case 'or':
|
|
186
|
+
return check.conditions.some((c) => this.evaluateCheck(c, node));
|
|
187
|
+
|
|
188
|
+
case 'not':
|
|
189
|
+
return !this.evaluateCheck(check.condition, node);
|
|
190
|
+
|
|
191
|
+
default:
|
|
192
|
+
// Unknown check type - fail closed
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Evaluate a regex match on node.id.
|
|
199
|
+
*/
|
|
200
|
+
private evaluateMatch(pattern: string, flags: string | undefined, node: ConfigNode): boolean {
|
|
201
|
+
try {
|
|
202
|
+
const regex = this.getRegex(pattern, flags);
|
|
203
|
+
return regex.test(node.id);
|
|
204
|
+
} catch {
|
|
205
|
+
return false; // Invalid regex
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Check if node has a child matching the selector (case-insensitive prefix).
|
|
211
|
+
*/
|
|
212
|
+
private hasMatchingChild(node: ConfigNode, selector: string): boolean {
|
|
213
|
+
return this.getMatchingChildren(node, selector).length > 0;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Check if any matching child's id matches the pattern.
|
|
218
|
+
*/
|
|
219
|
+
private childMatches(
|
|
220
|
+
node: ConfigNode,
|
|
221
|
+
selector: string,
|
|
222
|
+
pattern: string,
|
|
223
|
+
flags?: string
|
|
224
|
+
): boolean {
|
|
225
|
+
const matchingChildren = this.getMatchingChildren(node, selector);
|
|
226
|
+
try {
|
|
227
|
+
const regex = this.getRegex(pattern, flags);
|
|
228
|
+
return matchingChildren.some((child) => regex.test(child.id));
|
|
229
|
+
} catch {
|
|
230
|
+
return false; // Invalid regex
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Check if any matching child's id contains the text.
|
|
236
|
+
*/
|
|
237
|
+
private childContains(node: ConfigNode, selector: string, text: string): boolean {
|
|
238
|
+
const textLower = text.toLowerCase();
|
|
239
|
+
const matchingChildren = this.getMatchingChildren(node, selector);
|
|
240
|
+
return matchingChildren.some((child) =>
|
|
241
|
+
child.id.toLowerCase().includes(textLower)
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Evaluate a helper function check.
|
|
247
|
+
*/
|
|
248
|
+
private evaluateHelper(
|
|
249
|
+
check: { type: 'helper'; helper: string; args?: JsonArgValue[]; negate?: boolean },
|
|
250
|
+
node: ConfigNode
|
|
251
|
+
): boolean {
|
|
252
|
+
const helperFn = resolveHelper(this.registry, check.helper);
|
|
253
|
+
if (!helperFn) {
|
|
254
|
+
// Unknown helper - fail closed
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Resolve arguments
|
|
259
|
+
const args = (check.args ?? []).map((arg) => this.resolveArg(arg, node));
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
const result = (helperFn as HelperFunction)(...args);
|
|
263
|
+
const boolResult = Boolean(result);
|
|
264
|
+
return check.negate ? !boolResult : boolResult;
|
|
265
|
+
} catch {
|
|
266
|
+
return false; // Helper execution error
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Resolve an argument value, handling $ref references.
|
|
272
|
+
*/
|
|
273
|
+
private resolveArg(arg: JsonArgValue, node: ConfigNode): unknown {
|
|
274
|
+
if (arg === null) return null;
|
|
275
|
+
if (typeof arg !== 'object') return arg;
|
|
276
|
+
|
|
277
|
+
if ('$ref' in arg) {
|
|
278
|
+
switch (arg.$ref) {
|
|
279
|
+
case 'node':
|
|
280
|
+
return node;
|
|
281
|
+
case 'node.id':
|
|
282
|
+
return node.id;
|
|
283
|
+
case 'node.type':
|
|
284
|
+
return node.type;
|
|
285
|
+
case 'node.children':
|
|
286
|
+
return node.children;
|
|
287
|
+
case 'node.params':
|
|
288
|
+
return node.params;
|
|
289
|
+
case 'node.rawText':
|
|
290
|
+
return node.rawText;
|
|
291
|
+
default:
|
|
292
|
+
return undefined;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return arg;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Create a new JSON rule compiler.
|
|
302
|
+
*/
|
|
303
|
+
export function createJsonRuleCompiler(options?: JsonRuleCompilerOptions): JsonRuleCompiler {
|
|
304
|
+
return new JsonRuleCompiler(options);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Default singleton compiler for convenience
|
|
308
|
+
let defaultCompiler: JsonRuleCompiler | null = null;
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Get the default JSON rule compiler (singleton).
|
|
312
|
+
*/
|
|
313
|
+
export function getJsonRuleCompiler(): JsonRuleCompiler {
|
|
314
|
+
if (!defaultCompiler) {
|
|
315
|
+
defaultCompiler = new JsonRuleCompiler();
|
|
316
|
+
}
|
|
317
|
+
return defaultCompiler;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Compile a JSON rule to IRule using the default compiler.
|
|
322
|
+
*/
|
|
323
|
+
export function compileJsonRule(jsonRule: JsonRule): IRule {
|
|
324
|
+
return getJsonRuleCompiler().compile(jsonRule);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Compile multiple JSON rules to IRule array using the default compiler.
|
|
329
|
+
*/
|
|
330
|
+
export function compileJsonRules(jsonRules: JsonRule[]): IRule[] {
|
|
331
|
+
return getJsonRuleCompiler().compileAll(jsonRules);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Clear the default compiler (useful for testing).
|
|
336
|
+
*/
|
|
337
|
+
export function clearJsonRuleCompiler(): void {
|
|
338
|
+
defaultCompiler = null;
|
|
339
|
+
}
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
// packages/core/src/json-rules/JsonRuleValidator.ts
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* JSON Rule Validator
|
|
5
|
+
*
|
|
6
|
+
* Validates JSON rule files for both structural correctness and semantic validity.
|
|
7
|
+
* Provides detailed error messages for debugging rule authoring issues.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { isValidVendorId, VALID_VENDOR_IDS, type RuleVendor } from '../types/IRule';
|
|
11
|
+
import { RULE_ID_PATTERN, MAX_PATTERN_LENGTH, REDOS_PATTERN } from '../constants';
|
|
12
|
+
import { isJsonRule, isJsonRuleFile, isJsonCheck, type JsonRule, type JsonRuleFile, type JsonCheck } from './types';
|
|
13
|
+
import { getHelperRegistry, hasHelper, type HelperRegistry } from './HelperRegistry';
|
|
14
|
+
import { isValidExpression } from './ExpressionEvaluator';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A validation error with path and message.
|
|
18
|
+
*/
|
|
19
|
+
export interface ValidationError {
|
|
20
|
+
/** JSON path to the error location (e.g., "/rules/0/check/helper") */
|
|
21
|
+
path: string;
|
|
22
|
+
/** Human-readable error message */
|
|
23
|
+
message: string;
|
|
24
|
+
/** Error severity */
|
|
25
|
+
severity: 'error' | 'warning';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Result of validation.
|
|
30
|
+
*/
|
|
31
|
+
export interface ValidationResult {
|
|
32
|
+
/** Whether the validation passed (no errors) */
|
|
33
|
+
valid: boolean;
|
|
34
|
+
/** Array of validation errors */
|
|
35
|
+
errors: ValidationError[];
|
|
36
|
+
/** Array of validation warnings */
|
|
37
|
+
warnings: ValidationError[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Options for validation.
|
|
42
|
+
*/
|
|
43
|
+
export interface ValidationOptions {
|
|
44
|
+
/** Custom helper registry for validating helper names */
|
|
45
|
+
registry?: HelperRegistry;
|
|
46
|
+
/** Whether to validate helper names exist (default: true) */
|
|
47
|
+
validateHelpers?: boolean;
|
|
48
|
+
/** Whether to validate expressions are safe (default: true) */
|
|
49
|
+
validateExpressions?: boolean;
|
|
50
|
+
/** Whether to allow unknown vendors (default: false) */
|
|
51
|
+
allowUnknownVendors?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Validate a JSON rule file.
|
|
56
|
+
*
|
|
57
|
+
* @param data The data to validate
|
|
58
|
+
* @param options Validation options
|
|
59
|
+
* @returns Validation result with errors and warnings
|
|
60
|
+
*/
|
|
61
|
+
export function validateJsonRuleFile(
|
|
62
|
+
data: unknown,
|
|
63
|
+
options: ValidationOptions = {}
|
|
64
|
+
): ValidationResult {
|
|
65
|
+
const errors: ValidationError[] = [];
|
|
66
|
+
const warnings: ValidationError[] = [];
|
|
67
|
+
const registry = options.registry ?? getHelperRegistry();
|
|
68
|
+
const validateHelpers = options.validateHelpers ?? true;
|
|
69
|
+
const validateExpressions = options.validateExpressions ?? true;
|
|
70
|
+
const allowUnknownVendors = options.allowUnknownVendors ?? false;
|
|
71
|
+
|
|
72
|
+
// Phase 1: Structural validation using type guards
|
|
73
|
+
if (!isJsonRuleFile(data)) {
|
|
74
|
+
errors.push({
|
|
75
|
+
path: '',
|
|
76
|
+
message: 'Invalid JSON rule file structure',
|
|
77
|
+
severity: 'error',
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Try to provide more specific errors
|
|
81
|
+
if (typeof data !== 'object' || data === null) {
|
|
82
|
+
errors.push({
|
|
83
|
+
path: '',
|
|
84
|
+
message: 'Expected an object',
|
|
85
|
+
severity: 'error',
|
|
86
|
+
});
|
|
87
|
+
} else {
|
|
88
|
+
const obj = data as Record<string, unknown>;
|
|
89
|
+
|
|
90
|
+
if (obj.version !== '1.0') {
|
|
91
|
+
errors.push({
|
|
92
|
+
path: '/version',
|
|
93
|
+
message: `Invalid version: expected "1.0", got "${String(obj.version)}"`,
|
|
94
|
+
severity: 'error',
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!Array.isArray(obj.rules)) {
|
|
99
|
+
errors.push({
|
|
100
|
+
path: '/rules',
|
|
101
|
+
message: 'Expected "rules" to be an array',
|
|
102
|
+
severity: 'error',
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { valid: false, errors, warnings };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const file = data as JsonRuleFile;
|
|
111
|
+
|
|
112
|
+
// Phase 2: Validate each rule
|
|
113
|
+
for (let i = 0; i < file.rules.length; i++) {
|
|
114
|
+
const rule = file.rules[i];
|
|
115
|
+
if (!rule) continue;
|
|
116
|
+
const rulePath = `/rules/${i}`;
|
|
117
|
+
|
|
118
|
+
validateRule(rule, rulePath, {
|
|
119
|
+
errors,
|
|
120
|
+
warnings,
|
|
121
|
+
registry,
|
|
122
|
+
validateHelpers,
|
|
123
|
+
validateExpressions,
|
|
124
|
+
allowUnknownVendors,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Phase 3: Check for duplicate rule IDs
|
|
129
|
+
const ruleIds = new Set<string>();
|
|
130
|
+
for (let i = 0; i < file.rules.length; i++) {
|
|
131
|
+
const rule = file.rules[i];
|
|
132
|
+
if (!rule) continue;
|
|
133
|
+
|
|
134
|
+
if (ruleIds.has(rule.id)) {
|
|
135
|
+
errors.push({
|
|
136
|
+
path: `/rules/${i}/id`,
|
|
137
|
+
message: `Duplicate rule ID: "${rule.id}"`,
|
|
138
|
+
severity: 'error',
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
ruleIds.add(rule.id);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
valid: errors.length === 0,
|
|
146
|
+
errors,
|
|
147
|
+
warnings,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Validate a single JSON rule.
|
|
153
|
+
*/
|
|
154
|
+
export function validateJsonRule(
|
|
155
|
+
data: unknown,
|
|
156
|
+
options: ValidationOptions = {}
|
|
157
|
+
): ValidationResult {
|
|
158
|
+
const errors: ValidationError[] = [];
|
|
159
|
+
const warnings: ValidationError[] = [];
|
|
160
|
+
const registry = options.registry ?? getHelperRegistry();
|
|
161
|
+
const validateHelpers = options.validateHelpers ?? true;
|
|
162
|
+
const validateExpressions = options.validateExpressions ?? true;
|
|
163
|
+
const allowUnknownVendors = options.allowUnknownVendors ?? false;
|
|
164
|
+
|
|
165
|
+
if (!isJsonRule(data)) {
|
|
166
|
+
errors.push({
|
|
167
|
+
path: '',
|
|
168
|
+
message: 'Invalid JSON rule structure',
|
|
169
|
+
severity: 'error',
|
|
170
|
+
});
|
|
171
|
+
return { valid: false, errors, warnings };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
validateRule(data as JsonRule, '', {
|
|
175
|
+
errors,
|
|
176
|
+
warnings,
|
|
177
|
+
registry,
|
|
178
|
+
validateHelpers,
|
|
179
|
+
validateExpressions,
|
|
180
|
+
allowUnknownVendors,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
valid: errors.length === 0,
|
|
185
|
+
errors,
|
|
186
|
+
warnings,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
interface ValidationContext {
|
|
191
|
+
errors: ValidationError[];
|
|
192
|
+
warnings: ValidationError[];
|
|
193
|
+
registry: HelperRegistry;
|
|
194
|
+
validateHelpers: boolean;
|
|
195
|
+
validateExpressions: boolean;
|
|
196
|
+
allowUnknownVendors: boolean;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Validate a rule and add errors/warnings to context.
|
|
201
|
+
*/
|
|
202
|
+
function validateRule(rule: JsonRule, path: string, ctx: ValidationContext): void {
|
|
203
|
+
// Validate rule ID format
|
|
204
|
+
if (!RULE_ID_PATTERN.test(rule.id)) {
|
|
205
|
+
ctx.errors.push({
|
|
206
|
+
path: `${path}/id`,
|
|
207
|
+
message: `Invalid rule ID format: "${rule.id}". Must match pattern: ^[A-Z][A-Z0-9_-]{2,49}$`,
|
|
208
|
+
severity: 'error',
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Validate vendor(s)
|
|
213
|
+
if (rule.vendor !== undefined) {
|
|
214
|
+
const vendors = Array.isArray(rule.vendor) ? rule.vendor : [rule.vendor];
|
|
215
|
+
for (const vendor of vendors) {
|
|
216
|
+
if (!ctx.allowUnknownVendors && !isValidVendorId(vendor)) {
|
|
217
|
+
ctx.errors.push({
|
|
218
|
+
path: `${path}/vendor`,
|
|
219
|
+
message: `Unknown vendor: "${vendor}". Valid vendors: ${VALID_VENDOR_IDS.join(', ')}`,
|
|
220
|
+
severity: 'error',
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Validate metadata
|
|
227
|
+
if (!rule.metadata.description) {
|
|
228
|
+
ctx.warnings.push({
|
|
229
|
+
path: `${path}/metadata/description`,
|
|
230
|
+
message: 'Rule should have a description',
|
|
231
|
+
severity: 'warning',
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (!rule.metadata.remediation) {
|
|
236
|
+
ctx.warnings.push({
|
|
237
|
+
path: `${path}/metadata/remediation`,
|
|
238
|
+
message: 'Rule should have remediation guidance',
|
|
239
|
+
severity: 'warning',
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Validate check
|
|
244
|
+
validateCheck(rule.check, `${path}/check`, ctx);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Validate a check condition recursively.
|
|
249
|
+
*/
|
|
250
|
+
function validateCheck(check: JsonCheck, path: string, ctx: ValidationContext): void {
|
|
251
|
+
switch (check.type) {
|
|
252
|
+
case 'match':
|
|
253
|
+
case 'not_match':
|
|
254
|
+
validateRegex(check.pattern, check.flags, `${path}/pattern`, ctx);
|
|
255
|
+
break;
|
|
256
|
+
|
|
257
|
+
case 'child_matches':
|
|
258
|
+
validateRegex(check.pattern, check.flags, `${path}/pattern`, ctx);
|
|
259
|
+
break;
|
|
260
|
+
|
|
261
|
+
case 'helper':
|
|
262
|
+
if (ctx.validateHelpers && !hasHelper(ctx.registry, check.helper)) {
|
|
263
|
+
ctx.errors.push({
|
|
264
|
+
path: `${path}/helper`,
|
|
265
|
+
message: `Unknown helper: "${check.helper}"`,
|
|
266
|
+
severity: 'error',
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
break;
|
|
270
|
+
|
|
271
|
+
case 'expr':
|
|
272
|
+
if (ctx.validateExpressions && !isValidExpression(check.expr)) {
|
|
273
|
+
ctx.errors.push({
|
|
274
|
+
path: `${path}/expr`,
|
|
275
|
+
message: `Invalid or unsafe expression: "${check.expr}"`,
|
|
276
|
+
severity: 'error',
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
break;
|
|
280
|
+
|
|
281
|
+
case 'and':
|
|
282
|
+
case 'or':
|
|
283
|
+
if (check.conditions.length === 0) {
|
|
284
|
+
ctx.errors.push({
|
|
285
|
+
path: `${path}/conditions`,
|
|
286
|
+
message: `Empty conditions array in "${check.type}" check - this will always ${check.type === 'and' ? 'pass' : 'fail'}`,
|
|
287
|
+
severity: 'error',
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
for (let i = 0; i < check.conditions.length; i++) {
|
|
291
|
+
const cond = check.conditions[i];
|
|
292
|
+
if (cond) {
|
|
293
|
+
validateCheck(cond, `${path}/conditions/${i}`, ctx);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
break;
|
|
297
|
+
|
|
298
|
+
case 'not':
|
|
299
|
+
validateCheck(check.condition, `${path}/condition`, ctx);
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Validate a regex pattern.
|
|
306
|
+
*/
|
|
307
|
+
function validateRegex(
|
|
308
|
+
pattern: string,
|
|
309
|
+
flags: string | undefined,
|
|
310
|
+
path: string,
|
|
311
|
+
ctx: ValidationContext
|
|
312
|
+
): void {
|
|
313
|
+
// Check pattern length (ReDoS protection)
|
|
314
|
+
if (pattern.length > MAX_PATTERN_LENGTH) {
|
|
315
|
+
ctx.errors.push({
|
|
316
|
+
path,
|
|
317
|
+
message: `Regex pattern too long: ${pattern.length} chars exceeds limit of ${MAX_PATTERN_LENGTH}`,
|
|
318
|
+
severity: 'error',
|
|
319
|
+
});
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Check for ReDoS patterns (nested quantifiers)
|
|
324
|
+
if (REDOS_PATTERN.test(pattern)) {
|
|
325
|
+
ctx.errors.push({
|
|
326
|
+
path,
|
|
327
|
+
message: `Regex pattern contains nested quantifiers which may cause ReDoS: "${pattern.slice(0, 50)}${pattern.length > 50 ? '...' : ''}"`,
|
|
328
|
+
severity: 'error',
|
|
329
|
+
});
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
new RegExp(pattern, flags);
|
|
335
|
+
} catch (e) {
|
|
336
|
+
ctx.errors.push({
|
|
337
|
+
path,
|
|
338
|
+
message: `Invalid regex: ${e instanceof Error ? e.message : 'unknown error'}`,
|
|
339
|
+
severity: 'error',
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Format validation result as a human-readable string.
|
|
346
|
+
*/
|
|
347
|
+
export function formatValidationResult(result: ValidationResult): string {
|
|
348
|
+
const lines: string[] = [];
|
|
349
|
+
|
|
350
|
+
if (result.valid) {
|
|
351
|
+
lines.push('✓ Validation passed');
|
|
352
|
+
} else {
|
|
353
|
+
lines.push('✗ Validation failed');
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (result.errors.length > 0) {
|
|
357
|
+
lines.push(`\nErrors (${result.errors.length}):`);
|
|
358
|
+
for (const error of result.errors) {
|
|
359
|
+
lines.push(` ${error.path || '/'}: ${error.message}`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (result.warnings.length > 0) {
|
|
364
|
+
lines.push(`\nWarnings (${result.warnings.length}):`);
|
|
365
|
+
for (const warning of result.warnings) {
|
|
366
|
+
lines.push(` ${warning.path || '/'}: ${warning.message}`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return lines.join('\n');
|
|
371
|
+
}
|