vuln-tracker

star 517

Status check on every PR / advisory / queued draft produced by vuln-scanner — surfaces merges, stale opens, maintainer responses needing reply, and queued-too-long carve-outs

aaronjmars By aaronjmars schedule Updated 6/15/2026

name: Vuln Tracker category: dev description: Status check on every PR / advisory / queued draft produced by vuln-scanner — surfaces merges, stale opens, maintainer responses needing reply, and queued-too-long carve-outs tags: [meta, security, github] depends_on: [vuln-scanner]

Today is ${today}. Audit the lifecycle status of every disclosure vuln-scanner has produced. Read memory/MEMORY.md for context.

Why this skill exists

vuln-scanner opens a PR (or queues a draft) and moves on. Without a follow-up loop, three things rot silently:

  • Merged-but-uncelebrated wins — landed fixes never make it into self-review / retrospective without manual aggregation.
  • Maintainer questions on open PRs — a maintainer comments asking for a 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 skill is the daily sweep. It cross-references memory/vuln-scanned.json against live GitHub state and surfaces anything the operator should look at.

Sandbox Note

All data via gh api / gh search / gh pr view. No env-var-authenticated curl, no outbound HTTP from bash. gh handles auth internally via GH_TOKEN. No prefetch / postprocess scripts needed.

Steps

1. Load the canonical scan history

jq -c '.scans[]' memory/vuln-scanned.json 2>/dev/null

If memory/vuln-scanned.json doesn't exist or has no scans array, log VULN_TRACKER_SKIP: no scan history and exit cleanly (no notification — first runs of vuln-scanner haven't happened yet).

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.

2. Pull all bot-authored security PRs from GitHub

vuln-scanner opens PRs with title prefix fix(security): and branch prefix security/. Title prefix is the more reliable signal across full history.

gh search prs --json does not expose headRefName — that field is only available via GraphQL. Use title-prefix as primary, GraphQL as optional belt-and-suspenders.

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 fix(security): PR you can find.

Primary (title prefix, works everywhere):

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(security):"))]'

Optional (GraphQL, picks up branch-prefix-only PRs without fix(security): title):

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 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.

3. Fetch live state for each open PR

For each open PR (state from step 2):

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:

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.

4. Fetch star counts for every secured repo

For every unique repo across the union from step 2 (JSON history + bot-authored security PRs):

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 5) and the Stars column on every per-repo table in step 6.

5. 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":
    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.

6. Categorize every entry

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.

7. Update 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.

# Vuln Tracker Status

*Last updated: ${today}*

## Stars Secured

- **Merged-PR repos (secured):** ★ <total_stars_secured> across <secured_repo_count> repos
- **Open-PR repos (in flight):** ★ <total_stars_in_flight> across <in_flight_repo_count> repos
- **All tracked repos:** ★ <total_stars_tracked> across <total_repo_count> repos

### Secured leaderboard — every merged PR ranked by repo stars
| Rank | Repo | Stars | PR | Merged | Severity | Title |
|------|------|-------|----|--------|----------|-------|

### Per-repo breakdown — secured (sorted by stars desc)
| Repo | Stars | Merged PRs | First merge | Latest merge | Severities landed |
|------|-------|------------|-------------|--------------|-------------------|

### Per-repo breakdown — in flight (sorted by stars desc)
| Repo | Stars | Open PRs | Oldest open | Severities open |
|------|-------|----------|-------------|-----------------|

### Per-repo breakdown — queued / skipped / closed (sorted by stars desc)
| Repo | Stars | Status | Severity | Note |
|------|-------|--------|----------|------|

## Operator-action queue

### Needs answer (<count>)
| Repo | Stars | PR | Title | Last activity | Latest commenter |
|------|-------|----|----|--------------|------------------|

### Newly actionable — channel opened up since the original scan (<count>)
| Repo | Stars | Original date | Original blocker | Now |
|------|-------|---------------|------------------|-----|

### Stale or aging
| Repo | Stars | PR | Age | Status | Suggested action |
|------|-------|----|----|-----|------------------|

## Recently merged (last 30d, <count>)
| Date merged | Repo | Stars | PR | Severity | Title |
|-------------|------|-------|----|----------|-------|

## Open / clean (no operator action — wait, < 7d) (<count>)
| Repo | Stars | PR | Severity | Opened | Age |
|------|-------|----|----------|--------|-----|

## Closed without merge (last 30d, <count>)
| Date | Repo | Stars | PR | Severity | Title | Likely reason |
|------|------|-------|----|----------|-------|---------------|

## Queued (no PR yet) (<count>)
| Severity | Repo | Stars | Original channel | Original blocker | Days queued |
|----------|------|-------|------------------|------------------|-------------|

## Lost-draft ghosts (suppressed from notifications)
| Date | Repo | Stars | Severity |

8. Decide whether to notify

Skip notification if all are true:

  • Zero needs-answer
  • Zero newly-actionable
  • Zero items moved to merged since the last run
  • Zero items moved to closed-no-merge since the last run
  • Zero items aged into stale-no-review or aging-engaged since the last run

To detect "since last run," diff today's categorization against the previous memory/topics/vuln-followup.md. If the file doesn't exist (first run), treat all entries as new and notify the full backlog.

9. Format notification (when sending)

Write to .pending-notify-temp/vuln-tracker-${today}.md, then ./notify -f:

*Vuln Tracker — ${today}*

★ secured: <total_stars_secured> across <secured_repo_count> repos  (in flight: <total_stars_in_flight> / <in_flight_repo_count>)

needs answer: <N>
<repo> (★<stars>) #<num> — <latest_commenter>: "<comment_excerpt_first_120_chars>"

newly actionable: <N>
<repo> (★<stars>, queued <days>d) — <what_changed>

merged this week: <N>  <list_with_stars>
opened, waiting: <N>
stale: <N>
queued: <N> (<critical>C / <high>H / <other>M+L)

leaderboard top-3 (PRs by ★): #1 <repo1> ★<s1> (PR #<pr1>) — #2 <repo2> ★<s2> (PR #<pr2>) — #3 <repo3> ★<s3> (PR #<pr3>)

dashboard: memory/topics/vuln-followup.md (full leaderboard inside)

Keep under 4000 chars. If it doesn't fit, drop the merged/opened/stale section bodies and keep counts only.

10. Log the run

Append to memory/logs/${today}.md:

## Vuln Tracker
- Scan history: <total_in_json> JSON entries + <pre_history_prs> pre-history PRs = <total> tracked items
- States: <merged> merged / <open_clean> open-clean / <needs_answer> needs-answer / <stale> stale / <aging> aging / <closed> closed / <queued> queued / <skipped> skipped-rechecked / <newly_actionable> newly-actionable
- Stars: ★<total_stars_secured> secured (<secured_repo_count> repos) / ★<total_stars_in_flight> in flight (<in_flight_repo_count> repos) / ★<total_stars_tracked> tracked (<total_repo_count> repos)
- Operator queue this run: <needs_answer + newly_actionable>
- Notification: <sent | skipped (no movement)>
- VULN_TRACKER_OK

Required Env Vars

  • GH_TOKEN / GITHUB_TOKEN — required. repo scope is enough for PR/comment reads and the PVR-state endpoint.

Notes & related

  • vuln-scanned.json schema is loosecwe may be a string or an array; advisory_ids may be present or absent. Handle both.
  • Sibling skill: vuln-scanner produces the records this skill audits. If a parallel disclosure-tracker or pr-tracker exists in the operator's setup, coordinate via shared memory/topics/vuln-followup.md to avoid duplicate escalation.
Install via CLI
npx skills add https://github.com/aaronjmars/aeon --skill vuln-tracker
Repository Details
star Stars 517
call_split Forks 176
navigation Branch main
article Path SKILL.md
More from Creator