mirror of
https://github.com/elementalsouls/Claude-BugHunter.git
synced 2026-11-07 10:36:04 -10:00
DX hardening, cbh packaging, and Claude-OSINT integration (#43)
* fix(dx): harden install, package cbh as pipx-installable, fix broken commands Addresses 6 P1 defects from a DX review (solo bug-bounty hunter persona): - install: .gitattributes (eol=lf) + CRLF self-heal in install.sh; INSTALL uses bash form - cli: package cbh as a real pip/pipx console-script (pyproject.toml + cbh/ package + bundled cbh/data/skill_index.json fallback + scripts/gen_skill_index.py); scripts/cbh.py kept as a clone shim; recon writes to ./recon when installed - commands: add /scope backed by a new deterministic engine/scope.py CLI (was called mandatory in /hunt but missing); reframe /token-scan, /intel, /surface so the skill path is primary and missing helper scripts are clearly optional - docs: install capability matrix (plugin vs clone vs pipx); reconcile the authorization story in /hunt (don't ingest the SOW document != skip scope verification) Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> * feat(skills): consolidate recon skills as canonical (integration Phase 1) BugHunter becomes the source of truth for the two recon skills shared with Claude-OSINT (which now re-exports them). - offensive-osint: port h1_reference.py (HackerOne hacktivity agent) + dashboard.py (local recon console) + assets/ font from Claude-OSINT (CRLF->LF); documented in references/helpers-and-automation.md sec 49-50 + SKILL.md router. references/ split kept; OSINT's recent change was dashboard-only so no SKILL.md content delta. - osint-methodology: keep BugHunter's expanded version (1703 vs 455 lines, supersedes OSINT's); bump version 2.1 -> 2.3 to pass OSINT's 2.2. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> * fix(dx): correct CRLF docs + scope.py footguns (post-verification) A 20-agent adversarial verification found the install.sh CRLF self-heal was non-functional (bash parse-aborts on the CRLF case statement before the guard runs) and the docs over-promised it; plus two fail-safe scope.py footguns. - install.sh: remove the dead self-heal block (.gitattributes is the real fix); INSTALL.md now tells the truth (renormalize an existing CRLF checkout manually) - engine/scope.py: keep bullets under nested ### sub-headings (was dropping in-scope assets under default-deny); --in-scope/--out-of-scope are now repeatable flags so a target placed after them is no longer swallowed - commands/scope.md: inline example uses repeated --in-scope flags - .gitignore: anchor scope.md -> /scope.md so it no longer shadows commands/scope.md Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> * feat(skills): add triggers frontmatter to offensive-osint offensive-osint lacked the `triggers:` field that osint-methodology already has and that the sibling Claude-OSINT repo's frontmatter lint requires (>=5). Added the 127-entry recon trigger list so the skill is internally consistent and Claude-OSINT can re-export it without CI failure. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> * feat(install): install manifest + safe --uninstall + skip-identical (Phase 3) Coexistence with the sister bundle Claude-OSINT: each install now records a manifest of its footprint so uninstall removes only its own files and keeps any skill the other bundle still owns (the two shared recon skills). - install.sh: write ~/.claude/.skill-manifests/claude-bughunter.txt; --uninstall removes tracked skills/commands/hunt.sh + rc line but keeps shared-owned skills; skip re-copying a skill/command already present and identical (no last-installer-wins clobber) - INSTALL.md: replace the unsafe rm -rf ~/.claude/skills advice with --uninstall - README: document safe coexistence with Claude-OSINT Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> * feat(cbh): recon->hunt manifest contract (integration Phase 4) The data handoff between recon and hunt: cbh recon emits recon/<target>/manifest.json, which cbh surface and the hunt scaffold consume. Turns the conceptual 'OSINT recons, BugHunter hunts' into a real, tested contract. - cbh recon: emit manifest.json (schema 1.0) -- assets + ranked_surface (P1/P2/Kill via host heuristic + classify) + counts; secrets[]/identity_fabric{} left for the offensive-osint skill's deeper probes to append (shared-container design) - cbh surface <target>: new subcommand -- read the manifest, print the ranked surface - hunt.sh: ingest a manifest (2nd arg / $HUNT_MANIFEST / ./recon/<target>/) to seed scope.md (live hosts) + notes.md (ranked surface) - docs/recon-manifest.md + recon-manifest.schema.json: the contract spec - docs/cbh-cli.md, commands/surface.md: document the surface subcommand + manifest Round-trip verified end-to-end (synthetic + a real 'cbh recon example.com'). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> * feat(dx): P2 backlog — --no-shell flag, supply-chain trust section, feedback template - install.sh: add --no-shell (don't touch shell rc; print the source line to add); the default path now also echoes the exact line it appended (T2) - SECURITY.md: 'Verifying what you install' supply-chain trust section — leans on the existing skill-lint CI, with an explicit 'what this does NOT prove' disclaimer and pin-to-reviewed-commit guidance, no malware-scanner theater (T13) - add .github/ISSUE_TEMPLATE/dx_feedback.yml — low-friction 'skill didn't trigger / first-hunt' DX feedback form (T14) Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> --------- Co-authored-by: Brian Richter <[email protected]> Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
1 parent
517eef0090
commit
279d0060ba
30 files changed
+3216
-804
No files matched your search
@@ -0,0 +1,20 @@
|
||||
# Normalize line endings so shell/Python entrypoints stay LF on every platform.
|
||||
# Without this, a Windows checkout (incl. WSL on a Windows-mounted drive) can
|
||||
# land CRLF in the scripts and break the installer with `env: 'bash\r': ...`.
|
||||
* text=auto eol=lf
|
||||
|
||||
# Executable text MUST be LF regardless of platform.
|
||||
*.sh text eol=lf
|
||||
*.py text eol=lf
|
||||
*.bash text eol=lf
|
||||
|
||||
# Treat known binary assets as binary (no eol munging).
|
||||
*.svg binary
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.cast binary
|
||||
*.woff2 binary
|
||||
*.woff binary
|
||||
@@ -0,0 +1,41 @@
|
||||
name: 💬 DX feedback (skill didn't trigger / first-hunt experience)
|
||||
description: A skill didn't load when you expected it, install/onboarding tripped you up, or you want to share how your first hunt went.
|
||||
title: "[dx] <short summary>"
|
||||
labels: ["dx-feedback"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Quick, low-friction feedback on the developer experience — onboarding, install, or skill triggering. Helps us tune description keywords and the getting-started flow. **Anonymize all target data.**
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: What's this about?
|
||||
options:
|
||||
- A skill didn't trigger when I expected it to
|
||||
- Install / onboarding friction
|
||||
- First-hunt experience (good or bad)
|
||||
- cbh CLI behavior
|
||||
- Other DX feedback
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: For a non-triggering skill, paste the prompt you used and which skill you expected to load. For install/onboarding, what step and what you saw.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: What did you expect instead?
|
||||
validations:
|
||||
required: false
|
||||
- type: input
|
||||
id: env
|
||||
attributes:
|
||||
label: Environment (optional)
|
||||
description: Harness (Claude Code / OpenCode / Codex / Hermes), OS, install path (plugin vs clone), cbh version.
|
||||
validations:
|
||||
required: false
|
||||
+5
-1
@@ -28,7 +28,7 @@ evidence/
|
||||
findings/
|
||||
submissions.txt
|
||||
notes.md
|
||||
scope.md
|
||||
/scope.md
|
||||
CLAUDE.md.local
|
||||
targets/
|
||||
|
||||
@@ -48,6 +48,10 @@ __pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
venv/
|
||||
# packaging build output (cbh is pip/pipx installable)
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
|
||||
# Node (if anyone adds tooling later)
|
||||
node_modules/
|
||||
|
||||
+22
-20
@@ -37,10 +37,15 @@ cd Claude-BugHunter
|
||||
## Step 2 — Run the installer
|
||||
|
||||
```bash
|
||||
chmod +x scripts/install.sh
|
||||
./scripts/install.sh
|
||||
bash scripts/install.sh
|
||||
```
|
||||
|
||||
> On Windows/WSL, the repo ships a `.gitattributes` that forces LF line endings, so a
|
||||
> **fresh clone installs cleanly**. If you have an **existing** checkout that already
|
||||
> picked up CRLF (cloned before this `.gitattributes`), normalize it once with
|
||||
> `git add --renormalize . && git checkout .` (or just re-clone) — a CRLF-corrupted
|
||||
> shell script aborts with a `syntax error` and cannot fix itself.
|
||||
|
||||
This copies:
|
||||
- All 71 skills → `~/.claude/skills/`
|
||||
- All 15 slash commands → `~/.claude/commands/`
|
||||
@@ -192,24 +197,21 @@ Then go find a real program and put it to work. See [USAGE.md](USAGE.md) for the
|
||||
|
||||
## Uninstall
|
||||
|
||||
To remove everything this repo installed:
|
||||
The installer writes a manifest of exactly what it placed (under
|
||||
`~/.claude/.skill-manifests/claude-bughunter.txt`). Remove that footprint — and
|
||||
**only** that footprint — with:
|
||||
|
||||
```bash
|
||||
# Remove all bundled skills (this removes EVERY skill in ~/.claude/skills,
|
||||
# including any you added manually — be selective if needed)
|
||||
# rm -rf ~/.claude/skills
|
||||
|
||||
# Or remove only the originals contributed by this repo:
|
||||
rm -rf ~/.claude/skills/bugcrowd-reporting
|
||||
rm -rf ~/.claude/skills/evidence-hygiene
|
||||
|
||||
# Remove all bundled commands
|
||||
# rm -rf ~/.claude/commands
|
||||
|
||||
# Remove the hunt shell command
|
||||
rm -f ~/.claude/scripts/hunt.sh
|
||||
sed -i.bak '/claude\/scripts\/hunt.sh/d' ~/.zshrc
|
||||
|
||||
# Remove Burp MCP entry
|
||||
claude mcp remove burp
|
||||
bash scripts/install.sh --uninstall
|
||||
```
|
||||
|
||||
This removes the bundle's skills, slash commands, the `hunt.sh` script, and its
|
||||
shell-rc source line. Skills you also installed from the **sister bundle**
|
||||
[Claude-OSINT](https://github.com/elementalsouls/Claude-OSINT) — `offensive-osint`
|
||||
and `osint-methodology` — are **kept** if Claude-OSINT's manifest still claims them,
|
||||
so uninstalling one bundle never breaks the other. (Install backups under
|
||||
`~/.claude/install-backups/` are left in place; delete them manually if you want.)
|
||||
|
||||
If you installed via the **plugin** instead of the script: `/plugin uninstall claude-bughunter@elementalsouls`.
|
||||
|
||||
Burp MCP, if you wired it, is removed separately: `claude mcp remove burp`.
|
||||
@@ -53,6 +53,18 @@ cd Claude-BugHunter
|
||||
bash scripts/install.sh # copies skills + commands into ~/.claude/
|
||||
```
|
||||
|
||||
**What each install path gives you:**
|
||||
|
||||
| Path | 71 skills + 15 slash commands | `cbh` CLI | `hunt` scaffolder |
|
||||
|---|---|---|---|
|
||||
| **A — plugin** | ✅ namespaced under `claude-bughunter:` | ➕ separate `pipx install` | ❌ clone-only |
|
||||
| **B — copy install** | ✅ copied into `~/.claude/` | ✅ from the clone | ✅ from the clone |
|
||||
|
||||
The plugin is the fastest path to the skills + slash commands. The terminal-native
|
||||
`cbh` runner installs standalone — `pipx install git+https://github.com/elementalsouls/Claude-BugHunter`
|
||||
— so plugin users can add it without a full clone (see [`cbh` CLI](docs/cbh-cli.md)).
|
||||
The `hunt` engagement scaffolder ships with the clone (Option B).
|
||||
|
||||
That's it. Open Claude Code and describe what you're testing in plain English — the right skill loads automatically, no invocation by name:
|
||||
|
||||
```text
|
||||
@@ -249,7 +261,7 @@ Operational tradecraft accumulated across bug-bounty engagements and authorized
|
||||
|
||||
**Author:** [ElementalSoul](https://github.com/elementalsouls) · GenAI Security Research
|
||||
|
||||
**Sister project:** [Claude-OSINT](https://github.com/elementalsouls/Claude-OSINT) — paired skills for the recon phase that this bundle picks up after.
|
||||
**Sister project:** [Claude-OSINT](https://github.com/elementalsouls/Claude-OSINT) — paired skills for the recon phase that this bundle picks up after. Its two recon skills (`offensive-osint`, `osint-methodology`) are **canonically maintained here** and re-exported there, so the two are byte-identical. **Installing both is safe:** each bundle's `install.sh` records a manifest, the script skips re-copying an identical skill, and `--uninstall` keeps any skill the other bundle still owns — uninstalling one never breaks the other.
|
||||
|
||||
**Vendored foundation:** [shuvonsec/claude-bug-bounty](https://github.com/shuvonsec/claude-bug-bounty) — methodology, validation, reporting, payload library (8 of 71 skills + 15 slash commands)
|
||||
|
||||
|
||||
+18
@@ -48,6 +48,24 @@ The reasoning: internal-AD attacks against monitored corporate networks have a f
|
||||
|
||||
If you reach domain-admin-class objectives during an engagement, the bundle's external-perimeter chain ends at "credential discovered + access verified" — handoff to specialist internal-RT tooling (Impacket, NetExec, CrackMapExec, Rubeus, Certify, BloodHound) is intentionally outside the bundle's scope.
|
||||
|
||||
## Verifying what you install (supply-chain trust)
|
||||
|
||||
You are installing 71 `SKILL.md` files plus shell and Python helpers into your AI agent's context. Agent Skills are third-party code — treat them like any dependency you run. Independent research (Snyk "ToxicSkills", 2026) found prompt injection in a meaningful fraction of public skills, so verification matters.
|
||||
|
||||
**What we do on our side:**
|
||||
|
||||
- **CI gate on every skill change** — [`.github/workflows/skill-lint.yml`](.github/workflows/skill-lint.yml) runs `scripts/lint_skills.py` on each PR: it validates `SKILL.md` structure against `CONTRIBUTING.md` and scans for leaked secrets and client/engagement identifiers.
|
||||
- **Pinned, reviewable provenance** — installing via the **plugin marketplace** pins each release to a specific commit SHA and runs Anthropic's automated screening. From a clone you can pin yourself: `git checkout <tag-or-commit>` before `bash scripts/install.sh`.
|
||||
- **No network calls at install time** — `install.sh` only copies local files and (unless `--no-shell`) appends one `source` line to your shell rc, which it prints back to you.
|
||||
|
||||
**What this does NOT prove — read this:**
|
||||
|
||||
- The lint is a **structural + secrets/identifier** check. It is **not** a malware or prompt-injection detector and makes no claim to catch a malicious skill. A clean lint means "well-formed, no obvious leaked secret" — nothing more.
|
||||
- **Review before you trust.** The `SKILL.md` files and bundled scripts (`skills/*/scripts/`, `scripts/`) are plain text — skim them before installing, paying attention to anything that fetches from the network or executes shell.
|
||||
- **Pin to a reviewed tag/commit** rather than tracking `main`, so an upstream change can't alter what runs in your agent without your say-so.
|
||||
|
||||
If you find a skill or script in this repo that could be abused, see **Reporting a security issue** below.
|
||||
|
||||
## Reporting a security issue in this repo
|
||||
|
||||
If you discover a security issue in **this repository itself** (not in a target you're hunting):
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
"""claude-bughunter CLI package."""
|
||||
__version__ = "2.1.0"
|
||||
Executable
+925
@@ -0,0 +1,925 @@
|
||||
#!/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) -> 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.
|
||||
|
||||
Resolution order:
|
||||
1. explicit proxy_url argument
|
||||
2. CBH_BURP_PROXY env var
|
||||
3. HTTPS_PROXY / HTTP_PROXY env vars
|
||||
4. fallback: auto-detect default Burp on http://127.0.0.1:8080 (only if --burp flag)
|
||||
"""
|
||||
global _HTTP_OPENER
|
||||
if not proxy_url:
|
||||
proxy_url = os.environ.get("CBH_BURP_PROXY") or os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY")
|
||||
if not proxy_url:
|
||||
_HTTP_OPENER = None
|
||||
return False, "direct (no proxy)"
|
||||
|
||||
# Disable TLS verification when going through Burp (its CA isn't typically trusted)
|
||||
import ssl
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
# WARNING: TLS verification is disabled for Burp proxy traffic.
|
||||
# Use --proxy only in isolated lab environments — not on production targets.
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
print("[warning] TLS certificate verification is DISABLED — proxy mode active. "
|
||||
"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}"
|
||||
|
||||
|
||||
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:
|
||||
return set()
|
||||
try:
|
||||
rows = json.loads(body)
|
||||
except Exception:
|
||||
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
|
||||
if getattr(args, "proxy", None):
|
||||
proxy_url = args.proxy
|
||||
elif getattr(args, "burp", False):
|
||||
proxy_url = detect_burp() or "http://127.0.0.1:8080"
|
||||
configured, mode = configure_http_proxy(proxy_url)
|
||||
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())
|
||||
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"reports": [
|
||||
"hunt-brute-force.md",
|
||||
"hunt-business-logic.md",
|
||||
"hunt-cache-poison.md",
|
||||
"hunt-cors.md",
|
||||
"hunt-csrf.md",
|
||||
"hunt-deserialization.md",
|
||||
"hunt-file-upload.md",
|
||||
"hunt-graphql.md",
|
||||
"hunt-host-header.md",
|
||||
"hunt-http-smuggling.md",
|
||||
"hunt-idor.md",
|
||||
"hunt-ldap.md",
|
||||
"hunt-mfa-bypass.md",
|
||||
"hunt-nosqli.md",
|
||||
"hunt-oauth.md",
|
||||
"hunt-open-redirect.md",
|
||||
"hunt-rce.md",
|
||||
"hunt-saml.md",
|
||||
"hunt-session.md",
|
||||
"hunt-sqli.md",
|
||||
"hunt-ssrf.md",
|
||||
"hunt-ssti.md",
|
||||
"hunt-xss.md"
|
||||
],
|
||||
"skills": {
|
||||
"apk-redteam-pipeline": "End-to-end Android APK red-team pipeline \u2014 automated APK acquisition (Play Store + apkpure + apkmirror fallback), jadx decompilation, secret/URL/JWT/Firebase grep, pinned-cert extraction, exported-component enumeration, Frida runtime instrumentation templates, intent-injection probes. Built from an authorized external red-team engagement where 7 APKs were pulled manually, 4 download attempts truncated, and a hardcoded JWT + 30 internal API endpoints were recovered from one of the apps. Use when target has a mobile app catalogue (Play Store developer page), when you find an APK URL hosted on a web server, or when post-recon mentions \"mobile app\" in scope.",
|
||||
"bb-local-toolkit": "Complete bug bounty workflow \u2014 recon (subdomain enumeration, asset discovery, fingerprinting, HackerOne scope, source code audit), pre-hunt learning (disclosed reports, tech stack research, mind maps, threat modeling), vulnerability hunting (IDOR, SSRF, XSS, auth bypass, CSRF, race conditions, SQLi, XXE, file upload, business logic, GraphQL, HTTP smuggling, cache poisoning, OAuth, timing side-channels, OIDC, SSTI, subdomain takeover, cloud misconfig, ATO chains, agentic AI), LLM/AI security testing (chatbot IDOR, prompt injection, indirect injection, ASCII smuggling, exfil channels, RCE via code tools, system prompt extraction, ASI01-ASI10), A-to-B bug chaining (IDOR\u2192auth bypass, SSRF\u2192cloud metadata, XSS\u2192ATO, open redirect\u2192OAuth theft, S3\u2192bundle\u2192secret\u2192OAuth), bypass tables (SSRF IP bypass, open redirect bypass, file upload bypass), language-specific grep (JS prototype pollution, Python pickle, PHP type juggling, Go template.HTML, Ruby YAML.load, Rust unwrap), and reporting (7-Question Gate, 4 validation gates, human-tone writing, templates by vuln class, CVSS 3.1, PoC generation, always-rejected list, conditional chain table, submission checklist). Use for ANY bug bounty task \u2014 starting a new target, doing recon, hunting specific vulns, auditing source code, testing AI features, validating findings, or writing reports. \u4e2d\u6587\u89e6\u53d1\u8bcd\uff1a\u6f0f\u6d1e\u8d4f\u91d1\u3001\u5b89\u5168\u6d4b\u8bd5\u3001\u6e17\u900f\u6d4b\u8bd5\u3001\u6f0f\u6d1e\u6316\u6398\u3001\u4fe1\u606f\u6536\u96c6\u3001\u5b50\u57df\u540d\u679a\u4e3e\u3001XSS\u6d4b\u8bd5\u3001SQL\u6ce8\u5165\u3001SSRF\u3001\u5b89\u5168\u5ba1\u8ba1\u3001\u6f0f\u6d1e\u62a5\u544a",
|
||||
"bb-methodology": "Use at the START of any bug bounty hunting session, when switching targets, or when feeling lost about what to do next. Master orchestrator that combines the 5-phase non-linear hunting workflow with the critical thinking framework (developer psychology, anomaly detection, What-If experiments). Routes to all other skills based on current hunting phase. Also use when asking \"what should I do next\" or \"where am I in the process.",
|
||||
"bug-bounty": "Complete bug bounty workflow \u2014 recon (subdomain enumeration, asset discovery, fingerprinting, HackerOne scope, source code audit), pre-hunt learning (disclosed reports, tech stack research, mind maps, threat modeling), vulnerability hunting (IDOR, SSRF, XSS, auth bypass, CSRF, race conditions, SQLi, XXE, file upload, business logic, GraphQL, HTTP smuggling, cache poisoning, OAuth, timing side-channels, OIDC, SSTI, subdomain takeover, cloud misconfig, ATO chains, agentic AI), LLM/AI security testing (chatbot IDOR, prompt injection, indirect injection, ASCII smuggling, exfil channels, RCE via code tools, system prompt extraction, ASI01-ASI10), A-to-B bug chaining (IDOR\u2192auth bypass, SSRF\u2192cloud metadata, XSS\u2192ATO, open redirect\u2192OAuth theft, S3\u2192bundle\u2192secret\u2192OAuth), bypass tables (SSRF IP bypass, open redirect bypass, file upload bypass), language-specific grep (JS prototype pollution, Python pickle, PHP type juggling, Go template.HTML, Ruby YAML.load, Rust unwrap), and reporting (7-Question Gate, 4 validation gates, human-tone writing, templates by vuln class, CVSS 3.1, PoC generation, always-rejected list, conditional chain table, submission checklist). Use for ANY bug bounty task \u2014 starting a new target, doing recon, hunting specific vulns, auditing source code, testing AI features, validating findings, or writing reports. \u4e2d\u6587\u89e6\u53d1\u8bcd\uff1a\u6f0f\u6d1e\u8d4f\u91d1\u3001\u5b89\u5168\u6d4b\u8bd5\u3001\u6e17\u900f\u6d4b\u8bd5\u3001\u6f0f\u6d1e\u6316\u6398\u3001\u4fe1\u606f\u6536\u96c6\u3001\u5b50\u57df\u540d\u679a\u4e3e\u3001XSS\u6d4b\u8bd5\u3001SQL\u6ce8\u5165\u3001SSRF\u3001\u5b89\u5168\u5ba1\u8ba1\u3001\u6f0f\u6d1e\u62a5\u544a",
|
||||
"bugcrowd-reporting": "Bugcrowd-specific reporting tactics complementing report-writing: VRT category search-and-fallback strategy when no exact match exists, manual severity override when VRT defaults underrate impact, severity-request paragraph as first body section, OOS-clause rebuttal templates (rate limiting on auth-flow endpoints, debug-info framing, user-enumeration with sensitive PII, theoretical-issue counter), chained-finding cross-reference patterns, target selection for QA-vs-prod programs, researcher-side hygiene (Bugcrowdninja email alias, account state restoration, friendly-tester posture). Use when filing a Bugcrowd submission, when VRT default seems wrong, when triager closes as OOS or downgrades severity, when chaining linked submissions, or when scope distinguishes production from QA. Pairs with report-writing and triage-validation.",
|
||||
"cloud-iam-deep": "Cloud IAM red-team attack chain across AWS, Azure, GCP \u2014 focused on EXTERNAL exploitation paths and post-credential-discovery privilege analysis. Covers IAM enumeration (aws iam, az role, gcloud iam), STS/AssumeRole chaining, Azure Managed Identity abuse (via SSRF/leak), GCP service account JSON abuse, IMDSv1/v2 attacks via SSRF, K8s ServiceAccount token privilege analysis once held (token discovery / cluster exposure is owned by hunt-k8s), role-trust-policy confused-deputy, cross-account assume-role enumeration, IAM privilege escalation patterns (24+ AWS, 8+ Azure, 6+ GCP), and AWS Cognito Identity Pool unauthenticated-role attack chain (GetId \u2192 GetCredentialsForIdentity \u2192 IAM role abuse). Built for the case where recon yields a credential (key, JSON, token) and you need to know what it grants and how to escalate. Use when an AWS key / Azure secret / GCP service account JSON / K8s SA token surfaces from a code repo, JS bundle, APK, breach corpus, or SSRF chain.",
|
||||
"enterprise-vpn-attack": "External SSL VPN / remote-access appliance attack matrix \u2014 Cisco ASA/AnyConnect, Fortinet FortiGate/FortiOS, Citrix NetScaler/ADC, Palo Alto GlobalProtect, Pulse Secure / Ivanti Connect Secure, SonicWall, F5 Big-IP. Covers version fingerprinting, CVE matrix (2018-2026), AAA backend identification, default credentials, configuration-disclosure paths, pre-auth RCE/SSRF/path-traversal exploits where applicable. Built from authorized-engagement Cisco ASA testing plus 2024-2026 enterprise VPN CVE landscape. Use whenever the target's perimeter exposes any SSL VPN appliance or remote-access gateway \u2014 these are the most common initial-access points in 2024-2026 actor TTPs.",
|
||||
"evidence-hygiene": "Evidence-capture and PoC-redaction discipline for bug-bounty submissions: cookie redaction protocol (which fields to mask, Preview annotation / Burp panel hiding / DevTools workflow), PII black-bar discipline (what to mask in other-user data \u2014 names, emails, phones, faces \u2014 vs what is safe to leave \u2014 usernames, trace IDs, request bodies), HAR file sanitization (jq filters for Cookie/Set-Cookie/Authorization headers), Burp Repeater/Intruder screenshot hygiene (hide request body, show only Results table for rate-limit attacks), Chrome DevTools Console PoC patterns (credentials include so cookies are not echoed, labeled console.log), screenshot capture order, filename conventions, post-submission rotation hygiene. Use BEFORE any PoC screenshot, BEFORE attaching a HAR, or whenever preparing evidence with session cookies or other-user PII. Pairs with bugcrowd-reporting and report-writing.",
|
||||
"hunt-api-misconfig": "Hunt API security misconfiguration \u2014 mass assignment, JWT attacks, prototype pollution, HTTP verb tampering. Mass assignment: send {is_admin:true, role:admin, verified:true} on profile/account/reset endpoints \u2014 server blindly applies. JWT: alg=none, weak HMAC bruteforce, kid path traversal, JWK injection, token confusion. Prototype pollution: __proto__ injection in JSON merge / Object.assign / lodash _.merge \u2192 polluted prototype reaches sink (RCE in Node, XSS in browser). HTTP verb: GET-bypass-CSRF, X-HTTP-Method-Override, TRACE enabled. Detection: API responses with extra fields, JWTs in headers (decode at jwt.io). CORS misconfiguration (reflect-any-origin, null origin, subdomain-regex bypass, postMessage) is owned by hunt-cors. Use when hunting API misconfigs, JWT flaws, mass-assignment, prototype pollution.",
|
||||
"hunt-aspnet": "Hunt ASP.NET-specific surface \u2014 ViewState deserialization (signed-only vs encrypted), machineKey recovery, dual-parser MAC-bypass anti-pattern, request-validator bypass, trace.axd/elmah.axd disclosure, load-balanced ViewState cross-node failures, SafeControl enumeration via reflection, customErrors mode=Off stack-trace leaks, classic Webforms .aspx/.asmx/.svc surface. Built for ASP.NET Webforms + WCF + SharePoint farms.",
|
||||
"hunt-ato": "Hunt account takeover taxonomy \u2014 9 distinct paths to ATO, plus chains. Paths: (1) password reset flaws (host-header injection redirects token, predictable/numeric token, Referer leak, no-expiry/reuse), (2) email change without re-auth, (3) OAuth account-link CSRF, (4) MFA bypass (per hunt-mfa-bypass), (5) session fixation, (6) JWT manipulation (alg:none, RS256\u2192HS256 key confusion, weak HMAC secret, kid injection), (7) password change without step-up (chain with login timing/length oracle), (8) social-recovery / security-question brute-force, (9) SSO subdomain takeover at OAuth redirect_uri. Chains: cookie theft + password oracle + no step-up = persistent ATO; lax redirect_uri = auth-code theft; dangling-CNAME takeover at redirect_uri = ATO. Validate: demonstrate real takeover of test account B from attacker A's session; OOB/Collaborator confirm blind token-leak steps. Use when hunting ATO chains, testing password reset / email change / MFA / OAuth / session / JWT, or chaining primitives toward Critical.",
|
||||
"hunt-auth-bypass": "Hunting skill for auth bypass vulnerabilities. Built from 12 public bug bounty reports across SAML XSW / parser-differential (GitHub Enterprise CVE-2025-25291/25292), SAML signature stripping (Uber, Rocket.Chat, samlify CVE-2025-47949), SAML domain enforcement bypass via control characters (HackerOne 2024), partner-portal cross-IdP assertion reuse (Slack), WordPress XMLRPC bypassing SSO (Uber), JWT alg-confusion HS256/RS256 (Jitsi), JWT signature-validation skip (Linktree, Newspack), and token-audience confusion (Argo CD CVE-2023-22482). Use when hunting auth bypass \u2014 see the Legacy-Protocol Matrix for branded-UI vs legacy-endpoint patterns.",
|
||||
"hunt-brute-force": "Hunt Missing/Weak Rate Limiting \u2014 login brute force, OTP/2FA brute force (10^6 keyspace), password-reset-token brute, credential stuffing, username/email enumeration via error-string / status-code / timing differences, weak password policy, missing CAPTCHA, IP-based rate-limit bypass via X-Forwarded-For and friends, ReDoS. Distinguishes hard lockout vs soft IP-throttle vs CAPTCHA-injection vs silent shadow-throttling (avoids false-negative 'no rate limit' conclusions). Medium to Critical depending on what the brute reaches (OTP\u2192ATO = Critical).",
|
||||
"hunt-business-logic": "Hunting skill for business logic vulnerabilities. Built from 12 public bug bounty reports. Covers coupon-race-stacking (Instacart, Stripe, Reverb), negative-quantity-in-cart price tampering (Upserve, Eternal/Zomato), decimal/fraction price-field overflow (Shipt), client-side checkout amount trust on PayPal redirect (WordPress.org), price-per-unit mass-assignment (Krisp), and archived-price swap / cart-TOCTOU (Stripe). Use when hunting business logic \u2014 heavy emphasis on financial-impact-demonstrated cases.",
|
||||
"hunt-cache-poison": "Hunting skill for cache poison vulnerabilities. Built from 10 public bug bounty reports including X-Forwarded-Host poisoning, X-HTTP-Method-Override / GCS cache, reflected\u2192stored XSS via cache, classic Omer-Gil Web Cache Deception, Cloudflare Cache Deception Armor bypass, session-token cache deception, Akamai hop-by-hop smuggling \u2192 server-side edge poisoning, and Kettle's 2024 path-normalization WCD against Cloudflare/Fastly/GCP. Use when hunting cache poisoning, Web Cache Deception, CDN-fronted apps.",
|
||||
"hunt-cicd": "Hunt CI/CD pipeline vulnerabilities \u2014 GitHub Actions workflow injection (pull_request_target Pwnrequest + ${{ }}-into-shell), self-hosted runner poisoning, OIDC trust-policy abuse, Jenkins script-console RCE and CVE-2024-23897 file read, GitLab CI runner-token registration, Terraform state file leakage, artifact/log secret leakage, pipeline env-var disclosure. Use when target has a public GitHub/GitLab org, exposed CI dashboards (Jenkins/TeamCity/Drone/Argo), or build artifacts/images are reachable.",
|
||||
"hunt-cloud-misconfig": "Hunt cloud / infrastructure misconfigurations. AWS: public S3 buckets (s3:GetObject anonymous), permissive bucket policies (PutObjectAcl public-write), exposed CloudFront origin, public Lambda function URL, public RDS snapshot, IAM credentials in JS bundles, AWS metadata accessible via SSRF. GCP: public GCS buckets, exposed Cloud Run services, leaked service account JSON. Azure: public blob containers, exposed Function App. (Kubernetes/Docker exposure is owned by hunt-k8s; CI/CD pipeline attacks by hunt-cicd; post-credential IAM escalation by cloud-iam-deep.) Detection: targeted dorking, certificate transparency, JS bundle secret extraction, port scan for known service ports. Validate: actual data read / write / RCE. Use when hunting cloud-native storage and compute misconfig (S3/GCS/Blob, IMDS-via-SSRF, serverless, public managed services).",
|
||||
"hunt-cors": "Hunt CORS Misconfiguration \u2014 origin-reflection with credentials, null-origin trust, subdomain-regex bypass (unanchored vs unescaped-dot vs prefix-only), pre-flight (OPTIONS) gating bypass, postMessage origin checks. High only when an attacker-controlled origin can perform a CREDENTIALED cross-origin read of sensitive data and you have proven it in a browser. Use when testing API endpoints, SPAs, or any app emitting Access-Control-* headers.",
|
||||
"hunt-csrf": "Hunting skill for csrf vulnerabilities. Built from 15 public bug bounty reports including modern variants \u2014 SameSite=Lax sibling-subdomain bypass (Argo CD CVE-2024-22424), GraphQL mutations-via-GET (GitLab $3,370), framework-wide CSRF middleware disabled (Stripe Dashboard $5,000), path-traversal CSRF-token bypass (GitHub Enterprise CVE-2022-23732 $10k), Origin-omission bypass (TikTok $2,500), OAuth-state null-byte (Streamlabs), WebSocket CSRF / CSWSH (Coda), default-SameSite email-change \u2192 ATO (YoYo Games $400), social-account-link CSRF (HackerOne), JSON-CSRF via text/plain on email-change (TikTok $500). Use when hunting modern CSRF \u2014 heavy emphasis on chain-to-ATO patterns.",
|
||||
"hunt-deserialization": "Hunt Insecure Deserialization \u2014 Java gadget chains (ysoserial), PHP object injection (phpggc), Python pickle RCE, .NET BinaryFormatter, Ruby Marshal.load, JNDI/Log4Shell. RCE via deserialization is almost always Critical. Use when target runs Java, PHP serialization, Python pickle, .NET, or Ruby on Rails.",
|
||||
"hunt-dispatch": "Skill-set loader for /hunt orchestrator. Fingerprints the target, picks the right platform attack skills, and loads the Red Team or WAPT skill set. Use when /hunt has just received a mode answer (redteam or wapt + blackbox|greybox) and needs to load the appropriate skills and print the taxonomy. Not for direct user invocation.",
|
||||
"hunt-dom": "Hunt client-side DOM vulnerabilities \u2014 DOM Clobbering (overwrite JS globals via HTML injection), PostMessage hijacking (missing origin check), Service Worker abuse (intercept requests from same-origin script), CSS Injection/Exfiltration (attribute selectors \u2192 token char-by-char via OOB), client-side template injection, dangerouslySetInnerHTML. Grounded in named public research: Gareth Heyes / PortSwigger DOM-clobbering + DOM-Invader, Micha\u0142 Bentkowski DOMPurify clobbering bypasses, jQuery htmlPrefilter XSS (CVE-2020-11022 / CVE-2020-11023), d0nut CSS-exfil research. Use when hunting DOM-XSS, client-side auth bypass, or token exfiltration without server-side interaction.",
|
||||
"hunt-file-upload": "Hunt file upload bugs \u2014 RCE via webshell, XSS via SVG/HTML, SSRF via XXE in DOCX, path traversal via filename. Bypass tables (10 techniques): double extension (shell.php.jpg if server checks last ext only), magic bytes spoofing (PNG header on PHP), null byte (shell.php\\0.jpg), case (PHP, .Php, .pHP), .htaccess upload to enable execution, SVG with <script>, HTML/SVG XSS, DOCX with embedded XXE, ZIP slip (../../../etc/passwd in archive), polyglot files. Detection: any /upload, /avatar, /profile-picture, /attachment, /import endpoint. Test: upload PHP/JSP/ASPX shells, request via direct URL, check response. Validate: actual code execution (whoami output) for RCE; reflected XSS in profile-photo URL. Use when testing file upload features, avatar/attachment endpoints, import/export functions, XML/DOCX/ZIP processors. Real paid examples.",
|
||||
"hunt-graphql": "Hunting skill for graphql vulnerabilities. Built from 12 public bug bounty reports across IDOR via node() / GID, mutation IDOR including AI/LLM features, cross-tenant IDOR, SSRF via argument, batching-DoS, query-cost-bypass, SQLi via argument, broken-object-level-authz, auth-bypass via unscoped mutations, and PII exposure from missing field-level authz. Use when hunting graphql on any target.",
|
||||
"hunt-grpc": "Hunt gRPC vulnerabilities \u2014 server reflection enabled (enumerate all services/methods), missing authentication / metadata-stripping on internal endpoints, plaintext gRPC over HTTP/2, internal endpoint disclosure, proto file leakage, gRPC-Web/grpc-gateway transcoding injection, and HTTP/2 Rapid Reset DoS (CVE-2023-44487). Use when target exposes port 50051 / 443 / 8443 / 9090 with HTTP/2, when grpcurl/grpcui detects reflection, when an Envoy or grpc-gateway proxy is fronting a microservice, or when recon reveals a microservice architecture.",
|
||||
"hunt-host-header": "Hunt Host Header Injection \u2014 password reset poisoning \u2192 ATO, web cache poisoning via unkeyed Host/X-Forwarded-Host, routing-based SSRF (Host picks upstream \u2192 cloud metadata/internal services), path-override SSRF/ACL-bypass (X-Original-URL/X-Rewrite-URL), OAuth redirect_uri/issuer poisoning, and absolute-URL link poisoning in emails. High to Critical when it reaches ATO or mass cache poisoning. Built on public Host-header research (PortSwigger 'Practical web cache poisoning' + James Kettle, and the classic password-reset-poisoning class). Use on any forgot-password flow, CDN/reverse-proxy-fronted app, OAuth/OIDC endpoint, or absolute-URL-in-email feature.",
|
||||
"hunt-http-smuggling": "Hunt HTTP request smuggling (CL.TE, TE.CL, H2.CL, H2.TE). Cause: front-end proxy and back-end server disagree on where one request ends and the next begins (Content-Length vs Transfer-Encoding header parsing inconsistency). CL.TE: front-end uses CL, back uses TE \u2192 smuggle by sending TE: chunked but with body that fits CL count. TE.CL: opposite. H2.CL: HTTP/2 downgrade, smuggle CL into HTTP/1.1 back-end. Detection tools: Burp HTTP Request Smuggler extension, smuggler.py, h2csmuggler. Confirm: time-delay technique (smuggled GET with 30s timeout) \u2014 if front-end returns slow on next victim request, smuggling works. Validate: cache poisoning chain (smuggle request that gets cached for victim), credential theft (smuggle X-Forwarded-For override that captures next user's cookies), bypass auth (smuggled internal-path request). Real paid examples from major CDN deployments. Use when hunting H1 paid programs running CDN+origin stacks, when targeting load balancer / WAF bypass.",
|
||||
"hunt-idor": "Hunting skill for idor vulnerabilities. Built from 26 public bug bounty reports. Use when hunting idor on any target.",
|
||||
"hunt-k8s": "Hunt Kubernetes & Docker \u2014 API anonymous access, kubelet 10250 exec (SPDY/WebSocket, NOT plain POST) and the simpler /run primitive, etcd 2379 unauth, dashboard skip-login, RBAC misconfig, secret/SA-token abuse, docker.sock host escape, runc/container-escape (Leaky Vessels CVE-2024-21626), API-server-mediated nodes/proxy RCE, EphemeralContainers node-shell, bound/projected SA-token audience+expiry abuse, admission-controller bypass, Helm/Tiller remnants. Use when target runs containerized infra, exposes K8s ports (6443/10250/10255/2379/8443), or cloud metadata reveals K8s service accounts.",
|
||||
"hunt-laravel": "Hunt Laravel specific vulnerabilities \u2014 Debug mode leakage (APP_DEBUG=true exposes full stack trace + env vars), Laravel Telescope/Horizon dashboard unauthorized access, Ignition RCE (CVE-2021-3129), Signed URL manipulation, Queue Worker abuse, mass assignment via Eloquent, deserialization via cookies, .env file exposure. Use when target runs Laravel (PHP) \u2014 detected via X-Powered-By, Laravel session cookies, or /storage/ paths.",
|
||||
"hunt-ldap": "Hunt LDAP Injection and XPath Injection \u2014 authentication bypass, blind char-by-char attribute exfiltration, AD user/group enumeration, XML-store XPath bypass. Covers the LDAP special-character set (* ( ) \\\\ NUL /), search-filter-context vs DN-injection, parenthesis-balancing, AND/OR filter logic, and {SSHA}/{CRYPT} userPassword exfil on non-AD directories. Use when target uses LDAP/AD authentication, corporate SSO with a directory backend, an address-book/people-search API, or XML-based data stores queried with XPath.",
|
||||
"hunt-lfi": "Hunt Local File Inclusion (LFI), Remote File Inclusion (RFI), and Path Traversal \u2014 /etc/passwd read, log poisoning \u2192 RCE, PHP filter-chain RCE (no upload needed), php:// / data:// / zip:// / phar:// wrappers, RFI via allow_url_include, directory traversal read/write/delete. Covers OOB/blind LFI confirmation and false-positive discipline. Use when hunting file-include or path-traversal bugs on any target.",
|
||||
"hunt-llm-ai": "Hunt LLM/AI feature bugs \u2014 prompt injection, indirect injection, exfiltration via tool-use/markdown, ASCII smuggling, agentic AI security (OWASP Agentic Apps 2026, ASI01-ASI10). Patterns: direct injection ('ignore previous instructions'), indirect injection via documents/web pages/email the model reads, ASCII smuggling (Unicode Tags block U+E0000-U+E007F, invisible to humans, decoded by the model), tool-use exfiltration (model has fetch/browse tool, attacker injects OOB URL, model exfils chat history/secrets), markdown-image zero-click exfil, system-prompt extraction, IDOR-via-AI (cross-tenant data). Targets: chatbots, RAG, summarizers, agentic copilots, MCP tools. Detection: any LLM-backed endpoint, doc upload triggering AI processing, autonomous agent with tools. Validate: OOB/Collaborator callback for exfil, verbatim-reproducible system-prompt leak (run twice), verifiable cross-tenant leak or RCE. Confabulation is NOT a finding. Use when hunting AI features, chatbots, RAG, agentic systems, MCP.",
|
||||
"hunt-mfa-bypass": "Hunt MFA / 2FA bypass \u2014 7 distinct patterns. (1) MFA not enforced on sensitive endpoints (password change, email change accept without MFA challenge), (2) MFA-step skip via direct navigation to post-login URL, (3) MFA-token replay (same code accepted twice), (4) brute-force the 6-digit OTP without rate limit (10^6 attempts at server speed), (5) race condition on OTP validation, (6) recovery-code dump via /api/me, (7) backup factor downgrade (SMS factor with no rate limit). Plus the chain: cookie theft + password oracle + no step-up = ATO without MFA challenge. Detection: trace auth flow in Burp, find every state transition, check if MFA is middleware-gated vs per-endpoint, check OTP entropy and rate limit on OTP-validate. Validate: attacker session reaching post-MFA state. Use when hunting auth bypass, MFA flows, chaining primitives toward ATO.",
|
||||
"hunt-misc": "Hunting skill for misc vulnerabilities. Built from 225 public bug bounty reports. Use when hunting misc on any target.",
|
||||
"hunt-nextjs": "Hunt Next.js specific vulnerabilities \u2014 Server Actions arbitrary function execution, Middleware auth bypass via static asset paths, ISR cache poisoning, Image Optimization SSRF (/_next/image), RSC payload leakage, getServerSideProps injection, source map exposure, debug endpoint leakage. Use when target runs Next.js 13/14/15 or any React SSR framework.",
|
||||
"hunt-nodejs": "Hunt Node.js specific vulnerabilities \u2014 Prototype Pollution \u2192 RCE chains (lodash/merge/assign), Express trust proxy misconfiguration, child_process/eval injection, template engine SSTI (EJS/Pug/Handlebars), path traversal in file servers, require() injection, environment variable exfil via /proc/self/environ. Use when target runs Node.js/Express/Fastify/NestJS/Koa.",
|
||||
"hunt-nosqli": "Hunt NoSQL Injection \u2014 MongoDB operator injection ($where, $regex, $gt, $ne), CouchDB, Redis command injection, auth bypass via NoSQLi, data dump. Use when target uses MongoDB/Mongoose, CouchDB, Redis, or shows NoSQL error messages.",
|
||||
"hunt-ntlm-info": "Hunt NTLM/Negotiate information disclosure on internet-reachable IIS/SharePoint/Exchange. Anonymous NTLM Type-2 challenge capture leaks NetBIOS domain, internal DNS forest, computer name, AD timestamp via AV_PAIRS structure. Default Windows-installer hostnames (WIN-XXXXXXXXXXX pattern) signal lazy provisioning. Use when target advertises `WWW-Authenticate: NTLM` or `Negotiate` headers anonymously.",
|
||||
"hunt-oauth": "Hunting skill for oauth vulnerabilities. Built from 19 public bug bounty reports. Use when hunting oauth on any target.",
|
||||
"hunt-open-redirect": "Hunt Open Redirect \u2014 all types including low-impact, chained to OAuth token theft \u2192 ATO, phishing chains. URL parameter manipulation, JavaScript redirect, meta refresh, header injection. Use when hunting redirect bugs or building ATO chains.",
|
||||
"hunt-race-condition": "Hunting skill for race condition vulnerabilities. Built from 12 public bug bounty reports including modern HTTP/2 single-packet attack cases (James Kettle DEF CON 2023 \"Smashing the State Machine\"; RyotaK / Flatt Security 10,000-request first-sequence-sync expansion 2024). Covers coupon double-redemption, gift-card double-spend, MFA-OTP-validate race, account-create race, faucet/crypto token double-mint, email-activation race, vote/upvote inflation, password-reset token race, rate-limit bypass via concurrent requests. Use when hunting race conditions, TOCTOU bugs, MFA-bypass-via-timing.",
|
||||
"hunt-rce": "Hunting skill for rce vulnerabilities. Built from 67 public bug bounty reports. Use when hunting rce on any target.",
|
||||
"hunt-saml": "Hunt SAML / SSO attacks. Patterns: XML Signature Wrapping (XSW) \u2014 modify Assertion while keeping Signature valid by relocating signed element, comment injection in NameID ([email protected]<!--evil-->@attacker.com \u2192 some parsers see [email protected]), signature stripping (remove Signature element entirely, server should reject but doesn't), key confusion (signed by attacker's IdP, accepted by SP), audience-restriction not validated, replay attack (same Assertion accepted twice within validity window). Tools: SAML Raider Burp extension, samlmagic, manual XML manipulation. Detection: any /saml endpoint, /Shibboleth.sso, /sso/saml/, Microsoft ADFS endpoints. Validate: account takeover via altered NameID, admin role injection via altered AttributeStatement. Use when hunting SSO flows, when SAML AssertionConsumerService is reachable, when chaining IdP-trust to SP-impersonation.",
|
||||
"hunt-session": "Hunt Session Management vulnerabilities \u2014 session fixation (no regeneration on login), insufficient invalidation on logout / password-change / email-change, predictable or low-entropy session IDs, JWT-as-session with no exp/revocation, refresh-token rotation/reuse-detection gaps, OAuth/SSO session linkage, device-bound-session (DBSC) downgrade, and cookie attribute issues (Secure/HttpOnly/SameSite/__Host-). Validate with TWO real sessions (attacker A + victim B), body-diff every 200, and OOB confirmation for theft chains. Medium to Critical (fixation\u2192admin hijack, no-invalidation\u2192persistent ATO).",
|
||||
"hunt-sharepoint": "Hunt Microsoft SharePoint Server (2013/2016/2019/Subscription Edition) on-prem farms \u2014 anonymous endpoint enumeration, version disclosure, legacy SOAP login bypass (Authentication.asmx), ToolShell precondition chain (CVE-2025-53770), SafeControl reflection enumeration via Picker.aspx, NTLM Type-2 AD topology disclosure, custom-branding module discovery, EoL farm permanent-CVE-window exploitation, FormDigest anonymous issuance, file-extension blocklist NOT-an-oracle pattern, custom-zone Forms auth bridging on-prem AD. Use when target has SharePoint headers (SPRequestGuid, X-MS-InvokeApp, X-SharePointHealthScore, MicrosoftSharePointTeamServices) or paths (/_layouts/15/, /_vti_bin/, /_api/, /_catalogs/).",
|
||||
"hunt-source-leak": "Hunt source code and build artifact leakage \u2014 JavaScript source maps (.js.map) reconstructing TypeScript/ES6 source, Swagger/OpenAPI JSON endpoint discovery, .env/.git exposure, webpack chunks with hardcoded secrets, robots.txt/security.txt recon, build-info files, asset-manifest.json API route discovery, .DS_Store file listing. Use at the START of every recon session \u2014 these findings often unlock the entire attack surface.",
|
||||
"hunt-springboot": "Hunt Spring Boot specific vulnerabilities \u2014 Actuator endpoints (heapdump, env, loggers, mappings, shutdown), Spring Expression Language (SpEL) injection \u2192 RCE, H2 console RCE, Jolokia JMX exposure, Spring4Shell (CVE-2022-22965), Spring Cloud Function SPEL (CVE-2022-22963), heap dump credential extraction. Use when target runs Spring Boot \u2014 detected via X-Application-Context header, /actuator, Whitelabel Error Page, or Java stack traces.",
|
||||
"hunt-sqli": "Hunting skill for sqli vulnerabilities. Built from 12 public bug bounty reports including modern NoSQL injection (Rocket.Chat CVE-2021-22911 MongoDB $regex, Mongoose ORM CVE-2024-53900 $where bypass), modern ORM raw-fragment SQLi (Django CVE-2024-42005, Sequelize GHSA-wrh9-cjv3-2hpw), second-order SOQL injection (HackerOne Salesforce), time-based blind SQLi in GraphQL resolvers, and SQLi on OIDC-proxy backends. Use when hunting SQLi / NoSQLi on any target.",
|
||||
"hunt-ssrf": "Hunting skill for ssrf vulnerabilities. Built from 15 public bug bounty reports including AWS metadata SSRF (HackerOne $25k Analytics PDF, Shopify Exchange $25k, Capital One 106M-record breach, Dropbox/HelloSign $4,913), GCP metadata SSRF (Snapchat $4k), Azure IMDS SSRF (Azure DevOps $15k chain, ChatGPT Custom Actions MSRC), DNS rebinding SSRF (Concrete CMS, GitLab UrlBlocker), gopher-protocol-to-Redis-RCE (Yahoo Mail $15k), link-preview SSRF (Reddit Matrix $6k), and headless-browser PDF-generator SSRF chains. Use when hunting SSRF on any target \u2014 OOB Collaborator confirmation mandatory for blind cases.",
|
||||
"hunt-ssti": "Hunt server-side template injection (SSTI) across Jinja2 (Flask/Django), Twig (Symfony), Freemarker (Java), ERB (Rails), Spring, Velocity, Mako, Thymeleaf, Smarty. Detection probes use double-curly and dollar-curly math expressions evaluated server-side. Once an engine is fingerprinted, escalate to RCE via the engine-specific class-walker, callback-registrar, or Execute-utility patterns documented in disclosed reports. Detection patterns: error messages reveal engine, blank or numeric eval reveals expression mode. Targets: email templates, PDF/report generators, CMS preview features, error pages with user input. Use when hunting RCE via template rendering, when content shows engine fingerprints, when finding endpoints that compose strings with user input before render.",
|
||||
"hunt-subdomain": "Hunting skill for subdomain takeover vulnerabilities. Includes modern provider fingerprints \u2014 Microsoft Azure DevOps `cloudapp.azure.com` regional-pool re-issue (1-click OAuth ATO via wildcard `reply_to`, Binary Security), Zendesk help-desk takeover \u2192 email interception \u2192 password reset chain (0xprial writeup), Vercel `cname.vercel-dns.com` deleted-project takeover, plus general Fastly CDN service re-attach and S3 dangling-bucket cookie-scope techniques. Use when hunting subdomain takeover \u2014 emphasis on ATO-chain primitives (OAuth `redirect_uri`, cookie-domain, email DNS).",
|
||||
"hunt-tls-network": "Hunt TLS/SSL and DNS misconfigurations \u2014 missing HSTS (downgrade attack), weak cipher suites, expired/invalid certificates, mTLS bypass, missing SPF/DKIM/DMARC (email spoofing), DNS Zone Transfer (AXFR), dangling CNAME subdomain takeover, CAA records. Most of these are Info/Low on their own \u2014 this skill is opinionated about which findings actually pay (spoofable DMARC with delivered-to-inbox proof, AXFR returning internal hosts, dangling-CNAME takeover) versus which get rejected as best-practice noise (missing CAA, missing HSTS with no MitM position). Use during recon to find infrastructure weaknesses, and to TRIAGE them honestly before reporting.",
|
||||
"hunt-websocket": "Hunt WebSocket vulnerabilities \u2014 Cross-Site WebSocket Hijacking (CSWSH), missing/weak Origin validation on the WS handshake, no per-message authentication, message tampering, socket.io namespace/room authorization bypass, and handshake-layer Upgrade smuggling. Use when target has WebSocket endpoints (ws:// or wss://), socket.io / SignalR / Phoenix Channels, real-time features, chat, live dashboards, notifications, or trading platforms.",
|
||||
"hunt-xss": "Hunting skill for xss vulnerabilities. Built from 174 public bug bounty reports. Use when hunting xss on any target.",
|
||||
"hunt-xxe": "Hunting skill for xxe vulnerabilities. Built from 10 public bug bounty reports including SVG-upload XXE, Office-doc (PPTX/DOCX) XXE, SOAP XXE, SAML AssertionConsumer XXE, blind OOB XXE via DTD callback, parameter-entity XXE, XXE-to-LFI, XXE-to-SSRF, and XXE-to-RCE chains (Adobe Commerce CosmicSting CVE-2024-34102). Use when hunting XXE on any target \u2014 emphasis on OOB-Or-It-Didn't-Happen Gate for blind cases.",
|
||||
"m365-entra-attack": "Microsoft 365 / Entra ID red-team attack chain \u2014 current 2026 reality. AADSTS code reference, user enumeration vectors (with hardening status), Smart Lockout math, Conditional Access bypass options, ROPC + SAML SSO browser flow, Burp/Playwright templates. Built from authorized red-team work where ROPC spray surfaced pre-existing lockouts and CA-blocked credentials, plus real-time external attacker activity correlation. Use for any M365/Entra credential attack, password spray, user enumeration, CA-bypass exploration, or active-attacker-detection scenario.",
|
||||
"meme-coin-audit": "Meme coin and token security audit \u2014 rug pull detection (honeypot, hidden mint, fee manipulation, LP lock bypass), Solana SPL token analysis (freeze authority, mint authority, metadata mutability), Token-2022 extension risks (transfer hooks, permanent delegate), DEX liquidity pool attacks (sandwich amplification, LP drain, bonding curve exploits), pump.fun/Raydium/Jupiter integration risks, and real exploit examples from 2024-2025. Use for any token audit, rug pull assessment, meme coin security review, or pre-investment due diligence.",
|
||||
"mid-engagement-ir-detection": "Methodology for detecting client SOC patches, attacker activity, and security-state changes that occur DURING a red-team engagement \u2014 and converting those observations into deliverable findings. Built from authorized red-team work where the client patched a confirmed SQLi within 30 minutes of detection AND an external attacker locked multiple new accounts during a single test session. Use when (a) running ANY active engagement against a monitored target, (b) a previously-confirmed finding stops reproducing, (c) baseline timing shifts unexpectedly, or (d) you notice response patterns changing during testing.",
|
||||
"offensive-osint": "Operational arsenal for authorized external red-team and bug-bounty recon. Concrete probes, wordlists, regexes, dorks, curl one-liners for: subdomain enum, GraphQL/Swagger/REST discovery, identity fabric (Entra/Okta/ADFS/Google/SAML/M365 deep \u2014 Teams/SharePoint/OneDrive), cloud bucket enum (S3/GCS/Azure), CDN/WAF bypass, origin discovery, vendor fingerprinting (Citrix/F5/Pulse/Fortinet/PaloAlto/Cisco/VMware), CI/CD exposure, 48-pattern secret-scan catalog (AWS/GCP/GitHub/Stripe/Slack/Anthropic/OpenAI/Atlassian/DataDog/npm/PyPI), Postman workspaces, breach correlation (HudsonRock/HIBP/DeHashed/IntelX), TLS/JA3 audit, certificate transparency, JS endpoint extraction, package registry leaks, mobile/APK recon, sat imagery, sector-specific recon (healthcare DICOM, finance SWIFT, ICS/SCADA Modbus/BACnet). Detail content in 15 modular reference files, loaded on demand. Use for any authorized recon: scoping, asset discovery, attack-path mapping, secret triage, severity scoring.",
|
||||
"okta-attack": "Okta-as-IdP red-team attack chain \u2014 tenant discovery, user enumeration (multiple vectors), authentication flow analysis (factors enumeration, push-notification fatigue, SMS bypass), password spray with lockout discipline, Okta-specific phishing primitives (kits, FastPass abuse, OIDC redirect_uri tampering), MFA enumeration, post-compromise admin API surface. Many enterprise orgs use Okta instead of (or alongside) Entra ID. Distinct endpoints, distinct rate-limiting, distinct factor flows. Use when recon shows `<tenant>.okta.com`, `<tenant>.okta-emea.com`, `<tenant>.oktapreview.com`, or autodiscover-style records pointing at Okta IdP.",
|
||||
"osint-methodology": "Comprehensive OSINT methodology for external red-team operations and authorized attack-surface assessments. Covers the 5-stage recon pipeline (seed discovery, asset expansion, enrichment, exposure analysis, reporting), asset-graph discipline with 29 asset types, severity rubric (CRITICAL/HIGH/MEDIUM/LOW/INFO), confidence upgrade workflows, time budgeting, asset-level triage rules, scale-based tactics, identity-fabric mapping (Entra/Okta/ADFS/Google/SAML/M365 Teams+SharePoint+OAuth), API and auth-map methodology, JavaScript deep analysis, mobile attack surface, cloud attack surface, breach\u00d7identity correlation, detectability tagging, detection-aware probing (back-off, persona rotation), read-only validator discipline, WAF/CDN bypass + origin discovery, vulnerability prioritization (CVE/EPSS/KEV), phishing infrastructure planning + pretext development, bug bounty submission templates, client deliverable templates with risk translation, threat-actor investigation (incl. RU/CN pivots), cryptocurrency tracing, image/video forensics, chronolocation. Use when planning or executing reconnaissance against authorized targets, mapping an organization's external attack surface, investigating a person/entity, tracing crypto flows, geolocating media, or performing attribution work.",
|
||||
"redteam-mindset": "Red-team operator discipline \u2014 the mindset corrections that separate offensive testing from defensive WAPT. Built from authorized red-team work where conservative defaults caused multiple findings to be missed and one to be incorrectly retracted. Use at the START of any red-team engagement and again whenever feeling stuck or considering \"stopping\" on a defended target. The single most important skill to load when scope is \"external red team\" not \"bug bounty / WAPT\".",
|
||||
"redteam-report-template": "Client-facing red-team deliverable format \u2014 codifies the Subject / Observations / Description / Impact / Recommendation / PoC structure used for external red-team engagements (not bug-bounty platform reports). Different audience, different tone, different cadence. Built from an authorized engagement deliverable where 14 findings were packaged into a 52KB MD + 2.2MB DOCX with 16 embedded screenshots. Use when the engagement is \"external red team for an enterprise client\" (not H1/Bugcrowd/Intigriti), when generating the final report, when the client has specified a custom report format, or when packaging findings into DOCX/PDF.",
|
||||
"report-writing": "Bug bounty report writing for H1/Bugcrowd/Intigriti/Immunefi \u2014 report templates, human tone guidelines, impact-first writing, CVSS 3.1 scoring, title formula, impact statement formula, severity decision guide, downgrade counters, pre-submit checklist. Use after validating a finding and before submitting. Never use \"could potentially\" \u2014 prove it or don't report.",
|
||||
"security-arsenal": "Security payloads, bypass tables, wordlists, gf pattern names, always-rejected bug list, and conditionally-valid-with-chain table. Use when you need specific payloads for XSS/SSRF/SQLi/XXE/NoSQLi/command injection/SSTI/IDOR/path-traversal/HTTP smuggling/WebSocket/MFA bypass, bypass techniques, or to check if a finding is submittable. Also use when asked about what NOT to submit.",
|
||||
"supply-chain-attack-recon": "External recon for software supply-chain attack surface \u2014 package-namespace squatting candidates, dependency-confusion vulnerabilities, GitHub Actions injection openings, container image registry exposure, SBOM mining, internal-package-name leakage, and CI/CD configuration exposure. Reconnaissance and identification ONLY \u2014 actual package publishing / typosquat attacks are EXTERNAL-OFFENSIVE and require explicit written sign-off because they can affect the entire npm/PyPI ecosystem. Use when the target has a public GitHub org, when their build artifacts/SBOMs are reachable, when their docker images are on Docker Hub/GHCR, or when you find internal package names in their JS bundles.",
|
||||
"triage-validation": "Finding validation before writing any report \u2014 7-Question Gate (all 7 questions), 4 pre-submission gates, always-rejected list, conditionally valid with chain table, CVSS 3.1 quick reference, severity decision guide, report title formula, 60-second pre-submit checklist. Use BEFORE writing any report. One wrong answer = kill the finding and move on. Saves N/A ratio.",
|
||||
"vmware-vcenter-attack": "VMware vSphere / vCenter Server external attack matrix \u2014 version fingerprinting, the high-impact CVE chain (CVE-2021-21972 vRealize unauth file upload, CVE-2021-21985 vSAN plugin RCE, CVE-2022-22954 Workspace ONE SSTI, CVE-2023-20887 Aria RCE, CVE-2024-37085 ESXi AD bypass, CVE-2023-34048 vCenter DCERPC OOB write APT-exploited), default credentials, SSO configuration disclosure, vmdir LDAP enumeration, ESXi Open SLP RCE history. ONLY for vCenter / Workspace ONE / Aria instances exposed to the internet \u2014 internal-network vCenter is out of scope per the external-only boundary. Use when recon shows port 443 with vCenter banner, `/ui` redirect, `/websso/SAML2/Metadata`, or VMware product fingerprints.",
|
||||
"web2-recon": "Web2 recon pipeline \u2014 subdomain enumeration (subfinder, Chaos API, assetfinder), live host discovery (dnsx, httpx), URL crawling (katana, waybackurls, gau), directory fuzzing (ffuf), JS analysis (LinkFinder, SecretFinder), continuous monitoring (new subdomain alerts, JS change detection, GitHub commit watch). Use when starting recon on any web2 target or when asked about asset discovery, subdomain enum, or attack surface mapping.",
|
||||
"web3-audit": "Smart contract security audit \u2014 10 DeFi bug classes (accounting desync, access control, incomplete path, off-by-one, oracle, ERC4626, reentrancy, flash loan, signature replay, proxy), pre-dive kill signals (TVL < $500K etc), Foundry PoC template, grep patterns for each class, and real Immunefi paid examples. Use for any Solidity/Rust contract audit or when deciding whether a DeFi target is worth hunting."
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -32,7 +32,12 @@ options:
|
||||
2. WAPT / BugHunting — full OWASP coverage, platform/program report
|
||||
```
|
||||
|
||||
do not prompt for SOW, scope-of-work, engagement letter, or authorization.
|
||||
do not prompt the operator to paste a SOW, scope-of-work, or engagement-letter
|
||||
**document** — that is sensitive client data and must not be ingested or persisted.
|
||||
This is a data-hygiene rule, NOT an authorization bypass: scope is still enforced by
|
||||
`/scope` (deterministic, deny-wins) and the `triage-validation` 7-Question Gate before
|
||||
any finding ships. Authorization is assumed to be established out-of-band; the bundle
|
||||
verifies in-scope, it does not collect proof-of-authorization paperwork.
|
||||
|
||||
## step 2a — red team
|
||||
|
||||
|
||||
+11
-6
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: intel
|
||||
description: On-demand intelligence fetch for a target — CVEs, disclosed reports, new features. Wraps learn.py + hunt memory context. Usage: /intel target.com
|
||||
description: On-demand intelligence fetch for a target — CVEs, disclosed reports, new features. Pulls NVD/GitHub-Advisory CVEs + bundled disclosed reports + hunt memory context. Usage: /intel target.com
|
||||
---
|
||||
|
||||
# /intel
|
||||
@@ -9,8 +9,10 @@ Fetch actionable intelligence for a target.
|
||||
|
||||
## What This Does
|
||||
|
||||
1. Runs `learn.py` for CVEs and advisories matching the target's tech stack
|
||||
1. Looks up CVEs and advisories matching the target's tech stack (NVD + GitHub
|
||||
Advisory via web search; `learn.py` is used as an accelerator only if present)
|
||||
2. Fetches HackerOne Hacktivity for the target (via HackerOne MCP if available)
|
||||
and cross-references the bundled `docs/disclosed-reports/`
|
||||
3. Cross-references with hunt memory — flags untested CVEs and new endpoints
|
||||
4. Outputs prioritized intel with hunt recommendations
|
||||
|
||||
@@ -48,8 +50,11 @@ MEMORY CONTEXT:
|
||||
|
||||
| Source | What | Auth required? |
|
||||
|---|---|---|
|
||||
| `learn.py` — NVD | CVEs matching tech stack | No |
|
||||
| `learn.py` — GitHub Advisory | Security advisories | No |
|
||||
| `learn.py` — HackerOne Hacktivity | Disclosed reports | No |
|
||||
| HackerOne MCP (if connected) | Program stats, policy | No (public) |
|
||||
| NVD (web) | CVEs matching tech stack | No |
|
||||
| GitHub Advisory (web) | Security advisories | No |
|
||||
| `docs/disclosed-reports/` | Bundled disclosed-report patterns | No (local) |
|
||||
| HackerOne Hacktivity / MCP (if connected) | Disclosed reports, program stats | No (public) |
|
||||
| Hunt memory | Previously tested endpoints | Local files |
|
||||
|
||||
> `learn.py` is an optional accelerator. If it isn't present, Claude performs the
|
||||
> lookups above directly — the command works without it.
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: scope
|
||||
description: Mandatory pre-flight scope check — verify an asset is in scope BEFORE any HTTP touch. Deterministic (deny-wins, default-deny) via engine/scope.py against the engagement's scope.md. Blocks out-of-scope testing. Usage: /scope <asset> [<asset> ...]
|
||||
---
|
||||
|
||||
# /scope
|
||||
|
||||
The mandatory pre-flight gate. Verify a host or URL is in scope **before** any
|
||||
active testing. Scope is enforced in **code** (`engine/scope.py`), not by LLM
|
||||
judgment — deny wins, default deny.
|
||||
|
||||
## What This Does
|
||||
|
||||
1. Loads in-scope / out-of-scope patterns from the engagement's `scope.md`
|
||||
(the `hunt` scaffold creates it) or from inline patterns.
|
||||
2. Checks each asset deterministically with `engine/scope.py`:
|
||||
- apex `acme.com` → the apex **and** any subdomain
|
||||
- `*.acme.com` → any subdomain (NOT the bare apex)
|
||||
- `api.acme.com` → that exact host
|
||||
- `10.0.0.0/8` → any IP in the CIDR
|
||||
- `re:^lab[0-9]+\.acme\.io$` → explicit regex (prefix `re:`)
|
||||
3. Prints `IN-SCOPE` / `OUT-OF-SCOPE <reason>` and exits non-zero if any asset
|
||||
is out of scope — so it can gate automation.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/scope api.acme.com
|
||||
/scope api.acme.com staging.acme.io admin.acme.com
|
||||
```
|
||||
|
||||
Under the hood, run the deterministic checker directly:
|
||||
|
||||
```bash
|
||||
# Against the engagement scope.md (preferred — fill scope.md from the program page first)
|
||||
python3 engine/scope.py api.acme.com --md ~/Targets/acme/scope.md
|
||||
|
||||
# Or with inline patterns (repeat --in-scope / --out-of-scope per pattern)
|
||||
python3 engine/scope.py api.acme.com evil.com \
|
||||
--in-scope acme.com --in-scope '*.acme.io' --out-of-scope admin.acme.com
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- **Run this before any HTTP touch.** An `OUT-OF-SCOPE` result is a hard stop —
|
||||
do not probe the asset.
|
||||
- If `scope.md` is still the empty template (`(paste ... here)` placeholders),
|
||||
fill it from the program page first; an empty in-scope list = default-deny =
|
||||
everything rejected, which is the safe default.
|
||||
- Deny wins: if an asset matches both an in-scope and an out-of-scope rule, it is
|
||||
out of scope.
|
||||
+8
-3
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: surface
|
||||
description: Show ranked attack surface for a target based on recon output + hunt memory. Invokes recon-ranker agent. Usage: /surface target.com
|
||||
description: Show ranked attack surface for a target from its recon manifest + hunt memory. Deterministic backing is `cbh surface <target>` (reads recon/<target>/manifest.json); LLM layer adds hunt-memory signal. Usage: /surface target.com
|
||||
---
|
||||
|
||||
# /surface
|
||||
@@ -9,11 +9,16 @@ View the prioritized attack surface for a target.
|
||||
|
||||
## What This Does
|
||||
|
||||
1. Reads cached recon output from `recon/<target>/`
|
||||
1. Reads the recon manifest at `recon/<target>/manifest.json` (produced by `cbh recon`)
|
||||
— the deterministic ranking comes from `cbh surface <target>`
|
||||
2. Reads hunt memory for patterns and previously tested endpoints
|
||||
3. Invokes the `recon-ranker` agent to produce a prioritized ranking
|
||||
3. Layers `engine/skill_map.py` (surface → bug-class → skill) + hunt-memory signal on
|
||||
top of the manifest's `ranked_surface` (P1/P2/Kill)
|
||||
4. Outputs P1 (start here), P2 (after P1), and Kill List (skip)
|
||||
|
||||
> The manifest is the recon→hunt contract — see [`docs/recon-manifest.md`](../docs/recon-manifest.md).
|
||||
> Run `cbh surface <target>` for the raw deterministic ranking.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
|
||||
+13
-13
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: token-scan
|
||||
description: Meme coin and token security scan — checks for rug pull vectors (hidden mint, honeypot, fee manipulation, LP lock bypass, authority retention, bonding curve exploits, fake renounce, sandwich amplification). Runs automated token_scanner.py + manual 8-class audit. Usage: /token-scan <contract_path_or_dir> [--chain solana]
|
||||
description: Meme coin and token security scan — checks for rug pull vectors (hidden mint, honeypot, fee manipulation, LP lock bypass, authority retention, bonding curve exploits, fake renounce, sandwich amplification). Manual 8-class grep audit (with an optional automated scanner if present). Usage: /token-scan <contract_path_or_dir> [--chain solana]
|
||||
---
|
||||
|
||||
# /token-scan
|
||||
@@ -30,20 +30,21 @@ Before scanning code, check:
|
||||
|
||||
If ANY answer is NO → flag and proceed with extreme caution.
|
||||
|
||||
## Step 1: Run Automated Scanner
|
||||
## Step 1: Optional Automated Scanner
|
||||
|
||||
> **Optional accelerator — not bundled by default.** If you have a local
|
||||
> `token_scanner.py` (or any rug-vector scanner) on PATH, run it first for a quick
|
||||
> risk score. If you don't, **skip straight to Step 2** — the manual 8-class grep
|
||||
> audit below is the real, self-contained check and needs no extra tooling.
|
||||
|
||||
```bash
|
||||
# EVM
|
||||
python3 tools/token_scanner.py <contract_path>
|
||||
|
||||
# Solana
|
||||
python3 tools/token_scanner.py <program_dir> --chain solana --recursive
|
||||
|
||||
# JSON output for piping
|
||||
python3 tools/token_scanner.py <path> --json
|
||||
# If present:
|
||||
python3 tools/token_scanner.py <contract_path> # EVM
|
||||
python3 tools/token_scanner.py <program_dir> --chain solana --recursive # Solana
|
||||
```
|
||||
|
||||
The scanner checks all 8 bug classes via regex and returns a risk score + verdict.
|
||||
The manual audit (Steps 2-9) covers all 8 bug classes via grep and is sufficient
|
||||
on its own.
|
||||
|
||||
## Step 2: Hidden Mint Check
|
||||
|
||||
@@ -122,11 +123,10 @@ Look for: auto-swap with amountOutMin=0, rebase on every transfer.
|
||||
|
||||
## Output
|
||||
|
||||
The scanner produces:
|
||||
Summarize the audit (whether driven by the manual steps or the optional scanner) as:
|
||||
- **Risk score** (0-100) based on finding severity
|
||||
- **Verdict** (CLEAN / LOW RISK / MEDIUM RISK / HIGH RISK / CRITICAL RISK)
|
||||
- **Individual findings** with file:line, code snippet, and recommendation
|
||||
- **Exit code** 1 if CRITICAL/HIGH findings (for CI integration)
|
||||
|
||||
## What to Do Next
|
||||
|
||||
|
||||
+21
-1
@@ -3,6 +3,13 @@
|
||||
> **Secondary interface — slash commands are primary.** Inside a Claude Code conversation, use the slash commands (`/recon`, `/hunt`, `/triage`, `/report`, `/validate`, `/chain`, `/autopilot`, `/scope`, etc.) — they leverage the full skill content and the LLM's judgment.
|
||||
>
|
||||
> `cbh` is the **terminal-native deterministic runner** — use it when you're outside Claude Code, automating in CI/CD, running scheduled recon, or verifying labs reproducibly. Same skills, different execution model.
|
||||
>
|
||||
> **Availability — three ways to run `cbh`:**
|
||||
> 1. **From a git clone** (no install): `python3 scripts/cbh.py <cmd>` — uses the live `skills/` + `docs/disclosed-reports/`.
|
||||
> 2. **Installed standalone** (works without the repo): `pipx install git+https://github.com/elementalsouls/Claude-BugHunter` (or `pip install .` from a clone). Gives you a global `cbh` backed by a bundled skill index; recon output goes to `./recon/` (override with `--out`), and skill/report pointers link to GitHub.
|
||||
> 3. It does **not** ship with the plugin — the plugin provides skills + slash commands only.
|
||||
>
|
||||
> Regenerate the bundled index after editing skills: `python3 scripts/gen_skill_index.py`.
|
||||
|
||||
## When to use `cbh` vs slash commands
|
||||
|
||||
@@ -103,7 +110,7 @@ This mode is the most ergonomic — the LLM drives Burp via MCP while consulting
|
||||
|
||||
---
|
||||
|
||||
## The four subcommands
|
||||
## The five subcommands
|
||||
|
||||
### `cbh recon <target>` — passive recon + live-host probe
|
||||
|
||||
@@ -123,6 +130,19 @@ Outputs:
|
||||
- `recon/<target>/resolved.txt`
|
||||
- `recon/<target>/live-hosts.json`
|
||||
- `recon/<target>/RECON_SUMMARY.md`
|
||||
- `recon/<target>/manifest.json` — the **recon→hunt handoff contract** ([`docs/recon-manifest.md`](recon-manifest.md))
|
||||
|
||||
### `cbh surface <target>` — ranked attack surface from the recon manifest
|
||||
|
||||
```bash
|
||||
cbh surface hackerone.com
|
||||
```
|
||||
|
||||
Reads `recon/<target>/manifest.json` and prints the ranked **P1 / P2 / Kill** surface
|
||||
(api/auth/admin hosts → P1; static/CDN → Kill). The same manifest is what `hunt <target>`
|
||||
ingests to seed `scope.md` + `notes.md`, and what the `offensive-osint` skill appends
|
||||
secrets / identity-fabric data to. This is the deterministic backing for the `/surface`
|
||||
slash command. See [`docs/recon-manifest.md`](recon-manifest.md) for the full contract.
|
||||
|
||||
### `cbh classify <url>` — pattern-match URL → hunt-* skills
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# Recon → Hunt Manifest
|
||||
|
||||
`manifest.json` is the data contract between the **recon** phase
|
||||
([Claude-OSINT](https://github.com/elementalsouls/Claude-OSINT) · `offensive-osint` ·
|
||||
`cbh recon`) and the **hunt** phase (Claude-BugHunter · `hunt` · `cbh surface` · `/hunt`).
|
||||
One file, written to `recon/<target>/manifest.json`, that the hunt side ingests so recon
|
||||
output flows straight into the engagement instead of being re-typed.
|
||||
|
||||
JSON Schema: [`recon-manifest.schema.json`](recon-manifest.schema.json).
|
||||
|
||||
## Producers — who writes it
|
||||
|
||||
The manifest is a **shared container**; each producer fills the parts it discovers:
|
||||
|
||||
| Producer | Fills |
|
||||
|---|---|
|
||||
| `cbh recon <target>` | `target`, `generated_at`, `counts`, `assets`, `ranked_surface` |
|
||||
| `offensive-osint` skill — `secret_scan.py` | appends `secrets[]` |
|
||||
| `offensive-osint` skill — identity-fabric probes | fills `identity_fabric{}` |
|
||||
|
||||
A producer that doesn't discover a section leaves it empty (`[]` / `{}`) — never absent.
|
||||
|
||||
## Consumers — who reads it
|
||||
|
||||
| Consumer | Uses |
|
||||
|---|---|
|
||||
| `cbh surface <target>` | prints the ranked **P1 / P2 / Kill** surface from `ranked_surface` |
|
||||
| `hunt <target> [manifest.json]` | seeds `scope.md` (live hosts) + `notes.md` (ranked surface) |
|
||||
| `/hunt`, `/surface` slash commands | same data, LLM-driven |
|
||||
|
||||
`hunt` resolves the manifest from: 2nd arg → `$HUNT_MANIFEST` → `./recon/<target>/manifest.json`.
|
||||
|
||||
## Schema (v1.0)
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `schema_version` | string | `"1.0"` |
|
||||
| `target` | string | root domain |
|
||||
| `generated_at` | string | UTC ISO-8601 |
|
||||
| `producers` | string[] | e.g. `["cbh-recon/2.1.0"]` |
|
||||
| `counts` | object | `{subdomains, resolved, live}` |
|
||||
| `assets` | object[] | `{host, ips[], url, status, server, title, tech[], source}` (`url`/`status` null for DNS-only) |
|
||||
| `ranked_surface` | object[] | `{url, host, bug_classes[], priority, rationale}` · priority ∈ `P1`/`P2`/`KILL` |
|
||||
| `secrets` | object[] | `{pattern, severity, category, source}` — from `secret_scan.py` |
|
||||
| `identity_fabric` | object | `{idp, tenant, domains[], ...}` — from identity-fabric probes |
|
||||
|
||||
## Example
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"target": "acme.com",
|
||||
"generated_at": "2026-06-25T12:00:00+00:00",
|
||||
"producers": ["cbh-recon/2.1.0"],
|
||||
"counts": { "subdomains": 47, "resolved": 31, "live": 12 },
|
||||
"assets": [
|
||||
{ "host": "api.acme.com", "ips": ["1.2.3.4"], "url": "https://api.acme.com",
|
||||
"status": 200, "server": "nginx", "title": "API", "tech": [], "source": "crtsh+httpx" }
|
||||
],
|
||||
"ranked_surface": [
|
||||
{ "url": "https://api.acme.com", "host": "api.acme.com",
|
||||
"bug_classes": ["hunt-api-misconfig", "hunt-idor"], "priority": "P1",
|
||||
"rationale": "high-value surface (api/auth/admin/non-prod)" }
|
||||
],
|
||||
"secrets": [],
|
||||
"identity_fabric": {}
|
||||
}
|
||||
```
|
||||
|
||||
## Round-trip
|
||||
|
||||
```bash
|
||||
cbh recon acme.com # → recon/acme.com/manifest.json (producer)
|
||||
cbh surface acme.com # ranked P1/P2/Kill (consumer)
|
||||
hunt acme.com recon/acme.com/manifest.json # seeds scope.md + notes.md (consumer)
|
||||
```
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/elementalsouls/Claude-BugHunter/blob/main/docs/recon-manifest.schema.json",
|
||||
"title": "Recon → Hunt Manifest",
|
||||
"description": "Data contract between the recon phase (cbh recon / offensive-osint) and the hunt phase (hunt / cbh surface / /hunt).",
|
||||
"type": "object",
|
||||
"required": ["schema_version", "target", "generated_at", "assets", "ranked_surface"],
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"schema_version": { "type": "string", "const": "1.0" },
|
||||
"target": { "type": "string" },
|
||||
"generated_at": { "type": "string", "format": "date-time" },
|
||||
"producers": { "type": "array", "items": { "type": "string" } },
|
||||
"counts": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"subdomains": { "type": "integer" },
|
||||
"resolved": { "type": "integer" },
|
||||
"live": { "type": "integer" }
|
||||
}
|
||||
},
|
||||
"assets": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["host"],
|
||||
"properties": {
|
||||
"host": { "type": "string" },
|
||||
"ips": { "type": "array", "items": { "type": "string" } },
|
||||
"url": { "type": ["string", "null"] },
|
||||
"status": { "type": ["integer", "null"] },
|
||||
"server": { "type": "string" },
|
||||
"title": { "type": "string" },
|
||||
"tech": { "type": "array", "items": { "type": "string" } },
|
||||
"source": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"ranked_surface": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["priority"],
|
||||
"properties": {
|
||||
"url": { "type": ["string", "null"] },
|
||||
"host": { "type": "string" },
|
||||
"bug_classes": { "type": "array", "items": { "type": "string" } },
|
||||
"priority": { "type": "string", "enum": ["P1", "P2", "KILL"] },
|
||||
"rationale": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"secrets": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": { "type": "string" },
|
||||
"severity": { "type": "string" },
|
||||
"category": { "type": "string" },
|
||||
"source": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"identity_fabric": { "type": "object" }
|
||||
}
|
||||
}
|
||||
+77
-1
@@ -108,5 +108,81 @@ def _selftest():
|
||||
print("scope.py self-test: PASS")
|
||||
|
||||
|
||||
def _scope_from_md(path):
|
||||
"""Build a Scope from a scaffolded scope.md (the `hunt` template).
|
||||
|
||||
Collects bullet items under an '## In scope' / '## Out of scope' heading.
|
||||
Takes the first whitespace token of each bullet (so '- api.example.com — note'
|
||||
yields 'api.example.com'). Skips placeholder bullets like '(paste ... here)'.
|
||||
"""
|
||||
in_scope, out_scope, section = [], [], None
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
low = line.strip().lower()
|
||||
if low.startswith("#"):
|
||||
hashes = len(low) - len(low.lstrip("#"))
|
||||
if "out of scope" in low or "out-of-scope" in low:
|
||||
section = "out"
|
||||
elif "in scope" in low or "in-scope" in low:
|
||||
section = "in"
|
||||
elif hashes <= 2:
|
||||
section = None # a peer-level section (## Focus areas, etc.)
|
||||
# deeper sub-headings (### Web, #### APIs) keep the current section
|
||||
continue
|
||||
s = line.strip()
|
||||
if section and (s.startswith("- ") or s.startswith("* ")):
|
||||
tok = s[2:].strip().split()[0:1]
|
||||
if not tok:
|
||||
continue
|
||||
pat = tok[0]
|
||||
if pat.startswith("(") or pat.startswith("["):
|
||||
continue # placeholder text, not a real pattern
|
||||
(in_scope if section == "in" else out_scope).append(pat)
|
||||
return Scope(in_scope=in_scope, out_of_scope=out_scope, name=path)
|
||||
|
||||
|
||||
def _main(argv=None):
|
||||
import argparse
|
||||
ap = argparse.ArgumentParser(
|
||||
prog="scope.py",
|
||||
description="Deterministic in-scope check (deny wins, default deny).")
|
||||
ap.add_argument("targets", nargs="*", help="hosts/URLs to check")
|
||||
ap.add_argument("--md", help="path to a scaffolded scope.md")
|
||||
ap.add_argument("--scope-file", help="path to an engagement scope.json")
|
||||
ap.add_argument("--in-scope", action="append", default=[], metavar="PATTERN",
|
||||
help="in-scope pattern (repeatable)")
|
||||
ap.add_argument("--out-of-scope", action="append", default=[], metavar="PATTERN",
|
||||
help="out-of-scope pattern (repeatable)")
|
||||
ap.add_argument("--selftest", action="store_true", help="run the built-in self-test")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
if args.selftest:
|
||||
_selftest()
|
||||
return 0
|
||||
|
||||
if args.md:
|
||||
scope = _scope_from_md(args.md)
|
||||
elif args.scope_file:
|
||||
scope = Scope.load(args.scope_file)
|
||||
elif args.in_scope or args.out_of_scope:
|
||||
scope = Scope(in_scope=args.in_scope, out_of_scope=args.out_of_scope)
|
||||
else:
|
||||
ap.error("provide --md, --scope-file, or --in-scope/--out-of-scope patterns")
|
||||
|
||||
if not args.targets:
|
||||
ap.error("no targets given")
|
||||
|
||||
worst = 0
|
||||
for t in args.targets:
|
||||
reason = scope.reject_reason(t)
|
||||
if reason is None:
|
||||
print(f"IN-SCOPE {t}")
|
||||
else:
|
||||
print(f"OUT-OF-SCOPE {t} ({reason})")
|
||||
worst = 1
|
||||
return worst
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_selftest()
|
||||
import sys
|
||||
sys.exit(_main())
|
||||
@@ -0,0 +1,31 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "claude-bughunter"
|
||||
version = "2.1.0"
|
||||
description = "cbh — terminal-native recon/classify/triage/report runner for the Claude-BugHunter skill bundle"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
license = { text = "MIT" }
|
||||
authors = [{ name = "Sachin Sharma" }]
|
||||
keywords = ["security", "bug-bounty", "recon", "claude", "pentest"]
|
||||
dependencies = []
|
||||
|
||||
[project.optional-dependencies]
|
||||
# `requests` is optional — cbh falls back to urllib when it's absent.
|
||||
http = ["requests"]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/elementalsouls/Claude-BugHunter"
|
||||
Documentation = "https://github.com/elementalsouls/Claude-BugHunter/blob/main/docs/cbh-cli.md"
|
||||
|
||||
[project.scripts]
|
||||
cbh = "cbh.cli:main"
|
||||
|
||||
[tool.setuptools]
|
||||
packages = ["cbh"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
cbh = ["data/*.json"]
|
||||
Executable → Regular
+8
-748
@@ -1,755 +1,15 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Thin shim — run the cbh CLI from a clone without installing.
|
||||
|
||||
Kept for backward compatibility with `scripts/cbh.py <cmd>` and the existing
|
||||
symlink instructions. The implementation now lives in `cbh/cli.py`, which is also
|
||||
the pip console-script entry point (`cbh`). See docs/cbh-cli.md.
|
||||
"""
|
||||
cbh — claude-bughunter CLI.
|
||||
|
||||
Bridges the repo's skill content into a real runner. Four subcommands compose
|
||||
the engagement loop:
|
||||
|
||||
cbh recon <target> passive subdomain enum + live-host probe + URL
|
||||
classification. Writes recon/<target>/.
|
||||
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"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 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) -> 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.
|
||||
|
||||
Resolution order:
|
||||
1. explicit proxy_url argument
|
||||
2. CBH_BURP_PROXY env var
|
||||
3. HTTPS_PROXY / HTTP_PROXY env vars
|
||||
4. fallback: auto-detect default Burp on http://127.0.0.1:8080 (only if --burp flag)
|
||||
"""
|
||||
global _HTTP_OPENER
|
||||
if not proxy_url:
|
||||
proxy_url = os.environ.get("CBH_BURP_PROXY") or os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY")
|
||||
if not proxy_url:
|
||||
_HTTP_OPENER = None
|
||||
return False, "direct (no proxy)"
|
||||
|
||||
# Disable TLS verification when going through Burp (its CA isn't typically trusted)
|
||||
import ssl
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
# WARNING: TLS verification is disabled for Burp proxy traffic.
|
||||
# Use --proxy only in isolated lab environments — not on production targets.
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
print("[warning] TLS certificate verification is DISABLED — proxy mode active. "
|
||||
"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}"
|
||||
|
||||
|
||||
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:
|
||||
return set()
|
||||
try:
|
||||
rows = json.loads(body)
|
||||
except Exception:
|
||||
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
|
||||
if getattr(args, "proxy", None):
|
||||
proxy_url = args.proxy
|
||||
elif getattr(args, "burp", False):
|
||||
proxy_url = detect_burp() or "http://127.0.0.1:8080"
|
||||
configured, mode = configure_http_proxy(proxy_url)
|
||||
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
|
||||
out_dir = REPO_ROOT / "recon" / target
|
||||
resolved = out_dir.resolve()
|
||||
safe = (REPO_ROOT / "recon").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}")
|
||||
|
||||
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))
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 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
|
||||
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": list(REPORTS_DIR.glob("hunt-*.md")) if REPORTS_DIR.exists() else [],
|
||||
}
|
||||
|
||||
|
||||
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 = REPORTS_DIR / f"{skill}.md"
|
||||
if report.exists():
|
||||
say(f" 📖 Pattern Library: {color(str(report), 'cyan')}")
|
||||
skill_path = SKILLS_DIR / skill / "SKILL.md"
|
||||
if skill_path.exists():
|
||||
say(f" 📋 Skill: {color(str(skill_path), '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
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 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")
|
||||
_add_proxy_args(p_recon)
|
||||
p_recon.set_defaults(func=cmd_recon)
|
||||
|
||||
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)
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from cbh.cli import main # noqa: E402
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
gen_skill_index.py — build cbh/data/skill_index.json from the repo.
|
||||
|
||||
The `cbh` CLI needs two things at runtime: each skill's `description:` frontmatter
|
||||
(for `cbh classify`'s matcher) and the list of disclosed-report filenames (shown as
|
||||
pointers). When `cbh` is pip-installed without the full repo, it falls back to this
|
||||
generated index instead of reading skills/ and docs/disclosed-reports/ directly.
|
||||
|
||||
Run after adding/editing skills:
|
||||
python3 scripts/gen_skill_index.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
SKILLS = REPO / "skills"
|
||||
REPORTS = REPO / "docs" / "disclosed-reports"
|
||||
OUT = REPO / "cbh" / "data" / "skill_index.json"
|
||||
|
||||
DESC_RE = re.compile(r"^description:\s*(.+?)(?=\n[a-z_]+:|^---|\Z)", re.M | re.S)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
skills: dict[str, str] = {}
|
||||
for d in sorted(SKILLS.iterdir()):
|
||||
sm = d / "SKILL.md"
|
||||
if not d.is_dir() or not sm.exists():
|
||||
continue
|
||||
m = DESC_RE.search(sm.read_text(encoding="utf-8"))
|
||||
if m:
|
||||
desc = m.group(1).strip().strip('"').strip("'").strip()
|
||||
skills[d.name] = desc[:2000]
|
||||
|
||||
reports = sorted(p.name for p in REPORTS.glob("hunt-*.md")) if REPORTS.exists() else []
|
||||
|
||||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUT.write_text(json.dumps(
|
||||
{"skills": skills, "reports": reports}, indent=1, sort_keys=True) + "\n",
|
||||
encoding="utf-8")
|
||||
print(f"wrote {OUT.relative_to(REPO)} — {len(skills)} skills, {len(reports)} reports")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -232,6 +232,38 @@ Thumbs.db
|
||||
*.key
|
||||
GITIGNORE
|
||||
|
||||
# ============== recon manifest ingestion (optional) ==============
|
||||
# If `cbh recon <target>` produced a manifest, seed scope + notes from it.
|
||||
# Resolve from: 2nd arg → $HUNT_MANIFEST → ./recon/<target>/manifest.json.
|
||||
local manifest="${2:-${HUNT_MANIFEST:-}}"
|
||||
if [ -z "$manifest" ]; then
|
||||
for c in "./recon/$target/manifest.json" "recon/$target/manifest.json" "$HOME/recon/$target/manifest.json"; do
|
||||
[ -f "$c" ] && manifest="$c" && break
|
||||
done
|
||||
fi
|
||||
if [ -n "$manifest" ] && [ -f "$manifest" ] && command -v python3 >/dev/null 2>&1; then
|
||||
python3 - "$manifest" "$dir" <<'PYIN'
|
||||
import json, sys
|
||||
try:
|
||||
m = json.load(open(sys.argv[1], encoding="utf-8"))
|
||||
except Exception as e:
|
||||
print(f" (recon manifest present but unreadable: {e})"); sys.exit(0)
|
||||
d = sys.argv[2]
|
||||
hosts = [a["host"] for a in m.get("assets", []) if a.get("url")]
|
||||
ranked = m.get("ranked_surface", [])
|
||||
with open(d + "/scope.md", "a", encoding="utf-8") as f:
|
||||
f.write("\n## Recon-seeded live hosts (from cbh manifest — VERIFY in-scope before testing)\n\n")
|
||||
for h in hosts:
|
||||
f.write(f"- {h}\n")
|
||||
with open(d + "/notes.md", "a", encoding="utf-8") as f:
|
||||
f.write("\n## Ranked surface (from recon manifest)\n\n")
|
||||
for r in ranked:
|
||||
cls = ", ".join(r.get("bug_classes", [])) or "no class match"
|
||||
f.write(f"- [{r.get('priority','?')}] {r.get('url') or r.get('host')} - {cls}\n")
|
||||
print(f" ✓ Ingested recon manifest: {len(hosts)} live hosts -> scope.md, {len(ranked)} ranked -> notes.md")
|
||||
PYIN
|
||||
fi
|
||||
|
||||
# ============== confirmation ==============
|
||||
echo "Initialized $dir with CLAUDE.md and engagement template."
|
||||
echo "cd $dir to start hacking."
|
||||
|
||||
+92
-7
@@ -22,20 +22,37 @@
|
||||
# --normalize-frontmatter
|
||||
# strip non-standard keys (sources/report_count) from the NON-Claude
|
||||
# copies — only needed if a harness rejects unknown frontmatter keys
|
||||
# --no-shell don't modify any shell rc; just print the 'source hunt.sh' line to add
|
||||
# --uninstall remove this bundle's footprint via the install manifest; skills a
|
||||
# sibling bundle (e.g. claude-osint) still owns are kept
|
||||
# -h|--help show this help
|
||||
#
|
||||
# Idempotent. Existing skills/commands are backed up OUTSIDE the loading path
|
||||
# Idempotent. Re-runs skip skills already installed and identical; otherwise the
|
||||
# existing skill/command is backed up OUTSIDE the loading path
|
||||
# (~/.claude/install-backups/<ts>/) so backups never load as duplicate skills.
|
||||
# An install manifest is written to ~/.claude/.skill-manifests/ for clean uninstall.
|
||||
# Requires: bash. (--burp-mcp also requires python3.)
|
||||
# =====================================================================
|
||||
set -e
|
||||
|
||||
# Line endings: this repo ships a .gitattributes (eol=lf) so fresh clones are
|
||||
# LF-clean on every platform, including Windows/WSL. An OLD checkout that already
|
||||
# picked up CRLF cannot be rescued from inside this script (bash aborts on a
|
||||
# CRLF compound statement before any guard could run) — normalize it once with
|
||||
# git add --renormalize . && git checkout . (or re-clone).
|
||||
REPO_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd )"
|
||||
BACKUP_DEST="$HOME/.claude/install-backups/$(date +%Y%m%d-%H%M%S)"
|
||||
|
||||
usage() { sed -n '2,30p' "$0" | sed 's/^#\{0,1\} \{0,1\}//'; }
|
||||
# Footprint tracking: each bundle records what it placed in ~/.claude here, so
|
||||
# uninstall removes only its own files and KEEPS skills a sibling bundle (e.g.
|
||||
# claude-osint) still owns. The two recon skills are co-owned by both bundles.
|
||||
BUNDLE_NAME="claude-bughunter"
|
||||
MANIFEST_DIR="$HOME/.claude/.skill-manifests"
|
||||
MANIFEST="$MANIFEST_DIR/$BUNDLE_NAME.txt"
|
||||
|
||||
DO_AGENTS=0; DO_HERMES=0; DO_MCP=0; NORMALIZE=0; DETECT=0
|
||||
usage() { sed -n '2,/^# ===/p' "$0" | sed 's/^#\{0,1\} \{0,1\}//'; }
|
||||
|
||||
DO_AGENTS=0; DO_HERMES=0; DO_MCP=0; NORMALIZE=0; DETECT=0; DO_UNINSTALL=0; NO_SHELL=0
|
||||
HAS_CLAUDE=0; HAS_OPENCODE=0; HAS_CODEX=0; HAS_HERMES=0
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
@@ -44,12 +61,56 @@ while [ $# -gt 0 ]; do
|
||||
--all) DETECT=1 ;;
|
||||
--burp-mcp) DO_MCP=1 ;;
|
||||
--normalize-frontmatter) NORMALIZE=1 ;;
|
||||
--no-shell) NO_SHELL=1 ;;
|
||||
--uninstall) DO_UNINSTALL=1 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "Unknown option: $1 (try --help)" >&2; exit 2 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
# === Uninstall: remove only our footprint; keep skills a sibling bundle owns ===
|
||||
uninstall_bundle() {
|
||||
if [ ! -f "$MANIFEST" ]; then
|
||||
echo "No manifest at $MANIFEST — nothing tracked to uninstall."
|
||||
echo "(Installed before manifests existed? See INSTALL.md for manual removal.)"
|
||||
return 0
|
||||
fi
|
||||
echo "Uninstalling $BUNDLE_NAME using $MANIFEST"
|
||||
local rel target other owned removed=0 kept=0
|
||||
while IFS= read -r rel; do
|
||||
[ -z "$rel" ] && continue
|
||||
target="$HOME/.claude/$rel"
|
||||
owned=0
|
||||
for other in "$MANIFEST_DIR"/*.txt; do
|
||||
[ -e "$other" ] || continue
|
||||
[ "$other" = "$MANIFEST" ] && continue
|
||||
if grep -qxF "$rel" "$other" 2>/dev/null; then owned=1; break; fi
|
||||
done
|
||||
if [ "$owned" = "1" ]; then
|
||||
kept=$((kept + 1)) # another bundle still owns it — keep
|
||||
else
|
||||
rm -rf "$target"; removed=$((removed + 1))
|
||||
fi
|
||||
done < "$MANIFEST"
|
||||
rm -f "$MANIFEST"
|
||||
echo " ✓ removed $removed item(s); kept $kept still owned by another bundle"
|
||||
# The hunt.sh rc source line is ours alone — strip it from shell rc files.
|
||||
for rc in "$HOME/.zshrc" "$HOME/.bashrc" "${ZDOTDIR:-}/.zshrc"; do
|
||||
[ -f "$rc" ] || continue
|
||||
if grep -q "claude/scripts/hunt.sh" "$rc" 2>/dev/null; then
|
||||
sed -i.bak '/claude\/scripts\/hunt.sh/d; /Bug-bounty engagement scaffolding/d' "$rc"
|
||||
echo " ✓ removed hunt.sh source line from $rc (backup: $rc.bak)"
|
||||
fi
|
||||
done
|
||||
echo "Done. (Install backups remain under ~/.claude/install-backups/.)"
|
||||
}
|
||||
|
||||
if [ "$DO_UNINSTALL" = "1" ]; then
|
||||
uninstall_bundle
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --all → detect which harnesses are actually installed (binary on PATH, or its standard
|
||||
# config dir present) and route to each. Claude always; ~/.agents only if Codex is present
|
||||
# (OpenCode reads ~/.claude/skills directly); ~/.hermes only if Hermes is present.
|
||||
@@ -82,6 +143,11 @@ install_skills() {
|
||||
for skill_dir in "$REPO_DIR/skills"/*/; do
|
||||
name="$(basename "$skill_dir")"
|
||||
if [ -d "$dest/$name" ] && [ ! -L "$dest/$name" ]; then
|
||||
# Already present and byte-identical (e.g. a sibling bundle installed the
|
||||
# same shared skill) → skip; no needless backup, no "last installer wins".
|
||||
if diff -rq --exclude=__pycache__ "$skill_dir" "$dest/$name" >/dev/null 2>&1; then
|
||||
continue
|
||||
fi
|
||||
mkdir -p "$BACKUP_DEST/$label"
|
||||
mv "$dest/$name" "$BACKUP_DEST/$label/$name"
|
||||
fi
|
||||
@@ -106,6 +172,7 @@ if [ -d "$REPO_DIR/commands" ]; then
|
||||
[ -e "$cmd_file" ] || continue
|
||||
cmd_name="$(basename "$cmd_file")"
|
||||
if [ -f "$COMMANDS_DEST/$cmd_name" ] && [ ! -L "$COMMANDS_DEST/$cmd_name" ]; then
|
||||
if cmp -s "$cmd_file" "$COMMANDS_DEST/$cmd_name"; then continue; fi
|
||||
mkdir -p "$BACKUP_DEST/commands"
|
||||
mv "$COMMANDS_DEST/$cmd_name" "$BACKUP_DEST/commands/$cmd_name"
|
||||
fi
|
||||
@@ -129,23 +196,41 @@ elif [ -f "$HOME/.zshrc" ]; then
|
||||
elif [ -f "$HOME/.bashrc" ]; then
|
||||
SHELL_RC="$HOME/.bashrc"
|
||||
fi
|
||||
if [ -n "$SHELL_RC" ]; then
|
||||
if [ "$NO_SHELL" = "1" ]; then
|
||||
echo " --no-shell: leaving your shell rc untouched. To enable the 'hunt' command,"
|
||||
echo " add this one line to your ~/.zshrc or ~/.bashrc yourself:"
|
||||
echo " source ~/.claude/scripts/hunt.sh"
|
||||
elif [ -n "$SHELL_RC" ]; then
|
||||
if grep -q "claude/scripts/hunt.sh" "$SHELL_RC" 2>/dev/null; then
|
||||
echo " ✓ hunt.sh already sourced from $SHELL_RC"
|
||||
else
|
||||
echo "" >> "$SHELL_RC"
|
||||
echo "# Bug-bounty engagement scaffolding (bug-bounty-claude-skills)" >> "$SHELL_RC"
|
||||
echo "source ~/.claude/scripts/hunt.sh" >> "$SHELL_RC"
|
||||
echo " ✓ Added 'source ~/.claude/scripts/hunt.sh' to $SHELL_RC"
|
||||
echo " ✓ Added this line to $SHELL_RC (re-run with --no-shell to skip this):"
|
||||
echo " source ~/.claude/scripts/hunt.sh"
|
||||
fi
|
||||
else
|
||||
echo " ⚠ Could not detect shell rc file. Manually add:"
|
||||
echo " source ~/.claude/scripts/hunt.sh"
|
||||
echo " ⚠ Could not detect a shell rc file. Add this line yourself:"
|
||||
echo " source ~/.claude/scripts/hunt.sh"
|
||||
fi
|
||||
# shellcheck disable=SC1091
|
||||
source "$SCRIPTS_DEST/hunt.sh" 2>/dev/null || true
|
||||
echo ""
|
||||
|
||||
# === Write install manifest (footprint tracking for clean uninstall) ===
|
||||
mkdir -p "$MANIFEST_DIR"
|
||||
{
|
||||
for d in "$REPO_DIR/skills"/*/; do echo "skills/$(basename "$d")"; done
|
||||
if [ -d "$REPO_DIR/commands" ]; then
|
||||
for c in "$REPO_DIR/commands"/*.md; do [ -e "$c" ] && echo "commands/$(basename "$c")"; done
|
||||
fi
|
||||
echo "scripts/hunt.sh"
|
||||
} > "$MANIFEST"
|
||||
echo " ✓ Install manifest ($(wc -l < "$MANIFEST" | tr -d ' ') entries) → $MANIFEST"
|
||||
echo " Uninstall later with: bash scripts/install.sh --uninstall"
|
||||
echo ""
|
||||
|
||||
# === Extra harness targets (skills only) ===
|
||||
if [ "$DO_AGENTS" = "1" ]; then
|
||||
install_skills "$HOME/.agents/skills" "agents"
|
||||
|
||||
@@ -2,6 +2,134 @@
|
||||
name: offensive-osint
|
||||
description: "Operational arsenal for authorized external red-team and bug-bounty recon. Concrete probes, wordlists, regexes, dorks, curl one-liners for: subdomain enum, GraphQL/Swagger/REST discovery, identity fabric (Entra/Okta/ADFS/Google/SAML/M365 deep — Teams/SharePoint/OneDrive), cloud bucket enum (S3/GCS/Azure), CDN/WAF bypass, origin discovery, vendor fingerprinting (Citrix/F5/Pulse/Fortinet/PaloAlto/Cisco/VMware), CI/CD exposure, 48-pattern secret-scan catalog (AWS/GCP/GitHub/Stripe/Slack/Anthropic/OpenAI/Atlassian/DataDog/npm/PyPI), Postman workspaces, breach correlation (HudsonRock/HIBP/DeHashed/IntelX), TLS/JA3 audit, certificate transparency, JS endpoint extraction, package registry leaks, mobile/APK recon, sat imagery, sector-specific recon (healthcare DICOM, finance SWIFT, ICS/SCADA Modbus/BACnet). Detail content in 15 modular reference files, loaded on demand. Use for any authorized recon: scoping, asset discovery, attack-path mapping, secret triage, severity scoring."
|
||||
version: 3.0.0
|
||||
triggers:
|
||||
- external recon
|
||||
- external red team
|
||||
- red team external
|
||||
- attack surface management
|
||||
- ASM
|
||||
- bug bounty recon
|
||||
- bug bounty
|
||||
- reconnaissance
|
||||
- footprinting
|
||||
- asset discovery
|
||||
- swagger discovery
|
||||
- openapi discovery
|
||||
- graphql introspection
|
||||
- graphql discovery
|
||||
- subdomain enumeration
|
||||
- subdomain takeover
|
||||
- cloud bucket enumeration
|
||||
- bucket enum
|
||||
- S3 enum
|
||||
- GCS enum
|
||||
- Azure blob enum
|
||||
- identity fabric
|
||||
- SSO discovery
|
||||
- IdP fingerprinting
|
||||
- tenant fingerprinting
|
||||
- okta enum
|
||||
- entra enum
|
||||
- azure AD enum
|
||||
- ADFS enum
|
||||
- SAML metadata
|
||||
- mobile recon
|
||||
- APK analysis
|
||||
- mobile attack surface
|
||||
- secret scanning
|
||||
- secret leak
|
||||
- leaked credential
|
||||
- github dorking
|
||||
- google dorking
|
||||
- bing dorking
|
||||
- DDG dorking
|
||||
- postman workspace
|
||||
- stack exchange OSINT
|
||||
- breach lookup
|
||||
- have I been pwned
|
||||
- HudsonRock cavalier
|
||||
- infostealer
|
||||
- dehashed
|
||||
- intelx
|
||||
- shodan recon
|
||||
- censys recon
|
||||
- certificate transparency
|
||||
- crt.sh
|
||||
- JARM
|
||||
- favicon mmh3
|
||||
- JS endpoint extraction
|
||||
- sourcemap leak
|
||||
- copy paste probes
|
||||
- curl one-liner
|
||||
- email security analysis
|
||||
- SPF DMARC DKIM
|
||||
- origin discovery
|
||||
- CDN bypass
|
||||
- WAF bypass
|
||||
- vendor product fingerprints
|
||||
- Citrix Netscaler
|
||||
- F5 BIG-IP
|
||||
- Pulse Secure
|
||||
- FortiGate
|
||||
- PaloAlto GlobalProtect
|
||||
- Cisco AnyConnect
|
||||
- VMware vCenter
|
||||
- cloud native fingerprint
|
||||
- Lambda function URL
|
||||
- Cloud Run
|
||||
- kubernetes exposure
|
||||
- kubelet
|
||||
- etcd
|
||||
- CI CD exposure
|
||||
- Jenkins recon
|
||||
- GitLab self-hosted
|
||||
- GitHub Actions secrets
|
||||
- documentation leak
|
||||
- Notion public
|
||||
- Confluence anonymous
|
||||
- Trello board
|
||||
- WHOIS RDAP
|
||||
- DNS record catalog
|
||||
- Wayback CDX
|
||||
- LinkedIn enumeration
|
||||
- job posting tech stack
|
||||
- Slack workspace discovery
|
||||
- Discord server discovery
|
||||
- npm token leak
|
||||
- PyPI token leak
|
||||
- Docker Hub leak
|
||||
- sat imagery physical recon
|
||||
- TLS deep audit
|
||||
- JA3 JA4
|
||||
- reverse DNS sweep
|
||||
- IPv6 enumeration
|
||||
- CVE prioritization
|
||||
- EPSS scoring
|
||||
- CISA KEV
|
||||
- vulnerability prioritization
|
||||
- tooling install
|
||||
- sector specific recon
|
||||
- healthcare DICOM
|
||||
- finance SWIFT
|
||||
- ICS SCADA
|
||||
- Modbus
|
||||
- BACnet
|
||||
- post discovery workflow
|
||||
- JWT triage
|
||||
- AWS key triage
|
||||
- GraphQL field suggestion
|
||||
- Anthropic API key
|
||||
- OpenAI API key
|
||||
- Microsoft 365 deep
|
||||
- Teams federation
|
||||
- SharePoint enum
|
||||
- OneDrive enum
|
||||
- hackerone reference
|
||||
- h1 hacktivity
|
||||
- disclosed reports
|
||||
- community bug reports
|
||||
- prior disclosures
|
||||
- bug bounty reference
|
||||
---
|
||||
|
||||
# Offensive OSINT — External Red-Team Arsenal
|
||||
@@ -108,7 +236,7 @@ This skill is a **lean operational index**. Most concrete data (wordlists, regex
|
||||
| `severity-matrix.md` | 80+ worked examples mapping observed conditions → finding severity (CRITICAL/HIGH/MEDIUM/LOW/INFO) | severity decision, finding severity, severity matrix |
|
||||
| `sector-notes.md` | Recon notes for healthcare (DICOM), finance (SWIFT), ICS/SCADA (Modbus/BACnet), IoT, government | sector specific recon, healthcare DICOM, finance SWIFT, ICS SCADA, Modbus, BACnet |
|
||||
| `tooling-install.md` | Quick-install one-liners for Subfinder, Amass, httpx, nuclei, gau, katana, gowitness, dnsx, mapcidr, naabu, sslyze, testssl.sh, etc. | tooling install, install subfinder, install nuclei, install httpx |
|
||||
| `helpers-and-automation.md` | AI-assisted OSINT, archiving & evidence preservation, automation & workflow patterns, cross-module sidecar coordination, runnable secret_scan.py helper notes | AI-assisted OSINT, evidence preservation, automation, sidecar |
|
||||
| `helpers-and-automation.md` | AI-assisted OSINT, archiving & evidence preservation, automation & workflow patterns, cross-module sidecar coordination, runnable secret_scan.py / h1_reference.py / dashboard.py helper notes | AI-assisted OSINT, evidence preservation, automation, sidecar, hacktivity reference, recon console |
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -189,3 +189,43 @@ Output is JSONL — one finding per line — drops cleanly into `jq` for filteri
|
||||
|
||||
---
|
||||
|
||||
## 49. Runnable Helper — `h1_reference.py`
|
||||
|
||||
HackerOne Hacktivity reference agent. Queries HackerOne's **public** GraphQL API for
|
||||
disclosed reports during recon — no API key required (max 50 results/page). Use it to
|
||||
surface validated techniques and business-impact framing for the target's tech stack
|
||||
before you hunt. Pure stdlib.
|
||||
|
||||
```bash
|
||||
# Best-validated techniques to start from
|
||||
python3 h1_reference.py --top-voted --limit 25
|
||||
|
||||
# Highest-bounty reports (signal for impact framing)
|
||||
python3 h1_reference.py --top-bounty --limit 25
|
||||
|
||||
# Scope to a class / CWE / program
|
||||
python3 h1_reference.py --query "idor" --severity high critical
|
||||
python3 h1_reference.py --cwe "CWE-918" --program <handle>
|
||||
python3 h1_reference.py --lookup-program <handle>
|
||||
```
|
||||
|
||||
Pairs with the bundled `docs/disclosed-reports/` pattern libraries: `h1_reference.py`
|
||||
pulls live disclosed reports; the pattern libraries are the curated per-class digests.
|
||||
|
||||
## 50. Runnable Helper — `dashboard.py`
|
||||
|
||||
Zero-dependency localhost recon console that wraps `secret_scan.py` and
|
||||
`h1_reference.py` behind a stdlib web UI. Binds to `127.0.0.1` by default — the scan
|
||||
surface can read arbitrary local files you point it at, so it is never exposed to the
|
||||
network.
|
||||
|
||||
```bash
|
||||
python3 dashboard.py # http://127.0.0.1:8765
|
||||
python3 dashboard.py --port 9000 # custom port
|
||||
```
|
||||
|
||||
Use it for a fast visual pass — paste a blob or point at a path to aggregate secrets by
|
||||
severity/category, and browse disclosed-report references — without leaving the box.
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
archivo-black.woff2
|
||||
====================
|
||||
|
||||
Font: Archivo Black
|
||||
Designer: Omnibus-Type
|
||||
License: SIL Open Font License, Version 1.1 (OFL-1.1)
|
||||
Source: https://fonts.google.com/specimen/Archivo+Black
|
||||
https://github.com/Omnibus-Type/Archivo
|
||||
|
||||
This font is bundled and served locally by dashboard.py (route
|
||||
/font/display.woff2) so the recon console has no runtime CDN dependency.
|
||||
The OFL permits bundling and redistribution; this file preserves the
|
||||
required attribution. Full license text: https://openfontlicense.org/
|
||||
Binary file not shown.
@@ -0,0 +1,968 @@
|
||||
#!/usr/bin/env python3
|
||||
"""dashboard.py — local recon console for claude-osint.
|
||||
|
||||
A zero-dependency (stdlib-only) localhost web UI that wraps the bundled
|
||||
recon helpers:
|
||||
|
||||
* secret_scan.py — scan a filesystem path or pasted blob for 48 secret
|
||||
patterns, with severity/category aggregation.
|
||||
* h1_reference.py — query HackerOne's public disclosed-report corpus.
|
||||
|
||||
Design goals mirror the rest of the repo: no third-party packages, runs on
|
||||
any box with python3, and binds to 127.0.0.1 by default so the scan surface
|
||||
(which can read arbitrary local files the operator points it at) is never
|
||||
exposed to the network.
|
||||
|
||||
Usage:
|
||||
python3 dashboard.py # http://127.0.0.1:8765
|
||||
python3 dashboard.py --port 9000
|
||||
python3 dashboard.py --open # also open a browser tab
|
||||
python3 dashboard.py --host 0.0.0.0 # EXPOSE to LAN (prints a warning)
|
||||
|
||||
Exit codes:
|
||||
0 — clean shutdown (Ctrl-C)
|
||||
2 — invalid arguments / bind failure
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import webbrowser
|
||||
from collections import Counter
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
# Make the bundled helpers importable regardless of CWD.
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
if SCRIPT_DIR not in sys.path:
|
||||
sys.path.insert(0, SCRIPT_DIR)
|
||||
|
||||
try:
|
||||
from secret_scan import scan_path, scan_text # noqa: E402
|
||||
except ImportError as exc: # pragma: no cover - defensive
|
||||
print(f"[!] Could not import secret_scan.py from {SCRIPT_DIR}: {exc}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
H1_SCRIPT = os.path.join(SCRIPT_DIR, "h1_reference.py")
|
||||
|
||||
# Cap returned findings so a huge tree can't produce a multi-MB JSON payload.
|
||||
MAX_FINDINGS = 5000
|
||||
# Hard wall-clock cap for the HackerOne subprocess.
|
||||
H1_TIMEOUT_SEC = 60
|
||||
|
||||
SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Core logic (kept separate from HTTP plumbing so it stays testable)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _summarize(findings: list[dict]) -> dict:
|
||||
"""Aggregate a flat findings list into dashboard summary counters."""
|
||||
by_sev = Counter(f["severity"] for f in findings)
|
||||
by_cat = Counter(f["category"] for f in findings)
|
||||
files = {f["source"] for f in findings}
|
||||
return {
|
||||
"total": len(findings),
|
||||
"by_severity": dict(by_sev),
|
||||
"by_category": dict(by_cat),
|
||||
"files": len(files),
|
||||
}
|
||||
|
||||
|
||||
def run_scan(target: str) -> dict:
|
||||
"""Scan a filesystem path (file or directory)."""
|
||||
target = os.path.expanduser(target.strip())
|
||||
if not target:
|
||||
return {"ok": False, "error": "Empty path."}
|
||||
if not os.path.exists(target):
|
||||
return {"ok": False, "error": f"Path does not exist: {target}"}
|
||||
|
||||
findings: list[dict] = []
|
||||
truncated = False
|
||||
for hit in scan_path(target):
|
||||
findings.append(hit)
|
||||
if len(findings) >= MAX_FINDINGS:
|
||||
truncated = True
|
||||
break
|
||||
|
||||
findings.sort(key=lambda f: (SEVERITY_ORDER.get(f["severity"], 9), f["source"], f["line"]))
|
||||
return {
|
||||
"ok": True,
|
||||
"target": target,
|
||||
"truncated": truncated,
|
||||
"summary": _summarize(findings),
|
||||
"findings": findings,
|
||||
}
|
||||
|
||||
|
||||
def run_scan_text(blob: str) -> dict:
|
||||
"""Scan a pasted text blob."""
|
||||
if not blob.strip():
|
||||
return {"ok": False, "error": "Empty input."}
|
||||
findings = list(scan_text(blob, source="<pasted>"))
|
||||
findings.sort(key=lambda f: (SEVERITY_ORDER.get(f["severity"], 9), f["line"]))
|
||||
return {
|
||||
"ok": True,
|
||||
"target": "<pasted>",
|
||||
"truncated": False,
|
||||
"summary": _summarize(findings),
|
||||
"findings": findings,
|
||||
}
|
||||
|
||||
|
||||
def run_h1(params: dict) -> dict:
|
||||
"""Shell out to h1_reference.py --json and parse the array it prints."""
|
||||
if not os.path.exists(H1_SCRIPT):
|
||||
return {"ok": False, "error": "h1_reference.py not found alongside dashboard."}
|
||||
|
||||
mode = params.get("mode", "top-voted")
|
||||
cmd = [sys.executable, H1_SCRIPT, "--json"]
|
||||
if mode == "top-voted":
|
||||
cmd.append("--top-voted")
|
||||
elif mode == "top-bounty":
|
||||
cmd.append("--top-bounty")
|
||||
|
||||
query = (params.get("query") or "").strip()
|
||||
if query:
|
||||
cmd += ["--query", query]
|
||||
program = (params.get("program") or "").strip()
|
||||
if program:
|
||||
cmd += ["--program", program]
|
||||
|
||||
try:
|
||||
pages = max(1, min(int(params.get("pages", 3)), 20))
|
||||
except (TypeError, ValueError):
|
||||
pages = 3
|
||||
try:
|
||||
limit = max(1, min(int(params.get("limit", 25)), 50))
|
||||
except (TypeError, ValueError):
|
||||
limit = 25
|
||||
cmd += ["--pages", str(pages), "--limit", str(limit)]
|
||||
|
||||
severities = params.get("severities") or []
|
||||
if isinstance(severities, list) and severities:
|
||||
cmd += ["--severity", *[str(s) for s in severities]]
|
||||
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=H1_TIMEOUT_SEC,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"ok": False, "error": f"HackerOne query timed out after {H1_TIMEOUT_SEC}s."}
|
||||
except OSError as exc:
|
||||
return {"ok": False, "error": f"Failed to launch h1_reference.py: {exc}"}
|
||||
|
||||
if proc.returncode != 0:
|
||||
err = (proc.stderr or proc.stdout or "unknown error").strip()
|
||||
return {"ok": False, "error": err[:1000]}
|
||||
|
||||
try:
|
||||
results = json.loads(proc.stdout or "[]")
|
||||
except json.JSONDecodeError:
|
||||
return {"ok": False, "error": "Could not parse h1_reference.py output as JSON."}
|
||||
|
||||
return {"ok": True, "count": len(results), "results": results}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# HTTP layer
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "claude-osint-dashboard/1.0"
|
||||
|
||||
# Silence default per-request logging; keep stderr clean for warnings.
|
||||
def log_message(self, *args): # noqa: D401
|
||||
return
|
||||
|
||||
def _send_json(self, obj: dict, status: int = 200) -> None:
|
||||
body = json.dumps(obj).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _send_html(self, html: str) -> None:
|
||||
body = html.encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _send_static(self, path: str, content_type: str) -> None:
|
||||
try:
|
||||
with open(path, "rb") as fh:
|
||||
body = fh.read()
|
||||
except OSError:
|
||||
self._send_json({"ok": False, "error": "not found"}, status=404)
|
||||
return
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "max-age=86400")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _read_json_body(self) -> dict:
|
||||
length = int(self.headers.get("Content-Length", 0) or 0)
|
||||
if length <= 0:
|
||||
return {}
|
||||
raw = self.rfile.read(length)
|
||||
try:
|
||||
return json.loads(raw or b"{}")
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
|
||||
def do_GET(self): # noqa: N802
|
||||
if self.path in ("/", "/index.html"):
|
||||
self._send_html(PAGE_HTML)
|
||||
elif self.path == "/api/health":
|
||||
self._send_json({"ok": True, "service": "claude-osint-dashboard"})
|
||||
elif self.path == "/font/display.woff2":
|
||||
self._send_static(os.path.join(SCRIPT_DIR, "assets", "archivo-black.woff2"), "font/woff2")
|
||||
else:
|
||||
self._send_json({"ok": False, "error": "not found"}, status=404)
|
||||
|
||||
def do_POST(self): # noqa: N802
|
||||
body = self._read_json_body()
|
||||
try:
|
||||
if self.path == "/api/scan":
|
||||
self._send_json(run_scan(body.get("path", "")))
|
||||
elif self.path == "/api/scan-text":
|
||||
self._send_json(run_scan_text(body.get("text", "")))
|
||||
elif self.path == "/api/h1":
|
||||
self._send_json(run_h1(body))
|
||||
else:
|
||||
self._send_json({"ok": False, "error": "not found"}, status=404)
|
||||
except Exception as exc: # pragma: no cover - last-resort guard
|
||||
self._send_json({"ok": False, "error": f"internal error: {exc}"}, status=500)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Embedded single-page UI — tactical HUD command console.
|
||||
# No external assets / no CDN — fully offline.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
PAGE_HTML = r"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>claude / osint · recon.live</title>
|
||||
<style>
|
||||
/* bundled display face (served locally by the dashboard — no CDN). Falls back
|
||||
to Arial Black / system if the route is unavailable (e.g. file:// use). */
|
||||
@font-face {
|
||||
font-family: "Archivo Black";
|
||||
src: url("/font/display.woff2") format("woff2");
|
||||
font-weight: 900; font-style: normal; font-display: swap;
|
||||
}
|
||||
:root {
|
||||
--bg: #08060a;
|
||||
--bg-grid: rgba(255, 40, 60, 0.035);
|
||||
--panel: #100b0f;
|
||||
--panel-2: #150e13;
|
||||
--line: rgba(255, 60, 80, 0.14);
|
||||
--line-2: rgba(255, 255, 255, 0.06);
|
||||
--red: #ff2536;
|
||||
--red-soft:#ff5563;
|
||||
--red-dim: rgba(255, 37, 54, 0.55);
|
||||
--red-glow:rgba(255, 37, 54, 0.35);
|
||||
--white: #f3eef0;
|
||||
--muted: #7c7480;
|
||||
--muted-2: #56505a;
|
||||
--crit: #ff2536;
|
||||
--high: #ff8a3d;
|
||||
--med: #ffce4a;
|
||||
--low: #5fa8ff;
|
||||
--mono: "SFMono-Regular", ui-monospace, "JetBrains Mono", Menlo, Consolas, monospace;
|
||||
--disp: "Archivo Black", "Arial Black", system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { scrollbar-color: var(--red-dim) var(--bg); }
|
||||
body {
|
||||
margin: 0; background: var(--bg); color: var(--white);
|
||||
font-family: var(--mono); font-size: 13px; line-height: 1.45;
|
||||
background-image:
|
||||
radial-gradient(1000px 520px at 92% -8%, rgba(255,37,54,0.10), transparent 60%),
|
||||
radial-gradient(900px 600px at 6% 4%, rgba(255,37,54,0.05), transparent 55%),
|
||||
linear-gradient(var(--bg-grid) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--bg-grid) 1px, transparent 1px);
|
||||
background-size: auto, auto, 34px 34px, 34px 34px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
a { color: inherit; text-decoration: none; }
|
||||
.wrap { max-width: 2200px; margin: 0 auto; padding: 0 clamp(24px, 5vw, 120px); }
|
||||
.micro { font-size: 10.5px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--muted); }
|
||||
.red { color: var(--red); }
|
||||
.dot { color: var(--red); }
|
||||
|
||||
/* ---- top status bar ---- */
|
||||
.statusbar { display: flex; align-items: center; gap: 18px; padding: 13px 0; border-bottom: 1px solid var(--line); }
|
||||
.corner { width: 22px; height: 22px; border: 1px solid var(--red-dim); color: var(--red);
|
||||
display: grid; place-items: center; font-size: 13px; flex: none; }
|
||||
.statusbar .seg { display: flex; gap: 8px; align-items: center; }
|
||||
.statusbar .grow { flex: 1; }
|
||||
.statusbar .ctr { justify-content: center; }
|
||||
|
||||
/* ---- hero ---- */
|
||||
header.hero { display: flex; gap: 26px; align-items: flex-start; padding: 40px 0 30px; }
|
||||
.glyph { width: 96px; height: 96px; border: 1px solid var(--line); flex: none; position: relative;
|
||||
display: grid; place-items: center; background:
|
||||
radial-gradient(circle at 50% 50%, rgba(255,37,54,0.16), transparent 70%); }
|
||||
.glyph svg { filter: drop-shadow(0 0 10px var(--red-glow)); }
|
||||
.wordmark { display: flex; flex-direction: column; gap: 14px; }
|
||||
.wordmark h1 { margin: 0; font-family: var(--disp); font-weight: 900; letter-spacing: -0.03em;
|
||||
font-size: clamp(44px, 7vw, 88px); line-height: 0.86; }
|
||||
.wordmark h1 .c { color: var(--white); }
|
||||
.wordmark h1 .slash { color: var(--muted-2); font-weight: 400; }
|
||||
.wordmark h1 .o { color: var(--red); position: relative; text-shadow: 0 0 28px var(--red-glow); }
|
||||
.wordmark h1 .o::after { content:""; position:absolute; left:0; right:6px; bottom:-2px; height:3px;
|
||||
background: var(--red); box-shadow: 0 0 14px var(--red); }
|
||||
.sub { display: flex; align-items: center; gap: 9px; letter-spacing: 0.22em; }
|
||||
.cta { align-self: flex-start; margin-top: 6px; display: inline-flex; align-items: center; gap: 8px;
|
||||
background: var(--red); color: #1a0407; border: 0; font-family: var(--mono); font-weight: 800;
|
||||
font-size: 12px; letter-spacing: 0.1em; text-transform: uppercase; padding: 11px 20px; cursor: pointer;
|
||||
transition: filter .15s, box-shadow .15s, transform .05s; }
|
||||
.cta:hover { filter: brightness(1.1); box-shadow: 0 0 20px var(--red-glow); }
|
||||
.cta:active { transform: translateY(1px); }
|
||||
.lead { color: var(--muted); font-size: 12.5px; margin: 0 0 14px; max-width: 70ch; line-height: 1.5; }
|
||||
.hero .meta { margin-left: auto; text-align: right; display: flex; flex-direction: column; gap: 7px; padding-top: 4px; }
|
||||
.pill { align-self: flex-end; display: inline-flex; align-items: center; gap: 7px; color: var(--red);
|
||||
border: 1px solid var(--red-dim); padding: 4px 11px; font-size: 10.5px; letter-spacing: 0.16em;
|
||||
text-transform: uppercase; }
|
||||
.pill .bl { width: 6px; height: 6px; border-radius: 50%; background: var(--red); box-shadow: 0 0 8px var(--red);
|
||||
animation: pulse 2s ease-in-out infinite; }
|
||||
@keyframes pulse { 50% { opacity: 0.35; } }
|
||||
.meta .kv { font-size: 11px; color: var(--muted); }
|
||||
.meta .kv b { color: var(--white); font-weight: 600; }
|
||||
.meta a.kv:hover { color: var(--red); }
|
||||
|
||||
/* ---- section header ---- */
|
||||
.sechead { display: flex; align-items: center; gap: 14px; padding: 18px 0 16px; border-top: 1px solid var(--line); margin-top: 8px; }
|
||||
.sechead .bar { width: 3px; height: 15px; background: var(--red); box-shadow: 0 0 10px var(--red); }
|
||||
.sechead .title { color: var(--white); letter-spacing: 0.22em; text-transform: uppercase; font-size: 12px; font-weight: 600; }
|
||||
.sechead .grow { flex: 1; }
|
||||
|
||||
/* ---- tick corners ---- */
|
||||
.tick { position: relative; }
|
||||
.tick::before, .tick::after { content:""; position:absolute; width:9px; height:9px; pointer-events:none; }
|
||||
.tick::before { top:-1px; left:-1px; border-top:1px solid var(--red); border-left:1px solid var(--red); }
|
||||
.tick::after { bottom:-1px; right:-1px; border-bottom:1px solid var(--red); border-right:1px solid var(--red); }
|
||||
|
||||
/* ---- arsenal grid ---- */
|
||||
.arsenal { display: grid; grid-template-columns: repeat(4, 1fr); gap: 18px; padding-bottom: 8px; }
|
||||
.col > .colhead { display: flex; align-items: baseline; gap: 8px; margin-bottom: 12px; }
|
||||
.colhead .idx { color: var(--red); font-size: 11px; }
|
||||
.colhead .nm { color: var(--white); letter-spacing: 0.14em; text-transform: uppercase; font-size: 11px; }
|
||||
.acard { position: relative; border: 1px solid var(--line); background: linear-gradient(160deg, var(--panel), var(--bg));
|
||||
padding: 14px 15px; margin-bottom: 12px; transition: border-color .18s, transform .12s, background .18s; cursor: default; }
|
||||
.acard::before { content:""; position:absolute; left:0; top:0; bottom:0; width:2px; background: var(--red-dim); opacity:.3; }
|
||||
/* reference tiles, not buttons: a faint border lift only, no slide/glow affordance */
|
||||
.acard:hover { border-color: rgba(255,60,80,0.2); }
|
||||
.acard .t { color: var(--white); font-weight: 700; font-size: 14px; letter-spacing: -0.01em; }
|
||||
.acard .d { color: var(--muted); font-size: 11px; margin-top: 5px; }
|
||||
.acard .tag { position: absolute; top: 11px; right: 12px; color: var(--muted-2); font-size: 9.5px; letter-spacing: 0.1em;
|
||||
border: 1px solid var(--line-2); padding: 1px 5px; }
|
||||
|
||||
/* ---- kill chain ---- */
|
||||
.killchain { display: flex; align-items: stretch; gap: 0; padding: 6px 0 4px; }
|
||||
.kclabel { color: var(--muted); letter-spacing: 0.14em; font-size: 10px; text-transform: uppercase;
|
||||
align-self: center; width: 56px; line-height: 1.25; flex: none; }
|
||||
.kcseg { flex: 1; display: flex; justify-content: space-between; align-items: center; gap: 10px;
|
||||
border: 1px solid var(--line); padding: 11px 14px; background: var(--panel); }
|
||||
.kcseg.on { border-color: var(--red-dim); box-shadow: inset 0 0 18px rgba(255,37,54,0.06); }
|
||||
.kcseg .s { color: var(--white); font-size: 12px; letter-spacing: 0.06em; }
|
||||
.kcseg .s i { color: var(--red); font-style: normal; margin-right: 7px; }
|
||||
.kcseg .mode { color: var(--muted); font-size: 10px; letter-spacing: 0.1em; }
|
||||
.kcarrow { width: 26px; flex: none; display: grid; place-items: center; color: var(--red); }
|
||||
.kcarrow svg { display: block; }
|
||||
|
||||
/* ---- stats ---- */
|
||||
.stats { position: relative; display: grid; grid-template-columns: repeat(6, 1fr); border: 1px solid var(--line);
|
||||
margin: 8px 0 6px; background: linear-gradient(180deg, var(--panel), var(--bg)); }
|
||||
.stat { padding: 22px 20px; border-right: 1px solid var(--line); }
|
||||
.stat:last-child { border-right: 0; }
|
||||
.stat .n { font-family: var(--disp); font-weight: 900; color: var(--red); font-size: 34px; line-height: 1;
|
||||
text-shadow: 0 0 22px var(--red-glow); letter-spacing: -0.02em; }
|
||||
.stat .k { color: var(--muted); font-size: 10px; letter-spacing: 0.16em; text-transform: uppercase; margin-top: 9px; }
|
||||
|
||||
/* ---- footer strip ---- */
|
||||
.footstrip { display: flex; align-items: center; gap: 16px; padding: 20px 0 14px; border-top: 1px solid var(--line);
|
||||
margin-top: 16px; color: var(--muted); }
|
||||
.footstrip .grow { flex: 1; }
|
||||
.footstrip .ctr { color: var(--red); letter-spacing: 0.18em; font-size: 10.5px; }
|
||||
|
||||
/* ================= LIVE CONSOLE ================= */
|
||||
.console { margin: 6px 0 64px; }
|
||||
.tabs { display: flex; gap: 0; margin-bottom: 0; }
|
||||
.tabs button { background: var(--panel); border: 1px solid var(--line); border-bottom: 0; color: var(--muted);
|
||||
font: inherit; font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase; padding: 11px 20px;
|
||||
cursor: pointer; transition: color .15s, background .15s; }
|
||||
.tabs button + button { border-left: 0; }
|
||||
.tabs button:hover { color: var(--white); }
|
||||
.tabs button.active { color: var(--red); background: var(--panel-2); box-shadow: inset 0 2px 0 var(--red); }
|
||||
.panelbox { border: 1px solid var(--line); background: linear-gradient(180deg, var(--panel), var(--bg)); padding: 20px; }
|
||||
.panel { display: none; }
|
||||
.panel.active { display: block; animation: fade .22s ease; }
|
||||
@keyframes fade { from { opacity: 0; transform: translateY(3px);} to {opacity:1; transform:none;} }
|
||||
|
||||
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items: stretch; }
|
||||
input[type=text], textarea, select {
|
||||
background: var(--bg); color: var(--white); border: 1px solid var(--line);
|
||||
padding: 11px 13px; font-family: var(--mono); font-size: 12.5px; outline: none;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
input[type=text]:focus, textarea:focus, select:focus {
|
||||
border-color: var(--red-dim); box-shadow: 0 0 0 3px rgba(255,37,54,0.10);
|
||||
}
|
||||
input.grow { flex: 1; min-width: 240px; }
|
||||
textarea { width: 100%; min-height: 150px; resize: vertical; line-height: 1.5; }
|
||||
label.fld { display: flex; flex-direction: column; gap: 6px; font-size: 10px; color: var(--muted);
|
||||
letter-spacing: 0.1em; text-transform: uppercase; }
|
||||
|
||||
button.go { background: var(--red); color: #1a0407; border: 0; font-weight: 800; font-size: 12px;
|
||||
letter-spacing: 0.08em; text-transform: uppercase; padding: 11px 22px; cursor: pointer;
|
||||
transition: filter .15s, transform .05s; font-family: var(--mono); }
|
||||
button.go:hover { filter: brightness(1.12); box-shadow: 0 0 18px var(--red-glow); }
|
||||
button.go:active { transform: translateY(1px); }
|
||||
button.go:disabled { opacity: .5; cursor: progress; box-shadow: none; }
|
||||
button.ghost { background: var(--bg); color: var(--muted); border: 1px solid var(--line); font-size: 11px;
|
||||
letter-spacing: 0.08em; text-transform: uppercase; padding: 9px 14px; cursor: pointer; font-family: var(--mono); }
|
||||
button.ghost:hover { border-color: var(--red-dim); color: var(--red); }
|
||||
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 10px; margin: 20px 0; }
|
||||
.card { border: 1px solid var(--line); background: var(--panel); padding: 15px 16px; position: relative; }
|
||||
.card .n { font-family: var(--disp); font-weight: 900; font-size: 28px; line-height: 1; color: var(--white); }
|
||||
.card .k { font-size: 10px; color: var(--muted); letter-spacing: 0.12em; text-transform: uppercase; margin-top: 8px; }
|
||||
.card.sev-critical .n { color: var(--crit); } .card.sev-critical { border-color: rgba(255,37,54,0.4); }
|
||||
.card.sev-high .n { color: var(--high); }
|
||||
.card.sev-medium .n { color: var(--med); }
|
||||
.card.sev-low .n { color: var(--low); }
|
||||
|
||||
.toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin: 18px 0 10px; }
|
||||
.chip { font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; padding: 5px 11px; border: 1px solid var(--line);
|
||||
background: var(--bg); color: var(--muted-2); cursor: pointer; transition: all .12s; }
|
||||
.chip:hover { color: var(--white); }
|
||||
.chip.on { color: var(--white); border-color: var(--red-dim); background: var(--panel-2); }
|
||||
.chip.on[data-sev=critical] { color: var(--crit); border-color: var(--crit); }
|
||||
.chip.on[data-sev=high] { color: var(--high); border-color: var(--high); }
|
||||
.chip.on[data-sev=medium] { color: var(--med); border-color: var(--med); }
|
||||
.chip.on[data-sev=low] { color: var(--low); border-color: var(--low); }
|
||||
.toolbar .spacer { flex: 1; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
|
||||
thead th { text-align: left; color: var(--muted); font-weight: 600; font-size: 10px; letter-spacing: 0.1em;
|
||||
text-transform: uppercase; padding: 10px 12px; border-bottom: 1px solid var(--line);
|
||||
position: sticky; top: 0; background: var(--bg); }
|
||||
tbody td { padding: 10px 12px; border-bottom: 1px solid var(--line-2); vertical-align: top; }
|
||||
tbody tr:hover { background: rgba(255,37,54,0.04); }
|
||||
td.match { color: var(--white); word-break: break-all; }
|
||||
td.src { color: var(--muted); font-size: 11.5px; word-break: break-all; }
|
||||
.badge { font-size: 10px; font-weight: 700; padding: 2px 9px; letter-spacing: 0.04em; text-transform: uppercase; white-space: nowrap; }
|
||||
.badge.critical { background: rgba(255,37,54,0.16); color: var(--crit); }
|
||||
.badge.high { background: rgba(255,138,61,0.16); color: var(--high); }
|
||||
.badge.medium { background: rgba(255,206,74,0.16); color: var(--med); }
|
||||
.badge.low { background: rgba(95,168,255,0.16); color: var(--low); }
|
||||
td.pat { color: var(--red); font-size: 11.5px; }
|
||||
.copy { cursor: pointer; opacity: .5; margin-left: 8px; font-size: 10px; }
|
||||
.copy:hover { opacity: 1; color: var(--red); }
|
||||
|
||||
.empty, .err { padding: 28px; text-align: center; color: var(--muted); font-size: 12.5px; }
|
||||
.err { color: var(--crit); }
|
||||
.note { color: var(--high); font-size: 11.5px; margin: 10px 0; letter-spacing: 0.04em; }
|
||||
|
||||
.h1card { border: 1px solid var(--line); background: var(--panel); padding: 15px 16px; margin-bottom: 10px; }
|
||||
.h1card a { color: var(--red); }
|
||||
.h1card a:hover { text-shadow: 0 0 12px var(--red-glow); }
|
||||
.h1card .meta { color: var(--muted); font-size: 11px; margin-top: 8px; display: flex; gap: 16px; flex-wrap: wrap; }
|
||||
.spin::after { content: "▍"; animation: blink 1s steps(2) infinite; }
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
|
||||
/* designed keyboard focus — visible only for keyboard users, not mouse clicks */
|
||||
a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible {
|
||||
outline: 2px solid var(--red); outline-offset: 2px;
|
||||
}
|
||||
.tabs button:focus-visible { outline-offset: -2px; }
|
||||
.acard:focus-within { border-color: var(--red-dim); }
|
||||
|
||||
/* ---- tactical motion: scanline sweep + boot reveal (reduced-motion safe) ---- */
|
||||
.scan { position: fixed; inset: 0; pointer-events: none; z-index: 60; overflow: hidden; }
|
||||
.scan::before { content:""; position: absolute; left: 0; right: 0; top: 0; height: 160px;
|
||||
background: linear-gradient(180deg, transparent, rgba(255,37,54,0.05) 46%, rgba(255,90,99,0.07) 50%, transparent);
|
||||
animation: sweep 7.5s linear infinite; }
|
||||
@keyframes sweep { 0% { transform: translateY(-170px); } 100% { transform: translateY(100vh); } }
|
||||
@keyframes rise { from { opacity: 0; transform: translateY(11px); } to { opacity: 1; transform: none; } }
|
||||
.wrap > * { animation: rise .5s cubic-bezier(.16,1,.3,1) both; }
|
||||
.wrap > *:nth-child(2){ animation-delay:.05s } .wrap > *:nth-child(3){ animation-delay:.10s }
|
||||
.wrap > *:nth-child(4){ animation-delay:.15s } .wrap > *:nth-child(5){ animation-delay:.20s }
|
||||
.wrap > *:nth-child(6){ animation-delay:.25s } .wrap > *:nth-child(7){ animation-delay:.30s }
|
||||
.wrap > *:nth-child(8){ animation-delay:.35s } .wrap > *:nth-child(9){ animation-delay:.40s }
|
||||
.wrap > *:nth-child(10){ animation-delay:.45s }
|
||||
|
||||
/* honor reduced-motion: kill pulses, blinks, fades, sweep, boot reveal, transitions */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation: none !important; transition: none !important; }
|
||||
}
|
||||
|
||||
/* back-to-scan affordance — appears once you scroll past the console */
|
||||
.backscan { position: fixed; right: 20px; bottom: 20px; z-index: 70; background: var(--red); color: #1a0407;
|
||||
border: 0; font-family: var(--mono); font-weight: 800; font-size: 11px; letter-spacing: 0.1em;
|
||||
text-transform: uppercase; padding: 11px 16px; cursor: pointer; opacity: 0; transform: translateY(12px);
|
||||
pointer-events: none; transition: opacity .2s, transform .2s, box-shadow .15s, filter .15s;
|
||||
box-shadow: 0 6px 22px rgba(0,0,0,0.55); }
|
||||
.backscan.show { opacity: 1; transform: none; pointer-events: auto; }
|
||||
.backscan:hover { box-shadow: 0 0 22px var(--red-glow); filter: brightness(1.08); }
|
||||
|
||||
@media (max-width: 880px) {
|
||||
.arsenal { grid-template-columns: repeat(2, 1fr); }
|
||||
.stats { grid-template-columns: repeat(3, 1fr); }
|
||||
.killchain { flex-wrap: wrap; } .kclabel { width: 100%; margin-bottom: 6px; }
|
||||
.hero { flex-wrap: wrap; } .hero .meta { margin-left: 0; text-align: left; align-items: flex-start; }
|
||||
.statusbar .ctr { display: none; }
|
||||
}
|
||||
/* phones: one breakpoint stood between this and shipping on mobile */
|
||||
@media (max-width: 560px) {
|
||||
html, body { overflow-x: hidden; }
|
||||
* { min-width: 0; } /* flex children must be allowed to shrink */
|
||||
.sub { display: block; } /* subtitle wraps as text, not a rigid flex line */
|
||||
.sechead .micro { flex-basis: 100%; } /* section flags drop to their own line */
|
||||
.wrap { padding: 0 16px; }
|
||||
.statusbar { gap: 10px; }
|
||||
/* drop the left coordinates seg AND the long SYS seg — they can't fit a phone */
|
||||
.statusbar > div:nth-child(2), .statusbar #systag { display: none; }
|
||||
.statusbar .seg.micro { font-size: 9px; letter-spacing: 0.08em; }
|
||||
.hero > * { min-width: 0; }
|
||||
.wordmark, .meta { flex-basis: 100%; }
|
||||
.wordmark h1 { font-size: clamp(30px, 12vw, 48px); overflow-wrap: anywhere; }
|
||||
.sub { flex-wrap: wrap; overflow-wrap: anywhere; }
|
||||
.sechead { flex-wrap: wrap; gap: 8px 12px; }
|
||||
.sechead .micro { font-size: 9px; min-width: 0; overflow-wrap: anywhere; }
|
||||
.arsenal { grid-template-columns: 1fr; }
|
||||
.stats { grid-template-columns: repeat(2, 1fr); }
|
||||
.stat { border-right: 0; border-bottom: 1px solid var(--line); }
|
||||
.tabs { flex-wrap: wrap; }
|
||||
.tabs button { flex: 1 1 42%; font-size: 10px; padding: 10px 8px; border: 1px solid var(--line); border-bottom: 0; }
|
||||
.row { flex-direction: column; align-items: stretch; }
|
||||
input.grow { min-width: 0; width: 100%; }
|
||||
.row .go, .row button.go { width: 100%; }
|
||||
.fld { width: 100% !important; min-width: 0 !important; }
|
||||
.footstrip { flex-wrap: wrap; gap: 6px 12px; }
|
||||
.backscan { right: 12px; bottom: 12px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="scan" aria-hidden="true"></div>
|
||||
<div class="wrap">
|
||||
|
||||
<!-- STATUS BAR -->
|
||||
<div class="statusbar">
|
||||
<div class="corner">+</div>
|
||||
<div class="seg micro">⌖ N 00°00'00" · SECTOR-A</div>
|
||||
<div class="grow"></div>
|
||||
<div class="seg ctr micro" id="buildbar"># BUILD:STABLE · SHA:A7F3C91 · NO TELEMETRY</div>
|
||||
<div class="grow"></div>
|
||||
<div class="seg micro" id="systag">SYS://RECON.LIVE · UP <span id="uptime">00:00:00</span></div>
|
||||
<div class="corner">+</div>
|
||||
</div>
|
||||
|
||||
<!-- HERO -->
|
||||
<header class="hero">
|
||||
<div class="glyph">
|
||||
<svg width="46" height="46" viewBox="0 0 46 46" fill="none" stroke="#ff2536" stroke-width="2">
|
||||
<g>
|
||||
<line x1="23" y1="3" x2="23" y2="43"/><line x1="3" y1="23" x2="43" y2="23"/>
|
||||
<line x1="9" y1="9" x2="37" y2="37"/><line x1="37" y1="9" x2="9" y2="37"/>
|
||||
<circle cx="23" cy="23" r="5" fill="#ff2536" stroke="none"/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="wordmark">
|
||||
<h1><span class="c">claude</span> <span class="slash">/</span> <span class="o">osint</span></h1>
|
||||
<div class="sub micro"><span class="dot">●</span> External Red-Team Recon · Bug-Bounty Arsenal</div>
|
||||
<button class="cta" id="runScanCta" type="button">▸ Run a scan</button>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span class="pill"><span class="bl"></span> Operational</span>
|
||||
<span class="kv">VERSION <b>v2.1.1</b> · MIT</span>
|
||||
<span class="kv">AUTHOR <b>@elementalsouls</b></span>
|
||||
<a class="kv" href="https://github.com/elementalsouls/Claude-OSINT" target="_blank" rel="noopener">REPO <b>github.com/elementalsouls/Claude-OSINT</b></a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ================= RUN A SCAN (primary action) ================= -->
|
||||
<div class="sechead" id="console-anchor">
|
||||
<span class="bar"></span><span class="title">Run a Scan</span>
|
||||
<span class="micro">// point it at a path, or paste a blob — runs locally, nothing leaves this box</span>
|
||||
<span class="grow"></span>
|
||||
<span class="micro" id="consoleflag">0X01 Scan · Paste · H1-Ref</span>
|
||||
</div>
|
||||
|
||||
<div class="console">
|
||||
<nav class="tabs">
|
||||
<button data-tab="scan" class="active">▸ Secret Scan</button>
|
||||
<button data-tab="paste">▸ Paste & Scan</button>
|
||||
<button data-tab="h1">▸ HackerOne Ref</button>
|
||||
</nav>
|
||||
<div class="panelbox">
|
||||
|
||||
<section class="panel active" id="tab-scan">
|
||||
<p class="lead">Scan a local repo or file for leaked secrets — 48 patterns, recursive, offline.</p>
|
||||
<div class="row">
|
||||
<input type="text" id="scanPath" class="grow" placeholder="/path/to/repo-or-file · recursive · binaries & .git skipped">
|
||||
<button class="go" id="scanBtn">Scan path</button>
|
||||
</div>
|
||||
<div id="scanOut"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="tab-paste">
|
||||
<p class="lead">Paste any text — JS, configs, env dumps, response bodies. Checked locally against the same 48 patterns.</p>
|
||||
<textarea id="pasteText" placeholder="Paste JS, configs, env dumps, response bodies… 48 secret patterns checked locally — nothing leaves this machine."></textarea>
|
||||
<div class="row" style="margin-top:10px"><button class="go" id="pasteBtn">Scan text</button></div>
|
||||
<div id="pasteOut"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="tab-h1">
|
||||
<p class="lead">Look up disclosed HackerOne reports for techniques and impact framing. This tab reaches the network.</p>
|
||||
<div class="row">
|
||||
<label class="fld">Mode
|
||||
<select id="h1Mode"><option value="top-voted">Top voted</option><option value="top-bounty">Top bounty</option></select>
|
||||
</label>
|
||||
<label class="fld" style="flex:1; min-width:200px">Keyword (optional)
|
||||
<input type="text" id="h1Query" placeholder="SSRF · OAuth bypass · IDOR"></label>
|
||||
<label class="fld">Program (optional)
|
||||
<input type="text" id="h1Program" placeholder="shopify"></label>
|
||||
<label class="fld" style="width:90px">Pages
|
||||
<input type="text" id="h1Pages" value="3"></label>
|
||||
<label class="fld" style="align-self:flex-end"><button class="go" id="h1Btn">Query</button></label>
|
||||
</div>
|
||||
<div class="note">↑ This tab makes outbound HTTPS requests to hackerone.com. The two scan tabs are fully offline.</div>
|
||||
<div id="h1Out"></div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================= CAPABILITY MAP (reference, not interactive) ================= -->
|
||||
<div class="sechead">
|
||||
<span class="bar"></span><span class="title">Capability Map</span>
|
||||
<span class="micro">// reference — what these skills cover · not interactive</span>
|
||||
<span class="grow"></span>
|
||||
<span class="micro">0X02 4 Domains · 12 Surfaces · 9 Validators</span>
|
||||
</div>
|
||||
|
||||
<!-- ARSENAL GRID -->
|
||||
<div class="arsenal">
|
||||
<div class="col">
|
||||
<div class="colhead"><span class="idx">[01]</span><span class="nm">Identity</span></div>
|
||||
<div class="acard tick"><div class="t">M365 / Entra</div><div class="d">tenant GUID · SharePoint · OWA</div><span class="tag">ID-01</span></div>
|
||||
<div class="acard tick"><div class="t">Anthropic · OpenAI</div><div class="d">key catalog · live validators</div><span class="tag">ID-02</span></div>
|
||||
<div class="acard tick"><div class="t">LinkedIn Enum</div><div class="d">people · roles · job postings</div><span class="tag">ID-03</span></div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="colhead"><span class="idx">[02]</span><span class="nm">Infrastructure</span></div>
|
||||
<div class="acard tick"><div class="t">AWS</div><div class="d">STS · IAM enum · S3 buckets</div><span class="tag">IN-01</span></div>
|
||||
<div class="acard tick"><div class="t">Kubernetes</div><div class="d">kubelet · etcd · K8s API</div><span class="tag">IN-02</span></div>
|
||||
<div class="acard tick"><div class="t">Citrix · F5 · Fortinet</div><div class="d">vendor fingerprint · KEV CVEs</div><span class="tag">IN-03</span></div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="colhead"><span class="idx">[03]</span><span class="nm">Code & APIs</span></div>
|
||||
<div class="acard tick"><div class="t">GitHub</div><div class="d">PAT scope · code dorks · secrets</div><span class="tag">CO-01</span></div>
|
||||
<div class="acard tick"><div class="t">GraphQL</div><div class="d">introspection · field-suggestion</div><span class="tag">CO-02</span></div>
|
||||
<div class="acard tick"><div class="t">Postman PMAK</div><div class="d">workspace leak · collection scan</div><span class="tag">CO-03</span></div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="colhead"><span class="idx">[04]</span><span class="nm">Intel & Comms</span></div>
|
||||
<div class="acard tick"><div class="t">HudsonRock · Wayback</div><div class="d">breach corpus · CDX archive</div><span class="tag">IT-01</span></div>
|
||||
<div class="acard tick"><div class="t">Slack · Discord</div><div class="d">webhook leaks · token scan</div><span class="tag">IT-02</span></div>
|
||||
<div class="acard tick"><div class="t">Atlassian · DataDog</div><div class="d">jira leaks · dashboard exposure</div><span class="tag">IT-03</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KILL CHAIN -->
|
||||
<div class="killchain">
|
||||
<div class="kclabel">Kill Chain</div>
|
||||
<div class="kcseg on"><span class="s"><i>01</i>RECON</span><span class="mode">passive</span></div>
|
||||
<div class="kcarrow"><svg width="9" height="12" viewBox="0 0 9 12"><path d="M0 0L9 6L0 12Z" fill="#ff2536"/></svg></div>
|
||||
<div class="kcseg"><span class="s"><i>02</i>ENUMERATE</span><span class="mode">surface</span></div>
|
||||
<div class="kcarrow"><svg width="9" height="12" viewBox="0 0 9 12"><path d="M0 0L9 6L0 12Z" fill="#ff2536"/></svg></div>
|
||||
<div class="kcseg"><span class="s"><i>03</i>VALIDATE</span><span class="mode">live</span></div>
|
||||
<div class="kcarrow"><svg width="9" height="12" viewBox="0 0 9 12"><path d="M0 0L9 6L0 12Z" fill="#ff2536"/></svg></div>
|
||||
<div class="kcseg"><span class="s"><i>04</i>CHAIN</span><span class="mode">exploit</span></div>
|
||||
<div class="kcarrow"><svg width="9" height="12" viewBox="0 0 9 12"><path d="M0 0L9 6L0 12Z" fill="#ff2536"/></svg></div>
|
||||
<div class="kcseg"><span class="s"><i>05</i>REPORT</span><span class="mode">deliver</span></div>
|
||||
</div>
|
||||
|
||||
<!-- STATS -->
|
||||
<div class="stats tick">
|
||||
<div class="stat"><div class="n">90+</div><div class="k">Modules</div></div>
|
||||
<div class="stat"><div class="n">48</div><div class="k">Patterns</div></div>
|
||||
<div class="stat"><div class="n">80+</div><div class="k">Dorks</div></div>
|
||||
<div class="stat"><div class="n">9</div><div class="k">Validators</div></div>
|
||||
<div class="stat"><div class="n">27</div><div class="k">Attack Paths</div></div>
|
||||
<div class="stat"><div class="n">5,500+</div><div class="k">Lines</div></div>
|
||||
</div>
|
||||
|
||||
<!-- FOOTER STRIP -->
|
||||
<div class="footstrip micro">
|
||||
<span>▸ Methodology · Arsenal</span>
|
||||
<span class="grow"></span>
|
||||
<span class="ctr">Paired · Production-Ready · Offensive · Documented</span>
|
||||
<span class="grow"></span>
|
||||
<span>Ship It ◂</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<button class="backscan" id="backScan" type="button" aria-label="Jump back to scan">▸ scan</button>
|
||||
|
||||
<script>
|
||||
const $ = (s, r=document) => r.querySelector(s);
|
||||
const $$ = (s, r=document) => [...r.querySelectorAll(s)];
|
||||
const esc = s => String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
|
||||
$$('.tabs button').forEach(b => b.onclick = () => {
|
||||
$$('.tabs button').forEach(x => x.classList.remove('active'));
|
||||
$$('.panel').forEach(x => x.classList.remove('active'));
|
||||
b.classList.add('active');
|
||||
$('#tab-' + b.dataset.tab).classList.add('active');
|
||||
});
|
||||
|
||||
function activateTab(name) {
|
||||
$$('.tabs button').forEach(x => x.classList.remove('active'));
|
||||
$$('.panel').forEach(x => x.classList.remove('active'));
|
||||
const btn = document.querySelector(`.tabs button[data-tab=${name}]`);
|
||||
if (btn) btn.classList.add('active');
|
||||
const pan = $('#tab-' + name);
|
||||
if (pan) pan.classList.add('active');
|
||||
}
|
||||
|
||||
// Jump to the console, select Secret Scan, focus the input. Shared by the
|
||||
// hero CTA and the floating back-to-scan button.
|
||||
function goToScan() {
|
||||
activateTab('scan');
|
||||
$('#console-anchor').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
setTimeout(() => $('#scanPath').focus(), 350);
|
||||
}
|
||||
const runCta = $('#runScanCta');
|
||||
if (runCta) runCta.onclick = goToScan;
|
||||
|
||||
// Floating back-to-scan button: reveal once the user scrolls past the console.
|
||||
const backScan = $('#backScan');
|
||||
const consoleAnchor = $('#console-anchor');
|
||||
if (backScan && consoleAnchor) {
|
||||
backScan.onclick = goToScan;
|
||||
const toggleBackScan = () => {
|
||||
const past = window.scrollY > consoleAnchor.offsetTop + 320;
|
||||
backScan.classList.toggle('show', past);
|
||||
};
|
||||
window.addEventListener('scroll', toggleBackScan, { passive: true });
|
||||
toggleBackScan();
|
||||
}
|
||||
|
||||
async function postJSON(url, payload) {
|
||||
const r = await fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload)});
|
||||
return r.json();
|
||||
}
|
||||
|
||||
let activeFindings = [];
|
||||
let sevFilter = new Set(['critical','high','medium','low']);
|
||||
let textFilter = '';
|
||||
|
||||
function severityCards(sum) {
|
||||
const sev = sum.by_severity || {};
|
||||
const cards = [
|
||||
`<div class="card"><div class="n">${sum.total}</div><div class="k">findings</div></div>`,
|
||||
`<div class="card"><div class="n">${sum.files}</div><div class="k">sources</div></div>`,
|
||||
];
|
||||
for (const s of ['critical','high','medium','low'])
|
||||
cards.push(`<div class="card sev-${s}"><div class="n">${sev[s]||0}</div><div class="k">${s}</div></div>`);
|
||||
return `<div class="cards">${cards.join('')}</div>`;
|
||||
}
|
||||
|
||||
function renderFindings(outEl, data) {
|
||||
if (!data.ok) { outEl.innerHTML = `<div class="err">✗ ${esc(data.error||'scan failed')}</div>`; return; }
|
||||
activeFindings = data.findings;
|
||||
sevFilter = new Set(['critical','high','medium','low']);
|
||||
textFilter = '';
|
||||
const trunc = data.truncated ? `<div class="note">⚠ Output capped at ${activeFindings.length} findings — narrow the path for a complete view.</div>` : '';
|
||||
|
||||
if (data.summary.total === 0) {
|
||||
outEl.innerHTML = severityCards(data.summary) + `<div class="empty">No secrets matched in <b>${esc(data.target)}</b>. Clean — or worth a deeper manual look.</div>`;
|
||||
return;
|
||||
}
|
||||
const cats = Object.entries(data.summary.by_category).sort((a,b)=>b[1]-a[1])
|
||||
.map(([c,n]) => `<span class="chip" data-cat="${esc(c)}">${esc(c)} ${n}</span>`).join('');
|
||||
|
||||
outEl.innerHTML = severityCards(data.summary) + trunc + `
|
||||
<div class="toolbar">
|
||||
${['critical','high','medium','low'].map(s=>`<span class="chip on" data-sev="${s}">${s}</span>`).join('')}
|
||||
<div class="spacer"></div>
|
||||
<input type="text" id="rowSearch" placeholder="filter match / source…" style="min-width:200px">
|
||||
<button class="ghost" id="expJson">JSON</button>
|
||||
<button class="ghost" id="expCsv">CSV</button>
|
||||
</div>
|
||||
<div class="toolbar" style="margin-top:0">${cats}</div>
|
||||
<table><thead><tr><th>Severity</th><th>Pattern</th><th>Match</th><th>Source : line</th></tr></thead>
|
||||
<tbody id="rows"></tbody></table>`;
|
||||
|
||||
$$('.chip[data-sev]', outEl).forEach(c => c.onclick = () => {
|
||||
c.classList.toggle('on');
|
||||
const s = c.dataset.sev;
|
||||
if (sevFilter.has(s)) sevFilter.delete(s); else sevFilter.add(s);
|
||||
drawRows(outEl);
|
||||
});
|
||||
let catFilter = null;
|
||||
$$('.chip[data-cat]', outEl).forEach(c => c.onclick = () => {
|
||||
const was = c.classList.contains('on');
|
||||
$$('.chip[data-cat]', outEl).forEach(x => x.classList.remove('on'));
|
||||
catFilter = was ? null : c.dataset.cat;
|
||||
if (!was) c.classList.add('on');
|
||||
drawRows(outEl, catFilter);
|
||||
});
|
||||
$('#rowSearch', outEl).oninput = e => { textFilter = e.target.value.toLowerCase(); drawRows(outEl, catFilter); };
|
||||
$('#expJson', outEl).onclick = () => download('findings.json', JSON.stringify(currentRows(catFilter), null, 2), 'application/json');
|
||||
$('#expCsv', outEl).onclick = () => download('findings.csv', toCsv(currentRows(catFilter)), 'text/csv');
|
||||
drawRows(outEl);
|
||||
}
|
||||
|
||||
function currentRows(catFilter) {
|
||||
return activeFindings.filter(f =>
|
||||
sevFilter.has(f.severity) &&
|
||||
(!catFilter || f.category === catFilter) &&
|
||||
(!textFilter || (f.match+f.source).toLowerCase().includes(textFilter)));
|
||||
}
|
||||
|
||||
function drawRows(outEl, catFilter=null) {
|
||||
const rows = currentRows(catFilter);
|
||||
const tb = $('#rows', outEl);
|
||||
if (!rows.length) { tb.innerHTML = `<tr><td colspan="4" class="empty">No rows match the active filters.</td></tr>`; return; }
|
||||
tb.innerHTML = rows.map(f => `<tr>
|
||||
<td><span class="badge ${f.severity}">${f.severity}</span></td>
|
||||
<td class="pat">${esc(f.pattern)}</td>
|
||||
<td class="match">${esc(f.match)}<span class="copy" data-c="${esc(f.match)}">copy</span></td>
|
||||
<td class="src">${esc(f.source)}${f.line?(' : '+f.line):''}</td>
|
||||
</tr>`).join('');
|
||||
$$('.copy', tb).forEach(el => el.onclick = () => { navigator.clipboard.writeText(el.dataset.c); el.textContent='✓'; setTimeout(()=>el.textContent='copy',900); });
|
||||
}
|
||||
|
||||
function toCsv(rows) {
|
||||
const head = ['severity','pattern','category','match','source','line'];
|
||||
const q = v => `"${String(v??'').replace(/"/g,'""')}"`;
|
||||
return [head.join(','), ...rows.map(r => head.map(h => q(r[h])).join(','))].join('\n');
|
||||
}
|
||||
function download(name, text, mime) {
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(new Blob([text], {type:mime}));
|
||||
a.download = name; a.click(); URL.revokeObjectURL(a.href);
|
||||
}
|
||||
|
||||
$('#scanBtn').onclick = async () => {
|
||||
const path = $('#scanPath').value.trim();
|
||||
if (!path) return;
|
||||
const btn = $('#scanBtn'); btn.disabled = true; btn.textContent = 'Scanning…';
|
||||
$('#scanOut').innerHTML = `<div class="empty spin">walking ${esc(path)}</div>`;
|
||||
try { renderFindings($('#scanOut'), await postJSON('/api/scan', {path})); }
|
||||
catch (e) { $('#scanOut').innerHTML = `<div class="err">✗ ${esc(e.message)}</div>`; }
|
||||
btn.disabled = false; btn.textContent = 'Scan path';
|
||||
};
|
||||
$('#scanPath').addEventListener('keydown', e => { if (e.key==='Enter') $('#scanBtn').click(); });
|
||||
|
||||
$('#pasteBtn').onclick = async () => {
|
||||
const text = $('#pasteText').value;
|
||||
if (!text.trim()) return;
|
||||
const btn = $('#pasteBtn'); btn.disabled = true; btn.textContent = 'Scanning…';
|
||||
try { renderFindings($('#pasteOut'), await postJSON('/api/scan-text', {text})); }
|
||||
catch (e) { $('#pasteOut').innerHTML = `<div class="err">✗ ${esc(e.message)}</div>`; }
|
||||
btn.disabled = false; btn.textContent = 'Scan text';
|
||||
};
|
||||
|
||||
$('#h1Btn').onclick = async () => {
|
||||
const payload = { mode: $('#h1Mode').value, query: $('#h1Query').value, program: $('#h1Program').value, pages: $('#h1Pages').value, limit: 25 };
|
||||
const btn = $('#h1Btn'); btn.disabled = true; btn.textContent = 'Querying…';
|
||||
$('#h1Out').innerHTML = `<div class="empty spin">querying hackerone</div>`;
|
||||
try {
|
||||
const d = await postJSON('/api/h1', payload);
|
||||
if (!d.ok) { $('#h1Out').innerHTML = `<div class="err">✗ ${esc(d.error)}</div>`; }
|
||||
else if (!d.count) { $('#h1Out').innerHTML = `<div class="empty">No disclosed reports matched.</div>`; }
|
||||
else {
|
||||
$('#h1Out').innerHTML = `<div class="note" style="color:var(--muted)">${d.count} reports</div>` + d.results.map(r => {
|
||||
const sev = (r.severity||'n/a').toLowerCase();
|
||||
const bounty = r.bounty!=null ? ('$'+r.bounty) : '—';
|
||||
return `<div class="h1card">
|
||||
<a href="${esc(r.url||'#')}" target="_blank" rel="noopener">${esc(r.title||'(untitled)')}</a>
|
||||
<div class="meta">
|
||||
<span class="badge ${['critical','high','medium','low'].includes(sev)?sev:'low'}">${esc(sev)}</span>
|
||||
<span>CWE: ${esc(r.cwe||'n/a')}</span>
|
||||
<span>program: ${esc(r.program||'?')}</span>
|
||||
<span>bounty: ${esc(bounty)}</span>
|
||||
<span>votes: ${esc(r.votes??0)}</span>
|
||||
</div></div>`;
|
||||
}).join('');
|
||||
}
|
||||
} catch (e) { $('#h1Out').innerHTML = `<div class="err">✗ ${esc(e.message)}</div>`; }
|
||||
btn.disabled = false; btn.textContent = 'Query';
|
||||
};
|
||||
|
||||
// ---- live uptime ticker (status bar) ----
|
||||
const _t0 = Date.now();
|
||||
const _pad = n => String(n).padStart(2, '0');
|
||||
setInterval(() => {
|
||||
const s = Math.floor((Date.now() - _t0) / 1000);
|
||||
const el = $('#uptime');
|
||||
if (el) el.textContent = `${_pad(Math.floor(s/3600))}:${_pad(Math.floor(s%3600/60))}:${_pad(s%60)}`;
|
||||
}, 1000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="Local recon console for claude-osint.")
|
||||
ap.add_argument("--host", default="127.0.0.1", help="Bind address (default: 127.0.0.1).")
|
||||
ap.add_argument("--port", type=int, default=8765, help="Bind port (default: 8765).")
|
||||
ap.add_argument("--open", action="store_true", help="Open a browser tab on startup.")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.host not in ("127.0.0.1", "localhost", "::1"):
|
||||
print(
|
||||
f"[!] WARNING: binding to {args.host} exposes a file-reading scan API to the "
|
||||
"network. Only do this on a trusted, isolated host.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
try:
|
||||
httpd = ThreadingHTTPServer((args.host, args.port), Handler)
|
||||
except OSError as exc:
|
||||
print(f"[!] Could not bind {args.host}:{args.port} — {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
url = f"http://{args.host}:{args.port}"
|
||||
print(f"==> claude-osint recon console: {url}")
|
||||
print(" Ctrl-C to stop.")
|
||||
if args.open:
|
||||
threading.Timer(0.6, lambda: webbrowser.open(url)).start()
|
||||
|
||||
try:
|
||||
httpd.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\n==> shutting down.")
|
||||
finally:
|
||||
httpd.server_close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,378 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
h1_reference.py — HackerOne Hacktivity Reference Agent
|
||||
|
||||
Queries HackerOne's public GraphQL API for disclosed reports and surfaces
|
||||
relevant findings during recon. No API key required. Max 50 results/page.
|
||||
|
||||
Usage examples:
|
||||
# Top voted reports (best validated techniques — great starting point)
|
||||
python3 h1_reference.py --top-voted --limit 25
|
||||
|
||||
# Highest bounty reports (signal for business-impact framing)
|
||||
python3 h1_reference.py --top-bounty --limit 10
|
||||
|
||||
# Keyword search in titles across multiple pages
|
||||
python3 h1_reference.py --query "SSRF" --pages 10
|
||||
python3 h1_reference.py --query "OAuth bypass" --pages 5
|
||||
|
||||
# Filter by severity (client-side)
|
||||
python3 h1_reference.py --top-voted --severity critical high --limit 20
|
||||
|
||||
# Filter by CWE (client-side, matches CWE label substring)
|
||||
python3 h1_reference.py --top-voted --cwe "SSRF" "Request Forgery"
|
||||
|
||||
# Program-specific disclosures
|
||||
python3 h1_reference.py --program shopify --pages 3
|
||||
|
||||
# Lookup a program's numeric team ID
|
||||
python3 h1_reference.py --lookup-program gitlab
|
||||
|
||||
# Full combo: top-bounty SSRF reports, critical/high only
|
||||
python3 h1_reference.py --top-bounty --query "SSRF" --severity critical high --pages 10
|
||||
|
||||
# JSON output for piping
|
||||
python3 h1_reference.py --top-voted --query "XSS" --pages 5 --json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
import re
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from typing import Optional
|
||||
|
||||
GRAPHQL_URL = "https://hackerone.com/graphql"
|
||||
HEADERS = {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36",
|
||||
"Origin": "https://hackerone.com",
|
||||
"Referer": "https://hackerone.com/hacktivity",
|
||||
}
|
||||
PAGE_SIZE = 50
|
||||
|
||||
# HackerOne's GraphQL server crashes on named variables + substate filter + report fields.
|
||||
# We build inline query strings to avoid this. Additional known crashes:
|
||||
# - disclosed_at field with substate filter
|
||||
# - sort + substate filter + report fields
|
||||
REPORT_FIELDS = """... on HacktivityDocument {
|
||||
id
|
||||
severity_rating
|
||||
total_awarded_amount
|
||||
currency
|
||||
cwe
|
||||
cve_ids
|
||||
votes
|
||||
team { handle name }
|
||||
reporter { username }
|
||||
report { _id title url }
|
||||
}"""
|
||||
|
||||
TEAM_LOOKUP_QUERY = """
|
||||
query TeamLookup($handle: String!) {
|
||||
team(handle: $handle) {
|
||||
id
|
||||
_id
|
||||
handle
|
||||
name
|
||||
url
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def _escape(s: str) -> str:
|
||||
return s.replace("\\", "\\\\").replace('"', '\\"')
|
||||
|
||||
|
||||
def _gql_list(items: list) -> str:
|
||||
return "[" + ", ".join(f'"{_escape(str(i))}"' for i in items) + "]"
|
||||
|
||||
|
||||
def _offset_cursor(n: int) -> str:
|
||||
"""H1 uses base64(str(n)) as cursor offsets."""
|
||||
return base64.b64encode(str(n).encode()).decode()
|
||||
|
||||
|
||||
def gql_raw(query: str) -> dict:
|
||||
payload = json.dumps({"query": query}).encode()
|
||||
req = urllib.request.Request(GRAPHQL_URL, data=payload, headers=HEADERS, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as resp:
|
||||
return json.loads(resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode(errors="replace")
|
||||
print(f"[!] HTTP {e.code}: {body[:300]}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"[!] Request failed: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def gql_vars(query: str, variables: dict) -> dict:
|
||||
payload = json.dumps({"query": query, "variables": variables}).encode()
|
||||
req = urllib.request.Request(GRAPHQL_URL, data=payload, headers=HEADERS, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as resp:
|
||||
return json.loads(resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode(errors="replace")
|
||||
print(f"[!] HTTP {e.code}: {body[:300]}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"[!] Request failed: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def lookup_program(handle: str) -> Optional[str]:
|
||||
data = gql_vars(TEAM_LOOKUP_QUERY, {"handle": handle})
|
||||
team = data.get("data", {}).get("team")
|
||||
if not team:
|
||||
print(f"[!] Program '{handle}' not found or is private", file=sys.stderr)
|
||||
return None
|
||||
print(f"[+] {team['handle']} → id={team['_id']}, name={team['name']}")
|
||||
return team["_id"]
|
||||
|
||||
|
||||
def build_page_query(args, after: Optional[str] = None) -> str:
|
||||
"""
|
||||
Build an inline GraphQL query for one page of results.
|
||||
|
||||
H1 crash rules (discovered empirically):
|
||||
- substate filter + sort + report fields → CRASH
|
||||
- disclosed_at field + substate filter → CRASH
|
||||
- named variables + substate filter + report fields → CRASH
|
||||
|
||||
Strategy:
|
||||
- Pure sort (no --program): no substate filter, sort works fine
|
||||
- Program filter: substate filter, no sort, client-side sort
|
||||
"""
|
||||
has_sort = args.top_voted or args.top_bounty
|
||||
has_program = bool(args.program_id)
|
||||
|
||||
after_str = f', after: "{_escape(after)}"' if after else ""
|
||||
|
||||
if has_sort and not has_program:
|
||||
# Sort-only mode: no substate filter, works without crashing
|
||||
sort_field = "votes" if args.top_voted else "total_awarded_amount"
|
||||
return (
|
||||
f'{{ search(index: CompleteHacktivityReportIndex, query: {{bool: {{}}}}, '
|
||||
f'first: {PAGE_SIZE}{after_str}, sort: {{field: "{sort_field}", direction: DESC}}) '
|
||||
f'{{ total_count pageInfo {{ endCursor }} nodes {{ {REPORT_FIELDS} }} }} }}'
|
||||
)
|
||||
else:
|
||||
# Filter mode: substate + optional team filter, no sort
|
||||
filter_parts = ['terms: {substate: ["resolved"]}']
|
||||
if has_program:
|
||||
filter_parts.append(f'terms: {{team_id: ["{_escape(str(args.program_id))}"]}}')
|
||||
filter_str = ", ".join(f"{{{p}}}" for p in filter_parts)
|
||||
return (
|
||||
f'{{ search(index: CompleteHacktivityReportIndex, '
|
||||
f'query: {{bool: {{filter: [{filter_str}]}}}}, '
|
||||
f'first: {PAGE_SIZE}{after_str}) '
|
||||
f'{{ total_count pageInfo {{ endCursor }} nodes {{ {REPORT_FIELDS} }} }} }}'
|
||||
)
|
||||
|
||||
|
||||
def fetch_all_pages(args) -> list[dict]:
|
||||
"""Fetch up to args.pages pages from the H1 GraphQL API."""
|
||||
all_nodes: list[dict] = []
|
||||
cursor = None
|
||||
|
||||
for page_num in range(1, args.pages + 1):
|
||||
if page_num > 1:
|
||||
time.sleep(0.3) # polite rate limiting
|
||||
|
||||
query = build_page_query(args, after=cursor)
|
||||
data = gql_raw(query)
|
||||
|
||||
if "errors" in data or data.get("data") is None:
|
||||
for err in (data.get("errors") or []):
|
||||
print(f"[!] GraphQL error (page {page_num}): {err.get('message')}", file=sys.stderr)
|
||||
break
|
||||
|
||||
search = data["data"]["search"]
|
||||
nodes = search.get("nodes", [])
|
||||
all_nodes.extend(nodes)
|
||||
cursor = search.get("pageInfo", {}).get("endCursor")
|
||||
|
||||
if not nodes or not cursor:
|
||||
break
|
||||
|
||||
# Stop early if we already have enough matches (when keyword filtering)
|
||||
if not args.query and not args.severity and not args.cwe:
|
||||
matching = len([n for n in all_nodes if n.get("report")])
|
||||
if matching >= args.limit:
|
||||
break
|
||||
|
||||
return all_nodes
|
||||
|
||||
|
||||
def apply_client_filters(nodes: list[dict], args) -> list[dict]:
|
||||
keyword_pattern = re.compile(args.query, re.IGNORECASE) if args.query else None
|
||||
severity_set = set(s.lower() for s in args.severity) if args.severity else None
|
||||
cwe_patterns = [re.compile(c, re.IGNORECASE) for c in args.cwe] if args.cwe else []
|
||||
|
||||
results = []
|
||||
for node in nodes:
|
||||
report = node.get("report")
|
||||
if not report:
|
||||
continue
|
||||
|
||||
title = report.get("title", "")
|
||||
|
||||
if keyword_pattern and not keyword_pattern.search(title):
|
||||
continue
|
||||
|
||||
if severity_set:
|
||||
node_sev = (node.get("severity_rating") or "none").lower()
|
||||
if node_sev not in severity_set:
|
||||
continue
|
||||
|
||||
if cwe_patterns:
|
||||
node_cwe = node.get("cwe") or ""
|
||||
if not any(p.search(node_cwe) for p in cwe_patterns):
|
||||
continue
|
||||
|
||||
results.append(node)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def client_sort(results: list[dict], args) -> list[dict]:
|
||||
"""Apply client-side sort when server-side sort was skipped (filter mode)."""
|
||||
has_sort = args.top_voted or args.top_bounty
|
||||
has_program = bool(args.program_id)
|
||||
|
||||
if has_sort and has_program:
|
||||
key = "votes" if args.top_voted else "total_awarded_amount"
|
||||
results = sorted(results, key=lambda n: n.get(key) or 0, reverse=True)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def format_severity(s: Optional[str]) -> str:
|
||||
if not s:
|
||||
return "none"
|
||||
colors = {
|
||||
"critical": "\033[91m",
|
||||
"high": "\033[33m",
|
||||
"medium": "\033[93m",
|
||||
"low": "\033[32m",
|
||||
"none": "\033[37m",
|
||||
}
|
||||
reset = "\033[0m"
|
||||
c = colors.get(s.lower(), "")
|
||||
return f"{c}{s.upper()}{reset}"
|
||||
|
||||
|
||||
def print_results(results: list[dict], total_fetched: int, args):
|
||||
if not results:
|
||||
print("[*] No matching results found.")
|
||||
return
|
||||
|
||||
print(f"\n{'='*72}")
|
||||
label_parts = []
|
||||
if args.top_voted:
|
||||
label_parts.append("top voted")
|
||||
if args.top_bounty:
|
||||
label_parts.append("top bounty")
|
||||
if args.query:
|
||||
label_parts.append(f'keyword="{args.query}"')
|
||||
if args.severity:
|
||||
label_parts.append(f"severity={','.join(args.severity)}")
|
||||
if args.program:
|
||||
label_parts.append(f"program={args.program}")
|
||||
label = " | ".join(label_parts) if label_parts else "all disclosed"
|
||||
print(f" HackerOne Reference — {label}")
|
||||
print(f" {len(results)} matches from {total_fetched} fetched reports")
|
||||
print(f"{'='*72}\n")
|
||||
|
||||
for i, node in enumerate(results[: args.limit], 1):
|
||||
report = node.get("report", {}) or {}
|
||||
team = node.get("team", {}) or {}
|
||||
reporter = node.get("reporter", {}) or {}
|
||||
|
||||
title = report.get("title", "Unknown")
|
||||
url = report.get("url", "")
|
||||
severity = format_severity(node.get("severity_rating"))
|
||||
cwe = node.get("cwe") or ""
|
||||
cves = ", ".join(node.get("cve_ids") or [])
|
||||
bounty = node.get("total_awarded_amount")
|
||||
currency = node.get("currency") or "USD"
|
||||
votes = node.get("votes") or 0
|
||||
program = team.get("handle", "?")
|
||||
reporter_name = reporter.get("username", "?")
|
||||
|
||||
bounty_str = f"${bounty:,} {currency}" if bounty else "no bounty"
|
||||
|
||||
print(f"[{i:02d}] {title}")
|
||||
print(f" Severity : {severity} | CWE: {cwe or 'n/a'}")
|
||||
print(f" Program : {program} | Reporter: {reporter_name}")
|
||||
if cves:
|
||||
print(f" CVE : {cves}")
|
||||
print(f" Bounty : {bounty_str} | Votes: {votes}")
|
||||
print(f" URL : {url}")
|
||||
print()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="HackerOne Hacktivity Reference Agent — surface relevant disclosures during recon",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
)
|
||||
parser.add_argument("--query", "-q", help="Keyword regex to match in report titles (client-side)")
|
||||
parser.add_argument(
|
||||
"--severity", "-s",
|
||||
nargs="+",
|
||||
choices=["none", "low", "medium", "high", "critical"],
|
||||
metavar="LEVEL",
|
||||
help="Client-side severity filter: none low medium high critical",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--cwe",
|
||||
nargs="+",
|
||||
metavar="PATTERN",
|
||||
help="Client-side CWE label filter (regex, e.g. 'SSRF' 'Traversal')",
|
||||
)
|
||||
parser.add_argument("--program", "-p", metavar="HANDLE", help="Filter by program handle")
|
||||
parser.add_argument("--lookup-program", metavar="HANDLE", help="Resolve a program handle to its numeric ID and exit")
|
||||
parser.add_argument("--top-voted", action="store_true", help="Sort by community votes (validated techniques)")
|
||||
parser.add_argument("--top-bounty", action="store_true", help="Sort by bounty amount (impact framing reference)")
|
||||
parser.add_argument("--limit", "-n", type=int, default=20, help="Max results to display (default: 20)")
|
||||
parser.add_argument(
|
||||
"--pages",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Number of pages to fetch (50 results/page, default: 1). Use 5-20 for keyword searches.",
|
||||
)
|
||||
parser.add_argument("--json", action="store_true", help="Output raw JSON instead of formatted text")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.lookup_program:
|
||||
lookup_program(args.lookup_program)
|
||||
return
|
||||
|
||||
args.program_id = None
|
||||
if args.program:
|
||||
args.program_id = lookup_program(args.program)
|
||||
if not args.program_id:
|
||||
sys.exit(1)
|
||||
|
||||
nodes = fetch_all_pages(args)
|
||||
results = apply_client_filters(nodes, args)
|
||||
results = client_sort(results, args)
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(results[: args.limit], indent=2))
|
||||
else:
|
||||
print_results(results, len(nodes), args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: osint-methodology
|
||||
description: "Comprehensive OSINT methodology for external red-team operations and authorized attack-surface assessments. Covers the 5-stage recon pipeline (seed discovery, asset expansion, enrichment, exposure analysis, reporting), asset-graph discipline with 29 asset types, severity rubric (CRITICAL/HIGH/MEDIUM/LOW/INFO), confidence upgrade workflows, time budgeting, asset-level triage rules, scale-based tactics, identity-fabric mapping (Entra/Okta/ADFS/Google/SAML/M365 Teams+SharePoint+OAuth), API and auth-map methodology, JavaScript deep analysis, mobile attack surface, cloud attack surface, breach×identity correlation, detectability tagging, detection-aware probing (back-off, persona rotation), read-only validator discipline, WAF/CDN bypass + origin discovery, vulnerability prioritization (CVE/EPSS/KEV), phishing infrastructure planning + pretext development, bug bounty submission templates, client deliverable templates with risk translation, threat-actor investigation (incl. RU/CN pivots), cryptocurrency tracing, image/video forensics, chronolocation. Use when planning or executing reconnaissance against authorized targets, mapping an organization's external attack surface, investigating a person/entity, tracing crypto flows, geolocating media, or performing attribution work."
|
||||
version: 2.1
|
||||
version: 2.3
|
||||
triggers:
|
||||
- external recon
|
||||
- external red team
|
||||
|
||||
Reference in new issue
Block a user