Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 9 additions & 12 deletions lib/core/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,22 +22,19 @@

from functools import wraps
from time import time
from typing import Any, Callable, TypeVar
from typing_extensions import ParamSpec
from typing import Any, Callable, TypeVar, cast

_lock = threading.Lock()
_cache: dict[int, tuple[float, Any]] = {}
_cache_lock = threading.Lock()

# https://mypy.readthedocs.io/en/stable/generics.html#declaring-decorators
P = ParamSpec("P")
T = TypeVar("T")
F = TypeVar("F", bound=Callable[..., Any])


def cached(timeout: int | float = 100) -> Callable[..., Any]:
def _cached(func: Callable[P, T]) -> Callable[P, T]:
def cached(timeout: int | float = 100) -> Callable[[F], F]:
def _cached(func: F) -> F:
@wraps(func)
def with_caching(*args: P.args, **kwargs: P.kwargs) -> T:
def with_caching(*args: Any, **kwargs: Any) -> Any:
key = id(func)
for arg in args:
key += id(arg)
Expand All @@ -54,14 +51,14 @@ def with_caching(*args: P.args, **kwargs: P.kwargs) -> T:

return result

return with_caching
return cast(F, with_caching)

return _cached


def locked(func: Callable[P, T]) -> Callable[P, T]:
def with_locking(*args: P.args, **kwargs: P.kwargs) -> T:
def locked(func: F) -> F:
def with_locking(*args: Any, **kwargs: Any) -> Any:
with _lock:
return func(*args, **kwargs)

return with_locking
return cast(F, with_locking)
4 changes: 2 additions & 2 deletions lib/parse/nmap.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
from __future__ import annotations

import defusedxml.ElementTree as ET
from lib.utils import safe_xml


def parse_nmap(file: str) -> list[str]:
root = ET.parse(file).getroot()
root = safe_xml.parse_file(file).getroot()
targets = []
for host in root.iter("host"):
hostname = (
Expand Down
13 changes: 5 additions & 8 deletions lib/utils/mimetype.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,10 @@

import re
import json
from typing_extensions import LiteralString

from defusedxml import ElementTree
from xml.etree import ElementTree

Check failure

Code scanning / Semgrep OSS

Semgrep Finding: python.lang.security.use-defused-xml.use-defused-xml Error

The Python documentation recommends using defusedxml instead of xml because the native Python xml library is vulnerable to XML External Entity (XXE) attacks. These attacks can leak confidential data and "XML bombs" can cause denial of service.

from lib.core.settings import QUERY_STRING_REGEX
from lib.utils import safe_xml


class MimeTypeUtils:
Expand All @@ -37,12 +36,10 @@
@staticmethod
def is_xml(content):
try:
ElementTree.fromstring(content)
safe_xml.fromstring(content)
return True
except ElementTree.ParseError:
except (ElementTree.ParseError, safe_xml.UnsafeXML):
return False
except Exception:
return True

@staticmethod
def is_query_string(content):
Expand All @@ -52,7 +49,7 @@
return False


def guess_mimetype(content) -> LiteralString:
def guess_mimetype(content) -> str:
if MimeTypeUtils.is_json(content):
return "application/json"
elif MimeTypeUtils.is_xml(content):
Expand Down
52 changes: 52 additions & 0 deletions lib/utils/safe_xml.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# -*- coding: utf-8 -*-
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
# MA 02110-1301, USA.
#
# Author: Mauro Soria

from __future__ import annotations

import re
from pathlib import Path
from xml.etree import ElementTree

Check failure

Code scanning / Semgrep OSS

Semgrep Finding: python.lang.security.use-defused-xml.use-defused-xml Error

The Python documentation recommends using defusedxml instead of xml because the native Python xml library is vulnerable to XML External Entity (XXE) attacks. These attacks can leak confidential data and "XML bombs" can cause denial of service.


FORBIDDEN_XML_MARKUP = re.compile(br"<!\s*(?:DOCTYPE|ENTITY)\b", re.IGNORECASE)


class UnsafeXML(ValueError):
pass


def reject_unsafe_xml_markup(content: bytes | str) -> bytes | str:
payload = content.encode("utf-8", errors="ignore") if isinstance(content, str) else content
normalized_payload = payload.replace(b"\x00", b"") if b"\x00" in payload else payload
if FORBIDDEN_XML_MARKUP.search(payload) or FORBIDDEN_XML_MARKUP.search(
normalized_payload
):
raise UnsafeXML("XML DTDs and entity declarations are not supported")

return content


def fromstring(content: bytes | str) -> ElementTree.Element:
reject_unsafe_xml_markup(content)
return ElementTree.fromstring(content)


def parse_file(path: str | Path) -> ElementTree.ElementTree:
data = Path(path).read_bytes()
reject_unsafe_xml_markup(data)
return ElementTree.ElementTree(ElementTree.fromstring(data))
1 change: 0 additions & 1 deletion pyinstaller/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,6 @@ build() {
--hidden-import=PySocks \
--hidden-import=socks \
--hidden-import=jinja2 \
--hidden-import=defusedxml \
--hidden-import=OpenSSL \
--hidden-import=ntlm_auth \
--hidden-import=requests_ntlm \
Expand Down
1 change: 0 additions & 1 deletion pyinstaller/dirsearch.spec
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ hidden_imports += [
'socks',
'jinja2',
'markupsafe',
'defusedxml',
'OpenSSL',
'cryptography',
'ntlm_auth',
Expand Down
1 change: 0 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
# Last updated: 2026-04-29
PySocks==1.7.1
Jinja2==3.1.6
defusedxml==0.7.1
pyopenssl==26.1.0
requests==2.33.1
requests-ntlm==1.3.0
Expand Down
1 change: 0 additions & 1 deletion requirements/runtime.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
PySocks==1.7.1
Jinja2==3.1.6
defusedxml==0.7.1
pyopenssl==26.1.0
requests==2.33.1
requests-ntlm==1.3.0
Expand Down
14 changes: 14 additions & 0 deletions tests/parse/test_nmap.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,22 @@
from unittest import TestCase
from pathlib import Path
from tempfile import TemporaryDirectory

from lib.parse.nmap import parse_nmap
from lib.utils.safe_xml import UnsafeXML


class TestNmapParser(TestCase):
def test_parse_nmap(self):
self.assertEqual(parse_nmap("./tests/static/nmap.xml"), ["scanme.nmap.org:80"], "Nmap parser gives unexpected result")

def test_parse_nmap_rejects_dtd(self):
with TemporaryDirectory() as directory:
report = Path(directory, "nmap.xml")
report.write_text(
'<?xml version="1.0"?><!DOCTYPE nmaprun [<!ENTITY xxe SYSTEM "file:///etc/passwd">]><nmaprun />',
encoding="utf-8",
)

with self.assertRaises(UnsafeXML):
parse_nmap(str(report))
17 changes: 17 additions & 0 deletions tests/utils/test_mimetype.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,22 @@ def test_is_json(self):
def test_is_xml(self):
self.assertTrue(MimeTypeUtils.is_xml('<?xml version="1.0" encoding="UTF-8"?><foo>bar</foo>'), "Failed to detect XML mimetype")

def test_is_xml_rejects_dtd(self):
self.assertFalse(
MimeTypeUtils.is_xml(
'<?xml version="1.0"?><!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]><foo>&xxe;</foo>'
),
"XML mimetype detection should reject entity declarations",
)

def test_is_xml_rejects_utf16_dtd(self):
payload = (
'<?xml version="1.0"?><!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]><foo>&xxe;</foo>'
).encode("utf-16")
self.assertFalse(
MimeTypeUtils.is_xml(payload),
"XML mimetype detection should reject encoded entity declarations",
)

def test_is_query_string(self):
self.assertTrue(MimeTypeUtils.is_query_string("foo=1&bar=&foobar=2"), "Failed to detect query string")
72 changes: 72 additions & 0 deletions tests/utils/test_safe_xml.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest import TestCase
from xml.etree import ElementTree

from lib.utils import safe_xml
from lib.utils.safe_xml import UnsafeXML


class TestSafeXML(TestCase):
def test_fromstring_parses_valid_xml_string(self):
root = safe_xml.fromstring('<root><child name="foo">bar</child></root>')

self.assertEqual(root.tag, "root")
self.assertEqual(root.find("child").get("name"), "foo")
self.assertEqual(root.findtext("child"), "bar")

def test_fromstring_parses_valid_xml_bytes(self):
root = safe_xml.fromstring(b'<?xml version="1.0"?><root><child /></root>')

self.assertEqual(root.tag, "root")
self.assertEqual(root.find("child").tag, "child")

def test_parse_file_parses_valid_xml(self):
with TemporaryDirectory() as directory:
xml_file = Path(directory, "report.xml")
xml_file.write_text("<root><item>ok</item></root>", encoding="utf-8")

tree = safe_xml.parse_file(xml_file)

self.assertEqual(tree.getroot().findtext("item"), "ok")

def test_invalid_xml_still_raises_parse_error(self):
with self.assertRaises(ElementTree.ParseError):
safe_xml.fromstring("<root>")

def test_rejects_doctype_declarations(self):
with self.assertRaises(UnsafeXML):
safe_xml.fromstring('<?xml version="1.0"?><!DOCTYPE root><root />')

def test_rejects_entity_declarations(self):
with self.assertRaises(UnsafeXML):
safe_xml.fromstring(
'<?xml version="1.0"?><!ENTITY xxe SYSTEM "file:///etc/passwd"><root />'
)

def test_rejects_case_insensitive_unsafe_markup(self):
with self.assertRaises(UnsafeXML):
safe_xml.fromstring('<?xml version="1.0"?><!doctype root><root />')

def test_rejects_utf16_unsafe_markup(self):
payload = '<?xml version="1.0"?><!DOCTYPE root><root />'.encode("utf-16")

with self.assertRaises(UnsafeXML):
safe_xml.fromstring(payload)

def test_rejects_utf32_unsafe_markup(self):
payload = '<?xml version="1.0"?><!DOCTYPE root><root />'.encode("utf-32")

with self.assertRaises(UnsafeXML):
safe_xml.fromstring(payload)

def test_parse_file_rejects_unsafe_xml(self):
with TemporaryDirectory() as directory:
xml_file = Path(directory, "report.xml")
xml_file.write_text(
'<?xml version="1.0"?><!DOCTYPE root><root />',
encoding="utf-8",
)

with self.assertRaises(UnsafeXML):
safe_xml.parse_file(xml_file)
Loading