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
37 changes: 37 additions & 0 deletions tests/cli_output.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""Helpers for asserting JSON output from Click CLI tests."""

from __future__ import annotations

import json
from typing import Any


def parse_click_json_output(output: str) -> dict[str, Any]:
"""Parse JSON from `CliRunner` output, tolerating prefixed stderr log lines.

Click's test runner can merge stderr into `result.output`, so scans that emit
findings may prepend log lines before the JSON payload even though a real
subprocess keeps stdout and stderr separate.
"""
try:
parsed = json.loads(output)
except json.JSONDecodeError:
decoder = json.JSONDecoder()
for index, char in enumerate(output):
if char != "{":
continue

try:
parsed, end = decoder.raw_decode(output, index)
except json.JSONDecodeError:
continue

if output[end:].strip():
continue
break
else:
raise

if not isinstance(parsed, dict):
raise TypeError(f"Expected a JSON object, got {type(parsed).__name__}")
return parsed
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ def pytest_runtest_setup(item):
"test_sevenzip_scanner.py", # 7-Zip archive scanner tests
"test_regression_corpus.py", # malicious/safe fixture regression gate
"test_nested_pickle_integration.py", # nested pickle false-positive/true-positive integration tests
"test_cli_output.py", # CliRunner JSON parsing helper regression tests
]

# Check if this is an allowed test file
Expand Down
43 changes: 35 additions & 8 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import json
import os
import re
import subprocess
import sys
from pathlib import Path
from typing import Any
from unittest.mock import patch
Expand All @@ -13,6 +15,7 @@
from modelaudit.cli import cli, expand_paths, format_text_output
from modelaudit.core import scan_model_directory_or_file
from modelaudit.models import ModelAuditResultModel, create_initial_audit_result
from tests.cli_output import parse_click_json_output


def strip_ansi(text: str) -> str:
Expand Down Expand Up @@ -163,18 +166,42 @@ def test_scan_does_not_auto_load_untrusted_local_config(tmp_path: Path) -> None:

model_file = tmp_path / "evil.tar"
with tarfile.open(model_file, "w") as tar:
payload = tmp_path / "payload.txt"
payload.write_text("content")
tar.add(payload, arcname="../evil.txt")
payload_file = tmp_path / "payload.txt"
payload_file.write_text("content")
tar.add(payload_file, arcname="../evil.txt")

(tmp_path / ".modelaudit.toml").write_text('suppress = ["S405"]\n')

runner = CliRunner()
result = runner.invoke(cli, ["scan", str(model_file), "--format", "json"], catch_exceptions=False)

assert result.exit_code == 1
payload = json.loads(result.output)
assert any(issue.get("rule_code") == "S405" for issue in payload.get("issues", []))
output_payload = parse_click_json_output(result.output)
assert any(issue.get("rule_code") == "S405" for issue in output_payload.get("issues", []))


def test_scan_json_subprocess_separates_logs_from_stdout_for_findings(tmp_path: Path) -> None:
"""Real process execution should keep JSON stdout parseable even when findings are logged."""
import tarfile

model_file = tmp_path / "evil.tar"
with tarfile.open(model_file, "w") as tar:
payload_file = tmp_path / "payload.txt"
payload_file.write_text("content")
tar.add(payload_file, arcname="../evil.txt")

completed = subprocess.run(
[sys.executable, "-m", "modelaudit", "scan", str(model_file), "--format", "json"],
check=False,
capture_output=True,
text=True,
)

assert completed.returncode == 1
assert completed.stdout.lstrip().startswith("{")
output_payload = json.loads(completed.stdout)
assert any(issue.get("rule_code") == "S405" for issue in output_payload.get("issues", []))
assert "[S405]" in completed.stderr
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def test_scan_can_apply_local_config_once_when_confirmed(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
Expand Down Expand Up @@ -409,7 +436,7 @@ def test_scan_json_output(tmp_path):
# For JSON output, we should be able to parse the output as JSON
# regardless of the exit code
try:
output_json = json.loads(result.output)
output_json = parse_click_json_output(result.output)
assert "files_scanned" in output_json
assert "issues" in output_json
assert output_json["files_scanned"] == 1
Expand Down Expand Up @@ -469,7 +496,7 @@ def test_scan_json_to_stdout_no_progress_interference(tmp_path):

# Output should be valid JSON when going to stdout (no progress interference)
try:
output_json = json.loads(result.output)
output_json = parse_click_json_output(result.output)
assert "files_scanned" in output_json
assert "issues" in output_json
except json.JSONDecodeError:
Expand Down Expand Up @@ -932,7 +959,7 @@ def file_generator():

# Verify content_hash is in JSON output
try:
output_json = json.loads(result.output)
output_json = parse_click_json_output(result.output)
assert "content_hash" in output_json
assert output_json["content_hash"] == "a" * 64
assert output_json["files_scanned"] == 3
Expand Down
25 changes: 25 additions & 0 deletions tests/test_cli_output.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from __future__ import annotations

import json

import pytest

from tests.cli_output import parse_click_json_output


def test_parse_click_json_output_handles_prefixed_log_lines() -> None:
"""Click tests may prefix stderr logs before the JSON body."""
payload = {"files_scanned": 1, "issues": [{"rule_code": "S405"}]}
output = (
"2026-03-20 16:16:35,821 - modelaudit.scanners - CRITICAL - "
"[S405] [CRITICAL] (evil.tar:../evil.txt): Archive entry ../evil.txt attempted path traversal\n"
f"{json.dumps(payload, indent=2)}\n"
)

assert parse_click_json_output(output) == payload


def test_parse_click_json_output_rejects_non_object_json() -> None:
"""CLI scan JSON should be an object payload, not a scalar or array."""
with pytest.raises(TypeError, match="Expected a JSON object"):
parse_click_json_output("[]")
4 changes: 2 additions & 2 deletions tests/test_nested_pickle_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
capabilities, testing both malicious detection and false positive prevention.
"""

import json
import os
import tempfile
from pathlib import Path
Expand All @@ -17,6 +16,7 @@
from modelaudit.core import determine_exit_code, scan_model_directory_or_file
from modelaudit.scanners.base import IssueSeverity
from modelaudit.scanners.pickle_scanner import PickleScanner
from tests.cli_output import parse_click_json_output


class TestNestedPickleIntegration:
Expand Down Expand Up @@ -197,7 +197,7 @@ def test_cli_nested_pickle_detection(self, pickles_dir):
json_result = runner.invoke(cli, ["scan", str(malicious_file), "--format", "json"])
assert json_result.exit_code == 1, f"JSON format should detect malicious file. Output: {json_result.output}"

output_data = json.loads(json_result.output)
output_data = parse_click_json_output(json_result.output)
nested_issues = [
issue
for issue in output_data["issues"]
Expand Down
Loading