Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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