chore: upgrade fast-uri to ^3.1.5 to address CVE-2026-18446 (#1541) #225
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: | |
| push: | |
| branches: | |
| - main | |
| 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 triage 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 | |
| skip_codeql: | |
| description: 'Skip CodeQL only when code scanning is unavailable for the caller repository.' | |
| required: false | |
| type: boolean | |
| default: false | |
| linear_assignee_id: | |
| description: 'Linear user UUID to assign. Leave empty to use assign_to_api_key_owner behavior.' | |
| required: false | |
| type: string | |
| default: '' | |
| assign_to_api_key_owner: | |
| description: 'Assign issues to the Linear API key owner when linear_assignee_id is empty.' | |
| required: false | |
| type: boolean | |
| default: true | |
| linear_state_name: | |
| description: 'Linear workflow state for newly created or reopened issues.' | |
| required: false | |
| type: string | |
| default: 'Triage' | |
| linear_cycle_id: | |
| description: 'Linear cycle UUID to assign. Takes precedence over linear_use_active_cycle.' | |
| required: false | |
| type: string | |
| default: '' | |
| linear_use_active_cycle: | |
| description: 'Assign issues to the Linear team active cycle when linear_cycle_id is empty.' | |
| required: false | |
| type: boolean | |
| default: false | |
| secrets: | |
| 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 | |
| vulnerability-alerts: read # Required for Dependabot 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 || secrets.GITHUB_TOKEN }} | |
| SKIP_CODEQL: ${{ inputs.skip_codeql || false }} | |
| run: | | |
| set -euo pipefail | |
| 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 "::error::Could not fetch Dependabot alerts (HTTP $DEPENDABOT_STATUS). Reconciliation requires a complete alert snapshot." | |
| exit 1 | |
| fi | |
| else | |
| echo "::error::No token is available for Dependabot alerts. Reconciliation requires a complete alert snapshot." | |
| exit 1 | |
| fi | |
| # Check CodeQL alerts (uses GITHUB_TOKEN with security-events: read). | |
| # Private repositories without GitHub Code Security return HTTP 403, | |
| # which is indistinguishable from a real authorization failure. Those | |
| # callers must opt out explicitly rather than weakening fail-closed | |
| # handling for every 403 response. | |
| if [ "$SKIP_CODEQL" = "true" ]; then | |
| echo "CodeQL is unavailable for this repository by caller configuration. Skipping." | |
| else | |
| 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 "::error::Could not fetch CodeQL alerts (HTTP $CODEQL_STATUS). Reconciliation requires a complete alert snapshot." | |
| exit 1 | |
| fi | |
| fi | |
| echo "has_alerts=$HAS_ALERTS" >> "$GITHUB_OUTPUT" | |
| - name: Write alerts summary | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| DEPENDABOT_PAT: ${{ secrets.DEPENDABOT_PAT || secrets.GITHUB_TOKEN }} | |
| SKIP_CODEQL: ${{ inputs.skip_codeql || false }} | |
| 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 | |
| if [ "$SKIP_CODEQL" = "true" ]; then | |
| echo "### CodeQL" >> "$GITHUB_STEP_SUMMARY" | |
| echo "Unavailable (explicit caller configuration)." >> "$GITHUB_STEP_SUMMARY" | |
| else | |
| 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 | |
| fi | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "**Result:** has_alerts=${{ steps.check.outputs.has_alerts }}" >> "$GITHUB_STEP_SUMMARY" | |
| triage: | |
| name: Linear Triage | |
| needs: [scan, check-alerts] | |
| # A clean scan still needs reconciliation so resolved Linear issues can be | |
| # closed. Never reconcile after a failed scanner job: missing data must not | |
| # be interpreted as an empty vulnerability set. | |
| if: >- | |
| always() && | |
| !cancelled() && | |
| needs.check-alerts.result == 'success' && | |
| (needs.scan.result == 'success' || needs.scan.result == 'skipped') | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v4 | |
| - name: Checkout reusable workflow assets | |
| uses: actions/checkout@v4 | |
| with: | |
| repository: ${{ job.workflow_repository }} | |
| ref: ${{ job.workflow_sha }} | |
| sparse-checkout: .github/scripts | |
| path: .vulnerability-triage-workflow | |
| persist-credentials: false | |
| - 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 || secrets.GITHUB_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| if [ -z "$DEPENDABOT_PAT" ]; then | |
| echo "::error::No token is available for Dependabot alerts. Refusing to reconcile an incomplete snapshot." | |
| exit 1 | |
| 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 "::error::Failed to fetch Dependabot alerts (HTTP $HTTP_CODE). Refusing to reconcile an incomplete snapshot." | |
| echo "Response body: $(cat /tmp/dependabot-body.json | head -c 500)" | |
| exit 1 | |
| 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 }} | |
| SKIP_CODEQL: ${{ inputs.skip_codeql || false }} | |
| run: | | |
| set -euo pipefail | |
| if [ "$SKIP_CODEQL" = "true" ]; then | |
| echo "CodeQL is unavailable for this repository by caller configuration. Writing empty results." | |
| echo "[]" > codeql-alerts.json | |
| exit 0 | |
| fi | |
| 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 "::error::Failed to fetch CodeQL alerts (HTTP $HTTP_CODE). Refusing to reconcile an incomplete snapshot." | |
| exit 1 | |
| 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: Build findings | |
| run: | | |
| set -euo pipefail | |
| # Deterministically build the findings list from the three normalized scan | |
| # files. Dedup by the pre-computed `id` field so each unique id yields exactly | |
| # one finding: duplicate ids within a single scanner are collapsed, a CVE/GHSA | |
| # id present in both Trivy and Dependabot is merged into one entry, and CodeQL | |
| # alerts are grouped by rule id. Titles and descriptions are templated from the | |
| # scan fields — no LLM. | |
| for f in trivy-alerts.json dependabot-alerts.json codeql-alerts.json; do | |
| [ -f "$f" ] || echo "[]" > "$f" | |
| done | |
| jq -n \ | |
| --slurpfile trivy trivy-alerts.json \ | |
| --slurpfile dependabot dependabot-alerts.json \ | |
| --slurpfile codeql codeql-alerts.json ' | |
| def sev: (. // "medium") | ascii_upcase; | |
| ($trivy[0] // []) as $trivy_raw | | |
| ($dependabot[0] // []) as $dep_raw | | |
| ($codeql[0] // []) as $codeql_raw | | |
| ($trivy_raw | map(.id)) as $trivy_ids | | |
| # --- Trivy findings (deduped by id, merged with Dependabot when ids collide) --- | |
| # Trivy can report the same CVE multiple times (across lockfiles/targets or | |
| # affecting several packages), so group by id and collect every package. | |
| ($trivy_raw | group_by(.id) | map( | |
| . as $group | | |
| ($group[0]) as $t | | |
| ($group | map(.pkg_name) | unique) as $pkgs | | |
| ($dep_raw | map(select(.id == $t.id)) | .[0]) as $match | | |
| { | |
| cveId: $t.id, | |
| severity: ($t.severity | sev), | |
| source: (if $match then "trivy+dependabot" else "trivy" end), | |
| title: (if (($t.title // "") != "") then $t.title else (($t.severity | sev) + " vulnerability in " + ($pkgs | join(", "))) end), | |
| affectedPackage: ($pkgs | join(", ")), | |
| description: ( | |
| "**Source:** Trivy container scan" + (if $match then " + Dependabot" else "" end) + "\n\n" | |
| + "**Affected package(s):**\n" | |
| + ($group | map("- `" + .pkg_name + "` (installed `" + .installed_version + "`" + (if ((.fixed_version // "") != "") then ", fixed in `" + .fixed_version + "`" else ", no fix available" end) + ")") | unique | join("\n")) + "\n\n" | |
| + "**Severity:** " + ($t.severity | sev) + "\n\n" | |
| + (if (($t.title // "") != "") then "**" + $t.title + "**\n\n" else "" end) | |
| + (if (($t.description // "") != "") then $t.description + "\n\n" else "" end) | |
| + (if (($match.html_url // "") != "") then "**Dependabot alert:** " + $match.html_url + "\n\n" else "" end) | |
| + (if (($match.first_patched_version // "") != "") then "**Dependabot patched version:** `" + $match.first_patched_version + "`\n\n" else "" end) | |
| + (if ((($t.references // []) | length) > 0) then "**References:**\n" + ($t.references | map("- " + .) | join("\n")) + "\n" else "" end) | |
| ) | |
| } | |
| )) as $trivy_findings | | |
| # --- Dependabot-only findings (deduped by id, ids not already covered by Trivy) --- | |
| ($dep_raw | group_by(.id) | map(select((.[0].id) as $id | ($trivy_ids | index($id)) | not)) | map( | |
| . as $group | | |
| ($group[0]) as $d | | |
| ($group | map(.package_name) | unique) as $pkgs | | |
| { | |
| cveId: $d.id, | |
| severity: ($d.severity | sev), | |
| source: "dependabot", | |
| title: (if (($d.summary // "") != "") then $d.summary else (($d.severity | sev) + " vulnerability in " + ($pkgs | join(", "))) end), | |
| affectedPackage: ($pkgs | join(", ")), | |
| description: ( | |
| "**Source:** Dependabot\n\n" | |
| + "**Package:** `" + ($pkgs | join("`, `")) + "` (" + $d.package_ecosystem + ")" | |
| + (if (($d.first_patched_version // "") != "") then " — patched in `" + $d.first_patched_version + "`" else " — no patched version available" end) + "\n\n" | |
| + (if (($d.manifest_path // "") != "") then "**Manifest:** `" + $d.manifest_path + "`\n\n" else "" end) | |
| + "**Severity:** " + ($d.severity | sev) + "\n\n" | |
| + (if (($d.summary // "") != "") then "**" + $d.summary + "**\n\n" else "" end) | |
| + (if (($d.description // "") != "") then $d.description + "\n\n" else "" end) | |
| + (if (($d.html_url // "") != "") then "**Alert:** " + $d.html_url + "\n" else "" end) | |
| ) | |
| } | |
| )) as $dep_findings | | |
| # --- CodeQL findings (grouped by rule id) --- | |
| ($codeql_raw | group_by(.id) | map( | |
| . as $group | | |
| ($group[0]) as $first | | |
| ($group | map(.location_path) | unique) as $paths | | |
| { | |
| cveId: $first.id, | |
| severity: ($first.security_severity_level | sev), | |
| source: "codeql", | |
| title: (($first.security_severity_level | sev) + " " + $first.rule_id + " (" + (($group | length) | tostring) + " location(s))"), | |
| affectedPackage: ($paths | join(", ")), | |
| description: ( | |
| "**Source:** CodeQL static analysis\n\n" | |
| + "**Rule:** `" + $first.rule_id + "`" | |
| + (if (($first.rule_description // "") != "") then " — " + $first.rule_description else "" end) + "\n\n" | |
| + "**Severity:** " + ($first.security_severity_level | sev) + "\n\n" | |
| + "This rule was triggered in " + (($group | length) | tostring) + " location(s):\n" | |
| + ($group | map("- `" + .location_path + ":" + (.location_start_line | tostring) + "-" + (.location_end_line | tostring) + "` ([view](" + .html_url + "))") | join("\n")) + "\n" | |
| ) | |
| } | |
| )) as $codeql_findings | | |
| {cves: ($trivy_findings + $dep_findings + $codeql_findings)} | |
| ' > findings-base.json | |
| echo "Built $(jq '.cves | length' findings-base.json) finding(s)." | |
| - name: Match existing Linear issues | |
| id: match | |
| env: | |
| LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} | |
| LINEAR_TEAM_ID: ${{ secrets.LINEAR_TEAM_ID }} | |
| REPOSITORY: ${{ github.repository }} | |
| LINEAR_STATE_NAME: ${{ inputs.linear_state_name || 'Triage' }} | |
| run: | | |
| set -euo pipefail | |
| # Resolve team UUID, labels, configured/open and completed states, | |
| # active cycle, and API key owner once. The completed state is used to | |
| # close issues whose finding is absent from the complete scanner snapshot. | |
| METADATA_QUERY='query($teamId: String!, $stateName: String!) { team(id: $teamId) { id labels(filter: { name: { eq: "CVE" } }) { nodes { id } } states(filter: { name: { eq: $stateName } }) { nodes { id } } doneStates: states(filter: { type: { eq: "completed" } }) { nodes { id position } } activeCycle { id } } viewer { id } }' | |
| METADATA_PAYLOAD=$(jq -n \ | |
| --arg query "$METADATA_QUERY" \ | |
| --arg teamId "$LINEAR_TEAM_ID" \ | |
| --arg stateName "$LINEAR_STATE_NAME" \ | |
| '{query: $query, variables: {teamId: $teamId, stateName: $stateName}}') | |
| METADATA_RESPONSE=$(LINEAR_API_KEY="$LINEAR_API_KEY" \ | |
| .vulnerability-triage-workflow/.github/scripts/linear-graphql-request.sh <<<"$METADATA_PAYLOAD") | |
| if [ "$(echo "$METADATA_RESPONSE" | jq 'has("errors") or (.data.team == null)')" = "true" ]; then | |
| echo "::error::Could not load Linear team metadata: $(echo "$METADATA_RESPONSE" | jq -c '.errors // .')" | |
| exit 1 | |
| fi | |
| 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') | |
| DONE_STATE_ID=$(echo "$METADATA_RESPONSE" | jq -r '.data.team.doneStates.nodes | sort_by(.position) | .[0].id // empty') | |
| ACTIVE_CYCLE_ID=$(echo "$METADATA_RESPONSE" | jq -r '.data.team.activeCycle.id // empty') | |
| VIEWER_ID=$(echo "$METADATA_RESPONSE" | jq -r '.data.viewer.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 | |
| # Resolve (or create) a team label named after the repository, so each created | |
| # issue is tagged with its source repo (e.g. "sourcebot-dev/sourcebot"). | |
| REPO_LABEL_QUERY='query($teamId: String!, $name: String!) { team(id: $teamId) { labels(filter: { name: { eq: $name } }) { nodes { id } } } }' | |
| REPO_LABEL_PAYLOAD=$(jq -n --arg query "$REPO_LABEL_QUERY" --arg teamId "$TEAM_UUID" --arg name "$REPOSITORY" \ | |
| '{query: $query, variables: {teamId: $teamId, name: $name}}') | |
| REPO_LABEL_RESPONSE=$(LINEAR_API_KEY="$LINEAR_API_KEY" \ | |
| .vulnerability-triage-workflow/.github/scripts/linear-graphql-request.sh <<<"$REPO_LABEL_PAYLOAD") | |
| REPO_LABEL_ID=$(echo "$REPO_LABEL_RESPONSE" | jq -r '.data.team.labels.nodes[0].id // empty') | |
| if [ -z "$REPO_LABEL_ID" ]; then | |
| echo "No '$REPOSITORY' label in Linear team — creating it." | |
| CREATE_LABEL_MUTATION='mutation($teamId: String!, $name: String!) { issueLabelCreate(input: { teamId: $teamId, name: $name }) { success issueLabel { id } } }' | |
| CREATE_LABEL_PAYLOAD=$(jq -n --arg query "$CREATE_LABEL_MUTATION" --arg teamId "$TEAM_UUID" --arg name "$REPOSITORY" \ | |
| '{query: $query, variables: {teamId: $teamId, name: $name}}') | |
| if ! CREATE_LABEL_RESPONSE=$(LINEAR_API_KEY="$LINEAR_API_KEY" \ | |
| .vulnerability-triage-workflow/.github/scripts/linear-graphql-request.sh <<<"$CREATE_LABEL_PAYLOAD"); then | |
| echo "::warning::Linear request failed while creating '$REPOSITORY' label. Continuing without it; the next run will query for an ambiguously committed label." | |
| else | |
| REPO_LABEL_ID=$(echo "$CREATE_LABEL_RESPONSE" | jq -r '.data.issueLabelCreate.issueLabel.id // empty') | |
| if [ -z "$REPO_LABEL_ID" ]; then | |
| echo "::warning::Could not create '$REPOSITORY' label: $(echo "$CREATE_LABEL_RESPONSE" | jq -c '.errors // .')" | |
| fi | |
| fi | |
| fi | |
| { | |
| echo "team_uuid=$TEAM_UUID" | |
| echo "label_id=$LABEL_ID" | |
| echo "repo_label_id=$REPO_LABEL_ID" | |
| echo "state_id=$STATE_ID" | |
| echo "done_state_id=$DONE_STATE_ID" | |
| echo "active_cycle_id=$ACTIVE_CYCLE_ID" | |
| echo "viewer_id=$VIEWER_ID" | |
| } >> "$GITHUB_OUTPUT" | |
| # Search by the exact workflow-managed title prefix. The local jq | |
| # filter repeats the exact-id check and prefers an open issue over a | |
| # completed/canceled one, while preserving Duplicate as a terminal | |
| # deduplication marker. | |
| SEARCH_QUERY='query($titlePrefix: String!, $teamId: ID!) { issues(filter: { title: { startsWith: $titlePrefix }, team: { id: { eq: $teamId } } }) { nodes { id identifier url title state { type } } } }' | |
| echo '[]' > /tmp/matched.json | |
| jq -c '.cves[]' findings-base.json > /tmp/findings.jsonl | |
| while IFS= read -r finding; do | |
| CVE_ID=$(echo "$finding" | jq -r '.cveId') | |
| TITLE_PREFIX="[$REPOSITORY] $CVE_ID:" | |
| VARS=$(jq -n \ | |
| --arg titlePrefix "$TITLE_PREFIX" \ | |
| --arg teamId "$TEAM_UUID" \ | |
| '{titlePrefix: $titlePrefix, teamId: $teamId}') | |
| PAYLOAD=$(jq -n --arg query "$SEARCH_QUERY" --argjson vars "$VARS" '{query: $query, variables: $vars}') | |
| RESPONSE=$(LINEAR_API_KEY="$LINEAR_API_KEY" \ | |
| .vulnerability-triage-workflow/.github/scripts/linear-graphql-request.sh <<<"$PAYLOAD") | |
| # A failed search is not "no match": treating it as one could create | |
| # a duplicate issue, so stop before any Linear mutations. | |
| if [ "$(echo "$RESPONSE" | jq 'has("errors") or (.data.issues == null)')" = "true" ]; then | |
| echo "::error::Linear search for $CVE_ID failed: $(echo "$RESPONSE" | jq -c '.errors // .')" | |
| exit 1 | |
| fi | |
| SELECTED=$(echo "$RESPONSE" | jq \ | |
| --arg prefix "[$REPOSITORY]" \ | |
| --arg findingId "$CVE_ID" \ | |
| -f .vulnerability-triage-workflow/.github/scripts/match-vulnerability-issue.jq) | |
| MERGED=$(echo "$finding" "$SELECTED" | jq -s '.[0] + .[1]') | |
| jq --argjson item "$MERGED" '. + [$item]' /tmp/matched.json > /tmp/matched.tmp && mv /tmp/matched.tmp /tmp/matched.json | |
| done < /tmp/findings.jsonl | |
| jq -n --slurpfile cves /tmp/matched.json '{cves: $cves[0]}' > findings.json | |
| echo "Matched $(jq '.cves | length' findings.json) finding(s) against Linear." | |
| - name: Write findings summary | |
| run: | | |
| set -euo pipefail | |
| STRUCTURED_OUTPUT=$(cat findings.json) | |
| 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 "## Findings" >> "$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 }} | |
| REPOSITORY: ${{ github.repository }} | |
| TEAM_UUID: ${{ steps.match.outputs.team_uuid }} | |
| LABEL_ID: ${{ steps.match.outputs.label_id }} | |
| REPO_LABEL_ID: ${{ steps.match.outputs.repo_label_id }} | |
| STATE_ID: ${{ steps.match.outputs.state_id }} | |
| ACTIVE_CYCLE_ID: ${{ steps.match.outputs.active_cycle_id }} | |
| VIEWER_ID: ${{ steps.match.outputs.viewer_id }} | |
| CONFIGURED_ASSIGNEE_ID: ${{ inputs.linear_assignee_id }} | |
| ASSIGN_TO_API_KEY_OWNER: ${{ inputs.assign_to_api_key_owner }} | |
| LINEAR_STATE_NAME: ${{ inputs.linear_state_name || 'Triage' }} | |
| CONFIGURED_CYCLE_ID: ${{ inputs.linear_cycle_id }} | |
| USE_ACTIVE_CYCLE: ${{ inputs.linear_use_active_cycle }} | |
| run: | | |
| set -uo pipefail | |
| # Team UUID, labels, state, cycle, and assignee metadata were already | |
| # resolved by the "Match existing Linear issues" step. | |
| STRUCTURED_OUTPUT=$(cat findings.json) | |
| TARGET_ASSIGNEE_ID="$CONFIGURED_ASSIGNEE_ID" | |
| if [ -z "$TARGET_ASSIGNEE_ID" ] && [ "$ASSIGN_TO_API_KEY_OWNER" = "true" ]; then | |
| TARGET_ASSIGNEE_ID="$VIEWER_ID" | |
| fi | |
| TARGET_CYCLE_ID="$CONFIGURED_CYCLE_ID" | |
| if [ -z "$TARGET_CYCLE_ID" ] && [ "$USE_ACTIVE_CYCLE" = "true" ]; then | |
| TARGET_CYCLE_ID="$ACTIVE_CYCLE_ID" | |
| fi | |
| if [ -z "$LABEL_ID" ]; then | |
| echo "::warning::Could not find 'CVE' label in Linear team. Creating issues without label." | |
| fi | |
| if [ -z "$REPO_LABEL_ID" ]; then | |
| echo "::warning::Could not resolve '$REPOSITORY' repository label. Creating issues without it." | |
| fi | |
| if [ -z "$STATE_ID" ]; then | |
| echo "::warning::Could not find '$LINEAR_STATE_NAME' state in Linear team. Using default state." | |
| fi | |
| if [ -z "$TARGET_ASSIGNEE_ID" ]; then | |
| echo "Issues will be created or reopened unassigned." | |
| fi | |
| if [ "$USE_ACTIVE_CYCLE" = "true" ] && [ -z "$TARGET_CYCLE_ID" ]; then | |
| echo "::warning::Could not resolve an active Linear cycle. Issues will be created without a cycle." | |
| 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, $assigneeId: String, $cycleId: String) { issueCreate(input: { teamId: $teamId, title: $title, description: $description, priority: $priority, labelIds: $labelIds, stateId: $stateId, assigneeId: $assigneeId, cycleId: $cycleId }) { 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 using the configured repository routing. | |
| 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 '$LINEAR_STATE_NAME' state found. Skipping." | |
| echo "- Skipped **$CVE_ID** — found closed issue [$LINEAR_IDENTIFIER]($LINEAR_URL) but no '$LINEAR_STATE_NAME' state to reopen" >> "$GITHUB_STEP_SUMMARY" | |
| SKIPPED_COUNT=$((SKIPPED_COUNT + 1)) | |
| continue | |
| fi | |
| REOPEN_MUTATION='mutation($issueId: String!, $stateId: String!, $assigneeId: String, $cycleId: String) { issueUpdate(id: $issueId, input: { stateId: $stateId, assigneeId: $assigneeId, cycleId: $cycleId }) { success issue { id identifier url } } }' | |
| REOPEN_VARIABLES=$(jq -n \ | |
| --arg issueId "$LINEAR_ISSUE_ID" \ | |
| --arg stateId "$STATE_ID" \ | |
| '{issueId: $issueId, stateId: $stateId}') | |
| if [ -n "$TARGET_ASSIGNEE_ID" ]; then | |
| REOPEN_VARIABLES=$(echo "$REOPEN_VARIABLES" | jq --arg aid "$TARGET_ASSIGNEE_ID" '. + {assigneeId: $aid}') | |
| else | |
| REOPEN_VARIABLES=$(echo "$REOPEN_VARIABLES" | jq '. + {assigneeId: null}') | |
| fi | |
| if [ -n "$TARGET_CYCLE_ID" ]; then | |
| REOPEN_VARIABLES=$(echo "$REOPEN_VARIABLES" | jq --arg cid "$TARGET_CYCLE_ID" '. + {cycleId: $cid}') | |
| fi | |
| REOPEN_PAYLOAD=$(jq -n --arg query "$REOPEN_MUTATION" --argjson vars "$REOPEN_VARIABLES" '{query: $query, variables: $vars}') | |
| if ! REOPEN_RESPONSE=$(LINEAR_API_KEY="$LINEAR_API_KEY" \ | |
| .vulnerability-triage-workflow/.github/scripts/linear-graphql-request.sh <<<"$REOPEN_PAYLOAD"); then | |
| echo "::error::Linear request failed while reopening $LINEAR_IDENTIFIER for $CVE_ID" | |
| echo "- **FAILED** to reopen [$LINEAR_IDENTIFIER]($LINEAR_URL) for **$CVE_ID**" >> "$GITHUB_STEP_SUMMARY" | |
| FAILED_COUNT=$((FAILED_COUNT + 1)) | |
| continue | |
| fi | |
| 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 to $LINEAR_STATE_NAME)" >> "$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}') | |
| # Attach the "CVE" label and the repository label (dropping any that failed to resolve). | |
| LABEL_IDS=$(jq -n --arg cve "$LABEL_ID" --arg repo "$REPO_LABEL_ID" '[$cve, $repo] | map(select(. != ""))') | |
| if [ "$(echo "$LABEL_IDS" | jq 'length')" -gt 0 ]; then | |
| VARIABLES=$(echo "$VARIABLES" | jq --argjson lids "$LABEL_IDS" '. + {labelIds: $lids}') | |
| fi | |
| if [ -n "$STATE_ID" ]; then | |
| VARIABLES=$(echo "$VARIABLES" | jq --arg sid "$STATE_ID" '. + {stateId: $sid}') | |
| fi | |
| if [ -n "$TARGET_ASSIGNEE_ID" ]; then | |
| VARIABLES=$(echo "$VARIABLES" | jq --arg aid "$TARGET_ASSIGNEE_ID" '. + {assigneeId: $aid}') | |
| else | |
| VARIABLES=$(echo "$VARIABLES" | jq '. + {assigneeId: null}') | |
| fi | |
| if [ -n "$TARGET_CYCLE_ID" ]; then | |
| VARIABLES=$(echo "$VARIABLES" | jq --arg cid "$TARGET_CYCLE_ID" '. + {cycleId: $cid}') | |
| fi | |
| PAYLOAD=$(jq -n --arg query "$MUTATION" --argjson vars "$VARIABLES" '{query: $query, variables: $vars}') | |
| if ! RESPONSE=$(LINEAR_API_KEY="$LINEAR_API_KEY" \ | |
| .vulnerability-triage-workflow/.github/scripts/linear-graphql-request.sh <<<"$PAYLOAD"); then | |
| echo "::error::Linear request failed while creating an issue for $CVE_ID" | |
| echo "- **FAILED** to create issue for **$CVE_ID** — $TITLE" >> "$GITHUB_STEP_SUMMARY" | |
| FAILED_COUNT=$((FAILED_COUNT + 1)) | |
| continue | |
| fi | |
| 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 | |
| - name: Close resolved Linear issues | |
| if: inputs.dry_run != true | |
| env: | |
| LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} | |
| REPOSITORY: ${{ github.repository }} | |
| TEAM_UUID: ${{ steps.match.outputs.team_uuid }} | |
| DONE_STATE_ID: ${{ steps.match.outputs.done_state_id }} | |
| SKIP_CODEQL: ${{ inputs.skip_codeql || false }} | |
| run: | | |
| set -euo pipefail | |
| # The job only reaches this step after every configured scanner | |
| # produced a complete snapshot. Pipeline-managed issues use the exact | |
| # "[repository] finding-id: title" convention, so an open issue can be | |
| # closed when its exact finding id is absent from findings.json. | |
| if [ -z "$DONE_STATE_ID" ]; then | |
| echo "::error::Could not resolve a completed Linear workflow state." | |
| exit 1 | |
| fi | |
| PREFIX="[$REPOSITORY]" | |
| SEARCH_QUERY='query($prefix: String!, $teamId: ID!, $after: String) { issues(first: 100, after: $after, filter: { title: { startsWith: $prefix }, team: { id: { eq: $teamId } }, state: { type: { nin: ["completed", "canceled", "duplicate"] } } }) { nodes { id identifier url title } pageInfo { hasNextPage endCursor } } }' | |
| echo '[]' > /tmp/open-issues.json | |
| AFTER="" | |
| while true; do | |
| if [ -z "$AFTER" ]; then | |
| VARS=$(jq -n --arg prefix "$PREFIX" --arg teamId "$TEAM_UUID" '{prefix: $prefix, teamId: $teamId}') | |
| else | |
| VARS=$(jq -n \ | |
| --arg prefix "$PREFIX" \ | |
| --arg teamId "$TEAM_UUID" \ | |
| --arg after "$AFTER" \ | |
| '{prefix: $prefix, teamId: $teamId, after: $after}') | |
| fi | |
| PAYLOAD=$(jq -n --arg query "$SEARCH_QUERY" --argjson vars "$VARS" '{query: $query, variables: $vars}') | |
| RESPONSE=$(LINEAR_API_KEY="$LINEAR_API_KEY" \ | |
| .vulnerability-triage-workflow/.github/scripts/linear-graphql-request.sh <<<"$PAYLOAD") | |
| if [ "$(echo "$RESPONSE" | jq 'has("errors") or (.data.issues == null)')" = "true" ]; then | |
| echo "::error::Failed to fetch open Linear issues: $(echo "$RESPONSE" | jq -c '.errors // .')" | |
| exit 1 | |
| fi | |
| echo "$RESPONSE" | jq '.data.issues.nodes' > /tmp/open-issues-page.json | |
| jq -s '.[0] + .[1]' /tmp/open-issues.json /tmp/open-issues-page.json > /tmp/open-issues.tmp | |
| mv /tmp/open-issues.tmp /tmp/open-issues.json | |
| HAS_NEXT=$(echo "$RESPONSE" | jq -r '.data.issues.pageInfo.hasNextPage') | |
| if [ "$HAS_NEXT" != "true" ]; then | |
| break | |
| fi | |
| AFTER=$(echo "$RESPONSE" | jq -r '.data.issues.pageInfo.endCursor') | |
| done | |
| jq \ | |
| --arg prefix "$PREFIX" \ | |
| --argjson skipCodeql "$SKIP_CODEQL" \ | |
| --slurpfile findings findings.json \ | |
| -f .vulnerability-triage-workflow/.github/scripts/classify-vulnerability-issues.jq \ | |
| /tmp/open-issues.json > /tmp/classified-issues.json | |
| CURRENT_COUNT=$(jq '.cves | length' findings.json) | |
| OPEN_COUNT=$(jq 'length' /tmp/open-issues.json) | |
| CLOSE_COUNT=$(jq '[.[] | select(.action == "close")] | length' /tmp/classified-issues.json) | |
| echo "Reconciling $OPEN_COUNT open managed issue(s) against $CURRENT_COUNT current finding(s); $CLOSE_COUNT issue(s) are resolved." | |
| echo "## Auto-close Resolved Issues" >> "$GITHUB_STEP_SUMMARY" | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| CLOSE_MUTATION='mutation($issueId: String!, $stateId: String!) { issueUpdate(id: $issueId, input: { stateId: $stateId }) { success issue { id identifier url } } }' | |
| CLOSED_COUNT=0 | |
| FAILED_COUNT=0 | |
| jq -c '.[] | select(.action == "close")' /tmp/classified-issues.json > /tmp/issues-to-close.jsonl | |
| while IFS= read -r issue; do | |
| ISSUE_ID=$(echo "$issue" | jq -r '.id') | |
| ISSUE_IDENTIFIER=$(echo "$issue" | jq -r '.identifier') | |
| ISSUE_URL=$(echo "$issue" | jq -r '.url') | |
| FINDING_ID=$(echo "$issue" | jq -r '.findingId') | |
| CLOSE_VARS=$(jq -n --arg issueId "$ISSUE_ID" --arg stateId "$DONE_STATE_ID" '{issueId: $issueId, stateId: $stateId}') | |
| CLOSE_PAYLOAD=$(jq -n --arg query "$CLOSE_MUTATION" --argjson vars "$CLOSE_VARS" '{query: $query, variables: $vars}') | |
| if ! CLOSE_RESPONSE=$(LINEAR_API_KEY="$LINEAR_API_KEY" \ | |
| .vulnerability-triage-workflow/.github/scripts/linear-graphql-request.sh <<<"$CLOSE_PAYLOAD"); then | |
| echo "::error::Linear request failed while closing $ISSUE_IDENTIFIER" | |
| echo "- **FAILED** to close [$ISSUE_IDENTIFIER]($ISSUE_URL)" >> "$GITHUB_STEP_SUMMARY" | |
| FAILED_COUNT=$((FAILED_COUNT + 1)) | |
| continue | |
| fi | |
| if [ "$(echo "$CLOSE_RESPONSE" | jq -r '.data.issueUpdate.success // false')" = "true" ]; then | |
| echo "Closed $ISSUE_IDENTIFIER — $FINDING_ID is no longer reported" | |
| echo "- Closed [$ISSUE_IDENTIFIER]($ISSUE_URL) — **$FINDING_ID** is no longer reported" >> "$GITHUB_STEP_SUMMARY" | |
| CLOSED_COUNT=$((CLOSED_COUNT + 1)) | |
| else | |
| echo "::error::Failed to close $ISSUE_IDENTIFIER" | |
| echo "$CLOSE_RESPONSE" | jq . | |
| echo "- **FAILED** to close [$ISSUE_IDENTIFIER]($ISSUE_URL)" >> "$GITHUB_STEP_SUMMARY" | |
| FAILED_COUNT=$((FAILED_COUNT + 1)) | |
| fi | |
| done < /tmp/issues-to-close.jsonl | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "**Summary:** Closed $CLOSED_COUNT resolved issue(s), failed $FAILED_COUNT." >> "$GITHUB_STEP_SUMMARY" | |
| if [ "$CLOSED_COUNT" -eq 0 ] && [ "$FAILED_COUNT" -eq 0 ]; then | |
| echo "No resolved issues to close." >> "$GITHUB_STEP_SUMMARY" | |
| fi | |
| if [ "$FAILED_COUNT" -gt 0 ]; then | |
| exit 1 | |
| fi |