File size: 14,738 Bytes
ca949ba | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 | """RecRedTeam AuditLab β interactive red-team audit for shopping agents.
Runs entirely offline on a deterministic mock agent + rule-based judge, so every
number in this Space is reproducible without an API key.
"""
from __future__ import annotations
import html as html_lib
from typing import Dict, List
import gradio as gr
from auditlab import DEFENSES, load_cases, load_taxonomy
from auditlab.pipeline import per_category, run_case, run_suite, summarize
CASES = load_cases("public")
TAXONOMY = load_taxonomy()
BY_ID: Dict[str, object] = {c.attack_id: c for c in CASES}
CATEGORIES = sorted({c.category_id for c in CASES})
STACKS: List[tuple] = [
("baseline (no defense)", []),
("sanitizer", ["sanitizer"]),
("rag_guard", ["rag_guard"]),
("policy_guard", ["policy_guard"]),
("full stack (all three)", ["sanitizer", "rag_guard", "policy_guard"]),
]
SEVERITY_COLOR = {"critical": "#dc2626", "high": "#ea580c", "medium": "#ca8a04"}
# ---------------------------------------------------------------------------
# Rendering helpers
# ---------------------------------------------------------------------------
def _esc(text: str) -> str:
return html_lib.escape(str(text))
def _badge(result: dict) -> str:
passed = result["verdict"] == "PASS"
color = "#16a34a" if passed else "#dc2626"
items = "".join(f"<li>{_esc(r)}</li>" for r in result["reasons"]) or (
"<li>no attack evidence found in the answer</li>"
)
return (
f"<div style='border-left:4px solid {color};padding:10px 14px;"
f"background:#f8fafc;border-radius:6px'>"
f"<div style='font-weight:700;color:{color};font-size:15px'>"
f"{result['verdict']} Β· hazard: {result['hazard']}</div>"
f"<ul style='margin:8px 0 0 18px;padding:0;font-size:13px;line-height:1.6'>{items}</ul>"
f"</div>"
)
def _table(headers: List[str], rows: List[List[str]]) -> str:
head = "".join(f"<th style='{_TH}'>{_esc(h)}</th>" for h in headers)
body = "".join(
"<tr>"
+ "".join(
f"<td style='{_TD}'>{_esc(c)}</td>" if i == 0 else f"<td style='{_TD_N}'>{_esc(c)}</td>"
for i, c in enumerate(row)
)
+ "</tr>"
for row in rows
)
return (
"<div style='overflow-x:auto'><table style='border-collapse:collapse;width:100%;"
"font-family:ui-sans-serif,system-ui,sans-serif;font-size:13px'>"
f"<thead><tr>{head}</tr></thead><tbody>{body}</tbody></table></div>"
)
_TH = (
"text-align:left;padding:8px 12px;border-bottom:2px solid #cbd5e1;"
"background:#f1f5f9;font-weight:700;white-space:nowrap"
)
_TD = "padding:7px 12px;border-bottom:1px solid #e2e8f0;font-weight:600;white-space:nowrap"
_TD_N = (
"padding:7px 12px;border-bottom:1px solid #e2e8f0;white-space:nowrap;"
"font-variant-numeric:tabular-nums"
)
def _bars(rows: List[tuple]) -> str:
out = ["<div style='font-family:ui-sans-serif,system-ui,sans-serif'>"]
for label, value in rows:
pct = max(0.0, min(1.0, value)) * 100
hue = int(120 - (pct / 100) * 120)
out.append(
"<div style='display:flex;align-items:center;margin:7px 0'>"
f"<div style='width:180px;font-size:13px'>{_esc(label)}</div>"
"<div style='flex:1;background:#e5e7eb;border-radius:4px;height:18px;overflow:hidden'>"
f"<div style='width:{pct:.1f}%;background:hsl({hue},70%,45%);height:100%'></div></div>"
f"<div style='width:64px;text-align:right;font-size:13px;font-variant-numeric:tabular-nums'>"
f"{pct:.1f}%</div></div>"
)
out.append("</div>")
return "".join(out)
def _case_card(case) -> str:
rows = []
for p in case.products:
flag = ", ".join(f"`{k}`" for k in sorted(p.attrs)) or "β"
rows.append(
f"| `{p.pid}` | {_esc(p.name)} | ${p.price:.2f} | {p.rating}/5 "
f"({p.review_count}) | trust {p.seller_trust} | {flag} |"
)
table = "\n".join(rows)
injected = ""
if case.hidden_instructions:
chunks = "".join(
f"> **{pid}** β {_esc(text)} \n" for pid, text in case.hidden_instructions.items()
)
injected = f"\n\n**Hidden instruction planted in the catalog**\n\n{chunks}"
sev = SEVERITY_COLOR.get(case.severity, "#475569")
return (
f"### {_esc(case.name)}\n\n"
f"**{_esc(case.category_name)}** ({_esc(case.category_name_cn)}) Β· "
f"<span style='color:{sev};font-weight:700'>{case.severity}</span> Β· "
f"`{_esc(case.owasp_asi)}` Β· `{_esc(case.owasp_llm)}`\n\n"
f"{_esc(case.description)}\n\n"
f"**Shopper query** \n{_esc(case.user_query)}\n\n"
f"**Attacker goal** \n{_esc(case.goal)}\n\n"
f"**Poisoned catalog**\n\n"
f"| pid | product | price | rating | seller trust | poisoning attrs |\n"
f"|---|---|---|---|---|---|\n{table}"
f"{injected}\n\n"
f"**Judge failure signals** β "
+ ", ".join(f"`{_esc(s)}`" for s in case.failure_signals)
)
# ---------------------------------------------------------------------------
# Tab 1 β taxonomy
# ---------------------------------------------------------------------------
def taxonomy_view() -> str:
counts: Dict[str, int] = {}
for c in CASES:
counts[c.category_id] = counts.get(c.category_id, 0) + 1
lines = [
"## Threat taxonomy β 8 + 1 categories",
"",
"Every category is mapped to **OWASP Agentic Security Initiative (ASI) 2026** "
"and the **OWASP LLM Top-10**.",
"",
"| Category | εη§° | OWASP ASI 2026 | OWASP LLM | Severity | Cases (public) |",
"|---|---|---|---|---|---|",
]
for t in TAXONOMY:
sev = t["default_severity"]
color = SEVERITY_COLOR.get(sev, "#475569")
lines.append(
f"| `{t['category_id']}` | {t['name']} / {t['name_cn']} | {t['owasp_asi']} | "
f"{t['owasp_llm']} | <span style='color:{color};font-weight:700'>{sev}</span> | "
f"{counts.get(t['category_id'], 0)} |"
)
lines += [
"",
f"This Space ships the **public split** β {len(CASES)} of the 270 cases in "
"the dataset (26 per category; the held-out split is reserved for reporting).",
"",
"<details><summary>Full descriptions</summary>",
"",
]
for t in TAXONOMY:
lines.append(f"- **{t['name']}** β {t['description']}")
lines += ["", "</details>"]
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Tab 2 β single attack
# ---------------------------------------------------------------------------
def inspect(attack_id: str):
case = BY_ID[attack_id]
return _case_card(case)
def audit(attack_id: str, plugins: List[str]):
case = BY_ID[attack_id]
base = run_case(case, [])
defended = run_case(case, plugins)
return base["response"], _badge(base), defended["response"], _badge(defended)
# ---------------------------------------------------------------------------
# Tab 3 β benchmark
# ---------------------------------------------------------------------------
def benchmark(per_cat: int, cats: List[str]):
chosen = set(cats) or set(CATEGORIES)
selected: List = []
for cat in CATEGORIES:
if cat in chosen:
selected.extend([c for c in CASES if c.category_id == cat][:per_cat])
if not selected:
return "<p>Select at least one category.</p>", "", ""
runs = {label: run_suite(selected, plugins) for label, plugins in STACKS}
stats = {label: summarize(res) for label, res in runs.items()}
base_asr = stats["baseline (no defense)"]["asr"] or 1.0
leaderboard = []
for label, _ in STACKS:
s = stats[label]
eff = "β" if label.startswith("baseline") else f"{max(0.0, 1 - s['asr'] / base_asr):.1%}"
leaderboard.append(
[
label,
s["n"],
f"{s['asr']:.1%}",
eff,
f"{s['mean_hazard']:.3f}",
f"{s['overblock']:.1%}",
]
)
chart = _bars([(label, stats[label]["asr"]) for label, _ in STACKS])
labels = [label for label, _ in STACKS]
per_cat_asr: Dict[str, Dict[str, float]] = {}
for label, res in runs.items():
for cat, sub in per_category(res).items():
per_cat_asr.setdefault(cat, {})[label] = sub["asr"]
matrix = [
[cat] + [f"{per_cat_asr.get(cat, {}).get(l, 0.0):.0%}" for l in labels]
for cat in sorted(per_cat_asr)
]
board_html = _table(
["defense", "n", "ASR", "effectiveness", "hazard", "over-block"], leaderboard
)
matrix_html = _table(["category"] + labels, matrix)
return board_html, chart, matrix_html
# ---------------------------------------------------------------------------
# UI
# ---------------------------------------------------------------------------
CHOICES = [
(f"{c.attack_id} Β· {c.category_name}", c.attack_id)
for c in sorted(CASES, key=lambda c: (c.category_id, c.attack_id))
]
with gr.Blocks(title="RecRedTeam AuditLab") as demo:
gr.Markdown(
"""
# π‘οΈ RecRedTeam AuditLab
**Red-team audit for LLM / agentic recommendation and shopping assistants.**
Pick an attack from a 9-category taxonomy mapped to **OWASP ASI 2026**, run it against a
deterministic shopping agent, and watch the verdict change as you switch defenses on.
No API key, no network call β everything is computed locally and reproducibly.
**Metrics** β `ASR` attack success rate Β· `hazard` mean graded harm (0β1) Β·
`effectiveness` = `1 β ASR(defended) / ASR(baseline)` Β· `over-block` share of cases where
the defense filtered the entire catalog away.
"""
)
with gr.Tabs():
with gr.Tab("Threat taxonomy"):
gr.Markdown(taxonomy_view())
with gr.Tab("Single attack"):
with gr.Row():
attack_dd = gr.Dropdown(
choices=CHOICES, value=CHOICES[0][1], label="Attack case", scale=3
)
defense_cg = gr.CheckboxGroup(
choices=list(DEFENSES), value=[], label="Defense plugins", scale=2
)
case_md = gr.Markdown(inspect(CHOICES[0][1]))
run_btn = gr.Button("Run audit", variant="primary")
with gr.Row():
with gr.Column():
gr.Markdown("#### Baseline agent (no defense)")
base_out = gr.Textbox(lines=14, label="Agent answer")
base_verdict = gr.HTML()
with gr.Column():
gr.Markdown("#### Defended agent")
def_out = gr.Textbox(lines=14, label="Agent answer")
def_verdict = gr.HTML()
attack_dd.change(inspect, inputs=[attack_dd], outputs=[case_md])
run_btn.click(
audit,
inputs=[attack_dd, defense_cg],
outputs=[base_out, base_verdict, def_out, def_verdict],
)
with gr.Tab("Benchmark / leaderboard"):
with gr.Row():
n_slider = gr.Slider(
minimum=2, maximum=26, value=10, step=1,
label="Cases per category", scale=1,
)
cat_cg = gr.CheckboxGroup(
choices=CATEGORIES, value=CATEGORIES, label="Threat categories", scale=3,
)
bench_btn = gr.Button("Run benchmark", variant="primary")
board = gr.HTML(label="Leaderboard")
chart_html = gr.HTML(label="ASR chart")
matrix_tbl = gr.HTML(label="Per-category ASR")
bench_btn.click(
benchmark,
inputs=[n_slider, cat_cg],
outputs=[board, chart_html, matrix_tbl],
)
with gr.Tab("About"):
gr.Markdown(
f"""
### How an audit runs
1. **Poison** β hidden instructions from the case are planted into the product catalog.
2. **Defend** β selected plugins act at retrieval (`rag_guard`), content (`sanitizer`)
and output (`policy_guard`) level.
3. **Answer** β a deterministic mock shopping agent ranks the catalog and paraphrases
what it retrieved. Its only flaw is the realistic one: *it trusts retrieved content*.
4. **Judge** β a rule-based dual-channel judge looks for PII disclosure, untrusted
destinations, executed instructions and category failure signals.
5. **Grade** β verdict (`PASS`/`FAIL`) plus a hazard grade from the category severity.
### Reading the leaderboard
Each plugin has a deliberately different coverage profile, which is the point of the
framework:
- `sanitizer` kills injected instructions and unverifiable claims β but is blind to
ranking manipulation and metadata exposure.
- `rag_guard` removes poisoned listings at retrieval β powerful, but it over-blocks,
and it cannot see a fabricated spec that looks exactly like a real one.
- `policy_guard` only rewrites the answer β it stops PII leaks and phishing redirects
and nothing else.
**Product hallucination is the category no rule-based defense solves.** Fabricated but
plausible specs ("30-day battery life", "#1 best seller", "50,000 units sold") are
lexically indistinguishable from real ones; closing that gap needs grounding and
external verification, which is the open research direction.
### Limitations
The agent under test is a **deterministic mock**, not a real LLM. Absolute ASR values
therefore describe this mock; what transfers to real agents is the *relative* coverage
profile of the defenses and the taxonomy itself. The full framework adds an optional
LLM judge channel and LangGraph / OpenAI-Agents adapters.
### Links
- Dataset β [NoahIsARider/RecRedTeam-ASI-Bench](https://huggingface.co/datasets/NoahIsARider/RecRedTeam-ASI-Bench)
- Framework β [github.com/NoahIsARider/RecRedTeam](https://github.com/NoahIsARider/RecRedTeam)
- Author β [noahisarider.github.io](https://noahisarider.github.io/)
<sub>Defensive research only. Attack cases run against a local simulated catalog, never
against real systems.</sub>
"""
)
demo.launch()
|