from dataclasses import dataclass from typing import Callable import shlex import re from app.models import ScanOptions ProfileName = str @dataclass(frozen=True) class PhaseDef: name: str tool: str command_template: str requires: tuple[str, ...] = () success_exit_codes: tuple[int, ...] = (0,) timeout_seconds: int | None = None retries: int = 0 retry_delay_seconds: float = 0.0 llm_analyze: bool = True def should_run(self, options: ScanOptions) -> bool: if "include_web" in self.requires and not options.include_web: return False if "include_brute" in self.requires and not options.include_brute: return False if "include_ssl" in self.requires and not options.include_ssl: return False return True def _target_url(target: str) -> str: if target.startswith("http://") or target.startswith("https://"): return target return f"http://{target}" def _https_target_url(target: str) -> str: if target.startswith("https://"): return target if target.startswith("http://"): return "https://" + target.removeprefix("http://") return f"https://{target}" def _root_domain(target: str) -> str: # Best-effort heuristic for homelab/public domains like levkin.ca. # If target is an IP, just return it. host = target if host.startswith("http://") or host.startswith("https://"): host = re.sub(r"^https?://", "", host) host = host.split("/")[0] if re.fullmatch(r"\d{1,3}(\.\d{1,3}){3}", host): return host parts = [p for p in host.split(".") if p] if len(parts) >= 2: return ".".join(parts[-2:]) return host def _ctx(target: str, options: ScanOptions, scan_id: str, open_ports: str = "80,443") -> dict[str, str]: t = shlex.quote(target) return { "target": t, "target_url": shlex.quote(_target_url(target)), "https_target_url": shlex.quote(_https_target_url(target)), "root_domain": shlex.quote(_root_domain(target)), "port_range": shlex.quote(options.port_range), "open_ports": shlex.quote(open_ports), "scan_id": shlex.quote(scan_id), "users": shlex.quote("/usr/share/seclists/Usernames/top-usernames-shortlist.txt"), "pwds": shlex.quote("/usr/share/wordlists/rockyou.txt"), } def render_command(template: str, ctx: dict[str, str]) -> list[str]: rendered = template.format(**ctx) return shlex.split(rendered) PASSIVE: list[PhaseDef] = [ PhaseDef("host_alive", "nmap", "nmap -sn {target}"), PhaseDef( "port_scan", "nmap", "nmap -sS -p {port_range} --open -T4 -oX - {target}", ), PhaseDef( "service_detect", "nmap", "nmap -sV -sC -p {open_ports} -oX - {target}", llm_analyze=True, ), PhaseDef( "ssl_check", "testssl", "testssl.sh --jsonfile-pretty - {final_https_url}", requires=("include_ssl",), success_exit_codes=(0, 1), timeout_seconds=600, retries=1, retry_delay_seconds=2.0, ), ] STANDARD_EXTRA: list[PhaseDef] = [ PhaseDef("web_fingerprint", "whatweb", "whatweb --log-json=- {final_url}", requires=("include_web",)), PhaseDef( "http_headers", "curl", "curl -sI -L {final_url}", requires=("include_web",), llm_analyze=False, ), PhaseDef( "dir_enum", "gobuster", "/bin/sh -lc \"set -e; " "url={final_url}; " "wl=''; " "for f in " "/usr/share/wordlists/dirb/common.txt " "/usr/share/wordlists/dirb/big.txt " "/usr/share/seclists/Discovery/Web-Content/common.txt " "/usr/share/seclists/Discovery/Web-Content/directory-list-2.3-small.txt " "; do " "if [ -f \\\"$f\\\" ]; then wl=\\\"$f\\\"; break; fi; " "done; " "if [ -z \\\"$wl\\\" ]; then " "echo 'ERROR: no wordlist found (install dirb/seclists or mount one)'; " "exit 2; " "fi; " "echo 'Using wordlist:' $wl; " "gobuster dir -u \\\"$url\\\" -w \\\"$wl\\\" -o - -q -r --random-agent --delay 100ms -t 10\"", requires=("include_web",), timeout_seconds=600, retries=1, retry_delay_seconds=2.0, ), PhaseDef( "web_misconfig", "curl", "/bin/sh -lc \"set -e; " "base={final_https_url}; " "echo 'Base:' $base; " "echo; " "for p in /.git/ /.git/config /.env /security.txt /.well-known/security.txt /robots.txt /sitemap.xml /sitemap_index.xml /backup /old /admin ; do " "echo '== '\"'$p'\"; " "curl -skI --max-time 10 \"$base$p\" | tr -d '\\r' || true; " "echo; " "done; " "echo '== CSP header on /'; " "curl -skI --max-time 10 \"$base/\" | tr -d '\\r' | (grep -i '^content-security-policy:' || echo '(no Content-Security-Policy header)')\"", requires=("include_web",), llm_analyze=False, timeout_seconds=120, retries=1, retry_delay_seconds=1.0, ), PhaseDef( "nikto", "nikto", "nikto -h {final_url} -Format json -output -", requires=("include_web",), ), PhaseDef( "mail_dns", "dig", "/bin/sh -lc \"set -e; " "d={root_domain}; " "echo 'Domain:' $d; " "echo; " "echo '== SPF (TXT)'; dig +short TXT $d || true; " "echo; " "echo '== DMARC (_dmarc)'; dig +short TXT _dmarc.$d || true; " "echo; " "echo '== DKIM (common selectors)'; " "for s in default selector1 selector2 google mail ; do " "echo '-- '\"'$s'\"; " "dig +short TXT $s._domainkey.$d || true; " "done\"", llm_analyze=False, timeout_seconds=60, retries=1, retry_delay_seconds=1.0, ), ] REDTEAM_EXTRA: list[PhaseDef] = [ PhaseDef( "sqlmap_probe", "sqlmap", "sqlmap -u {target_url} --batch --crawl=2 --level=2 --risk=1 --output-dir=/tmp/sqlmap-{scan_id}", requires=("include_web",), ), PhaseDef( "ssh_brute", "hydra", "hydra -L {users} -P {pwds} -t 4 -f ssh://{target}", requires=("include_brute",), llm_analyze=False, ), PhaseDef( "searchsploit", "searchsploit", "searchsploit --json {target}", llm_analyze=True, ), ] _PROFILE_PHASES: dict[ProfileName, list[PhaseDef]] = { "passive": PASSIVE, "standard": PASSIVE + STANDARD_EXTRA, "redteam": PASSIVE + STANDARD_EXTRA + REDTEAM_EXTRA, } def get_phase_defs(profile: ProfileName) -> list[PhaseDef]: return list(_PROFILE_PHASES.get(profile, PASSIVE)) def get_profiles_payload() -> list[dict[str, object]]: return [ { "id": "passive", "label": "Passive Recon", "description": "Host discovery, port scan, service detection, optional SSL check.", "phase_count": len(PASSIVE), }, { "id": "standard", "label": "Standard Pentest", "description": "Passive plus web fingerprinting, directory enum, and Nikto.", "phase_count": len(PASSIVE) + len(STANDARD_EXTRA), }, { "id": "redteam", "label": "Full Red Team", "description": "Standard plus sqlmap, optional SSH brute, searchsploit.", "phase_count": len(PASSIVE) + len(STANDARD_EXTRA) + len(REDTEAM_EXTRA), }, ]