From df2f1493585e31ea145ad256e843c137666e99ae Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 20:08:58 +0000 Subject: [PATCH] Fix silent secret leaks, add tests, CI and documentation Security fixes (secrets previously leaking in clear text, most of them not even flagged by the residual heuristic): - message-digest-key / ntp authentication-key: when the key number was 0/5/7/8/9, the generic inline "key " rule destroyed the "md5" token instead of the secret. The inline rule now refuses to match inside hyphenated directives (message-digest-key, pre-shared-key...) and runs last. - snmp-server host: vrf/use-vrf/version 3 forms leaked the community string; replaced the two host rules with a single rule covering IOS/NX-OS/EOS keyword combinations, with a lookahead preventing keywords from being mistaken for the community. - SNMPv3: NX-OS "priv 0x..." and "priv aes-128 " (hyphenated) as well as "auth sha-256" forms were not matched at all. - crypto isakmp key: with an encryption type ("key 6 ") the type digit was destroyed instead of the secret. - tacacs/radius-server: keys following auth-port/acct-port options were missed, as were bare "key " lines inside "radius server X" / "tacacs server X" blocks (key chain structure is preserved). - HSRP/VRRP plain-text "authentication ", IS-IS isis password / area-password / domain-password, ip http client password, key config-key password-encrypt and EOS "username ... sshkey" were not covered. - FQDNs (host.domain) leaked both identifiers because word boundaries rejected dots; hostname/domain replacement is now case-insensitive and matches inside FQDNs (numeric-only names keep strict boundaries). Robustness improvements: - IPv4 pool extended with 198.18.0.0/15 (RFC 2544) instead of mapping every address past ~760 to the same duplicate. - Same MAC written aabb.ccdd.eeff / aa:bb:cc:dd:ee:ff now gets one alias; multicast/broadcast and well-known virtual MACs (HSRP, VRRP, GLBP) are kept; counter no longer wraps at 65535. - Residual heuristic also flags hash-looking values ($1$/$6$/$9$..., long 0x blobs) even on partially sanitized lines, plus passwd/psk keywords. - Friendly errors on unreadable input/output, --strict (exit code 2 on residual findings, CI-friendly) and --version flags. Project structure: - tests/test_sanitize.py: 58 stdlib-only unit tests, one regression test per fixed leak, plus CLI end-to-end tests. - GitHub Actions workflow running the suite on Python 3.9 and 3.13. - Real README (usage, options, guarantees, limits) and updated module docstring. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Sra7y2NhqmvwkvWSB5Q5D4 --- .github/workflows/ci.yml | 19 ++ README.md | 124 +++++++++++- sanitize_netconfig.py | 294 ++++++++++++++++++++-------- tests/test_sanitize.py | 402 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 754 insertions(+), 85 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 tests/test_sanitize.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..21edd2e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,19 @@ +name: tests + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Run unit tests + run: python -m unittest discover -s tests -v diff --git a/README.md b/README.md index 475d984..be7d58a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,124 @@ # netanom -Another network configuration anonymiser + +Anonymiseur de configurations réseau **Cisco (IOS / IOS-XE / NX-OS)** et +**Arista EOS**, pensé pour pouvoir soumettre une configuration à une IA (ou à +tout tiers) sans divulguer de secrets ni d'informations identifiantes — tout +en gardant le fichier **cohérent et exploitable** pour l'analyse. + +Un seul fichier, aucune dépendance : `sanitize_netconfig.py` (Python ≥ 3.8). + +## Principe + +Deux familles de traitement : + +1. **Secrets → destruction.** La vraie valeur est remplacée par un marqueur + (``, ``…) et n'est conservée + nulle part. L'indice d'algorithme (type 7, type 9, sha512, RO/RW, ACL…) + est gardé : c'est une information de sécurité utile et non confidentielle. + +2. **Identifiants → pseudonymisation cohérente.** Une même valeur d'origine + est toujours remplacée par le même alias (`device-1`, `198.51.100.x`, + `example-1.net`…) afin que les relations internes du fichier restent + analysables. La table de correspondance peut être écrite dans un fichier + `.map.json` **à conserver en local** : elle permet de ré-appliquer les + recommandations de l'IA à la vraie configuration. + +## Usage + +```console +# fichier -> fichier, avec table de correspondance locale +python3 sanitize_netconfig.py config.txt -o config.san.txt -m config.map.json + +# en filtre (stdin -> stdout) +cat config.txt | python3 sanitize_netconfig.py - > config.san.txt + +# anonymiser aussi les IP privées, garder les descriptions d'interface +python3 sanitize_netconfig.py config.txt --anonymize-all-ips --keep-descriptions +``` + +Un récapitulatif est affiché sur stderr, avec la liste des lignes résiduelles +qui ressemblent encore à un secret : **relisez-les toujours** avant envoi. + +### Options + +| Option | Effet | +|---|---| +| `-o, --output FICHIER` | Fichier de sortie (défaut : stdout) | +| `-m, --map FICHIER` | Écrit la table de correspondance JSON (à garder en local, ne **jamais** transmettre) | +| `--anonymize-all-ips` | Anonymise aussi les IP privées (RFC 1918), pas seulement les publiques | +| `--keep-descriptions` | Conserve les descriptions d'interface (les e-mails/IP/hostnames qu'elles contiennent restent traités) | +| `--keep-macs` | Conserve les adresses MAC | +| `--no-summary` | Pas de récapitulatif sur stderr | +| `--strict` | Code retour 2 si des lignes résiduelles suspectes subsistent (pratique en CI/pipeline) | +| `--version` | Affiche la version | + +## Ce qui est neutralisé + +**Secrets détruits** : enable secret/password, username secret/password +(y compris EOS `sha512`), clés publiques SSH des comptes, mots de passe de +lignes (con/vty/aux), communautés SNMP (`community` et toutes les formes de +`snmp-server host`, y compris `vrf`/`use-vrf`), clés SNMPv3 (`auth md5/sha…`, +`priv des/aes/aes-128…`, formes NX-OS `0x…`), clés TACACS+/RADIUS (lignes +globales et blocs `radius server X` / `tacacs server X`), clé maîtresse +`key config-key password-encrypt`, clés pré-partagées VPN (`crypto isakmp +key`, `pre-shared-key`), authentification de routage (BGP `neighbor … +password`, OSPF `message-digest-key`/`authentication-key`, IS-IS +`isis password`/`area-password`/`domain-password`), authentification HSRP/VRRP +en clair, `key-string` des key chains, PPP CHAP/PAP, `ip ftp password`, +`ip http client password`, clés NTP, certificats PKI (blocs hexadécimaux IOS), +blocs PEM, bannières et adresses e-mail. + +**Identifiants pseudonymisés (cohérents)** : + +- hostname / switchname / sysname → `device-N` (partout, insensible à la + casse, y compris dans un FQDN `hote.domaine`) ; +- domaine (`ip domain-name`, `domain list`, `dns domain`, `domain-name` des + pools DHCP) → `example-N.net` (partout, y compris en suffixe de FQDN) ; +- IPv4 publiques → plages de documentation RFC 5737, puis 198.18.0.0/15 + (RFC 2544) au-delà de ~760 adresses distinctes ; +- IPv6 publiques → `2001:db8::/32` (RFC 3849) ; +- MAC → adresses fictives d'administration locale (`02:…`), format conservé + et alias identique entre notations `aabb.ccdd.eeff` et `aa:bb:cc:dd:ee:ff`. + Les MAC multicast/broadcast et les MAC virtuelles bien connues (HSRP, VRRP, + GLBP) sont conservées : porteuses de sens protocolaire, non identifiantes ; +- descriptions d'interface → jeton cohérent `` ; +- `snmp-server location` / `contact` → marqueur. + +Sont volontairement **conservés** : IP privées (par défaut), plages de +documentation, multicast, loopback, link-local, masques et wildcards, numéros +d'AS, VLAN, indices de type de chiffrement (7, 9, sha512…). + +## Le fichier `.map.json` + +La table écrite par `-m` contient la correspondance +`valeur réelle → alias`. Elle sert à dé-anonymiser les réponses de l'IA pour +les ré-appliquer à la vraie configuration. **Elle ne doit jamais être +transmise à l'IA ni à un tiers** — traitez-la comme un secret. + +## Limites (important) + +Outil « best effort » à base de motifs : il ne remplace **pas** une relecture +humaine. Vérifiez notamment : noms de VRF / route-map / ACL / trustpoint, +numéros de série et licences, identifiants de circuit dans les descriptions, +jetons d'agents EOS (`daemon TerminAttr`…), clés PSK WLAN, `snmp engineID`, +valeurs purement numériques après `key` (ambiguës avec un numéro de clé de +key chain) et tout secret à syntaxe exotique. Les masques/préfixes ne sont +pas recalculés : la cohérence de sous-réseau des adresses remplacées n'est +pas garantie. Le récapitulatif signale les lignes résiduelles suspectes ; +l'option `--strict` transforme cette alerte en code d'erreur. + +## Tests + +```console +python3 -m unittest discover -s tests -v +``` + +La suite couvre chaque famille de règles ainsi que des cas de régression +(fuites corrigées) : `message-digest-key`/clé NTP dont le numéro vaut +0/5/7/8/9, `snmp-server host … vrf`, formes NX-OS `priv 0x…`/`aes-128`, +`crypto isakmp key 6 …`, `radius-server … auth-port … key`, `key` en bloc +serveur, HSRP/VRRP en clair, FQDN `hote.domaine`, etc. + +## Licence + +[MIT](LICENSE) diff --git a/sanitize_netconfig.py b/sanitize_netconfig.py index cc8cb33..baa6f37 100755 --- a/sanitize_netconfig.py +++ b/sanitize_netconfig.py @@ -26,27 +26,40 @@ Ce qui est neutralise --------------------- SECRETS DETRUITS : - - enable secret / enable password (tous types : 0,5,7,8,9) + - enable secret / enable password (tous types : 0,5,7,8,9, sha512) - username ... secret/password (incl. EOS "secret sha512") + - cles publiques SSH des comptes (username ... sshkey ...) : identifiantes - mots de passe de lignes (line con/vty/aux : password ...) - - communautes SNMP (snmp-server community) + communaute dans snmp-server host - - cles SNMPv3 (auth md5/sha ..., priv des/aes ...) - - cles TACACS+ / RADIUS (tacacs-server key, radius-server key, key 7 ...) - - cles pre-partagees VPN (crypto isakmp key, pre-shared-key) + - communautes SNMP (snmp-server community) + communaute dans snmp-server + host (y compris formes vrf / use-vrf / traps / informs / version 1|2c|3) + - cles SNMPv3 (auth md5/sha[-]..., priv des/aes[-128...], formes NX-OS 0x...) + - cles TACACS+ / RADIUS (tacacs-server key, radius-server ... key, blocs + "radius server X" / "tacacs server X" avec "key" indente, key 7 ...) + - cle maitre de chiffrement (key config-key password-encrypt) + - cles pre-partagees VPN (crypto isakmp key [0|6|encrypted], pre-shared-key) - authentification de voisinage / routage (neighbor ... password, OSPF - message-digest-key md5, authentication-key, authentication text) + message-digest-key md5, authentication-key, authentication text, + IS-IS isis password / area-password / domain-password) + - authentification FHRP en clair (standby / vrrp ... authentication) - key-string (key chains) - - PPP CHAP/PAP, ip ftp password, cle d'authentification NTP + - PPP CHAP/PAP, ip ftp password, ip http client password, + cle d'authentification NTP - certificats PKI (blocs hex IOS) et blocs PEM (-----BEGIN...-----END-----) - bannieres (banner motd/login/exec ...) souvent porteuses d'infos org/legales - adresses e-mail IDENTIFIANTS PSEUDONYMISES (coherents) : - - hostname / switchname / sysname -> device-N (remplace partout) - - domaine (ip domain-name, dns domain) -> example-N.net (remplace partout) - - adresses IPv4 publiques -> plages de documentation RFC 5737 + - hostname / switchname / sysname -> device-N (remplace partout, insensible + a la casse, y compris au sein d'un FQDN "hote.domaine") + - domaine (ip domain-name / domain list / dns domain / domain-name DHCP) + -> example-N.net (remplace partout, y compris en suffixe de FQDN) + - adresses IPv4 publiques -> plages de documentation RFC 5737, puis + 198.18.0.0/15 (RFC 2544) si plus de ~760 adresses distinctes - adresses IPv6 publiques -> 2001:db8::/32 (RFC 3849) - - adresses MAC -> MAC d'administration locale fictive (format conserve) + - adresses MAC -> MAC d'administration locale fictive (format conserve, + coherent entre notations aabb.ccdd.eeff et aa:bb:cc:dd:ee:ff) ; les MAC + multicast/broadcast et les MAC virtuelles bien connues (HSRP, VRRP, + GLBP) sont conservees car porteuses de sens et non identifiantes - descriptions d'interface -> jeton coherent (option --keep-descriptions) - snmp-server location / contact -> marqueur (adresse, tel, nom => sensibles) @@ -55,15 +68,20 @@ Outil "best effort" base sur des motifs. Il ne remplace PAS une relecture humaine. Pensez en particulier a verifier manuellement : noms de VRF, de route-map, d'ACL, de trustpoint, numeros de serie/licence, identifiants de -circuit dans les descriptions, et tout secret a syntaxe exotique. Le script -affiche en fin de traitement les lignes residuelles qui ressemblent encore a -un secret : relisez-les. +circuit dans les descriptions, jetons d'agents EOS (daemon TerminAttr...), +cles PSK WLAN, snmp engineID, valeurs numeriques pures apres "key" (ambigues +avec un numero de cle de key chain), et tout secret a syntaxe exotique. +Les masques/prefixes ne sont pas recalcules : une adresse reseau remplacee +peut devenir une adresse hote (la coherence de sous-reseau n'est pas +garantie). Le script affiche en fin de traitement les lignes residuelles qui +ressemblent encore a un secret : relisez-les. Usage ----- python3 sanitize_netconfig.py config.txt -o config.san.txt -m config.map.json cat config.txt | python3 sanitize_netconfig.py - > config.san.txt python3 sanitize_netconfig.py config.txt --anonymize-all-ips --keep-descriptions + python3 sanitize_netconfig.py config.txt --strict # code retour 2 si residu """ import argparse @@ -73,6 +91,8 @@ import sys from itertools import count +__version__ = "0.2.0" + # --------------------------------------------------------------------------- # Marqueurs # --------------------------------------------------------------------------- @@ -87,22 +107,33 @@ def ph(category: str) -> str: S_CONTACT = ph("CONTACT") S_BANNER = ph("BANNER") S_CERT = ph("CERTIFICATE-OR-KEY") +S_SSHKEY = ph("SSH-KEY") # --------------------------------------------------------------------------- # Regles de DESTRUCTION des secrets # Chaque regle = (regex compilee, chaine de remplacement). # Convention : on conserve la "directive" + l'indice d'algorithme via des # groupes capturants, on detruit uniquement la valeur sensible. +# ORDRE IMPORTANT : du plus specifique au plus generique. La regle inline +# "key " est volontairement en DERNIER et ne matche jamais a +# l'interieur d'une directive suffixee en -key (message-digest-key, etc.), +# sinon elle detruirait le jeton d'algorithme et laisserait fuir le secret. # --------------------------------------------------------------------------- _F = re.IGNORECASE SECRET_RULES = [ - # enable secret/password [level N] [0|5|7|8|9] - (re.compile(r'^(\s*enable\s+(?:secret|password)(?:\s+level\s+\d+)?(?:\s+\d)?\s+)\S+.*$', _F), + # enable secret/password [level N] [0|5|7|8|9|sha512...] + (re.compile(r'^(\s*enable\s+(?:secret|password)(?:\s+level\s+\d+)?' + r'(?:\s+(?:0|5|7|8|9|sha512|sha256|md5))?\s+)\S+.*$', _F), r'\1' + S_SECRET), # username X [privilege N] [role ...] secret|password [algo] [reste] - (re.compile(r'^(\s*username\s+\S+\s+.*?(?:secret|password)(?:\s+(?:sha512|sha256|md5|0|5|7|8|9))?\s+)(\S+)(.*)$', _F), + (re.compile(r'^(\s*username\s+\S+\s+.*?\b(?:secret|password)' + r'(?:\s+(?:sha512|sha256|md5|0|5|7|8|9))?\s+)(\S+)(.*)$', _F), r'\1' + S_SECRET + r'\3'), + # cle publique SSH d'un compte (EOS "username X sshkey ssh-rsa ...") : + # pas un secret, mais identifiante (commentaire, correlation possible) + (re.compile(r'^(\s*username\s+\S+\s+ssh-?key\s+).*$', _F), + r'\1' + S_SSHKEY), # mot de passe de ligne ou generique : password [type] (re.compile(r'^(\s*password\s+(?:\d\s+)?)\S+.*$', _F), @@ -114,30 +145,42 @@ def ph(category: str) -> str: # SNMP : communaute (on garde RO/RW + ACL eventuelle) (re.compile(r'^(\s*snmp-server\s+community\s+)\S+(.*)$', _F), r'\1' + S_COMMUNITY + r'\2'), - # SNMP host avec version explicite : ... version X [reste] - (re.compile(r'^(\s*snmp-server\s+host\s+\S+(?:\s+(?:traps|informs))?\s+version\s+\S+\s+)(\S+)(.*)$', _F), - r'\1' + S_COMMUNITY + r'\3'), - # SNMP host v2c sans "version" : snmp-server host [reste] - (re.compile(r'^(\s*snmp-server\s+host\s+\S+\s+)(?!version|traps|informs|vrf|udp-port)(\S+)(.*)$', _F), + # SNMP host : toutes formes IOS/NX-OS/EOS. On saute les mots-cles connus + # (vrf X, use-vrf X, traps, informs, version X, auth/noauth/priv) puis on + # detruit le jeton suivant = communaute (ou utilisateur v3, identifiant). + (re.compile(r'^(\s*snmp-server\s+host\s+\S+' + r'(?:\s+(?:(?:vrf|use-vrf|filter-vrf|version)\s+\S+' + r'|traps|informs|auth|noauth|priv))*' + r'\s+)(?!(?:vrf|use-vrf|filter-vrf|version|traps|informs' + r'|auth|noauth|priv|udp-port)\b)(\S+)(.*)$', _F), r'\1' + S_COMMUNITY + r'\3'), - # SNMPv3 : auth md5|sha / priv des|3des|aes [128|192|256] - (re.compile(r'(\bauth\s+(?:md5|sha(?:512|384|256|224)?)\s+)(\S+)', _F), + # SNMPv3 : auth md5|sha|sha-256... / priv des|3des|aes[- ]128... + (re.compile(r'(\bauth\s+(?:md5|sha-?(?:512|384|256|224)?)\s+)(\S+)', _F), + r'\1' + S_SECRET), + (re.compile(r'(\bpriv\s+(?:des|3des|aes(?:[\s-]+(?:128|192|256))?)\s+)(\S+)', _F), r'\1' + S_SECRET), - (re.compile(r'(\bpriv\s+(?:des|3des|aes(?:\s+(?:128|192|256))?)\s+)(\S+)', _F), + # forme NX-OS localisee : priv 0x (sans mot-cle d'algo) + (re.compile(r'(\bpriv\s+)(0x[0-9A-Fa-f]+)', _F), r'\1' + S_SECRET), - # TACACS+ / RADIUS - (re.compile(r'^(\s*(?:tacacs|radius)-server\s+(?:host\s+\S+\s+)?key\s+(?:\d\s+)?)\S+.*$', _F), + # TACACS+ / RADIUS : ligne globale, quel que soit ce qui precede "key" + # (host, auth-port, acct-port, timeout...) + (re.compile(r'^(\s*(?:tacacs|radius)-server\s+.*?\bkey\s+(?:\d\s+)?)\S.*$', _F), r'\1' + S_SECRET), - (re.compile(r'^(\s*key\s+\d\s+)\S+.*$', _F), # forme indentee "key 7 " + # cle maitre de chiffrement des mots de passe (AES password encryption) + (re.compile(r'^(\s*key\s+config-key\s+password-encrypt\s+)\S.*$', _F), r'\1' + S_SECRET), - (re.compile(r'(\bkey\s+(?:0|5|7|8|9)\s+)(\S+)', _F), # inline "... key 7 " + # "key [type] " en debut de ligne (blocs "radius server X" / + # "tacacs server X"). Ne touche ni "key chain X", ni un numero de cle + # de key chain ("key 1"), ni "key config-key ..." (regle dediee ci-dessus). + (re.compile(r'^(\s*key\s+(?:\d\s+)?)(?!chain\b|config-key\b|\d+\s*$)\S.*$', _F), r'\1' + S_SECRET), # VPN / IPsec - (re.compile(r'^(\s*crypto\s+isakmp\s+key\s+)(\S+)(.*)$', _F), + (re.compile(r'^(\s*crypto\s+isakmp\s+key\s+(?:[06]\s+)?)(\S+)(.*)$', _F), r'\1' + S_SECRET + r'\3'), - (re.compile(r'(\bpre-shared-key\s+(?:local\s+|remote\s+)?(?:\d\s+)?)(\S+)', _F), + (re.compile(r'(\bpre-shared-key\s+(?:(?:local|remote|encrypted)\s+)?(?:\d\s+)?)' + r'(?!address\b|hostname\b)(\S+)', _F), r'\1' + S_SECRET), # Routage : BGP / OSPF / divers @@ -149,19 +192,35 @@ def ph(category: str) -> str: r'\1' + S_SECRET), (re.compile(r'(\bauthentication\s+text\s+)(\S+)', _F), r'\1' + S_SECRET), + # IS-IS : isis password / area-password / domain-password [hmac-md5] + (re.compile(r'^(\s*(?:isis\s+password|(?:area|domain)-password)\s+' + r'(?:hmac-md5\s+)?)(\S+)(.*)$', _F), + r'\1' + S_SECRET + r'\3'), + # FHRP : standby/vrrp ... authentication + # (les formes md5/text/key-string/key-chain sont gerees par d'autres regles) + (re.compile(r'^(\s*(?:standby|vrrp)\s+(?:\d+\s+)?authentication\s+)' + r'(?!md5\b|text\b|key-string\b|key-chain\b)(\S+)(.*)$', _F), + r'\1' + S_SECRET + r'\3'), # Key chains (re.compile(r'(\bkey-string\s+(?:\d\s+)?)(\S+)', _F), r'\1' + S_SECRET), - # PPP / FTP / NTP + # PPP / FTP / HTTP / NTP (re.compile(r'(\bppp\s+chap\s+(?:hostname\s+\S+\s+)?password\s+(?:\d\s+)?)(\S+)', _F), r'\1' + S_SECRET), (re.compile(r'(\bsent-username\s+\S+\s+password\s+(?:\d\s+)?)(\S+)', _F), r'\1' + S_SECRET), - (re.compile(r'(\bip\s+ftp\s+password\s+(?:\d\s+)?)(\S+)', _F), + (re.compile(r'(\bip\s+(?:ftp|http\s+client)\s+password\s+(?:\d\s+)?)(\S+)', _F), r'\1' + S_SECRET), - (re.compile(r'(\bntp\s+authentication-key\s+\d+\s+(?:md5|sha\d*)\s+(?:\d\s+)?)(\S+)', _F), + (re.compile(r'(\bntp\s+authentication-key\s+\d+\s+' + r'(?:md5|sha\d*|hmac-sha[0-9-]*|cmac-aes-\d+)\s+(?:\d\s+)?)(\S+)', _F), + r'\1' + S_SECRET), + + # Generique inline "... key " — EN DERNIER. Le lookbehind + # interdit le match au sein de "message-digest-key", "authentication-key", + # "pre-shared-key", etc. (directives deja traitees plus haut). + (re.compile(r'((? str: # Captures d'identifiants (passe de collecte) HOSTNAME_RX = re.compile(r'^\s*(?:hostname|switchname|sysname)\s+(\S+)\s*$', _F) -DOMAIN_RX = re.compile(r'^\s*(?:ip\s+domain[\s-]name|dns\s+domain)\s+(\S+)\s*$', _F) +DOMAIN_RX = re.compile(r'^\s*(?:ip\s+)?(?:domain[\s-](?:name|list)|dns\s+domain)\s+' + r'(?:vrf\s+\S+\s+)?(\S+)\s*$', _F) # Bloc PEM / banniere / blob hex PEM_BEGIN_RX = re.compile(r'-----BEGIN [^-]+-----') @@ -202,9 +262,18 @@ def ph(category: str) -> str: NET0_8 = ipaddress.ip_network("0.0.0.0/8") +# MAC virtuelles bien connues, conservees (prefixes hex sans separateur) : +# HSRP 0000.0c07.acXX / HSRPv2 0000.0c9f.fXXX / VRRP 0000.5e00.01XX / +# GLBP 0007.b40X.XXXX +KEEP_MAC_PREFIXES = ("00000c07ac", "00000c9ff", "00005e0001", "0007b4") + # Detection de secrets residuels (filet de securite en sortie) RESIDUAL_RX = re.compile( - r'\b(password|secret|community|key-string|pre-shared|passphrase|private-key)\b', _F) + r'\b(password|passwd|secret|community|key-string|pre-shared|passphrase|' + r'private-key|psk)\b', _F) +# Valeurs qui ressemblent a un hash/une cle, meme sur une ligne deja marquee +# (hash crypt/type5/8/9 : $1$..., $6$..., $8$..., $9$... ; blobs hex 0x...) +RESIDUAL_VALUE_RX = re.compile(r'\$(?:1|2[aby]?|5|6|8|9)\$\S{6,}|\b0x[0-9A-Fa-f]{16,}\b') # --------------------------------------------------------------------------- @@ -226,13 +295,22 @@ def __init__(self, anon_all_ips=False, keep_descriptions=False, keep_macs=False) self._host_c = count(1) self._domain_c = count(1) self._desc_c = count(1) - self._ipv4_pool = self._ipv4_pool_gen() + self._ipv4_pool = self._hosts_of( + "198.51.100.0/24", "203.0.113.0/24", "192.0.2.0/24", + "198.18.0.0/15") # extension RFC 2544 si >760 IP self._ipv6_pool = (ipaddress.ip_address("2001:db8::") + i for i in count(1)) self._mac_c = count(1) + # Cles normalisees -> alias (une meme adresse sous plusieurs ecritures + # recoit toujours le meme alias) + self._ipv4_norm = {} + self._ipv6_norm = {} + self._mac_norm = {} + # Remplacement mot-pour-mot (rempli en passe de collecte) - self._word_map = {} # alias <- terme original - self._word_rx = None + self._word_map = {} # alias <- terme original (cle en minuscules) + self._word_rx = None # termes avec lettres : bordure '.' toleree (FQDN) + self._word_rx_strict = None # termes sans lettre : bordures strictes # Stats self.stats = {"secrets": 0, "banners": 0, "certs": 0, @@ -240,35 +318,47 @@ def __init__(self, anon_all_ips=False, keep_descriptions=False, keep_macs=False) # --- pools / allocations ------------------------------------------------ @staticmethod - def _ipv4_pool_gen(): - for net in ("198.51.100.0/24", "203.0.113.0/24", "192.0.2.0/24"): + def _hosts_of(*nets): + for net in nets: for host in ipaddress.ip_network(net).hosts(): yield host - def _alloc_ipv4(self, original): - if original not in self.ipv4: + def _alloc_ipv4(self, original, ip): + key = str(ip) + if key not in self._ipv4_norm: try: - self.ipv4[original] = str(next(self._ipv4_pool)) - except StopIteration: # pool epuise - self.ipv4[original] = "198.51.100.254" - return self.ipv4[original] - - def _alloc_ipv6(self, original): - if original not in self.ipv6: - self.ipv6[original] = str(next(self._ipv6_pool)) - return self.ipv6[original] + self._ipv4_norm[key] = str(next(self._ipv4_pool)) + except StopIteration: # pool epuise (>130 000 IP !) + self._ipv4_norm[key] = "198.19.255.254" + alias = self._ipv4_norm[key] + self.ipv4[original] = alias + return alias + + def _alloc_ipv6(self, original, ip): + key = ip.compressed.lower() + if key not in self._ipv6_norm: + self._ipv6_norm[key] = str(next(self._ipv6_pool)) + alias = self._ipv6_norm[key] + self.ipv6[original] = alias + return alias def _alloc_mac(self, original, sep_dot): - if original not in self.macs: - n = next(self._mac_c) + digits = "".join(ch for ch in original if ch not in ".:").lower() + # MAC multicast/broadcast, nulle ou virtuelle bien connue : on conserve + # (porteuses de sens protocolaire, non identifiantes) + if int(digits[:2], 16) & 1 or digits == "000000000000" \ + or digits.startswith(KEEP_MAC_PREFIXES): + return original + if digits not in self._mac_norm: # OUI d'administration locale (bit U/L a 1) -> aucun vrai materiel - raw = "02000000%04x" % (n & 0xFFFF) - if sep_dot: - fake = f"{raw[0:4]}.{raw[4:8]}.{raw[8:12]}" - else: - fake = ":".join(raw[i:i+2] for i in range(0, 12, 2)) - self.macs[original] = fake - return self.macs[original] + self._mac_norm[digits] = "02%010x" % next(self._mac_c) + raw = self._mac_norm[digits] + if sep_dot: + fake = f"{raw[0:4]}.{raw[4:8]}.{raw[8:12]}" + else: + fake = ":".join(raw[i:i + 2] for i in range(0, 12, 2)) + self.macs[original] = fake + return fake # --- decision de conservation d'une IP ---------------------------------- def _keep_ip(self, ip): @@ -283,27 +373,43 @@ def _keep_ip(self, ip): # --- passe 1 : collecte hostnames / domaines ---------------------------- def collect(self, lines): + seen_hosts, seen_domains = set(), set() for ln in lines: m = HOSTNAME_RX.match(ln) - if m and m.group(1) not in self.hosts: + if m and m.group(1).lower() not in seen_hosts: + seen_hosts.add(m.group(1).lower()) self.hosts[m.group(1)] = f"device-{next(self._host_c)}" m = DOMAIN_RX.match(ln) - if m and m.group(1) not in self.domains: + if m and m.group(1).lower() not in seen_domains: + seen_domains.add(m.group(1).lower()) self.domains[m.group(1)] = f"example-{next(self._domain_c)}.net" for original, alias in {**self.hosts, **self.domains}.items(): - self._word_map[original] = alias - if self._word_map: - # termes les plus longs d'abord pour eviter les sous-chaines - terms = sorted((re.escape(t) for t in self._word_map), - key=len, reverse=True) - self._word_rx = re.compile(r'(?= limit: break @@ -501,14 +609,22 @@ def main(): help="Conserve les adresses MAC telles quelles.") p.add_argument("--no-summary", action="store_true", help="N'affiche pas le recapitulatif sur stderr.") + p.add_argument("--strict", action="store_true", + help="Termine avec le code retour 2 si des lignes residuelles " + "suspectes subsistent (utilisable en CI / pipeline).") + p.add_argument("--version", action="version", + version=f"%(prog)s {__version__}") args = p.parse_args() # lecture if args.input == "-": data = sys.stdin.read() else: - with open(args.input, "r", encoding="utf-8", errors="replace") as f: - data = f.read() + try: + with open(args.input, "r", encoding="utf-8", errors="replace") as f: + data = f.read() + except OSError as e: + sys.exit(f"sanitize_netconfig: lecture impossible : {e}") lines = data.splitlines() anon = Anonymizer(anon_all_ips=args.anonymize_all_ips, @@ -522,12 +638,20 @@ def main(): if args.output == "-": sys.stdout.write(body) else: - with open(args.output, "w", encoding="utf-8") as f: - f.write(body) + try: + with open(args.output, "w", encoding="utf-8") as f: + f.write(body) + except OSError as e: + sys.exit(f"sanitize_netconfig: ecriture impossible : {e}") if args.mapfile: - with open(args.mapfile, "w", encoding="utf-8") as f: - json.dump(anon.mapping(), f, ensure_ascii=False, indent=2) + try: + with open(args.mapfile, "w", encoding="utf-8") as f: + json.dump(anon.mapping(), f, ensure_ascii=False, indent=2) + except OSError as e: + sys.exit(f"sanitize_netconfig: ecriture de la table impossible : {e}") + + flagged = residual_warnings(result) # recapitulatif + alerte residuelle (sur stderr pour ne pas polluer stdout) if not args.no_summary: @@ -544,7 +668,6 @@ def main(): print(f" MAC : {len(anon.macs)}", file=sys.stderr) print(f" Descriptions : {len(anon.descriptions)}", file=sys.stderr) - flagged = residual_warnings(result) if flagged: print("\n /!\\ Lignes a RELIRE (secret potentiel non neutralise) :", file=sys.stderr) @@ -554,6 +677,9 @@ def main(): print("\n Aucun secret residuel detecte par l'heuristique.", file=sys.stderr) + if args.strict and flagged: + sys.exit(2) + if __name__ == "__main__": main() diff --git a/tests/test_sanitize.py b/tests/test_sanitize.py new file mode 100644 index 0000000..5169432 --- /dev/null +++ b/tests/test_sanitize.py @@ -0,0 +1,402 @@ +# -*- coding: utf-8 -*- +"""Tests de sanitize_netconfig.py — stdlib uniquement (unittest). + +Lancer : python3 -m unittest discover -s tests -v +""" + +import pathlib +import subprocess +import sys +import unittest + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +import sanitize_netconfig as sn # noqa: E402 + +SCRIPT = ROOT / "sanitize_netconfig.py" + + +def sanitize(text, **kwargs): + """Pipeline complet collect + process, retourne (sortie, anonymizer).""" + anon = sn.Anonymizer(**kwargs) + lines = text.splitlines() + anon.collect(lines) + return "\n".join(anon.process(lines)), anon + + +class TestSecrets(unittest.TestCase): + def test_enable_secret(self): + out, _ = sanitize("enable secret 9 $9$abc.def/ghi123") + self.assertEqual(out, "enable secret 9 " + sn.S_SECRET) + + def test_enable_password_sha512_eos(self): + out, _ = sanitize("enable password sha512 $6$saltsalt$hashhashhash") + self.assertNotIn("$6$", out) + self.assertIn("sha512", out) + + def test_line_password(self): + out, _ = sanitize("line vty 0 4\n password s3cretpw\n login") + self.assertNotIn("s3cretpw", out) + self.assertIn(" password " + sn.S_SECRET, out) + + def test_username_keeps_trailing_options(self): + out, _ = sanitize("username admin password 5 $5$abc$defgh role network-admin") + self.assertNotIn("$5$abc$defgh", out) + self.assertIn("role network-admin", out) + + def test_username_nopassword_untouched(self): + line = "username admin privilege 15 nopassword" + out, _ = sanitize(line) + self.assertEqual(out, line) + + def test_username_sshkey_destroyed(self): + out, _ = sanitize( + "username admin sshkey ssh-rsa AAAAB3NzaC1yc2EAAAADAQAB admin@corp.com") + self.assertNotIn("AAAAB3", out) + self.assertNotIn("admin@corp.com", out) + self.assertIn(sn.S_SSHKEY, out) + + # --- regressions : fuites corrigees ------------------------------------- + def test_ospf_message_digest_key_number_in_type_set(self): + # Le numero de cle 5 declenchait la regle inline "key 5 ..." qui + # detruisait "md5" et laissait fuir le secret. + out, _ = sanitize("ip ospf message-digest-key 5 md5 OspfS3cret") + self.assertNotIn("OspfS3cret", out) + self.assertIn("md5", out) + + def test_ntp_key_number_in_type_set(self): + out, _ = sanitize("ntp authentication-key 5 md5 NtpS3cret") + self.assertNotIn("NtpS3cret", out) + self.assertIn("md5", out) + + def test_ntp_trailing_encryption_type(self): + out, _ = sanitize("ntp authentication-key 10 md5 141B1309 7") + self.assertNotIn("141B1309", out) + + def test_isakmp_key_with_encryption_type(self): + # "crypto isakmp key 6 SECRET address x" : le 6 etait detruit a la + # place du secret. + out, _ = sanitize("crypto isakmp key 6 IsakmpS3cret address 198.51.100.99") + self.assertNotIn("IsakmpS3cret", out) + self.assertIn("address", out) + + def test_isakmp_key_plain(self): + out, _ = sanitize("crypto isakmp key MyPSK address 0.0.0.0 0.0.0.0") + self.assertNotIn("MyPSK", out) + + def test_radius_server_with_ports_key(self): + out, _ = sanitize( + "radius-server host 10.1.1.2 auth-port 1812 acct-port 1813 key RadiusS3cret") + self.assertNotIn("RadiusS3cret", out) + + def test_tacacs_server_key_type7(self): + out, _ = sanitize("tacacs-server host 10.1.1.3 key 7 06120A3258F4A1") + self.assertNotIn("06120A3258F4A1", out) + + def test_radius_block_indented_bare_key(self): + cfg = ("radius server ISE\n" + " address ipv4 10.5.5.5 auth-port 1812 acct-port 1813\n" + " key BlockS3cret!\n") + out, _ = sanitize(cfg) + self.assertNotIn("BlockS3cret!", out) + self.assertIn("radius server ISE", out) + + def test_key_chain_structure_preserved(self): + cfg = ("key chain LANKEYS\n" + " key 1\n" + " key-string 7 141B1309000E\n") + out, _ = sanitize(cfg) + self.assertIn("key chain LANKEYS", out) + self.assertIn("\n key 1\n", out) + self.assertNotIn("141B1309000E", out) + + def test_key_config_key(self): + out, _ = sanitize("key config-key password-encrypt MyMasterKey") + self.assertNotIn("MyMasterKey", out) + self.assertIn("key config-key password-encrypt", out) + + def test_hsrp_plaintext_authentication(self): + out, _ = sanitize("standby 1 authentication HsrpS3cret") + self.assertNotIn("HsrpS3cret", out) + + def test_hsrp_md5_keystring(self): + out, _ = sanitize("standby 1 authentication md5 key-string 7 09441B1B13") + self.assertNotIn("09441B1B13", out) + self.assertIn("authentication md5 key-string 7", out) + + def test_vrrp_plaintext_authentication(self): + out, _ = sanitize("vrrp 10 authentication VrrpPass") + self.assertNotIn("VrrpPass", out) + + def test_isis_passwords(self): + out, _ = sanitize("domain-password IsisS3cret\n" + "area-password AreaS3cret\n" + "isis password IfS3cret level-2") + for secret in ("IsisS3cret", "AreaS3cret", "IfS3cret"): + self.assertNotIn(secret, out) + self.assertIn("level-2", out) + + def test_pre_shared_key_keyring_address_form(self): + out, _ = sanitize(" pre-shared-key address 192.0.2.99 key 0 SuperPSK") + self.assertNotIn("SuperPSK", out) + self.assertIn("address", out) + + def test_pre_shared_key_local(self): + out, _ = sanitize(" pre-shared-key local Sup3rS3cret") + self.assertNotIn("Sup3rS3cret", out) + + def test_neighbor_password(self): + out, _ = sanitize("neighbor 203.0.113.5 password 7 0822455D0A16") + self.assertNotIn("0822455D0A16", out) + + def test_http_client_password(self): + out, _ = sanitize("ip http client password 7 05080F1C2243") + self.assertNotIn("05080F1C2243", out) + + +class TestSnmp(unittest.TestCase): + def test_community(self): + out, _ = sanitize("snmp-server community S3cretC0mm RO 99") + self.assertNotIn("S3cretC0mm", out) + self.assertIn("RO 99", out) + + def test_host_legacy_v2c(self): + out, _ = sanitize("snmp-server host 10.1.1.1 public") + self.assertNotIn("public", out) + + def test_host_with_vrf(self): + # La forme vrf laissait fuir la communaute (lookahead trop strict). + out, _ = sanitize("snmp-server host 10.1.1.1 vrf MGMT version 2c C0mmun1ty") + self.assertNotIn("C0mmun1ty", out) + self.assertIn("vrf MGMT", out) + + def test_host_v3_priv_user(self): + out, _ = sanitize("snmp-server host 192.0.2.50 version 3 priv NetOpsUser") + self.assertNotIn("NetOpsUser", out) + self.assertIn("version 3 priv", out) + + def test_host_nxos_use_vrf_line_untouched(self): + line = "snmp-server host 10.1.1.1 use-vrf management" + out, _ = sanitize(line) + self.assertEqual(out, line) + + def test_nxos_user_priv_hex(self): + # NX-OS "priv 0x..." (sans mot-cle d'algo) fuyait silencieusement. + out, _ = sanitize("snmp-server user admin network-admin " + "auth md5 0x1a2b3c4d5e priv 0x5e4d3c2b1a localizedkey") + self.assertNotIn("0x1a2b3c4d5e", out) + self.assertNotIn("0x5e4d3c2b1a", out) + self.assertIn("localizedkey", out) + + def test_priv_aes_dash_form(self): + # "priv aes-128 KEY" (tiret) fuyait silencieusement. + out, _ = sanitize("snmp-server user ops grp v3 auth sha AuthPass priv aes-128 PrivPass") + self.assertNotIn("AuthPass", out) + self.assertNotIn("PrivPass", out) + + def test_auth_sha_dash_form(self): + out, _ = sanitize("snmp-server user x g v3 auth sha-256 S3cretAuth") + self.assertNotIn("S3cretAuth", out) + + def test_location_contact(self): + out, _ = sanitize("snmp-server location 12 rue de la Paix, Paris\n" + "snmp-server contact Jean Dupont 0601020304") + self.assertNotIn("Paris", out) + self.assertNotIn("Dupont", out) + self.assertIn(sn.S_LOCATION, out) + self.assertIn(sn.S_CONTACT, out) + + +class TestIdentifiers(unittest.TestCase): + def test_hostname_and_fqdn(self): + # Un FQDN "hote.domaine" fuyait entierement (bordures interdisant '.'). + cfg = ("hostname SW-CORE\n" + "ip domain-name corp.example\n" + "ntp server sw-core.corp.example\n") + out, _ = sanitize(cfg) + self.assertNotIn("SW-CORE", out) + self.assertNotIn("sw-core", out) + self.assertNotIn("corp.example", out) + self.assertIn("hostname device-1", out) + self.assertIn("ntp server device-1.example-1.net", out) + + def test_domain_with_vrf_and_dhcp_pool(self): + cfg = ("ip domain name vrf MGMT corp.example\n" + "ip dhcp pool LAN\n" + " domain-name corp.example\n") + out, _ = sanitize(cfg) + self.assertNotIn("corp.example", out) + + def test_public_ip_coherent(self): + cfg = "ip route 8.8.8.8 255.255.255.255 11.22.33.44\nlogging host 8.8.8.8\n" + out, anon = sanitize(cfg) + self.assertNotIn("8.8.8.8", out) + self.assertNotIn("11.22.33.44", out) + # meme original -> meme alias sur les deux lignes + alias = anon.ipv4["8.8.8.8"] + self.assertEqual(out.count(alias), 2) + + def test_private_ip_kept_by_default(self): + line = "ip route 10.0.0.0 255.0.0.0 192.168.1.1" + out, _ = sanitize(line) + self.assertEqual(out, line) + + def test_anonymize_all_ips(self): + out, _ = sanitize("interface Vlan10\n ip address 192.168.1.1 255.255.255.0", + anon_all_ips=True) + self.assertNotIn("192.168.1.1", out) + self.assertIn("255.255.255.0", out) # masque conserve + + def test_ipv4_pool_no_duplicate_after_doc_ranges(self): + # >762 IP publiques distinctes : l'ancien code retombait sur une + # adresse unique dupliquee, cassant la coherence de la table. + lines = [f"ip route 11.{i // 200}.{i % 200}.1 255.255.255.255 Null0" + for i in range(800)] + _, anon = sanitize("\n".join(lines)) + self.assertEqual(len(anon.ipv4), 800) + self.assertEqual(len(set(anon.ipv4.values())), 800) + + def test_ipv6_global_replaced_coherent(self): + cfg = "ipv6 route 2400:cb00::/32 Null0\nntp server 2400:cb00::\n" + out, anon = sanitize(cfg) + self.assertNotIn("2400:cb00", out) + self.assertEqual(len(set(anon.ipv6.values())), 1) + + def test_ipv6_doc_range_kept(self): + line = "ipv6 route 2001:db8:1::/48 Null0" + out, _ = sanitize(line) + self.assertEqual(out, line) + + def test_mac_unicast_replaced_coherently_across_formats(self): + cfg = ("mac address-table static aabb.ccdd.eeff vlan 10 interface Gi1/0/1\n" + "arp 10.0.0.5 aa:bb:cc:dd:ee:ff arpa\n") + out, anon = sanitize(cfg) + self.assertNotIn("aabb.ccdd.eeff", out) + self.assertNotIn("aa:bb:cc:dd:ee:ff", out) + digits = {v.replace(".", "").replace(":", "") for v in anon.macs.values()} + self.assertEqual(len(digits), 1) # meme MAC -> meme alias + + def test_mac_multicast_and_virtual_kept(self): + for line in ("mac address-table static 0100.5e00.0001 vlan 1 interface Gi1/0/1", + "arp 10.0.0.9 0000.0c07.ac0a arpa", + "arp 10.0.0.8 0000.5e00.0105 arpa"): + out, _ = sanitize(line) + self.assertEqual(out, line) + + def test_email_destroyed(self): + out, _ = sanitize("alias exec support mail noc@corp.example") + self.assertNotIn("noc@corp.example", out) + self.assertIn(sn.S_EMAIL, out) + + def test_descriptions_tokenized_coherently(self): + cfg = ("interface Gi1/0/1\n description Lien WAN Orange CT-12345\n" + "interface Gi1/0/2\n description Lien WAN Orange CT-12345\n" + "interface Gi1/0/3\n description Autre lien\n") + out, anon = sanitize(cfg) + self.assertNotIn("CT-12345", out) + self.assertEqual(len(anon.descriptions), 2) + + def test_keep_descriptions(self): + out, _ = sanitize("interface Gi1\n description Lien WAN CT-12345", + keep_descriptions=True) + self.assertIn("Lien WAN CT-12345", out) + + +class TestBlocks(unittest.TestCase): + def test_banner_multiline(self): + cfg = ("banner motd ^C\n" + "Property of ACME Corp - authorized access only\n" + "^C\n" + "line vty 0 4\n") + out, _ = sanitize(cfg) + self.assertNotIn("ACME", out) + self.assertIn(sn.S_BANNER, out) + self.assertIn("line vty 0 4", out) + + def test_banner_singleline(self): + out, _ = sanitize("banner login #Restricted - ACME#") + self.assertNotIn("ACME", out) + self.assertIn(sn.S_BANNER, out) + + def test_banner_eos_eof(self): + cfg = ("banner login\n" + "Welcome to ACME network\n" + "EOF\n" + "interface Ethernet1\n") + out, _ = sanitize(cfg) + self.assertNotIn("ACME", out) + self.assertIn("interface Ethernet1", out) + + def test_pem_block(self): + cfg = ("ssl certificate mycert\n" + "-----BEGIN CERTIFICATE-----\n" + "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA7x8Z\n" + "-----END CERTIFICATE-----\n" + "end\n") + out, _ = sanitize(cfg) + self.assertNotIn("MIIBIjAN", out) + self.assertIn(sn.S_CERT, out) + self.assertIn("end", out) + + def test_hexblob_with_quit(self): + cfg = ("crypto pki certificate chain TP-self-signed-1\n" + " certificate self-signed 01\n" + " 30820330 308202B9 A0030201 02020101\n" + " 300D0609 2A864886 F70D0101 04050030\n" + " quit\n" + "!\n") + out, _ = sanitize(cfg) + self.assertNotIn("30820330", out) + self.assertEqual(out.count(sn.S_CERT), 1) + self.assertIn(" certificate self-signed 01", out) + self.assertIn("!", out) + + +class TestResidual(unittest.TestCase): + def test_keyword_line_flagged(self): + flagged = sn.residual_warnings(["foo passphrase bar"]) + self.assertEqual(len(flagged), 1) + + def test_sanitized_line_not_flagged(self): + flagged = sn.residual_warnings(["username x secret " + sn.S_SECRET]) + self.assertEqual(flagged, []) + + def test_hash_value_flagged_even_with_marker(self): + # Une valeur type hash restee sur une ligne partiellement traitee + # doit quand meme remonter. + flagged = sn.residual_warnings( + ["something " + sn.S_SECRET + " leftover $6$roundsalt$abcdef012345"]) + self.assertEqual(len(flagged), 1) + + +class TestCli(unittest.TestCase): + def run_cli(self, args, stdin): + return subprocess.run( + [sys.executable, str(SCRIPT)] + args, + input=stdin, capture_output=True, text=True) + + def test_stdin_stdout(self): + r = self.run_cli(["-"], "enable secret 5 $1$abc$def\n") + self.assertEqual(r.returncode, 0) + self.assertIn(sn.S_SECRET, r.stdout) + self.assertNotIn("$1$abc$def", r.stdout) + self.assertIn("Recapitulatif", r.stderr) + + def test_no_summary(self): + r = self.run_cli(["-", "--no-summary"], "hostname R1\n") + self.assertEqual(r.stderr, "") + + def test_strict_exit_code_on_residual(self): + r = self.run_cli(["-", "--strict", "--no-summary"], + "some unknown passphrase thing\n") + self.assertEqual(r.returncode, 2) + + def test_missing_input_file_friendly_error(self): + r = self.run_cli(["/nonexistent/file.txt"], "") + self.assertEqual(r.returncode, 1) + self.assertIn("lecture impossible", r.stderr) + + +if __name__ == "__main__": + unittest.main()