Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,5 @@ jobs:
persist-credentials: false
- name: php_tests_worktree
run: ./test/php_tests_worktree.sh
- name: ssm
run: ./test/ssm.sh
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
115 changes: 115 additions & 0 deletions docs/ssm.md
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
Comment thread
coderabbitai[bot] marked this conversation as resolved.

### 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
```
103 changes: 103 additions & 0 deletions ssm.mk
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)
119 changes: 119 additions & 0 deletions test/ssm.sh
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"
Comment thread
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"
Loading