mirror of
https://github.com/elementalsouls/Claude-BugHunter.git
synced 2026-11-07 10:36:04 -10:00
* fix(engine): 3 verified council findings — F7 (silent failed-hunt), F32 (TLS), F43 (auth-sweep FP)
F7 — engine/agent.py + engine/engine.py: agent hard errors (timeout / rate-limit /
parse-fail / missing 'claude' CLI) were recorded as 'tested, found nothing' and never
retried; sequential mode also crashed on a missing CLI. Now: catch FileNotFoundError/
OSError in run_agent; _hunt_agent flags errored/no-verdict results; hunt() leaves those
items un-tested (stay on worklist for retry) instead of marking them tested-negative.
F32 — cbh/cli.py: an ambient corporate HTTPS_PROXY/HTTP_PROXY silently set ssl.CERT_NONE
(TLS verification off). Now CERT_NONE is gated behind an explicit Burp/insecure opt-in
(--burp/--proxy or CBH_BURP_PROXY); ambient proxy env keeps verification ON.
F43 — engine/recon.py: a global OpenAPI 'security: []' (= public API) was treated as
secured, producing false-positive 'broken function-level auth' findings. Now mirrors the
op-level check: glob = spec.get('security') not in (None, []).
Verified: all files compile; agent.py/state.py self-tests pass; --mock dry-run unchanged;
added behavioral checks for each fix (errored-not-tested, ambient-proxy-TLS-on, security:[]-not-swept).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* fix(engine+eval): 8 more verified council findings (F8/F9/F41/F24/F23/F12/F14/F20)
Core engine:
- F8 cbh/cli.py: crt.sh outage/rate-limit/JSON-error now warns loudly instead of
silently returning empty (a failed lookup was indistinguishable from '0 subdomains').
- F9 engine/recon.py: prune_inert_params distinguishes a FAILED probe (code 0, network/
curl error -> keep the endpoint) from a genuine HTTP >=400 dead endpoint; logs the
unprobeable count. Transient failures no longer silently drop live surface.
- F41 engine/recon.py: mined relative routes now carry their source-document base
(found_rel stores (base, rel)); each is resolved against the doc it came from, not the
first seed's origin — fixes wrong-host endpoints on multi-seed runs.
Eval / labs / scripts:
- F24 docs/verification/phase3-playwright/harness.py: OAuth @-userinfo bypass payload was
malformed (@ landed in the PATH -> browser stayed on localhost, refuting a real bug).
Corrected to http://localhost:58020@<evil>/... (userinfo=localhost:58020, host=evil),
which passes the server's startswith prefix AND swaps host. Docstring corrected.
- F23 eval/run_fp.py: validate --conditions against {baseline,skills}; a typo'd condition
previously ran as baseline under the wrong label and invalidated the ablation.
- F12 eval/run_eval_ps.py: a post-run oracle read-failure (expired instance) was scored as
solved=False (a miss). Now captures the ok flag, records solved=None + oracle_ok, and
excludes oracle-unreadable runs from the solve-rate denominator (summary + per-class).
- F14 secret_scan.py: missing/unreadable and >10MB files were silently skipped (false
'clean'); now warns to stderr on every skip.
- F20 eval/fp_app.py: the 'safe' /go open-redirect trap accepted /\evil.com (browsers
normalize \ -> / -> protocol-relative external redirect), so its ground-truth 'safe'
label was wrong. Now rejects backslashes and control chars.
Verified: all files compile; recon.py/agent.py/state.py self-tests pass; --mock flow
unchanged; added behavioral checks for F9/F41/F20/F24/F14/F23/F12.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
---------
Co-authored-by: Sachin Sharma <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
940 lines
38 KiB
Python
Executable File
940 lines
38 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
cbh — claude-bughunter CLI.
|
|
|
|
Bridges the repo's skill content into a real runner. Five subcommands compose
|
|
the engagement loop:
|
|
|
|
cbh recon <target> passive subdomain enum + live-host probe + URL
|
|
classification. Writes recon/<target>/ including
|
|
manifest.json (the recon→hunt handoff contract).
|
|
cbh surface <target> read recon/<target>/manifest.json and print the
|
|
ranked P1/P2/Kill attack surface. See
|
|
docs/recon-manifest.md.
|
|
cbh classify <url> pattern-match a single URL against hunt-* skill
|
|
descriptions; print the matched skills + ranked
|
|
attack candidates from docs/disclosed-reports/.
|
|
cbh triage <finding.md> run the triage-validation 7-Question Gate
|
|
against a finding file. Output: PASS / DOWNGRADE
|
|
/ KILL with reason.
|
|
cbh report <finding.md> emit a report draft (H1 / Bugcrowd / Intigriti /
|
|
Immunefi templates) based on finding metadata.
|
|
|
|
Stdlib + optional `requests`. No build step. Drop on PATH:
|
|
|
|
ln -s $(pwd)/scripts/cbh.py /usr/local/bin/cbh
|
|
|
|
Or run inline:
|
|
|
|
scripts/cbh.py recon target.com
|
|
"""
|
|
from __future__ import annotations
|
|
import argparse
|
|
import datetime
|
|
import json
|
|
import os
|
|
import re
|
|
import shlex
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import textwrap
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from pathlib import Path
|
|
from typing import Iterable
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
SKILLS_DIR = REPO_ROOT / "skills"
|
|
REPORTS_DIR = REPO_ROOT / "docs" / "disclosed-reports"
|
|
|
|
# Clone mode = the live repo is present (skills/ on disk). Otherwise we're
|
|
# pip-installed and fall back to the bundled cbh/data/skill_index.json.
|
|
CLONE_MODE = SKILLS_DIR.is_dir() and any(SKILLS_DIR.iterdir()) if SKILLS_DIR.exists() else False
|
|
REPO_URL = "https://github.com/elementalsouls/Claude-BugHunter"
|
|
|
|
_BUNDLED_INDEX: dict | None = None
|
|
|
|
|
|
def _bundled_index() -> dict:
|
|
"""Load the packaged skill/report index (installed mode). Cached."""
|
|
global _BUNDLED_INDEX
|
|
if _BUNDLED_INDEX is None:
|
|
try:
|
|
from importlib.resources import files
|
|
raw = (files("cbh") / "data" / "skill_index.json").read_text(encoding="utf-8")
|
|
except Exception:
|
|
# Last-resort: read alongside this file.
|
|
p = Path(__file__).resolve().parent / "data" / "skill_index.json"
|
|
raw = p.read_text(encoding="utf-8") if p.exists() else '{"skills":{},"reports":[]}'
|
|
_BUNDLED_INDEX = json.loads(raw)
|
|
return _BUNDLED_INDEX
|
|
|
|
|
|
# ============================================================
|
|
# Shared utilities
|
|
# ============================================================
|
|
def color(s: str, c: str) -> str:
|
|
if not sys.stdout.isatty():
|
|
return s
|
|
codes = {"red": 31, "green": 32, "yellow": 33, "blue": 34, "cyan": 36, "bold": 1, "dim": 2}
|
|
return f"\033[{codes.get(c, 0)}m{s}\033[0m"
|
|
|
|
|
|
def say(s: str = ""):
|
|
print(s)
|
|
|
|
|
|
def section(title: str):
|
|
say()
|
|
say(color("=" * 70, "blue"))
|
|
say(color(title, "bold"))
|
|
say(color("=" * 70, "blue"))
|
|
|
|
|
|
def has_cmd(name: str) -> bool:
|
|
return subprocess.call(["which", name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0
|
|
|
|
|
|
def run_cmd(cmd: list[str], timeout: int = 30) -> tuple[int, str, str]:
|
|
try:
|
|
p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
|
return p.returncode, p.stdout, p.stderr
|
|
except subprocess.TimeoutExpired:
|
|
return 124, "", "timeout"
|
|
except FileNotFoundError:
|
|
return 127, "", "not found"
|
|
|
|
|
|
_HTTP_OPENER: urllib.request.OpenerDirector | None = None
|
|
|
|
|
|
def configure_http_proxy(proxy_url: str | None = None, insecure: bool = False) -> tuple[bool, str]:
|
|
"""Configure urllib to route through a proxy (typically Burp Suite at
|
|
127.0.0.1:8080). Returns (configured, message) — message describes the mode.
|
|
|
|
TLS certificate verification is disabled ONLY when `insecure=True` (an explicit
|
|
Burp/insecure opt-in). An ambient corporate HTTPS_PROXY/HTTP_PROXY is still used,
|
|
but keeps certificate verification ON — so a corporate proxy env var can never
|
|
silently turn off TLS validation while reconning a production target.
|
|
|
|
Resolution order when proxy_url is not given:
|
|
1. CBH_BURP_PROXY env var (the tool's own Burp var → implies insecure)
|
|
2. HTTPS_PROXY / HTTP_PROXY env vars (ambient/corporate → TLS stays verified)
|
|
"""
|
|
global _HTTP_OPENER
|
|
if not proxy_url:
|
|
burp_env = os.environ.get("CBH_BURP_PROXY")
|
|
if burp_env:
|
|
proxy_url, insecure = burp_env, True # Burp's CA isn't trusted → insecure
|
|
else:
|
|
proxy_url = os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY")
|
|
if not proxy_url:
|
|
_HTTP_OPENER = None
|
|
return False, "direct (no proxy)"
|
|
|
|
import ssl
|
|
ctx = ssl.create_default_context()
|
|
if insecure:
|
|
# Only for an explicit Burp/insecure opt-in: Burp's CA isn't typically trusted.
|
|
ctx.check_hostname = False
|
|
ctx.verify_mode = ssl.CERT_NONE
|
|
print("[warning] TLS certificate verification is DISABLED — insecure/Burp proxy mode. "
|
|
"Use only in isolated lab environments, not on production targets.", flush=True)
|
|
|
|
proxy_handler = urllib.request.ProxyHandler({"http": proxy_url, "https": proxy_url})
|
|
https_handler = urllib.request.HTTPSHandler(context=ctx)
|
|
_HTTP_OPENER = urllib.request.build_opener(proxy_handler, https_handler)
|
|
return True, f"via proxy {proxy_url} ({'TLS verify OFF' if insecure else 'TLS verify on'})"
|
|
|
|
|
|
def detect_burp() -> str | None:
|
|
"""Return Burp proxy URL if responsive on default port, else None."""
|
|
try:
|
|
with urllib.request.urlopen("http://127.0.0.1:8080/", timeout=1) as r:
|
|
# Burp's proxy returns a help page; just confirming it's listening
|
|
if r.status == 200:
|
|
return "http://127.0.0.1:8080"
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def http_get(url: str, timeout: int = 5, headers: dict | None = None) -> tuple[int, dict, str]:
|
|
"""Stdlib HTTP GET returning (status_code, headers_dict, body_str). Routes
|
|
through the configured proxy (e.g. Burp) if `configure_http_proxy()` was
|
|
called. Returns (0, {}, error_msg) on failure."""
|
|
req = urllib.request.Request(url, headers=headers or {})
|
|
opener = _HTTP_OPENER if _HTTP_OPENER is not None else urllib.request
|
|
try:
|
|
if _HTTP_OPENER is not None:
|
|
with _HTTP_OPENER.open(req, timeout=timeout) as r:
|
|
body = r.read().decode("utf-8", errors="replace")
|
|
return r.status, dict(r.headers), body
|
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
body = r.read().decode("utf-8", errors="replace")
|
|
return r.status, dict(r.headers), body
|
|
except urllib.error.HTTPError as e:
|
|
try:
|
|
body = e.read().decode("utf-8", errors="replace")
|
|
except Exception:
|
|
body = ""
|
|
return e.code, dict(e.headers or {}), body
|
|
except Exception as e:
|
|
return 0, {}, str(e)
|
|
|
|
|
|
# ============================================================
|
|
# recon — passive subdomain enum + DNS + HTTP probe
|
|
# ============================================================
|
|
def recon_subdomains_via_crtsh(target: str) -> set[str]:
|
|
"""crt.sh certificate transparency — passive, no API key needed."""
|
|
url = f"https://crt.sh/?q=%25.{target}&output=json"
|
|
status, _, body = http_get(url, timeout=20)
|
|
if status != 200 or not body:
|
|
# An outage / rate-limit (crt.sh often 503s) must NOT be mistaken for
|
|
# "no subdomains exist" — warn loudly so a failed lookup isn't read as empty.
|
|
why = f"HTTP {status}" if status else "unreachable / network error"
|
|
print(f"[warning] crt.sh lookup failed ({why}) — subdomain results may be INCOMPLETE, "
|
|
f"not necessarily empty.", file=sys.stderr, flush=True)
|
|
return set()
|
|
try:
|
|
rows = json.loads(body)
|
|
except Exception:
|
|
print("[warning] crt.sh returned unparseable JSON — subdomain results may be INCOMPLETE.",
|
|
file=sys.stderr, flush=True)
|
|
return set()
|
|
subs = set()
|
|
for r in rows:
|
|
nv = (r.get("name_value") or "").lower()
|
|
for line in nv.split("\n"):
|
|
line = line.strip()
|
|
if line and "*" not in line:
|
|
subs.add(line)
|
|
return subs
|
|
|
|
|
|
def recon_subdomains_via_subfinder(target: str) -> set[str]:
|
|
if not has_cmd("subfinder"):
|
|
return set()
|
|
_, out, _ = run_cmd(["subfinder", "-d", target, "-silent"], timeout=60)
|
|
return {line.strip().lower() for line in out.splitlines() if line.strip()}
|
|
|
|
|
|
def recon_resolve(host: str) -> list[str]:
|
|
"""Resolve A records using socket.getaddrinfo (stdlib). Avoids the
|
|
dnsx/httpx segfault issue documented on macOS arm64."""
|
|
try:
|
|
infos = socket.getaddrinfo(host, None, family=socket.AF_INET)
|
|
return sorted(set(i[4][0] for i in infos))
|
|
except Exception:
|
|
return []
|
|
|
|
|
|
def recon_http_probe(host: str) -> dict | None:
|
|
"""Fast HTTP probe — try https:// first, fall back to http://. Returns
|
|
a record with code/server/title or None if unreachable."""
|
|
for scheme in ("https", "http"):
|
|
url = f"{scheme}://{host}/"
|
|
code, headers, body = http_get(url, timeout=4)
|
|
if code == 0:
|
|
continue
|
|
title = ""
|
|
m = re.search(r"<title[^>]*>([^<]*)</title>", body[:8192], re.I)
|
|
if m:
|
|
title = m.group(1).strip()[:80]
|
|
return {
|
|
"url": url,
|
|
"code": code,
|
|
"server": headers.get("Server", ""),
|
|
"title": title,
|
|
"powered_by": headers.get("X-Powered-By", ""),
|
|
"drupal_cache": headers.get("X-Drupal-Cache", ""),
|
|
}
|
|
return None
|
|
|
|
|
|
def configure_proxy_from_args(args: argparse.Namespace) -> None:
|
|
"""If --burp or --proxy was passed, set up the urllib opener accordingly.
|
|
Print the mode banner so the operator knows where traffic is going."""
|
|
proxy_url = None
|
|
insecure = False
|
|
if getattr(args, "proxy", None):
|
|
proxy_url, insecure = args.proxy, True # explicit --proxy: operator opted in
|
|
elif getattr(args, "burp", False):
|
|
proxy_url, insecure = (detect_burp() or "http://127.0.0.1:8080"), True
|
|
configured, mode = configure_http_proxy(proxy_url, insecure)
|
|
if configured:
|
|
say(color(f" HTTP routing: {mode}", "yellow"))
|
|
say(color(f" Tip: requests will appear in Burp Proxy → HTTP history.", "dim"))
|
|
|
|
|
|
def cmd_recon(args: argparse.Namespace) -> int:
|
|
target = args.target
|
|
# Clone mode writes under the repo; installed mode writes under the CWD
|
|
# (never into site-packages). Override with --out.
|
|
recon_root = Path(args.out).resolve() if getattr(args, "out", None) \
|
|
else (REPO_ROOT / "recon" if CLONE_MODE else Path.cwd() / "recon")
|
|
out_dir = recon_root / target
|
|
resolved = out_dir.resolve()
|
|
safe = recon_root.resolve()
|
|
# Real containment check — `startswith` is bypassable by a sibling that
|
|
# shares the prefix (e.g. recon-evil vs recon), so test ancestry properly.
|
|
if resolved != safe and safe not in resolved.parents:
|
|
print(f"[error] invalid target: {target}", file=sys.stderr); return 1
|
|
out_dir = resolved
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
section(f"recon — {target}")
|
|
configure_proxy_from_args(args)
|
|
|
|
# Step 1 — passive subdomain enumeration
|
|
say(color("[1/4] passive subdomain enumeration", "cyan"))
|
|
subs = set()
|
|
subs |= recon_subdomains_via_crtsh(target)
|
|
say(f" crt.sh: {len(subs)} candidates")
|
|
sf = recon_subdomains_via_subfinder(target)
|
|
if sf:
|
|
before = len(subs)
|
|
subs |= sf
|
|
say(f" subfinder: {len(sf)} candidates (new: {len(subs) - before})")
|
|
else:
|
|
say(f" subfinder: {color('not installed', 'dim')}")
|
|
if not subs:
|
|
subs.add(target)
|
|
subs.add(target)
|
|
(out_dir / "subdomains.txt").write_text("\n".join(sorted(subs)) + "\n")
|
|
say(f" Total unique: {color(str(len(subs)), 'bold')}")
|
|
|
|
# Step 2 — DNS resolution
|
|
say()
|
|
say(color("[2/4] DNS resolution", "cyan"))
|
|
resolved = {}
|
|
for s in sorted(subs):
|
|
ips = recon_resolve(s)
|
|
if ips:
|
|
resolved[s] = ips
|
|
(out_dir / "resolved.txt").write_text(
|
|
"\n".join(f"{h}|{','.join(ips)}" for h, ips in sorted(resolved.items())) + "\n"
|
|
)
|
|
say(f" Resolved: {color(str(len(resolved)), 'bold')} / {len(subs)}")
|
|
|
|
# Step 3 — HTTP probe (concurrent via thread pool)
|
|
say()
|
|
say(color("[3/4] HTTP probe", "cyan"))
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
live = []
|
|
with ThreadPoolExecutor(max_workers=10) as ex:
|
|
futures = {ex.submit(recon_http_probe, h): h for h in resolved}
|
|
for f in as_completed(futures):
|
|
host = futures[f]
|
|
rec = f.result()
|
|
if rec:
|
|
rec["host"] = host
|
|
live.append(rec)
|
|
(out_dir / "live-hosts.json").write_text(json.dumps(live, indent=2))
|
|
say(f" HTTP-live: {color(str(len(live)), 'bold')} / {len(resolved)}")
|
|
|
|
# Step 4 — summary report
|
|
say()
|
|
say(color("[4/4] writing recon summary", "cyan"))
|
|
summary_path = out_dir / "RECON_SUMMARY.md"
|
|
write_recon_summary(target, subs, resolved, live, summary_path)
|
|
say(f" {summary_path}")
|
|
|
|
# recon→hunt handoff contract — see docs/recon-manifest.md
|
|
manifest = build_manifest(target, subs, resolved, live)
|
|
manifest_path = out_dir / "manifest.json"
|
|
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n")
|
|
say(f" {manifest_path} {color('(recon→hunt manifest)', 'dim')}")
|
|
|
|
section("SUMMARY")
|
|
say(f" Target: {target}")
|
|
say(f" Subdomains: {len(subs)}")
|
|
say(f" Resolved: {len(resolved)}")
|
|
say(f" HTTP-live: {len(live)}")
|
|
say(f" Output: {out_dir}")
|
|
say()
|
|
say(f" Next: {color('cbh classify <url>', 'bold')} for fast pattern-match, or {color('/hunt <target>', 'bold')} in Claude Code for full LLM-driven hunting")
|
|
return 0
|
|
|
|
|
|
def write_recon_summary(target: str, subs: set[str], resolved: dict, live: list, out: Path):
|
|
lines = [f"# Recon — {target}",
|
|
"",
|
|
f"_Generated by `cbh recon {target}` at {datetime.datetime.now().isoformat(timespec='seconds')}._",
|
|
"",
|
|
"## Attack-surface snapshot",
|
|
"",
|
|
f"- Subdomains discovered (passive): **{len(subs)}**",
|
|
f"- DNS-resolved: **{len(resolved)}**",
|
|
f"- HTTP-live: **{len(live)}**",
|
|
"",
|
|
"## Live hosts",
|
|
"",
|
|
"| Host | URL | Code | Server | Title |",
|
|
"|---|---|---|---|---|",
|
|
]
|
|
for r in sorted(live, key=lambda x: x["host"]):
|
|
title = (r.get("title") or "").replace("|", "\\|")[:50]
|
|
lines.append(f"| `{r['host']}` | {r['url']} | {r['code']} | {r.get('server','')} | {title} |")
|
|
lines += [
|
|
"",
|
|
"## Suggested next moves",
|
|
"",
|
|
"- For each live host, run `cbh classify https://<host>/<path>?<params>` to surface attack candidates.",
|
|
"- Cross-TLD pivot: check JS bundles for sister-domain references (per `web2-recon` Operator Notes).",
|
|
"- For `mta-sts.*` / `*.github.io` hosts, fingerprint against `hunt-subdomain` takeover table.",
|
|
"",
|
|
]
|
|
out.write_text("\n".join(lines))
|
|
|
|
|
|
# ============================================================
|
|
# recon→hunt manifest (the integration handoff contract)
|
|
# ============================================================
|
|
PRODUCER = "cbh-recon/2.1.0"
|
|
|
|
# subdomain keywords → triage priority + rationale for ranked_surface
|
|
_P1_HINTS = ("api.", "api-", "graphql", "auth.", "sso.", "login.", "account.",
|
|
"admin.", "internal.", "intranet.", "staging.", "stage.", "dev.",
|
|
"test.", "uat.", "qa.", "gateway.", "gw.", "vpn.", "portal.")
|
|
_KILL_HINTS = ("cdn.", "static.", "assets.", "img.", "images.", "media.", "fonts.")
|
|
|
|
|
|
def _rank_host(host: str) -> tuple[str, str]:
|
|
h = host.lower()
|
|
if any(k in h for k in _P1_HINTS):
|
|
return "P1", "high-value surface (api/auth/admin/non-prod)"
|
|
if any(h.startswith(k) for k in _KILL_HINTS):
|
|
return "KILL", "static/CDN host — low yield"
|
|
return "P2", "standard web surface"
|
|
|
|
|
|
def build_manifest(target: str, subs: set, resolved: dict, live: list) -> dict:
|
|
"""Assemble the recon→hunt manifest. cbh fills assets + ranked_surface; the
|
|
offensive-osint skill's deeper probes append secrets[] and identity_fabric{}."""
|
|
live_by_host = {r["host"]: r for r in live}
|
|
assets = []
|
|
for host in sorted(live_by_host):
|
|
r = live_by_host[host]
|
|
assets.append({
|
|
"host": host, "ips": resolved.get(host, []), "url": r.get("url"),
|
|
"status": r.get("code"), "server": r.get("server", ""),
|
|
"title": r.get("title", ""), "tech": r.get("tech", []),
|
|
"source": "crtsh+httpx",
|
|
})
|
|
for host in sorted(resolved): # DNS-only (resolved, not HTTP-live)
|
|
if host not in live_by_host:
|
|
assets.append({
|
|
"host": host, "ips": resolved[host], "url": None, "status": None,
|
|
"server": "", "title": "", "tech": [], "source": "crtsh+dns",
|
|
})
|
|
|
|
ranked = []
|
|
for host in sorted(live_by_host):
|
|
r = live_by_host[host]
|
|
prio, why = _rank_host(host)
|
|
bug_classes = sorted(classify_url(r["url"])["matches"].keys()) if r.get("url") else []
|
|
ranked.append({"url": r.get("url"), "host": host, "bug_classes": bug_classes,
|
|
"priority": prio, "rationale": why})
|
|
_order = {"P1": 0, "P2": 1, "KILL": 2}
|
|
ranked.sort(key=lambda x: (_order.get(x["priority"], 9), x["host"]))
|
|
|
|
return {
|
|
"schema_version": "1.0",
|
|
"target": target,
|
|
"generated_at": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"),
|
|
"producers": [PRODUCER],
|
|
"counts": {"subdomains": len(subs), "resolved": len(resolved), "live": len(live)},
|
|
"assets": assets,
|
|
"ranked_surface": ranked,
|
|
# Filled in by the offensive-osint skill's deeper probes (see docs/recon-manifest.md):
|
|
"secrets": [], # {pattern, severity, category, source} ← secret_scan.py
|
|
"identity_fabric": {}, # {idp, tenant, domains, ...} ← identity-fabric probes
|
|
}
|
|
|
|
|
|
# ============================================================
|
|
# classify — pattern-match URL against skill descriptions + reports
|
|
# ============================================================
|
|
SKILL_DESC_CACHE: dict[str, str] = {}
|
|
|
|
|
|
def load_skill_descriptions() -> dict[str, str]:
|
|
"""Read the `description:` frontmatter of each SKILL.md."""
|
|
if SKILL_DESC_CACHE:
|
|
return SKILL_DESC_CACHE
|
|
if not CLONE_MODE:
|
|
# Installed mode: read descriptions from the bundled index.
|
|
SKILL_DESC_CACHE.update(_bundled_index().get("skills", {}))
|
|
return SKILL_DESC_CACHE
|
|
for skill_dir in SKILLS_DIR.iterdir():
|
|
if not skill_dir.is_dir():
|
|
continue
|
|
sm = skill_dir / "SKILL.md"
|
|
if not sm.exists():
|
|
continue
|
|
try:
|
|
text = sm.read_text(encoding="utf-8")
|
|
except Exception:
|
|
continue
|
|
m = re.search(r"^description:\s*(.+?)(?=\n[a-z_]+:|^---|\Z)",
|
|
text, re.M | re.S)
|
|
if m:
|
|
desc = m.group(1).strip().strip('"').strip("'").strip()
|
|
SKILL_DESC_CACHE[skill_dir.name] = desc[:2000]
|
|
return SKILL_DESC_CACHE
|
|
|
|
|
|
# A small, hand-curated trigger map. Augments the description-matcher
|
|
# with high-confidence URL-pattern → skill associations.
|
|
URL_PATTERN_TO_SKILLS = [
|
|
(r"[?&](url|next|redirect|return|callback|target|destination|continue)=", ["hunt-ssrf"]),
|
|
(r"[?&](id|user|userid|user_id|uid|pid|post|order|invoice|account)=\d", ["hunt-idor"]),
|
|
(r"/(api|rest|v[0-9])/", ["hunt-api-misconfig", "hunt-idor"]),
|
|
(r"/graphql", ["hunt-graphql"]),
|
|
(r"/(login|signin|signup|register|forgot|reset)", ["hunt-auth-bypass", "hunt-ato"]),
|
|
(r"/oauth/(authorize|token|callback)", ["hunt-oauth"]),
|
|
(r"/saml/(acs|sso|metadata)", ["hunt-saml"]),
|
|
(r"/_layouts/15/|/_vti_bin/|/_api/(web|contextinfo)", ["hunt-sharepoint"]),
|
|
(r"/(file|upload|attachment|avatar|document|media)", ["hunt-file-upload"]),
|
|
(r"/search\?", ["hunt-xss", "hunt-sqli"]),
|
|
(r"[?&]q=|[?&]query=|[?&]s=", ["hunt-xss"]),
|
|
(r"\.(php|aspx?|cgi|jsp)", ["hunt-rce", "hunt-aspnet"]),
|
|
(r"/(admin|management|debug|test|staging|dev|internal)", ["hunt-auth-bypass"]),
|
|
(r"/jenkins|jnlpJars|/cli", ["hunt-rce"]), # CVE-2024-23897
|
|
(r"/functionRouter|/uppercase|/lowercase", ["hunt-rce", "hunt-ssti"]), # Spring Cloud Function
|
|
(r"/(2fa|mfa|otp|verify)", ["hunt-mfa-bypass"]),
|
|
(r"/(coupon|promo|cart|checkout)", ["hunt-business-logic", "hunt-race-condition"]),
|
|
(r"/(webhook|callback/event)", ["hunt-business-logic"]),
|
|
(r"/parse-xml|/import-xml|\.xml", ["hunt-xxe"]),
|
|
]
|
|
|
|
|
|
def classify_url(url: str) -> dict:
|
|
"""Return matched skills with rationale + pointers to disclosed-reports library."""
|
|
skills = load_skill_descriptions()
|
|
matches: dict[str, list[str]] = {}
|
|
|
|
parsed = urllib.parse.urlparse(url)
|
|
raw = url
|
|
|
|
# Pattern triggers
|
|
for pattern, skill_names in URL_PATTERN_TO_SKILLS:
|
|
if re.search(pattern, raw, re.I):
|
|
for s in skill_names:
|
|
matches.setdefault(s, []).append(f"URL matches /{pattern}/")
|
|
|
|
# Description keyword match (lighter signal)
|
|
keywords = re.findall(r"[a-z]{4,}", raw.lower())
|
|
for skill, desc in skills.items():
|
|
# Look for the same keywords in the description
|
|
if skill in matches:
|
|
continue
|
|
score = 0
|
|
hits = []
|
|
for kw in set(keywords):
|
|
if re.search(rf"\b{re.escape(kw)}\b", desc.lower()):
|
|
score += 1
|
|
hits.append(kw)
|
|
if score >= 2:
|
|
break
|
|
if score >= 2:
|
|
matches[skill] = [f"description keywords: {hits}"]
|
|
|
|
return {
|
|
"url": url,
|
|
"matches": matches,
|
|
"available_reports": ([p.name for p in REPORTS_DIR.glob("hunt-*.md")]
|
|
if REPORTS_DIR.exists() else _bundled_index().get("reports", [])),
|
|
}
|
|
|
|
|
|
def cmd_classify(args: argparse.Namespace) -> int:
|
|
result = classify_url(args.url)
|
|
section(f"classify — {args.url}")
|
|
if getattr(args, "burp", False) or getattr(args, "proxy", None):
|
|
proxy_url = args.proxy if getattr(args, "proxy", None) else (detect_burp() or "http://127.0.0.1:8080")
|
|
say(color(f" Burp proxy ready at {proxy_url} — pipe candidate requests through it.", "yellow"))
|
|
say()
|
|
if not result["matches"]:
|
|
say(" No high-confidence matches. Try:")
|
|
say(f" {color('cbh recon <target>', 'bold')} to map attack surface first.")
|
|
return 1
|
|
say()
|
|
for skill, reasons in sorted(result["matches"].items()):
|
|
say(color(f" → {skill}", "green"))
|
|
for r in reasons:
|
|
say(f" • {r}")
|
|
report_name = f"{skill}.md"
|
|
local_report = REPORTS_DIR / report_name
|
|
if local_report.exists():
|
|
say(f" 📖 Pattern Library: {color(str(local_report), 'cyan')}")
|
|
elif report_name in _bundled_index().get("reports", []):
|
|
say(f" 📖 Pattern Library: {color(f'{REPO_URL}/blob/main/docs/disclosed-reports/{report_name}', 'cyan')}")
|
|
skill_path = SKILLS_DIR / skill / "SKILL.md"
|
|
if skill_path.exists():
|
|
say(f" 📋 Skill: {color(str(skill_path), 'cyan')}")
|
|
elif not CLONE_MODE:
|
|
say(f" 📋 Skill: {color(f'{REPO_URL}/blob/main/skills/{skill}/SKILL.md', 'cyan')}")
|
|
say()
|
|
say(color("Next moves:", "bold"))
|
|
say(f" 1. Read the Pattern Library docs for each matched skill")
|
|
say(f" 2. Pick the highest-confidence attack from the Pattern Library")
|
|
say(f" 3. Apply OOB-Or-It-Didn't-Happen Gate before claiming success")
|
|
say(f" 4. {color('cbh triage <finding.md>', 'bold')} once you have a candidate finding")
|
|
say()
|
|
say(color("Need richer context? In a Claude Code conversation:", "dim"))
|
|
say(color(f" /hunt <url> — full hunt-dispatch routing with LLM judgment", "dim"))
|
|
say(color(f" /chain — build A→B→C exploit chains", "dim"))
|
|
return 0
|
|
|
|
|
|
# ============================================================
|
|
# triage — run the 7-Question Gate against a finding markdown
|
|
# ============================================================
|
|
TRIAGE_QUESTIONS = [
|
|
("Q1", "Can an attacker use this RIGHT NOW with a real HTTP request?",
|
|
["curl ", "POST ", "GET ", "HTTP/1.1", "PUT ", "DELETE ", "PATCH "]),
|
|
("Q2", "Is the impact on the program's accepted-impact list?",
|
|
["impact:", "severity:", "p1", "p2", "p3", "p4", "critical", "high", "medium", "low"]),
|
|
("Q3", "Is the asset in scope?",
|
|
["scope", "in-scope", "in scope", "target:", "asset:"]),
|
|
("Q4", "Does it work without privileged access an attacker can't get?",
|
|
["attacker", "unauthenticated", "user-role", "low-priv", "any user", "session"]),
|
|
("Q5", "Is this not already known or documented behavior?",
|
|
["disclosed-reports", "h1 hacktivity", "not duplicate", "novel", "first reported", "previously unknown", "previously"]),
|
|
("Q6", "Can impact be proved beyond 'technically possible'?",
|
|
["leaked", "exfiltrated", "rce", "data:", "credential", "session-id", "cookie:",
|
|
"admin email", "production", "oob callback", "interactsh"]),
|
|
("Q7", "Is this not on the never-submit list?",
|
|
["self-xss", "rate-limit only", "click-jacking", "csrf on logout", "missing security headers"]),
|
|
]
|
|
|
|
|
|
def cmd_triage(args: argparse.Namespace) -> int:
|
|
finding_path = Path(args.finding)
|
|
if not finding_path.exists():
|
|
say(color(f" finding not found: {finding_path}", "red"))
|
|
return 2
|
|
text = finding_path.read_text(encoding="utf-8").lower()
|
|
|
|
section(f"triage — {finding_path.name}")
|
|
say(color("Running 7-Question Gate (triage-validation skill)", "cyan"))
|
|
say()
|
|
|
|
answers = []
|
|
for qid, question, signals in TRIAGE_QUESTIONS:
|
|
# Q7 is INVERTED: presence of a "never-submit" signal = answer is "NO" (kill)
|
|
hit = any(s in text for s in signals)
|
|
if qid == "Q7":
|
|
answer = "NO — finding matches never-submit category" if hit else "YES"
|
|
ok = not hit
|
|
else:
|
|
answer = "YES — evidence found" if hit else "NO — no supporting evidence in finding"
|
|
ok = hit
|
|
answers.append((qid, question, answer, ok))
|
|
marker = color("✓", "green") if ok else color("✗", "red")
|
|
say(f" {marker} {color(qid, 'bold')}: {question}")
|
|
say(f" → {answer}")
|
|
say()
|
|
|
|
fail_qs = [q[0] for q in answers if not q[3]]
|
|
say(color("=" * 70, "blue"))
|
|
if not fail_qs:
|
|
say(color("VERDICT: PASS — all 7 questions answered with evidence.", "green"))
|
|
say(color("Eligible for report drafting via `cbh report <finding.md>`.", "dim"))
|
|
return 0
|
|
if len(fail_qs) == 1 and fail_qs[0] in ("Q2", "Q5"):
|
|
say(color(f"VERDICT: DOWNGRADE — failed {fail_qs[0]} (severity / duplication concerns).", "yellow"))
|
|
say(color("Continue but tone down the severity claim.", "dim"))
|
|
return 1
|
|
say(color(f"VERDICT: KILL — failed {','.join(fail_qs)}.", "red"))
|
|
say(color("Per triage-validation discipline: do not draft the report.", "dim"))
|
|
say(color("Address the failing question(s) or move on.", "dim"))
|
|
return 2
|
|
|
|
|
|
# ============================================================
|
|
# report — emit a report draft based on finding metadata
|
|
# ============================================================
|
|
def parse_finding_metadata(text: str) -> dict:
|
|
"""Best-effort parse of YAML-ish frontmatter + section content."""
|
|
md = {"title": "", "severity": "Medium", "asset": "", "endpoint": "",
|
|
"summary": "", "steps": "", "impact": "", "remediation": ""}
|
|
# YAML frontmatter
|
|
m = re.match(r"^---\n(.*?)\n---\n(.*)", text, re.S)
|
|
body = text
|
|
if m:
|
|
for line in m.group(1).splitlines():
|
|
if ":" in line:
|
|
k, _, v = line.partition(":")
|
|
md[k.strip().lower()] = v.strip().strip('"').strip("'")
|
|
body = m.group(2)
|
|
# Section grabbers
|
|
for key, pat in [
|
|
("summary", r"##\s*(?:summary|description)\s*\n(.+?)(?=\n##|\Z)"),
|
|
("steps", r"##\s*(?:steps|reproduction|reproduce|poc)\s*\n(.+?)(?=\n##|\Z)"),
|
|
("impact", r"##\s*impact\s*\n(.+?)(?=\n##|\Z)"),
|
|
("remediation", r"##\s*(?:remediation|fix|mitigation)\s*\n(.+?)(?=\n##|\Z)"),
|
|
]:
|
|
m = re.search(pat, body, re.I | re.S)
|
|
if m and not md.get(key):
|
|
md[key] = m.group(1).strip()
|
|
# First line as title if missing
|
|
if not md["title"]:
|
|
for line in body.splitlines():
|
|
line = line.strip()
|
|
if line.startswith("# "):
|
|
md["title"] = line[2:].strip()
|
|
break
|
|
return md
|
|
|
|
|
|
def render_report(md: dict, platform: str) -> str:
|
|
"""Render a report draft using the report-writing + bugcrowd-reporting style.
|
|
Built with plain string concatenation to avoid textwrap.dedent issues with
|
|
multi-line interpolated content."""
|
|
title = md.get("title") or "Untitled finding"
|
|
severity = md.get("severity") or "Medium"
|
|
summary = md.get("summary") or "(fill in)"
|
|
steps = md.get("steps") or "(fill in — curl commands per step)"
|
|
impact = md.get("impact") or "(fill in — concrete dollar / PII / state impact)"
|
|
remediation = md.get("remediation") or "(fill in)"
|
|
asset = md.get("asset") or md.get("endpoint") or "(fill in)"
|
|
user = os.environ.get('USER', 'researcher')
|
|
today = datetime.date.today().isoformat()
|
|
|
|
if platform == "bugcrowd":
|
|
return f"""# {title}
|
|
|
|
**Bug type (VRT):** _to be filled in — pick the closest match from VRT 1.x and include the manual override paragraph below if defaults underrate impact._
|
|
**Severity:** {severity}
|
|
**Asset:** {asset}
|
|
**Date:** {today}
|
|
|
|
## Severity request
|
|
|
|
The closest VRT category for this finding is _<VRT-path>_, which Bugcrowd defaults to **<default-severity>**. **I am requesting evaluation at {severity}** for the following reasons:
|
|
|
|
1. _<concrete impact reason>_
|
|
2. _<exploit complexity reason>_
|
|
3. _<chained-finding cross-reference if applicable>_
|
|
|
|
## Summary
|
|
{summary}
|
|
|
|
## Steps to reproduce
|
|
{steps}
|
|
|
|
## Impact
|
|
{impact}
|
|
|
|
## Suggested remediation
|
|
{remediation}
|
|
|
|
## Researcher account
|
|
- Bugcrowd handle: _<your-handle>_
|
|
- Test account email: _<your-alias>@bugcrowdninja.com_
|
|
"""
|
|
|
|
if platform == "immunefi":
|
|
slug = re.sub(r"[^a-z0-9]+", "_", title.lower())
|
|
return f"""# {title}
|
|
|
|
**Severity:** {severity}
|
|
**Chain ID / Contract:** {asset}
|
|
**Date:** {today}
|
|
|
|
## Summary
|
|
{summary}
|
|
|
|
## Vulnerability Details
|
|
{impact}
|
|
|
|
## Steps to reproduce (Foundry PoC required)
|
|
```bash
|
|
forge test --match-test test_{slug} -vvv
|
|
```
|
|
{steps}
|
|
|
|
## Proof of Concept
|
|
_Attach the Foundry test file producing the exploit._
|
|
|
|
## Suggested remediation
|
|
{remediation}
|
|
"""
|
|
|
|
common = f"""# {title}
|
|
|
|
**Severity:** {severity}
|
|
**Asset:** {asset}
|
|
**Reporter:** {user}
|
|
**Date:** {today}
|
|
|
|
## Summary
|
|
{summary}
|
|
|
|
## Steps to reproduce
|
|
{steps}
|
|
|
|
## Impact
|
|
{impact}
|
|
|
|
## Suggested remediation
|
|
{remediation}
|
|
"""
|
|
|
|
if platform == "intigriti":
|
|
return common + "\n## CVSS 3.1 vector\n`AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N` (fill in)\n"
|
|
|
|
# Default: H1
|
|
return common
|
|
|
|
|
|
def cmd_report(args: argparse.Namespace) -> int:
|
|
finding_path = Path(args.finding)
|
|
if not finding_path.exists():
|
|
say(color(f" finding not found: {finding_path}", "red"))
|
|
return 2
|
|
md = parse_finding_metadata(finding_path.read_text(encoding="utf-8"))
|
|
draft = render_report(md, args.platform)
|
|
if args.out:
|
|
out_path = Path(args.out).resolve()
|
|
cwd = Path.cwd().resolve()
|
|
# Ancestry check, not str.startswith (which a `<cwd>-evil` sibling bypasses).
|
|
if cwd not in out_path.parents:
|
|
print("[error] --out path must be within cwd", file=sys.stderr); return 1
|
|
out_path.write_text(draft)
|
|
section(f"report — {args.platform}")
|
|
say(f" Draft written: {color(str(out_path), 'bold')}")
|
|
say()
|
|
say(color("Next:", "bold"))
|
|
say(" • Review the draft — every (fill in) needs operator content")
|
|
say(" • Apply evidence-hygiene before attaching screenshots")
|
|
say(f" • Submit on {args.platform}")
|
|
else:
|
|
print(draft)
|
|
return 0
|
|
|
|
|
|
# ============================================================
|
|
# surface — consume a recon manifest, print the ranked surface
|
|
# ============================================================
|
|
def cmd_surface(args: argparse.Namespace) -> int:
|
|
target = args.target
|
|
mpath = Path(args.manifest) if getattr(args, "manifest", None) else None
|
|
if not mpath:
|
|
for base in (REPO_ROOT / "recon", Path.cwd() / "recon"):
|
|
cand = base / target / "manifest.json"
|
|
if cand.exists():
|
|
mpath = cand
|
|
break
|
|
if not mpath or not mpath.exists():
|
|
print(f"[error] no recon manifest for {target}. Run: cbh recon {target}", file=sys.stderr)
|
|
return 1
|
|
try:
|
|
m = json.loads(mpath.read_text(encoding="utf-8"))
|
|
except Exception as e:
|
|
print(f"[error] could not parse {mpath}: {e}", file=sys.stderr)
|
|
return 1
|
|
|
|
section(f"surface — {m.get('target', target)}")
|
|
c = m.get("counts", {})
|
|
say(f" {c.get('live', '?')} live / {c.get('resolved', '?')} resolved / "
|
|
f"{c.get('subdomains', '?')} subdomains · {mpath}")
|
|
ranked = m.get("ranked_surface", [])
|
|
colors = {"P1": "green", "P2": "yellow", "KILL": "dim"}
|
|
for prio, label in (("P1", "Priority 1 — start here"),
|
|
("P2", "Priority 2 — after P1"),
|
|
("KILL", "Kill list — skip")):
|
|
items = [r for r in ranked if r.get("priority") == prio]
|
|
if not items:
|
|
continue
|
|
say()
|
|
say(color(label, "bold"))
|
|
for r in items:
|
|
classes = ", ".join(r.get("bug_classes", [])) or color("no class match", "dim")
|
|
say(f" {color(r.get('url') or r.get('host'), colors.get(prio, 'cyan'))}")
|
|
say(f" {classes} · {r.get('rationale', '')}")
|
|
secrets = m.get("secrets", [])
|
|
idf = m.get("identity_fabric", {})
|
|
if secrets:
|
|
say()
|
|
say(color(f" ⚠ {len(secrets)} secret(s) flagged in manifest", "red"))
|
|
if idf:
|
|
say(color(f" identity fabric: {', '.join(idf.keys())}", "cyan"))
|
|
say()
|
|
say(f" Next: {color('/hunt ' + target, 'bold')} in Claude Code, "
|
|
f"or {color('cbh classify <url>', 'bold')} for a single URL")
|
|
return 0
|
|
|
|
|
|
# ============================================================
|
|
# Main dispatcher
|
|
# ============================================================
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(
|
|
prog="cbh",
|
|
description=("claude-bughunter CLI — terminal-native deterministic runner.\n"
|
|
"SECONDARY interface; slash commands (/hunt, /recon, /triage, /report) "
|
|
"in Claude Code are PRIMARY. Use cbh for CI/CD, scripted runs, "
|
|
"deterministic verification, or when not in a Claude Code session."),
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog=textwrap.dedent("""\
|
|
Examples:
|
|
cbh recon hackerone.com
|
|
cbh recon target.com --burp # route via Burp proxy
|
|
cbh classify "https://api.target.com/v1/users/42?next=https://evil.com"
|
|
cbh triage findings/idor-2026-05-15.md
|
|
cbh report findings/idor-2026-05-15.md --platform bugcrowd --out draft.md
|
|
|
|
For LLM-driven hunting with full skill context, use the slash commands
|
|
inside Claude Code: /hunt /recon /triage /report /validate /chain /autopilot
|
|
See docs/cbh-cli.md for the "when to use which" matrix.
|
|
"""),
|
|
)
|
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
|
|
def _add_proxy_args(p):
|
|
p.add_argument("--burp", action="store_true",
|
|
help="Route HTTP through Burp Suite proxy (auto-detects 127.0.0.1:8080)")
|
|
p.add_argument("--proxy", help="Explicit proxy URL (overrides --burp)")
|
|
|
|
p_recon = sub.add_parser("recon", help="passive recon + live-host probe + summary")
|
|
p_recon.add_argument("target", help="root domain, e.g. hackerone.com")
|
|
p_recon.add_argument("--out", help="output directory for recon/<target>/ "
|
|
"(default: repo recon/ in a clone, ./recon/ when installed)")
|
|
_add_proxy_args(p_recon)
|
|
p_recon.set_defaults(func=cmd_recon)
|
|
|
|
p_surface = sub.add_parser("surface", help="rank a target's attack surface from its recon manifest")
|
|
p_surface.add_argument("target", help="target whose recon/<target>/manifest.json to read")
|
|
p_surface.add_argument("--manifest", help="explicit path to a manifest.json")
|
|
p_surface.set_defaults(func=cmd_surface)
|
|
|
|
p_class = sub.add_parser("classify", help="pattern-match URL to hunt-* skills")
|
|
p_class.add_argument("url", help="single URL to classify")
|
|
_add_proxy_args(p_class)
|
|
p_class.set_defaults(func=cmd_classify)
|
|
|
|
p_triage = sub.add_parser("triage", help="run 7-Question Gate on a finding")
|
|
p_triage.add_argument("finding", help="path to finding markdown file")
|
|
p_triage.set_defaults(func=cmd_triage)
|
|
|
|
p_report = sub.add_parser("report", help="emit a report draft")
|
|
p_report.add_argument("finding", help="path to finding markdown file")
|
|
p_report.add_argument("--platform", default="h1",
|
|
choices=["h1", "bugcrowd", "intigriti", "immunefi"])
|
|
p_report.add_argument("--out", help="write draft to this path (else print to stdout)")
|
|
p_report.set_defaults(func=cmd_report)
|
|
|
|
args = parser.parse_args()
|
|
return args.func(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|