Skip to content

Vulnerability Scan & Triage #11

Vulnerability Scan & Triage

Vulnerability Scan & Triage #11

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: latest)'
required: false
default: 'latest'
dry_run:
description: 'Dry run (analyze but do not create Linear issues)'
required: false
type: boolean
default: false
force_analysis:
description: 'Force Claude analysis even if no vulnerabilities are found'
required: false
type: boolean
default: false
env:
IMAGE: ghcr.io/sourcebot-dev/sourcebot
permissions:
contents: read
packages: read
id-token: write # Required for OIDC authentication
jobs:
scan:
name: Trivy Scan
runs-on: ubuntu-latest
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: "${{ env.IMAGE }}:${{ inputs.image_tag || 'latest' }}"
format: "table"
output: "trivy-results.txt"
trivy-config: trivy.yaml
- name: Check for vulnerabilities
id: check
run: |
if [ -s trivy-results.txt ] && grep -qE "Total: [1-9]" trivy-results.txt; 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.txt
retention-days: 30
triage:
name: Claude Analysis & Linear Triage
needs: scan
if: needs.scan.outputs.has_vulnerabilities == 'true' || inputs.force_analysis == true
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
- name: Download scan results
uses: actions/download-artifact@v4
with:
name: trivy-results
- name: Analyze vulnerabilities with Claude
id: claude
uses: anthropics/claude-code-action@v1
env:
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
LINEAR_TEAM_ID: ${{ secrets.LINEAR_TEAM_ID }}
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
claude_args: |
--allowedTools Bash,Read,Write,Edit,Glob,Grep,WebSearch,WebFetch
--model claude-sonnet-4-6
--json-schema '{"type":"object","properties":{"cves":{"type":"array","items":{"type":"object","properties":{"cveId":{"type":"string"},"severity":{"type":"string","enum":["CRITICAL","HIGH","MEDIUM","LOW"]},"title":{"type":"string","description":"Short summary for the Linear issue title"},"description":{"type":"string","description":"Markdown analysis: affected packages, direct vs transitive, remediation steps, and references"},"affectedPackage":{"type":"string"},"linearIssueExists":{"type":"boolean"}},"required":["cveId","severity","title","description","affectedPackage","linearIssueExists"]}}},"required":["cves"]}'
prompt: |
You are a security engineer triaging a Trivy vulnerability scan for the Sourcebot Docker image.
## Your Task
1. Read and analyze the Trivy scan results in `trivy-results.txt`. For **each unique CVE**, produce
a separate entry in the `cves` array.
2. For each CVE, determine:
- The affected package and whether it is a direct or transitive dependency.
- Remediation steps (e.g., upgrade to a specific version).
- A short `title` suitable for a Linear issue title.
- A `description` in markdown with your full analysis, references, and remediation guidance.
- The `severity` (CRITICAL, HIGH, MEDIUM, or LOW) as reported by Trivy.
3. Read files such as `Dockerfile`, `package.json`, and `go.mod` to gather context about
dependencies. Use `yarn why <package> --recursive` to determine why an npm package is included.
4. **Check Linear for existing issues** for each CVE:
- For each CVE ID, run a GraphQL query against the Linear API to search for issues whose title
contains the CVE ID and that belong to the team.
- Use the following curl command pattern:
```
curl -s -X POST https://api.linear.app/graphql \
-H "Content-Type: application/json" \
-H "Authorization: $LINEAR_API_KEY" \
-d '{"query": "query { issues(filter: { team: { id: { eq: \"'$LINEAR_TEAM_ID'\" } }, title: { contains: \"CVE-XXXX-XXXXX\" } }) { nodes { id title } } }"}'
```
- Set `linearIssueExists` to `true` if any matching issue is found, `false` otherwise.
5. Return the structured JSON with all CVEs in the `cves` array.
- name: Write job summary
env:
STRUCTURED_OUTPUT: ${{ steps.claude.outputs.structured_output }}
run: |
CVE_COUNT=$(echo "$STRUCTURED_OUTPUT" | jq '.cves | length')
NEW_COUNT=$(echo "$STRUCTURED_OUTPUT" | jq '[.cves[] | select(.linearIssueExists == false)] | length')
EXISTING_COUNT=$(echo "$STRUCTURED_OUTPUT" | jq '[.cves[] | select(.linearIssueExists == true)] | length')
echo "## Trivy Vulnerability Triage" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "**$CVE_COUNT** CVE(s) found: **$NEW_COUNT** new, **$EXISTING_COUNT** already tracked in Linear." >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| CVE ID | Severity | Package | Linear Status |" >> "$GITHUB_STEP_SUMMARY"
echo "|--------|----------|---------|---------------|" >> "$GITHUB_STEP_SUMMARY"
echo "$STRUCTURED_OUTPUT" | jq -r '.cves[] | "| \(.cveId) | \(.severity) | \(.affectedPackage) | \(if .linearIssueExists then "Existing" else "New" end) |"' >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "### Details" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "$STRUCTURED_OUTPUT" | jq -r '.cves[] | "#### \(.cveId): \(.title)\n\n\(.description)\n"' >> "$GITHUB_STEP_SUMMARY"
- name: Create Linear issues
if: inputs.dry_run != true
env:
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
LINEAR_TEAM_ID: ${{ secrets.LINEAR_TEAM_ID }}
STRUCTURED_OUTPUT: ${{ steps.claude.outputs.structured_output }}
REPOSITORY: ${{ github.repository }}
run: |
# Look up the "CVE" label ID and "Triage" state ID for the team
METADATA_RESPONSE=$(curl -s -X POST https://api.linear.app/graphql \
-H "Content-Type: application/json" \
-H "Authorization: $LINEAR_API_KEY" \
-d "{\"query\": \"query { team(id: \\\"$LINEAR_TEAM_ID\\\") { labels(filter: { name: { eq: \\\"CVE\\\" } }) { nodes { id } } states(filter: { name: { eq: \\\"Triage\\\" } }) { nodes { id } } } }\"}")
LABEL_ID=$(echo "$METADATA_RESPONSE" | jq -r '.data.team.labels.nodes[0].id // empty')
STATE_ID=$(echo "$METADATA_RESPONSE" | jq -r '.data.team.states.nodes[0].id // empty')
if [ -z "$LABEL_ID" ]; then
echo "::warning::Could not find 'CVE' label in Linear team. Creating issues without label."
fi
if [ -z "$STATE_ID" ]; then
echo "::warning::Could not find 'Triage' state in Linear team. Using default state."
fi
# Map severity to Linear priority
severity_to_priority() {
case "$1" in
CRITICAL) echo 1 ;;
HIGH) echo 2 ;;
MEDIUM) echo 3 ;;
LOW) echo 4 ;;
*) echo 3 ;;
esac
}
CREATED_COUNT=0
SKIPPED_COUNT=0
# 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
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')
if [ "$LINEAR_EXISTS" = "true" ]; then
echo "Skipping $CVE_ID — Linear issue already exists."
SKIPPED_COUNT=$((SKIPPED_COUNT + 1))
continue
fi
PRIORITY=$(severity_to_priority "$SEVERITY")
ISSUE_TITLE="[$REPOSITORY] $CVE_ID: $TITLE"
ESCAPED_TITLE=$(echo "$ISSUE_TITLE" | jq -Rs . | sed 's/^"//;s/"$//')
ESCAPED_DESC=$(echo "$DESCRIPTION" | jq -Rs .)
EXTRA_INPUT=""
if [ -n "$LABEL_ID" ]; then
EXTRA_INPUT="$EXTRA_INPUT, labelIds: [\\\"$LABEL_ID\\\"]"
fi
if [ -n "$STATE_ID" ]; then
EXTRA_INPUT="$EXTRA_INPUT, stateId: \\\"$STATE_ID\\\""
fi
RESPONSE=$(curl -s -X POST https://api.linear.app/graphql \
-H "Content-Type: application/json" \
-H "Authorization: $LINEAR_API_KEY" \
-d "{\"query\": \"mutation { issueCreate(input: { teamId: \\\"$LINEAR_TEAM_ID\\\", title: \\\"$ESCAPED_TITLE\\\", description: $ESCAPED_DESC, priority: $PRIORITY$EXTRA_INPUT }) { success issue { id identifier url } } }\"}")
ISSUE_URL=$(echo "$RESPONSE" | jq -r '.data.issueCreate.issue.url // empty')
if [ -n "$ISSUE_URL" ]; then
echo "Created Linear issue for $CVE_ID: $ISSUE_URL"
echo "- Created [$CVE_ID]($ISSUE_URL) (priority: $SEVERITY)" >> "$GITHUB_STEP_SUMMARY"
CREATED_COUNT=$((CREATED_COUNT + 1))
else
echo "::warning::Failed to create Linear issue for $CVE_ID"
echo "$RESPONSE" | jq .
fi
done < /tmp/cves.jsonl
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "**Summary:** Created $CREATED_COUNT issue(s), skipped $SKIPPED_COUNT existing issue(s)." >> "$GITHUB_STEP_SUMMARY"