Red-team catalog reference: profiles, raw-protocol attacks and verdict classification
Quadrant: Reference (lookup tables). Audience: Red-team developers needing the authoritative catalog map and the profile contract.
This page is the authoritative map of the Red catalog: the profile contract every .mjs file honours, the label rule that decides whether a sample counts as automated or as human baseline, the verdict-classification table the e2e runner applies, the 47-profile catalog (45 must-block bots + 1 detection-ceiling + 1 baseline), and the seven raw-protocol attacks the Go cmd/redteam client constructs. It is a lookup reference, not a build guide — to author a new profile, see Write a Red profile; for how these pieces fit together, see Red catalog architecture.
Important: This catalog is purely defensive, local-only and self-target-only. Everything here exercises your own detector’s test catalog against your own loopback server; it teaches understanding and extending that catalog, never third-party evasion. Read the Red-team rules of engagement before running anything — that page carries the guardrail these profiles operate under (loopback target hard-coded, no external hosts). Blocked traffic here is “automated” / “not verified as human”. Every verdict named below is reference-measured on your machine, not a guarantee: T4-class adversaries (anti-detect plus real-human click-farms) remain a design boundary mitigated by rate and reputation, and the heuristic rules HR-11 / HR-12 / HR-17 can challenge some real humans.
The profile contract
Every profile is an ES module under test/redteam/ that exports three things. The shape is fixed because test/e2e/runner.mjs imports the module and reads exactly these fields.
| Export | Type | Meaning |
|---|---|---|
run(baseURL) |
async function returning {verdict, riskScore, hardRuleFired, topContributors} |
Drives one attack against baseURL and returns exactly what POST /api/collect returns — the runner reads verdict, riskScore, hardRuleFired, and topContributors[].id. |
label |
const string |
Classification label. Must start with bot: for an automated sample (see below). |
needsBrowser |
const boolean |
true if the profile launches a real browser via _driver.mjs; false if it POSTs a spoofed report or shells a raw attack. |
The returned object is the /api/collect response verbatim: verdict is one of ALLOW | CHALLENGE | DENY, riskScore is 0–100, hardRuleFired is the winning hard-rule id or empty, and topContributors is the ranked {id, score} list. The runner records topContributors.map(c => c.id) into results.json.
Helper modules _bin.mjs (wraps the Go cmd/redteam binary) and _driver.mjs (launches the browser) export no run() and are not profiles.
The bot: label rule
classify() decides whether a sample is automated or human baseline solely from the label prefix:
- A label starting with
bot:is scored as automation. - The only non-bot label is
human(inhuman.mjs), the human baseline.
Warning: Getting the label wrong silently flips a profile into the human baseline. A profile that forgets the
bot:prefix is treated as a human sample: its DENY becomes a false positive and its ALLOW becomes a true negative, quietly corrupting the reported rates. There is no error — the mislabel just changes which column the sample lands in.
Verdict classification
classify(label, verdict) in runner.mjs maps each result to one of TP / FN / FP / TN, plus the two detection-ceiling classes CEILING / CEIL-CAUGHT. This mirrors the source exactly:
const denied = verdict === 'DENY' || verdict === 'CHALLENGE';
if (label.startsWith('ceiling:')) return denied ? 'CEIL-CAUGHT' : 'CEILING';
if (label.startsWith('bot:')) return denied ? 'TP' : 'FN';
return verdict === 'DENY' ? 'FP' : 'TN';
| Sample kind | Verdict | Outcome |
|---|---|---|
bot (label starts bot:) |
CHALLENGE or DENY | TP (caught) |
| bot | ALLOW | FN (missed) |
ceiling (label starts ceiling:) |
ALLOW | CEILING (the honest detection ceiling — the expected result, not a miss) |
| ceiling | CHALLENGE or DENY | CEIL-CAUGHT (a bonus catch over the stated ceiling) |
human (label = human) |
DENY | FP (wrongly denied) |
| human | ALLOW or CHALLENGE | TN |
Note: A
ceiling:label is not a must-block bot. The single T4native_coherent_ceilingprofile is the documented detection ceiling: ALLOW is the honest expected result, priced via the attested / ceiling-guard mechanism (rate limiting plus cross-session reputation), not a bypass. A CHALLENGE or DENY on it is a bonus, not the pass condition.
Note: A human CHALLENGE is scored TN, not FP. So
humanFPRis a DENY-only metric — it under-reports human friction, because a human sent to a proof-of-work interstitial does not count against it. Inspect the challenge-rate on the baseline separately; do not read a cleanhumanFPRas “zero human friction”.
The 47-profile catalog
The catalog is 47 profiles = 45 must-block bots + 1 detection-ceiling + 1 baseline. Rows are in runner.mjs PROFILES order. Expected HR is the hard rule the profile is designed to trip; where the ground-truth mapping does not pin a specific rule, the profile is still expected to score TP (CHALLENGE or DENY) but the exact rule is marked score-driven / for verification.
The catalog is organised as the T0–T4 attacker cost-escalation ladder defined in test/e2e/tiers.mjs — from the cheapest trivial script (T0) up to a funded real-engine adversary (T3) and the honest detection ceiling (T4). assertCoverage() in runner.mjs fails loudly if this catalog and the tier ladder drift. The raw-protocol attacks are a parallel axis that eighteen .mjs catalog profiles shell into via _bin.mjs.
flowchart TD
CAT["47-profile catalog (test/redteam/*.mjs) · 45 must-block bots + 1 detection-ceiling + 1 baseline"]
CAT --> BASE["Baseline · 1"]
CAT --> T0["T0 trivial · 13"]
CAT --> T1["T1 low · 12"]
CAT --> T2["T2 moderate · 12"]
CAT --> T3["T3 high · 8"]
CAT --> T4["T4 ceiling · 1 (ALLOW by design)"]
RAW["Raw-protocol attacks · 18 -attack values (cmd/redteam)"]
T0 -. "e.g. tls-static" .-> RAW
T1 -. "e.g. flood" .-> RAW
T3 -. "e.g. distributed" .-> RAW
Node counts sum to the catalog total: 1 + 13 + 12 + 12 + 8 + 1 = 47.
Baseline
The single must-not-block sample. A DENY here is a false positive.
| File | label |
needsBrowser |
Tell reproduced | Expected verdict / HR |
|---|---|---|---|---|
human.mjs |
human |
true | Real browser drive (installed Edge / Playwright Firefox), genuine interaction | ALLOW (no hard rule) |
T0 · trivial ($0, a script, no browser)
Non-browser HTTP/uTLS clients, header/token tricks, and the cheapest behaviour tells. Blue’s expectation: reliable.
| File | label |
needsBrowser |
Tell reproduced | Expected verdict / HR |
|---|---|---|---|---|
nonbrowser_ua.mjs |
bot:nonbrowser-ua |
false | Bare HTTP-library UA (x.non_browser_ua) |
DENY / HR-10 |
http_client.mjs |
bot:http-client |
false | Browser UA but zero client-side (WASM/JS) evidence — HTTP parrot | DENY / HR-18 |
tls_parrot.mjs |
bot:tls-parrot |
false | uTLS Chrome ClientHello parrot; browser UA, no JS residual | DENY / HR-18 |
tls_static.mjs |
bot:tls-static |
false | Static parrot: byte-identical ClientHello every connection, no extension permutation (l5.traffic.tls_no_permutation) |
DENY / HR-14 |
sec_chua_absent.mjs |
bot:sec-chua-absent |
false | Chrome UA without Sec-CH-UA (x.uach_present) |
CHALLENGE/DENY (score-driven) |
sec_fetch_absent.mjs |
bot:sec-fetch-absent |
false | Chrome UA without Sec-Fetch-* (sec_fetch_missing) |
CHALLENGE/DENY (score-driven) |
rit_absent.mjs |
bot:rit-absent |
false | API call with no RIT token | CHALLENGE / HR-17 |
rit_replay.mjs |
bot:rit-replay |
false | RIT token replay — stale counter (l5.rit.stale_replay) |
CHALLENGE / HR-17 |
rit_tamper.mjs |
bot:rit-tamper |
false | RIT body tamper — HMAC over observed body fails (l5.rit.header_tampered) |
DENY / HR-16 |
ua_rotate.mjs |
bot:ua-rotate |
false | Mid-session User-Agent rotation (l5.traffic.ua_rotation) |
CHALLENGE/DENY (score-driven; not a pinned hard rule) |
xff_spoof.mjs |
bot:xff-spoof |
false | Forged private X-Forwarded-For (l5.header.forwarded_private) |
CHALLENGE/DENY (score-driven) |
behavior_no_interaction.mjs |
bot:no-interaction |
false | Zero interaction over the window | CHALLENGE / HR-12 |
behavior_untrusted.mjs |
bot:untrusted-events |
false | isTrusted=false injected events (l4.event.untrusted) |
CHALLENGE/DENY (score-driven) |
T1 · low ($, off-the-shelf tools on defaults)
Naive automation frameworks, single-axis TLS/engine churn, and cheap resource/DoS abuse. Blue’s expectation: reliable.
| File | label |
needsBrowser |
Tell reproduced | Expected verdict / HR |
|---|---|---|---|---|
selenium.mjs |
bot:selenium |
true | cdc_ artifacts + webdriver planted via addInitScript |
DENY / HR-1 |
puppeteer.mjs |
bot:puppeteer |
true | Headless token + webdriver | DENY / HR-7 |
playwright_plain.mjs |
bot:playwright |
true | Headless + webdriver | DENY / HR-7 |
undetected.mjs |
bot:undetected-chromedriver |
true | Headless, webdriver stripped | DENY / HR-7 |
direct_cdp.mjs |
bot:direct-cdp |
true | Raw CDP driver (CDP leak) | DENY / HR-9 |
tls_rotate.mjs |
bot:tls-rotate |
false | Mid-session TLS engine rotation (l5.traffic.engine_rotation) |
DENY / HR-14 |
ja4_churn.mjs |
bot:ja4-churn |
false | 3+ distinct JA4 fingerprints in one session | DENY / HR-14 |
grease_absent_js.mjs |
bot:grease-absent-js |
false | No-GREASE Go TLS + JS (grease_absent + x.ua_vs_ja4) |
CHALLENGE/DENY (score-driven) |
video_scrape.mjs |
bot:video-scrape |
false | Media Range-storm on a heavy resource | DENY / HR-14 |
watermark_strip.mjs |
bot:watermark-strip |
false | Resource leak + metadata strip (forensic trace) | DENY / wm-traced (forensic trace) |
flood.mjs |
bot:flood |
false | Application-layer request flood (l5.abuse.flood) |
DENY / HR-21 |
rapid_reset.mjs |
bot:h2-rapid-reset |
false | HTTP/2 Rapid Reset DoS, CVE-2023-44487 (l5.h2dos.rapid_reset) |
DENY / HR-21 |
T2 · moderate ($$, stealth / rotation / fingerprint-spoof / humanizers)
Stealth-patched automation, multi-axis rotation, fingerprint-spoof contradictions, and mouse/keystroke humanizers. Blue’s expectation: reliable.
| File | label |
needsBrowser |
Tell reproduced | Expected verdict / HR |
|---|---|---|---|---|
puppeteer_stealth.mjs |
bot:puppeteer-stealth |
true | Patched native toString (L3 integrity) |
DENY / HR-8 |
playwright_stealth.mjs |
bot:playwright-stealth |
true | Patched natives (L3) | DENY / HR-8 |
patchright.mjs |
bot:patchright |
true | Console API disabled (l3.guard.console_disabled) |
DENY / HR-9 |
multi_axis_rotate.mjs |
bot:multi-axis-rotate |
false | UA + TLS rotate together | DENY / HR-14 or HR-15 |
adv_webgpu_mismatch.mjs |
bot:webgpu-mismatch |
false | WebGL vs WebGPU vendor contradiction (l2.adv.webgpu_mismatch) |
CHALLENGE/DENY (score-driven) |
headless_ua_token.mjs |
bot:headless-ua-token |
false | HeadlessChrome UA token + a second tell | CHALLENGE/DENY (score-driven) |
signal_forgery.mjs |
bot:signal-forgery |
false | Forged l7.pass.solved / l7.pow.solved — stripped, no ALLOW (round-3 provenance) |
CHALLENGE |
behavior_machine_keystroke.mjs |
bot:machine-keystroke |
false | Sub-25ms machine typing (l4.key.machine_speed) |
CHALLENGE/DENY (score-driven) |
behavior_teleport_click.mjs |
bot:teleport-click |
false | Clicks with no approach trajectory (l4.mouse.click_no_trajectory) |
CHALLENGE/DENY (score-driven) |
behavior_bezier_mouse.mjs |
bot:bezier-mouse |
false | Pathologically smooth ghost-cursor path (l4.mouse.*) |
CHALLENGE/DENY (score-driven) |
behavior_fixed_typing.mjs |
bot:fixed-typing |
false | Fixed-interval typing, near-zero variance (l4.key.*) |
CHALLENGE/DENY (score-driven) |
ai_burst_silence.mjs |
bot:ai-burst-silence |
false | LLM inference-loop cadence (l4.agent.burst_silence) |
CHALLENGE/DENY (score-driven) |
T3 · high ($$$, real engine + AI + proxy infrastructure)
Real-engine anti-detect (nodriver / camoufox / xvfb / anti-detect), full AI-agent cadence, and residential-proxy-rotation correlation. Blue’s expectation: degrades gracefully — scores and challenges or denies at lower confidence.
| File | label |
needsBrowser |
Tell reproduced | Expected verdict / HR |
|---|---|---|---|---|
nodriver.mjs |
bot:nodriver |
true | Headful frontier — behaviour only, no display tells | CHALLENGE / HR-12 |
xvfb_headful.mjs |
bot:xvfb-headful |
true | Headful, no display tells — behaviour | CHALLENGE / HR-12 |
antidetect.mjs |
bot:anti-detect-browser |
true | Coherent spoof + behaviour | CHALLENGE / HR-12 |
camoufox.mjs |
bot:camoufox |
true | Firefox fork (Playwright Firefox stand-in) | CHALLENGE / HR-12 |
ai_agent.mjs |
bot:ai-agent |
true | LLM browser-agent cadence — teleport click, no trajectory | DENY / HR-20 |
ai_full_cadence.mjs |
bot:ai-full-cadence |
false | Teleport + burst-silence + machine keystroke fused | DENY / HR-20 |
distributed.mjs |
bot:distributed-proxy |
false | Rotating residential-proxy pool — one fingerprint across many subnets (l5.correlation.proxy_rotation) |
DENY / HR-19 |
privacy_evasion.mjs |
bot:privacy-evasion |
false | Proxy-rotation + forged adBlock/GPC — still HR-19 (round-5) | DENY / HR-19 |
T4 · very high — the detection ceiling
A fully coherent BotBrowser-class engine spoof (or genuine human behaviour on a real engine): not separable by detection alone. Blue’s honest expectation: NOT solved. The coherent case scores ALLOW; it is priced only by rate limiting and cross-session reputation (the attested / ceiling-guard mechanism), not caught by a signal. This single profile carries the ceiling: label, so its ALLOW is the expected honest result, not a false negative.
| File | label |
needsBrowser |
Tell reproduced | Expected verdict / HR |
|---|---|---|---|---|
native_coherent_ceiling.mjs |
ceiling:native-coherent |
false | Fully-coherent Chrome-TLS engine spoof | ALLOW by design — the documented detection ceiling, priced via the attested / ceiling-guard mechanism, not a bypass |
Note: The expected verdict and hard rule in these tables are reference-measured from the bundled catalog run against the local engine; run self-validation to reproduce them for your build. A hard rule fires on a specific tell (for example HR-7 on a headless browser that also exposes
webdriver), so it is stable across environments; the exact risk score is not, and a profile whose tell is score-driven rather than rule-driven can land on a different hard rule if signal availability changes.
Raw-protocol attacks (cmd/redteam)
The Go cmd/redteam client drives evasions a browser cannot: it controls the TLS ClientHello (via uTLS) and RIT tokens precisely, and it spoofs the client-hint / sec-fetch headers a real Chrome sends so the L6 header cross-checks stay quiet — isolating the one bypass under test. It exposes eighteen real -attack values.
Note: These are eighteen values the
-attackswitch incmd/redteam/main.goimplements; the table below documents a representative seven. The full set also includesprivacy-evasion,signal-forgery,nonbrowser-ua,sec-chua-absent,sec-fetch-absent,rit-absent,ja4-churn,multi-axis-rotate,grease-absent-js,coherent-ceiling, andxff-spoof. (HTTP/2 Rapid Reset is exercised by therapid_resetnode profile, not this binary.)
-attack |
What it constructs | Signal targeted | Hard rule |
|---|---|---|---|
flood |
90 rapid POST /api/collect requests from one TLS fingerprint, each carrying JS-execution evidence (so the flood signal, not browser_no_js, is the clean catch) |
l5.abuse.flood |
HR-21 |
distributed |
One fixed device fingerprint across four different exit IPs (X-Forwarded-For); each session looks legit in isolation |
l5.correlation.proxy_rotation |
HR-19 |
tls-static |
Four cookied requests in one session, all with a byte-identical HelloChrome_100 ClientHello (a pre-110 fingerprint that does not permute extension order) |
l5.traffic.tls_no_permutation |
HR-14 |
tls-rotate |
Two cookied requests in one session with different TLS stacks (Chrome then Firefox) | l5.traffic.engine_rotation |
HR-14 |
ua-rotate |
Two cookied requests with different User-Agents, same TLS | l5.traffic.ua_rotation |
— (score-based; not a pinned hard rule) |
rit-replay |
One valid signed request advances the counter, then replays the same token/counter (stale) | l5.rit.stale_replay |
HR-17 |
rit-tamper |
Signs one body, then sends a different body with that token — the HMAC over the observed body fails | l5.rit.header_tampered |
HR-16 |
The -host flag defaults to 127.0.0.1:8443. That default is advisory, not enforced — the CLI connects to whatever host:port is passed, so keeping it loopback is the operator’s responsibility. (Contrast the Detection Observatory launcher, which is structurally locked to loopback.)
Two entry points for the same attack
Every raw-protocol -attack value also exists as a .mjs profile that shells to the Go binary via _bin.mjs — eighteen profiles in all (for example flood, distributed, tls-static). Running the profile and running the raw -attack exercise the same underlying attack; the profile wrapper is what lets the e2e runner and the Observatory catalog reach it.
Where verdict detail comes from
The full per-layer trace is not in every output. Sources, from least to most detail:
node test/redteam/run-one.mjs <profile> https://127.0.0.1:8443stdout — intentionally minimal:{label, verdict, riskScore, hardRuleFired}. It dropstopContributors.POST /api/collectJSON — the full{verdict, riskScore, hardRuleFired, topContributors}returned to every profile’srun().test/e2e/results.json— the runner’s record per run, including thetopcontributor id list.GET /playground/explain/{id}(Detection Observatory) — the completeScoreTracefor a stored session: per-signal contributions, dedup keep/drop, per-layer cap, and the ordered hard-rule evaluation. See Run the Detection Observatory.
Related pages
- Hard rules, verdicts and signal-ID reference — the HR-1..HR-21 table and verdict bands.
- Red catalog architecture — how profiles, helpers, the runner and the Go client fit together.
- Write a Red profile — authoring guide, including the three registration points.
- Self-validation Red-team run — running the suite and reading the rates.
- Run the Detection Observatory — the live feed and the score trace.
- Red-team rules of engagement — the guardrail these attacks operate under.