File size: 11,671 Bytes
256c9c2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
"""
bugbounty_checklist.py
──────────────────────
In-process loader for the vendored Galaxy-Bugbounty-Checklist
(https://github.com/0xmaximus/Galaxy-Bugbounty-Checklist).

The repo is a hand-curated bug bounty methodology library β€” one folder per
vulnerability class (XSS, SSRF, SQLi, OAuth, IDOR, CSRF bypass, …),
each containing a long-form checklist plus, where applicable, a payload
file (e.g. ``sql_injection/SQL.txt``, ``xss_payloads/README.md``).

This module loads it from ``vendor/galaxy_bugbounty/`` at import time so
that the rest of the system can:

  * Look up a checklist by category or by CWE / vulnerability tag
    (``vuln_classifier.py``, ``red_team_fuzzer.py``).
  * Pull payload corpora for a category (used by the fuzzer + harness
    factory to seed boundary-value inputs).
  * Surface "what should I check next" hints to the orchestrator while
    triaging a candidate finding.
  * Expose the same data through a tiny REST adapter so other engines
    (and the OpenClaude tool layer) can consume it without re-parsing
    markdown on every call.

Design rules
────────────
1. Pure Python stdlib β€” no heavy dependencies, no I/O at import time
   beyond a single directory scan.
2. Zero hard dependency: if the vendor directory is missing the module
   degrades to empty-result helpers and logs a warning instead of
   raising, so the orchestrator keeps working.
3. Read-only β€” never mutate the vendored content.
"""

from __future__ import annotations

import logging
import os
import re
from dataclasses import dataclass, field
from functools import lru_cache
from pathlib import Path
from typing import Dict, Iterable, List, Optional

log = logging.getLogger("rhodawk.bugbounty")

# ─── Locations ─────────────────────────────────────────────────────────
_HERE = Path(__file__).resolve().parent
VENDOR_DIR = Path(
    os.environ.get("RHODAWK_GALAXY_DIR", str(_HERE / "vendor" / "galaxy_bugbounty"))
)

# CWE / common-tag β†’ vendored category-folder slug.
# Keep the keys lowercase, snake_case-friendly so callers can match
# either CWE numbers or a normalized vulnerability label.
CWE_CATEGORY_MAP: Dict[str, str] = {
    # XSS family
    "cwe-79": "xss_payloads",
    "xss": "xss_payloads",
    "reflected_xss": "xss_payloads",
    "stored_xss": "xss_payloads",
    "dom_xss": "xss_payloads",
    # Injection
    "cwe-89": "sql_injection",
    "sqli": "sql_injection",
    "sql_injection": "sql_injection",
    "cwe-93": "crlf_injection",
    "crlf": "crlf_injection",
    # SSRF
    "cwe-918": "ssrf",
    "ssrf": "ssrf",
    # CSRF
    "cwe-352": "csrf_bypass",
    "csrf": "csrf_bypass",
    # Access control / IDOR
    "cwe-284": "broken_access_control",
    "cwe-285": "broken_access_control",
    "cwe-639": "broken_access_control",
    "idor": "broken_access_control",
    "bac": "broken_access_control",
    # Auth / account takeover / 2FA
    "cwe-287": "account_takeover",
    "ato": "account_takeover",
    "account_takeover": "account_takeover",
    "2fa_bypass": "2fa_bypass",
    # Open redirect
    "cwe-601": "open_redirect",
    "open_redirect": "open_redirect",
    # Sensitive data / info disclosure
    "cwe-200": "sensitive_data_exposure",
    "info_disclosure": "sensitive_data_exposure",
    # File upload
    "cwe-434": "file_upload",
    "file_upload": "file_upload",
    # Rate limit
    "rate_limit": "rate_limit_bypass",
    "rate_limit_bypass": "rate_limit_bypass",
    # OAuth
    "oauth": "oauth",
    # Smuggling
    "cwe-444": "http_request_smuggling",
    "request_smuggling": "http_request_smuggling",
    # Cache deception
    "cache_deception": "web_cache_deception",
    "web_cache_deception": "web_cache_deception",
    # WordPress / IIS / Log4j
    "wordpress": "wordpress",
    "iis": "internet_information_services_iis",
    "log4j": "log4shell",
    "log4shell": "log4shell",
    # Reset password
    "reset_password": "reset_password_vulnerabilities",
    # Param pollution
    "hpp": "parameter_pollution",
    "parameter_pollution": "parameter_pollution",
    # OSINT recon
    "osint": "osint",
    # API
    "api": "api_security",
    "api_security": "api_security",
    # DoS
    "cwe-400": "dos",
    "dos": "dos",
}


# ─── Data model ────────────────────────────────────────────────────────
@dataclass(frozen=True)
class Checklist:
    """A single vulnerability-class checklist as vendored from Galaxy."""
    category: str                     # snake_case slug, e.g. "ssrf"
    title: str                        # human-readable title
    markdown: str                     # full README content
    payloads: List[str] = field(default_factory=list)  # raw payload strings
    payload_files: List[str] = field(default_factory=list)
    source_dir: str = ""

    def summary(self, max_chars: int = 600) -> str:
        body = re.sub(r"\s+", " ", self.markdown).strip()
        return body[:max_chars] + ("…" if len(body) > max_chars else "")

    def to_dict(self) -> Dict[str, object]:
        return {
            "category": self.category,
            "title": self.title,
            "summary": self.summary(),
            "payload_count": len(self.payloads),
            "payload_files": list(self.payload_files),
            "source_dir": self.source_dir,
        }


# ─── Loader ────────────────────────────────────────────────────────────
def _read(path: Path) -> str:
    try:
        return path.read_text(encoding="utf-8", errors="replace")
    except OSError as exc:
        log.warning("could not read %s: %s", path, exc)
        return ""


def _extract_title(markdown: str, fallback: str) -> str:
    for line in markdown.splitlines():
        stripped = line.strip()
        if stripped.startswith("# "):
            return stripped[2:].strip()
    return fallback.replace("_", " ").title()


def _split_payloads(text: str) -> List[str]:
    """Split a payload file into individual non-empty payload lines.

    Galaxy payload files are mostly newline-separated; some entries
    contain inline whitespace which we preserve.
    """
    lines = []
    for raw in text.splitlines():
        s = raw.strip()
        if not s or s.startswith("#"):
            continue
        lines.append(s)
    return lines


@lru_cache(maxsize=1)
def load_all() -> Dict[str, Checklist]:
    """Return ``{category_slug: Checklist}`` for every vendored category."""
    out: Dict[str, Checklist] = {}
    if not VENDOR_DIR.is_dir():
        log.warning("Galaxy bug bounty vendor dir missing at %s", VENDOR_DIR)
        return out

    for entry in sorted(VENDOR_DIR.iterdir()):
        if not entry.is_dir():
            continue
        readme = next(
            (p for p in entry.iterdir() if p.name.lower() == "readme.md"),
            None,
        )
        markdown = _read(readme) if readme else ""
        title = _extract_title(markdown, entry.name)

        payloads: List[str] = []
        payload_files: List[str] = []
        for f in sorted(entry.iterdir()):
            if f.is_file() and f.suffix.lower() == ".txt":
                payload_files.append(f.name)
                payloads.extend(_split_payloads(_read(f)))

        out[entry.name] = Checklist(
            category=entry.name,
            title=title,
            markdown=markdown,
            payloads=payloads,
            payload_files=payload_files,
            source_dir=str(entry),
        )
    return out


# ─── Public API ────────────────────────────────────────────────────────
def list_categories() -> List[str]:
    return sorted(load_all().keys())


def get_checklist(category: str) -> Optional[Checklist]:
    """Look up by exact category slug (e.g. ``"ssrf"``)."""
    return load_all().get(category)


def _normalize_tag(tag: str) -> str:
    return re.sub(r"[^a-z0-9]+", "_", tag.lower()).strip("_")


def match_for_tag(tag: str) -> Optional[Checklist]:
    """Map an arbitrary tag (CWE id, vuln label, classifier output, …)
    to a checklist.  Returns ``None`` if no mapping is known.
    """
    if not tag:
        return None
    norm = _normalize_tag(tag)
    cats = load_all()

    # 1. exact category
    if norm in cats:
        return cats[norm]
    # 2. mapped CWE / synonym
    slug = CWE_CATEGORY_MAP.get(norm)
    if slug and slug in cats:
        return cats[slug]
    # 3. CWE prefix variants ("cwe79" / "79")
    if norm.isdigit():
        slug = CWE_CATEGORY_MAP.get(f"cwe-{norm}")
        if slug and slug in cats:
            return cats[slug]
    # 4. fuzzy contains
    for key, cl in cats.items():
        if norm in key or key in norm:
            return cl
    return None


def payloads_for(tag_or_category: str, limit: int = 200) -> List[str]:
    """Return up to ``limit`` payload strings for a category/tag.

    Used by ``red_team_fuzzer.py`` and ``harness_factory.py`` to seed
    boundary-value test corpora β€” Galaxy's ``SQL.txt`` is a classic
    SQLi corpus, ``xss_payloads/README.md`` carries a long XSS list
    embedded in a markdown table.
    """
    cl = match_for_tag(tag_or_category)
    if not cl:
        return []
    payloads = list(cl.payloads)
    # If no .txt files, scrape inline ``code`` snippets from the README
    # which is where many of the XSS / SSRF payloads actually live.
    if not payloads:
        payloads = re.findall(r"`([^`\n]{2,200})`", cl.markdown)
    # De-duplicate while preserving order.
    seen, dedup = set(), []
    for p in payloads:
        if p in seen:
            continue
        seen.add(p)
        dedup.append(p)
        if len(dedup) >= limit:
            break
    return dedup


def hints_for_finding(
    *,
    cwe: str = "",
    label: str = "",
    description: str = "",
    max_bullets: int = 8,
) -> List[str]:
    """Return short bullet-point reminders extracted from the matching
    checklist β€” meant to be injected into the orchestrator's triage
    prompt so the model considers Galaxy's hand-written tradecraft when
    assessing a finding."""
    cl = match_for_tag(cwe) or match_for_tag(label)
    if not cl and description:
        # last-ditch keyword scan
        d_norm = description.lower()
        for key in CWE_CATEGORY_MAP:
            if key in d_norm:
                cl = match_for_tag(key)
                if cl:
                    break
    if not cl:
        return []

    bullets = re.findall(
        r"^\s*[-*]\s+(.+)$",
        cl.markdown,
        flags=re.MULTILINE,
    )
    out, seen = [], set()
    for b in bullets:
        s = b.strip()
        if not s or s in seen:
            continue
        seen.add(s)
        out.append(s)
        if len(out) >= max_bullets:
            break
    return out


def stats() -> Dict[str, object]:
    cats = load_all()
    return {
        "vendor_dir": str(VENDOR_DIR),
        "available": bool(cats),
        "categories": len(cats),
        "total_payloads": sum(len(c.payloads) for c in cats.values()),
        "category_list": sorted(cats.keys()),
    }


__all__ = [
    "Checklist",
    "CWE_CATEGORY_MAP",
    "load_all",
    "list_categories",
    "get_checklist",
    "match_for_tag",
    "payloads_for",
    "hints_for_finding",
    "stats",
]