From 774ebf71e733b6ffd023f4fadf0d7e590de73f59 Mon Sep 17 00:00:00 2001 From: Cavonstavant <38893947+Cavonstavant@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:38:22 +0200 Subject: [PATCH 1/3] Add reusable betterleaks secrets scan workflow (default rules) Reusable workflow_call variant of Secu.Actions' betterleaks PR check, running betterleaks with its built-in default rules (no custom betterleaks.toml) over the patches of the commits introduced by the PR (base..HEAD range), so a secret added then removed within the PR is still caught. Emits inline annotations, fails while any secret is present, and maintains a single auto-updated PR comment with rebase and rotation guidance. No internal Lucca integrations: intended for public repositories with external contributors. Co-Authored-By: Claude Fable 5 --- .../workflows/betterleaks-scan-public.yaml | 246 ++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 .github/workflows/betterleaks-scan-public.yaml diff --git a/.github/workflows/betterleaks-scan-public.yaml b/.github/workflows/betterleaks-scan-public.yaml new file mode 100644 index 0000000..ef3fba3 --- /dev/null +++ b/.github/workflows/betterleaks-scan-public.yaml @@ -0,0 +1,246 @@ +name: "Security: Betterleaks scan" + +# Reusable secrets scan for pull requests, running betterleaks with its +# DEFAULT rules (no custom betterleaks.toml) over the patches of the +# commits introduced by the pull request (base..HEAD commit range). +# +# Call it from a repository like this: +# +# name: Betterleaks Secrets Scanning +# on: +# pull_request: +# merge_group: +# jobs: +# secrets-scan: +# uses: LuccaSA/PublicWorkflows/.github/workflows/betterleaks-scan-public.yaml@main +# permissions: +# contents: read +# pull-requests: write + +on: + workflow_call: + inputs: + betterleaks-version: + description: "betterleaks release tag to install (e.g. v1.8.1)" + required: false + type: string + default: "v1.8.1" + +# Effective permissions are declared per job below (and can only be a subset +# of what the calling workflow grants). +permissions: {} + +jobs: + secrets-scan: + name: Secrets Scan (default rules) + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read # clone the repository with full history for the commit-range scan + pull-requests: write # comment on the PR when findings are detected + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Install betterleaks + shell: bash + env: + BETTERLEAKS_VERSION: ${{ inputs.betterleaks-version }} + GH_TOKEN: ${{ github.token }} + run: | + VERSION="${BETTERLEAKS_VERSION#v}" + INSTALL_DIR="$HOME/.local/bin" + mkdir -p "$INSTALL_DIR" + + echo "Installing betterleaks v${VERSION}..." + ASSET="betterleaks_${VERSION}_linux_x64.tar.gz" + BASE_URL="https://github.com/betterleaks/betterleaks/releases/download/v${VERSION}" + + gh release verify "v${VERSION}" --repo betterleaks/betterleaks + + curl -fsSL --retry 3 --retry-all-errors \ + "${BASE_URL}/${ASSET}" \ + -o "/tmp/${ASSET}" + curl -fsSL --retry 3 --retry-all-errors \ + "${BASE_URL}/checksums.txt" \ + -o /tmp/betterleaks-checksums.txt + (cd /tmp && sha256sum --check --ignore-missing betterleaks-checksums.txt) + + gh release verify-asset "v${VERSION}" "/tmp/${ASSET}" --repo betterleaks/betterleaks + + tar -xzf "/tmp/${ASSET}" -C /tmp betterleaks + install -m 755 /tmp/betterleaks "$INSTALL_DIR/betterleaks" + rm -f "/tmp/${ASSET}" /tmp/betterleaks /tmp/betterleaks-checksums.txt + echo "$INSTALL_DIR" >> "$GITHUB_PATH" + echo "Installed betterleaks to $INSTALL_DIR/betterleaks" + + - name: Run betterleaks on the PR commit range (default rules) + shell: bash + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }} + run: | + ARGS=() + # Scan the patches of every commit introduced by the PR / merge + # group, so a secret added then removed within the PR is still + # caught; fall back to full history when the triggering event has + # no base SHA. + if [ -n "${BASE_SHA}" ]; then + ARGS+=(--log-opts "${BASE_SHA}..HEAD") + fi + betterleaks git \ + "${ARGS[@]}" \ + --report-format json \ + --report-path /tmp/betterleaks-results.json || true + + - name: Emit GitHub annotations for findings + id: annotations + if: always() + shell: bash + run: | + RESULTS=/tmp/betterleaks-results.json + + # Normalize to a valid JSON array so jq never chokes. + if [ ! -s "$RESULTS" ] || [ "$(cat "$RESULTS")" = 'null' ]; then echo '[]' > "$RESULTS"; fi + + TOTAL_FINDINGS=$(jq -r 'length' "$RESULTS") + echo "Found $TOTAL_FINDINGS secret(s) in the PR commit range" + + if [ "$TOTAL_FINDINGS" -eq 0 ]; then + echo "No secrets found." + echo "has_findings=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "has_findings=true" >> "$GITHUB_OUTPUT" + DELIM="EOF_$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n')" + { + echo "findings<<${DELIM}" + cat "$RESULTS" + echo "${DELIM}" + } >> "$GITHUB_OUTPUT" + + # Emit one annotation per finding, pointing at the real file/line so + # GitHub attaches it to the source (inline on the PR diff when the line + # is part of the diff, and always in the run's annotation summary). + # A workflow command must be a single line, so collapse the message. + while IFS= read -r finding; do + [ -z "$finding" ] && continue + file=$(printf '%s' "$finding" | jq -r '.File // ""') + line=$(printf '%s' "$finding" | jq -r '.StartLine // 1') + endline=$(printf '%s' "$finding" | jq -r '.EndLine // .StartLine // 1') + col=$(printf '%s' "$finding" | jq -r '.StartColumn // 1') + endcol=$(printf '%s' "$finding" | jq -r '.EndColumn // .StartColumn // 1') + rule=$(printf '%s' "$finding" | jq -r '.RuleID // "secret"') + desc=$(printf '%s' "$finding" | jq -r '.Description // "Potential secret detected"' | tr '\n' ' ') + + [ -z "$file" ] && continue + echo "::error file=${file},line=${line},endLine=${endline},col=${col},endColumn=${endcol},title=Betterleaks: ${rule}::${desc}" + done < <(jq -c '.[]' "$RESULTS" 2>/dev/null) + + exit 1 + + - name: Comment on PR with findings + if: always() && steps.annotations.outputs.has_findings == 'true' && github.event_name == 'pull_request' + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + env: + FINDINGS: ${{ steps.annotations.outputs.findings }} + with: + github-token: ${{ github.token }} + script: | + const raw = process.env.FINDINGS; + let findings; + try { findings = JSON.parse(raw); } catch { findings = []; } + if (!findings.length) return; + + const headSha = context.payload.pull_request.head.sha; + const lines = findings.map(f => { + const file = f.File || 'unknown'; + const line = f.StartLine || '?'; + const rule = f.RuleID || 'secret'; + const desc = f.Description || 'Potential secret detected'; + const commit = f.Commit ? ` (commit \`${f.Commit.slice(0, 8)}\`)` : ''; + const permalink = `https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${f.Commit || headSha}/${file}#L${line}`; + return `🔴 **[${rule}](${permalink})** in \`${file}:${line}\`${commit}\n ${desc}`; + }).join('\n\n'); + + // Build per-commit rebase guidance + const badCommits = [...new Set(findings.map(f => f.Commit).filter(Boolean))]; + const firstBadCommit = badCommits[0]; + const rebaseInstructions = firstBadCommit ? [ + '### How to fix: rewrite the offending commit(s)', + '', + '> ⚠️ Simply adding a new commit to remove the secret is **not enough** — this scanner checks the full commit history of the PR. You must rewrite history so the secret never appears in any commit.', + '', + '**Step 1 — Start an interactive rebase from just before the first offending commit:**', + '```sh', + `git rebase -i ${firstBadCommit}~1`, + '```', + '', + '**Step 2 — In the editor**, mark the offending commit(s) as `edit` (to amend them) or `drop` (to remove them entirely):', + '```', + badCommits.map(c => `edit ${c.slice(0, 8)} ← contains secret`).join('\n'), + '```', + '', + '**Step 3 — For each commit marked `edit`**, git will pause. Remove the secret from the file, then:', + '```sh', + 'git add ', + 'git commit --amend --no-edit', + 'git rebase --continue', + '```', + '', + '**Step 4 — Force-push your branch:**', + '```sh', + `git push --force-with-lease origin ${context.payload.pull_request.head.ref}`, + '```', + '', + '**Step 5 — Rotate the secret** — treat it as compromised regardless of how quickly it was removed.', + ].join('\n') : [ + '### How to fix', + '', + '> ⚠️ Simply adding a new commit to remove the secret is **not enough** — this scanner checks the full commit history of the PR. You must rewrite history so the secret never appears in any commit.', + '', + 'Use `git rebase -i ~1` to edit or drop the offending commit(s), then force-push.', + '', + '**Rotate the secret** — treat it as compromised regardless of how quickly it was removed.', + ].join('\n'); + + const marker = ''; + const body = [ + marker, + '## 🚨 Betterleaks Secret Findings', + '', + 'Potential secrets were detected in the commits introduced by this PR:', + '', + lines, + '', + rebaseInstructions, + ].join('\n'); + + // Update the existing report comment instead of stacking a new + // one on every run. + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + per_page: 100, + }); + const existing = comments.find(c => c.body && c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body, + }); + } From c99cc72f99788536084cfda975b8286d35d5a9ba Mon Sep 17 00:00:00 2001 From: Cavonstavant <38893947+Cavonstavant@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:22:58 +0200 Subject: [PATCH 2/3] Switch workflow_call to pull_request triggering action --- .../workflows/betterleaks-scan-public.yaml | 46 +++++++++---------- 1 file changed, 21 insertions(+), 25 deletions(-) diff --git a/.github/workflows/betterleaks-scan-public.yaml b/.github/workflows/betterleaks-scan-public.yaml index ef3fba3..cc96a18 100644 --- a/.github/workflows/betterleaks-scan-public.yaml +++ b/.github/workflows/betterleaks-scan-public.yaml @@ -1,33 +1,27 @@ name: "Security: Betterleaks scan" -# Reusable secrets scan for pull requests, running betterleaks with its -# DEFAULT rules (no custom betterleaks.toml) over the patches of the -# commits introduced by the pull request (base..HEAD commit range). +# Secrets scan for pull requests, running betterleaks with its DEFAULT rules +# (no custom betterleaks.toml) over the patches of the commits introduced by +# the pull request (base..HEAD commit range). # -# Call it from a repository like this: +# Meant to be enforced as a required check through an organization ruleset +# ("Require workflows to pass before merging"), the same way as +# zizmor-audit-public.yaml: the ruleset points at this file and GitHub runs it +# in the context of the target repository on every pull request (and merge +# queue entry), regardless of whether that repository defines any workflow. # -# name: Betterleaks Secrets Scanning -# on: -# pull_request: -# merge_group: -# jobs: -# secrets-scan: -# uses: LuccaSA/PublicWorkflows/.github/workflows/betterleaks-scan-public.yaml@main -# permissions: -# contents: read -# pull-requests: write +# Ruleset-required workflows only run for the pull_request, pull_request_target +# and merge_group events (any filters on those events are ignored). on: - workflow_call: - inputs: - betterleaks-version: - description: "betterleaks release tag to install (e.g. v1.8.1)" - required: false - type: string - default: "v1.8.1" - -# Effective permissions are declared per job below (and can only be a subset -# of what the calling workflow grants). + pull_request: + merge_group: + +env: + # betterleaks release tag to install (see https://github.com/betterleaks/betterleaks/releases) + BETTERLEAKS_VERSION: "v1.8.1" + +# Effective permissions are declared per job below. permissions: {} jobs: @@ -49,7 +43,6 @@ jobs: - name: Install betterleaks shell: bash env: - BETTERLEAKS_VERSION: ${{ inputs.betterleaks-version }} GH_TOKEN: ${{ github.token }} run: | VERSION="${BETTERLEAKS_VERSION#v}" @@ -145,6 +138,9 @@ jobs: - name: Comment on PR with findings if: always() && steps.annotations.outputs.has_findings == 'true' && github.event_name == 'pull_request' + # The check already fails from the annotations step above; the comment + # is best effort (the token is read-only for pull requests from forks). + continue-on-error: true uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 env: FINDINGS: ${{ steps.annotations.outputs.findings }} From 9b54c5d1d1856c88ccfb6c79d6b1e4206283b7a8 Mon Sep 17 00:00:00 2001 From: Cavonstavant <38893947+Cavonstavant@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:34:26 +0200 Subject: [PATCH 3/3] Pass betterleaks version via github vars --- .github/workflows/betterleaks-scan-public.yaml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/betterleaks-scan-public.yaml b/.github/workflows/betterleaks-scan-public.yaml index cc96a18..e731288 100644 --- a/.github/workflows/betterleaks-scan-public.yaml +++ b/.github/workflows/betterleaks-scan-public.yaml @@ -18,8 +18,11 @@ on: merge_group: env: - # betterleaks release tag to install (see https://github.com/betterleaks/betterleaks/releases) - BETTERLEAKS_VERSION: "v1.8.1" + # betterleaks release tag to install, read from the BETTERLEAKS_VERSION + # Actions variable (organization or repository level) of the repository the + # workflow runs in, with a pinned fallback when the variable is not defined. + # See https://github.com/betterleaks/betterleaks/releases + BETTERLEAKS_VERSION: ${{ vars.BETTERLEAKS_VERSION || 'v1.8.1' }} # Effective permissions are declared per job below. permissions: {}