Files
hackingtool/scripts/audit_tools.py
T
Hardik Zinzuvadiya 2482cc9fd0 feat: AI operator console — 215 curated tools, AI layer, /find discovery (v2.0.0 rework)
Squashed rework of hackingtool from a tool launcher into an AI-guided operator
console for authorized security testing. 93 commits collapsed into this one;
the pre-rework tree is tagged v2.0.0.

CATALOG & ENGINE
- Data-driven YAML catalog (21 categories, 215 live tools + 59 archived) with a
  registry/overlay loader and a fixed 66-tag taxonomy (63 in use). Adding a tool
  is one YAML entry, not edits across the codebase.
- Engine honesty: real exit codes, truthful install success/failure, reuse-first
  skip, EOF-safe prompts, command audit logging.
- Safe installs: sha256-required safe-fetch (killed `curl | bash` in feroxbuster,
  Caido and Sliver), list-form subprocess only, no forced sudo.

AI LAYER (bring-your-own-key or local model; degrades offline, never fabricates)
- AI1 intent -> tools; AI2 tool+goal -> command, curated-first with a grounded
  fallback; AI3 findings summary and engagement report; AI4 per-finding impact
  and remediation. Prompt-injection hardened per OWASP LLM01.
- /goal plans an objective and runs it one step at a time, showing every command
  before it runs, with a plan.json + run.log audit trail.

/find TOOL DISCOVERY (this branch's headline feature)
- Suggests real GitHub projects when the catalog has no tool for a need.
  Deterministic: zero model calls, structured API fields only, suggest-only —
  it never clones, installs or runs anything.
- A charter filter refuses destructive/DoS/jamming/mass-targeting/evasion asks
  before any network I/O, while a defensive-intent guard keeps blue-team and
  DFIR phrasing ("detect a SYN flood in a pcap") from being false-refused.
- Query rewriting proved to be the dominant quality lever (the first design
  measured 29% precision with no results on 5 of 8 needs): a curated 41-row
  intent table maps plain English to canonical jargon plus a GitHub topic, and
  a two-arm search unions topic coverage with jargon precision.
- Explainable additive ranking: log-flattened stars, license/age/language,
  trusted-author bonus derived from owners we already ship, docs-repo demotion
  by name, staleness as a soft demotion rather than a filter (a hard cutoff
  would delete THC-Hydan and John the Ripper), and a relevance term weighting
  curated topics above free-text description.
- Optional no-scope GitHub token purely as a rate-limit lever (10 -> 30 req/min);
  it reaches only an Authorization header, never a cache key, log or output.
- `[a]` saves a pick to ~/.hackingtool/found.yaml as a structurally inert entry,
  and the loader strips executable keys from user catalogs at read time so a
  hand-edited file cannot become a runnable command.

CONSOLE & PACKAGING
- REPL with a / command palette, @ tool mentions, tag filters, history and
  completion; background tmux panes; settings and first-run scaffolding.
- src-layout package with catalog and pipelines as package data, console entry
  point, Docker image, signed releases with SBOM and build provenance.
- Health docs (SECURITY, CONTRIBUTING, CHANGELOG, CODE_OF_CONDUCT), a CI gate
  (ruff + pytest + catalog/taxonomy conformance) and a pre-push hook.
- README rewritten with a section index, the tool catalog split into
  docs/TOOLS.md and a step-by-step docs/HOW-TO-USE.md.

278 tests passing; scripts/check.sh green.
2026-07-27 00:18:34 +05:30

267 lines
11 KiB
Python

#!/usr/bin/env python3
"""
M1 — Tool-list curation audit.
Statically parses every HackingTool subclass in tools/, extracts its PROJECT_URL
(and the git-clone URL from INSTALL_COMMANDS), then queries the GitHub API for
each unique repo's maintenance signals (last push, archived flag, stars, 404).
Classifies each tool KEEP / STALE / DEAD / REFRESH-URL / ARCHIVED / MANUAL and
writes a Markdown + JSON report.
Read-only: it does NOT modify tools/. Applying verdicts is a separate step.
Auth: needs a GitHub token to cover all repos in one pass (anon = 60 req/hr).
Provide via env GITHUB_TOKEN / GH_TOKEN, or a gitignored file scripts/.github_token.
A plain token with NO scopes is enough (all repos are public).
Results are cached in scripts/.audit_cache.json, so reruns only fetch what's new.
Usage:
python3 scripts/audit_tools.py # audit, using cache
python3 scripts/audit_tools.py --refresh # ignore cache, re-fetch all
python3 scripts/audit_tools.py --stale-years 3
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
TOOLS_DIR = ROOT / "tools"
CACHE_FILE = ROOT / "scripts" / ".audit_cache.json"
REPORT_MD = ROOT / "scripts" / "tool_audit_report.md"
REPORT_JSON = ROOT / "scripts" / "tool_audit.json"
CLASS_RE = re.compile(r"^class\s+(\w+)\s*\(", re.M)
TITLE_RE = re.compile(r'TITLE\s*=\s*["\'](.+?)["\']')
PROJECT_URL_RE = re.compile(r'PROJECT_URL\s*=\s*["\'](https?://[^"\']+)["\']')
ARCHIVED_RE = re.compile(r"ARCHIVED\s*=\s*True")
ARCHIVED_REASON_RE = re.compile(r'ARCHIVED_REASON\s*=\s*["\'](.+?)["\']')
GH_CLONE_RE = re.compile(r"git\s+clone\s+(?:--\S+\s+)*(https?://github\.com/[^\s\"']+)")
GH_REPO_RE = re.compile(r"github\.com/([^/\s]+)/([^/\s#?\"']+)")
def gh_repo(url: str) -> str | None:
"""Normalize a GitHub URL to 'owner/repo' (lowercased), or None."""
m = GH_REPO_RE.search(url or "")
if not m:
return None
owner, repo = m.group(1), m.group(2)
# Cut at the first char that can't be in a repo name (handles ".git", ";cmd", etc.)
repo = re.split(r"[^\w.\-]", repo)[0].removesuffix(".git")
return f"{owner}/{repo}".lower()
def parse_tools() -> list[dict]:
"""Extract one record per HackingTool-ish class that declares a PROJECT_URL."""
records = []
for path in sorted(TOOLS_DIR.rglob("*.py")):
text = path.read_text(encoding="utf-8", errors="replace")
category = path.stem.replace("_", " ").title()
# Split file into per-class blocks.
starts = [(m.start(), m.group(1)) for m in CLASS_RE.finditer(text)]
for i, (pos, cname) in enumerate(starts):
end = starts[i + 1][0] if i + 1 < len(starts) else len(text)
block = text[pos:end]
purl_m = PROJECT_URL_RE.search(block)
if not purl_m:
continue # no project URL → nothing to check
title_m = TITLE_RE.search(block)
clone_m = GH_CLONE_RE.search(block)
records.append({
"class": cname,
"title": (title_m.group(1) if title_m else cname),
"category": category,
"file": str(path.relative_to(ROOT)),
"project_url": purl_m.group(1).strip(),
"project_repo": gh_repo(purl_m.group(1)),
"clone_repo": gh_repo(clone_m.group(1)) if clone_m else None,
"already_archived": bool(ARCHIVED_RE.search(block)),
"archived_reason": (ARCHIVED_REASON_RE.search(block).group(1)
if ARCHIVED_REASON_RE.search(block) else ""),
})
return records
def load_token() -> str | None:
import os
tok = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
if tok:
return tok.strip()
tok_file = ROOT / "scripts" / ".github_token"
if tok_file.exists():
return tok_file.read_text().strip() or None
return None
def fetch_repo(repo: str, token: str | None) -> dict:
"""Query the GitHub API for one repo. Returns a status dict (never raises)."""
req = urllib.request.Request(
f"https://api.github.com/repos/{repo}",
headers={"Accept": "application/vnd.github+json",
"User-Agent": "hackingtool-audit"},
)
if token:
req.add_header("Authorization", f"Bearer {token}")
try:
with urllib.request.urlopen(req, timeout=20) as r:
d = json.load(r)
return {
"status": 200,
"pushed_at": d.get("pushed_at"),
"archived": d.get("archived", False),
"stars": d.get("stargazers_count", 0),
"fork": d.get("fork", False),
"full_name": d.get("full_name"),
}
except urllib.error.HTTPError as e:
# Rate limiting: 429, or 403 whose body mentions "rate limit". Unify on 429
# so a *persistent* 403 (DMCA/suspended/blocked repo) stays distinct and cacheable.
if e.code == 429:
return {"status": 429}
if e.code == 403 and "rate limit" in (e.read().decode("utf-8", "ignore").lower()):
return {"status": 429}
return {"status": e.code} # incl. persistent 403 = blocked
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as e:
return {"status": 0, "error": str(e)}
def classify(rec: dict, api: dict, stale_years: int) -> tuple[str, str]:
"""Return (verdict, reason)."""
if rec["already_archived"]:
return "ALREADY-ARCHIVED", rec["archived_reason"] or "flagged in code"
if not rec["project_repo"]:
return "MANUAL", "non-GitHub host — check by hand"
st = api.get("status")
if st is None:
return "PENDING", "not fetched yet — add token and rerun"
if st == 404:
return "DEAD", "repo 404 (deleted/renamed)"
if st == 403:
return "DEAD", "repo blocked (DMCA / suspended / private)"
if st == 429:
return "PENDING", "rate-limited — needs token to finish"
if st != 200:
return "MANUAL", f"API status {st}"
if api.get("archived"):
return "ARCHIVE", "upstream repo archived"
pushed = api.get("pushed_at")
if pushed:
age_days = (datetime.now(timezone.utc)
- datetime.fromisoformat(pushed.replace("Z", "+00:00"))).days
if age_days > stale_years * 365:
return "STALE", f"no push in {age_days // 365}y {(age_days % 365) // 30}m"
# URL points at a different repo than we clone → drift
if rec["clone_repo"] and rec["project_repo"] and rec["clone_repo"] != rec["project_repo"]:
return "REFRESH-URL", f"PROJECT_URL={rec['project_repo']} but clones {rec['clone_repo']}"
yrs = ""
if pushed:
yrs = f", last push {pushed[:10]}"
return "KEEP", f"{api.get('stars', 0)}★{yrs}"
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--refresh", action="store_true", help="ignore cache")
ap.add_argument("--stale-years", type=int, default=2)
args = ap.parse_args()
records = parse_tools()
repos = sorted({r["project_repo"] for r in records if r["project_repo"]})
print(f"Parsed {len(records)} tools with a PROJECT_URL; {len(repos)} unique GitHub repos.")
cache: dict = {}
if CACHE_FILE.exists() and not args.refresh:
cache = json.loads(CACHE_FILE.read_text())
# Drop any rate-limit misses that were cached by older buggy runs.
poisoned = [k for k, v in cache.items() if v.get("status") in (403, 429)]
for k in poisoned:
del cache[k]
if poisoned:
print(f"Purged {len(poisoned)} stale rate-limit entries from cache.")
token = load_token()
print(f"GitHub token: {'yes' if token else 'NO (anon 60/hr limit)'}")
fetched = rate_limited = 0
for repo in repos:
if repo in cache and not args.refresh:
continue
api = fetch_repo(repo, token)
if api.get("status") == 429:
rate_limited += 1
if rate_limited == 1:
print(" ! hit rate limit — caching what we have; add a token to finish.")
continue # don't cache a rate-limit miss
cache[repo] = api
fetched += 1
if not token:
time.sleep(0.3) # be polite when anon
CACHE_FILE.write_text(json.dumps(cache, indent=2, sort_keys=True))
print(f"Fetched {fetched} new; {len(cache)} cached; {len(repos) - len(cache)} still unfetched.")
# Classify + assemble
results = []
for rec in records:
api = cache.get(rec["project_repo"], {}) if rec["project_repo"] else {}
verdict, reason = classify(rec, api, args.stale_years)
results.append({**rec, "verdict": verdict, "reason": reason,
"stars": api.get("stars"), "pushed_at": api.get("pushed_at")})
order = ["DEAD", "ARCHIVE", "STALE", "REFRESH-URL", "MANUAL", "PENDING",
"ALREADY-ARCHIVED", "KEEP"]
results.sort(key=lambda r: (order.index(r["verdict"]) if r["verdict"] in order else 99,
r["category"], r["title"]))
counts: dict[str, int] = {}
for r in results:
counts[r["verdict"]] = counts.get(r["verdict"], 0) + 1
REPORT_JSON.write_text(json.dumps(results, indent=2))
lines = [f"# Tool-List Curation Audit — {datetime.now().strftime('%Y-%m-%d')}", "",
f"Parsed **{len(records)}** tools with a PROJECT_URL across "
f"**{len(repos)}** unique GitHub repos.", "",
"## Summary", "", "| Verdict | Count | Meaning |", "|---|---:|---|"]
meaning = {
"DEAD": "repo 404 → archive/remove",
"ARCHIVE": "upstream archived → move to Archived",
"STALE": f"no commits in {args.stale_years}y+ → archive candidate",
"REFRESH-URL": "PROJECT_URL ≠ cloned repo → fix link",
"MANUAL": "non-GitHub / API error → check by hand",
"PENDING": "not yet fetched (needs token)",
"ALREADY-ARCHIVED": "already flagged in code",
"KEEP": "maintained → keep",
}
for v in order:
if counts.get(v):
lines.append(f"| {v} | {counts[v]} | {meaning.get(v,'')} |")
lines += ["", "## Action list (non-KEEP, non-already-archived)", "",
"| Verdict | Tool | Category | Repo | Reason | File |",
"|---|---|---|---|---|---|"]
for r in results:
if r["verdict"] in ("KEEP", "ALREADY-ARCHIVED"):
continue
lines.append(f"| {r['verdict']} | {r['title']} | {r['category']} | "
f"{r['project_repo'] or r['project_url']} | {r['reason']} | {r['file']} |")
lines.append("")
REPORT_MD.write_text("\n".join(lines))
print("\n" + " · ".join(f"{v}:{counts[v]}" for v in order if counts.get(v)))
print(f"Report → {REPORT_MD.relative_to(ROOT)} | {REPORT_JSON.relative_to(ROOT)}")
if counts.get("PENDING"):
print(f"\n⚠ {counts['PENDING']} tools PENDING — add a GitHub token and rerun to finish.")
return 0
if __name__ == "__main__":
sys.exit(main())