Skip to content

Vulnerability Scanner #26

Vulnerability Scanner

Vulnerability Scanner #26

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