Skip to content

Commit edb38ec

Browse files
authored
Merge pull request #703 from yzhao062/development
v3.6.2: security release (CVE-2026-15529)
2 parents d9db50a + 22c119b commit edb38ec

7 files changed

Lines changed: 175 additions & 46 deletions

File tree

CHANGES.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,3 +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>, <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: 38 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,14 @@ Quick Start
2020
# Save with a versioned envelope.
2121
save(clf, "clf.pyod.joblib", metadata={"dataset": "demo"})
2222
23-
# Later, in a possibly different environment:
24-
clf = load("clf.pyod.joblib")
23+
# Later, in a possibly different environment. ``trusted=True`` is
24+
# required because joblib/pickle artifacts can execute code while
25+
# they are being deserialized.
26+
clf = load("clf.pyod.joblib", trusted=True)
2527
2628
# Or get the envelope back alongside the model:
27-
clf, env = load("clf.pyod.joblib", return_metadata=True)
29+
clf, env = load(
30+
"clf.pyod.joblib", return_metadata=True, trusted=True)
2831
print(env["sklearn_version"], env["saved_at"])
2932
3033
The complete example in
@@ -38,8 +41,16 @@ Trust Boundary
3841
``pickle`` and ``joblib`` deserialize arbitrary Python code. Load only
3942
from sources you trust. This applies equally to raw ``joblib.load``,
4043
raw ``pickle.load``, :func:`~pyod.utils.persistence.load`, and
41-
:func:`~pyod.utils.persistence.compat_load`. The new wrapper does not
42-
change this security model; it does not sandbox the unpickling step.
44+
:func:`~pyod.utils.persistence.compat_load`. The wrapper does not
45+
sandbox the unpickling step, and ``strict=True`` is only a dependency
46+
version policy.
47+
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.
4354

4455
Why a Versioned Wrapper
4556
-----------------------
@@ -77,7 +88,7 @@ running sklearn's dtype before sklearn's own ``__setstate__`` raises.
7788
7889
from pyod.utils.persistence import compat_load
7990
80-
clf = compat_load("legacy.joblib")
91+
clf = compat_load("legacy.joblib", trusted=True)
8192
# Re-save under the new envelope to avoid repeating the dance:
8293
from pyod.utils.persistence import save
8394
save(clf, "legacy_resaved.pyod.joblib")
@@ -92,7 +103,7 @@ legacy handler:
92103
93104
from pyod.utils.persistence import load
94105
95-
clf = load("legacy.joblib") # transparently recovers from dtype drift
106+
clf = load("legacy.joblib", trusted=True) # recovers from dtype drift
96107
97108
The fall-through emits a ``UserWarning`` so the recovery does not
98109
go unnoticed. Re-save with :func:`~pyod.utils.persistence.save` (or
@@ -107,16 +118,17 @@ Decision Tree
107118
Saving a new model?
108119
-> use save(clf, path)
109120

110-
Loading a model and load(path) works without warnings?
121+
Loading a trusted model and load(path, trusted=True) works without warnings?
111122
-> done
112123

113-
Loading a model and load(path) succeeds with a "recovered" warning?
124+
Loading a trusted model and load(path, trusted=True) succeeds with a "recovered" warning?
114125
-> the artifact was repaired via compat_load; re-save with save()
115126

116-
Loading a model and load(path) raises?
117-
-> if the error is about Tree-node dtype, try compat_load directly
118-
and check whether the warning recommends re-fit. If it cannot
119-
recover, re-fit on the current sklearn.
127+
Loading a trusted model and load(path, trusted=True) raises?
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.
120132

121133
Cross-Sklearn-Version Compatibility
122134
-----------------------------------
@@ -157,7 +169,7 @@ Troubleshooting
157169
================================================================== ==================================================================
158170
Error text starts with Recommended action
159171
================================================================== ==================================================================
160-
``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.
161173
``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.
162174
Other sklearn unpickling errors The artifact is incompatible beyond what ``compat_load`` repairs. Re-fit on the current sklearn.
163175
================================================================== ==================================================================
@@ -172,38 +184,38 @@ For version-pinned production environments, pass ``strict=True`` to
172184
173185
from pyod.utils.persistence import load
174186
175-
clf = load("prod.pyod.joblib", strict=True)
187+
clf = load("prod.pyod.joblib", strict=True, trusted=True)
176188
177189
Under strict mode, any drift in sklearn, joblib, numpy, or scipy
178190
raises ``ValueError`` rather than emitting a warning. Drift in the
179191
Python version does not raise because it is informational only.
180192
Strict mode also rejects raw legacy artifacts (no envelope to
181193
compare against) and refuses to return a model that required a
182194
``compat_load`` repair: strict callers must either re-save under the
183-
current environment or re-fit.
195+
current environment or re-fit. Strict mode does not make untrusted
196+
pickle/joblib files safe to load; ``trusted=True`` is still required.
184197

185198
Reading Envelope Metadata
186199
-------------------------
187200

188-
``load(path, return_metadata=True)`` returns a ``(model, envelope)``
189-
tuple where ``envelope`` is the full envelope dict minus the
190-
``model`` field:
201+
``load(path, return_metadata=True, trusted=True)`` returns a
202+
``(model, envelope)`` tuple where ``envelope`` is the full envelope
203+
dict minus the ``model`` field:
191204

192205
.. code-block:: python
193206
194207
from pyod.utils.persistence import load
195208
196-
clf, env = load("clf.pyod.joblib", return_metadata=True)
209+
clf, env = load(
210+
"clf.pyod.joblib", return_metadata=True, trusted=True)
197211
print(env["pyod_version"], env["sklearn_version"])
198212
print(env["saved_at"], env["model_class"])
199213
print(env["metadata"]) # whatever you passed to save(... metadata=...)
200214
201-
A future PyOD release plans a true header-only ``inspect_artifact``
202-
(reading metadata without unpickling the model), paired with a
203-
``.pyod`` zip container that separates metadata from the model
204-
payload. Until that ships, ``load(..., return_metadata=True)`` is
205-
the supported way to introspect a saved artifact, and it does
206-
unpickle the model.
215+
``load(..., return_metadata=True, trusted=True)`` still unpickles the
216+
model. It should only be used for trusted artifacts. A future PyOD
217+
release may add a true header-only ``inspect_artifact`` API for reading
218+
metadata without unpickling the model payload.
207219

208220
Neural Network Models
209221
---------------------

examples/save_load_model_example.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,13 @@
6161
save(clf, artifact_path, metadata={'dataset': 'demo', 'note': 'LOF baseline'})
6262

6363
# The matching load() reads the envelope and warns on dependency
64-
# drift; pass strict=True for version-pinned production deployments.
65-
clf = load(artifact_path)
64+
# drift. joblib/pickle artifacts can execute code while loading, so
65+
# load() requires trusted=True to acknowledge the artifact source.
66+
# Pass strict=True as well for version-pinned production deployments.
67+
clf = load(artifact_path, trusted=True)
6668

6769
# To inspect the envelope without separately re-reading the file:
68-
clf, env = load(artifact_path, return_metadata=True)
70+
clf, env = load(artifact_path, return_metadata=True, trusted=True)
6971
print(
7072
f"Loaded {env['model_class']} "
7173
f"(pyod={env['pyod_version']}, sklearn={env['sklearn_version']}, "

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: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,15 +48,31 @@
4848
_CURRENT_PERSISTENCE_VERSION,
4949
_DTYPE_MISMATCH_PREFIX,
5050
_TREE_NODE_FIELD_DEFAULTS,
51-
compat_load,
52-
load,
51+
compat_load as _compat_load,
52+
load as _load,
5353
save,
5454
)
5555

5656

5757
FIXTURES_DIR = Path(__file__).resolve().parent / 'fixtures'
5858

5959

60+
def load(*args, **kwargs):
61+
"""Test helper for the trusted artifact path exercised historically."""
62+
kwargs.setdefault('trusted', True)
63+
return _load(*args, **kwargs)
64+
65+
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+
72+
def _write_marker(path, text):
73+
Path(path).write_text(text)
74+
75+
6076
# ---------------------------------------------------------------------
6177
# Helpers: build pickles whose Tree-node dtype mimics an older sklearn
6278
# ---------------------------------------------------------------------
@@ -254,6 +270,22 @@ def setUp(self):
254270
def _tmp(self, name='artifact.joblib'):
255271
return os.path.join(self._tmpdir.name, name)
256272

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+
257289
# ------------------------------------------------------------
258290
# Realignment on synthetic aged pickles
259291
# ------------------------------------------------------------
@@ -554,6 +586,38 @@ def test_save_load_with_user_metadata(self):
554586
self.assertEqual(env['metadata'], meta)
555587

556588

589+
class TestLoadTrustBoundary(unittest.TestCase):
590+
591+
def setUp(self):
592+
self._tmpdir = tempfile.TemporaryDirectory()
593+
self.addCleanup(self._tmpdir.cleanup)
594+
595+
def _tmp(self, name='artifact.joblib'):
596+
return os.path.join(self._tmpdir.name, name)
597+
598+
def test_load_rejects_untrusted_artifact_before_unpickling(self):
599+
marker = self._tmp('marker.txt')
600+
601+
class Payload:
602+
def __reduce__(self):
603+
return (_write_marker, (marker, 'executed'))
604+
605+
path = self._tmp()
606+
joblib.dump({
607+
'_pyod_persistence_version': _CURRENT_PERSISTENCE_VERSION,
608+
'model': Payload(),
609+
}, path)
610+
self.assertFalse(os.path.exists(marker))
611+
612+
with self.assertRaises(ValueError) as cm:
613+
_load(path)
614+
615+
self.assertIn('trusted', str(cm.exception).lower())
616+
self.assertFalse(
617+
os.path.exists(marker),
618+
'untrusted load must fail before pickle reducers execute')
619+
620+
557621
class TestLoadLegacy(unittest.TestCase):
558622

559623
def setUp(self):
@@ -877,9 +941,11 @@ def test_load_auto_fallthrough_trigger_is_exact_prefix(self):
877941
Path(path).write_bytes(b'')
878942

879943
compat_calls = []
944+
compat_trusted = []
880945

881-
def fake_compat_load(p, mmap_mode=None):
946+
def fake_compat_load(p, mmap_mode=None, *, trusted=False):
882947
compat_calls.append(str(p))
948+
compat_trusted.append(trusted)
883949
raise RuntimeError(
884950
'fake_compat_load should not run for this branch')
885951

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

9301000

9311001
if __name__ == '__main__':

0 commit comments

Comments
 (0)