|
| 1 | +name: Security |
| 2 | + |
| 3 | +on: |
| 4 | + pull_request: |
| 5 | + branches: [main, master] |
| 6 | + |
| 7 | +# One run per PR; a new push cancels the in-progress scan (no duplicate |
| 8 | +# comments, no wasted runner minutes). |
| 9 | +concurrency: |
| 10 | + group: secret-scan-${{ github.event.pull_request.number }} |
| 11 | + cancel-in-progress: true |
| 12 | + |
| 13 | +permissions: {} # no broad grants at workflow level |
| 14 | + |
| 15 | +jobs: |
| 16 | + secret-scan: |
| 17 | + name: TruffleHog Secret Scan |
| 18 | + runs-on: cx-public-ubuntu-x64 |
| 19 | + timeout-minutes: 5 |
| 20 | + permissions: |
| 21 | + contents: read # required to check out the PR head |
| 22 | + pull-requests: write # required to upsert the findings comment |
| 23 | + |
| 24 | + steps: |
| 25 | + - name: Checkout repository |
| 26 | + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 |
| 27 | + with: |
| 28 | + fetch-depth: 0 |
| 29 | + ref: ${{ github.event.pull_request.head.sha }} |
| 30 | + persist-credentials: false |
| 31 | + |
| 32 | + - name: Run TruffleHog |
| 33 | + id: trufflehog |
| 34 | + shell: bash |
| 35 | + env: |
| 36 | + # Pinned by immutable digest (tag 3.96.0). Bump digest + comment on upgrade. |
| 37 | + TRUFFLEHOG_IMAGE: "ghcr.io/trufflesecurity/trufflehog@sha256:aa821cf4ace8861c7d096d83818cdf7bb9719028a52d37a52eaad44086a52577" # 3.96.0 |
| 38 | + BASE_SHA: ${{ github.event.pull_request.base.sha }} |
| 39 | + run: | |
| 40 | + set -euo pipefail |
| 41 | + # --no-fail: findings exit 0, so TruffleHog only exits nonzero on real |
| 42 | + # breakage (image pull, daemon, bad flags). With `set -e` and no `|| true`, |
| 43 | + # that breakage fails the step (and, with no continue-on-error, the job) — |
| 44 | + # instead of silently producing empty results. We block on VERIFIED secrets |
| 45 | + # ourselves in a later step. |
| 46 | + ARGS=(git "file:///repo" --json --no-update --no-fail) |
| 47 | + if [[ -n "${BASE_SHA}" && ! "${BASE_SHA}" =~ ^0+$ ]]; then |
| 48 | + ARGS+=(--since-commit "${BASE_SHA}") |
| 49 | + fi |
| 50 | + echo "Running: trufflehog ${ARGS[*]}" |
| 51 | + docker run --rm -v "${GITHUB_WORKSPACE}:/repo:ro" -w /repo \ |
| 52 | + "${TRUFFLEHOG_IMAGE}" "${ARGS[@]}" > trufflehog-results.json |
| 53 | + echo "--- raw results ---"; cat trufflehog-results.json |
| 54 | +
|
| 55 | + - name: Parse results |
| 56 | + id: parse |
| 57 | + if: always() |
| 58 | + shell: bash |
| 59 | + run: | |
| 60 | + set -uo pipefail |
| 61 | + grep -E '"DetectorName"' trufflehog-results.json > findings.ndjson || true |
| 62 | + # Count non-empty lines. grep -c prints "0" AND exits 1 on no match, so |
| 63 | + # capture once and only reset on failure (avoids a duplicate "0"). |
| 64 | + COUNT=$(grep -c . findings.ndjson 2>/dev/null) || COUNT=0 |
| 65 | + # Verified = confirmed-live by the provider (compact JSON -> "Verified":true). |
| 66 | + # These are what we BLOCK on; everything else is a warning. |
| 67 | + VERIFIED=$(grep -c '"Verified":true' findings.ndjson 2>/dev/null) || VERIFIED=0 |
| 68 | + echo "count=${COUNT}" >> "$GITHUB_OUTPUT" |
| 69 | + echo "verified=${VERIFIED}" >> "$GITHUB_OUTPUT" |
| 70 | + echo "Total=${COUNT} verified(blocking)=${VERIFIED} unverified(warn)=$((COUNT - VERIFIED))" |
| 71 | +
|
| 72 | + - name: Upsert single PR comment |
| 73 | + if: always() |
| 74 | + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 |
| 75 | + with: |
| 76 | + script: | |
| 77 | + const fs = require('fs'); |
| 78 | + const MARKER = '<!-- trufflehog-secret-scan -->'; |
| 79 | + const server = process.env.GITHUB_SERVER_URL; |
| 80 | + const { owner, repo } = context.repo; |
| 81 | + const issue_number = context.payload.pull_request.number; |
| 82 | +
|
| 83 | + const findings = fs.existsSync('findings.ndjson') |
| 84 | + ? fs.readFileSync('findings.ndjson', 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l)) |
| 85 | + : []; |
| 86 | + const n = findings.length; |
| 87 | +
|
| 88 | + const verifiedCount = findings.filter(f => f.Verified === true).length; |
| 89 | + const unverifiedCount = n - verifiedCount; |
| 90 | +
|
| 91 | + let body; |
| 92 | + if (n > 0) { |
| 93 | + const seen = new Set(); |
| 94 | + const rows = []; |
| 95 | + for (const f of findings) { |
| 96 | + const g = f.SourceMetadata?.Data?.Git || {}; |
| 97 | + const type = f.DetectorName || 'Unknown'; |
| 98 | + const status = f.Verified ? '✅ verified' : '⚠️ unverified'; |
| 99 | + const commit = g.commit || ''; |
| 100 | + const file = g.file || '(unknown)'; |
| 101 | + const line = g.line || 0; |
| 102 | +
|
| 103 | + let view = '—'; |
| 104 | + if (commit && file !== '(unknown)') { |
| 105 | + const anchor = line > 0 ? `#L${line}` : ''; |
| 106 | + view = `[🔎 View secret](${server}/${owner}/${repo}/blob/${commit}/${file}${anchor})`; |
| 107 | + } |
| 108 | +
|
| 109 | + const key = `${type}|${file}|${line}|${commit}`; |
| 110 | + if (seen.has(key)) continue; |
| 111 | + seen.add(key); |
| 112 | + rows.push(`| ${type} | ${status} | \`${commit.slice(0,10)}\` | \`${file}\` | ${view} |`); |
| 113 | + } |
| 114 | + const author = context.payload.pull_request.user.login; |
| 115 | + // Tiered: VERIFIED secrets block the merge; unverified are warnings. |
| 116 | + const header = verifiedCount > 0 |
| 117 | + ? '## 🚨 Verified secret(s) detected — merge blocked' |
| 118 | + : '## ⚠️ Potential secret(s) detected (unverified)'; |
| 119 | + const footer = verifiedCount > 0 |
| 120 | + ? '> ⛔ This PR is **blocked**: a verified, live secret was found. Remove it and re-scan to merge.' |
| 121 | + : '> ⚠️ These are **unverified** matches and do **not** block the merge — please review and confirm they are not real secrets.'; |
| 122 | + body = [ |
| 123 | + MARKER, |
| 124 | + header, |
| 125 | + '', |
| 126 | + `@${author} — TruffleHog found **${n}** potential secret(s) in this PR ` + |
| 127 | + `(**${verifiedCount} verified**, ${unverifiedCount} unverified).`, |
| 128 | + '', |
| 129 | + '| Secret Type | Status | Commit | File Path | View |', |
| 130 | + '| --- | --- | --- | --- | --- |', |
| 131 | + ...rows, |
| 132 | + '', |
| 133 | + '### 🛠 Guidelines to remediate hardcoded secrets', |
| 134 | + '- Understand the implications of revoking this secret by investigating where it is used in your code.', |
| 135 | + '- **Immediately rotate/revoke** the exposed secret. (https://howtorotate.com/docs/introduction/getting-started/)', |
| 136 | + '- Remove it from git history ( **a new "remove secret" commit is NOT enough** e.g. with [`git-filter-repo`](https://github.com/newren/git-filter-repo)).', |
| 137 | + '', |
| 138 | + 'To avoid such incidents in the future consider:', |
| 139 | + '- Following the best practices for managing and storing secrets, including API keys and other credentials.', |
| 140 | + '- Re-scan after cleanup before merging.', |
| 141 | + '', |
| 142 | + footer, |
| 143 | + ].join('\n'); |
| 144 | + } else { |
| 145 | + body = [ |
| 146 | + MARKER, |
| 147 | + '## ✅ No secrets detected', |
| 148 | + '', |
| 149 | + 'TruffleHog found no secrets in the current commits of this PR.', |
| 150 | + ].join('\n'); |
| 151 | + } |
| 152 | +
|
| 153 | + const comments = await github.paginate(github.rest.issues.listComments, { |
| 154 | + owner, repo, issue_number, per_page: 100, |
| 155 | + }); |
| 156 | + const existing = comments.find(c => c.body && c.body.includes(MARKER)); |
| 157 | +
|
| 158 | + if (existing) { |
| 159 | + await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); |
| 160 | + } else if (n > 0) { |
| 161 | + await github.rest.issues.createComment({ owner, repo, issue_number, body }); |
| 162 | + } |
| 163 | +
|
| 164 | + # Block the merge ONLY on verified (confirmed-live) secrets. Unverified |
| 165 | + # findings are surfaced in the PR comment but do not fail the check. |
| 166 | + - name: Fail if verified secrets were found |
| 167 | + if: steps.parse.outputs.verified != '0' |
| 168 | + env: |
| 169 | + VERIFIED: ${{ steps.parse.outputs.verified }} # <-- add |
| 170 | + run: | |
| 171 | + echo "::error::TruffleHog found ${VERIFIED} VERIFIED secret(s). Merge is blocked." |
| 172 | + exit 1 |
0 commit comments