anta 1.10.0.dev1__tar.gz → 1.10.0.dev2__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 (141) hide show
  1. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/PKG-INFO +1 -1
  2. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/_advisory/reporter/md_reporter.py +96 -49
  3. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/_advisory/reporter/reporting.py +4 -4
  4. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/constants.py +1 -0
  5. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/tests/advisories/sa_117.py +24 -8
  6. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/tests/advisories/sa_146.py +1 -1
  7. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/tests/stp.py +4 -1
  8. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta.egg-info/PKG-INFO +1 -1
  9. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/pyproject.toml +3 -3
  10. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/LICENSE +0 -0
  11. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/README.md +0 -0
  12. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/__init__.py +0 -0
  13. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/_advisory/__init__.py +0 -0
  14. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/_advisory/base.py +0 -0
  15. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/_advisory/eos_versions.py +0 -0
  16. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/_advisory/models.py +0 -0
  17. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/_advisory/optional_commands.py +0 -0
  18. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/_advisory/platforms.py +0 -0
  19. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/_advisory/remediation.py +0 -0
  20. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/_advisory/reporter/__init__.py +0 -0
  21. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/_advisory/reporter/csv_reporter.py +0 -0
  22. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/_advisory/results.py +0 -0
  23. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/_advisory/status.py +0 -0
  24. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/_eos/__init__.py +0 -0
  25. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/_eos/version.py +0 -0
  26. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/_runner.py +0 -0
  27. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/catalog.py +0 -0
  28. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/cli/__init__.py +0 -0
  29. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/cli/_main.py +0 -0
  30. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/cli/check/__init__.py +0 -0
  31. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/cli/check/commands.py +0 -0
  32. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/cli/console.py +0 -0
  33. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/cli/debug/__init__.py +0 -0
  34. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/cli/debug/commands.py +0 -0
  35. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/cli/debug/utils.py +0 -0
  36. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/cli/exec/__init__.py +0 -0
  37. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/cli/exec/commands.py +0 -0
  38. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/cli/exec/utils.py +0 -0
  39. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/cli/get/__init__.py +0 -0
  40. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/cli/get/commands.py +0 -0
  41. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/cli/get/utils.py +0 -0
  42. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/cli/nrfu/__init__.py +0 -0
  43. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/cli/nrfu/commands.py +0 -0
  44. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/cli/nrfu/utils.py +0 -0
  45. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/cli/psirt.py +0 -0
  46. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/cli/utils.py +0 -0
  47. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/custom_types.py +0 -0
  48. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/decorators.py +0 -0
  49. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/device.py +0 -0
  50. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/input_models/__init__.py +0 -0
  51. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/input_models/avt.py +0 -0
  52. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/input_models/bfd.py +0 -0
  53. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/input_models/configuration.py +0 -0
  54. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/input_models/connectivity.py +0 -0
  55. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/input_models/cvx.py +0 -0
  56. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/input_models/evpn.py +0 -0
  57. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/input_models/flow_tracking.py +0 -0
  58. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/input_models/hardware.py +0 -0
  59. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/input_models/interfaces.py +0 -0
  60. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/input_models/logging.py +0 -0
  61. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/input_models/path_selection.py +0 -0
  62. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/input_models/routing/__init__.py +0 -0
  63. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/input_models/routing/bgp.py +0 -0
  64. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/input_models/routing/generic.py +0 -0
  65. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/input_models/routing/isis.py +0 -0
  66. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/input_models/routing/ospf.py +0 -0
  67. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/input_models/security.py +0 -0
  68. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/input_models/services.py +0 -0
  69. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/input_models/snmp.py +0 -0
  70. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/input_models/stun.py +0 -0
  71. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/input_models/system.py +0 -0
  72. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/input_models/vlan.py +0 -0
  73. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/inventory/__init__.py +0 -0
  74. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/inventory/exceptions.py +0 -0
  75. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/inventory/models.py +0 -0
  76. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/logger.py +0 -0
  77. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/models.py +0 -0
  78. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/py.typed +0 -0
  79. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/reporter/__init__.py +0 -0
  80. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/reporter/csv_reporter.py +0 -0
  81. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/reporter/jinja_reporter.py +0 -0
  82. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/reporter/md_reporter.py +0 -0
  83. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/reporter/table_reporter.py +0 -0
  84. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/result_manager/__init__.py +0 -0
  85. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/result_manager/models.py +0 -0
  86. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/runner.py +0 -0
  87. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/settings.py +0 -0
  88. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/tests/__init__.py +0 -0
  89. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/tests/aaa.py +0 -0
  90. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/tests/advisories/__init__.py +0 -0
  91. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/tests/advisories/sa_140.py +0 -0
  92. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/tests/advisories/sa_142.py +0 -0
  93. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/tests/advisories/sa_147.py +0 -0
  94. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/tests/avt.py +0 -0
  95. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/tests/bfd.py +0 -0
  96. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/tests/configuration.py +0 -0
  97. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/tests/connectivity.py +0 -0
  98. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/tests/cvx.py +0 -0
  99. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/tests/evpn.py +0 -0
  100. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/tests/field_notices.py +0 -0
  101. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/tests/flow_tracking.py +0 -0
  102. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/tests/greent.py +0 -0
  103. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/tests/hardware.py +0 -0
  104. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/tests/interfaces.py +0 -0
  105. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/tests/lanz.py +0 -0
  106. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/tests/logging.py +0 -0
  107. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/tests/mlag.py +0 -0
  108. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/tests/multicast.py +0 -0
  109. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/tests/path_selection.py +0 -0
  110. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/tests/profiles.py +0 -0
  111. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/tests/ptp.py +0 -0
  112. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/tests/routing/__init__.py +0 -0
  113. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/tests/routing/bgp.py +0 -0
  114. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/tests/routing/generic.py +0 -0
  115. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/tests/routing/isis.py +0 -0
  116. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/tests/routing/ospf.py +0 -0
  117. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/tests/security.py +0 -0
  118. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/tests/services.py +0 -0
  119. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/tests/snmp.py +0 -0
  120. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/tests/software.py +0 -0
  121. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/tests/stun.py +0 -0
  122. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/tests/system.py +0 -0
  123. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/tests/vlan.py +0 -0
  124. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/tests/vxlan.py +0 -0
  125. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta/tools.py +0 -0
  126. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta.egg-info/SOURCES.txt +0 -0
  127. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta.egg-info/dependency_links.txt +0 -0
  128. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta.egg-info/entry_points.txt +0 -0
  129. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta.egg-info/requires.txt +0 -0
  130. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/anta.egg-info/top_level.txt +0 -0
  131. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/asynceapi/__init__.py +0 -0
  132. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/asynceapi/_auth.py +0 -0
  133. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/asynceapi/_constants.py +0 -0
  134. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/asynceapi/_errors.py +0 -0
  135. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/asynceapi/_models.py +0 -0
  136. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/asynceapi/_types.py +0 -0
  137. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/asynceapi/aio_portcheck.py +0 -0
  138. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/asynceapi/config_session.py +0 -0
  139. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/asynceapi/device.py +0 -0
  140. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/asynceapi/errors.py +0 -0
  141. {anta-1.10.0.dev1 → anta-1.10.0.dev2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: anta
3
- Version: 1.10.0.dev1
3
+ Version: 1.10.0.dev2
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>
@@ -7,9 +7,9 @@ from __future__ import annotations
7
7
 
8
8
  from typing import TYPE_CHECKING, ClassVar
9
9
 
10
- from anta._advisory.models import _AdvisoryMetadata, _AdvisoryVulnerabilitySeverity
10
+ from anta._advisory.models import _ADVISORY_VULNERABILITY_SEVERITY_RANK, _AdvisoryMetadata, _AdvisoryVulnerabilitySeverity
11
11
  from anta._advisory.reporter.reporting import SecurityAdvisoryRunOverviewData, _get_advisory_result
12
- from anta._advisory.results import _get_atomic_vulnerability_ids
12
+ from anta._advisory.results import _AdvisoryAtomicTestResult, _AdvisoryTestResult, _get_atomic_vulnerability_ids
13
13
  from anta.reporter.md_reporter import MDReportBase
14
14
  from anta.result_manager.models import AntaTestStatus
15
15
 
@@ -64,6 +64,27 @@ class SecurityAdvisoryMDReportBase(MDReportBase):
64
64
  """Format an ANTA result using advisory-facing terminology."""
65
65
  return f"{RESULT_ICONS[result.result]}&nbsp;{_get_advisory_result(result).title()}"
66
66
 
67
+ @staticmethod
68
+ def format_severity(severity: _AdvisoryVulnerabilitySeverity) -> str:
69
+ """Format a vulnerability severity with its identifying icon."""
70
+ return f"{SEVERITY_ICONS[severity]}&nbsp;{severity.value.title()}"
71
+
72
+ def format_remediations(self, result: TestResult | AtomicTestResult) -> str:
73
+ """Format advisory remediation entries for a Markdown table cell."""
74
+ if not isinstance(result, (_AdvisoryTestResult, _AdvisoryAtomicTestResult)):
75
+ return "-"
76
+ remediations = list(result.remediations)
77
+ atomic_remediations: list[str] = []
78
+ if isinstance(result, _AdvisoryTestResult):
79
+ atomic_remediations = [
80
+ remediation for atomic in result.atomic_results if isinstance(atomic, _AdvisoryAtomicTestResult) for remediation in atomic.remediations
81
+ ]
82
+ remediations.extend(atomic_remediations)
83
+ unique_remediations = dict.fromkeys(remediations)
84
+ if atomic_remediations:
85
+ return "<br>".join(f"•&nbsp;{self.safe_markdown(remediation)}" for remediation in unique_remediations)
86
+ return self.safe_markdown("\n".join(unique_remediations)) or "-"
87
+
67
88
 
68
89
  class ANTASecurityAdvisoryReport(SecurityAdvisoryMDReportBase):
69
90
  """Generate the title and table of contents for a security advisory report."""
@@ -72,15 +93,18 @@ class ANTASecurityAdvisoryReport(SecurityAdvisoryMDReportBase):
72
93
 
73
94
  def generate_section(self) -> None:
74
95
  """Generate the security advisory report heading and table of contents."""
75
- self.write_heading(heading_level=1)
76
- toc = "**Table of Contents:**\n\n- [ANTA Security Advisory Report](#anta-security-advisory-report)\n"
96
+ self.mdfile.write('<h1 id="anta-security-advisory-report" align="center">🛡️ ANTA Security Advisory Report 🛡️</h1>\n\n')
97
+ toc = "**Table of Contents:**\n\n"
77
98
  if self.groups:
78
- toc += " - [Advisory Exposure Summary](#advisory-exposure-summary)\n - [Security Advisory Details](#security-advisory-details)\n"
79
- toc += " - [Security Advisory Run Overview](#security-advisory-run-overview)"
99
+ toc += "- [Advisory Assessment Summary](#advisory-assessment-summary)\n- [Security Advisory Details](#security-advisory-details)\n"
100
+ for group in self.groups:
101
+ advisory = group.advisory
102
+ toc += f" - [SA{advisory.sa_number}: {self.safe_markdown(advisory.title)}](#sa-{advisory.sa_number.lower()})\n"
103
+ toc += "- [Run Overview](#run-overview)"
80
104
  self.mdfile.write(toc + "\n\n")
81
105
 
82
106
 
83
- class AdvisoryExposureSummary(SecurityAdvisoryMDReportBase):
107
+ class AdvisoryAssessmentSummary(SecurityAdvisoryMDReportBase):
84
108
  """Generate a compact status summary grouped by security advisory."""
85
109
 
86
110
  ICON = "📊"
@@ -107,8 +131,7 @@ class AdvisoryExposureSummary(SecurityAdvisoryMDReportBase):
107
131
  for group in self.groups:
108
132
  advisory = group.advisory
109
133
  advisory_link = f"[SA{advisory.sa_number}: {self.safe_markdown(advisory.title)}](#sa-{advisory.sa_number.lower()})"
110
- advisory_severity = group.severity
111
- severity = f"{SEVERITY_ICONS[advisory_severity]}&nbsp;{advisory_severity.value.title()}"
134
+ severity = self.format_severity(group.severity)
112
135
  devices = len({result.name for result in group.results})
113
136
  yield (
114
137
  f"| {advisory_link} | {severity} | {devices} "
@@ -121,7 +144,7 @@ class AdvisoryExposureSummary(SecurityAdvisoryMDReportBase):
121
144
  )
122
145
 
123
146
  def generate_section(self) -> None:
124
- """Generate the advisory exposure summary section."""
147
+ """Generate the advisory assessment summary section."""
125
148
  self.write_heading(heading_level=2)
126
149
  self.write_table(table_heading=self.TABLE_HEADING)
127
150
 
@@ -133,29 +156,34 @@ class SecurityAdvisoryDetails(SecurityAdvisoryMDReportBase):
133
156
 
134
157
  def _write_vulnerabilities(self, advisory: _AdvisoryMetadata) -> None:
135
158
  """Write vulnerability details for an advisory."""
136
- self.mdfile.write("#### Vulnerabilities\n\n")
137
- heading = self.generate_table_heading(["Vulnerability", "Description", "Severity"])
138
- self.mdfile.write("\n".join(heading) + "\n")
139
- for vulnerability in advisory.vulnerabilities:
159
+ # NOTE: Nested tables are not supported consistently by every Markdown renderer. Prefix every table line and pad it with quoted blank lines to maximize
160
+ # compatibility across renderers that support Markdown tables.
161
+ heading = self.generate_table_heading(["Vulnerability", "Severity", "Description"])
162
+ self.mdfile.write("\n".join(f"> {line}" for line in heading) + "\n")
163
+ vulnerabilities = sorted(
164
+ advisory.vulnerabilities,
165
+ key=lambda vulnerability: (-_ADVISORY_VULNERABILITY_SEVERITY_RANK[vulnerability.severity], vulnerability.id.casefold()),
166
+ )
167
+ for vulnerability in vulnerabilities:
140
168
  vulnerability_id = self.safe_markdown(vulnerability.id)
141
169
  description = self.safe_markdown(vulnerability.description)
142
- self.mdfile.write(f"| {vulnerability_id} | {description} | {vulnerability.severity.value.title()} |\n")
143
- self.mdfile.write("\n")
170
+ self.mdfile.write(f"> | {vulnerability_id} | {self.format_severity(vulnerability.severity)} | {description} |\n")
171
+ self.mdfile.write(">\n\n")
144
172
 
145
173
  def _write_findings(self, group: AdvisoryResultGroup) -> None:
146
174
  """Write per-device findings for an advisory."""
147
175
  # TODO: When revisiting Markdown reports, fall back to atomic descriptions and messages if the parent result has no messages.
148
- # TODO: Render parent and atomic remediation lists once the Markdown remediation presentation is defined.
149
176
  self.mdfile.write("#### 🔎 Device Findings\n\n")
150
177
  if self.config.expand_results:
151
178
  self._write_expanded_findings(group)
152
179
  return
153
180
 
154
- heading = self.generate_table_heading(["Device", "Test", "Result", "Messages"])
181
+ heading = self.generate_table_heading(["Device", "Result", "Findings", "Remediations"])
155
182
  self.mdfile.write("\n".join(heading) + "\n")
156
183
  for result in group.results:
157
- messages = self.safe_markdown("<br>".join(result.messages)) or "-"
158
- self.mdfile.write(f"| {self.safe_markdown(result.name)} | {self.safe_markdown(result.test)} | {self.format_advisory_result(result)} | {messages} |\n")
184
+ findings = self.safe_markdown("<br>".join(result.messages)) or "-"
185
+ remediation = self.format_remediations(result)
186
+ self.mdfile.write(f"| {self.safe_markdown(result.name)} | {self.format_advisory_result(result)} | {findings} | {remediation} |\n")
159
187
 
160
188
  @staticmethod
161
189
  def _atomic_summary(result: TestResult) -> str:
@@ -175,29 +203,39 @@ class SecurityAdvisoryDetails(SecurityAdvisoryMDReportBase):
175
203
 
176
204
  def _write_expanded_findings(self, group: AdvisoryResultGroup) -> None:
177
205
  """Write parent advisory results followed by their actual detailed issue results."""
178
- heading = self.generate_table_heading(["Device", "Test", "Description", "Vulnerability ID(s)", "Result", "Messages"])
206
+ heading = self.generate_table_heading(["Device", "Description", "Vulnerability ID(s)", "Result", "Findings", "Remediations"])
179
207
  self.mdfile.write("\n".join(heading) + "\n")
208
+ vulnerability_by_id = {vulnerability.id: vulnerability for vulnerability in group.advisory.vulnerabilities}
180
209
  for result in group.results:
181
210
  has_details = bool(result.atomic_results)
182
211
  if has_details:
183
- messages = f"**Detailed findings:** {self._atomic_summary(result)}"
212
+ findings = f"**Detailed findings:** {self._atomic_summary(result)}"
184
213
  if result.messages:
185
- messages += f"<br>**Overall evidence:** {self.safe_markdown('<br>'.join(result.messages))}"
214
+ findings += f"<br>**Overall evidence:** {self.safe_markdown('<br>'.join(result.messages))}"
186
215
  else:
187
- messages = self.safe_markdown("<br>".join(result.messages)) or "-"
216
+ findings = self.safe_markdown("<br>".join(result.messages)) or "-"
188
217
  description = self.safe_markdown(result.description) or "-"
189
- self.mdfile.write(
190
- f"| {self.safe_markdown(result.name)} | {self.safe_markdown(result.test)} | {description} | - "
191
- f"| {self.format_advisory_result(result)} | {messages} |\n"
192
- )
218
+ remediation = self.format_remediations(result)
219
+ self.mdfile.write(f"| {self.safe_markdown(result.name)} | {description} | - | {self.format_advisory_result(result)} | {findings} | {remediation} |\n")
193
220
  for index, atomic in enumerate(result.atomic_results):
194
221
  tree = "└──" if index == len(result.atomic_results) - 1 else "├──"
195
- atomic_description = self.safe_markdown(atomic.description) or "-"
196
- description = f"&nbsp;&nbsp;{tree}&nbsp;{atomic_description}"
197
222
  vulnerability_ids = _get_atomic_vulnerability_ids(atomic)
198
- vulnerabilities = self.safe_markdown(", ".join(vulnerability_ids)) if vulnerability_ids else "-"
199
- atomic_messages = self.safe_markdown("<br>".join(atomic.messages)) or "-"
200
- self.mdfile.write(f"| | | {description} | {vulnerabilities} | {self.format_advisory_result(atomic)} | {atomic_messages} |\n")
223
+ if vulnerability_ids:
224
+ atomic_description = "<br>".join(self.safe_markdown(vulnerability_by_id[vulnerability_id].description) for vulnerability_id in vulnerability_ids)
225
+ else:
226
+ atomic_description = self.safe_markdown(atomic.description) or "-"
227
+ description = f"&nbsp;&nbsp;{tree}&nbsp;{atomic_description}"
228
+ vulnerabilities = (
229
+ "<br>".join(
230
+ f"{SEVERITY_ICONS[vulnerability_by_id[vulnerability_id].severity]}&nbsp;{self.safe_markdown(vulnerability_id)}"
231
+ for vulnerability_id in vulnerability_ids
232
+ )
233
+ if vulnerability_ids
234
+ else "-"
235
+ )
236
+ atomic_findings = self.safe_markdown("<br>".join(atomic.messages)) or "-"
237
+ remediation = self.format_remediations(atomic)
238
+ self.mdfile.write(f"| | {description} | {vulnerabilities} | {self.format_advisory_result(atomic)} | {atomic_findings} | {remediation} |\n")
201
239
 
202
240
  def generate_section(self) -> None:
203
241
  """Generate detailed advisory metadata and findings."""
@@ -206,10 +244,11 @@ class SecurityAdvisoryDetails(SecurityAdvisoryMDReportBase):
206
244
  advisory = group.advisory
207
245
  anchor = f"sa-{advisory.sa_number.lower()}"
208
246
  title = self.safe_markdown(advisory.title)
209
- self.mdfile.write(f'### [SA{advisory.sa_number}: {title}]({advisory.url}) <a id="{anchor}"></a>\n\n')
247
+ self.mdfile.write(f'### SA{advisory.sa_number}: {title} <a id="{anchor}"></a>\n\n')
210
248
  advisory_severity = group.severity
211
- severity = f"{SEVERITY_ICONS[advisory_severity]} **Severity:** {advisory_severity.value.title()}"
212
- self.mdfile.write(f"{severity}\n\n{self.safe_markdown(advisory.description)}\n\n")
249
+ severity = f"**Severity:** {SEVERITY_ICONS[advisory_severity]} {advisory_severity.value.title()}"
250
+ description = self.safe_markdown(advisory.description)
251
+ self.mdfile.write(f"> {severity}\\\n> **URL:** <{advisory.url}>\n>\n> {description}\n>\n")
213
252
  self._write_vulnerabilities(advisory)
214
253
  self._write_findings(group)
215
254
  if index < len(self.groups) - 1:
@@ -217,14 +256,11 @@ class SecurityAdvisoryDetails(SecurityAdvisoryMDReportBase):
217
256
  self.mdfile.write("\n")
218
257
 
219
258
 
220
- class SecurityAdvisoryRunOverview(SecurityAdvisoryMDReportBase):
259
+ class RunOverview(SecurityAdvisoryMDReportBase):
221
260
  """Generate the Run Overview section for a security advisory report."""
222
261
 
223
262
  ICON = "📋"
224
263
 
225
- _TABLE_COLUMNS: ClassVar[list[str]] = ["⚙️ Run Metric", "📝 Details"]
226
- TABLE_HEADING: ClassVar[list[str]] = MDReportBase.generate_table_heading(columns=_TABLE_COLUMNS)
227
-
228
264
  def _format_row_value(self, value: object) -> str:
229
265
  """Format one run overview value for Markdown table rendering."""
230
266
  if isinstance(value, list | tuple):
@@ -241,14 +277,25 @@ class SecurityAdvisoryRunOverview(SecurityAdvisoryMDReportBase):
241
277
  return "<br>".join(items)
242
278
  return self.safe_markdown(self.format_value(value))
243
279
 
244
- def generate_rows(self) -> Generator[str, None, None]:
245
- """Generate the rows for the security advisory run overview table."""
246
- run_overview = SecurityAdvisoryRunOverviewData.from_context(self.run_context)
247
- for label, value in run_overview.iter_rows():
248
- row_value = self._format_row_value(value)
249
- yield f"| **{label}** | {row_value} |\n"
250
-
251
280
  def generate_section(self) -> None:
252
- """Generate the security advisory run overview section."""
281
+ """Generate the Run Overview section."""
253
282
  self.write_heading(heading_level=2)
254
- self.write_table(table_heading=self.TABLE_HEADING, last_table=True)
283
+ run_overview = SecurityAdvisoryRunOverviewData.from_context(self.run_context)
284
+ start_time = self._format_row_value(run_overview.test_execution_start_time)
285
+ end_time = self._format_row_value(run_overview.test_execution_end_time)
286
+ duration = self._format_row_value(run_overview.total_duration)
287
+ overview_metrics: list[tuple[str, object]] = [
288
+ ("ANTA Version", run_overview.anta_version),
289
+ ("Duration", f"{duration} ({start_time} → {end_time})"),
290
+ ("Security Advisories Tested", run_overview.security_advisories_assessed),
291
+ ("Total Devices In Inventory", run_overview.total_devices_in_inventory),
292
+ ("Devices Assessed", run_overview.devices_assessed),
293
+ ("Devices Unreachable At Setup", run_overview.devices_unreachable_at_setup),
294
+ ("Devices Filtered At Setup", run_overview.devices_filtered_at_setup),
295
+ ("Filters Applied", run_overview.filters_applied),
296
+ ]
297
+ if run_overview.warnings_at_setup:
298
+ overview_metrics.append(("Warnings At Setup", run_overview.warnings_at_setup))
299
+ self.mdfile.write("| | |\n| :- | :- |\n")
300
+ for label, value in overview_metrics:
301
+ self.mdfile.write(f"| **{label}** | {self._format_row_value(value)} |\n")
@@ -220,19 +220,19 @@ def generate_security_advisory_md_report(
220
220
  validate_advisory_results(run_context.manager.results)
221
221
 
222
222
  from anta._advisory.reporter.md_reporter import ( # noqa: PLC0415
223
- AdvisoryExposureSummary,
223
+ AdvisoryAssessmentSummary,
224
224
  ANTASecurityAdvisoryReport,
225
+ RunOverview,
225
226
  SecurityAdvisoryDetails,
226
- SecurityAdvisoryRunOverview,
227
227
  )
228
228
 
229
229
  try:
230
230
  with md_filename.open("w", encoding="utf-8") as mdfile:
231
231
  ANTASecurityAdvisoryReport(mdfile, report, config, run_context).generate_section()
232
232
  if report.groups:
233
- AdvisoryExposureSummary(mdfile, report, config, run_context).generate_section()
233
+ AdvisoryAssessmentSummary(mdfile, report, config, run_context).generate_section()
234
234
  SecurityAdvisoryDetails(mdfile, report, config, run_context).generate_section()
235
- SecurityAdvisoryRunOverview(mdfile, report, config, run_context).generate_section()
235
+ RunOverview(mdfile, report, config, run_context).generate_section()
236
236
  except OSError as exc:
237
237
  message = f"OSError caught while writing the Markdown file '{md_filename.resolve()}'."
238
238
  anta_log_exception(exc, message, logger)
@@ -86,6 +86,7 @@ UNSUPPORTED_PLATFORM_ERRORS = [
86
86
  "Invalid input (at token 1: 'supervisor-peer:/mnt/flash')",
87
87
  "Incomplete command (at token 1: 'module')",
88
88
  "Incomplete command (at token 1: 'ptp')",
89
+ "Invalid input (at token 1: 'directflow')",
89
90
  ]
90
91
  """Error messages indicating platform or hardware unsupported commands. Includes both general hardware
91
92
  platform errors and specific ASIC family limitations.
@@ -75,16 +75,32 @@ ADVISORY = _AdvisoryMetadata(
75
75
  )
76
76
 
77
77
 
78
- def _evaluate_gnmi_transport_enabled(gnmi_output: Mapping[str, object]) -> bool | None:
79
- """Return whether at least one gNMI transport is enabled."""
78
+ def _gnmi_transport_values(gnmi_output: Mapping[str, object]) -> tuple[object, ...] | None:
79
+ """Return transport values from nested or flattened EOS gNMI output.
80
+
81
+ EOS 4.33 eAPI revision 1 exposes a single transport at the top level, while
82
+ newer schemas expose named transports below ``transports``.
83
+ """
84
+ if "transports" not in gnmi_output:
85
+ enabled = gnmi_output.get("enabled")
86
+ if not isinstance(enabled, bool):
87
+ return None
88
+ return (gnmi_output,) if enabled else ()
89
+
80
90
  transports = gnmi_output.get("transports")
81
91
  if not isinstance(transports, Mapping):
82
92
  return None
83
- if not transports:
84
- return False
93
+ return tuple(transports.values())
94
+
95
+
96
+ def _evaluate_gnmi_transport_enabled(gnmi_output: Mapping[str, object]) -> bool | None:
97
+ """Return whether at least one gNMI transport is enabled."""
98
+ transports = _gnmi_transport_values(gnmi_output)
99
+ if transports is None:
100
+ return None
85
101
 
86
102
  unknown = False
87
- for transport in transports.values():
103
+ for transport in transports:
88
104
  if not isinstance(transport, Mapping):
89
105
  unknown = True
90
106
  continue
@@ -99,12 +115,12 @@ def _evaluate_gnmi_transport_enabled(gnmi_output: Mapping[str, object]) -> bool
99
115
 
100
116
  def _evaluate_gnmi_accounting_enabled(gnmi_output: Mapping[str, object]) -> bool | None:
101
117
  """Return whether an enabled gNMI transport has accounting enabled."""
102
- transports = gnmi_output.get("transports")
103
- if not isinstance(transports, Mapping):
118
+ transports = _gnmi_transport_values(gnmi_output)
119
+ if transports is None:
104
120
  return None
105
121
 
106
122
  unknown = False
107
- for transport in transports.values():
123
+ for transport in transports:
108
124
  if not isinstance(transport, Mapping):
109
125
  unknown = True
110
126
  continue
@@ -474,7 +474,7 @@ class VerifySA146(OptionalCommandsMixin, _AntaAdvisoryTest):
474
474
  AntaCommand(command="show version detail", revision=1),
475
475
  OptionalAntaCommand(command="show management api gnmi", revision=1),
476
476
  OptionalAntaCommand(command="show management api gribi", revision=1),
477
- OptionalAntaCommand(command="show daemon TerminAttr", revision=1),
477
+ OptionalAntaCommand(command="show daemon", revision=1),
478
478
  OptionalAntaCommand(command="show running-config section grpcaddr", ofmt="text"),
479
479
  OptionalAntaCommand(command="show management security ssl profile", revision=1),
480
480
  ]
@@ -316,7 +316,10 @@ class VerifyStpTopologyChanges(AntaTest):
316
316
  # Verifies the number of changes across all interfaces
317
317
  for topology, topology_details in stp_topologies.items():
318
318
  for interface, details in topology_details.get("interfaces", {}).items():
319
- if (num_of_changes := details.get("numChanges")) > self.inputs.threshold:
319
+ num_of_changes = details.get("numChanges")
320
+ if num_of_changes is None:
321
+ self.result.is_failure(f"Topology: {topology} Interface: {interface} - Number of changes counter not found")
322
+ elif num_of_changes > self.inputs.threshold:
320
323
  self.result.is_failure(
321
324
  f"Topology: {topology} Interface: {interface} - Number of changes not within the threshold - Expected: "
322
325
  f"{self.inputs.threshold} Actual: {num_of_changes}"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: anta
3
- Version: 1.10.0.dev1
3
+ Version: 1.10.0.dev2
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>
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
 
6
6
  [project]
7
7
  name = "anta"
8
- version = "v1.10.0.dev1"
8
+ version = "v1.10.0.dev2"
9
9
  readme = "README.md"
10
10
  authors = [{ name = "Arista Networks ANTA maintainers", email = "anta-dev@arista.com" }]
11
11
  maintainers = [
@@ -94,7 +94,7 @@ doc = [
94
94
  "griffe >=2.2.0,<2.3",
95
95
  "griffe-warnings-deprecated~=1.1.1",
96
96
  "mike @ git+https://github.com/squidfunk/mike.git@2d4ad799442f4592db8ad53b179bfb33db8c69ac",
97
- "zensical==0.0.56",
97
+ "zensical==0.0.57",
98
98
  "mkdocs~=1.6",
99
99
  "mkdocs-autorefs~=1.4",
100
100
  "mkdocs-material~=9.7",
@@ -121,7 +121,7 @@ namespaces = false
121
121
  # Version
122
122
  ################################
123
123
  [tool.bumpver]
124
- current_version = "1.10.0.dev1"
124
+ current_version = "1.10.0.dev2"
125
125
  version_pattern = "MAJOR.MINOR.PATCH[.PYTAGNUM]"
126
126
  commit_message = "bump: Version {old_version} -> {new_version}"
127
127
  commit = true
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes