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: security-scan: runs-on: ubuntu-latest steps: - name: Harden Runner uses: step-security/harden-runner@c6295a65d1254861815972266d5933fd6e532bdf # v2.11.1 with: egress-policy: audit - name: Checkout PR uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 - name: Check if recipe files changed in this push id: recipe_changes 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' 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 echo "$RECIPE_FILES" > "$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@1583c0f09d26c58c59d25b0eef29792b7ce99d9a - 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: | docker buildx build \ --pull \ --no-cache \ --load \ --platform linux/amd64 \ -t "recipe-scanner:${IMAGE_TAG}" \ -f recipe-scanner/Dockerfile \ 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@v4 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@v7 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@v7 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