diff --git a/.github/workflows/gitleaks-analysis.yml b/.github/workflows/gitleaks-analysis.yml index dab45518..ae295273 100644 --- a/.github/workflows/gitleaks-analysis.yml +++ b/.github/workflows/gitleaks-analysis.yml @@ -3,15 +3,31 @@ name: gitleaks on: workflow_call: +env: + # Same rule as the centreon-ubuntu-26.04 runner images (centreon-images, + # runners/base-ubuntu-26.04/Dockerfile): "latest" = newest stable release + # published at least 4 days before the run. Set a version (e.g. "8.30.1") + # to pin it. + GITLEAKS_VERSION: "latest" + # Installed when "latest" cannot be resolved, so the scan still runs. + GITLEAKS_FALLBACK_VERSION: "8.30.1" + jobs: secret-scan: name: Run Gitleaks runs-on: ${{ github.repository_visibility != 'public' && (github.repository_owner == 'centreon' && (github.event.pull_request.commits && 'centreon-ubuntu-26.04-slim' || 'centreon-ubuntu-26.04-small') || github.repository_owner == 'quanta-computing' && 'quanta-security') || 'ubuntu-26.04' }} permissions: contents: read - pull-requests: read steps: + # pull_request_target is not compliant with the security posture: the run + # always fails, before anything is checked out or executed. + - name: Reject pull_request_target + if: github.event_name == 'pull_request_target' + run: | + echo "::error::pull_request_target is not allowed" + exit 1 + # Pull requests only scan their own commits: fetch them plus the merge and base commits, full history otherwise - name: Compute fetch depth id: fetch-depth @@ -27,11 +43,285 @@ jobs: fetch-depth: ${{ steps.fetch-depth.outputs.value }} persist-credentials: false + - name: Install Gitleaks + run: | + set -uo pipefail + + case "${RUNNER_ARCH}" in + X64) ARCH=x64 ;; + ARM64) ARCH=arm64 ;; + *) echo "::error::Unsupported runner architecture '${RUNNER_ARCH}'."; exit 1 ;; + esac + + VERSION="${GITLEAKS_VERSION}" + if [ "${VERSION}" = "latest" ]; then + # Read the public releases feed: no token, no API rate limit. Its + # date is at or after the publication date, so the + # 4-day delay is never shortened. Drafts are not in the feed, and + # only vX.Y.Z tags are kept, which leaves pre-releases out. + cutoff="$(date -u -d '-4 days' +%Y-%m-%dT%H:%M:%SZ)" + VERSION="$(curl -fsSL --retry 3 "https://github.com/gitleaks/gitleaks/releases.atom" \ + | python3 -I -c ' + import re, sys, xml.etree.ElementTree as ET + ns = {"a": "http://www.w3.org/2005/Atom"} + for entry in ET.parse(sys.stdin).getroot().findall("a:entry", ns): + tag = entry.findtext("a:id", "", ns).rsplit("/", 1)[-1] + if re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+", tag) and entry.findtext("a:updated", "", ns) <= sys.argv[1]: + print(tag[1:]) + ' "${cutoff}" \ + | sort -V | tail -1)" || VERSION="" + if [ -z "${VERSION}" ]; then + # Do not block the run: fall back to a known release, still + # verified against its checksums file below. + VERSION="${GITLEAKS_FALLBACK_VERSION}" + echo "::warning::gitleaks-version-fallback: could not resolve the latest gitleaks release from its releases feed; using ${VERSION}." + fi + fi + + # The centreon-ubuntu-26.04-* runners ship gitleaks, built + # with the same rule: keep it when it is the expected version, + # otherwise (missing, or any other version) install that release. + CURRENT="$(gitleaks version 2>/dev/null || true)" + if [ "${CURRENT#v}" = "${VERSION}" ]; then + echo "gitleaks ${VERSION} is already installed: $(command -v gitleaks)." + exit 0 + fi + echo "Found gitleaks version '${CURRENT:-none}', expected ${VERSION}: installing it." + + TARBALL="gitleaks_${VERSION}_linux_${ARCH}.tar.gz" + BASE_URL="https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}" + + workdir="$(mktemp -d)" + cd "$workdir" + + curl -fsSL --retry 3 -o "${TARBALL}" "${BASE_URL}/${TARBALL}" + curl -fsSL --retry 3 "${BASE_URL}/gitleaks_${VERSION}_checksums.txt" \ + | grep " ${TARBALL}\$" | sha256sum -c - + + mkdir -p "$HOME/.local/bin" + tar -xzf "${TARBALL}" -C "$HOME/.local/bin" gitleaks + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + "$HOME/.local/bin/gitleaks" version + cd / && rm -rf "$workdir" + - name: Run secret scan - uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e # v3.0.0 env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITLEAKS_LICENSE: "Centreon" - GITLEAKS_ENABLE_COMMENTS: false - GITLEAKS_ENABLE_UPLOAD_ARTIFACT: false - GITLEAKS_ENABLE_SUMMARY: false + EVENT_NAME: ${{ github.event_name }} + EVENT_SHA: ${{ github.sha }} + REF_NAME: ${{ github.ref_name }} + FULL_REF: ${{ github.ref }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_COMMITS: ${{ github.event.pull_request.commits }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_BASE_REF: ${{ github.event.pull_request.base.ref }} + SERVER_URL: ${{ github.server_url }} + FETCH_TOKEN: ${{ github.token }} + run: | + set -u + + # Every non-merge commit of the analyzed branch is scanned; only + # the commits already on the destination branch are excluded, + # whether or not they are also on other branches. No branch name + # is assumed: + # pull_request: base..head, the commits not on the PR base. + # workflow_dispatch: no destination to exclude, so the ref's + # whole history. + # The event's commit SHAs can be unreachable in the clone (e.g. + # force-pushed away between the event and the checkout, which then + # fails with "invalid revision range"). Resolve the range in a loop, + # re-fetching between attempts, and scan only once it is valid. + # --text keeps a repo's .gitattributes (e.g. "* -diff") from hiding + # file contents from the scan. + LOG_OPTS="" + HEAD_SHA="${PR_HEAD_SHA}" + BASE_SHA="${PR_BASE_SHA}" + TIP_SHA="${EVENT_SHA}" + + # persist-credentials: false leaves no token in .git/config, so the + # re-fetches below authenticate on their own. The header is set on + # each git process only, through env-scoped config + # (GIT_CONFIG_COUNT/KEY/VALUE): never written to disk, never on a + # command line, masked in the logs, and not exported to gitleaks. + FETCH_AUTH="$(printf 'x-access-token:%s' "${FETCH_TOKEN}" | base64 -w0)" + echo "::add-mask::${FETCH_AUTH}" + unset FETCH_TOKEN + + fetch() { + GIT_CONFIG_COUNT=1 \ + GIT_CONFIG_KEY_0="http.${SERVER_URL}/.extraheader" \ + GIT_CONFIG_VALUE_0="AUTHORIZATION: basic ${FETCH_AUTH}" \ + git fetch --no-tags --force origin "$@" \ + || echo "::warning::git fetch failed: $*" + } + + has_commit() { [ -n "$1" ] && git cat-file -e "$1^{commit}" 2>/dev/null; } + + # Prints the commit a ref points to; prints nothing and fails if the + # ref is missing. + ref_commit() { git rev-parse --verify -q "$1^{commit}"; } + + # Pull requests are checked out shallow (see "Compute fetch depth"), + # where a boundary commit looks parentless: gitleaks would scan its + # whole tree. A parentless commit in base..head is either such a + # boundary or shared history the base side does not reach yet + # (the base branch moved past the fetched depth): either way the + # range is not trusted until the history is deeper. On a complete + # clone, a root commit in the range is genuinely the branch's own. + SHALLOW_FILE="$(git rev-parse --git-path shallow)" + is_shallow() { [ "$(git rev-parse --is-shallow-repository)" = "true" ]; } + range_untrusted() { + is_shallow \ + && [ -n "$(git rev-list --max-parents=0 -n 1 "${BASE_SHA}..${HEAD_SHA}")" ] + } + + # Deepens the base side only (the PR side already holds all its + # commits), 32 to 1024 more commits doubling each round, then + # unshallows as a last resort (e.g. unrelated histories). Fails once + # out of rounds, or if a fetch brought nothing (fetch() never fails: + # compare the boundaries instead). + deepen() { # round + local before + before="$(cat "${SHALLOW_FILE}" 2>/dev/null)" + if [ "$1" -lt 6 ]; then + fetch --depth="$(( ${PR_COMMITS:-0} + 1 + (32 << $1) ))" "${BASE_SHA}" + elif [ "$1" -eq 6 ]; then + fetch --unshallow "${BASE_SHA}" "${HEAD_SHA}" + else + return 1 + fi + [ "$(cat "${SHALLOW_FILE}" 2>/dev/null)" != "${before}" ] + } + + # Sets LOG_OPTS; returns non-zero if the range is unresolvable. The + # range is checked by running git log with the same --log-opts + # gitleaks will receive. + compute_range() { + case "${EVENT_NAME}" in + pull_request) + has_commit "${HEAD_SHA}" || return 1 + has_commit "${BASE_SHA}" || return 1 + # Every non-merge commit the PR brings, including commits + # merged in from other branches, and its whole history if it + # shares none with the base; the base branch is excluded. + LOG_OPTS="--text --no-merges ${BASE_SHA}..${HEAD_SHA}" + round=0 + while range_untrusted; do + echo "Shallow history does not cover the PR range yet; deepening the base side (round $((round + 1)))." + deepen "${round}" || return 1 + round=$((round + 1)) + done + ;; + workflow_dispatch) + # The tip must be in the clone, otherwise the scan would miss + # it: retry after a re-fetch. The dispatch gets a full clone + # (fetch-depth 0). + has_commit "${TIP_SHA}" || return 1 + LOG_OPTS="--text --no-merges ${TIP_SHA}" + ;; + esac + + git log ${LOG_OPTS} >/dev/null 2>&1 || return 1 + return 0 + } + + # actions/checkout can't be re-run in a loop, so re-fetch only what + # the range needs: the PR head and base branch plus the PR head/base + # SHAs (pull_request), or all branches plus the dispatched commit + # (workflow_dispatch), if the server still serves them. Only the + # history is refreshed; gitleaks' config and ignore files keep + # coming from the initial checkout. If an event SHA can no longer be fetched + # (force-pushed away), fall back to the live tip of its branch, so + # the scan covers what the branch now holds. + refetch() { + if [ "${EVENT_NAME}" = "pull_request" ] && [ -n "${PR_NUMBER}" ]; then + fetch "+refs/pull/${PR_NUMBER}/head:refs/remotes/origin/pr/${PR_NUMBER}" \ + "+refs/heads/${PR_BASE_REF}:refs/remotes/origin/${PR_BASE_REF}" + has_commit "${HEAD_SHA}" || fetch "${HEAD_SHA}" + has_commit "${BASE_SHA}" || fetch "${BASE_SHA}" + if ! has_commit "${HEAD_SHA}" \ + && live="$(ref_commit "refs/remotes/origin/pr/${PR_NUMBER}")"; then + echo "::warning::PR head ${HEAD_SHA} is gone; scanning the live PR tip ${live}." + HEAD_SHA="${live}" + fi + if ! has_commit "${BASE_SHA}" \ + && live="$(ref_commit "refs/remotes/origin/${PR_BASE_REF}")"; then + echo "::warning::PR base ${BASE_SHA} is gone; using the live ${PR_BASE_REF} tip ${live}." + BASE_SHA="${live}" + fi + else + fetch "+refs/heads/*:refs/remotes/origin/*" + if [ -n "${TIP_SHA}" ] && ! has_commit "${TIP_SHA}"; then + fetch "${TIP_SHA}" + # Live-tip fallback for branches only: refs/remotes/origin/ holds + # branches, so a tag must not resolve to a same-name branch. + if ! has_commit "${TIP_SHA}" \ + && [ "${FULL_REF}" = "refs/heads/${REF_NAME}" ] \ + && live="$(ref_commit "refs/remotes/origin/${REF_NAME}")"; then + echo "::warning::Commit ${TIP_SHA} is gone; scanning the live ${REF_NAME} tip ${live}." + TIP_SHA="${live}" + fi + fi + fi + } + + case "${EVENT_NAME}" in + pull_request|workflow_dispatch) ;; + *) + # Fail closed: other events (push, schedule, merge_group…) are + # not supported. + echo "::error::Unsupported event '${EVENT_NAME}' (supported: pull_request, workflow_dispatch); failing the job." + exit 1 + ;; + esac + + attempt=1 + max_attempts=5 + while : ; do + if compute_range; then + echo "::notice::Commit range resolved on attempt ${attempt}: ${LOG_OPTS}" + break + fi + + if [ "${attempt}" -ge "${max_attempts}" ]; then + echo "::error::Could not resolve a valid commit range after ${max_attempts} attempts; failing the job." + echo "::error::Check the git fetch warnings above. If there are none, the event's commits and their branch tip are no longer reachable (branch deleted or history rewritten): push a new commit or re-run the dispatch." + exit 1 + fi + + delay=$((attempt * 5)) + echo "Commit range not resolvable yet; re-fetching refs in ${delay}s (attempt ${attempt}/${max_attempts})." + sleep "${delay}" + refetch + attempt=$((attempt + 1)) + done + + COMMIT_COUNT="$(git log --format=%H ${LOG_OPTS} | wc -l)" + if [ "${COMMIT_COUNT}" -eq 0 ]; then + echo "::notice::No commit to scan." + exit 0 + fi + echo "Scanning ${COMMIT_COUNT} commit(s) of the branch." + + # --exit-code=2 separates "leaks found" (2) from gitleaks errors + # (1, e.g. a broken .gitleaks.toml), which fail closed below. + EXIT_CODE=0 + gitleaks detect \ + --source . \ + --log-opts="${LOG_OPTS}" \ + --exit-code=2 \ + --redact \ + --verbose || EXIT_CODE=$? + + if [ "${EXIT_CODE}" -eq 0 ]; then + echo "No secrets found." + elif [ "${EXIT_CODE}" -eq 2 ]; then + echo "::error::Gitleaks has detected secrets in the scanned commits!" + exit 1 + else + # Fail closed: a scan that did not run (e.g. a broken .gitleaks.toml) + # must not pass the gate. + echo "::error::Gitleaks failed to run (exit code: ${EXIT_CODE}); the scan did not complete, failing the job." + exit 1 + fi diff --git a/.github/workflows/security-checks.yml b/.github/workflows/security-checks.yml index 52c3c6f1..80e31c14 100644 --- a/.github/workflows/security-checks.yml +++ b/.github/workflows/security-checks.yml @@ -8,12 +8,7 @@ on: pull_request: branches: - main - push: - branches: - - main workflow_dispatch: - schedule: - - cron: 0 1 * * 1-5 jobs: dependency-scan: diff --git a/CLAUDE.md b/CLAUDE.md index d15d7e71..66bcc106 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,11 +16,11 @@ Because consumers pin to `@main`, changes to workflows and the blocklist take ef ### Reusable workflows (`.github/workflows/`) -- `security-checks.yml` — the **orchestrator**. Runs on PR/push to `main`, on a weekday cron, and on demand. It calls the two `workflow_call` workflows below. This is also the pattern downstream repos copy: `uses: centreon/security-tools/.github/workflows/.yml@main`. +- `security-checks.yml` — the **orchestrator**. Runs on PRs to `main` and on demand. It calls the two `workflow_call` workflows below. This is also the pattern downstream repos copy: `uses: centreon/security-tools/.github/workflows/.yml@main`. - `dependency-analysis.yml` — `workflow_call` + runs on PR. Two responsibilities: 1. **Lockfile compliance** — enforces PNPM only (rejects `yarn.lock` / `package-lock.json`), requires a lockfile, and enforces a minimum `lockfileVersion` (`8.9.9`). 2. **Blocklist scan** — downloads `compromised-packages.txt` from `main` and greps every `pnpm-lock.yaml` for `name@version` matches. -- `gitleaks-analysis.yml` — `workflow_call`. Runs `gitleaks/gitleaks-action`. +- `gitleaks-analysis.yml` — `workflow_call`, supporting `pull_request` and `workflow_dispatch` only (org repos call it on both; `workflow_dispatch` is mostly for debugging the pipeline). Resolves the gitleaks version like the `centreon-ubuntu-26.04` runner images (`centreon-images`, `runners/base-ubuntu-26.04/Dockerfile`): `GITLEAKS_VERSION: latest` is the newest stable release published at least 4 days before the run (read from the public releases feed, `releases.atom`, without any token), falling back to `GITLEAKS_FALLBACK_VERSION` with a `gitleaks-version-fallback` warning when the feed cannot be resolved; set a version to pin it. Keeps the runner's own gitleaks when `gitleaks version` matches (the `centreon-ubuntu-26.04-*` runners ship it), and otherwise installs that release for the runner's architecture, verified against the release's checksums file. On a PR it scans `base..head`: every non-merge commit not already on the destination branch (whatever it is called, and whether or not the commits are on other branches), so it never fails on secrets already on the base and fails on a secret in one of the PR's commits. On `workflow_dispatch` it scans the ref's whole history. Pull requests are checked out shallow (`fetch-depth` = PR commits + 2): the scan deepens the base side until the range holds no parentless commit (a shallow boundary would be read as a root and its whole tree scanned). Event SHAs missing from the clone are re-fetched with the job token. Fails closed: leaks, gitleaks errors, unresolvable ranges and unsupported events (push, schedule, …) all fail the job. `pull_request_target` always fails (not compliant with the security posture), from a first step that runs before checkout. **Warn-then-enforce gating** (in `dependency-analysis.yml`): findings are reported as `[WARNING]` until a deadline, after which they become `[ERROR]` and fail the build. Controlled by two repository-level GitHub variables: - `OVERRIDE_DEPENDENCY_ENFORCEMENT_DATE` — the date enforcement begins. @@ -28,7 +28,7 @@ Because consumers pin to `@main`, changes to workflows and the blocklist take ef Findings accumulate in `error_log.txt`, which is posted back to the PR as a sticky comment via `marocchino/sticky-pull-request-comment`. The job only fails if `fail_the_build=true` (set when enforcement is active and findings exist). -**Self-hosted runner selection**: jobs use a conditional `runs-on` that picks a per-org self-hosted runner only for **private** repos — `centreon-security` for the `centreon` org, `quanta-security` for the `quanta-computing` org — and falls back to `ubuntu-24.04` for public repos or any other owner. The same expression is used in both `dependency-analysis.yml` and `gitleaks-analysis.yml`; keep them in sync when editing. +**Self-hosted runner selection**: jobs use a conditional `runs-on` that picks a per-org self-hosted runner only for **non-public** (private or internal) repos, and falls back to `ubuntu-26.04` for public repos or any other owner. For the `centreon` org: `centreon-ubuntu-26.04-slim` for `dependency-analysis.yml` and for pull request scans of `gitleaks-analysis.yml`, `centreon-ubuntu-26.04-small` for its full-history scans (`workflow_dispatch`); for the `quanta-computing` org: `quanta-security`. Keep the two expressions consistent when editing. ### The blocklist (`blacklist/compromised-packages.txt`)