Po roku z modelami wracam do dziury, której model nie zamyka
Jest 15 listopada 2023. Rok temu, w podsumowaniu 2022, zapisałem ChatGPT jako nagłówek końca roku, a nie jako narzędzie roku. Dziś muszę przyznać, że ten nagłówek został na dłużej: prawie wszystko, co napisałem na tym blogu w 2023, dotyczyło modeli językowych przy pracy QA. Przypadki testowe, struktura promptu, syntetyczne dane do fixture’ów, czytanie migotliwych przebiegów, szkice dokumentacji. Nie żałuję ani jednego z tych wieczorów.
Przez cały ten rok w moim zestawie testów siedziała jednak dziura, której żaden z tych eksperymentów nawet nie dotknął. Bezpieczeństwo aplikacji, którą testuję. Nie w sensie “kto ma dostęp do repozytorium” i nie w sensie “czy klucz API jest w zmiennej środowiskowej”, bo to mam poukładane. W sensie prostszym i bardziej niewygodnym: mój zestaw E2E świeci na zielono, kiedy użytkownik robi to, co przewidziałem, a nikt z nas nigdy nie sprawdził, co ta sama aplikacja odpowiada, kiedy zalogowany użytkownik poprosi o zasób należący do kogoś innego. Zielony przebieg E2E nie znaczy “nie ma IDOR”. Znaczy tylko, że nie mam testu, który by go szukał.
Zanim ktokolwiek pomyśli, że to wpis o modnym temacie: nie jest. Prompt injection i jailbreak modeli językowych widzę na slajdach konferencyjnych od wiosny, ale to nie ten post. To też nie kurs pentestu, bo pentestu nie umiem i nie sprzedaję. To notatka o tym, co inżynier automatyzacji testów może zrobić z podstawami bezpieczeństwa, zanim ktokolwiek nazwie to usługą: OWASP Top 10 jako mapa ryzyk, ZAP baseline jako dym na CI, Burp ręcznie przy logowaniu.
Dwa techniczne przypisy, żeby było jasne, na czym to stoi. Wczoraj, 14 listopada, wyszedł .NET 8. Odnotowuję i wracam do tematu, bo nic z tego, co poniżej, nie zależy od wersji runtime’u; moje projekty stoją na .NET 7 i przykłady zostają na 7. Playwrighta pinuję na 1.39.0, tej samej wersji co miesiąc temu.
Mapa ryzyk, nie checklista czterystu narzędzi
Pierwszy odruch miałem taki jak zawsze przy nowym temacie: poszukać narzędzia. Po dwóch wieczorach z listami “top security testing tools” wiem, że to najgorszy możliwy start. Narzędzi jest kilkaset, każde chce być platformą, a żadne nie odpowiada na pytanie, które mam naprawdę: które z ryzyk dotyczy aplikacji, którą testuję w tym tygodniu.
Dlatego zaczynam od mapy, a mapą jest OWASP Top 10 w edycji 2021. Poprzednia edycja jest z 2017 roku, więc to nadal aktualna lista, i to jest pierwsza rzecz, którą trzeba o niej wiedzieć: zmienia się raz na kilka lat, więc nie ma sensu odświeżać jej co sprint. Druga rzecz jest ważniejsza. To dokument świadomościowy zbudowany z danych i ankiety, a nie plan testów, nie standard i nie certyfikat. Nie da się “przejść Top 10”. Da się przy jego pomocy nazwać ryzyko, o którym wcześniej nie myślałem.
Najwyżej na liście 2021 stoi Broken Access Control, czyli A01. W edycji 2017 ta kategoria była na piątym miejscu i ten awans jest dla mnie najbardziej treściwą informacją z całego dokumentu, bo Broken Access Control to dokładnie ta klasa błędów, której nie widzi ani mój zestaw jednostkowy, ani E2E przez interfejs. Interfejs nigdy nie prosi o cudze id, bo nie ma na to przycisku.
Nie testuję dziesięciu kategorii. Wybieram trzy, które pasują do tego, co i tak mam pod ręką, czyli do API i sesji.
A01, Broken Access Control. Brak sprawdzenia właściciela zasobu, brak sprawdzenia uprawnienia na poziomie funkcji (endpoint administracyjny odpowiadający zwykłemu użytkownikowi), zaufanie do pola z żądania zamiast do tożsamości z tokenu. Tu leży mój IDOR z poprzedniego akapitu.
A07, Identification and Authentication Failures. Interesują mnie trzy pytania, na które umiem napisać test: czy wylogowanie faktycznie unieważnia sesję po stronie serwera, czy token wygasa i co dzieje się po jego wygaśnięciu, czy identyfikator sesji nie wędruje w adresie URL. To nie jest pełna kategoria, to trzy sprawdzalne zdania z niej.
A05, Security Misconfiguration. Domyślna konfiguracja przeniesiona ze szkieletu projektu, endpoint diagnostyczny wystawiony na staging, strona błędu ze stack trace’em, CORS z gwiazdką przy jednoczesnym przesyłaniu ciasteczek, brak nagłówków bezpieczeństwa. Ta kategoria jest jedyną z trzech, w której automat naprawdę mi pomaga.
Świadomie zostawiam resztę i mówię, dlaczego. Injection (A03) po stronie mojego stacku zamyka głównie ORM z parametryzowanymi zapytaniami i review kodu, a poza tym nie zamierzam publikować payloadów w notatce na blogu. Vulnerable and Outdated Components (A06) to nie temat na wieczór z narzędziem interaktywnym, tylko osobny job w pipeline: dotnet list package --vulnerable po stronie .NET i skanowanie zależności po stronie npm. To zresztą najtańsza rzecz z całej listy i pierwsza, którą bym włączył, gdybym miał zrobić tylko jedną.
I zdanie, które chcę mieć zapisane, żeby nie dało się mnie z niego wyciągnąć w przyszłym roku: ani Top 10, ani żaden skaner nie zastępują review kodu i modelowania zagrożeń. Skaner nie powie mi, że dany endpoint w ogóle nie powinien istnieć, ani że dwie role w systemie zostały zaprojektowane tak, że jedna z definicji widzi za dużo. To rozmowa z ludźmi, którzy znają domenę, i nie ma jej czym zautomatyzować.
Automat: ZAP baseline jako dym, nie jako skan
OWASP ZAP potrafi dużo, ale mnie interesuje w nim jedna, najnudniejsza funkcja: baseline. To przebieg pasywny. Narzędzie przechodzi po aplikacji, patrzy na to, co dostaje w odpowiedziach, i zgłasza alerty z reguł pasywnych. Nie wysyła żądań, które mają zmienić stan aplikacji, i kończy się w minutach, nie w godzinach. To jest zupełnie inna czynność niż pełny skan aktywny, który wysyła setki żądań, zapisuje dane, potrafi wyczyścić koszyk testowego użytkownika i obudzić alerting.
Ta różnica decyduje o tym, gdzie taki job trafia. Wracam do trzech koszyków z strategii testów w CI/CD i wkładam baseline w drugi koszyk, ten po merdżu, bo baseline potrzebuje wdrożonej aplikacji, a bramka na pull requeście ma być deterministyczna i mieścić się w minutach. Skan aktywny, jeśli kiedykolwiek, to nocą i na środowisku, którego nikt w tym momencie nie używa. Nigdy na produkcji i nigdy na cudzym hoście.
Szkic joba wygląda tak, celowo bez publikacji raportu i bez kroków budowania:
security-baseline:
if: github.event_name == 'push'
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: ZAP baseline na staging
uses: zaproxy/action-baseline@v0.10.0
with:
# wlasne srodowisko, wlasna domena wewnetrzna
target: https://staging.qa.internal.example/
rules_file_name: .zap/rules.tsv
# -I: ostrzezenia nie wywalaja joba, czerwony jest tylko poziom FAIL
cmd_options: '-I'Trzy decyzje w tym szkicu są całą jego treścią. Trigger jest na push, więc job nie blokuje nikomu merdża. Nie ma tu trybu aktywnego, bo to ma być dym, a nie skan. I jest plik reguł, który w praktyce jest najważniejszym plikiem w całym przedsięwzięciu.
Plik .zap/rules.tsv to lista identyfikatorów reguł z poziomem: IGNORE, WARN albo FAIL. Domyślnie wszystko jest ostrzeżeniem, a z opcją -I ostrzeżenia nie psują builda. Czerwony job dostaję więc tylko z tych reguł, które sam podniosłem do FAIL, czyli z tego, co po przejrzeniu pierwszego raportu uznałem za realne ryzyko wysokie w mojej aplikacji. Reszta ląduje w raporcie i idzie do przeglądu, a nie na czerwono.
Jest jeden warunek, bez którego cała ta konstrukcja przestaje działać po dwóch tygodniach: każdy wpis IGNORE ma komentarz z powodem i datą. Alert wyciszony bez uzasadnienia to nie jest decyzja, to jest zapomnienie, a job, który świeci na czerwono bez końca, zostanie wyłączony przez pierwszą osobę, której zablokuje wydanie. Widziałem to przy testach migotliwych i mechanizm jest identyczny.
Co baseline faktycznie u mnie znalazł na własnym stagingu: brak Strict-Transport-Security, brak Content-Security-Policy, ciasteczko bez atrybutu SameSite, nagłówek serwera zdradzający wersję, przestarzała biblioteka JavaScript ładowana z CDN. Nie wklejam raportu, bo nie o to chodzi, a poza tym połowa tych alertów okazała się różnicą między stagingiem a produkcją: część nagłówków dokłada u nas warstwa wejściowa, której na stagingu po prostu nie ma. To był pierwszy raz, kiedy zobaczyłem, że różnica w konfiguracji środowisk jest sama w sobie ustaleniem, a nie szumem.
Ważniejsze jest to, czego baseline nie znalazł. Nie znalazł braku sprawdzenia właściciela zasobu, bo pasywna reguła nie ma pojęcia, kto jest właścicielem zamówienia numer 41. Odpowiedź 200 OK z poprawnym JSON-em jest dla skanera wzorowa. Wygląda dokładnie tak, jak powinna wyglądać poprawna odpowiedź, i tym się właśnie różni ta klasa błędów od reszty.
Ręcznie: Burp przy auth
Dlatego druga połowa tego wieczoru wygląda inaczej: przeglądarka, proxy i jedno żądanie powtórzone raz. Używam Burp Suite w wersji Community, więc mam proxy i Repeatera; skaner jest w wersji Pro i nie jest mi na tym etapie potrzebny. Konfiguracja to certyfikat CA w przeglądarce i filtr na zakres, żeby w historii nie lądował cały internet, tylko mój host.
Dwie zasady przed pierwszym kliknięciem, obie twarde. Pracuję wyłącznie na środowisku, które należy do nas, i wyłącznie na kontach, które sam założyłem, z danymi syntetycznymi. Nie kieruję proxy na żywy host, którego nie jestem właścicielem, i nie robię tego również wtedy, gdy “to tylko jedno żądanie”.
Idea jest prosta na tyle, że da się ją zapisać w jednym zdaniu, i nie jest to instrukcja włamania, bo nie ma tu czego łamać. Zakładam dwa syntetyczne konta, A i B. Konto A ma zamówienie. Loguję się jako A, przechwytuję żądanie odczytu tego zamówienia, przenoszę je do Repeatera i wysyłam raz jeszcze, tym razem z tokenem konta B. Oczekiwana odpowiedź to odmowa: 403, albo 404, jeśli nie chcemy potwierdzać istnienia zasobu. Ten sam request, inna tożsamość właściciela, oczekiwana odmowa. To wszystko. Jeżeli dostaję 200 z danymi konta A, to endpoint nie sprawdza właściciela i mam ustalenie, którego nie da się już odzobaczyć.
Demo, które opisuję, jest syntetyczne i istnieje tylko w tym wpisie. Handler odczytu zamówienia, który sprawdza wyłącznie, czy token jest ważny, i zapomina o właścicielu. Po stronie .NET różnica między błędnym i poprawnym wariantem to jeden predykat:
// bledny handler: autoryzacja konczy sie na tym, ze token jest wazny
var order = await db.Orders
.FirstOrDefaultAsync(o => o.Id == id);
// poprawny handler: tozsamosc wolajacego jest czescia zapytania
var order = await db.Orders
.FirstOrDefaultAsync(o => o.Id == id && o.OwnerId == callerId);To jest cała treść tej klasy błędów i dlatego nie ma tu żadnego payloadu do opublikowania. Nie ma sprytnego ciągu znaków, nie ma obejścia mechanizmu logowania. Jest zapytanie, w którym zabrakło jednego warunku, i to zapytanie zachowuje się bezbłędnie tak długo, jak długo pyta o nie właściciel.
Wieczór z Repeaterem nie kończy się jednak raportem, bo raport nikogo w moim zespole nie obchodzi i słusznie. Kończy się testem w zestawie API, czyli w tym samym miejscu, w którym trzymam testy integracyjne opisane przy DI na potrzeby testów:
[Fact]
public async Task Odczyt_zamowienia_innego_wlasciciela_zwraca_403()
{
var owner = await _factory.CreateSyntheticUserAsync("owner@example.com");
var stranger = await _factory.CreateSyntheticUserAsync("stranger@example.com");
var orderId = await _factory.CreateOrderForAsync(owner);
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization = stranger.Bearer;
var response = await client.GetAsync($"/api/orders/{orderId}");
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}Ten test ma jednak jeden warunek istnienia i jest on nieoczywisty. Ma sens tylko wtedy, gdy host testowy nie podmienił uwierzytelniania na schemat, który przepuszcza wszystko. Sztuczka z podmienianiem implementacji przez kontener zależności, którą opisywałem rok temu jako wygodę, jest jednocześnie najprostszym sposobem, żeby po cichu usunąć dokładnie ten mechanizm, o który tu pytam. Jeżeli w konfiguracji testowej rejestruję permisywny handler uwierzytelniania, to moja asercja na 403 mierzy własną atrapę. Sprawdziłem to u siebie zanim napisałem drugi taki test i było gorzej, niż się spodziewałem.
Playwright w wersji 1.39.0 tego nie złapie i nie ma powodu, żeby próbować. Test przez interfejs klika w to, co interfejs pokazuje, a interfejs nigdy nie wpisuje cudzego identyfikatora. To pytanie należy do warstwy API i tam zostaje.
Co już mam na blogu i czego to nie sprawdza
Najbardziej niewygodna część tego tematu polega na tym, że kilka rzeczy, które sam tu opisywałem jako dobre praktyki, daje przy bezpieczeństwie fałszywe poczucie pokrycia.
Mock HTTP nie jest mechanizmem bezpieczeństwa. Stub w WireMocku zwraca dokładnie to, co mu wpisałem, i nigdy nie odmawia. Jeżeli prawdziwy dostawca odrzuca token bez odpowiedniego zakresu uprawnień, a mój stub odpowiada 200 na każde żądanie, to mój zielony zestaw testuje uprzejmą fikcję. Mock odpowiada na pytanie o dostępność i kształt odpowiedzi, nie o politykę dostępu.
Kontrakt nie sprawdza uprawnień. Test kontraktowy z wpisu o contract testingu opisuje kształt komunikatu i status dla opisanej interakcji. Weryfikacja po stronie dostawcy przechodzi, bo stan dostawcy przygotowałem sam, razem z tożsamością wołającego. Kto ma prawo wywołać ten endpoint, jest poza kontraktem i żaden pakt tego nie wyłapie.
Host in-memory nie widzi infrastruktury. Testy integracyjne przez fabrykę aplikacji, o których pisałem przy DI dla testów, nie mają przed sobą ani warstwy wejściowej, ani terminacji TLS, ani zapory aplikacyjnej. Wszystkie nagłówki, które w produkcji dokłada infrastruktura, są w takim teście niewidoczne, więc asercja “mamy CSP” napisana na tym poziomie jest asercją o niczym.
Kontener to izolacja, nie hartowanie. Zestaw kontenerów z Testcontainers na Azure Pipelines daje mi powtarzalne środowisko i czysty stan. Nie daje modelu zagrożeń i nie zmienia tego, że obraz z domyślnym hasłem w zmiennej środowiskowej jest dokładnie tym, na co wygląda. To narzędzie do izolacji testów, a jego obecność w pipeline nie jest argumentem w rozmowie o bezpieczeństwie.
Żaden z tych wpisów nie był błędny. Wszystkie odpowiadały na inne pytania i dopiero teraz widzę, w którym miejscu przestają odpowiadać.
Podsumowanie: co łapie QA, a co jest osobną umową
Po dwóch tygodniach czytania i dwóch wieczorach z narzędziami mam listę, którą uznaję za podstawy dla siebie na dziś, i jest krótsza, niż się bałem.
Baseline ZAP jako job po merdżu, czerwony wyłącznie na regułach, które sam podniosłem, z plikiem wyciszeń, w którym każdy wpis ma powód i datę. Garść testów autoryzacji na zasób w zestawie API: ten sam request, inny właściciel, oczekiwana odmowa. Trzy sprawdzalne pytania o sesję: czy wylogowanie unieważnia ją po stronie serwera, czy token wygasa, czy identyfikator sesji nie siedzi w adresie. Skanowanie zależności jako osobny, tani job. Do tego jedno ustalenie procesowe: konfiguracja stagingu różniąca się od produkcji jest ustaleniem, nie szumem.
Czego nie robię i nie zamierzam nazywać inaczej. Nie nazywam tego pentestem, bo pentest to metodyka, zakres i osoby, które robią to zawodowo. Nie dotykam systemów, których nie jesteśmy właścicielami. Nie buduję łańcuchów exploitów i nie publikuję ustaleń, nawet własnych. Pentest i program bug bounty to osobna umowa: zakres, reguły zaangażowania i pisemna zgoda przed pierwszym żądaniem. Modelowanie zagrożeń zostaje przy ludziach, którzy znają domenę, a review kodu przy zespole.
Zdanie, które chcę pamiętać z tego listopada: zielony przebieg E2E mówi, że aplikacja działa dla użytkownika, który zachowuje się grzecznie. O tym, czy działa również dla użytkownika, który wpisze cudze id, mówi wyłącznie test, który o to pyta. Model językowy nie napisze mi tego testu w miejsce myślenia o ryzyku, bo najpierw ktoś musi wybrać, o który zasób i o którego właściciela pyta.
Za miesiąc siadam do podsumowania roku i już wiem, że będzie o AI, bo taki był ten rok. Ta notatka jest po to, żeby w tym podsumowaniu nie zabrakło jednego akapitu o tym, czego modele w moim zestawie testów w ogóle nie ruszyły.

