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/.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/.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/.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/README.md b/README.md index 065933533..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, @@ -90,6 +106,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..aff5eb34c 100644 --- a/containers/agent/entrypoint.sh +++ b/containers/agent/entrypoint.sh @@ -152,6 +152,7 @@ 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 @@ -1134,99 +1135,69 @@ 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 /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 [ -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" + 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 + [ ! -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..1450d74cb --- /dev/null +++ b/containers/agent/trust/filter-ca-store.sh @@ -0,0 +1,88 @@ +#!/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) +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 +: > "$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; 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; } +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_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") + 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..1992fd695 --- /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 /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 +# 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..b685e9c6f --- /dev/null +++ b/containers/agent/trust/node-trust.cjs @@ -0,0 +1,150 @@ +// 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_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 +// 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 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 { + // 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') + throw error; + } + } + } + return certificates; +} +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 usesOpenSslCa = /(?:^|\s)--use-openssl-ca(?:\s|$)/.test(`${process.execArgv.join(' ')} ${process.env.NODE_OPTIONS || ''}`); + 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) { + 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 = {}) => { + const normalizedOptions = options ?? {}; + return createSecureContext({ + ...normalizedOptions, + ca: normalizedOptions.ca + ? filterCaCertificates(normalizedOptions.ca) : filteredDefaults, + }); + }; + 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..1450d74cb --- /dev/null +++ b/containers/api-proxy/trust/filter-ca-store.sh @@ -0,0 +1,88 @@ +#!/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) +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 +: > "$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; 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; } +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_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") + 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..1992fd695 --- /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 /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 +# 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..b685e9c6f --- /dev/null +++ b/containers/api-proxy/trust/node-trust.cjs @@ -0,0 +1,150 @@ +// 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_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 +// 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 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 { + // 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') + throw error; + } + } + } + return certificates; +} +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 usesOpenSslCa = /(?:^|\s)--use-openssl-ca(?:\s|$)/.test(`${process.execArgv.join(' ')} ${process.env.NODE_OPTIONS || ''}`); + 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) { + 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 = {}) => { + const normalizedOptions = options ?? {}; + return createSecureContext({ + ...normalizedOptions, + ca: normalizedOptions.ca + ? filterCaCertificates(normalizedOptions.ca) : filteredDefaults, + }); + }; + 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..1450d74cb --- /dev/null +++ b/containers/build-tools/trust/filter-ca-store.sh @@ -0,0 +1,88 @@ +#!/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) +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 +: > "$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; 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; } +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_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") + 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..1992fd695 --- /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 /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 +# 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..1450d74cb --- /dev/null +++ b/containers/cli-proxy/trust/filter-ca-store.sh @@ -0,0 +1,88 @@ +#!/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) +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 +: > "$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; 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; } +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_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") + 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..1992fd695 --- /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 /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 +# 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..b685e9c6f --- /dev/null +++ b/containers/cli-proxy/trust/node-trust.cjs @@ -0,0 +1,150 @@ +// 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_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 +// 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 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 { + // 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') + throw error; + } + } + } + return certificates; +} +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 usesOpenSslCa = /(?:^|\s)--use-openssl-ca(?:\s|$)/.test(`${process.execArgv.join(' ')} ${process.env.NODE_OPTIONS || ''}`); + 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) { + 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 = {}) => { + const normalizedOptions = options ?? {}; + return createSecureContext({ + ...normalizedOptions, + ca: normalizedOptions.ca + ? filterCaCertificates(normalizedOptions.ca) : filteredDefaults, + }); + }; + 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..1450d74cb --- /dev/null +++ b/containers/dind-ubuntu/trust/filter-ca-store.sh @@ -0,0 +1,88 @@ +#!/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) +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 +: > "$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; 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; } +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_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") + 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..1992fd695 --- /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 /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 +# 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..b685e9c6f --- /dev/null +++ b/containers/dind-ubuntu/trust/node-trust.cjs @@ -0,0 +1,150 @@ +// 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_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 +// 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 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 { + // 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') + throw error; + } + } + } + return certificates; +} +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 usesOpenSslCa = /(?:^|\s)--use-openssl-ca(?:\s|$)/.test(`${process.execArgv.join(' ')} ${process.env.NODE_OPTIONS || ''}`); + 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) { + 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 = {}) => { + 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 4342adc07..54054d043 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,13 +72,17 @@ RUN chmod 0555 /usr/local/bin/run-enclave-agent \ FROM node:26.11.1-alpine3.24@sha256:143494b1da2945f061539253adc65e4f1569ddf07da2d384c022c791a9d90a4a AS enclave-mcp-server -ARG AWF_SOURCE_REVISION=unavailable - -RUN apk upgrade --no-cache \ +COPY trust/ /opt/awf/trust/ +RUN apk add --no-cache openssl ca-certificates \ + && 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/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..1450d74cb --- /dev/null +++ b/containers/gh-aw-node/trust/filter-ca-store.sh @@ -0,0 +1,88 @@ +#!/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) +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 +: > "$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; 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; } +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_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") + 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..1992fd695 --- /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 /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 +# 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..b685e9c6f --- /dev/null +++ b/containers/gh-aw-node/trust/node-trust.cjs @@ -0,0 +1,150 @@ +// 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_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 +// 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 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 { + // 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') + throw error; + } + } + } + return certificates; +} +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 usesOpenSslCa = /(?:^|\s)--use-openssl-ca(?:\s|$)/.test(`${process.execArgv.join(' ')} ${process.env.NODE_OPTIONS || ''}`); + 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) { + 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 = {}) => { + const normalizedOptions = options ?? {}; + return createSecureContext({ + ...normalizedOptions, + ca: normalizedOptions.ca + ? filterCaCertificates(normalizedOptions.ca) : filteredDefaults, + }); + }; + 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..1450d74cb --- /dev/null +++ b/containers/router/trust/filter-ca-store.sh @@ -0,0 +1,88 @@ +#!/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) +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 +: > "$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; 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; } +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_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") + 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..1992fd695 --- /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 /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 +# 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..1450d74cb --- /dev/null +++ b/containers/squid/trust/filter-ca-store.sh @@ -0,0 +1,88 @@ +#!/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) +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 +: > "$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; 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; } +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_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") + 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..1992fd695 --- /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 /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 +# 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..1450d74cb --- /dev/null +++ b/containers/trust/filter-ca-store.sh @@ -0,0 +1,88 @@ +#!/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) +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 +: > "$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; 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; } +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_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") + 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..1992fd695 --- /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 /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 +# 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..b685e9c6f --- /dev/null +++ b/containers/trust/node-trust.cjs @@ -0,0 +1,150 @@ +// 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_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 +// 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 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 { + // 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') + throw error; + } + } + } + return certificates; +} +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 usesOpenSslCa = /(?:^|\s)--use-openssl-ca(?:\s|$)/.test(`${process.execArgv.join(' ')} ${process.env.NODE_OPTIONS || ''}`); + 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) { + 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 = {}) => { + 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/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/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/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..990e0dc3d --- /dev/null +++ b/docs/security-ca-trust.md @@ -0,0 +1,119 @@ +# 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 -- 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. + +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. + +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/install.sh b/install.sh index 8de8df9b2..6c9505a59 100755 --- a/install.sh +++ b/install.sh @@ -3,27 +3,28 @@ 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: -# # Install latest version -# curl -sSL https://raw.githubusercontent.com/github/gh-aw-firewall/main/install.sh | sudo bash -# -# # Install specific version -# curl -sSL https://raw.githubusercontent.com/github/gh-aw-firewall/main/install.sh | sudo bash -s -- 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 +# 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.) -# - 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 +82,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 +98,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 +212,7 @@ set_version() { else get_latest_version fi + validate_version "$VERSION" } # Download file @@ -218,6 +226,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 +248,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 +349,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 +359,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 +383,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/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/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 } } } } } }, + }); +}); diff --git a/scripts/ci/release-bootstrap-provenance.test.ts b/scripts/ci/release-bootstrap-provenance.test.ts new file mode 100644 index 000000000..2956b8535 --- /dev/null +++ b/scripts/ci/release-bootstrap-provenance.test.ts @@ -0,0 +1,302 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { createHash } from 'crypto'; +import { execFileSync, 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('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); + 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/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 string[]; +}>('./generate-trust-policy.cjs'); +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('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); + +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('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); + 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('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({ + 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 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`)), + 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; + let appliedCa; + tls.createSecureContext = options => { appliedCa = options.ca; return original(options); }; + const {distrustedSpkis} = require(${JSON.stringify(path.join(policyDir, 'node-trust.cjs'))}); + 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, ['-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..1450d74cb --- /dev/null +++ b/scripts/security/filter-ca-store.sh @@ -0,0 +1,88 @@ +#!/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) +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 +: > "$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; 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; } +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_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") + 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..15768d9f8 --- /dev/null +++ b/scripts/security/generate-trust-policy.cjs @@ -0,0 +1,77 @@ +#!/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, '../..'); +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); + } + } + } + if (mismatched) process.exitCode = 1; +} + +if (require.main === module) generate(); + +module.exports = { extractDistrustedSpkis }; 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..1992fd695 --- /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 /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 +# 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..d0474e51e --- /dev/null +++ b/scripts/security/tls-runtime-smoke.cjs @@ -0,0 +1,103 @@ +// 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, null]) { + const allowed = name === 'private-awf'; + const script = ` + 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); + 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); + 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'), + AWF_MODE: mode, + AWF_PORT: String(port), + AWF_EXPLICIT: explicit === null ? 'null' : explicit ? '1' : '0', + AWF_ALLOWED: allowed ? '1' : '0', + AWF_INPUTS_PEM: 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/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/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.test.ts b/src/tls-trust-policy.test.ts new file mode 100644 index 000000000..87b1610b9 --- /dev/null +++ b/src/tls-trust-policy.test.ts @@ -0,0 +1,306 @@ +import { X509Certificate } from 'node:crypto'; +import { constants, 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; +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; + opensslCertificateReadError?: Error; + opensslDefaultDirectory?: string; + opensslDefaultPathOutput?: 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 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) { + throw Object.assign(new Error('file is missing'), { code: 'ENOENT' }); + } + 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' }); + } + 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], + getCACertificates: options.legacy ? undefined : getCACertificates, + setDefaultCACertificates: options.legacy || options.setter === false ? undefined : setDefaultCACertificates, + 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; + if (options.missingOpenSslDirectory) { + throw Object.assign(new Error('directory is missing'), { code: 'ENOENT' }); + } + return options.opensslDirectory || directory === '/openssl-default/certs' + ? ['deadbeef.0', 'README'] : []; + }), + })); + 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, execFileSync, nativeContext, + open, close, inspect, + }; +} + +beforeEach(() => { + jest.resetModules(); + 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('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'); + 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, null, { ca: null }, { ca: '' }]) { + // 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.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, + opensslFile: blocked, + opensslDirectory: '/openssl-certs', + opensslDirectoryCertificate: trusted, + }); + expect(f.read).toHaveBeenCalledWith('/openssl-ca.pem', '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] }); +}); + +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]]) { + 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'); +}); diff --git a/src/tls-trust-policy.ts b/src/tls-trust-policy.ts new file mode 100644 index 000000000..cb9e2aee1 --- /dev/null +++ b/src/tls-trust-policy.ts @@ -0,0 +1,139 @@ +import { createHash, X509Certificate } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { closeSync, constants, fstatSync, openSync, readFileSync, readdirSync } from 'node:fs'; +import path from 'node:path'; +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(); + +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 { + // 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; + } + } + } + return certificates; +} + +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 usesOpenSslCa = /(?:^|\s)--use-openssl-ca(?:\s|$)/.test( + `${process.execArgv.join(' ')} ${process.env.NODE_OPTIONS || ''}`, + ); + 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) { + 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 = {}) => { + const normalizedOptions = options ?? {}; + return createSecureContext({ + ...normalizedOptions, + ca: normalizedOptions.ca + ? filterCaCertificates(normalizedOptions.ca) : filteredDefaults, + }); + }; + installed = true; +} + +applyTlsTrustPolicy(); diff --git a/tests/entrypoint-phase-functions.test.sh b/tests/entrypoint-phase-functions.test.sh index 025fdc2ff..d89945161 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() { @@ -190,8 +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" - printf '%s\n' "fixture-ca-cert" > "${host_root}/etc/pki/tls/certs/ca-bundle.crt" + 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}" @@ -204,29 +217,38 @@ 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" ] - [ -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}" ] + [ "${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=$? + 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