feat(dane): mechanizm rekordów-pułapek (canary) — wykrywanie wycieku baz (DAN-26)
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m29s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 11s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 8s
build / build (push) Successful in 19s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m30s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 11s
Testy / Kontrola składni wszystkich warstw (push) Successful in 7s
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m29s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 11s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 8s
build / build (push) Successful in 19s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m30s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 11s
Testy / Kontrola składni wszystkich warstw (push) Successful in 7s
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 <noreply@anthropic.com>
This commit was merged in pull request #52.
This commit is contained in:
@@ -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.
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user