From f973412f01df01103c2309f705b88c7ef106eba9 Mon Sep 17 00:00:00 2001 From: lewissmithweb Date: Mon, 10 Aug 2026 14:56:31 +0100 Subject: [PATCH 1/2] WEB-11460: Add SSM secret seeding and placeholder-guard make helpers Add ssm.mk with ssm_put (create/update a single SecureString), ssm_sync (seed placeholder SecureStrings for missing keys from a committed key-name list, never overwriting existing values) and ssm_guard (fail when any referenced key is missing or still holds the PENDING sentinel). Parameters follow the RFC #356.1 convention /ecs///, service defaulting to the repo name. Add a Docker/AWS-free test harness (test/ssm.sh) wired into CI, and document the targets in docs/ssm.md and the README. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/test.yaml | 2 + README.md | 1 + docs/ssm.md | 105 ++++++++++++++++++++++++++++++++++++ ssm.mk | 103 +++++++++++++++++++++++++++++++++++ test/ssm.sh | 100 ++++++++++++++++++++++++++++++++++ 5 files changed, 311 insertions(+) create mode 100644 docs/ssm.md create mode 100644 ssm.mk create mode 100755 test/ssm.sh diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index c047a49..a5ae318 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -18,3 +18,5 @@ jobs: persist-credentials: false - name: php_tests_worktree run: ./test/php_tests_worktree.sh + - name: ssm + run: ./test/ssm.sh diff --git a/README.md b/README.md index f360366..a04a9a2 100644 --- a/README.md +++ b/README.md @@ -68,4 +68,5 @@ init: MAKE_FILES = ansible common * [node](./docs/node.md) * [php](./docs/php.md) * [python](./docs/python.md) +* [ssm](./docs/ssm.md) * [terraform](./docs/terraform.md) diff --git a/docs/ssm.md b/docs/ssm.md new file mode 100644 index 0000000..5f48bb8 --- /dev/null +++ b/docs/ssm.md @@ -0,0 +1,105 @@ +# 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///`. 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) + * [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 + +### 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 +``` + +``` +# 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 +``` diff --git a/ssm.mk b/ssm.mk new file mode 100644 index 0000000..0e6eddf --- /dev/null +++ b/ssm.mk @@ -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= +# 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///. +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_ or SSM_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_ or SSM_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) diff --git a/test/ssm.sh b/test/ssm.sh new file mode 100755 index 0000000..c3247f6 --- /dev/null +++ b/test/ssm.sh @@ -0,0 +1,100 @@ +#!/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///), 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) +trap 'rm -f "$helper"' 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" + +echo +if [ "$failures" -ne 0 ]; then + echo "ssm: $failures assertion(s) FAILED" + exit 1 +fi +echo "ssm: all assertions passed" From 1c4d0c9798fb531064fc9fe4115189d52e002a4c Mon Sep 17 00:00:00 2001 From: lewissmithweb Date: Mon, 10 Aug 2026 15:04:11 +0100 Subject: [PATCH 2/2] WEB-11460: Address CodeRabbit review feedback - docs/ssm.md: document the MAKE_FILES opt-in step and tag the ssm.keys fenced block with `text` (markdownlint MD040). - test/ssm.sh: run ssm_guard end-to-end against a fake aws returning a canary value and assert the decrypted value never reaches stdout/stderr. Co-Authored-By: Claude Opus 4.8 --- docs/ssm.md | 12 +++++++++++- test/ssm.sh | 21 ++++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/docs/ssm.md b/docs/ssm.md index 5f48bb8..d5884c1 100644 --- a/docs/ssm.md +++ b/docs/ssm.md @@ -9,6 +9,7 @@ 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) @@ -20,6 +21,15 @@ real value; a guard then fails CI if any referenced key still holds that sentine ## 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 @@ -56,7 +66,7 @@ project root: SSM_KEYS_FILE = $(PWD)/ssm.keys ``` -``` +```text # ssm.keys DATABASE_URL STRIPE_SECRET_KEY diff --git a/test/ssm.sh b/test/ssm.sh index c3247f6..170e766 100755 --- a/test/ssm.sh +++ b/test/ssm.sh @@ -72,7 +72,8 @@ expect "ssm_guard runs the guard program" 'sh -eu -c "$SSM_GUARD"' # --- 2. the exported shell programs -------------------------------------------- helper=$(mktemp -t ssm_helper.XXXXXX) -trap 'rm -f "$helper"' EXIT INT TERM HUP +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) @@ -92,6 +93,24 @@ expect "guard decrypts to read the value" "--with-decryption" expect "guard compares against the placeholder" '"$value" = "$PLACEHOLDER"' "$guard_script" expect "guard fails when a key is unresolved" "exit 1" "$guard_script" +# 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" < "$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"