Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
e93420c
feat(secu): gitleaks v5 custom
sc979 Jul 27, 2026
7ff7cc0
enh(secu): manage invalid-revision range error
sc979 Jul 28, 2026
701f941
enh PR tpl
sc979 Jul 28, 2026
59ba89b
Apply suggestions from code review
sc979 Jul 28, 2026
6e3bd71
Apply suggestion from @sc979
sc979 Jul 28, 2026
eafdad1
Apply suggestions from code review
sc979 Jul 29, 2026
75b9427
Apply suggestion from @sc979
sc979 Jul 29, 2026
7f7dcbb
Apply suggestion from @sc979
sc979 Jul 29, 2026
22e80c8
Apply suggestion from @sc979
sc979 Jul 29, 2026
586551a
Apply suggestion from @sc979
sc979 Jul 29, 2026
b36d793
remove concurrency
sc979 Jul 29, 2026
e86eff3
Update gitleaks-analysis.yml
sc979 Jul 29, 2026
8fec2c5
ci(secu): limit new-branch scan to its own commits
sc979 Jul 29, 2026
38eed42
ci(secu): make gitleaks scan robust and fail-closed
sc979 Oct 5, 2026
197d7fc
ci(secu): avoid false leaks on shallow PR checkout
sc979 Oct 7, 2026
a99784a
ci(secu): only fail on the branch's own secrets
sc979 Oct 7, 2026
85270f5
ci(secu): scan commits even if on other branches
sc979 Oct 7, 2026
e9f7315
ci(secu): run secret scan on PR and dispatch only
sc979 Oct 7, 2026
14a001a
ci(secu): run security checks on PR and dispatch only
sc979 Oct 7, 2026
71dd023
docs(secu): describe the Ubuntu 26.04 scan runners
sc979 Oct 12, 2026
1972fbe
ci(secu): reuse the runner's gitleaks when pinned
sc979 Oct 8, 2026
89ae7c9
ci(secu): resolve gitleaks version like the runners
sc979 Oct 8, 2026
1c69836
ci(secu): resolve gitleaks version without a token
sc979 Oct 8, 2026
a6ac6ab
ci(secu): fall back to gitleaks 8.30.1 if needed
sc979 Oct 8, 2026
56075db
docs(secu): gitleaks ships on all Ubuntu 26.04 runners
sc979 Oct 12, 2026
ef14ed2
Update .github/workflows/gitleaks-analysis.yml
sc979 Oct 13, 2026
b2f00a9
Update .github/workflows/gitleaks-analysis.yml
sc979 Oct 13, 2026
e607109
fix(secu): restore gitleaks download on runners
sc979 Oct 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
304 changes: 297 additions & 7 deletions .github/workflows/gitleaks-analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,31 @@ name: gitleaks
on:
workflow_call:

env:
# Same rule as the centreon-ubuntu-26.04 runner images (centreon-images,
# runners/base-ubuntu-26.04/Dockerfile): "latest" = newest stable release
# published at least 4 days before the run. Set a version (e.g. "8.30.1")
# to pin it.
GITLEAKS_VERSION: "latest"
# Installed when "latest" cannot be resolved, so the scan still runs.
GITLEAKS_FALLBACK_VERSION: "8.30.1"

jobs:
secret-scan:
name: Run Gitleaks
runs-on: ${{ github.repository_visibility != 'public' && (github.repository_owner == 'centreon' && (github.event.pull_request.commits && 'centreon-ubuntu-26.04-slim' || 'centreon-ubuntu-26.04-small') || github.repository_owner == 'quanta-computing' && 'quanta-security') || 'ubuntu-26.04' }}
permissions:
contents: read
pull-requests: read

steps:
# pull_request_target is not compliant with the security posture: the run
# always fails, before anything is checked out or executed.
- name: Reject pull_request_target
if: github.event_name == 'pull_request_target'
run: |
echo "::error::pull_request_target is not allowed"
exit 1

# Pull requests only scan their own commits: fetch them plus the merge and base commits, full history otherwise
- name: Compute fetch depth
id: fetch-depth
Expand All @@ -27,11 +43,285 @@ jobs:
fetch-depth: ${{ steps.fetch-depth.outputs.value }}
persist-credentials: false

- name: Install Gitleaks
run: |
set -uo pipefail

case "${RUNNER_ARCH}" in
X64) ARCH=x64 ;;
ARM64) ARCH=arm64 ;;
*) echo "::error::Unsupported runner architecture '${RUNNER_ARCH}'."; exit 1 ;;
esac

VERSION="${GITLEAKS_VERSION}"
if [ "${VERSION}" = "latest" ]; then
# Read the public releases feed: no token, no API rate limit. Its
# <updated> date is at or after the publication date, so the
# 4-day delay is never shortened. Drafts are not in the feed, and
# only vX.Y.Z tags are kept, which leaves pre-releases out.
cutoff="$(date -u -d '-4 days' +%Y-%m-%dT%H:%M:%SZ)"
VERSION="$(curl -fsSL --retry 3 "https://github.com/gitleaks/gitleaks/releases.atom" \
| python3 -I -c '
import re, sys, xml.etree.ElementTree as ET
ns = {"a": "http://www.w3.org/2005/Atom"}
for entry in ET.parse(sys.stdin).getroot().findall("a:entry", ns):
tag = entry.findtext("a:id", "", ns).rsplit("/", 1)[-1]
if re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+", tag) and entry.findtext("a:updated", "", ns) <= sys.argv[1]:
print(tag[1:])
' "${cutoff}" \
| sort -V | tail -1)" || VERSION=""
if [ -z "${VERSION}" ]; then
# Do not block the run: fall back to a known release, still
# verified against its checksums file below.
VERSION="${GITLEAKS_FALLBACK_VERSION}"
echo "::warning::gitleaks-version-fallback: could not resolve the latest gitleaks release from its releases feed; using ${VERSION}."
fi
fi

# The centreon-ubuntu-26.04-* runners ship gitleaks, built
# with the same rule: keep it when it is the expected version,
# otherwise (missing, or any other version) install that release.
CURRENT="$(gitleaks version 2>/dev/null || true)"
if [ "${CURRENT#v}" = "${VERSION}" ]; then
echo "gitleaks ${VERSION} is already installed: $(command -v gitleaks)."
exit 0
fi
echo "Found gitleaks version '${CURRENT:-none}', expected ${VERSION}: installing it."

TARBALL="gitleaks_${VERSION}_linux_${ARCH}.tar.gz"
BASE_URL="https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}"

workdir="$(mktemp -d)"
cd "$workdir"

curl -fsSL --retry 3 -o "${TARBALL}" "${BASE_URL}/${TARBALL}"
curl -fsSL --retry 3 "${BASE_URL}/gitleaks_${VERSION}_checksums.txt" \
| grep " ${TARBALL}\$" | sha256sum -c -

mkdir -p "$HOME/.local/bin"
tar -xzf "${TARBALL}" -C "$HOME/.local/bin" gitleaks
echo "$HOME/.local/bin" >> "$GITHUB_PATH"

"$HOME/.local/bin/gitleaks" version
cd / && rm -rf "$workdir"

- name: Run secret scan
uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e # v3.0.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITLEAKS_LICENSE: "Centreon"
GITLEAKS_ENABLE_COMMENTS: false
GITLEAKS_ENABLE_UPLOAD_ARTIFACT: false
GITLEAKS_ENABLE_SUMMARY: false
EVENT_NAME: ${{ github.event_name }}
EVENT_SHA: ${{ github.sha }}
REF_NAME: ${{ github.ref_name }}
FULL_REF: ${{ github.ref }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_COMMITS: ${{ github.event.pull_request.commits }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_BASE_REF: ${{ github.event.pull_request.base.ref }}
SERVER_URL: ${{ github.server_url }}
FETCH_TOKEN: ${{ github.token }}
run: |
set -u

# Every non-merge commit of the analyzed branch is scanned; only
# the commits already on the destination branch are excluded,
# whether or not they are also on other branches. No branch name
# is assumed:
# pull_request: base..head, the commits not on the PR base.
# workflow_dispatch: no destination to exclude, so the ref's
# whole history.
# The event's commit SHAs can be unreachable in the clone (e.g.
# force-pushed away between the event and the checkout, which then
# fails with "invalid revision range"). Resolve the range in a loop,
# re-fetching between attempts, and scan only once it is valid.
# --text keeps a repo's .gitattributes (e.g. "* -diff") from hiding
# file contents from the scan.
LOG_OPTS=""
HEAD_SHA="${PR_HEAD_SHA}"
BASE_SHA="${PR_BASE_SHA}"
TIP_SHA="${EVENT_SHA}"

# persist-credentials: false leaves no token in .git/config, so the
# re-fetches below authenticate on their own. The header is set on
# each git process only, through env-scoped config
# (GIT_CONFIG_COUNT/KEY/VALUE): never written to disk, never on a
# command line, masked in the logs, and not exported to gitleaks.
FETCH_AUTH="$(printf 'x-access-token:%s' "${FETCH_TOKEN}" | base64 -w0)"
echo "::add-mask::${FETCH_AUTH}"
unset FETCH_TOKEN

fetch() {
GIT_CONFIG_COUNT=1 \
GIT_CONFIG_KEY_0="http.${SERVER_URL}/.extraheader" \
GIT_CONFIG_VALUE_0="AUTHORIZATION: basic ${FETCH_AUTH}" \
git fetch --no-tags --force origin "$@" \
|| echo "::warning::git fetch failed: $*"
}

has_commit() { [ -n "$1" ] && git cat-file -e "$1^{commit}" 2>/dev/null; }

# Prints the commit a ref points to; prints nothing and fails if the
# ref is missing.
ref_commit() { git rev-parse --verify -q "$1^{commit}"; }

# Pull requests are checked out shallow (see "Compute fetch depth"),
# where a boundary commit looks parentless: gitleaks would scan its
# whole tree. A parentless commit in base..head is either such a
# boundary or shared history the base side does not reach yet
# (the base branch moved past the fetched depth): either way the
# range is not trusted until the history is deeper. On a complete
# clone, a root commit in the range is genuinely the branch's own.
SHALLOW_FILE="$(git rev-parse --git-path shallow)"
is_shallow() { [ "$(git rev-parse --is-shallow-repository)" = "true" ]; }
range_untrusted() {
is_shallow \
&& [ -n "$(git rev-list --max-parents=0 -n 1 "${BASE_SHA}..${HEAD_SHA}")" ]
}

# Deepens the base side only (the PR side already holds all its
# commits), 32 to 1024 more commits doubling each round, then
# unshallows as a last resort (e.g. unrelated histories). Fails once
# out of rounds, or if a fetch brought nothing (fetch() never fails:
# compare the boundaries instead).
deepen() { # round
local before
before="$(cat "${SHALLOW_FILE}" 2>/dev/null)"
if [ "$1" -lt 6 ]; then
fetch --depth="$(( ${PR_COMMITS:-0} + 1 + (32 << $1) ))" "${BASE_SHA}"
elif [ "$1" -eq 6 ]; then
fetch --unshallow "${BASE_SHA}" "${HEAD_SHA}"
else
return 1
fi
[ "$(cat "${SHALLOW_FILE}" 2>/dev/null)" != "${before}" ]
}

# Sets LOG_OPTS; returns non-zero if the range is unresolvable. The
# range is checked by running git log with the same --log-opts
# gitleaks will receive.
compute_range() {
case "${EVENT_NAME}" in
pull_request)
has_commit "${HEAD_SHA}" || return 1
has_commit "${BASE_SHA}" || return 1
# Every non-merge commit the PR brings, including commits
# merged in from other branches, and its whole history if it
# shares none with the base; the base branch is excluded.
LOG_OPTS="--text --no-merges ${BASE_SHA}..${HEAD_SHA}"
round=0
while range_untrusted; do
echo "Shallow history does not cover the PR range yet; deepening the base side (round $((round + 1)))."
deepen "${round}" || return 1
round=$((round + 1))
done
;;
workflow_dispatch)
# The tip must be in the clone, otherwise the scan would miss
# it: retry after a re-fetch. The dispatch gets a full clone
# (fetch-depth 0).
has_commit "${TIP_SHA}" || return 1
LOG_OPTS="--text --no-merges ${TIP_SHA}"
;;
esac

git log ${LOG_OPTS} >/dev/null 2>&1 || return 1
return 0
}

# actions/checkout can't be re-run in a loop, so re-fetch only what
# the range needs: the PR head and base branch plus the PR head/base
# SHAs (pull_request), or all branches plus the dispatched commit
# (workflow_dispatch), if the server still serves them. Only the
# history is refreshed; gitleaks' config and ignore files keep
# coming from the initial checkout. If an event SHA can no longer be fetched
# (force-pushed away), fall back to the live tip of its branch, so
# the scan covers what the branch now holds.
refetch() {
if [ "${EVENT_NAME}" = "pull_request" ] && [ -n "${PR_NUMBER}" ]; then
fetch "+refs/pull/${PR_NUMBER}/head:refs/remotes/origin/pr/${PR_NUMBER}" \
"+refs/heads/${PR_BASE_REF}:refs/remotes/origin/${PR_BASE_REF}"
has_commit "${HEAD_SHA}" || fetch "${HEAD_SHA}"
has_commit "${BASE_SHA}" || fetch "${BASE_SHA}"
if ! has_commit "${HEAD_SHA}" \
&& live="$(ref_commit "refs/remotes/origin/pr/${PR_NUMBER}")"; then
echo "::warning::PR head ${HEAD_SHA} is gone; scanning the live PR tip ${live}."
HEAD_SHA="${live}"
fi
if ! has_commit "${BASE_SHA}" \
&& live="$(ref_commit "refs/remotes/origin/${PR_BASE_REF}")"; then
echo "::warning::PR base ${BASE_SHA} is gone; using the live ${PR_BASE_REF} tip ${live}."
BASE_SHA="${live}"
fi
else
fetch "+refs/heads/*:refs/remotes/origin/*"
if [ -n "${TIP_SHA}" ] && ! has_commit "${TIP_SHA}"; then
fetch "${TIP_SHA}"
# Live-tip fallback for branches only: refs/remotes/origin/ holds
# branches, so a tag must not resolve to a same-name branch.
if ! has_commit "${TIP_SHA}" \
&& [ "${FULL_REF}" = "refs/heads/${REF_NAME}" ] \
&& live="$(ref_commit "refs/remotes/origin/${REF_NAME}")"; then
echo "::warning::Commit ${TIP_SHA} is gone; scanning the live ${REF_NAME} tip ${live}."
TIP_SHA="${live}"
fi
fi
fi
}

case "${EVENT_NAME}" in
pull_request|workflow_dispatch) ;;
*)
# Fail closed: other events (push, schedule, merge_group…) are
# not supported.
echo "::error::Unsupported event '${EVENT_NAME}' (supported: pull_request, workflow_dispatch); failing the job."
exit 1
;;
esac

attempt=1
max_attempts=5
while : ; do
if compute_range; then
echo "::notice::Commit range resolved on attempt ${attempt}: ${LOG_OPTS}"
break
fi

if [ "${attempt}" -ge "${max_attempts}" ]; then
echo "::error::Could not resolve a valid commit range after ${max_attempts} attempts; failing the job."
echo "::error::Check the git fetch warnings above. If there are none, the event's commits and their branch tip are no longer reachable (branch deleted or history rewritten): push a new commit or re-run the dispatch."
exit 1
fi

delay=$((attempt * 5))
echo "Commit range not resolvable yet; re-fetching refs in ${delay}s (attempt ${attempt}/${max_attempts})."
sleep "${delay}"
refetch
attempt=$((attempt + 1))
done

COMMIT_COUNT="$(git log --format=%H ${LOG_OPTS} | wc -l)"
if [ "${COMMIT_COUNT}" -eq 0 ]; then
echo "::notice::No commit to scan."
exit 0
fi
echo "Scanning ${COMMIT_COUNT} commit(s) of the branch."

# --exit-code=2 separates "leaks found" (2) from gitleaks errors
# (1, e.g. a broken .gitleaks.toml), which fail closed below.
EXIT_CODE=0
gitleaks detect \
--source . \
--log-opts="${LOG_OPTS}" \
--exit-code=2 \
--redact \
--verbose || EXIT_CODE=$?

if [ "${EXIT_CODE}" -eq 0 ]; then
echo "No secrets found."
elif [ "${EXIT_CODE}" -eq 2 ]; then
echo "::error::Gitleaks has detected secrets in the scanned commits!"
exit 1
else
# Fail closed: a scan that did not run (e.g. a broken .gitleaks.toml)
# must not pass the gate.
echo "::error::Gitleaks failed to run (exit code: ${EXIT_CODE}); the scan did not complete, failing the job."
exit 1
fi
5 changes: 0 additions & 5 deletions .github/workflows/security-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,7 @@ on:
pull_request:
branches:
- main
push:
branches:
- main
workflow_dispatch:
schedule:
- cron: 0 1 * * 1-5

jobs:
dependency-scan:
Expand Down
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,19 @@ 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/<file>.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/<file>.yml@main`.
- `dependency-analysis.yml` — `workflow_call` + runs on PR. Two responsibilities:
1. **Lockfile compliance** — enforces PNPM only (rejects `yarn.lock` / `package-lock.json`), requires a lockfile, and enforces a minimum `lockfileVersion` (`8.9.9`).
2. **Blocklist scan** — downloads `compromised-packages.txt` from `main` and greps every `pnpm-lock.yaml` for `name@version` matches.
- `gitleaks-analysis.yml` — `workflow_call`. Runs `gitleaks/gitleaks-action`.
- `gitleaks-analysis.yml` — `workflow_call`, supporting `pull_request` and `workflow_dispatch` only (org repos call it on both; `workflow_dispatch` is mostly for debugging the pipeline). Resolves the gitleaks version like the `centreon-ubuntu-26.04` runner images (`centreon-images`, `runners/base-ubuntu-26.04/Dockerfile`): `GITLEAKS_VERSION: latest` is the newest stable release published at least 4 days before the run (read from the public releases feed, `releases.atom`, without any token), falling back to `GITLEAKS_FALLBACK_VERSION` with a `gitleaks-version-fallback` warning when the feed cannot be resolved; set a version to pin it. Keeps the runner's own gitleaks when `gitleaks version` matches (the `centreon-ubuntu-26.04-*` runners ship it), and otherwise installs that release for the runner's architecture, verified against the release's checksums file. On a PR it scans `base..head`: every non-merge commit not already on the destination branch (whatever it is called, and whether or not the commits are on other branches), so it never fails on secrets already on the base and fails on a secret in one of the PR's commits. On `workflow_dispatch` it scans the ref's whole history. Pull requests are checked out shallow (`fetch-depth` = PR commits + 2): the scan deepens the base side until the range holds no parentless commit (a shallow boundary would be read as a root and its whole tree scanned). Event SHAs missing from the clone are re-fetched with the job token. Fails closed: leaks, gitleaks errors, unresolvable ranges and unsupported events (push, schedule, …) all fail the job. `pull_request_target` always fails (not compliant with the security posture), from a first step that runs before checkout.

**Warn-then-enforce gating** (in `dependency-analysis.yml`): findings are reported as `[WARNING]` until a deadline, after which they become `[ERROR]` and fail the build. Controlled by two repository-level GitHub variables:
- `OVERRIDE_DEPENDENCY_ENFORCEMENT_DATE` — the date enforcement begins.
- `OVERRIDE_DEPENDENCY_SCAN` — set to `"true"` to bypass the scan entirely.

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`)

Expand Down