CI Pipeline / lint (push) Failing after 55s
CI Pipeline / canary (push) Successful in 6s
CI Pipeline / best-practices (push) Successful in 5s
CI Pipeline / documentation (push) Successful in 5s
CI Pipeline / gitea-offline (push) Successful in 39s
CI Pipeline / required-checks (push) Skipped
Auto-label / auto-label (pull_request) Successful in 2s
CI Pipeline / lint (pull_request) Failing after 22s
CI Pipeline / canary (pull_request) Successful in 5s
CI Pipeline / best-practices (pull_request) Successful in 5s
CI Pipeline / documentation (pull_request) Successful in 5s
CI Pipeline / gitea-offline (pull_request) Successful in 39s
CI Pipeline / required-checks (pull_request) Skipped
Track branch policy, labels, milestones, and wiki content as declarative state with a read-only-by-default provisioner. Harden off-disk backup publication and add a rootless, networkless restore drill that validates SQLite, application data, and database-declared bare repositories. Run offline contracts in CI, pin audited dependencies, document operations, and bump Rune to 0.43.0.
312 lines
12 KiB
Bash
Executable File
312 lines
12 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
# Docker Registry multi-arch manifest digest for tag 1.27.0-rootless.
|
|
readonly DEFAULT_IMAGE="docker.io/gitea/gitea@sha256:414ba5b2b1163480e9ed4213a989cd798579cfa88582a2359303273009b2b852"
|
|
IMAGE=${GITEA_RESTORE_IMAGE:-$DEFAULT_IMAGE}
|
|
PODMAN_BIN=${PODMAN_BIN:-podman}
|
|
PYTHON_BIN=${PYTHON_BIN:-python3}
|
|
|
|
die() { printf 'Restore verification failed: %s\n' "$*" >&2; exit 1; }
|
|
|
|
[[ ${RUNE_GITEA_RESTORE_VERIFY:-} == 1 ]] || die "set RUNE_GITEA_RESTORE_VERIFY=1 to acknowledge the disposable drill"
|
|
((EUID != 0)) || die "must run rootless (never run this verifier as root)"
|
|
[[ $# -eq 1 ]] || die "usage: $0 ARCHIVE.tar.gz"
|
|
ARCHIVE=$(realpath -- "$1")
|
|
[[ -f "$ARCHIVE" ]] || die "archive does not exist: $ARCHIVE"
|
|
CHECKSUM="$ARCHIVE.sha256"
|
|
[[ -f "$CHECKSUM" ]] || die "checksum sidecar does not exist: $CHECKSUM"
|
|
[[ "$IMAGE" == "$DEFAULT_IMAGE" ]] || die "Gitea restore image must match the audited immutable digest"
|
|
command -v "$PODMAN_BIN" >/dev/null || die "rootless Podman is required"
|
|
GIT_BIN=$(command -v git) || die "git is required"
|
|
command -v "$PYTHON_BIN" >/dev/null || die "Python 3 is required"
|
|
"$PODMAN_BIN" image exists "$IMAGE" || die "required local image is unavailable; refusing registry contact"
|
|
|
|
WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/gitea-restore-verify.XXXXXX")
|
|
CONTAINER_ID=""
|
|
cleanup() {
|
|
local status=$?
|
|
if [[ -n "$CONTAINER_ID" ]]; then
|
|
"$PODMAN_BIN" rm -f "$CONTAINER_ID" >/dev/null 2>&1 || true
|
|
fi
|
|
rm -rf -- "$WORK_DIR"
|
|
exit "$status"
|
|
}
|
|
trap cleanup EXIT INT TERM
|
|
|
|
# Git validation must not inherit object-store or configuration overrides from
|
|
# the caller. An isolated HOME plus an otherwise-empty environment prevents
|
|
# fsck from succeeding via host objects or hostile Git config.
|
|
mkdir -m 0700 "$WORK_DIR/git-home"
|
|
safe_git() {
|
|
env -i PATH="$PATH" HOME="$WORK_DIR/git-home" GIT_CONFIG_NOSYSTEM=1 \
|
|
"$GIT_BIN" "$@"
|
|
}
|
|
|
|
# Parse the sidecar ourselves: sha256sum -c would accept arbitrary filenames
|
|
# from an attacker-controlled sidecar.
|
|
"$PYTHON_BIN" - "$ARCHIVE" "$CHECKSUM" <<'PY' || die "checksum validation failed"
|
|
from pathlib import Path, PurePosixPath
|
|
import hashlib, re, sys
|
|
archive, sidecar = map(Path, sys.argv[1:])
|
|
contents = sidecar.read_text()
|
|
# Accept exactly one record, with either no terminator or one POSIX LF. Do not
|
|
# strip whitespace: that would hide blank/extra records or CRLF ambiguity.
|
|
line = contents[:-1] if contents.endswith("\n") else contents
|
|
if "\n" in line or "\r" in line:
|
|
raise SystemExit("invalid checksum sidecar")
|
|
match = re.fullmatch(r"([0-9a-fA-F]{64}) (.+)", line)
|
|
if not match:
|
|
raise SystemExit("invalid checksum sidecar")
|
|
|
|
# The recorded path is an inert lexical claim. Never open, stat, or resolve it;
|
|
# only compare its final POSIX component with the CLI-supplied archive name.
|
|
recorded = match.group(2)
|
|
if "\\" in recorded:
|
|
raise SystemExit("invalid checksum sidecar")
|
|
if "/" not in recorded:
|
|
valid_name = recorded == archive.name
|
|
else:
|
|
path = PurePosixPath(recorded)
|
|
components = recorded.split("/")
|
|
valid_name = (
|
|
path.is_absolute()
|
|
and not recorded.startswith("//")
|
|
and all(component not in {"", ".", ".."} for component in components[1:])
|
|
and path.name == archive.name
|
|
)
|
|
if not valid_name:
|
|
raise SystemExit("invalid checksum sidecar")
|
|
digest = hashlib.sha256()
|
|
with archive.open("rb") as stream:
|
|
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
actual = digest.hexdigest()
|
|
if actual.lower() != match.group(1).lower():
|
|
raise SystemExit("checksum mismatch")
|
|
PY
|
|
|
|
# Validate every name and link before extracting, then require each top-level
|
|
# Gitea dump member independently. No production path is ever accepted here.
|
|
"$PYTHON_BIN" - "$ARCHIVE" "$WORK_DIR/extracted" <<'PY'
|
|
from pathlib import Path, PurePosixPath
|
|
import sys, tarfile
|
|
archive, destination = Path(sys.argv[1]), Path(sys.argv[2])
|
|
required = {"app.ini", "data", "repos", "gitea-db.sql"}
|
|
with tarfile.open(archive, "r:gz") as source:
|
|
members = source.getmembers()
|
|
present = set()
|
|
for member in members:
|
|
name = member.name.removeprefix("./")
|
|
path = PurePosixPath(name)
|
|
if not name or path.is_absolute() or ".." in path.parts:
|
|
raise SystemExit(f"unsafe archive path: {member.name}")
|
|
if not (member.isfile() or member.isdir()):
|
|
raise SystemExit(f"unsafe archive member type: {member.name}")
|
|
present.add(path.parts[0])
|
|
missing = sorted(required - present)
|
|
if missing:
|
|
raise SystemExit("missing mandatory member: " + ", ".join(missing))
|
|
destination.mkdir(mode=0o700)
|
|
source.extractall(destination)
|
|
PY
|
|
|
|
RESTORE_ROOT="$WORK_DIR/extracted"
|
|
APP_INI="$RESTORE_ROOT/app.ini"
|
|
"$PYTHON_BIN" - "$APP_INI" <<'PY'
|
|
from pathlib import Path
|
|
import configparser
|
|
import sys
|
|
|
|
|
|
def fail(message):
|
|
print(f"Restore verification failed: {message}", file=sys.stderr)
|
|
raise SystemExit(1)
|
|
|
|
|
|
try:
|
|
contents = Path(sys.argv[1]).read_text(encoding="utf-8")
|
|
except (OSError, UnicodeError):
|
|
fail("cannot read app.ini safely")
|
|
|
|
if "\x00" in contents:
|
|
fail("malformed app.ini")
|
|
|
|
# Gitea permits global keys such as WORK_PATH before the first named section.
|
|
# Give those keys an in-memory section so strict stdlib INI parsing can still
|
|
# validate the original bytes. The extracted backup is never rewritten.
|
|
config = configparser.RawConfigParser(interpolation=None, strict=True)
|
|
try:
|
|
config.read_string("[__gitea_top_level__]\n" + contents)
|
|
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as error:
|
|
if error.section.casefold() in {"database", "default"}:
|
|
fail("ambiguous database configuration in app.ini")
|
|
fail("malformed app.ini")
|
|
except configparser.Error:
|
|
fail("malformed app.ini")
|
|
|
|
database_sections = [
|
|
section for section in config.sections() if section.casefold() == "database"
|
|
]
|
|
if len(database_sections) > 1:
|
|
fail("ambiguous database configuration in app.ini")
|
|
if not database_sections:
|
|
fail("missing database section in app.ini")
|
|
|
|
database_section = database_sections[0]
|
|
if "db_type" in config.defaults():
|
|
fail("ambiguous database configuration in app.ini")
|
|
if not config.has_option(database_section, "db_type"):
|
|
fail("missing database DB_TYPE in app.ini")
|
|
|
|
database_type = config.get(database_section, "db_type", raw=True).strip().casefold()
|
|
if database_type not in {"sqlite", "sqlite3"}:
|
|
fail("unsupported database DB_TYPE; only sqlite/sqlite3 are supported")
|
|
PY
|
|
|
|
DB="$WORK_DIR/gitea.db"
|
|
"$PYTHON_BIN" - "$RESTORE_ROOT/gitea-db.sql" "$DB" <<'PY'
|
|
from pathlib import Path
|
|
import sqlite3, sys
|
|
sql, database = Path(sys.argv[1]), Path(sys.argv[2])
|
|
connection = sqlite3.connect(database)
|
|
try:
|
|
denied_actions = {sqlite3.SQLITE_ATTACH, sqlite3.SQLITE_DETACH}
|
|
denied_functions = {"load_extension", "readfile", "writefile"}
|
|
denied_pragmas = {"data_store_directory", "temp_store_directory"}
|
|
|
|
def authorize(action, argument1, argument2, _database_name, _trigger_name):
|
|
if action in denied_actions:
|
|
return sqlite3.SQLITE_DENY
|
|
if action == sqlite3.SQLITE_FUNCTION and (argument2 or "").lower() in denied_functions:
|
|
return sqlite3.SQLITE_DENY
|
|
if action == sqlite3.SQLITE_PRAGMA and (argument1 or "").lower() in denied_pragmas:
|
|
return sqlite3.SQLITE_DENY
|
|
return sqlite3.SQLITE_OK
|
|
|
|
# The dump is untrusted input. Keep SQLite's semantic authorizer active for
|
|
# the entire import so file/extension operations cannot escape WORK_DIR.
|
|
connection.set_authorizer(authorize)
|
|
connection.executescript(sql.read_text())
|
|
connection.set_authorizer(None)
|
|
result = connection.execute("PRAGMA integrity_check").fetchone()[0]
|
|
if result != "ok":
|
|
raise SystemExit(f"SQLite integrity_check returned {result!r}")
|
|
finally:
|
|
connection.close()
|
|
PY
|
|
|
|
# Tie the filesystem inventory back to Gitea's authoritative database rows.
|
|
# Exact schema/column names and safe canonical lower-name components are
|
|
# required; orphaned owners and missing canonical repositories fail closed.
|
|
"$PYTHON_BIN" - "$DB" "$RESTORE_ROOT/repos" <<'PY'
|
|
from pathlib import Path
|
|
import sqlite3, sys
|
|
|
|
database, repos = Path(sys.argv[1]), Path(sys.argv[2])
|
|
connection = sqlite3.connect(database)
|
|
try:
|
|
objects = connection.execute(
|
|
"SELECT name, type FROM sqlite_master WHERE lower(name) IN ('repository', 'user')"
|
|
).fetchall()
|
|
if sorted(objects) != [("repository", "table"), ("user", "table")]:
|
|
raise SystemExit("absent or ambiguous repository ownership schema")
|
|
expected_columns = {
|
|
"repository": {"owner_id", "lower_name"},
|
|
"user": {"id", "lower_name"},
|
|
}
|
|
for table, required in expected_columns.items():
|
|
columns = {row[1] for row in connection.execute(f'PRAGMA table_info("{table}")')}
|
|
if not required <= columns:
|
|
raise SystemExit("absent or ambiguous repository ownership schema")
|
|
rows = connection.execute(
|
|
'SELECT u.lower_name, r.lower_name FROM "repository" AS r '
|
|
'LEFT JOIN "user" AS u ON u.id = r.owner_id'
|
|
).fetchall()
|
|
for owner, name in rows:
|
|
components = (owner, name)
|
|
if any(
|
|
not isinstance(component, str)
|
|
or not component
|
|
or component in {".", ".."}
|
|
or component != component.casefold()
|
|
or "/" in component
|
|
or "\\" in component
|
|
or "\x00" in component
|
|
for component in components
|
|
):
|
|
raise SystemExit("unsafe database repository path component")
|
|
canonical = repos / owner / f"{name}.git"
|
|
if not canonical.is_dir():
|
|
raise SystemExit("database-declared repository is missing")
|
|
except sqlite3.Error:
|
|
raise SystemExit("absent or ambiguous repository ownership schema") from None
|
|
finally:
|
|
connection.close()
|
|
PY
|
|
|
|
repo_count=0
|
|
while IFS= read -r -d '' repo; do
|
|
repo_count=$((repo_count + 1))
|
|
for alternates in "$repo/objects/info/alternates" "$repo/objects/info/http-alternates"; do
|
|
[[ ! -e "$alternates" ]] || die "repository alternates are forbidden: $alternates"
|
|
done
|
|
bare_repository=$(safe_git --git-dir="$repo" rev-parse --is-bare-repository 2>/dev/null) \
|
|
|| die "repository bare-state validation failed"
|
|
[[ "$bare_repository" == true ]] || die "repository is not bare"
|
|
safe_git --git-dir="$repo" fsck --full --strict >/dev/null
|
|
default_ref=$(safe_git --git-dir="$repo" symbolic-ref HEAD) || die "repository has no symbolic default branch: $repo"
|
|
safe_git --git-dir="$repo" show-ref --verify --quiet "$default_ref" || die "repository default branch has no ref: $repo ($default_ref)"
|
|
done < <(find "$RESTORE_ROOT/repos" -type d -name '*.git' -print0)
|
|
((repo_count > 0)) || die "no Git repositories found in backup"
|
|
|
|
INSTALL_ROOT="$WORK_DIR/isolated-gitea"
|
|
mkdir -p "$INSTALL_ROOT/gitea" "$INSTALL_ROOT/git/repositories"
|
|
cp -a -- "$RESTORE_ROOT/data/." "$INSTALL_ROOT/gitea/"
|
|
mkdir -p "$INSTALL_ROOT/gitea/conf"
|
|
cp "$DB" "$INSTALL_ROOT/gitea/gitea.db"
|
|
cp -a "$RESTORE_ROOT/repos/." "$INSTALL_ROOT/git/repositories/"
|
|
cat >"$INSTALL_ROOT/gitea/conf/app.ini" <<'EOF'
|
|
APP_NAME = Rune restore verification
|
|
RUN_MODE = prod
|
|
[server]
|
|
PROTOCOL = http
|
|
HTTP_ADDR = 127.0.0.1
|
|
HTTP_PORT = 3000
|
|
OFFLINE_MODE = true
|
|
[database]
|
|
DB_TYPE = sqlite3
|
|
PATH = /data/gitea/gitea.db
|
|
[repository]
|
|
ROOT = /data/git/repositories
|
|
[security]
|
|
INSTALL_LOCK = true
|
|
EOF
|
|
|
|
# The container has no external network, no published ports, a read-only root,
|
|
# and only the disposable restore tree mounted. Probe over its loopback only.
|
|
CONTAINER_ID=$("$PODMAN_BIN" run --pull=never -d --rm --network none --read-only \
|
|
--userns=keep-id:uid=1000,gid=1000 \
|
|
--tmpfs /tmp:rw,noexec,nosuid,nodev --tmpfs /run:rw,nosuid,nodev \
|
|
-v "$INSTALL_ROOT:/data:Z" "$IMAGE" \
|
|
gitea web --config /data/gitea/conf/app.ini)
|
|
[[ -n "$CONTAINER_ID" ]] || die "isolated Gitea container did not start"
|
|
booted=0
|
|
for _ in $(seq 1 30); do
|
|
if "$PODMAN_BIN" exec "$CONTAINER_ID" wget -q -O /dev/null \
|
|
http://127.0.0.1:3000/api/v1/version >/dev/null 2>&1; then
|
|
booted=1
|
|
break
|
|
fi
|
|
running=$("$PODMAN_BIN" inspect --format '{{.State.Running}}' "$CONTAINER_ID" 2>/dev/null) \
|
|
|| die "isolated Gitea container exited before becoming healthy"
|
|
[[ "$running" == true ]] \
|
|
|| die "isolated Gitea container exited before becoming healthy"
|
|
sleep 1
|
|
done
|
|
((booted)) || die "isolated Gitea did not become healthy"
|
|
"$PODMAN_BIN" rm -f "$CONTAINER_ID" >/dev/null
|
|
CONTAINER_ID=""
|
|
|
|
printf 'Disposable restore verified: checksum, archive, SQLite, %d Git repositories, isolated Gitea boot\n' "$repo_count"
|