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,1083 @@
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 Parser
19
+ *
20
+ * Parses YARA condition strings into AST format for evaluation.
21
+ * This is a simplified parser that handles common YARA condition patterns.
22
+ *
23
+ * Supported patterns:
24
+ * - String identifiers: $a, $b, $c
25
+ * - Rule identifiers: RuleA, RuleB (dependent rules)
26
+ * - Quantifiers: any of them, all of them, N of them, X% of them
27
+ * - For loops: for all/any of them : (condition), for i in (range) : (condition)
28
+ * - Logical operators: and, or, not
29
+ * - Comparison: ==, !=, <, >, <=, >=
30
+ * - Arithmetic operators: +, -, *, /, %
31
+ * - Bitwise operators: &, |, ^, ~, <<, >>
32
+ * - Proximity operators: $a at offset, $a in (range), $a within N of $b
33
+ * - Identifiers: filesize, entrypoint
34
+ * - Boolean literals: true, false
35
+ * - Numbers and basic arithmetic
36
+ */
37
+
38
+ // Constants
39
+ const PLACEHOLDER_PREFIX = '__LITERAL_';
40
+ const PLACEHOLDER_SUFFIX = '__';
41
+ const STRING_WILDCARD_VAR = '$';
42
+
43
+ const KEYWORD_LITERALS = {
44
+ TRUE: 'true',
45
+ FALSE: 'false',
46
+ FILESIZE: 'filesize',
47
+ ENTRYPOINT: 'entrypoint'
48
+ };
49
+
50
+ const QUANTIFIER_KEYWORDS = {
51
+ ALL: 'all',
52
+ ANY: 'any',
53
+ NONE: 'none',
54
+ THEM: 'them'
55
+ };
56
+
57
+ // Data access type definitions
58
+ const DATA_ACCESS_TYPES = [
59
+ { name: 'uint8', bits: 8, signed: false, endian: 'little' },
60
+ { name: 'uint16', bits: 16, signed: false, endian: 'little' },
61
+ { name: 'uint32', bits: 32, signed: false, endian: 'little' },
62
+ { name: 'int8', bits: 8, signed: true, endian: 'little' },
63
+ { name: 'int16', bits: 16, signed: true, endian: 'little' },
64
+ { name: 'int32', bits: 32, signed: true, endian: 'little' },
65
+ { name: 'uint16be', bits: 16, signed: false, endian: 'big' },
66
+ { name: 'uint32be', bits: 32, signed: false, endian: 'big' },
67
+ { name: 'int16be', bits: 16, signed: true, endian: 'big' },
68
+ { name: 'int32be', bits: 32, signed: true, endian: 'big' }
69
+ ];
70
+
71
+ // Operator registry
72
+ const OPERATOR_REGISTRY = {
73
+ string: [
74
+ { pattern: 'icontains', type: 'icontains' },
75
+ { pattern: 'contains', type: 'contains' },
76
+ { pattern: 'istartswith', type: 'istartswith' },
77
+ { pattern: 'startswith', type: 'startswith' },
78
+ { pattern: 'iendswith', type: 'iendswith' },
79
+ { pattern: 'endswith', type: 'endswith' }
80
+ ],
81
+ comparison: [
82
+ { pattern: '==', type: 'equal' },
83
+ { pattern: '!=', type: 'notEqual' },
84
+ { pattern: '<=', type: 'lessThanOrEqual' },
85
+ { pattern: '>=', type: 'greaterThanOrEqual' },
86
+ { pattern: '<', type: 'lessThan' },
87
+ { pattern: '>', type: 'greaterThan' }
88
+ ],
89
+ arithmetic: [
90
+ { pattern: '+', type: 'add' },
91
+ { pattern: '-', type: 'subtract' },
92
+ { pattern: '*', type: 'multiply' },
93
+ { pattern: '\\', type: 'divide' },
94
+ { pattern: '%', type: 'modulo' }
95
+ ]
96
+ };
97
+
98
+ /**
99
+ * Utility: Skip whitespace in a string starting from a given index
100
+ * @param {string} str - String to process
101
+ * @param {number} startIndex - Starting position
102
+ * @returns {number} Index after whitespace
103
+ */
104
+ function skipWhitespace(str, startIndex) {
105
+ let index = startIndex;
106
+ while (index < str.length && /\s/.test(str[index])) index++;
107
+ return index;
108
+ }
109
+
110
+ /**
111
+ * Utility: Find matching closing parenthesis
112
+ * @param {string} str - String to search
113
+ * @param {number} startIndex - Index of opening paren
114
+ * @returns {number} Index of closing paren, or -1 if not found
115
+ */
116
+ function findMatchingParen(str, startIndex) {
117
+ let depth = 1;
118
+ let index = startIndex + 1;
119
+ while (index < str.length && depth > 0) {
120
+ if (str[index] === '(') depth++;
121
+ if (str[index] === ')') depth--;
122
+ index++;
123
+ }
124
+ return depth === 0 ? index : -1;
125
+ }
126
+
127
+ /**
128
+ * Utility: Check if parentheses wrap the entire expression
129
+ * @param {string} expr - Expression to check
130
+ * @returns {boolean} True if outer parens wrap everything
131
+ */
132
+ function wrapsEntireExpression(expr) {
133
+ if (!expr.startsWith('(') || !expr.endsWith(')')) return false;
134
+ let depth = 0;
135
+ for (let i = 0; i < expr.length; i++) {
136
+ if (expr[i] === '(') depth++;
137
+ if (expr[i] === ')') depth--;
138
+ if (depth === 0 && i < expr.length - 1) return false;
139
+ }
140
+ return true;
141
+ }
142
+
143
+ /**
144
+ * Check if a string has unescaped quotes in the middle
145
+ * @param {string} str - String to check (including outer quotes)
146
+ * @param {string} quoteChar - Quote character to look for (" or ')
147
+ * @returns {boolean} True if there are unescaped quotes in the middle
148
+ */
149
+ function hasUnescapedQuotesInMiddle(str, quoteChar) {
150
+ let escaped = false;
151
+
152
+ // Check from position 1 to length-1 (excluding outer quotes)
153
+ for (let i = 1; i < str.length - 1; i++) {
154
+ if (escaped) {
155
+ escaped = false;
156
+ continue;
157
+ }
158
+ if (str[i] === '\\') {
159
+ escaped = true;
160
+ continue;
161
+ }
162
+ if (str[i] === quoteChar) {
163
+ return true; // Found unescaped quote in middle
164
+ }
165
+ }
166
+
167
+ return false;
168
+ }
169
+
170
+ /**
171
+ * Validate if a string is a properly quoted string literal
172
+ * @param {string} str - String to validate (including outer quotes)
173
+ * @param {string} quoteChar - Quote character used (" or ')
174
+ * @returns {boolean} True if valid string literal
175
+ */
176
+ function isValidStringLiteral(str, quoteChar) {
177
+ // Empty string is valid
178
+ if (str.length === 2) {
179
+ return true;
180
+ }
181
+
182
+ // Check for unescaped quotes in the middle
183
+ return !hasUnescapedQuotesInMiddle(str, quoteChar);
184
+ }
185
+
186
+ /**
187
+ * Extract string literals from condition and replace with placeholders
188
+ * Phase 1 of parsing: removes string literals to avoid confusion with operators
189
+ *
190
+ * @param {string} condition - Raw condition string
191
+ * @returns {Object} { processed: string with placeholders, literals: array of literal info }
192
+ */
193
+ function extractStringLiterals(condition) {
194
+ const literals = [];
195
+ let processed = condition;
196
+ let counter = 0;
197
+
198
+ /**
199
+ * Helper to extract strings with a specific quote character
200
+ * @param {string} text - Text to process
201
+ * @param {string} quoteChar - Quote character (" or ')
202
+ * @returns {string} Processed text with placeholders
203
+ */
204
+ const extractQuoted = (text, quoteChar) => {
205
+ const regex = new RegExp(`${quoteChar}(?:[^${quoteChar}\\\\]|\\\\.)*${quoteChar}`, 'g');
206
+ return text.replace(regex, (match) => {
207
+ if (isValidStringLiteral(match, quoteChar)) {
208
+ const placeholder = `${PLACEHOLDER_PREFIX}${counter}${PLACEHOLDER_SUFFIX}`;
209
+ literals.push({
210
+ id: placeholder,
211
+ type: 'string',
212
+ value: match.slice(1, -1) // Remove quotes
213
+ });
214
+ counter++;
215
+ return placeholder;
216
+ }
217
+ return match; // Keep as-is if invalid
218
+ });
219
+ };
220
+
221
+ // Extract double-quoted strings, then single-quoted strings
222
+ processed = extractQuoted(processed, '"');
223
+ processed = extractQuoted(processed, "'");
224
+
225
+ return { processed, literals };
226
+ }
227
+
228
+ /**
229
+ * Restore string literals in AST by replacing placeholder identifiers
230
+ * Phase 3 of parsing: replaces placeholders with actual string literal nodes
231
+ *
232
+ * @param {Object} ast - AST node to process
233
+ * @param {Array} literals - Array of literal info from extraction phase
234
+ * @returns {Object} AST with literals restored
235
+ */
236
+ function restoreStringLiterals(ast, literals) {
237
+ if (!ast || typeof ast !== 'object') {
238
+ return ast;
239
+ }
240
+
241
+ // Check if this is a placeholder identifier
242
+ if (ast.type === 'identifier' && ast.name && ast.name.startsWith(PLACEHOLDER_PREFIX)) {
243
+ const literal = literals.find(lit => lit.id === ast.name);
244
+ if (literal) {
245
+ return { type: 'string', value: literal.value };
246
+ }
247
+ }
248
+
249
+ // Recursively process all properties
250
+ const result = { ...ast };
251
+ for (const key in result) {
252
+ if (Object.prototype.hasOwnProperty.call(result, key)) {
253
+ if (Array.isArray(result[key])) {
254
+ result[key] = result[key].map(item => {
255
+ // Handle plain string items (like in 'items' arrays)
256
+ if (typeof item === 'string' && item.startsWith(PLACEHOLDER_PREFIX)) {
257
+ const literal = literals.find(lit => lit.id === item);
258
+ return literal ? literal.value : item;
259
+ }
260
+ // Handle object items
261
+ return typeof item === 'object' ? restoreStringLiterals(item, literals) : item;
262
+ });
263
+ } else if (typeof result[key] === 'object') {
264
+ result[key] = restoreStringLiterals(result[key], literals);
265
+ }
266
+ }
267
+ }
268
+
269
+ return result;
270
+ }
271
+
272
+ /**
273
+ * Parse YARA condition to AST
274
+ * @param {string} condition - Condition string
275
+ * @param {Object} strings - Available string identifiers
276
+ * @returns {Object} AST node
277
+ */
278
+ export function parseConditionToAST(condition, strings = {}) {
279
+ condition = condition.trim();
280
+
281
+ // Phase 1: Extract string literals to avoid parsing confusion
282
+ const { processed, literals } = extractStringLiterals(condition);
283
+
284
+ // Phase 2: Parse structure with placeholders
285
+ const ast = parseStructure(processed, strings);
286
+
287
+ // Phase 3: Restore string literals in AST
288
+ return restoreStringLiterals(ast, literals);
289
+ }
290
+
291
+ /**
292
+ * Parse quantifier expressions (any/all/none of them, N of them, etc.)
293
+ * Consolidates all quantifier parsing patterns into one function
294
+ *
295
+ * @param {string} condition - Condition string
296
+ * @returns {Object|null} AST node for quantifier, or null if not a quantifier
297
+ */
298
+ function parseQuantifier(condition) {
299
+ // Pattern: <quantifier> of <items>
300
+ // quantifier can be: any, all, none, N (number), X% (percentage), N..M (range)
301
+ // items can be: them, or ($a, $b, $c)
302
+
303
+ const quantifierPattern = /^(any|all|none|\d+|\d+%|\d+\.\.\d+)\s+of\s+(them|\([^)]+\))$/;
304
+ const match = condition.match(quantifierPattern);
305
+
306
+ if (!match) return null;
307
+
308
+ const [, quantPart, itemsPart] = match;
309
+
310
+ // Parse quantifier part
311
+ let quantifier;
312
+ let type;
313
+
314
+ if (quantPart === 'any') {
315
+ type = 'any';
316
+ } else if (quantPart === 'all') {
317
+ type = 'all';
318
+ } else if (quantPart === 'none') {
319
+ type = 'none';
320
+ } else if (quantPart.includes('..')) {
321
+ // Range: N..M
322
+ const [min, max] = quantPart.split('..').map(n => parseInt(n));
323
+ type = 'quantified';
324
+ quantifier = { type: 'range', min, max };
325
+ } else if (quantPart.endsWith('%')) {
326
+ // Percentage: X%
327
+ type = 'quantified';
328
+ quantifier = { type: 'percentage', value: parseInt(quantPart) };
329
+ } else {
330
+ // Number: N
331
+ type = 'quantified';
332
+ quantifier = { type: 'number', value: parseInt(quantPart) };
333
+ }
334
+
335
+ // Parse items part
336
+ let items;
337
+ if (itemsPart === 'them') {
338
+ items = 'them';
339
+ } else {
340
+ // Extract items from parentheses: ($a, $b, $c)
341
+ const itemsStr = itemsPart.slice(1, -1); // Remove outer parens
342
+ items = itemsStr.split(',').map(s => s.trim());
343
+ }
344
+
345
+ return { type, ...(quantifier && { quantifier }), items };
346
+ }
347
+
348
+ /**
349
+ * Parse condition structure (Phase 2)
350
+ * This is the main parsing logic, now working with string literals replaced by placeholders
351
+ * @param {string} condition - Condition string with placeholders
352
+ * @param {Object} strings - Available string identifiers
353
+ * @returns {Object} AST node
354
+ */
355
+ function parseStructure(condition, strings = {}) {
356
+ condition = condition.trim();
357
+
358
+ // Remove outer parentheses if they wrap the entire expression
359
+ while (wrapsEntireExpression(condition)) {
360
+ condition = condition.slice(1, -1).trim();
361
+ }
362
+
363
+ // Try parsing as quantifier first
364
+ const quantifierNode = parseQuantifier(condition);
365
+ if (quantifierNode) {
366
+ return quantifierNode;
367
+ }
368
+
369
+ /**
370
+ * Helper: Parse binary operator (or, and)
371
+ * @param {string} operator - Operator name ('or', 'and')
372
+ * @param {number} operatorLength - Length of operator string
373
+ * @returns {Object|null} AST node or null
374
+ */
375
+ const parseBinaryOperator = (operator, operatorLength) => {
376
+ const opIndex = findOperatorOutsideParens(condition, ` ${operator} `);
377
+ if (opIndex === -1) return null;
378
+
379
+ // Find where operator ends (skip whitespace before, operator, and whitespace after)
380
+ let endIndex = skipWhitespace(condition, opIndex);
381
+ endIndex += operatorLength; // skip operator
382
+ endIndex = skipWhitespace(condition, endIndex);
383
+
384
+ return {
385
+ type: operator,
386
+ left: parseStructure(condition.substring(0, opIndex), strings),
387
+ right: parseStructure(condition.substring(endIndex), strings)
388
+ };
389
+ };
390
+
391
+ // Handle "or" operator (lowest precedence - check first)
392
+ const orNode = parseBinaryOperator('or', 2);
393
+ if (orNode) return orNode;
394
+
395
+ // Handle "and" operator (higher precedence than OR - check after OR)
396
+ const andNode = parseBinaryOperator('and', 3);
397
+ if (andNode) return andNode;
398
+
399
+
400
+ // Handle "for" loops (higher precedence than AND/OR - check after them)
401
+ // Patterns:
402
+ // - for all of them : ( condition )
403
+ // - for any of them : ( condition )
404
+ // - for none of them : ( condition )
405
+ // - for 2 of them : ( condition )
406
+ // - for 50% of them : ( condition )
407
+ // - for all of ($a*, $b*) : ( condition )
408
+ // - for all i in (1..10) : ( condition )
409
+ // - for all i in (0..#a) : ( condition )
410
+
411
+ // Use a smarter approach: find "for", then find the matching parentheses for the body
412
+ if (condition.trim().startsWith('for ')) {
413
+ // Find the colon that separates iterator from body
414
+ const colonIndex = condition.indexOf(':');
415
+ if (colonIndex !== -1) {
416
+ // Find the opening paren after the colon
417
+ let openParenIndex = colonIndex + 1;
418
+ while (openParenIndex < condition.length && condition[openParenIndex] !== '(') {
419
+ openParenIndex++;
420
+ }
421
+
422
+ if (openParenIndex < condition.length) {
423
+ // Find the matching closing paren
424
+ let depth = 1;
425
+ let closeParenIndex = openParenIndex + 1;
426
+ while (closeParenIndex < condition.length && depth > 0) {
427
+ if (condition[closeParenIndex] === '(') depth++;
428
+ if (condition[closeParenIndex] === ')') depth--;
429
+ closeParenIndex++;
430
+ }
431
+
432
+ if (depth === 0) {
433
+ // Successfully found matching parens
434
+ const forHeader = condition.substring(0, colonIndex).trim();
435
+ const bodyCondition = condition.substring(openParenIndex + 1, closeParenIndex - 1).trim();
436
+
437
+ /**
438
+ * Helper: Parse for-loop quantifier
439
+ * @param {string} quantifierStr - Quantifier string (all, any, none, N, N%)
440
+ * @returns {string|Object} Parsed quantifier
441
+ */
442
+ const parseForQuantifier = (quantifierStr) => {
443
+ if (['all', 'any', 'none'].includes(quantifierStr)) return quantifierStr;
444
+ if (quantifierStr.endsWith('%')) {
445
+ return { type: 'percentage', value: parseInt(quantifierStr) };
446
+ }
447
+ return { type: 'number', value: parseInt(quantifierStr) };
448
+ };
449
+
450
+ /**
451
+ * Helper: Parse for-loop iterator
452
+ * @param {string} iteratorPart - Iterator part of for-loop
453
+ * @returns {Object} { variable, set }
454
+ */
455
+ const parseForIterator = (iteratorPart) => {
456
+ // Check for "i in (range)" pattern
457
+ const iteratorInRangeMatch = iteratorPart.match(/^(\w+)\s+in\s+\((.+?)\.\.(.+?)\)$/);
458
+ if (iteratorInRangeMatch) {
459
+ return {
460
+ variable: iteratorInRangeMatch[1],
461
+ set: {
462
+ type: 'range',
463
+ start: parseExpression(iteratorInRangeMatch[2].trim()),
464
+ end: parseExpression(iteratorInRangeMatch[3].trim())
465
+ }
466
+ };
467
+ }
468
+
469
+ // Check for "of them" or "of (set)" pattern
470
+ const ofMatch = iteratorPart.match(/^of\s+(.+)$/);
471
+ if (ofMatch) {
472
+ const setStr = ofMatch[1].trim();
473
+ let items;
474
+
475
+ if (setStr === 'them') {
476
+ items = 'them';
477
+ } else if (setStr.startsWith('(') && setStr.endsWith(')')) {
478
+ items = setStr.slice(1, -1).split(',').map(s => s.trim());
479
+ } else {
480
+ items = setStr; // wildcard pattern like $api*
481
+ }
482
+
483
+ return {
484
+ variable: '$', // implicit variable for strings
485
+ set: { type: 'stringSet', items }
486
+ };
487
+ }
488
+
489
+ return { variable: null, set: null };
490
+ };
491
+
492
+ // Parse the "for quantifier iterator" part
493
+ const forMatch = forHeader.match(/^for\s+(all|any|none|\d+|(\d+)%)\s+(.+)$/);
494
+ if (forMatch) {
495
+ const quantifier = parseForQuantifier(forMatch[1]);
496
+ const { variable, set } = parseForIterator(forMatch[3]);
497
+
498
+ return {
499
+ type: 'for',
500
+ quantifier,
501
+ variable,
502
+ set,
503
+ condition: parseStructure(bodyCondition, strings)
504
+ };
505
+ }
506
+ }
507
+ }
508
+ }
509
+ }
510
+
511
+ // Handle "not" operator
512
+ if (condition.startsWith('not ')) {
513
+ return {
514
+ type: 'not',
515
+ operand: parseStructure(condition.substring(4), strings)
516
+ };
517
+ }
518
+
519
+ // Handle "defined" operator
520
+ if (condition.startsWith('defined ')) {
521
+ return {
522
+ type: 'defined',
523
+ operand: parseStructure(condition.substring(8), strings)
524
+ };
525
+ }
526
+
527
+ // Handle "$a at offset", "$ at offset" (for loops), or "string" at offset (string literals)
528
+ const atMatch = condition.match(/^(\$\w*|\w+)\s+at\s+(.+)$/);
529
+ if (atMatch) {
530
+ const firstPart = atMatch[1];
531
+ // Check if it's a string identifier ($a) or a placeholder (__LITERAL_N__) or other identifier
532
+ if (firstPart.startsWith('$')) {
533
+ return {
534
+ type: 'at',
535
+ identifier: firstPart,
536
+ offset: parseExpression(atMatch[2])
537
+ };
538
+ } else if (firstPart.startsWith(PLACEHOLDER_PREFIX)) {
539
+ // String literal placeholder - parse it as an expression to get the string node
540
+ return {
541
+ type: 'at',
542
+ identifier: parseExpression(firstPart),
543
+ offset: parseExpression(atMatch[2])
544
+ };
545
+ }
546
+ }
547
+
548
+ // Handle "$a in (start..end)" or "$ in (start..end)" (for loops)
549
+ const inRangeMatch = condition.match(/^(\$\w*)\s+in\s+\((.+)\.\.(.+)\)$/);
550
+ if (inRangeMatch) {
551
+ return {
552
+ type: 'inRange',
553
+ identifier: inRangeMatch[1],
554
+ range: {
555
+ type: 'range',
556
+ start: parseExpression(inRangeMatch[2]),
557
+ end: parseExpression(inRangeMatch[3])
558
+ }
559
+ };
560
+ }
561
+
562
+ // Handle "$a within N of $b" or "$a within N bytes of $b"
563
+ // Proximity operator: checks if any occurrence of $a is within N bytes of any occurrence of $b
564
+ const withinMatch = condition.match(/^(\$\w*)\s+within\s+(.+?)(?:\s+bytes)?\s+of\s+(\$\w*)$/);
565
+ if (withinMatch) {
566
+ return {
567
+ type: 'within',
568
+ identifier: withinMatch[1],
569
+ distance: parseExpression(withinMatch[2]),
570
+ reference: withinMatch[3]
571
+ };
572
+ }
573
+
574
+ // Handle string operators (must check before comparison operators)
575
+ // These are word-based operators that require flexible whitespace matching
576
+ const stringOps = [
577
+ { pattern: 'icontains', type: 'icontains' },
578
+ { pattern: 'contains', type: 'contains' },
579
+ { pattern: 'istartswith', type: 'istartswith' },
580
+ { pattern: 'startswith', type: 'startswith' },
581
+ { pattern: 'iendswith', type: 'iendswith' },
582
+ { pattern: 'endswith', type: 'endswith' }
583
+ ];
584
+
585
+ for (const op of stringOps) {
586
+ const opIndex = findOperatorOutsideParens(condition, ` ${op.pattern} `);
587
+ if (opIndex !== -1) {
588
+ // Find where the operator ends (skip whitespace before, operator, and whitespace after)
589
+ let endIndex = opIndex;
590
+ while (endIndex < condition.length && /\s/.test(condition[endIndex])) endIndex++;
591
+ endIndex += op.pattern.length;
592
+ while (endIndex < condition.length && /\s/.test(condition[endIndex])) endIndex++;
593
+
594
+ return {
595
+ type: op.type,
596
+ left: parseExpression(condition.substring(0, opIndex)),
597
+ right: parseExpression(condition.substring(endIndex))
598
+ };
599
+ }
600
+ }
601
+
602
+ // Handle comparison operators
603
+ const comparisonOps = [
604
+ { pattern: '==', type: 'equal' },
605
+ { pattern: '!=', type: 'notEqual' },
606
+ { pattern: '<=', type: 'lessThanOrEqual' },
607
+ { pattern: '>=', type: 'greaterThanOrEqual' },
608
+ { pattern: '<', type: 'lessThan' },
609
+ { pattern: '>', type: 'greaterThan' }
610
+ ];
611
+
612
+ for (const op of comparisonOps) {
613
+ const opIndex = findOperatorOutsideParens(condition, op.pattern);
614
+ if (opIndex !== -1) {
615
+ return {
616
+ type: op.type,
617
+ left: parseExpression(condition.substring(0, opIndex)),
618
+ right: parseExpression(condition.substring(opIndex + op.pattern.length))
619
+ };
620
+ }
621
+ }
622
+
623
+ // Handle single expression
624
+ return parseExpression(condition);
625
+ }
626
+
627
+ /**
628
+ * Parse an expression (identifier, number, string identifier, etc.)
629
+ * @param {string} expr - Expression string
630
+ * @returns {Object} AST node
631
+ */
632
+ function parseExpression(expr) {
633
+ expr = expr.trim();
634
+
635
+ // Boolean literals - must check before identifiers
636
+ if (expr === 'true') {
637
+ return { type: 'boolean', value: true };
638
+ }
639
+ if (expr === 'false') {
640
+ return { type: 'boolean', value: false };
641
+ }
642
+
643
+ // Size units (KB, MB, GB) - must check before plain numbers
644
+ const sizeUnitMatch = expr.match(/^(\d+)(KB|MB|GB)$/i);
645
+ if (sizeUnitMatch) {
646
+ const value = parseInt(sizeUnitMatch[1]);
647
+ const unit = sizeUnitMatch[2].toUpperCase();
648
+ const multipliers = { KB: 1024, MB: 1024 * 1024, GB: 1024 * 1024 * 1024 };
649
+ return { type: 'number', value: value * multipliers[unit] };
650
+ }
651
+
652
+ // Note: String literals are handled in Phase 1 (extractStringLiterals)
653
+ // and restored in Phase 3 (restoreStringLiterals).
654
+ // By this point, they are placeholders like __LITERAL_0__
655
+
656
+ // Floating point number (must be checked before member access)
657
+ if (/^-?\d+\.\d+$/.test(expr)) {
658
+ return { type: 'number', value: parseFloat(expr) };
659
+ }
660
+
661
+ // Number (integer)
662
+ if (/^-?\d+$/.test(expr)) {
663
+ return { type: 'number', value: parseInt(expr) };
664
+ }
665
+
666
+ // Hex number
667
+ if (/^0x[0-9a-fA-F]+$/.test(expr)) {
668
+ return { type: 'number', value: parseInt(expr, 16) };
669
+ }
670
+
671
+ // String identifier $a or just $ (for loops)
672
+ if (/^\$\w*$/.test(expr)) {
673
+ return { type: 'stringIdentifier', identifier: expr };
674
+ }
675
+
676
+ // String count #a or just # (for loops)
677
+ if (/^#\w*$/.test(expr)) {
678
+ return { type: 'stringCount', identifier: expr.replace('#', '$') };
679
+ }
680
+
681
+ // String offset @a or just @ (for loops)
682
+ if (/^@\w*$/.test(expr)) {
683
+ return { type: 'stringOffset', identifier: expr.replace('@', '$'), index: 0 };
684
+ }
685
+
686
+ /**
687
+ * Helper: Parse indexed string operations (@a[n] or !a[n])
688
+ * @param {string} expr - Expression to parse
689
+ * @param {string} prefix - Prefix character (@ or !)
690
+ * @param {string} type - Type name ('stringOffset' or 'stringLength')
691
+ * @returns {Object|null} AST node or null
692
+ */
693
+ const parseIndexedStringOp = (expr, prefix, type) => {
694
+ const pattern = new RegExp(`^\\${prefix}([\\w$]+)\\[([^\\]]+)\\]$`);
695
+ const match = expr.match(pattern);
696
+ if (!match) return null;
697
+
698
+ const identifierPart = match[1];
699
+ const indexExpr = match[2].trim();
700
+ const identifier = identifierPart.startsWith('$') ? identifierPart : `$${identifierPart}`;
701
+
702
+ // Parse index as number or expression
703
+ const index = /^\d+$/.test(indexExpr) ? parseInt(indexExpr) : parseExpression(indexExpr);
704
+
705
+ return { type, identifier, index };
706
+ };
707
+
708
+ // String offset @a[expr] - supports @a[n], @a[i], @a[i+1], etc.
709
+ const offsetIndexed = parseIndexedStringOp(expr, '@', 'stringOffset');
710
+ if (offsetIndexed) return offsetIndexed;
711
+
712
+ // String length !a or !$
713
+ if (/^![\w$]+$/.test(expr)) {
714
+ const id = expr.slice(1); // Remove '!'
715
+ return { type: 'stringLength', identifier: id.startsWith('$') ? id : `$${id}`, index: 0 };
716
+ }
717
+
718
+ // String length !a[expr] - supports !a[n], !a[i], !a[i+1], etc.
719
+ const lengthIndexed = parseIndexedStringOp(expr, '!', 'stringLength');
720
+ if (lengthIndexed) return lengthIndexed;
721
+
722
+
723
+ // filesize
724
+ if (expr === 'filesize') {
725
+ return { type: 'identifier', name: 'filesize' };
726
+ }
727
+
728
+ // entrypoint
729
+ if (expr === 'entrypoint') {
730
+ return { type: 'identifier', name: 'entrypoint' };
731
+ }
732
+
733
+ // Data access functions: uint8, uint16, uint32, int8, int16, int32, uint16be, uint32be, int16be, int32be
734
+ for (const dataAccessType of DATA_ACCESS_TYPES) {
735
+ const pattern = new RegExp(`^${dataAccessType.name}\\((.+)\\)$`);
736
+ const match = expr.match(pattern);
737
+ if (match) {
738
+ return {
739
+ type: 'dataAccess',
740
+ dataType: dataAccessType.name.replace('be', ''), // Remove 'be' suffix for storage
741
+ offset: parseExpression(match[1]),
742
+ endian: dataAccessType.endian
743
+ };
744
+ }
745
+ }
746
+
747
+
748
+ // Module function calls (e.g., math.entropy(...), string.to_int(...), time.now())
749
+ const moduleFuncMatch = expr.match(/^(\w+)\.(\w+)\((.*)\)$/);
750
+ if (moduleFuncMatch) {
751
+ const [, moduleName, functionName, argsStr] = moduleFuncMatch;
752
+
753
+ // Parse arguments (simple comma-separated for now)
754
+ let args = [];
755
+ if (argsStr.trim()) {
756
+ // Split by comma, but respect nested parentheses
757
+ let depth = 0;
758
+ let currentArg = '';
759
+ for (let i = 0; i < argsStr.length; i++) {
760
+ const ch = argsStr[i];
761
+ if (ch === '(') depth++;
762
+ if (ch === ')') depth--;
763
+ if (ch === ',' && depth === 0) {
764
+ args.push(parseExpression(currentArg.trim()));
765
+ currentArg = '';
766
+ } else {
767
+ currentArg += ch;
768
+ }
769
+ }
770
+ if (currentArg.trim()) {
771
+ args.push(parseExpression(currentArg.trim()));
772
+ }
773
+ }
774
+
775
+ return {
776
+ type: 'moduleFunction',
777
+ module: moduleName,
778
+ function: functionName,
779
+ args
780
+ };
781
+ }
782
+
783
+ // Module property with array indexing and optional chained access
784
+ // Examples: pe.sections[0], pe.sections[0].name, elf.sections[i].virtual_address
785
+ const moduleArrayChainMatch = expr.match(/^(\w+)\.(\w+)\[([^\]]+)\]\.?(\w*)$/);
786
+ if (moduleArrayChainMatch) {
787
+ const [, moduleName, propertyName, indexExpr, rest] = moduleArrayChainMatch;
788
+
789
+ // Base array access node
790
+ let node = {
791
+ type: 'arrayAccess',
792
+ object: {
793
+ type: 'memberAccess',
794
+ object: { type: 'identifier', name: moduleName },
795
+ property: propertyName
796
+ },
797
+ index: parseExpression(indexExpr.trim())
798
+ };
799
+
800
+ // If there's chained access (e.g., .name after [0]), parse it
801
+ if (rest) {
802
+ const chainedProperties = rest.split('.');
803
+ for (const prop of chainedProperties) {
804
+ if (prop) {
805
+ node = {
806
+ type: 'memberAccess',
807
+ object: node,
808
+ property: prop
809
+ };
810
+ }
811
+ }
812
+ }
813
+
814
+ return node;
815
+ }
816
+
817
+ // Module property access (e.g., pe.entry_point, elf.is_64bit)
818
+ const modulePropMatch = expr.match(/^(\w+)\.(\w+)$/);
819
+ if (modulePropMatch) {
820
+ const [, moduleName, propertyName] = modulePropMatch;
821
+ return {
822
+ type: 'memberAccess',
823
+ object: { type: 'identifier', name: moduleName },
824
+ property: propertyName
825
+ };
826
+ }
827
+
828
+ // Bitwise NOT operator (unary, highest precedence)
829
+ // Only consume a primary expression (number, identifier, or parenthesized)
830
+ if (expr.startsWith('~')) {
831
+ const rest = expr.substring(1).trim();
832
+ let operandEnd = 0;
833
+
834
+ // If starts with paren, find matching close paren
835
+ if (rest.startsWith('(')) {
836
+ let depth = 1;
837
+ operandEnd = 1;
838
+ while (operandEnd < rest.length && depth > 0) {
839
+ if (rest[operandEnd] === '(') depth++;
840
+ if (rest[operandEnd] === ')') depth--;
841
+ operandEnd++;
842
+ }
843
+ }
844
+ // If starts with another ~, recursively count tildes and then parse primary
845
+ else if (rest.startsWith('~')) {
846
+ // Count consecutive tildes (for potential future multi-NOT support)
847
+ let idx = 0;
848
+ while (idx < rest.length && rest[idx] === '~') {
849
+ idx++;
850
+ }
851
+ const afterTildes = rest.substring(idx).trim();
852
+
853
+ // Now find the primary expression after all the tildes
854
+ let primaryEnd = 0;
855
+ if (afterTildes.startsWith('(')) {
856
+ let depth = 1;
857
+ primaryEnd = 1;
858
+ while (primaryEnd < afterTildes.length && depth > 0) {
859
+ if (afterTildes[primaryEnd] === '(') depth++;
860
+ if (afterTildes[primaryEnd] === ')') depth--;
861
+ primaryEnd++;
862
+ }
863
+ } else {
864
+ // Check if it's a function call like uint8(4), int16be(10), etc.
865
+ const funcMatch = afterTildes.match(/^([a-zA-Z_$]\w*)\s*\(/);
866
+ if (funcMatch) {
867
+ // Find the matching closing parenthesis for the function call
868
+ let depth = 1;
869
+ primaryEnd = funcMatch[0].length; // Start after the opening '('
870
+ while (primaryEnd < afterTildes.length && depth > 0) {
871
+ if (afterTildes[primaryEnd] === '(') depth++;
872
+ if (afterTildes[primaryEnd] === ')') depth--;
873
+ primaryEnd++;
874
+ }
875
+ } else {
876
+ // Simple identifier or number
877
+ const match = afterTildes.match(/^(0x[0-9a-fA-F]+|\d+|[a-zA-Z_$]\w*)/);
878
+ if (match) {
879
+ primaryEnd = match[0].length;
880
+ }
881
+ }
882
+ }
883
+
884
+ // Total operand is all the tildes + the primary
885
+ operandEnd = idx + primaryEnd;
886
+ }
887
+ // Otherwise, consume hex number, decimal number, identifier, or function call
888
+ else {
889
+ // Check if it's a function call like uint8(4), int16be(10), etc.
890
+ const funcMatch = rest.match(/^([a-zA-Z_$]\w*)\s*\(/);
891
+ if (funcMatch) {
892
+ // Find the matching closing parenthesis for the function call
893
+ let depth = 1;
894
+ operandEnd = funcMatch[0].length; // Start after the opening '('
895
+ while (operandEnd < rest.length && depth > 0) {
896
+ if (rest[operandEnd] === '(') depth++;
897
+ if (rest[operandEnd] === ')') depth--;
898
+ operandEnd++;
899
+ }
900
+ } else {
901
+ // Match: hex (0x...), decimal number, or identifier
902
+ const match = rest.match(/^(0x[0-9a-fA-F]+|\d+|[a-zA-Z_$]\w*)/);
903
+ if (match) {
904
+ operandEnd = match[0].length;
905
+ } else {
906
+ // Fallback: parse entire rest as operand (shouldn't happen)
907
+ return {
908
+ type: 'bitwiseNot',
909
+ operand: parseExpression(rest)
910
+ };
911
+ }
912
+ }
913
+ }
914
+
915
+ const operand = parseExpression(rest.substring(0, operandEnd));
916
+ const remaining = rest.substring(operandEnd).trim();
917
+
918
+ // If there's more after the operand, we need to continue parsing the full expression
919
+ if (remaining) {
920
+ // The ~ is part of a larger expression, wrap the NOT and remaining in parens to reparse
921
+ const fullExpr = '(' + expr.substring(0, operandEnd + 1) + ')' + remaining;
922
+ return parseExpression(fullExpr);
923
+ }
924
+
925
+ return {
926
+ type: 'bitwiseNot',
927
+ operand
928
+ };
929
+ }
930
+
931
+ // Bitwise OR operator (lowest precedence among bitwise)
932
+ const bitwiseOrIndex = findOperatorOutsideParens(expr, '|');
933
+ if (bitwiseOrIndex !== -1) {
934
+ return {
935
+ type: 'bitwiseOr',
936
+ left: parseExpression(expr.substring(0, bitwiseOrIndex)),
937
+ right: parseExpression(expr.substring(bitwiseOrIndex + 1))
938
+ };
939
+ }
940
+
941
+ // Bitwise XOR operator
942
+ const bitwiseXorIndex = findOperatorOutsideParens(expr, '^');
943
+ if (bitwiseXorIndex !== -1) {
944
+ return {
945
+ type: 'bitwiseXor',
946
+ left: parseExpression(expr.substring(0, bitwiseXorIndex)),
947
+ right: parseExpression(expr.substring(bitwiseXorIndex + 1))
948
+ };
949
+ }
950
+
951
+ // Bitwise AND operator
952
+ const bitwiseAndIndex = findOperatorOutsideParens(expr, '&');
953
+ if (bitwiseAndIndex !== -1) {
954
+ return {
955
+ type: 'bitwiseAnd',
956
+ left: parseExpression(expr.substring(0, bitwiseAndIndex)),
957
+ right: parseExpression(expr.substring(bitwiseAndIndex + 1))
958
+ };
959
+ }
960
+
961
+ // Shift operators
962
+ const shiftLeftIndex = findOperatorOutsideParens(expr, '<<');
963
+ if (shiftLeftIndex !== -1) {
964
+ return {
965
+ type: 'shiftLeft',
966
+ left: parseExpression(expr.substring(0, shiftLeftIndex)),
967
+ right: parseExpression(expr.substring(shiftLeftIndex + 2))
968
+ };
969
+ }
970
+
971
+ const shiftRightIndex = findOperatorOutsideParens(expr, '>>');
972
+ if (shiftRightIndex !== -1) {
973
+ return {
974
+ type: 'shiftRight',
975
+ left: parseExpression(expr.substring(0, shiftRightIndex)),
976
+ right: parseExpression(expr.substring(shiftRightIndex + 2))
977
+ };
978
+ }
979
+
980
+ // Arithmetic operators
981
+ const arithmeticOps = [
982
+ { pattern: '+', type: 'add' },
983
+ { pattern: '-', type: 'subtract' },
984
+ { pattern: '*', type: 'multiply' },
985
+ { pattern: '\\', type: 'divide' },
986
+ { pattern: '%', type: 'modulo' }
987
+ ];
988
+
989
+ for (const op of arithmeticOps) {
990
+ const opIndex = findOperatorOutsideParens(expr, op.pattern);
991
+ if (opIndex !== -1) {
992
+ return {
993
+ type: op.type,
994
+ left: parseExpression(expr.substring(0, opIndex)),
995
+ right: parseExpression(expr.substring(opIndex + op.pattern.length))
996
+ };
997
+ }
998
+ }
999
+
1000
+ // Parenthesized expression - only remove if parentheses wrap the entire expression
1001
+ if (expr.startsWith('(') && expr.endsWith(')')) {
1002
+ let depth = 0;
1003
+ let wrapsEntireExpression = true;
1004
+ for (let i = 0; i < expr.length; i++) {
1005
+ if (expr[i] === '(') depth++;
1006
+ if (expr[i] === ')') depth--;
1007
+ // If depth hits 0 before the end, the outer parens don't wrap everything
1008
+ if (depth === 0 && i < expr.length - 1) {
1009
+ wrapsEntireExpression = false;
1010
+ break;
1011
+ }
1012
+ }
1013
+ if (wrapsEntireExpression) {
1014
+ return parseExpression(expr.slice(1, -1));
1015
+ }
1016
+ }
1017
+
1018
+ // Rule identifier (uppercase names like RuleA, MyRule, etc.)
1019
+ // YARA rule names must start with a letter and contain only alphanumeric characters and underscores
1020
+ if (/^[A-Z][a-zA-Z0-9_]*$/.test(expr)) {
1021
+ return { type: 'ruleIdentifier', name: expr };
1022
+ }
1023
+
1024
+ // Default: treat as unknown identifier
1025
+ return { type: 'identifier', name: expr };
1026
+ }
1027
+
1028
+ /**
1029
+ * Find operator outside of parentheses and brackets
1030
+ * For word operators (and, or, not), matches with flexible whitespace
1031
+ * @param {string} str - String to search
1032
+ * @param {string} op - Operator to find (e.g., ' and ', ' or ', '>', '==')
1033
+ * @returns {number} Index of operator, or -1 if not found
1034
+ */
1035
+ function findOperatorOutsideParens(str, op) {
1036
+ const opTrimmed = op.trim();
1037
+ const isWordOp = /^(and|or|not)$/.test(opTrimmed);
1038
+
1039
+ let parenDepth = 0;
1040
+ let bracketDepth = 0;
1041
+
1042
+ for (let i = 0; i < str.length; i++) {
1043
+ if (str[i] === '(') parenDepth++;
1044
+ if (str[i] === ')') parenDepth--;
1045
+ if (str[i] === '[') bracketDepth++;
1046
+ if (str[i] === ']') bracketDepth--;
1047
+
1048
+ // Only look for operators when outside all brackets and parens
1049
+ if (parenDepth === 0 && bracketDepth === 0) {
1050
+ if (isWordOp) {
1051
+ // For word operators, match the word with flexible surrounding whitespace
1052
+ // Must have whitespace before (or be at start)
1053
+ const hasWhitespaceBefore = i === 0 || /\s/.test(str[i - 1]);
1054
+ if (!hasWhitespaceBefore) continue;
1055
+
1056
+ // Skip leading whitespace
1057
+ let j = i;
1058
+ while (j < str.length && /\s/.test(str[j])) j++;
1059
+
1060
+ // Check if operator word matches
1061
+ if (str.substring(j, j + opTrimmed.length) === opTrimmed) {
1062
+ // Check what comes after the operator
1063
+ const afterOp = j + opTrimmed.length;
1064
+ const hasWhitespaceAfter = afterOp >= str.length || /\s/.test(str[afterOp]);
1065
+
1066
+ if (hasWhitespaceAfter) {
1067
+ return i; // Return the position where whitespace before operator starts
1068
+ }
1069
+ }
1070
+ } else {
1071
+ // For symbol operators, exact match
1072
+ if (str.substring(i, i + op.length) === op) {
1073
+ return i;
1074
+ }
1075
+ }
1076
+ }
1077
+ }
1078
+ return -1;
1079
+ }
1080
+
1081
+ export default {
1082
+ parseConditionToAST
1083
+ };