sekant-intercept-js 1.0.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.
@@ -0,0 +1,1373 @@
1
+ /*
2
+ * Copyright 2026 Rishi Kant (Sekant Security Inc.)
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ /**
18
+ * YARA Condition Matching Engine
19
+ *
20
+ * Evaluates YARA rule conditions against scan results.
21
+ * Supports:
22
+ * - String identifiers ($a, $b, #a, @a)
23
+ * - Rule identifiers (dependent rules: RuleA, RuleB)
24
+ * - Module functions (pe.*, elf.*, math.*, hash.*, time.*, string.*)
25
+ * - Boolean operators (and, or, not)
26
+ * - Comparison operators (==, !=, <, >, <=, >=)
27
+ * - Arithmetic operators (+, -, *, \, %)
28
+ * - Bitwise operators (&, |, ^, ~, <<, >>)
29
+ * - String operators (contains, matches, startswith, endswith, icontains, iequals)
30
+ * - Proximity operators (at, in range, within)
31
+ * - Quantifiers (all, any, none, X of them)
32
+ * - Set membership (in)
33
+ * - For expressions (for any/all of them)
34
+ * - Filesize checks
35
+ * - Entrypoint checks
36
+ *
37
+ * @see https://yara.readthedocs.io/en/stable/writingrules.html
38
+ */
39
+
40
+ /**
41
+ * Scan Facts Structure
42
+ *
43
+ * This is the standardized format for scan results that will be evaluated
44
+ * against YARA rule conditions.
45
+ *
46
+ * @typedef {Object} ScanFacts
47
+ * @property {Uint8Array} data - The raw file data being scanned
48
+ * @property {number} filesize - Size of the file in bytes
49
+ * @property {number} entrypoint - Entry point offset (for PE/ELF files)
50
+ * @property {Object.<string, StringMatchResult>} strings - String match results
51
+ * @property {Object} modules - Module instances (pe, elf, math, hash, time, string)
52
+ * @property {Object.<string, boolean>} matchedRules - Map of rule names to match status (for dependent rules)
53
+ * @property {Object} metadata - Additional metadata about the scan
54
+ *
55
+ * @typedef {Object} StringMatchResult
56
+ * @property {string} identifier - String identifier (e.g., "$a", "$hex1")
57
+ * @property {boolean} matched - Whether the string matched at all
58
+ * @property {number} count - Number of matches
59
+ * @property {Array<MatchInstance>} matches - Array of individual match instances
60
+ * @property {Array<number>} offsets - Array of match offsets (for quick access)
61
+ * @property {number} length - Length of the matched string (if fixed)
62
+ *
63
+ * @typedef {Object} MatchInstance
64
+ * @property {number} offset - Offset where the match occurred
65
+ * @property {number} length - Length of the matched data
66
+ * @property {string} [data] - Optional matched data (for debugging)
67
+ *
68
+ * Example ScanFacts:
69
+ * {
70
+ * data: Uint8Array([...]),
71
+ * filesize: 1024,
72
+ * entrypoint: 0x1000,
73
+ * strings: {
74
+ * "$a": {
75
+ * identifier: "$a",
76
+ * matched: true,
77
+ * count: 3,
78
+ * matches: [
79
+ * { offset: 10, length: 5, data: "hello" },
80
+ * { offset: 100, length: 5, data: "hello" },
81
+ * { offset: 500, length: 5, data: "hello" }
82
+ * ],
83
+ * offsets: [10, 100, 500],
84
+ * length: 5
85
+ * },
86
+ * "$b": {
87
+ * identifier: "$b",
88
+ * matched: false,
89
+ * count: 0,
90
+ * matches: [],
91
+ * offsets: [],
92
+ * length: null
93
+ * }
94
+ * },
95
+ * modules: {
96
+ * pe: { ... }, // PE module instance
97
+ * elf: { ... }, // ELF module instance
98
+ * math: { ... }, // Math module instance
99
+ * hash: { ... }, // Hash module instance
100
+ * time: { ... }, // Time module instance
101
+ * string: { ... } // String module instance
102
+ * },
103
+ * metadata: {
104
+ * filename: "sample.exe",
105
+ * scanTime: Date.now(),
106
+ * scanner: "yara-js"
107
+ * }
108
+ * }
109
+ */
110
+
111
+ /**
112
+ * Create a standard ScanFacts object from scan results
113
+ * @param {Uint8Array} data - File data
114
+ * @param {Object} stringMatches - String match results from scanner
115
+ * @param {Object} modules - Module instances
116
+ * @param {Object} options - Additional options
117
+ * @returns {ScanFacts}
118
+ */
119
+ export function createScanFacts(data, stringMatches = {}, modules = {}, options = {}) {
120
+ // Normalize string matches to standard format
121
+ const normalizedStrings = {};
122
+
123
+ for (const [identifier, result] of Object.entries(stringMatches)) {
124
+ if (Array.isArray(result)) {
125
+ // Convert array of matches to standard format
126
+ normalizedStrings[identifier] = {
127
+ identifier,
128
+ matched: result.length > 0,
129
+ count: result.length,
130
+ matches: result,
131
+ offsets: result.map(m => m.offset),
132
+ // length: result.length > 0 ? result[0].length : null
133
+ };
134
+ } else if (typeof result === 'object' && result !== null) {
135
+ // Already in standard format or close to it
136
+ normalizedStrings[identifier] = {
137
+ identifier,
138
+ matched: result.matched ?? (result.count > 0),
139
+ count: result.count ?? 0,
140
+ matches: result.matches ?? [],
141
+ offsets: result.offsets ?? (result.matches || []).map(m => m.offset),
142
+ // length: result.length ?? result.matches?.length,
143
+ ...result
144
+ };
145
+ }
146
+ }
147
+
148
+ return {
149
+ data,
150
+ filesize: data.length,
151
+ entrypoint: options.entrypoint ?? 0,
152
+ isFileSizeCapped: options.isFileSizeCapped ?? false,
153
+ maxFileSize: options.maxFileSize ?? (1024 * 1024), // Default 1MB
154
+ strings: normalizedStrings,
155
+ modules: modules || {},
156
+ matchedRules: options.matchedRules || {}, // Map of rule names to match status
157
+ metadata: options.metadata || {}
158
+ };
159
+ }
160
+
161
+ /**
162
+ * YARA Condition Evaluator
163
+ */
164
+ export class ConditionEvaluator {
165
+ constructor(scanFacts) {
166
+ this.facts = scanFacts;
167
+ this.data = scanFacts.data;
168
+ this.filesize = scanFacts.filesize;
169
+ this.entrypoint = scanFacts.entrypoint;
170
+ this.isFileSizeCapped = scanFacts.isFileSizeCapped ?? false;
171
+ this.maxFileSize = scanFacts.maxFileSize ?? (1024 * 1024);
172
+ this.strings = scanFacts.strings;
173
+ this.modules = scanFacts.modules;
174
+ this.matchedRules = scanFacts.matchedRules || {}; // Map of rule names to match status
175
+ }
176
+
177
+ /**
178
+ * Check if any operand is undefined (YARA semantics)
179
+ * According to YARA docs: "All remaining operators, including the not operator,
180
+ * return undefined if any of their operands is undefined"
181
+ * @param {...*} operands - One or more operands to check
182
+ * @returns {boolean} True if any operand is undefined
183
+ */
184
+ isAnyUndefined(...operands) {
185
+ return operands.some(operand => operand === undefined);
186
+ }
187
+
188
+ /**
189
+ * Evaluate a YARA condition expression
190
+ * @param {string|Object} condition - Condition string or AST
191
+ * @returns {Promise<boolean>} Result of condition evaluation
192
+ */
193
+ async evaluate(condition) {
194
+ if (typeof condition === 'string') {
195
+ condition = this.parseCondition(condition);
196
+ }
197
+ return await this.evaluateNode(condition);
198
+ }
199
+
200
+ /**
201
+ * Parse a condition string into an AST
202
+ * This is a simplified parser - in production, use a proper parser
203
+ * @param {string} condition
204
+ * @returns {Object} AST node
205
+ */
206
+ parseCondition(condition) {
207
+ // This would normally use a proper parser (e.g., PEG.js, antlr)
208
+ // For now, return a simple structure
209
+ // In practice, the rule compiler would generate this AST
210
+ throw new Error('String parsing not implemented - pass AST directly');
211
+ }
212
+
213
+ /**
214
+ * Evaluate an AST node
215
+ * @param {Object} node
216
+ * @returns {Promise<*>} Evaluation result
217
+ */
218
+ async evaluateNode(node) {
219
+ if (node === null || node === undefined) {
220
+ return false;
221
+ }
222
+
223
+ const type = node.type;
224
+
225
+ switch (type) {
226
+ // Literals
227
+ case 'boolean':
228
+ return node.value;
229
+ case 'number':
230
+ return node.value;
231
+ case 'string':
232
+ return node.value;
233
+ case 'identifier':
234
+ return this.resolveIdentifier(node.name);
235
+
236
+ // Rule identifiers (dependent rules)
237
+ case 'ruleIdentifier':
238
+ return this.evaluateRuleIdentifier(node);
239
+
240
+ // String identifiers
241
+ case 'stringIdentifier':
242
+ return this.evaluateStringIdentifier(node);
243
+ case 'stringCount':
244
+ return this.getStringCount(node.identifier);
245
+ case 'stringOffset': {
246
+ // Evaluate index if it's an expression (e.g., for loop variable)
247
+ let index = node.index;
248
+ if (typeof index === 'object') {
249
+ index = await this.evaluateNode(index);
250
+ }
251
+ return this.getStringOffset(node.identifier, index);
252
+ }
253
+ case 'stringLength': {
254
+ // Evaluate index if it's an expression (e.g., for loop variable)
255
+ let index = node.index;
256
+ if (typeof index === 'object') {
257
+ index = await this.evaluateNode(index);
258
+ }
259
+ return this.getStringLength(node.identifier, index);
260
+ }
261
+
262
+ // Logical operators
263
+ case 'and': {
264
+ const left = await this.evaluateNode(node.left);
265
+ const right = await this.evaluateNode(node.right);
266
+ // YARA: treat undefined as false in AND operation
267
+ if (left === undefined || left === false) return false;
268
+ if (right === undefined || right === false) return false;
269
+ return true;
270
+ }
271
+ case 'or': {
272
+ const left = await this.evaluateNode(node.left);
273
+ const right = await this.evaluateNode(node.right);
274
+ // YARA: treat undefined as false in OR operation
275
+ const leftBool = left === undefined ? false : left;
276
+ const rightBool = right === undefined ? false : right;
277
+ return leftBool || rightBool;
278
+ }
279
+ case 'not': {
280
+ const operand = await this.evaluateNode(node.operand);
281
+ // YARA: not of undefined returns undefined
282
+ if (this.isAnyUndefined(operand)) return undefined;
283
+ return !operand;
284
+ }
285
+ case 'defined':
286
+ return await this.evaluateDefined(node.operand);
287
+
288
+ // Comparison operators
289
+ case 'equal':
290
+ return await this.evaluateEqual(node);
291
+ case 'notEqual':
292
+ return await this.evaluateNotEqual(node);
293
+ case 'lessThan':
294
+ return await this.evaluateLessThan(node);
295
+ case 'greaterThan':
296
+ return await this.evaluateGreaterThan(node);
297
+ case 'lessThanOrEqual':
298
+ return await this.evaluateLessThanOrEqual(node);
299
+ case 'greaterThanOrEqual':
300
+ return await this.evaluateGreaterThanOrEqual(node);
301
+
302
+ // Arithmetic operators
303
+ case 'add': {
304
+ const left = await this.evaluateNode(node.left);
305
+ const right = await this.evaluateNode(node.right);
306
+ if (this.isAnyUndefined(left, right)) return undefined;
307
+ return left + right;
308
+ }
309
+ case 'subtract': {
310
+ const left = await this.evaluateNode(node.left);
311
+ const right = await this.evaluateNode(node.right);
312
+ if (this.isAnyUndefined(left, right)) return undefined;
313
+ return left - right;
314
+ }
315
+ case 'multiply': {
316
+ const left = await this.evaluateNode(node.left);
317
+ const right = await this.evaluateNode(node.right);
318
+ if (this.isAnyUndefined(left, right)) return undefined;
319
+ return left * right;
320
+ }
321
+ case 'divide': {
322
+ const left = await this.evaluateNode(node.left);
323
+ const right = await this.evaluateNode(node.right);
324
+ if (this.isAnyUndefined(left, right)) return undefined;
325
+ return Math.floor(left / right);
326
+ }
327
+ case 'modulo': {
328
+ const left = await this.evaluateNode(node.left);
329
+ const right = await this.evaluateNode(node.right);
330
+ if (this.isAnyUndefined(left, right)) return undefined;
331
+ return left % right;
332
+ }
333
+
334
+ // Bitwise operators (use signed 32-bit integers to match standard YARA behavior)
335
+ case 'bitwiseAnd': {
336
+ const left = await this.evaluateNode(node.left);
337
+ const right = await this.evaluateNode(node.right);
338
+ if (this.isAnyUndefined(left, right)) return undefined;
339
+ return (left & right);
340
+ }
341
+ case 'bitwiseOr': {
342
+ const left = await this.evaluateNode(node.left);
343
+ const right = await this.evaluateNode(node.right);
344
+ if (this.isAnyUndefined(left, right)) return undefined;
345
+ return (left | right);
346
+ }
347
+ case 'bitwiseXor': {
348
+ const left = await this.evaluateNode(node.left);
349
+ const right = await this.evaluateNode(node.right);
350
+ if (this.isAnyUndefined(left, right)) return undefined;
351
+ return (left ^ right);
352
+ }
353
+ case 'bitwiseNot': {
354
+ const operand = await this.evaluateNode(node.operand);
355
+ if (this.isAnyUndefined(operand)) return undefined;
356
+ return (~operand);
357
+ }
358
+ case 'shiftLeft': {
359
+ const left = await this.evaluateNode(node.left);
360
+ const right = await this.evaluateNode(node.right);
361
+ if (this.isAnyUndefined(left, right)) return undefined;
362
+ return left << right;
363
+ }
364
+ case 'shiftRight': {
365
+ const left = await this.evaluateNode(node.left);
366
+ const right = await this.evaluateNode(node.right);
367
+ if (this.isAnyUndefined(left, right)) return undefined;
368
+ return left >> right;
369
+ }
370
+
371
+ // String operators
372
+ case 'contains':
373
+ return await this.stringContains(node.left, node.right, false);
374
+ case 'icontains':
375
+ return await this.stringContains(node.left, node.right, true);
376
+ case 'startswith':
377
+ return await this.stringStartsWith(node.left, node.right, false);
378
+ case 'istartswith':
379
+ return await this.stringStartsWith(node.left, node.right, true);
380
+ case 'endswith':
381
+ return await this.stringEndsWith(node.left, node.right, false);
382
+ case 'iendswith':
383
+ return await this.stringEndsWith(node.left, node.right, true);
384
+ case 'iequals':
385
+ return await this.stringEquals(node.left, node.right, true);
386
+ case 'matches':
387
+ return await this.stringMatches(node.left, node.right);
388
+
389
+ // Set membership
390
+ case 'in':
391
+ return await this.evaluateInOperator(node);
392
+
393
+ // Range
394
+ case 'range':
395
+ return { start: await this.evaluateNode(node.start), end: await this.evaluateNode(node.end) };
396
+
397
+ // Quantifiers
398
+ case 'all':
399
+ return await this.evaluateAll(node.items);
400
+ case 'any':
401
+ return await this.evaluateAny(node.items);
402
+ case 'none':
403
+ return await this.evaluateNone(node.items);
404
+ case 'quantified':
405
+ return await this.evaluateQuantified(node);
406
+
407
+ // For expressions
408
+ case 'for':
409
+ return await this.evaluateFor(node);
410
+
411
+ // Member access (e.g., pe.entry_point)
412
+ case 'memberAccess':
413
+ return await this.evaluateMemberAccess(node);
414
+
415
+ // Array access (e.g., pe.sections[0])
416
+ case 'arrayAccess':
417
+ return await this.evaluateArrayAccess(node);
418
+
419
+ // Function call
420
+ case 'functionCall':
421
+ return await this.evaluateFunctionCall(node);
422
+
423
+ // Data access (uint8, uint16, etc.)
424
+ case 'dataAccess':
425
+ return this.evaluateDataAccess(node);
426
+
427
+ // At expression ($a at 0x100)
428
+ case 'at':
429
+ return await this.evaluateAt(node);
430
+
431
+ // In range expression ($a in (0..100))
432
+ case 'inRange':
433
+ return await this.evaluateInRange(node);
434
+
435
+ // Within expression ($a within N of $b)
436
+ case 'within':
437
+ return await this.evaluateWithin(node);
438
+
439
+ // Module function call (e.g., string.to_int(), time.now())
440
+ case 'moduleFunction':
441
+ return await this.evaluateModuleFunction(node);
442
+
443
+ default:
444
+ throw new Error(`Unknown node type: ${type}`);
445
+ }
446
+ }
447
+
448
+ /**
449
+ * Resolve a simple identifier (filesize, entrypoint, modules, etc.)
450
+ */
451
+ resolveIdentifier(name) {
452
+ // Check for loop context first (for iterator variables like 'i')
453
+ if (this.forContext && name in this.forContext) {
454
+ return this.forContext[name];
455
+ }
456
+
457
+ switch (name) {
458
+ case 'filesize':
459
+ return this.filesize;
460
+ case 'entrypoint':
461
+ // entrypoint can be 0 (valid for non-binary files or PE at offset 0)
462
+ if (typeof this.entrypoint !== 'number') {
463
+ throw new Error('Entrypoint is not defined');
464
+ }
465
+ return this.entrypoint;
466
+ default:
467
+ // Check if it's a module name (pe, elf, hash, math, string, time)
468
+ if (this.modules && name in this.modules) {
469
+ return this.modules[name]; // Could be undefined if module not available
470
+ }
471
+ // Return undefined for known module names when module is unavailable
472
+ // This allows graceful handling of missing modules (e.g., ELF module for non-ELF files)
473
+ if (['pe', 'elf', 'hash', 'math', 'string', 'time'].includes(name)) {
474
+ return undefined;
475
+ }
476
+ throw new Error(`Unknown identifier: ${name}`);
477
+ }
478
+ }
479
+
480
+ /**
481
+ * Check if left side is filesize identifier
482
+ */
483
+ isFilesizeNode(node) {
484
+ return node && node.type === 'identifier' && node.name === 'filesize';
485
+ }
486
+
487
+ /**
488
+ * Evaluate equal comparison with filesize special handling
489
+ */
490
+ async evaluateEqual(node) {
491
+ const left = await this.evaluateNode(node.left);
492
+ const right = await this.evaluateNode(node.right);
493
+
494
+ // YARA: comparison with undefined returns undefined
495
+ if (this.isAnyUndefined(left, right)) return undefined;
496
+
497
+ // Special handling: if file is capped and comparing filesize == N where N >= maxFileSize
498
+ if (this.isFilesizeNode(node.left) && this.isFileSizeCapped && right >= this.maxFileSize) {
499
+ return true; // Assume any filesize >= maxFileSize when capped
500
+ }
501
+ if (this.isFilesizeNode(node.right) && this.isFileSizeCapped && left >= this.maxFileSize) {
502
+ return true;
503
+ }
504
+
505
+ return left === right;
506
+ }
507
+
508
+ /**
509
+ * Evaluate not equal comparison with filesize special handling
510
+ */
511
+ async evaluateNotEqual(node) {
512
+ const result = await this.evaluateEqual(node);
513
+ // YARA: not equal with undefined returns undefined
514
+ if (this.isAnyUndefined(result)) return undefined;
515
+ return !result;
516
+ }
517
+
518
+ /**
519
+ * Evaluate less than comparison with filesize special handling
520
+ */
521
+ async evaluateLessThan(node) {
522
+ const left = await this.evaluateNode(node.left);
523
+ const right = await this.evaluateNode(node.right);
524
+
525
+ // YARA: comparison with undefined returns undefined
526
+ if (this.isAnyUndefined(left, right)) return undefined;
527
+
528
+ // Normal comparison - filesize < N uses actual value
529
+ return left < right;
530
+ }
531
+
532
+ /**
533
+ * Evaluate greater than comparison with filesize special handling
534
+ */
535
+ async evaluateGreaterThan(node) {
536
+ const left = await this.evaluateNode(node.left);
537
+ const right = await this.evaluateNode(node.right);
538
+
539
+ // YARA: comparison with undefined returns undefined
540
+ if (this.isAnyUndefined(left, right)) return undefined;
541
+
542
+ // Special handling: if file is capped and comparing filesize > N where N >= maxFileSize
543
+ if (this.isFilesizeNode(node.left) && this.isFileSizeCapped && right >= this.maxFileSize) {
544
+ return true; // File could be larger than the cap
545
+ }
546
+
547
+ // Normal comparison
548
+ return left > right;
549
+ }
550
+
551
+ /**
552
+ * Evaluate less than or equal comparison with filesize special handling
553
+ */
554
+ async evaluateLessThanOrEqual(node) {
555
+ const left = await this.evaluateNode(node.left);
556
+ const right = await this.evaluateNode(node.right);
557
+
558
+ // YARA: comparison with undefined returns undefined
559
+ if (this.isAnyUndefined(left, right)) return undefined;
560
+
561
+ // Normal comparison - filesize <= N uses actual value
562
+ return left <= right;
563
+ }
564
+
565
+ /**
566
+ * Evaluate greater than or equal comparison with filesize special handling
567
+ */
568
+ async evaluateGreaterThanOrEqual(node) {
569
+ const left = await this.evaluateNode(node.left);
570
+ const right = await this.evaluateNode(node.right);
571
+
572
+ // YARA: comparison with undefined returns undefined
573
+ if (this.isAnyUndefined(left, right)) return undefined;
574
+
575
+ // Special handling: if file is capped and comparing filesize >= N where N >= maxFileSize
576
+ if (this.isFilesizeNode(node.left) && this.isFileSizeCapped && right >= this.maxFileSize) {
577
+ return true; // File could be larger than or equal to the cap
578
+ }
579
+
580
+ // Normal comparison
581
+ return left >= right;
582
+ }
583
+
584
+ /**
585
+ * Evaluate string identifier ($a)
586
+ * In for loops, if identifier is '$', use the current loop variable
587
+ */
588
+ evaluateStringIdentifier(node) {
589
+ let identifier = node.identifier;
590
+
591
+ // In for loops with string iteration, '$' refers to the current string
592
+ if (identifier === '$' && this.forContext && this.forContext['$']) {
593
+ identifier = this.forContext['$'];
594
+ }
595
+
596
+ const stringInfo = this.strings[identifier];
597
+
598
+ if (!stringInfo) {
599
+ return false;
600
+ }
601
+
602
+ return stringInfo.matched && stringInfo.count > 0;
603
+ }
604
+
605
+ /**
606
+ * Evaluate rule identifier (dependent rule reference)
607
+ * Returns true if the referenced rule matched, false otherwise
608
+ */
609
+ evaluateRuleIdentifier(node) {
610
+ const ruleName = node.name;
611
+
612
+ // Check if rule has been evaluated
613
+ if (!(ruleName in this.matchedRules)) {
614
+ // Rule not found or not yet evaluated
615
+ // In YARA, referencing an undefined rule is typically an error,
616
+ // but we'll return false for now
617
+ return false;
618
+ }
619
+
620
+ return this.matchedRules[ruleName] === true;
621
+ }
622
+
623
+ /**
624
+ * Get string match count (#a)
625
+ * In for loops, if identifier is '$', use the current loop variable
626
+ */
627
+ getStringCount(identifier) {
628
+ // Handle for loop context: if identifier is '$', use current string
629
+ if (identifier === '$' && this.forContext && this.forContext['$']) {
630
+ identifier = this.forContext['$'];
631
+ }
632
+
633
+ const stringInfo = this.strings[identifier];
634
+ return stringInfo ? stringInfo.count : 0;
635
+ }
636
+
637
+ /**
638
+ * Get string match offset (@a or @a[1])
639
+ * In for loops, supports @a[i] where i is the iterator variable
640
+ * YARA uses 1-indexed: @a[1] = first match, @a[2] = second match, etc.
641
+ */
642
+ getStringOffset(identifier, index = 0) {
643
+ // Handle for loop context: if identifier is '$', use current string
644
+ if (identifier === '$' && this.forContext && this.forContext['$']) {
645
+ identifier = this.forContext['$'];
646
+ }
647
+
648
+ // If index is an identifier in for context, resolve it
649
+ if (typeof index === 'string' && this.forContext && index in this.forContext) {
650
+ index = this.forContext[index];
651
+ }
652
+
653
+ const stringInfo = this.strings[identifier];
654
+ if (!stringInfo || !stringInfo.offsets || stringInfo.offsets.length === 0) {
655
+ return undefined;
656
+ }
657
+
658
+ // YARA uses 1-indexed arrays: @a or @a[1] = first match, @a[2] = second match
659
+ // Convert to 0-indexed for JavaScript arrays
660
+ // Special case: @a (without index) and @a[0] both return first match
661
+ const arrayIndex = index === 0 ? 0 : index - 1;
662
+ return stringInfo.offsets[arrayIndex];
663
+ }
664
+
665
+ /**
666
+ * Get string match length (!a or !a[1])
667
+ * In for loops, supports !a[i] where i is the iterator variable
668
+ * YARA uses 1-indexed: !a[1] = first match length, !a[2] = second match length, etc.
669
+ */
670
+ getStringLength(identifier, index = 0) {
671
+ // Handle for loop context: if identifier is '$', use current string
672
+ if (identifier === '$' && this.forContext && this.forContext['$']) {
673
+ identifier = this.forContext['$'];
674
+ }
675
+
676
+ // If index is an identifier in for context, resolve it
677
+ if (typeof index === 'string' && this.forContext && index in this.forContext) {
678
+ index = this.forContext[index];
679
+ }
680
+
681
+ const stringInfo = this.strings[identifier];
682
+ if (!stringInfo || !stringInfo.matches || stringInfo.matches.length === 0) {
683
+ return undefined;
684
+ }
685
+
686
+ // YARA uses 1-indexed arrays: !a or !a[1] = first match, !a[2] = second match
687
+ // Convert to 0-indexed for JavaScript arrays
688
+ // Special case: !a (without index) and !a[0] both return first match
689
+ const arrayIndex = index === 0 ? 0 : index - 1;
690
+ const match = stringInfo.matches[arrayIndex];
691
+ return match ? match.length : undefined;
692
+ }
693
+
694
+ /**
695
+ * Evaluate 'all of them' or 'all of ($a*, $b*)'
696
+ */
697
+ evaluateAll(items) {
698
+ const identifiers = this.resolveStringSet(items);
699
+ if (identifiers.length === 0) return false;
700
+
701
+ return identifiers.every(id => {
702
+ const stringInfo = this.strings[id];
703
+ return stringInfo && stringInfo.matched && stringInfo.count > 0;
704
+ });
705
+ }
706
+
707
+ /**
708
+ * Evaluate 'any of them' or 'any of ($a*, $b*)'
709
+ */
710
+ evaluateAny(items) {
711
+ const identifiers = this.resolveStringSet(items);
712
+ if (identifiers.length === 0) return false;
713
+
714
+ return identifiers.some(id => {
715
+ const stringInfo = this.strings[id];
716
+ return stringInfo && stringInfo.matched && stringInfo.count > 0;
717
+ });
718
+ }
719
+
720
+ /**
721
+ * Evaluate 'none of them'
722
+ */
723
+ evaluateNone(items) {
724
+ const identifiers = this.resolveStringSet(items);
725
+ if (identifiers.length === 0) return true;
726
+
727
+ return identifiers.every(id => {
728
+ const stringInfo = this.strings[id];
729
+ return !stringInfo || !stringInfo.matched || stringInfo.count === 0;
730
+ });
731
+ }
732
+
733
+ /**
734
+ * Evaluate quantified expression (e.g., '2 of them', '50% of them')
735
+ */
736
+ evaluateQuantified(node) {
737
+ const identifiers = this.resolveStringSet(node.items);
738
+ if (identifiers.length === 0) return false;
739
+
740
+ const matchCount = identifiers.filter(id => {
741
+ const stringInfo = this.strings[id];
742
+ return stringInfo && stringInfo.matched && stringInfo.count > 0;
743
+ }).length;
744
+
745
+ const quantifier = node.quantifier;
746
+
747
+ if (quantifier.type === 'number') {
748
+ return matchCount >= quantifier.value;
749
+ } else if (quantifier.type === 'percentage') {
750
+ const required = Math.ceil(identifiers.length * quantifier.value / 100);
751
+ return matchCount >= required;
752
+ } else if (quantifier.type === 'range') {
753
+ const min = quantifier.min;
754
+ const max = quantifier.max;
755
+ return matchCount >= min && matchCount <= max;
756
+ }
757
+
758
+ return false;
759
+ }
760
+
761
+ /**
762
+ * Resolve string set (e.g., 'them', '$a*', '($a, $b, $c)')
763
+ */
764
+ resolveStringSet(items) {
765
+ if (items === 'them') {
766
+ return Object.keys(this.strings);
767
+ }
768
+
769
+ if (Array.isArray(items)) {
770
+ // Process each item in the array, expanding wildcards
771
+ const resolved = [];
772
+ for (const item of items) {
773
+ if (item.includes('*')) {
774
+ // Expand wildcard pattern
775
+ const pattern = item.replace(/\*/g, '.*').replace(/\$/g, '\\$');
776
+ const regex = new RegExp('^' + pattern + '$');
777
+ const matches = Object.keys(this.strings).filter(id => regex.test(id));
778
+ resolved.push(...matches);
779
+ } else {
780
+ resolved.push(item);
781
+ }
782
+ }
783
+ return resolved;
784
+ }
785
+
786
+ if (typeof items === 'string') {
787
+ // Handle wildcard patterns like '$a*'
788
+ if (items.includes('*')) {
789
+ const pattern = items.replace(/\*/g, '.*').replace(/\$/g, '\\$');
790
+ const regex = new RegExp('^' + pattern + '$');
791
+ return Object.keys(this.strings).filter(id => regex.test(id));
792
+ }
793
+ return [items];
794
+ }
795
+
796
+ return [];
797
+ }
798
+
799
+ /**
800
+ * Evaluate 'for' expression
801
+ * e.g., 'for any of them : ($ at entrypoint)'
802
+ * e.g., 'for all i in (1..5) : (@a[i] < @a[i+1])'
803
+ * e.g., 'for 50% of them : ($ in (0..100))'
804
+ * e.g., 'for all of ($api*) : ($ at entrypoint)'
805
+ */
806
+ async evaluateFor(node) {
807
+ const quantifier = node.quantifier; // 'any', 'all', 'none', number, or percentage object
808
+ const variable = node.variable; // iterator variable (e.g., 'i', '$')
809
+ const set = node.set; // what to iterate over
810
+ const condition = node.condition; // condition to evaluate
811
+
812
+ let items = [];
813
+
814
+ if (set.type === 'stringSet') {
815
+ // Iterating over string identifiers
816
+ items = this.resolveStringSet(set.items);
817
+
818
+ if (items.length === 0) {
819
+ // For "all", empty set means true; for "any", empty set means false
820
+ if (quantifier === 'all') return true;
821
+ return false;
822
+ }
823
+ } else if (set.type === 'range') {
824
+ // Iterating over numeric range
825
+ const startVal = await this.evaluateNode(set.start);
826
+ const endVal = await this.evaluateNode(set.end);
827
+
828
+ if (typeof startVal !== 'number' || typeof endVal !== 'number') {
829
+ throw new Error(`Invalid range values: ${startVal} to ${endVal}`);
830
+ }
831
+
832
+ for (let i = startVal; i <= endVal; i++) {
833
+ items.push(i);
834
+ }
835
+
836
+ if (items.length === 0) {
837
+ if (quantifier === 'all') return true;
838
+ return false;
839
+ }
840
+ } else {
841
+ // Evaluate the set directly
842
+ const evaluated = await this.evaluateNode(set);
843
+ if (Array.isArray(evaluated)) {
844
+ items = evaluated;
845
+ } else {
846
+ items = [evaluated];
847
+ }
848
+ }
849
+
850
+ if (items.length === 0) {
851
+ // Empty set: "all" is vacuously true, others are false
852
+ if (quantifier === 'all') return true;
853
+ return false;
854
+ }
855
+
856
+ // Store original context
857
+ const originalContext = this.forContext || {};
858
+
859
+ // Evaluate condition for each item (sequentially to maintain order)
860
+ const results = [];
861
+ for (const item of items) {
862
+ // Set the iterator variable in the context
863
+ this.forContext = { ...originalContext, [variable]: item };
864
+
865
+ try {
866
+ // Evaluate the condition with the current item
867
+ const result = await this.evaluateNode(condition);
868
+ results.push(!!result); // Coerce to boolean
869
+ } catch (error) {
870
+ // If evaluation fails, treat as false
871
+ console.warn(`For loop condition evaluation failed for item ${item}:`, error.message);
872
+ results.push(false);
873
+ }
874
+ }
875
+
876
+ // Restore original context
877
+ this.forContext = originalContext;
878
+
879
+ // Apply quantifier to results
880
+ const trueCount = results.filter(r => r).length;
881
+
882
+ if (quantifier === 'any') {
883
+ return trueCount > 0;
884
+ } else if (quantifier === 'all') {
885
+ return trueCount === items.length;
886
+ } else if (quantifier === 'none') {
887
+ return trueCount === 0;
888
+ } else if (typeof quantifier === 'number') {
889
+ // Exact count: "2 of them" means at least 2
890
+ return trueCount >= quantifier;
891
+ } else if (quantifier && quantifier.type === 'percentage') {
892
+ // Percentage: "50% of them" means at least 50%
893
+ const required = Math.ceil(items.length * quantifier.value / 100);
894
+ return trueCount >= required;
895
+ } else if (quantifier && quantifier.type === 'number') {
896
+ // Number from object
897
+ return trueCount >= quantifier.value;
898
+ }
899
+
900
+ return false;
901
+ }
902
+
903
+ /**
904
+ * Evaluate member access (e.g., pe.entry_point, math.entropy(0, 100))
905
+ */
906
+ async evaluateMemberAccess(node) {
907
+ const obj = await this.evaluateNode(node.object);
908
+ const property = node.property;
909
+
910
+ if (obj === undefined || obj === null) {
911
+ return undefined;
912
+ }
913
+
914
+ if (typeof property === 'string') {
915
+ return obj[property];
916
+ } else {
917
+ // Computed property access
918
+ const prop = await this.evaluateNode(property);
919
+ return obj[prop];
920
+ }
921
+ }
922
+
923
+ /**
924
+ * Evaluate array access (e.g., pe.sections[0], elf.sections[1])
925
+ */
926
+ async evaluateArrayAccess(node) {
927
+ const obj = await this.evaluateNode(node.object);
928
+ const index = await this.evaluateNode(node.index);
929
+
930
+ if (obj === undefined || obj === null) {
931
+ return undefined;
932
+ }
933
+
934
+ if (!Array.isArray(obj)) {
935
+ return undefined;
936
+ }
937
+
938
+ // Handle negative indices (not standard in YARA but useful)
939
+ const actualIndex = index < 0 ? obj.length + index : index;
940
+
941
+ if (actualIndex < 0 || actualIndex >= obj.length) {
942
+ return undefined;
943
+ }
944
+
945
+ return obj[actualIndex];
946
+ }
947
+
948
+ /**
949
+ * Evaluate function call
950
+ */
951
+ async evaluateFunctionCall(node) {
952
+ const func = await this.evaluateNode(node.function);
953
+ const args = await Promise.all(node.arguments.map(arg => this.evaluateNode(arg)));
954
+
955
+ if (typeof func !== 'function') {
956
+ throw new Error(`Not a function: ${node.function}`);
957
+ }
958
+
959
+ const result = func(...args);
960
+ // Handle both sync and async functions
961
+ return result instanceof Promise ? await result : result;
962
+ }
963
+
964
+ /**
965
+ * Evaluate data access (uint8, uint16, uint32, int8, int16, int32)
966
+ */
967
+ async evaluateDataAccess(node) {
968
+ const offset = await this.evaluateNode(node.offset);
969
+ const dataType = node.dataType;
970
+ const endian = node.endian || 'little'; // 'little' or 'big'
971
+
972
+ if (offset < 0 || offset >= this.data.length) {
973
+ return undefined;
974
+ }
975
+
976
+ const view = new DataView(this.data.buffer, this.data.byteOffset, this.data.byteLength);
977
+ const littleEndian = endian === 'little';
978
+
979
+ try {
980
+ switch (dataType) {
981
+ case 'uint8':
982
+ return view.getUint8(offset);
983
+ case 'uint16':
984
+ return view.getUint16(offset, littleEndian);
985
+ case 'uint32':
986
+ return view.getUint32(offset, littleEndian);
987
+ case 'int8':
988
+ return view.getInt8(offset);
989
+ case 'int16':
990
+ return view.getInt16(offset, littleEndian);
991
+ case 'int32':
992
+ return view.getInt32(offset, littleEndian);
993
+ default:
994
+ throw new Error(`Unknown data type: ${dataType}`);
995
+ }
996
+ } catch (e) {
997
+ return undefined;
998
+ }
999
+ }
1000
+
1001
+ /**
1002
+ * Evaluate 'defined' operator
1003
+ * Checks if an expression is defined (not undefined, not null)
1004
+ */
1005
+ async evaluateDefined(operand) {
1006
+ try {
1007
+ const value = await this.evaluateNode(operand);
1008
+ // Consider undefined and null as not defined
1009
+ return value !== undefined && value !== null;
1010
+ } catch {
1011
+ // If evaluation throws an error, the expression is not defined
1012
+ return false;
1013
+ }
1014
+ }
1015
+
1016
+ /**
1017
+ * Evaluate 'at' expression ($a at 0x100 or "string" at 0)
1018
+ * In for loops, if identifier is '$', use the current loop variable
1019
+ */
1020
+ async evaluateAt(node) {
1021
+ let identifier = node.identifier;
1022
+
1023
+ // Handle string literal nodes (e.g., "PDF" at 0)
1024
+ if (typeof identifier === 'object' && identifier.type === 'string') {
1025
+ const offset = await this.evaluateNode(node.offset);
1026
+ const stringValue = identifier.value;
1027
+ const stringBytes = new TextEncoder().encode(stringValue);
1028
+
1029
+ // Check if the string exists at the specified offset
1030
+ if (offset < 0 || offset + stringBytes.length > this.data.length) {
1031
+ return false;
1032
+ }
1033
+
1034
+ // Compare byte by byte
1035
+ for (let i = 0; i < stringBytes.length; i++) {
1036
+ if (this.data[offset + i] !== stringBytes[i]) {
1037
+ return false;
1038
+ }
1039
+ }
1040
+ return true;
1041
+ }
1042
+
1043
+ // In for loops with string iteration, '$' refers to the current string
1044
+ if (identifier === '$' && this.forContext && this.forContext['$']) {
1045
+ identifier = this.forContext['$'];
1046
+ }
1047
+
1048
+ const offset = await this.evaluateNode(node.offset);
1049
+ const stringInfo = this.strings[identifier];
1050
+
1051
+ if (!stringInfo || !stringInfo.offsets) {
1052
+ return false;
1053
+ }
1054
+
1055
+ return stringInfo.offsets.includes(offset);
1056
+ }
1057
+
1058
+ /**
1059
+ * Evaluate 'in range' expression ($a in (0..100))
1060
+ * In for loops, if identifier is '$', use the current loop variable
1061
+ */
1062
+ async evaluateInRange(node) {
1063
+ let identifier = node.identifier;
1064
+
1065
+ // In for loops with string iteration, '$' refers to the current string
1066
+ if (identifier === '$' && this.forContext && this.forContext['$']) {
1067
+ identifier = this.forContext['$'];
1068
+ }
1069
+
1070
+ const range = await this.evaluateNode(node.range);
1071
+ const stringInfo = this.strings[identifier];
1072
+
1073
+ if (!stringInfo || !stringInfo.offsets) {
1074
+ return false;
1075
+ }
1076
+
1077
+ return stringInfo.offsets.some(offset =>
1078
+ offset >= range.start && offset <= range.end
1079
+ );
1080
+ }
1081
+
1082
+ /**
1083
+ * Evaluate 'within' expression ($a within N of $b)
1084
+ * Checks if any occurrence of $a is within N bytes of any occurrence of $b
1085
+ *
1086
+ * Distance is measured from start offset to start offset (YARA standard behavior).
1087
+ * Returns true if the absolute distance between any pair of matches is <= N.
1088
+ *
1089
+ * @param {Object} node - AST node with identifier, distance, reference
1090
+ * @returns {Promise<boolean>} True if any match is within distance
1091
+ *
1092
+ * TODO: Performance optimization needed for large match sets
1093
+ * - Current O(n*m) complexity could be slow with many matches
1094
+ * - Consider: early termination, sorted offset arrays with binary search,
1095
+ * or distance caching for repeated evaluations
1096
+ */
1097
+ async evaluateWithin(node) {
1098
+ let identifier = node.identifier;
1099
+ let reference = node.reference;
1100
+
1101
+ // Handle for-loop context: if identifier is '$', use current loop variable
1102
+ if (identifier === '$' && this.forContext && this.forContext['$']) {
1103
+ identifier = this.forContext['$'];
1104
+ }
1105
+ if (reference === '$' && this.forContext && this.forContext['$']) {
1106
+ reference = this.forContext['$'];
1107
+ }
1108
+
1109
+ const distance = await this.evaluateNode(node.distance);
1110
+ const stringInfo = this.strings[identifier];
1111
+ const refInfo = this.strings[reference];
1112
+
1113
+ // Handle missing/empty matches - return false if either string has no matches
1114
+ if (!stringInfo || !stringInfo.offsets || stringInfo.offsets.length === 0) {
1115
+ return false;
1116
+ }
1117
+ if (!refInfo || !refInfo.offsets || refInfo.offsets.length === 0) {
1118
+ return false;
1119
+ }
1120
+
1121
+ // Check if any occurrence of identifier is within distance of any occurrence of reference
1122
+ // YARA measures distance from start offset to start offset (not considering match lengths)
1123
+ // TODO: Performance - consider optimizing this nested loop for large match sets
1124
+ for (const offset of stringInfo.offsets) {
1125
+ for (const refOffset of refInfo.offsets) {
1126
+ const dist = Math.abs(offset - refOffset);
1127
+ if (dist <= distance) {
1128
+ return true;
1129
+ }
1130
+ }
1131
+ }
1132
+
1133
+ return false;
1134
+ }
1135
+
1136
+ /**
1137
+ * Evaluate module function call (e.g., string.to_int($version), time.now())
1138
+ */
1139
+ async evaluateModuleFunction(node) {
1140
+ const { module, function: functionName, args } = node;
1141
+
1142
+ // Get the module from available modules
1143
+ const moduleObj = this.modules[module];
1144
+ if (!moduleObj) {
1145
+ // Return undefined when module is not available (e.g., pe module for non-PE files)
1146
+ // This allows conditions like "pe.imports(...)" to evaluate to undefined/false
1147
+ return undefined;
1148
+ }
1149
+
1150
+ // Get the function from the module
1151
+ const func = moduleObj[functionName];
1152
+ if (typeof func !== 'function') {
1153
+ // Return undefined if function not found in module
1154
+ return undefined;
1155
+ }
1156
+
1157
+ // Evaluate arguments
1158
+ const evaluatedArgs = await Promise.all(args.map(async arg => {
1159
+ const result = await this.evaluateNode(arg);
1160
+
1161
+ // For string identifiers, get the actual matched string value
1162
+ if (arg.type === 'stringIdentifier') {
1163
+ const stringInfo = this.strings[arg.identifier];
1164
+ if (stringInfo && stringInfo.matches && stringInfo.matches.length > 0) {
1165
+ // Get the first match value
1166
+ const match = stringInfo.matches[0];
1167
+ if (match.value) {
1168
+ return match.value;
1169
+ }
1170
+ // If no value stored, try to extract from data
1171
+ if (this.data && match.offset !== undefined && match.length !== undefined) {
1172
+ const bytes = this.data.slice(match.offset, match.offset + match.length);
1173
+ return new TextDecoder().decode(bytes);
1174
+ }
1175
+ }
1176
+ return '';
1177
+ }
1178
+
1179
+ return result;
1180
+ }));
1181
+
1182
+ // Call the function with evaluated arguments
1183
+ try {
1184
+ const result = func.apply(moduleObj, evaluatedArgs);
1185
+ // Handle both sync and async module functions
1186
+ return result instanceof Promise ? await result : result;
1187
+ } catch (error) {
1188
+ throw new Error(`Error calling ${module}.${functionName}: ${error.message}`);
1189
+ }
1190
+ }
1191
+
1192
+ /**
1193
+ * Evaluate 'in' operator (value in (set))
1194
+ */
1195
+ async evaluateInOperator(node) {
1196
+ const value = await this.evaluateNode(node.value);
1197
+ const set = await this.evaluateNode(node.set);
1198
+
1199
+ // YARA: in operator returns undefined if operands are undefined
1200
+ if (this.isAnyUndefined(value, set)) return undefined;
1201
+
1202
+ if (Array.isArray(set)) {
1203
+ return set.includes(value);
1204
+ } else if (set && typeof set === 'object' && set.start !== undefined && set.end !== undefined) {
1205
+ // Range
1206
+ return value >= set.start && value <= set.end;
1207
+ }
1208
+
1209
+ return false;
1210
+ }
1211
+
1212
+ /**
1213
+ * String contains (case-sensitive or insensitive)
1214
+ */
1215
+ async stringContains(leftNode, rightNode, ignoreCase = false) {
1216
+ let left = await this.evaluateNode(leftNode);
1217
+ let right = await this.evaluateNode(rightNode);
1218
+
1219
+ // YARA: string operators return undefined if operands are undefined
1220
+ if (this.isAnyUndefined(left, right)) return undefined;
1221
+
1222
+ if (typeof left !== 'string' || typeof right !== 'string') {
1223
+ return false;
1224
+ }
1225
+
1226
+ if (ignoreCase) {
1227
+ left = left.toLowerCase();
1228
+ right = right.toLowerCase();
1229
+ }
1230
+
1231
+ return left.includes(right);
1232
+ }
1233
+
1234
+ /**
1235
+ * String starts with
1236
+ */
1237
+ async stringStartsWith(leftNode, rightNode, ignoreCase = false) {
1238
+ let left = await this.evaluateNode(leftNode);
1239
+ let right = await this.evaluateNode(rightNode);
1240
+
1241
+ // YARA: string operators return undefined if operands are undefined
1242
+ if (this.isAnyUndefined(left, right)) return undefined;
1243
+
1244
+ if (typeof left !== 'string' || typeof right !== 'string') {
1245
+ return false;
1246
+ }
1247
+
1248
+ if (ignoreCase) {
1249
+ left = left.toLowerCase();
1250
+ right = right.toLowerCase();
1251
+ }
1252
+
1253
+ return left.startsWith(right);
1254
+ }
1255
+
1256
+ /**
1257
+ * String ends with
1258
+ */
1259
+ async stringEndsWith(leftNode, rightNode, ignoreCase = false) {
1260
+ let left = await this.evaluateNode(leftNode);
1261
+ let right = await this.evaluateNode(rightNode);
1262
+
1263
+ // YARA: string operators return undefined if operands are undefined
1264
+ if (this.isAnyUndefined(left, right)) return undefined;
1265
+
1266
+ if (typeof left !== 'string' || typeof right !== 'string') {
1267
+ return false;
1268
+ }
1269
+
1270
+ if (ignoreCase) {
1271
+ left = left.toLowerCase();
1272
+ right = right.toLowerCase();
1273
+ }
1274
+
1275
+ return left.endsWith(right);
1276
+ }
1277
+
1278
+ /**
1279
+ * String equals (case-insensitive)
1280
+ */
1281
+ async stringEquals(leftNode, rightNode, ignoreCase = false) {
1282
+ let left = await this.evaluateNode(leftNode);
1283
+ let right = await this.evaluateNode(rightNode);
1284
+
1285
+ // YARA: string operators return undefined if operands are undefined
1286
+ if (this.isAnyUndefined(left, right)) return undefined;
1287
+
1288
+ if (typeof left !== 'string' || typeof right !== 'string') {
1289
+ return false;
1290
+ }
1291
+
1292
+ if (ignoreCase) {
1293
+ left = left.toLowerCase();
1294
+ right = right.toLowerCase();
1295
+ }
1296
+
1297
+ return left === right;
1298
+ }
1299
+
1300
+ /**
1301
+ * String matches regex
1302
+ */
1303
+ async stringMatches(leftNode, rightNode) {
1304
+ const left = await this.evaluateNode(leftNode);
1305
+ const right = await this.evaluateNode(rightNode);
1306
+
1307
+ // YARA: string operators return undefined if operands are undefined
1308
+ if (this.isAnyUndefined(left, right)) return undefined;
1309
+
1310
+ if (typeof left !== 'string') {
1311
+ return false;
1312
+ }
1313
+
1314
+ let regex;
1315
+ if (right instanceof RegExp) {
1316
+ regex = right;
1317
+ } else if (typeof right === 'string') {
1318
+ regex = new RegExp(right);
1319
+ } else {
1320
+ return false;
1321
+ }
1322
+
1323
+ return regex.test(left);
1324
+ }
1325
+ }
1326
+
1327
+ /**
1328
+ * Helper function to evaluate a condition against scan facts
1329
+ * @param {Object} condition - Condition AST
1330
+ * @param {ScanFacts} scanFacts - Scan results
1331
+ * @returns {Promise<boolean>} Evaluation result
1332
+ */
1333
+ export async function evaluateCondition(condition, scanFacts) {
1334
+ const evaluator = new ConditionEvaluator(scanFacts);
1335
+ return evaluator.evaluate(condition);
1336
+ }
1337
+
1338
+ /**
1339
+ * Batch evaluate multiple rules against scan facts
1340
+ * @param {Array<Object>} rules - Array of rule objects with condition
1341
+ * @param {ScanFacts} scanFacts - Scan results
1342
+ * @returns {Promise<Array<Object>>} Array of results { rule, matched, error }
1343
+ */
1344
+ export async function evaluateRules(rules, scanFacts) {
1345
+ const evaluator = new ConditionEvaluator(scanFacts);
1346
+ const results = [];
1347
+
1348
+ for (const rule of rules) {
1349
+ try {
1350
+ const matched = await evaluator.evaluate(rule.condition);
1351
+ results.push({
1352
+ rule: rule.name || rule.id,
1353
+ matched,
1354
+ error: null
1355
+ });
1356
+ } catch (error) {
1357
+ results.push({
1358
+ rule: rule.name || rule.id,
1359
+ matched: false,
1360
+ error: error.message
1361
+ });
1362
+ }
1363
+ }
1364
+
1365
+ return results;
1366
+ }
1367
+
1368
+ export default {
1369
+ createScanFacts,
1370
+ ConditionEvaluator,
1371
+ evaluateCondition,
1372
+ evaluateRules
1373
+ };