Automate OpenRouter API Key Distribution for External Recipe Contributors (#3198)
Co-authored-by: w. ian douglas <ian.douglas@iandouglas.com>
This commit is contained in:
@@ -0,0 +1,394 @@
|
||||
name: Recipe Security Scan
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
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
|
||||
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
|
||||
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 ${{ github.event.before }}..${{ github.event.after }})
|
||||
else
|
||||
# For opened/reopened, check all files in the PR
|
||||
echo "📝 PR opened/reopened - checking all files in PR"
|
||||
CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}..HEAD)
|
||||
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
|
||||
id: find_recipes
|
||||
if: steps.recipe_changes.outputs.recipe_files_changed == 'true'
|
||||
run: |
|
||||
set -e
|
||||
echo "Looking for recipe files in PR..."
|
||||
|
||||
# Find all .yaml/.yml files in the recipes directory
|
||||
RECIPE_FILES=$(find documentation/src/pages/recipes/data/recipes/ -name "*.yaml" -o -name "*.yml" 2>/dev/null || true)
|
||||
|
||||
if [ -z "$RECIPE_FILES" ]; then
|
||||
echo "No recipe files found in PR"
|
||||
echo "has_recipes=false" >> "$GITHUB_OUTPUT"
|
||||
echo "recipe_count=0" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "Found 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
|
||||
|
||||
# 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
|
||||
Reference in New Issue
Block a user