From 05c041b18f77a25145dd8e226bede15266b7da86 Mon Sep 17 00:00:00 2001 From: s8n Date: Sun, 10 May 2026 06:30:16 +0100 Subject: [PATCH] chore(ci): add gitleaks secret-scan workflow --- .forgejo/workflows/secret-scan.yml | 229 +++++++++++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 .forgejo/workflows/secret-scan.yml diff --git a/.forgejo/workflows/secret-scan.yml b/.forgejo/workflows/secret-scan.yml new file mode 100644 index 0000000..aecd548 --- /dev/null +++ b/.forgejo/workflows/secret-scan.yml @@ -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 }}"