Skip to content
This repository was archived by the owner on May 30, 2022. It is now read-only.

Commit 562b7a1

Browse files
committed
add a web frontend
1 parent 7a1a130 commit 562b7a1

File tree

6 files changed

+336
-15
lines changed

6 files changed

+336
-15
lines changed

app.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import collections
2+
import os.path
3+
from typing import Dict
4+
from typing import Optional
5+
from typing import Tuple
6+
7+
import flask
8+
9+
from scan import File
10+
from scan import Repo
11+
from util import db_connect
12+
13+
app = flask.Flask(__name__)
14+
15+
16+
@app.route('/', methods=['GET'])
17+
def index() -> str:
18+
with db_connect() as db:
19+
repos = []
20+
21+
res = db.execute('SELECT * FROM data ORDER BY repo, filename')
22+
23+
name, filename, rev, account_type, star_count, checksum = next(res)
24+
files: Tuple[File, ...] = (File(filename, checksum),)
25+
repo = Repo(name, rev, account_type, star_count, files)
26+
27+
for name, filename, rev, account_type, star_count, checksum in res:
28+
if name == repo.repo:
29+
files = (*repo.files, File(filename, checksum))
30+
repo = repo._replace(files=files)
31+
else:
32+
repos.append(repo)
33+
files = (File(filename, checksum),)
34+
repo = Repo(name, rev, account_type, star_count, files)
35+
36+
repos.append(repo)
37+
repos.sort(key=lambda repo: -repo.star_count)
38+
39+
vulnerable_values: Dict[str, Optional[bool]] = {
40+
k: bool(v) for k, v in db.execute('SELECT * FROM status')
41+
}
42+
vulnerable = collections.defaultdict(lambda: None, vulnerable_values)
43+
done = {repo for repo, in db.execute('SELECT * FROM done')}
44+
45+
return flask.render_template(
46+
'index.html',
47+
repos=repos,
48+
vulnerable=vulnerable,
49+
done=done,
50+
)
51+
52+
53+
@app.route('/repo/<repo1>/<repo2>', methods=['GET'])
54+
def repo(repo1: str, repo2: str) -> str:
55+
repo_s = f'{repo1}/{repo2}'
56+
57+
with db_connect() as db:
58+
query = 'SELECT * FROM data WHERE repo = ? ORDER BY filename'
59+
res = db.execute(query, (repo_s,)).fetchall()
60+
files = tuple(
61+
File(filename, checksum)
62+
for _, filename, _, _, _, checksum in res
63+
)
64+
name, _, rev, account_type, star_count, _ = next(iter(res))
65+
repo = Repo(name, rev, account_type, star_count, files)
66+
67+
vulnerable_values: Dict[str, Optional[bool]] = {
68+
k: bool(v) for k, v in db.execute('SELECT * FROM status')
69+
}
70+
vulnerable = collections.defaultdict(lambda: None, vulnerable_values)
71+
done = {repo for repo, in db.execute('SELECT * FROM done')}
72+
73+
def _readfile(file: File) -> str:
74+
with open(os.path.join('files', repo.repo, repo.rev, file.name)) as f:
75+
return f.read()
76+
77+
contents = {file.name: _readfile(file) for file in repo.files}
78+
79+
return flask.render_template(
80+
'repo.html',
81+
repo=repo,
82+
vulnerable=vulnerable,
83+
done=done,
84+
contents=contents,
85+
)
86+
87+
88+
@app.route('/make-bad/<checksum>', methods=['POST'])
89+
def make_bad(checksum: str) -> Tuple[str, int]:
90+
with db_connect() as db:
91+
query = 'INSERT OR REPLACE INTO status VALUES (?, ?)'
92+
db.execute(query, (checksum, 1))
93+
return '', 204
94+
95+
96+
@app.route('/make-good/<checksum>', methods=['POST'])
97+
def make_good(checksum: str) -> Tuple[str, int]:
98+
with db_connect() as db:
99+
query = 'INSERT OR REPLACE INTO status VALUES (?, ?)'
100+
db.execute(query, (checksum, 0))
101+
return '', 204
102+
103+
104+
@app.route('/clear-status/<checksum>', methods=['POST'])
105+
def clear_status(checksum: str) -> Tuple[str, int]:
106+
with db_connect() as db:
107+
db.execute('DELETE FROM status WHERE checksum = ?', (checksum,))
108+
return '', 204
109+
110+
111+
@app.route('/mark-done/<repo1>/<repo2>', methods=['POST'])
112+
def mark_done(repo1: str, repo2: str) -> Tuple[str, int]:
113+
repo = f'{repo1}/{repo2}'
114+
with db_connect() as db:
115+
db.execute('INSERT OR REPLACE INTO done VALUES (?)', (repo,))
116+
return '', 204
117+
118+
119+
def main() -> int:
120+
app.run(port=8000, threaded=False)
121+
return 0
122+
123+
124+
if __name__ == '__main__':
125+
exit(main())

backfill_checksum.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import hashlib
2+
import os.path
3+
4+
from util import db_connect
5+
6+
7+
def main() -> int:
8+
with db_connect() as db:
9+
query = 'SELECT repo, filename, rev FROM data'
10+
for repo, filename, rev in db.execute(query):
11+
with open(os.path.join('files', repo, rev, filename), 'rb') as f:
12+
checksum = hashlib.sha256(f.read()).hexdigest()
13+
14+
db.execute(
15+
'UPDATE data SET checksum = ? WHERE repo = ? AND filename = ?',
16+
(checksum, repo, filename),
17+
)
18+
print('.', end='', flush=True)
19+
print()
20+
return 0
21+
22+
23+
if __name__ == '__main__':
24+
exit(main())

scan.py

Lines changed: 66 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import argparse
22
import collections
3+
import hashlib
34
import os.path
45
import shutil
56
import subprocess
@@ -12,6 +13,8 @@
1213
from typing import Generator
1314
from typing import List
1415
from typing import NamedTuple
16+
from typing import Optional
17+
from typing import Set
1518
from typing import Tuple
1619

1720
import ruamel.yaml
@@ -47,12 +50,48 @@ def _repos(token: str) -> Generator[str, None, None]:
4750
yield result['repository']['full_name']
4851

4952

50-
class RepoInfo(NamedTuple):
53+
class File(NamedTuple):
54+
name: str
55+
checksum: str
56+
57+
def css_class(self, vulnerable: Dict[str, Optional[bool]]) -> str:
58+
return {
59+
None: 'file-unknown',
60+
True: 'file-bad',
61+
False: 'file-ok',
62+
}[vulnerable[self.checksum]]
63+
64+
65+
class Repo(NamedTuple):
5166
repo: str
5267
rev: str
5368
account_type: str
5469
star_count: int
55-
filenames: Tuple[str, ...]
70+
files: Tuple[File, ...]
71+
72+
@property
73+
def repo1(self) -> str:
74+
repo1, _, _ = self.repo.partition('/')
75+
return repo1
76+
77+
@property
78+
def repo2(self) -> str:
79+
_, _, repo2 = self.repo.partition('/')
80+
return repo2
81+
82+
def css_class(
83+
self,
84+
vulnerable: Dict[str, Optional[bool]],
85+
done: Set[str],
86+
) -> str:
87+
if self.repo in done:
88+
return 'repo-done'
89+
elif all(vulnerable[file.checksum] is False for file in self.files):
90+
return 'repo-ok'
91+
elif any(vulnerable[file.checksum] is True for file in self.files):
92+
return 'repo-bad'
93+
else:
94+
return 'repo-unknown'
5695

5796
@property
5897
def url(self) -> str:
@@ -76,8 +115,11 @@ def _vulnerable_on(contents: Any) -> bool:
76115
cfg = on['pull_request_target']
77116
if cfg is None:
78117
return True
79-
elif 'types' not in cfg or not isinstance(cfg['types'], list):
118+
elif 'types' not in cfg:
80119
return True
120+
elif isinstance(cfg['types'], str):
121+
return cfg['types'] in {'opened', 'synchronize', 'reopened'}
122+
81123
return bool(set(cfg['types']) & {'opened', 'synchronize', 'reopened'})
82124

83125

@@ -111,7 +153,7 @@ def _vulnerable_jobs(contents: Dict[str, Any]) -> bool:
111153
return False
112154

113155

114-
def _get_repo_info(repo: str, token: str) -> RepoInfo:
156+
def _get_repo_info(repo: str, token: str) -> Repo:
115157
resp = req(
116158
f'https://api.github.com/repos/{repo}',
117159
headers={'Authorization': f'token {token}'},
@@ -129,7 +171,7 @@ def _get_repo_info(repo: str, token: str) -> RepoInfo:
129171
subprocess.check_call((*git, 'checkout', '--', '.github/workflows'))
130172
rev = subprocess.check_output((*git, 'rev-parse', 'HEAD')).strip()
131173

132-
filenames = []
174+
files = []
133175
for filename in os.listdir(os.path.join(tmpdir, '.github/workflows')):
134176
filename = os.path.join('.github/workflows', filename)
135177
if not filename.endswith('.yml'):
@@ -143,18 +185,21 @@ def _get_repo_info(repo: str, token: str) -> RepoInfo:
143185
continue
144186

145187
if _vulnerable_on(contents) and _vulnerable_jobs(contents):
146-
filenames.append(filename)
188+
with open(tmp_filename, 'rb') as f_b:
189+
checksum = hashlib.sha256(f_b.read()).hexdigest()
190+
191+
files.append(File(filename, checksum))
147192

148193
dest = os.path.join('files', repo, rev.decode(), filename)
149194
os.makedirs(os.path.dirname(dest), exist_ok=True)
150195
shutil.copy(tmp_filename, dest)
151196

152-
return RepoInfo(
197+
return Repo(
153198
repo=repo,
154199
rev=rev.decode(),
155200
account_type=account_type,
156201
star_count=star_count,
157-
filenames=tuple(sorted(filenames)),
202+
files=tuple(sorted(files)),
158203
)
159204

160205

@@ -174,7 +219,7 @@ def main() -> int:
174219
else:
175220
seen = set()
176221

177-
by_org: Dict[str, List[RepoInfo]] = collections.defaultdict(list)
222+
by_org: Dict[str, List[Repo]] = collections.defaultdict(list)
178223

179224
for repo_s in _repos(token):
180225
if repo_s in seen:
@@ -185,25 +230,32 @@ def main() -> int:
185230
org, _ = repo_s.split('/')
186231
by_org[org].append(_get_repo_info(repo_s, token))
187232

188-
by_org = {k: [r for r in v if r.filenames] for k, v in by_org.items()}
233+
by_org = {k: [r for r in v if r.files] for k, v in by_org.items()}
189234
by_org = {k: v for k, v in sorted(by_org.items()) if v}
190235

191236
for org, repos in by_org.items():
192237
for repo in repos:
193238
print(repo.repo)
194-
for filename in repo.filenames:
239+
for filename, _ in repo.files:
195240
print(f'- {repo.file_url(filename)}')
196241
print()
197242

198243
rows = [
199-
(repo.repo, filename, repo.rev, repo.account_type, repo.star_count)
244+
(
245+
repo.repo,
246+
filename,
247+
repo.rev,
248+
repo.account_type,
249+
repo.star_count,
250+
checksum,
251+
)
200252
for repos in by_org.values()
201253
for repo in repos
202-
for filename in repo.filenames
254+
for filename, checksum in repo.files
203255
]
204256
seen_rows = [(repo,) for repo in seen]
205257
with db_connect() as db:
206-
query = 'INSERT OR REPLACE INTO data VALUES (?, ?, ?, ?, ?)'
258+
query = 'INSERT OR REPLACE INTO data VALUES (?, ?, ?, ?, ?, ?)'
207259
db.executemany(query, rows)
208260
db.executemany('INSERT OR REPLACE INTO seen VALUES (?)', seen_rows)
209261

templates/index.html

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<!doctype html>
2+
<html>
3+
<head>
4+
<title>all repos</title>
5+
<style>
6+
a {
7+
color: #000 !important;
8+
}
9+
.repo {
10+
border-bottom: 1px solid #000;
11+
border-left: 3px solid #000;
12+
padding: 6px;
13+
}
14+
.repo-done {
15+
border-left-color: #1e77d3;
16+
}
17+
.repo-ok {
18+
border-left-color: #1f913d;
19+
}
20+
.repo-bad {
21+
border-left-color: #a31c1c;
22+
}
23+
.repo-unknown {
24+
border-left-color: #edd118;
25+
}
26+
</style>
27+
</head>
28+
<body>
29+
<h1>repos</h1>
30+
{% for repo in repos %}
31+
<div class="repo {{repo.css_class(vulnerable, done)}}">
32+
<a href="{{url_for('repo', repo1=repo.repo1, repo2=repo.repo2)}}">{{repo.repo}}</a>
33+
★ {{repo.star_count}}
34+
</div>
35+
{% endfor %}
36+
</body>
37+
</html>

0 commit comments

Comments
 (0)