Vulnerability Scan & Triage #3
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 Scan & Triage | |
| on: | |
| schedule: | |
| # Run daily at 6am Pacific (13:00 UTC during PDT) | |
| - cron: '0 13 * * *' | |
| workflow_dispatch: | |
| inputs: | |
| image_tag: | |
| description: 'Image tag to scan (default: main)' | |
| required: false | |
| default: 'main' | |
| dry_run: | |
| description: 'Dry run (analyze but do not create Linear issues)' | |
| required: false | |
| type: boolean | |
| default: false | |
| force_analysis: | |
| description: 'Force Claude analysis even if no vulnerabilities are found' | |
| required: false | |
| type: boolean | |
| default: false | |
| workflow_call: | |
| inputs: | |
| image: | |
| description: 'Full Docker image to scan with Trivy (e.g., ghcr.io/org/repo). Leave empty to skip Trivy scanning.' | |
| required: false | |
| type: string | |
| default: '' | |
| image_tag: | |
| description: 'Image tag to scan' | |
| required: false | |
| type: string | |
| default: 'main' | |
| dry_run: | |
| required: false | |
| type: boolean | |
| default: false | |
| force_analysis: | |
| required: false | |
| type: boolean | |
| default: false | |
| secrets: | |
| ANTHROPIC_API_KEY: | |
| required: true | |
| LINEAR_API_KEY: | |
| required: true | |
| LINEAR_TEAM_ID: | |
| required: true | |
| DEPENDABOT_PAT: | |
| required: false | |
| env: | |
| IMAGE: ghcr.io/sourcebot-dev/sourcebot | |
| permissions: | |
| contents: read | |
| packages: read | |
| security-events: read # Required for CodeQL alerts API | |
| id-token: write # Required for OIDC authentication | |
| jobs: | |
| scan: | |
| name: Trivy Scan | |
| runs-on: ubuntu-latest | |
| if: github.repository == 'sourcebot-dev/sourcebot' || inputs.image != '' | |
| outputs: | |
| has_vulnerabilities: ${{ steps.check.outputs.has_vulnerabilities }} | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v4 | |
| - name: Log in to GHCR | |
| uses: docker/login-action@v3 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Run Trivy vulnerability scan | |
| uses: aquasecurity/trivy-action@master | |
| with: | |
| image-ref: "${{ inputs.image || env.IMAGE }}:${{ inputs.image_tag || 'main' }}" | |
| format: "json" | |
| output: "trivy-results.json" | |
| trivy-config: trivy.yaml | |
| - name: Check for vulnerabilities | |
| id: check | |
| run: | | |
| VULN_COUNT=$(jq '[.Results[]? | .Vulnerabilities[]?] | length' trivy-results.json) | |
| if [ "$VULN_COUNT" -gt 0 ]; then | |
| echo "has_vulnerabilities=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "has_vulnerabilities=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Upload scan results | |
| if: steps.check.outputs.has_vulnerabilities == 'true' || inputs.force_analysis == true | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: trivy-results | |
| path: trivy-results.json | |
| retention-days: 30 | |
| - name: Write Trivy summary | |
| run: | | |
| echo "## Trivy Scan" >> "$GITHUB_STEP_SUMMARY" | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "**Image:** \`${{ inputs.image || env.IMAGE }}:${{ inputs.image_tag || 'main' }}\`" >> "$GITHUB_STEP_SUMMARY" | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| if [ "${{ steps.check.outputs.has_vulnerabilities }}" = "true" ]; then | |
| VULN_COUNT=$(jq '[.Results[]? | .Vulnerabilities[]?] | length' trivy-results.json) | |
| CRIT_COUNT=$(jq '[.Results[]? | .Vulnerabilities[]? | select(.Severity == "CRITICAL")] | length' trivy-results.json) | |
| HIGH_COUNT=$(jq '[.Results[]? | .Vulnerabilities[]? | select(.Severity == "HIGH")] | length' trivy-results.json) | |
| MED_COUNT=$(jq '[.Results[]? | .Vulnerabilities[]? | select(.Severity == "MEDIUM")] | length' trivy-results.json) | |
| echo "**$VULN_COUNT** vulnerabilities found: **$CRIT_COUNT** critical, **$HIGH_COUNT** high, **$MED_COUNT** medium." >> "$GITHUB_STEP_SUMMARY" | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "| CVE ID | Severity | Package | Installed | Fixed |" >> "$GITHUB_STEP_SUMMARY" | |
| echo "|--------|----------|---------|-----------|-------|" >> "$GITHUB_STEP_SUMMARY" | |
| jq -r '[.Results[]? | .Vulnerabilities[]?] | sort_by(.Severity) | .[] | "| \(.VulnerabilityID) | \(.Severity) | \(.PkgName) | \(.InstalledVersion) | \(.FixedVersion // "N/A") |"' trivy-results.json >> "$GITHUB_STEP_SUMMARY" | |
| else | |
| echo "No vulnerabilities found." >> "$GITHUB_STEP_SUMMARY" | |
| fi | |
| check-alerts: | |
| name: Check Dependabot & CodeQL Alerts | |
| runs-on: ubuntu-latest | |
| outputs: | |
| has_alerts: ${{ steps.check.outputs.has_alerts }} | |
| steps: | |
| - name: Check for open alerts | |
| id: check | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| DEPENDABOT_PAT: ${{ secrets.DEPENDABOT_PAT }} | |
| run: | | |
| HAS_ALERTS=false | |
| # Check Dependabot alerts (requires DEPENDABOT_PAT) | |
| if [ -n "$DEPENDABOT_PAT" ]; then | |
| DEPENDABOT_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ | |
| -H "Accept: application/vnd.github+json" \ | |
| -H "Authorization: Bearer $DEPENDABOT_PAT" \ | |
| "https://api.github.com/repos/${{ github.repository }}/dependabot/alerts?state=open&per_page=1") | |
| if [ "$DEPENDABOT_STATUS" = "200" ]; then | |
| DEPENDABOT_COUNT=$(curl -s \ | |
| -H "Accept: application/vnd.github+json" \ | |
| -H "Authorization: Bearer $DEPENDABOT_PAT" \ | |
| "https://api.github.com/repos/${{ github.repository }}/dependabot/alerts?state=open&per_page=1" | jq 'length') | |
| if [ "$DEPENDABOT_COUNT" -gt 0 ]; then | |
| echo "Found open Dependabot alerts" | |
| HAS_ALERTS=true | |
| fi | |
| else | |
| echo "::warning::Could not fetch Dependabot alerts (HTTP $DEPENDABOT_STATUS). Is DEPENDABOT_PAT configured?" | |
| fi | |
| else | |
| echo "::warning::DEPENDABOT_PAT not configured. Skipping Dependabot alert check." | |
| fi | |
| # Check CodeQL alerts (uses GITHUB_TOKEN with security-events: read) | |
| CODEQL_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ | |
| -H "Accept: application/vnd.github+json" \ | |
| -H "Authorization: Bearer $GH_TOKEN" \ | |
| "https://api.github.com/repos/${{ github.repository }}/code-scanning/alerts?state=open&per_page=1") | |
| if [ "$CODEQL_STATUS" = "200" ]; then | |
| CODEQL_COUNT=$(curl -s \ | |
| -H "Accept: application/vnd.github+json" \ | |
| -H "Authorization: Bearer $GH_TOKEN" \ | |
| "https://api.github.com/repos/${{ github.repository }}/code-scanning/alerts?state=open&per_page=1" | jq 'length') | |
| if [ "$CODEQL_COUNT" -gt 0 ]; then | |
| echo "Found open CodeQL alerts" | |
| HAS_ALERTS=true | |
| fi | |
| elif [ "$CODEQL_STATUS" = "404" ]; then | |
| echo "CodeQL is not enabled for this repository. Skipping." | |
| else | |
| echo "::warning::Could not fetch CodeQL alerts (HTTP $CODEQL_STATUS)" | |
| fi | |
| echo "has_alerts=$HAS_ALERTS" >> "$GITHUB_OUTPUT" | |
| - name: Write alerts summary | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| DEPENDABOT_PAT: ${{ secrets.DEPENDABOT_PAT }} | |
| run: | | |
| echo "## Dependabot & CodeQL Alert Check" >> "$GITHUB_STEP_SUMMARY" | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| # Dependabot status | |
| if [ -z "$DEPENDABOT_PAT" ]; then | |
| echo "### Dependabot" >> "$GITHUB_STEP_SUMMARY" | |
| echo "Skipped (DEPENDABOT_PAT not configured)" >> "$GITHUB_STEP_SUMMARY" | |
| else | |
| DEPENDABOT_RESPONSE=$(curl -s \ | |
| -H "Accept: application/vnd.github+json" \ | |
| -H "Authorization: Bearer $DEPENDABOT_PAT" \ | |
| "https://api.github.com/repos/${{ github.repository }}/dependabot/alerts?state=open&per_page=100") | |
| DEPENDABOT_COUNT=$(echo "$DEPENDABOT_RESPONSE" | jq 'if type == "array" then length else 0 end') | |
| echo "### Dependabot — $DEPENDABOT_COUNT open alert(s)" >> "$GITHUB_STEP_SUMMARY" | |
| if [ "$DEPENDABOT_COUNT" -gt 0 ]; then | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "| CVE / GHSA | Severity | Package | Ecosystem | Patched Version | Link |" >> "$GITHUB_STEP_SUMMARY" | |
| echo "|------------|----------|---------|-----------|-----------------|------|" >> "$GITHUB_STEP_SUMMARY" | |
| echo "$DEPENDABOT_RESPONSE" | jq -r '.[] | "| \(.security_advisory.cve_id // .security_advisory.ghsa_id // "—") | \(.security_advisory.severity // "—") | \(.security_vulnerability.package.name // "—") | \(.security_vulnerability.package.ecosystem // "—") | \(.security_vulnerability.first_patched_version.identifier // "N/A") | [View](\(.html_url)) |"' >> "$GITHUB_STEP_SUMMARY" | |
| fi | |
| fi | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| # CodeQL status | |
| CODEQL_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ | |
| -H "Accept: application/vnd.github+json" \ | |
| -H "Authorization: Bearer $GH_TOKEN" \ | |
| "https://api.github.com/repos/${{ github.repository }}/code-scanning/alerts?state=open&per_page=100") | |
| if [ "$CODEQL_STATUS" = "404" ]; then | |
| echo "### CodeQL" >> "$GITHUB_STEP_SUMMARY" | |
| echo "Not enabled for this repository." >> "$GITHUB_STEP_SUMMARY" | |
| elif [ "$CODEQL_STATUS" = "200" ]; then | |
| CODEQL_RESPONSE=$(curl -s \ | |
| -H "Accept: application/vnd.github+json" \ | |
| -H "Authorization: Bearer $GH_TOKEN" \ | |
| "https://api.github.com/repos/${{ github.repository }}/code-scanning/alerts?state=open&per_page=100") | |
| CODEQL_COUNT=$(echo "$CODEQL_RESPONSE" | jq 'length') | |
| echo "### CodeQL — $CODEQL_COUNT open alert(s)" >> "$GITHUB_STEP_SUMMARY" | |
| if [ "$CODEQL_COUNT" -gt 0 ]; then | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "| Rule ID | Severity | Tool | File | Lines | Link |" >> "$GITHUB_STEP_SUMMARY" | |
| echo "|---------|----------|------|------|-------|------|" >> "$GITHUB_STEP_SUMMARY" | |
| echo "$CODEQL_RESPONSE" | jq -r '.[] | "| \(.rule.id // "—") | \(.rule.security_severity_level // "—") | \(.tool.name // "—") | \(.most_recent_instance.location.path // "—") | \(.most_recent_instance.location.start_line // "—")-\(.most_recent_instance.location.end_line // "—") | [View](\(.html_url)) |"' >> "$GITHUB_STEP_SUMMARY" | |
| fi | |
| else | |
| echo "### CodeQL" >> "$GITHUB_STEP_SUMMARY" | |
| echo "Failed to check (HTTP $CODEQL_STATUS)" >> "$GITHUB_STEP_SUMMARY" | |
| fi | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "**Result:** has_alerts=${{ steps.check.outputs.has_alerts }}" >> "$GITHUB_STEP_SUMMARY" | |
| triage: | |
| name: Claude Analysis & Linear Triage | |
| needs: [scan, check-alerts] | |
| if: >- | |
| always() && !cancelled() && ( | |
| needs.scan.outputs.has_vulnerabilities == 'true' || | |
| needs.check-alerts.outputs.has_alerts == 'true' || | |
| inputs.force_analysis == true | |
| ) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v4 | |
| with: | |
| submodules: recursive | |
| - name: Download scan results | |
| if: needs.scan.outputs.has_vulnerabilities == 'true' | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: trivy-results | |
| - name: Normalize Trivy results | |
| run: | | |
| if [ ! -f trivy-results.json ]; then | |
| echo '{"Results":[]}' > trivy-results.json | |
| fi | |
| jq '[.Results[]? | .Vulnerabilities[]? | { | |
| id: .VulnerabilityID, | |
| severity: .Severity, | |
| pkg_name: .PkgName, | |
| installed_version: .InstalledVersion, | |
| fixed_version: (.FixedVersion // ""), | |
| title: (.Title // ""), | |
| description: (.Description // ""), | |
| references: ([.References[]?] // []) | |
| }]' trivy-results.json > trivy-alerts.json | |
| - name: Fetch Dependabot alerts | |
| env: | |
| DEPENDABOT_PAT: ${{ secrets.DEPENDABOT_PAT }} | |
| run: | | |
| if [ -z "$DEPENDABOT_PAT" ]; then | |
| echo "::warning::DEPENDABOT_PAT not configured. Writing empty Dependabot alerts." | |
| echo "[]" > dependabot-alerts.json | |
| exit 0 | |
| fi | |
| ALL_ALERTS="[]" | |
| URL="https://api.github.com/repos/${{ github.repository }}/dependabot/alerts?state=open&per_page=100" | |
| while [ -n "$URL" ]; do | |
| # Fetch with headers saved to parse Link for cursor pagination | |
| HTTP_CODE=$(curl -s -o /tmp/dependabot-body.json -w "%{http_code}" -D /tmp/dependabot-headers.txt \ | |
| -H "Accept: application/vnd.github+json" \ | |
| -H "Authorization: Bearer $DEPENDABOT_PAT" \ | |
| "$URL") | |
| echo "Dependabot API response: HTTP $HTTP_CODE" | |
| if [ "$HTTP_CODE" != "200" ]; then | |
| echo "::warning::Failed to fetch Dependabot alerts (HTTP $HTTP_CODE). Writing empty results." | |
| echo "Response body: $(cat /tmp/dependabot-body.json | head -c 500)" | |
| echo "[]" > dependabot-alerts.json | |
| exit 0 | |
| fi | |
| BODY=$(cat /tmp/dependabot-body.json) | |
| COUNT=$(echo "$BODY" | jq 'length') | |
| echo "Page returned $COUNT alert(s)" | |
| if [ "$COUNT" -eq 0 ]; then | |
| break | |
| fi | |
| EXTRACTED=$(echo "$BODY" | jq '[.[] | { | |
| id: (.security_advisory.cve_id // .security_advisory.ghsa_id // ""), | |
| cve_id: (.security_advisory.cve_id // null), | |
| ghsa_id: (.security_advisory.ghsa_id // null), | |
| severity: (.security_advisory.severity // "medium"), | |
| summary: (.security_advisory.summary // ""), | |
| description: (.security_advisory.description // ""), | |
| package_name: (.security_vulnerability.package.name // ""), | |
| package_ecosystem: (.security_vulnerability.package.ecosystem // ""), | |
| manifest_path: (.dependency.manifest_path // ""), | |
| html_url: (.html_url // ""), | |
| first_patched_version: (.security_vulnerability.first_patched_version.identifier // "") | |
| }]') | |
| EXTRACTED_COUNT=$(echo "$EXTRACTED" | jq 'length') | |
| echo "Extracted $EXTRACTED_COUNT alert(s) after parsing" | |
| ALL_ALERTS=$(echo "$ALL_ALERTS" "$EXTRACTED" | jq -s '.[0] + .[1]') | |
| # Parse Link header for next page URL (cursor-based pagination) | |
| URL=$(sed -n 's/.*<\([^>]*\)>; *rel="next".*/\1/p' /tmp/dependabot-headers.txt || true) | |
| done | |
| ALERT_COUNT=$(echo "$ALL_ALERTS" | jq 'length') | |
| echo "Fetched $ALERT_COUNT Dependabot alert(s) total" | |
| echo "$ALL_ALERTS" > dependabot-alerts.json | |
| - name: Write Dependabot fetch summary | |
| run: | | |
| echo "## Dependabot Alerts Fetched" >> "$GITHUB_STEP_SUMMARY" | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| if [ ! -f dependabot-alerts.json ]; then | |
| echo "No Dependabot alerts file found." >> "$GITHUB_STEP_SUMMARY" | |
| else | |
| COUNT=$(jq 'length' dependabot-alerts.json) | |
| echo "**$COUNT** open Dependabot alert(s) fetched." >> "$GITHUB_STEP_SUMMARY" | |
| if [ "$COUNT" -gt 0 ]; then | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "| CVE / GHSA | Severity | Package | Ecosystem | Patched Version |" >> "$GITHUB_STEP_SUMMARY" | |
| echo "|------------|----------|---------|-----------|-----------------|" >> "$GITHUB_STEP_SUMMARY" | |
| jq -r '.[] | "| \(.id) | \(.severity) | \(.package_name) | \(.package_ecosystem) | \(.first_patched_version // "N/A") |"' dependabot-alerts.json >> "$GITHUB_STEP_SUMMARY" | |
| fi | |
| fi | |
| - name: Fetch CodeQL alerts | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| ALL_ALERTS="[]" | |
| PAGE=1 | |
| while true; do | |
| RESPONSE=$(curl -s -w "\n%{http_code}" \ | |
| -H "Accept: application/vnd.github+json" \ | |
| -H "Authorization: Bearer $GH_TOKEN" \ | |
| "https://api.github.com/repos/${{ github.repository }}/code-scanning/alerts?state=open&per_page=100&page=$PAGE") | |
| HTTP_CODE=$(echo "$RESPONSE" | tail -1) | |
| BODY=$(echo "$RESPONSE" | sed '$d') | |
| if [ "$HTTP_CODE" = "404" ]; then | |
| echo "CodeQL is not enabled for this repository. Writing empty results." | |
| echo "[]" > codeql-alerts.json | |
| exit 0 | |
| fi | |
| if [ "$HTTP_CODE" != "200" ]; then | |
| echo "::warning::Failed to fetch CodeQL alerts (HTTP $HTTP_CODE). Writing empty results." | |
| echo "[]" > codeql-alerts.json | |
| exit 0 | |
| fi | |
| COUNT=$(echo "$BODY" | jq 'length') | |
| if [ "$COUNT" -eq 0 ]; then | |
| break | |
| fi | |
| EXTRACTED=$(echo "$BODY" | jq '[.[] | { | |
| id: ("codeql:" + (.rule.id // "")), | |
| number: .number, | |
| rule_id: (.rule.id // ""), | |
| rule_description: (.rule.description // ""), | |
| security_severity_level: (.rule.security_severity_level // "medium"), | |
| tool_name: (.tool.name // ""), | |
| location_path: (.most_recent_instance.location.path // ""), | |
| location_start_line: (.most_recent_instance.location.start_line // 0), | |
| location_end_line: (.most_recent_instance.location.end_line // 0), | |
| html_url: (.html_url // ""), | |
| state: (.state // "") | |
| }]') | |
| ALL_ALERTS=$(echo "$ALL_ALERTS" "$EXTRACTED" | jq -s '.[0] + .[1]') | |
| if [ "$COUNT" -lt 100 ]; then | |
| break | |
| fi | |
| PAGE=$((PAGE + 1)) | |
| done | |
| ALERT_COUNT=$(echo "$ALL_ALERTS" | jq 'length') | |
| echo "Fetched $ALERT_COUNT CodeQL alert(s)" | |
| echo "$ALL_ALERTS" > codeql-alerts.json | |
| - name: Write CodeQL fetch summary | |
| run: | | |
| echo "## CodeQL Alerts Fetched" >> "$GITHUB_STEP_SUMMARY" | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| if [ ! -f codeql-alerts.json ]; then | |
| echo "No CodeQL alerts file found." >> "$GITHUB_STEP_SUMMARY" | |
| else | |
| COUNT=$(jq 'length' codeql-alerts.json) | |
| echo "**$COUNT** open CodeQL alert(s) fetched." >> "$GITHUB_STEP_SUMMARY" | |
| if [ "$COUNT" -gt 0 ]; then | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "| Rule ID | Severity | Tool | File | Lines |" >> "$GITHUB_STEP_SUMMARY" | |
| echo "|---------|----------|------|------|-------|" >> "$GITHUB_STEP_SUMMARY" | |
| jq -r '.[] | "| \(.id) | \(.security_severity_level) | \(.tool_name) | \(.location_path) | \(.location_start_line)-\(.location_end_line) |"' codeql-alerts.json >> "$GITHUB_STEP_SUMMARY" | |
| fi | |
| fi | |
| - name: Analyze vulnerabilities with Claude | |
| id: claude | |
| uses: anthropics/claude-code-action@v1 | |
| env: | |
| LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} | |
| LINEAR_TEAM_ID: ${{ secrets.LINEAR_TEAM_ID }} | |
| with: | |
| anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} | |
| claude_args: | | |
| --allowedTools Bash,Read,Write,Edit,Glob,Grep,WebSearch,WebFetch | |
| --model claude-sonnet-4-6 | |
| --json-schema '{"type":"object","properties":{"cves":{"type":"array","items":{"type":"object","properties":{"cveId":{"type":"string","description":"CVE ID, GHSA ID, or codeql:<rule-id>"},"severity":{"type":"string","enum":["CRITICAL","HIGH","MEDIUM","LOW"]},"source":{"type":"string","enum":["trivy","dependabot","codeql","trivy+dependabot"],"description":"Which scanner(s) reported this finding"},"title":{"type":"string","description":"Short summary for the Linear issue title"},"description":{"type":"string","description":"Markdown analysis: affected packages, direct vs transitive, remediation steps, and references"},"affectedPackage":{"type":"string"},"linearIssueExists":{"type":"boolean"},"linearIssueId":{"type":"string","description":"The Linear issue UUID if a matching issue was found, empty string otherwise"},"linearIssueIdentifier":{"type":"string","description":"The Linear issue identifier (e.g. SOU-926) if found, empty string otherwise"},"linearIssueUrl":{"type":"string","description":"The Linear issue URL if found, empty string otherwise"},"linearIssueClosed":{"type":"boolean","description":"True if the matching Linear issue is in a completed or canceled state"}},"required":["cveId","severity","source","title","description","affectedPackage","linearIssueExists","linearIssueId","linearIssueIdentifier","linearIssueUrl","linearIssueClosed"]}}},"required":["cves"]}' | |
| prompt: | | |
| You are a security engineer triaging vulnerabilities and security findings for the repository **${{ github.repository }}**. | |
| You have three data sources to analyze. Each is a JSON array where every entry has a pre-computed | |
| `id` field for deterministic deduplication: | |
| 1. **Trivy scan results** in `trivy-alerts.json` — each entry has `id` (CVE ID, e.g., `CVE-2024-1234`) | |
| 2. **Dependabot alerts** in `dependabot-alerts.json` — each entry has `id` (CVE ID or GHSA ID) | |
| 3. **CodeQL alerts** in `codeql-alerts.json` — each entry has `id` (prefixed, e.g., `codeql:js/sql-injection`). Multiple entries may share the same `id` (same rule, different locations). | |
| ## Your Task | |
| 1. Read and analyze all three data sources. For **each unique `id`**, produce a separate entry | |
| in the `cves` array. Use the `id` field as the `cveId`. | |
| 2. **Deduplication**: If the same `id` appears in both `trivy-alerts.json` and `dependabot-alerts.json`, | |
| merge them into a single entry with `source: "trivy+dependabot"`. Combine information from both | |
| sources in the description. CodeQL `id` values are prefixed with `codeql:` so they never collide. | |
| 3. For **Trivy and Dependabot findings**: | |
| - Use the `id` field as `cveId`. | |
| - Set `source` to `"trivy"`, `"dependabot"`, or `"trivy+dependabot"` as appropriate. | |
| - Include the affected package, severity, remediation steps, and whether it is direct or transitive. | |
| 4. For **CodeQL findings**: | |
| - **Group all alerts with the same `id` (rule ID) into a single entry.** Multiple alerts for | |
| the same rule in different files/locations should produce ONE finding, not separate ones. | |
| - Use the `id` field as `cveId` (e.g., `codeql:js/path-injection`). | |
| - Set `source` to `"codeql"`. | |
| - Set `affectedPackage` to a comma-separated list of affected file paths, or the primary one | |
| if there are many. | |
| - Normalize `security_severity_level` to uppercase (CRITICAL/HIGH/MEDIUM/LOW). | |
| - The `description` MUST include details for **every alert instance** in the group: | |
| - The rule ID and what it detects | |
| - For **each** alert: the exact file path, line number(s), and a link to its alert URL (`html_url`) | |
| - For **each** alert: an explanation of the specific code at that location and why it's flagged | |
| - Concrete remediation steps with code examples where possible | |
| - A link to the CodeQL rule documentation | |
| - A summary count (e.g., "This rule was triggered in 3 locations:") | |
| 5. For each finding, determine: | |
| - A short `title` suitable for a Linear issue title. | |
| - A `description` in markdown with your full analysis, references, and remediation guidance. | |
| - The `severity` (CRITICAL, HIGH, MEDIUM, or LOW) — normalize to uppercase from all sources. | |
| 6. Read files such as `Dockerfile`, `package.json`, and `go.mod` to gather context about | |
| dependencies. Use `yarn why <package> --recursive` to determine why an npm package is included. | |
| 7. **Check Linear for existing issues** for each finding: | |
| - For each `cveId`, run a GraphQL query against the Linear API to search for issues | |
| whose title contains that ID. Search ALL issues regardless of state (open, completed, cancelled). | |
| - Use the following curl command pattern: | |
| ``` | |
| curl -s -X POST https://api.linear.app/graphql \ | |
| -H "Content-Type: application/json" \ | |
| -H "Authorization: $LINEAR_API_KEY" \ | |
| -d '{"query": "query { issues(filter: { team: { id: { eq: \"'$LINEAR_TEAM_ID'\" } }, title: { contains: \"<ID>\" } }) { nodes { id identifier url title state { type } } } }"}' | |
| ``` | |
| - **IMPORTANT: Repository scoping.** Linear issues are titled with a `[$REPOSITORY]` prefix | |
| (e.g., `[sourcebot-dev/sourcebot] CVE-2024-1234: ...`). When checking for existing issues, | |
| you MUST verify that the matched issue's title starts with `[${{ github.repository }}]`. | |
| An issue for `[sourcebot-dev/sourcebot]` is NOT the same as one for `[sourcebot-dev/sourcebot-helm-chart]`. | |
| Ignore issues whose title prefix does not match the current repository `${{ github.repository }}`. | |
| - Set `linearIssueExists` to `true` if a matching issue scoped to this repo is found, `false` otherwise. | |
| - If multiple issues match, prefer the one with an open state (i.e., state type is NOT `"completed"` or `"canceled"`). | |
| Only use a closed issue if no open issue exists for that finding. | |
| - Set `linearIssueId` to the `id` (UUID) of the selected matching issue, or `""` if none found. | |
| - Set `linearIssueIdentifier` to the issue identifier (e.g., `SOU-926`) of the selected matching issue, or `""` if none found. | |
| - Set `linearIssueUrl` to the `url` of the selected matching issue, or `""` if none found. | |
| - Set `linearIssueClosed` to `true` if the selected issue's `state.type` is `"completed"` or `"canceled"`, `false` otherwise. | |
| 8. Return the structured JSON with all findings in the `cves` array. | |
| - name: Write Claude analysis summary | |
| env: | |
| STRUCTURED_OUTPUT: ${{ steps.claude.outputs.structured_output }} | |
| run: | | |
| CVE_COUNT=$(echo "$STRUCTURED_OUTPUT" | jq '.cves | length') | |
| NEW_COUNT=$(echo "$STRUCTURED_OUTPUT" | jq '[.cves[] | select(.linearIssueExists == false)] | length') | |
| EXISTING_OPEN_COUNT=$(echo "$STRUCTURED_OUTPUT" | jq '[.cves[] | select(.linearIssueExists == true and .linearIssueClosed == false)] | length') | |
| EXISTING_CLOSED_COUNT=$(echo "$STRUCTURED_OUTPUT" | jq '[.cves[] | select(.linearIssueExists == true and .linearIssueClosed == true)] | length') | |
| echo "## Claude Analysis" >> "$GITHUB_STEP_SUMMARY" | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "**$CVE_COUNT** finding(s): **$NEW_COUNT** new, **$EXISTING_OPEN_COUNT** already tracked (open), **$EXISTING_CLOSED_COUNT** previously closed (will reopen)." >> "$GITHUB_STEP_SUMMARY" | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "| ID | Source | Severity | Package | Linear Status | Linear Issue |" >> "$GITHUB_STEP_SUMMARY" | |
| echo "|----|--------|----------|---------|---------------|--------------|" >> "$GITHUB_STEP_SUMMARY" | |
| echo "$STRUCTURED_OUTPUT" | jq -r '.cves[] | "| \(.cveId) | \(.source) | \(.severity) | \(.affectedPackage) | \(if .linearIssueClosed then "Reopen" elif .linearIssueExists then "Existing (skip)" else "New (create)" end) | \(if .linearIssueUrl != "" then "[\(.linearIssueIdentifier)](\(.linearIssueUrl))" else "—" end) |"' >> "$GITHUB_STEP_SUMMARY" | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "### Details" >> "$GITHUB_STEP_SUMMARY" | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "$STRUCTURED_OUTPUT" | jq -r '.cves[] | "#### \(.cveId): \(.title)\n\n\(.description)\n"' >> "$GITHUB_STEP_SUMMARY" | |
| - name: Create Linear issues | |
| if: inputs.dry_run != true | |
| env: | |
| LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} | |
| LINEAR_TEAM_ID: ${{ secrets.LINEAR_TEAM_ID }} | |
| STRUCTURED_OUTPUT: ${{ steps.claude.outputs.structured_output }} | |
| REPOSITORY: ${{ github.repository }} | |
| run: | | |
| # Look up the "CVE" label ID and "Triage" state ID for the team | |
| METADATA_QUERY='query($teamId: String!) { team(id: $teamId) { id labels(filter: { name: { eq: "CVE" } }) { nodes { id } } states(filter: { name: { eq: "Triage" } }) { nodes { id } } } }' | |
| METADATA_PAYLOAD=$(jq -n --arg query "$METADATA_QUERY" --arg teamId "$LINEAR_TEAM_ID" \ | |
| '{query: $query, variables: {teamId: $teamId}}') | |
| METADATA_RESPONSE=$(curl -s -X POST https://api.linear.app/graphql \ | |
| -H "Content-Type: application/json" \ | |
| -H "Authorization: $LINEAR_API_KEY" \ | |
| -d "$METADATA_PAYLOAD") | |
| # Resolve the actual team UUID (LINEAR_TEAM_ID may be a slug/key, but issueCreate requires a UUID) | |
| TEAM_UUID=$(echo "$METADATA_RESPONSE" | jq -r '.data.team.id // empty') | |
| LABEL_ID=$(echo "$METADATA_RESPONSE" | jq -r '.data.team.labels.nodes[0].id // empty') | |
| STATE_ID=$(echo "$METADATA_RESPONSE" | jq -r '.data.team.states.nodes[0].id // empty') | |
| if [ -z "$TEAM_UUID" ]; then | |
| echo "::error::Could not resolve team UUID from LINEAR_TEAM_ID. Check the secret value." | |
| exit 1 | |
| fi | |
| if [ -z "$LABEL_ID" ]; then | |
| echo "::warning::Could not find 'CVE' label in Linear team. Creating issues without label." | |
| fi | |
| if [ -z "$STATE_ID" ]; then | |
| echo "::warning::Could not find 'Triage' state in Linear team. Using default state." | |
| fi | |
| # Map severity to Linear priority | |
| severity_to_priority() { | |
| case "$1" in | |
| CRITICAL) echo 1 ;; | |
| HIGH) echo 2 ;; | |
| MEDIUM) echo 3 ;; | |
| LOW) echo 4 ;; | |
| *) echo 3 ;; | |
| esac | |
| } | |
| CREATED_COUNT=0 | |
| SKIPPED_COUNT=0 | |
| REOPENED_COUNT=0 | |
| FAILED_COUNT=0 | |
| echo "## Linear Issue Creation" >> "$GITHUB_STEP_SUMMARY" | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| # Write CVEs to temp file so the while loop doesn't run in a pipe subshell | |
| echo "$STRUCTURED_OUTPUT" | jq -c '.cves[]' > /tmp/cves.jsonl | |
| MUTATION='mutation CreateIssue($teamId: String!, $title: String!, $description: String, $priority: Int, $labelIds: [String!], $stateId: String) { issueCreate(input: { teamId: $teamId, title: $title, description: $description, priority: $priority, labelIds: $labelIds, stateId: $stateId }) { success issue { id identifier url } } }' | |
| while IFS= read -r cve; do | |
| CVE_ID=$(echo "$cve" | jq -r '.cveId') | |
| SEVERITY=$(echo "$cve" | jq -r '.severity') | |
| TITLE=$(echo "$cve" | jq -r '.title') | |
| DESCRIPTION=$(echo "$cve" | jq -r '.description') | |
| LINEAR_EXISTS=$(echo "$cve" | jq -r '.linearIssueExists') | |
| LINEAR_ISSUE_ID=$(echo "$cve" | jq -r '.linearIssueId') | |
| LINEAR_IDENTIFIER=$(echo "$cve" | jq -r '.linearIssueIdentifier') | |
| LINEAR_URL=$(echo "$cve" | jq -r '.linearIssueUrl') | |
| LINEAR_CLOSED=$(echo "$cve" | jq -r '.linearIssueClosed') | |
| if [ "$LINEAR_EXISTS" = "true" ] && [ "$LINEAR_CLOSED" = "false" ]; then | |
| echo "Skipping $CVE_ID — Linear issue $LINEAR_IDENTIFIER already exists and is open ($LINEAR_URL)" | |
| echo "- Skipped **$CVE_ID** — already tracked in [$LINEAR_IDENTIFIER]($LINEAR_URL) (open)" >> "$GITHUB_STEP_SUMMARY" | |
| SKIPPED_COUNT=$((SKIPPED_COUNT + 1)) | |
| continue | |
| fi | |
| if [ "$LINEAR_EXISTS" = "true" ] && [ "$LINEAR_CLOSED" = "true" ]; then | |
| # Reopen the closed issue by setting its state back to Triage | |
| echo "Found closed Linear issue $LINEAR_IDENTIFIER for $CVE_ID ($LINEAR_URL) — will attempt to reopen" | |
| if [ -z "$STATE_ID" ]; then | |
| echo "::warning::Cannot reopen $CVE_ID ($LINEAR_IDENTIFIER) — no Triage state found. Skipping." | |
| echo "- Skipped **$CVE_ID** — found closed issue [$LINEAR_IDENTIFIER]($LINEAR_URL) but no Triage state to reopen" >> "$GITHUB_STEP_SUMMARY" | |
| SKIPPED_COUNT=$((SKIPPED_COUNT + 1)) | |
| continue | |
| fi | |
| REOPEN_MUTATION='mutation($issueId: String!, $stateId: String!) { issueUpdate(id: $issueId, input: { stateId: $stateId }) { success issue { id identifier url } } }' | |
| REOPEN_VARIABLES=$(jq -n \ | |
| --arg issueId "$LINEAR_ISSUE_ID" \ | |
| --arg stateId "$STATE_ID" \ | |
| '{issueId: $issueId, stateId: $stateId}') | |
| REOPEN_PAYLOAD=$(jq -n --arg query "$REOPEN_MUTATION" --argjson vars "$REOPEN_VARIABLES" '{query: $query, variables: $vars}') | |
| REOPEN_RESPONSE=$(curl -s -X POST https://api.linear.app/graphql \ | |
| -H "Content-Type: application/json" \ | |
| -H "Authorization: $LINEAR_API_KEY" \ | |
| -d "$REOPEN_PAYLOAD") | |
| REOPEN_URL=$(echo "$REOPEN_RESPONSE" | jq -r '.data.issueUpdate.issue.url // empty') | |
| REOPEN_IDENTIFIER=$(echo "$REOPEN_RESPONSE" | jq -r '.data.issueUpdate.issue.identifier // empty') | |
| if [ -n "$REOPEN_URL" ]; then | |
| echo "Reopened Linear issue $REOPEN_IDENTIFIER for $CVE_ID: $REOPEN_URL" | |
| echo "- Reopened [$REOPEN_IDENTIFIER]($REOPEN_URL) for **$CVE_ID** — $TITLE (moved back to Triage)" >> "$GITHUB_STEP_SUMMARY" | |
| REOPENED_COUNT=$((REOPENED_COUNT + 1)) | |
| else | |
| echo "::error::Failed to reopen Linear issue $LINEAR_IDENTIFIER for $CVE_ID" | |
| echo "$REOPEN_RESPONSE" | jq . | |
| echo "- **FAILED** to reopen [$LINEAR_IDENTIFIER]($LINEAR_URL) for **$CVE_ID**" >> "$GITHUB_STEP_SUMMARY" | |
| FAILED_COUNT=$((FAILED_COUNT + 1)) | |
| fi | |
| continue | |
| fi | |
| # Create new issue | |
| PRIORITY=$(severity_to_priority "$SEVERITY") | |
| ISSUE_TITLE="[$REPOSITORY] $CVE_ID: $TITLE" | |
| # Build variables JSON with jq to handle all escaping properly | |
| VARIABLES=$(jq -n \ | |
| --arg teamId "$TEAM_UUID" \ | |
| --arg title "$ISSUE_TITLE" \ | |
| --arg desc "$DESCRIPTION" \ | |
| --argjson priority "$PRIORITY" \ | |
| '{teamId: $teamId, title: $title, description: $desc, priority: $priority}') | |
| if [ -n "$LABEL_ID" ]; then | |
| VARIABLES=$(echo "$VARIABLES" | jq --arg lid "$LABEL_ID" '. + {labelIds: [$lid]}') | |
| fi | |
| if [ -n "$STATE_ID" ]; then | |
| VARIABLES=$(echo "$VARIABLES" | jq --arg sid "$STATE_ID" '. + {stateId: $sid}') | |
| fi | |
| PAYLOAD=$(jq -n --arg query "$MUTATION" --argjson vars "$VARIABLES" '{query: $query, variables: $vars}') | |
| RESPONSE=$(curl -s -X POST https://api.linear.app/graphql \ | |
| -H "Content-Type: application/json" \ | |
| -H "Authorization: $LINEAR_API_KEY" \ | |
| -d "$PAYLOAD") | |
| ISSUE_URL=$(echo "$RESPONSE" | jq -r '.data.issueCreate.issue.url // empty') | |
| ISSUE_IDENTIFIER=$(echo "$RESPONSE" | jq -r '.data.issueCreate.issue.identifier // empty') | |
| if [ -n "$ISSUE_URL" ]; then | |
| echo "Created Linear issue $ISSUE_IDENTIFIER for $CVE_ID: $ISSUE_URL" | |
| echo "- Created [$ISSUE_IDENTIFIER]($ISSUE_URL) for **$CVE_ID** — $TITLE (priority: $SEVERITY)" >> "$GITHUB_STEP_SUMMARY" | |
| CREATED_COUNT=$((CREATED_COUNT + 1)) | |
| else | |
| echo "::error::Failed to create Linear issue for $CVE_ID" | |
| echo "$RESPONSE" | jq . | |
| echo "- **FAILED** to create issue for **$CVE_ID** — $TITLE" >> "$GITHUB_STEP_SUMMARY" | |
| FAILED_COUNT=$((FAILED_COUNT + 1)) | |
| fi | |
| done < /tmp/cves.jsonl | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "**Summary:** Created $CREATED_COUNT issue(s), reopened $REOPENED_COUNT issue(s), skipped $SKIPPED_COUNT existing issue(s), failed $FAILED_COUNT issue(s)." >> "$GITHUB_STEP_SUMMARY" | |
| if [ "$FAILED_COUNT" -gt 0 ]; then | |
| echo "::error::Failed to create $FAILED_COUNT Linear issue(s)" | |
| exit 1 | |
| fi |