Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion src/core/config/Categories.json
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,7 @@
"Scan for Embedded Files",
"Extract Files",
"YARA Rules",
"YARA-X Scan",
"Remove EXIF",
"Extract EXIF",
"Extract RGBA",
Expand Down Expand Up @@ -603,4 +604,4 @@
"Comment"
]
}
]
]
324 changes: 324 additions & 0 deletions src/core/operations/YARAXScan.mjs
Original file line number Diff line number Diff line change
@@ -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<void>}
*/
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;
8 changes: 8 additions & 0 deletions tests/lib/wasmFetchPolyfill.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
*/

import { readFile } from "fs/promises";
import { fileURLToPath } from "url";

if (globalThis.fetch) {
const originalFetch = globalThis.fetch;
Expand All @@ -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);
};
}
Loading