--- name: Skill Security Audit 'on': pull_request: types: [opened, synchronize, reopened] paths: - 'engineering/**' - 'engineering-team/**' - 'business-growth/**' - 'c-level-advisor/**' - 'documentation/**' - 'finance/**' - 'marketing-skill/**' - 'product-team/**' - 'project-management/**' - 'ra-qm-team/**' - 'agents/**' - 'templates/**' concurrency: group: security-audit-${{ github.event.pull_request.number }} cancel-in-progress: true jobs: detect-changes: name: Detect changed skills runs-on: ubuntu-latest permissions: contents: read outputs: skills: ${{ steps.find.outputs.skills }} has_skills: ${{ steps.find.outputs.has_skills }} steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Find changed skill directories id: find run: | # Get list of changed files in PR CHANGED=$(git diff --name-only origin/${{ github.base_ref }}...HEAD 2>/dev/null || echo "") if [ -z "$CHANGED" ]; then echo "skills=[]" >> "$GITHUB_OUTPUT" echo "has_skills=false" >> "$GITHUB_OUTPUT" exit 0 fi # Extract unique skill root directories (top-level dirs containing SKILL.md) SKILLS=() SEEN=() while IFS= read -r file; do # Get the top-level directory dir=$(echo "$file" | cut -d'/' -f1-2) # Skip non-skill paths case "$dir" in .github/*|.claude/*|.codex/*|.gemini/*|docs/*|scripts/*|commands/*|standards/*|eval-workspace/*) continue ;; esac # Check if this directory has a SKILL.md (is a skill) skill_root=$(echo "$file" | cut -d'/' -f1) # Walk up to find the skill root that contains SKILL.md for candidate in "$skill_root" "$dir"; do if [ -f "$candidate/SKILL.md" ] && [[ ! " ${SEEN[*]} " =~ " $candidate " ]]; then SKILLS+=("$candidate") SEEN+=("$candidate") break fi done done <<< "$CHANGED" if [ ${#SKILLS[@]} -eq 0 ]; then echo "skills=[]" >> "$GITHUB_OUTPUT" echo "has_skills=false" >> "$GITHUB_OUTPUT" else # Build JSON array JSON="[" for i in "${!SKILLS[@]}"; do [ $i -gt 0 ] && JSON+="," JSON+="\"${SKILLS[$i]}\"" done JSON+="]" echo "skills=$JSON" >> "$GITHUB_OUTPUT" echo "has_skills=true" >> "$GITHUB_OUTPUT" echo "Changed skills: $JSON" fi audit: name: Security audit needs: detect-changes if: needs.detect-changes.outputs.has_skills == 'true' runs-on: ubuntu-latest permissions: contents: read pull-requests: write steps: - name: Checkout uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.11' - name: Run security auditor on changed skills id: audit run: | AUDITOR="engineering/skill-security-auditor/scripts/skill_security_auditor.py" SKILLS='${{ needs.detect-changes.outputs.skills }}' REPORT_FILE=$(mktemp) OVERALL_EXIT=0 echo "## 🔒 Skill Security Audit Results" > "$REPORT_FILE" echo "" >> "$REPORT_FILE" # Parse JSON array of skill dirs for skill_dir in $(echo "$SKILLS" | python3 -c "import sys,json; [print(s) for s in json.load(sys.stdin)]"); do echo "::group::Auditing $skill_dir" echo "Scanning: $skill_dir" # Run auditor in strict mode with JSON output JSON_OUT=$(python3 "$AUDITOR" "$skill_dir" --strict --json 2>&1) && EXIT_CODE=$? || EXIT_CODE=$? # Try to parse JSON output VERDICT=$(echo "$JSON_OUT" | python3 -c " import sys, json try: d = json.load(sys.stdin) v = d.get('verdict', 'UNKNOWN') c = d.get('summary', {}).get('critical', 0) h = d.get('summary', {}).get('high', 0) i = d.get('summary', {}).get('info', 0) t = d.get('summary', {}).get('total', 0) print(f'{v}|{c}|{h}|{i}|{t}') except: print('ERROR|0|0|0|0') " 2>/dev/null || echo "ERROR|0|0|0|0") IFS='|' read -r V CRIT HIGH INFO TOTAL <<< "$VERDICT" # Map verdict to emoji case "$V" in PASS) ICON="✅" ;; WARN) ICON="⚠️" ;; FAIL) ICON="❌"; OVERALL_EXIT=1 ;; *) ICON="❓"; OVERALL_EXIT=1 ;; esac echo "### $ICON \`$skill_dir\` — $V" >> "$REPORT_FILE" echo "" >> "$REPORT_FILE" if [ "$TOTAL" -gt 0 ]; then echo "| Severity | Count |" >> "$REPORT_FILE" echo "|----------|-------|" >> "$REPORT_FILE" [ "$CRIT" -gt 0 ] && echo "| 🔴 Critical | $CRIT |" >> "$REPORT_FILE" [ "$HIGH" -gt 0 ] && echo "| 🟡 High | $HIGH |" >> "$REPORT_FILE" [ "$INFO" -gt 0 ] && echo "| ⚪ Info | $INFO |" >> "$REPORT_FILE" echo "" >> "$REPORT_FILE" # Include finding details for WARN/FAIL if [ "$V" != "PASS" ]; then echo "
Findings detail" >> "$REPORT_FILE" echo "" >> "$REPORT_FILE" echo '```json' >> "$REPORT_FILE" echo "$JSON_OUT" | python3 -c " import sys, json try: d = json.load(sys.stdin) findings = d.get('findings', []) for f in findings: if f.get('severity') in ('CRITICAL', 'HIGH'): print(json.dumps(f, indent=2)) except: pass " >> "$REPORT_FILE" echo '```' >> "$REPORT_FILE" echo "
" >> "$REPORT_FILE" echo "" >> "$REPORT_FILE" fi else echo "No findings." >> "$REPORT_FILE" echo "" >> "$REPORT_FILE" fi echo "::endgroup::" done # Save report for comment step echo "report_file=$REPORT_FILE" >> "$GITHUB_OUTPUT" echo "exit_code=$OVERALL_EXIT" >> "$GITHUB_OUTPUT" - name: Post audit results to job summary if: always() run: | REPORT_FILE="${{ steps.audit.outputs.report_file }}" if [ -f "$REPORT_FILE" ]; then cat "$REPORT_FILE" >> "$GITHUB_STEP_SUMMARY" fi - name: Post audit results as PR comment if: always() continue-on-error: true # Fork PRs have read-only GITHUB_TOKEN uses: actions/github-script@v7 with: script: | const fs = require('fs'); const reportFile = '${{ steps.audit.outputs.report_file }}'; let body = '## 🔒 Skill Security Audit\n\nNo report generated.'; try { body = fs.readFileSync(reportFile, 'utf8'); } catch (e) { console.log('Could not read report file:', e.message); } // Find and update existing comment or create new const { data: comments } = await github.rest.issues.listComments({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, }); const marker = '## 🔒 Skill Security Audit'; const existing = comments.find(c => c.body.startsWith(marker)); if (existing) { await github.rest.issues.updateComment({ owner: context.repo.owner, repo: context.repo.repo, comment_id: existing.id, body: body, }); } else { await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, body: body, }); } - name: Fail on critical findings if: steps.audit.outputs.exit_code == '1' run: | echo "::error::Security audit found CRITICAL findings. Merge blocked." exit 1