dr4g0n369 Claude Sonnet 4.6 commited on
Commit
a9a44e8
·
1 Parent(s): fa5485d

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>

Files changed (1) hide show
  1. 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 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
- "- 🔓 **IDOR** Insecure Direct Object Reference\n",
31
- "- 💉 **SQL Injection** with WAF bypass\n",
32
- "- 📂 **Path Traversal** chained with privilege escalation\n",
33
  "\n",
34
- "**Runtime:** GPU T4 or better \n",
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('⚠️ No GPU detected. Go to Runtime Change runtime type GPU')\n",
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 · Install Dependencies"
59
  ],
60
  "id": "cell-md-install"
61
  },
@@ -64,14 +64,21 @@
64
  "execution_count": null,
65
  "metadata": {},
66
  "outputs": [],
67
- "source": "%%capture\n!pip install -q \"unsloth[colab-new]\" \"trl>=0.16\" datasets accelerate mergekit\n!pip install -q flask werkzeug requests openenv-core python-dotenv",
 
 
 
 
 
 
 
68
  "id": "cell-install"
69
  },
70
  {
71
  "cell_type": "markdown",
72
  "metadata": {},
73
  "source": [
74
- "## 2 · Clone Environment"
75
  ],
76
  "id": "cell-md-clone"
77
  },
@@ -87,7 +94,7 @@
87
  "cell_type": "markdown",
88
  "metadata": {},
89
  "source": [
90
- "## 3 · Imports"
91
  ],
92
  "id": "cell-md-imports"
93
  },
@@ -119,7 +126,7 @@
119
  "cell_type": "markdown",
120
  "metadata": {},
121
  "source": [
122
- "## 4 · Hyperparameters"
123
  ],
124
  "id": "cell-md-hyper"
125
  },
@@ -129,23 +136,23 @@
129
  "metadata": {},
130
  "outputs": [],
131
  "source": [
132
- "# ── Model ─────────────────────────────────────────────────────\n",
133
- "# Gemma 4 variants pick based on available VRAM:\n",
134
- "# gemma-4-4b-it ~4 GB in 4-bit T4 (15 GB), comfortable\n",
135
- "# gemma-4-12b-it ~8 GB in 4-bit T4 tight, A100 preferred\n",
136
- "# gemma-4-27b-it ~18 GB in 4-bit A100 40 GB+\n",
137
  "MODEL_NAME = \"unsloth/gemma-4-4b-it\"\n",
138
  "\n",
139
- "# ── LoRA ──────────────────────────────────────────────────────\n",
140
  "MAX_SEQ_LEN = 768\n",
141
  "LORA_RANK = 16\n",
142
  "\n",
143
- "# ── GRPO ──────────────────────────────────────────────────────\n",
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
- "# ── Eval + output ─────────────────────────────────────────────\n",
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 · System Prompt & Seeded Training States"
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 output only the request line\"\"\"\n",
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
- " # ── IDOR ────────────────────────────────────────────────\n",
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 try id=1 (admin).\",\n",
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 sensitive data returned without auth. Now target admin (id=1).\",\n",
211
  " },\n",
212
- " # ── SQL Injection ────────────────────────────────────────\n",
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 injectable. WAF blocks UNION SELECT, use UNION/**/SELECT bypass.\",\n",
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
- " # ── Path Traversal ───────────────────────────────────────\n",
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 escalate via SQLi first.\",\n",
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 use double-encoding: /api/files?name=..%252fetc%252fpasswd\",\n",
278
  " },\n",
279
  "]\n",
280
  "\n",
@@ -286,7 +293,7 @@
286
  "cell_type": "markdown",
287
  "metadata": {},
288
  "source": [
289
- "## 6 · Helper Functions"
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 everything after the method is the path\n",
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 · Reward Function"
 
 
 
 
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[\"task\"])\n",
368
- " for m, p, b in state[\"pre_actions\"]:\n",
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 reward_fn(completions: list, state_idx=None, **kwargs) -> list:\n",
382
- " \"\"\"TRL GRPOTrainer reward function interface.\"\"\"\n",
383
  " if state_idx is None:\n",
384
  " state_idx = [0] * len(completions)\n",
385
- " return [compute_reward(int(idx), c) for idx, c in zip(state_idx, completions)]\n",
 
 
 
 
 
386
  "\n",
387
  "\n",
388
- "# Quick sanity check\n",
389
- "test_reward = compute_reward(0, \"GET /api/users\")\n",
390
- "print(f\"Sanity check — state 0 + 'GET /api/users' → reward {test_reward:+.3f} (expected ~+0.15)\")"
 
391
  ],
392
  "id": "cell-reward"
393
  },
@@ -395,7 +415,7 @@
395
  "cell_type": "markdown",
396
  "metadata": {},
397
  "source": [
398
- "## 8 · Evaluation Function"
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–1).\"\"\"\n",
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} {obs.status_code} r={obs.reward:+.3f}\")\n",
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 · Load Model"
473
  ],
474
  "id": "cell-md-model"
475
  },
@@ -510,7 +530,7 @@
510
  "cell_type": "markdown",
511
  "metadata": {},
512
  "source": [
513
- "## 10 · Baseline Evaluation (Before Training)"
514
  ],
515
  "id": "cell-md-baseline"
516
  },
@@ -530,7 +550,7 @@
530
  "cell_type": "markdown",
531
  "metadata": {},
532
  "source": [
533
- "## 11 · Build Training Dataset"
534
  ],
535
  "id": "cell-md-dataset"
536
  },
@@ -554,7 +574,7 @@
554
  "cell_type": "markdown",
555
  "metadata": {},
556
  "source": [
557
- "## 12 · GRPO Training"
558
  ],
559
  "id": "cell-md-train"
560
  },
@@ -565,40 +585,50 @@
565
  "outputs": [],
566
  "source": [
567
  "config = GRPOConfig(\n",
568
- " output_dir = OUTPUT_DIR,\n",
569
- " num_train_epochs = 1,\n",
570
- " max_steps = TRAINING_STEPS,\n",
571
- " per_device_train_batch_size= BATCH_SIZE,\n",
572
- " num_generations = NUM_GENERATIONS,\n",
573
- " max_completion_length = 80,\n",
574
- " learning_rate = 5e-6,\n",
575
- " warmup_steps = 5,\n",
576
- " logging_steps = 5,\n",
577
- " save_steps = TRAINING_STEPS,\n",
578
- " temperature = 0.9,\n",
579
- " report_to = \"none\",\n",
580
- " remove_unused_columns = False,\n",
 
 
 
 
 
 
 
 
 
 
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\"Starting GRPO training {TRAINING_STEPS} steps ...\")\n",
594
  "trainer.train()\n",
595
  "\n",
596
  "step_rewards = [\n",
597
- " entry[\"reward\"]\n",
598
  " for entry in trainer.state.log_history\n",
599
- " if \"reward\" in entry\n",
600
  "]\n",
601
- "print(f\"\\nTraining complete. Reward log entries: {len(step_rewards)}\")"
602
  ],
603
  "id": "cell-train"
604
  },
@@ -606,7 +636,7 @@
606
  "cell_type": "markdown",
607
  "metadata": {},
608
  "source": [
609
- "## 13 · Post-Training Evaluation"
610
  ],
611
  "id": "cell-md-posttrain"
612
  },
@@ -626,7 +656,7 @@
626
  "cell_type": "markdown",
627
  "metadata": {},
628
  "source": [
629
- "## 14 · Save LoRA Weights"
630
  ],
631
  "id": "cell-md-save"
632
  },
@@ -651,7 +681,7 @@
651
  "cell_type": "markdown",
652
  "metadata": {},
653
  "source": [
654
- "## 15 · Results"
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 GRPO Training (Gemma 4)\", fontsize=14, fontweight=\"bold\")\n",
669
  "\n",
670
- "# ── Reward curve ──────────────────────────────────────────────\n",
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
- "# ── Before / after bars ───────────────────────────────────────\n",
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 1.0)\")\n",
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
- "# ── Summary table ─────────────────────────────────────────────\n",
715
  "print(\"\\n\" + \"=\"*50)\n",
716
- "print(f\"{'Task':<22} {'Before':>8} {'After':>8} {'Δ':>8}\")\n",
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",