Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d55cbd264e |
@@ -254,15 +254,32 @@ from urllib.parse import urlparse
|
||||
|
||||
|
||||
def _origin_and_path(url):
|
||||
# Normalize a URL to (scheme, host, effective-port) + comment path. The port
|
||||
# defaults to the scheme's default (80 http / 443 otherwise) so an implicit
|
||||
# port and its explicit default form compare equal.
|
||||
# Normalize a URL to (scheme-class, host, distinguishing-port) + comment path.
|
||||
#
|
||||
# #991: http and https collapse into ONE scheme class ("web"). A Gitea whose
|
||||
# ROOT_URL is configured http:// returns http:// object URLs even when every
|
||||
# client reaches it over https://, so a scheme-strict comparison rejects the
|
||||
# provider's own correct answer about a write that landed — a deterministic
|
||||
# false negative on every comment posted against such a deployment. The
|
||||
# scheme is also not what this check defends: the forgeries it exists to
|
||||
# catch (look-alike host, decoy path prefix, wrong owner/repo/number) all
|
||||
# vary the HOST or the PATH, both of which stay strict below. Any OTHER
|
||||
# scheme (file:, ftp:, javascript:) remains distinguishing and is rejected.
|
||||
#
|
||||
# Port: an implicit port and its own scheme's default compare equal, so
|
||||
# http://h == https://h. An EXPLICIT non-default port still distinguishes,
|
||||
# because a different port is a different service on the same host.
|
||||
parsed = urlparse(url or "")
|
||||
scheme = (parsed.scheme or "").lower()
|
||||
host = (parsed.hostname or "").lower()
|
||||
default_port = 80 if scheme == "http" else 443
|
||||
port = parsed.port if parsed.port is not None else default_port
|
||||
return (scheme, host, port), parsed.path.rstrip("/")
|
||||
if scheme in ("http", "https"):
|
||||
scheme_class = "web"
|
||||
default_port = 80 if scheme == "http" else 443
|
||||
port = None if parsed.port in (None, default_port) else parsed.port
|
||||
else:
|
||||
scheme_class = scheme
|
||||
port = parsed.port
|
||||
return (scheme_class, host, port), parsed.path.rstrip("/")
|
||||
|
||||
|
||||
try:
|
||||
|
||||
@@ -243,15 +243,35 @@ from urllib.parse import urlparse
|
||||
|
||||
|
||||
def _origin_and_path(url):
|
||||
# Normalize a URL to (scheme, host, effective-port) + comment path. The port
|
||||
# defaults to the scheme's default (80 http / 443 otherwise) so an implicit
|
||||
# port and its explicit default form compare equal.
|
||||
# Normalize a URL to (scheme-class, host, distinguishing-port) + comment path.
|
||||
#
|
||||
# #991: http and https collapse into ONE scheme class ("web"). A Gitea whose
|
||||
# ROOT_URL is configured http:// returns http:// object URLs even when every
|
||||
# client reaches it over https://, so a scheme-strict comparison rejects the
|
||||
# provider's own correct answer about a comment that landed — a deterministic
|
||||
# false negative on EVERY review comment posted against such a deployment.
|
||||
# That matters more here than anywhere else: on a host where no seat can
|
||||
# create a review OBJECT, the comment-form review record this path produces
|
||||
# is the only gate-16 evidence available, and this check refuses all of it.
|
||||
# The scheme is also not what the check defends: the forgeries it exists to
|
||||
# catch (look-alike host, decoy path prefix, wrong owner/repo/kind/number)
|
||||
# all vary the HOST or the PATH, both of which stay strict below. Any OTHER
|
||||
# scheme (file:, ftp:, javascript:) remains distinguishing and is rejected.
|
||||
#
|
||||
# Port: an implicit port and its own scheme's default compare equal, so
|
||||
# http://h == https://h. An EXPLICIT non-default port still distinguishes,
|
||||
# because a different port is a different service on the same host.
|
||||
parsed = urlparse(url or "")
|
||||
scheme = (parsed.scheme or "").lower()
|
||||
host = (parsed.hostname or "").lower()
|
||||
default_port = 80 if scheme == "http" else 443
|
||||
port = parsed.port if parsed.port is not None else default_port
|
||||
return (scheme, host, port), parsed.path.rstrip("/")
|
||||
if scheme in ("http", "https"):
|
||||
scheme_class = "web"
|
||||
default_port = 80 if scheme == "http" else 443
|
||||
port = None if parsed.port in (None, default_port) else parsed.port
|
||||
else:
|
||||
scheme_class = scheme
|
||||
port = parsed.port
|
||||
return (scheme_class, host, port), parsed.path.rstrip("/")
|
||||
|
||||
|
||||
try:
|
||||
|
||||
@@ -7,40 +7,14 @@ 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" "$HOME_DIR"
|
||||
mkdir -p "$REPO_DIR" "$BIN_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'
|
||||
{
|
||||
@@ -112,7 +86,6 @@ run_in_repo() {
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
PATH="$BIN_DIR:$PATH" \
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||
MOSAIC_TEST_LOG="$LOG_FILE" \
|
||||
"$@"
|
||||
@@ -310,7 +283,6 @@ run_in_repo2() {
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
PATH="$BIN_DIR2:$PATH" \
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||
MOSAIC_TEST_LOG="$LOG_FILE" \
|
||||
"$@"
|
||||
@@ -371,7 +343,7 @@ write_fixture() { printf '%s' "$1" > "$FIXTURE_XDG/tea/config.yml"; }
|
||||
token_fallback() {
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
HOME="$HOME_DIR" XDG_CONFIG_HOME="$FIXTURE_XDG" PYTHONPATH="$NOYAML_DIR" bash -c '
|
||||
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"
|
||||
@@ -382,7 +354,7 @@ token_fallback() {
|
||||
token_pyyaml() {
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
HOME="$HOME_DIR" XDG_CONFIG_HOME="$FIXTURE_XDG" bash -c '
|
||||
XDG_CONFIG_HOME="$FIXTURE_XDG" bash -c '
|
||||
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
||||
get_gitea_token_for_login "$1" "$2"
|
||||
' _ "$1" "$2"
|
||||
|
||||
@@ -41,7 +41,13 @@
|
||||
# that other host's credential cross-host;
|
||||
# 10. leaves NO temp files behind (POST/GET bodies + metadata) on either the
|
||||
# success or the failure path — nested function-scoped RETURN traps do not
|
||||
# clobber each other and every scratch file is removed on all exit paths.
|
||||
# clobber each other and every scratch file is removed on all exit paths;
|
||||
# 11. (#991) ACCEPTS a record whose provider-returned URL differs from the repo
|
||||
# remote ONLY in http-vs-https — the standing state on a deployment whose
|
||||
# Gitea ROOT_URL is misconfigured — while still REJECTING a different
|
||||
# explicit port and a non-web scheme. Every prior fixture here was https://
|
||||
# and every negative varied only host or path, so the one axis that fails
|
||||
# in production had zero coverage: the fixtures encoded the assumption.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -61,6 +67,12 @@ 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"
|
||||
# #1007: a sandboxed HOME. detect-platform.sh's step-0 per-agent identity lookup
|
||||
# reads ~/.config/mosaic/gitea-tokens/<identity>, which is OUTSIDE both
|
||||
# XDG_CONFIG_HOME and MOSAIC_CREDENTIALS_FILE — so on any seat that has a real
|
||||
# per-agent token the suite resolves a PRODUCTION credential and dies at the
|
||||
# authenticated-identity read (HTTP 401) before reaching case 1. Pointing HOME
|
||||
# at the work dir keeps that lookup inside the sandbox.
|
||||
HOME_DIR="$WORK_DIR/home"
|
||||
|
||||
cleanup() {
|
||||
@@ -71,43 +83,12 @@ trap cleanup EXIT
|
||||
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.
|
||||
# #1007: shadow any GLOBAL `mosaic.gitIdentity` with an empty REPO-LOCAL value.
|
||||
# A repo-local key shadows the global and reads back empty at rc=0, which is the
|
||||
# only way to make step 0 fall through inside a sandbox. Note the env-var route
|
||||
# (MOSAIC_GIT_IDENTITY="") does NOT work: detect-platform.sh reads it with
|
||||
# `${MOSAIC_GIT_IDENTITY:-}`, and `:-` treats set-but-empty identically to
|
||||
# unset, so setting it empty is silently the same as not setting it at all.
|
||||
git -C "$REPO_DIR" config mosaic.gitIdentity ""
|
||||
|
||||
ISSUE_NUMBER=7
|
||||
@@ -305,6 +286,19 @@ elif mode == "url-wrong-repo":
|
||||
elif mode == "url-suffix-injection":
|
||||
# Prefix-injected: a bare endswith("/<slug>/issues/7") test would ACCEPT this.
|
||||
issue_url = f"https://git.mosaicstack.dev/deceptive/{repo}/issues/7"
|
||||
elif mode == "url-scheme-downgrade":
|
||||
# #991, and the ONLY one of these modes that must be ACCEPTED. A Gitea whose
|
||||
# ROOT_URL is http:// returns http:// object URLs for a repo cloned over
|
||||
# https://. Same host, same path, correct record — the provider is telling
|
||||
# the truth about a write that landed.
|
||||
issue_url = f"http://git.mosaicstack.dev/{repo}/issues/7"
|
||||
elif mode == "url-wrong-port":
|
||||
# An EXPLICIT non-default port is a different service on the same host and
|
||||
# must stay distinguishing — collapsing the scheme must not collapse this.
|
||||
issue_url = f"https://git.mosaicstack.dev:8443/{repo}/issues/7"
|
||||
elif mode == "url-non-web-scheme":
|
||||
# Only http/https collapse. Any other scheme stays distinguishing.
|
||||
issue_url = f"ftp://git.mosaicstack.dev/{repo}/issues/7"
|
||||
record = {
|
||||
"id": new_id,
|
||||
"body": body,
|
||||
@@ -404,8 +398,8 @@ run_comment() {
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
PATH="$BIN_DIR:$PATH" \
|
||||
TMPDIR="$TMP_SCRATCH" \
|
||||
HOME="$HOME_DIR" \
|
||||
TMPDIR="$TMP_SCRATCH" \
|
||||
XDG_CONFIG_HOME="$XDG_DIR" \
|
||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||
ISSUE_COMMENT_TEA_LOG="$TEA_LOG" \
|
||||
@@ -585,13 +579,17 @@ if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
|
||||
fi
|
||||
assert_no_temp_leak "cross-host"
|
||||
|
||||
# Cases 7-10 (#865 Blocker 3): the created record's id/author/body are all
|
||||
# correct, but its provider-returned issue_url is forged. Verification pins the
|
||||
# URL's ORIGIN (scheme+host+effective-port) and its FULL path (deployment prefix
|
||||
# + exact owner/repo + kind + number), so each forgery must FAIL CLOSED. A bare
|
||||
# endswith/suffix test would wrongly accept the look-alike-host and
|
||||
# prefix-injection variants.
|
||||
for bad_mode in url-wrong-host url-wrong-owner url-wrong-repo url-suffix-injection; do
|
||||
# Cases 7-12 (#865 Blocker 3): the created record's id/author/body are all
|
||||
# correct, but its provider-returned issue_url does not belong to this issue on
|
||||
# this provider/repo. Verification pins the URL's ORIGIN (scheme-class + host +
|
||||
# explicit non-default port) and its FULL path (deployment prefix + exact
|
||||
# owner/repo + kind + number), so each must FAIL CLOSED. A bare endswith/suffix
|
||||
# test would wrongly accept the look-alike-host and prefix-injection variants.
|
||||
# url-wrong-port and url-non-web-scheme (#991) bound the scheme relaxation from
|
||||
# the other side: collapsing http/https must not also collapse a different port
|
||||
# or a different scheme family.
|
||||
for bad_mode in url-wrong-host url-wrong-owner url-wrong-repo url-suffix-injection \
|
||||
url-wrong-port url-non-web-scheme; do
|
||||
if run_comment "$bad_mode"; then
|
||||
echo "FAIL: forged comment URL ($bad_mode) was accepted" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
@@ -608,4 +606,19 @@ done
|
||||
# issue_url (already exercised by Case 1's fresh-success), so the tightened check
|
||||
# is not rejecting genuine writes.
|
||||
|
||||
# Case 13 (#991): the deployment's ROOT_URL is http:// while the repo remote is
|
||||
# https://, so the provider returns an http:// issue_url for a record that is
|
||||
# otherwise entirely correct. This is not a forgery — it is the provider's own
|
||||
# truthful answer about a write that landed — and a scheme-strict comparison
|
||||
# rejects it deterministically, on EVERY comment, converting a successful write
|
||||
# into a reported failure. It must be ACCEPTED. Host, path, owner, repo, kind
|
||||
# and number all remain strict; only the http/https distinction is relaxed.
|
||||
run_comment url-scheme-downgrade
|
||||
grep -q 'Added and verified comment on Gitea issue #7 (comment ID 1)' "$OUTPUT_FILE"
|
||||
# The write really happened and was read back by exact id — this case passes
|
||||
# through the same POST/GET chain as case 1, not around it.
|
||||
grep -q "^POST $API_BASE/issues/7/comments$" "$CURL_LOG"
|
||||
grep -q "^GET $API_BASE/issues/comments/1$" "$CURL_LOG"
|
||||
assert_no_temp_leak "url-scheme-downgrade"
|
||||
|
||||
echo "issue-comment.sh REST create + exact-id read-back regression passed"
|
||||
|
||||
@@ -7,38 +7,13 @@ 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" "$HOME_DIR"
|
||||
mkdir -p "$REPO_DIR" "$BIN_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"}}}
|
||||
@@ -75,7 +50,6 @@ run_wrapper() {
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
PATH="$BIN_DIR:$PATH" \
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||
MOSAIC_TEST_LOG="$LOG_FILE" \
|
||||
"$@"
|
||||
|
||||
@@ -8,7 +8,6 @@ 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() {
|
||||
@@ -16,7 +15,7 @@ cleanup() {
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$MOCK_BIN" "$REPO_DIR" "$HOME_DIR"
|
||||
mkdir -p "$MOCK_BIN" "$REPO_DIR"
|
||||
: > "$LOG_FILE"
|
||||
|
||||
cat > "$MOCK_BIN/tea" <<'EOF'
|
||||
@@ -100,48 +99,7 @@ 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"
|
||||
|
||||
@@ -8,68 +8,12 @@ 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" "$HOME_DIR"
|
||||
mkdir -p "$REPO_DIR" "$FIXTURE_DIR" "$STUB_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'
|
||||
{
|
||||
@@ -187,8 +131,6 @@ 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" \
|
||||
@@ -228,8 +170,6 @@ 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" \
|
||||
@@ -264,8 +204,7 @@ run_curl_early_exit_cleanup_case() {
|
||||
run_case() {
|
||||
local fixture="$1" expected_number="$2" expected_head="$3"
|
||||
local output
|
||||
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")
|
||||
output=$(cd "$REPO_DIR" && 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
|
||||
|
||||
@@ -436,6 +436,19 @@ elif mode == "comment-url-wrong-repo":
|
||||
elif mode == "comment-url-suffix-injection":
|
||||
# Prefix-injected: a bare endswith("/<slug>/pulls/123") test would ACCEPT it.
|
||||
pr_url = f"{_origin}/deceptive{_slug}/pulls/123"
|
||||
elif mode == "comment-url-wrong-port":
|
||||
# #991 bound: an EXPLICIT non-default port is a different service on the same
|
||||
# host. Relaxing http-vs-https must NOT relax this.
|
||||
pr_url = f"{_p.scheme}://{_p.hostname}:8443{_slug}/pulls/123"
|
||||
elif mode == "comment-url-non-web-scheme":
|
||||
# #991 bound: ONLY http/https collapse; any other scheme stays distinguishing.
|
||||
pr_url = f"ftp://{_p.netloc}{_slug}/pulls/123"
|
||||
elif mode == "comment-url-scheme-downgrade":
|
||||
# #991, and the only URL mode here that must be ACCEPTED. A Gitea whose
|
||||
# ROOT_URL is http:// returns http:// object URLs for a repo reached over
|
||||
# https://. Same host, same path, correct record — a truthful provider
|
||||
# answer about a comment that landed, not a forgery.
|
||||
pr_url = f"http://{_p.netloc}{_slug}/pulls/123"
|
||||
elif mode == "comment-mixed-case-slug":
|
||||
# #875: EXPECTED_REPO_SLUG is taken verbatim from GITEA_API_BASE and can be
|
||||
# mixed-case (e.g. "USC/uconnect"), but Gitea canonicalizes the returned
|
||||
@@ -890,11 +903,16 @@ fi
|
||||
assert_no_temp_leak "review-body-reuse"
|
||||
|
||||
# Cases 12-15 (#865 Blocker 3): a PR comment whose id/author/body are all correct
|
||||
# but whose provider-returned pull_request_url is forged must FAIL CLOSED.
|
||||
# Verification pins the URL's ORIGIN (scheme+host+effective-port) and FULL path
|
||||
# (deployment prefix + exact owner/repo + kind + number); a bare endswith/suffix
|
||||
# test would wrongly accept the look-alike-host and prefix-injection variants.
|
||||
for bad_mode in comment-url-wrong-host comment-url-wrong-owner comment-url-wrong-repo comment-url-suffix-injection; do
|
||||
# but whose provider-returned pull_request_url does not belong to this PR must
|
||||
# FAIL CLOSED. Verification pins the URL's ORIGIN (scheme-class + host + explicit
|
||||
# non-default port) and FULL path (deployment prefix + exact owner/repo + kind +
|
||||
# number); a bare endswith/suffix test would wrongly accept the look-alike-host
|
||||
# and prefix-injection variants. comment-url-wrong-port and
|
||||
# comment-url-non-web-scheme (#991) bound the scheme relaxation from the other
|
||||
# side: collapsing http/https must not also collapse a different port or a
|
||||
# different scheme family.
|
||||
for bad_mode in comment-url-wrong-host comment-url-wrong-owner comment-url-wrong-repo \
|
||||
comment-url-suffix-injection comment-url-wrong-port comment-url-non-web-scheme; do
|
||||
if run_review "$bad_mode" comment durable-body; then
|
||||
echo "FAIL: forged comment URL ($bad_mode) was accepted" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
@@ -920,6 +938,19 @@ run_review comment-mixed-case-slug comment durable-body https://git.mosaicstack.
|
||||
grep -q 'Added and verified comment on Gitea PR #123' "$OUTPUT_FILE"
|
||||
assert_no_temp_leak "comment-mixed-case-slug"
|
||||
|
||||
# Case 15c (#991): the deployment's Gitea ROOT_URL is http:// while every client
|
||||
# reaches it over https://, so the provider returns an http:// pull_request_url
|
||||
# for a comment that is otherwise entirely correct. Same class as 15b — a
|
||||
# legitimate provider response, not a spoof — and a scheme-strict compare
|
||||
# rejects it on EVERY comment, deterministically. That is not a cosmetic false
|
||||
# negative here: on a host where no seat can create a review OBJECT, this
|
||||
# comment-form record is the only gate-16 evidence obtainable, and the wrapper
|
||||
# refuses all of it while the comment sits durably on the PR. Host, path, owner,
|
||||
# repo, kind and number stay strict; only http-vs-https is relaxed.
|
||||
run_review comment-url-scheme-downgrade comment durable-body
|
||||
grep -q 'Added and verified comment on Gitea PR #123' "$OUTPUT_FILE"
|
||||
assert_no_temp_leak "comment-url-scheme-downgrade"
|
||||
|
||||
# Case 16 (#865 ITEM 1, current-head TOCTOU): the PR head advances between the
|
||||
# pre-submit head read (which pins the review) and the post-verify re-read. The
|
||||
# review is genuinely created and verified as pinned to the OLD head, but the
|
||||
|
||||
Reference in New Issue
Block a user