diff --git a/.agents/skills/review-opensecret-security/SKILL.md b/.agents/skills/review-opensecret-security/SKILL.md index 16c54ef8..2428d0b3 100644 --- a/.agents/skills/review-opensecret-security/SKILL.md +++ b/.agents/skills/review-opensecret-security/SKILL.md @@ -133,7 +133,12 @@ database, provider, client, build/artifact, and live evidence separately. Local artifact builds and read-only PCR comparison are validation when in scope. Root backend CI validates Rust, Nix checks/default binary, and SDK -compatibility; it does not build or publish EIFs or deploy the TEE service. +compatibility. A separate read-only ARM64 workflow compares dev/prod EIF +measurements on explicit approved-PCR JSON edits in PRs, relevant master +changes, and manual runs. Do not require ordinary backend PRs to update +approvals, and do not suppress meaningful master mismatches. CI never signs, +publishes EIFs, or deploys the TEE service. A passing comparison is not live +deployment evidence or proof that both public PCR locations are synchronized. Use `docs/pcr-compatibility.md` for manual signed-PCR validation and legacy publication. Require explicit authorization for PCR reference/history mutation, signing, KMS/IAM changes, shared or remote diff --git a/.agents/skills/validate-opensecret/SKILL.md b/.agents/skills/validate-opensecret/SKILL.md index 19e0eaec..6f6e1fb7 100644 --- a/.agents/skills/validate-opensecret/SKILL.md +++ b/.agents/skills/validate-opensecret/SKILL.md @@ -1,6 +1,6 @@ --- name: validate-opensecret -description: Validate OpenSecret changes with focused Rust tests, exact Rust CI parity, disposable PostgreSQL migration and ignored-test proof, separately authorized provider checks, encrypted SDK or Maple smoke tests, Nix checks, and release-only EIF/PCR evidence. Use before claiming backend work complete or when reviewing whether test evidence matches a changed API, provider, persistence, security, build, or deployment boundary. +description: Validate OpenSecret changes with focused Rust tests, exact Rust CI parity, disposable PostgreSQL migration and ignored-test proof, separately authorized provider checks, encrypted SDK or Maple smoke tests, Nix checks, and read-only EIF/PCR evidence. Use before claiming backend work complete or when reviewing whether test evidence matches a changed API, provider, persistence, security, build, or deployment boundary. --- # Validate OpenSecret @@ -170,10 +170,16 @@ nix flake check --no-write-lock-file --print-build-logs '.?submodules=1' nix build --no-link --no-write-lock-file '.?submodules=1#default' ``` -EIF construction, PCR comparison, and reference/history updates are -release-only work. Root backend CI runs applicable Nix checks and builds the -default backend binary; it does not build or publish EIFs or deploy the TEE -service. Ordinary pull-request completion does not update PCR references. +PCR reference/history updates remain operator-controlled release work. +Read-only EIF construction and PCR comparison are validation when in scope. +Root backend CI runs applicable Nix checks and builds the +default backend binary. The separate root EIF approval workflow builds dev/prod +and compares generated measurements on PRs that explicitly edit the four +approved PCR JSON files, relevant backend/TEE or approval changes to master, +and manual runs. Ordinary backend PRs do not require new PCR approvals; master +mismatches intentionally signal that the revision does not match its current +approvals. CI does not sign, publish EIFs, or deploy the TEE service. +Ordinary pull-request completion does not update PCR references. Do not copy or sign values just to clear a validation failure; distinguish an EIF build failure from a PCR mismatch. Use `docs/pcr-compatibility.md` for the offline signed-history validation and manual legacy-publication procedure. @@ -193,8 +199,8 @@ configuration, and every unrun or unavailable layer. For release evidence, also record the target artifact and PCR source. Use narrow labels: **static/unit**, **disposable DB**, **live provider**, or -**local encrypted full stack**. Use **Linux/Nitro/PCR** or **deployed** only for -authorized release/deployment evidence. +**local encrypted full stack**. Use **Linux/Nitro/PCR** for actual artifact +evidence and **deployed** only for authorized live deployment evidence. Failed, skipped, ignored, interrupted, timing-dependent, and unavailable checks remain exactly that; do not turn partial evidence into “fully tested” or “production ready.” diff --git a/.github/workflows/opensecret-change-detection.yml b/.github/workflows/opensecret-change-detection.yml index 9d2f00a7..236b811b 100644 --- a/.github/workflows/opensecret-change-detection.yml +++ b/.github/workflows/opensecret-change-detection.yml @@ -18,6 +18,12 @@ on: pcr: description: Whether signed PCR compatibility checks are needed value: ${{ jobs.detect.outputs.pcr }} + eif: + description: Whether EIF build inputs or approval references changed + value: ${{ jobs.detect.outputs.eif }} + pcr_approvals: + description: Whether the diff explicitly changes an approved dev/prod PCR JSON file + value: ${{ jobs.detect.outputs.pcr_approvals }} permissions: contents: read @@ -32,6 +38,8 @@ jobs: integration: ${{ steps.classify.outputs.integration }} audit: ${{ steps.classify.outputs.audit }} pcr: ${{ steps.classify.outputs.pcr }} + eif: ${{ steps.classify.outputs.eif }} + pcr_approvals: ${{ steps.classify.outputs.pcr_approvals }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: @@ -46,9 +54,18 @@ jobs: run: | set -euo pipefail enable_all() { - for output in rust nix integration audit pcr; do + for output in rust nix integration audit pcr eif; do printf '%s=true\n' "$output" >> "$GITHUB_OUTPUT" done + # Unknown/manual input must not masquerade as an approval edit. + printf 'pcr_approvals=false\n' >> "$GITHUB_OUTPUT" + } + selection_failed() { + if [[ "$GITHUB_EVENT_NAME" == pull_request ]]; then + echo "::error::Cannot determine the PR's approved-PCR changes; refusing an ambiguous approval check." + exit 1 + fi + enable_all } case "$GITHUB_EVENT_NAME" in pull_request) separator='...' ;; @@ -61,19 +78,39 @@ jobs: ;; schedule) # Scheduled advisories change independently of repository files. - printf 'rust=false\nnix=false\nintegration=false\naudit=true\npcr=false\n' >> "$GITHUB_OUTPUT" + printf 'rust=false\nnix=false\nintegration=false\naudit=true\npcr=false\neif=false\npcr_approvals=false\n' >> "$GITHUB_OUTPUT" exit 0 ;; *) enable_all; exit 0 ;; esac if ! git cat-file -e "${BASE_SHA}^{commit}" || ! git cat-file -e "${HEAD_SHA}^{commit}"; then - enable_all + selection_failed exit 0 fi changed_paths="$(mktemp)" - trap 'rm -f "$changed_paths"' EXIT + selected_checks="$(mktemp)" + trap 'rm -f "$changed_paths" "$selected_checks"' EXIT if ! git diff --no-renames --name-only -z "${BASE_SHA}${separator}${HEAD_SHA}" > "$changed_paths"; then - enable_all - elif ! python3 scripts/ci/opensecret_change_detection.py < "$changed_paths" >> "$GITHUB_OUTPUT"; then - enable_all + selection_failed + elif ! python3 scripts/ci/opensecret_change_detection.py < "$changed_paths" > "$selected_checks"; then + selection_failed + elif ! python3 - "$selected_checks" <<'PY' + from pathlib import Path + import sys + from scripts.ci.opensecret_change_detection import OUTPUTS + + lines = Path(sys.argv[1]).read_text().splitlines() + expected = set(OUTPUTS) + keys = [line.partition("=")[0] for line in lines] + valid = ( + len(lines) == len(expected) + and set(keys) == expected + and all(line.partition("=")[2] in {"true", "false"} for line in lines) + ) + sys.exit(0 if valid else 1) + PY + then + selection_failed + else + cat "$selected_checks" >> "$GITHUB_OUTPUT" fi diff --git a/.github/workflows/opensecret-eif.yml b/.github/workflows/opensecret-eif.yml new file mode 100644 index 00000000..e6e8da4b --- /dev/null +++ b/.github/workflows/opensecret-eif.yml @@ -0,0 +1,64 @@ +name: OpenSecret EIF approval checks + +on: + push: + branches: [master] + pull_request: + branches: [master] + workflow_dispatch: + +# This is measurement verification, never signing, publication, or deployment. +permissions: + contents: read + +concurrency: + group: opensecret-eif-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + changes: + uses: ./.github/workflows/opensecret-change-detection.yml + + eif: + name: EIF/PCR approval match (${{ matrix.mode }}) + needs: changes + # PR comparison requires an actual edit to one of the four approval files. + # A selector failure fails the changes job, not a speculative PCR comparison. + if: >- + ${{ + always() && !cancelled() && + ( + github.event_name == 'workflow_dispatch' || + (github.event_name == 'pull_request' && + needs.changes.result == 'success' && + needs.changes.outputs.pcr_approvals == 'true') || + (github.event_name == 'push' && github.ref == 'refs/heads/master' && + (needs.changes.result != 'success' || needs.changes.outputs.eif != 'false')) + ) + }} + runs-on: ubuntu-24.04-arm + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + mode: [dev, prod] + env: + EIF_MODE: ${{ matrix.mode }} + OPENSECRET_DEV_POSTGRES: "0" + OPENSECRET_DEV_ENV: "0" + OPENSECRET_DEV_CONTAINERS: "0" + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + submodules: recursive + fetch-depth: 0 + + - name: Install pinned Nix + uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22 + with: + github-token: "" + + - name: Build EIF and compare approved measurements + shell: bash + run: bash scripts/ci/check_opensecret_eif.sh "$EIF_MODE" diff --git a/AGENTS.md b/AGENTS.md index 6b5f0bd6..19d97713 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,6 +101,10 @@ its table-driven tests when the dependency graph or component layout changes. The backend has its own root `opensecret-ci.yml` workflow and change selector; `sdk-integration.yml` tests both SDKs against `services/opensecret/` from the same checkout. Backend changes do not imply Research or Agent packaging. +The separate `opensecret-eif.yml` compares dev/prod EIF measurements only on PRs +editing approved PCR JSON, relevant master changes, and manual runs. Preserve +ordinary backend PRs without fresh approvals and meaningful master mismatches; +these read-only checks never sign, publish, or authorize deployment. For Pages, read [the deployment guide](docs/pages-deployments.md). Preserve unprivileged preview builds and separate development/production profiles. diff --git a/docs/opensecret-import.md b/docs/opensecret-import.md index 27176dba..67f2d200 100644 --- a/docs/opensecret-import.md +++ b/docs/opensecret-import.md @@ -2,8 +2,9 @@ OpenSecret lives in `services/opensecret/`, retaining its Rust package, Nix toolchain, operator recipes, submodule revisions, and PCR filenames. This is a -source and development-workflow migration. Building and deploying the TEE -service remains an operator-controlled process. +source and development-workflow migration. Approval, signing, publication, and +deployment remain operator-controlled. Read-only EIF/PCR comparisons now also +run in CI under the [approval-check policy](../services/opensecret/docs/nitro-deploy.md#ci-approval-checks). ## Preserved source boundary @@ -48,8 +49,12 @@ backend revision. This advances that lane from the former pinned commit PR jobs have read-only credentials and use hosted runners. Backend CI has no EIF publisher, signing credentials, OIDC permission, or deployment step. PCR -file changes have their own validation lane. Backend-only changes do not -select Research or Agent application packaging. +file changes retain their signature-validation lane. A separate ARM64 EIF +workflow compares dev/prod measurements on PRs explicitly editing approved PCR +JSON, relevant backend/TEE or approval changes to master, and manual runs. +Ordinary backend PRs do not fail merely because approvals have not been +updated; master mismatches are an intentional deployment-approval signal. +Backend-only changes do not select Research or Agent application packaging. The companion OpenSecret Workspaces change supports Maple-only compositions through `services/opensecret/`. An explicitly included standalone `opensecret` diff --git a/scripts/ci/check_opensecret_eif.sh b/scripts/ci/check_opensecret_eif.sh new file mode 100644 index 00000000..22bb560a --- /dev/null +++ b/scripts/ci/check_opensecret_eif.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Build and compare only. Never copy, sign, or publish approval files. +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "Usage: check_opensecret_eif.sh dev|prod" >&2 + exit 2 +fi +case "$1" in + dev) reference=pcrDev.json ;; + prod) reference=pcrProd.json ;; + *) echo "Expected dev or prod." >&2; exit 2 ;; +esac +mode=$1 + +if [[ "$(uname -s)" != Linux || "$(uname -m)" != aarch64 ]]; then + echo "EIF approval checks require a Linux ARM64 runner." >&2 + exit 1 +fi + +repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd -P) +cd "$repo_root/services/opensecret" +if [[ ! -f "$reference" || ! -s "$reference" || -L "$reference" ]]; then + echo "Missing regular approved measurement file: $reference" >&2 + exit 1 +fi + +# Direct Nix invocation avoids just's dotenv loader and development shell hooks. +# A fresh temporary link never replaces an operator's existing result symlink. +output_dir=$(mktemp -d "${TMPDIR:-/tmp}/opensecret-eif-${mode}.XXXXXX") +trap 'rm -rf -- "$output_dir"' EXIT +nix build --no-update-lock-file --print-build-logs \ + --out-link "$output_dir/result" ".?submodules=1#eif-$mode" + +if [[ ! -f "$output_dir/result/image.eif" || ! -s "$output_dir/result/image.eif" || + ! -f "$output_dir/result/pcr.json" || ! -s "$output_dir/result/pcr.json" ]]; then + echo "EIF build did not produce image.eif and pcr.json." >&2 + exit 1 +fi + +if ! diff -u -- "$reference" "$output_dir/result/pcr.json"; then + echo "EIF/PCR approval mismatch ($mode): this build does not match $reference." >&2 + echo "Review the measurements through the manual approval process; CI will not update or sign them." >&2 + exit 1 +fi +echo "EIF/PCR approval match ($mode). This does not authorize deployment." diff --git a/scripts/ci/opensecret_change_detection.py b/scripts/ci/opensecret_change_detection.py index 384c401e..59cab7c8 100644 --- a/scripts/ci/opensecret_change_detection.py +++ b/scripts/ci/opensecret_change_detection.py @@ -8,17 +8,24 @@ import sys -OUTPUTS = ("rust", "nix", "integration", "audit", "pcr") -ALL_OUTPUTS = frozenset(OUTPUTS) +CHECK_OUTPUTS = ("rust", "nix", "integration", "audit", "pcr", "eif") +OUTPUTS = (*CHECK_OUTPUTS, "pcr_approvals") +ALL_CHECKS = frozenset(CHECK_OUTPUTS) BACKEND_PREFIX = "services/opensecret/" BACKEND_INERT_FILES = frozenset({ "AGENTS.md", "README.md", "LICENSE", ".gitignore", ".gitmodules", }) -PCR_INPUTS = frozenset({ +APPROVED_PCR_FILES = frozenset({ "pcrDev.json", "pcrDevHistory.json", "pcrProd.json", "pcrProdHistory.json", +}) +PCR_INPUTS = frozenset({ "pcrPreview.json", "pcrPreviewHistory.json", "pcr_verify.js", "pcr_sign.js", "scripts/pcr_compatibility.py", "scripts/test_pcr_compatibility.py", }) +EIF_CI_INPUTS = frozenset({ + ".github/workflows/opensecret-eif.yml", + "scripts/ci/check_opensecret_eif.sh", +}) # Shell test inputs consumed directly by the component flake, not Cargo. NIX_TEST_INPUTS = frozenset({"tests/entrypoint_entropy_preflight.sh"}) BACKEND_INERT_PREFIXES = ("docs/", ".agents/", ".github/") @@ -46,25 +53,29 @@ def classify_path(path: str) -> frozenset[str]: if not path or path.startswith("/") or ".." in path.split("/"): - return ALL_OUTPUTS + return ALL_CHECKS if path in SHARED_INPUTS: - return ALL_OUTPUTS + return ALL_CHECKS + if path in EIF_CI_INPUTS: + return frozenset({"eif"}) if path == ".github/workflows/opensecret-ci.yml": - return frozenset({"rust", "nix", "audit", "pcr"}) + return frozenset({"rust", "nix", "audit", "pcr", "eif"}) if path in SDK_INTEGRATION_FILES or path.startswith(("sdk/src/", "sdk/rust/", "sdk/test/")): return frozenset({"integration"}) if path.startswith(BACKEND_PREFIX): relative = path.removeprefix(BACKEND_PREFIX) if relative in BACKEND_INERT_FILES or relative.startswith(BACKEND_INERT_PREFIXES): return frozenset() + if relative in APPROVED_PCR_FILES: + return frozenset({"pcr", "eif", "pcr_approvals"}) if relative in PCR_INPUTS: return frozenset({"pcr"}) if relative == "deny.toml": return frozenset({"audit"}) if relative in {"Cargo.toml", "Cargo.lock", "rust-toolchain.toml", "flake.nix", "flake.lock"}: - return ALL_OUTPUTS + return ALL_CHECKS if relative.startswith(("src/", ".cargo/")) or relative == "build.rs": - return frozenset({"rust", "nix", "integration"}) + return frozenset({"rust", "nix", "integration", "eif"}) if relative in NIX_TEST_INPUTS: return frozenset({"nix"}) if relative.startswith(("tests/", "migrations/")): @@ -74,13 +85,13 @@ def classify_path(path: str) -> frozenset[str]: if relative.startswith(("nix/", "nitro-toolkit/", "privatemode-public/")) or relative in { "entrypoint.sh", "continuum-proxy", "nitro-toolkit", "privatemode-public", }: - return frozenset({"nix"}) + return frozenset({"nix", "eif"}) # Unknown backend files could be build or runtime inputs. - return ALL_OUTPUTS + return ALL_CHECKS if path.startswith("sdk/") or path in KNOWN_INDEPENDENT_FILES or path.startswith(KNOWN_INDEPENDENT_PREFIXES): return frozenset() # New roots must be classified explicitly before checks can be skipped. - return ALL_OUTPUTS + return ALL_CHECKS def classify_paths(paths: Iterable[str]) -> dict[str, bool]: @@ -92,10 +103,10 @@ def classify_paths(paths: Iterable[str]) -> dict[str, bool]: def main() -> int: parser = argparse.ArgumentParser() - parser.add_argument("--all", action="store_true", help="Select all checks when the diff is unavailable") + parser.add_argument("--all", action="store_true", help="Select all checks without claiming approvals changed") args = parser.parse_args() if args.all: - result = dict.fromkeys(OUTPUTS, True) + result = {name: name in ALL_CHECKS for name in OUTPUTS} else: paths = (path.decode("utf-8", errors="surrogateescape") for path in sys.stdin.buffer.read().split(b"\0") if path) diff --git a/scripts/ci/test_opensecret_change_detection.py b/scripts/ci/test_opensecret_change_detection.py index 83c7c0ac..30f3b727 100644 --- a/scripts/ci/test_opensecret_change_detection.py +++ b/scripts/ci/test_opensecret_change_detection.py @@ -7,7 +7,7 @@ from agent_change_detection import affects_agent from change_detection import classify_path as research_routes -from opensecret_change_detection import OUTPUTS, classify_paths +from opensecret_change_detection import CHECK_OUTPUTS, OUTPUTS, classify_paths class OpenSecretChangeDetectionTests(unittest.TestCase): @@ -19,7 +19,7 @@ def test_backend_runtime_and_migrations_select_compatibility_without_packaging_a "migrations/2026/up.sql", ".cargo/config.toml", "build.rs"): path = "services/opensecret/" + relative with self.subTest(path=path): - expected = ("rust", "integration") if relative.startswith(("tests/", "migrations/")) else ("rust", "nix", "integration") + expected = ("rust", "integration") if relative.startswith(("tests/", "migrations/")) else ("rust", "nix", "integration", "eif") self.assert_routes([path], *expected) self.assertEqual(research_routes(path), frozenset()) self.assertFalse(affects_agent(path)) @@ -28,7 +28,7 @@ def test_backend_dependency_and_toolchain_inputs_select_all_backend_checks(self) for relative in ("Cargo.toml", "Cargo.lock", "rust-toolchain.toml", "flake.nix", "flake.lock"): path = "services/opensecret/" + relative with self.subTest(path=path): - self.assert_routes([path], *OUTPUTS) + self.assert_routes([path], *CHECK_OUTPUTS) self.assertEqual(research_routes(path), frozenset()) self.assertFalse(affects_agent(path)) @@ -41,13 +41,20 @@ def test_backend_flake_shell_test_selects_nix_without_rust_or_app_builds(self): def test_backend_nix_only_inputs_and_dependency_policy_keep_independent_checks(self): for relative in ("nix/kernel-upstream.nix", "entrypoint.sh", "continuum-proxy", "nitro-toolkit", "nitro-toolkit/init/main.c", "privatemode-public"): - self.assert_routes(["services/opensecret/" + relative], "nix") + self.assert_routes(["services/opensecret/" + relative], "nix", "eif") self.assert_routes(["services/opensecret/deny.toml"], "audit") self.assert_routes(["services/opensecret/.env.sample"], "integration") - def test_signed_pcr_and_operator_documentation_changes_do_not_rebuild_clients(self): - for relative in ("pcrDev.json", "pcrDevHistory.json", "pcrProd.json", "pcrProdHistory.json", - "pcrPreview.json", "pcrPreviewHistory.json", "pcr_sign.js", "pcr_verify.js", + def test_only_the_four_approved_json_files_select_pr_eif_comparison(self): + for relative in ("pcrDev.json", "pcrDevHistory.json", "pcrProd.json", "pcrProdHistory.json"): + path = "services/opensecret/" + relative + with self.subTest(path=path): + self.assert_routes([path], "pcr", "eif", "pcr_approvals") + self.assertEqual(research_routes(path), frozenset()) + self.assertFalse(affects_agent(path)) + + def test_pcr_tooling_and_other_pcr_names_do_not_claim_an_approval_edit(self): + for relative in ("pcrPreview.json", "pcrPreviewHistory.json", "pcr_sign.js", "pcr_verify.js", "scripts/pcr_compatibility.py", "scripts/test_pcr_compatibility.py"): path = "services/opensecret/" + relative with self.subTest(path=path): @@ -77,25 +84,41 @@ def test_independent_components_and_docs_skip_backend_checks(self): def test_submodules_and_selector_changes_select_all_backend_checks(self): for path in (".gitmodules", ".github/workflows/opensecret-change-detection.yml", "scripts/ci/opensecret_change_detection.py"): - self.assert_routes([path], *OUTPUTS) + self.assert_routes([path], *CHECK_OUTPUTS) self.assertFalse(affects_agent(".gitmodules")) self.assertEqual(research_routes(".gitmodules"), frozenset()) - self.assert_routes([".github/workflows/opensecret-ci.yml"], "rust", "nix", "audit", "pcr") + self.assert_routes([".github/workflows/opensecret-ci.yml"], "rust", "nix", "audit", "pcr", "eif") self.assert_routes([".github/workflows/sdk-integration.yml"], "integration") + def test_eif_workflow_and_comparison_helper_select_only_master_eif_checks(self): + for path in (".github/workflows/opensecret-eif.yml", "scripts/ci/check_opensecret_eif.sh"): + self.assert_routes([path], "eif") + self.assertEqual(research_routes(path), frozenset()) + self.assertFalse(affects_agent(path)) + + def test_mixed_backend_and_approval_changes_retain_both_signals(self): + self.assert_routes( + ["services/opensecret/src/main.rs", "services/opensecret/pcrProd.json"], + "rust", "nix", "integration", "pcr", "eif", "pcr_approvals", + ) + self.assert_routes( + ["sdk/src/lib/client.ts", "services/opensecret/pcrDevHistory.json"], + "integration", "pcr", "eif", "pcr_approvals", + ) + def test_unknown_inputs_invalid_paths_and_empty_diff(self): for path in ("unknown-root.toml", "services/opensecret/new-build-input", "", "/tmp/path", "services/opensecret/../updates/src/main.ts"): - self.assert_routes([path], *OUTPUTS) + self.assert_routes([path], *CHECK_OUTPUTS) self.assert_routes([]) - self.assert_routes(["README.md", "services/opensecret/src/main.rs"], "rust", "nix", "integration") + self.assert_routes(["README.md", "services/opensecret/src/main.rs"], "rust", "nix", "integration", "eif") def test_null_delimited_cli_and_explicit_fallback(self): script = Path(__file__).with_name("opensecret_change_detection.py") for arguments, paths, selected in ( - ([], b"README.md\0services/opensecret/src/name with\nnewline.rs\0", {"rust", "nix", "integration"}), + ([], b"README.md\0services/opensecret/src/name with\nnewline.rs\0", {"rust", "nix", "integration", "eif"}), ([], b"README.md\0services/opensecret/docs/note with spaces.md\0", set()), - (["--all"], b"", set(OUTPUTS)), + (["--all"], b"", set(CHECK_OUTPUTS)), ): result = subprocess.run([sys.executable, str(script), *arguments], input=paths, check=True, capture_output=True) diff --git a/scripts/ci/test_opensecret_workflows.py b/scripts/ci/test_opensecret_workflows.py index 2a617d52..459299a6 100644 --- a/scripts/ci/test_opensecret_workflows.py +++ b/scripts/ci/test_opensecret_workflows.py @@ -12,7 +12,7 @@ import tomllib import unittest -from opensecret_change_detection import OUTPUTS +from opensecret_change_detection import CHECK_OUTPUTS, OUTPUTS ROOT = Path(__file__).resolve().parents[2] @@ -43,6 +43,7 @@ def test_nix_jobs_fetch_complete_backend_and_submodule_history(self): # fetcher cannot calculate revCount for a shallow recursive input. for workflow_name, job_names in ( ("opensecret-ci.yml", ("rust", "nix", "pcr")), + ("opensecret-eif.yml", ("eif",)), ("sdk-integration.yml", ("sdk-integration",)), ): for job_name in job_names: @@ -55,7 +56,8 @@ def test_nix_jobs_fetch_complete_backend_and_submodule_history(self): self.assertEqual(checkouts[0].get("fetch-depth"), 0) def test_fork_jobs_are_hosted_read_only_and_credential_free(self): - for name in ("opensecret-ci.yml", "opensecret-change-detection.yml", "sdk-integration.yml"): + for name in ("opensecret-ci.yml", "opensecret-eif.yml", + "opensecret-change-detection.yml", "sdk-integration.yml"): config = workflow(name) with self.subTest(workflow=name): self.assertEqual(config["permissions"], {"contents": "read"}) @@ -70,7 +72,8 @@ def test_fork_jobs_are_hosted_read_only_and_credential_free(self): self.assertEqual(job["uses"], "./.github/workflows/opensecret-change-detection.yml") self.assertNotIn("secrets", job) continue - self.assertEqual(job["runs-on"], "ubuntu-latest") + expected_runner = "ubuntu-24.04-arm" if name == "opensecret-eif.yml" else "ubuntu-latest" + self.assertEqual(job["runs-on"], expected_runner) for step in job["steps"]: self.assertNotIn("${{", step.get("run", "")) action = step.get("uses", "") @@ -83,7 +86,7 @@ def test_fork_jobs_are_hosted_read_only_and_credential_free(self): if action.startswith("DeterminateSystems/nix-installer-action@"): self.assertEqual(step["with"]["github-token"], "") - def test_backend_does_not_publish_or_run_privileged_legacy_builds(self): + def test_ordinary_backend_ci_does_not_publish_or_build_eifs(self): config = workflow("opensecret-ci.yml") self.assertEqual(set(config["on"]), {"push", "pull_request", "schedule", "workflow_dispatch"}) self.assertEqual(config["on"]["push"]["branches"], ["master"]) @@ -98,6 +101,60 @@ def test_backend_does_not_publish_or_run_privileged_legacy_builds(self): self.assertEqual(cache["save-if"], "${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}") + def test_eif_workflow_is_read_only_and_keeps_both_environments_independent(self): + config = workflow("opensecret-eif.yml") + self.assertEqual(set(config["on"]), {"push", "pull_request", "workflow_dispatch"}) + for event in ("push", "pull_request"): + self.assertEqual(config["on"][event]["branches"], ["master"]) + self.assertNotIn("paths", config["on"][event]) + self.assertEqual(config["concurrency"]["group"], + "opensecret-eif-${{ github.event_name }}-${{ github.ref }}") + job = config["jobs"]["eif"] + self.assertEqual(job["strategy"]["matrix"], {"mode": ["dev", "prod"]}) + self.assertIs(job["strategy"]["fail-fast"], False) + self.assertEqual(job["env"]["EIF_MODE"], "${{ matrix.mode }}") + self.assertEqual(job["timeout-minutes"], 90) + for key in ("OPENSECRET_DEV_POSTGRES", "OPENSECRET_DEV_ENV", "OPENSECRET_DEV_CONTAINERS"): + self.assertEqual(job["env"][key], "0") + commands = [step["run"] for step in job["steps"] if "run" in step] + self.assertEqual(commands, ['bash scripts/ci/check_opensecret_eif.sh "$EIF_MODE"']) + for value in strings(config["jobs"]): + self.assertNotRegex(value, r"deploy-|stage-|scp-|update-pcr|append-pcr|generate-keys") + self.assertNotRegex(value, r"upload-artifact|download-artifact|flakehub-cache|gh release") + + def test_eif_event_gate_matches_the_approval_policy(self): + # Exercise the actual GitHub boolean expression with a restricted, + # equivalent local representation, not a separately implemented policy. + expression = workflow("opensecret-eif.yml")["jobs"]["eif"]["if"] + expression = expression.strip().removeprefix("${{").removesuffix("}}").strip() + expression = expression.replace("&&", " and ").replace("||", " or ") + expression = expression.replace("!cancelled()", "True").replace("always()", "True") + expression = " ".join(expression.split()) + cases = ( + ("pull_request", "refs/pull/1/merge", "success", "true", "false", False), + ("pull_request", "refs/pull/1/merge", "success", "true", "true", True), + ("pull_request", "refs/pull/1/merge", "failure", "", "", False), + ("pull_request", "refs/pull/1/merge", "success", "", "", False), + ("push", "refs/heads/master", "success", "true", "false", True), + ("push", "refs/heads/master", "success", "false", "false", False), + ("push", "refs/heads/master", "failure", "", "", True), + ("push", "refs/heads/master", "success", "", "", True), + ("push", "refs/heads/feature", "success", "true", "true", False), + ("workflow_dispatch", "refs/heads/master", "success", "true", "false", True), + ("schedule", "refs/heads/master", "success", "true", "true", False), + ) + for event, ref, result, eif, approvals, expected in cases: + with self.subTest(event=event, ref=ref, result=result, eif=eif, approvals=approvals): + condition = expression + for key, value in { + "github.event_name": event, "github.ref": ref, + "needs.changes.result": result, + "needs.changes.outputs.eif": eif, + "needs.changes.outputs.pcr_approvals": approvals, + }.items(): + condition = condition.replace(key, repr(value)) + self.assertEqual(eval(condition, {"__builtins__": {}}, {}), expected) + def test_backend_retains_exact_rust_gates_and_disabled_stateful_shell_hooks(self): config = workflow("opensecret-ci.yml") self.assertEqual(config["defaults"]["run"]["working-directory"], "services/opensecret") @@ -153,7 +210,7 @@ def test_sdk_integration_uses_checked_out_backend_and_disposable_services(self): self.assertEqual(job["services"]["postgres"]["env"]["POSTGRES_DB"], "opensecret") def test_selector_failures_or_missing_outputs_cannot_skip_validation(self): - for workflow_name, lanes in (("opensecret-ci.yml", {name: name for name in OUTPUTS if name != "integration"}), + for workflow_name, lanes in (("opensecret-ci.yml", {name: name for name in ("rust", "nix", "audit", "pcr")}), ("sdk-integration.yml", {"sdk-integration": "integration"})): config = workflow(workflow_name) self.assertNotIn("paths", config["on"]["pull_request"]) @@ -232,6 +289,143 @@ def test_successful_sdk_checks_all_execute(self): self.assertTrue(command.startswith(expected), command) +class EifComparisonCommandTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.repo = self.root / "repo" + self.component = self.repo / "services/opensecret" + self.component.mkdir(parents=True) + scripts = self.repo / "scripts/ci" + scripts.mkdir(parents=True) + self.script = scripts / "check_opensecret_eif.sh" + shutil.copyfile(ROOT / "scripts/ci/check_opensecret_eif.sh", self.script) + self.binaries = self.root / "bin" + self.binaries.mkdir() + self.scratch = self.root / "scratch" + self.scratch.mkdir() + for tool in ("dirname", "mktemp", "rm", "diff"): + (self.binaries / tool).symlink_to(shutil.which(tool)) + uname = self.binaries / "uname" + uname.write_text( + f"#!{sys.executable}\n" + "import os, sys\n" + "print('Linux' if sys.argv[1] == '-s' else os.environ.get('TEST_ARCH', 'aarch64'))\n" + ) + uname.chmod(0o755) + nix = self.binaries / "nix" + nix.write_text( + f"#!{sys.executable}\n" + "import json, os, sys\n" + "from pathlib import Path\n" + "args = sys.argv[1:]\n" + "Path(os.environ['TEST_TRACE']).write_text(json.dumps({'args': args, 'cwd': os.getcwd()}))\n" + "if os.environ.get('TEST_FAIL_BUILD'):\n" + " sys.exit(42)\n" + "output = Path(args[args.index('--out-link') + 1])\n" + "output.mkdir()\n" + "if not os.environ.get('TEST_MISSING_IMAGE'):\n" + " (output / 'image.eif').write_bytes(b'fixture EIF, not a real image')\n" + "if os.environ.get('TEST_PCR_DIRECTORY'):\n" + " (output / 'pcr.json').mkdir()\n" + "elif not os.environ.get('TEST_MISSING_PCR'):\n" + " (output / 'pcr.json').write_text(os.environ['TEST_MEASUREMENTS'])\n" + ) + nix.chmod(0o755) + self.measurements = { + mode: json.dumps({"HashAlgorithm": "fixture", "PCR0": f"reviewed-{mode}"}) + "\n" + for mode in ("dev", "prod") + } + for mode, name in (("dev", "pcrDev.json"), ("prod", "pcrProd.json")): + (self.component / name).write_text(self.measurements[mode]) + for name in ("pcrDevHistory.json", "pcrProdHistory.json"): + (self.component / name).write_text("untouched history fixture\n") + self.existing_result = self.component / "result" + self.existing_result.symlink_to(self.root / "operator-owned-output") + self.sentinel = self.root / "dotenv-was-loaded" + (self.component / ".env").write_text(f"touch '{self.sentinel}'\n") + self.before_files = { + p.name: p.read_bytes() for p in self.component.iterdir() if p.is_file() + } + + def run_check(self, mode="dev", **extra_env): + env = { + "PATH": str(self.binaries), "HOME": str(self.root), + "TMPDIR": str(self.scratch), "TEST_TRACE": str(self.root / "trace"), + "TEST_MEASUREMENTS": self.measurements.get(mode, self.measurements["dev"]), **extra_env, + } + result = subprocess.run( + [shutil.which("bash"), "--noprofile", "--norc", str(self.script), mode], + cwd=self.root, env=env, capture_output=True, text=True, + ) + self.assertFalse(self.sentinel.exists()) + self.assertEqual(os.readlink(self.existing_result), str(self.root / "operator-owned-output")) + self.assertEqual( + {p.name: p.read_bytes() for p in self.component.iterdir() if p.is_file()}, + self.before_files, + ) + self.assertEqual(list(self.scratch.iterdir()), []) + return result + + def test_each_environment_builds_the_pinned_component_without_loading_dotenv(self): + for mode in ("dev", "prod"): + with self.subTest(mode=mode): + result = self.run_check(mode) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn(f"EIF/PCR approval match ({mode})", result.stdout) + trace = json.loads((self.root / "trace").read_text()) + self.assertEqual(trace["cwd"], str(self.component)) + self.assertEqual(trace["args"][:4], [ + "build", "--no-update-lock-file", "--print-build-logs", "--out-link", + ]) + self.assertEqual(trace["args"][-1], f".?submodules=1#eif-{mode}") + + def test_measurement_mismatch_fails_without_rewriting_approvals(self): + result = self.run_check(TEST_MEASUREMENTS='{"PCR0":"not approved"}\n') + self.assertNotEqual(result.returncode, 0) + self.assertIn("EIF/PCR approval mismatch", result.stderr) + + def test_build_failure_and_missing_generated_measurements_fail(self): + result = self.run_check(TEST_FAIL_BUILD="1") + self.assertEqual(result.returncode, 42) + for failure in ("TEST_MISSING_PCR", "TEST_MISSING_IMAGE", "TEST_PCR_DIRECTORY"): + with self.subTest(failure=failure): + result = self.run_check(**{failure: "1"}) + self.assertNotEqual(result.returncode, 0) + self.assertIn("did not produce", result.stderr) + + def test_invalid_mode_and_wrong_architecture_do_not_build(self): + for mode, extra_env in (("preview", {}), ("dev", {"TEST_ARCH": "x86_64"})): + with self.subTest(mode=mode, extra_env=extra_env): + result = self.run_check(mode, **extra_env) + self.assertNotEqual(result.returncode, 0) + self.assertFalse((self.root / "trace").exists()) + + def test_missing_or_symlinked_approved_measurements_do_not_build(self): + reference = self.component / "pcrDev.json" + reference.unlink() + self.before_files.pop(reference.name) + result = self.run_check() + self.assertNotEqual(result.returncode, 0) + self.assertFalse((self.root / "trace").exists()) + reference.symlink_to(self.component / "pcrProd.json") + self.before_files[reference.name] = self.measurements["prod"].encode() + result = self.run_check() + self.assertNotEqual(result.returncode, 0) + self.assertFalse((self.root / "trace").exists()) + + def test_directory_instead_of_approved_measurements_does_not_build(self): + reference = self.component / "pcrDev.json" + reference.unlink() + self.before_files.pop(reference.name) + reference.mkdir() + result = self.run_check() + self.assertNotEqual(result.returncode, 0) + self.assertIn("Missing regular approved measurement file", result.stderr) + self.assertFalse((self.root / "trace").exists()) + + class OpenSecretDiffSelectionTests(unittest.TestCase): def setUp(self): self.temporary = tempfile.TemporaryDirectory() @@ -259,7 +453,7 @@ def commit_file(self, path, text): self.git("commit", "-qm", "fixture change") return self.git("rev-parse", "HEAD") - def select(self, event, base, head): + def select(self, event, base, head, *, succeeds=True): step = next(step for step in workflow("opensecret-change-detection.yml")["jobs"]["detect"]["steps"] if step.get("id") == "classify") output = self.root / "output" @@ -268,8 +462,12 @@ def select(self, event, base, head): "GITHUB_OUTPUT": str(output)} result = subprocess.run(["bash", "-c", step["run"]], cwd=self.root, env=env, capture_output=True, text=True) - self.assertEqual(result.returncode, 0, result.stderr) - return dict(line.split("=", 1) for line in output.read_text().splitlines()) + if succeeds: + self.assertEqual(result.returncode, 0, result.stderr) + else: + self.assertNotEqual(result.returncode, 0) + self.assertIn("Cannot determine the PR's approved-PCR changes", result.stdout) + return dict(line.split("=", 1) for line in output.read_text().splitlines()) if output.exists() else {} def expected(self, *selected): return {name: "true" if name in selected else "false" for name in OUTPUTS} @@ -278,9 +476,9 @@ def test_docs_only_push_backend_runtime_push_and_signed_pcr_push(self): docs = self.commit_file("services/opensecret/docs/design.md", "design\n") self.assertEqual(self.select("push", self.base, docs), self.expected()) runtime = self.commit_file("services/opensecret/src/main.rs", "fn main() {}\n") - self.assertEqual(self.select("push", docs, runtime), self.expected("rust", "nix", "integration")) + self.assertEqual(self.select("push", docs, runtime), self.expected("rust", "nix", "integration", "eif")) pcr = self.commit_file("services/opensecret/pcrDevHistory.json", "[]\n") - self.assertEqual(self.select("push", runtime, pcr), self.expected("pcr")) + self.assertEqual(self.select("push", runtime, pcr), self.expected("pcr", "eif", "pcr_approvals")) def test_pull_request_uses_merge_base_instead_of_unrelated_base_changes(self): master = self.commit_file("services/opensecret/src/main.rs", "fn main() {}\n") @@ -288,21 +486,61 @@ def test_pull_request_uses_merge_base_instead_of_unrelated_base_changes(self): docs = self.commit_file("services/opensecret/docs/design.md", "design\n") self.assertEqual(self.select("pull_request", master, docs), self.expected()) + def test_unrelated_master_approvals_do_not_count_as_pr_approval_edits(self): + master = self.commit_file("services/opensecret/pcrDev.json", "approved on master\n") + self.git("checkout", "-qb", "contributor", self.base) + runtime = self.commit_file("services/opensecret/src/main.rs", "fn main() {}\n") + self.assertEqual(self.select("pull_request", master, runtime), + self.expected("rust", "nix", "integration", "eif")) + + def test_pr_approval_edit_selects_comparison_even_with_backend_changes(self): + runtime = self.commit_file("services/opensecret/src/main.rs", "fn main() {}\n") + approvals = self.commit_file("services/opensecret/pcrProdHistory.json", "[]\n") + self.assertEqual(self.select("pull_request", self.base, approvals), + self.expected("rust", "nix", "integration", "pcr", "eif", "pcr_approvals")) + self.assertEqual(self.select("pull_request", runtime, approvals), + self.expected("pcr", "eif", "pcr_approvals")) + def test_deletion_or_rename_out_of_backend_still_selects_contract_checks(self): runtime = self.commit_file("services/opensecret/src/main.rs", "fn main() {}\n") self.git("mv", "services/opensecret/src/main.rs", "LICENSE") self.git("commit", "-qam", "rename fixture") self.assertEqual(self.select("push", runtime, self.git("rev-parse", "HEAD")), - self.expected("rust", "nix", "integration")) + self.expected("rust", "nix", "integration", "eif")) + + def test_deletion_or_rename_of_an_approval_file_is_an_explicit_edit(self): + for rename in (False, True): + with self.subTest(rename=rename): + before = self.commit_file("services/opensecret/pcrProd.json", "approval\n") + if rename: + (self.root / "services/opensecret/docs").mkdir(exist_ok=True) + self.git("mv", "services/opensecret/pcrProd.json", "services/opensecret/docs/old-approval.json") + else: + self.git("rm", "services/opensecret/pcrProd.json") + self.git("commit", "-qam", "remove approval fixture") + self.assertEqual(self.select("pull_request", before, self.git("rev-parse", "HEAD")), + self.expected("pcr", "eif", "pcr_approvals")) def test_missing_history_manual_event_classifier_failure_and_partial_output_fail_safe(self): for event, base, head in (("push", "0" * 40, self.base), ("push", "a" * 40, self.base), ("workflow_dispatch", "", "")): with self.subTest(event=event, base=base): - self.assertEqual(self.select(event, base, head), self.expected(*OUTPUTS)) + self.assertEqual(self.select(event, base, head), self.expected(*CHECK_OUTPUTS)) classifier = self.root / "scripts/ci/opensecret_change_detection.py" classifier.write_text("print('rust=false')\nraise RuntimeError('fixture')\n") - self.assertEqual(self.select("push", self.base, self.base), self.expected(*OUTPUTS)) + self.assertEqual(self.select("push", self.base, self.base), self.expected(*CHECK_OUTPUTS)) + self.assertEqual(self.select("pull_request", self.base, self.base, succeeds=False), {}) + + def test_pr_missing_history_fails_routing_without_inventing_approval_changes(self): + self.assertEqual(self.select("pull_request", "a" * 40, self.base, succeeds=False), {}) + + def test_successful_but_incomplete_classifier_output_is_rejected(self): + classifier = self.root / "scripts/ci/opensecret_change_detection.py" + classifier.write_text( + "OUTPUTS = " + repr(OUTPUTS) + "\nprint('rust=false')\n" + ) + self.assertEqual(self.select("pull_request", self.base, self.base, succeeds=False), {}) + self.assertEqual(self.select("push", self.base, self.base), self.expected(*CHECK_OUTPUTS)) def test_schedule_selects_only_advisory_audit(self): self.assertEqual(self.select("schedule", "", ""), self.expected("audit")) diff --git a/services/opensecret/AGENTS.md b/services/opensecret/AGENTS.md index bd2d9606..fa38fe77 100644 --- a/services/opensecret/AGENTS.md +++ b/services/opensecret/AGENTS.md @@ -133,7 +133,7 @@ guidance. Use `$develop-opensecret` for the local stack and code-placement workflow. Use `$validate-opensecret` to choose focused tests, exact Rust CI parity, disposable-database validation, authorized provider probes, encrypted client -smoke tests, Nix checks, and release-only EIF/PCR evidence. +smoke tests, Nix checks, and read-only EIF/PCR evidence. Match evidence to the changed boundary. Report exact commands, counts, ignored or skipped tests, configured external services, and every unverified layer. @@ -144,7 +144,12 @@ EIF/PCR parity is a release and deployment gate, not an ordinary development or pull-request gate. The monorepo-root `opensecret-ci.yml` validates Rust, Nix checks and the default backend binary, and dependency policy; `sdk-integration.yml` exercises both in-tree SDKs against this backend. -GitHub Actions does not build or publish EIFs or deploy the TEE service. +The separate root `opensecret-eif.yml` builds dev/prod EIFs and compares their +measurements only when a PR explicitly edits one of the four approved PCR JSON +files, on relevant backend/TEE or approval changes to master, or on a manual +run. An ordinary backend PR does not require updated PCR approvals. A master +mismatch deliberately reports that the revision does not match current +approvals. GitHub Actions never signs or publishes EIFs or deploys the service. Do not update PCR references as part of ordinary pull-request work. Treat an EIF build failure separately from PCR mismatch. diff --git a/services/opensecret/docs/nitro-deploy.md b/services/opensecret/docs/nitro-deploy.md index 0aa0655c..7dcfbf67 100644 --- a/services/opensecret/docs/nitro-deploy.md +++ b/services/opensecret/docs/nitro-deploy.md @@ -2,9 +2,31 @@ This operator runbook remains manual. Run repository-local build and `just` commands from `services/opensecret/` in the Maple monorepo using its pinned Nix -flake. The root GitHub workflows do not build or publish EIFs or deploy this -service. For authorized signed-PCR updates and legacy client compatibility, -follow [the PCR publication procedure](pcr-compatibility.md). +flake. The root EIF approval workflow performs read-only dev/prod builds and +measurement comparisons under the policy below; it never signs, publishes, +or deploys this service. For authorized signed-PCR updates and legacy client +compatibility, follow [the PCR publication procedure](pcr-compatibility.md). + +## CI approval checks + +`opensecret-eif.yml` compares generated measurements with the approved JSON +files on Linux ARM64. A PR runs these comparisons only if its own diff edits +`pcrDev.json`, `pcrProd.json`, `pcrDevHistory.json`, or `pcrProdHistory.json` +under `services/opensecret/`. Backend code changes alone do not require new +approvals to pass PR CI. The PR's checkout supplies both source and references. + +Master compares on backend/TEE build-input, approved-PCR, and EIF-check tooling +changes. A mismatch intentionally leaves the distinct EIF/PCR approval check +red until an operator reviews and updates approvals. Unrelated client-only or +documentation changes skip it. Manual dispatch checks both environments. +If a PR's changed files cannot be determined, routing fails explicitly instead +of treating missing information as an approval edit. + +Existing signed-history validation is separate. Neither a matching EIF nor +green CI verifies both public publication locations, live KMS policy, or the +running enclave, and neither authorizes deployment. Builds use normal Nix +cache semantics; this is measurement parity, not a forced independent rebuild. +CI never updates references or handles signing keys. ## Log into AWS CLI