github-security-report 0.7.0__tar.gz → 0.7.1__tar.gz

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.
Files changed (33) hide show
  1. {github_security_report-0.7.0 → github_security_report-0.7.1}/PKG-INFO +12 -3
  2. {github_security_report-0.7.0 → github_security_report-0.7.1}/README.md +10 -1
  3. {github_security_report-0.7.0 → github_security_report-0.7.1}/pyproject.toml +2 -2
  4. {github_security_report-0.7.0 → github_security_report-0.7.1}/src/github_security_report/_version.py +2 -2
  5. {github_security_report-0.7.0 → github_security_report-0.7.1}/src/github_security_report/categories.py +1 -2
  6. {github_security_report-0.7.0 → github_security_report-0.7.1}/src/github_security_report/classify.py +65 -19
  7. {github_security_report-0.7.0 → github_security_report-0.7.1}/src/github_security_report/cli.py +164 -59
  8. {github_security_report-0.7.0 → github_security_report-0.7.1}/src/github_security_report/client.py +82 -50
  9. {github_security_report-0.7.0 → github_security_report-0.7.1}/src/github_security_report/collect.py +34 -29
  10. {github_security_report-0.7.0 → github_security_report-0.7.1}/src/github_security_report/config.py +9 -14
  11. {github_security_report-0.7.0 → github_security_report-0.7.1}/src/github_security_report/gating.py +26 -7
  12. {github_security_report-0.7.0 → github_security_report-0.7.1}/src/github_security_report/models.py +2 -6
  13. {github_security_report-0.7.0 → github_security_report-0.7.1}/src/github_security_report/posture.py +1 -4
  14. {github_security_report-0.7.0 → github_security_report-0.7.1}/src/github_security_report/remediate.py +3 -9
  15. {github_security_report-0.7.0 → github_security_report-0.7.1}/src/github_security_report/render/html.py +8 -7
  16. {github_security_report-0.7.0 → github_security_report-0.7.1}/src/github_security_report/render/markdown.py +22 -13
  17. {github_security_report-0.7.0 → github_security_report-0.7.1}/src/github_security_report/render/slack.py +21 -11
  18. {github_security_report-0.7.0 → github_security_report-0.7.1}/src/github_security_report/render/terminal.py +17 -17
  19. {github_security_report-0.7.0 → github_security_report-0.7.1}/src/github_security_report/report.py +2 -7
  20. {github_security_report-0.7.0 → github_security_report-0.7.1}/src/github_security_report/rulesets.py +2 -5
  21. {github_security_report-0.7.0 → github_security_report-0.7.1}/.gitignore +0 -0
  22. {github_security_report-0.7.0 → github_security_report-0.7.1}/LICENSE +0 -0
  23. {github_security_report-0.7.0 → github_security_report-0.7.1}/LICENSES/Apache-2.0.txt +0 -0
  24. {github_security_report-0.7.0 → github_security_report-0.7.1}/scripts/README.md +0 -0
  25. {github_security_report-0.7.0 → github_security_report-0.7.1}/src/github_security_report/__init__.py +0 -0
  26. {github_security_report-0.7.0 → github_security_report-0.7.1}/src/github_security_report/gitctx.py +0 -0
  27. {github_security_report-0.7.0 → github_security_report-0.7.1}/src/github_security_report/py.typed +0 -0
  28. {github_security_report-0.7.0 → github_security_report-0.7.1}/src/github_security_report/render/__init__.py +0 -0
  29. {github_security_report-0.7.0 → github_security_report-0.7.1}/src/github_security_report/runner.py +0 -0
  30. {github_security_report-0.7.0 → github_security_report-0.7.1}/src/github_security_report/scope.py +0 -0
  31. {github_security_report-0.7.0 → github_security_report-0.7.1}/src/github_security_report/severity.py +0 -0
  32. {github_security_report-0.7.0 → github_security_report-0.7.1}/src/github_security_report/templates/index.html.j2 +0 -0
  33. {github_security_report-0.7.0 → github_security_report-0.7.1}/src/github_security_report/templates/report.html.j2 +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github-security-report
3
- Version: 0.7.0
3
+ Version: 0.7.1
4
4
  Summary: Security and quality reporting across GitHub organisations
5
5
  Project-URL: Homepage, https://github.com/lfreleng-actions/github-security-report-action
6
6
  Project-URL: Repository, https://github.com/lfreleng-actions/github-security-report-action
@@ -37,7 +37,7 @@ Requires-Dist: pytest-cov==7.1.0; extra == 'dev'
37
37
  Requires-Dist: pytest==9.1.1; extra == 'dev'
38
38
  Requires-Dist: respx==0.23.1; extra == 'dev'
39
39
  Requires-Dist: ruff==0.15.20; extra == 'dev'
40
- Requires-Dist: syrupy==5.3.4; extra == 'dev'
40
+ Requires-Dist: syrupy==5.5.1; extra == 'dev'
41
41
  Requires-Dist: types-jsonschema==4.26.0.20260518; extra == 'dev'
42
42
  Requires-Dist: types-pyyaml==6.0.12.20250915; extra == 'dev'
43
43
  Description-Content-Type: text/markdown
@@ -460,13 +460,22 @@ The tool is published to PyPI and runs with `uvx`. Inside a Git checkout with a
460
460
  `upstream` remote, then `origin`) and prints a Rich table report:
461
461
 
462
462
  ```bash
463
- export GITHUB_TOKEN=ghp_your_token
463
+ export GITHUB_TOKEN="your-token"
464
464
  uvx github-security-report report
465
465
 
466
466
  # Or org mode locally with a PAT:
467
467
  uvx github-security-report report --org lfreleng-actions
468
468
  ```
469
469
 
470
+ ### GitHub Enterprise Server
471
+
472
+ The API endpoints honour the standard environment variables that GitHub
473
+ Actions exports, so the tool works against GitHub Enterprise Server
474
+ without code changes: set `GITHUB_API_URL` and `GITHUB_GRAPHQL_URL` to
475
+ your enterprise endpoints (Actions sets these automatically on GHES
476
+ runners). `SCORECARD_API_URL` overrides the external OpenSSF Scorecard
477
+ API in the same way.
478
+
470
479
  ## Remediation
471
480
 
472
481
  The `remediate` subcommand is the in-tool counterpart to the report: it runs the
@@ -416,13 +416,22 @@ The tool is published to PyPI and runs with `uvx`. Inside a Git checkout with a
416
416
  `upstream` remote, then `origin`) and prints a Rich table report:
417
417
 
418
418
  ```bash
419
- export GITHUB_TOKEN=ghp_your_token
419
+ export GITHUB_TOKEN="your-token"
420
420
  uvx github-security-report report
421
421
 
422
422
  # Or org mode locally with a PAT:
423
423
  uvx github-security-report report --org lfreleng-actions
424
424
  ```
425
425
 
426
+ ### GitHub Enterprise Server
427
+
428
+ The API endpoints honour the standard environment variables that GitHub
429
+ Actions exports, so the tool works against GitHub Enterprise Server
430
+ without code changes: set `GITHUB_API_URL` and `GITHUB_GRAPHQL_URL` to
431
+ your enterprise endpoints (Actions sets these automatically on GHES
432
+ runners). `SCORECARD_API_URL` overrides the external OpenSSF Scorecard
433
+ API in the same way.
434
+
426
435
  ## Remediation
427
436
 
428
437
  The `remediate` subcommand is the in-tool counterpart to the report: it runs the
@@ -63,7 +63,7 @@ dev = [
63
63
  "pytest-asyncio==1.4.0",
64
64
  "pytest-cov==7.1.0",
65
65
  "respx==0.23.1",
66
- "syrupy==5.3.4",
66
+ "syrupy==5.5.1",
67
67
  "mypy==2.1.0",
68
68
  "ruff==0.15.20",
69
69
  "types-jsonschema==4.26.0.20260518",
@@ -95,7 +95,7 @@ dev = [
95
95
  "pytest-asyncio==1.4.0",
96
96
  "pytest-cov==7.1.0",
97
97
  "respx==0.23.1",
98
- "syrupy==5.3.4",
98
+ "syrupy==5.5.1",
99
99
  "mypy==2.1.0",
100
100
  "ruff==0.15.20",
101
101
  "types-jsonschema==4.26.0.20260518",
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.7.0'
22
- __version_tuple__ = version_tuple = (0, 7, 0)
21
+ __version__ = version = '0.7.1'
22
+ __version_tuple__ = version_tuple = (0, 7, 1)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -118,8 +118,7 @@ _CATEGORIES: dict[CategoryKey, CategoryMeta] = {
118
118
  fail_label=None,
119
119
  url="https://github.com/scanaislop/aislop",
120
120
  description=(
121
- "aislop AI-slop / code-quality findings, ranked worst-first by "
122
- "severity."
121
+ "aislop AI-slop / code-quality findings, ranked worst-first by severity."
123
122
  ),
124
123
  # aislop, like zizmor, populates only the SARIF level axis
125
124
  # (error/warning/note); "note" normalises to LOW (see severity.py), so
@@ -37,9 +37,7 @@ from github_security_report.severity import Severity
37
37
  FailSeverities = Mapping[SignalType, Severity]
38
38
 
39
39
 
40
- def _cutoff(
41
- signal: SignalType, fail_severities: FailSeverities | None
42
- ) -> Severity:
40
+ def _cutoff(signal: SignalType, fail_severities: FailSeverities | None) -> Severity:
43
41
  """The fail-severity cutoff for ``signal`` (config override or default)."""
44
42
  if fail_severities is not None and signal in fail_severities:
45
43
  return fail_severities[signal]
@@ -131,13 +129,19 @@ def _code_scanning_tool_signal(
131
129
  facts.code_scanning_status == 200 and tool_name in facts.code_scanning_tools
132
130
  )
133
131
  if not enabled:
134
- detail = "code scanning disabled" if facts.code_scanning_status == 404 else f"{tool_name} not enabled"
132
+ detail = (
133
+ "code scanning disabled"
134
+ if facts.code_scanning_status == 404
135
+ else f"{tool_name} not enabled"
136
+ )
135
137
  return RepoSignal(repo, signal, RepoState.NAG, detail=detail)
136
138
  counts = count_code_scanning(facts.code_scanning_alerts, tool_name)
137
139
  # Enabled with no findings is only "clean" when the alert read succeeded;
138
140
  # an unreadable sweep (e.g. org-bulk 403/5xx) must not masquerade as clean.
139
141
  if counts.total == 0 and facts.code_scanning_alerts_status != 200:
140
- return RepoSignal(repo, signal, RepoState.UNKNOWN, detail="alert data unavailable")
142
+ return RepoSignal(
143
+ repo, signal, RepoState.UNKNOWN, detail="alert data unavailable"
144
+ )
141
145
  # Only findings at or above the category's fail-severity cutoff make the
142
146
  # repo an offender; sub-threshold findings (e.g. informational) fold into
143
147
  # the clean count.
@@ -201,21 +205,34 @@ def classify_scorecard(
201
205
  # the code-scanning cutoff logic used elsewhere in this file.
202
206
  clean = facts.scorecard_score >= 10.0 and not counts.at_or_above(cutoff)
203
207
  state = RepoState.CLEAN if clean else RepoState.OFFENDER
204
- return RepoSignal(repo, SignalType.SCORECARD, state, counts=counts, score=facts.scorecard_score)
208
+ return RepoSignal(
209
+ repo,
210
+ SignalType.SCORECARD,
211
+ state,
212
+ counts=counts,
213
+ score=facts.scorecard_score,
214
+ )
205
215
  if has_cs:
206
216
  if counts.total == 0 and facts.code_scanning_alerts_status != 200:
207
- return RepoSignal(repo, SignalType.SCORECARD, RepoState.UNKNOWN, detail="alert data unavailable")
208
- state = (
209
- RepoState.OFFENDER if counts.at_or_above(cutoff) else RepoState.CLEAN
210
- )
217
+ return RepoSignal(
218
+ repo,
219
+ SignalType.SCORECARD,
220
+ RepoState.UNKNOWN,
221
+ detail="alert data unavailable",
222
+ )
223
+ state = RepoState.OFFENDER if counts.at_or_above(cutoff) else RepoState.CLEAN
211
224
  return RepoSignal(repo, SignalType.SCORECARD, state, counts=counts)
212
225
  # An indeterminate external request (a 403 forbidden/blocked, a 5xx
213
226
  # including the synthetic 503 a transport failure produces, or a forbidden
214
227
  # code-scanning probe) is unknown, not a definitive nag. Only a clean 404
215
228
  # with no code-scanning Scorecard data means "no results".
216
229
  if facts.code_scanning_status == 403 or facts.scorecard_status not in (200, 404):
217
- return RepoSignal(repo, SignalType.SCORECARD, RepoState.UNKNOWN, detail="indeterminate")
218
- return RepoSignal(repo, SignalType.SCORECARD, RepoState.NAG, detail="no Scorecard results")
230
+ return RepoSignal(
231
+ repo, SignalType.SCORECARD, RepoState.UNKNOWN, detail="indeterminate"
232
+ )
233
+ return RepoSignal(
234
+ repo, SignalType.SCORECARD, RepoState.NAG, detail="no Scorecard results"
235
+ )
219
236
 
220
237
 
221
238
  def classify_secret_scanning(
@@ -226,9 +243,19 @@ def classify_secret_scanning(
226
243
  # other classifiers (they are dispatched positionally) and is unused here.
227
244
  repo = facts.repo
228
245
  if facts.secret_scanning_status == 403:
229
- return RepoSignal(repo, SignalType.SECRET_SCANNING, RepoState.UNKNOWN, detail="insufficient permission")
246
+ return RepoSignal(
247
+ repo,
248
+ SignalType.SECRET_SCANNING,
249
+ RepoState.UNKNOWN,
250
+ detail="insufficient permission",
251
+ )
230
252
  if facts.secret_scanning_status == 404:
231
- return RepoSignal(repo, SignalType.SECRET_SCANNING, RepoState.NAG, detail="secret scanning disabled")
253
+ return RepoSignal(
254
+ repo,
255
+ SignalType.SECRET_SCANNING,
256
+ RepoState.NAG,
257
+ detail="secret scanning disabled",
258
+ )
232
259
  # Flat open count; stored as HIGH so ranking (more == worse) works and the
233
260
  # repo-mode gate treats leaked secrets as serious, without conflating them
234
261
  # with CRITICAL code findings (which would make --fail-threshold critical
@@ -238,12 +265,19 @@ def classify_secret_scanning(
238
265
  # Positive evidence of leaked secrets is actionable even when the read was
239
266
  # incomplete (a later page failed) or the enablement probe was indeterminate.
240
267
  if facts.secret_scanning_open:
241
- return RepoSignal(repo, SignalType.SECRET_SCANNING, RepoState.OFFENDER, counts=counts)
268
+ return RepoSignal(
269
+ repo, SignalType.SECRET_SCANNING, RepoState.OFFENDER, counts=counts
270
+ )
242
271
  # A zero count is only "clean" when both the enablement probe and the
243
272
  # open-count read succeeded; an indeterminate status (5xx) or an unreadable
244
273
  # sweep cannot confirm the repo is clean.
245
274
  if facts.secret_scanning_status != 200 or facts.secret_scanning_open_status != 200:
246
- return RepoSignal(repo, SignalType.SECRET_SCANNING, RepoState.UNKNOWN, detail="alert data unavailable")
275
+ return RepoSignal(
276
+ repo,
277
+ SignalType.SECRET_SCANNING,
278
+ RepoState.UNKNOWN,
279
+ detail="alert data unavailable",
280
+ )
247
281
  return RepoSignal(repo, SignalType.SECRET_SCANNING, RepoState.CLEAN, counts=counts)
248
282
 
249
283
 
@@ -252,13 +286,25 @@ def classify_dependabot(
252
286
  ) -> RepoSignal:
253
287
  repo = facts.repo
254
288
  if facts.dependabot_enabled is None:
255
- return RepoSignal(repo, SignalType.DEPENDABOT, RepoState.UNKNOWN, detail="indeterminate")
289
+ return RepoSignal(
290
+ repo, SignalType.DEPENDABOT, RepoState.UNKNOWN, detail="indeterminate"
291
+ )
256
292
  if facts.dependabot_enabled is False:
257
- return RepoSignal(repo, SignalType.DEPENDABOT, RepoState.NAG, detail="Dependabot alerts disabled")
293
+ return RepoSignal(
294
+ repo,
295
+ SignalType.DEPENDABOT,
296
+ RepoState.NAG,
297
+ detail="Dependabot alerts disabled",
298
+ )
258
299
  counts = count_dependabot(facts.dependabot_alerts)
259
300
  # Enabled with no alerts is only "clean" when the alert read succeeded.
260
301
  if counts.total == 0 and facts.dependabot_alerts_status != 200:
261
- return RepoSignal(repo, SignalType.DEPENDABOT, RepoState.UNKNOWN, detail="alert data unavailable")
302
+ return RepoSignal(
303
+ repo,
304
+ SignalType.DEPENDABOT,
305
+ RepoState.UNKNOWN,
306
+ detail="alert data unavailable",
307
+ )
262
308
  failing = counts.at_or_above(_cutoff(SignalType.DEPENDABOT, fail_severities))
263
309
  state = RepoState.OFFENDER if failing else RepoState.CLEAN
264
310
  return RepoSignal(repo, SignalType.DEPENDABOT, state, counts=counts)
@@ -59,7 +59,10 @@ def _version_callback(value: bool) -> None:
59
59
  @app.callback()
60
60
  def main(
61
61
  _version: bool = typer.Option(
62
- False, "--version", callback=_version_callback, is_eager=True,
62
+ False,
63
+ "--version",
64
+ callback=_version_callback,
65
+ is_eager=True,
63
66
  help="Show the version and exit.",
64
67
  ),
65
68
  verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose logging."),
@@ -176,9 +179,7 @@ def _slack_show(
176
179
  when any contributing org would show it on Slack -- mirroring the
177
180
  most-generous ``top_n`` rule for the same grouping.
178
181
  """
179
- return lambda key: any(
180
- oc.report.shows_category(key, "slack") for oc, _ in items
181
- )
182
+ return lambda key: any(oc.report.shows_category(key, "slack") for oc, _ in items)
182
183
 
183
184
 
184
185
  def _write_org_files(
@@ -192,15 +193,11 @@ def _write_org_files(
192
193
  org_dir = output_dir / slug
193
194
  org_dir.mkdir(parents=True, exist_ok=True)
194
195
  (org_dir / "report.md").write_text(
195
- md_render.render_org(
196
- org, top_n=top_n, show=_show(report_cfg, "markdown")
197
- ),
196
+ md_render.render_org(org, top_n=top_n, show=_show(report_cfg, "markdown")),
198
197
  encoding="utf-8",
199
198
  )
200
199
  (org_dir / "report.html").write_text(
201
- html_render.render_org_html(
202
- org, top_n=top_n, show=_show(report_cfg, "html")
203
- ),
200
+ html_render.render_org_html(org, top_n=top_n, show=_show(report_cfg, "html")),
204
201
  encoding="utf-8",
205
202
  )
206
203
  # report.json is the complete machine-readable dataset, so the per-output
@@ -255,36 +252,48 @@ def _abort_network(console: Console, exc: NetworkError) -> NoReturn:
255
252
  raise typer.Exit(3)
256
253
 
257
254
 
258
- async def _run_org(cfg: Config, *, console: Console, output_dir: Path | None,
259
- pages_url: str | None, top_n: int | None, force_notify: bool,
260
- slack_channel: str | None = None,
261
- repo_min_age_days: int | None = None,
262
- release_max_age_days: int | None = None,
263
- releases_exclude: tuple[str, ...] | None = None,
264
- top_n_report: int | None = None,
265
- top_n_cli: int | None = None,
266
- top_n_slack: int | None = None) -> int:
255
+ async def _run_org(
256
+ cfg: Config,
257
+ *,
258
+ console: Console,
259
+ output_dir: Path | None,
260
+ pages_url: str | None,
261
+ top_n: int | None,
262
+ force_notify: bool,
263
+ slack_channel: str | None = None,
264
+ repo_min_age_days: int | None = None,
265
+ release_max_age_days: int | None = None,
266
+ releases_exclude: tuple[str, ...] | None = None,
267
+ top_n_report: int | None = None,
268
+ top_n_cli: int | None = None,
269
+ top_n_slack: int | None = None,
270
+ ) -> int:
267
271
  now = dt.datetime.now(dt.timezone.utc)
268
272
  pairs: list[tuple[OrgConfig, OrgReport]] = []
269
273
  for org_cfg in cfg.organizations:
270
274
  token = config.resolve_token(org_cfg)
271
275
  if not token:
272
- console.print(f"[red]No token in ${org_cfg.token_env} for {org_cfg.name}[/red]")
276
+ console.print(
277
+ f"[red]No token in ${org_cfg.token_env} for {org_cfg.name}[/red]"
278
+ )
273
279
  return 2
274
280
  # CLI overrides win over config for the Releases/Tagging controls.
275
281
  report_cfg = org_cfg.report
276
282
  if repo_min_age_days is not None:
277
283
  report_cfg = replace(report_cfg, repo_min_age_days=repo_min_age_days)
278
284
  if release_max_age_days is not None:
279
- report_cfg = replace(
280
- report_cfg, release_max_age_days=release_max_age_days
281
- )
285
+ report_cfg = replace(report_cfg, release_max_age_days=release_max_age_days)
282
286
  effective_cfg = org_cfg
283
287
  if releases_exclude is not None:
284
288
  effective_cfg = replace(org_cfg, releases_exclude=releases_exclude)
285
289
  async with GitHubClient(token) as client:
286
290
  pairs.append(
287
- (org_cfg, await collect.collect_org(client, effective_cfg, report_cfg, generated_at=now))
291
+ (
292
+ org_cfg,
293
+ await collect.collect_org(
294
+ client, effective_cfg, report_cfg, generated_at=now
295
+ ),
296
+ )
288
297
  )
289
298
  org_reports = [report for _, report in pairs]
290
299
 
@@ -376,7 +385,10 @@ async def _run_org(cfg: Config, *, console: Console, output_dir: Path | None,
376
385
  outputs["slack_payload"] = json.dumps(payloads[0])
377
386
  if output_dir:
378
387
  for payload in payloads:
379
- dest = output_dir / f"slack-payload-{_safe_component(payload['channel'])}.json"
388
+ dest = (
389
+ output_dir
390
+ / f"slack-payload-{_safe_component(payload['channel'])}.json"
391
+ )
380
392
  dest.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
381
393
  runner.write_github_output(outputs)
382
394
  # The job summary mirrors the GitHub Pages Markdown, so it uses the report
@@ -396,9 +408,15 @@ async def _run_org(cfg: Config, *, console: Console, output_dir: Path | None,
396
408
  return 0
397
409
 
398
410
 
399
- async def _run_repo(owner: str, repo_name: str, *, token_env: str, console: Console,
400
- fail_threshold: str,
401
- ruleset_workflows: Mapping[str, str] | None = None) -> int:
411
+ async def _run_repo(
412
+ owner: str,
413
+ repo_name: str,
414
+ *,
415
+ token_env: str,
416
+ console: Console,
417
+ fail_threshold: str,
418
+ ruleset_workflows: Mapping[str, str] | None = None,
419
+ ) -> int:
402
420
  token = os.environ.get(token_env, "").strip()
403
421
  if not token:
404
422
  console.print(f"[red]No token in ${token_env}[/red]")
@@ -411,7 +429,9 @@ async def _run_repo(owner: str, repo_name: str, *, token_env: str, console: Cons
411
429
  return 2
412
430
 
413
431
  now = dt.datetime.now(dt.timezone.utc)
414
- org = build_org_report(f"{owner}/{repo_name}", signals, repo_count=1, generated_at=now)
432
+ org = build_org_report(
433
+ f"{owner}/{repo_name}", signals, repo_count=1, generated_at=now
434
+ )
415
435
  term_render.render_org(org, console)
416
436
 
417
437
  runner.append_step_summary(md_render.render_org(org))
@@ -466,7 +486,9 @@ async def _run_remediate(
466
486
 
467
487
  def _repo_outputs(signals: list[RepoSignal], fail_threshold: str) -> dict[str, str]:
468
488
  outputs = {s.signal.value + "_open": str(s.counts.total) for s in signals}
469
- outputs["failed"] = "true" if runner.should_fail(signals, fail_threshold) else "false"
489
+ outputs["failed"] = (
490
+ "true" if runner.should_fail(signals, fail_threshold) else "false"
491
+ )
470
492
  return outputs
471
493
 
472
494
 
@@ -475,24 +497,77 @@ def _repo_outputs(signals: list[RepoSignal], fail_threshold: str) -> dict[str, s
475
497
  # --------------------------------------------------------------------------- #
476
498
  @app.command()
477
499
  def report(
478
- config_file: str | None = typer.Option(None, "--config", "-c", help="Path to a JSON config file."),
479
- config_data: str | None = typer.Option(None, "--config-data", help="Raw or base64 JSON config (vars/secrets)."),
480
- org: str | None = typer.Option(None, "--org", help="Single organisation (shorthand for org mode)."),
500
+ config_file: str | None = typer.Option(
501
+ None, "--config", "-c", help="Path to a JSON config file."
502
+ ),
503
+ config_data: str | None = typer.Option(
504
+ None, "--config-data", help="Raw or base64 JSON config (vars/secrets)."
505
+ ),
506
+ org: str | None = typer.Option(
507
+ None, "--org", help="Single organisation (shorthand for org mode)."
508
+ ),
481
509
  scope: str = typer.Option("auto", "--scope", help="auto | org | repo."),
482
- repo: str | None = typer.Option(None, "--repo", help="owner/name for repo mode (else git-detected)."),
483
- token_env: str = typer.Option("GITHUB_TOKEN", "--token-env", help="Env var holding the repo-mode token."),
484
- output_dir: str | None = typer.Option(None, "--output-dir", "-o", help="Directory for Pages output (org mode)."),
485
- pages_url: str | None = typer.Option(None, "--pages-url", help="GitHub Pages URL for the Slack link."),
486
- slack_channel: str | None = typer.Option(None, "--slack-channel", help="Slack channel ID; overrides config slack.channel (e.g. SLACK_CHANNEL_ID)."),
487
- top_n: int | None = typer.Option(None, "--top-n", help="Offenders shown per signal across all outputs (0 = no limit; default: config, else 10). Overridden per output by the flags below."),
488
- top_n_report: int | None = typer.Option(None, "--top-n-report", help="Offenders per signal in the GitHub Pages output (0 = no limit; overrides --top-n)."),
489
- top_n_cli: int | None = typer.Option(None, "--top-n-cli", help="Offenders per signal in the terminal output (0 = no limit; overrides --top-n)."),
490
- top_n_slack: int | None = typer.Option(None, "--top-n-slack", help="Offenders per signal in the Slack digest (0 = no limit; overrides --top-n)."),
491
- fail_threshold: str = typer.Option("none", "--fail-threshold", help="none|low|medium|high|critical|any (repo mode)."),
492
- force_notify: bool = typer.Option(False, "--force-notify", help="Post to Slack regardless of report_day."),
493
- repo_min_age_days: int | None = typer.Option(None, "--repo-min-age-days", "--release-min-age-days", help="Exclude repos created within N days from Releases/Tagging (0 = include all; default: config, else 28). --release-min-age-days is a deprecated alias."),
494
- release_max_age_days: int | None = typer.Option(None, "--release-max-age-days", help="Flag a repo in Releases/Tagging only when its newest release or tag is older than N days (0 = flag every eligible repo; default: config, else 60)."),
495
- releases_exclude: list[str] | None = typer.Option(None, "--releases-exclude", help="Repository name to omit from the Releases/Tagging table (repeatable; overrides config)."),
510
+ repo: str | None = typer.Option(
511
+ None, "--repo", help="owner/name for repo mode (else git-detected)."
512
+ ),
513
+ token_env: str = typer.Option(
514
+ "GITHUB_TOKEN", "--token-env", help="Env var holding the repo-mode token."
515
+ ),
516
+ output_dir: str | None = typer.Option(
517
+ None, "--output-dir", "-o", help="Directory for Pages output (org mode)."
518
+ ),
519
+ pages_url: str | None = typer.Option(
520
+ None, "--pages-url", help="GitHub Pages URL for the Slack link."
521
+ ),
522
+ slack_channel: str | None = typer.Option(
523
+ None,
524
+ "--slack-channel",
525
+ help="Slack channel ID; overrides config slack.channel (e.g. SLACK_CHANNEL_ID).",
526
+ ),
527
+ top_n: int | None = typer.Option(
528
+ None,
529
+ "--top-n",
530
+ help="Offenders shown per signal across all outputs (0 = no limit; default: config, else 10). Overridden per output by the flags below.",
531
+ ),
532
+ top_n_report: int | None = typer.Option(
533
+ None,
534
+ "--top-n-report",
535
+ help="Offenders per signal in the GitHub Pages output (0 = no limit; overrides --top-n).",
536
+ ),
537
+ top_n_cli: int | None = typer.Option(
538
+ None,
539
+ "--top-n-cli",
540
+ help="Offenders per signal in the terminal output (0 = no limit; overrides --top-n).",
541
+ ),
542
+ top_n_slack: int | None = typer.Option(
543
+ None,
544
+ "--top-n-slack",
545
+ help="Offenders per signal in the Slack digest (0 = no limit; overrides --top-n).",
546
+ ),
547
+ fail_threshold: str = typer.Option(
548
+ "none",
549
+ "--fail-threshold",
550
+ help="none|low|medium|high|critical|any (repo mode).",
551
+ ),
552
+ force_notify: bool = typer.Option(
553
+ False, "--force-notify", help="Post to Slack regardless of report_day."
554
+ ),
555
+ repo_min_age_days: int | None = typer.Option(
556
+ None,
557
+ "--repo-min-age-days",
558
+ "--release-min-age-days",
559
+ help="Exclude repos created within N days from Releases/Tagging (0 = include all; default: config, else 28). --release-min-age-days is a deprecated alias.",
560
+ ),
561
+ release_max_age_days: int | None = typer.Option(
562
+ None,
563
+ "--release-max-age-days",
564
+ help="Flag a repo in Releases/Tagging only when its newest release or tag is older than N days (0 = flag every eligible repo; default: config, else 60).",
565
+ ),
566
+ releases_exclude: list[str] | None = typer.Option(
567
+ None,
568
+ "--releases-exclude",
569
+ help="Repository name to omit from the Releases/Tagging table (repeatable; overrides config).",
570
+ ),
496
571
  no_color: bool = typer.Option(False, "--no-color", help="Disable coloured output."),
497
572
  ) -> None:
498
573
  """Generate a security and quality report."""
@@ -548,13 +623,18 @@ def report(
548
623
  try:
549
624
  code = asyncio.run(
550
625
  _run_org(
551
- cfg, console=console,
626
+ cfg,
627
+ console=console,
552
628
  output_dir=Path(output_dir) if output_dir else None,
553
- pages_url=pages_url, top_n=top_n, force_notify=force_notify,
629
+ pages_url=pages_url,
630
+ top_n=top_n,
631
+ force_notify=force_notify,
554
632
  slack_channel=slack_channel or None,
555
633
  repo_min_age_days=repo_min_age_days,
556
634
  release_max_age_days=release_max_age_days,
557
- releases_exclude=tuple(releases_exclude) if releases_exclude else None,
635
+ releases_exclude=tuple(releases_exclude)
636
+ if releases_exclude
637
+ else None,
558
638
  top_n_report=top_n_report,
559
639
  top_n_cli=top_n_cli,
560
640
  top_n_slack=top_n_slack,
@@ -571,8 +651,11 @@ def report(
571
651
  try:
572
652
  code = asyncio.run(
573
653
  _run_repo(
574
- detected[0], detected[1], token_env=token_env,
575
- console=console, fail_threshold=fail_threshold,
654
+ detected[0],
655
+ detected[1],
656
+ token_env=token_env,
657
+ console=console,
658
+ fail_threshold=fail_threshold,
576
659
  ruleset_workflows=rw,
577
660
  )
578
661
  )
@@ -583,13 +666,35 @@ def report(
583
666
 
584
667
  @app.command()
585
668
  def remediate(
586
- config_file: str | None = typer.Option(None, "--config", "-c", help="Path to a JSON config file."),
587
- config_data: str | None = typer.Option(None, "--config-data", help="Raw or base64 JSON config (vars/secrets)."),
588
- org: str | None = typer.Option(None, "--org", help="Single organisation (shorthand for org mode)."),
589
- scope: str = typer.Option("org", "--scope", help="Only 'org' is supported; remediation is organisation-scoped."),
590
- category: list[str] | None = typer.Option(None, "--category", help="Remediable category to act on (repeatable; default: all). One of: codeql, secret_scanning, dependabot_alerts_enabled, dependabot_updates_enabled, private_vulnerability_reporting."),
591
- token_env: str = typer.Option("GITHUB_TOKEN", "--token-env", help="Env var holding a WRITE-capable org-admin PAT. Used for both reading posture and enabling features across every configured org."),
592
- apply: bool = typer.Option(False, "--apply", help="Perform the writes. Without this flag remediate only previews (dry run)."),
669
+ config_file: str | None = typer.Option(
670
+ None, "--config", "-c", help="Path to a JSON config file."
671
+ ),
672
+ config_data: str | None = typer.Option(
673
+ None, "--config-data", help="Raw or base64 JSON config (vars/secrets)."
674
+ ),
675
+ org: str | None = typer.Option(
676
+ None, "--org", help="Single organisation (shorthand for org mode)."
677
+ ),
678
+ scope: str = typer.Option(
679
+ "org",
680
+ "--scope",
681
+ help="Only 'org' is supported; remediation is organisation-scoped.",
682
+ ),
683
+ category: list[str] | None = typer.Option(
684
+ None,
685
+ "--category",
686
+ help="Remediable category to act on (repeatable; default: all). One of: codeql, secret_scanning, dependabot_alerts_enabled, dependabot_updates_enabled, private_vulnerability_reporting.",
687
+ ),
688
+ token_env: str = typer.Option(
689
+ "GITHUB_TOKEN",
690
+ "--token-env",
691
+ help="Env var holding a WRITE-capable org-admin PAT. Used for both reading posture and enabling features across every configured org.",
692
+ ),
693
+ apply: bool = typer.Option(
694
+ False,
695
+ "--apply",
696
+ help="Perform the writes. Without this flag remediate only previews (dry run).",
697
+ ),
593
698
  no_color: bool = typer.Option(False, "--no-color", help="Disable coloured output."),
594
699
  ) -> None:
595
700
  """Enable security features on repositories that lack them.