chore(ci): add gitleaks secret-scan workflow
This commit is contained in:
parent
886b2d6f84
commit
05c041b18f
1 changed files with 229 additions and 0 deletions
229
.forgejo/workflows/secret-scan.yml
Normal file
229
.forgejo/workflows/secret-scan.yml
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
# forgejo-actions-secret-scan.yml
|
||||
#
|
||||
# Drop into each repo at: .forgejo/workflows/secret-scan.yml
|
||||
# (Forgejo Actions reads .forgejo/workflows/ natively; .github/workflows/
|
||||
# also works as fallback if a repo has both. Prefer .forgejo/.)
|
||||
#
|
||||
# Layer-2 (CI) of the audit cadence — runs on every push + on pull-request.
|
||||
# Two scanners (gitleaks + detect-secrets) for belt-and-braces coverage.
|
||||
# On hit: opens a Forgejo Issue in this repo (assigned to operator)
|
||||
# with redacted preview, then fails the workflow so any auto-merge stops.
|
||||
#
|
||||
# Required repo secrets:
|
||||
# FORGEJO_TOKEN — PAT with scope `issue:write` for THIS repo only.
|
||||
# Bot account preferred (obsidian-ai), not operator's PAT.
|
||||
#
|
||||
# Runner label: nullstone (the existing self-hosted runner per memory).
|
||||
# If runner is offline / privileged-runner-design rejects this,
|
||||
# fall back to label `docker` and use a vanilla container runner.
|
||||
|
||||
name: secret-scan
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
pull_request:
|
||||
branches:
|
||||
- "**"
|
||||
workflow_dispatch:
|
||||
|
||||
# Don't run twice on the same SHA.
|
||||
concurrency:
|
||||
group: secret-scan-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
|
||||
gitleaks:
|
||||
name: gitleaks (HEAD + history)
|
||||
runs-on: nullstone
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- name: Checkout (full history for --log-opts=all)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install gitleaks
|
||||
run: |
|
||||
set -eu
|
||||
if ! command -v gitleaks >/dev/null 2>&1; then
|
||||
curl -sSL -o /tmp/gitleaks.tgz \
|
||||
"https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_x64.tar.gz"
|
||||
mkdir -p /tmp/gl && tar -xzf /tmp/gitleaks.tgz -C /tmp/gl
|
||||
sudo install -m 0755 /tmp/gl/gitleaks /usr/local/bin/gitleaks
|
||||
fi
|
||||
gitleaks version
|
||||
|
||||
- name: Pull s8n-stack ruleset
|
||||
run: |
|
||||
# Operator-tuned ruleset lives in s8n/security-vault.
|
||||
# If the repo is offline, fall back to gitleaks defaults.
|
||||
set -eu
|
||||
if curl -sSL -H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||
-o .gitleaks.toml \
|
||||
"https://git.s8n.ru/s8n/security-vault/raw/branch/main/prevention/.gitleaks.toml"; then
|
||||
echo "loaded operator-tuned ruleset"
|
||||
else
|
||||
echo "fallback to gitleaks defaults" >&2
|
||||
rm -f .gitleaks.toml
|
||||
fi
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
|
||||
|
||||
- name: Scan HEAD (staged + uncommitted only takes commits)
|
||||
id: gl-head
|
||||
run: |
|
||||
set -eu
|
||||
mkdir -p .scan
|
||||
gitleaks detect --source . \
|
||||
--no-banner --redact \
|
||||
${GITLEAKS_CONFIG_FLAG} \
|
||||
--report-format json \
|
||||
--report-path .scan/gitleaks-head.json \
|
||||
--exit-code 0
|
||||
# Count findings:
|
||||
n=$(jq 'length' .scan/gitleaks-head.json 2>/dev/null || echo 0)
|
||||
echo "head_count=$n" >> "$GITHUB_OUTPUT"
|
||||
echo "gitleaks HEAD findings: $n"
|
||||
env:
|
||||
GITLEAKS_CONFIG_FLAG: ${{ hashFiles('.gitleaks.toml') != '' && '--config .gitleaks.toml' || '' }}
|
||||
|
||||
- name: Scan history (--log-opts=--all)
|
||||
id: gl-hist
|
||||
run: |
|
||||
set -eu
|
||||
gitleaks detect --source . \
|
||||
--no-banner --redact \
|
||||
${GITLEAKS_CONFIG_FLAG} \
|
||||
--log-opts="--all" \
|
||||
--report-format json \
|
||||
--report-path .scan/gitleaks-history.json \
|
||||
--exit-code 0
|
||||
n=$(jq 'length' .scan/gitleaks-history.json 2>/dev/null || echo 0)
|
||||
echo "history_count=$n" >> "$GITHUB_OUTPUT"
|
||||
echo "gitleaks history findings: $n"
|
||||
env:
|
||||
GITLEAKS_CONFIG_FLAG: ${{ hashFiles('.gitleaks.toml') != '' && '--config .gitleaks.toml' || '' }}
|
||||
|
||||
- name: Upload gitleaks reports (artefact)
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: gitleaks-reports
|
||||
path: .scan/
|
||||
retention-days: 30
|
||||
|
||||
- name: Open Forgejo issue on hit (gitleaks)
|
||||
if: steps.gl-head.outputs.head_count != '0' || steps.gl-hist.outputs.history_count != '0'
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
REF: ${{ github.ref }}
|
||||
SHA: ${{ github.sha }}
|
||||
HEAD_COUNT: ${{ steps.gl-head.outputs.head_count }}
|
||||
HIST_COUNT: ${{ steps.gl-hist.outputs.history_count }}
|
||||
run: |
|
||||
set -eu
|
||||
# Build a redacted preview (rule-id + file + line, no values).
|
||||
preview="$(jq -r '.[] | "- rule:" + .RuleID + " file:" + .File + " line:" + (.StartLine|tostring) + " commit:" + (.Commit // "HEAD")' .scan/gitleaks-head.json .scan/gitleaks-history.json | head -50)"
|
||||
body=$(jq -nR --arg ref "$REF" --arg sha "$SHA" --arg hc "$HEAD_COUNT" --arg histc "$HIST_COUNT" --arg prev "$preview" \
|
||||
'{
|
||||
title: ("[secret-scan] gitleaks hit on " + $ref),
|
||||
body: ("**Automated secret-scan hit.**\n\nRef: `" + $ref + "`\nSHA: `" + $sha + "`\nHEAD findings: " + $hc + "\nHistory findings: " + $histc + "\n\n## Redacted preview\n\n```\n" + $prev + "\n```\n\nFull report: workflow run artefacts (gitleaks-reports).\n\n## Triage\n\n1. False-positive? Add a `.gitleaksignore` entry with justifying comment + close.\n2. True-positive? Trigger incident response per `rules/incident-response-rules.md`. Rotate the affected credential. Then redact + history-rewrite.\n\n/cc @s8n"),
|
||||
labels: ["security","secret-scan"]
|
||||
}')
|
||||
curl -sS -X POST \
|
||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$body" \
|
||||
"https://git.s8n.ru/api/v1/repos/${REPO}/issues" | jq '.html_url'
|
||||
|
||||
- name: Fail workflow on hit
|
||||
if: steps.gl-head.outputs.head_count != '0' || steps.gl-hist.outputs.history_count != '0'
|
||||
run: |
|
||||
echo "::error::gitleaks found secrets — see opened issue + workflow artefact"
|
||||
exit 1
|
||||
|
||||
detect-secrets:
|
||||
name: detect-secrets (entropy + cross-tool)
|
||||
runs-on: nullstone
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install detect-secrets
|
||||
run: |
|
||||
set -eu
|
||||
python3 -m pip install --user --upgrade pip detect-secrets
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Scan
|
||||
id: ds
|
||||
run: |
|
||||
set -eu
|
||||
mkdir -p .scan
|
||||
detect-secrets scan --all-files \
|
||||
--exclude-files '(^|/)(node_modules|venv|\.venv|dist|build|target|out|coverage|\.terraform)/' \
|
||||
--exclude-files '(^|/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|Cargo\.lock|go\.sum)$' \
|
||||
> .scan/detect-secrets.json
|
||||
# Count findings:
|
||||
n=$(jq '.results | to_entries | map(.value | length) | add // 0' .scan/detect-secrets.json)
|
||||
echo "count=$n" >> "$GITHUB_OUTPUT"
|
||||
echo "detect-secrets findings: $n"
|
||||
|
||||
- name: Upload detect-secrets report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: detect-secrets-report
|
||||
path: .scan/detect-secrets.json
|
||||
retention-days: 30
|
||||
|
||||
- name: Open Forgejo issue on hit (detect-secrets)
|
||||
if: steps.ds.outputs.count != '0'
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
REF: ${{ github.ref }}
|
||||
COUNT: ${{ steps.ds.outputs.count }}
|
||||
run: |
|
||||
set -eu
|
||||
preview="$(jq -r '.results | to_entries[] | .key as $f | .value[] | "- " + $f + ":" + (.line_number|tostring) + " type:" + .type' .scan/detect-secrets.json | head -50)"
|
||||
body=$(jq -nR --arg ref "$REF" --arg c "$COUNT" --arg prev "$preview" \
|
||||
'{
|
||||
title: ("[secret-scan] detect-secrets hit on " + $ref),
|
||||
body: ("**Automated secret-scan hit (detect-secrets).**\n\nRef: `" + $ref + "`\nFindings: " + $c + "\n\n## Redacted preview (file:line type — no values)\n\n```\n" + $prev + "\n```\n\nFull report: workflow run artefacts (detect-secrets-report).\n\n## Triage\n\n1. False-positive? Run locally `detect-secrets audit .scan/detect-secrets.json` and commit the audited baseline.\n2. True-positive? Trigger incident response per `rules/incident-response-rules.md`.\n\n/cc @s8n"),
|
||||
labels: ["security","secret-scan"]
|
||||
}')
|
||||
curl -sS -X POST \
|
||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$body" \
|
||||
"https://git.s8n.ru/api/v1/repos/${REPO}/issues" | jq '.html_url'
|
||||
|
||||
- name: Fail workflow on hit
|
||||
if: steps.ds.outputs.count != '0'
|
||||
run: |
|
||||
echo "::error::detect-secrets found candidates — see opened issue + workflow artefact"
|
||||
exit 1
|
||||
|
||||
summary:
|
||||
name: summary
|
||||
needs: [gitleaks, detect-secrets]
|
||||
if: always()
|
||||
runs-on: nullstone
|
||||
steps:
|
||||
- name: Outcome
|
||||
run: |
|
||||
echo "secret-scan complete."
|
||||
echo " gitleaks: ${{ needs.gitleaks.result }}"
|
||||
echo " detect-secrets: ${{ needs['detect-secrets'].result }}"
|
||||
Loading…
Reference in a new issue