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.
- {github_security_report-0.16.0 → github_security_report-0.17.0}/PKG-INFO +78 -2
- {github_security_report-0.16.0 → github_security_report-0.17.0}/README.md +77 -1
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/_version.py +2 -2
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/cli/app.py +30 -5
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/cli/modes.py +93 -4
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/client/codeql_parsers.py +11 -1
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/client/reads.py +48 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/client/writes.py +128 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/codeql/__init__.py +12 -0
- github_security_report-0.17.0/src/github_security_report/codeql/cleanup.py +123 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/codeql/facts.py +88 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/codeql/tables.py +27 -20
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/collect/__init__.py +6 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/collect/extras.py +3 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/collect/org.py +19 -51
- github_security_report-0.17.0/src/github_security_report/collect/scoping.py +122 -0
- 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
- github_security_report-0.17.0/src/github_security_report/remediate/codeql.py +99 -0
- github_security_report-0.17.0/src/github_security_report/remediate/model.py +154 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/render/terminal.py +45 -23
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/report/aggregate.py +10 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/scope.py +69 -2
- {github_security_report-0.16.0 → github_security_report-0.17.0}/.gitignore +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/LICENSE +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/LICENSES/Apache-2.0.txt +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/pyproject.toml +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/scripts/README.md +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/__init__.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/authors.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/categories/__init__.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/categories/keys.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/categories/signals.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/categories/tables.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/classify.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/cli/__init__.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/cli/__main__.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/cli/boundary.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/cli/options.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/cli/outputs.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/cli/publish.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/cli/serialise.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/client/__init__.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/client/alerts.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/client/batch_errors.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/client/copilot.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/client/endpoints.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/client/errors.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/client/org_reads.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/client/parsers.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/client/queries.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/client/reviews.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/client/transport.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/collect/codeql.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/collect/context.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/collect/protocols.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/collect/repo.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/config/__init__.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/config/loader.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/config/models.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/config/order.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/config/schema.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/gating.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/gitctx.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/issues.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/layout.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/models.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/ordering.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/posture/__init__.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/posture/enablement.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/posture/facts.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/posture/releases.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/pulls/__init__.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/pulls/columns.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/pulls/counting.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/pulls/presentation.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/pulls/table.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/py.typed +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/ranking.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/render/__init__.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/render/html.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/render/markdown.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/render/slack.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/render/slack_limits.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/report/__init__.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/report/display.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/report/signals.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/report/tables.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/rulesets.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/runner.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/secret_patterns.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/severity.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/summary.py +0 -0
- {github_security_report-0.16.0 → github_security_report-0.17.0}/src/github_security_report/templates/index.html.j2 +0 -0
- {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.
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (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.
|
|
309
|
-
|
|
310
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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
|