Vulnerability Scanner #15
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: Vulnerability Scanner | |
| # Scheduled npm audit scan. Release scripts use scripts/lib/check-audit-gate.sh (strict allowlist). | |
| # See docs/development/NPM-AUDIT-ALLOWLIST.md for allowlist policy and investigation steps. | |
| on: | |
| schedule: | |
| # Run twice daily at 9 AM and 9 PM UTC | |
| - cron: "0 9,21 * * *" | |
| workflow_dispatch: | |
| # Allow manual triggering anytime | |
| jobs: | |
| scan-vulnerabilities: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v6 | |
| with: | |
| node-version: "24" | |
| cache: "npm" | |
| - name: Pin npm version | |
| run: npm install -g npm@11.13.0 | |
| - name: Install dependencies | |
| run: | | |
| set -euo pipefail | |
| for attempt in 1 2 3 4 5; do | |
| if npm ci; then | |
| break | |
| fi | |
| echo "npm ci failed (attempt $attempt/5); retrying in 15s..." >&2 | |
| sleep 15 | |
| if [[ "$attempt" -eq 5 ]]; then | |
| exit 1 | |
| fi | |
| done | |
| - name: Run npm audit --omit=dev | |
| id: audit | |
| continue-on-error: true | |
| run: | | |
| echo "🔍 Scanning for vulnerabilities..." | |
| # Run npm audit and save output | |
| npm audit --omit=dev --json > audit-output.json || true | |
| # Check if any vulnerabilities found | |
| VULN_COUNT=$(jq '.metadata.vulnerabilities.total // 0' audit-output.json) | |
| # Always count by severity (for fail threshold: above low) | |
| CRITICAL=$(jq '.metadata.vulnerabilities.critical // 0' audit-output.json) | |
| HIGH=$(jq '.metadata.vulnerabilities.high // 0' audit-output.json) | |
| MODERATE=$(jq '.metadata.vulnerabilities.moderate // 0' audit-output.json) | |
| LOW=$(jq '.metadata.vulnerabilities.low // 0' audit-output.json) | |
| ABOVE_LOW=$((CRITICAL + HIGH + MODERATE)) | |
| echo "found_vulnerabilities=$VULN_COUNT" >> $GITHUB_OUTPUT | |
| echo "above_low_count=$ABOVE_LOW" >> $GITHUB_OUTPUT | |
| echo "critical=$CRITICAL" >> $GITHUB_OUTPUT | |
| echo "high=$HIGH" >> $GITHUB_OUTPUT | |
| echo "moderate=$MODERATE" >> $GITHUB_OUTPUT | |
| echo "low=$LOW" >> $GITHUB_OUTPUT | |
| if [ "$VULN_COUNT" -gt 0 ]; then | |
| echo "⚠️ Found $VULN_COUNT total vulnerabilities (fail only if above low: $ABOVE_LOW)" | |
| echo " 🔴 Critical: $CRITICAL" | |
| echo " 🟠 High: $HIGH" | |
| echo " 🟡 Moderate: $MODERATE" | |
| echo " 🟢 Low: $LOW" | |
| else | |
| echo "✅ No vulnerabilities found" | |
| fi | |
| - name: Fail if vulnerabilities above low | |
| if: steps.audit.outputs.above_low_count != '0' | |
| run: | | |
| echo "❌ Failing: ${{ steps.audit.outputs.above_low_count }} vulnerability/vulnerabilities with severity above low (critical/high/moderate)" | |
| echo "See docs/development/NPM-AUDIT-ALLOWLIST.md and scripts/lib/check-audit-gate.sh (release gate)." | |
| exit 1 | |
| - name: Parse vulnerabilities | |
| id: parse | |
| if: steps.audit.outputs.found_vulnerabilities != '0' | |
| run: | | |
| echo "📊 Parsing vulnerability data..." | |
| # Extract unique advisories (grouped by GHSA/CVE) | |
| jq -r '.vulnerabilities | to_entries | .[] | | |
| .value.via[] | | |
| select(type == "object") | | |
| { | |
| title: .title, | |
| url: .url, | |
| severity: .severity, | |
| cve: (.cve // []), | |
| ghsa: (.source // 0), | |
| package_name: .name, | |
| range: .range, | |
| recommendation: (.fixAvailable // "No fix available") | |
| }' audit-output.json | jq -s 'unique_by(.url)' > unique-advisories.json | |
| # Count unique advisories | |
| ADVISORY_COUNT=$(jq '. | length' unique-advisories.json) | |
| echo "unique_advisories=$ADVISORY_COUNT" >> $GITHUB_OUTPUT | |
| echo "✅ Found $ADVISORY_COUNT unique advisories" | |
| - name: Check for existing issues and PRs | |
| id: check-existing | |
| if: steps.audit.outputs.found_vulnerabilities != '0' | |
| run: | | |
| echo "🔍 Checking for existing issues and Dependabot PRs..." | |
| # Get all open security issues | |
| gh issue list --label "security" --state open --json number,title,body --limit 100 > existing-issues.json | |
| # Get all open Dependabot PRs | |
| gh pr list --author "app/dependabot" --state open --json number,title,url,body --limit 100 > dependabot-prs.json | |
| echo "✅ Retrieved existing issues and PRs" | |
| - name: Create issues for new vulnerabilities | |
| id: create-issues | |
| if: steps.audit.outputs.found_vulnerabilities != '0' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| echo "📝 Processing vulnerabilities..." | |
| CREATED_COUNT=0 | |
| SKIPPED_COUNT=0 | |
| ISSUES_CREATED="" | |
| # Read each unique advisory | |
| jq -c '.[]' unique-advisories.json | while read -r advisory; do | |
| TITLE=$(echo "$advisory" | jq -r '.title') | |
| URL=$(echo "$advisory" | jq -r '.url') | |
| SEVERITY=$(echo "$advisory" | jq -r '.severity') | |
| CVE=$(echo "$advisory" | jq -r '.cve[]? // empty' | head -1) | |
| GHSA=$(echo "$advisory" | jq -r '.ghsa') | |
| PACKAGE=$(echo "$advisory" | jq -r '.package_name') | |
| RANGE=$(echo "$advisory" | jq -r '.range') | |
| # Extract GHSA ID from URL if not in data | |
| if [ -z "$GHSA" ] || [ "$GHSA" = "0" ]; then | |
| GHSA=$(echo "$URL" | grep -oE 'GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}' || echo "") | |
| fi | |
| # Check if issue already exists (search for GHSA or CVE in existing issues) | |
| SEARCH_TERM="$GHSA" | |
| if [ -z "$GHSA" ] && [ -n "$CVE" ]; then | |
| SEARCH_TERM="$CVE" | |
| fi | |
| if [ -n "$SEARCH_TERM" ]; then | |
| EXISTS=$(jq --arg term "$SEARCH_TERM" '[.[] | select(.title | contains($term) or (.body | contains($term)))] | length' existing-issues.json) | |
| if [ "$EXISTS" -gt 0 ]; then | |
| echo "⏭️ Skipping: Issue already exists for $SEARCH_TERM" | |
| SKIPPED_COUNT=$((SKIPPED_COUNT + 1)) | |
| continue | |
| fi | |
| fi | |
| # Check if Dependabot already created a PR for this vulnerability | |
| DEPENDABOT_PR="" | |
| if [ -n "$PACKAGE" ]; then | |
| DEPENDABOT_PR=$(jq -r --arg pkg "$PACKAGE" '[.[] | select(.title | contains($pkg))] | .[0].url // empty' dependabot-prs.json) | |
| fi | |
| # Format severity with emoji | |
| case "$SEVERITY" in | |
| critical) | |
| SEVERITY_EMOJI="🔴" | |
| PRIORITY_LABEL="priority:critical" | |
| ;; | |
| high) | |
| SEVERITY_EMOJI="🟠" | |
| PRIORITY_LABEL="priority:high" | |
| ;; | |
| moderate) | |
| SEVERITY_EMOJI="🟡" | |
| PRIORITY_LABEL="priority:medium" | |
| ;; | |
| low) | |
| SEVERITY_EMOJI="🟢" | |
| PRIORITY_LABEL="priority:low" | |
| ;; | |
| *) | |
| SEVERITY_EMOJI="⚪" | |
| PRIORITY_LABEL="" | |
| ;; | |
| esac | |
| # Build issue body - use printf to avoid YAML parsing issues with markdown | |
| printf -v BODY '%s' \ | |
| "## Security Vulnerability" $'\n\n' \ | |
| "**Severity**: ${SEVERITY_EMOJI} ${SEVERITY}" $'\n' \ | |
| "**Package**: \`${PACKAGE}\`" $'\n' \ | |
| "**Affected Versions**: ${RANGE}" $'\n' | |
| if [ -n "$CVE" ]; then | |
| printf -v BODY '%s' "$BODY" "**CVE**: ${CVE}" $'\n' | |
| fi | |
| if [ -n "$GHSA" ]; then | |
| printf -v BODY '%s' "$BODY" "**GHSA**: ${GHSA}" $'\n' | |
| fi | |
| printf -v BODY '%s' "$BODY" $'\n' \ | |
| "### Description" $'\n' \ | |
| "${TITLE}" $'\n\n' \ | |
| "### References" $'\n' \ | |
| "- [GitHub Advisory](${URL})" $'\n' | |
| if [ -n "$CVE" ]; then | |
| printf -v BODY '%s' "$BODY" "- [CVE Details](https://nvd.nist.gov/vuln/detail/${CVE})" $'\n' | |
| fi | |
| if [ -n "$DEPENDABOT_PR" ]; then | |
| printf -v BODY '%s' "$BODY" $'\n' \ | |
| "### Dependabot PR" $'\n' \ | |
| "Dependabot has already created a PR to fix this vulnerability:" $'\n' \ | |
| "- ${DEPENDABOT_PR}" $'\n\n' \ | |
| "Review and merge the PR to resolve this issue." $'\n' | |
| fi | |
| printf -v BODY '%s' "$BODY" $'\n' \ | |
| "### Action Required" $'\n' \ | |
| "- [ ] Review vulnerability details" $'\n' \ | |
| "- [ ] " | |
| if [ -n "$DEPENDABOT_PR" ]; then | |
| printf -v BODY '%s' "$BODY" "Review and merge Dependabot PR" | |
| else | |
| printf -v BODY '%s' "$BODY" "Update \`${PACKAGE}\` to a patched version" | |
| fi | |
| printf -v BODY '%s' "$BODY" $'\n' \ | |
| "- [ ] Test changes" $'\n' \ | |
| "- [ ] Deploy fix" $'\n\n' \ | |
| "---" $'\n' \ | |
| "*Automatically created by vulnerability scanner*" | |
| # Build issue title | |
| if [ -n "$CVE" ]; then | |
| ISSUE_TITLE="Security: ${CVE} - ${SEVERITY} severity in ${PACKAGE}" | |
| elif [ -n "$GHSA" ]; then | |
| ISSUE_TITLE="Security: ${GHSA} - ${SEVERITY} severity in ${PACKAGE}" | |
| else | |
| ISSUE_TITLE="Security: ${SEVERITY} severity in ${PACKAGE}" | |
| fi | |
| # Build labels | |
| LABELS="security,dependencies" | |
| if [ -n "$PRIORITY_LABEL" ]; then | |
| LABELS="${LABELS},${PRIORITY_LABEL}" | |
| fi | |
| echo "📝 Creating issue: $ISSUE_TITLE" | |
| # Create issue | |
| ISSUE_URL=$(gh issue create \ | |
| --title "$ISSUE_TITLE" \ | |
| --body "$BODY" \ | |
| --label "$LABELS" \ | |
| --json url --jq '.url') | |
| CREATED_COUNT=$((CREATED_COUNT + 1)) | |
| ISSUES_CREATED="${ISSUES_CREATED}${ISSUE_URL}"$'\n' | |
| echo "✅ Created: $ISSUE_URL" | |
| # Rate limit protection - wait 2 seconds between issue creations | |
| sleep 2 | |
| done | |
| echo "created_count=$CREATED_COUNT" >> $GITHUB_OUTPUT | |
| echo "skipped_count=$SKIPPED_COUNT" >> $GITHUB_OUTPUT | |
| echo "issues_created<<EOF" >> $GITHUB_OUTPUT | |
| echo "$ISSUES_CREATED" >> $GITHUB_OUTPUT | |
| echo "EOF" >> $GITHUB_OUTPUT | |
| - name: Summary | |
| if: always() | |
| run: | | |
| echo "# 🛡️ Vulnerability Scanner Summary" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| if [ "${{ steps.audit.outputs.found_vulnerabilities }}" == "0" ]; then | |
| echo "✅ **No vulnerabilities found!**" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "All dependencies are secure." >> $GITHUB_STEP_SUMMARY | |
| else | |
| echo "## Vulnerabilities Found" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "| Severity | Count |" >> $GITHUB_STEP_SUMMARY | |
| echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY | |
| echo "| 🔴 Critical | ${{ steps.audit.outputs.critical }} |" >> $GITHUB_STEP_SUMMARY | |
| echo "| 🟠 High | ${{ steps.audit.outputs.high }} |" >> $GITHUB_STEP_SUMMARY | |
| echo "| 🟡 Moderate | ${{ steps.audit.outputs.moderate }} |" >> $GITHUB_STEP_SUMMARY | |
| echo "| 🟢 Low | ${{ steps.audit.outputs.low }} |" >> $GITHUB_STEP_SUMMARY | |
| echo "| **Total** | **${{ steps.audit.outputs.found_vulnerabilities }}** |" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| if [ -n "${{ steps.parse.outputs.unique_advisories }}" ]; then | |
| echo "**Unique advisories**: ${{ steps.parse.outputs.unique_advisories }}" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| fi | |
| if [ -n "${{ steps.create-issues.outputs.created_count }}" ]; then | |
| echo "## Issues Created" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "✅ **Created**: ${{ steps.create-issues.outputs.created_count }} new security issues" >> $GITHUB_STEP_SUMMARY | |
| if [ "${{ steps.create-issues.outputs.skipped_count }}" != "0" ]; then | |
| echo "⏭️ **Skipped**: ${{ steps.create-issues.outputs.skipped_count }} (already tracked)" >> $GITHUB_STEP_SUMMARY | |
| fi | |
| if [ -n "${{ steps.create-issues.outputs.issues_created }}" ]; then | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "### New Issues" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "${{ steps.create-issues.outputs.issues_created }}" | while read -r url; do | |
| if [ -n "$url" ]; then | |
| echo "- $url" >> $GITHUB_STEP_SUMMARY | |
| fi | |
| done | |
| fi | |
| fi | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "## Next Steps" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "1. Review the created issues in the [Issues tab](https://github.com/${{ github.repository }}/issues?q=is%3Aissue+is%3Aopen+label%3Asecurity)" >> $GITHUB_STEP_SUMMARY | |
| echo "2. Check for Dependabot PRs that may already fix some vulnerabilities" >> $GITHUB_STEP_SUMMARY | |
| echo "3. Prioritize critical and high severity vulnerabilities" >> $GITHUB_STEP_SUMMARY | |
| echo "4. Update affected packages and test changes" >> $GITHUB_STEP_SUMMARY | |
| echo "5. Release allowlist policy: [NPM-AUDIT-ALLOWLIST.md](docs/development/NPM-AUDIT-ALLOWLIST.md) (strict mode in bump-version / promote scripts)" >> $GITHUB_STEP_SUMMARY | |
| fi | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "---" >> $GITHUB_STEP_SUMMARY | |
| echo "*Scan completed at $(date -u '+%Y-%m-%d %H:%M:%S UTC')*" >> $GITHUB_STEP_SUMMARY |