Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
58 changes: 58 additions & 0 deletions .github/scripts/render-cve-remediation-summary.sh
Original file line number Diff line number Diff line change
@@ -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-json-file> <notes-directory>

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//|/\\|}"
}
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.
129 changes: 128 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,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 \
Comment thread
brendan-kellam marked this conversation as resolved.
"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")" \
Expand Down Expand Up @@ -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/<LINEAR-IDENTIFIER>.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"'
Comment thread
brendan-kellam marked this conversation as resolved.
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)
Comment thread
brendan-kellam marked this conversation as resolved.
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."
41 changes: 38 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,22 @@ 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 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 }}
Expand Down Expand Up @@ -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/<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 +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"
Loading