11name : TruffleHog Secret Scan
22
33on :
4+ # Direct trigger for org-level repository rulesets — works for PRs from forks
5+ # since pull_request_target runs with base repo context. The GITHUB_TOKEN is
6+ # explicitly scoped to least privilege in the job permissions block below.
47 pull_request_target :
58 types : [opened, synchronize, reopened]
9+
10+ # Also support being called as a reusable workflow from individual repos
11+ workflow_call :
12+
613 workflow_dispatch :
714
815permissions :
916 contents : read
17+ # pull-requests: write and issues: write are needed for the PR comment step
18+ # (workflow_call path only). The PR comment step uses github.rest.issues.*
19+ # APIs which require issues: write; pull-requests: write is also kept for
20+ # potential future comment resolution. pull_request_target skips the comment
21+ # step, but GitHub Actions has no per-event conditional permissions within a
22+ # single job — splitting into two jobs would add significant complexity.
1023 pull-requests : write
24+ issues : write
1125
1226# Default exclusion patterns (regex format)
1327# Supports: exact filenames, wildcards, regex patterns
@@ -37,17 +51,26 @@ jobs:
3751
3852 steps :
3953 - name : Checkout repository
40- uses : actions/checkout@v4
54+ uses : actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
4155 with :
4256 fetch-depth : 0
4357 persist-credentials : false
4458
4559 - name : Fetch PR head commits
4660 if : github.event_name != 'workflow_dispatch'
61+ env :
62+ GH_TOKEN : ${{ secrets.GITHUB_TOKEN }}
63+ SERVER_URL : ${{ github.server_url }}
64+ REPO : ${{ github.repository }}
65+ PR_NUMBER : ${{ github.event.pull_request.number }}
66+ PR_HEAD_SHA : ${{ github.event.pull_request.head.sha }}
4767 run : |
48- # Fetch PR commits using GitHub's merge ref (works for all PRs including forks)
49- git fetch origin +refs/pull/${{ github.event.pull_request.number }}/head:refs/remotes/origin/pr-head
50- echo "Fetched PR #${{ github.event.pull_request.number }} head commit: ${{ github.event.pull_request.head.sha }}"
68+ # Scope credentials to this repo only — git -c passes the header in-memory
69+ # and is never written to .git/config, so persist-credentials: false is preserved.
70+ AUTH_HEADER="Authorization: basic $(printf 'x-access-token:%s' "${GH_TOKEN}" | base64 -w0)"
71+ git -c "http.${SERVER_URL}/${REPO}/.extraheader=${AUTH_HEADER}" \
72+ fetch origin +refs/pull/${PR_NUMBER}/head:refs/remotes/origin/pr-head
73+ echo "Fetched PR #${PR_NUMBER} head commit: ${PR_HEAD_SHA}"
5174
5275 - name : Setup exclude config
5376 id : config
5679 run : |
5780 # Always include default exclusions
5881 echo "Adding default exclusions"
59- cat << 'EOF' > .trufflehog-ignore
60- ${{ env.DEFAULT_EXCLUDES }}
61- EOF
82+ printf '%s\n' "$DEFAULT_EXCLUDES" > .trufflehog-ignore
6283
6384 # Append repo/org-level custom exclusions if defined
6485 if [ -n "$TRUFFLEHOG_EXCLUDES" ]; then
@@ -71,34 +92,26 @@ jobs:
7192 cat .trufflehog-ignore
7293 echo "exclude_args=--exclude-paths=.trufflehog-ignore" >> $GITHUB_OUTPUT
7394
74- - name : TruffleHog Scan
75- id : trufflehog
76- # Pinned to v3.94.2 commit SHA to prevent supply chain attacks via mutable tag
77- uses : trufflesecurity/trufflehog@6bd2d14f7a4bc1e569fa3550efa7ec632a4fa67b # v3.94.2
78- continue-on-error : true
79- with :
80- base : ${{ github.event.pull_request.base.sha }}
81- head : ${{ github.event.pull_request.head.sha }}
82- extra_args : --json ${{ steps.config.outputs.exclude_args }}
83-
84- - name : Parse scan results
95+ - name : Scan changed files for secrets
8596 id : parse
8697 if : github.event_name != 'workflow_dispatch'
98+ env :
99+ PR_HEAD_SHA : ${{ github.event.pull_request.head.sha }}
100+ PR_BASE_SHA : ${{ github.event.pull_request.base.sha }}
101+ EXCLUDE_ARGS : ${{ steps.config.outputs.exclude_args }}
87102 run : |
88- # Scan the current state of PR files (not git history)
89- # This ensures renamed files and removed secrets are handled correctly
90- echo "Parsing TruffleHog results..."
103+ echo "Scanning PR changed files for secrets..."
91104
92105 VERIFIED_COUNT=0
93106 UNVERIFIED_COUNT=0
94107
95108 # Checkout PR head to scan current file state
96- git checkout ${{ github.event.pull_request.head.sha }} --quiet
109+ git checkout "${PR_HEAD_SHA}" --quiet
97110
98111 # Get list of files changed in this PR (with rename detection)
99112 # -M enables rename detection, showing only the new filename for renamed files
100113 # --diff-filter=d excludes deleted files (we only want files that exist in the PR head)
101- CHANGED_FILES=$(git diff --name-only -M --diff-filter=d ${{ github.event.pull_request.base.sha }} ...${{ github.event.pull_request.head.sha }} | grep -v '^$' || true)
114+ CHANGED_FILES=$(git diff --name-only -M --diff-filter=d "${PR_BASE_SHA} ...${PR_HEAD_SHA}" | grep -v '^$' || true)
102115
103116 if [ -z "$CHANGED_FILES" ]; then
104117 echo "No files changed in PR"
@@ -110,14 +123,23 @@ jobs:
110123 echo "Scanning changed files:"
111124 echo "$CHANGED_FILES"
112125
113- # Scan only the changed files in their current state using filesystem scanner
114- # Pinned to v3.94.2 to match the action version above
126+ # Scan changed files using TruffleHog filesystem mode — pinned by digest for reproducibility
127+ SCAN_EXIT=0
115128 SCAN_OUTPUT=$(docker run --rm -v "$(pwd)":/tmp -w /tmp \
116- ghcr.io/trufflesecurity/trufflehog:3.94.2 \
129+ ghcr.io/trufflesecurity/trufflehog:3.94.2@sha256:dabd9a5f78ed81211792afc39a35100e2b947baa9f43f1e73a97759d7d15ab86 \
117130 filesystem /tmp/ \
118131 --json \
119- ${{ steps.config.outputs.exclude_args }} \
120- --no-update 2>/dev/null || true)
132+ $EXCLUDE_ARGS \
133+ --no-update 2>/dev/null) || SCAN_EXIT=$?
134+
135+ # TruffleHog exits 0 (no secrets found) or 183 (secrets found) on a successful run.
136+ # Any other exit code means Docker or TruffleHog itself failed to execute.
137+ # We must not silently pass in that case — an empty SCAN_OUTPUT would report 0 findings
138+ # and let the job succeed, bypassing the required ruleset check (fail-open).
139+ if [ "$SCAN_EXIT" -ne 0 ] && [ "$SCAN_EXIT" -ne 183 ]; then
140+ echo "::error::TruffleHog Docker scan failed (exit ${SCAN_EXIT}). Blocking to prevent fail-open bypass of secret scanning."
141+ exit 1
142+ fi
121143
122144 # Parse JSON lines and filter to only changed files
123145 if [ -n "$SCAN_OUTPUT" ]; then
@@ -128,11 +150,24 @@ jobs:
128150 fi
129151
130152 FILE=$(echo "$line" | jq -r '.SourceMetadata.Data.Filesystem.file // "unknown"')
131- # Remove /tmp/ prefix if present
153+ # Remove /tmp/ prefix (Docker mounts the repo at /tmp/)
132154 FILE="${FILE#/tmp/}"
133155
134- # Only count secrets in files that are part of this PR
135- if ! echo "$CHANGED_FILES" | grep -qx "$FILE"; then
156+ # Re-apply exclusion patterns against the relative path.
157+ # Docker sees absolute paths (/tmp/vendor/config.js), so TruffleHog's
158+ # --exclude-paths misses patterns anchored with ^ (e.g. ^vendor/).
159+ # We check here after stripping the /tmp/ prefix to enforce them correctly.
160+ EXCLUDED=false
161+ while IFS= read -r excl_pattern; do
162+ excl_pattern="${excl_pattern#"${excl_pattern%%[! ]*}"}" # ltrim whitespace
163+ [ -z "$excl_pattern" ] && continue
164+ [ "${excl_pattern#\#}" != "$excl_pattern" ] && continue # skip comment lines
165+ grep -qE -- "$excl_pattern" <<< "$FILE" && EXCLUDED=true && break
166+ done < .trufflehog-ignore
167+ [ "$EXCLUDED" = "true" ] && continue
168+
169+ # Only process files changed in this PR
170+ if ! grep -qxF -- "$FILE" <<< "$CHANGED_FILES"; then
136171 continue
137172 fi
138173
@@ -184,18 +219,33 @@ jobs:
184219 fi
185220
186221 - name : Post PR comment on findings
187- if : github.event_name != 'workflow_dispatch'
188- uses : actions/github-script@v7
222+ # workflow_call: token is scoped to the calling repo — write access works.
223+ # pull_request_target (org ruleset): token is read-only for the triggering repo —
224+ # createComment/updateComment will 403. Skip and rely on job annotations instead.
225+ if : github.event_name == 'workflow_call'
226+ env :
227+ PR_HEAD_SHA : ${{ github.event.pull_request.head.sha }}
228+ HAS_SECRETS : ${{ steps.process.outputs.has_secrets }}
229+ HAS_VERIFIED : ${{ steps.process.outputs.has_verified }}
230+ VERIFIED_COUNT : ${{ steps.parse.outputs.verified_count }}
231+ UNVERIFIED_COUNT : ${{ steps.parse.outputs.unverified_count }}
232+ SERVER_URL : ${{ github.server_url }}
233+ REPO : ${{ github.repository }}
234+ RUN_ID : ${{ github.run_id }}
235+ uses : actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
189236 with :
190237 script : |
191238 const commentMarker = '<!-- TRUFFLEHOG-SCAN-COMMENT -->';
192- const commitSha = '${{ github.event.pull_request.head.sha }}' ;
239+ const commitSha = process.env.PR_HEAD_SHA ;
193240 const shortSha = commitSha.substring(0, 7);
194- const hasSecrets = '${{ steps.process.outputs.has_secrets }}' === 'true';
195- const hasVerified = '${{ steps.process.outputs.has_verified }}' === 'true';
196- const verifiedCount = '${{ steps.parse.outputs.verified_count }}' || '0';
197- const unverifiedCount = '${{ steps.parse.outputs.unverified_count }}' || '0';
198-
241+ const hasSecrets = process.env.HAS_SECRETS === 'true';
242+ const hasVerified = process.env.HAS_VERIFIED === 'true';
243+ const verifiedCount = process.env.VERIFIED_COUNT || '0';
244+ const unverifiedCount = process.env.UNVERIFIED_COUNT || '0';
245+ const serverUrl = process.env.SERVER_URL;
246+ const repo = process.env.REPO;
247+ const runId = process.env.RUN_ID;
248+ try {
199249 // Find existing comment
200250 const { data: comments } = await github.rest.issues.listComments({
201251 owner: context.repo.owner,
@@ -224,7 +274,7 @@ jobs:
224274
225275 **No secrets detected in this pull request.**
226276
227- **Scanned commit:** \`${shortSha}\` ([${commitSha}](${{ github.server_url }} /${{ github.repository } }/commit/${commitSha}))
277+ **Scanned commit:** \`${shortSha}\` ([${commitSha}](${serverUrl} /${repo }/commit/${commitSha}))
228278
229279 Previous ${previousType} have been resolved. Thank you for addressing the security concerns!
230280
@@ -262,7 +312,7 @@ jobs:
262312 - **Verified (active) secrets:** ${verifiedCount} ${verifiedCount > 0 ? ':x:' : ':white_check_mark:'}
263313 - **Unverified (potential) secrets:** ${unverifiedCount} ${unverifiedCount > 0 ? ':warning:' : ':white_check_mark:'}
264314
265- **Scanned commit:** \`${shortSha}\` ([${commitSha}](${{ github.server_url }} /${{ github.repository } }/commit/${commitSha}))
315+ **Scanned commit:** \`${shortSha}\` ([${commitSha}](${serverUrl} /${repo }/commit/${commitSha}))
266316
267317 ${action}
268318
@@ -278,7 +328,7 @@ jobs:
278328 | **Verified** | Confirmed active credential | **Must remove & rotate** - PR blocked |
279329 | **Unverified** | Potential secret pattern | Review recommended - PR can proceed |
280330
281- Check the [workflow run logs](${{ github.server_url }} /${{ github.repository }} /actions/runs/${{ github.run_id } }) for details.
331+ Check the [workflow run logs](${serverUrl} /${repo} /actions/runs/${runId }) for details.
282332
283333 ---
284334 *Verified secrets are confirmed active by TruffleHog. Unverified secrets match known patterns but couldn't be validated.*
@@ -299,6 +349,10 @@ jobs:
299349 body: body
300350 });
301351 }
352+ } catch(e) {
353+ core.error(`Failed to post PR comment: ${e.status || ''} ${e.message}`);
354+ if (e.response) core.error(`Response: ${JSON.stringify(e.response.data).slice(0,400)}`);
355+ }
302356
303357 - name : Fail workflow if verified secrets found
304358 if : steps.process.outputs.has_verified == 'true'
0 commit comments