diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01578a4..bc4b3d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,13 +13,22 @@ permissions: jobs: test: - runs-on: ubuntu-latest + # Release Sentinel requires the authentic Actions check named CI. + name: CI + runs-on: ubuntu-24.04 timeout-minutes: 30 steps: - name: Check out exact source - uses: actions/checkout@v4 + uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - name: Verify candidate identity + env: + EXPECTED_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" - name: Configure Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: "3.13" cache: pip @@ -37,9 +46,9 @@ jobs: run: python scripts/p7_acceptance.py - name: Upload verification evidence if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: - name: certification-forge-evidence-${{ github.sha }} + name: certification-forge-evidence-${{ github.event.pull_request.head.sha || github.sha }} path: artifacts/ if-no-files-found: error retention-days: 30 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..446e95b --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,42 @@ +name: certification-forge-codeql + +"on": + push: + branches: + - main + - feat/p5-adapter-execution + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + analyze: + name: CodeQL + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + security-events: write + steps: + - name: Check out exact source + uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - name: Verify candidate identity + env: + EXPECTED_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" + - name: Initialize Python analysis + uses: github/codeql-action/init@v4 + with: + languages: python + build-mode: none + - name: Analyze and upload results + uses: github/codeql-action/analyze@v4 + with: + category: /language:python + ref: ${{ github.event_name == 'pull_request' && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }} + sha: ${{ github.event.pull_request.head.sha || github.sha }} diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..3c1d713 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,30 @@ +# Deployment readiness + +`deploy_forge.sh` uses `readiness.sh` for staging, production, and rollback +health polling. Keep the two files together when invoking the deployment script. + +`CERTFORGE_READY_TIMEOUT_S` defaults to `90` and accepts a decimal integer from +`1` through `1800`, without leading zeroes. Invalid values fail before locking, +fetching, or changing deployment state. Each wait has a fresh elapsed-time +deadline; failed HTTP probes consume that budget. Each connection/response is +limited to the smaller of two seconds and the remaining budget. A healthy probe +returns immediately. Shell clock granularity and the polling pause can add a +subsecond tolerance; service-manager and listener-ownership commands are separate +from the HTTP budget. + +Staging failures retain the service log under +`$STATE_ROOT/deploy-logs/staging-.log`. Listener ownership, signed +readiness verification, live-smoke gates, and rollback requirements still apply. + +# Source validation and release evidence + +The `CI` Actions job runs the full deterministic suite with coverage and P1, P6, +and P7 verification. The `CodeQL` job performs Python security analysis and uploads +its results. Both check out the immutable PR head (or event SHA on push), verify +that checkout, and bind their output to it. Their names match Release Sentinel's +required authentic checks; neither is a substitute status or a release verdict. + +A green source-validation run does not prove current production identity or +provide signed production E2E evidence. Every changed candidate needs fresh +exact-SHA certification. Historical smoke reports and old certified tags must not +be reused as certification for a new commit. diff --git a/deploy/deploy_forge.sh b/deploy/deploy_forge.sh index 7301e49..4785c0c 100644 --- a/deploy/deploy_forge.sh +++ b/deploy/deploy_forge.sh @@ -20,6 +20,11 @@ CURRENT_LINK="${CERTFORGE_CURRENT_LINK:-/home/forge/echo-certification-forge-cur STATE_ROOT="${CERTFORGE_STATE_ROOT:-/home/forge/echo-certification-forge/var}" PRODUCTION_E2E_ATTESTATION_DIR="${ECHO_CERTFORGE_PRODUCTION_E2E_ATTESTATION_DIR:-$STATE_ROOT/production-e2e/attestations}" PRODUCTION_E2E_TRUSTED_KEYS="${ECHO_CERTFORGE_TRUSTED_PRODUCTION_E2E_KEYS:-$STATE_ROOT/production-e2e/trusted-public-keys}" +# Readiness waits bound /healthz probes and return on the first healthy probe. +# 20s proved too short for a cold uvicorn boot on a loaded FORGE (staging abort, +# false rollback-restore failure, needless production rollback). +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/readiness.sh" +configure_readiness_timeout "${CERTFORGE_READY_TIMEOUT_S:-90}" ADAPTER_DIR="${ECHO_CERTFORGE_PROD_ADAPTER_DIR:-$STATE_ROOT/p5}" ADAPTER_MODE="${CERTFORGE_ADAPTER_MODE:-required}" # Canonical-JSON digest of policies/mandatory-rules.v2.json (== run_worker._PRODUCTION_MANIFEST_SHA256). @@ -265,13 +270,16 @@ ECHO_CERTFORGE_API_KEY_PEPPER="$STAGING_PEPPER" \ STAGING_PID=$! ready=0 -for _ in $(seq 1 40); do +readiness_start +while readiness_pending; do kill -0 "$STAGING_PID" 2>/dev/null || { echo "!! staging process exited before readiness" - tail -20 "$STAGING_ROOT/service.log" + mkdir -p "$STATE_ROOT/deploy-logs" && + cp -f "$STAGING_ROOT/service.log" "$STATE_ROOT/deploy-logs/staging-$RELEASE_ID.log" 2>/dev/null || true + tail -40 "$STAGING_ROOT/service.log" exit 1 } - curl -sf "http://127.0.0.1:$STAGING_PORT/healthz" >/dev/null 2>&1 && { + readiness_probe "$STAGING_PORT" >/dev/null 2>&1 && { if ss -H -ltnp "sport = :$STAGING_PORT" | grep -q "pid=$STAGING_PID,"; then ready=1 else @@ -280,11 +288,16 @@ for _ in $(seq 1 40); do fi break } - sleep 0.5 + readiness_pause done if [ "$ready" != 1 ]; then echo "!! staging never became healthy" - tail -20 "$STAGING_ROOT/service.log" + echo " waited ${READY_TIMEOUT_S}s; set CERTFORGE_READY_TIMEOUT_S to adjust" + curl -sS -m 5 -o /dev/null -w " /healthz probe: HTTP %{http_code}\n" \ + "http://127.0.0.1:$STAGING_PORT/healthz" 2>&1 || true + mkdir -p "$STATE_ROOT/deploy-logs" && + cp -f "$STAGING_ROOT/service.log" "$STATE_ROOT/deploy-logs/staging-$RELEASE_ID.log" 2>/dev/null || true + tail -40 "$STAGING_ROOT/service.log" exit 1 fi @@ -427,13 +440,14 @@ rollback_production() { if [ "$PREV_ACTIVE" = "active" ]; then sudo systemctl start "$SERVICE.service" || rollback_status=1 restored=0 - for _ in $(seq 1 40); do + readiness_start + while readiness_pending; do service_owns_port "$PROD_PORT" && - curl -sf "http://127.0.0.1:$PROD_PORT/healthz" >/dev/null 2>&1 && { + readiness_probe "$PROD_PORT" >/dev/null 2>&1 && { restored=1 break } - sleep 0.5 + readiness_pause done [ "$restored" = 1 ] || rollback_status=1 else @@ -634,13 +648,14 @@ sudo systemctl enable "$DISPATCH_SERVICE.service" echo "== [8/9] production health + live-smoke ==" ready=0 -for _ in $(seq 1 40); do +readiness_start +while readiness_pending; do service_owns_port "$PROD_PORT" && - curl -sf "http://127.0.0.1:$PROD_PORT/healthz" >/dev/null 2>&1 && { + readiness_probe "$PROD_PORT" >/dev/null 2>&1 && { ready=1 break } - sleep 0.5 + readiness_pause done if [ "$ready" != 1 ] || ! ECHO_CERTFORGE_DB="$DB_PATH" \ ECHO_CERTFORGE_SUBSCRIBER_POLICY="$RELEASE_DIR/policies/subscriber-governance.v1.json" \ diff --git a/deploy/readiness.sh b/deploy/readiness.sh new file mode 100644 index 0000000..9fec7ef --- /dev/null +++ b/deploy/readiness.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Sourced by deploy_forge.sh; defining these helpers has no side effects. + +configure_readiness_timeout() { + local value="$1" + # Validate before arithmetic: Bash otherwise accepts expressions and overflows. + if [[ ! "$value" =~ ^[1-9][0-9]{0,3}$ ]] || (( value > 1800 )); then + echo "!! CERTFORGE_READY_TIMEOUT_S must be an integer between 1 and 1800" >&2 + return 1 + fi + READY_TIMEOUT_S="$value" +} + +readiness_start() { + READY_DEADLINE=$(( SECONDS + READY_TIMEOUT_S )) +} + +readiness_pending() { + (( SECONDS < READY_DEADLINE )) +} + +readiness_probe() { + local port="$1" + local remaining=$(( READY_DEADLINE - SECONDS )) + (( remaining > 0 )) || return 1 + # Each probe consumes the existing deadline, never a fresh total budget. + # Small individual probes allow the caller to recheck process ownership. + local probe_timeout="$remaining" + (( probe_timeout <= 2 )) || probe_timeout=2 + curl -sf "http://127.0.0.1:$port/healthz" \ + --connect-timeout "$probe_timeout" --max-time "$probe_timeout" +} + +readiness_pause() { + # SECONDS has one-second resolution; the polling pause adds at most 0.5s. + # Callers retain their process ownership checks outside these HTTP helpers. + if readiness_pending; then + sleep 0.5 + fi +} diff --git a/pyproject.toml b/pyproject.toml index 3f1a10e..0d44c78 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ requires-python = ">=3.12" license = {text = "Proprietary"} authors = [{name = "ECHO-OMEGA-PRIME", email = "bobbymcwilliams@echo-op.com"}] dependencies = [ - "cryptography>=44.0.0", + "cryptography>=50.0.0", "fastapi>=0.115.0", "pydantic>=2.10.0", "uvicorn>=0.34.0", diff --git a/tests/test_deploy_gate.py b/tests/test_deploy_gate.py index 2b9009e..b9d7acd 100644 --- a/tests/test_deploy_gate.py +++ b/tests/test_deploy_gate.py @@ -145,7 +145,7 @@ def test_deploy_gate_rejects_dead_staging_process() -> None: ) process_check = DEPLOY_SCRIPT.index('kill -0 "$STAGING_PID"') health_check = DEPLOY_SCRIPT.index( - 'curl -sf "http://127.0.0.1:$STAGING_PORT/healthz"' + 'readiness_probe "$STAGING_PORT"' ) ownership_check = DEPLOY_SCRIPT.index( 'grep -q "pid=$STAGING_PID,"' diff --git a/tests/test_deploy_readiness.py b/tests/test_deploy_readiness.py new file mode 100644 index 0000000..e442dc9 --- /dev/null +++ b/tests/test_deploy_readiness.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +import os +import re +import shutil +import subprocess +import time +from pathlib import Path + +import pytest + + +HELPER = Path(__file__).resolve().parents[1] / "deploy" / "readiness.sh" + + +def test_deploy_validates_before_side_effects_and_bounds_all_health_waits() -> None: + script = HELPER.with_name("deploy_forge.sh").read_text(encoding="utf-8") + source = script.index('/readiness.sh"') + configure = script.index('configure_readiness_timeout "${CERTFORGE_READY_TIMEOUT_S:-90}"') + lock = script.index('exec 9>"$LOCK_FILE"') + fetch = script.index('git "${GITC[@]}" fetch') + assert source < configure < lock < fetch + waits = re.findall( + r"^[ \t]*readiness_start\n[ \t]*while readiness_pending; do\n(.*?)^[ \t]*done$", + script, + flags=re.MULTILINE | re.DOTALL, + ) + assert len(waits) == 3 + assert sum('readiness_probe "$STAGING_PORT"' in wait for wait in waits) == 1 + assert sum('readiness_probe "$PROD_PORT"' in wait for wait in waits) == 2 + assert all("readiness_pause" in wait for wait in waits) + assert "READY_POLLS" not in script + + +def _bash() -> str: + # Windows' System32/bash.exe may only be an unconfigured WSL launcher. + if os.name == "nt": + git_bash = Path(os.environ.get("ProgramFiles", "C:/Program Files")) / "Git/bin/bash.exe" + if git_bash.is_file(): + return str(git_bash) + executable = shutil.which("bash") + if executable is None: + pytest.skip("Bash is required for deploy readiness behavior tests") + return executable + + +def _run(body: str, *args: str, timeout: float = 5) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + _bash(), "--noprofile", "--norc", "-c", + 'set -euo pipefail\nsource "$1"\nshift\n' + body, + "readiness-test", HELPER.as_posix(), *args, + ], + text=True, + capture_output=True, + timeout=timeout, + check=False, + ) + + +@pytest.mark.parametrize("value", ["1", "90", "1800"]) +def test_readiness_accepts_supported_timeout(value: str) -> None: + result = _run('configure_readiness_timeout "$1"\nprintf "%s" "$READY_TIMEOUT_S"', value) + assert result.returncode == 0, result.stderr + assert result.stdout == value + + +@pytest.mark.parametrize( + "value", + ["", "0", "-1", "1801", "090", "08", "1.5", "1+2", " 90", "90 ", "x", "9" * 40], +) +def test_readiness_rejects_invalid_configuration_before_continuing(value: str) -> None: + result = _run( + 'configure_readiness_timeout "$1"\nprintf "UNEXPECTED SIDE EFFECT"', value + ) + assert result.returncode != 0 + assert result.stdout == "" + assert "must be an integer between 1 and 1800" in result.stderr + + +def test_readiness_returns_on_first_success_without_polling_delay() -> None: + result = _run( + """ +configure_readiness_timeout 90 +curl() { printf 'healthy'; } +sleep() { printf 'UNEXPECTED DELAY'; return 1; } +readiness_start +while readiness_pending; do + if readiness_probe 8311; then + exit 0 + fi + readiness_pause +done +exit 1 +""" + ) + assert result.returncode == 0, result.stderr + assert result.stdout == "healthy" + + +def test_readiness_allows_cold_start_beyond_old_twenty_second_budget() -> None: + result = _run( + """ +configure_readiness_timeout 90 +SECONDS=100 +attempts=0 +curl() { + attempts=$(( attempts + 1 )) + SECONDS=$(( SECONDS + 1 )) + (( attempts == 25 )) +} +sleep() { :; } +readiness_start +while readiness_pending; do + if readiness_probe 8311; then + printf '%s' "$attempts" + exit 0 + fi + readiness_pause +done +exit 1 +""" + ) + assert result.returncode == 0, result.stderr + assert result.stdout == "25" + + +def test_failed_probe_time_consumes_total_deadline() -> None: + result = _run( + """ +configure_readiness_timeout 3 +SECONDS=100 +attempts=0 +curl() { + attempts=$(( attempts + 1 )) + SECONDS=$(( SECONDS + 1 )) + return 22 +} +sleep() { :; } +readiness_start +while readiness_pending; do + if readiness_probe 8311; then + exit 1 + fi + readiness_pause +done +printf '%s' "$attempts" +""" + ) + assert result.returncode == 0, result.stderr + assert result.stdout == "3" + + +@pytest.mark.parametrize("remaining, expected", [(90, "2"), (2, "2"), (1, "1")]) +def test_probe_bounds_connection_and_response_to_remaining_budget( + remaining: int, expected: str +) -> None: + result = _run( + """ +configure_readiness_timeout "$1" +SECONDS=100 +curl() { printf '%s\n' "$@"; } +readiness_start +readiness_probe 8311 +""", + str(remaining), + ) + assert result.returncode == 0, result.stderr + assert result.stdout.splitlines() == [ + "-sf", "http://127.0.0.1:8311/healthz", + "--connect-timeout", expected, "--max-time", expected, + ] + + +def test_expired_budget_neither_probes_nor_sleeps() -> None: + result = _run( + """ +configure_readiness_timeout 1 +SECONDS=100 +curl() { printf 'UNEXPECTED PROBE'; } +sleep() { printf 'UNEXPECTED DELAY'; } +readiness_start +SECONDS=$(( SECONDS + 1 )) +if readiness_pending || readiness_probe 8311; then + exit 1 +fi +readiness_pause +""" + ) + assert result.returncode == 0, result.stderr + assert result.stdout == "" + + +def test_stalled_probe_uses_finite_response_timeout() -> None: + started = time.monotonic() + result = _run( + """ +configure_readiness_timeout 1 +curl() { + local max_time='' + while (( $# )); do + if [[ "$1" == --max-time ]]; then + max_time="$2" + shift + fi + shift + done + [[ "$max_time" == 1 ]] || return 99 + # Simulate curl accepting a connection but receiving no response. No network. + sleep "$max_time" + return 28 +} +readiness_start +if readiness_probe 8311; then + exit 1 +else + status=$? +fi +printf '%s' "$status" +""" + ) + elapsed = time.monotonic() - started + assert result.returncode == 0, result.stderr + assert result.stdout == "28" + assert 0.8 <= elapsed < 5 diff --git a/tests/test_package_dependency_policy.py b/tests/test_package_dependency_policy.py new file mode 100644 index 0000000..bfe83e1 --- /dev/null +++ b/tests/test_package_dependency_policy.py @@ -0,0 +1,17 @@ +"""Package-install policy, separate from the stdlib-only certification journey.""" +from pathlib import Path +import tomllib + +from packaging.requirements import Requirement + + +def test_package_install_rejects_vulnerable_cryptography_versions() -> None: + # The deployment venv installs pyproject.toml, independently of the image lock. + manifest = Path(__file__).resolve().parents[1] / "pyproject.toml" + dependencies = tomllib.loads(manifest.read_text(encoding="utf-8"))["project"]["dependencies"] + requirement = next( + item for item in map(Requirement, dependencies) if item.name == "cryptography" + ) + for vulnerable in ("44.0.0", "49.0.0", "49.0.1"): + assert not requirement.specifier.contains(vulnerable) + assert requirement.specifier.contains("50.0.0")