From 71140dbd62e6cb241896a885a9f2e31d6b19c45f Mon Sep 17 00:00:00 2001 From: Carl Flottmann Date: Fri, 13 Dec 2024 17:36:48 +1000 Subject: [PATCH] feat: new metadata-based heuristic analyzing version numbers for single releases that are too high --- .../pypi_heuristics/heuristics.py | 4 + .../metadata/anomalistic_version.py | 167 ++++++ .../checks/detect_malicious_metadata_check.py | 70 +++ .../pypi/test_anomalistic_version.py | 563 ++++++++++++++++++ 4 files changed, 804 insertions(+) create mode 100644 src/macaron/malware_analyzer/pypi_heuristics/metadata/anomalistic_version.py create mode 100644 tests/malware_analyzer/pypi/test_anomalistic_version.py diff --git a/src/macaron/malware_analyzer/pypi_heuristics/heuristics.py b/src/macaron/malware_analyzer/pypi_heuristics/heuristics.py index d3e574027..b98b8ff89 100644 --- a/src/macaron/malware_analyzer/pypi_heuristics/heuristics.py +++ b/src/macaron/malware_analyzer/pypi_heuristics/heuristics.py @@ -34,6 +34,10 @@ class Heuristics(str, Enum): #: Indicates that the package does not include a .whl file WHEEL_ABSENCE = "wheel_absence" + #: Indicates that the package has an unusually large version jump between any two release versions, or it starts + #: at an unusually high version + ANOMALISTIC_VERSION = "anomalistic_version" + class HeuristicResult(str, Enum): """Result type indicating the outcome of a heuristic.""" diff --git a/src/macaron/malware_analyzer/pypi_heuristics/metadata/anomalistic_version.py b/src/macaron/malware_analyzer/pypi_heuristics/metadata/anomalistic_version.py new file mode 100644 index 000000000..711105dcc --- /dev/null +++ b/src/macaron/malware_analyzer/pypi_heuristics/metadata/anomalistic_version.py @@ -0,0 +1,167 @@ +# Copyright (c) 2024 - 2024, Oracle and/or its affiliates. All rights reserved. +# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/. + +"""The heuristic analyzer to check for an anomalistic package version.""" + +import logging +from enum import Enum + +from packaging.version import InvalidVersion, parse + +from macaron.errors import HeuristicAnalyzerValueError +from macaron.json_tools import JsonType, json_extract +from macaron.malware_analyzer.datetime_parser import parse_datetime +from macaron.malware_analyzer.pypi_heuristics.base_analyzer import BaseHeuristicAnalyzer +from macaron.malware_analyzer.pypi_heuristics.heuristics import HeuristicResult, Heuristics +from macaron.slsa_analyzer.package_registry.pypi_registry import PyPIPackageJsonAsset + +logger: logging.Logger = logging.getLogger(__name__) + + +class AnomalisticVersionAnalyzer(BaseHeuristicAnalyzer): + """ + Analyze the version number (if there is only a single release) to detect if it is anomalistic. + + A version number is anomalistic if it is above the thresholds for an epoch, major, or minor value. + If the version does not adhere to PyPI standards (PEP 440, as per the 'packaging' module), this heuristic + cannot analyze it. + + Calendar versioning is detected as version numbers with the major value as the year (either yyyy or yy), + the minor as the month, and the micro as the day (+/- 2 days), with no further values. + + Calendar-semantic versioning is detected as version numbers with the major value as the year (either yyyy or yy), + and any other series of numbers following it. + + All other versionings are detected as semantic versioning. + """ + + DATETIME_FORMAT: str = "%Y-%m-%dT%H:%M:%S" + + MAJOR_THRESHOLD: int = 20 + MINOR_THRESHOLD: int = 40 + EPOCH_THRESHOLD: int = 5 + + DETAIL_INFO_KEY: str = "versioning" + + def __init__(self) -> None: + super().__init__( + name="anomalistic_version_analyzer", + heuristic=Heuristics.ANOMALISTIC_VERSION, + depends_on=[(Heuristics.ONE_RELEASE, HeuristicResult.FAIL)], + ) + + def analyze(self, pypi_package_json: PyPIPackageJsonAsset) -> tuple[HeuristicResult, dict[str, JsonType]]: + """Analyze the package. + + Parameters + ---------- + pypi_package_json: PyPIPackageJsonAsset + The PyPI package JSON asset object. + + Returns + ------- + tuple[HeuristicResult, dict[str, JsonType]]: + The result and related information collected during the analysis. + + Raises + ------ + HeuristicAnalyzerValueError + if there is no release information available. + """ + releases = pypi_package_json.get_releases() + if releases is None: # no release information + error_msg = "There is no information for any release of this package." + logger.debug(error_msg) + raise HeuristicAnalyzerValueError(error_msg) + + if len(releases) != 1: + error_msg = ( + "This heuristic depends on a single release, but somehow there are multiple when the one release" + + " heuristic failed." + ) + logger.debug(error_msg) + raise HeuristicAnalyzerValueError(error_msg) + + # Since there is only one release, the latest version should be that release + release = pypi_package_json.get_latest_version() + if release is None: + error_msg = "No latest version information available" + logger.debug(error_msg) + raise HeuristicAnalyzerValueError(error_msg) + + try: + release_metadata = releases[release] + except KeyError as release_error: + error_msg = "The latest release is not available in the list of releases" + logger.debug(error_msg) + raise HeuristicAnalyzerValueError(error_msg) from release_error + + try: + version = parse(release) + except InvalidVersion: + return HeuristicResult.SKIP, {self.DETAIL_INFO_KEY: Versioning.INVALID.value} + + calendar_semantic = False + + if len(str(version.major)) == 4 or len(str(version.major)) == 2: + # possible this version number refers to a date + + for distribution in release_metadata: + upload_time = json_extract(distribution, ["upload_time"], str) + if upload_time is None: + error_msg = "Missing upload time from release information" + logger.debug(error_msg) + raise HeuristicAnalyzerValueError(error_msg) + + parsed_time = parse_datetime(upload_time, self.DATETIME_FORMAT) + if parsed_time is None: + error_msg = "Upload time is not of the expected PyPI format" + logger.debug(error_msg) + raise HeuristicAnalyzerValueError(error_msg) + + if version.major in (parsed_time.year, parsed_time.year % 100): + # the major of the version refers to the year published + if ( + parsed_time.month == version.minor + and parsed_time.day + 2 >= version.micro >= parsed_time.day - 2 + and len(version.release) == 3 + ): + # In the format of full_year.month.day or year.month.day, with a 48-hour buffer for timezone differences + detail_info: dict[str, JsonType] = {self.DETAIL_INFO_KEY: Versioning.CALENDAR.value} + if version.epoch > self.EPOCH_THRESHOLD: + return HeuristicResult.FAIL, detail_info + + return HeuristicResult.PASS, detail_info + + calendar_semantic = True + + if calendar_semantic: + detail_info = {self.DETAIL_INFO_KEY: Versioning.CALENDAR_SEMANTIC.value} + # analyze starting from the minor instead + if version.epoch > self.EPOCH_THRESHOLD: + return HeuristicResult.FAIL, detail_info + if version.minor > self.MAJOR_THRESHOLD: + return HeuristicResult.FAIL, detail_info + + return HeuristicResult.PASS, detail_info + + # semantic versioning + detail_info = {self.DETAIL_INFO_KEY: Versioning.SEMANTIC.value} + + if version.epoch > self.EPOCH_THRESHOLD: + return HeuristicResult.FAIL, detail_info + if version.major > self.MAJOR_THRESHOLD: + return HeuristicResult.FAIL, detail_info + if version.minor > self.MINOR_THRESHOLD: + return HeuristicResult.FAIL, detail_info + + return HeuristicResult.PASS, detail_info + + +class Versioning(Enum): + """Enum used to assign different versioning methods.""" + + INVALID = "invalid" + CALENDAR = "calendar" + CALENDAR_SEMANTIC = "calendar_semantic" + SEMANTIC = "semantic" diff --git a/src/macaron/slsa_analyzer/checks/detect_malicious_metadata_check.py b/src/macaron/slsa_analyzer/checks/detect_malicious_metadata_check.py index 15daf8d65..779e47bf5 100644 --- a/src/macaron/slsa_analyzer/checks/detect_malicious_metadata_check.py +++ b/src/macaron/slsa_analyzer/checks/detect_malicious_metadata_check.py @@ -15,6 +15,7 @@ from macaron.json_tools import JsonType, json_extract from macaron.malware_analyzer.pypi_heuristics.base_analyzer import BaseHeuristicAnalyzer from macaron.malware_analyzer.pypi_heuristics.heuristics import HeuristicResult, Heuristics +from macaron.malware_analyzer.pypi_heuristics.metadata.anomalistic_version import AnomalisticVersionAnalyzer from macaron.malware_analyzer.pypi_heuristics.metadata.closer_release_join_date import CloserReleaseJoinDateAnalyzer from macaron.malware_analyzer.pypi_heuristics.metadata.empty_project_link import EmptyProjectLinkAnalyzer from macaron.malware_analyzer.pypi_heuristics.metadata.high_release_frequency import HighReleaseFrequencyAnalyzer @@ -73,6 +74,7 @@ class MaliciousMetadataFacts(CheckFacts): CloserReleaseJoinDateAnalyzer, SuspiciousSetupAnalyzer, WheelAbsenceAnalyzer, + AnomalisticVersionAnalyzer, ] # The HeuristicResult sequence is aligned with the sequence of ANALYZERS list @@ -86,6 +88,7 @@ class MaliciousMetadataFacts(CheckFacts): HeuristicResult, HeuristicResult, HeuristicResult, + HeuristicResult, ], float, ] = { @@ -98,9 +101,26 @@ class MaliciousMetadataFacts(CheckFacts): HeuristicResult.FAIL, # Closer Release Join Date HeuristicResult.FAIL, # Suspicious Setup HeuristicResult.FAIL, # Wheel Absence + HeuristicResult.FAIL, # Anomalistic Version # No project link, only one release, and the maintainer released it shortly # after account registration. # The setup.py file contains suspicious imports and .whl file isn't present. + # Anomalistic version has no effect. + ): Confidence.HIGH, + ( + HeuristicResult.FAIL, # Empty Project + HeuristicResult.SKIP, # Unreachable Project Links + HeuristicResult.FAIL, # One Release + HeuristicResult.SKIP, # High Release Frequency + HeuristicResult.SKIP, # Unchanged Release + HeuristicResult.FAIL, # Closer Release Join Date + HeuristicResult.FAIL, # Suspicious Setup + HeuristicResult.FAIL, # Wheel Absence + HeuristicResult.PASS, # Anomalistic Version + # No project link, only one release, and the maintainer released it shortly + # after account registration. + # The setup.py file contains suspicious imports and .whl file isn't present. + # Anomalistic version has no effect. ): Confidence.HIGH, ( HeuristicResult.FAIL, # Empty Project @@ -111,6 +131,7 @@ class MaliciousMetadataFacts(CheckFacts): HeuristicResult.FAIL, # Closer Release Join Date HeuristicResult.FAIL, # Suspicious Setup HeuristicResult.FAIL, # Wheel Absence + HeuristicResult.SKIP, # Anomalistic Version # No project link, frequent releases of multiple versions without modifying the content, # and the maintainer released it shortly after account registration. # The setup.py file contains suspicious imports and .whl file isn't present. @@ -124,6 +145,7 @@ class MaliciousMetadataFacts(CheckFacts): HeuristicResult.FAIL, # Closer Release Join Date HeuristicResult.FAIL, # Suspicious Setup HeuristicResult.FAIL, # Wheel Absence + HeuristicResult.SKIP, # Anomalistic Version # No project link, frequent releases of multiple versions, # and the maintainer released it shortly after account registration. # The setup.py file contains suspicious imports and .whl file isn't present. @@ -137,6 +159,7 @@ class MaliciousMetadataFacts(CheckFacts): HeuristicResult.FAIL, # Closer Release Join Date HeuristicResult.PASS, # Suspicious Setup HeuristicResult.PASS, # Wheel Absence + HeuristicResult.SKIP, # Anomalistic Version # No project link, frequent releases of multiple versions without modifying the content, # and the maintainer released it shortly after account registration. Presence/Absence of # .whl file has no effect @@ -150,6 +173,7 @@ class MaliciousMetadataFacts(CheckFacts): HeuristicResult.FAIL, # Closer Release Join Date HeuristicResult.PASS, # Suspicious Setup HeuristicResult.FAIL, # Wheel Absence + HeuristicResult.SKIP, # Anomalistic Version # No project link, frequent releases of multiple versions without modifying the content, # and the maintainer released it shortly after account registration. Presence/Absence of # .whl file has no effect @@ -163,10 +187,56 @@ class MaliciousMetadataFacts(CheckFacts): HeuristicResult.FAIL, # Closer Release Join Date HeuristicResult.FAIL, # Suspicious Setup HeuristicResult.FAIL, # Wheel Absence + HeuristicResult.SKIP, # Anomalistic Version # All project links are unreachable, frequent releases of multiple versions, # and the maintainer released it shortly after account registration. # The setup.py file contains suspicious imports and .whl file isn't present. ): Confidence.HIGH, + ( + HeuristicResult.FAIL, # Empty Project + HeuristicResult.SKIP, # Unreachable Project Links + HeuristicResult.FAIL, # One Release + HeuristicResult.SKIP, # High Release Frequency + HeuristicResult.SKIP, # Unchanged Release + HeuristicResult.FAIL, # Closer Release Join Date + HeuristicResult.PASS, # Suspicious Setup + HeuristicResult.PASS, # Wheel Absence + HeuristicResult.FAIL, # Anomalistic Version + # No project link, only one release, and the maintainer released it shortly + # after account registration. + # The setup.py file has no effect and .whl file is present. + # The version number is anomalistic. + ): Confidence.MEDIUM, + ( + HeuristicResult.FAIL, # Empty Project + HeuristicResult.SKIP, # Unreachable Project Links + HeuristicResult.FAIL, # One Release + HeuristicResult.SKIP, # High Release Frequency + HeuristicResult.SKIP, # Unchanged Release + HeuristicResult.FAIL, # Closer Release Join Date + HeuristicResult.FAIL, # Suspicious Setup + HeuristicResult.PASS, # Wheel Absence + HeuristicResult.FAIL, # Anomalistic Version + # No project link, only one release, and the maintainer released it shortly + # after account registration. + # The setup.py file has no effect and .whl file is present. + # The version number is anomalistic. + ): Confidence.MEDIUM, + ( + HeuristicResult.FAIL, # Empty Project + HeuristicResult.SKIP, # Unreachable Project Links + HeuristicResult.FAIL, # One Release + HeuristicResult.SKIP, # High Release Frequency + HeuristicResult.SKIP, # Unchanged Release + HeuristicResult.FAIL, # Closer Release Join Date + HeuristicResult.SKIP, # Suspicious Setup + HeuristicResult.PASS, # Wheel Absence + HeuristicResult.FAIL, # Anomalistic Version + # No project link, only one release, and the maintainer released it shortly + # after account registration. + # The setup.py file has no effect and .whl file is present. + # The version number is anomalistic. + ): Confidence.MEDIUM, } diff --git a/tests/malware_analyzer/pypi/test_anomalistic_version.py b/tests/malware_analyzer/pypi/test_anomalistic_version.py new file mode 100644 index 000000000..423a59d7e --- /dev/null +++ b/tests/malware_analyzer/pypi/test_anomalistic_version.py @@ -0,0 +1,563 @@ +# Copyright (c) 2024 - 2024, Oracle and/or its affiliates. All rights reserved. +# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/. + +"""Tests for heuristic detecting anomalistic version numbers""" +from unittest.mock import MagicMock + +import pytest + +from macaron.errors import HeuristicAnalyzerValueError +from macaron.malware_analyzer.pypi_heuristics.heuristics import HeuristicResult +from macaron.malware_analyzer.pypi_heuristics.metadata.anomalistic_version import AnomalisticVersionAnalyzer + + +def test_analyze_no_information(pypi_package_json: MagicMock) -> None: + """Test for when there is no release information, so error""" + analyzer = AnomalisticVersionAnalyzer() + + pypi_package_json.get_releases.return_value = None + + with pytest.raises(HeuristicAnalyzerValueError): + analyzer.analyze(pypi_package_json) + + +def test_invalid_version(pypi_package_json: MagicMock) -> None: + """Test for when the version is invalid as per PyPI requirements (PEP 440), so skip""" + analyzer = AnomalisticVersionAnalyzer() + version = "2016-10-13" # Invalid as per PEP 440 due to '-' + upload_date = "2016-10-13" + + release = { + version: [ + { + "comment_text": "", + "digests": { + "blake2b_256": "defa2fbcebaeeb909511139ce28dac4a77ab2452ba72b49a22b12981b2f375b3", + "md5": "9203bbb130f8ddb38269f4861c170d04", + "sha256": "168bcccbf5106132e90b85659297700194369b8f6b3e5a03769614f0d200e370", + }, + "downloads": -1, + "filename": "ttttttttest_nester.py-0.1.0.tar.gz", + "has_sig": False, + "md5_digest": "9203bbb130f8ddb38269f4861c170d04", + "packagetype": "sdist", + "python_version": "source", + "requires_python": None, + "size": 546, + "upload_time": f"{upload_date}T05:42:27", + "upload_time_iso_8601": f"{upload_date}T05:42:27.073842Z", + "url": "https://files.pythonhosted.org/packages/de/fa/" + + "2fbcebaeeb909511139ce28dac4a77ab2452ba72b49a22b12981b2f375b3/ttttttttest_nester.py-0.1.0.tar.gz", + "yanked": False, + "yanked_reason": None, + } + ] + } + + pypi_package_json.get_releases.return_value = release + pypi_package_json.get_latest_version.return_value = version + expected_result: tuple[HeuristicResult, dict] = (HeuristicResult.SKIP, {"versioning": "invalid"}) + + actual_result = analyzer.analyze(pypi_package_json) + + assert actual_result == expected_result + + +def test_calendar_pass(pypi_package_json: MagicMock) -> None: + """Test for when the version uses calendar versioning, expected to pass""" + analyzer = AnomalisticVersionAnalyzer() + version = "2016.10.12" + upload_date = "2016-10-13" + + release = { + version: [ + { + "comment_text": "", + "digests": { + "blake2b_256": "defa2fbcebaeeb909511139ce28dac4a77ab2452ba72b49a22b12981b2f375b3", + "md5": "9203bbb130f8ddb38269f4861c170d04", + "sha256": "168bcccbf5106132e90b85659297700194369b8f6b3e5a03769614f0d200e370", + }, + "downloads": -1, + "filename": "ttttttttest_nester.py-0.1.0.tar.gz", + "has_sig": False, + "md5_digest": "9203bbb130f8ddb38269f4861c170d04", + "packagetype": "sdist", + "python_version": "source", + "requires_python": None, + "size": 546, + "upload_time": f"{upload_date}T05:42:27", + "upload_time_iso_8601": f"{upload_date}T05:42:27.073842Z", + "url": "https://files.pythonhosted.org/packages/de/fa/" + + "2fbcebaeeb909511139ce28dac4a77ab2452ba72b49a22b12981b2f375b3/ttttttttest_nester.py-0.1.0.tar.gz", + "yanked": False, + "yanked_reason": None, + } + ] + } + + pypi_package_json.get_releases.return_value = release + pypi_package_json.get_latest_version.return_value = version + expected_result: tuple[HeuristicResult, dict] = (HeuristicResult.PASS, {"versioning": "calendar"}) + + actual_result = analyzer.analyze(pypi_package_json) + + assert actual_result == expected_result + + +def test_calendar_epoch_pass(pypi_package_json: MagicMock) -> None: + """Test for when the version uses calendar versioning with an epoch below the threshold, expected to pass""" + analyzer = AnomalisticVersionAnalyzer() + version = "2!16.10.14" + upload_date = "2016-10-13" + + release = { + version: [ + { + "comment_text": "", + "digests": { + "blake2b_256": "defa2fbcebaeeb909511139ce28dac4a77ab2452ba72b49a22b12981b2f375b3", + "md5": "9203bbb130f8ddb38269f4861c170d04", + "sha256": "168bcccbf5106132e90b85659297700194369b8f6b3e5a03769614f0d200e370", + }, + "downloads": -1, + "filename": "ttttttttest_nester.py-0.1.0.tar.gz", + "has_sig": False, + "md5_digest": "9203bbb130f8ddb38269f4861c170d04", + "packagetype": "sdist", + "python_version": "source", + "requires_python": None, + "size": 546, + "upload_time": f"{upload_date}T05:42:27", + "upload_time_iso_8601": f"{upload_date}T05:42:27.073842Z", + "url": "https://files.pythonhosted.org/packages/de/fa/" + + "2fbcebaeeb909511139ce28dac4a77ab2452ba72b49a22b12981b2f375b3/ttttttttest_nester.py-0.1.0.tar.gz", + "yanked": False, + "yanked_reason": None, + } + ] + } + + pypi_package_json.get_releases.return_value = release + pypi_package_json.get_latest_version.return_value = version + expected_result: tuple[HeuristicResult, dict] = (HeuristicResult.PASS, {"versioning": "calendar"}) + + actual_result = analyzer.analyze(pypi_package_json) + + assert actual_result == expected_result + + +def test_calendar_epoch_fail(pypi_package_json: MagicMock) -> None: + """Test for when the version uses calendar versioning with an epoch above the threshold, expected to fail""" + analyzer = AnomalisticVersionAnalyzer() + version = "100!2016.10.14" + upload_date = "2016-10-13" + + release = { + version: [ + { + "comment_text": "", + "digests": { + "blake2b_256": "defa2fbcebaeeb909511139ce28dac4a77ab2452ba72b49a22b12981b2f375b3", + "md5": "9203bbb130f8ddb38269f4861c170d04", + "sha256": "168bcccbf5106132e90b85659297700194369b8f6b3e5a03769614f0d200e370", + }, + "downloads": -1, + "filename": "ttttttttest_nester.py-0.1.0.tar.gz", + "has_sig": False, + "md5_digest": "9203bbb130f8ddb38269f4861c170d04", + "packagetype": "sdist", + "python_version": "source", + "requires_python": None, + "size": 546, + "upload_time": f"{upload_date}T05:42:27", + "upload_time_iso_8601": f"{upload_date}T05:42:27.073842Z", + "url": "https://files.pythonhosted.org/packages/de/fa/" + + "2fbcebaeeb909511139ce28dac4a77ab2452ba72b49a22b12981b2f375b3/ttttttttest_nester.py-0.1.0.tar.gz", + "yanked": False, + "yanked_reason": None, + } + ] + } + + pypi_package_json.get_releases.return_value = release + pypi_package_json.get_latest_version.return_value = version + expected_result: tuple[HeuristicResult, dict] = (HeuristicResult.FAIL, {"versioning": "calendar"}) + + actual_result = analyzer.analyze(pypi_package_json) + + assert actual_result == expected_result + + +def test_calendar_semantic_pass(pypi_package_json: MagicMock) -> None: + """Test for when the version uses calendar semantic versioning below the threshold, expected to pass""" + analyzer = AnomalisticVersionAnalyzer() + version = "2016.1" + upload_date = "2016-10-13" + + release = { + version: [ + { + "comment_text": "", + "digests": { + "blake2b_256": "defa2fbcebaeeb909511139ce28dac4a77ab2452ba72b49a22b12981b2f375b3", + "md5": "9203bbb130f8ddb38269f4861c170d04", + "sha256": "168bcccbf5106132e90b85659297700194369b8f6b3e5a03769614f0d200e370", + }, + "downloads": -1, + "filename": "ttttttttest_nester.py-0.1.0.tar.gz", + "has_sig": False, + "md5_digest": "9203bbb130f8ddb38269f4861c170d04", + "packagetype": "sdist", + "python_version": "source", + "requires_python": None, + "size": 546, + "upload_time": f"{upload_date}T05:42:27", + "upload_time_iso_8601": f"{upload_date}T05:42:27.073842Z", + "url": "https://files.pythonhosted.org/packages/de/fa/" + + "2fbcebaeeb909511139ce28dac4a77ab2452ba72b49a22b12981b2f375b3/ttttttttest_nester.py-0.1.0.tar.gz", + "yanked": False, + "yanked_reason": None, + } + ] + } + + pypi_package_json.get_releases.return_value = release + pypi_package_json.get_latest_version.return_value = version + expected_result: tuple[HeuristicResult, dict] = (HeuristicResult.PASS, {"versioning": "calendar_semantic"}) + + actual_result = analyzer.analyze(pypi_package_json) + + assert actual_result == expected_result + + +def test_calendar_semantic_fail(pypi_package_json: MagicMock) -> None: + """Test for when the version uses calendar semantic versioning above the threshold, expected to fail""" + analyzer = AnomalisticVersionAnalyzer() + version = "2016.100" + upload_date = "2016-10-13" + + release = { + version: [ + { + "comment_text": "", + "digests": { + "blake2b_256": "defa2fbcebaeeb909511139ce28dac4a77ab2452ba72b49a22b12981b2f375b3", + "md5": "9203bbb130f8ddb38269f4861c170d04", + "sha256": "168bcccbf5106132e90b85659297700194369b8f6b3e5a03769614f0d200e370", + }, + "downloads": -1, + "filename": "ttttttttest_nester.py-0.1.0.tar.gz", + "has_sig": False, + "md5_digest": "9203bbb130f8ddb38269f4861c170d04", + "packagetype": "sdist", + "python_version": "source", + "requires_python": None, + "size": 546, + "upload_time": f"{upload_date}T05:42:27", + "upload_time_iso_8601": f"{upload_date}T05:42:27.073842Z", + "url": "https://files.pythonhosted.org/packages/de/fa/" + + "2fbcebaeeb909511139ce28dac4a77ab2452ba72b49a22b12981b2f375b3/ttttttttest_nester.py-0.1.0.tar.gz", + "yanked": False, + "yanked_reason": None, + } + ] + } + + pypi_package_json.get_releases.return_value = release + pypi_package_json.get_latest_version.return_value = version + expected_result: tuple[HeuristicResult, dict] = (HeuristicResult.FAIL, {"versioning": "calendar_semantic"}) + + actual_result = analyzer.analyze(pypi_package_json) + + assert actual_result == expected_result + + +def test_calendar_semantic_epoch_pass(pypi_package_json: MagicMock) -> None: + """Test for when the version uses calendar semantic versioning with an epoch below the threshold, expected to pass""" + analyzer = AnomalisticVersionAnalyzer() + version = "2!2016.1" + upload_date = "2016-10-13" + + release = { + version: [ + { + "comment_text": "", + "digests": { + "blake2b_256": "defa2fbcebaeeb909511139ce28dac4a77ab2452ba72b49a22b12981b2f375b3", + "md5": "9203bbb130f8ddb38269f4861c170d04", + "sha256": "168bcccbf5106132e90b85659297700194369b8f6b3e5a03769614f0d200e370", + }, + "downloads": -1, + "filename": "ttttttttest_nester.py-0.1.0.tar.gz", + "has_sig": False, + "md5_digest": "9203bbb130f8ddb38269f4861c170d04", + "packagetype": "sdist", + "python_version": "source", + "requires_python": None, + "size": 546, + "upload_time": f"{upload_date}T05:42:27", + "upload_time_iso_8601": f"{upload_date}T05:42:27.073842Z", + "url": "https://files.pythonhosted.org/packages/de/fa/" + + "2fbcebaeeb909511139ce28dac4a77ab2452ba72b49a22b12981b2f375b3/ttttttttest_nester.py-0.1.0.tar.gz", + "yanked": False, + "yanked_reason": None, + } + ] + } + + pypi_package_json.get_releases.return_value = release + pypi_package_json.get_latest_version.return_value = version + expected_result: tuple[HeuristicResult, dict] = (HeuristicResult.PASS, {"versioning": "calendar_semantic"}) + + actual_result = analyzer.analyze(pypi_package_json) + + assert actual_result == expected_result + + +def test_calendar_semantic_epoch_fail(pypi_package_json: MagicMock) -> None: + """Test for when the version uses calendar semantic versioning with an epoch below the threshold, expected to fail""" + analyzer = AnomalisticVersionAnalyzer() + version = "100!2016.1" + upload_date = "2016-10-13" + + release = { + version: [ + { + "comment_text": "", + "digests": { + "blake2b_256": "defa2fbcebaeeb909511139ce28dac4a77ab2452ba72b49a22b12981b2f375b3", + "md5": "9203bbb130f8ddb38269f4861c170d04", + "sha256": "168bcccbf5106132e90b85659297700194369b8f6b3e5a03769614f0d200e370", + }, + "downloads": -1, + "filename": "ttttttttest_nester.py-0.1.0.tar.gz", + "has_sig": False, + "md5_digest": "9203bbb130f8ddb38269f4861c170d04", + "packagetype": "sdist", + "python_version": "source", + "requires_python": None, + "size": 546, + "upload_time": f"{upload_date}T05:42:27", + "upload_time_iso_8601": f"{upload_date}T05:42:27.073842Z", + "url": "https://files.pythonhosted.org/packages/de/fa/" + + "2fbcebaeeb909511139ce28dac4a77ab2452ba72b49a22b12981b2f375b3/ttttttttest_nester.py-0.1.0.tar.gz", + "yanked": False, + "yanked_reason": None, + } + ] + } + + pypi_package_json.get_releases.return_value = release + pypi_package_json.get_latest_version.return_value = version + expected_result: tuple[HeuristicResult, dict] = (HeuristicResult.FAIL, {"versioning": "calendar_semantic"}) + + actual_result = analyzer.analyze(pypi_package_json) + + assert actual_result == expected_result + + +def test_semantic_pass(pypi_package_json: MagicMock) -> None: + """Test for when the version uses semantic versioning below the threshold, expected to pass""" + analyzer = AnomalisticVersionAnalyzer() + version = "3.1" + upload_date = "2016-10-13" + + release = { + version: [ + { + "comment_text": "", + "digests": { + "blake2b_256": "defa2fbcebaeeb909511139ce28dac4a77ab2452ba72b49a22b12981b2f375b3", + "md5": "9203bbb130f8ddb38269f4861c170d04", + "sha256": "168bcccbf5106132e90b85659297700194369b8f6b3e5a03769614f0d200e370", + }, + "downloads": -1, + "filename": "ttttttttest_nester.py-0.1.0.tar.gz", + "has_sig": False, + "md5_digest": "9203bbb130f8ddb38269f4861c170d04", + "packagetype": "sdist", + "python_version": "source", + "requires_python": None, + "size": 546, + "upload_time": f"{upload_date}T05:42:27", + "upload_time_iso_8601": f"{upload_date}T05:42:27.073842Z", + "url": "https://files.pythonhosted.org/packages/de/fa/" + + "2fbcebaeeb909511139ce28dac4a77ab2452ba72b49a22b12981b2f375b3/ttttttttest_nester.py-0.1.0.tar.gz", + "yanked": False, + "yanked_reason": None, + } + ] + } + + pypi_package_json.get_releases.return_value = release + pypi_package_json.get_latest_version.return_value = version + expected_result: tuple[HeuristicResult, dict] = (HeuristicResult.PASS, {"versioning": "semantic"}) + + actual_result = analyzer.analyze(pypi_package_json) + + assert actual_result == expected_result + + +def test_semantic_fail(pypi_package_json: MagicMock) -> None: + """Test for when the version uses semantic versioning above the threshold, expected to fail""" + analyzer = AnomalisticVersionAnalyzer() + version = "999" + upload_date = "2016-10-13" + + release = { + version: [ + { + "comment_text": "", + "digests": { + "blake2b_256": "defa2fbcebaeeb909511139ce28dac4a77ab2452ba72b49a22b12981b2f375b3", + "md5": "9203bbb130f8ddb38269f4861c170d04", + "sha256": "168bcccbf5106132e90b85659297700194369b8f6b3e5a03769614f0d200e370", + }, + "downloads": -1, + "filename": "ttttttttest_nester.py-0.1.0.tar.gz", + "has_sig": False, + "md5_digest": "9203bbb130f8ddb38269f4861c170d04", + "packagetype": "sdist", + "python_version": "source", + "requires_python": None, + "size": 546, + "upload_time": f"{upload_date}T05:42:27", + "upload_time_iso_8601": f"{upload_date}T05:42:27.073842Z", + "url": "https://files.pythonhosted.org/packages/de/fa/" + + "2fbcebaeeb909511139ce28dac4a77ab2452ba72b49a22b12981b2f375b3/ttttttttest_nester.py-0.1.0.tar.gz", + "yanked": False, + "yanked_reason": None, + } + ] + } + + pypi_package_json.get_releases.return_value = release + pypi_package_json.get_latest_version.return_value = version + expected_result: tuple[HeuristicResult, dict] = (HeuristicResult.FAIL, {"versioning": "semantic"}) + + actual_result = analyzer.analyze(pypi_package_json) + + assert actual_result == expected_result + + version = "1.999" + upload_date = "2016-10-13" + + release = { + version: [ + { + "comment_text": "", + "digests": { + "blake2b_256": "defa2fbcebaeeb909511139ce28dac4a77ab2452ba72b49a22b12981b2f375b3", + "md5": "9203bbb130f8ddb38269f4861c170d04", + "sha256": "168bcccbf5106132e90b85659297700194369b8f6b3e5a03769614f0d200e370", + }, + "downloads": -1, + "filename": "ttttttttest_nester.py-0.1.0.tar.gz", + "has_sig": False, + "md5_digest": "9203bbb130f8ddb38269f4861c170d04", + "packagetype": "sdist", + "python_version": "source", + "requires_python": None, + "size": 546, + "upload_time": f"{upload_date}T05:42:27", + "upload_time_iso_8601": f"{upload_date}T05:42:27.073842Z", + "url": "https://files.pythonhosted.org/packages/de/fa/" + + "2fbcebaeeb909511139ce28dac4a77ab2452ba72b49a22b12981b2f375b3/ttttttttest_nester.py-0.1.0.tar.gz", + "yanked": False, + "yanked_reason": None, + } + ] + } + + pypi_package_json.get_releases.return_value = release + pypi_package_json.get_latest_version.return_value = version + expected_result = (HeuristicResult.FAIL, {"versioning": "semantic"}) + + actual_result = analyzer.analyze(pypi_package_json) + + assert actual_result == expected_result + + +def test_semantic_epoch_pass(pypi_package_json: MagicMock) -> None: + """Test for when the version uses semantic versioning with an epoch below the threshold, expected to pass""" + analyzer = AnomalisticVersionAnalyzer() + version = "3!3.1" + upload_date = "2016-10-13" + + release = { + version: [ + { + "comment_text": "", + "digests": { + "blake2b_256": "defa2fbcebaeeb909511139ce28dac4a77ab2452ba72b49a22b12981b2f375b3", + "md5": "9203bbb130f8ddb38269f4861c170d04", + "sha256": "168bcccbf5106132e90b85659297700194369b8f6b3e5a03769614f0d200e370", + }, + "downloads": -1, + "filename": "ttttttttest_nester.py-0.1.0.tar.gz", + "has_sig": False, + "md5_digest": "9203bbb130f8ddb38269f4861c170d04", + "packagetype": "sdist", + "python_version": "source", + "requires_python": None, + "size": 546, + "upload_time": f"{upload_date}T05:42:27", + "upload_time_iso_8601": f"{upload_date}T05:42:27.073842Z", + "url": "https://files.pythonhosted.org/packages/de/fa/" + + "2fbcebaeeb909511139ce28dac4a77ab2452ba72b49a22b12981b2f375b3/ttttttttest_nester.py-0.1.0.tar.gz", + "yanked": False, + "yanked_reason": None, + } + ] + } + + pypi_package_json.get_releases.return_value = release + pypi_package_json.get_latest_version.return_value = version + expected_result: tuple[HeuristicResult, dict] = (HeuristicResult.PASS, {"versioning": "semantic"}) + + actual_result = analyzer.analyze(pypi_package_json) + + assert actual_result == expected_result + + +def test_semantic_epoch_fail(pypi_package_json: MagicMock) -> None: + """Test for when the version uses semantic versioning with an epoch above the threshold, expected to fail""" + analyzer = AnomalisticVersionAnalyzer() + version = "999!0.0.0" + upload_date = "2016-10-13" + + release = { + version: [ + { + "comment_text": "", + "digests": { + "blake2b_256": "defa2fbcebaeeb909511139ce28dac4a77ab2452ba72b49a22b12981b2f375b3", + "md5": "9203bbb130f8ddb38269f4861c170d04", + "sha256": "168bcccbf5106132e90b85659297700194369b8f6b3e5a03769614f0d200e370", + }, + "downloads": -1, + "filename": "ttttttttest_nester.py-0.1.0.tar.gz", + "has_sig": False, + "md5_digest": "9203bbb130f8ddb38269f4861c170d04", + "packagetype": "sdist", + "python_version": "source", + "requires_python": None, + "size": 546, + "upload_time": f"{upload_date}T05:42:27", + "upload_time_iso_8601": f"{upload_date}T05:42:27.073842Z", + "url": "https://files.pythonhosted.org/packages/de/fa/" + + "2fbcebaeeb909511139ce28dac4a77ab2452ba72b49a22b12981b2f375b3/ttttttttest_nester.py-0.1.0.tar.gz", + "yanked": False, + "yanked_reason": None, + } + ] + } + + pypi_package_json.get_releases.return_value = release + pypi_package_json.get_latest_version.return_value = version + expected_result: tuple[HeuristicResult, dict] = (HeuristicResult.FAIL, {"versioning": "semantic"}) + + actual_result = analyzer.analyze(pypi_package_json) + + assert actual_result == expected_result