Files
wehub-resource-sync 7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

185 lines
7.7 KiB
Python

"""Tests for the restricted-unpickler FAISS loader (faiss_safe_load).
Covers:
- A real ``FAISS.save_local`` index round-trips through ``safe_load_faiss``
(proves the restricted unpickler accepts legitimate docstore payloads).
- A malicious pickle that would execute code under a plain ``pickle.load`` is
rejected before execution (proves the RCE is actually closed, including the
realistic ".pkl-only swap" where the .faiss is left byte-identical).
"""
import datetime
import io
import pickle
from pathlib import Path
import pytest
from langchain_community.docstore.in_memory import InMemoryDocstore
from langchain_community.vectorstores import FAISS
from langchain_core.documents import Document
from local_deep_research.research_library.services.faiss_safe_load import (
_RestrictedFaissUnpickler,
safe_load_faiss,
)
try: # location varies across langchain versions
from langchain_community.embeddings import FakeEmbeddings
except Exception: # pragma: no cover
from langchain_core.embeddings import FakeEmbeddings
def _build_and_save(
dir_path: Path, index_name: str = "index"
) -> FakeEmbeddings:
"""Create a small real FAISS index on disk and return its embeddings."""
emb = FakeEmbeddings(size=16)
texts = ["alpha chunk", "beta chunk", "gamma chunk"]
# Metadata mirrors what the RAG service attaches: plain scalars + lists.
metas = [
{"source": "a.pdf", "page": 1, "word_count": 2, "authors": None},
{"source": "b.pdf", "page": 2, "tags": ["x", "y"]},
{"source": "c.pdf", "score": 0.9, "nested": {"k": 1}},
]
vs = FAISS.from_texts(texts, emb, metadatas=metas)
vs.save_local(str(dir_path), index_name=index_name)
return emb
class TestSafeLoadRoundTrip:
def test_loads_real_index(self, tmp_path):
emb = _build_and_save(tmp_path)
vs = safe_load_faiss(
str(tmp_path), emb, index_name="index", normalize_L2=True
)
contents = {d.page_content for d in vs.docstore._dict.values()}
assert contents == {"alpha chunk", "beta chunk", "gamma chunk"}
assert len(vs.index_to_docstore_id) == 3
# Metadata (scalars, list, nested dict) survives the restricted load.
metas = [d.metadata for d in vs.docstore._dict.values()]
assert any(m.get("tags") == ["x", "y"] for m in metas)
# normalize_L2 is threaded into the FAISS constructor by hand — guard
# it, since a silent drop would corrupt cosine-distance search.
assert vs._normalize_L2 is True
def test_normalize_l2_defaults_false(self, tmp_path):
emb = _build_and_save(tmp_path)
vs = safe_load_faiss(str(tmp_path), emb, index_name="index")
assert vs._normalize_L2 is False
def test_search_works_after_safe_load(self, tmp_path):
emb = _build_and_save(tmp_path)
vs = safe_load_faiss(str(tmp_path), emb, index_name="index")
results = vs.similarity_search("alpha", k=1)
assert len(results) == 1
# Module-level helpers so pickle can reference them by qualified name. The
# payload writes a marker file instead of doing real harm, so the test can
# prove (a) it WOULD run under plain pickle and (b) it does NOT under the
# restricted unpickler.
def _write_marker(path: str):
Path(path).write_text("EXECUTED")
return InMemoryDocstore({})
class _Evil:
def __init__(self, marker: str):
self._marker = marker
def __reduce__(self):
return (_write_marker, (self._marker,))
class TestSafeLoadBlocksMalicious:
def test_restricted_unpickler_blocks_and_does_not_execute(self, tmp_path):
marker = tmp_path / "marker.txt"
blob = pickle.dumps((_Evil(str(marker)), {0: "id"}))
# Sanity: a plain pickle.load DOES execute the payload. This
# unsafe call is the point of the test — it demonstrates the very
# RCE that safe_load_faiss prevents, on a benign marker payload.
pickle.loads(blob) # noqa: S301
assert marker.read_text() == "EXECUTED"
marker.unlink()
# The restricted unpickler refuses and never runs the payload.
with pytest.raises(pickle.UnpicklingError):
_RestrictedFaissUnpickler(io.BytesIO(blob)).load()
assert not marker.exists()
def test_safe_load_faiss_rejects_pkl_only_swap(self, tmp_path):
"""The realistic attack: leave .faiss byte-identical, swap the .pkl."""
emb = _build_and_save(tmp_path) # valid .faiss + .pkl
marker = tmp_path / "marker.txt"
faiss_before = (tmp_path / "index.faiss").read_bytes()
(tmp_path / "index.pkl").write_bytes(
pickle.dumps((_Evil(str(marker)), {0: "id"}))
)
with pytest.raises(pickle.UnpicklingError):
safe_load_faiss(str(tmp_path), emb, index_name="index")
assert not marker.exists()
# We did not touch the .faiss (mirrors the attack precondition).
assert (tmp_path / "index.faiss").read_bytes() == faiss_before
def test_blocks_copyreg_extension_opcode_even_when_cache_warm(
self, tmp_path
):
"""copyreg EXT1/EXT2/EXT4 opcodes resolve a callable via the extension
registry. ``find_class`` alone is NOT enough: ``get_extension``
short-circuits on the process-global ``copyreg._extension_cache`` and
skips ``find_class`` once a code has been resolved. So this test WARMS
the cache first (a plain ``pickle.loads`` that runs the payload), which
is exactly the state in which a ``find_class``-only guard would be
bypassed — then asserts the restricted unpickler still refuses (proving
the ``get_extension`` override, not ``find_class``, does the blocking).
"""
import copyreg
import pickletools
marker = tmp_path / "marker.txt"
code = 0xC0DE
copyreg.add_extension(
_write_marker.__module__, _write_marker.__name__, code
)
try:
blob = pickle.dumps((_Evil(str(marker)), {0: "id"}), protocol=2)
# Guard: the payload must actually use an EXT opcode, else this
# test would pass for the wrong reason.
op_names = [op.name for op, _, _ in pickletools.genops(blob)]
assert any(n.startswith("EXT") for n in op_names), op_names
# Warm copyreg._extension_cache for this code. A find_class-only
# restricted unpickler would now be bypassed on the EXT path.
pickle.loads(blob) # noqa: S301
assert marker.exists() # confirms the cache is now warm
marker.unlink()
with pytest.raises(pickle.UnpicklingError):
_RestrictedFaissUnpickler(io.BytesIO(blob)).load()
assert not marker.exists()
finally:
copyreg.remove_extension(
_write_marker.__module__, _write_marker.__name__, code
)
# Drop the warmed cache entry so it can't leak into other tests.
copyreg._extension_cache.pop(code, None)
def test_blocks_disallowed_class_buried_in_metadata(self):
"""The allow-list applies to EVERY global the pickle resolves, not
just the top-level docstore class. A disallowed class hidden inside a
Document's metadata (here a benign ``datetime``) must still be
refused — otherwise an attacker could smuggle a gadget through a
nested value while the outer objects look legitimate.
"""
doc = Document(
page_content="x",
metadata={"created": datetime.datetime(2026, 1, 1)},
)
blob = pickle.dumps((InMemoryDocstore({"id1": doc}), {0: "id1"}))
with pytest.raises(pickle.UnpicklingError, match="datetime"):
_RestrictedFaissUnpickler(io.BytesIO(blob)).load()