130 lines
4.8 KiB
Python
130 lines
4.8 KiB
Python
"""recon-triage CLI. Single entrypoint with `scan` and `replay` subcommands.
|
|
|
|
AUTHORIZED USE ONLY. This tool performs recon and triage only. It never runs,
|
|
generates, downloads, or executes exploits.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import UTC
|
|
from pathlib import Path
|
|
|
|
import typer
|
|
|
|
from . import __version__
|
|
from .orchestrator import build_report_from_fixtures, run_scan
|
|
from .report import markdown
|
|
from .schema import ReconReport, export_json_schema
|
|
from .scope import Scope
|
|
from .triage.llm import run_triage
|
|
|
|
AUTH_NOTICE = (
|
|
"AUTHORIZED USE ONLY — recon & triage scope. This tool enumerates assets, "
|
|
"normalizes output, and references Exploit-DB candidates. It never runs or "
|
|
"generates exploits. You are responsible for ensuring all targets are in scope."
|
|
)
|
|
|
|
app = typer.Typer(
|
|
add_completion=False,
|
|
help=f"recon-triage v{__version__}\n\n{AUTH_NOTICE}",
|
|
no_args_is_help=True,
|
|
)
|
|
|
|
|
|
def _setup_logging(verbose: bool) -> None:
|
|
logging.basicConfig(
|
|
level=logging.DEBUG if verbose else logging.INFO,
|
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
)
|
|
|
|
|
|
def _utcnow() -> str:
|
|
from datetime import datetime
|
|
|
|
return datetime.now(UTC).isoformat()
|
|
|
|
|
|
def _write_outputs(report: ReconReport, out_dir: Path) -> None:
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
(out_dir / "report.json").write_text(report.model_dump_json(indent=2) + "\n")
|
|
(out_dir / "report.md").write_text(markdown.render(report))
|
|
# Always export the schema alongside for inspection.
|
|
export_json_schema(out_dir / "schemas")
|
|
typer.echo(f"Wrote {out_dir/'report.json'} and {out_dir/'report.md'}")
|
|
|
|
|
|
@app.command()
|
|
def scan(
|
|
scope: Path = typer.Option(..., "--scope", help="Path to scope.yaml"),
|
|
target: str = typer.Option(..., "--target", help="In-scope root domain to enumerate"),
|
|
out: Path = typer.Option(Path("/data/out"), "--out", help="Output directory"),
|
|
enable_nuclei: bool = typer.Option(False, "--enable-nuclei", help="Run nuclei (needs network/time)"),
|
|
passive_only: bool = typer.Option(False, "--passive-only", help="Passive enumeration only"),
|
|
ports: str | None = typer.Option(None, "--ports", help="Explicit naabu port spec, e.g. '22,80,5000' or '1-10000'. Overrides --top-ports."),
|
|
top_ports: int = typer.Option(1000, "--top-ports", help="Scan naabu's top-N ports when --ports is unset (default 1000)."),
|
|
full_ports: bool = typer.Option(False, "--full-ports", help="Full 1-65535 sweep (slow). Shorthand for --ports '-'."),
|
|
rate_limit: int | None = typer.Option(None, "--rate-limit", help="Per-tool rate limit"),
|
|
timeout: float = typer.Option(600.0, "--timeout", help="Per-tool timeout (seconds)"),
|
|
templates_dir: str | None = typer.Option(None, "--templates-dir", help="nuclei templates cache dir"),
|
|
verbose: bool = typer.Option(False, "--verbose", "-v"),
|
|
) -> None:
|
|
"""Run the live recon pipeline against an in-scope target."""
|
|
_setup_logging(verbose)
|
|
typer.echo(AUTH_NOTICE)
|
|
sc = Scope.load(scope)
|
|
run_id = f"scan-{_utcnow()}"
|
|
port_spec = "-" if full_ports else ports
|
|
report = run_scan(
|
|
run_id=run_id,
|
|
target=target,
|
|
scope=sc,
|
|
enable_nuclei=enable_nuclei,
|
|
passive_only=passive_only,
|
|
rate_limit=rate_limit,
|
|
timeout=timeout,
|
|
templates_dir=templates_dir,
|
|
ports=port_spec,
|
|
top_ports=top_ports,
|
|
)
|
|
report.started_at = report.started_at or run_id.replace("scan-", "")
|
|
report.finished_at = _utcnow()
|
|
report.triage = run_triage(report)
|
|
_write_outputs(report, out)
|
|
|
|
|
|
@app.command()
|
|
def replay(
|
|
fixtures: Path = typer.Option(Path("tests/fixtures"), "--fixtures", help="Fixtures directory"),
|
|
out: Path = typer.Option(Path("/data/out"), "--out", help="Output directory"),
|
|
scope: Path | None = typer.Option(None, "--scope", help="Optional scope.yaml to gate fixtures"),
|
|
enable_nuclei: bool = typer.Option(True, "--enable-nuclei/--no-nuclei", help="Include nuclei fixture"),
|
|
verbose: bool = typer.Option(False, "--verbose", "-v"),
|
|
) -> None:
|
|
"""Run the full normalize->ground->report path on canned fixtures (no network)."""
|
|
_setup_logging(verbose)
|
|
typer.echo(AUTH_NOTICE)
|
|
sc = Scope.load(scope) if scope else None
|
|
report = build_report_from_fixtures(
|
|
fixtures, run_id="replay", scope=sc, enable_nuclei=enable_nuclei
|
|
)
|
|
report.started_at = "replay"
|
|
report.finished_at = "replay"
|
|
report.triage = run_triage(report)
|
|
_write_outputs(report, out)
|
|
|
|
|
|
@app.command()
|
|
def schema(out: Path = typer.Option(Path("schemas"), "--out")) -> None:
|
|
"""Export the JSON Schema for the report contract."""
|
|
p = export_json_schema(out)
|
|
typer.echo(f"Wrote schema to {p.parent}")
|
|
|
|
|
|
def main() -> None: # entrypoint for the console script
|
|
app()
|
|
|
|
|
|
if __name__ == "__main__": # pragma: no cover
|
|
main()
|