@murk-security/runner-linux-arm64 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,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.