github-security-report 0.16.0__tar.gz → 0.18.0__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 (94) hide show
  1. {github_security_report-0.16.0 → github_security_report-0.18.0}/PKG-INFO +113 -3
  2. {github_security_report-0.16.0 → github_security_report-0.18.0}/README.md +111 -1
  3. {github_security_report-0.16.0 → github_security_report-0.18.0}/pyproject.toml +2 -2
  4. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/_version.py +2 -2
  5. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/cli/app.py +30 -5
  6. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/cli/modes.py +93 -4
  7. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/client/codeql_parsers.py +11 -1
  8. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/client/reads.py +48 -0
  9. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/client/writes.py +128 -0
  10. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/codeql/__init__.py +12 -0
  11. github_security_report-0.18.0/src/github_security_report/codeql/cleanup.py +123 -0
  12. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/codeql/facts.py +88 -0
  13. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/codeql/tables.py +27 -20
  14. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/collect/__init__.py +6 -0
  15. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/collect/extras.py +5 -0
  16. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/collect/org.py +19 -51
  17. github_security_report-0.18.0/src/github_security_report/collect/scoping.py +122 -0
  18. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/config/loader.py +27 -16
  19. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/config/models.py +6 -0
  20. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/config/schema.py +6 -0
  21. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/issues.py +73 -3
  22. github_security_report-0.16.0/src/github_security_report/remediate.py → github_security_report-0.18.0/src/github_security_report/remediate/__init__.py +109 -99
  23. github_security_report-0.18.0/src/github_security_report/remediate/codeql.py +99 -0
  24. github_security_report-0.18.0/src/github_security_report/remediate/model.py +154 -0
  25. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/render/terminal.py +45 -23
  26. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/report/aggregate.py +10 -0
  27. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/scope.py +69 -2
  28. {github_security_report-0.16.0 → github_security_report-0.18.0}/.gitignore +0 -0
  29. {github_security_report-0.16.0 → github_security_report-0.18.0}/LICENSE +0 -0
  30. {github_security_report-0.16.0 → github_security_report-0.18.0}/LICENSES/Apache-2.0.txt +0 -0
  31. {github_security_report-0.16.0 → github_security_report-0.18.0}/scripts/README.md +0 -0
  32. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/__init__.py +0 -0
  33. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/authors.py +0 -0
  34. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/categories/__init__.py +0 -0
  35. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/categories/keys.py +0 -0
  36. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/categories/signals.py +0 -0
  37. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/categories/tables.py +0 -0
  38. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/classify.py +0 -0
  39. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/cli/__init__.py +0 -0
  40. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/cli/__main__.py +0 -0
  41. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/cli/boundary.py +0 -0
  42. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/cli/options.py +0 -0
  43. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/cli/outputs.py +0 -0
  44. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/cli/publish.py +0 -0
  45. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/cli/serialise.py +0 -0
  46. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/client/__init__.py +0 -0
  47. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/client/alerts.py +0 -0
  48. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/client/batch_errors.py +0 -0
  49. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/client/copilot.py +0 -0
  50. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/client/endpoints.py +0 -0
  51. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/client/errors.py +0 -0
  52. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/client/org_reads.py +0 -0
  53. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/client/parsers.py +0 -0
  54. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/client/queries.py +0 -0
  55. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/client/reviews.py +0 -0
  56. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/client/transport.py +0 -0
  57. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/collect/codeql.py +0 -0
  58. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/collect/context.py +0 -0
  59. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/collect/protocols.py +0 -0
  60. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/collect/repo.py +0 -0
  61. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/config/__init__.py +0 -0
  62. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/config/order.py +0 -0
  63. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/gating.py +0 -0
  64. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/gitctx.py +0 -0
  65. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/layout.py +0 -0
  66. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/models.py +0 -0
  67. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/ordering.py +0 -0
  68. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/posture/__init__.py +0 -0
  69. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/posture/enablement.py +0 -0
  70. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/posture/facts.py +0 -0
  71. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/posture/releases.py +0 -0
  72. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/pulls/__init__.py +0 -0
  73. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/pulls/columns.py +0 -0
  74. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/pulls/counting.py +0 -0
  75. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/pulls/presentation.py +0 -0
  76. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/pulls/table.py +0 -0
  77. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/py.typed +0 -0
  78. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/ranking.py +0 -0
  79. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/render/__init__.py +0 -0
  80. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/render/html.py +0 -0
  81. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/render/markdown.py +0 -0
  82. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/render/slack.py +0 -0
  83. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/render/slack_limits.py +0 -0
  84. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/report/__init__.py +0 -0
  85. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/report/display.py +0 -0
  86. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/report/signals.py +0 -0
  87. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/report/tables.py +0 -0
  88. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/rulesets.py +0 -0
  89. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/runner.py +0 -0
  90. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/secret_patterns.py +0 -0
  91. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/severity.py +0 -0
  92. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/summary.py +0 -0
  93. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/templates/index.html.j2 +0 -0
  94. {github_security_report-0.16.0 → github_security_report-0.18.0}/src/github_security_report/templates/report.html.j2 +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: github-security-report
3
- Version: 0.16.0
3
+ Version: 0.18.0
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
@@ -36,7 +36,7 @@ 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.16.8; extra == 'dev'
39
+ Requires-Dist: ruff==0.16.9; extra == 'dev'
40
40
  Requires-Dist: syrupy==6.1.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'
@@ -345,6 +345,8 @@ environment-variable name, never embedded.
345
345
  "repo_min_age_days": 28,
346
346
  "release_max_age_days": 60,
347
347
  "codeql_stale_days": 30,
348
+ "issue_age_warn_days": 30,
349
+ "issue_age_error_days": 60,
348
350
  "graph_batch": 10,
349
351
  "order": { "style": "auto" }
350
352
  },
@@ -407,7 +409,8 @@ organisation, as `--top-n` does.
407
409
 
408
410
  The per-org `exclude` list removes repositories from analysis entirely; they are
409
411
  reported as **excluded** (distinct from "not enabled"), so an intentional
410
- exclusion is visible rather than silently dropped.
412
+ exclusion is visible rather than silently dropped. Entries match repository
413
+ names case-insensitively, as GitHub does.
411
414
 
412
415
  Every category reports those exclusions beneath its counts, so an
413
416
  organisation-wide list repeats under each one. `report.excluded_display`
@@ -836,6 +839,38 @@ with HTTP 200 and this one field null, so affected repositories are reported as
836
839
  `❓ Unknown` rather than counted as having no open issues — an unreadable backlog
837
840
  is never presented as a clean one.
838
841
 
842
+ #### Issue colours and age thresholds
843
+
844
+ On the terminal the counts are coloured so the table reads at a glance: `Bug`
845
+ and `Untriaged` **red**, `Docs` **green**. As in the Pull Requests table, only
846
+ **non-zero** counts are coloured, so the eye lands on the rows with something
847
+ in them. The colours follow the column **name** (case-insensitively), so they
848
+ apply to a custom `issue_labels` that keeps a `Bug` or `Docs` column and simply
849
+ do not appear for one that renames them.
850
+
851
+ `Oldest` is coloured against two age thresholds, in days:
852
+
853
+ ```json
854
+ {
855
+ "report": {
856
+ "issue_age_warn_days": 30,
857
+ "issue_age_error_days": 60
858
+ },
859
+ "organizations": [{ "name": "lfreleng-actions" }]
860
+ }
861
+ ```
862
+
863
+ - **Green** while the oldest open issue is at most `issue_age_warn_days`
864
+ (default `30`) old.
865
+ - **Yellow** once it is older than `issue_age_warn_days`.
866
+ - **Red** once it is older than `issue_age_error_days` (default `60`).
867
+
868
+ An `unknown` age is never coloured. Either threshold may be set to `0` to
869
+ switch that level off; with both off the column is left uncoloured.
870
+ `issue_age_error_days` must be **strictly greater** than `issue_age_warn_days`
871
+ (or `0`), otherwise the yellow level could never be reached, so it is rejected
872
+ at load. Both are settable per organisation.
873
+
839
874
  ### Pull Requests
840
875
 
841
876
  The `pull_requests` category counts each repository's **open pull requests**,
@@ -1519,8 +1554,30 @@ uvx github-security-report remediate \
1519
1554
  # Limit to specific categories (repeatable).
1520
1555
  uvx github-security-report remediate --org lfreleng-actions \
1521
1556
  --category codeql --category private_vulnerability_reporting --apply
1557
+
1558
+ # Limit to specific repositories, for any category (comma-separated and/or
1559
+ # repeatable; `name` in any configured org, or `owner/name`).
1560
+ uvx github-security-report remediate --org lfreleng-actions \
1561
+ --repos dependamerge,python-nss-ng --apply
1522
1562
  ```
1523
1563
 
1564
+ `--repos` narrows the run itself, not just its output. Everything done per
1565
+ repository (the feature probes, the batched prefetch, the CodeQL history walk
1566
+ and every write) covers only the named repositories, so a targeted run skips
1567
+ the bulk of a full one. The organisation-wide alert sweeps are the exception:
1568
+ each is a single org-bulk request, so its cost follows the organisation's open
1569
+ alert backlog rather than its repository count. It combines with
1570
+ `--category`. Every name is checked against every configured organisation
1571
+ before anything is written: a name that matches no repository (almost always
1572
+ a typo), or one that matches only repositories the configuration excludes
1573
+ (the `exclude` list, archived, fork, template or test), stops the run with
1574
+ exit code 2 and says which. A bare name excluded in one organisation but in
1575
+ scope in another runs against the in-scope one only; naming a repository never
1576
+ brings an excluded one back. A `--repos` value that names nothing also stops
1577
+ the run, rather than falling back to every repository. The run then collects
1578
+ exactly the repositories that check validated, without listing the
1579
+ organisation again, and prints `Limited to:` beneath its heading.
1580
+
1524
1581
  The remediable categories are the simple on/off features with a documented
1525
1582
  enablement endpoint:
1526
1583
 
@@ -1537,6 +1594,59 @@ Qualitative findings (Scorecard, zizmor, open Dependabot alerts, cooldown,
1537
1594
  release freshness/mutability) are reported but not auto-remediated. Remediation
1538
1595
  is organisation-scoped (`--scope org`, the default and only supported scope).
1539
1596
 
1597
+ ### Cleaning up stale CodeQL configurations
1598
+
1599
+ One category is **destructive**, so it runs only when named. The no-argument
1600
+ run never includes it:
1601
+
1602
+ ```bash
1603
+ # Preview: which configurations would go, and which are refused and why.
1604
+ uvx github-security-report remediate --org lfreleng-actions \
1605
+ --category codeql_stale_configurations
1606
+
1607
+ # Apply.
1608
+ uvx github-security-report remediate --org lfreleng-actions \
1609
+ --category codeql_stale_configurations --apply
1610
+ ```
1611
+
1612
+ It acts on the rows of **CodeQL: Stale Configurations**, according to their
1613
+ cause:
1614
+
1615
+ <!-- markdownlint-disable MD013 -->
1616
+
1617
+ | Cause | Action |
1618
+ | ----- | ------ |
1619
+ | Default setup disabled; workflow removed; superseded by default setup; language removed from default setup | **Delete** the configuration: it can never upload again |
1620
+ | Workflow disabled after inactivity | **Re-enable** the workflow, restoring the scan |
1621
+ | Analyses failing, default setup changing state or not uploading, uploaded outside GitHub Actions, workflow disabled by hand, active but not uploading, or state unreadable | Reported only; needs a person |
1622
+
1623
+ <!-- markdownlint-enable MD013 -->
1624
+
1625
+ Deleting removes a configuration's analyses, which clears GitHub's *"Code
1626
+ Scanning results may be out of date"* warning but also removes its alert
1627
+ history. A deletion is therefore **refused**, in a dry run as in an apply,
1628
+ when:
1629
+
1630
+ - no current configuration scans its language: the stale results are then
1631
+ the only record of it, so add scanning for the language first (the refusal
1632
+ names it, and **CodeQL: Language Coverage** lists the gap);
1633
+ - it would help close an open alert. This is judged across **every deletion
1634
+ planned in the repository**, not one at a time: an alert held by two stale
1635
+ configurations would close if both went, so both are refused, although
1636
+ neither holds it alone. An alert also reported by a live configuration
1637
+ blocks nothing;
1638
+ - the repository's open alerts cannot be read.
1639
+
1640
+ Refusals are listed beside the work done and do not fail the run. Deletion is
1641
+ paced at one request a second across the whole run, as GitHub asks of
1642
+ mutating requests, and is resumable: an interrupted run's next pass picks up
1643
+ the remaining analyses. GitHub answers requests it will not authorise with
1644
+ `404`, so a deletion's `404` counts as already done only once the analysis
1645
+ also reads back as gone; a token that may not delete fails instead of
1646
+ reporting a cleanup that never happened. The token needs the classic `repo`
1647
+ scope. See [ADR-0005](docs/adr/0005-codeql-configuration-cleanup.md) for the
1648
+ reasoning.
1649
+
1540
1650
  ## Bulk Remediation Scripts
1541
1651
 
1542
1652
  The standalone scripts below predate the `remediate` subcommand and remain for
@@ -301,6 +301,8 @@ environment-variable name, never embedded.
301
301
  "repo_min_age_days": 28,
302
302
  "release_max_age_days": 60,
303
303
  "codeql_stale_days": 30,
304
+ "issue_age_warn_days": 30,
305
+ "issue_age_error_days": 60,
304
306
  "graph_batch": 10,
305
307
  "order": { "style": "auto" }
306
308
  },
@@ -363,7 +365,8 @@ organisation, as `--top-n` does.
363
365
 
364
366
  The per-org `exclude` list removes repositories from analysis entirely; they are
365
367
  reported as **excluded** (distinct from "not enabled"), so an intentional
366
- exclusion is visible rather than silently dropped.
368
+ exclusion is visible rather than silently dropped. Entries match repository
369
+ names case-insensitively, as GitHub does.
367
370
 
368
371
  Every category reports those exclusions beneath its counts, so an
369
372
  organisation-wide list repeats under each one. `report.excluded_display`
@@ -792,6 +795,38 @@ with HTTP 200 and this one field null, so affected repositories are reported as
792
795
  `❓ Unknown` rather than counted as having no open issues — an unreadable backlog
793
796
  is never presented as a clean one.
794
797
 
798
+ #### Issue colours and age thresholds
799
+
800
+ On the terminal the counts are coloured so the table reads at a glance: `Bug`
801
+ and `Untriaged` **red**, `Docs` **green**. As in the Pull Requests table, only
802
+ **non-zero** counts are coloured, so the eye lands on the rows with something
803
+ in them. The colours follow the column **name** (case-insensitively), so they
804
+ apply to a custom `issue_labels` that keeps a `Bug` or `Docs` column and simply
805
+ do not appear for one that renames them.
806
+
807
+ `Oldest` is coloured against two age thresholds, in days:
808
+
809
+ ```json
810
+ {
811
+ "report": {
812
+ "issue_age_warn_days": 30,
813
+ "issue_age_error_days": 60
814
+ },
815
+ "organizations": [{ "name": "lfreleng-actions" }]
816
+ }
817
+ ```
818
+
819
+ - **Green** while the oldest open issue is at most `issue_age_warn_days`
820
+ (default `30`) old.
821
+ - **Yellow** once it is older than `issue_age_warn_days`.
822
+ - **Red** once it is older than `issue_age_error_days` (default `60`).
823
+
824
+ An `unknown` age is never coloured. Either threshold may be set to `0` to
825
+ switch that level off; with both off the column is left uncoloured.
826
+ `issue_age_error_days` must be **strictly greater** than `issue_age_warn_days`
827
+ (or `0`), otherwise the yellow level could never be reached, so it is rejected
828
+ at load. Both are settable per organisation.
829
+
795
830
  ### Pull Requests
796
831
 
797
832
  The `pull_requests` category counts each repository's **open pull requests**,
@@ -1475,8 +1510,30 @@ uvx github-security-report remediate \
1475
1510
  # Limit to specific categories (repeatable).
1476
1511
  uvx github-security-report remediate --org lfreleng-actions \
1477
1512
  --category codeql --category private_vulnerability_reporting --apply
1513
+
1514
+ # Limit to specific repositories, for any category (comma-separated and/or
1515
+ # repeatable; `name` in any configured org, or `owner/name`).
1516
+ uvx github-security-report remediate --org lfreleng-actions \
1517
+ --repos dependamerge,python-nss-ng --apply
1478
1518
  ```
1479
1519
 
1520
+ `--repos` narrows the run itself, not just its output. Everything done per
1521
+ repository (the feature probes, the batched prefetch, the CodeQL history walk
1522
+ and every write) covers only the named repositories, so a targeted run skips
1523
+ the bulk of a full one. The organisation-wide alert sweeps are the exception:
1524
+ each is a single org-bulk request, so its cost follows the organisation's open
1525
+ alert backlog rather than its repository count. It combines with
1526
+ `--category`. Every name is checked against every configured organisation
1527
+ before anything is written: a name that matches no repository (almost always
1528
+ a typo), or one that matches only repositories the configuration excludes
1529
+ (the `exclude` list, archived, fork, template or test), stops the run with
1530
+ exit code 2 and says which. A bare name excluded in one organisation but in
1531
+ scope in another runs against the in-scope one only; naming a repository never
1532
+ brings an excluded one back. A `--repos` value that names nothing also stops
1533
+ the run, rather than falling back to every repository. The run then collects
1534
+ exactly the repositories that check validated, without listing the
1535
+ organisation again, and prints `Limited to:` beneath its heading.
1536
+
1480
1537
  The remediable categories are the simple on/off features with a documented
1481
1538
  enablement endpoint:
1482
1539
 
@@ -1493,6 +1550,59 @@ Qualitative findings (Scorecard, zizmor, open Dependabot alerts, cooldown,
1493
1550
  release freshness/mutability) are reported but not auto-remediated. Remediation
1494
1551
  is organisation-scoped (`--scope org`, the default and only supported scope).
1495
1552
 
1553
+ ### Cleaning up stale CodeQL configurations
1554
+
1555
+ One category is **destructive**, so it runs only when named. The no-argument
1556
+ run never includes it:
1557
+
1558
+ ```bash
1559
+ # Preview: which configurations would go, and which are refused and why.
1560
+ uvx github-security-report remediate --org lfreleng-actions \
1561
+ --category codeql_stale_configurations
1562
+
1563
+ # Apply.
1564
+ uvx github-security-report remediate --org lfreleng-actions \
1565
+ --category codeql_stale_configurations --apply
1566
+ ```
1567
+
1568
+ It acts on the rows of **CodeQL: Stale Configurations**, according to their
1569
+ cause:
1570
+
1571
+ <!-- markdownlint-disable MD013 -->
1572
+
1573
+ | Cause | Action |
1574
+ | ----- | ------ |
1575
+ | Default setup disabled; workflow removed; superseded by default setup; language removed from default setup | **Delete** the configuration: it can never upload again |
1576
+ | Workflow disabled after inactivity | **Re-enable** the workflow, restoring the scan |
1577
+ | Analyses failing, default setup changing state or not uploading, uploaded outside GitHub Actions, workflow disabled by hand, active but not uploading, or state unreadable | Reported only; needs a person |
1578
+
1579
+ <!-- markdownlint-enable MD013 -->
1580
+
1581
+ Deleting removes a configuration's analyses, which clears GitHub's *"Code
1582
+ Scanning results may be out of date"* warning but also removes its alert
1583
+ history. A deletion is therefore **refused**, in a dry run as in an apply,
1584
+ when:
1585
+
1586
+ - no current configuration scans its language: the stale results are then
1587
+ the only record of it, so add scanning for the language first (the refusal
1588
+ names it, and **CodeQL: Language Coverage** lists the gap);
1589
+ - it would help close an open alert. This is judged across **every deletion
1590
+ planned in the repository**, not one at a time: an alert held by two stale
1591
+ configurations would close if both went, so both are refused, although
1592
+ neither holds it alone. An alert also reported by a live configuration
1593
+ blocks nothing;
1594
+ - the repository's open alerts cannot be read.
1595
+
1596
+ Refusals are listed beside the work done and do not fail the run. Deletion is
1597
+ paced at one request a second across the whole run, as GitHub asks of
1598
+ mutating requests, and is resumable: an interrupted run's next pass picks up
1599
+ the remaining analyses. GitHub answers requests it will not authorise with
1600
+ `404`, so a deletion's `404` counts as already done only once the analysis
1601
+ also reads back as gone; a token that may not delete fails instead of
1602
+ reporting a cleanup that never happened. The token needs the classic `repo`
1603
+ scope. See [ADR-0005](docs/adr/0005-codeql-configuration-cleanup.md) for the
1604
+ reasoning.
1605
+
1496
1606
  ## Bulk Remediation Scripts
1497
1607
 
1498
1608
  The standalone scripts below predate the `remediate` subcommand and remain for
@@ -65,7 +65,7 @@ dev = [
65
65
  "respx==0.23.1",
66
66
  "syrupy==6.1.1",
67
67
  "mypy==2.3.1",
68
- "ruff==0.16.8",
68
+ "ruff==0.16.9",
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==6.1.1",
99
99
  "mypy==2.3.1",
100
- "ruff==0.16.8",
100
+ "ruff==0.16.9",
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.16.0'
22
- __version_tuple__ = version_tuple = (0, 16, 0)
21
+ __version__ = version = '0.18.0'
22
+ __version_tuple__ = version_tuple = (0, 18, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -27,6 +27,7 @@ from github_security_report.cli.modes import (
27
27
  )
28
28
  from github_security_report.cli.options import OrgRunOptions, ReportOverrides
29
29
  from github_security_report.cli.outputs import TopNLimits
30
+ from github_security_report.scope import RepoSelectionError, parse_repo_selection
30
31
 
31
32
  app = typer.Typer(
32
33
  name="github-security-report",
@@ -267,6 +268,7 @@ def report(
267
268
  # Derived from the remediator registry rather than restated, so adding a
268
269
  # remediable category cannot leave the help text naming the previous set.
269
270
  _REMEDIABLE_HELP = ", ".join(key.value for key in remediate_mod.REMEDIABLE)
271
+ _EXPLICIT_HELP = ", ".join(key.value for key in remediate_mod.EXPLICIT_ONLY)
270
272
 
271
273
 
272
274
  @app.command()
@@ -288,7 +290,7 @@ def remediate(
288
290
  category: list[str] | None = typer.Option(
289
291
  None,
290
292
  "--category",
291
- help=f"Remediable category to act on (repeatable; default: all). One of: {_REMEDIABLE_HELP}.",
293
+ help=f"Remediable category to act on (repeatable; default: all except the destructive {_EXPLICIT_HELP}, which run only when named). One of: {_REMEDIABLE_HELP}.",
292
294
  ),
293
295
  token_env: str | None = typer.Option(
294
296
  None,
@@ -300,14 +302,21 @@ def remediate(
300
302
  "--apply",
301
303
  help="Perform the writes. Without this flag remediate only previews (dry run).",
302
304
  ),
305
+ repos: list[str] | None = typer.Option(
306
+ None,
307
+ "--repos",
308
+ help="Limit every selected category to these repositories: comma-separated and/or repeatable, each 'name' (in any configured org) or 'owner/name'. Each org's repository list is checked first: a name matching nothing, a name matching only excluded repositories, or an incomplete list stops the run before any per-repository read or any write.",
309
+ ),
303
310
  no_color: bool = typer.Option(False, "--no-color", help="Disable coloured output."),
304
311
  ) -> None:
305
312
  """Enable security features on repositories that lack them.
306
313
 
307
314
  Runs the same collection the report uses, then switches on each selected
308
- remediable feature wherever a repository has it confirmed off. Dry run by
309
- default: pass --apply to make changes. Requires a write-capable token
310
- (org admin), distinct from the read-only reporting PAT.
315
+ remediable feature wherever a repository has it confirmed off. Named
316
+ explicitly, codeql_stale_configurations also deletes orphaned CodeQL
317
+ configurations. Dry run by default: pass --apply to make changes. Requires
318
+ a write-capable token (org admin), distinct from the read-only reporting
319
+ PAT.
311
320
  """
312
321
  console = _console(no_color)
313
322
 
@@ -326,7 +335,22 @@ def remediate(
326
335
  markup=False,
327
336
  )
328
337
  raise typer.Exit(2)
329
- categories = keys or list(remediate_mod.REMEDIABLE)
338
+ categories = keys or list(remediate_mod.DEFAULT_REMEDIABLE)
339
+
340
+ try:
341
+ selection = parse_repo_selection(repos or [])
342
+ except RepoSelectionError as exc:
343
+ # markup=False: the value is printed exactly as the user typed it.
344
+ console.print(f"--repos: {exc}", style="red", markup=False)
345
+ raise typer.Exit(2) from exc
346
+ if repos and not selection:
347
+ # An explicit narrowing must never widen to "every repository".
348
+ console.print(
349
+ "--repos: names no repository; omit it to act on every one",
350
+ style="red",
351
+ markup=False,
352
+ )
353
+ raise typer.Exit(2)
330
354
 
331
355
  cfg = _load_config(config_file, config_data, org, token_env, console=console)
332
356
  if cfg is None:
@@ -354,6 +378,7 @@ def remediate(
354
378
  token=token,
355
379
  categories=categories,
356
380
  apply=apply,
381
+ repos=selection,
357
382
  ),
358
383
  )
359
384
  raise typer.Exit(code)
@@ -20,7 +20,7 @@ from typing import NoReturn
20
20
  import typer
21
21
  from rich.console import Console
22
22
 
23
- from github_security_report import collect, config, layout, runner
23
+ from github_security_report import collect, config, layout, runner, scope
24
24
  from github_security_report import remediate as remediate_mod
25
25
  from github_security_report.categories import CategoryKey
26
26
  from github_security_report.cli import publish
@@ -36,7 +36,9 @@ from github_security_report.cli.outputs import (
36
36
  show,
37
37
  )
38
38
  from github_security_report.client import AuthError, GitHubClient, NetworkError
39
+ from github_security_report.collect.protocols import ClientProtocol
39
40
  from github_security_report.config import Config, OrgConfig, ReportConfig
41
+ from github_security_report.models import Repo
40
42
  from github_security_report.render import markdown as md_render
41
43
  from github_security_report.render import terminal as term_render
42
44
  from github_security_report.report import build_org_report
@@ -261,19 +263,35 @@ async def _run_remediate(
261
263
  token: str,
262
264
  categories: Sequence[CategoryKey],
263
265
  apply: bool,
266
+ repos: Sequence[scope.RepoRef] = (),
264
267
  ) -> int:
265
268
  """Collect each org's posture and enable (or preview enabling) features.
266
269
 
267
270
  A single write-capable token drives both the read (collection) and the
268
271
  writes for every configured org, so the per-org read ``token_env`` in the
269
- config is intentionally bypassed. Returns 1 when any enable failed, else 0.
272
+ config is intentionally bypassed. ``repos``, when given, limits every
273
+ category to those repositories; the selection is checked against every
274
+ org before anything is read or written, and a name that matches nothing,
275
+ or only an excluded repository, stops the run. Returns 2 for a bad
276
+ selection, 1 when any write failed, else 0.
270
277
  """
271
278
  now = dt.datetime.now(dt.timezone.utc)
272
279
  failures = 0
273
280
  async with GitHubClient(token) as client:
274
- for org_cfg in cfg.organizations:
281
+ selected = await _check_selection(client, cfg, repos, console=console)
282
+ if selected is None:
283
+ return 2
284
+ for index, org_cfg in enumerate(cfg.organizations):
285
+ if repos and index not in selected:
286
+ continue # none of the named repositories live here
287
+ # The validated repositories themselves, so collection cannot
288
+ # silently lose one to a second, incomplete listing.
275
289
  report = await collect.collect_org(
276
- client, org_cfg, org_cfg.report, generated_at=now
290
+ client,
291
+ org_cfg,
292
+ org_cfg.report,
293
+ generated_at=now,
294
+ selected=selected[index] if repos else None,
277
295
  )
278
296
  results = await remediate_mod.remediate_org(
279
297
  client, report, categories=categories, apply=apply
@@ -286,6 +304,77 @@ async def _run_remediate(
286
304
  console,
287
305
  apply=apply,
288
306
  top_n=org_cfg.report.cli_top_n,
307
+ # This entry's own validated repositories, not every selector:
308
+ # in a multi-org run each org names only what it collected.
309
+ limited_to=[r.name for r in selected[index]] if repos else [],
289
310
  )
290
311
  failures += sum(result.failures for result in results)
291
312
  return 1 if failures else 0
313
+
314
+
315
+ async def _check_selection(
316
+ client: ClientProtocol,
317
+ cfg: Config,
318
+ repos: Sequence[scope.RepoRef],
319
+ *,
320
+ console: Console,
321
+ ) -> dict[int, tuple[Repo, ...]] | None:
322
+ """Each org entry's repositories a selection names, or None to stop.
323
+
324
+ Keyed by the entry's position in the configuration, holding only entries
325
+ with a match: a run collects exactly these, so an entry the selection
326
+ never touches is skipped. Keyed by position rather than name because the
327
+ configuration may list one organisation twice with different scoping;
328
+ each entry must act only on what its own configuration allows.
329
+
330
+ Every name must match a repository some configured org has in scope. A
331
+ name matching nothing is almost always a typo, and one matching only an
332
+ excluded repository (the exclude list, archived, fork, template, test)
333
+ must not bypass that exclusion, so both stop the run. So does any org
334
+ whose listing came back incomplete, since it may hide a repository a
335
+ selector names. Every problem is reported at once. An empty selection
336
+ returns an empty mapping, which callers read as "no selection".
337
+ """
338
+ if not repos:
339
+ return {}
340
+ usable: dict[scope.RepoRef, bool] = dict.fromkeys(repos, False)
341
+ excluded: dict[scope.RepoRef, list[str]] = {ref: [] for ref in repos}
342
+ touched: dict[int, tuple[Repo, ...]] = {}
343
+ problems: list[str] = []
344
+ for index, org_cfg in enumerate(cfg.organizations):
345
+ named = await collect.check_named_repos(client, org_cfg, org_cfg.report, repos)
346
+ if named.partial:
347
+ # A narrowed run must validate its whole target set, and an
348
+ # incomplete listing may hide a repository a selector names (a
349
+ # bare name can match in any org), so it cannot proceed on the
350
+ # matches it happened to see.
351
+ problems.append(
352
+ f"the repository listing for {org_cfg.name} was incomplete, so "
353
+ "the selection cannot be checked in full; try again"
354
+ )
355
+ for ref in repos:
356
+ if any(ref.matches(org_cfg.name, r.name) for r in named.in_scope):
357
+ usable[ref] = True
358
+ excluded[ref].extend(
359
+ f"{r.full_name} is excluded ({reason})"
360
+ for r, reason in named.excluded
361
+ if ref.matches(org_cfg.name, r.name)
362
+ )
363
+ if named.in_scope:
364
+ touched[index] = named.in_scope
365
+ # A bare name may be usable in one org and excluded in another: only a
366
+ # name with no usable match anywhere is a problem, so the run acts on the
367
+ # usable match and never on the excluded one.
368
+ for ref in repos:
369
+ if not usable[ref]:
370
+ problems.extend(excluded[ref])
371
+ unknown = [str(ref) for ref in repos if not usable[ref] and not excluded[ref]]
372
+ if unknown:
373
+ where = ", ".join(org_cfg.name for org_cfg in cfg.organizations)
374
+ problems.append(f"no repository named {', '.join(unknown)} in {where}")
375
+ if problems:
376
+ # markup=False: repository names come from the command line and the API.
377
+ for problem in problems:
378
+ console.print(f"--repos: {problem}", style="red", markup=False)
379
+ return None
380
+ return touched
@@ -65,7 +65,9 @@ def latest_codeql_configurations(
65
65
  workflow that fails on every run read as current indefinitely. A
66
66
  configuration that has never once succeeded has no last scan at all
67
67
  (``None``), which makes it stale however recent its attempts. ``failing``
68
- records whether the newest attempt errored.
68
+ records whether the newest attempt errored. Every analysis id in the
69
+ configuration is kept, newest first, which is the order GitHub requires
70
+ them deleted in.
69
71
  """
70
72
  grouped: dict[str, list[tuple[dt.datetime, Mapping[str, object]]]] = {}
71
73
  for analysis in analyses:
@@ -87,12 +89,19 @@ def _errored(analysis: Mapping[str, object]) -> bool:
87
89
  return isinstance(error, str) and bool(error.strip())
88
90
 
89
91
 
92
+ def _analysis_id(analysis: Mapping[str, object]) -> int | None:
93
+ value = analysis.get("id")
94
+ return value if isinstance(value, int) and not isinstance(value, bool) else None
95
+
96
+
90
97
  def _configuration(
91
98
  category: str, entries: list[tuple[dt.datetime, Mapping[str, object]]]
92
99
  ) -> CodeQLConfiguration:
93
100
  """One configuration from its analyses, newest first."""
94
101
  _newest_at, newest = entries[0]
95
102
  succeeded = [created for created, analysis in entries if not _errored(analysis)]
103
+ # Already newest first: the order GitHub requires them deleted in.
104
+ ids = [_analysis_id(analysis) for _created, analysis in entries]
96
105
  key = newest.get("analysis_key")
97
106
  return CodeQLConfiguration(
98
107
  category=category,
@@ -100,6 +109,7 @@ def _configuration(
100
109
  last_scan_at=succeeded[0] if succeeded else None,
101
110
  language=_analysis_language(newest),
102
111
  failing=_errored(newest),
112
+ analysis_ids=tuple(i for i in ids if i is not None),
103
113
  )
104
114
 
105
115
 
@@ -360,3 +360,51 @@ class ReadClient(OrgReadClient):
360
360
  readable = resp.status_code == 200
361
361
  await resp.aclose() # only the status matters here
362
362
  return readable
363
+
364
+ async def codeql_alert_holders(
365
+ self, org: str, repo: str, branch: str
366
+ ) -> list[frozenset[str]] | None:
367
+ """Each open CodeQL alert's holding categories on ``branch``.
368
+
369
+ One entry per open alert: the categories of the configurations whose
370
+ instance of it is still open there. An instance already fixed holds
371
+ nothing open, so it is not counted. Deleting configurations closes every alert whose
372
+ holders they all belong to, so a cleanup judges its whole planned
373
+ deletion set against these, not one deletion at a time. ``None`` when
374
+ the alerts or any alert's instances could not be read in full: an
375
+ unknown answer must stop a deletion, never permit one.
376
+ """
377
+ base = f"{self._api_url}/repos/{org}/{repo}/code-scanning"
378
+ ref = f"refs/heads/{branch}"
379
+ # Filtered to the branch up front: only its alerts can be held open by
380
+ # a configuration on it, and each costs an instances read below.
381
+ status, alerts = await self._get_list(
382
+ f"{base}/alerts",
383
+ tool_name=CODE_SCANNING_TOOLS[SignalType.CODEQL],
384
+ state="open",
385
+ ref=ref,
386
+ )
387
+ if status != 200:
388
+ return None
389
+ holders: list[frozenset[str]] = []
390
+ for alert in alerts:
391
+ status, instances = await self._get_list(
392
+ f"{base}/alerts/{alert.get('number')}/instances", ref=ref
393
+ )
394
+ if status != 200:
395
+ return None
396
+ held_by: set[str] = set()
397
+ for instance in instances:
398
+ state = instance.get("state")
399
+ category = instance.get("category")
400
+ if state not in ("open", "fixed") or not isinstance(category, str):
401
+ # An instance whose state or configuration is unknown could
402
+ # be the one keeping the alert open; guessing would not
403
+ # fail closed.
404
+ return None
405
+ # Only an open instance holds the alert open: a configuration
406
+ # that has already fixed its instance keeps nothing alive.
407
+ if state == "open":
408
+ held_by.add(category)
409
+ holders.append(frozenset(held_by))
410
+ return holders