v0.1.1.0 feat: optional remote URL in .secrets-store (EGB-282) (#2)
* chore: ignore .gstack/ (per-project local state)
* feat: optional remote URL in .secrets-store (EGB-282)
A second whitespace-separated token after the store name in .secrets-store
is treated as the store's git remote URL. When a teammate clones a project
bound to a store they don't have locally yet, the directed missing-store
error now fills in `git clone <url> <path>` so they can copy-paste instead
of asking the original setter for the URL.
Backward compatible: single-token .secrets-store files (the v0.1.0.x
format) continue to work and produce the existing `<their-store-remote>`
placeholder.
Security hardening (caught by adversarial review during /ship):
- 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 `rm -rf ~` on paste. The parser now rejects URLs containing
shell metacharacters (;&|<>$`(){}*?!"'\\), control characters (incl.
ANSI escape sequences that could spoof terminal output), and embedded
whitespace. Rejected URLs are dropped with a stderr warning; the error
falls back to the safe placeholder.
- Switched from `set -- $line` to `read -r spec rest` so the URL field
isn't glob-expanded or word-split — important so `work *` from a
populated directory doesn't leak filenames into the URL field.
Tests 72 → 80. New: backward compat, SSH+HTTPS+~/-prefix URL forms,
comment-and-URL form, four named injection vectors (shell metachar,
backtick, $(), ANSI escape), multi-token URL, glob char, and a positive
test asserting standard git URL chars round-trip unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: bump version and changelog (v0.1.1.0)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1bb729f73b
commit
7c3a76e8c1
7 changed files with 296 additions and 20 deletions
100
secrets
100
secrets
|
|
@ -24,6 +24,10 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||
STORE_SOURCE="default"
|
||||
# Path to the .secrets-store file that won resolution, if any.
|
||||
_LAST_FOUND_AT=""
|
||||
# Remote URL parsed from the active .secrets-store file (optional 2nd token).
|
||||
# Used by check_initialized to fill in a runnable `git clone <URL> <PATH>`
|
||||
# in the missing-store error so teammates don't have to ask.
|
||||
_REMOTE_URL=""
|
||||
# --store flag value, captured by the pre-pass.
|
||||
STORE_OVERRIDE=""
|
||||
|
||||
|
|
@ -41,12 +45,21 @@ check_initialized() {
|
|||
return
|
||||
fi
|
||||
if [ "$STORE_SOURCE" != "default" ]; then
|
||||
# Fill in the remote URL when .secrets-store provided one, so the
|
||||
# teammate can copy-paste the clone command without asking the original
|
||||
# setter for the URL. Falls back to a placeholder otherwise.
|
||||
local clone_url
|
||||
if [ -n "${_REMOTE_URL:-}" ]; then
|
||||
clone_url="$_REMOTE_URL"
|
||||
else
|
||||
clone_url="<their-store-remote>"
|
||||
fi
|
||||
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 <their-store-remote> $SECRETS_DIR
|
||||
git clone $clone_url $SECRETS_DIR
|
||||
# then copy their key.txt to $SECRETS_DIR/key.txt
|
||||
|
||||
If you want a fresh new store at this path:
|
||||
|
|
@ -100,10 +113,20 @@ derive_project_name() {
|
|||
# 4. $HOME/.secrets default
|
||||
#
|
||||
# .secrets-store file format: first non-empty non-comment line is the store
|
||||
# spec. Spec is one of:
|
||||
# spec, optionally followed by a remote URL on the same line. 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)
|
||||
#
|
||||
# The optional second token (whitespace-separated) is a git remote URL. It
|
||||
# is only used as a hint when the store directory does not yet exist on
|
||||
# the current machine — the missing-store error includes a runnable
|
||||
# `git clone <url> <path>` for the teammate to copy. Examples:
|
||||
#
|
||||
# work
|
||||
# work git@github.com:acme/work-secrets.git
|
||||
# ~/.secrets-work https://github.com/acme/work-secrets.git
|
||||
#
|
||||
# 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.
|
||||
|
|
@ -136,8 +159,21 @@ _expand_store_path() {
|
|||
esac
|
||||
}
|
||||
|
||||
# Read a .secrets-store file. Print the first non-empty non-comment line,
|
||||
# trimmed. Return 1 if no usable line is found.
|
||||
# Read a .secrets-store file. On success, print "<store-spec>\t<remote-url>"
|
||||
# (URL empty if not provided or rejected as unsafe). The first non-empty
|
||||
# non-comment line is the active line; the first whitespace splits it into
|
||||
# the spec and an optional URL.
|
||||
#
|
||||
# Security: the URL is later substituted into a copy-paste-ready `git clone`
|
||||
# command in the missing-store error. An attacker who slips a malicious line
|
||||
# into a committed .secrets-store could weaponize that copy-paste — e.g.
|
||||
# `work evil.git;rm -rf ~` would render as `git clone evil.git;rm -rf ~ ...`
|
||||
# and a teammate following the directed error would execute the payload.
|
||||
# We reject URLs containing shell metacharacters, control characters, ANSI
|
||||
# escapes, and embedded whitespace. Rejected URLs are dropped silently from
|
||||
# the parser's perspective (a warning is printed to stderr); the resolver
|
||||
# falls back to the `<their-store-remote>` placeholder so the directed
|
||||
# error stays useful without rendering the attacker-controlled string.
|
||||
_parse_secrets_store_file() {
|
||||
local file="$1"
|
||||
local line
|
||||
|
|
@ -150,7 +186,25 @@ _parse_secrets_store_file() {
|
|||
line="${line%"${line##*[![:space:]]}"}"
|
||||
[ -z "$line" ] && continue
|
||||
case "$line" in '#'*) continue ;; esac
|
||||
printf '%s\n' "$line"
|
||||
# Split into spec (first whitespace-separated token) and url (rest of
|
||||
# the line, verbatim). `read -r` does NOT glob-expand and preserves the
|
||||
# tail in $rest as a single string — important so `work *` doesn't
|
||||
# silently expand to `work file1 file2 ...`.
|
||||
local spec="" rest=""
|
||||
read -r spec rest <<< "$line"
|
||||
local url="$rest"
|
||||
if [ -n "$url" ]; then
|
||||
# Reject URLs containing characters that could weaponize a
|
||||
# copy-paste shell command, terminal escapes, or be ambiguous.
|
||||
# Match order: control chars (incl. ESC \x1b), shell meta, whitespace.
|
||||
case "$url" in
|
||||
*[[:cntrl:]]*|*[\;\&\|\<\>\$\`\(\)\{\}\*\?\!\"\'\\]*|*' '*|*" "*)
|
||||
echo "WARNING: $file: dropping unsafe characters in remote URL hint" >&2
|
||||
url=""
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
printf '%s\t%s\n' "$spec" "$url"
|
||||
return 0
|
||||
done < "$file"
|
||||
return 1
|
||||
|
|
@ -159,8 +213,9 @@ _parse_secrets_store_file() {
|
|||
# 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>\t<source-file-path>" and returns 0.
|
||||
# The caller (resolve_store) splits the tab-separated tuple. We can't set a
|
||||
# On success: prints "<expanded-store-dir>\t<source-file-path>\t<remote-url>"
|
||||
# (URL empty if .secrets-store didn't include one) and returns 0. The caller
|
||||
# (resolve_store) splits the tab-separated 3-tuple. We can't set a
|
||||
# parent-shell variable from here because we're typically called inside
|
||||
# `$(...)` command substitution, which runs in a subshell.
|
||||
_find_secrets_store_file() {
|
||||
|
|
@ -182,11 +237,14 @@ _find_secrets_store_file() {
|
|||
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 parsed
|
||||
if parsed=$(_parse_secrets_store_file "$dir/.secrets-store"); then
|
||||
# parsed is "<spec>\t<url>" (URL may be empty)
|
||||
local spec="${parsed%%$'\t'*}"
|
||||
local url="${parsed#*$'\t'}"
|
||||
local expanded
|
||||
expanded=$(_expand_store_path "$content")
|
||||
printf '%s\t%s\n' "$expanded" "$dir/.secrets-store"
|
||||
expanded=$(_expand_store_path "$spec")
|
||||
printf '%s\t%s\t%s\n' "$expanded" "$dir/.secrets-store" "$url"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
|
@ -198,18 +256,25 @@ _find_secrets_store_file() {
|
|||
# Resolve the active store directory and update SECRETS_DIR + KEY_FILE.
|
||||
# Sets STORE_SOURCE to one of:
|
||||
# "--store flag" | ".secrets-store file (<path>)" | "SECRETS_DIR env var" | "default"
|
||||
# Also sets _REMOTE_URL to the optional remote URL parsed from .secrets-store
|
||||
# (empty when not present). check_initialized uses _REMOTE_URL to fill in a
|
||||
# copy-paste-ready `git clone` command for teammates whose store doesn't
|
||||
# exist yet.
|
||||
resolve_store() {
|
||||
local resolved=""
|
||||
local source=""
|
||||
_REMOTE_URL=""
|
||||
|
||||
if [ -n "${STORE_OVERRIDE:-}" ]; then
|
||||
resolved=$(_expand_store_path "$STORE_OVERRIDE")
|
||||
source="--store flag"
|
||||
_LAST_FOUND_AT=""
|
||||
elif _find_result=$(_find_secrets_store_file); then
|
||||
# _find_secrets_store_file returns "<dir>\t<source-file-path>"
|
||||
resolved="${_find_result%%$'\t'*}"
|
||||
_LAST_FOUND_AT="${_find_result#*$'\t'}"
|
||||
# _find_secrets_store_file returns "<dir>\t<source-file-path>\t<url>"
|
||||
# IFS=$'\t' prefix is scoped to this single `read` builtin — no manual
|
||||
# save/restore needed. URL field is empty when .secrets-store didn't
|
||||
# include a URL or when it was rejected as unsafe.
|
||||
IFS=$'\t' read -r resolved _LAST_FOUND_AT _REMOTE_URL <<< "$_find_result"
|
||||
source=".secrets-store file ($_LAST_FOUND_AT)"
|
||||
elif [ -n "${_USER_SECRETS_DIR:-}" ]; then
|
||||
resolved="$_USER_SECRETS_DIR"
|
||||
|
|
@ -868,6 +933,13 @@ Stores:
|
|||
Bare names ("work") expand to ~/.secrets-work. The name "default"
|
||||
resolves to ~/.secrets. Run `secrets which` to inspect the active store.
|
||||
|
||||
Optional .secrets-store URL hint:
|
||||
A second whitespace-separated token on the line is treated as the
|
||||
store's git remote URL. It is used to fill in a runnable `git clone
|
||||
<url> <path>` in the missing-store error so teammates joining the
|
||||
project don't have to ask for the URL. Example:
|
||||
work git@github.com:acme/work-secrets.git
|
||||
|
||||
Workspaces:
|
||||
With -w/--workspaces, reads package.json "workspaces" field to find
|
||||
workspace directories. Each workspace's secret files are stored under
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue