Skip to content

PDP-1182: Remove per-repo pr-workflow.yaml #58

PDP-1182: Remove per-repo pr-workflow.yaml

PDP-1182: Remove per-repo pr-workflow.yaml #58

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