Spaces:
Sleeping
Sleeping
Rewrite notebook to follow Unsloth GRPO pattern (trl==0.22.2)
Browse files- Fix llm_blender error: replace trl>=0.16 with pinned trl==0.22.2 via uv
- Split reward into formatting_reward_func + environment_reward_func
- Upgrade GRPOConfig: DR-GRPO loss, cosine LR, adamw_8bit, importance sampling
- Add max_prompt_length, max_grad_norm, log_completions, gradient_accumulation_steps
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- bughunter_grpo_training.ipynb +110 -80
bughunter_grpo_training.ipynb
CHANGED
|
@@ -22,16 +22,16 @@
|
|
| 22 |
"cell_type": "markdown",
|
| 23 |
"metadata": {},
|
| 24 |
"source": [
|
| 25 |
-
"# BugHunterEnv
|
| 26 |
"\n",
|
| 27 |
"Trains a Gemma 4 model via **Group Relative Policy Optimization (GRPO)** to find web vulnerabilities in a simulated corporate API.\n",
|
| 28 |
"\n",
|
| 29 |
"The environment teaches three real-world bug classes:\n",
|
| 30 |
-
"-
|
| 31 |
-
"-
|
| 32 |
-
"-
|
| 33 |
"\n",
|
| 34 |
-
"**Runtime:** GPU
|
| 35 |
"**Estimated time:** ~30 min on T4"
|
| 36 |
],
|
| 37 |
"id": "cell-markdown-title"
|
|
@@ -45,7 +45,7 @@
|
|
| 45 |
"import subprocess, sys\n",
|
| 46 |
"gpu_info = subprocess.run(['nvidia-smi'], capture_output=True, text=True).stdout\n",
|
| 47 |
"if 'failed' in gpu_info.lower() or gpu_info == '':\n",
|
| 48 |
-
" print('
|
| 49 |
"else:\n",
|
| 50 |
" print(gpu_info.split('\\n')[8])"
|
| 51 |
],
|
|
@@ -55,7 +55,7 @@
|
|
| 55 |
"cell_type": "markdown",
|
| 56 |
"metadata": {},
|
| 57 |
"source": [
|
| 58 |
-
"## 1
|
| 59 |
],
|
| 60 |
"id": "cell-md-install"
|
| 61 |
},
|
|
@@ -64,14 +64,21 @@
|
|
| 64 |
"execution_count": null,
|
| 65 |
"metadata": {},
|
| 66 |
"outputs": [],
|
| 67 |
-
"source":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
"id": "cell-install"
|
| 69 |
},
|
| 70 |
{
|
| 71 |
"cell_type": "markdown",
|
| 72 |
"metadata": {},
|
| 73 |
"source": [
|
| 74 |
-
"## 2
|
| 75 |
],
|
| 76 |
"id": "cell-md-clone"
|
| 77 |
},
|
|
@@ -87,7 +94,7 @@
|
|
| 87 |
"cell_type": "markdown",
|
| 88 |
"metadata": {},
|
| 89 |
"source": [
|
| 90 |
-
"## 3
|
| 91 |
],
|
| 92 |
"id": "cell-md-imports"
|
| 93 |
},
|
|
@@ -119,7 +126,7 @@
|
|
| 119 |
"cell_type": "markdown",
|
| 120 |
"metadata": {},
|
| 121 |
"source": [
|
| 122 |
-
"## 4
|
| 123 |
],
|
| 124 |
"id": "cell-md-hyper"
|
| 125 |
},
|
|
@@ -129,23 +136,23 @@
|
|
| 129 |
"metadata": {},
|
| 130 |
"outputs": [],
|
| 131 |
"source": [
|
| 132 |
-
"#
|
| 133 |
-
"# Gemma 4 variants
|
| 134 |
-
"# gemma-4-4b-it ~4 GB in 4-bit
|
| 135 |
-
"# gemma-4-12b-it ~8 GB in 4-bit
|
| 136 |
-
"# gemma-4-27b-it ~18 GB in 4-bit
|
| 137 |
"MODEL_NAME = \"unsloth/gemma-4-4b-it\"\n",
|
| 138 |
"\n",
|
| 139 |
-
"#
|
| 140 |
"MAX_SEQ_LEN = 768\n",
|
| 141 |
"LORA_RANK = 16\n",
|
| 142 |
"\n",
|
| 143 |
-
"#
|
| 144 |
"NUM_GENERATIONS = 4 # candidate completions per prompt\n",
|
| 145 |
"TRAINING_STEPS = 80 # ~25 min on T4\n",
|
| 146 |
"BATCH_SIZE = 2 # prompts per gradient step\n",
|
| 147 |
"\n",
|
| 148 |
-
"#
|
| 149 |
"EVAL_EPISODES = 3\n",
|
| 150 |
"OUTPUT_DIR = \"/content/grpo_bughunter\"\n",
|
| 151 |
"os.makedirs(OUTPUT_DIR, exist_ok=True)\n",
|
|
@@ -159,7 +166,7 @@
|
|
| 159 |
"cell_type": "markdown",
|
| 160 |
"metadata": {},
|
| 161 |
"source": [
|
| 162 |
-
"## 5
|
| 163 |
],
|
| 164 |
"id": "cell-md-data"
|
| 165 |
},
|
|
@@ -184,13 +191,13 @@
|
|
| 184 |
"- METHOD is GET or POST\n",
|
| 185 |
"- PATH starts with /\n",
|
| 186 |
"- BODY is JSON only for POST\n",
|
| 187 |
-
"- No explanation
|
| 188 |
"\n",
|
| 189 |
"# Each state is a specific decision point in an attack chain.\n",
|
| 190 |
"# pre_actions seeds the environment to that point; the model's\n",
|
| 191 |
"# action is then executed and rewarded. Dense signal at every step.\n",
|
| 192 |
"SEEDED_STATES = [\n",
|
| 193 |
-
" #
|
| 194 |
" {\n",
|
| 195 |
" \"task\": \"idor\",\n",
|
| 196 |
" \"pre_actions\": [],\n",
|
|
@@ -201,15 +208,15 @@
|
|
| 201 |
" \"task\": \"idor\",\n",
|
| 202 |
" \"pre_actions\": [(\"GET\", \"/api/users\", None)],\n",
|
| 203 |
" \"obs\": 'HTTP 200\\n[{\"id\":1,\"username\":\"admin\",\"role\":\"admin\"},{\"id\":2,\"username\":\"alice\",\"role\":\"user\"}]',\n",
|
| 204 |
-
" \"hint\": \"Users listed. Probe /api/users/<id> without auth
|
| 205 |
" },\n",
|
| 206 |
" {\n",
|
| 207 |
" \"task\": \"idor\",\n",
|
| 208 |
" \"pre_actions\": [(\"GET\", \"/api/users\", None), (\"GET\", \"/api/users/2\", None)],\n",
|
| 209 |
" \"obs\": 'HTTP 200\\n{\"id\":2,\"username\":\"alice\",\"ssn\":\"234-56-7890\",\"salary\":75000}',\n",
|
| 210 |
-
" \"hint\": \"IDOR confirmed
|
| 211 |
" },\n",
|
| 212 |
-
" #
|
| 213 |
" {\n",
|
| 214 |
" \"task\": \"sqli\",\n",
|
| 215 |
" \"pre_actions\": [],\n",
|
|
@@ -220,7 +227,7 @@
|
|
| 220 |
" \"task\": \"sqli\",\n",
|
| 221 |
" \"pre_actions\": [(\"GET\", \"/api/search?q='\", None)],\n",
|
| 222 |
" \"obs\": 'HTTP 500\\n{\"error\":\"OperationalError: unrecognized token\"}',\n",
|
| 223 |
-
" \"hint\": \"SQL error
|
| 224 |
" },\n",
|
| 225 |
" {\n",
|
| 226 |
" \"task\": \"sqli\",\n",
|
|
@@ -241,11 +248,11 @@
|
|
| 241 |
" \"obs\": 'HTTP 200\\n{\"results\":[],\"count\":0}',\n",
|
| 242 |
" \"hint\": \"WAF bypassed! 4 columns needed. Extract: GET /api/search?q=' UNION/**/SELECT 1,username,password,role FROM users--\",\n",
|
| 243 |
" },\n",
|
| 244 |
-
" #
|
| 245 |
" {\n",
|
| 246 |
" \"task\": \"path_traversal\",\n",
|
| 247 |
" \"pre_actions\": [],\n",
|
| 248 |
-
" \"obs\": \"HTTP 200\\nTask: Read a sensitive server file. /api/files needs admin
|
| 249 |
" \"hint\": 'POST /api/login is SQLi vulnerable: {\"username\": \"admin\\'--\", \"password\": \"x\"}',\n",
|
| 250 |
" },\n",
|
| 251 |
" {\n",
|
|
@@ -274,7 +281,7 @@
|
|
| 274 |
" (\"GET\", \"/api/files?name=../etc/passwd\", None),\n",
|
| 275 |
" ],\n",
|
| 276 |
" \"obs\": 'HTTP 403\\n{\"error\":\"Invalid path: directory traversal not allowed\"}',\n",
|
| 277 |
-
" \"hint\": \"Literal ../ blocked. Flask auto-decodes %2f
|
| 278 |
" },\n",
|
| 279 |
"]\n",
|
| 280 |
"\n",
|
|
@@ -286,7 +293,7 @@
|
|
| 286 |
"cell_type": "markdown",
|
| 287 |
"metadata": {},
|
| 288 |
"source": [
|
| 289 |
-
"## 6
|
| 290 |
],
|
| 291 |
"id": "cell-md-helpers"
|
| 292 |
},
|
|
@@ -317,7 +324,7 @@
|
|
| 317 |
" continue\n",
|
| 318 |
" rest = parts[1].strip()\n",
|
| 319 |
" if m == \"GET\":\n",
|
| 320 |
-
" # GET has no body
|
| 321 |
" # (handles spaces in query strings like UNION payloads)\n",
|
| 322 |
" p, b = rest, None\n",
|
| 323 |
" else:\n",
|
|
@@ -345,7 +352,11 @@
|
|
| 345 |
"cell_type": "markdown",
|
| 346 |
"metadata": {},
|
| 347 |
"source": [
|
| 348 |
-
"## 7
|
|
|
|
|
|
|
|
|
|
|
|
|
| 349 |
],
|
| 350 |
"id": "cell-md-reward"
|
| 351 |
},
|
|
@@ -355,17 +366,21 @@
|
|
| 355 |
"metadata": {},
|
| 356 |
"outputs": [],
|
| 357 |
"source": [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 358 |
"def compute_reward(state_idx: int, completion: str) -> float:\n",
|
| 359 |
-
" \"\"\"\n",
|
| 360 |
-
" Spin up a fresh environment instance, replay pre_actions to seed\n",
|
| 361 |
-
" the attack state, then execute the model's proposed action.\n",
|
| 362 |
-
" Returns the step reward from the environment.\n",
|
| 363 |
-
" \"\"\"\n",
|
| 364 |
" state = SEEDED_STATES[state_idx]\n",
|
| 365 |
" env = BugHunterEnvEnvironment()\n",
|
| 366 |
" try:\n",
|
| 367 |
-
" env.reset(task_id=state[
|
| 368 |
-
" for m, p, b in state[
|
| 369 |
" env.step(_Action(m, p, b))\n",
|
| 370 |
" action = parse_action(completion)\n",
|
| 371 |
" if action is None:\n",
|
|
@@ -378,16 +393,21 @@
|
|
| 378 |
" env.close()\n",
|
| 379 |
"\n",
|
| 380 |
"\n",
|
| 381 |
-
"def
|
| 382 |
-
" \"\"\"TRL GRPOTrainer reward function interface.\"\"\"\n",
|
| 383 |
" if state_idx is None:\n",
|
| 384 |
" state_idx = [0] * len(completions)\n",
|
| 385 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 386 |
"\n",
|
| 387 |
"\n",
|
| 388 |
-
"
|
| 389 |
-
"
|
| 390 |
-
"print(f
|
|
|
|
| 391 |
],
|
| 392 |
"id": "cell-reward"
|
| 393 |
},
|
|
@@ -395,7 +415,7 @@
|
|
| 395 |
"cell_type": "markdown",
|
| 396 |
"metadata": {},
|
| 397 |
"source": [
|
| 398 |
-
"## 8
|
| 399 |
],
|
| 400 |
"id": "cell-md-eval"
|
| 401 |
},
|
|
@@ -406,7 +426,7 @@
|
|
| 406 |
"outputs": [],
|
| 407 |
"source": [
|
| 408 |
"def run_episode(model, tokenizer, task_id: str) -> float:\n",
|
| 409 |
-
" \"\"\"Run one full episode and return the final grade (0
|
| 410 |
" max_steps = {\"idor\": 10, \"sqli\": 15, \"path_traversal\": 20}[task_id]\n",
|
| 411 |
" env = BugHunterEnvEnvironment()\n",
|
| 412 |
" try:\n",
|
|
@@ -443,7 +463,7 @@
|
|
| 443 |
" if action is None:\n",
|
| 444 |
" break\n",
|
| 445 |
" obs = env.step(action)\n",
|
| 446 |
-
" history.append(f\"[{step+1:02d}] {action.method} {action.path}
|
| 447 |
"\n",
|
| 448 |
" return env.get_grade()\n",
|
| 449 |
" finally:\n",
|
|
@@ -469,7 +489,7 @@
|
|
| 469 |
"cell_type": "markdown",
|
| 470 |
"metadata": {},
|
| 471 |
"source": [
|
| 472 |
-
"## 9
|
| 473 |
],
|
| 474 |
"id": "cell-md-model"
|
| 475 |
},
|
|
@@ -510,7 +530,7 @@
|
|
| 510 |
"cell_type": "markdown",
|
| 511 |
"metadata": {},
|
| 512 |
"source": [
|
| 513 |
-
"## 10
|
| 514 |
],
|
| 515 |
"id": "cell-md-baseline"
|
| 516 |
},
|
|
@@ -530,7 +550,7 @@
|
|
| 530 |
"cell_type": "markdown",
|
| 531 |
"metadata": {},
|
| 532 |
"source": [
|
| 533 |
-
"## 11
|
| 534 |
],
|
| 535 |
"id": "cell-md-dataset"
|
| 536 |
},
|
|
@@ -554,7 +574,7 @@
|
|
| 554 |
"cell_type": "markdown",
|
| 555 |
"metadata": {},
|
| 556 |
"source": [
|
| 557 |
-
"## 12
|
| 558 |
],
|
| 559 |
"id": "cell-md-train"
|
| 560 |
},
|
|
@@ -565,40 +585,50 @@
|
|
| 565 |
"outputs": [],
|
| 566 |
"source": [
|
| 567 |
"config = GRPOConfig(\n",
|
| 568 |
-
" output_dir
|
| 569 |
-
"
|
| 570 |
-
"
|
| 571 |
-
"
|
| 572 |
-
" num_generations
|
| 573 |
-
"
|
| 574 |
-
"
|
| 575 |
-
"
|
| 576 |
-
"
|
| 577 |
-
"
|
| 578 |
-
"
|
| 579 |
-
"
|
| 580 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 581 |
")\n",
|
| 582 |
"\n",
|
| 583 |
"FastLanguageModel.for_training(model)\n",
|
| 584 |
"\n",
|
| 585 |
"trainer = GRPOTrainer(\n",
|
| 586 |
" model = model,\n",
|
| 587 |
-
" reward_funcs = [reward_fn],\n",
|
| 588 |
" args = config,\n",
|
| 589 |
-
" train_dataset = dataset,\n",
|
| 590 |
" processing_class = tokenizer,\n",
|
|
|
|
|
|
|
| 591 |
")\n",
|
| 592 |
"\n",
|
| 593 |
-
"print(f
|
| 594 |
"trainer.train()\n",
|
| 595 |
"\n",
|
| 596 |
"step_rewards = [\n",
|
| 597 |
-
" entry[
|
| 598 |
" for entry in trainer.state.log_history\n",
|
| 599 |
-
" if
|
| 600 |
"]\n",
|
| 601 |
-
"print(f\
|
| 602 |
],
|
| 603 |
"id": "cell-train"
|
| 604 |
},
|
|
@@ -606,7 +636,7 @@
|
|
| 606 |
"cell_type": "markdown",
|
| 607 |
"metadata": {},
|
| 608 |
"source": [
|
| 609 |
-
"## 13
|
| 610 |
],
|
| 611 |
"id": "cell-md-posttrain"
|
| 612 |
},
|
|
@@ -626,7 +656,7 @@
|
|
| 626 |
"cell_type": "markdown",
|
| 627 |
"metadata": {},
|
| 628 |
"source": [
|
| 629 |
-
"## 14
|
| 630 |
],
|
| 631 |
"id": "cell-md-save"
|
| 632 |
},
|
|
@@ -651,7 +681,7 @@
|
|
| 651 |
"cell_type": "markdown",
|
| 652 |
"metadata": {},
|
| 653 |
"source": [
|
| 654 |
-
"## 15
|
| 655 |
],
|
| 656 |
"id": "cell-md-results"
|
| 657 |
},
|
|
@@ -665,9 +695,9 @@
|
|
| 665 |
"tasks = list(baseline.keys())\n",
|
| 666 |
"\n",
|
| 667 |
"fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))\n",
|
| 668 |
-
"fig.suptitle(\"BugHunterEnv
|
| 669 |
"\n",
|
| 670 |
-
"#
|
| 671 |
"if step_rewards:\n",
|
| 672 |
" window = max(1, len(step_rewards) // 10)\n",
|
| 673 |
" smoothed = [\n",
|
|
@@ -687,7 +717,7 @@
|
|
| 687 |
" transform=ax1.transAxes, color=\"gray\", fontsize=12)\n",
|
| 688 |
" ax1.set_title(\"Training Reward Curve\")\n",
|
| 689 |
"\n",
|
| 690 |
-
"#
|
| 691 |
"x = range(len(tasks))\n",
|
| 692 |
"b_bars = ax2.bar([i-0.2 for i in x], [baseline[t] for t in tasks],\n",
|
| 693 |
" width=0.38, label=\"Before\", color=\"#e07070\")\n",
|
|
@@ -699,7 +729,7 @@
|
|
| 699 |
" ha=\"center\", va=\"bottom\", fontsize=9)\n",
|
| 700 |
"ax2.set_xticks(list(x))\n",
|
| 701 |
"ax2.set_xticklabels(task_names)\n",
|
| 702 |
-
"ax2.set_ylabel(\"Task Grade (0
|
| 703 |
"ax2.set_title(\"Task Performance: Before vs After\")\n",
|
| 704 |
"ax2.set_ylim(0, 1.3)\n",
|
| 705 |
"ax2.legend()\n",
|
|
@@ -711,9 +741,9 @@
|
|
| 711 |
"plt.show()\n",
|
| 712 |
"print(f\"Saved: {out_path}\")\n",
|
| 713 |
"\n",
|
| 714 |
-
"#
|
| 715 |
"print(\"\\n\" + \"=\"*50)\n",
|
| 716 |
-
"print(f\"{'Task':<22} {'Before':>8} {'After':>8} {'
|
| 717 |
"print(\"-\"*50)\n",
|
| 718 |
"for task, name in zip(tasks, task_names):\n",
|
| 719 |
" d = final[task] - baseline[task]\n",
|
|
|
|
| 22 |
"cell_type": "markdown",
|
| 23 |
"metadata": {},
|
| 24 |
"source": [
|
| 25 |
+
"# BugHunterEnv \u2014 GRPO Training with Gemma 4\n",
|
| 26 |
"\n",
|
| 27 |
"Trains a Gemma 4 model via **Group Relative Policy Optimization (GRPO)** to find web vulnerabilities in a simulated corporate API.\n",
|
| 28 |
"\n",
|
| 29 |
"The environment teaches three real-world bug classes:\n",
|
| 30 |
+
"- \ud83d\udd13 **IDOR** \u2014 Insecure Direct Object Reference\n",
|
| 31 |
+
"- \ud83d\udc89 **SQL Injection** \u2014 with WAF bypass\n",
|
| 32 |
+
"- \ud83d\udcc2 **Path Traversal** \u2014 chained with privilege escalation\n",
|
| 33 |
"\n",
|
| 34 |
+
"**Runtime:** GPU \u2192 T4 or better \n",
|
| 35 |
"**Estimated time:** ~30 min on T4"
|
| 36 |
],
|
| 37 |
"id": "cell-markdown-title"
|
|
|
|
| 45 |
"import subprocess, sys\n",
|
| 46 |
"gpu_info = subprocess.run(['nvidia-smi'], capture_output=True, text=True).stdout\n",
|
| 47 |
"if 'failed' in gpu_info.lower() or gpu_info == '':\n",
|
| 48 |
+
" print('\u26a0\ufe0f No GPU detected. Go to Runtime \u2192 Change runtime type \u2192 GPU')\n",
|
| 49 |
"else:\n",
|
| 50 |
" print(gpu_info.split('\\n')[8])"
|
| 51 |
],
|
|
|
|
| 55 |
"cell_type": "markdown",
|
| 56 |
"metadata": {},
|
| 57 |
"source": [
|
| 58 |
+
"## 1 \u00b7 Install Dependencies"
|
| 59 |
],
|
| 60 |
"id": "cell-md-install"
|
| 61 |
},
|
|
|
|
| 64 |
"execution_count": null,
|
| 65 |
"metadata": {},
|
| 66 |
"outputs": [],
|
| 67 |
+
"source": [
|
| 68 |
+
"%%capture\n",
|
| 69 |
+
"import os\n",
|
| 70 |
+
"!pip install --upgrade -qqq uv\n",
|
| 71 |
+
"!uv pip install --upgrade --no-deps trl==0.22.2 unsloth unsloth_zoo\n",
|
| 72 |
+
"!uv pip install -qqq unsloth datasets accelerate\n",
|
| 73 |
+
"!uv pip install -qqq flask werkzeug requests openenv-core python-dotenv"
|
| 74 |
+
],
|
| 75 |
"id": "cell-install"
|
| 76 |
},
|
| 77 |
{
|
| 78 |
"cell_type": "markdown",
|
| 79 |
"metadata": {},
|
| 80 |
"source": [
|
| 81 |
+
"## 2 \u00b7 Clone Environment"
|
| 82 |
],
|
| 83 |
"id": "cell-md-clone"
|
| 84 |
},
|
|
|
|
| 94 |
"cell_type": "markdown",
|
| 95 |
"metadata": {},
|
| 96 |
"source": [
|
| 97 |
+
"## 3 \u00b7 Imports"
|
| 98 |
],
|
| 99 |
"id": "cell-md-imports"
|
| 100 |
},
|
|
|
|
| 126 |
"cell_type": "markdown",
|
| 127 |
"metadata": {},
|
| 128 |
"source": [
|
| 129 |
+
"## 4 \u00b7 Hyperparameters"
|
| 130 |
],
|
| 131 |
"id": "cell-md-hyper"
|
| 132 |
},
|
|
|
|
| 136 |
"metadata": {},
|
| 137 |
"outputs": [],
|
| 138 |
"source": [
|
| 139 |
+
"# \u2500\u2500 Model \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n",
|
| 140 |
+
"# Gemma 4 variants \u2014 pick based on available VRAM:\n",
|
| 141 |
+
"# gemma-4-4b-it ~4 GB in 4-bit \u2192 T4 (15 GB), comfortable\n",
|
| 142 |
+
"# gemma-4-12b-it ~8 GB in 4-bit \u2192 T4 tight, A100 preferred\n",
|
| 143 |
+
"# gemma-4-27b-it ~18 GB in 4-bit \u2192 A100 40 GB+\n",
|
| 144 |
"MODEL_NAME = \"unsloth/gemma-4-4b-it\"\n",
|
| 145 |
"\n",
|
| 146 |
+
"# \u2500\u2500 LoRA \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n",
|
| 147 |
"MAX_SEQ_LEN = 768\n",
|
| 148 |
"LORA_RANK = 16\n",
|
| 149 |
"\n",
|
| 150 |
+
"# \u2500\u2500 GRPO \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n",
|
| 151 |
"NUM_GENERATIONS = 4 # candidate completions per prompt\n",
|
| 152 |
"TRAINING_STEPS = 80 # ~25 min on T4\n",
|
| 153 |
"BATCH_SIZE = 2 # prompts per gradient step\n",
|
| 154 |
"\n",
|
| 155 |
+
"# \u2500\u2500 Eval + output \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n",
|
| 156 |
"EVAL_EPISODES = 3\n",
|
| 157 |
"OUTPUT_DIR = \"/content/grpo_bughunter\"\n",
|
| 158 |
"os.makedirs(OUTPUT_DIR, exist_ok=True)\n",
|
|
|
|
| 166 |
"cell_type": "markdown",
|
| 167 |
"metadata": {},
|
| 168 |
"source": [
|
| 169 |
+
"## 5 \u00b7 System Prompt & Seeded Training States"
|
| 170 |
],
|
| 171 |
"id": "cell-md-data"
|
| 172 |
},
|
|
|
|
| 191 |
"- METHOD is GET or POST\n",
|
| 192 |
"- PATH starts with /\n",
|
| 193 |
"- BODY is JSON only for POST\n",
|
| 194 |
+
"- No explanation \u2014 output only the request line\"\"\"\n",
|
| 195 |
"\n",
|
| 196 |
"# Each state is a specific decision point in an attack chain.\n",
|
| 197 |
"# pre_actions seeds the environment to that point; the model's\n",
|
| 198 |
"# action is then executed and rewarded. Dense signal at every step.\n",
|
| 199 |
"SEEDED_STATES = [\n",
|
| 200 |
+
" # \u2500\u2500 IDOR \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n",
|
| 201 |
" {\n",
|
| 202 |
" \"task\": \"idor\",\n",
|
| 203 |
" \"pre_actions\": [],\n",
|
|
|
|
| 208 |
" \"task\": \"idor\",\n",
|
| 209 |
" \"pre_actions\": [(\"GET\", \"/api/users\", None)],\n",
|
| 210 |
" \"obs\": 'HTTP 200\\n[{\"id\":1,\"username\":\"admin\",\"role\":\"admin\"},{\"id\":2,\"username\":\"alice\",\"role\":\"user\"}]',\n",
|
| 211 |
+
" \"hint\": \"Users listed. Probe /api/users/<id> without auth \u2014 try id=1 (admin).\",\n",
|
| 212 |
" },\n",
|
| 213 |
" {\n",
|
| 214 |
" \"task\": \"idor\",\n",
|
| 215 |
" \"pre_actions\": [(\"GET\", \"/api/users\", None), (\"GET\", \"/api/users/2\", None)],\n",
|
| 216 |
" \"obs\": 'HTTP 200\\n{\"id\":2,\"username\":\"alice\",\"ssn\":\"234-56-7890\",\"salary\":75000}',\n",
|
| 217 |
+
" \"hint\": \"IDOR confirmed \u2014 sensitive data returned without auth. Now target admin (id=1).\",\n",
|
| 218 |
" },\n",
|
| 219 |
+
" # \u2500\u2500 SQL Injection \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n",
|
| 220 |
" {\n",
|
| 221 |
" \"task\": \"sqli\",\n",
|
| 222 |
" \"pre_actions\": [],\n",
|
|
|
|
| 227 |
" \"task\": \"sqli\",\n",
|
| 228 |
" \"pre_actions\": [(\"GET\", \"/api/search?q='\", None)],\n",
|
| 229 |
" \"obs\": 'HTTP 500\\n{\"error\":\"OperationalError: unrecognized token\"}',\n",
|
| 230 |
+
" \"hint\": \"SQL error \u2014 injectable. WAF blocks UNION SELECT, use UNION/**/SELECT bypass.\",\n",
|
| 231 |
" },\n",
|
| 232 |
" {\n",
|
| 233 |
" \"task\": \"sqli\",\n",
|
|
|
|
| 248 |
" \"obs\": 'HTTP 200\\n{\"results\":[],\"count\":0}',\n",
|
| 249 |
" \"hint\": \"WAF bypassed! 4 columns needed. Extract: GET /api/search?q=' UNION/**/SELECT 1,username,password,role FROM users--\",\n",
|
| 250 |
" },\n",
|
| 251 |
+
" # \u2500\u2500 Path Traversal \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n",
|
| 252 |
" {\n",
|
| 253 |
" \"task\": \"path_traversal\",\n",
|
| 254 |
" \"pre_actions\": [],\n",
|
| 255 |
+
" \"obs\": \"HTTP 200\\nTask: Read a sensitive server file. /api/files needs admin \u2014 escalate via SQLi first.\",\n",
|
| 256 |
" \"hint\": 'POST /api/login is SQLi vulnerable: {\"username\": \"admin\\'--\", \"password\": \"x\"}',\n",
|
| 257 |
" },\n",
|
| 258 |
" {\n",
|
|
|
|
| 281 |
" (\"GET\", \"/api/files?name=../etc/passwd\", None),\n",
|
| 282 |
" ],\n",
|
| 283 |
" \"obs\": 'HTTP 403\\n{\"error\":\"Invalid path: directory traversal not allowed\"}',\n",
|
| 284 |
+
" \"hint\": \"Literal ../ blocked. Flask auto-decodes %2f \u2014 use double-encoding: /api/files?name=..%252fetc%252fpasswd\",\n",
|
| 285 |
" },\n",
|
| 286 |
"]\n",
|
| 287 |
"\n",
|
|
|
|
| 293 |
"cell_type": "markdown",
|
| 294 |
"metadata": {},
|
| 295 |
"source": [
|
| 296 |
+
"## 6 \u00b7 Helper Functions"
|
| 297 |
],
|
| 298 |
"id": "cell-md-helpers"
|
| 299 |
},
|
|
|
|
| 324 |
" continue\n",
|
| 325 |
" rest = parts[1].strip()\n",
|
| 326 |
" if m == \"GET\":\n",
|
| 327 |
+
" # GET has no body \u2014 everything after the method is the path\n",
|
| 328 |
" # (handles spaces in query strings like UNION payloads)\n",
|
| 329 |
" p, b = rest, None\n",
|
| 330 |
" else:\n",
|
|
|
|
| 352 |
"cell_type": "markdown",
|
| 353 |
"metadata": {},
|
| 354 |
"source": [
|
| 355 |
+
"## 7 \u00b7 Reward Functions\n",
|
| 356 |
+
"\n",
|
| 357 |
+
"Two reward signals following the Unsloth GRPO pattern:\n",
|
| 358 |
+
"- **Formatting reward** \u2014 does the completion parse as a valid HTTP request?\n",
|
| 359 |
+
"- **Environment reward** \u2014 execute the action in the env, return the step reward"
|
| 360 |
],
|
| 361 |
"id": "cell-md-reward"
|
| 362 |
},
|
|
|
|
| 366 |
"metadata": {},
|
| 367 |
"outputs": [],
|
| 368 |
"source": [
|
| 369 |
+
"def formatting_reward_func(completions, **kwargs) -> list[float]:\n",
|
| 370 |
+
" scores = []\n",
|
| 371 |
+
" for completion in completions:\n",
|
| 372 |
+
" if isinstance(completion, list):\n",
|
| 373 |
+
" completion = completion[0]['content'] if completion else ''\n",
|
| 374 |
+
" scores.append(1.0 if parse_action(completion) is not None else 0.0)\n",
|
| 375 |
+
" return scores\n",
|
| 376 |
+
"\n",
|
| 377 |
+
"\n",
|
| 378 |
"def compute_reward(state_idx: int, completion: str) -> float:\n",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 379 |
" state = SEEDED_STATES[state_idx]\n",
|
| 380 |
" env = BugHunterEnvEnvironment()\n",
|
| 381 |
" try:\n",
|
| 382 |
+
" env.reset(task_id=state['task'])\n",
|
| 383 |
+
" for m, p, b in state['pre_actions']:\n",
|
| 384 |
" env.step(_Action(m, p, b))\n",
|
| 385 |
" action = parse_action(completion)\n",
|
| 386 |
" if action is None:\n",
|
|
|
|
| 393 |
" env.close()\n",
|
| 394 |
"\n",
|
| 395 |
"\n",
|
| 396 |
+
"def environment_reward_func(completions, state_idx=None, **kwargs) -> list[float]:\n",
|
|
|
|
| 397 |
" if state_idx is None:\n",
|
| 398 |
" state_idx = [0] * len(completions)\n",
|
| 399 |
+
" results = []\n",
|
| 400 |
+
" for idx, c in zip(state_idx, completions):\n",
|
| 401 |
+
" if isinstance(c, list):\n",
|
| 402 |
+
" c = c[0]['content'] if c else ''\n",
|
| 403 |
+
" results.append(compute_reward(int(idx), c))\n",
|
| 404 |
+
" return results\n",
|
| 405 |
"\n",
|
| 406 |
"\n",
|
| 407 |
+
"test_fmt = formatting_reward_func(['GET /api/users', 'not a request'])\n",
|
| 408 |
+
"test_env = compute_reward(0, 'GET /api/users')\n",
|
| 409 |
+
"print(f'Formatting reward: {test_fmt} (expected [1.0, 0.0])')\n",
|
| 410 |
+
"print(f'Environment reward (state 0, GET /api/users): {test_env:+.3f} (expected ~+0.15)')"
|
| 411 |
],
|
| 412 |
"id": "cell-reward"
|
| 413 |
},
|
|
|
|
| 415 |
"cell_type": "markdown",
|
| 416 |
"metadata": {},
|
| 417 |
"source": [
|
| 418 |
+
"## 8 \u00b7 Evaluation Function"
|
| 419 |
],
|
| 420 |
"id": "cell-md-eval"
|
| 421 |
},
|
|
|
|
| 426 |
"outputs": [],
|
| 427 |
"source": [
|
| 428 |
"def run_episode(model, tokenizer, task_id: str) -> float:\n",
|
| 429 |
+
" \"\"\"Run one full episode and return the final grade (0\u20131).\"\"\"\n",
|
| 430 |
" max_steps = {\"idor\": 10, \"sqli\": 15, \"path_traversal\": 20}[task_id]\n",
|
| 431 |
" env = BugHunterEnvEnvironment()\n",
|
| 432 |
" try:\n",
|
|
|
|
| 463 |
" if action is None:\n",
|
| 464 |
" break\n",
|
| 465 |
" obs = env.step(action)\n",
|
| 466 |
+
" history.append(f\"[{step+1:02d}] {action.method} {action.path} \u2192 {obs.status_code} r={obs.reward:+.3f}\")\n",
|
| 467 |
"\n",
|
| 468 |
" return env.get_grade()\n",
|
| 469 |
" finally:\n",
|
|
|
|
| 489 |
"cell_type": "markdown",
|
| 490 |
"metadata": {},
|
| 491 |
"source": [
|
| 492 |
+
"## 9 \u00b7 Load Model"
|
| 493 |
],
|
| 494 |
"id": "cell-md-model"
|
| 495 |
},
|
|
|
|
| 530 |
"cell_type": "markdown",
|
| 531 |
"metadata": {},
|
| 532 |
"source": [
|
| 533 |
+
"## 10 \u00b7 Baseline Evaluation (Before Training)"
|
| 534 |
],
|
| 535 |
"id": "cell-md-baseline"
|
| 536 |
},
|
|
|
|
| 550 |
"cell_type": "markdown",
|
| 551 |
"metadata": {},
|
| 552 |
"source": [
|
| 553 |
+
"## 11 \u00b7 Build Training Dataset"
|
| 554 |
],
|
| 555 |
"id": "cell-md-dataset"
|
| 556 |
},
|
|
|
|
| 574 |
"cell_type": "markdown",
|
| 575 |
"metadata": {},
|
| 576 |
"source": [
|
| 577 |
+
"## 12 \u00b7 GRPO Training"
|
| 578 |
],
|
| 579 |
"id": "cell-md-train"
|
| 580 |
},
|
|
|
|
| 585 |
"outputs": [],
|
| 586 |
"source": [
|
| 587 |
"config = GRPOConfig(\n",
|
| 588 |
+
" output_dir = OUTPUT_DIR,\n",
|
| 589 |
+
" max_steps = TRAINING_STEPS,\n",
|
| 590 |
+
" per_device_train_batch_size = BATCH_SIZE,\n",
|
| 591 |
+
" gradient_accumulation_steps = 2,\n",
|
| 592 |
+
" num_generations = NUM_GENERATIONS,\n",
|
| 593 |
+
" max_prompt_length = 512,\n",
|
| 594 |
+
" max_completion_length = 80,\n",
|
| 595 |
+
" learning_rate = 5e-6,\n",
|
| 596 |
+
" adam_beta1 = 0.9,\n",
|
| 597 |
+
" adam_beta2 = 0.99,\n",
|
| 598 |
+
" weight_decay = 0.1,\n",
|
| 599 |
+
" warmup_ratio = 0.1,\n",
|
| 600 |
+
" lr_scheduler_type = 'cosine',\n",
|
| 601 |
+
" optim = 'adamw_8bit',\n",
|
| 602 |
+
" max_grad_norm = 0.1,\n",
|
| 603 |
+
" logging_steps = 1,\n",
|
| 604 |
+
" log_completions = True,\n",
|
| 605 |
+
" save_steps = TRAINING_STEPS,\n",
|
| 606 |
+
" report_to = 'none',\n",
|
| 607 |
+
" remove_unused_columns = False,\n",
|
| 608 |
+
" loss_type = 'dr_grpo',\n",
|
| 609 |
+
" importance_sampling_level = 'sequence',\n",
|
| 610 |
+
" mask_truncated_completions = False,\n",
|
| 611 |
")\n",
|
| 612 |
"\n",
|
| 613 |
"FastLanguageModel.for_training(model)\n",
|
| 614 |
"\n",
|
| 615 |
"trainer = GRPOTrainer(\n",
|
| 616 |
" model = model,\n",
|
|
|
|
| 617 |
" args = config,\n",
|
|
|
|
| 618 |
" processing_class = tokenizer,\n",
|
| 619 |
+
" reward_funcs = [formatting_reward_func, environment_reward_func],\n",
|
| 620 |
+
" train_dataset = dataset,\n",
|
| 621 |
")\n",
|
| 622 |
"\n",
|
| 623 |
+
"print(f'Starting GRPO training \u2014 {TRAINING_STEPS} steps (DR-GRPO) ...')\n",
|
| 624 |
"trainer.train()\n",
|
| 625 |
"\n",
|
| 626 |
"step_rewards = [\n",
|
| 627 |
+
" entry['reward']\n",
|
| 628 |
" for entry in trainer.state.log_history\n",
|
| 629 |
+
" if 'reward' in entry\n",
|
| 630 |
"]\n",
|
| 631 |
+
"print(f'\\nTraining complete. Reward log entries: {len(step_rewards)}')"
|
| 632 |
],
|
| 633 |
"id": "cell-train"
|
| 634 |
},
|
|
|
|
| 636 |
"cell_type": "markdown",
|
| 637 |
"metadata": {},
|
| 638 |
"source": [
|
| 639 |
+
"## 13 \u00b7 Post-Training Evaluation"
|
| 640 |
],
|
| 641 |
"id": "cell-md-posttrain"
|
| 642 |
},
|
|
|
|
| 656 |
"cell_type": "markdown",
|
| 657 |
"metadata": {},
|
| 658 |
"source": [
|
| 659 |
+
"## 14 \u00b7 Save LoRA Weights"
|
| 660 |
],
|
| 661 |
"id": "cell-md-save"
|
| 662 |
},
|
|
|
|
| 681 |
"cell_type": "markdown",
|
| 682 |
"metadata": {},
|
| 683 |
"source": [
|
| 684 |
+
"## 15 \u00b7 Results"
|
| 685 |
],
|
| 686 |
"id": "cell-md-results"
|
| 687 |
},
|
|
|
|
| 695 |
"tasks = list(baseline.keys())\n",
|
| 696 |
"\n",
|
| 697 |
"fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))\n",
|
| 698 |
+
"fig.suptitle(\"BugHunterEnv \u2014 GRPO Training (Gemma 4)\", fontsize=14, fontweight=\"bold\")\n",
|
| 699 |
"\n",
|
| 700 |
+
"# \u2500\u2500 Reward curve \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n",
|
| 701 |
"if step_rewards:\n",
|
| 702 |
" window = max(1, len(step_rewards) // 10)\n",
|
| 703 |
" smoothed = [\n",
|
|
|
|
| 717 |
" transform=ax1.transAxes, color=\"gray\", fontsize=12)\n",
|
| 718 |
" ax1.set_title(\"Training Reward Curve\")\n",
|
| 719 |
"\n",
|
| 720 |
+
"# \u2500\u2500 Before / after bars \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n",
|
| 721 |
"x = range(len(tasks))\n",
|
| 722 |
"b_bars = ax2.bar([i-0.2 for i in x], [baseline[t] for t in tasks],\n",
|
| 723 |
" width=0.38, label=\"Before\", color=\"#e07070\")\n",
|
|
|
|
| 729 |
" ha=\"center\", va=\"bottom\", fontsize=9)\n",
|
| 730 |
"ax2.set_xticks(list(x))\n",
|
| 731 |
"ax2.set_xticklabels(task_names)\n",
|
| 732 |
+
"ax2.set_ylabel(\"Task Grade (0 \u2013 1.0)\")\n",
|
| 733 |
"ax2.set_title(\"Task Performance: Before vs After\")\n",
|
| 734 |
"ax2.set_ylim(0, 1.3)\n",
|
| 735 |
"ax2.legend()\n",
|
|
|
|
| 741 |
"plt.show()\n",
|
| 742 |
"print(f\"Saved: {out_path}\")\n",
|
| 743 |
"\n",
|
| 744 |
+
"# \u2500\u2500 Summary table \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n",
|
| 745 |
"print(\"\\n\" + \"=\"*50)\n",
|
| 746 |
+
"print(f\"{'Task':<22} {'Before':>8} {'After':>8} {'\u0394':>8}\")\n",
|
| 747 |
"print(\"-\"*50)\n",
|
| 748 |
"for task, name in zip(tasks, task_names):\n",
|
| 749 |
" d = final[task] - baseline[task]\n",
|