From e40cdd3026eb78dca7e8ee240eb1add8d1f0f571 Mon Sep 17 00:00:00 2001 From: msukkari Date: Mon, 27 Jul 2026 13:31:24 -0700 Subject: [PATCH 1/8] fix vulnerability triage for reusable callers --- .github/scripts/test-vulnerability-triage.sh | 17 +++ .github/workflows/vulnerability-triage.yml | 108 ++++++++++++------- 2 files changed, 89 insertions(+), 36 deletions(-) diff --git a/.github/scripts/test-vulnerability-triage.sh b/.github/scripts/test-vulnerability-triage.sh index 7a4f078ec..a08e9c72b 100755 --- a/.github/scripts/test-vulnerability-triage.sh +++ b/.github/scripts/test-vulnerability-triage.sh @@ -5,6 +5,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" MATCH_FILTER="$SCRIPT_DIR/match-vulnerability-issue.jq" CLASSIFY_FILTER="$SCRIPT_DIR/classify-vulnerability-issues.jq" +WORKFLOW_FILE="$SCRIPT_DIR/../workflows/vulnerability-triage.yml" PREFIX="[sourcebot-dev/example]" assert_json() { @@ -20,6 +21,22 @@ assert_json() { fi } +assert_workflow_contains() { + local description="$1" + local expected="$2" + + if ! grep -Fq -- "$expected" "$WORKFLOW_FILE"; then + echo "FAIL: $description" + echo "Expected workflow to contain: $expected" + exit 1 + fi +} + +assert_workflow_contains "checks out assets from the called workflow repository" 'repository: ${{ job.workflow_repository }}' +assert_workflow_contains "pins assets to the called workflow revision" 'ref: ${{ job.workflow_sha }}' +assert_workflow_contains "uses the shared match filter" '-f .vulnerability-triage-workflow/.github/scripts/match-vulnerability-issue.jq' +assert_workflow_contains "uses the shared classification filter" '-f .vulnerability-triage-workflow/.github/scripts/classify-vulnerability-issues.jq' + match() { local finding_id="$1" jq -c --arg prefix "$PREFIX" --arg findingId "$finding_id" -f "$MATCH_FILTER" diff --git a/.github/workflows/vulnerability-triage.yml b/.github/workflows/vulnerability-triage.yml index fb9af3189..80b941805 100644 --- a/.github/workflows/vulnerability-triage.yml +++ b/.github/workflows/vulnerability-triage.yml @@ -43,6 +43,11 @@ on: 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 @@ -160,6 +165,7 @@ jobs: 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 @@ -188,25 +194,33 @@ jobs: exit 1 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 \ + # 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" | jq 'length') - if [ "$CODEQL_COUNT" -gt 0 ]; then - echo "Found open CodeQL alerts" - HAS_ALERTS=true + "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 - 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 echo "has_alerts=$HAS_ALERTS" >> "$GITHUB_OUTPUT" @@ -215,6 +229,7 @@ jobs: 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" @@ -241,29 +256,34 @@ jobs: 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 + if [ "$SKIP_CODEQL" = "true" ]; then echo "### CodeQL" >> "$GITHUB_STEP_SUMMARY" - echo "Not enabled for this repository." >> "$GITHUB_STEP_SUMMARY" - elif [ "$CODEQL_STATUS" = "200" ]; then - CODEQL_RESPONSE=$(curl -s \ + 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") - 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" + 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 - else - echo "### CodeQL" >> "$GITHUB_STEP_SUMMARY" - echo "Failed to check (HTTP $CODEQL_STATUS)" >> "$GITHUB_STEP_SUMMARY" fi echo "" >> "$GITHUB_STEP_SUMMARY" @@ -285,6 +305,15 @@ jobs: - 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 @@ -388,8 +417,15 @@ jobs: - 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 @@ -672,7 +708,7 @@ jobs: SELECTED=$(echo "$RESPONSE" | jq \ --arg prefix "[$REPOSITORY]" \ --arg findingId "$CVE_ID" \ - -f .github/scripts/match-vulnerability-issue.jq) + -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 @@ -954,7 +990,7 @@ jobs: jq \ --arg prefix "$PREFIX" \ --slurpfile findings findings.json \ - -f .github/scripts/classify-vulnerability-issues.jq \ + -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) From eca25d4ed232be48feea3ab5d4bf99f2b2f3cbe0 Mon Sep 17 00:00:00 2001 From: msukkari Date: Mon, 27 Jul 2026 13:32:21 -0700 Subject: [PATCH 2/8] docs add vulnerability triage changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92fdc34be..bffd4e476 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Vulnerability triage now keeps Linear issues synchronized with current security findings. +- Fixed vulnerability triage for reusable-workflow callers and repositories without CodeQL. [#1515](https://github.com/sourcebot-dev/sourcebot/pull/1515) ## [5.1.4] - 2026-07-24 From 1074f1e5a8620a99427f500c3fe64db68d692001 Mon Sep 17 00:00:00 2001 From: msukkari Date: Mon, 27 Jul 2026 13:39:27 -0700 Subject: [PATCH 3/8] docs classify vulnerability triage fix --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bffd4e476..aa910ca5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Vulnerability triage now keeps Linear issues synchronized with current security findings. + +### Fixed - Fixed vulnerability triage for reusable-workflow callers and repositories without CodeQL. [#1515](https://github.com/sourcebot-dev/sourcebot/pull/1515) ## [5.1.4] - 2026-07-24 From ab64775f760d8ae2f3a01642c7fb5061d3cb0277 Mon Sep 17 00:00:00 2001 From: msukkari Date: Mon, 27 Jul 2026 13:55:11 -0700 Subject: [PATCH 4/8] fix: retry transient Linear triage responses --- .github/scripts/linear-graphql-request.sh | 60 ++++++++++++++++++++ .github/scripts/test-vulnerability-triage.sh | 50 ++++++++++++++++ .github/workflows/vulnerability-triage.yml | 58 +++++++++---------- CHANGELOG.md | 2 +- 4 files changed, 137 insertions(+), 33 deletions(-) create mode 100755 .github/scripts/linear-graphql-request.sh diff --git a/.github/scripts/linear-graphql-request.sh b/.github/scripts/linear-graphql-request.sh new file mode 100755 index 000000000..c2257df5e --- /dev/null +++ b/.github/scripts/linear-graphql-request.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ -z "${LINEAR_API_KEY:-}" ]]; then + echo "LINEAR_API_KEY is required" >&2 + exit 1 +fi + +payload=$(cat) +attempts="${LINEAR_GRAPHQL_ATTEMPTS:-4}" +retry_delay="${LINEAR_GRAPHQL_RETRY_DELAY_SECONDS:-2}" +endpoint="${LINEAR_GRAPHQL_ENDPOINT:-https://api.linear.app/graphql}" + +for ((attempt = 1; attempt <= attempts; attempt++)); do + response_file=$(mktemp) + http_code="" + + if http_code=$(curl \ + --silent \ + --show-error \ + --output "$response_file" \ + --write-out '%{http_code}' \ + --connect-timeout 10 \ + --max-time 45 \ + -X POST "$endpoint" \ + -H "Content-Type: application/json" \ + -H "Authorization: $LINEAR_API_KEY" \ + -d "$payload"); then + response=$(<"$response_file") + rm -f "$response_file" + + if [[ "$http_code" =~ ^2[0-9][0-9]$ ]] && jq -e . >/dev/null 2>&1 <<<"$response"; then + printf '%s' "$response" + exit 0 + fi + + if [[ "$http_code" =~ ^(408|429|5[0-9][0-9])$ ]]; then + echo "Linear GraphQL returned transient HTTP $http_code (attempt $attempt/$attempts)." >&2 + elif jq -e . >/dev/null 2>&1 <<<"$response"; then + # Preserve structured non-retryable errors so the workflow can report + # the GraphQL response rather than replacing it with a transport error. + printf '%s' "$response" + exit 0 + else + echo "Linear GraphQL returned a non-JSON response (HTTP $http_code, attempt $attempt/$attempts)." >&2 + fi + else + curl_status=$? + response=$(<"$response_file") + rm -f "$response_file" + echo "Linear GraphQL request failed (curl $curl_status, HTTP ${http_code:-unknown}, attempt $attempt/$attempts)." >&2 + fi + + if ((attempt < attempts)); then + sleep "$retry_delay" + fi +done + +echo "Linear GraphQL did not return a valid JSON response after $attempts attempts." >&2 +exit 1 diff --git a/.github/scripts/test-vulnerability-triage.sh b/.github/scripts/test-vulnerability-triage.sh index a08e9c72b..b7c763f11 100755 --- a/.github/scripts/test-vulnerability-triage.sh +++ b/.github/scripts/test-vulnerability-triage.sh @@ -5,6 +5,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" MATCH_FILTER="$SCRIPT_DIR/match-vulnerability-issue.jq" CLASSIFY_FILTER="$SCRIPT_DIR/classify-vulnerability-issues.jq" +LINEAR_REQUEST="$SCRIPT_DIR/linear-graphql-request.sh" WORKFLOW_FILE="$SCRIPT_DIR/../workflows/vulnerability-triage.yml" PREFIX="[sourcebot-dev/example]" @@ -36,6 +37,55 @@ assert_workflow_contains "checks out assets from the called workflow repository" assert_workflow_contains "pins assets to the called workflow revision" 'ref: ${{ job.workflow_sha }}' assert_workflow_contains "uses the shared match filter" '-f .vulnerability-triage-workflow/.github/scripts/match-vulnerability-issue.jq' assert_workflow_contains "uses the shared classification filter" '-f .vulnerability-triage-workflow/.github/scripts/classify-vulnerability-issues.jq' +assert_workflow_contains "uses the retrying Linear GraphQL client" '.vulnerability-triage-workflow/.github/scripts/linear-graphql-request.sh' + +FAKE_CURL_DIR=$(mktemp -d) +FAKE_CURL_COUNT=$(mktemp) +trap 'rm -rf "$FAKE_CURL_DIR"; rm -f "$FAKE_CURL_COUNT"' EXIT +printf '0\n' > "$FAKE_CURL_COUNT" + +cat > "$FAKE_CURL_DIR/curl" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +output_file="" +while (($# > 0)); do + case "$1" in + --output) + output_file="$2" + shift 2 + ;; + *) + shift + ;; + esac +done + +count=$(( $(<"$FAKE_CURL_COUNT") + 1 )) +printf '%s\n' "$count" > "$FAKE_CURL_COUNT" +if ((count < 3)); then + printf 'Bad Gateway' > "$output_file" + printf '502' +else + printf '{"data":{"ok":true}}' > "$output_file" + printf '200' +fi +EOF +chmod +x "$FAKE_CURL_DIR/curl" + +LINEAR_RESPONSE=$( + PATH="$FAKE_CURL_DIR:$PATH" \ + FAKE_CURL_COUNT="$FAKE_CURL_COUNT" \ + LINEAR_API_KEY="test-key" \ + LINEAR_GRAPHQL_ATTEMPTS=3 \ + LINEAR_GRAPHQL_RETRY_DELAY_SECONDS=0 \ + "$LINEAR_REQUEST" <<<'{"query":"query { viewer { id } }"}' +) +assert_json "retries non-JSON Linear responses" "$LINEAR_RESPONSE" '{"data":{"ok":true}}' +if [[ "$(<"$FAKE_CURL_COUNT")" != "3" ]]; then + echo "FAIL: expected Linear request helper to retry twice" + exit 1 +fi match() { local finding_id="$1" diff --git a/.github/workflows/vulnerability-triage.yml b/.github/workflows/vulnerability-triage.yml index 80b941805..77eb47234 100644 --- a/.github/workflows/vulnerability-triage.yml +++ b/.github/workflows/vulnerability-triage.yml @@ -618,10 +618,8 @@ jobs: --arg teamId "$LINEAR_TEAM_ID" \ --arg stateName "$LINEAR_STATE_NAME" \ '{query: $query, variables: {teamId: $teamId, stateName: $stateName}}') - METADATA_RESPONSE=$(curl -s -X POST https://api.linear.app/graphql \ - -H "Content-Type: application/json" \ - -H "Authorization: $LINEAR_API_KEY" \ - -d "$METADATA_PAYLOAD") + 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 // .')" @@ -645,10 +643,8 @@ jobs: 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=$(curl -s -X POST https://api.linear.app/graphql \ - -H "Content-Type: application/json" \ - -H "Authorization: $LINEAR_API_KEY" \ - -d "$REPO_LABEL_PAYLOAD") + 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 @@ -656,10 +652,8 @@ jobs: 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}}') - CREATE_LABEL_RESPONSE=$(curl -s -X POST https://api.linear.app/graphql \ - -H "Content-Type: application/json" \ - -H "Authorization: $LINEAR_API_KEY" \ - -d "$CREATE_LABEL_PAYLOAD") + CREATE_LABEL_RESPONSE=$(LINEAR_API_KEY="$LINEAR_API_KEY" \ + .vulnerability-triage-workflow/.github/scripts/linear-graphql-request.sh <<<"$CREATE_LABEL_PAYLOAD") 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 // .')" @@ -693,10 +687,8 @@ jobs: --arg teamId "$TEAM_UUID" \ '{titlePrefix: $titlePrefix, teamId: $teamId}') 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") + 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. @@ -854,10 +846,13 @@ jobs: 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") + 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') @@ -905,10 +900,13 @@ jobs: 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") + 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') @@ -966,10 +964,8 @@ jobs: '{prefix: $prefix, teamId: $teamId, after: $after}') fi 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") + 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 // .')" @@ -1014,10 +1010,8 @@ jobs: 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}') - CLOSE_RESPONSE=$(curl -s -X POST https://api.linear.app/graphql \ - -H "Content-Type: application/json" \ - -H "Authorization: $LINEAR_API_KEY" \ - -d "$CLOSE_PAYLOAD") + CLOSE_RESPONSE=$(LINEAR_API_KEY="$LINEAR_API_KEY" \ + .vulnerability-triage-workflow/.github/scripts/linear-graphql-request.sh <<<"$CLOSE_PAYLOAD") if [ "$(echo "$CLOSE_RESPONSE" | jq -r '.data.issueUpdate.success // false')" = "true" ]; then echo "Closed $ISSUE_IDENTIFIER — $FINDING_ID is no longer reported" diff --git a/CHANGELOG.md b/CHANGELOG.md index aa910ca5e..ef79bb918 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Vulnerability triage now keeps Linear issues synchronized with current security findings. ### Fixed -- Fixed vulnerability triage for reusable-workflow callers and repositories without CodeQL. [#1515](https://github.com/sourcebot-dev/sourcebot/pull/1515) +- Fixed vulnerability triage for reusable-workflow callers, repositories without CodeQL, and transient non-JSON Linear API responses. [#1515](https://github.com/sourcebot-dev/sourcebot/pull/1515) ## [5.1.4] - 2026-07-24 From 97626c3e45a07bc3b6a1fd3b18be575da93b4dba Mon Sep 17 00:00:00 2001 From: msukkari Date: Mon, 27 Jul 2026 14:02:15 -0700 Subject: [PATCH 5/8] fix: retry only Linear queries --- .github/scripts/linear-graphql-request.sh | 12 +++++++++++- .github/scripts/test-vulnerability-triage.sh | 15 +++++++++++++++ CHANGELOG.md | 2 +- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/.github/scripts/linear-graphql-request.sh b/.github/scripts/linear-graphql-request.sh index c2257df5e..18bc61838 100755 --- a/.github/scripts/linear-graphql-request.sh +++ b/.github/scripts/linear-graphql-request.sh @@ -7,9 +7,19 @@ if [[ -z "${LINEAR_API_KEY:-}" ]]; then fi payload=$(cat) -attempts="${LINEAR_GRAPHQL_ATTEMPTS:-4}" +configured_attempts="${LINEAR_GRAPHQL_ATTEMPTS:-4}" retry_delay="${LINEAR_GRAPHQL_RETRY_DELAY_SECONDS:-2}" endpoint="${LINEAR_GRAPHQL_ENDPOINT:-https://api.linear.app/graphql}" +is_mutation=$(jq -r '(.query // "") | test("^\\s*mutation(?:\\s|\\(|\\{)")' <<<"$payload") + +# Retrying a mutation after an ambiguous transport failure can replay a write +# that Linear already committed. Queries are safe to retry; mutations fail +# visibly after one attempt and rely on the workflow's reconciliation pass. +if [[ "$is_mutation" == "true" ]]; then + attempts=1 +else + attempts="$configured_attempts" +fi for ((attempt = 1; attempt <= attempts; attempt++)); do response_file=$(mktemp) diff --git a/.github/scripts/test-vulnerability-triage.sh b/.github/scripts/test-vulnerability-triage.sh index b7c763f11..b942558d3 100755 --- a/.github/scripts/test-vulnerability-triage.sh +++ b/.github/scripts/test-vulnerability-triage.sh @@ -87,6 +87,21 @@ if [[ "$(<"$FAKE_CURL_COUNT")" != "3" ]]; then exit 1 fi +printf '0\n' > "$FAKE_CURL_COUNT" +if PATH="$FAKE_CURL_DIR:$PATH" \ + FAKE_CURL_COUNT="$FAKE_CURL_COUNT" \ + LINEAR_API_KEY="test-key" \ + LINEAR_GRAPHQL_ATTEMPTS=3 \ + LINEAR_GRAPHQL_RETRY_DELAY_SECONDS=0 \ + "$LINEAR_REQUEST" <<<'{"query":"mutation { issueCreate(input: {}) { success } }"}' >/dev/null 2>&1; then + echo "FAIL: ambiguous Linear mutations must not be retried" + exit 1 +fi +if [[ "$(<"$FAKE_CURL_COUNT")" != "1" ]]; then + echo "FAIL: expected exactly one Linear mutation attempt" + exit 1 +fi + match() { local finding_id="$1" jq -c --arg prefix "$PREFIX" --arg findingId "$finding_id" -f "$MATCH_FILTER" diff --git a/CHANGELOG.md b/CHANGELOG.md index ef79bb918..a33e4c728 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Vulnerability triage now keeps Linear issues synchronized with current security findings. ### Fixed -- Fixed vulnerability triage for reusable-workflow callers, repositories without CodeQL, and transient non-JSON Linear API responses. [#1515](https://github.com/sourcebot-dev/sourcebot/pull/1515) +- Fixed vulnerability triage for reusable-workflow callers, repositories without CodeQL, and transient non-JSON Linear query responses. [#1515](https://github.com/sourcebot-dev/sourcebot/pull/1515) ## [5.1.4] - 2026-07-24 From ec0dd4e811ddabdbbd811ff5033639e821db5e1c Mon Sep 17 00:00:00 2001 From: msukkari Date: Mon, 27 Jul 2026 14:07:11 -0700 Subject: [PATCH 6/8] fix: preserve CodeQL issues when scans are unavailable --- .github/scripts/classify-vulnerability-issues.jq | 2 ++ .github/scripts/test-vulnerability-triage.sh | 8 ++++++-- .github/workflows/vulnerability-triage.yml | 2 ++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/scripts/classify-vulnerability-issues.jq b/.github/scripts/classify-vulnerability-issues.jq index 61f725ab6..e4eaa6955 100644 --- a/.github/scripts/classify-vulnerability-issues.jq +++ b/.github/scripts/classify-vulnerability-issues.jq @@ -12,6 +12,8 @@ def finding_id($prefix): | .findingId as $findingId | if .findingId == "" then . + { action: "ignore" } + elif ($skipCodeql and (.findingId | startswith("codeql:"))) then + . + { action: "ignore" } elif ($currentIds | index($findingId)) != null then . + { action: "keep" } else diff --git a/.github/scripts/test-vulnerability-triage.sh b/.github/scripts/test-vulnerability-triage.sh index b942558d3..af44a0161 100755 --- a/.github/scripts/test-vulnerability-triage.sh +++ b/.github/scripts/test-vulnerability-triage.sh @@ -201,13 +201,17 @@ OPEN_ISSUES='[ } ]' -CLASSIFIED="$(jq -c --arg prefix "$PREFIX" --slurpfile findings <(printf '%s\n' "$FINDINGS") -f "$CLASSIFY_FILTER" <<<"$OPEN_ISSUES")" +CLASSIFIED="$(jq -c --arg prefix "$PREFIX" --argjson skipCodeql false --slurpfile findings <(printf '%s\n' "$FINDINGS") -f "$CLASSIFY_FILTER" <<<"$OPEN_ISSUES")" assert_json "keeps every exact current finding" "$(jq -c '[.[] | select(.action == "keep") | .id]' <<<"$CLASSIFIED")" '["keep-cve","keep-codeql"]' assert_json "closes only resolved managed findings" "$(jq -c '[.[] | select(.action == "close") | .id]' <<<"$CLASSIFIED")" '["close-cve","close-prefix-collision"]' assert_json "ignores unrelated titles and repositories" "$(jq -c '[.[] | select(.action == "ignore") | .id]' <<<"$CLASSIFIED")" '["ignore-other-repo","ignore-unmanaged"]' EMPTY_FINDINGS='{"cves":[]}' -CLASSIFIED_EMPTY="$(jq -c --arg prefix "$PREFIX" --slurpfile findings <(printf '%s\n' "$EMPTY_FINDINGS") -f "$CLASSIFY_FILTER" <<<"$OPEN_ISSUES")" +CLASSIFIED_EMPTY="$(jq -c --arg prefix "$PREFIX" --argjson skipCodeql false --slurpfile findings <(printf '%s\n' "$EMPTY_FINDINGS") -f "$CLASSIFY_FILTER" <<<"$OPEN_ISSUES")" assert_json "closes all managed issues when a complete scan is clean" "$(jq -c '[.[] | select(.action == "close") | .id]' <<<"$CLASSIFIED_EMPTY")" '["keep-cve","keep-codeql","close-cve","close-prefix-collision"]' +CLASSIFIED_WITH_CODEQL_SKIPPED="$(jq -c --arg prefix "$PREFIX" --argjson skipCodeql true --slurpfile findings <(printf '%s\n' "$EMPTY_FINDINGS") -f "$CLASSIFY_FILTER" <<<"$OPEN_ISSUES")" +assert_json "preserves CodeQL issues when CodeQL is unavailable" "$(jq -c '[.[] | select(.action == "ignore") | .id]' <<<"$CLASSIFIED_WITH_CODEQL_SKIPPED")" '["keep-codeql","ignore-other-repo","ignore-unmanaged"]' +assert_json "still closes resolved non-CodeQL issues when CodeQL is unavailable" "$(jq -c '[.[] | select(.action == "close") | .id]' <<<"$CLASSIFIED_WITH_CODEQL_SKIPPED")" '["keep-cve","close-cve","close-prefix-collision"]' + echo "All vulnerability triage reconciliation tests passed." diff --git a/.github/workflows/vulnerability-triage.yml b/.github/workflows/vulnerability-triage.yml index 77eb47234..a14482551 100644 --- a/.github/workflows/vulnerability-triage.yml +++ b/.github/workflows/vulnerability-triage.yml @@ -937,6 +937,7 @@ jobs: REPOSITORY: ${{ github.repository }} TEAM_UUID: ${{ steps.match.outputs.team_uuid }} DONE_STATE_ID: ${{ steps.match.outputs.done_state_id }} + SKIP_CODEQL: ${{ inputs.skip_codeql }} run: | set -euo pipefail # The job only reaches this step after every configured scanner @@ -985,6 +986,7 @@ jobs: 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 From 9bdf11373b780684f0e2cea17e99267fc6c66c85 Mon Sep 17 00:00:00 2001 From: msukkari Date: Mon, 27 Jul 2026 14:10:16 -0700 Subject: [PATCH 7/8] fix: report Linear mutation transport failures --- .github/workflows/vulnerability-triage.yml | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/.github/workflows/vulnerability-triage.yml b/.github/workflows/vulnerability-triage.yml index a14482551..621023e49 100644 --- a/.github/workflows/vulnerability-triage.yml +++ b/.github/workflows/vulnerability-triage.yml @@ -652,11 +652,14 @@ jobs: 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}}') - CREATE_LABEL_RESPONSE=$(LINEAR_API_KEY="$LINEAR_API_KEY" \ - .vulnerability-triage-workflow/.github/scripts/linear-graphql-request.sh <<<"$CREATE_LABEL_PAYLOAD") - 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 // .')" + 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 @@ -1012,8 +1015,13 @@ jobs: 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}') - CLOSE_RESPONSE=$(LINEAR_API_KEY="$LINEAR_API_KEY" \ - .vulnerability-triage-workflow/.github/scripts/linear-graphql-request.sh <<<"$CLOSE_PAYLOAD") + 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" From 10176592ee0253ba66ad77c53b48824f544efef3 Mon Sep 17 00:00:00 2001 From: msukkari Date: Mon, 27 Jul 2026 14:17:19 -0700 Subject: [PATCH 8/8] fix: default skipped CodeQL state explicitly --- .github/workflows/vulnerability-triage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/vulnerability-triage.yml b/.github/workflows/vulnerability-triage.yml index 621023e49..e56ac3c25 100644 --- a/.github/workflows/vulnerability-triage.yml +++ b/.github/workflows/vulnerability-triage.yml @@ -940,7 +940,7 @@ jobs: REPOSITORY: ${{ github.repository }} TEAM_UUID: ${{ steps.match.outputs.team_uuid }} DONE_STATE_ID: ${{ steps.match.outputs.done_state_id }} - SKIP_CODEQL: ${{ inputs.skip_codeql }} + SKIP_CODEQL: ${{ inputs.skip_codeql || false }} run: | set -euo pipefail # The job only reaches this step after every configured scanner