rohitc1612 commited on
Commit
2b050c2
·
0 Parent(s):

Initial commit: vuln-patch-env OpenEnv hackathon submission

Browse files
Files changed (12) hide show
  1. .gitignore +5 -0
  2. Dockerfile +16 -0
  3. README.md +429 -0
  4. environment.py +226 -0
  5. inference.py +97 -0
  6. openenv.yaml +8 -0
  7. pyproject.toml +31 -0
  8. requirements.txt +6 -0
  9. server.py +10 -0
  10. server/__init__.py +4 -0
  11. server/app.py +124 -0
  12. uv.lock +0 -0
.gitignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ .env
2
+ .venv/
3
+ __pycache__/
4
+ *.pyc
5
+ .pytest_cache/
Dockerfile ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Install dependencies first for Docker layer caching
6
+ COPY requirements.txt .
7
+ RUN pip install --no-cache-dir -r requirements.txt
8
+
9
+ # Copy the rest of the application
10
+ COPY . .
11
+
12
+ # Expose the standard Hugging Face Spaces port
13
+ EXPOSE 7860
14
+
15
+ # Run the FastAPI server using the server package
16
+ CMD ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,429 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Vuln-Patch-Env
2
+
3
+ A real-world OpenEnv environment for training and evaluating AI agents on automated code vulnerability detection and patching.
4
+
5
+ ---
6
+
7
+ ## Overview
8
+
9
+ Vuln-Patch-Env simulates the task of **Static Application Security Testing (SAST) auto-remediation** — a genuine problem that security engineering teams solve daily. Given a snippet of Python code containing a known vulnerability, an agent must identify and patch the vulnerability without breaking the existing functionality.
10
+
11
+ The environment exposes three tasks of increasing difficulty, covering three of the most common vulnerability classes found in real-world codebases:
12
+
13
+ - **Hardcoded Secrets** (CWE-798)
14
+ - **SQL Injection via f-strings** (CWE-89)
15
+ - **Command Injection via os.system** (CWE-78)
16
+
17
+ The environment is fully compliant with the [OpenEnv](https://huggingface.co/openenv) specification and is designed to be used as a benchmark for evaluating LLM-based security agents.
18
+
19
+ ---
20
+
21
+ ## Motivation
22
+
23
+ Vulnerability remediation is a task that organizations spend significant engineering resources on. Automated patching tools exist but are largely rule-based and brittle. Training RL or LLM agents on a structured environment like this one opens the door to agents that can:
24
+
25
+ - Understand code semantics, not just surface patterns
26
+ - Generalize across variable names, table names, and command structures
27
+ - Learn to use safe APIs (parameterized queries, subprocess with argument lists) as a matter of policy
28
+
29
+ This environment fills a gap in the OpenEnv ecosystem by providing a **code security domain** benchmark with rigorous, AST-based grading that rewards only structurally correct fixes — not superficial string changes.
30
+
31
+ ---
32
+
33
+ ## Environment Specification
34
+
35
+ ### OpenEnv Compliance
36
+
37
+ | Interface | Implementation |
38
+ |---|---|
39
+ | `reset()` | Returns initial `Observation`. Resets step counter, generates fresh vulnerable code. |
40
+ | `step(action)` | Returns `(Observation, Reward, done: bool, Info)` |
41
+ | `state()` | Returns current `Observation` without advancing the episode |
42
+ | `close()` | No-op cleanup method satisfying the OpenEnv spec |
43
+ | Typed models | `Observation`, `Action`, `Reward`, `Info` all defined as Pydantic models |
44
+ | `openenv.yaml` | Present at project root with name, version, entrypoint, and task list |
45
+
46
+ ---
47
+
48
+ ## Data Models
49
+
50
+ ### Observation
51
+
52
+ Returned by `reset()`, `step()`, and `state()`.
53
+
54
+ | Field | Type | Description |
55
+ |---|---|---|
56
+ | `step` | `int` | Current step index within the episode (starts at 0) |
57
+ | `vulnerability_type` | `str` | Task identifier: `"easy"`, `"medium"`, or `"hard"` |
58
+ | `current_code` | `str` | The Python source code containing the vulnerability to be fixed |
59
+ | `linter_output` | `str` | Output from the security scanner. `"Not run yet."` until a scan is performed |
60
+
61
+ ### Action
62
+
63
+ Submitted by the agent on each step.
64
+
65
+ | Field | Type | Required | Description |
66
+ |---|---|---|---|
67
+ | `action_type` | `str` | Always | Either `"run_scan"` or `"submit_patch"` |
68
+ | `patched_code` | `str` | Only for `"submit_patch"` | The complete fixed Python code as a string |
69
+
70
+ ### Reward
71
+
72
+ | Field | Type | Description |
73
+ |---|---|---|
74
+ | `value` | `float` | Scalar reward in the range `[0.0, 1.0]` |
75
+
76
+ ### Info
77
+
78
+ | Field | Type | Description |
79
+ |---|---|---|
80
+ | `error` | `str` or `None` | Environment-level error message, if any |
81
+
82
+ ---
83
+
84
+ ## Action Space
85
+
86
+ The agent has exactly two possible action types per step:
87
+
88
+ **1. `run_scan`**
89
+ Triggers the security linter. Returns a hint in the `linter_output` field of the next observation. Grants a small reward (`+0.1`) as a partial progress signal, encouraging the agent to gather information before patching.
90
+
91
+ **2. `submit_patch`**
92
+ Submits the agent's proposed fix via the `patched_code` field. The environment grades the patch using a hybrid AST + string analysis grader and returns a reward between `0.0` and `1.0`. The episode ends immediately upon submission.
93
+
94
+ ---
95
+
96
+ ## Observation Space
97
+
98
+ The agent receives a structured JSON observation on every step containing:
99
+
100
+ - The **current step index** so the agent can manage its budget
101
+ - The **vulnerability type** so the agent knows what class of problem it is solving
102
+ - The **current source code** — the same vulnerable snippet on every step (the environment does not modify the code between steps)
103
+ - The **linter output** — populated only after `run_scan` is called, providing a natural-language hint about the vulnerability class
104
+
105
+ ---
106
+
107
+ ## Tasks and Difficulty
108
+
109
+ ### Task 1 — Easy: Hardcoded Secrets (CWE-798)
110
+
111
+ **Objective:** Remove a hardcoded API key from the source code and replace it with a call to `os.getenv()` or an equivalent safe environment-variable access pattern.
112
+
113
+ **Example vulnerable code generated:**
114
+ ```python
115
+ import os
116
+
117
+ def fetch_resource():
118
+ ACCESS_KEY = 'sk-A7FX29KQBR1NWLTZ'
119
+ return fetch(ACCESS_KEY)
120
+ ```
121
+
122
+ **Expected fix:**
123
+ ```python
124
+ import os
125
+
126
+ def fetch_resource():
127
+ ACCESS_KEY = os.getenv('ACCESS_KEY')
128
+ return fetch(ACCESS_KEY)
129
+ ```
130
+
131
+ **Grading breakdown:**
132
+ | Condition | Reward |
133
+ |---|---|
134
+ | Hardcoded secret string is absent from the patched code | +0.5 |
135
+ | Code uses a safe env-var access pattern (AST-verified) | +0.5 |
136
+ | **Maximum total** | **1.0** |
137
+
138
+ The grader accepts all of the following valid patterns via AST analysis:
139
+ - `os.getenv("KEY")`
140
+ - `os.environ.get("KEY")`
141
+ - `os.environ["KEY"]`
142
+ - Any bare reference to `os.environ`
143
+
144
+ ---
145
+
146
+ ### Task 2 — Medium: SQL Injection via f-strings (CWE-89)
147
+
148
+ **Objective:** Replace an f-string SQL query with a parameterized query to prevent SQL injection.
149
+
150
+ **Example vulnerable code generated:**
151
+ ```python
152
+ import sqlite3
153
+
154
+ def get_users(name):
155
+ query = f"SELECT * FROM users WHERE name='{name}'"
156
+ cursor.execute(query)
157
+ ```
158
+
159
+ **Expected fix:**
160
+ ```python
161
+ import sqlite3
162
+
163
+ def get_users(name):
164
+ query = "SELECT * FROM users WHERE name=?"
165
+ cursor.execute(query, (name,))
166
+ ```
167
+
168
+ **Grading breakdown:**
169
+ | Condition | Reward |
170
+ |---|---|
171
+ | f-string SQL construction is absent from patched code | +0.4 |
172
+ | `cursor.execute()` is called with a parameter argument (AST-verified) | +0.6 |
173
+ | Fallback: patch contains `?` or `%s` placeholder (string check) | +0.4 (instead of +0.6) |
174
+ | **Maximum total** | **1.0** |
175
+
176
+ ---
177
+
178
+ ### Task 3 — Hard: Command Injection via os.system (CWE-78)
179
+
180
+ **Objective:** Replace an `os.system()` call that passes unsanitized user input with a `subprocess.run()` call using a separated argument list, which prevents shell injection.
181
+
182
+ **Example vulnerable code generated:**
183
+ ```python
184
+ import os
185
+ import subprocess
186
+
187
+ def run_util(user_arg):
188
+ os.system(f'ping -c 4 {user_arg}')
189
+ ```
190
+
191
+ **Expected fix:**
192
+ ```python
193
+ import subprocess
194
+
195
+ def run_util(user_arg):
196
+ subprocess.run(["ping", "-c", "4", user_arg])
197
+ ```
198
+
199
+ **Grading breakdown:**
200
+ | Condition | Reward |
201
+ |---|---|
202
+ | `os.system` is absent from the patched code | +0.3 |
203
+ | `subprocess.run()` (or equivalent) is called with a list argument and no `shell=True` (AST-verified) | +0.7 |
204
+ | Fallback: patch contains `subprocess` with list brackets (string check) | +0.4 (instead of +0.7) |
205
+ | **Maximum total** | **1.0** |
206
+
207
+ ---
208
+
209
+ ## Reward Function Design
210
+
211
+ The reward function is designed to provide **meaningful signal over the full trajectory**, not just at episode end.
212
+
213
+ | Event | Reward | Rationale |
214
+ |---|---|---|
215
+ | `run_scan` action | +0.1 | Encourages information gathering before patching |
216
+ | Correct patch (full credit) | +1.0 | Agent removed the vulnerability and used the correct safe API |
217
+ | Partial patch (fallback) | +0.4 to +0.8 | Agent improved security but did not use the ideal pattern |
218
+ | Reaching step 5 without completing | -0.2 | Penalizes agents that loop without making progress |
219
+ | All rewards | Clamped to `[0.0, 1.0]` | Strict compliance with OpenEnv spec |
220
+
221
+ An episode ends when the agent calls `submit_patch` or when the step count reaches 5 (whichever comes first).
222
+
223
+ ---
224
+
225
+ ## Grading Methodology
226
+
227
+ A key design decision in this environment is the use of **Python's Abstract Syntax Tree (AST) module** for grading, rather than simple string matching or regex. This makes the grader:
228
+
229
+ - **Robust to formatting differences** — whitespace, line breaks, and quote style do not affect the grade
230
+ - **Semantically accurate** — a patch is only credited if the correct API is actually *called* in the code, not just mentioned in a comment
231
+ - **Resistant to false positives** — for example, `subprocess.run(..., shell=True)` is explicitly detected and rejected even though it contains `subprocess`
232
+
233
+ Each grader function (`uses_os_getenv`, `uses_parameterized_query`, `uses_safe_subprocess`) parses the submitted code into an AST and walks the node tree to verify the structural correctness of the fix. All graders fall back to string-based checks if the AST check is inconclusive, ensuring partial credit is awarded for genuinely improved but imperfect patches.
234
+
235
+ ---
236
+
237
+ ## Dynamic Code Generation
238
+
239
+ To prevent an LLM agent from memorizing fixed vulnerable snippets, the environment **generates code dynamically** on each `reset()` call by randomly selecting from pools of variable names, function names, table names, SQL fields, and shell commands. A fixed random seed (`seed=42`) is applied at the start of each `reset()` to ensure that baseline scores are fully reproducible across runs.
240
+
241
+ ---
242
+
243
+ ## Episode Lifecycle
244
+
245
+ ```
246
+ reset(task)
247
+ |
248
+ v
249
+ Observation (step=0, vulnerable code, linter="Not run yet.")
250
+ |
251
+ v
252
+ Agent calls run_scan --> reward=0.1, linter hint populated
253
+ |
254
+ v
255
+ Agent calls submit_patch --> grader runs, reward=0.0–1.0, done=True
256
+ |
257
+ v
258
+ [END] logged, episode complete
259
+ ```
260
+
261
+ Maximum steps per episode: **5**. If the agent does not submit a patch within 5 steps, the episode is forcibly ended with a `-0.2` penalty.
262
+
263
+ ---
264
+
265
+ ## Baseline Inference Script
266
+
267
+ The `inference.py` script runs a full three-task evaluation loop using any OpenAI-compatible LLM endpoint.
268
+
269
+ **Required environment variables:**
270
+
271
+ | Variable | Description | Default |
272
+ |---|---|---|
273
+ | `HF_TOKEN` | Hugging Face API token (or any OpenAI-compatible API key) | None (required) |
274
+ | `MODEL_NAME` | Model identifier to use for inference | `meta-llama/Llama-3.3-70B-Instruct` |
275
+ | `API_BASE_URL` | Base URL for the OpenAI-compatible inference endpoint | `https://router.huggingface.co/v1` |
276
+
277
+ **Running the baseline:**
278
+ ```bash
279
+ export HF_TOKEN="your_hf_token_here"
280
+ export MODEL_NAME="meta-llama/Llama-3.3-70B-Instruct"
281
+ export API_BASE_URL="https://router.huggingface.co/v1"
282
+
283
+ python inference.py
284
+ ```
285
+
286
+ **Expected stdout format:**
287
+ ```
288
+ [START] task=easy env=vuln-patch-env model=meta-llama/Llama-3.3-70B-Instruct
289
+ [STEP] step=1 action=run_scan() reward=0.10 done=false error=null
290
+ [STEP] step=2 action=submit_patch() reward=1.00 done=true error=null
291
+ [END] success=true steps=2 score=1.00 rewards=0.10,1.00
292
+
293
+ [START] task=medium env=vuln-patch-env model=meta-llama/Llama-3.3-70B-Instruct
294
+ [STEP] step=1 action=submit_patch() reward=1.00 done=true error=null
295
+ [END] success=true steps=1 score=1.00 rewards=1.00
296
+
297
+ [START] task=hard env=vuln-patch-env model=meta-llama/Llama-3.3-70B-Instruct
298
+ [STEP] step=1 action=run_scan() reward=0.10 done=false error=null
299
+ [STEP] step=2 action=submit_patch() reward=1.00 done=true error=null
300
+ [END] success=true steps=2 score=1.00 rewards=0.10,1.00
301
+ ```
302
+
303
+ **Baseline scores (Llama-3.3-70B-Instruct, temperature=0.0):**
304
+
305
+ | Task | Expected Score |
306
+ |---|---|
307
+ | easy | 1.00 |
308
+ | medium | >= 0.80 |
309
+ | hard | >= 0.80 |
310
+
311
+ ---
312
+
313
+ ## REST API Reference
314
+
315
+ The environment is served as a FastAPI application. All endpoints are available once the server is running.
316
+
317
+ | Method | Endpoint | Description |
318
+ |---|---|---|
319
+ | `GET` | `/` | Health check, returns server status |
320
+ | `GET` | `/health` | Returns `{"status": "healthy"}` — required by `openenv validate` |
321
+ | `GET` | `/metadata` | Returns environment name, description, version, and task list |
322
+ | `GET` | `/schema` | Returns JSON schemas for `Action`, `Observation`, and state |
323
+ | `POST` | `/mcp` | Minimal MCP (Model Context Protocol) endpoint — JSON-RPC 2.0 |
324
+ | `POST` | `/reset` | Resets the environment. Accepts `{"task": "easy" | "medium" | "hard"}` |
325
+ | `POST` | `/step` | Takes one step. Accepts an `Action` object as JSON body |
326
+ | `GET` | `/state` | Returns the current observation without advancing the episode |
327
+
328
+ **Example: Reset to the hard task**
329
+ ```bash
330
+ curl -X POST http://localhost:7860/reset \
331
+ -H "Content-Type: application/json" \
332
+ -d '{"task": "hard"}'
333
+ ```
334
+
335
+ **Example: Submit a patch**
336
+ ```bash
337
+ curl -X POST http://localhost:7860/step \
338
+ -H "Content-Type: application/json" \
339
+ -d '{"action_type": "submit_patch", "patched_code": "import subprocess\n\ndef run_util(user_arg):\n subprocess.run([\"ping\", \"-c\", \"4\", user_arg])"}'
340
+ ```
341
+
342
+ ---
343
+
344
+ ## Project Structure
345
+
346
+ ```
347
+ vuln-patch-env/
348
+ |
349
+ |-- server/
350
+ | |-- __init__.py # Exports the FastAPI app
351
+ | +-- app.py # FastAPI server with all OpenEnv-required endpoints
352
+ |
353
+ |-- environment.py # Core environment: Pydantic models, AST graders, VulnPatchEnv class
354
+ |-- inference.py # Baseline inference script using OpenAI-compatible client
355
+ |-- server.py # Root-level entry point (re-exports server/app.py)
356
+ |-- openenv.yaml # OpenEnv metadata declaration
357
+ |-- pyproject.toml # Python package configuration
358
+ |-- requirements.txt # Pinned dependencies
359
+ |-- Dockerfile # Container definition for Hugging Face Spaces deployment
360
+ |-- README.md # This file
361
+ +-- .gitignore
362
+ ```
363
+
364
+ ---
365
+
366
+ ## Setup and Local Usage
367
+
368
+ **1. Clone the repository and install dependencies:**
369
+ ```bash
370
+ git clone https://github.com/YOUR_USERNAME/vuln-patch-env.git
371
+ cd vuln-patch-env
372
+ pip install -r requirements.txt
373
+ ```
374
+
375
+ **2. Start the server locally:**
376
+ ```bash
377
+ uvicorn server.app:app --host 0.0.0.0 --port 7860
378
+ ```
379
+
380
+ **3. Validate the OpenEnv spec compliance:**
381
+ ```bash
382
+ openenv validate .
383
+ ```
384
+
385
+ **4. Use the environment directly in Python:**
386
+ ```python
387
+ from environment import VulnPatchEnv, Action
388
+
389
+ # Run the easy task
390
+ env = VulnPatchEnv(task="easy")
391
+ obs = env.reset()
392
+ print(obs.current_code)
393
+
394
+ # Get a hint
395
+ obs, reward, done, info = env.step(Action(action_type="run_scan"))
396
+ print(obs.linter_output)
397
+
398
+ # Submit a patch
399
+ patched = obs.current_code.replace("ACCESS_KEY = 'sk-...'", "ACCESS_KEY = os.getenv('ACCESS_KEY')")
400
+ obs, reward, done, info = env.step(Action(action_type="submit_patch", patched_code=patched))
401
+ print(f"Reward: {reward.value}")
402
+
403
+ env.close()
404
+ ```
405
+
406
+ ---
407
+
408
+ ## Docker
409
+
410
+ **Build and run locally:**
411
+ ```bash
412
+ docker build -t vuln-patch-env .
413
+ docker run -p 7860:7860 vuln-patch-env
414
+ ```
415
+
416
+ The server will be available at `http://localhost:7860`.
417
+
418
+ ---
419
+
420
+ ## Dependencies
421
+
422
+ | Package | Version | Purpose |
423
+ |---|---|---|
424
+ | `openai` | 2.30.0 | OpenAI-compatible client for inference script |
425
+ | `pydantic` | 2.12.5 | Typed data models for Observation, Action, Reward, Info |
426
+ | `fastapi` | 0.135.3 | REST API server |
427
+ | `uvicorn` | 0.44.0 | ASGI server for FastAPI |
428
+ | `openenv-core` | 0.2.3 | OpenEnv spec utilities and validation |
429
+ | `python-dotenv` | 1.2.2 | Environment variable loading from `.env` files
environment.py ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import ast
2
+ import random
3
+ import string
4
+ from typing import Optional
5
+
6
+ from pydantic import BaseModel
7
+
8
+
9
+ # 1. OpenEnv Typed Models
10
+ class Observation(BaseModel):
11
+ step: int
12
+ vulnerability_type: str
13
+ current_code: str
14
+ linter_output: str
15
+
16
+
17
+ class Action(BaseModel):
18
+ action_type: str # e.g., "run_scan" or "submit_patch"
19
+ patched_code: Optional[str] = ""
20
+
21
+
22
+ class Reward(BaseModel):
23
+ value: float
24
+
25
+
26
+ class Info(BaseModel):
27
+ error: Optional[str] = None
28
+
29
+
30
+ # AST Checkers for Robust Grading
31
+ def uses_os_getenv(code: str) -> bool:
32
+ """
33
+ Returns True if the code uses any safe environment-variable access pattern:
34
+ - os.getenv("KEY") -> ast.Call with Attribute attr='getenv'
35
+ - os.environ.get("KEY") -> ast.Call with chained Attribute: environ -> get
36
+ - os.environ["KEY"] -> ast.Subscript on os.environ
37
+ - bare os.environ reference -> ast.Attribute with attr='environ'
38
+ """
39
+ try:
40
+ tree = ast.parse(code)
41
+ for node in ast.walk(tree):
42
+ # Pattern 1: os.getenv(...) or getenv(...)
43
+ if isinstance(node, ast.Call):
44
+ func = node.func
45
+ if isinstance(func, ast.Attribute) and func.attr == "getenv":
46
+ return True
47
+ if isinstance(func, ast.Name) and func.id == "getenv":
48
+ return True
49
+ # Pattern 2: os.environ.get(...) — chained call
50
+ if (
51
+ isinstance(func, ast.Attribute)
52
+ and func.attr == "get"
53
+ and isinstance(func.value, ast.Attribute)
54
+ and func.value.attr == "environ"
55
+ ):
56
+ return True
57
+ # Pattern 3: os.environ["KEY"] — subscript access
58
+ if isinstance(node, ast.Subscript):
59
+ val = node.value
60
+ if isinstance(val, ast.Attribute) and val.attr == "environ":
61
+ return True
62
+ # Pattern 4: bare os.environ reference (e.g. env = os.environ)
63
+ if isinstance(node, ast.Attribute) and node.attr == "environ":
64
+ return True
65
+ except SyntaxError:
66
+ pass
67
+ return False
68
+
69
+
70
+ def uses_parameterized_query(code: str) -> bool:
71
+ try:
72
+ tree = ast.parse(code)
73
+ for node in ast.walk(tree):
74
+ if isinstance(node, ast.Call):
75
+ # Simple heuristic mapping for typical execution paths
76
+ if hasattr(node.func, "attr") and node.func.attr == "execute":
77
+ # length of args > 1 indicates query + params, or uses named keywords like parameters=...
78
+ if len(node.args) > 1 or (
79
+ node.keywords
80
+ and any(
81
+ k.arg in ("parameters", "params") for k in node.keywords
82
+ )
83
+ ):
84
+ return True
85
+ # Check if any arg is explicitly a tuple or dictionary, common in SQLite parameterization
86
+ for arg in node.args:
87
+ if isinstance(arg, ast.Tuple) or isinstance(arg, ast.Dict):
88
+ return True
89
+ except SyntaxError:
90
+ pass
91
+ return False
92
+
93
+
94
+ def uses_safe_subprocess(code: str) -> bool:
95
+ try:
96
+ tree = ast.parse(code)
97
+ for node in ast.walk(tree):
98
+ if isinstance(node, ast.Call):
99
+ if hasattr(node.func, "attr") and node.func.attr in (
100
+ "run",
101
+ "Popen",
102
+ "call",
103
+ "check_call",
104
+ "check_output",
105
+ ):
106
+ # Ensure shell=True is NOT passed
107
+ is_shell = False
108
+ for kw in node.keywords:
109
+ if (
110
+ kw.arg == "shell"
111
+ and isinstance(kw.value, ast.Constant)
112
+ and kw.value.value is True
113
+ ):
114
+ is_shell = True
115
+ if not is_shell:
116
+ # Ensure first argument is a list (args array)
117
+ if node.args and isinstance(node.args[0], ast.List):
118
+ return True
119
+ except SyntaxError:
120
+ pass
121
+ return False
122
+
123
+
124
+ # 2. Environment Implementation
125
+ class VulnPatchEnv:
126
+ def __init__(self, task="easy"):
127
+ self.task = task
128
+ self.step_count = 0
129
+ self.done = False
130
+ self.code = ""
131
+ self.linter = ""
132
+ self.target_secret = ""
133
+ self.reset()
134
+
135
+ def reset(self) -> Observation:
136
+ random.seed(42) # Fixed seed for reproducible baseline scores
137
+ self.step_count = 0
138
+ self.done = False
139
+ self.linter = "Not run yet."
140
+
141
+ # Dynamic variable generation prevents simple string memorization by the LLM
142
+ if self.task == "easy":
143
+ key_val = "sk-" + "".join(
144
+ random.choices(string.ascii_uppercase + string.digits, k=16)
145
+ )
146
+ var_name = random.choice(
147
+ ["API_KEY", "SECRET_KEY", "AUTH_TOKEN", "ACCESS_KEY"]
148
+ )
149
+ func_name = random.choice(["get_data", "fetch_resource", "load_user"])
150
+ self.code = f"import os\n\ndef {func_name}():\n {var_name} = '{key_val}'\n return fetch({var_name})"
151
+ self.target_secret = key_val
152
+
153
+ elif self.task == "medium":
154
+ table = random.choice(["users", "accounts", "employees", "orders"])
155
+ field = random.choice(["name", "email", "username", "id"])
156
+ self.code = f"import sqlite3\n\ndef get_{table}({field}):\n query = f\"SELECT * FROM {table} WHERE {field}='{{{field}}}'\"\n cursor.execute(query)"
157
+
158
+ elif self.task == "hard":
159
+ cmd = random.choice(["ping -c 4", "ls -l", "curl", "nmap"])
160
+ self.code = f"import os\nimport subprocess\n\ndef run_util(user_arg):\n os.system(f'{cmd} {{user_arg}}')"
161
+
162
+ else:
163
+ self.code = "Unknown task."
164
+ self.target_secret = ""
165
+
166
+ return self.state()
167
+
168
+ def state(self) -> Observation:
169
+ return Observation(
170
+ step=self.step_count,
171
+ vulnerability_type=self.task,
172
+ current_code=self.code,
173
+ linter_output=self.linter,
174
+ )
175
+
176
+ def step(self, action: Action) -> tuple[Observation, Reward, bool, Info]:
177
+ self.step_count += 1
178
+ reward_val = 0.0
179
+
180
+ if action.action_type == "run_scan":
181
+ self.linter = "SECURITY SCAN: Vulnerability detected. Fix hardcoded secrets, SQLi, or Command Injection."
182
+ reward_val = 0.1 # Incremental progress signal
183
+
184
+ elif action.action_type == "submit_patch":
185
+ patched = action.patched_code if action.patched_code else ""
186
+
187
+ # Hybrid AST/String Grading for robustness against formatting
188
+ if self.task == "easy":
189
+ if self.target_secret and self.target_secret not in patched:
190
+ reward_val += 0.5
191
+ if uses_os_getenv(patched):
192
+ reward_val += 0.5
193
+
194
+ elif self.task == "medium":
195
+ if 'f"SELECT' not in patched and "f'SELECT" not in patched:
196
+ reward_val += 0.4
197
+ if uses_parameterized_query(patched):
198
+ reward_val += 0.6
199
+ elif "?" in patched or "%s" in patched: # Fallback text format check
200
+ reward_val += 0.4
201
+
202
+ elif self.task == "hard":
203
+ if "os.system" not in patched:
204
+ reward_val += 0.3
205
+ if uses_safe_subprocess(patched):
206
+ reward_val += 0.7
207
+ elif (
208
+ "subprocess" in patched and "[" in patched and "]" in patched
209
+ ): # Fallback text format check
210
+ reward_val += 0.4
211
+
212
+ self.done = True
213
+
214
+ # Hard limit to prevent infinite loops (Penalize logic per OpenEnv spec requirement)
215
+ if self.step_count >= 5 and not self.done:
216
+ self.done = True
217
+ reward_val -= 0.2
218
+
219
+ # Ensure reward is strictly between 0.0 and 1.0 per OpenEnv spec requirement
220
+ reward_val = min(max(reward_val, 0.0), 1.0)
221
+
222
+ return self.state(), Reward(value=reward_val), self.done, Info()
223
+
224
+ def close(self) -> None:
225
+ """No-op cleanup method required by the OpenEnv spec."""
226
+ pass
inference.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+
4
+ from dotenv import load_dotenv
5
+
6
+ load_dotenv()
7
+ from openai import OpenAI
8
+
9
+ from environment import Action, VulnPatchEnv
10
+
11
+ # Strict Environment Variables required by the hackathon rubric
12
+ API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
13
+ MODEL_NAME = os.getenv("MODEL_NAME", "meta-llama/Llama-3.3-70B-Instruct")
14
+ HF_TOKEN = os.getenv("HF_TOKEN") or os.getenv("API_KEY")
15
+
16
+ if not HF_TOKEN:
17
+ print("WARNING: HF_TOKEN is missing. API calls will fail.", flush=True)
18
+
19
+ client = OpenAI(base_url=API_BASE_URL, api_key=HF_TOKEN or "dummy-key")
20
+
21
+
22
+ def run_episode(task_name: str):
23
+ env = VulnPatchEnv(task=task_name)
24
+ obs = env.reset()
25
+
26
+ # REQUIRED [START] line
27
+ print(f"[START] task={task_name} env=vuln-patch-env model={MODEL_NAME}", flush=True)
28
+
29
+ done = False
30
+ rewards = []
31
+
32
+ try:
33
+ while not done:
34
+ prompt = (
35
+ f"Task: Fix the vulnerability in the code.\n"
36
+ f"Observation: {obs.model_dump_json()}\n"
37
+ "Output valid JSON ONLY. Required keys:\n"
38
+ "- 'action_type': Must be 'run_scan' or 'submit_patch'.\n"
39
+ "- 'patched_code': The patched python code (string). Only required if submitting."
40
+ )
41
+
42
+ try:
43
+ response = client.chat.completions.create(
44
+ model=MODEL_NAME,
45
+ messages=[
46
+ {
47
+ "role": "system",
48
+ "content": "You are a cyber security agent who is an expert in python and security. You are given a task to fix the vulnerability in the code and can find bugs in the code also. Always output valid JSON.",
49
+ },
50
+ {"role": "user", "content": prompt},
51
+ ],
52
+ response_format={"type": "json_object"},
53
+ temperature=0.0, # Deterministic LLM response
54
+ timeout=60, # Prevent indefinite hang on slow API
55
+ )
56
+ raw_content = response.choices[0].message.content
57
+ action_data = json.loads(raw_content)
58
+ action = Action(**action_data)
59
+ error_msg = "null"
60
+ except Exception as e:
61
+ action = Action(action_type="error", patched_code="")
62
+ error_msg = str(e).replace("\n", " ")
63
+
64
+ # Step the environment
65
+ obs, reward_obj, done, info = env.step(action)
66
+ reward = reward_obj.value
67
+ rewards.append(reward)
68
+
69
+ # Use environment's info.error if set, else fall back to LLM error, else null
70
+ env_error = info.error if info.error else None
71
+ step_error = env_error or (error_msg if error_msg != "null" else None)
72
+ step_error_str = step_error if step_error else "null"
73
+
74
+ # REQUIRED [STEP] line (no newlines, 2 decimal places, lowercase bools)
75
+ action_safe_str = f"{action.action_type}()"
76
+ done_str = "true" if done else "false"
77
+ print(
78
+ f"[STEP] step={env.step_count} action={action_safe_str} reward={reward:.2f} done={done_str} error={step_error_str}",
79
+ flush=True,
80
+ )
81
+
82
+ finally:
83
+ env.close()
84
+ # REQUIRED [END] line — always emitted even on exception, score to 2 decimal places
85
+ score = rewards[-1] if rewards else 0.0
86
+ score = min(max(score, 0.0), 1.0) # Clamp score to 0.0 - 1.0
87
+ success_str = "true" if score >= 0.8 else "false"
88
+ rewards_str = ",".join([f"{r:.2f}" for r in rewards])
89
+ print(
90
+ f"[END] success={success_str} steps={env.step_count} score={score:.2f} rewards={rewards_str}",
91
+ flush=True,
92
+ )
93
+
94
+
95
+ if __name__ == "__main__":
96
+ for t in ["easy", "medium", "hard"]:
97
+ run_episode(t)
openenv.yaml ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ name: "vuln-patch-env"
2
+ version: "1.0.0"
3
+ entrypoint: "environment:VulnPatchEnv"
4
+ description: "A real-world code security environment where agents detect and patch CVEs (Hardcoded Secrets, SQLi, Command Injection) without breaking functionality."
5
+ tasks:
6
+ - easy
7
+ - medium
8
+ - hard
pyproject.toml ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.backends.legacy:build"
4
+
5
+ [project]
6
+ name = "vuln-patch-env"
7
+ version = "1.0.0"
8
+ description = "A real-world code security environment where AI agents detect and patch vulnerabilities"
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ "openai",
12
+ "pydantic",
13
+ "fastapi",
14
+ "uvicorn",
15
+ "openenv-core",
16
+ "python-dotenv",
17
+ ]
18
+
19
+ [project.scripts]
20
+ server = "server.app:main"
21
+
22
+ [project.urls]
23
+ Homepage = "https://huggingface.co/spaces"
24
+
25
+ [tool.setuptools]
26
+ include-package-data = true
27
+ packages = ["server"]
28
+ package-dir = { "server" = "server" }
29
+
30
+ [tool.openenv]
31
+ environment = "environment:VulnPatchEnv"
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ openai==2.30.0
2
+ pydantic==2.12.5
3
+ fastapi==0.135.3
4
+ uvicorn==0.44.0
5
+ openenv-core==0.2.3
6
+ python-dotenv==1.2.2
server.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ server.py — Root-level entry point for backwards compatibility.
3
+ The canonical server is defined in server/app.py.
4
+ """
5
+ from server.app import app, main
6
+
7
+ __all__ = ["app"]
8
+
9
+ if __name__ == "__main__":
10
+ main()
server/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ # server package
2
+ from server.app import app
3
+
4
+ __all__ = ["app"]
server/app.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ server/app.py — OpenEnv-compatible FastAPI application.
3
+ This is the canonical app entry point expected by `openenv validate`.
4
+ The root server.py re-exports this for backwards compatibility.
5
+ """
6
+ from fastapi import FastAPI, Request
7
+ from environment import VulnPatchEnv
8
+
9
+ app = FastAPI(
10
+ title="vuln-patch-env",
11
+ description="OpenEnv environment for code vulnerability detection and patching.",
12
+ version="1.0.0",
13
+ )
14
+
15
+ # One shared environment instance per server process (stateless reset on each call)
16
+ _env = VulnPatchEnv()
17
+
18
+
19
+ @app.get("/")
20
+ async def health_check():
21
+ return {"status": "running", "message": "vuln-patch-env OpenEnv Server is live"}
22
+
23
+
24
+ @app.get("/health")
25
+ async def health():
26
+ """Required by openenv validate — must return {"status": "healthy"}."""
27
+ return {"status": "healthy"}
28
+
29
+
30
+ @app.get("/metadata")
31
+ async def metadata():
32
+ """Required by openenv validate — must return name and description."""
33
+ return {
34
+ "name": "vuln-patch-env",
35
+ "description": (
36
+ "A real-world code security environment where AI agents detect "
37
+ "and patch vulnerabilities (hardcoded secrets, SQL injection, "
38
+ "command injection) in Python code."
39
+ ),
40
+ "version": "1.0.0",
41
+ "tasks": ["easy", "medium", "hard"],
42
+ }
43
+
44
+
45
+ @app.get("/schema")
46
+ async def schema():
47
+ """Required by openenv validate — must return action, observation and state schemas."""
48
+ from environment import Action, Observation
49
+ return {
50
+ "action": Action.model_json_schema(),
51
+ "observation": Observation.model_json_schema(),
52
+ "state": Observation.model_json_schema(), # state has same shape as observation
53
+ }
54
+
55
+
56
+ @app.post("/mcp")
57
+ async def mcp_endpoint(request: Request):
58
+ """
59
+ Minimal Model Context Protocol (MCP) endpoint.
60
+ Required by openenv validate — must return a JSON-RPC 2.0 envelope.
61
+ """
62
+ try:
63
+ body = await request.json()
64
+ except Exception:
65
+ body = {}
66
+
67
+ return {
68
+ "jsonrpc": "2.0",
69
+ "id": body.get("id", 1),
70
+ "result": {
71
+ "name": "vuln-patch-env",
72
+ "description": "OpenEnv environment for code vulnerability patching.",
73
+ "tools": ["reset", "step", "state"],
74
+ },
75
+ }
76
+
77
+
78
+ @app.post("/reset")
79
+ async def reset_endpoint(request: Request):
80
+ """Reset the environment and return the initial observation."""
81
+ try:
82
+ body = await request.json()
83
+ task = body.get("task", "easy")
84
+ except Exception:
85
+ task = "easy"
86
+
87
+ _env.task = task
88
+ obs = _env.reset()
89
+ return {"status": "ok", "observation": obs.model_dump()}
90
+
91
+
92
+ @app.post("/step")
93
+ async def step_endpoint(request: Request):
94
+ """Take one step in the environment."""
95
+ from environment import Action
96
+ try:
97
+ body = await request.json()
98
+ action = Action(**body)
99
+ except Exception as e:
100
+ return {"error": str(e)}, 400
101
+
102
+ obs, reward, done, info = _env.step(action)
103
+ return {
104
+ "observation": obs.model_dump(),
105
+ "reward": reward.value,
106
+ "done": done,
107
+ "info": info.model_dump(),
108
+ }
109
+
110
+
111
+ @app.get("/state")
112
+ async def state_endpoint():
113
+ """Return the current environment state."""
114
+ return {"observation": _env.state().model_dump()}
115
+
116
+
117
+ def main():
118
+ """Entry point for the server script (used by pyproject.toml [project.scripts])."""
119
+ import uvicorn
120
+ uvicorn.run("server.app:app", host="0.0.0.0", port=7860, reload=False)
121
+
122
+
123
+ if __name__ == "__main__":
124
+ main()
uv.lock ADDED
The diff for this file is too large to render. See raw diff