Skip to content

Commit f2c1edd

Browse files
authored
Merge pull request #45 from marklogic/feat/PDP-1182-trufflehog-org-ruleset-support
PDP-1182: Add org ruleset support to trufflehog-scan.yml
2 parents 1952b3c + b2f0af2 commit f2c1edd

1 file changed

Lines changed: 96 additions & 42 deletions

File tree

.github/workflows/trufflehog-scan.yml

Lines changed: 96 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,27 @@
11
name: TruffleHog Secret Scan
22

33
on:
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

815
permissions:
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
@@ -56,9 +79,7 @@ jobs:
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

Comments
 (0)