diff --git a/package-lock.json b/package-lock.json index e42ae6c4a7..6d01029edc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@astronautlabs/amf": "^0.0.6", "@blu3r4y/lzma": "^2.3.3", "@noble/hashes": "2.2.0", + "@virustotal/yara-x": "^1.15.0", "@wavesenterprise/crypto-gost-js": "^2.1.0-RC1", "@xmldom/xmldom": "^0.8.13", "argon2-browser": "^1.18.0", @@ -4851,6 +4852,12 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@virustotal/yara-x": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@virustotal/yara-x/-/yara-x-1.15.0.tgz", + "integrity": "sha512-tR+Ue5ci9bURbD7/qjXY0VLVXDUP+NK/uvvgjX6UvSXN+3msfMtpAMaiCdsltFEciU+tXaZeGbHu3N07bvfjsw==", + "license": "BSD-3-Clause" + }, "node_modules/@wavesenterprise/crypto-gost-js": { "version": "2.1.0-RC1", "resolved": "https://registry.npmjs.org/@wavesenterprise/crypto-gost-js/-/crypto-gost-js-2.1.0-RC1.tgz", diff --git a/package.json b/package.json index ed678f3350..aea97063d3 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,7 @@ "@astronautlabs/amf": "^0.0.6", "@blu3r4y/lzma": "^2.3.3", "@noble/hashes": "2.2.0", + "@virustotal/yara-x": "^1.15.0", "@wavesenterprise/crypto-gost-js": "^2.1.0-RC1", "@xmldom/xmldom": "^0.8.13", "argon2-browser": "^1.18.0", diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json index 2879a13a76..ea8bae57ef 100644 --- a/src/core/config/Categories.json +++ b/src/core/config/Categories.json @@ -517,6 +517,7 @@ "Scan for Embedded Files", "Extract Files", "YARA Rules", + "YARA-X Scan", "Remove EXIF", "Extract EXIF", "Extract RGBA", @@ -603,4 +604,4 @@ "Comment" ] } -] \ No newline at end of file +] diff --git a/src/core/operations/YARAXScan.mjs b/src/core/operations/YARAXScan.mjs new file mode 100644 index 0000000000..6ade021c25 --- /dev/null +++ b/src/core/operations/YARAXScan.mjs @@ -0,0 +1,324 @@ +/** + * @author Zain Nadeem [zainnadeemzainnadeem80@gmail.com] + * @copyright Crown Copyright 2026 + * @license Apache-2.0 + */ + +import Operation from "../Operation.mjs"; +import OperationError from "../errors/OperationError.mjs"; +import Utils, { isWorkerEnvironment } from "../Utils.mjs"; +import initYaraX, { Compiler } from "@virustotal/yara-x"; + +let yaraXInitPromise = null; + +/** + * YARA-X Scan operation + */ +class YARAXScan extends Operation { + + /** + * YARAXScan constructor + */ + constructor() { + super(); + + this.name = "YARA-X Scan"; + this.module = "Yara"; + this.description = "Scans the input with YARA-X rules using the official YARA-X WebAssembly package."; + this.infoURL = "https://virustotal.github.io/yara-x/"; + this.inputType = "ArrayBuffer"; + this.outputType = "string"; + this.args = [ + { + name: "Rules", + type: "text", + value: "", + rows: 5, + allowEmpty: false + }, + { + name: "Show matching patterns", + type: "boolean", + value: true + }, + { + name: "Show metadata", + type: "boolean", + value: true + }, + { + name: "Show warnings", + type: "boolean", + value: true + }, + { + name: "Timeout (ms)", + type: "number", + value: 0, + min: 0, + integer: true + }, + ]; + } + + /** + * @param {ArrayBuffer} input + * @param {Object[]} args + * @returns {string} + */ + async run(input, args) { + const [rulesText, showPatterns, showMetadata, showWarnings, timeout] = args; + + if (!rulesText.trim()) { + throw new OperationError("Rules cannot be empty."); + } + + await initialiseYaraX(); + + const inputBytes = new Uint8Array(input); + let compiler = null, + rules = null, + scanner = null, + result = null, + compilerWarnings = []; + + try { + compiler = new Compiler(); + + try { + compiler.addSource(rulesText); + compilerWarnings = compiler.warnings; + rules = compiler.build(); + } catch (err) { + throw new OperationError(`Error compiling YARA-X rules. (${formatError(err)})`); + } + + try { + if (timeout > 0) { + scanner = rules.scanner(); + scanner.setTimeoutMs(timeout); + result = scanner.scan(inputBytes); + } else { + result = rules.scan(inputBytes); + } + } catch (err) { + throw new OperationError(`Error scanning input with YARA-X. (${formatError(err)})`); + } + + if (!result || result.valid !== true || !Array.isArray(result.matches)) { + throw new OperationError(`Invalid YARA-X scan result. (${formatResultErrors(result)})`); + } + + return formatScanResult( + result, + inputBytes, + collectWarnings(compilerWarnings, rules.warnings, result.warnings), + showPatterns, + showMetadata, + showWarnings + ); + } finally { + if (scanner) scanner.free(); + if (rules) rules.free(); + if (compiler) compiler.free(); + } + } + +} + + +/** + * Initialises the YARA-X WASM module once per worker. + * + * @returns {Promise} + */ +async function initialiseYaraX() { + if (!yaraXInitPromise) { + if (isWorkerEnvironment()) { + self.sendStatusMessage("Instantiating YARA-X..."); + } + + yaraXInitPromise = initYaraX().catch(err => { + yaraXInitPromise = null; + throw new OperationError(`Error initialising YARA-X. (${formatError(err)})`); + }); + } + + await yaraXInitPromise; +} + + +/** + * @param {Object} result + * @param {Uint8Array} inputBytes + * @param {string[]} warnings + * @param {boolean} showPatterns + * @param {boolean} showMetadata + * @param {boolean} showWarnings + * @returns {string} + */ +function formatScanResult(result, inputBytes, warnings, showPatterns, showMetadata, showWarnings) { + const output = []; + + if (showWarnings && warnings.length) { + output.push("Warnings:"); + warnings.forEach(warning => output.push(` ${warning}`)); + output.push(""); + } + + if (result.matches.length === 0) { + output.push("No matches"); + return output.join("\n"); + } + + result.matches.forEach((rule, i) => { + if (i > 0) output.push(""); + + output.push(`Rule: ${rule.identifier}`); + output.push(`Namespace: ${rule.namespace || "default"}`); + + if (Array.isArray(rule.tags) && rule.tags.length) { + output.push(`Tags: ${rule.tags.join(", ")}`); + } + + if (showMetadata && Array.isArray(rule.metadata) && rule.metadata.length) { + output.push("Metadata:"); + rule.metadata.forEach(metadata => { + output.push(` ${metadata.identifier}: ${formatValue(metadata.value)}`); + }); + } + + if (showPatterns && Array.isArray(rule.patterns)) { + const lines = formatPatternMatches(rule.patterns, inputBytes); + if (lines.length) { + output.push("Patterns:"); + output.push(...lines); + } + } + }); + + return output.join("\n"); +} + + +/** + * @param {Object[]} patterns + * @param {Uint8Array} inputBytes + * @returns {string[]} + */ +function formatPatternMatches(patterns, inputBytes) { + const lines = []; + + patterns.forEach(pattern => { + if (!Array.isArray(pattern.matches)) return; + + pattern.matches.forEach(match => { + if (!Number.isInteger(match.offset) || + !Number.isInteger(match.length) || + match.offset < 0 || + match.length < 0 || + match.offset + match.length > inputBytes.length) { + throw new OperationError("Invalid YARA-X pattern match."); + } + + lines.push( + ` ${pattern.identifier} at 0x${match.offset.toString(16)}: ` + + formatMatchedBytes(inputBytes, match.offset, match.length) + ); + }); + }); + + return lines; +} + + +/** + * @param {Uint8Array} inputBytes + * @param {number} offset + * @param {number} length + * @returns {string} + */ +function formatMatchedBytes(inputBytes, offset, length) { + const bytes = inputBytes.slice(offset, offset + length); + let output = ""; + + bytes.forEach(byte => { + switch (byte) { + case 0x09: + output += "\\t"; + break; + case 0x0a: + output += "\\n"; + break; + case 0x0d: + output += "\\r"; + break; + case 0x22: + output += "\\\""; + break; + case 0x5c: + output += "\\\\"; + break; + default: + output += byte >= 0x20 && byte <= 0x7e ? + String.fromCharCode(byte) : + `\\x${Utils.hex(byte)}`; + } + }); + + return `"${output}"`; +} + + +/** + * @param {...string[]} warningLists + * @returns {string[]} + */ +function collectWarnings(...warningLists) { + const warnings = new Set(); + + warningLists.forEach(warningList => { + if (Array.isArray(warningList)) { + warningList.forEach(warning => warnings.add(warning)); + } + }); + + return [...warnings]; +} + + +/** + * @param {*} value + * @returns {string} + */ +function formatValue(value) { + if (typeof value === "string") return value; + if (value === null) return "null"; + if (typeof value === "object") return JSON.stringify(value); + return String(value); +} + + +/** + * @param {*} err + * @returns {string} + */ +function formatError(err) { + if (!err) return "Unknown error"; + if (typeof err === "string") return err; + return err.message || err.toString(); +} + + +/** + * @param {*} result + * @returns {string} + */ +function formatResultErrors(result) { + if (result && Array.isArray(result.errors) && result.errors.length) { + return result.errors.join("\n"); + } + return "Unknown error"; +} + +export default YARAXScan; diff --git a/tests/lib/wasmFetchPolyfill.mjs b/tests/lib/wasmFetchPolyfill.mjs index cc1a6a393d..5247bfb388 100644 --- a/tests/lib/wasmFetchPolyfill.mjs +++ b/tests/lib/wasmFetchPolyfill.mjs @@ -9,6 +9,7 @@ */ import { readFile } from "fs/promises"; +import { fileURLToPath } from "url"; if (globalThis.fetch) { const originalFetch = globalThis.fetch; @@ -26,6 +27,13 @@ if (globalThis.fetch) { headers: { "Content-Type": "application/wasm" }, }); } + if (urlStr.startsWith("file:")) { + const buffer = await readFile(fileURLToPath(urlStr)); + return new Response(buffer, { + status: 200, + headers: { "Content-Type": "application/wasm" }, + }); + } return originalFetch(url, options); }; } diff --git a/tests/operations/tests/YARAX.mjs b/tests/operations/tests/YARAX.mjs new file mode 100644 index 0000000000..891103e488 --- /dev/null +++ b/tests/operations/tests/YARAX.mjs @@ -0,0 +1,127 @@ +/** + * YARA-X Scan tests. + * + * @author Zain Nadeem [zainnadeemzainnadeem80@gmail.com] + * + * @copyright Crown Copyright 2026 + * @license Apache-2.0 + */ + +import TestRegister from "../../lib/TestRegister.mjs"; + +const SIMPLE_RULE = "rule ExampleRule { strings: $a = \"hello\" condition: $a }"; +const METADATA_RULE = `rule MetadataRule : test_tag +{ + meta: + author = "test" + score = 7 + strings: + $a = "hello" + condition: + $a +}`; +const WITH_RULE = `rule WithRule +{ + condition: + with size = filesize : ( + size == 3 + ) +}`; + +TestRegister.addTests([ + { + name: "YARA-X Scan: simple match", + input: "hello world", + expectedOutput: "Rule: ExampleRule\n" + + "Namespace: default\n" + + "Patterns:\n" + + " $a at 0x0: \"hello\"", + recipeConfig: [ + { + op: "YARA-X Scan", + args: [SIMPLE_RULE, true, true, true, 0], + } + ], + }, + { + name: "YARA-X Scan: no match", + input: "goodbye world", + expectedOutput: "No matches", + recipeConfig: [ + { + op: "YARA-X Scan", + args: [SIMPLE_RULE, true, true, true, 0], + } + ], + }, + { + name: "YARA-X Scan: empty rules", + input: "hello world", + expectedOutput: "Rules cannot be empty.", + recipeConfig: [ + { + op: "YARA-X Scan", + args: [" ", true, true, true, 0], + } + ], + }, + { + name: "YARA-X Scan: invalid rule syntax", + input: "hello world", + expectedOutput: "Error compiling YARA-X rules. (error[E001]: syntax error\n" + + " --> line:1:23\n" + + " |\n" + + "1 | rule bad { condition: }\n" + + " | ^ expecting expression or identifier, found `}`)", + recipeConfig: [ + { + op: "YARA-X Scan", + args: ["rule bad { condition: }", true, true, true, 0], + } + ], + }, + { + name: "YARA-X Scan: with statement match", + input: "abc", + expectedOutput: "Rule: WithRule\n" + + "Namespace: default", + recipeConfig: [ + { + op: "YARA-X Scan", + args: [WITH_RULE, true, true, true, 0], + } + ], + }, + { + name: "YARA-X Scan: binary input match", + input: "\x00A\xff", + expectedOutput: "Rule: BinaryRule\n" + + "Namespace: default\n" + + "Patterns:\n" + + " $a at 0x0: \"\\x00A\\xff\"", + recipeConfig: [ + { + op: "YARA-X Scan", + args: ["rule BinaryRule { strings: $a = { 00 41 FF } condition: $a }", true, true, true, 0], + } + ], + }, + { + name: "YARA-X Scan: metadata output", + input: "hello world", + expectedOutput: "Rule: MetadataRule\n" + + "Namespace: default\n" + + "Tags: test_tag\n" + + "Metadata:\n" + + " author: test\n" + + " score: 7\n" + + "Patterns:\n" + + " $a at 0x0: \"hello\"", + recipeConfig: [ + { + op: "YARA-X Scan", + args: [METADATA_RULE, true, true, true, 0], + } + ], + }, +]);