From 836ec3cb1db958c7e4e2919c474a4325ffa697fb Mon Sep 17 00:00:00 2001 From: fred Date: Fri, 21 Aug 2026 17:54:18 -0500 Subject: [PATCH 1/3] feat(fleet-tools): mint-seat-credential.sh joins the framework toolkit Moves seat credential minting out of a brain-local fleet/bin into packages/mosaic/framework/tools/fleet/, parameterized for any deployment: - MOSAIC_ADMIN_SEAT (or --admin-seat) names the seat whose admin token calls the Gitea admin API; no seat name is hardcoded. - MOSAIC_GITEA_INSTANCES / MOSAIC_GITEA_URL_ select and override instances, the same convention seat-logins.sh already uses. - MOSAIC_SEAT_EMAIL_DOMAIN sets the account email domain. - tea projection calls the sibling seat-logins.sh, not a brain-local copy. Hermetic suite test-mint-seat-credential.sh (mock curl, sandboxed brain home, no tea, no network) pins: slot written from the mint response at mode 600; admin seat must be configured (rc=3) and its token present (rc=1, no API call); instance selection and URL override; admin token value never echoed. Joins ci.yml and the verify-release canonical list. Adds tools/fleet/README.md. Plan: docs/plans/2026-08-21_git-operations-toolkit.md step 6 (first of three scripts; new-seat.sh and launch-seat.sh need a design ruling, see the plan). --- .woodpecker/ci.yml | 5 + .../mosaic/framework/tools/fleet/README.md | 23 +++ .../tools/fleet/mint-seat-credential.sh | 150 ++++++++++++++++++ .../tools/fleet/test-mint-seat-credential.sh | 106 +++++++++++++ scripts/verify-release.mjs | 1 + 5 files changed, 285 insertions(+) create mode 100644 packages/mosaic/framework/tools/fleet/README.md create mode 100644 packages/mosaic/framework/tools/fleet/mint-seat-credential.sh create mode 100755 packages/mosaic/framework/tools/fleet/test-mint-seat-credential.sh diff --git a/.woodpecker/ci.yml b/.woodpecker/ci.yml index 561112ac..f2fe090e 100644 --- a/.woodpecker/ci.yml +++ b/.woodpecker/ci.yml @@ -100,6 +100,11 @@ steps: # repo. Pins that comment BODIES render on both paths and that a tea # failure is named as what it was (git-config vs credential). - bash packages/mosaic/framework/tools/git/test-issue-view-comments.sh + # Hermetic regression for mint-seat-credential.sh (fleet onboarding moved into + # the framework): mock curl, sandboxed brain home, no tea, no network. Pins + # that the admin seat is configured rather than hardcoded and that the seat + # slot is written from the mint response at mode 600. + - bash packages/mosaic/framework/tools/fleet/test-mint-seat-credential.sh # Hermetic behavioural regression for the PreToolUse wrapper guard: proves # it still blocks the three mistakes AND still lets reads, unwrapped # endpoints and ordinary commands through. Both directions are asserted — diff --git a/packages/mosaic/framework/tools/fleet/README.md b/packages/mosaic/framework/tools/fleet/README.md new file mode 100644 index 00000000..89a65120 --- /dev/null +++ b/packages/mosaic/framework/tools/fleet/README.md @@ -0,0 +1,23 @@ +# Fleet tools + +Seat lifecycle tools for a Mosaic fleet. Paths are relative to +`packages/mosaic/framework/tools/fleet/` (deployed to `~/.config/mosaic/tools/fleet/`). + +| Script | Purpose | +| ----------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `start-agent-session.sh` | launch, stop, or attach a roster-driven agent session (reads `.env.generated`, honours `MOSAIC_TMUX_SOCKET`) | +| `seat-logins.sh` | project seat tokens into `tea` logins named `-` (dry-run by default, `--apply`, `--adopt`) | +| `mint-seat-credential.sh` | create the Gitea account for a seat on every configured instance, mint a token, write the seat's credential slot, then project it into `tea` | +| `start-interaction-service.sh`, `print-interaction-effective-policy.sh`, `start-tmux-holder.sh` | operator interaction service and tmux holder | + +## Onboarding a seat's credential + +``` +MOSAIC_ADMIN_SEAT= mint-seat-credential.sh +``` + +- The admin token is read from `$MOSAIC_BRAIN_HOME/fleet/agents//secrets/gitea--.token`. It is never printed. +- Instances default to the map shared with `seat-logins.sh`; `MOSAIC_GITEA_INSTANCES="a b"` limits the set and `MOSAIC_GITEA_URL_` overrides a server URL. +- The seat slot is written from the mint response: `.token`, `.scopes` (what was granted), `.principal`, each mode 600. +- `tea` absent is a warning, not a failure: REST-path wrappers work with the token alone. +- Regression suite: `test-mint-seat-credential.sh` (hermetic, mock curl, no network). diff --git a/packages/mosaic/framework/tools/fleet/mint-seat-credential.sh b/packages/mosaic/framework/tools/fleet/mint-seat-credential.sh new file mode 100644 index 00000000..1c452346 --- /dev/null +++ b/packages/mosaic/framework/tools/fleet/mint-seat-credential.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +# mint-seat-credential.sh — create the Gitea account and mint a token for one seat, +# on every configured instance, writing the result into that seat's credential slot. +# +# mint-seat-credential.sh [--admin-seat ] [--instances " "] +# +# Configuration (environment; flags win over environment): +# MOSAIC_ADMIN_SEAT seat whose admin token is used to call the Gitea +# admin API. Required. Its token is read from +# $MOSAIC_BRAIN_HOME/fleet/agents//secrets/ +# gitea--.token. Never printed. +# MOSAIC_GITEA_INSTANCES space-separated instance names to mint on. +# Default: every instance in the map below. +# MOSAIC_GITEA_URL_ server URL override per instance (same +# convention as seat-logins.sh). +# MOSAIC_SEAT_EMAIL_DOMAIN domain for the account email (@). +# MOSAIC_BRAIN_HOME brain checkout; default ~/.mosaic. +# +# Exit codes: 0 minted and projected on every instance; 1 at least one instance +# failed (the others are untouched or complete); 3 usage error. +# +# WHY BASIC AUTH, WHICH LOOKS WRONG AT FIRST +# Gitea refuses token auth on POST /users/{user}/tokens by design, and the Sudo +# header and sudo query parameter are both rejected there (probed 2026-08-19, probe +# token deleted). So minting for another account needs a password: this script +# generates a random one, uses it once, and never stores or prints it. Agents +# authenticate by token; the password is not a credential anyone keeps. +# +# The .scopes file is written from the mint RESPONSE rather than from what was +# requested, so the record is what was granted rather than what was asked for. +set -Eeuo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BRAIN="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}" +ADMIN="${MOSAIC_ADMIN_SEAT:-}" +INSTANCES="${MOSAIC_GITEA_INSTANCES:-}" +EMAIL_DOMAIN="${MOSAIC_SEAT_EMAIL_DOMAIN:-mosaicstack.dev}" +SEAT="" + +usage() { sed -n '2,20p' "${BASH_SOURCE[0]}" >&2; exit 3; } + +while [[ $# -gt 0 ]]; do + case "$1" in + --admin-seat) ADMIN="${2:-}"; shift 2 ;; + --instances) INSTANCES="${2:-}"; shift 2 ;; + -h|--help) usage ;; + -*) echo "mint: unknown flag: $1" >&2; exit 3 ;; + *) [[ -z "$SEAT" ]] || { echo "mint: one seat only" >&2; exit 3; }; SEAT="$1"; shift ;; + esac +done + +[[ -n "$SEAT" ]] || usage +[[ "$SEAT" =~ ^[a-z0-9][a-z0-9-]*$ ]] || { echo "mint: bad seat name: $SEAT" >&2; exit 3; } +[[ -n "$ADMIN" ]] || { echo "mint: no admin seat. Set MOSAIC_ADMIN_SEAT or pass --admin-seat." >&2; exit 3; } +[[ "$ADMIN" =~ ^[a-z0-9][a-z0-9-]*$ ]] || { echo "mint: bad admin seat name: $ADMIN" >&2; exit 3; } + +# Instance -> server URL. Same map and override convention as seat-logins.sh. +declare -A INSTANCE_URL=( + [mosaicstack]="https://git.mosaicstack.dev" + [usc]="https://git.uscllc.com" +) +for inst in "${!INSTANCE_URL[@]}"; do + ov="MOSAIC_GITEA_URL_${inst^^}" + [[ -n "${!ov:-}" ]] && INSTANCE_URL[$inst]="${!ov}" +done +[[ -n "$INSTANCES" ]] || INSTANCES="$(printf '%s\n' "${!INSTANCE_URL[@]}" | sort | tr '\n' ' ')" + +SCOPES='["read:user","write:repository","write:issue","read:organization"]' +D="$BRAIN/fleet/agents/$SEAT/secrets" +mkdir -p "$D"; chmod 700 "$D" + +rc=0 +for KEY in $INSTANCES; do + ov="MOSAIC_GITEA_URL_${KEY^^}" + BASE="${INSTANCE_URL[$KEY]:-${!ov:-}}" + [[ -n "$BASE" ]] || { echo " $KEY: no URL known for this instance (set $ov), skipped" >&2; rc=1; continue; } + ADMIN_TOKEN_FILE="$BRAIN/fleet/agents/$ADMIN/secrets/gitea-$KEY-$ADMIN.token" + [[ -r "$ADMIN_TOKEN_FILE" ]] || { echo " $KEY: no admin token for seat '$ADMIN' ($ADMIN_TOKEN_FILE), skipped" >&2; rc=1; continue; } + T="$(cat "$ADMIN_TOKEN_FILE")" + PW="$(openssl rand -base64 33 | tr -d '\n/+=' | head -c 32)" + + if curl -sf -o /dev/null -H "Authorization: token $T" "$BASE/api/v1/users/$SEAT"; then + curl -s -o /dev/null -X PATCH -H "Authorization: token $T" -H "Content-Type: application/json" \ + -d "{\"login_name\":\"$SEAT\",\"source_id\":0,\"password\":\"$PW\",\"must_change_password\":false}" \ + "$BASE/api/v1/admin/users/$SEAT" + act="reset-pw" + else + curl -s -o /dev/null -X POST -H "Authorization: token $T" -H "Content-Type: application/json" \ + -d "{\"username\":\"$SEAT\",\"email\":\"$SEAT@$EMAIL_DOMAIN\",\"password\":\"$PW\",\"must_change_password\":false,\"full_name\":\"Mosaic fleet seat $SEAT\"}" \ + "$BASE/api/v1/admin/users" + act="create" + fi + + tmp="$(mktemp)" + code="$(curl -s -o "$tmp" -w '%{http_code}' -X POST -u "$SEAT:$PW" -H "Content-Type: application/json" \ + -d "{\"name\":\"mosaic-seat\",\"scopes\":$SCOPES}" "$BASE/api/v1/users/$SEAT/tokens")" + if [[ "$code" != "201" ]]; then + echo " $KEY: mint FAILED http=$code ($act)" >&2; rm -f "$tmp"; rc=1; PW=""; continue + fi + + python3 - "$tmp" "$D" "$KEY" "$SEAT" <<'PY' +import json,sys,pathlib +tmp,d,key,seat=sys.argv[1:5] +t=json.load(open(tmp)) +p=pathlib.Path(d) +(p/f"gitea-{key}-{seat}.token").write_text(t["sha1"]+"\n") +(p/f"gitea-{key}-{seat}.scopes").write_text(json.dumps(t.get("scopes",[]))+"\n") +(p/f"gitea-{key}-{seat}.principal").write_text(seat+"\n") +for suf in ("token","scopes","principal"): + (p/f"gitea-{key}-{seat}.{suf}").chmod(0o600) +PY + rm -f "$tmp"; PW="" + + login="$(curl -s -H "Authorization: token $(cat "$D/gitea-$KEY-$SEAT.token")" "$BASE/api/v1/user" \ + | python3 -c 'import json,sys;print(json.load(sys.stdin).get("login","ERR"))' 2>/dev/null || echo ERR)" + if [[ "$login" == "$SEAT" ]]; then + echo " $KEY: $act, minted, GET /user -> $login" + else + echo " $KEY: minted but identity check returned '$login', expected '$SEAT'" >&2; rc=1 + fi +done + +# ── Project into tea ───────────────────────────────────────────────────────── +# A token in the secrets dir is only half a credential. tea 0.14.0 cannot read +# that store, it only uses logins already in its own config, so a seat minted +# but not projected holds a working token and no login. Minting and projecting +# are therefore ONE operation. +# +# --adopt is deliberately NOT passed. Adopting deletes an operator-made login, +# which is a human decision. A collision reports BLOCK and a nonzero rc instead. +# +# tea absent is not a minting failure. The REST-path wrappers still work with +# the token that was just written, so warn and carry on. +SEAT_LOGINS="$SCRIPT_DIR/seat-logins.sh" +if [[ "$rc" -eq 0 ]]; then + if command -v tea >/dev/null 2>&1; then + if "$SEAT_LOGINS" --apply --seat "$SEAT"; then + : + else + echo " projection FAILED: token is minted and valid, but no tea login exists for $SEAT." >&2 + echo " tea-path wrappers will not act as this seat. Re-run:" >&2 + echo " $SEAT_LOGINS --apply --seat $SEAT" >&2 + rc=1 + fi + else + echo " tea not on PATH: token minted, no login projected (REST-path wrappers still work)." >&2 + fi +fi + +exit $rc diff --git a/packages/mosaic/framework/tools/fleet/test-mint-seat-credential.sh b/packages/mosaic/framework/tools/fleet/test-mint-seat-credential.sh new file mode 100755 index 00000000..e7a01f1c --- /dev/null +++ b/packages/mosaic/framework/tools/fleet/test-mint-seat-credential.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# Hermetic regression for mint-seat-credential.sh: mock curl on PATH, sandboxed +# brain home, no tea, no network, no real credentials. +# +# Pins: +# M1 the seat slot is written from the mint RESPONSE (token, granted scopes, +# principal), each file mode 600, and the identity check passes. +# M2 the admin token is read from MOSAIC_ADMIN_SEAT's slot, never hardcoded; +# a missing admin token is reported per instance and exits nonzero. +# M3 MOSAIC_GITEA_INSTANCES limits which instances are touched, and the URL +# override MOSAIC_GITEA_URL_ is honoured. +# M4 no admin seat configured is a usage error (rc=3), nothing written. +# M5 the admin token value never appears on stdout or stderr. +set -euo pipefail + +WORK_ROOT="${AGENT_WORK_ROOT:-${TMPDIR:-/tmp}}" +SANDBOX="$WORK_ROOT/mint-seat-credential-test-$$" +MOCK_BIN="$SANDBOX/bin"; BRAIN="$SANDBOX/brain"; CALLS="$SANDBOX/calls.log" +cleanup() { rm -rf "$SANDBOX"; } +trap cleanup EXIT +fail() { echo "FAIL: $*"; exit 1; } + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TARGET="$SCRIPT_DIR/mint-seat-credential.sh" +[ -f "$TARGET" ] || fail "mint-seat-credential.sh not found beside this test" + +mkdir -p "$MOCK_BIN" "$BRAIN/fleet/agents/admin-seat/secrets" || fail "setup: sandbox" +: > "$CALLS" +ADMIN_TOKEN_VALUE="admin-token-value-sentinel-4491" +printf '%s\n' "$ADMIN_TOKEN_VALUE" > "$BRAIN/fleet/agents/admin-seat/secrets/gitea-alpha-admin-seat.token" +chmod 600 "$BRAIN/fleet/agents/admin-seat/secrets/gitea-alpha-admin-seat.token" + +# A PATH with only the mock bin plus the system tools the script needs, and no tea. +SYS_BIN="$SANDBOX/sys"; mkdir -p "$SYS_BIN" +for t in bash sed cat mktemp openssl tr head python3 sort printf chmod mkdir rm dirname grep stat; do + p="$(command -v "$t" 2>/dev/null || true)"; [ -n "$p" ] && ln -s "$p" "$SYS_BIN/$t" +done +export PATH="$MOCK_BIN:$SYS_BIN" CALLS +export MOSAIC_BRAIN_HOME="$BRAIN" +export MOSAIC_GITEA_URL_ALPHA="https://alpha.example.test" +unset MOSAIC_ADMIN_SEAT MOSAIC_GITEA_INSTANCES + +# --- mock curl: records method + URL, answers the minting sequence ----------- +cat > "$MOCK_BIN/curl" <<'EOF' +#!/bin/bash +method=GET; url=""; out=""; wcode=0 +while [ $# -gt 0 ]; do + case "$1" in + -X) method="$2"; shift 2 ;; + -o) out="$2"; shift 2 ;; + -w) wcode=1; shift 2 ;; + -H|-d|-u) shift 2 ;; + http*) url="$1"; shift ;; + *) shift ;; + esac +done +printf '%s %s\n' "$method" "$url" >> "$CALLS" +emit() { if [ -n "$out" ]; then printf '%s' "$1" > "$out"; else printf '%s' "$1"; fi; } +case "$method $url" in + "GET "*/api/v1/users/newseat) exit 22 ;; # 404 under -f: account does not exist yet + "POST "*/api/v1/admin/users) emit '{}'; exit 0 ;; + "POST "*/api/v1/users/newseat/tokens) emit '{"id":9,"name":"mosaic-seat","sha1":"minted-token-7f3a","scopes":["read:user","write:repository"]}' + [ "$wcode" = 1 ] && printf '201'; exit 0 ;; + "GET "*/api/v1/user) emit '{"login":"newseat"}'; exit 0 ;; + *) emit '{}'; exit 0 ;; +esac +EOF +chmod +x "$MOCK_BIN/curl" +[ "$(command -v curl)" = "$MOCK_BIN/curl" ] || fail "setup: curl does not resolve to the mock" +command -v tea >/dev/null 2>&1 && fail "setup: tea must be absent from the sandbox PATH" + +run() { bash "$TARGET" "$@" >"$SANDBOX/out" 2>"$SANDBOX/err"; echo $?; } + +# M4: no admin seat configured. +rc=$(run newseat) +[ "$rc" = 3 ] || fail "M4: expected rc=3 without an admin seat, got $rc: $(cat "$SANDBOX/err")" +grep -q 'MOSAIC_ADMIN_SEAT' "$SANDBOX/err" || fail "M4: error does not name MOSAIC_ADMIN_SEAT" +[ ! -e "$BRAIN/fleet/agents/newseat/secrets/gitea-alpha-newseat.token" ] || fail "M4: a token was written without an admin seat" + +# M1 + M3 + M5: mint on the single configured instance. +: > "$CALLS" +rc=$(MOSAIC_ADMIN_SEAT=admin-seat MOSAIC_GITEA_INSTANCES=alpha run newseat) +[ "$rc" = 0 ] || fail "M1: expected rc=0, got $rc: $(cat "$SANDBOX/err")" +SLOT="$BRAIN/fleet/agents/newseat/secrets" +[ "$(cat "$SLOT/gitea-alpha-newseat.token")" = "minted-token-7f3a" ] || fail "M1: token file not written from the mint response" +grep -q 'write:repository' "$SLOT/gitea-alpha-newseat.scopes" || fail "M1: scopes file not written from the response" +[ "$(cat "$SLOT/gitea-alpha-newseat.principal")" = "newseat" ] || fail "M1: principal file wrong" +for suf in token scopes principal; do + m=$(stat -c '%a' "$SLOT/gitea-alpha-newseat.$suf"); [ "$m" = 600 ] || fail "M1: $suf is mode $m, expected 600" +done +grep -q 'alpha: create, minted, GET /user -> newseat' "$SANDBOX/out" || fail "M1: success line missing: $(cat "$SANDBOX/out")" +grep -q 'https://alpha.example.test/api/v1/admin/users' "$CALLS" || fail "M3: URL override not honoured: $(cat "$CALLS")" +if grep -q 'usc\|mosaicstack' "$CALLS"; then fail "M3: an instance outside MOSAIC_GITEA_INSTANCES was touched: $(cat "$CALLS")"; fi +grep -q 'tea not on PATH' "$SANDBOX/err" || fail "tea-absent path should warn, not fail: $(cat "$SANDBOX/err")" +if grep -q "$ADMIN_TOKEN_VALUE" "$SANDBOX/out" "$SANDBOX/err"; then fail "M5: admin token value leaked to output"; fi + +# M2: admin token missing for the instance is reported, rc=1, nothing written. +rm -rf "$BRAIN/fleet/agents/newseat" +: > "$CALLS" +rc=$(MOSAIC_ADMIN_SEAT=other-admin MOSAIC_GITEA_INSTANCES=alpha run newseat) +[ "$rc" = 1 ] || fail "M2: expected rc=1 with no admin token, got $rc" +grep -q "no admin token for seat 'other-admin'" "$SANDBOX/err" || fail "M2: missing-admin-token not reported: $(cat "$SANDBOX/err")" +[ ! -s "$CALLS" ] || fail "M2: API was called without an admin token: $(cat "$CALLS")" +[ ! -e "$BRAIN/fleet/agents/newseat/secrets/gitea-alpha-newseat.token" ] || fail "M2: token written without an admin token" + +echo "mint-seat-credential regression harness passed" diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs index f35edf14..18964971 100644 --- a/scripts/verify-release.mjs +++ b/scripts/verify-release.mjs @@ -63,6 +63,7 @@ export const STAGES = [ 'bash packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh', 'bash packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh', 'bash packages/mosaic/framework/tools/git/test-issue-view-comments.sh', + 'bash packages/mosaic/framework/tools/fleet/test-mint-seat-credential.sh', 'bash packages/mosaic/framework/tools/git/test-wrapper-guard.sh', 'bash packages/mosaic/framework/tools/git/test-mosaic-worktree-large-repo.sh', ], -- 2.54.0 From 53e0fe912e8e5ede29b1964bc5cdeb5523f0e9ce Mon Sep 17 00:00:00 2001 From: code-infra-01 Date: Fri, 21 Aug 2026 22:38:31 -0500 Subject: [PATCH 2/3] =?UTF-8?q?fix(#1367):=20mint-seat-credential=20?= =?UTF-8?q?=E2=80=94=20secrets=20out=20of=20argv;=20discriminating=20pins?= =?UTF-8?q?=20(review=20259=20blocker,=20review=20260=20SF1-SF3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blocker (rev-security-01 review 259): admin token, generated password and minted seat token travelled curl ARGV (three Authorization sites, -u at the mint, inline -d bodies), leaking to /proc cmdline and bash -x traces — the durable password under must_change_password:false is a live credential. All three now travel in 0600 staging files: --config for the Authorization header (the landed gitea_write_auth_config pattern), a user= directive for basic auth at the mint, --data @file for bodies. Unlinked after each use; M6 asserts no call is unauthenticated and no body is inline. Scope pin (SF2 + rev-security-01 M1, same defect): a mutant writing the REQUESTED scopes passed green because the grep target appears in both sets. M7 now asserts write:issue (requested, not granted) is ABSENT; mutant killed. SF3: hyphenated instance overrides map hyphen->underscore exactly like seat-logins.sh (url_override_var); M8 pins it; the uppercase-only mutant dies at the invalid-variable-name refusal again, now by design. SF1: mint-seat-credential.sh mode 755 (update-index), README invocation updated to name the now-required MOSAIC_SEAT_EMAIL_DOMAIN. Framework-PR firewall answer (rev-security-01): the email domain has NO default — unset is rc=3 with a named variable (M9); the instance host map stays per the seat-logins.sh precedent already on next. Estate domains belong to the estate, not the tree. --- .../mosaic/framework/tools/fleet/README.md | 5 +- .../tools/fleet/mint-seat-credential.sh | 78 +++++++++++++++---- .../tools/fleet/test-mint-seat-credential.sh | 65 ++++++++++++++-- 3 files changed, 126 insertions(+), 22 deletions(-) mode change 100644 => 100755 packages/mosaic/framework/tools/fleet/mint-seat-credential.sh diff --git a/packages/mosaic/framework/tools/fleet/README.md b/packages/mosaic/framework/tools/fleet/README.md index 89a65120..0d781c17 100644 --- a/packages/mosaic/framework/tools/fleet/README.md +++ b/packages/mosaic/framework/tools/fleet/README.md @@ -13,11 +13,12 @@ Seat lifecycle tools for a Mosaic fleet. Paths are relative to ## Onboarding a seat's credential ``` -MOSAIC_ADMIN_SEAT= mint-seat-credential.sh +MOSAIC_ADMIN_SEAT= MOSAIC_SEAT_EMAIL_DOMAIN= mint-seat-credential.sh ``` - The admin token is read from `$MOSAIC_BRAIN_HOME/fleet/agents//secrets/gitea--.token`. It is never printed. -- Instances default to the map shared with `seat-logins.sh`; `MOSAIC_GITEA_INSTANCES="a b"` limits the set and `MOSAIC_GITEA_URL_` overrides a server URL. +- `MOSAIC_SEAT_EMAIL_DOMAIN` is required (no default): the framework ships no estate-specific domain. +- Instances default to the map shared with `seat-logins.sh`; `MOSAIC_GITEA_INSTANCES="a b"` limits the set and `MOSAIC_GITEA_URL_` overrides a server URL (hyphens in the instance name become underscores in the variable, as in `seat-logins.sh`). - The seat slot is written from the mint response: `.token`, `.scopes` (what was granted), `.principal`, each mode 600. - `tea` absent is a warning, not a failure: REST-path wrappers work with the token alone. - Regression suite: `test-mint-seat-credential.sh` (hermetic, mock curl, no network). diff --git a/packages/mosaic/framework/tools/fleet/mint-seat-credential.sh b/packages/mosaic/framework/tools/fleet/mint-seat-credential.sh old mode 100644 new mode 100755 index 1c452346..4bf38e08 --- a/packages/mosaic/framework/tools/fleet/mint-seat-credential.sh +++ b/packages/mosaic/framework/tools/fleet/mint-seat-credential.sh @@ -14,6 +14,10 @@ # MOSAIC_GITEA_URL_ server URL override per instance (same # convention as seat-logins.sh). # MOSAIC_SEAT_EMAIL_DOMAIN domain for the account email (@). +# Required, no default: the framework tree +# carries no estate-specific domain +# (framework-PR firewall; the instance host +# map stays per seat-logins.sh precedent). # MOSAIC_BRAIN_HOME brain checkout; default ~/.mosaic. # # Exit codes: 0 minted and projected on every instance; 1 at least one instance @@ -28,13 +32,43 @@ # # The .scopes file is written from the mint RESPONSE rather than from what was # requested, so the record is what was granted rather than what was asked for. +# +# SECRETS NEVER TOUCH ARGV (#1343 class, rev-security-01 review 259): the admin +# token, the generated password, and the minted seat token all pass through +# 0600 curl --config / --data files — the landed in-tree standard +# (gitea_write_auth_config in detect-platform.sh). argv is world-readable via +# /proc//cmdline for the life of each request, and a bash -x trace would +# print every secret otherwise. The staging files are unlinked after each use. set -Eeuo pipefail +# Stage secrets into 0600 files; nothing secret reaches argv or a trace. +# write_auth_config -> curl --config carrying the Authorization header +# (same shape as gitea_write_auth_config in +# detect-platform.sh, local so this script stays +# standalone under tools/fleet). +# write_user_config -> curl --config with `user =` (covers -u). +# write_body -> 0600 file for --data @file. +write_auth_config() { + local f; f=$(mktemp "${TMPDIR:-/tmp}/mosaic-mint-auth.XXXXXX") || return 1 + printf 'header = "Authorization: token %s"\n' "$1" >"$f" || { rm -f "$f"; return 1; } + chmod 600 "$f"; printf '%s' "$f" +} +write_user_config() { + local f; f=$(mktemp "${TMPDIR:-/tmp}/mosaic-mint-user.XXXXXX") || return 1 + printf 'user = "%s:%s"\n' "$1" "$2" >"$f" || { rm -f "$f"; return 1; } + chmod 600 "$f"; printf '%s' "$f" +} +write_body() { + local f; f=$(mktemp "${TMPDIR:-/tmp}/mosaic-mint-body.XXXXXX") || return 1 + printf '%s' "$1" >"$f" || { rm -f "$f"; return 1; } + chmod 600 "$f"; printf '%s' "$f" +} + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" BRAIN="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}" ADMIN="${MOSAIC_ADMIN_SEAT:-}" INSTANCES="${MOSAIC_GITEA_INSTANCES:-}" -EMAIL_DOMAIN="${MOSAIC_SEAT_EMAIL_DOMAIN:-mosaicstack.dev}" +EMAIL_DOMAIN="${MOSAIC_SEAT_EMAIL_DOMAIN:-}" SEAT="" usage() { sed -n '2,20p' "${BASH_SOURCE[0]}" >&2; exit 3; } @@ -53,14 +87,18 @@ done [[ "$SEAT" =~ ^[a-z0-9][a-z0-9-]*$ ]] || { echo "mint: bad seat name: $SEAT" >&2; exit 3; } [[ -n "$ADMIN" ]] || { echo "mint: no admin seat. Set MOSAIC_ADMIN_SEAT or pass --admin-seat." >&2; exit 3; } [[ "$ADMIN" =~ ^[a-z0-9][a-z0-9-]*$ ]] || { echo "mint: bad admin seat name: $ADMIN" >&2; exit 3; } +[[ -n "$EMAIL_DOMAIN" ]] || { echo "mint: no email domain. Set MOSAIC_SEAT_EMAIL_DOMAIN (the framework ships no estate default)." >&2; exit 3; } -# Instance -> server URL. Same map and override convention as seat-logins.sh. +# Instance -> server URL. Same map and override convention as seat-logins.sh: +# hyphens in instance names map to underscores in the override variable +# (MOSAIC_GITEA_URL_MY-INST is not a valid shell name; MY_INST is). +url_override_var() { printf 'MOSAIC_GITEA_URL_%s' "$(printf '%s' "$1" | tr '[:lower:]-' '[:upper:]_')"; } declare -A INSTANCE_URL=( [mosaicstack]="https://git.mosaicstack.dev" [usc]="https://git.uscllc.com" ) for inst in "${!INSTANCE_URL[@]}"; do - ov="MOSAIC_GITEA_URL_${inst^^}" + ov="$(url_override_var "$inst")" [[ -n "${!ov:-}" ]] && INSTANCE_URL[$inst]="${!ov}" done [[ -n "$INSTANCES" ]] || INSTANCES="$(printf '%s\n' "${!INSTANCE_URL[@]}" | sort | tr '\n' ' ')" @@ -71,31 +109,39 @@ mkdir -p "$D"; chmod 700 "$D" rc=0 for KEY in $INSTANCES; do - ov="MOSAIC_GITEA_URL_${KEY^^}" + ov="$(url_override_var "$KEY")" BASE="${INSTANCE_URL[$KEY]:-${!ov:-}}" [[ -n "$BASE" ]] || { echo " $KEY: no URL known for this instance (set $ov), skipped" >&2; rc=1; continue; } ADMIN_TOKEN_FILE="$BRAIN/fleet/agents/$ADMIN/secrets/gitea-$KEY-$ADMIN.token" [[ -r "$ADMIN_TOKEN_FILE" ]] || { echo " $KEY: no admin token for seat '$ADMIN' ($ADMIN_TOKEN_FILE), skipped" >&2; rc=1; continue; } T="$(cat "$ADMIN_TOKEN_FILE")" + AUTH_CFG="$(write_auth_config "$T")" PW="$(openssl rand -base64 33 | tr -d '\n/+=' | head -c 32)" + USER_CFG="$(write_user_config "$SEAT" "$PW")" - if curl -sf -o /dev/null -H "Authorization: token $T" "$BASE/api/v1/users/$SEAT"; then - curl -s -o /dev/null -X PATCH -H "Authorization: token $T" -H "Content-Type: application/json" \ - -d "{\"login_name\":\"$SEAT\",\"source_id\":0,\"password\":\"$PW\",\"must_change_password\":false}" \ + if curl -sf -o /dev/null --config "$AUTH_CFG" "$BASE/api/v1/users/$SEAT"; then + BODY="$(write_body "{\"login_name\":\"$SEAT\",\"source_id\":0,\"password\":\"$PW\",\"must_change_password\":false}")" + curl -s -o /dev/null -X PATCH -H "Content-Type: application/json" \ + --config "$AUTH_CFG" --data "@$BODY" \ "$BASE/api/v1/admin/users/$SEAT" + rm -f "$BODY"; BODY="" act="reset-pw" else - curl -s -o /dev/null -X POST -H "Authorization: token $T" -H "Content-Type: application/json" \ - -d "{\"username\":\"$SEAT\",\"email\":\"$SEAT@$EMAIL_DOMAIN\",\"password\":\"$PW\",\"must_change_password\":false,\"full_name\":\"Mosaic fleet seat $SEAT\"}" \ + BODY="$(write_body "{\"username\":\"$SEAT\",\"email\":\"$SEAT@$EMAIL_DOMAIN\",\"password\":\"$PW\",\"must_change_password\":false,\"full_name\":\"Mosaic fleet seat $SEAT\"}")" + curl -s -o /dev/null -X POST -H "Content-Type: application/json" \ + --config "$AUTH_CFG" --data "@$BODY" \ "$BASE/api/v1/admin/users" + rm -f "$BODY"; BODY="" act="create" fi - tmp="$(mktemp)" - code="$(curl -s -o "$tmp" -w '%{http_code}' -X POST -u "$SEAT:$PW" -H "Content-Type: application/json" \ - -d "{\"name\":\"mosaic-seat\",\"scopes\":$SCOPES}" "$BASE/api/v1/users/$SEAT/tokens")" + tmp="$(mktemp)"; chmod 600 "$tmp" + MINT_BODY="$(write_body "{\"name\":\"mosaic-seat\",\"scopes\":$SCOPES}")" + code="$(curl -s -o "$tmp" -w '%{http_code}' -X POST -H "Content-Type: application/json" \ + --config "$USER_CFG" --data "@$MINT_BODY" "$BASE/api/v1/users/$SEAT/tokens")" + rm -f "$MINT_BODY"; MINT_BODY="" if [[ "$code" != "201" ]]; then - echo " $KEY: mint FAILED http=$code ($act)" >&2; rm -f "$tmp"; rc=1; PW=""; continue + echo " $KEY: mint FAILED http=$code ($act)" >&2; rm -f "$tmp"; rc=1; PW=""; rm -f "$AUTH_CFG" "$USER_CFG"; continue fi python3 - "$tmp" "$D" "$KEY" "$SEAT" <<'PY' @@ -109,10 +155,12 @@ p=pathlib.Path(d) for suf in ("token","scopes","principal"): (p/f"gitea-{key}-{seat}.{suf}").chmod(0o600) PY - rm -f "$tmp"; PW="" + rm -f "$tmp"; PW=""; rm -f "$AUTH_CFG" "$USER_CFG" - login="$(curl -s -H "Authorization: token $(cat "$D/gitea-$KEY-$SEAT.token")" "$BASE/api/v1/user" \ + VERIFY_CFG="$(write_auth_config "$(cat "$D/gitea-$KEY-$SEAT.token")")" + login="$(curl -s --config "$VERIFY_CFG" "$BASE/api/v1/user" \ | python3 -c 'import json,sys;print(json.load(sys.stdin).get("login","ERR"))' 2>/dev/null || echo ERR)" + rm -f "$VERIFY_CFG" if [[ "$login" == "$SEAT" ]]; then echo " $KEY: $act, minted, GET /user -> $login" else diff --git a/packages/mosaic/framework/tools/fleet/test-mint-seat-credential.sh b/packages/mosaic/framework/tools/fleet/test-mint-seat-credential.sh index e7a01f1c..97fffce0 100755 --- a/packages/mosaic/framework/tools/fleet/test-mint-seat-credential.sh +++ b/packages/mosaic/framework/tools/fleet/test-mint-seat-credential.sh @@ -11,6 +11,17 @@ # override MOSAIC_GITEA_URL_ is honoured. # M4 no admin seat configured is a usage error (rc=3), nothing written. # M5 the admin token value never appears on stdout or stderr. +# M6 secrets never touch argv: no Authorization header, no -u user:pass, no +# inline --data JSON carrying the password, on any curl invocation; auth +# travels in --config files and bodies in --data @files (#1343 class, +# rev-security-01 review 259 blocker). +# M7 the scopes record discriminates: a requested-but-not-granted scope +# (write:issue) must be ABSENT from .scopes — the pin is on the RESPONSE, +# and a mutant writing the requested set fails here (both reviewers). +# M8 hyphenated instance names resolve their override through the underscore +# variable, matching seat-logins.sh (SF3). +# M9 MOSAIC_SEAT_EMAIL_DOMAIN is required: unset is a usage error (rc=3), +# nothing written (framework-PR firewall answer). set -euo pipefail WORK_ROOT="${AGENT_WORK_ROOT:-${TMPDIR:-/tmp}}" @@ -38,23 +49,34 @@ done export PATH="$MOCK_BIN:$SYS_BIN" CALLS export MOSAIC_BRAIN_HOME="$BRAIN" export MOSAIC_GITEA_URL_ALPHA="https://alpha.example.test" +export MOSAIC_SEAT_EMAIL_DOMAIN="seats.example.test" unset MOSAIC_ADMIN_SEAT MOSAIC_GITEA_INSTANCES -# --- mock curl: records method + URL, answers the minting sequence ----------- +# --- mock curl: records method + URL + a REDACTED auth marker, answers minting -- cat > "$MOCK_BIN/curl" <<'EOF' #!/bin/bash -method=GET; url=""; out=""; wcode=0 +method=GET; url=""; out=""; wcode=0; auth=""; body="" while [ $# -gt 0 ]; do case "$1" in -X) method="$2"; shift 2 ;; -o) out="$2"; shift 2 ;; -w) wcode=1; shift 2 ;; - -H|-d|-u) shift 2 ;; + --config) + if grep -q 'Authorization: token' "$2" 2>/dev/null; then auth="${auth}token,"; fi + if grep -q '^user = ' "$2" 2>/dev/null; then auth="${auth}user,"; fi + shift 2 ;; + --data) + case "$2" in + @*) body="@file" ;; + *) body="inline" ;; + esac + shift 2 ;; + -H|-u) shift 2 ;; http*) url="$1"; shift ;; *) shift ;; esac done -printf '%s %s\n' "$method" "$url" >> "$CALLS" +printf '%s %s auth=%s body=%s\n' "$method" "$url" "${auth:-NONE}" "$body" >> "$CALLS" emit() { if [ -n "$out" ]; then printf '%s' "$1" > "$out"; else printf '%s' "$1"; fi; } case "$method $url" in "GET "*/api/v1/users/newseat) exit 22 ;; # 404 under -f: account does not exist yet @@ -77,6 +99,13 @@ rc=$(run newseat) grep -q 'MOSAIC_ADMIN_SEAT' "$SANDBOX/err" || fail "M4: error does not name MOSAIC_ADMIN_SEAT" [ ! -e "$BRAIN/fleet/agents/newseat/secrets/gitea-alpha-newseat.token" ] || fail "M4: a token was written without an admin seat" +# M9: email domain is required, unset is a usage error, nothing written. +rc=$(MOSAIC_ADMIN_SEAT=admin-seat MOSAIC_GITEA_INSTANCES=alpha MOSAIC_SEAT_EMAIL_DOMAIN= run newseat) +[ "$rc" = 3 ] || fail "M9: expected rc=3 with no email domain, got $rc: $(cat "$SANDBOX/err")" +grep -q 'MOSAIC_SEAT_EMAIL_DOMAIN' "$SANDBOX/err" || fail "M9: error does not name MOSAIC_SEAT_EMAIL_DOMAIN" +[ ! -s "$CALLS" ] || fail "M9: API called without an email domain" +[ ! -e "$BRAIN/fleet/agents/newseat/secrets/gitea-alpha-newseat.token" ] || fail "M9: token written without an email domain" + # M1 + M3 + M5: mint on the single configured instance. : > "$CALLS" rc=$(MOSAIC_ADMIN_SEAT=admin-seat MOSAIC_GITEA_INSTANCES=alpha run newseat) @@ -92,7 +121,33 @@ grep -q 'alpha: create, minted, GET /user -> newseat' "$SANDBOX/out" || fail "M1 grep -q 'https://alpha.example.test/api/v1/admin/users' "$CALLS" || fail "M3: URL override not honoured: $(cat "$CALLS")" if grep -q 'usc\|mosaicstack' "$CALLS"; then fail "M3: an instance outside MOSAIC_GITEA_INSTANCES was touched: $(cat "$CALLS")"; fi grep -q 'tea not on PATH' "$SANDBOX/err" || fail "tea-absent path should warn, not fail: $(cat "$SANDBOX/err")" -if grep -q "$ADMIN_TOKEN_VALUE" "$SANDBOX/out" "$SANDBOX/err"; then fail "M5: admin token value leaked to output"; fi +if grep -q "$ADMIN_TOKEN_VALUE" "$SANDBOX/out" "$SANDBOX/err" "$CALLS"; then fail "M5: admin token value leaked to output or call log"; fi + +# M7: scopes pin discriminates — requested-but-not-granted scope is ABSENT. +if grep -q 'write:issue' "$SLOT/gitea-alpha-newseat.scopes"; then + fail "M7: write:issue appears in .scopes — the record is the REQUESTED set, not the response" +fi + +# M6: no secret ever travels argv — every call authenticates via --config +# (token header or user= basic-auth directive) and bodies go as --data @file. +while IFS= read -r line; do + case "$line" in + *auth=NONE*) fail "M6: unauthenticated call: $line" ;; + *body=inline*) fail "M6: inline body (secret in argv risk): $line" ;; + esac +done < "$CALLS" +[ "$(grep -c 'auth=token' "$CALLS")" -eq 3 ] || fail "M6: expected exactly 3 token-auth calls (exists-check, admin write, verify), got: $(cat "$CALLS")" +grep -q 'auth=user' "$CALLS" || fail "M6: mint call did not use the user= directive: $(cat "$CALLS")" + +# M8: hyphenated instance name resolves its override via the underscore variable. +printf '%s\n' "$ADMIN_TOKEN_VALUE" > "$BRAIN/fleet/agents/admin-seat/secrets/gitea-my-inst-admin-seat.token" +chmod 600 "$BRAIN/fleet/agents/admin-seat/secrets/gitea-my-inst-admin-seat.token" +export MOSAIC_GITEA_URL_MY_INST="https://myinst.example.test" +: > "$CALLS"; rm -rf "$BRAIN/fleet/agents/newseat" +rc=$(MOSAIC_ADMIN_SEAT=admin-seat MOSAIC_GITEA_INSTANCES=my-inst run newseat) +[ "$rc" = 0 ] || fail "M8: hyphenated instance mint failed rc=$rc: $(cat "$SANDBOX/err")" +grep -q 'https://myinst.example.test/api/v1/admin/users' "$CALLS" || fail "M8: hyphen override (MY_INST) not honoured: $(cat "$CALLS")" +unset MOSAIC_GITEA_URL_MY_INST # M2: admin token missing for the instance is reported, rc=1, nothing written. rm -rf "$BRAIN/fleet/agents/newseat" -- 2.54.0 From 825e56d4545a78f9e2fbda08c9c4504fb0026572 Mon Sep 17 00:00:00 2001 From: code-infra-01 Date: Fri, 21 Aug 2026 23:20:24 -0500 Subject: [PATCH 3/3] =?UTF-8?q?fix(#1367):=20close=20both=20secret=20chann?= =?UTF-8?q?els=20=E2=80=94=20trap-swept=20staging=20and=20file-to-file=20a?= =?UTF-8?q?ssembly=20(review=20263)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blocker 2 (secret at rest on error paths): all staging now lives in ONE per-run mktemp -d removed by an EXIT/INT/TERM trap; a curl dying rc=7 mid-run (the reviewer's transport-failure case) leaves nothing behind. M10 pins it against a dying mock in an isolated TMPDIR; trap-removed mutant killed. Blocker 1 (bash -x trace channel, upheld above landed parity because this is the admin-token minter): secrets are assembled FILE-TO-FILE — stage_auth/stage_user take token/password FILE PATHS and build the curl configs with jq --rawfile; the password is generated straight into its staging file; bodies are composed by jq from the template + password file. No secret is ever expanded into a shell word a trace would print. M11 runs a real bash -x and asserts the admin-token value, the minted token value, and any password-shaped 32-char expansion are all absent; expansion mutant killed (measured: the mutant's trace shows '+ PW_VALUE=<32 chars>', the fixed script's trace shows paths only). Header comment corrected to state what is actually true, including the explicit note that detect-platform's gitea_write_auth_config still leaks under -x — that parity gap is now tracked as #1369, opened per review 263 and fred's ruling; issue-comment/pr-review/pr-edit left untouched in this PR. --- .../tools/fleet/mint-seat-credential.sh | 103 +++++++++++------- .../tools/fleet/test-mint-seat-credential.sh | 88 ++++++++++++++- 2 files changed, 153 insertions(+), 38 deletions(-) diff --git a/packages/mosaic/framework/tools/fleet/mint-seat-credential.sh b/packages/mosaic/framework/tools/fleet/mint-seat-credential.sh index 4bf38e08..1e4a7fc9 100755 --- a/packages/mosaic/framework/tools/fleet/mint-seat-credential.sh +++ b/packages/mosaic/framework/tools/fleet/mint-seat-credential.sh @@ -33,34 +33,59 @@ # The .scopes file is written from the mint RESPONSE rather than from what was # requested, so the record is what was granted rather than what was asked for. # -# SECRETS NEVER TOUCH ARGV (#1343 class, rev-security-01 review 259): the admin -# token, the generated password, and the minted seat token all pass through -# 0600 curl --config / --data files — the landed in-tree standard -# (gitea_write_auth_config in detect-platform.sh). argv is world-readable via -# /proc//cmdline for the life of each request, and a bash -x trace would -# print every secret otherwise. The staging files are unlinked after each use. +# SECRETS NEVER TOUCH ARGV (#1343 class, rev-security-01 review 259), and the +# staging area is a single trap-swept directory (review 263 blocker 2): the +# admin token, the generated password, and the minted seat token all pass +# through 0600 files under a per-run staging dir removed by an EXIT/INT/TERM +# trap, so a transport failure mid-run cannot leave secrets at rest in /tmp. +# +# TRACE CHANNEL, stated plainly (review 263 blocker 1): this script is held to +# a higher bar than ordinary wrappers because it mints admin-grade +# credentials and a durable password. Secrets here are assembled FILE-TO-FILE +# — source token file, password generated straight into its staging file, +# bodies composed with jq from those files — so no secret is ever expanded +# into a shell word a trace would print. A plain `bash -x` of this script +# shows staging PATHS only. (The landed gitea_write_auth_config in +# detect-platform.sh still expands tokens into shell words and DOES leak +# under -x; that fleet-wide parity gap is tracked in its own issue — see the +# framework-hardening issue referenced from this PR.) set -Eeuo pipefail -# Stage secrets into 0600 files; nothing secret reaches argv or a trace. -# write_auth_config -> curl --config carrying the Authorization header -# (same shape as gitea_write_auth_config in -# detect-platform.sh, local so this script stays -# standalone under tools/fleet). -# write_user_config -> curl --config with `user =` (covers -u). -# write_body -> 0600 file for --data @file. -write_auth_config() { - local f; f=$(mktemp "${TMPDIR:-/tmp}/mosaic-mint-auth.XXXXXX") || return 1 - printf 'header = "Authorization: token %s"\n' "$1" >"$f" || { rm -f "$f"; return 1; } +STAGE_DIR="" +cleanup_stage() { + [ -n "$STAGE_DIR" ] && rm -rf -- "$STAGE_DIR" + STAGE_DIR="" +} +trap cleanup_stage EXIT INT TERM + +# All staging lives in one per-run dir, swept by the trap above. Files are +# created 0600 and secrets are moved between them only by tool reads +# (jq/cat), never through shell-word expansion. +new_stage() { STAGE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-mint.XXXXXX")"; chmod 700 "$STAGE_DIR"; } + +# stage_auth — curl --config carrying the Authorization header, +# reading the token from the file with jq so it never +# becomes a shell word. +# stage_user — curl --config with `user =`; the password is +# read from its file by jq. is not a secret. +# stage_body — body file; jq injects the password +# file's value into the template. The mint body has +# no secret and is written directly. +stage_auth() { + local f="$STAGE_DIR/auth.cfg" + jq -rn --rawfile t "$1" '"header = \"Authorization: token " + $t + "\""' >"$f" || return 1 chmod 600 "$f"; printf '%s' "$f" } -write_user_config() { - local f; f=$(mktemp "${TMPDIR:-/tmp}/mosaic-mint-user.XXXXXX") || return 1 - printf 'user = "%s:%s"\n' "$1" "$2" >"$f" || { rm -f "$f"; return 1; } +stage_user() { + local f="$STAGE_DIR/user.cfg" + jq -rn --rawfile p "$2" --arg u "$1" '"user = \"" + $u + ":" + $p + "\""' >"$f" || return 1 chmod 600 "$f"; printf '%s' "$f" } -write_body() { - local f; f=$(mktemp "${TMPDIR:-/tmp}/mosaic-mint-body.XXXXXX") || return 1 - printf '%s' "$1" >"$f" || { rm -f "$f"; return 1; } +stage_body() { + # $1 is a JSON template string (no secrets); $2 is the password file. jq + # parses the template and injects the password read straight from the file. + local f="$STAGE_DIR/body.json" + jq -c --rawfile p "$2" '.password = $p' <<<"$1" >"$f" || return 1 chmod 600 "$f"; printf '%s' "$f" } @@ -114,34 +139,37 @@ for KEY in $INSTANCES; do [[ -n "$BASE" ]] || { echo " $KEY: no URL known for this instance (set $ov), skipped" >&2; rc=1; continue; } ADMIN_TOKEN_FILE="$BRAIN/fleet/agents/$ADMIN/secrets/gitea-$KEY-$ADMIN.token" [[ -r "$ADMIN_TOKEN_FILE" ]] || { echo " $KEY: no admin token for seat '$ADMIN' ($ADMIN_TOKEN_FILE), skipped" >&2; rc=1; continue; } - T="$(cat "$ADMIN_TOKEN_FILE")" - AUTH_CFG="$(write_auth_config "$T")" - PW="$(openssl rand -base64 33 | tr -d '\n/+=' | head -c 32)" - USER_CFG="$(write_user_config "$SEAT" "$PW")" + # Per-instance staging dir: everything under it dies with the trap, so a + # transport failure (review 263 blocker 2) cannot leave secrets at rest. + new_stage + AUTH_CFG="$(stage_auth "$ADMIN_TOKEN_FILE")" + # The password is generated STRAIGHT INTO its staging file; the variable + # below is its path, never the value (review 263 blocker 1). + openssl rand -base64 33 | tr -d '\n/+=' | head -c 32 >"$STAGE_DIR/pw" + chmod 600 "$STAGE_DIR/pw" + USER_CFG="$(stage_user "$SEAT" "$STAGE_DIR/pw")" if curl -sf -o /dev/null --config "$AUTH_CFG" "$BASE/api/v1/users/$SEAT"; then - BODY="$(write_body "{\"login_name\":\"$SEAT\",\"source_id\":0,\"password\":\"$PW\",\"must_change_password\":false}")" + BODY="$(stage_body '{"login_name":"'"$SEAT"'","source_id":0,"password":"","must_change_password":false}' "$STAGE_DIR/pw")" curl -s -o /dev/null -X PATCH -H "Content-Type: application/json" \ --config "$AUTH_CFG" --data "@$BODY" \ "$BASE/api/v1/admin/users/$SEAT" - rm -f "$BODY"; BODY="" act="reset-pw" else - BODY="$(write_body "{\"username\":\"$SEAT\",\"email\":\"$SEAT@$EMAIL_DOMAIN\",\"password\":\"$PW\",\"must_change_password\":false,\"full_name\":\"Mosaic fleet seat $SEAT\"}")" + BODY="$(stage_body '{"username":"'"$SEAT"'","email":"'"$SEAT@$EMAIL_DOMAIN"'","password":"","must_change_password":false,"full_name":"Mosaic fleet seat '"$SEAT"'"}' "$STAGE_DIR/pw")" curl -s -o /dev/null -X POST -H "Content-Type: application/json" \ --config "$AUTH_CFG" --data "@$BODY" \ "$BASE/api/v1/admin/users" - rm -f "$BODY"; BODY="" act="create" fi - tmp="$(mktemp)"; chmod 600 "$tmp" - MINT_BODY="$(write_body "{\"name\":\"mosaic-seat\",\"scopes\":$SCOPES}")" + tmp="$STAGE_DIR/mint-response.json" + printf '{"name":"mosaic-seat","scopes":%s}' "$SCOPES" >"$STAGE_DIR/mint-body.json"; chmod 600 "$STAGE_DIR/mint-body.json" + MINT_BODY="$STAGE_DIR/mint-body.json" code="$(curl -s -o "$tmp" -w '%{http_code}' -X POST -H "Content-Type: application/json" \ --config "$USER_CFG" --data "@$MINT_BODY" "$BASE/api/v1/users/$SEAT/tokens")" - rm -f "$MINT_BODY"; MINT_BODY="" if [[ "$code" != "201" ]]; then - echo " $KEY: mint FAILED http=$code ($act)" >&2; rm -f "$tmp"; rc=1; PW=""; rm -f "$AUTH_CFG" "$USER_CFG"; continue + echo " $KEY: mint FAILED http=$code ($act)" >&2; rm -f "$tmp"; rc=1; cleanup_stage; continue fi python3 - "$tmp" "$D" "$KEY" "$SEAT" <<'PY' @@ -155,12 +183,13 @@ p=pathlib.Path(d) for suf in ("token","scopes","principal"): (p/f"gitea-{key}-{seat}.{suf}").chmod(0o600) PY - rm -f "$tmp"; PW=""; rm -f "$AUTH_CFG" "$USER_CFG" + rm -f "$tmp"; cleanup_stage - VERIFY_CFG="$(write_auth_config "$(cat "$D/gitea-$KEY-$SEAT.token")")" + new_stage # fresh staging for the verify read + VERIFY_CFG="$(stage_auth "$D/gitea-$KEY-$SEAT.token")" login="$(curl -s --config "$VERIFY_CFG" "$BASE/api/v1/user" \ | python3 -c 'import json,sys;print(json.load(sys.stdin).get("login","ERR"))' 2>/dev/null || echo ERR)" - rm -f "$VERIFY_CFG" + cleanup_stage if [[ "$login" == "$SEAT" ]]; then echo " $KEY: $act, minted, GET /user -> $login" else diff --git a/packages/mosaic/framework/tools/fleet/test-mint-seat-credential.sh b/packages/mosaic/framework/tools/fleet/test-mint-seat-credential.sh index 97fffce0..7cba5b3a 100755 --- a/packages/mosaic/framework/tools/fleet/test-mint-seat-credential.sh +++ b/packages/mosaic/framework/tools/fleet/test-mint-seat-credential.sh @@ -22,6 +22,11 @@ # variable, matching seat-logins.sh (SF3). # M9 MOSAIC_SEAT_EMAIL_DOMAIN is required: unset is a usage error (rc=3), # nothing written (framework-PR firewall answer). +# M10 no secret at rest after ANY exit, including transport failure mid-run +# (review 263 blocker 2): the staging dir is swept by the trap, so a curl +# that dies rc=7 on the admin POST leaves nothing behind. +# M11 a real `bash -x` trace of the whole run contains no secret VALUE — +# staging appears only as paths (review 263 blocker 1). set -euo pipefail WORK_ROOT="${AGENT_WORK_ROOT:-${TMPDIR:-/tmp}}" @@ -43,7 +48,7 @@ chmod 600 "$BRAIN/fleet/agents/admin-seat/secrets/gitea-alpha-admin-seat.token" # A PATH with only the mock bin plus the system tools the script needs, and no tea. SYS_BIN="$SANDBOX/sys"; mkdir -p "$SYS_BIN" -for t in bash sed cat mktemp openssl tr head python3 sort printf chmod mkdir rm dirname grep stat; do +for t in bash sed cat mktemp openssl tr head python3 sort printf chmod mkdir rm dirname grep stat jq find wc; do p="$(command -v "$t" 2>/dev/null || true)"; [ -n "$p" ] && ln -s "$p" "$SYS_BIN/$t" done export PATH="$MOCK_BIN:$SYS_BIN" CALLS @@ -158,4 +163,85 @@ grep -q "no admin token for seat 'other-admin'" "$SANDBOX/err" || fail "M2: miss [ ! -s "$CALLS" ] || fail "M2: API was called without an admin token: $(cat "$CALLS")" [ ! -e "$BRAIN/fleet/agents/newseat/secrets/gitea-alpha-newseat.token" ] || fail "M2: token written without an admin token" +# M10: transport failure mid-run leaves NO secret at rest (review 263 blocker 2). +# A second mock that dies rc=7 on the admin POST; the trap must sweep the staging dir. +rm -rf "$BRAIN/fleet/agents/newseat" +M10_TMP="$SANDBOX/m10-tmp"; mkdir -p "$M10_TMP" +cat > "$MOCK_BIN/curl" <<'EOF' +#!/bin/bash +while [ $# -gt 0 ]; do + case "$1" in + http*) echo "$1" >> "${FAIL_URLS:?}"; exit 7 ;; + *) shift ;; + esac +done +exit 7 +EOF +chmod +x "$MOCK_BIN/curl" +export FAIL_URLS="$SANDBOX/failed-urls.txt"; : > "$FAIL_URLS" +BEFORE=$(find "$M10_TMP" -maxdepth 1 -name 'mosaic-mint.*' 2>/dev/null | wc -l) +rc=$(TMPDIR="$M10_TMP" MOSAIC_ADMIN_SEAT=admin-seat MOSAIC_GITEA_INSTANCES=alpha run newseat) +[ "$rc" != 0 ] || fail "M10: transport failure reported rc=0" +AFTER=$(find "$M10_TMP" -maxdepth 1 -name 'mosaic-mint.*' 2>/dev/null | wc -l) +[ "$AFTER" -le "$BEFORE" ] || fail "M10: staging left at rest after failure: $AFTER dir(s) under $M10_TMP" +[ -s "$FAIL_URLS" ] || fail "M10: mock never called" + +# Restore the well-behaved mock for M11. +cat > "$MOCK_BIN/curl" <<'EOF' +#!/bin/bash +method=GET; url=""; out=""; wcode=0; auth=""; body="" +while [ $# -gt 0 ]; do + case "$1" in + -X) method="$2"; shift 2 ;; + -o) out="$2"; shift 2 ;; + -w) wcode=1; shift 2 ;; + --config) + if grep -q 'Authorization: token' "$2" 2>/dev/null; then auth="${auth}token,"; fi + if grep -q '^user = ' "$2" 2>/dev/null; then auth="${auth}user,"; fi + shift 2 ;; + --data) + case "$2" in + @*) body="@file" ;; + *) body="inline" ;; + esac + shift 2 ;; + -H|-u) shift 2 ;; + http*) url="$1"; shift ;; + *) shift ;; + esac +done +printf '%s %s auth=%s body=%s\n' "$method" "$url" "${auth:-NONE}" "$body" >> "$CALLS" +emit() { if [ -n "$out" ]; then printf '%s' "$1" > "$out"; else printf '%s' "$1"; fi; } +case "$method $url" in + "GET "*/api/v1/users/newseat) exit 22 ;; + "POST "*/api/v1/admin/users) emit '{}'; exit 0 ;; + "POST "*/api/v1/users/newseat/tokens) emit '{"id":9,"name":"mosaic-seat","sha1":"minted-token-7f3a","scopes":["read:user","write:repository"]}' + [ "$wcode" = 1 ] && printf '201'; exit 0 ;; + "GET "*/api/v1/user) emit '{"login":"newseat"}'; exit 0 ;; + *) emit '{}'; exit 0 ;; +esac +EOF +chmod +x "$MOCK_BIN/curl" +unset FAIL_URLS + +# M11: a real bash -x trace of a full mint contains no secret VALUE (review 263 +# blocker 1). Secrets are generated straight into staging files and moved only +# by jq reads, so only staging PATHS may appear. The mock above has no secrets, +# so this measures the SCRIPT's word handling: the admin token sentinel and the +# mint response token must not appear in the xtrace of a successful run. +rm -rf "$BRAIN/fleet/agents/newseat" +: > "$CALLS" +TRACE="$SANDBOX/trace.log" +MOSAIC_ADMIN_SEAT=admin-seat MOSAIC_GITEA_INSTANCES=alpha bash -x "$TARGET" newseat >"$SANDBOX/out11" 2>"$TRACE" || fail "M11: traced run failed" +if grep -qF "$ADMIN_TOKEN_VALUE" "$TRACE"; then fail "M11: admin token value appears in xtrace"; fi +if grep -qF 'minted-token-7f3a' "$TRACE"; then fail "M11: minted token value appears in xtrace"; fi +# Any password-shaped expansion: the password is 32 base64ish chars. A trace +# that expands it into a word (assignment or argument) prints exactly that +# shape; the clean script's trace contains no 32-char base64ish run at all +# (paths and URLs are the only long strings). +if grep -qE "[[:space:]=\"'][A-Za-z0-9]{32}([[:space:]\"']|$)" "$TRACE"; then + LEAK=$(grep -oE "[[:space:]=\"'][A-Za-z0-9]{32}" "$TRACE" | head -1) + fail "M11: password-like value expansion in xtrace: $LEAK" +fi + echo "mint-seat-credential regression harness passed" -- 2.54.0