goose/.github/workflows/recipe-security-scanner.yml

412 lines
18 KiB
YAML
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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