Visitar URL original
Prevent pwn-request RCE via the Qodana workflow by dennisdoomen · Pull Request #3217 · fluentassertions/fluentassertions · GitHub
Skip to content

Prevent pwn-request RCE via the Qodana workflow - #3217

Merged
dennisdoomen merged 2 commits into
mainfrom
dennisdoomen/fix-pwn-request-workflow
Jul 13, 2026
Merged

dennisdoomen merged 2 commits into
mainfrom
dennisdoomen/fix-pwn-request-workflow

Conversation

@dennisdoomen

@dennisdoomen dennisdoomen commented May 19, 2026 •

Copy link
Copy Markdown
Member

Security vulnerability fixed

The problem

.github/workflows/code_quality.yml used pull_request_target (which runs on the base repo with access to repository secrets) together with actions/checkout using ref: github.event.pull_request.head.sha, which checks out untrusted contributor code in a privileged context. Because QODANA_TOKEN was injected via env:, any PR from an external contributor could exfiltrate or abuse it, and any other secret available to the job — a classic pwn request RCE pattern.

The fix: split trusted vs. untrusted triggers, and scope the PR token

The single vulnerable workflow is replaced with two workflows plus a dedicated GitHub Environment:

File Trigger Secrets available Runs contributor code?
code_quality.yml push to main/develop, workflow_dispatch Repo-wide QODANA_TOKEN No (trusted)
code_quality_pr.yml pull_request Only QODANA_TOKEN, scoped to the qodana-pr GitHub Environment Yes (untrusted, but blast radius contained)

Workflow 1 (code_quality.yml) — unchanged behaviour for branch pushes and manual triggers. Checks out trusted code and uploads results to Qodana Cloud with the repo-wide QODANA_TOKEN.

Workflow 2 (code_quality_pr.yml) — triggered by pull_request (not pull_request_target), so it runs with the contributor's untrusted code. Since the Qodana cloud linter requires a token to run at all, this workflow still needs QODANA_TOKEN — but that secret now lives in a dedicated qodana-pr GitHub Environment rather than as a repo/org-wide secret. This means:

  • Untrusted PR code can only ever reach QODANA_TOKEN, never any other repo or org secret, even if this workflow is edited later to add more steps.
  • The qodana-pr environment can have required reviewers configured, so a maintainer must manually approve the run for first-time or fork contributors before their code executes with the token.
  • GITHUB_TOKEN permissions for this job are explicitly locked down to contents: read and pull-requests: write (needed only for Qodana to post its PR comment), so the auto-token can't be used to push commits, modify workflows, or touch repo settings.

Why two separate workflow files instead of one combined file

It's tempting to merge these into a single workflow with on: [push, pull_request, workflow_dispatch] and a conditional environment:/if: per job. We deliberately keep them split, for future maintainers:

  • Auditability of the trust boundary. With two files, the security-relevant question — "does this code run with elevated trust or not?" — is answered just by reading the on: trigger of the file. A combined file requires carefully reading conditional expressions every time to reach the same conclusion, which is much easier to get wrong during review.
  • Blast radius of future edits. In a merged workflow, a later change (e.g. adding a step, tweaking an if:) can accidentally let a step that needs broader secrets run in the PR-triggered branch. Separate files make that class of mistake structurally harder to introduce.
  • This matches GitHub's own guidance. The GitHub Security Lab advisory this fix is based on explicitly recommends separating trusted-trigger workflows from untrusted-trigger workflows rather than branching inside one file.
  • The permission/environment shape genuinely differs. code_quality.yml needs no environment:/scoped token and broad contents: read; code_quality_pr.yml needs the qodana-pr environment plus pull-requests: write. Combining them doesn't save much complexity, it just costs readability of the trust boundary.

Please keep this split when touching Qodana CI in the future — resist the urge to consolidate them back into one file for "DRY" reasons; the duplication here is intentional and load-bearing for security.

References

@github-actions

This comment has been minimized.

@jnyrup jnyrup mentioned this pull request Jul 11, 2026
6 of 9 tasks
@dennisdoomen
dennisdoomen force-pushed the dennisdoomen/fix-pwn-request-workflow branch from a40dade to 1c603b0 Compare July 12, 2026 06:04
@dennisdoomen dennisdoomen self-assigned this Jul 12, 2026
@dennisdoomen dennisdoomen changed the title fix: prevent pwn-request RCE via Qodana workflow Prevent pwn-requests via Qodana workflow Jul 12, 2026
@github-actions

github-actions Bot commented Jul 12, 2026 •

Copy link
Copy Markdown

Test Results

    37 files  ± 0      37 suites  ±0   2m 57s ⏱️ +20s
 6 388 tests + 2   6 387 ✅ + 2  1 💤 ±0  0 ❌ ±0 
39 674 runs  +12  39 668 ✅ +12  6 💤 ±0  0 ❌ ±0 

Results for commit 77c063a. ± Comparison against base commit 9fdefb8.

This pull request removes 10 and adds 10 tests. Note that renamed tests count towards both.
FluentAssertions.Specs.Streams.StreamAssertionSpecs+HaveLength ‑ When_a_throwing_stream_should_have_a_length_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
FluentAssertions.Specs.Streams.StreamAssertionSpecs+HaveLength ‑ When_a_throwing_stream_should_have_a_length_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
FluentAssertions.Specs.Streams.StreamAssertionSpecs+HavePosition ‑ When_a_throwing_stream_should_have_a_position_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
FluentAssertions.Specs.Streams.StreamAssertionSpecs+HavePosition ‑ When_a_throwing_stream_should_have_a_position_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
FluentAssertions.Specs.Streams.StreamAssertionSpecs+NotHaveLength ‑ When_a_throwing_stream_should_not_have_a_length_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
FluentAssertions.Specs.Streams.StreamAssertionSpecs+NotHaveLength ‑ When_a_throwing_stream_should_not_have_a_length_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
FluentAssertions.Specs.Streams.StreamAssertionSpecs+NotHavePosition ‑ When_a_throwing_stream_should_not_have_a_position_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
FluentAssertions.Specs.Streams.StreamAssertionSpecs+NotHavePosition ‑ When_a_throwing_stream_should_not_have_a_position_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
Object name: 'GetLengthExceptionMessage'.)
Object name: 'GetPositionExceptionMessage'.)
FluentAssertions.Specs.Streams.StreamAssertionSpecs+HaveLength ‑ When_a_throwing_stream_should_have_a_length_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
Object name: 'GetLengthExceptionMessage'.)
FluentAssertions.Specs.Streams.StreamAssertionSpecs+HaveLength ‑ When_a_throwing_stream_should_have_a_length_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
Object name: 'GetLengthExceptionMessage'.)
FluentAssertions.Specs.Streams.StreamAssertionSpecs+HavePosition ‑ When_a_throwing_stream_should_have_a_position_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
Object name: 'GetPositionExceptionMessage'.)
FluentAssertions.Specs.Streams.StreamAssertionSpecs+HavePosition ‑ When_a_throwing_stream_should_have_a_position_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
Object name: 'GetPositionExceptionMessage'.)
FluentAssertions.Specs.Streams.StreamAssertionSpecs+NotHaveLength ‑ When_a_throwing_stream_should_not_have_a_length_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
Object name: 'GetLengthExceptionMessage'.)
FluentAssertions.Specs.Streams.StreamAssertionSpecs+NotHaveLength ‑ When_a_throwing_stream_should_not_have_a_length_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
Object name: 'GetLengthExceptionMessage'.)
FluentAssertions.Specs.Streams.StreamAssertionSpecs+NotHavePosition ‑ When_a_throwing_stream_should_not_have_a_position_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
Object name: 'GetPositionExceptionMessage'.)
FluentAssertions.Specs.Streams.StreamAssertionSpecs+NotHavePosition ‑ When_a_throwing_stream_should_not_have_a_position_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
Object name: 'GetPositionExceptionMessage'.)
FluentAssertions.Specs.Types.MethodInfoSelectorSpecs ‑ When_selecting_types_with_predicate_it_should_return_the_correct_type
FluentAssertions.Specs.Types.PropertyInfoSelectorSpecs ‑ When_selecting_types_with_predicate_it_should_return_the_correct_type

♻️ This comment has been updated with latest results.

@coveralls

coveralls commented Jul 12, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 29192752655

Coverage remained the same at 97.138%

Details

  • Coverage remained the same as the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 13399
Covered Lines: 13174
Line Coverage: 98.32%
Relevant Branches: 4384
Covered Branches: 4100
Branch Coverage: 93.52%
Branches in Coverage %: Yes
Coverage Strength: 67353.9 hits per line

💛 - Coveralls

@github-actions

github-actions Bot commented Jul 12, 2026 •

Copy link
Copy Markdown

Qodana for .NET

It seems all right 👌

No new problems were found according to the checks applied

💡 Qodana analysis was run in the pull request mode: only the changed files were checked
☁️ View the detailed Qodana report

Contact Qodana team

Contact us at qodana-support@jetbrains.com

@dennisdoomen
dennisdoomen requested a review from jnyrup July 12, 2026 06:28

@jnyrup jnyrup left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new "Qodana (PR)" pipeline fails to run.
I guess it still requires the Qodana token to run?

Comment thread .github/workflows/code_quality.yml Resolved
Comment thread .github/workflows/code_quality_pr.yml Resolved
Comment thread .github/workflows/code_quality_pr.yml Resolved
The previous code_quality.yml combined pull_request_target (which runs with base-repo secrets) with actions/checkout using ref: github.event.pull_request.head.sha (which checks out untrusted contributor code). This is the classic 'pwn request' pattern: any external contributor could submit a PR that executes arbitrary code with access to QODANA_TOKEN.

Fix: split into two workflows plus a scoped GitHub Environment.

1. code_quality.yml (push / workflow_dispatch only) - handles pushes to main and develop, plus manual triggers, using the repo-wide QODANA_TOKEN as before.

2. code_quality_pr.yml (pull_request) - runs Qodana against untrusted PR code, but with QODANA_TOKEN scoped to a dedicated 'qodana-pr' GitHub Environment instead of a repo/org-wide secret, and GITHUB_TOKEN permissions locked down to contents: read and pull-requests: write. This keeps blast radius limited to that one token and lets the environment require reviewer approval for first-time/fork contributors.

References:

- https://securitylab.github.com/research/github-actions-preventing-pwn-requests/

- https://www.jetbrains.com/help/qodana/github.html

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@dennisdoomen
dennisdoomen force-pushed the dennisdoomen/fix-pwn-request-workflow branch from 5264a00 to 8f373e0 Compare July 12, 2026 12:30
@dennisdoomen dennisdoomen changed the title Prevent pwn-requests via Qodana workflow Prevent pwn-request RCE via the Qodana workflow Jul 12, 2026
@dennisdoomen
dennisdoomen requested a review from jnyrup July 12, 2026 12:38
@dennisdoomen

Copy link
Copy Markdown
Member Author

The new "Qodana (PR)" pipeline fails to run.
I guess it still requires the Qodana token to run?

I had to revisit the solution. The PR description now properly explains the approach.

Comment thread .github/workflows/code_quality_pr.yml Resolved
@dennisdoomen
dennisdoomen merged commit 4ca19c6 into main Jul 13, 2026
15 checks passed
@dennisdoomen
dennisdoomen deleted the dennisdoomen/fix-pwn-request-workflow branch July 13, 2026 06:51
This was referenced Nov 11, 2026

This branch was previously deployed

1 inactive deployment
qodana-pr — 77c063a0 Deployed Jul 13, 2026 by Copilot via Qodana Scan #6
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants