Feature: Sandbox du scope propagé sur footholds
Contexte
AttackSim propage le scanner sur des footholds via --propagate (Docker, kubelet, SSH, ADB, cloud). L'instance propagée hérite du ScopeGuard du parent — mais ScopeGuard n'expose pas de méthode de sérialisation (.specs), et l'héritage de scope échoue silencieusement. L'instance propagée se retrouve avec un guard vide → authorize() retourne True pour tout hôte à intrusive+ → le scanner déployé dans un container d'un réseau interne 10.0.5.0/24 peut scanner toute la /24 voire au-delà sans aucune restriction.
La promesse "every peer is scope-checked" est vraie pour le scanner parent, fausse pour l'instance propagée.
Description
- Exposer une méthode de sérialisation du
ScopeGuard pour transmettre le scope à l'instance propagée.
- Toujours injecter
--scope <foothold_subnet> comme borne minimale dans la propagation.
- Refuser de scanner hors du sous-réseau du foothold sauf
--scope explicite.
Spécifications fonctionnelles
Comportement attendu
-
Quand --propagate déploie le scanner sur un foothold à l'adresse 10.0.5.42 :
- Le
/24 dérivé est 10.0.5.0/24
- L'instance propagée reçoit
--scope 10.0.5.0/24 en plus du scope parent
- L'instance propagée ne peut scanner que dans
10.0.5.0/24 (et le scope parent si fourni)
-
Si le scope parent contient 10.0.0.0/16 :
- L'instance propagée reçoit l'intersection (
10.0.5.0/24 étant plus restrictif)
- En pratique :
--scope 10.0.5.0/24 prime
-
Si l'opérateur fournit --scope 10.0.0.0/16 explicitement au parent :
- L'instance propagée reçoit
--scope 10.0.0.0/16 (scope opérateur respecté)
- Mais un warning est émis : "Propagated instance can scan 10.0.0.0/16 from foothold 10.0.5.42"
-
Si aucun scope n'est fourni :
- L'instance propagée reçoit
--scope 10.0.5.0/24 (borne minimale de sécurité)
- Warning : "No explicit scope — propagated instance limited to /24 of foothold"
Refus de scan hors scope
Si l'instance propagée tente de scanner 10.0.99.5 (hors 10.0.5.0/24 et hors scope parent) :
[!] scope_guard: refusing to scan 10.0.99.5:80 — out of propagated scope (10.0.5.0/24)
Spécifications techniques
Fichier : sandbox/scope_guard.py
Ajouter serialize() et deserialize()
def serialize(self) -> dict:
"""Serialize the scope for propagation."""
return {
"hosts": list(self._hosts),
"domains": list(self._domains),
"networks": [str(n) for n in self._networks],
"ports": list(self._ports),
"intensity": self._intensity,
"empty": self.empty,
}
@classmethod
def deserialize(cls, data: dict) -> "ScopeGuard":
"""Reconstruct a ScopeGuard from serialized data."""
g = cls.__new__(cls)
g._hosts = set(data.get("hosts", []))
g._domains = set(data.get("domains", []))
g._networks = [ipaddress.ip_network(n) for n in data.get("networks", [])]
g._ports = set(data.get("ports", []))
g._intensity = data.get("intensity", "intrusive")
g._empty = data.get("empty", True)
return g
Ajouter foothold_subnet(ip) -> str
def foothold_subnet(ip: str, prefix: int = 24) -> str:
"""Derive the /24 (default) subnet for a foothold IP."""
addr = ipaddress.ip_address(ip)
net = ipaddress.ip_network(f"{addr}/{prefix}", strict=False)
return str(net)
Fichier : main.py — _build_propagation_args (ligne 1272-1349)
Changements
-
Calculer le subnet du foothold :
foothold_ip = foothold.get("ip") # ex: "10.0.5.42"
derived_subnet = scope_guard.foothold_subnet(foothold_ip, prefix=24)
-
Sérialiser le scope parent et l'injecter dans le payload propagé :
scope_data = guard.serialize()
# Ajouter le subnet du foothold comme borne minimale
if not scope_data["networks"]:
scope_data["networks"] = [derived_subnet]
else:
# Intersection : ne garder que les réseaux qui contiennent le foothold
scope_data["networks"] = [
n for n in scope_data["networks"]
if ipaddress.ip_network(n).supernet_of(ipaddress.ip_network(derived_subnet))
] or [derived_subnet]
-
Ajouter --scope aux args du scanner propagé :
args_list.extend(["--scope", ",".join(scope_data["networks"])])
-
Si le scope parent est vide (pas de --scope opérateur) :
if guard.empty:
print(f"[!] No explicit scope — propagated instance limited to {derived_subnet}", file=sys.stderr)
args_list.extend(["--scope", derived_subnet])
Fichier : sandbox/propagate_pack.py
Ajouter le scope sérialisé dans le manifest JSON sidecar :
manifest["scope"] = scope_data
Fichier : config.py — import_from_propagation (ligne 986-989)
Ajouter la validation du scope propagé :
def import_from_propagation(data: dict) -> None:
# Existing overlay import (with allow-list, cf. FEATURE_redaction)
for k, v in data.get("config", {}).items():
if k in _PROPAGATABLE_KEYS:
_RUNTIME_OVERLAY[k] = v
# Import scope
if "scope" in data:
_PROPAGATED_SCOPE = ScopeGuard.deserialize(data["scope"])
Tests
tests/test_propagated_scope.py (nouveau) :
ScopeGuard.serialize() → deserialize() → round-trip identique
- Propagation avec scope parent
10.0.0.0/16 + foothold 10.0.5.42 → propagated scope = 10.0.5.0/24 (intersection)
- Propagation sans scope parent + foothold
10.0.5.42 → propagated scope = 10.0.5.0/24
- Propagation avec scope parent
192.168.0.0/24 + foothold 10.0.5.42 → propagated scope = 10.0.5.0/24 (pas d'intersection → borne minimale)
authorize("10.0.5.1", 80) dans scope 10.0.5.0/24 → True
authorize("10.0.99.5", 80) dans scope 10.0.5.0/24 → False
- Vérifier que le warning est émis quand scope parent vide
tests/test_propagation_v2.py (existant) — vérifier que les tests passent toujours.
Acceptance criteria
Références
- Issue parent : REVIEW.md I1 —
--propagate déploie sans appliquer le scope aux sous-réseaux découverts
sandbox/scope_guard.py:318-343 — authorize() retourne True à intrusive+ avec allow-list vide
main.py:1272-1349 — _build_propagation_args
Feature: Sandbox du scope propagé sur footholds
Contexte
AttackSim propage le scanner sur des footholds via
--propagate(Docker, kubelet, SSH, ADB, cloud). L'instance propagée hérite duScopeGuarddu parent — maisScopeGuardn'expose pas de méthode de sérialisation (.specs), et l'héritage de scope échoue silencieusement. L'instance propagée se retrouve avec un guard vide →authorize()retourneTruepour tout hôte à intrusive+ → le scanner déployé dans un container d'un réseau interne 10.0.5.0/24 peut scanner toute la /24 voire au-delà sans aucune restriction.La promesse "every peer is scope-checked" est vraie pour le scanner parent, fausse pour l'instance propagée.
Description
ScopeGuardpour transmettre le scope à l'instance propagée.--scope <foothold_subnet>comme borne minimale dans la propagation.--scopeexplicite.Spécifications fonctionnelles
Comportement attendu
Quand
--propagatedéploie le scanner sur un foothold à l'adresse10.0.5.42:/24dérivé est10.0.5.0/24--scope 10.0.5.0/24en plus du scope parent10.0.5.0/24(et le scope parent si fourni)Si le scope parent contient
10.0.0.0/16:10.0.5.0/24étant plus restrictif)--scope 10.0.5.0/24primeSi l'opérateur fournit
--scope 10.0.0.0/16explicitement au parent :--scope 10.0.0.0/16(scope opérateur respecté)Si aucun scope n'est fourni :
--scope 10.0.5.0/24(borne minimale de sécurité)Refus de scan hors scope
Si l'instance propagée tente de scanner
10.0.99.5(hors10.0.5.0/24et hors scope parent) :Spécifications techniques
Fichier :
sandbox/scope_guard.pyAjouter
serialize()etdeserialize()Ajouter
foothold_subnet(ip) -> strFichier :
main.py—_build_propagation_args(ligne 1272-1349)Changements
Calculer le subnet du foothold :
Sérialiser le scope parent et l'injecter dans le payload propagé :
Ajouter
--scopeaux args du scanner propagé :Si le scope parent est vide (pas de
--scopeopérateur) :Fichier :
sandbox/propagate_pack.pyAjouter le scope sérialisé dans le manifest JSON sidecar :
Fichier :
config.py—import_from_propagation(ligne 986-989)Ajouter la validation du scope propagé :
Tests
tests/test_propagated_scope.py(nouveau) :ScopeGuard.serialize()→deserialize()→ round-trip identique10.0.0.0/16+ foothold10.0.5.42→ propagated scope =10.0.5.0/24(intersection)10.0.5.42→ propagated scope =10.0.5.0/24192.168.0.0/24+ foothold10.0.5.42→ propagated scope =10.0.5.0/24(pas d'intersection → borne minimale)authorize("10.0.5.1", 80)dans scope10.0.5.0/24→ Trueauthorize("10.0.99.5", 80)dans scope10.0.5.0/24→ Falsetests/test_propagation_v2.py(existant) — vérifier que les tests passent toujours.Acceptance criteria
ScopeGuard.serialize()/deserialize()round-trip--scope <foothold_subnet>comme borne minimaleRéférences
--propagatedéploie sans appliquer le scope aux sous-réseaux découvertssandbox/scope_guard.py:318-343—authorize()retourne True à intrusive+ avec allow-list videmain.py:1272-1349—_build_propagation_args