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,448 @@
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
+ import { compileYaraLike } from './yaraStringMatch.mjs';
18
+
19
+ /**
20
+ * List of supported YARA modules
21
+ * All modules are automatically available, but imports are validated
22
+ */
23
+ const SUPPORTED_MODULES = [
24
+ 'pe', // Windows PE file format module
25
+ 'elf', // Linux/Unix ELF file format module
26
+ 'math', // Mathematical and statistical functions
27
+ 'hash', // Cryptographic hash functions (MD5, SHA1, SHA256, CRC32)
28
+ 'time', // Time-related functions
29
+ 'string', // String manipulation functions
30
+ ];
31
+
32
+ /**
33
+ * Parse import statements from YARA rule text
34
+ * Validates that all imported modules are supported
35
+ * @param {string} text - The YARA rule text (before comment stripping)
36
+ * @returns {string[]} Array of imported module names
37
+ * @throws {Error} If an unsupported module is imported
38
+ */
39
+ function parseImports(text) {
40
+ const imports = [];
41
+ const importRegex = /^\s*import\s+"(\w+)"\s*$/gm;
42
+ let match;
43
+
44
+ while ((match = importRegex.exec(text)) !== null) {
45
+ const moduleName = match[1];
46
+
47
+ // Validate that the module is supported
48
+ if (!SUPPORTED_MODULES.includes(moduleName)) {
49
+ throw new Error(
50
+ `Unsupported module import: "${moduleName}". ` +
51
+ `Supported modules are: ${SUPPORTED_MODULES.join(', ')}. ` +
52
+ `Note: All supported modules are automatically available; ` +
53
+ `the import statement is only for validation.`
54
+ );
55
+ }
56
+
57
+ imports.push(moduleName);
58
+ }
59
+
60
+ return imports;
61
+ }
62
+
63
+ /**
64
+ * Remove comments from YARA rule text
65
+ * Handles both single-line (//) and multi-line C-style comments
66
+ * Preserves strings to avoid removing comment-like patterns inside them
67
+ * @param {string} text - The text to strip comments from
68
+ * @returns {string} Text with comments removed
69
+ */
70
+ function stripComments(text) {
71
+ let result = '';
72
+ let i = 0;
73
+ const len = text.length;
74
+
75
+ while (i < len) {
76
+ // Check for string literals (preserve them as-is)
77
+ if (text[i] === '"' || text[i] === "'") {
78
+ const quote = text[i];
79
+ result += text[i++];
80
+
81
+ // Copy the entire string literal, including escaped quotes
82
+ while (i < len) {
83
+ if (text[i] === '\\' && i + 1 < len) {
84
+ result += text[i++]; // backslash
85
+ result += text[i++]; // escaped character
86
+ } else if (text[i] === quote) {
87
+ result += text[i++]; // closing quote
88
+ break;
89
+ } else {
90
+ result += text[i++];
91
+ }
92
+ }
93
+ }
94
+ // Check for single-line comment
95
+ else if (text[i] === '/' && i + 1 < len && text[i + 1] === '/') {
96
+ // Skip until end of line
97
+ i += 2;
98
+ while (i < len && text[i] !== '\n') {
99
+ i++;
100
+ }
101
+ // Keep the newline for line integrity
102
+ if (i < len && text[i] === '\n') {
103
+ result += text[i++];
104
+ }
105
+ }
106
+ // Check for multi-line comment
107
+ else if (text[i] === '/' && i + 1 < len && text[i + 1] === '*') {
108
+ // Skip until closing */
109
+ i += 2;
110
+ while (i < len - 1) {
111
+ if (text[i] === '*' && text[i + 1] === '/') {
112
+ i += 2;
113
+ break;
114
+ }
115
+ // Preserve newlines to maintain line numbers for error reporting
116
+ if (text[i] === '\n') {
117
+ result += '\n';
118
+ }
119
+ i++;
120
+ }
121
+ }
122
+ // Regular character
123
+ else {
124
+ result += text[i++];
125
+ }
126
+ }
127
+
128
+ return result;
129
+ }
130
+
131
+ /**
132
+ * Count braces in a line while ignoring those inside string literals
133
+ * @param {string} line - The line to count braces in
134
+ * @returns {Object} Object with openCount and closeCount
135
+ */
136
+ function countBracesOutsideStrings(line) {
137
+ let openCount = 0;
138
+ let closeCount = 0;
139
+ let inString = false;
140
+ let stringChar = null;
141
+ let i = 0;
142
+
143
+ while (i < line.length) {
144
+ const char = line[i];
145
+
146
+ // Handle escape sequences in strings
147
+ if (inString && char === '\\' && i + 1 < line.length) {
148
+ i += 2; // Skip the backslash and the next character
149
+ continue;
150
+ }
151
+
152
+ // Check for string start/end
153
+ if ((char === '"' || char === "'") && !inString) {
154
+ inString = true;
155
+ stringChar = char;
156
+ } else if (char === stringChar && inString) {
157
+ inString = false;
158
+ stringChar = null;
159
+ }
160
+ // Count braces only when not inside a string
161
+ else if (!inString) {
162
+ if (char === '{') {
163
+ openCount++;
164
+ } else if (char === '}') {
165
+ closeCount++;
166
+ }
167
+ }
168
+
169
+ i++;
170
+ }
171
+
172
+ return { openCount, closeCount };
173
+ }
174
+
175
+ export function parseYaraRuleGroup(multiRuleText, existingRules = []) {
176
+ // Parse and validate import statements before processing rules
177
+ // This will throw an error if an unsupported module is imported
178
+ parseImports(multiRuleText);
179
+
180
+ // Strip import statements from the text after validation
181
+ // This prevents them from interfering with rule parsing
182
+ multiRuleText = multiRuleText.replace(/^\s*import\s+"[^"]+"\s*$/gm, '');
183
+
184
+ const rules = existingRules ?? [];
185
+ let currentRuleText = '';
186
+ let braceDepth = 0;
187
+ let inRule = false;
188
+
189
+ const lines = multiRuleText.split('\n');
190
+ for (let line of lines) {
191
+ // Check for the start of a rule (with or without private modifier)
192
+ if (!inRule && line.match(/^\s*(?:\s*(?:private|global)\s+)*rule\b/i)) {
193
+ inRule = true;
194
+ currentRuleText = line + '\n';
195
+ // Count braces while ignoring those inside strings
196
+ const braceCounts = countBracesOutsideStrings(line);
197
+ braceDepth = braceCounts.openCount - braceCounts.closeCount;
198
+ const atleastOneBrace = (braceCounts.openCount > 0);
199
+
200
+ // Handle single-line rules where braces balance immediately
201
+ if (braceDepth === 0 && atleastOneBrace) {
202
+ rules.push(parseYaraRule(currentRuleText));
203
+ inRule = false;
204
+ currentRuleText = '';
205
+ }
206
+ } else if (inRule) {
207
+ currentRuleText += line + '\n';
208
+ // Update brace depth while ignoring braces inside strings
209
+ const braceCounts = countBracesOutsideStrings(line);
210
+ braceDepth += braceCounts.openCount - braceCounts.closeCount;
211
+
212
+ // Check for end of rule
213
+ if (braceDepth === 0) {
214
+ // Complete rule found
215
+ rules.push(parseYaraRule(currentRuleText));
216
+ inRule = false;
217
+ currentRuleText = '';
218
+ // console.log("Pushed rule: ", rules[rules.length - 1].name);
219
+ }
220
+ } else {
221
+ // Not in a rule, ignore the line
222
+ // console.log('Ignoring line outside of rule:', line);
223
+ }
224
+ // Lines outside of rules are ignored
225
+ }
226
+
227
+ // Ensure each rule has a unique ID
228
+ rules.forEach((rule, index) => {
229
+ rule.id = index + 1;
230
+ });
231
+
232
+ return rules;
233
+ }
234
+
235
+ /**
236
+ * Parse a YARA rule and return an object representation
237
+ * @param {string} ruleText - The complete YARA rule text
238
+ * @returns {Object} Parsed rule object with metadata, strings, and condition
239
+ */
240
+ export function parseYaraRule(ruleText) {
241
+ // Strip comments before parsing
242
+ ruleText = stripComments(ruleText).trim();
243
+
244
+ // Check for private modifier
245
+ const isPrivate = /^.*\bprivate\b.*rule\s+/i.test(ruleText);
246
+
247
+ // Check for global modifier
248
+ const isGlobal = /^.*\bglobal\b.*rule\s+/i.test(ruleText);
249
+
250
+ // Extract rule name and tags (with or without private modifier)
251
+ const ruleMatch = ruleText.match(/^(?:.*)?rule\s+(\w+)(?:\s*:\s*([\w\s]+))?\s*\{/);
252
+ if (!ruleMatch) {
253
+ throw new Error('Invalid YARA rule: missing rule declaration');
254
+ }
255
+
256
+ const ruleName = ruleMatch[1];
257
+ const tags = ruleMatch[2] ? ruleMatch[2].trim().split(/\s+/) : [];
258
+
259
+ // Extract the rule body (everything between the outer braces)
260
+ const bodyMatch = ruleText.match(/\{([\s\S]*)\}/);
261
+ if (!bodyMatch) {
262
+ throw new Error('Invalid YARA rule: missing rule body');
263
+ }
264
+
265
+ const ruleBody = bodyMatch[1];
266
+
267
+ // Parse metadata section
268
+ const metadata = parseMetadata(ruleBody);
269
+
270
+ // Parse strings section
271
+ const strings = parseStrings(ruleBody);
272
+
273
+ // Parse condition section
274
+ const condition = parseCondition(ruleBody);
275
+
276
+ return {
277
+ name: ruleName,
278
+ tags,
279
+ metadata,
280
+ strings,
281
+ condition,
282
+ private: isPrivate,
283
+ global: isGlobal
284
+ };
285
+ }
286
+
287
+ /**
288
+ * Parse the metadata section of a YARA rule
289
+ * @param {string} ruleBody - The rule body text
290
+ * @returns {Object} Metadata key-value pairs
291
+ */
292
+ function parseMetadata(ruleBody) {
293
+ const metaMatch = ruleBody.match(/meta:\s*([\s\S]*?)(?=strings:|condition:|$)/);
294
+ if (!metaMatch) return {};
295
+
296
+ const metaText = metaMatch[1];
297
+ const metadata = {};
298
+
299
+ // Match key = value pairs (handles strings and numbers)
300
+ const metaLines = metaText.match(/(\w+)\s*=\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|(-)?\d+(\.\d+)?|true|false)/g);
301
+
302
+ if (metaLines) {
303
+ for (const line of metaLines) {
304
+ const [, key, value] = line.match(/(\w+)\s*=\s*(.+)/);
305
+ // Remove quotes from string values
306
+ if (value.startsWith('"') || value.startsWith("'")) {
307
+ metadata[key] = value.slice(1, -1);
308
+ } else if (value === 'true' || value === 'false') {
309
+ metadata[key] = value === 'true';
310
+ } else if (!isNaN(value)) {
311
+ metadata[key] = Number(value);
312
+ } else {
313
+ metadata[key] = value;
314
+ }
315
+ }
316
+ }
317
+
318
+ return metadata;
319
+ }
320
+
321
+ /**
322
+ * Parse the strings section of a YARA rule
323
+ * Supports both named strings ($var = "pattern") and anonymous strings ($ = "pattern")
324
+ * Anonymous strings are automatically assigned internal names like .anon_0, .anon_1, etc.
325
+ * @param {string} ruleBody - The rule body text
326
+ * @returns {Object} String definitions with their compiled matchers
327
+ */
328
+ function parseStrings(ruleBody) {
329
+ const stringsMatch = ruleBody.match(/strings:\s*([\s\S]*?)(?=condition:|$)/);
330
+ if (!stringsMatch) return {};
331
+
332
+ const stringsText = stringsMatch[1];
333
+ const strings = {};
334
+
335
+ // Match string definitions: $var = "text" modifiers or $ = "text" or $var = /regex/ or $var = { hex }
336
+ const stringLines = stringsText.match(/\$\w*\s*=\s*(.*)?/g);
337
+
338
+ if (stringLines) {
339
+ let anonCounter = 0; // Counter for anonymous strings
340
+
341
+ for (const line of stringLines) {
342
+ const match = line.match(/\$(\w*)\s*=\s*(.+)/);
343
+ if (!match) continue;
344
+
345
+ let [, varName, definition] = match;
346
+
347
+ // Handle anonymous strings ($ = "pattern")
348
+ if (!varName || varName === '') {
349
+ varName = `.anon_${anonCounter++}`;
350
+ } else {
351
+ // Add proper $ prefix to string identifier if not present -- NEED TO VERIFY THIS LOGIC
352
+ if (!varName.startsWith('$')) {
353
+ varName = '$' + varName;
354
+ }
355
+ }
356
+
357
+ try {
358
+ // Compile the string definition into a matcher function
359
+ const compiledRule = compileYaraLike(definition.trim());
360
+
361
+ strings[varName] = {
362
+ definition: definition.trim(),
363
+ ...compiledRule
364
+ };
365
+ } catch (error) {
366
+ console.warn(`Warning: Failed to compile string $${varName}: ${error.message}`);
367
+ console.warn(`Definition: ${definition.trim()}`);
368
+ console.warn(line);
369
+ console.warn(stringsMatch);
370
+ console.warn(ruleBody);
371
+ strings[varName] = {
372
+ definition: definition.trim(),
373
+ type: null,
374
+ matcher: null,
375
+ error: error.message,
376
+ };
377
+ }
378
+ }
379
+ }
380
+
381
+ return strings;
382
+ }
383
+
384
+ /**
385
+ * Parse the condition section of a YARA rule
386
+ * @param {string} ruleBody - The rule body text
387
+ * @returns {string} The condition expression as a string (to be evaluated later)
388
+ */
389
+ function parseCondition(ruleBody) {
390
+ const conditionMatch = ruleBody.match(/condition:\s*([\s\S]*?)$/);
391
+ if (!conditionMatch) {
392
+ throw new Error('Invalid YARA rule: missing condition');
393
+ }
394
+
395
+ // Return the condition as a trimmed string, removing any trailing braces
396
+ return conditionMatch[1].trim().replace(/\}\s*$/, '').trim();
397
+ }
398
+
399
+ /**
400
+ * Compile a complete YARA rule into an executable form
401
+ * @param {string} ruleText - The complete YARA rule text
402
+ * @returns {Object} Compiled rule object ready for evaluation
403
+ */
404
+ export function compileYaraRule(ruleText) {
405
+ const parsed = parseYaraRule(ruleText);
406
+
407
+ return {
408
+ name: parsed.name,
409
+ tags: parsed.tags,
410
+ metadata: parsed.metadata,
411
+ strings: parsed.strings,
412
+ condition: parsed.condition,
413
+
414
+ /**
415
+ * Match this rule against binary data
416
+ * @param {Uint8Array} data - The data to scan
417
+ * @returns {Object} Match results with string matches and overall result
418
+ */
419
+ match(data) {
420
+ // Execute all string matchers
421
+ const stringMatches = {};
422
+
423
+ for (const [varName, stringDef] of Object.entries(parsed.strings)) {
424
+ if (stringDef.matcher) {
425
+ try {
426
+ const matches = stringDef.matcher(data);
427
+ stringMatches[varName] = matches;
428
+ } catch (error) {
429
+ console.warn(`Warning: Error matching $${varName}: ${error.message}`);
430
+ stringMatches[varName] = [];
431
+ }
432
+ } else {
433
+ stringMatches[varName] = [];
434
+ }
435
+ }
436
+
437
+ // TODO: Evaluate the condition expression against stringMatches
438
+ // For now, return the matches without evaluating the condition
439
+ return {
440
+ ruleName: parsed.name,
441
+ matched: null, // Will be true/false once condition evaluation is implemented
442
+ stringMatches,
443
+ condition: parsed.condition,
444
+ };
445
+ },
446
+ };
447
+ }
448
+