diff --git a/CHANGELOG.md b/CHANGELOG.md index 7978671680..d2c14b3a9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,37 @@ All major and minor version changes will be documented in this file. Details of ## Details +### [11.4.0] - 2026-08-18 +This release includes a security fix +- Security: patch XSS in Regular expression module [@Ne0re0] +- chore (deps): bump @codemirror/view from 6.43.8 to 6.43.9 in the patch-updates group | [#2731] +- chore (deps): bump @codemirror/commands from 6.10.4 to 6.11.0 in the minor-updates group | [#2732] +- Add XPRESS (MS-XCA) decompression operations [@MP-GOWTHAM] | [#2722] +- Feat/node 26 support [@alleria173] | [#2699] +- chore(root): update allowlist [@evenstensberg] | [#2713] +- chore (deps): bump the patch-updates group across 1 directory with 7 updates | [#2730] +- chore (deps): bump the minor-updates group across 1 directory with 9 updates | [#2729] +- chore (deps): bump docker/login-action from 4.5.2 to 4.6.0 in the actions-dependencies group | [#2716] +- chore (deps): bump node from `a0b9bf0` to `d32cdf6` in the docker-dependencies group | [#2723] +- docs(root): improve docs a bit [@evenstensberg] | [#2718] +- fix: use js-yaml for both JSON to YAML and YAML to JSON [@bartvanandel] | [#2710] +- chore (deps): bump the patch-updates group across 1 directory with 6 updates | [#2712] +- chore (deps): bump the minor-updates group across 1 directory with 3 updates | [#2705] +- chore (deps): bump the actions-dependencies group with 2 updates | [#2703] +- fix: replace `shasum` / `sha256sum` / `sed` calls with node built-ins [@bartvanandel] | [#2019] +- chore (deps): bump fast-uri from 3.1.4 to 3.1.5 | [#2709] +- chore (deps): bump ip-address from 10.2.0 to 10.4.0 | [#2708] +- fix: stop Parse QR Code from participating in Magic (#2610) [@Sanjays2402] | [#2613] +- Restrict A1Z26 Magic checks to valid ranges [@vetrovk] | [#2644] +- feat: Extend automated ingredient validation to include argSelector ingredients (#2641) [@mansiverma897993] | [#2643] +- Add Modular Exponentiation operation [@p-leriche] | [#2149] +- Add npm allowScripts policy for npm v12 [@zainnadeem786] | [#2682] +- chore (deps): bump assorted vulnerable dependencies [@GCHQDeveloper581] | [#2689] +- chore (deps): bump shell-quote from 1.8.4 to 1.10.0 | [#2690] +- chore (deps): bump the patch-updates group across 1 directory with 9 updates | [#2686] +- chore (deps): bump the actions-dependencies group across 1 directory with 2 updates | [#2685] +- chore (deps): bump nginxinc/nginx-unprivileged from `fd3314e` to `44e3633` in the docker-dependencies group | [#2684] + ### [11.3.0] - 2026-07-24 This release includes a security fix ([#2687]) - Security: Fix pretty recipe parser ReDoS [@zainnadeem786] | [#2687] @@ -787,6 +818,7 @@ Breaking changes: ## [4.0.0] - 2016-11-28 - Initial open source commit [@n1474335] | [b1d73a72](https://github.com/gchq/CyberChef/commit/b1d73a725dc7ab9fb7eb789296efd2b7e4b08306) +[11.4.0]: https://github.com/gchq/CyberChef/releases/tag/v11.4.0 [11.3.0]: https://github.com/gchq/CyberChef/releases/tag/v11.3.0 [11.2.0]: https://github.com/gchq/CyberChef/releases/tag/v11.2.0 [11.1.0]: https://github.com/gchq/CyberChef/releases/tag/v11.1.0 @@ -1099,6 +1131,9 @@ Breaking changes: [@zainnadeem786]: https://github.com/zainnadeem786 [@williballenthin]: https://github.com/williballenthin [@Shailendra1703]: https://github.com/Shailendra1703 +[@Ne0re0]: https://github.com/Ne0re0 +[@MP-GOWTHAM]: https://github.com/MP-GOWTHAM +[@Sanjays2402]: https://github.com/Sanjays2402 [8ad18b]: https://github.com/gchq/CyberChef/commit/8ad18bc7db6d9ff184ba3518686293a7685bf7b7 @@ -1536,3 +1571,31 @@ Breaking changes: [#2577]: https://github.com/gchq/CyberChef/pull/2577 [#2574]: https://github.com/gchq/CyberChef/pull/2574 [#2573]: https://github.com/gchq/CyberChef/pull/2573 +[#2731]: https://github.com/gchq/CyberChef/pull/2731 +[#2732]: https://github.com/gchq/CyberChef/pull/2732 +[#2722]: https://github.com/gchq/CyberChef/pull/2722 +[#2699]: https://github.com/gchq/CyberChef/pull/2699 +[#2713]: https://github.com/gchq/CyberChef/pull/2713 +[#2730]: https://github.com/gchq/CyberChef/pull/2730 +[#2729]: https://github.com/gchq/CyberChef/pull/2729 +[#2716]: https://github.com/gchq/CyberChef/pull/2716 +[#2723]: https://github.com/gchq/CyberChef/pull/2723 +[#2718]: https://github.com/gchq/CyberChef/pull/2718 +[#2710]: https://github.com/gchq/CyberChef/pull/2710 +[#2712]: https://github.com/gchq/CyberChef/pull/2712 +[#2705]: https://github.com/gchq/CyberChef/pull/2705 +[#2703]: https://github.com/gchq/CyberChef/pull/2703 +[#2019]: https://github.com/gchq/CyberChef/pull/2019 +[#2709]: https://github.com/gchq/CyberChef/pull/2709 +[#2708]: https://github.com/gchq/CyberChef/pull/2708 +[#2613]: https://github.com/gchq/CyberChef/pull/2613 +[#2644]: https://github.com/gchq/CyberChef/pull/2644 +[#2643]: https://github.com/gchq/CyberChef/pull/2643 +[#2149]: https://github.com/gchq/CyberChef/pull/2149 +[#2682]: https://github.com/gchq/CyberChef/pull/2682 +[#2689]: https://github.com/gchq/CyberChef/pull/2689 +[#2690]: https://github.com/gchq/CyberChef/pull/2690 +[#2686]: https://github.com/gchq/CyberChef/pull/2686 +[#2685]: https://github.com/gchq/CyberChef/pull/2685 +[#2684]: https://github.com/gchq/CyberChef/pull/2684 + diff --git a/package-lock.json b/package-lock.json index a7c1fd5f6f..cd0cda1103 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cyberchef", - "version": "11.3.0", + "version": "11.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cyberchef", - "version": "11.3.0", + "version": "11.4.0", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -116,11 +116,11 @@ "@babel/plugin-transform-runtime": "^7.29.7", "@babel/preset-env": "^7.29.7", "@babel/runtime": "^7.29.7", - "@codemirror/commands": "^6.10.4", + "@codemirror/commands": "^6.11.0", "@codemirror/language": "^6.12.4", "@codemirror/search": "^6.7.1", "@codemirror/state": "^6.7.1", - "@codemirror/view": "^6.43.8", + "@codemirror/view": "^6.43.9", "@puppeteer/browsers": "3.2.0", "autoprefixer": "^10.5.4", "babel-loader": "^10.1.1", @@ -1851,9 +1851,9 @@ } }, "node_modules/@codemirror/commands": { - "version": "6.10.4", - "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz", - "integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==", + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.0.tgz", + "integrity": "sha512-/K4Rl5BN0OtTiPWmJCdqODu38XnDMsDxKY5rgrPnCkutPTJf2wVbkoixLfealF5Kwse/s8P8M5jAiURiwSwnFA==", "dev": true, "license": "MIT", "dependencies": { @@ -1901,9 +1901,9 @@ } }, "node_modules/@codemirror/view": { - "version": "6.43.8", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.8.tgz", - "integrity": "sha512-qtItTDssZ/5GFfi94hrILu9j/VUeFPDPkhovEfmWFj2ipTxnzPB8DdHgfbb8HYTzLTYhrndKmyQxXUz/PDLenw==", + "version": "6.43.9", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.9.tgz", + "integrity": "sha512-sTuUzTpPMFebRhg6dawChoKKgndIwfjmJgKVxBefPElcU2NwQ6AFroupk0SFqEerQyZOGRfDNnSN8Dw/lMAsXw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index fbd2cc60b6..8c92a6b19c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cyberchef", - "version": "11.3.0", + "version": "11.4.0", "description": "The Cyber Swiss Army Knife for encryption, encoding, compression and data analysis.", "author": "GCHQ ", "homepage": "https://gchq.github.io/CyberChef", @@ -44,11 +44,11 @@ "@babel/plugin-transform-runtime": "^7.29.7", "@babel/preset-env": "^7.29.7", "@babel/runtime": "^7.29.7", - "@codemirror/commands": "^6.10.4", + "@codemirror/commands": "^6.11.0", "@codemirror/language": "^6.12.4", "@codemirror/search": "^6.7.1", "@codemirror/state": "^6.7.1", - "@codemirror/view": "^6.43.8", + "@codemirror/view": "^6.43.9", "@puppeteer/browsers": "3.2.0", "autoprefixer": "^10.5.4", "babel-loader": "^10.1.1", diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json index 6ad3adb776..1a8288c6cd 100644 --- a/src/core/config/Categories.json +++ b/src/core/config/Categories.json @@ -433,7 +433,9 @@ "LZMA Compress", "LZ4 Decompress", "LZ4 Compress", - "LZNT1 Decompress" + "LZNT1 Decompress", + "XPRESS Decompress", + "XPRESS LZ77+Huffman Decompress" ] }, { diff --git a/src/core/lib/XPRESS.mjs b/src/core/lib/XPRESS.mjs new file mode 100644 index 0000000000..f7cb9a108e --- /dev/null +++ b/src/core/lib/XPRESS.mjs @@ -0,0 +1,270 @@ +/** + * XPRESS (MS-XCA) decompression. + * + * @author MP Gowtham [gowthamrockerzzz@gmail.com] + * @copyright Crown Copyright 2026 + * @license Apache-2.0 + * + * Implements the two XPRESS variants from: + * https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-xca/ + * (2.1 XPRESS Algorithm Details, 2.2 LZ77+Huffman Algorithm Details) + * + * Cross-validated against the reference decoder in The Sleuth Kit + * (tsk/fs/xpress.c) and go-ntfs (parser/xpress.go). + */ + +import OperationError from "../errors/OperationError.mjs"; + +/** Maximum output per call (Windows sizes XPRESS blocks at up to + * 32 MiB for WIM chunks and up to 1 MiB for WOF chunks). */ +const MAX_DECOMPRESSED = 32 * 1024 * 1024; + +/** + * Decompress an XPRESS plain-LZ77 stream. + * + * The stream is self-terminating: a sequence of 32-bit flag groups + * tested from bit 31 down. A clear bit is a literal byte. A set bit is + * a match described by an LE16 word, (offset-1) in the top 13 bits and + * (length-3) in the low 3 bits. A match whose low 3 bits are 7 uses the + * shared-nibble form: the low nibble of the next stream byte extends + * the length, and its high nibble extends the next match that also uses + * this form. A nibble of 15 selects a raw length: a byte, an LE16 if + * the byte is 255, or an LE32 if the LE16 is 0. The final flag group + * is padded with set bits; a match flag with no input left is the + * end-of-data marker. + * + * @param {byteArray} input + * @returns {byteArray} decompressed data + */ +export function decompress(input) { + const out = []; + let pending = -1; // offset of the shared-nibble byte, -1 when none pending + let flags = 0; + let flagsLeft = 0; + let i = 0; + + while (true) { + if (flagsLeft === 0) { + if (input.length - i < 4) + throw new OperationError("XPRESS: truncated flag group"); + flags = (input[i] | (input[i + 1] << 8) | + (input[i + 2] << 16) | (input[i + 3] << 24)) >>> 0; + i += 4; + flagsLeft = 32; + } + flagsLeft--; + if (((flags >>> flagsLeft) & 1) === 0) { + if (i >= input.length) + throw new OperationError("XPRESS: truncated literal"); + out.push(input[i++]); + continue; + } + + // A set flag with no input left is the end-of-data marker. + if (i >= input.length) + return out; + if (input.length - i < 2) + throw new OperationError("XPRESS: truncated match"); + const mb = input[i] | (input[i + 1] << 8); + i += 2; + const moff = (mb >>> 3) + 1; + let mlen = (mb & 7) + 3; + + if ((mb & 7) === 7) { + let nib; + if (pending === -1) { + if (i >= input.length) + throw new OperationError("XPRESS: truncated shared nibble"); + nib = input[i] & 0x0f; + pending = i++; + } else { + nib = input[pending] >>> 4; + pending = -1; + } + if (nib === 15) { + let v = 0; + if (i >= input.length) + throw new OperationError("XPRESS: truncated raw length"); + v = input[i++]; + if (v === 255) { + if (input.length - i < 2) + throw new OperationError("XPRESS: truncated raw length"); + v = input[i] | (input[i + 1] << 8); + i += 2; + if (v === 0) { + if (input.length - i < 4) + throw new OperationError("XPRESS: truncated raw length"); + v = (input[i] | (input[i + 1] << 8) | + (input[i + 2] << 16) | (input[i + 3] << 24)) >>> 0; + i += 4; + } + if (v < 22) + throw new OperationError("XPRESS: invalid match length"); + mlen = v + 3; + } else { + mlen = v + 25; + } + } else { + mlen = nib + 10; + } + } + + if (moff > 8192 || moff > out.length) + throw new OperationError("XPRESS: match offset out of range"); + if (out.length + mlen > MAX_DECOMPRESSED) + throw new OperationError("XPRESS: decompression ratio too large"); + + const start = out.length - moff; + for (let j = 0; j < mlen; j++) + out.push(out[start + j]); + } +} + +/** + * Decompress an XPRESS LZ77+Huffman stream into exactly + * decompressedSize bytes. + * + * The first 256 bytes hold 512 4-bit code lengths, the even symbol in + * the low nibble and the odd in the high. Canonical codes are assigned + * in (length, symbol) order, most-significant bit first. The bit stream + * follows as LE16 words, MSB first, read through a 32-bit register + * refilled while fewer than 15 bits remain. Symbols 0..255 are + * literals. Symbol 256 is end-of-data; mid-stream it decodes as a match + * of length 3 at offset 1. Symbols 257..511 are matches: ((s-256)>>4) + * selects the offset bit width, ((s-256)&15) the base length, with a + * nibble of 15 selecting a raw length byte (an LE16 if 255, an LE32 if + * the LE16 is 0). + * + * @param {byteArray} input + * @param {number} decompressedSize + * @returns {byteArray} decompressed data + */ +export function decompressHuffman(input, decompressedSize) { + if (decompressedSize <= 0 || decompressedSize > MAX_DECOMPRESSED) + throw new OperationError("XPRESS: invalid decompressed size"); + if (input.length < 256) + throw new OperationError("XPRESS: truncated Huffman table"); + + const lens = new Array(512); + for (let l = 0; l < 256; l++) { + lens[l * 2] = input[l] & 0x0f; + lens[l * 2 + 1] = input[l] >>> 4; + } + + // Decode table in canonical (length, symbol) order, MSB first. + const TABLE_BITS = 15; + const TABLE_SIZE = 1 << TABLE_BITS; + const table = new Array(TABLE_SIZE); + let e = 0; + for (let l = 1; l <= TABLE_BITS; l++) { + for (let s = 0; s < 512; s++) { + if (lens[s] === l) { + const n = 1 << (TABLE_BITS - l); + for (let k = 0; k < n; k++) + table[e++] = s; + } + } + } + if (e !== TABLE_SIZE) + throw new OperationError("XPRESS: invalid Huffman code lengths"); + + // Preload two LE16 words, most-significant bit first. + let bits = 0; + let nbits = 0; + let i = 256; + while (nbits < 32) { + if (input.length - i < 2) + throw new OperationError("XPRESS: truncated bit stream"); + bits = ((bits >>> 0) | (input[i] | (input[i + 1] << 8)) << (16 - nbits)) >>> 0; + i += 2; + nbits += 16; + } + + const out = []; + for (;;) { + while (nbits < 15) { + if (input.length - i < 2) + throw new OperationError("XPRESS: truncated bit stream"); + bits = ((bits >>> 0) | (input[i] | (input[i + 1] << 8)) << (16 - nbits)) >>> 0; + i += 2; + nbits += 16; + } + const sym = table[(bits >>> 17) & 0x7fff]; + const clen = lens[sym]; + bits = (bits >>> 0) << clen; + nbits -= clen; + + if (sym < 256) { + out.push(sym); + if (out.length > decompressedSize) + throw new OperationError("XPRESS: output exceeds declared size"); + continue; + } + + if (sym === 256) { + // End of data; mid-stream it decodes as a match(3, 1). + if (out.length === decompressedSize) + break; + if (out.length === 0 || decompressedSize - out.length < 3) + throw new OperationError("XPRESS: corrupt end-of-data marker"); + const start = out.length - 1; + for (let j = 0; j < 3; j++) + out.push(out[start + j]); + continue; + } + + const hb = (sym - 256) >>> 4; + let mlen = (sym - 256) & 15; + if (mlen === 15) { + let v = 0; + if (i >= input.length) + throw new OperationError("XPRESS: truncated raw length"); + v = input[i++]; + if (v === 255) { + if (input.length - i < 2) + throw new OperationError("XPRESS: truncated raw length"); + v = input[i] | (input[i + 1] << 8); + i += 2; + if (v === 0) { + if (input.length - i < 4) + throw new OperationError("XPRESS: truncated raw length"); + v = (input[i] | (input[i + 1] << 8) | + (input[i + 2] << 16) | (input[i + 3] << 24)) >>> 0; + i += 4; + } + mlen = v + 3; + } else { + mlen = v + 18; + } + } else { + mlen += 3; + } + + while (nbits < hb) { + if (input.length - i < 2) + throw new OperationError("XPRESS: truncated bit stream"); + bits = ((bits >>> 0) | (input[i] | (input[i + 1] << 8)) << (16 - nbits)) >>> 0; + i += 2; + nbits += 16; + } + let moff = 0; + if (hb > 0) { + moff = (bits >>> (32 - hb)) & ((1 << hb) - 1); + bits = (bits >>> 0) << hb; + nbits -= hb; + } + moff += 1 << hb; + + if (moff > out.length) + throw new OperationError("XPRESS: match offset out of range"); + if (out.length + mlen > MAX_DECOMPRESSED) + throw new OperationError("XPRESS: decompression ratio too large"); + if (out.length + mlen > decompressedSize) + throw new OperationError("XPRESS: output exceeds declared size"); + + const start = out.length - moff; + for (let j = 0; j < mlen; j++) + out.push(out[start + j]); + } + return out; +} diff --git a/src/core/operations/RegularExpression.mjs b/src/core/operations/RegularExpression.mjs index c3ab5b1df9..19f0aeace2 100644 --- a/src/core/operations/RegularExpression.mjs +++ b/src/core/operations/RegularExpression.mjs @@ -238,15 +238,23 @@ function regexHighlight(input, regex, displayTotal) { const captureGroups = []; output = input.replace(regex, (match, ...args) => { + // The replacer is called with (match, p1, ..., pn, offset, string) and, if the + // regex contains named capture groups, a trailing `groups` object (ES2018). + // Capture groups are only ever strings or undefined, so an object in the last + // position can only be the named groups collection. + if (args.length && typeof args[args.length - 1] === "object" && args[args.length - 1] !== null) + args.pop(); // Throw away named capture group object args.pop(); // Throw away full string const offset = args.pop(), groups = args; - title = `Offset: ${offset}\n`; + // Everything interpolated into the title attribute must be escaped, including + // the offset, which is not guaranteed to be a number for all regex engines. + title = `Offset: ${Utils.escapeHtml(String(offset))}\n`; if (groups.length) { title += "Groups:\n"; for (let i = 0; i < groups.length; i++) { - title += `\t${i+1}: ${Utils.escapeHtml(groups[i] || "")}\n`; + title += `\t${i+1}: ${Utils.escapeHtml(String(groups[i] ?? ""))}\n`; } } diff --git a/src/core/operations/XPRESSDecompress.mjs b/src/core/operations/XPRESSDecompress.mjs new file mode 100644 index 0000000000..a8167d37fc --- /dev/null +++ b/src/core/operations/XPRESSDecompress.mjs @@ -0,0 +1,41 @@ +/** + * @author MP Gowtham [gowthamrockerzzz@gmail.com] + * @copyright Crown Copyright 2026 + * @license Apache-2.0 + */ + +import Operation from "../Operation.mjs"; +import {decompress} from "../lib/XPRESS.mjs"; + +/** + * XPRESS Decompress operation + */ +class XPRESSDecompress extends Operation { + + /** + * XPRESS Decompress constructor + */ + constructor() { + super(); + + this.name = "XPRESS Decompress"; + this.module = "Compression"; + this.description = "Decompresses data using the XPRESS plain LZ77 algorithm (MS-XCA section 2.1).

Similar to the Windows API RtlDecompressBuffer with COMPRESSION_FORMAT_XPRESS."; + this.infoURL = "https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-xca/5655f4a3-6ba4-489b-959f-e1f407c52f15"; + this.inputType = "byteArray"; + this.outputType = "byteArray"; + this.args = []; + } + + /** + * @param {byteArray} input + * @param {Object[]} args + * @returns {byteArray} + */ + run(input, args) { + return decompress(input); + } + +} + +export default XPRESSDecompress; diff --git a/src/core/operations/XPRESSHuffmanDecompress.mjs b/src/core/operations/XPRESSHuffmanDecompress.mjs new file mode 100644 index 0000000000..0b667dcb98 --- /dev/null +++ b/src/core/operations/XPRESSHuffmanDecompress.mjs @@ -0,0 +1,48 @@ +/** + * @author MP Gowtham [gowthamrockerzzz@gmail.com] + * @copyright Crown Copyright 2026 + * @license Apache-2.0 + */ + +import Operation from "../Operation.mjs"; +import {decompressHuffman} from "../lib/XPRESS.mjs"; + +/** + * XPRESS LZ77+Huffman Decompress operation + */ +class XPRESSHuffmanDecompress extends Operation { + + /** + * XPRESS LZ77+Huffman Decompress constructor + */ + constructor() { + super(); + + this.name = "XPRESS LZ77+Huffman Decompress"; + this.module = "Compression"; + this.description = "Decompresses data using the XPRESS LZ77+Huffman algorithm (MS-XCA section 2.2).

The uncompressed size must be known in advance, as it is from the WOF chunk table or WIM header, so it is taken as an argument."; + this.infoURL = "https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-xca/5655f4a3-6ba4-489b-959f-e1f407c52f15"; + this.inputType = "byteArray"; + this.outputType = "byteArray"; + this.args = [ + { + "name": "Decompressed size", + "type": "number", + "value": 4096 + } + ]; + } + + /** + * @param {byteArray} input + * @param {Object[]} args + * @returns {byteArray} + */ + run(input, args) { + const size = args[0]; + return decompressHuffman(input, size); + } + +} + +export default XPRESSHuffmanDecompress; diff --git a/tests/operations/tests/RegularExpression.mjs b/tests/operations/tests/RegularExpression.mjs index 80af3ce773..548ca6a242 100644 --- a/tests/operations/tests/RegularExpression.mjs +++ b/tests/operations/tests/RegularExpression.mjs @@ -72,4 +72,40 @@ TestRegister.addTests([ }, ], }, + { + // A named capture group whose name is written as a Unicode escape is passed + // through to the native engine verbatim, so the replacer receives an extra + // `groups` argument. The offset must still be resolved correctly and escaped, + // otherwise the input is injected raw into the title attribute. + name: "Regular Expression - highlight with escaped named capture group", + input: "~'>", + expectedOutput: "~'><script>alert('1')</script>", + recipeConfig: [ + { + op: "Regular expression", + args: [ + "User defined", + "(?<\\u0041>~)", + false, false, false, false, false, false, + "Highlight matches", + ], + }, + ], + }, + { + name: "Regular Expression - highlight with named capture group", + input: "ab ab", + expectedOutput: "ab ab", + recipeConfig: [ + { + op: "Regular expression", + args: [ + "User defined", + "(?a)(b)", + false, false, false, false, false, false, + "Highlight matches", + ], + }, + ], + }, ]); diff --git a/tests/operations/tests/XPRESS.mjs b/tests/operations/tests/XPRESS.mjs new file mode 100644 index 0000000000..14bd68868a --- /dev/null +++ b/tests/operations/tests/XPRESS.mjs @@ -0,0 +1,136 @@ +/** + * XPRESS tests. + * + * @author MP Gowtham [gowthamrockerzzz@gmail.com] + * @copyright Crown Copyright 2026 + * @license Apache-2.0 + */ +import TestRegister from "../../lib/TestRegister.mjs"; + +TestRegister.addTests([ + + // MS-XCA section 3.1 worked example (plain LZ77, all literals). + { + name: "XPRESS Decompress: worked example", + input: "0000000047484f53542f2f5245434f5645522064617461207265636f7665727920656e67ffffff07696e652e0a", + expectedOutput: "GHOST//RECOVER data recovery engine.\n", + recipeConfig: [ + { + "op": "From Hex", + "args": ["Space"] + }, + { + "op": "XPRESS Decompress", + "args": [] + } + ] + }, + + // MS-XCA section 3.1 worked example (plain LZ77): literals, then a + // shared-nibble match with an LE16 raw length. The low nibble of + // 0x0f selects the raw length and its high nibble feeds the next + // match, which reads the trailing LE16 (0x0126). + { + name: "XPRESS Decompress: shared nibble and LE16 length", + input: "ffffff1f61626317000fff2601", + expectedOutput: "abc".repeat(100), + recipeConfig: [ + { + "op": "From Hex", + "args": ["Space"] + }, + { + "op": "XPRESS Decompress", + "args": [] + } + ] + }, + + // Shared-nibble form without a raw length: nibble 13 + 10. + { + name: "XPRESS Decompress: nibble length", + input: "ffffff7f6e07000d", + expectedOutput: "n".repeat(24), + recipeConfig: [ + { + "op": "From Hex", + "args": ["Space"] + }, + { + "op": "XPRESS Decompress", + "args": [] + } + ] + }, + + // The shared-nibble half-byte survives literal runs: the low nibble + // of 0x21 gives the first match its length, the literal 'B' does not + // clear it, and the second match uses the high nibble. + { + name: "XPRESS Decompress: shared half-byte across a literal", + input: "ffffff5f41070021420700", + expectedOutput: "A".repeat(12) + "B".repeat(13), + recipeConfig: [ + { + "op": "From Hex", + "args": ["Space"] + }, + { + "op": "XPRESS Decompress", + "args": [] + } + ] + }, + + // One-byte raw length form: byte 0xd7 + 25 = 240. + { + name: "XPRESS Decompress: one-byte raw length", + input: "ffff0000413142324333443445354636473748387f000fd7", + expectedOutput: "A1B2C3D4E5F6G7H8".repeat(16), + recipeConfig: [ + { + "op": "From Hex", + "args": ["Space"] + }, + { + "op": "XPRESS Decompress", + "args": [] + } + ] + }, + + // LE16 raw lengths: 0xfffc + 3 = 65535, then a shared-nibble match + // with LE16 0x116d + 3 = 4464, for 70000 bytes total. + { + name: "XPRESS Decompress: LE16 raw lengths", + input: "ffffff7f570700fffffcffffffff6d11", + expectedOutput: "W".repeat(70000), + recipeConfig: [ + { + "op": "From Hex", + "args": ["Space"] + }, + { + "op": "XPRESS Decompress", + "args": [] + } + ] + }, + + // MS-XCA section 3.1 worked example (LZ77+Huffman), ten repetitions. + { + name: "XPRESS LZ77+Huffman Decompress: worked example", + input: "00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 03 00 00 00 00 00 00 05 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 06 00 00 00 00 00 50 66 55 55 66 65 55 45 65 55 55 65 55 05 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 05 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 05 00 00 00 00 00 00 00 00 00 00 00 00 00 00 50 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 b4 e3 a9 8f 5e e7 62 8e bc 5f ac 28 47 19 40 42 98 aa eb 89 7c da 20 5c 61 96 e4 b6 ff 38 01 00 00", + expectedOutput: "The quick brown fox jumps over the lazy dog. ".repeat(8), + recipeConfig: [ + { + "op": "From Hex", + "args": ["Space"] + }, + { + "op": "XPRESS LZ77+Huffman Decompress", + "args": [360] + } + ] + } +]);