diff --git a/api/tests/test_lockbox_put.py b/api/tests/test_lockbox_put.py new file mode 100644 index 0000000..37e8d33 --- /dev/null +++ b/api/tests/test_lockbox_put.py @@ -0,0 +1,77 @@ +"""lockbox-put against a LOCAL fake lockbox repo only (never the real one).""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +FAKE = "Zk3vQ8mT1pLw7Xn2Rb5Hd9Yc" # synthetic + + +def sh(*a, cwd=None): + return subprocess.run(a, cwd=cwd, check=True, capture_output=True, text=True) + + +def make_remote(tmp_path): + work = tmp_path / "seed" + work.mkdir() + sh("git", "init", "-q", "-b", "main", cwd=work) + (work / "ACCESS_LOCKBOX.md").write_text("# LOCKBOX\n\n- **`EXISTING_KEY`**: `abcdefgh12345678`\nOLD_ENV_KEY=whatever123456\n") + sh("git", "add", "-A", cwd=work) + sh("git", "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "seed", cwd=work) + bare = tmp_path / "remote.git" + sh("git", "clone", "-q", "--bare", str(work), str(bare)) + return bare + + +def put(tmp_path, bare, key, content, mode=0o600): + f = tmp_path / "val" + f.write_text(content) + os.chmod(f, mode) + e = {**os.environ, "LOCKBOX_PUT_REPO": str(bare), "LOCKBOX_PUT_NO_PR": "1", "HOME": str(tmp_path / "h")} + (tmp_path / "h" / ".cache").mkdir(parents=True, exist_ok=True) + r = subprocess.run([sys.executable, str(ROOT / "scripts" / "lockbox_put.py"), key, str(f), "--lane", "test", "--note", "n"], + capture_output=True, text=True, env=e) + return r.returncode, r.stdout + r.stderr + + +def test_appends_one_key_by_branch_and_never_echoes_value(tmp_path): + bare = make_remote(tmp_path) + rc, out = put(tmp_path, bare, "NEW_TEST_KEY", FAKE) + assert rc == 0 and "pushed branch lockbox-put/new_test_key-" in out + assert FAKE not in out and FAKE[:6] not in out + br = [b.strip() for b in sh("git", "branch", "--list", "lockbox-put/*", cwd=bare).stdout.splitlines()] + assert len(br) == 1 + diff = sh("git", "diff", "--numstat", f"main..{br[0]}", cwd=bare).stdout.split() + assert diff[1] == "0" and diff[2] == "ACCESS_LOCKBOX.md" # additions only + content = sh("git", "show", f"{br[0]}:ACCESS_LOCKBOX.md", cwd=bare).stdout + assert f"- **`NEW_TEST_KEY`**: `{FAKE}`" in content and "EXISTING_KEY" in content + # main is untouched + assert FAKE not in sh("git", "show", "main:ACCESS_LOCKBOX.md", cwd=bare).stdout + + +def test_refuses_existing_key_both_formats_and_bad_input(tmp_path): + bare = make_remote(tmp_path) + for k in ("EXISTING_KEY", "OLD_ENV_KEY"): + rc, out = put(tmp_path, bare, k, FAKE) + assert rc == 3 and "already exists" in out and FAKE not in out + assert put(tmp_path, bare, "lower_case", FAKE)[0] == 2 + assert put(tmp_path, bare, "OK_KEY_1", FAKE, mode=0o644)[0] == 2 # not 0600 + assert put(tmp_path, bare, "OK_KEY_2", "has space `tick`")[0] == 2 # unsafe value + assert not sh("git", "branch", "--list", "lockbox-put/*", cwd=bare).stdout.strip() # nothing pushed + + +def test_symlink_refused(tmp_path): + bare = make_remote(tmp_path) + real = tmp_path / "real" + real.write_text(FAKE) + os.chmod(real, 0o600) + link = tmp_path / "link" + link.symlink_to(real) + e = {**os.environ, "LOCKBOX_PUT_REPO": str(bare), "LOCKBOX_PUT_NO_PR": "1"} + r = subprocess.run([sys.executable, str(ROOT / "scripts" / "lockbox_put.py"), "SYM_KEY", str(link)], + capture_output=True, text=True, env=e) + assert r.returncode == 2 and FAKE not in r.stdout + r.stderr diff --git a/scripts/install_secret_tools.sh b/scripts/install_secret_tools.sh index 8dc7204..84101ea 100755 --- a/scripts/install_secret_tools.sh +++ b/scripts/install_secret_tools.sh @@ -5,10 +5,10 @@ set -euo pipefail here=$(cd "$(dirname "$0")" && pwd) dest="$HOME/.local/share/secret-tools" mkdir -p "$dest" "$HOME/.local/bin" -cp "$here/secret_shapes.py" "$here/secret_scan.py" "$here/env_names.py" "$dest/" -for pair in "secret-scan:secret_scan.py" "env-names:env_names.py"; do +cp "$here/secret_shapes.py" "$here/secret_scan.py" "$here/env_names.py" "$here/lockbox_put.py" "$dest/" +for pair in "secret-scan:secret_scan.py" "env-names:env_names.py" "lockbox-put:lockbox_put.py"; do n=${pair%%:*}; f=${pair##*:} printf '#!/usr/bin/env bash\nexec python3 "%s/%s" "$@"\n' "$dest" "$f" > "$HOME/.local/bin/$n" chmod 755 "$HOME/.local/bin/$n" done -echo "installed secret-scan and env-names (shapes from secret_shapes.py, same as secret-guard)" +echo "installed secret-scan, env-names and lockbox-put (shapes from secret_shapes.py, same as secret-guard)" diff --git a/scripts/lockbox_put.py b/scripts/lockbox_put.py new file mode 100644 index 0000000..c0d6bb1 --- /dev/null +++ b/scripts/lockbox_put.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""lockbox-put: add ONE secret to the lockbox by PR, without anyone reading, printing or +grepping the lockbox (Boss rule 10-01; Windy Hub ruling). + + lockbox-put KEY FILE [--lane NAME] [--note TEXT] + +KEY exact name, ^[A-Z][A-Z0-9_]{2,63}$ . FILE a 0600 file you own (not a symlink) whose +content is the value (one line). Appends ONE line `- **`KEY`**: ``` (the format +lockbox-get reads) under a new heading at the END of ACCESS_LOCKBOX.md in a fresh temp clone, +on a new branch, and opens a kit-army-config PR. Append-only: the diff is verified to be one +file, additions only, before pushing. REFUSES if KEY already exists (a bool computed in +memory; no line, value or location is ever printed). Reviewers see the KEY NAME + lane only +if they look at the diff; the PR body never carries the value. Never echoes the value. +Env (tests): LOCKBOX_PUT_REPO=, LOCKBOX_PUT_NO_PR=1. +""" +from __future__ import annotations + +import argparse +import datetime as dt +import os +import re +import shutil +import stat +import subprocess +import sys +import tempfile +from pathlib import Path + +REPO = os.environ.get("LOCKBOX_PUT_REPO", "https://github.com/sneakyfree/kit-army-config.git") +SLUG = "sneakyfree/kit-army-config" +KEY_RE = re.compile(r"^[A-Z][A-Z0-9_]{2,63}$") +VAL_RE = re.compile(r"^[A-Za-z0-9._~+/=:@%,-]{8,512}$") # no backtick, quote, space or newline + + +def die(msg: str, code: int = 2): + print(f"lockbox-put: {msg}") + sys.exit(code) + + +def git(cwd: str, *a: str, quiet=True) -> subprocess.CompletedProcess: + # stderr is dropped: git/gh errors can echo URLs; stdout only when we need it. + return subprocess.run(["git", "-C", cwd, *a], capture_output=True, text=True, errors="ignore") + + +def key_exists(clone: str, key: str) -> bool: + """True if KEY is already defined anywhere lockbox-get reads. Bool only, nothing printed.""" + pat_env = re.compile(r"^" + re.escape(key) + r"=") + pat_md = re.compile(r"^\s*[-*]?\s*\*\*`" + re.escape(key) + r"`\*\*\s*:") + paths = [Path(clone, "ACCESS_LOCKBOX.md")] + [ + Path(dp, f) for dp, _d, fs in os.walk(Path(clone, "secrets")) for f in fs if f.endswith(".env")] + for p in paths: + try: + with open(p, errors="ignore") as fh: + for line in fh: + if pat_env.match(line) or pat_md.match(line): + return True + except OSError: + continue + return False + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(prog="lockbox-put") + ap.add_argument("key") + ap.add_argument("file") + ap.add_argument("--lane", default=os.environ.get("LOCKBOX_LANE", "a lane")) + ap.add_argument("--note", default="") + a = ap.parse_args(argv) + if not KEY_RE.match(a.key): + die("KEY must match ^[A-Z][A-Z0-9_]{2,63}$") + try: + st = os.lstat(a.file) + except OSError: + die("FILE not found") + if stat.S_ISLNK(st.st_mode) or not stat.S_ISREG(st.st_mode): + die("FILE must be a regular file (not a symlink)") + if st.st_uid != os.getuid() or (st.st_mode & 0o077): + die("FILE must be owned by you and mode 0600") + with open(a.file) as fh: + value = fh.read().strip() + if not VAL_RE.match(value): + die("value must be one line of 8-512 chars from [A-Za-z0-9._~+/=:@%,-] (no spaces, quotes, backticks)") + note = re.sub(r"[`\n\r]", " ", a.note)[:160] + lane = re.sub(r"[^A-Za-z0-9 ._-]", "", a.lane)[:40] + tmp = tempfile.mkdtemp(prefix="lockbox-put-", dir=str(Path.home() / ".cache") if (Path.home() / ".cache").is_dir() else None) + os.chmod(tmp, 0o700) + clone = os.path.join(tmp, "k") + try: + if subprocess.run(["git", "clone", "-q", "--depth", "1", REPO, clone], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode != 0: + die("clone failed (details withheld: URLs can carry tokens)") + if key_exists(clone, a.key): + die(f"{a.key} already exists: refusing to overwrite (append-only; pick a new KEY)", 3) + lb = Path(clone, "ACCESS_LOCKBOX.md") + if not lb.is_file(): + die("ACCESS_LOCKBOX.md not found in the repo") + today = dt.date.today().isoformat() + stamp = dt.datetime.now(dt.UTC).strftime("%Y%m%d%H%M") + branch = f"lockbox-put/{a.key.lower()}-{stamp}" + with open(lb, "a") as fh: + fh.write(f"\n## šŸ—ļø {a.key} (added {today} by {lane} via lockbox-put)\n") + fh.write(f"- **`{a.key}`**: `{value}`\n") + if note: + fh.write(f"- **Note:** {note}\n") + git(clone, "checkout", "-q", "-b", branch) + git(clone, "add", "ACCESS_LOCKBOX.md") + ns = git(clone, "diff", "--cached", "--numstat").stdout.split() + # numstat: ; exactly one file, no deletions + if len(ns) != 3 or ns[1] != "0" or ns[2] != "ACCESS_LOCKBOX.md": + die("diff is not a pure append to ACCESS_LOCKBOX.md: aborting, nothing pushed") + msg = f"lockbox: add {a.key} (via lockbox-put, {lane})\n\nCo-Authored-By: Claude Sonnet 5.5 " + if git(clone, "-c", "user.name=lockbox-put", "-c", "user.email=lockbox-put@windy.invalid", + "commit", "-q", "-m", msg).returncode != 0: + die("commit failed") + if git(clone, "push", "-q", "origin", branch).returncode != 0: + die("push failed (details withheld)") + if os.environ.get("LOCKBOX_PUT_NO_PR"): + print(f"ok: pushed branch {branch} ({a.key}); PR skipped") + return 0 + body = (f"Adds exactly one key: `{a.key}` (by {lane}). Append-only, one file, no deletions " + f"(verified before push). Review by KEY NAME only; do not paste the value anywhere.\n\n" + f"{note}\n\nšŸ¤– Generated with [Claude Code](https://claude.com/claude-code)") + r = subprocess.run(["gh", "pr", "create", "-R", SLUG, "--head", branch, "--base", "main", + "--title", f"lockbox: add {a.key} ({lane})", "--body", body], + capture_output=True, text=True) + if r.returncode != 0: + die("branch pushed but `gh pr create` failed; open the PR for the branch by hand") + print(f"ok: {a.key} added via PR {r.stdout.strip().splitlines()[-1]}") + return 0 + finally: + shutil.rmtree(tmp, ignore_errors=True) + + +if __name__ == "__main__": + try: + sys.exit(main()) + except SystemExit: + raise + except Exception as e: # never a traceback: it could carry data + print(f"lockbox-put: error: {type(e).__name__}") + sys.exit(2)