diff --git a/.github/actions/deploy-prod/action.yml b/.github/actions/deploy-prod/action.yml index d4396b2d6..3782f617c 100644 --- a/.github/actions/deploy-prod/action.yml +++ b/.github/actions/deploy-prod/action.yml @@ -36,20 +36,18 @@ runs: using: composite steps: - name: ⚙️ Setup KSail - # Install the KSail CLI from the release tarball via curl. We use curl - # rather than brew/setup-ksail-cli because the v7.20.0 release shipped - # only desktop artifacts (linux CLI missing, 404 on the tarball). - # v7.25.0 ships complete linux artifacts again and carries the - # cluster-update fixes; renovate keeps this pin current. + # Install the KSail CLI from the release tarball and verify the + # downloaded asset against GitHub's published release checksum before + # running it with production credentials. We still use the tarball rather + # than brew/setup-ksail-cli because v7.20.0 shipped only desktop artifacts + # (linux CLI missing, 404 on the tarball); Renovate keeps this pin current. shell: bash env: # renovate: datasource=github-releases depName=devantler-tech/ksail extractVersion=^v(?.+)$ KSAIL_VERSION: "7.178.25" + GITHUB_TOKEN: ${{ github.token }} run: | - curl -fsSL "https://github.com/devantler-tech/ksail/releases/download/v${KSAIL_VERSION}/ksail_${KSAIL_VERSION}_linux_amd64.tar.gz" -o /tmp/ksail.tar.gz - tar -xzf /tmp/ksail.tar.gz -C /tmp - sudo install /tmp/ksail /usr/local/bin/ksail - ksail --version + .github/scripts/setup-ksail.sh - name: ⚙️ Setup talosctl # refresh-flux-ghcr-auth.sh uses the host API for a secret-safe, diff --git a/.github/scripts/setup-ksail.sh b/.github/scripts/setup-ksail.sh new file mode 100755 index 000000000..730d786e2 --- /dev/null +++ b/.github/scripts/setup-ksail.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ -z "${KSAIL_VERSION:-}" ]; then + echo "::error::KSAIL_VERSION must be set" + exit 1 +fi + +asset_name="ksail_${KSAIL_VERSION}_linux_amd64.tar.gz" +release_base="https://github.com/devantler-tech/ksail/releases/download/v${KSAIL_VERSION}" +api_url="https://api.github.com/repos/devantler-tech/ksail/releases/tags/v${KSAIL_VERSION}" +tarball="${RUNNER_TEMP:-/tmp}/${asset_name}" +checksums="${RUNNER_TEMP:-/tmp}/ksail_${KSAIL_VERSION}_checksums.txt" +release_json="${RUNNER_TEMP:-/tmp}/ksail-release-${KSAIL_VERSION}.json" + +curl_headers=(-H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28") +if [ -n "${GITHUB_TOKEN:-}" ]; then + curl_headers+=(-H "Authorization: Bearer ${GITHUB_TOKEN}") +fi + +curl -fsSL "${release_base}/${asset_name}" -o "${tarball}" +curl -fsSL "${release_base}/ksail_${KSAIL_VERSION}_checksums.txt" -o "${checksums}" +curl -fsSL "${curl_headers[@]}" "${api_url}" -o "${release_json}" + +manifest_digest=$(awk -v asset="${asset_name}" '$2 == asset {print $1}' "${checksums}") +if [ -z "${manifest_digest}" ]; then + echo "::error::no published checksum for ${asset_name} — refusing to install unverified" + exit 1 +fi + +metadata_digest=$( + jq -r --arg asset "${asset_name}" \ + '[.assets[] | select(.name == $asset) | .digest | select(type == "string" and startswith("sha256:"))][0] // empty | sub("^sha256:"; "")' \ + "${release_json}" +) +if [ -z "${metadata_digest}" ]; then + echo "::error::no sha256 digest for ${asset_name} in GitHub release metadata — refusing to install unverified" + exit 1 +fi + +actual_digest=$(sha256sum "${tarball}" | cut -d' ' -f1) +if [ "${metadata_digest}" != "${actual_digest}" ]; then + echo "::error::release metadata digest mismatch for ${asset_name}: expected ${metadata_digest}, got ${actual_digest}" + exit 1 +fi + +if [ "${manifest_digest}" != "${actual_digest}" ]; then + echo "::error::checksum manifest mismatch for ${asset_name}: expected ${manifest_digest}, got ${actual_digest}" + exit 1 +fi + +tar -xzf "${tarball}" -C "${RUNNER_TEMP:-/tmp}" ksail +sudo install "${RUNNER_TEMP:-/tmp}/ksail" /usr/local/bin/ksail +ksail --version diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b2edda395..678efde70 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -97,6 +97,8 @@ jobs: - 'talos/**' - 'scripts/validate-naming.py' - 'scripts/validate-embedded-json.py' + - '.github/scripts/setup-ksail.sh' + - 'scripts/tests/test-setup-ksail.sh' - 'scripts/tests/test-openbao-oidc-role.sh' - 'scripts/tests/test-github-config-role-activation-parity.sh' - 'scripts/tests/test-restrict-tenant-secret-stores.sh' @@ -303,6 +305,12 @@ jobs: go-version-file: go.mod cache: false + - name: 🔐 Validate KSail installer integrity guard + if: needs.changes.outputs.k8s == 'true' + run: | + shellcheck .github/scripts/setup-ksail.sh scripts/tests/test-setup-ksail.sh + bash scripts/tests/test-setup-ksail.sh + - name: 🔐 Validate Flux GHCR credential bridge # Pure local tests with fake ksail/docker/kubectl commands: no SOPS key, # registry credential, cluster access, or network is used. @@ -556,48 +564,9 @@ jobs: env: # renovate: datasource=github-releases depName=devantler-tech/ksail extractVersion=^v(?.+)$ KSAIL_VERSION: "7.178.25" + GITHUB_TOKEN: ${{ github.token }} run: | - set -euo pipefail - BASE="https://github.com/devantler-tech/ksail/releases/download/v${KSAIL_VERSION}" - ASSET="ksail_${KSAIL_VERSION}_linux_amd64.tar.gz" - curl -fsSL "${BASE}/${ASSET}" -o /tmp/ksail.tar.gz - # A version-pinned URL names WHICH asset to fetch, not WHAT BYTES to - # expect. This archive is `sudo install`ed and executed on the runner, - # and this job runs on `pull_request` including FORK PRs, so a replaced - # release asset would execute here on every contributor's build. - # (This job is `pull_request`-only — the prod deploy paths install the - # same archive separately and are tracked in #3075.) - # - # Checked against the published list rather than a literal SHA pinned - # here: Renovate bumps KSAIL_VERSION and cannot update an opaque digest - # beside it, so a pinned SHA would break every bump until hand-edited — - # a control that reliably breaks the routine path is one that gets - # removed. LIMIT, stated plainly: archive and checksum list share an - # origin, so this detects a corrupted, truncated or individually - # substituted asset, not a fully compromised release. - # - # Kept byte-identical to the same block in validate-main.yaml. - curl -fsSL "${BASE}/ksail_${KSAIL_VERSION}_checksums.txt" -o /tmp/ksail_checksums.txt - # Match the filename field EXACTLY. A regex match treats the dots in - # the asset name as metacharacters, so `ksail_7x178x20_…` would select - # a different line's digest and report a confusing mismatch instead of - # the actionable "no published checksum" below. awk also exits 0 with - # no match, so the empty-result check is the single fail-closed path. - expected=$(awk -v a="${ASSET}" '$2 == a {print $1}' /tmp/ksail_checksums.txt) - # Fail closed on a MISSING entry too — otherwise a renamed asset - # silently skips verification instead of failing. - if [ -z "$expected" ]; then - echo "::error::no published checksum for ${ASSET} — refusing to install unverified" - exit 1 - fi - actual=$(sha256sum /tmp/ksail.tar.gz | cut -d' ' -f1) - if [ "$expected" != "$actual" ]; then - echo "::error::checksum mismatch for ${ASSET}: expected ${expected}, got ${actual}" - exit 1 - fi - tar -xzf /tmp/ksail.tar.gz -C /tmp - sudo install /tmp/ksail /usr/local/bin/ksail - ksail --version + .github/scripts/setup-ksail.sh - name: 📥 Restore kubeconform schema cache # ksail's kubeconform client fetches JSON schemas from diff --git a/.github/workflows/dr-rebuild.yaml b/.github/workflows/dr-rebuild.yaml index 87dce1d7b..dafb210a1 100644 --- a/.github/workflows/dr-rebuild.yaml +++ b/.github/workflows/dr-rebuild.yaml @@ -149,16 +149,14 @@ jobs: persist-credentials: false - name: ⚙️ Setup KSail - # Same install path as ci.yaml's deploy-prod job; renovate keeps the - # pin current in both places. + # Same verified install path as ci.yaml's validate job and the + # deploy-prod action; renovate keeps the pin current in all places. env: # renovate: datasource=github-releases depName=devantler-tech/ksail extractVersion=^v(?.+)$ KSAIL_VERSION: "7.178.25" + GITHUB_TOKEN: ${{ github.token }} run: | - curl -fsSL "https://github.com/devantler-tech/ksail/releases/download/v${KSAIL_VERSION}/ksail_${KSAIL_VERSION}_linux_amd64.tar.gz" -o /tmp/ksail.tar.gz - tar -xzf /tmp/ksail.tar.gz -C /tmp - sudo install /tmp/ksail /usr/local/bin/ksail - ksail --version + .github/scripts/setup-ksail.sh - name: ⚙️ Setup talosctl # The GHCR bridge verifies RegistryAuthConfig on each stale node. diff --git a/.github/workflows/validate-main.yaml b/.github/workflows/validate-main.yaml index ff9a16018..128a4e94e 100644 --- a/.github/workflows/validate-main.yaml +++ b/.github/workflows/validate-main.yaml @@ -133,46 +133,9 @@ jobs: env: # renovate: datasource=github-releases depName=devantler-tech/ksail extractVersion=^v(?.+)$ KSAIL_VERSION: "7.178.25" + GITHUB_TOKEN: ${{ github.token }} run: | - set -euo pipefail - BASE="https://github.com/devantler-tech/ksail/releases/download/v${KSAIL_VERSION}" - ASSET="ksail_${KSAIL_VERSION}_linux_amd64.tar.gz" - curl -fsSL "${BASE}/${ASSET}" -o /tmp/ksail.tar.gz - # A version-pinned URL is not an integrity check: this archive is - # `sudo install`ed and executed on a trusted `main` workflow, so a - # replaced release asset would run here. Verify against the release's - # published checksums before extracting. - # - # Checked against the published list rather than a literal SHA pinned - # in this file: Renovate bumps KSAIL_VERSION and cannot update an - # opaque digest beside it, so a pinned SHA would break every bump - # until a human hand-edited it — a control people end up removing. - # This costs nothing per bump. Its LIMIT, stated plainly: the archive - # and the checksum list come from the same origin, so this detects a - # corrupted, truncated or individually-substituted asset, not a fully - # compromised release. Provenance attestations would close that and - # are tracked separately. - curl -fsSL "${BASE}/ksail_${KSAIL_VERSION}_checksums.txt" -o /tmp/ksail_checksums.txt - # Match the filename field EXACTLY. A regex match treats the dots in - # the asset name as metacharacters, so `ksail_7x178x20_…` would select - # a different line's digest and report a confusing mismatch instead of - # the actionable "no published checksum" below. awk also exits 0 with - # no match, so the empty-result check is the single fail-closed path. - expected=$(awk -v a="${ASSET}" '$2 == a {print $1}' /tmp/ksail_checksums.txt) - # Fail closed on a MISSING entry too — otherwise a renamed asset - # silently skips verification instead of failing. - if [ -z "$expected" ]; then - echo "::error::no published checksum for ${ASSET} — refusing to install unverified" - exit 1 - fi - actual=$(sha256sum /tmp/ksail.tar.gz | cut -d' ' -f1) - if [ "$expected" != "$actual" ]; then - echo "::error::checksum mismatch for ${ASSET}: expected ${expected}, got ${actual}" - exit 1 - fi - tar -xzf /tmp/ksail.tar.gz -C /tmp - sudo install /tmp/ksail /usr/local/bin/ksail - ksail --version + .github/scripts/setup-ksail.sh # The guard runs HERE too, not only in ci.yaml. A direct push to main # bypasses the merge queue, so without this a push that reduced the scan diff --git a/scripts/tests/test-setup-ksail.sh b/scripts/tests/test-setup-ksail.sh new file mode 100755 index 000000000..8e2699e1d --- /dev/null +++ b/scripts/tests/test-setup-ksail.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(git rev-parse --show-toplevel) +test_root=$(mktemp -d "${TMPDIR:-/tmp}/test-setup-ksail.XXXXXX") +trap 'rm -rf "${test_root}"' EXIT + +fixture_dir="${test_root}/fixtures" +fake_bin="${test_root}/bin" +runner_temp="${test_root}/runner" +mkdir -p "${fixture_dir}" "${fake_bin}" "${runner_temp}" + +printf '#!/usr/bin/env bash\nexit 0\n' >"${fixture_dir}/ksail" +chmod +x "${fixture_dir}/ksail" +tar -czf "${fixture_dir}/ksail.tar.gz" -C "${fixture_dir}" ksail +digest=$(sha256sum "${fixture_dir}/ksail.tar.gz" | cut -d' ' -f1) + +printf '{"assets":[{"name":"ksail_7.178.25_linux_amd64.tar.gz","digest":"sha256:%s"}]}\n' \ + "${digest}" >"${fixture_dir}/release.json" +printf '%s ksail_7x178x25_linux_amd64.tar.gz\n' "${digest}" >"${fixture_dir}/checksums.txt" + +cat >"${fake_bin}/curl" <<'FAKE_CURL' +#!/usr/bin/env bash +set -euo pipefail + +output='' +url='' +while [ "$#" -gt 0 ]; do + case "$1" in + -o) + output=$2 + shift 2 + ;; + -H) + if [ "$2" = 'Authorization: Bearer test-token' ]; then + touch "${TEST_AUTH_MARKER}" + fi + shift 2 + ;; + -*) shift ;; + *) + url=$1 + shift + ;; + esac +done + +case "${url}" in + */ksail_7.178.25_linux_amd64.tar.gz) + cp "${TEST_FIXTURE_DIR}/ksail.tar.gz" "${output}" + ;; + */releases/tags/v7.178.25) + cp "${TEST_FIXTURE_DIR}/release.json" "${output}" + ;; + */ksail_7.178.25_checksums.txt) + cp "${TEST_FIXTURE_DIR}/checksums.txt" "${output}" + ;; + *) + printf 'unexpected URL: %s\n' "${url}" >&2 + exit 2 + ;; +esac +FAKE_CURL + +cat >"${fake_bin}/sudo" <<'FAKE_SUDO' +#!/usr/bin/env bash +set -euo pipefail +touch "${TEST_INSTALL_MARKER}" +FAKE_SUDO + +cat >"${fake_bin}/ksail" <<'FAKE_KSAIL' +#!/usr/bin/env bash +exit 0 +FAKE_KSAIL + +chmod +x \ + "${fake_bin}/curl" \ + "${fake_bin}/sudo" \ + "${fake_bin}/ksail" + +run_installer() { + if output=$( + cd "${repo_root}" && + PATH="${fake_bin}:${PATH}" \ + RUNNER_TEMP="${runner_temp}" \ + KSAIL_VERSION='7.178.25' \ + GITHUB_TOKEN='test-token' \ + TEST_FIXTURE_DIR="${fixture_dir}" \ + TEST_AUTH_MARKER="${test_root}/authenticated" \ + TEST_INSTALL_MARKER="${test_root}/installed" \ + .github/scripts/setup-ksail.sh 2>&1 + ); then + status=0 + else + status=$? + fi +} + +assert_rejected_before_install() { + expected_output=$1 + + if [ "${status}" -eq 0 ]; then + printf 'expected setup-ksail.sh to reject invalid release evidence\n' >&2 + exit 1 + fi + + if [ -e "${test_root}/installed" ]; then + printf 'setup-ksail.sh attempted installation before release verification\n' >&2 + exit 1 + fi + + case "${output}" in + *"${expected_output}"*) ;; + *) + printf 'unexpected failure output: %s\n' "${output}" >&2 + exit 1 + ;; + esac +} + +assert_callsite_passes_token() { + callsite=$1 + + if ! awk ' + /GITHUB_TOKEN:.*github\.token/ { token_line = NR } + /\.github\/scripts\/setup-ksail\.sh/ && token_line && NR - token_line <= 10 { found = 1 } + END { exit !found } + ' "${callsite}"; then + printf 'setup-ksail call site does not pass github.token: %s\n' "${callsite}" >&2 + exit 1 + fi +} + +for callsite in \ + .github/actions/deploy-prod/action.yml \ + .github/workflows/ci.yaml \ + .github/workflows/dr-rebuild.yaml \ + .github/workflows/validate-main.yaml; do + assert_callsite_passes_token "${callsite}" +done + +run_installer +assert_rejected_before_install 'no published checksum for ksail_7.178.25_linux_amd64.tar.gz' + +if [ ! -e "${test_root}/authenticated" ]; then + printf 'setup-ksail.sh did not authenticate the release-metadata request\n' >&2 + exit 1 +fi + +printf '%s ksail_7.178.25_linux_amd64.tar.gz\n' "${digest}" >"${fixture_dir}/checksums.txt" +printf '{"assets":[{"name":"ksail_7.178.25_linux_amd64.tar.gz","digest":"sha256:%064d"}]}\n' \ + 0 >"${fixture_dir}/release.json" + +run_installer +assert_rejected_before_install 'release metadata digest mismatch for ksail_7.178.25_linux_amd64.tar.gz' + +printf 'setup-ksail dual-source verification tests passed\n'