diff --git a/anta/custom_types.py b/anta/custom_types.py index bd6a7b8d2..4763be495 100644 --- a/anta/custom_types.py +++ b/anta/custom_types.py @@ -208,7 +208,6 @@ def validate_regex(value: str) -> str: SnmpErrorCounter = Literal[ "inVersionErrs", "inBadCommunityNames", "inBadCommunityUses", "inParseErrs", "outTooBigErrs", "outNoSuchNameErrs", "outBadValueErrs", "outGeneralErrs" ] - IPv4RouteType = Literal[ "connected", "static", @@ -238,3 +237,6 @@ def validate_regex(value: str) -> str: "Route Cache Route", "CBF Leaked Route", ] +SnmpVersion = Literal["v1", "v2c", "v3"] +SnmpHashingAlgorithm = Literal["MD5", "SHA", "SHA-224", "SHA-256", "SHA-384", "SHA-512"] +SnmpEncryptionAlgorithm = Literal["AES-128", "AES-192", "AES-256", "DES"] diff --git a/anta/input_models/snmp.py b/anta/input_models/snmp.py new file mode 100644 index 000000000..680d617c0 --- /dev/null +++ b/anta/input_models/snmp.py @@ -0,0 +1,35 @@ +# Copyright (c) 2023-2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Module containing input models for SNMP tests.""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + +from anta.custom_types import SnmpEncryptionAlgorithm, SnmpHashingAlgorithm, SnmpVersion + + +class SnmpUser(BaseModel): + """Model for a SNMP User.""" + + model_config = ConfigDict(extra="forbid") + username: str + """SNMP user name.""" + group_name: str + """SNMP group for the user.""" + version: SnmpVersion + """SNMP protocol version.""" + auth_type: SnmpHashingAlgorithm | None = None + """User authentication algorithm. Can be provided in the `VerifySnmpUser` test.""" + priv_type: SnmpEncryptionAlgorithm | None = None + """User privacy algorithm. Can be provided in the `VerifySnmpUser` test.""" + + def __str__(self) -> str: + """Return a human-readable string representation of the SnmpUser for reporting. + + Examples + -------- + - User: Test Group: Test_Group Version: v2c + """ + return f"User: {self.username} Group: {self.group_name} Version: {self.version}" diff --git a/anta/tests/snmp.py b/anta/tests/snmp.py index 36b97ba2f..ef7410430 100644 --- a/anta/tests/snmp.py +++ b/anta/tests/snmp.py @@ -9,7 +9,10 @@ from typing import TYPE_CHECKING, ClassVar, get_args +from pydantic import field_validator + from anta.custom_types import PositiveInteger, SnmpErrorCounter, SnmpPdu +from anta.input_models.snmp import SnmpUser from anta.models import AntaCommand, AntaTest from anta.tools import get_value @@ -339,3 +342,79 @@ def test(self) -> None: self.result.is_success() else: self.result.is_failure(f"The following SNMP error counters are not found or have non-zero error counters:\n{error_counters_not_ok}") + + +class VerifySnmpUser(AntaTest): + """Verifies the SNMP user configurations. + + This test performs the following checks for each specified user: + + 1. User exists in SNMP configuration. + 2. Group assignment is correct. + 3. For SNMPv3 users only: + - Authentication type matches (if specified) + - Privacy type matches (if specified) + + Expected Results + ---------------- + * Success: If all of the following conditions are met: + - All users exist with correct group assignments. + - SNMPv3 authentication and privacy types match specified values. + * Failure: If any of the following occur: + - User not found in SNMP configuration. + - Incorrect group assignment. + - For SNMPv3: Mismatched authentication or privacy types. + + Examples + -------- + ```yaml + anta.tests.snmp: + - VerifySnmpUser: + snmp_users: + - username: test + group_name: test_group + version: v3 + auth_type: MD5 + priv_type: AES-128 + ``` + """ + + categories: ClassVar[list[str]] = ["snmp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp user", revision=1)] + + class Input(AntaTest.Input): + """Input model for the VerifySnmpUser test.""" + + snmp_users: list[SnmpUser] + """List of SNMP users.""" + + @field_validator("snmp_users") + @classmethod + def validate_snmp_users(cls, snmp_users: list[SnmpUser]) -> list[SnmpUser]: + """Validate that 'auth_type' or 'priv_type' field is provided in each SNMPv3 user.""" + for user in snmp_users: + if user.version == "v3" and not (user.auth_type or user.priv_type): + msg = f"{user} 'auth_type' or 'priv_type' field is required with 'version: v3'" + raise ValueError(msg) + return snmp_users + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifySnmpUser.""" + self.result.is_success() + + for user in self.inputs.snmp_users: + # Verify SNMP user details. + if not (user_details := get_value(self.instance_commands[0].json_output, f"usersByVersion.{user.version}.users.{user.username}")): + self.result.is_failure(f"{user} - Not found") + continue + + if user.group_name != (act_group := user_details.get("groupName", "Not Found")): + self.result.is_failure(f"{user} - Incorrect user group - Actual: {act_group}") + + if user.version == "v3": + if user.auth_type and (act_auth_type := get_value(user_details, "v3Params.authType", "Not Found")) != user.auth_type: + self.result.is_failure(f"{user} - Incorrect authentication type - Expected: {user.auth_type} Actual: {act_auth_type}") + + if user.priv_type and (act_encryption := get_value(user_details, "v3Params.privType", "Not Found")) != user.priv_type: + self.result.is_failure(f"{user} - Incorrect privacy type - Expected: {user.priv_type} Actual: {act_encryption}") diff --git a/docs/api/tests.snmp.md b/docs/api/tests.snmp.md index 85c147e2a..eec97202f 100644 --- a/docs/api/tests.snmp.md +++ b/docs/api/tests.snmp.md @@ -7,7 +7,10 @@ anta_title: ANTA catalog for SNMP tests ~ that can be found in the LICENSE file. --> +# Tests + ::: anta.tests.snmp + options: show_root_heading: false show_root_toc_entry: false @@ -18,3 +21,16 @@ anta_title: ANTA catalog for SNMP tests filters: - "!test" - "!render" + +# Input models + +::: anta.input_models.snmp + + options: + show_root_heading: false + show_root_toc_entry: false + show_bases: false + merge_init_into_class: false + anta_hide_test_module_description: true + show_labels: true + filters: ["!^__str__"] diff --git a/examples/tests.yaml b/examples/tests.yaml index 0b573616c..c2e00f009 100644 --- a/examples/tests.yaml +++ b/examples/tests.yaml @@ -746,6 +746,14 @@ anta.tests.snmp: - VerifySnmpStatus: # Verifies if the SNMP agent is enabled. vrf: default + - VerifySnmpUser: + # Verifies the SNMP user configurations. + snmp_users: + - username: test + group_name: test_group + version: v3 + auth_type: MD5 + priv_type: AES-128 anta.tests.software: - VerifyEOSExtensions: # Verifies that all EOS extensions installed on the device are enabled for boot persistence. diff --git a/tests/units/anta_tests/test_snmp.py b/tests/units/anta_tests/test_snmp.py index 195ef298e..d2eb6b87f 100644 --- a/tests/units/anta_tests/test_snmp.py +++ b/tests/units/anta_tests/test_snmp.py @@ -15,6 +15,7 @@ VerifySnmpLocation, VerifySnmpPDUCounters, VerifySnmpStatus, + VerifySnmpUser, ) from tests.units.anta_tests import test @@ -319,4 +320,168 @@ ], }, }, + { + "name": "success", + "test": VerifySnmpUser, + "eos_data": [ + { + "usersByVersion": { + "v1": { + "users": { + "Test1": { + "groupName": "TestGroup1", + }, + } + }, + "v2c": { + "users": { + "Test2": { + "groupName": "TestGroup2", + }, + } + }, + "v3": { + "users": { + "Test3": { + "groupName": "TestGroup3", + "v3Params": {"authType": "SHA-384", "privType": "AES-128"}, + }, + "Test4": {"groupName": "TestGroup3", "v3Params": {"authType": "SHA-512", "privType": "AES-192"}}, + } + }, + } + } + ], + "inputs": { + "snmp_users": [ + {"username": "Test1", "group_name": "TestGroup1", "version": "v1"}, + {"username": "Test2", "group_name": "TestGroup2", "version": "v2c"}, + {"username": "Test3", "group_name": "TestGroup3", "version": "v3", "auth_type": "SHA-384", "priv_type": "AES-128"}, + {"username": "Test4", "group_name": "TestGroup3", "version": "v3", "auth_type": "SHA-512", "priv_type": "AES-192"}, + ] + }, + "expected": {"result": "success"}, + }, + { + "name": "failure-not-configured", + "test": VerifySnmpUser, + "eos_data": [ + { + "usersByVersion": { + "v3": { + "users": { + "Test3": { + "groupName": "TestGroup3", + "v3Params": {"authType": "SHA-384", "privType": "AES-128"}, + }, + } + }, + } + } + ], + "inputs": { + "snmp_users": [ + {"username": "Test1", "group_name": "TestGroup1", "version": "v1"}, + {"username": "Test2", "group_name": "TestGroup2", "version": "v2c"}, + {"username": "Test3", "group_name": "TestGroup3", "version": "v3", "auth_type": "SHA-384", "priv_type": "AES-128"}, + {"username": "Test4", "group_name": "TestGroup3", "version": "v3", "auth_type": "SHA-512", "priv_type": "AES-192"}, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "User: Test1 Group: TestGroup1 Version: v1 - Not found", + "User: Test2 Group: TestGroup2 Version: v2c - Not found", + "User: Test4 Group: TestGroup3 Version: v3 - Not found", + ], + }, + }, + { + "name": "failure-incorrect-group", + "test": VerifySnmpUser, + "eos_data": [ + { + "usersByVersion": { + "v1": { + "users": { + "Test1": { + "groupName": "TestGroup2", + }, + } + }, + "v2c": { + "users": { + "Test2": { + "groupName": "TestGroup1", + }, + } + }, + "v3": {}, + } + } + ], + "inputs": { + "snmp_users": [ + {"username": "Test1", "group_name": "TestGroup1", "version": "v1"}, + {"username": "Test2", "group_name": "TestGroup2", "version": "v2c"}, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "User: Test1 Group: TestGroup1 Version: v1 - Incorrect user group - Actual: TestGroup2", + "User: Test2 Group: TestGroup2 Version: v2c - Incorrect user group - Actual: TestGroup1", + ], + }, + }, + { + "name": "failure-incorrect-auth-encryption", + "test": VerifySnmpUser, + "eos_data": [ + { + "usersByVersion": { + "v1": { + "users": { + "Test1": { + "groupName": "TestGroup1", + }, + } + }, + "v2c": { + "users": { + "Test2": { + "groupName": "TestGroup2", + }, + } + }, + "v3": { + "users": { + "Test3": { + "groupName": "TestGroup3", + "v3Params": {"authType": "SHA-512", "privType": "AES-192"}, + }, + "Test4": {"groupName": "TestGroup4", "v3Params": {"authType": "SHA-384", "privType": "AES-128"}}, + } + }, + } + } + ], + "inputs": { + "snmp_users": [ + {"username": "Test1", "group_name": "TestGroup1", "version": "v1"}, + {"username": "Test2", "group_name": "TestGroup2", "version": "v2c"}, + {"username": "Test3", "group_name": "TestGroup3", "version": "v3", "auth_type": "SHA-384", "priv_type": "AES-128"}, + {"username": "Test4", "group_name": "TestGroup4", "version": "v3", "auth_type": "SHA-512", "priv_type": "AES-192"}, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "User: Test3 Group: TestGroup3 Version: v3 - Incorrect authentication type - Expected: SHA-384 Actual: SHA-512", + "User: Test3 Group: TestGroup3 Version: v3 - Incorrect privacy type - Expected: AES-128 Actual: AES-192", + "User: Test4 Group: TestGroup4 Version: v3 - Incorrect authentication type - Expected: SHA-512 Actual: SHA-384", + "User: Test4 Group: TestGroup4 Version: v3 - Incorrect privacy type - Expected: AES-192 Actual: AES-128", + ], + }, + }, ] diff --git a/tests/units/input_models/test_snmp.py b/tests/units/input_models/test_snmp.py new file mode 100644 index 000000000..94551ca76 --- /dev/null +++ b/tests/units/input_models/test_snmp.py @@ -0,0 +1,44 @@ +# Copyright (c) 2023-2025 Arista Networks, Inc. +# Use of this source code is governed by the Apache License 2.0 +# that can be found in the LICENSE file. +"""Tests for anta.input_models.snmp.py.""" + +# pylint: disable=C0302 +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from pydantic import ValidationError + +from anta.tests.snmp import VerifySnmpUser + +if TYPE_CHECKING: + from anta.input_models.snmp import SnmpUser + + +class TestVerifySnmpUserInput: + """Test anta.tests.snmp.VerifySnmpUser.Input.""" + + @pytest.mark.parametrize( + ("snmp_users"), + [ + pytest.param([{"username": "test", "group_name": "abc", "version": "v1", "auth_type": None, "priv_type": None}], id="valid-v1"), + pytest.param([{"username": "test", "group_name": "abc", "version": "v2c", "auth_type": None, "priv_type": None}], id="valid-v2c"), + pytest.param([{"username": "test", "group_name": "abc", "version": "v3", "auth_type": "SHA", "priv_type": "AES-128"}], id="valid-v3"), + ], + ) + def test_valid(self, snmp_users: list[SnmpUser]) -> None: + """Test VerifySnmpUser.Input valid inputs.""" + VerifySnmpUser.Input(snmp_users=snmp_users) + + @pytest.mark.parametrize( + ("snmp_users"), + [ + pytest.param([{"username": "test", "group_name": "abc", "version": "v3", "auth_type": None, "priv_type": None}], id="invalid-v3"), + ], + ) + def test_invalid(self, snmp_users: list[SnmpUser]) -> None: + """Test VerifySnmpUser.Input invalid inputs.""" + with pytest.raises(ValidationError): + VerifySnmpUser.Input(snmp_users=snmp_users)