PDP-1182: Remove per-repo pr-workflow.yaml #58
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: TruffleHog Secret Scan | |
| on: | |
| # Direct trigger for org-level repository rulesets — works for PRs from forks | |
| # since pull_request_target runs with base repo context. The GITHUB_TOKEN is | |
| # explicitly scoped to least privilege in the job permissions block below. | |
| pull_request_target: | |
| types: [opened, synchronize, reopened] | |
| # Also support being called as a reusable workflow from individual repos | |
| workflow_call: | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| # pull-requests: write and issues: write are needed for the PR comment step | |
| # (workflow_call path only). The PR comment step uses github.rest.issues.* | |
| # APIs which require issues: write; pull-requests: write is also kept for | |
| # potential future comment resolution. pull_request_target skips the comment | |
| # step, but GitHub Actions has no per-event conditional permissions within a | |
| # single job — splitting into two jobs would add significant complexity. | |
| pull-requests: write | |
| issues: write | |
| # Default exclusion patterns (regex format) | |
| # Supports: exact filenames, wildcards, regex patterns | |
| # Examples: | |
| # Exact file: ^config/settings\.json$ | |
| # Directory: ^node_modules/ | |
| # Extension: \.lock$ | |
| # Wildcard: .*\.min\.js$ | |
| # Regex: ^src/test/.*_test\.py$ | |
| env: | |
| DEFAULT_EXCLUDES: | | |
| ^node_modules/ | |
| ^vendor/ | |
| ^\.git/ | |
| \.lock$ | |
| ^package-lock\.json$ | |
| ^yarn\.lock$ | |
| ^pnpm-lock\.yaml$ | |
| \.min\.js$ | |
| \.min\.css$ | |
| jobs: | |
| trufflehog-scan: | |
| name: Scan PR for Secrets | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 | |
| with: | |
| fetch-depth: 0 | |
| persist-credentials: false | |
| - name: Fetch PR head commits | |
| if: github.event_name != 'workflow_dispatch' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| SERVER_URL: ${{ github.server_url }} | |
| REPO: ${{ github.repository }} | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} | |
| run: | | |
| # Scope credentials to this repo only — git -c passes the header in-memory | |
| # and is never written to .git/config, so persist-credentials: false is preserved. | |
| AUTH_HEADER="Authorization: basic $(printf 'x-access-token:%s' "${GH_TOKEN}" | base64 -w0)" | |
| git -c "http.${SERVER_URL}/${REPO}/.extraheader=${AUTH_HEADER}" \ | |
| fetch origin +refs/pull/${PR_NUMBER}/head:refs/remotes/origin/pr-head | |
| echo "Fetched PR #${PR_NUMBER} head commit: ${PR_HEAD_SHA}" | |
| - name: Setup exclude config | |
| id: config | |
| env: | |
| TRUFFLEHOG_EXCLUDES: ${{ vars.TRUFFLEHOG_EXCLUDES }} | |
| run: | | |
| # Always include default exclusions | |
| echo "Adding default exclusions" | |
| printf '%s\n' "$DEFAULT_EXCLUDES" > .trufflehog-ignore | |
| # Append repo/org-level custom exclusions if defined | |
| if [ -n "$TRUFFLEHOG_EXCLUDES" ]; then | |
| echo "Adding repo/org-level TRUFFLEHOG_EXCLUDES patterns" | |
| # Support both comma-separated and newline-separated patterns | |
| echo "$TRUFFLEHOG_EXCLUDES" | tr ',' '\n' | sed '/^$/d' >> .trufflehog-ignore | |
| fi | |
| echo "Exclusion patterns:" | |
| cat .trufflehog-ignore | |
| echo "exclude_args=--exclude-paths=.trufflehog-ignore" >> $GITHUB_OUTPUT | |
| - name: Scan changed files for secrets | |
| id: parse | |
| if: github.event_name != 'workflow_dispatch' | |
| env: | |
| PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} | |
| PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} | |
| EXCLUDE_ARGS: ${{ steps.config.outputs.exclude_args }} | |
| run: | | |
| echo "Scanning PR changed files for secrets..." | |
| VERIFIED_COUNT=0 | |
| UNVERIFIED_COUNT=0 | |
| # Checkout PR head to scan current file state | |
| git checkout "${PR_HEAD_SHA}" --quiet | |
| # Get list of files changed in this PR (with rename detection) | |
| # -M enables rename detection, showing only the new filename for renamed files | |
| # --diff-filter=d excludes deleted files (we only want files that exist in the PR head) | |
| CHANGED_FILES=$(git diff --name-only -M --diff-filter=d "${PR_BASE_SHA}...${PR_HEAD_SHA}" | grep -v '^$' || true) | |
| if [ -z "$CHANGED_FILES" ]; then | |
| echo "No files changed in PR" | |
| echo "verified_count=0" >> $GITHUB_OUTPUT | |
| echo "unverified_count=0" >> $GITHUB_OUTPUT | |
| exit 0 | |
| fi | |
| echo "Scanning changed files:" | |
| echo "$CHANGED_FILES" | |
| # Scan changed files using TruffleHog filesystem mode — pinned by digest for reproducibility | |
| SCAN_EXIT=0 | |
| SCAN_OUTPUT=$(docker run --rm -v "$(pwd)":/tmp -w /tmp \ | |
| ghcr.io/trufflesecurity/trufflehog:3.94.2@sha256:dabd9a5f78ed81211792afc39a35100e2b947baa9f43f1e73a97759d7d15ab86 \ | |
| filesystem /tmp/ \ | |
| --json \ | |
| $EXCLUDE_ARGS \ | |
| --no-update 2>/dev/null) || SCAN_EXIT=$? | |
| # TruffleHog exits 0 (no secrets found) or 183 (secrets found) on a successful run. | |
| # Any other exit code means Docker or TruffleHog itself failed to execute. | |
| # We must not silently pass in that case — an empty SCAN_OUTPUT would report 0 findings | |
| # and let the job succeed, bypassing the required ruleset check (fail-open). | |
| if [ "$SCAN_EXIT" -ne 0 ] && [ "$SCAN_EXIT" -ne 183 ]; then | |
| echo "::error::TruffleHog Docker scan failed (exit ${SCAN_EXIT}). Blocking to prevent fail-open bypass of secret scanning." | |
| exit 1 | |
| fi | |
| # Parse JSON lines and filter to only changed files | |
| if [ -n "$SCAN_OUTPUT" ]; then | |
| while IFS= read -r line; do | |
| # Skip non-JSON lines (info logs) | |
| if ! echo "$line" | jq -e '.DetectorName' > /dev/null 2>&1; then | |
| continue | |
| fi | |
| FILE=$(echo "$line" | jq -r '.SourceMetadata.Data.Filesystem.file // "unknown"') | |
| # Remove /tmp/ prefix (Docker mounts the repo at /tmp/) | |
| FILE="${FILE#/tmp/}" | |
| # Re-apply exclusion patterns against the relative path. | |
| # Docker sees absolute paths (/tmp/vendor/config.js), so TruffleHog's | |
| # --exclude-paths misses patterns anchored with ^ (e.g. ^vendor/). | |
| # We check here after stripping the /tmp/ prefix to enforce them correctly. | |
| EXCLUDED=false | |
| while IFS= read -r excl_pattern; do | |
| excl_pattern="${excl_pattern#"${excl_pattern%%[! ]*}"}" # ltrim whitespace | |
| [ -z "$excl_pattern" ] && continue | |
| [ "${excl_pattern#\#}" != "$excl_pattern" ] && continue # skip comment lines | |
| grep -qE -- "$excl_pattern" <<< "$FILE" && EXCLUDED=true && break | |
| done < .trufflehog-ignore | |
| [ "$EXCLUDED" = "true" ] && continue | |
| # Only process files changed in this PR | |
| if ! grep -qxF -- "$FILE" <<< "$CHANGED_FILES"; then | |
| continue | |
| fi | |
| LINE_NUM=$(echo "$line" | jq -r '.SourceMetadata.Data.Filesystem.line // 1') | |
| DETECTOR=$(echo "$line" | jq -r '.DetectorName // "Secret"') | |
| VERIFIED=$(echo "$line" | jq -r '.Verified // false') | |
| if [ "$VERIFIED" == "true" ]; then | |
| VERIFIED_COUNT=$((VERIFIED_COUNT + 1)) | |
| # Error annotation for verified secrets | |
| echo "::error file=${FILE},line=${LINE_NUM},title=${DETECTOR} [VERIFIED]::VERIFIED ACTIVE CREDENTIAL: ${DETECTOR} found in ${FILE} at line ${LINE_NUM}. This secret is confirmed active. Remove and rotate immediately!" | |
| else | |
| UNVERIFIED_COUNT=$((UNVERIFIED_COUNT + 1)) | |
| # Warning annotation for unverified secrets | |
| echo "::warning file=${FILE},line=${LINE_NUM},title=${DETECTOR} [Unverified]::Potential secret: ${DETECTOR} found in ${FILE} at line ${LINE_NUM}. Review and remove if this is a real credential." | |
| fi | |
| done <<< "$SCAN_OUTPUT" | |
| fi | |
| echo "verified_count=${VERIFIED_COUNT}" >> $GITHUB_OUTPUT | |
| echo "unverified_count=${UNVERIFIED_COUNT}" >> $GITHUB_OUTPUT | |
| echo "Scan complete: ${VERIFIED_COUNT} verified, ${UNVERIFIED_COUNT} unverified secrets found" | |
| - name: Process scan results | |
| id: process | |
| if: github.event_name != 'workflow_dispatch' | |
| env: | |
| VERIFIED_COUNT: ${{ steps.parse.outputs.verified_count }} | |
| UNVERIFIED_COUNT: ${{ steps.parse.outputs.unverified_count }} | |
| run: | | |
| VERIFIED=${VERIFIED_COUNT:-0} | |
| UNVERIFIED=${UNVERIFIED_COUNT:-0} | |
| if [ "$VERIFIED" -gt 0 ]; then | |
| # Verified secrets found - must fail | |
| echo "has_verified=true" >> $GITHUB_OUTPUT | |
| echo "has_secrets=true" >> $GITHUB_OUTPUT | |
| echo "description=Found ${VERIFIED} verified (active) secrets - action required" >> $GITHUB_OUTPUT | |
| elif [ "$UNVERIFIED" -gt 0 ]; then | |
| # Only unverified secrets - warn but pass | |
| echo "has_verified=false" >> $GITHUB_OUTPUT | |
| echo "has_secrets=true" >> $GITHUB_OUTPUT | |
| echo "description=Found ${UNVERIFIED} unverified potential secrets - review recommended" >> $GITHUB_OUTPUT | |
| else | |
| # No secrets | |
| echo "has_verified=false" >> $GITHUB_OUTPUT | |
| echo "has_secrets=false" >> $GITHUB_OUTPUT | |
| echo "description=No secrets detected in PR changes" >> $GITHUB_OUTPUT | |
| fi | |
| - name: Post PR comment on findings | |
| # workflow_call: token is scoped to the calling repo — write access works. | |
| # pull_request_target (org ruleset): token is read-only for the triggering repo — | |
| # createComment/updateComment will 403. Skip and rely on job annotations instead. | |
| if: github.event_name == 'workflow_call' | |
| env: | |
| PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} | |
| HAS_SECRETS: ${{ steps.process.outputs.has_secrets }} | |
| HAS_VERIFIED: ${{ steps.process.outputs.has_verified }} | |
| VERIFIED_COUNT: ${{ steps.parse.outputs.verified_count }} | |
| UNVERIFIED_COUNT: ${{ steps.parse.outputs.unverified_count }} | |
| SERVER_URL: ${{ github.server_url }} | |
| REPO: ${{ github.repository }} | |
| RUN_ID: ${{ github.run_id }} | |
| uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 | |
| with: | |
| script: | | |
| const commentMarker = '<!-- TRUFFLEHOG-SCAN-COMMENT -->'; | |
| const commitSha = process.env.PR_HEAD_SHA; | |
| const shortSha = commitSha.substring(0, 7); | |
| const hasSecrets = process.env.HAS_SECRETS === 'true'; | |
| const hasVerified = process.env.HAS_VERIFIED === 'true'; | |
| const verifiedCount = process.env.VERIFIED_COUNT || '0'; | |
| const unverifiedCount = process.env.UNVERIFIED_COUNT || '0'; | |
| const serverUrl = process.env.SERVER_URL; | |
| const repo = process.env.REPO; | |
| const runId = process.env.RUN_ID; | |
| try { | |
| // Find existing comment | |
| 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(commentMarker)); | |
| let body; | |
| if (!hasSecrets) { | |
| // No secrets found | |
| if (existing) { | |
| // Check if existing comment already shows "Passed" state | |
| const alreadyPassed = existing.body.includes(':white_check_mark: Secret Scanning Passed'); | |
| if (!alreadyPassed) { | |
| // Update to show all secrets are now resolved | |
| // Determine what type of secrets were previously found | |
| const hadVerified = existing.body.includes('CRITICAL') || existing.body.includes(':rotating_light:'); | |
| const previousType = hadVerified ? 'verified secrets' : 'potential secrets'; | |
| body = `${commentMarker} | |
| ## :white_check_mark: Secret Scanning Passed | |
| **No secrets detected in this pull request.** | |
| **Scanned commit:** \`${shortSha}\` ([${commitSha}](${serverUrl}/${repo}/commit/${commitSha})) | |
| Previous ${previousType} have been resolved. Thank you for addressing the security concerns! | |
| --- | |
| *This comment will be updated if new secrets are detected in future commits.* | |
| `; | |
| await github.rest.issues.updateComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| comment_id: existing.id, | |
| body: body | |
| }); | |
| } | |
| } | |
| // If no existing comment and no secrets, don't post anything | |
| return; | |
| } | |
| // Secrets found - create or update warning comment | |
| let severity, icon, action; | |
| if (hasVerified) { | |
| severity = 'CRITICAL'; | |
| icon = ':rotating_light:'; | |
| action = 'This PR is **blocked** until verified secrets are removed.'; | |
| } else { | |
| severity = 'Warning'; | |
| icon = ':warning:'; | |
| action = 'This PR can proceed, but please review the potential secrets below.'; | |
| } | |
| body = `${commentMarker} | |
| ## ${icon} Secret Scanning ${severity} | |
| **TruffleHog scan results:** | |
| - **Verified (active) secrets:** ${verifiedCount} ${verifiedCount > 0 ? ':x:' : ':white_check_mark:'} | |
| - **Unverified (potential) secrets:** ${unverifiedCount} ${unverifiedCount > 0 ? ':warning:' : ':white_check_mark:'} | |
| **Scanned commit:** \`${shortSha}\` ([${commitSha}](${serverUrl}/${repo}/commit/${commitSha})) | |
| ${action} | |
| ### What to do: | |
| 1. **Review the workflow annotations** - they point to exact file and line locations | |
| 2. **Remove any exposed secrets** from your code | |
| 3. **Rotate compromised credentials** - especially verified ones | |
| 4. **Push the fix** to this branch | |
| ### Understanding Results | |
| | Type | Meaning | Action Required | | |
| |------|---------|--------------------| | |
| | **Verified** | Confirmed active credential | **Must remove & rotate** - PR blocked | | |
| | **Unverified** | Potential secret pattern | Review recommended - PR can proceed | | |
| Check the [workflow run logs](${serverUrl}/${repo}/actions/runs/${runId}) for details. | |
| --- | |
| *Verified secrets are confirmed active by TruffleHog. Unverified secrets match known patterns but couldn't be validated.* | |
| `; | |
| if (existing) { | |
| await github.rest.issues.updateComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| comment_id: existing.id, | |
| body: body | |
| }); | |
| } else { | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.payload.pull_request.number, | |
| body: body | |
| }); | |
| } | |
| } catch(e) { | |
| core.error(`Failed to post PR comment: ${e.status || ''} ${e.message}`); | |
| if (e.response) core.error(`Response: ${JSON.stringify(e.response.data).slice(0,400)}`); | |
| } | |
| - name: Fail workflow if verified secrets found | |
| if: steps.process.outputs.has_verified == 'true' | |
| run: | | |
| echo "::error::VERIFIED SECRETS DETECTED - These are confirmed active credentials that must be removed and rotated immediately." | |
| exit 1 |