Files
evilsocket-audit/audit/cli.py
T
Mark Atwood 1d25b239d2 auth: add opt-in api_key mode via --allow-api-key
Adds a fourth auth mode (api_key) for users who want to bill against
their own Anthropic API account instead of through a Claude Code
subscription. The mode is opt-in to preserve the existing 'subscription
only' default — users who have ANTHROPIC_API_KEY set in their shell for
other tools won't accidentally be billed metered rates here.

Opt-in via either:
  --allow-api-key            (CLI flag on auth-check and run)
  AUDIT_ALLOW_API_KEY=1      (env var; useful for CI)

When opted in, the precedence matches the underlying SDK:
  gateway > api_key > oauth_token > keychain_login

ANTHROPIC_AUTH_TOKEN is scrubbed in api_key mode so a stale gateway
token can't outrank the API key (rung 2 > rung 3 in SDK precedence).

Default behavior is unchanged: with allow_api_key=False (or absent),
ANTHROPIC_API_KEY is scrubbed and one of the subscription modes wins,
exactly matching the original behavior.

Tests: 54/54 pass (4 new api_key-mode tests; existing tests updated to
explicitly assert the upstream default-scrub behavior they relied on).

Error message updated to mention --allow-api-key when the user has set
ANTHROPIC_API_KEY but no subscription auth, so the failure mode is
self-explanatory.
2026-05-21 09:19:03 -07:00

310 lines
12 KiB
Python

"""Click-based CLI: auth-check, run, status, report."""
from __future__ import annotations
import asyncio
import json
import logging
import os
import sys
import uuid
from pathlib import Path
import click
from rich.console import Console
from rich.logging import RichHandler
from rich.table import Table
from audit.auth import AuthError, configure_auth
def _allow_api_key_from_env_or_flag(flag: bool) -> bool:
"""A user may opt into api_key mode via --allow-api-key OR via
AUDIT_ALLOW_API_KEY=1 in the env. Either is sufficient."""
if flag:
return True
return os.environ.get("AUDIT_ALLOW_API_KEY", "").strip() not in ("", "0", "false", "False")
from audit.config import load_config
from audit.orchestrator import CostExceeded, run_pipeline
from audit.state import StateDB
REPO_ROOT = Path(__file__).resolve().parent.parent
DB_PATH = REPO_ROOT / "state.db"
RESULTS_ROOT = REPO_ROOT / "results"
console = Console()
def _setup_logging(verbose: bool) -> None:
level = logging.DEBUG if verbose else logging.INFO
logging.basicConfig(
level=level,
format="%(message)s",
datefmt="[%X]",
handlers=[RichHandler(console=console, rich_tracebacks=True,
show_path=False, markup=False)],
)
@click.group()
@click.option("-v", "--verbose", is_flag=True, help="DEBUG logging.")
@click.pass_context
def main(ctx: click.Context, verbose: bool) -> None:
"""audit — Cloudflare-style 8-stage vulnerability discovery agent."""
ctx.ensure_object(dict)
_setup_logging(verbose)
@main.command("auth-check")
@click.option("--allow-api-key", is_flag=True, default=False,
help="Honor ANTHROPIC_API_KEY for metered Anthropic billing "
"(also via AUDIT_ALLOW_API_KEY=1).")
def auth_check(allow_api_key: bool) -> None:
"""Verify Claude Code auth is configured correctly."""
allow = _allow_api_key_from_env_or_flag(allow_api_key)
try:
status = configure_auth(allow_api_key=allow)
except AuthError as e:
console.print(f"[red]auth error:[/red] {e}")
sys.exit(2)
if status.auth_mode == "oauth_token":
console.print("[green]OK[/green] using CLAUDE_CODE_OAUTH_TOKEN")
elif status.auth_mode == "api_key":
console.print(
"[green]OK[/green] using ANTHROPIC_API_KEY (metered Anthropic API billing)"
)
elif status.auth_mode == "keychain_login":
console.print(
f"[green]OK[/green] using stored login from {status.credentials_file}"
)
elif status.auth_mode == "gateway":
console.print(
f"[green]OK[/green] using LLM gateway at {status.gateway_base_url} "
"(ANTHROPIC_AUTH_TOKEN)"
)
if status.gateway_model:
console.print(f" ANTHROPIC_MODEL={status.gateway_model}")
if status.api_key_scrubbed:
console.print("[yellow]scrubbed[/yellow] ANTHROPIC_API_KEY removed from env "
"(it would have outranked the active auth mode)")
if status.auth_token_scrubbed:
console.print("[yellow]scrubbed[/yellow] ANTHROPIC_AUTH_TOKEN removed from env "
"(no gateway base URL set — leaving it would outrank subscription)")
console.print(f"claude CLI: {status.claude_cli_path} ({status.claude_cli_version})")
@main.command("run")
@click.option("--repo", "repo", required=True, type=click.Path(exists=True, file_okay=False),
help="Path to the target source-code repo.")
@click.option("--run-id", default=None, help="Run identifier (default: random).")
@click.option("--resume", is_flag=True, help="Resume an existing run-id.")
@click.option("--max-cost-usd", default=None, type=float,
help="Abort if cumulative cost crosses this threshold.")
@click.option("--max-concurrency", default=None, type=int,
help="Cap every stage's concurrency to this (cost containment).")
@click.option("--max-recon-tasks", default=None, type=int,
help="Cap the number of initial Hunt tasks Recon may emit.")
@click.option("--target-url", default=None,
help="Optional: URL of a live deployment the agents can hit "
"to confirm findings (e.g. http://server.local:8888).")
@click.option("--target-creds", "target_creds", multiple=True,
metavar="KEY=VALUE",
help="Credentials for the live target. Repeat the flag for "
"each KEY=VALUE pair (e.g. --target-creds email=admin@x "
"--target-creds password=...).")
@click.option("--scope-notes", "scope_notes_path", default=None,
type=click.Path(exists=True, dir_okay=False),
help="Optional: path to a text file with target-specific scope "
"rules / exclusions; passed verbatim to every stage.")
@click.option("--config", "config_path", default=None, type=click.Path(),
help="Override config/stages.yaml.")
@click.option("--allow-api-key", is_flag=True, default=False,
help="Honor ANTHROPIC_API_KEY for metered Anthropic billing "
"(also via AUDIT_ALLOW_API_KEY=1).")
def run(repo: str, run_id: str | None, resume: bool, max_cost_usd: float | None,
max_concurrency: int | None, max_recon_tasks: int | None,
target_url: str | None, target_creds: tuple[str, ...],
scope_notes_path: str | None,
config_path: str | None,
allow_api_key: bool) -> None:
"""Run the full 8-stage pipeline against a target repo."""
allow = _allow_api_key_from_env_or_flag(allow_api_key)
try:
configure_auth(allow_api_key=allow)
except AuthError as e:
console.print(f"[red]auth error:[/red] {e}")
sys.exit(2)
config = load_config(Path(config_path)) if config_path else load_config()
if max_concurrency is not None:
config.cap_concurrency(max_concurrency)
console.print(f"[cyan]capped concurrency to {max_concurrency} across all stages[/cyan]")
# Live-target plumbing — agents will receive {"url": ..., "credentials": {...}}
# in their user_input when set.
live_target: dict | None = None
if target_url:
creds: dict[str, str] = {}
for kv in target_creds:
if "=" not in kv:
console.print(f"[red]invalid --target-creds {kv!r} — expected KEY=VALUE[/red]")
sys.exit(2)
k, _, v = kv.partition("=")
creds[k.strip()] = v.strip()
live_target = {"url": target_url, "credentials": creds}
console.print(f"[cyan]live target:[/cyan] {target_url} (creds: {sorted(creds)})")
elif target_creds:
console.print("[yellow]--target-creds without --target-url is ignored[/yellow]")
scope_notes: str | None = None
if scope_notes_path:
scope_notes = Path(scope_notes_path).read_text()
console.print(f"[cyan]scope notes loaded:[/cyan] {scope_notes_path} ({len(scope_notes)} chars)")
run_id = run_id or f"run_{uuid.uuid4().hex[:8]}"
repo_path = Path(repo).resolve()
db = StateDB(DB_PATH)
try:
report = asyncio.run(run_pipeline(
repo_path=repo_path,
run_id=run_id,
db=db,
config=config,
max_cost_usd=max_cost_usd,
resume=resume,
max_recon_tasks=max_recon_tasks,
live_target=live_target,
scope_notes=scope_notes,
))
console.print(f"[green]done[/green] run_id={run_id} report={report}")
except CostExceeded as e:
console.print(f"[yellow]aborted[/yellow] {e}")
sys.exit(3)
except Exception as e:
console.print(f"[red]failed[/red] {type(e).__name__}: {e}")
raise
finally:
db.close()
@main.command("status")
@click.option("--run-id", default=None)
def status(run_id: str | None) -> None:
"""Show pipeline status: tasks, findings, traces, cost."""
db = StateDB(DB_PATH)
try:
if run_id is None:
_show_runs_table(db)
return
run = db.get_run(run_id)
if run is None:
console.print(f"[red]unknown run_id {run_id!r}[/red]")
sys.exit(1)
_show_run_detail(db, run_id)
finally:
db.close()
@main.command("report")
@click.option("--run-id", required=True)
@click.option("--format", "fmt", type=click.Choice(["json", "md"]), default="json")
def report(run_id: str, fmt: str) -> None:
"""Print (or generate) the final report."""
db = StateDB(DB_PATH)
try:
report_path = RESULTS_ROOT / run_id / "report" / "report.json"
if not report_path.exists():
console.print(f"[red]no report at {report_path}[/red]")
sys.exit(1)
payload = json.loads(report_path.read_text())
if fmt == "json":
click.echo(json.dumps(payload, indent=2))
else:
click.echo(_render_markdown_report(payload))
finally:
db.close()
def _show_runs_table(db: StateDB) -> None:
runs = db.list_runs()
t = Table(title="runs", show_lines=False)
t.add_column("run_id")
t.add_column("repo")
t.add_column("status")
t.add_column("cost ($)")
for r in runs:
t.add_row(r["run_id"], r["repo_path"], r["status"],
f"{db.total_cost(r['run_id']):.4f}")
console.print(t)
def _show_run_detail(db: StateDB, run_id: str) -> None:
tasks = db.get_all_tasks(run_id)
findings = db.get_findings(run_id)
confirmed = [f for f in findings if f.validation_status == "confirmed"]
canonical = [f for f in confirmed if f.is_canonical]
reachable = db.get_reachable_canonical_findings(run_id)
t = Table(title=f"run {run_id}", show_lines=False)
t.add_column("metric"); t.add_column("count")
t.add_row("tasks (total)", str(len(tasks)))
t.add_row("tasks (pending)", str(sum(1 for x in tasks if x.status == "pending")))
t.add_row("tasks (done)", str(sum(1 for x in tasks if x.status == "done")))
t.add_row("tasks (failed)", str(sum(1 for x in tasks if x.status == "failed")))
t.add_row("findings (raw)", str(len(findings)))
t.add_row("findings (confirmed)", str(len(confirmed)))
t.add_row("findings (canonical)", str(len(canonical)))
t.add_row("findings (reachable)", str(len(reachable)))
t.add_row("total cost ($)", f"{db.total_cost(run_id):.4f}")
console.print(t)
def _render_markdown_report(report: dict) -> str:
lines: list[str] = []
lines.append(f"# Vulnerability report — `{report['run_id']}`")
lines.append(f"Target: `{report['target']['repo_path']}` ")
s = report["summary"]
by = s.get("by_severity", {})
lines.append(f"**Total findings: {s['total']}** — "
+ ", ".join(f"{k}: {v}" for k, v in by.items()) if by
else f"**Total findings: {s['total']}**")
lines.append("")
for f in report["findings"]:
lines.append(f"## {f['title']}")
lines.append(f"- **Severity**: {f['severity']} ")
lines.append(f"- **Class**: {f['vuln_class']}"
+ (f" ({f['cwe']})" if f.get("cwe") else ""))
lines.append(f"- **Location**: `{f['file']}:{f['line_start']}-{f['line_end']}` ")
lines.append("")
lines.append(f["description"])
lines.append("")
lines.append("```")
lines.append(f["evidence"])
lines.append("```")
lines.append("")
ep = f["trace"].get("entry_points", [])
if ep:
lines.append("**Entry points**:")
for e in ep:
lines.append(f"- `{e['kind']}` at `{e['location']}`")
lines.append("")
cc = f["trace"].get("call_chain", [])
if cc:
lines.append("**Call chain**:")
for frame in cc:
lines.append(f"1. `{frame['file']}:{frame['line']}` — `{frame['function']}()`")
lines.append("")
lines.append(f"**Recommendation**: {f['recommendation']}")
lines.append("")
if f.get("variants"):
lines.append(f"_Variants_: {', '.join(f['variants'])}")
lines.append("")
lines.append("---")
lines.append("")
return "\n".join(lines)
if __name__ == "__main__":
main()