diff --git a/src/macaron/malware_analyzer/README.md b/src/macaron/malware_analyzer/README.md index 9e581d76b..468aafc1b 100644 --- a/src/macaron/malware_analyzer/README.md +++ b/src/macaron/malware_analyzer/README.md @@ -70,6 +70,17 @@ When a heuristic fails, with `HeuristicResult.FAIL`, then that is an indicator b - **Rule**: Return `HeuristicResult.FAIL` if the email is invalid; otherwise, return `HeuristicResult.PASS`. - **Dependency**: None. + +12. **Minimal Content** + - **Description**: Checks if the package has a small number of files. + - **Rule**: Return `HeuristicResult.FAIL` if the number of files is strictly less than FILES_THRESHOLD; otherwise, return `HeuristicResult.PASS`. + - **Dependency**: None. + +13. **Unsecure Description** + - **Description**: Checks if the package description is unsecure, such as not having a descriptive keywords that indicates its a stub package . + - **Rule**: Return `HeuristicResult.FAIL` if no descriptive word is found in the package description or summary ; otherwise, return `HeuristicResult.PASS`. + - **Dependency**: None. + ### Source Code Analysis with Semgrep **PyPI Source Code Analyzer** - **Description**: Uses Semgrep, with default rules written in `src/macaron/resources/pypi_malware_rules` and custom rules available by supplying a path to `custom_semgrep_rules` in `defaults.ini`, to scan the package `.tar` source code. diff --git a/src/macaron/malware_analyzer/pypi_heuristics/heuristics.py b/src/macaron/malware_analyzer/pypi_heuristics/heuristics.py index 1f1fdbf2e..3c1507a11 100644 --- a/src/macaron/malware_analyzer/pypi_heuristics/heuristics.py +++ b/src/macaron/malware_analyzer/pypi_heuristics/heuristics.py @@ -49,6 +49,12 @@ class Heuristics(str, Enum): #: Indicates that the package has a similar structure to other packages maintained by the same user. SIMILAR_PROJECTS = "similar_projects" + #: Indicates that the package has minimal content. + MINIMAL_CONTENT = "minimal_content" + + #: Indicates that the package's description is unsecure, such as not having a descriptive keywords. + UNSECURE_DESCRIPTION = "unsecure_description" + class HeuristicResult(str, Enum): """Result type indicating the outcome of a heuristic.""" diff --git a/src/macaron/malware_analyzer/pypi_heuristics/metadata/minimal_content.py b/src/macaron/malware_analyzer/pypi_heuristics/metadata/minimal_content.py new file mode 100644 index 000000000..47f6dc5cd --- /dev/null +++ b/src/macaron/malware_analyzer/pypi_heuristics/metadata/minimal_content.py @@ -0,0 +1,54 @@ +# Copyright (c) 2024 - 2025, 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/. + +"""This analyzer checks if a PyPI package has minimal content.""" + +import logging +import os + +from macaron.errors import SourceCodeError +from macaron.json_tools import JsonType +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 MinimalContentAnalyzer(BaseHeuristicAnalyzer): + """Check whether the package has minimal content.""" + + FILES_THRESHOLD = 50 + + def __init__(self) -> None: + super().__init__( + name="minimal_content_analyzer", + heuristic=Heuristics.MINIMAL_CONTENT, + depends_on=None, + ) + + 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. + """ + result = pypi_package_json.download_sourcecode() + if not result: + error_msg = "No source code files have been downloaded" + logger.debug(error_msg) + raise SourceCodeError(error_msg) + + file_count = sum(len(files) for _, _, files in os.walk(pypi_package_json.package_sourcecode_path)) + + if file_count >= self.FILES_THRESHOLD: + return HeuristicResult.PASS, {"message": "Package has sufficient content"} + + return HeuristicResult.FAIL, {"message": "Not enough files found"} diff --git a/src/macaron/malware_analyzer/pypi_heuristics/metadata/unsecure_description.py b/src/macaron/malware_analyzer/pypi_heuristics/metadata/unsecure_description.py new file mode 100644 index 000000000..c9ffce52c --- /dev/null +++ b/src/macaron/malware_analyzer/pypi_heuristics/metadata/unsecure_description.py @@ -0,0 +1,56 @@ +# Copyright (c) 2024 - 2025, 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/. + +"""This analyzer checks if a PyPI package has unsecure description.""" + +import logging +import re + +from macaron.errors import HeuristicAnalyzerValueError +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.slsa_analyzer.package_registry.pypi_registry import PyPIPackageJsonAsset + +logger: logging.Logger = logging.getLogger(__name__) + + +class UnsecureDescriptionAnalyzer(BaseHeuristicAnalyzer): + """Check whether the package's description is unsecure.""" + + SECURE_DESCRIPTION_REGEX = re.compile( + r"\b(?:internal|private|stub|placeholder|dependency confusion|security|namespace protection|reserved|harmless|prevent)\b", + re.IGNORECASE, + ) + + def __init__(self) -> None: + super().__init__( + name="unsecure_description_analyzer", heuristic=Heuristics.UNSECURE_DESCRIPTION, depends_on=None + ) + + 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. + """ + package_json = pypi_package_json.package_json + info = package_json.get("info", {}) + if not info: + error_msg = "No package info found in metadata" + logger.debug(error_msg) + raise HeuristicAnalyzerValueError(error_msg) + + description = json_extract(package_json, ["info", "description"], str) + summary = json_extract(package_json, ["info", "summary"], str) + data = f"{description} {summary}" + if self.SECURE_DESCRIPTION_REGEX.search(data): + return HeuristicResult.PASS, {"message": "Package description is secure"} + return HeuristicResult.FAIL, {"message": "Package description is unsecure"} 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 9f09362a4..03f4aa093 100644 --- a/src/macaron/slsa_analyzer/checks/detect_malicious_metadata_check.py +++ b/src/macaron/slsa_analyzer/checks/detect_malicious_metadata_check.py @@ -22,11 +22,13 @@ from macaron.malware_analyzer.pypi_heuristics.metadata.empty_project_link import EmptyProjectLinkAnalyzer from macaron.malware_analyzer.pypi_heuristics.metadata.fake_email import FakeEmailAnalyzer from macaron.malware_analyzer.pypi_heuristics.metadata.high_release_frequency import HighReleaseFrequencyAnalyzer +from macaron.malware_analyzer.pypi_heuristics.metadata.minimal_content import MinimalContentAnalyzer from macaron.malware_analyzer.pypi_heuristics.metadata.one_release import OneReleaseAnalyzer from macaron.malware_analyzer.pypi_heuristics.metadata.similar_projects import SimilarProjectAnalyzer from macaron.malware_analyzer.pypi_heuristics.metadata.source_code_repo import SourceCodeRepoAnalyzer from macaron.malware_analyzer.pypi_heuristics.metadata.typosquatting_presence import TyposquattingPresenceAnalyzer from macaron.malware_analyzer.pypi_heuristics.metadata.unchanged_release import UnchangedReleaseAnalyzer +from macaron.malware_analyzer.pypi_heuristics.metadata.unsecure_description import UnsecureDescriptionAnalyzer from macaron.malware_analyzer.pypi_heuristics.metadata.wheel_absence import WheelAbsenceAnalyzer from macaron.malware_analyzer.pypi_heuristics.sourcecode.pypi_sourcecode_analyzer import PyPISourcecodeAnalyzer from macaron.malware_analyzer.pypi_heuristics.sourcecode.suspicious_setup import SuspiciousSetupAnalyzer @@ -366,6 +368,8 @@ def run_check(self, ctx: AnalyzeContext) -> CheckResultData: TyposquattingPresenceAnalyzer, FakeEmailAnalyzer, SimilarProjectAnalyzer, + UnsecureDescriptionAnalyzer, + MinimalContentAnalyzer, ] # name used to query the result of all problog rules, so it can be accessed outside the model. @@ -419,6 +423,13 @@ def run_check(self, ctx: AnalyzeContext) -> CheckResultData: {Confidence.HIGH.value}::trigger(malware_high_confidence_4) :- quickUndetailed, forceSetup, failed({Heuristics.TYPOSQUATTING_PRESENCE.value}). + % Package released with dependency confusion . + {Confidence.HIGH.value}::trigger(malware_high_confidence_5) :- + forceSetup, + passed({Heuristics.MINIMAL_CONTENT.value}), + failed({Heuristics.ANOMALOUS_VERSION.value}), + failed({Heuristics.UNSECURE_DESCRIPTION.value}). + % Package released recently with little detail, with multiple releases as a trust marker, but frequent and with % the same code. {Confidence.MEDIUM.value}::trigger(malware_medium_confidence_1) :- @@ -431,7 +442,8 @@ def run_check(self, ctx: AnalyzeContext) -> CheckResultData: {Confidence.MEDIUM.value}::trigger(malware_medium_confidence_2) :- quickUndetailed, failed({Heuristics.ONE_RELEASE.value}), - failed({Heuristics.ANOMALOUS_VERSION.value}). + failed({Heuristics.ANOMALOUS_VERSION.value}), + failed({Heuristics.UNSECURE_DESCRIPTION.value}). % Package has no links, one release or multiple quick releases, and a suspicious maintainer who recently % joined, has a fake email address, and other similarly-structured projects. @@ -445,6 +457,7 @@ def run_check(self, ctx: AnalyzeContext) -> CheckResultData: failed({Heuristics.SIMILAR_PROJECTS.value}), failed({Heuristics.HIGH_RELEASE_FREQUENCY.value}), failed({Heuristics.FAKE_EMAIL.value}). + % ----- Evaluation ----- % Aggregate result @@ -452,6 +465,7 @@ def run_check(self, ctx: AnalyzeContext) -> CheckResultData: {problog_result_access} :- trigger(malware_high_confidence_2). {problog_result_access} :- trigger(malware_high_confidence_3). {problog_result_access} :- trigger(malware_high_confidence_4). + {problog_result_access} :- trigger(malware_high_confidence_5). {problog_result_access} :- trigger(malware_medium_confidence_1). {problog_result_access} :- trigger(malware_medium_confidence_2). {problog_result_access} :- trigger(malware_medium_confidence_3). diff --git a/tests/malware_analyzer/pypi/test_minimal_content.py b/tests/malware_analyzer/pypi/test_minimal_content.py new file mode 100644 index 000000000..1ebe3714c --- /dev/null +++ b/tests/malware_analyzer/pypi/test_minimal_content.py @@ -0,0 +1,107 @@ +# Copyright (c) 2024 - 2025, 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 the MinimalContentAnalyzer heuristic.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from macaron.errors import SourceCodeError +from macaron.malware_analyzer.pypi_heuristics.heuristics import HeuristicResult +from macaron.malware_analyzer.pypi_heuristics.metadata.minimal_content import MinimalContentAnalyzer + + +@pytest.fixture(name="analyzer") +def analyzer_() -> MinimalContentAnalyzer: + """Pytest fixture to create a MinimalContentAnalyzer instance.""" + return MinimalContentAnalyzer() + + +def test_analyze_sufficient_files_pass(analyzer: MinimalContentAnalyzer, pypi_package_json: MagicMock) -> None: + """Test the analyzer passes when the package has sufficient files.""" + pypi_package_json.download_sourcecode.return_value = True + pypi_package_json.package_sourcecode_path = "/fake/path" + with patch("os.walk") as mock_walk: + mock_walk.return_value = [("root", [], [f"file{i}.py" for i in range(60)])] + result, info = analyzer.analyze(pypi_package_json) + + assert result == HeuristicResult.PASS + assert info == {"message": "Package has sufficient content"} + pypi_package_json.download_sourcecode.assert_called_once() + + +def test_analyze_exactly_threshold_files_pass(analyzer: MinimalContentAnalyzer, pypi_package_json: MagicMock) -> None: + """Test the analyzer passes when the package has exactly the threshold number of files.""" + pypi_package_json.download_sourcecode.return_value = True + pypi_package_json.package_sourcecode_path = "/fake/path" + with patch("os.walk") as mock_walk: + mock_walk.return_value = [("root", [], [f"file{i}.py" for i in range(50)])] + result, info = analyzer.analyze(pypi_package_json) + + assert result == HeuristicResult.PASS + assert info == {"message": "Package has sufficient content"} + + +def test_analyze_insufficient_files_fail(analyzer: MinimalContentAnalyzer, pypi_package_json: MagicMock) -> None: + """Test the analyzer fails when the package has insufficient files.""" + pypi_package_json.download_sourcecode.return_value = True + pypi_package_json.package_sourcecode_path = "/fake/path" + with patch("os.walk") as mock_walk: + mock_walk.return_value = [("root", [], ["file1.py"])] + result, info = analyzer.analyze(pypi_package_json) + + assert result == HeuristicResult.FAIL + assert info == {"message": "Not enough files found"} + + +def test_analyze_no_files_fail(analyzer: MinimalContentAnalyzer, pypi_package_json: MagicMock) -> None: + """Test the analyzer fails when the package has no files.""" + pypi_package_json.download_sourcecode.return_value = True + pypi_package_json.package_sourcecode_path = "/fake/path" + with patch("os.walk") as mock_walk: + mock_walk.return_value = [("root", [], [])] + result, info = analyzer.analyze(pypi_package_json) + + assert result == HeuristicResult.FAIL + assert info == {"message": "Not enough files found"} + + +def test_analyze_download_failed_raises_error(analyzer: MinimalContentAnalyzer, pypi_package_json: MagicMock) -> None: + """Test the analyzer raises SourceCodeError when source code download fails.""" + pypi_package_json.download_sourcecode.return_value = False + + with pytest.raises(SourceCodeError) as exc_info: + analyzer.analyze(pypi_package_json) + + assert "No source code files have been downloaded" in str(exc_info.value) + pypi_package_json.download_sourcecode.assert_called_once() + + +@pytest.mark.parametrize( + ("file_count", "expected_result"), + [ + (0, HeuristicResult.FAIL), + (1, HeuristicResult.FAIL), + (2, HeuristicResult.FAIL), + (55, HeuristicResult.PASS), + (70, HeuristicResult.PASS), + ], +) +def test_analyze_various_file_counts( + analyzer: MinimalContentAnalyzer, + pypi_package_json: MagicMock, + file_count: int, + expected_result: HeuristicResult, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test the analyzer with various file counts.""" + pypi_package_json.download_sourcecode.return_value = True + pypi_package_json.package_sourcecode_path = "/fake/path" + files = [f"file{i}.py" for i in range(file_count)] + mock_walk = MagicMock(return_value=[("root", [], files)]) + monkeypatch.setattr("os.walk", mock_walk) + + result, _ = analyzer.analyze(pypi_package_json) + + assert result == expected_result diff --git a/tests/malware_analyzer/pypi/test_unsecure_description.py b/tests/malware_analyzer/pypi/test_unsecure_description.py new file mode 100644 index 000000000..d8c7ae805 --- /dev/null +++ b/tests/malware_analyzer/pypi/test_unsecure_description.py @@ -0,0 +1,67 @@ +# Copyright (c) 2024 - 2025, 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 the UnsecureDescriptionAnalyzer heuristic.""" + +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.unsecure_description import UnsecureDescriptionAnalyzer + + +@pytest.fixture(name="analyzer") +def analyzer_() -> UnsecureDescriptionAnalyzer: + """Pytest fixture to create an UnsecureDescriptionAnalyzer instance.""" + return UnsecureDescriptionAnalyzer() + + +def test_analyze_secure_description_pass(analyzer: UnsecureDescriptionAnalyzer, pypi_package_json: MagicMock) -> None: + """Test the analyzer passes when the package description is secure.""" + pypi_package_json.package_json = {"info": {"description": "This is an internal package."}} + result, info = analyzer.analyze(pypi_package_json) + assert result == HeuristicResult.PASS + assert info["message"] == "Package description is secure" + + +def test_analyze_unsecure_description_fail(analyzer: UnsecureDescriptionAnalyzer, pypi_package_json: MagicMock) -> None: + """Test the analyzer fails when the package description is unsecure.""" + pypi_package_json.package_json = {"info": {"description": "A public utility library."}} + result, info = analyzer.analyze(pypi_package_json) + assert result == HeuristicResult.FAIL + assert info["message"] == "Package description is unsecure" + + +def test_analyze_no_info_skip(analyzer: UnsecureDescriptionAnalyzer, pypi_package_json: MagicMock) -> None: + """Test the analyzer raises an error when no package info is found.""" + pypi_package_json.package_json = {} + with pytest.raises(HeuristicAnalyzerValueError) as exc_info: + analyzer.analyze(pypi_package_json) + assert "No package info found in metadata" in str(exc_info.value) + + +@pytest.mark.parametrize( + ("metadata", "expected_result"), + [ + ({"description": "For internal use only"}, HeuristicResult.PASS), + ({"summary": "This is a private package"}, HeuristicResult.PASS), + ({"description": "A placeholder for a future project"}, HeuristicResult.PASS), + ({"summary": "Used for dependency confusion testing"}, HeuristicResult.PASS), + ({"description": "A package for security research"}, HeuristicResult.PASS), + ({"summary": "This name is reserved for namespace protection"}, HeuristicResult.PASS), + ({"description": "This is a stub package"}, HeuristicResult.PASS), + ({"description": "A regular package", "summary": "Does regular things"}, HeuristicResult.FAIL), + ], +) +def test_analyze_scenarios( + analyzer: UnsecureDescriptionAnalyzer, + pypi_package_json: MagicMock, + metadata: dict, + expected_result: HeuristicResult, +) -> None: + """Test the analyzer with various metadata scenarios.""" + pypi_package_json.package_json = {"info": metadata} + result, _ = analyzer.analyze(pypi_package_json) + assert result == expected_result