Skip to content

Commit e969fb3

Browse files
committed
chore(tests): improve test coverage and apply minor heuristic changes
Signed-off-by: Amine <amine.raouane@enim.ac.ma>
1 parent d0a0d65 commit e969fb3

File tree

9 files changed

+161
-225
lines changed

9 files changed

+161
-225
lines changed

src/macaron/malware_analyzer/pypi_heuristics/metadata/inconsistent_description.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,9 @@ def analyze(self, pypi_package_json: PyPIPackageJsonAsset) -> tuple[HeuristicRes
107107
user_prompt=description,
108108
response_format=self.RESPONSE_FORMAT,
109109
)
110+
if not analysis_result:
111+
logger.error("LLM returned invalid response, skipping the analysis.")
112+
return HeuristicResult.SKIP, {}
110113

111114
if analysis_result["score"] < self.threshold:
112115
return HeuristicResult.FAIL, {

src/macaron/malware_analyzer/pypi_heuristics/sourcecode/matching_docstrings.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ def analyze(self, pypi_package_json: PyPIPackageJsonAsset) -> tuple[HeuristicRes
9393
logger.warning("No source code found for the package, skipping the matching docstrings analysis.")
9494
return HeuristicResult.SKIP, {}
9595

96+
none_attempts = 5
9697
for file, content in pypi_package_json.iter_sourcecode():
9798
if file.endswith(".py"):
9899
time.sleep(self.REQUEST_INTERVAL) # Respect the request interval to avoid rate limiting.
@@ -101,6 +102,13 @@ def analyze(self, pypi_package_json: PyPIPackageJsonAsset) -> tuple[HeuristicRes
101102
user_prompt=code_str,
102103
response_format=self.RESPONSE_FORMAT,
103104
)
105+
if not analysis_result:
106+
none_attempts -= 1
107+
if none_attempts == 0:
108+
logger.error("LLM returned None multiple times, skipping the analysis.")
109+
return HeuristicResult.SKIP, {}
110+
continue
111+
104112
if analysis_result["decision"] == "inconsistent":
105113
return HeuristicResult.FAIL, {
106114
"file": file,

src/macaron/slsa_analyzer/build_tool/gradle.py

Lines changed: 0 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
# Copyright (c) 2022 - 2025, Oracle and/or its affiliates. All rights reserved.
2-
# Copyright (c) 2022 - 2025, Oracle and/or its affiliates. All rights reserved.
32
# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/.
43

54
"""This module contains the Gradle class which inherits BaseBuildTool.
@@ -70,94 +69,6 @@ def is_detected(self, repo_path: str) -> bool:
7069
gradle_config_files = self.build_configs + self.entry_conf
7170
return any(file_exists(repo_path, file) for file in gradle_config_files)
7271

73-
def prepare_config_files(self, wrapper_path: str, build_dir: str) -> bool:
74-
"""Prepare the necessary wrapper files for running the build.
75-
76-
This method will return False if there is any errors happened during operation.
77-
78-
Parameters
79-
----------
80-
wrapper_path : str
81-
The path where all necessary wrapper files are located.
82-
build_dir : str
83-
The path of the build dir. This is where all files are copied to.
84-
85-
Returns
86-
-------
87-
bool
88-
True if succeed else False.
89-
"""
90-
# The path of the needed wrapper files
91-
wrapper_files = self.wrapper_files
92-
93-
if copy_file_bulk(wrapper_files, wrapper_path, build_dir):
94-
# Ensure that gradlew is executable.
95-
file_path = os.path.join(build_dir, "gradlew")
96-
status = os.stat(file_path)
97-
if oct(status.st_mode)[-3:] != "744":
98-
logger.debug("%s does not have 744 permission. Changing it to 744.")
99-
os.chmod(file_path, 0o744)
100-
return True
101-
102-
return False
103-
104-
def get_dep_analyzer(self) -> CycloneDxGradle:
105-
"""Create a DependencyAnalyzer for the Gradle build tool.
106-
107-
Returns
108-
-------
109-
CycloneDxGradle
110-
The CycloneDxGradle object.
111-
112-
Raises
113-
------
114-
DependencyAnalyzerError
115-
"""
116-
if "dependency.resolver" not in defaults or "dep_tool_gradle" not in defaults["dependency.resolver"]:
117-
raise DependencyAnalyzerError("No default dependency analyzer is found.")
118-
if not DependencyAnalyzer.tool_valid(defaults.get("dependency.resolver", "dep_tool_gradle")):
119-
raise DependencyAnalyzerError(
120-
f"Dependency analyzer {defaults.get('dependency.resolver', 'dep_tool_gradle')} is not valid.",
121-
)
122-
123-
tool_name, tool_version = tuple(
124-
defaults.get(
125-
"dependency.resolver",
126-
"dep_tool_gradle",
127-
fallback="cyclonedx-gradle:1.7.3",
128-
).split(":")
129-
)
130-
if tool_name == DependencyTools.CYCLONEDX_GRADLE:
131-
return CycloneDxGradle(
132-
resources_path=global_config.resources_path,
133-
file_name="bom.json",
134-
tool_name=tool_name,
135-
tool_version=tool_version,
136-
)
137-
138-
raise DependencyAnalyzerError(f"Unsupported SBOM generator for Gradle: {tool_name}.")
139-
140-
def get_gradle_exec(self, repo_path: str) -> str:
141-
"""Get the Gradle executable for the repo.
142-
143-
Parameters
144-
----------
145-
repo_path: str
146-
The absolute path to a repository containing Gradle projects.
147-
148-
Returns
149-
-------
150-
str
151-
The absolute path to the Gradle executable.
152-
"""
153-
# We try to use the gradlew that comes with the repository first.
154-
repo_gradlew = os.path.join(repo_path, "gradlew")
155-
if os.path.isfile(repo_gradlew) and os.access(repo_gradlew, os.X_OK):
156-
return repo_gradlew
157-
158-
# We use Macaron's built-in gradlew as a fallback option.
159-
return os.path.join(os.path.join(macaron.MACARON_PATH, "resources"), "gradlew")
160-
16172
def get_group_id(self, gradle_exec: str, project_path: str) -> str | None:
16273
"""Get the group id of a Gradle project.
16374

src/macaron/slsa_analyzer/build_tool/maven.py

Lines changed: 0 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -64,71 +64,3 @@ def is_detected(self, repo_path: str) -> bool:
6464
return False
6565
maven_config_files = self.build_configs
6666
return any(file_exists(repo_path, file) for file in maven_config_files)
67-
68-
def prepare_config_files(self, wrapper_path: str, build_dir: str) -> bool:
69-
"""Prepare the necessary wrapper files for running the build.
70-
71-
This method will return False if there is any errors happened during operation.
72-
73-
Parameters
74-
----------
75-
wrapper_path : str
76-
The path where all necessary wrapper files are located.
77-
build_dir : str
78-
The path of the build dir. This is where all files are copied to.
79-
80-
Returns
81-
-------
82-
bool
83-
True if succeed else False.
84-
"""
85-
# The path of the needed wrapper files
86-
wrapper_files = self.wrapper_files
87-
88-
if copy_file_bulk(wrapper_files, wrapper_path, build_dir):
89-
# Ensure that mvnw is executable.
90-
file_path = os.path.join(build_dir, "mvnw")
91-
status = os.stat(file_path)
92-
if oct(status.st_mode)[-3:] != "744":
93-
logger.debug("%s does not have 744 permission. Changing it to 744.")
94-
os.chmod(file_path, 0o744)
95-
return True
96-
97-
return False
98-
99-
def get_dep_analyzer(self) -> CycloneDxMaven:
100-
"""
101-
Create a DependencyAnalyzer for the Maven build tool.
102-
103-
Returns
104-
-------
105-
CycloneDxMaven
106-
The CycloneDxMaven object.
107-
108-
Raises
109-
------
110-
DependencyAnalyzerError
111-
"""
112-
if "dependency.resolver" not in defaults or "dep_tool_maven" not in defaults["dependency.resolver"]:
113-
raise DependencyAnalyzerError("No default dependency analyzer is found.")
114-
if not DependencyAnalyzer.tool_valid(defaults.get("dependency.resolver", "dep_tool_maven")):
115-
raise DependencyAnalyzerError(
116-
f"Dependency analyzer {defaults.get('dependency.resolver', 'dep_tool_maven')} is not valid.",
117-
)
118-
119-
tool_name, tool_version = tuple(
120-
defaults.get(
121-
"dependency.resolver",
122-
"dep_tool_maven",
123-
fallback="cyclonedx-maven:2.6.2",
124-
).split(":")
125-
)
126-
if tool_name == DependencyTools.CYCLONEDX_MAVEN:
127-
return CycloneDxMaven(
128-
resources_path=global_config.resources_path,
129-
file_name="bom.json",
130-
tool_name=tool_name,
131-
tool_version=tool_version,
132-
)
133-
134-
raise DependencyAnalyzerError(f"Unsupported SBOM generator for Maven: {tool_name}.")

src/macaron/slsa_analyzer/build_tool/pip.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,6 @@ def get_dep_analyzer(self) -> DependencyAnalyzer:
6464
DependencyAnalyzer
6565
The DependencyAnalyzer object.
6666
"""
67-
tool_name = "cyclonedx_py"
68-
if not DependencyAnalyzer.tool_valid(f"{tool_name}:{cyclonedx_version}"):
69-
raise DependencyAnalyzerError(
70-
f"Dependency analyzer {defaults.get('dependency.resolver', 'dep_tool_gradle')} is not valid.",
71-
)
7267
return CycloneDxPython(
7368
resources_path=global_config.resources_path,
7469
file_name="python_sbom.json",

src/macaron/slsa_analyzer/build_tool/poetry.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -102,11 +102,6 @@ def get_dep_analyzer(self) -> DependencyAnalyzer:
102102
DependencyAnalyzer
103103
The DependencyAnalyzer object.
104104
"""
105-
tool_name = "cyclonedx_py"
106-
if not DependencyAnalyzer.tool_valid(f"{tool_name}:{cyclonedx_version}"):
107-
raise DependencyAnalyzerError(
108-
f"Dependency analyzer {defaults.get('dependency.resolver', 'dep_tool_gradle')} is not valid.",
109-
)
110105
return CycloneDxPython(
111106
resources_path=global_config.resources_path,
112107
file_name="python_sbom.json",

src/macaron/slsa_analyzer/checks/detect_malicious_metadata_check.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -450,8 +450,9 @@ def run_check(self, ctx: AnalyzeContext) -> CheckResultData:
450450
failed({Heuristics.SIMILAR_PROJECTS.value}),
451451
failed({Heuristics.HIGH_RELEASE_FREQUENCY.value}),
452452
failed({Heuristics.FAKE_EMAIL.value}).
453+
453454
% Package released with a name similar to a popular package.
454-
{Confidence.MEDIUM.value}::trigger(malware_medium_confidence_3) :-
455+
{Confidence.MEDIUM.value}::trigger(malware_medium_confidence_5) :-
455456
quickUndetailed, forceSetup, failed({Heuristics.MATCHING_DOCSTRINGS.value}).
456457
457458
% ----- Evaluation -----
@@ -461,6 +462,7 @@ def run_check(self, ctx: AnalyzeContext) -> CheckResultData:
461462
{problog_result_access} :- trigger(malware_high_confidence_2).
462463
{problog_result_access} :- trigger(malware_high_confidence_3).
463464
{problog_result_access} :- trigger(malware_high_confidence_4).
465+
{problog_result_access} :- trigger(malware_medium_confidence_5).
464466
{problog_result_access} :- trigger(malware_medium_confidence_4).
465467
{problog_result_access} :- trigger(malware_medium_confidence_3).
466468
{problog_result_access} :- trigger(malware_medium_confidence_2).

tests/malware_analyzer/pypi/test_inconsistent_description.py

Lines changed: 111 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -27,35 +27,16 @@ def skip_if_client_disabled(analyzer: InconsistentDescriptionAnalyzer) -> None:
2727
pytest.skip("AI client disabled - skipping test")
2828

2929

30-
def test_analyze_consistent_description_pass(
31-
analyzer: InconsistentDescriptionAnalyzer, pypi_package_json: MagicMock
32-
) -> None:
33-
"""Test the analyzer passes when the description is consistent."""
34-
pypi_package_json.package_json = {"info": {"description": "This is a test package."}}
35-
mock_result = {"score": 80, "reason": "The description is consistent."}
36-
37-
with patch.object(analyzer.client, "invoke", return_value=mock_result) as mock_invoke:
38-
result, info = analyzer.analyze(pypi_package_json)
39-
assert result == HeuristicResult.PASS
40-
assert isinstance(info["message"], str)
41-
assert "consistent description with a 80 score" in info["message"]
42-
mock_invoke.assert_called_once()
43-
44-
4530
def test_analyze_inconsistent_description_fail(
4631
analyzer: InconsistentDescriptionAnalyzer, pypi_package_json: MagicMock
4732
) -> None:
4833
"""Test the analyzer fails when the description is inconsistent."""
4934
pypi_package_json.package_json = {"info": {"description": "This is a misleading package."}}
50-
mock_result = {"score": 30, "reason": "The description is misleading."}
5135

52-
with patch.object(analyzer.client, "invoke", return_value=mock_result) as mock_invoke:
53-
result, info = analyzer.analyze(pypi_package_json)
54-
assert result == HeuristicResult.FAIL
55-
assert isinstance(info["message"], str)
56-
assert "inconsistent description with score 30" in info["message"]
57-
assert "because The description is misleading" in info["message"]
58-
mock_invoke.assert_called_once()
36+
result, info = analyzer.analyze(pypi_package_json)
37+
assert result == HeuristicResult.FAIL
38+
assert isinstance(info["message"], str)
39+
assert info["message"].startswith("inconsistent description with score")
5940

6041

6142
def test_analyze_no_description_fail(analyzer: InconsistentDescriptionAnalyzer, pypi_package_json: MagicMock) -> None:
@@ -73,3 +54,110 @@ def test_analyze_no_info_raises_error(analyzer: InconsistentDescriptionAnalyzer,
7354
pypi_package_json.package_json = {}
7455
with pytest.raises(HeuristicAnalyzerValueError):
7556
analyzer.analyze(pypi_package_json)
57+
58+
59+
CONSISTENT_DESCRIPTION = """
60+
# Requests
61+
62+
**Requests** is a simple, yet elegant, HTTP library.
63+
64+
Requests allows you to send HTTP/1.1 requests extremely easily.
65+
There’s no need to manually add query strings to your URLs,
66+
or to form-encode your `PUT` & `POST` data — but nowadays, just use the `json` method!
67+
68+
Requests is one of the most downloaded Python packages today,
69+
pulling in around `30M downloads / week`— according to GitHub,
70+
Requests is currently
71+
[depended upon](https://github.com/psf/requests/network/dependents?package_id=UGFja2FnZS01NzA4OTExNg%3D%3D)
72+
by `1,000,000+` repositories.
73+
You may certainly put your trust in this code.
74+
75+
[![Downloads](https://static.pepy.tech/badge/requests/month)](https://pepy.tech/project/requests)
76+
[![Supported Versions](https://img.shields.io/pypi/pyversions/requests.svg)](https://pypi.org/project/requests)
77+
[![Contributors](https://img.shields.io/github/contributors/psf/requests.svg)](https://github.com/psf/requests/graphs/contributors)
78+
79+
## Installing Requests and Supported Versions
80+
81+
Requests is available on PyPI:
82+
83+
```console
84+
$ python -m pip install requests
85+
```
86+
87+
Requests officially supports Python 3.9+.
88+
89+
## Supported Features & Best–Practices
90+
91+
Requests is ready for the demands of building robust and reliable HTTP–speaking applications, for the needs of today.
92+
93+
- Keep-Alive & Connection Pooling
94+
- International Domains and URLs
95+
- Sessions with Cookie Persistence
96+
- Browser-style TLS/SSL Verification
97+
- Basic & Digest Authentication
98+
- Familiar `dict`–like Cookies
99+
- Automatic Content Decompression and Decoding
100+
- Multi-part File Uploads
101+
- SOCKS Proxy Support
102+
- Connection Timeouts
103+
- Streaming Downloads
104+
- Automatic honoring of `.netrc`
105+
- Chunked HTTP Requests
106+
107+
## API Reference and User Guide available on [Read the Docs](https://requests.readthedocs.io)
108+
109+
[![Read the Docs](https://raw.githubusercontent.com/psf/requests/main/ext/ss.png)](https://requests.readthedocs.io)
110+
111+
## Cloning the repository
112+
113+
When cloning the Requests repository, you may need to add the `-c
114+
fetch.fsck.badTimezone=ignore` flag to avoid an error about a bad commit timestamp (see
115+
[this issue](https://github.com/psf/requests/issues/2690) for more background):
116+
117+
```shell
118+
git clone -c fetch.fsck.badTimezone=ignore https://github.com/psf/requests.git
119+
```
120+
121+
You can also apply this setting to your global Git config:
122+
123+
```shell
124+
git config --global fetch.fsck.badTimezone ignore
125+
```
126+
127+
---
128+
129+
[![Kenneth Reitz](https://raw.githubusercontent.com/psf/requests/main/ext/kr.png)](https://kennethreitz.org)
130+
[![Python Software Foundation](https://raw.githubusercontent.com/psf/requests/main/ext/psf.png)](https://www.python.org/psf)
131+
"""
132+
133+
134+
def test_analyze_consistent_description_pass(
135+
analyzer: InconsistentDescriptionAnalyzer, pypi_package_json: MagicMock
136+
) -> None:
137+
"""Test the analyzer passes when the description is consistent."""
138+
pypi_package_json.package_json = {
139+
"info": {
140+
"description": CONSISTENT_DESCRIPTION,
141+
}
142+
}
143+
144+
result, info = analyzer.analyze(pypi_package_json)
145+
assert result == HeuristicResult.PASS
146+
assert isinstance(info["message"], str)
147+
assert info["message"].startswith("consistent description with a ")
148+
149+
150+
def test_analyze_excessive_llm_invocation_error_skip(
151+
analyzer: InconsistentDescriptionAnalyzer, pypi_package_json: MagicMock
152+
) -> None:
153+
"""Test the analyzer skips if the LLM invocation returns None multiple times."""
154+
pypi_package_json.package_json = {
155+
"info": {
156+
"description": "description",
157+
}
158+
}
159+
160+
with patch.object(analyzer.client, "invoke", return_value=None):
161+
result, info = analyzer.analyze(pypi_package_json)
162+
assert result == HeuristicResult.SKIP
163+
assert not info

0 commit comments

Comments
 (0)