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.
Files changed (164) hide show
  1. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/PKG-INFO +1 -1
  2. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/_advisory/base.py +35 -4
  3. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/_advisory/eos_versions.py +5 -13
  4. anta-1.10.0.dev5/anta/_advisory/facts/__init__.py +4 -0
  5. anta-1.10.0.dev5/anta/_advisory/facts/eos.py +91 -0
  6. anta-1.10.0.dev5/anta/_advisory/facts/management.py +334 -0
  7. anta-1.10.0.dev5/anta/_advisory/facts/models.py +215 -0
  8. anta-1.10.0.dev5/anta/_advisory/facts/platform.py +34 -0
  9. anta-1.10.0.dev5/anta/_advisory/facts/redirection.py +322 -0
  10. anta-1.10.0.dev5/anta/_advisory/facts/software.py +77 -0
  11. anta-1.10.0.dev5/anta/_advisory/facts/ssh.py +152 -0
  12. anta-1.10.0.dev5/anta/_advisory/facts/terminattr.py +104 -0
  13. anta-1.10.0.dev5/anta/_advisory/findings/__init__.py +4 -0
  14. anta-1.10.0.dev5/anta/_advisory/findings/models.py +239 -0
  15. anta-1.10.0.dev5/anta/_advisory/findings/projection.py +129 -0
  16. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/_advisory/models.py +2 -0
  17. anta-1.10.0.dev5/anta/_advisory/remediation.py +372 -0
  18. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/_advisory/reporter/csv_reporter.py +20 -28
  19. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/_advisory/reporter/md_reporter.py +47 -95
  20. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/_advisory/reporter/reporting.py +7 -12
  21. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/_advisory/results.py +10 -7
  22. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/_advisory/status.py +20 -10
  23. anta-1.10.0.dev5/anta/_advisory/version.py +30 -0
  24. anta-1.10.0.dev5/anta/_eos/parsing.py +73 -0
  25. anta-1.10.0.dev5/anta/_eos/platform.py +677 -0
  26. anta-1.10.0.dev5/anta/_eos/version.py +87 -0
  27. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/cli/get/commands.py +5 -1
  28. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/cli/get/utils.py +6 -0
  29. anta-1.10.0.dev5/anta/cli/nrfu/__init__.py +138 -0
  30. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/cli/nrfu/utils.py +6 -7
  31. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/cli/psirt.py +83 -21
  32. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/cli/utils.py +30 -0
  33. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/device.py +93 -12
  34. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/advisories/__init__.py +9 -9
  35. anta-1.10.0.dev5/anta/tests/advisories/sa_117.py +180 -0
  36. anta-1.10.0.dev5/anta/tests/advisories/sa_140.py +150 -0
  37. anta-1.10.0.dev5/anta/tests/advisories/sa_142.py +487 -0
  38. anta-1.10.0.dev5/anta/tests/advisories/sa_146.py +316 -0
  39. anta-1.10.0.dev5/anta/tests/advisories/sa_147.py +289 -0
  40. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/interfaces.py +1 -1
  41. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/routing/ospf.py +3 -0
  42. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/system.py +6 -6
  43. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta.egg-info/PKG-INFO +1 -1
  44. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta.egg-info/SOURCES.txt +15 -1
  45. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/pyproject.toml +6 -5
  46. anta-1.10.0.dev2/anta/_advisory/platforms.py +0 -77
  47. anta-1.10.0.dev2/anta/_advisory/remediation.py +0 -78
  48. anta-1.10.0.dev2/anta/_eos/version.py +0 -49
  49. anta-1.10.0.dev2/anta/cli/nrfu/__init__.py +0 -179
  50. anta-1.10.0.dev2/anta/tests/advisories/sa_117.py +0 -295
  51. anta-1.10.0.dev2/anta/tests/advisories/sa_140.py +0 -169
  52. anta-1.10.0.dev2/anta/tests/advisories/sa_142.py +0 -738
  53. anta-1.10.0.dev2/anta/tests/advisories/sa_146.py +0 -523
  54. anta-1.10.0.dev2/anta/tests/advisories/sa_147.py +0 -434
  55. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/LICENSE +0 -0
  56. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/README.md +0 -0
  57. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/__init__.py +0 -0
  58. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/_advisory/__init__.py +0 -0
  59. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/_advisory/optional_commands.py +0 -0
  60. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/_advisory/reporter/__init__.py +0 -0
  61. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/_eos/__init__.py +0 -0
  62. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/_runner.py +0 -0
  63. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/catalog.py +0 -0
  64. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/cli/__init__.py +0 -0
  65. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/cli/_main.py +0 -0
  66. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/cli/check/__init__.py +0 -0
  67. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/cli/check/commands.py +0 -0
  68. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/cli/console.py +0 -0
  69. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/cli/debug/__init__.py +0 -0
  70. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/cli/debug/commands.py +0 -0
  71. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/cli/debug/utils.py +0 -0
  72. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/cli/exec/__init__.py +0 -0
  73. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/cli/exec/commands.py +0 -0
  74. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/cli/exec/utils.py +0 -0
  75. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/cli/get/__init__.py +0 -0
  76. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/cli/nrfu/commands.py +0 -0
  77. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/constants.py +0 -0
  78. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/custom_types.py +0 -0
  79. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/decorators.py +0 -0
  80. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/input_models/__init__.py +0 -0
  81. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/input_models/avt.py +0 -0
  82. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/input_models/bfd.py +0 -0
  83. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/input_models/configuration.py +0 -0
  84. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/input_models/connectivity.py +0 -0
  85. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/input_models/cvx.py +0 -0
  86. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/input_models/evpn.py +0 -0
  87. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/input_models/flow_tracking.py +0 -0
  88. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/input_models/hardware.py +0 -0
  89. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/input_models/interfaces.py +0 -0
  90. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/input_models/logging.py +0 -0
  91. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/input_models/path_selection.py +0 -0
  92. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/input_models/routing/__init__.py +0 -0
  93. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/input_models/routing/bgp.py +0 -0
  94. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/input_models/routing/generic.py +0 -0
  95. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/input_models/routing/isis.py +0 -0
  96. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/input_models/routing/ospf.py +0 -0
  97. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/input_models/security.py +0 -0
  98. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/input_models/services.py +0 -0
  99. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/input_models/snmp.py +0 -0
  100. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/input_models/stun.py +0 -0
  101. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/input_models/system.py +0 -0
  102. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/input_models/vlan.py +0 -0
  103. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/inventory/__init__.py +0 -0
  104. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/inventory/exceptions.py +0 -0
  105. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/inventory/models.py +0 -0
  106. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/logger.py +0 -0
  107. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/models.py +0 -0
  108. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/py.typed +0 -0
  109. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/reporter/__init__.py +0 -0
  110. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/reporter/csv_reporter.py +0 -0
  111. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/reporter/jinja_reporter.py +0 -0
  112. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/reporter/md_reporter.py +0 -0
  113. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/reporter/table_reporter.py +0 -0
  114. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/result_manager/__init__.py +0 -0
  115. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/result_manager/models.py +0 -0
  116. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/runner.py +0 -0
  117. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/settings.py +0 -0
  118. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/__init__.py +0 -0
  119. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/aaa.py +0 -0
  120. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/avt.py +0 -0
  121. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/bfd.py +0 -0
  122. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/configuration.py +0 -0
  123. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/connectivity.py +0 -0
  124. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/cvx.py +0 -0
  125. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/evpn.py +0 -0
  126. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/field_notices.py +0 -0
  127. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/flow_tracking.py +0 -0
  128. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/greent.py +0 -0
  129. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/hardware.py +0 -0
  130. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/lanz.py +0 -0
  131. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/logging.py +0 -0
  132. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/mlag.py +0 -0
  133. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/multicast.py +0 -0
  134. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/path_selection.py +0 -0
  135. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/profiles.py +0 -0
  136. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/ptp.py +0 -0
  137. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/routing/__init__.py +0 -0
  138. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/routing/bgp.py +0 -0
  139. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/routing/generic.py +0 -0
  140. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/routing/isis.py +0 -0
  141. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/security.py +0 -0
  142. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/services.py +0 -0
  143. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/snmp.py +0 -0
  144. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/software.py +0 -0
  145. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/stp.py +0 -0
  146. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/stun.py +0 -0
  147. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/vlan.py +0 -0
  148. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tests/vxlan.py +0 -0
  149. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta/tools.py +0 -0
  150. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta.egg-info/dependency_links.txt +0 -0
  151. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta.egg-info/entry_points.txt +0 -0
  152. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta.egg-info/requires.txt +0 -0
  153. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/anta.egg-info/top_level.txt +0 -0
  154. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/asynceapi/__init__.py +0 -0
  155. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/asynceapi/_auth.py +0 -0
  156. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/asynceapi/_constants.py +0 -0
  157. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/asynceapi/_errors.py +0 -0
  158. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/asynceapi/_models.py +0 -0
  159. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/asynceapi/_types.py +0 -0
  160. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/asynceapi/aio_portcheck.py +0 -0
  161. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/asynceapi/config_session.py +0 -0
  162. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/asynceapi/device.py +0 -0
  163. {anta-1.10.0.dev2 → anta-1.10.0.dev5}/asynceapi/errors.py +0 -0
  164. {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.dev2
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
- """Set subclass identity and validate required advisory attributes."""
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.device import DeviceVersion
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
- device_version: DeviceVersion | None,
113
- version_matrix: Sequence[VersionRule],
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(device_version)
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,4 @@
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
+ """Normalized facts for security-advisory assessments."""
@@ -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)