diff --git a/netlint/checks/checker.py b/netlint/checks/checker.py index 182d239..f53fe5d 100644 --- a/netlint/checks/checker.py +++ b/netlint/checks/checker.py @@ -2,6 +2,8 @@ import functools import typing +from netlint.utils import NOS + class CheckResult(typing.NamedTuple): """Result of a single check.""" @@ -26,7 +28,7 @@ class Checker: """Class to handle check execution.""" # Map NOSes to applicable checks - checks: typing.Dict[str, typing.List[CheckFunctionTuple]] = {} + checks: typing.Dict[NOS, typing.List[CheckFunctionTuple]] = {} def __init__(self) -> None: # Map check name to check result (NOS-agnostic) @@ -34,7 +36,7 @@ def __init__(self) -> None: @classmethod def register( - cls, apply_to: typing.List[str], name: str + cls, apply_to: typing.List[NOS], name: str ) -> typing.Callable[ [typing.Callable[[typing.List[str]], typing.Optional[CheckResult]]], typing.Callable[[typing.List[str]], typing.Optional[CheckResult]], @@ -80,7 +82,7 @@ def wrapper(config: typing.List[str]) -> typing.Optional[CheckResult]: return decorator - def run_checks(self, configuration: typing.List[str], nos: str) -> bool: + def run_checks(self, configuration: typing.List[str], nos: NOS) -> bool: """ Run all the registered checks on the configuration. diff --git a/netlint/checks/cisco_ios/__init__.py b/netlint/checks/cisco_ios/__init__.py index 1ccd609..51e7f33 100644 --- a/netlint/checks/cisco_ios/__init__.py +++ b/netlint/checks/cisco_ios/__init__.py @@ -10,12 +10,14 @@ "check_plaintext_passwords", "check_console_password", "check_ip_http_server", + "check_password_hash_strength", ] from netlint.checks.cisco_ios.utils import get_password_hash_algorithm +from netlint.utils import NOS -@Checker.register(apply_to=["cisco_ios"], name="IOS101") +@Checker.register(apply_to=[NOS.CISCO_IOS], name="IOS101") def check_plaintext_passwords(config: typing.List[str]) -> typing.Optional[CheckResult]: """Check if there are any plaintext passwords in the configuration.""" parsed_config = CiscoConfParse(config) @@ -39,7 +41,7 @@ def check_plaintext_passwords(config: typing.List[str]) -> typing.Optional[Check return None -@Checker.register(apply_to=["cisco_ios"], name="IOS102") +@Checker.register(apply_to=[NOS.CISCO_IOS], name="IOS102") def check_ip_http_server(config: typing.List[str]) -> typing.Optional[CheckResult]: """Check if the http server is enabled.""" parsed_config = CiscoConfParse(config) @@ -50,7 +52,7 @@ def check_ip_http_server(config: typing.List[str]) -> typing.Optional[CheckResul return None -@Checker.register(apply_to=["cisco_ios"], name="IOS103") +@Checker.register(apply_to=[NOS.CISCO_IOS], name="IOS103") def check_console_password(config: typing.List[str]) -> typing.Optional[CheckResult]: """Check for authentication on the console line.""" parsed_config = CiscoConfParse(config) @@ -72,7 +74,7 @@ def check_console_password(config: typing.List[str]) -> typing.Optional[CheckRes return None -@Checker.register(apply_to=["cisco_ios"], name="IOS104") +@Checker.register(apply_to=[NOS.CISCO_IOS], name="IOS104") def check_password_hash_strength( config: typing.List[str], ) -> typing.Optional[CheckResult]: diff --git a/netlint/checks/cisco_nxos/__init__.py b/netlint/checks/cisco_nxos/__init__.py index 7584841..37eff20 100644 --- a/netlint/checks/cisco_nxos/__init__.py +++ b/netlint/checks/cisco_nxos/__init__.py @@ -13,9 +13,10 @@ ] from netlint.checks.constants import bogus_as_numbers +from netlint.utils import NOS -@Checker.register(apply_to=["cisco_nxos"], name="NXOS101") +@Checker.register(apply_to=[NOS.CISCO_NXOS], name="NXOS101") def check_telnet_enabled(config: typing.List[str]) -> typing.Optional[CheckResult]: """Check if the telnet feature is explicitly enabled.""" parsed_config = CiscoConfParse(config) @@ -26,7 +27,7 @@ def check_telnet_enabled(config: typing.List[str]) -> typing.Optional[CheckResul return None -@Checker.register(apply_to=["cisco_nxos"], name="NXOS102") +@Checker.register(apply_to=[NOS.CISCO_NXOS], name="NXOS102") def check_routing_protocol_enabled_and_used( config: typing.List[str], ) -> typing.Optional[CheckResult]: @@ -46,7 +47,7 @@ def check_routing_protocol_enabled_and_used( return None -@Checker.register(apply_to=["cisco_nxos"], name="NXOS103") +@Checker.register(apply_to=[NOS.CISCO_NXOS], name="NXOS103") def check_password_strength(config: typing.List[str]) -> typing.Optional[CheckResult]: """Check if the password strength check has been disabled.""" parsed_config = CiscoConfParse(config) @@ -57,7 +58,7 @@ def check_password_strength(config: typing.List[str]) -> typing.Optional[CheckRe return None -@Checker.register(apply_to=["cisco_nxos"], name="NXOS104") +@Checker.register(apply_to=[NOS.CISCO_NXOS], name="NXOS104") def check_bogus_as(config: typing.List[str]) -> typing.Optional[CheckResult]: """Check if any bogus autonomous system is used in the configuration.""" parsed_config = CiscoConfParse(config) diff --git a/netlint/checks/various/__init__.py b/netlint/checks/various/__init__.py index 464af65..9010c96 100644 --- a/netlint/checks/various/__init__.py +++ b/netlint/checks/various/__init__.py @@ -8,8 +8,10 @@ __all__ = ["check_default_snmp_communities"] +from netlint.utils import NOS -@Checker.register(apply_to=["cisco_ios", "cisco_nxos"], name="VAR101") + +@Checker.register(apply_to=[NOS.CISCO_IOS, NOS.CISCO_NXOS], name="VAR101") def check_default_snmp_communities( config: typing.List[str], ) -> typing.Optional[CheckResult]: diff --git a/netlint/main.py b/netlint/main.py index 5b562af..c75e4c8 100644 --- a/netlint/main.py +++ b/netlint/main.py @@ -8,7 +8,7 @@ from netlint.checks.checker import Checker from netlint.types import JSONOutputDict -from netlint.utils import smart_open, style +from netlint.utils import smart_open, style, detect_nos, NOS CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]} DEFAULT_CONFIG = "pyproject.toml" @@ -87,11 +87,6 @@ def configure( type=click.Choice(["json", "normal"]), help="The format of the output data (--format json implies --quiet).", ) -@click.option( - "--nos", - type=click.Choice(["cisco_ios", "cisco_nxos"]), - help="The NOS the configuration(s) is/are for.", -) @click.option( "--select", type=str, @@ -139,7 +134,6 @@ def cli( prefix: str, output: typing.Optional[str], format_: str, - nos: str, select: typing.Optional[str], exclude: typing.Optional[str], quiet: bool, @@ -160,24 +154,42 @@ def cli( checker_instance = Checker() + configurations: typing.Dict[str, typing.List[str]] = {"default": []} + + input_path = Path(path) + nos_mapping = {"default": None} + if input_path.is_file(): + with open(input_path) as f: + configurations["default"] = f.readlines() + nos_mapping["default"] = detect_nos(configurations["default"]) + elif input_path.is_dir(): + path_items = input_path.glob(glob) + for item in path_items: + dict_key = str(item) + with open(item) as f: + configurations[dict_key] = f.readlines() + nos_mapping[dict_key] = detect_nos(configurations[dict_key]) + if select: selected_checks = [] - for check in checker_instance.checks[nos]: + for check in checker_instance.checks[nos_mapping["default"]]: if check.name in select.split(","): selected_checks.append(check) - checker_instance.checks[nos] = selected_checks + checker_instance.checks[nos_mapping["default"]] = selected_checks elif exclude: excluded_checks = [] - for check in checker_instance.checks[nos]: - if check.name in exclude.split(","): - excluded_checks.append(check) + # Iterate over each unique NOS + for nos in set(nos_mapping.values()): + for check in checker_instance.checks[nos]: + if check.name in exclude.split(","): + excluded_checks.append(check) for check in excluded_checks: - checker_instance.checks[nos].remove(check) - - input_path = Path(path) + checker_instance.checks[nos_mapping["default"]].remove(check) if input_path.is_file(): - processed_config = check_config(checker_instance, input_path, nos) + processed_config = check_config( + checker_instance, configurations["default"], nos_mapping["default"] + ) if processed_config: has_errors = True with smart_open(output) as f: @@ -189,7 +201,9 @@ def cli( path_items = input_path.glob(glob) processed_configs: typing.Dict[str, JSONOutputDict] = {} for item in path_items: - processed_configs[str(item)] = check_config(checker_instance, item, nos) + processed_configs[str(item)] = check_config( + checker_instance, configurations[str(item)], nos_mapping[str(item)] + ) if processed_configs: has_errors = True with smart_open(output) as f: @@ -219,12 +233,12 @@ def cli( ctx.exit(0) -def check_config(checker_instance: Checker, path: Path, nos: str) -> JSONOutputDict: +def check_config( + checker_instance: Checker, configuration: typing.List[str], nos: NOS +) -> JSONOutputDict: """Run checks on config at a given path.""" return_value: JSONOutputDict = {} - with open(path, "r") as f: - configuration = f.readlines() checker_instance.run_checks(configuration, nos) for check, result in checker_instance.check_results.items(): diff --git a/netlint/utils.py b/netlint/utils.py index 15bded3..527149d 100644 --- a/netlint/utils.py +++ b/netlint/utils.py @@ -2,6 +2,7 @@ import contextlib import sys import typing +from enum import Enum import click @@ -28,3 +29,21 @@ def style(message: str, quiet: bool, **kwargs: typing.Any) -> str: return message else: return click.style(message, **kwargs) + + +class NOS(Enum): + """Overview of different available NOSes.""" + + CISCO_IOS = "cisco_ios" + CISCO_NXOS = "cisco_nxos" + + +def detect_nos(configuration: typing.List[str]) -> NOS: + """Automatically detect the NOS in the configuration. + + Very rudimentary as of now, will get more complex support for more NOSes is added. + """ + for line in configuration: + if "feature" in line: + return NOS.CISCO_NXOS + return NOS.CISCO_IOS diff --git a/tests/test_cli.py b/tests/test_cli.py index c3710e3..8575fc4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -17,7 +17,7 @@ def test_lint_basic(quiet: bool, format_: str): cisco_ios_faulty_conf = TESTS_DIR / "cisco_ios" / "configurations" / "faulty.conf" - commands = ["--nos", "cisco_ios", str(cisco_ios_faulty_conf)] + commands = [str(cisco_ios_faulty_conf)] if quiet: commands.insert(0, "--quiet")