github-security-report 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- github_security_report/__init__.py +13 -0
- github_security_report/_version.py +24 -0
- github_security_report/classify.py +208 -0
- github_security_report/cli.py +448 -0
- github_security_report/client.py +493 -0
- github_security_report/collect.py +376 -0
- github_security_report/config.py +343 -0
- github_security_report/gitctx.py +66 -0
- github_security_report/models.py +172 -0
- github_security_report/posture.py +264 -0
- github_security_report/py.typed +1 -0
- github_security_report/render/__init__.py +3 -0
- github_security_report/render/html.py +142 -0
- github_security_report/render/markdown.py +172 -0
- github_security_report/render/slack.py +215 -0
- github_security_report/render/terminal.py +163 -0
- github_security_report/report.py +173 -0
- github_security_report/rulesets.py +137 -0
- github_security_report/runner.py +147 -0
- github_security_report/scope.py +97 -0
- github_security_report/severity.py +83 -0
- github_security_report/templates/index.html.j2 +57 -0
- github_security_report/templates/report.html.j2 +162 -0
- github_security_report-0.1.0.dist-info/METADATA +318 -0
- github_security_report-0.1.0.dist-info/RECORD +28 -0
- github_security_report-0.1.0.dist-info/WHEEL +4 -0
- github_security_report-0.1.0.dist-info/entry_points.txt +2 -0
- github_security_report-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 The Linux Foundation
|
|
3
|
+
"""Configuration: schema, loading, token resolution, and Slack-day gating.
|
|
4
|
+
|
|
5
|
+
The tool's configuration is JSON, supplied either as a CLI file, a plain
|
|
6
|
+
GitHub ``vars.`` entry, or base64 inside a ``secrets.`` entry (base64 only to
|
|
7
|
+
stop raw JSON braces tripping GitHub's log redaction -- it is encoding, not
|
|
8
|
+
encryption). Tokens are referenced by environment-variable name, never embedded
|
|
9
|
+
literally. See ``docs/BRIEF.md`` sections 8-9.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import binascii
|
|
16
|
+
import datetime as dt
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
from collections.abc import Mapping
|
|
21
|
+
from dataclasses import dataclass, field, replace
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from types import MappingProxyType
|
|
24
|
+
|
|
25
|
+
import jsonschema
|
|
26
|
+
|
|
27
|
+
log = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
WEEKDAYS = (
|
|
30
|
+
"monday",
|
|
31
|
+
"tuesday",
|
|
32
|
+
"wednesday",
|
|
33
|
+
"thursday",
|
|
34
|
+
"friday",
|
|
35
|
+
"saturday",
|
|
36
|
+
"sunday",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Heuristic to warn when a token value, rather than an env-var name, is given.
|
|
40
|
+
_TOKEN_PREFIXES = ("ghp_", "gho_", "ghu_", "ghs_", "ghr_", "github_pat_")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ConfigError(ValueError):
|
|
44
|
+
"""Raised when configuration is malformed or fails validation."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
CONFIG_SCHEMA: dict = {
|
|
48
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
49
|
+
"type": "object",
|
|
50
|
+
"additionalProperties": False,
|
|
51
|
+
"properties": {
|
|
52
|
+
"slack": {
|
|
53
|
+
"type": "object",
|
|
54
|
+
"additionalProperties": False,
|
|
55
|
+
"properties": {
|
|
56
|
+
"channel": {"type": "string"},
|
|
57
|
+
"report_day": {
|
|
58
|
+
"oneOf": [
|
|
59
|
+
{"type": "string"},
|
|
60
|
+
{"type": "array", "items": {"type": "string"}},
|
|
61
|
+
]
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
"report": {
|
|
66
|
+
"type": "object",
|
|
67
|
+
"additionalProperties": False,
|
|
68
|
+
"properties": {
|
|
69
|
+
"top_n": {"type": "integer", "minimum": 1},
|
|
70
|
+
"top_n_report": {"type": "integer", "minimum": 1},
|
|
71
|
+
"top_n_cli": {"type": "integer", "minimum": 1},
|
|
72
|
+
"top_n_slack": {"type": "integer", "minimum": 1},
|
|
73
|
+
"include_archived": {"type": "boolean"},
|
|
74
|
+
"include_test": {"type": "boolean"},
|
|
75
|
+
"release_min_age_days": {"type": "integer", "minimum": 0},
|
|
76
|
+
"ruleset_workflows": {
|
|
77
|
+
"type": "object",
|
|
78
|
+
"additionalProperties": {"type": "string"},
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
"organizations": {
|
|
83
|
+
"type": "array",
|
|
84
|
+
"minItems": 1,
|
|
85
|
+
"items": {
|
|
86
|
+
"type": "object",
|
|
87
|
+
"additionalProperties": False,
|
|
88
|
+
"required": ["name"],
|
|
89
|
+
"properties": {
|
|
90
|
+
"name": {"type": "string", "minLength": 1},
|
|
91
|
+
"token_env": {"type": "string"},
|
|
92
|
+
"exclude": {"type": "array", "items": {"type": "string"}},
|
|
93
|
+
"releases_exclude": {
|
|
94
|
+
"type": "array",
|
|
95
|
+
"items": {"type": "string"},
|
|
96
|
+
},
|
|
97
|
+
"slack": {"$ref": "#/properties/slack"},
|
|
98
|
+
"report": {"$ref": "#/properties/report"},
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
"required": ["organizations"],
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True)
|
|
108
|
+
class ReportDay:
|
|
109
|
+
"""When to post the Slack digest. Owned and evaluated by the tool."""
|
|
110
|
+
|
|
111
|
+
always: bool = False
|
|
112
|
+
never: bool = False
|
|
113
|
+
days: frozenset[str] = field(default_factory=frozenset)
|
|
114
|
+
|
|
115
|
+
def should_notify(
|
|
116
|
+
self, *, now: dt.date | None = None, force: bool = False
|
|
117
|
+
) -> bool:
|
|
118
|
+
if force or self.always:
|
|
119
|
+
return True
|
|
120
|
+
if self.never:
|
|
121
|
+
return False
|
|
122
|
+
today = (now or dt.date.today()).strftime("%A").lower()
|
|
123
|
+
return today in self.days
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True)
|
|
127
|
+
class SlackConfig:
|
|
128
|
+
channel: str = ""
|
|
129
|
+
report_day: ReportDay = field(default_factory=lambda: ReportDay(days=frozenset({"tuesday"})))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# Default mapping of signal value -> required-workflow path keyword. A repo
|
|
133
|
+
# covered by an active org ruleset whose required workflow path contains the
|
|
134
|
+
# keyword is treated as having that tool enabled (see :mod:`rulesets`).
|
|
135
|
+
DEFAULT_RULESET_WORKFLOWS = {"zizmor": "zizmor"}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(frozen=True)
|
|
139
|
+
class ReportConfig:
|
|
140
|
+
# Shared default number of offenders shown per signal; per-output overrides
|
|
141
|
+
# below take precedence when set. report = GitHub Pages (Markdown + HTML),
|
|
142
|
+
# cli = terminal, slack = the Slack digest.
|
|
143
|
+
top_n: int = 10
|
|
144
|
+
top_n_report: int | None = None
|
|
145
|
+
top_n_cli: int | None = None
|
|
146
|
+
top_n_slack: int | None = None
|
|
147
|
+
include_archived: bool = False
|
|
148
|
+
include_test: bool = False
|
|
149
|
+
# Repositories created within this many days are excluded from the
|
|
150
|
+
# Releases/Tagging requirement (0 = include all repositories).
|
|
151
|
+
release_min_age_days: int = 28
|
|
152
|
+
# Read-only mapping (frozen dataclasses do not deep-freeze a plain dict, so a
|
|
153
|
+
# MappingProxyType prevents in-place mutation of a shared config).
|
|
154
|
+
ruleset_workflows: Mapping[str, str] = field(
|
|
155
|
+
default_factory=lambda: MappingProxyType(dict(DEFAULT_RULESET_WORKFLOWS))
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def report_top_n(self) -> int:
|
|
160
|
+
"""Offenders shown per signal in the GitHub Pages output."""
|
|
161
|
+
return self.top_n_report if self.top_n_report is not None else self.top_n
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def cli_top_n(self) -> int:
|
|
165
|
+
"""Offenders shown per signal in the terminal output."""
|
|
166
|
+
return self.top_n_cli if self.top_n_cli is not None else self.top_n
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def slack_top_n(self) -> int:
|
|
170
|
+
"""Offenders shown per signal in the Slack digest."""
|
|
171
|
+
return self.top_n_slack if self.top_n_slack is not None else self.top_n
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass(frozen=True)
|
|
175
|
+
class OrgConfig:
|
|
176
|
+
name: str
|
|
177
|
+
token_env: str = "GITHUB_TOKEN"
|
|
178
|
+
exclude: tuple[str, ...] = ()
|
|
179
|
+
# Repositories excluded from the Releases/Tagging table only (e.g. repos
|
|
180
|
+
# that are never released/consumed externally).
|
|
181
|
+
releases_exclude: tuple[str, ...] = ()
|
|
182
|
+
slack: SlackConfig = field(default_factory=SlackConfig)
|
|
183
|
+
report: ReportConfig = field(default_factory=ReportConfig)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@dataclass(frozen=True)
|
|
187
|
+
class Config:
|
|
188
|
+
organizations: tuple[OrgConfig, ...]
|
|
189
|
+
slack: SlackConfig = field(default_factory=SlackConfig)
|
|
190
|
+
report: ReportConfig = field(default_factory=ReportConfig)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def parse_report_day(value: str | list[str] | None) -> ReportDay:
|
|
194
|
+
"""Parse ``report_day`` into a :class:`ReportDay`.
|
|
195
|
+
|
|
196
|
+
Accepts a single weekday, a list of weekdays, ``"never"`` or ``"always"``
|
|
197
|
+
(case-insensitive). Defaults to Tuesday when unset.
|
|
198
|
+
"""
|
|
199
|
+
if value is None:
|
|
200
|
+
return ReportDay(days=frozenset({"tuesday"}))
|
|
201
|
+
items = [value] if isinstance(value, str) else list(value)
|
|
202
|
+
normalised = [item.strip().lower() for item in items if item.strip()]
|
|
203
|
+
if normalised == ["always"]:
|
|
204
|
+
return ReportDay(always=True)
|
|
205
|
+
if normalised == ["never"]:
|
|
206
|
+
return ReportDay(never=True)
|
|
207
|
+
for day in normalised:
|
|
208
|
+
if day in {"always", "never"}:
|
|
209
|
+
raise ConfigError(
|
|
210
|
+
f"'{day}' cannot be combined with weekdays in report_day"
|
|
211
|
+
)
|
|
212
|
+
if day not in WEEKDAYS:
|
|
213
|
+
raise ConfigError(f"invalid report_day value: {day!r}")
|
|
214
|
+
if not normalised:
|
|
215
|
+
return ReportDay(days=frozenset({"tuesday"}))
|
|
216
|
+
return ReportDay(days=frozenset(normalised))
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _slack_from(data: dict, base: SlackConfig) -> SlackConfig:
|
|
220
|
+
return SlackConfig(
|
|
221
|
+
channel=data.get("channel", base.channel),
|
|
222
|
+
report_day=(
|
|
223
|
+
parse_report_day(data["report_day"])
|
|
224
|
+
if "report_day" in data
|
|
225
|
+
else base.report_day
|
|
226
|
+
),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _report_from(data: dict, base: ReportConfig) -> ReportConfig:
|
|
231
|
+
result = replace(
|
|
232
|
+
base,
|
|
233
|
+
**{
|
|
234
|
+
k: v
|
|
235
|
+
for k, v in data.items()
|
|
236
|
+
if k in {
|
|
237
|
+
"top_n",
|
|
238
|
+
"top_n_report",
|
|
239
|
+
"top_n_cli",
|
|
240
|
+
"top_n_slack",
|
|
241
|
+
"include_archived",
|
|
242
|
+
"include_test",
|
|
243
|
+
"release_min_age_days",
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
)
|
|
247
|
+
if "ruleset_workflows" in data:
|
|
248
|
+
# Merge so the built-in defaults (e.g. zizmor) survive unless overridden.
|
|
249
|
+
merged = {**base.ruleset_workflows, **data["ruleset_workflows"]}
|
|
250
|
+
result = replace(result, ruleset_workflows=MappingProxyType(merged))
|
|
251
|
+
return result
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def build_config(data: dict) -> Config:
|
|
255
|
+
"""Validate a config mapping and build the typed :class:`Config`."""
|
|
256
|
+
try:
|
|
257
|
+
jsonschema.validate(data, CONFIG_SCHEMA)
|
|
258
|
+
except jsonschema.ValidationError as exc:
|
|
259
|
+
raise ConfigError(f"configuration is invalid: {exc.message}") from exc
|
|
260
|
+
|
|
261
|
+
global_slack = _slack_from(data.get("slack", {}), SlackConfig())
|
|
262
|
+
global_report = _report_from(data.get("report", {}), ReportConfig())
|
|
263
|
+
|
|
264
|
+
orgs: list[OrgConfig] = []
|
|
265
|
+
for raw in data["organizations"]:
|
|
266
|
+
token_env = raw.get("token_env", "GITHUB_TOKEN")
|
|
267
|
+
if token_env.startswith(_TOKEN_PREFIXES):
|
|
268
|
+
log.warning(
|
|
269
|
+
"organization %r token_env looks like a literal token; it must "
|
|
270
|
+
"be an environment-variable NAME, not a token value",
|
|
271
|
+
raw["name"],
|
|
272
|
+
)
|
|
273
|
+
orgs.append(
|
|
274
|
+
OrgConfig(
|
|
275
|
+
name=raw["name"],
|
|
276
|
+
token_env=token_env,
|
|
277
|
+
exclude=tuple(raw.get("exclude", ())),
|
|
278
|
+
releases_exclude=tuple(raw.get("releases_exclude", ())),
|
|
279
|
+
slack=_slack_from(raw.get("slack", {}), global_slack),
|
|
280
|
+
report=_report_from(raw.get("report", {}), global_report),
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
return Config(
|
|
284
|
+
organizations=tuple(orgs), slack=global_slack, report=global_report
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def loads(raw: str) -> Config:
|
|
289
|
+
"""Load config from a string that is either raw JSON or base64-of-JSON.
|
|
290
|
+
|
|
291
|
+
Tries JSON first; if that fails, tries base64-decoding then JSON. This lets
|
|
292
|
+
the same loader read a plain ``vars.`` entry or a base64 ``secrets.`` entry
|
|
293
|
+
without the caller knowing which it is.
|
|
294
|
+
"""
|
|
295
|
+
text = raw.strip()
|
|
296
|
+
try:
|
|
297
|
+
data = json.loads(text)
|
|
298
|
+
except json.JSONDecodeError:
|
|
299
|
+
try:
|
|
300
|
+
decoded = base64.b64decode(text, validate=True).decode("utf-8")
|
|
301
|
+
data = json.loads(decoded)
|
|
302
|
+
except (binascii.Error, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
303
|
+
raise ConfigError(
|
|
304
|
+
"configuration is neither valid JSON nor base64-encoded JSON"
|
|
305
|
+
) from exc
|
|
306
|
+
if not isinstance(data, dict):
|
|
307
|
+
raise ConfigError("configuration must be a JSON object")
|
|
308
|
+
return build_config(data)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def load_file(path: str) -> Config:
|
|
312
|
+
with open(path, encoding="utf-8") as handle:
|
|
313
|
+
return loads(handle.read())
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# Conventional per-user config location, so a local run with no flags picks up
|
|
317
|
+
# a central config instead of erroring. Honours $XDG_CONFIG_HOME, falling back
|
|
318
|
+
# to ~/.config (the XDG Base Directory default).
|
|
319
|
+
DEFAULT_CONFIG_DIR = "github-security-report"
|
|
320
|
+
DEFAULT_CONFIG_FILE = "config.json"
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def default_config_path() -> Path:
|
|
324
|
+
"""The conventional per-user config path (whether or not it exists).
|
|
325
|
+
|
|
326
|
+
``$XDG_CONFIG_HOME/github-security-report/config.json`` when the variable is
|
|
327
|
+
set, otherwise ``~/.config/github-security-report/config.json``.
|
|
328
|
+
"""
|
|
329
|
+
base = os.environ.get("XDG_CONFIG_HOME", "").strip() or str(Path.home() / ".config")
|
|
330
|
+
return Path(base) / DEFAULT_CONFIG_DIR / DEFAULT_CONFIG_FILE
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def find_default_config() -> Path | None:
|
|
334
|
+
"""The per-user config path if a readable file exists there, else None."""
|
|
335
|
+
path = default_config_path()
|
|
336
|
+
return path if path.is_file() else None
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def resolve_token(org: OrgConfig, env: dict[str, str] | None = None) -> str | None:
|
|
340
|
+
"""Resolve an organisation's token from the environment by name."""
|
|
341
|
+
environ = env if env is not None else os.environ
|
|
342
|
+
token = environ.get(org.token_env, "").strip()
|
|
343
|
+
return token or None
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 The Linux Foundation
|
|
3
|
+
"""Git context detection for local repo-mode.
|
|
4
|
+
|
|
5
|
+
When run inside a Git checkout with no org config, the tool defaults to repo
|
|
6
|
+
mode for the repository the checkout points at. The remote is resolved in
|
|
7
|
+
preference order ``upstream`` then ``origin``, and only ``github.com`` remotes
|
|
8
|
+
qualify. See ``docs/BRIEF.md`` section 10.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import re
|
|
15
|
+
import subprocess
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
REMOTE_PREFERENCE = ("upstream", "origin")
|
|
21
|
+
|
|
22
|
+
# git@github.com:owner/repo(.git) or https://github.com/owner/repo(.git)
|
|
23
|
+
_SSH = re.compile(r"^git@github\.com:(?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?$")
|
|
24
|
+
_HTTPS = re.compile(
|
|
25
|
+
r"^https://github\.com/(?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?/?$"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parse_remote_url(url: str) -> tuple[str, str] | None:
|
|
30
|
+
"""Parse a github.com remote URL into ``(owner, repo)``; None otherwise."""
|
|
31
|
+
url = url.strip()
|
|
32
|
+
match = _SSH.match(url) or _HTTPS.match(url)
|
|
33
|
+
if not match:
|
|
34
|
+
return None
|
|
35
|
+
return match.group("owner"), match.group("repo")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _git_remote_url(name: str) -> str | None:
|
|
39
|
+
try:
|
|
40
|
+
result = subprocess.run(
|
|
41
|
+
["git", "remote", "get-url", name],
|
|
42
|
+
capture_output=True,
|
|
43
|
+
text=True,
|
|
44
|
+
check=False,
|
|
45
|
+
)
|
|
46
|
+
except (OSError, FileNotFoundError): # git not installed / not a repo
|
|
47
|
+
return None
|
|
48
|
+
if result.returncode != 0:
|
|
49
|
+
return None
|
|
50
|
+
return result.stdout.strip() or None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def detect_repo(
|
|
54
|
+
remote_reader: Callable[[str], str | None] = _git_remote_url,
|
|
55
|
+
) -> tuple[str, str] | None:
|
|
56
|
+
"""Resolve ``(owner, repo)`` from git remotes (upstream, then origin)."""
|
|
57
|
+
for name in REMOTE_PREFERENCE:
|
|
58
|
+
url = remote_reader(name)
|
|
59
|
+
if not url:
|
|
60
|
+
continue
|
|
61
|
+
parsed = parse_remote_url(url)
|
|
62
|
+
if parsed:
|
|
63
|
+
log.info("detected %s/%s from the %s remote", parsed[0], parsed[1], name)
|
|
64
|
+
return parsed
|
|
65
|
+
log.debug("remote %s (%s) is not a github.com remote", name, url)
|
|
66
|
+
return None
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 The Linux Foundation
|
|
3
|
+
"""Domain models for the security report.
|
|
4
|
+
|
|
5
|
+
Encodes the Phase 0 design (see ``docs/BRIEF.md`` and
|
|
6
|
+
``docs/phase0-findings.md``): the five v1 signals, the four-state per-report
|
|
7
|
+
classification, severity counts with hierarchical worst-first ordering, and the
|
|
8
|
+
ranking rules (alert tables sort by severity descending; Scorecard by score
|
|
9
|
+
ascending).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import datetime as dt
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from enum import Enum
|
|
17
|
+
|
|
18
|
+
from github_security_report.severity import Severity
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SignalType(str, Enum):
|
|
22
|
+
"""The five v1 ranked signals."""
|
|
23
|
+
|
|
24
|
+
CODEQL = "codeql"
|
|
25
|
+
SCORECARD = "scorecard"
|
|
26
|
+
ZIZMOR = "zizmor"
|
|
27
|
+
DEPENDABOT = "dependabot"
|
|
28
|
+
SECRET_SCANNING = "secret_scanning"
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def heading(self) -> str:
|
|
32
|
+
return {
|
|
33
|
+
SignalType.CODEQL: "CodeQL",
|
|
34
|
+
SignalType.SCORECARD: "OpenSSF Scorecard",
|
|
35
|
+
SignalType.ZIZMOR: "Zizmor Static Analysis",
|
|
36
|
+
SignalType.DEPENDABOT: "Dependabot: Security Alerts",
|
|
37
|
+
SignalType.SECRET_SCANNING: "Secret scanning",
|
|
38
|
+
}[self]
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def uses_severity_columns(self) -> bool:
|
|
42
|
+
"""Secret scanning is a flat open-count; the rest use severity columns.
|
|
43
|
+
|
|
44
|
+
Scorecard's primary metric is its aggregate score, but its
|
|
45
|
+
code-scanning findings still carry severities, so it keeps the columns.
|
|
46
|
+
"""
|
|
47
|
+
return self is not SignalType.SECRET_SCANNING
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def sort_ascending(self) -> bool:
|
|
51
|
+
"""Scorecard ranks by score ascending (lower == worse); others descend."""
|
|
52
|
+
return self is SignalType.SCORECARD
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class RepoState(str, Enum):
|
|
56
|
+
"""The four-state per-report classification (BRIEF section 6)."""
|
|
57
|
+
|
|
58
|
+
OFFENDER = "offender" # enabled + has open findings -> table row
|
|
59
|
+
CLEAN = "clean" # enabled + zero findings -> counted beneath table
|
|
60
|
+
NAG = "nag" # supported but not enabled -> bulleted nag list
|
|
61
|
+
UNKNOWN = "unknown" # indeterminate (403 / insufficient permission)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class Repo:
|
|
66
|
+
"""Minimal repository identity carried through the report."""
|
|
67
|
+
|
|
68
|
+
name: str
|
|
69
|
+
full_name: str
|
|
70
|
+
html_url: str
|
|
71
|
+
archived: bool = False
|
|
72
|
+
fork: bool = False
|
|
73
|
+
is_template: bool = False
|
|
74
|
+
private: bool = False
|
|
75
|
+
default_branch: str = "main"
|
|
76
|
+
# Repository creation time (UTC); used to exclude freshly-created repos from
|
|
77
|
+
# the release/tag age requirement. None when the API did not provide it.
|
|
78
|
+
created_at: dt.datetime | None = None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class SeverityCounts:
|
|
83
|
+
"""Open-finding counts by severity, with worst-first ordering."""
|
|
84
|
+
|
|
85
|
+
critical: int = 0
|
|
86
|
+
high: int = 0
|
|
87
|
+
medium: int = 0
|
|
88
|
+
low: int = 0
|
|
89
|
+
|
|
90
|
+
def add(self, severity: Severity, count: int = 1) -> None:
|
|
91
|
+
if severity is Severity.CRITICAL:
|
|
92
|
+
self.critical += count
|
|
93
|
+
elif severity is Severity.HIGH:
|
|
94
|
+
self.high += count
|
|
95
|
+
elif severity is Severity.MEDIUM:
|
|
96
|
+
self.medium += count
|
|
97
|
+
else:
|
|
98
|
+
self.low += count
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def total(self) -> int:
|
|
102
|
+
return self.critical + self.high + self.medium + self.low
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def weighted(self) -> int:
|
|
106
|
+
"""Severity-weighted score, so 1 critical outranks many low findings."""
|
|
107
|
+
return (
|
|
108
|
+
self.critical * 1000
|
|
109
|
+
+ self.high * 100
|
|
110
|
+
+ self.medium * 10
|
|
111
|
+
+ self.low
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def sort_key(self) -> tuple[int, int, int, int]:
|
|
116
|
+
"""Hierarchical key: critical, then high, then medium, then low.
|
|
117
|
+
|
|
118
|
+
Use with ``reverse=True`` for worst-first ordering.
|
|
119
|
+
"""
|
|
120
|
+
return (self.critical, self.high, self.medium, self.low)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass
|
|
124
|
+
class RepoSignal:
|
|
125
|
+
"""One repository's result for one signal."""
|
|
126
|
+
|
|
127
|
+
repo: Repo
|
|
128
|
+
signal: SignalType
|
|
129
|
+
state: RepoState
|
|
130
|
+
counts: SeverityCounts = field(default_factory=SeverityCounts)
|
|
131
|
+
score: float | None = None # Scorecard aggregate (0-10), lower == worse
|
|
132
|
+
detail: str = "" # short human note (e.g. "secret scanning disabled")
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def is_offender(self) -> bool:
|
|
136
|
+
return self.state is RepoState.OFFENDER
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def rank_offenders(signals: list[RepoSignal]) -> list[RepoSignal]:
|
|
140
|
+
"""Sort offenders worst-first for a single signal.
|
|
141
|
+
|
|
142
|
+
Alert-based signals sort by the hierarchical severity key descending, with
|
|
143
|
+
total as a tiebreaker. Scorecard sorts by aggregate score ascending (lower
|
|
144
|
+
== worse). Repo name breaks remaining ties, ascending.
|
|
145
|
+
|
|
146
|
+
Numeric components are negated so the whole sort runs ascending (no
|
|
147
|
+
``reverse=True``); that keeps the name tiebreaker correctly ascending even
|
|
148
|
+
when one name is a prefix of another.
|
|
149
|
+
"""
|
|
150
|
+
offenders = [s for s in signals if s.is_offender]
|
|
151
|
+
if not offenders:
|
|
152
|
+
return []
|
|
153
|
+
signal = offenders[0].signal
|
|
154
|
+
if signal.sort_ascending:
|
|
155
|
+
return sorted(
|
|
156
|
+
offenders,
|
|
157
|
+
key=lambda s: (
|
|
158
|
+
s.score if s.score is not None else float("inf"),
|
|
159
|
+
s.repo.name,
|
|
160
|
+
),
|
|
161
|
+
)
|
|
162
|
+
return sorted(
|
|
163
|
+
offenders,
|
|
164
|
+
key=lambda s: (
|
|
165
|
+
-s.counts.critical,
|
|
166
|
+
-s.counts.high,
|
|
167
|
+
-s.counts.medium,
|
|
168
|
+
-s.counts.low,
|
|
169
|
+
-s.counts.total,
|
|
170
|
+
s.repo.name,
|
|
171
|
+
),
|
|
172
|
+
)
|