Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,21 @@ jobs:
- name: Run clippy
run: cargo clippy --no-default-features --features pg${{ matrix.pg_version }},http-allow-test-domains -- -D warnings

# pgspot SQL security gate (pg17 only; the install SQL is the shipped DDL).
# http-allow-test-domains is a test-only feature that emits no extra DDL, so
# the scanned surface matches the shipped build while reusing this job's build.
- name: Generate extension install SQL
if: matrix.pg_version == 17
run: |
cargo pgrx schema pg${{ matrix.pg_version }} \
--no-default-features \
--features pg${{ matrix.pg_version }},http-allow-test-domains \
-o /tmp/pg_durable-install.sql

- name: Run pgspot SQL security gate
if: matrix.pg_version == 17
run: scripts/pgspot-gate.sh /tmp/pg_durable-install.sql

# Include an http feature to allow http unit tests to run. No time for multiple feature combos.
- name: Run unit tests
run: cargo pgrx test pg${{ matrix.pg_version }} --features http-allow-test-domains
Expand Down
14 changes: 12 additions & 2 deletions docs/upgrade-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,9 @@ Each PR that changes the extension schema or modifies SQL queries in Rust code s

1. Add the necessary DDL to the upgrade script (`sql/pg_durable--<prev>--<current>.sql`)
2. Ensure the `.so` is backward compatible with **all** previous schemas in the same provider compatibility line (Scenario B1)
3. Add version-specific notes to this document under "Version-Specific Changes" below
4. Pass upgrade tests in CI
3. Keep all new DDL — in the Rust install SQL *and* in any new upgrade script — schema-qualified so it passes the pgspot SQL security gate (`scripts/pgspot-gate.sh`): qualify operators as `OPERATOR(pg_catalog.<op>)`, functions/types/objects by schema (e.g. `pg_catalog.now()`), and qualify references inside anonymous `DO` blocks (they run under the session search_path). New upgrade scripts are gated automatically.
4. Add version-specific notes to this document under "Version-Specific Changes" below
5. Pass upgrade and pgspot tests in CI

### Future work

Expand All @@ -185,6 +186,15 @@ No additional fixture is needed for subsequent minors — intermediate versions

`cargo pgrx package` generates the new major's install SQL. The previous major's install SQL and upgrade scripts are still needed for the A/B2 upgrade chain when the provider line continues across the major bump. B1 will be a no-op if there are no previous compatible versions within the new major, or if `PROVIDER_COMPAT_START_VERSION` marks the new major as the start of a new provider line.

### Upgrade scripts and the pgspot gate

The pgspot gate scans every upgrade script matching `*--*--*.sql`, except a small
hardcoded list of pre-pgspot legacy scripts in `scripts/pgspot-gate.sh` (authored
before the install DDL was schema-qualified, and immutable now that they're
released). Every new upgrade script is gated and must pass — keep its DDL
schema-qualified (see step 3 above). Scripts written after qualification pass the
gate, so they never need to be added to the exclude list.

---

## Version-Specific Changes
Expand Down
72 changes: 72 additions & 0 deletions scripts/pgspot-gate.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
#!/bin/bash
# Copyright (c) Microsoft Corporation.
# Licensed under the PostgreSQL License.

# pgspot-gate.sh - Project entry point for the pgspot SQL security gate.
#
# Scans the generated install SQL plus every active upgrade script, except the
# pre-pgspot legacy scripts listed below. New scripts are gated automatically.
#
# Usage: scripts/pgspot-gate.sh [INSTALL_SQL]
# INSTALL_SQL install SQL to scan. Optional (omit when no local pgrx install);
# CI always generates and passes it. Without it, only upgrade
# scripts are scanned.

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
SQL_DIR="$PROJECT_DIR/sql"

install_sql="${1:-}"

# Released upgrade scripts authored before the install DDL was schema-qualified.
# They are immutable and don't all pass pgspot, so they're excluded. Scripts
# written after qualification pass the gate, so they need no entry here.
EXCLUDE=(
pg_durable--0.1.1--0.2.0.sql
pg_durable--0.2.0--0.2.1.sql
pg_durable--0.2.1--0.2.2.sql
)

is_excluded() {
local base="$1" e
for e in "${EXCLUDE[@]}"; do
[[ "$base" == "$e" ]] && return 0
done
return 1
}

# Upgrade scripts (basename `*--*--*.sql`; the single-`--` first-version fixture
# never matches), minus the excluded legacy ones.
targets=()
shopt -s nullglob
for f in "$SQL_DIR"/pg_durable--*--*.sql; do
base="$(basename "$f")"
if is_excluded "$base"; then
echo "excluded (pre-pgspot): $base"
continue
fi
targets+=("$f")
done
shopt -u nullglob

scan=()
if [[ -n "$install_sql" ]]; then
if [[ ! -f "$install_sql" ]]; then
echo "ERROR: install SQL not found: $install_sql" >&2
exit 2
fi
scan+=("$install_sql")
else
echo "NOTE: no install SQL provided; scanning upgrade scripts only." >&2
fi
scan+=("${targets[@]}")

if [[ ${#scan[@]} -eq 0 ]]; then
echo "ERROR: nothing to scan (no install SQL and no gated upgrade scripts)." >&2
echo " CI must pass the generated install SQL; an empty scan set fails the gate." >&2
exit 2
fi

exec "$SCRIPT_DIR/run-pgspot.sh" "${scan[@]}"
168 changes: 168 additions & 0 deletions scripts/run-pgspot.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
#!/bin/bash
# Copyright (c) Microsoft Corporation.
# Licensed under the PostgreSQL License.

# run-pgspot.sh - Lint shipped extension SQL with pgspot.
#
# Scans the SQL pg_durable ships (generated install SQL + active upgrade scripts)
# for search_path / privilege-escalation issues (CVE-2018-1058).
#
# A file passes only if every pgspot finding is on the per-finding allowlist
# (PGSPOT_ALLOW); scan_file is fail-closed via two passes (see its comment).
#
# Usage: scripts/run-pgspot.sh FILE [FILE ...] (globs expanded by caller)
#
# Env:
# PGSPOT_VERSION pgspot version to pin (default: 0.9.2)
# PGSPOT_VENV venv dir to install/reuse (default: a cache dir)
# PGSPOT_BIN existing pgspot executable (skips venv setup)

set -euo pipefail

PGSPOT_VERSION="${PGSPOT_VERSION:-0.9.2}"
PGSPOT_VENV="${PGSPOT_VENV:-${XDG_CACHE_HOME:-$HOME/.cache}/pg_durable/pgspot-venv}"

# --- Finding allowlist -----------------------------------------------------
# pgspot prints one line per finding: "PSxxx: <title>: <context> at line N". We
# allow findings by exact match, not by suppressing whole codes (--ignore), so a
# future unsafe instance of the same code still fails. Anything unmatched -- plus
# unknowns, fatals, and unexplained non-zero exits -- fails the gate.
PGSPOT_ALLOW=(
# pgrx emits `CREATE SCHEMA IF NOT EXISTS df` from #[pg_schema]; the IF NOT
# EXISTS (what PS010 flags) isn't controllable from source. Only df is allowed;
# any other PS010 still fails. Schemas we control omit IF NOT EXISTS.
'^PS010: Unsafe schema creation: df at line [0-9]+$'
)

# Whole codes to suppress globally (pgspot --ignore). Prefer PGSPOT_ALLOW. Empty.
PGSPOT_IGNORE=()

# ---------------------------------------------------------------------------

err() { printf '%s\n' "$*" >&2; }

# Build the two --ignore sets: GLOBAL (PGSPOT_IGNORE only, used in pass A) and
# ALLOW (GLOBAL + the allowlisted codes, used in pass B).
IGNORE_GLOBAL_ARGS=()
IGNORE_ALLOW_ARGS=()
build_ignore_args() {
local code re
for code in "${PGSPOT_IGNORE[@]:-}"; do
[[ -z "$code" ]] && continue
IGNORE_GLOBAL_ARGS+=(--ignore "$code")
IGNORE_ALLOW_ARGS+=(--ignore "$code")
done
for re in "${PGSPOT_ALLOW[@]:-}"; do
if [[ "$re" =~ (PS[0-9]+) ]]; then
IGNORE_ALLOW_ARGS+=(--ignore "${BASH_REMATCH[1]}")
fi
done
}

# scan_file FILE -- fail-closed pass/fail against PGSPOT_ALLOW via two passes:
# Pass A: print all findings; every "PSxxx:" line must match the allowlist.
# Catches a disallowed instance of an allowlisted code (e.g. PS010 for a
# non-df schema), which pass B's per-code --ignore would otherwise hide.
# Pass B: ignore the allowlisted codes; the file must exit fully clean. Catches
# unknowns, parse fatals, and findings pgspot reports only via exit code.
# FILE passes iff pass A has no disallowed line AND pass B exits clean.
scan_file() {
local file="$1"

local outA
# Pass A uses the printed findings, not the exit code; `|| true` keeps set -e
# from aborting when pgspot exits non-zero on a finding.
outA="$("$PGSPOT" "${IGNORE_GLOBAL_ARGS[@]}" "$file" 2>&1)" || true
printf '%s\n' "$outA"

local disallowed=0 line re ok
while IFS= read -r line; do
[[ "$line" =~ ^PS[0-9]+:\ ]] || continue
ok=0
for re in "${PGSPOT_ALLOW[@]}"; do
[[ -z "$re" ]] && continue
if [[ "$line" =~ $re ]]; then ok=1; break; fi
done
if [[ $ok -eq 0 ]]; then
disallowed=$((disallowed + 1))
err " disallowed finding: $line"
fi
done <<< "$outA"

local rcB=0
"$PGSPOT" "${IGNORE_ALLOW_ARGS[@]}" "$file" >/dev/null 2>&1 || rcB=$?

if [[ $disallowed -gt 0 ]]; then
return 1
fi
if [[ $rcB -ne 0 ]]; then
err " pgspot reports residual findings after ignoring allowlisted codes (unknown/fatal/non-allowlisted); exit $rcB"
return 1
fi
return 0
}

resolve_pgspot() {
if [[ -n "${PGSPOT_BIN:-}" ]]; then
if "$PGSPOT_BIN" --version 2>/dev/null | grep -q "pgspot ${PGSPOT_VERSION}"; then
PGSPOT="$PGSPOT_BIN"
return
fi
err "PGSPOT_BIN=$PGSPOT_BIN is not pgspot ${PGSPOT_VERSION}"
exit 2
fi

local venv_bin="$PGSPOT_VENV/bin/pgspot"
if [[ -x "$venv_bin" ]] && "$venv_bin" --version 2>/dev/null | grep -q "pgspot ${PGSPOT_VERSION}"; then
PGSPOT="$venv_bin"
return
fi

err "Installing pgspot ${PGSPOT_VERSION} into ${PGSPOT_VENV} ..."
python3 -m venv "$PGSPOT_VENV"
"$PGSPOT_VENV/bin/pip" install --quiet --upgrade pip
"$PGSPOT_VENV/bin/pip" install --quiet "pgspot==${PGSPOT_VERSION}"
PGSPOT="$venv_bin"
}

main() {
if [[ $# -eq 0 ]]; then
err "usage: $0 FILE [FILE ...]"
exit 2
fi

resolve_pgspot
build_ignore_args

local failed=0
local checked=0
local file
for file in "$@"; do
if [[ ! -f "$file" ]]; then
err "skip (not found): $file"
continue
fi
checked=$((checked + 1))
printf '\n=== pgspot: %s ===\n' "$file"
if scan_file "$file"; then
printf 'OK: %s\n' "$file"
else
err "FAIL: $file"
failed=$((failed + 1))
fi
done

if [[ $checked -eq 0 ]]; then
err "ERROR: no files were checked"
exit 2
fi

printf '\n--- pgspot summary: %d file(s) checked, %d failed ---\n' "$checked" "$failed"
if [[ $failed -ne 0 ]]; then
err "pgspot gate FAILED ($failed file(s) with findings)"
exit 1
fi
printf 'pgspot gate PASSED\n'
}

main "$@"
Loading
Loading