name: Recipe Security Scan on: pull_request_target: types: [opened, synchronize, reopened] paths: - 'documentation/src/pages/recipes/data/recipes/**' concurrency: group: scanner-${{ github.workflow }}-${{ github.event.pull_request.number }} cancel-in-progress: true permissions: contents: read pull-requests: write issues: write 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 }} 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; 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 }} runs-on: ubuntu-latest steps: - name: Harden Runner uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 with: egress-policy: audit # SECURITY FIX (GHSA-7qhh-cph9-6ppm): Checkout base branch for trusted Dockerfile # The PR could contain a malicious Dockerfile that exfiltrates secrets. # We checkout the base branch (trusted) for building the scanner image, # and only fetch recipe files from the PR for scanning. - name: Checkout base branch (trusted code) uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.base.sha }} fetch-depth: 0 path: trusted - name: Fetch PR recipe files only uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 path: pr-content sparse-checkout: | documentation/src/pages/recipes/data/recipes/ - name: Check if recipe files changed in this push id: recipe_changes working-directory: pr-content run: | set -e echo "🔍 Checking if recipe files were modified in this push..." # Get the list of changed files in this specific push (added/modified only, not deleted) if [ "${{ github.event_name }}" = "pull_request" ] && [ "${{ github.event.action }}" = "synchronize" ]; then # For synchronize events, check files changed since the previous commit echo "📝 Synchronize event - checking files changed since previous commit" CHANGED_FILES=$(git diff --name-only --diff-filter=AM ${{ github.event.before }}..${{ github.event.after }}) else # For opened/reopened, check all files in the PR (compare PR head against base) echo "📝 PR opened/reopened - checking all files in PR" CHANGED_FILES=$(git diff --name-only --diff-filter=AM ${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}) fi echo "Changed files in this push:" echo "$CHANGED_FILES" echo "" # Check if any recipe files were changed if echo "$CHANGED_FILES" | grep -q "^documentation/src/pages/recipes/data/recipes/"; then echo "recipe_files_changed=true" >> "$GITHUB_OUTPUT" echo "✅ Recipe files were modified in this push - proceeding with scan" else echo "recipe_files_changed=false" >> "$GITHUB_OUTPUT" echo "â„šī¸ No recipe files were modified in this push - skipping scan" fi - name: Ensure jq available if: steps.recipe_changes.outputs.recipe_files_changed == 'true' run: sudo apt-get update && sudo apt-get install -y jq - name: Find recipe files in PR (new or modified) id: find_recipes if: steps.recipe_changes.outputs.recipe_files_changed == 'true' working-directory: pr-content run: | set -e echo "Looking for recipe files in PR (new or modified)..." # Get the list of changed/new files in this PR (added/modified only, not deleted) if [ "${{ github.event_name }}" = "pull_request" ] && [ "${{ github.event.action }}" = "synchronize" ]; then # For synchronize events, check files changed since the previous commit echo "📝 Synchronize event - checking files changed/added since previous commit" CHANGED_FILES=$(git diff --name-only --diff-filter=AM ${{ github.event.before }}..${{ github.event.after }}) else # For opened/reopened, check all files in the PR (compare PR head against base) echo "📝 PR opened/reopened - checking all new/modified files in PR" CHANGED_FILES=$(git diff --name-only --diff-filter=AM ${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}) fi # Filter for recipe files only that were changed or added RECIPE_FILES=$(echo "$CHANGED_FILES" | grep "^documentation/src/pages/recipes/data/recipes/" | grep -E "\.(yaml|yml)$" || true) if [ -z "$RECIPE_FILES" ]; then echo "No changed recipe files found in PR" echo "has_recipes=false" >> "$GITHUB_OUTPUT" echo "recipe_count=0" >> "$GITHUB_OUTPUT" else echo "Found changed recipe files:" echo "$RECIPE_FILES" RECIPE_COUNT=$(echo "$RECIPE_FILES" | wc -l) echo "has_recipes=true" >> "$GITHUB_OUTPUT" echo "recipe_count=$RECIPE_COUNT" >> "$GITHUB_OUTPUT" # Save recipe file paths for later steps (with pr-content prefix for mounting) echo "$RECIPE_FILES" | sed 's|^|pr-content/|' > "$RUNNER_TEMP/recipe_files.txt" fi - name: Set up Docker Buildx if: steps.find_recipes.outputs.has_recipes == 'true' && steps.recipe_changes.outputs.recipe_files_changed == 'true' uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Prune Docker caches if: steps.find_recipes.outputs.has_recipes == 'true' && steps.recipe_changes.outputs.recipe_files_changed == 'true' run: | docker buildx prune -af || true docker system prune -af || true - name: Build scanner image (no cache) if: steps.find_recipes.outputs.has_recipes == 'true' && steps.recipe_changes.outputs.recipe_files_changed == 'true' env: DOCKER_BUILDKIT: 1 IMAGE_TAG: ${{ github.sha }} run: | # SECURITY: Build from trusted/ directory (base branch) to prevent malicious Dockerfile docker buildx build \ --pull \ --no-cache \ --load \ --platform linux/amd64 \ -t "recipe-scanner:${IMAGE_TAG}" \ -f trusted/recipe-scanner/Dockerfile \ trusted/recipe-scanner/ - name: Scan all recipe files if: steps.find_recipes.outputs.has_recipes == 'true' && steps.recipe_changes.outputs.recipe_files_changed == 'true' env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} TRAINING_DATA_LOW: ${{ secrets.TRAINING_DATA_LOW }} TRAINING_DATA_MEDIUM: ${{ secrets.TRAINING_DATA_MEDIUM }} TRAINING_DATA_EXTREME: ${{ secrets.TRAINING_DATA_EXTREME }} IMAGE_TAG: ${{ github.sha }} run: | set -e OUT="$RUNNER_TEMP/security-scan" mkdir -p "$OUT" # Set permissions for Docker container (scanner user is UID 1000) sudo chmod -R 777 "$OUT" || true # Verify secrets are available (without logging details) if [ -z "$OPENAI_API_KEY" ] || [ -z "$TRAINING_DATA_LOW" ] || [ -z "$TRAINING_DATA_MEDIUM" ] || [ -z "$TRAINING_DATA_EXTREME" ]; then echo "❌ One or more required secrets are missing or inaccessible" exit 1 fi # Initialize overall scan results echo '{"scanned_recipes": [], "overall_status": "UNKNOWN", "failed_scans": 0}' > "$OUT/pr_scan_summary.json" RECIPE_NUM=1 FAILED_SCANS=0 BLOCKED_RECIPES=0 # Scan each recipe file while IFS= read -r RECIPE_FILE; do if [ -f "$RECIPE_FILE" ]; then echo "🔍 Scanning recipe $RECIPE_NUM: $RECIPE_FILE" # Create output directory for this recipe RECIPE_OUT="$OUT/recipe-$RECIPE_NUM" mkdir -p "$RECIPE_OUT" sudo chmod -R 777 "$RECIPE_OUT" || true # Run scanner on this recipe with training data if docker run --rm \ -e OPENAI_API_KEY="$OPENAI_API_KEY" \ -e TRAINING_DATA_LOW="$TRAINING_DATA_LOW" \ -e TRAINING_DATA_MEDIUM="$TRAINING_DATA_MEDIUM" \ -e TRAINING_DATA_EXTREME="$TRAINING_DATA_EXTREME" \ -v "$PWD/$RECIPE_FILE:/input/recipe.yaml:ro" \ -v "$RECIPE_OUT:/output" \ "recipe-scanner:${IMAGE_TAG}" 2>&1 | tee "$RECIPE_OUT/scan-log.txt"; then echo "✅ Scan completed for recipe $RECIPE_NUM" # Check scan result if [ -f "$RECIPE_OUT/scan_status.json" ]; then STATUS=$(jq -r .status "$RECIPE_OUT/scan_status.json" || echo "UNKNOWN") RISK_LEVEL=$(jq -r .risk_level "$RECIPE_OUT/scan_status.json" || echo "UNKNOWN") if [ "$STATUS" = "BLOCKED" ]; then BLOCKED_RECIPES=$((BLOCKED_RECIPES + 1)) fi # Check if risk level requires blocking (MEDIUM, HIGH, CRITICAL) if [ "$RISK_LEVEL" = "MEDIUM" ] || [ "$RISK_LEVEL" = "HIGH" ] || [ "$RISK_LEVEL" = "CRITICAL" ]; then BLOCKED_RECIPES=$((BLOCKED_RECIPES + 1)) echo "âš ī¸ Recipe $RECIPE_NUM blocked due to $RISK_LEVEL risk level" fi else echo "âš ī¸ No scan_status.json found for recipe $RECIPE_NUM" FAILED_SCANS=$((FAILED_SCANS + 1)) fi else echo "❌ Scan failed for recipe $RECIPE_NUM" FAILED_SCANS=$((FAILED_SCANS + 1)) fi RECIPE_NUM=$((RECIPE_NUM + 1)) fi done < "$RUNNER_TEMP/recipe_files.txt" # Determine overall status if [ $FAILED_SCANS -gt 0 ]; then OVERALL_STATUS="SCAN_FAILED" elif [ $BLOCKED_RECIPES -gt 0 ]; then OVERALL_STATUS="BLOCKED" else OVERALL_STATUS="APPROVED" fi # Update summary jq --arg status "$OVERALL_STATUS" --argjson failed "$FAILED_SCANS" --argjson blocked "$BLOCKED_RECIPES" \ '.overall_status = $status | .failed_scans = $failed | .blocked_recipes = $blocked' \ "$OUT/pr_scan_summary.json" > "$OUT/pr_scan_summary_tmp.json" && \ mv "$OUT/pr_scan_summary_tmp.json" "$OUT/pr_scan_summary.json" echo "📊 Scan Summary:" echo "- Total recipes: $((RECIPE_NUM - 1))" echo "- Failed scans: $FAILED_SCANS" echo "- Blocked recipes: $BLOCKED_RECIPES" echo "- Overall status: $OVERALL_STATUS" - name: Upload scan artifacts if: always() && steps.find_recipes.outputs.has_recipes == 'true' && steps.recipe_changes.outputs.recipe_files_changed == 'true' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: security-scan path: ${{ runner.temp }}/security-scan/** if-no-files-found: warn retention-days: 10 - name: Post scan results to PR if: always() && steps.find_recipes.outputs.has_recipes == 'true' && steps.recipe_changes.outputs.recipe_files_changed == 'true' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: WORKSPACE: ${{ github.workspace }} RUNNER_TEMP: ${{ runner.temp }} with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const fs = require('fs'); const path = require('path'); const tempDir = process.env.RUNNER_TEMP; const outDir = path.join(tempDir, 'security-scan'); // Read PR scan summary const summaryPath = path.join(outDir, 'pr_scan_summary.json'); let summary = { overall_status: 'UNKNOWN', failed_scans: 0, blocked_recipes: 0 }; try { if (fs.existsSync(summaryPath)) { summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8')); } } catch (e) { console.log('Could not read PR scan summary:', e.message); } // Build comment based on overall results let commentLines = ['🔍 **Recipe Security Scan Results**', '']; if (summary.overall_status === 'APPROVED') { commentLines.push('✅ **Status: APPROVED** - All recipes passed security scan'); } else if (summary.overall_status === 'BLOCKED') { commentLines.push('❌ **Status: BLOCKED** - One or more recipes have MEDIUM risk or higher'); commentLines.push(''); commentLines.push('âš ī¸ **Merge Protection**: This PR cannot be merged until security concerns are addressed.'); commentLines.push('Repository maintainers can override this decision if needed.'); } else if (summary.overall_status === 'SCAN_FAILED') { commentLines.push('âš ī¸ **Status: SCAN FAILED** - Technical issues during scanning'); } else { commentLines.push('❓ **Status: UNKNOWN** - Could not determine scan results'); } commentLines.push(''); // Add summary stats const recipeFiles = fs.readdirSync(outDir).filter(name => name.startsWith('recipe-')); commentLines.push(`📊 **Scan Summary:**`); commentLines.push(`- Total recipes scanned: ${recipeFiles.length}`); if (summary.blocked_recipes > 0) { commentLines.push(`- Blocked recipes: ${summary.blocked_recipes}`); } if (summary.failed_scans > 0) { commentLines.push(`- Failed scans: ${summary.failed_scans}`); } // Add individual recipe results if (recipeFiles.length > 0) { commentLines.push('', '📋 **Individual Recipe Results:**'); recipeFiles.forEach((recipeDir, index) => { const recipePath = path.join(outDir, recipeDir); const statusPath = path.join(recipePath, 'scan_status.json'); let status = 'UNKNOWN'; let risk = 'UNKNOWN'; try { if (fs.existsSync(statusPath)) { const statusData = JSON.parse(fs.readFileSync(statusPath, 'utf8')); status = statusData.status || 'UNKNOWN'; risk = statusData.risk_level || 'UNKNOWN'; } } catch (e) { status = 'SCAN_ERROR'; } const statusEmoji = status === 'APPROVED' ? '✅' : status === 'BLOCKED' ? '❌' : status === 'ALLOWED_WITH_WARNINGS' ? 'âš ī¸' : '❓'; commentLines.push(`${statusEmoji} Recipe ${index + 1}: ${status} (${risk} risk)`); }); } commentLines.push('', `🔗 **View detailed scan results in the [workflow artifacts](https://github.com/${context.repo.owner}/${context.repo.repo}/actions).**`); const comment = commentLines.join('\n'); await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.payload.pull_request.number, body: comment }); - name: Set GitHub status check if: always() && steps.find_recipes.outputs.has_recipes == 'true' && steps.recipe_changes.outputs.recipe_files_changed == 'true' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: RUNNER_TEMP: ${{ runner.temp }} with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const fs = require('fs'); const path = require('path'); const tempDir = process.env.RUNNER_TEMP; const outDir = path.join(tempDir, 'security-scan'); // Read PR scan summary const summaryPath = path.join(outDir, 'pr_scan_summary.json'); let summary = { overall_status: 'UNKNOWN' }; try { if (fs.existsSync(summaryPath)) { summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8')); } } catch (e) { console.log('Could not read PR scan summary:', e.message); } // Determine GitHub status let state, description; if (summary.overall_status === 'APPROVED') { state = 'success'; description = 'All recipes passed security scan'; } else if (summary.overall_status === 'BLOCKED') { state = 'failure'; description = 'One or more recipes failed security scan'; } else if (summary.overall_status === 'SCAN_FAILED') { state = 'error'; description = 'Technical issues during security scan'; } else { state = 'error'; description = 'Could not determine scan results'; } // Set status check await github.rest.repos.createCommitStatus({ owner: context.repo.owner, repo: context.repo.repo, sha: context.payload.pull_request.head.sha, state: state, target_url: `${context.payload.pull_request.html_url}/checks`, description: description, context: 'security-scan/recipe-scanner' }); - name: Final scan result if: always() run: | # Check if recipe files were changed in this push if [ "${{ steps.recipe_changes.outputs.recipe_files_changed }}" = "false" ]; then # No recipe files were modified in this push - scan skipped exit 0 fi OUT="$RUNNER_TEMP/security-scan" SUMMARY_FILE="$OUT/pr_scan_summary.json" if [ -f "$SUMMARY_FILE" ]; then OVERALL_STATUS=$(jq -r .overall_status "$SUMMARY_FILE") echo "📊 Final scan result: $OVERALL_STATUS" if [ "$OVERALL_STATUS" = "BLOCKED" ]; then echo "::error::One or more recipes have MEDIUM risk or higher - PR merge blocked" echo "Repository maintainers can override this decision if needed" exit 1 elif [ "$OVERALL_STATUS" = "APPROVED" ]; then echo "::notice::All recipes APPROVED by security scan" else echo "::error::Scan did not complete successfully - check artifacts for details" exit 1 fi else 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.' );