33import sys
44from datetime import date
55
6- import httpx
76import trio
87
98from . import render_md , render_terminal
10- from .constants import ALL_STATES
11- from .fetch import check_token , fetch , fetch_pulls
12- from .render_common import HEADER_COLS
9+ from .fetch import STATES , fetch_advisories , fetch_pulls , github_client
10+ from .rows import COLUMNS , build_rows , select_columns
1311
14- RENDERERS = {
15- "terminal" : render_terminal ,
16- "md" : render_md ,
17- }
1812
19-
20- def parse_states (value ):
21- states = [s .strip () for s in value .split ("," ) if s .strip ()]
22- for s in states :
23- if s not in ALL_STATES :
24- raise argparse .ArgumentTypeError (
25- f"invalid state { s !r} (choose from { ', ' .join (ALL_STATES )} )"
26- )
27- return states
28-
29-
30- def parse_columns (value ):
31- cols = [c .strip () for c in value .split ("," ) if c .strip ()]
32- for c in cols :
33- if c not in HEADER_COLS :
34- raise argparse .ArgumentTypeError (
35- f"invalid column { c !r} (choose from { ', ' .join (HEADER_COLS )} )"
36- )
37- return cols
13+ def name_list (valid , what ):
14+ """An argparse type for `--flag a,b`: a comma-separated list of known names."""
15+ def parse (value ):
16+ names = [v .strip () for v in value .split ("," ) if v .strip ()]
17+ for name in names :
18+ if name not in valid :
19+ raise argparse .ArgumentTypeError (
20+ f"invalid { what } { name !r} (choose from { ', ' .join (valid )} )"
21+ )
22+ return names
23+ return parse
3824
3925
4026def parse_date (value ):
@@ -45,147 +31,97 @@ def parse_date(value):
4531 return value
4632
4733
48- def filter_date_range (results , field , date_from , date_to ):
49- """Keep advisories whose `field` date falls in [date_from, date_to] (inclusive).
50-
51- Advisories missing the field (e.g. never published) are dropped when a bound is set.
52- """
53- if not date_from and not date_to :
54- return results
55-
56- def keep (advisory ):
57- value = (advisory .get (field ) or "" )[:10 ]
58- if not value :
59- return False
60- if date_from and value < date_from :
61- return False
62- if date_to and value > date_to :
63- return False
64- return True
34+ def in_date_range (timestamp , start , end ):
35+ """Does this timestamp's day fall in [start, end]? A missing timestamp never does."""
36+ day = (timestamp or "" )[:10 ]
37+ return bool (day ) and (start is None or day >= start ) and (end is None or day <= end )
6538
66- return {key : [a for a in advisories if keep (a )] for key , advisories in results .items ()}
6739
68-
69- async def main ():
70- token = check_token ()
40+ def parse_args ():
7141 parser = argparse .ArgumentParser (prog = "security-overview" )
7242 parser .add_argument ("orgs" , nargs = "+" , metavar = "org" )
7343 parser .add_argument (
7444 "--state" ,
75- type = parse_states ,
45+ type = name_list ( STATES , "state" ) ,
7646 action = "append" ,
7747 metavar = "STATE[,STATE...]" ,
7848 help = "filter by state; repeatable and/or comma-separated (default: all states)" ,
7949 )
8050 parser .add_argument (
8151 "--columns" ,
82- type = parse_columns ,
52+ type = name_list ( COLUMNS , "column" ) ,
8353 action = "append" ,
8454 metavar = "COL[,COL...]" ,
8555 help = (
8656 "show only these columns; repeatable and/or comma-separated, always "
87- f"printed in the canonical order (default: all of { ', ' .join (HEADER_COLS )} )"
57+ f"printed in the canonical order (default: all of { ', ' .join (COLUMNS )} )"
8858 ),
8959 )
9060 parser .add_argument (
9161 "--redact" ,
9262 action = "store_true" ,
93- help = "hide org/repo names, GHSA random chars, and drop title + headers " ,
63+ help = "hide org/repo names and drop every column that identifies an advisory " ,
9464 )
9565 parser .add_argument (
9666 "--format" ,
9767 choices = ["terminal" , "md" ],
9868 default = "terminal" ,
9969 help = "output format (default: terminal)" ,
10070 )
101- parser .add_argument (
102- "--opened-from" ,
103- type = parse_date ,
104- metavar = "YYYY-MM-DD" ,
105- help = "only advisories opened on or after this date" ,
106- )
107- parser .add_argument (
108- "--opened-to" ,
109- type = parse_date ,
110- metavar = "YYYY-MM-DD" ,
111- help = "only advisories opened on or before this date" ,
112- )
113- parser .add_argument (
114- "--published-from" ,
115- type = parse_date ,
116- metavar = "YYYY-MM-DD" ,
117- help = "only advisories published on or after this date" ,
118- )
119- parser .add_argument (
120- "--published-to" ,
121- type = parse_date ,
122- metavar = "YYYY-MM-DD" ,
123- help = "only advisories published on or before this date" ,
124- )
125- args = parser .parse_args ()
126- states = [s for group in (args .state or []) for s in group ] or ALL_STATES
127- columns = [c for group in (args .columns or []) for c in group ] or None
128- renderer = RENDERERS [args .format ]
129-
130- headers = {
131- "Authorization" : f"Bearer { token } " ,
132- "Accept" : "application/vnd.github+json" ,
133- "X-GitHub-Api-Version" : "2026-03-10" ,
134- "User-Agent" : "security-overview" ,
135- }
136-
137- results = {}
138- pull_results = {}
139- async with httpx .AsyncClient (headers = headers , timeout = 30.0 ) as client :
140- async with trio .open_nursery () as nursery :
141- for org in args .orgs :
142- for state in states :
143- nursery .start_soon (fetch , client , org , state , results )
144-
145- results = filter_date_range (results , "created_at" , args .opened_from , args .opened_to )
146- results = filter_date_range (results , "published_at" , args .published_from , args .published_to )
147-
148- fork_urls = {
149- advisory ["private_fork" ]["html_url" ]
150- for advisories in results .values ()
151- for advisory in advisories
152- if advisory .get ("private_fork" ) and advisory ["private_fork" ].get ("html_url" )
153- }
154- async with trio .open_nursery () as nursery :
155- for fork_url in fork_urls :
156- nursery .start_soon (fetch_pulls , client , fork_url , pull_results )
157-
158- render_kwargs = {"pull_results" : pull_results , "redact" : args .redact , "columns" : columns }
159- if args .format == "terminal" :
160- # tab-separated plain output when piped, so cut/awk/datamash can parse it
161- plain = not sys .stdout .isatty ()
162- render_kwargs ["plain" ] = plain
163- # one width per column for the whole run: every org and the header line
164- # up in a single table, instead of each org sizing its own columns
165- widths = None if plain else render_terminal .column_widths (
166- states , results , pull_results , args .redact , columns
71+ for when in ("opened" , "published" ):
72+ parser .add_argument (
73+ f"--{ when } -from" , type = parse_date , metavar = "YYYY-MM-DD" ,
74+ help = f"only advisories { when } on or after this date" ,
16775 )
168- render_kwargs ["widths" ] = widths
169- # headers go to stderr so stdout stays 1 line = 1 advisory
170- print (
171- render_terminal .header (redact = args .redact , plain = plain , columns = columns , widths = widths ),
172- file = sys .stderr ,
76+ parser .add_argument (
77+ f"--{ when } -to" , type = parse_date , metavar = "YYYY-MM-DD" ,
78+ help = f"only advisories { when } on or before this date" ,
17379 )
174- for org in args .orgs :
175- out = renderer .render_org (org , states , results , ** render_kwargs )
176- if out :
177- print (out )
178- # terminal format stays 1 line = 1 advisory so `wc -l` counts vulns
80+ return parser .parse_args ()
81+
82+
83+ async def main ():
84+ args = parse_args ()
85+ token = os .environ .get ("GITHUB_TOKEN" )
86+ if not token :
87+ sys .exit ("error: GITHUB_TOKEN environment variable not set" )
88+
89+ # --state and --columns are repeatable, so each one holds a list of lists of names
90+ states = [s for group in args .state or [] for s in group ] or STATES
91+ asked_for = [c for group in args .columns or [] for c in group ]
92+ columns = select_columns (asked_for or None , args .redact )
93+ date_filters = [
94+ ("created_at" , args .opened_from , args .opened_to ),
95+ ("published_at" , args .published_from , args .published_to ),
96+ ]
97+
98+ async with github_client (token ) as client :
99+ advisories = await fetch_advisories (client , args .orgs , states )
100+ for field , start , end in date_filters :
101+ if start or end :
102+ advisories = [a for a in advisories if in_date_range (a .get (field ), start , end )]
103+ # after filtering, so we only ask about the forks we are going to print
104+ await fetch_pulls (client , advisories )
105+
106+ rows = build_rows (advisories , args .orgs , args .redact )
179107 if args .format == "md" :
180- print ()
108+ markdown = render_md .render (rows , columns )
109+ if markdown :
110+ print (markdown + "\n " )
111+ return
112+
113+ header , lines = render_terminal .render (rows , columns , plain = not sys .stdout .isatty ())
114+ print (header , file = sys .stderr ) # headers go to stderr so stdout stays 1 line = 1 advisory
115+ for line in lines :
116+ print (line )
181117
182118
183119def run ():
184120 """Synchronous console-script entry point."""
185121 try :
186122 trio .run (main )
187123 except BrokenPipeError :
188- # downstream closed the pipe (e.g. `| head`); redirect stdout to
189- # devnull so the interpreter's exit flush doesn't error too
124+ # downstream closed the pipe (e.g. `| head`); point stdout at devnull so
125+ # the interpreter's exit flush doesn't fail too
190126 os .dup2 (os .open (os .devnull , os .O_WRONLY ), sys .stdout .fileno ())
191127 sys .exit (141 ) # 128 + SIGPIPE
0 commit comments