From 699e48a795d2420808f5a586180bd1e435769923 Mon Sep 17 00:00:00 2001 From: artiehinz <133814822+artiehinz@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:29:20 -0600 Subject: [PATCH 1/9] ci: defer privileged recipe scans for forks --- .github/workflows/recipe-security-scanner.yml | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.github/workflows/recipe-security-scanner.yml b/.github/workflows/recipe-security-scanner.yml index 084b61824dc5..3e44fa2fffdd 100644 --- a/.github/workflows/recipe-security-scanner.yml +++ b/.github/workflows/recipe-security-scanner.yml @@ -17,7 +17,30 @@ permissions: statuses: write jobs: + fork-review-boundary: + name: Fork recipe review boundary + if: ${{ github.event.pull_request.head.repo.full_name != github.repository }} + permissions: {} + runs-on: ubuntu-latest + steps: + - name: Defer privileged scan + run: | + echo "::notice title=Privileged recipe scan deferred::Fork recipe instructions are untrusted input. A reviewer with write access must inspect the change before a maintainer mirrors it to an origin branch for the secret-bearing AI scan." + cat >> "$GITHUB_STEP_SUMMARY" <<'EOF' + # Privileged recipe scan deferred + + This recipe comes from a fork. The AI scanner has repository secrets, + outbound network access, and developer tooling, so it must not process + fork-controlled instructions in a `pull_request_target` job. + + A reviewer with write access must inspect the recipe. If the AI scan is + needed, a maintainer can mirror the approved commit to a branch in this + repository, where the `security-scan` job is allowed to run. + EOF + security-scan: + # Never load fork-controlled recipe instructions into the privileged scanner. + if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} runs-on: ubuntu-latest steps: - name: Harden Runner From deac9a6800905960e78e463fe5a16102958660ed Mon Sep 17 00:00:00 2001 From: artiehinz <133814822+artiehinz@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:45:45 -0600 Subject: [PATCH 2/9] ci: require write approval for fork recipes --- .github/workflows/recipe-security-scanner.yml | 87 ++++++++++++++++--- 1 file changed, 73 insertions(+), 14 deletions(-) diff --git a/.github/workflows/recipe-security-scanner.yml b/.github/workflows/recipe-security-scanner.yml index 3e44fa2fffdd..53ff06049cf6 100644 --- a/.github/workflows/recipe-security-scanner.yml +++ b/.github/workflows/recipe-security-scanner.yml @@ -5,6 +5,8 @@ on: types: [opened, synchronize, reopened] paths: - 'documentation/src/pages/recipes/data/recipes/**' + pull_request_review: + types: [submitted, dismissed] concurrency: group: scanner-${{ github.workflow }}-${{ github.event.pull_request.number }} @@ -20,27 +22,84 @@ jobs: fork-review-boundary: name: Fork recipe review boundary if: ${{ github.event.pull_request.head.repo.full_name != github.repository }} - permissions: {} + permissions: + contents: read + pull-requests: read runs-on: ubuntu-latest steps: - - name: Defer privileged scan - run: | - echo "::notice title=Privileged recipe scan deferred::Fork recipe instructions are untrusted input. A reviewer with write access must inspect the change before a maintainer mirrors it to an origin branch for the secret-bearing AI scan." - cat >> "$GITHUB_STEP_SUMMARY" <<'EOF' - # Privileged recipe scan deferred + - name: Require write-access approval + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const recipePrefix = 'documentation/src/pages/recipes/data/recipes/'; + const pullNumber = context.payload.pull_request.number; + const request = { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pullNumber, + per_page: 100, + }; + + const files = await github.paginate(github.rest.pulls.listFiles, request); + const hasReviewableRecipe = files.some( + (file) => file.filename.startsWith(recipePrefix) && file.status !== 'removed' + ); + + if (!hasReviewableRecipe) { + core.notice('No added or modified recipe requires the fork review boundary.'); + return; + } - This recipe comes from a fork. The AI scanner has repository secrets, - outbound network access, and developer tooling, so it must not process - fork-controlled instructions in a `pull_request_target` job. + const reviews = await github.paginate(github.rest.pulls.listReviews, request); + const latestByReviewer = new Map(); + for (const review of reviews) { + if (!review.user?.login || !review.submitted_at) continue; + const state = review.state?.toUpperCase(); + if (!['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(state)) continue; + const current = latestByReviewer.get(review.user.login); + if (!current || new Date(review.submitted_at) > new Date(current.submitted_at)) { + latestByReviewer.set(review.user.login, review); + } + } + + const allowedPermissions = new Set(['admin', 'maintain', 'write']); + const approvers = []; + for (const review of latestByReviewer.values()) { + if (review.state?.toUpperCase() !== 'APPROVED') continue; + try { + const response = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: review.user.login, + }); + if (allowedPermissions.has(response.data.permission)) { + approvers.push(review.user.login); + } + } catch (error) { + if (error.status !== 404) throw error; + } + } + + if (approvers.length === 0) { + core.setFailed( + 'Fork recipe instructions cannot enter the secret-bearing AI scanner. ' + + 'An approving review from a user with write access is required.' + ); + return; + } - A reviewer with write access must inspect the recipe. If the AI scan is - needed, a maintainer can mirror the approved commit to a branch in this - repository, where the `security-scan` job is allowed to run. - EOF + core.notice(`Fork recipe review boundary approved by @${approvers.join(', @')}.`); + await core.summary + .addHeading('Fork recipe review boundary') + .addRaw( + 'The privileged AI scan was not run because this recipe comes from a fork. ' + + `Write-access approval was verified from @${approvers.join(', @')}.` + ) + .write(); security-scan: # Never load fork-controlled recipe instructions into the privileged scanner. - if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} + if: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.full_name == github.repository }} runs-on: ubuntu-latest steps: - name: Harden Runner From 3bcc52d9c895df7abf3b7f583f62644ad5c33b82 Mon Sep 17 00:00:00 2001 From: artiehinz <133814822+artiehinz@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:48:10 -0600 Subject: [PATCH 3/9] docs: clarify fork recipe trust boundary --- .github/workflows/recipe-security-scanner.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/recipe-security-scanner.yml b/.github/workflows/recipe-security-scanner.yml index 53ff06049cf6..943a52c9afcb 100644 --- a/.github/workflows/recipe-security-scanner.yml +++ b/.github/workflows/recipe-security-scanner.yml @@ -19,6 +19,8 @@ permissions: statuses: write jobs: + # Forks are evaluated from GitHub file/review metadata only; recipe content + # never enters this job or the secret-bearing scanner. fork-review-boundary: name: Fork recipe review boundary if: ${{ github.event.pull_request.head.repo.full_name != github.repository }} From c4d85a4792bb7ac40afea9aead6c9057088712f8 Mon Sep 17 00:00:00 2001 From: artiehinz <133814822+artiehinz@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:49:38 -0600 Subject: [PATCH 4/9] ci: use repository review decision for fork gate --- .github/workflows/recipe-security-scanner.yml | 46 +++++++------------ 1 file changed, 16 insertions(+), 30 deletions(-) diff --git a/.github/workflows/recipe-security-scanner.yml b/.github/workflows/recipe-security-scanner.yml index 943a52c9afcb..fa67fa8db522 100644 --- a/.github/workflows/recipe-security-scanner.yml +++ b/.github/workflows/recipe-security-scanner.yml @@ -52,37 +52,23 @@ jobs: return; } - const reviews = await github.paginate(github.rest.pulls.listReviews, request); - const latestByReviewer = new Map(); - for (const review of reviews) { - if (!review.user?.login || !review.submitted_at) continue; - const state = review.state?.toUpperCase(); - if (!['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(state)) continue; - const current = latestByReviewer.get(review.user.login); - if (!current || new Date(review.submitted_at) > new Date(current.submitted_at)) { - latestByReviewer.set(review.user.login, review); - } - } - - const allowedPermissions = new Set(['admin', 'maintain', 'write']); - const approvers = []; - for (const review of latestByReviewer.values()) { - if (review.state?.toUpperCase() !== 'APPROVED') continue; - try { - const response = await github.rest.repos.getCollaboratorPermissionLevel({ - owner: context.repo.owner, - repo: context.repo.repo, - username: review.user.login, - }); - if (allowedPermissions.has(response.data.permission)) { - approvers.push(review.user.login); + const reviewData = await github.graphql( + `query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + reviewDecision + } } - } catch (error) { - if (error.status !== 404) throw error; + }`, + { + owner: context.repo.owner, + repo: context.repo.repo, + number: pullNumber, } - } + ); + const reviewDecision = reviewData.repository.pullRequest.reviewDecision; - if (approvers.length === 0) { + if (reviewDecision !== 'APPROVED') { core.setFailed( 'Fork recipe instructions cannot enter the secret-bearing AI scanner. ' + 'An approving review from a user with write access is required.' @@ -90,12 +76,12 @@ jobs: return; } - core.notice(`Fork recipe review boundary approved by @${approvers.join(', @')}.`); + core.notice('GitHub reports that the fork recipe has the required approval.'); await core.summary .addHeading('Fork recipe review boundary') .addRaw( 'The privileged AI scan was not run because this recipe comes from a fork. ' + - `Write-access approval was verified from @${approvers.join(', @')}.` + 'GitHub reports that the repository review requirement is approved.' ) .write(); From cc8a60b04eb205802105495b543e67cbc2d2440f Mon Sep 17 00:00:00 2001 From: artiehinz <133814822+artiehinz@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:57:03 -0600 Subject: [PATCH 5/9] ci: bind fork approval to current head --- .github/workflows/recipe-security-scanner.yml | 54 ++++++++++++------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/.github/workflows/recipe-security-scanner.yml b/.github/workflows/recipe-security-scanner.yml index fa67fa8db522..7f5fc11edaf6 100644 --- a/.github/workflows/recipe-security-scanner.yml +++ b/.github/workflows/recipe-security-scanner.yml @@ -5,8 +5,6 @@ on: types: [opened, synchronize, reopened] paths: - 'documentation/src/pages/recipes/data/recipes/**' - pull_request_review: - types: [submitted, dismissed] concurrency: group: scanner-${{ github.workflow }}-${{ github.event.pull_request.number }} @@ -25,7 +23,10 @@ jobs: name: Fork recipe review boundary if: ${{ github.event.pull_request.head.repo.full_name != github.repository }} permissions: - contents: read + # getCollaboratorPermissionLevel requires push access. This job runs only + # pinned base-branch JavaScript against GitHub metadata and never checks + # out or reads fork recipe content. + contents: write pull-requests: read runs-on: ubuntu-latest steps: @@ -52,42 +53,55 @@ jobs: return; } - const reviewData = await github.graphql( - `query($owner: String!, $repo: String!, $number: Int!) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $number) { - reviewDecision - } - } - }`, - { + const currentHead = context.payload.pull_request.head.sha; + const reviews = await github.paginate(github.rest.pulls.listReviews, request); + const latestByReviewer = new Map(); + for (const review of reviews) { + if (!review.user?.login || !review.submitted_at) continue; + const state = review.state?.toUpperCase(); + if (!['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(state)) continue; + const current = latestByReviewer.get(review.user.login); + if (!current || new Date(review.submitted_at) > new Date(current.submitted_at)) { + latestByReviewer.set(review.user.login, review); + } + } + + const allowedPermissions = new Set(['admin', 'maintain', 'write']); + const approvers = []; + for (const review of latestByReviewer.values()) { + if (review.state?.toUpperCase() !== 'APPROVED') continue; + if (review.commit_id !== currentHead) continue; + const response = await github.rest.repos.getCollaboratorPermissionLevel({ owner: context.repo.owner, repo: context.repo.repo, - number: pullNumber, + username: review.user.login, + }); + if (allowedPermissions.has(response.data.permission)) { + approvers.push(review.user.login); } - ); - const reviewDecision = reviewData.repository.pullRequest.reviewDecision; + } - if (reviewDecision !== 'APPROVED') { + if (approvers.length === 0) { core.setFailed( 'Fork recipe instructions cannot enter the secret-bearing AI scanner. ' + - 'An approving review from a user with write access is required.' + 'The current head requires an approving review from a user with write access. ' + + 'After approval, rerun this failed job.' ); return; } - core.notice('GitHub reports that the fork recipe has the required approval.'); + core.notice(`Current fork head approved by @${approvers.join(', @')}.`); await core.summary .addHeading('Fork recipe review boundary') .addRaw( 'The privileged AI scan was not run because this recipe comes from a fork. ' + - 'GitHub reports that the repository review requirement is approved.' + `Write-access approval of the current head was verified from @${approvers.join(', @')}.` ) .write(); security-scan: # Never load fork-controlled recipe instructions into the privileged scanner. - if: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.full_name == github.repository }} + if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} runs-on: ubuntu-latest steps: - name: Harden Runner From 946c83bba3d0e2e5e07ed36409096d94a2eb187a Mon Sep 17 00:00:00 2001 From: artiehinz <133814822+artiehinz@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:05:48 -0600 Subject: [PATCH 6/9] ci: preserve required scanner context --- .github/workflows/recipe-security-scanner.yml | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/.github/workflows/recipe-security-scanner.yml b/.github/workflows/recipe-security-scanner.yml index 7f5fc11edaf6..41f4aeda89d6 100644 --- a/.github/workflows/recipe-security-scanner.yml +++ b/.github/workflows/recipe-security-scanner.yml @@ -99,7 +99,8 @@ jobs: ) .write(); - security-scan: + origin-ai-scan: + name: Origin recipe AI scan # Never load fork-controlled recipe instructions into the privileged scanner. if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} runs-on: ubuntu-latest @@ -511,3 +512,38 @@ jobs: echo "::error::No scan summary found - scan may have failed completely" exit 1 fi + + # Preserve the existing required job context. This job always runs and + # reflects the applicable isolated check instead of allowing a skipped + # privileged scanner to satisfy branch protection for a fork. + security-scan: + name: security-scan + if: ${{ always() }} + needs: + - fork-review-boundary + - origin-ai-scan + permissions: + contents: read + runs-on: ubuntu-latest + steps: + - name: Enforce the applicable security boundary + env: + IS_FORK: ${{ github.event.pull_request.head.repo.full_name != github.repository }} + FORK_BOUNDARY_RESULT: ${{ needs.fork-review-boundary.result }} + ORIGIN_SCAN_RESULT: ${{ needs.origin-ai-scan.result }} + run: | + set -euo pipefail + + if [ "$IS_FORK" = "true" ]; then + if [ "$FORK_BOUNDARY_RESULT" != "success" ]; then + echo "::error::Fork review boundary result: $FORK_BOUNDARY_RESULT" + exit 1 + fi + echo "::notice::Fork review boundary passed; privileged AI scan was not run." + else + if [ "$ORIGIN_SCAN_RESULT" != "success" ]; then + echo "::error::Origin recipe AI scan result: $ORIGIN_SCAN_RESULT" + exit 1 + fi + echo "::notice::Origin recipe AI scan passed." + fi From d0f5ce4abd852d34da4dfaad519e5dd85170df1f Mon Sep 17 00:00:00 2001 From: artiehinz <133814822+artiehinz@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:20:46 -0600 Subject: [PATCH 7/9] ci: publish scanner status from final gate --- .github/workflows/recipe-security-scanner.yml | 53 ++++++++++++------- 1 file changed, 34 insertions(+), 19 deletions(-) diff --git a/.github/workflows/recipe-security-scanner.yml b/.github/workflows/recipe-security-scanner.yml index 41f4aeda89d6..a8e8ffa2fb96 100644 --- a/.github/workflows/recipe-security-scanner.yml +++ b/.github/workflows/recipe-security-scanner.yml @@ -23,10 +23,8 @@ jobs: name: Fork recipe review boundary if: ${{ github.event.pull_request.head.repo.full_name != github.repository }} permissions: - # getCollaboratorPermissionLevel requires push access. This job runs only - # pinned base-branch JavaScript against GitHub metadata and never checks - # out or reads fork recipe content. - contents: write + # GitHub App installation tokens can query collaborator permission with + # their implicit metadata read access. pull-requests: read runs-on: ubuntu-latest steps: @@ -524,26 +522,43 @@ jobs: - origin-ai-scan permissions: contents: read + statuses: write runs-on: ubuntu-latest steps: - name: Enforce the applicable security boundary + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: IS_FORK: ${{ github.event.pull_request.head.repo.full_name != github.repository }} FORK_BOUNDARY_RESULT: ${{ needs.fork-review-boundary.result }} ORIGIN_SCAN_RESULT: ${{ needs.origin-ai-scan.result }} - run: | - set -euo pipefail + with: + script: | + const isFork = process.env.IS_FORK === 'true'; + const applicableResult = isFork + ? process.env.FORK_BOUNDARY_RESULT + : process.env.ORIGIN_SCAN_RESULT; + const checkName = isFork ? 'fork review boundary' : 'origin recipe AI scan'; + const passed = applicableResult === 'success'; - if [ "$IS_FORK" = "true" ]; then - if [ "$FORK_BOUNDARY_RESULT" != "success" ]; then - echo "::error::Fork review boundary result: $FORK_BOUNDARY_RESULT" - exit 1 - fi - echo "::notice::Fork review boundary passed; privileged AI scan was not run." - else - if [ "$ORIGIN_SCAN_RESULT" != "success" ]; then - echo "::error::Origin recipe AI scan result: $ORIGIN_SCAN_RESULT" - exit 1 - fi - echo "::notice::Origin recipe AI scan passed." - fi + await github.rest.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha: context.payload.pull_request.head.sha, + state: passed ? 'success' : 'failure', + target_url: `${context.payload.pull_request.html_url}/checks`, + description: passed + ? `${checkName} passed` + : `${checkName} result: ${applicableResult}`, + context: 'security-scan/recipe-scanner', + }); + + if (!passed) { + core.setFailed(`${checkName} result: ${applicableResult}`); + return; + } + + core.notice( + isFork + ? 'Fork review boundary passed; privileged AI scan was not run.' + : 'Origin recipe AI scan passed.' + ); From a7a27c5fd6c3aa1de41a7d06f97b3f5a646dacda Mon Sep 17 00:00:00 2001 From: artiehinz <133814822+artiehinz@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:25:27 -0600 Subject: [PATCH 8/9] ci: minimize final gate permissions --- .github/workflows/recipe-security-scanner.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/recipe-security-scanner.yml b/.github/workflows/recipe-security-scanner.yml index a8e8ffa2fb96..040a4c7f61f7 100644 --- a/.github/workflows/recipe-security-scanner.yml +++ b/.github/workflows/recipe-security-scanner.yml @@ -521,7 +521,6 @@ jobs: - fork-review-boundary - origin-ai-scan permissions: - contents: read statuses: write runs-on: ubuntu-latest steps: From a980ca873f80613e12f5f63b37a6a3503f795e46 Mon Sep 17 00:00:00 2001 From: artiehinz <133814822+artiehinz@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:31:58 -0600 Subject: [PATCH 9/9] ci: honor active change requests in fork gate --- .github/workflows/recipe-security-scanner.yml | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/recipe-security-scanner.yml b/.github/workflows/recipe-security-scanner.yml index 040a4c7f61f7..b4ae3ae5d160 100644 --- a/.github/workflows/recipe-security-scanner.yml +++ b/.github/workflows/recipe-security-scanner.yml @@ -66,19 +66,33 @@ jobs: const allowedPermissions = new Set(['admin', 'maintain', 'write']); const approvers = []; + const blockers = []; for (const review of latestByReviewer.values()) { - if (review.state?.toUpperCase() !== 'APPROVED') continue; if (review.commit_id !== currentHead) continue; + const state = review.state?.toUpperCase(); + if (!['APPROVED', 'CHANGES_REQUESTED'].includes(state)) continue; const response = await github.rest.repos.getCollaboratorPermissionLevel({ owner: context.repo.owner, repo: context.repo.repo, username: review.user.login, }); if (allowedPermissions.has(response.data.permission)) { - approvers.push(review.user.login); + if (state === 'CHANGES_REQUESTED') { + blockers.push(review.user.login); + } else { + approvers.push(review.user.login); + } } } + if (blockers.length > 0) { + core.setFailed( + 'The current fork head has changes requested by a reviewer with write access: ' + + `@${blockers.join(', @')}.` + ); + return; + } + if (approvers.length === 0) { core.setFailed( 'Fork recipe instructions cannot enter the secret-bearing AI scanner. ' +