Skip to content

Commit 9436f3d

Browse files
committed
pvr-triage: add bulk respond taskflow, 3-path fast-close, reputation integration
- Add pvr_respond_batch.yaml for bulk response actions - pvr_triage.yaml: 3-path fast-close (high trust / skepticism / normal) - pvr_triage_batch.yaml: created_at in scored entries, Age column + tie-break sort - pvr_respond.yaml: call mark_response_sent on success - pvr_ghsa.py: add list_pending_responses, mark_response_sent tools - README.md: update for taskflow 4, batch/respond/output-file sections - run_pvr_triage.sh: add respond_batch subcommand - tests: expand to 32 passing tests covering new tools and taskflows
1 parent 7fd9074 commit 9436f3d

8 files changed

Lines changed: 356 additions & 36 deletions

File tree

scripts/run_pvr_triage.sh

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@
55
# Local test / demo script for the PVR triage taskflows.
66
#
77
# Usage:
8-
# ./scripts/run_pvr_triage.sh batch <owner/repo>
9-
# ./scripts/run_pvr_triage.sh triage <owner/repo> <GHSA-xxxx-xxxx-xxxx>
10-
# ./scripts/run_pvr_triage.sh respond <owner/repo> <GHSA-xxxx-xxxx-xxxx> <comment|reject|withdraw>
11-
# ./scripts/run_pvr_triage.sh demo <owner/repo>
8+
# ./scripts/run_pvr_triage.sh batch <owner/repo>
9+
# ./scripts/run_pvr_triage.sh triage <owner/repo> <GHSA-xxxx-xxxx-xxxx>
10+
# ./scripts/run_pvr_triage.sh respond <owner/repo> <GHSA-xxxx-xxxx-xxxx> <comment|reject|withdraw>
11+
# ./scripts/run_pvr_triage.sh respond_batch <owner/repo> <comment|reject|withdraw>
12+
# ./scripts/run_pvr_triage.sh demo <owner/repo>
1213
#
1314
# Environment (any already-set values are respected):
1415
# GH_TOKEN — GitHub token; falls back to: gh auth token
@@ -31,18 +32,22 @@ usage() {
3132
Usage: $(basename "$0") <command> [args]
3233
3334
Commands:
34-
batch <owner/repo>
35+
batch <owner/repo>
3536
Score unprocessed draft advisories and save a ranked queue table to REPORT_DIR.
3637
Advisories already present in REPORT_DIR are skipped.
3738
38-
triage <owner/repo> <GHSA-xxxx-xxxx-xxxx>
39+
triage <owner/repo> <GHSA-xxxx-xxxx-xxxx>
3940
Run full triage on one advisory: verify code, generate report + response draft.
4041
41-
respond <owner/repo> <GHSA-xxxx-xxxx-xxxx> <action>
42+
respond <owner/repo> <GHSA-xxxx-xxxx-xxxx> <action>
4243
Post the response draft to GitHub. action = comment | reject | withdraw
4344
Requires pvr_triage to have been run first for the given GHSA.
4445
45-
demo <owner/repo>
46+
respond_batch <owner/repo> <action>
47+
Scan REPORT_DIR for all pending response drafts and post them in one session.
48+
action = comment | reject | withdraw
49+
50+
demo <owner/repo>
4651
Full pipeline on the given repo (batch → triage on first draft advisory → report preview).
4752
Does not post anything to GitHub.
4853
@@ -140,6 +145,20 @@ cmd_respond() {
140145
-g "action=${action}"
141146
}
142147

148+
cmd_respond_batch() {
149+
local repo="${1:?Usage: $0 respond_batch <owner/repo> <action>}"
150+
local action="${2:?Usage: $0 respond_batch <owner/repo> <action>}"
151+
case "${action}" in
152+
comment|reject|withdraw) ;;
153+
*) echo "ERROR: action must be comment, reject, or withdraw" >&2; exit 1 ;;
154+
esac
155+
echo "==> Bulk respond for ${repo} (action=${action}) ..."
156+
run_agent \
157+
-t seclab_taskflows.taskflows.pvr_triage.pvr_respond_batch \
158+
-g "repo=${repo}" \
159+
-g "action=${action}"
160+
}
161+
143162
cmd_demo() {
144163
local repo="${1:?Usage: $0 demo <owner/repo>}"
145164

@@ -177,9 +196,10 @@ cmd_demo() {
177196
# ---------------------------------------------------------------------------
178197

179198
case "${1:-}" in
180-
batch) shift; cmd_batch "$@" ;;
181-
triage) shift; cmd_triage "$@" ;;
182-
respond) shift; cmd_respond "$@" ;;
183-
demo) shift; cmd_demo "$@" ;;
199+
batch) shift; cmd_batch "$@" ;;
200+
triage) shift; cmd_triage "$@" ;;
201+
respond) shift; cmd_respond "$@" ;;
202+
respond_batch) shift; cmd_respond_batch "$@" ;;
203+
demo) shift; cmd_demo "$@" ;;
184204
*) echo "ERROR: unknown command '${1}'" >&2; usage; exit 1 ;;
185205
esac

src/seclab_taskflows/mcp_servers/pvr_ghsa.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import os
1515
import re
1616
import subprocess
17+
from datetime import datetime, timezone
1718
from pathlib import Path
1819

1920
from fastmcp import FastMCP
@@ -523,5 +524,60 @@ def read_triage_report(
523524
return report_path.read_text(encoding="utf-8")
524525

525526

527+
@mcp.tool()
528+
def list_pending_responses() -> str:
529+
"""
530+
List advisories that have a response draft but have not yet been sent.
531+
532+
Globs REPORT_DIR for *_response_triage.md files and skips any whose
533+
corresponding *_response_sent.md marker exists.
534+
Returns a JSON list of {ghsa_id, triage_report_exists} objects.
535+
"""
536+
if not REPORT_DIR.exists():
537+
return json.dumps([])
538+
539+
results = []
540+
for draft_path in sorted(REPORT_DIR.glob("*_response_triage.md")):
541+
# stem is e.g. "GHSA-xxxx-xxxx-xxxx_response_triage"
542+
stem = draft_path.stem
543+
# Extract ghsa_id: remove "_response_triage" suffix
544+
ghsa_id = stem.replace("_response_triage", "")
545+
safe_name = "".join(c for c in ghsa_id if c.isalnum() or c in "-_")
546+
547+
# Skip if sent marker exists
548+
sent_marker = REPORT_DIR / f"{safe_name}_response_sent.md"
549+
if sent_marker.exists():
550+
continue
551+
552+
triage_report = REPORT_DIR / f"{safe_name}_triage.md"
553+
results.append({
554+
"ghsa_id": ghsa_id,
555+
"triage_report_exists": triage_report.exists(),
556+
})
557+
558+
return json.dumps(results, indent=2)
559+
560+
561+
@mcp.tool()
562+
def mark_response_sent(
563+
ghsa_id: str = Field(description="GHSA ID of the advisory whose response was sent"),
564+
) -> str:
565+
"""
566+
Create a marker file indicating that the response for this advisory has been sent.
567+
568+
Writes REPORT_DIR/{ghsa_id}_response_sent.md with an ISO timestamp.
569+
Returns the path of the created marker, or an error string if ghsa_id is empty.
570+
"""
571+
safe_name = "".join(c for c in ghsa_id if c.isalnum() or c in "-_")
572+
if not safe_name:
573+
return "Error: ghsa_id produced an empty filename after sanitization"
574+
REPORT_DIR.mkdir(parents=True, exist_ok=True)
575+
marker_path = REPORT_DIR / f"{safe_name}_response_sent.md"
576+
timestamp = datetime.now(timezone.utc).isoformat()
577+
marker_path.write_text(f"Response sent: {timestamp}\n", encoding="utf-8")
578+
logging.info("Response sent marker written to %s", marker_path)
579+
return str(marker_path.resolve())
580+
581+
526582
if __name__ == "__main__":
527583
mcp.run(show_banner=False)

src/seclab_taskflows/taskflows/pvr_triage/README.md

Lines changed: 67 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@
22

33
Tools for triaging GitHub Security Advisories submitted via [Private Vulnerability Reporting (PVR)](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability). The taskflows fetch a draft advisory, verify the claimed vulnerability against actual source code, score report quality, and generate a structured analysis and a ready-to-send response draft.
44

5-
Three taskflows cover the full triage lifecycle:
5+
Four taskflows cover the full triage lifecycle:
66

77
| Taskflow | Purpose |
88
|---|---|
99
| `pvr_triage` | Deep-analyse one advisory end-to-end |
1010
| `pvr_triage_batch` | Score an entire inbox and produce a ranked queue |
11-
| `pvr_respond` | Post or save the response once you've reviewed the analysis |
11+
| `pvr_respond` | Post the response for one advisory once you've reviewed the analysis |
12+
| `pvr_respond_batch` | Scan REPORT_DIR and post all pending response drafts in a single session |
1213

1314
---
1415

@@ -62,7 +63,11 @@ python -m seclab_taskflow_agent \
6263

6364
1. **Initialize** — clears the in-memory cache.
6465
2. **Fetch & parse** — fetches the advisory from the GitHub API and extracts structured metadata: vulnerability type, affected component, file references, PoC quality signals, reporter credits.
65-
3. **Quality gate** — calls `get_reporter_score` for the reporter's history and `find_similar_triage_reports` to detect duplicates. Computes a `fast_close` flag when the report has no file references, no PoC, no line numbers, *and* a similar report already exists. Fast-close skips deep code analysis.
66+
3. **Quality gate** — calls `get_reporter_score` for the reporter's history and `find_similar_triage_reports` to detect duplicates. Computes `fast_close` using a reputation-gated decision tree:
67+
- **high-trust reporter** → always `fast_close = false` (full verification).
68+
- **skepticism reporter**`fast_close = true` when all three quality signals are absent (prior similar report not required).
69+
- **normal / no history**`fast_close = true` only when all three signals are absent *and* a prior similar report exists.
70+
Fast-close skips deep code analysis.
6671
4. **Code verification** — resolves the claimed version to a git tag/SHA, fetches the relevant source files, and checks whether the vulnerability pattern is actually present. After verifying at the claimed version, also checks HEAD to determine patch status (`still_vulnerable` / `patched` / `could_not_determine`). Skipped automatically when `fast_close` is true.
6772
5. **Report generation** — writes a markdown report covering: Verdict, Code Verification, Severity Assessment, CVSS 3.1 assessment, Duplicate/Prior Reports, Patch Status, Report Quality, Reporter Reputation, and Recommendations.
6873
6. **Save report** — writes the report to `REPORT_DIR/<GHSA-ID>_triage.md` and prints the path.
@@ -110,12 +115,14 @@ Saved to `REPORT_DIR/batch_queue_<repo>_<date>.md`:
110115
```markdown
111116
# PVR Batch Triage Queue: owner/repo
112117

113-
| GHSA | Severity | Vuln Type | Quality Signals | Priority | Status | Suggested Action |
114-
|------|----------|-----------|-----------------|----------|--------|-----------------|
115-
| GHSA-... | high | SQL injection | PoC, Files | 6 | Not triaged | Triage Immediately |
116-
| GHSA-... | medium | XSS | None | 1 | Not triaged | Likely Low Quality — Fast Close |
118+
| GHSA | Age (days) | Severity | Vuln Type | Quality Signals | Priority | Status | Suggested Action |
119+
|------|------------|----------|-----------|-----------------|----------|--------|-----------------|
120+
| GHSA-... | 14 | high | SQL injection | PoC, Files | 6 | Not triaged | Triage Immediately |
121+
| GHSA-... | 3 | medium | XSS | None | 1 | Not triaged | Likely Low Quality — Fast Close |
117122
```
118123

124+
Rows are sorted by priority score descending; ties are broken by `created_at` ascending (oldest advisory first).
125+
119126
### Priority scoring
120127

121128
Advisories with an existing report in `REPORT_DIR` are skipped entirely. Only unprocessed advisories are scored:
@@ -164,6 +171,40 @@ python -m seclab_taskflow_agent \
164171

165172
The toolbox marks `reject_pvr_advisory`, `withdraw_pvr_advisory`, and `add_pvr_advisory_comment` as `confirm`-gated. The agent will print the verdict, quality rating, and full response draft, then ask for explicit confirmation before making any change to GitHub.
166173

174+
After a successful write-back, `pvr_respond` calls `mark_response_sent` to create a `<GHSA-ID>_response_sent.md` marker so `pvr_respond_batch` will skip this advisory in future runs.
175+
176+
---
177+
178+
## Taskflow 4 — Bulk respond (`pvr_respond_batch`)
179+
180+
Scans `REPORT_DIR` for advisories that have a response draft (`*_response_triage.md`) but no sent marker (`*_response_sent.md`), then posts each response to GitHub in a single session.
181+
182+
```bash
183+
python -m seclab_taskflow_agent \
184+
-t seclab_taskflows.taskflows.pvr_triage.pvr_respond_batch \
185+
-g repo=owner/repo \
186+
-g action=comment
187+
188+
# or via the helper script:
189+
./scripts/run_pvr_triage.sh respond_batch owner/repo comment
190+
```
191+
192+
### How it works
193+
194+
**Task 1** calls `list_pending_responses` (local read-only, no confirm gate) to find all unsent drafts and prints a summary table. If there are no pending drafts it stops immediately.
195+
196+
**Task 2** iterates over every pending entry:
197+
1. Reads the triage report and response draft from disk.
198+
2. Prints a per-item preview (GHSA, verdict, first 200 chars of response).
199+
3. Executes the chosen action (`comment` / `reject` / `withdraw`) via the confirm-gated write-back tool.
200+
4. On success, calls `mark_response_sent` to create a `*_response_sent.md` marker so the advisory is skipped in future runs.
201+
202+
Prints a final count: `"Sent N / M responses."`
203+
204+
### Sent markers
205+
206+
`pvr_respond` also calls `mark_response_sent` after a successful write-back, keeping single-advisory and bulk responds in sync. Once a marker exists, neither `pvr_respond` nor `pvr_respond_batch` will attempt to re-send.
207+
167208
---
168209

169210
## Typical workflow
@@ -178,10 +219,15 @@ The toolbox marks `reject_pvr_advisory`, `withdraw_pvr_advisory`, and `add_pvr_a
178219
- Check the Verdict and Code Verification sections.
179220
- Edit the response draft (_response_triage.md) if needed.
180221
181-
4. Run pvr_respond to send the response:
182-
- action=comment → post reply only (advisory stays draft)
183-
- action=reject → reject + post reply
184-
- action=withdraw → withdraw + post reply
222+
4a. Send responses one at a time with pvr_respond:
223+
- action=comment → post reply only (advisory stays draft)
224+
- action=reject → reject + post reply
225+
- action=withdraw → withdraw + post reply
226+
227+
4b. Or send all pending drafts at once with pvr_respond_batch:
228+
Scans REPORT_DIR for unsent drafts (no _response_sent.md marker)
229+
and posts them all in one session.
230+
Useful after triaging a batch in step 2.
185231
```
186232

187233
### Example session
@@ -202,7 +248,7 @@ python -m seclab_taskflow_agent \
202248
cat reports/GHSA-1234-5678-abcd_triage.md
203249
cat reports/GHSA-1234-5678-abcd_response_triage.md
204250

205-
# Step 4a: send a comment (most common — doesn't change advisory state)
251+
# Step 4a: send a comment for one advisory (doesn't change advisory state)
206252
python -m seclab_taskflow_agent \
207253
-t seclab_taskflows.taskflows.pvr_triage.pvr_respond \
208254
-g repo=acme/widget \
@@ -215,6 +261,12 @@ python -m seclab_taskflow_agent \
215261
-g repo=acme/widget \
216262
-g ghsa=GHSA-1234-5678-abcd \
217263
-g action=reject
264+
265+
# Step 4c: or post all pending drafts at once (after triaging several advisories)
266+
python -m seclab_taskflow_agent \
267+
-t seclab_taskflows.taskflows.pvr_triage.pvr_respond_batch \
268+
-g repo=acme/widget \
269+
-g action=comment
218270
```
219271

220272
---
@@ -233,7 +285,7 @@ The quality gate in Task 3 of `pvr_triage` calls `get_reporter_score` automatica
233285
| confirmed_pct ≤ 20% or Low-quality share ≥ 50% | treat with skepticism |
234286
| Otherwise | normal |
235287

236-
A "treat with skepticism" score alone does not trigger fast-close — it is informational. Fast-close is triggered only by the combination of missing quality signals *and* an existing duplicate report.
288+
Reputation directly gates the fast-close decision. See [SCORING.md](SCORING.md) Section 3 for the full three-path decision table and reputation × fast-close matrix.
237289

238290
---
239291

@@ -258,4 +310,5 @@ All files are written to `REPORT_DIR` (default: `./reports`).
258310
|---|---|---|
259311
| `<GHSA-ID>_triage.md` | `pvr_triage` task 6 | Full triage analysis report |
260312
| `<GHSA-ID>_response_triage.md` | `pvr_triage` task 8 | Plain-text response draft for the reporter |
261-
| `batch_queue_<repo>_<date>.md` | `pvr_triage_batch` task 3 | Ranked inbox table |
313+
| `<GHSA-ID>_response_sent.md` | `pvr_respond` / `pvr_respond_batch` | Marker: response has been sent (contains ISO timestamp) |
314+
| `batch_queue_<repo>_<date>.md` | `pvr_triage_batch` task 3 | Ranked inbox table with Age column |

src/seclab_taskflows/taskflows/pvr_triage/pvr_respond.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,6 @@ taskflow:
113113
and stop.
114114
115115
Print the result returned by the API call.
116+
117+
On success (action was not "anything else"), call mark_response_sent with
118+
ghsa_id="{{ globals.ghsa }}" to record that this response has been sent.

0 commit comments

Comments
 (0)