Skip to content

chore: automate CVE remediation (#1538) #220

chore: automate CVE remediation (#1538)

chore: automate CVE remediation (#1538) #220

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