Skip to content

Commit 303b402

Browse files
authored
Add explicit strict-mode policy for pull_request_target checkouts (#66649)
1 parent b57433e commit 303b402

25 files changed

Lines changed: 561 additions & 59 deletions
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# ADR-66649: Explicit Maintainer-Declared Trusted Checkout Policy for `pull_request_target`
2+
3+
**Date**: 2026-10-07
4+
**Status**: Draft
5+
**Deciders**: pelikhan [TODO: verify full decider list]
6+
7+
---
8+
9+
### Context
10+
11+
In strict mode, `pull_request_target` workflows are validated by `pkg/workflow/pull_request_target_validation.go` with two unconditional behaviors: a hard error for any checkout that is not omitted, `false`, or pinned to the base repository/base ref, and a "very dangerous trigger" warning emitted even when `checkout: false` is set. This blocks a legitimate class of workflows (#66564): a workflow that reacts to *merged* PRs (`types: [closed]` + `if: github.event.pull_request.merged == true`), checks out a fixed external documentation repository at a fixed ref, and reads the source PR's changes as data rather than executing them. The only escape hatches were `strict: false` or reorganizing to `pull_request`, both of which are unacceptable: the first disables unrelated security diagnostics for the entire workflow, and the second does not receive repository secrets for fork PRs. The constraint is a deliberate policy, not a bug, so relaxing it needs a scoped, auditable opt-in rather than a loosened default.
12+
13+
### Decision
14+
15+
We will add two opt-in keys under `on.pull_request_target` in the main workflow's frontmatter: `allowed-checkouts`, a list of exact, case-sensitive literal `repository`/`ref` pairs that are additionally trusted for checkout validation, and `acknowledge-risk`, a boolean that suppresses *only* the strict-mode dangerous-trigger warning. Checkout validation still runs when risk is acknowledged, and every configured checkout must independently satisfy either the existing base-repository rules or an allowlist entry. The validator fails closed: GitHub expressions, wildcards, omitted refs, `refs/pull/...` refs, and wiki checkouts are rejected even if their literal text appears in the policy (`pullRequestTargetLiteralRepositoryPattern` / `pullRequestTargetLiteralRefPattern`). The primary driver is to keep strict mode as the default security posture while allowing maintainers to record an explicit, reviewable trust statement at the exact repository/ref granularity. The change also preserves configured checkout refs as strings in generated YAML so literals such as `0x10` are not reinterpreted as numbers.
16+
17+
### Alternatives Considered
18+
19+
#### Alternative 1: Require `strict: false` for this scenario
20+
21+
The existing mechanism already permits the configuration by lowering the effective strict mode. It was rejected because `strict: false` is workflow-wide: it disables every other strict diagnostic, destroys the zero-warning validation gate the requesting team relies on, and leaves no machine-readable record of *which* external checkout was trusted. Reviewers would see a blanket opt-out instead of a specific, greppable allowlist entry.
22+
23+
#### Alternative 2: Automatically allow any literal (non-expression) external repository/ref checkout
24+
25+
Trust could have been inferred: if neither repository nor ref derives from the PR payload, the checkout cannot pull untrusted head code, so the validator could accept it without new frontmatter. This was a close call and is the smallest change, but it silently weakens the default for every existing workflow and removes the explicit maintainer acknowledgment. A moved branch or tag in a trusted-by-inference repository would then execute with elevated permissions and secret access with no prior review signal, so an explicit declaration was preferred.
26+
27+
#### Alternative 3: A single global escape key (e.g. `pull-request-target: unsafe: true`)
28+
29+
One boolean covering both the warning and the checkout error would have been simpler to implement and document. It was rejected because it conflates two independent risks — acknowledging elevated permissions versus trusting a specific external code source — and gives no per-repository granularity, which is precisely the audit information a security reviewer needs.
30+
31+
### Consequences
32+
33+
#### Positive
34+
- Workflows that legitimately need a fixed external checkout under `pull_request_target` can compile with strict mode fully enabled, preserving all other diagnostics and zero-warning gates.
35+
- The trust decision is explicit, declarative, and diffable: reviewers can see the exact `repository`/`ref` pairs a maintainer vouched for, and the JSON schema constrains them to literals.
36+
- Validation fails closed independently of schema checks, so expressions, wildcards, PR refs, and wiki checkouts cannot slip through the allowlist path.
37+
- Exact ref preservation prevents a class of silent misconfiguration where YAML scalar coercion changes the checked-out ref.
38+
39+
#### Negative
40+
- The security surface of `pull_request_target` now has a documented bypass; an over-broad or careless allowlist entry (especially a mutable branch or tag) grants privileged execution to code the repository does not control.
41+
- `acknowledge-risk: true` removes the most visible warning about the most dangerous trigger, which may reduce the chance that a reviewer notices a later, riskier change to the same workflow.
42+
- Two more frontmatter keys, schema entries, and validation branches increase the compiler surface that must stay in sync with the reference documentation and regression tests.
43+
44+
#### Neutral
45+
- Defaults are unchanged: with the policy omitted, existing workflows compile and warn exactly as before, so no migration is required.
46+
- The policy is scoped to the main workflow's frontmatter and is not inherited from imports, matching how other trust-bearing settings are handled.
47+
- The policy covers compile-time checkout authorization only; checkout authentication (`github-app` / `github-token`) and safe-output publication remain configured and validated separately.
48+
- Enforcement lives in `pull_request_target_validation.go` with behavior pinned by `pull_request_target_validation_test.go`; future changes to trusted-checkout semantics should be made there and reflected in `docs/src/content/docs/reference/checkout.md`.
49+
50+
---
51+
52+
*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*

‎docs/src/content/docs/reference/checkout.md‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,36 @@ checkout:
170170
ref: ${{ github.event.pull_request.base.sha }}
171171
```
172172

173-
This produces the "Checkout PR branch" step pointing at the safe base ref. Do **not** use `github.event.pull_request.head.sha` or `refs/pull/.../head` in a privileged `pull_request_target` job — that would check out untrusted code in a context with write permissions.
173+
This produces a configured checkout pointing at the safe base ref; the "Checkout PR branch" step remains disabled for `pull_request_target`. Do **not** use `github.event.pull_request.head.sha` or `refs/pull/.../head` in a privileged `pull_request_target` job — that would check out untrusted code in a context with write permissions.
174+
175+
### Explicit Trusted Checkout Policy
176+
177+
Strict mode rejects `pull_request_target` checkouts unless checkout is explicitly disabled, targets the supported base repository/ref, or matches a literal pair in `on.pull_request_target.allowed-checkouts`. Declare this policy in the main workflow's frontmatter; it is not inherited from imports. The default restrictions remain unchanged when the policy is omitted.
178+
179+
```aw wrap
180+
on:
181+
pull_request_target:
182+
types: [closed]
183+
branches: [main]
184+
acknowledge-risk: true
185+
allowed-checkouts:
186+
- repository: dotnet/AspNetCore.Docs
187+
ref: main
188+
if: github.event.pull_request.merged == true
189+
190+
checkout:
191+
- repository: dotnet/AspNetCore.Docs
192+
ref: main
193+
path: .
194+
current: true
195+
```
196+
197+
`on.pull_request_target.allowed-checkouts` defaults to no additional trusted checkouts. Each entry requires a literal `owner/repository` and a non-empty literal branch, tag, or commit SHA. Matching is exact and case-sensitive; repository/ref pairs cannot be combined across entries. Expressions, wildcards, and `refs/pull/...` refs are rejected in the policy. Every configured checkout must independently satisfy the policy or the existing base-checkout rules. Do not configure `checkout.fetch` for these checkouts: follow-up fetches can add refs beyond the trusted checkout, so they do not satisfy the policy. External repositories with omitted or PR-dependent refs remain rejected. Wiki checkouts are not covered by this allowlist.
198+
199+
`on.pull_request_target.acknowledge-risk` defaults to `false`. Setting it to `true` suppresses **only** the strict-mode dangerous-trigger warning, including for `checkout: false` or a supported base checkout. It neither allows other checkouts nor disables unrelated warnings, strict validation, or safe-update checks. An allowlist without acknowledgment still emits the trigger warning.
200+
201+
> [!CAUTION]
202+
> This policy records maintainer trust, not proof that checked-out code is safe. Prefer immutable commit SHAs; literal branches and tags can move. Keep workflow code and helpers on trusted base refs, read source PR changes as data, and never execute PR-controlled code with privileged credentials. Checkout authentication (`github-app` or `github-token`) and safe-output publication must still be configured separately. This example illustrates compilation policy, not runtime authentication or publication validation.
174203

175204
## Marking a Primary Repository (`current: true`)
176205

‎docs/src/content/docs/reference/frontmatter.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -762,6 +762,8 @@ Enable experimental or optional compiler and runtime behaviors as key-value pair
762762

763763
Enables enhanced security validation for production workflows. Default: `true`.
764764

765+
For `pull_request_target` workflows, the `on.pull_request_target` options documented in [Explicit Trusted Checkout Policy](/gh-aw/reference/checkout/#explicit-trusted-checkout-policy) can declare fixed trusted checkout pairs and acknowledge the trigger-specific warning without disabling strict mode.
766+
765767
```yaml wrap
766768
strict: false # Disable enhanced security validation for development/testing
767769
```

‎pkg/parser/schemas/main_workflow_schema.json‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1730,6 +1730,34 @@
17301730
}
17311731
],
17321732
"description": "When true, allows workflow to run on pull requests from forked repositories with write permissions. Security consideration: use cautiously as fork PRs run with base repository permissions."
1733+
},
1734+
"acknowledge-risk": {
1735+
"type": "boolean",
1736+
"default": false,
1737+
"description": "Acknowledge elevated permissions and secret access to suppress only the strict-mode pull_request_target trigger warning. Checkout validation remains enforced."
1738+
},
1739+
"allowed-checkouts": {
1740+
"type": "array",
1741+
"description": "Exact literal repository/ref pairs trusted by the maintainer in addition to the supported base-repository checkouts. Expressions, wildcards, omitted refs, and PR refs are not allowed.",
1742+
"items": {
1743+
"type": "object",
1744+
"additionalProperties": false,
1745+
"required": ["repository", "ref"],
1746+
"properties": {
1747+
"repository": {
1748+
"type": "string",
1749+
"pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$",
1750+
"description": "Literal owner/repository slug. Matches exactly, including case."
1751+
},
1752+
"ref": {
1753+
"type": "string",
1754+
"minLength": 1,
1755+
"pattern": "^[^\\s${}*?\\[\\\\:~^]+$",
1756+
"not": { "pattern": "^refs/pull/" },
1757+
"description": "Literal trusted branch, tag, or commit SHA; not a pull request ref. Prefer an immutable commit SHA."
1758+
}
1759+
}
1760+
}
17331761
}
17341762
},
17351763
"additionalProperties": false,

‎pkg/workflow/checkout_input_render.go‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package workflow
22

33
import (
44
"fmt"
5+
"strconv"
56
"strings"
67
)
78

@@ -29,7 +30,7 @@ func (cm *CheckoutManager) writeDefaultCheckoutInputs(sb *strings.Builder, overr
2930
fmt.Fprintf(sb, " repository: %s\n", override.key.repository)
3031
}
3132
if override.ref != "" && cm.defaultRefOverride == "" {
32-
fmt.Fprintf(sb, " ref: %s\n", override.ref)
33+
writeCheckoutRef(sb, override.ref)
3334
}
3435
if len(override.sparsePatterns) > 0 {
3536
// Fetch blobs up front rather than requiring authenticated lazy fetches.
@@ -44,6 +45,10 @@ func (cm *CheckoutManager) writeDefaultCheckoutInputs(sb *strings.Builder, overr
4445
return token != ""
4546
}
4647

48+
func writeCheckoutRef(sb *strings.Builder, ref string) {
49+
fmt.Fprintf(sb, " ref: %s\n", strconv.Quote(ref)) //nolint:fprintferrorunchecked // strings.Builder writes cannot fail.
50+
}
51+
4752
func writeCheckoutDepthAndSparse(sb *strings.Builder, entry *resolvedCheckout) {
4853
if entry.fetchDepth != nil {
4954
fmt.Fprintf(sb, " fetch-depth: %d\n", *entry.fetchDepth)

‎pkg/workflow/checkout_manager_test.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ func TestGenerateDefaultCheckoutStep(t *testing.T) {
156156
})
157157
lines := cm.GenerateDefaultCheckoutStep(false, "", getPin)
158158
combined := strings.Join(lines, "")
159-
assert.Contains(t, combined, "ref: develop", "should include ref override")
159+
assert.Contains(t, combined, "ref: \"develop\"", "should include ref override")
160160
})
161161

162162
t.Run("trial mode overrides user config", func(t *testing.T) {
@@ -334,7 +334,7 @@ func TestGenerateAdditionalCheckoutSteps(t *testing.T) {
334334
combined := strings.Join(lines, "")
335335
assert.Contains(t, combined, "repository: owner/libs", "should include repo")
336336
assert.Contains(t, combined, "path: ./libs/owner-libs", "should include path")
337-
assert.Contains(t, combined, "ref: main", "should include ref")
337+
assert.Contains(t, combined, "ref: \"main\"", "should include ref")
338338
assert.Contains(t, combined, "persist-credentials: false", "must always have persist-credentials: false")
339339
})
340340

‎pkg/workflow/checkout_step_generator.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,7 @@ func generateCheckoutStepLines(entry *resolvedCheckout, index int, keepCredentia
364364
fmt.Fprintf(&sb, " repository: %s\n", entry.key.repository)
365365
}
366366
if entry.ref != "" {
367-
fmt.Fprintf(&sb, " ref: %s\n", entry.ref)
367+
writeCheckoutRef(&sb, entry.ref)
368368
}
369369
if entry.key.path != "" {
370370
fmt.Fprintf(&sb, " path: %s\n", entry.key.path)

‎pkg/workflow/compiler_safe_outputs_steps_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ func TestBuildSharedPRCheckoutSteps(t *testing.T) {
177177
{Ref: "develop"},
178178
},
179179
checkContains: []string{
180-
"ref: develop",
180+
"ref: \"develop\"",
181181
},
182182
},
183183
{

0 commit comments

Comments
 (0)