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.
- package/LICENSE +231 -0
- package/NOTICE +15 -0
- package/README.md +327 -0
- package/dist/sekant-intercept.browser.mjs +15 -0
- package/dist/sekant-intercept.node.mjs +15 -0
- package/package.json +69 -0
- package/src/ahocorasickEngine.mjs +229 -0
- package/src/elfModule.mjs +306 -0
- package/src/hashModule.mjs +284 -0
- package/src/index.js +9 -0
- package/src/interceptCustomModules.mjs +230 -0
- package/src/interceptScanner.mjs +865 -0
- package/src/mathModule.mjs +506 -0
- package/src/peModule.mjs +295 -0
- package/src/performanceInstrumentation.mjs +172 -0
- package/src/sharedUtils.mjs +127 -0
- package/src/stringModule.mjs +43 -0
- package/src/timeModule.mjs +19 -0
- package/src/yaraConditionParser.mjs +1083 -0
- package/src/yaraConditionsMatch.mjs +1373 -0
- package/src/yaraRuleCompiler.mjs +448 -0
- package/src/yaraStringMatch.mjs +449 -0
|
@@ -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
|
+
};
|