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 fastapi import FastAPI
|
||||||
|
|
||||||
from app import link_crypto, security
|
from app import canary, link_crypto, security
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.models import HealthInfo, SearchQuery, SearchResult
|
from app.models import HealthInfo, SearchQuery, SearchResult
|
||||||
from app.providers.factory import build_provider
|
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)
|
@app.post("/search", response_model=SearchResult)
|
||||||
def search(query: SearchQuery) -> 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)
|
@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