diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
new file mode 100644
index 0000000..7530c79
--- /dev/null
+++ b/.gitea/workflows/ci.yml
@@ -0,0 +1,15 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ tags: ["v*"]
+ pull_request:
+
+jobs:
+ driver:
+ uses: NAPALM/driver-standard/.gitea/workflows/driver.yml@main
+ with:
+ standard-ref: main
+ secrets:
+ PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 3b4fdc1..cca36af 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,6 @@ build/
dist/
.venv/
.pytest_cache/
+.coverage
+.mypy_cache/
+.ruff_cache/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4bc7107..71e63f3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,12 +1,35 @@
# Changelog
-## 0.1.1
+All notable changes to this driver. The format follows
+[Keep a Changelog](https://keepachangelog.com/), versions follow
+[Semantic Versioning](https://semver.org/).
+## [Unreleased]
+
+## [0.2.0] - 2026-10-23
+
+The first release on PyPI.
+
+### Changed
+- The driver follows the NAPALM driver standard
+ (https://git.netork.io/NAPALM/driver-standard): CI on Gitea runs ruff, mypy, the
+ tests on Python 3.10–3.13 and at the lowest declared versions, bandit, pip-audit and
+ a build check, and a release tag publishes to PyPI. Python 3.10 stays the floor; the
+ license is declared as the SPDX string `Apache-2.0`, next to the LICENSE file and
+ `py.typed` the package already shipped. napalm-device-types now comes from PyPI
+ (4.21.1 or later).
+- AMT's answers are parsed with defusedxml (a new dependency). An answer that carries a
+ DTD is refused in any encoding; before, one in UTF-16 got through.
+
+## [0.1.1] - 2026-10-18
+
+### Fixed
- `SNMP_CAPABLE = False`: AMT runs no SNMP agent, so netOrk does not poll it over SNMP
as it does other management controllers.
-## 0.1.0
+## [0.1.0] - 2026-10-18
+### Added
- `amt` driver (`napalm_amt.AmtDriver`), a `ManagementControllerDriver` for Intel AMT over
WS-Management: facts, AMT's network port, hardware inventory with the SMBIOS system
UUID, firmware (AMT with build, BIOS), power state and power control, the hardware event
diff --git a/README.md b/README.md
index 44d46e1..1aba640 100644
--- a/README.md
+++ b/README.md
@@ -29,8 +29,8 @@ nothing with napalm-redfish beyond the role, so it lives here.
`optional_args`: `port` (16992; 16993 means TLS), `tls` (overrides what the port
implies), `verify_ssl` (`True`, a CA file path, or `False`), `retries` (1).
-The WS-Management client in `napalm_amt/wsman.py` is self-contained (requests and
-ElementTree). Neither `pywsman` (a C binding) nor `python-amt` (unmaintained, power only)
+The WS-Management client in `napalm_amt/wsman.py` is self-contained (requests, and
+defusedxml to parse the answers). Neither `pywsman` (a C binding) nor `python-amt` (unmaintained, power only)
is used.
## What AMT knows, and what it does not
@@ -54,12 +54,13 @@ of its own in the MEBx ([docs/lenovo-m910x.md](docs/lenovo-m910x.md)).
## Development
+The rules are in [STANDARDS.md](https://git.netork.io/NAPALM/driver-standard/src/branch/main/STANDARDS.md).
+
```bash
python -m venv .venv
-.venv/bin/pip install -e ../napalm-device-types # not on PyPI
-.venv/bin/pip install -e '.[test]' ruff mypy types-requests
-.venv/bin/python -m pytest
-.venv/bin/ruff check . && .venv/bin/mypy napalm_amt
+.venv/bin/pip install -e '.[dev]'
+.venv/bin/pytest --cov
+.venv/bin/ruff format --check . && .venv/bin/ruff check . && .venv/bin/mypy napalm_amt
```
## Conventions
diff --git a/napalm_amt/record.py b/napalm_amt/record.py
index d1752aa..97741fe 100644
--- a/napalm_amt/record.py
+++ b/napalm_amt/record.py
@@ -107,7 +107,9 @@ def _landing_page(host: str, port: int, tls: bool, out: Path) -> None:
"""The page discovery fetches, without credentials, with its status line and headers."""
url = f"{'https' if tls else 'http'}://{host}:{port}/"
try:
- response = requests.get(url, timeout=10, verify=False, allow_redirects=True)
+ # Recorded as discovery sees it: no credentials go out, and AMT's own certificate
+ # on 16993 is self-signed unless one was provisioned.
+ response = requests.get(url, timeout=10, verify=False, allow_redirects=True) # nosec B501
except requests.RequestException as exc:
print(f"landing page: {exc}", file=sys.stderr)
return
diff --git a/napalm_amt/wsman.py b/napalm_amt/wsman.py
index 5fb44fa..b9a3d13 100644
--- a/napalm_amt/wsman.py
+++ b/napalm_amt/wsman.py
@@ -13,14 +13,21 @@ The envelopes follow Intel's own go-wsman-messages, which every AMT since versio
from __future__ import annotations
import itertools
-import xml.etree.ElementTree as ET
from collections.abc import Callable, Iterable, Mapping
-from typing import Any
-from xml.sax.saxutils import escape, quoteattr
+from typing import TYPE_CHECKING, Any
+
+# escape and quoteattr build the request envelopes; nothing here parses with SAX.
+from xml.sax.saxutils import escape, quoteattr # nosec B406
import requests
+from defusedxml import DefusedXmlException
+from defusedxml.ElementTree import ParseError, fromstring
from requests.auth import HTTPDigestAuth
+if TYPE_CHECKING:
+ # For the annotations only; answers are parsed by defusedxml.
+ import xml.etree.ElementTree as ET # nosec B405
+
SOAP_NS = "http://www.w3.org/2003/05/soap-envelope"
ADDRESSING_NS = "http://schemas.xmlsoap.org/ws/2004/08/addressing"
WSMAN_NS = "http://schemas.dmtf.org/wbem/wsman/1/wsman.xsd"
@@ -267,14 +274,13 @@ class WsmanTransport:
"""The SOAP body of a response; a fault, a refusal or anything else raises."""
if response.status_code == 401:
raise WsmanAuthError(f"{self.url}: credentials refused (HTTP 401)")
- body = None
- # A DTD has no business in a WS-Management response; refusing one keeps entity
- # expansion out of the parser.
- if b"=64"]
+requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta"
[project]
name = "napalm-amt"
-version = "0.1.1"
+version = "0.2.0"
description = "NAPALM driver for Intel Active Management Technology (AMT) over WS-Management"
readme = "README.md"
-license = { file = "LICENSE" }
+license = "Apache-2.0"
requires-python = ">=3.10"
dependencies = [
"napalm>=5.0",
- # Not on PyPI: install from git before this package (see README).
- "napalm-device-types>=4.16",
+ "napalm-device-types>=4.21.1",
"requests>=2.31",
+ "defusedxml>=0.7",
]
classifiers = [
- "License :: OSI Approved :: Apache Software License",
"Programming Language :: Python :: 3",
"Topic :: System :: Hardware",
"Topic :: System :: Networking",
@@ -24,12 +23,12 @@ classifiers = [
[project.urls]
Repository = "https://git.netork.io/NAPALM/napalm-amt"
+Issues = "https://git.netork.io/NAPALM/napalm-amt/issues"
+Changelog = "https://git.netork.io/NAPALM/napalm-amt/src/branch/main/CHANGELOG.md"
[project.optional-dependencies]
-test = [
- "pytest>=7",
- "responses>=0.23",
-]
+# responses mocks AMT's HTTP answers in the tests.
+dev = ["pytest>=8", "pytest-cov>=5", "mypy>=1.11", "ruff>=0.6", "responses>=0.23"]
[project.entry-points."napalm.drivers"]
amt = "napalm_amt:AmtDriver"
@@ -40,19 +39,37 @@ include = ["napalm_amt*"]
[tool.setuptools.package-data]
napalm_amt = ["py.typed"]
-[tool.pytest.ini_options]
-testpaths = ["tests"]
-
[tool.ruff]
line-length = 100
target-version = "py310"
[tool.ruff.lint]
-select = ["E", "F", "I", "UP"]
+select = ["E", "F", "I", "UP", "B"]
+
+[tool.ruff.lint.per-file-ignores]
+# Recorded device output keeps its lines.
+"tests/**" = ["E501"]
[tool.mypy]
python_version = "3.10"
strict = true
-ignore_missing_imports = true
# napalm itself is untyped, so every driver subclasses Any.
disallow_subclassing_any = false
+
+[[tool.mypy.overrides]]
+module = ["napalm.*", "requests.*", "defusedxml.*"]
+ignore_missing_imports = true
+
+[[tool.mypy.overrides]]
+# Not yet under strict mypy (quality baseline); may only shrink.
+module = ["napalm_amt.driver"]
+ignore_errors = true
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+
+[tool.coverage.run]
+source = ["napalm_amt"]
+
+[tool.coverage.report]
+fail_under = 95
diff --git a/tests/quality_baseline.json b/tests/quality_baseline.json
new file mode 100644
index 0000000..2f8b93e
--- /dev/null
+++ b/tests/quality_baseline.json
@@ -0,0 +1,12 @@
+{
+ "coverage_floor": 95.0,
+ "function_complexity": {
+ "napalm_amt/driver.py::AmtDriver.get_event_log": 11
+ },
+ "function_lines": {},
+ "mypy_relaxed": [
+ "napalm_amt.driver"
+ ],
+ "python_file_lines": {},
+ "untyped_public": []
+}
diff --git a/tests/test_wsman.py b/tests/test_wsman.py
index 4972b62..52b5cb9 100644
--- a/tests/test_wsman.py
+++ b/tests/test_wsman.py
@@ -214,6 +214,29 @@ def test_a_non_soap_error_raises():
_transport(retries=0).get("AMT_GeneralSettings")
+_WITH_DTD = (
+ ''
+ ']>'
+ '&a;'
+)
+
+
+@pytest.mark.parametrize("encoding", ["utf-8", "utf-16"])
+def test_an_answer_with_a_dtd_is_refused(encoding):
+ """A DTD has no business in a WS-Management answer, in whatever encoding it comes."""
+ with responses.RequestsMock() as rsps:
+ rsps.add(responses.POST, URL, body=_WITH_DTD.format(encoding=encoding).encode(encoding))
+ with pytest.raises(WsmanError, match="no WS-Management answer"):
+ _transport(retries=0).get("AMT_GeneralSettings")
+
+
+def test_an_answer_that_is_no_xml_raises():
+ with responses.RequestsMock() as rsps:
+ rsps.add(responses.POST, URL, status=200, body=b"")
+ with pytest.raises(WsmanError, match="HTTP 200"):
+ _transport(retries=0).get("AMT_GeneralSettings")
+
+
def test_a_connection_error_is_retried_then_raised():
with responses.RequestsMock() as rsps:
rsps.add(responses.POST, URL, body=requests.ConnectionError("refused"))