Add reusable secretScan workflow for TruffleHog secret scanning - #110
Conversation
This comment was marked as off-topic.
This comment was marked as off-topic.
This comment was marked as off-topic.
This comment was marked as off-topic.
This comment was marked as off-topic.
This comment was marked as off-topic.
continue-on-error suppressed every non-zero exit from the TruffleHog action, so a scan that failed to start reported a pass. TruffleHog exits 183 for a finding and 1 for an operational error, so warn-only mode now appends --no-fail to suppress only the 183 exit. Also scan this repository with a local workflow ref, so a change to secretScan.yml is checked by the version it proposes.
The trufflesecurity/trufflehog action hardcodes --fail and exposes no exit code, and kingpin rejects a repeated flag with "flag 'fail' cannot be repeated", so --no-fail cannot override it. TruffleHog exits 183 for a finding and 1 for an operational error. Reading the exit code is the only way to let warn-only mode suppress a finding while still failing the job when the scan itself breaks. Pin the image by digest and derive the scan range from the triggering event.
The scan reads file:///repo, so it cannot reach the remote. Two events
did not meet that requirement:
- pull_request_target checks out the base repository, so an external
fork's head commit is absent.
- A force push can leave the previous head unreachable from every
remaining ref, so --since-commit cannot resolve.
Neither event has a caller, so drop both rather than fetch the missing
objects. Any unlisted event now fails with an explicit error.
Also check that each commit in the scan range resolves before starting.
TruffleHog aborts on a missing commit without saying why.
This comment was marked as off-topic.
This comment was marked as off-topic.
The note ran under if: success(), which is true in warn-only mode even when TruffleHog exits 183, so a run that found a credential claimed the scan was clean. Move the note into the exit-code-0 branch.
|
@roryabraham and @neil-marcellini Requesting your reviews as the top contributors to this repo please 🙏 |
This comment was marked as off-topic.
This comment was marked as off-topic.
A credential pushed to a branch that never opens a pull request was invisible to pull_request scanning. Web-PDFs branch jasper-dontThrowIfPagesDontMatch has carried a GitHub App private key since March 2025 for exactly this reason. Rulesets cannot trigger on push, so each repository gets its own caller on push instead. Every commit in a pull request is pushed first, so push covers the same ground; pull_request is only needed where external forks contribute. Three pushes name no commits to scan, and each is handled: a branch deletion, a tag push whose commit was already scanned on its branch, and a force push or new branch whose starting commit is unreachable. The last falls back to where the branch left the default branch, capped at 50 commits when there is no shared ancestor.
Skipping every tag push assumed the tagged commit had already been scanned on a branch. Git allows pushing a tag whose commit reaches no branch at all, which transfers that commit with the tag, so the tag push was the only event that could scan it. Skip only when the commit already reaches a branch. Also add workflow_dispatch to the caller. push covers only commits that land after the workflow exists, leaving pre-existing history unscanned with no way to run a baseline. No schedule: history does not change.
Capping the no-ancestor case at 50 commits left older commits from that push permanently unscanned: the next push starts from this push's head, so nothing ever revisits them. Orphan branches, the first push to a new repository, and a force push to the default branch all take this path.
| # The scan scope follows the event that triggered the calling workflow: | ||
| # - push -> scans the commits the push introduced | ||
| # - pull_request -> scans only the commits in the pull request | ||
| # - schedule / workflow_dispatch -> scans the full history |
There was a problem hiding this comment.
Do we plan to keep the schedule / workflow_dispatch triggers?
Is that redundant given that it will run on push to any branch?
There was a problem hiding this comment.
workflow_dispatch isn't redundant — push only covers commits that land after the workflow exists, so it never scans the history that predates installation. That's the one-off baseline scan. schedule shares the same code branch at zero cost and no caller sets one, so I've left it handled rather than rejected.
| # v6.0.3 | ||
| uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 | ||
| with: | ||
| fetch-depth: 0 |
There was a problem hiding this comment.
fetch-depth: 0 is very slow (~4 mins in app).
We might be able to do something like this to optimize fetching, assuming we only need to handle pull_request and push:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10
with:
fetch-depth: 1
- name: Fetch history needed by the scan
env:
EVENT_NAME: ${{ github.event_name }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PUSH_BEFORE_SHA: ${{ github.event.before }}
PUSH_AFTER_SHA: ${{ github.event.after }}
PUSH_REF: ${{ github.event.ref }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
shell: bash
run: |
set -euo pipefail
if [[ "$EVENT_NAME" == "pull_request" ]]; then
# The checkout is shallow at the PR merge commit. Deepen only that ref;
# the scan needs the PR base/head ancestry, not unrelated branch histories.
git fetch --no-tags --unshallow origin \
"+refs/pull/${PR_NUMBER}/merge:refs/remotes/origin/pr-merge"
exit 0
fi
[[ "$EVENT_NAME" == "push" ]] || exit 0
# A deleted ref introduces no commits.
if [[ "$PUSH_AFTER_SHA" =~ ^0+$ ]]; then
exit 0
fi
if [[ "$PUSH_REF" == refs/tags/* ]]; then
# The existing tag logic checks whether any remote branch contains the
# tagged commit, so retain a full fetch for tag pushes.
git fetch --no-tags --unshallow origin \
"+refs/heads/*:refs/remotes/origin/*" \
"+refs/tags/*:refs/tags/*"
exit 0
fi
# For a normal branch push, deepen by the number of commits in the event.
# Starting from the checked-out after SHA, this should make before available.
PUSH_COMMIT_COUNT="$(jq '(.commits // []) | length' "$GITHUB_EVENT_PATH")"
if ((PUSH_COMMIT_COUNT > 0)); then
git fetch --no-tags --deepen="$PUSH_COMMIT_COUNT" origin "$PUSH_AFTER_SHA"
fi
# Branch creation and some force pushes have no usable before commit in the
# fetched range. Preserve the existing merge-base fallback, but fetch only
# the pushed branch and default branch for it.
if [[ "$PUSH_BEFORE_SHA" =~ ^0+$ ]] ||
! git cat-file -e "${PUSH_BEFORE_SHA}^{commit}" 2>/dev/null; then
FETCH_ARGS=(--no-tags)
if [[ "$(git rev-parse --is-shallow-repository)" == "true" ]]; then
FETCH_ARGS+=(--unshallow)
fi
git fetch "${FETCH_ARGS[@]}" origin \
"+refs/heads/${DEFAULT_BRANCH}:refs/remotes/origin/${DEFAULT_BRANCH}" \
"+${PUSH_REF}:refs/remotes/origin/push-target"
fi
It also might be useful to wrap that up in a composite action : it could come in handy in other repos too
There was a problem hiding this comment.
Tried it, and it silently breaks the scan — reverted in 5d0f326.
TruffleHog resolves the merge base for --since-commit through go-git, which ignores .git/shallow:
error chunking dir: unable to resolve merge base: object not found
finished scanning {"chunks": 0, "bytes": 0}
Real git merge-base resolves the same clone at any depth, so it's a go-git limitation rather than something depth tuning fixes. Worse, TruffleHog exited 0 on that error, so the check went green having scanned nothing. I've added --fail-on-scan-errors so that class of failure can't pass silently again — good catch by accident.
If you want to pursue the optimisation, the viable route is dropping --since-commit and using --branch <after> --max-depth <push commit count> instead, which needs no merge base. Happy to try that here if you think it's worth the risk.
There was a problem hiding this comment.
Ok, bummer. Maybe we file an upstream issue in go-git, or subscribe if there's an existing one so we can do this optimization later.
fetch-depth: 0 cloned the whole repository for a scan that reads a few commits: around 1 GB for Auth or Web-Expensify, 3 GB for App. Check out at depth 1 and deepen by the commit count in the push payload instead, which the payload carries for up to 2048 commits. Three cases still need every branch and fall back to a full fetch: a pull request, a tag push, because deciding whether a branch contains the tagged commit needs full ancestry rather than branch tips, and a push naming no reachable starting commit. Guard --unshallow behind a shallow check, because it fails on a repository that is already complete. Rename fail_on_findings to should_fail_on_findings.
Without --fail-on-scan-errors TruffleHog logs the error, scans zero commits and still exits 0, so the job reported a clean scan for a scan that covered nothing. That is the same silently-green-check failure the exit-code branching exists to prevent. Also revert the shallow checkout. TruffleHog resolves the merge base for --since-commit through go-git, which ignores .git/shallow and fails with "unable to resolve merge base: object not found" at any depth. Real git merge-base handles the same clone, so depth tuning cannot fix it.
GitHub creates no event for tags when more than three are pushed at once, so a commit reaching no branch that arrives in such a batch goes unscanned. Recovery today is running the caller manually. A schedule would close it automatically, but a full scan currently re-reports every finding already in history. That is worth revisiting once findings create deduplicated issues.
|
|
||
| - name: Scan for secrets | ||
| env: | ||
| EVENT_NAME: ${{ github.event_name }} |
There was a problem hiding this comment.
NAB: Not sure all these env aliases for GitHub workflow expressions are helpful. They seem to be adding an indirection without a clear purpose or clarity improvement.
Details
Reusable workflow that scans a repository for committed credentials with TruffleHog. Each repo adds a short caller on
push.Scan on
push, notpull_request. PR commits are pushed first, sopushcovers the same ground plus branches that never open a PR.Web-PDFsbranchjasper-dontThrowIfPagesDontMatchhas carried a GitHub App private key since March 2025 — no PR-based check would see it. Rulesets cannot trigger onpush, hence a per-repo caller. Addpull_requestonly where external forks contribute, such asApp.A finding never fails the check. A pushed credential is already compromised, so blocking a merge does not undo the leak.
fail_on_findingsexists but defaults tofalse.Three non-obvious details, each confirmed:
v—3.97.4is 200,v3.97.4is 404. Pinned by digest.--resultsis omitted deliberately. With--no-verificationevery finding isunverified, so TruffleHog's own recommended--results=verified,unknownwould report nothing — a silently green check.trufflesecurity/trufflehog. That action hardcodes--failand exposes no exit code, and--no-failis rejected withflag 'fail' cannot be repeated. Exit 183 means a finding, anything else means a broken scan, and only reading the code separates them.Related Issues
https://github.com/Expensify/Expensify/issues/681714
Manual Tests
secretScanSelf.ymlmakes this repo scan itself via a local ref, so a PR changingsecretScan.ymlis checked by the version it proposes. That caught two bugs here before review.I extracted the scan step and ran it against a stubbed
dockerin a throwaway repo containing a genuinely unreachable commit and a tag-only orphan commit.before..after--max-depth 50pull_request_target,releaseVerify after merge: push a branch to
Saltwithout opening a PR, and confirm the check runs.Linked PRs
Enable TruffleHog secret scanning in CI — on HOLD until this merges