From a22f3a616280a36dc8ccc03124756619ded83ad2 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Tue, 20 Oct 2026 09:01:03 -0700 Subject: [PATCH 1/9] fix: distrust compromised eMudhra CA chains across AWF Apply a shared SPKI distrust policy to controlled Node and OpenSSL trust paths, stage sanitized host CA stores without modifying host inputs, build gh with patched Go, and authenticate release bootstrap with constrained provenance. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 89097bc0-0800-4855-965e-ceaab0cef6d3 --- .github/workflows/release.yml | 27 +- README.md | 1 + action.yml | 110 ++++--- containers/agent/Dockerfile | 9 +- containers/agent/Dockerfile.minimal | 4 +- containers/agent/entrypoint.sh | 144 ++++----- containers/agent/trust/distrusted-spki.txt | 12 + containers/agent/trust/filter-ca-store.sh | 62 ++++ containers/agent/trust/install-node-trust.sh | 14 + .../agent/trust/install-system-trust.sh | 11 + containers/agent/trust/node-trust.cjs | 79 +++++ containers/api-proxy/Dockerfile | 8 +- .../api-proxy/trust/distrusted-spki.txt | 12 + containers/api-proxy/trust/filter-ca-store.sh | 62 ++++ .../api-proxy/trust/install-node-trust.sh | 14 + .../api-proxy/trust/install-system-trust.sh | 11 + containers/api-proxy/trust/node-trust.cjs | 79 +++++ containers/build-tools/Dockerfile | 8 +- .../build-tools/trust/distrusted-spki.txt | 12 + .../build-tools/trust/filter-ca-store.sh | 62 ++++ .../build-tools/trust/install-system-trust.sh | 11 + containers/cli-proxy/Dockerfile | 8 +- containers/cli-proxy/entrypoint.sh | 9 +- .../cli-proxy/trust/distrusted-spki.txt | 12 + containers/cli-proxy/trust/filter-ca-store.sh | 62 ++++ .../cli-proxy/trust/install-node-trust.sh | 14 + .../cli-proxy/trust/install-system-trust.sh | 11 + containers/cli-proxy/trust/node-trust.cjs | 79 +++++ containers/cloud-hypervisor/Dockerfile | 5 + containers/dind-ubuntu/Dockerfile | 4 + .../dind-ubuntu/trust/distrusted-spki.txt | 12 + .../dind-ubuntu/trust/filter-ca-store.sh | 62 ++++ .../dind-ubuntu/trust/install-node-trust.sh | 14 + .../dind-ubuntu/trust/install-system-trust.sh | 11 + containers/dind-ubuntu/trust/node-trust.cjs | 79 +++++ containers/enclave/Dockerfile | 21 +- containers/gh-aw-node/Dockerfile | 5 + .../gh-aw-node/trust/distrusted-spki.txt | 12 + .../gh-aw-node/trust/filter-ca-store.sh | 62 ++++ .../gh-aw-node/trust/install-node-trust.sh | 14 + .../gh-aw-node/trust/install-system-trust.sh | 11 + containers/gh-aw-node/trust/node-trust.cjs | 79 +++++ containers/router/.dockerignore | 2 + containers/router/Dockerfile | 6 + containers/router/trust/distrusted-spki.txt | 12 + containers/router/trust/filter-ca-store.sh | 62 ++++ .../router/trust/install-system-trust.sh | 11 + containers/squid/Dockerfile | 2 + containers/squid/trust/distrusted-spki.txt | 12 + containers/squid/trust/filter-ca-store.sh | 62 ++++ .../squid/trust/install-system-trust.sh | 11 + containers/trust/distrusted-spki.txt | 12 + containers/trust/filter-ca-store.sh | 62 ++++ containers/trust/install-node-trust.sh | 14 + containers/trust/install-system-trust.sh | 11 + containers/trust/node-trust.cjs | 79 +++++ docs/releasing.md | 49 ++++ docs/security-ca-trust.md | 99 +++++++ install.sh | 67 ++++- .../ci/release-bootstrap-provenance.test.ts | 274 ++++++++++++++++++ scripts/security/ca-trust.test.ts | 199 +++++++++++++ scripts/security/chroot-trust-smoke.sh | 36 +++ scripts/security/filter-ca-store.sh | 62 ++++ scripts/security/fixtures/emsign-ecc-g3.pem | 15 + scripts/security/generate-trust-policy.cjs | 41 +++ scripts/security/install-node-trust.sh | 14 + scripts/security/install-system-trust.sh | 11 + scripts/security/tls-runtime-smoke.cjs | 88 ++++++ src/cli.ts | 1 + src/squid-config-ssl-bump.test.ts | 2 + src/squid/ssl-bump.ts | 3 + src/tls-trust-policy.ts | 75 +++++ tests/entrypoint-phase-functions.test.sh | 50 ++-- 73 files changed, 2615 insertions(+), 167 deletions(-) create mode 100644 containers/agent/trust/distrusted-spki.txt create mode 100644 containers/agent/trust/filter-ca-store.sh create mode 100644 containers/agent/trust/install-node-trust.sh create mode 100644 containers/agent/trust/install-system-trust.sh create mode 100644 containers/agent/trust/node-trust.cjs create mode 100644 containers/api-proxy/trust/distrusted-spki.txt create mode 100644 containers/api-proxy/trust/filter-ca-store.sh create mode 100644 containers/api-proxy/trust/install-node-trust.sh create mode 100644 containers/api-proxy/trust/install-system-trust.sh create mode 100644 containers/api-proxy/trust/node-trust.cjs create mode 100644 containers/build-tools/trust/distrusted-spki.txt create mode 100644 containers/build-tools/trust/filter-ca-store.sh create mode 100644 containers/build-tools/trust/install-system-trust.sh create mode 100644 containers/cli-proxy/trust/distrusted-spki.txt create mode 100644 containers/cli-proxy/trust/filter-ca-store.sh create mode 100644 containers/cli-proxy/trust/install-node-trust.sh create mode 100644 containers/cli-proxy/trust/install-system-trust.sh create mode 100644 containers/cli-proxy/trust/node-trust.cjs create mode 100644 containers/dind-ubuntu/trust/distrusted-spki.txt create mode 100644 containers/dind-ubuntu/trust/filter-ca-store.sh create mode 100644 containers/dind-ubuntu/trust/install-node-trust.sh create mode 100644 containers/dind-ubuntu/trust/install-system-trust.sh create mode 100644 containers/dind-ubuntu/trust/node-trust.cjs create mode 100644 containers/gh-aw-node/trust/distrusted-spki.txt create mode 100644 containers/gh-aw-node/trust/filter-ca-store.sh create mode 100644 containers/gh-aw-node/trust/install-node-trust.sh create mode 100644 containers/gh-aw-node/trust/install-system-trust.sh create mode 100644 containers/gh-aw-node/trust/node-trust.cjs create mode 100644 containers/router/trust/distrusted-spki.txt create mode 100644 containers/router/trust/filter-ca-store.sh create mode 100644 containers/router/trust/install-system-trust.sh create mode 100644 containers/squid/trust/distrusted-spki.txt create mode 100644 containers/squid/trust/filter-ca-store.sh create mode 100644 containers/squid/trust/install-system-trust.sh create mode 100644 containers/trust/distrusted-spki.txt create mode 100644 containers/trust/filter-ca-store.sh create mode 100644 containers/trust/install-node-trust.sh create mode 100644 containers/trust/install-system-trust.sh create mode 100644 containers/trust/node-trust.cjs create mode 100644 docs/security-ca-trust.md create mode 100644 scripts/ci/release-bootstrap-provenance.test.ts create mode 100644 scripts/security/ca-trust.test.ts create mode 100644 scripts/security/chroot-trust-smoke.sh create mode 100644 scripts/security/filter-ca-store.sh create mode 100644 scripts/security/fixtures/emsign-ecc-g3.pem create mode 100644 scripts/security/generate-trust-policy.cjs create mode 100644 scripts/security/install-node-trust.sh create mode 100644 scripts/security/install-system-trust.sh create mode 100644 scripts/security/tls-runtime-smoke.cjs create mode 100644 src/tls-trust-policy.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7436c368e..fba27a74d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,7 @@ permissions: contents: write # Required for creating releases, pushing version commits and tags packages: write # Required for pushing to GHCR id-token: write # Required for cosign keyless signing - attestations: write # Required for Cloud Hypervisor test artifact provenance + attestations: write # Required for release and Cloud Hypervisor artifact provenance jobs: bump-version: @@ -1012,6 +1012,30 @@ jobs: cd release sha256sum * > checksums.txt + - name: Bind release tag to checksums + env: + RELEASE_TAG: ${{ needs.bump-version.outputs.version }} + run: | + set -euo pipefail + [[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] + test "$(git rev-parse HEAD)" = "$(git rev-parse "refs/tags/${RELEASE_TAG}^{commit}")" + printf '%s refs/tags/%s\n' "$(sha256sum release/checksums.txt | cut -d ' ' -f1)" \ + "$RELEASE_TAG" > release/release-identity.txt + + - name: Attest AWF release payloads and metadata + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: | + release/awf-linux-x64 + release/awf-linux-arm64 + release/awf-darwin-x64 + release/awf-darwin-arm64 + release/awf-bundle.js + release/awf.tgz + release/checksums.txt + release/containers.txt + release/release-identity.txt + - name: Get previous release tag id: previous_tag run: | @@ -1166,6 +1190,7 @@ jobs: release/audit.schema.json release/token-usage.schema.json release/checksums.txt + release/release-identity.txt env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index 065933533..d3a462c1d 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ See [GitHub Actions](docs/github_actions.md) for advanced setup and `awf logs su - [Environment variables](docs/environment.md) — passing environment variables to containers - [Logging quick reference](docs/logging_quickref.md) and [Squid log filtering](docs/squid_log_filtering.md) — view and filter traffic - [Security model](docs/security.md) — what the firewall protects and how +- [Emergency CA distrust](docs/security-ca-trust.md) — compromised CA mitigations, rollout, and runtime trust boundaries - [Architecture](docs/architecture.md) — how Squid, Docker, and iptables fit together - [Filesystem Sharing Contract](docs/filesystem-sharing-contract.md) — component identities, host permissions, generated access inventory, and runtime verification - [Compatibility](docs/compatibility.md) — supported Node.js, OS, and Docker versions diff --git a/action.yml b/action.yml index fac3ebde1..4477a8ca7 100644 --- a/action.yml +++ b/action.yml @@ -54,6 +54,25 @@ runs: BINARY_NAME="awf-linux-x64" INSTALL_DIR="${RUNNER_TEMP}/awf-bin" + # gh itself and this action must already come from a trusted channel. + if ! command -v gh &> /dev/null || \ + ! gh attestation verify --help 2>/dev/null | grep -q -- '--signer-workflow'; then + echo "::error::A trusted gh CLI with attestation verify support is required. Install it through an independently authenticated channel." + exit 1 + fi + + verify_provenance() { + if ! gh attestation verify "$1" \ + --hostname github.com \ + --repo "$REPO" \ + --signer-workflow "${REPO}/.github/workflows/release.yml" \ + --cert-oidc-issuer https://token.actions.githubusercontent.com \ + --deny-self-hosted-runners; then + echo "::error::Release provenance verification failed for $(basename "$1"). Legacy unattested releases are not supported; use an attested release and a trusted, authenticated gh CLI." + exit 1 + fi + } + # Build auth headers for GitHub API (Bearer is the recommended format for GITHUB_TOKEN) AUTH_HEADER=() if [ -n "${GITHUB_TOKEN:-}" ]; then @@ -90,28 +109,26 @@ runs: echo "Latest version: $VERSION" else VERSION="$INPUT_VERSION" - # Validate version format (supports v1.0.0, v1.0.0-beta.1, v1.0.0-rc.1, etc.) - if ! echo "$VERSION" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$'; then - echo "::error::Invalid version format: $VERSION. Expected format: v1.0.0 or v1.0.0-beta.1" - exit 1 - fi + fi + # Validate even API-supplied versions before using them in URLs or outputs. + if ! echo "$VERSION" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$'; then + echo "::error::Invalid version format: $VERSION. Expected format: v1.0.0 or v1.0.0-beta.1" + exit 1 fi - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - # Download URLs BASE_URL="https://github.com/${REPO}/releases/download/${VERSION}" BINARY_URL="${BASE_URL}/${BINARY_NAME}" CHECKSUMS_URL="${BASE_URL}/checksums.txt" CONTAINERS_URL="${BASE_URL}/containers.txt" - # Extract image tag (version without 'v' prefix), then augment with digest pins when available + # Extract the version tag, then attach authenticated digest pins. IMAGE_TAG="${VERSION#v}" IMAGE_TAG_WITH_DIGESTS="$IMAGE_TAG" # Download binary echo "Downloading awf ${VERSION}..." - if ! curl -fsSL "$BINARY_URL" -o "$INSTALL_DIR/awf"; then + if ! curl -fsSL "$BINARY_URL" -o "$INSTALL_DIR/$BINARY_NAME.download"; then echo "::error::Failed to download binary from $BINARY_URL" exit 1 fi @@ -123,15 +140,35 @@ runs: exit 1 fi - # Download optional containers digest manifest - echo "Downloading containers manifest (optional)..." + # Bind the checksums to the requested tag before consuming any metadata. + if ! curl -fsSL "${BASE_URL}/release-identity.txt" -o "$INSTALL_DIR/release-identity.txt"; then + echo "::error::Missing release identity for ${VERSION}; legacy unattested releases are not supported" + exit 1 + fi + verify_provenance "$INSTALL_DIR/release-identity.txt" + EXPECTED_IDENTITY="$(sha256sum "$INSTALL_DIR/checksums.txt" | awk '{print $1}') refs/tags/${VERSION}" + if [ "$(cat "$INSTALL_DIR/release-identity.txt")" != "$EXPECTED_IDENTITY" ]; then + echo "::error::Release identity does not bind checksums.txt to refs/tags/${VERSION}" + exit 1 + fi + verify_provenance "$INSTALL_DIR/checksums.txt" + verify_provenance "$INSTALL_DIR/$BINARY_NAME.download" + + # The digest manifest is authenticated, checksum-bound, and required. + echo "Downloading containers manifest..." if curl -fsSL "$CONTAINERS_URL" -o "$INSTALL_DIR/containers.txt"; then + verify_provenance "$INSTALL_DIR/containers.txt" + MANIFEST_SUM="$(sha256sum "$INSTALL_DIR/containers.txt" | awk '{print $1}')" + EXPECTED_MANIFEST_SUM="$(awk '$2 == "containers.txt" {print $1; exit}' "$INSTALL_DIR/checksums.txt")" + if [ "$MANIFEST_SUM" != "$EXPECTED_MANIFEST_SUM" ]; then + echo "::error::containers.txt does not match the authenticated release checksums" + exit 1 + fi extract_digest() { local image_name="$1" grep -E "^ghcr\\.io/${REPO}/${image_name}@sha256:[a-fA-F0-9]{64}$" "$INSTALL_DIR/containers.txt" \ | sed -E 's#.*@(sha256:[a-fA-F0-9]{64})#\1#' \ - | tr '[:upper:]' '[:lower:]' \ - | head -n 1 + | tr '[:upper:]' '[:lower:]' } DIGEST_ENTRIES=() @@ -145,6 +182,15 @@ runs: ENCLAVE_AGENT_DIGEST="$(extract_digest enclave-agent || true)" ENCLAVE_MCP_SERVER_DIGEST="$(extract_digest enclave-mcp-server || true)" + for DIGEST in "$SQUID_DIGEST" "$AGENT_DIGEST" "$AGENT_ACT_DIGEST" \ + "$API_PROXY_DIGEST" "$ROUTER_DIGEST" "$CLI_PROXY_DIGEST" \ + "$ENCLAVE_SCRIPT_DIGEST" "$ENCLAVE_AGENT_DIGEST" "$ENCLAVE_MCP_SERVER_DIGEST"; do + if [[ ! "$DIGEST" =~ ^sha256:[a-f0-9]{64}$ ]]; then + echo "::error::containers.txt is missing a required image digest or contains duplicate entries" + exit 1 + fi + done + [ -n "${SQUID_DIGEST:-}" ] && DIGEST_ENTRIES+=("squid=${SQUID_DIGEST}") [ -n "${AGENT_DIGEST:-}" ] && DIGEST_ENTRIES+=("agent=${AGENT_DIGEST}") [ -n "${AGENT_ACT_DIGEST:-}" ] && DIGEST_ENTRIES+=("agent-act=${AGENT_ACT_DIGEST}") @@ -159,15 +205,12 @@ runs: DIGEST_CSV="$(IFS=,; echo "${DIGEST_ENTRIES[*]}")" IMAGE_TAG_WITH_DIGESTS="${IMAGE_TAG},${DIGEST_CSV}" echo "Discovered digest pins for ${#DIGEST_ENTRIES[@]} image(s)" - else - echo "::warning::containers.txt downloaded but no valid digest entries matched ghcr.io/${REPO}/@sha256:; falling back to tag-only image metadata." fi else - echo "::warning::No containers.txt found for ${VERSION}; falling back to tag-only image metadata" + echo "::error::Missing containers.txt for ${VERSION}; tag-only fallback is not permitted" + exit 1 fi - echo "image_tag=$IMAGE_TAG_WITH_DIGESTS" >> "$GITHUB_OUTPUT" - # Verify checksum echo "Verifying SHA256 checksum..." @@ -192,7 +235,7 @@ runs: # Normalize to lowercase for comparison EXPECTED_SUM=$(echo "$EXPECTED_SUM" | tr '[:upper:]' '[:lower:]') - ACTUAL_SUM=$(sha256sum "$INSTALL_DIR/awf" | awk '{print $1}' | tr '[:upper:]' '[:lower:]') + ACTUAL_SUM=$(sha256sum "$INSTALL_DIR/$BINARY_NAME.download" | awk '{print $1}' | tr '[:upper:]' '[:lower:]') if [ "$EXPECTED_SUM" != "$ACTUAL_SUM" ]; then echo "::error::Checksum verification failed!" @@ -204,18 +247,21 @@ runs: echo "Checksum verification passed ✓" # Verify it's a valid ELF executable - if ! file "$INSTALL_DIR/awf" | grep -q "ELF.*executable"; then + if ! file "$INSTALL_DIR/$BINARY_NAME.download" | grep -q "ELF.*executable"; then echo "::error::Downloaded file is not a valid Linux executable" exit 1 fi # Make executable - chmod +x "$INSTALL_DIR/awf" + chmod +x "$INSTALL_DIR/$BINARY_NAME.download" + mv "$INSTALL_DIR/$BINARY_NAME.download" "$INSTALL_DIR/awf" # Clean up downloaded metadata files - rm -f "$INSTALL_DIR/checksums.txt" "$INSTALL_DIR/containers.txt" + rm -f "$INSTALL_DIR/checksums.txt" "$INSTALL_DIR/containers.txt" "$INSTALL_DIR/release-identity.txt" # Add to PATH + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "image_tag=$IMAGE_TAG_WITH_DIGESTS" >> "$GITHUB_OUTPUT" echo "$INSTALL_DIR" >> "$GITHUB_PATH" echo "Successfully installed awf ${VERSION} to $INSTALL_DIR" @@ -231,30 +277,28 @@ runs: REGISTRY="ghcr.io/github/gh-aw-firewall" - BASE_TAG="${IMAGE_TAG%%,*}" extract_digest_from_tag() { local key="$1" echo "$IMAGE_TAG" | tr ',' '\n' | grep -E "^${key}=sha256:[a-fA-F0-9]{64}$" | cut -d'=' -f2 | tr '[:upper:]' '[:lower:]' | head -n 1 } SQUID_DIGEST="$(extract_digest_from_tag squid || true)" AGENT_DIGEST="$(extract_digest_from_tag agent || true)" - SQUID_REF="${REGISTRY}/squid:${BASE_TAG}${SQUID_DIGEST:+@${SQUID_DIGEST}}" - AGENT_REF="${REGISTRY}/agent:${BASE_TAG}${AGENT_DIGEST:+@${AGENT_DIGEST}}" + if [[ ! "$SQUID_DIGEST" =~ ^sha256:[a-f0-9]{64}$ ]] || \ + [[ ! "$AGENT_DIGEST" =~ ^sha256:[a-f0-9]{64}$ ]]; then + echo "::error::Authenticated squid and agent digest pins are required" + exit 1 + fi + SQUID_REF="${REGISTRY}/squid@${SQUID_DIGEST}" + AGENT_REF="${REGISTRY}/agent@${AGENT_DIGEST}" echo "Pulling awf Docker images with tag: ${IMAGE_TAG}" # Pull squid image echo "Pulling ${SQUID_REF}..." - if ! docker pull "${SQUID_REF}"; then - echo "::warning::Failed to pull squid image ${SQUID_REF}, trying 'latest'" - docker pull "${REGISTRY}/squid:latest" - fi + docker pull "${SQUID_REF}" # Pull agent image echo "Pulling ${AGENT_REF}..." - if ! docker pull "${AGENT_REF}"; then - echo "::warning::Failed to pull agent image ${AGENT_REF}, trying 'latest'" - docker pull "${REGISTRY}/agent:latest" - fi + docker pull "${AGENT_REF}" echo "Docker images pulled successfully ✓" diff --git a/containers/agent/Dockerfile b/containers/agent/Dockerfile index fec6ee4f1..01bdc5035 100644 --- a/containers/agent/Dockerfile +++ b/containers/agent/Dockerfile @@ -7,9 +7,9 @@ # NOTE: ARG declared before first FROM is global and available in all FROM statements ARG BASE_IMAGE=default-base -FROM golang:1.27.2-alpine@sha256:f92b6ef800e499660581efdabdf25d9d817a9d124eaf900924f0504e7e27e12d AS gh-build +FROM golang:1.27.3-alpine@sha256:f2c872bcee949b7be9c490efd02b48a8a2fbf947cdf533f1c58525ec9d8170fd AS gh-build -ENV GOTOOLCHAIN=go1.27.2 +ENV GOTOOLCHAIN=go1.27.3 ARG GH_VERSION=2.98.0 ARG GH_SOURCE_SHA256=abada9e8b550547ac93f99250f3ad4d90ad623fa245cb54cb058f78030a6a5f6 @@ -24,6 +24,7 @@ RUN wget -q "https://github.com/cli/cli/archive/refs/tags/v${GH_VERSION}.tar.gz" && go get "google.golang.org/grpc@v${GH_GRPC_VERSION}" "golang.org/x/crypto@v${GH_X_CRYPTO_VERSION}" "golang.org/x/net@v${GH_X_NET_VERSION}" \ && CGO_ENABLED=0 GH_VERSION="${GH_VERSION}" SOURCE_DATE_EPOCH="${GH_SOURCE_DATE_EPOCH}" \ go run ./script/build.go bin/gh \ + && go version -m bin/gh | head -n 1 | grep -Eq ': go1\.27\.3$' \ && go version -m bin/gh | awk -v version="v${GH_GRPC_VERSION}" \ '$1 == "dep" && $2 == "google.golang.org/grpc" && $3 == version { found=1 } END { exit !found }' \ && go version -m bin/gh | awk -v version="v${GH_X_CRYPTO_VERSION}" \ @@ -370,4 +371,8 @@ RUN chmod +x /usr/bin/docker WORKDIR /workspace # Use entrypoint to setup iptables and run command +COPY trust/ /opt/awf/trust/ +RUN sh /opt/awf/trust/install-system-trust.sh \ + && sh /opt/awf/trust/install-node-trust.sh + ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/containers/agent/Dockerfile.minimal b/containers/agent/Dockerfile.minimal index 6997df767..ebb7c459c 100644 --- a/containers/agent/Dockerfile.minimal +++ b/containers/agent/Dockerfile.minimal @@ -16,7 +16,7 @@ RUN apt-get update && \ apt-get install -y --no-install-recommends \ iptables \ iproute2 \ - git && \ + git openssl ca-certificates && \ rm -rf /var/lib/apt/lists/* # Create non-root user with UID/GID matching host user @@ -43,6 +43,8 @@ RUN if ! getent group awfuser >/dev/null 2>&1; then \ # Copy entrypoint scripts COPY setup-iptables.sh /usr/local/bin/setup-iptables.sh COPY entrypoint.sh /usr/local/bin/entrypoint.sh +COPY trust/ /opt/awf/trust/ +RUN sh /opt/awf/trust/install-system-trust.sh RUN chmod +x /usr/local/bin/setup-iptables.sh /usr/local/bin/entrypoint.sh # Set working directory diff --git a/containers/agent/entrypoint.sh b/containers/agent/entrypoint.sh index 317189b52..560d24279 100644 --- a/containers/agent/entrypoint.sh +++ b/containers/agent/entrypoint.sh @@ -156,6 +156,7 @@ if [ "${AWF_SSL_BUMP_ENABLED}" = "true" ]; then echo "[entrypoint][WARN] SSL Bump enabled but CA certificate not found" fi fi +sh /opt/awf/trust/install-system-trust.sh } wait_for_iptables() { @@ -1134,99 +1135,64 @@ copy_awf_ca_cert() { } copy_system_ca_bundle() { - # Detect and copy the host system CA bundle to a chroot-accessible path. - # On Amazon Linux / RHEL-family systems, the CA bundle often lives under - # /etc/pki/. This function finds the system bundle and, when it is not already - # accessible in the chroot, copies it to /run/awf-lib/ so TLS works regardless - # of distro. - # - # In SSL Bump mode, the AWF CA must remain the active trust bundle for MITM - # proxy validation. We only append the system bundle to that staged AWF CA. - SYSTEM_CA_CHROOT="" - - if [ "${AWF_SSL_BUMP_ENABLED}" = "true" ]; then - if [ -z "$AWF_CA_CHROOT" ] || [ ! -f "/host${AWF_CA_CHROOT}" ]; then - echo "[entrypoint][WARN] SSL Bump enabled but chroot AWF CA bundle is unavailable — preserving existing TLS env vars" - return - fi - - # SSL Bump already configured TLS env vars; detect system bundle and append - # it to the AWF CA so tools trust both the AWF proxy CA AND the upstream CAs. - local SYSTEM_BUNDLE="" - for candidate in \ - /host/etc/ssl/certs/ca-certificates.crt \ - /host/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem \ - /host/etc/pki/tls/certs/ca-bundle.crt \ - /host/etc/pki/tls/cert.pem \ - /host/etc/ssl/cert.pem; do - if [ -s "$candidate" ]; then - SYSTEM_BUNDLE="$candidate" - break - fi - done - if [ -n "$SYSTEM_BUNDLE" ]; then - # Append system bundle to the AWF CA cert so both are trusted - if { printf '\n'; cat "$SYSTEM_BUNDLE"; } >> "/host${AWF_CA_CHROOT}" 2>/dev/null; then - echo "[entrypoint] System CA bundle ($SYSTEM_BUNDLE) appended to AWF CA cert" - fi - fi - return - fi - - # No SSL Bump — detect and expose the system CA bundle for chroot. - local SYSTEM_BUNDLE="" - for candidate in \ - /host/etc/ssl/certs/ca-certificates.crt \ - /host/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem \ - /host/etc/pki/tls/certs/ca-bundle.crt \ - /host/etc/pki/tls/cert.pem \ - /host/etc/ssl/cert.pem; do - if [ -s "$candidate" ]; then - SYSTEM_BUNDLE="$candidate" - break - fi + # Never point TLS clients straight at a host bundle or hashed CA directory. + # Build a private store before covering mounted trust paths in this namespace. + local trust="/host/run/awf-lib/trust" + local inputs=() candidate + local directories=( + /host/etc/ssl/certs + /host/etc/pki/ca-trust/extracted/pem + /host/etc/pki/tls/certs + ) + for candidate in "${directories[@]}" \ + /host/etc/ssl/cert.pem /host/etc/pki/tls/cert.pem; do + [ ! -e "$candidate" ] || inputs+=("$candidate") done - - if [ -z "$SYSTEM_BUNDLE" ]; then - echo "[entrypoint][WARN] No system CA bundle found — TLS may fail inside chroot" - return - fi - - # Check if the bundle is already accessible inside the chroot via existing mounts. - # AWF mounts the common CA roots under /etc/ssl, /etc/ca-certificates, and the - # RHEL/Amazon Linux CA roots under /etc/pki/ca-trust/extracted and /etc/pki/tls/certs. - local CHROOT_RELATIVE="${SYSTEM_BUNDLE#/host}" - case "$CHROOT_RELATIVE" in - /etc/ssl/*|/etc/ca-certificates/*|/etc/pki/ca-trust/extracted/*|/etc/pki/tls/certs/*) - # Already accessible via existing bind mounts - export SSL_CERT_FILE="$CHROOT_RELATIVE" - export NODE_EXTRA_CA_CERTS="$CHROOT_RELATIVE" - export REQUESTS_CA_BUNDLE="$CHROOT_RELATIVE" - export CURL_CA_BUNDLE="$CHROOT_RELATIVE" - export GIT_SSL_CAINFO="$CHROOT_RELATIVE" - echo "[entrypoint] System CA bundle found at $CHROOT_RELATIVE (already accessible in chroot)" - return - ;; - esac - - # Bundle is not accessible in chroot. Copy it. - if mkdir -p /host/run/awf-lib 2>/dev/null; then - if cp "$SYSTEM_BUNDLE" /host/run/awf-lib/system-ca-certificates.crt 2>/dev/null && \ - [ -s /host/run/awf-lib/system-ca-certificates.crt ]; then - local CA_PATH="/run/awf-lib/system-ca-certificates.crt" - SYSTEM_CA_CHROOT="$CA_PATH" - export SSL_CERT_FILE="$CA_PATH" - export NODE_EXTRA_CA_CERTS="$CA_PATH" - export REQUESTS_CA_BUNDLE="$CA_PATH" - export CURL_CA_BUNDLE="$CA_PATH" - export GIT_SSL_CAINFO="$CA_PATH" - echo "[entrypoint] System CA bundle copied from $SYSTEM_BUNDLE to $CA_PATH" + if [ -n "${AWF_CA_CHROOT:-}" ]; then + inputs+=("/host${AWF_CA_CHROOT}") + elif [ -n "${NODE_EXTRA_CA_CERTS:-}" ]; then + if [ -f "/host${NODE_EXTRA_CA_CERTS}" ]; then + inputs+=("/host${NODE_EXTRA_CA_CERTS}") else - echo "[entrypoint][WARN] Could not copy system CA bundle to chroot — TLS may fail" + inputs+=("${NODE_EXTRA_CA_CERTS}") fi - else - echo "[entrypoint][WARN] Could not create /host/run/awf-lib for system CA bundle" fi + if [ "${#inputs[@]}" -eq 0 ]; then + echo "[entrypoint][ERROR] No CA store available for AWF trust policy" >&2 + exit 1 + fi + mkdir -p "$trust" + sh /opt/awf/trust/filter-ca-store.sh "$trust" "${inputs[@]}" + cp /opt/awf/trust/node-trust.cjs "$trust/node-trust.cjs" + # The /run staging root is container-local; none of these operations writes + # through the read-only host /etc bind mounts. + for candidate in "${directories[@]}"; do + [ ! -d "$candidate" ] || { + mount --bind "$trust/certs" "$candidate" + mount -o remount,ro,bind "$candidate" + } + done + for candidate in /host/etc/ssl/cert.pem /host/etc/pki/tls/cert.pem; do + [ ! -f "$candidate" ] || { + mount --bind "$trust/ca-certificates.crt" "$candidate" + mount -o remount,ro,bind "$candidate" + } + done + # Standard CAfile names must also work independently of environment settings. + # The overlaid directories no longer contain their original bundle files. + for candidate in ca-certificates.crt ca-bundle.crt ca-bundle.pem tls-ca-bundle.pem cert.pem; do + cp "$trust/ca-certificates.crt" "$trust/certs/$candidate" + done + chmod -R a-w "$trust" + SYSTEM_CA_CHROOT="/run/awf-lib/trust/ca-certificates.crt" + export SSL_CERT_FILE="$SYSTEM_CA_CHROOT" + export SSL_CERT_DIR="/run/awf-lib/trust/certs" + export NODE_EXTRA_CA_CERTS="$SYSTEM_CA_CHROOT" + export REQUESTS_CA_BUNDLE="$SYSTEM_CA_CHROOT" + export CURL_CA_BUNDLE="$SYSTEM_CA_CHROOT" + export GIT_SSL_CAINFO="$SYSTEM_CA_CHROOT" + export NODE_OPTIONS="${NODE_OPTIONS:+${NODE_OPTIONS} }--require=/run/awf-lib/trust/node-trust.cjs" + echo "[entrypoint] Filtered host CA trust staged at $SYSTEM_CA_CHROOT (host store unchanged)" } check_chroot_prereqs() { diff --git a/containers/agent/trust/distrusted-spki.txt b/containers/agent/trust/distrusted-spki.txt new file mode 100644 index 000000000..79428417c --- /dev/null +++ b/containers/agent/trust/distrusted-spki.txt @@ -0,0 +1,12 @@ +43bffa19d6ef651451f6841e019931e96f6f2eaeff28ab4b6f1ce4576de2717b +49dd353dc2b919a3770645117d0e60c16115e7d1bc259eefe734b00ea434fe5f +5c0660a2b164910d8ff589c18e14552e9e0ec237dcefe2cb736d41a4811f81a0 +63ecefee431d49523bc1136c4e72d0206a477086caf606cd5cf025686c109cf8 +92c063d67e8f14db71d288e11266ffdaf69cbf115f7bab14f3bc2f68d25e0e30 +e0254c7fcdc265e2f0f8a428dabb75ab4ae039b4872d62f5ee57f0877d5a8a41 +ebed6475c2c2d80fcb199f3fe8dd6e6b3ce60b77eb15d4e42c3dec0c6f5b5105 +ec4ffa1ad0ac88081314b7aaffef6a91f97cd57291bb5ba686c5415f5eb4f6b6 +c619e4780ed8e0abeda24ad732567de4383a7ea941338f6d1a7d1b49ac792fe9 +0a6c2b2ca5b364207954171987ac9d2178561569d8205457e77ca90c32ede282 +bd153ed7b0434f6886b17bce8bbe84ed340c7132d702a8f4fa318f756ecbd6f3 +8d417db2dd8bf5e3084d1e3f196d583849d81bdd4c00c70b9d39369e96b8c782 diff --git a/containers/agent/trust/filter-ca-store.sh b/containers/agent/trust/filter-ca-store.sh new file mode 100644 index 000000000..604586b2f --- /dev/null +++ b/containers/agent/trust/filter-ca-store.sh @@ -0,0 +1,62 @@ +#!/bin/sh +# Create a new private store; never rewrite the supplied host/container inputs. +set -eu +output=$1 +shift +policy_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +mkdir -p "$output" +work=$(mktemp -d "$output/.filter.XXXXXX") +trap 'rm -rf "$work"' EXIT HUP INT TERM +: > "$work/input.pem" +for input in "$@"; do + if [ -d "$input" ]; then + for cert in "$input"/*; do + [ -f "$cert" ] || continue + case "$cert" in + *.trust.crt) continue ;; # RHEL AUX trust is represented by its extracted TLS PEM bundle. + *.crt|*.pem|*.[0-9]) cat "$cert" >> "$work/input.pem"; printf '\n' >> "$work/input.pem" ;; + esac + done + elif [ -f "$input" ]; then + cat "$input" >> "$work/input.pem" + printf '\n' >> "$work/input.pem" + else + echo "AWF trust policy: missing CA input: $input" >&2 + exit 1 + fi +done +awk -v dir="$work" ' + /-----BEGIN CERTIFICATE-----/ { if (active) exit 1; active=1; count++; file=dir "/cert-" count ".pem" } + active { print > file } + /-----END CERTIFICATE-----/ { if (!active) exit 1; close(file); active=0 } + /-----BEGIN TRUSTED CERTIFICATE-----/ { exit 1 } + END { if (active || !count) exit 1 } +' "$work/input.pem" || { echo "AWF trust policy: invalid or empty PEM CA store" >&2; exit 1; } +mkdir "$work/certs" +: > "$work/bundle.pem" +for cert in "$work"/cert-*.pem; do + openssl x509 -in "$cert" -pubkey -noout > "$work/key.pem" + openssl pkey -pubin -in "$work/key.pem" -outform DER -out "$work/key.der" + openssl dgst -sha256 "$work/key.der" > "$work/digest" + key=$(awk '{print $NF}' "$work/digest") + if grep -Fqx "$key" "$policy_dir/distrusted-spki.txt"; then + echo "AWF trust policy: removed distrusted CA key $key" >&2 + continue + fi + openssl x509 -in "$cert" -outform DER -out "$work/cert.der" + openssl dgst -sha256 "$work/cert.der" > "$work/digest" + fingerprint=$(awk '{print $NF}' "$work/digest") + if [ ! -f "$work/certs/$fingerprint.pem" ]; then + cp "$cert" "$work/certs/$fingerprint.pem" + cat "$cert" >> "$work/bundle.pem" + fi +done +[ -s "$work/bundle.pem" ] || { echo "AWF trust policy: no trusted certificates remain" >&2; exit 1; } +openssl rehash "$work/certs" +# A fresh destination is required so stale hash links cannot restore trust. +[ ! -e "$output/certs" ] && [ ! -e "$output/ca-certificates.crt" ] || { + echo "AWF trust policy: destination store already exists: $output" >&2 + exit 1 +} +mv "$work/certs" "$output/certs" +mv "$work/bundle.pem" "$output/ca-certificates.crt" diff --git a/containers/agent/trust/install-node-trust.sh b/containers/agent/trust/install-node-trust.sh new file mode 100644 index 000000000..e3f83eb1d --- /dev/null +++ b/containers/agent/trust/install-node-trust.sh @@ -0,0 +1,14 @@ +#!/bin/sh +set -eu +# Use an argv preload, not only NODE_OPTIONS, so caller-supplied Node options +# cannot accidentally disable the policy in AWF-owned runtime images. +if [ ! -e /usr/local/bin/node-awf ]; then + node_path=$(command -v node) + mv "$node_path" /usr/local/bin/node-awf + cat > "$node_path" <<'EOF' +#!/bin/sh +export NODE_OPTIONS="${NODE_OPTIONS:+${NODE_OPTIONS} }--require=/opt/awf/trust/node-trust.cjs" +exec /usr/local/bin/node-awf --require=/opt/awf/trust/node-trust.cjs "$@" +EOF + chmod 0755 "$node_path" +fi diff --git a/containers/agent/trust/install-system-trust.sh b/containers/agent/trust/install-system-trust.sh new file mode 100644 index 000000000..da9b3886f --- /dev/null +++ b/containers/agent/trust/install-system-trust.sh @@ -0,0 +1,11 @@ +#!/bin/sh +set -eu +policy_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +staging=$(mktemp -d /tmp/awf-ca-store.XXXXXX) +trap 'rm -rf "$staging"' EXIT HUP INT TERM +sh "$policy_dir/filter-ca-store.sh" "$staging" /etc/ssl/certs +# This script is for image-local stores only. Host mounts are handled by the +# agent entrypoint with private, read-only mount overlays instead. +find /etc/ssl/certs -maxdepth 1 \( -type f -o -type l \) -delete +cp -a "$staging/certs/." /etc/ssl/certs/ +cp "$staging/ca-certificates.crt" /etc/ssl/certs/ca-certificates.crt diff --git a/containers/agent/trust/node-trust.cjs b/containers/agent/trust/node-trust.cjs new file mode 100644 index 000000000..ee619da35 --- /dev/null +++ b/containers/agent/trust/node-trust.cjs @@ -0,0 +1,79 @@ +// Generated by scripts/security/generate-trust-policy.cjs; do not edit. +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.distrustedSpkis = void 0; +exports.filterCaCertificates = filterCaCertificates; +exports.applyTlsTrustPolicy = applyTlsTrustPolicy; +const node_crypto_1 = require("node:crypto"); +const node_fs_1 = require("node:fs"); +const node_tls_1 = __importDefault(require("node:tls")); +// October 2026 eMudhra disclosure: eight compromised keys, followed by the +// four issuing/cross-signing roots. Root distrust is an emergency fallback for +// clients without chain-level SPKI revocation, including OpenSSL and Node. +// Sources: https://emudhra.we-have-a-problem.com/{impacted-intermediates,roots}.json +exports.distrustedSpkis = new Set([ + '43bffa19d6ef651451f6841e019931e96f6f2eaeff28ab4b6f1ce4576de2717b', + '49dd353dc2b919a3770645117d0e60c16115e7d1bc259eefe734b00ea434fe5f', + '5c0660a2b164910d8ff589c18e14552e9e0ec237dcefe2cb736d41a4811f81a0', + '63ecefee431d49523bc1136c4e72d0206a477086caf606cd5cf025686c109cf8', + '92c063d67e8f14db71d288e11266ffdaf69cbf115f7bab14f3bc2f68d25e0e30', + 'e0254c7fcdc265e2f0f8a428dabb75ab4ae039b4872d62f5ee57f0877d5a8a41', + 'ebed6475c2c2d80fcb199f3fe8dd6e6b3ce60b77eb15d4e42c3dec0c6f5b5105', + 'ec4ffa1ad0ac88081314b7aaffef6a91f97cd57291bb5ba686c5415f5eb4f6b6', + 'c619e4780ed8e0abeda24ad732567de4383a7ea941338f6d1a7d1b49ac792fe9', + '0a6c2b2ca5b364207954171987ac9d2178561569d8205457e77ca90c32ede282', + 'bd153ed7b0434f6886b17bce8bbe84ed340c7132d702a8f4fa318f756ecbd6f3', + '8d417db2dd8bf5e3084d1e3f196d583849d81bdd4c00c70b9d39369e96b8c782', +]); +const certificateCache = new Map(); +function filterCaCertificates(ca) { + const inputs = Array.isArray(ca) ? ca : [ca]; + return inputs.flatMap(input => { + const text = input.toString(); + if (!text.trim()) + return []; + const certificates = text.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g); + if (!certificates || text.includes('-----BEGIN TRUSTED CERTIFICATE-----')) { + throw new Error('AWF trust policy requires PEM CERTIFICATE entries'); + } + return certificates.filter(pem => { + let allowed = certificateCache.get(pem); + if (allowed === undefined) { + const certificate = new node_crypto_1.X509Certificate(pem); + const spki = certificate.publicKey.export({ type: 'spki', format: 'der' }); + allowed = !exports.distrustedSpkis.has((0, node_crypto_1.createHash)('sha256').update(spki).digest('hex')); + certificateCache.set(pem, allowed); + } + return allowed; + }); + }); +} +let installed = false; +function applyTlsTrustPolicy() { + if (installed) + return; + const defaults = node_tls_1.default.getCACertificates + ? [...node_tls_1.default.getCACertificates('default')] + : [...node_tls_1.default.rootCertificates]; + // Read explicitly as well: Node otherwise warns and ignores malformed or + // missing NODE_EXTRA_CA_CERTS, which must not silently defeat private trust. + if (process.env.NODE_EXTRA_CA_CERTS) { + defaults.push((0, node_fs_1.readFileSync)(process.env.NODE_EXTRA_CA_CERTS, 'utf8')); + } + const filteredDefaults = filterCaCertificates(defaults); + if (filteredDefaults.length === 0) + throw new Error('AWF trust policy produced an empty default CA store'); + node_tls_1.default.setDefaultCACertificates?.(filteredDefaults); + const createSecureContext = node_tls_1.default.createSecureContext; + // Also cover explicit custom CA options and Node versions predating the + // default-CA API. This affects https, fetch/undici and direct tls.connect. + node_tls_1.default.createSecureContext = (options = {}) => createSecureContext({ + ...options, + ca: options.ca === undefined ? filteredDefaults : filterCaCertificates(options.ca), + }); + installed = true; +} +applyTlsTrustPolicy(); diff --git a/containers/api-proxy/Dockerfile b/containers/api-proxy/Dockerfile index 5a874abd2..c79bd09b1 100644 --- a/containers/api-proxy/Dockerfile +++ b/containers/api-proxy/Dockerfile @@ -4,7 +4,11 @@ FROM node:26.11.1-alpine3.24@sha256:143494b1da2945f061539253adc65e4f1569ddf07da2 # Install current Alpine security updates and curl for healthchecks. RUN apk upgrade --no-cache \ - && apk add --no-cache "curl>=8.21.0-r0" + && apk add --no-cache "curl>=8.21.0-r0" openssl ca-certificates + +COPY trust/ /opt/awf/trust/ +RUN sh /opt/awf/trust/install-system-trust.sh \ + && sh /opt/awf/trust/install-node-trust.sh # Replace the bundled npm with npm 11.18.0. # npm 11.18.0 bundles vulnerable copies of undici, brace-expansion, and @@ -103,4 +107,4 @@ ENV AWF_VERSION=${AWF_VERSION} EXPOSE 10000 10001 10002 10003 10004 # Use exec form so node is PID 1 and receives SIGTERM directly -CMD ["node", "server.js"] +CMD ["node", "--require=/opt/awf/trust/node-trust.cjs", "server.js"] diff --git a/containers/api-proxy/trust/distrusted-spki.txt b/containers/api-proxy/trust/distrusted-spki.txt new file mode 100644 index 000000000..79428417c --- /dev/null +++ b/containers/api-proxy/trust/distrusted-spki.txt @@ -0,0 +1,12 @@ +43bffa19d6ef651451f6841e019931e96f6f2eaeff28ab4b6f1ce4576de2717b +49dd353dc2b919a3770645117d0e60c16115e7d1bc259eefe734b00ea434fe5f +5c0660a2b164910d8ff589c18e14552e9e0ec237dcefe2cb736d41a4811f81a0 +63ecefee431d49523bc1136c4e72d0206a477086caf606cd5cf025686c109cf8 +92c063d67e8f14db71d288e11266ffdaf69cbf115f7bab14f3bc2f68d25e0e30 +e0254c7fcdc265e2f0f8a428dabb75ab4ae039b4872d62f5ee57f0877d5a8a41 +ebed6475c2c2d80fcb199f3fe8dd6e6b3ce60b77eb15d4e42c3dec0c6f5b5105 +ec4ffa1ad0ac88081314b7aaffef6a91f97cd57291bb5ba686c5415f5eb4f6b6 +c619e4780ed8e0abeda24ad732567de4383a7ea941338f6d1a7d1b49ac792fe9 +0a6c2b2ca5b364207954171987ac9d2178561569d8205457e77ca90c32ede282 +bd153ed7b0434f6886b17bce8bbe84ed340c7132d702a8f4fa318f756ecbd6f3 +8d417db2dd8bf5e3084d1e3f196d583849d81bdd4c00c70b9d39369e96b8c782 diff --git a/containers/api-proxy/trust/filter-ca-store.sh b/containers/api-proxy/trust/filter-ca-store.sh new file mode 100644 index 000000000..604586b2f --- /dev/null +++ b/containers/api-proxy/trust/filter-ca-store.sh @@ -0,0 +1,62 @@ +#!/bin/sh +# Create a new private store; never rewrite the supplied host/container inputs. +set -eu +output=$1 +shift +policy_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +mkdir -p "$output" +work=$(mktemp -d "$output/.filter.XXXXXX") +trap 'rm -rf "$work"' EXIT HUP INT TERM +: > "$work/input.pem" +for input in "$@"; do + if [ -d "$input" ]; then + for cert in "$input"/*; do + [ -f "$cert" ] || continue + case "$cert" in + *.trust.crt) continue ;; # RHEL AUX trust is represented by its extracted TLS PEM bundle. + *.crt|*.pem|*.[0-9]) cat "$cert" >> "$work/input.pem"; printf '\n' >> "$work/input.pem" ;; + esac + done + elif [ -f "$input" ]; then + cat "$input" >> "$work/input.pem" + printf '\n' >> "$work/input.pem" + else + echo "AWF trust policy: missing CA input: $input" >&2 + exit 1 + fi +done +awk -v dir="$work" ' + /-----BEGIN CERTIFICATE-----/ { if (active) exit 1; active=1; count++; file=dir "/cert-" count ".pem" } + active { print > file } + /-----END CERTIFICATE-----/ { if (!active) exit 1; close(file); active=0 } + /-----BEGIN TRUSTED CERTIFICATE-----/ { exit 1 } + END { if (active || !count) exit 1 } +' "$work/input.pem" || { echo "AWF trust policy: invalid or empty PEM CA store" >&2; exit 1; } +mkdir "$work/certs" +: > "$work/bundle.pem" +for cert in "$work"/cert-*.pem; do + openssl x509 -in "$cert" -pubkey -noout > "$work/key.pem" + openssl pkey -pubin -in "$work/key.pem" -outform DER -out "$work/key.der" + openssl dgst -sha256 "$work/key.der" > "$work/digest" + key=$(awk '{print $NF}' "$work/digest") + if grep -Fqx "$key" "$policy_dir/distrusted-spki.txt"; then + echo "AWF trust policy: removed distrusted CA key $key" >&2 + continue + fi + openssl x509 -in "$cert" -outform DER -out "$work/cert.der" + openssl dgst -sha256 "$work/cert.der" > "$work/digest" + fingerprint=$(awk '{print $NF}' "$work/digest") + if [ ! -f "$work/certs/$fingerprint.pem" ]; then + cp "$cert" "$work/certs/$fingerprint.pem" + cat "$cert" >> "$work/bundle.pem" + fi +done +[ -s "$work/bundle.pem" ] || { echo "AWF trust policy: no trusted certificates remain" >&2; exit 1; } +openssl rehash "$work/certs" +# A fresh destination is required so stale hash links cannot restore trust. +[ ! -e "$output/certs" ] && [ ! -e "$output/ca-certificates.crt" ] || { + echo "AWF trust policy: destination store already exists: $output" >&2 + exit 1 +} +mv "$work/certs" "$output/certs" +mv "$work/bundle.pem" "$output/ca-certificates.crt" diff --git a/containers/api-proxy/trust/install-node-trust.sh b/containers/api-proxy/trust/install-node-trust.sh new file mode 100644 index 000000000..e3f83eb1d --- /dev/null +++ b/containers/api-proxy/trust/install-node-trust.sh @@ -0,0 +1,14 @@ +#!/bin/sh +set -eu +# Use an argv preload, not only NODE_OPTIONS, so caller-supplied Node options +# cannot accidentally disable the policy in AWF-owned runtime images. +if [ ! -e /usr/local/bin/node-awf ]; then + node_path=$(command -v node) + mv "$node_path" /usr/local/bin/node-awf + cat > "$node_path" <<'EOF' +#!/bin/sh +export NODE_OPTIONS="${NODE_OPTIONS:+${NODE_OPTIONS} }--require=/opt/awf/trust/node-trust.cjs" +exec /usr/local/bin/node-awf --require=/opt/awf/trust/node-trust.cjs "$@" +EOF + chmod 0755 "$node_path" +fi diff --git a/containers/api-proxy/trust/install-system-trust.sh b/containers/api-proxy/trust/install-system-trust.sh new file mode 100644 index 000000000..da9b3886f --- /dev/null +++ b/containers/api-proxy/trust/install-system-trust.sh @@ -0,0 +1,11 @@ +#!/bin/sh +set -eu +policy_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +staging=$(mktemp -d /tmp/awf-ca-store.XXXXXX) +trap 'rm -rf "$staging"' EXIT HUP INT TERM +sh "$policy_dir/filter-ca-store.sh" "$staging" /etc/ssl/certs +# This script is for image-local stores only. Host mounts are handled by the +# agent entrypoint with private, read-only mount overlays instead. +find /etc/ssl/certs -maxdepth 1 \( -type f -o -type l \) -delete +cp -a "$staging/certs/." /etc/ssl/certs/ +cp "$staging/ca-certificates.crt" /etc/ssl/certs/ca-certificates.crt diff --git a/containers/api-proxy/trust/node-trust.cjs b/containers/api-proxy/trust/node-trust.cjs new file mode 100644 index 000000000..ee619da35 --- /dev/null +++ b/containers/api-proxy/trust/node-trust.cjs @@ -0,0 +1,79 @@ +// Generated by scripts/security/generate-trust-policy.cjs; do not edit. +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.distrustedSpkis = void 0; +exports.filterCaCertificates = filterCaCertificates; +exports.applyTlsTrustPolicy = applyTlsTrustPolicy; +const node_crypto_1 = require("node:crypto"); +const node_fs_1 = require("node:fs"); +const node_tls_1 = __importDefault(require("node:tls")); +// October 2026 eMudhra disclosure: eight compromised keys, followed by the +// four issuing/cross-signing roots. Root distrust is an emergency fallback for +// clients without chain-level SPKI revocation, including OpenSSL and Node. +// Sources: https://emudhra.we-have-a-problem.com/{impacted-intermediates,roots}.json +exports.distrustedSpkis = new Set([ + '43bffa19d6ef651451f6841e019931e96f6f2eaeff28ab4b6f1ce4576de2717b', + '49dd353dc2b919a3770645117d0e60c16115e7d1bc259eefe734b00ea434fe5f', + '5c0660a2b164910d8ff589c18e14552e9e0ec237dcefe2cb736d41a4811f81a0', + '63ecefee431d49523bc1136c4e72d0206a477086caf606cd5cf025686c109cf8', + '92c063d67e8f14db71d288e11266ffdaf69cbf115f7bab14f3bc2f68d25e0e30', + 'e0254c7fcdc265e2f0f8a428dabb75ab4ae039b4872d62f5ee57f0877d5a8a41', + 'ebed6475c2c2d80fcb199f3fe8dd6e6b3ce60b77eb15d4e42c3dec0c6f5b5105', + 'ec4ffa1ad0ac88081314b7aaffef6a91f97cd57291bb5ba686c5415f5eb4f6b6', + 'c619e4780ed8e0abeda24ad732567de4383a7ea941338f6d1a7d1b49ac792fe9', + '0a6c2b2ca5b364207954171987ac9d2178561569d8205457e77ca90c32ede282', + 'bd153ed7b0434f6886b17bce8bbe84ed340c7132d702a8f4fa318f756ecbd6f3', + '8d417db2dd8bf5e3084d1e3f196d583849d81bdd4c00c70b9d39369e96b8c782', +]); +const certificateCache = new Map(); +function filterCaCertificates(ca) { + const inputs = Array.isArray(ca) ? ca : [ca]; + return inputs.flatMap(input => { + const text = input.toString(); + if (!text.trim()) + return []; + const certificates = text.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g); + if (!certificates || text.includes('-----BEGIN TRUSTED CERTIFICATE-----')) { + throw new Error('AWF trust policy requires PEM CERTIFICATE entries'); + } + return certificates.filter(pem => { + let allowed = certificateCache.get(pem); + if (allowed === undefined) { + const certificate = new node_crypto_1.X509Certificate(pem); + const spki = certificate.publicKey.export({ type: 'spki', format: 'der' }); + allowed = !exports.distrustedSpkis.has((0, node_crypto_1.createHash)('sha256').update(spki).digest('hex')); + certificateCache.set(pem, allowed); + } + return allowed; + }); + }); +} +let installed = false; +function applyTlsTrustPolicy() { + if (installed) + return; + const defaults = node_tls_1.default.getCACertificates + ? [...node_tls_1.default.getCACertificates('default')] + : [...node_tls_1.default.rootCertificates]; + // Read explicitly as well: Node otherwise warns and ignores malformed or + // missing NODE_EXTRA_CA_CERTS, which must not silently defeat private trust. + if (process.env.NODE_EXTRA_CA_CERTS) { + defaults.push((0, node_fs_1.readFileSync)(process.env.NODE_EXTRA_CA_CERTS, 'utf8')); + } + const filteredDefaults = filterCaCertificates(defaults); + if (filteredDefaults.length === 0) + throw new Error('AWF trust policy produced an empty default CA store'); + node_tls_1.default.setDefaultCACertificates?.(filteredDefaults); + const createSecureContext = node_tls_1.default.createSecureContext; + // Also cover explicit custom CA options and Node versions predating the + // default-CA API. This affects https, fetch/undici and direct tls.connect. + node_tls_1.default.createSecureContext = (options = {}) => createSecureContext({ + ...options, + ca: options.ca === undefined ? filteredDefaults : filterCaCertificates(options.ca), + }); + installed = true; +} +applyTlsTrustPolicy(); diff --git a/containers/build-tools/Dockerfile b/containers/build-tools/Dockerfile index eb2011744..4604c97a2 100644 --- a/containers/build-tools/Dockerfile +++ b/containers/build-tools/Dockerfile @@ -25,9 +25,9 @@ # Match the reviewed CLI build in containers/agent/Dockerfile rather than # inheriting Ubuntu's gh binary built with a vulnerable Go runtime. -FROM golang:1.27.2-alpine@sha256:f92b6ef800e499660581efdabdf25d9d817a9d124eaf900924f0504e7e27e12d AS gh-build +FROM golang:1.27.3-alpine@sha256:f2c872bcee949b7be9c490efd02b48a8a2fbf947cdf533f1c58525ec9d8170fd AS gh-build -ENV GOTOOLCHAIN=go1.27.2 +ENV GOTOOLCHAIN=go1.27.3 ARG GH_VERSION=2.98.0 ARG GH_SOURCE_SHA256=abada9e8b550547ac93f99250f3ad4d90ad623fa245cb54cb058f78030a6a5f6 @@ -42,6 +42,7 @@ RUN wget -q "https://github.com/cli/cli/archive/refs/tags/v${GH_VERSION}.tar.gz" && go get "google.golang.org/grpc@v${GH_GRPC_VERSION}" "golang.org/x/crypto@v${GH_X_CRYPTO_VERSION}" "golang.org/x/net@v${GH_X_NET_VERSION}" \ && CGO_ENABLED=0 GH_VERSION="${GH_VERSION}" SOURCE_DATE_EPOCH="${GH_SOURCE_DATE_EPOCH}" \ go run ./script/build.go bin/gh \ + && go version -m bin/gh | head -n 1 | grep -Eq ': go1\.27\.3$' \ && go version -m bin/gh | awk -v version="v${GH_GRPC_VERSION}" \ '$1 == "dep" && $2 == "google.golang.org/grpc" && $3 == version { found=1 } END { exit !found }' \ && go version -m bin/gh | awk -v version="v${GH_X_CRYPTO_VERSION}" \ @@ -127,3 +128,6 @@ RUN set -eux; \ COPY --from=gh-build /tmp/cli-*/bin/gh /usr/bin/gh RUN gh --version && gosu --version && test "$(gosu nobody id -u)" = 65534 + +COPY trust/ /opt/awf/trust/ +RUN sh /opt/awf/trust/install-system-trust.sh diff --git a/containers/build-tools/trust/distrusted-spki.txt b/containers/build-tools/trust/distrusted-spki.txt new file mode 100644 index 000000000..79428417c --- /dev/null +++ b/containers/build-tools/trust/distrusted-spki.txt @@ -0,0 +1,12 @@ +43bffa19d6ef651451f6841e019931e96f6f2eaeff28ab4b6f1ce4576de2717b +49dd353dc2b919a3770645117d0e60c16115e7d1bc259eefe734b00ea434fe5f +5c0660a2b164910d8ff589c18e14552e9e0ec237dcefe2cb736d41a4811f81a0 +63ecefee431d49523bc1136c4e72d0206a477086caf606cd5cf025686c109cf8 +92c063d67e8f14db71d288e11266ffdaf69cbf115f7bab14f3bc2f68d25e0e30 +e0254c7fcdc265e2f0f8a428dabb75ab4ae039b4872d62f5ee57f0877d5a8a41 +ebed6475c2c2d80fcb199f3fe8dd6e6b3ce60b77eb15d4e42c3dec0c6f5b5105 +ec4ffa1ad0ac88081314b7aaffef6a91f97cd57291bb5ba686c5415f5eb4f6b6 +c619e4780ed8e0abeda24ad732567de4383a7ea941338f6d1a7d1b49ac792fe9 +0a6c2b2ca5b364207954171987ac9d2178561569d8205457e77ca90c32ede282 +bd153ed7b0434f6886b17bce8bbe84ed340c7132d702a8f4fa318f756ecbd6f3 +8d417db2dd8bf5e3084d1e3f196d583849d81bdd4c00c70b9d39369e96b8c782 diff --git a/containers/build-tools/trust/filter-ca-store.sh b/containers/build-tools/trust/filter-ca-store.sh new file mode 100644 index 000000000..604586b2f --- /dev/null +++ b/containers/build-tools/trust/filter-ca-store.sh @@ -0,0 +1,62 @@ +#!/bin/sh +# Create a new private store; never rewrite the supplied host/container inputs. +set -eu +output=$1 +shift +policy_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +mkdir -p "$output" +work=$(mktemp -d "$output/.filter.XXXXXX") +trap 'rm -rf "$work"' EXIT HUP INT TERM +: > "$work/input.pem" +for input in "$@"; do + if [ -d "$input" ]; then + for cert in "$input"/*; do + [ -f "$cert" ] || continue + case "$cert" in + *.trust.crt) continue ;; # RHEL AUX trust is represented by its extracted TLS PEM bundle. + *.crt|*.pem|*.[0-9]) cat "$cert" >> "$work/input.pem"; printf '\n' >> "$work/input.pem" ;; + esac + done + elif [ -f "$input" ]; then + cat "$input" >> "$work/input.pem" + printf '\n' >> "$work/input.pem" + else + echo "AWF trust policy: missing CA input: $input" >&2 + exit 1 + fi +done +awk -v dir="$work" ' + /-----BEGIN CERTIFICATE-----/ { if (active) exit 1; active=1; count++; file=dir "/cert-" count ".pem" } + active { print > file } + /-----END CERTIFICATE-----/ { if (!active) exit 1; close(file); active=0 } + /-----BEGIN TRUSTED CERTIFICATE-----/ { exit 1 } + END { if (active || !count) exit 1 } +' "$work/input.pem" || { echo "AWF trust policy: invalid or empty PEM CA store" >&2; exit 1; } +mkdir "$work/certs" +: > "$work/bundle.pem" +for cert in "$work"/cert-*.pem; do + openssl x509 -in "$cert" -pubkey -noout > "$work/key.pem" + openssl pkey -pubin -in "$work/key.pem" -outform DER -out "$work/key.der" + openssl dgst -sha256 "$work/key.der" > "$work/digest" + key=$(awk '{print $NF}' "$work/digest") + if grep -Fqx "$key" "$policy_dir/distrusted-spki.txt"; then + echo "AWF trust policy: removed distrusted CA key $key" >&2 + continue + fi + openssl x509 -in "$cert" -outform DER -out "$work/cert.der" + openssl dgst -sha256 "$work/cert.der" > "$work/digest" + fingerprint=$(awk '{print $NF}' "$work/digest") + if [ ! -f "$work/certs/$fingerprint.pem" ]; then + cp "$cert" "$work/certs/$fingerprint.pem" + cat "$cert" >> "$work/bundle.pem" + fi +done +[ -s "$work/bundle.pem" ] || { echo "AWF trust policy: no trusted certificates remain" >&2; exit 1; } +openssl rehash "$work/certs" +# A fresh destination is required so stale hash links cannot restore trust. +[ ! -e "$output/certs" ] && [ ! -e "$output/ca-certificates.crt" ] || { + echo "AWF trust policy: destination store already exists: $output" >&2 + exit 1 +} +mv "$work/certs" "$output/certs" +mv "$work/bundle.pem" "$output/ca-certificates.crt" diff --git a/containers/build-tools/trust/install-system-trust.sh b/containers/build-tools/trust/install-system-trust.sh new file mode 100644 index 000000000..da9b3886f --- /dev/null +++ b/containers/build-tools/trust/install-system-trust.sh @@ -0,0 +1,11 @@ +#!/bin/sh +set -eu +policy_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +staging=$(mktemp -d /tmp/awf-ca-store.XXXXXX) +trap 'rm -rf "$staging"' EXIT HUP INT TERM +sh "$policy_dir/filter-ca-store.sh" "$staging" /etc/ssl/certs +# This script is for image-local stores only. Host mounts are handled by the +# agent entrypoint with private, read-only mount overlays instead. +find /etc/ssl/certs -maxdepth 1 \( -type f -o -type l \) -delete +cp -a "$staging/certs/." /etc/ssl/certs/ +cp "$staging/ca-certificates.crt" /etc/ssl/certs/ca-certificates.crt diff --git a/containers/cli-proxy/Dockerfile b/containers/cli-proxy/Dockerfile index a13e22af2..0e84d3286 100644 --- a/containers/cli-proxy/Dockerfile +++ b/containers/cli-proxy/Dockerfile @@ -5,7 +5,8 @@ # externally on the host, started by the gh-aw compiler. A TCP tunnel # forwards localhost traffic to the external proxy for TLS hostname matching. # -FROM golang:1.27.2-alpine@sha256:f92b6ef800e499660581efdabdf25d9d817a9d124eaf900924f0504e7e27e12d AS gh-build +FROM golang:1.27.3-alpine@sha256:f2c872bcee949b7be9c490efd02b48a8a2fbf947cdf533f1c58525ec9d8170fd AS gh-build +ENV GOTOOLCHAIN=go1.27.3 ARG GH_VERSION=2.98.0 ARG GH_SOURCE_SHA256=abada9e8b550547ac93f99250f3ad4d90ad623fa245cb54cb058f78030a6a5f6 @@ -20,6 +21,7 @@ RUN wget -q "https://github.com/cli/cli/archive/refs/tags/v${GH_VERSION}.tar.gz" && go get "google.golang.org/grpc@v${GH_GRPC_VERSION}" "golang.org/x/crypto@v${GH_X_CRYPTO_VERSION}" "golang.org/x/net@v${GH_X_NET_VERSION}" \ && CGO_ENABLED=0 GH_VERSION="${GH_VERSION}" SOURCE_DATE_EPOCH="${GH_SOURCE_DATE_EPOCH}" \ go run ./script/build.go bin/gh \ + && go version -m bin/gh | head -n 1 | grep -Eq ': go1\.27\.3$' \ && go version -m bin/gh | awk -v version="v${GH_GRPC_VERSION}" \ '$1 == "dep" && $2 == "google.golang.org/grpc" && $3 == version { found=1 } END { exit !found }' \ && go version -m bin/gh | awk -v version="v${GH_X_CRYPTO_VERSION}" \ @@ -110,6 +112,10 @@ RUN mkdir -p /var/log/cli-proxy && \ RUN mkdir -p /tmp/proxy-tls && chown cliproxy:cliproxy /tmp/proxy-tls # Switch to non-root user +COPY trust/ /opt/awf/trust/ +RUN apk add --no-cache openssl \ + && sh /opt/awf/trust/install-system-trust.sh \ + && sh /opt/awf/trust/install-node-trust.sh USER cliproxy # Expose port for agent->cli-proxy HTTP communication diff --git a/containers/cli-proxy/entrypoint.sh b/containers/cli-proxy/entrypoint.sh index aeec823ac..047492271 100644 --- a/containers/cli-proxy/entrypoint.sh +++ b/containers/cli-proxy/entrypoint.sh @@ -46,8 +46,10 @@ echo "[cli-proxy] TLS certificate available" # Build a combined CA bundle so the gh CLI (Go binary) trusts the DIFC proxy's # self-signed cert. NODE_EXTRA_CA_CERTS only helps Node.js; Go programs use # the system store or SSL_CERT_FILE. -COMBINED_CA="/tmp/proxy-tls/combined-ca.crt" -cat /etc/ssl/certs/ca-certificates.crt /tmp/proxy-tls/ca.crt > "${COMBINED_CA}" +TRUST_DIR="$(mktemp -d /tmp/awf-cli-trust.XXXXXX)" +sh /opt/awf/trust/filter-ca-store.sh "${TRUST_DIR}" \ + /etc/ssl/certs/ca-certificates.crt /tmp/proxy-tls/ca.crt +COMBINED_CA="${TRUST_DIR}/ca-certificates.crt" echo "[cli-proxy] Combined CA bundle created at ${COMBINED_CA}" # Configure gh CLI to route through the DIFC proxy via the TCP tunnel @@ -62,8 +64,9 @@ export GH_REPO="${GH_REPO:-$GITHUB_REPOSITORY}" # Node.js (server.js / tcp-tunnel.js) uses NODE_EXTRA_CA_CERTS; # gh CLI (Go) uses SSL_CERT_FILE pointing to the combined bundle; # git (called by `gh repo clone`) uses GIT_SSL_CAINFO for OpenSSL verification. -export NODE_EXTRA_CA_CERTS="/tmp/proxy-tls/ca.crt" +export NODE_EXTRA_CA_CERTS="${COMBINED_CA}" export SSL_CERT_FILE="${COMBINED_CA}" +export SSL_CERT_DIR="${TRUST_DIR}/certs" export GIT_SSL_CAINFO="${COMBINED_CA}" echo "[cli-proxy] gh CLI configured to route through DIFC proxy at ${GH_HOST}" diff --git a/containers/cli-proxy/trust/distrusted-spki.txt b/containers/cli-proxy/trust/distrusted-spki.txt new file mode 100644 index 000000000..79428417c --- /dev/null +++ b/containers/cli-proxy/trust/distrusted-spki.txt @@ -0,0 +1,12 @@ +43bffa19d6ef651451f6841e019931e96f6f2eaeff28ab4b6f1ce4576de2717b +49dd353dc2b919a3770645117d0e60c16115e7d1bc259eefe734b00ea434fe5f +5c0660a2b164910d8ff589c18e14552e9e0ec237dcefe2cb736d41a4811f81a0 +63ecefee431d49523bc1136c4e72d0206a477086caf606cd5cf025686c109cf8 +92c063d67e8f14db71d288e11266ffdaf69cbf115f7bab14f3bc2f68d25e0e30 +e0254c7fcdc265e2f0f8a428dabb75ab4ae039b4872d62f5ee57f0877d5a8a41 +ebed6475c2c2d80fcb199f3fe8dd6e6b3ce60b77eb15d4e42c3dec0c6f5b5105 +ec4ffa1ad0ac88081314b7aaffef6a91f97cd57291bb5ba686c5415f5eb4f6b6 +c619e4780ed8e0abeda24ad732567de4383a7ea941338f6d1a7d1b49ac792fe9 +0a6c2b2ca5b364207954171987ac9d2178561569d8205457e77ca90c32ede282 +bd153ed7b0434f6886b17bce8bbe84ed340c7132d702a8f4fa318f756ecbd6f3 +8d417db2dd8bf5e3084d1e3f196d583849d81bdd4c00c70b9d39369e96b8c782 diff --git a/containers/cli-proxy/trust/filter-ca-store.sh b/containers/cli-proxy/trust/filter-ca-store.sh new file mode 100644 index 000000000..604586b2f --- /dev/null +++ b/containers/cli-proxy/trust/filter-ca-store.sh @@ -0,0 +1,62 @@ +#!/bin/sh +# Create a new private store; never rewrite the supplied host/container inputs. +set -eu +output=$1 +shift +policy_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +mkdir -p "$output" +work=$(mktemp -d "$output/.filter.XXXXXX") +trap 'rm -rf "$work"' EXIT HUP INT TERM +: > "$work/input.pem" +for input in "$@"; do + if [ -d "$input" ]; then + for cert in "$input"/*; do + [ -f "$cert" ] || continue + case "$cert" in + *.trust.crt) continue ;; # RHEL AUX trust is represented by its extracted TLS PEM bundle. + *.crt|*.pem|*.[0-9]) cat "$cert" >> "$work/input.pem"; printf '\n' >> "$work/input.pem" ;; + esac + done + elif [ -f "$input" ]; then + cat "$input" >> "$work/input.pem" + printf '\n' >> "$work/input.pem" + else + echo "AWF trust policy: missing CA input: $input" >&2 + exit 1 + fi +done +awk -v dir="$work" ' + /-----BEGIN CERTIFICATE-----/ { if (active) exit 1; active=1; count++; file=dir "/cert-" count ".pem" } + active { print > file } + /-----END CERTIFICATE-----/ { if (!active) exit 1; close(file); active=0 } + /-----BEGIN TRUSTED CERTIFICATE-----/ { exit 1 } + END { if (active || !count) exit 1 } +' "$work/input.pem" || { echo "AWF trust policy: invalid or empty PEM CA store" >&2; exit 1; } +mkdir "$work/certs" +: > "$work/bundle.pem" +for cert in "$work"/cert-*.pem; do + openssl x509 -in "$cert" -pubkey -noout > "$work/key.pem" + openssl pkey -pubin -in "$work/key.pem" -outform DER -out "$work/key.der" + openssl dgst -sha256 "$work/key.der" > "$work/digest" + key=$(awk '{print $NF}' "$work/digest") + if grep -Fqx "$key" "$policy_dir/distrusted-spki.txt"; then + echo "AWF trust policy: removed distrusted CA key $key" >&2 + continue + fi + openssl x509 -in "$cert" -outform DER -out "$work/cert.der" + openssl dgst -sha256 "$work/cert.der" > "$work/digest" + fingerprint=$(awk '{print $NF}' "$work/digest") + if [ ! -f "$work/certs/$fingerprint.pem" ]; then + cp "$cert" "$work/certs/$fingerprint.pem" + cat "$cert" >> "$work/bundle.pem" + fi +done +[ -s "$work/bundle.pem" ] || { echo "AWF trust policy: no trusted certificates remain" >&2; exit 1; } +openssl rehash "$work/certs" +# A fresh destination is required so stale hash links cannot restore trust. +[ ! -e "$output/certs" ] && [ ! -e "$output/ca-certificates.crt" ] || { + echo "AWF trust policy: destination store already exists: $output" >&2 + exit 1 +} +mv "$work/certs" "$output/certs" +mv "$work/bundle.pem" "$output/ca-certificates.crt" diff --git a/containers/cli-proxy/trust/install-node-trust.sh b/containers/cli-proxy/trust/install-node-trust.sh new file mode 100644 index 000000000..e3f83eb1d --- /dev/null +++ b/containers/cli-proxy/trust/install-node-trust.sh @@ -0,0 +1,14 @@ +#!/bin/sh +set -eu +# Use an argv preload, not only NODE_OPTIONS, so caller-supplied Node options +# cannot accidentally disable the policy in AWF-owned runtime images. +if [ ! -e /usr/local/bin/node-awf ]; then + node_path=$(command -v node) + mv "$node_path" /usr/local/bin/node-awf + cat > "$node_path" <<'EOF' +#!/bin/sh +export NODE_OPTIONS="${NODE_OPTIONS:+${NODE_OPTIONS} }--require=/opt/awf/trust/node-trust.cjs" +exec /usr/local/bin/node-awf --require=/opt/awf/trust/node-trust.cjs "$@" +EOF + chmod 0755 "$node_path" +fi diff --git a/containers/cli-proxy/trust/install-system-trust.sh b/containers/cli-proxy/trust/install-system-trust.sh new file mode 100644 index 000000000..da9b3886f --- /dev/null +++ b/containers/cli-proxy/trust/install-system-trust.sh @@ -0,0 +1,11 @@ +#!/bin/sh +set -eu +policy_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +staging=$(mktemp -d /tmp/awf-ca-store.XXXXXX) +trap 'rm -rf "$staging"' EXIT HUP INT TERM +sh "$policy_dir/filter-ca-store.sh" "$staging" /etc/ssl/certs +# This script is for image-local stores only. Host mounts are handled by the +# agent entrypoint with private, read-only mount overlays instead. +find /etc/ssl/certs -maxdepth 1 \( -type f -o -type l \) -delete +cp -a "$staging/certs/." /etc/ssl/certs/ +cp "$staging/ca-certificates.crt" /etc/ssl/certs/ca-certificates.crt diff --git a/containers/cli-proxy/trust/node-trust.cjs b/containers/cli-proxy/trust/node-trust.cjs new file mode 100644 index 000000000..ee619da35 --- /dev/null +++ b/containers/cli-proxy/trust/node-trust.cjs @@ -0,0 +1,79 @@ +// Generated by scripts/security/generate-trust-policy.cjs; do not edit. +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.distrustedSpkis = void 0; +exports.filterCaCertificates = filterCaCertificates; +exports.applyTlsTrustPolicy = applyTlsTrustPolicy; +const node_crypto_1 = require("node:crypto"); +const node_fs_1 = require("node:fs"); +const node_tls_1 = __importDefault(require("node:tls")); +// October 2026 eMudhra disclosure: eight compromised keys, followed by the +// four issuing/cross-signing roots. Root distrust is an emergency fallback for +// clients without chain-level SPKI revocation, including OpenSSL and Node. +// Sources: https://emudhra.we-have-a-problem.com/{impacted-intermediates,roots}.json +exports.distrustedSpkis = new Set([ + '43bffa19d6ef651451f6841e019931e96f6f2eaeff28ab4b6f1ce4576de2717b', + '49dd353dc2b919a3770645117d0e60c16115e7d1bc259eefe734b00ea434fe5f', + '5c0660a2b164910d8ff589c18e14552e9e0ec237dcefe2cb736d41a4811f81a0', + '63ecefee431d49523bc1136c4e72d0206a477086caf606cd5cf025686c109cf8', + '92c063d67e8f14db71d288e11266ffdaf69cbf115f7bab14f3bc2f68d25e0e30', + 'e0254c7fcdc265e2f0f8a428dabb75ab4ae039b4872d62f5ee57f0877d5a8a41', + 'ebed6475c2c2d80fcb199f3fe8dd6e6b3ce60b77eb15d4e42c3dec0c6f5b5105', + 'ec4ffa1ad0ac88081314b7aaffef6a91f97cd57291bb5ba686c5415f5eb4f6b6', + 'c619e4780ed8e0abeda24ad732567de4383a7ea941338f6d1a7d1b49ac792fe9', + '0a6c2b2ca5b364207954171987ac9d2178561569d8205457e77ca90c32ede282', + 'bd153ed7b0434f6886b17bce8bbe84ed340c7132d702a8f4fa318f756ecbd6f3', + '8d417db2dd8bf5e3084d1e3f196d583849d81bdd4c00c70b9d39369e96b8c782', +]); +const certificateCache = new Map(); +function filterCaCertificates(ca) { + const inputs = Array.isArray(ca) ? ca : [ca]; + return inputs.flatMap(input => { + const text = input.toString(); + if (!text.trim()) + return []; + const certificates = text.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g); + if (!certificates || text.includes('-----BEGIN TRUSTED CERTIFICATE-----')) { + throw new Error('AWF trust policy requires PEM CERTIFICATE entries'); + } + return certificates.filter(pem => { + let allowed = certificateCache.get(pem); + if (allowed === undefined) { + const certificate = new node_crypto_1.X509Certificate(pem); + const spki = certificate.publicKey.export({ type: 'spki', format: 'der' }); + allowed = !exports.distrustedSpkis.has((0, node_crypto_1.createHash)('sha256').update(spki).digest('hex')); + certificateCache.set(pem, allowed); + } + return allowed; + }); + }); +} +let installed = false; +function applyTlsTrustPolicy() { + if (installed) + return; + const defaults = node_tls_1.default.getCACertificates + ? [...node_tls_1.default.getCACertificates('default')] + : [...node_tls_1.default.rootCertificates]; + // Read explicitly as well: Node otherwise warns and ignores malformed or + // missing NODE_EXTRA_CA_CERTS, which must not silently defeat private trust. + if (process.env.NODE_EXTRA_CA_CERTS) { + defaults.push((0, node_fs_1.readFileSync)(process.env.NODE_EXTRA_CA_CERTS, 'utf8')); + } + const filteredDefaults = filterCaCertificates(defaults); + if (filteredDefaults.length === 0) + throw new Error('AWF trust policy produced an empty default CA store'); + node_tls_1.default.setDefaultCACertificates?.(filteredDefaults); + const createSecureContext = node_tls_1.default.createSecureContext; + // Also cover explicit custom CA options and Node versions predating the + // default-CA API. This affects https, fetch/undici and direct tls.connect. + node_tls_1.default.createSecureContext = (options = {}) => createSecureContext({ + ...options, + ca: options.ca === undefined ? filteredDefaults : filterCaCertificates(options.ca), + }); + installed = true; +} +applyTlsTrustPolicy(); diff --git a/containers/cloud-hypervisor/Dockerfile b/containers/cloud-hypervisor/Dockerfile index 511c8b7ee..52fdf97a8 100644 --- a/containers/cloud-hypervisor/Dockerfile +++ b/containers/cloud-hypervisor/Dockerfile @@ -18,6 +18,9 @@ RUN apt-get update \ && apt-get upgrade -y \ && rm -rf /var/lib/apt/lists/* /root/.cache /tmp/* COPY --from=node-runtime /usr/local/bin/node /usr/local/bin/node +COPY trust/ /opt/awf/trust/ +RUN sh /opt/awf/trust/install-system-trust.sh \ + && sh /opt/awf/trust/install-node-trust.sh COPY --from=supervisor /awf-supervisor /usr/sbin/awf-supervisor RUN chmod 0755 /usr/sbin/awf-supervisor \ && mkdir -p /awf/seed /awf/out /dev/shm /proc /sys /tmp \ @@ -44,6 +47,7 @@ RUN apt-get update \ libgdiplus libev-dev dnsutils net-tools netcat-openbsd gnupg \ && rm -rf /var/lib/apt/lists/* COPY --from=build-tools /usr/bin/gh /usr/bin/gh +RUN sh /opt/awf/trust/install-system-trust.sh RUN test -x /usr/bin/gh && gcc --version && make --version \ && python3 -c 'import json, pathlib, runpy' \ && node --version @@ -58,6 +62,7 @@ RUN apt-get update \ && rm -rf /var/lib/apt/lists/* COPY --from=copilot-runtime /usr/local/lib/node_modules/@github/copilot/ /usr/local/lib/node_modules/@github/copilot/ COPY --from=copilot-runtime /usr/bin/gh /usr/bin/gh +RUN sh /opt/awf/trust/install-system-trust.sh COPY enclave/script-entrypoint.py /usr/local/bin/run-enclave-script COPY enclave/agent-entrypoint.py /usr/local/bin/run-enclave-agent RUN sed -i '1c#!/usr/local/bin/node' /usr/local/lib/node_modules/@github/copilot/npm-loader.js \ diff --git a/containers/dind-ubuntu/Dockerfile b/containers/dind-ubuntu/Dockerfile index 5ef0ce0e6..46f753b8d 100644 --- a/containers/dind-ubuntu/Dockerfile +++ b/containers/dind-ubuntu/Dockerfile @@ -27,4 +27,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* +COPY trust/ /opt/awf/trust/ +RUN sh /opt/awf/trust/install-system-trust.sh \ + && sh /opt/awf/trust/install-node-trust.sh + CMD ["dockerd", "--host=unix:///var/run/docker.sock"] diff --git a/containers/dind-ubuntu/trust/distrusted-spki.txt b/containers/dind-ubuntu/trust/distrusted-spki.txt new file mode 100644 index 000000000..79428417c --- /dev/null +++ b/containers/dind-ubuntu/trust/distrusted-spki.txt @@ -0,0 +1,12 @@ +43bffa19d6ef651451f6841e019931e96f6f2eaeff28ab4b6f1ce4576de2717b +49dd353dc2b919a3770645117d0e60c16115e7d1bc259eefe734b00ea434fe5f +5c0660a2b164910d8ff589c18e14552e9e0ec237dcefe2cb736d41a4811f81a0 +63ecefee431d49523bc1136c4e72d0206a477086caf606cd5cf025686c109cf8 +92c063d67e8f14db71d288e11266ffdaf69cbf115f7bab14f3bc2f68d25e0e30 +e0254c7fcdc265e2f0f8a428dabb75ab4ae039b4872d62f5ee57f0877d5a8a41 +ebed6475c2c2d80fcb199f3fe8dd6e6b3ce60b77eb15d4e42c3dec0c6f5b5105 +ec4ffa1ad0ac88081314b7aaffef6a91f97cd57291bb5ba686c5415f5eb4f6b6 +c619e4780ed8e0abeda24ad732567de4383a7ea941338f6d1a7d1b49ac792fe9 +0a6c2b2ca5b364207954171987ac9d2178561569d8205457e77ca90c32ede282 +bd153ed7b0434f6886b17bce8bbe84ed340c7132d702a8f4fa318f756ecbd6f3 +8d417db2dd8bf5e3084d1e3f196d583849d81bdd4c00c70b9d39369e96b8c782 diff --git a/containers/dind-ubuntu/trust/filter-ca-store.sh b/containers/dind-ubuntu/trust/filter-ca-store.sh new file mode 100644 index 000000000..604586b2f --- /dev/null +++ b/containers/dind-ubuntu/trust/filter-ca-store.sh @@ -0,0 +1,62 @@ +#!/bin/sh +# Create a new private store; never rewrite the supplied host/container inputs. +set -eu +output=$1 +shift +policy_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +mkdir -p "$output" +work=$(mktemp -d "$output/.filter.XXXXXX") +trap 'rm -rf "$work"' EXIT HUP INT TERM +: > "$work/input.pem" +for input in "$@"; do + if [ -d "$input" ]; then + for cert in "$input"/*; do + [ -f "$cert" ] || continue + case "$cert" in + *.trust.crt) continue ;; # RHEL AUX trust is represented by its extracted TLS PEM bundle. + *.crt|*.pem|*.[0-9]) cat "$cert" >> "$work/input.pem"; printf '\n' >> "$work/input.pem" ;; + esac + done + elif [ -f "$input" ]; then + cat "$input" >> "$work/input.pem" + printf '\n' >> "$work/input.pem" + else + echo "AWF trust policy: missing CA input: $input" >&2 + exit 1 + fi +done +awk -v dir="$work" ' + /-----BEGIN CERTIFICATE-----/ { if (active) exit 1; active=1; count++; file=dir "/cert-" count ".pem" } + active { print > file } + /-----END CERTIFICATE-----/ { if (!active) exit 1; close(file); active=0 } + /-----BEGIN TRUSTED CERTIFICATE-----/ { exit 1 } + END { if (active || !count) exit 1 } +' "$work/input.pem" || { echo "AWF trust policy: invalid or empty PEM CA store" >&2; exit 1; } +mkdir "$work/certs" +: > "$work/bundle.pem" +for cert in "$work"/cert-*.pem; do + openssl x509 -in "$cert" -pubkey -noout > "$work/key.pem" + openssl pkey -pubin -in "$work/key.pem" -outform DER -out "$work/key.der" + openssl dgst -sha256 "$work/key.der" > "$work/digest" + key=$(awk '{print $NF}' "$work/digest") + if grep -Fqx "$key" "$policy_dir/distrusted-spki.txt"; then + echo "AWF trust policy: removed distrusted CA key $key" >&2 + continue + fi + openssl x509 -in "$cert" -outform DER -out "$work/cert.der" + openssl dgst -sha256 "$work/cert.der" > "$work/digest" + fingerprint=$(awk '{print $NF}' "$work/digest") + if [ ! -f "$work/certs/$fingerprint.pem" ]; then + cp "$cert" "$work/certs/$fingerprint.pem" + cat "$cert" >> "$work/bundle.pem" + fi +done +[ -s "$work/bundle.pem" ] || { echo "AWF trust policy: no trusted certificates remain" >&2; exit 1; } +openssl rehash "$work/certs" +# A fresh destination is required so stale hash links cannot restore trust. +[ ! -e "$output/certs" ] && [ ! -e "$output/ca-certificates.crt" ] || { + echo "AWF trust policy: destination store already exists: $output" >&2 + exit 1 +} +mv "$work/certs" "$output/certs" +mv "$work/bundle.pem" "$output/ca-certificates.crt" diff --git a/containers/dind-ubuntu/trust/install-node-trust.sh b/containers/dind-ubuntu/trust/install-node-trust.sh new file mode 100644 index 000000000..e3f83eb1d --- /dev/null +++ b/containers/dind-ubuntu/trust/install-node-trust.sh @@ -0,0 +1,14 @@ +#!/bin/sh +set -eu +# Use an argv preload, not only NODE_OPTIONS, so caller-supplied Node options +# cannot accidentally disable the policy in AWF-owned runtime images. +if [ ! -e /usr/local/bin/node-awf ]; then + node_path=$(command -v node) + mv "$node_path" /usr/local/bin/node-awf + cat > "$node_path" <<'EOF' +#!/bin/sh +export NODE_OPTIONS="${NODE_OPTIONS:+${NODE_OPTIONS} }--require=/opt/awf/trust/node-trust.cjs" +exec /usr/local/bin/node-awf --require=/opt/awf/trust/node-trust.cjs "$@" +EOF + chmod 0755 "$node_path" +fi diff --git a/containers/dind-ubuntu/trust/install-system-trust.sh b/containers/dind-ubuntu/trust/install-system-trust.sh new file mode 100644 index 000000000..da9b3886f --- /dev/null +++ b/containers/dind-ubuntu/trust/install-system-trust.sh @@ -0,0 +1,11 @@ +#!/bin/sh +set -eu +policy_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +staging=$(mktemp -d /tmp/awf-ca-store.XXXXXX) +trap 'rm -rf "$staging"' EXIT HUP INT TERM +sh "$policy_dir/filter-ca-store.sh" "$staging" /etc/ssl/certs +# This script is for image-local stores only. Host mounts are handled by the +# agent entrypoint with private, read-only mount overlays instead. +find /etc/ssl/certs -maxdepth 1 \( -type f -o -type l \) -delete +cp -a "$staging/certs/." /etc/ssl/certs/ +cp "$staging/ca-certificates.crt" /etc/ssl/certs/ca-certificates.crt diff --git a/containers/dind-ubuntu/trust/node-trust.cjs b/containers/dind-ubuntu/trust/node-trust.cjs new file mode 100644 index 000000000..ee619da35 --- /dev/null +++ b/containers/dind-ubuntu/trust/node-trust.cjs @@ -0,0 +1,79 @@ +// Generated by scripts/security/generate-trust-policy.cjs; do not edit. +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.distrustedSpkis = void 0; +exports.filterCaCertificates = filterCaCertificates; +exports.applyTlsTrustPolicy = applyTlsTrustPolicy; +const node_crypto_1 = require("node:crypto"); +const node_fs_1 = require("node:fs"); +const node_tls_1 = __importDefault(require("node:tls")); +// October 2026 eMudhra disclosure: eight compromised keys, followed by the +// four issuing/cross-signing roots. Root distrust is an emergency fallback for +// clients without chain-level SPKI revocation, including OpenSSL and Node. +// Sources: https://emudhra.we-have-a-problem.com/{impacted-intermediates,roots}.json +exports.distrustedSpkis = new Set([ + '43bffa19d6ef651451f6841e019931e96f6f2eaeff28ab4b6f1ce4576de2717b', + '49dd353dc2b919a3770645117d0e60c16115e7d1bc259eefe734b00ea434fe5f', + '5c0660a2b164910d8ff589c18e14552e9e0ec237dcefe2cb736d41a4811f81a0', + '63ecefee431d49523bc1136c4e72d0206a477086caf606cd5cf025686c109cf8', + '92c063d67e8f14db71d288e11266ffdaf69cbf115f7bab14f3bc2f68d25e0e30', + 'e0254c7fcdc265e2f0f8a428dabb75ab4ae039b4872d62f5ee57f0877d5a8a41', + 'ebed6475c2c2d80fcb199f3fe8dd6e6b3ce60b77eb15d4e42c3dec0c6f5b5105', + 'ec4ffa1ad0ac88081314b7aaffef6a91f97cd57291bb5ba686c5415f5eb4f6b6', + 'c619e4780ed8e0abeda24ad732567de4383a7ea941338f6d1a7d1b49ac792fe9', + '0a6c2b2ca5b364207954171987ac9d2178561569d8205457e77ca90c32ede282', + 'bd153ed7b0434f6886b17bce8bbe84ed340c7132d702a8f4fa318f756ecbd6f3', + '8d417db2dd8bf5e3084d1e3f196d583849d81bdd4c00c70b9d39369e96b8c782', +]); +const certificateCache = new Map(); +function filterCaCertificates(ca) { + const inputs = Array.isArray(ca) ? ca : [ca]; + return inputs.flatMap(input => { + const text = input.toString(); + if (!text.trim()) + return []; + const certificates = text.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g); + if (!certificates || text.includes('-----BEGIN TRUSTED CERTIFICATE-----')) { + throw new Error('AWF trust policy requires PEM CERTIFICATE entries'); + } + return certificates.filter(pem => { + let allowed = certificateCache.get(pem); + if (allowed === undefined) { + const certificate = new node_crypto_1.X509Certificate(pem); + const spki = certificate.publicKey.export({ type: 'spki', format: 'der' }); + allowed = !exports.distrustedSpkis.has((0, node_crypto_1.createHash)('sha256').update(spki).digest('hex')); + certificateCache.set(pem, allowed); + } + return allowed; + }); + }); +} +let installed = false; +function applyTlsTrustPolicy() { + if (installed) + return; + const defaults = node_tls_1.default.getCACertificates + ? [...node_tls_1.default.getCACertificates('default')] + : [...node_tls_1.default.rootCertificates]; + // Read explicitly as well: Node otherwise warns and ignores malformed or + // missing NODE_EXTRA_CA_CERTS, which must not silently defeat private trust. + if (process.env.NODE_EXTRA_CA_CERTS) { + defaults.push((0, node_fs_1.readFileSync)(process.env.NODE_EXTRA_CA_CERTS, 'utf8')); + } + const filteredDefaults = filterCaCertificates(defaults); + if (filteredDefaults.length === 0) + throw new Error('AWF trust policy produced an empty default CA store'); + node_tls_1.default.setDefaultCACertificates?.(filteredDefaults); + const createSecureContext = node_tls_1.default.createSecureContext; + // Also cover explicit custom CA options and Node versions predating the + // default-CA API. This affects https, fetch/undici and direct tls.connect. + node_tls_1.default.createSecureContext = (options = {}) => createSecureContext({ + ...options, + ca: options.ca === undefined ? filteredDefaults : filterCaCertificates(options.ca), + }); + installed = true; +} +applyTlsTrustPolicy(); diff --git a/containers/enclave/Dockerfile b/containers/enclave/Dockerfile index 4342adc07..15bca5773 100644 --- a/containers/enclave/Dockerfile +++ b/containers/enclave/Dockerfile @@ -7,14 +7,23 @@ FROM python:3.14.8-alpine3.24@sha256:f6a589d43c42b9e7f7dc67a12d37132491f362859a5 RUN apk upgrade --no-cache \ && python3 -c 'import json, pathlib, sys; sys.exit(0)' \ && test -x /usr/local/bin/python3 \ - && ln -s /usr/local/bin/python3 /usr/bin/python3 \ - && rm -f /sbin/apk + && ln -s /usr/local/bin/python3 /usr/bin/python3 COPY enclave/script-entrypoint.py /usr/local/bin/run-enclave-script RUN chmod 0555 /usr/local/bin/run-enclave-script \ && mkdir -p /script /awf/seed +COPY trust/ /opt/awf/trust/ +RUN apk add --no-cache openssl ca-certificates \ + && sh /opt/awf/trust/install-system-trust.sh \ + && rm -f /sbin/apk FROM node:26.11.1-trixie-slim@sha256:193fe51b64e77981119c98c2002c9e32a70e2f006fb4d25068ce0558998917f0 AS enclave-agent-build +COPY trust/ /opt/awf/trust/ +RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates \ + && sh /opt/awf/trust/install-system-trust.sh \ + && rm -rf /var/lib/apt/lists/* +ENV NODE_OPTIONS="--require=/opt/awf/trust/node-trust.cjs" + ARG NPM_CONFIG_REGISTRY=https://registry.npmjs.org/ COPY enclave/package*.json /tmp/copilot-cli/ RUN npm_config_ignore_scripts=false npm ci --prefix /tmp/copilot-cli --install-strategy=nested \ @@ -43,6 +52,9 @@ RUN apt-get update \ && rm -rf /var/lib/apt/lists/* \ && rm -f /usr/bin/apt /usr/bin/apt-get /usr/bin/apt-cache COPY --from=enclave-agent-build /usr/local/bin/node /usr/local/bin/node +COPY trust/ /opt/awf/trust/ +RUN sh /opt/awf/trust/install-system-trust.sh \ + && sh /opt/awf/trust/install-node-trust.sh COPY --from=enclave-agent-build /usr/local/lib/node_modules/@github/copilot/ /usr/local/lib/node_modules/@github/copilot/ # Avoid uutils env's executable-name check on shebangs under BuildKit's QEMU. RUN sed -i '1c#!/usr/local/bin/node' /usr/local/lib/node_modules/@github/copilot/npm-loader.js \ @@ -60,6 +72,11 @@ RUN chmod 0555 /usr/local/bin/run-enclave-agent \ FROM node:26.11.1-alpine3.24@sha256:143494b1da2945f061539253adc65e4f1569ddf07da2d384c022c791a9d90a4a AS enclave-mcp-server +COPY trust/ /opt/awf/trust/ +RUN apk add --no-cache openssl ca-certificates \ + && sh /opt/awf/trust/install-system-trust.sh \ + && sh /opt/awf/trust/install-node-trust.sh + ARG AWF_SOURCE_REVISION=unavailable RUN apk upgrade --no-cache \ diff --git a/containers/gh-aw-node/Dockerfile b/containers/gh-aw-node/Dockerfile index 802c00347..a54363408 100644 --- a/containers/gh-aw-node/Dockerfile +++ b/containers/gh-aw-node/Dockerfile @@ -70,3 +70,8 @@ LABEL org.opencontainers.image.description="Minimal Node.js Alpine image for gh- # AWF version baked into the image at release build time. ARG AWF_VERSION=0.0.0-dev ENV AWF_VERSION=${AWF_VERSION} + +COPY trust/ /opt/awf/trust/ +RUN apk add --no-cache openssl ca-certificates \ + && sh /opt/awf/trust/install-system-trust.sh \ + && sh /opt/awf/trust/install-node-trust.sh diff --git a/containers/gh-aw-node/trust/distrusted-spki.txt b/containers/gh-aw-node/trust/distrusted-spki.txt new file mode 100644 index 000000000..79428417c --- /dev/null +++ b/containers/gh-aw-node/trust/distrusted-spki.txt @@ -0,0 +1,12 @@ +43bffa19d6ef651451f6841e019931e96f6f2eaeff28ab4b6f1ce4576de2717b +49dd353dc2b919a3770645117d0e60c16115e7d1bc259eefe734b00ea434fe5f +5c0660a2b164910d8ff589c18e14552e9e0ec237dcefe2cb736d41a4811f81a0 +63ecefee431d49523bc1136c4e72d0206a477086caf606cd5cf025686c109cf8 +92c063d67e8f14db71d288e11266ffdaf69cbf115f7bab14f3bc2f68d25e0e30 +e0254c7fcdc265e2f0f8a428dabb75ab4ae039b4872d62f5ee57f0877d5a8a41 +ebed6475c2c2d80fcb199f3fe8dd6e6b3ce60b77eb15d4e42c3dec0c6f5b5105 +ec4ffa1ad0ac88081314b7aaffef6a91f97cd57291bb5ba686c5415f5eb4f6b6 +c619e4780ed8e0abeda24ad732567de4383a7ea941338f6d1a7d1b49ac792fe9 +0a6c2b2ca5b364207954171987ac9d2178561569d8205457e77ca90c32ede282 +bd153ed7b0434f6886b17bce8bbe84ed340c7132d702a8f4fa318f756ecbd6f3 +8d417db2dd8bf5e3084d1e3f196d583849d81bdd4c00c70b9d39369e96b8c782 diff --git a/containers/gh-aw-node/trust/filter-ca-store.sh b/containers/gh-aw-node/trust/filter-ca-store.sh new file mode 100644 index 000000000..604586b2f --- /dev/null +++ b/containers/gh-aw-node/trust/filter-ca-store.sh @@ -0,0 +1,62 @@ +#!/bin/sh +# Create a new private store; never rewrite the supplied host/container inputs. +set -eu +output=$1 +shift +policy_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +mkdir -p "$output" +work=$(mktemp -d "$output/.filter.XXXXXX") +trap 'rm -rf "$work"' EXIT HUP INT TERM +: > "$work/input.pem" +for input in "$@"; do + if [ -d "$input" ]; then + for cert in "$input"/*; do + [ -f "$cert" ] || continue + case "$cert" in + *.trust.crt) continue ;; # RHEL AUX trust is represented by its extracted TLS PEM bundle. + *.crt|*.pem|*.[0-9]) cat "$cert" >> "$work/input.pem"; printf '\n' >> "$work/input.pem" ;; + esac + done + elif [ -f "$input" ]; then + cat "$input" >> "$work/input.pem" + printf '\n' >> "$work/input.pem" + else + echo "AWF trust policy: missing CA input: $input" >&2 + exit 1 + fi +done +awk -v dir="$work" ' + /-----BEGIN CERTIFICATE-----/ { if (active) exit 1; active=1; count++; file=dir "/cert-" count ".pem" } + active { print > file } + /-----END CERTIFICATE-----/ { if (!active) exit 1; close(file); active=0 } + /-----BEGIN TRUSTED CERTIFICATE-----/ { exit 1 } + END { if (active || !count) exit 1 } +' "$work/input.pem" || { echo "AWF trust policy: invalid or empty PEM CA store" >&2; exit 1; } +mkdir "$work/certs" +: > "$work/bundle.pem" +for cert in "$work"/cert-*.pem; do + openssl x509 -in "$cert" -pubkey -noout > "$work/key.pem" + openssl pkey -pubin -in "$work/key.pem" -outform DER -out "$work/key.der" + openssl dgst -sha256 "$work/key.der" > "$work/digest" + key=$(awk '{print $NF}' "$work/digest") + if grep -Fqx "$key" "$policy_dir/distrusted-spki.txt"; then + echo "AWF trust policy: removed distrusted CA key $key" >&2 + continue + fi + openssl x509 -in "$cert" -outform DER -out "$work/cert.der" + openssl dgst -sha256 "$work/cert.der" > "$work/digest" + fingerprint=$(awk '{print $NF}' "$work/digest") + if [ ! -f "$work/certs/$fingerprint.pem" ]; then + cp "$cert" "$work/certs/$fingerprint.pem" + cat "$cert" >> "$work/bundle.pem" + fi +done +[ -s "$work/bundle.pem" ] || { echo "AWF trust policy: no trusted certificates remain" >&2; exit 1; } +openssl rehash "$work/certs" +# A fresh destination is required so stale hash links cannot restore trust. +[ ! -e "$output/certs" ] && [ ! -e "$output/ca-certificates.crt" ] || { + echo "AWF trust policy: destination store already exists: $output" >&2 + exit 1 +} +mv "$work/certs" "$output/certs" +mv "$work/bundle.pem" "$output/ca-certificates.crt" diff --git a/containers/gh-aw-node/trust/install-node-trust.sh b/containers/gh-aw-node/trust/install-node-trust.sh new file mode 100644 index 000000000..e3f83eb1d --- /dev/null +++ b/containers/gh-aw-node/trust/install-node-trust.sh @@ -0,0 +1,14 @@ +#!/bin/sh +set -eu +# Use an argv preload, not only NODE_OPTIONS, so caller-supplied Node options +# cannot accidentally disable the policy in AWF-owned runtime images. +if [ ! -e /usr/local/bin/node-awf ]; then + node_path=$(command -v node) + mv "$node_path" /usr/local/bin/node-awf + cat > "$node_path" <<'EOF' +#!/bin/sh +export NODE_OPTIONS="${NODE_OPTIONS:+${NODE_OPTIONS} }--require=/opt/awf/trust/node-trust.cjs" +exec /usr/local/bin/node-awf --require=/opt/awf/trust/node-trust.cjs "$@" +EOF + chmod 0755 "$node_path" +fi diff --git a/containers/gh-aw-node/trust/install-system-trust.sh b/containers/gh-aw-node/trust/install-system-trust.sh new file mode 100644 index 000000000..da9b3886f --- /dev/null +++ b/containers/gh-aw-node/trust/install-system-trust.sh @@ -0,0 +1,11 @@ +#!/bin/sh +set -eu +policy_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +staging=$(mktemp -d /tmp/awf-ca-store.XXXXXX) +trap 'rm -rf "$staging"' EXIT HUP INT TERM +sh "$policy_dir/filter-ca-store.sh" "$staging" /etc/ssl/certs +# This script is for image-local stores only. Host mounts are handled by the +# agent entrypoint with private, read-only mount overlays instead. +find /etc/ssl/certs -maxdepth 1 \( -type f -o -type l \) -delete +cp -a "$staging/certs/." /etc/ssl/certs/ +cp "$staging/ca-certificates.crt" /etc/ssl/certs/ca-certificates.crt diff --git a/containers/gh-aw-node/trust/node-trust.cjs b/containers/gh-aw-node/trust/node-trust.cjs new file mode 100644 index 000000000..ee619da35 --- /dev/null +++ b/containers/gh-aw-node/trust/node-trust.cjs @@ -0,0 +1,79 @@ +// Generated by scripts/security/generate-trust-policy.cjs; do not edit. +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.distrustedSpkis = void 0; +exports.filterCaCertificates = filterCaCertificates; +exports.applyTlsTrustPolicy = applyTlsTrustPolicy; +const node_crypto_1 = require("node:crypto"); +const node_fs_1 = require("node:fs"); +const node_tls_1 = __importDefault(require("node:tls")); +// October 2026 eMudhra disclosure: eight compromised keys, followed by the +// four issuing/cross-signing roots. Root distrust is an emergency fallback for +// clients without chain-level SPKI revocation, including OpenSSL and Node. +// Sources: https://emudhra.we-have-a-problem.com/{impacted-intermediates,roots}.json +exports.distrustedSpkis = new Set([ + '43bffa19d6ef651451f6841e019931e96f6f2eaeff28ab4b6f1ce4576de2717b', + '49dd353dc2b919a3770645117d0e60c16115e7d1bc259eefe734b00ea434fe5f', + '5c0660a2b164910d8ff589c18e14552e9e0ec237dcefe2cb736d41a4811f81a0', + '63ecefee431d49523bc1136c4e72d0206a477086caf606cd5cf025686c109cf8', + '92c063d67e8f14db71d288e11266ffdaf69cbf115f7bab14f3bc2f68d25e0e30', + 'e0254c7fcdc265e2f0f8a428dabb75ab4ae039b4872d62f5ee57f0877d5a8a41', + 'ebed6475c2c2d80fcb199f3fe8dd6e6b3ce60b77eb15d4e42c3dec0c6f5b5105', + 'ec4ffa1ad0ac88081314b7aaffef6a91f97cd57291bb5ba686c5415f5eb4f6b6', + 'c619e4780ed8e0abeda24ad732567de4383a7ea941338f6d1a7d1b49ac792fe9', + '0a6c2b2ca5b364207954171987ac9d2178561569d8205457e77ca90c32ede282', + 'bd153ed7b0434f6886b17bce8bbe84ed340c7132d702a8f4fa318f756ecbd6f3', + '8d417db2dd8bf5e3084d1e3f196d583849d81bdd4c00c70b9d39369e96b8c782', +]); +const certificateCache = new Map(); +function filterCaCertificates(ca) { + const inputs = Array.isArray(ca) ? ca : [ca]; + return inputs.flatMap(input => { + const text = input.toString(); + if (!text.trim()) + return []; + const certificates = text.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g); + if (!certificates || text.includes('-----BEGIN TRUSTED CERTIFICATE-----')) { + throw new Error('AWF trust policy requires PEM CERTIFICATE entries'); + } + return certificates.filter(pem => { + let allowed = certificateCache.get(pem); + if (allowed === undefined) { + const certificate = new node_crypto_1.X509Certificate(pem); + const spki = certificate.publicKey.export({ type: 'spki', format: 'der' }); + allowed = !exports.distrustedSpkis.has((0, node_crypto_1.createHash)('sha256').update(spki).digest('hex')); + certificateCache.set(pem, allowed); + } + return allowed; + }); + }); +} +let installed = false; +function applyTlsTrustPolicy() { + if (installed) + return; + const defaults = node_tls_1.default.getCACertificates + ? [...node_tls_1.default.getCACertificates('default')] + : [...node_tls_1.default.rootCertificates]; + // Read explicitly as well: Node otherwise warns and ignores malformed or + // missing NODE_EXTRA_CA_CERTS, which must not silently defeat private trust. + if (process.env.NODE_EXTRA_CA_CERTS) { + defaults.push((0, node_fs_1.readFileSync)(process.env.NODE_EXTRA_CA_CERTS, 'utf8')); + } + const filteredDefaults = filterCaCertificates(defaults); + if (filteredDefaults.length === 0) + throw new Error('AWF trust policy produced an empty default CA store'); + node_tls_1.default.setDefaultCACertificates?.(filteredDefaults); + const createSecureContext = node_tls_1.default.createSecureContext; + // Also cover explicit custom CA options and Node versions predating the + // default-CA API. This affects https, fetch/undici and direct tls.connect. + node_tls_1.default.createSecureContext = (options = {}) => createSecureContext({ + ...options, + ca: options.ca === undefined ? filteredDefaults : filterCaCertificates(options.ca), + }); + installed = true; +} +applyTlsTrustPolicy(); diff --git a/containers/router/.dockerignore b/containers/router/.dockerignore index dd5a75693..f8c54f305 100644 --- a/containers/router/.dockerignore +++ b/containers/router/.dockerignore @@ -6,4 +6,6 @@ !src/** !routing/ !routing/cost-*.json +!trust/ +!trust/** **/__pycache__/ \ No newline at end of file diff --git a/containers/router/Dockerfile b/containers/router/Dockerfile index 9280b8748..e6886bb57 100644 --- a/containers/router/Dockerfile +++ b/containers/router/Dockerfile @@ -28,6 +28,12 @@ RUN apk upgrade --no-cache \ -h /home/gh-aw-router -s /sbin/nologin gh-aw-router \ && mkdir -p /app/src /routing +COPY trust/ /opt/awf/trust/ +RUN apk add --no-cache openssl ca-certificates \ + && sh /opt/awf/trust/install-system-trust.sh +ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \ + REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt + COPY requirements.lock /tmp/requirements.lock RUN python -m pip install \ --disable-pip-version-check \ diff --git a/containers/router/trust/distrusted-spki.txt b/containers/router/trust/distrusted-spki.txt new file mode 100644 index 000000000..79428417c --- /dev/null +++ b/containers/router/trust/distrusted-spki.txt @@ -0,0 +1,12 @@ +43bffa19d6ef651451f6841e019931e96f6f2eaeff28ab4b6f1ce4576de2717b +49dd353dc2b919a3770645117d0e60c16115e7d1bc259eefe734b00ea434fe5f +5c0660a2b164910d8ff589c18e14552e9e0ec237dcefe2cb736d41a4811f81a0 +63ecefee431d49523bc1136c4e72d0206a477086caf606cd5cf025686c109cf8 +92c063d67e8f14db71d288e11266ffdaf69cbf115f7bab14f3bc2f68d25e0e30 +e0254c7fcdc265e2f0f8a428dabb75ab4ae039b4872d62f5ee57f0877d5a8a41 +ebed6475c2c2d80fcb199f3fe8dd6e6b3ce60b77eb15d4e42c3dec0c6f5b5105 +ec4ffa1ad0ac88081314b7aaffef6a91f97cd57291bb5ba686c5415f5eb4f6b6 +c619e4780ed8e0abeda24ad732567de4383a7ea941338f6d1a7d1b49ac792fe9 +0a6c2b2ca5b364207954171987ac9d2178561569d8205457e77ca90c32ede282 +bd153ed7b0434f6886b17bce8bbe84ed340c7132d702a8f4fa318f756ecbd6f3 +8d417db2dd8bf5e3084d1e3f196d583849d81bdd4c00c70b9d39369e96b8c782 diff --git a/containers/router/trust/filter-ca-store.sh b/containers/router/trust/filter-ca-store.sh new file mode 100644 index 000000000..604586b2f --- /dev/null +++ b/containers/router/trust/filter-ca-store.sh @@ -0,0 +1,62 @@ +#!/bin/sh +# Create a new private store; never rewrite the supplied host/container inputs. +set -eu +output=$1 +shift +policy_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +mkdir -p "$output" +work=$(mktemp -d "$output/.filter.XXXXXX") +trap 'rm -rf "$work"' EXIT HUP INT TERM +: > "$work/input.pem" +for input in "$@"; do + if [ -d "$input" ]; then + for cert in "$input"/*; do + [ -f "$cert" ] || continue + case "$cert" in + *.trust.crt) continue ;; # RHEL AUX trust is represented by its extracted TLS PEM bundle. + *.crt|*.pem|*.[0-9]) cat "$cert" >> "$work/input.pem"; printf '\n' >> "$work/input.pem" ;; + esac + done + elif [ -f "$input" ]; then + cat "$input" >> "$work/input.pem" + printf '\n' >> "$work/input.pem" + else + echo "AWF trust policy: missing CA input: $input" >&2 + exit 1 + fi +done +awk -v dir="$work" ' + /-----BEGIN CERTIFICATE-----/ { if (active) exit 1; active=1; count++; file=dir "/cert-" count ".pem" } + active { print > file } + /-----END CERTIFICATE-----/ { if (!active) exit 1; close(file); active=0 } + /-----BEGIN TRUSTED CERTIFICATE-----/ { exit 1 } + END { if (active || !count) exit 1 } +' "$work/input.pem" || { echo "AWF trust policy: invalid or empty PEM CA store" >&2; exit 1; } +mkdir "$work/certs" +: > "$work/bundle.pem" +for cert in "$work"/cert-*.pem; do + openssl x509 -in "$cert" -pubkey -noout > "$work/key.pem" + openssl pkey -pubin -in "$work/key.pem" -outform DER -out "$work/key.der" + openssl dgst -sha256 "$work/key.der" > "$work/digest" + key=$(awk '{print $NF}' "$work/digest") + if grep -Fqx "$key" "$policy_dir/distrusted-spki.txt"; then + echo "AWF trust policy: removed distrusted CA key $key" >&2 + continue + fi + openssl x509 -in "$cert" -outform DER -out "$work/cert.der" + openssl dgst -sha256 "$work/cert.der" > "$work/digest" + fingerprint=$(awk '{print $NF}' "$work/digest") + if [ ! -f "$work/certs/$fingerprint.pem" ]; then + cp "$cert" "$work/certs/$fingerprint.pem" + cat "$cert" >> "$work/bundle.pem" + fi +done +[ -s "$work/bundle.pem" ] || { echo "AWF trust policy: no trusted certificates remain" >&2; exit 1; } +openssl rehash "$work/certs" +# A fresh destination is required so stale hash links cannot restore trust. +[ ! -e "$output/certs" ] && [ ! -e "$output/ca-certificates.crt" ] || { + echo "AWF trust policy: destination store already exists: $output" >&2 + exit 1 +} +mv "$work/certs" "$output/certs" +mv "$work/bundle.pem" "$output/ca-certificates.crt" diff --git a/containers/router/trust/install-system-trust.sh b/containers/router/trust/install-system-trust.sh new file mode 100644 index 000000000..da9b3886f --- /dev/null +++ b/containers/router/trust/install-system-trust.sh @@ -0,0 +1,11 @@ +#!/bin/sh +set -eu +policy_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +staging=$(mktemp -d /tmp/awf-ca-store.XXXXXX) +trap 'rm -rf "$staging"' EXIT HUP INT TERM +sh "$policy_dir/filter-ca-store.sh" "$staging" /etc/ssl/certs +# This script is for image-local stores only. Host mounts are handled by the +# agent entrypoint with private, read-only mount overlays instead. +find /etc/ssl/certs -maxdepth 1 \( -type f -o -type l \) -delete +cp -a "$staging/certs/." /etc/ssl/certs/ +cp "$staging/ca-certificates.crt" /etc/ssl/certs/ca-certificates.crt diff --git a/containers/squid/Dockerfile b/containers/squid/Dockerfile index 2d83d9671..b8e6de7f2 100644 --- a/containers/squid/Dockerfile +++ b/containers/squid/Dockerfile @@ -63,6 +63,8 @@ RUN mkdir -p /var/log/squid /var/spool/squid /var/run/squid && \ chown -R proxy:proxy /var/log/squid /var/spool/squid /var/run/squid /etc/squid # Copy entrypoint script +COPY trust/ /opt/awf/trust/ +RUN sh /opt/awf/trust/install-system-trust.sh COPY entrypoint.sh /usr/local/bin/entrypoint.sh RUN chmod +x /usr/local/bin/entrypoint.sh diff --git a/containers/squid/trust/distrusted-spki.txt b/containers/squid/trust/distrusted-spki.txt new file mode 100644 index 000000000..79428417c --- /dev/null +++ b/containers/squid/trust/distrusted-spki.txt @@ -0,0 +1,12 @@ +43bffa19d6ef651451f6841e019931e96f6f2eaeff28ab4b6f1ce4576de2717b +49dd353dc2b919a3770645117d0e60c16115e7d1bc259eefe734b00ea434fe5f +5c0660a2b164910d8ff589c18e14552e9e0ec237dcefe2cb736d41a4811f81a0 +63ecefee431d49523bc1136c4e72d0206a477086caf606cd5cf025686c109cf8 +92c063d67e8f14db71d288e11266ffdaf69cbf115f7bab14f3bc2f68d25e0e30 +e0254c7fcdc265e2f0f8a428dabb75ab4ae039b4872d62f5ee57f0877d5a8a41 +ebed6475c2c2d80fcb199f3fe8dd6e6b3ce60b77eb15d4e42c3dec0c6f5b5105 +ec4ffa1ad0ac88081314b7aaffef6a91f97cd57291bb5ba686c5415f5eb4f6b6 +c619e4780ed8e0abeda24ad732567de4383a7ea941338f6d1a7d1b49ac792fe9 +0a6c2b2ca5b364207954171987ac9d2178561569d8205457e77ca90c32ede282 +bd153ed7b0434f6886b17bce8bbe84ed340c7132d702a8f4fa318f756ecbd6f3 +8d417db2dd8bf5e3084d1e3f196d583849d81bdd4c00c70b9d39369e96b8c782 diff --git a/containers/squid/trust/filter-ca-store.sh b/containers/squid/trust/filter-ca-store.sh new file mode 100644 index 000000000..604586b2f --- /dev/null +++ b/containers/squid/trust/filter-ca-store.sh @@ -0,0 +1,62 @@ +#!/bin/sh +# Create a new private store; never rewrite the supplied host/container inputs. +set -eu +output=$1 +shift +policy_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +mkdir -p "$output" +work=$(mktemp -d "$output/.filter.XXXXXX") +trap 'rm -rf "$work"' EXIT HUP INT TERM +: > "$work/input.pem" +for input in "$@"; do + if [ -d "$input" ]; then + for cert in "$input"/*; do + [ -f "$cert" ] || continue + case "$cert" in + *.trust.crt) continue ;; # RHEL AUX trust is represented by its extracted TLS PEM bundle. + *.crt|*.pem|*.[0-9]) cat "$cert" >> "$work/input.pem"; printf '\n' >> "$work/input.pem" ;; + esac + done + elif [ -f "$input" ]; then + cat "$input" >> "$work/input.pem" + printf '\n' >> "$work/input.pem" + else + echo "AWF trust policy: missing CA input: $input" >&2 + exit 1 + fi +done +awk -v dir="$work" ' + /-----BEGIN CERTIFICATE-----/ { if (active) exit 1; active=1; count++; file=dir "/cert-" count ".pem" } + active { print > file } + /-----END CERTIFICATE-----/ { if (!active) exit 1; close(file); active=0 } + /-----BEGIN TRUSTED CERTIFICATE-----/ { exit 1 } + END { if (active || !count) exit 1 } +' "$work/input.pem" || { echo "AWF trust policy: invalid or empty PEM CA store" >&2; exit 1; } +mkdir "$work/certs" +: > "$work/bundle.pem" +for cert in "$work"/cert-*.pem; do + openssl x509 -in "$cert" -pubkey -noout > "$work/key.pem" + openssl pkey -pubin -in "$work/key.pem" -outform DER -out "$work/key.der" + openssl dgst -sha256 "$work/key.der" > "$work/digest" + key=$(awk '{print $NF}' "$work/digest") + if grep -Fqx "$key" "$policy_dir/distrusted-spki.txt"; then + echo "AWF trust policy: removed distrusted CA key $key" >&2 + continue + fi + openssl x509 -in "$cert" -outform DER -out "$work/cert.der" + openssl dgst -sha256 "$work/cert.der" > "$work/digest" + fingerprint=$(awk '{print $NF}' "$work/digest") + if [ ! -f "$work/certs/$fingerprint.pem" ]; then + cp "$cert" "$work/certs/$fingerprint.pem" + cat "$cert" >> "$work/bundle.pem" + fi +done +[ -s "$work/bundle.pem" ] || { echo "AWF trust policy: no trusted certificates remain" >&2; exit 1; } +openssl rehash "$work/certs" +# A fresh destination is required so stale hash links cannot restore trust. +[ ! -e "$output/certs" ] && [ ! -e "$output/ca-certificates.crt" ] || { + echo "AWF trust policy: destination store already exists: $output" >&2 + exit 1 +} +mv "$work/certs" "$output/certs" +mv "$work/bundle.pem" "$output/ca-certificates.crt" diff --git a/containers/squid/trust/install-system-trust.sh b/containers/squid/trust/install-system-trust.sh new file mode 100644 index 000000000..da9b3886f --- /dev/null +++ b/containers/squid/trust/install-system-trust.sh @@ -0,0 +1,11 @@ +#!/bin/sh +set -eu +policy_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +staging=$(mktemp -d /tmp/awf-ca-store.XXXXXX) +trap 'rm -rf "$staging"' EXIT HUP INT TERM +sh "$policy_dir/filter-ca-store.sh" "$staging" /etc/ssl/certs +# This script is for image-local stores only. Host mounts are handled by the +# agent entrypoint with private, read-only mount overlays instead. +find /etc/ssl/certs -maxdepth 1 \( -type f -o -type l \) -delete +cp -a "$staging/certs/." /etc/ssl/certs/ +cp "$staging/ca-certificates.crt" /etc/ssl/certs/ca-certificates.crt diff --git a/containers/trust/distrusted-spki.txt b/containers/trust/distrusted-spki.txt new file mode 100644 index 000000000..79428417c --- /dev/null +++ b/containers/trust/distrusted-spki.txt @@ -0,0 +1,12 @@ +43bffa19d6ef651451f6841e019931e96f6f2eaeff28ab4b6f1ce4576de2717b +49dd353dc2b919a3770645117d0e60c16115e7d1bc259eefe734b00ea434fe5f +5c0660a2b164910d8ff589c18e14552e9e0ec237dcefe2cb736d41a4811f81a0 +63ecefee431d49523bc1136c4e72d0206a477086caf606cd5cf025686c109cf8 +92c063d67e8f14db71d288e11266ffdaf69cbf115f7bab14f3bc2f68d25e0e30 +e0254c7fcdc265e2f0f8a428dabb75ab4ae039b4872d62f5ee57f0877d5a8a41 +ebed6475c2c2d80fcb199f3fe8dd6e6b3ce60b77eb15d4e42c3dec0c6f5b5105 +ec4ffa1ad0ac88081314b7aaffef6a91f97cd57291bb5ba686c5415f5eb4f6b6 +c619e4780ed8e0abeda24ad732567de4383a7ea941338f6d1a7d1b49ac792fe9 +0a6c2b2ca5b364207954171987ac9d2178561569d8205457e77ca90c32ede282 +bd153ed7b0434f6886b17bce8bbe84ed340c7132d702a8f4fa318f756ecbd6f3 +8d417db2dd8bf5e3084d1e3f196d583849d81bdd4c00c70b9d39369e96b8c782 diff --git a/containers/trust/filter-ca-store.sh b/containers/trust/filter-ca-store.sh new file mode 100644 index 000000000..604586b2f --- /dev/null +++ b/containers/trust/filter-ca-store.sh @@ -0,0 +1,62 @@ +#!/bin/sh +# Create a new private store; never rewrite the supplied host/container inputs. +set -eu +output=$1 +shift +policy_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +mkdir -p "$output" +work=$(mktemp -d "$output/.filter.XXXXXX") +trap 'rm -rf "$work"' EXIT HUP INT TERM +: > "$work/input.pem" +for input in "$@"; do + if [ -d "$input" ]; then + for cert in "$input"/*; do + [ -f "$cert" ] || continue + case "$cert" in + *.trust.crt) continue ;; # RHEL AUX trust is represented by its extracted TLS PEM bundle. + *.crt|*.pem|*.[0-9]) cat "$cert" >> "$work/input.pem"; printf '\n' >> "$work/input.pem" ;; + esac + done + elif [ -f "$input" ]; then + cat "$input" >> "$work/input.pem" + printf '\n' >> "$work/input.pem" + else + echo "AWF trust policy: missing CA input: $input" >&2 + exit 1 + fi +done +awk -v dir="$work" ' + /-----BEGIN CERTIFICATE-----/ { if (active) exit 1; active=1; count++; file=dir "/cert-" count ".pem" } + active { print > file } + /-----END CERTIFICATE-----/ { if (!active) exit 1; close(file); active=0 } + /-----BEGIN TRUSTED CERTIFICATE-----/ { exit 1 } + END { if (active || !count) exit 1 } +' "$work/input.pem" || { echo "AWF trust policy: invalid or empty PEM CA store" >&2; exit 1; } +mkdir "$work/certs" +: > "$work/bundle.pem" +for cert in "$work"/cert-*.pem; do + openssl x509 -in "$cert" -pubkey -noout > "$work/key.pem" + openssl pkey -pubin -in "$work/key.pem" -outform DER -out "$work/key.der" + openssl dgst -sha256 "$work/key.der" > "$work/digest" + key=$(awk '{print $NF}' "$work/digest") + if grep -Fqx "$key" "$policy_dir/distrusted-spki.txt"; then + echo "AWF trust policy: removed distrusted CA key $key" >&2 + continue + fi + openssl x509 -in "$cert" -outform DER -out "$work/cert.der" + openssl dgst -sha256 "$work/cert.der" > "$work/digest" + fingerprint=$(awk '{print $NF}' "$work/digest") + if [ ! -f "$work/certs/$fingerprint.pem" ]; then + cp "$cert" "$work/certs/$fingerprint.pem" + cat "$cert" >> "$work/bundle.pem" + fi +done +[ -s "$work/bundle.pem" ] || { echo "AWF trust policy: no trusted certificates remain" >&2; exit 1; } +openssl rehash "$work/certs" +# A fresh destination is required so stale hash links cannot restore trust. +[ ! -e "$output/certs" ] && [ ! -e "$output/ca-certificates.crt" ] || { + echo "AWF trust policy: destination store already exists: $output" >&2 + exit 1 +} +mv "$work/certs" "$output/certs" +mv "$work/bundle.pem" "$output/ca-certificates.crt" diff --git a/containers/trust/install-node-trust.sh b/containers/trust/install-node-trust.sh new file mode 100644 index 000000000..e3f83eb1d --- /dev/null +++ b/containers/trust/install-node-trust.sh @@ -0,0 +1,14 @@ +#!/bin/sh +set -eu +# Use an argv preload, not only NODE_OPTIONS, so caller-supplied Node options +# cannot accidentally disable the policy in AWF-owned runtime images. +if [ ! -e /usr/local/bin/node-awf ]; then + node_path=$(command -v node) + mv "$node_path" /usr/local/bin/node-awf + cat > "$node_path" <<'EOF' +#!/bin/sh +export NODE_OPTIONS="${NODE_OPTIONS:+${NODE_OPTIONS} }--require=/opt/awf/trust/node-trust.cjs" +exec /usr/local/bin/node-awf --require=/opt/awf/trust/node-trust.cjs "$@" +EOF + chmod 0755 "$node_path" +fi diff --git a/containers/trust/install-system-trust.sh b/containers/trust/install-system-trust.sh new file mode 100644 index 000000000..da9b3886f --- /dev/null +++ b/containers/trust/install-system-trust.sh @@ -0,0 +1,11 @@ +#!/bin/sh +set -eu +policy_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +staging=$(mktemp -d /tmp/awf-ca-store.XXXXXX) +trap 'rm -rf "$staging"' EXIT HUP INT TERM +sh "$policy_dir/filter-ca-store.sh" "$staging" /etc/ssl/certs +# This script is for image-local stores only. Host mounts are handled by the +# agent entrypoint with private, read-only mount overlays instead. +find /etc/ssl/certs -maxdepth 1 \( -type f -o -type l \) -delete +cp -a "$staging/certs/." /etc/ssl/certs/ +cp "$staging/ca-certificates.crt" /etc/ssl/certs/ca-certificates.crt diff --git a/containers/trust/node-trust.cjs b/containers/trust/node-trust.cjs new file mode 100644 index 000000000..ee619da35 --- /dev/null +++ b/containers/trust/node-trust.cjs @@ -0,0 +1,79 @@ +// Generated by scripts/security/generate-trust-policy.cjs; do not edit. +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.distrustedSpkis = void 0; +exports.filterCaCertificates = filterCaCertificates; +exports.applyTlsTrustPolicy = applyTlsTrustPolicy; +const node_crypto_1 = require("node:crypto"); +const node_fs_1 = require("node:fs"); +const node_tls_1 = __importDefault(require("node:tls")); +// October 2026 eMudhra disclosure: eight compromised keys, followed by the +// four issuing/cross-signing roots. Root distrust is an emergency fallback for +// clients without chain-level SPKI revocation, including OpenSSL and Node. +// Sources: https://emudhra.we-have-a-problem.com/{impacted-intermediates,roots}.json +exports.distrustedSpkis = new Set([ + '43bffa19d6ef651451f6841e019931e96f6f2eaeff28ab4b6f1ce4576de2717b', + '49dd353dc2b919a3770645117d0e60c16115e7d1bc259eefe734b00ea434fe5f', + '5c0660a2b164910d8ff589c18e14552e9e0ec237dcefe2cb736d41a4811f81a0', + '63ecefee431d49523bc1136c4e72d0206a477086caf606cd5cf025686c109cf8', + '92c063d67e8f14db71d288e11266ffdaf69cbf115f7bab14f3bc2f68d25e0e30', + 'e0254c7fcdc265e2f0f8a428dabb75ab4ae039b4872d62f5ee57f0877d5a8a41', + 'ebed6475c2c2d80fcb199f3fe8dd6e6b3ce60b77eb15d4e42c3dec0c6f5b5105', + 'ec4ffa1ad0ac88081314b7aaffef6a91f97cd57291bb5ba686c5415f5eb4f6b6', + 'c619e4780ed8e0abeda24ad732567de4383a7ea941338f6d1a7d1b49ac792fe9', + '0a6c2b2ca5b364207954171987ac9d2178561569d8205457e77ca90c32ede282', + 'bd153ed7b0434f6886b17bce8bbe84ed340c7132d702a8f4fa318f756ecbd6f3', + '8d417db2dd8bf5e3084d1e3f196d583849d81bdd4c00c70b9d39369e96b8c782', +]); +const certificateCache = new Map(); +function filterCaCertificates(ca) { + const inputs = Array.isArray(ca) ? ca : [ca]; + return inputs.flatMap(input => { + const text = input.toString(); + if (!text.trim()) + return []; + const certificates = text.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g); + if (!certificates || text.includes('-----BEGIN TRUSTED CERTIFICATE-----')) { + throw new Error('AWF trust policy requires PEM CERTIFICATE entries'); + } + return certificates.filter(pem => { + let allowed = certificateCache.get(pem); + if (allowed === undefined) { + const certificate = new node_crypto_1.X509Certificate(pem); + const spki = certificate.publicKey.export({ type: 'spki', format: 'der' }); + allowed = !exports.distrustedSpkis.has((0, node_crypto_1.createHash)('sha256').update(spki).digest('hex')); + certificateCache.set(pem, allowed); + } + return allowed; + }); + }); +} +let installed = false; +function applyTlsTrustPolicy() { + if (installed) + return; + const defaults = node_tls_1.default.getCACertificates + ? [...node_tls_1.default.getCACertificates('default')] + : [...node_tls_1.default.rootCertificates]; + // Read explicitly as well: Node otherwise warns and ignores malformed or + // missing NODE_EXTRA_CA_CERTS, which must not silently defeat private trust. + if (process.env.NODE_EXTRA_CA_CERTS) { + defaults.push((0, node_fs_1.readFileSync)(process.env.NODE_EXTRA_CA_CERTS, 'utf8')); + } + const filteredDefaults = filterCaCertificates(defaults); + if (filteredDefaults.length === 0) + throw new Error('AWF trust policy produced an empty default CA store'); + node_tls_1.default.setDefaultCACertificates?.(filteredDefaults); + const createSecureContext = node_tls_1.default.createSecureContext; + // Also cover explicit custom CA options and Node versions predating the + // default-CA API. This affects https, fetch/undici and direct tls.connect. + node_tls_1.default.createSecureContext = (options = {}) => createSecureContext({ + ...options, + ca: options.ca === undefined ? filteredDefaults : filterCaCertificates(options.ca), + }); + installed = true; +} +applyTlsTrustPolicy(); diff --git a/docs/releasing.md b/docs/releasing.md index 58a166a17..29618052d 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -31,6 +31,8 @@ The workflow will: - Build and push Docker images to GHCR - Create Linux x64 and arm64 binaries - Create NPM tarball and checksums +- Attest the AWF binaries, bundle, tarball, checksums, container manifest and + tag-to-checksums identity using the pinned build-provenance action - Generate versioned JSON Schema files with the release tag embedded in their `$id` URLs - Publish the GitHub Release with auto-generated changelog - Dispatch full Cloud Hypervisor Enclave Conformance on that exact published tag @@ -129,14 +131,61 @@ Each release includes: ### GitHub Release Assets - `awf-linux-x64` - Linux x64 standalone executable - `awf-linux-arm64` - Linux arm64 standalone executable +- `awf-darwin-x64`, `awf-darwin-arm64` - macOS standalone executables +- `awf-bundle.js` - Lightweight Node.js executable bundle - `awf.tgz` - NPM package tarball (alternative installation method) - `checksums.txt` - SHA256 checksums for all files - `containers.txt` - Digest-pinned container manifest for published runtime images +- `release-identity.txt` - Attested binding of the requested `refs/tags/` + to the SHA256 digest of `checksums.txt` - `awf-config.schema.json` - AWF config JSON Schema - `awf-config.v1.schema.json` - **Deprecated alias** of `awf-config.schema.json` (kept for backward compatibility) - `audit.schema.json` - AWF audit JSONL record JSON Schema - `token-usage.schema.json` - AWF token-usage JSONL record JSON Schema +### Bootstrap authentication and compatibility + +The installer and setup action require GitHub build-provenance attestations +for the payload and release metadata. They constrain verification to +`github/gh-aw-firewall`, signer workflow `.github/workflows/release.yml`, +the GitHub Actions OIDC issuer, and GitHub-hosted runners. Attestations must +be published before uploading the corresponding release assets. + +The release workflow is dispatched on `main` or a maintenance branch and +then checks out the new tag. Its certificate's source ref is therefore the +dispatch branch, **not** the checkout tag: adding +`--source-ref refs/tags/` would reject these real releases. +Instead, the separately attested `release-identity.txt` contains exactly +` refs/tags/`. Consumers verify its provenance, +require the requested tag and checksums digest to match, and verify the +checksums' and payload's provenance before installation. The setup action +also authenticates and checksum-checks `containers.txt` before reading +image digests, requires all runtime image pins, and never falls back to +mutable tags when a manifest or digest-pinned pull fails. Existing enclave +artifact verification and acceptance checks remain additional requirements. + +Legacy releases without these attestations and identity file intentionally +fail closed. Select a release published with this protocol; do not bypass +verification or silently fall back to checksum-only installation. +The signed tag binding prevents cross-release substitution but does not +establish freshness of the unauthenticated `latest` API response. Pin an +explicit known release version when rollback resistance is needed. + +**Initial trust is a prerequisite, not something the installer can create.** +Obtain and review the installer (or pin the setup action to a reviewed commit) +through an independently authenticated channel. Obtain `gh` with +`attestation verify --signer-workflow` support, and any Node.js runtime used +for the bundle, through a trusted package/image distribution or another +authenticated bootstrap. Configure GitHub authentication for `gh` +(`GH_TOKEN`/`GITHUB_TOKEN` or `gh auth login`; preserve it appropriately +when using sudo). The installer does not download its own verifier. +Use a verifier built with Go's fixed certificate validation where applicable, +or an independently trusted verifier not affected by the reported CA-chain +issue. A compromised verifier can subvert verification; a compromised CA +can also affect HTTPS trust-root retrieval. Downloading both a payload and +its checksum over HTTPS, or blindly running `curl | sudo bash`, is not an +independent authentication mechanism. + ### JSON Schema versioning Each release generates all three schemas with a `$id` URL that includes the release tag, creating stable, pinnable references: diff --git a/docs/security-ca-trust.md b/docs/security-ca-trust.md new file mode 100644 index 000000000..d3d30a674 --- /dev/null +++ b/docs/security-ca-trust.md @@ -0,0 +1,99 @@ +# Emergency CA distrust policy + +AWF applies an emergency distrust policy for the October 2026 +[eMudhra intermediate-key disclosure](https://emudhra.we-have-a-problem.com/). +There is no evidence of exploitation of AWF. Compromised TLS issuer keys can +enable interception of connections to **already allowed** domains when an +attacker can also redirect traffic; domain allowlisting is not a substitute for +certificate authentication. The two disclosed code-signing issuer keys are not +themselves TLS issuers. + +## Policy and compatibility + +`src/tls-trust-policy.ts` is the canonical policy. It lists the eight compromised +SPKI SHA-256 hashes from +[`impacted-intermediates.json`](https://emudhra.we-have-a-problem.com/impacted-intermediates.json). +For clients without intermediate-SPKI revocation, AWF also temporarily distrusts +the four issuer/cross-signer root keys listed in +[`roots.json`](https://emudhra.we-have-a-problem.com/roots.json): +emSign Root TLS CA - G3, emSign Root CS CA - G3, AAA Certificate Services +(also distributed as Comodo AAA Services root), and emSign ECC Root CA - G3. +Key-based matching also removes same-key cross-signs with different certificate +fingerprints. This deliberately blocks **unrelated chains under these roots**; +it is a conservative emergency fallback, not a claim that those root private +keys were compromised. Revisit root distrust once upstream runtimes enforce +the intermediate-key revocations. + +Generate the checked-in per-image copies with +`node scripts/security/generate-trust-policy.cjs`; `--check` detects drift. +Copies are required because ordinary Docker images have independent component +build contexts. The enclave and Cloud Hypervisor images use `containers/`. + +## Protected AWF-controlled paths + +- The full/minimal agent, Squid, API/CLI proxies, router, build-tools/DinD sysroots, gh-aw-node, + enclave images, and Cloud Hypervisor guest base ship filtered OpenSSL bundles + and hashed directories. Squid interception explicitly uses that store and + rejects certificate errors. CONNECT/spliced traffic remains end-to-end TLS, + so its client must validate the upstream certificate. +- Node uses a mandatory runtime preload, not just `NODE_EXTRA_CA_CERTS` (which + only adds trust). The preload filters default/embedded, extra, and explicit + custom CAs for HTTPS, direct TLS, and fetch. Image Node launchers preserve the + preload for child processes and do not depend solely on a caller-preserved + `NODE_OPTIONS`. The host AWF CLI loads the same policy. +- Chroot imports are filtered into container-local `/run/awf-lib/trust`. + Read-only bind overlays cover the mounted Debian/RHEL CA directories; + CAfile aliases and `SSL_CERT_DIR` use only the sanitized store. The host trust + store is **not modified**. AWF interception and legitimate private CAs remain + trusted unless their keys are denylisted. Missing, malformed, or entirely + distrusted stores fail startup rather than silently restoring host trust. +- The CLI proxy's dynamically combined gateway CA bundle is also filtered. + The API proxy's private upstream CA mount is preserved and filtered by Node. +- All image-built `gh` binaries use Go 1.27.3 (CVE-2026-111943 fix), with a build + assertion on the **compiled binary's toolchain metadata**, in addition to + existing source checksums and module-version assertions. +- Release binaries/bundles and checksums/container metadata receive build + provenance. Bootstrap verifies constrained provenance before installation or + use of image digests. See [release authentication](releasing.md) for trusted + verifier requirements and the intentional refusal of unsigned older releases. + +## Boundaries and rollout + +Rebuild and publish **all affected images and guest rootfs artifacts**, then +upgrade AWF and its image pins together. Existing containers, old image digests, +pre-existing guest rootfs images, and older installed AWF releases are unchanged. +Build runners and the initial installer/verifier must already be obtained through +a trusted channel and have updated TLS trust; an installer cannot authenticate +the script that is already executing. + +AWF cannot universally repair arbitrary host-installed or user-provided TLS +runtimes. Update host clients and operating-system stores separately, including +the Docker daemon's registry trust. Java keystores, Python certifi/pip's vendored +certifi, Rust/native embedded-root clients, browser stores, independently +configured CA paths, and applications disabling certificate verification need +their own vendor updates or explicitly configured sanitized trust. A user +command can also override `NODE_OPTIONS`, invoke an unwrapped executable, or +provide its own trust context; this policy is not a new enforcement boundary +against adversarial sandbox commands. Root filtering is not general online +revocation checking and does not replace ordinary CA/runtime updates. + +Offline regressions cover SPKI matching, same-key cross-sign removal, hashed +directory fallback, unchanged source stores, private/AWF CA preservation, and +Node HTTPS/direct TLS/fetch behavior. Full Linux mount, Squid configuration, +container build, and guest-boot integration require a Linux Docker/KVM runner; +they cannot be established by the offline tests alone. + +## Regression checks + +Run `npm test -- scripts/security/ca-trust.test.ts` for the policy, generated-copy, +image-wiring, and offline TLS regressions. The dependency-free runtime smoke, +`node --test scripts/security/tls-runtime-smoke.cjs`, can also run directly inside +each rebuilt Node image with the repository mounted at `/work`; it needs +OpenSSL and the image's installed Node launcher. + +For actual mount validation, run `scripts/security/chroot-trust-smoke.sh` inside +a **disposable Linux container** with `CAP_SYS_ADMIN`, the repository mounted +read-only at `/work`, and `containers/trust` mounted read-only at +`/opt/awf/trust`. It rejects execution outside a container or with an existing +`/host`, verifies the real bind overlays, and checks that the source store stays +unchanged. Do not run this mount test on a host or in a live AWF container. diff --git a/install.sh b/install.sh index 8de8df9b2..bb713f3ca 100755 --- a/install.sh +++ b/install.sh @@ -3,27 +3,30 @@ set -e # Install script for awf (Agentic Workflow Firewall) # -# This script downloads, verifies, and installs the awf binary with SHA256 validation -# to protect against corrupted or tampered downloads. +# This script authenticates release provenance before installing the awf binary. +# Obtain this installer and the gh verifier through an independently trusted channel; +# piping an unauthenticated HTTPS download into a shell cannot authenticate itself. # -# Usage: +# Usage (after independently authenticating this script and gh): # # Install latest version -# curl -sSL https://raw.githubusercontent.com/github/gh-aw-firewall/main/install.sh | sudo bash +# sudo bash ./install.sh # # # Install specific version -# curl -sSL https://raw.githubusercontent.com/github/gh-aw-firewall/main/install.sh | sudo bash -s -- v1.0.0 +# sudo bash ./install.sh v1.0.0 # # # Or with environment variable -# curl -sSL https://raw.githubusercontent.com/github/gh-aw-firewall/main/install.sh | sudo AWF_VERSION=v1.0.0 bash +# sudo AWF_VERSION=v1.0.0 bash ./install.sh # # Security features: # - Uses curl -f to fail on HTTP errors (404, 403, etc.) -# - Verifies SHA256 checksum from official checksums.txt +# - Requires GitHub artifact attestations from the release workflow +# - Authenticates the requested tag and checksums before checking the payload # - Validates downloaded file is a valid ELF executable # - Detects HTML error pages that may slip through # # Requirements: # - curl +# - A trusted gh CLI with attestation verify support (and GitHub authentication) # - sha256sum # - file # - sudo/root access @@ -81,7 +84,7 @@ sha256_portable() { check_requirements() { local missing=() - for cmd in curl file; do + for cmd in curl file gh; do if ! command -v "$cmd" &> /dev/null; then missing+=("$cmd") fi @@ -97,6 +100,12 @@ check_requirements() { error "Please install them and try again" exit 1 fi + + if ! gh attestation verify --help 2>/dev/null | grep -q -- '--signer-workflow'; then + error "A trusted gh CLI with 'attestation verify --signer-workflow' support is required" + error "Install or upgrade gh through an independently authenticated channel" + exit 1 + fi } # Check OS and architecture @@ -205,6 +214,7 @@ set_version() { else get_latest_version fi + validate_version "$VERSION" } # Download file @@ -218,6 +228,9 @@ download_file() { if ! curl -fsSL "$url" -o "$output"; then error "Failed to download $url" error "Please check if the release exists and try again" + if [ "$(basename "$output")" = "release-identity.txt" ]; then + error "Legacy releases without attested release identity metadata are not supported" + fi exit 1 fi @@ -237,6 +250,37 @@ download_file() { fi } +# A checksum fetched over the same TLS connection as a payload is not authentication. +verify_provenance() { + local asset="$1" + if ! gh attestation verify "$asset" \ + --hostname github.com \ + --repo "$REPO" \ + --signer-workflow "${REPO}/.github/workflows/release.yml" \ + --cert-oidc-issuer https://token.actions.githubusercontent.com \ + --deny-self-hosted-runners; then + error "Release provenance verification failed for $(basename "$asset")" + error "An attested release and a trusted, authenticated gh CLI are required; legacy unattested releases are not supported" + exit 1 + fi +} + +# The release workflow is dispatched on a branch and checks out a new tag. +# Its certificate therefore identifies the dispatch branch, not the checkout tag. +# Authenticate the signed tag-to-checksums binding instead of trusting TLS metadata. +verify_release_identity() { + local checksums_file="$1" + local identity_file="$2" + verify_provenance "$identity_file" + local expected_identity + expected_identity="$(sha256_portable "$checksums_file") refs/tags/${VERSION}" + if [ "$(cat "$identity_file")" != "$expected_identity" ]; then + error "Release identity does not bind checksums.txt to refs/tags/${VERSION}" + exit 1 + fi + verify_provenance "$checksums_file" +} + # Verify checksum verify_checksum() { local file="$1" @@ -307,6 +351,9 @@ main() { # Download URLs BASE_URL="https://github.com/${REPO}/releases/download/${VERSION}" CHECKSUMS_URL="${BASE_URL}/checksums.txt" + download_file "$CHECKSUMS_URL" "$TEMP_DIR/checksums.txt" + download_file "${BASE_URL}/release-identity.txt" "$TEMP_DIR/release-identity.txt" + verify_release_identity "$TEMP_DIR/checksums.txt" "$TEMP_DIR/release-identity.txt" if [ "$USE_BUNDLE" = true ]; then # Lightweight bundle path — requires Node.js >= 20 @@ -314,7 +361,7 @@ main() { ASSET_URL="${BASE_URL}/${ASSET_NAME}" download_file "$ASSET_URL" "$TEMP_DIR/$ASSET_NAME" - download_file "$CHECKSUMS_URL" "$TEMP_DIR/checksums.txt" + verify_provenance "$TEMP_DIR/$ASSET_NAME" # Verify checksum (reuse BINARY_NAME for the checksum lookup) BINARY_NAME="$ASSET_NAME" @@ -338,7 +385,7 @@ main() { # Download binary and checksums download_file "$BINARY_URL" "$TEMP_DIR/$BINARY_NAME" - download_file "$CHECKSUMS_URL" "$TEMP_DIR/checksums.txt" + verify_provenance "$TEMP_DIR/$BINARY_NAME" # Verify checksum verify_checksum "$TEMP_DIR/$BINARY_NAME" "$TEMP_DIR/checksums.txt" diff --git a/scripts/ci/release-bootstrap-provenance.test.ts b/scripts/ci/release-bootstrap-provenance.test.ts new file mode 100644 index 000000000..fc5086a61 --- /dev/null +++ b/scripts/ci/release-bootstrap-provenance.test.ts @@ -0,0 +1,274 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { createHash } from 'crypto'; +import { spawnSync } from 'child_process'; +import * as yaml from 'js-yaml'; + +const root = path.resolve(__dirname, '../..'); +const installer = fs.readFileSync(path.join(root, 'install.sh'), 'utf8'); +type Step = { name: string; run?: string; uses?: string; with?: Record }; +const action = yaml.load(fs.readFileSync(path.join(root, 'action.yml'), 'utf8')) as { + runs: { steps: Step[] }; +}; +const installAction = action.runs.steps.find(step => step.name === 'Install awf')!.run!; +const pullAction = action.runs.steps.find(step => step.name === 'Pull Docker images')!.run!; +const scratch = path.join(root, `.bootstrap-provenance-tests-${process.pid}`); +const hash = (text: string) => createHash('sha256').update(text).digest('hex'); +const tag = 'v1.2.3'; +const images = [ + 'squid', 'agent', 'agent-act', 'api-proxy', 'router', 'cli-proxy', + 'enclave-script', 'enclave-agent', 'enclave-mcp-server', +]; +let sequence = 0; + +function fixture(options: { bundle?: boolean; identityTag?: string; containers?: string } = {}) { + const directory = path.join(scratch, String(sequence++)); + const bin = path.join(directory, 'bin'); + const assets = path.join(directory, 'assets'); + fs.mkdirSync(bin, { recursive: true }); + fs.mkdirSync(assets); + fs.mkdirSync(path.join(directory, 'installed')); + const payload = options.bundle ? '#!/usr/bin/env node\nconsole.log("awf");\n' : 'test ELF payload\n'; + const containers = options.containers ?? + images.map(image => `ghcr.io/github/gh-aw-firewall/${image}@sha256:${'a'.repeat(64)}\n`).join(''); + const checksums = [ + `${hash(payload)} awf-linux-x64`, + `${hash(payload)} awf-bundle.js`, + `${hash(containers)} containers.txt`, + '', + ].join('\n'); + for (const [name, content] of Object.entries({ + 'awf-linux-x64': payload, + 'awf-bundle.js': payload, + 'checksums.txt': checksums, + 'containers.txt': containers, + 'release-identity.txt': `${hash(checksums)} refs/tags/${options.identityTag ?? tag}\n`, + })) fs.writeFileSync(path.join(assets, name), content); + const scripts = { + gh: `#!/bin/bash +if [ "$3" = "--help" ]; then + [ "\${GH_NO_SUPPORT:-}" != "1" ] || exit 1 + echo "--signer-workflow --deny-self-hosted-runners" + exit 0 +fi +printf '%s\\n' "$*" >> "$CASE_DIR/gh.log" +[ "$1 $2" = "attestation verify" ] || exit 51 +[[ "$*" == *"--hostname github.com"* ]] || exit 52 +[[ "$*" == *"--repo github/gh-aw-firewall"* ]] || exit 53 +[[ "$*" == *"--signer-workflow github/gh-aw-firewall/.github/workflows/release.yml"* ]] || exit 54 +[[ "$*" == *"--cert-oidc-issuer https://token.actions.githubusercontent.com"* ]] || exit 55 +[[ "$*" == *"--deny-self-hosted-runners"* ]] || exit 56 +if [ -n "\${FAIL_ATTESTATION:-}" ] && [[ "$3" == *"$FAIL_ATTESTATION"* ]]; then exit 57; fi +`, + curl: `#!/bin/bash +url="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) output="$2"; shift 2 ;; + https://*) url="$1"; shift ;; + *) shift ;; + esac +done +asset="\${url##*/}" +printf '%s\\n' "$asset" >> "$CASE_DIR/curl.log" +if [ "$asset" = latest ]; then printf '{"tag_name": "%s"}\\n' "\${LATEST_TAG:-v1.2.3}"; exit; fi +cp "$CASE_DIR/assets/$asset" "$output" +`, + file: '#!/bin/bash\necho "$1: ELF 64-bit LSB executable"\n', + uname: '#!/bin/bash\nif [ "$1" = "-s" ]; then echo Linux; else echo x86_64; fi\n', + docker: '#!/bin/bash\nprintf "%s\\n" "$*" >> "$CASE_DIR/docker.log"\nexit "${DOCKER_STATUS:-0}"\n', + node: '#!/bin/bash\necho v22.0.0\n', + }; + for (const [name, script] of Object.entries(scripts)) { + fs.writeFileSync(path.join(bin, name), script, { mode: 0o755 }); + } + return { + directory, + assets, + run(target: 'installer' | 'action' | 'pull', overrides: Record = {}) { + const script = target === 'installer' + ? installer.replace(/\nmain "\$@"\s*$/, '\n') + .replace('TEMP_DIR=$(mktemp -d -t awf-install.XXXXXX)', + 'TEMP_DIR="$CASE_DIR/downloads"; mkdir "$TEMP_DIR"') + + '\ncheck_sudo() { :; }\n' + + // Keep all test artifacts in this project's scratch directory, never the OS temp area. + 'INSTALL_DIR="$CASE_DIR/installed"\nmain "$INPUT_VERSION"\n' + : target === 'action' ? installAction : pullAction; + return spawnSync('bash', ['-c', script], { + cwd: directory, + encoding: 'utf8', + env: { + ...process.env, + PATH: `${bin}:${process.env.PATH}`, + CASE_DIR: directory, + AWF_VERSION: '', + AWF_FORCE_BINARY: options.bundle ? '' : '1', + INPUT_VERSION: tag, + GITHUB_TOKEN: '', + RUNNER_TEMP: directory, + GITHUB_OUTPUT: path.join(directory, 'output'), + GITHUB_PATH: path.join(directory, 'github-path'), + ...overrides, + }, + }); + }, + log(name: string) { + const file = path.join(directory, name); + return fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''; + }, + }; +} + +afterAll(() => fs.rmSync(scratch, { recursive: true, force: true })); + +describe('release bootstrap authentication', () => { + it.each(['installer', 'action'] as const)('installs an authenticated %s payload', target => { + const f = fixture(); + const result = f.run(target); + expect(result.stderr + result.stdout).not.toContain('::error::'); + expect(result.status).toBe(0); + const destination = path.join(f.directory, target === 'installer' ? 'installed/awf' : 'awf-bin/awf'); + expect(fs.statSync(destination).mode & 0o111).not.toBe(0); + const calls = f.log('gh.log').trim().split('\n'); + expect(calls[0]).toContain('release-identity.txt'); + expect(calls[1]).toContain('checksums.txt'); + expect(calls[2]).toContain('awf-linux-x64'); + if (target === 'action') { + expect(calls[3]).toContain('containers.txt'); + expect(f.log('output')).toContain(`squid=sha256:${'a'.repeat(64)}`); + } + }); + + it('authenticates the lightweight bundle before installation', () => { + const f = fixture({ bundle: true }); + expect(f.run('installer').status).toBe(0); + expect(f.log('gh.log')).toContain('awf-bundle.js'); + }); + + it('rejects an unattested lightweight bundle', () => { + const f = fixture({ bundle: true }); + expect(f.run('installer', { FAIL_ATTESTATION: 'awf-bundle.js' }).status).not.toBe(0); + expect(fs.existsSync(path.join(f.directory, 'installed/awf'))).toBe(false); + }); + + for (const target of ['installer', 'action'] as const) { + it(`${target} requires a trusted, attestation-capable verifier before any download`, () => { + const f = fixture(); + const result = f.run(target, { GH_NO_SUPPORT: '1' }); + expect(result.status).not.toBe(0); + expect(result.stderr + result.stdout).toMatch(/trusted gh CLI/); + expect(f.log('curl.log')).toBe(''); + }); + + it.each(['release-identity.txt', 'checksums.txt', 'awf-linux-x64'])( + `${target} rejects failed provenance for %s`, + asset => { + const f = fixture(); + const result = f.run(target, { FAIL_ATTESTATION: asset }); + expect(result.status).not.toBe(0); + expect(result.stderr + result.stdout).toMatch(/attested release/i); + expect(f.log('github-path')).toBe(''); + expect(fs.existsSync(path.join(f.directory, 'installed/awf'))).toBe(false); + expect(fs.existsSync(path.join(f.directory, 'awf-bin/awf'))).toBe(false); + }, + ); + + it(`${target} rejects another release's signed identity`, () => { + const f = fixture({ identityTag: 'v9.9.9' }); + expect(f.run(target).status).not.toBe(0); + expect(f.log('gh.log').trim().split('\n')).toHaveLength(1); + }); + + it(`${target} rejects same-channel substitution of payload and checksums`, () => { + const f = fixture(); + const payload = 'attacker controlled executable'; + fs.writeFileSync(path.join(f.assets, 'awf-linux-x64'), payload); + fs.writeFileSync(path.join(f.assets, 'checksums.txt'), `${hash(payload)} awf-linux-x64\n`); + const result = f.run(target); + expect(result.status).not.toBe(0); + expect(result.stderr + result.stdout).toContain('Release identity does not bind'); + }); + + it(`${target} retains checksum validation in addition to provenance`, () => { + const f = fixture(); + fs.appendFileSync(path.join(f.assets, 'awf-linux-x64'), 'changed\n'); + const result = f.run(target); + expect(result.status).not.toBe(0); + expect(result.stderr + result.stdout).toContain('Checksum verification failed'); + }); + + it(`${target} fails closed for legacy releases missing identity metadata`, () => { + const f = fixture(); + fs.unlinkSync(path.join(f.assets, 'release-identity.txt')); + expect(f.run(target).status).not.toBe(0); + expect(f.log('gh.log')).toBe(''); + }); + + it(`${target} validates a latest API response before downloading assets`, () => { + const f = fixture(); + expect(f.run(target, { INPUT_VERSION: target === 'installer' ? '' : 'latest', LATEST_TAG: '../bad' }).status).not.toBe(0); + expect(f.log('curl.log').trim()).toBe('latest'); + }); + } + + it.each(['missing', 'unattested', 'substituted', 'duplicate', 'incomplete'])( + 'rejects a %s container manifest without tag fallback', + failure => { + const containers = `ghcr.io/github/gh-aw-firewall/squid@sha256:${'a'.repeat(64)}\n`; + const f = fixture(failure === 'duplicate' ? { containers: containers.repeat(2) } : + failure === 'incomplete' ? { containers } : {}); + if (failure === 'missing') fs.unlinkSync(path.join(f.assets, 'containers.txt')); + if (failure === 'substituted') fs.appendFileSync(path.join(f.assets, 'containers.txt'), 'changed\n'); + const result = f.run('action', failure === 'unattested' ? { FAIL_ATTESTATION: 'containers.txt' } : {}); + expect(result.status).not.toBe(0); + expect(f.log('output')).not.toContain('image_tag='); + expect(f.log('github-path')).toBe(''); + }, + ); + + it('pulls only authenticated digest-pinned images and does not retry latest on failure', () => { + const f = fixture(); + const imageTag = `${tag.slice(1)},squid=sha256:${'a'.repeat(64)},agent=sha256:${'b'.repeat(64)}`; + expect(f.run('pull', { IMAGE_TAG: imageTag }).status).toBe(0); + expect(f.log('docker.log')).toBe( + `pull ghcr.io/github/gh-aw-firewall/squid@sha256:${'a'.repeat(64)}\n` + + `pull ghcr.io/github/gh-aw-firewall/agent@sha256:${'b'.repeat(64)}\n`, + ); + const failed = fixture(); + expect(failed.run('pull', { IMAGE_TAG: imageTag, DOCKER_STATUS: '1' }).status).not.toBe(0); + expect(failed.log('docker.log').trim().split('\n')).toHaveLength(1); + expect(failed.log('docker.log')).not.toContain('latest'); + }); +}); + +describe('release provenance publication', () => { + const workflow = yaml.load(fs.readFileSync(path.join(root, '.github/workflows/release.yml'), 'utf8')) as { + permissions: Record; + jobs: Record; + }; + const steps = workflow.jobs.release.steps; + + it('attests every bootstrap subject after generation and before publication', () => { + const attest = steps.findIndex(step => step.name === 'Attest AWF release payloads and metadata'); + const identity = steps.findIndex(step => step.name === 'Bind release tag to checksums'); + const checksums = steps.findIndex(step => step.name === 'Generate checksums'); + const publish = steps.findIndex(step => step.name === 'Create GitHub Release'); + expect(workflow.permissions.attestations).toBe('write'); + expect(checksums).toBeLessThan(identity); + expect(identity).toBeLessThan(attest); + expect(attest).toBeLessThan(publish); + expect(steps[attest].uses).toBe( + 'actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8', + ); + for (const asset of [ + 'awf-linux-x64', 'awf-linux-arm64', 'awf-darwin-x64', 'awf-darwin-arm64', + 'awf-bundle.js', 'awf.tgz', 'checksums.txt', 'containers.txt', 'release-identity.txt', + ]) { + expect(steps[attest].with?.['subject-path']).toContain(`release/${asset}\n`); + expect(steps[publish].with?.files).toContain(`release/${asset}\n`); + } + expect(steps[identity].run).toContain('refs/tags/${RELEASE_TAG}^{commit}'); + expect(steps[identity].run).toContain('sha256sum release/checksums.txt'); + expect(steps[identity].run).toContain('refs/tags/%s'); + }); +}); diff --git a/scripts/security/ca-trust.test.ts b/scripts/security/ca-trust.test.ts new file mode 100644 index 000000000..20ba2100a --- /dev/null +++ b/scripts/security/ca-trust.test.ts @@ -0,0 +1,199 @@ +import { execFileSync, spawn } from 'node:child_process'; +import { createHash, X509Certificate } from 'node:crypto'; +import * as fs from 'node:fs'; +import * as https from 'node:https'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +const root = path.resolve(__dirname, '../..'); +const policyDir = path.join(root, 'containers/trust'); +let work: string; +let blockedRoot: string; +let goodRoot: string; +let blockedKey: string; + +function openssl(...args: string[]): string { + return execFileSync('openssl', args, { cwd: work, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); +} + +function spki(pem: string): string { + return createHash('sha256').update( + new X509Certificate(pem).publicKey.export({ format: 'der', type: 'spki' }), + ).digest('hex'); +} + +function makeRoot(name: string): string { + openssl('req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '2', + '-subj', `/CN=${name}`, '-keyout', `${name}.key`, '-out', `${name}.pem`, + '-addext', 'basicConstraints=critical,CA:TRUE'); + return fs.readFileSync(path.join(work, `${name}.pem`), 'utf8'); +} + +function makeLeaf(name: string, issuer: string): void { + openssl('req', '-new', '-newkey', 'rsa:2048', '-nodes', '-subj', '/CN=localhost', + '-keyout', `${name}.key`, '-out', `${name}.csr`); + openssl('x509', '-req', '-in', `${name}.csr`, '-CA', `${issuer}.pem`, + '-CAkey', `${issuer}.key`, '-CAcreateserial', '-days', '2', + '-extfile', 'leaf.ext', '-out', `${name}.pem`); +} + +beforeAll(() => { + work = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-trust-test-')); + blockedRoot = makeRoot('blocked'); + goodRoot = makeRoot('private-awf'); + blockedKey = spki(blockedRoot); + fs.writeFileSync(path.join(work, 'leaf.ext'), 'subjectAltName=DNS:localhost\nbasicConstraints=CA:FALSE\n'); + makeLeaf('bad-leaf', 'blocked'); + makeLeaf('good-leaf', 'private-awf'); + fs.writeFileSync(path.join(work, 'inputs.pem'), blockedRoot + goodRoot); + fs.copyFileSync(path.join(policyDir, 'filter-ca-store.sh'), path.join(work, 'filter-ca-store.sh')); + fs.writeFileSync(path.join(work, 'distrusted-spki.txt'), `${blockedKey}\n`); + // Exercise production policy code with a generated, offline compromised key. + const policy = fs.readFileSync(path.join(policyDir, 'node-trust.cjs'), 'utf8') + .replace('43bffa19d6ef651451f6841e019931e96f6f2eaeff28ab4b6f1ce4576de2717b', blockedKey); + fs.writeFileSync(path.join(work, 'policy.cjs'), policy); +}, 30000); + +afterAll(() => fs.rmSync(work, { recursive: true, force: true })); + +test('generated Docker trust policies remain synchronized with canonical TypeScript', () => { + execFileSync(process.execPath, ['scripts/security/generate-trust-policy.cjs', '--check'], { cwd: root }); +}); + +test('dependency-free runtime transport smoke also runs in the root regression suite', () => { + execFileSync(process.execPath, ['--test', 'scripts/security/tls-runtime-smoke.cjs'], { cwd: root, stdio: 'pipe' }); +}, 30000); + +test('known compromised and root SPKIs are all present and valid', () => { + const keys = fs.readFileSync(path.join(policyDir, 'distrusted-spki.txt'), 'utf8').trim().split('\n'); + expect(new Set(keys).size).toBe(12); + expect(keys.every(key => /^[a-f0-9]{64}$/.test(key))).toBe(true); + expect(keys).toContain('8d417db2dd8bf5e3084d1e3f196d583849d81bdd4c00c70b9d39369e96b8c782'); + expect(keys).toContain('bd153ed7b0434f6886b17bce8bbe84ed340c7132d702a8f4fa318f756ecbd6f3'); +}); + +test('filters bundles and hashed directories without modifying input trust', () => { + const inputDir = path.join(work, 'input-certs'); + fs.mkdirSync(inputDir); + fs.writeFileSync(path.join(inputDir, 'blocked.pem'), blockedRoot); + fs.writeFileSync(path.join(inputDir, 'private.pem'), goodRoot); + openssl('rehash', inputDir); + const original = fs.readFileSync(path.join(work, 'inputs.pem'), 'utf8'); + const output = path.join(work, 'filtered'); + execFileSync('sh', [path.join(work, 'filter-ca-store.sh'), output, inputDir, path.join(work, 'inputs.pem')]); + const bundle = fs.readFileSync(path.join(output, 'ca-certificates.crt'), 'utf8'); + expect(bundle).toContain(goodRoot.trim()); + expect(bundle).not.toContain(blockedRoot.trim()); + expect(fs.readFileSync(path.join(work, 'inputs.pem'), 'utf8')).toBe(original); + expect(openssl('verify', '-CAfile', path.join(output, 'ca-certificates.crt'), + '-CApath', path.join(output, 'certs'), 'good-leaf.pem')).toContain('OK'); + expect(() => openssl('verify', '-CAfile', path.join(output, 'ca-certificates.crt'), + '-CApath', path.join(output, 'certs'), 'bad-leaf.pem')).toThrow(); + // A same-key cross-sign has a different certificate fingerprint but is removed. + openssl('req', '-new', '-key', 'blocked.key', '-subj', '/CN=cross-signed', '-out', 'cross.csr'); + fs.writeFileSync(path.join(work, 'ca.ext'), 'basicConstraints=critical,CA:TRUE\n'); + openssl('x509', '-req', '-in', 'cross.csr', '-CA', 'private-awf.pem', '-CAkey', 'private-awf.key', + '-CAcreateserial', '-days', '2', '-extfile', 'ca.ext', '-out', 'cross.pem'); + const cross = fs.readFileSync(path.join(work, 'cross.pem'), 'utf8'); + expect(spki(cross)).toBe(blockedKey); + fs.writeFileSync(path.join(work, 'cross-bundle.pem'), cross + goodRoot); + execFileSync('sh', [path.join(work, 'filter-ca-store.sh'), path.join(work, 'cross-output'), + path.join(work, 'cross-bundle.pem')]); + expect(fs.readFileSync(path.join(work, 'cross-output/ca-certificates.crt'), 'utf8')).not.toContain(cross.trim()); +}); + +test('invalid and all-distrusted CA stores fail closed', () => { + fs.writeFileSync(path.join(work, 'invalid.pem'), 'not a certificate'); + fs.writeFileSync(path.join(work, 'only-blocked.pem'), blockedRoot); + for (const input of ['invalid.pem', 'only-blocked.pem', 'missing.pem']) { + expect(() => execFileSync('sh', [path.join(work, 'filter-ca-store.sh'), + path.join(work, `reject-${input}`), path.join(work, input)], { stdio: 'pipe' })).toThrow(); + } +}); + +test.each(['https', 'tls', 'fetch'])('%s rejects compromised default/extra/explicit trust and keeps private CA support', async mode => { + for (const [leaf, allowed] of [['bad-leaf', false], ['good-leaf', true]] as const) { + const server = https.createServer({ + key: fs.readFileSync(path.join(work, `${leaf}.key`)), + cert: fs.readFileSync(path.join(work, `${leaf}.pem`)), + }, (_req, res) => res.end('ok')); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Missing test server address'); + try { + for (const explicit of [false, true]) { + const script = ` + const fs = require('fs'); + const options = {host:'127.0.0.1', port:${address.port}, servername:'localhost', + ${explicit ? `ca:fs.readFileSync(${JSON.stringify(path.join(work, 'inputs.pem'))}),` : ''}}; + const fail = () => process.exit(${allowed ? 1 : 0}); + const pass = () => process.exit(${allowed ? 0 : 1}); + if (${JSON.stringify(mode)} === 'tls') { + require('tls').connect(options, pass).on('error', fail); + } else if (${JSON.stringify(mode)} === 'fetch') { + // Fetch uses the filtered default store; explicit CA is exercised by https/tls. + fetch('https://localhost:${address.port}').then(pass, fail); + } else { + require('https').get(options, pass).on('error', fail); + } + setTimeout(() => process.exit(2), 5000).unref(); + `; + const status = await new Promise((resolve, reject) => { + const child = spawn(process.execPath, ['--require', path.join(work, 'policy.cjs'), '-e', script], { + env: { ...process.env, NODE_EXTRA_CA_CERTS: path.join(work, 'inputs.pem') }, + stdio: 'pipe', + }); + let stderr = ''; + child.stderr.on('data', chunk => { stderr += chunk; }); + child.on('error', reject); + child.on('close', code => code === 0 ? resolve(code) : reject(new Error(`${mode}/${leaf}: ${stderr} (exit ${code})`))); + }); + expect(status).toBe(0); + } + } finally { + await new Promise(resolve => server.close(() => resolve())); + } + } +}, 30000); + +test('Node defaults no longer contain emergency-distrusted roots', () => { + const script = ` + const tls = require('tls'), {X509Certificate,createHash} = require('crypto'); + const {distrustedSpkis} = require(${JSON.stringify(path.join(policyDir, 'node-trust.cjs'))}); + for (const pem of tls.getCACertificates('default')) { + const key = new X509Certificate(pem).publicKey.export({format:'der',type:'spki'}); + if (distrustedSpkis.has(createHash('sha256').update(key).digest('hex'))) process.exit(1); + } + `; + execFileSync(process.execPath, ['-r', path.join(policyDir, 'node-trust.cjs'), '-e', script]); +}); + +test('all gh builds use the fixed Go toolchain and assert compiled metadata', () => { + for (const component of ['agent', 'cli-proxy', 'build-tools']) { + const dockerfile = fs.readFileSync(path.join(root, 'containers', component, 'Dockerfile'), 'utf8'); + expect(dockerfile).toContain('golang:1.27.3-alpine@sha256:f2c872bcee949b7be9c490efd02b48a8a2fbf947cdf533f1c58525ec9d8170fd'); + expect(dockerfile).toContain("go version -m bin/gh | head -n 1 | grep -Eq ': go1\\.27\\.3$'"); + } +}); + +test('every controlled image wires the policy from its actual build context', () => { + for (const component of ['agent', 'api-proxy', 'cli-proxy', 'build-tools', 'gh-aw-node', + 'router', 'dind-ubuntu', 'squid', 'enclave', 'cloud-hypervisor']) { + const dockerfile = fs.readFileSync(path.join(root, 'containers', component, 'Dockerfile'), 'utf8'); + expect(dockerfile).toContain('COPY trust/ /opt/awf/trust/'); + expect(dockerfile).toContain('sh /opt/awf/trust/install-system-trust.sh'); + if (!['squid', 'router', 'build-tools'].includes(component)) { + expect(dockerfile).toContain('sh /opt/awf/trust/install-node-trust.sh'); + } + } + const minimal = fs.readFileSync(path.join(root, 'containers/agent/Dockerfile.minimal'), 'utf8'); + expect(minimal).toContain('COPY trust/ /opt/awf/trust/'); + expect(minimal).toContain('openssl ca-certificates'); + const routerIgnore = fs.readFileSync(path.join(root, 'containers/router/.dockerignore'), 'utf8'); + expect(routerIgnore).toContain('!trust/**'); + for (const component of ['cli-proxy', 'api-proxy', 'squid', 'router']) { + const dockerfile = fs.readFileSync(path.join(root, 'containers', component, 'Dockerfile'), 'utf8'); + expect(dockerfile.lastIndexOf('sh /opt/awf/trust/install-system-trust.sh')) + .toBeLessThan(dockerfile.lastIndexOf('\nUSER ')); + } +}); diff --git a/scripts/security/chroot-trust-smoke.sh b/scripts/security/chroot-trust-smoke.sh new file mode 100644 index 000000000..32c9b4756 --- /dev/null +++ b/scripts/security/chroot-trust-smoke.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# Run only inside a disposable Linux container with CAP_SYS_ADMIN, repository +# mounted read-only at /work, and containers/trust mounted at /opt/awf/trust. +set -euo pipefail +[ -f /.dockerenv ] && [ ! -e /host ] || { + echo "This test requires a disposable container without an existing /host" >&2 + exit 1 +} +mkdir -p /fixtures/etc/ssl/certs /host/etc/ssl /host/run/awf-lib +openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj /CN=private-awf \ + -keyout /fixtures/private.key -out /fixtures/private.pem >/dev/null 2>&1 +cat /fixtures/private.pem /work/scripts/security/fixtures/emsign-ecc-g3.pem \ + > /fixtures/etc/ssl/certs/ca-certificates.crt +cp /work/scripts/security/fixtures/emsign-ecc-g3.pem /fixtures/etc/ssl/certs/compromised.pem +openssl rehash /fixtures/etc/ssl/certs 2>/dev/null +before="$(sha256sum /fixtures/etc/ssl/certs/ca-certificates.crt)" +mount --bind /fixtures/etc/ssl /host/etc/ssl +mount -o remount,ro,bind /host/etc/ssl +# shellcheck source=../../containers/agent/entrypoint.sh +source /work/containers/agent/entrypoint.sh +AWF_CA_CHROOT="" +unset NODE_EXTRA_CA_CERTS NODE_OPTIONS +copy_system_ca_bundle +openssl verify -CAfile /host/etc/ssl/certs/ca-certificates.crt \ + -CApath /host/etc/ssl/certs /fixtures/private.pem +if openssl verify -CAfile /host/etc/ssl/certs/ca-certificates.crt \ + -CApath /host/etc/ssl/certs /work/scripts/security/fixtures/emsign-ecc-g3.pem; then + echo "Compromised trust survived the chroot overlay" >&2 + exit 1 +fi +[ "$(sha256sum /fixtures/etc/ssl/certs/ca-certificates.crt)" = "$before" ] +if touch /host/etc/ssl/certs/should-not-write; then + echo "Trust overlay is writable" >&2 + exit 1 +fi +echo "Linux chroot CA overlays reject compromised trust, retain private trust, and leave source mounts unchanged" diff --git a/scripts/security/filter-ca-store.sh b/scripts/security/filter-ca-store.sh new file mode 100644 index 000000000..604586b2f --- /dev/null +++ b/scripts/security/filter-ca-store.sh @@ -0,0 +1,62 @@ +#!/bin/sh +# Create a new private store; never rewrite the supplied host/container inputs. +set -eu +output=$1 +shift +policy_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +mkdir -p "$output" +work=$(mktemp -d "$output/.filter.XXXXXX") +trap 'rm -rf "$work"' EXIT HUP INT TERM +: > "$work/input.pem" +for input in "$@"; do + if [ -d "$input" ]; then + for cert in "$input"/*; do + [ -f "$cert" ] || continue + case "$cert" in + *.trust.crt) continue ;; # RHEL AUX trust is represented by its extracted TLS PEM bundle. + *.crt|*.pem|*.[0-9]) cat "$cert" >> "$work/input.pem"; printf '\n' >> "$work/input.pem" ;; + esac + done + elif [ -f "$input" ]; then + cat "$input" >> "$work/input.pem" + printf '\n' >> "$work/input.pem" + else + echo "AWF trust policy: missing CA input: $input" >&2 + exit 1 + fi +done +awk -v dir="$work" ' + /-----BEGIN CERTIFICATE-----/ { if (active) exit 1; active=1; count++; file=dir "/cert-" count ".pem" } + active { print > file } + /-----END CERTIFICATE-----/ { if (!active) exit 1; close(file); active=0 } + /-----BEGIN TRUSTED CERTIFICATE-----/ { exit 1 } + END { if (active || !count) exit 1 } +' "$work/input.pem" || { echo "AWF trust policy: invalid or empty PEM CA store" >&2; exit 1; } +mkdir "$work/certs" +: > "$work/bundle.pem" +for cert in "$work"/cert-*.pem; do + openssl x509 -in "$cert" -pubkey -noout > "$work/key.pem" + openssl pkey -pubin -in "$work/key.pem" -outform DER -out "$work/key.der" + openssl dgst -sha256 "$work/key.der" > "$work/digest" + key=$(awk '{print $NF}' "$work/digest") + if grep -Fqx "$key" "$policy_dir/distrusted-spki.txt"; then + echo "AWF trust policy: removed distrusted CA key $key" >&2 + continue + fi + openssl x509 -in "$cert" -outform DER -out "$work/cert.der" + openssl dgst -sha256 "$work/cert.der" > "$work/digest" + fingerprint=$(awk '{print $NF}' "$work/digest") + if [ ! -f "$work/certs/$fingerprint.pem" ]; then + cp "$cert" "$work/certs/$fingerprint.pem" + cat "$cert" >> "$work/bundle.pem" + fi +done +[ -s "$work/bundle.pem" ] || { echo "AWF trust policy: no trusted certificates remain" >&2; exit 1; } +openssl rehash "$work/certs" +# A fresh destination is required so stale hash links cannot restore trust. +[ ! -e "$output/certs" ] && [ ! -e "$output/ca-certificates.crt" ] || { + echo "AWF trust policy: destination store already exists: $output" >&2 + exit 1 +} +mv "$work/certs" "$output/certs" +mv "$work/bundle.pem" "$output/ca-certificates.crt" diff --git a/scripts/security/fixtures/emsign-ecc-g3.pem b/scripts/security/fixtures/emsign-ecc-g3.pem new file mode 100644 index 000000000..060e943bf --- /dev/null +++ b/scripts/security/fixtures/emsign-ecc-g3.pem @@ -0,0 +1,15 @@ +-----BEGIN CERTIFICATE----- +MIICTjCCAdOgAwIBAgIKPPYHqWhwDtqLhDAKBggqhkjOPQQDAzBrMQswCQYDVQQG +EwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNo +bm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0g +RzMwHhcNMTgwMjE4MTgzMDAwWhcNNDMwMjE4MTgzMDAwWjBrMQswCQYDVQQGEwJJ +TjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNobm9s +b2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0gRzMw +djAQBgcqhkjOPQIBBgUrgQQAIgNiAAQjpQy4LRL1KPOxst3iAhKAnjlfSU2fySU0 +WXTsuwYc58Byr+iuL+FBVIcUqEqy6HyC5ltqtdyzdc6LBtCGI79G1Y4PPwT01xyS +fvalY8L1X44uT6EYGQIrMgqCZH0Wk9GjQjBAMB0GA1UdDgQWBBR8XQKEE9TMipuB +zhccLikenEhjQjAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAKBggq +hkjOPQQDAwNpADBmAjEAvvNhzwIQHWSVB7gYboiFBS+DCBeQyh+KTOgNG3qxrdWB +CUfvO6wIBHxcmbHtRwfSAjEAnbpV/KlK6O3t5nYBQnvI+GDZjVGLVTv7jHvrZQnD ++JbNR6iC8hZVdyR+EhCVBCyj +-----END CERTIFICATE----- diff --git a/scripts/security/generate-trust-policy.cjs b/scripts/security/generate-trust-policy.cjs new file mode 100644 index 000000000..b71ef3853 --- /dev/null +++ b/scripts/security/generate-trust-policy.cjs @@ -0,0 +1,41 @@ +#!/usr/bin/env node +// Per-component Docker build contexts cannot COPY a shared parent directory. +const fs = require('node:fs'); +const path = require('node:path'); +const ts = require('typescript'); +const root = path.resolve(__dirname, '../..'); +const source = fs.readFileSync(path.join(root, 'src/tls-trust-policy.ts'), 'utf8'); +const js = '// Generated by scripts/security/generate-trust-policy.cjs; do not edit.\n' + + ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, esModuleInterop: true }, + }).outputText; +const hashes = [...source.matchAll(/'([a-f0-9]{64})'/g)].map(match => match[1]).join('\n') + '\n'; +const files = { + 'node-trust.cjs': js, + 'distrusted-spki.txt': hashes, + 'filter-ca-store.sh': fs.readFileSync(path.join(__dirname, 'filter-ca-store.sh'), 'utf8'), + 'install-system-trust.sh': fs.readFileSync(path.join(__dirname, 'install-system-trust.sh'), 'utf8'), + 'install-node-trust.sh': fs.readFileSync(path.join(__dirname, 'install-node-trust.sh'), 'utf8'), +}; +let mismatched = false; +for (const component of ['agent', 'api-proxy', 'cli-proxy', 'squid', 'build-tools', 'gh-aw-node', 'router', 'dind-ubuntu', 'trust']) { + const dir = path.join(root, 'containers', component, ...(component === 'trust' ? [] : ['trust'])); + for (const [name, content] of Object.entries(files)) { + const dest = path.join(dir, name); + if (['squid', 'router', 'build-tools'].includes(component) + && ['node-trust.cjs', 'install-node-trust.sh'].includes(name)) { + if (!process.argv.includes('--check') && fs.existsSync(dest)) fs.unlinkSync(dest); + continue; + } + if (process.argv.includes('--check')) { + if (!fs.existsSync(dest) || fs.readFileSync(dest, 'utf8') !== content) { + console.error(`Out-of-sync trust policy: ${path.relative(root, dest)}`); + mismatched = true; + } + } else { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(dest, content); + } + } +} +if (mismatched) process.exitCode = 1; diff --git a/scripts/security/install-node-trust.sh b/scripts/security/install-node-trust.sh new file mode 100644 index 000000000..e3f83eb1d --- /dev/null +++ b/scripts/security/install-node-trust.sh @@ -0,0 +1,14 @@ +#!/bin/sh +set -eu +# Use an argv preload, not only NODE_OPTIONS, so caller-supplied Node options +# cannot accidentally disable the policy in AWF-owned runtime images. +if [ ! -e /usr/local/bin/node-awf ]; then + node_path=$(command -v node) + mv "$node_path" /usr/local/bin/node-awf + cat > "$node_path" <<'EOF' +#!/bin/sh +export NODE_OPTIONS="${NODE_OPTIONS:+${NODE_OPTIONS} }--require=/opt/awf/trust/node-trust.cjs" +exec /usr/local/bin/node-awf --require=/opt/awf/trust/node-trust.cjs "$@" +EOF + chmod 0755 "$node_path" +fi diff --git a/scripts/security/install-system-trust.sh b/scripts/security/install-system-trust.sh new file mode 100644 index 000000000..da9b3886f --- /dev/null +++ b/scripts/security/install-system-trust.sh @@ -0,0 +1,11 @@ +#!/bin/sh +set -eu +policy_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +staging=$(mktemp -d /tmp/awf-ca-store.XXXXXX) +trap 'rm -rf "$staging"' EXIT HUP INT TERM +sh "$policy_dir/filter-ca-store.sh" "$staging" /etc/ssl/certs +# This script is for image-local stores only. Host mounts are handled by the +# agent entrypoint with private, read-only mount overlays instead. +find /etc/ssl/certs -maxdepth 1 \( -type f -o -type l \) -delete +cp -a "$staging/certs/." /etc/ssl/certs/ +cp "$staging/ca-certificates.crt" /etc/ssl/certs/ca-certificates.crt diff --git a/scripts/security/tls-runtime-smoke.cjs b/scripts/security/tls-runtime-smoke.cjs new file mode 100644 index 000000000..dc4bb571a --- /dev/null +++ b/scripts/security/tls-runtime-smoke.cjs @@ -0,0 +1,88 @@ +// Dependency-free regression for the actual Node runtime used by each image. +const assert = require('node:assert/strict'); +const { execFileSync, spawn } = require('node:child_process'); +const { createHash, X509Certificate } = require('node:crypto'); +const fs = require('node:fs'); +const https = require('node:https'); +const os = require('node:os'); +const path = require('node:path'); +const { test } = require('node:test'); + +test('image launcher retains policy when NODE_OPTIONS is replaced and in child Node processes', { + skip: !fs.existsSync('/usr/local/bin/node-awf'), +}, () => { + const script = ` + const tls=require('tls'),{X509Certificate}=require('crypto'),assert=require('assert'); + const check="const tls=require('tls'),{X509Certificate}=require('crypto');"+ + "console.log(tls.getCACertificates('default').some(pem=>new X509Certificate(pem).subject.includes('emSign ECC Root CA - G3')))"; + assert(!tls.getCACertificates('default').some(pem=>new X509Certificate(pem).subject.includes('emSign ECC Root CA - G3'))); + assert.equal(require('child_process').execFileSync(process.execPath,['-e',check],{encoding:'utf8'}).trim(),'false'); + `; + execFileSync('/usr/local/bin/node', ['-e', script], { + env: { + ...process.env, NODE_OPTIONS: '', + NODE_EXTRA_CA_CERTS: path.resolve(__dirname, 'fixtures/emsign-ecc-g3.pem'), + }, + stdio: 'pipe', + }); +}); + +test('HTTPS, direct TLS and fetch enforce default, extra and explicit CA distrust', async () => { + const work = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-node-trust-')); + const run = (...args) => execFileSync('openssl', args, { cwd: work, stdio: 'pipe' }); + const read = name => fs.readFileSync(path.join(work, name), 'utf8'); + const servers = []; + try { + for (const name of ['blocked', 'private-awf']) { + run('req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '2', '-subj', `/CN=${name}`, + '-keyout', `${name}.key`, '-out', `${name}.pem`, '-addext', 'basicConstraints=critical,CA:TRUE'); + fs.writeFileSync(path.join(work, 'leaf.ext'), 'subjectAltName=DNS:localhost\nbasicConstraints=CA:FALSE\n'); + run('req', '-new', '-newkey', 'rsa:2048', '-nodes', '-subj', '/CN=localhost', + '-keyout', `${name}-leaf.key`, '-out', `${name}-leaf.csr`); + run('x509', '-req', '-in', `${name}-leaf.csr`, '-CA', `${name}.pem`, '-CAkey', `${name}.key`, + '-CAcreateserial', '-days', '2', '-extfile', 'leaf.ext', '-out', `${name}-leaf.pem`); + } + const key = new X509Certificate(read('blocked.pem')).publicKey.export({ format: 'der', type: 'spki' }); + const hash = createHash('sha256').update(key).digest('hex'); + const policy = fs.readFileSync(path.resolve(__dirname, '../../containers/trust/node-trust.cjs'), 'utf8') + .replace('43bffa19d6ef651451f6841e019931e96f6f2eaeff28ab4b6f1ce4576de2717b', hash); + fs.writeFileSync(path.join(work, 'policy.cjs'), policy); + fs.writeFileSync(path.join(work, 'inputs.pem'), read('blocked.pem') + read('private-awf.pem')); + for (const name of ['blocked', 'private-awf']) { + const server = https.createServer({ key: read(`${name}-leaf.key`), cert: read(`${name}-leaf.pem`) }, + (_req, res) => res.end('ok')); + servers.push(server); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address(); + for (const mode of ['https', 'tls', 'fetch']) { + for (const explicit of [false, true]) { + const allowed = name === 'private-awf'; + const script = ` + const options={host:'127.0.0.1',port:${port},servername:'localhost', + ${explicit ? `ca:require('fs').readFileSync(${JSON.stringify(path.join(work, 'inputs.pem'))}),` : ''}}; + const fail=()=>process.exit(${allowed ? 1 : 0}), pass=()=>process.exit(${allowed ? 0 : 1}); + if (${JSON.stringify(mode)}==='tls') require('tls').connect(options,pass).on('error',fail); + else if (${JSON.stringify(mode)}==='fetch') fetch('https://localhost:${port}').then(pass,fail); + else require('https').get(options,pass).on('error',fail); + setTimeout(()=>process.exit(2),5000).unref(); + `; + const code = await new Promise((resolve, reject) => { + const child = spawn(process.execPath, ['--require', path.join(work, 'policy.cjs'), '-e', script], { + env: { ...process.env, NODE_EXTRA_CA_CERTS: path.join(work, 'inputs.pem') }, + stdio: ['ignore', 'ignore', 'pipe'], + }); + let stderr = ''; + child.stderr.on('data', chunk => { stderr += chunk; }); + child.on('error', reject); + child.on('close', status => status === 0 + ? resolve(status) : reject(new Error(`${mode}/${name}/explicit=${explicit}: ${stderr} (exit ${status})`))); + }); + assert.equal(code, 0); + } + } + } + } finally { + await Promise.all(servers.map(server => new Promise(resolve => server.close(resolve)))); + fs.rmSync(work, { recursive: true, force: true }); + } +}); diff --git a/src/cli.ts b/src/cli.ts index 9c8843215..ef8569a19 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node +import './tls-trust-policy'; import { program } from './cli-options'; import { createMainAction } from './commands/main-action'; diff --git a/src/squid-config-ssl-bump.test.ts b/src/squid-config-ssl-bump.test.ts index 205ede788..65f836fe8 100644 --- a/src/squid-config-ssl-bump.test.ts +++ b/src/squid-config-ssl-bump.test.ts @@ -21,6 +21,8 @@ describe('generateSquidConfig', () => { expect(result).toContain('SSL Bump configuration for HTTPS content inspection'); expect(result).toContain('ssl-bump'); expect(result).toContain('security_file_certgen'); + expect(result).toContain('tls_outgoing_options cafile=/etc/ssl/certs/ca-certificates.crt capath=/etc/ssl/certs'); + expect(result).toContain('sslproxy_cert_error deny all'); }); it('should include SSL Bump warning comment', () => { diff --git a/src/squid/ssl-bump.ts b/src/squid/ssl-bump.ts index 0098f05b6..47258fee1 100644 --- a/src/squid/ssl-bump.ts +++ b/src/squid/ssl-bump.ts @@ -158,6 +158,9 @@ export function generateSslBumpSection( return ` # SSL Bump configuration for HTTPS content inspection +# Authenticate upstreams against AWF's filtered bundle AND hashed directory. +tls_outgoing_options cafile=/etc/ssl/certs/ca-certificates.crt capath=/etc/ssl/certs +sslproxy_cert_error deny all # WARNING: This enables TLS interception - traffic is decrypted for inspection # A per-session CA certificate is used for dynamic certificate generation diff --git a/src/tls-trust-policy.ts b/src/tls-trust-policy.ts new file mode 100644 index 000000000..74353e990 --- /dev/null +++ b/src/tls-trust-policy.ts @@ -0,0 +1,75 @@ +import { createHash, X509Certificate } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import tls from 'node:tls'; + +// October 2026 eMudhra disclosure: eight compromised keys, followed by the +// four issuing/cross-signing roots. Root distrust is an emergency fallback for +// clients without chain-level SPKI revocation, including OpenSSL and Node. +// Sources: https://emudhra.we-have-a-problem.com/{impacted-intermediates,roots}.json +export const distrustedSpkis = new Set([ + '43bffa19d6ef651451f6841e019931e96f6f2eaeff28ab4b6f1ce4576de2717b', + '49dd353dc2b919a3770645117d0e60c16115e7d1bc259eefe734b00ea434fe5f', + '5c0660a2b164910d8ff589c18e14552e9e0ec237dcefe2cb736d41a4811f81a0', + '63ecefee431d49523bc1136c4e72d0206a477086caf606cd5cf025686c109cf8', + '92c063d67e8f14db71d288e11266ffdaf69cbf115f7bab14f3bc2f68d25e0e30', + 'e0254c7fcdc265e2f0f8a428dabb75ab4ae039b4872d62f5ee57f0877d5a8a41', + 'ebed6475c2c2d80fcb199f3fe8dd6e6b3ce60b77eb15d4e42c3dec0c6f5b5105', + 'ec4ffa1ad0ac88081314b7aaffef6a91f97cd57291bb5ba686c5415f5eb4f6b6', + 'c619e4780ed8e0abeda24ad732567de4383a7ea941338f6d1a7d1b49ac792fe9', + '0a6c2b2ca5b364207954171987ac9d2178561569d8205457e77ca90c32ede282', + 'bd153ed7b0434f6886b17bce8bbe84ed340c7132d702a8f4fa318f756ecbd6f3', + '8d417db2dd8bf5e3084d1e3f196d583849d81bdd4c00c70b9d39369e96b8c782', +]); + +const certificateCache = new Map(); + +export function filterCaCertificates( + ca: NonNullable, +): string[] { + const inputs = Array.isArray(ca) ? ca : [ca]; + return inputs.flatMap(input => { + const text = input.toString(); + if (!text.trim()) return []; + const certificates = text.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g); + if (!certificates || text.includes('-----BEGIN TRUSTED CERTIFICATE-----')) { + throw new Error('AWF trust policy requires PEM CERTIFICATE entries'); + } + return certificates.filter(pem => { + let allowed = certificateCache.get(pem); + if (allowed === undefined) { + const certificate = new X509Certificate(pem); + const spki = certificate.publicKey.export({ type: 'spki', format: 'der' }); + allowed = !distrustedSpkis.has(createHash('sha256').update(spki).digest('hex')); + certificateCache.set(pem, allowed); + } + return allowed; + }); + }); +} + +let installed = false; + +export function applyTlsTrustPolicy(): void { + if (installed) return; + const defaults = tls.getCACertificates + ? [...tls.getCACertificates('default')] + : [...tls.rootCertificates]; + // Read explicitly as well: Node otherwise warns and ignores malformed or + // missing NODE_EXTRA_CA_CERTS, which must not silently defeat private trust. + if (process.env.NODE_EXTRA_CA_CERTS) { + defaults.push(readFileSync(process.env.NODE_EXTRA_CA_CERTS, 'utf8')); + } + const filteredDefaults = filterCaCertificates(defaults); + if (filteredDefaults.length === 0) throw new Error('AWF trust policy produced an empty default CA store'); + tls.setDefaultCACertificates?.(filteredDefaults); + const createSecureContext = tls.createSecureContext; + // Also cover explicit custom CA options and Node versions predating the + // default-CA API. This affects https, fetch/undici and direct tls.connect. + tls.createSecureContext = (options = {}) => createSecureContext({ + ...options, + ca: options.ca === undefined ? filteredDefaults : filterCaCertificates(options.ca), + }); + installed = true; +} + +applyTlsTrustPolicy(); diff --git a/tests/entrypoint-phase-functions.test.sh b/tests/entrypoint-phase-functions.test.sh index 025fdc2ff..bacfbc276 100755 --- a/tests/entrypoint-phase-functions.test.sh +++ b/tests/entrypoint-phase-functions.test.sh @@ -20,7 +20,7 @@ write_entrypoint_fixture() { $0 == "if [[ \"${BASH_SOURCE[0]}\" == \"$0\" ]]; then" { skip=1; next } skip && $0 == "fi" { skip=0; next } !skip { print } - ' "${ENTRYPOINT}" | sed 's#/host#${AWF_TEST_HOST_ROOT}#g' > "${fixture_entrypoint}" + ' "${ENTRYPOINT}" | sed 's#/host#${AWF_TEST_HOST_ROOT}#g; s#/opt/awf/trust#${AWF_TEST_TRUST_POLICY}#g' > "${fixture_entrypoint}" } required_functions=( @@ -125,7 +125,7 @@ CHROOT_BLOCK="$(awk ' COPY_SYSTEM_CA_BUNDLE_BLOCK="$(awk ' /^[[:space:]]*copy_system_ca_bundle\(\)[[:space:]]*\{[[:space:]]*$/ { in_fn=1; next } - in_fn && /^[[:space:]]*}[[:space:]]*$/ { in_fn=0; exit } + in_fn && /^}[[:space:]]*$/ { in_fn=0; exit } in_fn { print } ' "${ENTRYPOINT}")" @@ -166,23 +166,24 @@ else fail "mount_host_cgroupfs() may leave a writable cgroup mount behind" fi -if printf '%s\n' "${COPY_SYSTEM_CA_BUNDLE_BLOCK}" | grep -Fq 'if [ "${AWF_SSL_BUMP_ENABLED}" = "true" ]'; then - pass "copy_system_ca_bundle() keys SSL Bump handling off AWF_SSL_BUMP_ENABLED" +if printf '%s\n' "${COPY_SYSTEM_CA_BUNDLE_BLOCK}" | grep -Fq 'sh /opt/awf/trust/filter-ca-store.sh'; then + pass "copy_system_ca_bundle() filters host trust before exposing it" else - fail "copy_system_ca_bundle() does not key SSL Bump handling off AWF_SSL_BUMP_ENABLED" + fail "copy_system_ca_bundle() does not filter host trust" fi -if printf '%s\n' "${COPY_SYSTEM_CA_BUNDLE_BLOCK}" | grep -Fq "printf '\\n'" && \ +if printf '%s\n' "${COPY_SYSTEM_CA_BUNDLE_BLOCK}" | grep -Fq 'inputs+=(' && \ printf '%s\n' "${COPY_SYSTEM_CA_BUNDLE_BLOCK}" | grep -Fq '"/host${AWF_CA_CHROOT}"'; then - pass "copy_system_ca_bundle() appends system roots to the staged AWF CA bundle safely" + pass "copy_system_ca_bundle() preserves staged AWF trust through the shared filter" else - fail "copy_system_ca_bundle() does not safely append system roots to the staged AWF CA bundle" + fail "copy_system_ca_bundle() does not preserve staged AWF trust" fi -if printf '%s\n' "${COPY_SYSTEM_CA_BUNDLE_BLOCK}" | grep -Fq '/etc/pki/ca-trust/extracted/*|/etc/pki/tls/certs/*)'; then - pass "copy_system_ca_bundle() treats mounted RHEL/Amazon Linux CA paths as chroot-accessible" +if printf '%s\n' "${COPY_SYSTEM_CA_BUNDLE_BLOCK}" | grep -Fq '/host/etc/pki/ca-trust/extracted/pem' && \ + printf '%s\n' "${COPY_SYSTEM_CA_BUNDLE_BLOCK}" | grep -Fq 'mount -o remount,ro,bind'; then + pass "copy_system_ca_bundle() overlays RHEL and hashed CA directories read-only" else - fail "copy_system_ca_bundle() does not recognize mounted RHEL/Amazon Linux CA paths as chroot-accessible" + fail "copy_system_ca_bundle() does not protect mounted trust directories" fi run_copy_system_ca_bundle_fixture() { @@ -191,7 +192,10 @@ run_copy_system_ca_bundle_fixture() { local host_root="${tmp_dir}/host-root" local fixture_entrypoint="${tmp_dir}/entrypoint-fixture.sh" mkdir -p "${host_root}/etc/pki/tls/certs" "${host_root}/etc/ssl/certs" - printf '%s\n' "fixture-ca-cert" > "${host_root}/etc/pki/tls/certs/ca-bundle.crt" + openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj /CN=private-fixture \ + -keyout "${tmp_dir}/fixture.key" -out "${host_root}/etc/pki/tls/certs/ca-bundle.crt" >/dev/null 2>&1 + cp "${host_root}/etc/pki/tls/certs/ca-bundle.crt" "${tmp_dir}/original.crt" + ln -s ../../pki/tls/certs/ca-bundle.crt "${host_root}/etc/ssl/certs/ca-certificates.crt" write_entrypoint_fixture "${fixture_entrypoint}" @@ -204,29 +208,33 @@ run_copy_system_ca_bundle_fixture() { AWF_SSL_BUMP_ENABLED="false" AWF_CA_CHROOT="" AWF_TEST_HOST_ROOT="${host_root}" + AWF_TEST_TRUST_POLICY="$(cd "$(dirname "${ENTRYPOINT}")/../trust" && pwd)" + # macOS cannot perform Linux bind mounts. Record mount intent while the + # real shared filter processes the fixture store. + mount() { printf '%s\n' "$*" >> "${tmp_dir}/mounts"; } unset SSL_CERT_FILE NODE_EXTRA_CA_CERTS REQUESTS_CA_BUNDLE CURL_CA_BUNDLE GIT_SSL_CAINFO SYSTEM_CA_CHROOT copy_system_ca_bundle - [ "${SSL_CERT_FILE}" = "/etc/pki/tls/certs/ca-bundle.crt" ] + [ "${SSL_CERT_FILE}" = "/run/awf-lib/trust/ca-certificates.crt" ] [ -r "${AWF_TEST_HOST_ROOT}${SSL_CERT_FILE}" ] [ "${NODE_EXTRA_CA_CERTS}" = "${SSL_CERT_FILE}" ] - ln -sf ../../pki/tls/certs/ca-bundle.crt "${host_root}/etc/ssl/certs/ca-certificates.crt" - unset SSL_CERT_FILE NODE_EXTRA_CA_CERTS REQUESTS_CA_BUNDLE CURL_CA_BUNDLE GIT_SSL_CAINFO SYSTEM_CA_CHROOT - copy_system_ca_bundle - [ "${SSL_CERT_FILE}" = "/etc/ssl/certs/ca-certificates.crt" ] - [ -r "${AWF_TEST_HOST_ROOT}${SSL_CERT_FILE}" ] - [ "${NODE_EXTRA_CA_CERTS}" = "${SSL_CERT_FILE}" ] + cmp "${host_root}/etc/pki/tls/certs/ca-bundle.crt" "${tmp_dir}/original.crt" + grep -q 'remount,ro,bind' "${tmp_dir}/mounts" + grep -q '/etc/pki/tls/certs' "${tmp_dir}/mounts" + [ "${SSL_CERT_DIR}" = "/run/awf-lib/trust/certs" ] + case "${NODE_OPTIONS}" in *--require=/run/awf-lib/trust/node-trust.cjs*) ;; *) exit 1 ;; esac ) result=$? + chmod -R u+w "${tmp_dir}" rm -rf "${tmp_dir}" return "${result}" } if run_copy_system_ca_bundle_fixture; then - pass "copy_system_ca_bundle() exports chroot-readable CA paths for direct RHEL bundles and /etc/ssl symlink targets" + pass "copy_system_ca_bundle() stages filtered RHEL/symlink trust without changing host inputs" else - fail "copy_system_ca_bundle() does not export chroot-readable CA paths for RHEL bundle fixtures" + fail "copy_system_ca_bundle() does not safely stage RHEL bundle fixtures" fi if grep -Eq '\[ -n "\$\{SYSTEM_CA_CHROOT\}" \]' "${ENTRYPOINT}"; then From 4243f0ca1773a14c3e788ea6641d993c854ee244 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Tue, 20 Oct 2026 09:11:16 -0700 Subject: [PATCH 2/9] Potential fix for pull request finding 'CodeQL / Improper code sanitization' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- scripts/security/tls-runtime-smoke.cjs | 30 +++++++++++++++++++------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/scripts/security/tls-runtime-smoke.cjs b/scripts/security/tls-runtime-smoke.cjs index dc4bb571a..3244ea88b 100644 --- a/scripts/security/tls-runtime-smoke.cjs +++ b/scripts/security/tls-runtime-smoke.cjs @@ -58,17 +58,31 @@ test('HTTPS, direct TLS and fetch enforce default, extra and explicit CA distrus for (const explicit of [false, true]) { const allowed = name === 'private-awf'; const script = ` - const options={host:'127.0.0.1',port:${port},servername:'localhost', - ${explicit ? `ca:require('fs').readFileSync(${JSON.stringify(path.join(work, 'inputs.pem'))}),` : ''}}; - const fail=()=>process.exit(${allowed ? 1 : 0}), pass=()=>process.exit(${allowed ? 0 : 1}); - if (${JSON.stringify(mode)}==='tls') require('tls').connect(options,pass).on('error',fail); - else if (${JSON.stringify(mode)}==='fetch') fetch('https://localhost:${port}').then(pass,fail); - else require('https').get(options,pass).on('error',fail); - setTimeout(()=>process.exit(2),5000).unref(); + const fs = require('fs'); + const mode = process.env.AWF_MODE; + const port = Number(process.env.AWF_PORT); + const explicit = process.env.AWF_EXPLICIT === '1'; + const allowed = process.env.AWF_ALLOWED === '1'; + const options = { host: '127.0.0.1', port, servername: 'localhost' }; + if (explicit) options.ca = fs.readFileSync(process.env.AWF_INPUTS_PEM); + const fail = () => process.exit(allowed ? 1 : 0); + const pass = () => process.exit(allowed ? 0 : 1); + if (mode === 'tls') require('tls').connect(options, pass).on('error', fail); + else if (mode === 'fetch') fetch('https://localhost:' + port).then(pass, fail); + else require('https').get(options, pass).on('error', fail); + setTimeout(() => process.exit(2), 5000).unref(); `; const code = await new Promise((resolve, reject) => { const child = spawn(process.execPath, ['--require', path.join(work, 'policy.cjs'), '-e', script], { - env: { ...process.env, NODE_EXTRA_CA_CERTS: path.join(work, 'inputs.pem') }, + env: { + ...process.env, + NODE_EXTRA_CA_CERTS: path.join(work, 'inputs.pem'), + AWF_MODE: mode, + AWF_PORT: String(port), + AWF_EXPLICIT: explicit ? '1' : '0', + AWF_ALLOWED: allowed ? '1' : '0', + AWF_INPUTS_PEM: path.join(work, 'inputs.pem'), + }, stdio: ['ignore', 'ignore', 'pipe'], }); let stderr = ''; From 7352390788772df7854b1301e16962700f53282e Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Tue, 20 Oct 2026 09:16:16 -0700 Subject: [PATCH 3/9] fix: repair CA mitigation CI without weakening trust Use isolated signed action fixtures while preserving mandatory production provenance. Retain an optional real-release verification job. Support Node's null CA default semantics using filtered roots, and exercise the Node 20 fallback without newer TLS APIs. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 89097bc0-0800-4855-965e-ceaab0cef6d3 --- .github/workflows/test-action.yml | 64 ++++++++++--- containers/agent/trust/node-trust.cjs | 3 +- containers/api-proxy/trust/node-trust.cjs | 3 +- containers/cli-proxy/trust/node-trust.cjs | 3 +- containers/dind-ubuntu/trust/node-trust.cjs | 3 +- containers/gh-aw-node/trust/node-trust.cjs | 3 +- containers/trust/node-trust.cjs | 3 +- docs/security-ca-trust.md | 19 ++++ scripts/ci/action-fixture-smoke.cjs | 94 ++++++++++++++++++ .../ci/release-bootstrap-provenance.test.ts | 30 +++++- scripts/ci/setup-action-test-fixtures.cjs | 96 +++++++++++++++++++ scripts/security/ca-trust.test.ts | 17 +++- scripts/security/tls-runtime-smoke.cjs | 5 +- src/tls-trust-policy.ts | 3 +- 14 files changed, 320 insertions(+), 26 deletions(-) create mode 100644 scripts/ci/action-fixture-smoke.cjs create mode 100644 scripts/ci/setup-action-test-fixtures.cjs diff --git a/.github/workflows/test-action.yml b/.github/workflows/test-action.yml index 554a85112..0e562a0db 100644 --- a/.github/workflows/test-action.yml +++ b/.github/workflows/test-action.yml @@ -9,11 +9,20 @@ on: - '**/*.md' - '.github/workflows/release.yml' workflow_dispatch: + inputs: + attested-version: + description: 'Published attested release for a live GitHub/Sigstore/image-pull test (e.g. v1.2.3)' + required: false + type: string + default: '' permissions: contents: read jobs: + # Positive plumbing tests use isolated signed fixtures: historical releases + # cannot acquire the new provenance metadata retroactively. Production action + # code has no fixture mode; the manually selected live job uses real services. test-action-latest: name: Test Action (Latest Version) runs-on: ubuntu-latest @@ -23,6 +32,9 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 + - name: Prepare signed action test fixtures + run: node scripts/ci/setup-action-test-fixtures.cjs + - name: Setup awf using action id: setup-awf uses: ./ @@ -33,6 +45,8 @@ jobs: which awf awf --version awf --help + test "${{ steps.setup-awf.outputs.version }}" = "$AWF_ACTION_FIXTURE_VERSION" + test "$(wc -l < "$AWF_ACTION_FIXTURE_DIR/verified.log")" -eq 4 test-action-specific-version: name: Test Action (Specific Version) @@ -43,11 +57,14 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 + - name: Prepare signed action test fixtures + run: node scripts/ci/setup-action-test-fixtures.cjs + - name: Setup awf using action with specific version id: setup-awf uses: ./ with: - version: 'v0.7.0' + version: 'v1.2.3' - name: Verify awf is installed with correct version run: | @@ -56,16 +73,17 @@ jobs: which awf awf --version # Verify the version matches - if [[ "${{ steps.setup-awf.outputs.version }}" != "v0.7.0" ]]; then - echo "::error::Version mismatch! Expected v0.7.0, got ${{ steps.setup-awf.outputs.version }}" + if [[ "${{ steps.setup-awf.outputs.version }}" != "$AWF_ACTION_FIXTURE_VERSION" ]]; then + echo "::error::Version mismatch! Expected $AWF_ACTION_FIXTURE_VERSION, got ${{ steps.setup-awf.outputs.version }}" exit 1 fi # Verify image tag metadata starts with the expected base tag (without 'v' prefix) - # and may include optional digest metadata entries. - if [[ "${{ steps.setup-awf.outputs.image-tag }}" != 0.7.0* ]]; then - echo "::error::Image tag metadata mismatch! Expected prefix 0.7.0, got ${{ steps.setup-awf.outputs.image-tag }}" + # and includes authenticated digest metadata entries. + if [[ "${{ steps.setup-awf.outputs.image-tag }}" != 1.2.3,squid=sha256:* ]]; then + echo "::error::Image tag metadata mismatch" exit 1 fi + test "$(wc -l < "$AWF_ACTION_FIXTURE_DIR/verified.log")" -eq 4 test-action-with-images: name: Test Action (With Image Pull) @@ -76,11 +94,14 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 + - name: Prepare signed action test fixtures + run: node scripts/ci/setup-action-test-fixtures.cjs + - name: Setup awf with image pull id: setup-awf uses: ./ with: - version: 'v0.7.0' + version: 'v1.2.3' pull-images: 'true' - name: Verify awf and images are available @@ -90,10 +111,11 @@ jobs: which awf awf --version - # Verify Docker images are pulled - echo "Checking for pulled images..." - docker images ghcr.io/github/gh-aw-firewall/squid - docker images ghcr.io/github/gh-aw-firewall/agent + # The isolated Docker fixture rejects tags and unknown digests. + test "$(wc -l < "$AWF_ACTION_FIXTURE_DIR/pulled.log")" -eq 2 + head -n 2 "$AWF_ACTION_FIXTURE_DIR/assets/containers.txt" \ + | cmp - "$AWF_ACTION_FIXTURE_DIR/pulled.log" + test "$(wc -l < "$AWF_ACTION_FIXTURE_DIR/verified.log")" -eq 4 test-action-invalid-version: name: Test Action (Invalid Version - Should Fail) @@ -118,3 +140,23 @@ jobs: exit 1 fi echo "Action correctly rejected invalid version format" + + test-action-live-attested-release: + name: Test Action (Live Attested Release) + if: github.event_name == 'workflow_dispatch' && inputs.attested-version != '' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 + + - name: Install published release with real provenance and image pulls + uses: ./ + with: + version: ${{ inputs.attested-version }} + pull-images: 'true' + + - name: Verify installed release + run: | + awf --version + awf --help diff --git a/containers/agent/trust/node-trust.cjs b/containers/agent/trust/node-trust.cjs index ee619da35..f931da035 100644 --- a/containers/agent/trust/node-trust.cjs +++ b/containers/agent/trust/node-trust.cjs @@ -72,7 +72,8 @@ function applyTlsTrustPolicy() { // default-CA API. This affects https, fetch/undici and direct tls.connect. node_tls_1.default.createSecureContext = (options = {}) => createSecureContext({ ...options, - ca: options.ca === undefined ? filteredDefaults : filterCaCertificates(options.ca), + ca: options.ca === undefined || options.ca === null + ? filteredDefaults : filterCaCertificates(options.ca), }); installed = true; } diff --git a/containers/api-proxy/trust/node-trust.cjs b/containers/api-proxy/trust/node-trust.cjs index ee619da35..f931da035 100644 --- a/containers/api-proxy/trust/node-trust.cjs +++ b/containers/api-proxy/trust/node-trust.cjs @@ -72,7 +72,8 @@ function applyTlsTrustPolicy() { // default-CA API. This affects https, fetch/undici and direct tls.connect. node_tls_1.default.createSecureContext = (options = {}) => createSecureContext({ ...options, - ca: options.ca === undefined ? filteredDefaults : filterCaCertificates(options.ca), + ca: options.ca === undefined || options.ca === null + ? filteredDefaults : filterCaCertificates(options.ca), }); installed = true; } diff --git a/containers/cli-proxy/trust/node-trust.cjs b/containers/cli-proxy/trust/node-trust.cjs index ee619da35..f931da035 100644 --- a/containers/cli-proxy/trust/node-trust.cjs +++ b/containers/cli-proxy/trust/node-trust.cjs @@ -72,7 +72,8 @@ function applyTlsTrustPolicy() { // default-CA API. This affects https, fetch/undici and direct tls.connect. node_tls_1.default.createSecureContext = (options = {}) => createSecureContext({ ...options, - ca: options.ca === undefined ? filteredDefaults : filterCaCertificates(options.ca), + ca: options.ca === undefined || options.ca === null + ? filteredDefaults : filterCaCertificates(options.ca), }); installed = true; } diff --git a/containers/dind-ubuntu/trust/node-trust.cjs b/containers/dind-ubuntu/trust/node-trust.cjs index ee619da35..f931da035 100644 --- a/containers/dind-ubuntu/trust/node-trust.cjs +++ b/containers/dind-ubuntu/trust/node-trust.cjs @@ -72,7 +72,8 @@ function applyTlsTrustPolicy() { // default-CA API. This affects https, fetch/undici and direct tls.connect. node_tls_1.default.createSecureContext = (options = {}) => createSecureContext({ ...options, - ca: options.ca === undefined ? filteredDefaults : filterCaCertificates(options.ca), + ca: options.ca === undefined || options.ca === null + ? filteredDefaults : filterCaCertificates(options.ca), }); installed = true; } diff --git a/containers/gh-aw-node/trust/node-trust.cjs b/containers/gh-aw-node/trust/node-trust.cjs index ee619da35..f931da035 100644 --- a/containers/gh-aw-node/trust/node-trust.cjs +++ b/containers/gh-aw-node/trust/node-trust.cjs @@ -72,7 +72,8 @@ function applyTlsTrustPolicy() { // default-CA API. This affects https, fetch/undici and direct tls.connect. node_tls_1.default.createSecureContext = (options = {}) => createSecureContext({ ...options, - ca: options.ca === undefined ? filteredDefaults : filterCaCertificates(options.ca), + ca: options.ca === undefined || options.ca === null + ? filteredDefaults : filterCaCertificates(options.ca), }); installed = true; } diff --git a/containers/trust/node-trust.cjs b/containers/trust/node-trust.cjs index ee619da35..f931da035 100644 --- a/containers/trust/node-trust.cjs +++ b/containers/trust/node-trust.cjs @@ -72,7 +72,8 @@ function applyTlsTrustPolicy() { // default-CA API. This affects https, fetch/undici and direct tls.connect. node_tls_1.default.createSecureContext = (options = {}) => createSecureContext({ ...options, - ca: options.ca === undefined ? filteredDefaults : filterCaCertificates(options.ca), + ca: options.ca === undefined || options.ca === null + ? filteredDefaults : filterCaCertificates(options.ca), }); installed = true; } diff --git a/docs/security-ca-trust.md b/docs/security-ca-trust.md index d3d30a674..fb2d12c4e 100644 --- a/docs/security-ca-trust.md +++ b/docs/security-ca-trust.md @@ -97,3 +97,22 @@ read-only at `/work`, and `containers/trust` mounted read-only at `/opt/awf/trust`. It rejects execution outside a container or with an existing `/host`, verifies the real bind overlays, and checks that the source store stays unchanged. Do not run this mount test on a host or in a live AWF container. + +The three positive action CI jobs use isolated local release fixtures because +historical releases cannot retroactively supply the required provenance. +`scripts/ci/setup-action-test-fixtures.cjs` generates an executable, signed +metadata, and ephemeral Ed25519 signatures, with test-only transport, verifier, +and Docker stand-ins. They exercise the real composite-action shell, required +verification constraints, metadata binding, and digest-pull requests; they do +**not** validate GitHub/Sigstore attestations or download real container images. +There is no fixture option or legacy-release exception in the production action. + +Run `npm test -- scripts/ci/release-bootstrap-provenance.test.ts` for these +regressions. Its standalone `node --test scripts/ci/action-fixture-smoke.cjs` +also runs the actual installation scenarios on Linux with a C compiler, Bash, +and `file`; non-Linux hosts only run the fixture verifier checks. Missing legacy +metadata and tampered fixture signatures must still fail closed. +Before rollout, use the **Test Setup Action** workflow's manual +`attested-version` input to test a genuinely published attested release against +real GitHub verification and digest-pinned image pulls. That job never prepares +the local fixtures. diff --git a/scripts/ci/action-fixture-smoke.cjs b/scripts/ci/action-fixture-smoke.cjs new file mode 100644 index 000000000..b03e4b7d9 --- /dev/null +++ b/scripts/ci/action-fixture-smoke.cjs @@ -0,0 +1,94 @@ +const assert = require('node:assert/strict'); +const { execFileSync, spawnSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { test } = require('node:test'); +const yaml = require('js-yaml'); + +const root = path.resolve(__dirname, '../..'); +const action = yaml.load(fs.readFileSync(path.join(root, 'action.yml'), 'utf8')); +const install = action.runs.steps.find(step => step.id === 'install').run; +const pull = action.runs.steps.find(step => step.name === 'Pull Docker images').run; + +function fixture() { + const work = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-action-smoke-')); + const env = { + ...process.env, + RUNNER_TEMP: work, + GITHUB_PATH: path.join(work, 'path'), + GITHUB_ENV: path.join(work, 'env'), + GITHUB_OUTPUT: path.join(work, 'output'), + GITHUB_TOKEN: 'isolated-fixture-token', + }; + execFileSync(process.execPath, [path.join(__dirname, 'setup-action-test-fixtures.cjs')], { env, stdio: 'pipe' }); + for (const line of fs.readFileSync(env.GITHUB_ENV, 'utf8').trim().split('\n')) { + const index = line.indexOf('='); + env[line.slice(0, index)] = line.slice(index + 1); + } + env.PATH = `${fs.readFileSync(env.GITHUB_PATH, 'utf8').trim()}:${process.env.PATH}`; + return { work, env, dir: env.AWF_ACTION_FIXTURE_DIR }; +} + +test('isolated verifier checks signatures and all production provenance constraints', () => { + const f = fixture(); + try { + const args = ['attestation', 'verify', path.join(f.dir, 'assets/awf-linux-x64'), + '--hostname', 'github.com', '--repo', 'github/gh-aw-firewall', + '--signer-workflow', 'github/gh-aw-firewall/.github/workflows/release.yml', + '--cert-oidc-issuer', 'https://token.actions.githubusercontent.com', '--deny-self-hosted-runners']; + const gh = path.join(f.dir, 'bin/gh'); + execFileSync(gh, args, { env: f.env, stdio: 'pipe' }); + for (const flag of ['--hostname', '--repo', '--signer-workflow', '--cert-oidc-issuer']) { + const wrong = [...args]; + wrong[wrong.indexOf(flag) + 1] = 'unexpected-constraint'; + assert.notEqual(spawnSync(gh, wrong, { env: f.env }).status, 0); + } + assert.notEqual(spawnSync(gh, args.filter(arg => arg !== '--deny-self-hosted-runners'), { env: f.env }).status, 0); + fs.appendFileSync(args[2], 'tampered'); + assert.notEqual(spawnSync(gh, args, { env: f.env }).status, 0); + for (const reference of ['ghcr.io/github/gh-aw-firewall/squid:latest', `unknown@sha256:${'a'.repeat(64)}`]) { + assert.notEqual(spawnSync(path.join(f.dir, 'bin/docker'), ['pull', reference], { env: f.env }).status, 0); + } + } finally { + fs.rmSync(f.work, { recursive: true, force: true }); + } +}); + +test('real action shell succeeds for latest/specific/digest pulls and rejects legacy/tampered releases', { + skip: process.platform !== 'linux', +}, () => { + for (const scenario of ['latest', 'specific', 'images', 'legacy', 'tampered']) { + const f = fixture(); + try { + if (scenario === 'legacy') fs.unlinkSync(path.join(f.dir, 'assets/release-identity.txt')); + if (scenario === 'tampered') fs.appendFileSync(path.join(f.dir, 'assets/awf-linux-x64'), 'tampered'); + const result = spawnSync('bash', ['-c', install], { + env: { ...f.env, INPUT_VERSION: scenario === 'latest' ? 'latest' : 'v1.2.3' }, encoding: 'utf8', + }); + if (['legacy', 'tampered'].includes(scenario)) { + assert.notEqual(result.status, 0); + assert.match(result.stderr + result.stdout, + scenario === 'legacy' ? /Missing release identity/ : /provenance verification failed/); + assert(!fs.existsSync(path.join(f.work, 'awf-bin/awf'))); + continue; + } + assert.equal(result.status, 0, result.stderr + result.stdout); + const version = execFileSync(path.join(f.work, 'awf-bin/awf'), ['--version'], { encoding: 'utf8' }); + assert.match(version, /v1\.2\.3/); + assert.equal(fs.readFileSync(path.join(f.dir, 'verified.log'), 'utf8').trim().split('\n').length, 4); + if (scenario === 'images') { + const output = fs.readFileSync(f.env.GITHUB_OUTPUT, 'utf8'); + const imageTag = output.split('\n').find(line => line.startsWith('image_tag=')).slice('image_tag='.length); + const pulled = spawnSync('bash', ['-c', pull], { + env: { ...f.env, IMAGE_TAG: imageTag }, encoding: 'utf8', + }); + assert.equal(pulled.status, 0, pulled.stderr + pulled.stdout); + assert.deepEqual(fs.readFileSync(path.join(f.dir, 'pulled.log'), 'utf8').trim().split('\n'), + fs.readFileSync(path.join(f.dir, 'assets/containers.txt'), 'utf8').trim().split('\n').slice(0, 2)); + } + } finally { + fs.rmSync(f.work, { recursive: true, force: true }); + } + } +}); diff --git a/scripts/ci/release-bootstrap-provenance.test.ts b/scripts/ci/release-bootstrap-provenance.test.ts index fc5086a61..2956b8535 100644 --- a/scripts/ci/release-bootstrap-provenance.test.ts +++ b/scripts/ci/release-bootstrap-provenance.test.ts @@ -1,7 +1,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { createHash } from 'crypto'; -import { spawnSync } from 'child_process'; +import { execFileSync, spawnSync } from 'child_process'; import * as yaml from 'js-yaml'; const root = path.resolve(__dirname, '../..'); @@ -122,6 +122,34 @@ cp "$CASE_DIR/assets/$asset" "$output" afterAll(() => fs.rmSync(scratch, { recursive: true, force: true })); describe('release bootstrap authentication', () => { + it('validates the isolated signed fixtures used by composite-action CI', () => { + execFileSync(process.execPath, ['--test', 'scripts/ci/action-fixture-smoke.cjs'], { cwd: root, stdio: 'pipe' }); + }, 30000); + + it('isolates action fixtures from the optional live attested-release test', () => { + const workflow = yaml.load(fs.readFileSync(path.join(root, '.github/workflows/test-action.yml'), 'utf8')) as { + jobs: Record; + }; + for (const name of ['test-action-latest', 'test-action-specific-version', 'test-action-with-images']) { + const steps = workflow.jobs[name].steps; + const prepare = steps.findIndex(step => step.run === 'node scripts/ci/setup-action-test-fixtures.cjs'); + const install = steps.findIndex(step => step.uses === './'); + expect(prepare).toBeGreaterThanOrEqual(0); + expect(prepare).toBeLessThan(install); + expect(steps[install].with?.version).toBe(name === 'test-action-latest' ? undefined : tag); + expect(steps.some(step => step.run?.includes('verified.log'))).toBe(true); + } + const live = workflow.jobs['test-action-live-attested-release']; + expect(live.if).toContain("github.event_name == 'workflow_dispatch'"); + expect(live.steps.some(step => step.run?.includes('setup-action-test-fixtures'))).toBe(false); + expect(live.steps.find(step => step.uses === './')?.with?.['pull-images']).toBe('true'); + for (const job of Object.values(workflow.jobs)) { + for (const step of job.steps.filter(step => step.run)) { + expect(spawnSync('bash', ['-n'], { input: step.run, encoding: 'utf8' }).status).toBe(0); + } + } + }); + it.each(['installer', 'action'] as const)('installs an authenticated %s payload', target => { const f = fixture(); const result = f.run(target); diff --git a/scripts/ci/setup-action-test-fixtures.cjs b/scripts/ci/setup-action-test-fixtures.cjs new file mode 100644 index 000000000..dfaf320c0 --- /dev/null +++ b/scripts/ci/setup-action-test-fixtures.cjs @@ -0,0 +1,96 @@ +#!/usr/bin/env node +// Test-only transport/verifier fixtures for the real composite action. Never +// installed by action.yml or published as part of a release. +const { execFileSync } = require('node:child_process'); +const { createHash, generateKeyPairSync, sign } = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); + +const directory = fs.mkdtempSync(path.join(process.env.RUNNER_TEMP, 'awf-action-fixtures-')); +const version = 'v1.2.3'; +const assets = path.join(directory, 'assets'); +const bin = path.join(directory, 'bin'); +fs.mkdirSync(assets); +fs.mkdirSync(bin); +const source = path.join(directory, 'fixture.c'); +fs.writeFileSync(source, '#include \nint main(void) { puts("awf fixture v1.2.3"); return 0; }\n'); +execFileSync('cc', [source, '-o', path.join(assets, 'awf-linux-x64')], { stdio: 'inherit' }); +const images = ['squid', 'agent', 'agent-act', 'api-proxy', 'router', 'cli-proxy', + 'enclave-script', 'enclave-agent', 'enclave-mcp-server']; +const hash = value => createHash('sha256').update(value).digest('hex'); +fs.writeFileSync(path.join(assets, 'containers.txt'), + images.map(image => `ghcr.io/github/gh-aw-firewall/${image}@sha256:${hash(image)}\n`).join('')); +fs.writeFileSync(path.join(assets, 'checksums.txt'), ['awf-linux-x64', 'containers.txt'] + .map(name => `${hash(fs.readFileSync(path.join(assets, name)))} ${name}\n`).join('')); +fs.writeFileSync(path.join(assets, 'release-identity.txt'), + `${hash(fs.readFileSync(path.join(assets, 'checksums.txt')))} refs/tags/${version}\n`); +const { privateKey, publicKey } = generateKeyPairSync('ed25519'); +fs.writeFileSync(path.join(directory, 'public.pem'), publicKey.export({ format: 'pem', type: 'spki' })); +for (const name of fs.readdirSync(assets)) { + fs.writeFileSync(path.join(directory, `${name}.sig`), sign(null, fs.readFileSync(path.join(assets, name)), privateKey)); +} +const prefix = `#!/usr/bin/env node\nconst directory=${JSON.stringify(directory)};\n`; +const tools = { + gh: ` +const fs=require('node:fs'),path=require('node:path'),{verify}=require('node:crypto'); +const args=process.argv.slice(2); +if (args.join(' ')==='attestation verify --help') { + console.log('--signer-workflow --deny-self-hosted-runners'); process.exit(0); +} +if (args.join(' ')==='api repos/github/gh-aw-firewall/releases/latest --jq .tag_name') { + console.log('${version}'); process.exit(0); +} +if (args[0]!=='attestation' || args[1]!=='verify') throw Error('Unexpected gh invocation'); +const required={ + '--hostname':'github.com', + '--repo':'github/gh-aw-firewall', + '--signer-workflow':'github/gh-aw-firewall/.github/workflows/release.yml', + '--cert-oidc-issuer':'https://token.actions.githubusercontent.com', +}; +for (const [flag,value] of Object.entries(required)) { + const index=args.indexOf(flag); + if(index<0 || args[index+1]!==value) throw Error('Missing provenance constraint '+flag); +} +if(!args.includes('--deny-self-hosted-runners')) throw Error('Self-hosted signers must be denied'); +const name=path.basename(args[2]).replace(/\\.download$/, ''); +if(!['awf-linux-x64','checksums.txt','release-identity.txt','containers.txt'].includes(name)) + throw Error('Unexpected provenance subject'); +if(!verify(null,fs.readFileSync(args[2]),fs.readFileSync(path.join(directory,'public.pem')), + fs.readFileSync(path.join(directory,name+'.sig')))) throw Error('Fixture signature verification failed'); +fs.appendFileSync(path.join(directory,'verified.log'),name+'\\n'); +`, + curl: ` +const fs=require('node:fs'),path=require('node:path'); +let output,url; +const args=process.argv.slice(2); +for(let i=0;i { const script = ` - const tls = require('tls'), {X509Certificate,createHash} = require('crypto'); + const tls = require('tls'), {X509Certificate,createHash} = require('crypto'), assert = require('assert'); + const original = tls.createSecureContext; + let appliedCa; + tls.createSecureContext = options => { appliedCa = options.ca; return original(options); }; const {distrustedSpkis} = require(${JSON.stringify(path.join(policyDir, 'node-trust.cjs'))}); - for (const pem of tls.getCACertificates('default')) { - const key = new X509Certificate(pem).publicKey.export({format:'der',type:'spki'}); - if (distrustedSpkis.has(createHash('sha256').update(key).digest('hex'))) process.exit(1); + for (const options of [{}, {ca:null}]) { + tls.createSecureContext(options); + assert(Array.isArray(appliedCa) && appliedCa.length > 0); + for (const pem of [...appliedCa, ...(tls.getCACertificates?.('default') || [])]) { + const key = new X509Certificate(pem).publicKey.export({format:'der',type:'spki'}); + assert(!distrustedSpkis.has(createHash('sha256').update(key).digest('hex'))); + } } `; - execFileSync(process.execPath, ['-r', path.join(policyDir, 'node-trust.cjs'), '-e', script]); + execFileSync(process.execPath, ['-e', script]); }); test('all gh builds use the fixed Go toolchain and assert compiled metadata', () => { diff --git a/scripts/security/tls-runtime-smoke.cjs b/scripts/security/tls-runtime-smoke.cjs index 3244ea88b..d0474e51e 100644 --- a/scripts/security/tls-runtime-smoke.cjs +++ b/scripts/security/tls-runtime-smoke.cjs @@ -55,7 +55,7 @@ test('HTTPS, direct TLS and fetch enforce default, extra and explicit CA distrus await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); const { port } = server.address(); for (const mode of ['https', 'tls', 'fetch']) { - for (const explicit of [false, true]) { + for (const explicit of [false, true, null]) { const allowed = name === 'private-awf'; const script = ` const fs = require('fs'); @@ -65,6 +65,7 @@ test('HTTPS, direct TLS and fetch enforce default, extra and explicit CA distrus const allowed = process.env.AWF_ALLOWED === '1'; const options = { host: '127.0.0.1', port, servername: 'localhost' }; if (explicit) options.ca = fs.readFileSync(process.env.AWF_INPUTS_PEM); + else if (process.env.AWF_EXPLICIT === 'null') options.ca = null; const fail = () => process.exit(allowed ? 1 : 0); const pass = () => process.exit(allowed ? 0 : 1); if (mode === 'tls') require('tls').connect(options, pass).on('error', fail); @@ -79,7 +80,7 @@ test('HTTPS, direct TLS and fetch enforce default, extra and explicit CA distrus NODE_EXTRA_CA_CERTS: path.join(work, 'inputs.pem'), AWF_MODE: mode, AWF_PORT: String(port), - AWF_EXPLICIT: explicit ? '1' : '0', + AWF_EXPLICIT: explicit === null ? 'null' : explicit ? '1' : '0', AWF_ALLOWED: allowed ? '1' : '0', AWF_INPUTS_PEM: path.join(work, 'inputs.pem'), }, diff --git a/src/tls-trust-policy.ts b/src/tls-trust-policy.ts index 74353e990..45302a29e 100644 --- a/src/tls-trust-policy.ts +++ b/src/tls-trust-policy.ts @@ -67,7 +67,8 @@ export function applyTlsTrustPolicy(): void { // default-CA API. This affects https, fetch/undici and direct tls.connect. tls.createSecureContext = (options = {}) => createSecureContext({ ...options, - ca: options.ca === undefined ? filteredDefaults : filterCaCertificates(options.ca), + ca: options.ca === undefined || options.ca === null + ? filteredDefaults : filterCaCertificates(options.ca), }); installed = true; } From af61e29e731a4a9acd0296171061abdb78b4bf8d Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Tue, 20 Oct 2026 09:29:13 -0700 Subject: [PATCH 4/9] test: fully cover CA trust policy failure and fallback paths Exercise modern and legacy Node trust APIs, null/default/explicit CA options, malformed or unreadable stores, empty results, and idempotent installation. Cover every canonical policy branch without weakening coverage gates. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 89097bc0-0800-4855-965e-ceaab0cef6d3 --- docs/security-ca-trust.md | 5 +- src/tls-trust-policy.test.ts | 100 +++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 src/tls-trust-policy.test.ts diff --git a/docs/security-ca-trust.md b/docs/security-ca-trust.md index fb2d12c4e..990e0dc3d 100644 --- a/docs/security-ca-trust.md +++ b/docs/security-ca-trust.md @@ -85,8 +85,9 @@ they cannot be established by the offline tests alone. ## Regression checks -Run `npm test -- scripts/security/ca-trust.test.ts` for the policy, generated-copy, -image-wiring, and offline TLS regressions. The dependency-free runtime smoke, +Run `npm test -- src/tls-trust-policy.test.ts scripts/security/ca-trust.test.ts` +for the policy, generated-copy, image-wiring, and offline TLS regressions. +The dependency-free runtime smoke, `node --test scripts/security/tls-runtime-smoke.cjs`, can also run directly inside each rebuilt Node image with the repository mounted at `/work`; it needs OpenSSL and the image's installed Node launcher. diff --git a/src/tls-trust-policy.test.ts b/src/tls-trust-policy.test.ts new file mode 100644 index 000000000..808f01f6f --- /dev/null +++ b/src/tls-trust-policy.test.ts @@ -0,0 +1,100 @@ +import { X509Certificate } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import * as path from 'node:path'; +import { rootCertificates } from 'node:tls'; + +type Policy = typeof import('./tls-trust-policy'); +const trusted = rootCertificates.find(pem => new X509Certificate(pem).subject.includes('ISRG Root X1'))!; +const blocked = readFileSync(path.resolve(__dirname, '../scripts/security/fixtures/emsign-ecc-g3.pem'), 'utf8').trim(); +const originalExtraCa = process.env.NODE_EXTRA_CA_CERTS; + +function load(options: { + legacy?: boolean; + defaults?: string[]; + extra?: string; + extraError?: Error; + setter?: boolean; +} = {}) { + const nativeContext = {}; + const createSecureContext = jest.fn().mockReturnValue(nativeContext); + const setDefaultCACertificates = jest.fn(); + const getCACertificates = jest.fn().mockReturnValue(options.defaults ?? [trusted, blocked]); + const read = jest.fn(() => { + if (options.extraError) throw options.extraError; + return options.extra; + }); + if (options.extra !== undefined || options.extraError) process.env.NODE_EXTRA_CA_CERTS = '/private-ca.pem'; + jest.doMock('node:tls', () => ({ + rootCertificates: options.defaults ?? [trusted, blocked], + getCACertificates: options.legacy ? undefined : getCACertificates, + setDefaultCACertificates: options.legacy || options.setter === false ? undefined : setDefaultCACertificates, + createSecureContext, + })); + jest.doMock('node:fs', () => ({ readFileSync: read })); + let policy!: Policy; + let tls!: typeof import('node:tls'); + jest.isolateModules(() => { + policy = jest.requireActual('./tls-trust-policy'); + tls = jest.requireMock('node:tls'); + }); + return { policy, tls, createSecureContext, setDefaultCACertificates, getCACertificates, read, nativeContext }; +} + +beforeEach(() => { delete process.env.NODE_EXTRA_CA_CERTS; }); +afterEach(() => { + jest.dontMock('node:tls'); + jest.dontMock('node:fs'); + if (originalExtraCa === undefined) delete process.env.NODE_EXTRA_CA_CERTS; + else process.env.NODE_EXTRA_CA_CERTS = originalExtraCa; +}); + +test('filters modern defaults and extra roots and installs the policy only once', () => { + const f = load({ extra: blocked + '\n' + trusted }); + expect(f.getCACertificates).toHaveBeenCalledWith('default'); + expect(f.read).toHaveBeenCalledWith('/private-ca.pem', 'utf8'); + expect(f.setDefaultCACertificates).toHaveBeenCalledWith([trusted, trusted]); + f.policy.applyTlsTrustPolicy(); + expect(f.setDefaultCACertificates).toHaveBeenCalledTimes(1); +}); + +test.each([false, true])('default and null CA options use sanitized defaults (legacy=%s)', legacy => { + const f = load({ legacy }); + for (const options of [undefined, { ca: null }]) { + // Node accepts ca:null at runtime, despite its declaration excluding null. + const result = options === undefined + ? f.tls.createSecureContext() + : Reflect.apply(f.tls.createSecureContext, f.tls, [options]); + expect(result).toBe(f.nativeContext); + expect(f.createSecureContext).toHaveBeenLastCalledWith({ ca: [trusted] }); + } + expect(f.setDefaultCACertificates).toHaveBeenCalledTimes(legacy ? 0 : 1); +}); + +test('filters explicit CA strings, buffers and arrays without dropping other options', () => { + const f = load({ setter: false }); + for (const ca of [trusted + '\n' + blocked, Buffer.from(trusted), [Buffer.from(blocked), trusted]]) { + f.tls.createSecureContext({ ca, minVersion: 'TLSv1.2' }); + expect(f.createSecureContext).toHaveBeenLastCalledWith({ ca: [trusted], minVersion: 'TLSv1.2' }); + } + f.tls.createSecureContext({ ca: blocked }); + expect(f.createSecureContext).toHaveBeenLastCalledWith({ ca: [] }); + expect(f.policy.filterCaCertificates(' \n')).toEqual([]); +}); + +test.each([ + ['not a certificate', /PEM CERTIFICATE/], + ['-----BEGIN TRUSTED CERTIFICATE-----\ninvalid\n-----END TRUSTED CERTIFICATE-----', /PEM CERTIFICATE/], + ['-----BEGIN CERTIFICATE-----\ninvalid\n-----END CERTIFICATE-----', /./], +])('rejects malformed explicit trust: %s', (ca, error) => { + const f = load(); + expect(() => f.tls.createSecureContext({ ca })).toThrow(error); + expect(f.createSecureContext).not.toHaveBeenCalled(); +}); + +test.each([false, true])('rejects an entirely distrusted default store (legacy=%s)', legacy => { + expect(() => load({ legacy, defaults: [blocked] })).toThrow('empty default CA store'); +}); + +test('fails closed when an explicit extra CA file cannot be read', () => { + expect(() => load({ extraError: new Error('private CA is unreadable') })).toThrow('private CA is unreadable'); +}); From f10dbc6e9d7866f9deb11cc9c391a4be84dd3946 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:29:11 +0000 Subject: [PATCH 5/9] fix: fail closed on missing CA policy --- containers/agent/trust/filter-ca-store.sh | 23 ++++++++++++-- containers/api-proxy/trust/filter-ca-store.sh | 23 ++++++++++++-- .../build-tools/trust/filter-ca-store.sh | 23 ++++++++++++-- containers/cli-proxy/trust/filter-ca-store.sh | 23 ++++++++++++-- .../dind-ubuntu/trust/filter-ca-store.sh | 23 ++++++++++++-- .../gh-aw-node/trust/filter-ca-store.sh | 23 ++++++++++++-- containers/router/trust/filter-ca-store.sh | 23 ++++++++++++-- containers/squid/trust/filter-ca-store.sh | 23 ++++++++++++-- containers/trust/filter-ca-store.sh | 23 ++++++++++++-- scripts/security/ca-trust.test.ts | 30 +++++++++++++++++++ scripts/security/filter-ca-store.sh | 23 ++++++++++++-- 11 files changed, 230 insertions(+), 30 deletions(-) diff --git a/containers/agent/trust/filter-ca-store.sh b/containers/agent/trust/filter-ca-store.sh index 604586b2f..c540c8dea 100644 --- a/containers/agent/trust/filter-ca-store.sh +++ b/containers/agent/trust/filter-ca-store.sh @@ -4,6 +4,11 @@ set -eu output=$1 shift policy_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +policy_file="$policy_dir/distrusted-spki.txt" +[ -f "$policy_file" ] && [ -s "$policy_file" ] && [ -r "$policy_file" ] || { + echo "AWF trust policy: missing, empty, or unreadable distrust policy: $policy_file" >&2 + exit 1 +} mkdir -p "$output" work=$(mktemp -d "$output/.filter.XXXXXX") trap 'rm -rf "$work"' EXIT HUP INT TERM @@ -39,10 +44,22 @@ for cert in "$work"/cert-*.pem; do openssl pkey -pubin -in "$work/key.pem" -outform DER -out "$work/key.der" openssl dgst -sha256 "$work/key.der" > "$work/digest" key=$(awk '{print $NF}' "$work/digest") - if grep -Fqx "$key" "$policy_dir/distrusted-spki.txt"; then - echo "AWF trust policy: removed distrusted CA key $key" >&2 - continue + if grep -Fqx "$key" "$policy_file"; then + grep_status=0 + else + grep_status=$? fi + case "$grep_status" in + 0) + echo "AWF trust policy: removed distrusted CA key $key" >&2 + continue + ;; + 1) ;; + *) + echo "AWF trust policy: failed to read distrust policy: $policy_file" >&2 + exit 1 + ;; + esac openssl x509 -in "$cert" -outform DER -out "$work/cert.der" openssl dgst -sha256 "$work/cert.der" > "$work/digest" fingerprint=$(awk '{print $NF}' "$work/digest") diff --git a/containers/api-proxy/trust/filter-ca-store.sh b/containers/api-proxy/trust/filter-ca-store.sh index 604586b2f..c540c8dea 100644 --- a/containers/api-proxy/trust/filter-ca-store.sh +++ b/containers/api-proxy/trust/filter-ca-store.sh @@ -4,6 +4,11 @@ set -eu output=$1 shift policy_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +policy_file="$policy_dir/distrusted-spki.txt" +[ -f "$policy_file" ] && [ -s "$policy_file" ] && [ -r "$policy_file" ] || { + echo "AWF trust policy: missing, empty, or unreadable distrust policy: $policy_file" >&2 + exit 1 +} mkdir -p "$output" work=$(mktemp -d "$output/.filter.XXXXXX") trap 'rm -rf "$work"' EXIT HUP INT TERM @@ -39,10 +44,22 @@ for cert in "$work"/cert-*.pem; do openssl pkey -pubin -in "$work/key.pem" -outform DER -out "$work/key.der" openssl dgst -sha256 "$work/key.der" > "$work/digest" key=$(awk '{print $NF}' "$work/digest") - if grep -Fqx "$key" "$policy_dir/distrusted-spki.txt"; then - echo "AWF trust policy: removed distrusted CA key $key" >&2 - continue + if grep -Fqx "$key" "$policy_file"; then + grep_status=0 + else + grep_status=$? fi + case "$grep_status" in + 0) + echo "AWF trust policy: removed distrusted CA key $key" >&2 + continue + ;; + 1) ;; + *) + echo "AWF trust policy: failed to read distrust policy: $policy_file" >&2 + exit 1 + ;; + esac openssl x509 -in "$cert" -outform DER -out "$work/cert.der" openssl dgst -sha256 "$work/cert.der" > "$work/digest" fingerprint=$(awk '{print $NF}' "$work/digest") diff --git a/containers/build-tools/trust/filter-ca-store.sh b/containers/build-tools/trust/filter-ca-store.sh index 604586b2f..c540c8dea 100644 --- a/containers/build-tools/trust/filter-ca-store.sh +++ b/containers/build-tools/trust/filter-ca-store.sh @@ -4,6 +4,11 @@ set -eu output=$1 shift policy_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +policy_file="$policy_dir/distrusted-spki.txt" +[ -f "$policy_file" ] && [ -s "$policy_file" ] && [ -r "$policy_file" ] || { + echo "AWF trust policy: missing, empty, or unreadable distrust policy: $policy_file" >&2 + exit 1 +} mkdir -p "$output" work=$(mktemp -d "$output/.filter.XXXXXX") trap 'rm -rf "$work"' EXIT HUP INT TERM @@ -39,10 +44,22 @@ for cert in "$work"/cert-*.pem; do openssl pkey -pubin -in "$work/key.pem" -outform DER -out "$work/key.der" openssl dgst -sha256 "$work/key.der" > "$work/digest" key=$(awk '{print $NF}' "$work/digest") - if grep -Fqx "$key" "$policy_dir/distrusted-spki.txt"; then - echo "AWF trust policy: removed distrusted CA key $key" >&2 - continue + if grep -Fqx "$key" "$policy_file"; then + grep_status=0 + else + grep_status=$? fi + case "$grep_status" in + 0) + echo "AWF trust policy: removed distrusted CA key $key" >&2 + continue + ;; + 1) ;; + *) + echo "AWF trust policy: failed to read distrust policy: $policy_file" >&2 + exit 1 + ;; + esac openssl x509 -in "$cert" -outform DER -out "$work/cert.der" openssl dgst -sha256 "$work/cert.der" > "$work/digest" fingerprint=$(awk '{print $NF}' "$work/digest") diff --git a/containers/cli-proxy/trust/filter-ca-store.sh b/containers/cli-proxy/trust/filter-ca-store.sh index 604586b2f..c540c8dea 100644 --- a/containers/cli-proxy/trust/filter-ca-store.sh +++ b/containers/cli-proxy/trust/filter-ca-store.sh @@ -4,6 +4,11 @@ set -eu output=$1 shift policy_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +policy_file="$policy_dir/distrusted-spki.txt" +[ -f "$policy_file" ] && [ -s "$policy_file" ] && [ -r "$policy_file" ] || { + echo "AWF trust policy: missing, empty, or unreadable distrust policy: $policy_file" >&2 + exit 1 +} mkdir -p "$output" work=$(mktemp -d "$output/.filter.XXXXXX") trap 'rm -rf "$work"' EXIT HUP INT TERM @@ -39,10 +44,22 @@ for cert in "$work"/cert-*.pem; do openssl pkey -pubin -in "$work/key.pem" -outform DER -out "$work/key.der" openssl dgst -sha256 "$work/key.der" > "$work/digest" key=$(awk '{print $NF}' "$work/digest") - if grep -Fqx "$key" "$policy_dir/distrusted-spki.txt"; then - echo "AWF trust policy: removed distrusted CA key $key" >&2 - continue + if grep -Fqx "$key" "$policy_file"; then + grep_status=0 + else + grep_status=$? fi + case "$grep_status" in + 0) + echo "AWF trust policy: removed distrusted CA key $key" >&2 + continue + ;; + 1) ;; + *) + echo "AWF trust policy: failed to read distrust policy: $policy_file" >&2 + exit 1 + ;; + esac openssl x509 -in "$cert" -outform DER -out "$work/cert.der" openssl dgst -sha256 "$work/cert.der" > "$work/digest" fingerprint=$(awk '{print $NF}' "$work/digest") diff --git a/containers/dind-ubuntu/trust/filter-ca-store.sh b/containers/dind-ubuntu/trust/filter-ca-store.sh index 604586b2f..c540c8dea 100644 --- a/containers/dind-ubuntu/trust/filter-ca-store.sh +++ b/containers/dind-ubuntu/trust/filter-ca-store.sh @@ -4,6 +4,11 @@ set -eu output=$1 shift policy_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +policy_file="$policy_dir/distrusted-spki.txt" +[ -f "$policy_file" ] && [ -s "$policy_file" ] && [ -r "$policy_file" ] || { + echo "AWF trust policy: missing, empty, or unreadable distrust policy: $policy_file" >&2 + exit 1 +} mkdir -p "$output" work=$(mktemp -d "$output/.filter.XXXXXX") trap 'rm -rf "$work"' EXIT HUP INT TERM @@ -39,10 +44,22 @@ for cert in "$work"/cert-*.pem; do openssl pkey -pubin -in "$work/key.pem" -outform DER -out "$work/key.der" openssl dgst -sha256 "$work/key.der" > "$work/digest" key=$(awk '{print $NF}' "$work/digest") - if grep -Fqx "$key" "$policy_dir/distrusted-spki.txt"; then - echo "AWF trust policy: removed distrusted CA key $key" >&2 - continue + if grep -Fqx "$key" "$policy_file"; then + grep_status=0 + else + grep_status=$? fi + case "$grep_status" in + 0) + echo "AWF trust policy: removed distrusted CA key $key" >&2 + continue + ;; + 1) ;; + *) + echo "AWF trust policy: failed to read distrust policy: $policy_file" >&2 + exit 1 + ;; + esac openssl x509 -in "$cert" -outform DER -out "$work/cert.der" openssl dgst -sha256 "$work/cert.der" > "$work/digest" fingerprint=$(awk '{print $NF}' "$work/digest") diff --git a/containers/gh-aw-node/trust/filter-ca-store.sh b/containers/gh-aw-node/trust/filter-ca-store.sh index 604586b2f..c540c8dea 100644 --- a/containers/gh-aw-node/trust/filter-ca-store.sh +++ b/containers/gh-aw-node/trust/filter-ca-store.sh @@ -4,6 +4,11 @@ set -eu output=$1 shift policy_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +policy_file="$policy_dir/distrusted-spki.txt" +[ -f "$policy_file" ] && [ -s "$policy_file" ] && [ -r "$policy_file" ] || { + echo "AWF trust policy: missing, empty, or unreadable distrust policy: $policy_file" >&2 + exit 1 +} mkdir -p "$output" work=$(mktemp -d "$output/.filter.XXXXXX") trap 'rm -rf "$work"' EXIT HUP INT TERM @@ -39,10 +44,22 @@ for cert in "$work"/cert-*.pem; do openssl pkey -pubin -in "$work/key.pem" -outform DER -out "$work/key.der" openssl dgst -sha256 "$work/key.der" > "$work/digest" key=$(awk '{print $NF}' "$work/digest") - if grep -Fqx "$key" "$policy_dir/distrusted-spki.txt"; then - echo "AWF trust policy: removed distrusted CA key $key" >&2 - continue + if grep -Fqx "$key" "$policy_file"; then + grep_status=0 + else + grep_status=$? fi + case "$grep_status" in + 0) + echo "AWF trust policy: removed distrusted CA key $key" >&2 + continue + ;; + 1) ;; + *) + echo "AWF trust policy: failed to read distrust policy: $policy_file" >&2 + exit 1 + ;; + esac openssl x509 -in "$cert" -outform DER -out "$work/cert.der" openssl dgst -sha256 "$work/cert.der" > "$work/digest" fingerprint=$(awk '{print $NF}' "$work/digest") diff --git a/containers/router/trust/filter-ca-store.sh b/containers/router/trust/filter-ca-store.sh index 604586b2f..c540c8dea 100644 --- a/containers/router/trust/filter-ca-store.sh +++ b/containers/router/trust/filter-ca-store.sh @@ -4,6 +4,11 @@ set -eu output=$1 shift policy_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +policy_file="$policy_dir/distrusted-spki.txt" +[ -f "$policy_file" ] && [ -s "$policy_file" ] && [ -r "$policy_file" ] || { + echo "AWF trust policy: missing, empty, or unreadable distrust policy: $policy_file" >&2 + exit 1 +} mkdir -p "$output" work=$(mktemp -d "$output/.filter.XXXXXX") trap 'rm -rf "$work"' EXIT HUP INT TERM @@ -39,10 +44,22 @@ for cert in "$work"/cert-*.pem; do openssl pkey -pubin -in "$work/key.pem" -outform DER -out "$work/key.der" openssl dgst -sha256 "$work/key.der" > "$work/digest" key=$(awk '{print $NF}' "$work/digest") - if grep -Fqx "$key" "$policy_dir/distrusted-spki.txt"; then - echo "AWF trust policy: removed distrusted CA key $key" >&2 - continue + if grep -Fqx "$key" "$policy_file"; then + grep_status=0 + else + grep_status=$? fi + case "$grep_status" in + 0) + echo "AWF trust policy: removed distrusted CA key $key" >&2 + continue + ;; + 1) ;; + *) + echo "AWF trust policy: failed to read distrust policy: $policy_file" >&2 + exit 1 + ;; + esac openssl x509 -in "$cert" -outform DER -out "$work/cert.der" openssl dgst -sha256 "$work/cert.der" > "$work/digest" fingerprint=$(awk '{print $NF}' "$work/digest") diff --git a/containers/squid/trust/filter-ca-store.sh b/containers/squid/trust/filter-ca-store.sh index 604586b2f..c540c8dea 100644 --- a/containers/squid/trust/filter-ca-store.sh +++ b/containers/squid/trust/filter-ca-store.sh @@ -4,6 +4,11 @@ set -eu output=$1 shift policy_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +policy_file="$policy_dir/distrusted-spki.txt" +[ -f "$policy_file" ] && [ -s "$policy_file" ] && [ -r "$policy_file" ] || { + echo "AWF trust policy: missing, empty, or unreadable distrust policy: $policy_file" >&2 + exit 1 +} mkdir -p "$output" work=$(mktemp -d "$output/.filter.XXXXXX") trap 'rm -rf "$work"' EXIT HUP INT TERM @@ -39,10 +44,22 @@ for cert in "$work"/cert-*.pem; do openssl pkey -pubin -in "$work/key.pem" -outform DER -out "$work/key.der" openssl dgst -sha256 "$work/key.der" > "$work/digest" key=$(awk '{print $NF}' "$work/digest") - if grep -Fqx "$key" "$policy_dir/distrusted-spki.txt"; then - echo "AWF trust policy: removed distrusted CA key $key" >&2 - continue + if grep -Fqx "$key" "$policy_file"; then + grep_status=0 + else + grep_status=$? fi + case "$grep_status" in + 0) + echo "AWF trust policy: removed distrusted CA key $key" >&2 + continue + ;; + 1) ;; + *) + echo "AWF trust policy: failed to read distrust policy: $policy_file" >&2 + exit 1 + ;; + esac openssl x509 -in "$cert" -outform DER -out "$work/cert.der" openssl dgst -sha256 "$work/cert.der" > "$work/digest" fingerprint=$(awk '{print $NF}' "$work/digest") diff --git a/containers/trust/filter-ca-store.sh b/containers/trust/filter-ca-store.sh index 604586b2f..c540c8dea 100644 --- a/containers/trust/filter-ca-store.sh +++ b/containers/trust/filter-ca-store.sh @@ -4,6 +4,11 @@ set -eu output=$1 shift policy_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +policy_file="$policy_dir/distrusted-spki.txt" +[ -f "$policy_file" ] && [ -s "$policy_file" ] && [ -r "$policy_file" ] || { + echo "AWF trust policy: missing, empty, or unreadable distrust policy: $policy_file" >&2 + exit 1 +} mkdir -p "$output" work=$(mktemp -d "$output/.filter.XXXXXX") trap 'rm -rf "$work"' EXIT HUP INT TERM @@ -39,10 +44,22 @@ for cert in "$work"/cert-*.pem; do openssl pkey -pubin -in "$work/key.pem" -outform DER -out "$work/key.der" openssl dgst -sha256 "$work/key.der" > "$work/digest" key=$(awk '{print $NF}' "$work/digest") - if grep -Fqx "$key" "$policy_dir/distrusted-spki.txt"; then - echo "AWF trust policy: removed distrusted CA key $key" >&2 - continue + if grep -Fqx "$key" "$policy_file"; then + grep_status=0 + else + grep_status=$? fi + case "$grep_status" in + 0) + echo "AWF trust policy: removed distrusted CA key $key" >&2 + continue + ;; + 1) ;; + *) + echo "AWF trust policy: failed to read distrust policy: $policy_file" >&2 + exit 1 + ;; + esac openssl x509 -in "$cert" -outform DER -out "$work/cert.der" openssl dgst -sha256 "$work/cert.der" > "$work/digest" fingerprint=$(awk '{print $NF}' "$work/digest") diff --git a/scripts/security/ca-trust.test.ts b/scripts/security/ca-trust.test.ts index e68ca4a4f..0d1e15079 100644 --- a/scripts/security/ca-trust.test.ts +++ b/scripts/security/ca-trust.test.ts @@ -111,6 +111,36 @@ test('invalid and all-distrusted CA stores fail closed', () => { } }); +test('missing, empty, and grep-error distrust policies fail closed', () => { + const policyFile = path.join(work, 'distrusted-spki.txt'); + const policy = fs.readFileSync(policyFile, 'utf8'); + const output = path.join(work, 'reject-policy'); + try { + fs.unlinkSync(policyFile); + expect(() => execFileSync('sh', [path.join(work, 'filter-ca-store.sh'), output, + path.join(work, 'inputs.pem')], { stdio: 'pipe' })).toThrow(); + + fs.writeFileSync(policyFile, ''); + expect(() => execFileSync('sh', [path.join(work, 'filter-ca-store.sh'), output, + path.join(work, 'inputs.pem')], { stdio: 'pipe' })).toThrow(); + + fs.writeFileSync(policyFile, policy); + const fakeBin = path.join(work, 'fake-bin'); + fs.mkdirSync(fakeBin); + const fakeGrep = path.join(fakeBin, 'grep'); + fs.writeFileSync(fakeGrep, '#!/bin/sh\nexit 2\n'); + fs.chmodSync(fakeGrep, 0o755); + expect(() => execFileSync('sh', [path.join(work, 'filter-ca-store.sh'), output, + path.join(work, 'inputs.pem')], { + env: { ...process.env, PATH: `${fakeBin}:${process.env.PATH}` }, + stdio: 'pipe', + })).toThrow(); + expect(fs.existsSync(path.join(output, 'ca-certificates.crt'))).toBe(false); + } finally { + fs.writeFileSync(policyFile, policy); + } +}); + test.each(['https', 'tls', 'fetch'])('%s rejects compromised default/extra/explicit trust and keeps private CA support', async mode => { for (const [leaf, allowed] of [['bad-leaf', false], ['good-leaf', true]] as const) { const server = https.createServer({ diff --git a/scripts/security/filter-ca-store.sh b/scripts/security/filter-ca-store.sh index 604586b2f..c540c8dea 100644 --- a/scripts/security/filter-ca-store.sh +++ b/scripts/security/filter-ca-store.sh @@ -4,6 +4,11 @@ set -eu output=$1 shift policy_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +policy_file="$policy_dir/distrusted-spki.txt" +[ -f "$policy_file" ] && [ -s "$policy_file" ] && [ -r "$policy_file" ] || { + echo "AWF trust policy: missing, empty, or unreadable distrust policy: $policy_file" >&2 + exit 1 +} mkdir -p "$output" work=$(mktemp -d "$output/.filter.XXXXXX") trap 'rm -rf "$work"' EXIT HUP INT TERM @@ -39,10 +44,22 @@ for cert in "$work"/cert-*.pem; do openssl pkey -pubin -in "$work/key.pem" -outform DER -out "$work/key.der" openssl dgst -sha256 "$work/key.der" > "$work/digest" key=$(awk '{print $NF}' "$work/digest") - if grep -Fqx "$key" "$policy_dir/distrusted-spki.txt"; then - echo "AWF trust policy: removed distrusted CA key $key" >&2 - continue + if grep -Fqx "$key" "$policy_file"; then + grep_status=0 + else + grep_status=$? fi + case "$grep_status" in + 0) + echo "AWF trust policy: removed distrusted CA key $key" >&2 + continue + ;; + 1) ;; + *) + echo "AWF trust policy: failed to read distrust policy: $policy_file" >&2 + exit 1 + ;; + esac openssl x509 -in "$cert" -outform DER -out "$work/cert.der" openssl dgst -sha256 "$work/cert.der" > "$work/digest" fingerprint=$(awk '{print $NF}' "$work/digest") From d12058e94562d6af40df79d2f89e0cf25cfdbbca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:56:23 +0000 Subject: [PATCH 6/9] fix: address CA trust review feedback --- .claude/skills/awf-skill/README.md | 7 +- .claude/skills/awf-skill/SKILL.md | 10 +- .claude/skills/awf-skill/install.sh | 4 +- README.md | 18 +- containers/agent/entrypoint.sh | 11 +- containers/agent/trust/filter-ca-store.sh | 15 +- .../agent/trust/install-system-trust.sh | 2 +- containers/agent/trust/node-trust.cjs | 75 +++++++- containers/api-proxy/trust/filter-ca-store.sh | 15 +- .../api-proxy/trust/install-system-trust.sh | 2 +- containers/api-proxy/trust/node-trust.cjs | 75 +++++++- .../build-tools/trust/filter-ca-store.sh | 15 +- .../build-tools/trust/install-system-trust.sh | 2 +- containers/cli-proxy/trust/filter-ca-store.sh | 15 +- .../cli-proxy/trust/install-system-trust.sh | 2 +- containers/cli-proxy/trust/node-trust.cjs | 75 +++++++- .../dind-ubuntu/trust/filter-ca-store.sh | 15 +- .../dind-ubuntu/trust/install-system-trust.sh | 2 +- containers/dind-ubuntu/trust/node-trust.cjs | 75 +++++++- containers/enclave/Dockerfile | 11 +- .../gh-aw-node/trust/filter-ca-store.sh | 15 +- .../gh-aw-node/trust/install-system-trust.sh | 2 +- containers/gh-aw-node/trust/node-trust.cjs | 75 +++++++- containers/router/trust/filter-ca-store.sh | 15 +- .../router/trust/install-system-trust.sh | 2 +- containers/squid/trust/filter-ca-store.sh | 15 +- .../squid/trust/install-system-trust.sh | 2 +- containers/trust/filter-ca-store.sh | 15 +- containers/trust/install-system-trust.sh | 2 +- containers/trust/node-trust.cjs | 75 +++++++- docs-site/src/content/docs/index.md | 26 +-- docs/RELEASE_TEMPLATE.md | 18 +- docs/enterprise-configuration.md | 4 +- docs/github_actions.md | 29 ++-- docs/quickstart.md | 20 ++- install.sh | 16 +- scripts/security/ca-trust.test.ts | 73 +++++++- scripts/security/filter-ca-store.sh | 15 +- scripts/security/install-system-trust.sh | 2 +- skill.md | 14 +- src/tls-trust-policy.test.ts | 160 +++++++++++++++++- src/tls-trust-policy.ts | 71 +++++++- tests/entrypoint-phase-functions.test.sh | 32 +++- 43 files changed, 981 insertions(+), 163 deletions(-) diff --git a/.claude/skills/awf-skill/README.md b/.claude/skills/awf-skill/README.md index 515520a85..27029c2bc 100644 --- a/.claude/skills/awf-skill/README.md +++ b/.claude/skills/awf-skill/README.md @@ -44,8 +44,11 @@ curl -sSL https://raw.githubusercontent.com/github/gh-aw-firewall/main/skill.md Before using this skill, ensure AWF is installed: ```bash -# Install AWF -curl -sSL https://raw.githubusercontent.com/github/gh-aw-firewall/main/install.sh | sudo bash +# Obtain and review install.sh through an independently authenticated channel. +gh auth status +export GH_TOKEN="$(gh auth token)" +sudo --preserve-env=GH_TOKEN bash ./install.sh +unset GH_TOKEN # Verify installation sudo awf --version diff --git a/.claude/skills/awf-skill/SKILL.md b/.claude/skills/awf-skill/SKILL.md index aca24601a..9d36a1470 100644 --- a/.claude/skills/awf-skill/SKILL.md +++ b/.claude/skills/awf-skill/SKILL.md @@ -55,10 +55,16 @@ sudo awf --allow-domains github.com --log-level debug -- curl https://api.github ### Install Script (Recommended) ```bash -curl -sSL https://raw.githubusercontent.com/github/gh-aw-firewall/main/install.sh | sudo bash +gh auth status +export GH_TOKEN="$(gh auth token)" +sudo --preserve-env=GH_TOKEN bash ./install.sh +unset GH_TOKEN sudo awf --version ``` +Obtain and review `install.sh` through an independently authenticated channel +before running it. Do not pipe an unauthenticated download into `sudo bash`. + ### Build from Source ```bash @@ -216,7 +222,7 @@ sudo awf \ ```yaml - name: Setup awf - uses: github/gh-aw-firewall@main + uses: github/gh-aw-firewall@ - name: Run with firewall run: | diff --git a/.claude/skills/awf-skill/install.sh b/.claude/skills/awf-skill/install.sh index 4d43ab738..7a471b3ef 100755 --- a/.claude/skills/awf-skill/install.sh +++ b/.claude/skills/awf-skill/install.sh @@ -52,7 +52,9 @@ echo " - Common workflow examples" echo " - Debugging and troubleshooting guides" echo "" echo "Prerequisites:" -echo " 1. Install AWF: curl -sSL https://raw.githubusercontent.com/github/gh-aw-firewall/main/install.sh | sudo bash" +echo " 1. Obtain and review install.sh independently, authenticate gh, then run:" +echo " export GH_TOKEN=\"\$(gh auth token)\"" +echo " sudo --preserve-env=GH_TOKEN bash ./install.sh" echo " 2. Verify: sudo awf --version" echo "" echo "Claude Code agents in this project can now use AWF effectively!" diff --git a/README.md b/README.md index d3a462c1d..8ebf6a08c 100644 --- a/README.md +++ b/README.md @@ -27,10 +27,26 @@ See [Compatibility](docs/compatibility.md) for full details on supported version ## Get started fast ```bash -curl -sSL https://raw.githubusercontent.com/github/gh-aw-firewall/main/install.sh | sudo bash sudo awf --allow-domains github.com -- curl https://api.github.com ``` +For CI, prefer the [setup action](docs/github_actions.md) pinned to a reviewed +commit. For local installation, first obtain and review `install.sh` through an +independently authenticated channel. The installer requires a trusted `gh` CLI +with GitHub authentication; because `sudo` normally changes `HOME` and drops +tokens, pass the token explicitly: + +```bash +gh auth status +export GH_TOKEN="$(gh auth token)" +sudo --preserve-env=GH_TOKEN bash ./install.sh +unset GH_TOKEN +``` + +Do not pipe an unauthenticated download into `sudo bash`. See +[release security](docs/releasing.md#initial-trust-is-a-prerequisite-not-something-the-installer-can-create) +for the initial trust requirements. + The `--` separator divides firewall options from the command to run. To inspect the API proxy endpoints and models without running an agent command, diff --git a/containers/agent/entrypoint.sh b/containers/agent/entrypoint.sh index 560d24279..aff5eb34c 100644 --- a/containers/agent/entrypoint.sh +++ b/containers/agent/entrypoint.sh @@ -152,11 +152,11 @@ if [ "${AWF_SSL_BUMP_ENABLED}" = "true" ]; then echo "[entrypoint] CA certificates updated for SSL Bump" echo "[entrypoint] NODE_EXTRA_CA_CERTS set to $NODE_EXTRA_CA_CERTS" echo "[entrypoint] ⚠️ WARNING: HTTPS traffic will be intercepted for URL inspection" + sh /opt/awf/trust/install-system-trust.sh else echo "[entrypoint][WARN] SSL Bump enabled but CA certificate not found" fi fi -sh /opt/awf/trust/install-system-trust.sh } wait_for_iptables() { @@ -1144,8 +1144,9 @@ copy_system_ca_bundle() { /host/etc/pki/ca-trust/extracted/pem /host/etc/pki/tls/certs ) - for candidate in "${directories[@]}" \ - /host/etc/ssl/cert.pem /host/etc/pki/tls/cert.pem; do + for candidate in /host/etc/ssl/certs /host/etc/pki/tls/certs \ + /host/etc/ssl/cert.pem /host/etc/pki/tls/cert.pem \ + /host/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem; do [ ! -e "$candidate" ] || inputs+=("$candidate") done if [ -n "${AWF_CA_CHROOT:-}" ]; then @@ -1164,6 +1165,10 @@ copy_system_ca_bundle() { mkdir -p "$trust" sh /opt/awf/trust/filter-ca-store.sh "$trust" "${inputs[@]}" cp /opt/awf/trust/node-trust.cjs "$trust/node-trust.cjs" + if [ -e /host/etc/ssl/certs/java/cacerts ]; then + mkdir -p "$trust/certs/java" + cp -L /host/etc/ssl/certs/java/cacerts "$trust/certs/java/cacerts" + fi # The /run staging root is container-local; none of these operations writes # through the read-only host /etc bind mounts. for candidate in "${directories[@]}"; do diff --git a/containers/agent/trust/filter-ca-store.sh b/containers/agent/trust/filter-ca-store.sh index c540c8dea..1450d74cb 100644 --- a/containers/agent/trust/filter-ca-store.sh +++ b/containers/agent/trust/filter-ca-store.sh @@ -31,9 +31,18 @@ for input in "$@"; do fi done awk -v dir="$work" ' - /-----BEGIN CERTIFICATE-----/ { if (active) exit 1; active=1; count++; file=dir "/cert-" count ".pem" } - active { print > file } - /-----END CERTIFICATE-----/ { if (!active) exit 1; close(file); active=0 } + /-----BEGIN CERTIFICATE-----/ { if (active) exit 1; active=1; block="" } + active { block=block $0 "\n" } + /-----END CERTIFICATE-----/ { + if (!active) exit 1 + if (!seen[block]++) { + count++ + unique=dir "/cert-" count ".pem" + print block > unique + close(unique) + } + active=0 + } /-----BEGIN TRUSTED CERTIFICATE-----/ { exit 1 } END { if (active || !count) exit 1 } ' "$work/input.pem" || { echo "AWF trust policy: invalid or empty PEM CA store" >&2; exit 1; } diff --git a/containers/agent/trust/install-system-trust.sh b/containers/agent/trust/install-system-trust.sh index da9b3886f..1992fd695 100644 --- a/containers/agent/trust/install-system-trust.sh +++ b/containers/agent/trust/install-system-trust.sh @@ -1,7 +1,7 @@ #!/bin/sh set -eu policy_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -staging=$(mktemp -d /tmp/awf-ca-store.XXXXXX) +staging=$(mktemp -d /run/awf-ca-store.XXXXXX) trap 'rm -rf "$staging"' EXIT HUP INT TERM sh "$policy_dir/filter-ca-store.sh" "$staging" /etc/ssl/certs # This script is for image-local stores only. Host mounts are handled by the diff --git a/containers/agent/trust/node-trust.cjs b/containers/agent/trust/node-trust.cjs index f931da035..6f7b0df93 100644 --- a/containers/agent/trust/node-trust.cjs +++ b/containers/agent/trust/node-trust.cjs @@ -8,7 +8,9 @@ exports.distrustedSpkis = void 0; exports.filterCaCertificates = filterCaCertificates; exports.applyTlsTrustPolicy = applyTlsTrustPolicy; const node_crypto_1 = require("node:crypto"); +const node_child_process_1 = require("node:child_process"); const node_fs_1 = require("node:fs"); +const node_path_1 = __importDefault(require("node:path")); const node_tls_1 = __importDefault(require("node:tls")); // October 2026 eMudhra disclosure: eight compromised keys, followed by the // four issuing/cross-signing roots. Root distrust is an emergency fallback for @@ -29,6 +31,63 @@ exports.distrustedSpkis = new Set([ '8d417db2dd8bf5e3084d1e3f196d583849d81bdd4c00c70b9d39369e96b8c782', ]); const certificateCache = new Map(); +function readSelectedOpenSslCertificates() { + const variables = process.config.variables; + let openSslDir; + if ((!variables.openssl_default_cert_file && process.env.SSL_CERT_FILE === undefined) + || (!variables.openssl_default_cert_dir && process.env.SSL_CERT_DIR === undefined)) { + const output = (0, node_child_process_1.execFileSync)('openssl', ['version', '-d'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + const match = output.match(/^OPENSSLDIR: "([^"]+)"$/m); + if (!match) + throw new Error('Unable to determine OpenSSL default CA paths'); + openSslDir = match[1]; + } + const file = process.env.SSL_CERT_FILE + ?? variables.openssl_default_cert_file + ?? node_path_1.default.join(openSslDir, 'cert.pem'); + const dirList = process.env.SSL_CERT_DIR + ?? variables.openssl_default_cert_dir + ?? node_path_1.default.join(openSslDir, 'certs'); + const dirs = dirList.split(node_path_1.default.delimiter).filter(Boolean); + const certificates = []; + if (file) { + try { + certificates.push((0, node_fs_1.readFileSync)(file, 'utf8')); + } + catch (error) { + if (error.code !== 'ENOENT') + throw error; + } + } + for (const dir of dirs) { + let entries; + try { + entries = (0, node_fs_1.readdirSync)(dir); + } + catch (error) { + if (error.code === 'ENOENT') + continue; + throw error; + } + for (const entry of entries) { + if (!/^[a-f0-9]{8}\.\d+$/i.test(entry)) + continue; + const candidate = node_path_1.default.join(dir, entry); + try { + if ((0, node_fs_1.statSync)(candidate).isFile()) + certificates.push((0, node_fs_1.readFileSync)(candidate, 'utf8')); + } + catch (error) { + if (error.code !== 'ENOENT') + throw error; + } + } + } + return certificates; +} function filterCaCertificates(ca) { const inputs = Array.isArray(ca) ? ca : [ca]; return inputs.flatMap(input => { @@ -55,9 +114,10 @@ let installed = false; function applyTlsTrustPolicy() { if (installed) return; + const usesOpenSslCa = /(?:^|\s)--use-openssl-ca(?:\s|$)/.test(`${process.execArgv.join(' ')} ${process.env.NODE_OPTIONS || ''}`); const defaults = node_tls_1.default.getCACertificates ? [...node_tls_1.default.getCACertificates('default')] - : [...node_tls_1.default.rootCertificates]; + : usesOpenSslCa ? readSelectedOpenSslCertificates() : [...node_tls_1.default.rootCertificates]; // Read explicitly as well: Node otherwise warns and ignores malformed or // missing NODE_EXTRA_CA_CERTS, which must not silently defeat private trust. if (process.env.NODE_EXTRA_CA_CERTS) { @@ -70,11 +130,14 @@ function applyTlsTrustPolicy() { const createSecureContext = node_tls_1.default.createSecureContext; // Also cover explicit custom CA options and Node versions predating the // default-CA API. This affects https, fetch/undici and direct tls.connect. - node_tls_1.default.createSecureContext = (options = {}) => createSecureContext({ - ...options, - ca: options.ca === undefined || options.ca === null - ? filteredDefaults : filterCaCertificates(options.ca), - }); + node_tls_1.default.createSecureContext = (options = {}) => { + const normalizedOptions = options ?? {}; + return createSecureContext({ + ...normalizedOptions, + ca: normalizedOptions.ca + ? filterCaCertificates(normalizedOptions.ca) : filteredDefaults, + }); + }; installed = true; } applyTlsTrustPolicy(); diff --git a/containers/api-proxy/trust/filter-ca-store.sh b/containers/api-proxy/trust/filter-ca-store.sh index c540c8dea..1450d74cb 100644 --- a/containers/api-proxy/trust/filter-ca-store.sh +++ b/containers/api-proxy/trust/filter-ca-store.sh @@ -31,9 +31,18 @@ for input in "$@"; do fi done awk -v dir="$work" ' - /-----BEGIN CERTIFICATE-----/ { if (active) exit 1; active=1; count++; file=dir "/cert-" count ".pem" } - active { print > file } - /-----END CERTIFICATE-----/ { if (!active) exit 1; close(file); active=0 } + /-----BEGIN CERTIFICATE-----/ { if (active) exit 1; active=1; block="" } + active { block=block $0 "\n" } + /-----END CERTIFICATE-----/ { + if (!active) exit 1 + if (!seen[block]++) { + count++ + unique=dir "/cert-" count ".pem" + print block > unique + close(unique) + } + active=0 + } /-----BEGIN TRUSTED CERTIFICATE-----/ { exit 1 } END { if (active || !count) exit 1 } ' "$work/input.pem" || { echo "AWF trust policy: invalid or empty PEM CA store" >&2; exit 1; } diff --git a/containers/api-proxy/trust/install-system-trust.sh b/containers/api-proxy/trust/install-system-trust.sh index da9b3886f..1992fd695 100644 --- a/containers/api-proxy/trust/install-system-trust.sh +++ b/containers/api-proxy/trust/install-system-trust.sh @@ -1,7 +1,7 @@ #!/bin/sh set -eu policy_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -staging=$(mktemp -d /tmp/awf-ca-store.XXXXXX) +staging=$(mktemp -d /run/awf-ca-store.XXXXXX) trap 'rm -rf "$staging"' EXIT HUP INT TERM sh "$policy_dir/filter-ca-store.sh" "$staging" /etc/ssl/certs # This script is for image-local stores only. Host mounts are handled by the diff --git a/containers/api-proxy/trust/node-trust.cjs b/containers/api-proxy/trust/node-trust.cjs index f931da035..6f7b0df93 100644 --- a/containers/api-proxy/trust/node-trust.cjs +++ b/containers/api-proxy/trust/node-trust.cjs @@ -8,7 +8,9 @@ exports.distrustedSpkis = void 0; exports.filterCaCertificates = filterCaCertificates; exports.applyTlsTrustPolicy = applyTlsTrustPolicy; const node_crypto_1 = require("node:crypto"); +const node_child_process_1 = require("node:child_process"); const node_fs_1 = require("node:fs"); +const node_path_1 = __importDefault(require("node:path")); const node_tls_1 = __importDefault(require("node:tls")); // October 2026 eMudhra disclosure: eight compromised keys, followed by the // four issuing/cross-signing roots. Root distrust is an emergency fallback for @@ -29,6 +31,63 @@ exports.distrustedSpkis = new Set([ '8d417db2dd8bf5e3084d1e3f196d583849d81bdd4c00c70b9d39369e96b8c782', ]); const certificateCache = new Map(); +function readSelectedOpenSslCertificates() { + const variables = process.config.variables; + let openSslDir; + if ((!variables.openssl_default_cert_file && process.env.SSL_CERT_FILE === undefined) + || (!variables.openssl_default_cert_dir && process.env.SSL_CERT_DIR === undefined)) { + const output = (0, node_child_process_1.execFileSync)('openssl', ['version', '-d'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + const match = output.match(/^OPENSSLDIR: "([^"]+)"$/m); + if (!match) + throw new Error('Unable to determine OpenSSL default CA paths'); + openSslDir = match[1]; + } + const file = process.env.SSL_CERT_FILE + ?? variables.openssl_default_cert_file + ?? node_path_1.default.join(openSslDir, 'cert.pem'); + const dirList = process.env.SSL_CERT_DIR + ?? variables.openssl_default_cert_dir + ?? node_path_1.default.join(openSslDir, 'certs'); + const dirs = dirList.split(node_path_1.default.delimiter).filter(Boolean); + const certificates = []; + if (file) { + try { + certificates.push((0, node_fs_1.readFileSync)(file, 'utf8')); + } + catch (error) { + if (error.code !== 'ENOENT') + throw error; + } + } + for (const dir of dirs) { + let entries; + try { + entries = (0, node_fs_1.readdirSync)(dir); + } + catch (error) { + if (error.code === 'ENOENT') + continue; + throw error; + } + for (const entry of entries) { + if (!/^[a-f0-9]{8}\.\d+$/i.test(entry)) + continue; + const candidate = node_path_1.default.join(dir, entry); + try { + if ((0, node_fs_1.statSync)(candidate).isFile()) + certificates.push((0, node_fs_1.readFileSync)(candidate, 'utf8')); + } + catch (error) { + if (error.code !== 'ENOENT') + throw error; + } + } + } + return certificates; +} function filterCaCertificates(ca) { const inputs = Array.isArray(ca) ? ca : [ca]; return inputs.flatMap(input => { @@ -55,9 +114,10 @@ let installed = false; function applyTlsTrustPolicy() { if (installed) return; + const usesOpenSslCa = /(?:^|\s)--use-openssl-ca(?:\s|$)/.test(`${process.execArgv.join(' ')} ${process.env.NODE_OPTIONS || ''}`); const defaults = node_tls_1.default.getCACertificates ? [...node_tls_1.default.getCACertificates('default')] - : [...node_tls_1.default.rootCertificates]; + : usesOpenSslCa ? readSelectedOpenSslCertificates() : [...node_tls_1.default.rootCertificates]; // Read explicitly as well: Node otherwise warns and ignores malformed or // missing NODE_EXTRA_CA_CERTS, which must not silently defeat private trust. if (process.env.NODE_EXTRA_CA_CERTS) { @@ -70,11 +130,14 @@ function applyTlsTrustPolicy() { const createSecureContext = node_tls_1.default.createSecureContext; // Also cover explicit custom CA options and Node versions predating the // default-CA API. This affects https, fetch/undici and direct tls.connect. - node_tls_1.default.createSecureContext = (options = {}) => createSecureContext({ - ...options, - ca: options.ca === undefined || options.ca === null - ? filteredDefaults : filterCaCertificates(options.ca), - }); + node_tls_1.default.createSecureContext = (options = {}) => { + const normalizedOptions = options ?? {}; + return createSecureContext({ + ...normalizedOptions, + ca: normalizedOptions.ca + ? filterCaCertificates(normalizedOptions.ca) : filteredDefaults, + }); + }; installed = true; } applyTlsTrustPolicy(); diff --git a/containers/build-tools/trust/filter-ca-store.sh b/containers/build-tools/trust/filter-ca-store.sh index c540c8dea..1450d74cb 100644 --- a/containers/build-tools/trust/filter-ca-store.sh +++ b/containers/build-tools/trust/filter-ca-store.sh @@ -31,9 +31,18 @@ for input in "$@"; do fi done awk -v dir="$work" ' - /-----BEGIN CERTIFICATE-----/ { if (active) exit 1; active=1; count++; file=dir "/cert-" count ".pem" } - active { print > file } - /-----END CERTIFICATE-----/ { if (!active) exit 1; close(file); active=0 } + /-----BEGIN CERTIFICATE-----/ { if (active) exit 1; active=1; block="" } + active { block=block $0 "\n" } + /-----END CERTIFICATE-----/ { + if (!active) exit 1 + if (!seen[block]++) { + count++ + unique=dir "/cert-" count ".pem" + print block > unique + close(unique) + } + active=0 + } /-----BEGIN TRUSTED CERTIFICATE-----/ { exit 1 } END { if (active || !count) exit 1 } ' "$work/input.pem" || { echo "AWF trust policy: invalid or empty PEM CA store" >&2; exit 1; } diff --git a/containers/build-tools/trust/install-system-trust.sh b/containers/build-tools/trust/install-system-trust.sh index da9b3886f..1992fd695 100644 --- a/containers/build-tools/trust/install-system-trust.sh +++ b/containers/build-tools/trust/install-system-trust.sh @@ -1,7 +1,7 @@ #!/bin/sh set -eu policy_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -staging=$(mktemp -d /tmp/awf-ca-store.XXXXXX) +staging=$(mktemp -d /run/awf-ca-store.XXXXXX) trap 'rm -rf "$staging"' EXIT HUP INT TERM sh "$policy_dir/filter-ca-store.sh" "$staging" /etc/ssl/certs # This script is for image-local stores only. Host mounts are handled by the diff --git a/containers/cli-proxy/trust/filter-ca-store.sh b/containers/cli-proxy/trust/filter-ca-store.sh index c540c8dea..1450d74cb 100644 --- a/containers/cli-proxy/trust/filter-ca-store.sh +++ b/containers/cli-proxy/trust/filter-ca-store.sh @@ -31,9 +31,18 @@ for input in "$@"; do fi done awk -v dir="$work" ' - /-----BEGIN CERTIFICATE-----/ { if (active) exit 1; active=1; count++; file=dir "/cert-" count ".pem" } - active { print > file } - /-----END CERTIFICATE-----/ { if (!active) exit 1; close(file); active=0 } + /-----BEGIN CERTIFICATE-----/ { if (active) exit 1; active=1; block="" } + active { block=block $0 "\n" } + /-----END CERTIFICATE-----/ { + if (!active) exit 1 + if (!seen[block]++) { + count++ + unique=dir "/cert-" count ".pem" + print block > unique + close(unique) + } + active=0 + } /-----BEGIN TRUSTED CERTIFICATE-----/ { exit 1 } END { if (active || !count) exit 1 } ' "$work/input.pem" || { echo "AWF trust policy: invalid or empty PEM CA store" >&2; exit 1; } diff --git a/containers/cli-proxy/trust/install-system-trust.sh b/containers/cli-proxy/trust/install-system-trust.sh index da9b3886f..1992fd695 100644 --- a/containers/cli-proxy/trust/install-system-trust.sh +++ b/containers/cli-proxy/trust/install-system-trust.sh @@ -1,7 +1,7 @@ #!/bin/sh set -eu policy_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -staging=$(mktemp -d /tmp/awf-ca-store.XXXXXX) +staging=$(mktemp -d /run/awf-ca-store.XXXXXX) trap 'rm -rf "$staging"' EXIT HUP INT TERM sh "$policy_dir/filter-ca-store.sh" "$staging" /etc/ssl/certs # This script is for image-local stores only. Host mounts are handled by the diff --git a/containers/cli-proxy/trust/node-trust.cjs b/containers/cli-proxy/trust/node-trust.cjs index f931da035..6f7b0df93 100644 --- a/containers/cli-proxy/trust/node-trust.cjs +++ b/containers/cli-proxy/trust/node-trust.cjs @@ -8,7 +8,9 @@ exports.distrustedSpkis = void 0; exports.filterCaCertificates = filterCaCertificates; exports.applyTlsTrustPolicy = applyTlsTrustPolicy; const node_crypto_1 = require("node:crypto"); +const node_child_process_1 = require("node:child_process"); const node_fs_1 = require("node:fs"); +const node_path_1 = __importDefault(require("node:path")); const node_tls_1 = __importDefault(require("node:tls")); // October 2026 eMudhra disclosure: eight compromised keys, followed by the // four issuing/cross-signing roots. Root distrust is an emergency fallback for @@ -29,6 +31,63 @@ exports.distrustedSpkis = new Set([ '8d417db2dd8bf5e3084d1e3f196d583849d81bdd4c00c70b9d39369e96b8c782', ]); const certificateCache = new Map(); +function readSelectedOpenSslCertificates() { + const variables = process.config.variables; + let openSslDir; + if ((!variables.openssl_default_cert_file && process.env.SSL_CERT_FILE === undefined) + || (!variables.openssl_default_cert_dir && process.env.SSL_CERT_DIR === undefined)) { + const output = (0, node_child_process_1.execFileSync)('openssl', ['version', '-d'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + const match = output.match(/^OPENSSLDIR: "([^"]+)"$/m); + if (!match) + throw new Error('Unable to determine OpenSSL default CA paths'); + openSslDir = match[1]; + } + const file = process.env.SSL_CERT_FILE + ?? variables.openssl_default_cert_file + ?? node_path_1.default.join(openSslDir, 'cert.pem'); + const dirList = process.env.SSL_CERT_DIR + ?? variables.openssl_default_cert_dir + ?? node_path_1.default.join(openSslDir, 'certs'); + const dirs = dirList.split(node_path_1.default.delimiter).filter(Boolean); + const certificates = []; + if (file) { + try { + certificates.push((0, node_fs_1.readFileSync)(file, 'utf8')); + } + catch (error) { + if (error.code !== 'ENOENT') + throw error; + } + } + for (const dir of dirs) { + let entries; + try { + entries = (0, node_fs_1.readdirSync)(dir); + } + catch (error) { + if (error.code === 'ENOENT') + continue; + throw error; + } + for (const entry of entries) { + if (!/^[a-f0-9]{8}\.\d+$/i.test(entry)) + continue; + const candidate = node_path_1.default.join(dir, entry); + try { + if ((0, node_fs_1.statSync)(candidate).isFile()) + certificates.push((0, node_fs_1.readFileSync)(candidate, 'utf8')); + } + catch (error) { + if (error.code !== 'ENOENT') + throw error; + } + } + } + return certificates; +} function filterCaCertificates(ca) { const inputs = Array.isArray(ca) ? ca : [ca]; return inputs.flatMap(input => { @@ -55,9 +114,10 @@ let installed = false; function applyTlsTrustPolicy() { if (installed) return; + const usesOpenSslCa = /(?:^|\s)--use-openssl-ca(?:\s|$)/.test(`${process.execArgv.join(' ')} ${process.env.NODE_OPTIONS || ''}`); const defaults = node_tls_1.default.getCACertificates ? [...node_tls_1.default.getCACertificates('default')] - : [...node_tls_1.default.rootCertificates]; + : usesOpenSslCa ? readSelectedOpenSslCertificates() : [...node_tls_1.default.rootCertificates]; // Read explicitly as well: Node otherwise warns and ignores malformed or // missing NODE_EXTRA_CA_CERTS, which must not silently defeat private trust. if (process.env.NODE_EXTRA_CA_CERTS) { @@ -70,11 +130,14 @@ function applyTlsTrustPolicy() { const createSecureContext = node_tls_1.default.createSecureContext; // Also cover explicit custom CA options and Node versions predating the // default-CA API. This affects https, fetch/undici and direct tls.connect. - node_tls_1.default.createSecureContext = (options = {}) => createSecureContext({ - ...options, - ca: options.ca === undefined || options.ca === null - ? filteredDefaults : filterCaCertificates(options.ca), - }); + node_tls_1.default.createSecureContext = (options = {}) => { + const normalizedOptions = options ?? {}; + return createSecureContext({ + ...normalizedOptions, + ca: normalizedOptions.ca + ? filterCaCertificates(normalizedOptions.ca) : filteredDefaults, + }); + }; installed = true; } applyTlsTrustPolicy(); diff --git a/containers/dind-ubuntu/trust/filter-ca-store.sh b/containers/dind-ubuntu/trust/filter-ca-store.sh index c540c8dea..1450d74cb 100644 --- a/containers/dind-ubuntu/trust/filter-ca-store.sh +++ b/containers/dind-ubuntu/trust/filter-ca-store.sh @@ -31,9 +31,18 @@ for input in "$@"; do fi done awk -v dir="$work" ' - /-----BEGIN CERTIFICATE-----/ { if (active) exit 1; active=1; count++; file=dir "/cert-" count ".pem" } - active { print > file } - /-----END CERTIFICATE-----/ { if (!active) exit 1; close(file); active=0 } + /-----BEGIN CERTIFICATE-----/ { if (active) exit 1; active=1; block="" } + active { block=block $0 "\n" } + /-----END CERTIFICATE-----/ { + if (!active) exit 1 + if (!seen[block]++) { + count++ + unique=dir "/cert-" count ".pem" + print block > unique + close(unique) + } + active=0 + } /-----BEGIN TRUSTED CERTIFICATE-----/ { exit 1 } END { if (active || !count) exit 1 } ' "$work/input.pem" || { echo "AWF trust policy: invalid or empty PEM CA store" >&2; exit 1; } diff --git a/containers/dind-ubuntu/trust/install-system-trust.sh b/containers/dind-ubuntu/trust/install-system-trust.sh index da9b3886f..1992fd695 100644 --- a/containers/dind-ubuntu/trust/install-system-trust.sh +++ b/containers/dind-ubuntu/trust/install-system-trust.sh @@ -1,7 +1,7 @@ #!/bin/sh set -eu policy_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -staging=$(mktemp -d /tmp/awf-ca-store.XXXXXX) +staging=$(mktemp -d /run/awf-ca-store.XXXXXX) trap 'rm -rf "$staging"' EXIT HUP INT TERM sh "$policy_dir/filter-ca-store.sh" "$staging" /etc/ssl/certs # This script is for image-local stores only. Host mounts are handled by the diff --git a/containers/dind-ubuntu/trust/node-trust.cjs b/containers/dind-ubuntu/trust/node-trust.cjs index f931da035..6f7b0df93 100644 --- a/containers/dind-ubuntu/trust/node-trust.cjs +++ b/containers/dind-ubuntu/trust/node-trust.cjs @@ -8,7 +8,9 @@ exports.distrustedSpkis = void 0; exports.filterCaCertificates = filterCaCertificates; exports.applyTlsTrustPolicy = applyTlsTrustPolicy; const node_crypto_1 = require("node:crypto"); +const node_child_process_1 = require("node:child_process"); const node_fs_1 = require("node:fs"); +const node_path_1 = __importDefault(require("node:path")); const node_tls_1 = __importDefault(require("node:tls")); // October 2026 eMudhra disclosure: eight compromised keys, followed by the // four issuing/cross-signing roots. Root distrust is an emergency fallback for @@ -29,6 +31,63 @@ exports.distrustedSpkis = new Set([ '8d417db2dd8bf5e3084d1e3f196d583849d81bdd4c00c70b9d39369e96b8c782', ]); const certificateCache = new Map(); +function readSelectedOpenSslCertificates() { + const variables = process.config.variables; + let openSslDir; + if ((!variables.openssl_default_cert_file && process.env.SSL_CERT_FILE === undefined) + || (!variables.openssl_default_cert_dir && process.env.SSL_CERT_DIR === undefined)) { + const output = (0, node_child_process_1.execFileSync)('openssl', ['version', '-d'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + const match = output.match(/^OPENSSLDIR: "([^"]+)"$/m); + if (!match) + throw new Error('Unable to determine OpenSSL default CA paths'); + openSslDir = match[1]; + } + const file = process.env.SSL_CERT_FILE + ?? variables.openssl_default_cert_file + ?? node_path_1.default.join(openSslDir, 'cert.pem'); + const dirList = process.env.SSL_CERT_DIR + ?? variables.openssl_default_cert_dir + ?? node_path_1.default.join(openSslDir, 'certs'); + const dirs = dirList.split(node_path_1.default.delimiter).filter(Boolean); + const certificates = []; + if (file) { + try { + certificates.push((0, node_fs_1.readFileSync)(file, 'utf8')); + } + catch (error) { + if (error.code !== 'ENOENT') + throw error; + } + } + for (const dir of dirs) { + let entries; + try { + entries = (0, node_fs_1.readdirSync)(dir); + } + catch (error) { + if (error.code === 'ENOENT') + continue; + throw error; + } + for (const entry of entries) { + if (!/^[a-f0-9]{8}\.\d+$/i.test(entry)) + continue; + const candidate = node_path_1.default.join(dir, entry); + try { + if ((0, node_fs_1.statSync)(candidate).isFile()) + certificates.push((0, node_fs_1.readFileSync)(candidate, 'utf8')); + } + catch (error) { + if (error.code !== 'ENOENT') + throw error; + } + } + } + return certificates; +} function filterCaCertificates(ca) { const inputs = Array.isArray(ca) ? ca : [ca]; return inputs.flatMap(input => { @@ -55,9 +114,10 @@ let installed = false; function applyTlsTrustPolicy() { if (installed) return; + const usesOpenSslCa = /(?:^|\s)--use-openssl-ca(?:\s|$)/.test(`${process.execArgv.join(' ')} ${process.env.NODE_OPTIONS || ''}`); const defaults = node_tls_1.default.getCACertificates ? [...node_tls_1.default.getCACertificates('default')] - : [...node_tls_1.default.rootCertificates]; + : usesOpenSslCa ? readSelectedOpenSslCertificates() : [...node_tls_1.default.rootCertificates]; // Read explicitly as well: Node otherwise warns and ignores malformed or // missing NODE_EXTRA_CA_CERTS, which must not silently defeat private trust. if (process.env.NODE_EXTRA_CA_CERTS) { @@ -70,11 +130,14 @@ function applyTlsTrustPolicy() { const createSecureContext = node_tls_1.default.createSecureContext; // Also cover explicit custom CA options and Node versions predating the // default-CA API. This affects https, fetch/undici and direct tls.connect. - node_tls_1.default.createSecureContext = (options = {}) => createSecureContext({ - ...options, - ca: options.ca === undefined || options.ca === null - ? filteredDefaults : filterCaCertificates(options.ca), - }); + node_tls_1.default.createSecureContext = (options = {}) => { + const normalizedOptions = options ?? {}; + return createSecureContext({ + ...normalizedOptions, + ca: normalizedOptions.ca + ? filterCaCertificates(normalizedOptions.ca) : filteredDefaults, + }); + }; installed = true; } applyTlsTrustPolicy(); diff --git a/containers/enclave/Dockerfile b/containers/enclave/Dockerfile index 15bca5773..54054d043 100644 --- a/containers/enclave/Dockerfile +++ b/containers/enclave/Dockerfile @@ -74,16 +74,15 @@ FROM node:26.11.1-alpine3.24@sha256:143494b1da2945f061539253adc65e4f1569ddf07da2 COPY trust/ /opt/awf/trust/ RUN apk add --no-cache openssl ca-certificates \ - && sh /opt/awf/trust/install-system-trust.sh \ - && sh /opt/awf/trust/install-node-trust.sh - -ARG AWF_SOURCE_REVISION=unavailable - -RUN apk upgrade --no-cache \ + && apk upgrade --no-cache \ && apk add --no-cache docker-cli \ + && sh /opt/awf/trust/install-system-trust.sh \ + && sh /opt/awf/trust/install-node-trust.sh \ && test -x /usr/bin/docker \ && rm -rf /usr/local/lib/node_modules/npm \ && rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack + +ARG AWF_SOURCE_REVISION=unavailable WORKDIR /opt/awf/enclave/mcp-server LABEL org.opencontainers.image.revision="${AWF_SOURCE_REVISION}" COPY bounded-execution/ /opt/awf/bounded-execution/ diff --git a/containers/gh-aw-node/trust/filter-ca-store.sh b/containers/gh-aw-node/trust/filter-ca-store.sh index c540c8dea..1450d74cb 100644 --- a/containers/gh-aw-node/trust/filter-ca-store.sh +++ b/containers/gh-aw-node/trust/filter-ca-store.sh @@ -31,9 +31,18 @@ for input in "$@"; do fi done awk -v dir="$work" ' - /-----BEGIN CERTIFICATE-----/ { if (active) exit 1; active=1; count++; file=dir "/cert-" count ".pem" } - active { print > file } - /-----END CERTIFICATE-----/ { if (!active) exit 1; close(file); active=0 } + /-----BEGIN CERTIFICATE-----/ { if (active) exit 1; active=1; block="" } + active { block=block $0 "\n" } + /-----END CERTIFICATE-----/ { + if (!active) exit 1 + if (!seen[block]++) { + count++ + unique=dir "/cert-" count ".pem" + print block > unique + close(unique) + } + active=0 + } /-----BEGIN TRUSTED CERTIFICATE-----/ { exit 1 } END { if (active || !count) exit 1 } ' "$work/input.pem" || { echo "AWF trust policy: invalid or empty PEM CA store" >&2; exit 1; } diff --git a/containers/gh-aw-node/trust/install-system-trust.sh b/containers/gh-aw-node/trust/install-system-trust.sh index da9b3886f..1992fd695 100644 --- a/containers/gh-aw-node/trust/install-system-trust.sh +++ b/containers/gh-aw-node/trust/install-system-trust.sh @@ -1,7 +1,7 @@ #!/bin/sh set -eu policy_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -staging=$(mktemp -d /tmp/awf-ca-store.XXXXXX) +staging=$(mktemp -d /run/awf-ca-store.XXXXXX) trap 'rm -rf "$staging"' EXIT HUP INT TERM sh "$policy_dir/filter-ca-store.sh" "$staging" /etc/ssl/certs # This script is for image-local stores only. Host mounts are handled by the diff --git a/containers/gh-aw-node/trust/node-trust.cjs b/containers/gh-aw-node/trust/node-trust.cjs index f931da035..6f7b0df93 100644 --- a/containers/gh-aw-node/trust/node-trust.cjs +++ b/containers/gh-aw-node/trust/node-trust.cjs @@ -8,7 +8,9 @@ exports.distrustedSpkis = void 0; exports.filterCaCertificates = filterCaCertificates; exports.applyTlsTrustPolicy = applyTlsTrustPolicy; const node_crypto_1 = require("node:crypto"); +const node_child_process_1 = require("node:child_process"); const node_fs_1 = require("node:fs"); +const node_path_1 = __importDefault(require("node:path")); const node_tls_1 = __importDefault(require("node:tls")); // October 2026 eMudhra disclosure: eight compromised keys, followed by the // four issuing/cross-signing roots. Root distrust is an emergency fallback for @@ -29,6 +31,63 @@ exports.distrustedSpkis = new Set([ '8d417db2dd8bf5e3084d1e3f196d583849d81bdd4c00c70b9d39369e96b8c782', ]); const certificateCache = new Map(); +function readSelectedOpenSslCertificates() { + const variables = process.config.variables; + let openSslDir; + if ((!variables.openssl_default_cert_file && process.env.SSL_CERT_FILE === undefined) + || (!variables.openssl_default_cert_dir && process.env.SSL_CERT_DIR === undefined)) { + const output = (0, node_child_process_1.execFileSync)('openssl', ['version', '-d'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + const match = output.match(/^OPENSSLDIR: "([^"]+)"$/m); + if (!match) + throw new Error('Unable to determine OpenSSL default CA paths'); + openSslDir = match[1]; + } + const file = process.env.SSL_CERT_FILE + ?? variables.openssl_default_cert_file + ?? node_path_1.default.join(openSslDir, 'cert.pem'); + const dirList = process.env.SSL_CERT_DIR + ?? variables.openssl_default_cert_dir + ?? node_path_1.default.join(openSslDir, 'certs'); + const dirs = dirList.split(node_path_1.default.delimiter).filter(Boolean); + const certificates = []; + if (file) { + try { + certificates.push((0, node_fs_1.readFileSync)(file, 'utf8')); + } + catch (error) { + if (error.code !== 'ENOENT') + throw error; + } + } + for (const dir of dirs) { + let entries; + try { + entries = (0, node_fs_1.readdirSync)(dir); + } + catch (error) { + if (error.code === 'ENOENT') + continue; + throw error; + } + for (const entry of entries) { + if (!/^[a-f0-9]{8}\.\d+$/i.test(entry)) + continue; + const candidate = node_path_1.default.join(dir, entry); + try { + if ((0, node_fs_1.statSync)(candidate).isFile()) + certificates.push((0, node_fs_1.readFileSync)(candidate, 'utf8')); + } + catch (error) { + if (error.code !== 'ENOENT') + throw error; + } + } + } + return certificates; +} function filterCaCertificates(ca) { const inputs = Array.isArray(ca) ? ca : [ca]; return inputs.flatMap(input => { @@ -55,9 +114,10 @@ let installed = false; function applyTlsTrustPolicy() { if (installed) return; + const usesOpenSslCa = /(?:^|\s)--use-openssl-ca(?:\s|$)/.test(`${process.execArgv.join(' ')} ${process.env.NODE_OPTIONS || ''}`); const defaults = node_tls_1.default.getCACertificates ? [...node_tls_1.default.getCACertificates('default')] - : [...node_tls_1.default.rootCertificates]; + : usesOpenSslCa ? readSelectedOpenSslCertificates() : [...node_tls_1.default.rootCertificates]; // Read explicitly as well: Node otherwise warns and ignores malformed or // missing NODE_EXTRA_CA_CERTS, which must not silently defeat private trust. if (process.env.NODE_EXTRA_CA_CERTS) { @@ -70,11 +130,14 @@ function applyTlsTrustPolicy() { const createSecureContext = node_tls_1.default.createSecureContext; // Also cover explicit custom CA options and Node versions predating the // default-CA API. This affects https, fetch/undici and direct tls.connect. - node_tls_1.default.createSecureContext = (options = {}) => createSecureContext({ - ...options, - ca: options.ca === undefined || options.ca === null - ? filteredDefaults : filterCaCertificates(options.ca), - }); + node_tls_1.default.createSecureContext = (options = {}) => { + const normalizedOptions = options ?? {}; + return createSecureContext({ + ...normalizedOptions, + ca: normalizedOptions.ca + ? filterCaCertificates(normalizedOptions.ca) : filteredDefaults, + }); + }; installed = true; } applyTlsTrustPolicy(); diff --git a/containers/router/trust/filter-ca-store.sh b/containers/router/trust/filter-ca-store.sh index c540c8dea..1450d74cb 100644 --- a/containers/router/trust/filter-ca-store.sh +++ b/containers/router/trust/filter-ca-store.sh @@ -31,9 +31,18 @@ for input in "$@"; do fi done awk -v dir="$work" ' - /-----BEGIN CERTIFICATE-----/ { if (active) exit 1; active=1; count++; file=dir "/cert-" count ".pem" } - active { print > file } - /-----END CERTIFICATE-----/ { if (!active) exit 1; close(file); active=0 } + /-----BEGIN CERTIFICATE-----/ { if (active) exit 1; active=1; block="" } + active { block=block $0 "\n" } + /-----END CERTIFICATE-----/ { + if (!active) exit 1 + if (!seen[block]++) { + count++ + unique=dir "/cert-" count ".pem" + print block > unique + close(unique) + } + active=0 + } /-----BEGIN TRUSTED CERTIFICATE-----/ { exit 1 } END { if (active || !count) exit 1 } ' "$work/input.pem" || { echo "AWF trust policy: invalid or empty PEM CA store" >&2; exit 1; } diff --git a/containers/router/trust/install-system-trust.sh b/containers/router/trust/install-system-trust.sh index da9b3886f..1992fd695 100644 --- a/containers/router/trust/install-system-trust.sh +++ b/containers/router/trust/install-system-trust.sh @@ -1,7 +1,7 @@ #!/bin/sh set -eu policy_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -staging=$(mktemp -d /tmp/awf-ca-store.XXXXXX) +staging=$(mktemp -d /run/awf-ca-store.XXXXXX) trap 'rm -rf "$staging"' EXIT HUP INT TERM sh "$policy_dir/filter-ca-store.sh" "$staging" /etc/ssl/certs # This script is for image-local stores only. Host mounts are handled by the diff --git a/containers/squid/trust/filter-ca-store.sh b/containers/squid/trust/filter-ca-store.sh index c540c8dea..1450d74cb 100644 --- a/containers/squid/trust/filter-ca-store.sh +++ b/containers/squid/trust/filter-ca-store.sh @@ -31,9 +31,18 @@ for input in "$@"; do fi done awk -v dir="$work" ' - /-----BEGIN CERTIFICATE-----/ { if (active) exit 1; active=1; count++; file=dir "/cert-" count ".pem" } - active { print > file } - /-----END CERTIFICATE-----/ { if (!active) exit 1; close(file); active=0 } + /-----BEGIN CERTIFICATE-----/ { if (active) exit 1; active=1; block="" } + active { block=block $0 "\n" } + /-----END CERTIFICATE-----/ { + if (!active) exit 1 + if (!seen[block]++) { + count++ + unique=dir "/cert-" count ".pem" + print block > unique + close(unique) + } + active=0 + } /-----BEGIN TRUSTED CERTIFICATE-----/ { exit 1 } END { if (active || !count) exit 1 } ' "$work/input.pem" || { echo "AWF trust policy: invalid or empty PEM CA store" >&2; exit 1; } diff --git a/containers/squid/trust/install-system-trust.sh b/containers/squid/trust/install-system-trust.sh index da9b3886f..1992fd695 100644 --- a/containers/squid/trust/install-system-trust.sh +++ b/containers/squid/trust/install-system-trust.sh @@ -1,7 +1,7 @@ #!/bin/sh set -eu policy_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -staging=$(mktemp -d /tmp/awf-ca-store.XXXXXX) +staging=$(mktemp -d /run/awf-ca-store.XXXXXX) trap 'rm -rf "$staging"' EXIT HUP INT TERM sh "$policy_dir/filter-ca-store.sh" "$staging" /etc/ssl/certs # This script is for image-local stores only. Host mounts are handled by the diff --git a/containers/trust/filter-ca-store.sh b/containers/trust/filter-ca-store.sh index c540c8dea..1450d74cb 100644 --- a/containers/trust/filter-ca-store.sh +++ b/containers/trust/filter-ca-store.sh @@ -31,9 +31,18 @@ for input in "$@"; do fi done awk -v dir="$work" ' - /-----BEGIN CERTIFICATE-----/ { if (active) exit 1; active=1; count++; file=dir "/cert-" count ".pem" } - active { print > file } - /-----END CERTIFICATE-----/ { if (!active) exit 1; close(file); active=0 } + /-----BEGIN CERTIFICATE-----/ { if (active) exit 1; active=1; block="" } + active { block=block $0 "\n" } + /-----END CERTIFICATE-----/ { + if (!active) exit 1 + if (!seen[block]++) { + count++ + unique=dir "/cert-" count ".pem" + print block > unique + close(unique) + } + active=0 + } /-----BEGIN TRUSTED CERTIFICATE-----/ { exit 1 } END { if (active || !count) exit 1 } ' "$work/input.pem" || { echo "AWF trust policy: invalid or empty PEM CA store" >&2; exit 1; } diff --git a/containers/trust/install-system-trust.sh b/containers/trust/install-system-trust.sh index da9b3886f..1992fd695 100644 --- a/containers/trust/install-system-trust.sh +++ b/containers/trust/install-system-trust.sh @@ -1,7 +1,7 @@ #!/bin/sh set -eu policy_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -staging=$(mktemp -d /tmp/awf-ca-store.XXXXXX) +staging=$(mktemp -d /run/awf-ca-store.XXXXXX) trap 'rm -rf "$staging"' EXIT HUP INT TERM sh "$policy_dir/filter-ca-store.sh" "$staging" /etc/ssl/certs # This script is for image-local stores only. Host mounts are handled by the diff --git a/containers/trust/node-trust.cjs b/containers/trust/node-trust.cjs index f931da035..6f7b0df93 100644 --- a/containers/trust/node-trust.cjs +++ b/containers/trust/node-trust.cjs @@ -8,7 +8,9 @@ exports.distrustedSpkis = void 0; exports.filterCaCertificates = filterCaCertificates; exports.applyTlsTrustPolicy = applyTlsTrustPolicy; const node_crypto_1 = require("node:crypto"); +const node_child_process_1 = require("node:child_process"); const node_fs_1 = require("node:fs"); +const node_path_1 = __importDefault(require("node:path")); const node_tls_1 = __importDefault(require("node:tls")); // October 2026 eMudhra disclosure: eight compromised keys, followed by the // four issuing/cross-signing roots. Root distrust is an emergency fallback for @@ -29,6 +31,63 @@ exports.distrustedSpkis = new Set([ '8d417db2dd8bf5e3084d1e3f196d583849d81bdd4c00c70b9d39369e96b8c782', ]); const certificateCache = new Map(); +function readSelectedOpenSslCertificates() { + const variables = process.config.variables; + let openSslDir; + if ((!variables.openssl_default_cert_file && process.env.SSL_CERT_FILE === undefined) + || (!variables.openssl_default_cert_dir && process.env.SSL_CERT_DIR === undefined)) { + const output = (0, node_child_process_1.execFileSync)('openssl', ['version', '-d'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + const match = output.match(/^OPENSSLDIR: "([^"]+)"$/m); + if (!match) + throw new Error('Unable to determine OpenSSL default CA paths'); + openSslDir = match[1]; + } + const file = process.env.SSL_CERT_FILE + ?? variables.openssl_default_cert_file + ?? node_path_1.default.join(openSslDir, 'cert.pem'); + const dirList = process.env.SSL_CERT_DIR + ?? variables.openssl_default_cert_dir + ?? node_path_1.default.join(openSslDir, 'certs'); + const dirs = dirList.split(node_path_1.default.delimiter).filter(Boolean); + const certificates = []; + if (file) { + try { + certificates.push((0, node_fs_1.readFileSync)(file, 'utf8')); + } + catch (error) { + if (error.code !== 'ENOENT') + throw error; + } + } + for (const dir of dirs) { + let entries; + try { + entries = (0, node_fs_1.readdirSync)(dir); + } + catch (error) { + if (error.code === 'ENOENT') + continue; + throw error; + } + for (const entry of entries) { + if (!/^[a-f0-9]{8}\.\d+$/i.test(entry)) + continue; + const candidate = node_path_1.default.join(dir, entry); + try { + if ((0, node_fs_1.statSync)(candidate).isFile()) + certificates.push((0, node_fs_1.readFileSync)(candidate, 'utf8')); + } + catch (error) { + if (error.code !== 'ENOENT') + throw error; + } + } + } + return certificates; +} function filterCaCertificates(ca) { const inputs = Array.isArray(ca) ? ca : [ca]; return inputs.flatMap(input => { @@ -55,9 +114,10 @@ let installed = false; function applyTlsTrustPolicy() { if (installed) return; + const usesOpenSslCa = /(?:^|\s)--use-openssl-ca(?:\s|$)/.test(`${process.execArgv.join(' ')} ${process.env.NODE_OPTIONS || ''}`); const defaults = node_tls_1.default.getCACertificates ? [...node_tls_1.default.getCACertificates('default')] - : [...node_tls_1.default.rootCertificates]; + : usesOpenSslCa ? readSelectedOpenSslCertificates() : [...node_tls_1.default.rootCertificates]; // Read explicitly as well: Node otherwise warns and ignores malformed or // missing NODE_EXTRA_CA_CERTS, which must not silently defeat private trust. if (process.env.NODE_EXTRA_CA_CERTS) { @@ -70,11 +130,14 @@ function applyTlsTrustPolicy() { const createSecureContext = node_tls_1.default.createSecureContext; // Also cover explicit custom CA options and Node versions predating the // default-CA API. This affects https, fetch/undici and direct tls.connect. - node_tls_1.default.createSecureContext = (options = {}) => createSecureContext({ - ...options, - ca: options.ca === undefined || options.ca === null - ? filteredDefaults : filterCaCertificates(options.ca), - }); + node_tls_1.default.createSecureContext = (options = {}) => { + const normalizedOptions = options ?? {}; + return createSecureContext({ + ...normalizedOptions, + ca: normalizedOptions.ca + ? filterCaCertificates(normalizedOptions.ca) : filteredDefaults, + }); + }; installed = true; } applyTlsTrustPolicy(); diff --git a/docs-site/src/content/docs/index.md b/docs-site/src/content/docs/index.md index ef621fe0c..938ea098d 100644 --- a/docs-site/src/content/docs/index.md +++ b/docs-site/src/content/docs/index.md @@ -38,25 +38,27 @@ When AI agents like GitHub Copilot CLI run with access to tools and MCP servers, ### Installation -Download the latest release binary: +For CI, use the setup action pinned to a reviewed commit. For local use, obtain +and review `install.sh` through an independently authenticated channel, and +authenticate a trusted `gh` CLI before running it: ```bash -# One-line installer with SHA verification (recommended) -curl -sSL https://raw.githubusercontent.com/github/gh-aw-firewall/main/install.sh | sudo bash - -# Or manual installation -curl -fL https://github.com/github/gh-aw-firewall/releases/latest/download/awf-linux-x64 -o awf -curl -fL https://github.com/github/gh-aw-firewall/releases/latest/download/checksums.txt -o checksums.txt -sha256sum -c checksums.txt --ignore-missing -chmod +x awf -sudo mv awf /usr/local/bin/ +gh auth status +export GH_TOKEN="$(gh auth token)" +sudo --preserve-env=GH_TOKEN bash ./install.sh +unset GH_TOKEN # Verify installation sudo awf --version ``` -:::tip[Automatic SHA Verification] -The one-line installer automatically verifies the SHA256 checksum to protect against corrupted or tampered downloads. +`sudo` normally changes `HOME` and drops credentials, so preserve the token +explicitly as shown. Do not pipe an unauthenticated download into `sudo bash`. +See the [release security requirements](/reference/security-architecture/). + +:::tip[CI setup] +Prefer the setup action for CI, pinned to a reviewed commit. It authenticates +the release assets before installing them. ::: ### Your First Command diff --git a/docs/RELEASE_TEMPLATE.md b/docs/RELEASE_TEMPLATE.md index 0808d7abd..8dc6cb43f 100644 --- a/docs/RELEASE_TEMPLATE.md +++ b/docs/RELEASE_TEMPLATE.md @@ -29,17 +29,25 @@ Everything below the `---` separator becomes the release notes. ## Installation -### One-Line Installer (Recommended) +### Installer + +The installer verifies GitHub release provenance and requires a trusted, +authenticated `gh` CLI. Obtain and review `install.sh` through an independently +authenticated channel before running it; never pipe an unauthenticated download +into a privileged shell. After authenticating `gh`, explicitly preserve its +token across `sudo`: -**Linux and macOS (x64 and ARM64) with automatic SHA verification:** ```bash -curl -sSL https://raw.githubusercontent.com/{{REPOSITORY}}/main/install.sh | sudo bash +gh auth status +export GH_TOKEN="$(gh auth token)" +sudo --preserve-env=GH_TOKEN bash ./install.sh {{VERSION}} +unset GH_TOKEN ``` This installer: - Automatically detects your OS (Linux or macOS) and architecture (x86_64/aarch64/arm64) -- Downloads the correct release binary -- Verifies SHA256 checksum against `checksums.txt` +- Verifies the signed release identity and GitHub artifact attestations before installation +- Verifies the release checksum and downloaded payload - Validates the file is a valid executable (ELF on Linux, Mach-O on macOS) - Installs to `/usr/local/bin/awf` diff --git a/docs/enterprise-configuration.md b/docs/enterprise-configuration.md index 2aebd7d2a..b5b8060e3 100644 --- a/docs/enterprise-configuration.md +++ b/docs/enterprise-configuration.md @@ -71,7 +71,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Setup awf - uses: github/gh-aw-firewall@main + uses: github/gh-aw-firewall@ - name: Run Copilot with GHEC env: @@ -198,7 +198,7 @@ jobs: runs-on: self-hosted # GHES typically uses self-hosted runners steps: - name: Setup awf - uses: github/gh-aw-firewall@main + uses: github/gh-aw-firewall@ - name: Run Copilot with GHES env: diff --git a/docs/github_actions.md b/docs/github_actions.md index d9106b323..b91bba46b 100644 --- a/docs/github_actions.md +++ b/docs/github_actions.md @@ -4,12 +4,14 @@ ### Using the Setup Action (Recommended) -The simplest way to install awf in GitHub Actions is using the setup action: +The setup action authenticates release assets with GitHub artifact attestations +using the workflow token. Pin it to a reviewed full commit SHA rather than a +mutable branch or tag: ```yaml steps: - name: Setup awf - uses: github/gh-aw-firewall@main + uses: github/gh-aw-firewall@ # with: # version: 'v1.0.0' # Optional: defaults to latest # pull-images: 'true' # Optional: pre-pull Docker images @@ -19,8 +21,8 @@ steps: ``` The action: -- Downloads the specified version (or latest) from GitHub releases -- Verifies SHA256 checksum +- Verifies the signed release identity and GitHub artifact attestations before installation +- Verifies checksums and downloaded payloads - Installs to PATH for subsequent steps - Optionally pre-pulls Docker images for the installed version @@ -46,7 +48,7 @@ For reproducible builds, you can pin both the awf binary and Docker images: steps: - name: Setup awf id: setup-awf - uses: github/gh-aw-firewall@main + uses: github/gh-aw-firewall@ with: version: 'v0.7.0' pull-images: 'true' @@ -60,14 +62,11 @@ steps: ### Using the Install Script -Alternatively, use the install script: - -```yaml -steps: - - name: Install awf - run: | - curl -sSL https://raw.githubusercontent.com/github/gh-aw-firewall/main/install.sh | sudo bash -``` +Use the setup action above instead of downloading and piping the installer into +a privileged shell. Pin the action to a reviewed commit. It provides the +workflow token for authenticated release verification; see +[the release security requirements](releasing.md#initial-trust-is-a-prerequisite-not-something-the-installer-can-create) +before bootstrapping the action itself. ### Building from Source @@ -107,7 +106,7 @@ jobs: - uses: actions/checkout@v4 - name: Setup awf - uses: github/gh-aw-firewall@main + uses: github/gh-aw-firewall@ - name: Install GitHub Copilot CLI run: npm install -g @github/copilot@latest @@ -176,7 +175,7 @@ jobs: - uses: actions/checkout@v4 - name: Setup awf - uses: github/gh-aw-firewall@main + uses: github/gh-aw-firewall@ - name: Test with Firewall env: diff --git a/docs/quickstart.md b/docs/quickstart.md index f78d0c7d8..4564eb145 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -15,21 +15,35 @@ See [Compatibility](compatibility.md) for full version details and tested config ### Option 1: Install Script (Recommended for Local Use) +First obtain and review `install.sh` through an independently authenticated +channel, and authenticate a trusted `gh` CLI with `gh auth login`. The installer +verifies GitHub attestations and requires `gh` authentication. `sudo` normally +changes `HOME` and drops credentials, so pass the token explicitly: + ```bash -# Install latest version -curl -sSL https://raw.githubusercontent.com/github/gh-aw-firewall/main/install.sh | sudo bash +gh auth status +export GH_TOKEN="$(gh auth token)" +sudo --preserve-env=GH_TOKEN bash ./install.sh # Verify installation sudo awf --version +unset GH_TOKEN ``` +Do not pipe an unauthenticated download into a privileged shell. In CI, prefer +the setup action pinned to a reviewed commit; see +[GitHub Actions Integration](github_actions.md). + ### Option 2: GitHub Action (Recommended for CI/CD) ```yaml - name: Setup awf - uses: github/gh-aw-firewall@main + uses: github/gh-aw-firewall@ ``` +Replace the placeholder with a reviewed full commit SHA so the bootstrap +does not follow a mutable branch. + See [GitHub Actions Integration](github_actions.md) for more details. ### Option 3: Build from Source diff --git a/install.sh b/install.sh index bb713f3ca..6c9505a59 100755 --- a/install.sh +++ b/install.sh @@ -7,15 +7,13 @@ set -e # Obtain this installer and the gh verifier through an independently trusted channel; # piping an unauthenticated HTTPS download into a shell cannot authenticate itself. # -# Usage (after independently authenticating this script and gh): -# # Install latest version -# sudo bash ./install.sh -# -# # Install specific version -# sudo bash ./install.sh v1.0.0 -# -# # Or with environment variable -# sudo AWF_VERSION=v1.0.0 bash ./install.sh +# Obtain and review this script through an independently authenticated channel; +# do not pipe an unauthenticated download into a privileged shell. Authenticate +# gh as your user, then explicitly preserve its token across sudo: +# export GH_TOKEN="$(gh auth token)" +# sudo --preserve-env=GH_TOKEN bash ./install.sh +# sudo --preserve-env=GH_TOKEN bash ./install.sh v1.0.0 +# Alternatively, set GITHUB_TOKEN and preserve that variable instead. # # Security features: # - Uses curl -f to fail on HTTP errors (404, 403, etc.) diff --git a/scripts/security/ca-trust.test.ts b/scripts/security/ca-trust.test.ts index 0d1e15079..5ccc814ea 100644 --- a/scripts/security/ca-trust.test.ts +++ b/scripts/security/ca-trust.test.ts @@ -4,6 +4,7 @@ import * as fs from 'node:fs'; import * as https from 'node:https'; import * as os from 'node:os'; import * as path from 'node:path'; +import * as tls from 'node:tls'; const root = path.resolve(__dirname, '../..'); const policyDir = path.join(root, 'containers/trust'); @@ -102,6 +103,31 @@ test('filters bundles and hashed directories without modifying input trust', () expect(fs.readFileSync(path.join(work, 'cross-output/ca-certificates.crt'), 'utf8')).not.toContain(cross.trim()); }); +test('deduplicates identical PEM blocks before invoking OpenSSL', () => { + const inputDir = path.join(work, 'duplicate-input'); + const fakeBin = path.join(work, 'counting-bin'); + const calls = path.join(work, 'openssl-calls'); + fs.mkdirSync(inputDir); + fs.mkdirSync(fakeBin); + fs.writeFileSync(path.join(inputDir, 'same.pem'), goodRoot); + fs.writeFileSync(path.join(inputDir, 'ca-certificates.crt'), goodRoot); + openssl('rehash', inputDir); + const realOpenSsl = execFileSync('which', ['openssl'], { encoding: 'utf8' }).trim(); + fs.writeFileSync(path.join(fakeBin, 'openssl'), + '#!/bin/sh\ncase "$1" in x509|pkey|dgst) printf "%s\\n" "$1" >> "$AWF_OPENSSL_CALLS";; esac\nexec "$AWF_REAL_OPENSSL" "$@"\n'); + fs.chmodSync(path.join(fakeBin, 'openssl'), 0o755); + execFileSync('sh', [path.join(work, 'filter-ca-store.sh'), path.join(work, 'deduplicated-output'), + inputDir], { + env: { + ...process.env, + PATH: `${fakeBin}:${process.env.PATH}`, + AWF_OPENSSL_CALLS: calls, + AWF_REAL_OPENSSL: realOpenSsl, + }, + }); + expect(fs.readFileSync(calls, 'utf8').trim().split('\n')).toHaveLength(5); +}); + test('invalid and all-distrusted CA stores fail closed', () => { fs.writeFileSync(path.join(work, 'invalid.pem'), 'not a certificate'); fs.writeFileSync(path.join(work, 'only-blocked.pem'), blockedRoot); @@ -186,7 +212,52 @@ test.each(['https', 'tls', 'fetch'])('%s rejects compromised default/extra/expli } }, 30000); -test('Node defaults no longer contain emergency-distrusted roots', () => { +test('Node preserves private roots selected by OpenSSL on Node versions without CA APIs', async () => { + for (const [leaf, allowed] of [['bad-leaf', false], ['good-leaf', true]] as const) { + const server = https.createServer({ + key: fs.readFileSync(path.join(work, `${leaf}.key`)), + cert: fs.readFileSync(path.join(work, `${leaf}.pem`)), + }, (_req, res) => res.end('ok')); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Missing test server address'); + try { + const script = ` + const fail = () => process.exit(${allowed ? 1 : 0}); + const pass = () => process.exit(${allowed ? 0 : 1}); + require('https').get({host:'127.0.0.1', port:${address.port}, servername:'localhost'}, pass).on('error', fail); + setTimeout(() => process.exit(2), 5000).unref(); + `; + const status = await new Promise((resolve, reject) => { + const child = spawn(process.execPath, [ + '--use-openssl-ca', '--require', path.join(work, 'policy.cjs'), '-e', script, + ], { + env: { + ...process.env, + NODE_EXTRA_CA_CERTS: '', + SSL_CERT_FILE: path.join(work, 'inputs.pem'), + SSL_CERT_DIR: '', + }, + stdio: 'pipe', + }); + let stderr = ''; + child.stderr.on('data', chunk => { stderr += chunk; }); + child.on('error', reject); + child.on('close', code => code === 0 + ? resolve(code) : reject(new Error(`OpenSSL-selected trust child failed for ${leaf}: ${stderr} (exit ${code})`))); + }); + expect(status).toBe(0); + } finally { + await new Promise(resolve => server.close(() => resolve())); + } + } +}, 30000); + +const defaultCaApisAvailable = typeof tls.getCACertificates === 'function' + && typeof tls.setDefaultCACertificates === 'function'; +const testDefaultCaInspection = defaultCaApisAvailable ? test : test.skip; + +testDefaultCaInspection('Node defaults no longer contain emergency-distrusted roots', () => { const script = ` const tls = require('tls'), {X509Certificate,createHash} = require('crypto'), assert = require('assert'); const original = tls.createSecureContext; diff --git a/scripts/security/filter-ca-store.sh b/scripts/security/filter-ca-store.sh index c540c8dea..1450d74cb 100644 --- a/scripts/security/filter-ca-store.sh +++ b/scripts/security/filter-ca-store.sh @@ -31,9 +31,18 @@ for input in "$@"; do fi done awk -v dir="$work" ' - /-----BEGIN CERTIFICATE-----/ { if (active) exit 1; active=1; count++; file=dir "/cert-" count ".pem" } - active { print > file } - /-----END CERTIFICATE-----/ { if (!active) exit 1; close(file); active=0 } + /-----BEGIN CERTIFICATE-----/ { if (active) exit 1; active=1; block="" } + active { block=block $0 "\n" } + /-----END CERTIFICATE-----/ { + if (!active) exit 1 + if (!seen[block]++) { + count++ + unique=dir "/cert-" count ".pem" + print block > unique + close(unique) + } + active=0 + } /-----BEGIN TRUSTED CERTIFICATE-----/ { exit 1 } END { if (active || !count) exit 1 } ' "$work/input.pem" || { echo "AWF trust policy: invalid or empty PEM CA store" >&2; exit 1; } diff --git a/scripts/security/install-system-trust.sh b/scripts/security/install-system-trust.sh index da9b3886f..1992fd695 100644 --- a/scripts/security/install-system-trust.sh +++ b/scripts/security/install-system-trust.sh @@ -1,7 +1,7 @@ #!/bin/sh set -eu policy_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -staging=$(mktemp -d /tmp/awf-ca-store.XXXXXX) +staging=$(mktemp -d /run/awf-ca-store.XXXXXX) trap 'rm -rf "$staging"' EXIT HUP INT TERM sh "$policy_dir/filter-ca-store.sh" "$staging" /etc/ssl/certs # This script is for image-local stores only. Host mounts are handled by the diff --git a/skill.md b/skill.md index fa53d1516..e51ce0d87 100644 --- a/skill.md +++ b/skill.md @@ -52,13 +52,21 @@ sudo awf --allow-domains github.com --log-level debug -- curl https://api.github ## Installation -### Install Script (Recommended) +### Install Script ```bash -curl -sSL https://raw.githubusercontent.com/github/gh-aw-firewall/main/install.sh | sudo bash +# Obtain and review install.sh through an independently authenticated channel. +gh auth status +export GH_TOKEN="$(gh auth token)" +sudo --preserve-env=GH_TOKEN bash ./install.sh +unset GH_TOKEN sudo awf --version ``` +The installer requires a trusted, authenticated `gh` CLI. Do not pipe an +unauthenticated download into a privileged shell; in CI, use the setup action +pinned to a reviewed commit. + ### Build from Source ```bash @@ -216,7 +224,7 @@ sudo awf \ ```yaml - name: Setup awf - uses: github/gh-aw-firewall@main + uses: github/gh-aw-firewall@ - name: Run with firewall run: | diff --git a/src/tls-trust-policy.test.ts b/src/tls-trust-policy.test.ts index 808f01f6f..2bf7a6137 100644 --- a/src/tls-trust-policy.test.ts +++ b/src/tls-trust-policy.test.ts @@ -7,11 +7,26 @@ type Policy = typeof import('./tls-trust-policy'); const trusted = rootCertificates.find(pem => new X509Certificate(pem).subject.includes('ISRG Root X1'))!; const blocked = readFileSync(path.resolve(__dirname, '../scripts/security/fixtures/emsign-ecc-g3.pem'), 'utf8').trim(); const originalExtraCa = process.env.NODE_EXTRA_CA_CERTS; +const originalNodeOptions = process.env.NODE_OPTIONS; +const originalSslCertFile = process.env.SSL_CERT_FILE; +const originalSslCertDir = process.env.SSL_CERT_DIR; function load(options: { legacy?: boolean; defaults?: string[]; extra?: string; + opensslFile?: string; + opensslDirectory?: string; + opensslDirectoryCertificate?: string; + missingOpenSslFile?: boolean; + missingOpenSslDirectory?: boolean; + missingOpenSslCertificate?: boolean; + opensslDirectoryEntryIsFile?: boolean; + opensslFileError?: Error; + opensslDirectoryError?: Error; + opensslCertificateError?: Error; + opensslDefaultDirectory?: string; + opensslDefaultPathOutput?: string; extraError?: Error; setter?: boolean; } = {}) { @@ -19,10 +34,25 @@ function load(options: { const createSecureContext = jest.fn().mockReturnValue(nativeContext); const setDefaultCACertificates = jest.fn(); const getCACertificates = jest.fn().mockReturnValue(options.defaults ?? [trusted, blocked]); - const read = jest.fn(() => { - if (options.extraError) throw options.extraError; - return options.extra; + const read = jest.fn((filename?: string) => { + if (options.extraError && filename === '/private-ca.pem') throw options.extraError; + if (options.opensslFileError && filename === '/openssl-ca.pem') throw options.opensslFileError; + if (filename === '/openssl-ca.pem' && options.missingOpenSslFile) { + throw Object.assign(new Error('file is missing'), { code: 'ENOENT' }); + } + if (options.opensslCertificateError && filename === '/openssl-certs/deadbeef.0') { + throw options.opensslCertificateError; + } + if (filename === '/openssl-certs/deadbeef.0' && options.missingOpenSslCertificate) { + throw Object.assign(new Error('certificate is missing'), { code: 'ENOENT' }); + } + if (filename === '/openssl-certs/deadbeef.0') return options.opensslDirectoryCertificate; + if (filename === '/openssl-default/certs/deadbeef.0') return options.opensslDirectoryCertificate; + if (filename === '/private-ca.pem') return options.extra; + return options.opensslFile; }); + const execFileSync = jest.fn(() => options.opensslDefaultPathOutput + ?? `OPENSSLDIR: "${options.opensslDefaultDirectory ?? '/openssl-default'}"\n`); if (options.extra !== undefined || options.extraError) process.env.NODE_EXTRA_CA_CERTS = '/private-ca.pem'; jest.doMock('node:tls', () => ({ rootCertificates: options.defaults ?? [trusted, blocked], @@ -30,22 +60,49 @@ function load(options: { setDefaultCACertificates: options.legacy || options.setter === false ? undefined : setDefaultCACertificates, createSecureContext, })); - jest.doMock('node:fs', () => ({ readFileSync: read })); + jest.doMock('node:fs', () => ({ + readFileSync: read, + readdirSync: jest.fn((directory: string) => { + if (options.opensslDirectoryError) throw options.opensslDirectoryError; + if (options.missingOpenSslDirectory) { + throw Object.assign(new Error('directory is missing'), { code: 'ENOENT' }); + } + return options.opensslDirectory || directory === '/openssl-default/certs' + ? ['deadbeef.0', 'README'] : []; + }), + statSync: jest.fn(() => { + if (options.opensslCertificateError) throw options.opensslCertificateError; + if (options.missingOpenSslCertificate) { + throw Object.assign(new Error('certificate is missing'), { code: 'ENOENT' }); + } + return { isFile: () => options.opensslDirectoryEntryIsFile !== false }; + }), + })); + jest.doMock('node:child_process', () => ({ execFileSync })); let policy!: Policy; let tls!: typeof import('node:tls'); jest.isolateModules(() => { policy = jest.requireActual('./tls-trust-policy'); tls = jest.requireMock('node:tls'); }); - return { policy, tls, createSecureContext, setDefaultCACertificates, getCACertificates, read, nativeContext }; + return { + policy, tls, createSecureContext, setDefaultCACertificates, getCACertificates, read, execFileSync, nativeContext, + }; } beforeEach(() => { delete process.env.NODE_EXTRA_CA_CERTS; }); afterEach(() => { jest.dontMock('node:tls'); jest.dontMock('node:fs'); + jest.dontMock('node:child_process'); if (originalExtraCa === undefined) delete process.env.NODE_EXTRA_CA_CERTS; else process.env.NODE_EXTRA_CA_CERTS = originalExtraCa; + if (originalNodeOptions === undefined) delete process.env.NODE_OPTIONS; + else process.env.NODE_OPTIONS = originalNodeOptions; + if (originalSslCertFile === undefined) delete process.env.SSL_CERT_FILE; + else process.env.SSL_CERT_FILE = originalSslCertFile; + if (originalSslCertDir === undefined) delete process.env.SSL_CERT_DIR; + else process.env.SSL_CERT_DIR = originalSslCertDir; }); test('filters modern defaults and extra roots and installs the policy only once', () => { @@ -59,7 +116,7 @@ test('filters modern defaults and extra roots and installs the policy only once' test.each([false, true])('default and null CA options use sanitized defaults (legacy=%s)', legacy => { const f = load({ legacy }); - for (const options of [undefined, { ca: null }]) { + for (const options of [undefined, null, { ca: null }, { ca: '' }]) { // Node accepts ca:null at runtime, despite its declaration excluding null. const result = options === undefined ? f.tls.createSecureContext() @@ -70,6 +127,97 @@ test.each([false, true])('default and null CA options use sanitized defaults (le expect(f.setDefaultCACertificates).toHaveBeenCalledTimes(legacy ? 0 : 1); }); +test('legacy Node preserves OpenSSL-selected CA sources instead of bundled roots', () => { + process.env.NODE_OPTIONS = '--use-openssl-ca'; + process.env.SSL_CERT_FILE = '/openssl-ca.pem'; + process.env.SSL_CERT_DIR = '/openssl-certs'; + const f = load({ + legacy: true, + opensslFile: blocked, + opensslDirectory: '/openssl-certs', + opensslDirectoryCertificate: trusted, + }); + expect(f.read).toHaveBeenCalledWith('/openssl-ca.pem', 'utf8'); + expect(f.read).toHaveBeenCalledWith('/openssl-certs/deadbeef.0', 'utf8'); + f.tls.createSecureContext(); + expect(f.createSecureContext).toHaveBeenCalledWith({ ca: [trusted] }); +}); + +test('legacy Node discovers OpenSSL default CA paths when build metadata omits them', () => { + process.env.NODE_OPTIONS = '--use-openssl-ca'; + delete process.env.SSL_CERT_FILE; + delete process.env.SSL_CERT_DIR; + const f = load({ + legacy: true, + opensslFile: blocked, + opensslDirectory: '/openssl-default/certs', + opensslDirectoryCertificate: trusted, + }); + expect(f.execFileSync).toHaveBeenCalledWith('openssl', ['version', '-d'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + expect(f.read).toHaveBeenCalledWith('/openssl-default/cert.pem', 'utf8'); + f.tls.createSecureContext(); + expect(f.createSecureContext).toHaveBeenCalledWith({ ca: [trusted] }); +}); + +test.each([ + ['unreadable file', 'file'], + ['unreadable directory', 'directory'], + ['unreadable hashed certificate', 'certificate'], +])('fails closed on an %s in the selected OpenSSL trust store', (_label, source) => { + process.env.NODE_OPTIONS = '--use-openssl-ca'; + process.env.SSL_CERT_FILE = '/openssl-ca.pem'; + process.env.SSL_CERT_DIR = '/openssl-certs'; + const error = Object.assign(new Error('permission denied'), { code: 'EACCES' }); + expect(() => load({ + legacy: true, + opensslFile: trusted, + opensslDirectory: '/openssl-certs', + opensslDirectoryCertificate: trusted, + opensslFileError: source === 'file' ? error : undefined, + opensslDirectoryError: source === 'directory' ? error : undefined, + opensslCertificateError: source === 'certificate' ? error : undefined, + })).toThrow('permission denied'); +}); + +test('fails closed when OpenSSL default directory cannot be discovered', () => { + process.env.NODE_OPTIONS = '--use-openssl-ca'; + delete process.env.SSL_CERT_FILE; + delete process.env.SSL_CERT_DIR; + expect(() => load({ legacy: true, opensslDefaultPathOutput: 'no OPENSSLDIR here' })) + .toThrow('Unable to determine OpenSSL default CA paths'); +}); + +test('fails closed when the selected OpenSSL sources contain no readable CA certificates', () => { + process.env.NODE_OPTIONS = '--use-openssl-ca'; + process.env.SSL_CERT_FILE = ''; + process.env.SSL_CERT_DIR = '/openssl-certs'; + expect(() => load({ + legacy: true, + opensslDirectory: '/openssl-certs', + opensslDirectoryEntryIsFile: false, + })).toThrow('empty default CA store'); +}); + +test.each(['file', 'directory', 'certificate'])('ignores missing OpenSSL trust path %s when another source is available', missing => { + process.env.NODE_OPTIONS = '--use-openssl-ca'; + process.env.SSL_CERT_FILE = '/openssl-ca.pem'; + process.env.SSL_CERT_DIR = '/openssl-certs'; + const f = load({ + legacy: true, + opensslFile: trusted, + opensslDirectory: '/openssl-certs', + opensslDirectoryCertificate: trusted, + missingOpenSslFile: missing === 'file', + missingOpenSslDirectory: missing === 'directory', + missingOpenSslCertificate: missing === 'certificate', + }); + f.tls.createSecureContext(); + expect(f.createSecureContext).toHaveBeenCalledWith({ ca: [trusted] }); +}); + test('filters explicit CA strings, buffers and arrays without dropping other options', () => { const f = load({ setter: false }); for (const ca of [trusted + '\n' + blocked, Buffer.from(trusted), [Buffer.from(blocked), trusted]]) { diff --git a/src/tls-trust-policy.ts b/src/tls-trust-policy.ts index 45302a29e..cb5c03dee 100644 --- a/src/tls-trust-policy.ts +++ b/src/tls-trust-policy.ts @@ -1,5 +1,7 @@ import { createHash, X509Certificate } from 'node:crypto'; -import { readFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import path from 'node:path'; import tls from 'node:tls'; // October 2026 eMudhra disclosure: eight compromised keys, followed by the @@ -23,6 +25,55 @@ export const distrustedSpkis = new Set([ const certificateCache = new Map(); +function readSelectedOpenSslCertificates(): string[] { + const variables = process.config.variables as unknown as Record; + let openSslDir: string | undefined; + if ((!variables.openssl_default_cert_file && process.env.SSL_CERT_FILE === undefined) + || (!variables.openssl_default_cert_dir && process.env.SSL_CERT_DIR === undefined)) { + const output = execFileSync('openssl', ['version', '-d'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + const match = output.match(/^OPENSSLDIR: "([^"]+)"$/m); + if (!match) throw new Error('Unable to determine OpenSSL default CA paths'); + openSslDir = match[1]; + } + const file = process.env.SSL_CERT_FILE + ?? variables.openssl_default_cert_file + ?? path.join(openSslDir!, 'cert.pem'); + const dirList = process.env.SSL_CERT_DIR + ?? variables.openssl_default_cert_dir + ?? path.join(openSslDir!, 'certs'); + const dirs = dirList.split(path.delimiter).filter(Boolean); + const certificates: string[] = []; + if (file) { + try { + certificates.push(readFileSync(file, 'utf8')); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + } + for (const dir of dirs) { + let entries: string[]; + try { + entries = readdirSync(dir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue; + throw error; + } + for (const entry of entries) { + if (!/^[a-f0-9]{8}\.\d+$/i.test(entry)) continue; + const candidate = path.join(dir, entry); + try { + if (statSync(candidate).isFile()) certificates.push(readFileSync(candidate, 'utf8')); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + } + } + return certificates; +} + export function filterCaCertificates( ca: NonNullable, ): string[] { @@ -51,9 +102,12 @@ let installed = false; export function applyTlsTrustPolicy(): void { if (installed) return; + const usesOpenSslCa = /(?:^|\s)--use-openssl-ca(?:\s|$)/.test( + `${process.execArgv.join(' ')} ${process.env.NODE_OPTIONS || ''}`, + ); const defaults = tls.getCACertificates ? [...tls.getCACertificates('default')] - : [...tls.rootCertificates]; + : usesOpenSslCa ? readSelectedOpenSslCertificates() : [...tls.rootCertificates]; // Read explicitly as well: Node otherwise warns and ignores malformed or // missing NODE_EXTRA_CA_CERTS, which must not silently defeat private trust. if (process.env.NODE_EXTRA_CA_CERTS) { @@ -65,11 +119,14 @@ export function applyTlsTrustPolicy(): void { const createSecureContext = tls.createSecureContext; // Also cover explicit custom CA options and Node versions predating the // default-CA API. This affects https, fetch/undici and direct tls.connect. - tls.createSecureContext = (options = {}) => createSecureContext({ - ...options, - ca: options.ca === undefined || options.ca === null - ? filteredDefaults : filterCaCertificates(options.ca), - }); + tls.createSecureContext = (options = {}) => { + const normalizedOptions = options ?? {}; + return createSecureContext({ + ...normalizedOptions, + ca: normalizedOptions.ca + ? filterCaCertificates(normalizedOptions.ca) : filteredDefaults, + }); + }; installed = true; } diff --git a/tests/entrypoint-phase-functions.test.sh b/tests/entrypoint-phase-functions.test.sh index bacfbc276..d89945161 100755 --- a/tests/entrypoint-phase-functions.test.sh +++ b/tests/entrypoint-phase-functions.test.sh @@ -191,11 +191,20 @@ run_copy_system_ca_bundle_fixture() { tmp_dir="$(mktemp -d)" local host_root="${tmp_dir}/host-root" local fixture_entrypoint="${tmp_dir}/entrypoint-fixture.sh" - mkdir -p "${host_root}/etc/pki/tls/certs" "${host_root}/etc/ssl/certs" + local java_store="${host_root}/etc/ssl/certs/java/cacerts" + local java_link="${host_root}/usr/lib/jvm/fixture/lib/security/cacerts" + mkdir -p "${host_root}/etc/pki/tls/certs" "${host_root}/etc/ssl/certs/java" \ + "${host_root}/etc/pki/ca-trust/extracted/pem" "$(dirname "${java_link}")" openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj /CN=private-fixture \ -keyout "${tmp_dir}/fixture.key" -out "${host_root}/etc/pki/tls/certs/ca-bundle.crt" >/dev/null 2>&1 cp "${host_root}/etc/pki/tls/certs/ca-bundle.crt" "${tmp_dir}/original.crt" ln -s ../../pki/tls/certs/ca-bundle.crt "${host_root}/etc/ssl/certs/ca-certificates.crt" + printf 'fixture-java-keystore' > "${java_store}" + ln -s /etc/ssl/certs/java/cacerts "${java_link}" + cp "${host_root}/etc/pki/tls/certs/ca-bundle.crt" \ + "${host_root}/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem" + printf 'email-only-anchor\n' > "${host_root}/etc/pki/ca-trust/extracted/pem/email-ca-bundle.pem" + printf 'code-signing-only-anchor\n' > "${host_root}/etc/pki/ca-trust/extracted/pem/objsign-ca-bundle.pem" write_entrypoint_fixture "${fixture_entrypoint}" @@ -215,14 +224,19 @@ run_copy_system_ca_bundle_fixture() { unset SSL_CERT_FILE NODE_EXTRA_CA_CERTS REQUESTS_CA_BUNDLE CURL_CA_BUNDLE GIT_SSL_CAINFO SYSTEM_CA_CHROOT copy_system_ca_bundle - [ "${SSL_CERT_FILE}" = "/run/awf-lib/trust/ca-certificates.crt" ] - [ -r "${AWF_TEST_HOST_ROOT}${SSL_CERT_FILE}" ] - [ "${NODE_EXTRA_CA_CERTS}" = "${SSL_CERT_FILE}" ] - - cmp "${host_root}/etc/pki/tls/certs/ca-bundle.crt" "${tmp_dir}/original.crt" - grep -q 'remount,ro,bind' "${tmp_dir}/mounts" - grep -q '/etc/pki/tls/certs' "${tmp_dir}/mounts" - [ "${SSL_CERT_DIR}" = "/run/awf-lib/trust/certs" ] + [ "${SSL_CERT_FILE}" = "/run/awf-lib/trust/ca-certificates.crt" ] || exit 1 + [ -r "${AWF_TEST_HOST_ROOT}${SSL_CERT_FILE}" ] || exit 1 + [ "${NODE_EXTRA_CA_CERTS}" = "${SSL_CERT_FILE}" ] || exit 1 + [ "$(readlink "${java_link}")" = "/etc/ssl/certs/java/cacerts" ] || exit 1 + cmp "${java_store}" "${host_root}/run/awf-lib/trust/certs/java/cacerts" || exit 1 + ! grep -q 'email-only-anchor\|code-signing-only-anchor' \ + "${host_root}/run/awf-lib/trust/ca-certificates.crt" || exit 1 + + cmp "${host_root}/etc/pki/tls/certs/ca-bundle.crt" "${tmp_dir}/original.crt" || exit 1 + grep -q 'remount,ro,bind' "${tmp_dir}/mounts" || exit 1 + grep -q '/etc/pki/tls/certs' "${tmp_dir}/mounts" || exit 1 + grep -q '/etc/pki/ca-trust/extracted/pem' "${tmp_dir}/mounts" || exit 1 + [ "${SSL_CERT_DIR}" = "/run/awf-lib/trust/certs" ] || exit 1 case "${NODE_OPTIONS}" in *--require=/run/awf-lib/trust/node-trust.cjs*) ;; *) exit 1 ;; esac ) result=$? From 28a402958b704884222c80c2d6c815685252cd92 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Tue, 20 Oct 2026 09:58:53 -0700 Subject: [PATCH 7/9] fix: price confirmed Azure smoke deployment without budget bypass Map the operator-confirmed o4-mini-aw deployment to repo-pinned o4-mini rates in the two Azure BYOK smoke configs only. Postprocess generated locks, preserve all existing budget and isolation settings, reject conflicting catalogs, and verify actual staged config commands plus token accounting locally. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 89097bc0-0800-4855-965e-ceaab0cef6d3 --- .../smoke-copilot-byok-aoai-apikey.lock.yml | 1 + .../smoke-copilot-byok-aoai-entra.lock.yml | 1 + docs/api-proxy-sidecar.md | 11 ++ scripts/ci/apply-general-workflow-patches.ts | 11 ++ scripts/ci/configure-aoai-smoke-pricing.cjs | 51 ++++++++ .../ci/configure-aoai-smoke-pricing.test.ts | 115 ++++++++++++++++++ 6 files changed, 190 insertions(+) create mode 100644 scripts/ci/configure-aoai-smoke-pricing.cjs create mode 100644 scripts/ci/configure-aoai-smoke-pricing.test.ts diff --git a/.github/workflows/smoke-copilot-byok-aoai-apikey.lock.yml b/.github/workflows/smoke-copilot-byok-aoai-apikey.lock.yml index 65d976c16..5c84b5910 100644 --- a/.github/workflows/smoke-copilot-byok-aoai-apikey.lock.yml +++ b/.github/workflows/smoke-copilot-byok-aoai-apikey.lock.yml @@ -1069,6 +1069,7 @@ jobs: fi printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.28.62/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"*.githubusercontent.com\",\"api.snapcraft.io\",\"archive.ubuntu.com\",\"azure.archive.ubuntu.com\",\"codeload.github.com\",\"crl.geotrust.com\",\"crl.globalsign.com\",\"crl.identrust.com\",\"crl.sectigo.com\",\"crl.thawte.com\",\"crl.usertrust.com\",\"crl.verisign.com\",\"crl3.digicert.com\",\"crl4.digicert.com\",\"crls.ssl.com\",\"docs.github.com\",\"github-cloud.githubusercontent.com\",\"github-cloud.s3.amazonaws.com\",\"github.blog\",\"github.com\",\"github.githubassets.com\",\"json-schema.org\",\"json.schemastore.org\",\"keyserver.ubuntu.com\",\"lfs.github.com\",\"objects.githubusercontent.com\",\"ocsp.digicert.com\",\"ocsp.geotrust.com\",\"ocsp.globalsign.com\",\"ocsp.identrust.com\",\"ocsp.sectigo.com\",\"ocsp.ssl.com\",\"ocsp.thawte.com\",\"ocsp.usertrust.com\",\"ocsp.verisign.com\",\"packagecloud.io\",\"packages.cloud.google.com\",\"packages.microsoft.com\",\"patch-diff.githubusercontent.com\",\"patchdiff.githubusercontent.com\",\"ppa.launchpad.net\",\"raw.githubusercontent.com\",\"s.symcb.com\",\"s.symcd.com\",\"security.ubuntu.com\",\"ts-crl.ws.symantec.com\",\"ts-ocsp.ws.symantec.com\",\"www.googleapis.com\"],\"isolation\":true,\"topologyAttach\":[\"awmg-mcpg\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":500,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"maxCacheMisses\":5,\"models\":{\"agent\":[\"sonnet-6x\",\"gpt-6\",\"gpt-5.4\",\"gpt-5.5\",\"gpt-5.6\",\"gpt-5.3\",\"gemini-pro\",\"any\"],\"antigravity\":[\"copilot/antigravity*\",\"google/antigravity*\",\"gemini/antigravity*\"],\"any\":[\"copilot/*\",\"anthropic/*\",\"openai/*\",\"google/*\",\"gemini/*\"],\"auto\":[\"copilot/auto\",\"large\"],\"claude\":[\"agent\"],\"codex\":[\"agent\"],\"coding\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\",\"gpt-5-codex\",\"kimi\"],\"computer-use\":[\"copilot/*computer-use*\",\"google/*computer-use*\",\"gemini/*computer-use*\",\"openai/*computer-use*\"],\"copilot\":[\"agent\"],\"deep-research\":[\"copilot/deep-research*\",\"copilot/o3-deep-research*\",\"copilot/o4-mini-deep-research*\",\"google/deep-research*\",\"gemini/deep-research*\",\"openai/o3-deep-research*\",\"openai/o4-mini-deep-research*\"],\"detection\":[\"small\"],\"evals\":[\"small\"],\"fable\":[\"copilot/*fable*\",\"anthropic/*fable*\"],\"gemini\":[\"agent\"],\"gemini-3-flash\":[\"copilot/gemini-3*flash*\",\"google/gemini-3*flash*\",\"gemini/gemini-3*flash*\"],\"gemini-3-pro\":[\"copilot/gemini-3*pro*\",\"google/gemini-3*pro*\",\"google/nano-banana*\",\"gemini/gemini-3*pro*\"],\"gemini-3.1-flash\":[\"copilot/gemini-3.1*flash*\",\"google/gemini-3.1*flash*\",\"gemini/gemini-3.1*flash*\"],\"gemini-3.1-pro\":[\"copilot/gemini-3.1*pro*\",\"google/gemini-3.1*pro*\",\"gemini/gemini-3.1*pro*\"],\"gemini-3.5-flash\":[\"copilot/gemini-3.5*flash*\",\"google/gemini-3.5*flash*\",\"gemini/gemini-3.5*flash*\"],\"gemini-3.6-flash\":[\"copilot/gemini-3.6*flash*\",\"google/gemini-3.6*flash*\",\"gemini/gemini-3.6*flash*\"],\"gemini-3.7-flash\":[\"copilot/gemini-3.7*flash*\",\"google/gemini-3.7*flash*\",\"gemini/gemini-3.7*flash*\"],\"gemini-3.8-flash\":[\"copilot/gemini-3.8*flash*\",\"google/gemini-3.8*flash*\",\"gemini/gemini-3.8*flash*\"],\"gemini-flash\":[\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"],\"gemini-flash-lite\":[\"copilot/gemini-*flash*lite*\",\"google/gemini-*flash*lite*\",\"gemini/gemini-*flash*lite*\"],\"gemini-omni\":[\"copilot/gemini-omni*\",\"google/gemini-omni*\",\"gemini/gemini-omni*\"],\"gemini-pro\":[\"copilot/gemini-*pro*\",\"google/gemini-*pro*\",\"gemini/gemini-*pro*\"],\"gemma\":[\"copilot/gemma*\",\"google/gemma*\",\"gemini/gemma*\"],\"gpt-5\":[\"copilot/gpt-5*\",\"openai/gpt-5*\"],\"gpt-5-codex\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\"],\"gpt-5-mini\":[\"copilot/gpt-5*mini*\",\"openai/gpt-5*mini*\"],\"gpt-5-nano\":[\"copilot/gpt-5*nano*\",\"openai/gpt-5*nano*\"],\"gpt-5-pro\":[\"copilot/gpt-5*pro*\",\"openai/gpt-5*pro*\"],\"gpt-5.1\":[\"copilot/gpt-5.1*\",\"openai/gpt-5.1*\"],\"gpt-5.2\":[\"copilot/gpt-5.2*\",\"openai/gpt-5.2*\"],\"gpt-5.3\":[\"copilot/gpt-5.3*\",\"openai/gpt-5.3*\"],\"gpt-5.4\":[\"copilot/gpt-5.4*\",\"openai/gpt-5.4*\"],\"gpt-5.5\":[\"copilot/gpt-5.5*\",\"openai/gpt-5.5*\"],\"gpt-5.6\":[\"copilot/gpt-5.6*\",\"openai/gpt-5.6*\"],\"gpt-6\":[\"copilot/gpt-6*\",\"openai/gpt-6*\"],\"grok\":[\"copilot/*grok*\",\"openai/*grok*\"],\"haiku\":[\"copilot/*haiku*\",\"anthropic/*haiku*\"],\"image-generation\":[\"copilot/gpt-image*\",\"openai/gpt-image*\",\"openai/chatgpt-image*\",\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"google/imagen*\"],\"kimi\":[\"copilot/kimi*\",\"openai/kimi*\"],\"kiwi\":[\"copilot/kiwi*\",\"openai/kiwi*\"],\"large\":[\"sonnet\",\"gpt-6\",\"gpt-5-pro\",\"gpt-5\",\"gemini-pro\"],\"lyria\":[\"google/lyria*\",\"gemini/lyria*\",\"copilot/lyria*\"],\"mai-code\":[\"copilot/MAI-Code*\",\"copilot/mai-code*\",\"openai/MAI-Code*\"],\"mai-code-1-flash-picker\":[\"copilot/MAI-Code-1-Flash-picker*\",\"copilot/mai-code-1-flash-picker*\",\"openai/MAI-Code-1-Flash-picker*\"],\"mini\":[\"haiku\",\"gpt-5-mini\",\"gpt-5-nano\",\"gemini-flash-lite\"],\"nano-banana\":[\"copilot/nano-banana*\",\"google/nano-banana*\",\"gemini/nano-banana*\"],\"opus\":[\"copilot/*opus*\",\"anthropic/*opus*\"],\"opusplan\":[\"opus?effort=high\"],\"raptor-mini\":[\"copilot/raptor*\",\"openai/raptor*\"],\"reasoning\":[\"copilot/o1*\",\"copilot/o3*\",\"copilot/o4*\",\"openai/o1*\",\"openai/o3*\",\"openai/o4*\"],\"robotics\":[\"copilot/*robotics*\",\"google/*robotics*\",\"gemini/*robotics*\"],\"small\":[\"mini\"],\"small-agent\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash\"],\"sonnet\":[\"copilot/*sonnet*\",\"anthropic/*sonnet*\"],\"sonnet-6x\":[\"copilot/*sonnet-4.5*\",\"copilot/*sonnet-4.6*\",\"copilot/*sonnet-5*\",\"copilot/*sonnet-4-5-*\",\"anthropic/*sonnet-4-5-*\",\"copilot/*sonnet-4-6*\",\"anthropic/*sonnet-4-6*\",\"anthropic/*sonnet-5*\"],\"summarization\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash-lite\",\"mini\"],\"veo\":[\"google/veo*\",\"gemini/veo*\"],\"vision\":[\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"]}},\"container\":{\"imageTag\":\"0.28.62,squid=sha256:b145850ed8bdffd2d4fcd8f1f3ea5793b56cadee6989700b91f42cff490630ef,agent=sha256:80c4ddd927647b636f49807d2557b4cd3ec175c894194e432c9374f5a69e890f,api-proxy=sha256:ebb9e7b5bec475f6b6c2eb6e999ada7d41d57d0acbacb8493c5bebc0106fd44d,cli-proxy=sha256:b23bbe2c025f60ea9aacde67c078ea937b54b4039552bca072ae9610d9e1c294\",\"agentTimeout\":15},\"logging\":{\"proxyLogsDir\":\"/tmp/gh-aw/sandbox/firewall/logs\",\"auditDir\":\"/tmp/gh-aw/sandbox/firewall/audit\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json" cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json + node "${GITHUB_WORKSPACE}/scripts/ci/configure-aoai-smoke-pricing.cjs" "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json export GH_AW_MODELS_JSON_PATH="/tmp/gh-aw/models.json" GH_AW_DOCKER_HOST="" if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then diff --git a/.github/workflows/smoke-copilot-byok-aoai-entra.lock.yml b/.github/workflows/smoke-copilot-byok-aoai-entra.lock.yml index ad7509de3..9a6e886b3 100644 --- a/.github/workflows/smoke-copilot-byok-aoai-entra.lock.yml +++ b/.github/workflows/smoke-copilot-byok-aoai-entra.lock.yml @@ -1036,6 +1036,7 @@ jobs: fi printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.28.62/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"*.githubusercontent.com\",\"api.snapcraft.io\",\"archive.ubuntu.com\",\"azure.archive.ubuntu.com\",\"codeload.github.com\",\"crl.geotrust.com\",\"crl.globalsign.com\",\"crl.identrust.com\",\"crl.sectigo.com\",\"crl.thawte.com\",\"crl.usertrust.com\",\"crl.verisign.com\",\"crl3.digicert.com\",\"crl4.digicert.com\",\"crls.ssl.com\",\"docs.github.com\",\"github-cloud.githubusercontent.com\",\"github-cloud.s3.amazonaws.com\",\"github.blog\",\"github.com\",\"github.githubassets.com\",\"json-schema.org\",\"json.schemastore.org\",\"keyserver.ubuntu.com\",\"lfs.github.com\",\"login.microsoftonline.com\",\"objects.githubusercontent.com\",\"ocsp.digicert.com\",\"ocsp.geotrust.com\",\"ocsp.globalsign.com\",\"ocsp.identrust.com\",\"ocsp.sectigo.com\",\"ocsp.ssl.com\",\"ocsp.thawte.com\",\"ocsp.usertrust.com\",\"ocsp.verisign.com\",\"packagecloud.io\",\"packages.cloud.google.com\",\"packages.microsoft.com\",\"patch-diff.githubusercontent.com\",\"patchdiff.githubusercontent.com\",\"ppa.launchpad.net\",\"raw.githubusercontent.com\",\"s.symcb.com\",\"s.symcd.com\",\"security.ubuntu.com\",\"ts-crl.ws.symantec.com\",\"ts-ocsp.ws.symantec.com\",\"www.googleapis.com\"],\"isolation\":true,\"topologyAttach\":[\"awmg-mcpg\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":500,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"maxCacheMisses\":5,\"models\":{\"agent\":[\"sonnet-6x\",\"gpt-6\",\"gpt-5.4\",\"gpt-5.5\",\"gpt-5.6\",\"gpt-5.3\",\"gemini-pro\",\"any\"],\"antigravity\":[\"copilot/antigravity*\",\"google/antigravity*\",\"gemini/antigravity*\"],\"any\":[\"copilot/*\",\"anthropic/*\",\"openai/*\",\"google/*\",\"gemini/*\"],\"auto\":[\"copilot/auto\",\"large\"],\"claude\":[\"agent\"],\"codex\":[\"agent\"],\"coding\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\",\"gpt-5-codex\",\"kimi\"],\"computer-use\":[\"copilot/*computer-use*\",\"google/*computer-use*\",\"gemini/*computer-use*\",\"openai/*computer-use*\"],\"copilot\":[\"agent\"],\"deep-research\":[\"copilot/deep-research*\",\"copilot/o3-deep-research*\",\"copilot/o4-mini-deep-research*\",\"google/deep-research*\",\"gemini/deep-research*\",\"openai/o3-deep-research*\",\"openai/o4-mini-deep-research*\"],\"detection\":[\"small\"],\"evals\":[\"small\"],\"fable\":[\"copilot/*fable*\",\"anthropic/*fable*\"],\"gemini\":[\"agent\"],\"gemini-3-flash\":[\"copilot/gemini-3*flash*\",\"google/gemini-3*flash*\",\"gemini/gemini-3*flash*\"],\"gemini-3-pro\":[\"copilot/gemini-3*pro*\",\"google/gemini-3*pro*\",\"google/nano-banana*\",\"gemini/gemini-3*pro*\"],\"gemini-3.1-flash\":[\"copilot/gemini-3.1*flash*\",\"google/gemini-3.1*flash*\",\"gemini/gemini-3.1*flash*\"],\"gemini-3.1-pro\":[\"copilot/gemini-3.1*pro*\",\"google/gemini-3.1*pro*\",\"gemini/gemini-3.1*pro*\"],\"gemini-3.5-flash\":[\"copilot/gemini-3.5*flash*\",\"google/gemini-3.5*flash*\",\"gemini/gemini-3.5*flash*\"],\"gemini-3.6-flash\":[\"copilot/gemini-3.6*flash*\",\"google/gemini-3.6*flash*\",\"gemini/gemini-3.6*flash*\"],\"gemini-3.7-flash\":[\"copilot/gemini-3.7*flash*\",\"google/gemini-3.7*flash*\",\"gemini/gemini-3.7*flash*\"],\"gemini-3.8-flash\":[\"copilot/gemini-3.8*flash*\",\"google/gemini-3.8*flash*\",\"gemini/gemini-3.8*flash*\"],\"gemini-flash\":[\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"],\"gemini-flash-lite\":[\"copilot/gemini-*flash*lite*\",\"google/gemini-*flash*lite*\",\"gemini/gemini-*flash*lite*\"],\"gemini-omni\":[\"copilot/gemini-omni*\",\"google/gemini-omni*\",\"gemini/gemini-omni*\"],\"gemini-pro\":[\"copilot/gemini-*pro*\",\"google/gemini-*pro*\",\"gemini/gemini-*pro*\"],\"gemma\":[\"copilot/gemma*\",\"google/gemma*\",\"gemini/gemma*\"],\"gpt-5\":[\"copilot/gpt-5*\",\"openai/gpt-5*\"],\"gpt-5-codex\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\"],\"gpt-5-mini\":[\"copilot/gpt-5*mini*\",\"openai/gpt-5*mini*\"],\"gpt-5-nano\":[\"copilot/gpt-5*nano*\",\"openai/gpt-5*nano*\"],\"gpt-5-pro\":[\"copilot/gpt-5*pro*\",\"openai/gpt-5*pro*\"],\"gpt-5.1\":[\"copilot/gpt-5.1*\",\"openai/gpt-5.1*\"],\"gpt-5.2\":[\"copilot/gpt-5.2*\",\"openai/gpt-5.2*\"],\"gpt-5.3\":[\"copilot/gpt-5.3*\",\"openai/gpt-5.3*\"],\"gpt-5.4\":[\"copilot/gpt-5.4*\",\"openai/gpt-5.4*\"],\"gpt-5.5\":[\"copilot/gpt-5.5*\",\"openai/gpt-5.5*\"],\"gpt-5.6\":[\"copilot/gpt-5.6*\",\"openai/gpt-5.6*\"],\"gpt-6\":[\"copilot/gpt-6*\",\"openai/gpt-6*\"],\"grok\":[\"copilot/*grok*\",\"openai/*grok*\"],\"haiku\":[\"copilot/*haiku*\",\"anthropic/*haiku*\"],\"image-generation\":[\"copilot/gpt-image*\",\"openai/gpt-image*\",\"openai/chatgpt-image*\",\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"google/imagen*\"],\"kimi\":[\"copilot/kimi*\",\"openai/kimi*\"],\"kiwi\":[\"copilot/kiwi*\",\"openai/kiwi*\"],\"large\":[\"sonnet\",\"gpt-6\",\"gpt-5-pro\",\"gpt-5\",\"gemini-pro\"],\"lyria\":[\"google/lyria*\",\"gemini/lyria*\",\"copilot/lyria*\"],\"mai-code\":[\"copilot/MAI-Code*\",\"copilot/mai-code*\",\"openai/MAI-Code*\"],\"mai-code-1-flash-picker\":[\"copilot/MAI-Code-1-Flash-picker*\",\"copilot/mai-code-1-flash-picker*\",\"openai/MAI-Code-1-Flash-picker*\"],\"mini\":[\"haiku\",\"gpt-5-mini\",\"gpt-5-nano\",\"gemini-flash-lite\"],\"nano-banana\":[\"copilot/nano-banana*\",\"google/nano-banana*\",\"gemini/nano-banana*\"],\"opus\":[\"copilot/*opus*\",\"anthropic/*opus*\"],\"opusplan\":[\"opus?effort=high\"],\"raptor-mini\":[\"copilot/raptor*\",\"openai/raptor*\"],\"reasoning\":[\"copilot/o1*\",\"copilot/o3*\",\"copilot/o4*\",\"openai/o1*\",\"openai/o3*\",\"openai/o4*\"],\"robotics\":[\"copilot/*robotics*\",\"google/*robotics*\",\"gemini/*robotics*\"],\"small\":[\"mini\"],\"small-agent\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash\"],\"sonnet\":[\"copilot/*sonnet*\",\"anthropic/*sonnet*\"],\"sonnet-6x\":[\"copilot/*sonnet-4.5*\",\"copilot/*sonnet-4.6*\",\"copilot/*sonnet-5*\",\"copilot/*sonnet-4-5-*\",\"anthropic/*sonnet-4-5-*\",\"copilot/*sonnet-4-6*\",\"anthropic/*sonnet-4-6*\",\"anthropic/*sonnet-5*\"],\"summarization\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash-lite\",\"mini\"],\"veo\":[\"google/veo*\",\"gemini/veo*\"],\"vision\":[\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"]}},\"container\":{\"imageTag\":\"0.28.62,squid=sha256:b145850ed8bdffd2d4fcd8f1f3ea5793b56cadee6989700b91f42cff490630ef,agent=sha256:80c4ddd927647b636f49807d2557b4cd3ec175c894194e432c9374f5a69e890f,api-proxy=sha256:ebb9e7b5bec475f6b6c2eb6e999ada7d41d57d0acbacb8493c5bebc0106fd44d,cli-proxy=sha256:b23bbe2c025f60ea9aacde67c078ea937b54b4039552bca072ae9610d9e1c294\",\"agentTimeout\":15},\"logging\":{\"proxyLogsDir\":\"/tmp/gh-aw/sandbox/firewall/logs\",\"auditDir\":\"/tmp/gh-aw/sandbox/firewall/audit\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json" cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json + node "${GITHUB_WORKSPACE}/scripts/ci/configure-aoai-smoke-pricing.cjs" "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json export GH_AW_MODELS_JSON_PATH="/tmp/gh-aw/models.json" GH_AW_DOCKER_HOST="" if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then diff --git a/docs/api-proxy-sidecar.md b/docs/api-proxy-sidecar.md index e5bf48ed3..6678f6ef2 100644 --- a/docs/api-proxy-sidecar.md +++ b/docs/api-proxy-sidecar.md @@ -1001,6 +1001,17 @@ only when the prompt count exceeds the threshold. Overlay costs use the models.dev format (dollars per token) and are normalized to dollars per million tokens inside the proxy. Shared-catalog rates use dollars per million tokens. +Azure deployment names are not inferred from model-family prefixes. With an +active AI-credit budget, an unknown deployment must have explicit pricing or +the proxy rejects it before inference. The two Azure BYOK smoke workflows use +the operator-confirmed mapping `o4-mini-aw` to canonical `openai/o4-mini`. +Their postprocessor installs a workflow-scoped catalog from the checked-in +models.dev snapshot via `scripts/ci/configure-aoai-smoke-pricing.cjs`, preserving +all budget limits and unknown-model rejection for other deployments. This is +not a production-wide alias or a generic fallback. Update the confirmed mapping +and pinned pricing together if that Azure deployment changes its underlying +model. + ### Caller attribution (`x-awf-caller`) The agent harness may set an optional `x-awf-caller` request header so that diff --git a/scripts/ci/apply-general-workflow-patches.ts b/scripts/ci/apply-general-workflow-patches.ts index da53600fe..53c4714c2 100644 --- a/scripts/ci/apply-general-workflow-patches.ts +++ b/scripts/ci/apply-general-workflow-patches.ts @@ -81,6 +81,17 @@ export function applyGeneralWorkflowPatches( ): PatchResult { const log: string[] = []; + if (/(?:^|[/\\])smoke-copilot-byok-aoai-(?:apikey|entra)\.lock\.yml$/.test(workflowPath)) { + const pricingCommand = 'node "${GITHUB_WORKSPACE}/scripts/ci/configure-aoai-smoke-pricing.cjs" ' + + '"${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json'; + if (!content.includes(pricingCommand)) { + const configCopy = /^([ \t]*)cp "\$\{RUNNER_TEMP\}\/gh-aw\/awf-config\.json" \/tmp\/gh-aw\/awf-config\.json$/m; + if (!configCopy.test(content)) throw new Error(`Missing AWF config staging in Azure BYOK smoke: ${workflowPath}`); + content = content.replace(configCopy, (match, indent: string) => `${match}\n${indent}${pricingCommand}`); + log.push(' Configured workflow-scoped Azure deployment pricing without changing budget limits'); + } + } + if (workflowPath.endsWith('smoke-enclave-issues-read.lock.yml')) { const original = content; content = content diff --git a/scripts/ci/configure-aoai-smoke-pricing.cjs b/scripts/ci/configure-aoai-smoke-pricing.cjs new file mode 100644 index 000000000..cde87a71e --- /dev/null +++ b/scripts/ci/configure-aoai-smoke-pricing.cjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node +const { createHash } = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); + +const configPaths = process.argv.slice(2); +if (configPaths.length !== 2) throw new Error('Usage: configure-aoai-smoke-pricing.cjs '); +const source = fs.readFileSync(path.resolve(__dirname, '../../containers/api-proxy/models.dev.catalog.json'), 'utf8'); +const model = JSON.parse(source).data.find(entry => entry.id === 'openai/o4-mini'); +if (!model) throw new Error('Pinned pricing catalog is missing canonical openai/o4-mini'); +function rate(category) { + const value = model.pricing[category]; + const number = typeof value === 'string' && value.trim() ? Number(value) * 1e6 : NaN; + if (!Number.isFinite(number) || number < 0) throw new Error(`Invalid pinned o4-mini ${category} pricing`); + return number; +} +// The repository operator confirmed this exact Azure deployment-to-model map. +// Keep it scoped to these smoke configurations, never global model inference. +const catalog = { + schemaVersion: 1, + unit: 'USD_PER_1M_TOKENS', + catalogId: 'github/gh-aw-firewall/azure-byok-smoke', + version: `sha256:${createHash('sha256').update(source).digest('hex')}`, + providers: { + copilot: { + models: { + 'o4-mini-aw': { + input: rate('prompt'), + cachedInput: rate('input_cache_read'), + cacheWrite: null, + output: rate('completion'), + reasoning: rate('completion'), + }, + }, + }, + }, +}; +const configs = configPaths.map(file => { + const config = JSON.parse(fs.readFileSync(file, 'utf8')); + if (!config.apiProxy?.enabled) throw new Error(`Azure smoke requires an enabled API proxy: ${file}`); + if (config.apiProxy.modelPricingCatalog && + JSON.stringify(config.apiProxy.modelPricingCatalog) !== JSON.stringify(catalog)) { + throw new Error(`Refusing to replace an existing workflow pricing catalog: ${file}`); + } + return config; +}); +for (let index = 0; index < configPaths.length; index++) { + configs[index].apiProxy.modelPricingCatalog = catalog; + fs.writeFileSync(configPaths[index], JSON.stringify(configs[index]) + '\n'); +} +console.log('Configured exact Azure smoke deployment pricing from the pinned o4-mini catalog; budget limits unchanged'); diff --git a/scripts/ci/configure-aoai-smoke-pricing.test.ts b/scripts/ci/configure-aoai-smoke-pricing.test.ts new file mode 100644 index 000000000..4be232234 --- /dev/null +++ b/scripts/ci/configure-aoai-smoke-pricing.test.ts @@ -0,0 +1,115 @@ +import { execFileSync, spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as yaml from 'js-yaml'; +import { applyGeneralWorkflowPatches } from './apply-general-workflow-patches'; + +const root = path.resolve(__dirname, '../..'); +const script = path.join(__dirname, 'configure-aoai-smoke-pricing.cjs'); +const original = { + network: { isolation: true, allowDomains: ['github.com'] }, + apiProxy: { enabled: true, maxAiCredits: 10, maxCacheMisses: 5, maxRuns: 500 }, +}; +let work: string; +let files: string[]; +beforeEach(() => { + work = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-aoai-pricing-')); + files = [path.join(work, 'runner.json'), path.join(work, 'sandbox.json')]; + for (const file of files) fs.writeFileSync(file, JSON.stringify(original)); +}); +afterEach(() => fs.rmSync(work, { recursive: true, force: true })); + +test('pins confirmed deployment pricing in both configs while preserving every existing limit', () => { + for (let repeat = 0; repeat < 2; repeat++) execFileSync(process.execPath, [script, ...files]); + for (const file of files) { + expect(JSON.parse(fs.readFileSync(file, 'utf8'))).toEqual({ + ...original, + apiProxy: { + ...original.apiProxy, + modelPricingCatalog: { + schemaVersion: 1, + unit: 'USD_PER_1M_TOKENS', + catalogId: 'github/gh-aw-firewall/azure-byok-smoke', + version: expect.stringMatching(/^sha256:[a-f0-9]{64}$/), + providers: { + copilot: { models: { + 'o4-mini-aw': { input: 1.1, cachedInput: 0.275, cacheWrite: null, output: 4.4, reasoning: 4.4 }, + } }, + }, + }, + }, + }); + } +}); + +test('the actual guard accepts only the explicitly priced alias and accounts for its tokens', () => { + execFileSync(process.execPath, [script, ...files]); + const config = JSON.parse(fs.readFileSync(files[0], 'utf8')) as { apiProxy: { modelPricingCatalog: unknown } }; + const probe = ` + const assert=require('node:assert/strict'); + const guard=require('./containers/api-proxy/guards/ai-credits-guard'); + assert.equal(guard.checkUnknownModelRejection('o4-mini-aw','copilot'),null); + assert(guard.checkUnknownModelRejection('unknown-azure-deployment','copilot').rejected); + const usage=guard.applyAiCreditsUsage({ + input_tokens:1000,cache_read_tokens:100,output_tokens:500,input_tokens_include_cache:true + },'o4-mini-aw','copilot'); + assert(Math.abs(usage.aiCreditsThisResponse-0.32175)<1e-10); + const state=guard.getAiCreditsReflectState(); + assert.equal(state.max_ai_credits,10); + assert.equal(state.effective_max_ai_credits,10); + `; + execFileSync(process.execPath, ['-e', probe], { + cwd: root, + env: { ...process.env, AWF_MAX_AI_CREDITS: '10', AWF_MODEL_PRICING_CATALOG: JSON.stringify(config.apiProxy.modelPricingCatalog) }, + stdio: 'pipe', + }); +}); + +test('does not overwrite a pre-existing compiler catalog or partially mutate staged configs', () => { + fs.writeFileSync(files[1], JSON.stringify({ + ...original, apiProxy: { ...original.apiProxy, modelPricingCatalog: { catalogId: 'compiler-owned' } }, + })); + const before = files.map(file => fs.readFileSync(file, 'utf8')); + const result = spawnSync(process.execPath, [script, ...files], { encoding: 'utf8' }); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('Refusing to replace'); + expect(files.map(file => fs.readFileSync(file, 'utf8'))).toEqual(before); +}); + +test.each(['apikey', 'entra'])('postprocessing configures only the Azure %s smoke, idempotently', mode => { + const input = ' cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json\n'; + const workflow = `/tmp/smoke-copilot-byok-aoai-${mode}.lock.yml`; + const patched = applyGeneralWorkflowPatches(input, workflow).content; + expect(patched).toContain('node "${GITHUB_WORKSPACE}/scripts/ci/configure-aoai-smoke-pricing.cjs"'); + expect(applyGeneralWorkflowPatches(patched, workflow).content).toBe(patched); + expect(applyGeneralWorkflowPatches(input, '/tmp/smoke-copilot-byok.lock.yml').content).toBe(input); + expect(() => applyGeneralWorkflowPatches('', workflow)).toThrow('Missing AWF config staging'); +}); + +test.each(['apikey', 'entra'])('the actual generated %s config stage installs pricing before AWF launches', mode => { + const lockPath = path.join(root, `.github/workflows/smoke-copilot-byok-aoai-${mode}.lock.yml`); + const content = fs.readFileSync(lockPath, 'utf8'); + expect(applyGeneralWorkflowPatches(content, lockPath).content).toBe(content); + const workflow = yaml.load(content) as { jobs: { agent: { steps: { run?: string }[] } } }; + const run = workflow.jobs.agent.steps.find(step => step.run?.includes('configure-aoai-smoke-pricing.cjs'))!.run!; + const lines = run.split('\n'); + const start = lines.findIndex(line => line.startsWith("printf '%s\\n' ")); + const end = lines.findIndex(line => line.startsWith('node "${GITHUB_WORKSPACE}/scripts/ci/configure-aoai-smoke-pricing.cjs"')); + expect(start).toBeGreaterThanOrEqual(0); + expect(end).toBeGreaterThan(start); + expect(run.indexOf('configure-aoai-smoke-pricing.cjs')).toBeLessThan(run.indexOf('awf --config')); + fs.mkdirSync(path.join(work, 'gh-aw')); + const stage = lines.slice(start, end + 1).join('\n') + .split('/tmp/gh-aw/awf-config.json').join(files[1]); + execFileSync('bash', ['-e', '-c', stage], { + env: { ...process.env, GITHUB_WORKSPACE: root, RUNNER_TEMP: work, GH_AW_MAX_AI_CREDITS: '10' }, + stdio: 'pipe', + }); + const runner = fs.readFileSync(path.join(work, 'gh-aw/awf-config.json'), 'utf8'); + expect(runner).toBe(fs.readFileSync(files[1], 'utf8')); + expect(JSON.parse(runner)).toMatchObject({ + apiProxy: { maxAiCredits: 10, maxRuns: 500, maxCacheMisses: 5, + modelPricingCatalog: { providers: { copilot: { models: { 'o4-mini-aw': { input: 1.1, output: 4.4 } } } } } }, + }); +}); From 58f73b75292c6a49e0889d6ae039e51b185f6d2e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:09:03 +0000 Subject: [PATCH 8/9] fix: validate generated CA distrust policy --- scripts/security/ca-trust.test.ts | 21 +++++ scripts/security/generate-trust-policy.cjs | 98 +++++++++++++++------- 2 files changed, 88 insertions(+), 31 deletions(-) diff --git a/scripts/security/ca-trust.test.ts b/scripts/security/ca-trust.test.ts index 5ccc814ea..0d7431174 100644 --- a/scripts/security/ca-trust.test.ts +++ b/scripts/security/ca-trust.test.ts @@ -8,6 +8,9 @@ import * as tls from 'node:tls'; const root = path.resolve(__dirname, '../..'); const policyDir = path.join(root, 'containers/trust'); +const { extractDistrustedSpkis } = require('./generate-trust-policy.cjs') as { + extractDistrustedSpkis: (source: string) => string[]; +}; let work: string; let blockedRoot: string; let goodRoot: string; @@ -61,6 +64,24 @@ test('generated Docker trust policies remain synchronized with canonical TypeScr execFileSync(process.execPath, ['scripts/security/generate-trust-policy.cjs', '--check'], { cwd: root }); }); +test('trust policy generator parses validated SPKI literals independent of quote style', () => { + const keys = ['a'.repeat(64), 'b'.repeat(64)]; + for (const quote of ["'", '"']) { + const entries = keys.map(key => `${quote}${key}${quote}`).join(', '); + expect(extractDistrustedSpkis(`export const distrustedSpkis = new Set([${entries}]);`)).toEqual(keys); + } +}); + +test.each([ + 'export const distrustedSpkis = new Set([]);', + `export const distrustedSpkis = new Set(['${'a'.repeat(63)}']);`, + `export const distrustedSpkis = new Set(['${'a'.repeat(64)}', '${'a'.repeat(64)}']);`, + `export const distrustedSpkis = new Set([...'${'a'.repeat(64)}']);`, + `export const other = new Set(['${'a'.repeat(64)}']);`, +])('trust policy generator rejects malformed canonical hash declarations', source => { + expect(() => extractDistrustedSpkis(source)).toThrow(); +}); + test('dependency-free runtime transport smoke also runs in the root regression suite', () => { execFileSync(process.execPath, ['--test', 'scripts/security/tls-runtime-smoke.cjs'], { cwd: root, stdio: 'pipe' }); }, 30000); diff --git a/scripts/security/generate-trust-policy.cjs b/scripts/security/generate-trust-policy.cjs index b71ef3853..15768d9f8 100644 --- a/scripts/security/generate-trust-policy.cjs +++ b/scripts/security/generate-trust-policy.cjs @@ -4,38 +4,74 @@ const fs = require('node:fs'); const path = require('node:path'); const ts = require('typescript'); const root = path.resolve(__dirname, '../..'); -const source = fs.readFileSync(path.join(root, 'src/tls-trust-policy.ts'), 'utf8'); -const js = '// Generated by scripts/security/generate-trust-policy.cjs; do not edit.\n' - + ts.transpileModule(source, { - compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, esModuleInterop: true }, - }).outputText; -const hashes = [...source.matchAll(/'([a-f0-9]{64})'/g)].map(match => match[1]).join('\n') + '\n'; -const files = { - 'node-trust.cjs': js, - 'distrusted-spki.txt': hashes, - 'filter-ca-store.sh': fs.readFileSync(path.join(__dirname, 'filter-ca-store.sh'), 'utf8'), - 'install-system-trust.sh': fs.readFileSync(path.join(__dirname, 'install-system-trust.sh'), 'utf8'), - 'install-node-trust.sh': fs.readFileSync(path.join(__dirname, 'install-node-trust.sh'), 'utf8'), -}; -let mismatched = false; -for (const component of ['agent', 'api-proxy', 'cli-proxy', 'squid', 'build-tools', 'gh-aw-node', 'router', 'dind-ubuntu', 'trust']) { - const dir = path.join(root, 'containers', component, ...(component === 'trust' ? [] : ['trust'])); - for (const [name, content] of Object.entries(files)) { - const dest = path.join(dir, name); - if (['squid', 'router', 'build-tools'].includes(component) - && ['node-trust.cjs', 'install-node-trust.sh'].includes(name)) { - if (!process.argv.includes('--check') && fs.existsSync(dest)) fs.unlinkSync(dest); - continue; - } - if (process.argv.includes('--check')) { - if (!fs.existsSync(dest) || fs.readFileSync(dest, 'utf8') !== content) { - console.error(`Out-of-sync trust policy: ${path.relative(root, dest)}`); - mismatched = true; +function extractDistrustedSpkis(source) { + const sourceFile = ts.createSourceFile( + 'tls-trust-policy.ts', + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + if (sourceFile.parseDiagnostics.length > 0) { + throw new Error('Unable to parse canonical TLS trust policy'); + } + const declaration = sourceFile.statements + .filter(ts.isVariableStatement) + .flatMap(statement => statement.declarationList.declarations) + .find(item => ts.isIdentifier(item.name) && item.name.text === 'distrustedSpkis'); + const initializer = declaration && declaration.initializer; + if (!initializer || !ts.isNewExpression(initializer) || !ts.isIdentifier(initializer.expression) + || initializer.expression.text !== 'Set' || initializer.arguments?.length !== 1 + || !ts.isArrayLiteralExpression(initializer.arguments[0])) { + throw new Error('Canonical TLS trust policy must declare distrustedSpkis as a Set of string literals'); + } + const keys = initializer.arguments[0].elements.map(element => ( + ts.isStringLiteral(element) ? element.text : '' + )); + if (keys.length === 0 || keys.some(key => !/^[a-f0-9]{64}$/.test(key)) || new Set(keys).size !== keys.length) { + throw new Error('Canonical TLS trust policy contains missing, invalid, or duplicate SPKI hashes'); + } + return keys; +} + +function generate() { + const source = fs.readFileSync(path.join(root, 'src/tls-trust-policy.ts'), 'utf8'); + const js = '// Generated by scripts/security/generate-trust-policy.cjs; do not edit.\n' + + ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, esModuleInterop: true }, + }).outputText; + const hashes = `${extractDistrustedSpkis(source).join('\n')}\n`; + const files = { + 'node-trust.cjs': js, + 'distrusted-spki.txt': hashes, + 'filter-ca-store.sh': fs.readFileSync(path.join(__dirname, 'filter-ca-store.sh'), 'utf8'), + 'install-system-trust.sh': fs.readFileSync(path.join(__dirname, 'install-system-trust.sh'), 'utf8'), + 'install-node-trust.sh': fs.readFileSync(path.join(__dirname, 'install-node-trust.sh'), 'utf8'), + }; + let mismatched = false; + for (const component of ['agent', 'api-proxy', 'cli-proxy', 'squid', 'build-tools', 'gh-aw-node', 'router', 'dind-ubuntu', 'trust']) { + const dir = path.join(root, 'containers', component, ...(component === 'trust' ? [] : ['trust'])); + for (const [name, content] of Object.entries(files)) { + const dest = path.join(dir, name); + if (['squid', 'router', 'build-tools'].includes(component) + && ['node-trust.cjs', 'install-node-trust.sh'].includes(name)) { + if (!process.argv.includes('--check') && fs.existsSync(dest)) fs.unlinkSync(dest); + continue; + } + if (process.argv.includes('--check')) { + if (!fs.existsSync(dest) || fs.readFileSync(dest, 'utf8') !== content) { + console.error(`Out-of-sync trust policy: ${path.relative(root, dest)}`); + mismatched = true; + } + } else { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(dest, content); } - } else { - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(dest, content); } } + if (mismatched) process.exitCode = 1; } -if (mismatched) process.exitCode = 1; + +if (require.main === module) generate(); + +module.exports = { extractDistrustedSpkis }; From 84e1aae3a050ee537fc6eebfde25bfb40ac32d43 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Tue, 20 Oct 2026 10:12:54 -0700 Subject: [PATCH 9/9] fix: close CA-store races and preserve selected OpenSSL trust Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 89097bc0-0800-4855-965e-ceaab0cef6d3 --- containers/agent/trust/node-trust.cjs | 17 +++-- containers/api-proxy/trust/node-trust.cjs | 17 +++-- containers/cli-proxy/trust/node-trust.cjs | 17 +++-- containers/dind-ubuntu/trust/node-trust.cjs | 17 +++-- containers/gh-aw-node/trust/node-trust.cjs | 17 +++-- containers/trust/node-trust.cjs | 17 +++-- scripts/security/ca-trust.test.ts | 6 +- src/tls-trust-policy.test.ts | 84 +++++++++++++++++---- src/tls-trust-policy.ts | 16 ++-- 9 files changed, 157 insertions(+), 51 deletions(-) diff --git a/containers/agent/trust/node-trust.cjs b/containers/agent/trust/node-trust.cjs index 6f7b0df93..b685e9c6f 100644 --- a/containers/agent/trust/node-trust.cjs +++ b/containers/agent/trust/node-trust.cjs @@ -77,8 +77,15 @@ function readSelectedOpenSslCertificates() { continue; const candidate = node_path_1.default.join(dir, entry); try { - if ((0, node_fs_1.statSync)(candidate).isFile()) - certificates.push((0, node_fs_1.readFileSync)(candidate, 'utf8')); + // Pin the check/read to one handle without blocking on an unexpected FIFO. + const descriptor = (0, node_fs_1.openSync)(candidate, node_fs_1.constants.O_RDONLY | node_fs_1.constants.O_NONBLOCK); + try { + if ((0, node_fs_1.fstatSync)(descriptor).isFile()) + certificates.push((0, node_fs_1.readFileSync)(descriptor, 'utf8')); + } + finally { + (0, node_fs_1.closeSync)(descriptor); + } } catch (error) { if (error.code !== 'ENOENT') @@ -115,9 +122,9 @@ function applyTlsTrustPolicy() { if (installed) return; const usesOpenSslCa = /(?:^|\s)--use-openssl-ca(?:\s|$)/.test(`${process.execArgv.join(' ')} ${process.env.NODE_OPTIONS || ''}`); - const defaults = node_tls_1.default.getCACertificates - ? [...node_tls_1.default.getCACertificates('default')] - : usesOpenSslCa ? readSelectedOpenSslCertificates() : [...node_tls_1.default.rootCertificates]; + const defaults = usesOpenSslCa + ? readSelectedOpenSslCertificates() + : node_tls_1.default.getCACertificates ? [...node_tls_1.default.getCACertificates('default')] : [...node_tls_1.default.rootCertificates]; // Read explicitly as well: Node otherwise warns and ignores malformed or // missing NODE_EXTRA_CA_CERTS, which must not silently defeat private trust. if (process.env.NODE_EXTRA_CA_CERTS) { diff --git a/containers/api-proxy/trust/node-trust.cjs b/containers/api-proxy/trust/node-trust.cjs index 6f7b0df93..b685e9c6f 100644 --- a/containers/api-proxy/trust/node-trust.cjs +++ b/containers/api-proxy/trust/node-trust.cjs @@ -77,8 +77,15 @@ function readSelectedOpenSslCertificates() { continue; const candidate = node_path_1.default.join(dir, entry); try { - if ((0, node_fs_1.statSync)(candidate).isFile()) - certificates.push((0, node_fs_1.readFileSync)(candidate, 'utf8')); + // Pin the check/read to one handle without blocking on an unexpected FIFO. + const descriptor = (0, node_fs_1.openSync)(candidate, node_fs_1.constants.O_RDONLY | node_fs_1.constants.O_NONBLOCK); + try { + if ((0, node_fs_1.fstatSync)(descriptor).isFile()) + certificates.push((0, node_fs_1.readFileSync)(descriptor, 'utf8')); + } + finally { + (0, node_fs_1.closeSync)(descriptor); + } } catch (error) { if (error.code !== 'ENOENT') @@ -115,9 +122,9 @@ function applyTlsTrustPolicy() { if (installed) return; const usesOpenSslCa = /(?:^|\s)--use-openssl-ca(?:\s|$)/.test(`${process.execArgv.join(' ')} ${process.env.NODE_OPTIONS || ''}`); - const defaults = node_tls_1.default.getCACertificates - ? [...node_tls_1.default.getCACertificates('default')] - : usesOpenSslCa ? readSelectedOpenSslCertificates() : [...node_tls_1.default.rootCertificates]; + const defaults = usesOpenSslCa + ? readSelectedOpenSslCertificates() + : node_tls_1.default.getCACertificates ? [...node_tls_1.default.getCACertificates('default')] : [...node_tls_1.default.rootCertificates]; // Read explicitly as well: Node otherwise warns and ignores malformed or // missing NODE_EXTRA_CA_CERTS, which must not silently defeat private trust. if (process.env.NODE_EXTRA_CA_CERTS) { diff --git a/containers/cli-proxy/trust/node-trust.cjs b/containers/cli-proxy/trust/node-trust.cjs index 6f7b0df93..b685e9c6f 100644 --- a/containers/cli-proxy/trust/node-trust.cjs +++ b/containers/cli-proxy/trust/node-trust.cjs @@ -77,8 +77,15 @@ function readSelectedOpenSslCertificates() { continue; const candidate = node_path_1.default.join(dir, entry); try { - if ((0, node_fs_1.statSync)(candidate).isFile()) - certificates.push((0, node_fs_1.readFileSync)(candidate, 'utf8')); + // Pin the check/read to one handle without blocking on an unexpected FIFO. + const descriptor = (0, node_fs_1.openSync)(candidate, node_fs_1.constants.O_RDONLY | node_fs_1.constants.O_NONBLOCK); + try { + if ((0, node_fs_1.fstatSync)(descriptor).isFile()) + certificates.push((0, node_fs_1.readFileSync)(descriptor, 'utf8')); + } + finally { + (0, node_fs_1.closeSync)(descriptor); + } } catch (error) { if (error.code !== 'ENOENT') @@ -115,9 +122,9 @@ function applyTlsTrustPolicy() { if (installed) return; const usesOpenSslCa = /(?:^|\s)--use-openssl-ca(?:\s|$)/.test(`${process.execArgv.join(' ')} ${process.env.NODE_OPTIONS || ''}`); - const defaults = node_tls_1.default.getCACertificates - ? [...node_tls_1.default.getCACertificates('default')] - : usesOpenSslCa ? readSelectedOpenSslCertificates() : [...node_tls_1.default.rootCertificates]; + const defaults = usesOpenSslCa + ? readSelectedOpenSslCertificates() + : node_tls_1.default.getCACertificates ? [...node_tls_1.default.getCACertificates('default')] : [...node_tls_1.default.rootCertificates]; // Read explicitly as well: Node otherwise warns and ignores malformed or // missing NODE_EXTRA_CA_CERTS, which must not silently defeat private trust. if (process.env.NODE_EXTRA_CA_CERTS) { diff --git a/containers/dind-ubuntu/trust/node-trust.cjs b/containers/dind-ubuntu/trust/node-trust.cjs index 6f7b0df93..b685e9c6f 100644 --- a/containers/dind-ubuntu/trust/node-trust.cjs +++ b/containers/dind-ubuntu/trust/node-trust.cjs @@ -77,8 +77,15 @@ function readSelectedOpenSslCertificates() { continue; const candidate = node_path_1.default.join(dir, entry); try { - if ((0, node_fs_1.statSync)(candidate).isFile()) - certificates.push((0, node_fs_1.readFileSync)(candidate, 'utf8')); + // Pin the check/read to one handle without blocking on an unexpected FIFO. + const descriptor = (0, node_fs_1.openSync)(candidate, node_fs_1.constants.O_RDONLY | node_fs_1.constants.O_NONBLOCK); + try { + if ((0, node_fs_1.fstatSync)(descriptor).isFile()) + certificates.push((0, node_fs_1.readFileSync)(descriptor, 'utf8')); + } + finally { + (0, node_fs_1.closeSync)(descriptor); + } } catch (error) { if (error.code !== 'ENOENT') @@ -115,9 +122,9 @@ function applyTlsTrustPolicy() { if (installed) return; const usesOpenSslCa = /(?:^|\s)--use-openssl-ca(?:\s|$)/.test(`${process.execArgv.join(' ')} ${process.env.NODE_OPTIONS || ''}`); - const defaults = node_tls_1.default.getCACertificates - ? [...node_tls_1.default.getCACertificates('default')] - : usesOpenSslCa ? readSelectedOpenSslCertificates() : [...node_tls_1.default.rootCertificates]; + const defaults = usesOpenSslCa + ? readSelectedOpenSslCertificates() + : node_tls_1.default.getCACertificates ? [...node_tls_1.default.getCACertificates('default')] : [...node_tls_1.default.rootCertificates]; // Read explicitly as well: Node otherwise warns and ignores malformed or // missing NODE_EXTRA_CA_CERTS, which must not silently defeat private trust. if (process.env.NODE_EXTRA_CA_CERTS) { diff --git a/containers/gh-aw-node/trust/node-trust.cjs b/containers/gh-aw-node/trust/node-trust.cjs index 6f7b0df93..b685e9c6f 100644 --- a/containers/gh-aw-node/trust/node-trust.cjs +++ b/containers/gh-aw-node/trust/node-trust.cjs @@ -77,8 +77,15 @@ function readSelectedOpenSslCertificates() { continue; const candidate = node_path_1.default.join(dir, entry); try { - if ((0, node_fs_1.statSync)(candidate).isFile()) - certificates.push((0, node_fs_1.readFileSync)(candidate, 'utf8')); + // Pin the check/read to one handle without blocking on an unexpected FIFO. + const descriptor = (0, node_fs_1.openSync)(candidate, node_fs_1.constants.O_RDONLY | node_fs_1.constants.O_NONBLOCK); + try { + if ((0, node_fs_1.fstatSync)(descriptor).isFile()) + certificates.push((0, node_fs_1.readFileSync)(descriptor, 'utf8')); + } + finally { + (0, node_fs_1.closeSync)(descriptor); + } } catch (error) { if (error.code !== 'ENOENT') @@ -115,9 +122,9 @@ function applyTlsTrustPolicy() { if (installed) return; const usesOpenSslCa = /(?:^|\s)--use-openssl-ca(?:\s|$)/.test(`${process.execArgv.join(' ')} ${process.env.NODE_OPTIONS || ''}`); - const defaults = node_tls_1.default.getCACertificates - ? [...node_tls_1.default.getCACertificates('default')] - : usesOpenSslCa ? readSelectedOpenSslCertificates() : [...node_tls_1.default.rootCertificates]; + const defaults = usesOpenSslCa + ? readSelectedOpenSslCertificates() + : node_tls_1.default.getCACertificates ? [...node_tls_1.default.getCACertificates('default')] : [...node_tls_1.default.rootCertificates]; // Read explicitly as well: Node otherwise warns and ignores malformed or // missing NODE_EXTRA_CA_CERTS, which must not silently defeat private trust. if (process.env.NODE_EXTRA_CA_CERTS) { diff --git a/containers/trust/node-trust.cjs b/containers/trust/node-trust.cjs index 6f7b0df93..b685e9c6f 100644 --- a/containers/trust/node-trust.cjs +++ b/containers/trust/node-trust.cjs @@ -77,8 +77,15 @@ function readSelectedOpenSslCertificates() { continue; const candidate = node_path_1.default.join(dir, entry); try { - if ((0, node_fs_1.statSync)(candidate).isFile()) - certificates.push((0, node_fs_1.readFileSync)(candidate, 'utf8')); + // Pin the check/read to one handle without blocking on an unexpected FIFO. + const descriptor = (0, node_fs_1.openSync)(candidate, node_fs_1.constants.O_RDONLY | node_fs_1.constants.O_NONBLOCK); + try { + if ((0, node_fs_1.fstatSync)(descriptor).isFile()) + certificates.push((0, node_fs_1.readFileSync)(descriptor, 'utf8')); + } + finally { + (0, node_fs_1.closeSync)(descriptor); + } } catch (error) { if (error.code !== 'ENOENT') @@ -115,9 +122,9 @@ function applyTlsTrustPolicy() { if (installed) return; const usesOpenSslCa = /(?:^|\s)--use-openssl-ca(?:\s|$)/.test(`${process.execArgv.join(' ')} ${process.env.NODE_OPTIONS || ''}`); - const defaults = node_tls_1.default.getCACertificates - ? [...node_tls_1.default.getCACertificates('default')] - : usesOpenSslCa ? readSelectedOpenSslCertificates() : [...node_tls_1.default.rootCertificates]; + const defaults = usesOpenSslCa + ? readSelectedOpenSslCertificates() + : node_tls_1.default.getCACertificates ? [...node_tls_1.default.getCACertificates('default')] : [...node_tls_1.default.rootCertificates]; // Read explicitly as well: Node otherwise warns and ignores malformed or // missing NODE_EXTRA_CA_CERTS, which must not silently defeat private trust. if (process.env.NODE_EXTRA_CA_CERTS) { diff --git a/scripts/security/ca-trust.test.ts b/scripts/security/ca-trust.test.ts index 0d7431174..fd6bd4918 100644 --- a/scripts/security/ca-trust.test.ts +++ b/scripts/security/ca-trust.test.ts @@ -8,9 +8,9 @@ import * as tls from 'node:tls'; const root = path.resolve(__dirname, '../..'); const policyDir = path.join(root, 'containers/trust'); -const { extractDistrustedSpkis } = require('./generate-trust-policy.cjs') as { +const { extractDistrustedSpkis } = jest.requireActual<{ extractDistrustedSpkis: (source: string) => string[]; -}; +}>('./generate-trust-policy.cjs'); let work: string; let blockedRoot: string; let goodRoot: string; @@ -233,7 +233,7 @@ test.each(['https', 'tls', 'fetch'])('%s rejects compromised default/extra/expli } }, 30000); -test('Node preserves private roots selected by OpenSSL on Node versions without CA APIs', async () => { +test('Node preserves private roots selected by OpenSSL', async () => { for (const [leaf, allowed] of [['bad-leaf', false], ['good-leaf', true]] as const) { const server = https.createServer({ key: fs.readFileSync(path.join(work, `${leaf}.key`)), diff --git a/src/tls-trust-policy.test.ts b/src/tls-trust-policy.test.ts index 2bf7a6137..87b1610b9 100644 --- a/src/tls-trust-policy.test.ts +++ b/src/tls-trust-policy.test.ts @@ -1,5 +1,5 @@ import { X509Certificate } from 'node:crypto'; -import { readFileSync } from 'node:fs'; +import { constants, readFileSync } from 'node:fs'; import * as path from 'node:path'; import { rootCertificates } from 'node:tls'; @@ -25,6 +25,7 @@ function load(options: { opensslFileError?: Error; opensslDirectoryError?: Error; opensslCertificateError?: Error; + opensslCertificateReadError?: Error; opensslDefaultDirectory?: string; opensslDefaultPathOutput?: string; extraError?: Error; @@ -34,7 +35,21 @@ function load(options: { const createSecureContext = jest.fn().mockReturnValue(nativeContext); const setDefaultCACertificates = jest.fn(); const getCACertificates = jest.fn().mockReturnValue(options.defaults ?? [trusted, blocked]); - const read = jest.fn((filename?: string) => { + const descriptors = new Map(); + const open = jest.fn((filename: string) => { + if (options.missingOpenSslCertificate) { + throw Object.assign(new Error('certificate is missing'), { code: 'ENOENT' }); + } + descriptors.set(42, filename); + return 42; + }); + const close = jest.fn(); + const inspect = jest.fn(() => { + if (options.opensslCertificateError) throw options.opensslCertificateError; + return { isFile: () => options.opensslDirectoryEntryIsFile !== false }; + }); + const read = jest.fn((input?: string | number) => { + const filename = typeof input === 'number' ? descriptors.get(input) : input; if (options.extraError && filename === '/private-ca.pem') throw options.extraError; if (options.opensslFileError && filename === '/openssl-ca.pem') throw options.opensslFileError; if (filename === '/openssl-ca.pem' && options.missingOpenSslFile) { @@ -43,6 +58,9 @@ function load(options: { if (options.opensslCertificateError && filename === '/openssl-certs/deadbeef.0') { throw options.opensslCertificateError; } + if (options.opensslCertificateReadError && filename === '/openssl-certs/deadbeef.0') { + throw options.opensslCertificateReadError; + } if (filename === '/openssl-certs/deadbeef.0' && options.missingOpenSslCertificate) { throw Object.assign(new Error('certificate is missing'), { code: 'ENOENT' }); } @@ -61,6 +79,10 @@ function load(options: { createSecureContext, })); jest.doMock('node:fs', () => ({ + constants, + openSync: open, + closeSync: close, + fstatSync: inspect, readFileSync: read, readdirSync: jest.fn((directory: string) => { if (options.opensslDirectoryError) throw options.opensslDirectoryError; @@ -70,13 +92,6 @@ function load(options: { return options.opensslDirectory || directory === '/openssl-default/certs' ? ['deadbeef.0', 'README'] : []; }), - statSync: jest.fn(() => { - if (options.opensslCertificateError) throw options.opensslCertificateError; - if (options.missingOpenSslCertificate) { - throw Object.assign(new Error('certificate is missing'), { code: 'ENOENT' }); - } - return { isFile: () => options.opensslDirectoryEntryIsFile !== false }; - }), })); jest.doMock('node:child_process', () => ({ execFileSync })); let policy!: Policy; @@ -87,10 +102,14 @@ function load(options: { }); return { policy, tls, createSecureContext, setDefaultCACertificates, getCACertificates, read, execFileSync, nativeContext, + open, close, inspect, }; } -beforeEach(() => { delete process.env.NODE_EXTRA_CA_CERTS; }); +beforeEach(() => { + jest.resetModules(); + delete process.env.NODE_EXTRA_CA_CERTS; +}); afterEach(() => { jest.dontMock('node:tls'); jest.dontMock('node:fs'); @@ -105,6 +124,43 @@ afterEach(() => { else process.env.SSL_CERT_DIR = originalSslCertDir; }); +test('checks and reads each hashed CA through the same opened descriptor', () => { + process.env.NODE_OPTIONS = '--use-openssl-ca'; + process.env.SSL_CERT_FILE = '/openssl-ca.pem'; + process.env.SSL_CERT_DIR = '/openssl-certs'; + const f = load({ legacy: true, opensslFile: trusted, opensslDirectory: '/openssl-certs', + opensslDirectoryCertificate: trusted }); + expect(f.open).toHaveBeenCalledWith('/openssl-certs/deadbeef.0', constants.O_RDONLY | constants.O_NONBLOCK); + expect(f.inspect).toHaveBeenCalledWith(42); + expect(f.read).toHaveBeenCalledWith(42, 'utf8'); + expect(f.read).not.toHaveBeenCalledWith('/openssl-certs/deadbeef.0', 'utf8'); + expect(f.close).toHaveBeenCalledWith(42); +}); + +test.each(['stat', 'read'])('closes the hashed CA descriptor when %s fails', source => { + process.env.NODE_OPTIONS = '--use-openssl-ca'; + process.env.SSL_CERT_FILE = '/openssl-ca.pem'; + process.env.SSL_CERT_DIR = '/openssl-certs'; + const error = Object.assign(new Error('hashed CA I/O failed'), { code: 'EIO' }); + expect(() => load({ + legacy: true, opensslFile: trusted, opensslDirectory: '/openssl-certs', + opensslCertificateError: source === 'stat' ? error : undefined, + opensslCertificateReadError: source === 'read' ? error : undefined, + })).toThrow('hashed CA I/O failed'); + expect(jest.requireMock('node:fs').closeSync).toHaveBeenCalledWith(42); +}); + +test('closes but never reads a non-regular hashed CA entry', () => { + process.env.NODE_OPTIONS = '--use-openssl-ca'; + process.env.SSL_CERT_FILE = '/openssl-ca.pem'; + process.env.SSL_CERT_DIR = '/openssl-certs'; + const f = load({ legacy: true, opensslFile: trusted, opensslDirectory: '/openssl-certs', + opensslDirectoryEntryIsFile: false }); + expect(f.open).toHaveBeenCalled(); + expect(f.read).not.toHaveBeenCalledWith(42, 'utf8'); + expect(f.close).toHaveBeenCalledWith(42); +}); + test('filters modern defaults and extra roots and installs the policy only once', () => { const f = load({ extra: blocked + '\n' + trusted }); expect(f.getCACertificates).toHaveBeenCalledWith('default'); @@ -127,18 +183,20 @@ test.each([false, true])('default and null CA options use sanitized defaults (le expect(f.setDefaultCACertificates).toHaveBeenCalledTimes(legacy ? 0 : 1); }); -test('legacy Node preserves OpenSSL-selected CA sources instead of bundled roots', () => { +test.each([false, true])('preserves OpenSSL-selected CA sources instead of bundled roots (legacy=%s)', legacy => { process.env.NODE_OPTIONS = '--use-openssl-ca'; process.env.SSL_CERT_FILE = '/openssl-ca.pem'; process.env.SSL_CERT_DIR = '/openssl-certs'; const f = load({ - legacy: true, + legacy, opensslFile: blocked, opensslDirectory: '/openssl-certs', opensslDirectoryCertificate: trusted, }); expect(f.read).toHaveBeenCalledWith('/openssl-ca.pem', 'utf8'); - expect(f.read).toHaveBeenCalledWith('/openssl-certs/deadbeef.0', 'utf8'); + expect(f.getCACertificates).not.toHaveBeenCalled(); + expect(f.open).toHaveBeenCalledWith('/openssl-certs/deadbeef.0', constants.O_RDONLY | constants.O_NONBLOCK); + expect(f.read).toHaveBeenCalledWith(42, 'utf8'); f.tls.createSecureContext(); expect(f.createSecureContext).toHaveBeenCalledWith({ ca: [trusted] }); }); diff --git a/src/tls-trust-policy.ts b/src/tls-trust-policy.ts index cb5c03dee..cb9e2aee1 100644 --- a/src/tls-trust-policy.ts +++ b/src/tls-trust-policy.ts @@ -1,6 +1,6 @@ import { createHash, X509Certificate } from 'node:crypto'; import { execFileSync } from 'node:child_process'; -import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { closeSync, constants, fstatSync, openSync, readFileSync, readdirSync } from 'node:fs'; import path from 'node:path'; import tls from 'node:tls'; @@ -65,7 +65,13 @@ function readSelectedOpenSslCertificates(): string[] { if (!/^[a-f0-9]{8}\.\d+$/i.test(entry)) continue; const candidate = path.join(dir, entry); try { - if (statSync(candidate).isFile()) certificates.push(readFileSync(candidate, 'utf8')); + // Pin the check/read to one handle without blocking on an unexpected FIFO. + const descriptor = openSync(candidate, constants.O_RDONLY | constants.O_NONBLOCK); + try { + if (fstatSync(descriptor).isFile()) certificates.push(readFileSync(descriptor, 'utf8')); + } finally { + closeSync(descriptor); + } } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } @@ -105,9 +111,9 @@ export function applyTlsTrustPolicy(): void { const usesOpenSslCa = /(?:^|\s)--use-openssl-ca(?:\s|$)/.test( `${process.execArgv.join(' ')} ${process.env.NODE_OPTIONS || ''}`, ); - const defaults = tls.getCACertificates - ? [...tls.getCACertificates('default')] - : usesOpenSslCa ? readSelectedOpenSslCertificates() : [...tls.rootCertificates]; + const defaults = usesOpenSslCa + ? readSelectedOpenSslCertificates() + : tls.getCACertificates ? [...tls.getCACertificates('default')] : [...tls.rootCertificates]; // Read explicitly as well: Node otherwise warns and ignores malformed or // missing NODE_EXTRA_CA_CERTS, which must not silently defeat private trust. if (process.env.NODE_EXTRA_CA_CERTS) {