
Kiedy „oficjalny obraz” nie oznacza „bezpieczny obraz”
W świecie Dockera często mówimy o obrazach i kontenerach, ale rzadziej zastanawiamy się nad systemem bazowym, na którym te kontenery są oparte. A to poważny błąd – „base image” to fundament naszego kontenera. jeżeli jest słaby lub popękany od znanych podatności, cała aplikacja może być zagrożona.
W tym artykule przyjrzymy się, jak system bazowy wpływa na bezpieczeństwo aplikacji w kontenerze, jak dobrać bezpieczną dystrybucję (od pełnych Debian/Ubuntu po ultralekkie Alpine, Distroless czy choćby Scratch), jak wykrywać podatności (narzędzia skanujące: Trivy, Clair, Grype, Snyk, JFrog Xray), a także omówimy konkretne zagrożenia (np. CVE, ataki escape, privilege escalation, wycieki tajnych kluczy) oraz najlepsze praktyki tworzenia obrazów. Wszystko to opatrzone przykładami, checklistą i praktycznymi poradami dla początkujących DevSecOps. Zapnij pasy – startujemy z kontenerem bezpieczeństwa!
Rola systemu bazowego w obrazie kontenera
Każdy obraz Dockera zaczyna się od linijki FROM ... – to wybór systemu bazowego. Ten bazowy obraz to nic innego jak minimalny system operacyjny z pewnym zbiorem bibliotek i narzędzi, na którym “doklejamy” naszą aplikację. Przykładowo startując od ubuntu:latest dostajemy pełne środowisko Ubuntu, a od alpine:3.18 – malutką dystrybucję Alpine Linux. Wszystko co zawiera base image (np. biblioteka glibc w Debianie lub musl w Alpine, pakiety systemowe, powłoki, narzędzia typu bash, curl itp.) staje się częścią naszego kontenera i dziedziczy się do wszystkich obrazów budowanych na jego podstawie. Niestety, oznacza to również dziedziczenie podatności: jeżeli bazowy system zawiera lukę bezpieczeństwa, każdy kontener z niego zbudowany będzie na nią narażony. W praktyce dziurawy base image = dziurawy kontener, niezależnie od tego, jak bezpiecznie napiszemy kod aplikacji.
Warto uświadomić sobie skalę problemu. Badania pokazują, iż ponad 33% oficjalnych obrazów na Docker Hub zawiera co najmniej jedną podatność o wysokim priorytecie, a blisko 66% ma luki wysokiego lub średniego poziomu. Innymi słowy – statystycznie dwie na trzy popularne bazy Dockerowe mają „wbudowane” dziury bezpieczeństwa prosto po pobraniu z sieci. Dotyczy to choćby renomowanych, oficjalnych obrazów dostarczanych przez vendorów. Na przykład podatności OpenSSL pokroju słynnego Heartbleed czy POODLE wykryto w około 10% oficjalnych obrazów na Docker Hub. o ile więc bezrefleksyjnie używamy obrazu bazowego, zakładając iż jest „na pewno bezpieczny”, to ryzykujemy wyniesienie tych dziur do naszego środowiska produkcyjnego.
Wpływ składu obrazu bazowego na bezpieczeństwo aplikacji
Im więcej komponentów w systemie bazowym, tym większa powierzchnia ataku. Duży obraz oparty na pełnej dystrybucji (np. Debian, Ubuntu) zawiera setki pakietów – od narzędzi powłoki, przez biblioteki systemowe, po menedżery pakietów. Każdy z tych elementów może mieć przypisane CVE (znane podatności). Dla porównania, obrazy Alpine Linux słyną z minimalistycznego podejścia – cała baza to zaledwie ~5 MB danych, korzystają z alternatyw (musl zamiast glibc, BusyBox zamiast coreutils), więc zawierają dużo mniej potencjalnie podatnych bibliotek. Mniejsze obrazy oznaczają mniej miejsca dla atakującego, mniej drzwi i okien, którymi może wejść. Jak ujął to raport Wiz, Alpine i podobne minimalne base image redukują bloat i mają mniejszą powierzchnię ataku w porównaniu do cięższych baz Debianowych.
Czy to znaczy, iż wystarczy przerzucić się na Alpine i problem znika? Niestety nie do końca. Po pierwsze, choćby minimalny system może zawierać podatności – np. luka w musl libc czy BusyBox dotknie wszystkie kontenery Alpine. Po drugie, paradoksalnie zbyt ubogi obraz utrudnia bezpieczeństwo operacyjne. Brak narzędzi diagnostycznych (brak powłoki Bash, brak menedżera pakietów) utrudnia debugowanie incydentów. Obrazy typu Distroless (Google) idą jeszcze dalej – nie mają żadnej powłoki ani zbędnych binarek, zawierają tylko absolutne minimum do uruchomienia aplikacji. Świetnie z perspektywy atakującego (bo choćby jak uzyska dostęp do kontenera, to nie znajdzie tam komfortowego środowiska do eksploracji), ale i dla obrońcy jest to wyzwanie – aby zajrzeć do wnętrza takiego kontenera, trzeba stosować sztuczki (np. tymczasowe Dockerfile dodające powłokę, albo debug-containery). Konkluzja: im mniejszy obraz, tym bezpieczniejszy z punktu widzenia liczby luk, ale tym większe wyzwania operacyjne i potencjalne problemy z kompatybilnością. Trzeba znaleźć złoty środek pasujący do naszego przypadku użycia.
Na bezpieczeństwo wpływa też rodowód obrazu bazowego. Obrazy oficjalne (np. node:18-bullseye od Node.js z bazą Debian, czy python:3.11-alpine) są zwykle lepiej utrzymane i regularnie patchowane, podczas gdy obrazy społecznościowe mogą tkwić latami bez aktualizacji. Badanie Shu et al. obejmujące 356 tysięcy obrazów wykazało, iż przeciętny obraz (oficjalny czy nie) zawiera ponad 180 znanych podatności, wiele obrazów nie było aktualizowanych od kilkuset dni, a podatności z obrazów bazowych „przechodzą w spadku” na obrazy potomne. Innymi słowy, jeżeli używamy starego, dawno nieodświeżanego base image, prawdopodobnie mamy w nim historyczne „dziury” czekające na exploita. Co gorsza, choćby aktualizując do najnowszych wydań, nie wyeliminujemy ryzyka w 100%. Naukowcy zauważyli, iż nie istnieje wydanie kontenera całkowicie pozbawione podatności – choćby mając najświeższe wersje pakietów w środku, zawsze znajdzie się jakaś luka. Dlatego celem nie jest utopia “zero CVE”, ale minimalizacja ryzyka: ograniczanie liczby podatnych komponentów i szybkie usuwanie krytycznych dziur.
Wybór dystrybucji: Debian vs Alpine vs Ubuntu vs Distroless vs Scratch
Przyjrzyjmy się krótko popularnym opcjom systemów bazowych i ich profilowi bezpieczeństwa:
- Debian/Ubuntu: Klasyka w świecie Docker – obrazy bazowe tych dystrybucji są pełne (zawierają apt, powłokę bash, standardowe biblioteki glibc). Zaletą jest wygoda (łatwo doinstalować pakiety, dostępne są wszystkie typowe narzędzia) oraz kompatybilność (wiele aplikacji jest testowanych na glibc). Wadą jest rozmiar (setki MB) i liczba potencjalnych podatności. W badaniu Zerouali et al. kontenery oparte na Debianie miały średnio około 460 podatności na obraz, podczas gdy średnia dla wszystkich analizowanych obrazów wynosiła ~120. Oczywiście większa liczba luk wynika z większej liczby pakietów – trudno porównywać wprost Debian do Alpine bez uwzględnienia różnicy w rozmiarze i funkcjonalności. Atutem Debiana/Ubuntu jest jednak długoterminowe wsparcie i backportowanie poprawek bezpieczeństwa. Uwaga: skanery luk czasem zgłaszają CVE w Debianie, mimo iż deweloperzy załatali je nie podnosząc wersji (backport patch). Może to generować false positive, gdy narzędzie opiera się wyłącznie na numerze wersji pakietu nie znając kontekstu poprawek.
- Alpine Linux: Ultralekka dystrybucja Linuksa zaprojektowana z myślą o bezpieczeństwie i małym zużyciu zasobów. Alpine używa alternatywnej libc (musl) i BusyBox, przez co obraz alpine:latest to zaledwie kilka megabajtów. Mniej pakietów to mniejsze ryzyko – często Alpine wychodzi „czyściej” w skanach bezpieczeństwa niż Debian. Dla przykładu, zacytowane wcześniej badania wskazywały średnio 180+ luk w obrazach, a Alpine bywa na przeciwległym biegunie z niewielką liczbą CVE. Jednak Alpine ma swoje pułapki: musl nie jest w 100% zgodny z glibc, co potrafi powodować błędy runtime w niektórych aplikacjach (czyli problemy z kompatybilnością). Ponadto zdarzały się krytyczne błędy w musl – np. CVE-2019-14697 (błąd w ld-musl) – które dotykały wszystkich obrazów Alpine naraz. Mimo to Alpine pozostaje synonimem minimalizmu i bywa pierwszym wyborem dla obrazów cloud-native. Bezpieczeństwo: bardzo dobry stosunek bezpieczeństwa do rozmiaru, ale wymaga większej uwagi przy debugowaniu i testowaniu kompatybilności.
- Distroless: To nie nazwa dystrybucji, a filozofia obrazów (promowana m.in. przez Google). Obraz distroless opiera się np. na Debianie, ale podczas budowy usuwa wszystkie „niepotrzebne” elementy: powłoki, menedżery pakietów, edytory – zostawiając tylko to, co wymagane do odpalenia aplikacji (np. jeżeli to aplikacja Java na JVM, to zostawia minimalne środowisko Java i parę bibliotek). Efekt? Bardzo mały obraz i brak dostępu do środka (nie da się zalogować do kontenera tradycyjnym docker exec sh, bo… nie ma powłoki!). Plusy: minimalna powierzchnia ataku – choćby jeżeli ktoś uzyska dostęp, nie ma czym wiele zwojować, brak narzędzi do eskalacji. Minusy: trudniejsze buildy (często multi-stage Dockerfile, żeby zbudować aplikację w normalnym środowisku, a potem do finalnego obrazu skopiować tylko binaria), brak możliwości łatwej inspekcji kontenera podczas pracy. Distroless są świetne dla finalnych, produkcyjnych artefaktów, ale wymagają dojrzałego pipeline CI/CD i monitoringu, bo gdy coś pójdzie nie tak, nie podepniemy się tam z SSH by sprawdzić logi (musimy polegać na mechanizmach logowania kontenera na zewnątrz).
- Scratch: Najbardziej skrajny przypadek – FROM scratch oznacza brak jakiegokolwiek systemu bazowego. Obraz tworzony “od zera” zwykle poprzez skopiowanie do niego jednej skompilowanej statycznie binarki naszej aplikacji. Tak buduje się np. minimalistyczne kontenery w Go lub C/C++ (gdzie można skompilować statycznie). Obraz scratch nie zawiera nic, choćby bibliotek C – wszystko musi być w binarce. Z punktu widzenia bezpieczeństwa to ideał: zero zbędnego kodu, zero znanych podatności, bo pusto. Z punktu widzenia praktyki – tylko nieliczne aplikacje da się tak uruchomić, brak choćby podstawowych plików konfiguracyjnych czy stref czasowych. Często jednak warto rozważyć podejście zbliżone do scratch: np. kompilacja statyczna i kopiowanie tylko potrzebnych plików do obrazu wynikowego, zamiast taszczyć cały OS.
TL;DR: Wybór dystrybucji bazowej ma ogromne znaczenie. Debian/Ubuntu zapewnią wygodę kosztem większej liczby CVE. Alpine da minimalny rozmiar i mniej luk, ale musisz uważać na zgodność. Distroless/Scratch – maksimum bezpieczeństwa pasywnego, ale wymagają dojrzałych procesów wokół. Zawsze wybieraj świadomie, analizując potrzeby aplikacji i ryzyko.
Wykrywanie podatności w obrazach: narzędzia i metody
Skoro wiemy, iż nasze obrazy bazowe mogą mieć ukryte podatności, najważniejsze jest ich skanowanie. manualne przeglądanie listy pakietów i sprawdzanie CVE w NVD byłoby absurdalnie czasochłonne (MITRE i NVD notują już ponad 250 tysięcy znanych CVE). Na szczęście mamy całą gamę skanerów container security, które zrobią to za nas. Jak one działają? Najczęściej statycznie – analizują obraz bez jego uruchamiania, wypisują listę zainstalowanych pakietów (systemowych i czasem aplikacyjnych) i porównują ich wersje z bazami podatności (np. z NVD, dystrybucyjnymi security advisory, danych z MITRE itp.). Wynikiem jest raport CVE z podziałem na poziomy ważności (niski/średni/wysoki/krytyczny) i często rekomendacją aktualizacji. Popularne narzędzia skanujące kontenery (open-source): Trivy, Grype/Anchore, Clair; komercyjne: Snyk Container, JFrog Xray, Aqua CSP i inne. Warto znać ich specyfikę:
- Trivy (Aqua Security) – chyba najłatwiejszy w użyciu skaner, do jednej binarki. Integruje się z CI/CD, potrafi skanować zarówno OS-level (apt, apk, yum) jak i zależności aplikacji (języki: Python pip, Node.js npm, Java Maven itd.), a choćby wykrywać niezamierzone sekrety w kodzie. Uchodzi za szybki – korzysta z lokalnej bazy podatności synchronizowanej w tle, dzięki czemu kolejne skany są bardzo sprawne. W testach Mendonsa okazał się nieco szybszy od konkurencyjnego Grype. Trivy generuje raporty w różnych formatach (tabularny, JSON, SARIF) i może służyć też do tworzenia SBOM (Software Bill of Materials), czyli spisu komponentów systemu w obrazie. To przydatne, gdy chcemy np. zgodności z wymogami prawa (SBOMy stają się standardem w kontekście zabezpieczania łańcucha dostaw oprogramowania).
- Grype (Anchore) – to skaner od Anchore, będący następcą cięższego Anchore Engine. Grype działa podobnie do Trivy – używa bazy CVE (Feed Anchore, synchronizowany offline) i zaczyna od zidentyfikowania pakietów w obrazie. Co ciekawe, Grype korzysta z narzędzia Syft do generowania SBOM, a następnie dopiero porównuje SBOM z bazą podatności. W efekcie również potrafi wykryć luki w bibliotekach aplikacyjnych (np. w plikach .jar, .js, .dll itp.), choć konfiguracja może wymagać dodania odpowiednich analizatorów. W niektórych porównaniach Grype wykrywał unikalne podatności pominięte przez inne skanery i vice-versa – wskazówka, iż czasem warto skanować kilkoma narzędziami, by niczego nie przegapić.
- Clair (Quay/Red Hat) – jeden z pierwszych skanerów kontenerowych, stworzony przez CoreOS. Clair działa jako usługa (serwer skanujący) i często jest używany w rejestrach obrazów (np. Quay.io, Harbor). Skupia się głównie na pakietach systemowych (OS packages) i wymaga integracji – sam z siebie nie skanuje plików aplikacji. Bywa chwalony za dokładność i mało false positives, ale trudniejszy w integracji bezpośrednio w CI (często woli się prostotę Trivy). W kontekście np. Kubernetesowego skanowania obrazów w klastrze Clair może działać jako centralny skaner podłączony do registry.
- Snyk Container – narzędzie komercyjne (SaaS) od firmy Snyk, mocno nastawione na developerów. Snyk wyróżnia się tym, iż poza wykryciem podatności oferuje też porady jak je naprawić (np. „przejdź na wersję X pakietu Y”), ma rozbudowane polityki (można ustawić reguły typu „blokuj build, jeżeli jest CVE krytyczne, ale ignoruj CVE niskie”) oraz integracje z repozytoriami kodu. W darmowym planie skanuje obrazy publiczne i ma ograniczenia, ale w organizacjach bywa lubiany za dobre raporty i integrację z IDE/CI. W badaniach Snyk czasem wykazywał niższą wykrywalność unikalnych podatności niż Trivy/Clair – może dlatego, iż skupia się na praktycznie istotnych lukach, ograniczając szum informacyjny.
- JFrog Xray – część ekosystemu JFrog (Artifactory). To także komercyjne narzędzie integrujące skanowanie kontenerów z zarządzaniem artefaktami. Ciekawostką jest, iż Xray w jednym z badań na obrazach ARM początkowo znajdował nieco mniej luk niż Trivy/Clair, ale w nowszych danych z 2020 potrafił je przegonić. JFrog Xray nie działa jako samodzielna binarka – obraz do skanowania trzeba wgrać do ich platformy lub użyć ich API. Zaletą jest bogata baza danych (JFrog korzysta z wielu źródeł, także monitów o lukach w zależnościach programistycznych). Wadą – konieczność posiadania Artifactory i pewne opóźnienie (scan jako usługa). W kontekście ARM warto wspomnieć, iż narzędzia takie jak Clair czy Xray nie mają natywnego wsparcia dla architektury ARM (trzeba obrazy ARM skanować na maszynach x86, co jest możliwe, bo skaner analizuje warstwy statycznie).
Jak widać, każde narzędzie ma swoje plusy i minusy. Żadne nie jest doskonałe i każde może coś przegapić. W badaniu Haqa et al. skanując oficjalne obrazy ARM czterema narzędziami (Trivy, Clair, Snyk, JFrog) okazało się, iż żaden pojedynczy skaner nie pokrył choćby 80% wszystkich wykrytych luk – każdy znalazł trochę unikalnych podatności. Ba, wcześniejsze analizy sugerują, iż różne skanery „specjalizują się” w różnych obrazach bazowych – np. dla obrazów Debian najlepszą wykrywalność miał Trivy, dla Alpine dobrze radził sobie Anchore (Grype) i Trivy, a dla Ubuntu – Anchore i Clair były na czele. To ważna wskazówka: nie polegajmy ślepo na jednym skanerze. Idealnie integrujemy jeden główny (np. Trivy w CI), ale od czasu do czasu warto przepuścić obrazy przez inny silnik lub zewnętrzny skaner (np. przed wydaniem do produkcji) dla pewności.
Integracja skanowania w proces CI/CD
Narzędzia to jedno, ale równie ważne jest kiedy i gdzie skanujemy. Najlepszą praktyką DevSecOps jest „shift-left”, czyli przesunięcie kontroli bezpieczeństwa jak najwcześniej – zanim obraz trafi do rejestru czy na produkcję. W praktyce pipeline CI/CD powinien mieć etap „Scan Image” tuż po zbudowaniu kontenera. jeżeli skaner wykryje krytyczne podatności, pipeline failuje – dzięki temu żadna podatność o randze np. HIGH/CRITICAL nie przejdzie dalej, dopóki deweloper jej nie usunie. Takie podejście przyjęto np. w pewnym globalnym banku, który zintegrował Trivy z Jenkinsem: rezultaty były świetne – liczba luk wysokiego ryzyka spadła dramatycznie, bo deweloperzy od razu korygowali obrazy, a czas naprawy krytycznych CVE skrócił się o 60%. Skanery można też podłączyć do GitHub Actions, GitLab CI, Azure Pipelines – praktycznie każde narzędzie CI ma gotowe wtyczki lub można odpalić skan poprzez skrypt w kontenerze.
Drugim miejscem integracji jest registry (rejestr obrazów). Większość chmur i platform ma dziś skanery wbudowane: Docker Hub skanuje obrazy dzięki Scanning Service (dawny Nautilus, w tej chwili dla prywatnych repo), Amazon ECR ma własny skaner (bazujący na Clair), Google Artifact Registry skanuje w oparciu o Grafeas, Harbor ma integrację z Trivy/Clair itd. Włączenie tych skanów to dodatkowa warstwa kontroli. Co więcej, niektóre rozwiązania wspierają continuous scanning – jeżeli pojawi się nowa podatność w bazie, to rejestr ponownie przeskanuje już istniejące obrazy i powiadomi, iż obraz kiedyś uznany za bezpieczny dziś ma np. 5 znanych CVE. To istotne, bo bezpieczeństwo kontenera to ruchomy cel: nowe podatności wychodzą codziennie. Automatyczne re-scan’y chronią nas przed sytuacją „mamy obraz sprzed roku, wtedy był czysty, ale od tego czasu znaleziono dziurę w OpenSSL i nikt o tym nie wie”. Warto więc utrzymywać obrazy w skanowanym rejestrze oraz regularnie przebudowywać i aktualizować bazy.
Wyniki skanowania – czytać, analizować, reagować
Uruchomienie skanera to dopiero początek pracy. Raport z kilkudziesięcioma CVE może przerazić początkujących. Pojawia się pytanie: czy ja muszę to wszystko załatać?. Tu warto wprowadzić polityki i priorytety. Najczęściej organizacje ustalają, iż podatności krytyczne (Critical) i wysokie (High) muszą być naprawione przed wydaniem do produkcji, średnie – w najbliższym cyklu, niskie – do rozważenia. Dobrą praktyką jest przygotowanie baseline – np. skan obrazu bazowego, zanim dodamy nasz kod. jeżeli np. bazowy Debian już ma 50 znanych CVE niskich w różnych pakietach, to możemy albo przejść na nowszy tag, albo zaakceptować pewien poziom ryzyka (wiedząc np., iż te komponenty i tak nie są używane w naszej aplikacji). W narzędziach typu Snyk można oznaczać wyjątki (false positive lub akceptacja ryzyka), by kolejne skany nie blokowały pipeline na znanej sprawie. Ważne, by decyzje te podejmować świadomie i udokumentować (np. „ignorujemy CVE-2020-1234 bo biblioteka X jest obecna ale nieużywana, a fix wymagałby zmiany bazy na nowszą niekompatybilną wersję – planowana migracja za 3 miesiące”).
Niestety, skanery czasem generują false positives. Przykładowo, wspomniane wcześniej backporty bezpieczeństwa w Debianie – narzędzie widzi starą wersję OpenSSL, alarmuje CVE, a faktycznie dziura jest załatana. Albo skanujemy obraz Javy i dostajemy listę 30 CVE w bibliotekach, z których nasza aplikacja używa może 5 – część luk może w praktyce nie być podatna na exploit (bo dana funkcja nie jest wywoływana). Trzeba więc łączyć automatyzację z wiedzą inżynierską. Rolą DevSecOps jest edukowanie zespołów, żeby nie ignorowały raportów skanowania, ale też żeby rozumiały ich kontekst. Czasem programista widząc 900 luk w raporcie może wpaść w panikę albo… całkowicie zlekceważyć skanowanie jako „bezużyteczne” – obie skrajności są groźne. Tutaj pomocne są spotkania, przeglądy bezpieczeństwa, gdzie omawia się wyniki i wspólnie decyduje co robimy z danym znaleziskiem. Pamiętajmy: skaner nie zastąpi myślenia. Jest narzędziem, które ma ułatwić wykrycie znanych zagrożeń, ale to my musimy podjąć działania korygujące.
Dynamika podatności i aktualizacje obrazów
Jak już wspomniano, bezpieczeństwo obrazów to proces ciągły, a nie jednorazowy skan. Podatności są odkrywane każdego dnia – dzisiejszy bezpieczny obraz jutro może okazać się dziurawy. Dlatego ważne jest śledzenie dynamiki podatności i częste aktualizacje.
W praktyce oznacza to:
- Ciągłe skanowanie – automatyczne re-skanowanie obrazów przy aktualizacji bazy CVE. jeżeli korzystamy z CI/CD, warto ustawić cykliczne pipeline’y skanujące obrazy już wdrożone w środowisku (np. co tydzień). Niektóre platformy (np. Harbor, Anchore Enterprise) same monitorują nowe CVE i odświeżają wyniki skanów. Badania wskazują, iż takie podejście ujawnia podatności, które “doszły” do obrazu po czasie – stare obrazy kryjące się w zakamarkach rejestru nagle okazują się mieć krytyczne błędy, gdy CVE wypłynie po kilku miesiącach.
- Regularne przebudowy – choćby jeżeli nasza aplikacja się nie zmienia, warto przebudować obraz bazowy co pewien czas (np. co miesiąc), aby pobrać najnowsze łatki systemu bazowego. Wiele oficjalnych obrazów jest aktualizowanych często (np. obrazy Alpine co kilka tygodni, Debian wydaje co miesiąc tzw. point release z update’ami bezpieczeństwa). jeżeli trzymamy się starego shota, narastają na nim „warstwy kurzu” w postaci CVE. Zespół Zerouali zauważył silną korelację: obrazy z mniejszym lagiem aktualizacji (czyli mniej „przestarzałe” pakiety) mają mniej podatności. To dość oczywiste – jeżeli dbamy o update, od razu łapiemy poprawki bezpieczeństwa i statystyka się poprawia. Innymi słowy: aktualizacje działają – ale trzeba je robić świadomie i często.
- Monitorowanie wydanych CVE – warto subskrybować powiadomienia o nowych podatnościach szczególnie związanych z używanymi przez nas obrazami bazowymi. Przykładowo, jeżeli korzystamy z Alpine, obserwujmy listę mailingową Alpine Security. jeżeli używamy OpenJDK, śledźmy biuletyny Oracle/OpenJDK. Gdy wychodzi głośna luka (typu Heartbleed, Shellshock, Log4Shell), natychmiast sprawdźmy, czy nasz obraz bazowy jej nie zawiera. Czasem CVE w popularnej bibliotece (np. OpenSSL) dotyka setek obrazów – od Debiana po Alpine – i wtedy krytycznie ważne jest szybkie zbudowanie nowych wersji kontenerów.
- Minimalizacja czasu życia kontenera – filozofia immutable infrastructure sugeruje, by zamiast łatać działający kontener, po prostu zastępować go nowym, zaktualizowanym obrazem. Kontenery powinny być traktowane jak przemijające, zastępowalne instancje. Dzięki temu redukujemy ryzyko, iż gdzieś tam w kącie datacenter hula sobie zapomniany kontener sprzed dwóch lat z mnóstwem dziur. Orkiestratory jak Kubernetes pomagają w tym, umożliwiając automatyczne rollouty nowych obrazów.
Podsumowując, utrzymanie bezpieczeństwa to proces. Jak ładnie ujął to jeden z raportów, “nowe CVE pojawiają się codziennie, więc regularne skany i aktualizacje zapobiegają temu, by stare obrazy kryły w sobie nowo odkryte zagrożenia”. Nie spoczywajmy na laurach po jednym skanie czy pierwszym wydaniu aplikacji. Security to ciągła walka z czasem – my kontra osoby odkrywające (lub wykorzystujące) luki.
Konkretne zagrożenia w środowisku kontenerowym
Przyjrzyjmy się teraz kilku konkretnym zagrożeniom, na które narażone są kontenery w zależności od tego, jak zbudowany jest obraz bazowy i jak zabezpieczymy uruchomienie.
- Escape / Container Breakout – to najgroźniejszy scenariusz: proces wewnątrz kontenera ucieka z izolacji i uzyskuje dostęp do hosta (np. do jądra lub systemu plików gospodarza). Takie ataki są możliwe głównie przez podatności na poziomie kontenerowego runtime (Docker, runc) lub jądra Linux. Przykładem jest CVE-2019-5736 (błąd w runc), który pozwalał z kontenera przejąć kontrolę nad hostem przy wykonaniu odpowiednio spreparowanego polecenia docker exec. Wcześniejsze wersje Dockera również miały luki tego rodzaju – np. CVE-2015-3627, gdzie błąd w mechanizmie mount namespace umożliwiał kodowi w kontenerze wydostanie się i wykonanie z eskalacją uprawnień na hoście. Co prawda takie błędy są stosunkowo rzadkie, ale jeśli ktoś je wykorzysta, skutek jest katastrofalny: przejęcie całej maszyny hosta (a więc i wszystkich kontenerów na nim). Jak się bronić? Aktualizować Dockera/runc na bieżąco – te podatności są łatane dość szybko. Po drugie, włączyć mechanizmy bezpieczeństwa jądra: AppArmor/SELinux oraz seccomp. Docker domyślnie stosuje profil AppArmor (na dystrybucjach które go używają, np. Ubuntu) i profil seccomp ograniczający najbardziej niebezpieczne wywołania systemowe. To nie jest srebrny pocisk, ale potrafi zablokować wiele prymitywów eskalacji. Dobrym zwyczajem jest też uruchamianie kontenerów jako użytkownik nieuprzywilejowany (unikanie USER root w Dockerfile). Wtedy choćby jeżeli ucieknie, atakujący nie od razu ma root na hoście – ma co najwyżej uprawnienia zwykłego użytkownika, co może dać szansę na reakcję obronną.
- Privilege escalation w kontenerze – tu mowa o sytuacji, gdy atakujący uzyska dostęp do aplikacji w środku kontenera (np. włamie się przez podatność aplikacji web) i następnie eskaluje swoje uprawnienia wewnątrz kontenera (np. z usera aplikacji na roota kontenera). Ponieważ wiele obrazów bazowych domyślnie działa jako root, często nie ma choćby potrzeby eskalować – atakujący od razu jest superuserem w środku. W starych obrazach Dockera bywały też podatności, które pozwalały np. na dostęp do plików systemowych kontenera wskutek złych uprawnień (vide CVE-2014-3630 – zbyt słabe restrykcje /proc w Docker 1.6 dawały dostęp do wrażliwych informacji). Wniosek: traktuj kontener jak potencjalnie zainfekowany system – nie zostawiaj w nim zbędnych narzędzi (np. kompilatorów, które ułatwią atakującemu budowę złośliwego kodu), nie trzymaj plików konfiguracyjnych z hasłami w world-readable, ustaw poprawnie użytkownika. Użycie minimalnych obrazów również tu pomaga – brak powłoki czy menedżera pakietów utrudni wykonanie niektórych typowych kroków po przejęciu (np. intruz nie zainstaluje sobie łatwo dodatkowego systemu typu rootkit).
- Wycieki sekretów (tajne klucze w obrazach) – zaskakująco częsty i bardzo niebezpieczny problem. Obraz kontenera to po prostu system plików – może zawierać pliki .env z hasłami, klucze prywatne, certyfikaty, tokeny API. Czasem deweloper przez nieuwagę COPYuje do obrazu plik konfiguracyjny z sekretami i publikując taki obraz np. na Docker Hub, adekwatnie ujawnia światu swoje klucze. Markus Dahlmanns i in. przeprowadzili skan 337 tysięcy obrazów publicznych i odkryli, iż aż 8,5% z nich zawierało w środku sekrety – znaleziono ponad 52 tysiące kluczy prywatnych i ponad 3 tysiące tokenów API!. To zatrważające statystyki. Co gorsza, wiele z tych kluczy faktycznie jest wykorzystywanych – badacze znaleźli setki serwerów SSH i TLS w internecie posługujących się wyciekłymi kluczami. Znaczy to, iż nie tylko twórcy obrazów popełnili błąd, ale też użytkownicy tych obrazów nie zdawali sobie sprawy, iż uruchamiają kontener z cudzym (i już skompromitowanym) kluczem. Obrona: Nigdy nie wrzucaj sekretów do obrazu. Do konfiguracji służą mechanizmy typu Docker secrets, zmienne środowiskowe ustawiane przy uruchomieniu kontenera, woluminy zewnętrzne itp. jeżeli już musimy zbudować obraz zawierający np. certyfikat, to niech będzie to cert publiczny, a klucz niech dołącza operator na etapie deploymentu. Warto też skanować obrazy pod kątem sekretów – Trivy i inne narzędzia mają tryby secret scan (wyszukują wzorce kluczy AWS, tokenów, kluczy SSH). Wspomniane badanie Dahlmannsa sugeruje choćby narzędzia do masowego wykrywania takich wycieków i ostrzegania autorów, zanim klucz zostanie wykorzystany przez atakujących.
- Ataki na łańcuch dostaw obrazu – czyli sytuacje, gdy ktoś celowo umieszcza złośliwy kod w obrazie bazowym lub wykorzystuje nasz zaufany base image jako wektor ataku. Historia z Docker Hub zna przypadki, gdy w publicznym rejestrze pojawiały się obrazy imitujące popularne (np. nazywające się podobnie do oficjalnych), które zawierały malware. W 2018 wykryto obrazy, które potajemnie kopały kryptowaluty – zanim je zdjęto, pobrano je 5 milionów razy i wykorzystywano do wydobycia Monero. Podobnie w 2020 Palo Alto Unit 42 opisało konto “azurenql” z DockerHuba, gdzie kilka obrazów zainfekowano minerem XMRig – ściągnięto je 2 mln razy. To pokazuje, iż nie tylko znane CVE są zagrożeniem – czasem celowo pozostawione backdoory lub malware mogą czaić się w obrazach. Rozwiązania? Używaj wyłącznie zaufanych źródeł obrazów bazowych. Oficjalne obrazy (oznaczone na Docker Hub jako “Official” lub dostarczane przez wiarygodnych dostawców) są generalnie bezpieczniejsze – Docker je weryfikuje, mają cyfrowe podpisy. Unikajmy losowych obrazów od nieznanych użytkowników. Dodatkowo, stosujmy podpisywanie obrazów we własnym zakresie. Docker Content Trust (Notary v1) czy nowsze mechanizmy jak Sigstore Cosign lub świeży pomysł Docker OpenPubKey pozwalają podpisać obraz kluczem prywatnym i zweryfikować na etapie uruchamiania, iż pochodzi od nas i nie został podmieniony. W 2023 Docker wprowadził wspomniany OpenPubKey, który integruje się z OIDC – rozwiązując część problemów z zarządzaniem kluczami do podpisu obrazów. Wszystko to zmierza do jednego celu: zagwarantować integralność obrazu od momentu zbudowania do momentu uruchomienia na produkcji. W dobie rosnącej liczby ataków supply-chain (patrz głośny SolarWinds czy przypadki malware w pakietach npm) – kontenery również muszą być objęte taką ochroną.
- Błędy konfiguracji i inne zagrożenia – na koniec warto wspomnieć, iż bezpieczeństwo kontenera to nie tylko CVE i malware. Często prosty błąd, jak uruchomienie kontenera z przywilejami (--privileged) lub podłączenie go do hostowej sieci, może otworzyć furtkę do eskalacji. Również pozostawienie wrażliwych danych w warstwach obrazu jest ryzykiem – np. kiedyś dodaliśmy do Dockerfile hasło w RUN (nawet jeżeli potem skasowane, może tkwić w historii warstw). Narzędzia typu docker history pozwalają podejrzeć każdą warstwę. Dlatego ważne jest przestrzeganie zasad tworzenia Dockerfile (można użyć lintera np. Hadolint do wychwycenia anty-wzorców) i skanowanie nie tylko pod kątem CVE, ale i konfiguracji. Skanery często wykrywają takie rzeczy jak uruchamianie procesu jako root, brak ustawionego HEALTHCHECK, użycie starej wersji Docker API itd., które same w sobie podatnością nie są, ale zwiększają ryzyko ataku lub utrudniają monitoring. W myśl zasady “Secure by default” starajmy się, by obraz kontenera już od bazowego systemu miał sensowne domyślne zabezpieczenia (np. użytkownika nie-root, tylko wymagane porty otwarte, itp.).
Najlepsze praktyki tworzenia bezpiecznych obrazów Docker
Pora zebrać wszystko razem. Jak budować i utrzymywać bezpieczne obrazy? Poniżej lista dobrych praktyk – traktuj ją jako checklistę przy następnym projekcie:
- Wybierz odpowiedni base image: Najpierw zastanów się, czego potrzebuje twoja aplikacja. jeżeli to prosta usługa napisana w Go – może wystarczy scratch albo distroless, co drastycznie zredukuje powierzchnię ataku. jeżeli aplikacja wymaga wielu zależności systemowych, rozważ Alpine. Gdy potrzebujesz pełnego środowiska – weź Debian Slim lub Ubuntu i usuń z niego, co zbędne. Zawsze preferuj najmniejszy możliwy obraz bazowy, spełniający wymagania aplikacji. Im mniej pakietów, tym mniej potencjalnych dziur.
- Używaj zaufanych źródeł: Pobieraj obrazy bazowe tylko z oficjalnych lub zweryfikowanych repozytoriów. Unikaj niesprawdzonych obrazów od nieznanych użytkowników. Sprawdzaj digest SHA256 obrazu, aby mieć pewność co do wersji (tag latest potrafi zmienić znaczenie!). jeżeli to możliwe, włącz Docker Content Trust i/lub używaj podpisanych obrazów – unikniesz podmiany przez atakującego.
- Aktualizuj bazę na etapie build: Gdy korzystasz z pełnych dystrybucji, zawsze w Dockerfile wykonuj apt-get update && apt-get upgrade (lub odpowiednik) zaraz po zdefiniowaniu base image. Wiele oficjalnych Dockerfile tak robi. Dzięki temu choćby jeżeli base image był wydany np. miesiąc temu, to doinstalujesz najnowsze łatki na moment budowania obrazu. Pamiętaj tylko, by czyścić cache menedżera pakietów (apt clean) by nie nadymać warstw.
- Usuń zbędne pliki i pakiety: Po zainstalowaniu zależności, oczyść obraz. Usuń kompilatory (g++, make), dokumentację, cache pakietów. jeżeli używasz multi-stage build, w finalnym obrazie umieść tylko binarki i potrzebne pliki. Każdy dodatkowy megabajt to potencjalne nowe CVE do pilnowania. Przykład: o ile instalujesz Node.js tylko po to, by zbudować front-end, to uruchamiaj build Node w kontenerze builder, a do finalnego obrazu kopiuj wyłącznie statyczne pliki wynikowe.
- Nie uruchamiaj jako root: To zasada numer jeden izolacji. Dodaj w Dockerfile użytkownika aplikacyjnego (RUN adduser ... lub użyj wbudowanego, np. node w oficjalnych obrazach Node). Potem USER appuser. Dzięki temu choćby jak ktoś włamie się do kontenera, nie będzie miał od razu root. W Alpine zwróć uwagę, iż domyślnie nie ma użytkownika – trzeba go dodać manualnie. W Debianie/Ubuntu wiele oficjalnych obrazów przez cały czas działa jako root by default – zmień to, jeżeli to możliwe.
- Konfiguruj minimalne uprawnienia runtime: To już poza samym obrazem, ale warte checklisty: uruchamiając kontener ustawiaj polityki bezpieczeństwa. Np. docker run --read-only (system plików tylko do odczytu, jeżeli aplikacja nie musi nic zapisywać), --cap-drop=ALL (odbierz kontenerowi wszystkie linuksowe capabilities, zostaw tylko te niezbędne), nie używaj --privileged ani --network=host bez konkretnej potrzeby. Kubernetes ma do tego SecurityContext (gdzie możesz wymusić brak roota, ograniczenia cap, seccomp, AppArmor itp.). Bazowy obraz może również zawierać własne mechanizmy – np. Red Hat UBI ma SELinux relabeling, by działać w OpenShift. jeżeli wiesz, iż Twój kontener będzie używany w tak secure środowisku, upewnij się, iż base image to wspiera.
- Skanuj obrazy na każdym kroku: Włącz skanowanie w pipeline CI (np. Trivy lub Snyk akcją), skanuj obrazy w registry i przed deploymentem. Raporty traktuj poważnie: naprawiaj krytyczne i wysokie luki od razu. jeżeli np. CVE dotyczy pakietu w base image – zaktualizuj base image do nowszego tagu. Ustalcie w zespole, jakie progi akceptujecie, a jakie blokują wydanie. Monitorujcie nowe podatności – obraz, który dziś przeszedł, jutro może zostać oflagowany przez skaner ciągły.
- Odporność na false-positive: o ile skaner zgłasza lukę, która Was nie dotyczy (np. biblioteka jest niewykorzystana), nie usuwajcie jej alertu przez całkowite wyłączenie skanu! Zamiast tego oznaczcie ją jako zignorowaną z uzasadnieniem. Dobre narzędzia pozwalają na policy as code – np. plik konfiguracyjny z listą dozwolonych wyjątków CVE (z datą i komentarzem kto zatwierdził). Dzięki temu budujecie wiedzę i nie powtarzacie analizy tej samej fałszywej podatności przy każdym skanie.
- Monitoruj wydajność i rozmiar: Bezpieczeństwo to nie wszystko – nie zapędźmy się w minimalizację kosztem wydajności. Przykładowo, aplikacje Python odpalone na Alpine (musl) mogą działać wolniej niż na Debianie (glibc) – trzeba to zbadać. Prowadźcie testy wydajnościowe i monitorujcie wykorzystanie zasobów. Czasem większy obraz może być uzasadniony, jeżeli minimalny powoduje problemy (to częsty dylemat: Alpine vs Debian-slim – Alpine miewa problemy z niektórymi bibliotekami numpy, pandas itd.). Z kolei mniejszy obraz może poprawić czas uruchomienia kontenera i mniej obciążać sieć przy pobieraniu – to też element bezpieczeństwa (mniejsza szansa, iż ktoś wstrzyknie złośliwy kod w transit, oraz krótsze okno podatności na atak typu „typosquatting image” podczas pobierania).
- Twórz SBOM i śledź zależności: Wraz z obrazem generuj SBOM (np. trivy sbom -f spdx-json -o obraz.sbom image:tag). Przechowuj te SBOM-y – pozwolą Ci gwałtownie odpowiedzieć na pytanie „czy w naszych obrazach była podatna biblioteka X?”. Coraz częściej pojawiają się automatyczne alerty typu „znaleziono krytyczną lukę w Log4j – które obrazy to mają?”. Mając SBOM (np. w formacie SPDX), możesz przeszukać je skryptem zamiast manualnie skanować wszystko od nowa. SBOM jest także coraz częściej wymagany regulacyjnie.
- Testuj bezpieczeństwo kontenera: Można pokusić się o dynamiczną analizę – uruchomić kontener w kontrolowanym środowisku i spróbować go „zhackować”. Są narzędzia, które monitorują runtime (np. Aqua Microscanner dynamiczny, czy open-source Falco do wykrywania podejrzanych zachowań w kontenerach). W przytoczonym wcześniej badaniu ARM, autorzy odpalali exploit znanych podatności wewnątrz kontenerów na Raspberry Pi, by zobaczyć czy np. mechanizmy DEP (Data Execution Prevention) powstrzymają atak. Tego typu testy mogą brzmieć zaawansowanie, ale dla krytycznych aplikacji warto rozważyć pentest kontenera – sprawdzenie czy np. od środka nie da się wykraść poświadczeń AWS (które bywają dostępne przez zmienne env w kontenerze) albo czy przy wywołaniu określonych syscalów nie uda się obejść seccomp.
Ta lista nie jest zamknięta – bezpieczeństwo to temat rzeka. Ale już stosując powyższe punkty, znacząco podniesiesz odporność swoich obrazów i kontenerów.
Dlaczego to ma znaczenie?
Dlaczego to wszystko jest ważne? Bo kontenery są dziś fundamentem nowoczesnego IT – od aplikacji webowych, przez IoT, po machine learning w chmurze. Ich lekkość i przenośność sprawiła, iż odpalamy tysiące kontenerów, często traktując je jako efemeryczne „jednostki obliczeń”. Ale lekceważąc bezpieczeństwo tych kontenerów, otwieramy tysiące potencjalnych wejść dla atakującego. Pamiętajmy, iż kontener to tylko izolowany proces na współdzielonym jądrze. Jeden błąd – i izolacja znika jak bańka mydlana, a atak przenosi się na hosta lub inne kontenery. Co z tego, iż nasz kod aplikacyjny jest bezpieczny, jeżeli pakujemy go do podatnego systemu bazowego?
Analiza systemów bazowych uczy nas, iż bezpieczeństwo aplikacji jest tak silne, jak najsłabszy komponent w jej obrazie. Może to być dziurawa biblioteka sprzed lat, zapomniany pakiet lub klucz SSH zostawiony w warstwie obrazu. Kontenery pozwalają łatwo dzielić się aplikacjami – ale to dzielenie się dotyczy też błędów i podatności, często na masową skalę. Wyobraźmy sobie, iż łącznie miliony kontenerów działających w chmurach zawierają tę samą lukę OpenSSL – to jak bomba zegarowa.
Z perspektywy organizacji, wdrożenie skanowania obrazów i dobrych praktyk (jak minimalne obrazy, aktualizacje, podpisy) jest częścią większej strategii Security DevOps. Budujemy kulturę, gdzie bezpieczeństwo jest wplecione w cykl wytwarzania systemu – od pierwszej linijki Dockerfile, przez pipeline CI, po monitoring na produkcji. Ma to realne przełożenie na redukcję incydentów. Case study z sektora medycznego pokazało, iż enforce’owanie polityki „żadnych krytycznych luk” i migracja na minimalne obrazy zmniejszyły rozmiary obrazów o 30% i wyeliminowały krytyczne podatności w produkcji do zera. To namacalny dowód, iż warto.
Na koniec – edukacyjnie – zagadnienia poruszone w tym artykule uczą przyszłych inżynierów, iż kontenery to nie czarna skrzynka, którą można się nie przejmować. Wręcz przeciwnie: trzeba rozumieć co jest w środku obrazu, z czego on się składa (stąd idea SBOM), i jak poskromić ryzyko zanim oprogramowanie trafi do chmury. Bez tej świadomości DevOps może narobić długów bezpieczeństwa, które prędzej czy później spłacimy po ataku lub audycie. Lepiej zapobiegać niż leczyć – a dzięki narzędziom i praktykom DevSecOps możemy to robić efektywnie, automatycznie i bez spowalniania developmentu. Bezpieczeństwo kontenerów to wspólna sprawa devów, opsów i sec – ale zaczyna się od właściwego systemu bazowego.
PAMIĘTAJ: Następnym razem gdy będziesz budować obraz Dockera, zatrzymaj się na chwilę przy wyborze base image. Przeanalizuj jego zawartość i historię aktualizacji. Uruchom skan Trivy lub Snyk i zobacz, co kryje się pod maską – wynik może Cię zaskoczyć. Zacznij stosować przedstawione tu praktyki krok po kroku: najpierw usuń zbędne pakiety, potem dodaj skanowanie do pipeline’u, przestaw kontenery na użytkownika nie-root. Każda z tych zmian to jedno okienko mniej dla włamywacza. Kontenery dają nam niesamowitą szybkość i skalę – zadbajmy, by nie odbywało się to kosztem bezpieczeństwa. Zrób dziś mały audit swoich obrazów bazowych – bezpieczeństwo bez tabu, za to z konkretnym działaniem!
Checklist – Bezpieczny obraz kontenera
- Świadomy dobór base image (minimalny, ale zgodny z wymaganiami; oficjalne źródło, zweryfikowany digest).
- Aktualizacje na bieżąco (regularny rebuild obrazu, aktualizacja pakietów podczas build, tracking nowych CVE).
- Minimalizacja zawartości (usuń nieużywane pakiety, używaj multi-stage build, zero sekretów w obrazie).
- Uruchamianie jako non-root (konfiguracja użytkownika w Dockerfile, ograniczenie uprawnień runtime – AppArmor/SELinux, seccomp, brak --privileged).
- Integracja skanowania CVE (CI/CD skan Trivy/Grype/Snyk, polityki bezpieczeństwa – blokada krytycznych luk, ciągłe skany w rejestrze).
- Weryfikacja integralności (podpisywanie obrazów, sprawdzanie podpisów/OIDC tokenów, korzystanie z zaufanych registry).
- Monitoring i reakcja (logi z kontenerów, alerty na wykrycie niepożądanych aktywności przez narzędzia typu Falco, reagowanie na incydenty).
- Dokumentacja i SBOM (generuj SBOM dla wszystkich releasu, miej listę użytych komponentów i ich wersji; zapisuj wyjątki/ignorowane CVE wraz z uzasadnieniem).
Dzięki powyższej liście, od planowania po deployment, utrzymasz bezpieczeństwo swoich kontenerów pod kontrolą. Powodzenia w implementacji!
Podsumowanie

System bazowy obrazu Dockera to fundament, na którym stawiasz całą resztę – i dokładnie dlatego jego wybór najczęściej decyduje o tym, ile CVE dziedziczysz „w pakiecie”, jak duża jest powierzchnia ataku i jak łatwo będzie to utrzymać w ryzach w dłuższym czasie.
Minimalne bazy (np. Alpine, distroless, a w skrajności scratch) potrafią wyciąć masę zbędnych komponentów, ale w zamian wymagają dojrzałego procesu: sensownego debugowania poza kontenerem, multi-stage buildów i porządnego monitoringu. Do tego dochodzi brutalna praktyka: narzędzia skanujące (Trivy/Grype/Clair/Snyk/JFrog) nie zawsze widzą to samo, więc bezpieczeństwo kontenerów nie kończy się na jednym raporcie z CI – to raczej powtarzalny cykl: skan → triage → aktualizacja bazy → rebuild → redeploy, plus kontrola źródła obrazów (digesty, podpisy, zaufane registry) i bezwzględny zakaz wpychania sekretów do warstw.
Jeśli masz zabrać z tego artykułu jedną rzecz, to tę: kontener nie jest magicznie bezpieczny dlatego, iż jest kontenerem – bezpieczeństwo zaczyna się od FROM, a potem wygrywa je konsekwencja w skanowaniu, aktualizacjach i hardeningu uruchomienia.
Newsletter – Zero Spamu
Dołącz by otrzymać aktualizacje bloga, akademii oraz ukryte materiały, zniżki i dodatkową wartość.
Wyrażam zgodę na przetwarzanie moich danych osobowych w celu otrzymywania newslettera od Security Bez Tabu zgodnie z Polityce Prywatności.
Dzięki!
Dzięki za dołączenie do newslettera Security Bez Tabu.
Wkrótce otrzymasz aktualizacje z bloga, materiały zza kulis i zniżki na szkolenia.
Jeśli nie widzisz wiadomości – sprawdź folder Spam lub Oferty.



