diff --git a/packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh b/packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh index 5a65f7fd..c619d134 100755 --- a/packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh +++ b/packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh @@ -7,14 +7,40 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/gitea-login-resolution}" REPO_DIR="$WORK_DIR/repo" BIN_DIR="$WORK_DIR/bin" +HOME_DIR="$WORK_DIR/home" LOG_FILE="$WORK_DIR/calls.log" CREDENTIALS_FILE="$WORK_DIR/credentials.json" rm -rf "$WORK_DIR" -mkdir -p "$REPO_DIR" "$BIN_DIR" +mkdir -p "$REPO_DIR" "$BIN_DIR" "$HOME_DIR" git -C "$REPO_DIR" init -q git -C "$REPO_DIR" remote add origin https://git.uscllc.com/USC/uconnect.git +# HERMETICITY (#1007) — TWO mechanisms with DIFFERENT jobs; do not conflate them. +# +# OPERATIVE: the empty repo-local `mosaic.gitIdentity` below. get_gitea_token() +# step 0 resolves a per-agent identity from `git config --get mosaic.gitIdentity`, +# which on a provisioned agent seat is set GLOBALLY and so leaks into this fresh +# repo. It then reads a REAL per-slot token from $HOME and returns it WITHOUT ever +# consulting MOSAIC_CREDENTIALS_FILE, so the fixture credentials below are silently +# ignored. This suite is the one where the consequence is not subtle: it FAILS +# outright on a provisioned seat (rc=1 bare, rc=0 with $HOME sandboxed, one +# variable changed) and passes everywhere else, including CI, which has no +# per-agent token to leak. +# +# CONTAINMENT: the sandboxed HOME in the four run helpers below. It only has to +# bound a failure that the pin should already have prevented. +# +# NOTE FOR ANYONE AUDITING THIS SUITE: the sandboxed HOME is containment, NOT an +# assay. Running a suite under a decoy HOME to test for this defect REMOVES the +# trigger — ~/.gitconfig is where the global identity lives, so step 0 is skipped +# by construction and every suite reads clean however vulnerable it is. To measure, +# REPLICATE a seat (a decoy HOME whose .gitconfig sets mosaic.gitIdentity, with no +# per-slot token) so step 0 reaches its fail-loud branch. +# +# Note the env-var route does NOT work: detect-platform.sh reads +# "${MOSAIC_GIT_IDENTITY:-}", and `:-` treats set-but-empty identically to unset. +git -C "$REPO_DIR" config mosaic.gitIdentity "" cat > "$CREDENTIALS_FILE" <<'JSON' { @@ -86,6 +112,7 @@ run_in_repo() { ( cd "$REPO_DIR" PATH="$BIN_DIR:$PATH" \ + HOME="$HOME_DIR" \ MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \ MOSAIC_TEST_LOG="$LOG_FILE" \ "$@" @@ -283,6 +310,7 @@ run_in_repo2() { ( cd "$REPO_DIR" PATH="$BIN_DIR2:$PATH" \ + HOME="$HOME_DIR" \ MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \ MOSAIC_TEST_LOG="$LOG_FILE" \ "$@" @@ -343,7 +371,7 @@ write_fixture() { printf '%s' "$1" > "$FIXTURE_XDG/tea/config.yml"; } token_fallback() { ( cd "$REPO_DIR" - XDG_CONFIG_HOME="$FIXTURE_XDG" PYTHONPATH="$NOYAML_DIR" bash -c ' + HOME="$HOME_DIR" XDG_CONFIG_HOME="$FIXTURE_XDG" PYTHONPATH="$NOYAML_DIR" bash -c ' source "'"$SCRIPT_DIR"'/detect-platform.sh" get_gitea_token_for_login "$1" "$2" ' _ "$1" "$2" @@ -354,7 +382,7 @@ token_fallback() { token_pyyaml() { ( cd "$REPO_DIR" - XDG_CONFIG_HOME="$FIXTURE_XDG" bash -c ' + HOME="$HOME_DIR" XDG_CONFIG_HOME="$FIXTURE_XDG" bash -c ' source "'"$SCRIPT_DIR"'/detect-platform.sh" get_gitea_token_for_login "$1" "$2" ' _ "$1" "$2" diff --git a/packages/mosaic/framework/tools/git/test-issue-comment-readback.sh b/packages/mosaic/framework/tools/git/test-issue-comment-readback.sh index b0178385..e8b1bb7c 100755 --- a/packages/mosaic/framework/tools/git/test-issue-comment-readback.sh +++ b/packages/mosaic/framework/tools/git/test-issue-comment-readback.sh @@ -61,15 +61,54 @@ STATE_FILE="$WORK_DIR/comments.json" # A dedicated scratch dir the wrapper is pointed at via TMPDIR, so the leak # check can assert every POST/GET body + metadata temp file is cleaned up. TMP_SCRATCH="$WORK_DIR/scratch" +HOME_DIR="$WORK_DIR/home" cleanup() { rm -rf "$WORK_DIR" } trap cleanup EXIT -mkdir -p "$REPO_DIR" "$BIN_DIR" "$XDG_DIR" "$TMP_SCRATCH" +mkdir -p "$REPO_DIR" "$BIN_DIR" "$XDG_DIR" "$TMP_SCRATCH" "$HOME_DIR" git -C "$REPO_DIR" init -q git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git +# HERMETICITY (#1007) — TWO mechanisms with DIFFERENT jobs; do not conflate them. +# +# OPERATIVE: the empty repo-local `mosaic.gitIdentity` below. get_gitea_token() +# step 0 resolves a per-agent identity from `git config --get mosaic.gitIdentity`, +# which on a provisioned agent seat is set GLOBALLY and so leaks into this fresh +# repo. It then reads a REAL per-slot token from $HOME and returns it WITHOUT ever +# consulting MOSAIC_CREDENTIALS_FILE, so the fixture credential below is silently +# ignored. The stub curl then rejects the unrecognised bearer, and this suite +# fails at its FIRST case with `Gitea authenticated-identity read failed with +# HTTP 401`. An empty repo-local value shadows the global one and reads back +# empty at rc=0. Measured: without this pin the suite is RED on every seat. +# +# CONTAINMENT: the sandboxed HOME in run_comment(). It only has to bound a +# failure that the pin should already have prevented. +# +# THIS SUITE WAS THE HARDEST OF THE FIVE TO SEE, and the reason is worth stating +# because it generalises: run_comment() sends the wrapper's stdout AND stderr to +# $OUTPUT_FILE, and the EXIT trap above deletes $WORK_DIR. So the 401 — the only +# thing that says what went wrong — exists only inside a directory that is gone +# by the time anyone looks. The suite exits 1 with ZERO bytes on stdout and +# stderr. A suite that discards or deletes its own evidence turns any post-hoc +# assay into a non-measurement: "nothing found" there means "no surviving +# trace", never "clean". It was found by intercepting the identity read at its +# SOURCE (a PATH shim over `git` logging every `mosaic.gitIdentity` read to a +# file outside $WORK_DIR), which is deletion-proof by construction, rather than +# by grepping for the symptom. +# +# NOTE FOR ANYONE AUDITING THIS SUITE: the sandboxed HOME is containment, NOT an +# assay. Running a suite under a decoy HOME to test for this defect REMOVES the +# trigger — ~/.gitconfig is where the global identity lives, so step 0 is skipped +# by construction and every suite reads clean however vulnerable it is. To +# measure, REPLICATE a seat (a decoy HOME whose .gitconfig sets +# mosaic.gitIdentity, with no per-slot token) so step 0 reaches its fail-loud +# branch — or intercept the read as described above. +# +# Note the env-var route does NOT work: detect-platform.sh reads +# "${MOSAIC_GIT_IDENTITY:-}", and `:-` treats set-but-empty identically to unset. +git -C "$REPO_DIR" config mosaic.gitIdentity "" ISSUE_NUMBER=7 REPO_SLUG="mosaicstack/stack" @@ -366,6 +405,7 @@ run_comment() { cd "$REPO_DIR" PATH="$BIN_DIR:$PATH" \ TMPDIR="$TMP_SCRATCH" \ + HOME="$HOME_DIR" \ XDG_CONFIG_HOME="$XDG_DIR" \ MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \ ISSUE_COMMENT_TEA_LOG="$TEA_LOG" \ diff --git a/packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh b/packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh index 496e2c43..ecec60cb 100755 --- a/packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh +++ b/packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh @@ -7,13 +7,38 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-create-interactive-auth}" REPO_DIR="$WORK_DIR/repo" BIN_DIR="$WORK_DIR/bin" +HOME_DIR="$WORK_DIR/home" LOG_FILE="$WORK_DIR/calls.log" CREDENTIALS_FILE="$WORK_DIR/credentials.json" rm -rf "$WORK_DIR" -mkdir -p "$REPO_DIR" "$BIN_DIR" +mkdir -p "$REPO_DIR" "$BIN_DIR" "$HOME_DIR" git -C "$REPO_DIR" init -q git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git +# HERMETICITY (#1007) — TWO mechanisms with DIFFERENT jobs; do not conflate them. +# +# OPERATIVE: the empty repo-local `mosaic.gitIdentity` below. get_gitea_token() +# step 0 resolves a per-agent identity from `git config --get mosaic.gitIdentity`, +# which on a provisioned agent seat is set GLOBALLY and so leaks into this fresh +# repo. It then reads a REAL per-slot token from $HOME and returns it WITHOUT ever +# consulting MOSAIC_CREDENTIALS_FILE, so the fixture credential below is silently +# ignored and the suite runs against a production credential. An empty repo-local +# value shadows the global one and reads back empty at rc=0. Measured: this suite +# resolves a per-slot token without it. +# +# CONTAINMENT: the sandboxed HOME in run_wrapper(). It only has to bound a failure +# that the pin should already have prevented. +# +# NOTE FOR ANYONE AUDITING THIS SUITE: the sandboxed HOME is containment, NOT an +# assay. Running a suite under a decoy HOME to test for this defect REMOVES the +# trigger — ~/.gitconfig is where the global identity lives, so step 0 is skipped +# by construction and every suite reads clean however vulnerable it is. To measure, +# REPLICATE a seat (a decoy HOME whose .gitconfig sets mosaic.gitIdentity, with no +# per-slot token) so step 0 reaches its fail-loud branch. +# +# Note the env-var route does NOT work: detect-platform.sh reads +# "${MOSAIC_GIT_IDENTITY:-}", and `:-` treats set-but-empty identically to unset. +git -C "$REPO_DIR" config mosaic.gitIdentity "" cat > "$CREDENTIALS_FILE" <<'JSON' {"gitea":{"mosaicstack":{"url":"https://git.mosaicstack.dev","token":"test-token"}}} @@ -50,6 +75,7 @@ run_wrapper() { ( cd "$REPO_DIR" PATH="$BIN_DIR:$PATH" \ + HOME="$HOME_DIR" \ MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \ MOSAIC_TEST_LOG="$LOG_FILE" \ "$@" diff --git a/packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh b/packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh index c642b81e..62bf7e5e 100755 --- a/packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh +++ b/packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh @@ -8,6 +8,7 @@ WORK_ROOT="${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}" SANDBOX="$WORK_ROOT/pr-merge-empty-uid-test-$$" MOCK_BIN="$SANDBOX/bin" REPO_DIR="$SANDBOX/repo" +HOME_DIR="$SANDBOX/home" LOG_FILE="$SANDBOX/mock.log" cleanup() { @@ -15,7 +16,7 @@ cleanup() { } trap cleanup EXIT -mkdir -p "$MOCK_BIN" "$REPO_DIR" +mkdir -p "$MOCK_BIN" "$REPO_DIR" "$HOME_DIR" : > "$LOG_FILE" cat > "$MOCK_BIN/tea" <<'EOF' @@ -99,7 +100,48 @@ chmod +x "$MOCK_BIN/curl" cd "$REPO_DIR" git init -q git remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git +# HERMETICITY (#1007) — TWO mechanisms with DIFFERENT jobs; do not conflate them. +# +# OPERATIVE: the empty repo-local `mosaic.gitIdentity` below. get_gitea_token() +# step 0 resolves a per-agent identity from `git config --get mosaic.gitIdentity`, +# which on a provisioned agent seat is set GLOBALLY and so leaks into this fresh +# repo. Step 0 runs BEFORE the credential loader AND before the GITEA_TOKEN env +# check, so the `GITEA_TOKEN=redacted-test-token` exported below is silently +# overridden and a REAL per-slot token from $HOME is what flows through the +# wrapper. Measured on a provisioned seat before this pin: all 5 mock-curl calls +# carried the real per-slot token in argv and the fixture token was never used at +# ALL. Three consequences specific to this suite: +# 1. pr-merge.sh passes the token as `-H "Authorization: token $token"` and the +# mock curl logs full argv, so the real credential is written to $LOG_FILE +# on disk — transiently: the suite truncates that file between phases and +# the EXIT trap removes $SANDBOX, so it leaves NO post-hoc trace. That is +# why this suite was the hardest of the three to detect; observing it needs +# an instrument that captures argv while the run is live. +# 2. Every failure path dumps $OUTPUT/$LOG_FILE to stderr through +# `sed 's/redacted-test-token/***REDACTED***/g'` — a redaction pattern that +# is the literal fixture string and therefore CANNOT match the token +# actually in use. +# 3. The leak assertion at "Token leaked to pr-merge.sh output" greps for that +# same fixture string, so on a provisioned seat it passes vacuously: it is +# searching for a value the run never used. +# An empty repo-local value shadows the global one and reads back empty at rc=0. +# +# CONTAINMENT: the sandboxed HOME exported below. It only has to bound a failure +# that the pin should already have prevented. +# +# NOTE FOR ANYONE AUDITING THIS SUITE: the sandboxed HOME is containment, NOT an +# assay. Running a suite under a decoy HOME to test for this defect REMOVES the +# trigger — ~/.gitconfig is where the global identity lives, so step 0 is skipped +# by construction and every suite reads clean however vulnerable it is. To measure, +# REPLICATE a seat (a decoy HOME whose .gitconfig sets mosaic.gitIdentity, with no +# per-slot token) so step 0 reaches its fail-loud branch. +# +# Note the env-var route does NOT work: detect-platform.sh reads +# "${MOSAIC_GIT_IDENTITY:-}", and `:-` treats set-but-empty identically to unset. +git -C "$REPO_DIR" config mosaic.gitIdentity "" +# $SANDBOX/$HOME_DIR were derived from the real $HOME above, before this export. +export HOME="$HOME_DIR" export PATH="$MOCK_BIN:$PATH" export PR_MERGE_TEST_LOG="$LOG_FILE" export GITEA_LOGIN="git.mosaicstack.dev" diff --git a/packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh b/packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh index 41ebefa8..f9dc856c 100755 --- a/packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh +++ b/packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh @@ -8,12 +8,68 @@ WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-metadata-gitea}" REPO_DIR="$WORK_DIR/repo" FIXTURE_DIR="$WORK_DIR/fixtures" STUB_DIR="$WORK_DIR/stubs" +HOME_DIR="$WORK_DIR/home" +CREDENTIALS_FILE="$WORK_DIR/credentials.json" rm -rf "$WORK_DIR" -mkdir -p "$REPO_DIR" "$FIXTURE_DIR" "$STUB_DIR" +mkdir -p "$REPO_DIR" "$FIXTURE_DIR" "$STUB_DIR" "$HOME_DIR" git -C "$REPO_DIR" init -q git -C "$REPO_DIR" remote add origin https://git.uscllc.com/USC/uconnect.git +# HERMETICITY (#1007) — TWO mechanisms with DIFFERENT jobs; do not conflate them. +# +# OPERATIVE: the empty repo-local `mosaic.gitIdentity` below. get_gitea_token() +# step 0 resolves a per-agent identity from `git config --get mosaic.gitIdentity`, +# which on a provisioned agent seat is set GLOBALLY and so leaks into this fresh +# repo. Step 0 runs BEFORE the credential loader AND before the GITEA_TOKEN env +# check, so the `GITEA_TOKEN="stub-token"` set in the run helpers below is +# silently overridden and a REAL per-slot token from $HOME is what reaches curl. +# Measured on a provisioned seat before this pin: both stub-curl calls carried +# the real token in argv. An empty repo-local value shadows the global one and +# reads back empty at rc=0. +# +# CONTAINMENT: the sandboxed HOME in the three run helpers below. It only has to +# bound a failure that the pin should already have prevented. +# +# NOTE FOR ANYONE AUDITING THIS SUITE: the sandboxed HOME is containment, NOT an +# assay. Running a suite under a decoy HOME to test for this defect REMOVES the +# trigger — ~/.gitconfig is where the global identity lives, so step 0 is skipped +# by construction and every suite reads clean however vulnerable it is. To measure, +# REPLICATE a seat (a decoy HOME whose .gitconfig sets mosaic.gitIdentity, with no +# per-slot token) so step 0 reaches its fail-loud branch. See +# test-gitea-token-identity.sh for the stronger `env -i HOME=…` form used where a +# suite's whole subject IS identity resolution. +# +# Note the env-var route does NOT work: detect-platform.sh reads +# "${MOSAIC_GIT_IDENTITY:-}", and `:-` treats set-but-empty identically to unset. +git -C "$REPO_DIR" config mosaic.gitIdentity "" + +# The pin above removes step 0, but this suite has a SECOND, independent +# dependency on operator state, and closing only the first would leave the suite +# red on any hermetic environment. The `GITEA_TOKEN="stub-token"` / +# `GITEA_URL="https://git.example.test"` pair the run helpers set is INERT: step 2 +# of get_gitea_token accepts GITEA_TOKEN only when GITEA_URL matches the remote +# host, and this repo's origin is git.uscllc.com, so that pair can never satisfy +# it. Before this fixture the only credential that could reach the authenticated +# curl branch was a REAL one — from step 0 on an agent seat, or from step 1 +# reading the operator's own ~/.config/mosaic/credentials.json. That is why the +# "curl success path" case passed: not because the stub credential worked, but +# because a production credential was available. +# +# A fixture is used rather than relying on the sandboxed HOME making step 1 find +# nothing: a test that passes because production configuration is ABSENT fails +# the moment it is present. Step 1 now resolves deterministically to a value that +# is a fixture on every machine. +cat > "$CREDENTIALS_FILE" <<'JSON' +{ + "gitea": { + "usc": { + "url": "https://git.uscllc.com", + "token": "stub-token" + } + } +} +JSON cat > "$FIXTURE_DIR/gitea-standard.json" <<'JSON' { @@ -131,6 +187,8 @@ run_curl_success_case() { set +e output=$(cd "$REPO_DIR" && \ PATH="$STUB_DIR:$PATH" \ + HOME="$HOME_DIR" \ + MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \ TMPDIR="$tmpdir" \ GITEA_TOKEN="stub-token" \ GITEA_URL="https://git.example.test" \ @@ -170,6 +228,8 @@ run_curl_early_exit_cleanup_case() { set +e output=$(cd "$REPO_DIR" && \ PATH="$STUB_DIR:$PATH" \ + HOME="$HOME_DIR" \ + MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \ TMPDIR="$tmpdir" \ GITEA_TOKEN="stub-token" \ GITEA_URL="https://git.example.test" \ @@ -204,7 +264,8 @@ run_curl_early_exit_cleanup_case() { run_case() { local fixture="$1" expected_number="$2" expected_head="$3" local output - output=$(cd "$REPO_DIR" && MOSAIC_GITEA_PR_METADATA_RAW_FILE="$fixture" "$SCRIPT_DIR/pr-metadata.sh" -n "$expected_number") + output=$(cd "$REPO_DIR" && HOME="$HOME_DIR" MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \ + MOSAIC_GITEA_PR_METADATA_RAW_FILE="$fixture" "$SCRIPT_DIR/pr-metadata.sh" -n "$expected_number") PR_METADATA_OUTPUT="$output" python3 - "$expected_number" "$expected_head" <<'PY' import json import os