diff --git a/component_catalog/models.py b/component_catalog/models.py index 7797f897..6d0e360b 100644 --- a/component_catalog/models.py +++ b/component_catalog/models.py @@ -37,7 +37,6 @@ from django.utils.text import normalize_newlines from django.utils.translation import gettext_lazy as _ -from attributecode.model import About from cyclonedx import model as cyclonedx_model from cyclonedx.model import component as cyclonedx_component from cyclonedx.model import contact as cyclonedx_contact @@ -55,6 +54,7 @@ from component_catalog.license_expression_dje import get_license_objects from component_catalog.license_expression_dje import parse_expression from component_catalog.license_expression_dje import render_expression_as_html +from dejacode_toolkit import aboutcode from dejacode_toolkit import download from dejacode_toolkit import spdx from dejacode_toolkit.purldb import PurlDB @@ -2335,9 +2335,7 @@ def as_about_yaml(self, extra=None): """Return ABOUT file data YAML formatted.""" about_data = self.as_about(extra) if about_data: - about = About() - about.load_dict(about_data, base_dir="") - return about.dumps() + return aboutcode.dumps(about_data) @property def about_file_name(self): diff --git a/dejacode/__init__.py b/dejacode/__init__.py index 5c8a8180..a6518da5 100644 --- a/dejacode/__init__.py +++ b/dejacode/__init__.py @@ -96,7 +96,6 @@ def get_git_describe_from_version_file(version_file_location=ROOT_DIR / ".VERSIO # Turn off the warnings for the following modules. warnings.filterwarnings("ignore", module="cyclonedx") -warnings.filterwarnings("ignore", module="clamd") warnings.filterwarnings("ignore", category=FutureWarning, module="rq_scheduler.utils") warnings.filterwarnings("ignore", category=FutureWarning, module="django.forms.formsets") diff --git a/dejacode_toolkit/aboutcode.py b/dejacode_toolkit/aboutcode.py new file mode 100644 index 00000000..2171b052 --- /dev/null +++ b/dejacode_toolkit/aboutcode.py @@ -0,0 +1,61 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# DejaCode is a trademark of nexB Inc. +# SPDX-License-Identifier: AGPL-3.0-only +# See https://github.com/aboutcode-org/dejacode for support or download. +# See https://aboutcode.org for more information about AboutCode FOSS projects. +# + +""" +Serialize data as an ABOUT file. +https://github.com/aboutcode-org/aboutcode-toolkit/blob/develop/SPECIFICATION.rst +""" + +import saneyaml + +# Supported fields, in the order they are written in the ABOUT file +ABOUT_FIELD_NAMES = [ + "about_resource", + "name", + "version", + "download_url", + "description", + "homepage_url", + "package_url", + "notes", + "license_expression", + "copyright", + "notice_file", + "notice_url", + "redistribute", + "attribute", + "track_changes", + "owner", + "owner_url", + "contact", + "vcs_repository", + "checksum_md5", + "checksum_sha1", + "licenses", +] + + +def normalize_text(text): + """Return `text` with LF line endings, without trailing spaces and surrounding blank lines.""" + return "\n".join(line.rstrip() for line in text.splitlines()).strip() + + +def dumps(about_data): + """ + Return the `about_data` dict as an ABOUT file YAML string. + Fields are written in the `ABOUT_FIELD_NAMES` order, empty and False values are skipped. + """ + about_fields = {} + for field_name in ABOUT_FIELD_NAMES: + value = about_data.get(field_name) + if isinstance(value, str): + value = normalize_text(value) + if value: + about_fields[field_name] = value + + return saneyaml.dump(about_fields) diff --git a/dejacode_toolkit/clamav.py b/dejacode_toolkit/clamav.py new file mode 100644 index 00000000..3a7235f8 --- /dev/null +++ b/dejacode_toolkit/clamav.py @@ -0,0 +1,98 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# DejaCode is a trademark of nexB Inc. +# SPDX-License-Identifier: AGPL-3.0-only +# See https://github.com/aboutcode-org/dejacode for support or download. +# See https://aboutcode.org for more information about AboutCode FOSS projects. +# + +""" +Minimal ClamAV client: scan a file-like object with the clamd INSTREAM command. +Standard library only, so the module can be reused outside of DejaCode. +""" + +import re +import socket +import struct + +DEFAULT_TCP_PORT = 3310 +DEFAULT_UNIX_SOCKET_PATH = "/var/run/clamav/clamd.ctl" + +# Each chunk must be smaller than the StreamMaxLength value of clamd.conf +CHUNK_SIZE = 64 * 1024 + +# The "n" prefix makes clamd terminate its reply with a newline +INSTREAM_COMMAND = b"nINSTREAM\n" +SIZE_LIMIT_EXCEEDED_RESPONSE = "INSTREAM size limit exceeded. ERROR" +SCAN_RESPONSE_REGEX = re.compile(r"^stream: (?:(?P.+) )?(?POK|FOUND|ERROR)$") + + +class ClamdError(Exception): + pass + + +class StreamTooLargeError(ClamdError): + pass + + +def open_connection(address, timeout=None): + """Return a socket connected to clamd at a Unix socket path or a (host, port) tuple.""" + if not isinstance(address, str): + return socket.create_connection(address, timeout=timeout) + + unix_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + unix_socket.settimeout(timeout) + try: + unix_socket.connect(address) + except OSError: + unix_socket.close() + raise + return unix_socket + + +def send_stream(clamd_socket, stream): + """Send `stream` as length-prefixed chunks, ended by a zero-length chunk.""" + clamd_socket.sendall(INSTREAM_COMMAND) + while chunk := stream.read(CHUNK_SIZE): + clamd_socket.sendall(struct.pack("!L", len(chunk)) + chunk) + clamd_socket.sendall(struct.pack("!L", 0)) + + +def read_response(clamd_socket): + with clamd_socket.makefile("rb") as response_file: + return response_file.readline().decode("utf-8").strip() + + +def parse_response(response): + """Return a (status, reason) tuple: ("OK", None), ("FOUND", signature), ("ERROR", reason).""" + if response == SIZE_LIMIT_EXCEEDED_RESPONSE: + raise StreamTooLargeError(response) + + response_match = SCAN_RESPONSE_REGEX.match(response) + if not response_match: + raise ClamdError(f"Unexpected clamd response: {response!r}") + + return response_match.group("status", "reason") + + +def scan_stream(stream, address, timeout=None): + """ + Scan the `stream` file-like object with the clamd service at `address`. + Return a (status, reason) tuple, see `parse_response`. + """ + try: + clamd_socket = open_connection(address, timeout) + except OSError as error: + raise ClamdError(f"Cannot connect to clamd at {address}: {error}") from error + + with clamd_socket: + try: + send_stream(clamd_socket, stream) + response = read_response(clamd_socket) + except BrokenPipeError as error: + # clamd closes the connection once the stream exceeds StreamMaxLength + raise StreamTooLargeError("Stream exceeds the clamd StreamMaxLength") from error + except OSError as error: + raise ClamdError(f"Communication error with clamd at {address}: {error}") from error + + return parse_response(response) diff --git a/dejacode_toolkit/tests/test_aboutcode.py b/dejacode_toolkit/tests/test_aboutcode.py new file mode 100644 index 00000000..1140efb1 --- /dev/null +++ b/dejacode_toolkit/tests/test_aboutcode.py @@ -0,0 +1,73 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# DejaCode is a trademark of nexB Inc. +# SPDX-License-Identifier: AGPL-3.0-only +# See https://github.com/aboutcode-org/dejacode for support or download. +# See https://aboutcode.org for more information about AboutCode FOSS projects. +# + +from unittest import TestCase + +from dejacode_toolkit import aboutcode + + +class AboutCodeTestCase(TestCase): + def test_aboutcode_dumps_field_order(self): + about_data = { + "licenses": [{"key": "mit", "name": "MIT License", "file": "mit.LICENSE"}], + "checksum_sha1": "sha1", + "owner": "Owner", + "attribute": True, + "license_expression": "mit", + "version": "1.0", + "name": "name", + "about_resource": "package.zip", + } + expected = ( + "about_resource: package.zip\n" + "name: name\n" + "version: '1.0'\n" + "license_expression: mit\n" + "attribute: yes\n" + "owner: Owner\n" + "checksum_sha1: sha1\n" + "licenses:\n" + " - key: mit\n" + " name: MIT License\n" + " file: mit.LICENSE\n" + ) + self.assertEqual(expected, aboutcode.dumps(about_data)) + + def test_aboutcode_dumps_skips_empty_false_and_unsupported_values(self): + about_data = { + "name": "name", + "version": "", + "description": " \n ", + "notes": None, + "redistribute": False, + "track_changes": True, + "licenses": [], + "unsupported_field": "value", + } + expected = "name: name\ntrack_changes: yes\n" + self.assertEqual(expected, aboutcode.dumps(about_data)) + + def test_aboutcode_dumps_normalizes_text(self): + about_data = { + "name": " name ", + "copyright": "Copyright A \r\nCopyright B\r\n", + } + expected = "name: name\ncopyright: |\n Copyright A\n Copyright B\n" + self.assertEqual(expected, aboutcode.dumps(about_data)) + + def test_aboutcode_normalize_text(self): + inputs = [ + # (text, expected) + ("", ""), + ("text", "text"), + (" text ", "text"), + ("line1 \r\nline2\t\r\n", "line1\nline2"), + ("\n\nline1\n\n indented\n\n", "line1\n\n indented"), + ] + for text, expected in inputs: + self.assertEqual(expected, aboutcode.normalize_text(text)) diff --git a/dejacode_toolkit/tests/test_clamav.py b/dejacode_toolkit/tests/test_clamav.py new file mode 100644 index 00000000..e87198c5 --- /dev/null +++ b/dejacode_toolkit/tests/test_clamav.py @@ -0,0 +1,163 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# DejaCode is a trademark of nexB Inc. +# SPDX-License-Identifier: AGPL-3.0-only +# See https://github.com/aboutcode-org/dejacode for support or download. +# See https://aboutcode.org for more information about AboutCode FOSS projects. +# + +import io +import socket +import struct +import tempfile +from pathlib import Path +from unittest import TestCase +from unittest.mock import MagicMock +from unittest.mock import patch + +from dejacode_toolkit import clamav + +CLAMD_ADDRESS = ("clamav", 3310) + + +def scan_with_clamd_response(stream, clamd_response): + """ + Run `scan_stream` against an in-memory socket pair that replies `clamd_response`. + Return the scan result and the bytes received on the clamd side. + """ + client_socket, clamd_socket = socket.socketpair() + clamd_socket.sendall(clamd_response) + + with clamd_socket: + with patch.object(clamav, "open_connection", return_value=client_socket): + scan_result = clamav.scan_stream(stream, CLAMD_ADDRESS) + + with clamd_socket.makefile("rb") as received_file: + received_bytes = received_file.read() + + return scan_result, received_bytes + + +def make_failing_socket(send_error): + clamd_socket = MagicMock() + clamd_socket.sendall.side_effect = send_error + return clamd_socket + + +class ClamAVTestCase(TestCase): + @patch.object(clamav, "CHUNK_SIZE", 4) + def test_clamav_scan_stream_sends_instream_chunks(self): + stream = io.BytesIO(b"0123456789") + scan_result, received_bytes = scan_with_clamd_response(stream, b"stream: OK\n") + + self.assertEqual(("OK", None), scan_result) + expected_bytes = ( + b"nINSTREAM\n" + + struct.pack("!L", 4) + + b"0123" + + struct.pack("!L", 4) + + b"4567" + + struct.pack("!L", 2) + + b"89" + + struct.pack("!L", 0) + ) + self.assertEqual(expected_bytes, received_bytes) + + def test_clamav_scan_stream_empty_stream(self): + stream = io.BytesIO(b"") + scan_result, received_bytes = scan_with_clamd_response(stream, b"stream: OK\n") + + self.assertEqual(("OK", None), scan_result) + self.assertEqual(b"nINSTREAM\n" + struct.pack("!L", 0), received_bytes) + + def test_clamav_scan_stream_found(self): + stream = io.BytesIO(b"content") + clamd_response = b"stream: Eicar-Test-Signature FOUND\n" + scan_result = scan_with_clamd_response(stream, clamd_response)[0] + + self.assertEqual(("FOUND", "Eicar-Test-Signature"), scan_result) + + def test_clamav_scan_stream_size_limit_exceeded(self): + stream = io.BytesIO(b"content") + clamd_response = b"INSTREAM size limit exceeded. ERROR\n" + + with self.assertRaises(clamav.StreamTooLargeError): + scan_with_clamd_response(stream, clamd_response) + + def test_clamav_scan_stream_broken_pipe(self): + clamd_socket = make_failing_socket(BrokenPipeError) + + with patch.object(clamav, "open_connection", return_value=clamd_socket): + with self.assertRaises(clamav.StreamTooLargeError): + clamav.scan_stream(io.BytesIO(b"content"), CLAMD_ADDRESS) + + clamd_socket.__exit__.assert_called_once() + + def test_clamav_scan_stream_communication_error(self): + clamd_socket = make_failing_socket(TimeoutError("timed out")) + + with patch.object(clamav, "open_connection", return_value=clamd_socket): + with self.assertRaises(clamav.ClamdError) as context: + clamav.scan_stream(io.BytesIO(b"content"), CLAMD_ADDRESS) + + self.assertNotIsInstance(context.exception, clamav.StreamTooLargeError) + expected = "Communication error with clamd at ('clamav', 3310): timed out" + self.assertEqual(expected, str(context.exception)) + clamd_socket.__exit__.assert_called_once() + + def test_clamav_scan_stream_connection_error(self): + with tempfile.TemporaryDirectory() as temp_directory: + missing_socket_path = str(Path(temp_directory) / "clamd.ctl") + + with self.assertRaises(clamav.ClamdError) as context: + clamav.scan_stream(io.BytesIO(b"content"), missing_socket_path) + + expected = f"Cannot connect to clamd at {missing_socket_path}" + self.assertTrue(str(context.exception).startswith(expected)) + + def test_clamav_parse_response(self): + self.assertEqual(("OK", None), clamav.parse_response("stream: OK")) + self.assertEqual( + ("FOUND", "Win.Test.EICAR_HDB-1"), + clamav.parse_response("stream: Win.Test.EICAR_HDB-1 FOUND"), + ) + self.assertEqual( + ("ERROR", "Can't allocate memory"), + clamav.parse_response("stream: Can't allocate memory ERROR"), + ) + + with self.assertRaises(clamav.StreamTooLargeError): + clamav.parse_response("INSTREAM size limit exceeded. ERROR") + + for unexpected_response in ["", "PONG", "stream: UNKNOWN"]: + with self.assertRaises(clamav.ClamdError) as context: + clamav.parse_response(unexpected_response) + expected = f"Unexpected clamd response: {unexpected_response!r}" + self.assertEqual(expected, str(context.exception)) + + @patch("socket.create_connection") + def test_clamav_open_connection_tcp(self, mock_create_connection): + tcp_socket = MagicMock() + mock_create_connection.return_value = tcp_socket + + self.assertEqual(tcp_socket, clamav.open_connection(CLAMD_ADDRESS, timeout=5)) + mock_create_connection.assert_called_once_with(CLAMD_ADDRESS, timeout=5) + + def test_clamav_open_connection_unix_socket(self): + with tempfile.TemporaryDirectory() as temp_directory: + socket_path = str(Path(temp_directory) / "clamd.ctl") + + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server_socket: + server_socket.bind(socket_path) + server_socket.listen() + + with clamav.open_connection(socket_path, timeout=5) as unix_socket: + self.assertEqual(socket_path, unix_socket.getpeername()) + self.assertEqual(5, unix_socket.gettimeout()) + + def test_clamav_open_connection_unix_socket_missing(self): + with tempfile.TemporaryDirectory() as temp_directory: + missing_socket_path = str(Path(temp_directory) / "clamd.ctl") + + with self.assertRaises(FileNotFoundError): + clamav.open_connection(missing_socket_path) diff --git a/dje/fields.py b/dje/fields.py index ea11a183..6270cbeb 100644 --- a/dje/fields.py +++ b/dje/fields.py @@ -16,6 +16,7 @@ from django.utils.text import normalize_newlines from django.utils.translation import gettext_lazy as _ +from dejacode_toolkit import clamav from dje.tasks import send_mail_to_admins_task from dje.validators import validate_list @@ -142,30 +143,26 @@ def clean(self, *args, **kwargs): @staticmethod def scan_file_for_virus(file): """Run a ClamAV scan on the uploaded file to detect virus infection.""" - import clamd - + clamd_address = clamav.DEFAULT_UNIX_SOCKET_PATH if settings.CLAMD_USE_TCP: - clamd_socket = clamd.ClamdNetworkSocket(settings.CLAMD_TCP_ADDR) - else: - clamd_socket = clamd.ClamdUnixSocket() + clamd_address = (settings.CLAMD_TCP_ADDR, clamav.DEFAULT_TCP_PORT) try: - scan_response = clamd_socket.instream(file) - except clamd.ClamdError: + scan_status, scan_reason = clamav.scan_stream(file, clamd_address) + except clamav.StreamTooLargeError: + raise forms.ValidationError(_("File size is too large.")) + except clamav.ClamdError: subject = "[DejaCode] Clamd Error" message = "Error with the ClamAV setup. Make sure the service is properly running." send_mail_to_admins_task.delay(subject, message) raise forms.ValidationError( _("File upload disabled at the moment. Please contact your administrator.") ) - except BrokenPipeError: - raise forms.ValidationError(_("File size is too large.")) - for status, reason in scan_response.values(): - if status == "FOUND": - raise forms.ValidationError(_("This file is infected. Upload aborted.")) - elif status == "ERROR": - raise forms.ValidationError(_("File upload error.")) + if scan_status == "FOUND": + raise forms.ValidationError(_("This file is infected. Upload aborted.")) + if scan_status == "ERROR": + raise forms.ValidationError(_("File upload error.")) class LastModifiedByField(models.ForeignKey): diff --git a/dje/tests/test_fields.py b/dje/tests/test_fields.py index 96c5e747..08f36e8d 100644 --- a/dje/tests/test_fields.py +++ b/dje/tests/test_fields.py @@ -14,37 +14,29 @@ from django.test import TestCase from django.test import override_settings -import clamd - +from dejacode_toolkit import clamav from dje.fields import SmartFileField @override_settings(ADMINS=["admin@dejacode.com"]) class SmartFileFieldTestCase(TestCase): - @staticmethod - def raise_broken_pipe(self, file): - raise BrokenPipeError - - @staticmethod - def raise_clamd_error(self, file): - raise clamd.ClamdError - - def test_restricted_file_field_scan_file_for_virus(self): - file = BytesIO(clamd.EICAR) - clamd_socket = clamd.ClamdNetworkSocket + @patch("dejacode_toolkit.clamav.scan_stream") + def test_restricted_file_field_scan_file_for_virus(self, mock_scan_stream): + file = BytesIO(b"content") scan_file_for_virus = SmartFileField.scan_file_for_virus - with patch.object(clamd_socket, "instream", self.raise_broken_pipe): - with self.assertRaises(ValidationError) as context: - scan_file_for_virus(file) - expected = "File size is too large." - self.assertEqual(expected, context.exception.message) + mock_scan_stream.side_effect = clamav.StreamTooLargeError + with self.assertRaises(ValidationError) as context: + scan_file_for_virus(file) + expected = "File size is too large." + self.assertEqual(expected, context.exception.message) + self.assertEqual(0, len(mail.outbox)) - with patch.object(clamd_socket, "instream", self.raise_clamd_error): - with self.assertRaises(ValidationError) as context: - scan_file_for_virus(file) - expected = "File upload disabled at the moment. Please contact your administrator." - self.assertEqual(expected, context.exception.message) + mock_scan_stream.side_effect = clamav.ClamdError + with self.assertRaises(ValidationError) as context: + scan_file_for_virus(file) + expected = "File upload disabled at the moment. Please contact your administrator." + self.assertEqual(expected, context.exception.message) self.assertEqual(len(mail.outbox), 1) sent_mail = mail.outbox[0] @@ -53,16 +45,31 @@ def test_restricted_file_field_scan_file_for_virus(self): self.assertEqual(expected_body, sent_mail.body) self.assertEqual(["admin@dejacode.com"], sent_mail.to) - scan_response = {"stream": ("FOUND", "Eicar-Test-Signature")} - with patch.object(clamd_socket, "instream", lambda *args: scan_response): - with self.assertRaises(ValidationError) as context: - scan_file_for_virus(file) - expected = "This file is infected. Upload aborted." - self.assertEqual(expected, context.exception.message) + mock_scan_stream.side_effect = None + mock_scan_stream.return_value = ("FOUND", "Eicar-Test-Signature") + with self.assertRaises(ValidationError) as context: + scan_file_for_virus(file) + expected = "This file is infected. Upload aborted." + self.assertEqual(expected, context.exception.message) + + mock_scan_stream.return_value = ("ERROR", "Error running scan") + with self.assertRaises(ValidationError) as context: + scan_file_for_virus(file) + expected = "File upload error." + self.assertEqual(expected, context.exception.message) + + mock_scan_stream.return_value = ("OK", None) + scan_file_for_virus(file) + + @patch("dejacode_toolkit.clamav.scan_stream") + def test_restricted_file_field_scan_file_for_virus_clamd_address(self, mock_scan_stream): + mock_scan_stream.return_value = ("OK", None) + file = BytesIO(b"content") + + with override_settings(CLAMD_USE_TCP=True, CLAMD_TCP_ADDR="clamav"): + SmartFileField.scan_file_for_virus(file) + mock_scan_stream.assert_called_with(file, ("clamav", 3310)) - scan_response = {"stream": ("ERROR", "Error running scan")} - with patch.object(clamd_socket, "instream", lambda *args: scan_response): - with self.assertRaises(ValidationError) as context: - scan_file_for_virus(file) - expected = "File upload error." - self.assertEqual(expected, context.exception.message) + with override_settings(CLAMD_USE_TCP=False): + SmartFileField.scan_file_for_virus(file) + mock_scan_stream.assert_called_with(file, "/var/run/clamav/clamd.ctl") diff --git a/dje/tests/test_utils.py b/dje/tests/test_utils.py index 58d3e99b..35751958 100644 --- a/dje/tests/test_utils.py +++ b/dje/tests/test_utils.py @@ -27,6 +27,7 @@ from dje.models import Dataspace from dje.utils import clone_related_objects from dje.utils import database_re_escape +from dje.utils import escape_xlsx_control_characters from dje.utils import extract_name_version from dje.utils import get_duplicates from dje.utils import get_instance_from_referer @@ -665,3 +666,18 @@ def test_utils_merge_common_non_empty_values(self): "parties": ["a", "b"], } self.assertEqual(expected, merge_common_non_empty_values([entry1, entry2])) + + def test_utils_escape_xlsx_control_characters(self): + inputs = [ + # (value, expected) + ("", ""), + ("plain text", "plain text"), + ("tab\tand\nnewline", "tab\tand\nnewline"), + ("null\x00character", "null_x0000_character"), + ("page\x0cbreak", "page_x000C_break"), + ("unit\x1fseparator", "unit_x001F_separator"), + ("literal _x0041_ sequence", "literal _x005F_x0041_ sequence"), + ("=1+1", "=1+1"), + ] + for value, expected in inputs: + self.assertEqual(expected, escape_xlsx_control_characters(value)) diff --git a/dje/utils.py b/dje/utils.py index 01d0eca4..02cddf0d 100644 --- a/dje/utils.py +++ b/dje/utils.py @@ -825,3 +825,20 @@ def style_xlsx_worksheet(worksheet, headers=None, auto_width=True): for col_index, header in enumerate(headers, 1): column_letter = get_column_letter(col_index) worksheet.column_dimensions[column_letter].width = max(len(str(header)) + 4, 12) + + +XLSX_ESCAPE_SEQUENCE_REGEX = re.compile(r"(_x[0-9a-fA-F]{4}_)") +XLSX_CONTROL_CHARACTER_REGEX = re.compile(r"[\x00-\x08\x0b-\x1f]") + + +def format_xlsx_control_character(control_character_match): + return f"_x{ord(control_character_match.group()):04X}_" + + +def escape_xlsx_control_characters(value): + """ + Return `value` with control characters escaped as `_xHHHH_` OOXML sequences. + A literal `_xHHHH_` is escaped first, so Excel does not read it as a control character. + """ + value = XLSX_ESCAPE_SEQUENCE_REGEX.sub(r"_x005F\1", value) + return XLSX_CONTROL_CHARACTER_REGEX.sub(format_xlsx_control_character, value) diff --git a/pyproject.toml b/pyproject.toml index f6c6c3ae..29473a81 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,11 +31,11 @@ classifiers = [ ] dependencies = [ # Base configuration tools - "setuptools==84.0.0", + "setuptools==84.2.1", "packaging==26.3", # Django "django==6.1.2", - "asgiref==3.12.1", + "asgiref==3.13.0", "typing-extensions==4.16.0", "sqlparse==0.6.0", # Django apps @@ -45,7 +45,7 @@ dependencies = [ "django-filter==26.2", "django-registration==5.2.1", "confusable_homoglyphs==3.3.1", - "django-guardian==3.5.1", + "django-guardian==3.5.2", "django-environ==0.14.0", "django-debug-toolbar==8.0.0", # Parallel testing @@ -61,7 +61,7 @@ dependencies = [ "drf-yasg==1.21.15", "uritemplate==4.2.0", "inflection==0.5.1", - "pytz==2026.5", + "pytz==2026.6", # Track failed login attempts "django-axes==8.3.1", # Multi-factor authentication @@ -71,36 +71,35 @@ dependencies = [ "psycopg==3.3.6", # Cache "redis==8.1.0", - # Antivirus - "clamd==1.0.2", # Testing "model-bakery==1.24.2", # Task queue "rq==2.12.0", + "click==8.5.0", "croniter==6.2.4", "django-rq==4.2.0", "fakeredis==2.40.0", # Libs - "certifi==2026.10.19", + "certifi==2026.10.23", "urllib3==2.8.0", - "python-dateutil==2.9.0.post0", + "python-dateutil==3.0.0", "six==1.17.0", + "tzdata==2026.6", "requests==2.34.2", "idna==3.20", "charset-normalizer==3.5.2", "PyYAML==6.0.3", - "XlsxWriter==3.2.9", # Markdown - "markdown==3.11.1", + "markdown==3.11.2", "bleach==6.4.0", "webencodings==0.6.1", # Authentication "defusedxml==0.7.1", # LDAP Auth "python-ldap==3.4.8", - "pyasn1==0.6.4", + "pyasn1==0.6.5", "pyasn1-modules==0.4.2", - "django-auth-ldap==5.3.0", + "django-auth-ldap==5.4.0", # license expressions "boolean.py==5.0", "license-expression==30.4.4", @@ -108,13 +107,9 @@ dependencies = [ "django_notifications_patched==2.0.0", "jsonfield==3.2.0", "swapper==1.5.0", - # AboutCode Toolkit - "aboutcode_toolkit==11.1.1", - "attrs==26.1.0", - "click==8.5.0", - "Jinja2==3.1.6", - "markupsafe==3.0.4", + # YAML "saneyaml==0.6.1", + # Spreadsheet "openpyxl==3.1.5", "et-xmlfile==2.0.0", # PackageURL @@ -126,14 +121,14 @@ dependencies = [ "sortedcontainers==2.4.0", "py-serializable==2.1.0", # CSAF - "pydantic==2.14.1", - "pydantic-core==2.50.1", + "pydantic==2.14.2", + "pydantic-core==2.50.2", "typing-inspection==0.4.4", "annotated-types==0.8.0", # OpenVEX "msgspec==0.22.0", # OpenDocument Format - "odfdo==3.27.2", + "odfdo==3.27.3", "lxml==6.1.3", ] diff --git a/reporting/fields.py b/reporting/fields.py index fec089aa..957dc1ac 100644 --- a/reporting/fields.py +++ b/reporting/fields.py @@ -12,8 +12,6 @@ from django.utils import timezone from django.utils.translation import gettext_lazy as _ -import dateutil - class BooleanSelect(Select): """ @@ -106,12 +104,8 @@ def _get_today(): return timezone.localtime().replace(hour=0, minute=0, second=0, microsecond=0) def render(self, name, value, attrs=None, renderer=None): - if not value: - # Force an Exception, empty string Return today in the parser - value = "ERROR" - try: - value_as_date = dateutil.parser.parse(value) + value_as_date = datetime.datetime.fromisoformat(value) # Make the parsed datetime timezone-aware to match "today" value if timezone.is_naive(value_as_date): value_as_date = timezone.make_aware(value_as_date) diff --git a/reporting/tests/test_fields.py b/reporting/tests/test_fields.py new file mode 100644 index 00000000..efcf8764 --- /dev/null +++ b/reporting/tests/test_fields.py @@ -0,0 +1,31 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# DejaCode is a trademark of nexB Inc. +# SPDX-License-Identifier: AGPL-3.0-only +# See https://github.com/aboutcode-org/dejacode for support or download. +# See https://aboutcode.org for more information about AboutCode FOSS projects. +# + +import datetime + +from django.test import TestCase + +from reporting.fields import DateFieldFilterSelect + + +class ReportingFieldsTestCase(TestCase): + def test_reporting_date_field_filter_select_render(self): + widget = DateFieldFilterSelect() + + for choice_value in ["today", "past_7_days", "past_30_days", "past_90_days"]: + value = widget.value_from_datadict({"value": choice_value}, files=None, name="value") + html = widget.render("value", value) + self.assertIn(f'