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,449 @@
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
+ const MAX_STRING_MATCH_LENGTH = 2048;
18
+ export const MAX_MATCHES = 100;
19
+
20
+ /**
21
+ * Compile a YARA-like string definition into a matcher function.
22
+ * Supports: text, wide, ascii, xor, base64/base64wide, regex, hex, fullword, nocase
23
+ */
24
+ export function compileYaraLike(definition) {
25
+ definition = definition.trim();
26
+
27
+ const isRegex = definition.startsWith("/");
28
+ const isHex = definition.startsWith("{");
29
+ const quoteMatch = definition.match(/^"(.*)"(.*)$/);
30
+
31
+ if (isRegex) {
32
+ return makeRegexMatcher(definition);
33
+ } else if (isHex) {
34
+ const mods = definition.slice(definition.lastIndexOf("}") + 1);
35
+ return makeHexMatcher(definition.slice(1, definition.lastIndexOf("}")).trim(), mods);
36
+ } else if (quoteMatch) {
37
+ const [, text, modsRaw] = quoteMatch;
38
+ const mods = modsRaw.toLowerCase();
39
+
40
+ const opts = {
41
+ wide: /\bwide\b/.test(mods),
42
+ xor: parseXorRange(mods),
43
+ // allowKey0: /\bxor\b/.test(mods) && !/\(0\)/.test(mods),
44
+ allowKey0: true,
45
+ ascii: /\bascii\b/.test(mods) || !/\bwide\b/.test(mods),
46
+ base64: /\bbase64\b/.test(mods),
47
+ base64wide: /\bbase64wide\b/.test(mods),
48
+ fullword: /\bfullword\b/.test(mods),
49
+ nocase: /\bnocase\b/.test(mods),
50
+ private: /\bprivate\b/.test(mods),
51
+ };
52
+
53
+ return makeTextMatcher(text, opts);
54
+ }
55
+
56
+ throw new Error("Unsupported definition: " + definition);
57
+ }
58
+
59
+ function addOptionsToMatcher(matcher) {
60
+ return (data, offset = -1, maxLength = MAX_STRING_MATCH_LENGTH) => {
61
+ // If offset is specified, slice the data accordingly and limit length for matching
62
+ const singleRun = offset >= 0;
63
+ // Start 1 byte before to do fullword checks if needed
64
+ offset = Math.max(0, offset - 1);
65
+ data = singleRun ? data.slice(offset, offset + maxLength) : data;
66
+ return matcher(data, singleRun ? offset : -1);
67
+ };
68
+ }
69
+
70
+ // ╭─────────────────────────────╮
71
+ // │ REGEX MATCHER │
72
+ // ╰─────────────────────────────╯
73
+ function makeRegexMatcher(definition) {
74
+ const mods = definition.split("/").pop().toLowerCase() ?? "";
75
+ const regexFlags = mods.split(" ")[0];
76
+ const isPrivate = /\bprivate\b/.test(mods);
77
+ const pattern = definition.slice(1, definition.lastIndexOf("/"));
78
+ const nocase = regexFlags.includes("i");
79
+ const flags = "g" + (regexFlags.includes("s") ? "s" : "") + (nocase ? "i" : "");
80
+ const literalPrefix = getRegexPrefix(pattern, nocase);
81
+ const re = new RegExp(pattern, flags);
82
+
83
+ const matcher = (data, offset = -1) => {
84
+ const encoder = new TextEncoder();
85
+ const text = new TextDecoder().decode(data);
86
+ const results = [];
87
+ const singleRun = offset >= 0;
88
+ offset = offset < 0 ? 0 : offset;
89
+ let m;
90
+ while ((m = re.exec(text)) !== null && results.length < MAX_MATCHES) {
91
+ const byteIndex = encoder.encode(text.slice(0, m.index)).length;
92
+ results.push({ offset: byteIndex + offset, length: encoder.encode(m[0]).length });
93
+ re.lastIndex = m.index + 1; // Move forward to find overlapping matches
94
+ if (singleRun) break;
95
+ }
96
+ return results;
97
+ };
98
+
99
+ return { matcher: addOptionsToMatcher(matcher), literalPrefix, type: "regex", nocase, private: isPrivate };
100
+ }
101
+
102
+ function getRegexPrefix(pattern, nocase = false) {
103
+ // Remove leading ^ if present
104
+ pattern = pattern.replace(/^\^/, "");
105
+ pattern = nocase ? pattern.toLowerCase() : pattern;
106
+
107
+ // Match literal prefix before first meta pattern
108
+ // const match = pattern.match(/^((?:\\.|[^\\[\]()|*+?{}.])+)/);
109
+ // Match literal prefix: either escaped chars (\. \? etc) or non-special chars
110
+ const match = pattern.match(/^((?:\\[^bBdDwWsSAZzGnrtfv0u]|[^\\[\]()|*+?{}.])+)/);
111
+ if (!match || match.length < 2) return "";
112
+
113
+ // Unescape hex sequences like \x41 → A
114
+ let prefix = match[1].replace(/\\x([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
115
+
116
+ // Unescape escaped characters like \. → .
117
+ return prefix.replace(/\\(.)/g, "$1");
118
+ }
119
+
120
+ // ╭─────────────────────────────╮
121
+ // │ TEXT MATCHER GENERATOR │
122
+ // ╰─────────────────────────────╯
123
+ function makeTextMatcher(text, opts) {
124
+ const encoder = new TextEncoder();
125
+ const patterns = [];
126
+
127
+ // Default to ASCII if wide is not specified
128
+ if (!opts.wide) opts.ascii = true;
129
+
130
+ // Skip invalid combinations
131
+ if (opts.base64 || opts.base64wide) {
132
+ opts.nocase = false; // Disable nocase for conflicting base64 types
133
+ opts.fullword = false; // Disable fullword for conflicting base64 types
134
+ opts.xor = null; // Disable xor for conflicting base64 types
135
+ }
136
+
137
+ // Unescape YARA string escape sequences
138
+ const unescapedText = text
139
+ .replace(/\\n/g, "\n")
140
+ .replace(/\\r/g, "\r")
141
+ .replace(/\\t/g, "\t")
142
+ .replace(/\\x([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
143
+ .replace(/\\\\/g, "\\")
144
+ .replace(/\\"/g, '"')
145
+ .replace(/\\'/g, "'");
146
+
147
+ // Handle nocase by forcing to lower case
148
+ const textVariants = opts.nocase ? [unescapedText.toLowerCase()] : [unescapedText];
149
+
150
+ for (const variant of textVariants) {
151
+ if (opts.base64 || opts.base64wide) {
152
+ // When using base64/base64wide, generate patterns from the original text
153
+ // in both ASCII and wide forms as needed
154
+ if (opts.base64) {
155
+ if (opts.ascii) {
156
+ patterns.push(...yaraBase64Variants(variant, false).map((bytes) => ({ bytes, isWide: false })));
157
+ }
158
+ if (opts.wide) {
159
+ patterns.push(...yaraBase64Variants(toWideBytes(variant), false).map((bytes) => ({ bytes, isWide: true })));
160
+ }
161
+ }
162
+ if (opts.base64wide) {
163
+ if (opts.ascii) {
164
+ // base64wide returns Uint8Arrays when wide=true, so don't encode again
165
+ patterns.push(...yaraBase64Variants(variant, true).map((bytes) => ({ bytes, isWide: true })));
166
+ }
167
+ if (opts.wide) {
168
+ // base64wide returns Uint8Arrays when wide=true, so don't encode again
169
+ patterns.push(...yaraBase64Variants(toWideBytes(variant), true).map((bytes) => ({ bytes, isWide: true })));
170
+ }
171
+ }
172
+ } else {
173
+ // Regular text patterns
174
+ if (opts.ascii) {
175
+ patterns.push({ bytes: encoder.encode(variant), isWide: false });
176
+ }
177
+ if (opts.wide) {
178
+ patterns.push({ bytes: toWideBytes(variant), isWide: true });
179
+ }
180
+ }
181
+ }
182
+
183
+ const matcher = (data, offset = -1) => {
184
+ let matches = [];
185
+ const singleRun = offset >= 0;
186
+ offset = offset < 0 ? 0 : offset;
187
+
188
+ for (const { bytes, isWide } of patterns) {
189
+ if (opts.xor) {
190
+ const xorMatches = findXorMatches(data, bytes, opts.xor, opts.allowKey0, singleRun);
191
+ // console.log(xorMatches);
192
+ matches.push(...xorMatches.map((m) => ({ ...m, length: bytes.length, isWide })));
193
+ } else {
194
+ const plainMatches = findPlainMatches(data, bytes, opts.nocase, singleRun);
195
+ matches.push(...plainMatches.map((m) => ({ ...m, length: bytes.length, isWide })));
196
+ }
197
+ }
198
+
199
+ if (opts.fullword) {
200
+ matches = matches.filter((m) => isFullWordMatch(data, m.offset, m.length));
201
+ }
202
+
203
+ // Adjust the offsets for the original data if offset was specified
204
+ matches.forEach((m) => {
205
+ m.offset += offset;
206
+ });
207
+
208
+ return matches;
209
+ };
210
+
211
+ return { matcher: addOptionsToMatcher(matcher), patterns, type: "text", xor: opts.xor, nocase: opts.nocase, private: opts.private };
212
+ }
213
+
214
+ // ╭─────────────────────────────╮
215
+ // │ XOR-AWARE MATCHING │
216
+ // ╰─────────────────────────────╯
217
+ function findXorMatches(data, pattern, keyRange, allowKey0 = true, singleRun = false) {
218
+ const [minKey, maxKey] = keyRange;
219
+ const matches = [];
220
+ const pLen = pattern.length;
221
+ const dLen = data.length;
222
+ if (pLen === 0 || pLen > dLen) return matches;
223
+
224
+ for (let candidateKey = minKey; candidateKey <= maxKey; candidateKey++) {
225
+ for (let i = 0; i <= dLen - pLen; i++) {
226
+ let ok = true;
227
+ for (let j = 0; j < pLen; j++) {
228
+ if ((data[i + j] ^ candidateKey) !== pattern[j]) {
229
+ ok = false;
230
+ break;
231
+ }
232
+ }
233
+ if (ok) {
234
+ matches.push({ offset: i, key: candidateKey });
235
+ if (singleRun) break;
236
+ }
237
+ }
238
+ }
239
+ return matches;
240
+ }
241
+
242
+ // ╭─────────────────────────────╮
243
+ // │ PLAIN MATCHING │
244
+ // ╰─────────────────────────────╯
245
+ function findPlainMatches(data, pattern, nocase = false, singleRun = false) {
246
+ const matches = [];
247
+ const pLen = pattern.length;
248
+ const dLen = data.length;
249
+ outer: for (let i = 0; i <= dLen - pLen; i++) {
250
+ for (let j = 0; j < pLen; j++) {
251
+ if ((nocase ? lowercaseByte(data[i + j]) : data[i + j]) !== pattern[j]) continue outer;
252
+ }
253
+ matches.push({ offset: i });
254
+ if (singleRun) break;
255
+ }
256
+ return matches;
257
+ }
258
+
259
+ function lowercaseByte(b) {
260
+ if (b >= 0x41 && b <= 0x5a) {
261
+ return b + 0x20;
262
+ }
263
+ return b;
264
+ }
265
+
266
+ // ╭─────────────────────────────╮
267
+ // │ FULLWORD CHECK │
268
+ // ╰─────────────────────────────╯
269
+ function isFullWordMatch(data, offset, length) {
270
+ const isWordChar = (b) =>
271
+ (b >= 0x30 && b <= 0x39) || // 0-9
272
+ (b >= 0x41 && b <= 0x5a) || // A-Z
273
+ (b >= 0x61 && b <= 0x7a); // a-z
274
+ const before = offset === 0 || !isWordChar(data[offset - 1]);
275
+ const after = offset + length >= data.length || !isWordChar(data[offset + length]);
276
+ return before && after;
277
+ }
278
+
279
+ // ╭─────────────────────────────╮
280
+ // │ WIDE BYTES │
281
+ // ╰─────────────────────────────╯
282
+ function toWideBytes(text) {
283
+ const buf = new Uint8Array(text.length * 2);
284
+ for (let i = 0; i < text.length; i++) {
285
+ const code = text.charCodeAt(i);
286
+ buf[i * 2] = code & 0xff;
287
+ buf[i * 2 + 1] = code >> 8;
288
+ }
289
+ return buf;
290
+ }
291
+
292
+ // ╭─────────────────────────────╮
293
+ // │ PARSE XOR RANGE │
294
+ // ╰─────────────────────────────╯
295
+ function parseXorRange(mods) {
296
+ const m = mods.match(/xor(?:\((0x[0-9a-f]+|\d+)(?:-(0x[0-9a-f]+|\d+))?\))?/i);
297
+ if (!m) return null;
298
+ const [, start, end] = m;
299
+ if (!start && !end) return [0, 255]; // YARA starts from 0 if no range specified
300
+
301
+ let startVal = start ? parseInt(start, start.startsWith("0x") ? 16 : 10) : null;
302
+ let endVal = end ? parseInt(end, end.startsWith("0x") ? 16 : 10) : null;
303
+
304
+ if (startVal !== null && endVal === null) return [startVal, startVal];
305
+ if (endVal !== null && startVal === null) return [endVal, endVal];
306
+
307
+ return [startVal, endVal];
308
+ }
309
+
310
+ // ╭─────────────────────────────╮
311
+ // │ HEX MATCHER GENERATOR │
312
+ // ╰─────────────────────────────╯
313
+
314
+ // Global constant for max jump range when not specified
315
+ const MAX_HEX_JUMP = 10000;
316
+
317
+ function makeHexMatcher(hexBody, mods) {
318
+ // Parse hex body into regex pattern
319
+ let pattern = "";
320
+ hexBody = hexBody?.toUpperCase().replace(/[^0-9A-F?~|()[\-\]]/g, "") || ""; // Trim invalid chars
321
+ const parts = hexBody.match(/\[\d*(-\d*)?\]|\(.*\)|~?[0-9A-F?]{2}/g) || [];
322
+ const isPrivate = /\bprivate\b/.test(mods);
323
+
324
+ if (parts.length === 0) {
325
+ // Empty pattern matches nothing
326
+ const matcher = (data) => [];
327
+ return { matcher, type: "hex", private: isPrivate };
328
+ }
329
+
330
+ for (const token of parts) {
331
+ if (token.startsWith("[")) {
332
+ // Jump: [N] or [N-M] or [N-] or [-M] or [-]
333
+ const dashIndex = token.indexOf("-");
334
+ if (dashIndex === -1) {
335
+ // [N] - exact count
336
+ const count = parseInt(token.slice(1, -1));
337
+ pattern += `(?:[0-9A-F]{2}){${count}}`;
338
+ } else {
339
+ // [N-M] or [N-] or [-M] or [-] - range
340
+ const match = /\[(\d*)-(\d*)\]/.exec(token);
341
+ const min = parseInt(match[1]) || 0;
342
+ const max = parseInt(match[2]) || MAX_HEX_JUMP;
343
+ pattern += `(?:[0-9A-F]{2}){${min},${max}}`;
344
+ }
345
+ } else if (token.startsWith("(")) {
346
+ // Group: (XX | ?X | ?? | ~XX | ~?X)
347
+ pattern += convertNegationToRegex(token).replace(/\?/g, "[0-9A-F]");
348
+ } else if (token.startsWith("~")) {
349
+ // Negated byte: ~XX
350
+ pattern += convertNegationToRegex(token);
351
+ } else {
352
+ // Wildcard: ?? or ?X or X?
353
+ // Literals: XX
354
+ pattern += token.replace(/\?/g, "[0-9A-F]");
355
+ }
356
+ }
357
+
358
+ const regex = new RegExp(pattern, "g");
359
+
360
+ const matcher = (data) => {
361
+ // Convert UInt8Array to hex string; if string passed, assumes it is cached hex string
362
+ const hexString = (typeof data === "string") ? data : Array.from(data)
363
+ .map((b) => b.toString(16).toUpperCase().padStart(2, "0"))
364
+ .join("");
365
+
366
+ const matches = [];
367
+ let m;
368
+ while ((m = regex.exec(hexString)) !== null && matches.length < MAX_MATCHES) {
369
+ // Convert hex string offset to byte offset
370
+ if (m.index % 2 !== 0) {
371
+ // Skip to next aligned position to check
372
+ regex.lastIndex = m.index + 1;
373
+ continue;
374
+ }
375
+ // Ignoring as better to overmatch than undermatch
376
+ // if (m[0].length % 2 !== 0) {
377
+ // // Skip non-aligned length matches
378
+ // continue;
379
+ // }
380
+ matches.push({ offset: m.index / 2 });
381
+ regex.lastIndex = m.index + 2; // Move forward to find overlapping matches
382
+ }
383
+ return matches;
384
+ };
385
+
386
+ return { matcher, type: "hex", private: isPrivate };
387
+ }
388
+
389
+ // function addAllOverlappingMatches(regex, text, textIndex = 0, matches = [], indexStep = 1) {
390
+ // if (!regex || !text || !matches) return;
391
+ // const newRegex = new RegExp(regex.source, regex.flags.replace("g", ""));
392
+ // text = text.slice(indexStep); // Start from next indexStep to check overlaps
393
+ // let additionalOffset = indexStep;
394
+ // let m;
395
+ // while (text?.length > 0 && (m = newRegex.exec(text)) !== null) {
396
+ // if (indexStep > 1 && (m.index % indexStep !== 0 || m[0].length % indexStep !== 0 )) {
397
+ // // Jump to next aligned position
398
+ // const nextAlignedIndex = indexStep * Math.ceil(m.index / indexStep);
399
+ // text = text.slice(nextAlignedIndex);
400
+ // additionalOffset += nextAlignedIndex;
401
+ // continue;
402
+ // } // Skip non-aligned matches
403
+ // matches.push({ offset: (textIndex + m.index + additionalOffset) / indexStep, length: m[0].length / indexStep });
404
+ // text = text.slice(m.index + indexStep);
405
+ // additionalOffset += m.index + indexStep;
406
+ // }
407
+ // return matches;
408
+ // }
409
+
410
+ function convertNegationToRegex(token) {
411
+ if (!token || !token.includes("~")) return token;
412
+ const matches = token.split("~");
413
+ let pattern = matches[0];
414
+ for (let i = 1; i < matches.length; i++) {
415
+ const nibbles = matches[i].slice(0, 2).split("");
416
+ nibbles[0] = nibbles[0] === "?" ? "[0-9A-F]" : `${nibbles[0]}`;
417
+ nibbles[1] = nibbles[1] === "?" ? "[0-9A-F]" : `${nibbles[1]}`;
418
+ pattern += `(?!${nibbles[0]}${nibbles[1]})[0-9A-F]{2}${matches[i].slice(2)}`;
419
+ }
420
+ return pattern;
421
+ }
422
+
423
+ // ╭─────────────────────────────╮
424
+ // │ YARA BASE64 / BASE64WIDE │
425
+ // ╰─────────────────────────────╯
426
+ function yaraBase64Variants(input, wide = false) {
427
+ // Accept either string or Uint8Array (for when 'wide' modifier is applied first)
428
+ const encoder = new TextEncoder();
429
+ let bytes;
430
+ if (typeof input === "string") {
431
+ bytes = encoder.encode(input);
432
+ } else {
433
+ bytes = input; // Already Uint8Array from toWideBytes()
434
+ }
435
+
436
+ const variants = [];
437
+ const dropCharsMap = [0, 2, 3];
438
+ for (let offset = 0; offset < 3; offset++) {
439
+ const padded = new Uint8Array(offset + bytes.length);
440
+ padded.set(bytes, offset);
441
+ let b64 = btoa(String.fromCharCode(...padded));
442
+ const dropChars = dropCharsMap[offset];
443
+ const trailing = (/=*$/.exec(b64 ?? [0]))[0].length;
444
+ const b64string = b64.slice(dropChars, b64.length - (trailing ? trailing + 1 : 0));
445
+ variants.push(wide ? toWideBytes(b64string) : encoder.encode(b64string));
446
+ }
447
+
448
+ return variants.filter((v) => v.length >= 1);
449
+ }