Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---

Expand Down
92 changes: 84 additions & 8 deletions activerecon/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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
94 changes: 84 additions & 10 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down Expand Up @@ -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]


Expand All @@ -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}
Loading