Merge 9da27f32e91eeed404716eed43e4d7bab12cea37 into 4290ea753912378913b1f3f54e0fc5720afeda5d
This commit is contained in:
commit
00f010099f
7
package-lock.json
generated
7
package-lock.json
generated
@ -14,6 +14,7 @@
|
|||||||
"@astronautlabs/amf": "^0.0.6",
|
"@astronautlabs/amf": "^0.0.6",
|
||||||
"@blu3r4y/lzma": "^2.3.3",
|
"@blu3r4y/lzma": "^2.3.3",
|
||||||
"@noble/hashes": "2.2.0",
|
"@noble/hashes": "2.2.0",
|
||||||
|
"@virustotal/yara-x": "^1.15.0",
|
||||||
"@wavesenterprise/crypto-gost-js": "^2.1.0-RC1",
|
"@wavesenterprise/crypto-gost-js": "^2.1.0-RC1",
|
||||||
"@xmldom/xmldom": "^0.8.13",
|
"@xmldom/xmldom": "^0.8.13",
|
||||||
"argon2-browser": "^1.18.0",
|
"argon2-browser": "^1.18.0",
|
||||||
@ -4881,6 +4882,12 @@
|
|||||||
"url": "https://opencollective.com/typescript-eslint"
|
"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": {
|
"node_modules/@wavesenterprise/crypto-gost-js": {
|
||||||
"version": "2.1.0-RC1",
|
"version": "2.1.0-RC1",
|
||||||
"resolved": "https://registry.npmjs.org/@wavesenterprise/crypto-gost-js/-/crypto-gost-js-2.1.0-RC1.tgz",
|
"resolved": "https://registry.npmjs.org/@wavesenterprise/crypto-gost-js/-/crypto-gost-js-2.1.0-RC1.tgz",
|
||||||
|
|||||||
@ -99,6 +99,7 @@
|
|||||||
"@astronautlabs/amf": "^0.0.6",
|
"@astronautlabs/amf": "^0.0.6",
|
||||||
"@blu3r4y/lzma": "^2.3.3",
|
"@blu3r4y/lzma": "^2.3.3",
|
||||||
"@noble/hashes": "2.2.0",
|
"@noble/hashes": "2.2.0",
|
||||||
|
"@virustotal/yara-x": "^1.15.0",
|
||||||
"@wavesenterprise/crypto-gost-js": "^2.1.0-RC1",
|
"@wavesenterprise/crypto-gost-js": "^2.1.0-RC1",
|
||||||
"@xmldom/xmldom": "^0.8.13",
|
"@xmldom/xmldom": "^0.8.13",
|
||||||
"argon2-browser": "^1.18.0",
|
"argon2-browser": "^1.18.0",
|
||||||
|
|||||||
@ -533,6 +533,7 @@
|
|||||||
"Scan for Embedded Files",
|
"Scan for Embedded Files",
|
||||||
"Extract Files",
|
"Extract Files",
|
||||||
"YARA Rules",
|
"YARA Rules",
|
||||||
|
"YARA-X Scan",
|
||||||
"Remove EXIF",
|
"Remove EXIF",
|
||||||
"Extract EXIF",
|
"Extract EXIF",
|
||||||
"Extract RGBA",
|
"Extract RGBA",
|
||||||
@ -619,4 +620,4 @@
|
|||||||
"Comment"
|
"Comment"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
324
src/core/operations/YARAXScan.mjs
Normal file
324
src/core/operations/YARAXScan.mjs
Normal file
@ -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;
|
||||||
@ -9,6 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { readFile } from "fs/promises";
|
import { readFile } from "fs/promises";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
if (globalThis.fetch) {
|
if (globalThis.fetch) {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
@ -26,6 +27,13 @@ if (globalThis.fetch) {
|
|||||||
headers: { "Content-Type": "application/wasm" },
|
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);
|
return originalFetch(url, options);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
127
tests/operations/tests/YARAX.mjs
Normal file
127
tests/operations/tests/YARAX.mjs
Normal file
@ -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],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
Loading…
x
Reference in New Issue
Block a user