mirror of https://github.com/garrytan/gstack.git
Merge remote-tracking branch 'origin/main' into garrytan/e2e-test-triage
# Conflicts: # CHANGELOG.md
This commit is contained in:
commit
e7b974dbab
|
|
@ -15,3 +15,4 @@ bun.lock
|
||||||
.env.local
|
.env.local
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
supabase/.temp/
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,12 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [0.11.16.1] - 2026-03-24 — Installation ID Privacy Fix
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Installation IDs are now random UUIDs instead of hostname hashes.** The old `SHA-256(hostname+username)` approach meant anyone who knew your machine identity could compute your installation ID. Now uses a random UUID stored in `~/.gstack/installation-id` — not derivable from any public input, rotatable by deleting the file.
|
||||||
|
- **RLS verification script handles edge cases.** `verify-rls.sh` now correctly treats INSERT success as expected (kept for old client compat), handles 409 conflicts and 204 no-ops.
|
||||||
|
|
||||||
## [0.11.16.0] - 2026-03-24 — Smarter CI + Telemetry Security
|
## [0.11.16.0] - 2026-03-24 — Smarter CI + Telemetry Security
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
|
||||||
|
|
@ -106,18 +106,29 @@ if [ -d "$STATE_DIR/sessions" ]; then
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Generate installation_id for community tier
|
# Generate installation_id for community tier
|
||||||
|
# Uses a random UUID stored locally — not derived from hostname/user so it
|
||||||
|
# can't be guessed or correlated by someone who knows your machine identity.
|
||||||
INSTALL_ID=""
|
INSTALL_ID=""
|
||||||
if [ "$TIER" = "community" ]; then
|
if [ "$TIER" = "community" ]; then
|
||||||
HOST="$(hostname 2>/dev/null || echo "unknown")"
|
ID_FILE="$HOME/.gstack/installation-id"
|
||||||
USER="$(whoami 2>/dev/null || echo "unknown")"
|
if [ -f "$ID_FILE" ]; then
|
||||||
if command -v shasum >/dev/null 2>&1; then
|
INSTALL_ID="$(cat "$ID_FILE" 2>/dev/null)"
|
||||||
INSTALL_ID="$(printf '%s-%s' "$HOST" "$USER" | shasum -a 256 | awk '{print $1}')"
|
fi
|
||||||
elif command -v sha256sum >/dev/null 2>&1; then
|
if [ -z "$INSTALL_ID" ]; then
|
||||||
INSTALL_ID="$(printf '%s-%s' "$HOST" "$USER" | sha256sum | awk '{print $1}')"
|
# Generate a random UUID v4
|
||||||
elif command -v openssl >/dev/null 2>&1; then
|
if command -v uuidgen >/dev/null 2>&1; then
|
||||||
INSTALL_ID="$(printf '%s-%s' "$HOST" "$USER" | openssl dgst -sha256 | awk '{print $NF}')"
|
INSTALL_ID="$(uuidgen | tr '[:upper:]' '[:lower:]')"
|
||||||
|
elif [ -r /proc/sys/kernel/random/uuid ]; then
|
||||||
|
INSTALL_ID="$(cat /proc/sys/kernel/random/uuid)"
|
||||||
|
else
|
||||||
|
# Fallback: random hex from /dev/urandom
|
||||||
|
INSTALL_ID="$(od -An -tx1 -N16 /dev/urandom 2>/dev/null | tr -d ' \n')"
|
||||||
|
fi
|
||||||
|
if [ -n "$INSTALL_ID" ]; then
|
||||||
|
mkdir -p "$(dirname "$ID_FILE")" 2>/dev/null
|
||||||
|
printf '%s' "$INSTALL_ID" > "$ID_FILE" 2>/dev/null
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
# If no SHA-256 command available, install_id stays empty
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Local-only fields (never sent remotely)
|
# Local-only fields (never sent remotely)
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# verify-rls.sh — smoke test that anon key is locked out after 002_tighten_rls.sql
|
# verify-rls.sh — smoke test after deploying 002_tighten_rls.sql
|
||||||
|
#
|
||||||
|
# Verifies:
|
||||||
|
# - SELECT denied on all tables and views (security fix)
|
||||||
|
# - UPDATE denied on installations (security fix)
|
||||||
|
# - INSERT still allowed on tables (kept for old client compat)
|
||||||
#
|
#
|
||||||
# Run manually after deploying the migration:
|
# Run manually after deploying the migration:
|
||||||
# bash supabase/verify-rls.sh
|
# bash supabase/verify-rls.sh
|
||||||
#
|
|
||||||
# All 9 checks should PASS (anon key denied for reads AND writes).
|
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
@ -14,90 +17,127 @@ URL="$GSTACK_SUPABASE_URL"
|
||||||
KEY="$GSTACK_SUPABASE_ANON_KEY"
|
KEY="$GSTACK_SUPABASE_ANON_KEY"
|
||||||
PASS=0
|
PASS=0
|
||||||
FAIL=0
|
FAIL=0
|
||||||
|
TOTAL=0
|
||||||
|
|
||||||
|
# check <description> <expected> <method> <path> [data]
|
||||||
|
# expected: "deny" (want 401/403) or "allow" (want 200/201)
|
||||||
check() {
|
check() {
|
||||||
local desc="$1"
|
local desc="$1"
|
||||||
local method="$2"
|
local expected="$2"
|
||||||
local path="$3"
|
local method="$3"
|
||||||
local data="${4:-}"
|
local path="$4"
|
||||||
|
local data="${5:-}"
|
||||||
|
TOTAL=$(( TOTAL + 1 ))
|
||||||
|
|
||||||
local args=(-sf -o /dev/null -w '%{http_code}' --max-time 10
|
local resp_file
|
||||||
-H "apikey: ${KEY}"
|
resp_file="$(mktemp 2>/dev/null || echo "/tmp/verify-rls-$$-$TOTAL")"
|
||||||
-H "Authorization: Bearer ${KEY}"
|
|
||||||
-H "Content-Type: application/json")
|
|
||||||
|
|
||||||
|
local http_code
|
||||||
if [ "$method" = "GET" ]; then
|
if [ "$method" = "GET" ]; then
|
||||||
HTTP="$(curl "${args[@]}" "${URL}/rest/v1/${path}" 2>/dev/null || echo "000")"
|
http_code="$(curl -s -o "$resp_file" -w '%{http_code}' --max-time 10 \
|
||||||
|
"${URL}/rest/v1/${path}" \
|
||||||
|
-H "apikey: ${KEY}" \
|
||||||
|
-H "Authorization: Bearer ${KEY}" \
|
||||||
|
-H "Content-Type: application/json" 2>/dev/null)" || http_code="000"
|
||||||
elif [ "$method" = "POST" ]; then
|
elif [ "$method" = "POST" ]; then
|
||||||
HTTP="$(curl "${args[@]}" -X POST "${URL}/rest/v1/${path}" -H "Prefer: return=minimal" -d "$data" 2>/dev/null || echo "000")"
|
http_code="$(curl -s -o "$resp_file" -w '%{http_code}' --max-time 10 \
|
||||||
|
-X POST "${URL}/rest/v1/${path}" \
|
||||||
|
-H "apikey: ${KEY}" \
|
||||||
|
-H "Authorization: Bearer ${KEY}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Prefer: return=minimal" \
|
||||||
|
-d "$data" 2>/dev/null)" || http_code="000"
|
||||||
elif [ "$method" = "PATCH" ]; then
|
elif [ "$method" = "PATCH" ]; then
|
||||||
HTTP="$(curl "${args[@]}" -X PATCH "${URL}/rest/v1/${path}" -d "$data" 2>/dev/null || echo "000")"
|
http_code="$(curl -s -o "$resp_file" -w '%{http_code}' --max-time 10 \
|
||||||
|
-X PATCH "${URL}/rest/v1/${path}" \
|
||||||
|
-H "apikey: ${KEY}" \
|
||||||
|
-H "Authorization: Bearer ${KEY}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$data" 2>/dev/null)" || http_code="000"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Only 401/403 prove RLS denial. 200 (even empty) means access is granted.
|
# Trim to last 3 chars (the HTTP code) in case of concatenation
|
||||||
# 5xx means something errored but access wasn't denied by policy.
|
http_code="$(echo "$http_code" | grep -oE '[0-9]{3}$' || echo "000")"
|
||||||
case "$HTTP" in
|
|
||||||
401|403)
|
if [ "$expected" = "deny" ]; then
|
||||||
echo " PASS $desc (HTTP $HTTP, denied by RLS)"
|
case "$http_code" in
|
||||||
PASS=$(( PASS + 1 ))
|
401|403)
|
||||||
;;
|
echo " PASS $desc (HTTP $http_code, denied)"
|
||||||
200)
|
PASS=$(( PASS + 1 )) ;;
|
||||||
# 200 means the request was accepted — check if data was returned
|
200|204)
|
||||||
if [ "$method" = "GET" ]; then
|
# For GETs: 200+empty means RLS filtering (pass). 200+data means leak (fail).
|
||||||
BODY="$(curl -sf --max-time 10 "${URL}/rest/v1/${path}" -H "apikey: ${KEY}" -H "Authorization: Bearer ${KEY}" -H "Content-Type: application/json" 2>/dev/null || echo "")"
|
# For PATCH: 204 means no rows matched — could be RLS or missing row.
|
||||||
if [ "$BODY" = "[]" ] || [ -z "$BODY" ]; then
|
if [ "$method" = "GET" ]; then
|
||||||
echo " WARN $desc (HTTP $HTTP, empty — may be RLS or empty table, verify manually)"
|
body="$(cat "$resp_file" 2>/dev/null || echo "")"
|
||||||
FAIL=$(( FAIL + 1 ))
|
if [ "$body" = "[]" ] || [ -z "$body" ]; then
|
||||||
|
echo " PASS $desc (HTTP $http_code, empty — RLS filtering)"
|
||||||
|
PASS=$(( PASS + 1 ))
|
||||||
|
else
|
||||||
|
echo " FAIL $desc (HTTP $http_code, got data!)"
|
||||||
|
FAIL=$(( FAIL + 1 ))
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo " FAIL $desc (HTTP $HTTP, got data)"
|
# PATCH 204 = no rows affected. RLS blocked the update or row doesn't exist.
|
||||||
FAIL=$(( FAIL + 1 ))
|
# Either way, the attacker can't modify data.
|
||||||
fi
|
echo " PASS $desc (HTTP $http_code, no rows affected)"
|
||||||
else
|
PASS=$(( PASS + 1 ))
|
||||||
echo " FAIL $desc (HTTP $HTTP, write accepted)"
|
fi ;;
|
||||||
FAIL=$(( FAIL + 1 ))
|
000)
|
||||||
fi
|
echo " WARN $desc (connection failed)"
|
||||||
;;
|
FAIL=$(( FAIL + 1 )) ;;
|
||||||
201)
|
*)
|
||||||
echo " FAIL $desc (HTTP $HTTP, write succeeded!)"
|
echo " WARN $desc (HTTP $http_code — unexpected)"
|
||||||
FAIL=$(( FAIL + 1 ))
|
FAIL=$(( FAIL + 1 )) ;;
|
||||||
;;
|
esac
|
||||||
000)
|
elif [ "$expected" = "allow" ]; then
|
||||||
echo " WARN $desc (connection failed)"
|
case "$http_code" in
|
||||||
FAIL=$(( FAIL + 1 ))
|
200|201|204|409)
|
||||||
;;
|
# 409 = conflict (duplicate key) — INSERT policy works, row already exists
|
||||||
*)
|
echo " PASS $desc (HTTP $http_code, allowed as expected)"
|
||||||
# 404, 406, 500, etc. — access not definitively denied by RLS
|
PASS=$(( PASS + 1 )) ;;
|
||||||
echo " WARN $desc (HTTP $HTTP — not a clean RLS denial)"
|
401|403)
|
||||||
FAIL=$(( FAIL + 1 ))
|
echo " FAIL $desc (HTTP $http_code, denied — should be allowed)"
|
||||||
;;
|
FAIL=$(( FAIL + 1 )) ;;
|
||||||
esac
|
000)
|
||||||
|
echo " WARN $desc (connection failed)"
|
||||||
|
FAIL=$(( FAIL + 1 )) ;;
|
||||||
|
*)
|
||||||
|
echo " WARN $desc (HTTP $http_code — unexpected)"
|
||||||
|
FAIL=$(( FAIL + 1 )) ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$resp_file" 2>/dev/null || true
|
||||||
}
|
}
|
||||||
|
|
||||||
echo "RLS Lockdown Verification"
|
echo "RLS Verification (after 002_tighten_rls.sql)"
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Read denial checks:"
|
echo "Read denial (should be blocked):"
|
||||||
check "SELECT telemetry_events" GET "telemetry_events?select=*&limit=1"
|
check "SELECT telemetry_events" deny GET "telemetry_events?select=*&limit=1"
|
||||||
check "SELECT installations" GET "installations?select=*&limit=1"
|
check "SELECT installations" deny GET "installations?select=*&limit=1"
|
||||||
check "SELECT update_checks" GET "update_checks?select=*&limit=1"
|
check "SELECT update_checks" deny GET "update_checks?select=*&limit=1"
|
||||||
check "SELECT crash_clusters" GET "crash_clusters?select=*&limit=1"
|
check "SELECT crash_clusters" deny GET "crash_clusters?select=*&limit=1"
|
||||||
check "SELECT skill_sequences" GET "skill_sequences?select=skill_a&limit=1"
|
check "SELECT skill_sequences" deny GET "skill_sequences?select=skill_a&limit=1"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Write denial checks:"
|
echo "Update denial (should be blocked):"
|
||||||
check "INSERT telemetry_events" POST "telemetry_events" '{"gstack_version":"test","os":"test","event_timestamp":"2026-01-01T00:00:00Z","outcome":"test"}'
|
check "UPDATE installations" deny PATCH "installations?installation_id=eq.test_verify_rls" '{"gstack_version":"hacked"}'
|
||||||
check "INSERT update_checks" POST "update_checks" '{"gstack_version":"test","os":"test"}'
|
|
||||||
check "INSERT installations" POST "installations" '{"installation_id":"test_verify_rls"}'
|
echo ""
|
||||||
check "UPDATE installations" PATCH "installations?installation_id=eq.test_verify_rls" '{"gstack_version":"hacked"}'
|
echo "Insert allowed (kept for old client compat):"
|
||||||
|
check "INSERT telemetry_events" allow POST "telemetry_events" '{"gstack_version":"verify_rls_test","os":"test","event_timestamp":"2026-01-01T00:00:00Z","outcome":"test"}'
|
||||||
|
check "INSERT update_checks" allow POST "update_checks" '{"gstack_version":"verify_rls_test","os":"test"}'
|
||||||
|
check "INSERT installations" allow POST "installations" '{"installation_id":"verify_rls_test"}'
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
echo "Results: $PASS passed, $FAIL failed (of 9 checks)"
|
echo "Results: $PASS passed, $FAIL failed (of $TOTAL checks)"
|
||||||
|
|
||||||
if [ "$FAIL" -gt 0 ]; then
|
if [ "$FAIL" -gt 0 ]; then
|
||||||
echo "VERDICT: FAIL — anon key still has access"
|
echo "VERDICT: FAIL"
|
||||||
exit 1
|
exit 1
|
||||||
else
|
else
|
||||||
echo "VERDICT: PASS — anon key fully locked out"
|
echo "VERDICT: PASS — reads/updates blocked, inserts allowed"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
|
|
@ -78,8 +78,8 @@ describe('gstack-telemetry-log', () => {
|
||||||
|
|
||||||
const events = parseJsonl();
|
const events = parseJsonl();
|
||||||
expect(events).toHaveLength(1);
|
expect(events).toHaveLength(1);
|
||||||
// installation_id should be a SHA-256 hash (64 hex chars)
|
// installation_id should be a UUID v4 (or hex fallback)
|
||||||
expect(events[0].installation_id).toMatch(/^[a-f0-9]{64}$/);
|
expect(events[0].installation_id).toMatch(/^[a-f0-9-]{32,36}$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('installation_id is null for anonymous tier', () => {
|
test('installation_id is null for anonymous tier', () => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue