Skip to content

Commit 7131770

Browse files
committed
Rework the code
1 parent 111a704 commit 7131770

16 files changed

Lines changed: 705 additions & 1080 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@
66
__pycache__
77
*.egg-info
88
task*.md
9+
build

README.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ advisories and `awk`/`cut`/`datamash` work directly, e.g. `security-overview acm
2727
## Options
2828

2929
```
30-
usage: security-overview [-h] [--state STATE[,STATE...]] [--redact]
30+
usage: security-overview [-h] [--state STATE[,STATE...]]
31+
[--columns COL[,COL...]] [--redact]
3132
[--format {terminal,md}] [--opened-from YYYY-MM-DD]
3233
[--opened-to YYYY-MM-DD]
3334
[--published-from YYYY-MM-DD]
@@ -39,8 +40,13 @@ options:
3940
--state STATE[,STATE...]
4041
filter by state; repeatable and/or comma-separated
4142
(default: all states)
42-
--redact hide org/repo names, GHSA random chars, and drop title
43-
+ headers
43+
--columns COL[,COL...]
44+
show only these columns; repeatable and/or comma-
45+
separated, always printed in the canonical order
46+
(default: all of created, updated, to-publish, state,
47+
org, repo, ghsa, cve, title, PRs)
48+
--redact hide org/repo names and drop every column that
49+
identifies an advisory
4450
--format {terminal,md}
4551
output format (default: terminal)
4652
--opened-from YYYY-MM-DD

security-overview

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
#!/usr/bin/env python3
2-
import trio
3-
from security_overview.cli import main
2+
from security_overview.cli import run
43

5-
trio.run(main)
4+
run()

security_overview/cli.py

Lines changed: 67 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -3,38 +3,24 @@
33
import sys
44
from datetime import date
55

6-
import httpx
76
import trio
87

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

4026
def 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

183119
def 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

security_overview/constants.py

Lines changed: 0 additions & 18 deletions
This file was deleted.

0 commit comments

Comments
 (0)