diff --git a/recon-triage/src/recon_triage/cli.py b/recon-triage/src/recon_triage/cli.py index a28380e..6aac30c 100644 --- a/recon-triage/src/recon_triage/cli.py +++ b/recon-triage/src/recon_triage/cli.py @@ -61,6 +61,9 @@ def scan( 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"), @@ -71,6 +74,7 @@ def scan( 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, @@ -80,6 +84,8 @@ def scan( 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() diff --git a/recon-triage/src/recon_triage/orchestrator.py b/recon-triage/src/recon_triage/orchestrator.py index b35a4be..8720db7 100644 --- a/recon-triage/src/recon_triage/orchestrator.py +++ b/recon-triage/src/recon_triage/orchestrator.py @@ -261,6 +261,8 @@ def run_scan( timeout: float = 600.0, templates_dir: str | None = None, search_fn: SearchFn | None = None, + ports: str | None = None, + top_ports: int | None = 1000, ) -> ReconReport: """Run the full live pipeline. Scope is enforced before any active probing. @@ -292,9 +294,12 @@ def run_scan( stages.append(StageResult(name=name, status=StageStatus.SKIPPED, detail="passive-only")) return _finalize(run_id, target, scope, table, stages, [], search_fn) - # Stage 3: naabu connect-scan (unprivileged) + # Stage 3: naabu connect-scan (unprivileged). Port breadth widened beyond + # naabu's top-100 default so high/uncommon-port services aren't missed. scan_targets = _scope_targets(table, scope) - nb, nb_run = naabu.run(scan_targets, timeout=timeout, rate_limit=rate_limit) + nb, nb_run = naabu.run( + scan_targets, timeout=timeout, rate_limit=rate_limit, ports=ports, top_ports=top_ports + ) nb = [h for h in nb if _hit_in_scope(h, scope)] table.add_naabu(nb) stages.append(_stage("naabu", nb_run, count=len(nb))) @@ -308,8 +313,12 @@ def run_scan( table.add_nmap(nm) stages.append(_stage("nmap", nm_run, count=len(nm))) - # Stage 5: httpx probing - hx, hx_run = httpx.run(_scope_targets(table, scope), timeout=timeout, rate_limit=rate_limit) + # Stage 5: httpx probing. Feed it every discovered open port — otherwise + # httpx only checks 80/443 and misses web apps on ports like 5000/8080. + open_for_http = sorted({p.number for h in table.hosts() for p in h.ports}) + hx, hx_run = httpx.run( + _scope_targets(table, scope), timeout=timeout, rate_limit=rate_limit, ports=open_for_http + ) table.add_httpx(hx) stages.append(_stage("httpx", hx_run, count=len(hx))) diff --git a/recon-triage/src/recon_triage/tools/httpx.py b/recon-triage/src/recon_triage/tools/httpx.py index e4cba2b..0171481 100644 --- a/recon-triage/src/recon_triage/tools/httpx.py +++ b/recon-triage/src/recon_triage/tools/httpx.py @@ -82,8 +82,18 @@ def _looks_like_ip(v) -> bool: return False -def run(targets: list[str], *, timeout: float = 180.0, rate_limit: int | None = None) -> tuple[list[HttpProbe], object]: +def run( + targets: list[str], + *, + timeout: float = 180.0, + rate_limit: int | None = None, + ports: list[int] | None = None, +) -> tuple[list[HttpProbe], object]: cmd = [BINARY, "-json", "-td", "-silent", "-title", "-tech-detect", "-web-server", "-status-code"] + # Without -p, httpx only probes 80/443. Feed it the ports discovered by + # naabu/nmap so web apps on non-standard ports (e.g. 5000, 8080) are probed. + if ports: + cmd += ["-p", ",".join(str(p) for p in sorted(set(ports)))] if rate_limit: cmd += ["-rate-limit", str(rate_limit)] res = run_tool(cmd, timeout=timeout, input_text="\n".join(targets) + "\n") diff --git a/recon-triage/src/recon_triage/tools/naabu.py b/recon-triage/src/recon_triage/tools/naabu.py index 3c5257f..05ee9fc 100644 --- a/recon-triage/src/recon_triage/tools/naabu.py +++ b/recon-triage/src/recon_triage/tools/naabu.py @@ -57,11 +57,18 @@ def run( timeout: float = 300.0, rate_limit: int | None = None, ports: str | None = None, + top_ports: int | None = 1000, ) -> tuple[list[PortHit], object]: # connect scan keeps the container unprivileged (no NET_RAW needed). cmd = [BINARY, "-json", "-scan-type", "connect", "-silent"] + # naabu's own default is only the top-100 ports, which silently misses + # services on higher/uncommon ports (a frequent THM/HTB/VulnHub case). + # An explicit `ports` spec wins; otherwise widen to top-N (default 1000). + # Pass ports="-" for a full 1-65535 sweep. if ports: cmd += ["-p", ports] + elif top_ports: + cmd += ["-top-ports", str(top_ports)] if rate_limit: cmd += ["-rate", str(rate_limit)] res = run_tool(cmd, timeout=timeout, input_text="\n".join(targets) + "\n") diff --git a/recon-triage/src/recon_triage/tools/nmap.py b/recon-triage/src/recon_triage/tools/nmap.py index 60f1a38..2c4ce39 100644 --- a/recon-triage/src/recon_triage/tools/nmap.py +++ b/recon-triage/src/recon_triage/tools/nmap.py @@ -102,8 +102,14 @@ def run( *, timeout: float = 600.0, unprivileged: bool = True, + version_all: bool = True, ) -> tuple[list[NmapHost], object]: cmd = [BINARY, "-sV", "-oX", "-", "-Pn"] + if version_all: + # Max version-detection intensity. Default (7) often yields only a + # low-confidence service name with null product/version on services + # that don't volunteer a banner; intensity 9 probes harder. + cmd.append("--version-all") if unprivileged: cmd.append("-sT") # TCP connect scan; no raw sockets required if ports: diff --git a/recon-triage/tests/test_port_widening.py b/recon-triage/tests/test_port_widening.py new file mode 100644 index 0000000..4de94b0 --- /dev/null +++ b/recon-triage/tests/test_port_widening.py @@ -0,0 +1,63 @@ +"""The recon stages must scan wider than the tools' narrow built-in defaults. + +These guard the regressions behind the 'single open port / no banner' report: +- naabu must not silently fall back to its top-100 default. +- httpx must be told the discovered open ports (else it only probes 80/443). +- nmap must probe version detection at full intensity. +""" + +from __future__ import annotations + +from recon_triage.tools import httpx, naabu, nmap +from recon_triage.tools.base import ToolRun + + +def _capture(monkeypatch, module): + """Patch a tool module's run_tool to record the command and return empty output.""" + seen: dict = {} + + def fake_run_tool(cmd, *, timeout=120.0, input_text=None): + seen["cmd"] = cmd + return ToolRun(cmd=cmd, returncode=0, stdout="", stderr="") + + monkeypatch.setattr(module, "run_tool", fake_run_tool) + return seen + + +def test_naabu_widens_beyond_top_100_by_default(monkeypatch): + seen = _capture(monkeypatch, naabu) + naabu.run(["10.0.0.1"]) + cmd = seen["cmd"] + # Default must explicitly request top-1000, not rely on naabu's top-100 default. + assert "-top-ports" in cmd + assert cmd[cmd.index("-top-ports") + 1] == "1000" + + +def test_naabu_explicit_ports_override_top_ports(monkeypatch): + seen = _capture(monkeypatch, naabu) + naabu.run(["10.0.0.1"], ports="-") # full sweep + cmd = seen["cmd"] + assert "-p" in cmd and cmd[cmd.index("-p") + 1] == "-" + assert "-top-ports" not in cmd # explicit ports win + + +def test_httpx_probes_discovered_ports(monkeypatch): + seen = _capture(monkeypatch, httpx) + httpx.run(["10.0.0.1"], ports=[5000, 8080]) + cmd = seen["cmd"] + assert "-p" in cmd + assert cmd[cmd.index("-p") + 1] == "5000,8080" + + +def test_httpx_without_ports_omits_flag(monkeypatch): + seen = _capture(monkeypatch, httpx) + httpx.run(["10.0.0.1"]) + assert "-p" not in seen["cmd"] + + +def test_nmap_uses_full_version_intensity(monkeypatch): + seen = _capture(monkeypatch, nmap) + nmap.run(["10.0.0.1"], ports=[5000]) + cmd = seen["cmd"] + assert "-sV" in cmd + assert "--version-all" in cmd