From 713940829dd99388590c43bd27f11ba1832bcb96 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Fri, 31 Jul 2026 15:12:04 -0700 Subject: [PATCH 1/2] fix(joblib): fail closed on inconclusive warning scans --- CHANGELOG.md | 1 + modelaudit/scanners/joblib_scanner.py | 9 +------ tests/scanners/test_joblib_scanner_codecs.py | 28 ++++++++++++++++++++ 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05b918dad..7a6319b51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Bug Fixes +- Keep incomplete Joblib NumPy-wrapper scans failed closed when embedded pickle analysis also reports warnings. - Avoid network false positives for bounded README examples that download sample images over HTTPS from Hugging Face. - Prevent Windows cache identity probes from creating locked temporary files inside scanned directories. - Preserve locked Windows cache probes reached through directory aliases while clearing stale scan results. diff --git a/modelaudit/scanners/joblib_scanner.py b/modelaudit/scanners/joblib_scanner.py index b73e0f22c..e7c785242 100644 --- a/modelaudit/scanners/joblib_scanner.py +++ b/modelaudit/scanners/joblib_scanner.py @@ -1530,15 +1530,8 @@ def scan(self, path: str) -> ScanResult: result.finish(success=False) return result - has_security_findings = any( - issue.severity in {IssueSeverity.WARNING, IssueSeverity.CRITICAL} for issue in result.issues - ) has_trusted_incomplete_tail = result.metadata.get("trusted_incomplete_tail") is True - if ( - result.metadata.get("scan_outcome") == INCONCLUSIVE_SCAN_OUTCOME - and not has_security_findings - and not has_trusted_incomplete_tail - ): + if result.metadata.get("scan_outcome") == INCONCLUSIVE_SCAN_OUTCOME and not has_trusted_incomplete_tail: result.finish(success=False) else: result.finish(success=not result.has_errors) diff --git a/tests/scanners/test_joblib_scanner_codecs.py b/tests/scanners/test_joblib_scanner_codecs.py index 43515e512..b84382ee7 100644 --- a/tests/scanners/test_joblib_scanner_codecs.py +++ b/tests/scanners/test_joblib_scanner_codecs.py @@ -1376,6 +1376,34 @@ def test_scan_fails_closed_on_protocol1_pickle_in_numpy_wrapper_tail(tmp_path: P assert "trusted_incomplete_tail" not in result.metadata +def test_scan_fails_closed_on_invalid_numpy_wrapper_with_origin_warning(tmp_path: Path) -> None: + path = tmp_path / "numpy_array_protocol1_origin_warning.joblib" + path.write_bytes(_joblib_numpy_list_payload(resumed_ops=b"\x80\x01K\x01.")) + + embedded_result = ScanResult("pickle") + embedded_result.add_check( + name="Standalone Pickle Finding", + passed=False, + message="Joblib NumPy wrapper requires origin verification", + severity=IssueSeverity.WARNING, + details={"import_reference": "joblib.numpy_pickle.NumpyArrayWrapper"}, + rule_code="NON_ALLOWLISTED_GLOBAL", + ) + embedded_result.finish(success=True) + + scanner = JoblibScanner() + scanner.pickle_scanner = _StaticEmbeddedPickleScanner(embedded_result) + result = scanner.scan(str(path)) + + assert result.success is False + assert result.metadata["scan_outcome"] == INCONCLUSIVE_SCAN_OUTCOME + assert result.metadata["analysis_incomplete"] is True + assert "joblib_numpy_array_wrapper_validation_failed" in result.metadata["scan_outcome_reasons"] + assert result.has_warnings is True + assert "trusted_incomplete_tail" not in result.metadata + assert should_cache_scan_result(result.to_dict(include_private_metadata=True)) is False + + def test_scan_detects_gzip_compressed_pickle_joblib(tmp_path: Path) -> None: path = tmp_path / "gzip_protocol4.joblib" path.write_bytes(gzip.compress(pickle.dumps(_Payload(), protocol=4))) From b5a98dd3244882e6520ef217d9a066e9da85633b Mon Sep 17 00:00:00 2001 From: mldangelo Date: Sat, 1 Aug 2026 06:04:42 -0700 Subject: [PATCH 2/2] test(joblib): reproduce the Windows fail-open path through the real pickle scanner The existing regression stubs out the embedded pickle scanner entirely, so it proves the guard but not the lane that actually failed. This adds a companion regression that keeps real payload parsing and metadata composition and forces only the embedded verdict to success=True - the state Windows reaches when the embedded result retains a trusted incomplete tail. Both regressions fail on main and pass with the scan() guard. Co-Authored-By: Claude Opus 5 (1M context) --- tests/scanners/test_joblib_scanner_codecs.py | 37 ++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/scanners/test_joblib_scanner_codecs.py b/tests/scanners/test_joblib_scanner_codecs.py index b84382ee7..0f8e9736c 100644 --- a/tests/scanners/test_joblib_scanner_codecs.py +++ b/tests/scanners/test_joblib_scanner_codecs.py @@ -35,6 +35,7 @@ _validated_numpy_dtype, np, ) +from modelaudit.scanners.pickle_scanner import PickleScanner from modelaudit.utils.file.detection import _LZ4_FRAME_MAGIC, validate_file_type_with_formats @@ -1404,6 +1405,42 @@ def test_scan_fails_closed_on_invalid_numpy_wrapper_with_origin_warning(tmp_path assert should_cache_scan_result(result.to_dict(include_private_metadata=True)) is False +def test_scan_fails_closed_when_embedded_pickle_reports_success( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Joblib must fail closed on its own wrapper validation, not on the embedded verdict. + + Windows CI reaches this state through the real pickle scanner: the embedded result keeps a + trusted incomplete tail and finishes ``success=True`` while Joblib NumPy-wrapper validation + is still inconclusive. Forcing only the embedded verdict reproduces that lane deterministically + on every platform while leaving payload parsing and metadata composition untouched. + """ + original_finish = PickleScanner._finish_after_wrapper_analysis + + def finish_as_successful(self: PickleScanner, result: ScanResult, *, base_success: bool) -> None: + original_finish(self, result, base_success=base_success) + if not result.success: + result.success = True + result._merged_children_success = True + + monkeypatch.setattr(PickleScanner, "_finish_after_wrapper_analysis", finish_as_successful) + + result = _scan_payload( + tmp_path, + _joblib_numpy_list_payload(resumed_ops=b"\x80\x01K\x01."), + "numpy_array_protocol1_embedded_success.joblib", + ) + + assert result.success is False + assert result.has_errors is False + assert result.metadata["scan_outcome"] == INCONCLUSIVE_SCAN_OUTCOME + assert result.metadata["analysis_incomplete"] is True + assert "joblib_numpy_array_wrapper_validation_failed" in result.metadata["scan_outcome_reasons"] + assert "trusted_incomplete_tail" not in result.metadata + assert should_cache_scan_result(result.to_dict(include_private_metadata=True)) is False + + def test_scan_detects_gzip_compressed_pickle_joblib(tmp_path: Path) -> None: path = tmp_path / "gzip_protocol4.joblib" path.write_bytes(gzip.compress(pickle.dumps(_Payload(), protocol=4)))