Wyszukiwanie frazy sk_live w katalogu dist zwraca trafienie w pliku assets/index-4f2a1c.js. To klucz do zewnętrznego API, wstawiony do komponentu formularza kontaktowego wygenerowanego trzy dni wcześniej. Plik leży na CDN, ma publiczny URL i był serwowany każdemu, kto otworzył stronę. Od tego momentu liczy się kolejność działań, bo część z nich jest odwracalna, a część nie.
Pierwsze 30 minut: rotacja przed jakąkolwiek diagnozą
Odruch większości zespołów to najpierw zrozumieć, jak klucz tam trafił, a potem sprzątać. To zła kolejność. Dopóki stary klucz jest aktywny, każda minuta analizy to minuta, w której ktoś może go użyć. Historia gita nigdzie nie ucieknie, aktywny klucz owszem.
- Krok 1. Wygeneruj nowy klucz u dostawcy. Jeśli dostawca pozwala trzymać dwa aktywne klucze naraz, wykorzystaj to – unikniesz przerwy w działaniu.
- Krok 2. Wstaw nowy klucz do zmiennych środowiskowych po stronie serwera i wdróż. Nie do repozytorium, nie do pliku konfiguracyjnego frontu.
- Krok 3. Unieważnij stary klucz. Jeśli dostawca nie obsługuje dwóch kluczy równocześnie, zaplanuj krótkie okno i zrób to natychmiast po wdrożeniu.
- Krok 4. Pobierz logi użycia API od momentu pierwszego wdrożenia z kluczem w bundlu. Szukasz wywołań spoza zakresu adresów IP Twojej infrastruktury i skoków liczby zapytań.
- Krok 5. Sprawdź, czy dostawca nie ma osobnego rejestru zdarzeń bezpieczeństwa – część platform pokazuje, kiedy klucz był użyty po raz pierwszy z nowego adresu.
Dopiero po tych pięciu krokach zaczyna się śledztwo. Jeśli logi pokazują użycie z obcych adresów, incydent przestaje być techniczny i wchodzi w tryb obsługi naruszenia – z powiadomieniami, oceną zakresu danych i decyzją, kto informuje o tym klienta.
Ustalenie okna ekspozycji
Zanim napiszesz cokolwiek do klienta, potrzebujesz dwóch dat: kiedy klucz pierwszy raz pojechał na produkcję i kiedy przestał być aktywny. Pierwsza wynika z historii wdrożeń, nie z daty commita – kod mógł leżeć w gałęzi tygodniami. Druga to moment unieważnienia. Różnica między nimi jest jedyną liczbą, która ma znaczenie w rozmowie o skutkach, i jedyną, której nie da się odtworzyć później, jeśli logi wdrożeń mają krótką retencję. Zrzuć obie do notatki od razu.
Dlaczego generator wkłada klucz do komponentu klienckiego
Model generujący kod optymalizuje pod jedno kryterium: żeby po jednym uruchomieniu było widać działający efekt. Wywołanie fetch prosto z funkcji onSubmit jest najkrótszą drogą do tego celu. Osobny endpoint na serwerze wymaga drugiego pliku, obsługi błędów i konfiguracji – to trzy dodatkowe okazje do tego, żeby wygenerowany kod nie zadziałał za pierwszym razem.
Drugi mechanizm jest bardziej podstępny. Kod klienta widzi tylko zmienne z odpowiednim prefiksem. Bez prefiksu zmienna jest po prostu undefined, formularz zwraca 401, a najszybsza „naprawa” to dopisanie prefiksu. Bundler nie protestuje, build przechodzi, ręczny test wygląda poprawnie.
| Stack | Prefiks wystawiający zmienną do przeglądarki | Zachowanie bez prefiksu |
|---|---|---|
| Next.js | NEXT_PUBLIC_ |
zmienna dostępna tylko w kodzie serwerowym, w komponencie klienckim undefined |
| Vite (React, Vue, Svelte) | VITE_ |
undefined w import.meta.env, bez ostrzeżenia |
| SvelteKit | PUBLIC_ przez $env/static/public |
import z $env/static/private w kodzie klienta przerywa build błędem |
| Astro | PUBLIC_ |
undefined po stronie klienta |
| Nuxt | runtimeConfig.public, NUXT_PUBLIC_ |
wartość widoczna wyłącznie na serwerze |
| Expo | EXPO_PUBLIC_ |
undefined w kodzie aplikacji |
Z całej tej tabeli tylko SvelteKit zachowuje się głośno – próba zaimportowania prywatnej zmiennej do kodu, który trafi do przeglądarki, wywala build. Reszta stacków milczy i zostawia decyzję człowiekowi, który akurat patrzy na zielony status builda.
Gdzie klucz zostaje po usunięciu go z kodu
Usunięcie linijki i commit „fix: remove hardcoded key” nie kończy sprawy. Klucz zwykle istnieje w kilku miejscach jednocześnie, a część z nich jest poza Twoją kontrolą.
| Miejsce | Jak sprawdzić | Czy da się wyczyścić |
|---|---|---|
| Historia gita, także w zamkniętych PR | git log -p -S "sk_live" --all |
tylko przez przepisanie historii, forki zostają |
| Artefakty builda zapisane w CI | lista artefaktów w danym przebiegu, ustawienia retencji | tak, ręczne usunięcie |
| Logi builda | szukaj echo i printenv w krokach pipeline’u |
tak, zwykle przez usunięcie całego loga |
| Stare pliki bundla na CDN | plik z hashem w nazwie zostaje pod starym URL | purge cache plus usunięcie z originu |
| Source mapy wgrane obok bundla | curl -I na plik .js.map |
tak, ale bywają też w narzędziu do monitoringu błędów |
| Warstwy obrazu Docker | docker history, przeszukanie warstw |
przebudowa i usunięcie tagów z rejestru |
| Klony i forki repozytorium | lista forków, lokalne kopie zespołu | nie |
Ostatni wiersz jest powodem, dla którego rotacja idzie pierwsza. Przepisanie historii to porządki, nie zabezpieczenie. Klucz, który raz był publiczny, pozostaje spalony niezależnie od tego, ile commitów przepiszesz.
Przeniesienie wywołania na serwer bez przepisywania formularza
Docelowy kształt jest prosty: przeglądarka wysyła dane formularza do własnego endpointu, endpoint dokłada klucz i rozmawia z zewnętrznym API. Front nie zna klucza i nie zna nawet adresu dostawcy. Zmienia się tylko URL w fetch i miejsce, w którym leci walidacja.
Pułapka polega na tym, że łatwo zbudować przezroczyste proxy – endpoint, który przyjmuje dowolne pola i przekazuje je dalej. To ten sam problem z jednym dodatkowym skokiem sieciowym. Endpoint musi być wąski.
- Schemat wejścia po stronie serwera, z odrzuceniem pól spoza listy. Walidacja wygenerowana przez model zwykle siedzi tylko w atrybutach HTML i znika przy wywołaniu z
curl. - Limit rozmiaru ciała żądania, ustawiony jawnie, nie domyślny z frameworka.
- Ograniczenie liczby żądań na adres IP i na sesję, plus mechanizm antybotowy, jeśli formularz jest publiczny.
- Twardy timeout na wywołanie zewnętrznego API i osobna obsługa jego błędów.
- Odpowiedź dostawcy nie idzie do klienta jeden do jednego – front dostaje tylko status i komunikat, który sam ustalasz.
- Klucz czytany ze zmiennej środowiskowej przy starcie procesu, nie z pliku w repozytorium.
Osobna kategoria to klucze przeznaczone do przeglądarki: klucz publikowalny bramki płatniczej, klucz anonimowy bazy typu backend-as-a-service. Sam fakt ich obecności w bundlu nie jest błędem. Błędem jest brak reguł dostępu po drugiej stronie – klucz anonimowy przy wyłączonych regułach na poziomie wiersza daje czytelnikowi bundla dostęp do tabel. Sprawdzenie, który to typ klucza, jest częścią diagnozy, nie wymówką od niej.
Ograniczenie uprawnień nowego klucza
Nowy klucz nie powinien być kopią uprawnień starego. Incydent to najlepszy moment, żeby zawęzić zakres, bo i tak przechodzisz przez konfigurację u dostawcy.
- Zakres uprawnień ograniczony do operacji, których faktycznie używa ten jeden formularz. Jeśli dostawca daje klucze tylko do zapisu albo tylko do wysyłki, użyj ich.
- Osobny klucz dla każdego środowiska. Klucz produkcyjny nie ma prawa działać na staging, bo wtedy wyciek ze staging jest wyciekiem produkcyjnym.
- Ograniczenie do adresów IP wychodzących z Twojego serwera albo do domeny, jeśli dostawca to obsługuje.
- Limit wydatków lub limit zapytań na poziomie konta. To jedyna bariera, która działa, gdy klucz wycieknie po raz drugi.
- Data ważności i wpis w kalendarzu na rotację. Klucz bez terminu żyje tyle, ile projekt.
Skan bundla i historii w CI, nie tylko źródeł
Typowa konfiguracja skanuje repozytorium i na tym poprzestaje. Opisywany przypadek przechodzi przez taki skan bez zatrzymania, bo klucz nie leży w kodzie – wchodzi do bundla ze zmiennej środowiskowej ustawionej w CI. Repozytorium czyste, artefakt brudny.
Sensowny układ ma trzy warstwy. Pierwsza to hak pre-commit z gitleaks, który blokuje commit, zanim sekret wejdzie do historii. Druga to krok w pipeline na każdym pull requeście: gitleaks na diffie plus trufflehog na historii, przy czym ten drugi weryfikuje, czy znaleziony sekret jest wciąż aktywny – to skraca przeglądanie fałszywych trafień. Trzecia to warstwa platformy: skanowanie sekretów i push protection po stronie GitHuba, według changeloga GitHuba z 2026 roku dostępne bez opłat i domyślnie włączone dla repozytoriów publicznych, z listą wzorców rozszerzaną co kilka tygodni.
Brakujący element to czwarty krok, wykonywany po buildzie:
npm run build, a zaraz po nimgitleaks detect --no-git --source dist– skan tego, co realnie pojedzie na CDN.- Własne wzorce w
.gitleaks.tomldla wewnętrznych formatów kluczy, których żaden gotowy detektor nie zna. - Prosty grep na diffie wyłapujący nowe zmienne z prefiksem publicznym, z wynikiem oznaczonym jako wymagający decyzji człowieka.
- Znalezisko przerywa build. Ostrzeżenie w logu jest ignorowane po trzecim razie.
Czas trwania tego kroku zależy od rozmiaru katalogu wyjściowego – zmierz go u siebie na jednym przebiegu, zanim zdecydujesz, czy uruchamiać skan na każdym pushu, czy tylko na gałęzi wdrożeniowej. Jeśli katalog builda waży setki megabajtów, ogranicz skan do plików .js, .mjs i .map, bo obrazy i czcionki tylko wydłużają przebieg.
Reguła code review, która to wyłapuje
Skanery łapią znane wzorce. Klucz w formacie, którego nie znają, przechodzi. Dlatego przy pull requestach z wygenerowanym kodem sprawdzenie sprowadza się do trzech pytań, wpisanych wprost w szablon PR.
- Czy w tym diffie jest wywołanie zewnętrznego API z pliku, który trafia do przeglądarki? Jeśli tak, gdzie jest klucz?
- Czy pojawiła się nowa zmienna z prefiksem publicznym? Każda taka zmienna wymaga jednozdaniowego uzasadnienia w opisie PR i akceptacji drugiej osoby.
- Czy walidacja wejścia istnieje po stronie serwera, czy tylko w atrybutach formularza?
Do tego wpis w CODEOWNERS na pliki konfiguracji środowisk i na katalog z definicjami endpointów. Wygenerowany kod różni się od pisanego ręcznie tym, że wygląda na skończony – ma obsługę błędów, komunikaty i komentarze. Recenzent czyta go szybciej i mniej podejrzliwie niż kod napisany przez kolegę z zespołu. Trzy stałe pytania w szablonie kompensują tę różnicę taniej niż jakiekolwiek narzędzie.
Co zrobić w tym tygodniu
Jeśli incydent już się wydarzył, kolejność jest zamknięta: nowy klucz, wdrożenie, unieważnienie starego, logi dostawcy, potem historia gita i artefakty. Jeśli jeszcze się nie wydarzył, jest jedna decyzja do podjęcia na najbliższym planowaniu – czy build ma przerywać się na znalezisku w katalogu wyjściowym, czy nadal tylko na testach.
Konkretny następny krok zajmuje kilkanaście minut: dopisz do pipeline’u krok skanujący katalog builda po jego wykonaniu i uruchom go raz na obecnym stanie repozytorium. Jeśli coś znajdzie, masz odpowiedź na pytanie, od którego zaczyna się ten tekst, zanim tę odpowiedź dostaniesz od kogoś z zewnątrz.