From 279ade26ceb3c6db71a776967a0b5072d7ffc9d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 14:06:26 +0000 Subject: [PATCH] Translate to English, expand docs and add uv packaging Internationalization: - Translate the module docstring, all comments, CLI help, stderr summary and error messages of sanitize_netconfig.py to English. Sanitization behavior is unchanged (markers were already English). - Rename the mapping-file warning key "_avertissement" to "_warning". - Translate the test suite and align assertions with the new CLI strings; add a test covering the JSON mapping file output. Documentation: - Rewrite README.md in English with a real before/after example (generated by the tool itself), motivation, installation via uv/uvx/pip/single-file copy, options and exit-code tables, detailed destroyed/pseudonymized/preserved lists, mapping-file guidance, limitations and development instructions, plus a CI badge. uv / packaging support: - Add pyproject.toml (setuptools backend, single py-module, dynamic version from __version__, netanom console entry point, MIT SPDX license metadata, Python >= 3.9). - Commit uv.lock; uv run / uv build / uv tool install now work out of the box. - CI now uses astral-sh/setup-uv, runs the unit tests on Python 3.9 and 3.13, smoke-tests the installed CLI entry point (--version and a --strict sanitization pass) and builds the sdist/wheel. - Bump version to 0.3.0. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Sra7y2NhqmvwkvWSB5Q5D4 --- .github/workflows/ci.yml | 13 +- README.md | 361 +++++++++++++++++++++++++++---------- pyproject.toml | 52 ++++++ sanitize_netconfig.py | 377 ++++++++++++++++++++------------------- tests/test_sanitize.py | 72 ++++---- uv.lock | 7 + 6 files changed, 566 insertions(+), 316 deletions(-) create mode 100644 pyproject.toml create mode 100644 uv.lock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21edd2e..d31b392 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: tests +name: CI on: push: @@ -12,8 +12,15 @@ jobs: python-version: ["3.9", "3.13"] steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - name: Install uv + uses: astral-sh/setup-uv@v5 with: python-version: ${{ matrix.python-version }} - name: Run unit tests - run: python -m unittest discover -s tests -v + run: uv run python -m unittest discover -s tests -v + - name: Smoke test the CLI entry point + run: | + uv run netanom --version + printf 'hostname R1\nenable secret 5 $1$abc$def\n' | uv run netanom - --strict + - name: Build sdist and wheel + run: uv build diff --git a/README.md b/README.md index be7d58a..5d46ac1 100644 --- a/README.md +++ b/README.md @@ -1,124 +1,289 @@ # netanom -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. +[![CI](https://github.com/Hotion13/netanom/actions/workflows/ci.yml/badge.svg)](https://github.com/Hotion13/netanom/actions/workflows/ci.yml) -Un seul fichier, aucune dépendance : `sanitize_netconfig.py` (Python ≥ 3.8). +**netanom** sanitizes **Cisco (IOS / IOS-XE / NX-OS)** and **Arista EOS** +configurations so you can share them with an AI assistant — or any third +party — without disclosing secrets or identifying information, while keeping +the file **internally consistent and fully usable for analysis**. -## Principe +It is a single, dependency-free Python module (`sanitize_netconfig.py`, +Python ≥ 3.9) that also installs as a proper CLI (`netanom`). -Deux familles de traitement : +## Why -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. +Pasting a running configuration into an AI assistant is one of the fastest +ways to get help with network design, troubleshooting or auditing — and one +of the fastest ways to leak enable secrets, SNMP communities, TACACS+ keys, +BGP passwords, public addressing plans and organizational details. Redacting +by hand is error-prone, and naive find-and-replace breaks the very +relationships (which peer talks to which address, which host owns which +name) that make the analysis useful. -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. +## How it works + +netanom applies two distinct treatments: + +1. **Secrets → destruction.** The real value is replaced by a marker + (``, ``, …) and is never + stored anywhere. The algorithm hint (type 7, type 9, sha512, RO/RW, + ACL name…) is preserved: it is useful security information and is not + confidential. + +2. **Identifiers → consistent pseudonymization.** A given original value is + always replaced by the same alias (`device-1`, `198.51.100.x`, + `example-1.net`, …) so cross-references inside the file remain + analyzable. The mapping table can be written to a `.map.json` file — + **to be kept local** — so you can translate the AI's recommendations + back to your real configuration. + +A **residual-secret heuristic** then scans the output and lists any line +that still looks suspicious (unknown password syntaxes, leftover +hash-looking values), so you know exactly what to review before sharing. + +## Example + +Input: + +```text +hostname PARIS-CORE-01 +ip domain name prod.acme.example +! +enable secret 5 $1$mERr$hx5rVt7rPNoS4wqbXKX7m0 +! +snmp-server community acme-RO-2024 RO 99 +snmp-server location Paris DC, rack B12 +! +interface GigabitEthernet0/0 + description WAN to provider - circuit CT-889123 + ip address 82.121.34.5 255.255.255.252 +! +router bgp 65001 + neighbor 82.121.34.6 remote-as 3215 + neighbor 82.121.34.6 password 7 121A0C041104 +! +ntp server ntp1.prod.acme.example +``` + +Output (`netanom config.txt`): + +```text +hostname device-1 +ip domain name example-1.net +! +enable secret 5 +! +snmp-server community RO 99 +snmp-server location +! +interface GigabitEthernet0/0 + description + ip address 198.51.100.1 255.255.255.252 +! +router bgp 65001 + neighbor 198.51.100.2 remote-as 3215 + neighbor 198.51.100.2 password 7 +! +ntp server ntp1.example-1.net +``` + +Note how the BGP peer keeps a single consistent alias (`198.51.100.2`) on +both lines, the FQDN `ntp1.prod.acme.example` follows the domain's alias, +private addressing and the `RO 99` community attributes are untouched, and +the secret type hints (`5`, `7`) are preserved. + +## Installation + +With [uv](https://docs.astral.sh/uv/) (recommended): + +```console +# install the netanom command globally +uv tool install git+https://github.com/Hotion13/netanom + +# or run it once, without installing +uvx --from git+https://github.com/Hotion13/netanom netanom config.txt +``` + +With pip: + +```console +pip install git+https://github.com/Hotion13/netanom +``` + +Zero-install: `sanitize_netconfig.py` is self-contained (standard library +only). Copy that single file to a jump host and run it with any +Python ≥ 3.9: + +```console +python3 sanitize_netconfig.py config.txt +``` ## Usage ```console -# fichier -> fichier, avec table de correspondance locale -python3 sanitize_netconfig.py config.txt -o config.san.txt -m config.map.json +# file -> file, with a local mapping table +netanom config.txt -o config.san.txt -m config.map.json + +# as a filter (stdin -> stdout) +show_run_output | netanom - > config.san.txt -# en filtre (stdin -> stdout) -cat config.txt | python3 sanitize_netconfig.py - > config.san.txt +# also anonymize private IPs, keep interface descriptions +netanom config.txt --anonymize-all-ips --keep-descriptions -# anonymiser aussi les IP privées, garder les descriptions d'interface -python3 sanitize_netconfig.py config.txt --anonymize-all-ips --keep-descriptions +# CI / pipeline gate: fail if anything suspicious remains +netanom config.txt -o config.san.txt --strict ``` -Un récapitulatif est affiché sur stderr, avec la liste des lignes résiduelles -qui ressemblent encore à un secret : **relisez-les toujours** avant envoi. +A summary is printed on stderr (stdout stays clean for piping), including +the list of residual lines that still look like secrets — **always review +them** before sharing the output. ### Options -| Option | Effet | +| Option | Effect | |---|---| -| `-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 +| `-o, --output FILE` | Output file (default: stdout) | +| `-m, --map FILE` | Write the JSON mapping table (keep it local, **never** share it) | +| `--anonymize-all-ips` | Also anonymize private (RFC 1918) addresses, not only public ones | +| `--keep-descriptions` | Keep interface descriptions as-is (e-mails/IPs/hostnames inside them are still processed) | +| `--keep-macs` | Keep MAC addresses as-is | +| `--no-summary` | Do not print the summary on stderr | +| `--strict` | Exit with status 2 if suspicious residual lines remain (CI-friendly) | +| `--version` | Show version | + +### Exit codes + +| Code | Meaning | +|---|---| +| 0 | Success | +| 1 | I/O error (unreadable input, unwritable output) | +| 2 | Residual suspicious lines found while `--strict` is set (also used by argparse for invalid arguments) | + +## What gets destroyed (secrets) + +**Authentication & AAA** +- `enable secret` / `enable password` (types 0/5/7/8/9, EOS `sha512`) +- `username … secret/password` (all types, trailing options preserved) +- account SSH public keys (`username … sshkey …`) — identifying material +- line passwords (`line con/vty/aux` → `password …`) +- TACACS+ / RADIUS keys: `tacacs-server`/`radius-server … key` (including + after `auth-port`/`acct-port` options) and indented `key …` inside + `radius server X` / `tacacs server X` blocks +- password-encryption master key (`key config-key password-encrypt`) +- `ip ftp password`, `ip http client password` + +**SNMP** +- `snmp-server community` (RO/RW and ACL kept) +- community/v3 user embedded in every `snmp-server host` form + (`vrf`/`use-vrf`, `traps`/`informs`, `version 1|2c|3 auth|noauth|priv`) +- SNMPv3 keys: `auth md5|sha|sha-256…`, `priv des|3des|aes [128|192|256]`, + hyphenated `aes-128` and NX-OS localized `0x…` forms +- `snmp-server location` / `contact` (free text → marker) + +**Routing, FHRP & neighbors** +- BGP/EOS `neighbor … password` +- OSPF `message-digest-key N md5` and `authentication-key` +- `authentication text`, key-chain `key-string` +- IS-IS `isis password`, `area-password`, `domain-password` +- HSRP/VRRP plaintext `authentication` (md5/key-chain forms handled via + their own rules, key-chain structure preserved) +- NTP authentication keys (md5/sha/hmac-sha2/cmac-aes, either + encryption-type position) +- PPP CHAP/PAP (`ppp chap password`, `sent-username … password`) + +**VPN & PKI** +- `crypto isakmp key [0|6] …`, `pre-shared-key [local|remote] …` +- inline `key 0|5|7|8|9 ` occurrences (keyrings, server blocks…) +- PEM blocks (`-----BEGIN … END-----`) and IOS hex certificate blobs + (collapsed to one `` marker) + +**Free text** +- banners (`banner motd/login/exec …`, IOS delimiters and EOS `EOF` style) +- e-mail addresses (everywhere, including inside kept descriptions) +- interface descriptions → consistent `` tokens + +## What gets pseudonymized (consistent aliases) + +| Identifier | Alias | Notes | +|---|---|---| +| `hostname` / `switchname` / `sysname` | `device-N` | replaced everywhere, case-insensitive, including inside `host.domain` FQDNs | +| domains (`ip domain-name`, `ip domain list`, `dns domain`, DHCP `domain-name`, `vrf` forms) | `example-N.net` | replaced everywhere, including as FQDN suffix | +| public IPv4 | RFC 5737 documentation ranges, then 198.18.0.0/15 (RFC 2544) | > 130,000 distinct addresses supported without alias collision | +| public IPv6 | `2001:db8::/32` (RFC 3849) | equivalent notations of one address share one alias | +| MAC addresses | locally-administered `02:…` | format preserved; `aabb.ccdd.eeff` and `aa:bb:cc:dd:ee:ff` notations of the same MAC share one alias | + +## What is deliberately preserved + +- private (RFC 1918) IPv4 addresses — unless `--anonymize-all-ips` +- documentation, multicast, loopback, link-local, unspecified and reserved + addresses; netmasks and wildcard masks; `0.0.0.0/8` +- multicast/broadcast MACs and well-known virtual MACs (HSRP, VRRP, GLBP) +- AS numbers, VLAN IDs, interface names, ACL/route-map/VRF names +- algorithm/type hints (`5`, `7`, `9`, `sha512`, `md5`…) and SNMP RO/RW + + ACL attributes +- key-chain structure (`key chain NAME`, key numbers) + +## The mapping file (`-m`) + +The table written by `-m` maps `real value → alias`: + +```json +{ + "_warning": "Mapping table used to DE-anonymize the AI's answers. KEEP THIS FILE LOCAL. Never send it to the AI or to any third party.", + "hosts": { "PARIS-CORE-01": "device-1" }, + "domains": { "prod.acme.example": "example-1.net" }, + "ipv4": { "82.121.34.5": "198.51.100.1", "82.121.34.6": "198.51.100.2" }, + "ipv6": {}, + "macs": {}, + "descriptions": { "WAN to provider - circuit CT-889123": "" } +} +``` + +It exists so you can apply the AI's answer back to the real network +(e.g. replace `device-1` / `198.51.100.2` with the actual values). **Treat +it as a secret: never send it to the AI or to any third party.** Destroyed +secrets are never written to it. + +## Limitations (important) + +This is a best-effort, pattern-based tool — it does **not** replace a human +review. In particular, check manually: + +- VRF, route-map, ACL, prefix-list and trustpoint **names** (kept as-is; they + sometimes encode customer or site names) +- serial numbers, license tokens, circuit IDs inside kept descriptions +- EOS agent tokens (`daemon TerminAttr` ingest keys), WLC/WLAN PSKs, + `snmp-server engineID` +- purely numeric values after `key` (ambiguous with a key-chain key number) +- any secret with an exotic or vendor-specific syntax + +Masks and prefixes are not recomputed: a replaced network address may become +a host address (subnet-level consistency is not guaranteed). The residual +heuristic reports suspicious leftovers; `--strict` turns that report into a +non-zero exit code for pipelines. + +## Development + +The project uses [uv](https://docs.astral.sh/uv/); tests are standard +`unittest` (no test dependencies). ```console -python3 -m unittest discover -s tests -v +git clone https://github.com/Hotion13/netanom +cd netanom + +uv run python -m unittest discover -s tests -v # run the test suite +uv run netanom --version # run the CLI from source +uv build # build sdist + wheel ``` -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. +Without uv, `python3 -m unittest discover -s tests -v` works too. + +The test suite includes one regression test per previously fixed leak +(OSPF/NTP keys whose key number is 0/5/7/8/9, `snmp-server host … vrf`, +NX-OS `priv 0x…`/`aes-128`, `crypto isakmp key 6 …`, +`radius-server … auth-port … key`, server-block `key`, plaintext HSRP/VRRP, +`host.domain` FQDNs, …). -## Licence +## License [MIT](LICENSE) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1dc187b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,52 @@ +[build-system] +requires = ["setuptools>=77"] +build-backend = "setuptools.build_meta" + +[project] +name = "netanom" +dynamic = ["version"] +description = "Sanitize Cisco IOS/IOS-XE/NX-OS and Arista EOS configurations before sharing them with an AI or any third party" +readme = "README.md" +requires-python = ">=3.9" +license = "MIT" +license-files = ["LICENSE"] +authors = [{ name = "Hotion" }] +keywords = [ + "network", + "cisco", + "arista", + "ios", + "nx-os", + "eos", + "anonymization", + "pseudonymization", + "sanitizer", + "security", + "configuration", +] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: System Administrators", + "Intended Audience :: Telecommunications Industry", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Topic :: Security", + "Topic :: System :: Networking", + "Topic :: System :: Systems Administration", +] + +[project.urls] +Homepage = "https://github.com/Hotion13/netanom" +Repository = "https://github.com/Hotion13/netanom" +Issues = "https://github.com/Hotion13/netanom/issues" + +[project.scripts] +netanom = "sanitize_netconfig:main" + +[tool.setuptools] +py-modules = ["sanitize_netconfig"] + +[tool.setuptools.dynamic] +version = { attr = "sanitize_netconfig.__version__" } diff --git a/sanitize_netconfig.py b/sanitize_netconfig.py index baa6f37..113224d 100755 --- a/sanitize_netconfig.py +++ b/sanitize_netconfig.py @@ -1,87 +1,91 @@ #!/usr/bin/env python3 -# -*- coding: utf-8 -*- """ -sanitize_netconfig.py — Anonymiseur de configurations reseau -============================================================ - -Cible : Cisco (IOS / IOS-XE / NX-OS) et Arista EOS. -But : retirer ou pseudonymiser les informations sensibles d'un fichier de - configuration AVANT de le transmettre a une IA (ou tout tiers), sans - casser la coherence interne necessaire a une analyse. - -Deux familles de traitement ---------------------------- -1. SECRETS -> DESTRUCTION. La vraie valeur est remplacee par un marqueur - (ex. ) et n'est jamais conservee nulle part. On garde - quand meme l'indice d'algorithme (type 7, type 9, sha512, RO/RW, ACL...) - car c'est une info de securite utile et NON confidentielle. - -2. IDENTIFIANTS -> PSEUDONYMISATION COHERENTE. Une meme valeur d'origine est - toujours remplacee par le meme alias (host-1, 198.51.100.x, example-1.net...) - afin que les relations du fichier restent exploitables par l'IA. La table de - correspondance peut etre ecrite dans un fichier .map.json a CONSERVER EN - LOCAL : elle permet de re-appliquer ensuite les recommandations de l'IA a la - vraie configuration. Ce fichier ne doit JAMAIS etre transmis a l'IA. - -Ce qui est neutralise +sanitize_netconfig.py — Network configuration sanitizer +======================================================== + +Targets : Cisco (IOS / IOS-XE / NX-OS) and Arista EOS. +Purpose : strip or pseudonymize sensitive information from a configuration + file BEFORE sending it to an AI (or any third party), without + breaking the internal consistency required for a useful analysis. + +Two families of treatment +------------------------- +1. SECRETS -> DESTRUCTION. The real value is replaced by a marker + (e.g. ) and is never stored anywhere. The algorithm + hint (type 7, type 9, sha512, RO/RW, ACL...) is kept, as it is useful + security information and NOT confidential. + +2. IDENTIFIERS -> CONSISTENT PSEUDONYMIZATION. A given original value is + always replaced by the same alias (device-1, 198.51.100.x, + example-1.net...) so that the relationships within the file remain + usable by the AI. The mapping table can be written to a .map.json file + to be KEPT LOCALLY: it lets you re-apply the AI's recommendations to + the real configuration afterwards. That file must NEVER be sent to the + AI or to any third party. + +What gets neutralized --------------------- -SECRETS DETRUITS : - - enable secret / enable password (tous types : 0,5,7,8,9, sha512) +DESTROYED SECRETS: + - enable secret / enable password (all 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 (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 + - user SSH public keys (username ... sshkey ...): identifying material + - line passwords (line con/vty/aux: password ...) + - SNMP communities (snmp-server community) and the community embedded in + snmp-server host (incl. vrf / use-vrf / traps / informs / version 1|2c|3) + - SNMPv3 keys (auth md5/sha[-]..., priv des/aes[-128...], NX-OS 0x... forms) + - TACACS+ / RADIUS keys (tacacs-server key, radius-server ... key, and + "radius server X" / "tacacs server X" blocks with an indented "key", + key 7 ...) + - password-encryption master key (key config-key password-encrypt) + - VPN pre-shared keys (crypto isakmp key [0|6|encrypted], pre-shared-key) + - neighbor / routing-protocol authentication (neighbor ... password, OSPF message-digest-key md5, authentication-key, authentication text, IS-IS isis password / area-password / domain-password) - - authentification FHRP en clair (standby / vrrp ... authentication) + - plaintext FHRP authentication (standby / vrrp ... authentication) - key-string (key chains) - 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, 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, - 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) - -Limites (IMPORTANT) -------------------- -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, 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. + NTP authentication keys + - PKI certificates (IOS hex blobs) and PEM blocks + (-----BEGIN...-----END-----) + - banners (banner motd/login/exec ...), which often carry + organizational/legal information + - e-mail addresses + +PSEUDONYMIZED IDENTIFIERS (consistent): + - hostname / switchname / sysname -> device-N (replaced everywhere, + case-insensitive, including inside "host.domain" FQDNs) + - domain (ip domain-name / domain list / dns domain / DHCP domain-name) + -> example-N.net (replaced everywhere, including as an FQDN suffix) + - public IPv4 addresses -> RFC 5737 documentation ranges, then + 198.18.0.0/15 (RFC 2544) beyond ~760 distinct addresses + - public IPv6 addresses -> 2001:db8::/32 (RFC 3849) + - MAC addresses -> fictitious locally-administered MACs (format preserved, + consistent across the aabb.ccdd.eeff and aa:bb:cc:dd:ee:ff notations); + multicast/broadcast MACs and well-known virtual MACs (HSRP, VRRP, GLBP) + are kept, as they carry protocol meaning and are not identifying + - interface descriptions -> consistent token (--keep-descriptions to keep) + - snmp-server location / contact -> marker (address, phone, name are + sensitive) + +Limitations (IMPORTANT) +----------------------- +Best-effort, pattern-based tool. It does NOT replace a human review. In +particular, manually check: VRF / route-map / ACL / trustpoint names, +serial and license numbers, circuit IDs inside descriptions, EOS agent +tokens (daemon TerminAttr...), WLAN PSKs, snmp engineID, purely numeric +values after "key" (ambiguous with a key-chain key number), and any secret +with an exotic syntax. Masks/prefixes are not recomputed: a replaced +network address may become a host address (subnet consistency is not +guaranteed). At the end of processing the script prints the residual lines +that still look like secrets: review them. Usage ----- + netanom config.txt -o config.san.txt -m config.map.json (installed CLI) 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 + python3 sanitize_netconfig.py config.txt --strict # exit code 2 on residue """ import argparse @@ -91,13 +95,13 @@ import sys from itertools import count -__version__ = "0.2.0" +__version__ = "0.3.0" # --------------------------------------------------------------------------- -# Marqueurs +# Markers # --------------------------------------------------------------------------- def ph(category: str) -> str: - """Retourne un marqueur de censure homogene pour une categorie donnee.""" + """Return a homogeneous redaction marker for a given category.""" return f"" S_SECRET = ph("SECRET") @@ -110,69 +114,69 @@ def ph(category: str) -> str: 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. +# Secret DESTRUCTION rules +# Each rule = (compiled regex, replacement string). +# Convention: keep the "directive" plus the algorithm hint through capturing +# groups, destroy only the sensitive value. +# ORDER MATTERS: from most specific to most generic. The inline +# "key " rule is deliberately LAST and never matches inside a +# hyphenated -key directive (message-digest-key, etc.), otherwise it would +# destroy the algorithm token and let the actual secret leak. # --------------------------------------------------------------------------- _F = re.IGNORECASE SECRET_RULES = [ - # enable secret/password [level N] [0|5|7|8|9|sha512...] + # 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] + # username X [privilege N] [role ...] secret|password [algo] [rest] (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) + # account SSH public key (EOS "username X sshkey ssh-rsa ..."): + # not a secret, but identifying (comment field, correlation potential) (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] + # line password or generic: password [type] (re.compile(r'^(\s*password\s+(?:\d\s+)?)\S+.*$', _F), r'\1' + S_SECRET), - # secret nu (rare hors username) : secret [algo] + # bare secret (rare outside username): secret [algo] (re.compile(r'^(\s*secret\s+(?:sha512\s+|sha256\s+|\d\s+)?)\S+.*$', _F), r'\1' + S_SECRET), - # SNMP : communaute (on garde RO/RW + ACL eventuelle) + # SNMP: community (keep RO/RW and the optional ACL) (re.compile(r'^(\s*snmp-server\s+community\s+)\S+(.*)$', _F), r'\1' + S_COMMUNITY + r'\2'), - # 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). + # SNMP host: every IOS/NX-OS/EOS form. Skip the known keywords (vrf X, + # use-vrf X, traps, informs, version X, auth/noauth/priv) then destroy + # the next token = community (or v3 user, an identifier). (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|sha-256... / priv des|3des|aes[- ]128... + # 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), - # forme NX-OS localisee : priv 0x (sans mot-cle d'algo) + # NX-OS localized form: priv 0x (no algorithm keyword) (re.compile(r'(\bpriv\s+)(0x[0-9A-Fa-f]+)', _F), r'\1' + S_SECRET), - # TACACS+ / RADIUS : ligne globale, quel que soit ce qui precede "key" + # TACACS+ / RADIUS: global line, whatever precedes "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), - # cle maitre de chiffrement des mots de passe (AES password encryption) + # password-encryption master key (AES password encryption) (re.compile(r'^(\s*key\s+config-key\s+password-encrypt\s+)\S.*$', _F), r'\1' + S_SECRET), - # "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). + # "key [type] " at the start of a line ("radius server X" / + # "tacacs server X" blocks). Leaves "key chain X", key-chain key numbers + # ("key 1") and "key config-key ..." (dedicated rule above) untouched. (re.compile(r'^(\s*key\s+(?:\d\s+)?)(?!chain\b|config-key\b|\d+\s*$)\S.*$', _F), r'\1' + S_SECRET), @@ -183,7 +187,7 @@ def ph(category: str) -> str: r'(?!address\b|hostname\b)(\S+)', _F), r'\1' + S_SECRET), - # Routage : BGP / OSPF / divers + # Routing: BGP / OSPF / misc (re.compile(r'(\bneighbor\s+\S+\s+password\s+(?:\d\s+)?)(\S+)', _F), r'\1' + S_SECRET), (re.compile(r'(\bmessage-digest-key\s+\d+\s+md5\s+(?:\d\s+)?)(\S+)', _F), @@ -192,12 +196,12 @@ 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] + # 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) + # FHRP: standby/vrrp ... authentication + # (md5/text/key-string/key-chain forms are handled by other rules) (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'), @@ -217,31 +221,31 @@ def ph(category: str) -> str: 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). + # Generic inline "... key " — LAST. The lookbehind forbids + # matching inside "message-digest-key", "authentication-key", + # "pre-shared-key", etc. (directives already handled above). (re.compile(r'((? marqueur (pas de pseudonymisation, on detruit) +# "Free text value" rules -> marker (no pseudonymization, destroy) LOCATION_RX = re.compile(r'^(\s*snmp-server\s+location\s+).*$', _F) CONTACT_RX = re.compile(r'^(\s*snmp-server\s+contact\s+).*$', _F) DESCRIPTION_RX = re.compile(r'^(\s*description\s+)(.*)$', _F) -# Captures d'identifiants (passe de collecte) +# Identifier captures (collection pass) HOSTNAME_RX = re.compile(r'^\s*(?:hostname|switchname|sysname)\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 block / banner / hex blob PEM_BEGIN_RX = re.compile(r'-----BEGIN [^-]+-----') PEM_END_RX = re.compile(r'-----END [^-]+-----') BANNER_RX = re.compile(r'^(\s*banner\s+\S+)\s?(.*)$', _F) EMAIL_RX = re.compile(r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b') -# Adresses +# Addresses IPV4_RX = re.compile(r'(? str: NET0_8 = ipaddress.ip_network("0.0.0.0/8") -# MAC virtuelles bien connues, conservees (prefixes hex sans separateur) : +# Well-known virtual MACs, kept (hex prefixes without separators): # 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-secret detection (output safety net) RESIDUAL_RX = re.compile( 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...) +# Values that look like a hash/key, even on an already-marked line +# (crypt/type5/8/9 hashes: $1$..., $6$..., $8$..., $9$... ; hex blobs 0x...) RESIDUAL_VALUE_RX = re.compile(r'\$(?:1|2[aby]?|5|6|8|9)\$\S{6,}|\b0x[0-9A-Fa-f]{16,}\b') @@ -283,7 +287,7 @@ def __init__(self, anon_all_ips=False, keep_descriptions=False, keep_macs=False) self.keep_descriptions = keep_descriptions self.keep_macs = keep_macs - # Tables de correspondance (identifiants uniquement) + # Mapping tables (identifiers only) self.hosts = {} self.domains = {} self.ipv4 = {} @@ -291,26 +295,26 @@ def __init__(self, anon_all_ips=False, keep_descriptions=False, keep_macs=False) self.macs = {} self.descriptions = {} - # Compteurs / pools + # Counters / pools self._host_c = count(1) self._domain_c = count(1) self._desc_c = count(1) 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 + "198.18.0.0/15") # RFC 2544 overflow (>760 IPs) 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) + # Normalized keys -> alias (the same address written in several ways + # always receives the same 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 (cle en minuscules) - self._word_rx = None # termes avec lettres : bordure '.' toleree (FQDN) - self._word_rx_strict = None # termes sans lettre : bordures strictes + # Word-for-word replacement (filled during the collection pass) + self._word_map = {} # alias <- original term (lowercase key) + self._word_rx = None # terms with letters: '.' allowed at edges (FQDN) + self._word_rx_strict = None # letterless terms: strict boundaries # Stats self.stats = {"secrets": 0, "banners": 0, "certs": 0, @@ -328,7 +332,7 @@ def _alloc_ipv4(self, original, ip): if key not in self._ipv4_norm: try: self._ipv4_norm[key] = str(next(self._ipv4_pool)) - except StopIteration: # pool epuise (>130 000 IP !) + except StopIteration: # pool exhausted (>130,000 IPs!) self._ipv4_norm[key] = "198.19.255.254" alias = self._ipv4_norm[key] self.ipv4[original] = alias @@ -344,13 +348,13 @@ def _alloc_ipv6(self, original, ip): def _alloc_mac(self, original, sep_dot): 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) + # Multicast/broadcast, all-zero or well-known virtual MAC: keep + # (they carry protocol meaning and are not identifying) 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 + # locally-administered OUI (U/L bit set) -> matches no real hardware self._mac_norm[digits] = "02%010x" % next(self._mac_c) raw = self._mac_norm[digits] if sep_dot: @@ -360,7 +364,7 @@ def _alloc_mac(self, original, sep_dot): self.macs[original] = fake return fake - # --- decision de conservation d'une IP ---------------------------------- + # --- should an IP be kept as-is? ----------------------------------------- def _keep_ip(self, ip): if ip.version == 4 and ip in NET0_8: return True @@ -371,7 +375,7 @@ def _keep_ip(self, ip): return False return not ip.is_global - # --- passe 1 : collecte hostnames / domaines ---------------------------- + # --- pass 1: collect hostnames / domains --------------------------------- def collect(self, lines): seen_hosts, seen_domains = set(), set() for ln in lines: @@ -387,10 +391,10 @@ def collect(self, lines): for original, alias in {**self.hosts, **self.domains}.items(): self._word_map[original.lower()] = alias - # Deux regex : les termes contenant des lettres acceptent un '.' en - # bordure (pour attraper les FQDN "hote.domaine.tld"), les termes - # purement numeriques gardent des bordures strictes (ne pas matcher - # dans une adresse IP). Termes les plus longs d'abord (sous-chaines). + # Two regexes: terms containing letters accept a '.' at their edges + # (to catch "host.domain.tld" FQDNs), purely numeric terms keep + # strict boundaries (must not match inside an IP address). Longest + # terms first (substrings). relaxed = [t for t in self._word_map if re.search(r'[a-z]', t)] strict = [t for t in self._word_map if t not in relaxed] @@ -404,7 +408,7 @@ def build(terms, before, after): self._word_rx = build(relaxed, r'(? marqueur + # 2) location / contact -> marker if LOCATION_RX.match(line): self.stats["locations"] += 1 return LOCATION_RX.sub(r'\1' + S_LOCATION, line) @@ -467,20 +471,20 @@ def transform_line(self, line): # 3) descriptions if not self.keep_descriptions: line = self._sub_description(line) - # 4) emails + # 4) e-mails if EMAIL_RX.search(line): self.stats["emails"] += len(EMAIL_RX.findall(line)) line = EMAIL_RX.sub(S_EMAIL, line) - # 5) hostnames / domaines (mot-pour-mot, coherent) + # 5) hostnames / domains (word-for-word, consistent) line = self._sub_words(line) - # 6) adresses + # 6) addresses line = self._sub_ipv4(line) line = self._sub_ipv6(line) if not self.keep_macs: line = self._sub_macs(line) return line - # --- passe 2 : traitement avec gestion des blocs multi-lignes ----------- + # --- pass 2: processing with multi-line block handling -------------------- def process(self, lines): out = [] i, n = 0, len(lines) @@ -488,29 +492,29 @@ def process(self, lines): line = lines[i] stripped = line.strip() - # bloc PEM + # PEM block if PEM_BEGIN_RX.search(line): out.append(self._indent(line) + S_CERT) self.stats["certs"] += 1 i += 1 while i < n and not PEM_END_RX.search(lines[i]): i += 1 - i += 1 # saute la ligne END + i += 1 # skip the END line continue - # banniere + # banner mb = BANNER_RX.match(line) if mb: head, rest = mb.group(1), mb.group(2) self.stats["banners"] += 1 rest_s = rest.strip() if rest_s: - # delimiteur : un seul char, ou sequence "^X" (ex. ^C) + # delimiter: a single char, or a "^X" sequence (e.g. ^C) if rest_s.startswith("^") and len(rest_s) >= 2 and rest_s[1].isalpha(): delim = rest_s[:2] else: delim = rest_s[0] - # banniere mono-ligne : delimiteur present 2 fois + # single-line banner: delimiter present twice if rest_s.count(delim) >= 2: out.append(f"{head} {delim}{S_BANNER}{delim}") i += 1 @@ -519,10 +523,10 @@ def process(self, lines): i += 1 while i < n and delim not in lines[i]: i += 1 - i += 1 # saute la ligne de fin + i += 1 # skip the closing line continue else: - # style EOS : termine par une ligne "EOF" + # EOS style: terminated by an "EOF" line out.append(f"{head} ^C{S_BANNER}^C") i += 1 while i < n and lines[i].strip() != "EOF": @@ -530,7 +534,7 @@ def process(self, lines): i += 1 continue - # blob hexa (certificat / cle IOS) + # hex blob (IOS certificate / key) if self._is_hexblob(stripped): out.append(self._indent(line) + S_CERT) self.stats["certs"] += 1 @@ -545,12 +549,12 @@ def process(self, lines): break continue - # ligne normale + # normal line out.append(self.transform_line(line)) i += 1 return out - # --- utilitaires -------------------------------------------------------- + # --- helpers -------------------------------------------------------------- @staticmethod def _indent(line): return line[:len(line) - len(line.lstrip())] @@ -564,9 +568,9 @@ def _is_hexblob(stripped): def mapping(self): return { - "_avertissement": ("Fichier de correspondance pour DE-anonymiser les " - "reponses de l'IA. A CONSERVER EN LOCAL. Ne jamais " - "transmettre a l'IA ou a un tiers."), + "_warning": ("Mapping table used to DE-anonymize the AI's answers. " + "KEEP THIS FILE LOCAL. Never send it to the AI or to " + "any third party."), "hosts": self.hosts, "domains": self.domains, "ipv4": self.ipv4, @@ -578,11 +582,11 @@ def mapping(self): # --------------------------------------------------------------------------- def residual_warnings(lines, limit=20): - """Repere les lignes qui ressemblent encore a un secret (a relire).""" + """Spot output lines that still look like a secret (to be reviewed).""" flagged = [] for idx, ln in enumerate(lines, 1): - # mots-cles suspects sur une ligne non traitee, OU valeur qui - # ressemble a un hash/une cle meme sur une ligne deja marquee + # suspicious keywords on an unprocessed line, OR a value that looks + # like a hash/key even on an already-marked line suspicious = RESIDUAL_VALUE_RX.search(ln) or ( " meme alias sur les deux lignes + # same original -> same alias on both lines alias = anon.ipv4["8.8.8.8"] self.assertEqual(out.count(alias), 2) @@ -246,11 +247,11 @@ 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 + self.assertIn("255.255.255.0", out) # netmask preserved 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. + # >762 distinct public IPs: the old code fell back to a single + # duplicated address, breaking the mapping table consistency. 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)) @@ -275,7 +276,7 @@ def test_mac_unicast_replaced_coherently_across_formats(self): 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 + self.assertEqual(len(digits), 1) # same MAC -> same 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", @@ -290,17 +291,17 @@ def test_email_destroyed(self): 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") + cfg = ("interface Gi1/0/1\n description WAN link Orange CT-12345\n" + "interface Gi1/0/2\n description WAN link Orange CT-12345\n" + "interface Gi1/0/3\n description Some other link\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", + out, _ = sanitize("interface Gi1\n description WAN link CT-12345", keep_descriptions=True) - self.assertIn("Lien WAN CT-12345", out) + self.assertIn("WAN link CT-12345", out) class TestBlocks(unittest.TestCase): @@ -363,8 +364,8 @@ def test_sanitized_line_not_flagged(self): 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. + # A hash-looking value left on a partially processed line must still + # be reported. flagged = sn.residual_warnings( ["something " + sn.S_SECRET + " leftover $6$roundsalt$abcdef012345"]) self.assertEqual(len(flagged), 1) @@ -381,7 +382,7 @@ def test_stdin_stdout(self): self.assertEqual(r.returncode, 0) self.assertIn(sn.S_SECRET, r.stdout) self.assertNotIn("$1$abc$def", r.stdout) - self.assertIn("Recapitulatif", r.stderr) + self.assertIn("Sanitization summary", r.stderr) def test_no_summary(self): r = self.run_cli(["-", "--no-summary"], "hostname R1\n") @@ -395,7 +396,18 @@ def test_strict_exit_code_on_residual(self): 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) + self.assertIn("cannot read", r.stderr) + + def test_map_file_written(self): + with tempfile.TemporaryDirectory() as tmp: + mapfile = pathlib.Path(tmp) / "out.map.json" + r = self.run_cli(["-", "--no-summary", "-m", str(mapfile)], + "hostname R1\nip route 8.8.8.8 255.255.255.255 Null0\n") + self.assertEqual(r.returncode, 0) + data = json.loads(mapfile.read_text(encoding="utf-8")) + self.assertIn("_warning", data) + self.assertEqual(data["hosts"], {"R1": "device-1"}) + self.assertIn("8.8.8.8", data["ipv4"]) if __name__ == "__main__": diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..8e2f361 --- /dev/null +++ b/uv.lock @@ -0,0 +1,7 @@ +version = 1 +revision = 3 +requires-python = ">=3.9" + +[[package]] +name = "netanom" +source = { editable = "." }