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
11 changes: 11 additions & 0 deletions src/macaron/malware_analyzer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions src/macaron/malware_analyzer/pypi_heuristics/heuristics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Comment thread
behnazh-w marked this conversation as resolved.

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"}
Original file line number Diff line number Diff line change
@@ -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):
Comment thread
behnazh-w marked this conversation as resolved.
"""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"}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) :-
Comment thread
behnazh-w marked this conversation as resolved.
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) :-
Expand All @@ -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.
Expand All @@ -445,13 +457,15 @@ 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
{problog_result_access} :- trigger(malware_high_confidence_1).
{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).
Expand Down
107 changes: 107 additions & 0 deletions tests/malware_analyzer/pypi/test_minimal_content.py
Original file line number Diff line number Diff line change
@@ -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
67 changes: 67 additions & 0 deletions tests/malware_analyzer/pypi/test_unsecure_description.py
Original file line number Diff line number Diff line change
@@ -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
Loading