chore(security-scan): First version of the security scan workflow #10
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Skill Security Scan | |
| on: | |
| pull_request: | |
| types: [opened, synchronize, reopened, ready_for_review] | |
| paths: | |
| - '**/skills/**' | |
| - '**/mcps.json' | |
| workflow_dispatch: | |
| inputs: | |
| pr_number: | |
| description: 'PR number to review' | |
| required: true | |
| type: number | |
| scan_all: | |
| description: 'Scan all packs (true) or changed only (false)' | |
| required: false | |
| default: 'false' | |
| type: choice | |
| options: | |
| - 'true' | |
| - 'false' | |
| concurrency: | |
| group: security-scan-${{ github.event.pull_request.number || github.run_id }} | |
| cancel-in-progress: true | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| checks: read | |
| jobs: | |
| security-scan: | |
| if: github.event.pull_request.draft == false || github.event_name == 'workflow_dispatch' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Resolve PR number | |
| id: pr | |
| run: | | |
| if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then | |
| echo "number=${{ inputs.pr_number }}" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "number=${{ github.event.pull_request.number }}" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Wait for prerequisite checks | |
| if: github.event_name == 'pull_request' | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const requiredChecks = ['compliance-check', 'skill-linter']; | |
| const sha = context.payload.pull_request.head.sha; | |
| const maxWait = 300000; // 5 minutes | |
| const interval = 15000; // 15 seconds | |
| let elapsed = 0; | |
| core.info(`Waiting for checks on ${sha}: ${requiredChecks.join(', ')}`); | |
| while (elapsed < maxWait) { | |
| const { data: { check_runs } } = await github.rest.checks.listForRef({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| ref: sha, | |
| }); | |
| const results = requiredChecks.map(name => { | |
| const run = check_runs.find(r => r.name === name); | |
| return { name, status: run?.status, conclusion: run?.conclusion }; | |
| }); | |
| const allCompleted = results.every(r => r.status === 'completed'); | |
| if (allCompleted) { | |
| const failed = results.filter(r => r.conclusion !== 'success'); | |
| if (failed.length > 0) { | |
| const summary = failed.map(r => `${r.name}: ${r.conclusion}`).join(', '); | |
| core.setFailed(`Prerequisite checks failed (${summary}) — skipping security scan to save tokens`); | |
| return; | |
| } | |
| core.info('All prerequisite checks passed — proceeding with security scan'); | |
| return; | |
| } | |
| const pending = results.filter(r => r.status !== 'completed').map(r => r.name); | |
| core.info(`Waiting for: ${pending.join(', ')} (${elapsed / 1000}s elapsed)`); | |
| await new Promise(r => setTimeout(r, interval)); | |
| elapsed += interval; | |
| } | |
| core.setFailed('Timed out waiting for prerequisite checks — skipping security scan'); | |
| - name: Set up uv | |
| uses: astral-sh/setup-uv@v7 | |
| with: | |
| enable-cache: true | |
| - name: Set up Python | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.11' | |
| - name: Install skill-scanner | |
| run: uv pip install --system 'cisco-ai-skill-scanner[google]' | |
| - name: Detect changed packs | |
| id: detect | |
| run: | | |
| if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ github.event.inputs.scan_all }}" = "true" ]; then | |
| PACKS=$(find . -maxdepth 2 -name skills -type d | sed 's|^\./||;s|/skills$||' | sort) | |
| else | |
| PACKS=$(bash scripts/detect-changed-packs.sh || true) | |
| fi | |
| if [ -z "$PACKS" ]; then | |
| echo "changed=false" >> $GITHUB_OUTPUT | |
| echo "packs=" >> $GITHUB_OUTPUT | |
| echo "No packs with changes detected — skipping security scan" | |
| else | |
| echo "changed=true" >> $GITHUB_OUTPUT | |
| echo "packs<<EOF" >> $GITHUB_OUTPUT | |
| echo "$PACKS" >> $GITHUB_OUTPUT | |
| echo "EOF" >> $GITHUB_OUTPUT | |
| echo "Changed packs detected:" | |
| echo "$PACKS" | |
| fi | |
| - name: Run security scan | |
| if: steps.detect.outputs.changed == 'true' | |
| id: scan | |
| env: | |
| SKILL_SCANNER_LLM_API_KEY: ${{ secrets.SKILL_SCANNER_LLM_API_KEY }} | |
| SKILL_SCANNER_LLM_MODEL: ${{ secrets.SKILL_SCANNER_LLM_MODEL }} | |
| run: | | |
| mkdir -p security-reports | |
| SCAN_FAILED=false | |
| while IFS= read -r pack; do | |
| [ -z "$pack" ] && continue | |
| echo "=== Scanning: $pack ===" | |
| skill-scanner scan-all "$pack/skills" \ | |
| --recursive \ | |
| --use-behavioral \ | |
| --use-llm \ | |
| --check-overlap \ | |
| --enable-meta \ | |
| --fail-on-severity medium \ | |
| --format markdown \ | |
| --detailed \ | |
| --output "security-reports/security-report-${pack}.md" || SCAN_FAILED=true | |
| echo "" | |
| done <<< "${{ steps.detect.outputs.packs }}" | |
| if [ "$SCAN_FAILED" = "true" ]; then | |
| echo "scan_result=failed" >> $GITHUB_OUTPUT | |
| else | |
| echo "scan_result=passed" >> $GITHUB_OUTPUT | |
| fi | |
| - name: Upload security reports | |
| id: upload | |
| if: steps.detect.outputs.changed == 'true' && always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: security-reports | |
| path: security-reports/ | |
| retention-days: 30 | |
| if-no-files-found: ignore | |
| - name: Post scan summary | |
| if: steps.detect.outputs.changed == 'true' && always() | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const prNumber = ${{ steps.pr.outputs.number }}; | |
| const scanResult = '${{ steps.scan.outputs.scan_result }}'; | |
| const icon = scanResult === 'passed' ? '✅' : '❌'; | |
| let body = `## ${icon} Skill Security Scan\n\n`; | |
| if (fs.existsSync('security-reports')) { | |
| const reports = fs.readdirSync('security-reports').filter(f => f.endsWith('.md')); | |
| if (reports.length === 0) { | |
| body += 'No reports generated.\n'; | |
| } else { | |
| for (const report of reports) { | |
| const pack = report.replace('security-report-', '').replace('.md', ''); | |
| const content = fs.readFileSync(`security-reports/${report}`, 'utf8'); | |
| body += `<details>\n<summary>📋 ${pack}</summary>\n\n${content}\n\n</details>\n\n`; | |
| } | |
| } | |
| } else { | |
| body += 'No reports generated.\n'; | |
| } | |
| const artifactUrl = '${{ steps.upload.outputs.artifact-url }}'; | |
| if (artifactUrl) { | |
| body += `\n\n> 📦 [Download security reports](${artifactUrl}) | [Workflow run](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})`; | |
| } else { | |
| body += `\n\n> [Workflow run](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})`; | |
| } | |
| const { data: comments } = await github.rest.issues.listComments({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| }); | |
| const existing = comments.find(c => | |
| c.user.type === 'Bot' && c.body.includes('Skill Security Scan') | |
| ); | |
| if (existing) { | |
| await github.rest.issues.updateComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| comment_id: existing.id, | |
| body, | |
| }); | |
| } else { | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| body, | |
| }); | |
| } | |
| - name: Check scan results | |
| if: steps.detect.outputs.changed == 'true' | |
| run: | | |
| if [ "${{ steps.scan.outputs.scan_result }}" = "failed" ]; then | |
| echo "❌ Security scan found MEDIUM or higher severity issues — blocking merge" | |
| exit 1 | |
| else | |
| echo "✅ Security scan passed — no MEDIUM or higher severity issues found" | |
| fi |