github-security-report 0.10.0__tar.gz → 0.11.2__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 (60) hide show
  1. {github_security_report-0.10.0 → github_security_report-0.11.2}/PKG-INFO +207 -5
  2. {github_security_report-0.10.0 → github_security_report-0.11.2}/README.md +203 -1
  3. {github_security_report-0.10.0 → github_security_report-0.11.2}/pyproject.toml +3 -3
  4. {github_security_report-0.10.0 → github_security_report-0.11.2}/src/github_security_report/_version.py +2 -2
  5. {github_security_report-0.10.0 → github_security_report-0.11.2}/src/github_security_report/categories.py +25 -1
  6. github_security_report-0.11.2/src/github_security_report/cli/__init__.py +66 -0
  7. github_security_report-0.11.2/src/github_security_report/cli/__main__.py +16 -0
  8. github_security_report-0.11.2/src/github_security_report/cli/app.py +338 -0
  9. github_security_report-0.11.2/src/github_security_report/cli/modes.py +376 -0
  10. github_security_report-0.11.2/src/github_security_report/cli/outputs.py +158 -0
  11. github_security_report-0.11.2/src/github_security_report/cli/serialise.py +87 -0
  12. github_security_report-0.11.2/src/github_security_report/client/__init__.py +84 -0
  13. github_security_report-0.11.2/src/github_security_report/client/endpoints.py +90 -0
  14. github_security_report-0.11.2/src/github_security_report/client/parsers.py +275 -0
  15. github_security_report-0.11.2/src/github_security_report/client/queries.py +88 -0
  16. github_security_report-0.11.2/src/github_security_report/client/reads.py +465 -0
  17. github_security_report-0.11.2/src/github_security_report/client/transport.py +311 -0
  18. github_security_report-0.11.2/src/github_security_report/client/writes.py +135 -0
  19. github_security_report-0.11.2/src/github_security_report/collect/__init__.py +44 -0
  20. github_security_report-0.11.2/src/github_security_report/collect/context.py +89 -0
  21. github_security_report-0.11.2/src/github_security_report/collect/extras.py +109 -0
  22. github_security_report-0.11.2/src/github_security_report/collect/org.py +384 -0
  23. github_security_report-0.11.2/src/github_security_report/collect/protocols.py +108 -0
  24. github_security_report-0.11.2/src/github_security_report/collect/repo.py +88 -0
  25. github_security_report-0.11.2/src/github_security_report/config/__init__.py +82 -0
  26. github_security_report-0.11.2/src/github_security_report/config/loader.py +349 -0
  27. github_security_report-0.11.2/src/github_security_report/config/models.py +234 -0
  28. github_security_report-0.11.2/src/github_security_report/config/schema.py +178 -0
  29. github_security_report-0.11.2/src/github_security_report/issues.py +247 -0
  30. {github_security_report-0.10.0 → github_security_report-0.11.2}/src/github_security_report/models.py +52 -0
  31. github_security_report-0.11.2/src/github_security_report/ordering.py +222 -0
  32. {github_security_report-0.10.0 → github_security_report-0.11.2}/src/github_security_report/posture.py +32 -3
  33. {github_security_report-0.10.0 → github_security_report-0.11.2}/src/github_security_report/render/html.py +32 -34
  34. {github_security_report-0.10.0 → github_security_report-0.11.2}/src/github_security_report/render/markdown.py +39 -30
  35. {github_security_report-0.10.0 → github_security_report-0.11.2}/src/github_security_report/render/slack.py +189 -111
  36. github_security_report-0.11.2/src/github_security_report/render/slack_limits.py +287 -0
  37. {github_security_report-0.10.0 → github_security_report-0.11.2}/src/github_security_report/render/terminal.py +40 -26
  38. {github_security_report-0.10.0 → github_security_report-0.11.2}/src/github_security_report/report.py +124 -81
  39. github_security_report-0.11.2/src/github_security_report/summary.py +95 -0
  40. {github_security_report-0.10.0 → github_security_report-0.11.2}/src/github_security_report/templates/report.html.j2 +7 -0
  41. github_security_report-0.10.0/src/github_security_report/cli.py +0 -760
  42. github_security_report-0.10.0/src/github_security_report/client.py +0 -1013
  43. github_security_report-0.10.0/src/github_security_report/collect.py +0 -536
  44. github_security_report-0.10.0/src/github_security_report/config.py +0 -569
  45. {github_security_report-0.10.0 → github_security_report-0.11.2}/.gitignore +0 -0
  46. {github_security_report-0.10.0 → github_security_report-0.11.2}/LICENSE +0 -0
  47. {github_security_report-0.10.0 → github_security_report-0.11.2}/LICENSES/Apache-2.0.txt +0 -0
  48. {github_security_report-0.10.0 → github_security_report-0.11.2}/scripts/README.md +0 -0
  49. {github_security_report-0.10.0 → github_security_report-0.11.2}/src/github_security_report/__init__.py +0 -0
  50. {github_security_report-0.10.0 → github_security_report-0.11.2}/src/github_security_report/classify.py +0 -0
  51. {github_security_report-0.10.0 → github_security_report-0.11.2}/src/github_security_report/gating.py +0 -0
  52. {github_security_report-0.10.0 → github_security_report-0.11.2}/src/github_security_report/gitctx.py +0 -0
  53. {github_security_report-0.10.0 → github_security_report-0.11.2}/src/github_security_report/py.typed +0 -0
  54. {github_security_report-0.10.0 → github_security_report-0.11.2}/src/github_security_report/remediate.py +0 -0
  55. {github_security_report-0.10.0 → github_security_report-0.11.2}/src/github_security_report/render/__init__.py +0 -0
  56. {github_security_report-0.10.0 → github_security_report-0.11.2}/src/github_security_report/rulesets.py +0 -0
  57. {github_security_report-0.10.0 → github_security_report-0.11.2}/src/github_security_report/runner.py +0 -0
  58. {github_security_report-0.10.0 → github_security_report-0.11.2}/src/github_security_report/scope.py +0 -0
  59. {github_security_report-0.10.0 → github_security_report-0.11.2}/src/github_security_report/severity.py +0 -0
  60. {github_security_report-0.10.0 → github_security_report-0.11.2}/src/github_security_report/templates/index.html.j2 +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: github-security-report
3
- Version: 0.10.0
3
+ Version: 0.11.2
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
@@ -29,14 +29,14 @@ Requires-Dist: jinja2==3.1.6
29
29
  Requires-Dist: jsonschema==4.26.0
30
30
  Requires-Dist: pyyaml==6.0.3
31
31
  Requires-Dist: rich==15.0.0
32
- Requires-Dist: typer==0.27.0
32
+ Requires-Dist: typer==0.27.1
33
33
  Provides-Extra: dev
34
34
  Requires-Dist: mypy==2.3.0; extra == 'dev'
35
35
  Requires-Dist: pytest-asyncio==1.4.0; extra == 'dev'
36
36
  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
- Requires-Dist: ruff==0.15.22; extra == 'dev'
39
+ Requires-Dist: ruff==0.16.2; extra == 'dev'
40
40
  Requires-Dist: syrupy==5.5.3; 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'
@@ -151,6 +151,20 @@ signal is skipped and every other part of the report is unaffected, so the
151
151
  minimal tokens below omit it. Grant the org-admin permission only if you want
152
152
  ruleset-based tool coverage.
153
153
 
154
+ A token without that permission gets a `404` from the endpoint, which the tool
155
+ reports at **INFO** — so it is invisible unless you pass `--verbose`:
156
+
157
+ ```text
158
+ org rulesets not readable for <org> (status 404); expected unless the token
159
+ carries the optional org-admin permission ...
160
+ ```
161
+
162
+ That line is informational, not a defect. Tools are still detected from the
163
+ code-scanning analyses they upload, so the report is identical unless a
164
+ repository is covered *solely* by a required-workflow ruleset whose workflow has
165
+ never run. A genuinely unexpected failure to read the rulesets (for example a
166
+ `5xx`) is still logged as a warning.
167
+
154
168
  ### Single organisation — fine-grained PAT
155
169
 
156
170
  A fine-grained PAT is bound to one resource owner, so it works for a report
@@ -166,6 +180,7 @@ organisation and **Repository access** set to *All repositories*, then grant:
166
180
  | Dependabot alerts | Open Dependabot vulnerability alerts |
167
181
  | Code scanning alerts | CodeQL / Scorecard / zizmor / aislop findings |
168
182
  | Secret scanning alerts | Open secret-scanning alerts |
183
+ | Issues | Open issues and their labels (GitHub Issues table) |
169
184
  | Administration | Dependabot enablement + security-updates status, and effective branch rules |
170
185
 
171
186
  **Organization permissions:**
@@ -324,7 +339,8 @@ out of the terminal and Slack while still publishing it to the Markdown and HTML
324
339
  Pages output. The valid category keys are: `codeql`, `scorecard`, `zizmor`,
325
340
  `aislop`, `dependabot_alerts`, `secret_scanning`, `dependabot_alerts_enabled`,
326
341
  `dependabot_updates_enabled`, `dependabot_cooldown`, `releases`,
327
- `mutable_releases`, `private_vulnerability_reporting`. Like the other `report`
342
+ `mutable_releases`, `private_vulnerability_reporting`, `github_issues`. Like the
343
+ other `report`
328
344
  settings, `categories` can be set
329
345
  globally and overridden per organisation (overrides merge key-by-key, so
330
346
  flipping one output leaves the rest untouched). The machine-readable
@@ -339,6 +355,192 @@ unless every org sharing that channel also disables it (this mirrors the
339
355
  most-generous `top_n` rule applied to the same grouping). The terminal, Markdown
340
356
  and HTML surfaces are per-org and are not affected by this union.
341
357
 
358
+ ### Per-category row limits
359
+
360
+ A category can also set its own `top_n`, capping that one table independently of
361
+ every other. Reach for this when one category is worth showing in full while the
362
+ rest stay short — set it to `0` for no limit at all:
363
+
364
+ ```json
365
+ {
366
+ "report": {
367
+ "top_n": 10,
368
+ "categories": {
369
+ "releases": { "top_n": 0 },
370
+ "codeql": { "top_n": 3 }
371
+ }
372
+ },
373
+ "organizations": [{ "name": "lfreleng-actions" }]
374
+ }
375
+ ```
376
+
377
+ Here Releases / Tagging lists every repository, CodeQL shows its worst three, and
378
+ every other category keeps the shared limit of 10. A category's `top_n` applies
379
+ to all four surfaces at once; combine it with `top_n_report` / `top_n_cli` /
380
+ `top_n_slack` to vary the fallback per surface.
381
+
382
+ The resolution order for one category on one surface, most specific first:
383
+
384
+ 1. `--top-n-report` / `--top-n-cli` / `--top-n-slack` (command line)
385
+ 2. `--top-n` (command line)
386
+ 3. `report.categories.<key>.top_n` (config)
387
+ 4. `report.top_n_report` / `top_n_cli` / `top_n_slack` (config)
388
+ 5. `report.top_n` (config, default `10`)
389
+
390
+ Command-line flags deliberately outrank the per-category configuration: a flag is
391
+ a decision about a single run, so `--top-n 5` caps every category even where the
392
+ config asked for an uncapped one. `0` means "no limit" at every level. In a
393
+ shared Slack channel the most generous value any contributing org configured for
394
+ that category wins, matching the visibility rule above.
395
+
396
+ On Slack, `0` is best-effort rather than absolute. Slack imposes hard structural
397
+ limits on a message — 50 blocks per post, 3,000 characters per text object (a
398
+ section body or a context note) and 150 for a header — and rejects the **whole**
399
+ post if any is breached, so an uncapped table would cost the entire digest rather
400
+ than merely overflowing. The digest therefore sizes itself to fit: repository
401
+ name lists are trimmed first, then table rows, and whatever is left out is
402
+ reported by the usual `… and N more` tally so the numbers on screen stay honest.
403
+ Counts are never dropped, only names and rows. The other three surfaces have no
404
+ Slack-style ceiling, but they still apply their own row limits — only the
405
+ `report.json` artifact is unconditionally complete. The digest links to the
406
+ GitHub Pages report whenever `pages_url` is set and short enough to render as a
407
+ link.
408
+
409
+ ### Per-category row ordering
410
+
411
+ Each table ships a sensible default ordering — largest backlog first, stalest
412
+ release first, and so on. `report.categories.<key>.sort` overrides it with a list
413
+ of column names, evaluated left to right:
414
+
415
+ ```json
416
+ {
417
+ "report": {
418
+ "categories": {
419
+ "github_issues": { "sort": ["untriaged", "bug", "total", "oldest"] }
420
+ }
421
+ },
422
+ "organizations": [{ "name": "lfreleng-actions" }]
423
+ }
424
+ ```
425
+
426
+ That ranks the Issues table by untriaged count, breaking ties on Bug, then on
427
+ total open issues, then on the oldest issue.
428
+
429
+ - Names match column headers **case-insensitively**, so `untriaged` finds
430
+ `Untriaged` and a custom `issue_labels` column such as `Regression` works with
431
+ no extra configuration. `repository` sorts by repository name.
432
+ - **Direction is implicit by type**: numeric columns descend (most first, which
433
+ is also oldest-first for an age column) and text columns ascend.
434
+ - A leading **`-` forces descending** and **`+` forces ascending**, so
435
+ `["+total"]` lists the smallest backlogs first.
436
+ - A cell with **no value to sort on** — an `Oldest` of `unknown`, say — stays at
437
+ the bottom whichever direction you choose. Missing is not the same as small.
438
+ - The repository name is always applied as the final tiebreaker, so rows that
439
+ are equal under every configured term still order deterministically.
440
+ - An unrecognised column name is logged and skipped rather than failing the run.
441
+ - Omitting `sort` keeps the table's own default ordering. This matters: some
442
+ defaults rank on values that are never displayed as a column — Releases /
443
+ Tagging ranks on *missing* release and tag signals — so they cannot be
444
+ expressed as a column list.
445
+
446
+ Ordering is resolved once, when the report is built, so every surface and
447
+ `report.json` agree. It applies to the generic tables (GitHub Issues, Releases /
448
+ Tagging, Mutable Releases, the Dependabot posture tables). The severity signal
449
+ tables keep their own ranking, which encodes domain logic a column sort would
450
+ flatten — Scorecard cascades through the worst populated severity rung so a lone
451
+ Critical is never buried by a weaker repository with a lower score.
452
+
453
+ ### GitHub Issues
454
+
455
+ The `github_issues` category counts each repository's **open issues**, split by
456
+ label into columns. It reads from the same batched GraphQL prefetch as the
457
+ release and Dependabot data, so it costs no extra API requests:
458
+
459
+ ```text
460
+ GitHub Issues
461
+ ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━┳━━━━━━━━━┳━━━━━━┳━━━━━━━┳━━━━━━━━━━━┳━━━━━━━┳━━━━━━━━━┓
462
+ ┃ Repository ┃ Bug ┃ Feature ┃ Docs ┃ Other ┃ Untriaged ┃ Total ┃ Oldest ┃
463
+ ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━╇━━━━━━━━━╇━━━━━━╇━━━━━━━╇━━━━━━━━━━━╇━━━━━━━╇━━━━━━━━━┩
464
+ │ .github │ 1 │ 0 │ 0 │ 0 │ 10 │ 11 │ 16 days │
465
+ │ tag-validate-action │ 0 │ 0 │ 0 │ 0 │ 8 │ 8 │ 25 days │
466
+ │ security-workflows │ 0 │ 5 │ 1 │ 0 │ 0 │ 6 │ today │
467
+ │ github-security-report-action │ 0 │ 0 │ 0 │ 3 │ 1 │ 4 │ 52 days │
468
+ │ dependamerge │ 0 │ 1 │ 0 │ 1 │ 1 │ 3 │ 52 days │
469
+ ├───────────────────────────────┼─────┼─────────┼──────┼───────┼───────────┼───────┼─────────┤
470
+ │ Total │ 1 │ 6 │ 1 │ 4 │ 20 │ 32 │ │
471
+ └───────────────────────────────┴─────┴─────────┴──────┴───────┴───────────┴───────┴─────────┘
472
+ … and 11 more
473
+ ❌ 16 With open issues
474
+ ✅ 87 No open issues
475
+ ```
476
+
477
+ That is a real run of `lfreleng-actions` under `top_n: 5`. The totals row sums
478
+ the rows actually **displayed**, matching the offender tables, so it stays
479
+ consistent with a truncated view; `… and 11 more` plus the five listed rows
480
+ reconcile with the `❌ 16` in the footer.
481
+
482
+ Two columns are always present and are not configurable:
483
+
484
+ - **Other** — the issue is labelled, but with nothing you asked about.
485
+ - **Untriaged** — the issue has no labels at all. This is the column to watch:
486
+ an unlabelled issue is one nobody has categorised.
487
+
488
+ Both names are reserved, as are `Repository`, `Total` and `Oldest`: configuring a
489
+ column with one of those names is rejected, because it would either share a
490
+ counter with the implicit column — stopping the class columns summing to `Total`
491
+ — or duplicate a header, which would also make `sort: ["repository"]` resolve to
492
+ a count column instead of the repository name. Column names must additionally be
493
+ non-blank, unpadded, distinct case-insensitively (`sort` matches them that way,
494
+ and strips its terms), and free of `|`, backticks and control characters, which
495
+ would corrupt the Markdown table or Slack code fence they are rendered into.
496
+
497
+ The remaining columns come from `report.issue_labels`, which maps a column name
498
+ to the issue labels that count towards it. An issue counts **once**, under the
499
+ first column whose labels it carries, so the columns always sum to the classified
500
+ total. Matching is case-insensitive on the whole label name, so `docs` does not
501
+ swallow an unrelated `docs-needed`. The default is:
502
+
503
+ ```json
504
+ {
505
+ "report": {
506
+ "issue_labels": {
507
+ "Bug": ["bug", "defect"],
508
+ "Feature": ["feature", "enhancement"],
509
+ "Docs": ["documentation", "docs"]
510
+ }
511
+ },
512
+ "organizations": [{ "name": "lfreleng-actions" }]
513
+ }
514
+ ```
515
+
516
+ Unlike `ruleset_workflows`, a configured `issue_labels` **replaces** the default
517
+ rather than merging into it — the mapping defines a coherent set of table
518
+ columns, so merging would leave behind default columns you deliberately left out.
519
+
520
+ Repositories with no open issues are counted in the `✅ No open issues` footer
521
+ rather than listed. Rows rank by total open issues, then by Untriaged. Pull
522
+ requests are **not** counted: the GraphQL `issues` connection excludes them.
523
+
524
+ > **Accuracy note.** `Total` is exact at any backlog size, as is `Oldest`
525
+ > wherever an age is shown. The label columns are computed from a bounded,
526
+ > oldest-first window of each repository's open issues (25 issues, 5 labels
527
+ > each) that keeps the query well inside GitHub's GraphQL rate-limit budget. A
528
+ > repository whose label breakdown is partial shows a trailing `+` on its
529
+ > `Oldest` cell. That covers a backlog exceeding the issue window, and any issue
530
+ > whose classification a label beyond the label window could have changed —
531
+ > which is every classification except a match on the *first* configured column,
532
+ > since columns are matched in declaration order and an unseen label could
533
+ > belong to an earlier one. An issue whose labels could not be read at all is
534
+ > left out of the class columns entirely rather than counted as `Untriaged`. An
535
+ > `Oldest` of `unknown` means the oldest issue came back unreadable or undated;
536
+ > it can still carry the `+`.
537
+
538
+ **Permissions.** A fine-grained PAT needs **Issues: read** for this table; a
539
+ classic PAT's `repo` scope already covers it. Without it GitHub serves the query
540
+ with HTTP 200 and this one field null, so affected repositories are reported as
541
+ `❓ Unknown` rather than counted as having no open issues — an unreadable backlog
542
+ is never presented as a clean one.
543
+
342
544
  ### Organisation feature gating
343
545
 
344
546
  The workflow-driven signals (OpenSSF Scorecard, zizmor, aislop) need
@@ -107,6 +107,20 @@ signal is skipped and every other part of the report is unaffected, so the
107
107
  minimal tokens below omit it. Grant the org-admin permission only if you want
108
108
  ruleset-based tool coverage.
109
109
 
110
+ A token without that permission gets a `404` from the endpoint, which the tool
111
+ reports at **INFO** — so it is invisible unless you pass `--verbose`:
112
+
113
+ ```text
114
+ org rulesets not readable for <org> (status 404); expected unless the token
115
+ carries the optional org-admin permission ...
116
+ ```
117
+
118
+ That line is informational, not a defect. Tools are still detected from the
119
+ code-scanning analyses they upload, so the report is identical unless a
120
+ repository is covered *solely* by a required-workflow ruleset whose workflow has
121
+ never run. A genuinely unexpected failure to read the rulesets (for example a
122
+ `5xx`) is still logged as a warning.
123
+
110
124
  ### Single organisation — fine-grained PAT
111
125
 
112
126
  A fine-grained PAT is bound to one resource owner, so it works for a report
@@ -122,6 +136,7 @@ organisation and **Repository access** set to *All repositories*, then grant:
122
136
  | Dependabot alerts | Open Dependabot vulnerability alerts |
123
137
  | Code scanning alerts | CodeQL / Scorecard / zizmor / aislop findings |
124
138
  | Secret scanning alerts | Open secret-scanning alerts |
139
+ | Issues | Open issues and their labels (GitHub Issues table) |
125
140
  | Administration | Dependabot enablement + security-updates status, and effective branch rules |
126
141
 
127
142
  **Organization permissions:**
@@ -280,7 +295,8 @@ out of the terminal and Slack while still publishing it to the Markdown and HTML
280
295
  Pages output. The valid category keys are: `codeql`, `scorecard`, `zizmor`,
281
296
  `aislop`, `dependabot_alerts`, `secret_scanning`, `dependabot_alerts_enabled`,
282
297
  `dependabot_updates_enabled`, `dependabot_cooldown`, `releases`,
283
- `mutable_releases`, `private_vulnerability_reporting`. Like the other `report`
298
+ `mutable_releases`, `private_vulnerability_reporting`, `github_issues`. Like the
299
+ other `report`
284
300
  settings, `categories` can be set
285
301
  globally and overridden per organisation (overrides merge key-by-key, so
286
302
  flipping one output leaves the rest untouched). The machine-readable
@@ -295,6 +311,192 @@ unless every org sharing that channel also disables it (this mirrors the
295
311
  most-generous `top_n` rule applied to the same grouping). The terminal, Markdown
296
312
  and HTML surfaces are per-org and are not affected by this union.
297
313
 
314
+ ### Per-category row limits
315
+
316
+ A category can also set its own `top_n`, capping that one table independently of
317
+ every other. Reach for this when one category is worth showing in full while the
318
+ rest stay short — set it to `0` for no limit at all:
319
+
320
+ ```json
321
+ {
322
+ "report": {
323
+ "top_n": 10,
324
+ "categories": {
325
+ "releases": { "top_n": 0 },
326
+ "codeql": { "top_n": 3 }
327
+ }
328
+ },
329
+ "organizations": [{ "name": "lfreleng-actions" }]
330
+ }
331
+ ```
332
+
333
+ Here Releases / Tagging lists every repository, CodeQL shows its worst three, and
334
+ every other category keeps the shared limit of 10. A category's `top_n` applies
335
+ to all four surfaces at once; combine it with `top_n_report` / `top_n_cli` /
336
+ `top_n_slack` to vary the fallback per surface.
337
+
338
+ The resolution order for one category on one surface, most specific first:
339
+
340
+ 1. `--top-n-report` / `--top-n-cli` / `--top-n-slack` (command line)
341
+ 2. `--top-n` (command line)
342
+ 3. `report.categories.<key>.top_n` (config)
343
+ 4. `report.top_n_report` / `top_n_cli` / `top_n_slack` (config)
344
+ 5. `report.top_n` (config, default `10`)
345
+
346
+ Command-line flags deliberately outrank the per-category configuration: a flag is
347
+ a decision about a single run, so `--top-n 5` caps every category even where the
348
+ config asked for an uncapped one. `0` means "no limit" at every level. In a
349
+ shared Slack channel the most generous value any contributing org configured for
350
+ that category wins, matching the visibility rule above.
351
+
352
+ On Slack, `0` is best-effort rather than absolute. Slack imposes hard structural
353
+ limits on a message — 50 blocks per post, 3,000 characters per text object (a
354
+ section body or a context note) and 150 for a header — and rejects the **whole**
355
+ post if any is breached, so an uncapped table would cost the entire digest rather
356
+ than merely overflowing. The digest therefore sizes itself to fit: repository
357
+ name lists are trimmed first, then table rows, and whatever is left out is
358
+ reported by the usual `… and N more` tally so the numbers on screen stay honest.
359
+ Counts are never dropped, only names and rows. The other three surfaces have no
360
+ Slack-style ceiling, but they still apply their own row limits — only the
361
+ `report.json` artifact is unconditionally complete. The digest links to the
362
+ GitHub Pages report whenever `pages_url` is set and short enough to render as a
363
+ link.
364
+
365
+ ### Per-category row ordering
366
+
367
+ Each table ships a sensible default ordering — largest backlog first, stalest
368
+ release first, and so on. `report.categories.<key>.sort` overrides it with a list
369
+ of column names, evaluated left to right:
370
+
371
+ ```json
372
+ {
373
+ "report": {
374
+ "categories": {
375
+ "github_issues": { "sort": ["untriaged", "bug", "total", "oldest"] }
376
+ }
377
+ },
378
+ "organizations": [{ "name": "lfreleng-actions" }]
379
+ }
380
+ ```
381
+
382
+ That ranks the Issues table by untriaged count, breaking ties on Bug, then on
383
+ total open issues, then on the oldest issue.
384
+
385
+ - Names match column headers **case-insensitively**, so `untriaged` finds
386
+ `Untriaged` and a custom `issue_labels` column such as `Regression` works with
387
+ no extra configuration. `repository` sorts by repository name.
388
+ - **Direction is implicit by type**: numeric columns descend (most first, which
389
+ is also oldest-first for an age column) and text columns ascend.
390
+ - A leading **`-` forces descending** and **`+` forces ascending**, so
391
+ `["+total"]` lists the smallest backlogs first.
392
+ - A cell with **no value to sort on** — an `Oldest` of `unknown`, say — stays at
393
+ the bottom whichever direction you choose. Missing is not the same as small.
394
+ - The repository name is always applied as the final tiebreaker, so rows that
395
+ are equal under every configured term still order deterministically.
396
+ - An unrecognised column name is logged and skipped rather than failing the run.
397
+ - Omitting `sort` keeps the table's own default ordering. This matters: some
398
+ defaults rank on values that are never displayed as a column — Releases /
399
+ Tagging ranks on *missing* release and tag signals — so they cannot be
400
+ expressed as a column list.
401
+
402
+ Ordering is resolved once, when the report is built, so every surface and
403
+ `report.json` agree. It applies to the generic tables (GitHub Issues, Releases /
404
+ Tagging, Mutable Releases, the Dependabot posture tables). The severity signal
405
+ tables keep their own ranking, which encodes domain logic a column sort would
406
+ flatten — Scorecard cascades through the worst populated severity rung so a lone
407
+ Critical is never buried by a weaker repository with a lower score.
408
+
409
+ ### GitHub Issues
410
+
411
+ The `github_issues` category counts each repository's **open issues**, split by
412
+ label into columns. It reads from the same batched GraphQL prefetch as the
413
+ release and Dependabot data, so it costs no extra API requests:
414
+
415
+ ```text
416
+ GitHub Issues
417
+ ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━┳━━━━━━━━━┳━━━━━━┳━━━━━━━┳━━━━━━━━━━━┳━━━━━━━┳━━━━━━━━━┓
418
+ ┃ Repository ┃ Bug ┃ Feature ┃ Docs ┃ Other ┃ Untriaged ┃ Total ┃ Oldest ┃
419
+ ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━╇━━━━━━━━━╇━━━━━━╇━━━━━━━╇━━━━━━━━━━━╇━━━━━━━╇━━━━━━━━━┩
420
+ │ .github │ 1 │ 0 │ 0 │ 0 │ 10 │ 11 │ 16 days │
421
+ │ tag-validate-action │ 0 │ 0 │ 0 │ 0 │ 8 │ 8 │ 25 days │
422
+ │ security-workflows │ 0 │ 5 │ 1 │ 0 │ 0 │ 6 │ today │
423
+ │ github-security-report-action │ 0 │ 0 │ 0 │ 3 │ 1 │ 4 │ 52 days │
424
+ │ dependamerge │ 0 │ 1 │ 0 │ 1 │ 1 │ 3 │ 52 days │
425
+ ├───────────────────────────────┼─────┼─────────┼──────┼───────┼───────────┼───────┼─────────┤
426
+ │ Total │ 1 │ 6 │ 1 │ 4 │ 20 │ 32 │ │
427
+ └───────────────────────────────┴─────┴─────────┴──────┴───────┴───────────┴───────┴─────────┘
428
+ … and 11 more
429
+ ❌ 16 With open issues
430
+ ✅ 87 No open issues
431
+ ```
432
+
433
+ That is a real run of `lfreleng-actions` under `top_n: 5`. The totals row sums
434
+ the rows actually **displayed**, matching the offender tables, so it stays
435
+ consistent with a truncated view; `… and 11 more` plus the five listed rows
436
+ reconcile with the `❌ 16` in the footer.
437
+
438
+ Two columns are always present and are not configurable:
439
+
440
+ - **Other** — the issue is labelled, but with nothing you asked about.
441
+ - **Untriaged** — the issue has no labels at all. This is the column to watch:
442
+ an unlabelled issue is one nobody has categorised.
443
+
444
+ Both names are reserved, as are `Repository`, `Total` and `Oldest`: configuring a
445
+ column with one of those names is rejected, because it would either share a
446
+ counter with the implicit column — stopping the class columns summing to `Total`
447
+ — or duplicate a header, which would also make `sort: ["repository"]` resolve to
448
+ a count column instead of the repository name. Column names must additionally be
449
+ non-blank, unpadded, distinct case-insensitively (`sort` matches them that way,
450
+ and strips its terms), and free of `|`, backticks and control characters, which
451
+ would corrupt the Markdown table or Slack code fence they are rendered into.
452
+
453
+ The remaining columns come from `report.issue_labels`, which maps a column name
454
+ to the issue labels that count towards it. An issue counts **once**, under the
455
+ first column whose labels it carries, so the columns always sum to the classified
456
+ total. Matching is case-insensitive on the whole label name, so `docs` does not
457
+ swallow an unrelated `docs-needed`. The default is:
458
+
459
+ ```json
460
+ {
461
+ "report": {
462
+ "issue_labels": {
463
+ "Bug": ["bug", "defect"],
464
+ "Feature": ["feature", "enhancement"],
465
+ "Docs": ["documentation", "docs"]
466
+ }
467
+ },
468
+ "organizations": [{ "name": "lfreleng-actions" }]
469
+ }
470
+ ```
471
+
472
+ Unlike `ruleset_workflows`, a configured `issue_labels` **replaces** the default
473
+ rather than merging into it — the mapping defines a coherent set of table
474
+ columns, so merging would leave behind default columns you deliberately left out.
475
+
476
+ Repositories with no open issues are counted in the `✅ No open issues` footer
477
+ rather than listed. Rows rank by total open issues, then by Untriaged. Pull
478
+ requests are **not** counted: the GraphQL `issues` connection excludes them.
479
+
480
+ > **Accuracy note.** `Total` is exact at any backlog size, as is `Oldest`
481
+ > wherever an age is shown. The label columns are computed from a bounded,
482
+ > oldest-first window of each repository's open issues (25 issues, 5 labels
483
+ > each) that keeps the query well inside GitHub's GraphQL rate-limit budget. A
484
+ > repository whose label breakdown is partial shows a trailing `+` on its
485
+ > `Oldest` cell. That covers a backlog exceeding the issue window, and any issue
486
+ > whose classification a label beyond the label window could have changed —
487
+ > which is every classification except a match on the *first* configured column,
488
+ > since columns are matched in declaration order and an unseen label could
489
+ > belong to an earlier one. An issue whose labels could not be read at all is
490
+ > left out of the class columns entirely rather than counted as `Untriaged`. An
491
+ > `Oldest` of `unknown` means the oldest issue came back unreadable or undated;
492
+ > it can still carry the `+`.
493
+
494
+ **Permissions.** A fine-grained PAT needs **Issues: read** for this table; a
495
+ classic PAT's `repo` scope already covers it. Without it GitHub serves the query
496
+ with HTTP 200 and this one field null, so affected repositories are reported as
497
+ `❓ Unknown` rather than counted as having no open issues — an unreadable backlog
498
+ is never presented as a clean one.
499
+
298
500
  ### Organisation feature gating
299
501
 
300
502
  The workflow-driven signals (OpenSSF Scorecard, zizmor, aislop) need
@@ -45,7 +45,7 @@ keywords = [
45
45
  ]
46
46
  dependencies = [
47
47
  "httpx[http2]==0.28.1",
48
- "typer==0.27.0",
48
+ "typer==0.27.1",
49
49
  "rich==15.0.0",
50
50
  "jinja2==3.1.6",
51
51
  "jsonschema==4.26.0",
@@ -65,7 +65,7 @@ dev = [
65
65
  "respx==0.23.1",
66
66
  "syrupy==5.5.3",
67
67
  "mypy==2.3.0",
68
- "ruff==0.15.22",
68
+ "ruff==0.16.2",
69
69
  "types-jsonschema==4.26.0.20260518",
70
70
  "types-PyYAML==6.0.12.20250915",
71
71
  ]
@@ -97,7 +97,7 @@ dev = [
97
97
  "respx==0.23.1",
98
98
  "syrupy==5.5.3",
99
99
  "mypy==2.3.0",
100
- "ruff==0.15.22",
100
+ "ruff==0.16.2",
101
101
  "types-jsonschema==4.26.0.20260518",
102
102
  "types-PyYAML==6.0.12.20250915",
103
103
  ]
@@ -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.10.0'
22
- __version_tuple__ = version_tuple = (0, 10, 0)
21
+ __version__ = version = '0.11.2'
22
+ __version_tuple__ = version_tuple = (0, 11, 2)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -40,6 +40,7 @@ class CategoryKey(str, Enum):
40
40
  RELEASES = "releases"
41
41
  MUTABLE_RELEASES = "mutable_releases"
42
42
  PRIVATE_VULNERABILITY_REPORTING = "private_vulnerability_reporting"
43
+ GITHUB_ISSUES = "github_issues"
43
44
 
44
45
 
45
46
  @dataclass(frozen=True)
@@ -48,7 +49,10 @@ class CategoryMeta:
48
49
 
49
50
  ``pass_label`` names the healthy state (e.g. ``"Clean"``, ``"Immutable"``)
50
51
  and is what the summary footer reports as ``All <pass_label>`` when nothing
51
- needs attention. ``fail_label`` names the actionable state for categories
52
+ needs attention. That collapse wants an adjectival label; a category whose
53
+ counted wording is a noun phrase ("12 No open issues") sets
54
+ ``pass_all_label`` to the word that reads correctly after "All" instead.
55
+ ``fail_label`` names the actionable state for categories
52
56
  with a binary pass/fail axis (enablement, cooldown, mutability, release
53
57
  freshness); it is ``None`` for the severity-ranked signals, whose offenders
54
58
  are enumerated in the table itself rather than as a single failure count.
@@ -63,6 +67,9 @@ class CategoryMeta:
63
67
  fail_label: str | None
64
68
  url: str
65
69
  description: str = ""
70
+ # Alternative pass wording for the collapsed "All <label>" footer line,
71
+ # when the counted wording would not read grammatically after "All".
72
+ pass_all_label: str | None = None
66
73
  # The lowest finding severity that counts as a failure for this category.
67
74
  # A repository fails (appears as an offender) only when it carries a finding
68
75
  # at or above this rung; findings below it fold into the clean count. The
@@ -252,6 +259,23 @@ _CATEGORIES: dict[CategoryKey, CategoryMeta] = {
252
259
  "instead of disclosing them publicly."
253
260
  ),
254
261
  ),
262
+ CategoryKey.GITHUB_ISSUES: CategoryMeta(
263
+ key=CategoryKey.GITHUB_ISSUES,
264
+ title="GitHub Issues",
265
+ pass_label="No open issues",
266
+ # "All No open issues" does not parse; the collapsed line reads
267
+ # "All Clean", matching the other categories' vocabulary.
268
+ pass_all_label="Clean",
269
+ fail_label="With open issues",
270
+ url="https://docs.github.com/en/issues",
271
+ description=(
272
+ "Open issues per repository, split by label into the configured "
273
+ "classes. Issues carrying none of the configured labels count as "
274
+ "Other; issues with no labels at all count as Untriaged, which is "
275
+ "the column to watch -- an unlabelled issue has not been triaged. "
276
+ "Ranked by total open issues, then by Untriaged."
277
+ ),
278
+ ),
255
279
  }
256
280
 
257
281
 
@@ -0,0 +1,66 @@
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
+ The command surface is split across:
11
+
12
+ - :mod:`cli.app` -- the Typer application, options and validation
13
+ - :mod:`cli.modes` -- the org, repo and remediate run modes
14
+ - :mod:`cli.outputs` -- offender limits, category visibility, file writing
15
+ - :mod:`cli.serialise` -- the machine-readable ``report.json`` shape
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from github_security_report.cli.app import (
21
+ _version_callback,
22
+ app,
23
+ main,
24
+ remediate,
25
+ report,
26
+ )
27
+ from github_security_report.cli.modes import (
28
+ OrgRunOptions,
29
+ ReleaseOverrides,
30
+ _abort_network,
31
+ _load_config,
32
+ _run_org,
33
+ _run_remediate,
34
+ _run_repo,
35
+ )
36
+ from github_security_report.cli.outputs import (
37
+ TopNLimits,
38
+ _safe_component,
39
+ repo_outputs,
40
+ write_org_files,
41
+ )
42
+ from github_security_report.cli.serialise import _org_to_dict, _table_to_dict
43
+
44
+ # The package façade: every name previously importable from
45
+ # ``github_security_report.cli`` still is, including the private helpers the
46
+ # tests reach for, so splitting the module changed no caller's import path.
47
+ __all__ = [
48
+ "OrgRunOptions",
49
+ "ReleaseOverrides",
50
+ "TopNLimits",
51
+ "_abort_network",
52
+ "_load_config",
53
+ "_org_to_dict",
54
+ "_run_org",
55
+ "_run_remediate",
56
+ "_run_repo",
57
+ "_safe_component",
58
+ "_table_to_dict",
59
+ "_version_callback",
60
+ "app",
61
+ "main",
62
+ "remediate",
63
+ "repo_outputs",
64
+ "report",
65
+ "write_org_files",
66
+ ]
@@ -0,0 +1,16 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 The Linux Foundation
3
+ """Support ``python -m github_security_report.cli``.
4
+
5
+ The console script declared in ``pyproject.toml`` is the usual entry point, but
6
+ the module form worked while the CLI was a single module and is preserved here:
7
+ a package cannot be executed through the ``if __name__ == "__main__"`` guard in
8
+ its ``__init__``, so it needs this file instead.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from github_security_report.cli.app import app
14
+
15
+ if __name__ == "__main__": # pragma: no cover
16
+ app()