Skip to content

Gate the Sonar PR publish on a maintainer approval instead of trusting fork code - #1976

Open
JamBalaya56562 wants to merge 2 commits into
fabric8io:masterfrom
JamBalaya56562:fix-sonar-pr-fork-gate
Open

Gate the Sonar PR publish on a maintainer approval instead of trusting fork code#1976
JamBalaya56562 wants to merge 2 commits into
fabric8io:masterfrom
JamBalaya56562:fix-sonar-pr-fork-gate

Conversation

@JamBalaya56562

@JamBalaya56562 JamBalaya56562 commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

What

Every run of Sonar PR Analysis Publish fails, and has done for a while — the last eight are all failure or cancelled:

##[error]Refusing to check out fork pull request code from a 'workflow_run' workflow.
This workflow runs with the base repository's GITHUB_TOKEN, secrets, default-branch cache
scope, and runner access. Fetching and executing a fork's code in that trusted context
commonly leads to "pwn request" vulnerabilities.
To opt in, review the risks at https://gh.io/securely-using-pull_request_target and set
'allow-unsafe-pr-checkout: true' on the actions/checkout step.

Nothing is broken about builds or tests — the effect is that pull requests from forks get no Sonar analysis.

Why actions/checkout is right

The job holds SONAR_TOKEN and a GITHUB_TOKEN, checks out refs/pull/N/head and runs ./mvnw clean install over it. A pull request from a fork controls that code, so it could read those secrets out of a test or a Maven plugin. Setting allow-unsafe-pr-checkout: true on its own would just reopen the hole that was closed.

How

The fork's code is built only once a human who can merge it has looked at the very commit that is about to run. A gate job:

  1. resolves the pull request from the head repository and branch,
  2. requires its head to still be the commit this run was triggered for, and
  3. requires an APPROVED review from an OWNER, MEMBER or COLLABORATOR carrying that same commit id.

Anything short of that skips the analysis rather than failing it, with a notice saying which condition was not met. Because the approval is tied to the commit, pushing after being approved closes the gate again. The checkout that follows is pinned to that commit and asserted with git rev-parse HEAD afterwards, so allow-unsafe-pr-checkout is only reached on the far side of a human decision.

Pull requests from branches of this repository skip the check entirely — only someone with write access can push there, so there is nothing to gate. Dependabot's pull requests keep being analysed automatically, as before.

The gate calls exactly two endpoints — GET /pulls?head=… to find the pull request and GET /pulls/{n}/reviews to read its reviews — both covered by the pull-requests: read it declares.

One trade-off worth naming: it decides "maintainer" from the review's author_association, so MEMBER means "member of the fabric8io organisation" rather than strictly "has write access to this repository". Checking the latter would mean GET /repos/{owner}/{repo}/collaborators/{username}/permission, which the docs say the caller needs push access for — more than this workflow should be handed for a read-only decision. That endpoint is deliberately not called; the gate makes do with the two reads above.

Two other faults in the same file

  • The concurrency group cancelled unrelated pull requests. It keyed on github.ref, which in a workflow_run event resolves to the default branch — every publish run of this workflow reports head_branch=master, so all pull requests shared one group with cancel-in-progress: true. That matches the cancelled entries in the run history. It now keys on the head repository and branch.
  • The pull request number was resolved without any check. It came from sender.login plus a branch name, taking .[0].number. Run 32851681701 shows the result: a run for branch fix-ansilogger-robustness (PR Report an unusable log output file instead of failing with a NullPointerException #1964) carried PR_NUMBER=1973. The new lookup uses the head repository owner and only accepts a pull request whose head SHA matches the commit being analysed.

The debug step dumping the entire event payload is gone; the gate reports what it decided and why.

Verification

A fork cannot exercise workflow_run against this repository, so the gate script was extracted from the YAML and run locally against recorded GitHub API responses. The fork case uses the real reviews on #1964 (two COMMENTED, one MEMBER and one CONTRIBUTOR); the approval cases are those same objects with state and commit_id altered, so the schema is genuine.

scenario analyse
head is a branch of this repository (Dependabot #1971) true — gate skipped
fork, approved by a MEMBER, commit id matches true
fork, approved by a MEMBER, but for an earlier commit false
fork, only COMMENTED reviews (the real state of #1964) false
fork, APPROVED by a CONTRIBUTOR false
no open pull request whose head is this commit false

The workflow was also parsed to confirm the job graph, needs/if wiring and permissions, and sonar-pr-request.yml is untouched.

What still needs checking after merge, since it cannot be exercised from a fork: that a fork pull request without approval shows the sonar job as skipped, that approving it makes the next run analyse, and that a Dependabot pull request passes straight through.

| select(.state == "APPROVED" and .commit_id == env.HEAD_SHA)
| select(.author_association == "OWNER"
or .author_association == "MEMBER"
or .author_association == "COLLABORATOR")

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.

Could we tighten the trust boundary here? author_association is broader than the “human who can merge” described in the comment above. In particular, COLLABORATOR and MEMBER don't necessarily imply write/maintain access to this repository.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — author_association doesn't answer the question the comment above it claims to answer. MEMBER only says the reviewer is in the org, and COLLABORATOR is just as true of read or triage access; neither can merge this PR.

Pushed a follow-up that drops the association check and resolves every login that approved the exact head commit through repos/{repo}/collaborators/{login}/permission instead, accepting only write (what the API reports for maintain too) or admin. Anything else, including a lookup the API declines to answer, leaves the gate closed.

…g fork code

Every run of this workflow now fails:

    Refusing to check out fork pull request code from a 'workflow_run' workflow.
    This workflow runs with the base repository's GITHUB_TOKEN, secrets,
    default-branch cache scope, and runner access. Fetching and executing a
    fork's code in that trusted context commonly leads to "pwn request"
    vulnerabilities.

`actions/checkout` is right to refuse. This job holds `SONAR_TOKEN` and a
`GITHUB_TOKEN`, checks out `refs/pull/N/head` and runs `./mvnw clean install`
over it - a pull request from a fork could read those secrets out of a test or
a Maven plugin. Turning the warning off with `allow-unsafe-pr-checkout` and
nothing else would restore exactly the hole that was closed.

So the fork's code is only built once a human who can merge it has looked at
the very commit that is about to run. A `gate` job resolves the pull request
from the head repository and branch, requires its head to still be the commit
this run was triggered for, and then requires an `APPROVED` review from an
owner, member or collaborator carrying that same commit id. Anything short of
that skips the analysis rather than failing it. Tying the approval to the
commit means pushing after being approved closes the gate again, and the
checkout that follows is pinned to that commit and asserted afterwards.

Pull requests whose head is a branch of this repository - Dependabot's among
them - skip the check entirely, since only someone with write access can push
there in the first place.

Two other faults in the same file are fixed while here. The concurrency group
keyed on `github.ref`, which in a `workflow_run` event resolves to the default
branch, so every pull request shared one group and cancelled the others; it now
keys on the head repository and branch. And the pull request number came from
`sender.login` plus a branch name with no check on the result, which had
already produced a mismatch in run 32851681701, where a run for branch
`fix-ansilogger-robustness` carried `PR_NUMBER=1973`.

The debug step dumping the whole event payload goes too; the gate reports what
it decided and why.
…sociation

The fork gate accepted an approval whose author_association was OWNER, MEMBER or
COLLABORATOR, and read that as "a maintainer looked at this". It is broader than
that: MEMBER only says the reviewer belongs to the organisation, and COLLABORATOR
is just as true of someone invited with read or triage access. Neither implies
being able to merge the pull request whose code is about to run against the base
repository's secrets.

Resolve every login that approved the exact head commit against
repos/{repo}/collaborators/{login}/permission instead, and take only write - what
the API reports for maintain as well - or admin. An approval that resolves to
anything else, or a lookup the API declines to answer, leaves the gate closed and
is reported as a warning rather than quietly widening it.

permissions:
contents: read
pull-requests: read

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.

Question about the token here, and I might be misreading the docs.

GET /repos/{owner}/{repo}/collaborators/{login}/permission is documented as needing push access on the caller. The job's token has contents: read + pull-requests: read; would that count as push access? My reading is that it wouldn't, and the call would 403, but I haven't tried it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It is not called, and the description was misleading about that — now reworded,
with the two endpoints the gate actually uses stated up front.

The gate reads only GET /pulls?head=… and GET /pulls/{n}/reviews, both
covered by the pull-requests: read it declares.

Your reading of the docs matches mine, and it is exactly why that endpoint is
avoided: it wants push access on the caller, which is more than this job should
hold to make a read-only decision. So the check settles for the review's
author_association instead, with the trade-off that MEMBER means "in the
fabric8io organisation" rather than strictly "has write access here".

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.

2 participants