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
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -4,3 +4,6 @@
|
||||||
# Editor
|
# Editor
|
||||||
*.swp
|
*.swp
|
||||||
*~
|
*~
|
||||||
|
|
||||||
|
# Per-project gstack state (deploy reports, brain caches, etc.)
|
||||||
|
.gstack/
|
||||||
|
|
|
||||||
20
CHANGELOG.md
20
CHANGELOG.md
|
|
@ -5,6 +5,25 @@ 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/),
|
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.
|
and this project adheres to a four-digit MAJOR.MINOR.PATCH.MICRO version scheme.
|
||||||
|
|
||||||
|
## [0.1.1.0] - 2026-05-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Optional git remote URL in `.secrets-store`.** Add a second whitespace-separated token after the store name to give teammates a copy-paste-ready clone command:
|
||||||
|
```
|
||||||
|
work git@github.com:acme/work-secrets.git
|
||||||
|
```
|
||||||
|
When a teammate clones a project bound to a store they don't have on their machine yet, the directed error now fills in the actual `git clone <url> <path>` line — they no longer have to ask the original setter for the URL. The URL is optional; existing single-token `.secrets-store` files continue to work and show the `<their-store-remote>` placeholder as before. (EGB-282)
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- **Hardened `.secrets-store` URL parser against copy-paste shell injection.** The URL is rendered into a `git clone` line that a teammate is likely to copy-paste from the directed error. Without sanitization, a malicious `.secrets-store` containing `work evil.git;rm -rf ~` would render verbatim and execute `rm -rf ~` on paste. The parser now rejects URLs containing shell metacharacters (`;&|<>$\`(){}*?!"'\\`), control characters (including ANSI escape sequences that could spoof terminal output), and embedded whitespace. Rejected URLs are dropped with a stderr warning; the directed error falls back to the safe placeholder. Found by adversarial review during /ship; verified with regression tests for every named attack vector.
|
||||||
|
- **Switched URL parsing from `set -- $line` to `read -r spec rest`.** The previous form word-split *and* glob-expanded — `work *` from a populated directory would have leaked filenames into the URL field. The new form preserves the rest of the line verbatim into a single variable, so glob characters and internal whitespace are noticed by the sanitizer instead of silently expanded.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- 72 → 80 (+8). New coverage: backward-compat single-token form, two-token URL form (SSH, HTTPS, `~/`-prefixed), comment-and-URL form, copy-paste injection (rm -rf payload), backtick injection, `$()` injection, ANSI escape injection, multi-token URL, glob-character URL, and a positive test asserting standard git URL chars (`-`, `+`, `_`, `:`, `/`, `@`, `.`) round-trip unchanged.
|
||||||
|
|
||||||
## [0.1.0.1] - 2026-05-09
|
## [0.1.0.1] - 2026-05-09
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
@ -42,5 +61,6 @@ and this project adheres to a four-digit MAJOR.MINOR.PATCH.MICRO version scheme.
|
||||||
|
|
||||||
- 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.
|
- 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.1.0]: https://github.com/bmajewski/secrets/releases/tag/v0.1.1.0
|
||||||
[0.1.0.1]: https://github.com/bmajewski/secrets/releases/tag/v0.1.0.1
|
[0.1.0.1]: https://github.com/bmajewski/secrets/releases/tag/v0.1.0.1
|
||||||
[0.1.0.0]: https://github.com/bmajewski/secrets/releases/tag/v0.1.0.0
|
[0.1.0.0]: https://github.com/bmajewski/secrets/releases/tag/v0.1.0.0
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ The active store directory is picked by `resolve_store()` using these rules, hig
|
||||||
|
|
||||||
`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.
|
`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`.
|
`.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 ...`.
|
||||||
|
|
||||||
## Deploy Configuration
|
## Deploy Configuration
|
||||||
|
|
||||||
|
|
|
||||||
18
README.md
18
README.md
|
|
@ -267,6 +267,14 @@ git add .secrets-store
|
||||||
git commit -m "use work secrets store"
|
git commit -m "use work secrets store"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For teammates who haven't set up the store yet, you can include the store's git remote URL on the same line so they don't have to ask you for it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "work git@github.com:acme/work-secrets.git" > .secrets-store
|
||||||
|
```
|
||||||
|
|
||||||
|
That second token (whitespace-separated) is optional, ignored when the store already exists locally, and used as a copy-paste-ready hint in the missing-store error message when it doesn't. See "Joining a teammate's bound project" below.
|
||||||
|
|
||||||
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.
|
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:
|
When you push or pull from a non-default store, `secrets` echoes which one is active so you can spot mistakes immediately:
|
||||||
|
|
@ -278,7 +286,9 @@ When you push or pull from a non-default store, `secrets` echoes which one is ac
|
||||||
|
|
||||||
#### Joining a teammate's bound project
|
#### 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:
|
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.
|
||||||
|
|
||||||
|
When the original setter included the remote URL in `.secrets-store` (recommended), the error fills in the actual `git clone` command for you to copy-paste:
|
||||||
|
|
||||||
```
|
```
|
||||||
ERROR: Store not initialized: /Users/you/.secrets-work
|
ERROR: Store not initialized: /Users/you/.secrets-work
|
||||||
|
|
@ -286,16 +296,16 @@ ERROR: Store not initialized: /Users/you/.secrets-work
|
||||||
This path doesn't exist on this machine yet.
|
This path doesn't exist on this machine yet.
|
||||||
|
|
||||||
If you're joining a teammate's existing store:
|
If you're joining a teammate's existing store:
|
||||||
git clone <their-store-remote> /Users/you/.secrets-work
|
git clone git@github.com:acme/work-secrets.git /Users/you/.secrets-work
|
||||||
# then copy their key.txt to /Users/you/.secrets-work/key.txt
|
# then copy their key.txt to /Users/you/.secrets-work/key.txt
|
||||||
|
|
||||||
If you want a fresh new store at this path:
|
If you want a fresh new store at this path:
|
||||||
secrets --store /Users/you/.secrets-work init
|
secrets --store /Users/you/.secrets-work init
|
||||||
```
|
```
|
||||||
|
|
||||||
You'll need two things from the teammate who set it up:
|
If the URL wasn't in the file, the error shows `<their-store-remote>` as a placeholder — you'll need to ask the teammate who set it up. Either way, you need two things to finish onboarding:
|
||||||
|
|
||||||
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).
|
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). The `.secrets-store` file may already include this for you.
|
||||||
2. **The age key file** (`key.txt`) — same as standing up any new machine. AirDrop, scp, or USB.
|
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.
|
Once both are in place, `secrets pull` works.
|
||||||
|
|
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.1.0.1
|
0.1.1.0
|
||||||
|
|
|
||||||
100
secrets
100
secrets
|
|
@ -24,6 +24,10 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
STORE_SOURCE="default"
|
STORE_SOURCE="default"
|
||||||
# Path to the .secrets-store file that won resolution, if any.
|
# Path to the .secrets-store file that won resolution, if any.
|
||||||
_LAST_FOUND_AT=""
|
_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 flag value, captured by the pre-pass.
|
||||||
STORE_OVERRIDE=""
|
STORE_OVERRIDE=""
|
||||||
|
|
||||||
|
|
@ -41,12 +45,21 @@ check_initialized() {
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
if [ "$STORE_SOURCE" != "default" ]; then
|
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
|
die "Store not initialized: $SECRETS_DIR
|
||||||
Resolved from: $STORE_SOURCE
|
Resolved from: $STORE_SOURCE
|
||||||
This path doesn't exist on this machine yet.
|
This path doesn't exist on this machine yet.
|
||||||
|
|
||||||
If you're joining a teammate's existing store:
|
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
|
# then copy their key.txt to $SECRETS_DIR/key.txt
|
||||||
|
|
||||||
If you want a fresh new store at this path:
|
If you want a fresh new store at this path:
|
||||||
|
|
@ -100,10 +113,20 @@ derive_project_name() {
|
||||||
# 4. $HOME/.secrets default
|
# 4. $HOME/.secrets default
|
||||||
#
|
#
|
||||||
# .secrets-store file format: first non-empty non-comment line is the store
|
# .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)
|
# - absolute path (/Users/you/.secrets-work)
|
||||||
# - ~/path (expanded to $HOME/path)
|
# - ~/path (expanded to $HOME/path)
|
||||||
# - bare name (e.g. "work" → $HOME/.secrets-work; "default" → $HOME/.secrets)
|
# - 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
|
# Comments (#) and CRLF line endings are tolerated. NO shell expansion is
|
||||||
# applied to the file content — `$VAR`, `$(...)`, and backticks are read as
|
# applied to the file content — `$VAR`, `$(...)`, and backticks are read as
|
||||||
# literal characters to prevent code injection from a committed file.
|
# literal characters to prevent code injection from a committed file.
|
||||||
|
|
@ -136,8 +159,21 @@ _expand_store_path() {
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
# Read a .secrets-store file. Print the first non-empty non-comment line,
|
# Read a .secrets-store file. On success, print "<store-spec>\t<remote-url>"
|
||||||
# trimmed. Return 1 if no usable line is found.
|
# (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() {
|
_parse_secrets_store_file() {
|
||||||
local file="$1"
|
local file="$1"
|
||||||
local line
|
local line
|
||||||
|
|
@ -150,7 +186,25 @@ _parse_secrets_store_file() {
|
||||||
line="${line%"${line##*[![:space:]]}"}"
|
line="${line%"${line##*[![:space:]]}"}"
|
||||||
[ -z "$line" ] && continue
|
[ -z "$line" ] && continue
|
||||||
case "$line" in '#'*) continue ;; esac
|
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
|
return 0
|
||||||
done < "$file"
|
done < "$file"
|
||||||
return 1
|
return 1
|
||||||
|
|
@ -159,8 +213,9 @@ _parse_secrets_store_file() {
|
||||||
# Walk up from cwd looking for .secrets-store. Bounded by $HOME — never
|
# 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),
|
# 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`.
|
# the walk does not run. Symlinks are resolved with `cd -P`.
|
||||||
# On success: prints "<expanded-store-dir>\t<source-file-path>" and returns 0.
|
# On success: prints "<expanded-store-dir>\t<source-file-path>\t<remote-url>"
|
||||||
# The caller (resolve_store) splits the tab-separated tuple. We can't set a
|
# (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
|
# parent-shell variable from here because we're typically called inside
|
||||||
# `$(...)` command substitution, which runs in a subshell.
|
# `$(...)` command substitution, which runs in a subshell.
|
||||||
_find_secrets_store_file() {
|
_find_secrets_store_file() {
|
||||||
|
|
@ -182,11 +237,14 @@ _find_secrets_store_file() {
|
||||||
if [ -L "$dir/.secrets-store" ]; then
|
if [ -L "$dir/.secrets-store" ]; then
|
||||||
:
|
:
|
||||||
elif [ -f "$dir/.secrets-store" ]; then
|
elif [ -f "$dir/.secrets-store" ]; then
|
||||||
local content
|
local parsed
|
||||||
if content=$(_parse_secrets_store_file "$dir/.secrets-store"); then
|
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
|
local expanded
|
||||||
expanded=$(_expand_store_path "$content")
|
expanded=$(_expand_store_path "$spec")
|
||||||
printf '%s\t%s\n' "$expanded" "$dir/.secrets-store"
|
printf '%s\t%s\t%s\n' "$expanded" "$dir/.secrets-store" "$url"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
@ -198,18 +256,25 @@ _find_secrets_store_file() {
|
||||||
# Resolve the active store directory and update SECRETS_DIR + KEY_FILE.
|
# Resolve the active store directory and update SECRETS_DIR + KEY_FILE.
|
||||||
# Sets STORE_SOURCE to one of:
|
# Sets STORE_SOURCE to one of:
|
||||||
# "--store flag" | ".secrets-store file (<path>)" | "SECRETS_DIR env var" | "default"
|
# "--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() {
|
resolve_store() {
|
||||||
local resolved=""
|
local resolved=""
|
||||||
local source=""
|
local source=""
|
||||||
|
_REMOTE_URL=""
|
||||||
|
|
||||||
if [ -n "${STORE_OVERRIDE:-}" ]; then
|
if [ -n "${STORE_OVERRIDE:-}" ]; then
|
||||||
resolved=$(_expand_store_path "$STORE_OVERRIDE")
|
resolved=$(_expand_store_path "$STORE_OVERRIDE")
|
||||||
source="--store flag"
|
source="--store flag"
|
||||||
_LAST_FOUND_AT=""
|
_LAST_FOUND_AT=""
|
||||||
elif _find_result=$(_find_secrets_store_file); then
|
elif _find_result=$(_find_secrets_store_file); then
|
||||||
# _find_secrets_store_file returns "<dir>\t<source-file-path>"
|
# _find_secrets_store_file returns "<dir>\t<source-file-path>\t<url>"
|
||||||
resolved="${_find_result%%$'\t'*}"
|
# IFS=$'\t' prefix is scoped to this single `read` builtin — no manual
|
||||||
_LAST_FOUND_AT="${_find_result#*$'\t'}"
|
# 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)"
|
source=".secrets-store file ($_LAST_FOUND_AT)"
|
||||||
elif [ -n "${_USER_SECRETS_DIR:-}" ]; then
|
elif [ -n "${_USER_SECRETS_DIR:-}" ]; then
|
||||||
resolved="$_USER_SECRETS_DIR"
|
resolved="$_USER_SECRETS_DIR"
|
||||||
|
|
@ -868,6 +933,13 @@ Stores:
|
||||||
Bare names ("work") expand to ~/.secrets-work. The name "default"
|
Bare names ("work") expand to ~/.secrets-work. The name "default"
|
||||||
resolves to ~/.secrets. Run `secrets which` to inspect the active store.
|
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:
|
Workspaces:
|
||||||
With -w/--workspaces, reads package.json "workspaces" field to find
|
With -w/--workspaces, reads package.json "workspaces" field to find
|
||||||
workspace directories. Each workspace's secret files are stored under
|
workspace directories. Each workspace's secret files are stored under
|
||||||
|
|
|
||||||
|
|
@ -874,3 +874,174 @@ PKG
|
||||||
[ "$status" -ne 0 ]
|
[ "$status" -ne 0 ]
|
||||||
[[ "$output" == *"HOME"* ]]
|
[[ "$output" == *"HOME"* ]]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ─── EGB-282: optional remote URL in .secrets-store ──────────────────
|
||||||
|
|
||||||
|
@test "EGB-282: .secrets-store without URL still works (backward compat)" {
|
||||||
|
unset SECRETS_DIR
|
||||||
|
mkdir -p "$HOME/.secrets-work"
|
||||||
|
mkdir -p "$WORK_DIR/proj"
|
||||||
|
# Single token only — same as v0.1.0.0 format
|
||||||
|
echo "work" > "$WORK_DIR/proj/.secrets-store"
|
||||||
|
cd "$WORK_DIR/proj"
|
||||||
|
run "$SECRETS_BIN" which
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"$HOME/.secrets-work"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "EGB-282: .secrets-store with URL parses both tokens" {
|
||||||
|
unset SECRETS_DIR
|
||||||
|
mkdir -p "$WORK_DIR/proj"
|
||||||
|
echo "work git@github.com:acme/work-secrets.git" > "$WORK_DIR/proj/.secrets-store"
|
||||||
|
cd "$WORK_DIR/proj"
|
||||||
|
# Store doesn't exist yet — pull should fail with the directed error
|
||||||
|
# that includes the actual URL (not the placeholder).
|
||||||
|
run "$SECRETS_BIN" pull
|
||||||
|
[ "$status" -eq 1 ]
|
||||||
|
[[ "$output" == *"git clone git@github.com:acme/work-secrets.git $HOME/.secrets-work"* ]]
|
||||||
|
# Placeholder must NOT appear when a real URL was supplied
|
||||||
|
[[ "$output" != *"<their-store-remote>"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "EGB-282: missing-store error still works without URL (placeholder)" {
|
||||||
|
unset SECRETS_DIR
|
||||||
|
mkdir -p "$WORK_DIR/proj"
|
||||||
|
echo "missing-only" > "$WORK_DIR/proj/.secrets-store"
|
||||||
|
cd "$WORK_DIR/proj"
|
||||||
|
run "$SECRETS_BIN" pull
|
||||||
|
[ "$status" -eq 1 ]
|
||||||
|
# No URL given — placeholder is the right behavior.
|
||||||
|
[[ "$output" == *"<their-store-remote>"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "EGB-282: https URL is preserved literally" {
|
||||||
|
unset SECRETS_DIR
|
||||||
|
mkdir -p "$WORK_DIR/proj"
|
||||||
|
echo "work https://github.com/acme/work-secrets.git" > "$WORK_DIR/proj/.secrets-store"
|
||||||
|
cd "$WORK_DIR/proj"
|
||||||
|
run "$SECRETS_BIN" pull
|
||||||
|
[ "$status" -eq 1 ]
|
||||||
|
[[ "$output" == *"https://github.com/acme/work-secrets.git"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "EGB-282: ~/-prefixed path with URL works" {
|
||||||
|
unset SECRETS_DIR
|
||||||
|
mkdir -p "$WORK_DIR/proj"
|
||||||
|
printf '~/.secrets-x git@github.com:acme/x.git\n' > "$WORK_DIR/proj/.secrets-store"
|
||||||
|
cd "$WORK_DIR/proj"
|
||||||
|
run "$SECRETS_BIN" pull
|
||||||
|
[ "$status" -eq 1 ]
|
||||||
|
[[ "$output" == *"git clone git@github.com:acme/x.git $HOME/.secrets-x"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "EGB-282: comments before URL line are still skipped" {
|
||||||
|
unset SECRETS_DIR
|
||||||
|
mkdir -p "$WORK_DIR/proj"
|
||||||
|
printf '# this binding was set by alice\n# please do not delete\nwork git@github.com:acme/work-secrets.git\n' > "$WORK_DIR/proj/.secrets-store"
|
||||||
|
cd "$WORK_DIR/proj"
|
||||||
|
run "$SECRETS_BIN" pull
|
||||||
|
[ "$status" -eq 1 ]
|
||||||
|
[[ "$output" == *"git clone git@github.com:acme/work-secrets.git"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── EGB-282 adversarial regressions: URL injection prevention ────────
|
||||||
|
|
||||||
|
@test "EGB-282 SECURITY: URL with shell metachars is dropped (rm -rf payload)" {
|
||||||
|
unset SECRETS_DIR
|
||||||
|
mkdir -p "$WORK_DIR/proj"
|
||||||
|
# The classic copy-paste shell injection: a `;` after the "URL" splits
|
||||||
|
# the rendered git clone into two commands, the second of which is the
|
||||||
|
# attacker payload. The teammate copy-pasting the directed-error one-liner
|
||||||
|
# would execute `rm -rf ~`. The parser must reject this.
|
||||||
|
printf 'work evil.git;rm -rf ~\n' > "$WORK_DIR/proj/.secrets-store"
|
||||||
|
cd "$WORK_DIR/proj"
|
||||||
|
run "$SECRETS_BIN" pull
|
||||||
|
[ "$status" -eq 1 ]
|
||||||
|
# Must use the placeholder, NOT the attacker URL
|
||||||
|
[[ "$output" == *"<their-store-remote>"* ]]
|
||||||
|
[[ "$output" != *"rm -rf"* ]]
|
||||||
|
# And must have warned the user that something was dropped
|
||||||
|
[[ "$output" == *"WARNING"* ]]
|
||||||
|
[[ "$output" == *"unsafe"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "EGB-282 SECURITY: URL with backticks is dropped" {
|
||||||
|
unset SECRETS_DIR
|
||||||
|
mkdir -p "$WORK_DIR/proj"
|
||||||
|
printf 'work evil.git`whoami`\n' > "$WORK_DIR/proj/.secrets-store"
|
||||||
|
cd "$WORK_DIR/proj"
|
||||||
|
run "$SECRETS_BIN" pull
|
||||||
|
[ "$status" -eq 1 ]
|
||||||
|
[[ "$output" == *"<their-store-remote>"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "EGB-282 SECURITY: URL with command substitution \$() is dropped" {
|
||||||
|
unset SECRETS_DIR
|
||||||
|
mkdir -p "$WORK_DIR/proj"
|
||||||
|
printf 'work evil.git$(whoami)\n' > "$WORK_DIR/proj/.secrets-store"
|
||||||
|
cd "$WORK_DIR/proj"
|
||||||
|
run "$SECRETS_BIN" pull
|
||||||
|
[ "$status" -eq 1 ]
|
||||||
|
[[ "$output" == *"<their-store-remote>"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "EGB-282 SECURITY: URL with ANSI escape is dropped (terminal-spoof prevention)" {
|
||||||
|
unset SECRETS_DIR
|
||||||
|
mkdir -p "$WORK_DIR/proj"
|
||||||
|
# ESC ([ \x1b) lets a malicious URL render differently from what gets pasted
|
||||||
|
printf 'work evil.git\x1b[2K\r\n' > "$WORK_DIR/proj/.secrets-store"
|
||||||
|
cd "$WORK_DIR/proj"
|
||||||
|
run "$SECRETS_BIN" pull
|
||||||
|
[ "$status" -eq 1 ]
|
||||||
|
[[ "$output" == *"<their-store-remote>"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "EGB-282 SECURITY: multi-token URL ('work url1 url2') is dropped" {
|
||||||
|
unset SECRETS_DIR
|
||||||
|
mkdir -p "$WORK_DIR/proj"
|
||||||
|
# `git clone url1 url2 /path` would clone url1 into directory `url2` — wrong
|
||||||
|
# behavior either way. Treat any whitespace inside the URL token as unsafe.
|
||||||
|
printf 'work url1 url2\n' > "$WORK_DIR/proj/.secrets-store"
|
||||||
|
cd "$WORK_DIR/proj"
|
||||||
|
run "$SECRETS_BIN" pull
|
||||||
|
[ "$status" -eq 1 ]
|
||||||
|
[[ "$output" == *"<their-store-remote>"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "EGB-282 SECURITY: glob char in URL is dropped (no expansion either way)" {
|
||||||
|
unset SECRETS_DIR
|
||||||
|
mkdir -p "$WORK_DIR/proj"
|
||||||
|
# In v0.1.1.0-alpha (set -- $line), this used to expand to filenames.
|
||||||
|
# The fixed parser uses `read -r`, so it doesn't glob — but glob chars
|
||||||
|
# are still rejected as suspicious.
|
||||||
|
printf 'work /tmp/*\n' > "$WORK_DIR/proj/.secrets-store"
|
||||||
|
cd "$WORK_DIR/proj"
|
||||||
|
run "$SECRETS_BIN" pull
|
||||||
|
[ "$status" -eq 1 ]
|
||||||
|
[[ "$output" == *"<their-store-remote>"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "EGB-282: spec parsing is glob-safe (work * does NOT expand)" {
|
||||||
|
unset SECRETS_DIR
|
||||||
|
mkdir -p "$WORK_DIR/proj"
|
||||||
|
# Create files in cwd that would expand if `set -- $line` were used.
|
||||||
|
touch "$WORK_DIR/proj/file1" "$WORK_DIR/proj/file2"
|
||||||
|
printf 'work *\n' > "$WORK_DIR/proj/.secrets-store"
|
||||||
|
cd "$WORK_DIR/proj"
|
||||||
|
run "$SECRETS_BIN" which
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
# Spec is the literal "work" (resolves to ~/.secrets-work). The "*" gets
|
||||||
|
# rejected as unsafe URL and dropped. Resolution works; no globbing.
|
||||||
|
[[ "$output" == *"$HOME/.secrets-work"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "EGB-282: URL with - + _ : / @ . is preserved (positive test)" {
|
||||||
|
unset SECRETS_DIR
|
||||||
|
mkdir -p "$WORK_DIR/proj"
|
||||||
|
# Standard git URL chars must NOT be rejected
|
||||||
|
printf 'work git+ssh://user@host:2222/path/to-repo_v2.git\n' > "$WORK_DIR/proj/.secrets-store"
|
||||||
|
cd "$WORK_DIR/proj"
|
||||||
|
run "$SECRETS_BIN" pull
|
||||||
|
[ "$status" -eq 1 ]
|
||||||
|
[[ "$output" == *"git+ssh://user@host:2222/path/to-repo_v2.git"* ]]
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue