Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion docs/src/content/docs/reference/checkout.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,36 @@ checkout:
ref: ${{ github.event.pull_request.base.sha }}
```

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.
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.

### Explicit Trusted Checkout Policy

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.

```aw wrap
on:
pull_request_target:
types: [closed]
branches: [main]
acknowledge-risk: true
allowed-checkouts:
- repository: dotnet/AspNetCore.Docs
ref: main
if: github.event.pull_request.merged == true

checkout:
- repository: dotnet/AspNetCore.Docs
ref: main
path: .
current: true
```

`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. External repositories with omitted or PR-dependent refs remain rejected. Wiki checkouts are not covered by this allowlist.

`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.

> [!CAUTION]
> 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.

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

Expand Down
2 changes: 2 additions & 0 deletions docs/src/content/docs/reference/frontmatter.md
Original file line number Diff line number Diff line change
Expand Up @@ -762,6 +762,8 @@ Enable experimental or optional compiler and runtime behaviors as key-value pair

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

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.

```yaml wrap
strict: false # Disable enhanced security validation for development/testing
```
Expand Down
28 changes: 28 additions & 0 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1730,6 +1730,34 @@
}
],
"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."
},
"acknowledge-risk": {
"type": "boolean",
"default": false,
"description": "Acknowledge elevated permissions and secret access to suppress only the strict-mode pull_request_target trigger warning. Checkout validation remains enforced."
},
"allowed-checkouts": {
Comment on lines +1734 to +1739

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.

The repository docs and copyable workflow example use on.pull_request_target. The PR body still has the earlier top-level example, and the available tools in this session do not expose PR-description editing; a maintainer will need to update that body.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/grill-with-docs] The PR description's copyable example configures the policy under a top-level pull-request-target: key, but the schema (and the new test "top-level policy is rejected") place these options under on.pull_request_target. A maintainer copy-pasting the PR description's YAML will get a hard schema-rejection instead of the intended behavior.

💡 Suggested fix

Align the PR description/example with the actual schema location (on.pull_request_target.acknowledge-risk / on.pull_request_target.allowed-checkouts), matching the docs in checkout.md which already use the correct nesting. This is a documentation-vocabulary mismatch the /grill-with-docs lens is meant to catch — the same domain concept (pull_request_target policy) is named inconsistently between the PR body and the schema/tests.

@copilot please address this.

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.

The repository docs and copyable workflow example use on.pull_request_target. The PR body still has the earlier top-level example, and the available tools in this session do not expose PR-description editing; a maintainer will need to update that body.

"type": "array",
"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.",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["repository", "ref"],
"properties": {
"repository": {
"type": "string",
"pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$",
"description": "Literal owner/repository slug. Matches exactly, including case."
},
"ref": {
"type": "string",
"minLength": 1,
"pattern": "^[^\\s${}*?\\[\\\\:~^]+$",
"not": { "pattern": "^refs/pull/" },
"description": "Literal trusted branch, tag, or commit SHA; not a pull request ref. Prefer an immutable commit SHA."
}
}
}
}
},
"additionalProperties": false,
Expand Down
15 changes: 14 additions & 1 deletion pkg/workflow/checkout_input_render.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ package workflow

import (
"fmt"
"strconv"
"strings"

"github.com/goccy/go-yaml"
)

func (cm *CheckoutManager) defaultCheckoutIndex(entry *resolvedCheckout) int {
Expand All @@ -29,7 +32,7 @@ func (cm *CheckoutManager) writeDefaultCheckoutInputs(sb *strings.Builder, overr
fmt.Fprintf(sb, " repository: %s\n", override.key.repository)
}
if override.ref != "" && cm.defaultRefOverride == "" {
fmt.Fprintf(sb, " ref: %s\n", override.ref)
writeCheckoutRef(sb, override.ref)
}
if len(override.sparsePatterns) > 0 {
// Fetch blobs up front rather than requiring authenticated lazy fetches.
Expand All @@ -44,6 +47,16 @@ func (cm *CheckoutManager) writeDefaultCheckoutInputs(sb *strings.Builder, overr
return token != ""
}

func writeCheckoutRef(sb *strings.Builder, ref string) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

L50: shrink: YAML unmarshal-and-compare solely to decide quoting. Always use strconv.Quote(ref); checkout refs are strings and that preserves every literal while removing the parser branch.

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.

writeCheckoutRef now always uses strconv.Quote(ref); the YAML unmarshal-and-compare branch is removed. Commit: 79c1b9d7.

// Preserve literal refs that YAML would otherwise interpret as numbers or indicators.
value := ref
var scalar any
if err := yaml.Unmarshal([]byte(ref), &scalar); err != nil || scalar != ref {
value = strconv.Quote(ref)
}
fmt.Fprintf(sb, " ref: %s\n", value) //nolint:fprintferrorunchecked // strings.Builder writes cannot fail.
}

func writeCheckoutDepthAndSparse(sb *strings.Builder, entry *resolvedCheckout) {
if entry.fetchDepth != nil {
fmt.Fprintf(sb, " fetch-depth: %d\n", *entry.fetchDepth)
Expand Down
2 changes: 1 addition & 1 deletion pkg/workflow/checkout_step_generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@ func generateCheckoutStepLines(entry *resolvedCheckout, index int, keepCredentia
fmt.Fprintf(&sb, " repository: %s\n", entry.key.repository)
}
if entry.ref != "" {
fmt.Fprintf(&sb, " ref: %s\n", entry.ref)
writeCheckoutRef(&sb, entry.ref)
}
if entry.key.path != "" {
fmt.Fprintf(&sb, " path: %s\n", entry.key.path)
Expand Down
35 changes: 32 additions & 3 deletions pkg/workflow/frontmatter_on_section_cleanup.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import (
"github.com/github/gh-aw/pkg/setutil"
)

// commentOutProcessedFieldsInOnSection comments out draft, max-stack, fork, forks, names, labels, manual-approval, cooldown, stop-after, skip-if-match, skip-if-no-match, skip-roles, reaction, lock-for-agent, steps, permissions, needs, restore-memory, stale-check, and report-blocked-version fields in the on section
// commentOutProcessedFieldsInOnSection comments out draft, max-stack, fork, forks, names, labels, manual-approval, cooldown, stop-after, skip-if-match, skip-if-no-match, skip-roles, reaction, lock-for-agent, steps, permissions, needs, restore-memory, stale-check, report-blocked-version, and pull_request_target policy fields in the on section
// These fields are processed separately and should be commented for documentation
// Exception: names fields in sections with __gh_aw_native_label_filter__ marker in frontmatter are NOT commented out
func (c *Compiler) commentOutProcessedFieldsInOnSection(yamlStr string, frontmatter map[string]any) string {
Expand Down Expand Up @@ -54,6 +54,7 @@ func newOnSectionLine(raw string) onSectionLine {

type onSectionCleanupState struct {
inPullRequest bool
inPullRequestTarget bool
inPullRequestReview bool
inIssues bool
inDiscussion bool
Expand All @@ -72,6 +73,8 @@ type onSectionCleanupState struct {
inBotsArray bool
inLabelsArray bool
inNeedsArray bool
inAllowedCheckouts bool
allowedCheckoutsIndent int
inGitHubApp bool
inOnSteps bool
inOnPermissions bool
Expand All @@ -88,6 +91,7 @@ func newOnSectionCleanupState() *onSectionCleanupState {
currentSectionIndent: -1,
deploymentStatusIndent: -1,
workflowRunIndent: -1,
allowedCheckoutsIndent: -1,
}
}

Expand Down Expand Up @@ -121,7 +125,7 @@ func collectNativeLabelFilterSections(frontmatter map[string]any) map[string]str
}

func (s *onSectionCleanupState) inEventSection() bool {
return s.inPullRequest || s.inPullRequestReview || s.inIssues || s.inDiscussion || s.inIssueComment
return s.inPullRequest || s.inPullRequestTarget || s.inPullRequestReview || s.inIssues || s.inDiscussion || s.inIssueComment
}

func (s *onSectionCleanupState) handleEventSectionEntry(info onSectionLine, result *[]string) bool {
Expand All @@ -142,7 +146,7 @@ func (s *onSectionCleanupState) detectEventSection(info onSectionLine) (string,
return "", false
}
switch info.trimmed {
case "pull_request:", "pull_request_review:", "issues:", "discussion:", "issue_comment:", "deployment_status:", "workflow_run:":
case "pull_request:", "pull_request_target:", "pull_request_review:", "issues:", "discussion:", "issue_comment:", "deployment_status:", "workflow_run:":
return strings.TrimSuffix(info.trimmed, ":"), true
default:
return "", false
Expand All @@ -155,6 +159,7 @@ func (s *onSectionCleanupState) activateEventSection(section string, indent int)
s.inCommentBlock = false
s.commentBlockIndent = ""
s.inPullRequest = section == "pull_request"
s.inPullRequestTarget = section == "pull_request_target"
s.inPullRequestReview = section == "pull_request_review"
s.inIssues = section == "issues"
s.inDiscussion = section == "discussion"
Expand All @@ -163,6 +168,8 @@ func (s *onSectionCleanupState) activateEventSection(section string, indent int)
s.inWorkflowRun = section == "workflow_run"
s.inWorkflowRunConclusionArray = false
s.inForksArray = false
s.inAllowedCheckouts = false
s.allowedCheckoutsIndent = -1
s.currentSection, s.currentSectionIndent = "", -1
if s.inEventSection() {
s.currentSection, s.currentSectionIndent = section, indent
Expand Down Expand Up @@ -194,6 +201,7 @@ func (s *onSectionCleanupState) leaveEventSections(info onSectionLine) {
s.leaveCurrentEventSection(info)
s.leaveDeploymentStatusSection(info)
s.leaveWorkflowRunSection(info)
s.leaveAllowedCheckouts(info)
}

func (s *onSectionCleanupState) leaveCurrentEventSection(info onSectionLine) {
Expand All @@ -202,16 +210,29 @@ func (s *onSectionCleanupState) leaveCurrentEventSection(info onSectionLine) {
}
if s.currentSectionIndent >= 0 && info.indent <= s.currentSectionIndent {
s.inPullRequest = false
s.inPullRequestTarget = false
s.inPullRequestReview = false
s.inIssues = false
s.inDiscussion = false
s.inIssueComment = false
s.inForksArray = false
s.inAllowedCheckouts = false
s.allowedCheckoutsIndent = -1
s.currentSection = ""
s.currentSectionIndent = -1
}
}

func (s *onSectionCleanupState) leaveAllowedCheckouts(info onSectionLine) {
if !s.inAllowedCheckouts || info.trimmed == "" || strings.HasPrefix(info.trimmed, "#") {
return
}
if info.indent <= s.allowedCheckoutsIndent {
s.inAllowedCheckouts = false
s.allowedCheckoutsIndent = -1
}
}

func (s *onSectionCleanupState) leaveDeploymentStatusSection(info onSectionLine) {
if !s.inDeploymentStatus || info.trimmed == "" || strings.HasPrefix(info.trimmed, "#") {
return
Expand Down Expand Up @@ -520,6 +541,14 @@ func (s *onSectionCleanupState) determineEventSectionComment(info onSectionLine,

func (s *onSectionCleanupState) commentPullRequestAndTriggerField(info onSectionLine) (bool, string) {
switch {
case s.inAllowedCheckouts && info.indent > s.allowedCheckoutsIndent:
return true, ""
case s.inPullRequestTarget && info.indent == 4 && strings.HasPrefix(info.trimmed, "acknowledge-risk:"):
return true, " # Risk acknowledgment is compiler-only"
case s.inPullRequestTarget && info.indent == 4 && strings.HasPrefix(info.trimmed, "allowed-checkouts:"):
s.inAllowedCheckouts = true
s.allowedCheckoutsIndent = info.indent
return true, " # Trusted checkout policy is compiler-only"
case s.inPullRequest && strings.Contains(info.trimmed, "draft:"):
return true, " # Draft filtering applied via job conditions"
case (s.inPullRequest || s.inPullRequestReview) && strings.HasPrefix(info.trimmed, "max-stack:"):
Expand Down
67 changes: 53 additions & 14 deletions pkg/workflow/pull_request_target_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,11 @@
//
// # Validation Rules
//
// 1. In strict mode: always emit a warning that pull_request_target is a very
// dangerous trigger, even when checkout: false is set, because the workflow
// still runs with full write permissions and secret access.
// Workflows can opt out by setting strict: false in frontmatter.
// 1. In strict mode: emit a warning about elevated permissions and secret access,
// unless on.pull_request_target.acknowledge-risk is explicitly true.
//
// 2. When checkout is NOT explicitly disabled (checkout: false not set):
// 2. When checkout is neither explicitly disabled nor restricted to trusted
// base-repository refs or literal on.pull_request_target.allowed-checkouts:
// - In strict mode: return a hard error (extremely insecure).
// - In non-strict mode: emit a warning.
//
Expand Down Expand Up @@ -50,14 +49,17 @@ var pullRequestTargetLog = logger.New("workflow:pull_request_target_validation")
// are never treated as a trusted literal allowlist match.
var pullRequestTargetGitHubExpressionPattern = regexp.MustCompile(`^\$\{\{\s*([^{}]+?)\s*\}\}$`)

var pullRequestTargetLiteralRepositoryPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$`)
var pullRequestTargetLiteralRefPattern = regexp.MustCompile(`^[^\s${}*?\[\\:~^]+$`)

// validatePullRequestTargetTrigger validates security requirements for pull_request_target triggers.
//
// The pull_request_target trigger runs with full write permissions and repository secret access
// on the base branch. When checkout is not explicitly disabled (checkout: false), the workflow
// may execute untrusted PR code with elevated privileges — a critical security vulnerability
// commonly known as a "pwn request" attack.
//
// In strict mode, a warning is always emitted that pull_request_target is inherently dangerous
// In strict mode, a warning is emitted unless the trigger-specific risk is acknowledged,
// even with checkout disabled, since the workflow still runs with elevated permissions.
// When the workflow frontmatter sets strict: false, effectiveStrictMode is lowered so the
// dangerous-trigger strict-only warning is skipped; the insecure-checkout check still runs
Expand Down Expand Up @@ -99,18 +101,29 @@ func (c *Compiler) validatePullRequestTargetTrigger(workflowData *WorkflowData,
}

effectiveStrictMode := c.effectiveStrictMode(workflowData.RawFrontmatter)
var acknowledged bool
var allowedCheckouts []any
if on, ok := workflowData.RawFrontmatter["on"].(map[string]any); ok {
if trigger, ok := on["pull_request_target"].(map[string]any); ok {
acknowledged = trigger["acknowledge-risk"] == true
if entries, ok := trigger["allowed-checkouts"].([]any); ok {
allowedCheckouts = entries
}
}
}

// In strict mode, always emit a warning that pull_request_target is a very dangerous trigger,
// In strict mode, emit a warning unless the trigger-specific risk is acknowledged,
// regardless of whether checkout is disabled. The workflow still runs with full write
// permissions and has access to all repository secrets.
if effectiveStrictMode {
if effectiveStrictMode && !acknowledged {
pullRequestTargetLog.Print("Emitting strict mode warning: pull_request_target is a very dangerous trigger")
warningMsg := "pull_request_target is a very dangerous trigger.\n" +
"This event runs with full write permissions and access to all repository secrets.\n" +
"Unlike pull_request, it runs in the context of the target (base) branch, giving\n" +
"the workflow elevated access even for PRs from untrusted fork contributors.\n" +
"Even with checkout: false, consider whether pull_request_target is truly necessary.\n" +
"If you only need to react to PR events without write access, use pull_request instead.\n" +
"To acknowledge this risk only, set on.pull_request_target.acknowledge-risk: true.\n" +
"See: https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/"
fmt.Fprintln(os.Stderr, formatCompilerMessage(markdownPath, "warning", warningMsg))
c.IncrementWarningCount()
Expand All @@ -125,10 +138,9 @@ func (c *Compiler) validatePullRequestTargetTrigger(workflowData *WorkflowData,
return nil
}

// Explicit checkout configurations that are pinned to the base repository/ref are considered
// safe for pull_request_target because they do not execute untrusted PR head code.
if hasOnlyTrustedPullRequestTargetCheckouts(workflowData.CheckoutConfigs) {
pullRequestTargetLog.Print("checkout config is pinned to trusted base repository/ref, skipping insecure-checkout error")
// Every checkout must target a supported base ref or a declared literal repository/ref pair.
if hasOnlyTrustedPullRequestTargetCheckouts(workflowData.CheckoutConfigs, allowedCheckouts) {
pullRequestTargetLog.Print("checkout config is restricted to trusted repository/ref pairs, skipping insecure-checkout error")
return nil
}

Expand All @@ -151,6 +163,8 @@ func (c *Compiler) validatePullRequestTargetTrigger(workflowData *WorkflowData,
"checkout:\n" +
" repository: ${{ github.repository }}\n\n" +
"You can also use 'ref: ${{ github.event.pull_request.base.ref }}'.\n" +
"For a fixed external checkout, declare its literal repository/ref pair in\n" +
"on.pull_request_target.allowed-checkouts and configure checkout to match exactly.\n" +
"See: https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/"

if effectiveStrictMode {
Expand All @@ -164,18 +178,43 @@ func (c *Compiler) validatePullRequestTargetTrigger(workflowData *WorkflowData,
return nil
}

func hasOnlyTrustedPullRequestTargetCheckouts(configs []*CheckoutConfig) bool {
func hasOnlyTrustedPullRequestTargetCheckouts(configs []*CheckoutConfig, allowedCheckouts []any) bool {
if len(configs) == 0 {
return false
}
for _, cfg := range configs {
if !isTrustedPullRequestTargetCheckout(cfg) {
if !isTrustedPullRequestTargetCheckout(cfg) && !isAllowedPullRequestTargetCheckout(cfg, allowedCheckouts) {

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.

Trusted-base and allowlisted checkouts now fail closed when fetch is non-empty. Regression tests cover PR refs, PR-head expressions, and wildcard fetches. Commit: 79c1b9d7.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/codebase-design] isAllowedPullRequestTargetCheckout validates cfg.Repository and cfg.Ref but never checks cfg.Fetch. An entry that is otherwise allowlisted (or trusted-base) can still set fetch: ["${{ github.event.pull_request.head.ref }}"], and generateFetchStepLines will emit an authenticated git fetch of that untrusted ref with the checkout's resolved credentials — defeating the allowlist's safety guarantee entirely.

💡 Suggested fix

Treat a non-empty cfg.Fetch the same as an untrusted repository/ref: either reject any allowlisted/trusted checkout that sets fetch to a non-literal value, or require each fetch entry to pass the same literal-pattern checks (pullRequestTargetLiteralRefPattern, no refs/pull/ prefix) as cfg.Ref. Add a regression test with checkout.fetch set to a PR-head expression paired with an otherwise-allowlisted checkout to lock in the fix — this is exactly the kind of case /diagnosing-bugs regression-test discipline would catch before merge.

@copilot please address this.

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.

Trusted-base and allowlisted checkouts now fail closed when fetch is non-empty. Regression tests cover PR-head expressions and PR refs. Commit: 79c1b9d7.

return false
}
}
return true
}

func isAllowedPullRequestTargetCheckout(cfg *CheckoutConfig, allowedCheckouts []any) bool {
if cfg == nil || cfg.Wiki {
return false
}
// Fail closed even when called without schema validation. Never allow expressions
// or PR refs merely because the same string appears in the policy.
if !pullRequestTargetLiteralRepositoryPattern.MatchString(cfg.Repository) ||
!pullRequestTargetLiteralRefPattern.MatchString(cfg.Ref) ||
strings.HasPrefix(cfg.Ref, "refs/pull/") {
return false
}
for _, entry := range allowedCheckouts {
pair, ok := entry.(map[string]any)
if !ok {
continue
}
repository, repositoryOK := pair["repository"].(string)
ref, refOK := pair["ref"].(string)
if repositoryOK && refOK && repository == cfg.Repository && ref == cfg.Ref {
return true
}
}
return false
}

func isTrustedPullRequestTargetCheckout(cfg *CheckoutConfig) bool {
if cfg == nil {
return false
Expand Down
Loading
Loading