쓰까(ssukka) is Busan dialect (부산 사투리) for "mix it up" (섞어).
HTML obfuscation library and CLI for Rust. Renders identically in browsers but is hard for humans to read.
- Class/ID/keyframe renaming - consistent across HTML, CSS, and JavaScript (including dynamic construction patterns);
@keyframesand theiranimation/animation-namereferences are renamed too - HTML entity encoding - text and attribute values encoded as decimal/hex/named entities
- Tag case randomization -
<div>becomes<DiV>(skipped inside<svg>/<math>, whose names are case-sensitive) - Attribute reordering - document-stable, seed-derived order: differs from source yet keeps output gzip/brotli-friendly
- CSS minification - via lightningcss
- CSS selector unicode escaping -
.foobecomes.\66\6f\6f - JS string encoding - string literals encoded with a randomized mix of
\xHH/\uXXXX/\u{..}(strict-mode-safe) - JS minification - comment removal and whitespace compression
- Comment removal and whitespace collapsing - IE conditional comments are preserved; whitespace-only text inside table/select containers (where it never renders) is dropped, everything else collapses to a single space
- Deterministic output - seed-based RNG for reproducible results
These change the DOM, output size, runtime cost, or accessibility, so they are off by default:
- Honeypots / decoys (
--honeypots N) - inject invisible trap links, fake form fields, and decoy article-like prose blocks (dense, link-free filler in content-like class names, so an extractor starved of the real body by--structuralharvests the decoy instead - data poisoning). Hidden from layout and assistive tech, and removed on load by an injected script, so no-JS bulk crawlers take the bait while JS clients get a clean DOM. Vocabulary: editablessukka_core/assets/honeypot/*.txt. - Structural obfuscation (
--structural) - move visible text out of the static markup into adata-attribute, restored client-side by an injected script. The encoding is polymorphic (per-build random attribute name + XOR key + byte order), so no single static decoder recipe works across builds. Resists static scrapers (curl / readability extractors that don't run JS) while rendering identically. Warning: breaks no-JS, SEO, and degrades accessibility. - AST JS engine (
--js-ast, powered by oxc):- Identifier mangling (
--mangle) - scope-aware renaming of local JS bindings (never globals, so cross-script / inline-handler references stay intact). - Poison names (
--poison-names) - rename local bindings to plausible-but-misleading words (cursor,vertex, ...) instead of short ones, so an LLM "clean this up" pass anchors on names it keeps rather than re-deriving the originals. Each name is unique and avoids every identifier already in the script, so nothing is shadowed. - String array (
--js-string-encoding array) - hoist string literals into a per-build shuffled character pool, decoded by (offset-shifted) index at runtime. Uses noatob/String.fromCharCode/TextDecoder, so hook-based deobfuscators have nothing to latch onto (a tool that executes the decoder still recovers the strings). Tune with--reserved-strings <s>(keep listed strings readable) and--string-array-threshold <0..1>(encode only a fraction). - Property keys (
--property-keys) - convert object-literal keys to computed string keys ({foo: 1}becomes{["foo"]: 1}), so with the string array the key names are hoisted and encoded too instead of sitting in plain sight. - Dead code injection (
--dead-code) - opaque-predicate-guarded junk that never executes; predicate and body shapes vary per build so they aren't a fixed signature. - Control-flow flattening (
--cff) - rewrite straight-line statement sequences (the top level and function/arrow block bodies) into awhile/switchstate machine with shuffled cases, a random state variable, and label-based transitions (varbindings hoisted). Correct-by-construction: a block is flattened only when every statement is an expression statement or a simplevardeclaration - no control flow,let/const, or declarations to preserve - so semantics hold; anything else is left intact. - Control-flow virtualization (
--vm) - a stronger alternative to--cffover the same blocks: lift the statements into an op table of arrow-function thunks plus an XOR-encoded bytecode array, run by a generic dispatch loop, so the execution order lives in data (the bytecode) instead of JS control flow. Same correct-by-construction subset; arrow thunks keepthis/arguments. Adds a small per-statement closure cost; when both are set,--vmwins. - MBA (mixed boolean-arithmetic) (
--mba) - replace integer literals with equivalent bitwise/arithmetic expressions (5becomes a(3^6)-style form), so a static or LLM cleanup pass has to do the arithmetic to read the constant. Exact under JS int32 semantics. - Opaque predicates (
--opaque-predicates) - wrap top-level expression statements in always-true guards (if(<opaque>){ stmt }) built from bitwise identities that hold for every input, so real code sits behind a condition the analyzer must evaluate. Skips declarations and directives. - Execution lock (
--domain-lock <hosts>,--lock-expiry <unix-secs>) - inject a guard that crashes the script (unbounded recursion) when run off an allowed host (or its subdomains) or past an expiry, so the obfuscated code can't be lifted to another site or kept working forever. A no-op on the allowed domain before expiry; a deterrent, not DRM (an attacker who strips the guard defeats it). - Self-defending (
--self-defending) - inject a check that disablesconsoleif the script was beautified or tampered with (deters casual beautify-and-run; a deobfuscator that strips the guard defeats it).
- Identifier mangling (
- Polymorphic mode (
--polymorphic) - vary which transforms run (and how) on every invocation, so identical input yields structurally different output each time (signature/cache evasion). With--js-astit also deepens the JS chain per build, stacking a random mix of the (semantics-preserving) AST transforms - and chaining is where LLM cleanup degrades. Ignored when--seedis set. - Watermark (
--watermark <N>) - embed a build/recipient id as invisible zero-width characters in the body text, so a scraped or leaked copy can be traced. The id is scattered as redundant copies across multiple text nodes and recovered by majority vote, so it survives copy-paste and partial deletion (truncation, copying one section). Renders invisibly; may affect screen readers. Reversed only by a Unicode normalizer that strips the zero-width class. - AI opt-out signals (
--ai-opt-out) - inject standards-aligned<meta>opt-out tags into<head>: legacyrobots: noai, W3C TDMRep (tdm-reservation, the EU CDSM Art.4 / AI Act rights-reservation lane), and a best-effort IETF AIPREFContent-Usage: train-ai=n(http-equiv). The canonical AIPREF/TDMRep transports - an HTTPContent-Usageresponse header, arobots.txtrule, and/.well-known/tdmrep.json- are exposed as library helpers (ssukka::ai_opt_out::{meta_block, robots_txt, content_usage_header, well_known_tdmrep_json}) for edge/server deployment. Pass--tdm-policy <url>to also link a machine-readable usage policy via atdm-policymeta (implies--ai-opt-out). A legally recognized signal that bulk crawlers widely ignore on its own; pair it with the in-content deterrents above. - Word splitting (
--comment-split) - insert empty comments inside long runs (about one per 6 chars, so long words and space-free scripts like CJK are fragmented throughout) so naive regex/substring scrapers see broken text, while browsers, screen readers, find-in-page, and content extractors read it intact. Flow content only (never<title>or other RCDATA). - WASM build - runs in the browser / Cloudflare Workers / Deno via the
ssukka-wasmcrate (wasm/).
Every AST transform re-parses its own output and is discarded if it would emit invalid JavaScript; on a parse failure the engine falls back to the token-based path. The transforms are semantics-preserving by construction (verified by executing obfuscated output under Node).
ssukka raises the cost of reading and scraping a page; it is not a security boundary. Anything the browser can render, a determined adversary with a headless browser can recover. Use it to deter casual copying and cheap bulk scraping, not to protect secrets.
Modern LLM-based deobfuscators can reverse simple identifier renaming and string encoding, so the strongest configurations layer transforms (string array + mangling + structural + honeypots) and lean on structural/visual approaches rather than renaming alone. Where renaming is used, --poison-names turns it from a no-op (an LLM just re-derives meaningful names) into a trap that anchors the cleanup pass on misleading names. Benchmark the presets with cargo bench, and measure extraction resistance with two differential harnesses: cargo test --test efficacy (naive/visibility-blind tier) and cargo test --test extraction (readability tier via the dom_smoothie dev-dependency). --structural drops recall to ~0 against both, cosmetic transforms are friction only (~1.0), and --structural + --honeypots poisons the naive tier - it harvests decoy filler instead of the body, while the readability tier drops the hidden decoys.
ssukka performs no network I/O at runtime - verified with strace (zero socket/connect syscalls). All dependencies (lol_html, lightningcss, oxc, rand) are pure Rust and compile from source. External resource inlining (--inline-local-resources) reads local files only and never fetches over the network.
A Cargo workspace; the engine does no I/O, and each front-end is a thin crate over it:
ssukka_core engine (lib): offline, forbid(unsafe). all transforms live here
ssukka facade lib (re-exports ssukka_core) + the CLI binary
ssukka-proxy serve-time reverse proxy (binary, skeleton)
ssukka-wasm wasm-bindgen bindings (cdylib, built for wasm32 only)
The engine is a streaming pipeline built on lol_html:
Input HTML
-> Pass 0 (optional): inline local CSS/JS
-> Pass 1: analyze -> SymbolMap
-> Pass 2: transform HTML / CSS / JS
-> Output HTML
cargo install ssukka # CLI
cargo install ssukka-proxy # serve-time reverse proxyOr build from source:
git clone https://github.com/miniex/ssukka.git
cd ssukka
cargo build --release# Basic
ssukka -i input.html -o output.html
# stdin/stdout
cat input.html | ssukka > output.html
# With options
ssukka -i input.html -o output.html --seed 42 --no-rename --no-minify-css| Flag | Description |
|---|---|
-i, --input <FILE> |
Input HTML file (default: stdin) |
-o, --output <FILE> |
Output file (default: stdout) |
--seed <N> |
Seed for deterministic output |
--no-rename |
Disable class/ID renaming |
--no-minify-css |
Disable CSS minification |
--no-minify-js |
Disable JS minification |
--no-encode-entities |
Disable entity encoding |
--no-shuffle-attrs |
Disable attribute reordering |
--no-randomize-case |
Disable tag case randomization |
--comment-split |
Split long words with empty comments (anti-regex-scraper) |
--js-string-encoding <none|escapes|array> |
JS string strategy (default: escapes) |
--honeypots <N> |
Inject N invisible decoy nodes (scraper traps) |
--structural |
Move text into encoded attrs, restore client-side |
--polymorphic |
Randomize transforms per run (ignored with --seed) |
--js-ast |
Use the oxc AST engine for <script> JS |
--mangle |
Scope-aware local identifier renaming (implies --js-ast) |
--poison-names |
Rename locals to misleading names (implies --js-ast) |
--cff |
Control-flow flattening (implies --js-ast) |
--vm |
Control-flow virtualization, stronger --cff (implies --js-ast) |
--mba |
Encode integer literals as mixed boolean-arithmetic (implies --js-ast) |
--opaque-predicates |
Wrap statements in always-true opaque guards (implies --js-ast) |
--property-keys |
Convert object keys to computed string keys (implies --js-ast) |
--reserved-strings <S> |
Comma-separated strings to keep out of the string array |
--string-array-threshold <0..1> |
Fraction of strings the string array encodes |
--domain-lock <HOSTS> |
Crash the script off these comma-separated hosts (implies --js-ast) |
--lock-expiry <UNIX_SECS> |
Crash the script after this Unix time (implies --js-ast) |
--dead-code |
Opaque-predicate dead code injection (implies --js-ast) |
--self-defending |
Disable console if the script is beautified (implies --js-ast) |
--dead-code-threshold <0..1> |
Fraction of sites that receive dead code |
--watermark <N> |
Embed an invisible zero-width id for provenance |
--ai-opt-out |
Inject AI opt-out <meta> (noai + TDMRep + AIPREF) into <head> |
--tdm-policy <URL> |
Add a TDMRep tdm-policy URL (implies --ai-opt-out) |
--inline-local-resources |
Inline local <link>/<script src> (offline only) |
--base-dir <DIR> |
Base directory for resolving local resources |
# Maximum: layered obfuscation for the strongest output
ssukka -i input.html -o output.html \
--honeypots 8 --structural --mangle --vm --dead-code --mba --opaque-predicates \
--js-string-encoding array// Simple
let result = ssukka::obfuscate(html)?;
// With configuration
let result = ssukka::Obfuscator::builder()
.seed(42)
.rename_classes(true)
.rename_ids(true)
.encode_text_entities(true)
.minify_css(true)
.build()
.obfuscate(html)?;
// Advanced, layered
use ssukka::config::JsStringEncoding;
let result = ssukka::Obfuscator::builder()
.inject_honeypots(true)
.honeypot_count(8)
.structural_obfuscation(true)
.js_ast(true)
.mangle_identifiers(true)
.js_string_encoding(JsStringEncoding::Array)
.dead_code_injection(true)
.control_flow_flattening(true)
.mba(true)
.opaque_predicates(true)
.build()
.obfuscate(html)?;wasm-pack build wasm
# or
cargo build -p ssukka-wasm --release --target wasm32-unknown-unknownExposes obfuscate(html), obfuscate_seeded(html, seed), obfuscate_max(html, honeypots, seed), plus the AI opt-out transport helpers content_usage_header(), robots_txt(), and well_known_tdmrep_json(policy) so a JS/edge middleware can emit the canonical signals. The ssukka-wasm crate is empty off the wasm32 target, so a host build never pulls the wasm toolchain. For Rust + wasm, depend on ssukka_core directly (pure Rust, no I/O); ssukka-wasm is the JS-glue layer, shipped to JS via the npm package (wasm-pack publish) rather than crates.io.
ssukka is a pure string transform, so wire it in wherever you serve HTML - the engine stays offline; the host does the I/O.
-
Build time (static sites): run the
ssukkaCLI over your HTML before deploy. -
Rust server: depend on
ssukka_coreand callObfuscator::builder()...obfuscate(body)in a handler/middleware; emit the HTTP opt-out signals fromssukka_core::ai_opt_out. -
Node / edge: use the
ssukka-wasmmodule (obfuscate,content_usage_header,robots_txt,well_known_tdmrep_json). -
Reverse proxy (any origin): run
ssukka-proxy, which forwards to an HTTP origin, obfuscates HTML responses (polymorphic, per-request), stampsContent-Usage, and serves/robots.txt+/.well-known/tdmrep.json:ssukka-proxy --listen 0.0.0.0:8080 --origin http://127.0.0.1:3000
Docker images: docker build -t ssukka . (CLI), --target ssukka-proxy (proxy), --target wasm -o type=local,dest=pkg . (wasm package). nix build .#ssukka-proxy builds the proxy; nix develop has the wasm toolchain.
- The default JS path (no
--js-ast) is token-based: string encoding + minification only. Enable--js-astfor AST-grade mangling / string arrays / dead code / control-flow flattening. - Dynamic class/ID construction in JS is handled via prefix-detection heuristics on the token path; highly dynamic patterns may not be caught.
- AST control-flow flattening is conservative: it only flattens top-level sequences of simple expression statements (anything with declarations or control flow is left as-is to guarantee correctness).
- External stylesheets/scripts are only processed with
--inline-local-resourcesand only from the local filesystem. - Obfuscation is a deterrent, not security - see Threat model.
ssukka renders identically for every client and never branches on user-agent or IP, so it is not cloaking - unlike tools that serve different content to bots. What it does and does not stop:
- Stops no-JS bulk fetchers and casual copy/paste. Most large AI-training crawlers (GPTBot, ClaudeBot, CCBot, and similar) do not execute JavaScript, so
--structuralcontent stays empty for them. - Does not stop any client that renders JavaScript: headless browsers, on-demand "read this URL" agents, and Google/Gemini all run the restore script and recover the text.
When using the aggressive layers, keep these costs in mind:
- SEO -
--structuralmoves text behind JS. Search engines that render JS (Google) usually recover it on a delayed pass, but unreliable or non-rendering crawlers index an empty shell. Keep SEO-critical copy in the static markup or provide a<noscript>fallback. - Accessibility -
--structuraltext is absent from the accessibility tree until the restore script runs;--watermarkadds zero-width characters that some screen readers announce and that break programmatic text matching. Never apply these to content that must be reliably read by assistive tech. The CLI prints a stderrwarning:for each aggressive option. - Legal -
--ai-opt-outemits the machine-readable signals (legacy robotsnoai, W3C TDMReptdm-reservation, IETF AIPREFContent-Usage) that are the rising, legally-backed opt-out lever - TDMRep is the protocol EU rightsholders point to under the CDSM Directive Art.4 and the AI Act. They are widely ignored on their own, so treat them as complementary to the in-content deterrents, not a replacement. For the HTTP-header / robots.txt /.well-knowntransports, use thessukka::ai_opt_outhelpers at the edge.
Requires Rust >= 1.94 (pinned in rust-toolchain.toml). A Dockerfile and a Nix flake (nix develop / nix build) are also provided. The repo ships POSIX sh tooling:
./tools/format.sh # cargo fmt + shfmt + taplo fmt + prettier (md)
./tools/lint.sh # cargo clippy -D warnings + shellcheck + shfmt -d + taploDecoy/word vocabulary lives in ssukka_core/assets/honeypot/*.txt (comma-separated, #-commented), embedded at build time via include_str! - edit a list and rebuild, no Rust changes and no runtime I/O.
cargo test -p ssukka_core --test obsmith is an OBsmith-style semantics harness: it runs representative snippets through the full JS pipeline and executes the original and obfuscated forms under Node, asserting identical output (skipped if node is absent).
MIT