From ab8b368ec0af3c153a65e1dabf72c1bf1d725b32 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 14:34:05 +0000 Subject: [PATCH] Add --keep-ips option to retain all IP addresses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a --keep-ips flag that keeps every IPv4/IPv6 address as-is, including public ones — useful for lab/documentation configs or when correlating with external logs requires the real addresses. Secrets are still destroyed. The flag is mutually exclusive with --anonymize-all-ips, the mapping file no longer records ipv4/ipv6 entries, and the stderr summary warns how many public addresses were left in clear. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01De6qFufM9vrnhwRoG6pisV --- README.md | 11 +++++++++- sanitize_netconfig.py | 42 ++++++++++++++++++++++++++++++------ tests/test_sanitize.py | 49 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 5d46ac1..5fa41d9 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,9 @@ show_run_output | netanom - > config.san.txt # also anonymize private IPs, keep interface descriptions netanom config.txt --anonymize-all-ips --keep-descriptions +# lab / documentation config: keep every IP address as-is +netanom config.txt --keep-ips + # CI / pipeline gate: fail if anything suspicious remains netanom config.txt -o config.san.txt --strict ``` @@ -144,7 +147,8 @@ them** before sharing the output. |---|---| | `-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 | +| `--anonymize-all-ips` | Also anonymize private (RFC 1918) addresses, not only public ones (mutually exclusive with `--keep-ips`) | +| `--keep-ips` | Keep **all** IP addresses (IPv4/IPv6) as-is, including public ones — the summary then warns how many public addresses were left in clear | | `--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 | @@ -215,6 +219,11 @@ them** before sharing the output. ## What is deliberately preserved - private (RFC 1918) IPv4 addresses — unless `--anonymize-all-ips` +- **all** IP addresses when `--keep-ips` is set — useful for lab or + documentation configs, or when correlating with external logs + (traceroutes, firewall logs) requires the real addresses; secrets are + still destroyed, and the stderr summary warns how many public addresses + were left in clear - 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) diff --git a/sanitize_netconfig.py b/sanitize_netconfig.py index 113224d..b9d1571 100755 --- a/sanitize_netconfig.py +++ b/sanitize_netconfig.py @@ -58,6 +58,8 @@ -> 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 + (--keep-ips to keep every address as-is; --anonymize-all-ips to also + anonymize private RFC 1918 addresses — the two are mutually exclusive) - 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); @@ -85,6 +87,7 @@ 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 --keep-ips # keep all IP addresses python3 sanitize_netconfig.py config.txt --strict # exit code 2 on residue """ @@ -95,7 +98,7 @@ import sys from itertools import count -__version__ = "0.3.0" +__version__ = "0.4.0" # --------------------------------------------------------------------------- # Markers @@ -282,10 +285,17 @@ def ph(category: str) -> str: # --------------------------------------------------------------------------- class Anonymizer: - def __init__(self, anon_all_ips=False, keep_descriptions=False, keep_macs=False): + def __init__(self, anon_all_ips=False, keep_descriptions=False, keep_macs=False, + keep_ips=False): + if anon_all_ips and keep_ips: + raise ValueError("anon_all_ips and keep_ips are mutually exclusive") self.anon_all_ips = anon_all_ips self.keep_descriptions = keep_descriptions self.keep_macs = keep_macs + self.keep_ips = keep_ips + # Distinct public addresses left in clear because of keep_ips + # (reported in the summary: the addressing plan is being disclosed) + self.kept_public_ips = set() # Mapping tables (identifiers only) self.hosts = {} @@ -424,6 +434,9 @@ def repl(m): return m.group(0) if self._keep_ip(ip): return m.group(0) + if self.keep_ips: + self.kept_public_ips.add(str(ip)) + return m.group(0) return self._alloc_ipv4(addr, ip) + prefix return IPV4_RX.sub(repl, line) @@ -436,6 +449,9 @@ def repl(m): return m.group(0) if ip.version != 6 or self._keep_ip(ip): return m.group(0) + if self.keep_ips: + self.kept_public_ips.add(ip.compressed.lower()) + return m.group(0) return self._alloc_ipv6(addr, ip) + prefix return IPV6_RX.sub(repl, line) @@ -573,8 +589,8 @@ def mapping(self): "any third party."), "hosts": self.hosts, "domains": self.domains, - "ipv4": self.ipv4, - "ipv6": self.ipv6, + "ipv4": self.ipv4 if not self.keep_ips else {}, + "ipv6": self.ipv6 if not self.keep_ips else {}, "macs": self.macs if not self.keep_macs else {}, "descriptions": self.descriptions if not self.keep_descriptions else {}, } @@ -607,8 +623,14 @@ def main(): help="Output file (default: stdout).") p.add_argument("-m", "--map", dest="mapfile", default=None, help="Write the JSON mapping table (keep it local, never share it).") - p.add_argument("--anonymize-all-ips", action="store_true", - help="Also anonymize private (RFC 1918) addresses, not only public ones.") + ip_group = p.add_mutually_exclusive_group() + ip_group.add_argument("--anonymize-all-ips", action="store_true", + help="Also anonymize private (RFC 1918) addresses, " + "not only public ones.") + ip_group.add_argument("--keep-ips", action="store_true", + help="Keep ALL IP addresses (IPv4/IPv6) as-is, including " + "public ones. WARNING: the output then discloses " + "your addressing plan.") p.add_argument("--keep-descriptions", action="store_true", help="Keep interface descriptions as-is (e-mails/IPs/hostnames " "inside them are still processed).") @@ -636,7 +658,8 @@ def main(): anon = Anonymizer(anon_all_ips=args.anonymize_all_ips, keep_descriptions=args.keep_descriptions, - keep_macs=args.keep_macs) + keep_macs=args.keep_macs, + keep_ips=args.keep_ips) anon.collect(lines) result = anon.process(lines) @@ -675,6 +698,11 @@ def main(): print(f" MAC addresses : {len(anon.macs)}", file=sys.stderr) print(f" Descriptions : {len(anon.descriptions)}", file=sys.stderr) + if anon.kept_public_ips: + print(f"\n /!\\ --keep-ips: {len(anon.kept_public_ips)} public IP " + "address(es) left in clear — the output discloses your " + "addressing plan.", file=sys.stderr) + if flagged: print("\n /!\\ Lines to REVIEW (potential secret not neutralized):", file=sys.stderr) diff --git a/tests/test_sanitize.py b/tests/test_sanitize.py index e082ff7..97a4068 100644 --- a/tests/test_sanitize.py +++ b/tests/test_sanitize.py @@ -249,6 +249,44 @@ def test_anonymize_all_ips(self): self.assertNotIn("192.168.1.1", out) self.assertIn("255.255.255.0", out) # netmask preserved + def test_keep_ips_public_v4_kept(self): + line = "ip route 8.8.8.8 255.255.255.255 11.22.33.44" + out, anon = sanitize(line, keep_ips=True) + self.assertEqual(out, line) + self.assertEqual(anon.mapping()["ipv4"], {}) + self.assertEqual(anon.kept_public_ips, {"8.8.8.8", "11.22.33.44"}) + + def test_keep_ips_public_v6_kept(self): + line = "ipv6 route 2400:cb00::/32 Null0" + out, anon = sanitize(line, keep_ips=True) + self.assertEqual(out, line) + self.assertEqual(anon.mapping()["ipv6"], {}) + self.assertEqual(anon.kept_public_ips, {"2400:cb00::"}) + + def test_keep_ips_counts_distinct_addresses(self): + cfg = "logging host 8.8.8.8\nntp server 8.8.8.8\nntp server 9.9.9.9\n" + _, anon = sanitize(cfg, keep_ips=True) + self.assertEqual(len(anon.kept_public_ips), 2) + + def test_keep_ips_secrets_still_destroyed(self): + out, _ = sanitize("neighbor 203.0.113.5 password 7 0822455D0A16", + keep_ips=True) + self.assertIn("neighbor 203.0.113.5", out) + self.assertNotIn("0822455D0A16", out) + + def test_keep_ips_other_identifiers_still_processed(self): + cfg = ("hostname SW-CORE\n" + "logging host 8.8.8.8\n" + "arp 10.0.0.5 aa:bb:cc:dd:ee:ff arpa\n") + out, _ = sanitize(cfg, keep_ips=True) + self.assertNotIn("SW-CORE", out) + self.assertNotIn("aa:bb:cc:dd:ee:ff", out) + self.assertIn("8.8.8.8", out) + + def test_keep_ips_conflicts_with_anonymize_all_ips(self): + with self.assertRaises(ValueError): + sn.Anonymizer(anon_all_ips=True, keep_ips=True) + def test_ipv4_pool_no_duplicate_after_doc_ranges(self): # >762 distinct public IPs: the old code fell back to a single # duplicated address, breaking the mapping table consistency. @@ -393,6 +431,17 @@ def test_strict_exit_code_on_residual(self): "some unknown passphrase thing\n") self.assertEqual(r.returncode, 2) + def test_keep_ips_flag_and_warning(self): + r = self.run_cli(["-", "--keep-ips"], "logging host 8.8.8.8\n") + self.assertEqual(r.returncode, 0) + self.assertIn("8.8.8.8", r.stdout) + self.assertIn("--keep-ips: 1 public IP", r.stderr) + + def test_keep_ips_and_anonymize_all_ips_rejected(self): + r = self.run_cli(["-", "--keep-ips", "--anonymize-all-ips"], "") + self.assertEqual(r.returncode, 2) + self.assertIn("not allowed with", r.stderr) + def test_missing_input_file_friendly_error(self): r = self.run_cli(["/nonexistent/file.txt"], "") self.assertEqual(r.returncode, 1)