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:
Ebony Louis
2025-08-28 18:37:09 -04:00
committed by GitHub
parent 1d0c08b3d3
commit 69bc978d00
15 changed files with 2557 additions and 168 deletions
@@ -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