diff --git a/docs/02_builtin.md b/docs/02_builtin.md index 516149d..85db0c6 100644 --- a/docs/02_builtin.md +++ b/docs/02_builtin.md @@ -132,6 +132,7 @@ The following list provides an overview of each build-in plugin, further informa | `homoglyph` | Encoding, Obfuscation | Substitutes Latin characters with visually-identical Unicode confusables (Cyrillic/Greek). Output looks unchanged to a human but uses different code points, evading ASCII keyword/regex filters. | `ratio` (fraction of mappable characters to substitute, 0.0-1.0, default: 1.0)
`seed` (optional integer seed for reproducible partial substitution) | | `morse` | Encoding | Encodes text into Morse code. | N/A | | `octal` | Encoding | Encodes text as space-separated ASCII/Unicode octal codepoint values. | `hint` (show the literal plaintext string `octal` in the encoded output, default true) | +| `zero_width` | Encoding, Obfuscation | Intersperses invisible zero-width Unicode characters between visible characters. Text looks unchanged to a human but contains no contiguous ASCII substring, evading keyword/regex filters. | `char` (which zero-width character to insert: `zwsp`/`zwnj`/`zwj`/`wj`/`zwnbsp`, default: `zwsp`)
`ratio` (fraction of gaps to fill, 0.0-1.0, default: 1.0)
`seed` (optional integer seed for reproducible partial output) | | `best_of_n` | Obfuscation, Attack-Based | Implements ["Best-of-N Jailbreaking" John Hughes et al., 2024](https://arxiv.org/html/2412.03556v1#A1) to apply character scrambling, random capitalization, and character noising. | `variants` (number of variations to generate, default: 50) | | `flip` | Obfuscation | Applies a flip attack to obfuscate text:
- FWO: Flip Word Order
- FCW: Flip Chars in Word
- FCS: Flip Chars in Sentence | `mode` (the flip mode to apply, default: `FWO`) | | `mask` | Obfuscation, LLM | Masks high-risk words in the text with random character sequences, while providing a suffix that maps the masks back to the original words. | `advanced` (if true, creates multiple masks for longer words)
`advanced-split` (the number of characters per mask chunk for the advanced option, default: 6) | diff --git a/spikee/plugins/zero_width.py b/spikee/plugins/zero_width.py new file mode 100644 index 0000000..5e8037a --- /dev/null +++ b/spikee/plugins/zero_width.py @@ -0,0 +1,136 @@ +""" +Zero-Width Plugin + +This plugin interleaves invisible zero-width Unicode characters between the +visible characters of the text. To a human reader (and in most UIs) the text +looks unchanged ("ignore previous instructions" still reads normally), but the +underlying byte sequence no longer contains any contiguous ASCII substring, which +defeats naive keyword/regex filters, substring denylists, and byte-level matching. + +This is distinct from the existing plugins: + - `ascii_smuggler` *replaces* the text with invisible Unicode tag characters + (U+E00xx), so the visible content disappears entirely. + - `homoglyph` *substitutes* Latin glyphs with look-alike confusables from other + scripts, changing every visible code point. +Zero-width interspersing keeps the original visible glyphs intact and simply +injects invisible separators between them. + +By default a separator is inserted in every gap (``ratio=1.0``), which is fully +deterministic. A lower ``ratio`` fills only that fraction of gaps, producing a +lighter obfuscation; pass ``seed`` for reproducible partial output. The invisible +character used is configurable via ``char``. + +Usage: + spikee generate --plugins zero_width + spikee generate --plugins zero_width --plugin-options "zero_width:char=zwnj" + spikee generate --plugins zero_width --plugin-options "zero_width:ratio=0.5,seed=42" + +Reference: + https://en.wikipedia.org/wiki/Zero-width_space + https://embracethered.com/blog/posts/2024/hiding-and-finding-text-with-unicode-tags/ + +Parameters: + text (str): The input text to be transformed. + char (str): Which zero-width character to insert. One of ``zwsp`` (U+200B, + default), ``zwnj`` (U+200C), ``zwj`` (U+200D), ``wj`` (U+2060), or + ``zwnbsp``/``bom`` (U+FEFF). + ratio (float): Fraction of inter-character gaps to fill, in [0.0, 1.0] + (default: 1.0). At 1.0 a separator is inserted in every gap; below 1.0 a + random subset of gaps is chosen. + seed (int, optional): Seed for the random subset when ``ratio`` < 1.0, for + reproducible output. + exclude_patterns (List[str], optional): Supplied by the framework. Substrings + matching these regex patterns are preserved as-is. + +Returns: + str: The transformed text with zero-width characters interspersed. +""" + +import random +from typing import Dict + +from spikee.templates.basic_plugin import BasicPlugin +from spikee.utilities.enums import ModuleTag +from spikee.utilities.hinting import ModuleDescriptionHint, ModuleOptionsHint +from spikee.utilities.modules import parse_options + + +class ZeroWidthPlugin(BasicPlugin): + DEFAULT_RATIO = 1.0 + DEFAULT_CHAR = "zwsp" + + # Supported invisible separators. Keys are the option values users pass. + ZERO_WIDTH_CHARS: Dict[str, str] = { + "zwsp": "​", # ZERO WIDTH SPACE + "zwnj": "‌", # ZERO WIDTH NON-JOINER + "zwj": "‍", # ZERO WIDTH JOINER + "wj": "⁠", # WORD JOINER + "zwnbsp": "", # ZERO WIDTH NO-BREAK SPACE (BOM) + "bom": "", # alias for zwnbsp + } + + def get_description(self) -> ModuleDescriptionHint: + return ( + [ModuleTag.ENCODING, ModuleTag.OBFUSCATION], + "Intersperses invisible zero-width characters between visible characters.", + ) + + def get_available_option_values(self) -> ModuleOptionsHint: + """Return supported attack options; Tuple[options (default is first), llm_required]""" + return [ + "char=zwsp", + "char=zwsp/zwnj/zwj/wj/zwnbsp (which zero-width character to insert)", + "ratio=N (0.0-1.0, fraction of inter-character gaps to fill)", + "seed=N (optional integer seed for reproducible partial output)", + ], False + + def plugin_transform(self, text: str, plugin_option: str = "") -> str: + """ + Intersperses zero-width characters between the characters of ``text``. + + Args: + text (str): The input text (or chunk thereof). + plugin_option (str, optional): Plugin options string, e.g. + "char=zwnj,ratio=0.5,seed=42". + + Returns: + str: The transformed text. + """ + zw_char, ratio, seed = self._parse_options(plugin_option) + + if len(text) < 2 or ratio <= 0.0: + return text + + if ratio >= 1.0: + return zw_char.join(text) + + rng = random.Random(seed) + result = [text[0]] + for char in text[1:]: + if rng.random() < ratio: + result.append(zw_char) + result.append(char) + return "".join(result) + + def _parse_options(self, plugin_option: str): + """Parse the ``char``, ``ratio`` and ``seed`` options, falling back to defaults.""" + opts = parse_options(plugin_option) + + char_key = str(opts.get("char", self.DEFAULT_CHAR)).lower() + zw_char = self.ZERO_WIDTH_CHARS.get(char_key, self.ZERO_WIDTH_CHARS[self.DEFAULT_CHAR]) + + ratio = self.DEFAULT_RATIO + if "ratio" in opts: + try: + ratio = max(0.0, min(1.0, float(opts["ratio"]))) + except ValueError: + ratio = self.DEFAULT_RATIO + + seed = None + if "seed" in opts: + try: + seed = int(opts["seed"]) + except ValueError: + seed = None + + return zw_char, ratio, seed diff --git a/tests/functional/test_spikee_list.py b/tests/functional/test_spikee_list.py index f89f4c3..e780ce6 100644 --- a/tests/functional/test_spikee_list.py +++ b/tests/functional/test_spikee_list.py @@ -47,7 +47,7 @@ def test_list_plugins(run_spikee, workspace_dir): output_lines = spikee_list(run_spikee, workspace_dir, "plugins") expected_local = {"test_repeat", "test_upper"} - expected_builtin = {"1337", "base64", "hex"} + expected_builtin = {"1337", "base64", "hex", "zero_width"} _assert_contains(output_lines, expected_local | expected_builtin)