"""Scope enforcement — mandatory gate. Anything not explicitly in scope is dropped. A ``scope.yaml`` declares in-scope domains and CIDRs plus optional out-of-scope exclusions. The tool refuses to touch anything not in scope: out-of-scope input is dropped with a logged warning, never scanned. """ from __future__ import annotations import ipaddress import logging from dataclasses import dataclass, field from pathlib import Path import yaml from .schema import ScopeUsed log = logging.getLogger("recon_triage.scope") def _norm_domain(d: str) -> str: return d.strip().lower().rstrip(".") def _is_ip(value: str) -> bool: try: ipaddress.ip_address(value) return True except ValueError: return False @dataclass class Scope: """Loaded scope rules with in/out gating. Domain matching includes subdomains (``example.com`` matches ``api.example.com``). Out-of-scope rules take precedence over in-scope rules. """ domains: list[str] = field(default_factory=list) cidrs: list[str] = field(default_factory=list) out_of_scope: list[str] = field(default_factory=list) @classmethod def load(cls, path: str | Path) -> Scope: data = yaml.safe_load(Path(path).read_text()) or {} return cls.from_dict(data) @classmethod def from_dict(cls, data: dict) -> Scope: domains = [_norm_domain(d) for d in (data.get("in_scope_domains") or []) if d] cidrs = [str(c).strip() for c in (data.get("in_scope_cidrs") or []) if c] out = [str(o).strip().lower().rstrip(".") for o in (data.get("out_of_scope") or []) if o] # Validate CIDRs early; drop invalid with a warning rather than crashing. valid_cidrs = [] for c in cidrs: try: ipaddress.ip_network(c, strict=False) valid_cidrs.append(c) except ValueError: log.warning("Ignoring invalid CIDR in scope: %s", c) return cls(domains=domains, cidrs=valid_cidrs, out_of_scope=out) # -- matching ----------------------------------------------------------- def _domain_in(self, host: str, rules: list[str]) -> bool: host = _norm_domain(host) for rule in rules: if host == rule or host.endswith("." + rule): return True return False def _ip_in_cidrs(self, ip: str) -> bool: try: addr = ipaddress.ip_address(ip) except ValueError: return False for c in self.cidrs: try: if addr in ipaddress.ip_network(c, strict=False): return True except ValueError: continue return False def _ip_out(self, ip: str) -> bool: for rule in self.out_of_scope: if rule == ip: return True # out_of_scope may also carry CIDRs try: if ipaddress.ip_address(ip) in ipaddress.ip_network(rule, strict=False): return True except ValueError: continue return False def is_in_scope(self, value: str) -> bool: """True iff ``value`` (a hostname or IP) is in scope and not excluded.""" value = value.strip() if not value: return False if _is_ip(value): if self._ip_out(value): return False return self._ip_in_cidrs(value) # hostname if self._domain_in(value, self.out_of_scope): return False return self._domain_in(value, self.domains) def filter(self, values: list[str]) -> list[str]: """Return only in-scope values; log a warning for each dropped one.""" kept: list[str] = [] for v in values: if self.is_in_scope(v): kept.append(v) else: log.warning("Dropping out-of-scope target: %s", v) return kept def as_used(self) -> ScopeUsed: return ScopeUsed( in_scope_domains=list(self.domains), in_scope_cidrs=list(self.cidrs), out_of_scope=list(self.out_of_scope), )