-
Notifications
You must be signed in to change notification settings - Fork 0
WEB-11460: Add SSM secret seeding and placeholder-guard make helpers #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,115 @@ | ||
| # ssm | ||
|
|
||
| Helpers for seeding AWS SSM Parameter Store secrets out-of-band and guarding a | ||
| deploy against unset placeholders, per | ||
| [RFC #356.1](https://lickdteam.atlassian.net/wiki/spaces/ET/pages/4605706241). | ||
|
|
||
| Parameters follow the convention `/ecs/<env>/<service>/<KEY>`. Missing keys are | ||
| seeded with a recognisable sentinel (`PENDING`) so they exist without holding a | ||
| real value; a guard then fails CI if any referenced key still holds that sentinel. | ||
|
|
||
| * [setup](#setup) | ||
| * [install](#install) | ||
| * [environment](#environment) | ||
| * [service override](#service-override) | ||
| * [key-name list](#key-name-list) | ||
| * [placeholder override](#placeholder-override) | ||
| * [commands](#commands) | ||
| * [ssm_put](#ssm_put) | ||
| * [ssm_sync](#ssm_sync) | ||
| * [ssm_guard](#ssm_guard) | ||
|
|
||
| ## setup | ||
|
|
||
| ### install | ||
|
|
||
| Opt into these helpers by adding `ssm` to your project's `MAKE_FILES` (see the | ||
| [README](../README.md#includes)), so `ssm.mk` is downloaded alongside the rest: | ||
|
|
||
| ```makefile | ||
| init: MAKE_FILES = common ssm | ||
| ``` | ||
|
|
||
| ### environment | ||
|
|
||
| Every target needs an environment. The `_%` variants take it from the target | ||
| stem, so `ssm_sync_production` targets the `production` environment. For a base | ||
| target, pass `SSM_ENV` instead: | ||
|
|
||
| ```shell | ||
| SSM_ENV=production make ssm_sync | ||
| ``` | ||
|
|
||
| ### service override | ||
|
|
||
| The service segment of the path defaults to the repo name (the git remote | ||
| basename, or the working-directory name if there is no remote). Override it if | ||
| your parameters live under a different service: | ||
|
|
||
| ```makefile | ||
| SSM_SERVICE = api-backend | ||
| ``` | ||
|
|
||
| You can also override the whole prefix if your convention differs: | ||
|
|
||
| ```makefile | ||
| SSM_PATH_PREFIX = /ecs/$(SSM_ENV)/api-backend | ||
| ``` | ||
|
|
||
| ### key-name list | ||
|
|
||
| `ssm_sync` and `ssm_guard` read a committed list of key names, one per line | ||
| (blank lines and `#` comments are ignored). It defaults to `ssm.keys` in the | ||
| project root: | ||
|
|
||
| ```makefile | ||
| SSM_KEYS_FILE = $(PWD)/ssm.keys | ||
| ``` | ||
|
|
||
| ```text | ||
| # ssm.keys | ||
| DATABASE_URL | ||
| STRIPE_SECRET_KEY | ||
| MAIL_PASSWORD | ||
| ``` | ||
|
|
||
| ### placeholder override | ||
|
|
||
| The sentinel written for a freshly-seeded key defaults to `PENDING`: | ||
|
|
||
| ```makefile | ||
| SSM_PLACEHOLDER = PENDING | ||
| ``` | ||
|
|
||
| ## commands | ||
|
|
||
| ### ssm_put | ||
|
|
||
| Create or update a single named `SecureString` parameter for an environment. | ||
|
|
||
| ```shell | ||
| KEY='DATABASE_URL' VALUE='postgres://...' make ssm_put_production | ||
| KEY='DATABASE_URL' VALUE='postgres://...' SSM_ENV=production make ssm_put | ||
| ``` | ||
|
|
||
| ### ssm_sync | ||
|
|
||
| Reconcile the committed key-name list against SSM: create a placeholder | ||
| `SecureString` for any missing key, and leave existing values untouched (it | ||
| never overwrites a real value). | ||
|
|
||
| ```shell | ||
| make ssm_sync_production | ||
| SSM_ENV=production make ssm_sync | ||
| ``` | ||
|
|
||
| ### ssm_guard | ||
|
|
||
| Fail when any referenced key is missing or still holds the placeholder sentinel, | ||
| so a placeholder cannot reach a deployed task. Safe to run in CI before a deploy; | ||
| it never prints parameter values. | ||
|
|
||
| ```shell | ||
| make ssm_guard_production | ||
| SSM_ENV=production make ssm_guard | ||
| ``` | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| # ssm | ||
|
|
||
| AWS_REGION ?= eu-west-1 | ||
|
|
||
| # Environment segment of the parameter path. Auto-set to the target stem on the | ||
| # `_%` variants (e.g. `make ssm_sync_production` -> production); pass SSM_ENV=<env> | ||
| # when calling a base target. | ||
| SSM_ENV ?= | ||
|
|
||
| # Service segment of the parameter path. Defaults to the repo name (git remote | ||
| # basename, else the working-directory name) so every repo that inherits these | ||
| # helpers targets its own path without configuration. | ||
| SSM_REPO_NAME = $(shell git config --get remote.origin.url 2>/dev/null | sed -E 's#.*/##; s#\.git$$##') | ||
| SSM_SERVICE ?= $(if $(SSM_REPO_NAME),$(SSM_REPO_NAME),$(notdir $(CURDIR))) | ||
|
|
||
| # Parameter path convention from RFC #356.1: /ecs/<env>/<service>/<KEY>. | ||
| SSM_PATH_PREFIX ?= /ecs/$(SSM_ENV)/$(SSM_SERVICE) | ||
|
|
||
| # Committed list of key names (one per line, `#` comments and blank lines ignored) | ||
| # that drives ssm_sync and ssm_guard. | ||
| SSM_KEYS_FILE ?= $(PWD)/ssm.keys | ||
|
|
||
| # Recognisable sentinel written for a freshly-seeded key that has no real value yet. | ||
| SSM_PLACEHOLDER ?= PENDING | ||
|
|
||
| # ssm_put — create or update a single named SecureString parameter for an | ||
| # environment. --overwrite makes it create-or-update. | ||
| # | ||
| # KEY=DATABASE_URL VALUE=... make ssm_put_production | ||
| # KEY=DATABASE_URL VALUE=... SSM_ENV=production make ssm_put | ||
| SSM_PUT_CMD = aws ssm put-parameter --region $(AWS_REGION) --type SecureString --overwrite --name "$(SSM_PATH_PREFIX)/$(KEY)" --value "$(VALUE)" | ||
| ssm_put: | ||
| $(SSM_PUT_CMD) | ||
| ssm_put_%: SSM_ENV = $* | ||
| ssm_put_%: | ||
| $(SSM_PUT_CMD) | ||
|
|
||
| # ssm_sync — reconcile the committed key-name list against SSM for an environment. | ||
| # For every key: if the parameter already exists it is left untouched; if it is | ||
| # missing a placeholder SecureString is created. Never overwrites an existing value. | ||
| define SSM_SYNC | ||
| [ -n "$$ENV" ] || { echo "ssm_sync: environment not set (use ssm_sync_<env> or SSM_ENV=<env>)" >&2; exit 1; } | ||
| [ -f "$$KEYS_FILE" ] || { echo "ssm_sync: keys file '$$KEYS_FILE' not found" >&2; exit 1; } | ||
| created=0; existing=0 | ||
| while IFS= read -r key || [ -n "$$key" ]; do | ||
| key=$${key%%#*} | ||
| key=$$(printf '%s' "$$key" | tr -d '[:space:]') | ||
| [ -n "$$key" ] || continue | ||
| name="$$PREFIX/$$key" | ||
| if aws ssm get-parameter --region "$$REGION" --name "$$name" >/dev/null 2>&1; then | ||
| echo "ssm_sync: exists, leaving untouched: $$name" | ||
| existing=$$((existing + 1)) | ||
| else | ||
| echo "ssm_sync: creating placeholder: $$name" | ||
| aws ssm put-parameter --region "$$REGION" --type SecureString --name "$$name" --value "$$PLACEHOLDER" >/dev/null | ||
| created=$$((created + 1)) | ||
| fi | ||
| done < "$$KEYS_FILE" | ||
| echo "ssm_sync: done ($$created created, $$existing already present)" | ||
| endef | ||
| export SSM_SYNC | ||
|
|
||
| SSM_SYNC_RUN = KEYS_FILE='$(SSM_KEYS_FILE)' PREFIX='$(SSM_PATH_PREFIX)' REGION='$(AWS_REGION)' PLACEHOLDER='$(SSM_PLACEHOLDER)' ENV='$(SSM_ENV)' sh -eu -c "$$SSM_SYNC" | ||
| ssm_sync: | ||
| @$(SSM_SYNC_RUN) | ||
| ssm_sync_%: SSM_ENV = $* | ||
| ssm_sync_%: | ||
| @$(SSM_SYNC_RUN) | ||
|
|
||
| # ssm_guard — CI-runnable check that fails when any referenced key still holds the | ||
| # sentinel placeholder (or is missing entirely), so a placeholder cannot reach a | ||
| # deployed task. Never prints parameter values. | ||
| define SSM_GUARD | ||
| [ -n "$$ENV" ] || { echo "ssm_guard: environment not set (use ssm_guard_<env> or SSM_ENV=<env>)" >&2; exit 1; } | ||
| [ -f "$$KEYS_FILE" ] || { echo "ssm_guard: keys file '$$KEYS_FILE' not found" >&2; exit 1; } | ||
| failed=0 | ||
| while IFS= read -r key || [ -n "$$key" ]; do | ||
| key=$${key%%#*} | ||
| key=$$(printf '%s' "$$key" | tr -d '[:space:]') | ||
| [ -n "$$key" ] || continue | ||
| name="$$PREFIX/$$key" | ||
| if ! value=$$(aws ssm get-parameter --region "$$REGION" --name "$$name" --with-decryption --query 'Parameter.Value' --output text 2>/dev/null); then | ||
| echo "ssm_guard: MISSING: $$name" >&2 | ||
| failed=$$((failed + 1)) | ||
| elif [ "$$value" = "$$PLACEHOLDER" ]; then | ||
| echo "ssm_guard: unresolved placeholder: $$name" >&2 | ||
| failed=$$((failed + 1)) | ||
| fi | ||
| done < "$$KEYS_FILE" | ||
| if [ "$$failed" -ne 0 ]; then | ||
| echo "ssm_guard: $$failed key(s) unresolved for '$$ENV'" >&2 | ||
| exit 1 | ||
| fi | ||
| echo "ssm_guard: all keys resolved for '$$ENV'" | ||
| endef | ||
| export SSM_GUARD | ||
|
|
||
| SSM_GUARD_RUN = KEYS_FILE='$(SSM_KEYS_FILE)' PREFIX='$(SSM_PATH_PREFIX)' REGION='$(AWS_REGION)' PLACEHOLDER='$(SSM_PLACEHOLDER)' ENV='$(SSM_ENV)' sh -eu -c "$$SSM_GUARD" | ||
| ssm_guard: | ||
| @$(SSM_GUARD_RUN) | ||
| ssm_guard_%: SSM_ENV = $* | ||
| ssm_guard_%: | ||
| @$(SSM_GUARD_RUN) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| #!/bin/sh | ||
| # | ||
| # Verifies the wiring of the `ssm.mk` targets without AWS, so it runs anywhere | ||
| # `make` is available. It checks two things: | ||
| # | ||
| # 1. the rendered recipes (`make -n`) build the RFC #356.1 path convention | ||
| # (/ecs/<env>/<service>/<KEY>), pass the right flags, and derive the | ||
| # environment from the target stem; | ||
| # 2. the shell programs the recipes run seed missing keys with a placeholder | ||
| # without overwriting existing values (ssm_sync) and fail on an unresolved | ||
| # placeholder (ssm_guard). | ||
| # | ||
| # The behaviour itself (which talks to AWS) is exercised end-to-end separately. | ||
|
|
||
| set -eu | ||
|
|
||
| repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) | ||
|
|
||
| # Pin service + region so assertions don't depend on the checkout's git remote. | ||
| mk="make -f $repo_root/ssm.mk SSM_SERVICE=api-backend AWS_REGION=eu-west-1" | ||
|
|
||
| failures=0 | ||
|
|
||
| expect() { # description, fixed-string, haystack | ||
| if printf '%s\n' "$3" | grep -qF -- "$2"; then | ||
| echo "ok - $1" | ||
| else | ||
| echo "FAIL - $1 (expected to find: $2)" | ||
| failures=$((failures + 1)) | ||
| fi | ||
| } | ||
|
|
||
| refute() { # description, fixed-string, haystack | ||
| if printf '%s\n' "$3" | grep -qF -- "$2"; then | ||
| echo "FAIL - $1 (did not expect: $2)" | ||
| failures=$((failures + 1)) | ||
| else | ||
| echo "ok - $1" | ||
| fi | ||
| } | ||
|
|
||
| render() { # target [extra make args...] | ||
| if ! out=$($mk -n "$@" 2>&1); then | ||
| echo "FAIL: 'make -n $*' errored:" >&2 | ||
| printf '%s\n' "$out" >&2 | ||
| exit 1 | ||
| fi | ||
| printf '%s' "$out" | ||
| } | ||
|
|
||
| # --- 1. rendered recipes (make -n) --------------------------------------------- | ||
|
|
||
| put=$(render ssm_put_production KEY=DATABASE_URL VALUE=secret) | ||
| echo "ssm_put_production recipe:"; printf '%s\n' "$put" | sed 's/^/ | /'; echo | ||
| expect "ssm_put calls put-parameter" "aws ssm put-parameter" "$put" | ||
| expect "ssm_put uses SecureString" "--type SecureString" "$put" | ||
| expect "ssm_put creates-or-updates" "--overwrite" "$put" | ||
| expect "ssm_put builds the env/service path" "/ecs/production/api-backend/DATABASE_URL" "$put" | ||
|
|
||
| sync=$(render ssm_sync_staging) | ||
| echo "ssm_sync_staging recipe:"; printf '%s\n' "$sync" | sed 's/^/ | /'; echo | ||
| expect "ssm_sync derives env from the stem" "ENV='staging'" "$sync" | ||
| expect "ssm_sync targets the env/service path" "PREFIX='/ecs/staging/api-backend'" "$sync" | ||
| expect "ssm_sync passes the sentinel" "PLACEHOLDER='PENDING'" "$sync" | ||
| expect "ssm_sync runs the sync program" 'sh -eu -c "$SSM_SYNC"' "$sync" | ||
|
|
||
| guard=$(render ssm_guard_production) | ||
| echo "ssm_guard_production recipe:"; printf '%s\n' "$guard" | sed 's/^/ | /'; echo | ||
| expect "ssm_guard derives env from the stem" "ENV='production'" "$guard" | ||
| expect "ssm_guard runs the guard program" 'sh -eu -c "$SSM_GUARD"' "$guard" | ||
|
|
||
| # --- 2. the exported shell programs -------------------------------------------- | ||
|
|
||
| helper=$(mktemp -t ssm_helper.XXXXXX) | ||
| leakdir=$(mktemp -d -t ssm_leak.XXXXXX) | ||
| trap 'rm -f "$helper"; rm -rf "$leakdir"' EXIT INT TERM HUP | ||
|
|
||
| printf '_sync:\n\t@printf %%s "$$SSM_SYNC"\n_guard:\n\t@printf %%s "$$SSM_GUARD"\n' > "$helper" | ||
| sync_script=$($mk -s -f "$helper" _sync) | ||
| guard_script=$($mk -s -f "$helper" _guard) | ||
|
|
||
| echo | ||
| # ssm_sync seeds missing keys with a placeholder, never overwriting existing values | ||
| expect "sync checks a key exists first" "aws ssm get-parameter" "$sync_script" | ||
| expect "sync seeds missing keys" "aws ssm put-parameter" "$sync_script" | ||
| expect "sync seeds a SecureString" "--type SecureString" "$sync_script" | ||
| refute "sync never overwrites existing values" "--overwrite" "$sync_script" | ||
| expect "sync writes the placeholder value" 'PLACEHOLDER' "$sync_script" | ||
|
|
||
| echo | ||
| # ssm_guard fails on an unresolved placeholder, without leaking values | ||
| expect "guard decrypts to read the value" "--with-decryption" "$guard_script" | ||
| expect "guard compares against the placeholder" '"$value" = "$PLACEHOLDER"' "$guard_script" | ||
| expect "guard fails when a key is unresolved" "exit 1" "$guard_script" | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| # Run the guard end-to-end against a fake `aws` that returns a canary secret, and | ||
| # assert the decrypted value never reaches stdout/stderr — a future `echo "$value"` | ||
| # leak would be caught here, not just by reading the source. | ||
| mkdir "$leakdir/bin" | ||
| canary='SECRET-CANARY-9d1f7a' | ||
| cat > "$leakdir/bin/aws" <<EOF | ||
| #!/bin/sh | ||
| # fake \`aws ssm get-parameter\`: always resolves to a real (non-placeholder) value | ||
| printf '%s\n' '$canary' | ||
| EOF | ||
| chmod +x "$leakdir/bin/aws" | ||
| printf 'API_KEY\n' > "$leakdir/ssm.keys" | ||
| guard_output=$(PATH="$leakdir/bin:$PATH" $mk ssm_guard_production SSM_KEYS_FILE="$leakdir/ssm.keys" 2>&1 || true) | ||
|
|
||
| echo | ||
| refute "guard never emits the decrypted value" "$canary" "$guard_output" | ||
| expect "guard resolves a real (non-sentinel) value" "all keys resolved" "$guard_output" | ||
|
|
||
| echo | ||
| if [ "$failures" -ne 0 ]; then | ||
| echo "ssm: $failures assertion(s) FAILED" | ||
| exit 1 | ||
| fi | ||
| echo "ssm: all assertions passed" | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.