feat: project scaffold, unified Pydantic schema, scope gating

This commit is contained in:
2026-06-29 17:35:50 -04:00
commit 1f531e4948
9 changed files with 1239 additions and 0 deletions
+129
View File
@@ -0,0 +1,129 @@
"""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),
)