diff --git a/.github/scripts/render-cve-remediation-summary.sh b/.github/scripts/render-cve-remediation-summary.sh new file mode 100755 index 000000000..7ec626575 --- /dev/null +++ b/.github/scripts/render-cve-remediation-summary.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Renders the CVE remediation results table for the workflow run summary. +# Combines the issue metadata produced by the discovery job with the optional +# per-issue notes written by the remediation agent. A note is looked up by the +# issue's Linear identifier (SOU-123.md) or, as a fallback, its Linear UUID. +# +# Usage: render-cve-remediation-summary.sh + +ISSUES_FILE="${1:?issues JSON file is required}" +NOTES_DIR="${2:?notes directory is required}" + +MAX_NOTE_LENGTH=500 + +if ! jq -e 'type == "array"' "$ISSUES_FILE" >/dev/null 2>&1; then + echo "issues file '$ISSUES_FILE' is missing or is not a JSON array" >&2 + exit 1 +fi + +escape_table_cell() { + local escaped="${1//\\/\\\\}" + printf '%s' "${escaped//|/\\|}" +} + +echo '| Linear issue | Priority | Status | Title | Notes |' +echo '| --- | ---: | --- | --- | --- |' + +while IFS= read -r issue; do + identifier=$(jq -r '.identifier' <<<"$issue") + id=$(jq -r '.id' <<<"$issue") + url=$(jq -r '.url' <<<"$issue") + priority=$(jq -r '.priority' <<<"$issue") + status=$(jq -r '.status' <<<"$issue") + title=$(jq -r '.title' <<<"$issue") + + note="" + for note_key in "$identifier" "$id"; do + if [[ -z "$note_key" || "$note_key" == */* || "$note_key" == .* ]]; then + continue + fi + note_file="$NOTES_DIR/$note_key.md" + if [[ -f "$note_file" ]]; then + note=$(tr -s '[:space:]' ' ' < "$note_file") + note="${note# }" + note="${note% }" + break + fi + done + if (( ${#note} > MAX_NOTE_LENGTH )); then + note="${note:0:MAX_NOTE_LENGTH}…" + fi + + printf '| [%s](%s) | %s | %s | %s | %s |\n' \ + "$identifier" "$url" "$priority" "$status" \ + "$(escape_table_cell "$title")" \ + "$(escape_table_cell "$note")" +done < <(jq -c '.[]' "$ISSUES_FILE") diff --git a/.github/scripts/test-cve-remediation.sh b/.github/scripts/test-cve-remediation.sh index fcaf431e9..186504a74 100755 --- a/.github/scripts/test-cve-remediation.sh +++ b/.github/scripts/test-cve-remediation.sh @@ -4,6 +4,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FILTER="$SCRIPT_DIR/filter-unlinked-cve-issues.jq" DISCOVERY_SCRIPT="$SCRIPT_DIR/find-unlinked-cve-issues.sh" +RENDER_SCRIPT="$SCRIPT_DIR/render-cve-remediation-summary.sh" WORKFLOW_FILE="$SCRIPT_DIR/../workflows/_cve-remediation.yml" CALLER_WORKFLOW_FILE="$SCRIPT_DIR/../workflows/cve-remediation.yml" SYSTEM_PROMPT_FILE="$SCRIPT_DIR/../prompts/cve-remediation-system.md" @@ -22,6 +23,19 @@ assert_json() { fi } +assert_equals() { + local description="$1" + local actual="$2" + local expected="$3" + + if [[ "$actual" != "$expected" ]]; then + echo "FAIL: $description" + echo "Expected: $expected" + echo "Actual: $actual" + exit 1 + fi +} + assert_workflow_contains() { local description="$1" local expected="$2" @@ -138,7 +152,9 @@ FAKE_CURL_DIR=$(mktemp -d) FAKE_CURL_COUNT=$(mktemp) FAKE_CURL_PAYLOAD_DIR=$(mktemp -d) FAKE_GH_LOG=$(mktemp) -trap 'rm -rf "$FAKE_CURL_DIR" "$FAKE_CURL_PAYLOAD_DIR"; rm -f "$FAKE_CURL_COUNT" "$FAKE_GH_LOG"' EXIT +RENDER_NOTES_DIR=$(mktemp -d) +RENDER_ISSUES_FILE=$(mktemp) +trap 'rm -rf "$FAKE_CURL_DIR" "$FAKE_CURL_PAYLOAD_DIR" "$RENDER_NOTES_DIR"; rm -f "$FAKE_CURL_COUNT" "$FAKE_GH_LOG" "$RENDER_ISSUES_FILE"' EXIT printf '0\n' > "$FAKE_CURL_COUNT" cat > "$FAKE_CURL_DIR/curl" <<'EOF' @@ -325,6 +341,83 @@ assert_json \ "$(jq -c '.variables.after' "$FAKE_CURL_PAYLOAD_DIR/3.json")" \ '"next-page"' +cat > "$RENDER_ISSUES_FILE" <<'EOF' +[ + { + "id": "issue-31", + "identifier": "SOU-31", + "title": "[sourcebot-dev/example] CVE-31: note keyed by identifier", + "url": "https://linear.app/sourcebot/issue/SOU-31/test", + "priority": 1, + "status": "Todo", + "statusType": "unstarted" + }, + { + "id": "issue-32", + "identifier": "SOU-32", + "title": "[sourcebot-dev/example] CVE-32: pipe | in title", + "url": "https://linear.app/sourcebot/issue/SOU-32/test", + "priority": 2, + "status": "Backlog", + "statusType": "backlog" + }, + { + "id": "issue-33", + "identifier": "SOU-33", + "title": "[sourcebot-dev/example] CVE-33: no note", + "url": "https://linear.app/sourcebot/issue/SOU-33/test", + "priority": 3, + "status": "Backlog", + "statusType": "backlog" + }, + { + "id": "issue-34", + "identifier": "SOU-34", + "title": "[sourcebot-dev/example] CVE-34: note keyed by UUID", + "url": "https://linear.app/sourcebot/issue/SOU-34/test", + "priority": 4, + "status": "Backlog", + "statusType": "backlog" + }, + { + "id": "issue-35", + "identifier": "SOU-35", + "title": "[sourcebot-dev/example] CVE-35: truncated long note", + "url": "https://linear.app/sourcebot/issue/SOU-35/test", + "priority": 5, + "status": "Backlog", + "statusType": "backlog" + } +] +EOF +printf 'Opened PR #99 upgrading foo to 1.2.3.\n' > "$RENDER_NOTES_DIR/SOU-31.md" +cat > "$RENDER_NOTES_DIR/SOU-32.md" <<'EOF' +No patched | release \| exists yet; +re-check tomorrow. +EOF +printf 'Skipped: dependency already patched on main.\n' > "$RENDER_NOTES_DIR/issue-34.md" +LONG_NOTE=$(printf 'a%.0s' $(seq 1 600)) +printf '%s\n' "$LONG_NOTE" > "$RENDER_NOTES_DIR/SOU-35.md" + +EXPECTED_SUMMARY='| Linear issue | Priority | Status | Title | Notes | +| --- | ---: | --- | --- | --- | +| [SOU-31](https://linear.app/sourcebot/issue/SOU-31/test) | 1 | Todo | [sourcebot-dev/example] CVE-31: note keyed by identifier | Opened PR #99 upgrading foo to 1.2.3. | +| [SOU-32](https://linear.app/sourcebot/issue/SOU-32/test) | 2 | Backlog | [sourcebot-dev/example] CVE-32: pipe \| in title | No patched \| release \\\| exists yet; re-check tomorrow. | +| [SOU-33](https://linear.app/sourcebot/issue/SOU-33/test) | 3 | Backlog | [sourcebot-dev/example] CVE-33: no note | | +| [SOU-34](https://linear.app/sourcebot/issue/SOU-34/test) | 4 | Backlog | [sourcebot-dev/example] CVE-34: note keyed by UUID | Skipped: dependency already patched on main. |' +EXPECTED_SUMMARY+=" +| [SOU-35](https://linear.app/sourcebot/issue/SOU-35/test) | 5 | Backlog | [sourcebot-dev/example] CVE-35: truncated long note | ${LONG_NOTE:0:500}… |" + +assert_equals \ + "renders the results table with flattened, escaped, truncated agent notes" \ + "$("$RENDER_SCRIPT" "$RENDER_ISSUES_FILE" "$RENDER_NOTES_DIR")" \ + "$EXPECTED_SUMMARY" + +if "$RENDER_SCRIPT" "$RENDER_NOTES_DIR/does-not-exist.json" "$RENDER_NOTES_DIR" >/dev/null 2>&1; then + echo "FAIL: fails loudly instead of rendering an empty table when the issues file is missing" + exit 1 +fi + assert_json \ "maps Linear on-call assignees to GitHub reviewers" \ "$(jq -c . "$REVIEWER_MAP_FILE")" \ @@ -403,5 +496,39 @@ assert_workflow_not_contains \ "does not use the unavailable inputs context in a reusable workflow call" \ '${{ inputs.max_issues' \ "$CALLER_WORKFLOW_FILE" +assert_workflow_contains \ + "hands issue metadata to the remediation job through an artifact" \ + 'name: cve-remediation-issues' +assert_workflow_contains \ + "uploads the issue metadata from the discovery job" \ + 'name: Upload issue metadata for the remediation summary' +assert_workflow_contains \ + "downloads the issue metadata in the remediation job" \ + 'name: Download issue metadata' +assert_workflow_contains \ + "prompts Claude with the notes location for the run summary" \ + 'cve-remediation-notes/.md' +assert_workflow_contains \ + "snapshots the renderer before the agent can touch the checkout" \ + 'cp .cve-remediation-workflow/.github/scripts/render-cve-remediation-summary.sh "$RUNNER_TEMP/render-cve-remediation-summary.sh"' +assert_workflow_contains \ + "renders the remediation results table from the pre-agent snapshot" \ + '"$RUNNER_TEMP/render-cve-remediation-summary.sh" "$ISSUES_FILE" "$NOTES_DIR"' +snapshot_line=$(grep -nF -- 'cp .cve-remediation-workflow/.github/scripts/render-cve-remediation-summary.sh' "$WORKFLOW_FILE" | head -1 | cut -d: -f1) +agent_line=$(grep -nF -- 'name: Run Claude CVE remediation agent' "$WORKFLOW_FILE" | head -1 | cut -d: -f1) +if [[ -z "$snapshot_line" || -z "$agent_line" ]] || ((snapshot_line >= agent_line)); then + echo "FAIL: snapshots the renderer before the Claude agent step runs" + echo "Expected the renderer copy (line ${snapshot_line:-missing}) to precede the agent step (line ${agent_line:-missing})" + exit 1 +fi +if ! grep -A1 -- '- name: Report remediation results' "$WORKFLOW_FILE" | grep -Fq 'if: always()'; then + echo "FAIL: reports remediation results even when the agent step fails" + echo "Expected the 'Report remediation results' step to run with 'if: always()'" + exit 1 +fi +assert_workflow_contains \ + "includes the agent notes column in the results table" \ + '| Linear issue | Priority | Status | Title | Notes |' \ + "$RENDER_SCRIPT" echo "All CVE remediation tests passed." diff --git a/.github/workflows/_cve-remediation.yml b/.github/workflows/_cve-remediation.yml index 2751a48fa..7d71f9004 100644 --- a/.github/workflows/_cve-remediation.yml +++ b/.github/workflows/_cve-remediation.yml @@ -67,6 +67,7 @@ jobs: issues=$(jq -c --argjson max "$MAX_ISSUES" '.[0:$max]' <<<"$all_issues") issue_ids=$(jq -c 'map(.id)' <<<"$issues") issue_count=$(jq 'length' <<<"$issues") + printf '%s\n' "$issues" > "$RUNNER_TEMP/cve-remediation-issues.json" if ((issue_count > 0)); then has_issues=true @@ -93,12 +94,18 @@ jobs: echo "Claude was not started." else echo - echo '| Linear issue | Priority | Status | Title |' - echo '| --- | ---: | --- | --- |' - jq -r '.[] | "| [\(.identifier)](\(.url)) | \(.priority) | \(.status) | \(.title | gsub("\\|"; "\\\\|")) |"' <<<"$issues" + echo "The processed issues, along with any notes recorded by the agent, are listed in the remediation job summary." fi } >> "$GITHUB_STEP_SUMMARY" + - name: Upload issue metadata for the remediation summary + if: steps.discover.outputs.has_issues == 'true' + uses: actions/upload-artifact@v4 + with: + name: cve-remediation-issues + path: ${{ runner.temp }}/cve-remediation-issues.json + retention-days: 7 + remediate: name: Remediate CVEs with Claude needs: discover @@ -124,9 +131,22 @@ jobs: sparse-checkout: | .github/prompts/cve-remediation-system.md .github/cve-reviewers.json + .github/scripts path: .cve-remediation-workflow persist-credentials: false + - name: Download issue metadata + uses: actions/download-artifact@v4 + with: + name: cve-remediation-issues + path: ${{ runner.temp }} + + - name: Prepare remediation summary inputs + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/cve-remediation-notes" + cp .cve-remediation-workflow/.github/scripts/render-cve-remediation-summary.sh "$RUNNER_TEMP/render-cve-remediation-summary.sh" + - name: Configure read-only Linear MCP env: LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} @@ -157,6 +177,8 @@ jobs: ${{ needs.discover.outputs.issue_ids }} This is an unattended run. Inspect every supplied issue through the read-only Linear MCP server, follow the system instructions, and open or update pull requests only for complete, verified remediations. + + For each issue, you may optionally record a short note (one or two sentences) describing the outcome by writing it to `${{ runner.temp }}/cve-remediation-notes/.md` (for example, `SOU-123.md`). Notes are shown next to each issue in the workflow run summary. Leave a note whenever the outcome would otherwise be unclear, especially when you decide not to open or update a pull request. claude_args: | --append-system-prompt-file "${{ github.workspace }}/.cve-remediation-workflow/.github/prompts/cve-remediation-system.md" --strict-mcp-config @@ -166,3 +188,16 @@ jobs: --tools "Bash,Read,Edit,Write,Glob,Grep" --allowedTools "Read,Edit,Write,Glob,Grep,Bash(git *),Bash(gh pr *),Bash(yarn *),Bash(npm *),Bash(npx *),Bash(pnpm *),Bash(bun *),Bash(go *),Bash(cargo *),Bash(uv *),Bash(pytest *),Bash(python -m pytest *),Bash(make *),Bash(just *),mcp__linear__get_issue,mcp__linear__list_comments" --disallowedTools "Bash(gh pr merge *),Bash(git push *--force*),Bash(npm publish *),Bash(yarn npm publish *),Bash(pnpm publish *),Bash(cargo publish *)" + + - name: Report remediation results + if: always() + env: + ISSUES_FILE: ${{ runner.temp }}/cve-remediation-issues.json + NOTES_DIR: ${{ runner.temp }}/cve-remediation-notes + run: | + set -euo pipefail + { + echo "## CVE remediation results" + echo + "$RUNNER_TEMP/render-cve-remediation-summary.sh" "$ISSUES_FILE" "$NOTES_DIR" + } >> "$GITHUB_STEP_SUMMARY"