Sprawa klucza, który był aktywny, a mimo to odrzucony
Krótka historia detektywistyczna o wydaniu, które padło z błędem „invalid token”, podczas gdy konsola zarzekała się, że klucz jest zdrowy. Obie strony mówiły prawdę. Tyle że o dwóch różnych kluczach, w dwóch różnych kontach AWS.
┌──────────────────────────────────────────────────────────────┐ │ INVESTIGATION BOARD │ ├──────────────────────────────────────────────────────────────┤ │ │ │ VICTIM: a release pipeline, stopped at the image push │ │ WEAPON: "The security token included in the request │ │ is invalid." │ │ │ │ SUSPECT 1: a revoked key ........... ALIBI: active, │ │ used 8 minutes ago │ │ SUSPECT 2: a stray session token ... no evidence │ │ SUSPECT 3: someone edited the var .. no evidence │ │ │ │ CULPRIT: the right key name, the wrong account, │ │ and a cleanup that warned us in a comment │ └──────────────────────────────────────────────────────────────┘
Punkt wyjścia
Projekt to SDK. Każde wydanie robi dwie rzeczy: publikuje pakiet Pythona i publikuje wieloarchitekturowy bazowy obraz kontenera, na którym budują się wszystkie usługi korzystające z SDK. Gdy obraz jest gotowy, boty otwierają merge requesty z aktualizacją w repozytoriach, które od niego zależą.
W dniu wydania pakiet poszedł. Obraz nie. Potok zatrzymał się na pierwszym wywołaniu AWS w zadaniu publikującym:
$ aws ecr get-login-password --region "$AWS_DEFAULT_REGION" An error occurred (UnrecognizedClientException) when calling the GetAuthorizationToken operation: The security token included in the request is invalid.
Trzy próby, trzy porażki. Bez obrazu nie ma merge requestów z aktualizacją, więc wydanie zostało dostarczone do połowy.
Oczywista odpowiedź
Pierwsze pytanie: czy zadanie się zmieniło? Nie. Ten sam skrypt, linijka w linijkę, opublikował poprzednie wydanie tydzień wcześniej. Jedyny commit w CI w międzyczasie dotyczył innego zadania.
Skrypt był więc w porządku, a dane uwierzytelniające nie. Zadanie logowało się parą długoterminowych kluczy dostępu, zapisanych jako zmienne CI na poziomie grupy. UnrecognizedClientException oznacza, że AWS w ogóle nie rozpoznaje identyfikatora klucza. Złe hasło do prawdziwego klucza daje inny błąd, SignatureDoesNotMatch. Klucz został więc pewnie usunięty albo dezaktywowany.
Wniosek napisał się sam: zrotować klucz, zaktualizować zmienną, ponowić. Sprawa zamknięta.
A potem przyszedł zrzut ekranu z konsoli IAM.
Zwrot pierwszy: alibi
IAM users ┌────────────────────────────────┬──────────────┬───────────────────┐ │ user │ access key │ key last used │ ├────────────────────────────────┼──────────────┼───────────────────┤ │ ... │ Active │ ... │ │ registry-push-user │ Active │ 8 minutes ago ✓ │ └────────────────────────────────┴──────────────┴───────────────────┘
Użytkownik z prawem zapisu do rejestru obrazów miał dokładnie jeden klucz. Był aktywny i osiem minut wcześniej został poprawnie użyty. Coś, gdzieś, właśnie w tej chwili nim pushowało.
AWS nie unieważnił więc klucza. To skierowało śledztwo w inną stronę. Skoro klucz działa, zadanie musi wysyłać coś innego.
Dwóch kandydatów:
- Zabłąkany token sesji. Skrypt zadania miał mechanizm zapasowy:
AWS_SESSION_TOKEN="${REGISTRY_SESSION_TOKEN:-${AWS_SESSION_TOKEN:-}}". Jeśli runner albo zmienna wyższego poziomu wstrzyknęłyby token sesji, AWS odrzuciłby nawet poprawny klucz statyczny z dokładnie tym komunikatem. Sprawdziliśmy runner: ten sam tydzień wcześniej publikował bez problemu. Sprawdziliśmy log audytu grupy pod kątem edycji zmiennych: nic. - Inny identyfikator klucza w zmiennej niż ten w konsoli.
Drugie sprawdzenie jest to nudne. Otwierasz zmienną CI, odsłaniasz wartość i porównujesz ją z kluczem w magazynie sekretów. Nikt nie ma na to ochoty, bo wydaje się, że konsola już odpowiedziała na pytanie.
Nie odpowiedziała. Identyfikatory były różne.
Zwrot drugi: niewłaściwe konto
Identyfikator klucza dostępu AWS nie jest losowy. Znaki po prefiksie AKIA kodują 12-cyfrowy numer konta, do którego należy klucz, i da się go odczytać offline, bez żadnych uprawnień:
import base64
def account_of(key_id):
b = base64.b32decode(key_id[4:]) # all 16 chars after "AKIA"
n = int.from_bytes(b[:6], "big")
return "%012d" % ((n & 0x7fffffffff80) >> 7)
(Dekoduj całą resztę. Za pierwszym razem ucięliśmy ją za krótko i wyszła 10-cyfrowa bzdura. Wynik, który nie ma 12 cyfr, to sygnał ostrzegawczy.)
the variable named "REGISTRY_ACCESS_KEY_ID"
│
├── key the job sends ........ account 111111111111 (development)
│
└── key in the console ....... account 222222222222 (image registry)
registry the job pushes to ... account 222222222222
(account numbers fabricated)
Zmienna nazwana od konta rejestru trzymała klucz z konta deweloperskiego. Działało to tak długo, jak klucz deweloperski był aktywny i miał prawo pushować między kontami. Konsola pokazywała zupełnie zdrowy klucz, tylko nie ten, którego używało zadanie.
Nasz drugi wniosek brzmiał: „ktoś wkleił do zmiennej zły klucz”. Też był błędny.
Zwrot trzeci: to było zapowiedziane
IAM konta deweloperskiego jest zarządzany w Terraformie. W jego historii był commit sprzed tygodnia, który dezaktywował statyczne klucze CI starszych użytkowników. Był częścią zaplanowanego przejścia z długoterminowych kluczy na krótkotrwałe poświadczenia OIDC w CI.
Commit ustawiał jedną flagę na Inactive, a komentarz obok wymieniał push obrazu SDK wśród pozostałych użytkowników klucza.
Nikt więc niczego nie zepsuł przypadkiem. Zespół infrastruktury wyłączył klucz celowo, a sama zmiana mówiła, że wydanie SDK wciąż od niego zależy.
Dlaczego przez tydzień nikt nie zauważył? Zadanie publikujące uruchamia się tylko na tagach wydań. Nie ma tagu, nie ma zadania, nie ma awarii. Usterka czekała do dnia wydania.
Przeszukaliśmy całą grupę pod kątem nazwy zmiennej. Inny projekt trafił na ten sam błąd dwa dni wcześniej, po cichu przeszedł na OIDC i pojechał dalej. Cztery kolejne projekty wciąż używały zmiennej i od tamtej pory nie uruchomiły żadnego zadania, którego to dotyczy. Wyłożyłyby się przy najbliższym wydaniu.
Poprawka w dwóch połowach
Odblokować dziś, ale porządnie. Kusiło, żeby kliknąć „Activate” w konsoli. Ale klucz był zarządzany przez Terraform, więc najbliższy apply wyłączyłby go z powrotem. Ręczne kliknięcie po cichu cofa też cudzą pracę nad bezpieczeństwem. Zamiast tego jednolinijkowy merge request przywrócił flagę na Active w środowisku deweloperskim, z komentarzem, że to rozwiązanie tymczasowe. Przeszedł przez potok infrastruktury i przegląd planu jak każda inna zmiana. Po wdrożeniu ponowiliśmy zadanie, obraz się opublikował, a merge requesty z aktualizacją się otworzyły.
Naprawić na dobre. Konto rejestru miało już rolę IAM ufającą tokenom OIDC z GitLaba. Zadania budujące we wspólnym szablonie CI już z niej korzystały. Zadanie publikujące dostało ten sam przepis:
release-publish:
id_tokens:
CI_OIDC_TOKEN:
aud: https://gitlab.example.com
variables:
AWS_ROLE_ARN: <registry account's CI push role>
AWS_WEB_IDENTITY_TOKEN_FILE: /tmp/oidc-token
before_script:
# static keys outrank web identity in the AWS credential chain
- unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN
- printf '%s' "$CI_OIDC_TOKEN" > "$AWS_WEB_IDENTITY_TOKEN_FILE"
- aws ecr get-login-password ... # unchanged
Nie ma sekretu do rotowania, nie ma czego wyłączyć przy porządkach, a log audytu w chmurze widzi, który projekt i która gałąź czy tag wykonały każdy push.
Moment, w którym zielony nic nie znaczył
Dwie cechy zadań uruchamianych tylko przy wydaniach sprawiły, że było trudniej, niż się wydawało.
Poprawka nie mogła uratować wydania, dla którego powstała. Ponowienie zadania w potoku tagu uruchamia konfigurację CI z otagowanego commita. Zmiana OIDC scalona do main nie dociera do tagu, który już istnieje. Dlatego rozwiązanie tymczasowe musiało powstać poza repozytorium.
Zielony potok merge requesta niczego nie dowodził. Zadanie działa tylko na tagach, więc potok MR go nie uruchomił. Zielony kolor oznaczał tylko poprawny YAML i to, że nic innego się nie zepsuło. Dowodu szukaliśmy więc gdzie indziej: w potoku tagu innego projektu, który używał tej samej roli przez wspólny szablon. Jego log pokazywał assumed-role/<the CI push role>. Polityka zaufania akceptowała potoki tagów z tej grupy, a ścieżka logowania była taka sama. Porównaliśmy obie konfiguracje linijka po linijce: różniły się tylko dodatkowym unset i inną wersją AWS CLI.
I jeszcze jedna pułapka. Po scaleniu zmiany OIDC klucz statyczny przestaje być zabezpieczeniem, bo zadanie go usuwa. Prawdziwym testem będzie następne wydanie. Zaakceptowaliśmy to, bo awaria byłaby głośna (następuje, zanim cokolwiek zostanie wypchnięte) i łatwa do cofnięcia (revert, potem nowy tag).
Epilog: nie każdy czerwony jest twój
Następnego ranka gałąź główna zrobiła się czerwona zaraz po scaleniu zmiany OIDC. Moment wskazywał prosto na tę zmianę.
To nie była ta zmiana. Testy jednostkowe padały z 'FixtureManager' object has no attribute '_arg2fixturedefs'. Framework testowy wypuścił w nocy nową wersję minor, która usunęła prywatny atrybut, na którym wciąż polegała popularna wtyczka. Plik lock projektu nie był commitowany, a framework był przypięty do "*", więc CI pobrało nowe wydanie przy najbliższym commicie, jaki akurat się uruchomił. Poprawka należała do innego zespołu, a diagnoza wymagała jednego porównania zainstalowanych wersji między ostatnim zielonym a pierwszym czerwonym przebiegiem.
Wskazówki na następne śledztwo
- „Aktywny w konsoli” to nie to samo co „to, co wysyła zadanie”. Konsola pokazuje, że prawdziwy klucz jest zdrowy. Nic nie mówi o tym, jaki klucz trzyma twoja zmienna CI. Zanim cokolwiek zrotujesz, odsłoń zmienną i porównaj identyfikatory.
- Odkoduj identyfikator klucza dostępu. Numer konta jest w identyfikatorze. Jedna linijka Pythona powie ci, czy zmienna nazwana od jednego konta trzyma klucz z innego.
- Czytaj błąd dokładnie.
UnrecognizedClientException/InvalidClientTokenIdoznacza, że AWS nie rozpoznaje identyfikatora klucza albo tokenu.SignatureDoesNotMatchoznacza, że identyfikator jest w porządku, a hasło jest złe. - Porządki, które same wymieniają aktywnego użytkownika, to bloker, nie przypis. Jeśli zmiana mówi „wciąż używane przez X”, najpierw zmigruj X albo uprzedź jego właściciela, podając datę. Użytkownicy uruchamiani tylko na tagach albo według harmonogramu psują się późno i daleko od zmiany.
- Stan zarządzany przez IaC cofaj przez IaC. Kliknięcie w konsoli przy kluczu zarządzanym przez Terraform zostanie cofnięte przez najbliższy apply i po cichu przekreśla cudzą pracę.
- Zadania uruchamianego tylko na tagach nie testuje potok MR. Zielony kolor dotyczy tam składni. Dowodu szukaj w potoku tagu innego projektu albo utwórz jednorazowy tag przedpremierowy.
- Ponowienie zadania na tagu nie podchwyci twojej poprawki. Uruchamia konfigurację z otagowanego commita. Dla wydania, które już wyszło, napraw środowisko albo utwórz nowy tag.
- Nieprzypięte zależności kłamią co do momentu. Gdy czerwony pojawia się zaraz po twoim scaleniu, najpierw porównaj zainstalowane wersje, a dopiero potem swój kod.