diff --git a/.gitignore b/.gitignore index 86944f7..46d4d3b 100644 --- a/.gitignore +++ b/.gitignore @@ -113,3 +113,9 @@ ENV/ # development artifacts working/ + +# downloaded test configs (see tests/TEST_DATA_SOURCES.md) +tests/test_data/ + +# local test configs (your own configs, see tests/README.md) +tests/test_data_local/ diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..a603e50 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,111 @@ +# Tests + +## Test Structure + +``` +tests/ + unit/ # Fast unit tests (always run) + end_to_end/ # End-to-end tests with inline data + integration/ # Real-config integration tests (need downloaded data) + test_data/ # Downloaded configs (git-ignored) + test_data_local/ # Your own configs (git-ignored) + TEST_DATA_SOURCES.md # Detailed list of public config sources +``` + +## Running Tests + +```bash +# Unit + end-to-end tests only (no downloads needed) +python -m pytest --override-ini="addopts=" -x -q + +# Integration tests only +python -m pytest --override-ini="addopts=" tests/integration/ -v + +# Everything together +python -m pytest --override-ini="addopts=" -v +``` + +Integration tests skip automatically if no test data is present. + +## Downloading Test Data + +The download script fetches real-world configs from public GitHub repos: + +```bash +# Download all vendors (~600 files) +python tools/download_test_configs.py + +# Download specific vendors only +python tools/download_test_configs.py --vendors cisco juniper_flat + +# Force re-download (overwrite existing files) +python tools/download_test_configs.py --force +``` + +Files go into `tests/test_data/{vendor}/{source}/`. This directory is +git-ignored. + +## Using Your Own Local Configs + +Place your own config files in `tests/test_data_local/` using the same +`{vendor}/{source}/` structure: + +```bash +mkdir -p tests/test_data_local/cisco/my_lab +cp ~/my-router.cfg tests/test_data_local/cisco/my_lab/ + +mkdir -p tests/test_data_local/juniper_flat/office +cp ~/srx-config.txt tests/test_data_local/juniper_flat/office/ +``` + +The integration tests scan both `test_data/` and `test_data_local/` +automatically. The vendor and source names are free-form — use whatever +makes sense for your files. Both directories are git-ignored so your +configs are never committed. + +## Adding a Download Source + +Edit the `SOURCES` dict in `tools/download_test_configs.py`. Each vendor +maps to a list of source entries. Three types are supported: + +```python +# git sparse checkout (for large repos where you only need one directory) +{ + "name": "my_source", + "type": "git_sparse", + "repo": "https://github.com/org/repo.git", + "paths": ["path/to/configs"], # sparse-checkout paths + "src_dir": "path/to/configs", # directory to copy files from + "glob": "*.cfg", # optional file filter +}, + +# git clone (for small repos) +{ + "name": "my_source", + "type": "git_clone", + "repo": "https://github.com/org/repo.git", + "glob": "*.conf", # optional file filter +}, + +# single file URL +{ + "name": "my_source", + "type": "url", + "url": "https://example.com/config.txt", + "filename": "config.txt", # name for the saved file +}, +``` + +After adding, run `python tools/download_test_configs.py --force` to fetch. + +## Removing a Download Source + +Delete the source entry from the `SOURCES` dict in +`tools/download_test_configs.py`, then delete its local directory: + +```bash +rm -rf tests/test_data/{vendor}/{source} +``` + +The metadata file (`tests/test_data/.download_metadata.json`) will be +updated on the next download run. diff --git a/tests/TEST_DATA_SOURCES.md b/tests/TEST_DATA_SOURCES.md new file mode 100644 index 0000000..b7b5997 --- /dev/null +++ b/tests/TEST_DATA_SOURCES.md @@ -0,0 +1,379 @@ +# Test Data Sources + +Where to find real-world network configuration files for testing netconan. + +Netconan's regex-based anonymization works across many vendor formats. Testing against +real configs (beyond the inline test strings in `tests/unit/`) helps validate coverage +and catch edge cases. + +## Directory Setup + +Downloaded configs should go in `tests/test_data/`, which is git-ignored to avoid +accidentally committing sensitive material. + +```bash +mkdir -p tests/test_data/{cisco,arista,juniper,fortinet,aws,snmp} +``` + +## Multi-Vendor Sources + +These repositories contain configs for multiple vendors in one place. + +- [Batfish](https://github.com/batfish/batfish) + — The single best source. Test configs for Cisco, Arista, Juniper (flat and + hierarchical), Fortinet, Palo Alto, and more under + `projects/batfish/src/test/resources/org/batfish/grammar/`. Hundreds of files. +- [CiscoConfParse test fixtures](https://github.com/mpenning/ciscoconfparse/tree/master/tests/fixtures/configs) + — Parser test fixtures covering Cisco IOS/NXOS/ASA/IOS-XR, Juniper JunOS, F5, + Arista EOS, Palo Alto, Brocade, HP, and more. +- [NTC Templates](https://github.com/networktocode/ntc-templates) + — TextFSM templates with sample `show` command outputs for many vendors. Look + under `tests/` for fixture data. +- [Azure VPN Config Samples](https://github.com/Azure/Azure-vpn-config-samples) + — Microsoft-maintained. Full running configs for Cisco ASA/ISR/ASR, Juniper SRX, + FortiGate, and others. Contains encrypted passwords and pre-shared keys. +- [Oxidized](https://github.com/ytti/oxidized) + — RANCID replacement supporting 90+ vendor OS types. The tool itself is useful for + collecting configs from lab devices. +- [Containerlab topologies (holo-routing)](https://github.com/holo-routing/containerlab-topologies) + — 25+ protocol lab topologies with configs for FRRouting, Nokia SR Linux, Arista + cEOS. Covers BGP, OSPF, IS-IS, MPLS-LDP, VRRP, and more. + +## Vendor Sources + +### Cisco IOS + +Cisco IOS has the broadest regex coverage in netconan (75+ password patterns). + +**GitHub repos:** + +- [Batfish Cisco test configs](https://github.com/batfish/batfish/tree/master/projects/batfish/src/test/resources/org/batfish/grammar/cisco/testconfigs) + — Hundreds of minimal IOS/IOS-XE configs covering edge cases. +- [CiscoConfParse test fixtures](https://github.com/mpenning/ciscoconfparse/tree/master/tests/fixtures/configs) + — Multiple `sample_NN.ios` files with diverse IOS syntax (interfaces, routing, + ACLs, HSRP, etc.). Also includes ASA, NXOS, IOS-XR samples. +- [tireland1985/cisco-config-examples](https://github.com/tireland1985/cisco-config-examples) + — 5 sanitized lab configs: AP1141N, C2811-CUCME, C2911 router, C3560G L3 switch. + Contains enable secrets, TACACS+, SNMP community strings. +- [NTC Templates](https://github.com/networktocode/ntc-templates) + — TextFSM templates with sample `show` command outputs under `tests/`. +- [frostbits-security/ccat](https://github.com/frostbits-security/ccat) + — Cisco Config Analysis Tool with test configs in `example/` directory. +- [bbartik/cisco-cfg](https://github.com/bbartik/cisco-cfg) + — 6 router config files including Jinja2 templates. + +**Download example (Batfish):** + +```bash +git clone --depth 1 --filter=blob:none --sparse \ + https://github.com/batfish/batfish.git /tmp/batfish +cd /tmp/batfish +git sparse-checkout set projects/batfish/src/test/resources/org/batfish/grammar/cisco/testconfigs +cp projects/batfish/src/test/resources/org/batfish/grammar/cisco/testconfigs/* \ + /path/to/netconan/tests/test_data/cisco/ +``` + +**Vendor docs:** + +- [Cisco IOS-XE Configuration Guides](https://www.cisco.com/c/en/us/support/ios-nx-os-software/ios-xe-17/products-installation-and-configuration-guides-list.html) + — Official config examples for every IOS-XE feature. + +### Arista EOS + +Arista EOS uses Cisco-like syntax. Netconan covers SHA-512 passwords and VRRP +authentication for EOS (see `default_pwd_regexes.py`, Issue #3 tracks expanding this). + +**GitHub repos:** + +- [Batfish Arista test configs](https://github.com/batfish/batfish/tree/master/projects/batfish/src/test/resources/org/batfish/grammar/arista/testconfigs) + — Arista-specific test configs in the Batfish grammar suite. +- [HPENetworking/HPEIMCUtils — Arista sample config](https://github.com/HPENetworking/HPEIMCUtils/blob/master/DeviceAdapters/Arista%20Networks/arista%20sample%20config.txt) + — Complete Arista config with enable secret, username admin secret, SNMP + community strings ("public"/"private"), MLAG, VLANs, NTP, AAA. +- [networkop/arista-network-ci](https://github.com/networkop/arista-network-ci) + — Generated configs for lab and production topologies. BGP, interfaces, VLANs, + SVIs, route-maps. Configs intentionally contain bugs for testing Batfish. +- [arista-netdevops-community/ceos_lab_demo](https://github.com/arista-netdevops-community/ceos_lab_demo) + — 3 cEOS startup configs for an EBGP triangle topology. +- [arista-netdevops-community/avd-evpn-webinar-june-11](https://github.com/arista-netdevops-community/avd-evpn-webinar-june-11) + — EVPN/VXLAN fabric configs in `intended/configs/` directory. +- [Ansible Arista EOS Collection](https://github.com/ansible-collections/arista.eos) + — Ansible modules with EOS config snippets in the docs and test fixtures. +- [JulioPDX/multi-vendor-python](https://github.com/JulioPDX/multi-vendor-python) + — Running/startup configs from Cisco vIOS, Arista vEOS, and Aruba CX. + +**Download example (Batfish):** + +```bash +git clone --depth 1 --filter=blob:none --sparse \ + https://github.com/batfish/batfish.git /tmp/batfish +cd /tmp/batfish +git sparse-checkout set projects/batfish/src/test/resources/org/batfish/grammar/arista/testconfigs +cp projects/batfish/src/test/resources/org/batfish/grammar/arista/testconfigs/* \ + /path/to/netconan/tests/test_data/arista/ +``` + +### Juniper JunOS (Set-Style) + +Set-style (`set system host-name ...`) is one of two JunOS config formats. Netconan +has strong Juniper support including Type 9 encryption handling. + +**GitHub repos:** + +- [Batfish Flat Juniper test configs](https://github.com/batfish/batfish/tree/master/projects/batfish/src/test/resources/org/batfish/grammar/flatjuniper/testconfigs) + — Set-style ("flat") Juniper configs used in Batfish parsing tests. +- [jcoeder/juniper-configurations](https://github.com/jcoeder/juniper-configurations) + — 28 production-style set-style config snippets: BGP (communities, policies, bogon + filtering), OSPF, firewall rules (QFX5100 RE protection), HA (chassis redundancy, + MC-LAG), SRX dynamic VPN, SNMPv3, TACACS, EVPN/VXLAN, IPFIX. +- [flightlesstux/juniper-srx-config](https://github.com/flightlesstux/juniper-srx-config) + — SRX110H-VA set-style config for VDSL2 internet connectivity. System setup, + interfaces, security zones, NAT. + +**Download example (jcoeder):** + +```bash +git clone https://github.com/jcoeder/juniper-configurations.git /tmp/juniper-configs +cp /tmp/juniper-configs/*.conf tests/test_data/juniper/ +``` + +**Vendor docs:** + +- [Juniper TechLibrary — CLI Configuration](https://www.juniper.net/documentation/us/en/software/junos/cli/topics/topic-map/cli-configuration.html) + — Official reference covering both set and hierarchical formats. + +### Juniper JunOS (Hierarchical) + +Hierarchical format uses curly braces (`system { host-name router1; }`). Both formats +should be tested since netconan's regexes may behave differently with indented blocks. + +**GitHub repos:** + +- [Batfish Juniper test configs](https://github.com/batfish/batfish/tree/master/projects/batfish/src/test/resources/org/batfish/grammar/juniper/testconfigs) + — Hierarchical (brace-style) Juniper configs. +- [Azure VPN Config Samples — Juniper SRX](https://github.com/Azure/Azure-vpn-config-samples/tree/master/Juniper/Current/SRX) + — Full hierarchical SRX configs for site-to-site VPNs with security zones, + policies, IPsec, IKE. Contains `encrypted-password "$1$..."` entries. +- [jtkristoff/junos](https://github.com/jtkristoff/junos) + — 10+ hierarchical config templates: BFD, BGP Monitoring Protocol, firewall + filters, iBGP, route origin validation, BGP route sanitization. +- [codeout/junoser](https://github.com/codeout/junoser) + — PEG parser for JunOS configs with test fixtures in both set and hierarchical + format. + +**Download example (Batfish):** + +```bash +git clone --depth 1 --filter=blob:none --sparse \ + https://github.com/batfish/batfish.git /tmp/batfish +cd /tmp/batfish +git sparse-checkout set projects/batfish/src/test/resources/org/batfish/grammar/juniper/testconfigs +cp projects/batfish/src/test/resources/org/batfish/grammar/juniper/testconfigs/* \ + /path/to/netconan/tests/test_data/juniper/ +``` + +### Fortinet FortiOS + +FortiOS uses `config`/`set`/`end` block syntax. Netconan has basic support for ENC +passwords and pksecret fields (4 regex patterns). + +**GitHub repos:** + +- [Azure VPN Config Samples — FortiGate](https://github.com/Azure/Azure-vpn-config-samples/tree/master/Fortinet/Current) + — Full `show full-configuration` output from a FortiGate device. Contains + `set password ENC SH2...` and `set psksecret ENC ...` entries. +- [Batfish Fortinet test configs](https://github.com/batfish/batfish/tree/master/projects/batfish/src/test/resources/org/batfish/grammar/fortios/testconfigs) + — FortiOS test configs in the Batfish grammar suite. +- [vansteenk/FortiLab-VPN-IPSEC](https://github.com/vansteenk/FortiLab-VPN-IPSEC) + — Lab environment with complete FortiGate `.conf` backup files. Contains VPN + IPsec phase1 configs with PSK, admin passwords (ENC format), user credentials, + certificate private keys. +- [fortinet/fortigate-terraform-deploy](https://github.com/fortinet/fortigate-terraform-deploy) + — Terraform deployment templates with FortiGate configs. See + `aws/6.2/ha/config-active.conf` for HA example. +- [cgustave/fgtconfig](https://github.com/cgustave/fgtconfig) + — FortiGate configuration analysis tool, may contain test fixture configs. + +**Download example (Azure VPN samples):** + +```bash +curl -o tests/test_data/fortinet/fortigate_full.txt \ + "https://raw.githubusercontent.com/Azure/Azure-vpn-config-samples/master/Fortinet/Current/fortigate_show%20full-configuration.txt" +``` + +**Download example (FortiLab):** + +```bash +git clone https://github.com/vansteenk/FortiLab-VPN-IPSEC.git /tmp/fortilab +cp /tmp/fortilab/*.conf tests/test_data/fortinet/ +``` + +**Vendor docs:** + +- [Fortinet Documentation Library](https://docs.fortinet.com/document/fortigate/7.6.0/administration-guide) + — Official FortiOS administration guide with config examples. + +### AWS VPN Configs + +AWS VPN configs use XML (``) and JSON (`PreSharedKey`) formats. +Netconan has 4 regex patterns for these. + +**GitHub repos:** + +- [AWS VPN Gateway strongSwan](https://github.com/aws-samples/vpn-gateway-strongswan) + — CloudFormation templates with VPN connection configs. +- [Terraform AWS VPN Gateway](https://github.com/terraform-aws-modules/terraform-aws-vpn-gateway) + — Terraform module with VPN config examples in `examples/`. +- [PackeTsar/AWS_IPv6_VPN](https://github.com/PackeTsar/AWS_IPv6_VPN) + — Guide for building IPv6 site-to-site VPN to AWS with IKEv1 pre-shared-key + config examples. +- [cloudposse/terraform-aws-vpn-connection](https://github.com/cloudposse/terraform-aws-vpn-connection) + — Terraform module supporting `tunnel1_preshared_key` / `tunnel2_preshared_key`. +- [asantos2000/aws_vpn_config](https://github.com/asantos2000/aws_vpn_config) + — Tool to download VPN configs from AWS and convert to vendor-specific formats. + Uses `describe_vpn_connections` API to get the XML. +- [Azure VPN Config Samples — Cisco ASA](https://github.com/Azure/Azure-vpn-config-samples/tree/master/Cisco/Current/ASA) + — ASA running-config with `ikev1 pre-shared-key` entries; useful for testing + Cisco VPN pre-shared key regexes too. + +**Vendor docs:** + +- [AWS Site-to-Site VPN User Guide](https://docs.aws.amazon.com/vpn/latest/s2svpn/VPNTunnels.html) + — Official documentation with XML/JSON config download examples. + +**Creating test data manually:** + +AWS VPN configs follow a predictable XML structure. A minimal test file: + +```xml + + + + + ExamplePreSharedKey123 + + + +``` + +### SNMP Configs + +SNMP community strings and SNMPv3 user/group definitions appear across all vendors. +Netconan covers `snmp-server community` and `snmp-server user` patterns (14+ regexes). + +**GitHub repos:** + +- [LibreNMS — SNMP Configuration Examples](https://github.com/librenms/librenms/blob/master/doc/Support/SNMP-Configuration-Examples.md) + — Comprehensive multi-vendor SNMP guide covering Cisco (ASA, IOS), Juniper, + Extreme, Linux, Windows. Uses placeholders like ``. +- [JunOS SNMPv3 example (Gist)](https://gist.github.com/rendoaw/541c41527d9c576305dd) + — Complete Juniper JunOS SNMPv3 config with `## SECRET-DATA` markers, MD5/SHA + auth, DES/AES128 privacy, community strings. +- [jcoeder/juniper-configurations](https://github.com/jcoeder/juniper-configurations) + — Includes SNMPv3 set-style config snippets among its 28 files. +- [net-snmp/net-snmp — EXAMPLE.conf](https://github.com/net-snmp/net-snmp/blob/master/EXAMPLE.conf.def) + — Example `snmpd.conf` with community string configuration. +- [colin-mccarthy/ansible-playbooks-for-cisco-ios](https://github.com/colin-mccarthy/ansible-playbooks-for-cisco-ios) + — SNMP configuration playbooks with `snmp-server` command examples. + +**Vendor docs:** + +- [Cisco SNMPv3 Configuration Guide](https://www.cisco.com/c/en/us/td/docs/ios-xml/ios/snmp/configuration/xe-3se/3850/snmp-xe-3se-3850-book/nm-snmp-snmpv3.html) + — Complete SNMPv3 user/group/view config examples with all security levels. + +**Creating test data manually:** + +SNMP config lines follow standard patterns across vendors: + +``` +snmp-server community public RO +snmp-server community private RW +snmp-server group MYGROUP v3 priv read MYVIEW write MYVIEW +snmp-server user MYUSER MYGROUP v3 auth sha AuthPass123 priv aes 128 PrivPass456 +``` + +## Automated Testing + +An automated download script and integration test suite are provided to streamline +testing against real-world configs. + +### Downloading configs + +```bash +# Download all vendors +python tools/download_test_configs.py + +# Download specific vendors only +python tools/download_test_configs.py --vendors cisco arista + +# Force re-download (overwrite existing) +python tools/download_test_configs.py --force +``` + +Configs are downloaded into `tests/test_data/{vendor}/{source}/`. The directory is +git-ignored so downloaded configs are never committed. + +**Current sources (7 across 5 vendors):** + +| Vendor | Source | Method | Files | +|--------|--------|--------|-------| +| cisco | batfish | git sparse | Cisco IOS/IOS-XE test configs | +| cisco | ciscoconfparse | git sparse | `*.ios` parser test fixtures | +| arista | batfish | git sparse | Arista EOS test configs | +| juniper_flat | jcoeder | git clone | 28 production-style set-style snippets | +| juniper_hierarchical | batfish | git sparse | Hierarchical (brace-style) Juniper configs | +| fortinet | batfish | git sparse | FortiOS test configs | +| fortinet | azure | curl | FortiGate `show full-configuration` | + +### Running integration tests + +```bash +# Run integration tests (skips if test_data/ not present) +python -m pytest tests/integration/test_real_configs.py -v + +# Run only crash tests (fastest) +python -m pytest tests/integration/test_real_configs.py -v -k test_no_crash + +# Run only password anonymization checks +python -m pytest tests/integration/test_real_configs.py -v -k test_passwords_anonymized +``` + +The integration tests verify three things per config file: +1. **No crash** — netconan processes the file without exceptions +2. **Passwords anonymized** — if the input contains password patterns, the output differs +3. **IPs anonymized** — if the input contains IP addresses, some change in the output + +## Using Test Data for Development + +### Running netconan against downloaded configs + +```bash +# Anonymize a single file +netconan -i tests/test_data/cisco/example.cfg -o /tmp/anon_output.cfg -a -p + +# Anonymize a whole directory +netconan -i tests/test_data/cisco/ -o /tmp/anon_cisco/ -a -p + +# Check what gets anonymized (diff original vs output) +diff tests/test_data/cisco/example.cfg /tmp/anon_output.cfg +``` + +### Adding configs as test fixtures + +If a downloaded config exposes a bug or an unhandled pattern, add it as a test case: + +1. **Extract the relevant lines** — isolate just the config lines that need testing. +2. **Add to the appropriate test** — add inline test data to + `tests/unit/test_sensitive_item_removal.py` (for password/community patterns) or + create a new test file. +3. **Never commit raw downloaded configs** — they may contain real credentials or + proprietary content. Always sanitize first. + +### Tips for creating test cases from real configs + +- Look for lines that netconan **should** anonymize but doesn't (false negatives). +- Look for lines that netconan anonymizes **incorrectly** (false positives or mangled output). +- Juniper Type 9 encrypted passwords and Cisco Type 7 passwords are especially good + test targets since netconan has dedicated handling for them. +- Test both `show running-config` style output and startup-config file format — some + vendors include extra headers/timestamps that can affect regex matching. diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..f92c4c0 --- /dev/null +++ b/tests/integration/__init__.py @@ -0,0 +1 @@ +"""Integration tests using real-world network config files.""" diff --git a/tests/integration/test_real_configs.py b/tests/integration/test_real_configs.py new file mode 100644 index 0000000..14308b6 --- /dev/null +++ b/tests/integration/test_real_configs.py @@ -0,0 +1,247 @@ +"""Integration tests that run netconan against real-world config files. + +Discovers configs in two directories: + - tests/test_data/{vendor}/{source}/ — downloaded by the download script + - tests/test_data_local/{vendor}/{source}/ — your own local configs + +Runs netconan against each file and verifies: + - No crashes (no unhandled exceptions) + - Output file is produced and non-empty + - Password-like patterns are anonymized when present in input + - IP addresses are changed when present in input + +Requires test data to be downloaded or placed manually: + python tools/download_test_configs.py # downloaded data + tests/test_data_local/{vendor}/{source}/ # your own configs + +The entire suite is skipped if neither directory contains config files. +""" + +import os +import re +from pathlib import Path + +import pytest + +from netconan.netconan import main + +# Test data directories (both are scanned for configs) +TEST_DATA_DIR = Path(__file__).parent.parent / "test_data" +TEST_DATA_LOCAL_DIR = Path(__file__).parent.parent / "test_data_local" + +# Regex for detecting password-like patterns in input configs. +# Intentionally conservative — only match patterns that netconan's password +# regexes would actually anonymize, to avoid false positives from Juniper +# policy "community" statements or "authentication-order password" lines. +_PASSWORD_PATTERNS = re.compile( + r"(?i)(" + r"\bpassword\s+['\"\$\d]\S*" + r"|\bsecret\s+['\"\$\d]\S*" + r"|\bsnmp\S*\s+.*\bcommunity\s+\S" + r"|\bpre-shared-key\s+\S" + r"|\bset password ENC\s+\S" + r"|\bset psksecret\s+\S" + r"|\bmd5\s+\d" + r")" +) + +# Regex for extracting IPv4 addresses +_IP_PATTERN = re.compile(r"\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b") + +# Extensions to skip (binary or non-config files) +_SKIP_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".pdf", ".gz", ".tar", ".zip"} + +# Max file size to process (10 MB) +_MAX_FILE_SIZE = 10 * 1024 * 1024 + + +def _scan_data_dir(data_dir): + """Scan a test data directory and return (vendor, source, filepath) tuples.""" + if not data_dir.is_dir(): + return [] + configs = [] + for vendor_dir in sorted(data_dir.iterdir()): + if not vendor_dir.is_dir() or vendor_dir.name.startswith("."): + continue + vendor = vendor_dir.name + for source_dir in sorted(vendor_dir.iterdir()): + if not source_dir.is_dir() or source_dir.name.startswith("."): + continue + source = source_dir.name + for config_file in sorted(source_dir.iterdir()): + if not config_file.is_file(): + continue + if config_file.suffix.lower() in _SKIP_EXTENSIONS: + continue + if config_file.name.startswith("."): + continue + if config_file.stat().st_size > _MAX_FILE_SIZE: + continue + configs.append((vendor, source, config_file)) + return configs + + +def discover_test_configs(): + """Scan test_data/ and test_data_local/ for config files.""" + return _scan_data_dir(TEST_DATA_DIR) + _scan_data_dir(TEST_DATA_LOCAL_DIR) + + +def has_password_patterns(content): + """Check if content contains password-like patterns.""" + return bool(_PASSWORD_PATTERNS.search(content)) + + +def extract_ip_addresses(content): + """Extract all IPv4 addresses from content.""" + return set(_IP_PATTERN.findall(content)) + + +def _is_readable_text(filepath): + """Check if a file is readable as text.""" + try: + with open(filepath, "r", encoding="utf-8", errors="strict") as f: + f.read(1024) + return True + except (UnicodeDecodeError, PermissionError): + return False + + +# Collect test configs at import time for parametrize +_TEST_CONFIGS = discover_test_configs() + +# Skip the entire module if no test data exists +pytestmark = pytest.mark.skipif( + not _TEST_CONFIGS, + reason="No test configs found — run: python tools/download_test_configs.py", +) + + +def _config_id(param): + """Generate readable test ID from parametrize tuple.""" + vendor, source, filepath = param + return f"{vendor}/{source}/{filepath.name}" + + +@pytest.fixture +def output_dir(tmp_path): + """Provide a temporary output directory.""" + return tmp_path / "output" + + +@pytest.mark.parametrize( + "vendor,source,config_path", + _TEST_CONFIGS, + ids=[_config_id(c) for c in _TEST_CONFIGS], +) +def test_no_crash(vendor, source, config_path, output_dir): + """Netconan should process the file without raising exceptions.""" + if not _is_readable_text(config_path): + pytest.skip("Not a readable text file") + + output_file = output_dir / config_path.name + os.makedirs(output_dir, exist_ok=True) + + main( + [ + "-i", + str(config_path), + "-o", + str(output_file), + "-a", + "-p", + "-s", + "TESTSALT", + ] + ) + + assert output_file.exists(), f"Output file was not created: {output_file}" + assert output_file.stat().st_size > 0, f"Output file is empty: {output_file}" + + +@pytest.mark.parametrize( + "vendor,source,config_path", + _TEST_CONFIGS, + ids=[_config_id(c) for c in _TEST_CONFIGS], +) +def test_passwords_anonymized(vendor, source, config_path, output_dir): + """If input has password patterns, output should show anonymization markers.""" + if not _is_readable_text(config_path): + pytest.skip("Not a readable text file") + + try: + input_text = config_path.read_text(encoding="utf-8", errors="replace") + except Exception: + pytest.skip("Could not read input file") + + if not has_password_patterns(input_text): + pytest.skip("No password patterns detected in input") + + output_file = output_dir / config_path.name + os.makedirs(output_dir, exist_ok=True) + + main( + [ + "-i", + str(config_path), + "-o", + str(output_file), + "-a", + "-p", + "-s", + "TESTSALT", + ] + ) + + output_text = output_file.read_text(encoding="utf-8", errors="replace") + + # At minimum, the output should differ from input when passwords are present + assert output_text != input_text, ( + f"Output is identical to input despite password patterns in {config_path.name}" + ) + + +@pytest.mark.parametrize( + "vendor,source,config_path", + _TEST_CONFIGS, + ids=[_config_id(c) for c in _TEST_CONFIGS], +) +def test_ips_anonymized(vendor, source, config_path, output_dir): + """If input has IP addresses, some should be changed in output.""" + if not _is_readable_text(config_path): + pytest.skip("Not a readable text file") + + try: + input_text = config_path.read_text(encoding="utf-8", errors="replace") + except Exception: + pytest.skip("Could not read input file") + + input_ips = extract_ip_addresses(input_text) + # Filter out common non-routable/mask IPs that netconan preserves by default + trivial_ips = {"0.0.0.0", "255.255.255.255", "255.255.255.0", "127.0.0.1"} + meaningful_ips = input_ips - trivial_ips + if len(meaningful_ips) < 2: + pytest.skip("Not enough non-trivial IP addresses in input") + + output_file = output_dir / config_path.name + os.makedirs(output_dir, exist_ok=True) + + main( + [ + "-i", + str(config_path), + "-o", + str(output_file), + "-a", + "-p", + "-s", + "TESTSALT", + ] + ) + + output_text = output_file.read_text(encoding="utf-8", errors="replace") + output_ips = extract_ip_addresses(output_text) + + # At least some IPs should have changed + assert input_ips != output_ips, ( + f"IP addresses unchanged in {config_path.name}: {meaningful_ips}" + ) diff --git a/tools/download_test_configs.py b/tools/download_test_configs.py new file mode 100644 index 0000000..cc12ce8 --- /dev/null +++ b/tools/download_test_configs.py @@ -0,0 +1,287 @@ +#!/usr/bin/env python3 +"""Download real-world network configs from public GitHub repos for testing. + +Downloads configs into tests/test_data/{vendor}/{source}/ for use with +the integration test suite in tests/integration/test_real_configs.py. + +Usage: + python tools/download_test_configs.py # all vendors + python tools/download_test_configs.py --vendors cisco arista + python tools/download_test_configs.py --force # re-download all +""" + +import argparse +import json +import logging +import os +import shutil +import subprocess +import sys +import tempfile +import urllib.request + +logging.basicConfig( + format="%(levelname)s %(message)s", + level=logging.INFO, +) +logger = logging.getLogger(__name__) + +# Root of the netconan project +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +TEST_DATA_DIR = os.path.join(PROJECT_ROOT, "tests", "test_data") +METADATA_FILE = os.path.join(TEST_DATA_DIR, ".download_metadata.json") + +# Batfish base paths inside the repo +_BF_GRAMMAR = "projects/batfish/src/test/resources/org/batfish/grammar" +_BF_VENDOR = "projects/batfish/src/test/resources/org/batfish/vendor" + +SOURCES = { + "cisco": [ + { + "name": "batfish", + "type": "git_sparse", + "repo": "https://github.com/batfish/batfish.git", + "paths": [f"{_BF_GRAMMAR}/cisco/testconfigs"], + "src_dir": f"{_BF_GRAMMAR}/cisco/testconfigs", + }, + { + "name": "ciscoconfparse", + "type": "git_sparse", + "repo": "https://github.com/mpenning/ciscoconfparse.git", + "paths": ["tests/fixtures/configs"], + "src_dir": "tests/fixtures/configs", + "glob": "*.ios", + }, + ], + "arista": [ + { + "name": "batfish", + "type": "git_sparse", + "repo": "https://github.com/batfish/batfish.git", + "paths": [f"{_BF_VENDOR}/arista/grammar/testconfigs"], + "src_dir": f"{_BF_VENDOR}/arista/grammar/testconfigs", + }, + ], + "juniper_flat": [ + { + "name": "jcoeder", + "type": "git_clone", + "repo": "https://github.com/jcoeder/juniper-configurations.git", + "glob": "*.conf", + }, + ], + "juniper_hierarchical": [ + { + "name": "batfish", + "type": "git_sparse", + "repo": "https://github.com/batfish/batfish.git", + "paths": [f"{_BF_GRAMMAR}/juniper/testconfigs"], + "src_dir": f"{_BF_GRAMMAR}/juniper/testconfigs", + }, + ], + "fortinet": [ + { + "name": "batfish", + "type": "git_sparse", + "repo": "https://github.com/batfish/batfish.git", + "paths": [f"{_BF_GRAMMAR}/fortios/testconfigs"], + "src_dir": f"{_BF_GRAMMAR}/fortios/testconfigs", + }, + { + "name": "azure", + "type": "url", + "url": ( + "https://raw.githubusercontent.com/Azure/" + "Azure-vpn-config-samples/master/Fortinet/Current/" + "fortigate_show%20full-configuration.txt" + ), + "filename": "fortigate_full.txt", + }, + ], +} + + +def load_metadata(): + """Load download metadata from disk.""" + if os.path.exists(METADATA_FILE): + with open(METADATA_FILE) as f: + return json.load(f) + return {} + + +def save_metadata(metadata): + """Save download metadata to disk.""" + os.makedirs(os.path.dirname(METADATA_FILE), exist_ok=True) + with open(METADATA_FILE, "w") as f: + json.dump(metadata, f, indent=2, sort_keys=True) + + +def source_key(vendor, source): + """Return a unique key for a vendor/source pair.""" + return f"{vendor}/{source['name']}" + + +def is_downloaded(metadata, vendor, source): + """Check if a source has already been downloaded.""" + key = source_key(vendor, source) + return key in metadata + + +def copy_files(src_dir, dest_dir, glob_pattern=None): + """Copy files from src_dir to dest_dir, optionally filtering by glob.""" + os.makedirs(dest_dir, exist_ok=True) + count = 0 + for entry in os.listdir(src_dir): + src_path = os.path.join(src_dir, entry) + if not os.path.isfile(src_path): + continue + if glob_pattern and not _matches_glob(entry, glob_pattern): + continue + shutil.copy2(src_path, os.path.join(dest_dir, entry)) + count += 1 + return count + + +def _matches_glob(filename, pattern): + """Simple glob matching for *.ext patterns.""" + if pattern.startswith("*."): + return filename.endswith(pattern[1:]) + return True + + +def download_git_sparse(source, tmp_dir, dest_dir): + """Download files using git sparse checkout.""" + repo_dir = os.path.join(tmp_dir, "repo") + subprocess.run( + [ + "git", + "clone", + "--depth", + "1", + "--filter=blob:none", + "--sparse", + source["repo"], + repo_dir, + ], + check=True, + capture_output=True, + text=True, + ) + subprocess.run( + ["git", "sparse-checkout", "set"] + source["paths"], + cwd=repo_dir, + check=True, + capture_output=True, + text=True, + ) + src_dir = os.path.join(repo_dir, source["src_dir"]) + if not os.path.isdir(src_dir): + raise FileNotFoundError(f"Source directory not found: {src_dir}") + return copy_files(src_dir, dest_dir, source.get("glob")) + + +def download_git_clone(source, tmp_dir, dest_dir): + """Download files by cloning a full repo (shallow).""" + repo_dir = os.path.join(tmp_dir, "repo") + subprocess.run( + ["git", "clone", "--depth", "1", source["repo"], repo_dir], + check=True, + capture_output=True, + text=True, + ) + return copy_files(repo_dir, dest_dir, source.get("glob")) + + +def download_url(source, dest_dir): + """Download a single file from a URL.""" + os.makedirs(dest_dir, exist_ok=True) + dest_path = os.path.join(dest_dir, source["filename"]) + urllib.request.urlretrieve(source["url"], dest_path) + return 1 + + +def download_source(vendor, source, force=False): + """Download a single source. Returns (count, error_or_None).""" + key = source_key(vendor, source) + dest_dir = os.path.join(TEST_DATA_DIR, vendor, source["name"]) + + if not force and os.path.isdir(dest_dir) and os.listdir(dest_dir): + logger.info(" [skip] %s — already exists", key) + return 0, None + + logger.info(" [download] %s ...", key) + + if source["type"] == "url": + count = download_url(source, dest_dir) + return count, None + + with tempfile.TemporaryDirectory(prefix="netconan_dl_") as tmp_dir: + if source["type"] == "git_sparse": + count = download_git_sparse(source, tmp_dir, dest_dir) + elif source["type"] == "git_clone": + count = download_git_clone(source, tmp_dir, dest_dir) + else: + raise ValueError(f"Unknown source type: {source['type']}") + return count, None + + +def main(argv=None): + """Main entry point.""" + parser = argparse.ArgumentParser( + description="Download real-world network configs for testing." + ) + parser.add_argument( + "--vendors", + nargs="+", + choices=sorted(SOURCES.keys()), + default=None, + help="Only download configs for these vendors (default: all)", + ) + parser.add_argument( + "--force", + action="store_true", + help="Re-download even if files already exist", + ) + args = parser.parse_args(argv) + + vendors = args.vendors or sorted(SOURCES.keys()) + metadata = load_metadata() + total_files = 0 + errors = [] + + for vendor in vendors: + sources = SOURCES.get(vendor, []) + if not sources: + logger.warning("No sources defined for vendor: %s", vendor) + continue + logger.info("Vendor: %s", vendor) + for src in sources: + try: + count, err = download_source(vendor, src, force=args.force) + if err: + errors.append((source_key(vendor, src), err)) + else: + total_files += count + metadata[source_key(vendor, src)] = { + "type": src["type"], + "repo": src.get("repo", src.get("url", "")), + } + except Exception as e: + key = source_key(vendor, src) + logger.error(" [error] %s: %s", key, e) + errors.append((key, str(e))) + + save_metadata(metadata) + + logger.info("") + logger.info("Done: %d files downloaded", total_files) + if errors: + logger.error("%d source(s) failed:", len(errors)) + for key, err in errors: + logger.error(" %s: %s", key, err) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main())