diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f89d317e..3b004e08 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -465,6 +465,29 @@ jobs: go-version-file: pkg/go.mod cache-dependency-path: '**/go.sum' + - name: Secret scan (gitleaks) + # The gitleaks CLI, not gitleaks-action (which needs an org licence). + # The release tarball is pinned by version and sha256 (from the + # release's gitleaks__checksums.txt), so a moved tag or a + # tampered asset fails the step. Scans the checked-out tree with the + # repo's .gitleaks.toml; any finding exits 1. The config self-test then + # proves a secret planted beside an allowlisted fixture is still caught. + if: always() && steps.checkout.outcome == 'success' + env: + GITLEAKS_VERSION: '8.30.1' + GITLEAKS_SHA256: '551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb' + run: | + set -euo pipefail + archive="${RUNNER_TEMP}/gitleaks.tar.gz" + curl -fsSL -o "${archive}" \ + "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" + echo "${GITLEAKS_SHA256} ${archive}" | sha256sum -c - + mkdir "${RUNNER_TEMP}/gitleaks" + tar -xzf "${archive}" -C "${RUNNER_TEMP}/gitleaks" gitleaks + "${RUNNER_TEMP}/gitleaks/gitleaks" dir . \ + --config .gitleaks.toml --redact --no-banner --exit-code 1 + GITLEAKS="${RUNNER_TEMP}/gitleaks/gitleaks" bash scripts/test-gitleaks-config.sh + - name: Run govulncheck CVE scanner (all modules) # Independent scanners still run after another scanner fails. if: always() && steps.checkout.outcome == 'success' && steps.setup_go.outcome == 'success' diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 00000000..583f614a --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,23 @@ +# gitleaks configuration for the CI "Secret scan (gitleaks)" step. +# Extends the default ruleset; every allowlist below is a regex anchored with +# ^...$ so it matches only the exact fixture secret, never a longer token that +# merely contains it. Do NOT add `paths`, `condition`, `stopwords`, `commits`, +# `targetRules` or `regexTarget`: in gitleaks 8.30.1 `paths` with +# condition = "AND" skips the whole file, so a real secret planted next to a +# fixture goes undetected. scripts/test-gitleaks-config.sh enforces this. +title = "cloud-commitments-go gitleaks config" + +[extend] +useDefault = true + +[[allowlists]] +description = "git-secrets 1.3.0 release commit SHA pinned in pre-commit.yml" +regexes = ['''^ad82d68ee924906a0401dfd48de5057731a9bc84$'''] + +[[allowlists]] +description = "synthetic bearer token fixture in purchaseguard tests" +regexes = ['''^tok-0123456789abcdef$'''] + +[[allowlists]] +description = "synthetic hex token fixture in the Azure idempotency marker tests" +regexes = ['''^4f1c2b0e9d8a7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a2f1e0d9c8b7a6f5e4d3c2b$'''] diff --git a/CHANGELOG.md b/CHANGELOG.md index e9fcf8b1..620b2109 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -141,6 +141,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). - **`provider`:** `ProviderConfig` is renamed to `Config` (#39). `ProviderConfig` stays as a deprecated type alias, so existing consumers keep compiling; migrate to `provider.Config`. The provider modules move over once their pinned `pkg` version includes the rename. - **`provider`:** `ProviderConfig` is renamed to `Config` (#39). `ProviderConfig` stays as a type alias, so existing consumers keep compiling; migrate to `provider.Config`. The provider modules move over once their pinned `pkg` version includes the rename. - **`httpclient`:** dials to the RFC 8215 local-use NAT64 prefix `64:ff9b:1::/48` are refused when any RFC 6052 layout (/48, /56, /64, /96) embeds a metadata IPv4 address; Teredo `2001::/32` stays allowed (#238). +- **CI:** the security-scan job runs a pinned gitleaks CLI (v8.30.1, sha256-verified) over the checkout with `.gitleaks.toml` and fails on any finding (#101). - **`common.HoursPerMonth`:** exported 730-hour average month (`HoursPerYear / 12`) next to `HoursPerYear`; the remaining bare 730 and 8760 literals in the aws and gcp providers use the shared constants (#109). - **`gcp`:** region-lookup permission errors are now classified by type only (googleapi 403 or gRPC `PermissionDenied`) through the shared `IsPermissionError`. gRPC `PermissionDenied` is now skipped with a warning like a 403, and an untyped error whose text merely contains "403" and "permission" now propagates instead of being skipped (#6). - **BREAKING `gcp`: stored contexts removed (#6).** `computeengine.NewClient` diff --git a/scripts/test-gitleaks-config.sh b/scripts/test-gitleaks-config.sh new file mode 100644 index 00000000..f46cf865 --- /dev/null +++ b/scripts/test-gitleaks-config.sh @@ -0,0 +1,74 @@ +#!/bin/bash +# Self-test for .gitleaks.toml. Allowlisted fixture literals are derived from +# the config itself (each `regexes` entry, ^ and $ stripped) and located in the +# tracked tree. Checks that: +# - the config uses no `paths`, `condition`, `stopwords`, `commits`, +# `targetRules` or `regexTarget` key (gitleaks 8.30.1 skips a whole file +# when `paths` is combined with condition = "AND"), and that injecting one +# is detected; +# - each literal scans clean on its own; +# - the literal with a prefix or suffix, or beside a PAT-shaped string, is caught; +# - the real tree is clean. +# Usage: GITLEAKS=/path/to/gitleaks bash scripts/test-gitleaks-config.sh +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CONFIG="$REPO_ROOT/.gitleaks.toml" +GITLEAKS="${GITLEAKS:-gitleaks}" +command -v "$GITLEAKS" >/dev/null || { echo "gitleaks not found (set GITLEAKS)" >&2; exit 1; } + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +# Assembled at runtime so this file itself scans clean. +PAT="ghp""_aB3dE5gH7jK9mN1pQ3sT5vW7yZ9bC1dE3fG5" + +pass=0 fail=0 +check() { # label expected_rc actual_rc + if [ "$3" -eq "$2" ]; then pass=$((pass + 1)); else fail=$((fail + 1)); echo "FAIL: $1 (expected rc $2, got $3)" >&2; fi +} + +scan() { # dir; prints nothing, returns gitleaks rc + local rc=0 + "$GITLEAKS" dir "$1" --config "$CONFIG" --no-banner --redact --exit-code 1 >/dev/null 2>&1 || rc=$? + echo "$rc" +} + +# 0 when the config is free of the forbidden keys, 1 otherwise. +forbidden_keys() { + if grep -qE '^[[:space:]]*(paths|condition|stopwords|commits|targetRules|regexTarget)[[:space:]]*=' "$1"; then return 1; fi +} +rc=0; forbidden_keys "$CONFIG" || rc=$? +check "config has no paths/condition/stopwords/commits/targetRules/regexTarget" 0 "$rc" +for key in 'paths = ['"'''"'unrelated\.txt'"'''"']' 'condition = "AND"' 'stopwords = ["x"]' 'commits = ["abc"]' 'targetRules = ["generic-api-key"]' 'regexTarget = "line"'; do + { cat "$CONFIG"; printf '\n[[allowlists]]\n%s\n' "$key"; } >"$tmp/injected.toml" + rc=0; forbidden_keys "$tmp/injected.toml" || rc=$? + check "injected '$key' is detected" 1 "$rc" +done + +# Literals from the config: regexes = ['''^literal$''']. +literals=$(sed -nE "s/^regexes = \['''\^(.*)\\$'''\]\$/\1/p" "$CONFIG") +[ -n "$literals" ] || { echo "no anchored regexes found in $CONFIG" >&2; exit 1; } +unanchored=$(grep -E "^regexes" "$CONFIG" | grep -vE "\['''\^.*\\$'''\]\$" || true) +[ -z "$unanchored" ] || { echo "FAIL: regexes not anchored with ^...\$:" >&2; echo "$unanchored" >&2; fail=$((fail + 1)); } + +i=0 +while IFS= read -r literal; do + i=$((i + 1)) + path=$(git -C "$REPO_ROOT" grep -lF -e "$literal" -- . ':!.gitleaks.toml' ':!scripts/test-gitleaks-config.sh' | head -1) + [ -n "$path" ] || { echo "FAIL: allowlisted literal #$i not found in the tree" >&2; fail=$((fail + 1)); continue; } + d="$tmp/c$i" + mkdir -p "$d/$(dirname "$path")" + printf 'const token = "%s"\n' "$literal" >"$d/$path" + check "fixture literal in $path is allowed" 0 "$(scan "$d")" + printf 'const token = "%sx9"\n' "$literal" >"$d/$path" + check "fixture literal plus a suffix in $path is caught" 1 "$(scan "$d")" + printf 'const token = "%s"\n' "$literal" >"$d/$path" + printf 'const real = "%s"\n' "$PAT" >>"$d/$path" + check "PAT-shaped string beside the fixture in $path is caught" 1 "$(scan "$d")" +done <<<"$literals" + +check "repository tree is clean" 0 "$(scan "$REPO_ROOT")" + +echo "$pass PASS, $fail FAIL" +[ "$fail" -eq 0 ]