Skip to content

Commit 5bb6545

Browse files
committed
Add date filtering ; add time to publication
1 parent a6e8914 commit 5bb6545

6 files changed

Lines changed: 477 additions & 133 deletions

File tree

README.md

Lines changed: 42 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,56 @@
11
# github-security-overview
22

3-
Aggregates and prints all github security advisories from all repos belonging to the input github organization(s).
3+
Print all GitHub security advisories across one or more GitHub organizations, one advisory per line.
44

5-
# Why
5+
```bash
6+
pip install .
7+
GITHUB_TOKEN=ghp_... security-overview org1 [org2 ...]
8+
```
69

7-
Because github has no aggregated view for security advisories across repositories.
10+
The token needs `security_events` read access.
811

9-
# Example output
12+
## Example
1013

1114
```
12-
$ ./security-overview acme
13-
14-
=== acme ===
15-
repo-a
16-
0d Arbitrary File Read via Path Traversal draft https://github.com/acme/repo-a/security/advisories/GHSA-xxxx-xxxx-xxxx
17-
0d Arbitrary File Write via Path Traversal draft https://github.com/acme/repo-a/security/advisories/GHSA-xxxx-xxxx-xxxx
18-
12d XSS when output is exported as HTML draft https://github.com/acme/repo-a/security/advisories/GHSA-xxxx-xxxx-xxxx
19-
repo-b
20-
107d Path Traversal in Assignment Validation draft https://github.com/acme/repo-b/security/advisories/GHSA-xxxx-xxxx-xxxx
21-
repo-c
22-
116d Authentication Token Theft via XSS triage https://github.com/acme/repo-c/security/advisories/GHSA-xxxx-xxxx-xxxx
23-
181d Open redirect via untrusted user input triage https://github.com/acme/repo-c/security/advisories/GHSA-xxxx-xxxx-xxxx
24-
232d Pwn Request via misconfigured workflow draft https://github.com/acme/repo-c/security/advisories/GHSA-xxxx-xxxx-xxxx
25-
454d XSS via mermaid chart rendering draft https://github.com/acme/repo-c/security/advisories/GHSA-xxxx-xxxx-xxxx
26-
624d DOM Clobbering XSS draft https://github.com/acme/repo-c/security/advisories/GHSA-xxxx-xxxx-xxxx
27-
638d Arbitrary file overwrite on extension install triage https://github.com/acme/repo-c/security/advisories/GHSA-xxxx-xxxx-xxxx
15+
$ security-overview acme
16+
created updated to-publish state org repo cve title
17+
12 0 draft acme repo-a CVE-2026-xxxx Arbitrary File Read via Path Traversal
18+
232 9 6 published acme repo-c CVE-2025-xxxx Pwn Request via misconfigured workflow
19+
233 107 draft acme repo-b Path Traversal in Assignment Validation PRs: #7 (opened)
20+
116 116 triage acme repo-c Authentication Token Theft via XSS
2821
```
2922

30-
# Run it
31-
32-
- Install [Github CLI](https://cli.github.com/) and `gh auth login`
23+
Ages are in days; `to-publish` is the time from open to publication. The header goes to
24+
stderr, and when stdout is piped the columns are tab-separated — so `wc -l` counts
25+
advisories and `awk`/`cut`/`datamash` work directly, e.g. `security-overview acme | datamash --narm median 3`.
3326

34-
- `curl -fsSL https://raw.githubusercontent.com/Yann-P/github-security-overview/main/security-overview | python3 - org1 [org2]`
35-
36-
# Usage
27+
## Options
3728

3829
```
39-
./security-overview org1 [org2] ... [org𝑛]
40-
./security-overview --state draft --state triage org1 [org2] ... [org𝑛]
30+
usage: security-overview [-h] [--state STATE[,STATE...]] [--redact]
31+
[--format {terminal,md}] [--opened-from YYYY-MM-DD]
32+
[--opened-to YYYY-MM-DD]
33+
[--published-from YYYY-MM-DD]
34+
[--published-to YYYY-MM-DD]
35+
org [org ...]
36+
37+
options:
38+
-h, --help show this help message and exit
39+
--state STATE[,STATE...]
40+
filter by state; repeatable and/or comma-separated
41+
(default: all states)
42+
--redact hide org/repo names, GHSA random chars, and drop title
43+
+ headers
44+
--format {terminal,md}
45+
output format (default: terminal)
46+
--opened-from YYYY-MM-DD
47+
only advisories opened on or after this date
48+
--opened-to YYYY-MM-DD
49+
only advisories opened on or before this date
50+
--published-from YYYY-MM-DD
51+
only advisories published on or after this date
52+
--published-to YYYY-MM-DD
53+
only advisories published on or before this date
4154
```
4255

43-
# Architecture principles
44-
45-
- No python dependencies
46-
47-
# Roadmap
48-
49-
- [x] color-coded full black character at the start of the line using plasma color range (yellow-purple) depending on last update date
50-
51-
# Licence
52-
53-
MIT
56+
MIT licence.

security_overview/cli.py

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import argparse
2+
import os
3+
import sys
4+
from datetime import date
25

36
import httpx
47
import trio
@@ -23,6 +26,35 @@ def parse_states(value):
2326
return states
2427

2528

29+
def parse_date(value):
30+
try:
31+
date.fromisoformat(value)
32+
except ValueError:
33+
raise argparse.ArgumentTypeError(f"invalid date {value!r} (expected YYYY-MM-DD)")
34+
return value
35+
36+
37+
def filter_date_range(results, field, date_from, date_to):
38+
"""Keep advisories whose `field` date falls in [date_from, date_to] (inclusive).
39+
40+
Advisories missing the field (e.g. never published) are dropped when a bound is set.
41+
"""
42+
if not date_from and not date_to:
43+
return results
44+
45+
def keep(advisory):
46+
value = (advisory.get(field) or "")[:10]
47+
if not value:
48+
return False
49+
if date_from and value < date_from:
50+
return False
51+
if date_to and value > date_to:
52+
return False
53+
return True
54+
55+
return {key: [a for a in advisories if keep(a)] for key, advisories in results.items()}
56+
57+
2658
async def main():
2759
token = check_token()
2860
parser = argparse.ArgumentParser(prog="security-overview")
@@ -45,6 +77,30 @@ async def main():
4577
default="terminal",
4678
help="output format (default: terminal)",
4779
)
80+
parser.add_argument(
81+
"--opened-from",
82+
type=parse_date,
83+
metavar="YYYY-MM-DD",
84+
help="only advisories opened on or after this date",
85+
)
86+
parser.add_argument(
87+
"--opened-to",
88+
type=parse_date,
89+
metavar="YYYY-MM-DD",
90+
help="only advisories opened on or before this date",
91+
)
92+
parser.add_argument(
93+
"--published-from",
94+
type=parse_date,
95+
metavar="YYYY-MM-DD",
96+
help="only advisories published on or after this date",
97+
)
98+
parser.add_argument(
99+
"--published-to",
100+
type=parse_date,
101+
metavar="YYYY-MM-DD",
102+
help="only advisories published on or before this date",
103+
)
48104
args = parser.parse_args()
49105
states = [s for group in (args.state or []) for s in group] or ALL_STATES
50106
renderer = RENDERERS[args.format]
@@ -64,6 +120,9 @@ async def main():
64120
for state in states:
65121
nursery.start_soon(fetch, client, org, state, results)
66122

123+
results = filter_date_range(results, "created_at", args.opened_from, args.opened_to)
124+
results = filter_date_range(results, "published_at", args.published_from, args.published_to)
125+
67126
fork_urls = {
68127
advisory["private_fork"]["html_url"]
69128
for advisories in results.values()
@@ -74,13 +133,28 @@ async def main():
74133
for fork_url in fork_urls:
75134
nursery.start_soon(fetch_pulls, client, fork_url, pull_results)
76135

136+
render_kwargs = {"pull_results": pull_results, "redact": args.redact}
137+
if args.format == "terminal":
138+
# tab-separated plain output when piped, so cut/awk/datamash can parse it
139+
plain = not sys.stdout.isatty()
140+
render_kwargs["plain"] = plain
141+
# headers go to stderr so stdout stays 1 line = 1 advisory
142+
print(render_terminal.header(redact=args.redact, plain=plain), file=sys.stderr)
77143
for org in args.orgs:
78-
out = renderer.render_org(org, states, results, pull_results=pull_results, redact=args.redact)
144+
out = renderer.render_org(org, states, results, **render_kwargs)
79145
if out:
80146
print(out)
81-
print()
147+
# terminal format stays 1 line = 1 advisory so `wc -l` counts vulns
148+
if args.format == "md":
149+
print()
82150

83151

84152
def run():
85153
"""Synchronous console-script entry point."""
86-
trio.run(main)
154+
try:
155+
trio.run(main)
156+
except BrokenPipeError:
157+
# downstream closed the pipe (e.g. `| head`); redirect stdout to
158+
# devnull so the interpreter's exit flush doesn't error too
159+
os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
160+
sys.exit(141) # 128 + SIGPIPE

security_overview/render_common.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@ def age_t(date_str):
2222
return max(0.0, 1.0 - age / 365)
2323

2424

25+
def days_between(start_str, end_str):
26+
if not start_str or not end_str:
27+
return None
28+
start = datetime.fromisoformat(start_str.replace("Z", "+00:00"))
29+
end = datetime.fromisoformat(end_str.replace("Z", "+00:00"))
30+
return (end - start).days
31+
32+
2533
def days_ago(date_str):
2634
if not date_str:
2735
return "?"
@@ -31,11 +39,15 @@ def days_ago(date_str):
3139
).days
3240

3341

42+
def repo_name(advisory):
43+
parts = (advisory.get("html_url") or "").split("/")
44+
return parts[4] if len(parts) > 4 else "unknown"
45+
46+
3447
def group_by_repo(advisories):
3548
by_repo = {}
3649
for advisory in advisories:
37-
repo = (advisory.get("html_url") or "").split("/")[4] or "unknown"
38-
by_repo.setdefault(repo, []).append(advisory)
50+
by_repo.setdefault(repo_name(advisory), []).append(advisory)
3951
return sorted(
4052
by_repo.items(),
4153
key=lambda x: max(a.get("updated_at", "") for a in x[1]),

0 commit comments

Comments
 (0)