diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json index d3e7648a..e435f541 100644 --- a/src/core/config/Categories.json +++ b/src/core/config/Categories.json @@ -324,6 +324,7 @@ "Filter", "Head", "Tail", + "String index lookup", "Count occurrences", "Expand alphabet range", "Drop bytes", diff --git a/src/core/operations/StringIndexLookup.mjs b/src/core/operations/StringIndexLookup.mjs new file mode 100644 index 00000000..ab95b767 --- /dev/null +++ b/src/core/operations/StringIndexLookup.mjs @@ -0,0 +1,89 @@ +/** + * @author skyswordw + * @copyright Crown Copyright 2026 + * @license Apache-2.0 + */ + +import Operation from "../Operation.mjs"; +import Utils from "../Utils.mjs"; +import { DELIM_OPTIONS } from "../lib/Delim.mjs"; +import OperationError from "../errors/OperationError.mjs"; + +/** + * String index lookup operation + */ +class StringIndexLookup extends Operation { + + /** + * StringIndexLookup constructor + */ + constructor() { + super(); + + this.name = "String index lookup"; + this.module = "Default"; + this.description = "Looks up characters from a source string using an input list of indexes. This is useful for deobfuscating command lines that build strings by indexing into a lookup string."; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { + "name": "Source string", + "type": "binaryString", + "value": "" + }, + { + "name": "Delimiter", + "type": "option", + "value": DELIM_OPTIONS + }, + { + "name": "Indexing", + "type": "option", + "value": ["Zero-based", "One-based"] + }, + { + "name": "Skip invalid indexes", + "type": "boolean", + "value": true + } + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + * + * @throws {OperationError} if an index is invalid and invalid indexes are not skipped + */ + run(input, args) { + const source = [...(args[0] || "")], + delim = Utils.charRep(args[1] || "Space"), + indexOffset = args[2] === "One-based" ? 1 : 0, + skipInvalid = args[3] !== false; + + return input.split(delim).reduce((output, rawIndex) => { + const trimmedIndex = rawIndex.trim(); + + if (trimmedIndex.length === 0) return output; + + const parsedIndex = Number(trimmedIndex); + + if (!Number.isInteger(parsedIndex)) { + if (skipInvalid) return output; + throw new OperationError(`Invalid index: ${trimmedIndex}`); + } + + const sourceIndex = parsedIndex - indexOffset; + if (sourceIndex < 0 || sourceIndex >= source.length) { + if (skipInvalid) return output; + throw new OperationError(`Index out of range: ${parsedIndex}`); + } + + return output + source[sourceIndex]; + }, ""); + } + +} + +export default StringIndexLookup; diff --git a/tests/operations/index.mjs b/tests/operations/index.mjs index 44792560..9592bfc4 100644 --- a/tests/operations/index.mjs +++ b/tests/operations/index.mjs @@ -169,6 +169,7 @@ import "./tests/RC6.mjs"; // import "./tests/SplitColourChannels.mjs"; // Cannot test operations that use the File type yet import "./tests/SQLBeautify.mjs"; import "./tests/StrUtils.mjs"; +import "./tests/StringIndexLookup.mjs"; import "./tests/StripIPv4Header.mjs"; import "./tests/StripTCPHeader.mjs"; import "./tests/StripUDPHeader.mjs"; diff --git a/tests/operations/tests/StringIndexLookup.mjs b/tests/operations/tests/StringIndexLookup.mjs new file mode 100644 index 00000000..ab7155d8 --- /dev/null +++ b/tests/operations/tests/StringIndexLookup.mjs @@ -0,0 +1,66 @@ +/** + * String index lookup tests. + * + * @author skyswordw + * @copyright Crown Copyright 2026 + * @licence Apache-2.0 + */ +import TestRegister from "../../lib/TestRegister.mjs"; + +TestRegister.addTests([ + { + name: "String index lookup: zero-based semicolon list", + input: "0;1;2;3;4;5;6;7;8;9", + expectedOutput: "powershell", + recipeConfig: [ + { + op: "String index lookup", + args: ["powershell", "Semi-colon", "Zero-based", false] + } + ] + }, + { + name: "String index lookup: one-based comma list", + input: "1, 2, 3, 4, 5", + expectedOutput: "abcde", + recipeConfig: [ + { + op: "String index lookup", + args: ["abcde", "Comma", "One-based", false] + } + ] + }, + { + name: "String index lookup: skip invalid indexes", + input: "0;99;1;-1;2", + expectedOutput: "abc", + recipeConfig: [ + { + op: "String index lookup", + args: ["abc", "Semi-colon", "Zero-based", true] + } + ] + }, + { + name: "String index lookup: default skips invalid tokens", + input: "0;foo;1;99;2", + expectedOutput: "abc", + recipeConfig: [ + { + op: "String index lookup", + args: ["abc", "Semi-colon", "Zero-based"] + } + ] + }, + { + name: "String index lookup: strict out of range", + input: "3", + expectedOutput: "Index out of range: 3", + recipeConfig: [ + { + op: "String index lookup", + args: ["abc", "Semi-colon", "Zero-based", false] + } + ] + } +]);