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,448 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 The Linux Foundation
3
+ """Command-line entry point.
4
+
5
+ Wires configuration, scope/mode resolution, collection, and rendering into the
6
+ ``github-security-report`` command. Org mode produces Pages/Slack/terminal
7
+ output; repo mode is a degraded PR gate emitting a job summary and outputs.
8
+ See ``docs/BRIEF.md`` sections 9-12.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import datetime as dt
15
+ import json
16
+ import logging
17
+ import os
18
+ import re
19
+ import sys
20
+ from collections.abc import Mapping
21
+ from dataclasses import replace
22
+ from pathlib import Path
23
+
24
+ import typer
25
+ from rich.console import Console
26
+
27
+ from github_security_report import __version__, collect, config, gitctx, runner
28
+ from github_security_report.client import GitHubClient
29
+ from github_security_report.config import Config, OrgConfig
30
+ from github_security_report.models import RepoSignal
31
+ from github_security_report.render import html as html_render
32
+ from github_security_report.render import markdown as md_render
33
+ from github_security_report.render import slack as slack_render
34
+ from github_security_report.render import terminal as term_render
35
+ from github_security_report.report import OrgReport, TableSection, build_org_report
36
+
37
+ app = typer.Typer(
38
+ name="github-security-report",
39
+ help="Security and quality reporting across GitHub organisations.",
40
+ no_args_is_help=True,
41
+ add_completion=False,
42
+ )
43
+
44
+
45
+ def _version_callback(value: bool) -> None:
46
+ if value:
47
+ # Match the dependamerge style: a label emoji plus a Rich-highlighted
48
+ # version number (Rich colourises the numeric version automatically).
49
+ Console().print(f"🏷️ github-security-report version {__version__}")
50
+ raise typer.Exit()
51
+
52
+
53
+ @app.callback()
54
+ def main(
55
+ _version: bool = typer.Option(
56
+ False, "--version", callback=_version_callback, is_eager=True,
57
+ help="Show the version and exit.",
58
+ ),
59
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose logging."),
60
+ ) -> None:
61
+ """Security and quality reporting across GitHub organisations."""
62
+ logging.basicConfig(
63
+ level=logging.INFO if verbose else logging.WARNING,
64
+ format="%(levelname)s %(name)s: %(message)s",
65
+ )
66
+
67
+
68
+ # --------------------------------------------------------------------------- #
69
+ # JSON serialisation
70
+ # --------------------------------------------------------------------------- #
71
+ def _table_to_dict(section: TableSection) -> dict:
72
+ """Serialise a generic posture/freshness table for JSON consumers."""
73
+ return {
74
+ "title": section.title,
75
+ "columns": list(section.columns),
76
+ "rows": [
77
+ {
78
+ "repo": row.repo.full_name,
79
+ "url": row.repo.html_url,
80
+ "cells": list(row.cells),
81
+ }
82
+ for row in section.rows
83
+ ],
84
+ "empty_note": section.empty_note,
85
+ "note": section.note,
86
+ }
87
+
88
+
89
+ def _org_to_dict(org: OrgReport) -> dict:
90
+ return {
91
+ "org": org.org,
92
+ "repo_count": org.repo_count,
93
+ "generated_at": org.generated_at.isoformat(),
94
+ # Surfaced so JSON consumers can distinguish a complete result from a
95
+ # partial one (the repository listing could not be fully read).
96
+ "partial": org.partial,
97
+ # Repositories explicitly excluded from analysis (per-org exclude list).
98
+ "excluded": [r.full_name for r in org.excluded_repos],
99
+ "sections": [
100
+ {
101
+ "signal": s.signal.value,
102
+ "offenders": [
103
+ {
104
+ "repo": rs.repo.full_name,
105
+ "url": rs.repo.html_url,
106
+ "counts": {
107
+ "critical": rs.counts.critical,
108
+ "high": rs.counts.high,
109
+ "medium": rs.counts.medium,
110
+ "low": rs.counts.low,
111
+ "total": rs.counts.total,
112
+ },
113
+ "score": rs.score,
114
+ }
115
+ for rs in s.offenders
116
+ ],
117
+ "clean_count": s.clean_count,
118
+ "nag": [r.full_name for r in s.nag_repos],
119
+ "unknown_count": s.unknown_count,
120
+ }
121
+ for s in org.sections
122
+ ],
123
+ # Extra reporting categories outside the four-state per-signal model.
124
+ "dependabot_tables": [_table_to_dict(t) for t in org.dependabot_tables],
125
+ "releases": _table_to_dict(org.releases) if org.releases else None,
126
+ }
127
+
128
+
129
+ # --------------------------------------------------------------------------- #
130
+ # Output writers
131
+ # --------------------------------------------------------------------------- #
132
+ # Keep filenames within output_dir: a channel value containing "/" or ".."
133
+ # (misconfiguration or hostile input) must not escape the directory.
134
+ _UNSAFE_COMPONENT = re.compile(r"[^A-Za-z0-9_.-]+")
135
+
136
+
137
+ def _safe_component(value: str) -> str:
138
+ """Sanitise a string for safe use as a single path component."""
139
+ safe = _UNSAFE_COMPONENT.sub("-", value).strip("-.")
140
+ return safe or "channel"
141
+
142
+
143
+ def _write_org_files(
144
+ org: OrgReport, output_dir: Path, *, top_n: int | None = None
145
+ ) -> None:
146
+ slug = html_render.slugify(org.org)
147
+ org_dir = output_dir / slug
148
+ org_dir.mkdir(parents=True, exist_ok=True)
149
+ (org_dir / "report.md").write_text(
150
+ md_render.render_org(org, top_n=top_n), encoding="utf-8"
151
+ )
152
+ (org_dir / "report.html").write_text(
153
+ html_render.render_org_html(org, top_n=top_n), encoding="utf-8"
154
+ )
155
+ (org_dir / "report.json").write_text(
156
+ json.dumps(_org_to_dict(org), indent=2) + "\n", encoding="utf-8"
157
+ )
158
+
159
+
160
+ # --------------------------------------------------------------------------- #
161
+ # Config resolution
162
+ # --------------------------------------------------------------------------- #
163
+ def _load_config(
164
+ config_file: str | None,
165
+ config_data: str | None,
166
+ org: str | None,
167
+ token_env: str = "GITHUB_TOKEN",
168
+ *,
169
+ console: Console | None = None,
170
+ ) -> Config | None:
171
+ if config_file:
172
+ return config.load_file(config_file)
173
+ if config_data:
174
+ return config.loads(config_data)
175
+ if org:
176
+ # Honour the selected token env var so --org works with non-default
177
+ # token environment variable names (e.g. a classic PAT secret).
178
+ return Config(organizations=(OrgConfig(name=org, token_env=token_env),))
179
+ # No explicit configuration: fall back to the per-user config file if one
180
+ # exists, so a local run with no flags works instead of erroring.
181
+ default_path = config.find_default_config()
182
+ if default_path is not None:
183
+ if console is not None:
184
+ console.print(f"[dim]Using config: {default_path}[/dim]")
185
+ return config.load_file(str(default_path))
186
+ return None
187
+
188
+
189
+ # --------------------------------------------------------------------------- #
190
+ # Modes
191
+ # --------------------------------------------------------------------------- #
192
+ async def _run_org(cfg: Config, *, console: Console, output_dir: Path | None,
193
+ pages_url: str | None, top_n: int | None, force_notify: bool,
194
+ slack_channel: str | None = None,
195
+ release_min_age_days: int | None = None,
196
+ releases_exclude: tuple[str, ...] | None = None,
197
+ top_n_report: int | None = None,
198
+ top_n_cli: int | None = None,
199
+ top_n_slack: int | None = None) -> int:
200
+ now = dt.datetime.now(dt.timezone.utc)
201
+ pairs: list[tuple[OrgConfig, OrgReport]] = []
202
+ for org_cfg in cfg.organizations:
203
+ token = config.resolve_token(org_cfg)
204
+ if not token:
205
+ console.print(f"[red]No token in ${org_cfg.token_env} for {org_cfg.name}[/red]")
206
+ return 2
207
+ # CLI overrides win over config for the Releases/Tagging controls.
208
+ report_cfg = org_cfg.report
209
+ if release_min_age_days is not None:
210
+ report_cfg = replace(report_cfg, release_min_age_days=release_min_age_days)
211
+ effective_cfg = org_cfg
212
+ if releases_exclude is not None:
213
+ effective_cfg = replace(org_cfg, releases_exclude=releases_exclude)
214
+ async with GitHubClient(token) as client:
215
+ pairs.append(
216
+ (org_cfg, await collect.collect_org(client, effective_cfg, report_cfg, generated_at=now))
217
+ )
218
+ org_reports = [report for _, report in pairs]
219
+
220
+ # Per-output offender limit: a category-specific CLI override wins, then the
221
+ # shared --top-n override, then the org's configured value for that output.
222
+ def _limit(org_cfg: OrgConfig, override: int | None, attr: str) -> int:
223
+ if override is not None:
224
+ return override
225
+ if top_n is not None:
226
+ return top_n
227
+ return int(getattr(org_cfg.report, attr))
228
+
229
+ for org_cfg, org_report in pairs:
230
+ term_render.render_org(
231
+ org_report, console, top_n=_limit(org_cfg, top_n_cli, "cli_top_n")
232
+ )
233
+
234
+ if output_dir:
235
+ for org_cfg, org_report in pairs:
236
+ _write_org_files(
237
+ org_report,
238
+ output_dir,
239
+ top_n=_limit(org_cfg, top_n_report, "report_top_n"),
240
+ )
241
+ (output_dir / "index.html").write_text(
242
+ html_render.render_index_html(org_reports), encoding="utf-8"
243
+ )
244
+ (output_dir / ".nojekyll").write_text("", encoding="utf-8")
245
+ console.print(f"[green]Wrote reports to {output_dir}[/green]")
246
+
247
+ # Slack: an org notifies on its own report_day (so should_notify reflects
248
+ # the schedule, independent of channel availability). The channel comes
249
+ # from the --slack-channel override (e.g. the SLACK_CHANNEL_ID variable)
250
+ # when given, otherwise the per-org config channel; notifying orgs are
251
+ # grouped by channel so each distinct channel gets one digest.
252
+ notifying = [
253
+ (org_cfg, org_report)
254
+ for org_cfg, org_report in pairs
255
+ if org_cfg.slack.report_day.should_notify(now=now.date(), force=force_notify)
256
+ ]
257
+ outputs = {
258
+ "should_notify": "true" if notifying else "false",
259
+ "failed": "false",
260
+ # Always declared so the action output is stable even when no digest is
261
+ # produced (no notifying org or no configured channel).
262
+ "slack_payload": "",
263
+ }
264
+
265
+ by_channel: dict[str, list[tuple[OrgConfig, OrgReport]]] = {}
266
+ for org_cfg, org_report in notifying:
267
+ channel = slack_channel or org_cfg.slack.channel
268
+ if not channel:
269
+ continue
270
+ by_channel.setdefault(channel, []).append((org_cfg, org_report))
271
+
272
+ # The Slack digest uses each org's slack offender limit (category override >
273
+ # shared --top-n > config slack_top_n). Orgs sharing a channel render into
274
+ # one payload, so take the most generous configured value for that channel.
275
+ payloads = [
276
+ slack_render.render_payload(
277
+ [report for _, report in items],
278
+ channel=channel,
279
+ top_n=max(
280
+ _limit(oc, top_n_slack, "slack_top_n") for oc, _ in items
281
+ ),
282
+ pages_url=pages_url,
283
+ )
284
+ for channel, items in by_channel.items()
285
+ ]
286
+ if payloads:
287
+ # The single action output carries the first channel's payload (the
288
+ # common single-channel case); every payload is also written to disk.
289
+ outputs["slack_payload"] = json.dumps(payloads[0])
290
+ if output_dir:
291
+ for payload in payloads:
292
+ dest = output_dir / f"slack-payload-{_safe_component(payload['channel'])}.json"
293
+ dest.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
294
+ runner.write_github_output(outputs)
295
+ # The job summary mirrors the GitHub Pages Markdown, so it uses the report
296
+ # offender limit per org.
297
+ summary = (
298
+ "\n\n".join(
299
+ md_render.render_org(
300
+ org_report, top_n=_limit(org_cfg, top_n_report, "report_top_n")
301
+ )
302
+ for org_cfg, org_report in pairs
303
+ ).rstrip()
304
+ + "\n"
305
+ )
306
+ runner.append_step_summary(summary)
307
+ return 0
308
+
309
+
310
+ async def _run_repo(owner: str, repo_name: str, *, token_env: str, console: Console,
311
+ fail_threshold: str,
312
+ ruleset_workflows: Mapping[str, str] | None = None) -> int:
313
+ token = os.environ.get(token_env, "").strip()
314
+ if not token:
315
+ console.print(f"[red]No token in ${token_env}[/red]")
316
+ return 2
317
+ async with GitHubClient(token) as client:
318
+ repo, signals = await collect.collect_repo(
319
+ client, owner, repo_name, ruleset_workflows=ruleset_workflows
320
+ )
321
+ if repo is None:
322
+ return 2
323
+
324
+ now = dt.datetime.now(dt.timezone.utc)
325
+ org = build_org_report(f"{owner}/{repo_name}", signals, repo_count=1, generated_at=now)
326
+ term_render.render_org(org, console)
327
+
328
+ runner.append_step_summary(md_render.render_org(org))
329
+ outputs = _repo_outputs(signals, fail_threshold)
330
+ # Keep the action's declared outputs stable across modes.
331
+ outputs["should_notify"] = "false"
332
+ outputs["slack_payload"] = ""
333
+ runner.write_github_output(outputs)
334
+
335
+ if runner.should_fail(signals, fail_threshold):
336
+ console.print(f"[red]Failing: findings at or above '{fail_threshold}'[/red]")
337
+ return 1
338
+ return 0
339
+
340
+
341
+ def _repo_outputs(signals: list[RepoSignal], fail_threshold: str) -> dict[str, str]:
342
+ outputs = {s.signal.value + "_open": str(s.counts.total) for s in signals}
343
+ outputs["failed"] = "true" if runner.should_fail(signals, fail_threshold) else "false"
344
+ return outputs
345
+
346
+
347
+ # --------------------------------------------------------------------------- #
348
+ # Command
349
+ # --------------------------------------------------------------------------- #
350
+ @app.command()
351
+ def report(
352
+ config_file: str | None = typer.Option(None, "--config", "-c", help="Path to a JSON config file."),
353
+ config_data: str | None = typer.Option(None, "--config-data", help="Raw or base64 JSON config (vars/secrets)."),
354
+ org: str | None = typer.Option(None, "--org", help="Single organisation (shorthand for org mode)."),
355
+ scope: str = typer.Option("auto", "--scope", help="auto | org | repo."),
356
+ repo: str | None = typer.Option(None, "--repo", help="owner/name for repo mode (else git-detected)."),
357
+ token_env: str = typer.Option("GITHUB_TOKEN", "--token-env", help="Env var holding the repo-mode token."),
358
+ output_dir: str | None = typer.Option(None, "--output-dir", "-o", help="Directory for Pages output (org mode)."),
359
+ pages_url: str | None = typer.Option(None, "--pages-url", help="GitHub Pages URL for the Slack link."),
360
+ slack_channel: str | None = typer.Option(None, "--slack-channel", help="Slack channel ID; overrides config slack.channel (e.g. SLACK_CHANNEL_ID)."),
361
+ top_n: int | None = typer.Option(None, "--top-n", help="Offenders shown per signal across all outputs (default: config, else 10). Overridden per output by the flags below."),
362
+ top_n_report: int | None = typer.Option(None, "--top-n-report", help="Offenders per signal in the GitHub Pages output (overrides --top-n)."),
363
+ top_n_cli: int | None = typer.Option(None, "--top-n-cli", help="Offenders per signal in the terminal output (overrides --top-n)."),
364
+ top_n_slack: int | None = typer.Option(None, "--top-n-slack", help="Offenders per signal in the Slack digest (overrides --top-n)."),
365
+ fail_threshold: str = typer.Option("none", "--fail-threshold", help="none|low|medium|high|critical|any (repo mode)."),
366
+ force_notify: bool = typer.Option(False, "--force-notify", help="Post to Slack regardless of report_day."),
367
+ release_min_age_days: int | None = typer.Option(None, "--release-min-age-days", help="Exclude repos created within N days from Releases/Tagging (0 = include all; default: config, else 28)."),
368
+ releases_exclude: list[str] | None = typer.Option(None, "--releases-exclude", help="Repository name to omit from the Releases/Tagging table (repeatable; overrides config)."),
369
+ no_color: bool = typer.Option(False, "--no-color", help="Disable coloured output."),
370
+ ) -> None:
371
+ """Generate a security and quality report."""
372
+ plain = no_color or bool(os.environ.get("CI")) or not sys.stdout.isatty()
373
+ console = Console(no_color=plain, highlight=False)
374
+
375
+ # Match the config schema (top_n minimum is 1): reject a non-positive
376
+ # override at the boundary rather than rendering an empty/odd digest.
377
+ for name, value in (
378
+ ("--top-n", top_n),
379
+ ("--top-n-report", top_n_report),
380
+ ("--top-n-cli", top_n_cli),
381
+ ("--top-n-slack", top_n_slack),
382
+ ):
383
+ if value is not None and value < 1:
384
+ console.print(f"[red]{name} must be 1 or greater[/red]")
385
+ raise typer.Exit(2)
386
+
387
+ # Match the config schema (release_min_age_days minimum is 0): reject a
388
+ # negative override at the boundary.
389
+ if release_min_age_days is not None and release_min_age_days < 0:
390
+ console.print("[red]--release-min-age-days must be 0 or greater[/red]")
391
+ raise typer.Exit(2)
392
+
393
+ cfg = _load_config(config_file, config_data, org, token_env, console=console)
394
+ detected: tuple[str, str] | None = None
395
+ if repo:
396
+ # An explicit --repo must be exactly 'owner/name' (one slash, both
397
+ # parts non-empty). A malformed value would otherwise be split
398
+ # incorrectly or fall back to git detection, risking a report against
399
+ # an unintended repository.
400
+ if not re.fullmatch(r"[^/]+/[^/]+", repo):
401
+ console.print("[red]--repo must be in 'owner/name' format[/red]")
402
+ raise typer.Exit(2)
403
+ owner, name = repo.split("/", 1)
404
+ detected = (owner, name)
405
+ elif scope != "org":
406
+ detected = gitctx.detect_repo()
407
+
408
+ try:
409
+ mode = runner.resolve_mode(
410
+ scope, has_org_config=cfg is not None, detected_repo=detected
411
+ )
412
+ except runner.ModeError as exc:
413
+ console.print(f"[red]{exc}[/red]")
414
+ raise typer.Exit(2) from exc
415
+
416
+ if mode is runner.Mode.ORG:
417
+ assert cfg is not None
418
+ code = asyncio.run(
419
+ _run_org(
420
+ cfg, console=console,
421
+ output_dir=Path(output_dir) if output_dir else None,
422
+ pages_url=pages_url, top_n=top_n, force_notify=force_notify,
423
+ slack_channel=slack_channel or None,
424
+ release_min_age_days=release_min_age_days,
425
+ releases_exclude=tuple(releases_exclude) if releases_exclude else None,
426
+ top_n_report=top_n_report,
427
+ top_n_cli=top_n_cli,
428
+ top_n_slack=top_n_slack,
429
+ )
430
+ )
431
+ else:
432
+ assert detected is not None
433
+ # In repo mode there is no per-org config; honour report.ruleset_workflows
434
+ # from a supplied config (e.g. --scope repo with --config) so keyword
435
+ # customisation applies, falling back to the built-in default otherwise.
436
+ rw = cfg.report.ruleset_workflows if cfg is not None else None
437
+ code = asyncio.run(
438
+ _run_repo(
439
+ detected[0], detected[1], token_env=token_env,
440
+ console=console, fail_threshold=fail_threshold,
441
+ ruleset_workflows=rw,
442
+ )
443
+ )
444
+ raise typer.Exit(code)
445
+
446
+
447
+ if __name__ == "__main__": # pragma: no cover
448
+ app()