diff --git a/actions/setup/sh/install_threat_detect_binary.sh b/actions/setup/sh/install_threat_detect_binary.sh index ff6a03f7e6d..7dc7151e55d 100755 --- a/actions/setup/sh/install_threat_detect_binary.sh +++ b/actions/setup/sh/install_threat_detect_binary.sh @@ -1,18 +1,22 @@ #!/usr/bin/env bash set +o histexpand -# Install the threat-detect binary from GitHub Releases with SHA256 checksum verification. +# Install the threat-detect binary from GitHub Releases with setup-action-pinned SHA256 verification. # Used when `features: gh-aw-detection: true` is set in the workflow frontmatter to enable # the external threat-detect binary detection path instead of inline engine execution. # -# Usage: install_threat_detect_binary.sh VERSION [--rootless] +# Usage: install_threat_detect_binary.sh VERSION [--sha256-amd64 DIGEST --sha256-arm64 DIGEST] [--rootless] # # Arguments: # VERSION - threat-detect version to install (e.g., v0.2.2) or "latest" to # install the latest release via GitHub's latest-release download endpoint -# --rootless - Install to ~/.local/bin without sudo; appends that directory to -# $GITHUB_PATH so subsequent steps find the binary. Use this on -# ARC/DinD runners that enforce allowPrivilegeEscalation: false. +# --sha256-amd64 - Expected SHA256 digest for the Linux amd64 binary (required +# when VERSION differs from the version pinned in this action) +# --sha256-arm64 - Expected SHA256 digest for the Linux arm64 binary (required +# when VERSION differs from the version pinned in this action) +# --rootless - Install to ~/.local/bin without sudo; appends that directory to +# $GITHUB_PATH so subsequent steps find the binary. Use this on +# ARC/DinD runners that enforce allowPrivilegeEscalation: false. # # Platform support: # - Linux (x64, arm64): Downloads pre-built binary @@ -24,7 +28,7 @@ set +o histexpand # # Security features: # - Downloads directly from GitHub releases -# - Verifies SHA256 checksum against official checksums.txt +# - Verifies SHA256 against setup-action-pinned, architecture-specific digests # - Fails fast if checksum verification fails set -euo pipefail @@ -34,28 +38,68 @@ THREAT_DETECT_REPO="github/gh-aw-threat-detection" THREAT_DETECT_INSTALL_DIR="/usr/local/bin" THREAT_DETECT_INSTALL_NAME="threat-detect" MACOS_FAQ_URL="https://github.github.com/gh-aw/reference/faq/#why-are-macos-runners-not-supported" +PINNED_THREAT_DETECT_VERSION="v0.5.1" +PINNED_THREAT_DETECT_SHA256_AMD64="1b27989fb52cbdc401e48137e508bea8b915aad4911468fb0fd9c87c2a7cd31b" +PINNED_THREAT_DETECT_SHA256_ARM64="204ba220229ac3fda80f7603b6e2a816f69e8a5ea2833c0ac534107bbffb827a" -# Parse arguments: treat the first non-flag argument as VERSION, all -- arguments as flags. +# Parse arguments. THREAT_DETECT_VERSION="" +THREAT_DETECT_SHA256_AMD64="" +THREAT_DETECT_SHA256_ARM64="" ROOTLESS=false -for arg in "$@"; do - case "$arg" in - --rootless) ROOTLESS=true ;; - --*) echo "WARNING: Unknown flag: $arg" >&2 ;; +while [ "$#" -gt 0 ]; do + case "$1" in + --rootless) + ROOTLESS=true + shift + ;; + --sha256-amd64|--sha256-arm64) + if [ "$#" -lt 2 ]; then + echo "ERROR: $1 requires a SHA256 digest" >&2 + exit 1 + fi + if [ "$1" = "--sha256-amd64" ]; then + THREAT_DETECT_SHA256_AMD64="$2" + else + THREAT_DETECT_SHA256_ARM64="$2" + fi + shift 2 + ;; + --*) + echo "ERROR: Unknown flag: $1" >&2 + exit 1 + ;; *) if [ -z "$THREAT_DETECT_VERSION" ]; then - THREAT_DETECT_VERSION="$arg" + THREAT_DETECT_VERSION="$1" + else + echo "ERROR: Unexpected argument: $1" >&2 + exit 1 fi + shift ;; esac done if [ -z "$THREAT_DETECT_VERSION" ]; then echo "ERROR: threat-detect version is required" - echo "Usage: $0 VERSION [--rootless]" + echo "Usage: $0 VERSION [--sha256-amd64 DIGEST --sha256-arm64 DIGEST] [--rootless]" exit 1 fi +# Use the digests embedded in this immutable action for the compiler-pinned version. +if [ "$THREAT_DETECT_VERSION" = "$PINNED_THREAT_DETECT_VERSION" ]; then + THREAT_DETECT_SHA256_AMD64="${THREAT_DETECT_SHA256_AMD64:-$PINNED_THREAT_DETECT_SHA256_AMD64}" + THREAT_DETECT_SHA256_ARM64="${THREAT_DETECT_SHA256_ARM64:-$PINNED_THREAT_DETECT_SHA256_ARM64}" +fi + +for digest in "$THREAT_DETECT_SHA256_AMD64" "$THREAT_DETECT_SHA256_ARM64"; do + if [[ ! "$digest" =~ ^[[:xdigit:]]{64}$ ]]; then + echo "ERROR: Valid SHA256 digests are required for both supported architectures" >&2 + exit 1 + fi +done + # In rootless mode, install into the user's home directory instead of /usr/local/bin # so that ARC/DinD runners with allowPrivilegeEscalation: false can run without sudo. if [ "$ROOTLESS" = "true" ]; then @@ -110,8 +154,6 @@ if [ "$THREAT_DETECT_VERSION" = "latest" ]; then else BASE_URL="https://github.com/${THREAT_DETECT_REPO}/releases/download/${THREAT_DETECT_VERSION}" fi -CHECKSUMS_URL="${BASE_URL}/checksums.txt" - # Platform-portable SHA256 function sha256_hash() { local file="$1" @@ -129,28 +171,20 @@ sha256_hash() { TEMP_DIR=$(mktemp -d) trap 'rm -rf "$TEMP_DIR"' EXIT -# Download checksums -echo "Downloading checksums from \"${CHECKSUMS_URL}\"..." -curl -fsSL --retry 5 --retry-delay 10 --retry-max-time 180 --retry-all-errors -o "${TEMP_DIR}/checksums.txt" "${CHECKSUMS_URL}" - verify_checksum() { local file="$1" local fname="$2" + local expected_checksum="$3" + local actual_checksum echo "Verifying SHA256 checksum for ${fname}..." - EXPECTED_CHECKSUM=$(awk -v fname="${fname}" '$2 == fname {print $1; exit}' "${TEMP_DIR}/checksums.txt" | tr 'A-F' 'a-f') + expected_checksum=$(printf '%s' "$expected_checksum" | tr 'A-F' 'a-f') + actual_checksum=$(sha256_hash "$file" | tr 'A-F' 'a-f') - if [ -z "$EXPECTED_CHECKSUM" ]; then - echo "ERROR: Could not find checksum for ${fname} in checksums.txt" - return 1 - fi - - ACTUAL_CHECKSUM=$(sha256_hash "$file" | tr 'A-F' 'a-f') - - if [ "$EXPECTED_CHECKSUM" != "$ACTUAL_CHECKSUM" ]; then + if [ "$expected_checksum" != "$actual_checksum" ]; then echo "ERROR: Checksum verification failed!" - echo " Expected: $EXPECTED_CHECKSUM" - echo " Got: $ACTUAL_CHECKSUM" + echo " Expected: $expected_checksum" + echo " Got: $actual_checksum" echo " The downloaded file may be corrupted or tampered with" return 1 fi @@ -161,9 +195,16 @@ verify_checksum() { install_linux_binary() { # Determine binary name based on architecture local binary_name + local expected_checksum case "$ARCH" in - x86_64|amd64) binary_name="threat-detect-linux-amd64" ;; - aarch64|arm64) binary_name="threat-detect-linux-arm64" ;; + x86_64|amd64) + binary_name="threat-detect-linux-amd64" + expected_checksum="$THREAT_DETECT_SHA256_AMD64" + ;; + aarch64|arm64) + binary_name="threat-detect-linux-arm64" + expected_checksum="$THREAT_DETECT_SHA256_ARM64" + ;; *) echo "ERROR: Unsupported Linux architecture: ${ARCH}"; exit 1 ;; esac @@ -172,7 +213,7 @@ install_linux_binary() { curl -fsSL --retry 5 --retry-delay 10 --retry-max-time 180 --retry-all-errors -o "${TEMP_DIR}/${binary_name}" "${binary_url}" # Verify checksum - verify_checksum "${TEMP_DIR}/${binary_name}" "${binary_name}" + verify_checksum "${TEMP_DIR}/${binary_name}" "${binary_name}" "${expected_checksum}" # Make binary executable and install chmod +x "${TEMP_DIR}/${binary_name}" diff --git a/actions/setup/sh/install_threat_detect_binary_test.sh b/actions/setup/sh/install_threat_detect_binary_test.sh index 38447933228..f4ff6e0df14 100644 --- a/actions/setup/sh/install_threat_detect_binary_test.sh +++ b/actions/setup/sh/install_threat_detect_binary_test.sh @@ -5,11 +5,12 @@ set +o histexpand # Run: bash install_threat_detect_binary_test.sh # # The tests run the real script with a stubbed `uname` (to fake the platform) and a -# stubbed `curl` (to record the requested asset URL and serve a fake binary plus a -# matching checksums.txt), so no network access is required. +# stubbed `curl` (to record the requested asset URL and serve a fake binary), so no +# network access is required. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" INSTALL_SCRIPT="${SCRIPT_DIR}/install_threat_detect_binary.sh" +VERSION_CONSTANTS="${SCRIPT_DIR}/../../../pkg/constants/version_constants.go" TESTS_PASSED=0 TESTS_FAILED=0 @@ -17,12 +18,13 @@ TESTS_FAILED=0 pass() { echo "PASS: $1"; TESTS_PASSED=$((TESTS_PASSED + 1)); } fail() { echo "FAIL: $1"; echo " $2"; TESTS_FAILED=$((TESTS_FAILED + 1)); } -# run_installer OS ARCH VERSION -> runs the installer in an isolated sandbox. +# run_installer OS ARCH VERSION [CHECKSUM_MODE] -> runs the installer in an isolated sandbox. # Sets globals: RUN_OUTPUT (stdout+stderr), RUN_STATUS (exit code), RUN_ASSET (downloaded asset name). run_installer() { local fake_os="$1" local fake_arch="$2" local version="$3" + local checksum_mode="${4:-valid}" local sandbox sandbox=$(mktemp -d) @@ -39,7 +41,7 @@ case "\$1" in esac EOF - # Stubbed curl records the requested asset and serves a fake binary + checksums.txt. + # Stubbed curl records the requested asset and serves a fake binary. cat >"${sandbox}/bin/curl" <<'EOF' #!/usr/bin/env bash out="" @@ -57,17 +59,10 @@ done payload='#!/usr/bin/env bash echo "fake threat-detect"' -name="${url##*/}" echo "${url}" >>"${SANDBOX_URL_LOG}" -if [ "$name" = "checksums.txt" ]; then - hash=$(printf '%s\n' "$payload" | sha256sum | awk '{print $1}') - for asset in threat-detect-linux-amd64 threat-detect-linux-arm64; do - echo "${hash} ${asset}" - done >"$out" -else - echo "$name" >>"${SANDBOX_ASSET_LOG}" - printf '%s\n' "$payload" >"$out" -fi +name="${url##*/}" +echo "$name" >>"${SANDBOX_ASSET_LOG}" +printf '%s\n' "$payload" >"$out" EOF chmod +x "${sandbox}/bin/uname" "${sandbox}/bin/curl" @@ -77,9 +72,22 @@ EOF : >"${asset_log}" : >"${url_log}" + local expected_hash + expected_hash=$(printf '%s\n' '#!/usr/bin/env bash' 'echo "fake threat-detect"' | sha256sum | awk '{print $1}') + local installer_args=("${version}" --rootless) + case "$checksum_mode" in + valid) + installer_args+=(--sha256-amd64 "$expected_hash" --sha256-arm64 "$expected_hash") + ;; + mismatch) + installer_args+=(--sha256-amd64 "$(printf '0%.0s' {1..64})" --sha256-arm64 "$(printf '0%.0s' {1..64})") + ;; + missing) ;; + esac + RUN_OUTPUT=$(cd "${sandbox}" && env PATH="${sandbox}/bin:${PATH}" HOME="${sandbox}" \ SANDBOX_ASSET_LOG="${asset_log}" SANDBOX_URL_LOG="${url_log}" GITHUB_PATH="" \ - bash "${INSTALL_SCRIPT}" "${version}" --rootless 2>&1) + bash "${INSTALL_SCRIPT}" "${installer_args[@]}" 2>&1) RUN_STATUS=$? RUN_ASSET=$(cat "${asset_log}") RUN_URLS=$(cat "${url_log}") @@ -149,21 +157,68 @@ assert_failure "Unknown OS is rejected" FreeBSD x86_64 "Unsupported operating sy echo "Test 7: unknown Linux architecture fails fast..." assert_failure "Unknown Linux architecture is rejected" Linux riscv64 "Unsupported Linux architecture" -# Test 8: latest release assets must be downloaded directly without the GitHub API. +# Test 8: latest release assets must be downloaded directly without the GitHub API or checksums file. echo "Test 8: latest release assets use direct downloads without the GitHub API..." run_installer Linux x86_64 latest if [ "${RUN_STATUS}" -ne 0 ]; then fail "Latest release installer succeeds" "installer exited with ${RUN_STATUS}: ${RUN_OUTPUT}" -elif ! echo "${RUN_URLS}" | grep -qF "https://github.com/github/gh-aw-threat-detection/releases/latest/download/checksums.txt"; then - fail "Latest release installer downloads checksums directly" "expected latest release checksum URL, got: ${RUN_URLS}" elif ! echo "${RUN_URLS}" | grep -qF "https://github.com/github/gh-aw-threat-detection/releases/latest/download/threat-detect-linux-amd64"; then fail "Latest release installer downloads the binary directly" "expected latest release binary URL, got: ${RUN_URLS}" +elif echo "${RUN_URLS}" | grep -qF "checksums.txt"; then + fail "Latest release installer avoids release-hosted checksums" "unexpected checksum URL: ${RUN_URLS}" elif echo "${RUN_URLS}" | grep -qF "api.github.com"; then fail "Latest release installer avoids GitHub API" "unexpected API URL: ${RUN_URLS}" else pass "Latest release installer uses direct downloads without GitHub API" fi +# Test 9: checksum mismatches fail closed. +echo "Test 9: checksum mismatch fails closed..." +run_installer Linux x86_64 v0.4.0 mismatch +if [ "${RUN_STATUS}" -eq 0 ]; then + fail "Checksum mismatch is rejected" "installer unexpectedly succeeded: ${RUN_OUTPUT}" +elif ! echo "${RUN_OUTPUT}" | grep -qF "Checksum verification failed"; then + fail "Checksum mismatch is rejected" "expected checksum failure in: ${RUN_OUTPUT}" +else + pass "Checksum mismatch is rejected" +fi + +# Test 10: an unpinned version without explicit digests fails before download. +echo "Test 10: missing digests fail closed before download..." +run_installer Linux x86_64 v0.4.0 missing +if [ "${RUN_STATUS}" -eq 0 ]; then + fail "Missing digests are rejected" "installer unexpectedly succeeded: ${RUN_OUTPUT}" +elif ! echo "${RUN_OUTPUT}" | grep -qF "Valid SHA256 digests are required"; then + fail "Missing digests are rejected" "expected digest validation failure in: ${RUN_OUTPUT}" +elif [ -n "${RUN_ASSET}" ]; then + fail "Missing digests are rejected before download" "installer attempted a binary download: ${RUN_ASSET}" +else + pass "Missing digests are rejected before download" +fi + +# Test 11: compiled workflows use the embedded digest and fail on mismatch. +echo "Test 11: pinned version remains secure without explicit digest arguments..." +run_installer Linux x86_64 v0.5.1 missing +if [ "${RUN_STATUS}" -eq 0 ]; then + fail "Pinned version uses embedded digest" "installer unexpectedly accepted the fake binary" +elif ! echo "${RUN_OUTPUT}" | grep -qF "Checksum verification failed"; then + fail "Pinned version uses embedded digest" "expected checksum failure in: ${RUN_OUTPUT}" +elif [ "${RUN_ASSET}" != "threat-detect-linux-amd64" ]; then + fail "Pinned version uses embedded digest" "expected one architecture-specific download, got: ${RUN_ASSET}" +else + pass "Pinned version uses embedded digest" +fi + +# Test 12: the installer and compiler pin the same threat-detect version. +echo "Test 12: installer digest version matches the compiler-pinned version..." +compiler_version=$(sed -n 's/^const DefaultThreatDetectVersion Version = "\(.*\)"/\1/p' "$VERSION_CONSTANTS") +installer_version=$(sed -n 's/^PINNED_THREAT_DETECT_VERSION="\(.*\)"/\1/p' "$INSTALL_SCRIPT") +if [ -z "$compiler_version" ] || [ "$installer_version" != "$compiler_version" ]; then + fail "Installer and compiler versions match" "compiler=${compiler_version:-missing}, installer=${installer_version:-missing}" +else + pass "Installer and compiler versions match" +fi + echo echo "===============================" echo "Tests passed: $TESTS_PASSED" diff --git a/docs/src/content/docs/reference/threat-detection.md b/docs/src/content/docs/reference/threat-detection.md index 6de60b9eddc..409cc67b9de 100644 --- a/docs/src/content/docs/reference/threat-detection.md +++ b/docs/src/content/docs/reference/threat-detection.md @@ -67,7 +67,9 @@ safe-outputs: The `features.gh-aw-detection` flag controls the detection implementation, not whether threat detection runs. The external `threat-detect` implementation is the default; set `features.gh-aw-detection: false` to select the legacy inline -engine implementation. +engine implementation. The external binary's version and per-architecture +SHA-256 digests are pinned in the immutable setup action, so installation does +not trust a checksum downloaded alongside the binary. > [!NOTE] > When a workflow explicitly sets `threat-detection: false`, that setting takes precedence over any imported fragments. Imported shared workflows that configure safe outputs without a `threat-detection` key will not re-enable threat detection in the importing workflow.