Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .github/scripts/render-cve-remediation-summary.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#!/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-json-file> <notes-directory>

ISSUES_FILE="${1:?issues JSON file is required}"
NOTES_DIR="${2:?notes directory is required}"

MAX_NOTE_LENGTH=500

escape_table_cell() {
printf '%s' "${1//|/\\|}"
Comment thread
brendan-kellam marked this conversation as resolved.
Outdated
}
Comment thread
brendan-kellam marked this conversation as resolved.

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")
Comment thread
brendan-kellam marked this conversation as resolved.
90 changes: 89 additions & 1 deletion .github/scripts/test-cve-remediation.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -325,6 +341,62 @@ 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"
}
]
EOF
printf 'Opened PR #99 upgrading foo to 1.2.3.\n' > "$RENDER_NOTES_DIR/SOU-31.md"
printf 'No patched | release exists yet;\nre-check tomorrow.\n' > "$RENDER_NOTES_DIR/SOU-32.md"
printf 'Skipped: dependency already patched on main.\n' > "$RENDER_NOTES_DIR/issue-34.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. |'

assert_equals \
Comment thread
brendan-kellam marked this conversation as resolved.
"renders the results table with flattened, escaped agent notes" \
"$("$RENDER_SCRIPT" "$RENDER_ISSUES_FILE" "$RENDER_NOTES_DIR")" \
"$EXPECTED_SUMMARY"

assert_json \
"maps Linear on-call assignees to GitHub reviewers" \
"$(jq -c . "$REVIEWER_MAP_FILE")" \
Expand Down Expand Up @@ -403,5 +475,21 @@ 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 \
"prompts Claude with the notes location for the run summary" \
'cve-remediation-notes/<LINEAR-IDENTIFIER>.md'
assert_workflow_contains \
"renders the remediation results table after the agent runs" \
'.cve-remediation-workflow/.github/scripts/render-cve-remediation-summary.sh'
assert_workflow_contains \
"reports remediation results even when the agent step fails" \
'if: always()'
Comment thread
brendan-kellam marked this conversation as resolved.
Outdated
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."
38 changes: 35 additions & 3 deletions .github/workflows/_cve-remediation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -124,9 +131,19 @@ jobs:
sparse-checkout: |
.github/prompts/cve-remediation-system.md
.github/cve-reviewers.json
.github/scripts
Comment thread
brendan-kellam marked this conversation as resolved.
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 agent notes directory
run: mkdir -p "$RUNNER_TEMP/cve-remediation-notes"

- name: Configure read-only Linear MCP
env:
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
Expand Down Expand Up @@ -157,6 +174,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/<LINEAR-IDENTIFIER>.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
Expand All @@ -166,3 +185,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
.cve-remediation-workflow/.github/scripts/render-cve-remediation-summary.sh "$ISSUES_FILE" "$NOTES_DIR"
} >> "$GITHUB_STEP_SUMMARY"
Loading