Safeguard
DevSecOps

Securing Playwright E2E Tests in GitHub Actions Without Leaking Secrets

A 2025 supply-chain attack on tj-actions/changed-files hit 23,000+ repos and dumped secrets into public logs — the same CI patterns power most Playwright pipelines.

Safeguard Research Team
Research
7 min read

On March 12, 2025, a malicious commit repointed every version tag of tj-actions/changed-files — a GitHub Action used in more than 23,000 repositories — to code that dumped CI runner memory and printed base64-encoded secrets straight into public workflow logs. StepSecurity caught it two days later, GitHub yanked the tags, and a patched v46.0.1 shipped within the week; CISA's advisory tracked the tj-actions compromise as CVE-2025-30066 and a related, similarly-timed compromise of reviewdog/action-setup as CVE-2025-30154. The attack worked because teams pin third-party actions by a mutable tag, not a commit SHA, and because those actions often run in workflows that already have repo secrets in scope. Playwright end-to-end suites sit squarely in that blast radius: they need real API keys, test-account credentials, and often a GITHUB_TOKEN to comment results on a PR, and they typically pull in third-party actions for checkout, caching, and artifact upload. GitHub itself separately reported roughly 39 million secret leaks detected across the platform in 2024. This post covers the concrete wiring choices — trigger selection, job separation, permissions scoping, and action pinning — that let you run Playwright against every pull request without handing a forked PR your production credentials.

Why is pull_request_target dangerous for Playwright test runs?

pull_request_target is dangerous because it runs your workflow with the base repository's permissions and full secrets access, evaluated from the target branch — but the moment that workflow checks out the PR's own head commit and executes anything from it (installing dependencies, running a test script, even a Playwright config file), you've handed an attacker-controlled commit your secrets. GitHub Security Lab documented this exact pattern under the name "pwn request," and GitHub's own secure-use guidance explicitly warns against checking out untrusted PR code inside a pull_request_target workflow. The safe default for running Playwright against a PR's actual code is the plain pull_request trigger: forked-PR runs get a read-only GITHUB_TOKEN and no repository secrets at all, so even a fully malicious playwright.config.ts in the fork can't exfiltrate anything that isn't already public. Reserve pull_request_target for a separate, later job that never checks out untrusted code — for example, one that only reads a previously uploaded artifact to post a comment.

How do you split test execution from privileged steps?

Split them into two workflows connected by workflow_run, not one workflow with an early secrets step. The first workflow triggers on pull_request, has no permissions beyond the default read scope, and does the actual work: checkout, npm ci, npx playwright test, then uploads the HTML report and trace files as build artifacts. It never touches a secret that matters — test fixtures should use scoped, disposable credentials for a staging environment, not production keys. A second workflow triggers on workflow_run for the first one completing, runs in the base-repo context (so it can have pull_request_target-equivalent trust), downloads the artifact, and does anything privileged: posting a PR comment with results, deploying a preview, or writing to a dashboard. This is the two-workflow pattern GitHub itself documents for handling untrusted pull requests safely, and it maps cleanly onto Playwright's existing artifact model since playwright-report/ is already designed to be uploaded and inspected after the fact.

What permissions should the test job actually have?

The test job should declare an explicit permissions: block scoped to only what Playwright needs, because the default GITHUB_TOKEN scope has changed meaningfully over time and you shouldn't rely on repo-level defaults. Repositories created before February 2023 default GITHUB_TOKEN to read/write across most scopes; repositories created after that date default to read-only. If your organization has older repos still running on the legacy default, a compromised or malicious dependency running inside the test job inherits write access it doesn't need. Set permissions: contents: read at the workflow or job level explicitly, and add nothing else unless a specific step requires it — for instance checks: write only on the reporting job, never the untrusted test job. Treat this the same way you'd treat least-privilege IAM: the Playwright job's job is to produce a report, not to write to the repo, comment on issues, or publish packages, so its token shouldn't be able to do any of that even if something inside the job goes wrong.

How should Playwright tests get the credentials they need without exposing them?

Playwright suites commonly need an API key, a test-user password, or a base URL for a staging environment, and the goal is to make sure a forked PR's code can never read them while a same-repo branch's code can. Because pull_request-triggered workflows on forked PRs receive no repository secrets by design, storing test credentials as encrypted GitHub Actions secrets and referencing them only in same-repo (non-fork) trigger contexts already blocks the leak path GitHub Security Lab describes — the risk reappears only if you switch to pull_request_target or workflow_dispatch with elevated trust. For contributions from forks that legitimately need to run authenticated E2E tests, use a GitHub Environment with required reviewers: the workflow pauses until a maintainer approves the run, which converts an automatic secrets exposure into a manual, auditable gate. Whatever credentials you do inject, use dedicated, rotate-able test accounts scoped to a non-production tenant — never reuse a production API key in a CI job, since Playwright's own console and network logging can incidentally capture request headers in the HTML report if a test author isn't careful about redaction.

Why does pinning third-party actions to a commit SHA matter here specifically?

It matters because a typical Playwright workflow pulls in several third-party actions — actions/checkout, actions/cache or actions/setup-node, actions/upload-artifact, and often a changed-files or coverage-comment action — and each one is a supply-chain dependency running with whatever permissions your job has. The tj-actions/changed-files compromise is the clearest evidence that tag-based pinning (@v45, @v46) is not a security boundary: the attacker didn't need to publish a new version, they repointed the existing tags that thousands of workflows already trusted. Pinning to a full 40-character commit SHA (uses: actions/checkout@<sha>) means an attacker would need to rewrite Git history or compromise the specific commit you reference, a materially harder bar. GitHub's own post-incident guidance reiterated commit-SHA pinning as the primary mitigation, and Dependabot can keep SHA-pinned actions current by opening version-bump PRs automatically, so pinning doesn't mean losing update visibility — it means updates go through review instead of happening silently underneath you.

How Safeguard Helps

Safeguard's secrets scanning covers exactly the surface a Playwright pipeline creates: it ingests CI build-log tails with redaction at source, so a misconfigured DEBUG=pw:api trace or an accidental console.log of a bearer token in a test file gets caught and verified against the issuing service — AWS keys via sts:GetCallerIdentity, GitHub PATs via /user, Stripe keys via a read-only balance check — before it's treated as a real incident rather than noise. Verified leaks can be set as a hard policy block (condition: any(secrets, verified == true)) so a leaked staging credential from a forked-PR run never quietly ships to main. The pre-push git hook catches the same patterns locally, before a workflow even runs, and Safeguard's remediation playbook can revoke and rotate a verified credential through your secret manager (Vault, AWS Secrets Manager) as soon as it's flagged — closing the loop from a leaked test token to a rotated one without a human having to chase it down manually.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.