pkg/appsec/challenge/js/README.md
This directory holds the JavaScript sources that the WAF challenge runtime serves to clients, and the build-time tools that turn them into the artifacts embedded in the crowdsec binary.
End users running make build do not need any of the tools below —
the generated artifacts are committed to the repository and pulled in via
//go:embed. The pipeline is only exercised when someone intentionally
changes the JS sources.
Three pipeline steps, all driven by go:generate directives in
generate.go:
| Step | Tool used | Output | Used at runtime by |
|---|---|---|---|
cmd/bundle | esbuild (Go module) | fpscanner/fpscanner.js and challenge_code.js | fpscanner.js is served as-is; challenge_code.js is the obfuscator input |
cmd/obfuscate | esbuild + javy | obfuscate/index.wasm.gz | wazero to obfuscate the dynamic key module per epoch |
cmd/initialbundle | wazero running the obfuscator wasm | ../initial_bundle.js.gz | Seeded into the challenge cache at startup so the service is ready instantly |
The client JS is split into two bundles:
fpscanner/fpscanner.js — the public fingerprint scanner
(fpscanner/src/*, entered via fpscanner/global.js), built as a minified
IIFE that assigns globalThis.CrowdsecFingerprintScanner. It is served
unobfuscated at ChallengeFPScannerPath via a plain <script src> tag
(cacheable across challenge pages). fpscanner is public open-source code, so
there is nothing to hide and no reason to spend CPU obfuscating it.challenge_code.js — the crypto/glue (../challenge.js: SHA-256, HMAC,
PoW, fingerprint obfuscation, submission, and the hook registration). This is
the obfuscation input: cmd/initialbundle substitutes the internal-path
placeholders and obfuscates it into ../initial_bundle.js.gz, which the
runtime injects inline on the challenge page. The challenge code reads the
scanner from globalThis.CrowdsecFingerprintScanner at use time.There is no runtime re-obfuscation of the challenge code — the build-time
initial_bundle.js.gz is the single static variant served. Only the sensitive
per-epoch key module is re-obfuscated at runtime (see ../dynamic_module.go).
obfuscate/obfuscate.js is a small driver around the vendored
obfuscate/javascript-obfuscator.js (≈1.6 MB, kept in tree so we don't
pull from npm at build time). The driver reads source code from stdin,
runs JavaScriptObfuscator.obfuscate(...) with the high-obfuscation
preset (plus a reservedStrings entry for the __CSEC_CHALLENGE_HOOK_v1__
sentinel that bridges the static and dynamic bundles), and writes the
obfuscated code to stdout. javy then compiles that driver to a WASM
module.
make generate-challenge-js
Or directly:
go generate ./pkg/appsec/challenge/js/...
Both invocations require javy (the Bytecode Alliance JS-to-WASM
compiler) to be on PATH. There is no apt/brew package; install via
the upstream releases:
curl -sL -o javy.gz \
https://github.com/bytecodealliance/javy/releases/download/v8.1.1/javy-x86_64-linux-v8.1.1.gz
gunzip javy.gz && chmod +x javy && mv javy /usr/local/bin/
(Substitute the platform suffix for macOS/Windows.) javy is
build-time only — it is not a runtime dependency of crowdsec.
Regeneration is fast now: only the small challenge_code.js (~4 KB) goes
through the obfuscator's high-obfuscation preset; the bulk of the client
JS (fpscanner, ~33 KB) is served raw and is never obfuscated.
javyis only needed bycmd/obfuscate(it rebuildsindex.wasm.gz). If you only changedchallenge.jsor the fpscanner sources, you can skip javy and regenerate just the two bundles:shcd pkg/appsec/challenge/js && go run ./cmd/bundle && go run ./cmd/initialbundle
Run make generate-challenge-js (or the two-step command above) after
changing any of:
pkg/appsec/challenge/challenge.jspkg/appsec/challenge/dynamic_module.js.tmplpkg/appsec/challenge/js/fpscanner/global.jspkg/appsec/challenge/js/fpscanner/src/**/*.tspkg/appsec/challenge/js/obfuscate/obfuscate.jspkg/appsec/challenge/js/obfuscate/javascript-obfuscator.js
(only when intentionally upgrading)The pipeline output bytes (fpscanner/fpscanner.js, challenge_code.js,
index.wasm.gz, initial_bundle.js.gz) are committed so a clean git clone
builds and runs without javy.
The __CSEC_CHALLENGE_HOOK_v1__ literal must appear verbatim in both
the obfuscated challenge code (initial_bundle.js.gz) and any dynamic key
module generated at runtime, so the two bundles meet at the same globalThis
key after independent obfuscation passes. The
reservedStrings: ["__CSEC_CHALLENGE_HOOK_v1__"] option in
obfuscate/obfuscate.js is what guarantees this. If you change the
sentinel string, change it in all three places:
obfuscate/obfuscate.js — reservedStringspkg/appsec/challenge/challenge.js — CSEC_HOOK_NAMEpkg/appsec/challenge/dynamic_module.js.tmpl — hookNameThe Go-side regression test
TestSplitBundle_HookSentinelInBakedBundle will fire on a mismatch.