github-security-report 0.16.0__tar.gz → 0.17.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.17.0}/PKG-INFO +78 -2
  2. {github_security_report-0.16.0 → github_security_report-0.17.0}/README.md +77 -1
  3. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/_version.py +2 -2
  4. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/cli/app.py +30 -5
  5. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/cli/modes.py +93 -4
  6. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/client/codeql_parsers.py +11 -1
  7. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/client/reads.py +48 -0
  8. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/client/writes.py +128 -0
  9. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/codeql/__init__.py +12 -0
  10. github_security_report-0.17.0/src/github_security_report/codeql/cleanup.py +123 -0
  11. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/codeql/facts.py +88 -0
  12. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/codeql/tables.py +27 -20
  13. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/collect/__init__.py +6 -0
  14. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/collect/extras.py +3 -0
  15. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/collect/org.py +19 -51
  16. github_security_report-0.17.0/src/github_security_report/collect/scoping.py +122 -0
  17. github_security_report-0.16.0/src/github_security_report/remediate.py → github_security_report-0.17.0/src/github_security_report/remediate/__init__.py +109 -99
  18. github_security_report-0.17.0/src/github_security_report/remediate/codeql.py +99 -0
  19. github_security_report-0.17.0/src/github_security_report/remediate/model.py +154 -0
  20. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/render/terminal.py +45 -23
  21. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/report/aggregate.py +10 -0
  22. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/scope.py +69 -2
  23. {github_security_report-0.16.0 → github_security_report-0.17.0}/.gitignore +0 -0
  24. {github_security_report-0.16.0 → github_security_report-0.17.0}/LICENSE +0 -0
  25. {github_security_report-0.16.0 → github_security_report-0.17.0}/LICENSES/Apache-2.0.txt +0 -0
  26. {github_security_report-0.16.0 → github_security_report-0.17.0}/pyproject.toml +0 -0
  27. {github_security_report-0.16.0 → github_security_report-0.17.0}/scripts/README.md +0 -0
  28. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/__init__.py +0 -0
  29. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/authors.py +0 -0
  30. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/categories/__init__.py +0 -0
  31. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/categories/keys.py +0 -0
  32. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/categories/signals.py +0 -0
  33. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/categories/tables.py +0 -0
  34. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/classify.py +0 -0
  35. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/cli/__init__.py +0 -0
  36. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/cli/__main__.py +0 -0
  37. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/cli/boundary.py +0 -0
  38. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/cli/options.py +0 -0
  39. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/cli/outputs.py +0 -0
  40. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/cli/publish.py +0 -0
  41. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/cli/serialise.py +0 -0
  42. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/client/__init__.py +0 -0
  43. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/client/alerts.py +0 -0
  44. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/client/batch_errors.py +0 -0
  45. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/client/copilot.py +0 -0
  46. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/client/endpoints.py +0 -0
  47. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/client/errors.py +0 -0
  48. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/client/org_reads.py +0 -0
  49. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/client/parsers.py +0 -0
  50. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/client/queries.py +0 -0
  51. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/client/reviews.py +0 -0
  52. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/client/transport.py +0 -0
  53. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/collect/codeql.py +0 -0
  54. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/collect/context.py +0 -0
  55. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/collect/protocols.py +0 -0
  56. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/collect/repo.py +0 -0
  57. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/config/__init__.py +0 -0
  58. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/config/loader.py +0 -0
  59. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/config/models.py +0 -0
  60. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/config/order.py +0 -0
  61. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/config/schema.py +0 -0
  62. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/gating.py +0 -0
  63. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/gitctx.py +0 -0
  64. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/issues.py +0 -0
  65. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/layout.py +0 -0
  66. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/models.py +0 -0
  67. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/ordering.py +0 -0
  68. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/posture/__init__.py +0 -0
  69. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/posture/enablement.py +0 -0
  70. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/posture/facts.py +0 -0
  71. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/posture/releases.py +0 -0
  72. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/pulls/__init__.py +0 -0
  73. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/pulls/columns.py +0 -0
  74. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/pulls/counting.py +0 -0
  75. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/pulls/presentation.py +0 -0
  76. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/pulls/table.py +0 -0
  77. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/py.typed +0 -0
  78. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/ranking.py +0 -0
  79. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/render/__init__.py +0 -0
  80. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/render/html.py +0 -0
  81. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/render/markdown.py +0 -0
  82. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/render/slack.py +0 -0
  83. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/render/slack_limits.py +0 -0
  84. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/report/__init__.py +0 -0
  85. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/report/display.py +0 -0
  86. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/report/signals.py +0 -0
  87. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/report/tables.py +0 -0
  88. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/rulesets.py +0 -0
  89. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/runner.py +0 -0
  90. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/secret_patterns.py +0 -0
  91. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/severity.py +0 -0
  92. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/summary.py +0 -0
  93. {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/templates/index.html.j2 +0 -0
  94. {github_security_report-0.16.0 → github_security_report-0.17.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.17.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
@@ -407,7 +407,8 @@ organisation, as `--top-n` does.
407
407
 
408
408
  The per-org `exclude` list removes repositories from analysis entirely; they are
409
409
  reported as **excluded** (distinct from "not enabled"), so an intentional
410
- exclusion is visible rather than silently dropped.
410
+ exclusion is visible rather than silently dropped. Entries match repository
411
+ names case-insensitively, as GitHub does.
411
412
 
412
413
  Every category reports those exclusions beneath its counts, so an
413
414
  organisation-wide list repeats under each one. `report.excluded_display`
@@ -1519,8 +1520,30 @@ uvx github-security-report remediate \
1519
1520
  # Limit to specific categories (repeatable).
1520
1521
  uvx github-security-report remediate --org lfreleng-actions \
1521
1522
  --category codeql --category private_vulnerability_reporting --apply
1523
+
1524
+ # Limit to specific repositories, for any category (comma-separated and/or
1525
+ # repeatable; `name` in any configured org, or `owner/name`).
1526
+ uvx github-security-report remediate --org lfreleng-actions \
1527
+ --repos dependamerge,python-nss-ng --apply
1522
1528
  ```
1523
1529
 
1530
+ `--repos` narrows the run itself, not just its output. Everything done per
1531
+ repository (the feature probes, the batched prefetch, the CodeQL history walk
1532
+ and every write) covers only the named repositories, so a targeted run skips
1533
+ the bulk of a full one. The organisation-wide alert sweeps are the exception:
1534
+ each is a single org-bulk request, so its cost follows the organisation's open
1535
+ alert backlog rather than its repository count. It combines with
1536
+ `--category`. Every name is checked against every configured organisation
1537
+ before anything is written: a name that matches no repository (almost always
1538
+ a typo), or one that matches only repositories the configuration excludes
1539
+ (the `exclude` list, archived, fork, template or test), stops the run with
1540
+ exit code 2 and says which. A bare name excluded in one organisation but in
1541
+ scope in another runs against the in-scope one only; naming a repository never
1542
+ brings an excluded one back. A `--repos` value that names nothing also stops
1543
+ the run, rather than falling back to every repository. The run then collects
1544
+ exactly the repositories that check validated, without listing the
1545
+ organisation again, and prints `Limited to:` beneath its heading.
1546
+
1524
1547
  The remediable categories are the simple on/off features with a documented
1525
1548
  enablement endpoint:
1526
1549
 
@@ -1537,6 +1560,59 @@ Qualitative findings (Scorecard, zizmor, open Dependabot alerts, cooldown,
1537
1560
  release freshness/mutability) are reported but not auto-remediated. Remediation
1538
1561
  is organisation-scoped (`--scope org`, the default and only supported scope).
1539
1562
 
1563
+ ### Cleaning up stale CodeQL configurations
1564
+
1565
+ One category is **destructive**, so it runs only when named. The no-argument
1566
+ run never includes it:
1567
+
1568
+ ```bash
1569
+ # Preview: which configurations would go, and which are refused and why.
1570
+ uvx github-security-report remediate --org lfreleng-actions \
1571
+ --category codeql_stale_configurations
1572
+
1573
+ # Apply.
1574
+ uvx github-security-report remediate --org lfreleng-actions \
1575
+ --category codeql_stale_configurations --apply
1576
+ ```
1577
+
1578
+ It acts on the rows of **CodeQL: Stale Configurations**, according to their
1579
+ cause:
1580
+
1581
+ <!-- markdownlint-disable MD013 -->
1582
+
1583
+ | Cause | Action |
1584
+ | ----- | ------ |
1585
+ | Default setup disabled; workflow removed; superseded by default setup; language removed from default setup | **Delete** the configuration: it can never upload again |
1586
+ | Workflow disabled after inactivity | **Re-enable** the workflow, restoring the scan |
1587
+ | 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 |
1588
+
1589
+ <!-- markdownlint-enable MD013 -->
1590
+
1591
+ Deleting removes a configuration's analyses, which clears GitHub's *"Code
1592
+ Scanning results may be out of date"* warning but also removes its alert
1593
+ history. A deletion is therefore **refused**, in a dry run as in an apply,
1594
+ when:
1595
+
1596
+ - no current configuration scans its language: the stale results are then
1597
+ the only record of it, so add scanning for the language first (the refusal
1598
+ names it, and **CodeQL: Language Coverage** lists the gap);
1599
+ - it would help close an open alert. This is judged across **every deletion
1600
+ planned in the repository**, not one at a time: an alert held by two stale
1601
+ configurations would close if both went, so both are refused, although
1602
+ neither holds it alone. An alert also reported by a live configuration
1603
+ blocks nothing;
1604
+ - the repository's open alerts cannot be read.
1605
+
1606
+ Refusals are listed beside the work done and do not fail the run. Deletion is
1607
+ paced at one request a second across the whole run, as GitHub asks of
1608
+ mutating requests, and is resumable: an interrupted run's next pass picks up
1609
+ the remaining analyses. GitHub answers requests it will not authorise with
1610
+ `404`, so a deletion's `404` counts as already done only once the analysis
1611
+ also reads back as gone; a token that may not delete fails instead of
1612
+ reporting a cleanup that never happened. The token needs the classic `repo`
1613
+ scope. See [ADR-0005](docs/adr/0005-codeql-configuration-cleanup.md) for the
1614
+ reasoning.
1615
+
1540
1616
  ## Bulk Remediation Scripts
1541
1617
 
1542
1618
  The standalone scripts below predate the `remediate` subcommand and remain for
@@ -363,7 +363,8 @@ organisation, as `--top-n` does.
363
363
 
364
364
  The per-org `exclude` list removes repositories from analysis entirely; they are
365
365
  reported as **excluded** (distinct from "not enabled"), so an intentional
366
- exclusion is visible rather than silently dropped.
366
+ exclusion is visible rather than silently dropped. Entries match repository
367
+ names case-insensitively, as GitHub does.
367
368
 
368
369
  Every category reports those exclusions beneath its counts, so an
369
370
  organisation-wide list repeats under each one. `report.excluded_display`
@@ -1475,8 +1476,30 @@ uvx github-security-report remediate \
1475
1476
  # Limit to specific categories (repeatable).
1476
1477
  uvx github-security-report remediate --org lfreleng-actions \
1477
1478
  --category codeql --category private_vulnerability_reporting --apply
1479
+
1480
+ # Limit to specific repositories, for any category (comma-separated and/or
1481
+ # repeatable; `name` in any configured org, or `owner/name`).
1482
+ uvx github-security-report remediate --org lfreleng-actions \
1483
+ --repos dependamerge,python-nss-ng --apply
1478
1484
  ```
1479
1485
 
1486
+ `--repos` narrows the run itself, not just its output. Everything done per
1487
+ repository (the feature probes, the batched prefetch, the CodeQL history walk
1488
+ and every write) covers only the named repositories, so a targeted run skips
1489
+ the bulk of a full one. The organisation-wide alert sweeps are the exception:
1490
+ each is a single org-bulk request, so its cost follows the organisation's open
1491
+ alert backlog rather than its repository count. It combines with
1492
+ `--category`. Every name is checked against every configured organisation
1493
+ before anything is written: a name that matches no repository (almost always
1494
+ a typo), or one that matches only repositories the configuration excludes
1495
+ (the `exclude` list, archived, fork, template or test), stops the run with
1496
+ exit code 2 and says which. A bare name excluded in one organisation but in
1497
+ scope in another runs against the in-scope one only; naming a repository never
1498
+ brings an excluded one back. A `--repos` value that names nothing also stops
1499
+ the run, rather than falling back to every repository. The run then collects
1500
+ exactly the repositories that check validated, without listing the
1501
+ organisation again, and prints `Limited to:` beneath its heading.
1502
+
1480
1503
  The remediable categories are the simple on/off features with a documented
1481
1504
  enablement endpoint:
1482
1505
 
@@ -1493,6 +1516,59 @@ Qualitative findings (Scorecard, zizmor, open Dependabot alerts, cooldown,
1493
1516
  release freshness/mutability) are reported but not auto-remediated. Remediation
1494
1517
  is organisation-scoped (`--scope org`, the default and only supported scope).
1495
1518
 
1519
+ ### Cleaning up stale CodeQL configurations
1520
+
1521
+ One category is **destructive**, so it runs only when named. The no-argument
1522
+ run never includes it:
1523
+
1524
+ ```bash
1525
+ # Preview: which configurations would go, and which are refused and why.
1526
+ uvx github-security-report remediate --org lfreleng-actions \
1527
+ --category codeql_stale_configurations
1528
+
1529
+ # Apply.
1530
+ uvx github-security-report remediate --org lfreleng-actions \
1531
+ --category codeql_stale_configurations --apply
1532
+ ```
1533
+
1534
+ It acts on the rows of **CodeQL: Stale Configurations**, according to their
1535
+ cause:
1536
+
1537
+ <!-- markdownlint-disable MD013 -->
1538
+
1539
+ | Cause | Action |
1540
+ | ----- | ------ |
1541
+ | Default setup disabled; workflow removed; superseded by default setup; language removed from default setup | **Delete** the configuration: it can never upload again |
1542
+ | Workflow disabled after inactivity | **Re-enable** the workflow, restoring the scan |
1543
+ | 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 |
1544
+
1545
+ <!-- markdownlint-enable MD013 -->
1546
+
1547
+ Deleting removes a configuration's analyses, which clears GitHub's *"Code
1548
+ Scanning results may be out of date"* warning but also removes its alert
1549
+ history. A deletion is therefore **refused**, in a dry run as in an apply,
1550
+ when:
1551
+
1552
+ - no current configuration scans its language: the stale results are then
1553
+ the only record of it, so add scanning for the language first (the refusal
1554
+ names it, and **CodeQL: Language Coverage** lists the gap);
1555
+ - it would help close an open alert. This is judged across **every deletion
1556
+ planned in the repository**, not one at a time: an alert held by two stale
1557
+ configurations would close if both went, so both are refused, although
1558
+ neither holds it alone. An alert also reported by a live configuration
1559
+ blocks nothing;
1560
+ - the repository's open alerts cannot be read.
1561
+
1562
+ Refusals are listed beside the work done and do not fail the run. Deletion is
1563
+ paced at one request a second across the whole run, as GitHub asks of
1564
+ mutating requests, and is resumable: an interrupted run's next pass picks up
1565
+ the remaining analyses. GitHub answers requests it will not authorise with
1566
+ `404`, so a deletion's `404` counts as already done only once the analysis
1567
+ also reads back as gone; a token that may not delete fails instead of
1568
+ reporting a cleanup that never happened. The token needs the classic `repo`
1569
+ scope. See [ADR-0005](docs/adr/0005-codeql-configuration-cleanup.md) for the
1570
+ reasoning.
1571
+
1496
1572
  ## Bulk Remediation Scripts
1497
1573
 
1498
1574
  The standalone scripts below predate the `remediate` subcommand and remain for
@@ -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.17.0'
22
+ __version_tuple__ = version_tuple = (0, 17, 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
@@ -12,14 +12,49 @@ and the remediation writes.
12
12
 
13
13
  from __future__ import annotations
14
14
 
15
+ import asyncio
16
+ from urllib.parse import quote
17
+
15
18
  import httpx
16
19
 
17
20
  from github_security_report.client.reads import ReadClient
18
21
 
22
+ # Minimum spacing between cleanup mutations. GitHub asks integrations to leave
23
+ # at least a second between mutating requests to avoid its secondary rate
24
+ # limits, and a cleanup can issue hundreds across many configurations. A class
25
+ # attribute so tests can set it to zero.
26
+ _MUTATION_INTERVAL_SECONDS = 1.0
27
+
19
28
 
20
29
  class GitHubClient(ReadClient):
21
30
  """Thin async client over the GitHub REST + GraphQL APIs."""
22
31
 
32
+ mutation_interval_seconds: float = _MUTATION_INTERVAL_SECONDS
33
+ # Event-loop time of the last paced mutation; None before the first.
34
+ _last_mutation_at: float | None = None
35
+
36
+ async def _paced_mutation(
37
+ self, method: str, url: str, *, params: dict[str, str] | None = None
38
+ ) -> httpx.Response:
39
+ """Send one cleanup mutation, spaced from the previous one.
40
+
41
+ Client-wide rather than per call, so the spacing holds across targets:
42
+ the last deletion of one configuration and the first of the next are
43
+ as far apart as any two within one. The time is stamped when the
44
+ request *returns*, not when it starts: ``_request`` retries 5xx and
45
+ rate-limit responses itself, and a retry that lands late is still the
46
+ latest mutation GitHub saw, so the next must wait from it.
47
+ """
48
+ loop = asyncio.get_running_loop()
49
+ if self._last_mutation_at is not None:
50
+ wait = self._last_mutation_at + self.mutation_interval_seconds - loop.time()
51
+ if wait > 0:
52
+ await asyncio.sleep(wait)
53
+ try:
54
+ return await self._request(method, url, params=params)
55
+ finally:
56
+ self._last_mutation_at = loop.time()
57
+
23
58
  # ------------------------------------------------------------------ #
24
59
  # Remediation writes (enable a feature on one repository)
25
60
  # ------------------------------------------------------------------ #
@@ -154,3 +189,96 @@ class GitHubClient(ReadClient):
154
189
  note = "" if ok else self._write_note(resp)
155
190
  await resp.aclose()
156
191
  return ok, note
192
+
193
+ # ------------------------------------------------------------------ #
194
+ # CodeQL configuration cleanup
195
+ # ------------------------------------------------------------------ #
196
+ async def delete_codeql_analyses(
197
+ self, org: str, repo: str, analysis_ids: tuple[int, ...]
198
+ ) -> tuple[bool, str]:
199
+ """Delete one configuration's analyses, newest first. ``(ok, note)``.
200
+
201
+ GitHub deletes a configuration one analysis at a time, and only its
202
+ newest is deletable at any moment, so ``analysis_ids`` must arrive
203
+ newest first. ``confirm_delete`` permits removing the last one, which
204
+ is the point: the configuration itself goes with it.
205
+
206
+ The delete response's ``confirm_delete_url`` is documented to chain to
207
+ the next analysis, but in practice returns null while older analyses
208
+ remain, so the ids collected with the report drive the walk instead.
209
+
210
+ A ``404`` may mean the analysis is already gone (a previous,
211
+ interrupted run, or the listing lagging behind a deletion), but GitHub
212
+ also answers a token that may not delete with ``404``. So the analysis
213
+ is read back with the same token, which listed it moments ago: gone
214
+ there too means skip it, so a re-run resumes; still readable means the
215
+ delete was refused, and the walk stops with a failure rather than
216
+ reporting a cleanup that never happened. The note reports how many
217
+ were deleted.
218
+ """
219
+ deleted = 0
220
+ for analysis_id in analysis_ids:
221
+ resp = await self._paced_mutation(
222
+ "DELETE",
223
+ f"{self._api_url}/repos/{org}/{repo}/code-scanning/analyses/{analysis_id}",
224
+ params={"confirm_delete": "true"},
225
+ )
226
+ status = resp.status_code
227
+ stopped = f"stopped after {deleted} of {len(analysis_ids)}"
228
+ if status not in (200, 404):
229
+ note = self._write_note(resp)
230
+ await resp.aclose()
231
+ return False, f"{stopped}: {note}"
232
+ await resp.aclose()
233
+ if status == 404:
234
+ gone = await self._analysis_gone(org, repo, analysis_id)
235
+ if gone is not True:
236
+ reason = (
237
+ "delete refused (404) though the analysis still exists; "
238
+ "check the token can delete code scanning analyses"
239
+ if gone is False
240
+ else "could not confirm the analysis was already gone"
241
+ )
242
+ return False, f"{stopped}: {reason}"
243
+ deleted += status == 200
244
+ noun = "analysis" if deleted == 1 else "analyses"
245
+ return True, f"{deleted} {noun} deleted"
246
+
247
+ async def _analysis_gone(
248
+ self, org: str, repo: str, analysis_id: int
249
+ ) -> bool | None:
250
+ """Whether an analysis no longer exists: True gone, False present.
251
+
252
+ ``None`` when the read itself fails otherwise, which a caller must not
253
+ mistake for either answer.
254
+ """
255
+ resp = await self._request(
256
+ "GET",
257
+ f"{self._api_url}/repos/{org}/{repo}/code-scanning/analyses/{analysis_id}",
258
+ )
259
+ status = resp.status_code
260
+ await resp.aclose() # only the status matters here
261
+ if status == 404:
262
+ return True
263
+ if status == 200:
264
+ return False
265
+ return None
266
+
267
+ async def enable_workflow(self, org: str, repo: str, path: str) -> tuple[bool, str]:
268
+ """Re-enable the workflow at ``path``. Returns ``(ok, note)``.
269
+
270
+ ``PUT .../actions/workflows/{file}/enable`` answers ``204``; the
271
+ workflow is addressed by file name, which the API accepts in place of
272
+ its numeric id.
273
+ """
274
+ # Encoded as one path segment, as the read is: a "?" or "#" in the
275
+ # file name would otherwise end the URL path early.
276
+ name = quote(path.rsplit("/", 1)[-1], safe="")
277
+ resp = await self._paced_mutation(
278
+ "PUT",
279
+ f"{self._api_url}/repos/{org}/{repo}/actions/workflows/{name}/enable",
280
+ )
281
+ ok = resp.status_code == 204
282
+ note = "" if ok else self._write_note(resp)
283
+ await resp.aclose()
284
+ return ok, note