mirror of
https://github.com/elementalsouls/Claude-BugHunter.git
synced 2026-11-07 10:36:04 -10:00
* 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]>
189 lines
7.4 KiB
Python
189 lines
7.4 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
scope.py — deterministic scope safety for the engagement engine.
|
|
|
|
The engine may run unattended, so scope is enforced in CODE, not by trusting an
|
|
LLM. Every host/URL is checked here before any agent is allowed to touch it.
|
|
Deny wins: an out-of-scope match excludes even if an in-scope pattern also matches.
|
|
Default deny: anything not matching an in-scope pattern is out of scope.
|
|
|
|
Pattern forms (matched against the URL's host):
|
|
example.com -> the apex AND any subdomain (example.com, api.example.com)
|
|
*.example.com -> any subdomain (NOT the bare apex)
|
|
api.example.com -> that exact host
|
|
10.0.0.0/8 -> any IP in the CIDR (IPv4)
|
|
re:^staging[0-9]+\\.example\\.com$ -> explicit regex (prefix re:)
|
|
"""
|
|
import ipaddress
|
|
import re
|
|
from urllib.parse import urlparse
|
|
|
|
|
|
def _host_of(target):
|
|
t = target.strip()
|
|
if "://" not in t:
|
|
t = "//" + t
|
|
host = (urlparse(t).hostname or "").lower().rstrip(".")
|
|
return host
|
|
|
|
|
|
def _match(pattern, host):
|
|
p = pattern.strip().lower()
|
|
if not p or not host:
|
|
return False
|
|
if p.startswith("re:"):
|
|
try:
|
|
return re.search(p[3:], host) is not None
|
|
except re.error:
|
|
return False
|
|
if "/" in p and p.replace(".", "").replace("/", "").isdigit(): # CIDR
|
|
try:
|
|
return ipaddress.ip_address(host) in ipaddress.ip_network(p, strict=False)
|
|
except ValueError:
|
|
return False
|
|
if p.startswith("*."):
|
|
base = p[2:]
|
|
return host.endswith("." + base)
|
|
# bare domain: apex or any subdomain; or exact host
|
|
return host == p or host.endswith("." + p)
|
|
|
|
|
|
class Scope:
|
|
def __init__(self, in_scope, out_of_scope=None, seeds=None, name="engagement"):
|
|
self.in_scope = [p for p in (in_scope or []) if p.strip()]
|
|
self.out_of_scope = [p for p in (out_of_scope or []) if p.strip()]
|
|
self.seeds = seeds or []
|
|
self.name = name
|
|
|
|
@classmethod
|
|
def load(cls, path):
|
|
import json
|
|
d = json.load(open(path))
|
|
return cls(d.get("in_scope", []), d.get("out_of_scope", []),
|
|
d.get("seeds", []), d.get("name", "engagement"))
|
|
|
|
def in_scope_host(self, target):
|
|
host = _host_of(target)
|
|
if not host:
|
|
return False
|
|
if any(_match(p, host) for p in self.out_of_scope):
|
|
return False # deny wins
|
|
return any(_match(p, host) for p in self.in_scope)
|
|
|
|
def reject_reason(self, target):
|
|
"""Return None if in scope, else a human reason (for logging)."""
|
|
host = _host_of(target)
|
|
if not host:
|
|
return "could not parse host"
|
|
if any(_match(p, host) for p in self.out_of_scope):
|
|
return f"{host} matches an out-of-scope rule"
|
|
if not any(_match(p, host) for p in self.in_scope):
|
|
return f"{host} matches no in-scope rule (default deny)"
|
|
return None
|
|
|
|
|
|
def _selftest():
|
|
s = Scope(in_scope=["example.com", "*.test.example.com", "10.0.0.0/8",
|
|
"re:^lab[0-9]+\\.acme\\.io$"],
|
|
out_of_scope=["admin.example.com", "internal.example.com"])
|
|
ok = lambda t: s.in_scope_host(t)
|
|
assert ok("https://example.com/login") # apex
|
|
assert ok("http://api.example.com/x") # subdomain of bare domain
|
|
assert ok("https://a.test.example.com") # wildcard
|
|
assert ok("https://10.1.2.3:8080/") # CIDR
|
|
assert ok("https://lab42.acme.io") # regex
|
|
assert not ok("https://admin.example.com") # out-of-scope (deny wins)
|
|
assert not ok("https://internal.example.com/x") # out-of-scope
|
|
assert ok("https://test.example.com") # in scope via bare example.com rule
|
|
assert not ok("https://evil.com") # default deny
|
|
assert not ok("https://notexample.com") # suffix-confusion guard
|
|
assert not ok("https://example.com.evil.com") # suffix-confusion guard
|
|
assert not ok("https://11.0.0.1") # outside CIDR
|
|
assert s.reject_reason("https://evil.com")
|
|
assert s.reject_reason("https://example.com") is None
|
|
# wildcard depth: *.test.example.com needs a label deeper than test.example.com
|
|
s2 = Scope(in_scope=["*.test.example.com"])
|
|
assert s2.in_scope_host("a.test.example.com")
|
|
assert not s2.in_scope_host("test.example.com")
|
|
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__":
|
|
import sys
|
|
sys.exit(_main())
|