Skip to content
150 changes: 149 additions & 1 deletion .github/workflows/recipe-security-scanner.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,104 @@ permissions:
statuses: write

jobs:
security-scan:
# 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 }}
permissions:
# GitHub App installation tokens can query collaborator permission with
# their implicit metadata read access.
pull-requests: read
runs-on: ubuntu-latest
steps:
- 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;
}

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 = [];
const blockers = [];
for (const review of latestByReviewer.values()) {
if (review.commit_id !== currentHead) continue;
Comment thread
artiehinz marked this conversation as resolved.
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)) {
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. ' +
'The current head requires an approving review from a user with write access. ' +
'After approval, rerun this failed job.'
);
return;
}

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. ' +
`Write-access approval of the current head was verified from @${approvers.join(', @')}.`
)
.write();

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 }}
Comment thread
artiehinz marked this conversation as resolved.
Comment thread
artiehinz marked this conversation as resolved.
runs-on: ubuntu-latest
steps:
- name: Harden Runner
Expand Down Expand Up @@ -427,3 +524,54 @@ 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:
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 }}
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';

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.'
);