@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.
Files changed (71) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +86 -0
  3. package/package.json +60 -0
  4. package/src/constants.ts +77 -0
  5. package/src/engine/RuleExecutor.ts +256 -0
  6. package/src/engine/Runner.ts +312 -0
  7. package/src/engine/SandboxedExecutor.ts +208 -0
  8. package/src/errors.ts +88 -0
  9. package/src/helpers/arista/helpers.ts +1220 -0
  10. package/src/helpers/arista/index.ts +12 -0
  11. package/src/helpers/aruba/helpers.ts +637 -0
  12. package/src/helpers/aruba/index.ts +13 -0
  13. package/src/helpers/cisco/helpers.ts +534 -0
  14. package/src/helpers/cisco/index.ts +11 -0
  15. package/src/helpers/common/helpers.ts +265 -0
  16. package/src/helpers/common/index.ts +5 -0
  17. package/src/helpers/common/validation.ts +280 -0
  18. package/src/helpers/cumulus/helpers.ts +676 -0
  19. package/src/helpers/cumulus/index.ts +12 -0
  20. package/src/helpers/extreme/helpers.ts +422 -0
  21. package/src/helpers/extreme/index.ts +12 -0
  22. package/src/helpers/fortinet/helpers.ts +892 -0
  23. package/src/helpers/fortinet/index.ts +12 -0
  24. package/src/helpers/huawei/helpers.ts +790 -0
  25. package/src/helpers/huawei/index.ts +11 -0
  26. package/src/helpers/index.ts +53 -0
  27. package/src/helpers/juniper/helpers.ts +756 -0
  28. package/src/helpers/juniper/index.ts +12 -0
  29. package/src/helpers/mikrotik/helpers.ts +722 -0
  30. package/src/helpers/mikrotik/index.ts +12 -0
  31. package/src/helpers/nokia/helpers.ts +856 -0
  32. package/src/helpers/nokia/index.ts +11 -0
  33. package/src/helpers/paloalto/helpers.ts +939 -0
  34. package/src/helpers/paloalto/index.ts +12 -0
  35. package/src/helpers/vyos/helpers.ts +429 -0
  36. package/src/helpers/vyos/index.ts +12 -0
  37. package/src/index.ts +30 -0
  38. package/src/json-rules/ExpressionEvaluator.ts +292 -0
  39. package/src/json-rules/HelperRegistry.ts +177 -0
  40. package/src/json-rules/JsonRuleCompiler.ts +339 -0
  41. package/src/json-rules/JsonRuleValidator.ts +371 -0
  42. package/src/json-rules/index.ts +97 -0
  43. package/src/json-rules/schema.json +350 -0
  44. package/src/json-rules/types.ts +303 -0
  45. package/src/pack-loader/PackLoader.ts +332 -0
  46. package/src/pack-loader/index.ts +17 -0
  47. package/src/pack-loader/types.ts +135 -0
  48. package/src/parser/IncrementalParser.ts +527 -0
  49. package/src/parser/Sanitizer.ts +104 -0
  50. package/src/parser/SchemaAwareParser.ts +504 -0
  51. package/src/parser/VendorSchema.ts +72 -0
  52. package/src/parser/vendors/arista-eos.ts +206 -0
  53. package/src/parser/vendors/aruba-aoscx.ts +123 -0
  54. package/src/parser/vendors/aruba-aosswitch.ts +113 -0
  55. package/src/parser/vendors/aruba-wlc.ts +173 -0
  56. package/src/parser/vendors/cisco-ios.ts +110 -0
  57. package/src/parser/vendors/cisco-nxos.ts +107 -0
  58. package/src/parser/vendors/cumulus-linux.ts +161 -0
  59. package/src/parser/vendors/extreme-exos.ts +154 -0
  60. package/src/parser/vendors/extreme-voss.ts +167 -0
  61. package/src/parser/vendors/fortinet-fortigate.ts +217 -0
  62. package/src/parser/vendors/huawei-vrp.ts +192 -0
  63. package/src/parser/vendors/index.ts +1521 -0
  64. package/src/parser/vendors/juniper-junos.ts +230 -0
  65. package/src/parser/vendors/mikrotik-routeros.ts +274 -0
  66. package/src/parser/vendors/nokia-sros.ts +251 -0
  67. package/src/parser/vendors/paloalto-panos.ts +264 -0
  68. package/src/parser/vendors/vyos-vyos.ts +454 -0
  69. package/src/types/ConfigNode.ts +72 -0
  70. package/src/types/DeclarativeRule.ts +158 -0
  71. 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
+ }