Skip to content

Commit 22c119b

Browse files
committed
Harden persistence.compat_load() with trusted gate; finalize v3.6.2 (CVE-2026-15529)
- compat_load() now requires trusted=True, matching load() (#698), closing the parallel unsafe-deserialization path through the other public loader - internal load()->compat_load fall-through forwards trusted=True, so load(path, trusted=True) still recovers legacy dtype-mismatched artifacts - tests: guard-order regression + internal-forwarding assertion (33 passed) - docs (trust boundary, example, decision tree, troubleshooting), CHANGES, and version bump 3.6.1 -> 3.6.2 - Reviewed via /implement-review (Codex, 2 rounds; R1 High fixed, R2 clean) Closes #697.
1 parent 16e6e3a commit 22c119b

6 files changed

Lines changed: 73 additions & 23 deletions

File tree

CHANGES.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,4 +230,4 @@ v<3.5.3>, <05/19/2026> -- KB-tools API for agent-driven and LLM-API-driven routi
230230
v<3.5.4>, <06/03/2026> -- Claims-honesty and framing-consistency remediation of the v3 agentic layer from an internal audit (no detector behavior change). Determinism: `ADEngine.random_state` docstring upgraded from the vague "deterministic-up-to-numpy-module-state" hedge to the audited guarantee (a run-to-run audit of the shipped shallow detectors found every one either honors the seed or is deterministic by construction; deep detectors additionally depend on framework seeding). Counts: every public surface now reports 60 buildable detectors instead of 60+/61/50+; `scripts/regen_skill.py` and `pyod/cli.py` exclude `status == "planned"` so the non-buildable `LLMAD` no longer inflates the od-expert skill's counts/lists or `pyod info` (now `60 total (43 tabular, 7 time-series, 8 graph, 2 text, 2 image, 1 multimodal)`); `LLMAD` stays in the raw KB as a roadmap entry. Expert-level: `docs/index.rst`, `od_expert/SKILL.md`, `docs/skill_maintenance.rst`, and `docs/examples/agentic.rst` reword "expert-level/expert-quality results" to a complete-workflow/accessibility claim. Trust verdict: `docs/examples/adengine.rst` demotes the quality verdict to descriptive diagnostics with a "heuristic, not a guarantee" note and corrects the stale "Jaccard" stability description to the cutoff-gap formula; `_quality_metrics.compute_quality` docstring documents `separation` as circular (computed from the run's own predicted labels, near-always high, and not independent of the majority-vote consensus labels); the od-expert skill's Trigger 4 is reframed to cutoff-instability on `stability` only, and its result-interpretation, per-modality confidence lines, and examples route confidence through low `agreement` plus label-free caveats instead of `separation`/`overall`/`verdict`. Consensus: skill guidance softened from "never report from a single detector" to "prefer consensus for robustness; about as accurate as the best single pick." Framing consistency: ADEngine is described as a "lifecycle orchestration" engine rather than "intelligent orchestration" across README, docs, the API reference, and the module docstring, matching the finding that the layer's value is the drivable, reproducible workflow rather than selection intelligence. Tests: 2 new count-locking regression tests (`test_cli.py::test_pyod_info_excludes_planned_detectors`, `test_skill_kb_consistency.py::test_skill_count_prose_matches_kb`) compute expected buildable counts from the KB and fail on regression. Reviewed via /implement-review (Codex, 4 rounds): R1 raised 3 High + 2 Medium + 1 Low, R2 verified 5/6 and flagged trust-gate residue, R3 cleared it, R4 confirmed commit-ready. No breaking API changes.
231231
v<3.6.0>, <06/04/2026> -- Add audio as a first-class anomaly detection modality. New AudioFeatureEncoder reduces each clip to a 74-dimensional handcrafted acoustic vector (20 MFCC, 12 chroma, and 5 spectral descriptors: centroid, bandwidth, rolloff, zero-crossing rate, and RMS, each as its mean and standard deviation over frames, via librosa), registered as the 'audio-mfcc' encoder and exposed through EmbeddingOD.for_audio() so any classical detector runs on audio. New AudioAE detector is a DCASE-style log-mel reconstruction autoencoder that reuses the PyOD AutoEncoder with clip-level error aggregation. ADEngine now profiles audio file paths and routes audio, with EmbeddingOD.for_audio as the default and AudioAE as the deep alternative; the knowledge base gains an AudioAE entry and audio support on EmbeddingOD and MultiModalOD. New optional extra pyod[audio] (librosa, soundfile). Buildable detector count rises from 60 to 61. References are the public methods (the DCASE 2020 Task 2 log-mel autoencoder baseline, and MFCC, chroma, and spectral features via librosa). Reviewed via /implement-review (Codex, no High findings; one Medium and two Low fixed). No breaking API changes.
232232
v<3.6.1>, <06/16/2026> -- Maintenance and contributor PRs since v3.6.0 (no breaking API changes). PyThresh v1 support (#684, Daniel Kulik): the pyod.models.thresholds wrappers and the BaseDetector threshold path use the pythresh v1 API (.fit()/.labels_/.predict() instead of .eval()), and the dependency pin moves to pythresh>=1.0.0. EmbeddingOD air-gapped and pre-instantiated encoder support (#696, Sunny Guntuka): a pre-loaded SentenceTransformer instance can be passed directly as the encoder, and a local filesystem path is loaded with local_files_only=True (no Hub call) for offline use; this also fixes a resolver-order bug where a SentenceTransformer instance was wrapped as a CallableEncoder (calling model(X) instead of model.encode(X)). DataFrame feature-name warning fix (#692, eferhire ugbotu; closes #540): GMM, IForest, LOF, and OCSVM run check_array in decision_function, so scoring a pandas DataFrame after fitting no longer emits the scikit-learn feature-name UserWarning; the predict/predict_proba/predict_confidence paths route through decision_function, so the single-point fix covers them. Audio docs: AudioAE and the audio modality are now in the README and docs algorithm tables with a new pyod.models.audio API page (v3.6.0 shipped AudioAE without a table row). Tests: regression tests for the DataFrame-warning fix (GMM/OCSVM/LOF) and a no-download EmbeddingOD resolver test using SentenceTransformer(modules=[]). Docs: PyPI download figure refreshed to 46M+. Reviewed via /implement-review (Codex gatekeeper): #696 and #692 merged after dual review, with the gatekeeper-flagged test gaps closed in a follow-up. Buildable detector count unchanged at 61.
233-
v<3.6.2>, <TBD> -- Security hardening for model persistence: `pyod.utils.persistence.load()` now refuses to deserialize pickle/joblib artifacts unless callers pass `trusted=True`, making the trust boundary explicit before `joblib.load()` can execute pickle reducers. `strict=True` remains a dependency-version policy and is documented as not making untrusted pickle files safe. Adds a regression test that verifies the default `load()` path rejects a crafted envelope-shaped artifact before unpickling side effects occur. Updates the model persistence guide and save/load example to pass `trusted=True` for trusted artifacts. Closes #697.
233+
v<3.6.2>, <07/20/2026> -- Security hardening for model persistence (CVE-2026-15529): both `pyod.utils.persistence.load()` and `pyod.utils.persistence.compat_load()` now refuse to deserialize pickle/joblib artifacts unless callers pass `trusted=True`, making the trust boundary explicit before `joblib.load()` can execute pickle reducers. This is a breaking change: existing `save()` then `load()` round trips, and any direct `compat_load()` calls, must add `trusted=True`, because both functions now refuse even artifacts you saved yourself until the caller acknowledges the source is trusted. The internal `load()` fall-through to `compat_load()` forwards the acknowledgement automatically, so `load(path, trusted=True)` still recovers legacy dtype-mismatched artifacts in a single call. `strict=True` remains a dependency-version policy and is documented as not making untrusted pickle files safe. Adds regression tests verifying that both the default `load()` path and a direct `compat_load()` call reject before any unpickling side effects occur. Updates the model persistence guide, decision tree, troubleshooting table, and save/load example to pass `trusted=True` for trusted artifacts. Closes #697.

docs/model_persistence.rst

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,12 @@ raw ``pickle.load``, :func:`~pyod.utils.persistence.load`, and
4545
sandbox the unpickling step, and ``strict=True`` is only a dependency
4646
version policy.
4747

48-
For that reason, :func:`~pyod.utils.persistence.load` refuses to
49-
deserialize unless callers pass ``trusted=True``. This flag is an
50-
explicit acknowledgement that the artifact came from a trusted training
51-
pipeline, model registry, or other trusted source. It is not a security
52-
scan of the file.
48+
For that reason, both :func:`~pyod.utils.persistence.load` and
49+
:func:`~pyod.utils.persistence.compat_load` refuse to deserialize
50+
unless callers pass ``trusted=True``. This flag is an explicit
51+
acknowledgement that the artifact came from a trusted training
52+
pipeline, model registry, or other trusted source. It is not a
53+
security scan of the file.
5354

5455
Why a Versioned Wrapper
5556
-----------------------
@@ -87,7 +88,7 @@ running sklearn's dtype before sklearn's own ``__setstate__`` raises.
8788
8889
from pyod.utils.persistence import compat_load
8990
90-
clf = compat_load("legacy.joblib")
91+
clf = compat_load("legacy.joblib", trusted=True)
9192
# Re-save under the new envelope to avoid repeating the dance:
9293
from pyod.utils.persistence import save
9394
save(clf, "legacy_resaved.pyod.joblib")
@@ -124,9 +125,10 @@ Decision Tree
124125
-> the artifact was repaired via compat_load; re-save with save()
125126

126127
Loading a trusted model and load(path, trusted=True) raises?
127-
-> if the error is about Tree-node dtype, try compat_load directly
128-
and check whether the warning recommends re-fit. If it cannot
129-
recover, re-fit on the current sklearn.
128+
-> if the error is about Tree-node dtype, try
129+
compat_load(path, trusted=True) directly and check whether the
130+
warning recommends re-fit. If it cannot recover, re-fit on the
131+
current sklearn.
130132

131133
Cross-Sklearn-Version Compatibility
132134
-----------------------------------
@@ -167,7 +169,7 @@ Troubleshooting
167169
================================================================== ==================================================================
168170
Error text starts with Recommended action
169171
================================================================== ==================================================================
170-
``node array from the pickle has an incompatible dtype`` Try :func:`~pyod.utils.persistence.compat_load`. If it succeeds, re-save with :func:`~pyod.utils.persistence.save`. If it raises, re-fit.
172+
``node array from the pickle has an incompatible dtype`` Try :func:`~pyod.utils.persistence.compat_load` with ``trusted=True``. If it succeeds, re-save with :func:`~pyod.utils.persistence.save`. If it raises, re-fit.
171173
``InconsistentVersionWarning`` (only a warning, not an error) Safe to ignore; sklearn is reminding you the save and run versions differ. Re-save or re-fit when convenient.
172174
Other sklearn unpickling errors The artifact is incompatible beyond what ``compat_load`` repairs. Re-fit on the current sklearn.
173175
================================================================== ==================================================================

pyod/test/fixtures/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ automatically on the same error.
3333
- raw `joblib.load(path)` raises a `ValueError` whose message starts with
3434
the documented dtype-mismatch prefix (so the fall-through trigger is
3535
observed end-to-end, not just hypothetical),
36-
- `compat_load(path)` returns an `IsolationForest`,
36+
- the trusted compat path (equivalent to `compat_load(path, trusted=True)`)
37+
returns an `IsolationForest`,
3738
- the loaded model's `estimators_[0].tree_.__getstate__()['nodes'].dtype`
3839
equals the running sklearn's `NODE_DTYPE` (the dtype was actually repaired,
3940
not just silently accepted),

pyod/test/test_persistence.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
_CURRENT_PERSISTENCE_VERSION,
4949
_DTYPE_MISMATCH_PREFIX,
5050
_TREE_NODE_FIELD_DEFAULTS,
51-
compat_load,
51+
compat_load as _compat_load,
5252
load as _load,
5353
save,
5454
)
@@ -63,6 +63,12 @@ def load(*args, **kwargs):
6363
return _load(*args, **kwargs)
6464

6565

66+
def compat_load(*args, **kwargs):
67+
"""Test helper for the trusted compat path exercised historically."""
68+
kwargs.setdefault('trusted', True)
69+
return _compat_load(*args, **kwargs)
70+
71+
6672
def _write_marker(path, text):
6773
Path(path).write_text(text)
6874

@@ -264,6 +270,22 @@ def setUp(self):
264270
def _tmp(self, name='artifact.joblib'):
265271
return os.path.join(self._tmpdir.name, name)
266272

273+
# ------------------------------------------------------------
274+
# Trust boundary (fail-closed, mirrors load())
275+
# ------------------------------------------------------------
276+
277+
def test_compat_load_requires_explicit_trust(self):
278+
# compat_load() must fail closed like load(): the trust guard
279+
# runs before the file is opened, so even a missing path raises
280+
# the trust ValueError rather than FileNotFoundError. Calls the
281+
# real _compat_load (not the trusted test wrapper) on purpose.
282+
missing = self._tmp('missing.joblib')
283+
with self.assertRaises(ValueError) as cm:
284+
_compat_load(missing)
285+
self.assertIn('trusted', str(cm.exception).lower())
286+
self.assertFalse(os.path.exists(missing),
287+
'guard must reject before the file is touched')
288+
267289
# ------------------------------------------------------------
268290
# Realignment on synthetic aged pickles
269291
# ------------------------------------------------------------
@@ -919,9 +941,11 @@ def test_load_auto_fallthrough_trigger_is_exact_prefix(self):
919941
Path(path).write_bytes(b'')
920942

921943
compat_calls = []
944+
compat_trusted = []
922945

923-
def fake_compat_load(p, mmap_mode=None):
946+
def fake_compat_load(p, mmap_mode=None, *, trusted=False):
924947
compat_calls.append(str(p))
948+
compat_trusted.append(trusted)
925949
raise RuntimeError(
926950
'fake_compat_load should not run for this branch')
927951

@@ -968,6 +992,10 @@ def fake_load_prefix(*args, **kwargs):
968992
self.assertEqual(len(compat_calls), 1,
969993
'prefix ValueError MUST trigger compat_load exactly '
970994
'once')
995+
self.assertEqual(
996+
compat_trusted, [True],
997+
'load() must forward trusted=True to the internal '
998+
'compat_load fall-through')
971999

9721000

9731001
if __name__ == '__main__':

pyod/utils/persistence.py

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,9 @@
2525
rescue path transparently.
2626
2727
WARNING: pickle and joblib load arbitrary Python code. Load only from
28-
trusted sources. `load()` refuses to deserialize unless callers pass
29-
`trusted=True`; `strict=True` and envelope validation are dependency
30-
checks, not a sandbox. The compat_load helper does not change this
31-
security model.
28+
trusted sources. `load()` and `compat_load()` refuse to deserialize
29+
unless callers pass `trusted=True`; `strict=True` and envelope
30+
validation are dependency checks, not a sandbox.
3231
3332
See `docs/model_persistence.rst` for the user-facing guide.
3433
"""
@@ -193,8 +192,10 @@ def load(
193192
envelope to verify.
194193
3. A file that fails the initial `joblib.load` with the
195194
sklearn `Tree` node dtype error. `load()` falls through to
196-
`compat_load(path)` and routes the recovered object through
197-
the same envelope/legacy handler. See module docstring.
195+
`compat_load(path, trusted=True)` and routes the recovered
196+
object through the same envelope/legacy handler (the trust
197+
acknowledgement is carried forward from this call). See module
198+
docstring.
198199
199200
Parameters
200201
----------
@@ -328,7 +329,7 @@ def _handle_compat_fallthrough(
328329
strict: bool,
329330
return_metadata: bool) -> Any:
330331
try:
331-
obj = compat_load(path)
332+
obj = compat_load(path, trusted=True)
332333
except Exception as compat_exc:
333334
raise compat_exc from original_exc
334335
return _handle_loaded_object(
@@ -408,7 +409,11 @@ def _format_strict_compat_drift_msg(
408409
# compat_load
409410
# ----------------------------------------------------------------------
410411

411-
def compat_load(path: Any, mmap_mode: str | None = None) -> Any:
412+
def compat_load(
413+
path: Any,
414+
mmap_mode: str | None = None,
415+
*,
416+
trusted: bool = False) -> Any:
412417
"""Load an artifact whose sklearn Tree node dtype no longer matches.
413418
414419
Mirrors `joblib.load` but plugs a dispatch-table override into
@@ -431,6 +436,13 @@ def compat_load(path: Any, mmap_mode: str | None = None) -> Any:
431436
mmap_mode : str or None, default None
432437
Forwarded to joblib's underlying load path. Supported values
433438
mirror joblib's: None, 'r', 'r+', 'w+', 'c'.
439+
trusted : bool, default False
440+
Required acknowledgement that the artifact comes from a trusted
441+
source. When False, ``compat_load()`` raises before opening or
442+
deserializing the file, mirroring ``load()``. The Tree-dtype
443+
realignment is a compatibility shim, not a security sandbox, so
444+
it cannot make an untrusted pickle safe. Set True only for
445+
artifacts from a trusted source.
434446
435447
Returns
436448
-------
@@ -439,6 +451,13 @@ def compat_load(path: Any, mmap_mode: str | None = None) -> Any:
439451
legacy raw saves; an envelope dict for Phase 2 saves). Callers
440452
that need envelope unwrapping should use `load()`.
441453
"""
454+
if not trusted:
455+
raise ValueError(
456+
"compat_load(): refusing to deserialize an untrusted "
457+
"pickle/joblib artifact. Pass trusted=True only for "
458+
"artifacts from a trusted source; the sklearn Tree-dtype "
459+
"realignment is a compatibility shim, not a security "
460+
"sandbox.")
442461
trees_realigned = [0]
443462

444463
class _CompatNumpyUnpickler(NumpyUnpickler):

pyod/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,4 @@
2020
# Dev branch marker is: 'X.Y.dev' or 'X.Y.devN' where N is an integer.
2121
# 'X.Y.dev0' is the canonical version of 'X.Y.dev'
2222
#
23-
__version__ = '3.6.1' # pragma: no cover
23+
__version__ = '3.6.2' # pragma: no cover

0 commit comments

Comments
 (0)