- name
- vuln-tracker
- description
- One lifecycle poll over everything vuln-scanner produces - PR and advisory status, PVR triage transitions, and pending-disclosure aging, with a stars-secured impact headline and one action queue.
- metadata
- {"title":"Vuln Tracker","category":"dev","var":"","mode":"write","tags":["meta","security","github"],"depends_on":["vuln-scanner"],"requires":["GH_TOKEN?","GH_GLOBAL?"]}
> **${var}** — Scope selector for the lifecycle poll:
> - empty → **full lifecycle poll**: PR/advisory status + PVR triage + disclosure-queue aging (default).
> - `prs` (also `pr` / `tracker`) → **Arm A only** — PR/advisory status audit + stars-secured dashboard.
> - `pvr` → **Arm B only** — PVR triage-state poll on submitted advisories.
> - `queue` (also `disclosures` / `backlog`) → **Arm C only** — pending-disclosure queue aging + escalation.
> - a bare `GHSA-xxxx-xxxx-xxxx` → **Arm B, single-advisory mode** — check just that one advisory's triage state on demand.
Today is ${today}. This skill is the daily read/poll arm of the vuln pipeline: `vuln-scanner` opens PRs, submits PVRs, and queues disclosure drafts, then moves on. This skill polls everything it produced and surfaces what the operator must look at — it does not open PRs or submit advisories itself (those are `vuln-scanner`'s write actions).
## Voice
If `soul/SOUL.md` and `soul/STYLE.md` are populated, read them and match the operator's voice in every notification. If empty or absent, use a clear, direct, neutral tone.
## Capability mode
This skill runs `mode: write` deliberately. It is a read/poll arm, but three of its capabilities cannot run under `read-only`:
- **Arm B polls private, unpublished advisory triage state** via `gh api repos/$REPO/security-advisories/$GHSA`. Draft/triage advisories are visible only to the repo maintainers and the reporter, so the read is intrinsically authenticated; `read-only` strips `gh`, and a bare `$SECRET` on the command line is refused by the Bash permission layer (so a hand-rolled authenticated `curl` isn't an option either) — the read needs `gh api`, which handles auth internally.
- **Arm B persists state transitions** — it rewrites `state:`/`last_checked:`/`resolved_at:` frontmatter in `memory/pending-disclosures/*.md` in place and **moves** resolved files to `memory/pending-disclosures/resolved/` (`Edit`/`git mv` — both stripped in `read-only`).
- **Arm A leans on authenticated `gh api`** for the PVR-state endpoint (`repos/$REPO/private-vulnerability-reporting`) and repo/advisory reads.
No sibling writes a repo file **outside** `memory/`, so there was nothing to relocate — the only outside-`memory/` writes are the ephemeral `.pending-notify-temp/` notify-staging files. Keeping `write` preserves every absorbed capability.
## Shared preamble (run for every invocation)
1. Read `memory/MEMORY.md` for context.
2. Read the last ~3 days of `memory/logs/` and drop anything already reported — don't re-surface the same signal twice.
3. Read `soul/SOUL.md` + `soul/STYLE.md` if populated (voice).
4. **Parse `${var}` → scope** (deterministic; trim + lowercase, except a `GHSA-` value which is compared case-insensitively but preserved verbatim):
- empty → `scope = full` (run Arm A, then B, then C).
- matches `^GHSA-` (case-insensitive) → `scope = pvr`, `single_advisory = <the GHSA value>` (Arm B filtered to one advisory).
- `prs` / `pr` / `tracker` → `scope = prs` (Arm A only).
- `pvr` → `scope = pvr` (Arm B only, all advisories).
- `queue` / `disclosures` / `backlog` → `scope = queue` (Arm C only).
- anything else → log `VULN_TRACKER_BAD_VAR: unrecognized scope '<var>'`, send no notification, exit.
5. `mkdir -p .pending-notify-temp` and start an empty combined-notification buffer at `.pending-notify-temp/vuln-tracker-${today}.md`. Each arm that has signal **appends its section** to this buffer; at the very end (step "Notify") the skill sends the buffer **once** if it is non-empty. This keeps notifications tight — a full poll with signal in two arms is one message, not two.
Then run the arm(s) selected by `scope`, and finish with the shared **Notify** and **Log** steps.
## Network Note
- **Arm A & Arm B (GitHub reads):** all data via `gh api` / `gh search` / `gh pr view`. `gh` handles auth internally via `GH_TOKEN` (and Arm B's private-advisory reads need the elevated `GH_GLOBAL` PAT). No env-var-authenticated `curl` from bash — a bare `$SECRET` on the command line is refused by the Bash permission layer, so `gh api` (auth handled internally) is the reliable path; no postprocess scripts needed. Arm B keeps a documented `curl` fallback for the advisory endpoint — see Arm B step B2 — but `gh api` is preferred.
- **Arm C (local only):** reads only local files (`memory/pending-disclosures/`, `memory/issues/`, `memory/topics/pr-status.md`). No outbound network or auth required.
---
## Arm A — PR & advisory lifecycle status (scope `full` or `prs`)
Audit the lifecycle status of every disclosure `vuln-scanner` has produced. Without a follow-up loop, three things rot silently:
- **Merged-but-uncelebrated wins** — landed fixes never reach self-improve / reflect (retro) without manual aggregation.
- **Maintainer questions on open PRs** — a maintainer comments asking for clarification; if the bot doesn't see it, the PR ages out.
- **Queued drafts past their disclosure window** — entries with `channel: "skipped"` (no-safe-channel) vanish into `vuln-scanned.json` with no recurring re-probe.
This arm cross-references `memory/vuln-scanned.json` against live GitHub state and surfaces anything the operator should look at.
### A1. Load the canonical scan history
**`memory/vuln-scanned.json` may be a flat top-level array of scan rows, not a `{scans: [...]}` object** - vuln-scanner writes the flat shape. Read it in a way that accepts both:
```bash
# Accepts both the flat `[ {repo,scanned_at,findings,channel}, ... ]` shape
# and the legacy `{scans: [...]}` object.
jq -c 'if type=="array" then .[] else (.scans[]? // empty) end' memory/vuln-scanned.json 2>/dev/null
```
A bare `jq -c '.scans[]'` **errors** on a flat-array file (`Cannot index array with string "scans"`) and, with `2>/dev/null` swallowing the error, silently yields zero rows - so the arm reads an empty scan history and reports only what other passes happen to surface. The `if type=="array"` guard is the fix; it keeps the legacy object shape working too.
If `memory/vuln-scanned.json` doesn't exist or the parsed row count is 0, log `VULN_TRACKER_SKIP: no scan history` for this arm and skip Arm A (no notification section - first runs of `vuln-scanner` haven't happened yet). In a `full` poll, continue to Arm B/C.
Each scan entry has at minimum: `repo`, `scanned_at`, `findings`, `channel`, `severity`. Public-PR entries also have `pr` (URL). Pending-disclosure entries have `draft_at` and `patch_branch`. Skipped entries have `reason`.
**Retro-active coverage:** if the JSON was written after vuln-scanner started running, some PRs won't be in the JSON. Pull all bot-authored security PRs from GitHub directly (next step) to fill the gap.
### A2. Pull all bot-authored security PRs from GitHub
`vuln-scanner` opens security-fix PRs on a **`security/`** branch. The **title** varies: dependency-bump PRs use **`fix(deps):`** (the A5a template is `fix(deps): bump <pkg> to patch <CVE>`), and older or hand-written ones use **`fix(security):`** or a bare **`security:`**. **Match all three title prefixes, and treat the `security/` branch prefix as the real invariant** - the branch name is mandated by A5a, the title wording is not.
> **Do not filter on `fix(security):` alone.** vuln-scanner's dominant output is `fix(deps):`, so a `fix(security):`-only filter silently drops most in-flight security PRs - that is how `alibaba/open-code-review#541` (conflicted, lint-failing, one unaddressed review) was missed by a poll while other PRs were tracked. Also exclude `docs(security):` - those are `security-sync` website PRs, not disclosures.
`gh search prs --json` does **not** expose `headRefName` - that field is only available via GraphQL. So run **both** passes below every time and union them on `repository + number`; neither alone is complete.
The bot author is whoever the workflow uses (typically `github-actions[bot]` or a dedicated account configured in the workflow). Determine the author from `aeon.yml` or the workflow file; default to whatever account opened the most recent security-fix PR (any of the prefixes above) you can find.
**Pass 1 (title prefixes, works everywhere):**
```bash
BOT_AUTHOR="<resolved bot author>"
gh search prs --author "$BOT_AUTHOR" --json number,title,url,state,createdAt,closedAt,repository --limit 200 \
| jq '[.[] | select(
(.title | startswith("fix(deps):")) or
(.title | startswith("fix(security):")) or
(.title | startswith("security:"))
)]'
```
**Pass 2 (GraphQL, required - picks up every `security/` branch whatever the title says):**
```bash
gh api graphql -f query='
{
search(query: "author:'"$BOT_AUTHOR"' is:pr sort:created-desc", type: ISSUE, first: 100) {
nodes { ... on PullRequest {
number title url state createdAt closedAt mergedAt
repository { nameWithOwner }
headRefName
}}
}
}' | jq '[.data.search.nodes[]
| select((.headRefName // "") | startswith("security/"))]'
```
Union the two result sets, dedup by `repository + number` (equivalently, by URL).
Cross-reference with `vuln-scanned.json`:
- PR present in JSON → use JSON's `severity` / `cwe` / `note` for the row.
- PR not in JSON → mark as `pre-history`; fill severity from the PR title if obvious.
- JSON entry with `channel != "public-pr"` → no PR to fetch; goes in the "queued" / "skipped" sections.
### A3. Fetch live state for each open PR
For each open PR (state from step A2):
```bash
gh pr view "$REPO/$NUM" --json state,merged,closedAt,createdAt,reviews,comments,reviewDecision,author
```
Per-PR signals:
- **Maintainer-needs-answer**: any comment whose `author.login != $BOT_AUTHOR` posted **after** the most recent comment by `$BOT_AUTHOR` (or after PR creation if the bot hasn't commented). Also `reviewDecision == "CHANGES_REQUESTED"` always counts.
- **Stale-no-review**: `state == "OPEN"` AND no review AND no maintainer comment AND `createdAt` > 7d ago.
- **Aging-with-engagement**: `state == "OPEN"` AND any maintainer activity AND open > 14d.
If a scan entry has `advisory_ids` (one or more GHSA IDs), check each one's published state:
```bash
gh api "repos/$ORIGIN_REPO/security-advisories/$GHSA_ID" --jq '.state // "not found"'
```
`state == "published"` → public advisory visible. 404 → osv-scanner referenced it but the upstream repo never published its own advisory.
### A4. Fetch star counts for every secured repo
For every unique `repo` across the union from step A2 (JSON history + bot-authored security PRs):
```bash
gh api "repos/$REPO" --jq '{stars: .stargazers_count, archived: .archived}' 2>/dev/null
```
**Refetch every run.** Do NOT carry star counts forward from the previous `memory/topics/vuln-followup.md` — per-repo counts drift between runs and the secured-stars headline is the operator's load-bearing metric. Cache only within a single run, keyed by `nameWithOwner`, so a repo with multiple PRs is fetched once.
Repo-state handling:
- **200 with stars**: use `.stargazers_count` (raw integer).
- **200 with `archived: true`**: still use the star count, but suffix the repo cell with ` (archived)` so the operator knows the maintainer isn't responsive.
- **404 / 403**: repo was deleted, renamed, or made private. Record `null` and render as `repo-deleted`. Exclude from `total_stars_*` aggregates entirely so dead repos don't quietly zero out the totals.
- **Other non-2xx**: record `null` and render `★?`. Flag in the run log for operator follow-up.
These per-repo counts power both the **Stars Secured** aggregate (step A6) and the `Stars` column on every per-repo table in step A6.
### A5. Re-probe `channel: "skipped"` and `channel: "pending-disclosure"` repos
For each historical entry where the disclosure couldn't ship, re-check whether the situation changed:
- **`channel: "skipped"`** with `reason` containing "no PVR":
```bash
PVR_NOW=$(gh api "repos/$REPO/private-vulnerability-reporting" --jq .enabled 2>/dev/null || echo "false")
```
If `PVR_NOW=true` and the original was `false`, surface as **newly-actionable**.
- **`channel: "skipped"`** with `reason` containing "no SECURITY.md" — re-check `gh api repos/$REPO/contents/SECURITY.md` and `.github/SECURITY.md`. If now present, surface as **newly-actionable**.
- **`channel: "pending-disclosure"`** — cross-reference with `memory/pending-disclosures/` to see if the draft is still on disk. If the file is gone but the JSON entry says `pending-disclosure`, mark as `lost-draft` so it stops being escalated forever.
### A6. Categorize every entry, then rewrite the dashboard
| Status | Meaning |
|---|---|
| `merged` | PR merged. One-time celebration — drop from notifications after 30d. |
| `open-clean` | PR open, no maintainer activity yet, < 7d old. Wait. |
| `needs-answer` | Maintainer commented or requested changes. **Operator action.** |
| `stale-no-review` | Open > 7d, zero maintainer activity. Consider polite ping or close. |
| `aging-engaged` | Open > 14d with engagement. Operator should triage. |
| `closed-no-merge` | PR closed without merging. Capture the reason for review. |
| `queued` | `pending-disclosure` draft on disk, not yet shipped. |
| `skipped-rechecked` | Channel was "skipped" originally; re-probe still shows no channel. |
| `newly-actionable` | Skipped originally; PVR or SECURITY.md now present. **Operator action.** |
| `lost-draft` | JSON says pending-disclosure but draft file is gone. Display once, then suppress. |
| `pre-history` | PR found via search but predates `vuln-scanned.json`. Fill what we can. |
Then rewrite `memory/topics/vuln-followup.md` (rewrite — don't append; this file is a living dashboard, not a log).
The **Stars Secured** block goes at the top so the operator sees aggregate impact before drilling into rows. `total_stars_secured` = sum of stargazers across every unique repo where vuln-scanner has landed at least one merged PR. `total_stars_in_flight` = sum across repos with an open PR. `total_stars_tracked` = sum across the full union. Track all three because celebration uses `secured`, prioritization uses `in_flight`, and historical review uses `tracked`.
Round star counts to abbreviated form for the headline (12.4k, 1.8k, 940). Keep raw integers in the per-repo tables so sort/diff stays exact.
```markdown
# Vuln Tracker Status
*Last updated: ${today}*
## Stars Secured
- **Merged-PR repos (secured):** ★ <total_stars_secured> across <secured_repo_count> repos
Auf GitHub ansehen