Skip to content

Vulnerability Scan & Triage #70

Vulnerability Scan & Triage

Vulnerability Scan & Triage #70

name: Vulnerability Scan & Triage
on:
schedule:
# Run daily at 6am Pacific (13:00 UTC during PDT)
- cron: '0 13 * * *'
workflow_dispatch:
inputs:
image_tag:
description: 'Image tag to scan (default: main)'
required: false
default: 'main'
dry_run:
description: 'Dry run (analyze but do not create Linear issues)'
required: false
type: boolean
default: false
force_analysis:
description: 'Force 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
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
id-token: write # Required for OIDC authentication
jobs:
scan:
name: Trivy Scan
runs-on: ubuntu-latest
if: github.repository == 'sourcebot-dev/sourcebot' || inputs.image != ''
outputs:
has_vulnerabilities: ${{ steps.check.outputs.has_vulnerabilities }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Run Trivy vulnerability scan
uses: aquasecurity/trivy-action@master
with:
image-ref: "${{ inputs.image || env.IMAGE }}:${{ inputs.image_tag || 'main' }}"
format: "json"
output: "trivy-results.json"
trivy-config: trivy.yaml
- name: Check for vulnerabilities
id: check
run: |
VULN_COUNT=$(jq '[.Results[]? | .Vulnerabilities[]?] | length' trivy-results.json)
if [ "$VULN_COUNT" -gt 0 ]; then
echo "has_vulnerabilities=true" >> "$GITHUB_OUTPUT"
else
echo "has_vulnerabilities=false" >> "$GITHUB_OUTPUT"
fi
- name: Upload scan results
if: steps.check.outputs.has_vulnerabilities == 'true' || inputs.force_analysis == true
uses: actions/upload-artifact@v4
with:
name: trivy-results
path: trivy-results.json
retention-days: 30
- name: Write Trivy summary
run: |
echo "## Trivy Scan" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "**Image:** \`${{ inputs.image || env.IMAGE }}:${{ inputs.image_tag || 'main' }}\`" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
if [ "${{ steps.check.outputs.has_vulnerabilities }}" = "true" ]; then
VULN_COUNT=$(jq '[.Results[]? | .Vulnerabilities[]?] | length' trivy-results.json)
CRIT_COUNT=$(jq '[.Results[]? | .Vulnerabilities[]? | select(.Severity == "CRITICAL")] | length' trivy-results.json)
HIGH_COUNT=$(jq '[.Results[]? | .Vulnerabilities[]? | select(.Severity == "HIGH")] | length' trivy-results.json)
MED_COUNT=$(jq '[.Results[]? | .Vulnerabilities[]? | select(.Severity == "MEDIUM")] | length' trivy-results.json)
echo "**$VULN_COUNT** vulnerabilities found: **$CRIT_COUNT** critical, **$HIGH_COUNT** high, **$MED_COUNT** medium." >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| CVE ID | Severity | Package | Installed | Fixed |" >> "$GITHUB_STEP_SUMMARY"
echo "|--------|----------|---------|-----------|-------|" >> "$GITHUB_STEP_SUMMARY"
jq -r '[.Results[]? | .Vulnerabilities[]?] | sort_by(.Severity) | .[] | "| \(.VulnerabilityID) | \(.Severity) | \(.PkgName) | \(.InstalledVersion) | \(.FixedVersion // "N/A") |"' trivy-results.json >> "$GITHUB_STEP_SUMMARY"
else
echo "No vulnerabilities found." >> "$GITHUB_STEP_SUMMARY"
fi
check-alerts:
name: Check Dependabot & CodeQL Alerts
runs-on: ubuntu-latest
outputs:
has_alerts: ${{ steps.check.outputs.has_alerts }}
steps:
- name: Check for open alerts
id: check
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DEPENDABOT_PAT: ${{ secrets.DEPENDABOT_PAT }}
run: |
HAS_ALERTS=false
# Check Dependabot alerts (requires DEPENDABOT_PAT)
if [ -n "$DEPENDABOT_PAT" ]; then
DEPENDABOT_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $DEPENDABOT_PAT" \
"https://api.github.com/repos/${{ github.repository }}/dependabot/alerts?state=open&per_page=1")
if [ "$DEPENDABOT_STATUS" = "200" ]; then
DEPENDABOT_COUNT=$(curl -s \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $DEPENDABOT_PAT" \
"https://api.github.com/repos/${{ github.repository }}/dependabot/alerts?state=open&per_page=1" | jq 'length')
if [ "$DEPENDABOT_COUNT" -gt 0 ]; then
echo "Found open Dependabot alerts"
HAS_ALERTS=true
fi
else
echo "::warning::Could not fetch Dependabot alerts (HTTP $DEPENDABOT_STATUS). Is DEPENDABOT_PAT configured?"
fi
else
echo "::warning::DEPENDABOT_PAT not configured. Skipping Dependabot alert check."
fi
# Check CodeQL alerts (uses GITHUB_TOKEN with security-events: read)
CODEQL_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GH_TOKEN" \
"https://api.github.com/repos/${{ github.repository }}/code-scanning/alerts?state=open&per_page=1")
if [ "$CODEQL_STATUS" = "200" ]; then
CODEQL_COUNT=$(curl -s \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GH_TOKEN" \
"https://api.github.com/repos/${{ github.repository }}/code-scanning/alerts?state=open&per_page=1" | jq 'length')
if [ "$CODEQL_COUNT" -gt 0 ]; then
echo "Found open CodeQL alerts"
HAS_ALERTS=true
fi
elif [ "$CODEQL_STATUS" = "404" ]; then
echo "CodeQL is not enabled for this repository. Skipping."
else
echo "::warning::Could not fetch CodeQL alerts (HTTP $CODEQL_STATUS)"
fi
echo "has_alerts=$HAS_ALERTS" >> "$GITHUB_OUTPUT"
- name: Write alerts summary
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DEPENDABOT_PAT: ${{ secrets.DEPENDABOT_PAT }}
run: |
echo "## Dependabot & CodeQL Alert Check" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
# Dependabot status
if [ -z "$DEPENDABOT_PAT" ]; then
echo "### Dependabot" >> "$GITHUB_STEP_SUMMARY"
echo "Skipped (DEPENDABOT_PAT not configured)" >> "$GITHUB_STEP_SUMMARY"
else
DEPENDABOT_RESPONSE=$(curl -s \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $DEPENDABOT_PAT" \
"https://api.github.com/repos/${{ github.repository }}/dependabot/alerts?state=open&per_page=100")
DEPENDABOT_COUNT=$(echo "$DEPENDABOT_RESPONSE" | jq 'if type == "array" then length else 0 end')
echo "### Dependabot — $DEPENDABOT_COUNT open alert(s)" >> "$GITHUB_STEP_SUMMARY"
if [ "$DEPENDABOT_COUNT" -gt 0 ]; then
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| CVE / GHSA | Severity | Package | Ecosystem | Patched Version | Link |" >> "$GITHUB_STEP_SUMMARY"
echo "|------------|----------|---------|-----------|-----------------|------|" >> "$GITHUB_STEP_SUMMARY"
echo "$DEPENDABOT_RESPONSE" | jq -r '.[] | "| \(.security_advisory.cve_id // .security_advisory.ghsa_id // "—") | \(.security_advisory.severity // "—") | \(.security_vulnerability.package.name // "—") | \(.security_vulnerability.package.ecosystem // "—") | \(.security_vulnerability.first_patched_version.identifier // "N/A") | [View](\(.html_url)) |"' >> "$GITHUB_STEP_SUMMARY"
fi
fi
echo "" >> "$GITHUB_STEP_SUMMARY"
# CodeQL status
CODEQL_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GH_TOKEN" \
"https://api.github.com/repos/${{ github.repository }}/code-scanning/alerts?state=open&per_page=100")
if [ "$CODEQL_STATUS" = "404" ]; then
echo "### CodeQL" >> "$GITHUB_STEP_SUMMARY"
echo "Not enabled for this repository." >> "$GITHUB_STEP_SUMMARY"
elif [ "$CODEQL_STATUS" = "200" ]; then
CODEQL_RESPONSE=$(curl -s \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GH_TOKEN" \
"https://api.github.com/repos/${{ github.repository }}/code-scanning/alerts?state=open&per_page=100")
CODEQL_COUNT=$(echo "$CODEQL_RESPONSE" | jq 'length')
echo "### CodeQL — $CODEQL_COUNT open alert(s)" >> "$GITHUB_STEP_SUMMARY"
if [ "$CODEQL_COUNT" -gt 0 ]; then
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| Rule ID | Severity | Tool | File | Lines | Link |" >> "$GITHUB_STEP_SUMMARY"
echo "|---------|----------|------|------|-------|------|" >> "$GITHUB_STEP_SUMMARY"
echo "$CODEQL_RESPONSE" | jq -r '.[] | "| \(.rule.id // "—") | \(.rule.security_severity_level // "—") | \(.tool.name // "—") | \(.most_recent_instance.location.path // "—") | \(.most_recent_instance.location.start_line // "—")-\(.most_recent_instance.location.end_line // "—") | [View](\(.html_url)) |"' >> "$GITHUB_STEP_SUMMARY"
fi
else
echo "### CodeQL" >> "$GITHUB_STEP_SUMMARY"
echo "Failed to check (HTTP $CODEQL_STATUS)" >> "$GITHUB_STEP_SUMMARY"
fi
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "**Result:** has_alerts=${{ steps.check.outputs.has_alerts }}" >> "$GITHUB_STEP_SUMMARY"
triage:
name: Linear Triage
needs: [scan, check-alerts]
if: >-
always() && !cancelled() && (
needs.scan.outputs.has_vulnerabilities == 'true' ||
needs.check-alerts.outputs.has_alerts == 'true' ||
inputs.force_analysis == true
)
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
- name: Download scan results
if: needs.scan.outputs.has_vulnerabilities == 'true'
uses: actions/download-artifact@v4
with:
name: trivy-results
- name: Normalize Trivy results
run: |
if [ ! -f trivy-results.json ]; then
echo '{"Results":[]}' > trivy-results.json
fi
jq '[.Results[]? | .Vulnerabilities[]? | {
id: .VulnerabilityID,
severity: .Severity,
pkg_name: .PkgName,
installed_version: .InstalledVersion,
fixed_version: (.FixedVersion // ""),
title: (.Title // ""),
description: (.Description // ""),
references: ([.References[]?] // [])
}]' trivy-results.json > trivy-alerts.json
- name: Fetch Dependabot alerts
env:
DEPENDABOT_PAT: ${{ secrets.DEPENDABOT_PAT }}
run: |
if [ -z "$DEPENDABOT_PAT" ]; then
echo "::warning::DEPENDABOT_PAT not configured. Writing empty Dependabot alerts."
echo "[]" > dependabot-alerts.json
exit 0
fi
ALL_ALERTS="[]"
URL="https://api.github.com/repos/${{ github.repository }}/dependabot/alerts?state=open&per_page=100"
while [ -n "$URL" ]; do
# Fetch with headers saved to parse Link for cursor pagination
HTTP_CODE=$(curl -s -o /tmp/dependabot-body.json -w "%{http_code}" -D /tmp/dependabot-headers.txt \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $DEPENDABOT_PAT" \
"$URL")
echo "Dependabot API response: HTTP $HTTP_CODE"
if [ "$HTTP_CODE" != "200" ]; then
echo "::warning::Failed to fetch Dependabot alerts (HTTP $HTTP_CODE). Writing empty results."
echo "Response body: $(cat /tmp/dependabot-body.json | head -c 500)"
echo "[]" > dependabot-alerts.json
exit 0
fi
BODY=$(cat /tmp/dependabot-body.json)
COUNT=$(echo "$BODY" | jq 'length')
echo "Page returned $COUNT alert(s)"
if [ "$COUNT" -eq 0 ]; then
break
fi
EXTRACTED=$(echo "$BODY" | jq '[.[] | {
id: (.security_advisory.cve_id // .security_advisory.ghsa_id // ""),
cve_id: (.security_advisory.cve_id // null),
ghsa_id: (.security_advisory.ghsa_id // null),
severity: (.security_advisory.severity // "medium"),
summary: (.security_advisory.summary // ""),
description: (.security_advisory.description // ""),
package_name: (.security_vulnerability.package.name // ""),
package_ecosystem: (.security_vulnerability.package.ecosystem // ""),
manifest_path: (.dependency.manifest_path // ""),
html_url: (.html_url // ""),
first_patched_version: (.security_vulnerability.first_patched_version.identifier // "")
}]')
EXTRACTED_COUNT=$(echo "$EXTRACTED" | jq 'length')
echo "Extracted $EXTRACTED_COUNT alert(s) after parsing"
ALL_ALERTS=$(echo "$ALL_ALERTS" "$EXTRACTED" | jq -s '.[0] + .[1]')
# Parse Link header for next page URL (cursor-based pagination)
URL=$(sed -n 's/.*<\([^>]*\)>; *rel="next".*/\1/p' /tmp/dependabot-headers.txt || true)
done
ALERT_COUNT=$(echo "$ALL_ALERTS" | jq 'length')
echo "Fetched $ALERT_COUNT Dependabot alert(s) total"
echo "$ALL_ALERTS" > dependabot-alerts.json
- name: Write Dependabot fetch summary
run: |
echo "## Dependabot Alerts Fetched" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
if [ ! -f dependabot-alerts.json ]; then
echo "No Dependabot alerts file found." >> "$GITHUB_STEP_SUMMARY"
else
COUNT=$(jq 'length' dependabot-alerts.json)
echo "**$COUNT** open Dependabot alert(s) fetched." >> "$GITHUB_STEP_SUMMARY"
if [ "$COUNT" -gt 0 ]; then
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| CVE / GHSA | Severity | Package | Ecosystem | Patched Version |" >> "$GITHUB_STEP_SUMMARY"
echo "|------------|----------|---------|-----------|-----------------|" >> "$GITHUB_STEP_SUMMARY"
jq -r '.[] | "| \(.id) | \(.severity) | \(.package_name) | \(.package_ecosystem) | \(.first_patched_version // "N/A") |"' dependabot-alerts.json >> "$GITHUB_STEP_SUMMARY"
fi
fi
- name: Fetch CodeQL alerts
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
ALL_ALERTS="[]"
PAGE=1
while true; do
RESPONSE=$(curl -s -w "\n%{http_code}" \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GH_TOKEN" \
"https://api.github.com/repos/${{ github.repository }}/code-scanning/alerts?state=open&per_page=100&page=$PAGE")
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
BODY=$(echo "$RESPONSE" | sed '$d')
if [ "$HTTP_CODE" = "404" ]; then
echo "CodeQL is not enabled for this repository. Writing empty results."
echo "[]" > codeql-alerts.json
exit 0
fi
if [ "$HTTP_CODE" != "200" ]; then
echo "::warning::Failed to fetch CodeQL alerts (HTTP $HTTP_CODE). Writing empty results."
echo "[]" > codeql-alerts.json
exit 0
fi
COUNT=$(echo "$BODY" | jq 'length')
if [ "$COUNT" -eq 0 ]; then
break
fi
EXTRACTED=$(echo "$BODY" | jq '[.[] | {
id: ("codeql:" + (.rule.id // "")),
number: .number,
rule_id: (.rule.id // ""),
rule_description: (.rule.description // ""),
security_severity_level: (.rule.security_severity_level // "medium"),
tool_name: (.tool.name // ""),
location_path: (.most_recent_instance.location.path // ""),
location_start_line: (.most_recent_instance.location.start_line // 0),
location_end_line: (.most_recent_instance.location.end_line // 0),
html_url: (.html_url // ""),
state: (.state // "")
}]')
ALL_ALERTS=$(echo "$ALL_ALERTS" "$EXTRACTED" | jq -s '.[0] + .[1]')
if [ "$COUNT" -lt 100 ]; then
break
fi
PAGE=$((PAGE + 1))
done
ALERT_COUNT=$(echo "$ALL_ALERTS" | jq 'length')
echo "Fetched $ALERT_COUNT CodeQL alert(s)"
echo "$ALL_ALERTS" > codeql-alerts.json
- name: Write CodeQL fetch summary
run: |
echo "## CodeQL Alerts Fetched" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
if [ ! -f codeql-alerts.json ]; then
echo "No CodeQL alerts file found." >> "$GITHUB_STEP_SUMMARY"
else
COUNT=$(jq 'length' codeql-alerts.json)
echo "**$COUNT** open CodeQL alert(s) fetched." >> "$GITHUB_STEP_SUMMARY"
if [ "$COUNT" -gt 0 ]; then
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| Rule ID | Severity | Tool | File | Lines |" >> "$GITHUB_STEP_SUMMARY"
echo "|---------|----------|------|------|-------|" >> "$GITHUB_STEP_SUMMARY"
jq -r '.[] | "| \(.id) | \(.security_severity_level) | \(.tool_name) | \(.location_path) | \(.location_start_line)-\(.location_end_line) |"' codeql-alerts.json >> "$GITHUB_STEP_SUMMARY"
fi
fi
- name: 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 }}
run: |
set -euo pipefail
# Resolve team UUID + the "CVE" label, "Triage" state, and API key owner once,
# and expose them as outputs so the issue-creation step can reuse them.
METADATA_QUERY='query($teamId: String!) { team(id: $teamId) { id labels(filter: { name: { eq: "CVE" } }) { nodes { id } } states(filter: { name: { eq: "Triage" } }) { nodes { id } } } viewer { id } }'
METADATA_PAYLOAD=$(jq -n --arg query "$METADATA_QUERY" --arg teamId "$LINEAR_TEAM_ID" \
'{query: $query, variables: {teamId: $teamId}}')
METADATA_RESPONSE=$(curl -s -X POST https://api.linear.app/graphql \
-H "Content-Type: application/json" \
-H "Authorization: $LINEAR_API_KEY" \
-d "$METADATA_PAYLOAD")
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')
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
{
echo "team_uuid=$TEAM_UUID"
echo "label_id=$LABEL_ID"
echo "state_id=$STATE_ID"
echo "viewer_id=$VIEWER_ID"
} >> "$GITHUB_OUTPUT"
# For each finding, search Linear for an existing issue whose title contains the
# finding id AND is scoped to this repo (title starts with "[<repository>]").
# Prefer an open issue over a closed one.
SEARCH_QUERY='query($teamId: String!, $text: String!) { issues(filter: { team: { id: { eq: $teamId } }, title: { contains: $text } }) { 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')
VARS=$(jq -n --arg teamId "$TEAM_UUID" --arg text "$CVE_ID" '{teamId: $teamId, text: $text}')
PAYLOAD=$(jq -n --arg query "$SEARCH_QUERY" --argjson vars "$VARS" '{query: $query, variables: $vars}')
RESPONSE=$(curl -s -X POST https://api.linear.app/graphql \
-H "Content-Type: application/json" \
-H "Authorization: $LINEAR_API_KEY" \
-d "$PAYLOAD")
SELECTED=$(echo "$RESPONSE" | jq --arg prefix "[$REPOSITORY]" '
[.data.issues.nodes[]? | select(.title | startswith($prefix))] as $matches |
($matches | map(select(.state.type != "completed" and .state.type != "canceled")) | .[0]) as $open |
($open // $matches[0]) as $chosen |
if $chosen == null then
{linearIssueExists: false, linearIssueId: "", linearIssueIdentifier: "", linearIssueUrl: "", linearIssueClosed: false}
else
{linearIssueExists: true, linearIssueId: $chosen.id, linearIssueIdentifier: $chosen.identifier, linearIssueUrl: $chosen.url, linearIssueClosed: (($chosen.state.type == "completed") or ($chosen.state.type == "canceled"))}
end
')
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 }}
STATE_ID: ${{ steps.match.outputs.state_id }}
VIEWER_ID: ${{ steps.match.outputs.viewer_id }}
run: |
set -uo pipefail
# Team UUID, "CVE" label, "Triage" state, and the API key owner's user ID were
# already resolved by the "Match existing Linear issues" step and passed in as env.
STRUCTURED_OUTPUT=$(cat findings.json)
if [ -z "$LABEL_ID" ]; then
echo "::warning::Could not find 'CVE' label in Linear team. Creating issues without label."
fi
if [ -z "$STATE_ID" ]; then
echo "::warning::Could not find 'Triage' state in Linear team. Using default state."
fi
if [ -z "$VIEWER_ID" ]; then
echo "::warning::Could not resolve Linear API key owner. Issues will be created unassigned."
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) { issueCreate(input: { teamId: $teamId, title: $title, description: $description, priority: $priority, labelIds: $labelIds, stateId: $stateId, assigneeId: $assigneeId }) { success issue { id identifier url } } }'
while IFS= read -r cve; do
CVE_ID=$(echo "$cve" | jq -r '.cveId')
SEVERITY=$(echo "$cve" | jq -r '.severity')
TITLE=$(echo "$cve" | jq -r '.title')
DESCRIPTION=$(echo "$cve" | jq -r '.description')
LINEAR_EXISTS=$(echo "$cve" | jq -r '.linearIssueExists')
LINEAR_ISSUE_ID=$(echo "$cve" | jq -r '.linearIssueId')
LINEAR_IDENTIFIER=$(echo "$cve" | jq -r '.linearIssueIdentifier')
LINEAR_URL=$(echo "$cve" | jq -r '.linearIssueUrl')
LINEAR_CLOSED=$(echo "$cve" | jq -r '.linearIssueClosed')
if [ "$LINEAR_EXISTS" = "true" ] && [ "$LINEAR_CLOSED" = "false" ]; then
echo "Skipping $CVE_ID — Linear issue $LINEAR_IDENTIFIER already exists and is open ($LINEAR_URL)"
echo "- Skipped **$CVE_ID** — already tracked in [$LINEAR_IDENTIFIER]($LINEAR_URL) (open)" >> "$GITHUB_STEP_SUMMARY"
SKIPPED_COUNT=$((SKIPPED_COUNT + 1))
continue
fi
if [ "$LINEAR_EXISTS" = "true" ] && [ "$LINEAR_CLOSED" = "true" ]; then
# Reopen the closed issue by setting its state back to Triage
echo "Found closed Linear issue $LINEAR_IDENTIFIER for $CVE_ID ($LINEAR_URL) — will attempt to reopen"
if [ -z "$STATE_ID" ]; then
echo "::warning::Cannot reopen $CVE_ID ($LINEAR_IDENTIFIER) — no Triage state found. Skipping."
echo "- Skipped **$CVE_ID** — found closed issue [$LINEAR_IDENTIFIER]($LINEAR_URL) but no Triage state to reopen" >> "$GITHUB_STEP_SUMMARY"
SKIPPED_COUNT=$((SKIPPED_COUNT + 1))
continue
fi
REOPEN_MUTATION='mutation($issueId: String!, $stateId: String!, $assigneeId: String) { issueUpdate(id: $issueId, input: { stateId: $stateId, assigneeId: $assigneeId }) { success issue { id identifier url } } }'
REOPEN_VARIABLES=$(jq -n \
--arg issueId "$LINEAR_ISSUE_ID" \
--arg stateId "$STATE_ID" \
'{issueId: $issueId, stateId: $stateId}')
if [ -n "$VIEWER_ID" ]; then
REOPEN_VARIABLES=$(echo "$REOPEN_VARIABLES" | jq --arg aid "$VIEWER_ID" '. + {assigneeId: $aid}')
fi
REOPEN_PAYLOAD=$(jq -n --arg query "$REOPEN_MUTATION" --argjson vars "$REOPEN_VARIABLES" '{query: $query, variables: $vars}')
REOPEN_RESPONSE=$(curl -s -X POST https://api.linear.app/graphql \
-H "Content-Type: application/json" \
-H "Authorization: $LINEAR_API_KEY" \
-d "$REOPEN_PAYLOAD")
REOPEN_URL=$(echo "$REOPEN_RESPONSE" | jq -r '.data.issueUpdate.issue.url // empty')
REOPEN_IDENTIFIER=$(echo "$REOPEN_RESPONSE" | jq -r '.data.issueUpdate.issue.identifier // empty')
if [ -n "$REOPEN_URL" ]; then
echo "Reopened Linear issue $REOPEN_IDENTIFIER for $CVE_ID: $REOPEN_URL"
echo "- Reopened [$REOPEN_IDENTIFIER]($REOPEN_URL) for **$CVE_ID** — $TITLE (moved back to Triage)" >> "$GITHUB_STEP_SUMMARY"
REOPENED_COUNT=$((REOPENED_COUNT + 1))
else
echo "::error::Failed to reopen Linear issue $LINEAR_IDENTIFIER for $CVE_ID"
echo "$REOPEN_RESPONSE" | jq .
echo "- **FAILED** to reopen [$LINEAR_IDENTIFIER]($LINEAR_URL) for **$CVE_ID**" >> "$GITHUB_STEP_SUMMARY"
FAILED_COUNT=$((FAILED_COUNT + 1))
fi
continue
fi
# Create new issue
PRIORITY=$(severity_to_priority "$SEVERITY")
ISSUE_TITLE="[$REPOSITORY] $CVE_ID: $TITLE"
# Build variables JSON with jq to handle all escaping properly
VARIABLES=$(jq -n \
--arg teamId "$TEAM_UUID" \
--arg title "$ISSUE_TITLE" \
--arg desc "$DESCRIPTION" \
--argjson priority "$PRIORITY" \
'{teamId: $teamId, title: $title, description: $desc, priority: $priority}')
if [ -n "$LABEL_ID" ]; then
VARIABLES=$(echo "$VARIABLES" | jq --arg lid "$LABEL_ID" '. + {labelIds: [$lid]}')
fi
if [ -n "$STATE_ID" ]; then
VARIABLES=$(echo "$VARIABLES" | jq --arg sid "$STATE_ID" '. + {stateId: $sid}')
fi
if [ -n "$VIEWER_ID" ]; then
VARIABLES=$(echo "$VARIABLES" | jq --arg aid "$VIEWER_ID" '. + {assigneeId: $aid}')
fi
PAYLOAD=$(jq -n --arg query "$MUTATION" --argjson vars "$VARIABLES" '{query: $query, variables: $vars}')
RESPONSE=$(curl -s -X POST https://api.linear.app/graphql \
-H "Content-Type: application/json" \
-H "Authorization: $LINEAR_API_KEY" \
-d "$PAYLOAD")
ISSUE_URL=$(echo "$RESPONSE" | jq -r '.data.issueCreate.issue.url // empty')
ISSUE_IDENTIFIER=$(echo "$RESPONSE" | jq -r '.data.issueCreate.issue.identifier // empty')
if [ -n "$ISSUE_URL" ]; then
echo "Created Linear issue $ISSUE_IDENTIFIER for $CVE_ID: $ISSUE_URL"
echo "- Created [$ISSUE_IDENTIFIER]($ISSUE_URL) for **$CVE_ID** — $TITLE (priority: $SEVERITY)" >> "$GITHUB_STEP_SUMMARY"
CREATED_COUNT=$((CREATED_COUNT + 1))
else
echo "::error::Failed to create Linear issue for $CVE_ID"
echo "$RESPONSE" | jq .
echo "- **FAILED** to create issue for **$CVE_ID** — $TITLE" >> "$GITHUB_STEP_SUMMARY"
FAILED_COUNT=$((FAILED_COUNT + 1))
fi
done < /tmp/cves.jsonl
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "**Summary:** Created $CREATED_COUNT issue(s), reopened $REOPENED_COUNT issue(s), skipped $SKIPPED_COUNT existing issue(s), failed $FAILED_COUNT issue(s)." >> "$GITHUB_STEP_SUMMARY"
if [ "$FAILED_COUNT" -gt 0 ]; then
echo "::error::Failed to create $FAILED_COUNT Linear issue(s)"
exit 1
fi