from __future__ import annotations import argparse import json import os import sys from pathlib import Path from . import __version__ from .core.scope import ScopeError, ScopePolicy, normalize_target from .core.standards import registry_document from .core.runner import Runner from .core.storage import RunStore, target_run_id from .modules.repository import run_repository_intelligence from .modules.base import ModuleContext from .profiles import PROFILES, build_modules from .interactive import interactive_arguments, yes_no from .ui import LiveConsole from .visualize import VisualServer def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="redflare", description="Authorized web assessment orchestrator") parser.add_argument("--version", action="version", version=f"REDflare {__version__}") sub = parser.add_subparsers(dest="command", required=True) sub.add_parser("modules", help="List profiles and modules") sub.add_parser("doctor", help="Check native REDflare capabilities") sub.add_parser("tests", help="List stable test IDs and standards mappings") visualize = sub.add_parser("visualize", help="Open the local visual investigation console for a run") visualize.add_argument("run_directory", help="REDflare run directory or local file:/// URL") visualize.add_argument("--port", type=int, default=8765, help="Loopback port (0 chooses an available port)") visualize.add_argument("--no-browser", action="store_true", help="Do not open the default browser") intel = sub.add_parser("intel", help="Run native REDflare repository secret intelligence") intel.add_argument("--repo", action="append", required=True, help="Authorized GitHub repository URL; repeatable") intel.add_argument("--authorized", action="store_true", help="Acknowledge authorization for every repository") default_runs = str(Path(os.environ.get("REDFLARE_HOME", Path.cwd())) / "runs") intel.add_argument("--output", default=default_runs, help="Base run output directory") scan = sub.add_parser("scan", help="Run an authorized assessment") scan.add_argument("targets", nargs="*", help="HTTP(S) URLs or hostnames") scan.add_argument("--targets-file", help="Targets file, one per line") scan.add_argument("--scope", help="JSON scope policy with allowed_hosts") scan.add_argument("--authorized", action="store_true", help="Acknowledge explicit authorization for every target") scan.add_argument("--allow-public", action="store_true", help="Permit authorized public targets") scan.add_argument("--profile", choices=sorted(PROFILES), default="quick") scan.add_argument("--output", default=default_runs, help="Base run output directory") scan.add_argument("--wordlist", help="Path wordlist for the web profile") scan.add_argument("--max-paths", type=int, default=100) scan.add_argument("--max-crawl-pages", type=int, default=30) scan.add_argument("--max-crawl-depth", type=int, default=2) scan.add_argument("--max-scripts", type=int, default=20) scan.add_argument("--max-schema-documents", type=int, default=8) scan.add_argument("--max-exposure-endpoints", type=int, default=75) scan.add_argument("--max-exposure-findings", type=int, default=100) scan.add_argument("--max-exposure-body-bytes", type=int, default=2_000_000) scan.add_argument("--max-cve-products", type=int, default=12, help="Maximum exact product/version fingerprints to correlate with NVD") scan.add_argument("--max-cves-per-product", type=int, default=100, help="Maximum NVD CVEs retained per fingerprint") scan.add_argument( "--graphql-introspection", action="store_true", help="Explicitly permit bounded GraphQL schema introspection on in-scope endpoints", ) scan.add_argument("--rate", type=float, default=2.0, help="Path requests per second") scan.add_argument("--timeout", type=float, default=8.0) scan.add_argument("--workers", type=int, default=2) scan.add_argument("--github-repo", action="append", default=[], help="Associated authorized GitHub repository URL; repeatable") return parser def main(argv: list[str] | None = None) -> int: guided = argv is None and len(sys.argv) == 1 if guided: argv = interactive_arguments() if argv is None: return 0 args = build_parser().parse_args(argv) if args.command == "modules": for name, classes in PROFILES.items(): print(f"{name:5} " + ", ".join(module.name for module in classes)) return 0 if args.command == "doctor": try: import playwright # noqa: F401 browser = "native-playwright" except ImportError: browser = "native-http-fallback" print(json.dumps({"standalone": True, "external_tools_required": False, "capabilities": {"browser_runtime": browser, "unauthenticated_surface": "native", "repository_intelligence": "native"}}, indent=2)) return 0 if args.command == "tests": print(json.dumps(registry_document(), indent=2)) return 0 if args.command == "visualize": try: server = VisualServer(args.run_directory, max(0, args.port)) except (OSError, ValueError, json.JSONDecodeError) as exc: print(f"Visualization error: {exc}", file=sys.stderr) return 2 server.serve(open_browser=not args.no_browser) return 0 if args.command == "intel": if not args.authorized: print("Refusing to run intelligence collection without --authorized.", file=sys.stderr) return 2 store = RunStore(args.output) result = run_repository_intelligence(args.repo, store.artifacts / "repository_intelligence") store.write_manifest( {"run_id": store.run_id, "kind": "repository-intelligence", "repositories": args.repo} ) (store.root / "summary.json").write_text(json.dumps(result, indent=2), encoding="utf-8") print(json.dumps(result, indent=2)) return 0 if result["status"] == "completed" else 1 if not args.authorized: print("Refusing to scan without --authorized acknowledgement.", file=sys.stderr) return 2 values = list(args.targets) if args.targets_file: values.extend( line.strip() for line in Path(args.targets_file).read_text(encoding="utf-8").splitlines() if line.strip() and not line.lstrip().startswith("#") ) if not values: print("Provide at least one target or --targets-file.", file=sys.stderr) return 2 try: policy = ScopePolicy.from_file(args.scope, allow_public=args.allow_public) targets = [] seen = set() for value in values: target = normalize_target(value) policy.validate(target) if target.url not in seen: targets.append(target) seen.add(target.url) except (ScopeError, OSError, json.JSONDecodeError) as exc: print(f"Scope error: {exc}", file=sys.stderr) return 2 store = RunStore(args.output, target_run_id(targets)) console = LiveConsole() context = ModuleContext( run_id=store.run_id, artifact_dir=store.artifacts, timeout=args.timeout, rate=args.rate, wordlist=args.wordlist, max_paths=max(1, args.max_paths), max_crawl_pages=max(1, args.max_crawl_pages), max_crawl_depth=max(0, args.max_crawl_depth), max_scripts=max(0, args.max_scripts), max_schema_documents=max(0, args.max_schema_documents), max_exposure_endpoints=max(1, args.max_exposure_endpoints), max_exposure_findings=max(1, args.max_exposure_findings), max_exposure_body_bytes=max(1_024, args.max_exposure_body_bytes), max_cve_products=max(1, args.max_cve_products), max_cves_per_product=max(1, args.max_cves_per_product), graphql_introspection=args.graphql_introspection, allow_public=args.allow_public, reporter=console.emit, ) print(f"REDflare {__version__} | run={store.run_id} | profile={args.profile}") print(f"Evidence: {store.root}") results, summary = Runner(store, build_modules(args.profile), context, args.workers).run(targets) if args.github_repo: intel = run_repository_intelligence(args.github_repo, store.artifacts / "repository_intelligence") summary["repository_intelligence"] = intel (store.root / "summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8") console.final_report(summary, results, store.root) intel_ok = not args.github_repo or summary["repository_intelligence"]["status"] == "completed" if guided and yes_no(f"Open this run in the visual console now ({store.root.name})", True): try: VisualServer(store.root, 8765).serve(open_browser=True) except (OSError, ValueError, json.JSONDecodeError) as exc: print(f"Visualization error: {exc}", file=sys.stderr) return 0 if summary["errors"] == 0 and intel_ok else 1