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.
@@ -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
+ )