diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml new file mode 100644 index 00000000..b4281b88 --- /dev/null +++ b/.github/workflows/secret-scan.yml @@ -0,0 +1,129 @@ +# Secret scanning for clean CI checkouts. +# +# The leaktk.git.pre-commit hook only scans staged changes and ignores the +# filenames passed by pre-commit, so it is a no-op on a clean CI checkout: +# a green "pre-commit run --all-files" is not evidence that PR content was +# scanned. This job closes that gap. +# +# Scope: the working tree of the fresh pull_request checkout, which is the +# tracked content at the merge HEAD. It is not a scan of git history and it +# does not depend on the index being populated. +# +# Behavior: fail-closed. Findings, scanner errors, missing, invalid or +# non-correlated responses and timeouts all fail the job. Scanner stdout and +# stderr are captured and never printed; findings are reported with rule, +# path and line only. +# +# The LeakTK revision is the one pinned by this repository in +# .pre-commit-config.yaml (full commit SHA required; tag-only pins are +# rejected). This is a secondary, non-required gate. +name: secret-scan + +on: + pull_request: + +permissions: {} + +concurrency: + group: secret-scan-${{ github.ref }} + cancel-in-progress: true + +jobs: + secret-scan: + permissions: + contents: read + runs-on: ubuntu-24.04 + timeout-minutes: 15 + env: + CGO_ENABLED: "0" + LEAKTK_ID: ci-secret-scan + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + cache: false + + - name: Resolve the pinned LeakTK revision + run: | + set -euo pipefail + LEAKTK_REV=$(awk ' + /^[[:space:]]*-[[:space:]]*repo:[[:space:]]*https:\/\/github\.com\/leaktk\/leaktk[[:space:]]*$/ { in_repo = 1; next } + in_repo && /^[[:space:]]*rev:/ { print $2; exit } + in_repo && /^[[:space:]]*-[[:space:]]*repo:/ { in_repo = 0 } + ' .pre-commit-config.yaml | tr -d '"') + if [ -z "${LEAKTK_REV:-}" ]; then + echo "::error::No leaktk rev found in .pre-commit-config.yaml. Adopting the secret scan in a repository without LeakTK requires an explicit scope decision; refusing to scan silently." + exit 1 + fi + if ! printf '%s' "$LEAKTK_REV" | grep -qE '^[0-9a-f]{40}$'; then + echo "::error::The leaktk rev in .pre-commit-config.yaml must be a full 40-character commit SHA; got: ${LEAKTK_REV}. Tag-only pins are rejected." + exit 1 + fi + echo "LeakTK revision from .pre-commit-config.yaml: ${LEAKTK_REV}" + echo "LEAKTK_REV=${LEAKTK_REV}" >> "$GITHUB_ENV" + + - name: Install LeakTK at the pinned revision + run: | + set -euo pipefail + LEAKTK_BIN="$RUNNER_TEMP/leaktk-bin/leaktk" + mkdir -p "$(dirname "$LEAKTK_BIN")" + GOBIN="$(dirname "$LEAKTK_BIN")" go install github.com/leaktk/leaktk@"$LEAKTK_REV" + "$LEAKTK_BIN" version || true + + - name: Scan tracked content for secrets + run: | + set -euo pipefail + LEAKTK_BIN="$RUNNER_TEMP/leaktk-bin/leaktk" + + HEAD_SHA=$(git rev-parse HEAD) + TRACKED_FILES=$(git ls-files | wc -l | tr -d ' ') + RESPONSE_FILE="$RUNNER_TEMP/leaktk-response.json" + STDERR_FILE="$RUNNER_TEMP/leaktk-stderr.log" + + set +e + timeout 480 "$LEAKTK_BIN" scan \ + --kind Files \ + --leak-exit-code 1 \ + --format json \ + --id "$LEAKTK_ID" \ + . >"$RESPONSE_FILE" 2>"$STDERR_FILE" + SCAN_STATUS=$? + set -e + + # Fail closed unless the scanner returns a valid response for this request. + if ! jq -e --arg id "$LEAKTK_ID" '.kind == "ScanResults" and .request_id == $id' "$RESPONSE_FILE" >/dev/null 2>&1; then + echo "::error::LeakTK did not return a valid response for this scan (exit status: ${SCAN_STATUS}); failing closed. Raw scanner output is withheld." + exit 1 + fi + + if jq -e '.error != null' "$RESPONSE_FILE" >/dev/null; then + echo "::error::LeakTK scan failed: $(jq -r '.error.message' "$RESPONSE_FILE") (code $(jq -r '.error.code' "$RESPONSE_FILE"))" + exit 1 + fi + + if ! jq -e '(.results | type) == "array"' "$RESPONSE_FILE" >/dev/null; then + echo "::error::LeakTK response has no results array; failing closed." + exit 1 + fi + + FINDINGS=$(jq '.results | length' "$RESPONSE_FILE") + + if [ "$FINDINGS" -gt 0 ]; then + echo "::error::Secret scan found ${FINDINGS} potential secret(s) in the tracked content at HEAD ${HEAD_SHA}." + echo "Redacted findings (secret values are withheld):" + jq -r '.results[] | "- \(.rule.description) at \(.location.path):\(.location.start.line) (rule id: \(.rule.id))"' "$RESPONSE_FILE" + exit 1 + fi + + if [ "$SCAN_STATUS" -ne 0 ]; then + echo "::error::LeakTK exited with status ${SCAN_STATUS} without reporting findings or errors; failing closed." + exit 1 + fi + + echo "Secret scan clean: ${TRACKED_FILES} tracked files at HEAD ${HEAD_SHA} scanned with LeakTK ${LEAKTK_REV} (kind=Files on the fresh checkout)."