diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..24cb1b2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,39 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to a four-digit MAJOR.MINOR.PATCH.MICRO version scheme. + +## [0.1.0.0] - 2026-05-09 + +### Added + +- **Multiple stores per user.** Run `secrets push` and `secrets pull` against any encrypted store directory you choose, not just `~/.secrets/`. Use cases: keep work secrets isolated from personal, run a separate store per client, or onboard a teammate to one project without giving them every other project's keys. +- **`.secrets-store` file** for per-project bindings. Drop a one-line file at the project root (e.g. `echo work > .secrets-store && git add .secrets-store && git commit`) and every machine that clones the project automatically uses `~/.secrets-work/` for that repo. No env var to remember, no per-machine setup. +- **`--store ` flag** for one-shot overrides on any subcommand. `secrets --store ~/.secrets-clientA pull myapp` works without touching files. Bare names like `--store work` expand to `$HOME/.secrets-work`. `--store default` is sugar for `~/.secrets`. +- **`secrets which`** prints the active store path and which rule chose it (flag, `.secrets-store` file, env var, or default). Aliases: `secrets where`, `secrets status`. +- **Directed errors for teammate onboarding.** When `.secrets-store` resolves to an uninitialized store or one missing `key.txt`, the error message names both recovery paths: `git clone ` to join an existing store, or `secrets --store init` to start fresh. +- **Active-store echo.** `secrets push` and `secrets pull` print `==> Store: (from )` whenever a non-default store is active, so wrong-store mistakes surface immediately. + +### Changed + +- `secrets list` now hints at `secrets which` when a non-default store is active. +- `cmd_help` documents the four-rule resolution order (`--store` > `.secrets-store` file > `SECRETS_DIR` > default). +- Error messages for missing init / missing key file are now context-aware: they distinguish between "default store on a fresh machine" and "non-default store referenced by `.secrets-store`." + +### Security + +- **Path expansion in `.secrets-store` is literal-only.** No `eval`, no `$VAR` interpolation, no `$(...)` execution. A committed `.secrets-store` containing `$(rm -rf ~)` reads as plain text, not as a command. +- **Walk-up bounded by `$HOME`.** `secrets` never reads `$HOME/.secrets-store`, never walks past `$HOME` to `/`, and never follows symlinked `.secrets-store` files. Symlinks (potential supply-chain attack via committed link to `~/.aws/credentials` or similar) are ignored. +- **`KEY_FILE` re-derives after `--store` switches stores.** Previously, calling `secrets pull --store other` would have decrypted ciphertext from the new store using the default store's key. Now `secrets` updates both `SECRETS_DIR` and `KEY_FILE` together inside `resolve_store()`. +- **`secrets run` cleanup survives paths with apostrophes.** The EXIT trap is now a named function rather than a string-interpolated command, so projects at e.g. `/Users/you/Mom's Mac/code` still get plaintext cleared after the wrapped command exits. +- **Test isolation:** the bats suite now sets `HOME=$TEST_TMPDIR` so `.secrets-store` walk-up cannot wander into the developer's real home directory. +- **`--store` flag value validation.** Empty values (`--store=`) and flag-shaped values (`--store --workspaces`) are rejected with directed errors instead of silently mapping to `~/.secrets-`-something. +- **Unset `HOME` is detected** with a directed error before the script tries to expand it. Helps cron, sudo without `-H`, and minimal CI runners. + +### Tests + +- 37 → 66 tests. New coverage: store resolution rules and precedence, walk-up boundaries, command-injection prevention, key-file re-derivation across stores, teammate-onboarding error path, monorepo workspace binding, F1–F5 adversarial regressions. + +[0.1.0.0]: https://github.com/bmajewski/secrets/releases/tag/v0.1.0.0 diff --git a/CLAUDE.md b/CLAUDE.md index 461b633..86440b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,6 +45,19 @@ CLAUDE.md # This file `~/.secrets/key.txt` is the age identity (private key). It is gitignored and must be copied manually to each machine once. +## Multi-store resolution + +The active store directory is picked by `resolve_store()` using these rules, highest precedence first: + +1. `--store ` flag (parsed in the main pre-pass into `STORE_OVERRIDE`). +2. `.secrets-store` file in cwd or any ancestor, walk-up bounded by `$HOME` (never reads `$HOME/.secrets-store` itself or anything above). +3. `SECRETS_DIR` env var (legacy escape hatch). +4. `~/.secrets` default. + +`resolve_store` mutates BOTH `SECRETS_DIR` and `KEY_FILE` so the existing single-store code paths just work. `STORE_SOURCE` reports which rule won. `_LAST_FOUND_AT` (when rule 2 fires) holds the path of the file that was read. + +`.secrets-store` parsing is deliberately conservative: first non-empty non-comment line wins, no shell expansion (no `$VAR`, `$()`, backticks). Bare names map via `_expand_store_path`: `work` → `$HOME/.secrets-work`, `default` → `$HOME/.secrets`. + ## Environment variable -`SECRETS_DIR` overrides the default `~/.secrets` location (useful for testing). +`SECRETS_DIR` overrides the default `~/.secrets` location (useful for testing). Per-project bindings via `.secrets-store` file beat this env var; use `--store ` for one-shot overrides that beat everything. diff --git a/README.md b/README.md index 318ad2b..ef3a226 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,95 @@ Stopping the dev server (or any failing command) ends the process; the `EXIT` tr If you prefer to keep decrypted files on disk for a long editing session, use `secrets pull` and `secrets clear` manually instead. +### Multiple stores + +By default, all your encrypted secrets live in one store at `~/.secrets/`. That works great if you have one set of secrets shared across machines. If you want **separate stores** — for example, work secrets isolated from personal projects, or one store per client — `secrets` supports that without any special setup. + +A "store" is just a directory with its own `.git` repo, age key, and remote. You can have as many as you want. + +#### How a store gets picked + +When you run `secrets push` or `secrets pull`, the tool resolves the active store using the first matching rule (highest precedence first): + +``` +1. --store flag passed on the command line +2. .secrets-store file in the current directory or any ancestor up to $HOME +3. SECRETS_DIR environment variable (legacy escape hatch) +4. ~/.secrets default +``` + +Run `secrets which` from any project directory to see which rule won and which store is active. Aliases `secrets where` and `secrets status` do the same thing. + +#### Set up a second store on this machine + +```bash +# Create a fresh store at ~/.secrets-work with its own age key +secrets --store work init + +# Connect it to a separate private GitHub repo +cd ~/.secrets-work +git remote add origin git@github.com:/work-secrets.git +git push -u origin main +``` + +The bare name `work` expands to `$HOME/.secrets-work`. Use `secrets --store /any/abs/path init` if you want a custom location. + +#### Bind a project to a non-default store + +In any project directory, write a `.secrets-store` file with the store's name (or path) and commit it: + +```bash +cd ~/myapp +echo work > .secrets-store +git add .secrets-store +git commit -m "use work secrets store" +``` + +After that, every `secrets push` / `secrets pull` from this project (or any subdirectory) automatically uses `~/.secrets-work`. Teammates who clone the project get the same binding for free — the file is in the repo. + +When you push or pull from a non-default store, `secrets` echoes which one is active so you can spot mistakes immediately: + +``` +==> Pushing secrets for project: myapp +==> Store: /Users/you/.secrets-work (from .secrets-store file (~/myapp/.secrets-store)) +``` + +#### Joining a teammate's bound project + +If you clone a project that has a committed `.secrets-store: work` file but you don't have `~/.secrets-work` set up locally, `secrets pull` will tell you exactly what to do: + +``` +ERROR: Store not initialized: /Users/you/.secrets-work + Resolved from: .secrets-store file (~/myapp/.secrets-store) + This path doesn't exist on this machine yet. + + If you're joining a teammate's existing store: + git clone /Users/you/.secrets-work + # then copy their key.txt to /Users/you/.secrets-work/key.txt + + If you want a fresh new store at this path: + secrets --store /Users/you/.secrets-work init +``` + +You'll need two things from the teammate who set it up: + +1. **The git remote URL** of the work-secrets repo — clone it to `~/.secrets-work` (or wherever the `.secrets-store` file resolves to on your machine). +2. **The age key file** (`key.txt`) — same as standing up any new machine. AirDrop, scp, or USB. + +Once both are in place, `secrets pull` works. + +#### Undo or change a binding + +```bash +# Stop using a non-default store for this project +rm .secrets-store +git commit -am "go back to default secrets store" + +# Or change which store the project is bound to +echo personal > .secrets-store +git commit -am "switch to personal secrets" +``` + ### Monorepo support For projects with multiple packages (monorepos using `package.json` workspaces), add the `-w` flag to operate on all workspaces at once: @@ -273,7 +362,7 @@ For complete rotation with no historical exposure, create a fresh `~/.secrets/` | Variable | Default | Purpose | |----------|---------|---------| -| `SECRETS_DIR` | `~/.secrets` | Override the secrets store location | +| `SECRETS_DIR` | `~/.secrets` | Override the secrets store location (legacy; prefer `--store` or a `.secrets-store` file — see "Multiple stores" above) | ## Troubleshooting diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..482e997 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0.0 diff --git a/secrets b/secrets index a1d92c8..82d0262 100755 --- a/secrets +++ b/secrets @@ -6,10 +6,27 @@ set -euo pipefail # secrets — encrypted env file sync between machines # Uses age key-file encryption + a private git repo. +# F5: explicit HOME check. `set -u` would already error if HOME were unset, +# but the message would be cryptic ("HOME: unbound variable" pointing at +# the SECRETS_DIR default-init line). Check up front with a directed message +# so cron/sudo/CI users know what to fix. +: "${HOME:?HOME is not set; secrets needs a home directory to find or create the store}" + +# Capture the user-provided SECRETS_DIR (if any) before defaulting. +# resolve_store() uses this to honor SECRETS_DIR as the legacy escape hatch +# while letting .secrets-store files take precedence per project. +_USER_SECRETS_DIR="${SECRETS_DIR:-}" SECRETS_DIR="${SECRETS_DIR:-$HOME/.secrets}" KEY_FILE="$SECRETS_DIR/key.txt" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# Set by resolve_store(). Reports which rule chose SECRETS_DIR. +STORE_SOURCE="default" +# Path to the .secrets-store file that won resolution, if any. +_LAST_FOUND_AT="" +# --store flag value, captured by the pre-pass. +STORE_OVERRIDE="" + # ─── Helpers ─────────────────────────────────────────────────────────── die() { echo "ERROR: $*" >&2; exit 1; } @@ -20,11 +37,37 @@ check_cmd() { } check_initialized() { - [ -d "$SECRETS_DIR/.git" ] || die "Not initialized. Run: secrets init" + if [ -d "$SECRETS_DIR/.git" ]; then + return + fi + if [ "$STORE_SOURCE" != "default" ]; then + die "Store not initialized: $SECRETS_DIR + Resolved from: $STORE_SOURCE + This path doesn't exist on this machine yet. + + If you're joining a teammate's existing store: + git clone $SECRETS_DIR + # then copy their key.txt to $SECRETS_DIR/key.txt + + If you want a fresh new store at this path: + secrets --store $SECRETS_DIR init" + fi + die "Not initialized. Run: secrets init" } check_key() { - [ -f "$KEY_FILE" ] || die "Key file not found at $KEY_FILE. Run: secrets init" + if [ -f "$KEY_FILE" ]; then + return + fi + if [ "$STORE_SOURCE" != "default" ]; then + die "Key file not found at $KEY_FILE + The store at $SECRETS_DIR exists but has no key.txt. + Resolved from: $STORE_SOURCE + + If joining a teammate's store: copy their key.txt to $KEY_FILE. + If this is your own new store: re-run init for this store path." + fi + die "Key file not found at $KEY_FILE. Run: secrets init" } get_pubkey() { @@ -48,6 +91,146 @@ derive_project_name() { basename "$PWD" } +# ─── Store resolution ────────────────────────────────────────────────── +# +# Resolution order (highest precedence first): +# 1. --store flag (captured by main pre-pass into STORE_OVERRIDE) +# 2. .secrets-store file in cwd or any ancestor up to but NOT including $HOME +# 3. SECRETS_DIR env var (legacy escape hatch) +# 4. $HOME/.secrets default +# +# .secrets-store file format: first non-empty non-comment line is the store +# spec. Spec is one of: +# - absolute path (/Users/you/.secrets-work) +# - ~/path (expanded to $HOME/path) +# - bare name (e.g. "work" → $HOME/.secrets-work; "default" → $HOME/.secrets) +# Comments (#) and CRLF line endings are tolerated. NO shell expansion is +# applied to the file content — `$VAR`, `$(...)`, and backticks are read as +# literal characters to prevent code injection from a committed file. + +# Take a store spec (path or bare name) and return an absolute directory. +_expand_store_path() { + local path="$1" + case "$path" in + /*) + echo "$path" + ;; + '~') + echo "$HOME" + ;; + '~/'*) + echo "$HOME/${path#\~/}" + ;; + .*|*/*) + # Relative path or path containing /. Treat as path relative to cwd. + echo "$path" + ;; + default) + # Sugar: --store default → the canonical default store + echo "$HOME/.secrets" + ;; + *) + # Bare name like "work" → $HOME/.secrets-work + echo "$HOME/.secrets-$path" + ;; + esac +} + +# Read a .secrets-store file. Print the first non-empty non-comment line, +# trimmed. Return 1 if no usable line is found. +_parse_secrets_store_file() { + local file="$1" + local line + while IFS= read -r line || [ -n "$line" ]; do + # Strip CRLF + line="${line%$'\r'}" + # Trim leading whitespace + line="${line#"${line%%[![:space:]]*}"}" + # Trim trailing whitespace + line="${line%"${line##*[![:space:]]}"}" + [ -z "$line" ] && continue + case "$line" in '#'*) continue ;; esac + printf '%s\n' "$line" + return 0 + done < "$file" + return 1 +} + +# Walk up from cwd looking for .secrets-store. Bounded by $HOME — never +# walks INTO or PAST $HOME. If cwd is outside $HOME entirely (e.g. /tmp), +# the walk does not run. Symlinks are resolved with `cd -P`. +# On success: prints expanded store dir, sets _LAST_FOUND_AT, returns 0. +_find_secrets_store_file() { + local dir + dir=$(pwd -P 2>/dev/null) || dir="$PWD" + local home_resolved + home_resolved=$(cd -P "$HOME" 2>/dev/null && pwd -P) || home_resolved="$HOME" + + while [ -n "$dir" ] && [ "$dir" != "/" ] && [ "$dir" != "$home_resolved" ]; do + # Bound: only walk while we're strictly below $HOME. + case "$dir" in + "$home_resolved"/*) ;; + *) return 1 ;; + esac + + # F2: never follow a symlinked .secrets-store. A committed symlink + # could point at any user-readable file (~/.aws/credentials, /etc/passwd) + # and trick the resolver into reading attacker-chosen content. + if [ -L "$dir/.secrets-store" ]; then + : + elif [ -f "$dir/.secrets-store" ]; then + local content + if content=$(_parse_secrets_store_file "$dir/.secrets-store"); then + local expanded + expanded=$(_expand_store_path "$content") + _LAST_FOUND_AT="$dir/.secrets-store" + printf '%s\n' "$expanded" + return 0 + fi + fi + dir=$(dirname "$dir") + done + return 1 +} + +# Resolve the active store directory and update SECRETS_DIR + KEY_FILE. +# Sets STORE_SOURCE to one of: +# "--store flag" | ".secrets-store file ()" | "SECRETS_DIR env var" | "default" +resolve_store() { + local resolved="" + local source="" + + if [ -n "${STORE_OVERRIDE:-}" ]; then + resolved=$(_expand_store_path "$STORE_OVERRIDE") + source="--store flag" + _LAST_FOUND_AT="" + elif resolved=$(_find_secrets_store_file); then + source=".secrets-store file ($_LAST_FOUND_AT)" + elif [ -n "${_USER_SECRETS_DIR:-}" ]; then + resolved="$_USER_SECRETS_DIR" + source="SECRETS_DIR env var" + _LAST_FOUND_AT="" + else + resolved="$HOME/.secrets" + source="default" + _LAST_FOUND_AT="" + fi + + SECRETS_DIR="$resolved" + KEY_FILE="$SECRETS_DIR/key.txt" + STORE_SOURCE="$source" +} + +# Echo "==> Store: ..." when SECRETS_DIR is not the canonical default. +# Called from cmd_push/cmd_pull (and their workspace variants) after resolution. +echo_store_if_non_default() { + if [ "$SECRETS_DIR" != "$HOME/.secrets" ]; then + info "Store: $SECRETS_DIR (from $STORE_SOURCE)" + fi +} + +# ─── End store resolution ────────────────────────────────────────────── + # Collect secret files from a directory: # .env, .env.*, .dev.vars (excluding .envrc, .environment-*) # Sets the COLLECTED_FILES array. Returns 1 if no files found. @@ -117,6 +300,7 @@ HOOKEOF cmd_init() { check_cmd age check_cmd git + resolve_store if [ -d "$SECRETS_DIR/.git" ]; then die "Already initialized at $SECRETS_DIR. Key file preserved." @@ -214,12 +398,14 @@ commit_and_push_secrets() { cmd_push() { check_cmd age check_cmd git + resolve_store check_initialized check_key local project project=$(derive_project_name "${1:-}") info "Pushing secrets for project: $project" + echo_store_if_non_default local pubkey pubkey=$(get_pubkey) @@ -235,6 +421,7 @@ cmd_push_workspaces() { check_cmd age check_cmd git check_cmd jq + resolve_store check_initialized check_key @@ -242,6 +429,7 @@ cmd_push_workspaces() { local monorepo_name monorepo_name=$(derive_project_name "") info "Pushing workspaces for monorepo: $monorepo_name" + echo_store_if_non_default local pubkey pubkey=$(get_pubkey) @@ -274,6 +462,7 @@ cmd_push_workspaces() { cmd_pull() { check_cmd age check_cmd git + resolve_store check_initialized check_key @@ -281,6 +470,7 @@ cmd_pull() { project=$(derive_project_name "${1:-}") local target_dir="$PWD" info "Pulling secrets for project: $project" + echo_store_if_non_default # Pull latest if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then @@ -344,6 +534,7 @@ cmd_pull_workspaces() { check_cmd age check_cmd git check_cmd jq + resolve_store check_initialized check_key @@ -351,6 +542,7 @@ cmd_pull_workspaces() { local monorepo_name monorepo_name=$(derive_project_name "") info "Pulling workspaces for monorepo: $monorepo_name" + echo_store_if_non_default # Pull latest from remote if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then @@ -396,6 +588,7 @@ cmd_pull_workspaces() { } cmd_list() { + resolve_store check_initialized local found=0 @@ -416,10 +609,17 @@ cmd_list() { if [ "$found" -eq 0 ]; then echo "No projects found. Run 'secrets push ' to add one." fi + + # DX-7 hint: when a non-default store is active, point users at `secrets which`. + if [ "$SECRETS_DIR" != "$HOME/.secrets" ]; then + echo "" + info "Showing projects in $SECRETS_DIR. Run 'secrets which' for details." + fi } cmd_rm() { check_cmd git + resolve_store check_initialized local project="${1:-}" @@ -443,6 +643,7 @@ cmd_rm() { cmd_rekey() { check_cmd age check_cmd git + resolve_store check_initialized check_key @@ -564,6 +765,16 @@ cmd_clear_workspaces() { info "Cleared $total secret file(s) from workspace" } +# EXIT-trap helpers for cmd_run. Defined as functions (not inline string +# traps) so $_RUN_PWD is dereferenced safely regardless of special chars +# in the path. F1: trap "cd '$X'" breaks when X contains a single quote. +_run_cleanup() { + cd -- "${_RUN_PWD:-.}" 2>/dev/null && cmd_clear +} +_run_cleanup_workspaces() { + cd -- "${_RUN_PWD:-.}" 2>/dev/null && cmd_clear_workspaces +} + cmd_run() { local workspace_mode=false local project="" @@ -580,18 +791,26 @@ cmd_run() { [ $# -gt 0 ] || die "Usage: secrets run [-w] [--] " - # Pull secrets + # F8: pin the project directory now so the EXIT trap clears the right + # plaintext files even if the user's command does `cd` into another dir. + # Use a global + named function (NOT string-interpolated trap) so paths + # with special characters — apostrophes, dollar signs, spaces — work. + # Single-quoting `$_RUN_PWD` into a string trap would break on any path + # with a single quote (e.g. /Users/bri/it's-app), and the trap would + # silently fail to clean up plaintext secrets. EGB-281 F1. + _RUN_PWD="$PWD" + + # Pull secrets (cmd_pull/cmd_pull_workspaces call resolve_store internally) if [ "$workspace_mode" = true ]; then cmd_pull_workspaces else cmd_pull "$project" fi - # Set trap to clear secrets on exit (normal, error, interrupt, terminate) if [ "$workspace_mode" = true ]; then - trap 'cmd_clear_workspaces' EXIT + trap _run_cleanup_workspaces EXIT else - trap 'cmd_clear' EXIT + trap _run_cleanup EXIT fi # Execute the command, capturing exit code (don't let set -e kill us) @@ -600,6 +819,12 @@ cmd_run() { exit "$rc" } +cmd_which() { + resolve_store + echo "store: $SECRETS_DIR" + echo "source: $STORE_SOURCE" +} + cmd_help() { cat << 'EOF' secrets — encrypted secret file sync between machines @@ -616,12 +841,28 @@ Usage: secrets list List all projects and their secret files secrets rm Remove a project's secrets from the repo secrets rekey Re-encrypt all secrets with a new key + secrets which Show the active store path and which rule chose it + secrets where Alias for `which` + secrets status Alias for `which` Tracked files: .env, .env.*, .dev.vars If [project] is omitted, it is derived from the current directory's git remote (if available) or the directory name. +Stores: + Most users have one ~/.secrets/ store. To use a separate store (e.g. + for work secrets vs personal), use any of these resolution rules + (highest precedence first): + + 1. --store flag secrets --store ~/.secrets-work pull + 2. .secrets-store file in project echo work > .secrets-store && git add ... + 3. SECRETS_DIR env var (legacy) SECRETS_DIR=~/.secrets-work secrets pull + 4. ~/.secrets default + + Bare names ("work") expand to ~/.secrets-work. The name "default" + resolves to ~/.secrets. Run `secrets which` to inspect the active store. + Workspaces: With -w/--workspaces, reads package.json "workspaces" field to find workspace directories. Each workspace's secret files are stored under @@ -629,12 +870,58 @@ Workspaces: are stored under / directly. Requires jq. Environment: - SECRETS_DIR Path to secrets repo (default: ~/.secrets) + SECRETS_DIR Path to secrets repo (default: ~/.secrets). See also + the .secrets-store file and --store flag above. EOF } # ─── Main ────────────────────────────────────────────────────────────── +# Pre-pass: extract --store flag from anywhere in the args (before `--` only). +# Lets `secrets --store work push`, `secrets push --store work`, and +# `secrets run --store work cmd ...` all work consistently. After `--`, +# args belong to the user's command and are passed through untouched. +ARGS=() +while [ $# -gt 0 ]; do + case "$1" in + --) + # Stop pre-pass; pass `--` and everything after through untouched + # so the user's command (e.g. `secrets run -- cmd --store foo`) is + # not mangled. + ARGS+=("$@") + break + ;; + --store) + [ $# -ge 2 ] || die "--store requires a directory or store name" + # F3: reject values that look like another flag — almost always a typo + # (`secrets --store push` → user dropped the value, would silently use + # ~/.secrets-push and surface a confusing "not initialized" error). + case "$2" in + --|-*) die "--store value looks like a flag: $2 (did you forget the value?)" ;; + esac + STORE_OVERRIDE="$2" + shift 2 + ;; + --store=*) + # F4: --store= with empty value used to silently fall through to the + # next rule. Treat it as a typo too. + [ -n "${1#--store=}" ] || die "--store= requires a value" + STORE_OVERRIDE="${1#--store=}" + shift + ;; + *) + ARGS+=("$1") + shift + ;; + esac +done +# Re-set positional params. Bulletproof against empty array under `set -u`. +if [ ${#ARGS[@]} -gt 0 ]; then + set -- "${ARGS[@]}" +else + set -- +fi + case "${1:-help}" in init) cmd_init ;; push) @@ -665,6 +952,7 @@ case "${1:-help}" in list) cmd_list ;; rm) cmd_rm "${2:-}" ;; rekey) cmd_rekey ;; + which|where|status) cmd_which ;; help|--help|-h) cmd_help ;; *) die "Unknown command: $1. Run 'secrets help' for usage." ;; esac diff --git a/test/secrets.bats b/test/secrets.bats index 504c374..79aafef 100644 --- a/test/secrets.bats +++ b/test/secrets.bats @@ -519,3 +519,356 @@ EOF [ "$status" -eq 1 ] [[ "$output" == *"No secret files"* ]] } + +# ─── EGB-281: multi-store resolution ────────────────────────────────── + +@test "which reports default store when no overrides" { + unset SECRETS_DIR + cd "$HOME" + mkdir -p subdir + cd subdir + run "$SECRETS_BIN" which + [ "$status" -eq 0 ] + [[ "$output" == *"$HOME/.secrets"* ]] + [[ "$output" == *"source: default"* ]] +} + +@test "which uses .secrets-store file in cwd" { + unset SECRETS_DIR + mkdir -p "$HOME/.secrets-work" + create_bound_project_dir myapp "~/.secrets-work" + run "$SECRETS_BIN" which + [ "$status" -eq 0 ] + [[ "$output" == *"$HOME/.secrets-work"* ]] + [[ "$output" == *".secrets-store file"* ]] +} + +@test "--store flag overrides .secrets-store file and SECRETS_DIR env" { + export SECRETS_DIR="$HOME/.secrets-from-env" + create_bound_project_dir myapp "~/.secrets-from-file" + run "$SECRETS_BIN" --store "$HOME/.secrets-from-flag" which + [ "$status" -eq 0 ] + [[ "$output" == *"$HOME/.secrets-from-flag"* ]] + [[ "$output" == *"--store flag"* ]] +} + +@test "which walks up to find .secrets-store in ancestor" { + unset SECRETS_DIR + mkdir -p "$HOME/.secrets-work" + mkdir -p "$WORK_DIR/repo/sub/deep" + echo "~/.secrets-work" > "$WORK_DIR/repo/.secrets-store" + cd "$WORK_DIR/repo/sub/deep" + run "$SECRETS_BIN" which + [ "$status" -eq 0 ] + [[ "$output" == *"$HOME/.secrets-work"* ]] +} + +@test "which walk-up stops at HOME boundary, does not read \$HOME/.secrets-store" { + unset SECRETS_DIR + echo "should-not-be-used" > "$HOME/.secrets-store" + mkdir -p "$WORK_DIR/repo" + cd "$WORK_DIR/repo" + run "$SECRETS_BIN" which + [ "$status" -eq 0 ] + [[ "$output" != *"should-not-be-used"* ]] + [[ "$output" == *"$HOME/.secrets"* ]] + [[ "$output" == *"source: default"* ]] +} + +@test "which from outside HOME falls through to default" { + unset SECRETS_DIR + cd /tmp + run "$SECRETS_BIN" which + [ "$status" -eq 0 ] + [[ "$output" == *"$HOME/.secrets"* ]] + [[ "$output" == *"source: default"* ]] +} + +@test "empty .secrets-store falls through to next rule" { + unset SECRETS_DIR + mkdir -p "$WORK_DIR/repo" + cd "$WORK_DIR/repo" + : > .secrets-store + run "$SECRETS_BIN" which + [ "$status" -eq 0 ] + [[ "$output" == *"source: default"* ]] +} + +@test "comment-only .secrets-store falls through" { + unset SECRETS_DIR + mkdir -p "$WORK_DIR/repo" + cd "$WORK_DIR/repo" + printf '# this is a comment\n \n# another\n' > .secrets-store + run "$SECRETS_BIN" which + [ "$status" -eq 0 ] + [[ "$output" == *"source: default"* ]] +} + +@test "bare name 'work' resolves to ~/.secrets-work" { + unset SECRETS_DIR + mkdir -p "$HOME/.secrets-work/.git" + mkdir -p "$WORK_DIR/repo" + echo "work" > "$WORK_DIR/repo/.secrets-store" + cd "$WORK_DIR/repo" + run "$SECRETS_BIN" which + [ "$status" -eq 0 ] + [[ "$output" == *"$HOME/.secrets-work"* ]] +} + +@test "~/-prefix in .secrets-store expands to HOME" { + unset SECRETS_DIR + mkdir -p "$HOME/.secrets-x" + mkdir -p "$WORK_DIR/repo" + echo "~/.secrets-x" > "$WORK_DIR/repo/.secrets-store" + cd "$WORK_DIR/repo" + run "$SECRETS_BIN" which + [ "$status" -eq 0 ] + [[ "$output" == *"$HOME/.secrets-x"* ]] +} + +@test ".secrets-store with command injection content does not execute" { + unset SECRETS_DIR + mkdir -p "$WORK_DIR/repo" + cd "$WORK_DIR/repo" + local pwn_marker="/tmp/secrets-pwn-$$" + rm -f "$pwn_marker" + echo "\$(touch $pwn_marker)" > .secrets-store + run "$SECRETS_BIN" which + [ ! -f "$pwn_marker" ] +} + +@test "pull --store uses the target store's key, not the default key" { + unset SECRETS_DIR + # Set up store A (default) with its own key + "$SECRETS_BIN" init >/dev/null 2>&1 + cd "$HOME/.secrets" && git remote add origin "$REMOTE_DIR" && cd - + + # Set up store B at a different path, with its OWN key + local STORE_B="$HOME/.secrets-b" + "$SECRETS_BIN" --store "$STORE_B" init >/dev/null 2>&1 + + # Push secrets to STORE B using B's key + create_project_dir testproj + "$SECRETS_BIN" --store "$STORE_B" push testproj >/dev/null 2>&1 + + # Pull to a new dir using --store B (must use B's key.txt, not the default) + local pull_dir="$WORK_DIR/pull-target" + mkdir -p "$pull_dir" + cd "$pull_dir" + run "$SECRETS_BIN" --store "$STORE_B" pull testproj + [ "$status" -eq 0 ] + [ -f "$pull_dir/.env" ] + grep -q "SECRET_KEY=abc123" "$pull_dir/.env" +} + +@test "run accepts --store flag" { + init_with_remote + create_project_dir testproj + "$SECRETS_BIN" push testproj >/dev/null 2>&1 + rm "$WORK_DIR/testproj/.env" "$WORK_DIR/testproj/.env.staging" + + run "$SECRETS_BIN" --store "$SECRETS_DIR" run -- cat .env + [ "$status" -eq 0 ] + [[ "$output" == *"SECRET_KEY=abc123"* ]] +} + +@test "uninitialized store referenced by .secrets-store gives directed error" { + unset SECRETS_DIR + "$SECRETS_BIN" init >/dev/null 2>&1 + mkdir -p "$WORK_DIR/repo" + echo "missing-store" > "$WORK_DIR/repo/.secrets-store" + cd "$WORK_DIR/repo" + + run "$SECRETS_BIN" pull + [ "$status" -eq 1 ] + [[ "$output" == *"git clone"* ]] + [[ "$output" == *"--store"* ]] +} + +@test "push -w ignores per-workspace .secrets-store, uses monorepo root binding" { + unset SECRETS_DIR + mkdir -p "$HOME/.secrets-monorepo" + "$SECRETS_BIN" --store "$HOME/.secrets-monorepo" init >/dev/null 2>&1 + cd "$HOME/.secrets-monorepo" && git remote add origin "$REMOTE_DIR" && \ + git commit --allow-empty -m "init" >/dev/null 2>&1 && \ + (git push -u origin main >/dev/null 2>&1 || git push -u origin master >/dev/null 2>&1) && \ + cd - + + local mono="$WORK_DIR/myapp" + mkdir -p "$mono/apps/web" + cat > "$mono/package.json" << 'PKG' +{"workspaces": ["apps/*"]} +PKG + echo "ROOT=top" > "$mono/.env" + echo "WEB=val" > "$mono/apps/web/.env" + echo "monorepo" > "$mono/.secrets-store" + echo "other-store" > "$mono/apps/web/.secrets-store" + git init "$mono" >/dev/null 2>&1 + cd "$mono" + + run "$SECRETS_BIN" push -w + [ "$status" -eq 0 ] + [ -f "$HOME/.secrets-monorepo/myapp/.env.age" ] + [ -f "$HOME/.secrets-monorepo/myapp/apps/web/.env.age" ] + [ ! -d "$HOME/.secrets-other-store" ] +} + +@test "push echoes Store info when non-default store is active" { + unset SECRETS_DIR + mkdir -p "$HOME/.secrets-work" + "$SECRETS_BIN" --store "$HOME/.secrets-work" init >/dev/null 2>&1 + cd "$HOME/.secrets-work" && git remote add origin "$REMOTE_DIR" && \ + git commit --allow-empty -m "init" >/dev/null 2>&1 && \ + (git push -u origin main >/dev/null 2>&1 || git push -u origin master >/dev/null 2>&1) && \ + cd - + + create_project_dir myapp + run "$SECRETS_BIN" --store "$HOME/.secrets-work" push myapp + [ "$status" -eq 0 ] + [[ "$output" == *"Store: $HOME/.secrets-work"* ]] +} + +# ─── EGB-281: gap-filler tests (auto-decided during /ship coverage audit) ─ + +@test "--store with missing argument errors out" { + run "$SECRETS_BIN" --store + [ "$status" -eq 1 ] + [[ "$output" == *"--store requires"* ]] +} + +@test "--store=value (equals form) is accepted" { + unset SECRETS_DIR + mkdir -p "$HOME/.secrets-equals" + cd "$HOME" + run "$SECRETS_BIN" --store="$HOME/.secrets-equals" which + [ "$status" -eq 0 ] + [[ "$output" == *"$HOME/.secrets-equals"* ]] +} + +@test "where and status are aliases of which" { + unset SECRETS_DIR + cd "$HOME" + mkdir -p subdir + cd subdir + run "$SECRETS_BIN" where + [ "$status" -eq 0 ] + [[ "$output" == *"source:"* ]] + + run "$SECRETS_BIN" status + [ "$status" -eq 0 ] + [[ "$output" == *"source:"* ]] +} + +@test "--store default sugar resolves to ~/.secrets" { + unset SECRETS_DIR + cd "$HOME" + run "$SECRETS_BIN" --store default which + [ "$status" -eq 0 ] + [[ "$output" == *"$HOME/.secrets"* ]] +} + +@test "missing key.txt in non-default store gives directed error" { + unset SECRETS_DIR + # Make a "store" directory with .git but no key.txt — simulates a teammate + # who cloned the remote but hasn't received the key yet. + local STORE_NOKEY="$HOME/.secrets-nokey" + mkdir -p "$STORE_NOKEY" + git init "$STORE_NOKEY" >/dev/null 2>&1 + mkdir -p "$WORK_DIR/proj" + echo "nokey" > "$WORK_DIR/proj/.secrets-store" + echo "VAL=x" > "$WORK_DIR/proj/.env" + cd "$WORK_DIR/proj" + + run "$SECRETS_BIN" push myapp + [ "$status" -eq 1 ] + [[ "$output" == *"key.txt"* ]] + [[ "$output" == *"teammate"* ]] +} + +@test "CRLF line endings in .secrets-store are tolerated" { + unset SECRETS_DIR + mkdir -p "$HOME/.secrets-crlf" + mkdir -p "$WORK_DIR/proj" + printf '~/.secrets-crlf\r\n' > "$WORK_DIR/proj/.secrets-store" + cd "$WORK_DIR/proj" + run "$SECRETS_BIN" which + [ "$status" -eq 0 ] + [[ "$output" == *"$HOME/.secrets-crlf"* ]] +} + +@test "list hints at 'secrets which' when non-default store is active" { + unset SECRETS_DIR + mkdir -p "$HOME/.secrets-x" + "$SECRETS_BIN" --store "$HOME/.secrets-x" init >/dev/null 2>&1 + + run "$SECRETS_BIN" --store "$HOME/.secrets-x" list + [ "$status" -eq 0 ] + [[ "$output" == *"secrets which"* ]] +} + +# ─── EGB-281: adversarial-review regression tests (F1-F5) ───────────── + +@test "F1: cmd_run cleans up plaintext when project path contains apostrophe" { + init_with_remote + # Project dir whose name contains a single quote — string-interpolated trap + # form would close its quoting early on this path and silently fail to clean up. + local QUOTED_DIR="$WORK_DIR/dont-leak'apostrophe-test" + mkdir -p "$QUOTED_DIR" + echo "SECRET_KEY=should-not-leak" > "$QUOTED_DIR/.env" + cd "$QUOTED_DIR" + # Push with the auto-derived project name (matches the dir name, including the ') + "$SECRETS_BIN" push >/dev/null 2>&1 + rm "$QUOTED_DIR/.env" + + # Run a command, then verify .env is cleared by the EXIT trap + run "$SECRETS_BIN" run -- cat .env + [ "$status" -eq 0 ] + [[ "$output" == *"should-not-leak"* ]] + # CRITICAL: the trap must have cleaned up — .env must NOT exist on disk. + # If F1 regressed (string-interpolated trap), the file would still be here. + [ ! -f "$QUOTED_DIR/.env" ] +} + +@test "F2: symlinked .secrets-store is skipped, not followed" { + unset SECRETS_DIR + # Create a sensitive-looking target outside the project + local TARGET="$HOME/.secret-target" + echo "/etc/passwd" > "$TARGET" + mkdir -p "$WORK_DIR/proj" + ln -s "$TARGET" "$WORK_DIR/proj/.secrets-store" + cd "$WORK_DIR/proj" + run "$SECRETS_BIN" which + [ "$status" -eq 0 ] + # The symlink should be ignored, falling through to default + [[ "$output" != *"/etc/passwd"* ]] + [[ "$output" == *"$HOME/.secrets"* ]] + [[ "$output" == *"source: default"* ]] +} + +@test "F3: --store rejects flag-shaped value" { + run "$SECRETS_BIN" --store --workspaces which + [ "$status" -eq 1 ] + [[ "$output" == *"looks like a flag"* ]] +} + +@test "F3: --store rejects literal --" { + run "$SECRETS_BIN" --store -- which + [ "$status" -eq 1 ] + [[ "$output" == *"looks like a flag"* ]] +} + +@test "F4: --store= empty value is rejected" { + run "$SECRETS_BIN" --store= which + [ "$status" -eq 1 ] + [[ "$output" == *"requires a value"* ]] +} + +@test "F5: HOME unset gives directed error" { + # Capture current HOME so we can restore for teardown + local SAVED_HOME="$HOME" + unset HOME + run "$SECRETS_BIN" which + export HOME="$SAVED_HOME" # restore before assertions in case bats relies on it + [ "$status" -ne 0 ] + [[ "$output" == *"HOME"* ]] +} diff --git a/test/test_helper.bash b/test/test_helper.bash index 4da4265..1470caf 100644 --- a/test/test_helper.bash +++ b/test/test_helper.bash @@ -14,11 +14,18 @@ setup() { export TEST_TMPDIR TEST_TMPDIR=$(mktemp -d) + # Isolate HOME so .secrets-store walk-up cannot stray into the real + # developer's home directory (or pick up files in / if HOME happens to + # not be a real ancestor of /tmp). EGB-281 F9. + export HOME="$TEST_TMPDIR" + # Secrets repo lives in temp export SECRETS_DIR="$TEST_TMPDIR/secrets-repo" - # Working directory for simulating project dirs - export WORK_DIR="$TEST_TMPDIR/work" + # Working directory for simulating project dirs (kept under $HOME so + # the .secrets-store walk-up logic, which is bounded by $HOME, can find + # files placed in test fixtures). + export WORK_DIR="$HOME/work" mkdir -p "$WORK_DIR" # Create a bare "remote" repo for push/pull testing @@ -50,3 +57,14 @@ create_project_dir() { echo "DB_HOST=staging.db.example.com" > "$dir/.env.staging" cd "$dir" } + +# Helper: create a project dir bound to a store via .secrets-store file +create_bound_project_dir() { + local name="$1" + local store_value="$2" + local dir="$WORK_DIR/$name" + mkdir -p "$dir" + echo "SECRET_KEY=abc123" > "$dir/.env" + echo "$store_value" > "$dir/.secrets-store" + cd "$dir" +}