Vulnerability Scan & Triage #11
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Vulnerability Scan & Triage | |
| on: | |
| schedule: | |
| # Run daily at 6am Pacific (13:00 UTC during PDT) | |
| - cron: '0 13 * * *' | |
| workflow_dispatch: | |
| inputs: | |
| image_tag: | |
| description: 'Image tag to scan (default: 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" |