Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand All @@ -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 |
Expand Down Expand Up @@ -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)
Expand Down
42 changes: 35 additions & 7 deletions sanitize_netconfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
"""

Expand All @@ -95,7 +98,7 @@
import sys
from itertools import count

__version__ = "0.3.0"
__version__ = "0.4.0"

# ---------------------------------------------------------------------------
# Markers
Expand Down Expand Up @@ -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 = {}
Expand Down Expand Up @@ -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)

Expand All @@ -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)

Expand Down Expand Up @@ -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 {},
}
Expand Down Expand Up @@ -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).")
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down
49 changes: 49 additions & 0 deletions tests/test_sanitize.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
Loading