fix: what bandit and the remaining lint rules found
- WS-Management answers are parsed with defusedxml (new dependency, defusedxml>=0.7) with DTDs forbidden. The check for "<!DOCTYPE" in the raw bytes it replaces missed a DTD in a UTF-16 answer, which expat reads too; on plain HTTP (16992) anyone in the path can send one. An answer with a DTD in any encoding is now refused as no WS-Management answer, as a UTF-8 one already was (B314, B405). - xml.sax.saxutils only escapes the request envelopes, nothing parses with SAX (nosec B406). - python -m napalm_amt.record fetches the landing page without certificate checks, as before: it sends no credentials and records what discovery sees, and AMT's certificate is self-signed unless one was provisioned (nosec B501). The driver itself still verifies by default (verify_ssl).
This commit is contained in:
1 parent
87f0dff43f
commit
76f85051cc
4 files changed
+44
-12
No files matched your search
@@ -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."""
|
"""The page discovery fetches, without credentials, with its status line and headers."""
|
||||||
url = f"{'https' if tls else 'http'}://{host}:{port}/"
|
url = f"{'https' if tls else 'http'}://{host}:{port}/"
|
||||||
try:
|
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:
|
except requests.RequestException as exc:
|
||||||
print(f"landing page: {exc}", file=sys.stderr)
|
print(f"landing page: {exc}", file=sys.stderr)
|
||||||
return
|
return
|
||||||
|
|||||||
+17
-11
@@ -13,14 +13,21 @@ The envelopes follow Intel's own go-wsman-messages, which every AMT since versio
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import itertools
|
import itertools
|
||||||
import xml.etree.ElementTree as ET
|
|
||||||
from collections.abc import Callable, Iterable, Mapping
|
from collections.abc import Callable, Iterable, Mapping
|
||||||
from typing import Any
|
from typing import TYPE_CHECKING, Any
|
||||||
from xml.sax.saxutils import escape, quoteattr
|
|
||||||
|
# escape and quoteattr build the request envelopes; nothing here parses with SAX.
|
||||||
|
from xml.sax.saxutils import escape, quoteattr # nosec B406
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
from defusedxml import DefusedXmlException
|
||||||
|
from defusedxml.ElementTree import ParseError, fromstring
|
||||||
from requests.auth import HTTPDigestAuth
|
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"
|
SOAP_NS = "http://www.w3.org/2003/05/soap-envelope"
|
||||||
ADDRESSING_NS = "http://schemas.xmlsoap.org/ws/2004/08/addressing"
|
ADDRESSING_NS = "http://schemas.xmlsoap.org/ws/2004/08/addressing"
|
||||||
WSMAN_NS = "http://schemas.dmtf.org/wbem/wsman/1/wsman.xsd"
|
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."""
|
"""The SOAP body of a response; a fault, a refusal or anything else raises."""
|
||||||
if response.status_code == 401:
|
if response.status_code == 401:
|
||||||
raise WsmanAuthError(f"{self.url}: credentials refused (HTTP 401)")
|
raise WsmanAuthError(f"{self.url}: credentials refused (HTTP 401)")
|
||||||
body = None
|
# A DTD has no business in a WS-Management response; refusing one, in whatever
|
||||||
# A DTD has no business in a WS-Management response; refusing one keeps entity
|
# encoding, keeps entity expansion out of the parser.
|
||||||
# expansion out of the parser.
|
body: ET.Element | None
|
||||||
if b"<!DOCTYPE" not in response.content:
|
try:
|
||||||
try:
|
body = fromstring(response.content, forbid_dtd=True).find(f"{{{SOAP_NS}}}Body")
|
||||||
body = ET.fromstring(response.content).find(f"{{{SOAP_NS}}}Body")
|
except (ParseError, DefusedXmlException):
|
||||||
except ET.ParseError:
|
body = None
|
||||||
body = None
|
|
||||||
if body is not None:
|
if body is not None:
|
||||||
fault = body.find(f"{{{SOAP_NS}}}Fault")
|
fault = body.find(f"{{{SOAP_NS}}}Fault")
|
||||||
if fault is not None:
|
if fault is not None:
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ dependencies = [
|
|||||||
# Not on PyPI: install from git before this package (see README).
|
# Not on PyPI: install from git before this package (see README).
|
||||||
"napalm-device-types>=4.16",
|
"napalm-device-types>=4.16",
|
||||||
"requests>=2.31",
|
"requests>=2.31",
|
||||||
|
"defusedxml>=0.7",
|
||||||
]
|
]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"License :: OSI Approved :: Apache Software License",
|
"License :: OSI Approved :: Apache Software License",
|
||||||
|
|||||||
@@ -214,6 +214,29 @@ def test_a_non_soap_error_raises():
|
|||||||
_transport(retries=0).get("AMT_GeneralSettings")
|
_transport(retries=0).get("AMT_GeneralSettings")
|
||||||
|
|
||||||
|
|
||||||
|
_WITH_DTD = (
|
||||||
|
'<?xml version="1.0" encoding="{encoding}"?>'
|
||||||
|
'<!DOCTYPE Envelope [<!ENTITY a "aaaa">]>'
|
||||||
|
'<Envelope xmlns="http://www.w3.org/2003/05/soap-envelope"><Body>&a;</Body></Envelope>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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"<Envelope><Body>")
|
||||||
|
with pytest.raises(WsmanError, match="HTTP 200"):
|
||||||
|
_transport(retries=0).get("AMT_GeneralSettings")
|
||||||
|
|
||||||
|
|
||||||
def test_a_connection_error_is_retried_then_raised():
|
def test_a_connection_error_is_retried_then_raised():
|
||||||
with responses.RequestsMock() as rsps:
|
with responses.RequestsMock() as rsps:
|
||||||
rsps.add(responses.POST, URL, body=requests.ConnectionError("refused"))
|
rsps.add(responses.POST, URL, body=requests.ConnectionError("refused"))
|
||||||
|
|||||||
Reference in new issue
Block a user