diff --git a/.github/workflows/reusable-secrets-check.yml b/.github/workflows/reusable-secrets-check.yml index 7c7d94a..0473f09 100644 --- a/.github/workflows/reusable-secrets-check.yml +++ b/.github/workflows/reusable-secrets-check.yml @@ -6,6 +6,33 @@ # license for organizations). # # No inputs required — always runs with default config +# +# Supply-chain hardening (GATOR-3555): +# - gitleaks is pinned to one version and its tarball is checked against a +# sha256 HARDCODED below (not the release's own checksums file, which an +# attacker who controls the release could replace too). A mismatch fails +# the job before anything is extracted. +# - actions/checkout is pinned to a commit SHA, and the job token is not +# persisted into .git/config. +# - The SARIF upload step was removed: no caller grants the job +# `security-events: write` (callers grant `contents: read`, and the org +# default token is read-only), so it failed on every run behind +# `continue-on-error`. Findings are reported in the job log (--verbose +# --redact) and the job fails on a hit, as before. +# +# Bumping gitleaks (manual; Dependabot does not track it): +# 1. Pick a stable release at least 7 days old: +# gh api repos/gitleaks/gitleaks/releases --jq '.[] | .tag_name+" "+.published_at' +# 2. Take the linux_x64 tarball's sha256 from TWO sources and check they match: +# gh api repos/gitleaks/gitleaks/releases/tags/vX.Y.Z \ +# --jq '.assets[] | select(.name|endswith("_linux_x64.tar.gz")) | .digest' +# curl -fsSLO https://github.com/gitleaks/gitleaks/releases/download/vX.Y.Z/gitleaks_X.Y.Z_linux_x64.tar.gz +# sha256sum gitleaks_X.Y.Z_linux_x64.tar.gz # (macOS: shasum -a 256) +# 3. Update GITLEAKS_VERSION and GITLEAKS_SHA256 together, in a reviewed PR. +# actions/checkout is bumped by Dependabot (.github/dependabot.yml), which +# updates the SHA and its version comment. +# +# Regression test: self-test-secrets-check.yml (lint, fail-closed install, smoke). name: Secrets Check @@ -15,35 +42,39 @@ on: jobs: secrets-check: runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 # Full history for scanning + persist-credentials: false - name: Install Gitleaks + shell: bash env: - GH_TOKEN: ${{ github.token }} + GITLEAKS_VERSION: "8.30.1" + GITLEAKS_SHA256: "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb" run: | - GITLEAKS_VERSION=$(curl -sH "Authorization: Bearer $GH_TOKEN" \ - https://api.github.com/repos/gitleaks/gitleaks/releases/latest \ - | grep '"tag_name"' | sed 's/.*"v\(.*\)".*/\1/') - if [ -z "$GITLEAKS_VERSION" ]; then - echo "::error::Failed to fetch latest gitleaks version (API rate limit?)" - exit 1 - fi - curl -sSLo gitleaks.tar.gz "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" - tar -xzf gitleaks.tar.gz gitleaks - chmod +x gitleaks - sudo mv gitleaks /usr/local/bin/ + set -euo pipefail + tarball="gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" + dldir="${RUNNER_TEMP}/gitleaks-download" + bindir="${RUNNER_TEMP}/gitleaks/bin" + mkdir -p "${dldir}" + cd "${dldir}" + curl -fsSL --proto '=https' --proto-redir '=https' --tlsv1.2 --retry 3 -o "${tarball}" \ + "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/${tarball}" + # The install dir is created only after the hash check passes. + echo "${GITLEAKS_SHA256} ${tarball}" | sha256sum --check --strict - + mkdir -p "${bindir}" + tar -xzf "${tarball}" -C "${bindir}" --no-same-owner gitleaks + chmod 0755 "${bindir}/gitleaks" + echo "${bindir}" >> "${GITHUB_PATH}" - name: Run Gitleaks + shell: bash run: | + set -euo pipefail + gitleaks version gitleaks detect --source . --verbose --redact --report-format sarif --report-path gitleaks-report.sarif - - - name: Upload SARIF report - if: always() - uses: github/codeql-action/upload-sarif@v4 - with: - sarif_file: gitleaks-report.sarif - continue-on-error: true # Don't fail if Code Security is not enabled diff --git a/.github/workflows/self-test-secrets-check.yml b/.github/workflows/self-test-secrets-check.yml new file mode 100644 index 0000000..a1b8131 --- /dev/null +++ b/.github/workflows/self-test-secrets-check.yml @@ -0,0 +1,170 @@ +# Self-test for reusable-secrets-check.yml (GATOR-3555) +# +# A committed regression test for the supply-chain properties of the +# reusable secrets check: +# lint - no floating gitleaks lookup (releases/latest), every +# `uses:` pinned to a 40-hex commit SHA, checkout with +# persist-credentials: false, a 64-hex GITLEAKS_SHA256, and +# no token or secret reference. +# fail-closed - the Install step's own script, run with a wrong sha256, +# exits non-zero after the download and before anything is +# installed; run unmodified, it installs the pinned version. +# smoke - the reusable workflow at this commit scans this repo and +# passes. +# +# yq here is mikefarah yq v4, preinstalled on ubuntu-latest. + +name: Self-test Secrets Check + +on: + pull_request: + paths: + - .github/workflows/reusable-secrets-check.yml + - .github/workflows/self-test-secrets-check.yml + push: + branches: + - main + paths: + - .github/workflows/reusable-secrets-check.yml + - .github/workflows/self-test-secrets-check.yml + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Lint reusable-secrets-check.yml + shell: bash + env: + TARGET: .github/workflows/reusable-secrets-check.yml + run: | + set -euo pipefail + yq --version | grep -q 'mikefarah' || { echo "::error::mikefarah yq v4 is required"; exit 1; } + [ -f "${TARGET}" ] || { echo "::error::${TARGET} not found"; exit 1; } + yq -e '.jobs' "${TARGET}" > /dev/null + + fail=0 + violation() { + echo "::error file=${TARGET}::VIOLATION: $1" + fail=1 + } + + # 1. No floating version lookup. + while IFS= read -r line; do + violation "floating release lookup: ${line}" + done < <(grep -n 'releases/latest' "${TARGET}" || true) + + # 2. Every uses: is pinned to a full commit SHA. + n_uses=0 + while IFS= read -r ref; do + n_uses=$((n_uses + 1)) + [[ "${ref}" =~ @[0-9a-f]{40}$ ]] || violation "uses: '${ref}' is not pinned to a 40-hex commit SHA" + done < <(yq '.. | select(tag == "!!map" and has("uses")) | .uses' "${TARGET}") + [ "${n_uses}" -gt 0 ] || violation "no uses: entries found" + + # 3. Every checkout drops the token. + n_checkout=0 + while IFS= read -r pc; do + n_checkout=$((n_checkout + 1)) + [ "${pc}" = "false" ] || violation "actions/checkout without persist-credentials: false (got '${pc}')" + done < <(yq '.. | select(tag == "!!map" and has("uses")) | select(.uses | test("^actions/checkout@")) | .with["persist-credentials"]' "${TARGET}") + [ "${n_checkout}" -gt 0 ] || violation "no actions/checkout step found" + + # 4. Exactly one hardcoded 64-hex GITLEAKS_SHA256. + n_sha=0 + while IFS= read -r sha; do + n_sha=$((n_sha + 1)) + [[ "${sha}" =~ ^[0-9a-f]{64}$ ]] || violation "GITLEAKS_SHA256 is not 64 lowercase hex: '${sha}'" + done < <(yq '.. | select(tag == "!!map" and has("GITLEAKS_SHA256")) | .GITLEAKS_SHA256' "${TARGET}") + [ "${n_sha}" -eq 1 ] || violation "expected exactly one GITLEAKS_SHA256, found ${n_sha}" + + # 5. No token or secret reference. + while IFS= read -r line; do + violation "token or secret reference: ${line}" + done < <(grep -nE 'secrets\.|GH_TOKEN|github\.token' "${TARGET}" || true) + + [ "${fail}" -eq 0 ] || exit 1 + echo "lint: OK (${n_uses} uses, ${n_checkout} checkout, ${n_sha} GITLEAKS_SHA256)" + + fail-closed: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Extract the Install Gitleaks script + shell: bash + env: + TARGET: .github/workflows/reusable-secrets-check.yml + run: | + set -euo pipefail + yq --version | grep -q 'mikefarah' || { echo "::error::mikefarah yq v4 is required"; exit 1; } + step='.jobs[].steps[] | select(.name == "Install Gitleaks")' + n=$(yq "[${step}] | length" "${TARGET}") + [ "${n}" = "1" ] || { echo "::error::expected one Install Gitleaks step, found ${n}"; exit 1; } + yq "${step} | .run" "${TARGET}" > "${RUNNER_TEMP}/install-gitleaks.sh" + version=$(yq "${step} | .env.GITLEAKS_VERSION" "${TARGET}") + sha=$(yq "${step} | .env.GITLEAKS_SHA256" "${TARGET}") + [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "::error::bad GITLEAKS_VERSION '${version}'"; exit 1; } + [[ "${sha}" =~ ^[0-9a-f]{64}$ ]] || { echo "::error::bad GITLEAKS_SHA256"; exit 1; } + { + echo "PINNED_VERSION=${version}" + echo "PINNED_SHA256=${sha}" + } >> "${GITHUB_ENV}" + echo "extracted: $(wc -l < "${RUNNER_TEMP}/install-gitleaks.sh") lines, version ${version}" + + - name: Wrong sha256 fails closed + shell: bash + run: | + set -euo pipefail + sandbox=$(mktemp -d "${RUNNER_TEMP}/neg.XXXXXX") + : > "${sandbox}/github_path" + zeros=$(printf '%064d' 0) + set +e + out=$(env RUNNER_TEMP="${sandbox}" GITHUB_PATH="${sandbox}/github_path" \ + GITLEAKS_VERSION="${PINNED_VERSION}" GITLEAKS_SHA256="${zeros}" \ + bash "${RUNNER_TEMP}/install-gitleaks.sh" 2>&1) + rc=$? + # errexit stays off through the checks so every one reports. + printf '%s\n' "${out}" + fail=0 + check() { + if [ "$2" = "0" ]; then echo "ok: $1"; else echo "::error::FAILED: $1"; fail=1; fi + } + [ "${rc}" -ne 0 ]; check "exits non-zero (rc=${rc})" "$?" + [ -s "${sandbox}/gitleaks-download/gitleaks_${PINNED_VERSION}_linux_x64.tar.gz" ] + check "the tarball was downloaded, so the hash check is what failed" "$?" + grep -q ': FAILED' <<< "${out}"; check "sha256sum reported the mismatch" "$?" + [ ! -e "${sandbox}/gitleaks" ]; check "RUNNER_TEMP/gitleaks does not exist" "$?" + [ ! -s "${sandbox}/github_path" ]; check "nothing was added to GITHUB_PATH" "$?" + if command -v gitleaks; then on_path=1; else on_path=0; fi + check "gitleaks is not on PATH" "${on_path}" + exit "${fail}" + + - name: Correct sha256 installs the pinned version + shell: bash + run: | + set -euo pipefail + sandbox=$(mktemp -d "${RUNNER_TEMP}/pos.XXXXXX") + : > "${sandbox}/github_path" + env RUNNER_TEMP="${sandbox}" GITHUB_PATH="${sandbox}/github_path" \ + GITLEAKS_VERSION="${PINNED_VERSION}" GITLEAKS_SHA256="${PINNED_SHA256}" \ + bash "${RUNNER_TEMP}/install-gitleaks.sh" + [ "$(cat "${sandbox}/github_path")" = "${sandbox}/gitleaks/bin" ] || { echo "::error::GITHUB_PATH not set to the install dir"; exit 1; } + got=$("${sandbox}/gitleaks/bin/gitleaks" version) + echo "gitleaks version: ${got}" + [ "${got}" = "${PINNED_VERSION}" ] || { echo "::error::expected ${PINNED_VERSION}, got ${got}"; exit 1; } + + smoke: + uses: ./.github/workflows/reusable-secrets-check.yml