From e93420caeb8aaa4916392ea22f0fd9566224031d Mon Sep 17 00:00:00 2001 From: schapron Date: Mon, 27 Jul 2026 17:15:55 +0200 Subject: [PATCH 01/28] feat(secu): gitleaks v5 custom --- .github/workflows/gitleaks-analysis.yml | 86 +++++++++++++++++++++++-- 1 file changed, 79 insertions(+), 7 deletions(-) diff --git a/.github/workflows/gitleaks-analysis.yml b/.github/workflows/gitleaks-analysis.yml index dab45518..598aacac 100644 --- a/.github/workflows/gitleaks-analysis.yml +++ b/.github/workflows/gitleaks-analysis.yml @@ -3,6 +3,10 @@ name: gitleaks on: workflow_call: +env: + # Pinned gitleaks release. + GITLEAKS_VERSION: "8.30.1" + jobs: secret-scan: name: Run Gitleaks @@ -27,11 +31,79 @@ jobs: fetch-depth: ${{ steps.fetch-depth.outputs.value }} persist-credentials: false + - name: Install Gitleaks + run: | + ASSET="gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" + BASE_URL="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}" + + workdir="$(mktemp -d)" + cd "$workdir" + + curl -sSfL -o "${ASSET}" "${BASE_URL}/${ASSET}" + curl -sSfL -o checksums.txt "${BASE_URL}/gitleaks_${GITLEAKS_VERSION}_checksums.txt" + + # Verify integrity + grep " ${ASSET}\$" checksums.txt | sha256sum -c - + + mkdir -p "$HOME/.local/bin" + tar -xzf "${ASSET}" -C "$HOME/.local/bin" gitleaks + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + "$HOME/.local/bin/gitleaks" version + - 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 + run: | + # Build the commit range from the triggering event. + LOG_OPTS="" + case "${{ github.event_name }}" in + pull_request) + HEAD="${{ github.event.pull_request.head.sha }}" + BASE="$(git merge-base "${{ github.event.pull_request.base.sha }}" "${HEAD}")" + echo "Scanning PR range: ${BASE}..${HEAD}" + LOG_OPTS="--no-merges --first-parent ${BASE}..${HEAD}" + ;; + push) + BEFORE="${{ github.event.before }}" + AFTER="${{ github.event.after }}" + # New branch rewritten history: fall back to a full scan rather than a bad range. + if [ -z "${BEFORE}" ] \ + || [ "${BEFORE}" = "0000000000000000000000000000000000000000" ] \ + || ! git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then + echo "Push without a valid previous commit: scanning full history." + else + echo "Scanning push range: ${BEFORE}..${AFTER}" + LOG_OPTS="--no-merges ${BEFORE}..${AFTER}" + fi + ;; + *) + # workflow_dispatch: scan the full history. + echo "Event '${{ github.event_name }}': scanning full history." + ;; + esac + + # Execute analysis + EXIT_CODE=0 + if [ -n "${LOG_OPTS}" ]; then + gitleaks detect \ + --source . \ + --log-opts="${LOG_OPTS}" \ + --exit-code=2 \ + --redact \ + --verbose || EXIT_CODE=$? + else + gitleaks detect \ + --source . \ + --exit-code=2 \ + --redact \ + --verbose || EXIT_CODE=$? + fi + + if [ "${EXIT_CODE}" -eq 0 ]; then + echo "No secrets found." + elif [ "${EXIT_CODE}" -eq 2 ]; then + echo "::error::Gitleaks has detected secrets in this branch!" + exit 1 + else + echo "::warning::Gitleaks encountered a git error (exit code: ${EXIT_CODE}). Scan skipped for this run." + exit 0 + fi From 7ff7cc02e1c347f5c5421fcfc7ff26832e7e493e Mon Sep 17 00:00:00 2001 From: schapron Date: Tue, 28 Jul 2026 11:21:21 +0200 Subject: [PATCH 02/28] enh(secu): manage invalid-revision range error --- .github/workflows/gitleaks-analysis.yml | 115 ++++++++++++++++++------ 1 file changed, 86 insertions(+), 29 deletions(-) diff --git a/.github/workflows/gitleaks-analysis.yml b/.github/workflows/gitleaks-analysis.yml index 598aacac..5f7e2fb0 100644 --- a/.github/workflows/gitleaks-analysis.yml +++ b/.github/workflows/gitleaks-analysis.yml @@ -3,6 +3,11 @@ name: gitleaks on: workflow_call: +# Cancel superseded PR scans; queue push/scheduled runs so a protected-branch scan is never dropped. +concurrency: + group: gitleaks-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + env: # Pinned gitleaks release. GITLEAKS_VERSION: "8.30.1" @@ -52,38 +57,90 @@ jobs: "$HOME/.local/bin/gitleaks" version - name: Run secret scan + env: + EVENT_NAME: ${{ github.event_name }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PUSH_BEFORE: ${{ github.event.before }} + PUSH_AFTER: ${{ github.event.after }} run: | - # Build the commit range from the triggering event. + set -u + + # checkout pins the PR merge ref at trigger time, so a mid-run push can + # leave the head commit out of the clone. Resolve the range in a loop, + # re-fetching between attempts, and scan only once it is valid. LOG_OPTS="" - case "${{ github.event_name }}" in - pull_request) - HEAD="${{ github.event.pull_request.head.sha }}" - BASE="$(git merge-base "${{ github.event.pull_request.base.sha }}" "${HEAD}")" - echo "Scanning PR range: ${BASE}..${HEAD}" - LOG_OPTS="--no-merges --first-parent ${BASE}..${HEAD}" - ;; - push) - BEFORE="${{ github.event.before }}" - AFTER="${{ github.event.after }}" - # New branch rewritten history: fall back to a full scan rather than a bad range. - if [ -z "${BEFORE}" ] \ - || [ "${BEFORE}" = "0000000000000000000000000000000000000000" ] \ - || ! git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then - echo "Push without a valid previous commit: scanning full history." - else - echo "Scanning push range: ${BEFORE}..${AFTER}" - LOG_OPTS="--no-merges ${BEFORE}..${AFTER}" + HEAD_SHA="${PR_HEAD_SHA}" + + # Sets LOG_OPTS (empty => full scan); returns non-zero if the range is + # unresolvable. Validated with gitleaks' exact flags. + compute_range() { + case "${EVENT_NAME}" in + pull_request) + git cat-file -e "${HEAD_SHA}^{commit}" 2>/dev/null || return 1 + base="$(git merge-base "${PR_BASE_SHA}" "${HEAD_SHA}" 2>/dev/null)" || return 1 + LOG_OPTS="--no-merges --first-parent ${base}..${HEAD_SHA}" + ;; + push) + # New branch / rewritten history: full scan. + if [ -z "${PUSH_BEFORE}" ] \ + || [ "${PUSH_BEFORE}" = "0000000000000000000000000000000000000000" ] \ + || ! git cat-file -e "${PUSH_BEFORE}^{commit}" 2>/dev/null; then + LOG_OPTS="" + else + LOG_OPTS="--no-merges ${PUSH_BEFORE}..${PUSH_AFTER}" + fi + ;; + *) + # workflow_dispatch, etc: full scan. + LOG_OPTS="" + ;; + esac + + if [ -n "${LOG_OPTS}" ]; then + # shellcheck disable=SC2086 + git log ${LOG_OPTS} >/dev/null 2>&1 || return 1 + fi + return 0 + } + + # Fetch the refs the range needs (actions/checkout can't be re-run in a + # loop). Falls back to the live PR tip if the head SHA was force-pushed away. + refetch() { + if [ "${EVENT_NAME}" = "pull_request" ] && [ -n "${PR_NUMBER}" ]; then + git fetch --no-tags --force origin \ + "+refs/pull/${PR_NUMBER}/head:refs/remotes/origin/pr/${PR_NUMBER}" || true + if ! git cat-file -e "${HEAD_SHA}^{commit}" 2>/dev/null; then + HEAD_SHA="$(git rev-parse "refs/remotes/origin/pr/${PR_NUMBER}" 2>/dev/null || echo "${HEAD_SHA}")" fi - ;; - *) - # workflow_dispatch: scan the full history. - echo "Event '${{ github.event_name }}': scanning full history." - ;; - esac - - # Execute analysis + else + git fetch --no-tags --force origin "+refs/heads/*:refs/remotes/origin/*" || true + fi + } + + attempt=1 + max_attempts=3 + while : ; do + if compute_range; then + echo "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." + echo "::error::The scan target is missing from the checkout (likely a force-push race); re-run this job." + exit 1 + fi + + echo "Commit range not resolvable yet; re-fetching refs (attempt ${attempt})." + refetch + attempt=$((attempt + 1)) + done + EXIT_CODE=0 if [ -n "${LOG_OPTS}" ]; then + # shellcheck disable=SC2086 gitleaks detect \ --source . \ --log-opts="${LOG_OPTS}" \ @@ -104,6 +161,6 @@ jobs: echo "::error::Gitleaks has detected secrets in this branch!" exit 1 else - echo "::warning::Gitleaks encountered a git error (exit code: ${EXIT_CODE}). Scan skipped for this run." - exit 0 + echo "::error::Gitleaks encountered an unexpected error (exit code: ${EXIT_CODE})." + exit 1 fi From 701f941297142217dee8398d36f8b147ac761f6e Mon Sep 17 00:00:00 2001 From: schapron Date: Tue, 28 Jul 2026 11:27:30 +0200 Subject: [PATCH 03/28] enh PR tpl --- .github/PULL_REQUEST_TEMPLATE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 1d3b0b00..88fb7e6b 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -2,4 +2,4 @@ **What are your needs or what are you planning to do in this PR?** -> +> From 59ba89ba54f86d18e210b7e0e5cc6b9b498451a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Chapron?= <34628915+sc979@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:43:52 +0200 Subject: [PATCH 04/28] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Stéphane Chapron <34628915+sc979@users.noreply.github.com> Signed-off-by: Stéphane Chapron <34628915+sc979@users.noreply.github.com> --- .github/workflows/gitleaks-analysis.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/gitleaks-analysis.yml b/.github/workflows/gitleaks-analysis.yml index 5f7e2fb0..2332b230 100644 --- a/.github/workflows/gitleaks-analysis.yml +++ b/.github/workflows/gitleaks-analysis.yml @@ -99,7 +99,6 @@ jobs: esac if [ -n "${LOG_OPTS}" ]; then - # shellcheck disable=SC2086 git log ${LOG_OPTS} >/dev/null 2>&1 || return 1 fi return 0 @@ -120,7 +119,7 @@ jobs: } attempt=1 - max_attempts=3 + max_attempts=5 while : ; do if compute_range; then echo "Commit range resolved on attempt ${attempt}: ${LOG_OPTS:-}" @@ -128,8 +127,8 @@ jobs: fi if [ "${attempt}" -ge "${max_attempts}" ]; then - echo "::error::Could not resolve a valid commit range after ${max_attempts} attempts." - echo "::error::The scan target is missing from the checkout (likely a force-push race); re-run this job." + echo "::error::Could not resolve a valid commit range after ${max_attempts} attempts. Skipping" + echo "::error::The scan target is missing from the checkout (likely a force-push race); re-run this job. Skipping" exit 1 fi @@ -162,5 +161,5 @@ jobs: exit 1 else echo "::error::Gitleaks encountered an unexpected error (exit code: ${EXIT_CODE})." - exit 1 + exit 0 fi From 6e3bd71a320058c2f95475eac8e621b84fb0c331 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Chapron?= <34628915+sc979@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:41:55 +0200 Subject: [PATCH 05/28] Apply suggestion from @sc979 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Stéphane Chapron <34628915+sc979@users.noreply.github.com> --- .github/workflows/gitleaks-analysis.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/gitleaks-analysis.yml b/.github/workflows/gitleaks-analysis.yml index 2332b230..bb8bdfdf 100644 --- a/.github/workflows/gitleaks-analysis.yml +++ b/.github/workflows/gitleaks-analysis.yml @@ -161,5 +161,6 @@ jobs: exit 1 else echo "::error::Gitleaks encountered an unexpected error (exit code: ${EXIT_CODE})." - exit 0 + # exit 0 + exit 1 fi From eafdad172af29eecd60741214b69f03794d05728 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Chapron?= <34628915+sc979@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:24:43 +0200 Subject: [PATCH 06/28] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Stéphane Chapron <34628915+sc979@users.noreply.github.com> Signed-off-by: Stéphane Chapron <34628915+sc979@users.noreply.github.com> --- .github/workflows/gitleaks-analysis.yml | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/.github/workflows/gitleaks-analysis.yml b/.github/workflows/gitleaks-analysis.yml index bb8bdfdf..79e51fd9 100644 --- a/.github/workflows/gitleaks-analysis.yml +++ b/.github/workflows/gitleaks-analysis.yml @@ -3,7 +3,6 @@ name: gitleaks on: workflow_call: -# Cancel superseded PR scans; queue push/scheduled runs so a protected-branch scan is never dropped. concurrency: group: gitleaks-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} @@ -68,7 +67,7 @@ jobs: set -u # checkout pins the PR merge ref at trigger time, so a mid-run push can - # leave the head commit out of the clone. Resolve the range in a loop, + # leave the head commit out of the clone. Resolving the range in a loop, # re-fetching between attempts, and scan only once it is valid. LOG_OPTS="" HEAD_SHA="${PR_HEAD_SHA}" @@ -122,7 +121,7 @@ jobs: max_attempts=5 while : ; do if compute_range; then - echo "Commit range resolved on attempt ${attempt}: ${LOG_OPTS:-}" + echo "::warning::Commit range resolved on attempt ${attempt}: ${LOG_OPTS:-}" break fi @@ -139,7 +138,6 @@ jobs: EXIT_CODE=0 if [ -n "${LOG_OPTS}" ]; then - # shellcheck disable=SC2086 gitleaks detect \ --source . \ --log-opts="${LOG_OPTS}" \ @@ -161,6 +159,5 @@ jobs: exit 1 else echo "::error::Gitleaks encountered an unexpected error (exit code: ${EXIT_CODE})." - # exit 0 - exit 1 + exit 0 fi From 75b942728e941d99246c18446eb6aff1a0921712 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Chapron?= <34628915+sc979@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:27:14 +0200 Subject: [PATCH 07/28] Apply suggestion from @sc979 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Stéphane Chapron <34628915+sc979@users.noreply.github.com> --- .github/workflows/gitleaks-analysis.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/gitleaks-analysis.yml b/.github/workflows/gitleaks-analysis.yml index 79e51fd9..2a6d95ce 100644 --- a/.github/workflows/gitleaks-analysis.yml +++ b/.github/workflows/gitleaks-analysis.yml @@ -1,11 +1,12 @@ name: gitleaks -on: - workflow_call: - concurrency: group: gitleaks-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + cancel-in-progress: true + +on: +workflow_call: + env: # Pinned gitleaks release. From 7f7dcbbeb947300cfe9446e800b253db0f4c56b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Chapron?= <34628915+sc979@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:27:29 +0200 Subject: [PATCH 08/28] Apply suggestion from @sc979 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Stéphane Chapron <34628915+sc979@users.noreply.github.com> --- .github/workflows/gitleaks-analysis.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/gitleaks-analysis.yml b/.github/workflows/gitleaks-analysis.yml index 2a6d95ce..eeaaada0 100644 --- a/.github/workflows/gitleaks-analysis.yml +++ b/.github/workflows/gitleaks-analysis.yml @@ -7,7 +7,6 @@ concurrency: on: workflow_call: - env: # Pinned gitleaks release. GITLEAKS_VERSION: "8.30.1" From 22e80c89efcbb35ee6828aad9e09454dc3dee116 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Chapron?= <34628915+sc979@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:28:13 +0200 Subject: [PATCH 09/28] Apply suggestion from @sc979 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Stéphane Chapron <34628915+sc979@users.noreply.github.com> --- .github/workflows/gitleaks-analysis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/gitleaks-analysis.yml b/.github/workflows/gitleaks-analysis.yml index eeaaada0..267e19f9 100644 --- a/.github/workflows/gitleaks-analysis.yml +++ b/.github/workflows/gitleaks-analysis.yml @@ -1,7 +1,7 @@ name: gitleaks concurrency: - group: gitleaks-${{ github.event.pull_request.number || github.ref }} + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true on: From 586551acda6b13a691b0965a50dcece074b72454 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Chapron?= <34628915+sc979@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:03:34 +0200 Subject: [PATCH 10/28] Apply suggestion from @sc979 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Stéphane Chapron <34628915+sc979@users.noreply.github.com> --- .github/workflows/gitleaks-analysis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/gitleaks-analysis.yml b/.github/workflows/gitleaks-analysis.yml index 267e19f9..5ebd57c7 100644 --- a/.github/workflows/gitleaks-analysis.yml +++ b/.github/workflows/gitleaks-analysis.yml @@ -5,7 +5,7 @@ concurrency: cancel-in-progress: true on: -workflow_call: + workflow_call: env: # Pinned gitleaks release. From b36d793268dd4951bb29f051807cdfa0bd0c505e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Chapron?= <34628915+sc979@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:07:32 +0200 Subject: [PATCH 11/28] remove concurrency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit already managed by the triggering pipeline. causing failing loop Signed-off-by: Stéphane Chapron <34628915+sc979@users.noreply.github.com> --- .github/workflows/gitleaks-analysis.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/gitleaks-analysis.yml b/.github/workflows/gitleaks-analysis.yml index 5ebd57c7..038413c9 100644 --- a/.github/workflows/gitleaks-analysis.yml +++ b/.github/workflows/gitleaks-analysis.yml @@ -1,9 +1,5 @@ name: gitleaks -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - on: workflow_call: From e86eff37a32afde60b0edd7cf861dfcb1bdfb168 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Chapron?= <34628915+sc979@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:11:15 +0200 Subject: [PATCH 12/28] Update gitleaks-analysis.yml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Stéphane Chapron <34628915+sc979@users.noreply.github.com> --- .github/workflows/gitleaks-analysis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/gitleaks-analysis.yml b/.github/workflows/gitleaks-analysis.yml index 038413c9..8588d2f2 100644 --- a/.github/workflows/gitleaks-analysis.yml +++ b/.github/workflows/gitleaks-analysis.yml @@ -117,7 +117,7 @@ jobs: max_attempts=5 while : ; do if compute_range; then - echo "::warning::Commit range resolved on attempt ${attempt}: ${LOG_OPTS:-}" + echo "::info::Commit range resolved on attempt ${attempt}: ${LOG_OPTS:-}" break fi From 8fec2c5cdccd4a6f7eafc7721d5d13d8b14850a1 Mon Sep 17 00:00:00 2001 From: schapron Date: Wed, 29 Jul 2026 16:05:16 +0200 Subject: [PATCH 13/28] ci(secu): limit new-branch scan to its own commits On a push that creates a new branch (or rewrites history), the gitleaks range fell back to a full-history scan. Because a branch usually forks off the default branch, that re-scanned commits already living in the default branch and re-reported pre-existing secrets that the branch never introduced. Resolve the range against the default branch instead: scan only merge-base(origin/, after)..after, i.e. the commits unique to the branch. The empty-"before" case now follows the same path, so it is consistent with a real new branch. When the default branch cannot be resolved, fall back to a full scan (over-scan is the safe direction). Also correct the range-resolved log to ::notice:: (::info:: is not a valid workflow command). Assisted-by: Claude Code (claude-opus-4-8) --- .github/workflows/gitleaks-analysis.yml | 28 ++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/.github/workflows/gitleaks-analysis.yml b/.github/workflows/gitleaks-analysis.yml index 8588d2f2..f0de2db9 100644 --- a/.github/workflows/gitleaks-analysis.yml +++ b/.github/workflows/gitleaks-analysis.yml @@ -59,6 +59,7 @@ jobs: PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} PUSH_BEFORE: ${{ github.event.before }} PUSH_AFTER: ${{ github.event.after }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} run: | set -u @@ -78,13 +79,26 @@ jobs: LOG_OPTS="--no-merges --first-parent ${base}..${HEAD_SHA}" ;; push) - # New branch / rewritten history: full scan. - if [ -z "${PUSH_BEFORE}" ] \ - || [ "${PUSH_BEFORE}" = "0000000000000000000000000000000000000000" ] \ - || ! git cat-file -e "${PUSH_BEFORE}^{commit}" 2>/dev/null; then - LOG_OPTS="" - else + if [ -n "${PUSH_BEFORE}" ] \ + && [ "${PUSH_BEFORE}" != "0000000000000000000000000000000000000000" ] \ + && git cat-file -e "${PUSH_BEFORE}^{commit}" 2>/dev/null; then + # Existing branch: scan exactly the commits this push added. LOG_OPTS="--no-merges ${PUSH_BEFORE}..${PUSH_AFTER}" + elif base="$(git merge-base "refs/remotes/origin/${DEFAULT_BRANCH}" "${PUSH_AFTER}" 2>/dev/null)" \ + && [ -n "${base}" ]; then + # New branch (before is the zero SHA or empty) or rewritten + # history: scan only the commits unique to this branch, i.e. + # since it diverged from the default branch. The shared base + # history is already covered by the default branch's own scans, + # so a full scan there would just re-report pre-existing, + # unrelated findings. (fetch-depth: 0 makes origin/ + # available in the clone.) + LOG_OPTS="--no-merges ${base}..${PUSH_AFTER}" + else + # Default branch not resolvable (unrelated history, or the ref + # is missing): fall back to a full scan rather than risk + # missing anything. Over-scan is the safe direction. + LOG_OPTS="" fi ;; *) @@ -117,7 +131,7 @@ jobs: max_attempts=5 while : ; do if compute_range; then - echo "::info::Commit range resolved on attempt ${attempt}: ${LOG_OPTS:-}" + echo "::notice::Commit range resolved on attempt ${attempt}: ${LOG_OPTS:-}" break fi From 38eed42d99adbf2cb8214a0c6cdf02d7ba5730ab Mon Sep 17 00:00:00 2001 From: schapron Date: Mon, 5 Oct 2026 19:36:49 +0200 Subject: [PATCH 14/28] ci(secu): make gitleaks scan robust and fail-closed Since persist-credentials: false, the re-fetch that recovers commits missing from the clone ("invalid revision range") had no credentials and could never succeed. Re-fetches now authenticate with the job token, passed to each git process only through env-scoped config: nothing is written to .git/config, shown on a command line or exported to gitleaks. The PR base, the pushed SHA and tag pushes are handled too, with a backoff between attempts and a warning on every fallback. Make the scan cover what the change under test brings, and fail closed: - PR scans include commits merged in from other branches, and .gitattributes can no longer hide file contents (--text) - a rewritten default branch gets a full scan instead of an empty range - gitleaks errors, unsupported events and unresolvable ranges fail the job; pull_request_target always fails, before checkout, as it is not compliant with the security posture; branch deletions pass - the gitleaks binary is verified against a sha256 pinned in the workflow, and the job token is scoped to contents: read Also document the workflow in CLAUDE.md and drop stray whitespace from the PR template. Assisted-by: Claude Code (claude-opus-5-5) Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/PULL_REQUEST_TEMPLATE.md | 2 +- .github/workflows/gitleaks-analysis.yml | 237 +++++++++++++++++------- CLAUDE.md | 2 +- 3 files changed, 174 insertions(+), 67 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 88fb7e6b..1d3b0b00 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -2,4 +2,4 @@ **What are your needs or what are you planning to do in this PR?** -> +> diff --git a/.github/workflows/gitleaks-analysis.yml b/.github/workflows/gitleaks-analysis.yml index f0de2db9..e47209ae 100644 --- a/.github/workflows/gitleaks-analysis.yml +++ b/.github/workflows/gitleaks-analysis.yml @@ -4,8 +4,11 @@ on: workflow_call: env: - # Pinned gitleaks release. + # Pinned gitleaks release. Bump both together: GITLEAKS_SHA256 is the sha256 + # of gitleaks__linux_x64.tar.gz, checked against the release's + # checksums file when bumping. GITLEAKS_VERSION: "8.30.1" + GITLEAKS_SHA256: "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb" jobs: secret-scan: @@ -13,9 +16,16 @@ jobs: 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: it is not compliant with the security posture. Trigger the scan on pull_request." + 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 @@ -40,16 +50,17 @@ jobs: cd "$workdir" curl -sSfL -o "${ASSET}" "${BASE_URL}/${ASSET}" - curl -sSfL -o checksums.txt "${BASE_URL}/gitleaks_${GITLEAKS_VERSION}_checksums.txt" - # Verify integrity - grep " ${ASSET}\$" checksums.txt | sha256sum -c - + # Verify against the pinned digest, not a checksums file served + # from the same release as the binary. + echo "${GITLEAKS_SHA256} ${ASSET}" | sha256sum -c - mkdir -p "$HOME/.local/bin" tar -xzf "${ASSET}" -C "$HOME/.local/bin" gitleaks echo "$HOME/.local/bin" >> "$GITHUB_PATH" "$HOME/.local/bin/gitleaks" version + cd / && rm -rf "$workdir" - name: Run secret scan env: @@ -57,117 +68,213 @@ jobs: PR_NUMBER: ${{ github.event.pull_request.number }} 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 }} PUSH_BEFORE: ${{ github.event.before }} PUSH_AFTER: ${{ github.event.after }} + PUSH_REF: ${{ github.ref_name }} + PUSH_FULL_REF: ${{ github.ref }} DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + SERVER_URL: ${{ github.server_url }} + FETCH_TOKEN: ${{ github.token }} run: | set -u - # checkout pins the PR merge ref at trigger time, so a mid-run push can - # leave the head commit out of the clone. Resolving the range in a loop, + # 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. + ZERO_SHA="0000000000000000000000000000000000000000" + FULL_SCAN="--text --full-history --all" LOG_OPTS="" HEAD_SHA="${PR_HEAD_SHA}" + BASE_SHA="${PR_BASE_SHA}" + AFTER_SHA="${PUSH_AFTER}" + + # 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}"; } - # Sets LOG_OPTS (empty => full scan); returns non-zero if the range is - # unresolvable. Validated with gitleaks' exact flags. + # 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) - git cat-file -e "${HEAD_SHA}^{commit}" 2>/dev/null || return 1 - base="$(git merge-base "${PR_BASE_SHA}" "${HEAD_SHA}" 2>/dev/null)" || return 1 - LOG_OPTS="--no-merges --first-parent ${base}..${HEAD_SHA}" + has_commit "${HEAD_SHA}" || return 1 + has_commit "${BASE_SHA}" || return 1 + if base="$(git merge-base "${BASE_SHA}" "${HEAD_SHA}" 2>/dev/null)"; then + # Every non-merge commit the PR brings, including commits + # merged in from other branches; the base branch history is + # excluded. + LOG_OPTS="--text --no-merges ${base}..${HEAD_SHA}" + else + # Both commits are present but share no history: nothing to + # exclude, so scan everything rather than retry in vain. + echo "::notice::PR head and base share no history; scanning the full history." + LOG_OPTS="${FULL_SCAN}" + fi ;; push) - if [ -n "${PUSH_BEFORE}" ] \ - && [ "${PUSH_BEFORE}" != "0000000000000000000000000000000000000000" ] \ - && git cat-file -e "${PUSH_BEFORE}^{commit}" 2>/dev/null; then - # Existing branch: scan exactly the commits this push added. - LOG_OPTS="--no-merges ${PUSH_BEFORE}..${PUSH_AFTER}" - elif base="$(git merge-base "refs/remotes/origin/${DEFAULT_BRANCH}" "${PUSH_AFTER}" 2>/dev/null)" \ - && [ -n "${base}" ]; then - # New branch (before is the zero SHA or empty) or rewritten - # history: scan only the commits unique to this branch, i.e. - # since it diverged from the default branch. The shared base - # history is already covered by the default branch's own scans, - # so a full scan there would just re-report pre-existing, - # unrelated findings. (fetch-depth: 0 makes origin/ - # available in the clone.) - LOG_OPTS="--no-merges ${base}..${PUSH_AFTER}" + # The pushed commit must be in the clone, otherwise even a full + # scan would miss it: retry after a re-fetch. + has_commit "${AFTER_SHA}" || return 1 + if [ -n "${PUSH_BEFORE}" ] && [ "${PUSH_BEFORE}" != "${ZERO_SHA}" ] \ + && has_commit "${PUSH_BEFORE}"; then + # Existing branch: scan the non-merge commits this push added. + LOG_OPTS="--text --no-merges ${PUSH_BEFORE}..${AFTER_SHA}" + elif [ "${PUSH_REF}" = "${DEFAULT_BRANCH}" ]; then + # Default branch created or rewritten (before is zero, empty + # or force-pushed away): nothing is "unique" to it, so the + # merge-base range below would be empty. Full scan. + LOG_OPTS="${FULL_SCAN}" + elif base="$(git merge-base "refs/remotes/origin/${DEFAULT_BRANCH}" "${AFTER_SHA}" 2>/dev/null)"; then + # New or rewritten branch: scan only the commits unique to it, + # i.e. since it diverged from the default branch. The shared + # history is assumed covered by the default branch's own push + # or scheduled scans, so a full scan would just re-report + # pre-existing findings. (fetch-depth: 0 makes origin/ + # available.) + LOG_OPTS="--text --no-merges ${base}..${AFTER_SHA}" else - # Default branch not resolvable (unrelated history, or the ref - # is missing): fall back to a full scan rather than risk - # missing anything. Over-scan is the safe direction. - LOG_OPTS="" + # Default branch ref missing or unrelated history: full scan. + # Over-scan is the safe direction. + LOG_OPTS="${FULL_SCAN}" fi ;; - *) - # workflow_dispatch, etc: full scan. - LOG_OPTS="" + workflow_dispatch|schedule) + LOG_OPTS="${FULL_SCAN}" ;; esac - if [ -n "${LOG_OPTS}" ]; then - git log ${LOG_OPTS} >/dev/null 2>&1 || return 1 - fi + git log ${LOG_OPTS} >/dev/null 2>&1 || return 1 return 0 } - # Fetch the refs the range needs (actions/checkout can't be re-run in a - # loop). Falls back to the live PR tip if the head SHA was force-pushed away. + # 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 pushed commit (push), + # if the server still serves them. A missing push "before" SHA is + # handled by compute_range, not here. 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 - git fetch --no-tags --force origin \ - "+refs/pull/${PR_NUMBER}/head:refs/remotes/origin/pr/${PR_NUMBER}" || true - if ! git cat-file -e "${HEAD_SHA}^{commit}" 2>/dev/null; then - HEAD_SHA="$(git rev-parse "refs/remotes/origin/pr/${PR_NUMBER}" 2>/dev/null || echo "${HEAD_SHA}")" + 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 - git fetch --no-tags --force origin "+refs/heads/*:refs/remotes/origin/*" || true + fetch "+refs/heads/*:refs/remotes/origin/*" + if [ -n "${AFTER_SHA}" ] && ! has_commit "${AFTER_SHA}"; then + fetch "${AFTER_SHA}" + # Live-tip fallback for branches only: refs/remotes/origin/ holds + # branches, so a tag push must not resolve to a same-name branch. + if ! has_commit "${AFTER_SHA}" \ + && [ "${PUSH_FULL_REF}" = "refs/heads/${PUSH_REF}" ] \ + && live="$(ref_commit "refs/remotes/origin/${PUSH_REF}")"; then + echo "::warning::Pushed commit ${AFTER_SHA} is gone; scanning the live ${PUSH_REF} tip ${live}." + AFTER_SHA="${live}" + fi + fi fi } + case "${EVENT_NAME}" in + pull_request|push|workflow_dispatch|schedule) ;; + pull_request_target) + # Defense in depth: the first step already rejects this event. + echo "::error::pull_request_target is not allowed: it is not compliant with the security posture. Trigger the scan on pull_request." + exit 1 + ;; + *) + # Fail closed: for other events (e.g. merge_group) the checkout + # may not hold the commits under test. + echo "::error::Unsupported event '${EVENT_NAME}' (supported: pull_request, push, workflow_dispatch, schedule); failing the job." + exit 1 + ;; + esac + + if [ "${EVENT_NAME}" = "push" ] && [ "${PUSH_AFTER}" = "${ZERO_SHA}" ]; then + echo "::notice::${PUSH_REF} was deleted; nothing to scan." + exit 0 + fi + attempt=1 max_attempts=5 while : ; do if compute_range; then - echo "::notice::Commit range resolved on attempt ${attempt}: ${LOG_OPTS:-}" + 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. Skipping" - echo "::error::The scan target is missing from the checkout (likely a force-push race); re-run this job. Skipping" + 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 to re-trigger the scan." exit 1 fi - echo "Commit range not resolvable yet; re-fetching refs (attempt ${attempt})." + 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 + # --exit-code=2 separates "leaks found" (2) from gitleaks errors + # (1, e.g. a broken .gitleaks.toml), which fail closed below. EXIT_CODE=0 - if [ -n "${LOG_OPTS}" ]; then - gitleaks detect \ - --source . \ - --log-opts="${LOG_OPTS}" \ - --exit-code=2 \ - --redact \ - --verbose || EXIT_CODE=$? - else - gitleaks detect \ - --source . \ - --exit-code=2 \ - --redact \ - --verbose || EXIT_CODE=$? - fi + 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 this branch!" + if [ "${LOG_OPTS}" = "${FULL_SCAN}" ]; then + echo "::error::Gitleaks has detected secrets in the repository history (all fetched refs)!" + else + echo "::error::Gitleaks has detected secrets in the scanned commits!" + fi exit 1 else - echo "::error::Gitleaks encountered an unexpected error (exit code: ${EXIT_CODE})." - exit 0 + # 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/CLAUDE.md b/CLAUDE.md index d15d7e71..2bce557d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ Because consumers pin to `@main`, changes to workflows and the blocklist take ef - `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`. Installs the gitleaks release pinned by `GITLEAKS_VERSION` + `GITLEAKS_SHA256` (bump both together; Dependabot/Renovate don't) and runs `gitleaks detect` on a commit range chosen per event (PR commits, pushed commits, or full history), re-fetching with the job token when event SHAs are missing from the clone. Fails closed: leaks, gitleaks errors, unresolvable ranges and unsupported events 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. From 197d7fca35031d032a82d93d57c43bff6bb72b31 Mon Sep 17 00:00:00 2001 From: schapron Date: Wed, 7 Oct 2026 11:27:03 +0200 Subject: [PATCH 15/28] ci(secu): avoid false leaks on shallow PR checkout Since #74 the PR checkout is shallow (fetch-depth = PR commits + 2). When the base branch moved further than that since the PR forked, the merge-base was unreachable: the scan concluded "no shared history" and ran a full scan of the shallow clone, where gitleaks reads boundary commits as roots and reports every pre-existing secret in the tree. A clean PR then failed on secrets already on the base branch. The scan now deepens the PR history (32 to 1024 commits, doubling) until the fork point is reachable from both sides, unshallows as a last resort, and never scans a range holding a shallow boundary commit. "No shared history" is only concluded on a complete clone. Deepening goes through the authenticated fetch and stops when a fetch brings nothing, so the existing retry loop takes over. Also shorten the pull_request_target error message. Co-Authored-By: Claude Opus 5.5 (1M context) Assisted-by: Claude Code (claude-opus-5-5) --- .github/workflows/gitleaks-analysis.yml | 70 +++++++++++++++++++------ CLAUDE.md | 2 +- 2 files changed, 56 insertions(+), 16 deletions(-) diff --git a/.github/workflows/gitleaks-analysis.yml b/.github/workflows/gitleaks-analysis.yml index e47209ae..049313e5 100644 --- a/.github/workflows/gitleaks-analysis.yml +++ b/.github/workflows/gitleaks-analysis.yml @@ -23,7 +23,7 @@ jobs: - name: Reject pull_request_target if: github.event_name == 'pull_request_target' run: | - echo "::error::pull_request_target is not allowed: it is not compliant with the security posture. Trigger the scan on pull_request." + 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 @@ -115,6 +115,34 @@ jobs: # ref is missing. ref_commit() { git rev-parse --verify -q "$1^{commit}"; } + # Pull requests are checked out shallow (see "Compute fetch depth"). + # A boundary commit looks parentless, so gitleaks would scan its + # whole tree and report every pre-existing secret: it must never be + # in the scanned range. + SHALLOW_FILE="$(git rev-parse --git-path shallow)" + is_shallow() { [ "$(git rev-parse --is-shallow-repository)" = "true" ]; } + range_hits_boundary() { + [ -s "${SHALLOW_FILE}" ] \ + && git log --format=%H ${LOG_OPTS} 2>/dev/null | grep -qFxf "${SHALLOW_FILE}" + } + + # Deepens the shallow PR history, doubling the step on each round + # (32 to 1024 commits), then unshallows as a last resort. 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 --deepen="$((32 << $1))" "${BASE_SHA}" "${HEAD_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. @@ -123,17 +151,29 @@ jobs: pull_request) has_commit "${HEAD_SHA}" || return 1 has_commit "${BASE_SHA}" || return 1 - if base="$(git merge-base "${BASE_SHA}" "${HEAD_SHA}" 2>/dev/null)"; then - # Every non-merge commit the PR brings, including commits - # merged in from other branches; the base branch history is - # excluded. - LOG_OPTS="--text --no-merges ${base}..${HEAD_SHA}" - else - # Both commits are present but share no history: nothing to - # exclude, so scan everything rather than retry in vain. - echo "::notice::PR head and base share no history; scanning the full history." - LOG_OPTS="${FULL_SCAN}" - fi + round=0 + while : ; do + if base="$(git merge-base "${BASE_SHA}" "${HEAD_SHA}" 2>/dev/null)"; then + # Every non-merge commit the PR brings, including commits + # merged in from other branches; the base branch history is + # excluded. + LOG_OPTS="--text --no-merges ${base}..${HEAD_SHA}" + range_hits_boundary || break + elif ! is_shallow; then + # Both commits are present but share no history: nothing to + # exclude, so scan everything rather than retry in vain. + echo "::notice::PR head and base share no history; scanning the full history." + LOG_OPTS="${FULL_SCAN}" + break + fi + # The base branch moved past the fetched depth since the PR + # forked: deepen until the fork point is reachable from both + # sides. If deepening fails, retry after a re-fetch. + LOG_OPTS="" + echo "Shallow history does not reach the PR fork point yet; deepening (round $((round + 1)))." + deepen "${round}" || return 1 + round=$((round + 1)) + done ;; push) # The pushed commit must be in the clone, otherwise even a full @@ -153,8 +193,8 @@ jobs: # i.e. since it diverged from the default branch. The shared # history is assumed covered by the default branch's own push # or scheduled scans, so a full scan would just re-report - # pre-existing findings. (fetch-depth: 0 makes origin/ - # available.) + # pre-existing findings. (Push events get a full clone, + # fetch-depth 0, so origin/ is available.) LOG_OPTS="--text --no-merges ${base}..${AFTER_SHA}" else # Default branch ref missing or unrelated history: full scan. @@ -216,7 +256,7 @@ jobs: pull_request|push|workflow_dispatch|schedule) ;; pull_request_target) # Defense in depth: the first step already rejects this event. - echo "::error::pull_request_target is not allowed: it is not compliant with the security posture. Trigger the scan on pull_request." + echo "::error::pull_request_target is not allowed" exit 1 ;; *) diff --git a/CLAUDE.md b/CLAUDE.md index 2bce557d..dbee4c92 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ Because consumers pin to `@main`, changes to workflows and the blocklist take ef - `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`. Installs the gitleaks release pinned by `GITLEAKS_VERSION` + `GITLEAKS_SHA256` (bump both together; Dependabot/Renovate don't) and runs `gitleaks detect` on a commit range chosen per event (PR commits, pushed commits, or full history), re-fetching with the job token when event SHAs are missing from the clone. Fails closed: leaks, gitleaks errors, unresolvable ranges and unsupported events all fail the job. `pull_request_target` always fails (not compliant with the security posture), from a first step that runs before checkout. +- `gitleaks-analysis.yml` — `workflow_call`. Installs the gitleaks release pinned by `GITLEAKS_VERSION` + `GITLEAKS_SHA256` (bump both together; Dependabot/Renovate don't) and runs `gitleaks detect` on a commit range chosen per event (PR commits, pushed commits, or full history), re-fetching with the job token when event SHAs are missing from the clone. Pull requests are checked out shallow (`fetch-depth` = PR commits + 2); the scan deepens that history until the PR fork point is reachable and never scans a shallow boundary commit (gitleaks would read it as a root and scan its whole tree). Other events get a full clone. Fails closed: leaks, gitleaks errors, unresolvable ranges and unsupported events 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. From a99784adbc33dc964f8335fb63672065dde3e8c3 Mon Sep 17 00:00:00 2001 From: schapron Date: Wed, 7 Oct 2026 14:08:22 +0200 Subject: [PATCH 16/28] ci(secu): only fail on the branch's own secrets Secrets already on the destination branch are that branch's own business: the scan must fail only when one of the analyzed branch's commits holds a secret, whatever the destination is called. Several ranges broke that rule: an unrelated-history PR, a rewritten or created default branch, workflow_dispatch and schedule scanned the full history of every branch; a new branch was compared with the default branch only (wrong for branches cut from dev-YY.MM.x); and a push merging the destination back in rescanned its commits. - pull_request scans base..head, the commits not on the PR base. - push, workflow_dispatch and schedule scan the ref's commits that are on no other branch (nor before the push), excluded through --remotes so the range does not grow with the branch count. An empty range passes without running gitleaks. - On the shallow PR checkout, only the base side is deepened, from the base SHA, until base..head holds no parentless commit, so the PR side is not fetched again. Co-Authored-By: Claude Opus 5.5 (1M context) Assisted-by: Claude Code (claude-opus-5-5) --- .github/workflows/gitleaks-analysis.yml | 161 ++++++++++++------------ CLAUDE.md | 2 +- 2 files changed, 78 insertions(+), 85 deletions(-) diff --git a/.github/workflows/gitleaks-analysis.yml b/.github/workflows/gitleaks-analysis.yml index 049313e5..307990c0 100644 --- a/.github/workflows/gitleaks-analysis.yml +++ b/.github/workflows/gitleaks-analysis.yml @@ -65,32 +65,39 @@ jobs: - name: Run secret scan env: 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 }} PUSH_BEFORE: ${{ github.event.before }} PUSH_AFTER: ${{ github.event.after }} - PUSH_REF: ${{ github.ref_name }} - PUSH_FULL_REF: ${{ github.ref }} - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} SERVER_URL: ${{ github.server_url }} FETCH_TOKEN: ${{ github.token }} run: | set -u - # 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, + # Only the commits the analyzed branch brings are scanned: secrets + # already on the destination (or any other) branch are that + # branch's own scans' business, so they never fail this one. No + # branch name is assumed: + # pull_request: base..head, the commits not on the PR base. + # push, workflow_dispatch, schedule: the commits of the ref that + # are on no other branch (nor before the push). + # 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. ZERO_SHA="0000000000000000000000000000000000000000" - FULL_SCAN="--text --full-history --all" LOG_OPTS="" HEAD_SHA="${PR_HEAD_SHA}" BASE_SHA="${PR_BASE_SHA}" - AFTER_SHA="${PUSH_AFTER}" + TIP_SHA="${PUSH_AFTER:-${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 @@ -115,26 +122,30 @@ jobs: # ref is missing. ref_commit() { git rev-parse --verify -q "$1^{commit}"; } - # Pull requests are checked out shallow (see "Compute fetch depth"). - # A boundary commit looks parentless, so gitleaks would scan its - # whole tree and report every pre-existing secret: it must never be - # in the scanned range. + # 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_hits_boundary() { - [ -s "${SHALLOW_FILE}" ] \ - && git log --format=%H ${LOG_OPTS} 2>/dev/null | grep -qFxf "${SHALLOW_FILE}" + range_untrusted() { + is_shallow \ + && [ -n "$(git rev-list --max-parents=0 -n 1 "${BASE_SHA}..${HEAD_SHA}")" ] } - # Deepens the shallow PR history, doubling the step on each round - # (32 to 1024 commits), then unshallows as a last resort. Fails once + # 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 --deepen="$((32 << $1))" "${BASE_SHA}" "${HEAD_SHA}" + fetch --depth="$(( ${PR_COMMITS:-0} + 1 + (32 << $1) ))" "${BASE_SHA}" elif [ "$1" -eq 6 ]; then fetch --unshallow "${BASE_SHA}" "${HEAD_SHA}" else @@ -143,6 +154,16 @@ jobs: [ "$(cat "${SHALLOW_FILE}" 2>/dev/null)" != "${before}" ] } + # git log arguments excluding every other branch (the ref's own + # branch and the origin/HEAD alias left out). Their length does not + # grow with the branch count, and they hold no space-separated + # empty item: gitleaks splits --log-opts on spaces. + other_branches() { + local own="" + [ "${FULL_REF}" = "refs/heads/${REF_NAME}" ] && own=" --exclude=origin/${REF_NAME}" + printf -- '--not%s --exclude=origin/HEAD --remotes=origin' "${own}" + } + # 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. @@ -151,59 +172,28 @@ jobs: 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 : ; do - if base="$(git merge-base "${BASE_SHA}" "${HEAD_SHA}" 2>/dev/null)"; then - # Every non-merge commit the PR brings, including commits - # merged in from other branches; the base branch history is - # excluded. - LOG_OPTS="--text --no-merges ${base}..${HEAD_SHA}" - range_hits_boundary || break - elif ! is_shallow; then - # Both commits are present but share no history: nothing to - # exclude, so scan everything rather than retry in vain. - echo "::notice::PR head and base share no history; scanning the full history." - LOG_OPTS="${FULL_SCAN}" - break - fi - # The base branch moved past the fetched depth since the PR - # forked: deepen until the fork point is reachable from both - # sides. If deepening fails, retry after a re-fetch. - LOG_OPTS="" - echo "Shallow history does not reach the PR fork point yet; deepening (round $((round + 1)))." + 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 ;; - push) - # The pushed commit must be in the clone, otherwise even a full - # scan would miss it: retry after a re-fetch. - has_commit "${AFTER_SHA}" || return 1 + push|workflow_dispatch|schedule) + # The tip must be in the clone, otherwise the scan would miss + # it: retry after a re-fetch. Push and other events get a full + # clone (fetch-depth 0), so every branch tip is available. + has_commit "${TIP_SHA}" || return 1 + prev="" if [ -n "${PUSH_BEFORE}" ] && [ "${PUSH_BEFORE}" != "${ZERO_SHA}" ] \ && has_commit "${PUSH_BEFORE}"; then - # Existing branch: scan the non-merge commits this push added. - LOG_OPTS="--text --no-merges ${PUSH_BEFORE}..${AFTER_SHA}" - elif [ "${PUSH_REF}" = "${DEFAULT_BRANCH}" ]; then - # Default branch created or rewritten (before is zero, empty - # or force-pushed away): nothing is "unique" to it, so the - # merge-base range below would be empty. Full scan. - LOG_OPTS="${FULL_SCAN}" - elif base="$(git merge-base "refs/remotes/origin/${DEFAULT_BRANCH}" "${AFTER_SHA}" 2>/dev/null)"; then - # New or rewritten branch: scan only the commits unique to it, - # i.e. since it diverged from the default branch. The shared - # history is assumed covered by the default branch's own push - # or scheduled scans, so a full scan would just re-report - # pre-existing findings. (Push events get a full clone, - # fetch-depth 0, so origin/ is available.) - LOG_OPTS="--text --no-merges ${base}..${AFTER_SHA}" - else - # Default branch ref missing or unrelated history: full scan. - # Over-scan is the safe direction. - LOG_OPTS="${FULL_SCAN}" + prev=" ^${PUSH_BEFORE}" fi - ;; - workflow_dispatch|schedule) - LOG_OPTS="${FULL_SCAN}" + LOG_OPTS="--text --no-merges ${TIP_SHA}${prev} $(other_branches)" ;; esac @@ -213,13 +203,13 @@ jobs: # 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 pushed commit (push), - # if the server still serves them. A missing push "before" SHA is - # handled by compute_range, not here. 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. + # SHAs (pull_request), or all branches plus the tip commit (other + # events), if the server still serves them. A missing push "before" + # SHA is handled by compute_range, not here. 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}" \ @@ -238,15 +228,15 @@ jobs: fi else fetch "+refs/heads/*:refs/remotes/origin/*" - if [ -n "${AFTER_SHA}" ] && ! has_commit "${AFTER_SHA}"; then - fetch "${AFTER_SHA}" + 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 push must not resolve to a same-name branch. - if ! has_commit "${AFTER_SHA}" \ - && [ "${PUSH_FULL_REF}" = "refs/heads/${PUSH_REF}" ] \ - && live="$(ref_commit "refs/remotes/origin/${PUSH_REF}")"; then - echo "::warning::Pushed commit ${AFTER_SHA} is gone; scanning the live ${PUSH_REF} tip ${live}." - AFTER_SHA="${live}" + # 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 @@ -268,7 +258,7 @@ jobs: esac if [ "${EVENT_NAME}" = "push" ] && [ "${PUSH_AFTER}" = "${ZERO_SHA}" ]; then - echo "::notice::${PUSH_REF} was deleted; nothing to scan." + echo "::notice::${REF_NAME} was deleted; nothing to scan." exit 0 fi @@ -293,6 +283,13 @@ jobs: attempt=$((attempt + 1)) done + COMMIT_COUNT="$(git log --format=%H ${LOG_OPTS} | wc -l)" + if [ "${COMMIT_COUNT}" -eq 0 ]; then + echo "::notice::The branch brings no commit of its own; nothing 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 @@ -306,11 +303,7 @@ jobs: if [ "${EXIT_CODE}" -eq 0 ]; then echo "No secrets found." elif [ "${EXIT_CODE}" -eq 2 ]; then - if [ "${LOG_OPTS}" = "${FULL_SCAN}" ]; then - echo "::error::Gitleaks has detected secrets in the repository history (all fetched refs)!" - else - echo "::error::Gitleaks has detected secrets in the scanned commits!" - fi + echo "::error::Gitleaks has detected secrets in the branch's commits!" exit 1 else # Fail closed: a scan that did not run (e.g. a broken .gitleaks.toml) diff --git a/CLAUDE.md b/CLAUDE.md index dbee4c92..52f3c843 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ Because consumers pin to `@main`, changes to workflows and the blocklist take ef - `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`. Installs the gitleaks release pinned by `GITLEAKS_VERSION` + `GITLEAKS_SHA256` (bump both together; Dependabot/Renovate don't) and runs `gitleaks detect` on a commit range chosen per event (PR commits, pushed commits, or full history), re-fetching with the job token when event SHAs are missing from the clone. Pull requests are checked out shallow (`fetch-depth` = PR commits + 2); the scan deepens that history until the PR fork point is reachable and never scans a shallow boundary commit (gitleaks would read it as a root and scan its whole tree). Other events get a full clone. Fails closed: leaks, gitleaks errors, unresolvable ranges and unsupported events all fail the job. `pull_request_target` always fails (not compliant with the security posture), from a first step that runs before checkout. +- `gitleaks-analysis.yml` — `workflow_call` (org repos call it on `pull_request` and `workflow_dispatch`; this repo also on push to `main` and on a schedule). Installs the gitleaks release pinned by `GITLEAKS_VERSION` + `GITLEAKS_SHA256` (bump both together; Dependabot/Renovate don't) and scans **only the commits the analyzed branch brings**, whatever the destination branch is called: `base..head` for a PR, and for other events the ref's commits that are on no other branch (nor before the push). Secrets already on the destination never fail the run; a secret in one of the branch's commits does. 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 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. From 85270f52dc00d75b3ba42150442bbd3d70cbe46f Mon Sep 17 00:00:00 2001 From: schapron Date: Wed, 7 Oct 2026 16:30:01 +0200 Subject: [PATCH 17/28] ci(secu): scan commits even if on other branches A commit must be scanned until it is on the destination branch, even when another branch (load, performance or security tests...) already holds it: excluding every other branch let such commits through, e.g. a dispatch on a branch whose commits sit on feature branches, or one push updating two branches. - push to an existing branch scans before..after, every pushed non-merge commit; a "before" missing after a force-push is fetched once while the server still serves it. - push creating a branch (or whose "before" is gone), workflow_dispatch and schedule have no destination nor "before" to exclude, so they scan the ref's whole history. - pull_request is unchanged: base..head. Co-Authored-By: Claude Opus 5.5 (1M context) Assisted-by: Claude Code (claude-opus-5-5) --- .github/workflows/gitleaks-analysis.yml | 51 +++++++++++++------------ CLAUDE.md | 2 +- 2 files changed, 28 insertions(+), 25 deletions(-) diff --git a/.github/workflows/gitleaks-analysis.yml b/.github/workflows/gitleaks-analysis.yml index 307990c0..68206cb3 100644 --- a/.github/workflows/gitleaks-analysis.yml +++ b/.github/workflows/gitleaks-analysis.yml @@ -80,13 +80,15 @@ jobs: run: | set -u - # Only the commits the analyzed branch brings are scanned: secrets - # already on the destination (or any other) branch are that - # branch's own scans' business, so they never fail this one. No - # branch name is assumed: + # 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. - # push, workflow_dispatch, schedule: the commits of the ref that - # are on no other branch (nor before the push). + # push to an existing branch: before..after, the pushed commits. + # push creating a branch (or whose "before" is gone), + # workflow_dispatch, schedule: no destination nor "before" 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, @@ -154,16 +156,6 @@ jobs: [ "$(cat "${SHALLOW_FILE}" 2>/dev/null)" != "${before}" ] } - # git log arguments excluding every other branch (the ref's own - # branch and the origin/HEAD alias left out). Their length does not - # grow with the branch count, and they hold no space-separated - # empty item: gitleaks splits --log-opts on spaces. - other_branches() { - local own="" - [ "${FULL_REF}" = "refs/heads/${REF_NAME}" ] && own=" --exclude=origin/${REF_NAME}" - printf -- '--not%s --exclude=origin/HEAD --remotes=origin' "${own}" - } - # 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. @@ -186,14 +178,25 @@ jobs: push|workflow_dispatch|schedule) # The tip must be in the clone, otherwise the scan would miss # it: retry after a re-fetch. Push and other events get a full - # clone (fetch-depth 0), so every branch tip is available. + # clone (fetch-depth 0). has_commit "${TIP_SHA}" || return 1 - prev="" - if [ -n "${PUSH_BEFORE}" ] && [ "${PUSH_BEFORE}" != "${ZERO_SHA}" ] \ - && has_commit "${PUSH_BEFORE}"; then - prev=" ^${PUSH_BEFORE}" + if [ "${EVENT_NAME}" = "push" ] && [ -n "${PUSH_BEFORE}" ] \ + && [ "${PUSH_BEFORE}" != "${ZERO_SHA}" ]; then + # A force-push leaves "before" out of the clone: fetch it once + # while the server still serves it. + if ! has_commit "${PUSH_BEFORE}" && [ -z "${BEFORE_FETCHED:-}" ]; then + BEFORE_FETCHED=1 + fetch "${PUSH_BEFORE}" + fi + if has_commit "${PUSH_BEFORE}"; then + LOG_OPTS="--text --no-merges ${PUSH_BEFORE}..${TIP_SHA}" + else + echo "::notice::Commit ${PUSH_BEFORE} (before the push) is gone; scanning the whole history of ${REF_NAME}." + LOG_OPTS="--text --no-merges ${TIP_SHA}" + fi + else + LOG_OPTS="--text --no-merges ${TIP_SHA}" fi - LOG_OPTS="--text --no-merges ${TIP_SHA}${prev} $(other_branches)" ;; esac @@ -285,7 +288,7 @@ jobs: COMMIT_COUNT="$(git log --format=%H ${LOG_OPTS} | wc -l)" if [ "${COMMIT_COUNT}" -eq 0 ]; then - echo "::notice::The branch brings no commit of its own; nothing to scan." + echo "::notice::No commit to scan." exit 0 fi echo "Scanning ${COMMIT_COUNT} commit(s) of the branch." @@ -303,7 +306,7 @@ jobs: if [ "${EXIT_CODE}" -eq 0 ]; then echo "No secrets found." elif [ "${EXIT_CODE}" -eq 2 ]; then - echo "::error::Gitleaks has detected secrets in the branch's commits!" + 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) diff --git a/CLAUDE.md b/CLAUDE.md index 52f3c843..b426cc5c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ Because consumers pin to `@main`, changes to workflows and the blocklist take ef - `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` (org repos call it on `pull_request` and `workflow_dispatch`; this repo also on push to `main` and on a schedule). Installs the gitleaks release pinned by `GITLEAKS_VERSION` + `GITLEAKS_SHA256` (bump both together; Dependabot/Renovate don't) and scans **only the commits the analyzed branch brings**, whatever the destination branch is called: `base..head` for a PR, and for other events the ref's commits that are on no other branch (nor before the push). Secrets already on the destination never fail the run; a secret in one of the branch's commits does. 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 all fail the job. `pull_request_target` always fails (not compliant with the security posture), from a first step that runs before checkout. +- `gitleaks-analysis.yml` — `workflow_call` (org repos call it on `pull_request` and `workflow_dispatch`; this repo also on push to `main` and on a schedule). Installs the gitleaks release pinned by `GITLEAKS_VERSION` + `GITLEAKS_SHA256` (bump both together; Dependabot/Renovate don't) and scans every non-merge commit of the analyzed branch, excluding only the commits already on the destination branch (whatever it is called, and whether or not they are on other branches): `base..head` for a PR, `before..after` for a push to an existing branch, and the ref's whole history when there is no destination nor `before` (new branch, `before` gone, `workflow_dispatch`, `schedule`). A PR never fails on secrets already on its base; it fails on a secret in one of its commits. 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 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. From e9f73157524f38400199c2966df917daba251189 Mon Sep 17 00:00:00 2001 From: schapron Date: Wed, 7 Oct 2026 17:46:49 +0200 Subject: [PATCH 18/28] ci(secu): run secret scan on PR and dispatch only The secret scan is a pull request gate; workflow_dispatch is kept to debug the pipeline. Push and schedule are no longer supported: the job fails closed on them, like on any other unsupported event, and security-checks.yml only calls the secret scan on pull_request and workflow_dispatch (its push and schedule runs keep the dependency scan). Co-Authored-By: Claude Opus 5.5 (1M context) Assisted-by: Claude Code (claude-opus-5-5) --- .github/workflows/gitleaks-analysis.yml | 59 +++++++------------------ .github/workflows/security-checks.yml | 2 + CLAUDE.md | 4 +- 3 files changed, 20 insertions(+), 45 deletions(-) diff --git a/.github/workflows/gitleaks-analysis.yml b/.github/workflows/gitleaks-analysis.yml index 68206cb3..4e8478ff 100644 --- a/.github/workflows/gitleaks-analysis.yml +++ b/.github/workflows/gitleaks-analysis.yml @@ -73,8 +73,6 @@ jobs: 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 }} - PUSH_BEFORE: ${{ github.event.before }} - PUSH_AFTER: ${{ github.event.after }} SERVER_URL: ${{ github.server_url }} FETCH_TOKEN: ${{ github.token }} run: | @@ -85,21 +83,18 @@ jobs: # 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. - # push to an existing branch: before..after, the pushed commits. - # push creating a branch (or whose "before" is gone), - # workflow_dispatch, schedule: no destination nor "before" to - # exclude, so the ref's whole history. + # 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. - ZERO_SHA="0000000000000000000000000000000000000000" LOG_OPTS="" HEAD_SHA="${PR_HEAD_SHA}" BASE_SHA="${PR_BASE_SHA}" - TIP_SHA="${PUSH_AFTER:-${EVENT_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 @@ -175,28 +170,12 @@ jobs: round=$((round + 1)) done ;; - push|workflow_dispatch|schedule) + workflow_dispatch) # The tip must be in the clone, otherwise the scan would miss - # it: retry after a re-fetch. Push and other events get a full - # clone (fetch-depth 0). + # it: retry after a re-fetch. The dispatch gets a full clone + # (fetch-depth 0). has_commit "${TIP_SHA}" || return 1 - if [ "${EVENT_NAME}" = "push" ] && [ -n "${PUSH_BEFORE}" ] \ - && [ "${PUSH_BEFORE}" != "${ZERO_SHA}" ]; then - # A force-push leaves "before" out of the clone: fetch it once - # while the server still serves it. - if ! has_commit "${PUSH_BEFORE}" && [ -z "${BEFORE_FETCHED:-}" ]; then - BEFORE_FETCHED=1 - fetch "${PUSH_BEFORE}" - fi - if has_commit "${PUSH_BEFORE}"; then - LOG_OPTS="--text --no-merges ${PUSH_BEFORE}..${TIP_SHA}" - else - echo "::notice::Commit ${PUSH_BEFORE} (before the push) is gone; scanning the whole history of ${REF_NAME}." - LOG_OPTS="--text --no-merges ${TIP_SHA}" - fi - else - LOG_OPTS="--text --no-merges ${TIP_SHA}" - fi + LOG_OPTS="--text --no-merges ${TIP_SHA}" ;; esac @@ -206,11 +185,10 @@ jobs: # 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 tip commit (other - # events), if the server still serves them. A missing push "before" - # SHA is handled by compute_range, not here. 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 + # 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() { @@ -246,25 +224,20 @@ jobs: } case "${EVENT_NAME}" in - pull_request|push|workflow_dispatch|schedule) ;; + pull_request|workflow_dispatch) ;; pull_request_target) # Defense in depth: the first step already rejects this event. echo "::error::pull_request_target is not allowed" exit 1 ;; *) - # Fail closed: for other events (e.g. merge_group) the checkout - # may not hold the commits under test. - echo "::error::Unsupported event '${EVENT_NAME}' (supported: pull_request, push, workflow_dispatch, schedule); failing the job." + # 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 - if [ "${EVENT_NAME}" = "push" ] && [ "${PUSH_AFTER}" = "${ZERO_SHA}" ]; then - echo "::notice::${REF_NAME} was deleted; nothing to scan." - exit 0 - fi - attempt=1 max_attempts=5 while : ; do @@ -275,7 +248,7 @@ jobs: 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 to re-trigger the scan." + 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 diff --git a/.github/workflows/security-checks.yml b/.github/workflows/security-checks.yml index 52c3c6f1..f918a367 100644 --- a/.github/workflows/security-checks.yml +++ b/.github/workflows/security-checks.yml @@ -19,5 +19,7 @@ jobs: dependency-scan: uses: centreon/security-tools/.github/workflows/dependency-analysis.yml@main + # The secret scan only supports pull_request and workflow_dispatch. secrets-scan: + if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' uses: centreon/security-tools/.github/workflows/gitleaks-analysis.yml@main diff --git a/CLAUDE.md b/CLAUDE.md index b426cc5c..27dbae05 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 PR/push to `main`, on a weekday cron, and on demand. It calls the two `workflow_call` workflows below (the secret scan on PR and on demand only). 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` (org repos call it on `pull_request` and `workflow_dispatch`; this repo also on push to `main` and on a schedule). Installs the gitleaks release pinned by `GITLEAKS_VERSION` + `GITLEAKS_SHA256` (bump both together; Dependabot/Renovate don't) and scans every non-merge commit of the analyzed branch, excluding only the commits already on the destination branch (whatever it is called, and whether or not they are on other branches): `base..head` for a PR, `before..after` for a push to an existing branch, and the ref's whole history when there is no destination nor `before` (new branch, `before` gone, `workflow_dispatch`, `schedule`). A PR never fails on secrets already on its base; it fails on a secret in one of its commits. 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 all fail the job. `pull_request_target` always fails (not compliant with the security posture), from a first step that runs before checkout. +- `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). Installs the gitleaks release pinned by `GITLEAKS_VERSION` + `GITLEAKS_SHA256` (bump both together; Dependabot/Renovate don't). 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. From 14a001a445518b5bc3d8f4dde2ccde774d6a5ab6 Mon Sep 17 00:00:00 2001 From: schapron Date: Wed, 7 Oct 2026 17:56:02 +0200 Subject: [PATCH 19/28] ci(secu): run security checks on PR and dispatch only Align security-checks.yml with the supported scan events: drop the push and schedule triggers, so the per-job event filter on the secret scan is no longer needed. Co-Authored-By: Claude Opus 5.5 (1M context) Assisted-by: Claude Code (claude-opus-5-5) --- .github/workflows/security-checks.yml | 7 ------- CLAUDE.md | 2 +- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/.github/workflows/security-checks.yml b/.github/workflows/security-checks.yml index f918a367..80e31c14 100644 --- a/.github/workflows/security-checks.yml +++ b/.github/workflows/security-checks.yml @@ -8,18 +8,11 @@ on: pull_request: branches: - main - push: - branches: - - main workflow_dispatch: - schedule: - - cron: 0 1 * * 1-5 jobs: dependency-scan: uses: centreon/security-tools/.github/workflows/dependency-analysis.yml@main - # The secret scan only supports pull_request and workflow_dispatch. secrets-scan: - if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' uses: centreon/security-tools/.github/workflows/gitleaks-analysis.yml@main diff --git a/CLAUDE.md b/CLAUDE.md index 27dbae05..b06cb56a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ 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 (the secret scan on PR and on demand only). 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. From 71dd023b9d9a652c23815692c5a9725fbae46b7f Mon Sep 17 00:00:00 2001 From: schapron Date: Mon, 12 Oct 2026 17:30:45 +0200 Subject: [PATCH 20/28] docs(secu): describe the Ubuntu 26.04 scan runners The runner paragraph still named centreon-security and ubuntu-24.04: describe the runners selected since #75, and that internal repositories get the self-hosted runner too. Refs: SECU-1249 Co-Authored-By: Claude Opus 5.5 (1M context) Assisted-by: Claude Code (claude-opus-5-5) --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index b06cb56a..be724f6e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`) From 1972fbebcb3a0ea52e32f62dfdba76b5ea60cc13 Mon Sep 17 00:00:00 2001 From: schapron Date: Thu, 8 Oct 2026 10:56:37 +0200 Subject: [PATCH 21/28] ci(secu): reuse the runner's gitleaks when pinned Some runners (centreon-ubuntu-26.04-small) already ship gitleaks. Keep it when `gitleaks version` reports the pinned GITLEAKS_VERSION, and install the pinned release, verified against GITLEAKS_SHA256, in every other case: gitleaks missing (e.g. the public ubuntu-26.04 runner) or any other version. Refs: SECU-1249 Co-Authored-By: Claude Opus 5.5 (1M context) Assisted-by: Claude Code (claude-opus-5-5) --- .github/workflows/gitleaks-analysis.yml | 10 ++++++++++ CLAUDE.md | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/gitleaks-analysis.yml b/.github/workflows/gitleaks-analysis.yml index 4e8478ff..70109a23 100644 --- a/.github/workflows/gitleaks-analysis.yml +++ b/.github/workflows/gitleaks-analysis.yml @@ -43,6 +43,16 @@ jobs: - name: Install Gitleaks run: | + # Some runners (e.g. centreon-ubuntu-26.04-small) ship gitleaks: keep + # it only when it is the pinned version, otherwise (missing, or any + # other version) install the pinned release. + CURRENT="$(gitleaks version 2>/dev/null || true)" + if [ "${CURRENT#v}" = "${GITLEAKS_VERSION}" ]; then + echo "gitleaks ${GITLEAKS_VERSION} is already installed: $(command -v gitleaks)." + exit 0 + fi + echo "Found gitleaks version '${CURRENT:-none}', expected ${GITLEAKS_VERSION}: installing it." + ASSET="gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" BASE_URL="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}" diff --git a/CLAUDE.md b/CLAUDE.md index be724f6e..5ffd2a3f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ Because consumers pin to `@main`, changes to workflows and the blocklist take ef - `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`, supporting `pull_request` and `workflow_dispatch` only (org repos call it on both; `workflow_dispatch` is mostly for debugging the pipeline). Installs the gitleaks release pinned by `GITLEAKS_VERSION` + `GITLEAKS_SHA256` (bump both together; Dependabot/Renovate don't). 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. +- `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). Keeps the runner's own gitleaks when `gitleaks version` matches `GITLEAKS_VERSION` (e.g. on `centreon-ubuntu-26.04-small`), and otherwise installs that release, verified against `GITLEAKS_SHA256` (bump both together; Dependabot/Renovate don't). 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. From 89ae7c9079b296b00de30009a0897c19ce6afd48 Mon Sep 17 00:00:00 2001 From: schapron Date: Thu, 8 Oct 2026 11:03:01 +0200 Subject: [PATCH 22/28] ci(secu): resolve gitleaks version like the runners Use the rule of the centreon-ubuntu-26.04 runner images (centreon-images, runners/base-ubuntu-26.04/Dockerfile), so the private runner's preinstalled gitleaks is the expected one and gets reused: GITLEAKS_VERSION "latest" is the newest stable release published at least 4 days before the run, and a version can still be pinned. When the runner's gitleaks differs, the release for the runner's architecture is installed and verified against its checksums file. The GitHub API is called with the job token, passed through curl's config on stdin, to avoid the anonymous rate limit shared behind an egress IP. Refs: SECU-1249 Co-Authored-By: Claude Opus 5.5 (1M context) Assisted-by: Claude Code (claude-opus-5-5) --- .github/workflows/gitleaks-analysis.yml | 65 +++++++++++++++++-------- CLAUDE.md | 2 +- 2 files changed, 47 insertions(+), 20 deletions(-) diff --git a/.github/workflows/gitleaks-analysis.yml b/.github/workflows/gitleaks-analysis.yml index 70109a23..fef15a7a 100644 --- a/.github/workflows/gitleaks-analysis.yml +++ b/.github/workflows/gitleaks-analysis.yml @@ -4,11 +4,11 @@ on: workflow_call: env: - # Pinned gitleaks release. Bump both together: GITLEAKS_SHA256 is the sha256 - # of gitleaks__linux_x64.tar.gz, checked against the release's - # checksums file when bumping. - GITLEAKS_VERSION: "8.30.1" - GITLEAKS_SHA256: "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb" + # 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" jobs: secret-scan: @@ -42,31 +42,58 @@ jobs: persist-credentials: false - name: Install Gitleaks + env: + API_TOKEN: ${{ github.token }} run: | - # Some runners (e.g. centreon-ubuntu-26.04-small) ship gitleaks: keep - # it only when it is the pinned version, otherwise (missing, or any - # other version) install the pinned release. + set -o 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 + # The job token lifts the anonymous API rate limit, shared by every + # job behind the same egress IP. It goes through curl's config on + # stdin, never on its command line. + cutoff="$(date -u -d '-4 days' +%Y-%m-%dT%H:%M:%SZ)" + VERSION="$(printf 'header = "Authorization: Bearer %s"\n' "${API_TOKEN}" \ + | curl -fsSL --retry 3 -K - "https://api.github.com/repos/gitleaks/gitleaks/releases?per_page=30" \ + | jq -r --arg cutoff "${cutoff}" '.[] + | select((.draft or .prerelease | not) and .published_at <= $cutoff and (.tag_name | test("^v[0-9]+\\.[0-9]+\\.[0-9]+$"))) + | .tag_name | ltrimstr("v")' \ + | sort -V | tail -1)" || VERSION="" + if [ -z "${VERSION}" ]; then + echo "::error::Could not resolve the latest gitleaks release from the GitHub API." + exit 1 + fi + fi + unset API_TOKEN + + # Some runners (e.g. centreon-ubuntu-26.04-small) 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}" = "${GITLEAKS_VERSION}" ]; then - echo "gitleaks ${GITLEAKS_VERSION} is already installed: $(command -v gitleaks)." + if [ "${CURRENT#v}" = "${VERSION}" ]; then + echo "gitleaks ${VERSION} is already installed: $(command -v gitleaks)." exit 0 fi - echo "Found gitleaks version '${CURRENT:-none}', expected ${GITLEAKS_VERSION}: installing it." + echo "Found gitleaks version '${CURRENT:-none}', expected ${VERSION}: installing it." - ASSET="gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" - BASE_URL="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}" + TARBALL="gitleaks_${VERSION}_linux_${ARCH}.tar.gz" + BASE_URL="https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}" workdir="$(mktemp -d)" cd "$workdir" - curl -sSfL -o "${ASSET}" "${BASE_URL}/${ASSET}" - - # Verify against the pinned digest, not a checksums file served - # from the same release as the binary. - echo "${GITLEAKS_SHA256} ${ASSET}" | sha256sum -c - + 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 "${ASSET}" -C "$HOME/.local/bin" gitleaks + tar -xzf "${TARBALL}" -C "$HOME/.local/bin" gitleaks echo "$HOME/.local/bin" >> "$GITHUB_PATH" "$HOME/.local/bin/gitleaks" version diff --git a/CLAUDE.md b/CLAUDE.md index 5ffd2a3f..38a40b10 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ Because consumers pin to `@main`, changes to workflows and the blocklist take ef - `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`, supporting `pull_request` and `workflow_dispatch` only (org repos call it on both; `workflow_dispatch` is mostly for debugging the pipeline). Keeps the runner's own gitleaks when `gitleaks version` matches `GITLEAKS_VERSION` (e.g. on `centreon-ubuntu-26.04-small`), and otherwise installs that release, verified against `GITLEAKS_SHA256` (bump both together; Dependabot/Renovate don't). 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. +- `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 (GitHub API, called with the job token); set a version to pin it. Keeps the runner's own gitleaks when `gitleaks version` matches (e.g. on `centreon-ubuntu-26.04-small`), 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. From 1c69836e1563162676ecb72f6a76669c61fac51a Mon Sep 17 00:00:00 2001 From: schapron Date: Thu, 8 Oct 2026 11:33:24 +0200 Subject: [PATCH 23/28] ci(secu): resolve gitleaks version without a token The latest-release lookup sent the job token to the GitHub API only to lift the anonymous rate limit. Read the public releases feed instead: it needs no token and no API quota, and its date is at or after the publication date, so the 4-day delay still holds. Drafts are not in the feed and only vX.Y.Z tags are kept. jq is no longer needed. Refs: SECU-1249 Co-Authored-By: Claude Opus 5.5 (1M context) Assisted-by: Claude Code (claude-opus-5-5) --- .github/workflows/gitleaks-analysis.yml | 26 +++++++++++++------------ CLAUDE.md | 2 +- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/.github/workflows/gitleaks-analysis.yml b/.github/workflows/gitleaks-analysis.yml index fef15a7a..72acb759 100644 --- a/.github/workflows/gitleaks-analysis.yml +++ b/.github/workflows/gitleaks-analysis.yml @@ -42,8 +42,6 @@ jobs: persist-credentials: false - name: Install Gitleaks - env: - API_TOKEN: ${{ github.token }} run: | set -o pipefail @@ -55,22 +53,26 @@ jobs: VERSION="${GITLEAKS_VERSION}" if [ "${VERSION}" = "latest" ]; then - # The job token lifts the anonymous API rate limit, shared by every - # job behind the same egress IP. It goes through curl's config on - # stdin, never on its command line. + # 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="$(printf 'header = "Authorization: Bearer %s"\n' "${API_TOKEN}" \ - | curl -fsSL --retry 3 -K - "https://api.github.com/repos/gitleaks/gitleaks/releases?per_page=30" \ - | jq -r --arg cutoff "${cutoff}" '.[] - | select((.draft or .prerelease | not) and .published_at <= $cutoff and (.tag_name | test("^v[0-9]+\\.[0-9]+\\.[0-9]+$"))) - | .tag_name | ltrimstr("v")' \ + 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 - echo "::error::Could not resolve the latest gitleaks release from the GitHub API." + echo "::error::Could not resolve the latest gitleaks release from its releases feed." exit 1 fi fi - unset API_TOKEN # Some runners (e.g. centreon-ubuntu-26.04-small) ship gitleaks, built # with the same rule: keep it when it is the expected version, diff --git a/CLAUDE.md b/CLAUDE.md index 38a40b10..75829595 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ Because consumers pin to `@main`, changes to workflows and the blocklist take ef - `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`, 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 (GitHub API, called with the job token); set a version to pin it. Keeps the runner's own gitleaks when `gitleaks version` matches (e.g. on `centreon-ubuntu-26.04-small`), 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. +- `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); set a version to pin it. Keeps the runner's own gitleaks when `gitleaks version` matches (e.g. on `centreon-ubuntu-26.04-small`), 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. From a6ac6ab1cb54bed007da9dd826091fd75b110b6a Mon Sep 17 00:00:00 2001 From: schapron Date: Thu, 8 Oct 2026 11:37:16 +0200 Subject: [PATCH 24/28] ci(secu): fall back to gitleaks 8.30.1 if needed An unreachable releases feed failed the job, blocking every pull request of the org. Install GITLEAKS_FALLBACK_VERSION (8.30.1) instead, still verified against its checksums file, and emit a gitleaks-version-fallback warning so the issue can be found and investigated. Refs: SECU-1249 Co-Authored-By: Claude Opus 5.5 (1M context) Assisted-by: Claude Code (claude-opus-5-5) --- .github/workflows/gitleaks-analysis.yml | 8 ++++++-- CLAUDE.md | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/gitleaks-analysis.yml b/.github/workflows/gitleaks-analysis.yml index 72acb759..0df500ef 100644 --- a/.github/workflows/gitleaks-analysis.yml +++ b/.github/workflows/gitleaks-analysis.yml @@ -9,6 +9,8 @@ env: # 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: @@ -69,8 +71,10 @@ jobs: ' "${cutoff}" \ | sort -V | tail -1)" || VERSION="" if [ -z "${VERSION}" ]; then - echo "::error::Could not resolve the latest gitleaks release from its releases feed." - exit 1 + # 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 diff --git a/CLAUDE.md b/CLAUDE.md index 75829595..940439d0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ Because consumers pin to `@main`, changes to workflows and the blocklist take ef - `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`, 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); set a version to pin it. Keeps the runner's own gitleaks when `gitleaks version` matches (e.g. on `centreon-ubuntu-26.04-small`), 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. +- `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 (e.g. on `centreon-ubuntu-26.04-small`), 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. From 56075db5db15c064edb814f7486c86e6dd1f8052 Mon Sep 17 00:00:00 2001 From: schapron Date: Mon, 12 Oct 2026 17:32:34 +0200 Subject: [PATCH 25/28] docs(secu): gitleaks ships on all Ubuntu 26.04 runners Since #75, pull request scans run on centreon-ubuntu-26.04-slim: both the slim and small runners are built from base-ubuntu-26.04, which ships gitleaks. Refs: SECU-1249 Co-Authored-By: Claude Opus 5.5 (1M context) Assisted-by: Claude Code (claude-opus-5-5) --- .github/workflows/gitleaks-analysis.yml | 2 +- CLAUDE.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/gitleaks-analysis.yml b/.github/workflows/gitleaks-analysis.yml index 0df500ef..0e36577a 100644 --- a/.github/workflows/gitleaks-analysis.yml +++ b/.github/workflows/gitleaks-analysis.yml @@ -78,7 +78,7 @@ jobs: fi fi - # Some runners (e.g. centreon-ubuntu-26.04-small) ship gitleaks, built + # 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)" diff --git a/CLAUDE.md b/CLAUDE.md index 940439d0..66bcc106 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ Because consumers pin to `@main`, changes to workflows and the blocklist take ef - `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`, 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 (e.g. on `centreon-ubuntu-26.04-small`), 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. +- `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. From ef14ed2543d1e4d79164a366aa6c13a25f430428 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Chapron?= <34628915+sc979@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:04:05 +0200 Subject: [PATCH 26/28] Update .github/workflows/gitleaks-analysis.yml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Stéphane Chapron <34628915+sc979@users.noreply.github.com> --- .github/workflows/gitleaks-analysis.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.github/workflows/gitleaks-analysis.yml b/.github/workflows/gitleaks-analysis.yml index 0e36577a..f9755959 100644 --- a/.github/workflows/gitleaks-analysis.yml +++ b/.github/workflows/gitleaks-analysis.yml @@ -47,12 +47,6 @@ jobs: run: | set -o 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 From b2f00a9da42a987957214933946f763669c5ab07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Chapron?= <34628915+sc979@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:04:15 +0200 Subject: [PATCH 27/28] Update .github/workflows/gitleaks-analysis.yml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Stéphane Chapron <34628915+sc979@users.noreply.github.com> --- .github/workflows/gitleaks-analysis.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/workflows/gitleaks-analysis.yml b/.github/workflows/gitleaks-analysis.yml index f9755959..79bc3cf1 100644 --- a/.github/workflows/gitleaks-analysis.yml +++ b/.github/workflows/gitleaks-analysis.yml @@ -262,11 +262,6 @@ jobs: case "${EVENT_NAME}" in pull_request|workflow_dispatch) ;; - pull_request_target) - # Defense in depth: the first step already rejects this event. - echo "::error::pull_request_target is not allowed" - exit 1 - ;; *) # Fail closed: other events (push, schedule, merge_group…) are # not supported. From e607109a9cd3d6697c0e6b304fa009d13c4e8c49 Mon Sep 17 00:00:00 2001 From: schapron Date: Wed, 14 Oct 2026 14:41:39 +0200 Subject: [PATCH 28/28] fix(secu): restore gitleaks download on runners Runners without the expected gitleaks (e.g. GitHub-hosted ubuntu-26.04) failed the install with a bare curl 404: the runner architecture detection had been dropped, so the tarball URL had an empty architecture. Restore it, and enable nounset in the install step so an undefined variable fails with an explicit error instead of a broken URL. Assisted-by: Claude Code (claude-opus-5-5) --- .github/workflows/gitleaks-analysis.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/gitleaks-analysis.yml b/.github/workflows/gitleaks-analysis.yml index 79bc3cf1..ae295273 100644 --- a/.github/workflows/gitleaks-analysis.yml +++ b/.github/workflows/gitleaks-analysis.yml @@ -45,7 +45,13 @@ jobs: - name: Install Gitleaks run: | - set -o pipefail + 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