Skip to content

Commit d57b53e

Browse files
fix(anta.tests): Cleaning up security tests module (VerifyAPISSLCertificate, VerifyIPv4ACL) (#957)
* refactor VerifyAPISSLCertificate, VerifyIPv4ACL tests for input model * Updated test docstring * updated the unit test for no acl found * addressed review comments: updated docs * addressed review comments: updated input model docstring * Add previous models for backward compatibility --------- Co-authored-by: Carl Baillargeon <[email protected]>
1 parent e82c1a5 commit d57b53e

File tree

4 files changed

+244
-176
lines changed

4 files changed

+244
-176
lines changed

anta/input_models/security.py

+114-3
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,20 @@
66
from __future__ import annotations
77

88
from ipaddress import IPv4Address
9-
from typing import Any
9+
from typing import TYPE_CHECKING, Any, ClassVar, get_args
1010
from warnings import warn
1111

12-
from pydantic import BaseModel, ConfigDict
12+
from pydantic import BaseModel, ConfigDict, Field, model_validator
13+
14+
from anta.custom_types import EcdsaKeySize, EncryptionAlgorithm, RsaKeySize
15+
16+
if TYPE_CHECKING:
17+
import sys
18+
19+
if sys.version_info >= (3, 11):
20+
from typing import Self
21+
else:
22+
from typing_extensions import Self
1323

1424

1525
class IPSecPeer(BaseModel):
@@ -43,6 +53,107 @@ class IPSecConn(BaseModel):
4353
"""The IPv4 address of the destination in the security connection."""
4454

4555

56+
class APISSLCertificate(BaseModel):
57+
"""Model for an API SSL certificate."""
58+
59+
model_config = ConfigDict(extra="forbid")
60+
certificate_name: str
61+
"""The name of the certificate to be verified."""
62+
expiry_threshold: int
63+
"""The expiry threshold of the certificate in days."""
64+
common_name: str
65+
"""The Common Name of the certificate."""
66+
encryption_algorithm: EncryptionAlgorithm
67+
"""The encryption algorithm used by the certificate."""
68+
key_size: RsaKeySize | EcdsaKeySize
69+
"""The key size (in bits) of the encryption algorithm."""
70+
71+
def __str__(self) -> str:
72+
"""Return a human-readable string representation of the APISSLCertificate for reporting.
73+
74+
Examples
75+
--------
76+
- Certificate: SIGNING_CA.crt
77+
"""
78+
return f"Certificate: {self.certificate_name}"
79+
80+
@model_validator(mode="after")
81+
def validate_inputs(self) -> Self:
82+
"""Validate the key size provided to the APISSLCertificates class.
83+
84+
If encryption_algorithm is RSA then key_size should be in {2048, 3072, 4096}.
85+
86+
If encryption_algorithm is ECDSA then key_size should be in {256, 384, 521}.
87+
"""
88+
if self.encryption_algorithm == "RSA" and self.key_size not in get_args(RsaKeySize):
89+
msg = f"`{self.certificate_name}` key size {self.key_size} is invalid for RSA encryption. Allowed sizes are {get_args(RsaKeySize)}."
90+
raise ValueError(msg)
91+
92+
if self.encryption_algorithm == "ECDSA" and self.key_size not in get_args(EcdsaKeySize):
93+
msg = f"`{self.certificate_name}` key size {self.key_size} is invalid for ECDSA encryption. Allowed sizes are {get_args(EcdsaKeySize)}."
94+
raise ValueError(msg)
95+
96+
return self
97+
98+
99+
class ACLEntry(BaseModel):
100+
"""Model for an Access Control List (ACL) entry."""
101+
102+
model_config = ConfigDict(extra="forbid")
103+
sequence: int = Field(ge=1, le=4294967295)
104+
"""Sequence number of the ACL entry, used to define the order of processing. Must be between 1 and 4294967295."""
105+
action: str
106+
"""Action of the ACL entry. Example: `deny ip any any`."""
107+
108+
def __str__(self) -> str:
109+
"""Return a human-readable string representation of the ACLEntry for reporting.
110+
111+
Examples
112+
--------
113+
- Sequence: 10
114+
"""
115+
return f"Sequence: {self.sequence}"
116+
117+
118+
class ACL(BaseModel):
119+
"""Model for an Access Control List (ACL)."""
120+
121+
model_config = ConfigDict(extra="forbid")
122+
name: str
123+
"""Name of the ACL."""
124+
entries: list[ACLEntry]
125+
"""List of the ACL entries."""
126+
IPv4ACLEntry: ClassVar[type[ACLEntry]] = ACLEntry
127+
"""To maintain backward compatibility."""
128+
129+
def __str__(self) -> str:
130+
"""Return a human-readable string representation of the ACL for reporting.
131+
132+
Examples
133+
--------
134+
- ACL name: Test
135+
"""
136+
return f"ACL name: {self.name}"
137+
138+
139+
class IPv4ACL(ACL): # pragma: no cover
140+
"""Alias for the ACL model to maintain backward compatibility.
141+
142+
When initialized, it will emit a deprecation warning and call the ACL model.
143+
144+
TODO: Remove this class in ANTA v2.0.0.
145+
"""
146+
147+
def __init__(self, **data: Any) -> None: # noqa: ANN401
148+
"""Initialize the IPv4ACL class, emitting a deprecation warning."""
149+
warn(
150+
message="IPv4ACL model is deprecated and will be removed in ANTA v2.0.0. Use the ACL model instead.",
151+
category=DeprecationWarning,
152+
stacklevel=2,
153+
)
154+
super().__init__(**data)
155+
156+
46157
class IPSecPeers(IPSecPeer): # pragma: no cover
47158
"""Alias for the IPSecPeers model to maintain backward compatibility.
48159
@@ -52,7 +163,7 @@ class IPSecPeers(IPSecPeer): # pragma: no cover
52163
"""
53164

54165
def __init__(self, **data: Any) -> None: # noqa: ANN401
55-
"""Initialize the IPSecPeer class, emitting a deprecation warning."""
166+
"""Initialize the IPSecPeers class, emitting a deprecation warning."""
56167
warn(
57168
message="IPSecPeers model is deprecated and will be removed in ANTA v2.0.0. Use the IPSecPeer model instead.",
58169
category=DeprecationWarning,

anta/tests/security.py

+68-113
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,12 @@
88
# Mypy does not understand AntaTest.Input typing
99
# mypy: disable-error-code=attr-defined
1010
from datetime import datetime, timezone
11-
from typing import TYPE_CHECKING, ClassVar, get_args
11+
from typing import ClassVar
1212

13-
from pydantic import BaseModel, Field, model_validator
14-
15-
from anta.custom_types import EcdsaKeySize, EncryptionAlgorithm, PositiveInteger, RsaKeySize
16-
from anta.input_models.security import IPSecPeer, IPSecPeers
13+
from anta.custom_types import PositiveInteger
14+
from anta.input_models.security import ACL, APISSLCertificate, IPSecPeer, IPSecPeers
1715
from anta.models import AntaCommand, AntaTemplate, AntaTest
18-
from anta.tools import get_failed_logs, get_item, get_value
19-
20-
if TYPE_CHECKING:
21-
import sys
22-
23-
if sys.version_info >= (3, 11):
24-
from typing import Self
25-
else:
26-
from typing_extensions import Self
16+
from anta.tools import get_item, get_value
2717

2818

2919
class VerifySSHStatus(AntaTest):
@@ -354,14 +344,27 @@ def test(self) -> None:
354344

355345

356346
class VerifyAPISSLCertificate(AntaTest):
357-
"""Verifies the eAPI SSL certificate expiry, common subject name, encryption algorithm and key size.
347+
"""Verifies the eAPI SSL certificate.
348+
349+
This test performs the following checks for each certificate:
350+
351+
1. Validates that the certificate is not expired and meets the configured expiry threshold.
352+
2. Validates that the certificate Common Name matches the expected one.
353+
3. Ensures the certificate uses the specified encryption algorithm.
354+
4. Verifies the certificate key matches the expected key size.
358355
359356
Expected Results
360357
----------------
361-
* Success: The test will pass if the certificate's expiry date is greater than the threshold,
362-
and the certificate has the correct name, encryption algorithm, and key size.
363-
* Failure: The test will fail if the certificate is expired or is going to expire,
364-
or if the certificate has an incorrect name, encryption algorithm, or key size.
358+
* Success: If all of the following occur:
359+
- The certificate's expiry date exceeds the configured threshold.
360+
- The certificate's Common Name matches the input configuration.
361+
- The encryption algorithm used by the certificate is as expected.
362+
- The key size of the certificate matches the input configuration.
363+
* Failure: If any of the following occur:
364+
- The certificate is expired or set to expire within the defined threshold.
365+
- The certificate's common name does not match the expected input.
366+
- The encryption algorithm is incorrect.
367+
- The key size does not match the expected input.
365368
366369
Examples
367370
--------
@@ -393,38 +396,7 @@ class Input(AntaTest.Input):
393396

394397
certificates: list[APISSLCertificate]
395398
"""List of API SSL certificates."""
396-
397-
class APISSLCertificate(BaseModel):
398-
"""Model for an API SSL certificate."""
399-
400-
certificate_name: str
401-
"""The name of the certificate to be verified."""
402-
expiry_threshold: int
403-
"""The expiry threshold of the certificate in days."""
404-
common_name: str
405-
"""The common subject name of the certificate."""
406-
encryption_algorithm: EncryptionAlgorithm
407-
"""The encryption algorithm of the certificate."""
408-
key_size: RsaKeySize | EcdsaKeySize
409-
"""The encryption algorithm key size of the certificate."""
410-
411-
@model_validator(mode="after")
412-
def validate_inputs(self) -> Self:
413-
"""Validate the key size provided to the APISSLCertificates class.
414-
415-
If encryption_algorithm is RSA then key_size should be in {2048, 3072, 4096}.
416-
417-
If encryption_algorithm is ECDSA then key_size should be in {256, 384, 521}.
418-
"""
419-
if self.encryption_algorithm == "RSA" and self.key_size not in get_args(RsaKeySize):
420-
msg = f"`{self.certificate_name}` key size {self.key_size} is invalid for RSA encryption. Allowed sizes are {get_args(RsaKeySize)}."
421-
raise ValueError(msg)
422-
423-
if self.encryption_algorithm == "ECDSA" and self.key_size not in get_args(EcdsaKeySize):
424-
msg = f"`{self.certificate_name}` key size {self.key_size} is invalid for ECDSA encryption. Allowed sizes are {get_args(EcdsaKeySize)}."
425-
raise ValueError(msg)
426-
427-
return self
399+
APISSLCertificate: ClassVar[type[APISSLCertificate]] = APISSLCertificate
428400

429401
@AntaTest.anta_test
430402
def test(self) -> None:
@@ -442,32 +414,33 @@ def test(self) -> None:
442414
# Collecting certificate expiry time and current EOS time.
443415
# These times are used to calculate the number of days until the certificate expires.
444416
if not (certificate_data := get_value(certificate_output, f"certificates..{certificate.certificate_name}", separator="..")):
445-
self.result.is_failure(f"SSL certificate '{certificate.certificate_name}', is not configured.\n")
417+
self.result.is_failure(f"{certificate} - Not found")
446418
continue
447419

448420
expiry_time = certificate_data["notAfter"]
449421
day_difference = (datetime.fromtimestamp(expiry_time, tz=timezone.utc) - datetime.fromtimestamp(current_timestamp, tz=timezone.utc)).days
450422

451423
# Verify certificate expiry
452424
if 0 < day_difference < certificate.expiry_threshold:
453-
self.result.is_failure(f"SSL certificate `{certificate.certificate_name}` is about to expire in {day_difference} days.\n")
425+
self.result.is_failure(
426+
f"{certificate} - set to expire within the threshold - Threshold: {certificate.expiry_threshold} days Actual: {day_difference} days"
427+
)
454428
elif day_difference < 0:
455-
self.result.is_failure(f"SSL certificate `{certificate.certificate_name}` is expired.\n")
429+
self.result.is_failure(f"{certificate} - certificate expired")
456430

457431
# Verify certificate common subject name, encryption algorithm and key size
458-
keys_to_verify = ["subject.commonName", "publicKey.encryptionAlgorithm", "publicKey.size"]
459-
actual_certificate_details = {key: get_value(certificate_data, key) for key in keys_to_verify}
432+
common_name = get_value(certificate_data, "subject.commonName", default="Not found")
433+
encryp_algo = get_value(certificate_data, "publicKey.encryptionAlgorithm", default="Not found")
434+
key_size = get_value(certificate_data, "publicKey.size", default="Not found")
460435

461-
expected_certificate_details = {
462-
"subject.commonName": certificate.common_name,
463-
"publicKey.encryptionAlgorithm": certificate.encryption_algorithm,
464-
"publicKey.size": certificate.key_size,
465-
}
436+
if common_name != certificate.common_name:
437+
self.result.is_failure(f"{certificate} - incorrect common name - Expected: {certificate.common_name} Actual: {common_name}")
438+
439+
if encryp_algo != certificate.encryption_algorithm:
440+
self.result.is_failure(f"{certificate} - incorrect encryption algorithm - Expected: {certificate.encryption_algorithm} Actual: {encryp_algo}")
466441

467-
if actual_certificate_details != expected_certificate_details:
468-
failed_log = f"SSL certificate `{certificate.certificate_name}` is not configured properly:"
469-
failed_log += get_failed_logs(expected_certificate_details, actual_certificate_details)
470-
self.result.is_failure(f"{failed_log}\n")
442+
if key_size != certificate.key_size:
443+
self.result.is_failure(f"{certificate} - incorrect public key - Expected: {certificate.key_size} Actual: {key_size}")
471444

472445

473446
class VerifyBannerLogin(AntaTest):
@@ -555,12 +528,22 @@ def test(self) -> None:
555528

556529

557530
class VerifyIPv4ACL(AntaTest):
558-
"""Verifies the configuration of IPv4 ACLs.
531+
"""Verifies the IPv4 ACLs.
532+
533+
This test performs the following checks for each IPv4 ACL:
534+
535+
1. Validates that the IPv4 ACL is properly configured.
536+
2. Validates that the sequence entries in the ACL are correctly ordered.
559537
560538
Expected Results
561539
----------------
562-
* Success: The test will pass if an IPv4 ACL is configured with the correct sequence entries.
563-
* Failure: The test will fail if an IPv4 ACL is not configured or entries are not in sequence.
540+
* Success: If all of the following occur:
541+
- Any IPv4 ACL entry is not configured.
542+
- The sequency entries are correctly configured.
543+
* Failure: If any of the following occur:
544+
- The IPv4 ACL is not configured.
545+
- The any IPv4 ACL entry is not configured.
546+
- The action for any entry does not match the expected input.
564547
565548
Examples
566549
--------
@@ -586,65 +569,37 @@ class VerifyIPv4ACL(AntaTest):
586569
"""
587570

588571
categories: ClassVar[list[str]] = ["security"]
589-
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaTemplate(template="show ip access-lists {acl}", revision=1)]
572+
commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show ip access-lists", revision=1)]
590573

591574
class Input(AntaTest.Input):
592575
"""Input model for the VerifyIPv4ACL test."""
593576

594-
ipv4_access_lists: list[IPv4ACL]
577+
ipv4_access_lists: list[ACL]
595578
"""List of IPv4 ACLs to verify."""
596-
597-
class IPv4ACL(BaseModel):
598-
"""Model for an IPv4 ACL."""
599-
600-
name: str
601-
"""Name of IPv4 ACL."""
602-
603-
entries: list[IPv4ACLEntry]
604-
"""List of IPv4 ACL entries."""
605-
606-
class IPv4ACLEntry(BaseModel):
607-
"""Model for an IPv4 ACL entry."""
608-
609-
sequence: int = Field(ge=1, le=4294967295)
610-
"""Sequence number of an ACL entry."""
611-
action: str
612-
"""Action of an ACL entry."""
613-
614-
def render(self, template: AntaTemplate) -> list[AntaCommand]:
615-
"""Render the template for each input ACL."""
616-
return [template.render(acl=acl.name) for acl in self.inputs.ipv4_access_lists]
579+
IPv4ACL: ClassVar[type[ACL]] = ACL
580+
"""To maintain backward compatibility."""
617581

618582
@AntaTest.anta_test
619583
def test(self) -> None:
620584
"""Main test function for VerifyIPv4ACL."""
621585
self.result.is_success()
622-
for command_output, acl in zip(self.instance_commands, self.inputs.ipv4_access_lists):
623-
# Collecting input ACL details
624-
acl_name = command_output.params.acl
625-
# Retrieve the expected entries from the inputs
626-
acl_entries = acl.entries
627-
628-
# Check if ACL is configured
629-
ipv4_acl_list = command_output.json_output["aclList"]
630-
if not ipv4_acl_list:
631-
self.result.is_failure(f"{acl_name}: Not found")
586+
587+
if not (command_output := self.instance_commands[0].json_output["aclList"]):
588+
self.result.is_failure("No Access Control List (ACL) configured")
589+
return
590+
591+
for access_list in self.inputs.ipv4_access_lists:
592+
if not (access_list_output := get_item(command_output, "name", access_list.name)):
593+
self.result.is_failure(f"{access_list} - Not configured")
632594
continue
633595

634-
# Check if the sequence number is configured and has the correct action applied
635-
failed_log = f"{acl_name}:\n"
636-
for acl_entry in acl_entries:
637-
acl_seq = acl_entry.sequence
638-
acl_action = acl_entry.action
639-
if (actual_entry := get_item(ipv4_acl_list[0]["sequence"], "sequenceNumber", acl_seq)) is None:
640-
failed_log += f"Sequence number `{acl_seq}` is not found.\n"
596+
for entry in access_list.entries:
597+
if not (actual_entry := get_item(access_list_output["sequence"], "sequenceNumber", entry.sequence)):
598+
self.result.is_failure(f"{access_list} {entry} - Not configured")
641599
continue
642600

643-
if actual_entry["text"] != acl_action:
644-
failed_log += f"Expected `{acl_action}` as sequence number {acl_seq} action but found `{actual_entry['text']}` instead.\n"
645-
646-
if failed_log != f"{acl_name}:\n":
647-
self.result.is_failure(f"{failed_log}")
601+
if (act_action := actual_entry["text"]) != entry.action:
602+
self.result.is_failure(f"{access_list} {entry} - action mismatch - Expected: {entry.action} Actual: {act_action}")
648603

649604

650605
class VerifyIPSecConnHealth(AntaTest):

0 commit comments

Comments
 (0)