diff --git a/README.md b/README.md index 7a321a2..5f6c15d 100644 --- a/README.md +++ b/README.md @@ -719,6 +719,7 @@ the bounded host observer once as root: ```sh scripts/install-brio-runtime-observer.sh \ scripts/brio-runtime-observe.sh \ + scripts/brio-postgres-control-receipt.py \ /secure/operator-path/brio-release-observer.pub ``` @@ -727,9 +728,24 @@ 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. +The root-owned helper opens a fixed read-only local `psql` transaction against +PostgreSQL's system settings and `pg_hba_file_rules`; it never selects an +application table or accepts a caller-selected SQL statement. It requires live +TLS, SCRAM password encryption, the exact ordered Brio application/identity and +backup HBA allows/rejects, and zero HBA parse errors. Credential-free local +`psql` probes must be rejected by the two leading `hostnossl` rules before +authentication. A PostgreSQL SSLRequest then upgrades a connection to +`127.0.0.1` twice and verifies the same live server certificate against the +root-owned CA for both the Brio application alias +`makepad-postgres-brio-staging` and the reviewed identity-host IP. The emitted +`makepad.brio.runtime-controls.v1` receipt contains only normalized settings, +HBA identities, host-network/listener identity, per-path TLS protocols, explicit +verify-full results, and the shared server-certificate SHA-256 fingerprint. It +does not copy the certificate's raw SAN list and never contains a password, +connection credential, database row, private key, or probe error body. The SSH +boundary cannot inspect 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. diff --git a/scripts/brio-postgres-control-receipt.py b/scripts/brio-postgres-control-receipt.py new file mode 100755 index 0000000..0465853 --- /dev/null +++ b/scripts/brio-postgres-control-receipt.py @@ -0,0 +1,344 @@ +#!/usr/bin/env python3 +"""Emit a deterministic, credential-free receipt for live Brio PostgreSQL controls.""" + +from __future__ import annotations + +import hashlib +import ipaddress +import json +import re +import socket +import ssl +import stat +import struct +import subprocess +import sys +from pathlib import Path +from typing import Any + + +DOCKER = "/usr/bin/docker" +CA_CERTIFICATE = Path("/etc/makepad/tls/postgres/ca.crt") +EXPECTED_APPLICATION_ALIAS = "makepad-postgres-brio-staging" +EXPECTED_IDENTITY_ENDPOINT = "65.21.134.125" +EXPECTED_HBA_FILE = "/etc/postgresql/runtrace-pg_hba.conf" +EXPECTED_SSL_CERT_FILE = "/etc/postgresql/tls/server.crt" +EXPECTED_PLAINTEXT_DENIALS = ( + "brio_staging/brio_staging_app", + "keycloak_brio_staging/keycloak_brio_staging_app", +) +MAX_PROCESS_OUTPUT = 2 * 1024 * 1024 +MAX_CA_CERTIFICATE_SIZE = 1024 * 1024 + +EXPECTED_HBA_RULES = ( + ("hostnossl", ("brio_staging",), ("all",), "all", "reject"), + ("hostnossl", ("keycloak_brio_staging",), ("all",), "all", "reject"), + ("hostssl", ("brio_staging",), ("brio_staging_app",), "all", "scram-sha-256"), + ("hostssl", ("brio_staging",), ("brio_staging_backup",), "all", "scram-sha-256"), + ("hostssl", ("keycloak_brio_staging",), ("keycloak_brio_staging_app",), "127.0.0.1/32", "scram-sha-256"), + ("hostssl", ("keycloak_brio_staging",), ("keycloak_brio_staging_app",), "88.99.209.165/32", "scram-sha-256"), + ("hostssl", ("keycloak_brio_staging",), ("keycloak_brio_staging_backup",), "127.0.0.1/32", "scram-sha-256"), + ("host", ("all",), ("brio_staging_app",), "all", "reject"), + ("host", ("all",), ("brio_staging_backup",), "all", "reject"), + ("host", ("all",), ("keycloak_brio_staging_app",), "all", "reject"), + ("host", ("all",), ("keycloak_brio_staging_backup",), "all", "reject"), +) + +SETTINGS_SQL = r""" +BEGIN TRANSACTION READ ONLY; +SELECT json_build_object( + 'ssl', current_setting('ssl'), + 'passwordEncryption', current_setting('password_encryption'), + 'hbaFile', current_setting('hba_file'), + 'sslCertFile', current_setting('ssl_cert_file'), + 'listenAddresses', current_setting('listen_addresses'), + 'port', current_setting('port')::integer, + 'hbaErrors', (SELECT count(*) FROM pg_hba_file_rules WHERE error IS NOT NULL), + 'rules', ( + SELECT coalesce(json_agg(json_build_object( + 'type', type, + 'databases', database, + 'users', user_name, + 'address', address, + 'netmask', netmask, + 'authMethod', auth_method, + 'options', options, + 'error', error + ) ORDER BY line_number), '[]'::json) + FROM pg_hba_file_rules + WHERE database && ARRAY['brio_staging', 'keycloak_brio_staging'] + OR user_name && ARRAY[ + 'brio_staging_app', 'brio_staging_backup', + 'keycloak_brio_staging_app', 'keycloak_brio_staging_backup' + ] + ) +)::text; +COMMIT; +""" + + +class ReceiptError(RuntimeError): + """A fail-closed database observation error without provider output.""" + + +def require(condition: bool, message: str) -> None: + if not condition: + raise ReceiptError(message) + + +def run_bounded(arguments: list[str], *, stdin: bytes = b"", expect_success: bool = True) -> subprocess.CompletedProcess: + try: + result = subprocess.run( + arguments, + input=stdin, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + timeout=20, + env={"PATH": "/usr/bin:/bin", "LANG": "C", "LC_ALL": "C"}, + ) + except (OSError, subprocess.TimeoutExpired) as error: + raise ReceiptError("A fixed local PostgreSQL probe could not complete") from error + require( + len(result.stdout) <= MAX_PROCESS_OUTPUT and len(result.stderr) <= MAX_PROCESS_OUTPUT, + "A fixed local PostgreSQL probe exceeded its output bound", + ) + if expect_success: + require(result.returncode == 0, "A fixed local PostgreSQL probe failed") + return result + + +def observe_settings(container_id: str) -> dict[str, Any]: + result = run_bounded( + [ + DOCKER, + "exec", + "-i", + container_id, + "psql", + "-X", + "-qAt", + "--no-password", + "--set=ON_ERROR_STOP=1", + "--username=postgres", + "--dbname=postgres", + ], + stdin=SETTINGS_SQL.encode("utf-8"), + ) + lines = result.stdout.decode("utf-8", errors="strict").splitlines() + require(len(lines) == 1, "PostgreSQL settings probe returned an invalid envelope") + try: + payload = json.loads(lines[0]) + except json.JSONDecodeError as error: + raise ReceiptError("PostgreSQL settings probe returned invalid JSON") from error + require(isinstance(payload, dict), "PostgreSQL settings probe returned invalid JSON") + return payload + + +def require_plaintext_denied(container_id: str, database: str, role: str) -> None: + connection = f"hostaddr=127.0.0.1 port=5432 dbname={database} user={role} sslmode=disable connect_timeout=5" + result = run_bounded( + [DOCKER, "exec", container_id, "psql", "-X", "-qAt", "--no-password", f"--dbname={connection}", "--command=select 1"], + expect_success=False, + ) + error = result.stderr.decode("utf-8", errors="replace") + require(result.returncode != 0, f"Plaintext PostgreSQL access was accepted for {database}/{role}") + require( + "pg_hba.conf rejects connection" in error + and f'user "{role}"' in error + and f'database "{database}"' in error + and "no encryption" in error, + f"Plaintext PostgreSQL rejection was not enforced by the expected HBA rule for {database}/{role}", + ) + + +def validate_ca_file(path: Path) -> None: + try: + metadata = path.lstat() + except OSError as error: + raise ReceiptError("PostgreSQL observer CA certificate is unavailable") from error + require(stat.S_ISREG(metadata.st_mode), "PostgreSQL observer CA certificate must be a regular file") + require(metadata.st_uid == 0 and stat.S_IMODE(metadata.st_mode) & 0o022 == 0, "PostgreSQL observer CA permissions are unsafe") + require(metadata.st_size <= MAX_CA_CERTIFICATE_SIZE, "PostgreSQL observer CA certificate exceeded its size bound") + + +def read_exact(sock: socket.socket, length: int) -> bytes: + chunks: list[bytes] = [] + remaining = length + while remaining: + chunk = sock.recv(remaining) + require(bool(chunk), "PostgreSQL closed the local TLS probe early") + chunks.append(chunk) + remaining -= len(chunk) + return b"".join(chunks) + + +def observe_certificate_identity(server_name: str) -> tuple[bytes, str]: + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + context.minimum_version = ssl.TLSVersion.TLSv1_2 + context.load_verify_locations(cafile=str(CA_CERTIFICATE)) + context.check_hostname = True + context.verify_mode = ssl.CERT_REQUIRED + try: + with socket.create_connection(("127.0.0.1", 5432), timeout=10) as connection: + connection.sendall(struct.pack("!II", 8, 80877103)) + require(read_exact(connection, 1) == b"S", "PostgreSQL refused the local TLS negotiation") + with context.wrap_socket(connection, server_hostname=server_name) as secured: + der = secured.getpeercert(binary_form=True) + protocol = secured.version() + except (OSError, ssl.SSLError) as error: + raise ReceiptError("PostgreSQL local verify-full certificate probe failed") from error + require(isinstance(der, bytes) and der, "PostgreSQL TLS peer certificate was unavailable") + require(protocol in {"TLSv1.2", "TLSv1.3"}, "PostgreSQL negotiated an unsupported TLS protocol") + return der, protocol + + +def observe_certificate() -> dict[str, Any]: + validate_ca_file(CA_CERTIFICATE) + application_der, application_protocol = observe_certificate_identity(EXPECTED_APPLICATION_ALIAS) + identity_der, identity_protocol = observe_certificate_identity(EXPECTED_IDENTITY_ENDPOINT) + require(application_der == identity_der, "PostgreSQL TLS identities returned different certificates") + return { + "applicationAlias": EXPECTED_APPLICATION_ALIAS, + "applicationAliasVerified": True, + "fingerprintSHA256": f"sha256:{hashlib.sha256(application_der).hexdigest()}", + "identityEndpoint": EXPECTED_IDENTITY_ENDPOINT, + "identityEndpointVerified": True, + "protocols": { + "applicationAlias": application_protocol, + "identityEndpoint": identity_protocol, + }, + "verification": "verify-full", + } + + +def normalized_address(address: Any, netmask: Any) -> str: + require(isinstance(address, str) and address, "Observed HBA address is invalid") + if address == "all": + require(netmask is None, "Observed all-address HBA rule has an unexpected netmask") + return "all" + require(isinstance(netmask, str) and netmask, "Observed HBA netmask is invalid") + try: + return str(ipaddress.ip_network(f"{address}/{netmask}", strict=False)) + except ValueError as error: + raise ReceiptError("Observed HBA address is invalid") from error + + +def normalize_hba_rules(raw_rules: Any) -> list[dict[str, Any]]: + require(isinstance(raw_rules, list), "Observed HBA rules are invalid") + normalized: list[dict[str, Any]] = [] + tuples: list[tuple[Any, ...]] = [] + for raw in raw_rules: + require(isinstance(raw, dict), "Observed HBA rule is invalid") + databases = raw.get("databases") + users = raw.get("users") + require( + isinstance(databases, list) + and databases + and all(isinstance(value, str) for value in databases) + and isinstance(users, list) + and users + and all(isinstance(value, str) for value in users), + "Observed HBA database or role scope is invalid", + ) + require(raw.get("error") is None, "PostgreSQL reported an active HBA parse error") + require(raw.get("options") in (None, []), "Brio HBA rules contain unsupported options") + rule = ( + raw.get("type"), + tuple(databases), + tuple(users), + normalized_address(raw.get("address"), raw.get("netmask")), + raw.get("authMethod"), + ) + tuples.append(rule) + normalized.append( + { + "address": rule[3], + "authMethod": rule[4], + "databases": list(rule[1]), + "type": rule[0], + "users": list(rule[2]), + } + ) + require(tuple(tuples) == EXPECTED_HBA_RULES, "Live Brio PostgreSQL HBA rules drifted") + return normalized + + +def normalize_control_receipt(settings: dict[str, Any], certificate: dict[str, Any]) -> dict[str, Any]: + require(settings.get("ssl") == "on", "Live PostgreSQL TLS setting drifted") + require(settings.get("passwordEncryption") == "scram-sha-256", "Live PostgreSQL password encryption drifted") + require(settings.get("hbaFile") == EXPECTED_HBA_FILE, "Live PostgreSQL HBA source drifted") + require(settings.get("sslCertFile") == EXPECTED_SSL_CERT_FILE, "Live PostgreSQL certificate source drifted") + require(settings.get("listenAddresses") == "*" and settings.get("port") == 5432, "Live PostgreSQL listener identity drifted") + require(settings.get("hbaErrors") == 0, "PostgreSQL reported an HBA parse error") + require( + set(certificate) + == { + "applicationAlias", + "applicationAliasVerified", + "fingerprintSHA256", + "identityEndpoint", + "identityEndpointVerified", + "protocols", + "verification", + }, + "PostgreSQL certificate receipt omitted a reviewed verify-full identity", + ) + protocols = certificate.get("protocols") + require( + certificate.get("applicationAlias") == EXPECTED_APPLICATION_ALIAS + and certificate.get("applicationAliasVerified") is True + and certificate.get("identityEndpoint") == EXPECTED_IDENTITY_ENDPOINT + and certificate.get("identityEndpointVerified") is True + and certificate.get("verification") == "verify-full" + and isinstance(certificate.get("fingerprintSHA256"), str) + and re.fullmatch(r"sha256:[a-f0-9]{64}", certificate["fingerprintSHA256"]) is not None + and isinstance(protocols, dict) + and set(protocols) == {"applicationAlias", "identityEndpoint"} + and protocols.get("applicationAlias") in {"TLSv1.2", "TLSv1.3"} + and protocols.get("identityEndpoint") in {"TLSv1.2", "TLSv1.3"}, + "PostgreSQL certificate receipt did not prove both reviewed verify-full identities", + ) + rules = normalize_hba_rules(settings.get("rules")) + serialized_rules = json.dumps(rules, sort_keys=True, separators=(",", ":")).encode("utf-8") + return { + "controls": { + "certificate": certificate, + "hbaRules": rules, + "networkBoundary": { + "containerNetworkMode": "host", + "hbaFile": EXPECTED_HBA_FILE, + "listenAddresses": ["*"], + "localProbeAddress": "127.0.0.1", + "plaintextRejectedFor": list(EXPECTED_PLAINTEXT_DENIALS), + "port": 5432, + }, + "normalizedRulesSHA256": f"sha256:{hashlib.sha256(serialized_rules).hexdigest()}", + "server": {"passwordEncryption": "scram-sha-256", "ssl": True}, + }, + "hostRole": "database", + "provider": "postgresql", + "schema": "makepad.brio.runtime-controls.v1", + "subject": "brio-databases", + } + + +def main(arguments: list[str]) -> int: + require(len(arguments) == 1 and re.fullmatch(r"[a-f0-9]{64}", arguments[0]) is not None, "Expected one validated PostgreSQL container ID") + container_id = arguments[0] + settings = observe_settings(container_id) + for identity in EXPECTED_PLAINTEXT_DENIALS: + database, role = identity.split("/", 1) + require_plaintext_denied(container_id, database, role) + certificate = observe_certificate() + receipt = normalize_control_receipt(settings, certificate) + print(json.dumps(receipt, sort_keys=True, separators=(",", ":"))) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main(sys.argv[1:])) + except ReceiptError as error: + print(f"Brio PostgreSQL control observation failed: {error}", file=sys.stderr) + raise SystemExit(1) diff --git a/scripts/brio-runtime-observe.sh b/scripts/brio-runtime-observe.sh index ca0dd49..b940034 100755 --- a/scripts/brio-runtime-observe.sh +++ b/scripts/brio-runtime-observe.sh @@ -10,6 +10,7 @@ readonly expected_project=postgres readonly expected_service=postgres readonly expected_image=postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 readonly expected_version=16.14 +readonly control_helper=/usr/local/libexec/makepad/brio-postgres-control-receipt readonly image_id_pattern='^sha256:[a-f0-9]{64}$' die() { @@ -56,15 +57,29 @@ version_output=$(docker_short exec "${container_id}" postgres --version 2>&1) || version=${BASH_REMATCH[1]}${BASH_REMATCH[2]:-} [[ "${version}" == "${expected_version}" ]] || die 'Shared PostgreSQL is not running the reviewed 16.14 runtime.' +[[ -x "${control_helper}" && ! -L "${control_helper}" ]] || die 'Brio PostgreSQL control observer is unavailable.' +[[ "$(stat -c '%U:%G:%a' "${control_helper}")" == root:root:755 ]] || \ + die 'Brio PostgreSQL control observer permissions are unsafe.' +control_receipt=$(timeout --signal=KILL 45s "${control_helper}" "${container_id}") || \ + die 'Cannot observe the live Brio PostgreSQL controls.' +(( ${#control_receipt} <= 32768 )) || die 'Brio PostgreSQL control receipt exceeded its bound.' + python3 - "${image}" "${runtime_image_id}" "${version}" "${container_id}" \ - "${started_at}" "${restart_count}" "${config_hash}" <<'PY' + "${started_at}" "${restart_count}" "${config_hash}" "${control_receipt}" <<'PY' import json import sys -image, image_id, version, container_id, started_at, restart_count, config_hash = sys.argv[1:] +image, image_id, version, container_id, started_at, restart_count, config_hash, raw_receipt = sys.argv[1:] +try: + control_receipt = json.loads(raw_receipt) +except json.JSONDecodeError as error: + raise SystemExit("Brio PostgreSQL control observer returned invalid JSON") from error +if control_receipt.get("schema") != "makepad.brio.runtime-controls.v1": + raise SystemExit("Brio PostgreSQL control observer returned the wrong schema") payload = { "schema": "makepad.brio.runtime-host-observation.v1", "hostRole": "database", + "controlReceipts": [control_receipt], "components": [{ "name": "postgres", "orchestrator": "compose", diff --git a/scripts/install-brio-runtime-observer.sh b/scripts/install-brio-runtime-observer.sh index 0c6f728..fe8f88c 100755 --- a/scripts/install-brio-runtime-observer.sh +++ b/scripts/install-brio-runtime-observer.sh @@ -9,6 +9,7 @@ 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 control_command=/usr/local/libexec/makepad/brio-postgres-control-receipt readonly sudoers_path=/etc/sudoers.d/brio-postgres-runtime-observer die() { @@ -17,15 +18,18 @@ die() { } (( 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' +[[ $# == 3 ]] || die 'usage: install-brio-runtime-observer.sh OBSERVER_SCRIPT CONTROL_HELPER 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 +control_source=$2 +public_key_file=$3 [[ -f "${source_script}" && ! -L "${source_script}" ]] || die 'Observer source must be a regular file.' +[[ -f "${control_source}" && ! -L "${control_source}" ]] || die 'Control-helper 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 < "${control_source}") -le 131072 ]] || die 'Control-helper 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}" @@ -48,11 +52,12 @@ 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}" \ +for controlled_path in "${observer_command}" "${control_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 "${control_command}" || -f "${control_command}" ]] || die 'Control-helper 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.' @@ -60,6 +65,7 @@ done die 'Observer authorized-keys path has an unsafe file type.' install -o root -g root -m 0755 -T "${source_script}" "${observer_command}" +install -o root -g root -m 0755 -T "${control_source}" "${control_command}" install -d -o root -g root -m 0755 "${observer_home}" install -d -o root -g root -m 0700 "${observer_home}/.ssh" @@ -78,12 +84,14 @@ 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' "${control_command}")" == root:root:755 ]] || die 'Control-helper 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 "${control_source}" "${control_command}" || die 'Installed control helper 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 03f113e..9048d1b 100755 --- a/scripts/run-ci.sh +++ b/scripts/run-ci.sh @@ -52,6 +52,8 @@ from pathlib import Path for source in ( "scripts/verify-brio-release-evidence.py", "scripts/verify-keycloak-cohort-evidence.py", + "scripts/brio-postgres-control-receipt.py", + "scripts/test-brio-postgres-control-receipt.py", "scripts/reconcile-github-environment-main-policy.py", "scripts/test-github-environment-main-policy.py", ): diff --git a/scripts/test-brio-postgres-control-receipt.py b/scripts/test-brio-postgres-control-receipt.py new file mode 100755 index 0000000..62dd7a4 --- /dev/null +++ b/scripts/test-brio-postgres-control-receipt.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Contract tests for the Brio PostgreSQL semantic-control receipt.""" + +import importlib.util +import ipaddress +import json +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location( + "brio_postgres_control_receipt", + ROOT / "scripts/brio-postgres-control-receipt.py", +) +assert SPEC is not None and SPEC.loader is not None +receipt_module = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(receipt_module) + + +def raw_rule(rule): + rule_type, databases, users, address, method = rule + if address == "all": + raw_address = "all" + netmask = None + else: + network = ipaddress.ip_network(address) + raw_address = str(network.network_address) + netmask = str(network.netmask) + return { + "type": rule_type, + "databases": list(databases), + "users": list(users), + "address": raw_address, + "netmask": netmask, + "authMethod": method, + "options": None, + "error": None, + } + + +def settings_fixture(): + return { + "ssl": "on", + "passwordEncryption": "scram-sha-256", + "hbaFile": receipt_module.EXPECTED_HBA_FILE, + "sslCertFile": receipt_module.EXPECTED_SSL_CERT_FILE, + "listenAddresses": "*", + "port": 5432, + "hbaErrors": 0, + "rules": [raw_rule(rule) for rule in receipt_module.EXPECTED_HBA_RULES], + "password": "must-never-enter-the-receipt", + } + + +def certificate_fixture(): + return { + "applicationAlias": receipt_module.EXPECTED_APPLICATION_ALIAS, + "applicationAliasVerified": True, + "fingerprintSHA256": f"sha256:{'a' * 64}", + "identityEndpoint": receipt_module.EXPECTED_IDENTITY_ENDPOINT, + "identityEndpointVerified": True, + "protocols": {"applicationAlias": "TLSv1.3", "identityEndpoint": "TLSv1.3"}, + "verification": "verify-full", + } + + +receipt = receipt_module.normalize_control_receipt(settings_fixture(), certificate_fixture()) +assert set(receipt) == {"controls", "hostRole", "provider", "schema", "subject"} +assert receipt["schema"] == "makepad.brio.runtime-controls.v1" +assert receipt["hostRole"] == "database" +assert receipt["provider"] == "postgresql" +assert receipt["subject"] == "brio-databases" +assert receipt["controls"]["server"] == {"passwordEncryption": "scram-sha-256", "ssl": True} +assert receipt["controls"]["networkBoundary"]["containerNetworkMode"] == "host" +assert receipt["controls"]["certificate"]["applicationAliasVerified"] is True +assert receipt["controls"]["certificate"]["identityEndpointVerified"] is True +assert receipt["controls"]["networkBoundary"]["plaintextRejectedFor"] == [ + "brio_staging/brio_staging_app", + "keycloak_brio_staging/keycloak_brio_staging_app", +] +assert len(receipt["controls"]["hbaRules"]) == 11 +assert receipt["controls"]["normalizedRulesSHA256"].startswith("sha256:") +assert "must-never-enter-the-receipt" not in json.dumps(receipt, sort_keys=True) +assert "BEGIN TRANSACTION READ ONLY" in receipt_module.SETTINGS_SQL +assert "pg_hba_file_rules" in receipt_module.SETTINGS_SQL + +ip_only_certificate = { + "fingerprintSHA256": f"sha256:{'a' * 64}", + "protocol": "TLSv1.3", + "subjectAlternativeNames": [f"IP:{receipt_module.EXPECTED_IDENTITY_ENDPOINT}"], + "verifiedHost": receipt_module.EXPECTED_IDENTITY_ENDPOINT, + "verification": "verify-full", +} +try: + receipt_module.normalize_control_receipt(settings_fixture(), ip_only_certificate) +except receipt_module.ReceiptError as error: + assert "identity" in str(error) +else: + raise AssertionError("an IP-only certificate receipt without the Brio application alias was accepted") + +drifted_settings = settings_fixture() +drifted_settings["ssl"] = "off" +try: + receipt_module.normalize_control_receipt(drifted_settings, certificate_fixture()) +except receipt_module.ReceiptError as error: + assert "TLS" in str(error) +else: + raise AssertionError("disabled PostgreSQL TLS was accepted") + +drifted_hba = settings_fixture() +drifted_hba["rules"][0]["authMethod"] = "trust" +try: + receipt_module.normalize_control_receipt(drifted_hba, certificate_fixture()) +except receipt_module.ReceiptError as error: + assert "HBA" in str(error) +else: + raise AssertionError("HBA authentication drift was accepted") + +parse_error = settings_fixture() +parse_error["rules"][2]["error"] = "fixture parse error" +try: + receipt_module.normalize_control_receipt(parse_error, certificate_fixture()) +except receipt_module.ReceiptError as error: + assert "HBA" in str(error) +else: + raise AssertionError("HBA parse error was accepted") + +print("Brio PostgreSQL control receipt contract passed.") diff --git a/scripts/test-brio-runtime-observer.sh b/scripts/test-brio-runtime-observer.sh index bac7e9d..94b70ef 100755 --- a/scripts/test-brio-runtime-observer.sh +++ b/scripts/test-brio-runtime-observer.sh @@ -4,11 +4,15 @@ 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 +control_helper=${repo_root}/scripts/brio-postgres-control-receipt.py -for path in "${observer}" "${installer}"; do +for path in "${observer}" "${installer}" "${control_helper}"; do [[ -f "${path}" && ! -L "${path}" ]] || { echo "missing runtime observer artifact: ${path}" >&2; exit 1; } - bash -n "${path}" done +bash -n "${observer}" +bash -n "${installer}" +python3 -c 'import ast, pathlib, sys; ast.parse(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"), filename=sys.argv[1])' "${control_helper}" +PYTHONDONTWRITEBYTECODE=1 python3 "${repo_root}/scripts/test-brio-postgres-control-receipt.py" for marker in \ 'export PATH=/usr/bin:/bin' \ @@ -24,6 +28,10 @@ for marker in \ 'runtimeImageID' \ 'postgres --version' \ 'readonly expected_version=16.14' \ + "timeout --signal=KILL 45s \"\${control_helper}\"" \ + 'brio-postgres-control-receipt' \ + 'controlReceipts' \ + 'makepad.brio.runtime-controls.v1' \ 'makepad.brio.runtime-host-observation.v1'; do grep -Fq -- "${marker}" "${observer}" || { echo "observer is missing ${marker}" >&2; exit 1; } done @@ -47,8 +55,27 @@ for marker in \ 'passwd --lock' \ 'passwd --status' \ 'root:root:700' \ - 'cmp -s'; do + 'cmp -s' \ + 'readonly control_command=/usr/local/libexec/makepad/brio-postgres-control-receipt' \ + 'Control-helper source is unexpectedly large' \ + 'Installed control helper differs from the reviewed source'; do grep -Fq -- "${marker}" "${installer}" || { echo "installer is missing ${marker}" >&2; exit 1; } done +for marker in \ + 'BEGIN TRANSACTION READ ONLY' \ + 'pg_hba_file_rules' \ + 'ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)' \ + 'server_hostname=server_name' \ + 'EXPECTED_APPLICATION_ALIAS = "makepad-postgres-brio-staging"' \ + 'applicationAliasVerified' \ + 'identityEndpointVerified' \ + 'sslmode=disable' \ + 'makepad.brio.runtime-controls.v1'; do + grep -Fq -- "${marker}" "${control_helper}" || { echo "control helper is missing ${marker}" >&2; exit 1; } +done +for forbidden in 'PGPASSWORD' 'POSTGRES_PASSWORD' 'Config.Env' 'Mounts' 'docker logs'; do + ! grep -Fq -- "${forbidden}" "${control_helper}" || { echo "control helper contains forbidden credential/runtime access: ${forbidden}" >&2; exit 1; } +done + echo 'Brio PostgreSQL runtime observer contract passed.'