From df8ec2f5b8a6b019033bff716dda982a170aa0c1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Jun 2026 11:28:28 +0000 Subject: [PATCH 1/8] Initial plan From e111e56d65f1b401cb111b0f08d340a2781ce46c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Jun 2026 11:41:05 +0000 Subject: [PATCH 2/8] Allow trusted base checkout for pull_request_target validation Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../pull_request_target_validation.go | 42 +++++++++++++++- .../pull_request_target_validation_test.go | 49 +++++++++++++++++++ 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/pkg/workflow/pull_request_target_validation.go b/pkg/workflow/pull_request_target_validation.go index 6d8436f86aa..7c42a45a39e 100644 --- a/pkg/workflow/pull_request_target_validation.go +++ b/pkg/workflow/pull_request_target_validation.go @@ -119,6 +119,13 @@ 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") + return nil + } + // Checkout is not disabled — the workflow may execute untrusted PR code with elevated privileges. pullRequestTargetLog.Print("checkout is NOT disabled, emitting pull_request_target insecure-checkout diagnostic") @@ -127,9 +134,14 @@ func (c *Compiler) validatePullRequestTargetTrigger(workflowData *WorkflowData, "but the workflow will check out code from a potentially untrusted PR contributor.\n" + "This is a well-known attack vector: a fork PR can inject malicious code that\n" + "executes with access to your repository's secrets (\"pwn request\" attack).\n\n" + - "Suggested fix: Add 'checkout: false' to your workflow frontmatter to prevent\n" + - "checking out untrusted PR code:\n" + + "Suggested fix: Use one of these safe patterns:\n" + + "1) Disable checkout entirely:\n" + "checkout: false\n\n" + + "2) Check out only the trusted base repo/ref:\n" + + "checkout:\n" + + " repository: ${{ github.repository }}\n" + + " ref: ${{ github.event.pull_request.base.sha }}\n\n" + + "If you omit ref, checkout defaults to the trigger ref for pull_request_target.\n" + "See: https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/" if effectiveStrictMode { @@ -142,3 +154,29 @@ func (c *Compiler) validatePullRequestTargetTrigger(workflowData *WorkflowData, return nil } + +func hasOnlyTrustedPullRequestTargetCheckouts(configs []*CheckoutConfig) bool { + if len(configs) == 0 { + return false + } + for _, cfg := range configs { + if !isTrustedPullRequestTargetCheckout(cfg) { + return false + } + } + return true +} + +func isTrustedPullRequestTargetCheckout(cfg *CheckoutConfig) bool { + if cfg == nil { + return false + } + + repository := strings.TrimSpace(cfg.Repository) + if repository != "" && repository != "${{ github.repository }}" { + return false + } + + ref := strings.TrimSpace(cfg.Ref) + return ref == "" || ref == "${{ github.event.pull_request.base.sha }}" +} diff --git a/pkg/workflow/pull_request_target_validation_test.go b/pkg/workflow/pull_request_target_validation_test.go index a3c37d90315..500cd3c4531 100644 --- a/pkg/workflow/pull_request_target_validation_test.go +++ b/pkg/workflow/pull_request_target_validation_test.go @@ -159,6 +159,55 @@ Test workflow content.`, errorContains: "pull_request_target trigger with checkout enabled is extremely insecure", warningCount: 1, // dangerous-trigger warning }, + { + name: "pull_request_target with explicit checkout pinned to base sha - strict - warning only", + frontmatter: `--- +on: + pull_request_target: + types: [opened] +tools: + github: + toolsets: [pull_requests] +permissions: + pull-requests: read +checkout: + repository: ${{ github.repository }} + ref: ${{ github.event.pull_request.base.sha }} +--- + +# PR Target Strict Trusted Checkout +Test workflow content.`, + filename: "prt-checkout-base-sha-strict.md", + strictMode: true, + expectError: false, + expectWarning: true, + warningCount: 1, // dangerous-trigger warning + }, + { + name: "pull_request_target with explicit checkout default ref in base repo - strict - warning only", + frontmatter: `--- +on: + pull_request_target: + types: [opened] +tools: + github: + toolsets: [pull_requests] +permissions: + pull-requests: read +checkout: + repository: ${{ github.repository }} + sparse-checkout: | + .github +--- + +# PR Target Strict Trusted Default Ref +Test workflow content.`, + filename: "prt-checkout-base-repo-default-ref-strict.md", + strictMode: true, + expectError: false, + expectWarning: true, + warningCount: 1, // dangerous-trigger warning + }, { name: "pull_request_target with checkout enabled - strict CLI + frontmatter strict false - warning only", frontmatter: `--- From 03cf85a9d713574bdb4a9b94f883927cdfb0eea4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Jun 2026 11:42:20 +0000 Subject: [PATCH 3/8] Accept compact trusted checkout expressions in pull_request_target validation Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../pull_request_target_validation.go | 18 ++++++++++++-- .../pull_request_target_validation_test.go | 24 +++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/pkg/workflow/pull_request_target_validation.go b/pkg/workflow/pull_request_target_validation.go index 7c42a45a39e..53891651d2c 100644 --- a/pkg/workflow/pull_request_target_validation.go +++ b/pkg/workflow/pull_request_target_validation.go @@ -173,10 +173,24 @@ func isTrustedPullRequestTargetCheckout(cfg *CheckoutConfig) bool { } repository := strings.TrimSpace(cfg.Repository) - if repository != "" && repository != "${{ github.repository }}" { + if repository != "" && !matchesGitHubExpression(repository, "github.repository") { return false } ref := strings.TrimSpace(cfg.Ref) - return ref == "" || ref == "${{ github.event.pull_request.base.sha }}" + return ref == "" || matchesGitHubExpression(ref, "github.event.pull_request.base.sha") +} + +func matchesGitHubExpression(value string, expectedExpression string) bool { + trimmed := strings.TrimSpace(value) + if trimmed == expectedExpression { + return true + } + + if !strings.HasPrefix(trimmed, "${{") || !strings.HasSuffix(trimmed, "}}") { + return false + } + + inner := strings.TrimSuffix(strings.TrimPrefix(trimmed, "${{"), "}}") + return strings.TrimSpace(inner) == expectedExpression } diff --git a/pkg/workflow/pull_request_target_validation_test.go b/pkg/workflow/pull_request_target_validation_test.go index 500cd3c4531..fa47ff39af8 100644 --- a/pkg/workflow/pull_request_target_validation_test.go +++ b/pkg/workflow/pull_request_target_validation_test.go @@ -208,6 +208,30 @@ Test workflow content.`, expectWarning: true, warningCount: 1, // dangerous-trigger warning }, + { + name: "pull_request_target with trusted checkout expressions using compact syntax - strict - warning only", + frontmatter: `--- +on: + pull_request_target: + types: [opened] +tools: + github: + toolsets: [pull_requests] +permissions: + pull-requests: read +checkout: + repository: ${{github.repository}} + ref: ${{github.event.pull_request.base.sha}} +--- + +# PR Target Strict Trusted Compact Expressions +Test workflow content.`, + filename: "prt-checkout-trusted-compact-expr-strict.md", + strictMode: true, + expectError: false, + expectWarning: true, + warningCount: 1, // dangerous-trigger warning + }, { name: "pull_request_target with checkout enabled - strict CLI + frontmatter strict false - warning only", frontmatter: `--- From 018b7c87545abe11c8f5b4b8b6b8651a849a38c4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Jun 2026 11:43:54 +0000 Subject: [PATCH 4/8] Harden trusted checkout expression matching for pull_request_target Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../pull_request_target_validation.go | 21 +++++--- .../pull_request_target_validation_test.go | 49 +++++++++++++++++++ 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/pkg/workflow/pull_request_target_validation.go b/pkg/workflow/pull_request_target_validation.go index 53891651d2c..2c5cda2acb0 100644 --- a/pkg/workflow/pull_request_target_validation.go +++ b/pkg/workflow/pull_request_target_validation.go @@ -141,7 +141,7 @@ func (c *Compiler) validatePullRequestTargetTrigger(workflowData *WorkflowData, "checkout:\n" + " repository: ${{ github.repository }}\n" + " ref: ${{ github.event.pull_request.base.sha }}\n\n" + - "If you omit ref, checkout defaults to the trigger ref for pull_request_target.\n" + + "If you omit ref with pull_request_target, checkout defaults to the base branch.\n" + "See: https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/" if effectiveStrictMode { @@ -178,15 +178,24 @@ func isTrustedPullRequestTargetCheckout(cfg *CheckoutConfig) bool { } ref := strings.TrimSpace(cfg.Ref) - return ref == "" || matchesGitHubExpression(ref, "github.event.pull_request.base.sha") + return ref == "" || matchesAnyGitHubExpression(ref, + "github.event.pull_request.base.sha", + "github.event.pull_request.base.ref", + "github.ref", + ) } -func matchesGitHubExpression(value string, expectedExpression string) bool { - trimmed := strings.TrimSpace(value) - if trimmed == expectedExpression { - return true +func matchesAnyGitHubExpression(value string, expectedExpressions ...string) bool { + for _, expected := range expectedExpressions { + if matchesGitHubExpression(value, expected) { + return true + } } + return false +} +func matchesGitHubExpression(value string, expectedExpression string) bool { + trimmed := strings.TrimSpace(value) if !strings.HasPrefix(trimmed, "${{") || !strings.HasSuffix(trimmed, "}}") { return false } diff --git a/pkg/workflow/pull_request_target_validation_test.go b/pkg/workflow/pull_request_target_validation_test.go index fa47ff39af8..da41527281a 100644 --- a/pkg/workflow/pull_request_target_validation_test.go +++ b/pkg/workflow/pull_request_target_validation_test.go @@ -232,6 +232,55 @@ Test workflow content.`, expectWarning: true, warningCount: 1, // dangerous-trigger warning }, + { + name: "pull_request_target with explicit checkout pinned to base ref expression - strict - warning only", + frontmatter: `--- +on: + pull_request_target: + types: [opened] +tools: + github: + toolsets: [pull_requests] +permissions: + pull-requests: read +checkout: + repository: ${{ github.repository }} + ref: ${{ github.event.pull_request.base.ref }} +--- + +# PR Target Strict Trusted Base Ref +Test workflow content.`, + filename: "prt-checkout-base-ref-strict.md", + strictMode: true, + expectError: false, + expectWarning: true, + warningCount: 1, // dangerous-trigger warning + }, + { + name: "pull_request_target with unwrapped trusted-looking ref string - strict - still errors", + frontmatter: `--- +on: + pull_request_target: + types: [opened] +tools: + github: + toolsets: [pull_requests] +permissions: + pull-requests: read +checkout: + repository: ${{ github.repository }} + ref: github.event.pull_request.base.sha +--- + +# PR Target Strict Unwrapped Ref String +Test workflow content.`, + filename: "prt-checkout-unwrapped-ref-string-strict.md", + strictMode: true, + expectError: true, + expectWarning: true, + errorContains: "pull_request_target trigger with checkout enabled is extremely insecure", + warningCount: 1, // dangerous-trigger warning + }, { name: "pull_request_target with checkout enabled - strict CLI + frontmatter strict false - warning only", frontmatter: `--- From 503658d7723dc240ec164493924acfc263fe81e2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Jun 2026 11:44:52 +0000 Subject: [PATCH 5/8] Clarify trusted pull_request_target ref guidance in validation error Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/pull_request_target_validation.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/workflow/pull_request_target_validation.go b/pkg/workflow/pull_request_target_validation.go index 2c5cda2acb0..606a807a2fd 100644 --- a/pkg/workflow/pull_request_target_validation.go +++ b/pkg/workflow/pull_request_target_validation.go @@ -141,6 +141,8 @@ func (c *Compiler) validatePullRequestTargetTrigger(workflowData *WorkflowData, "checkout:\n" + " repository: ${{ github.repository }}\n" + " ref: ${{ github.event.pull_request.base.sha }}\n\n" + + "You can also use 'ref: ${{ github.event.pull_request.base.ref }}' or\n" + + "'ref: ${{ github.ref }}' when appropriate.\n" + "If you omit ref with pull_request_target, checkout defaults to the base branch.\n" + "See: https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/" From f5253af7923ba5a64c785e975abb9e5cf26c4aef Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Jun 2026 11:47:53 +0000 Subject: [PATCH 6/8] Tighten trusted checkout parsing and cover expression edge cases Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../pull_request_target_validation.go | 13 +++-- .../pull_request_target_validation_test.go | 51 +++++++++++++++++++ 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/pkg/workflow/pull_request_target_validation.go b/pkg/workflow/pull_request_target_validation.go index 606a807a2fd..49299f1340e 100644 --- a/pkg/workflow/pull_request_target_validation.go +++ b/pkg/workflow/pull_request_target_validation.go @@ -36,12 +36,14 @@ package workflow import ( "fmt" "os" + "regexp" "strings" "github.com/goccy/go-yaml" ) var pullRequestTargetLog = newValidationLogger("pull_request_target") +var pullRequestTargetGitHubExpressionPattern = regexp.MustCompile(`^\$\{\{\s*([^{}]+?)\s*\}\}$`) // validatePullRequestTargetTrigger validates security requirements for pull_request_target triggers. // @@ -141,8 +143,7 @@ func (c *Compiler) validatePullRequestTargetTrigger(workflowData *WorkflowData, "checkout:\n" + " repository: ${{ github.repository }}\n" + " ref: ${{ github.event.pull_request.base.sha }}\n\n" + - "You can also use 'ref: ${{ github.event.pull_request.base.ref }}' or\n" + - "'ref: ${{ github.ref }}' when appropriate.\n" + + "You can also use 'ref: ${{ github.event.pull_request.base.ref }}'.\n" + "If you omit ref with pull_request_target, checkout defaults to the base branch.\n" + "See: https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/" @@ -183,7 +184,6 @@ func isTrustedPullRequestTargetCheckout(cfg *CheckoutConfig) bool { return ref == "" || matchesAnyGitHubExpression(ref, "github.event.pull_request.base.sha", "github.event.pull_request.base.ref", - "github.ref", ) } @@ -198,10 +198,9 @@ func matchesAnyGitHubExpression(value string, expectedExpressions ...string) boo func matchesGitHubExpression(value string, expectedExpression string) bool { trimmed := strings.TrimSpace(value) - if !strings.HasPrefix(trimmed, "${{") || !strings.HasSuffix(trimmed, "}}") { + matches := pullRequestTargetGitHubExpressionPattern.FindStringSubmatch(trimmed) + if len(matches) != 2 { return false } - - inner := strings.TrimSuffix(strings.TrimPrefix(trimmed, "${{"), "}}") - return strings.TrimSpace(inner) == expectedExpression + return strings.TrimSpace(matches[1]) == expectedExpression } diff --git a/pkg/workflow/pull_request_target_validation_test.go b/pkg/workflow/pull_request_target_validation_test.go index da41527281a..8242ad2f0fb 100644 --- a/pkg/workflow/pull_request_target_validation_test.go +++ b/pkg/workflow/pull_request_target_validation_test.go @@ -357,3 +357,54 @@ Test workflow content.`, }) } } + +func TestMatchesGitHubExpression(t *testing.T) { + t.Parallel() + + assertions := []struct { + name string + value string + expected string + match bool + }{ + { + name: "spaced expression", + value: "${{ github.repository }}", + expected: "github.repository", + match: true, + }, + { + name: "compact expression", + value: "${{github.repository}}", + expected: "github.repository", + match: true, + }, + { + name: "missing closing braces", + value: "${{ github.repository", + expected: "github.repository", + match: false, + }, + { + name: "empty expression", + value: "${{}}", + expected: "github.repository", + match: false, + }, + { + name: "extra trailing tokens", + value: "${{ github.repository }}bar}}", + expected: "github.repository", + match: false, + }, + } + + for _, tc := range assertions { + t.Run(tc.name, func(t *testing.T) { + actual := matchesGitHubExpression(tc.value, tc.expected) + if actual != tc.match { + t.Fatalf("matchesGitHubExpression(%q, %q) = %v, want %v", tc.value, tc.expected, actual, tc.match) + } + }) + } +} From bfe93989cd9273cd1c0f12e9558d335d62a8601c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Jun 2026 11:49:07 +0000 Subject: [PATCH 7/8] Clarify third safe base-checkout remediation pattern Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/pull_request_target_validation.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/workflow/pull_request_target_validation.go b/pkg/workflow/pull_request_target_validation.go index 49299f1340e..c065a3a259b 100644 --- a/pkg/workflow/pull_request_target_validation.go +++ b/pkg/workflow/pull_request_target_validation.go @@ -143,8 +143,10 @@ func (c *Compiler) validatePullRequestTargetTrigger(workflowData *WorkflowData, "checkout:\n" + " repository: ${{ github.repository }}\n" + " ref: ${{ github.event.pull_request.base.sha }}\n\n" + + "3) Check out only the trusted base repository and omit ref:\n" + + "checkout:\n" + + " repository: ${{ github.repository }}\n\n" + "You can also use 'ref: ${{ github.event.pull_request.base.ref }}'.\n" + - "If you omit ref with pull_request_target, checkout defaults to the base branch.\n" + "See: https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/" if effectiveStrictMode { From ccdcffca53ec8485e1cb3199a2a09a9678958aa2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Jun 2026 14:15:14 +0000 Subject: [PATCH 8/8] Address review gaps for trusted pull_request_target checkout validation Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../pull_request_target_validation.go | 6 +- .../pull_request_target_validation_test.go | 69 +++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/pkg/workflow/pull_request_target_validation.go b/pkg/workflow/pull_request_target_validation.go index c065a3a259b..22d72a515fe 100644 --- a/pkg/workflow/pull_request_target_validation.go +++ b/pkg/workflow/pull_request_target_validation.go @@ -43,6 +43,8 @@ import ( ) var pullRequestTargetLog = newValidationLogger("pull_request_target") +// [^{}]+? deliberately excludes brace characters so nested expression constructs +// are never treated as a trusted literal allowlist match. var pullRequestTargetGitHubExpressionPattern = regexp.MustCompile(`^\$\{\{\s*([^{}]+?)\s*\}\}$`) // validatePullRequestTargetTrigger validates security requirements for pull_request_target triggers. @@ -184,8 +186,8 @@ func isTrustedPullRequestTargetCheckout(cfg *CheckoutConfig) bool { ref := strings.TrimSpace(cfg.Ref) return ref == "" || matchesAnyGitHubExpression(ref, - "github.event.pull_request.base.sha", - "github.event.pull_request.base.ref", + "github.event.pull_request.base.sha", // immutable commit SHA + "github.event.pull_request.base.ref", // mutable branch tip, still trusted base code ) } diff --git a/pkg/workflow/pull_request_target_validation_test.go b/pkg/workflow/pull_request_target_validation_test.go index 8242ad2f0fb..bcd5bd051b1 100644 --- a/pkg/workflow/pull_request_target_validation_test.go +++ b/pkg/workflow/pull_request_target_validation_test.go @@ -70,6 +70,30 @@ Test workflow content.`, expectWarning: true, warningCount: 2, // 1 for insecure checkout + 1 for sandbox.agent: false }, + { + name: "pull_request_target with trusted checkout - non-strict - no warnings no error", + frontmatter: `--- +on: + pull_request_target: + types: [opened] +tools: + github: + toolsets: [pull_requests] +permissions: + pull-requests: read +checkout: + repository: ${{ github.repository }} + ref: ${{ github.event.pull_request.base.sha }} +--- + +# PR Target Non-Strict Trusted Checkout +Test workflow content.`, + filename: "prt-checkout-trusted-non-strict.md", + strictMode: false, + expectError: false, + expectWarning: false, + warningCount: 0, + }, { name: "pull_request trigger (not target) - non-strict - no diagnostic", frontmatter: `--- @@ -281,6 +305,33 @@ Test workflow content.`, errorContains: "pull_request_target trigger with checkout enabled is extremely insecure", warningCount: 1, // dangerous-trigger warning }, + { + name: "pull_request_target with mixed trusted and untrusted checkouts - strict - errors", + frontmatter: `--- +on: + pull_request_target: + types: [opened] +tools: + github: + toolsets: [pull_requests] +permissions: + pull-requests: read +checkout: + - repository: ${{ github.repository }} + ref: ${{ github.event.pull_request.base.sha }} + - repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} +--- + +# PR Target Mixed Checkouts +Test workflow content.`, + filename: "prt-checkout-mixed-strict.md", + strictMode: true, + expectError: true, + expectWarning: true, + errorContains: "pull_request_target trigger with checkout enabled is extremely insecure", + warningCount: 1, // dangerous-trigger warning + }, { name: "pull_request_target with checkout enabled - strict CLI + frontmatter strict false - warning only", frontmatter: `--- @@ -379,6 +430,24 @@ func TestMatchesGitHubExpression(t *testing.T) { expected: "github.repository", match: true, }, + { + name: "base sha expression matches", + value: "${{ github.event.pull_request.base.sha }}", + expected: "github.event.pull_request.base.sha", + match: true, + }, + { + name: "base ref expression matches", + value: "${{ github.event.pull_request.base.ref }}", + expected: "github.event.pull_request.base.ref", + match: true, + }, + { + name: "head sha expression does not match base sha", + value: "${{ github.event.pull_request.head.sha }}", + expected: "github.event.pull_request.base.sha", + match: false, + }, { name: "missing closing braces", value: "${{ github.repository",