11#!/usr/bin/env python3
2+ import argparse
23import os
34import sys
4- import argparse
55from datetime import datetime , timezone
66
77import httpx
@@ -11,21 +11,36 @@ ALL_STATES = ["published", "draft", "triage", "closed"]
1111
1212RESET = "\033 [0m"
1313BOLD = "\033 [1m"
14+ GREY = "\033 [2m"
1415
1516STATE_COLORS = {
16- "draft" : "\033 [36m" , # cyan
17- "triage" : "\033 [33m" , # yellow
17+ "draft" : "\033 [36m" , # cyan
18+ "triage" : "\033 [33m" , # yellow
1819 "published" : "\033 [32m" , # green
19- "closed" : " \033 [2m" , # grey
20+ "closed" : GREY ,
2021}
2122
23+ PR_STATE_COLORS = {
24+ "open" : "\033 [32m" , # green
25+ "merged" : "\033 [35m" , # purple
26+ "closed" : "\033 [31m" , # red
27+ }
28+
29+
30+ def pr_state (pull ):
31+ if pull is None :
32+ return None
33+ if pull .get ("merged_at" ):
34+ return "merged"
35+ return pull .get ("state" , "?" ) # "open" or "closed"
2236
23- def plasma (t ):
37+
38+ def plasma_rgb (t ):
2439 """Plasma color: t=0 → purple (old), t=1 → yellow (recent)."""
2540 r = int (13 + (253 - 13 ) * t )
2641 g = int (8 + (231 - 8 ) * t )
2742 b = int (135 + (37 - 135 ) * t )
28- return f" \033 [38;2; { r } ; { g } ; { b } m"
43+ return r , g , b
2944
3045
3146def age_t (date_str ):
@@ -34,9 +49,14 @@ def age_t(date_str):
3449 return max (0.0 , 1.0 - age / 365 )
3550
3651
37- def block (date_str ):
38- color = plasma (age_t (date_str )) if date_str else ""
39- return f"{ color } █{ RESET } "
52+ def age_badge (date_str , text ):
53+ """Render text on a plasma background; foreground chosen for legibility."""
54+ t = age_t (date_str ) if date_str else 0.0
55+ r , g , b = plasma_rgb (t )
56+ luminance = 0.299 * r + 0.587 * g + 0.114 * b
57+ fg = "\033 [30m" if luminance > 128 else "\033 [97m"
58+ bg = f"\033 [48;2;{ r } ;{ g } ;{ b } m"
59+ return f"{ bg } { fg } { text } { RESET } "
4060
4161
4262def redact_str (s ):
@@ -55,6 +75,23 @@ def check_token():
5575 return token
5676
5777
78+ async def fetch_first_pull (client , fork_html_url , results ):
79+ """Fetch the first pull request (by creation date) for a private fork repo."""
80+ path = fork_html_url .split ("github.com/" , 1 )[- 1 ].rstrip ("/" )
81+ url = f"{ GITHUB_API } /repos/{ path } /pulls"
82+ params = {"per_page" : 1 , "state" : "all" , "sort" : "created" , "direction" : "asc" }
83+ try :
84+ req = client .build_request ("GET" , url , params = params )
85+ resp = await client .send (req )
86+ if resp .status_code == 200 :
87+ pulls = resp .json ()
88+ results [fork_html_url ] = pulls [0 ] if pulls else None
89+ else :
90+ results [fork_html_url ] = None
91+ except httpx .HTTPError :
92+ results [fork_html_url ] = None
93+
94+
5895async def fetch (client , org , state , results ):
5996 """Fetch all advisories for (org, state), following pagination, storing in results[(org, state)]."""
6097 url = f"{ GITHUB_API } /orgs/{ org } /security-advisories"
@@ -85,12 +122,19 @@ async def fetch(client, org, state, results):
85122 results [(org , state )] = advisories
86123
87124
88- def print_org (org , states , results_by_key , redact = False ):
125+ def link (text , target ):
126+ return f"\033 [4m\033 ]8;;{ target } \033 \\ { text } \033 ]8;;\033 \\ \033 [24m"
127+
128+
129+ def color (text , color ):
130+ return f"{ color } { text } { RESET } "
131+
132+
133+ def print_org (org , states , results_by_key , pull_results = None , redact = False ):
89134 advisories = []
90135 for state in states :
91136 advisories .extend (results_by_key .get ((org , state ), []))
92- if not redact :
93- print (f"\n { BOLD } === { org } ==={ RESET } " )
137+ print (f"\n { BOLD } === { org if not redact else 'REDACTED ORG' } ==={ RESET } " )
94138 if not advisories :
95139 if not redact :
96140 print (" (no advisories)" )
@@ -109,29 +153,27 @@ def print_org(org, states, results_by_key, redact=False):
109153
110154 for repo , items in advisories_by_repo :
111155 items = sorted (items , key = lambda a : a .get ("updated_at" , "" ), reverse = True )
112- if not redact :
113- print (f"{ repo } " )
156+ print (f"{ BOLD } { repo if not redact else 'REDACTED REPO' } { RESET } " )
114157 for advisory in items :
158+ id = advisory .get ("ghsa_id" , "" ) if not redact else "GHSA-xxxx-yyyy-zzzz"
159+ url = advisory .get ("html_url" , "" )
115160 date = advisory .get ("updated_at" , "" )
116- title = (advisory .get ("summary" ) or "" )[:50 ].ljust (50 )
161+ title = (advisory .get ("summary" ) or "" )[:40 ].ljust (40 )
117162 state_val = advisory .get ("state" ) or "?"
118- state_col = STATE_COLORS .get (state_val , "" )
119- state_str = f"{ state_col } { state_val .ljust (10 )} { RESET } "
120- url = advisory .get ("html_url" , "" )
121- url_base , _ , url_id = url .rpartition ("/" )
122- if redact :
123- # replace org/repo slugs in the URL with literal ORG/REPO
124- parts = url_base .split ("/" )
125- if len (parts ) >= 5 :
126- parts [3 ] = "ORG"
127- parts [4 ] = "REPO"
128- url_base = "/" .join (parts )
129- # GHSA-xxxx-yyyy-zzzz → keep "GHSA-" prefix, hide the random bits
130- if url_id .startswith ("GHSA-" ):
131- url_id = "GHSA-" + redact_str (url_id [5 :])
132- else :
133- url_id = redact_str (url_id )
134- url_fmt = f"{ state_col } \033 [2m{ url_base } /\033 [22m{ url_id } { RESET } "
163+ state_str = color (state_val .ljust (9 ), STATE_COLORS .get (state_val , "" ))
164+ cve = advisory .get ("cve_id" ) or ""
165+ fork = advisory .get ("private_fork" )
166+ fork_html_url = fork .get ("html_url" ) if fork else None
167+ cve_str = cve [:15 ].ljust (15 )
168+ cve_str = color (cve_str , GREY )
169+ first_pull = (
170+ (pull_results or {}).get (fork_html_url ) if fork_html_url else None
171+ )
172+ ps = pr_state (first_pull )
173+ pull_str = (
174+ color (link ("PR " + ps .ljust (6 ), first_pull ["html_url" ]), PR_STATE_COLORS .get (ps , GREY ))
175+ if first_pull else " " * 9
176+ )
135177 days_ago = (
136178 (
137179 datetime .now (timezone .utc )
@@ -140,13 +182,14 @@ def print_org(org, states, results_by_key, redact=False):
140182 if date
141183 else "?"
142184 )
185+ badge = age_badge (date , (str (days_ago ) + 'd' ).rjust (5 ))
143186 if redact :
144187 print (
145- f" { block ( date ) } { str ( days_ago ). rjust ( 4 ) } d { state_str } { url_fmt } "
188+ f" { badge } \t { link ( id , url ) } { state_str } "
146189 )
147190 else :
148191 print (
149- f" { block ( date ) } { str ( days_ago ). rjust ( 4 ) } d { title } { state_str } { url_fmt } "
192+ f" { badge } \t { state_str } { color ( link ( id , url ), GREY ) } { title } { cve_str } { pull_str } "
150193 )
151194
152195
@@ -188,14 +231,25 @@ async def main():
188231 }
189232
190233 results = {}
234+ pull_results = {}
191235 async with httpx .AsyncClient (headers = headers , timeout = 30.0 ) as client :
192236 async with trio .open_nursery () as nursery :
193237 for org in args .orgs :
194238 for state in states :
195239 nursery .start_soon (fetch , client , org , state , results )
196240
241+ fork_urls = {
242+ advisory ["private_fork" ]["html_url" ]
243+ for advisories in results .values ()
244+ for advisory in advisories
245+ if advisory .get ("private_fork" ) and advisory ["private_fork" ].get ("html_url" )
246+ }
247+ async with trio .open_nursery () as nursery :
248+ for fork_url in fork_urls :
249+ nursery .start_soon (fetch_first_pull , client , fork_url , pull_results )
250+
197251 for org in args .orgs :
198- print_org (org , states , results , redact = args .redact )
252+ print_org (org , states , results , pull_results = pull_results , redact = args .redact )
199253 print ()
200254
201255
0 commit comments