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,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 };
|