Skip to content

feat(cli): port CRS 942100/941100 on libinjection compiled to WebAssembly - #239

Merged
ralyodio merged 2 commits into
masterfrom
feat/crs-libinjection-wasm
Sep 25, 2026
Merged

ralyodio merged 2 commits into
masterfrom
feat/crs-libinjection-wasm

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

What

This ports CRS 942100 (@detectSQLi) and 941100 (@detectXSS). The CRS port (#222) skipped both because they need libinjection. They now run on libinjection itself, compiled to WebAssembly and shipped in the npm package. A bare 1' OR 1=1 or admin'-- in a query argument used to score 0. Now it's banned.

  • Vendored: apps/cli/vendor/libinjection holds libinjection v4.0.0 (commit 2117822, BSD-3-Clause), unmodified: src/ library files, COPYING, and a subset of upstream test vectors. This is exactly the commit ModSecurity v3 master pins as its others/libinjection submodule.
  • Build: scripts/build-libinjection-wasm.mjs (pnpm --filter @profullstack/threatcrush libinjection:build) compiles the vendored sources and a small interface file, scripts/libinjection-wasm.c, with zig 0.16.0. It targets wasm32-wasi in the reactor model with -O ReleaseFast -fstrip -DNDEBUG, and zig's wasi-libc is linked statically. The result, src/core/crs/libinjection/libinjection.wasm (180,356 bytes, sha256 80ec3107…2f33), imports nothing; the script refuses to write a module that does. NDEBUG compiles out libinjection's assert()s, which would otherwise pull in WASI fd_write/proc_exit. The script requires exactly zig 0.16.0 (ZIG_VERSION).
  • --check: rebuilds and compares the hash, the same idea as the CRS generator's --check. A new CI job, libinjection.wasm is reproducible, installs zig 0.16.0 (mlugg/setup-zig@v2) and runs it. Building and testing the CLI still needs no C toolchain. I rebuilt from a copy of the tree in another directory with an empty zig global cache and got the same hash.
  • Wrapper: src/core/crs/libinjection.ts exposes detectSQLi(bytes) → { result, fingerprint } and detectXSS(bytes) → result. Both take byte strings, one char per byte, as the engine uses everywhere. The module is compiled and instantiated synchronously (new WebAssembly.Module(bytes)) when a CrsEngine is built. It lives at libinjection/libinjection.wasm next to libinjection.ts in src/, and next to the bundle in dist/ (tsup copies it together with COPYING), so a single __dirname-relative path works in both places. files in package.json now includes dist/libinjection/libinjection.wasm and dist/libinjection/COPYING.
  • Generator: @detectSQLi/@detectXSS are now supported operators, so both rules go through the generator with CRS's own targets and transformations:
    • 942100: REQUEST_HEADERS:User-Agent|REQUEST_HEADERS:Referer|ARGS_NAMES|ARGS, t:utf8toUnicode,t:urlDecodeUni,t:removeNulls, multiMatch
    • 941100: REQUEST_HEADERS:User-Agent|ARGS_NAMES|ARGS, t:utf8toUnicode,t:urlDecodeUni,t:htmlEntityDecode,t:jsDecode,t:cssDecode,t:removeNulls
    • Cookies and XML are dropped, as for every other rule.
    • Counts: 101 PL1 rules, 97 ported (was 95). The only remaining skips are the 4 whose targets an access log can't see.
  • Engine: Like ModSecurity v3 (isMaliciousLibinjectionResult), libinjection's ERROR result counts as a match (fail-safe). It never came up in any corpus below.
  • Docs: the public count goes from 94 to 96 OWASP CRS rules (97 ported minus the default-off 941130) in README, CLI README, landing page and deck. doc-claims.test.ts derives the number. auto-defence.md now describes both rules and libinjection's known false-positive shapes. NOTICE mentions libinjection and where its licence ships.

No new npm dependencies.

libinjection's own test vectors

Suite (upstream harness) Run against Result
tests/test-sqli-*.txt (testdriver type 2: fingerprint) shipped libinjection.wasm via the TS wrapper 50/50
tests/test-{tokens,folding,html5}-*.txt testdriver.c + the same sources, same zig 0.16.0 flags (wasm32-wasi -O ReleaseFast -DNDEBUG), under node:wasi; these tests need tokenizer internals the shipped module doesn't export 534/534 (tokens 249, folding 118, html5 167)
data/sqli-*.txt (reader -i -m 18) shipped module 85,802 inputs, 17 misses (upstream allows 18): pass
data/false_positives.txt (reader -m 21) shipped module 423 inputs, 21 flagged (upstream allows 21): pass
data/xss* (reader -t -i -x -m 20) shipped module 81,417 inputs, 20 misses (upstream allows 20): pass
wasm vs a native x86_64 build of reader.c per input 167,642/167,642 verdicts identical

The total is 584/584 of tests/, with all three sample suites passing at upstream's thresholds. Committed as src/core/crs/__tests__/libinjection.test.ts (BSD-3-Clause permits it, with COPYING vendored alongside): all 50 test-sqli vectors, plus false_positives.txt and 7 smaller sample files, checked against upstream's thresholds. There's also a test for bytes above 0x7f and for 100 KB inputs, which grow the module's memory.

Real-log replay, before vs after

The corpus is much smaller than earlier PRs'. The user's sudo setup script ran logrotate -f /etc/logrotate.d/nginx 27 times between 17:15 and 17:35 today, so access.log.1…14.gz now cover about 20 minutes and the two weeks of history earlier PRs replayed (212k requests) is gone; no copy exists. I replayed everything left in /var/log/nginx: the current access.log and its rotations, plus every vhost's access logs (alt.2600.* back to Sep 11, seo.rank.*, moshpit-parking.*, chovy.hacker.*, auto.hacker.*). The replay goes through the real log-parser (parseNginxLog → assessNginxRequest → attackSeverity), origin/master vs this branch.

Files / lines / parsed requests 79 / 24,323 / 18,566 (the unparsed rest is binary/TLS junk nginx logs as 400)
Requests at ban level 9,734 before, 9,734 after
Requests newly at ban level 0
Ban-level IPs 224 before, 224 after; 0 new
Requests that dropped below the threshold 0
Requests where 942100/941100 matched at all 40, all 941100

I hand-classified all 40 (40 distinct request shapes): one scanner, 45.148.10.95, probing 40 credential paths (/.aws/credentials, /aws.yml, /credentials.json, /terraform/aws.tf…) with ?id=%00&exemple=<svg/onload=confirm(1)>. All 40 are attacks, and every one was already at ban level through 941120/941160/941390. 0 legitimate requests are affected.

Synthetic benign corpus (to make up for the thin log set)

These target libinjection's known false-positive shapes. The replay uses both bundles and puts each value in the Referer too.

  • 500 requests: 125 values × 4 request shapes (/search?q= percent-encoded and +-encoded, a JSON-ish filter=, name=). The values cover apostrophes in names and search terms (O'Brien, D'Angelo, L'Oréal, McDonald's, Rock 'n' Roll, don't stop believin', Guns N' Roses, '90s, 5' 10"…), quotes ("exact phrase", she said "yes"), SQL words in prose (where is george, union station, select all, 1 or 2, true or false, drop table saw…), JSON in parameters ({"status":"open","n":1}, {"q":"O'Brien"}, [1,2,3]…), and odds and ends (C++ vs C#, AT&T, #hashtag, emails, dates, non-ASCII).
  • 92 realistic requests: 59 everyday query strings (utm/fbclid/gclid, pagination, sort/fields, OAuth state/code, JWTs, redirect=, phone numbers, addresses, filter[status]=open…), 24 common User-Agents (browsers, Googlebot, bingbot, curl, python-requests, facebookexternalhit, Slackbot…) and 9 Referers (Google, DuckDuckGo, t.co, HN, Reddit, android-app…).

Result: everything with apostrophes, quotes, SQL prose, JSON, User-Agents and Referers scores exactly as before. 8 synthetic requests (2 values) newly reach ban level:

Value libinjection fingerprint Why
50% off -- today only 1c number, then a -- comment
item /* note */ nc word, then a /* */ comment

Probing further: a number directly followed by -- (2019 -- 2020, 10--20, 3 -- 4) gets 1c, while page 10--20, a -- b, Spider-Man -- Homecoming, sale -- 50% off and /* hi */ don't match. This is what libinjection does under ModSecurity + CRS too. None of these shapes appeared in the real logs, so I haven't excluded anything. auto-defence.md names the shapes and the CRS-sanctioned remedy (exclude_rules = [942100], i.e. SecRuleRemoveById) for a site whose visitors search for -- number ranges.

Detection gain

These are libinjection's own sample inputs sent through the whole engine as a query argument:

  • SQLi at threshold: 75,718 → 85,786 of 85,802
  • XSS at threshold: 81,351 → 81,381 of 81,417 (941100 mostly adds points where the regex rules already fire)

Throughput (real logs, 24,370 lines)

Both bundles run in one process, in alternating passes, each pass with a fresh engine and an empty memo, 25 passes each, measured in CPU time. The machine was loaded (load average ~13), so wall-clock numbers were noisy.

median lines per CPU-second p25–p75
before 46,911 45,734–48,023
after 42,447 40,439–43,289

That's about 10% slower. Engine construction, including compiling the wasm, took ~50 ms both before and after (fresh-process median of 21 runs; no difference within noise).

Verification

  • pnpm --filter @profullstack/threatcrush test: 31 files, 360 tests pass. tsc --noEmit is clean. build-crs-rules.mjs --check and build-libinjection-wasm.mjs --check pass.

  • Failing before, passing after: with origin/master's rule table, log-parser.test.ts fails "bans tautology and comment SQLi that only libinjection sees (CRS 942100)" and "scores XSS libinjection finds (CRS 941100) …". Both pass on this branch. Those tests cover 1' OR 1=1 (percent-encoded and nginx's \x27), admin'--, 1 or 2 1.e/1, SQLi in the User-Agent and the Referer, and "><svg onload=alert(1)> in an argument, an argument name and the User-Agent. A third test keeps O'Brien, quotes, SQL prose and JSON (with the same URL as Referer) at score 0.

  • From dist/: I ran pnpm --filter @profullstack/threatcrush build and then node apps/cli/dist/index.js monitor -m log-watcher against the live /var/log/nginx/access.log, and sent three requests to the local nginx:

    • GET /login?user=1%27%20OR%201=1: [HIGH] Attack detected [SQLI]
    • GET /login?user=admin%27--: [HIGH] Attack detected [SQLI]
    • GET /search?q=O%27Brien: INFO … → 301

    That shows the wasm resolving from dist/libinjection/. npm pack --dry-run lists dist/libinjection/libinjection.wasm (180.4 kB) and dist/libinjection/COPYING.

Residual risk

  • Known libinjection FPs (above): a number followed by --, or a word followed by /* … */, in an argument, argument name, User-Agent or Referer, now bans. None occurred in the real traffic available, but that traffic is thin (see above). Operators can set exclude_rules = [942100].
  • The real-log sample is small and attack-heavy: 18.5k requests, more than half already at ban level. The synthetic corpus covers the known FP shapes, but it isn't real visitor traffic. The replay is worth repeating once a normal week of logs has accumulated.
  • About 10% lower log throughput.
  • NDEBUG removes libinjection's internal assert()s. The ones in v4.0.0 guard invariants (my_memmem arguments, pos >= 3), not input validation, and every upstream vector passes.
  • The CI reproducibility job depends on mlugg/setup-zig being able to download zig 0.16.0.

@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

12 finding(s)

HIGH/CRITICAL: 1 | MEDIUM: 6 | LOW: 5

Severity Rule Location
HIGH secret-aws-access-key prd/0003-detect-hardcoded-secrets-before-they-are-committed-or-served.md:126
MEDIUM js-open-redirect apps/web/src/app/auth/login/page.tsx:50
MEDIUM js-unescaped-html-sink apps/web/src/app/hire/page.tsx:104
MEDIUM js-unescaped-html-sink apps/web/src/app/hire/page.tsx:108
MEDIUM js-open-redirect apps/web/src/components/funding/FundingClient.tsx:97
MEDIUM js-unescaped-html-sink apps/web/src/components/GuideReader.tsx:265
MEDIUM js-uninitialized-buffer packages/scan/src/node-rules.ts:456
LOW secret-generic-credential PRD.md:269
LOW tls-verification-disabled prd/0004-find-dangerous-code-patterns-without-pretending-to-be-a-compiler.md:121
LOW tls-verification-disabled prd/0004-find-dangerous-code-patterns-without-pretending-to-be-a-compiler.md:122
LOW sh-remote-script-execution scripts/smoke-test.sh:47
LOW secret-aws-access-key scripts/smoke-test.sh:112

Snippets are redacted; ThreatCrush never prints matched credential material.

ralyodio added a commit that referenced this pull request Sep 25, 2026
… wording

SURFACES.md picks up the browser-extension (#240) and libinjection/WASM (#239)
status lines and the extension-store blockers; RELEASE_STATUS.md lists the
store accounts. The homepage now says unsigned macOS/Windows builds may be
blocked, not only warned about, on first launch.
…Assembly

CRS's @detectSQLi (942100) and @detectXSS (941100) were skipped because they
need libinjection. Vendor libinjection v4.0.0 (BSD-3-Clause, the commit
ModSecurity v3 builds against) unmodified, compile it with zig 0.16.0 to a
self-contained wasm32-wasi reactor module (no imports) through
scripts/build-libinjection-wasm.mjs, and check the module in so building
needs no C toolchain. `--check` rebuilds and compares the hash; a new CI job
runs it.

The generator now ports both rules with CRS's own targets and
transformations, so a bare `1' OR 1=1` or `admin'--` is banned. The module
ships in dist/libinjection/ with its licence and resolves from the bundle.
Public rule counts move from 94 to 96 via the doc-claims mechanism.
@ralyodio
ralyodio force-pushed the feat/crs-libinjection-wasm branch from 29bce1f to 8d0cc93 Compare September 25, 2026 18:25
ralyodio added a commit that referenced this pull request Sep 25, 2026
… wording

SURFACES.md picks up the browser-extension (#240) and libinjection/WASM (#239)
status lines and the extension-store blockers; RELEASE_STATUS.md lists the
store accounts. The homepage now says unsigned macOS/Windows builds may be
blocked, not only warned about, on first launch.
@ralyodio
ralyodio merged commit 3d32354 into master Sep 25, 2026
12 checks passed
ralyodio added a commit that referenced this pull request Sep 25, 2026
…refresh release docs (#238)

* ci(desktop): sign and notarize when secrets exist; declare libgbm1/ALSA in the deb

- scripts/desktop-signing-env.sh exports electron-builder's signing variables
  only for secrets that are set (APPLE_CERTIFICATE[_PASSWORD], APPLE_API_KEY/
  _KEY_ID/_ISSUER or APPLE_ID/APPLE_APP_SPECIFIC_PASSWORD/APPLE_TEAM_ID,
  WINDOWS_CERTIFICATE[_PASSWORD]); without them builds stay unsigned
- drop notarize: false so notarization follows the credentials
- package (never publish) the desktop matrix on PRs touching desktop packaging
- deb.depends adds libgbm1 and libasound2t64 | libasound2: the published deb
  failed on minimal installs with libgbm.so.1 missing
- docs: desktop packaging has been green since v0.13.2; refresh release,
  surface and mobile status from gh evidence
- web: Download Desktop links pointed at a 404 /releases; state that
  macOS/Windows builds are unsigned

* ci(desktop): leave pull-request packaging runs unsigned

electron-builder already skips macOS signing on PRs (CSC_FOR_PULL_REQUEST);
treat Windows the same so PR runs never decode the certificates, and say so
in the job summary instead of claiming signing is on.

* docs: record extension and libinjection status; soften unsigned-build wording

SURFACES.md picks up the browser-extension (#240) and libinjection/WASM (#239)
status lines and the extension-store blockers; RELEASE_STATUS.md lists the
store accounts. The homepage now says unsigned macOS/Windows builds may be
blocked, not only warned about, on first launch.

* docs: mark the extension page checks (#240) as merged in surface and release status
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant