File size: 16,708 Bytes
53e7aa3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
"""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


# --------------------------------------------------------------------------- #
# fixtures / helpers
# --------------------------------------------------------------------------- #
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()


# ========================================================================== #
# DEFENSES THAT HOLD  (these should PASS)
# ========================================================================== #
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):
        # An apostrophe is a legal name char (parameterized); ';' and '"' are
        # rejected by the client identifier validator. Either way: no SQLi.
        store.put(("legit",), "anchor", {"v": 1})
        store.put(("ns",), "x' OR '1'='1", {"secret": "leak"})
        # The classic injection string is stored verbatim as a literal name,
        # not interpreted, and does not widen the query.
        assert store.get(("ns",), "x' OR '1'='1").value == {"secret": "leak"}
        # The OR-injection did NOT make the anchor visible under ("ns",).
        assert store.get(("ns",), "anchor") is None
        # Table still intact.
        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  # noqa: F401
        # ';' is a forbidden identifier char -> ValidationError, not execution.
        with pytest.raises(Exception):
            store.put(("ns",), "a'; DROP TABLE entities;--", {"v": 1})
        # Prove the table was never dropped.
        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})
        # Filter eval is pure Python; SQL-shaped field/value cannot reach SQL.
        assert store.search(("f",), filter={"role'; DROP TABLE entities;--": "x"}) == []
        assert store.search(("f",), filter={"role": "x' OR '1'='1"}) == []
        # Table intact + legitimate filter still works.
        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",          # column filters
        "apple OR cherry", "apple AND banana", "NOT apple",           # boolean ops
        "apple NEAR cherry", "^apple", "app*",                       # near / col / prefix
        'secret"', '"unbalanced', '""', "(apple", "apple)", "\\",   # quotes / parens
        "*", "' OR 1=1 --",                                          # wildcard / sqli-shaped
    ]

    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()
        # Must not raise for any namespace scope.
        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()
        # nsB's scope must NEVER surface nsA's "secret" body, regardless of the
        # FTS operator / column-filter / quote trick injected.
        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):
        # ("users",)/"alice/profile"  must NOT collide with
        # ("users","alice")/"profile" — separate category & name columns.
        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"})   # overwrite A
        assert store.get(("users", "alice"), "profile").value == {"who": "B"}  # B untouched

    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")              # delete A
        assert store.get(("users", "alice"), "profile").value == {"who": "B"}  # B survives

    def test_defense_unicode_slash_lookalikes_do_not_collide(self, store):
        # U+002F "/" real separator, U+2044 fraction slash, U+FF0F fullwidth.
        store.put(("a", "b"), "k", {"id": "real-sep"})         # category "a/b"
        store.put(("a⁄b",), "k", {"id": "fraction"})      # single element
        store.put(("a/b",), "k", {"id": "fullwidth"})     # single element
        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):
        # "%2e%2e" is not a real traversal (no filesystem path is built); it is
        # stored as an opaque literal and round-trips, no escape.
        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)
        # beta's body never appears in alpha's subtree.
        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):
        # ("a",) prefix must not match sibling ("ab",) via string-prefix bleed.
        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"})
        # R16: SibylStore does NOT crash — the good record passes, the poison
        # record (str vs int is incomparable) is silently excluded.
        hits = sib.search(("p",), filter={"age": {"$gt": 18}})
        assert {it.key for it in hits} == {"good"}
        # Divergence preserved: InMemoryStore float-coerces "old" and raises.
        with pytest.raises(ValueError):
            im.search(("p",), filter={"age": {"$gt": 18}})


# ========================================================================== #
# FIX REGRESSION GUARDS  (these should PASS after the applied store.py fixes)
# ========================================================================== #
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"})  # oldest row
        # >old _POOL (1000) but well under the new _POOL (10_000). Written via
        # the client directly only for speed; same tenant / same DB / same path.
        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",))  # no-query subtree listing
        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"})
        # native: "10" > "3" is False ('1' < '3'); "10" > "09" is True ('1' > '0').
        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"})
        # native: "10" < "3" is True (lexical), the opposite of numeric 10 < 3.
        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"}}))
        # Documented, intentional divergence: native lexical (0) vs float (1).
        assert sib_hits == 0
        assert im_hits == 1
        assert sib_hits != im_hits


# ========================================================================== #
# RESIDUAL (xfail)  — bounded by the fix but not eliminated
# ========================================================================== #
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):
        # Lower the enumeration cap to exceed it cheaply (stands in for >10_000
        # rows, which the 2 MB free cap blocks). Same code path, same outcome.
        monkeypatch.setattr(store_mod, "_POOL", 50)
        s = _new_store()
        s.put(("victim",), "vkey", {"text": "victim apple"})  # oldest row
        for i in range(60):                                    # > lowered cap (50)
            s._client.set_entity("noisy", f"k{i}", {"t": i})
        # The data still exists ...
        assert s.get(("victim",), "vkey") is not None
        # ... but the CORRECT behavior (still searchable + listable) does NOT
        # hold once the row count exceeds the bounded, cursorless enumeration.
        assert len(s.search(("victim",))) == 1            # residual: returns 0
        assert ("victim",) in s.list_namespaces(limit=5000)  # residual: absent