You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Incomplete remediation of fork-status bypass: PullApprove authority is not bound to the trusted GitHub App installation
Summary
This report describes a potential incomplete remediation of the status-check spoofing vulnerability addressed by #3746.
PR #3746 added origin validation to unified-status-check after identifying that status checks associated with fork repositories could influence the unified status calculation.
The remediation establishes a strong repository-origin check for CheckRun objects.
For legacy StatusContext objects, however, the trust decision is different. The implementation relies on the status creator login being in a hardcoded trusted list, together with a targetUrl check intended to reject statuses pointing to a fork.
For PullApprove, this means a status can be accepted based on the global bot identity pullapprove[bot] without establishing that the status originated from the specific trusted PullApprove GitHub App installation authorized for the upstream repository.
The security distinction is:
Recognizing the integration identity is not equivalent to proving the authority of the installation that produced the status.
I reported this to Google under reference 559906863. Google has authorized publication of the technical details and reproduction evidence in the public GitHub issue and indicated that classification depends on OSS maintainer assessment.
The purpose of this report is not to claim that every status accepted by this path is attacker-controlled.
The narrower question is whether the trust predicate itself proves the authority that #3746 intended to establish.
Trust invariant
For a status that contributes to a security-sensitive aggregate decision, the required invariant should be equivalent to:
A successful status is accepted only when its provenance is bound to an integration installation authorized to produce that status for the protected repository.
The current legacy StatusContext predicate establishes something weaker:
A successful status is accepted when its creator login matches a trusted integration identity and the applicable target URL heuristic does not identify it as originating from the fork.
These are not equivalent predicates.
A GitHub App identity is global.
A GitHub App installation represents scoped authority.
Trusting the former does not, by itself, establish the latter.
Deterministic reproduction
I constructed a local deterministic model of the relevant trust predicate.
No Angular production state was modified.
The model contains two distinct GitHub App installations:
Both installations legitimately share the same global GitHub App bot identity:
pullapprove[bot]
The test status from the second installation uses:
context = pullapprove
state = success
creator = pullapprove[bot]
and a provider-hosted target URL that does not identify the attacker's fork repository.
Existing trust predicate
Under the modeled StatusContext predicate derived from the implementation, the second-installation fixture is accepted.
Result:
other installation status: ACCEPT
Installation-bound predicate
The same fixture is then evaluated with installation identity included as an authority requirement.
Expected trusted installation:
111111
Observed installation:
222222
Result:
other installation status: REJECT
The deterministic harness exited successfully and did not interact with or modify Angular production infrastructure.
The reproduction demonstrates the predicate gap:
same bot identity != same installation authority
Why creator.login is insufficient
The value:
pullapprove[bot]
answers:
Which GitHub App identity created this status?
It does not necessarily answer:
Was this produced under the installation authorized to act for the protected Angular repository?
Those are different authorization questions.
If multiple installations of the same GitHub App can produce legacy commit statuses carrying the same creator identity, then a creator allowlist authenticates the application identity but does not independently establish repository-scoped authority.
Why the targetUrl check does not close the same boundary
The targetUrl mitigation introduced in #3746 addresses the previously demonstrated fork-origin condition by identifying statuses whose target URL points toward the fork repository.
That is useful provenance information.
It is not equivalent to installation authentication.
A provider-hosted status URL can identify the service that produced a result without cryptographically or structurally establishing that the particular service installation was authorized for the upstream repository.
The questions are different:
Does this URL appear to originate from the fork?
versus:
Was the principal producing this status authorized by the upstream repository?
The first does not necessarily prove the second.
Expected behavior
A security-sensitive status consumed by unified-status-check should have provenance sufficient to establish its authority for the protected repository.
Where GitHub exposes repository or installation provenance, the decision should bind to that provenance rather than relying solely on a globally shared bot login.
Conceptually:
trusted app identity
AND
trusted installation/repository authority
AND
expected status context
AND
expected commit
rather than:
trusted creator login
AND
target URL does not look like the fork
Actual behavior demonstrated by the model
For the evaluated legacy StatusContext path, the trust decision does not identify the installation that conferred authority.
Consequently, two modeled statuses can differ in installation authority while presenting the same attributes used by the existing trust predicate.
The existing predicate accepts the second-installation fixture.
An installation-bound predicate rejects it.
Security consequence
The demonstrated issue is an authorization/provenance gap in the legacy status trust predicate.
If a status produced through another installation of a trusted integration is reachable on a commit evaluated by the Angular workflow, the current predicate does not itself distinguish that status from one produced under the intended installation based on installation authority.
Because the status participates in unified-status-check, that creates a potential path for an improperly authorized status to influence Angular's aggregate Merge Ready decision.
This report does not claim that I demonstrated:
an unauthorized merge into Angular
arbitrary code execution
access to Angular secrets
production compromise
release compromise
cross-installation production reachability
Those consequences have not been established by this reproduction.
The demonstrated result is narrower and precedes those consequences: the authorization predicate accepts modeled provenance that differs in the authority dimension the predicate does not inspect.
Why this appears to be incomplete remediation rather than an unrelated issue
#3746 explicitly addressed trust in status-check provenance.
Its remediation correctly introduced repository-origin validation for CheckRun.
For legacy StatusContext, the remediation instead uses an identity allowlist and URL heuristic.
The original vulnerability class was therefore addressed at one provenance layer, but the legacy status path appears to retain a weaker authority model.
The residual question is not whether the creator claims to be PullApprove.
The residual question is:
Which PullApprove authority produced the status, and was that authority authorized for the protected repository?
Requested maintainer assessment
I would appreciate maintainer guidance on the following:
Is a legacy StatusContext created by pullapprove[bot] guaranteed by GitHub or PullApprove to have originated only from the PullApprove installation authorized for the upstream Angular repository?
If so, what platform or application invariant provides that guarantee?
Does the GraphQL representation consumed by unified-status-check expose sufficient repository, application, or installation provenance to bind the status to the intended authority?
If installation provenance is unavailable for legacy commit statuses, should PullApprove's legacy StatusContext be considered sufficiently authoritative for a security-sensitive Merge Ready aggregation?
Would migration to an installation-bound CheckRun or another repository-bound signal be the preferred remediation?
Suggested remediation direction
I am intentionally not prescribing an implementation before maintainers confirm the platform guarantees.
The security property should be:
status identity + repository-scoped authority
not merely:
status identity
Possible remediation classes include:
consume a result type carrying repository-bound provenance
validate GitHub App installation/repository authority where that information is available
stop treating legacy statuses lacking sufficient provenance as authoritative for the aggregate decision
The appropriate implementation depends on GitHub and PullApprove's actual guarantees.
Verification criterion
A regression test should contain two status producers sharing the same trusted application identity but possessing different repository/install authority.
The test should establish:
authorized installation + expected status -> ACCEPT
different installation + same bot identity + same context -> REJECT
Changing only the provider-hosted URL should not substitute for proving repository-scoped authority.
Disclosure
This issue corresponds to Google report:
559906863: Incomplete remediation of Angular fork-status bypass: PullApprove authority is not bound to the trusted GitHub App installation
Google has explicitly permitted the technical details and reproduction evidence to be shared in the public GitHub issue and has indicated that status and classification will depend on assessment and feedback from the OSS maintainers.
I am filing this publicly pursuant to that direction and would appreciate maintainer technical assessment of the authority invariant described above.
Incomplete remediation of fork-status bypass: PullApprove authority is not bound to the trusted GitHub App installation
Summary
This report describes a potential incomplete remediation of the status-check spoofing vulnerability addressed by #3746.
PR #3746 added origin validation to
unified-status-checkafter identifying that status checks associated with fork repositories could influence the unified status calculation.The remediation establishes a strong repository-origin check for
CheckRunobjects.For legacy
StatusContextobjects, however, the trust decision is different. The implementation relies on the status creator login being in a hardcoded trusted list, together with atargetUrlcheck intended to reject statuses pointing to a fork.For PullApprove, this means a status can be accepted based on the global bot identity
pullapprove[bot]without establishing that the status originated from the specific trusted PullApprove GitHub App installation authorized for the upstream repository.The security distinction is:
Recognizing the integration identity is not equivalent to proving the authority of the installation that produced the status.
I reported this to Google under reference 559906863. Google has authorized publication of the technical details and reproduction evidence in the public GitHub issue and indicated that classification depends on OSS maintainer assessment.
Relationship to #3746
PR #3746 is titled:
Its description states that the previous
unified-status-checkbehavior constituted a security vulnerability.The remediation added:
headRepositorycheckSuite.repositoryforCheckRuncreator.loginandtargetUrlforStatusContextThe resulting security model is materially different between the two GitHub result types.
CheckRun
The remediation validates repository provenance through:
checkSuite.repositoryThis binds the check to repository origin.
StatusContext
The legacy status path instead determines trust using the creator identity and, for fork PRs, characteristics of
targetUrl.Trusted status creators include identities such as:
github-actionsangular-robotngbotpullapproveand corresponding
[bot]variants.For PullApprove, this establishes that the status appears to have been created by the PullApprove integration identity.
It does not establish which PullApprove installation had authority to produce the status.
That distinction is the subject of this report.
Code reviewed
The analysis was performed against:
angular/dev-infra@183403ae13b785698eaf13c819dda55b9fed430bRelevant implementation:
github-actions/unified-status-check/lib/pull-request.tsThe downstream consumer examined was Angular's Merge Ready workflow in:
angular/angular/.github/workflows/merge-ready-status.ymlThe purpose of this report is not to claim that every status accepted by this path is attacker-controlled.
The narrower question is whether the trust predicate itself proves the authority that #3746 intended to establish.
Trust invariant
For a status that contributes to a security-sensitive aggregate decision, the required invariant should be equivalent to:
The current legacy
StatusContextpredicate establishes something weaker:These are not equivalent predicates.
A GitHub App identity is global.
A GitHub App installation represents scoped authority.
Trusting the former does not, by itself, establish the latter.
Deterministic reproduction
I constructed a local deterministic model of the relevant trust predicate.
No Angular production state was modified.
The model contains two distinct GitHub App installations:
Both installations legitimately share the same global GitHub App bot identity:
The test status from the second installation uses:
and a provider-hosted target URL that does not identify the attacker's fork repository.
Existing trust predicate
Under the modeled
StatusContextpredicate derived from the implementation, the second-installation fixture is accepted.Result:
Installation-bound predicate
The same fixture is then evaluated with installation identity included as an authority requirement.
Expected trusted installation:
Observed installation:
Result:
The deterministic harness exited successfully and did not interact with or modify Angular production infrastructure.
The reproduction demonstrates the predicate gap:
Why
creator.loginis insufficientThe value:
answers:
It does not necessarily answer:
Those are different authorization questions.
If multiple installations of the same GitHub App can produce legacy commit statuses carrying the same creator identity, then a creator allowlist authenticates the application identity but does not independently establish repository-scoped authority.
Why the
targetUrlcheck does not close the same boundaryThe
targetUrlmitigation introduced in #3746 addresses the previously demonstrated fork-origin condition by identifying statuses whose target URL points toward the fork repository.That is useful provenance information.
It is not equivalent to installation authentication.
A provider-hosted status URL can identify the service that produced a result without cryptographically or structurally establishing that the particular service installation was authorized for the upstream repository.
The questions are different:
versus:
The first does not necessarily prove the second.
Expected behavior
A security-sensitive status consumed by
unified-status-checkshould have provenance sufficient to establish its authority for the protected repository.Where GitHub exposes repository or installation provenance, the decision should bind to that provenance rather than relying solely on a globally shared bot login.
Conceptually:
rather than:
Actual behavior demonstrated by the model
For the evaluated legacy
StatusContextpath, the trust decision does not identify the installation that conferred authority.Consequently, two modeled statuses can differ in installation authority while presenting the same attributes used by the existing trust predicate.
The existing predicate accepts the second-installation fixture.
An installation-bound predicate rejects it.
Security consequence
The demonstrated issue is an authorization/provenance gap in the legacy status trust predicate.
If a status produced through another installation of a trusted integration is reachable on a commit evaluated by the Angular workflow, the current predicate does not itself distinguish that status from one produced under the intended installation based on installation authority.
Because the status participates in
unified-status-check, that creates a potential path for an improperly authorized status to influence Angular's aggregate Merge Ready decision.This report does not claim that I demonstrated:
Those consequences have not been established by this reproduction.
The demonstrated result is narrower and precedes those consequences: the authorization predicate accepts modeled provenance that differs in the authority dimension the predicate does not inspect.
Why this appears to be incomplete remediation rather than an unrelated issue
#3746 explicitly addressed trust in status-check provenance.
Its remediation correctly introduced repository-origin validation for
CheckRun.For legacy
StatusContext, the remediation instead uses an identity allowlist and URL heuristic.The original vulnerability class was therefore addressed at one provenance layer, but the legacy status path appears to retain a weaker authority model.
The residual question is not whether the creator claims to be PullApprove.
The residual question is:
Which PullApprove authority produced the status, and was that authority authorized for the protected repository?
Requested maintainer assessment
I would appreciate maintainer guidance on the following:
Is a legacy
StatusContextcreated bypullapprove[bot]guaranteed by GitHub or PullApprove to have originated only from the PullApprove installation authorized for the upstream Angular repository?If so, what platform or application invariant provides that guarantee?
Does the GraphQL representation consumed by
unified-status-checkexpose sufficient repository, application, or installation provenance to bind the status to the intended authority?If installation provenance is unavailable for legacy commit statuses, should PullApprove's legacy
StatusContextbe considered sufficiently authoritative for a security-sensitive Merge Ready aggregation?Would migration to an installation-bound
CheckRunor another repository-bound signal be the preferred remediation?Suggested remediation direction
I am intentionally not prescribing an implementation before maintainers confirm the platform guarantees.
The security property should be:
not merely:
Possible remediation classes include:
CheckRunvalidation introduced in fix(github-actions): prevent status check bypass via fork spoofing #3746The appropriate implementation depends on GitHub and PullApprove's actual guarantees.
Verification criterion
A regression test should contain two status producers sharing the same trusted application identity but possessing different repository/install authority.
The test should establish:
Changing only the provider-hosted URL should not substitute for proving repository-scoped authority.
Disclosure
This issue corresponds to Google report:
559906863: Incomplete remediation of Angular fork-status bypass: PullApprove authority is not bound to the trusted GitHub App installation
Google has explicitly permitted the technical details and reproduction evidence to be shared in the public GitHub issue and has indicated that status and classification will depend on assessment and feedback from the OSS maintainers.
I am filing this publicly pursuant to that direction and would appreciate maintainer technical assessment of the authority invariant described above.