-
Notifications
You must be signed in to change notification settings - Fork 1
PDP-684 Add TruffleHog secret scanning workflow for PR validation #16
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
43162b1
200cee2
bf7b85b
527f3f2
599658f
f42179b
e496bac
d279b66
1aae01b
77909eb
c120b09
174b7d0
7ded023
40260d7
5907188
c38506c
6f0f819
2a55bbb
e1358eb
f30fed0
3f65074
467594b
693b2e7
8770dab
1a61036
aa00512
d57e46b
0bff560
cbfc2c8
f610f23
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,269 @@ | ||
| name: TruffleHog Secret Scan | ||
|
|
||
| on: | ||
| pull_request_target: | ||
| types: [opened, synchronize, reopened] | ||
| workflow_dispatch: | ||
|
|
||
| permissions: | ||
| contents: read | ||
| pull-requests: 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@v4 | ||
| with: | ||
| fetch-depth: 0 | ||
|
|
||
| - name: Fetch PR head commits | ||
| if: github.event_name != 'workflow_dispatch' | ||
| run: | | ||
| # Fetch PR commits using GitHub's merge ref (works for all PRs including forks) | ||
| git fetch origin +refs/pull/${{ github.event.pull_request.number }}/head:refs/remotes/origin/pr-head | ||
| echo "Fetched PR #${{ github.event.pull_request.number }} head commit: ${{ github.event.pull_request.head.sha }}" | ||
|
|
||
| - name: Setup exclude config | ||
| id: config | ||
| run: | | ||
| # Always include default exclusions | ||
| echo "Adding default exclusions" | ||
| cat << 'EOF' > .trufflehog-ignore | ||
| ${{ env.DEFAULT_EXCLUDES }} | ||
| EOF | ||
|
|
||
| # Append repo/org-level custom exclusions if defined | ||
| if [ -n "${{ vars.TRUFFLEHOG_EXCLUDES }}" ]; then | ||
| echo "Adding repo/org-level TRUFFLEHOG_EXCLUDES patterns" | ||
| # Support both comma-separated and newline-separated patterns | ||
| echo "${{ vars.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: TruffleHog Scan | ||
| id: trufflehog | ||
| uses: trufflesecurity/trufflehog@main | ||
| continue-on-error: true | ||
| with: | ||
| base: ${{ github.event.pull_request.base.sha }} | ||
| head: ${{ github.event.pull_request.head.sha }} | ||
| extra_args: --json ${{ steps.config.outputs.exclude_args }} | ||
|
|
||
| - name: Parse scan results | ||
| id: parse | ||
| if: github.event_name != 'workflow_dispatch' | ||
| run: | | ||
| # Capture TruffleHog JSON output by re-running with same args | ||
| echo "Parsing TruffleHog results..." | ||
|
|
||
| VERIFIED_COUNT=0 | ||
| UNVERIFIED_COUNT=0 | ||
|
|
||
| SCAN_OUTPUT=$(docker run --rm -v "$(pwd)":/tmp -w /tmp \ | ||
| ghcr.io/trufflesecurity/trufflehog:latest \ | ||
| git file:///tmp/ \ | ||
| --since-commit ${{ github.event.pull_request.base.sha }} \ | ||
| --branch ${{ github.event.pull_request.head.sha }} \ | ||
| --json \ | ||
| ${{ steps.config.outputs.exclude_args }} \ | ||
| --no-update 2>/dev/null || true) | ||
|
|
||
| # Parse JSON lines and create GitHub annotations | ||
| 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.Git.file // "unknown"') | ||
| LINE_NUM=$(echo "$line" | jq -r '.SourceMetadata.Data.Git.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' | ||
| run: | | ||
| VERIFIED=${{ steps.parse.outputs.verified_count || 0 }} | ||
| UNVERIFIED=${{ steps.parse.outputs.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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we really need a comment? Can't we just rely on annotations?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why PR Comments are better for security findings:
|
||
| if: github.event_name != 'workflow_dispatch' | ||
| uses: actions/github-script@v7 | ||
| with: | ||
| script: | | ||
| const commentMarker = '<!-- TRUFFLEHOG-SCAN-COMMENT -->'; | ||
GAdityaVarma marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| const commitSha = '${{ github.event.pull_request.head.sha }}'; | ||
| const shortSha = commitSha.substring(0, 7); | ||
| const hasSecrets = '${{ steps.process.outputs.has_secrets }}' === 'true'; | ||
| const hasVerified = '${{ steps.process.outputs.has_verified }}' === 'true'; | ||
| const verifiedCount = '${{ steps.parse.outputs.verified_count }}' || '0'; | ||
| const unverifiedCount = '${{ steps.parse.outputs.unverified_count }}' || '0'; | ||
|
|
||
| // 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 was a critical/blocking one (had verified secrets) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we update the PR comment in case developer removes unverified secrets in subsequent commit? |
||
| const wasBlocking = existing.body.includes('CRITICAL') || existing.body.includes(':rotating_light:'); | ||
| if (wasBlocking) { | ||
| // Update to show verified secrets are now resolved | ||
| body = `${commentMarker} | ||
| ## :white_check_mark: Secret Scanning Passed | ||
|
|
||
| **No secrets detected in this pull request.** | ||
|
|
||
| **Scanned commit:** \`${shortSha}\` ([${commitSha}](${{ github.server_url }}/${{ github.repository }}/commit/${commitSha})) | ||
|
|
||
| Previous issues 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 it was just a warning (unverified only), leave it as-is | ||
| } | ||
| // 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}](${{ github.server_url }}/${{ github.repository }}/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](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) 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 | ||
| }); | ||
| } | ||
|
|
||
| - 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 | ||
Uh oh!
There was an error while loading. Please reload this page.