From 6f19e23eaf7c70306131f70ae37dff873d226334 Mon Sep 17 00:00:00 2001 From: Kit OC5 Date: Thu, 1 Oct 2026 19:23:47 -0400 Subject: [PATCH] lockbox-names: names-only lister (section + label + resolvable yes/no/dup), never a value So lanes can discover what the lockbox holds without opening it (Super Admin 50 request). Co-Authored-By: Claude Sonnet 5.5 --- api/tests/test_lockbox_names.py | 64 +++++++++++++++ scripts/install_secret_tools.sh | 6 +- scripts/lockbox_names.py | 134 ++++++++++++++++++++++++++++++++ 3 files changed, 201 insertions(+), 3 deletions(-) create mode 100644 api/tests/test_lockbox_names.py create mode 100644 scripts/lockbox_names.py diff --git a/api/tests/test_lockbox_names.py b/api/tests/test_lockbox_names.py new file mode 100644 index 0000000..a0efe41 --- /dev/null +++ b/api/tests/test_lockbox_names.py @@ -0,0 +1,64 @@ +"""lockbox-names: headings + labels + resolvable, NEVER a value or prose after a label.""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +V1 = "Qm7xT2vLp9RkZw4HnB8dYc3S" # synthetic values +V2 = "other-fake-value-1234567890" +V3 = "dup-one-aaaaaaaaaaaaaaaa" +V4 = "dup-two-bbbbbbbbbbbbbbbb" +PROSE = "ZZPROSEZZ-not-for-printing" + + +def make(tmp_path): + r = tmp_path / "kit" + (r / "secrets" / "x").mkdir(parents=True) + (r / "ACCESS_LOCKBOX.md").write_text( + "# LOCKBOX\n\n## 🔷 AZURE signing (added 10-01)\n" + f"- **Tenant:** {PROSE} lives in the portal\n" + f"- **`AZURE_CLIENT_ID`**: `{V1}`\n" + f"- **Secret (AZURE_CLIENT_SECRET):** `{V2}`\n" + "## GOOGLE oauth\n" + f"GOOGLE_OAUTH_CLIENT_ID={V2}\n" + f"- **`DUP_KEY`**: `{V3}`\n- **`DUP_KEY`**: `{V4}`\n" + f"## stray\n**{V1}** is a heading-like bold that is secret shaped? no, just label\n") + (r / "secrets" / "x" / "a.env").write_text(f"FILE_KEY={V1}\n") + subprocess.run(["git", "init", "-q", "-b", "main"], cwd=r, check=True) + return r + + +def run(r, *args): + e = {**os.environ, "LOCKBOX_REPO": str(r), "LOCKBOX_REF": "WORKTREE"} + p = subprocess.run([sys.executable, str(ROOT / "scripts" / "lockbox_names.py"), *args], + capture_output=True, text=True, env=e) + return p.returncode, p.stdout + p.stderr + + +def test_lists_labels_and_resolvable_without_values_or_prose(tmp_path): + r = make(tmp_path) + rc, out = run(r, "AZURE|GOOGLE|FILE|DUP") + assert rc == 0 + assert "AZURE signing (added 10-01) | AZURE_CLIENT_ID | md | yes" in out + assert "| GOOGLE_OAUTH_CLIENT_ID | env | yes" in out + assert "| FILE_KEY | file | yes" in out + assert "| DUP_KEY | md | dup" in out + # a prose label is listed but never resolvable, and nothing after the label leaks + assert "| Tenant: " not in out or "| Tenant" in out + assert "| label | no" in out + for secret in (V1, V2, V3, V4, PROSE, "lives in the portal"): + assert secret not in out + for i in range(0, len(secret) - 7): + assert secret[i:i + 8] not in out + + +def test_filter_and_bad_regex(tmp_path): + r = make(tmp_path) + rc, out = run(r, "NOSUCHTHING") + assert rc == 0 and "0 entries" in out + rc, out = run(r, "(") + assert rc == 2 and "bad regex" in out diff --git a/scripts/install_secret_tools.sh b/scripts/install_secret_tools.sh index 84101ea..fb9c634 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" "$here/lockbox_put.py" "$dest/" -for pair in "secret-scan:secret_scan.py" "env-names:env_names.py" "lockbox-put:lockbox_put.py"; do +cp "$here/secret_shapes.py" "$here/secret_scan.py" "$here/env_names.py" "$here/lockbox_put.py" "$here/lockbox_names.py" "$dest/" +for pair in "secret-scan:secret_scan.py" "env-names:env_names.py" "lockbox-put:lockbox_put.py" "lockbox-names:lockbox_names.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, env-names and lockbox-put (shapes from secret_shapes.py, same as secret-guard)" +echo "installed secret-scan, env-names, lockbox-put and lockbox-names (shapes from secret_shapes.py, same as secret-guard)" diff --git a/scripts/lockbox_names.py b/scripts/lockbox_names.py new file mode 100644 index 0000000..31f9fe2 --- /dev/null +++ b/scripts/lockbox_names.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""lockbox-names: DISCOVER what the lockbox holds without reading it (Boss rule 10-01). + + lockbox-names [REGEX] (case-insensitive; matches the section heading or the label) + +Prints one row per entry: SECTION HEADING | LABEL | kind | resolvable + kind env = `KEY=value` line md = `- **`KEY`**: `value`` line + label = a bold prose label (`**Password (X):** ...`) file = secrets/**/*.env key + resolvable yes = `lockbox-get LABEL FILE` returns exactly one value + dup = defined with 2+ different values (lockbox-get refuses) + no = a prose-only label, or not an exact key + +NEVER prints a value or any prose after a label. A label or heading that itself looks +like a secret (secret_shapes) is replaced by . Reads the COMMITTED lockbox at +origin/main (like lockbox-get; LOCKBOX_REF= or WORKTREE overrides). Memory only, stdout only. +""" +from __future__ import annotations + +import hashlib +import os +import re +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import secret_shapes as ss # noqa: E402 + +REPO = os.environ.get("LOCKBOX_REPO", os.path.expanduser("~/kit-army-config")) +REF = os.environ.get("LOCKBOX_REF", "origin/main") +HEAD = re.compile(r"^#{1,6}\s+(.*\S)\s*$") +ENV = re.compile(r"^([A-Z][A-Z0-9_]{2,})=(.*)$") +MD = re.compile(r"^\s*[-*]?\s*\*\*`([A-Za-z0-9_]+)`\*\*\s*:\s*`([^`]+)`") +LABEL = re.compile(r"\*\*([^*`]{2,70}?)\*\*") + + +def git(*a: str) -> subprocess.CompletedProcess: + return subprocess.run(["git", "-C", REPO, *a], capture_output=True, text=True, errors="ignore") + + +def read_sources() -> dict[str, str]: + """{path: text} for ACCESS_LOCKBOX.md and secrets/**/*.env.""" + if REF == "WORKTREE": + out = {} + for p in [Path(REPO, "ACCESS_LOCKBOX.md"), *Path(REPO, "secrets").rglob("*.env")]: + if p.is_file(): + out[str(p.relative_to(REPO))] = p.read_text(errors="ignore") + return out + if REF.startswith("origin/"): + git("fetch", "-q", "origin", REF.split("/", 1)[1]) + names = ["ACCESS_LOCKBOX.md"] + [ + p for p in git("ls-tree", "-r", "--name-only", REF, "--", "secrets").stdout.splitlines() if p.endswith(".env")] + out = {} + for n in names: + r = git("show", f"{REF}:{n}") + if r.returncode == 0: + out[n] = r.stdout + return out + + +def safe(text: str, limit: int = 70) -> str: + text = re.sub(r"\s+", " ", text).strip() + return "" if ss.find(text) else text[:limit] + + +def h(v: str) -> str: + return hashlib.sha256(v.strip().strip('"').strip("'").encode()).hexdigest()[:16] + + +def collect(src: dict[str, str]): + """(rows, values) where values[KEY] = {hash,...} for resolvability; nothing printed from it.""" + rows, values = [], {} + for path, text in src.items(): + section = path + for line in text.splitlines(): + m = HEAD.match(line) if path.endswith(".md") else None + if m: + section = safe(m.group(1), 90) + continue + m = ENV.match(line) + if m: + values.setdefault(m.group(1), set()).add(h(m.group(2))) + rows.append((section, m.group(1), "file" if path.startswith("secrets/") else "env")) + continue + m = MD.match(line) + if m: + values.setdefault(m.group(1), set()).add(h(m.group(2))) + rows.append((section, m.group(1), "md")) + continue + if path.endswith(".md"): + mm = LABEL.search(line) + if mm and not mm.group(1).startswith("http"): + rows.append((section, safe(mm.group(1)), "label")) + return rows, values + + +def resolvable(label: str, kind: str, values) -> str: + if kind not in ("env", "md", "file"): + return "no" + n = len(values.get(label, ())) + return "yes" if n == 1 else "dup" if n > 1 else "no" + + +def main(argv=None) -> int: + argv = list(sys.argv[1:] if argv is None else argv) + rx = re.compile(argv[0], re.I) if argv else None + src = read_sources() + if not src: + print("lockbox-names: cannot read the lockbox") + return 2 + rows, values = collect(src) + seen, n = set(), 0 + for section, label, kind in rows: + if rx and not (rx.search(section) or rx.search(label)): + continue + key = (section, label, kind) + if key in seen: + continue + seen.add(key) + n += 1 + print(f"{section} | {label} | {kind} | {resolvable(label, kind, values)}") + print(f"# {n} entr{'y' if n == 1 else 'ies'}; names only, values never printed") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except re.error: + print("lockbox-names: bad regex") + sys.exit(2) + except Exception as e: # never a traceback + print(f"lockbox-names: error: {type(e).__name__}") + sys.exit(2)