Skip to content
Open
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
4 changes: 4 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ jobs:
run: |
python -m pip install --upgrade pip wheel setuptools
pip install -e .[test]
pip install mypy
- name: Type check
run: |
mypy netconan/
- name: Test
run: |
pytest
8 changes: 7 additions & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ Features
Netconan can anonymize *many types of sensitive information*:

* Sensitive strings like passwords or SNMP community strings (``--anonymize-passwords``, ``-p``), for many common network vendors.
* SSH public keys in authentication and known-hosts lines (``--anonymize-ssh-keys``). Supports RSA, DSA, ECDSA, and Ed25519 key types. Key blobs are replaced with deterministic, length-preserving replacements that maintain the SSH wire format key type header.
* IPv4 and IPv6 addresses (``--anonymize-ips``, ``-a``).
* User-specified sensitive words (``--sensitive-words``, ``-w``). *Note that any occurrence of a specified sensitive word will be replaced regardless of context, even if it is part of a larger string.*
* User-specified AS numbers (``--as-numbers``, ``-n``). *Note that any number matching a specified AS number will be anonymized.*
Expand Down Expand Up @@ -110,7 +111,8 @@ For more information about less commonly-used features, see the Netconan help (`

.. code-block:: bash

usage: netconan [-h] [--version] [-a] [-c CONFIG] [-d DUMP_IP_MAP] -i INPUT
usage: netconan [-h] [--version] [-a] [--anonymize-ssh-keys] [-c CONFIG]
[-d DUMP_IP_MAP] -i INPUT
[-l {DEBUG,INFO,WARNING,ERROR,CRITICAL}] [-n AS_NUMBERS] -o
OUTPUT [-p] [-r RESERVED_WORDS] [-s SALT] [-u]
[-w SENSITIVE_WORDS] [--preserve-prefixes PRESERVE_PREFIXES]
Expand All @@ -128,6 +130,10 @@ For more information about less commonly-used features, see the Netconan help (`
-h, --help show this help message and exit
--version Print version number and exit
-a, --anonymize-ips Anonymize IP addresses
--anonymize-ssh-keys Anonymize SSH public key blobs in authentication
and known-hosts lines. Supports RSA, DSA, ECDSA,
and Ed25519 key types. Replacement is deterministic
from --salt.
-c CONFIG, --config CONFIG
Netconan configuration file with defaults for these
CLI parameters
Expand Down
15 changes: 15 additions & 0 deletions netconan/anonymize_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import logging
import os
import random
import re
import string
import sys
from collections.abc import Sequence
Expand All @@ -33,6 +34,7 @@
generate_default_sensitive_item_regexes,
replace_matching_item,
)
from .ssh_key_anonymization import generate_ssh_key_regexes, replace_ssh_keys

_DEFAULT_SALT_LENGTH = 16
_CHAR_CHOICES = string.ascii_letters + string.digits
Expand All @@ -54,6 +56,7 @@ def __init__(
preserve_networks: Sequence[str] | None = None,
preserve_suffix_v4: int | None = None,
preserve_suffix_v6: int | None = None,
anon_ssh_keys: bool = False,
) -> None:
"""Creates anonymizer classes."""
self.undo_ip_anon = undo_ip_anon
Expand All @@ -64,6 +67,8 @@ def __init__(
self.anonymizer_sensitive_word: SensitiveWordAnonymizer | None = None
self.compiled_regexes: list[list[CompiledRegexRule]] | None = None
self.pwd_lookup: dict[str, str] | None = None
self.ssh_key_regexes: list[tuple[re.Pattern[str], str]] | None = None
self.ssh_key_lookup: dict[str, str] | None = None

# The salt is only used for IP and sensitive word anonymization
if salt is None:
Expand Down Expand Up @@ -95,6 +100,9 @@ def __init__(
)
if as_numbers is not None:
self.anonymizer_as_num = AsNumberAnonymizer(as_numbers, self.salt)
if anon_ssh_keys:
self.ssh_key_regexes = generate_ssh_key_regexes()
self.ssh_key_lookup = {}

def anonymize_io(self, in_io: IO[str], out_io: IO[str]) -> None:
"""Reads from the in_io buffer, writing anonymized configuration into the out_io buffer.
Expand Down Expand Up @@ -125,6 +133,11 @@ def anonymize_io(self, in_io: IO[str], out_io: IO[str]) -> None:
if self.anonymizer_as_num is not None:
output_line = anonymize_as_numbers(self.anonymizer_as_num, output_line)

if self.ssh_key_regexes is not None and self.ssh_key_lookup is not None:
output_line = replace_ssh_keys(
self.ssh_key_regexes, output_line, self.ssh_key_lookup, self.salt
)

if line != output_line:
logging.debug("Input line: %s", line.rstrip())
logging.debug("Output line: %s", output_line.rstrip())
Expand All @@ -146,6 +159,7 @@ def anonymize_files(
preserve_networks: Sequence[str] | None = None,
preserve_suffix_v4: int | None = None,
preserve_suffix_v6: int | None = None,
anon_ssh_keys: bool = False,
) -> None:
"""Anonymize each file in input and save to output."""
use_stdin = input_path == "-"
Expand Down Expand Up @@ -196,6 +210,7 @@ def anonymize_files(
salt=salt,
sensitive_words=sensitive_words,
undo_ip_anon=undo_ip_anon,
anon_ssh_keys=anon_ssh_keys,
)

for in_path, out_path in file_list:
Expand Down
2 changes: 1 addition & 1 deletion netconan/default_pwd_regexes.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@
[(r"(\S* )*md5 \d+ key [^ ;]+(.*)", None)],
[(r"(\S* )*(secret|simple-password) [^ ;]+(.*)", None)],
[(r"(\S* )*encrypted-password [^ ;]+(.*)", None)],
[(r"(\S* )*ssh-(rsa|dsa) \"(.*)", None)],
# [(r"(\S* )*ssh-(rsa|dsa) \"(.*)", None)], # Handled by ssh_key_anonymization module
[(r"(\S* )*((pre-shared-|)key (ascii-text|hexadecimal)) [^ ;]+(.*)", None)],
]
# Taken from RANCID community scrubbing regexes
Expand Down
8 changes: 8 additions & 0 deletions netconan/netconan.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,12 @@ def _parse_args(argv: list[str]) -> argparse.Namespace:
default=False,
help="Anonymize password and snmp community lines",
)
parser.add_argument(
"--anonymize-ssh-keys",
action="store_true",
default=False,
help="Anonymize SSH public key blobs in authentication and known-hosts lines. Supports RSA, DSA, ECDSA, and Ed25519 key types. Replacement is deterministic from --salt.",
)
parser.add_argument(
"-r",
"--reserved-words",
Expand Down Expand Up @@ -215,6 +221,7 @@ def main(argv: list[str] = sys.argv[1:]) -> None:

if not any(
[
args.anonymize_ssh_keys,
as_numbers,
sensitive_words,
args.anonymize_passwords,
Expand All @@ -241,6 +248,7 @@ def main(argv: list[str] = sys.argv[1:]) -> None:
preserve_addresses,
preserve_suffix_v4=args.preserve_host_bits,
preserve_suffix_v6=args.preserve_host_bits,
anon_ssh_keys=args.anonymize_ssh_keys,
)


Expand Down
179 changes: 179 additions & 0 deletions netconan/ssh_key_anonymization.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
"""Anonymize SSH public key blobs in router configurations."""

# Copyright 2018 Intentionet
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import base64
import hashlib
import hmac
import logging
import re
import struct

# Minimum base64 length for a valid SSH public key blob.
# An Ed25519 key (the smallest type) is 51 raw bytes → 68 base64 chars.
_MIN_KEY_BASE64_LEN = 68

# Authentication key types: ssh-rsa, ssh-dsa, ssh-ecdsa, ssh-ed25519,
# and bare ecdsa-sha2-nistp* (used by NX-OS/Arista without ssh- prefix).
# The comment group captures any trailing SSH key comment (e.g., "user@host")
# between the base64 blob and the closing quote, so it can be stripped.
_AUTH_KEY_REGEX = re.compile(
r"(?P<prefix>(?:\S+ )*(?:ssh-(?:rsa|dsa|ecdsa|ed25519)|ecdsa-sha2-nistp(?:256|384|521)) )"
r'"?(?P<key>[A-Za-z0-9+/=]{' + str(_MIN_KEY_BASE64_LEN) + r',})(?P<comment>[^"]*)"?'
)

# Cisco IOS key-hash: key-hash ssh-rsa <32-hex-MD5> [comment]
_CISCO_KEY_HASH_REGEX = re.compile(
r"(?P<prefix>key-hash\s+ssh-(?:rsa|dsa)\s+)"
r"(?P<keyhash>[0-9A-Fa-f]{32})"
r"(?P<comment>.*)"
)

# Known hosts key types: rsa-key, rsa1-key, dsa-key, ed25519-key, ecdsa-sha2-nistp*-key
_KNOWN_HOSTS_KEY_REGEX = re.compile(
r"(?P<prefix>(?:\S+ )*(?:rsa1?|dsa|ed25519|ecdsa-sha2-nistp(?:256|384|521))-key )"
r'"?(?P<key>[A-Za-z0-9+/=]{' + str(_MIN_KEY_BASE64_LEN) + r',})(?P<comment>[^"]*)"?'
)


def _read_ssh_wire_string(data: bytes, offset: int) -> tuple[bytes | None, int]:
"""Read a length-prefixed string from SSH wire format.

Returns (string_bytes, new_offset) or (None, offset) if data is too short.
"""
if offset + 4 > len(data):
return None, offset
length = struct.unpack(">I", data[offset : offset + 4])[0]
if offset + 4 + length > len(data):
return None, offset
return data[offset : offset + 4 + length], offset + 4 + length


def anonymize_ssh_key_blob(base64_blob: str, salt: str) -> str:
"""Generate a deterministic anonymized SSH key blob preserving format.

The replacement:
- Preserves the SSH wire format key type header (first field)
- Preserves exact base64 length
- Is deterministic from salt + original blob (HMAC-based)
"""
try:
raw = base64.b64decode(base64_blob)
except Exception:
logging.debug("Failed to base64-decode SSH key blob, returning original")
return base64_blob

# Extract the key type header (first SSH wire format field)
header, data_offset = _read_ssh_wire_string(raw, 0)
if header is None:
logging.debug("Failed to parse SSH wire format header, returning original")
return base64_blob

data_portion = raw[data_offset:]
if not data_portion:
return base64_blob

# Generate replacement bytes using HMAC-SHA256, expanding as needed
hmac_key = salt.encode()
replacement_bytes = b""
counter = 0
while len(replacement_bytes) < len(data_portion):
h = hmac.new(
hmac_key,
base64_blob.encode() + struct.pack(">I", counter),
hashlib.sha256,
)
replacement_bytes += h.digest()
counter += 1
replacement_bytes = replacement_bytes[: len(data_portion)]

# Reassemble: original header + replacement data
new_raw = header + replacement_bytes
new_blob = base64.b64encode(new_raw).decode()

# Ensure exact same base64 length by padding with '=' if needed
# (base64 encoding of same-length bytes should produce same-length output,
# but be defensive)
if len(new_blob) != len(base64_blob):
logging.debug(
"Base64 length mismatch: original=%d, new=%d",
len(base64_blob),
len(new_blob),
)

return new_blob


def anonymize_ssh_key_hash(hex_hash: str, salt: str) -> str:
"""Generate a deterministic anonymized SSH key hash (MD5 fingerprint).

Produces a same-length uppercase hex string from HMAC-SHA256.
"""
hmac_key = salt.encode()
h = hmac.new(hmac_key, hex_hash.encode(), hashlib.sha256)
return h.hexdigest()[: len(hex_hash)].upper()


def generate_ssh_key_regexes() -> list[tuple[re.Pattern[str], str]]:
"""Return compiled SSH key regexes as a list of (regex, group_name) tuples."""
return [
(_AUTH_KEY_REGEX, "key"),
(_KNOWN_HOSTS_KEY_REGEX, "key"),
(_CISCO_KEY_HASH_REGEX, "keyhash"),
]


def replace_ssh_keys(
compiled_regexes: list[tuple[re.Pattern[str], str]],
line: str,
lookup: dict[str, str],
salt: str,
) -> str:
"""Replace SSH public key blobs in the given line.

Args:
compiled_regexes: List of (compiled_regex, group_name) tuples from
generate_ssh_key_regexes().
line: Input configuration line.
lookup: Dict mapping original key blobs to anonymized blobs for
consistency across lines/files.
salt: Salt string for deterministic HMAC-based replacement.

Returns:
The line with SSH key blobs anonymized.
"""
for regex, group_name in compiled_regexes:
match = regex.search(line)
if match is None:
continue

original_key = match.group(group_name)

if original_key in lookup:
anon_key = lookup[original_key]
else:
if group_name == "keyhash":
anon_key = anonymize_ssh_key_hash(original_key, salt)
else:
anon_key = anonymize_ssh_key_blob(original_key, salt)
lookup[original_key] = anon_key

# Replace the key blob and strip any SSH key comment after it
line = line[: match.start(group_name)] + anon_key + line[match.end("comment") :]

logging.debug("Anonymized SSH key blob in line")
break # One SSH key per line

return line
Loading
Loading