ssmm 0.6.0

AWS SSM Parameter Store helper for team-scoped .env sync (systemd friendly)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
# ssmm — AWS SSM Parameter Store helper for team-scoped `.env` sync

[![build](https://img.shields.io/badge/build-cargo-orange)]() [![license](https://img.shields.io/badge/license-MIT-blue)]()

A small Rust CLI that treats AWS SSM Parameter Store as the source of truth
for a team's `.env` files, with a flat-key convention and tag-based overlays.

`ssmm` is intentionally narrow-scoped: it assumes you run Linux services
(typically via systemd `ExecStartPre`) that consume a generated `.env` file
with `EnvironmentFile=`. If that matches your setup, the tool is opinionated
enough to remove a lot of shell-script boilerplate.

## Why another SSM wrapper?

Two delivery modes, same prefix convention and overlay rules:

- **`ssmm sync`** materializes a `.env` file (mode 0600) that systemd loads
  via `EnvironmentFile=`. Drop-in replacement for plaintext `.env` with zero
  app-side changes.
- **`ssmm exec`** injects SSM values directly into a child process's
  environment via `execvp` — no file on disk. Use when your threat model
  disallows plaintext secrets on the filesystem.

See [Security model](#security-model) for the tradeoff.

Other opinions baked in:

- Parameters live under `/<team>/<app>/<key>` — the first segment is your
  team namespace (for IAM policy scoping), the second is the app, and the key
  is flat (`kintone-api-token`, not `kintone/api/token`).
- `SecureString` vs `String` is auto-detected from the key name (conservative:
  unknown keys default to `SecureString`). Only structural-looking suffixes
  (`_path` / `_dir` / `_channel` / `_name` / `_host` / `_port` / `_region` /
  `_endpoint`) map to `String`. **`_url` is NOT in the safe list** since
  URLs commonly embed credentials (e.g. `postgres://user:pass@host/db`,
  Slack webhook URLs) — URL-bearing keys stay `SecureString` by default.
  Override per key with `--plain KEY` if you really need plaintext.
- Each parameter is automatically tagged `app=<app>` so you can filter
  cross-namespace by tag later.

## Install

Requires Rust 1.77+ (or whatever supports `edition = "2024"`).

```bash
cargo install ssmm          # from crates.io
# or
cargo install --git https://github.com/kok1eee/ssmm
```

Your IAM role needs: `ssm:PutParameter`, `ssm:GetParametersByPath`,
`ssm:GetParameters`, `ssm:DescribeParameters`, `ssm:DeleteParameter(s)`,
`ssm:AddTagsToResource`, `ssm:RemoveTagsFromResource`,
`ssm:ListTagsForResource`. SSM `SecureString` uses the AWS-managed key
(`alias/aws/ssm`) by default.

## Configure your team's prefix

**ssmm requires an explicit prefix** — set it once and forget it:

```bash
# option 1: env var (recommended for systemd services)
export SSMM_PREFIX_ROOT=/myteam

# option 2: per-invocation flag
ssmm --prefix /myteam list --all
```

If neither is set, `ssmm` exits with:
```
Error: no prefix configured. Pass --prefix /<your-team> or set $SSMM_PREFIX_ROOT=/<your-team>.
```

All subcommands operate under this root prefix. Parameters end up at
`/<prefix>/<app>/<key>`.

## Quick tour

```bash
# Put a whole .env file (CWD basename becomes <app>, snake_case → dash-case)
cd your-app/
ssmm put --env .env
# ↳ /myteam/your-app/kintone-api-token (SecureString [auto: default], len=...)
# ↳ /myteam/your-app/slack-channel     (String [auto: suffix], len=...)

# Override the type per key when the heuristic gets it wrong
ssmm put --env .env --secure DATABASE_URL --secure SENTRY_DSN
ssmm put --env .env --plain-key METRICS_URL --plain-key PUBLIC_HOST
ssmm put --env .env --plain-all                  # everything String (public-config apps)

# List (CWD auto-detects app name via basename)
ssmm list
ssmm list --keys-only
ssmm list --all                                  # across every app under /myteam
ssmm list --tag env=prod

# Sync SSM → .env (systemd ExecStartPre friendly, mode 0600, idempotent)
ssmm sync --out ./.env
# ssmm: wrote 10 variables to ./.env (app=10, shared=0, tag=0)

# Strict mode: exit non-zero if a shared / tag key is overridden by an app key
# (good for systemd ExecStartPre — you want the service to FAIL, not silently diverge)
ssmm sync --out ./.env --strict

# Or skip the .env file entirely — SSM → process env directly (chamber-style).
# Parent env is inherited; SSM values overlay. Values never touch disk.
ssmm exec -- ./run.sh --flag value       # use `--` so child flags aren't eaten
ssmm exec --app myapp --include-tag shared=true -- python -m myapp
# stderr: ssmm: exec ./run.sh with 10 variables (app=10, shared=0, tag=0)

# Show one
ssmm show kintone-api-token

# Manage tags on an existing parameter
ssmm tag add kintone-api-token shared=true owner=backend
ssmm tag list kintone-api-token
ssmm tag remove kintone-api-token owner

# Dashboard of every app namespace
ssmm dirs

# Find duplicates (same key across apps, or identical values)
ssmm check --duplicates --values

# Migrate parameters — three safe steps (SSM has no soft-delete; go slow)
ssmm migrate /old-prefix/app /myteam/app                      # step 1: copy only
ssmm migrate /old-prefix/app /myteam/app --delete-old         # step 2: dry-run + backup dump
                                                              #   → /tmp/ssmm-migrate-backup-<ts>.json
ssmm migrate /old-prefix/app /myteam/app --delete-old --confirm  # step 3: actually delete sources
```

## Migrating an existing sync unit to exec mode

If you already have units running in sync mode (`ExecStartPre=ssmm sync ...` +
`EnvironmentFile=...env`) and want to switch to exec mode, generate the
drop-in with `ssmm migrate-to-exec` instead of hand-editing:

```bash
# dry-run (default): prints the proposed drop-in
ssmm migrate-to-exec \
  --unit myapp.service \
  --app myapp \
  --exec-cmd "/usr/bin/uv run python app.py --mode prod" \
  --keep-env-file /etc/defaults/common \
  --pre-exec "/usr/bin/playwright install chromium"

# actually write the drop-in and reload systemd
ssmm migrate-to-exec ... --apply
```

- `--exec-cmd` is the command to run after SSM injection. Paste the
  existing `ExecStart=` value from `systemctl cat <unit>` verbatim; ssmm
  deliberately does not auto-parse systemd's output since `show` / `cat`
  format differs across versions and drop-in resets.
- `--keep-env-file PATH` preserves non-SSM `EnvironmentFile=` entries
  (e.g. a machine-wide PATH setup). Everything else is cleared so the
  old `.env` stops being read.
- `--pre-exec CMD` repopulates `ExecStartPre=` after clearing it; useful
  when the original `ExecStartPre` mixed `ssmm sync` with other prep
  steps (playwright install, cache warm-up) — list only the steps you
  still need.
- `--apply` writes `<drop-in-dir>/exec-mode.conf` and runs
  `systemctl [--user|--system] daemon-reload`. Without `--apply` it's a
  pure stdout dry-run.
- **Revert is one command**: `rm <drop-in> && systemctl daemon-reload`.
- Tested against sdtab-managed units; the generated drop-in coexists
  with sdtab's own `<unit>.d/v2-syslog-identifier.conf` style drop-ins.
  If you later run `sdtab upgrade`, verify `exec-mode.conf` survives —
  report back if it doesn't.

## Onboarding a greenfield app in one command

`ssmm onboard` combines `put --env <file>` and `migrate-to-exec` for apps
that are not yet in SSM. It reads the `.env`, puts each key, generates
the systemd drop-in, and runs `daemon-reload` — all from one invocation.

```bash
# dry-run (default): prints put plan + drop-in preview, reads no files
ssmm onboard \
  --unit myapp.service \
  --app myapp \
  --env ./myapp.env \
  --exec-cmd "/usr/bin/uv run python app.py --mode prod" \
  --keep-env-file /etc/defaults/common \
  --pre-exec "/usr/bin/playwright install chromium"

# actually put + write drop-in + daemon-reload
ssmm onboard ... --apply
```

- **Default is fail-if-any-key-exists.** Running `onboard` twice won't
  silently overwrite a secret you rotated between runs. Pass
  `--overwrite` to opt into replace-existing semantics. Dry-run with
  `--overwrite` still lists the colliding keys under a
  `# WILL OVERWRITE` header so destructive intent is visible.
- Empty values in the `.env` are filtered out (matching `put`'s
  behaviour), so trailing `FOO=` lines don't trigger spurious
  "would overwrite" noise.
- Values never appear in dry-run output (names and `len=N` only);
  there is a snapshot test pinning this property.
- **If apply fails partway** — SSM put succeeds but `daemon-reload`
  fails — the error tells you to `ssmm delete <app> -r` to revert the
  SSM half. The systemd drop-in if written can be removed by
  `rm <path>` (shown in the error).
- **Use `migrate-to-exec` instead** when the app is already in SSM and
  you only need to switch modes. `onboard`'s default-fail guard will
  block you from double-putting.

## systemd integration

Two shapes, pick based on threat model. Both work with user-scoped
systemd units (as shown below) and system units alike.

```ini
# (a) sync-mode — drops a mode-0600 .env next to the app, then starts it.
# Existing apps that read EnvironmentFile= work unchanged.
# ~/.config/systemd/user/myapp.service
[Service]
Environment=SSMM_PREFIX_ROOT=/myteam
ExecStartPre=/home/you/.cargo/bin/ssmm sync --app myapp --out /opt/myapp/.env
EnvironmentFile=/opt/myapp/.env
ExecStart=/opt/myapp/run.sh
```

```ini
# (b) exec-mode — no .env on disk; ssmm exec replaces itself with the app,
# passing SSM values via environ. Use when plaintext-on-disk is unacceptable.
[Service]
Environment=SSMM_PREFIX_ROOT=/myteam
ExecStart=/home/you/.cargo/bin/ssmm exec --app myapp -- /opt/myapp/run.sh
```

Notes:

- `ssmm sync` is idempotent: if the generated content matches the existing
  file byte-for-byte, it's a no-op (`ssmm: no change`).
- `ssmm exec` uses `execvp` so systemd sees the child process directly —
  `Type=simple` semantics, signal delivery, MainPID, and journal output all
  work as if systemd had started the app itself. No supervisor wrapper.
- Always put `--` before the child command in exec mode, so flags for the
  child (`--port`, `-H`, etc.) are not consumed by ssmm.

## Shared namespace and tag overlays

Values shared across multiple apps have two expressions in `ssmm`:

```bash
# Put a cross-app value directly under /<prefix>/shared/*
ssmm put --app shared --env /path/shared.env

# Or tag an existing per-app parameter as shared
ssmm tag add kintone-api-token shared=true

# sync automatically overlays /<prefix>/shared/* (disable with --no-shared)
# and any tag-matched parameter via --include-tag
ssmm sync --include-tag shared=true
```

Precedence when the same key name appears in multiple layers:
**app > include-tag > shared**. Conflicts are logged to stderr.

## Auto-detection

When `--app` is omitted, `ssmm` picks the name from the current directory:

- `/home/you/services/my_api/``my-api` (snake_case → dash-case)
- `/home/you/services/billing-svc/``billing-svc`

Override with `--app <name>` any time.

## Path values and portability

SSM stores parameter values as opaque bytes; `ssmm` does not expand
shell-style shortcuts like `~` on the way in or out. When a value is a
filesystem path, prefer the `$HOME`-relative form and let the consuming
app expand it at runtime:

```
# SSM parameter (portable)
GOOGLE_SERVICE_ACCOUNT_KEY_PATH=~/.credentials/service-account.json
```

```python
# Python — app side
import os
path = os.path.expanduser(os.getenv("GOOGLE_SERVICE_ACCOUNT_KEY_PATH") or "")
```

Why this matters: a hard-coded absolute path (e.g. `/home/ec2-user/...`)
in SSM works on that one host but silently breaks local dev on a
different `$HOME`. With `~` + `expanduser`, one SSM value serves every
environment. The same applies to path-like env vars in general — store
them portable, expand on read.

## Concurrency and throttling

SSM's `PutParameter` has a low per-account TPS (~3/s for standard
parameters). `ssmm` defaults to `--write-concurrency=3` with AWS SDK
adaptive retry (`max_attempts=10`), so bulk imports complete without
manual backoff. Reads default to `--read-concurrency=10`. Both are
adjustable per invocation:

```bash
ssmm --write-concurrency 1 put --env .env     # tighter throttle-avoidance
ssmm --read-concurrency 20 list --all          # faster on high-limit accounts
```

## Advanced tier and custom KMS key

Two opt-in knobs for cases where the defaults don't fit:

```bash
# Advanced tier: raises per-parameter limit from 4KB to 8KB.
# Required for certificates, PEM keys, or large JSON blobs.
# Costs $0.05/month per Advanced parameter (Standard is free).
ssmm --advanced put --env .env

# Custom KMS key: use a team-scoped CMK instead of the default
# AWS-managed key (`alias/aws/ssm`). Useful when you want to restrict
# decrypt permission to a subset of IAM principals via key policy.
ssmm --kms-key-id alias/myteam-ssm put --env .env
```

Notes:

- `--kms-key-id` only affects **newly-created** SecureString parameters.
  Existing parameters keep their original key (AWS does not allow
  re-keying in place — delete and recreate if you need to rotate).
- When migrating values that exceed 4KB, pass `--advanced` to
  `ssmm migrate` as well, or the copy step will fail with
  `ValidationException`.
- Tier downgrade (Advanced → Standard) is not supported by SSM; once
  Advanced, the parameter stays Advanced until deleted.

## Security model

`ssmm` is **not a hardened secret manager**. It's a `.env`-compatible
convenience layer over SSM. Decide based on your threat model, and pick
the right delivery mode (`sync` or `exec`).

### What `ssmm sync` actually does

`ssmm sync` calls `GetParametersByPath` with `--with-decryption`, writes
the resulting `KEY=VALUE` lines to the output path, and `chmod 0600`s
the file. Decrypted SecureString values live:

1. In memory on the host running `ssmm sync`
2. In the on-disk file (mode 0600, owner-only readable)
3. In the target process's environment after `systemd` reads
   `EnvironmentFile=` (→ readable via `/proc/<pid>/environ` to the same
   UID / root)

### What `ssmm exec` actually does

`ssmm exec` performs the same `GetParametersByPath` + decryption, but
then `execvp`s the child command with SSM values added to the inherited
environment. Decrypted SecureString values live:

1. In memory on the host during the SSM fetch
2. In the child process's environment (readable via `/proc/<pid>/environ`
   to the same UID / root)

Specifically, **step 2 of `sync` — the on-disk file — does not exist
under `exec`**. That is the primary difference between the two modes.

### What either mode protects against

- Accidental commit of plaintext `.env` to git (SSM is the source of truth)
- Unauthorized teammates who have SSM read permission but not host login
- Drift between hosts (central management vs hand-copied `.env` files)

### What neither mode protects against

- **Same-UID process snooping**: `/proc/<pid>/environ` is readable by
  same-UID processes (and root). Both modes expose values here once the
  app is running.
- **Host compromise**: attacker with filesystem read sees the process
  environment; under `sync` they also see the plaintext `.env`.
- **Systemd journal / CloudWatch log exfiltration**: `ssmm` is designed
  so that parameter **values never appear in error messages or log output**
  (only parameter names / counts / lengths / SHA-256 hashes). If you find
  a path that does leak values into stderr or journalctl, please open an
  issue — it's considered a bug. When integrating with CloudWatch /
  fluent-bit / Datadog logs, verify your own wrappers also preserve this
  discipline.

### What `exec` additionally protects against (vs `sync`)

- **Backup exfiltration**: no plaintext file, so `/opt/myapp/` backups
  don't leak secrets (unless the backup also captures process memory /
  `/proc`, which is uncommon).
- **Unauthorized file read by a different UID on the same host**: the
  sync'd `.env` is 0600 but still a file. `exec`-mode values only exist
  in the process's environ, protected by kernel process isolation.

### If your threat model is stricter than either mode

Consider tools that avoid even same-UID environ exposure:

- **HashiCorp Vault + agent** — short-lived leases, audit logs
- **SOPS + age/KMS** — encrypted-at-rest files, decrypt in-app only
- **Runtime secret brokers** (AWS Secrets Manager SDK called from within
  the app, rotated values, scoped to short-lived in-memory handling)

## Similar tools

I haven't benchmarked against the following in detail, so treat this
as orientation, not authoritative comparison. Issues / PRs welcome if
my positioning is wrong:

- **[chamber]https://github.com/segmentio/chamber** — SSM-backed
  exec-time env injection. `ssmm exec` is the equivalent mode in `ssmm`
  (same underlying mechanism: decrypt + `execvp` + env overlay). `ssmm`
  adds a 3-segment prefix convention `/<team>/<app>/<key>`, a shared
  namespace, and tag overlays that chamber does not model.
- **[aws-vault]https://github.com/99designs/aws-vault** — exec-time
  AWS credential injection. Different problem (IAM credentials vs. app
  secrets) but the same "no plaintext on disk" philosophy.
- **dotenv-vault** — hosted `.env.vault` format; not AWS-native.
- **HashiCorp Vault** — full secret lifecycle management (leasing,
  rotation, audit). Different tier of tool.

### Choose ssmm when

1. Your team keeps **multiple services** that share a secret namespace
   and you want a prefix convention (`/<team>/<app>/<key>`) with IAM
   policy scoping at the team boundary.
2. You want **both** `.env` file generation (for legacy `EnvironmentFile=`
   consumers) **and** chamber-style exec-time injection from one tool,
   with one IAM policy and one mental model.
3. You want CWD-auto-detection of the app name, a shared namespace for
   cross-app values, and tag-based overlays out of the box.

### Choose something else when

- You need secret rotation, leasing, or audit logs → HashiCorp Vault.
- You're not on AWS → SOPS + age, dotenv-vault, or Doppler.
- Your app needs to fetch secrets at runtime (not at process start) →
  call the AWS Secrets Manager / Parameter Store SDK directly from the
  app.

## Claude Code skill (bundled)

This repo ships a [Claude Code](https://docs.claude.com/en/docs/agents-and-tools/claude-code/overview)
skill at `.claude/skills/ssmm/SKILL.md` covering typical
`put → sync → systemd` workflows, migration patterns, and the
SecureString heuristic override knobs. If you use Claude Code, drop
it in by either symlink or copy:

```bash
# Option 1: symlink (updates follow git pull)
ln -s $(pwd)/.claude/skills/ssmm ~/.claude/skills/ssmm

# Option 2: copy (frozen at clone time)
cp -r .claude/skills/ssmm ~/.claude/skills/ssmm
```

Then `/ssmm` in a Claude Code session will expand into the workflow.

## License

MIT. See [LICENSE](./LICENSE).