Ein CloudFormation-Template ist eine Textdatei. Es wird von CloudFormation gespeichert, liegt in cdk.out auf der Festplatte jedes Entwicklers, steht in den CI-Logs des letzten Synth, und wenn jemand cdk.out versehentlich committet, ist es für immer in Git. Alles, was im Template als Literal steht, ist praktisch innerhalb der Organisation öffentlich. Dazu gehört das Datenbankpasswort, das Sie mit environment: { DB_PASSWORD: '...' } gesetzt haben, weil es schneller ging, als es richtig zu machen.
Wir betreiben drei Konten aus einer CDK-Codebasis mit etwa fünfundzwanzig Secrets pro Konto, und keines davon war je in einem Template. Das hier ist der Regelsatz, der das wahr macht, die beiden beteiligten AWS-Dienste, wann man welchen nutzt, und die Muster, mit denen ein Wert in einen Container, eine Lambda oder einen Build gelangt, ohne dass ein Mensch ihn je irgendwo einfügt.
Die Regel
Das Template trägt Referenzen, nie Werte. Eine Referenz ist ein ARN, ein Name oder ein Parameterpfad. Der Wert lebt an einem von zwei Orten, Secrets Manager oder SSM Parameter Store, und wird zur Laufzeit von dem geholt, was ihn braucht, mit einer IAM-Berechtigung, die im selben Template erteilt wird. Wenn Sie ein Secret aus cdk.out greppen können, ist es ein Leck, und CDK macht die Prüfung leicht:
npx cdk synth --quiet && grep -rniE 'password|secret|token|api[_-]?key' cdk.out/*.template.json | grep -v 'arn:aws:\|Ref\|Fn::' || echo clean
Das läuft bei uns in CI. Es ist zweimal fehlgeschlagen, beide Male an einem gut gemeinten environment:-Eintrag in einer Lambda.
Die beiden Dienste, und wann welcher
| Secrets Manager | SSM Parameter Store (SecureString) | |
|---|---|---|
| Preis | $0,40 pro Secret und Monat + $0,05 pro 10.000 Aufrufe | Kostenlos in der Standardstufe (4 KB, 10.000 Parameter); $0,05 pro erweitertem Parameter |
| Rotation | Eingebaut, mit Lambda-Rotationsfunktionen für RDS, Aurora, Redshift und eigene | Keine; Sie rotieren, indem Sie eine neue Version schreiben |
| Generierung | Kann den Wert selbst erzeugen (generateSecretString) |
Nein |
| Kontoübergreifend | Ressourcen-Policy erlaubt einem anderen Konto das Lesen | Nicht direkt |
| Native Integration | App Runner runtimeEnvironmentSecrets, ECS secrets, Lambda-Extension, RDS Proxy |
ECS secrets, Lambda-Extension, CodeBuild parameter-store |
| Versionierung | Staging-Labels (AWSCURRENT / AWSPREVIOUS) | Nummerierte Versionen |
Die Regel, auf die wir uns geeinigt haben: Secrets Manager für alles, was rotiert, generiert wird oder kontoübergreifend gelesen wird. Parameter Store für alles andere. In der Praxis landen damit die Datenbank-Zugangsdaten, der Web-Push-Signaturschlüssel und die Drittanbieter-Tokens, die wir planmäßig rotieren, im Secrets Manager, und die lange Liste konfigurationsartiger Secrets (Feature-Flags mit sensiblen Werten, ein geteiltes Preview-Token, API-Basis-URLs pro Umgebung mit eingebetteten Schlüsseln) im Parameter Store.
Der Kostenunterschied ist größer, als er aussieht. Bei $0,40 pro Secret sind fünfundzwanzig Secrets in drei Konten $30 im Monat, was 4 % unserer Rechnung waren. Zwei Schritte haben das halbiert: zusammengehörige Werte in ein JSON-Secret gruppieren statt ein Secret pro Wert, und die konfigurationsartigen in den Parameter Store verschieben, der kostenlos ist.
Muster 1: das Secret, das CloudFormation erzeugt und das niemand je sieht
Das beste Secret ist eines, das kein Mensch je gelesen hat. Das Datenbankpasswort ist der klassische Fall: CDK bittet Secrets Manager, es zu erzeugen, Aurora wird angewiesen, es zu verwenden, und der Wert existiert nur im Secrets Manager und in der Datenbank.
const dbSecret = new secretsmanager.Secret(this, 'DbSecret', {
secretName: `/platform/${env}/db`,
generateSecretString: {
secretStringTemplate: JSON.stringify({ username: 'app' }),
generateStringKey: 'password',
excludeCharacters: '"@/\\\'',
passwordLength: 40,
},
});
const cluster = new rds.DatabaseCluster(this, 'Db', {
credentials: rds.Credentials.fromSecret(dbSecret),
// ...
});
Das Template enthält die Ressource des Secrets mit Anweisungen zur Erzeugung eines Werts. Es enthält den Wert nicht. CloudFormation erzeugt ihn, übergibt ihn an RDS über eine dynamische Referenz ({{resolve:secretsmanager:...}}), die innerhalb von CloudFormation aufgelöst wird und nie im gespeicherten Template erscheint, und das ist das letzte Mal, dass etwas außerhalb von Secrets Manager und Aurora ihn berührt. Rotation funktioniert, wenn Sie sie einschalten, genauso; das behandeln wir separat.
Muster 2: das Secret, das ein Mensch einmal einträgt, per Referenz
Drittanbieter-API-Schlüssel kommen aus dem Dashboard eines Anbieters, und ein Mensch muss sie irgendwo ablegen. Dieses Irgendwo ist die CLI, einmal pro Konto, und nie der Code:
aws secretsmanager create-secret --name /platform/prod/payments \
--secret-string '{"apiKey":"...","webhookSecret":"..."}' --profile prod
Die CDK-Seite referenziert es per Name und erteilt dem Leser die Berechtigung:
const payments = secretsmanager.Secret.fromSecretNameV2(this, 'Payments', `/platform/${env}/payments`);
payments.grantRead(apiService.instanceRole);
Das Template enthält den Namen. Nicht den Wert, nicht einmal einen Platzhalter. Existiert das Secret in einem Konto nicht, startet der Dienst mit einer klaren Fehlermeldung nicht, was das korrekte Verhalten für „jemand hat vergessen, das neue Konto einzurichten“ ist. Wir halten eine secrets.md im Repo, die jeden Secret-Namen und die Form seines JSON auflistet, damit die Person, die ein neues Konto einrichtet, eine Checkliste hat und der Code eine einzige Wahrheitsquelle für Schlüssel.
Muster 3: den Wert in den Prozess bekommen
Drei Konsumenten, drei Mechanismen, keiner davon berührt das Template.
App Runner hat runtimeEnvironmentSecrets: eine Zuordnung von Umgebungsvariablenname zu Secret-ARN plus JSON-Schlüssel. Der Dienst holt den Wert beim Instanzstart und injiziert ihn als gewöhnliche Umgebungsvariable in den Container. Die Instanzrolle braucht secretsmanager:GetSecretValue genau auf diesen ARNs, was grantRead liefert.
runtimeEnvironmentSecrets: {
DB_PASSWORD: apprunner.Secret.fromSecretsManager(dbSecret, 'password'),
PAYMENTS_API_KEY: apprunner.Secret.fromSecretsManager(payments, 'apiKey'),
PREVIEW_TOKEN: apprunner.Secret.fromSsmParameter(previewToken),
},
Lambda hat keine entsprechende Injektion, also holt sie beim Kaltstart. Die AWS Parameters and Secrets Lambda Extension läuft als Layer, bedient einen lokalen HTTP-Endpunkt, cacht für eine konfigurierbare TTL und bedeutet, dass der Code der Funktion einen localhost-Aufruf statt eines SDK-Aufrufs macht. Zehn Zeilen im Init des Handlers, und eine Rotation propagiert sich innerhalb der Cache-TTL ohne Redeploy.
CodeBuild liest Parameter Store und Secrets Manager direkt in den Blöcken env.secrets-manager und env.parameter-store der Buildspec, sodass ein Build das Registry-Token haben kann, ohne dass das Token in der Projektdefinition steht. Die Build-Rolle bekommt den Grant. Der Wert wird im Build-Log maskiert, was wir doppelt zu prüfen gelernt haben, nachdem einer trotzdem über ein echo auftauchte.
Die drei Wege, auf denen es trotzdem schiefgeht
SecretValue.unsafePlainText. CDK zwingt Sie, das Wort „unsafe“ zu tippen, um ein Literal-Secret in ein Template zu setzen, und Leute tun es trotzdem, meist in einem Test-Stack, der später ein echter wird. Unser CI-Grep fängt es. Verbieten Sie es auch im Linter.
Secrets in cdk.context.json. Kontextwerte werden per Design in Git committet, also sind sie der falsche Ort für alles Sensible. Wir haben gesehen, wie ein API-Schlüssel per --context apiKey=... auf der Kommandozeile dort landete. Kontext ist für Konto-IDs, VPC-Lookups und Feature-Toggles, sonst nichts.
Ein Secret in der CDK-App selbst lesen. secretsmanager.Secret.fromSecretNameV2(...).secretValue.unsafeUnwrap() zur Synth-Zeit löst den Wert auf dem Entwicklerrechner auf und schreibt ihn ins Template. Dasselbe Leck mit mehr Schritten. Die einzige korrekte Verwendung von secretValue ist die Übergabe an ein Construct, das weiß, wie man daraus eine dynamische Referenz macht, was Credentials.fromSecret und runtimeEnvironmentSecrets tun.
Wie es über drei Konten aussieht
Derselbe Code, drei Konten, drei Wertesätze, null Werte im Repository:
| Secret | Lebt in | Erstellt von | Gelesen von |
|---|---|---|---|
| Datenbank-Zugangsdaten | Secrets Manager | CloudFormation (generiert) | App Runner, Migrations-Lambda, RDS Proxy |
| Drittanbieter-API-Schlüssel (3) | Secrets Manager, je ein JSON | Ein Mensch, einmal pro Konto, per CLI | App Runner, Webhook-Lambdas |
| Web-Push-Signaturschlüssel | Secrets Manager | Ein Mensch, einmal | Benachrichtigungs-Lambda |
| Preview-Token, Feature-Flags mit Werten, interne URLs | Parameter Store | Ein Mensch, einmal, oder CDK für Unsensibles | App Runner, Lambdas |
| Registry-Token für Builds | Secrets Manager | CloudFormation (generiert) | CodeBuild |
Nichts in dieser Tabelle steht in einem Template, einer .env-Datei in Git, einem GitHub-Secret oder der Shell-Historie eines Laptops, und die Deploy-Rolle kann nichts davon lesen, weil sie es nicht muss: CloudFormation und die konsumierenden Dienste holen, mit ihren eigenen Rollen.
Wenn Ihre Templates oder Ihre .env-Dateien Werte enthalten und Sie das ändern möchten, wir haben diese Migration schon gemacht; etwa ein Tag pro Konto.