From 8aed88e2b6cd0f0bd575778cd9f4c1bd0ec1f066 Mon Sep 17 00:00:00 2001 From: CamiloCod3 Date: Wed, 17 Jun 2026 18:13:02 +0200 Subject: [PATCH] Clean up CLI scan summary output --- README.md | 4 +- activerecon/cli.py | 92 +++++++++++++++++++++++++++++++++++++++++---- tests/test_cli.py | 94 +++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 170 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index eb61ede..bda9038 100644 --- a/README.md +++ b/README.md @@ -254,8 +254,8 @@ activerecon --doctor | `--output-format` | `md`, `json`, or `both`. Defaults to `both` | | `--scope` | Optional file with allowed domains, IPs, or CIDR ranges | | `--dry-run` | Validate arguments and planned outputs without scanning | -| `--verbose` | Show debug logging | -| `--quiet` | Show only warnings and errors | +| `--verbose` | Show detailed internal logs | +| `--quiet` | Suppress the normal summary and show only errors plus report paths | --- diff --git a/activerecon/cli.py b/activerecon/cli.py index 00894f6..cc11b58 100644 --- a/activerecon/cli.py +++ b/activerecon/cli.py @@ -3,36 +3,42 @@ from .models import ReconOptions from .modules.doctor import run_doctor +from .modules.json_report import build_json_summary from .output_paths import DEFAULT_REPORT_DIR from .runner import ReconValidationError, run_recon LOG_FORMAT = "%(asctime)s - %(levelname)s - %(message)s" +QUIET_LOG_FORMAT = "%(levelname)s: %(message)s" def configure_logging(verbose=False, quiet=False): if verbose: level = logging.DEBUG + log_format = LOG_FORMAT elif quiet: - level = logging.WARNING + level = logging.ERROR + log_format = QUIET_LOG_FORMAT else: - level = logging.INFO + level = logging.WARNING + log_format = QUIET_LOG_FORMAT root_logger = logging.getLogger() if root_logger.handlers: root_logger.setLevel(level) for handler in root_logger.handlers: handler.setLevel(level) + handler.setFormatter(logging.Formatter(log_format)) else: - logging.basicConfig(level=level, format=LOG_FORMAT) + logging.basicConfig(level=level, format=log_format) def build_parser(): parser = argparse.ArgumentParser(description="Active Recon Tool") parser.add_argument("--target", help="Target IP or domain") parser.add_argument("--doctor", action="store_true", help="Check local dependencies and exit without scanning") - parser.add_argument("--verbose", action="store_true", help="Show debug logging") - parser.add_argument("--quiet", action="store_true", help="Only show warnings and errors") + parser.add_argument("--verbose", action="store_true", help="Show detailed internal logs") + parser.add_argument("--quiet", action="store_true", help="Show only errors and report paths") parser.add_argument( "--output", default=None, @@ -71,6 +77,70 @@ def options_from_args(args): ) +def _as_list(value): + return value if isinstance(value, list) else [] + + +def _dns_summary(result, summary): + dns_results = result.results.get("DNS Analysis", {}) + if isinstance(dns_results, dict) and dns_results.get("skipped"): + return "skipped for IP target" + return f"{summary['dns_records']} records" + + +def _endpoint_count(result, summary): + if "Endpoint Discovery" not in result.results: + return None + return summary["endpoint_count"] + + +def _report_paths(result): + paths = [] + if result.markdown_output: + paths.append(("Markdown", result.markdown_output)) + if result.json_output: + paths.append(("JSON", result.json_output)) + return paths + + +def print_report_paths(result, output=print): + paths = _report_paths(result) + if not paths: + return + for label, path in paths: + output(f"{label}: {path}") + + +def print_scan_summary(result, output=print): + summary = build_json_summary(result.results) + endpoint_count = _endpoint_count(result, summary) + + title = "ActiveRecon dry run completed" if result.dry_run else "ActiveRecon scan completed" + output(title) + output("") + output(f"Target: {result.target}") + output(f"Profile: {result.scan_profile}") + output("") + + if result.dry_run: + output("No scan executed.") + else: + output(f"Nmap: {summary['total_ports_listed']} ports listed, {summary['open_ports']} open") + output(f"HTTP: {summary['http_services']} service analyzed") + output(f"TLS: {summary['tls_results']} HTTPS services analyzed") + output(f"DNS: {_dns_summary(result, summary)}") + if endpoint_count is not None: + output(f"Endpoints: {endpoint_count} discovered") + output(f"Interesting Signals: {summary['interesting_signals']}") + + paths = _report_paths(result) + if paths: + output("") + output("Reports:") + for label, path in paths: + output(f"- {label}: {path}") + + def main(argv=None): parser = build_parser() args = parser.parse_args(argv) @@ -81,13 +151,19 @@ def main(argv=None): if args.doctor: run_doctor(DEFAULT_REPORT_DIR) - return None + return 0 if not args.target: parser.error("--target is required unless --doctor is used") try: - return run_recon(options_from_args(args)) + result = run_recon(options_from_args(args)) except ReconValidationError as e: parser.error(str(e)) - return None + return 2 + + if args.quiet: + print_report_paths(result) + else: + print_scan_summary(result) + return 0 diff --git a/tests/test_cli.py b/tests/test_cli.py index edbb027..b2ddefe 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -4,6 +4,44 @@ from activerecon.models import ReconOptions, ReconResult, TargetSpec +def _sample_result(): + return ReconResult( + target="127.0.0.1", + target_spec=TargetSpec(raw="127.0.0.1", host="127.0.0.1", is_ip=True), + scan_profile="web", + markdown_output="reports\\juice-shop_20260617_180544.md", + json_output="reports\\juice-shop_20260617_180544.json", + results={ + "Nmap Scan": { + "status": {"state": "up"}, + "ports": [ + {"portid": "80", "state": "open"}, + {"portid": "443", "state": "closed"}, + ], + }, + "HTTP Analysis": [{"url": "http://127.0.0.1:80", "status": 200}], + "TLS Analysis": [], + "DNS Analysis": { + "skipped": True, + "reason": "DNS analysis skipped for IP address target", + "A": [], + "MX": [], + "TXT": [], + }, + "Endpoint Discovery": [{ + "base_url": "http://127.0.0.1", + "endpoints": [ + {"path": "/api"}, + {"path": "/login"}, + {"path": "/api"}, + ], + }], + "Attention": [{"severity": "info", "message": "signal"}], + "Interesting Signals": [{"severity": "info", "message": "signal"}], + }, + ) + + def test_options_from_args_preserves_cli_flags(): parser = cli.build_parser() args = parser.parse_args([ @@ -39,7 +77,7 @@ def test_cli_doctor_skips_recon(monkeypatch): monkeypatch.setattr(cli, "run_doctor", lambda reports_dir: called.append(reports_dir)) monkeypatch.setattr(cli, "run_recon", lambda options: (_ for _ in ()).throw(AssertionError())) - assert cli.main(["--doctor"]) is None + assert cli.main(["--doctor"]) == 0 assert called == [cli.DEFAULT_REPORT_DIR] @@ -50,20 +88,56 @@ def test_cli_requires_target_without_doctor(): assert exc.value.code == 2 -def test_cli_calls_runner(monkeypatch): +def test_cli_calls_runner_and_prints_clean_summary(monkeypatch, capsys): captured = {} def fake_run_recon(options): captured["options"] = options - return ReconResult( - target=options.target, - target_spec=TargetSpec(raw=options.target, host=options.target), - scan_profile=options.scan_profile, - ) + return _sample_result() monkeypatch.setattr(cli, "run_recon", fake_run_recon) - result = cli.main(["--target", "example.com", "--scan-profile", "fast", "--quiet"]) + result = cli.main(["--target", "127.0.0.1", "--scan-profile", "web"]) + captured_output = capsys.readouterr() + + assert result == 0 + assert captured["options"].target == "127.0.0.1" + assert "ReconResult(" not in captured_output.out + assert "ActiveRecon scan completed" in captured_output.out + assert "Target: 127.0.0.1" in captured_output.out + assert "Profile: web" in captured_output.out + assert "Nmap: 2 ports listed, 1 open" in captured_output.out + assert "HTTP: 1 service analyzed" in captured_output.out + assert "TLS: 0 HTTPS services analyzed" in captured_output.out + assert "DNS: skipped for IP target" in captured_output.out + assert "Endpoints: 2 discovered" in captured_output.out + assert "Interesting Signals: 1" in captured_output.out + assert "- Markdown: reports\\juice-shop_20260617_180544.md" in captured_output.out + assert "- JSON: reports\\juice-shop_20260617_180544.json" in captured_output.out + + +def test_cli_quiet_suppresses_summary_but_keeps_report_paths(monkeypatch, capsys): + monkeypatch.setattr(cli, "run_recon", lambda options: _sample_result()) + + result = cli.main(["--target", "127.0.0.1", "--scan-profile", "web", "--quiet"]) + captured_output = capsys.readouterr() + + assert result == 0 + assert "ActiveRecon scan completed" not in captured_output.out + assert "Nmap:" not in captured_output.out + assert "Markdown: reports\\juice-shop_20260617_180544.md" in captured_output.out + assert "JSON: reports\\juice-shop_20260617_180544.json" in captured_output.out + + +def test_cli_verbose_enables_detailed_logging(monkeypatch): + captured = {} + + monkeypatch.setattr(cli, "run_recon", lambda options: _sample_result()) + monkeypatch.setattr( + cli, + "configure_logging", + lambda verbose=False, quiet=False: captured.update(verbose=verbose, quiet=quiet), + ) - assert result.target == "example.com" - assert captured["options"].quiet is True + assert cli.main(["--target", "127.0.0.1", "--verbose"]) == 0 + assert captured == {"verbose": True, "quiet": False}