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,865 @@
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 Scanner - End-to-End Implementation
19
+ *
20
+ * Complete YARA rule scanner that integrates:
21
+ * - Rule compilation (yaraRuleCompiler.mjs)
22
+ * - String pattern matching (yaraStringMatch.mjs)
23
+ * - Aho-Corasick fast scanning (ahocorasickEngine.mjs)
24
+ * - Condition parsing and evaluation (yaraConditionsMatch.mjs)
25
+ * - Module support (math, hash, pe, etc.)
26
+ */
27
+
28
+ import { AhoCorasick } from "./ahocorasickEngine.mjs";
29
+ import { parseYaraRuleGroup } from "./yaraRuleCompiler.mjs";
30
+ import { createScanFacts, evaluateCondition } from "./yaraConditionsMatch.mjs";
31
+ import { parseConditionToAST } from "./yaraConditionParser.mjs";
32
+ import { parsePEYara, createPEModule } from "./peModule.mjs";
33
+ import { parseELFYaraFull, createELFModule } from "./elfModule.mjs";
34
+ import { createMathModule } from "./mathModule.mjs";
35
+ import { createHashModule } from "./hashModule.mjs";
36
+ import { time as timeModule } from "./timeModule.mjs";
37
+ import { string as stringModule } from "./stringModule.mjs";
38
+ import { createPerformanceTracker } from "./performanceInstrumentation.mjs";
39
+ import { MAX_MATCHES } from "./yaraStringMatch.mjs";
40
+ import { isValidModuleName } from "./interceptCustomModules.mjs";
41
+
42
+ /**
43
+ * InterceptScanner - A JavaScript implementation of YARA rule scanning engine
44
+ *
45
+ * This class provides functionality to compile YARA rules and scan binary data
46
+ * for pattern matches. It uses Aho-Corasick algorithm for fast multi-pattern
47
+ * matching and supports YARA modules (PE, ELF, math, hash, string, time).
48
+ *
49
+ * @class InterceptScanner
50
+ *
51
+ * @example
52
+ * // Basic usage
53
+ * const scanner = new InterceptScanner();
54
+ * scanner.compile(`
55
+ * rule ExampleRule {
56
+ * strings:
57
+ * $a = "malware"
58
+ * $b = /suspicious[0-9]+/
59
+ * condition:
60
+ * any of them
61
+ * }
62
+ * `);
63
+ * const results = await scanner.scan(binaryData);
64
+ *
65
+ * @example
66
+ * // Results structure
67
+ * [
68
+ * {
69
+ * rule: "ExampleRule", // Name of the matched rule
70
+ * namespace: "default", // Namespace (default if not specified)
71
+ * tags: ["tag1", "tag2"], // Rule tags
72
+ * metadata: { // Rule metadata
73
+ * author: "...",
74
+ * description: "..."
75
+ * },
76
+ * strings: { // Matched strings with details
77
+ * "$a": {
78
+ * identifier: "$a", // String identifier
79
+ * matched: true, // Whether string was found
80
+ * count: 2, // Number of occurrences
81
+ * matches: [ // Array of match details
82
+ * { offset: 100, length: 7 },
83
+ * { offset: 250, length: 7 }
84
+ * ],
85
+ * offsets: [100, 250], // Array of match offsets
86
+ * length: 7 // Length of matched string
87
+ * },
88
+ * "$b": {
89
+ * identifier: "$b",
90
+ * matched: false,
91
+ * count: 0,
92
+ * matches: [],
93
+ * offsets: [],
94
+ * length: null
95
+ * }
96
+ * }
97
+ * }
98
+ * ]
99
+ *
100
+ * @property {Array} compiledRules - Array of compiled YARA rules
101
+ * @property {AhoCorasick|null} ac - Aho-Corasick automaton for fast pattern matching
102
+ * @property {boolean} autoParsePE - Automatically parse PE files (default: true)
103
+ * @property {boolean} autoParseELF - Automatically parse ELF files (default: true)
104
+ * @property {number} maxFileSize - Maximum file size for filesize operator (default: 1MB)
105
+ * @property {Object} modules - Available YARA modules (string, time, pe, elf, math, hash)
106
+ */
107
+ export class InterceptScanner {
108
+ constructor(options = {}) {
109
+ this.compiledRules = [];
110
+ this.ac = null;
111
+ this.autoParsePE = true; // Automatically parse PE files
112
+ this.autoParseELF = true; // Automatically parse ELF files
113
+ this.maxFileSize = 1024 * 1024; // 1MB default limit for filesize operator
114
+ this.setModules(options.modules);
115
+ this.customModules = options.modules || {};
116
+ const timingOptions = {
117
+ enabled: options.timing?.enabled ?? options.timing?.enableTiming ?? options.enableTiming ?? false,
118
+ autoPrint: options.timing?.autoPrint ?? options.autoPrint ?? false,
119
+ logger: options.timing?.logger ?? options.timingLogger ?? options.logger,
120
+ };
121
+ this.timingTracker = createPerformanceTracker(timingOptions);
122
+ }
123
+
124
+ /**
125
+ * Add YARA rules from text (Same as addRules, but named compile for clarity)
126
+ * @param {string} rulesText - YARA rules in text format
127
+ */
128
+ compile(rulesText) {
129
+ const tracker = this.timingTracker;
130
+ const timingEnabled = tracker?.isEnabled();
131
+ const start = timingEnabled ? tracker.now() : 0;
132
+ this.compiledRules = parseYaraRuleGroup(rulesText, this.compiledRules || []);
133
+ this.ac = null; // Reset AC automaton to force rebuild
134
+
135
+ if (timingEnabled) {
136
+ tracker.recordCompile(tracker.now() - start);
137
+ } else if (tracker) {
138
+ tracker.clearCompile();
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Add YARA rules from text
144
+ * @param {string} rulesText - YARA rules in text format
145
+ */
146
+ addRules(rulesText) {
147
+ this.compile(rulesText);
148
+ }
149
+
150
+ /**
151
+ * Remove all YARA rules
152
+ */
153
+ clearRules() {
154
+ this.compiledRules = [];
155
+ this.ac = null;
156
+ }
157
+
158
+ /**
159
+ * Set modules for condition evaluation
160
+ * @param {Object} modules - Module instances (pe, elf, math, hash, etc.)
161
+ */
162
+ setModules(modules) {
163
+ this.modules = { string: stringModule, time: timeModule, ...(modules || {}) };
164
+ }
165
+
166
+ setTiming(timingOptions = {}) {
167
+ this.timingTracker.updateOptions(timingOptions);
168
+ }
169
+
170
+ enableTiming(enable = true, logger) {
171
+ this.timingTracker.enable(enable, logger);
172
+ }
173
+
174
+ getTiming() {
175
+ return this.timingTracker.getSnapshot();
176
+ }
177
+
178
+ /**
179
+ * Deduplicate candidate matches
180
+ * @param {Array} candidates - Array of candidate matches from AC
181
+ * @returns {Array} Deduplicated candidates
182
+ */
183
+ deduplicateCandidates(candidates) {
184
+ const unique = new Map();
185
+ for (const candidate of candidates) {
186
+ const key = `${candidate.id}:${candidate.varName}:${candidate.offset}`;
187
+ if (!unique.has(key)) {
188
+ unique.set(key, candidate);
189
+ }
190
+ }
191
+ return Array.from(unique.values());
192
+ }
193
+
194
+ /**
195
+ * Verify candidates with full string matchers
196
+ * @param {Array} candidates - Candidate matches
197
+ * @param {Uint8Array} data - File data
198
+ * @param {Array} rules - Compiled rules
199
+ * @returns {Array} Verified matches
200
+ */
201
+ verifyCandidates(candidates, data, rules) {
202
+ const verified = [];
203
+
204
+ for (const candidate of candidates) {
205
+ const rule = rules.find((r) => r.id === candidate.id);
206
+ if (!rule) continue;
207
+
208
+ const strDef = rule.strings[candidate.varName];
209
+ if (!strDef || !strDef.matcher) continue;
210
+
211
+ // For text and regex with literal prefix, verify at the candidate offset
212
+ if (strDef.type === "text" || (strDef.type === "regex" && strDef.literalPrefix)) {
213
+ const matches = strDef.matcher(data, candidate.offset);
214
+ if (matches && matches.length > 0) {
215
+ verified.push({
216
+ ...candidate,
217
+ matches: matches,
218
+ });
219
+ }
220
+ } else {
221
+ // For other types, just accept the candidate
222
+ verified.push(candidate);
223
+ }
224
+ }
225
+
226
+ return verified;
227
+ }
228
+
229
+ /**
230
+ * Build string match results per rule
231
+ * @param {Array} verifiedCandidates - Verified matches
232
+ * @param {Array} rules - Compiled rules
233
+ * @param {Uint8Array} data - File data
234
+ * @returns {Object} String matches organized by rule
235
+ */
236
+ buildStringMatches(verifiedCandidates, rules, data) {
237
+ const ruleMatches = {};
238
+
239
+ // Initialize match structure for each rule
240
+ for (const rule of rules) {
241
+ ruleMatches[rule.id] = {};
242
+ for (const varName of Object.keys(rule.strings)) {
243
+ // Use variable name as-is since compiler now ensures proper prefixes ($ or .anon)
244
+ // If coming from old compiler without $, add it for named strings
245
+ const matchKey = varName.startsWith('$') || varName.startsWith('.') ? varName : `$${varName}`;
246
+ const anonymousKey = matchKey.startsWith('.') ? `\$${matchKey}` : matchKey;
247
+
248
+ // For compatibility with rest of system that expects $.anon for anonymous
249
+ const finalKey = varName.startsWith('.') ? `\$${varName}` : matchKey;
250
+
251
+ ruleMatches[rule.id][finalKey] = {
252
+ identifier: finalKey,
253
+ matched: false,
254
+ count: 0,
255
+ matches: [],
256
+ offsets: [],
257
+ // length: null,
258
+ private: rule.strings[varName].private,
259
+ };
260
+ }
261
+ }
262
+
263
+ // Populate with verified matches
264
+ for (const candidate of verifiedCandidates) {
265
+ // Handle key lookup (handle $.anon for anonymous)
266
+ const finalKey = candidate.varName.startsWith('.') ? `\$${candidate.varName}` : candidate.varName;
267
+ // Fallback for strict $ requirement
268
+ const lookupKey = ruleMatches[candidate.id][finalKey] ? finalKey : (finalKey.startsWith('$') ? finalKey : `\$${finalKey}`);
269
+
270
+ const matchInfo = ruleMatches[candidate.id][lookupKey];
271
+
272
+ if (candidate.matches && candidate.matches.length > 0) {
273
+ // Has detailed match information
274
+ const prunedList = candidate.matches.length > MAX_MATCHES ? candidate.matches.slice(0, MAX_MATCHES) : candidate.matches;
275
+ matchInfo.matched = true;
276
+ matchInfo.count += prunedList.length;
277
+ matchInfo.matches.push(...prunedList);
278
+ matchInfo.offsets.push(...candidate.matches.map((m) => m.offset));
279
+ // if (prunedList[0].length) {
280
+ // matchInfo.length = prunedList[0].length;
281
+ // }
282
+ } else {
283
+ // Simple match
284
+ matchInfo.matched = true;
285
+ matchInfo.count += 1;
286
+ matchInfo.matches.push({
287
+ offset: candidate.offset,
288
+ length: candidate.length || 0,
289
+ });
290
+ matchInfo.offsets.push(candidate.offset);
291
+ // if (candidate.length) {
292
+ // matchInfo.length = candidate.length;
293
+ // }
294
+ }
295
+ }
296
+
297
+ // Run string matches that could not be optimized via AC
298
+ let dataAsHexString = null;
299
+ for (const rule of rules) {
300
+ for (const [varName, strDef] of Object.entries(rule.strings)) {
301
+ if (strDef.type === "text" || (strDef.type === "regex" && strDef.literalPrefix?.length > 0)) continue; // Should be part of AC verified candidates
302
+
303
+ // Handle key lookup (handle $.anon for anonymous)
304
+ const finalKey = varName.startsWith('.') ? `\$${varName}` : varName;
305
+ const lookupKey = ruleMatches[rule.id][finalKey] ? finalKey : (finalKey.startsWith('$') ? finalKey : `\$${finalKey}`);
306
+
307
+ const matchInfo = ruleMatches[rule.id][lookupKey];
308
+
309
+ // Build cache hex string if needed
310
+ if (strDef.type === "hex" && dataAsHexString === null) {
311
+ dataAsHexString = Array.from(data)
312
+ .map((b) => b.toString(16).toUpperCase().padStart(2, "0"))
313
+ .join("");
314
+ }
315
+
316
+ // Run full matcher over entire data (-1 means find ALL matches, not verify at offset 0)
317
+ let matches = strDef.type === "hex" && dataAsHexString !== null ? strDef.matcher(dataAsHexString, -1) : strDef.matcher(data, -1);
318
+ if (matches && matches.length > 0) {
319
+ const prunedList = matches.length > MAX_MATCHES ? matches.slice(0, MAX_MATCHES) : matches;
320
+ matchInfo.matched = true;
321
+ matchInfo.count += prunedList.length;
322
+ if (matchInfo.matches.length === 0) {
323
+ matchInfo.matches = prunedList; // Replace if empty
324
+ } else {
325
+ matchInfo.matches.push(...prunedList);
326
+ }
327
+ matchInfo.offsets.push(...matches.map((m) => m.offset));
328
+ // if (prunedList[0].length) {
329
+ // matchInfo.length = prunedList[0].length;
330
+ // }
331
+ }
332
+ }
333
+ }
334
+
335
+ return ruleMatches;
336
+ }
337
+
338
+ /**
339
+ * Evaluate rule conditions
340
+ * @param {Object} ruleMatches - String matches per rule
341
+ * @param {Array} rules - Compiled rules
342
+ * @param {Uint8Array} data - File data
343
+ * @param {Object} metadata - Scan metadata to pass to custom modules
344
+ * @param {Object} timingContext - Timing context for performance tracking
345
+ * @returns {Array} Rules that matched
346
+ */
347
+ async evaluateConditions(ruleMatches, rules, data, metadata = {}, timingContext = null) {
348
+ const matchedRules = [];
349
+ // Track which rules have matched (for dependent rules)
350
+ const ruleMatchStatus = {};
351
+ const tracker = timingContext?.tracker;
352
+ const modulesTiming = timingContext?.modules;
353
+
354
+ // Try to parse PE if auto-parse is enabled and PE module not already provided
355
+ let peModule = this.modules.pe;
356
+ if (this.autoParsePE && !peModule) {
357
+ const start = tracker ? tracker.now() : 0;
358
+ try {
359
+ // Check for MZ signature
360
+ if (data.length > 2 && data[0] === 0x4d && data[1] === 0x5a) {
361
+ const parsedPE = await parsePEYara(data);
362
+ if (parsedPE && !parsedPE.error) {
363
+ peModule = createPEModule(parsedPE);
364
+ }
365
+ } else {
366
+ // Data does not have MZ signature, skipping PE parse.
367
+ }
368
+ } catch {
369
+ // PE parsing failed, continue without PE module
370
+ } finally {
371
+ if (tracker && modulesTiming) {
372
+ tracker.accumulateModule(modulesTiming, "pe", tracker.now() - start);
373
+ }
374
+ }
375
+ }
376
+
377
+ // Try to parse ELF if auto-parse is enabled and ELF module not already provided
378
+ let elfModule = this.modules.elf;
379
+ if (this.autoParseELF && !elfModule) {
380
+ const start = tracker ? tracker.now() : 0;
381
+ try {
382
+ // Check for ELF magic signature (0x7F 'E' 'L' 'F')
383
+ if (data.length > 4 && data[0] === 0x7f && data[1] === 0x45 && data[2] === 0x4c && data[3] === 0x46) {
384
+ const parsedELF = await parseELFYaraFull(data);
385
+ if (parsedELF && !parsedELF.error) {
386
+ elfModule = createELFModule(parsedELF);
387
+ }
388
+ }
389
+ } catch {
390
+ // ELF parsing failed, continue without ELF module
391
+ } finally {
392
+ if (tracker && modulesTiming) {
393
+ tracker.accumulateModule(modulesTiming, "elf", tracker.now() - start);
394
+ }
395
+ }
396
+ }
397
+
398
+ // Merge PE, ELF, math & hash modules with existing modules
399
+ const modulesWithBinary = {
400
+ ...this.modules,
401
+ ...(peModule && { pe: peModule }),
402
+ ...(elfModule && { elf: elfModule }),
403
+ };
404
+
405
+ const mathStart = tracker ? tracker.now() : 0;
406
+ modulesWithBinary.math = createMathModule(data);
407
+ if (tracker && modulesTiming) {
408
+ tracker.accumulateModule(modulesTiming, "math", tracker.now() - mathStart);
409
+ }
410
+
411
+ const hashStart = tracker ? tracker.now() : 0;
412
+ modulesWithBinary.hash = createHashModule(data);
413
+ if (tracker && modulesTiming) {
414
+ tracker.accumulateModule(modulesTiming, "hash", tracker.now() - hashStart);
415
+ }
416
+
417
+ // Initialize custom modules from this.modules
418
+ // Check each module to see if it's a custom module (has getName() method and non-reserved name)
419
+ for (const [key, moduleValue] of Object.entries(this.modules)) {
420
+ try {
421
+ // Check if this is a custom module by seeing if it has the custom module interface
422
+ if (typeof moduleValue?.getName === 'function') {
423
+ const moduleName = moduleValue.getName();
424
+
425
+ // Validate it's not a reserved name
426
+ if (!isValidModuleName(moduleName)) {
427
+ console.warn(`InterceptScanner: Custom module "${moduleName}" has reserved name - skipping initialization`);
428
+ continue;
429
+ }
430
+
431
+ // Load module if not already loaded (lazy loading)
432
+ if (typeof moduleValue.isLoaded === 'function' && !moduleValue.isLoaded()) {
433
+ const loadStart = tracker ? tracker.now() : 0;
434
+ if (typeof moduleValue.load === 'function') {
435
+ await moduleValue.load();
436
+ }
437
+ if (tracker && modulesTiming) {
438
+ tracker.accumulateModule(modulesTiming, `${moduleName}_load`, tracker.now() - loadStart);
439
+ }
440
+ }
441
+
442
+ // Initialize and create module for this scan
443
+ const moduleStart = tracker ? tracker.now() : 0;
444
+
445
+ // Merge passed metadata with computed values
446
+ const scanMetadata = {
447
+ ...metadata,
448
+ filesize: data.length,
449
+ hasPE: !!peModule,
450
+ hasELF: !!elfModule,
451
+ timestamp: Date.now(),
452
+ };
453
+
454
+ let moduleObject = moduleValue;
455
+ if (typeof moduleValue.initialize === 'function' && typeof moduleValue.createModule === 'function') {
456
+ const interimResults = await moduleValue.initialize(data, scanMetadata);
457
+ moduleObject = moduleValue.createModule(interimResults);
458
+ }
459
+
460
+ if (tracker && modulesTiming) {
461
+ tracker.accumulateModule(modulesTiming, moduleName, tracker.now() - moduleStart);
462
+ }
463
+
464
+ // Add to modules available for condition evaluation
465
+ modulesWithBinary[moduleName] = moduleObject;
466
+ }
467
+ } catch (error) {
468
+ console.warn(`InterceptScanner: Error initializing custom module "${key}": ${error.message}`);
469
+ }
470
+ }
471
+
472
+ // Extract global rules first
473
+ const globalRules = rules.filter((rule) => rule.global);
474
+ for (const rule of globalRules) {
475
+ const result = await this.evaluateRuleCondition(
476
+ ruleMatches,
477
+ rule,
478
+ data,
479
+ ruleMatchStatus,
480
+ { peModule, elfModule, modulesWithBinary },
481
+ timingContext,
482
+ );
483
+ if (result) {
484
+ if (!rule.private) {
485
+ matchedRules.push(result);
486
+ }
487
+ } else {
488
+ // Stop early for global rules if one fails
489
+ return matchedRules;
490
+ }
491
+ }
492
+
493
+ const nonGlobalRules = rules.filter((rule) => !rule.global);
494
+ for (const rule of nonGlobalRules) {
495
+ const result = await this.evaluateRuleCondition(
496
+ ruleMatches,
497
+ rule,
498
+ data,
499
+ ruleMatchStatus,
500
+ { peModule, elfModule, modulesWithBinary },
501
+ timingContext,
502
+ );
503
+ if (result && !rule.private) {
504
+ matchedRules.push(result);
505
+ }
506
+ }
507
+
508
+ // Includes non-function module data for downstream evaluations
509
+ for (const [moduleName, moduleInstance] of Object.entries(modulesWithBinary)) {
510
+ // Skip built-in modules that are function-based
511
+ if (["string", "time", "hash", "math"].includes(moduleName)) {
512
+ continue;
513
+ }
514
+ // Copy non-functions only & remove any data arrays (like raw_data or data)
515
+ matchedRules[moduleName] = {};
516
+ for (const [key, value] of Object.entries(moduleInstance)) {
517
+ // Skip functions and constants and _* keys (assume all uppercase keys are constants)
518
+ if (!key.startsWith("_") && typeof value !== "function" && !/[A-Z0-9]+/.test(key)) {
519
+ matchedRules[moduleName][key] = typeof value === "object" ? removeKeyDeep({ ... value }, ["raw_data", "data"]) : value;
520
+ }
521
+ }
522
+ }
523
+
524
+ return matchedRules;
525
+ }
526
+
527
+ async evaluateRuleCondition(ruleMatches, rule, data, ruleMatchStatus, modules = {}, timingContext = null) {
528
+ const { peModule, elfModule, modulesWithBinary } = modules;
529
+ const tracker = timingContext?.tracker;
530
+ const conditionTiming = timingContext?.condition;
531
+ try {
532
+ // Create scan facts for this rule
533
+ // Use entry point from PE or ELF module, with preference for PE
534
+ // For non-binary files, YARA defaults to 0
535
+ const entrypoint = peModule ? peModule.entry_point : elfModule ? elfModule.entry_point : -1e6;
536
+
537
+ // Calculate filesize with cap awareness
538
+ const actualFileSize = data.length;
539
+ const isFileSizeCapped = actualFileSize >= this.maxFileSize;
540
+
541
+ const scanFacts = createScanFacts(data, ruleMatches[rule.id], modulesWithBinary, {
542
+ entrypoint,
543
+ filesize: actualFileSize,
544
+ isFileSizeCapped: isFileSizeCapped,
545
+ maxFileSize: this.maxFileSize,
546
+ matchedRules: ruleMatchStatus, // Pass matched rules for dependent rule evaluation
547
+ metadata: {
548
+ ruleName: rule.name,
549
+ },
550
+ });
551
+
552
+ // Parse condition to AST
553
+ let conditionAST;
554
+ const parseStart = tracker ? tracker.now() : 0;
555
+ try {
556
+ conditionAST = parseConditionToAST(rule.condition, scanFacts.strings);
557
+ } catch {
558
+ // If parsing fails, try simple evaluation
559
+ conditionAST = this.parseSimpleCondition(rule.condition, scanFacts.strings);
560
+ } finally {
561
+ if (tracker && conditionTiming) {
562
+ tracker.accumulateCondition(conditionTiming, rule.name, tracker.now() - parseStart);
563
+ }
564
+ }
565
+
566
+ // Evaluate condition
567
+ const matched = await evaluateCondition(conditionAST, scanFacts);
568
+
569
+ // Track this rule's match status for dependent rules
570
+ ruleMatchStatus[rule.name] = matched;
571
+
572
+ if (matched) {
573
+ // Filter out anonymous strings (those starting with ".anon_") from output
574
+ const filteredStrings = {};
575
+ for (const [key, value] of Object.entries(ruleMatches[rule.id])) {
576
+ // Only include strings that don't start with "$." (anonymous strings)
577
+ if (!key.startsWith("$.") && !value.private) {
578
+ filteredStrings[key] = value;
579
+ }
580
+ }
581
+
582
+ // Return matched rule details
583
+ return {
584
+ rule: rule.name,
585
+ namespace: rule.namespace || "default",
586
+ tags: rule.tags || [],
587
+ metadata: rule.metadata || {},
588
+ strings: filteredStrings,
589
+ };
590
+ }
591
+ } catch (error) {
592
+ console.log("Failing rule: ", rule);
593
+ console.trace(error);
594
+ }
595
+ return false;
596
+ }
597
+
598
+ /**
599
+ * Parse simple conditions (fallback parser)
600
+ * Handles common patterns: $a, any of them, all of them, N of them
601
+ * @param {string} condition - Condition string
602
+ * @param {Object} strings - String match information
603
+ * @returns {Object} AST node
604
+ */
605
+ parseSimpleCondition(condition, strings) {
606
+ condition = condition.trim();
607
+
608
+ // Handle "any of them"
609
+ if (condition === "any of them") {
610
+ return { type: "any", items: "them" };
611
+ }
612
+
613
+ // Handle "all of them"
614
+ if (condition === "all of them") {
615
+ return { type: "all", items: "them" };
616
+ }
617
+
618
+ // Handle "none of them"
619
+ if (condition === "none of them") {
620
+ return { type: "none", items: "them" };
621
+ }
622
+
623
+ // Handle "N of them"
624
+ const nOfThemMatch = condition.match(/^(\d+)\s+of\s+them$/);
625
+ if (nOfThemMatch) {
626
+ return {
627
+ type: "quantified",
628
+ quantifier: { type: "number", value: parseInt(nOfThemMatch[1]) },
629
+ items: "them",
630
+ };
631
+ }
632
+
633
+ // Handle single string identifier "$a"
634
+ const singleStringMatch = condition.match(/^\$(\w+)$/);
635
+ if (singleStringMatch) {
636
+ return {
637
+ type: "stringIdentifier",
638
+ identifier: `$${singleStringMatch[1]}`,
639
+ };
640
+ }
641
+
642
+ // Handle "($a and $b)"
643
+ const andMatch = condition.match(/^\(?(\$\w+)\s+and\s+(\$\w+)\)?$/);
644
+ if (andMatch) {
645
+ return {
646
+ type: "and",
647
+ left: { type: "stringIdentifier", identifier: andMatch[1] },
648
+ right: { type: "stringIdentifier", identifier: andMatch[2] },
649
+ };
650
+ }
651
+
652
+ // Handle "($a or $b)"
653
+ const orMatch = condition.match(/^\(?(\$\w+)\s+or\s+(\$\w+)\)?$/);
654
+ if (orMatch) {
655
+ return {
656
+ type: "or",
657
+ left: { type: "stringIdentifier", identifier: orMatch[1] },
658
+ right: { type: "stringIdentifier", identifier: orMatch[2] },
659
+ };
660
+ }
661
+
662
+ // Default: if any string in condition is matched, return true
663
+ // This is a very simple fallback
664
+ const stringIds = Object.keys(strings);
665
+ for (const id of stringIds) {
666
+ if (condition.includes(id) && strings[id].matched) {
667
+ return { type: "boolean", value: true };
668
+ }
669
+ }
670
+
671
+ return { type: "boolean", value: false };
672
+ }
673
+
674
+ filterStringResults(ruleMatches) {
675
+ if (!Array.isArray(ruleMatches)) return;
676
+ ruleMatches.forEach((ruleMatch) => {
677
+ if (Object.keys(ruleMatch.strings).length > 0) {
678
+ for (const [strId, strInfo] of Object.entries(ruleMatch.strings)) {
679
+ if (!strInfo.matched) {
680
+ delete ruleMatch.strings[strId];
681
+ }
682
+ }
683
+ }
684
+ });
685
+ }
686
+
687
+ /**
688
+ * Scan binary data with all loaded rules
689
+ * @param {Uint8Array|string} data - Data to scan
690
+ * @returns {Promise<Array>} Matched rules with details
691
+ */
692
+ async scan(data, metadata = {}) {
693
+ const tracker = this.timingTracker;
694
+ const timingEnabled = tracker?.isEnabled();
695
+ const totalStart = timingEnabled ? tracker.now() : 0;
696
+ const scanTiming = timingEnabled ? tracker.createScanTiming() : null;
697
+
698
+ // Convert string to Uint8Array if needed
699
+ if (typeof data === "string") {
700
+ const encoder = new TextEncoder();
701
+ data = encoder.encode(data);
702
+ }
703
+
704
+ if (!this.compiledRules || this.compiledRules.length === 0) {
705
+ if (timingEnabled && scanTiming) {
706
+ scanTiming.total = tracker.now() - totalStart;
707
+ scanTiming.matchCount = 0;
708
+ tracker.finalizeScan(scanTiming);
709
+ } else if (tracker) {
710
+ tracker.clearScan();
711
+ }
712
+ return [];
713
+ }
714
+
715
+ // Phase 1: Fast candidate detection with Aho-Corasick
716
+ if (!this.ac) {
717
+ if (timingEnabled && scanTiming) {
718
+ const start = tracker.now();
719
+ this.ac = new AhoCorasick(this.compiledRules);
720
+ tracker.accumulateStep(scanTiming.steps, "buildAutomaton", tracker.now() - start);
721
+ } else {
722
+ this.ac = new AhoCorasick(this.compiledRules);
723
+ }
724
+ }
725
+
726
+ const acSearchStart = timingEnabled ? tracker.now() : 0;
727
+ const candidates = this.ac.search(data);
728
+ if (timingEnabled && scanTiming) {
729
+ tracker.accumulateStep(scanTiming.steps, "acSearch", tracker.now() - acSearchStart);
730
+ }
731
+
732
+ const dedupeStart = timingEnabled ? tracker.now() : 0;
733
+ const unique = this.deduplicateCandidates(candidates);
734
+ if (timingEnabled && scanTiming) {
735
+ tracker.accumulateStep(scanTiming.steps, "deduplicate", tracker.now() - dedupeStart);
736
+ }
737
+
738
+ const verifyStart = timingEnabled ? tracker.now() : 0;
739
+ const verified = this.verifyCandidates(unique, data, this.compiledRules);
740
+ if (timingEnabled && scanTiming) {
741
+ tracker.accumulateStep(scanTiming.steps, "verifyCandidates", tracker.now() - verifyStart);
742
+ }
743
+
744
+ const buildStart = timingEnabled ? tracker.now() : 0;
745
+ const ruleMatches = this.buildStringMatches(verified, this.compiledRules, data);
746
+ if (timingEnabled && scanTiming) {
747
+ tracker.accumulateStep(scanTiming.steps, "buildMatches", tracker.now() - buildStart);
748
+ }
749
+
750
+ let evaluationContext = null;
751
+ let evaluateStart = 0;
752
+ if (timingEnabled && scanTiming) {
753
+ evaluationContext = {
754
+ tracker,
755
+ modules: scanTiming.modules,
756
+ condition: scanTiming.conditionParsing,
757
+ };
758
+ evaluateStart = tracker.now();
759
+ }
760
+
761
+ const results = await this.evaluateConditions(ruleMatches, this.compiledRules, data, metadata, evaluationContext);
762
+
763
+ if (timingEnabled && scanTiming) {
764
+ tracker.accumulateStep(scanTiming.steps, "evaluateConditions", tracker.now() - evaluateStart);
765
+ }
766
+
767
+ const filterStart = timingEnabled ? tracker.now() : 0;
768
+ this.filterStringResults(results);
769
+ if (timingEnabled && scanTiming) {
770
+ tracker.accumulateStep(scanTiming.steps, "filterStrings", tracker.now() - filterStart);
771
+ scanTiming.total = tracker.now() - totalStart;
772
+ scanTiming.matchCount = results.length;
773
+ tracker.finalizeScan(scanTiming);
774
+ } else if (tracker) {
775
+ tracker.clearScan();
776
+ }
777
+
778
+ return results;
779
+ }
780
+
781
+ /**
782
+ * Set maximum file size limit for filesize operator
783
+ * @param {number} size - Maximum file size in bytes
784
+ */
785
+ setMaxFileSize(size) {
786
+ this.maxFileSize = size;
787
+ }
788
+
789
+ /**
790
+ * Get statistics about loaded rules
791
+ * @returns {Object} Rule statistics
792
+ */
793
+ getStats() {
794
+ return {
795
+ totalRules: this.compiledRules.length,
796
+ ruleNames: this.compiledRules.map((r) => r.name),
797
+ rulesWithStrings: this.compiledRules.filter((r) => Object.keys(r.strings).length > 0).length,
798
+ totalStrings: this.compiledRules.reduce((sum, r) => sum + Object.keys(r.strings).length, 0),
799
+ totalPatterns: this.compiledRules.reduce((sum, r) => {
800
+ return (
801
+ sum +
802
+ Object.values(r.strings).reduce((s, str) => {
803
+ return s + (str.patterns ? str.patterns.length : 0);
804
+ }, 0)
805
+ );
806
+ }, 0),
807
+ acBuilt: this.ac !== null,
808
+ maxFileSize: this.maxFileSize,
809
+ };
810
+ }
811
+
812
+ /**
813
+ * Clear all loaded rules
814
+ */
815
+ clear() {
816
+ this.compiledRules = [];
817
+ this.ac = null;
818
+ // Reset existing modules
819
+ this.setModules(this.customModules);
820
+ }
821
+ }
822
+
823
+
824
+ function removeKeyDeep(value, keysToRemove) {
825
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean" || value === null) {
826
+ return value;
827
+ }
828
+
829
+ if (Array.isArray(value)) {
830
+ return value.map(v => removeKeyDeep(v, keysToRemove));
831
+ }
832
+
833
+ if (value !== null && typeof value === "object") {
834
+ return Object.fromEntries(
835
+ Object.entries(value)
836
+ .filter(([k]) => !keysToRemove.includes(k))
837
+ .map(([k, v]) => [k, removeKeyDeep(v, keysToRemove)])
838
+ );
839
+ }
840
+
841
+ return value;
842
+ }
843
+
844
+ // Remove large arrays from object recursively (Prevent huge outputs to backend)
845
+ function removeArrays(value, size = 20) {
846
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean" || value === null) {
847
+ return value;
848
+ }
849
+
850
+ if (Array.isArray(value)) {
851
+ return value.length > size ? [] : value.map(v => removeArrays(v, size));
852
+ }
853
+
854
+ if (value !== null && typeof value === "object") {
855
+ for (const [k, v] of Object.entries(value)) {
856
+ if (v !== null && (typeof v === "object" || Array.isArray(v))) {
857
+ value[k] = removeArrays(v, size);
858
+ }
859
+ }
860
+ }
861
+
862
+ return value;
863
+ }
864
+
865
+ export { InterceptScanner as YaraScanner };