Skip to content
Open
107 changes: 74 additions & 33 deletions actions/setup/sh/install_threat_detect_binary.sh
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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 --<flag> 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
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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

Expand All @@ -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}"
Expand Down
91 changes: 73 additions & 18 deletions actions/setup/sh/install_threat_detect_binary_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,26 @@ 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

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)
Expand All @@ -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=""
Expand All @@ -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"
Expand All @@ -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}")
Expand Down Expand Up @@ -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"
Expand Down
4 changes: 3 additions & 1 deletion docs/src/content/docs/reference/threat-detection.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading