From 78af6d47558edbba9f6b836ea8fac9e5e246a8f5 Mon Sep 17 00:00:00 2001 From: migatu Date: Mon, 3 Aug 2026 21:15:53 +0200 Subject: [PATCH] =?UTF-8?q?feat(dane):=20mechanizm=20rekord=C3=B3w-pu?= =?UTF-8?q?=C5=82apek=20(canary)=20=E2=80=94=20wykrywanie=20wycieku=20baz?= =?UTF-8?q?=20(DAN-26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zabezpieczenie DETEKCYJNE (nie prewencyjne): kilka unikalnych, wiarygodnie wyglądających rekordów-pułapek w bazach. Nie zmieniają interpretacji (odsiewamy je z wyników), ale jeśli pojawią się w cudzej kopii — są dowodem pochodzenia, a przy wariancie na kopię — wskazują ŹRÓDŁO wycieku. `canary.py`: pułapkę rozpoznajemy po MARKERZE (unikalny ciąg z ENV, nieobecny w realnych danych). `screen(rows, query_value)`: - ODSIEWA rekordy z markerem z wyników — i to na WYJŚCIU z warstwy danych (`/search`), więc nie dotrą wyżej ani do promptu LLM (LOG-30), niezależnie od dostawcy (Excel/SQL); - TRIPWIRE: gdy zapytanie celuje wprost w marker (enumeracja bazy, nie liczenie horoskopu) → log warning. Bez `CANARY_MARKERS` — przezroczyste, zero kosztu dla normalnego ruchu. Rejestr wariant→kopia (traitor tracing) i wstrzyknięcie do REALNYCH baz to krok właściciela (poza kodem — nie ruszamy kupionych plików automatycznie); instrukcja: docs/canary-registry.md. Mechanizm zbudowany i przetestowany na syntetycznych pułapkach. Testy: +7 (przezroczystość bez markerów, odsiewanie, tripwire, marker w dowolnym polu, endpoint odsiewa przed zwrotem). Pierwsze testy w usłudze `data`. Co-Authored-By: Claude Opus 4.8 --- docs/canary-registry.md | 58 +++++++++++++++++++++++++ services/data/app/canary.py | 70 ++++++++++++++++++++++++++++++ services/data/app/main.py | 11 ++++- services/data/tests/test_canary.py | 61 ++++++++++++++++++++++++++ 4 files changed, 198 insertions(+), 2 deletions(-) create mode 100644 docs/canary-registry.md create mode 100644 services/data/app/canary.py create mode 100644 services/data/tests/test_canary.py diff --git a/docs/canary-registry.md b/docs/canary-registry.md new file mode 100644 index 0000000..a6cab03 --- /dev/null +++ b/docs/canary-registry.md @@ -0,0 +1,58 @@ +# Rekordy-pułapki (canary) — instrukcja (DAN-26) + +Zabezpieczenie **detekcyjne**: nie zapobiega wyciekowi baz, ale pozwala go +**wykryć** i wskazać **z której kopii** wyciekł. Bazy są kupione i są rdzeniem +produktu — jeśli krążą gdzie indziej, chcemy to udowodnić. + +Mechanizm żyje w warstwie danych (`services/data/app/canary.py`) i działa na +wyjściu z `/search`, więc pułapki **nie docierają** ani do użytkownika, ani do +promptu LLM (wymóg LOG-30) — niezależnie od dostawcy (Excel/SQL). + +## Jak to działa + +1. **Marker** — unikalny ciąg, który nie występuje w realnych danych, wpleciony + w kilka wiarygodnie wyglądających rekordów-pułapek w bazach (np. w polu + znaczącym: `Ve Tau ASTROLOLO-CANARY-7f3a9`). +2. **Odsiewanie** — warstwa danych wykrywa rekord z markerem i usuwa go z wyników, + zanim opuszczą usługę. Interpretacje i prompty są czyste (log `info`). +3. **Tripwire** — jeśli zapytanie **celuje wprost** w marker (ktoś enumeruje bazę, + a nie liczy horoskopu), leci `warning` — sygnał podejrzanego zachowania. + +## Konfiguracja (per wdrożenie) + +Zmienne środowiskowe usługi `data`: + +| Zmienna | Znaczenie | +|---|---| +| `CANARY_MARKERS` | markery oddzielone przecinkami (kilka na wariant) | +| `CANARY_VARIANT` | etykieta wariantu tego wdrożenia (np. `prod-2026`, `partnerX`) | + +Bez `CANARY_MARKERS` mechanizm jest **przezroczysty** (zero kosztu). Markery są +sekretem — trzymaj je jak `astrololo-auth` (poza repo GitOps), przez `secretKeyRef`. + +## Rejestr wariant → kopia (traitor tracing) + +Sedno atrybucji: **każda dystrybuowana kopia baz dostaje inny zestaw pułapek**, +a Ty trzymasz mapę, który wariant trafił dokąd. Gdy zobaczysz pułapkę w cudzej +kopii → sprawdzasz marker w rejestrze → wiesz, skąd wyciekła. + +Rejestr trzymaj **poza kodem i repo** (arkusz/menedżer sekretów po stronie ops), +np.: + +| Wariant | Markery | Wdrożenie / odbiorca | Data | +|---|---|---|---| +| `prod-2026` | `…7f3a9`, `…b12c` | produkcja czernobog | 2026-08 | +| `partnerX` | `…9de4`, `…0a1b` | kopia dla partnera X | 2026-08 | + +## Wstrzyknięcie pułapek do baz (krok właściciela) + +To robi właściciel na **realnych** plikach (kod tego nie robi — nie ruszamy +kupionych baz automatycznie): dodać kilka rekordów-pułapek z markerem danego +wariantu, w stylu nieodróżnialnym od prawdziwych wpisów. Kilka, wtopionych — +łatwiej, gdy ktoś zna mechanizm, wyciąć jeden oczywisty niż wszystkie. + +## Granice + +Canary dowodzi pochodzenia tylko, gdy wyciek **zawiera** treść pułapki (pełna +kopia — tak; parafraza — niekoniecznie). Nie wykrywa retencji *prawdziwej* treści +u dostawcy LLM — od tego jest bramka LOG-32 i wniosek o Zero Data Retention. diff --git a/services/data/app/canary.py b/services/data/app/canary.py new file mode 100644 index 0000000..bec65ce --- /dev/null +++ b/services/data/app/canary.py @@ -0,0 +1,70 @@ +"""Rekordy-pułapki (canary) — wykrywanie wycieku baz (DAN-26). + +Zabezpieczenie DETEKCYJNE, nie prewencyjne. Kilka unikalnych, wiarygodnie +wyglądających rekordów wplecionych w bazy: nie zmieniają interpretacji (są +ODSIEWANE z wyników, więc nie trafiają ani do użytkownika, ani do promptu LLM — +wymóg LOG-30), ale jeśli kiedyś pojawią się w cudzej kopii, są dowodem pochodzenia. + +Pułapkę rozpoznajemy po MARKERZE: unikalny ciąg, który nie występuje w realnych +danych (wpleciony np. w pole znaczące). Markery i WARIANT tego wdrożenia biorą się +z konfiguracji (ENV `CANARY_MARKERS`, `CANARY_VARIANT`) — każda kopia może dostać +swój zestaw. Który wariant trafił do którego wdrożenia trzyma rejestr po stronie +ops (osobno, poza kodem — patrz docs/canary-registry.md). + +Dwa sygnały: + * ODSIEWANIE — pułapka w wynikach znika, zanim opuści warstwę danych (log info); + * TRIPWIRE — zapytanie CELUJE wprost w marker (ktoś enumeruje bazę, a nie liczy + realny horoskop) → log warning, bo to podejrzane zachowanie. +""" +from __future__ import annotations + +import logging +import os + +log = logging.getLogger("astrololo.data.canary") + + +def _load_markers() -> list[str]: + return [m.strip() for m in os.getenv("CANARY_MARKERS", "").split(",") if m.strip()] + + +MARKERS = _load_markers() +VARIANT = os.getenv("CANARY_VARIANT", "") + + +def _row_is_canary(row: dict, markers: list[str]) -> bool: + """Czy KTÓRAKOLWIEK tekstowa wartość wiersza zawiera marker pułapki.""" + for v in row.values(): + if isinstance(v, str): + for m in markers: + if m in v: + return True + return False + + +def screen( + rows: list[dict], query_value: str = "", + markers: list[str] | None = None, variant: str | None = None, +) -> tuple[list[dict], dict]: + """Zwraca (widoczne_wiersze, raport). + + Odsiewa pułapki z wyników (nie opuszczą warstwy danych). Gdy zapytanie celuje + wprost w marker — podnosi TRIPWIRE (możliwa enumeracja bazy). Bez skonfigurowanych + markerów: przezroczyste (`active=False`), zero kosztu dla normalnego ruchu. + """ + markers = MARKERS if markers is None else markers + variant = VARIANT if variant is None else variant + if not markers: + return rows, {"active": False, "removed": 0, "tripwire": False} + + visible = [r for r in rows if not _row_is_canary(r, markers)] + removed = len(rows) - len(visible) + tripwire = any(m in (query_value or "") for m in markers) + + if tripwire: + log.warning( + "CANARY TRIPWIRE: zapytanie celuje wprost w rekord-pułapkę (wariant %s) " + "— możliwa enumeracja bazy", variant or "?") + elif removed: + log.info("Odsiano %d rekord(ów)-pułapek z wyników (wariant %s)", removed, variant or "?") + return visible, {"active": True, "removed": removed, "tripwire": tripwire} diff --git a/services/data/app/main.py b/services/data/app/main.py index 766be9c..8d9f5c9 100644 --- a/services/data/app/main.py +++ b/services/data/app/main.py @@ -11,7 +11,7 @@ from contextlib import asynccontextmanager from fastapi import FastAPI -from app import link_crypto, security +from app import canary, link_crypto, security from app.config import settings from app.models import HealthInfo, SearchQuery, SearchResult from app.providers.factory import build_provider @@ -34,7 +34,14 @@ link_crypto.install(app, link_crypto.ENV_LOGIC_DATA, "danych") @app.post("/search", response_model=SearchResult) def search(query: SearchQuery) -> SearchResult: - return provider.search(query) + result = provider.search(query) + # Rekordy-pułapki (DAN-26) odsiewamy TU, na wyjściu z warstwy danych — dzięki + # temu nie dotrą ani wyżej, ani do promptu LLM (LOG-30), niezależnie od dostawcy. + visible, report = canary.screen(result.rows, query.value) + if report["active"]: + result.rows = visible + result.total = len(visible) + return result @app.get("/health", response_model=HealthInfo) diff --git a/services/data/tests/test_canary.py b/services/data/tests/test_canary.py new file mode 100644 index 0000000..f7ad2a6 --- /dev/null +++ b/services/data/tests/test_canary.py @@ -0,0 +1,61 @@ +"""Rekordy-pułapki (canary) — DAN-26. + +Testujemy sam MECHANIZM na syntetycznych pułapkach: prawdziwe markery i injekcja +do baz przychodzą od właściciela produktu. Regresja byłaby CICHA i podwójnie zła: +albo pułapka wycieka do interpretacji/LLM (zdradza się i psuje wynik), albo +przestaje odsiewać i nie wiadomo o tym. +""" +import pathlib + +from app import canary + +MAIN = (pathlib.Path(__file__).resolve().parents[1] / "app" / "main.py").read_text(encoding="utf-8") + +MARK = "ASTROLOLO-CANARY-7f3a9" # unikalny — nie wystąpi w realnych danych +ROWS = [ + {"significator": "Ma Ari", "effect": "odważny, impulsywny"}, + {"significator": "Ve Tau " + MARK, "effect": "pułapka — nie dotknie interpretacji"}, + {"significator": "Su Leo", "effect": "dumny, twórczy"}, +] + + +def test_no_markers_is_transparent(): + """Bez skonfigurowanych markerów — zero ingerencji, zero kosztu.""" + out, rep = canary.screen(ROWS, "cokolwiek", markers=[]) + assert out == ROWS and rep["active"] is False + + +def test_canary_row_is_fenced_from_results(): + """Pułapka znika z wyników — nie opuści warstwy danych (a więc i promptu LLM).""" + out, rep = canary.screen(ROWS, "Ve Tau", markers=[MARK]) + assert rep["active"] and rep["removed"] == 1 + assert all(MARK not in str(r) for r in out) # nigdzie nie ma markera + assert len(out) == 2 and {"significator": "Su Leo", "effect": "dumny, twórczy"} in out + + +def test_real_results_pass_through_untouched(): + out, _ = canary.screen(ROWS, "Ari", markers=[MARK]) + assert {"significator": "Ma Ari", "effect": "odważny, impulsywny"} in out + + +def test_tripwire_when_query_targets_a_marker(): + """Zapytanie CELUJĄCE w marker = ktoś enumeruje bazę, nie liczy horoskopu.""" + _, rep = canary.screen(ROWS, "Ve Tau " + MARK, markers=[MARK]) + assert rep["tripwire"] is True + + +def test_normal_query_does_not_trip(): + _, rep = canary.screen(ROWS, "Ma Ari", markers=[MARK]) + assert rep["tripwire"] is False + + +def test_marker_matched_in_any_string_field(): + rows = [{"significator": "X", "effect": "opis " + MARK, "extra": 5}] + out, rep = canary.screen(rows, "X", markers=[MARK]) + assert out == [] and rep["removed"] == 1 # marker w polu 'effect' też łapiemy + + +def test_endpoint_fences_before_returning(): + """/search odsiewa pułapki na wyjściu z warstwy danych (niezależnie od dostawcy).""" + assert "canary.screen(result.rows, query.value)" in MAIN + assert "result.rows = visible" in MAIN