@murk-security/runner-linux-x64 0.0.0-stage → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/README.md +2 -2
  2. package/bin/harness +0 -0
  3. package/package.json +15 -4
  4. package/runners/basic_harness_run.py +1276 -0
  5. package/runners/checklists/anonymous.json +24 -0
  6. package/runners/cve_feed.json +53 -0
  7. package/runners/prod_env.py +1686 -0
  8. package/runners/profile.py +307 -0
  9. package/runners/recon.py +396 -0
  10. package/runners/remediate.py +429 -0
  11. package/runners/report.py +365 -0
  12. package/runners/skills/api/idor-enumeration/SKILL.md +42 -0
  13. package/runners/skills/exploit/authz-differential/SKILL.md +62 -0
  14. package/runners/skills/exploit/business-logic/SKILL.md +53 -0
  15. package/runners/skills/exploit/client-side-exploitation/SKILL.md +63 -0
  16. package/runners/skills/exploit/client-side-rendering/SKILL.md +57 -0
  17. package/runners/skills/exploit/credential-chaining/SKILL.md +56 -0
  18. package/runners/skills/exploit/known-cve-validation/SKILL.md +46 -0
  19. package/runners/skills/exploit/path-traversal/SKILL.md +44 -0
  20. package/runners/skills/exploit/reflected-xss/SKILL.md +43 -0
  21. package/runners/skills/exploit/service-exploitation/SKILL.md +64 -0
  22. package/runners/skills/exploit/service-probing/SKILL.md +51 -0
  23. package/runners/skills/exploit/sqli-inference/SKILL.md +46 -0
  24. package/runners/skills/exploit/ssrf-oob/SKILL.md +48 -0
  25. package/runners/skills/exploit/state-change-exploitation/SKILL.md +72 -0
  26. package/runners/skills/exploit/stateful-authz/SKILL.md +50 -0
  27. package/runners/skills/recon/subdomain-takeover/SKILL.md +45 -0
  28. package/runners/skills/web/content-discovery/SKILL.md +59 -0
  29. package/runners/skills/web/graphql-exposure/SKILL.md +40 -0
  30. package/runners/skills.py +174 -0
@@ -0,0 +1,429 @@
1
+ #!/usr/bin/env python3
2
+ """Generate a code patch for one confirmed finding.
3
+
4
+ The exploit/assessment harness is strictly READ-ONLY, so remediation is a separate,
5
+ isolated step. Rather than a single one-shot model call, this generates a HIGH-QUALITY
6
+ patch via a small pipeline:
7
+
8
+ 1. CONTEXT: read the whole target file (not a tiny window) plus a few files it references,
9
+ within a budget, so the model sees enough to produce hunks that actually match.
10
+ 2. BEST-OF-N candidates, via diverse strategies: a minimal unified diff (with a repair
11
+ retry that feeds back the exact `git apply` error), a full-file rewrite we diff
12
+ locally (robust against mis-formatted hunks), and a structural-fix variant. Only
13
+ candidates that pass `git apply --check` survive.
14
+ 3. REVIEWER pass: a stronger model judges each surviving candidate — does it actually FIX
15
+ the vuln, is it MINIMAL, any REGRESSION risk — and we pick the best-reviewed one.
16
+
17
+ The control plane takes the chosen validated diff and opens a PR via the GitHub App;
18
+ nothing here writes to the repo or runs git beyond the read-only `apply --check`.
19
+
20
+ Importable (the product runner calls generate_patch); model calls are injectable so the
21
+ logic is testable without the network.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import difflib
27
+ import json
28
+ import os
29
+ import re
30
+ import subprocess
31
+ import urllib.request
32
+ from pathlib import Path
33
+
34
+ OPENROUTER_URL = os.environ.get("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1").rstrip("/") + "/chat/completions"
35
+ MAX_SOURCE_CHARS = 24000 # windowed view of the target file sent in the diff prompt
36
+ MAX_CONTEXT_CHARS = 60000 # total context budget (target view + related files)
37
+ MAX_FULLFILE_CHARS = 40000 # only attempt a full-file rewrite when the file is at/under this
38
+
39
+
40
+ def load_key(key_file: str) -> str:
41
+ key = os.environ.get("OPENROUTER_API_KEY")
42
+ if key:
43
+ return key
44
+ try:
45
+ for line in Path(key_file).read_text().splitlines():
46
+ if line.startswith("OPENROUTER_API_KEY="):
47
+ return line.split("=", 1)[1].strip().strip("\"'")
48
+ except OSError:
49
+ pass
50
+ return ""
51
+
52
+
53
+ # ── context ────────────────────────────────────────────────────────────────────────────
54
+
55
+ def _referenced_files(checkout: str, file: str, text: str) -> list[str]:
56
+ """Best-effort: repo-local files the target references (Python imports, JS/TS relative
57
+ imports). Gives the model the blast radius, not just the one file. Bounded to a few."""
58
+ root = Path(checkout)
59
+ base = Path(file).parent
60
+ out: list[str] = []
61
+ for m in re.finditer(r"^\s*(?:from|import)\s+([.\w]+)", text, re.M):
62
+ mod = m.group(1).lstrip(".").split(".")[0]
63
+ if not mod:
64
+ continue
65
+ for cand in (base / f"{mod}.py", Path(f"{mod}.py"), base / mod / "__init__.py"):
66
+ if (root / cand).is_file():
67
+ out.append(str(cand))
68
+ break
69
+ for m in re.finditer(r"""(?:from|require\()\s*['"](\.[^'"]+)['"]""", text):
70
+ rel = (base / m.group(1)).as_posix()
71
+ for ext in ("", ".js", ".ts", ".jsx", ".tsx", "/index.js", "/index.ts"):
72
+ if (root / f"{rel}{ext}").is_file():
73
+ out.append(f"{rel}{ext}")
74
+ break
75
+ seen: set[str] = set()
76
+ res: list[str] = []
77
+ for p in out:
78
+ if p != file and p not in seen:
79
+ seen.add(p)
80
+ res.append(p)
81
+ return res[:3]
82
+
83
+
84
+ def _gather_context(checkout: str, file: str, line: int | None) -> tuple[dict | None, str | None]:
85
+ """Read the target file (whole, or windowed around `line` if huge) plus a few referenced
86
+ files, confined to the repo root. Returns (context, error)."""
87
+ root = Path(checkout).resolve()
88
+ target = (root / file).resolve()
89
+ try:
90
+ target.relative_to(root)
91
+ except ValueError:
92
+ return None, "source path escapes the repository"
93
+ if not target.is_file():
94
+ return None, f"source file not found in the checkout: {file}"
95
+ full = target.read_text(errors="replace")
96
+ if len(full) > MAX_SOURCE_CHARS and line:
97
+ lines = full.splitlines()
98
+ lo, hi = max(0, line - 200), min(len(lines), line + 200)
99
+ view = "\n".join(lines[lo:hi])[:MAX_SOURCE_CHARS]
100
+ else:
101
+ view = full[:MAX_SOURCE_CHARS]
102
+ related: list[tuple[str, str]] = []
103
+ budget = max(0, MAX_CONTEXT_CHARS - len(view))
104
+ for rel in _referenced_files(checkout, file, full):
105
+ if budget <= 0:
106
+ break
107
+ p = (root / rel).resolve()
108
+ try:
109
+ p.relative_to(root)
110
+ t = p.read_text(errors="replace")
111
+ except (OSError, ValueError):
112
+ continue
113
+ t = t[:budget]
114
+ related.append((rel, t))
115
+ budget -= len(t)
116
+ return {"target_full": full, "target_view": view, "related": related}, None
117
+
118
+
119
+ def _related_blob(ctx: dict) -> str:
120
+ return "".join(
121
+ f"\n\nRELATED FILE {p} (context only — do NOT edit it):\n```\n{t}\n```"
122
+ for p, t in ctx.get("related", [])
123
+ )
124
+
125
+
126
+ # ── prompts ────────────────────────────────────────────────────────────────────────────
127
+
128
+ _DIFF_SYSTEM = (
129
+ "You are a security remediation engineer. You are given a CONFIRMED vulnerability and the "
130
+ "current contents of the source file that contains it. Produce the MINIMAL code change that "
131
+ "fixes the vulnerability without altering unrelated behavior. Output EXACTLY two things and "
132
+ "nothing else:\n"
133
+ "1) a single fenced ```diff block containing a valid unified diff that `git apply -p1` accepts: "
134
+ "it MUST start with `diff --git a/<path> b/<path>`, use `--- a/<path>` / `+++ b/<path>` and `@@` "
135
+ "hunks, with paths relative to the repo root, and its context lines MUST match the file EXACTLY.\n"
136
+ "2) after the block, one line: `RATIONALE: <one sentence on why this fixes it>`.\n"
137
+ "Keep the diff tight; change only what the fix requires."
138
+ )
139
+ _STRUCTURAL_HINT = (
140
+ "\nPrefer a robust STRUCTURAL fix — cryptographic signing/verification, server-side validation, "
141
+ "parameterized/safe APIs, correct escaping, access checks — over blocklists or superficial guards."
142
+ )
143
+
144
+
145
+ def _diff_messages(finding: dict, file: str, line: int | None, ctx: dict, variant: int = 0) -> list[dict]:
146
+ system = _DIFF_SYSTEM + (_STRUCTURAL_HINT if variant else "")
147
+ user = (
148
+ f"FINDING: {finding.get('title', '')}\n"
149
+ f"DETAILS: {finding.get('description', '')}\n"
150
+ f"FILE: {file}" + (f" (around line {line})" if line else "") + "\n\n"
151
+ f"CURRENT CONTENTS OF {file}:\n```\n{ctx['target_view']}\n```" + _related_blob(ctx)
152
+ )
153
+ return [{"role": "system", "content": system}, {"role": "user", "content": user}]
154
+
155
+
156
+ def _fullfile_messages(finding: dict, file: str, full: str) -> list[dict]:
157
+ system = (
158
+ "You are a security remediation engineer. Fix the vulnerability with the MINIMAL change and "
159
+ "do not alter unrelated behavior. Return the COMPLETE corrected contents of the file inside a "
160
+ "single fenced code block, and nothing else except a final line `RATIONALE: <one sentence>`."
161
+ )
162
+ user = (
163
+ f"FINDING: {finding.get('title', '')}\nDETAILS: {finding.get('description', '')}\n"
164
+ f"FILE: {file}\n\nCURRENT CONTENTS:\n```\n{full}\n```"
165
+ )
166
+ return [{"role": "system", "content": system}, {"role": "user", "content": user}]
167
+
168
+
169
+ _REVIEW_SYSTEM = (
170
+ "You are a strict, skeptical security code reviewer. You are given a vulnerability, the original "
171
+ "file, and a proposed patch (unified diff). Judge the patch on three axes and respond with ONLY a "
172
+ "JSON object, no prose:\n"
173
+ '{"fixes": true|false, "minimal": true|false, "regression_risk": "none"|"low"|"med"|"high", '
174
+ '"score": <0-100>, "approved": true|false, "notes": "<=2 sentences"}\n'
175
+ "fixes = does it actually close THIS vulnerability (trace the fixed path). minimal = no unrelated "
176
+ "changes. approved = you would merge it. Be honest; a patch that applies but does not fix the vuln "
177
+ "is NOT approved."
178
+ )
179
+
180
+
181
+ def _review_messages(finding: dict, file: str, original: str, diff: str) -> list[dict]:
182
+ user = (
183
+ f"VULNERABILITY: {finding.get('title', '')}\nDETAILS: {finding.get('description', '')}\n"
184
+ f"FILE: {file}\n\nORIGINAL FILE:\n```\n{original[:MAX_SOURCE_CHARS]}\n```\n\n"
185
+ f"PROPOSED PATCH:\n```diff\n{diff}\n```"
186
+ )
187
+ return [{"role": "system", "content": _REVIEW_SYSTEM}, {"role": "user", "content": user}]
188
+
189
+
190
+ # ── model + diff plumbing ────────────────────────────────────────────────────────────────
191
+
192
+ def call_model(key: str, model: str, messages: list[dict], timeout: int = 150) -> str:
193
+ body = json.dumps({"model": model, "messages": messages, "temperature": 0}).encode()
194
+ req = urllib.request.Request(
195
+ OPENROUTER_URL, data=body, method="POST",
196
+ headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
197
+ )
198
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
199
+ data = json.loads(resp.read().decode("utf-8", "replace"))
200
+ return data["choices"][0]["message"]["content"]
201
+
202
+
203
+ def extract_diff(text: str) -> str:
204
+ """Pull a unified diff out of the model output: prefer a fenced block, then trim to the
205
+ first real diff marker so surrounding prose never reaches `git apply`."""
206
+ m = re.search(r"```(?:diff|patch)?\s*\n(.*?)```", text or "", re.S)
207
+ body = m.group(1) if m else (text or "")
208
+ idx = None
209
+ for marker in ("diff --git", "--- a/", "--- "):
210
+ i = body.find(marker)
211
+ if i != -1 and (idx is None or i < idx):
212
+ idx = i
213
+ if idx is None:
214
+ return ""
215
+ return body[idx:].rstrip() + "\n"
216
+
217
+
218
+ def extract_rationale(text: str) -> str:
219
+ m = re.search(r"RATIONALE:\s*(.+)", text or "")
220
+ return m.group(1).strip()[:500] if m else ""
221
+
222
+
223
+ def _extract_code_block(text: str) -> str:
224
+ """Pull FULL-FILE contents out of a fenced block for the rewrite strategy. Skips
225
+ ```diff/```patch fences (those are diffs, not file contents)."""
226
+ for m in re.finditer(r"```([A-Za-z0-9_+-]*)\s*\n(.*?)```", text or "", re.S):
227
+ if m.group(1).lower() in ("diff", "patch"):
228
+ continue
229
+ return m.group(2)
230
+ return ""
231
+
232
+
233
+ def _unified_diff(file: str, old: str, new: str) -> str:
234
+ """Build a git-apply-able unified diff between two full file contents, computed locally
235
+ (robust against a model mis-formatting hunk headers)."""
236
+ a = old.splitlines(keepends=True)
237
+ b = new.splitlines(keepends=True)
238
+ body = "".join(difflib.unified_diff(a, b, fromfile=f"a/{file}", tofile=f"b/{file}", n=3))
239
+ if not body.strip():
240
+ return ""
241
+ return f"diff --git a/{file} b/{file}\n{body}"
242
+
243
+
244
+ def validate_diff(checkout: str, diff: str) -> tuple[bool, str | None]:
245
+ """Does the diff apply cleanly to the checkout? Read-only (`--check`): applies nothing."""
246
+ if not diff.strip():
247
+ return False, "no diff produced"
248
+ try:
249
+ p = subprocess.run(
250
+ ["git", "apply", "--check", "-p1", "-"], input=diff, text=True,
251
+ cwd=checkout, capture_output=True, timeout=30,
252
+ )
253
+ return (p.returncode == 0), (p.stderr.strip() or None)
254
+ except Exception as e: # noqa: BLE001
255
+ return False, f"git apply --check failed to run: {e}"
256
+
257
+
258
+ # ── candidates ─────────────────────────────────────────────────────────────────────────
259
+
260
+ def _diff_candidate(checkout: str, finding: dict, file: str, line: int | None, ctx: dict,
261
+ key: str, model: str, model_fn, max_attempts: int, variant: int) -> dict | None:
262
+ """One diff-strategy candidate, with a repair retry that feeds back the git-apply error.
263
+ Returns the applying candidate if found, else the last non-applying one, else None."""
264
+ messages = _diff_messages(finding, file, line, ctx, variant)
265
+ last: dict | None = None
266
+ for attempt in range(max(1, max_attempts)):
267
+ try:
268
+ out = model_fn(key, model, messages)
269
+ except Exception: # noqa: BLE001
270
+ return last
271
+ diff = extract_diff(out)
272
+ if diff:
273
+ applies, aerr = validate_diff(checkout, diff)
274
+ cand = {"diff": diff, "rationale": extract_rationale(out), "applies": applies,
275
+ "strategy": "diff", "error": None if applies else (aerr or "did not apply cleanly")}
276
+ if applies:
277
+ return cand
278
+ last = cand
279
+ feedback = (f"That diff did not apply (`git apply --check`: {aerr}). Produce a corrected "
280
+ "unified diff whose @@ hunk headers and context lines EXACTLY match the file "
281
+ "shown above. Output only the ```diff block and the RATIONALE line.")
282
+ else:
283
+ feedback = ("You did not return a unified diff. Output only a single ```diff block that "
284
+ "`git apply -p1` accepts, then the RATIONALE line.")
285
+ if attempt + 1 < max(1, max_attempts):
286
+ messages = messages + [{"role": "assistant", "content": out},
287
+ {"role": "user", "content": feedback}]
288
+ return last
289
+
290
+
291
+ def _fullfile_candidate(checkout: str, finding: dict, file: str, full: str,
292
+ key: str, model: str, model_fn) -> dict | None:
293
+ """Full-file-rewrite strategy: ask for the whole corrected file, diff it ourselves. Robust
294
+ against hunk-format mistakes. Only for files small enough to rewrite without truncation."""
295
+ if not full or len(full) > MAX_FULLFILE_CHARS:
296
+ return None
297
+ try:
298
+ out = model_fn(key, model, _fullfile_messages(finding, file, full))
299
+ except Exception: # noqa: BLE001
300
+ return None
301
+ new = _extract_code_block(out)
302
+ if not new or new.strip() == full.strip():
303
+ return None
304
+ if re.match(r"\s*(diff --git|--- |\+\+\+ |@@ )", new):
305
+ return None # a diff slipped through, not a file rewrite
306
+ diff = _unified_diff(file, full, new)
307
+ if not diff:
308
+ return None
309
+ applies, aerr = validate_diff(checkout, diff)
310
+ return {"diff": diff, "rationale": extract_rationale(out), "applies": applies,
311
+ "strategy": "full-file", "error": None if applies else (aerr or "did not apply cleanly")}
312
+
313
+
314
+ def generate_candidates(checkout: str, finding: dict, file: str, line: int | None, ctx: dict,
315
+ key: str, model: str, model_fn, n: int, max_attempts: int) -> tuple[list[dict], list[dict]]:
316
+ """Produce up to `n` distinct candidate patches via diverse strategies. Returns
317
+ (applying_unique, all_produced)."""
318
+ cands: list[dict] = []
319
+ seen: set[str] = set()
320
+
321
+ def add(c: dict | None) -> None:
322
+ if c and c.get("diff") and c["diff"] not in seen:
323
+ seen.add(c["diff"])
324
+ cands.append(c)
325
+
326
+ add(_diff_candidate(checkout, finding, file, line, ctx, key, model, model_fn, max_attempts, variant=0))
327
+ add(_fullfile_candidate(checkout, finding, file, ctx["target_full"], key, model, model_fn))
328
+ variant = 1
329
+ while len([c for c in cands if c.get("applies")]) < n and variant <= 2:
330
+ add(_diff_candidate(checkout, finding, file, line, ctx, key, model, model_fn, 1, variant=variant))
331
+ variant += 1
332
+ applying = [c for c in cands if c.get("applies")]
333
+ return applying, cands
334
+
335
+
336
+ # ── reviewer ──────────────────────────────────────────────────────────────────────────
337
+
338
+ def _parse_review(text: str) -> dict | None:
339
+ m = re.search(r"\{.*\}", text or "", re.S)
340
+ if not m:
341
+ return None
342
+ try:
343
+ v = json.loads(m.group(0))
344
+ except (json.JSONDecodeError, TypeError):
345
+ return None
346
+ if not isinstance(v, dict):
347
+ return None
348
+ score = v.get("score", 0)
349
+ try:
350
+ score = max(0, min(100, int(score)))
351
+ except (ValueError, TypeError):
352
+ score = 0
353
+ return {
354
+ "fixes": bool(v.get("fixes")),
355
+ "minimal": bool(v.get("minimal", True)),
356
+ "regression_risk": str(v.get("regression_risk", "unknown"))[:16],
357
+ "score": score,
358
+ "approved": bool(v.get("approved", v.get("fixes"))),
359
+ "notes": str(v.get("notes", ""))[:400],
360
+ }
361
+
362
+
363
+ def review_patch(finding: dict, file: str, original: str, diff: str,
364
+ key: str, reviewer_model: str, reviewer_fn) -> dict | None:
365
+ try:
366
+ out = reviewer_fn(key, reviewer_model, _review_messages(finding, file, original, diff))
367
+ except Exception: # noqa: BLE001
368
+ return None
369
+ return _parse_review(out)
370
+
371
+
372
+ # ── orchestration ────────────────────────────────────────────────────────────────────────
373
+
374
+ def _failed(error: str, file: str | None = None) -> dict:
375
+ r = {"status": "failed", "error": error}
376
+ if file:
377
+ r["targetFile"] = file
378
+ return r
379
+
380
+
381
+ def generate_patch(checkout: str, finding: dict, key: str, model: str, model_fn=call_model,
382
+ reviewer_model: str | None = None, reviewer_fn=None,
383
+ candidates: int = 3, max_attempts: int = 2) -> dict:
384
+ """Produce {diff, rationale, targetFile, applies, status, ...} for one finding.
385
+
386
+ Pipeline: gather context -> generate best-of-N candidates (diverse strategies, each
387
+ validated with `git apply --check`) -> optional reviewer pass picks the best. status is
388
+ 'proposed' when a diff was produced (whether or not it applies), 'failed' when none was.
389
+ When a reviewer runs, the result carries `review` and `verified`."""
390
+ src = finding.get("source") or {}
391
+ file = src.get("file")
392
+ if not file:
393
+ return _failed("finding has no source file to patch")
394
+ ctx, err = _gather_context(checkout, file, src.get("line"))
395
+ if err or ctx is None:
396
+ return _failed(err or "could not read source", file)
397
+
398
+ applying, produced = generate_candidates(
399
+ checkout, finding, file, src.get("line"), ctx, key, model, model_fn, candidates, max_attempts)
400
+
401
+ if not applying:
402
+ last = next((c for c in produced if c.get("diff")), None)
403
+ if last:
404
+ return {"diff": last["diff"], "rationale": last.get("rationale", ""), "targetFile": file,
405
+ "applies": False, "status": "proposed",
406
+ "error": last.get("error") or "patch did not apply cleanly after retries"}
407
+ return _failed("model did not return a unified diff", file)
408
+
409
+ reviewer_fn = reviewer_fn or model_fn
410
+ chosen = applying[0]
411
+ if reviewer_model:
412
+ for c in applying:
413
+ c["review"] = review_patch(finding, file, ctx["target_full"], c["diff"], key, reviewer_model, reviewer_fn)
414
+ reviewed = [c for c in applying if c.get("review")]
415
+ if reviewed:
416
+ approved = [c for c in reviewed if c["review"]["approved"]]
417
+ pool = approved or reviewed
418
+ chosen = max(pool, key=lambda c: c["review"]["score"])
419
+
420
+ result = {
421
+ "diff": chosen["diff"], "rationale": chosen.get("rationale", ""), "targetFile": file,
422
+ "applies": True, "status": "proposed",
423
+ "strategy": chosen.get("strategy"), "candidates": len(applying),
424
+ }
425
+ review = chosen.get("review")
426
+ if review is not None:
427
+ result["review"] = review
428
+ result["verified"] = bool(review["approved"])
429
+ return result