v0.2.0.0 feat: sync gradle.properties keys via .secrets-files (EGB-531)
Add a committed .secrets-files manifest that lets secrets track designated keys from files outside the project root (motivating case: ~/.gradle/gradle.properties for Android Clerk publishable keys, which Android Studio GUI builds read but terminal env vars can't reach). - push extracts only the named keys, encrypts under <project>/external/ - pull MERGES them into the target, preserving unrelated keys/comments/order - pure-bash merge (no sed/regex): exact-string key match, opaque values - path validator: basename gradle.properties, within $HOME, no symlink/.. - external/ subdir keeps blobs out of the dotenv *.age globs; rekey + list recurse explicitly - which reads back the manifest; list shows [external]; pre-commit blocks plaintext gradle.properties Also fixes two latent bugs in 'secrets rekey' (never completed before, no prior test): age-keygen refusing to overwrite key.txt, and an EXIT trap referencing an out-of-scope local under set -u. Tests: 80 -> 104. Reviewed via /autoplan (CEO/Eng/DX). EGB-531.
This commit is contained in:
parent
ac2195d830
commit
110ac514cc
7 changed files with 816 additions and 13 deletions
25
CHANGELOG.md
25
CHANGELOG.md
|
|
@ -5,6 +5,31 @@ 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.2.0.0] - 2026-05-26
|
||||
|
||||
### Added
|
||||
|
||||
- **Sync designated keys from external files (Gradle properties).** A new committed `.secrets-files` manifest lets `secrets` track specific keys from files that live *outside* the project root — the motivating case being `~/.gradle/gradle.properties`, where Android builds read Clerk publishable keys (`beaconClerkPkTest`, `beaconClerkPkLive`) that Android Studio's GUI builds can only get from that persistent global file, not from terminal env vars. One entry per line: `gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest beaconClerkPkLive`. (EGB-531)
|
||||
- **push** extracts only the named keys and encrypts them under `<project>/external/` in the store.
|
||||
- **pull** *merges* those keys into the target file, preserving every unrelated key, comment, and line order. An existing managed key is updated in place; the target is backed up to `gradle.properties.secrets-bak` before the first merge.
|
||||
- `secrets which` reads back the parsed manifest; `secrets list` shows `[external]` entries; `secrets rekey` re-encrypts external blobs alongside dotenv ones.
|
||||
- Backward compatible: no `.secrets-files` → identical behavior to before.
|
||||
|
||||
### Security
|
||||
|
||||
- **The merge is pure bash with exact-string key matching — no `sed`/regex.** This is deliberate: a `sed`-based substitution would corrupt values containing `&`, `\`, or `/` (common in API keys) and would false-match substring keys (`beaconClerkPk` vs `beaconClerkPkTest`). Values are treated as opaque literals and round-trip byte-exact.
|
||||
- **External write targets are validated against an attacker-controlled path.** Because the target path comes from a *committed* manifest, the writable target is locked down: basename must be `gradle.properties`, the path must resolve inside `$HOME`, `..` traversal is rejected, and symlinked targets (file or parent dir) are refused — blocking a malicious manifest from appending decrypted keys to `~/.gitconfig`, `~/.bashrc`, etc. Manifest parsing rejects shell metacharacters and control characters in paths and keys, mirroring the `.secrets-store` posture. Writes are atomic (temp-in-same-dir + rename), mode-preserving, and default to `600` on create.
|
||||
- **Storage isolation.** External blobs live in a `<project>/external/` subdir so the existing broad `*.age` globs (pull, list, rekey) structurally never decrypt them into the working directory or orphan them.
|
||||
- **Note on plaintext.** Merged Gradle keys are written as permanent plaintext into the target file (`secrets clear` does not remove them) — appropriate for publishable/low-secrecy values like Clerk publishable keys, by design.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`secrets rekey` was broken and never completed.** Two latent bugs, exposed by the new rekey test: (1) `age-keygen -o key.txt` aborts because age-keygen refuses to overwrite an existing file — the new key is now generated into a temp dir and moved into place only on success, so the old key survives a failed rotation; (2) the `EXIT` trap referenced the function-local `$tmpdir` after the function returned, erroring under `set -u` and leaking the plaintext temp dir — the temp dir is now removed explicitly and the trap cleared on normal completion.
|
||||
|
||||
### Tests
|
||||
|
||||
- 80 → 104 (+24). New coverage: manifest parse/read-back, key extraction across `=`/`:`/space separators, merge (preserve unrelated/comments/order, substring-key isolation, sed-metachar value round-trip, duplicate-key collapse, continuation-line safety, idempotency), path validation (wrong basename, outside `$HOME`, symlink), first-create mode `600`, manifest injection/symlink rejection, rekey round-trip of external blobs, glob isolation (blob not leaked to cwd), `list` surfacing, pre-commit blocking plaintext `gradle.properties`, and backward compatibility.
|
||||
|
||||
## [0.1.1.0] - 2026-05-09
|
||||
|
||||
### Added
|
||||
|
|
|
|||
21
CLAUDE.md
21
CLAUDE.md
|
|
@ -26,16 +26,18 @@ Single bash script (`secrets`) with subcommands: init, push, pull, list, rm, rek
|
|||
- Encryption: `age` with key files (not passphrases — age passphrases are non-scriptable)
|
||||
- Storage: Private git repo at `~/.secrets/`
|
||||
- Convention: Tracks `.env`, `.env.*`, and `.dev.vars` (not `.envrc`, `.environment-*`)
|
||||
- External files: `.secrets-files` manifest tracks designated keys from files outside the project (e.g. `~/.gradle/gradle.properties`) — merged, not overwritten (EGB-531, see below)
|
||||
- Workspaces: `--workspaces` flag reads `package.json` workspaces, requires `jq`
|
||||
- Safety: Pre-commit hook rejects plaintext secret files
|
||||
- Safety: Pre-commit hook rejects plaintext secret files (`.env`, `.dev.vars`, `gradle.properties`)
|
||||
- Portability: must run on system bash 3.2 (macOS) — no associative arrays or bash-4 features
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
secrets # CLI script (~300 lines bash)
|
||||
secrets # CLI script (~600 lines bash)
|
||||
hooks/pre-commit # Pre-commit hook template
|
||||
test/
|
||||
secrets.bats # bats-core test suite (25 tests)
|
||||
secrets.bats # bats-core test suite (104 tests)
|
||||
test_helper.bash # Shared setup/teardown
|
||||
README.md # User-facing documentation
|
||||
CLAUDE.md # This file
|
||||
|
|
@ -58,6 +60,19 @@ The active store directory is picked by `resolve_store()` using these rules, hig
|
|||
|
||||
`.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`. An optional remote URL after the spec on the same line is captured as `_REMOTE_URL` and passed through to `check_initialized`, which uses it to fill in a runnable `git clone <url> <path>` in the missing-store error (EGB-282). The URL is parsed via `read -r spec rest` (no `set -- $line`, no glob expansion) and then **sanitized**: any URL containing shell metacharacters (`;&|<>$\`(){}*?!"'\\`), control characters (incl. ANSI escapes), or whitespace is dropped with a stderr warning. The directed error then falls back to the `<their-store-remote>` placeholder. This matters because the rendered `git clone` line is meant to be copy-pasted by a teammate — without sanitization, `work evil.git;rm -rf ~` would render verbatim and execute the payload on paste. Internal flow: `_parse_secrets_store_file` returns `<spec>\t<url>`; `_find_secrets_store_file` returns `<dir>\t<source-path>\t<url>`; `resolve_store` splits the 3-tuple via `IFS=$'\t' read -r ...`.
|
||||
|
||||
## External files (.secrets-files) — EGB-531
|
||||
|
||||
`.secrets-files` is a committed, project-root manifest declaring keys to sync from files **outside** the project (motivating case: `~/.gradle/gradle.properties`, which Android Studio GUI builds read but terminal env vars can't reach). One entry per line: `<type> <path> <key>...`. Only type `gradle-properties` is supported; the type token leaves room for future types **without** a plugin-dispatch framework (build the concrete case — a deliberate scope cut).
|
||||
|
||||
Key design decisions (all driven by /autoplan review):
|
||||
|
||||
- **Wire-in is at command scope** (`cmd_push`/`cmd_pull`), via `push_external_files` / `pull_external_files`, **not** inside `push_dir_to_project` / `pull_project_to_dir` (those loop per-workspace and `pull_project_to_dir` uses stdout as a data channel).
|
||||
- **Storage:** blobs live in `$SECRETS_DIR/<project>/external/<slug>.gradle-properties.age`. The `external/` subdir keeps them out of the existing non-recursive `*.age` / `.*.age` globs (pull, list, rekey), so the old dotenv path can never decrypt a blob into cwd. `cmd_rekey` and `cmd_list` recurse into `external/` explicitly (rekey MUST, or the blob is orphaned after rotation = data loss). `<slug>` = manifest path token with non-`[A-Za-z0-9._-]` chars → `_`, so it's machine-independent.
|
||||
- **Merge is pure bash, no `sed`/regex** (`merge_gradle_keys`): exact-string key comparison (avoids `beaconClerkPk` vs `beaconClerkPkTest` substring bug), value treated as opaque literal (survives `& \ /` in values). Updates a managed key in place at its first occurrence, collapses duplicates, appends new keys, preserves unrelated lines/comments/order. Continuation lines (trailing odd backslashes, tracked by `_trailing_bs_odd`) are never matched as keys. Atomic write: temp in the same dir → `chmod` to match (or `600` on create) → `mv`. Backs up to `<target>.secrets-bak` before the first merge.
|
||||
- **Properties separator parsing** (`_props_get`): key ends at the first `=`, `:`, or whitespace (after lstrip); handles `key=value`, `key = value`, `key:value`, `key value`; last definition wins.
|
||||
- **Security:** the write target comes from a committed file, so `_validate_external_target_path` locks it down — basename must be `gradle.properties`, must resolve inside `$HOME` (deepest-existing-ancestor resolved, symlink target/parent refused, `..` rejected). This blocks a malicious manifest from appending decrypted keys to `~/.gitconfig`/`~/.bashrc`. `_parse_secrets_files_manifest` rejects shell metacharacters/control chars in path and keys (path allows `[A-Za-z0-9/._~-]` only; keys allow `[A-Za-z0-9._-]` + space), mirrors the `.secrets-store` posture (no shell expansion, symlinked manifest skipped).
|
||||
- **Plaintext tradeoff (accepted, documented):** merged keys are permanent plaintext in the target; `secrets clear` does not remove them. Fine for the Clerk *publishable* keys this was built for; not for high-value secrets (use `secrets run` + `.env`).
|
||||
|
||||
## Deploy Configuration
|
||||
|
||||
- Platform: NONE (distributed via `git clone` from GitHub)
|
||||
|
|
|
|||
45
README.md
45
README.md
|
|
@ -54,6 +54,8 @@ flowchart TD
|
|||
|
||||
Files like `.envrc` (direnv) and `.environment-*` are intentionally **not** tracked.
|
||||
|
||||
Beyond project files, `secrets` can also sync designated keys from files that live *outside* the project — like `~/.gradle/gradle.properties` — merging them in without clobbering unrelated keys. See [External files (Gradle properties)](#external-files-gradle-properties).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **macOS** (uses Homebrew for installation)
|
||||
|
|
@ -346,6 +348,47 @@ Inside `~/.secrets/`, workspace secrets are organized by path:
|
|||
|
||||
Requires `jq` (`brew install jq`).
|
||||
|
||||
### External files (Gradle properties)
|
||||
|
||||
Some credentials don't live in your project at all. Android builds, for example, read keys from `~/.gradle/gradle.properties` — a global file, outside any project, shared by every Gradle project on your machine (the project's own `gradle.properties` is git-tracked, so it's the wrong home for secrets). `secrets` can sync specific keys from such a file without touching the unrelated keys around them.
|
||||
|
||||
You declare what to sync in a committed `.secrets-files` manifest at your project root, one entry per line:
|
||||
|
||||
```
|
||||
# <type> <path> <keys...>
|
||||
gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest beaconClerkPkLive
|
||||
```
|
||||
|
||||
- **type** — `gradle-properties` (the only supported type today).
|
||||
- **path** — absolute or `~/`-relative. The basename must be `gradle.properties` and must resolve inside `$HOME`.
|
||||
- **keys** — the property names to sync. Only these keys are read on push and merged on pull; everything else in the file is left alone.
|
||||
|
||||
#### Syncing to a second machine
|
||||
|
||||
On the machine that already has the keys set:
|
||||
|
||||
```bash
|
||||
cd ~/myapp
|
||||
echo "gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest beaconClerkPkLive" > .secrets-files
|
||||
git add .secrets-files && git commit -m "sync gradle Clerk keys"
|
||||
secrets push
|
||||
# ==> Extracted 2 key(s) from ~/.gradle/gradle.properties
|
||||
```
|
||||
|
||||
`secrets push` extracts just those keys, encrypts them, and stores them under `<project>/external/` in your secrets repo. Run `secrets which` from the project to confirm the manifest parsed.
|
||||
|
||||
On the other machine (after the usual key + repo setup):
|
||||
|
||||
```bash
|
||||
cd ~/myapp
|
||||
secrets pull
|
||||
# ==> Merged 2 key(s) into ~/.gradle/gradle.properties (beaconClerkPkTest, beaconClerkPkLive)
|
||||
```
|
||||
|
||||
`secrets pull` merges those keys into the local `~/.gradle/gradle.properties`, leaving every other key untouched. If a managed key already exists, its value is updated in place; comments, ordering, and unrelated entries are preserved. The file is backed up to `gradle.properties.secrets-bak` before the first merge.
|
||||
|
||||
> **Note:** unlike `.env` files, merged Gradle keys are written as **permanent plaintext** into the target file — `secrets clear` does **not** remove them. This is appropriate for publishable / low-secrecy values (like Clerk publishable keys, `pk_*`). For high-value secrets that should never sit on disk, use `secrets run` with a `.env` instead.
|
||||
|
||||
## Safety features
|
||||
|
||||
- **`secrets run` auto-clears** — plaintext files are deleted when the command exits, errors, or is interrupted with Ctrl-C
|
||||
|
|
@ -389,7 +432,7 @@ For complete rotation with no historical exposure, create a fresh `~/.secrets/`
|
|||
## Development
|
||||
|
||||
```bash
|
||||
# Run the test suite (37 tests)
|
||||
# Run the test suite (104 tests)
|
||||
brew install bats-core
|
||||
bats test/secrets.bats
|
||||
```
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.1.1.0
|
||||
0.2.0.0
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
#!/usr/bin/env bash
|
||||
# Pre-commit hook for the secrets repo.
|
||||
# Rejects staged files matching .env patterns without .age extension.
|
||||
# Rejects staged plaintext secret files (.env, .dev.vars, gradle.properties)
|
||||
# without a .age extension.
|
||||
# This is a safety net, not a security boundary (--no-verify bypasses it).
|
||||
|
||||
BLOCKED=$(git diff --cached --name-only | grep -E '\.(env|dev\.vars)' | grep -v '\.age$' || true)
|
||||
BLOCKED=$(git diff --cached --name-only | grep -E '(\.env|\.dev\.vars|gradle\.properties)' | grep -v '\.age$' || true)
|
||||
if [ -n "$BLOCKED" ]; then
|
||||
echo "ERROR: Plaintext secret files staged for commit:"
|
||||
echo "$BLOCKED"
|
||||
|
|
|
|||
427
secrets
427
secrets
|
|
@ -319,6 +319,325 @@ collect_env_files() {
|
|||
[ ${#COLLECTED_FILES[@]} -gt 0 ]
|
||||
}
|
||||
|
||||
# ─── External files (.secrets-files) — EGB-531 ─────────────────────────
|
||||
#
|
||||
# A committed, project-local `.secrets-files` manifest declares files that
|
||||
# live OUTSIDE the project root (e.g. global Gradle properties) whose
|
||||
# *designated keys* should sync. One entry per non-empty non-comment line:
|
||||
#
|
||||
# gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest beaconClerkPkLive
|
||||
# <type> <path> <key> [key...]
|
||||
#
|
||||
# push extracts only the named keys → encrypts a subset blob under
|
||||
# <project>/external/. pull decrypts it and MERGES those keys into <path>,
|
||||
# preserving every unrelated line. Only type `gradle-properties` is
|
||||
# supported (the type token leaves room for future types without a plugin
|
||||
# framework). Parsing mirrors `.secrets-store`: no shell expansion, no
|
||||
# symlink following, conservative sanitization. The merge is pure bash
|
||||
# (no sed/regex) with exact-string key matching, so it is safe against
|
||||
# values containing `& \ /` and against substring keys
|
||||
# (beaconClerkPk vs beaconClerkPkTest).
|
||||
|
||||
SECRETS_FILES_NAME=".secrets-files"
|
||||
SUPPORTED_EXTERNAL_TYPE="gradle-properties"
|
||||
|
||||
# True if the string ends in an odd number of backslashes (a Java/Gradle
|
||||
# properties line-continuation). Used to skip continuation lines when
|
||||
# matching keys so an unrelated continued value is never clobbered.
|
||||
_trailing_bs_odd() {
|
||||
local s="$1" n=0
|
||||
while [ "${s%\\}" != "$s" ]; do s="${s%\\}"; n=$((n + 1)); done
|
||||
[ $((n % 2)) -eq 1 ]
|
||||
}
|
||||
|
||||
# Parse .secrets-files. Emits "<type>\t<path>\t<keys>" per valid entry on
|
||||
# stdout; warnings (skipped/unsafe lines) to stderr. Conservative: rejects
|
||||
# shell/control chars and `..` in the path, restricts keys to a safe
|
||||
# charset. No shell expansion is applied to file content.
|
||||
_parse_secrets_files_manifest() {
|
||||
local file="$1"
|
||||
local line lineno=0 mtype mpath mkeys
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
lineno=$((lineno + 1))
|
||||
line="${line%$'\r'}"
|
||||
line="${line#"${line%%[![:space:]]*}"}"
|
||||
line="${line%"${line##*[![:space:]]}"}"
|
||||
[ -z "$line" ] && continue
|
||||
case "$line" in '#'*) continue ;; esac
|
||||
# `read -r` does not glob-expand and keeps the key list intact in $mkeys.
|
||||
read -r mtype mpath mkeys <<< "$line"
|
||||
if [ "$mtype" != "$SUPPORTED_EXTERNAL_TYPE" ]; then
|
||||
echo "WARNING: $file line $lineno: unknown type '$mtype' (supported: $SUPPORTED_EXTERNAL_TYPE). Skipping." >&2
|
||||
continue
|
||||
fi
|
||||
if [ -z "$mpath" ] || [ -z "$mkeys" ]; then
|
||||
echo "WARNING: $file line $lineno: expected '<type> <path> <key> [key...]'. Skipping." >&2
|
||||
continue
|
||||
fi
|
||||
# Path: allow alnum and / . _ ~ - only; reject everything else (blocks
|
||||
# \$ \` ; & | ( ) * ? whitespace etc.) and reject `..` traversal.
|
||||
case "$mpath" in
|
||||
*[!A-Za-z0-9/._~-]* | *..*)
|
||||
echo "WARNING: $file line $lineno: unsafe characters in path. Paths may contain letters, digits, / . _ - ~ only. Skipping." >&2
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
# Keys: space-separated; each may contain alnum and . _ - (covers the
|
||||
# systemProp. prefix). Reject anything else (=, *, control chars, ...).
|
||||
case "$mkeys" in
|
||||
*[!A-Za-z0-9._\ -]*)
|
||||
echo "WARNING: $file line $lineno: unsafe characters in key list. Skipping." >&2
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
printf '%s\t%s\t%s\n' "$mtype" "$mpath" "$mkeys"
|
||||
done < "$file"
|
||||
}
|
||||
|
||||
# Validate a writable external target path. Refuses anything that isn't a
|
||||
# `gradle.properties` resolving inside $HOME, blocks `..`, and refuses
|
||||
# symlinks (target file or its parent dir) — the path comes from a
|
||||
# committed file, so it is attacker-controllable. Returns 0 if safe.
|
||||
_validate_external_target_path() {
|
||||
local p="$1"
|
||||
local base; base=$(basename "$p")
|
||||
if [ "$base" != "gradle.properties" ]; then
|
||||
echo "ERROR: $SECRETS_FILES_NAME: target basename must be 'gradle.properties' (got '$base'). Refusing." >&2
|
||||
return 1
|
||||
fi
|
||||
case "$p" in *..*) echo "ERROR: $SECRETS_FILES_NAME: target path may not contain '..'. Refusing." >&2; return 1 ;; esac
|
||||
local home_real; home_real=$(cd -P "$HOME" 2>/dev/null && pwd -P) || home_real="$HOME"
|
||||
local dir; dir=$(dirname "$p")
|
||||
# Resolve the deepest existing ancestor and require it inside $HOME.
|
||||
local probe="$dir"
|
||||
while [ ! -e "$probe" ] && [ "$probe" != "/" ] && [ -n "$probe" ]; do
|
||||
probe=$(dirname "$probe")
|
||||
done
|
||||
local probe_real
|
||||
probe_real=$(cd -P "$probe" 2>/dev/null && pwd -P) || {
|
||||
echo "ERROR: $SECRETS_FILES_NAME: cannot resolve target ancestor '$probe'. Refusing." >&2
|
||||
return 1
|
||||
}
|
||||
case "$probe_real/" in
|
||||
"$home_real"/*) ;;
|
||||
*) echo "ERROR: $SECRETS_FILES_NAME: target '$p' must resolve inside \$HOME. Refusing." >&2; return 1 ;;
|
||||
esac
|
||||
if [ -L "$p" ]; then
|
||||
echo "ERROR: $SECRETS_FILES_NAME: target '$p' is a symlink. Refusing to follow." >&2
|
||||
return 1
|
||||
fi
|
||||
if [ -e "$dir" ] && [ -L "$dir" ]; then
|
||||
echo "ERROR: $SECRETS_FILES_NAME: target dir '$dir' is a symlink. Refusing." >&2
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Print the value of an exact key from a properties file (last definition
|
||||
# wins, matching Gradle). Returns 1 if the key is absent. Pure bash; the
|
||||
# key is compared by string equality (never as a regex), and the value is
|
||||
# read verbatim. Skips comments and continuation lines.
|
||||
_props_get() {
|
||||
local file="$1" want="$2"
|
||||
[ -f "$file" ] || return 1
|
||||
local line t key after val="" found=0 cont=0
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
line="${line%$'\r'}"
|
||||
if [ "$cont" -eq 1 ]; then
|
||||
_trailing_bs_odd "$line" && cont=1 || cont=0
|
||||
continue
|
||||
fi
|
||||
_trailing_bs_odd "$line" && cont=1 || cont=0
|
||||
t="${line#"${line%%[![:space:]]*}"}"
|
||||
case "$t" in ''|'#'*|'!'*) continue ;; esac
|
||||
key="${t%%[=:[:space:]]*}"
|
||||
[ "$key" = "$want" ] || continue
|
||||
after="${t#"$key"}"
|
||||
after="${after#"${after%%[![:space:]]*}"}"
|
||||
case "$after" in [=:]*) after="${after#?}" ;; esac
|
||||
after="${after#"${after%%[![:space:]]*}"}"
|
||||
val="$after"
|
||||
found=1
|
||||
done < "$file"
|
||||
[ "$found" -eq 1 ] || return 1
|
||||
printf '%s' "$val"
|
||||
}
|
||||
|
||||
# Turn a manifest path token into a machine-independent blob slug.
|
||||
_secrets_files_slug() {
|
||||
local p="$1"
|
||||
printf '%s' "${p//[!A-Za-z0-9._-]/_}"
|
||||
}
|
||||
|
||||
# Merge managed key=value lines (from $2) into target file $1, preserving
|
||||
# all unrelated lines/comments/order. Updates a managed key in place (first
|
||||
# occurrence), collapses duplicates, appends new keys. Atomic + mode-safe.
|
||||
merge_gradle_keys() {
|
||||
local target="$1" kvfile="$2"
|
||||
local -a mkey=() mval=() seen=()
|
||||
local line key val i
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
[ -n "$line" ] || continue
|
||||
key="${line%%=*}"
|
||||
val="${line#*=}"
|
||||
mkey+=("$key"); mval+=("$val"); seen+=(0)
|
||||
done < "$kvfile"
|
||||
local n=${#mkey[@]}
|
||||
[ "$n" -gt 0 ] || return 0
|
||||
|
||||
local dir; dir=$(dirname "$target")
|
||||
mkdir -p "$dir"
|
||||
local tmp; tmp=$(mktemp "$dir/.gradle-merge.XXXXXX") || return 1
|
||||
|
||||
local cont=0 t tkey matched
|
||||
if [ -f "$target" ]; then
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
local stripped="${line%$'\r'}"
|
||||
if [ "$cont" -eq 1 ]; then
|
||||
printf '%s\n' "$line" >> "$tmp"
|
||||
_trailing_bs_odd "$stripped" && cont=1 || cont=0
|
||||
continue
|
||||
fi
|
||||
_trailing_bs_odd "$stripped" && cont=1 || cont=0
|
||||
t="${stripped#"${stripped%%[![:space:]]*}"}"
|
||||
case "$t" in ''|'#'*|'!'*) printf '%s\n' "$line" >> "$tmp"; continue ;; esac
|
||||
tkey="${t%%[=:[:space:]]*}"
|
||||
matched=-1
|
||||
for ((i = 0; i < n; i++)); do
|
||||
if [ "$tkey" = "${mkey[$i]}" ]; then matched=$i; break; fi
|
||||
done
|
||||
if [ "$matched" -ge 0 ]; then
|
||||
if [ "${seen[$matched]}" -eq 0 ]; then
|
||||
printf '%s=%s\n' "${mkey[$matched]}" "${mval[$matched]}" >> "$tmp"
|
||||
seen[$matched]=1
|
||||
fi
|
||||
else
|
||||
printf '%s\n' "$line" >> "$tmp"
|
||||
fi
|
||||
done < "$target"
|
||||
fi
|
||||
for ((i = 0; i < n; i++)); do
|
||||
if [ "${seen[$i]}" -eq 0 ]; then
|
||||
printf '%s=%s\n' "${mkey[$i]}" "${mval[$i]}" >> "$tmp"
|
||||
fi
|
||||
done
|
||||
|
||||
# Back up the existing target before overwriting (seatbelt for the merge
|
||||
# engine touching a hand-maintained file), preserve mode, atomic rename.
|
||||
if [ -f "$target" ]; then
|
||||
cp "$target" "$target.secrets-bak" 2>/dev/null || true
|
||||
local mode
|
||||
mode=$(stat -f '%Lp' "$target" 2>/dev/null || stat -c '%a' "$target" 2>/dev/null || echo 600)
|
||||
chmod "$mode" "$tmp" 2>/dev/null || true
|
||||
else
|
||||
chmod 600 "$tmp" 2>/dev/null || true
|
||||
fi
|
||||
# TOCTOU recheck before the write into $HOME.
|
||||
if [ -L "$target" ]; then
|
||||
rm -f "$tmp"
|
||||
echo "ERROR: $target became a symlink; aborting merge." >&2
|
||||
return 1
|
||||
fi
|
||||
mv "$tmp" "$target"
|
||||
}
|
||||
|
||||
# Encrypt the managed keys declared in <root>/.secrets-files into
|
||||
# <project>/external/. Returns 0 if at least one entry was pushed, 1 if
|
||||
# there is no usable manifest. Dies on unsafe targets or all-missing keys.
|
||||
push_external_files() {
|
||||
local root="$1" project="$2" pubkey="$3"
|
||||
local manifest="$root/$SECRETS_FILES_NAME"
|
||||
[ -e "$manifest" ] || return 1
|
||||
if [ -L "$manifest" ]; then
|
||||
echo "WARNING: $manifest is a symlink; ignoring." >&2
|
||||
return 1
|
||||
fi
|
||||
[ -f "$manifest" ] || return 1
|
||||
|
||||
local pushed=0 mtype mpath mkeys
|
||||
while IFS=$'\t' read -r mtype mpath mkeys; do
|
||||
[ -n "$mtype" ] || continue
|
||||
local expanded; expanded=$(_expand_store_path "$mpath")
|
||||
if ! _validate_external_target_path "$expanded"; then
|
||||
die "Refusing unsafe external target in $SECRETS_FILES_NAME: $mpath"
|
||||
fi
|
||||
if [ ! -f "$expanded" ]; then
|
||||
echo "WARNING: source '$mpath' not found on this machine; skipping." >&2
|
||||
continue
|
||||
fi
|
||||
local tmp; tmp=$(mktemp)
|
||||
local found=0 k v
|
||||
for k in $mkeys; do
|
||||
if v=$(_props_get "$expanded" "$k"); then
|
||||
printf '%s=%s\n' "$k" "$v" >> "$tmp"
|
||||
found=$((found + 1))
|
||||
else
|
||||
echo "WARNING: key '$k' not found in $mpath — not synced. Set it locally first, or remove it from $SECRETS_FILES_NAME." >&2
|
||||
fi
|
||||
done
|
||||
if [ "$found" -eq 0 ]; then
|
||||
rm -f "$tmp"
|
||||
die "No managed keys found in $mpath (looked for: $mkeys)."
|
||||
fi
|
||||
mkdir -p "$SECRETS_DIR/$project/external"
|
||||
local slug; slug=$(_secrets_files_slug "$mpath")
|
||||
age -r "$pubkey" -o "$SECRETS_DIR/$project/external/$slug.$mtype.age" "$tmp"
|
||||
rm -f "$tmp"
|
||||
info "Extracted $found key(s) from $mpath"
|
||||
pushed=$((pushed + 1))
|
||||
done < <(_parse_secrets_files_manifest "$manifest")
|
||||
|
||||
[ "$pushed" -gt 0 ]
|
||||
}
|
||||
|
||||
# Decrypt the external blobs named by <root>/.secrets-files and merge their
|
||||
# keys into the declared targets. Skips (with a warning) unsafe targets or
|
||||
# missing blobs rather than aborting the whole pull.
|
||||
pull_external_files() {
|
||||
local root="$1" project="$2"
|
||||
local manifest="$root/$SECRETS_FILES_NAME"
|
||||
[ -e "$manifest" ] || return 0
|
||||
if [ -L "$manifest" ]; then
|
||||
echo "WARNING: $manifest is a symlink; ignoring." >&2
|
||||
return 0
|
||||
fi
|
||||
[ -f "$manifest" ] || return 0
|
||||
|
||||
local mtype mpath mkeys
|
||||
while IFS=$'\t' read -r mtype mpath mkeys; do
|
||||
[ -n "$mtype" ] || continue
|
||||
local expanded; expanded=$(_expand_store_path "$mpath")
|
||||
if ! _validate_external_target_path "$expanded"; then
|
||||
echo "WARNING: skipping unsafe external target: $mpath" >&2
|
||||
continue
|
||||
fi
|
||||
local slug; slug=$(_secrets_files_slug "$mpath")
|
||||
local blob="$SECRETS_DIR/$project/external/$slug.$mtype.age"
|
||||
if [ ! -f "$blob" ]; then
|
||||
echo "WARNING: $SECRETS_FILES_NAME names '$mpath' but no encrypted data exists in the store yet. Run 'secrets push' on a machine that has these keys. Skipping." >&2
|
||||
continue
|
||||
fi
|
||||
local tmp; tmp=$(mktemp)
|
||||
if ! age -d -i "$KEY_FILE" -o "$tmp" "$blob"; then
|
||||
rm -f "$tmp"
|
||||
die "Decryption failed for external target $mpath."
|
||||
fi
|
||||
local k
|
||||
for k in $mkeys; do
|
||||
grep -q "^$k=" "$tmp" 2>/dev/null || echo "WARNING: '$k' listed in $SECRETS_FILES_NAME but absent from synced data for $mpath." >&2
|
||||
done
|
||||
if [ ! -d "$(dirname "$expanded")" ]; then
|
||||
echo "WARNING: creating $(dirname "$expanded")" >&2
|
||||
fi
|
||||
local count; count=$(grep -c '=' "$tmp" 2>/dev/null || echo 0)
|
||||
if merge_gradle_keys "$expanded" "$tmp"; then
|
||||
local klist; klist=$(printf '%s' "$mkeys" | tr ' ' ',' | sed 's/,/, /g')
|
||||
info "Merged $count key(s) into $expanded ($klist)"
|
||||
fi
|
||||
rm -f "$tmp"
|
||||
done < <(_parse_secrets_files_manifest "$manifest")
|
||||
}
|
||||
|
||||
# Read package.json workspaces and expand globs to actual directories.
|
||||
# Prints one workspace path per line (relative to the monorepo root).
|
||||
get_workspaces() {
|
||||
|
|
@ -353,7 +672,7 @@ install_hook() {
|
|||
# Inline hook if template not found (e.g. secrets installed standalone)
|
||||
cat > "$hook_dst" << 'HOOKEOF'
|
||||
#!/usr/bin/env bash
|
||||
BLOCKED=$(git diff --cached --name-only | grep -E '\.(env|dev\.vars)' | grep -v '\.age$' || true)
|
||||
BLOCKED=$(git diff --cached --name-only | grep -E '(\.env|\.dev\.vars|gradle\.properties)' | grep -v '\.age$' || true)
|
||||
if [ -n "$BLOCKED" ]; then
|
||||
echo "ERROR: Plaintext secret files staged for commit:"
|
||||
echo "$BLOCKED"
|
||||
|
|
@ -480,8 +799,11 @@ cmd_push() {
|
|||
local pubkey
|
||||
pubkey=$(get_pubkey)
|
||||
|
||||
if ! push_dir_to_project "$PWD" "$project" "$pubkey"; then
|
||||
die "No secret files (.env, .env.*, .dev.vars) found in $PWD"
|
||||
local did=0
|
||||
if push_dir_to_project "$PWD" "$project" "$pubkey"; then did=1; fi
|
||||
if push_external_files "$PWD" "$project" "$pubkey"; then did=1; fi
|
||||
if [ "$did" -eq 0 ]; then
|
||||
die "No secret files (.env, .env.*, .dev.vars) or $SECRETS_FILES_NAME entries found in $PWD"
|
||||
fi
|
||||
|
||||
commit_and_push_secrets "update $project"
|
||||
|
|
@ -522,6 +844,12 @@ cmd_push_workspaces() {
|
|||
fi
|
||||
done <<< "$workspaces"
|
||||
|
||||
# External files (.secrets-files) are monorepo-root-scoped, like
|
||||
# .secrets-store — handle once, not per-workspace.
|
||||
if push_external_files "$root" "$monorepo_name" "$pubkey"; then
|
||||
total=$((total + 1))
|
||||
fi
|
||||
|
||||
if [ "$total" -eq 0 ]; then
|
||||
die "No secret files found in any workspace"
|
||||
fi
|
||||
|
|
@ -569,6 +897,9 @@ cmd_pull() {
|
|||
|
||||
info "Decrypted $count file(s) into $target_dir"
|
||||
|
||||
# Merge any external files (.secrets-files) declared in this project.
|
||||
pull_external_files "$PWD" "$project"
|
||||
|
||||
# Reinstall hook if missing
|
||||
if [ ! -x "$SECRETS_DIR/.git/hooks/pre-commit" ]; then
|
||||
install_hook
|
||||
|
|
@ -644,6 +975,12 @@ cmd_pull_workspaces() {
|
|||
fi
|
||||
done <<< "$workspaces"
|
||||
|
||||
# External files (.secrets-files) are monorepo-root-scoped — once.
|
||||
if [ -f "$root/$SECRETS_FILES_NAME" ] && [ ! -L "$root/$SECRETS_FILES_NAME" ]; then
|
||||
pull_external_files "$root" "$monorepo_name"
|
||||
total=$((total + 1))
|
||||
fi
|
||||
|
||||
if [ "$total" -eq 0 ]; then
|
||||
die "No secrets found for any workspace in $monorepo_name"
|
||||
fi
|
||||
|
|
@ -674,6 +1011,14 @@ cmd_list() {
|
|||
echo " $(basename "$f" .age)"
|
||||
found=1
|
||||
done
|
||||
# External files live in a subdir, invisible to the globs above.
|
||||
if [ -d "${dir}external" ]; then
|
||||
for f in "${dir}external"/*.age; do
|
||||
[ -f "$f" ] || continue
|
||||
echo " [external] $(basename "$f" .age)"
|
||||
found=1
|
||||
done
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$found" -eq 0 ]; then
|
||||
|
|
@ -720,7 +1065,10 @@ cmd_rekey() {
|
|||
# Create temp dir with cleanup trap
|
||||
local tmpdir
|
||||
tmpdir=$(mktemp -d)
|
||||
trap 'rm -rf "$tmpdir"' EXIT INT TERM
|
||||
# `${tmpdir:-}` guards against `set -u` if the trap somehow fires after the
|
||||
# function returns (the local would be out of scope). Normal completion
|
||||
# cleans up explicitly and clears the trap at the end of this function.
|
||||
trap 'rm -rf "${tmpdir:-}"' EXIT INT TERM
|
||||
|
||||
info "Decrypting all files with current key..."
|
||||
|
||||
|
|
@ -741,6 +1089,20 @@ cmd_rekey() {
|
|||
fi
|
||||
file_count=$((file_count + 1))
|
||||
done
|
||||
# External files live in a subdir; rekey them too or they become
|
||||
# undecryptable after rotation.
|
||||
if [ -d "${dir}external" ]; then
|
||||
mkdir -p "$tmpdir/$project/external"
|
||||
for f in "${dir}external"/*.age; do
|
||||
[ -f "$f" ] || continue
|
||||
local ename
|
||||
ename=$(basename "$f" .age)
|
||||
if ! age -d -i "$KEY_FILE" -o "$tmpdir/$project/external/$ename" "$f"; then
|
||||
die "Decryption failed for $project/external/$ename. Rekey aborted. Old key preserved."
|
||||
fi
|
||||
file_count=$((file_count + 1))
|
||||
done
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$file_count" -eq 0 ]; then
|
||||
|
|
@ -749,8 +1111,12 @@ cmd_rekey() {
|
|||
|
||||
info "Decrypted $file_count file(s). Generating new key pair..."
|
||||
|
||||
# Generate new key (overwrites old)
|
||||
age-keygen -o "$KEY_FILE" 2>&1
|
||||
# Generate the new key. age-keygen refuses to overwrite an existing
|
||||
# file, so generate into the temp dir and move it into place only on
|
||||
# success — the old key stays intact if generation fails.
|
||||
local newkey="$tmpdir/key.txt.new"
|
||||
age-keygen -o "$newkey" 2>&1
|
||||
mv "$newkey" "$KEY_FILE"
|
||||
local pubkey
|
||||
pubkey=$(get_pubkey)
|
||||
|
||||
|
|
@ -768,6 +1134,15 @@ cmd_rekey() {
|
|||
name=$(basename "$f")
|
||||
age -r "$pubkey" -o "$SECRETS_DIR/$project/${name}.age" "$f"
|
||||
done
|
||||
if [ -d "${dir}external" ]; then
|
||||
mkdir -p "$SECRETS_DIR/$project/external"
|
||||
for f in "${dir}external"/*; do
|
||||
[ -f "$f" ] || continue
|
||||
local ename
|
||||
ename=$(basename "$f")
|
||||
age -r "$pubkey" -o "$SECRETS_DIR/$project/external/${ename}.age" "$f"
|
||||
done
|
||||
fi
|
||||
done
|
||||
|
||||
# Commit and push
|
||||
|
|
@ -787,6 +1162,11 @@ cmd_rekey() {
|
|||
echo ""
|
||||
echo "WARNING: Old ciphertext remains in git history."
|
||||
echo "For full rotation, create a fresh repo."
|
||||
|
||||
# Clean up the plaintext temp dir while $tmpdir is still in scope, then
|
||||
# drop the trap so it can't fire (and error under set -u) post-return.
|
||||
rm -rf "$tmpdir"
|
||||
trap - EXIT INT TERM
|
||||
}
|
||||
|
||||
cmd_clear() {
|
||||
|
|
@ -893,6 +1273,22 @@ cmd_which() {
|
|||
resolve_store
|
||||
echo "store: $SECRETS_DIR"
|
||||
echo "source: $STORE_SOURCE"
|
||||
|
||||
# Read back any external-file manifest in cwd (validates the format and
|
||||
# gives the user a way to confirm it parsed, since there's no add-file
|
||||
# command). Skips symlinked manifests.
|
||||
local manifest="$PWD/$SECRETS_FILES_NAME"
|
||||
if [ -f "$manifest" ] && [ ! -L "$manifest" ]; then
|
||||
local mtype mpath mkeys printed=0
|
||||
while IFS=$'\t' read -r mtype mpath mkeys; do
|
||||
[ -n "$mtype" ] || continue
|
||||
if [ "$printed" -eq 0 ]; then
|
||||
echo "external files ($SECRETS_FILES_NAME at $manifest):"
|
||||
printed=1
|
||||
fi
|
||||
echo " $mtype $mpath $mkeys"
|
||||
done < <(_parse_secrets_files_manifest "$manifest")
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_help() {
|
||||
|
|
@ -946,6 +1342,25 @@ Workspaces:
|
|||
<monorepo>/<workspace-path>/ in the secrets repo. Root secret files
|
||||
are stored under <monorepo>/ directly. Requires jq.
|
||||
|
||||
External files (.secrets-files):
|
||||
Sync specific keys from files OUTSIDE the project root (e.g. global
|
||||
Gradle properties). Create a committed .secrets-files in the project
|
||||
root, one entry per line:
|
||||
|
||||
# <type> <path> <keys...>
|
||||
gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest beaconClerkPkLive
|
||||
|
||||
On push, the named keys are extracted from <path> and encrypted under
|
||||
<project>/external/ in the store. On pull, they are MERGED back into
|
||||
<path>, leaving all unrelated keys untouched. Only type
|
||||
'gradle-properties' is supported; the target basename must be
|
||||
'gradle.properties' and must resolve inside $HOME. Run 'secrets which'
|
||||
from the project to confirm the manifest parsed.
|
||||
|
||||
Note: merged keys are written as permanent plaintext into the target
|
||||
file (suitable for publishable/low-secrecy values). 'secrets clear'
|
||||
does NOT remove them.
|
||||
|
||||
Environment:
|
||||
SECRETS_DIR Path to secrets repo (default: ~/.secrets). See also
|
||||
the .secrets-store file and --store flag above.
|
||||
|
|
|
|||
|
|
@ -1045,3 +1045,307 @@ PKG
|
|||
[ "$status" -eq 1 ]
|
||||
[[ "$output" == *"git+ssh://user@host:2222/path/to-repo_v2.git"* ]]
|
||||
}
|
||||
|
||||
# ─── EGB-531: gradle.properties external file support ──────────────────
|
||||
|
||||
# Helper: write a fake global gradle.properties under the sandboxed HOME.
|
||||
gradle_src() {
|
||||
mkdir -p "$HOME/.gradle"
|
||||
printf '%s' "$1" > "$HOME/.gradle/gradle.properties"
|
||||
}
|
||||
|
||||
# Helper: bind a project dir to a gradle entry via .secrets-files, cd into it.
|
||||
gradle_project() {
|
||||
local name="${1:-gproj}"
|
||||
local keys="${2:-beaconClerkPkTest beaconClerkPkLive}"
|
||||
local dir="$WORK_DIR/$name"
|
||||
mkdir -p "$dir"
|
||||
printf 'gradle-properties ~/.gradle/gradle.properties %s\n' "$keys" > "$dir/.secrets-files"
|
||||
cd "$dir"
|
||||
}
|
||||
|
||||
@test "EGB-531: which shows parsed .secrets-files entries" {
|
||||
init_with_remote
|
||||
gradle_project gproj
|
||||
run "$SECRETS_BIN" which
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"gradle-properties"* ]]
|
||||
[[ "$output" == *"~/.gradle/gradle.properties"* ]]
|
||||
[[ "$output" == *"beaconClerkPkTest"* ]]
|
||||
}
|
||||
|
||||
@test "EGB-531: push extracts managed keys into external/ blob (no .env needed)" {
|
||||
init_with_remote
|
||||
gradle_src $'beaconClerkPkTest=pk_test_abc\nbeaconClerkPkLive=pk_live_xyz\nunrelated=keep\n'
|
||||
gradle_project gproj
|
||||
run "$SECRETS_BIN" push gproj
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"Extracted 2 key"* ]]
|
||||
run bash -c "ls $SECRETS_DIR/gproj/external/*.gradle-properties.age"
|
||||
[ "$status" -eq 0 ]
|
||||
}
|
||||
|
||||
@test "EGB-531: push dies if all managed keys missing from source" {
|
||||
init_with_remote
|
||||
gradle_src $'somethingelse=1\n'
|
||||
gradle_project gproj
|
||||
run "$SECRETS_BIN" push gproj
|
||||
[ "$status" -eq 1 ]
|
||||
[[ "$output" == *"not found"* ]]
|
||||
}
|
||||
|
||||
@test "EGB-531: pull merges managed keys, preserves unrelated entries" {
|
||||
init_with_remote
|
||||
gradle_src $'beaconClerkPkTest=pk_test_abc\nbeaconClerkPkLive=pk_live_xyz\n'
|
||||
gradle_project gproj
|
||||
"$SECRETS_BIN" push gproj >/dev/null 2>&1
|
||||
# Simulate a second machine: target holds only unrelated keys
|
||||
gradle_src $'unrelated.key=keepme\norg.gradle.jvmargs=-Xmx2g\n'
|
||||
run "$SECRETS_BIN" pull gproj
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"Merged 2 key"* ]]
|
||||
grep -q 'beaconClerkPkTest=pk_test_abc' "$HOME/.gradle/gradle.properties"
|
||||
grep -q 'beaconClerkPkLive=pk_live_xyz' "$HOME/.gradle/gradle.properties"
|
||||
grep -q 'unrelated.key=keepme' "$HOME/.gradle/gradle.properties"
|
||||
grep -q 'org.gradle.jvmargs=-Xmx2g' "$HOME/.gradle/gradle.properties"
|
||||
}
|
||||
|
||||
@test "EGB-531: merge does NOT touch a substring key" {
|
||||
init_with_remote
|
||||
gradle_src $'beaconClerkPkTest=secretval\n'
|
||||
gradle_project gproj beaconClerkPkTest
|
||||
"$SECRETS_BIN" push gproj >/dev/null 2>&1
|
||||
# Target has the shorter key AND a stale managed key
|
||||
gradle_src $'beaconClerkPk=DONOTCHANGE\nbeaconClerkPkTest=old\n'
|
||||
run "$SECRETS_BIN" pull gproj
|
||||
[ "$status" -eq 0 ]
|
||||
grep -q '^beaconClerkPk=DONOTCHANGE$' "$HOME/.gradle/gradle.properties"
|
||||
grep -q '^beaconClerkPkTest=secretval$' "$HOME/.gradle/gradle.properties"
|
||||
}
|
||||
|
||||
@test "EGB-531: value with sed/regex metacharacters round-trips byte-exact" {
|
||||
init_with_remote
|
||||
local val='a/b&c\d.e|f$g'
|
||||
gradle_src "$(printf 'beaconClerkPkTest=%s\n' "$val")"
|
||||
gradle_project gproj beaconClerkPkTest
|
||||
"$SECRETS_BIN" push gproj >/dev/null 2>&1
|
||||
gradle_src $'other=1\n'
|
||||
run "$SECRETS_BIN" pull gproj
|
||||
[ "$status" -eq 0 ]
|
||||
run grep -F "beaconClerkPkTest=$val" "$HOME/.gradle/gradle.properties"
|
||||
[ "$status" -eq 0 ]
|
||||
}
|
||||
|
||||
@test "EGB-531: colon and space separators are parsed" {
|
||||
init_with_remote
|
||||
gradle_src $'beaconClerkPkTest : pk_colon\nbeaconClerkPkLive pk_space\n'
|
||||
gradle_project gproj
|
||||
"$SECRETS_BIN" push gproj >/dev/null 2>&1
|
||||
gradle_src $'x=1\n'
|
||||
run "$SECRETS_BIN" pull gproj
|
||||
[ "$status" -eq 0 ]
|
||||
grep -q '^beaconClerkPkTest=pk_colon$' "$HOME/.gradle/gradle.properties"
|
||||
grep -q '^beaconClerkPkLive=pk_space$' "$HOME/.gradle/gradle.properties"
|
||||
}
|
||||
|
||||
@test "EGB-531: pull is idempotent (second pull leaves file byte-identical)" {
|
||||
init_with_remote
|
||||
gradle_src $'beaconClerkPkTest=pk_test_abc\nbeaconClerkPkLive=pk_live_xyz\n'
|
||||
gradle_project gproj
|
||||
"$SECRETS_BIN" push gproj >/dev/null 2>&1
|
||||
gradle_src $'unrelated=x\n# a comment\n'
|
||||
"$SECRETS_BIN" pull gproj >/dev/null 2>&1
|
||||
cp "$HOME/.gradle/gradle.properties" "$TEST_TMPDIR/snap1"
|
||||
"$SECRETS_BIN" pull gproj >/dev/null 2>&1
|
||||
run diff "$TEST_TMPDIR/snap1" "$HOME/.gradle/gradle.properties"
|
||||
[ "$status" -eq 0 ]
|
||||
}
|
||||
|
||||
@test "EGB-531: merge preserves comments and blank lines" {
|
||||
init_with_remote
|
||||
gradle_src $'beaconClerkPkTest=pk_test_abc\n'
|
||||
gradle_project gproj beaconClerkPkTest
|
||||
"$SECRETS_BIN" push gproj >/dev/null 2>&1
|
||||
gradle_src $'# header comment\n\nunrelated=x\n'
|
||||
run "$SECRETS_BIN" pull gproj
|
||||
[ "$status" -eq 0 ]
|
||||
grep -q '^# header comment$' "$HOME/.gradle/gradle.properties"
|
||||
grep -q '^unrelated=x$' "$HOME/.gradle/gradle.properties"
|
||||
grep -q '^beaconClerkPkTest=pk_test_abc$' "$HOME/.gradle/gradle.properties"
|
||||
}
|
||||
|
||||
@test "EGB-531: duplicate managed key in target collapses to one canonical line" {
|
||||
init_with_remote
|
||||
gradle_src $'beaconClerkPkTest=newval\n'
|
||||
gradle_project gproj beaconClerkPkTest
|
||||
"$SECRETS_BIN" push gproj >/dev/null 2>&1
|
||||
gradle_src $'beaconClerkPkTest=old1\nx=1\nbeaconClerkPkTest=old2\n'
|
||||
run "$SECRETS_BIN" pull gproj
|
||||
[ "$status" -eq 0 ]
|
||||
run grep -c '^beaconClerkPkTest=' "$HOME/.gradle/gradle.properties"
|
||||
[ "$output" -eq 1 ]
|
||||
grep -q '^beaconClerkPkTest=newval$' "$HOME/.gradle/gradle.properties"
|
||||
grep -q '^x=1$' "$HOME/.gradle/gradle.properties"
|
||||
}
|
||||
|
||||
@test "EGB-531: continuation-line-adjacent managed key is not clobbered" {
|
||||
init_with_remote
|
||||
gradle_src $'beaconClerkPkTest=realval\n'
|
||||
gradle_project gproj beaconClerkPkTest
|
||||
"$SECRETS_BIN" push gproj >/dev/null 2>&1
|
||||
# 'beaconClerkPkTest=...' here is a CONTINUATION of unrelated's value, not a definition
|
||||
printf 'unrelated=foo\\\nbeaconClerkPkTest=continuation\n' > "$HOME/.gradle/gradle.properties"
|
||||
run "$SECRETS_BIN" pull gproj
|
||||
[ "$status" -eq 0 ]
|
||||
# continuation line preserved verbatim
|
||||
grep -q '^beaconClerkPkTest=continuation$' "$HOME/.gradle/gradle.properties"
|
||||
# and the real managed key appended
|
||||
grep -q '^beaconClerkPkTest=realval$' "$HOME/.gradle/gradle.properties"
|
||||
}
|
||||
|
||||
@test "EGB-531: first-create target gets mode 600" {
|
||||
init_with_remote
|
||||
gradle_src $'beaconClerkPkTest=pk_test_abc\n'
|
||||
gradle_project gproj beaconClerkPkTest
|
||||
"$SECRETS_BIN" push gproj >/dev/null 2>&1
|
||||
rm -f "$HOME/.gradle/gradle.properties"
|
||||
run "$SECRETS_BIN" pull gproj
|
||||
[ "$status" -eq 0 ]
|
||||
[ -f "$HOME/.gradle/gradle.properties" ]
|
||||
local mode
|
||||
mode=$(stat -f '%Lp' "$HOME/.gradle/gradle.properties" 2>/dev/null || stat -c '%a' "$HOME/.gradle/gradle.properties")
|
||||
[ "$mode" = "600" ]
|
||||
}
|
||||
|
||||
@test "EGB-531: target with wrong basename is refused" {
|
||||
init_with_remote
|
||||
mkdir -p "$HOME/.gradle"
|
||||
printf 'beaconClerkPkTest=x\n' > "$HOME/.gradle/custom.properties"
|
||||
mkdir -p "$WORK_DIR/gproj"; cd "$WORK_DIR/gproj"
|
||||
printf 'gradle-properties ~/.gradle/custom.properties beaconClerkPkTest\n' > .secrets-files
|
||||
run "$SECRETS_BIN" push gproj
|
||||
[ "$status" -eq 1 ]
|
||||
[[ "$output" == *"gradle.properties"* ]]
|
||||
}
|
||||
|
||||
@test "EGB-531: target outside HOME is refused" {
|
||||
init_with_remote
|
||||
local outside
|
||||
outside=$(mktemp -d)
|
||||
mkdir -p "$outside/.gradle"
|
||||
printf 'beaconClerkPkTest=x\n' > "$outside/.gradle/gradle.properties"
|
||||
mkdir -p "$WORK_DIR/gproj"; cd "$WORK_DIR/gproj"
|
||||
printf 'gradle-properties %s/.gradle/gradle.properties beaconClerkPkTest\n' "$outside" > .secrets-files
|
||||
run "$SECRETS_BIN" push gproj
|
||||
rm -rf "$outside"
|
||||
[ "$status" -eq 1 ]
|
||||
[[ "$output" == *"HOME"* ]]
|
||||
}
|
||||
|
||||
@test "EGB-531: symlinked target is refused" {
|
||||
init_with_remote
|
||||
mkdir -p "$HOME/.gradle"
|
||||
printf 'beaconClerkPkTest=x\n' > "$HOME/realgradle"
|
||||
ln -s "$HOME/realgradle" "$HOME/.gradle/gradle.properties"
|
||||
gradle_project gproj beaconClerkPkTest
|
||||
run "$SECRETS_BIN" push gproj
|
||||
[ "$status" -eq 1 ]
|
||||
[[ "$output" == *"symlink"* ]]
|
||||
}
|
||||
|
||||
@test "EGB-531: unknown type in manifest warns and skips" {
|
||||
init_with_remote
|
||||
mkdir -p "$WORK_DIR/gproj"; cd "$WORK_DIR/gproj"
|
||||
printf 'gradle-props ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
|
||||
echo "X=1" > .env
|
||||
run "$SECRETS_BIN" push gproj
|
||||
[[ "$output" == *"unknown type"* ]]
|
||||
[ ! -d "$SECRETS_DIR/gproj/external" ]
|
||||
}
|
||||
|
||||
@test "EGB-531: malformed manifest line (no path/keys) is skipped with warning" {
|
||||
init_with_remote
|
||||
mkdir -p "$WORK_DIR/gproj"; cd "$WORK_DIR/gproj"
|
||||
printf 'gradle-properties\n' > .secrets-files
|
||||
run "$SECRETS_BIN" which
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"WARNING"* ]]
|
||||
}
|
||||
|
||||
@test "EGB-531: manifest path with command-substitution chars is rejected" {
|
||||
init_with_remote
|
||||
mkdir -p "$WORK_DIR/gproj"; cd "$WORK_DIR/gproj"
|
||||
local pwn="$TEST_TMPDIR/pwn-$$"
|
||||
rm -f "$pwn"
|
||||
printf 'gradle-properties ~/.gradle/gradle.properties$(touch %s) beaconClerkPkTest\n' "$pwn" > .secrets-files
|
||||
run "$SECRETS_BIN" which
|
||||
[ ! -f "$pwn" ]
|
||||
[[ "$output" == *"WARNING"* ]]
|
||||
}
|
||||
|
||||
@test "EGB-531: symlinked .secrets-files is ignored" {
|
||||
init_with_remote
|
||||
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > "$HOME/realmanifest"
|
||||
mkdir -p "$WORK_DIR/gproj"; cd "$WORK_DIR/gproj"
|
||||
ln -s "$HOME/realmanifest" .secrets-files
|
||||
run "$SECRETS_BIN" which
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" != *"beaconClerkPkTest"* ]]
|
||||
}
|
||||
|
||||
@test "EGB-531: rekey re-encrypts the external blob (still decryptable after)" {
|
||||
init_with_remote
|
||||
gradle_src $'beaconClerkPkTest=pk_test_abc\n'
|
||||
gradle_project gproj beaconClerkPkTest
|
||||
"$SECRETS_BIN" push gproj >/dev/null 2>&1
|
||||
"$SECRETS_BIN" rekey >/dev/null 2>&1
|
||||
gradle_src $'other=1\n'
|
||||
run "$SECRETS_BIN" pull gproj
|
||||
[ "$status" -eq 0 ]
|
||||
grep -q '^beaconClerkPkTest=pk_test_abc$' "$HOME/.gradle/gradle.properties"
|
||||
}
|
||||
|
||||
@test "EGB-531: gradle blob is NOT decrypted into cwd by dotenv pull" {
|
||||
init_with_remote
|
||||
gradle_src $'beaconClerkPkTest=pk_test_abc\n'
|
||||
mkdir -p "$WORK_DIR/gproj"; cd "$WORK_DIR/gproj"
|
||||
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
|
||||
echo "DOTENV=1" > .env
|
||||
"$SECRETS_BIN" push gproj >/dev/null 2>&1
|
||||
local pulldir="$WORK_DIR/pull-gproj"
|
||||
mkdir -p "$pulldir"
|
||||
cp "$WORK_DIR/gproj/.secrets-files" "$pulldir/.secrets-files"
|
||||
cd "$pulldir"
|
||||
run "$SECRETS_BIN" pull gproj
|
||||
[ "$status" -eq 0 ]
|
||||
[ -f "$pulldir/.env" ]
|
||||
[ ! -f "$pulldir/gradle.properties" ]
|
||||
}
|
||||
|
||||
@test "EGB-531: list shows external entry" {
|
||||
init_with_remote
|
||||
gradle_src $'beaconClerkPkTest=pk_test_abc\n'
|
||||
gradle_project gproj beaconClerkPkTest
|
||||
"$SECRETS_BIN" push gproj >/dev/null 2>&1
|
||||
run "$SECRETS_BIN" list
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"external"* ]]
|
||||
}
|
||||
|
||||
@test "EGB-531: no .secrets-files behaves exactly as before (backward compat)" {
|
||||
init_with_remote
|
||||
create_project_dir plainproj
|
||||
run "$SECRETS_BIN" push plainproj
|
||||
[ "$status" -eq 0 ]
|
||||
[ ! -d "$SECRETS_DIR/plainproj/external" ]
|
||||
}
|
||||
|
||||
@test "EGB-531: pre-commit blocks plaintext gradle.properties in store" {
|
||||
init_with_remote
|
||||
cd "$SECRETS_DIR"
|
||||
echo "beaconClerkPkTest=leak" > gradle.properties
|
||||
git add -f gradle.properties
|
||||
run git commit -m "should fail"
|
||||
[ "$status" -eq 1 ]
|
||||
[[ "$output" == *"Plaintext"* ]]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue