anta 1.10.0.dev2__tar.gz → 1.10.0.dev5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/PKG-INFO +1 -1
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/_advisory/base.py +35 -4
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/_advisory/eos_versions.py +5 -13
- anta-1.10.0.dev5/anta/_advisory/facts/__init__.py +4 -0
- anta-1.10.0.dev5/anta/_advisory/facts/eos.py +91 -0
- anta-1.10.0.dev5/anta/_advisory/facts/management.py +334 -0
- anta-1.10.0.dev5/anta/_advisory/facts/models.py +215 -0
- anta-1.10.0.dev5/anta/_advisory/facts/platform.py +34 -0
- anta-1.10.0.dev5/anta/_advisory/facts/redirection.py +322 -0
- anta-1.10.0.dev5/anta/_advisory/facts/software.py +77 -0
- anta-1.10.0.dev5/anta/_advisory/facts/ssh.py +152 -0
- anta-1.10.0.dev5/anta/_advisory/facts/terminattr.py +104 -0
- anta-1.10.0.dev5/anta/_advisory/findings/__init__.py +4 -0
- anta-1.10.0.dev5/anta/_advisory/findings/models.py +239 -0
- anta-1.10.0.dev5/anta/_advisory/findings/projection.py +129 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/_advisory/models.py +2 -0
- anta-1.10.0.dev5/anta/_advisory/remediation.py +372 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/_advisory/reporter/csv_reporter.py +20 -28
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/_advisory/reporter/md_reporter.py +47 -95
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/_advisory/reporter/reporting.py +7 -12
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/_advisory/results.py +10 -7
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/_advisory/status.py +20 -10
- anta-1.10.0.dev5/anta/_advisory/version.py +30 -0
- anta-1.10.0.dev5/anta/_eos/parsing.py +73 -0
- anta-1.10.0.dev5/anta/_eos/platform.py +677 -0
- anta-1.10.0.dev5/anta/_eos/version.py +87 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/cli/get/commands.py +5 -1
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/cli/get/utils.py +6 -0
- anta-1.10.0.dev5/anta/cli/nrfu/__init__.py +138 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/cli/nrfu/utils.py +6 -7
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/cli/psirt.py +83 -21
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/cli/utils.py +30 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/device.py +93 -12
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/advisories/__init__.py +9 -9
- anta-1.10.0.dev5/anta/tests/advisories/sa_117.py +180 -0
- anta-1.10.0.dev5/anta/tests/advisories/sa_140.py +150 -0
- anta-1.10.0.dev5/anta/tests/advisories/sa_142.py +487 -0
- anta-1.10.0.dev5/anta/tests/advisories/sa_146.py +316 -0
- anta-1.10.0.dev5/anta/tests/advisories/sa_147.py +289 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/interfaces.py +1 -1
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/routing/ospf.py +3 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/system.py +6 -6
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta.egg-info/PKG-INFO +1 -1
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta.egg-info/SOURCES.txt +15 -1
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/pyproject.toml +6 -5
- anta-1.10.0.dev2/anta/_advisory/platforms.py +0 -77
- anta-1.10.0.dev2/anta/_advisory/remediation.py +0 -78
- anta-1.10.0.dev2/anta/_eos/version.py +0 -49
- anta-1.10.0.dev2/anta/cli/nrfu/__init__.py +0 -179
- anta-1.10.0.dev2/anta/tests/advisories/sa_117.py +0 -295
- anta-1.10.0.dev2/anta/tests/advisories/sa_140.py +0 -169
- anta-1.10.0.dev2/anta/tests/advisories/sa_142.py +0 -738
- anta-1.10.0.dev2/anta/tests/advisories/sa_146.py +0 -523
- anta-1.10.0.dev2/anta/tests/advisories/sa_147.py +0 -434
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/LICENSE +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/README.md +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/__init__.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/_advisory/__init__.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/_advisory/optional_commands.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/_advisory/reporter/__init__.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/_eos/__init__.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/_runner.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/catalog.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/cli/__init__.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/cli/_main.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/cli/check/__init__.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/cli/check/commands.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/cli/console.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/cli/debug/__init__.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/cli/debug/commands.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/cli/debug/utils.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/cli/exec/__init__.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/cli/exec/commands.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/cli/exec/utils.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/cli/get/__init__.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/cli/nrfu/commands.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/constants.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/custom_types.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/decorators.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/input_models/__init__.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/input_models/avt.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/input_models/bfd.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/input_models/configuration.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/input_models/connectivity.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/input_models/cvx.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/input_models/evpn.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/input_models/flow_tracking.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/input_models/hardware.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/input_models/interfaces.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/input_models/logging.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/input_models/path_selection.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/input_models/routing/__init__.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/input_models/routing/bgp.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/input_models/routing/generic.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/input_models/routing/isis.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/input_models/routing/ospf.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/input_models/security.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/input_models/services.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/input_models/snmp.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/input_models/stun.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/input_models/system.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/input_models/vlan.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/inventory/__init__.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/inventory/exceptions.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/inventory/models.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/logger.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/models.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/py.typed +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/reporter/__init__.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/reporter/csv_reporter.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/reporter/jinja_reporter.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/reporter/md_reporter.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/reporter/table_reporter.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/result_manager/__init__.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/result_manager/models.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/runner.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/settings.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/__init__.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/aaa.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/avt.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/bfd.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/configuration.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/connectivity.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/cvx.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/evpn.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/field_notices.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/flow_tracking.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/greent.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/hardware.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/lanz.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/logging.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/mlag.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/multicast.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/path_selection.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/profiles.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/ptp.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/routing/__init__.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/routing/bgp.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/routing/generic.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/routing/isis.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/security.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/services.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/snmp.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/software.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/stp.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/stun.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/vlan.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/vxlan.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tools.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta.egg-info/dependency_links.txt +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta.egg-info/entry_points.txt +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta.egg-info/requires.txt +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta.egg-info/top_level.txt +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/asynceapi/__init__.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/asynceapi/_auth.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/asynceapi/_constants.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/asynceapi/_errors.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/asynceapi/_models.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/asynceapi/_types.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/asynceapi/aio_portcheck.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/asynceapi/config_session.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/asynceapi/device.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/asynceapi/errors.py +0 -0
- {anta-1.10.0.dev2 → anta-1.10.0.dev5}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: anta
|
|
3
|
-
Version: 1.10.0.
|
|
3
|
+
Version: 1.10.0.dev5
|
|
4
4
|
Summary: Arista Network Test Automation (ANTA) Framework
|
|
5
5
|
Author-email: Arista Networks ANTA maintainers <anta-dev@arista.com>
|
|
6
6
|
Maintainer-email: Arista Networks ANTA maintainers <anta-dev@arista.com>, Khelil Sator <ksator@arista.com>, Matthieu Tâche <mtache@arista.com>, Thomas Grimonet <tgrimonet@arista.com>, Guillaume Mulocher <gmulocher@arista.com>, Carl Baillargeon <carl.baillargeon@arista.com>
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
8
|
import sys
|
|
9
|
-
from typing import ClassVar
|
|
9
|
+
from typing import TYPE_CHECKING, Any, ClassVar, TypeVar
|
|
10
10
|
|
|
11
11
|
from anta._advisory.models import _AdvisoryMetadata
|
|
12
12
|
from anta._advisory.results import _AdvisoryTestResult
|
|
@@ -17,6 +17,11 @@ if sys.version_info >= (3, 12):
|
|
|
17
17
|
else:
|
|
18
18
|
from typing_extensions import override
|
|
19
19
|
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from anta._advisory.facts.models import Fact, FactDefinition
|
|
22
|
+
|
|
23
|
+
T = TypeVar("T")
|
|
24
|
+
|
|
20
25
|
|
|
21
26
|
class _AntaAdvisoryTest(AntaTest):
|
|
22
27
|
"""Base class for ANTA security advisory tests."""
|
|
@@ -26,6 +31,7 @@ class _AntaAdvisoryTest(AntaTest):
|
|
|
26
31
|
result: _AdvisoryTestResult # pyright: ignore[reportIncompatibleVariableOverride]
|
|
27
32
|
categories: ClassVar[list[str]] = ["advisories"]
|
|
28
33
|
commands: ClassVar[list[AntaCommand | AntaTemplate]] = []
|
|
34
|
+
required_facts: ClassVar[tuple[type[FactDefinition[Any]], ...]] = ()
|
|
29
35
|
advisory: ClassVar[_AdvisoryMetadata]
|
|
30
36
|
|
|
31
37
|
@override
|
|
@@ -40,9 +46,16 @@ class _AntaAdvisoryTest(AntaTest):
|
|
|
40
46
|
)
|
|
41
47
|
|
|
42
48
|
def __init_subclass__(cls) -> None:
|
|
43
|
-
"""
|
|
49
|
+
"""Derive commands, set subclass identity, and validate advisory attributes."""
|
|
44
50
|
has_own_name = "name" in cls.__dict__
|
|
45
51
|
has_own_description = "description" in cls.__dict__
|
|
52
|
+
required_facts = cls.__dict__.get("required_facts", ())
|
|
53
|
+
if required_facts:
|
|
54
|
+
if "commands" in cls.__dict__:
|
|
55
|
+
msg = f"Class {cls.__module__}.{cls.__name__} cannot define both 'required_facts' and 'commands'"
|
|
56
|
+
raise AttributeError(msg)
|
|
57
|
+
cls.commands = cls._commands_from_required_facts(required_facts)
|
|
58
|
+
|
|
46
59
|
super().__init_subclass__()
|
|
47
60
|
|
|
48
61
|
# AntaTest uses inherited attributes when resolving these values. Reset
|
|
@@ -58,6 +71,24 @@ class _AntaAdvisoryTest(AntaTest):
|
|
|
58
71
|
if not isinstance(cls.advisory, _AdvisoryMetadata):
|
|
59
72
|
msg = f"Class {cls.__module__}.{cls.__name__} class attribute 'advisory' must be an _AdvisoryMetadata instance"
|
|
60
73
|
raise TypeError(msg)
|
|
61
|
-
if not cls.commands:
|
|
62
|
-
msg = f"Class {cls.__module__}.{cls.__name__} must define at least one command"
|
|
74
|
+
if not cls.commands and not required_facts:
|
|
75
|
+
msg = f"Class {cls.__module__}.{cls.__name__} must define at least one command or required fact"
|
|
63
76
|
raise AttributeError(msg)
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def _commands_from_required_facts(cls, required_facts: tuple[type[FactDefinition[Any]], ...]) -> list[AntaCommand | AntaTemplate]:
|
|
80
|
+
"""Return the commands needed by the required facts in declaration order."""
|
|
81
|
+
return [command for definition in required_facts for command in definition.required_commands()]
|
|
82
|
+
|
|
83
|
+
def fact(self, definition: type[FactDefinition[T]]) -> Fact[T]:
|
|
84
|
+
"""Derive one required fact from device metadata or collected command data."""
|
|
85
|
+
command_offset = 0
|
|
86
|
+
for candidate in self.required_facts:
|
|
87
|
+
command_count = len(candidate.required_commands())
|
|
88
|
+
if candidate is definition:
|
|
89
|
+
commands = tuple(self.instance_commands[command_offset : command_offset + command_count])
|
|
90
|
+
return definition.derive(self.device, commands)
|
|
91
|
+
command_offset += command_count
|
|
92
|
+
|
|
93
|
+
msg = f"Fact '{definition.key}' is not listed in required_facts for {self.__class__.__name__}"
|
|
94
|
+
raise ValueError(msg)
|
|
@@ -9,12 +9,10 @@ from dataclasses import dataclass
|
|
|
9
9
|
from enum import Enum
|
|
10
10
|
from typing import TYPE_CHECKING
|
|
11
11
|
|
|
12
|
-
from anta._eos.version import EOSVersion, parse_eos_version
|
|
13
|
-
|
|
14
12
|
if TYPE_CHECKING:
|
|
15
13
|
from collections.abc import Sequence
|
|
16
14
|
|
|
17
|
-
from anta.
|
|
15
|
+
from anta._eos.version import EOSVersion
|
|
18
16
|
|
|
19
17
|
|
|
20
18
|
def _matches_bounds(
|
|
@@ -108,18 +106,12 @@ class VersionEvaluation:
|
|
|
108
106
|
affected_status: AffectedStatus
|
|
109
107
|
|
|
110
108
|
|
|
111
|
-
def evaluate_version(
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
) -> VersionEvaluation:
|
|
115
|
-
"""Evaluate the EOS version from refreshed device metadata."""
|
|
116
|
-
if device_version is None:
|
|
109
|
+
def evaluate_version(version: EOSVersion | None, version_matrix: Sequence[VersionRule]) -> VersionEvaluation:
|
|
110
|
+
"""Evaluate a normalized EOS version against an affected-version matrix."""
|
|
111
|
+
if version is None:
|
|
117
112
|
return VersionEvaluation(None, AffectedStatus.UNKNOWN)
|
|
118
113
|
|
|
119
|
-
version_string = str(
|
|
120
|
-
version = device_version if isinstance(device_version, EOSVersion) else parse_eos_version(version_string)
|
|
121
|
-
if version is None:
|
|
122
|
-
return VersionEvaluation(version_string, AffectedStatus.UNKNOWN)
|
|
114
|
+
version_string = str(version)
|
|
123
115
|
if any(rule.matches(version) for rule in version_matrix):
|
|
124
116
|
return VersionEvaluation(version_string, AffectedStatus.AFFECTED)
|
|
125
117
|
return VersionEvaluation(version_string, AffectedStatus.NOT_AFFECTED)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Copyright (c) 2026 Arista Networks, Inc.
|
|
2
|
+
# Use of this source code is governed by the Apache License 2.0
|
|
3
|
+
# that can be found in the LICENSE file.
|
|
4
|
+
"""Fact definitions derived from EOS device and command data."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from anta._advisory.facts.models import (
|
|
11
|
+
CommandsFactDefinition,
|
|
12
|
+
Fact,
|
|
13
|
+
FactDefinition,
|
|
14
|
+
FactProblemKind,
|
|
15
|
+
FactSource,
|
|
16
|
+
FactSourceKind,
|
|
17
|
+
FeatureName,
|
|
18
|
+
FeatureState,
|
|
19
|
+
FeatureValue,
|
|
20
|
+
)
|
|
21
|
+
from anta._eos.version import EOSVersion, parse_eos_version
|
|
22
|
+
from anta.models import AntaCommand
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from anta.device import AntaDevice
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class EosVersionFact(FactDefinition[EOSVersion]):
|
|
29
|
+
"""Derive the normalized EOS version from refreshed device metadata."""
|
|
30
|
+
|
|
31
|
+
key = "eos.version"
|
|
32
|
+
label = "EOS version"
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def derive(cls, device: AntaDevice, commands: tuple[AntaCommand, ...] = ()) -> Fact[EOSVersion]:
|
|
36
|
+
"""Normalize the device version into an EOS version fact."""
|
|
37
|
+
_ = commands
|
|
38
|
+
source = FactSource("device metadata", FactSourceKind.DEVICE_METADATA)
|
|
39
|
+
device_version = device.version
|
|
40
|
+
if device_version is None:
|
|
41
|
+
return cls.unavailable(FactProblemKind.MISSING, source)
|
|
42
|
+
version = device_version if isinstance(device_version, EOSVersion) else parse_eos_version(str(device_version)).unwrap_or_none()
|
|
43
|
+
if version is None:
|
|
44
|
+
return cls.unavailable(FactProblemKind.INVALID, source)
|
|
45
|
+
return cls.available(version, source)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SecureBootFact(CommandsFactDefinition[FeatureValue]):
|
|
49
|
+
"""Derive Secure Boot support and state from structured ``show boot`` output."""
|
|
50
|
+
|
|
51
|
+
key = "feature.secure_boot"
|
|
52
|
+
label = "Secure Boot feature state"
|
|
53
|
+
commands = (AntaCommand(command="show boot", revision=1),)
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def parse(cls, commands: tuple[AntaCommand, ...]) -> Fact[FeatureValue]:
|
|
57
|
+
"""Normalize the collected command output into a Secure Boot fact.
|
|
58
|
+
|
|
59
|
+
The structured fields prove the platform-support and configuration prerequisites
|
|
60
|
+
together. Empty structured output is the established unsupported-feature shape and
|
|
61
|
+
therefore proves absence. A false prerequisite is otherwise sufficient to establish
|
|
62
|
+
a safe state.
|
|
63
|
+
"""
|
|
64
|
+
(command,) = commands
|
|
65
|
+
boot_output = command.json_output
|
|
66
|
+
source = FactSource(command.command, FactSourceKind.COMMAND)
|
|
67
|
+
if not boot_output:
|
|
68
|
+
return cls.available(FeatureValue(FeatureName.SECURE_BOOT, FeatureState.UNSUPPORTED), source)
|
|
69
|
+
|
|
70
|
+
supported = boot_output.get("securebootSupported")
|
|
71
|
+
enabled = boot_output.get("securebootEnabled")
|
|
72
|
+
|
|
73
|
+
if supported is False and enabled is True:
|
|
74
|
+
return cls.unavailable(
|
|
75
|
+
FactProblemKind.CONTRADICTORY,
|
|
76
|
+
source,
|
|
77
|
+
observations=(
|
|
78
|
+
FeatureValue(FeatureName.SECURE_BOOT, FeatureState.UNSUPPORTED),
|
|
79
|
+
FeatureValue(FeatureName.SECURE_BOOT, FeatureState.ENABLED),
|
|
80
|
+
),
|
|
81
|
+
)
|
|
82
|
+
if supported is False:
|
|
83
|
+
return cls.available(FeatureValue(FeatureName.SECURE_BOOT, FeatureState.UNSUPPORTED), source)
|
|
84
|
+
if enabled is False:
|
|
85
|
+
return cls.available(FeatureValue(FeatureName.SECURE_BOOT, FeatureState.DISABLED), source)
|
|
86
|
+
if supported is True and enabled is True:
|
|
87
|
+
return cls.available(FeatureValue(FeatureName.SECURE_BOOT, FeatureState.ENABLED), source)
|
|
88
|
+
|
|
89
|
+
values = (supported, enabled)
|
|
90
|
+
problem = FactProblemKind.MISSING if any(value is None for value in values) else FactProblemKind.MALFORMED
|
|
91
|
+
return cls.unavailable(problem, source)
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
# Copyright (c) 2026 Arista Networks, Inc.
|
|
2
|
+
# Use of this source code is governed by the Apache License 2.0
|
|
3
|
+
# that can be found in the LICENSE file.
|
|
4
|
+
"""Facts derived from EOS management-service state."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from anta._advisory.facts.models import (
|
|
13
|
+
CommandsFactDefinition,
|
|
14
|
+
ConfigurationState,
|
|
15
|
+
ConfigurationValue,
|
|
16
|
+
Fact,
|
|
17
|
+
FactProblemKind,
|
|
18
|
+
FactSource,
|
|
19
|
+
FactSourceKind,
|
|
20
|
+
FeatureName,
|
|
21
|
+
FeatureState,
|
|
22
|
+
FeatureValue,
|
|
23
|
+
MitigationState,
|
|
24
|
+
MitigationValue,
|
|
25
|
+
SubFeature,
|
|
26
|
+
)
|
|
27
|
+
from anta._advisory.optional_commands import OptionalAntaCommand, is_unsupported_optional_command
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from anta.models import AntaCommand
|
|
31
|
+
|
|
32
|
+
RISKY_TRACE_SELECTORS = ("service/9", "interceptor/9", "transport_socketcli/9")
|
|
33
|
+
GNMI_COMMAND = OptionalAntaCommand(command="show management api gnmi", revision=1)
|
|
34
|
+
GRIBI_COMMAND = OptionalAntaCommand(command="show management api gribi", revision=1)
|
|
35
|
+
TRACE_COMMAND = OptionalAntaCommand(command="show running-config section trace", ofmt="text")
|
|
36
|
+
SSL_PROFILE_COMMAND = OptionalAntaCommand(command="show management security ssl profile", revision=1)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class _GnmiConfig:
|
|
41
|
+
"""Deserialized gNMI configuration without fact-specific interpretation."""
|
|
42
|
+
|
|
43
|
+
service_enabled: bool | None
|
|
44
|
+
transports: tuple[object, ...]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _deserialize_gnmi_config(gnmi_output: Mapping[str, object]) -> _GnmiConfig | None:
|
|
48
|
+
"""Deserialize flattened and nested EOS gNMI output without evaluating it."""
|
|
49
|
+
if "transports" not in gnmi_output:
|
|
50
|
+
service_enabled = gnmi_output.get("enabled")
|
|
51
|
+
if not isinstance(service_enabled, bool):
|
|
52
|
+
return None
|
|
53
|
+
return _GnmiConfig(service_enabled=service_enabled, transports=(gnmi_output,))
|
|
54
|
+
transports = gnmi_output.get("transports")
|
|
55
|
+
if not isinstance(transports, Mapping):
|
|
56
|
+
return None
|
|
57
|
+
service_enabled = gnmi_output.get("enabled")
|
|
58
|
+
if service_enabled is not None and not isinstance(service_enabled, bool):
|
|
59
|
+
return None
|
|
60
|
+
return _GnmiConfig(service_enabled=service_enabled, transports=tuple(transports.values()))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _feature_source(command: AntaCommand) -> FactSource:
|
|
64
|
+
"""Return the source for one command-derived fact."""
|
|
65
|
+
return FactSource(command.command, FactSourceKind.COMMAND)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class GnmiTransportFact(CommandsFactDefinition[FeatureValue]):
|
|
69
|
+
"""Effective gNMI transport state."""
|
|
70
|
+
|
|
71
|
+
key = "feature.gnmi.transport"
|
|
72
|
+
label = "gNMI transport state"
|
|
73
|
+
commands = (GNMI_COMMAND,)
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def parse(cls, commands: tuple[AntaCommand, ...]) -> Fact[FeatureValue]:
|
|
77
|
+
(command,) = commands
|
|
78
|
+
source = _feature_source(command)
|
|
79
|
+
if is_unsupported_optional_command(command):
|
|
80
|
+
return cls.available(FeatureValue(FeatureName.GNMI, FeatureState.UNSUPPORTED), source)
|
|
81
|
+
config = _deserialize_gnmi_config(command.json_output)
|
|
82
|
+
if config is None:
|
|
83
|
+
return cls.unavailable(FactProblemKind.MALFORMED, source)
|
|
84
|
+
if config.service_enabled is False and any(isinstance(transport, Mapping) and transport.get("enabled") is True for transport in config.transports):
|
|
85
|
+
return cls.unavailable(FactProblemKind.MALFORMED, source)
|
|
86
|
+
unknown = False
|
|
87
|
+
for transport in config.transports:
|
|
88
|
+
if not isinstance(transport, Mapping):
|
|
89
|
+
unknown = True
|
|
90
|
+
continue
|
|
91
|
+
enabled = transport.get("enabled")
|
|
92
|
+
if enabled is True:
|
|
93
|
+
return cls.available(FeatureValue(FeatureName.GNMI, FeatureState.ENABLED), source)
|
|
94
|
+
if enabled is not False:
|
|
95
|
+
unknown = True
|
|
96
|
+
if unknown:
|
|
97
|
+
return cls.unavailable(FactProblemKind.MALFORMED, source)
|
|
98
|
+
state = FeatureState.DISABLED
|
|
99
|
+
return cls.available(FeatureValue(FeatureName.GNMI, state), source)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class GnmiAccountingFact(CommandsFactDefinition[FeatureValue]):
|
|
103
|
+
"""Accounting state across enabled gNMI transports."""
|
|
104
|
+
|
|
105
|
+
key = "feature.gnmi.accounting"
|
|
106
|
+
label = "gNMI transport accounting state"
|
|
107
|
+
commands = (GNMI_COMMAND,)
|
|
108
|
+
|
|
109
|
+
@staticmethod
|
|
110
|
+
def _state(config: _GnmiConfig) -> FeatureState | FactProblemKind:
|
|
111
|
+
"""Interpret accounting state from neutral gNMI configuration."""
|
|
112
|
+
if config.service_enabled is False and any(isinstance(transport, Mapping) and transport.get("enabled") is True for transport in config.transports):
|
|
113
|
+
return FactProblemKind.MALFORMED
|
|
114
|
+
unknown = False
|
|
115
|
+
for transport in config.transports:
|
|
116
|
+
if not isinstance(transport, Mapping):
|
|
117
|
+
unknown = True
|
|
118
|
+
continue
|
|
119
|
+
enabled = transport.get("enabled")
|
|
120
|
+
if enabled is False:
|
|
121
|
+
continue
|
|
122
|
+
if enabled is not True:
|
|
123
|
+
unknown = True
|
|
124
|
+
continue
|
|
125
|
+
accounting = transport.get("accounting")
|
|
126
|
+
if accounting is True:
|
|
127
|
+
return FeatureState.ENABLED
|
|
128
|
+
if accounting is not False:
|
|
129
|
+
unknown = True
|
|
130
|
+
return FactProblemKind.MISSING if unknown else FeatureState.DISABLED
|
|
131
|
+
|
|
132
|
+
@classmethod
|
|
133
|
+
def parse(cls, commands: tuple[AntaCommand, ...]) -> Fact[FeatureValue]:
|
|
134
|
+
(command,) = commands
|
|
135
|
+
source = _feature_source(command)
|
|
136
|
+
feature = SubFeature(FeatureName.GNMI, "transport accounting")
|
|
137
|
+
if is_unsupported_optional_command(command):
|
|
138
|
+
return cls.available(FeatureValue(feature, FeatureState.UNSUPPORTED), source)
|
|
139
|
+
config = _deserialize_gnmi_config(command.json_output)
|
|
140
|
+
if config is None:
|
|
141
|
+
return cls.unavailable(FactProblemKind.MALFORMED, source)
|
|
142
|
+
state = cls._state(config)
|
|
143
|
+
if isinstance(state, FactProblemKind):
|
|
144
|
+
return cls.unavailable(state, source)
|
|
145
|
+
return cls.available(FeatureValue(feature, state), source)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class RiskyOpenConfigTraceFact(CommandsFactDefinition[ConfigurationValue]):
|
|
149
|
+
"""Presence of an advisory-identified OpenConfig trace selector."""
|
|
150
|
+
|
|
151
|
+
key = "configuration.openconfig.risky_trace_selector"
|
|
152
|
+
label = "OpenConfig trace selector configuration"
|
|
153
|
+
commands = (TRACE_COMMAND,)
|
|
154
|
+
|
|
155
|
+
@classmethod
|
|
156
|
+
def parse(cls, commands: tuple[AntaCommand, ...]) -> Fact[ConfigurationValue]:
|
|
157
|
+
(command,) = commands
|
|
158
|
+
source = _feature_source(command)
|
|
159
|
+
if is_unsupported_optional_command(command):
|
|
160
|
+
return cls.unavailable(FactProblemKind.UNSUPPORTED, source)
|
|
161
|
+
prefix = "trace OpenConfig setting "
|
|
162
|
+
configured = False
|
|
163
|
+
for line in command.text_output.splitlines():
|
|
164
|
+
candidate = line.lstrip()
|
|
165
|
+
if candidate.startswith(prefix):
|
|
166
|
+
selectors = {selector.strip() for selector in candidate.removeprefix(prefix).split(",")}
|
|
167
|
+
configured = any(selector in selectors for selector in RISKY_TRACE_SELECTORS)
|
|
168
|
+
if configured:
|
|
169
|
+
break
|
|
170
|
+
feature = SubFeature(FeatureName.TRACE, "advisory-identified selector")
|
|
171
|
+
state = ConfigurationState.CONFIGURED if configured else ConfigurationState.NOT_CONFIGURED
|
|
172
|
+
return cls.available(ConfigurationValue(feature, state), source)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class GribiTransportFact(CommandsFactDefinition[FeatureValue]):
|
|
176
|
+
"""Effective gRIBI service state."""
|
|
177
|
+
|
|
178
|
+
key = "feature.gribi.transport"
|
|
179
|
+
label = "gRIBI service state"
|
|
180
|
+
commands = (GRIBI_COMMAND,)
|
|
181
|
+
|
|
182
|
+
@classmethod
|
|
183
|
+
def parse(cls, commands: tuple[AntaCommand, ...]) -> Fact[FeatureValue]:
|
|
184
|
+
(command,) = commands
|
|
185
|
+
source = _feature_source(command)
|
|
186
|
+
if is_unsupported_optional_command(command):
|
|
187
|
+
return cls.available(FeatureValue(FeatureName.GRIBI, FeatureState.UNSUPPORTED), source)
|
|
188
|
+
enabled = command.json_output.get("enabled")
|
|
189
|
+
if not isinstance(enabled, bool):
|
|
190
|
+
return cls.unavailable(FactProblemKind.MALFORMED, source)
|
|
191
|
+
return cls.available(FeatureValue(FeatureName.GRIBI, FeatureState.ENABLED if enabled else FeatureState.DISABLED), source)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _ssl_profile_is_valid(profile: Mapping[str, object]) -> bool:
|
|
195
|
+
"""Return whether an SSL profile has valid server identity material."""
|
|
196
|
+
if profile.get("profileState") != "valid" or profile.get("profileError") != []:
|
|
197
|
+
return False
|
|
198
|
+
return all(isinstance(value, str) and value.strip() for value in (profile.get("certName"), profile.get("keyName")))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _ssl_profile_trust_is_valid(profile: Mapping[str, object]) -> bool | None:
|
|
202
|
+
"""Return whether an SSL profile has valid trusted client certificates."""
|
|
203
|
+
if "trustedCertificates" not in profile:
|
|
204
|
+
return False
|
|
205
|
+
trusted = profile["trustedCertificates"]
|
|
206
|
+
if not isinstance(trusted, Sequence) or isinstance(trusted, str | bytes):
|
|
207
|
+
return None
|
|
208
|
+
if not trusted:
|
|
209
|
+
return False
|
|
210
|
+
return True if all(isinstance(certificate, str) and certificate.strip() for certificate in trusted) else None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _ssl_profile_has_mtls(profile_name: object, ssl_output: Mapping[str, object]) -> bool | None:
|
|
214
|
+
"""Return whether a named, valid SSL profile enforces mutual TLS."""
|
|
215
|
+
if profile_name in (None, ""):
|
|
216
|
+
return False
|
|
217
|
+
if not isinstance(profile_name, str):
|
|
218
|
+
return None
|
|
219
|
+
profiles = ssl_output.get("profileStatus")
|
|
220
|
+
if not isinstance(profiles, Mapping) or not isinstance((profile := profiles.get(profile_name)), Mapping):
|
|
221
|
+
return None
|
|
222
|
+
if not _ssl_profile_is_valid(profile):
|
|
223
|
+
return False
|
|
224
|
+
return _ssl_profile_trust_is_valid(profile)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class GnmiMtlsFact(CommandsFactDefinition[MitigationValue]):
|
|
228
|
+
"""mTLS coverage across enabled gNMI transports."""
|
|
229
|
+
|
|
230
|
+
key = "mitigation.gnmi.mtls"
|
|
231
|
+
label = "gNMI mTLS"
|
|
232
|
+
commands = (GNMI_COMMAND, SSL_PROFILE_COMMAND)
|
|
233
|
+
|
|
234
|
+
@staticmethod
|
|
235
|
+
def _enabled_transports(config: _GnmiConfig) -> tuple[Mapping[str, object], ...] | None:
|
|
236
|
+
"""Select transports whose mTLS coverage this fact must evaluate."""
|
|
237
|
+
transports: list[Mapping[str, object]] = []
|
|
238
|
+
for transport in config.transports:
|
|
239
|
+
if not isinstance(transport, Mapping) or not isinstance((enabled := transport.get("enabled")), bool):
|
|
240
|
+
return None
|
|
241
|
+
if enabled:
|
|
242
|
+
transports.append(transport)
|
|
243
|
+
if (config.service_enabled is False and transports) or not transports:
|
|
244
|
+
return None
|
|
245
|
+
return tuple(transports)
|
|
246
|
+
|
|
247
|
+
@classmethod
|
|
248
|
+
def _profile_names(cls, gnmi: AntaCommand) -> Fact[MitigationValue] | tuple[str, ...]:
|
|
249
|
+
"""Return configured gNMI SSL profiles or a result decided by gNMI output alone."""
|
|
250
|
+
source = _feature_source(gnmi)
|
|
251
|
+
if is_unsupported_optional_command(gnmi):
|
|
252
|
+
return cls.unavailable(FactProblemKind.UNSUPPORTED, source)
|
|
253
|
+
config = _deserialize_gnmi_config(gnmi.json_output)
|
|
254
|
+
if config is None:
|
|
255
|
+
return cls.unavailable(FactProblemKind.INVALID, source)
|
|
256
|
+
transports = cls._enabled_transports(config)
|
|
257
|
+
if transports is None:
|
|
258
|
+
return cls.unavailable(FactProblemKind.INVALID, source)
|
|
259
|
+
|
|
260
|
+
profile_names = tuple(transport.get("sslProfile") for transport in transports)
|
|
261
|
+
if any(profile_name in (None, "") for profile_name in profile_names):
|
|
262
|
+
return cls.available(MitigationValue(MitigationState.INEFFECTIVE), source)
|
|
263
|
+
if any(not isinstance(profile_name, str) for profile_name in profile_names):
|
|
264
|
+
return cls.unavailable(FactProblemKind.MISSING, source)
|
|
265
|
+
return tuple(profile_name for profile_name in profile_names if isinstance(profile_name, str))
|
|
266
|
+
|
|
267
|
+
@classmethod
|
|
268
|
+
def _evaluate_profiles(cls, profile_names: tuple[str, ...], ssl: AntaCommand) -> Fact[MitigationValue]:
|
|
269
|
+
"""Evaluate configured gNMI profiles using SSL-profile output."""
|
|
270
|
+
source = _feature_source(ssl)
|
|
271
|
+
if is_unsupported_optional_command(ssl):
|
|
272
|
+
return cls.unavailable(FactProblemKind.UNSUPPORTED, source)
|
|
273
|
+
|
|
274
|
+
states = tuple(_ssl_profile_has_mtls(profile_name, ssl.json_output) for profile_name in profile_names)
|
|
275
|
+
if False in states:
|
|
276
|
+
return cls.available(MitigationValue(MitigationState.INEFFECTIVE), source)
|
|
277
|
+
if None in states:
|
|
278
|
+
return cls.unavailable(FactProblemKind.MISSING, source)
|
|
279
|
+
return cls.available(MitigationValue(MitigationState.EFFECTIVE), source)
|
|
280
|
+
|
|
281
|
+
@classmethod
|
|
282
|
+
def parse(cls, commands: tuple[AntaCommand, ...]) -> Fact[MitigationValue]:
|
|
283
|
+
gnmi, ssl = commands
|
|
284
|
+
profile_names = cls._profile_names(gnmi)
|
|
285
|
+
if not isinstance(profile_names, tuple):
|
|
286
|
+
return profile_names
|
|
287
|
+
return cls._evaluate_profiles(profile_names, ssl)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class GribiMtlsFact(CommandsFactDefinition[MitigationValue]):
|
|
291
|
+
"""mTLS state for the gRIBI service."""
|
|
292
|
+
|
|
293
|
+
key = "mitigation.gribi.mtls"
|
|
294
|
+
label = "gRIBI mTLS"
|
|
295
|
+
commands = (GRIBI_COMMAND, SSL_PROFILE_COMMAND)
|
|
296
|
+
|
|
297
|
+
@classmethod
|
|
298
|
+
def _profile_name(cls, gribi: AntaCommand) -> Fact[MitigationValue] | str:
|
|
299
|
+
"""Return the gRIBI SSL profile or a result decided by gRIBI output alone."""
|
|
300
|
+
source = _feature_source(gribi)
|
|
301
|
+
if is_unsupported_optional_command(gribi):
|
|
302
|
+
return cls.unavailable(FactProblemKind.UNSUPPORTED, source)
|
|
303
|
+
enabled = gribi.json_output.get("mTls")
|
|
304
|
+
if not isinstance(enabled, bool):
|
|
305
|
+
return cls.unavailable(FactProblemKind.MISSING, source)
|
|
306
|
+
if not enabled:
|
|
307
|
+
return cls.available(MitigationValue(MitigationState.INEFFECTIVE), source)
|
|
308
|
+
|
|
309
|
+
profile_name = gribi.json_output.get("sslProfile")
|
|
310
|
+
if profile_name in (None, ""):
|
|
311
|
+
return cls.available(MitigationValue(MitigationState.INEFFECTIVE), source)
|
|
312
|
+
if not isinstance(profile_name, str):
|
|
313
|
+
return cls.unavailable(FactProblemKind.MISSING, source)
|
|
314
|
+
return profile_name
|
|
315
|
+
|
|
316
|
+
@classmethod
|
|
317
|
+
def _evaluate_profile(cls, profile_name: str, ssl: AntaCommand) -> Fact[MitigationValue]:
|
|
318
|
+
"""Evaluate the configured gRIBI profile using SSL-profile output."""
|
|
319
|
+
source = _feature_source(ssl)
|
|
320
|
+
if is_unsupported_optional_command(ssl):
|
|
321
|
+
return cls.unavailable(FactProblemKind.UNSUPPORTED, source)
|
|
322
|
+
mtls = _ssl_profile_has_mtls(profile_name, ssl.json_output)
|
|
323
|
+
if mtls is None:
|
|
324
|
+
return cls.unavailable(FactProblemKind.MISSING, source)
|
|
325
|
+
state = MitigationState.EFFECTIVE if mtls else MitigationState.INEFFECTIVE
|
|
326
|
+
return cls.available(MitigationValue(state), source)
|
|
327
|
+
|
|
328
|
+
@classmethod
|
|
329
|
+
def parse(cls, commands: tuple[AntaCommand, ...]) -> Fact[MitigationValue]:
|
|
330
|
+
gribi, ssl = commands
|
|
331
|
+
profile_name = cls._profile_name(gribi)
|
|
332
|
+
if not isinstance(profile_name, str):
|
|
333
|
+
return profile_name
|
|
334
|
+
return cls._evaluate_profile(profile_name, ssl)
|