Skip to content

Allow trusted base-branch checkout in pull_request_target validation#37099

Merged
pelikhan merged 8 commits into
mainfrom
copilot/aw-compat-fix-codemod-issue
Jun 5, 2026
Merged

Allow trusted base-branch checkout in pull_request_target validation#37099
pelikhan merged 8 commits into
mainfrom
copilot/aw-compat-fix-codemod-issue

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Jun 5, 2026

pull_request_target workflows with explicit checkout: were being treated as insecure by default, even when checkout was intentionally pinned to trusted base-branch context. This update removes that false-negative path and makes the security guidance explicit about safe base-checkout patterns.

  • Trusted checkout recognition in strict validation

    • Updated pull_request_target security validation to treat explicit checkout as safe when it targets the base repository and trusted base ref expressions.
    • Accepted trusted forms:
      • repository: ${{ github.repository }} (or omitted)
      • ref: ${{ github.event.pull_request.base.sha }}, ref: ${{ github.event.pull_request.base.ref }}, or omitted ref (base-branch default for pull_request_target).
  • Actionable security diagnostic text

    • Expanded the insecure-checkout error guidance to show safe remediation patterns, including:
      1. checkout: false
      2. explicit trusted base checkout (github.repository + base ref)
      3. explicit trusted base checkout with repo only (omit ref)
  • Expression parsing hardening

    • Replaced loose string checks with strict expression-shape matching for ${{ ... }} and explicit inner-expression validation.
    • Prevents trusted-status bypass from malformed or non-expression literal values.
  • Focused coverage for safe vs unsafe explicit checkout

    • Added/extended unit tests for:
      • trusted base-sha and base-ref forms
      • compact expression syntax (${{github.repository}})
      • unwrapped literal ref strings (still insecure)
      • matcher edge cases (missing braces, empty expression, trailing junk)
on:
  pull_request_target:
    types: [opened, synchronize]

checkout:
  repository: ${{ github.repository }}
  ref: ${{ github.event.pull_request.base.sha }}

Copilot AI and others added 6 commits June 5, 2026 11:41
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
…lidation

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix codemod gap for pull_request_target and explicit checkout Allow trusted base-branch checkout in pull_request_target validation Jun 5, 2026
Copilot AI requested a review from pelikhan June 5, 2026 11:50
@pelikhan pelikhan marked this pull request as ready for review June 5, 2026 11:54
Copilot AI review requested due to automatic review settings June 5, 2026 11:54
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 5, 2026

🧠 Matt Pocock Skills Reviewer has completed the skills-based review. ✅

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 5, 2026

Test Quality Sentinel failed during test quality analysis.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 5, 2026

Design Decision Gate 🏗️ completed the design decision gate check.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 5, 2026

PR Code Quality Reviewer completed the code quality review.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Updates pull_request_target security validation to recognize explicitly trusted base-branch checkouts as safe (avoiding false-positive “insecure checkout” errors), while expanding the diagnostic guidance and adding targeted unit tests to harden expression matching.

Changes:

  • Treat explicit checkout: as trusted in pull_request_target when repository is omitted or exactly ${{ github.repository }}, and ref is omitted or exactly ${{ github.event.pull_request.base.sha }} / ${{ github.event.pull_request.base.ref }}.
  • Strengthen expression parsing by requiring a strict ${{ ... }} shape and exact inner-expression matches (preventing “trusted” status from malformed literals).
  • Extend unit tests to cover safe vs unsafe explicit checkout cases and expression-shape edge cases.
Show a summary per file
File Description
pkg/workflow/pull_request_target_validation.go Adds trusted base-checkout recognition and strict ${{ ... }} expression matching; improves insecure-checkout guidance text.
pkg/workflow/pull_request_target_validation_test.go Adds unit tests covering trusted base repo/ref forms, compact syntax, and malformed expression edge cases.

Copilot's findings

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 2/2 changed files
  • Comments generated: 0

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 5, 2026

🏗️ Design Decision Gate — ADR Required

This PR makes significant changes to core business logic (239 new lines in pkg/) but does not have a linked Architecture Decision Record (ADR).

📄 Draft ADR committed: docs/adr/37099-trusted-base-checkout-pull-request-target.md — review and complete it before merging.

🔒 This PR cannot merge until an ADR is linked in the PR body.

📋 What to do next
  1. Review the draft ADR committed to your branch — it was generated from the PR diff (the decision to recognize trusted base-branch checkout in pull_request_target validation).
  2. Complete the missing sections — add context the AI could not infer, refine the decision rationale, and confirm the alternatives you actually considered.
  3. Commit the finalized ADR to docs/adr/ on your branch.
  4. Reference the ADR in this PR body by adding a line such as:

    ADR: ADR-37099: Recognize Trusted Base-Branch Checkout in pull_request_target Validation

Once an ADR is linked in the PR body, this gate will re-run and verify the implementation matches the decision.

❓ Why ADRs Matter

ADRs create a searchable, permanent record of why the codebase looks the way it does — especially valuable for security-sensitive trust decisions like which checkout forms are considered safe under pull_request_target. Future contributors (and your future self) will thank you.

📋 Michael Nygard ADR Format Reference

An ADR must contain these four sections to be considered complete:

  • Context — What is the problem? What forces are at play?
  • Decision — What did you decide? Why?
  • Alternatives Considered — What else could have been done?
  • Consequences — What are the trade-offs (positive and negative)?

All ADRs are stored in docs/adr/ as Markdown files numbered by PR number (e.g., 37099-...md for PR #37099).

🔒 Blocking: link the ADR in the PR body to clear this gate.

🏗️ ADR gate enforced by Design Decision Gate 🏗️ · opus48 4.1M · 85.9 AIC ·

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Skills-Based Review 🧠

Applied /tdd and /diagnose — commenting with test coverage gaps and one clarification suggestion. No blocking issues.

📋 Key Themes & Highlights

Key Themes

  • Test coverage gaps: All 5 new trusted-checkout integration tests are strict-mode only; the non-strict path and the mixed-checkout path are untested.
  • TestMatchesGitHubExpression scope: Unit tests only exercise github.repository; the actual trusted ref expressions (base.sha, base.ref) have no dedicated unit coverage.
  • base.ref vs base.sha distinction: Both are accepted as trusted, but they have different mutability semantics that are worth documenting.
  • Regex intent: The [^{}]+? brace exclusion is a deliberate bypass-hardening choice but reads as incidental without a comment.

Positive Highlights

  • ✅ Strict regex + exact-match comparison prevents any expression-injection bypass — this is the right approach for a security allowlist.
  • hasOnlyTrustedPullRequestTargetCheckouts correctly requires all configs to be trusted, not just any one.
  • ✅ The dangerous-trigger strict warning is still always emitted before the trusted-checkout early return — the security posture is preserved.
  • ✅ The "unwrapped literal ref" test explicitly guards against the most obvious bypass attempt.
  • ✅ Whitelist is deliberately narrow and static; no dynamic lookup that could be manipulated.

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · sonnet46 2.2M · 322.5 AIC

expectWarning: true,
errorContains: "pull_request_target trigger with checkout enabled is extremely insecure",
warningCount: 1, // dangerous-trigger warning
},
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.

[/tdd] All five new trusted-checkout test cases use strictMode: true, leaving the non-strict path completely untested.

In non-strict mode with a trusted checkout the function should return nil with zero warnings, but this path is never exercised. A missed regression in effectiveStrictMode evaluation or in hasOnlyTrustedPullRequestTargetCheckouts would go undetected.

💡 Suggested addition
{
    name: "pull_request_target with trusted checkout - non-strict - no warnings no error",
    frontmatter: `---
on:
  pull_request_target:
    types: [opened]
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, // key: non-strict path
    expectError:   false,
    expectWarning: false,
    warningCount:  0,
},

With effectiveStrictMode = false the dangerous-trigger warning is not emitted, and the trusted checkout returns nil early, so warningCount should be 0.

warningCount: 1, // dangerous-trigger warning
},
{
name: "pull_request_target with explicit checkout pinned to base sha - strict - warning only",
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.

[/tdd] The new test suite has no case for a mixed checkout list — one trusted config and one untrusted config in the same checkout: array.

hasOnlyTrustedPullRequestTargetCheckouts iterates all entries and returns false if any is untrusted. Without a test for this, a refactor that accidentally short-circuits the loop (e.g. returns true on first trusted match) would not be caught.

💡 Suggested addition
{
    name: "pull_request_target with mixed trusted and untrusted checkouts - strict - errors",
    frontmatter: `---
on:
  pull_request_target:
    types: [opened]
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,
},

expected: "github.repository",
match: false,
},
}
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.

[/tdd] TestMatchesGitHubExpression only exercises github.repository as the expected argument. The actual whitelist trusted expressions — github.event.pull_request.base.sha and github.event.pull_request.base.ref — are never tested at the unit level.

Adding cases for those longer expressions would catch any future typo or off-by-one in the expression strings passed to matchesAnyGitHubExpression.

💡 Suggested additions
{
    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,
},

The third case also documents the intentional rejection of fork-head expressions.


ref := strings.TrimSpace(cfg.Ref)
return ref == "" || matchesAnyGitHubExpression(ref,
"github.event.pull_request.base.sha",
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.

[/diagnose] github.event.pull_request.base.ref resolves to the base branch name (e.g. main), not a pinned commit SHA. Unlike base.sha, it is mutable — a new commit pushed to the base branch between the PR event and the workflow execution can cause a different commit to be checked out.

This is still safe (it only ever checks out trusted base code, never fork head), but the behavioural difference is worth a brief comment so future maintainers don't equate the two forms.

💡 Suggested inline comment
return ref == "" || matchesAnyGitHubExpression(ref,
    "github.event.pull_request.base.sha", // immutable commit SHA - preferred for reproducibility
    "github.event.pull_request.base.ref", // branch name - mutable tip, still trusted base code
)

)

var pullRequestTargetLog = newValidationLogger("pull_request_target")
var pullRequestTargetGitHubExpressionPattern = regexp.MustCompile(`^\$\{\{\s*([^{}]+?)\s*\}\}$`)
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.

[/diagnose] The [^{}]+? character class in the regex intentionally prevents nested brace expressions from being accepted as trusted. This is a deliberate bypass-hardening choice, but it is implicit.

A short comment would help future maintainers understand why braces are excluded from the inner capture — otherwise it looks like an arbitrary restriction rather than a security invariant.

💡 Suggested comment
// [^{}]+? deliberately excludes brace characters to prevent nested expression constructs
// (e.g. "${{ foo || bar }}" or "${{ env.VAR }}") from being accepted as a trusted literal.
var pullRequestTargetGitHubExpressionPattern = regexp.MustCompile(`^\$\{\{\s*([^{}]+?)\s*\}\}$`)

@pelikhan
Copy link
Copy Markdown
Collaborator

pelikhan commented Jun 5, 2026

@copilot review all comments
Apply smoke-review skill

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copy link
Copy Markdown
Contributor Author

Copilot AI commented Jun 5, 2026

@copilot review all comments
Apply smoke-review skill

Done in ccdcffc. I reviewed the in-scope feedback and applied the requested smoke-review follow-up items: added missing non-strict trusted-checkout coverage, mixed trusted+untrusted checkout coverage, expression matcher cases for base.sha/base.ref (plus head.sha negative), and clarifying security comments in validation code. Targeted workflow validation tests pass.

@pelikhan pelikhan merged commit 9b49b79 into main Jun 5, 2026
@pelikhan pelikhan deleted the copilot/aw-compat-fix-codemod-issue branch June 5, 2026 14:18
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.

[aw-compat] Codemod gap: pull_request_target + explicit checkout is not auto-remediated by gh aw fix

3 participants