diff --git a/README.md b/README.md index d27550c..7a321a2 100644 --- a/README.md +++ b/README.md @@ -711,6 +711,26 @@ Brio staging uses only its isolated encrypted overlay and certificate-matching a postgres://brio_staging_app:@makepad-postgres-brio-staging:5432/brio_staging?sslmode=verify-full&sslrootcert=/etc/brio/postgres/ca.crt ``` +Brio release evidence observes the shared database runtime without receiving a +database or deployment credential. After deriving the public half of Brio's +dedicated release-observer SSH key from its canonical Proton Pass item, install +the bounded host observer once as root: + +```sh +scripts/install-brio-runtime-observer.sh \ + scripts/brio-runtime-observe.sh \ + /secure/operator-path/brio-release-observer.pub +``` + +The installer creates a locked `brio-runtime-observer` account whose key is +bound with OpenSSH `restrict` to one root-owned command. The command accepts +only `shared-runtime-observe`; it verifies the exact healthy standalone +`postgres/postgres` Compose unit, binds the running image content to its +immutable reference, and returns bounded image, version, and lifecycle JSON. +It cannot read database data, container environment or mounts, run arbitrary +Docker commands, or mutate the host. Never install a deployment key for this +account or mirror the observer private key to this repository. + If production overrides `DEPLOY_VIF_DB_NAME` or `DEPLOY_VIF_DB_USER`, use those values in the connection URI. ## Validation @@ -725,6 +745,7 @@ Run the static deployment checks and the disposable PostgreSQL 16 bootstrap test ./scripts/test-brio-deploy-guards.sh ./scripts/test-brio-deployment-contracts.sh ./scripts/test-brio-deployment-failures.sh +./scripts/test-brio-runtime-observer.sh ``` Run the local static checks before opening a deployment PR: @@ -738,4 +759,5 @@ bash scripts/test-brio-encrypted-restore.sh bash scripts/test-brio-deploy-guards.sh bash scripts/test-brio-deployment-contracts.sh bash scripts/test-brio-deployment-failures.sh +bash scripts/test-brio-runtime-observer.sh ``` diff --git a/scripts/brio-runtime-observe.sh b/scripts/brio-runtime-observe.sh new file mode 100755 index 0000000..ca0dd49 --- /dev/null +++ b/scripts/brio-runtime-observe.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +export PATH=/usr/bin:/bin +umask 077 + +readonly expected_command=shared-runtime-observe +readonly expected_container=postgres-postgres-1 +readonly expected_project=postgres +readonly expected_service=postgres +readonly expected_image=postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 +readonly expected_version=16.14 +readonly image_id_pattern='^sha256:[a-f0-9]{64}$' + +die() { + printf '%s\n' "$1" >&2 + exit 1 +} + +docker_short() { + timeout --signal=KILL 20s docker "$@" +} + +(( EUID == 0 )) || die 'The Brio PostgreSQL runtime observer must run through its exact passwordless sudo rule.' +[[ "${SSH_ORIGINAL_COMMAND:-}" == "${expected_command}" ]] || \ + die 'The Brio PostgreSQL runtime observer accepts only shared-runtime-observe.' + +record=$(docker_short inspect --type container --format \ + '{{.Id}}|{{.Name}}|{{.Config.Image}}|{{.Image}}|{{.State.Status}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}missing{{end}}|{{.State.StartedAt}}|{{.RestartCount}}|{{index .Config.Labels "com.docker.compose.project"}}|{{index .Config.Labels "com.docker.compose.service"}}|{{index .Config.Labels "com.docker.compose.oneoff"}}|{{index .Config.Labels "com.docker.compose.config-hash"}}|{{.HostConfig.NetworkMode}}' \ + "${expected_container}") || die 'Cannot inspect the shared PostgreSQL container.' +IFS='|' read -r container_id container_name image runtime_image_id state health started_at restart_count \ + compose_project compose_service compose_oneoff config_hash network_mode <<< "${record}" + +[[ "${container_id}" =~ ^[a-f0-9]{64}$ && "${container_name}" == "/${expected_container}" ]] || \ + die 'Shared PostgreSQL returned an invalid container identity.' +[[ "${image}" == "${expected_image}" && "${runtime_image_id}" =~ ${image_id_pattern} ]] || \ + die 'Shared PostgreSQL is not running an immutable reviewed image reference.' +[[ "${state}" == running && "${health}" == healthy ]] || die 'Shared PostgreSQL is not running and healthy.' +[[ "${compose_project}" == "${expected_project}" && "${compose_service}" == "${expected_service}" \ + && "${compose_oneoff}" == False && "${network_mode}" == host ]] || \ + die 'Shared PostgreSQL has unexpected Compose identity or network mode.' +[[ "${config_hash}" =~ ^[a-f0-9]{64}$ ]] || die 'Shared PostgreSQL has an invalid Compose configuration digest.' +[[ "${started_at}" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?Z$ && \ + "${restart_count}" =~ ^[0-9]+$ ]] || \ + die 'Shared PostgreSQL returned invalid lifecycle identity.' + +resolved_image_id=$(docker_short image inspect "${image}" --format '{{.Id}}') || \ + die 'Cannot resolve the shared PostgreSQL immutable image reference.' +[[ "${resolved_image_id}" == "${runtime_image_id}" ]] || \ + die 'Shared PostgreSQL container content does not match its immutable image reference.' +version_output=$(docker_short exec "${container_id}" postgres --version 2>&1) || \ + die 'Cannot read the running PostgreSQL version.' +(( ${#version_output} <= 128 )) || die 'Shared PostgreSQL returned an oversized runtime version.' +[[ "${version_output}" =~ ^postgres\ \(PostgreSQL\)\ (16\.[0-9]+)(\.[0-9]+)?$ ]] || \ + die 'Shared PostgreSQL returned an unexpected runtime version.' +version=${BASH_REMATCH[1]}${BASH_REMATCH[2]:-} +[[ "${version}" == "${expected_version}" ]] || die 'Shared PostgreSQL is not running the reviewed 16.14 runtime.' + +python3 - "${image}" "${runtime_image_id}" "${version}" "${container_id}" \ + "${started_at}" "${restart_count}" "${config_hash}" <<'PY' +import json +import sys + +image, image_id, version, container_id, started_at, restart_count, config_hash = sys.argv[1:] +payload = { + "schema": "makepad.brio.runtime-host-observation.v1", + "hostRole": "database", + "components": [{ + "name": "postgres", + "orchestrator": "compose", + "unit": "postgres/postgres", + "image": image, + "runtimeImageID": image_id, + "configDigest": f"sha256:{config_hash}", + "version": version, + "state": "running", + "health": "healthy", + "instanceID": f"sha256:{container_id}", + "startedAt": started_at, + "restartCount": int(restart_count), + }], +} +print(json.dumps(payload, sort_keys=True, separators=(",", ":"))) +PY diff --git a/scripts/install-brio-runtime-observer.sh b/scripts/install-brio-runtime-observer.sh new file mode 100755 index 0000000..0c6f728 --- /dev/null +++ b/scripts/install-brio-runtime-observer.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +export PATH=/usr/sbin:/usr/bin:/sbin:/bin +export LANG=C +export LC_ALL=C +umask 077 + +readonly observer_user=brio-runtime-observer +readonly observer_home=/var/lib/brio-runtime-observer +readonly observer_command=/usr/local/libexec/makepad/brio-postgres-runtime-observe +readonly sudoers_path=/etc/sudoers.d/brio-postgres-runtime-observer + +die() { + printf '%s\n' "$1" >&2 + exit 1 +} + +(( EUID == 0 )) || die 'Run this installer as root on the PostgreSQL host.' +[[ $# == 2 ]] || die 'usage: install-brio-runtime-observer.sh OBSERVER_SCRIPT ED25519_PUBLIC_KEY_FILE' +for command_name in cmp docker getent id install mktemp passwd python3 ssh-keygen stat timeout useradd visudo wc; do + command -v "${command_name}" >/dev/null || die "Missing required installer command: ${command_name}." +done +source_script=$1 +public_key_file=$2 +[[ -f "${source_script}" && ! -L "${source_script}" ]] || die 'Observer source must be a regular file.' +[[ -f "${public_key_file}" && ! -L "${public_key_file}" ]] || die 'Observer public key must be a regular file.' +[[ $(wc -c < "${source_script}") -le 65536 ]] || die 'Observer source is unexpectedly large.' +[[ $(wc -c < "${public_key_file}") -le 1024 ]] || die 'Observer public key file is unexpectedly large.' + +read -r key_type key_body key_extra < "${public_key_file}" +[[ "${key_type}" == ssh-ed25519 && "${key_body}" =~ ^[A-Za-z0-9+/]+={0,3}$ && -z "${key_extra:-}" ]] || \ + die 'Expected exactly one OpenSSH Ed25519 public key without trailing fields.' +[[ $(wc -l < "${public_key_file}") -eq 1 ]] || die 'Observer public key file must contain exactly one line.' +ssh-keygen -l -f "${public_key_file}" >/dev/null || die 'Observer public key is not valid OpenSSH key material.' + +if ! id "${observer_user}" >/dev/null 2>&1; then + useradd --system --user-group --create-home --home-dir "${observer_home}" --shell /bin/bash "${observer_user}" +fi +account_record=$(getent passwd "${observer_user}") || die 'Observer account could not be read.' +IFS=: read -r account_name _ account_uid _ _ account_home account_shell <<< "${account_record}" +[[ "${account_name}" == "${observer_user}" && "${account_uid}" =~ ^[1-9][0-9]*$ && \ + "${account_uid}" -lt 1000 && "${account_home}" == "${observer_home}" && \ + "${account_shell}" == /bin/bash && "$(id -gn "${observer_user}")" == "${observer_user}" && \ + "$(id -nG "${observer_user}")" == "${observer_user}" ]] || die 'Observer account identity is unsafe.' +passwd --lock "${observer_user}" >/dev/null +read -r _ password_state _ <<< "$(passwd --status "${observer_user}")" +[[ "${password_state}" == L ]] || die 'Observer account password is not locked.' + +install -d -o root -g root -m 0755 /usr/local/libexec /usr/local/libexec/makepad +for controlled_path in "${observer_command}" "${sudoers_path}" "${observer_home}" \ + "${observer_home}/.ssh" "${observer_home}/.ssh/authorized_keys"; do + [[ ! -L "${controlled_path}" ]] || die "Refusing symbolic link at managed path: ${controlled_path}." +done +[[ ! -e "${observer_command}" || -f "${observer_command}" ]] || die 'Observer command path has an unsafe file type.' +[[ ! -e "${sudoers_path}" || -f "${sudoers_path}" ]] || die 'Observer sudo rule path has an unsafe file type.' +[[ ! -e "${observer_home}" || -d "${observer_home}" ]] || die 'Observer home has an unsafe file type.' +[[ ! -e "${observer_home}/.ssh" || -d "${observer_home}/.ssh" ]] || die 'Observer SSH path has an unsafe file type.' +[[ ! -e "${observer_home}/.ssh/authorized_keys" || -f "${observer_home}/.ssh/authorized_keys" ]] || \ + die 'Observer authorized-keys path has an unsafe file type.' + +install -o root -g root -m 0755 -T "${source_script}" "${observer_command}" +install -d -o root -g root -m 0755 "${observer_home}" +install -d -o root -g root -m 0700 "${observer_home}/.ssh" + +authorized_keys=$(mktemp) +sudoers_candidate=$(mktemp) +trap 'rm -f -- "${authorized_keys}" "${sudoers_candidate}"' EXIT +printf 'restrict,command="/usr/bin/sudo -n %s" %s %s\n' \ + "${observer_command}" "${key_type}" "${key_body}" > "${authorized_keys}" +install -o root -g root -m 0600 -T \ + "${authorized_keys}" "${observer_home}/.ssh/authorized_keys" + +printf 'Defaults!%s env_keep += "SSH_ORIGINAL_COMMAND"\n' "${observer_command}" > "${sudoers_candidate}" +printf '%s ALL=(root) NOPASSWD: %s\n' "${observer_user}" "${observer_command}" >> "${sudoers_candidate}" +chmod 0440 "${sudoers_candidate}" +visudo -cf "${sudoers_candidate}" >/dev/null +install -o root -g root -m 0440 -T "${sudoers_candidate}" "${sudoers_path}" + +[[ "$(stat -c '%U:%G:%a' "${observer_command}")" == root:root:755 ]] || die 'Observer command permissions are unsafe.' +[[ "$(stat -c '%U:%G:%a' "${sudoers_path}")" == root:root:440 ]] || die 'Observer sudo rule permissions are unsafe.' +[[ "$(stat -c '%U:%G:%a' "${observer_home}")" == root:root:755 ]] || die 'Observer home permissions are unsafe.' +[[ "$(stat -c '%U:%G:%a' "${observer_home}/.ssh")" == root:root:700 ]] || die 'Observer SSH directory permissions are unsafe.' +[[ "$(stat -c '%U:%G:%a' "${observer_home}/.ssh/authorized_keys")" == \ + root:root:600 ]] || die 'Observer authorized_keys permissions are unsafe.' +cmp -s "${source_script}" "${observer_command}" || die 'Installed observer differs from the reviewed source.' +cmp -s "${authorized_keys}" "${observer_home}/.ssh/authorized_keys" || die 'Installed authorized key differs from the reviewed candidate.' +cmp -s "${sudoers_candidate}" "${sudoers_path}" || die 'Installed sudo rule differs from the reviewed candidate.' +visudo -cf "${sudoers_path}" >/dev/null || die 'Installed observer sudo rule is invalid.' diff --git a/scripts/run-ci.sh b/scripts/run-ci.sh index a5f6788..03f113e 100755 --- a/scripts/run-ci.sh +++ b/scripts/run-ci.sh @@ -19,6 +19,8 @@ shellcheck_paths=( \ scripts/install-keycloak-cohort-cleaner.sh \ scripts/keycloak-cohort-capture-dispatch.sh \ scripts/install-keycloak-cohort-capture-host.sh \ + scripts/brio-runtime-observe.sh \ + scripts/install-brio-runtime-observer.sh \ scripts/verify-brio-encrypted-restore.sh \ scripts/test-brio-bootstrap.sh \ scripts/test-brio-db-transaction.sh \ @@ -28,6 +30,7 @@ shellcheck_paths=( \ scripts/test-brio-deployment-contracts.sh \ scripts/test-brio-deployment-failures.sh \ scripts/test-brio-release-evidence.sh \ + scripts/test-brio-runtime-observer.sh \ scripts/test-keycloak-cohort-evidence.sh \ scripts/test-keycloak-cohort-hardening.sh \ scripts/capture-keycloak-cohort-backups.sh \ @@ -62,6 +65,7 @@ git diff --check ./scripts/test-brio-deployment-contracts.sh ./scripts/test-brio-deployment-failures.sh ./scripts/test-brio-release-evidence.sh +./scripts/test-brio-runtime-observer.sh ./scripts/test-keycloak-cohort-evidence.sh ./scripts/test-keycloak-cohort-hardening.sh ./scripts/test-brio-bootstrap.sh diff --git a/scripts/test-brio-runtime-observer.sh b/scripts/test-brio-runtime-observer.sh new file mode 100755 index 0000000..bac7e9d --- /dev/null +++ b/scripts/test-brio-runtime-observer.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +observer=${repo_root}/scripts/brio-runtime-observe.sh +installer=${repo_root}/scripts/install-brio-runtime-observer.sh + +for path in "${observer}" "${installer}"; do + [[ -f "${path}" && ! -L "${path}" ]] || { echo "missing runtime observer artifact: ${path}" >&2; exit 1; } + bash -n "${path}" +done + +for marker in \ + 'export PATH=/usr/bin:/bin' \ + 'SSH_ORIGINAL_COMMAND:-' \ + 'shared-runtime-observe' \ + 'timeout --signal=KILL 20s docker' \ + 'readonly expected_container=postgres-postgres-1' \ + 'postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777' \ + 'com.docker.compose.project' \ + 'com.docker.compose.service' \ + 'com.docker.compose.config-hash' \ + 'configDigest' \ + 'runtimeImageID' \ + 'postgres --version' \ + 'readonly expected_version=16.14' \ + 'makepad.brio.runtime-host-observation.v1'; do + grep -Fq -- "${marker}" "${observer}" || { echo "observer is missing ${marker}" >&2; exit 1; } +done + +for forbidden in 'Config.Env' 'Mounts' 'docker logs'; do + ! grep -Fq -- "${forbidden}" "${observer}" || { echo "observer contains forbidden inspection: ${forbidden}" >&2; exit 1; } +done + +for marker in \ + 'readonly observer_user=brio-runtime-observer' \ + 'export PATH=/usr/sbin:/usr/bin:/sbin:/bin' \ + 'useradd --system --user-group' \ + 'getent passwd' \ + 'id -nG' \ + 'ssh-keygen -l -f' \ + 'Refusing symbolic link at managed path' \ + 'restrict,command="/usr/bin/sudo -n %s"' \ + 'env_keep += "SSH_ORIGINAL_COMMAND"' \ + 'NOPASSWD:' \ + 'visudo -cf' \ + 'passwd --lock' \ + 'passwd --status' \ + 'root:root:700' \ + 'cmp -s'; do + grep -Fq -- "${marker}" "${installer}" || { echo "installer is missing ${marker}" >&2; exit 1; } +done + +echo 'Brio PostgreSQL runtime observer contract passed.'