mirror of
https://github.com/block/goose.git
synced 2026-04-29 03:59:36 +00:00
412 lines
18 KiB
YAML
412 lines
18 KiB
YAML
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
|