Repository navigation
fix(ci): de-privilege fork unit tests to close PWN-request exposure (Wiz f0dca03e) - #245
Merged
Merged
Conversation
Wiz issue f0dca03e (CRITICAL, "Publicly exposed CI workflow with access to secrets vulnerable to PWN request") flags four IaC findings in .github/workflows/test-fork-pr.yml: an untrusted fork checkout plus a local action invoked from that checkout, in each of the go-test and e2e-tests jobs. The go-test findings are avoidable. Unit tests need no repository secrets -- only the "Publish JUnit Tests" step does, and that step already carries its own same-repo `if:` guard. So fork unit tests do not need pull_request_target at all: - main.yml: drop the job-level same-repo gate on go-test so fork PRs get unit test coverage under the unprivileged `pull_request` trigger. The Datadog publish step stays gated, so DATADOG_API_KEY is never present in a job that runs fork code. - test-fork-pr.yml: delete the now-redundant privileged go-test job, and update check-success's `needs:`. This removes two of Wiz's four findings and takes DATADOG_API_KEY out of every privileged fork context entirely. The e2e job is irreducibly privileged and stays put: its whole purpose is to run the fork's own action code against the LaunchDarkly API, so it needs both the untrusted checkout and LD_ACCESS_TOKEN_WRITER. It keeps the existing safe-to-test label gate. As defence in depth, all checkouts that handle fork-authored code now set `persist-credentials: false` so poisoned code cannot reuse the workflow token from .git/config. Note that `go-test` is a required status check. It is still produced for fork PRs -- by main.yml now rather than test-fork-pr.yml -- and no longer requires a maintainer label to report. Fork runs remain gated by the repository's "require approval for all external contributors" Actions setting. Verified with actionlint: no new findings (SC2209 count drops from 2 to 1 because the duplicated test block is gone). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tknopp-ld
approved these changes
Aug 14, 2026
tknopp-ld
approved these changes
Aug 14, 2026
cspath1
approved these changes
Aug 14, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
[Wiz issue
f0dca03e](https://app.wiz.io/issues#~(issue~'f0dca03e-007b-49a4-a459-05b4aad4f094) — CRITICAL, "Publicly exposed CI workflow with access to secrets vulnerable to PWN request." Opened 2026-07-31, reopened 2026-08-09.Heads up for anyone reading that issue cold: the issue's affected entity says
.github/workflows/main.yml, but Wiz's own IaC scanner names the real file as.github/workflows/test-fork-pr.yml.main.ymlusespull_request, which never hands secrets to fork code. Don't chasemain.yml.Wiz has four line-level findings, all in
test-fork-pr.yml:go-testref: head.shain a privileged contextgo-testuses: ./.github/actions/publish-junitafter untrusted checkoute2e-testsref: head.shain a privileged contexte2e-testsuses: ./after untrusted checkoutThe finding is a true positive in mechanism. Under
pull_request_target, fork-authored code executes before the secret-bearing step, so it can poison$GITHUB_ENV/PATH/the local action definition and capture the secret. Thesafe-to-testlabel gate (#238) is a real control, but it is a human-review control — it depends on a maintainer catching a hostileaction.yml,.pre-commit-config.yaml, orDockerfilein the diff.What changed
The two
go-testfindings are avoidable, and this PR removes them. Unit tests need no repository secrets — only the "Publish JUnit Tests" step does, and that step already carries its own same-repoif:guard. So fork unit tests never neededpull_request_target:main.yml— dropped the job-level same-repo gate ongo-test, so fork PRs get unit test coverage under the unprivilegedpull_requesttrigger. The Datadog publish step stays gated, soDATADOG_API_KEYis now never present in any job that runs fork code.test-fork-pr.yml— deleted the redundant privilegedgo-testjob; updatedcheck-success'sneeds:.persist-credentials: false, so poisoned code can't reuse the workflow token out of.git/config.Net: 4 findings → 2, and
DATADOG_API_KEYleaves the privileged blast radius entirely.What this PR does not fix, and why
The
e2e-testsjob is irreducibly privileged. Its entire purpose is to run the fork's own action code against the real LaunchDarkly API, so it needs both the untrusted checkout andLD_ACCESS_TOKEN_WRITERin the same job. Wiz's suggested Strategies A/B/C (check out base instead of head; isolate fork code to a subdirectory; hand off artifacts viaworkflow_run) all break the test's purpose — there is nothing to test if you don't run the fork's code. So the twoe2e-testsfindings survive this PR by design, still mitigated by the label gate.So merging this PR will not close the Wiz issue, and that's expected. I checked how this rule has been closed before: tenant-wide it has been resolved exactly once, as
ISSUE_FIXED, and the resolution evidence is deletion of theIAC_FINDINGresource — i.e. the pattern was removed from the YAML. It has never been closed via a compensating control. Wiz's two remaining checks are IaC pattern matchers onpull_request_target+ref: head.sha+uses: ./, so any runtime control leaves them matching. Closing the residual will need an explicit risk acceptance in Wiz, not another workflow edit. ProdSec (me) will handle that side.Optional hardening worth considering on its own merits (not a route to closing the Wiz findings): move
LD_ACCESS_TOKEN_WRITERinto a GitHub Environment with required reviewers and addenvironment: fork-e2etoe2e-tests. Per GitHub's docs, "a job that references an environment must follow any protection rules for the environment before running or accessing the environment's secrets" — the job is held inWaitingbefore any step, so the untrusted checkout never happens without a per-run human approval. That's genuinely stronger than the label gate: it can't go stale and can't be raced by a push. I didn't wire it here because an Environment with no protection rules is a silent no-op that looks like a control, and picking the reviewer set is your call. Happy to open that PR if you want it.Also worth confirming:
LD_ACCESS_TOKEN_WRITERshould be scoped to thedeveloper-toolbar-sandboxproject only. If it already is, the residual blast radius is one sandbox project and the remaining risk is low.On secret rotation
Wiz's remediation plan says rotate both secrets. I checked whether the exposure was ever actually exercised, since exploitation requires a maintainer to have labeled a hostile fork PR
safe-to-test:Fieldguidefork, both authored by @jazanne.test-fork-pr.ymlruns to date were on those two PRs, labeled by @cspath1 and @tknopp-ld.So no untrusted third-party code has ever run in the privileged context. Rotation is precautionary, not incident-driven — your call whether it's worth the churn. Nothing here indicates compromise.
Review notes / risk
go-testis a required status check. It's still produced for fork PRs — bymain.ymlnow instead oftest-fork-pr.yml— and it no longer needs a maintainer label to report, which is a small improvement to fork contributor experience.Check Success (Fork)is not a required check, so theneeds:change is safe.approval_policy: all_external_contributors, so an external fork PR can't run any workflow until a maintainer approves the run.test-fork-pr.ymlonly fires onpull_request_target+labeled+ fork head, so this PR (same-repo) never ran it — the greene2e-testscheck above ismain.yml's job. That means thepersist-credentials: falseI added directly aboveuses: ./is unexercised. Thego-test/generate-docsruns here do provepersist-credentials: falsesurvivesgo mod tidyandpre-commit run --all-files, but not that step. You'd know better than I do whetherld-find-code-refsneeds a persisted git credential; if it does, drop that one line. Alternatively label a throwaway fork PRsafe-to-testto confirm.actionlint: no new findings. The SC2209 count actually drops 2 → 1 because the duplicated test block is gone. Remaining shellcheck nits are pre-existing and on untouched lines.via LD Research 🤖