@murk-security/runner-darwin-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.
- package/README.md +2 -2
- package/bin/harness +0 -0
- package/package.json +15 -4
- package/runners/basic_harness_run.py +1276 -0
- package/runners/checklists/anonymous.json +24 -0
- package/runners/cve_feed.json +53 -0
- package/runners/prod_env.py +1686 -0
- package/runners/profile.py +307 -0
- package/runners/recon.py +396 -0
- package/runners/remediate.py +429 -0
- package/runners/report.py +365 -0
- package/runners/skills/api/idor-enumeration/SKILL.md +42 -0
- package/runners/skills/exploit/authz-differential/SKILL.md +62 -0
- package/runners/skills/exploit/business-logic/SKILL.md +53 -0
- package/runners/skills/exploit/client-side-exploitation/SKILL.md +63 -0
- package/runners/skills/exploit/client-side-rendering/SKILL.md +57 -0
- package/runners/skills/exploit/credential-chaining/SKILL.md +56 -0
- package/runners/skills/exploit/known-cve-validation/SKILL.md +46 -0
- package/runners/skills/exploit/path-traversal/SKILL.md +44 -0
- package/runners/skills/exploit/reflected-xss/SKILL.md +43 -0
- package/runners/skills/exploit/service-exploitation/SKILL.md +64 -0
- package/runners/skills/exploit/service-probing/SKILL.md +51 -0
- package/runners/skills/exploit/sqli-inference/SKILL.md +46 -0
- package/runners/skills/exploit/ssrf-oob/SKILL.md +48 -0
- package/runners/skills/exploit/state-change-exploitation/SKILL.md +72 -0
- package/runners/skills/exploit/stateful-authz/SKILL.md +50 -0
- package/runners/skills/recon/subdomain-takeover/SKILL.md +45 -0
- package/runners/skills/web/content-discovery/SKILL.md +59 -0
- package/runners/skills/web/graphql-exposure/SKILL.md +40 -0
- package/runners/skills.py +174 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Customer-facing assessment report generation for the Blackglass/MURK runner.
|
|
3
|
+
|
|
4
|
+
The runner assembles a Markdown report at the end of a scan and writes it to the run's
|
|
5
|
+
session dir as `report.md`; the control plane serves that verbatim at
|
|
6
|
+
`GET /api/runs/:id/report` (falling back to its own minimal renderer only when a run did
|
|
7
|
+
not produce one). This module is pure + stdlib-only so it is unit-testable offline.
|
|
8
|
+
|
|
9
|
+
Two layers, per the agreed design:
|
|
10
|
+
* DETERMINISTIC sections (scope, severity rollup, each finding's full detail, the
|
|
11
|
+
pre-scan's coverage + posture observations, reproduction appendix) built straight
|
|
12
|
+
from the structured data -- no model, no guessing.
|
|
13
|
+
* An LLM-authored EXECUTIVE SUMMARY (`executive_summary`) generated as an isolated
|
|
14
|
+
post-pass over the same data, with a deterministic fallback when no model/key is
|
|
15
|
+
available. It never invents findings: it only narrates the ones already confirmed.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import html
|
|
20
|
+
import json
|
|
21
|
+
import re
|
|
22
|
+
from typing import Any, Callable
|
|
23
|
+
|
|
24
|
+
SEVERITY_ORDER = ["critical", "high", "medium", "low", "info"]
|
|
25
|
+
_SEV_RANK = {s: i for i, s in enumerate(SEVERITY_ORDER)}
|
|
26
|
+
|
|
27
|
+
# Finding/narrative text is authored by an LLM over TARGET content (e.g. a reflected
|
|
28
|
+
# payload echoed in evidence), so it is untrusted and must be neutralised before it goes
|
|
29
|
+
# into a report that may be rendered as HTML. These sanitisers are context-specific:
|
|
30
|
+
# _text — block prose: keep newlines, HTML-escape & < >, drop other control chars.
|
|
31
|
+
# _inline— single line (headings, labels): _text then collapse all whitespace.
|
|
32
|
+
# _cell — a Markdown table cell: _inline then escape the pipe.
|
|
33
|
+
# _code — inside a `...` span: strip backticks + newlines so it cannot break out.
|
|
34
|
+
_CTRL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _text(s: Any) -> str:
|
|
38
|
+
out = str(s or "").replace("\r\n", "\n").replace("\r", "\n")
|
|
39
|
+
return html.escape(_CTRL.sub("", out), quote=False)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _inline(s: Any) -> str:
|
|
43
|
+
return " ".join(_text(s).split())
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _cell(s: Any) -> str:
|
|
47
|
+
return _inline(s).replace("|", "\\|")[:300]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _code(s: Any) -> str:
|
|
51
|
+
return str(s or "").replace("`", "").replace("\r", " ").replace("\n", " ").strip()
|
|
52
|
+
|
|
53
|
+
# Human labels for the pre-scan's coverage tokens (profile.py `coverage`).
|
|
54
|
+
_COVERAGE_LABELS = {
|
|
55
|
+
"security-headers": "Security response headers",
|
|
56
|
+
"cookie-flags": "Session cookie flags",
|
|
57
|
+
"fingerprint": "Server / technology fingerprint",
|
|
58
|
+
"version-cve": "Version → known-CVE matching",
|
|
59
|
+
"http-methods": "Allowed HTTP methods",
|
|
60
|
+
"exposed-paths": "Exposed sensitive paths (VCS, secrets, backups, config)",
|
|
61
|
+
"transport": "Transport security (TLS)",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def severity_rank(sev: str) -> int:
|
|
66
|
+
return _SEV_RANK.get((sev or "").lower(), len(SEVERITY_ORDER))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def sort_findings(findings: list[dict]) -> list[dict]:
|
|
70
|
+
"""Most severe first; stable within a severity (preserves discovery order)."""
|
|
71
|
+
return sorted(findings, key=lambda f: severity_rank(str(f.get("severity") or "")))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def severity_counts(findings: list[dict]) -> dict[str, int]:
|
|
75
|
+
counts = {s: 0 for s in SEVERITY_ORDER}
|
|
76
|
+
for f in findings:
|
|
77
|
+
sev = str(f.get("severity") or "").lower()
|
|
78
|
+
if sev in counts:
|
|
79
|
+
counts[sev] += 1
|
|
80
|
+
return counts
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _rollup_table(findings: list[dict]) -> list[str]:
|
|
84
|
+
counts = severity_counts(findings)
|
|
85
|
+
present = [s for s in SEVERITY_ORDER if counts[s]]
|
|
86
|
+
if not present:
|
|
87
|
+
return ["No confirmed findings."]
|
|
88
|
+
out = ["| Severity | Count |", "| --- | --- |"]
|
|
89
|
+
out += [f"| {s.capitalize()} | {counts[s]} |" for s in present]
|
|
90
|
+
out.append(f"| **Total** | **{sum(counts.values())}** |")
|
|
91
|
+
return out
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _fence(text: str) -> list[str]:
|
|
95
|
+
"""A fenced code block whose content can't break out of the fence. The renderer
|
|
96
|
+
HTML-escapes fenced content, so we only neutralise stray ``` here."""
|
|
97
|
+
body = str(text or "").replace("```", "'''").rstrip()
|
|
98
|
+
return ["```", body, "```"]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _evidence_block(f: dict) -> list[str]:
|
|
102
|
+
"""Concrete, verbatim proof: each cited request and the response it returned, plus the
|
|
103
|
+
cited code with a snippet. Read from the run's records by the runner; never synthesized."""
|
|
104
|
+
repro = f.get("reproduction") or {}
|
|
105
|
+
ops = [o for o in (repro.get("ops") or []) if isinstance(o, dict) and (o.get("response") or {})]
|
|
106
|
+
crefs = [c for c in (repro.get("codeRefs") or []) if isinstance(c, dict)]
|
|
107
|
+
if not ops and not crefs:
|
|
108
|
+
return []
|
|
109
|
+
out = ["**Evidence**", ""]
|
|
110
|
+
for o in ops:
|
|
111
|
+
args = o.get("args") if isinstance(o.get("args"), dict) else {}
|
|
112
|
+
method = str(args.get("method") or ("POST" if o.get("op") == "login" else "GET")).upper()
|
|
113
|
+
path = args.get("path") or args.get("subject") or o.get("op") or "request"
|
|
114
|
+
resp = o.get("response") or {}
|
|
115
|
+
status = resp.get("status")
|
|
116
|
+
head = f"Request `{_code(method)} {_code(str(path))}`"
|
|
117
|
+
if status is not None:
|
|
118
|
+
head += f" → `{_code(str(status))}`"
|
|
119
|
+
out += [head, ""]
|
|
120
|
+
if resp.get("snippet"):
|
|
121
|
+
out += _fence(str(resp["snippet"]))
|
|
122
|
+
out.append("")
|
|
123
|
+
for c in crefs:
|
|
124
|
+
loc = str(c.get("file") or "") + (f":{c['line']}" if c.get("line") else "")
|
|
125
|
+
out += [f"Code `{_code(loc)}`", ""]
|
|
126
|
+
if c.get("snippet"):
|
|
127
|
+
out += _fence(str(c["snippet"]))
|
|
128
|
+
out.append("")
|
|
129
|
+
return out
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _finding_section(index: int, f: dict) -> list[str]:
|
|
133
|
+
sev = str(f.get("severity") or "unrated").capitalize()
|
|
134
|
+
lines = [f"### {index}. [{sev}] {_inline(f.get('title') or 'Untitled finding')}", ""]
|
|
135
|
+
meta_bits = []
|
|
136
|
+
if f.get("category"):
|
|
137
|
+
meta_bits.append(f"**Category:** {_inline(f['category'])}")
|
|
138
|
+
if f.get("cwe"):
|
|
139
|
+
meta_bits.append(f"**Weakness:** {_inline(f['cwe'])}")
|
|
140
|
+
if f.get("confidence"):
|
|
141
|
+
meta_bits.append(f"**Confidence:** {_inline(f['confidence'])}")
|
|
142
|
+
if meta_bits:
|
|
143
|
+
lines += [" · ".join(meta_bits), ""]
|
|
144
|
+
ep = f.get("endpoint") or {}
|
|
145
|
+
if ep.get("url") or ep.get("path"):
|
|
146
|
+
lines += [f"**Affected endpoint:** `{_code(ep.get('method', 'GET'))} {_code(ep.get('url') or ep.get('path'))}`", ""]
|
|
147
|
+
if f.get("impact"):
|
|
148
|
+
lines += ["**Impact**", "", _text(f["impact"]), ""]
|
|
149
|
+
if f.get("description"):
|
|
150
|
+
lines += ["**Detail**", "", _text(f["description"]), ""]
|
|
151
|
+
steps = f.get("steps") or []
|
|
152
|
+
if steps:
|
|
153
|
+
lines += ["**Reproduction**", ""]
|
|
154
|
+
lines += [f"{i + 1}. `{_code(s)}`" for i, s in enumerate(steps)]
|
|
155
|
+
lines.append("")
|
|
156
|
+
lines += _evidence_block(f)
|
|
157
|
+
if f.get("remediation"):
|
|
158
|
+
lines += ["**Remediation**", "", _text(f["remediation"]), ""]
|
|
159
|
+
src = f.get("source") or {}
|
|
160
|
+
if src.get("file"):
|
|
161
|
+
loc = str(src["file"]) + (f":{src['line']}" if src.get("line") else "")
|
|
162
|
+
lines += [f"**Source:** `{_code(loc)}`", ""]
|
|
163
|
+
return lines
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _posture_section(prescan: dict) -> list[str]:
|
|
167
|
+
"""Render the pre-scan's coverage + posture observations. These are factual
|
|
168
|
+
OBSERVATIONS of the public surface (what was checked, what is weak, what is
|
|
169
|
+
sound) -- deliberately not severity-rated findings: anything that is a real
|
|
170
|
+
exploitable vulnerability is confirmed and assessed by the agents above."""
|
|
171
|
+
if not prescan:
|
|
172
|
+
return []
|
|
173
|
+
out = ["## Surface posture & coverage", "",
|
|
174
|
+
"An automated pre-scan characterised the target's public surface. The items "
|
|
175
|
+
"below are observations of security posture, not confirmed vulnerabilities.", ""]
|
|
176
|
+
|
|
177
|
+
coverage = [c for c in (prescan.get("coverage") or []) if isinstance(c, str)]
|
|
178
|
+
if coverage:
|
|
179
|
+
out += ["### Checks performed", ""]
|
|
180
|
+
out += [f"- {_inline(_COVERAGE_LABELS.get(c, c))}" for c in coverage]
|
|
181
|
+
out.append("")
|
|
182
|
+
|
|
183
|
+
target = prescan.get("target") or {}
|
|
184
|
+
fp_bits = []
|
|
185
|
+
if target.get("server"):
|
|
186
|
+
fp_bits.append(f"Server: `{_code(target['server'])}`")
|
|
187
|
+
if target.get("x_powered_by"):
|
|
188
|
+
fp_bits.append(f"X-Powered-By: `{_code(target['x_powered_by'])}`")
|
|
189
|
+
if isinstance(target.get("fingerprints"), list) and target["fingerprints"]:
|
|
190
|
+
named = ", ".join(
|
|
191
|
+
_inline(f"{p.get('product')} {p.get('version')}".strip())
|
|
192
|
+
for p in target["fingerprints"] if isinstance(p, dict)
|
|
193
|
+
)
|
|
194
|
+
if named:
|
|
195
|
+
fp_bits.append(f"Detected: {named}")
|
|
196
|
+
if fp_bits:
|
|
197
|
+
out += ["### Technology", "", *[f"- {b}" for b in fp_bits], ""]
|
|
198
|
+
|
|
199
|
+
signals = [s for s in (prescan.get("signals") or []) if isinstance(s, dict)]
|
|
200
|
+
if signals:
|
|
201
|
+
out += ["### Observations", "",
|
|
202
|
+
"| Area | Observation | Confidence | Confirm at |", "| --- | --- | --- | --- |"]
|
|
203
|
+
for s in signals:
|
|
204
|
+
area = _cell(s.get("area") or s.get("category") or "—")
|
|
205
|
+
title = _cell(s.get("title") or s.get("detail") or "")
|
|
206
|
+
conf = _cell(s.get("confidence") or "—")
|
|
207
|
+
tier = _cell(s.get("tier_to_confirm") or "—")
|
|
208
|
+
out.append(f"| {area} | {title} | {conf} | {tier} |")
|
|
209
|
+
out.append("")
|
|
210
|
+
|
|
211
|
+
positives = prescan.get("posture", {}).get("hygiene_positives") or []
|
|
212
|
+
positives = [p for p in positives if isinstance(p, (str, dict))]
|
|
213
|
+
if positives:
|
|
214
|
+
out += ["### Verified sound", ""]
|
|
215
|
+
for p in positives:
|
|
216
|
+
out.append(f"- {_inline(p.get('title') if isinstance(p, dict) else p)}")
|
|
217
|
+
out.append("")
|
|
218
|
+
return out
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _appendix(findings: list[dict]) -> list[str]:
|
|
222
|
+
"""A compact evidence index: each finding mapped to the cited evidence (env call ids)
|
|
223
|
+
and code locations that back it. Non-empty whenever findings cite anything."""
|
|
224
|
+
rows: list[str] = []
|
|
225
|
+
for f in findings:
|
|
226
|
+
repro = f.get("reproduction") or {}
|
|
227
|
+
ids = [str(o.get("evidence_id")) for o in (repro.get("ops") or [])
|
|
228
|
+
if isinstance(o, dict) and o.get("evidence_id") is not None]
|
|
229
|
+
refs = list(f.get("evidenceRefs") or [])
|
|
230
|
+
if not ids and not refs:
|
|
231
|
+
continue
|
|
232
|
+
cited = "; ".join(filter(None, [
|
|
233
|
+
("evidence " + ", ".join(ids)) if ids else "",
|
|
234
|
+
("code " + ", ".join(_inline(r) for r in refs)) if refs else "",
|
|
235
|
+
]))
|
|
236
|
+
rows.append(f"| {_cell(f.get('title') or 'Finding')} | {_cell(cited)} |")
|
|
237
|
+
if not rows:
|
|
238
|
+
return []
|
|
239
|
+
return ["## Evidence index", "",
|
|
240
|
+
"Every confirmed finding and the evidence it cites (full request/response bodies "
|
|
241
|
+
"and source are reachable per-finding above, and via the MCP interface by id).", "",
|
|
242
|
+
"| Finding | Cited evidence |", "| --- | --- |", *rows, ""]
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def build_report(*, run_meta: dict, findings: list[dict], prescan: dict | None, narrative: str) -> str:
|
|
246
|
+
"""Assemble the full Markdown report. `findings` are finding-payload dicts."""
|
|
247
|
+
findings = sort_findings(findings)
|
|
248
|
+
target = _inline(run_meta.get("target") or "the target application")
|
|
249
|
+
lines: list[str] = [f"# Security assessment — {target}", ""]
|
|
250
|
+
|
|
251
|
+
meta_line = []
|
|
252
|
+
if run_meta.get("scanProfile"):
|
|
253
|
+
meta_line.append(f"**Scan:** {str(run_meta['scanProfile']).upper()}")
|
|
254
|
+
if run_meta.get("startedAt"):
|
|
255
|
+
meta_line.append(f"**Date:** {run_meta['startedAt']}")
|
|
256
|
+
if run_meta.get("durationMin") is not None:
|
|
257
|
+
meta_line.append(f"**Duration:** {run_meta['durationMin']:.0f} min")
|
|
258
|
+
if run_meta.get("runId"):
|
|
259
|
+
meta_line.append(f"**Run:** `{_code(run_meta['runId'])}`")
|
|
260
|
+
if meta_line:
|
|
261
|
+
lines += [" · ".join(meta_line), ""]
|
|
262
|
+
|
|
263
|
+
lines += ["## Executive summary", "", _text(narrative).strip() or "_No summary available._", ""]
|
|
264
|
+
|
|
265
|
+
lines += ["## Findings at a glance", ""]
|
|
266
|
+
lines += _rollup_table(findings)
|
|
267
|
+
lines.append("")
|
|
268
|
+
|
|
269
|
+
lines += ["## Scope & methodology", ""]
|
|
270
|
+
if run_meta.get("hosts"):
|
|
271
|
+
lines.append(f"- **In scope:** {', '.join(_inline(h) for h in run_meta['hosts'])}")
|
|
272
|
+
lines.append(f"- **Access:** {_inline(run_meta.get('actionTier') or 'passive (read-only)')}")
|
|
273
|
+
idents = run_meta.get("identities") or []
|
|
274
|
+
lines.append(f"- **Authenticated as:** {', '.join(_inline(i) for i in idents) if idents else 'unauthenticated (external attacker)'}")
|
|
275
|
+
if run_meta.get("scanProfile"):
|
|
276
|
+
lines.append(f"- **Depth:** {str(run_meta['scanProfile']).upper()} scan profile")
|
|
277
|
+
lines.append("")
|
|
278
|
+
|
|
279
|
+
if findings:
|
|
280
|
+
lines += ["## Confirmed findings", ""]
|
|
281
|
+
for i, f in enumerate(findings, 1):
|
|
282
|
+
lines += _finding_section(i, f)
|
|
283
|
+
else:
|
|
284
|
+
lines += ["## Confirmed findings", "",
|
|
285
|
+
"No confirmed vulnerabilities were found in scope during this assessment. "
|
|
286
|
+
"See the surface posture below for what was checked.", ""]
|
|
287
|
+
|
|
288
|
+
lines += _posture_section(prescan or {})
|
|
289
|
+
lines += _appendix(findings)
|
|
290
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _fallback_summary(findings: list[dict], prescan: dict | None) -> str:
|
|
294
|
+
counts = severity_counts(findings)
|
|
295
|
+
present = [f"{counts[s]} {s}" for s in SEVERITY_ORDER if counts[s]]
|
|
296
|
+
if present:
|
|
297
|
+
head = f"The assessment confirmed {sum(counts.values())} finding(s): {', '.join(present)}."
|
|
298
|
+
else:
|
|
299
|
+
head = "The assessment confirmed no vulnerabilities in scope."
|
|
300
|
+
top = next((f for f in sort_findings(findings) if f.get("title")), None)
|
|
301
|
+
if top:
|
|
302
|
+
head += f" The most severe is \"{_inline(top['title'])}\" ({str(top.get('severity') or 'unrated')})."
|
|
303
|
+
if prescan and (prescan.get("signals")):
|
|
304
|
+
head += (f" An automated pre-scan also recorded {len(prescan['signals'])} surface-posture "
|
|
305
|
+
"observation(s); see below.")
|
|
306
|
+
return head
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def llm_summary(findings: list[dict], prescan: dict | None, run_meta: dict,
|
|
310
|
+
key: str | None, model: str | None,
|
|
311
|
+
call: Callable[[str, str, list[dict]], str] | None = None,
|
|
312
|
+
log: Callable[[str], None] | None = None) -> str | None:
|
|
313
|
+
"""Attempt the LLM-authored executive summary. Returns the narrative, or None if the
|
|
314
|
+
prerequisites are missing or the call fails (logging the reason via `log`) so the
|
|
315
|
+
caller can retry with another model before using the deterministic fallback."""
|
|
316
|
+
if not (key and model and call):
|
|
317
|
+
return None
|
|
318
|
+
compact = [{
|
|
319
|
+
"title": f.get("title"), "severity": f.get("severity"),
|
|
320
|
+
"category": f.get("category"), "impact": f.get("impact"),
|
|
321
|
+
"endpoint": (f.get("endpoint") or {}).get("url") or (f.get("endpoint") or {}).get("path"),
|
|
322
|
+
} for f in sort_findings(findings)]
|
|
323
|
+
posture = [
|
|
324
|
+
{"area": s.get("area"), "title": s.get("title")}
|
|
325
|
+
for s in (prescan.get("signals") if prescan else []) or [] if isinstance(s, dict)
|
|
326
|
+
][:20]
|
|
327
|
+
payload = {
|
|
328
|
+
"target": run_meta.get("target"),
|
|
329
|
+
"scan_profile": run_meta.get("scanProfile"),
|
|
330
|
+
"confirmed_findings": compact,
|
|
331
|
+
"posture_observations": posture,
|
|
332
|
+
}
|
|
333
|
+
messages = [
|
|
334
|
+
{"role": "system", "content": (
|
|
335
|
+
"You write the executive summary of a web application security assessment for a "
|
|
336
|
+
"technical decision-maker. Use ONLY the findings and observations provided; do not "
|
|
337
|
+
"invent, infer, or speculate about anything not listed. Write four short, factual "
|
|
338
|
+
"paragraphs as flowing prose (no markdown headers or bullet lists):\n"
|
|
339
|
+
"1. Overall risk posture — one or two sentences on how exposed the application is and why.\n"
|
|
340
|
+
"2. The most significant confirmed issues, grouped by theme where they relate (e.g. "
|
|
341
|
+
"several access-control gaps), naming what each lets an attacker do and how they chain "
|
|
342
|
+
"into greater impact together.\n"
|
|
343
|
+
"3. What this means for the business — the data or functions actually at risk.\n"
|
|
344
|
+
"4. A prioritised remediation roadmap — what to fix first and why, then the next tier.\n"
|
|
345
|
+
"Reference specific findings by their real titles. If there are no confirmed findings, "
|
|
346
|
+
"say so plainly and summarise what was verified instead.")},
|
|
347
|
+
{"role": "user", "content": json.dumps(payload, ensure_ascii=False)},
|
|
348
|
+
]
|
|
349
|
+
try:
|
|
350
|
+
text = (call(key, model, messages) or "").strip()
|
|
351
|
+
if not text and log:
|
|
352
|
+
log(f"executive summary via {model}: empty response")
|
|
353
|
+
return text or None
|
|
354
|
+
except Exception as exc: # noqa: BLE001 — the report must render even if the call fails
|
|
355
|
+
if log:
|
|
356
|
+
log(f"executive summary via {model} failed: {exc}")
|
|
357
|
+
return None
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def executive_summary(findings: list[dict], prescan: dict | None, run_meta: dict,
|
|
361
|
+
key: str | None, model: str | None,
|
|
362
|
+
call: Callable[[str, str, list[dict]], str] | None = None) -> str:
|
|
363
|
+
"""The LLM-authored summary if available, else the deterministic fallback. Thin wrapper
|
|
364
|
+
over llm_summary for callers that want a single model and no retry (and for tests)."""
|
|
365
|
+
return llm_summary(findings, prescan, run_meta, key, model, call) or _fallback_summary(findings, prescan)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: idor-enumeration
|
|
3
|
+
description: Find IDOR / broken object-level authorization on id-keyed API endpoints by reading objects that belong to other users or tenants.
|
|
4
|
+
when-to-use: an endpoint path or response contains a numeric or guessable id (e.g. /api/orders/1001, ?user_id=, /invoices/{id}), or a resource is keyed by an identifier.
|
|
5
|
+
surface: web
|
|
6
|
+
severity: high
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# IDOR / broken object-level authorization
|
|
10
|
+
|
|
11
|
+
A read-only playbook for the `http` op. Goal: demonstrate that an object keyed by
|
|
12
|
+
an identifier can be read by a party who should not be authorized for it.
|
|
13
|
+
|
|
14
|
+
## 1. Find object-keyed endpoints
|
|
15
|
+
From the site map, APIs, and JS bundles, collect endpoints shaped like
|
|
16
|
+
`/api/<resource>/<id>`, `?id=`, `?user_id=`, `/<resource>/{uuid}`. Source-map and
|
|
17
|
+
JS mining (`http` with `grep`) often reveals the full API shape.
|
|
18
|
+
|
|
19
|
+
## 2. Establish a baseline
|
|
20
|
+
Read one object anonymously (or as a low-privilege identity if one exists):
|
|
21
|
+
`http {"path":"/api/orders/1001"}`. Note what fields come back and whose data it is
|
|
22
|
+
(owner email, tenant id, account number).
|
|
23
|
+
|
|
24
|
+
## 3. Enumerate neighbors
|
|
25
|
+
Request adjacent identifiers that should belong to OTHER principals:
|
|
26
|
+
`http {"path":"/api/orders/1002"}`, `/1003`, and so on. For UUIDs, use ids you
|
|
27
|
+
discovered elsewhere (in listings, JS, other responses) rather than guessing.
|
|
28
|
+
A 200 returning another principal's data with no auth, or with your low-priv
|
|
29
|
+
identity, is the IDOR.
|
|
30
|
+
|
|
31
|
+
## 4. Distinguish real IDOR from correct behavior
|
|
32
|
+
It is only a finding if the object is one the requester SHOULD NOT reach:
|
|
33
|
+
- different owner/tenant than the (anonymous or low-priv) requester, and
|
|
34
|
+
- no authorization error (not 401/403), and
|
|
35
|
+
- the data is sensitive or scoped (orders, invoices, profiles, PII).
|
|
36
|
+
Public, intentionally-shared objects are NOT findings.
|
|
37
|
+
|
|
38
|
+
## Evidence & severity
|
|
39
|
+
Cite two requests: the baseline and the cross-object read, plus the field that
|
|
40
|
+
proves different ownership (e.g. a different `owner`/`email`). Severity high, or
|
|
41
|
+
critical if it exposes PII/financial data at scale (sequential ids). Read only —
|
|
42
|
+
never modify or delete an enumerated object.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: authz-differential
|
|
3
|
+
description: Prove a broken-access-control impact (IDOR/BOLA, tenant crossing, privilege escalation) by differentially comparing responses across identities or ids, so the boundary break is demonstrated, not asserted.
|
|
4
|
+
when-to-use: a candidate access-control weakness needs PROOF — the same resource is reachable by parties who should have different access, or an id-keyed object may cross an owner/tenant boundary, and you want a reproducible comparison rather than a single suggestive response.
|
|
5
|
+
surface: web
|
|
6
|
+
severity: high
|
|
7
|
+
tier: safe-active
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Differential authorization proof (exploiter)
|
|
11
|
+
|
|
12
|
+
A non-destructive playbook for the `diff` op (safe-active). Where the passive
|
|
13
|
+
`idor-enumeration` skill *finds* id-keyed endpoints with `http`, this skill *proves*
|
|
14
|
+
the authorization boundary is broken by comparing responses side by side. The
|
|
15
|
+
`diff` op returns FACTUAL signals (`status_varies`, `length_varies`,
|
|
16
|
+
`content_varies`, `distinct_bodies`) — it renders no verdict; you reason over them.
|
|
17
|
+
|
|
18
|
+
## 1. State the authorization claim
|
|
19
|
+
Name, in one line, the boundary you believe is broken and who should be on each
|
|
20
|
+
side: e.g. "order 1001 belongs to account A; an anonymous caller (or account B)
|
|
21
|
+
must not read it." The claim is what the differential will confirm or refute.
|
|
22
|
+
|
|
23
|
+
## 2. Build the differential
|
|
24
|
+
Pick the comparison that isolates the boundary — vary ONE thing (the principal, or
|
|
25
|
+
the object id), hold the rest constant:
|
|
26
|
+
- **Cross-owner / tenant (BOLA):** fetch the SAME endpoint for ids owned by
|
|
27
|
+
different principals.
|
|
28
|
+
`diff {"requests":[{"path":"/api/orders/1001"},{"path":"/api/orders/1002"}]}`
|
|
29
|
+
- **Authenticated vs anonymous:** fetch one resource as a named identity and again
|
|
30
|
+
with no identity.
|
|
31
|
+
`diff {"requests":[{"path":"/api/orders/1001","identity":"user_a"},{"path":"/api/orders/1001"}]}`
|
|
32
|
+
- **Low-priv vs high-priv surface:** fetch an admin/privileged path as each
|
|
33
|
+
identity to show a role boundary is not enforced.
|
|
34
|
+
|
|
35
|
+
Each sub-request is shaped like `http` args; a `"label"` is optional but makes the
|
|
36
|
+
result readable.
|
|
37
|
+
|
|
38
|
+
## 3. Read the signals, decide the boundary
|
|
39
|
+
- `content_varies: true` with `distinct_bodies == 2` across two *different-owner*
|
|
40
|
+
reads, both `status 200`, no auth required → each party reads the other's
|
|
41
|
+
record: the object boundary is broken (IDOR/BOLA).
|
|
42
|
+
- A protected resource returning the SAME body to a named identity and to anonymous
|
|
43
|
+
→ authentication is not enforced on it.
|
|
44
|
+
- `content_varies: false` / `distinct_bodies == 1` across supposedly-distinct
|
|
45
|
+
principals → the boundary HOLDS, or the resource is public by design — NOT a
|
|
46
|
+
finding. (The benign twin: `/api/catalog` fetched twice returns one body.)
|
|
47
|
+
|
|
48
|
+
## 4. Distinguish a real break from correct/public behavior
|
|
49
|
+
It is only an impact if the data is one the requester SHOULD NOT reach: a different
|
|
50
|
+
owner/tenant, sensitive/scoped data (orders, invoices, profiles, PII), and no
|
|
51
|
+
authorization error (not 401/403). A public, intentionally-shared resource that
|
|
52
|
+
simply answers everyone is not a finding, however much it "varies."
|
|
53
|
+
|
|
54
|
+
## 5. Prove without harm
|
|
55
|
+
Demonstrate the read; never exercise it destructively. Read the record you should
|
|
56
|
+
not be able to read — do not modify, delete, or create. If a mutating step would
|
|
57
|
+
be needed to go further, stop: that boundary is deliberate.
|
|
58
|
+
|
|
59
|
+
## Evidence & severity
|
|
60
|
+
Cite the `diff` evidence id(s) and the exact distinguishing field that proves
|
|
61
|
+
different ownership (e.g. a differing `owner`/`email`/`tenant`). Severity high, or
|
|
62
|
+
critical when sequential/guessable ids expose PII or financial data at scale.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: business-logic
|
|
3
|
+
description: Find and prove business-logic flaws — the app enforces the wrong invariant, so tampering with a trusted parameter, step order, or quantity yields an outcome the rules should forbid.
|
|
4
|
+
when-to-use: a request carries a value the server should decide itself (price, total, discount, role, account id, quantity, status), or a multi-step workflow (cart→pay→ship, invite→accept, apply→approve) could be reordered, replayed, or skipped.
|
|
5
|
+
surface: web
|
|
6
|
+
severity: high
|
|
7
|
+
tier: safe-active
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Business-logic abuse (parameter tampering, workflow, mass assignment)
|
|
11
|
+
|
|
12
|
+
A non-destructive playbook for the `http` and `diff` ops. This is the most
|
|
13
|
+
LLM-differentiated class: no scanner signature finds it — you must reason about what
|
|
14
|
+
the operator INTENDED and where the app trusts the client for something it should
|
|
15
|
+
decide. Prove the broken invariant by demonstrating the outcome; do not complete a
|
|
16
|
+
genuinely fraudulent or destructive transaction.
|
|
17
|
+
|
|
18
|
+
## 1. State the invariant the app should enforce
|
|
19
|
+
In one line: what must always hold? "The price charged is the catalog price, never
|
|
20
|
+
the client's." "Only an admin may set role=admin." "A coupon applies at most once."
|
|
21
|
+
"You can only read your own account." The finding is a violation of THIS.
|
|
22
|
+
|
|
23
|
+
## 2. Find where the client is trusted
|
|
24
|
+
Look for request fields that encode a server-side decision: `price`, `total`,
|
|
25
|
+
`amount`, `discount`, `role`, `is_admin`, `account_id`, `owner`, `status`,
|
|
26
|
+
`quantity`, `step`. Baseline the honest request and note which of these the response
|
|
27
|
+
echoes back as if authoritative.
|
|
28
|
+
|
|
29
|
+
## 3. Tamper one thing and compare — use diff
|
|
30
|
+
Change exactly the trusted field, hold the rest constant, and compare:
|
|
31
|
+
`diff {"requests":[
|
|
32
|
+
{"path":"/api/checkout?item=1&price=19.99"},
|
|
33
|
+
{"path":"/api/checkout?item=1&price=0.01"}]}`.
|
|
34
|
+
`content_varies` with the charge following your input (0.01) is the flaw: the server
|
|
35
|
+
honored a value it should have decided. For workflows, diff the result of the normal
|
|
36
|
+
order vs the reordered/replayed/skipped order.
|
|
37
|
+
|
|
38
|
+
## 4. Distinguish a real flaw from correct behavior
|
|
39
|
+
If the server re-derives the value and ignores your input (`content_varies: false`,
|
|
40
|
+
the charge stays at the catalog price), the invariant holds — NOT a finding. Beware
|
|
41
|
+
values that are merely display echoes with no effect; confirm the tampered value
|
|
42
|
+
actually changes the authoritative outcome.
|
|
43
|
+
|
|
44
|
+
## 5. Prove without harm
|
|
45
|
+
Demonstrate the broken invariant with the smallest read/echo that shows it (the
|
|
46
|
+
response reflects the under-charge, the escalated role, the reused coupon). Do NOT
|
|
47
|
+
finalize a real fraudulent purchase, grant yourself durable admin, or mutate state —
|
|
48
|
+
if proving it further requires a write, stop and report the demonstrated boundary.
|
|
49
|
+
|
|
50
|
+
## Evidence & severity
|
|
51
|
+
Cite the honest vs. tampered requests (or the diff evidence id) and name the
|
|
52
|
+
invariant broken and the resulting impact (underpayment, privilege gain, boundary
|
|
53
|
+
cross). Severity by impact: high for money/privilege, up to critical at scale.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: client-side-exploitation
|
|
3
|
+
description: Prove interaction-only client-side bugs by DRIVING the page in a real headless browser — fill a field, click, submit a form — to trigger stored/DOM XSS that only fires on interaction, exercise a client-side state change, or complete a multi-step flow, then read the resulting DOM. The exploit-tier step beyond a passive render.
|
|
4
|
+
when-to-use: a safe-active `render` (load-and-observe) showed a sink or a form/button that only acts on user interaction (a comment box that reflects into innerHTML on submit, a handler that runs on click), a bug you cannot reach by just loading a URL, and the engagement is at the exploit tier.
|
|
5
|
+
surface: web
|
|
6
|
+
severity: high
|
|
7
|
+
tier: exploit
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Client-side exploitation (driving the browser)
|
|
11
|
+
|
|
12
|
+
The exploit-tier counterpart to `render`. A passive render loads a page and reads what
|
|
13
|
+
its JavaScript produced; this DRIVES the page — `render` with `actions` (fill, click,
|
|
14
|
+
press, submit) — to reach bugs that only trigger on interaction, then returns the
|
|
15
|
+
resulting DOM, console, and fetched URLs. Same op, same scope/origin rules; the
|
|
16
|
+
`actions` capability unlocks only at the exploit tier with destructiveActions.
|
|
17
|
+
|
|
18
|
+
Interaction causes real client-side effects (and can submit forms that change server
|
|
19
|
+
state). Keep the action sequence minimal and benign, and prove execution, not just
|
|
20
|
+
that you clicked.
|
|
21
|
+
|
|
22
|
+
## 1. Find the interaction sink first
|
|
23
|
+
From a passive render or the DOM, identify the element and the handler: a field whose
|
|
24
|
+
value is written into `innerHTML`/`document.write` on a button click, a form that
|
|
25
|
+
reflects its input on submit, a control that mutates client state. If a payload in the
|
|
26
|
+
URL fragment already fires (no interaction needed), that is the passive
|
|
27
|
+
`client-side-rendering` case — use that; this skill is for sinks that need a click.
|
|
28
|
+
|
|
29
|
+
## 2. Drive the minimal sequence
|
|
30
|
+
Fill the field with your payload, then trigger the handler:
|
|
31
|
+
`render {"path":"/form","actions":[{"type":"fill","selector":"#comment","value":"<img
|
|
32
|
+
src=x onerror=\"document.title='XSS_FIRED'\">"},{"type":"click","selector":"#go"}]}`.
|
|
33
|
+
Use a payload whose execution is unambiguous and observable — set `document.title`, add
|
|
34
|
+
a uniquely-identifiable node — not `alert()` (nothing reads it). Keep to the few actions
|
|
35
|
+
the proof needs.
|
|
36
|
+
|
|
37
|
+
## 3. Prove execution from the result
|
|
38
|
+
The finding is the payload EXECUTING after interaction: the title/marker your payload
|
|
39
|
+
set is present in the returned DOM/`title`, the injected node is live in the DOM, or a
|
|
40
|
+
handler console error shows it ran. A payload that appears as inert, escaped text in the
|
|
41
|
+
DOM did NOT execute — the sink is safe.
|
|
42
|
+
|
|
43
|
+
## 4. Confirm the input caused it (precision)
|
|
44
|
+
Run the same actions against a benign input (or the properly-escaped twin, if one
|
|
45
|
+
exists): if the marker does NOT appear there, your input — not the page — caused it.
|
|
46
|
+
A page that renders your payload with `textContent` (escaped) is NOT vulnerable; do not
|
|
47
|
+
claim it. Clicking a button that does nothing observable is not a finding.
|
|
48
|
+
|
|
49
|
+
## 5. If actions submit to the server
|
|
50
|
+
A `submit` can change server state (it is a real form post). Treat that like any
|
|
51
|
+
state-change exploit: prefer a reversible/throwaway submission, confirm the effect, and
|
|
52
|
+
clean up. Do not mass-submit or repeat destructive posts to "confirm".
|
|
53
|
+
|
|
54
|
+
## Scope & safety
|
|
55
|
+
`render` (with or without actions) only reaches the origin and hosts under the verified
|
|
56
|
+
root domain, and carries a login session as a cookie to the origin only. One proving
|
|
57
|
+
interaction per finding; keep the action list short.
|
|
58
|
+
|
|
59
|
+
## Evidence & severity
|
|
60
|
+
Cite the path, the exact action sequence (selectors + payload), and the rendered DOM /
|
|
61
|
+
title / console line that shows execution — plus the benign/escaped comparison. An
|
|
62
|
+
interaction-triggered XSS that runs in a victim's authenticated session is high
|
|
63
|
+
(session/account compromise). No proven execution, no finding.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: client-side-rendering
|
|
3
|
+
description: See the surface that only exists after JavaScript runs — render a page in a real headless browser to find DOM-based XSS (a payload that executes only once client JS processes it, invisible to a raw HTTP fetch) and to enumerate the routes and APIs a client-rendered app (SPA) loads at runtime.
|
|
4
|
+
when-to-use: the raw http body of a page is a near-empty shell (a React/Vue/Angular app, lots of script, little markup), you suspect a sink that reads location/hash/postMessage into the DOM, or you need to discover the endpoints a client-rendered app calls that never appear in the static HTML.
|
|
5
|
+
surface: web
|
|
6
|
+
severity: high
|
|
7
|
+
tier: safe-active
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Client-side rendering (DOM XSS & SPA surface)
|
|
11
|
+
|
|
12
|
+
A non-destructive playbook for the `render` op (with `http` for the raw comparison).
|
|
13
|
+
Modern apps ship a shell and build the page in the browser, so the `http` body shows
|
|
14
|
+
almost nothing — the real attack surface appears only after JavaScript runs. `render`
|
|
15
|
+
loads the page in a real browser and returns the rendered DOM, console messages, and
|
|
16
|
+
the URLs the page fetched.
|
|
17
|
+
|
|
18
|
+
`render` is LOAD-AND-OBSERVE ONLY. It navigates and reads; it does not click, submit,
|
|
19
|
+
or drive the page. Interacting with the page is exploit-tier — do not try to simulate
|
|
20
|
+
it here.
|
|
21
|
+
|
|
22
|
+
## 1. Confirm it's worth rendering
|
|
23
|
+
If `http` on a page returns a tiny shell dominated by `<script>` with little content,
|
|
24
|
+
or you see a client-side sink (code reading `location.hash`, `location.search`,
|
|
25
|
+
`document.referrer`, `postMessage`, or `name` into `innerHTML`/`document.write`),
|
|
26
|
+
render it.
|
|
27
|
+
|
|
28
|
+
## 2. Find DOM-based XSS
|
|
29
|
+
Put the payload in the part of the URL the sink reads — usually the fragment, which
|
|
30
|
+
never reaches the server: `render {"path":"/app#<img src=x onerror=...>"}`. A
|
|
31
|
+
reflected-XSS payload won't help here; the sink is client-side. Prove execution from
|
|
32
|
+
the rendered result: the injected node present in the returned DOM, a console error
|
|
33
|
+
from the handler, or a title/marker your payload set. Compare with a benign input to
|
|
34
|
+
confirm the input — not the page — caused it.
|
|
35
|
+
|
|
36
|
+
## 3. Enumerate client-rendered surface
|
|
37
|
+
The returned `requests` are the URLs the page fetched as it built itself — APIs,
|
|
38
|
+
config, feature-flag and route endpoints that never appear in the static HTML. Treat
|
|
39
|
+
each as a new target for `http`/`diff` (auth, IDOR, oversharing). This is often how
|
|
40
|
+
you find the real API behind an SPA.
|
|
41
|
+
|
|
42
|
+
## 4. Render as a session
|
|
43
|
+
To test authenticated client-side state, `login` first, then
|
|
44
|
+
`render {"path":"/dashboard","identity":"<session>"}` — the session cookie is carried
|
|
45
|
+
into the browser (origin only). Compare anonymous vs. authenticated renders.
|
|
46
|
+
|
|
47
|
+
## 5. Distinguish a real DOM-XSS from nothing
|
|
48
|
+
Only a finding if your input actually executes or injects live markup in the rendered
|
|
49
|
+
DOM. Input that appears escaped or as inert text is NOT DOM-XSS — the sink is safe;
|
|
50
|
+
do not claim it. A console error alone, with no injected execution, is not proof.
|
|
51
|
+
|
|
52
|
+
## Evidence & severity
|
|
53
|
+
Cite the rendered URL (with the payload), and the rendered DOM / console line that
|
|
54
|
+
shows execution — or, for surface enumeration, the fetched URLs. DOM-based XSS is
|
|
55
|
+
high (account/session compromise in the victim's browser); a newly-enumerated
|
|
56
|
+
unauthenticated API that overshares is as severe as the data it exposes. Render and
|
|
57
|
+
read only; never drive the page to perform actions.
|