| """Adversarial SECURITY / INJECTION / ABUSE suite for SibylStore. |
| |
| Lane: injection, path-traversal, encoding-collision, isolation, abuse/DoS. |
| |
| Structure: |
| * Tests prefixed ``test_defense_*`` PASS — they confirm a defense holds |
| (SQL/FTS injection closed, no encoding collision, tenant + subtree |
| isolation solid, traversal rejected). |
| * ``test_fix_*`` PASS — regression guards for the two security fixes the |
| coordinator applied to store.py (noisy-neighbor _POOL raised 1000 -> |
| 10_000; $gt/$lt now documented as native Python comparison by design). |
| * ``test_residual_*`` are ``xfail`` — they assert the fully-correct behavior |
| and document the RESIDUAL architectural limitation that the applied fix |
| bounds but does not eliminate. |
| |
| Run: |
| cd <repo>/sibyl-memory-langgraph && . .venv/bin/activate \ |
| && python -m pytest tests/test_adv_security.py -v |
| """ |
|
|
| from __future__ import annotations |
|
|
| import os |
| import tempfile |
|
|
| import pytest |
|
|
| import sibyl_memory_langgraph.store as store_mod |
| from sibyl_memory_langgraph import SibylStore |
| from langgraph.store.memory import InMemoryStore |
|
|
|
|
| |
| |
| |
| def _new_store(**kw) -> SibylStore: |
| db = os.path.join(tempfile.mkdtemp(), "t.db") |
| return SibylStore(path=db, tier="free", **kw) |
|
|
|
|
| @pytest.fixture() |
| def store() -> SibylStore: |
| return _new_store() |
|
|
|
|
| |
| |
| |
| class TestParameterizationHolds: |
| """Confirm the no-SQLi audit finding still holds at the adapter layer.""" |
|
|
| def test_defense_sql_payload_in_key_is_inert(self, store): |
| |
| |
| store.put(("legit",), "anchor", {"v": 1}) |
| store.put(("ns",), "x' OR '1'='1", {"secret": "leak"}) |
| |
| |
| assert store.get(("ns",), "x' OR '1'='1").value == {"secret": "leak"} |
| |
| assert store.get(("ns",), "anchor") is None |
| |
| assert store.get(("legit",), "anchor").value == {"v": 1} |
|
|
| def test_defense_sql_drop_in_key_rejected_not_executed(self, store): |
| from sibyl_memory_client import MemoryClient |
| |
| with pytest.raises(Exception): |
| store.put(("ns",), "a'; DROP TABLE entities;--", {"v": 1}) |
| |
| store.put(("ns",), "ok", {"v": 2}) |
| assert store.get(("ns",), "ok").value == {"v": 2} |
|
|
| def test_defense_sql_payload_in_filter_field_and_value_inert(self, store): |
| store.put(("f",), "row", {"role": "admin", "n": 1}) |
| |
| assert store.search(("f",), filter={"role'; DROP TABLE entities;--": "x"}) == [] |
| assert store.search(("f",), filter={"role": "x' OR '1'='1"}) == [] |
| |
| assert len(store.search(("f",), filter={"role": "admin"})) == 1 |
|
|
|
|
| class TestFTS5InjectionContained: |
| """FTS5 query-injection must not crash and must not cross namespaces.""" |
|
|
| FTS_PAYLOADS = [ |
| "body:secret", "category:nsB", "name:k", "rowid:1", |
| "apple OR cherry", "apple AND banana", "NOT apple", |
| "apple NEAR cherry", "^apple", "app*", |
| 'secret"', '"unbalanced', '""', "(apple", "apple)", "\\", |
| "*", "' OR 1=1 --", |
| ] |
|
|
| def _seed(self): |
| s = _new_store() |
| s.put(("nsA",), "k", {"text": "secret apple banana"}) |
| s.put(("nsB",), "k", {"text": "public cherry"}) |
| return s |
|
|
| @pytest.mark.parametrize("q", FTS_PAYLOADS) |
| def test_defense_fts_payload_no_crash(self, q): |
| s = self._seed() |
| |
| s.search(("nsA",), query=q) |
| s.search(("nsB",), query=q) |
| s.search((), query=q) |
|
|
| @pytest.mark.parametrize("q", FTS_PAYLOADS) |
| def test_defense_fts_payload_no_cross_namespace_leak(self, q): |
| s = self._seed() |
| |
| |
| for it in s.search(("nsB",), query=q): |
| assert "secret" not in str(it.value), f"FTS payload {q!r} leaked nsA into nsB" |
|
|
| def test_defense_search_all_keeps_each_item_in_its_own_namespace(self): |
| s = self._seed() |
| for it in s.search((), query="secret"): |
| assert it.namespace == ("nsA",) |
|
|
|
|
| class TestNoEncodingCollision: |
| """The highest-priority class: prove NO cross-namespace read/write/delete |
| collision via key/element separator tricks or unicode slash lookalikes.""" |
|
|
| def test_defense_key_with_slash_vs_deeper_namespace_are_distinct(self, store): |
| |
| |
| store.put(("users",), "alice/profile", {"who": "A"}) |
| store.put(("users", "alice"), "profile", {"who": "B"}) |
| assert store.get(("users",), "alice/profile").value == {"who": "A"} |
| assert store.get(("users", "alice"), "profile").value == {"who": "B"} |
|
|
| def test_defense_overwrite_does_not_cross_collide(self, store): |
| store.put(("users",), "alice/profile", {"who": "A"}) |
| store.put(("users", "alice"), "profile", {"who": "B"}) |
| store.put(("users",), "alice/profile", {"who": "A2"}) |
| assert store.get(("users", "alice"), "profile").value == {"who": "B"} |
|
|
| def test_defense_delete_does_not_cross_collide(self, store): |
| store.put(("users",), "alice/profile", {"who": "A"}) |
| store.put(("users", "alice"), "profile", {"who": "B"}) |
| store.delete(("users",), "alice/profile") |
| assert store.get(("users", "alice"), "profile").value == {"who": "B"} |
|
|
| def test_defense_unicode_slash_lookalikes_do_not_collide(self, store): |
| |
| store.put(("a", "b"), "k", {"id": "real-sep"}) |
| store.put(("a⁄b",), "k", {"id": "fraction"}) |
| store.put(("a/b",), "k", {"id": "fullwidth"}) |
| assert store.get(("a", "b"), "k").value == {"id": "real-sep"} |
| assert store.get(("a⁄b",), "k").value == {"id": "fraction"} |
| assert store.get(("a/b",), "k").value == {"id": "fullwidth"} |
| ns = set(store.list_namespaces(limit=100)) |
| assert {("a", "b"), ("a⁄b",), ("a/b",)} <= ns |
|
|
|
|
| class TestPathTraversalRejected: |
| def test_defense_dotdot_and_slash_rejected(self, store): |
| for bad in [("..",), ("a", ".."), ("....//",), ("..\\..",)]: |
| with pytest.raises(Exception): |
| store.put(bad, "k", {"v": 1}) |
|
|
| def test_defense_control_chars_in_namespace_rejected_on_write(self, store): |
| for bad in [("a\nb",), ("a\tb",), ("a\x00b",)]: |
| with pytest.raises(Exception): |
| store.put(bad, "k", {"v": 1}) |
|
|
| def test_defense_encoded_traversal_is_inert_literal(self, store): |
| |
| |
| store.put(("%2e%2e",), "k", {"v": 1}) |
| item = store.get(("%2e%2e",), "k") |
| assert item.namespace == ("%2e%2e",) |
|
|
|
|
| class TestIsolation: |
| def test_defense_tenant_isolation_on_shared_db(self): |
| d = tempfile.mkdtemp() |
| db = os.path.join(d, "t.db") |
| a = SibylStore(path=db, tier="free", tenant_id="tenantA") |
| b = SibylStore(path=db, tier="free", tenant_id="tenantB") |
| a.put(("ns",), "k", {"secret": "A-only"}) |
| assert b.get(("ns",), "k") is None |
| assert b.search(("ns",), query="A-only") == [] |
| assert b.list_namespaces() == [] |
| assert a.get(("ns",), "k").value == {"secret": "A-only"} |
|
|
| def test_defense_sibling_subtree_no_leak(self, store): |
| store.put(("team", "alpha"), "k", {"v": "alpha"}) |
| store.put(("team", "beta"), "k", {"v": "beta"}) |
| hits = store.search(("team", "alpha"), query="alpha") |
| assert all(it.namespace == ("team", "alpha") for it in hits) |
| |
| assert not any("beta" in str(it.value) for it in store.search(("team", "alpha"))) |
|
|
| def test_defense_prefix_cannot_string_escape_subtree(self, store): |
| |
| store.put(("a",), "k", {"v": "in-a"}) |
| store.put(("ab",), "k", {"v": "in-ab"}) |
| subtree = store.search(("a",)) |
| assert all(it.namespace == ("a",) for it in subtree) |
| assert not any("in-ab" in str(it.value) for it in subtree) |
|
|
|
|
| class TestFilterCrashParityNonIssue: |
| """A 'poison record' (string in a numerically-filtered field) used to crash |
| the whole filtered search with a raw TypeError. R16 (2026-07-05) hardened |
| this: an incomparable ``$gt``/``$lt`` pair is now read as 'no match' instead |
| of raising, so ONE malformed record can no longer abort an otherwise valid |
| search (or a batch that contains it). This is a DOCUMENTED divergence from |
| the reference InMemoryStore, which still raises on the same input.""" |
|
|
| def test_poison_record_excluded_not_crash_in_sibyl(self): |
| sib = _new_store() |
| im = InMemoryStore() |
| for s in (sib, im): |
| s.put(("p",), "good", {"age": 30}) |
| s.put(("p",), "poison", {"age": "old"}) |
| |
| |
| hits = sib.search(("p",), filter={"age": {"$gt": 18}}) |
| assert {it.key for it in hits} == {"good"} |
| |
| with pytest.raises(ValueError): |
| im.search(("p",), filter={"age": {"$gt": 18}}) |
|
|
|
|
| |
| |
| |
| class TestNoisyNeighborFixedAtPoolBound: |
| """Finding #1 fix: _POOL raised 1000 -> 10_000. At the original 1100-entity |
| repro scale the victim namespace is NO LONGER evicted — it stays searchable |
| AND listable even though a sibling namespace wrote far more than the OLD cap. |
| 1100 rows fit comfortably under the free-tier 2 MB cap (~3,584 rows).""" |
|
|
| def _flooded_1100(self): |
| s = _new_store() |
| s.put(("victim",), "vkey", {"text": "victim secret apple"}) |
| |
| |
| for i in range(1100): |
| s._client.set_entity("noisy", f"k{i}", {"text": f"noise item {i}"}) |
| return s |
|
|
| def test_fix_victim_get_still_works(self): |
| s = self._flooded_1100() |
| assert s.get(("victim",), "vkey").value == {"text": "victim secret apple"} |
|
|
| def test_fix_victim_query_search_not_evicted(self): |
| s = self._flooded_1100() |
| hits = s.search(("victim",), query="apple") |
| assert len(hits) == 1, "regression: victim evicted from query search below the new _POOL cap" |
|
|
| def test_fix_victim_subtree_listing_not_evicted(self): |
| s = self._flooded_1100() |
| hits = s.search(("victim",)) |
| assert len(hits) == 1, "regression: victim evicted from subtree listing below the new _POOL cap" |
|
|
| def test_fix_victim_namespace_still_listed(self): |
| s = self._flooded_1100() |
| assert ("victim",) in s.list_namespaces(limit=5000), ( |
| "regression: victim namespace dropped from list_namespaces below the new _POOL cap" |
| ) |
|
|
|
|
| class TestGtLtNativeComparisonByDesign: |
| """Finding #2 resolution: the divergence from InMemoryStore is INTENTIONAL. |
| store.py _OPS uses native Python ordering (NOT float() coercion); the |
| docstring now documents this. These tests pin the documented native- |
| comparison contract (and assert it deliberately differs from InMemoryStore's |
| float coercion for numeric strings, so a future silent regression to float |
| coercion would be caught).""" |
|
|
| def test_fix_gt_uses_native_lexical_comparison(self): |
| s = _new_store() |
| s.put(("p",), "x", {"v": "10"}) |
| |
| assert s.search(("p",), filter={"v": {"$gt": "3"}}) == [] |
| assert len(s.search(("p",), filter={"v": {"$gt": "09"}})) == 1 |
|
|
| def test_fix_lt_uses_native_lexical_comparison(self): |
| s = _new_store() |
| s.put(("p",), "x", {"v": "10"}) |
| |
| assert len(s.search(("p",), filter={"v": {"$lt": "3"}})) == 1 |
|
|
| def test_fix_native_comparison_intentionally_differs_from_inmemorystore(self): |
| sib = _new_store() |
| im = InMemoryStore() |
| for s in (sib, im): |
| s.put(("p",), "x", {"v": "10"}) |
| sib_hits = len(sib.search(("p",), filter={"v": {"$gt": "3"}})) |
| im_hits = len(im.search(("p",), filter={"v": {"$gt": "3"}})) |
| |
| assert sib_hits == 0 |
| assert im_hits == 1 |
| assert sib_hits != im_hits |
|
|
|
|
| |
| |
| |
| class TestResidualEnumerationEviction: |
| """Finding #1 RESIDUAL. The fix raises the enumeration cap (1000 -> 10_000) |
| and logs a warning when it is hit, but the candidate pool is still bounded: |
| `_list_capped` / `_categories_under` read `list_entities(limit=_POOL)`, the |
| client clamps every read to MAX_LIMIT=10_000, and there is NO cursor. Once a |
| tenant holds more rows than the cap, the oldest namespaces are still evicted |
| from search() and list_namespaces() while remaining retrievable via get(). |
| |
| A literal >10_000-row repro is not reachable in this sandbox: the free-tier |
| cap is 2 MB (~3,584 rows) and paid tiers fail closed offline (server tier |
| verification is unreachable, so writes are gated at the free cap). The |
| architectural property is cap-magnitude-independent, so it is demonstrated |
| faithfully by lowering the enumeration cap and exceeding it — the identical |
| `list_entities(limit=_POOL)` code path with the identical eviction outcome. |
| """ |
|
|
| @pytest.mark.xfail( |
| reason="architectural: bounded by client MAX_LIMIT=10_000 with no cursor; " |
| "full fix needs a client-side enumeration API — pending operator decision", |
| strict=False, |
| ) |
| def test_residual_oldest_namespace_evicted_beyond_cap(self, monkeypatch): |
| |
| |
| monkeypatch.setattr(store_mod, "_POOL", 50) |
| s = _new_store() |
| s.put(("victim",), "vkey", {"text": "victim apple"}) |
| for i in range(60): |
| s._client.set_entity("noisy", f"k{i}", {"t": i}) |
| |
| assert s.get(("victim",), "vkey") is not None |
| |
| |
| assert len(s.search(("victim",))) == 1 |
| assert ("victim",) in s.list_namespaces(limit=5000) |
|
|