github-security-report 0.11.0__tar.gz → 0.11.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {github_security_report-0.11.0 → github_security_report-0.11.2}/PKG-INFO +14 -1
- {github_security_report-0.11.0 → github_security_report-0.11.2}/README.md +13 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/_version.py +2 -2
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/client/reads.py +87 -13
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/client/transport.py +36 -13
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/collect/context.py +5 -5
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/collect/extras.py +1 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/models.py +8 -1
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/posture.py +22 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/render/slack.py +156 -92
- github_security_report-0.11.2/src/github_security_report/render/slack_limits.py +287 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/.gitignore +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/LICENSE +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/LICENSES/Apache-2.0.txt +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/pyproject.toml +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/scripts/README.md +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/__init__.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/categories.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/classify.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/cli/__init__.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/cli/__main__.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/cli/app.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/cli/modes.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/cli/outputs.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/cli/serialise.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/client/__init__.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/client/endpoints.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/client/parsers.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/client/queries.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/client/writes.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/collect/__init__.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/collect/org.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/collect/protocols.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/collect/repo.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/config/__init__.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/config/loader.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/config/models.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/config/schema.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/gating.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/gitctx.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/issues.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/ordering.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/py.typed +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/remediate.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/render/__init__.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/render/html.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/render/markdown.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/render/terminal.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/report.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/rulesets.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/runner.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/scope.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/severity.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/summary.py +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/templates/index.html.j2 +0 -0
- {github_security_report-0.11.0 → github_security_report-0.11.2}/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.11.
|
|
3
|
+
Version: 0.11.2
|
|
4
4
|
Summary: Security and quality reporting across GitHub organisations
|
|
5
5
|
Project-URL: Homepage, https://github.com/lfreleng-actions/github-security-report-action
|
|
6
6
|
Project-URL: Repository, https://github.com/lfreleng-actions/github-security-report-action
|
|
@@ -393,6 +393,19 @@ config asked for an uncapped one. `0` means "no limit" at every level. In a
|
|
|
393
393
|
shared Slack channel the most generous value any contributing org configured for
|
|
394
394
|
that category wins, matching the visibility rule above.
|
|
395
395
|
|
|
396
|
+
On Slack, `0` is best-effort rather than absolute. Slack imposes hard structural
|
|
397
|
+
limits on a message — 50 blocks per post, 3,000 characters per text object (a
|
|
398
|
+
section body or a context note) and 150 for a header — and rejects the **whole**
|
|
399
|
+
post if any is breached, so an uncapped table would cost the entire digest rather
|
|
400
|
+
than merely overflowing. The digest therefore sizes itself to fit: repository
|
|
401
|
+
name lists are trimmed first, then table rows, and whatever is left out is
|
|
402
|
+
reported by the usual `… and N more` tally so the numbers on screen stay honest.
|
|
403
|
+
Counts are never dropped, only names and rows. The other three surfaces have no
|
|
404
|
+
Slack-style ceiling, but they still apply their own row limits — only the
|
|
405
|
+
`report.json` artifact is unconditionally complete. The digest links to the
|
|
406
|
+
GitHub Pages report whenever `pages_url` is set and short enough to render as a
|
|
407
|
+
link.
|
|
408
|
+
|
|
396
409
|
### Per-category row ordering
|
|
397
410
|
|
|
398
411
|
Each table ships a sensible default ordering — largest backlog first, stalest
|
|
@@ -349,6 +349,19 @@ config asked for an uncapped one. `0` means "no limit" at every level. In a
|
|
|
349
349
|
shared Slack channel the most generous value any contributing org configured for
|
|
350
350
|
that category wins, matching the visibility rule above.
|
|
351
351
|
|
|
352
|
+
On Slack, `0` is best-effort rather than absolute. Slack imposes hard structural
|
|
353
|
+
limits on a message — 50 blocks per post, 3,000 characters per text object (a
|
|
354
|
+
section body or a context note) and 150 for a header — and rejects the **whole**
|
|
355
|
+
post if any is breached, so an uncapped table would cost the entire digest rather
|
|
356
|
+
than merely overflowing. The digest therefore sizes itself to fit: repository
|
|
357
|
+
name lists are trimmed first, then table rows, and whatever is left out is
|
|
358
|
+
reported by the usual `… and N more` tally so the numbers on screen stay honest.
|
|
359
|
+
Counts are never dropped, only names and rows. The other three surfaces have no
|
|
360
|
+
Slack-style ceiling, but they still apply their own row limits — only the
|
|
361
|
+
`report.json` artifact is unconditionally complete. The digest links to the
|
|
362
|
+
GitHub Pages report whenever `pages_url` is set and short enough to render as a
|
|
363
|
+
link.
|
|
364
|
+
|
|
352
365
|
### Per-category row ordering
|
|
353
366
|
|
|
354
367
|
Each table ships a sensible default ordering — largest backlog first, stalest
|
|
@@ -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.11.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 11,
|
|
21
|
+
__version__ = version = '0.11.2'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 11, 2)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -19,12 +19,45 @@ from github_security_report.client.queries import (
|
|
|
19
19
|
_DEPENDABOT_ENABLED_QUERY,
|
|
20
20
|
_REPO_GRAPH_FRAGMENT,
|
|
21
21
|
)
|
|
22
|
-
from github_security_report.client.transport import Transport
|
|
22
|
+
from github_security_report.client.transport import NetworkError, Transport
|
|
23
23
|
from github_security_report.models import Repo, RepoGraphData
|
|
24
24
|
|
|
25
25
|
log = logging.getLogger(__name__)
|
|
26
26
|
|
|
27
27
|
|
|
28
|
+
def _aliases_with_errors(errors: object, alias_count: int) -> set[str]:
|
|
29
|
+
"""Alias keys implicated by a batched query's ``errors`` array.
|
|
30
|
+
|
|
31
|
+
GitHub reports a *field-level* failure with HTTP 200: the alias is still a
|
|
32
|
+
populated dictionary, the field that failed is null, and an ``errors``
|
|
33
|
+
entry carries its path (e.g. ``["r3", "latestRelease"]``). Parsing such a
|
|
34
|
+
node would convert a read failure into a confident negative -- a nulled
|
|
35
|
+
``latestRelease`` is indistinguishable from "never released" -- so the
|
|
36
|
+
whole alias is treated as unreadable rather than partially trusted.
|
|
37
|
+
|
|
38
|
+
The alias is failed wholesale rather than per field: a finer-grained flag
|
|
39
|
+
per field would have to be threaded through every table to be honest
|
|
40
|
+
about which half of a row is trustworthy, whereas one unknown repository
|
|
41
|
+
is already a state every table renders correctly.
|
|
42
|
+
|
|
43
|
+
An error whose path names no alias cannot be attributed, so it implicates
|
|
44
|
+
every alias in the batch: with no way to tell which repositories it
|
|
45
|
+
touched, treating any of them as successfully read would be a guess.
|
|
46
|
+
"""
|
|
47
|
+
all_aliases = {f"r{i}" for i in range(alias_count)}
|
|
48
|
+
if not isinstance(errors, list):
|
|
49
|
+
return set()
|
|
50
|
+
affected: set[str] = set()
|
|
51
|
+
for entry in errors:
|
|
52
|
+
path = entry.get("path") if isinstance(entry, dict) else None
|
|
53
|
+
head = path[0] if isinstance(path, list) and path else None
|
|
54
|
+
if isinstance(head, str) and head in all_aliases:
|
|
55
|
+
affected.add(head)
|
|
56
|
+
else:
|
|
57
|
+
return all_aliases
|
|
58
|
+
return affected
|
|
59
|
+
|
|
60
|
+
|
|
28
61
|
class ReadClient(Transport):
|
|
29
62
|
"""The reporting reads: sweeps, per-repo probes and batched prefetch."""
|
|
30
63
|
|
|
@@ -334,12 +367,21 @@ class ReadClient(Transport):
|
|
|
334
367
|
) -> dict[str, RepoGraphData]:
|
|
335
368
|
"""Prefetch per-repo data for many repositories in one GraphQL query.
|
|
336
369
|
|
|
337
|
-
Returns a ``RepoGraphData`` per requested name.
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
370
|
+
Returns a ``RepoGraphData`` per requested name. This data is
|
|
371
|
+
load-bearing for whole report sections (releases/tags, Dependabot
|
|
372
|
+
enablement, open issues), and its defaults are indistinguishable from
|
|
373
|
+
confident negatives ("never released"), so a wholly failed query --
|
|
374
|
+
a non-200 response that survived the shared retry/backoff policy, or
|
|
375
|
+
a 200 carrying no ``data`` object -- raises :class:`NetworkError` to
|
|
376
|
+
abort the run rather than fabricating results. A repository that
|
|
377
|
+
cannot be fully read -- a ``null`` alias, or a populated alias whose
|
|
378
|
+
``errors`` entry shows a field failed to resolve -- degrades to
|
|
379
|
+
``RepoGraphData(unreadable=True)`` so downstream tables report it as
|
|
380
|
+
unknown. An empty ``names`` issues no request.
|
|
341
381
|
"""
|
|
342
|
-
|
|
382
|
+
# Seed every requested name as unreadable; only a successfully parsed
|
|
383
|
+
# alias replaces its entry, so nothing failed can masquerade as read.
|
|
384
|
+
out = {name: RepoGraphData(unreadable=True) for name in names}
|
|
343
385
|
if not names:
|
|
344
386
|
return out
|
|
345
387
|
aliases = "\n".join(
|
|
@@ -360,17 +402,25 @@ class ReadClient(Transport):
|
|
|
360
402
|
json={"query": query, "variables": variables},
|
|
361
403
|
)
|
|
362
404
|
if resp.status_code != 200:
|
|
405
|
+
status = resp.status_code
|
|
363
406
|
await resp.aclose() # unread body would leak a pooled connection
|
|
364
|
-
|
|
407
|
+
raise NetworkError(
|
|
408
|
+
f"GraphQL prefetch for {org} failed with HTTP {status} after "
|
|
409
|
+
"exhausting retries; aborting because the release/tag, "
|
|
410
|
+
"Dependabot-enablement and open-issues data for "
|
|
411
|
+
f"{len(names)} repositories would otherwise be fabricated "
|
|
412
|
+
"from defaults (e.g. reported as never released)."
|
|
413
|
+
)
|
|
365
414
|
body = resp.json()
|
|
366
|
-
data = body.get("data")
|
|
415
|
+
data = body.get("data")
|
|
367
416
|
await resp.aclose() # release the connection once the body is read
|
|
368
417
|
# GitHub answers a partially-refused query with HTTP 200: the readable
|
|
369
|
-
# aliases populated, the rest null, and an
|
|
370
|
-
#
|
|
371
|
-
#
|
|
372
|
-
#
|
|
418
|
+
# aliases populated, the rest null or missing individual fields, and an
|
|
419
|
+
# ``errors`` array explaining why. The paths are both logged for
|
|
420
|
+
# diagnosis and used to fail the affected aliases, since a field nulled
|
|
421
|
+
# by a failed read is indistinguishable from a genuine absence.
|
|
373
422
|
errors = body.get("errors")
|
|
423
|
+
errored_aliases = _aliases_with_errors(errors, len(names))
|
|
374
424
|
if errors:
|
|
375
425
|
log.warning(
|
|
376
426
|
"GraphQL prefetch for %s returned %d error(s); affected data is "
|
|
@@ -384,8 +434,32 @@ class ReadClient(Transport):
|
|
|
384
434
|
if isinstance(e, dict)
|
|
385
435
|
),
|
|
386
436
|
)
|
|
437
|
+
if not isinstance(data, dict):
|
|
438
|
+
# HTTP 200 with no data object at all: the whole batch failed
|
|
439
|
+
# (e.g. a timed-out or refused query). Same stakes as a non-200.
|
|
440
|
+
raise NetworkError(
|
|
441
|
+
f"GraphQL prefetch for {org} returned no data for any of "
|
|
442
|
+
f"{len(names)} repositories; aborting rather than reporting "
|
|
443
|
+
"fabricated defaults. "
|
|
444
|
+
f"errors={errors!r}"
|
|
445
|
+
)
|
|
387
446
|
for i, name in enumerate(names):
|
|
388
|
-
|
|
447
|
+
alias = f"r{i}"
|
|
448
|
+
if alias in errored_aliases:
|
|
449
|
+
# A field of this alias failed to resolve, so its null fields
|
|
450
|
+
# cannot be told apart from genuine absences. Leave the
|
|
451
|
+
# pre-seeded unreadable default in place.
|
|
452
|
+
continue
|
|
453
|
+
node = data.get(alias)
|
|
389
454
|
if isinstance(node, dict):
|
|
390
455
|
out[name] = _parse_repo_node(node)
|
|
456
|
+
unreadable = sorted(name for name, d in out.items() if d.unreadable)
|
|
457
|
+
if unreadable:
|
|
458
|
+
log.warning(
|
|
459
|
+
"GraphQL prefetch for %s could not read %d repositories "
|
|
460
|
+
"(reported as unknown): %s",
|
|
461
|
+
org,
|
|
462
|
+
len(unreadable),
|
|
463
|
+
", ".join(unreadable),
|
|
464
|
+
)
|
|
391
465
|
return out
|
|
@@ -35,15 +35,17 @@ _TransportT = TypeVar("_TransportT", bound="Transport")
|
|
|
35
35
|
|
|
36
36
|
|
|
37
37
|
class NetworkError(RuntimeError):
|
|
38
|
-
"""The GitHub API was
|
|
38
|
+
"""The GitHub API was unusable after exhausting the retry budget.
|
|
39
39
|
|
|
40
40
|
Raised for transport-level failures (DNS, connection, TLS, or read
|
|
41
41
|
timeout) against the GitHub API that persist across every retry within
|
|
42
|
-
``API_MAX_TOTAL_WAIT_SECONDS
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
42
|
+
``API_MAX_TOTAL_WAIT_SECONDS``, and by callers whose data is load-bearing
|
|
43
|
+
for the whole report (the batched GraphQL prefetch) when GitHub keeps
|
|
44
|
+
answering with server errors. The run aborts rather than rendering a
|
|
45
|
+
report from missing data: when the API itself cannot be relied on, an
|
|
46
|
+
empty or "all clean / all unknown" report is actively misleading.
|
|
47
|
+
Transport failures against the third-party Scorecard endpoint do not
|
|
48
|
+
raise this -- they degrade that one signal instead.
|
|
47
49
|
"""
|
|
48
50
|
|
|
49
51
|
|
|
@@ -163,8 +165,11 @@ class Transport:
|
|
|
163
165
|
-- a report built without live data would be misleading. The same
|
|
164
166
|
failure against the third-party Scorecard endpoint instead degrades to
|
|
165
167
|
an indeterminate 503, so one flaky external API never aborts the report.
|
|
166
|
-
|
|
167
|
-
exhausted, return the
|
|
168
|
+
Server errors (5xx) and rate-limit responses (403/429) back off on the
|
|
169
|
+
same schedule and, once exhausted, return the response for the caller
|
|
170
|
+
to handle: per-signal probes degrade to unknown, while callers whose
|
|
171
|
+
data is load-bearing (the GraphQL prefetch) abort the run instead of
|
|
172
|
+
fabricating results.
|
|
168
173
|
"""
|
|
169
174
|
http = client or self._client
|
|
170
175
|
is_external = http is self._ext_client
|
|
@@ -216,10 +221,11 @@ class Transport:
|
|
|
216
221
|
waited += delay
|
|
217
222
|
attempt += 1
|
|
218
223
|
continue
|
|
219
|
-
if resp.status_code not in (403, 429):
|
|
224
|
+
if resp.status_code not in (403, 429) and resp.status_code < 500:
|
|
220
225
|
return resp
|
|
221
|
-
# Reachable but
|
|
222
|
-
#
|
|
226
|
+
# Reachable but degraded: a 5xx (GitHub infrastructure trouble) or
|
|
227
|
+
# a possible rate limit. Distinguish secondary/primary rate
|
|
228
|
+
# limiting from a genuine 403, then back off on the shared
|
|
223
229
|
# schedule (honouring Retry-After) within the wait budget.
|
|
224
230
|
retry_after = resp.headers.get("retry-after")
|
|
225
231
|
remaining = resp.headers.get("x-ratelimit-remaining")
|
|
@@ -235,18 +241,35 @@ class Transport:
|
|
|
235
241
|
rate_limited = (
|
|
236
242
|
resp.status_code == 429 or retry_after is not None or remaining == "0"
|
|
237
243
|
)
|
|
244
|
+
# Any 5xx is retried: GitHub's infrastructure wobbles produce
|
|
245
|
+
# transient 500/502/503 responses that, if returned un-retried,
|
|
246
|
+
# would silently degrade (or falsify) whole report sections.
|
|
247
|
+
server_error = resp.status_code >= 500
|
|
238
248
|
delay = (
|
|
239
249
|
retry_after_secs
|
|
240
250
|
if retry_after_secs is not None
|
|
241
251
|
else self._backoff_delay(attempt)
|
|
242
252
|
)
|
|
243
253
|
if (
|
|
244
|
-
not rate_limited
|
|
254
|
+
not (rate_limited or server_error)
|
|
245
255
|
or attempt >= self._max_retries
|
|
246
256
|
or waited + delay > API_MAX_TOTAL_WAIT_SECONDS
|
|
247
257
|
):
|
|
258
|
+
# Retries exhausted (or a genuine 403): hand the response back
|
|
259
|
+
# so the caller can degrade its signal to unknown -- or, when
|
|
260
|
+
# its data is load-bearing, abort the run.
|
|
248
261
|
return resp
|
|
249
|
-
|
|
262
|
+
if server_error:
|
|
263
|
+
log.warning(
|
|
264
|
+
"server error %d on %s; retrying in %.0fs (retry %d of %d)",
|
|
265
|
+
resp.status_code,
|
|
266
|
+
url,
|
|
267
|
+
delay,
|
|
268
|
+
attempt + 1,
|
|
269
|
+
self._max_retries,
|
|
270
|
+
)
|
|
271
|
+
else:
|
|
272
|
+
log.warning("rate limited on %s; backing off %.0fs", url, delay)
|
|
250
273
|
# The discarded response must be closed; we are retrying and will
|
|
251
274
|
# not read its body, so leaving it open would leak a pool connection.
|
|
252
275
|
await resp.aclose()
|
|
@@ -60,13 +60,13 @@ class OrgCollectContext:
|
|
|
60
60
|
probe_scorecard: bool
|
|
61
61
|
|
|
62
62
|
def graph_for(self, name: str) -> RepoGraphData:
|
|
63
|
-
"""Prefetched GraphQL data for a repository, or
|
|
63
|
+
"""Prefetched GraphQL data for a repository, or unreadable defaults.
|
|
64
64
|
|
|
65
|
-
A repository missing from the prefetch
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
A repository missing from the prefetch entirely is marked
|
|
66
|
+
``unreadable`` so the dependent tables report it as unknown instead of
|
|
67
|
+
mislabelling it with confident negatives (e.g. "never released").
|
|
68
68
|
"""
|
|
69
|
-
return self.graph.get(name, RepoGraphData())
|
|
69
|
+
return self.graph.get(name, RepoGraphData(unreadable=True))
|
|
70
70
|
|
|
71
71
|
def ruleset_signals(self, name: str) -> set[str]:
|
|
72
72
|
"""Signals an org ruleset enforces for a repository (possibly none)."""
|
|
@@ -55,6 +55,7 @@ async def _posture_for_repo(repo: Repo, ctx: OrgCollectContext) -> RepoPosture:
|
|
|
55
55
|
)
|
|
56
56
|
return RepoPosture(
|
|
57
57
|
repo=repo,
|
|
58
|
+
graph_unreadable=graph.unreadable,
|
|
58
59
|
dependabot_alerts=graph.dependabot_alerts_enabled,
|
|
59
60
|
security_updates=security_updates,
|
|
60
61
|
private_vulnerability_reporting=pvr,
|
{github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/models.py
RENAMED
|
@@ -171,9 +171,16 @@ class RepoGraphData:
|
|
|
171
171
|
``open_issues``, and should present its own totals as covering the window
|
|
172
172
|
rather than the whole backlog. The window is ordered oldest-first, so the
|
|
173
173
|
oldest issue -- the one the age check reports -- is always present even when
|
|
174
|
-
|
|
174
|
+
window truncates.
|
|
175
175
|
"""
|
|
176
176
|
|
|
177
|
+
# True when this repository's data could not be read at all (a ``null``
|
|
178
|
+
# GraphQL alias, or the repository missing from the prefetch entirely).
|
|
179
|
+
# Downstream tables must report such repositories as unknown: the other
|
|
180
|
+
# defaults below are indistinguishable from "feature absent" readings
|
|
181
|
+
# (e.g. ``latest_release_at is None`` also means "never released"), so
|
|
182
|
+
# without this flag a failed read silently renders as false negatives.
|
|
183
|
+
unreadable: bool = False
|
|
177
184
|
dependabot_alerts_enabled: bool | None = None
|
|
178
185
|
latest_tag_at: dt.datetime | None = None
|
|
179
186
|
# Publish time of the "Latest" release, for release/tag staleness.
|
|
@@ -45,6 +45,11 @@ class RepoPosture:
|
|
|
45
45
|
"""Per-repository configuration/freshness facts for the extra sections."""
|
|
46
46
|
|
|
47
47
|
repo: Repo
|
|
48
|
+
# True when the batched GraphQL prefetch could not read this repository at
|
|
49
|
+
# all: the release/tag and dependabot.yml facts below are then unknown, not
|
|
50
|
+
# absent, and the tables must count the repository as unknown rather than
|
|
51
|
+
# render confident negatives such as "never released".
|
|
52
|
+
graph_unreadable: bool = False
|
|
48
53
|
# Dependabot repo-level feature flags (None = indeterminate).
|
|
49
54
|
dependabot_alerts: bool | None = None
|
|
50
55
|
security_updates: bool | None = None
|
|
@@ -217,12 +222,16 @@ def build_cooldown_table(postures: list[RepoPosture]) -> TableSection:
|
|
|
217
222
|
with_cooldown = sum(
|
|
218
223
|
1 for p in postures if p.has_dependabot_config and not p.cooldown_missing
|
|
219
224
|
)
|
|
225
|
+
# An unreadable prefetch means the dependabot.yml itself is unknown, not
|
|
226
|
+
# absent -- count it as unknown rather than silently dropping the repo.
|
|
227
|
+
indeterminate = sum(1 for p in postures if p.graph_unreadable)
|
|
220
228
|
return TableSection(
|
|
221
229
|
category=category_meta(CategoryKey.DEPENDABOT_COOLDOWN),
|
|
222
230
|
columns=("Repository", "Ecosystems without cooldown"),
|
|
223
231
|
rows=rows,
|
|
224
232
|
pass_count=with_cooldown,
|
|
225
233
|
fail_count=missing,
|
|
234
|
+
unknown_count=indeterminate,
|
|
226
235
|
)
|
|
227
236
|
|
|
228
237
|
|
|
@@ -283,6 +292,7 @@ def build_releases_table(
|
|
|
283
292
|
excluded = frozenset(exclude)
|
|
284
293
|
ranked: list[tuple[int, int, RepoPosture, int | None, int | None]] = []
|
|
285
294
|
current_count = 0
|
|
295
|
+
unknown_count = 0
|
|
286
296
|
for posture in postures:
|
|
287
297
|
repo = posture.repo
|
|
288
298
|
if is_release_excluded(
|
|
@@ -292,6 +302,12 @@ def build_releases_table(
|
|
|
292
302
|
exclude=excluded,
|
|
293
303
|
):
|
|
294
304
|
continue
|
|
305
|
+
if posture.graph_unreadable:
|
|
306
|
+
# The release/tag data could not be read for this repository, so
|
|
307
|
+
# its staleness is unknown -- never "never released/tagged", which
|
|
308
|
+
# is a confident negative the evidence does not support.
|
|
309
|
+
unknown_count += 1
|
|
310
|
+
continue
|
|
295
311
|
release_age = _age_days(posture.latest_release_at, generated_at)
|
|
296
312
|
tag_age = _age_days(posture.latest_tag_at, generated_at)
|
|
297
313
|
if _release_is_current(release_age, tag_age, release_max_age_days):
|
|
@@ -340,6 +356,7 @@ def build_releases_table(
|
|
|
340
356
|
rows=rows,
|
|
341
357
|
pass_count=current_count,
|
|
342
358
|
fail_count=len(rows),
|
|
359
|
+
unknown_count=unknown_count,
|
|
343
360
|
description=age_note + stale_note + meta.description,
|
|
344
361
|
)
|
|
345
362
|
|
|
@@ -361,6 +378,11 @@ def build_mutable_releases_table(postures: list[RepoPosture]) -> TableSection:
|
|
|
361
378
|
clean_count = 0
|
|
362
379
|
indeterminate_count = 0
|
|
363
380
|
for posture in postures:
|
|
381
|
+
if posture.graph_unreadable:
|
|
382
|
+
# The release data could not be read at all: whether any release
|
|
383
|
+
# exists (let alone is immutable) is unknown.
|
|
384
|
+
indeterminate_count += 1
|
|
385
|
+
continue
|
|
364
386
|
seen: set[str] = set()
|
|
365
387
|
candidates: list[ReleaseRef] = []
|
|
366
388
|
for ref in (posture.latest_release, posture.last_published_release):
|
|
@@ -8,6 +8,10 @@ offenders per signal, plus a prominent link to the full GitHub Pages report.
|
|
|
8
8
|
Like the terminal, Slack is a brevity-first surface: it carries the
|
|
9
9
|
standardised summary footer but omits the per-category explanatory description.
|
|
10
10
|
Produces a ``chat.postMessage`` payload. See ``docs/BRIEF.md`` section 11.
|
|
11
|
+
|
|
12
|
+
Slack validates the payload as a whole and rejects all of it if any structural
|
|
13
|
+
limit is breached, so every block built here is sized by
|
|
14
|
+
:mod:`~github_security_report.render.slack_limits` before it is emitted.
|
|
11
15
|
"""
|
|
12
16
|
|
|
13
17
|
from __future__ import annotations
|
|
@@ -16,6 +20,16 @@ from collections.abc import Callable, Sequence
|
|
|
16
20
|
|
|
17
21
|
from github_security_report.categories import CategoryKey
|
|
18
22
|
from github_security_report.models import Repo, RepoSignal, SignalType
|
|
23
|
+
from github_security_report.render.html import slugify
|
|
24
|
+
from github_security_report.render.slack_limits import (
|
|
25
|
+
MAX_TEXT_CHARS,
|
|
26
|
+
context_block,
|
|
27
|
+
enforce_block_limit,
|
|
28
|
+
fallback_text,
|
|
29
|
+
fit_section_text,
|
|
30
|
+
header_block,
|
|
31
|
+
text_length,
|
|
32
|
+
)
|
|
19
33
|
from github_security_report.report import (
|
|
20
34
|
ORG_SETUP_DOC_URL,
|
|
21
35
|
SKIP_MESSAGE,
|
|
@@ -33,10 +47,6 @@ from github_security_report.report import (
|
|
|
33
47
|
truncate,
|
|
34
48
|
)
|
|
35
49
|
|
|
36
|
-
# Slack rejects a chat.postMessage with more than 50 blocks, so a digest
|
|
37
|
-
# spanning many orgs must be capped or the whole message fails to deliver.
|
|
38
|
-
_SLACK_MAX_BLOCKS = 50
|
|
39
|
-
|
|
40
50
|
# Summary kinds whose repository names are listed beneath the count line.
|
|
41
51
|
_NAME_LIST_LABEL = {"disabled": "Disabled", "excluded": "Excluded"}
|
|
42
52
|
|
|
@@ -98,8 +108,17 @@ def _plain_total_row(
|
|
|
98
108
|
return ["Total", *base, *info]
|
|
99
109
|
|
|
100
110
|
|
|
101
|
-
def _fixed_table(section: SignalSection,
|
|
102
|
-
|
|
111
|
+
def _fixed_table(section: SignalSection, shown_count: int) -> str:
|
|
112
|
+
"""The fenced offender table showing the first ``shown_count`` rows.
|
|
113
|
+
|
|
114
|
+
Takes an absolute row count rather than a limit so the character budget in
|
|
115
|
+
:mod:`~github_security_report.render.slack_limits` can shed rows further
|
|
116
|
+
without a second truncation mechanism: the hidden tally is always derived
|
|
117
|
+
from the full offender list, so it stays honest no matter which cap did the
|
|
118
|
+
trimming.
|
|
119
|
+
"""
|
|
120
|
+
shown = section.offenders[:shown_count]
|
|
121
|
+
hidden = len(section.offenders) - len(shown)
|
|
103
122
|
informational = section_shows_informational(shown)
|
|
104
123
|
cols = _plain_columns(section.signal, informational=informational)
|
|
105
124
|
rows = [_plain_row(s, informational=informational) for s in shown]
|
|
@@ -127,28 +146,75 @@ def _fixed_table(section: SignalSection, top_n: int) -> str:
|
|
|
127
146
|
return "\n".join(lines)
|
|
128
147
|
|
|
129
148
|
|
|
130
|
-
def _summary_text(lines: Sequence[SummaryLine], *,
|
|
149
|
+
def _summary_text(lines: Sequence[SummaryLine], *, names: int) -> str:
|
|
131
150
|
"""The standardised footer as Slack mrkdwn: count lines then name lists.
|
|
132
151
|
|
|
133
152
|
One line per count (failures first), each prefixed with its shared glyph,
|
|
134
153
|
followed by the disabled/excluded repository name lists. Brevity-first, so
|
|
135
154
|
no per-category description is emitted.
|
|
155
|
+
|
|
156
|
+
``names`` caps each name list at an absolute number of entries. It is
|
|
157
|
+
resolved by :func:`_name_cap` before it gets here, so ``0`` means "list no
|
|
158
|
+
names" -- not ``truncate``'s "no limit" -- and drops the enumerations
|
|
159
|
+
entirely. Nothing is lost by that: every name list has a count line above
|
|
160
|
+
it, and the count lines always survive.
|
|
136
161
|
"""
|
|
137
162
|
out: list[str] = []
|
|
138
163
|
for line in lines:
|
|
139
164
|
out.append(f"{SUMMARY_EMOJI[line.kind]} {line.text}")
|
|
165
|
+
if names <= 0:
|
|
166
|
+
return "\n".join(out)
|
|
140
167
|
for line in lines:
|
|
141
168
|
label = _NAME_LIST_LABEL.get(line.kind)
|
|
142
169
|
if not (label and line.names):
|
|
143
170
|
continue
|
|
144
|
-
shown, hidden = truncate(line.names,
|
|
145
|
-
|
|
171
|
+
shown, hidden = truncate(line.names, names)
|
|
172
|
+
names_text = ", ".join(shown)
|
|
146
173
|
if hidden:
|
|
147
|
-
|
|
148
|
-
out.append(f"{label}: {
|
|
174
|
+
names_text += f" … (+{hidden} more)"
|
|
175
|
+
out.append(f"{label}: {names_text}")
|
|
149
176
|
return "\n".join(out)
|
|
150
177
|
|
|
151
178
|
|
|
179
|
+
def _name_breaks(lines: Sequence[SummaryLine]) -> tuple[int, ...]:
|
|
180
|
+
"""Lengths of the rendered name lists, where the allowance stops being
|
|
181
|
+
monotonic.
|
|
182
|
+
|
|
183
|
+
A list completing mid-range drops its "… (+N more)" suffix, which can
|
|
184
|
+
shorten the block even as the allowance rises. Handing these to
|
|
185
|
+
:func:`fit_section_text` puts each transition on a search boundary.
|
|
186
|
+
"""
|
|
187
|
+
return tuple(
|
|
188
|
+
sorted(
|
|
189
|
+
{
|
|
190
|
+
len(line.names)
|
|
191
|
+
for line in lines
|
|
192
|
+
if line.kind in _NAME_LIST_LABEL and line.names
|
|
193
|
+
}
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _name_cap(lines: Sequence[SummaryLine], top_n: int) -> int:
|
|
199
|
+
"""Resolve the configured limit into an absolute name-list allowance.
|
|
200
|
+
|
|
201
|
+
``top_n`` carries the documented "``0`` means no limit" convention, which
|
|
202
|
+
the character budget cannot work with -- it needs to be able to ask for
|
|
203
|
+
*fewer* names, including none. Resolving "no limit" to the longest list
|
|
204
|
+
present makes every allowance an ordinary count.
|
|
205
|
+
|
|
206
|
+
The result is bounded by that longest list either way. ``top_n`` is
|
|
207
|
+
operator-supplied with no schema maximum, and an allowance beyond the names
|
|
208
|
+
that exist renders identically to one that stops at them -- so passing the
|
|
209
|
+
raw value through would only make the budget's search probe a wide range of
|
|
210
|
+
indistinguishable outcomes before it could move on to shedding rows.
|
|
211
|
+
"""
|
|
212
|
+
longest = max((len(line.names) for line in lines), default=0)
|
|
213
|
+
if top_n > 0:
|
|
214
|
+
return min(top_n, longest)
|
|
215
|
+
return longest
|
|
216
|
+
|
|
217
|
+
|
|
152
218
|
def _fixed_table_generic(columns: tuple[str, ...], rows: list[list[str]]) -> str:
|
|
153
219
|
"""Fixed-width text table for a generic posture/freshness table."""
|
|
154
220
|
widths = [len(c) for c in columns]
|
|
@@ -174,30 +240,73 @@ def _table_block(
|
|
|
174
240
|
(genuinely no data) is skipped, keeping the brevity-first digest tight. The
|
|
175
241
|
explanatory description is omitted: Slack is a brevity-first surface.
|
|
176
242
|
"""
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
)
|
|
181
|
-
if not shown and not summary:
|
|
243
|
+
lines = build_summary(section.summary_counts(excluded))
|
|
244
|
+
row_cap = len(truncate(section.rows, top_n)[0])
|
|
245
|
+
name_cap = _name_cap(lines, top_n)
|
|
246
|
+
if not row_cap and not _summary_text(lines, names=name_cap):
|
|
182
247
|
return None
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
248
|
+
|
|
249
|
+
def build(rows: int, names: int) -> str:
|
|
250
|
+
text = f"*{section.title}*"
|
|
251
|
+
if rows:
|
|
252
|
+
shown = section.rows[:rows]
|
|
253
|
+
cells = [[row.repo.name, *row.cells] for row in shown]
|
|
254
|
+
totals = table_column_totals(section, shown)
|
|
255
|
+
if totals is not None:
|
|
256
|
+
cells.append(list(totals))
|
|
257
|
+
table = _fixed_table_generic(section.columns, cells)
|
|
258
|
+
hidden = len(section.rows) - len(shown)
|
|
259
|
+
if hidden:
|
|
260
|
+
table += f"\n… and {hidden} more"
|
|
261
|
+
text += f"\n```\n{table}\n```"
|
|
262
|
+
summary = _summary_text(lines, names=names)
|
|
263
|
+
if summary:
|
|
264
|
+
text += f"\n{summary}"
|
|
265
|
+
return text
|
|
266
|
+
|
|
195
267
|
return {
|
|
196
268
|
"type": "section",
|
|
197
|
-
"text": {
|
|
269
|
+
"text": {
|
|
270
|
+
"type": "mrkdwn",
|
|
271
|
+
"text": fit_section_text(
|
|
272
|
+
build,
|
|
273
|
+
rows=row_cap,
|
|
274
|
+
names=name_cap,
|
|
275
|
+
name_breaks=_name_breaks(lines),
|
|
276
|
+
),
|
|
277
|
+
},
|
|
198
278
|
}
|
|
199
279
|
|
|
200
280
|
|
|
281
|
+
def _signal_block(
|
|
282
|
+
section: SignalSection, top_n: int, *, excluded: Sequence[Repo]
|
|
283
|
+
) -> dict:
|
|
284
|
+
"""A Slack section block for one signal's offender table and footer."""
|
|
285
|
+
lines = build_summary(section.summary_counts(excluded))
|
|
286
|
+
|
|
287
|
+
def build(rows: int, names: int) -> str:
|
|
288
|
+
text = f"*{section.signal.heading}*"
|
|
289
|
+
if section.offenders:
|
|
290
|
+
text += f"\n```\n{_fixed_table(section, rows)}\n```"
|
|
291
|
+
summary = _summary_text(lines, names=names)
|
|
292
|
+
if summary:
|
|
293
|
+
text += f"\n{summary}"
|
|
294
|
+
elif not section.offenders:
|
|
295
|
+
# Only genuine absence of data (no rows and no countable state)
|
|
296
|
+
# warrants "no data"; an all-offender table has nothing to add.
|
|
297
|
+
text += "\nno data"
|
|
298
|
+
return text
|
|
299
|
+
|
|
300
|
+
row_cap = len(truncate(section.offenders, top_n)[0])
|
|
301
|
+
text = fit_section_text(
|
|
302
|
+
build,
|
|
303
|
+
rows=row_cap,
|
|
304
|
+
names=_name_cap(lines, top_n),
|
|
305
|
+
name_breaks=_name_breaks(lines),
|
|
306
|
+
)
|
|
307
|
+
return {"type": "section", "text": {"type": "mrkdwn", "text": text}}
|
|
308
|
+
|
|
309
|
+
|
|
201
310
|
def render_org_blocks(
|
|
202
311
|
org: OrgReport,
|
|
203
312
|
*,
|
|
@@ -215,24 +324,13 @@ def render_org_blocks(
|
|
|
215
324
|
# identically (both mean "no limit"), so normalise None to 0 here.
|
|
216
325
|
return resolve(key) or 0
|
|
217
326
|
|
|
218
|
-
blocks: list[dict] = [
|
|
219
|
-
{
|
|
220
|
-
"type": "header",
|
|
221
|
-
"text": {"type": "plain_text", "text": f"🔐 Security report: {org.org}"},
|
|
222
|
-
}
|
|
223
|
-
]
|
|
327
|
+
blocks: list[dict] = [header_block(f"🔐 Security report: {org.org}")]
|
|
224
328
|
if org.partial:
|
|
225
329
|
blocks.append(
|
|
226
|
-
|
|
227
|
-
"
|
|
228
|
-
"
|
|
229
|
-
|
|
230
|
-
"type": "mrkdwn",
|
|
231
|
-
"text": "⚠️ Incomplete: the repository listing could not "
|
|
232
|
-
"be fully read; some repositories may be missing.",
|
|
233
|
-
}
|
|
234
|
-
],
|
|
235
|
-
}
|
|
330
|
+
context_block(
|
|
331
|
+
"⚠️ Incomplete: the repository listing could not "
|
|
332
|
+
"be fully read; some repositories may be missing."
|
|
333
|
+
)
|
|
236
334
|
)
|
|
237
335
|
excluded = org.excluded_repos
|
|
238
336
|
|
|
@@ -249,12 +347,11 @@ def render_org_blocks(
|
|
|
249
347
|
for section in org.sections:
|
|
250
348
|
key = section.signal.category_key
|
|
251
349
|
if visible(key):
|
|
252
|
-
section_top_n = limit_for(key)
|
|
253
|
-
text = f"*{section.signal.heading}*"
|
|
254
350
|
if section.skipped:
|
|
255
351
|
# Feature gating found no organisation support: one skip line
|
|
256
352
|
# linking the setup guide, instead of a table and footer.
|
|
257
|
-
text
|
|
353
|
+
text = (
|
|
354
|
+
f"*{section.signal.heading}*"
|
|
258
355
|
f"\n{SUMMARY_EMOJI['excluded']} {SKIP_MESSAGE} — "
|
|
259
356
|
f"<{ORG_SETUP_DOC_URL}|setup guide>"
|
|
260
357
|
)
|
|
@@ -262,19 +359,7 @@ def render_org_blocks(
|
|
|
262
359
|
{"type": "section", "text": {"type": "mrkdwn", "text": text}}
|
|
263
360
|
)
|
|
264
361
|
continue
|
|
265
|
-
|
|
266
|
-
table = _fixed_table(section, section_top_n)
|
|
267
|
-
text += f"\n```\n{table}\n```"
|
|
268
|
-
summary = _summary_text(
|
|
269
|
-
build_summary(section.summary_counts(excluded)), top_n=section_top_n
|
|
270
|
-
)
|
|
271
|
-
if summary:
|
|
272
|
-
text += f"\n{summary}"
|
|
273
|
-
elif not section.offenders:
|
|
274
|
-
# Only genuine absence of data (no rows and no countable state)
|
|
275
|
-
# warrants "no data"; an all-offender table has nothing to add.
|
|
276
|
-
text += "\nno data"
|
|
277
|
-
blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": text}})
|
|
362
|
+
blocks.append(_signal_block(section, limit_for(key), excluded=excluded))
|
|
278
363
|
# Dependabot posture sub-tables follow the Dependabot signal block.
|
|
279
364
|
if section.signal is SignalType.DEPENDABOT:
|
|
280
365
|
for table_section in org.dependabot_tables:
|
|
@@ -284,38 +369,17 @@ def render_org_blocks(
|
|
|
284
369
|
add_table(org.private_vulnerability_reporting)
|
|
285
370
|
add_table(org.issues)
|
|
286
371
|
if pages_url:
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
372
|
+
# Link straight to this organisation's latest report page rather than
|
|
373
|
+
# the Pages index: the digest is per-org, so the index is a detour.
|
|
374
|
+
org_url = f"{pages_url.rstrip('/')}/{slugify(org.org)}/report.html"
|
|
375
|
+
link = f"<{org_url}|View the full report>"
|
|
376
|
+
# Omit the link rather than clamp it: a cut URL resolves elsewhere,
|
|
377
|
+
# which is a wrong answer rather than a missing one.
|
|
378
|
+
if text_length(link) <= MAX_TEXT_CHARS:
|
|
379
|
+
blocks.append(context_block(link))
|
|
295
380
|
return blocks
|
|
296
381
|
|
|
297
382
|
|
|
298
|
-
def _enforce_block_limit(blocks: list[dict], pages_url: str | None) -> list[dict]:
|
|
299
|
-
"""Cap blocks at Slack's per-message limit, noting any truncation.
|
|
300
|
-
|
|
301
|
-
A digest covering many orgs can exceed 50 blocks, which makes Slack reject
|
|
302
|
-
the entire message (no digest delivered). Keep the first blocks and replace
|
|
303
|
-
the overflow with a single note pointing at the full report.
|
|
304
|
-
"""
|
|
305
|
-
if len(blocks) <= _SLACK_MAX_BLOCKS:
|
|
306
|
-
return blocks
|
|
307
|
-
if pages_url:
|
|
308
|
-
note = (
|
|
309
|
-
f"… digest truncated to Slack's {_SLACK_MAX_BLOCKS}-block limit; "
|
|
310
|
-
f"<{pages_url}|view the full report>."
|
|
311
|
-
)
|
|
312
|
-
else:
|
|
313
|
-
note = f"… digest truncated to Slack's {_SLACK_MAX_BLOCKS}-block limit."
|
|
314
|
-
kept = blocks[: _SLACK_MAX_BLOCKS - 1]
|
|
315
|
-
kept.append({"type": "context", "elements": [{"type": "mrkdwn", "text": note}]})
|
|
316
|
-
return kept
|
|
317
|
-
|
|
318
|
-
|
|
319
383
|
def render_payload(
|
|
320
384
|
orgs: list[OrgReport],
|
|
321
385
|
*,
|
|
@@ -333,10 +397,10 @@ def render_payload(
|
|
|
333
397
|
org, top_n=top_n, pages_url=pages_url, show=show, limit=limit
|
|
334
398
|
)
|
|
335
399
|
)
|
|
336
|
-
blocks =
|
|
400
|
+
blocks = enforce_block_limit(blocks, pages_url)
|
|
337
401
|
names = ", ".join(o.org for o in orgs)
|
|
338
402
|
return {
|
|
339
403
|
"channel": channel,
|
|
340
|
-
"text": f"🔐 Security report: {names}",
|
|
404
|
+
"text": fallback_text(f"🔐 Security report: {names}"),
|
|
341
405
|
"blocks": blocks,
|
|
342
406
|
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 The Linux Foundation
|
|
3
|
+
"""Keeping a Slack payload inside Slack's hard structural limits.
|
|
4
|
+
|
|
5
|
+
Slack validates a ``chat.postMessage`` payload as a whole: exceeding any one
|
|
6
|
+
limit rejects the **entire** message, so a single oversized table costs the
|
|
7
|
+
whole digest rather than degrading it. This module is the one place those
|
|
8
|
+
limits are named and enforced:
|
|
9
|
+
|
|
10
|
+
* the **50-block** per-message ceiling, which a digest spanning many
|
|
11
|
+
organisations crosses (see :func:`enforce_block_limit`);
|
|
12
|
+
* the **3,000-character** ceiling on a text object, which an uncapped table
|
|
13
|
+
crosses long before it reaches 50 blocks (see :func:`fit_section_text`).
|
|
14
|
+
The same limit applies to a ``context`` element, whose text carries the
|
|
15
|
+
caller-supplied ``pages_url`` and so is *not* bounded by construction (see
|
|
16
|
+
:func:`context_block`);
|
|
17
|
+
* the **150-character** ceiling on a ``header``, whose text carries a
|
|
18
|
+
configured organisation name (see :func:`header_block`);
|
|
19
|
+
* the **40,000-character** ceiling on the top-level ``text`` fallback, which
|
|
20
|
+
concatenates every organisation name (see :func:`fallback_text`).
|
|
21
|
+
|
|
22
|
+
Only one limit is left unguarded, and it is genuinely structural rather than
|
|
23
|
+
data-dependent: a ``context`` block may hold 10 elements and every one built
|
|
24
|
+
here holds exactly one. Everything an operator can influence -- configured
|
|
25
|
+
organisation names, ``pages_url``, and any "no limit" row setting -- is
|
|
26
|
+
measured rather than assumed.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from collections.abc import Callable, Sequence
|
|
32
|
+
|
|
33
|
+
# Slack rejects a chat.postMessage with more than 50 blocks, so a digest
|
|
34
|
+
# spanning many orgs must be capped or the whole message fails to deliver.
|
|
35
|
+
MAX_BLOCKS = 50
|
|
36
|
+
|
|
37
|
+
# Slack's text object caps ``text`` at 3,000 characters wherever it appears --
|
|
38
|
+
# section bodies and context elements alike -- and rejects the whole payload
|
|
39
|
+
# with it. Reachable through any documented "no limit" setting (``top_n: 0`` at
|
|
40
|
+
# report, surface or category level), which uncaps a table long before 50 blocks
|
|
41
|
+
# are in play.
|
|
42
|
+
MAX_TEXT_CHARS = 3000
|
|
43
|
+
|
|
44
|
+
# A header block uses a plain_text object with its own, much tighter ceiling.
|
|
45
|
+
MAX_HEADER_CHARS = 150
|
|
46
|
+
|
|
47
|
+
# The top-level ``text`` fallback (notification preview) has a far larger one.
|
|
48
|
+
MAX_FALLBACK_CHARS = 40000
|
|
49
|
+
|
|
50
|
+
# Appended when even a row-less, name-less block will not fit: a guaranteed
|
|
51
|
+
# last resort so an over-budget block can never leave this module.
|
|
52
|
+
_CLAMP_NOTE = "\n… truncated"
|
|
53
|
+
_FENCE = "```"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def text_length(text: str) -> int:
|
|
57
|
+
"""Length of ``text`` as Slack measures it, in UTF-16 code units.
|
|
58
|
+
|
|
59
|
+
Slack's limits are enforced by a JavaScript-facing API, where string length
|
|
60
|
+
counts UTF-16 code units, so an astral-plane emoji costs two. Python's
|
|
61
|
+
``len`` counts code points and would under-count, letting a payload that
|
|
62
|
+
Slack rejects slip through. Counting UTF-16 is also the safe choice if
|
|
63
|
+
Slack in fact counts code points: it is never smaller, so it can only make
|
|
64
|
+
this module conservative, never permissive.
|
|
65
|
+
|
|
66
|
+
``surrogatepass`` because operator-controlled strings can carry an unpaired
|
|
67
|
+
surrogate: :func:`json.loads` accepts ``\\ud800`` in a configured
|
|
68
|
+
organisation name, and POSIX argument decoding turns undecodable bytes into
|
|
69
|
+
lone surrogates. The strict encoder raises ``UnicodeEncodeError`` on those,
|
|
70
|
+
which would abort report generation outright -- a worse failure than the
|
|
71
|
+
oversized payload this module exists to prevent, and one introduced by the
|
|
72
|
+
measuring rather than by the data.
|
|
73
|
+
"""
|
|
74
|
+
return len(text.encode("utf-16-le", "surrogatepass")) // 2
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def clamp(text: str, budget: int = MAX_TEXT_CHARS) -> str:
|
|
78
|
+
"""Hard-cut ``text`` to ``budget``, closing any code fence left open.
|
|
79
|
+
|
|
80
|
+
The unconditional backstop beneath :func:`fit_section_text`: it makes the
|
|
81
|
+
limit an invariant of this module rather than something every caller has to
|
|
82
|
+
get right. Cutting mid-table would leave an unterminated code fence, which
|
|
83
|
+
Slack renders as the rest of the block swallowed into a code span, so an odd
|
|
84
|
+
fence count is balanced before the truncation note is appended.
|
|
85
|
+
|
|
86
|
+
A budget too small to hold that note degrades further rather than
|
|
87
|
+
overshooting -- a bare ellipsis, then nothing at all -- because a backstop
|
|
88
|
+
that quietly exceeds its own budget is worse than no backstop. Only an
|
|
89
|
+
explicit ``budget`` argument can reach those cases; every production caller
|
|
90
|
+
passes :data:`MAX_TEXT_CHARS`.
|
|
91
|
+
"""
|
|
92
|
+
if text_length(text) <= budget:
|
|
93
|
+
return text
|
|
94
|
+
reserved = text_length(_CLAMP_NOTE) + text_length(f"\n{_FENCE}")
|
|
95
|
+
if budget < reserved:
|
|
96
|
+
return _ellipsize(text, budget)
|
|
97
|
+
room = budget - reserved
|
|
98
|
+
cut = text[:room]
|
|
99
|
+
# Re-measure rather than trusting the slice: ``room`` is a UTF-16 budget but
|
|
100
|
+
# the slice is by code point, so a surrogate pair can overshoot it.
|
|
101
|
+
while cut and text_length(cut) > room:
|
|
102
|
+
cut = cut[:-1]
|
|
103
|
+
if cut.count(_FENCE) % 2:
|
|
104
|
+
cut += f"\n{_FENCE}"
|
|
105
|
+
return cut + _CLAMP_NOTE
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _largest_fitting(
|
|
109
|
+
render: Callable[[int], str],
|
|
110
|
+
count: int,
|
|
111
|
+
budget: int,
|
|
112
|
+
breaks: Sequence[int] = (),
|
|
113
|
+
) -> int:
|
|
114
|
+
"""Largest ``n <= count`` whose rendered text fits ``budget`` (or ``0``).
|
|
115
|
+
|
|
116
|
+
A binary search, which is valid only where rendered length is non-decreasing
|
|
117
|
+
in ``n``. ``breaks`` names the values where that fails, splitting the range
|
|
118
|
+
into windows that are individually monotonic; each is searched and the best
|
|
119
|
+
result wins. The answer is therefore exact without enumerating every
|
|
120
|
+
candidate -- a few binary searches rather than one linear scan.
|
|
121
|
+
|
|
122
|
+
**Rows need no breaks.** :func:`fit_section_text` searches them only after
|
|
123
|
+
the render at ``count`` has been measured and rejected, so every ``n``
|
|
124
|
+
probed leaves rows hidden and the single "… and N more" note is present
|
|
125
|
+
throughout. Each additional row adds at least a newline while that one note
|
|
126
|
+
can shrink by at most a digit, so length never falls. The step that would
|
|
127
|
+
shorten it -- the note vanishing once nothing is left over -- happens only
|
|
128
|
+
at ``count``, which the precondition excludes.
|
|
129
|
+
|
|
130
|
+
**Names do need them,** because that argument does not survive a second
|
|
131
|
+
list. Each name list carries its own note, and a list shorter than the
|
|
132
|
+
allowance completes *inside* the range: its ``… (+N more)`` suffix vanishes
|
|
133
|
+
while only one entry is added, so with short repository names the render
|
|
134
|
+
gets shorter as the allowance rises. Passing each list's length as a break
|
|
135
|
+
puts every such transition on a window boundary, where it cannot mislead the
|
|
136
|
+
search. Without them a rejected midpoint discards a fitting range above it
|
|
137
|
+
and hides names that would have fitted.
|
|
138
|
+
"""
|
|
139
|
+
starts = sorted({0, *(b for b in breaks if 0 < b <= count)})
|
|
140
|
+
best = 0
|
|
141
|
+
for i, start in enumerate(starts):
|
|
142
|
+
end = starts[i + 1] - 1 if i + 1 < len(starts) else count
|
|
143
|
+
# Within a window length is non-decreasing, so if the smallest value
|
|
144
|
+
# does not fit then nothing above it in this window does either.
|
|
145
|
+
if text_length(render(start)) > budget:
|
|
146
|
+
continue
|
|
147
|
+
lo, hi = start, end
|
|
148
|
+
while lo < hi:
|
|
149
|
+
mid = (lo + hi + 1) // 2
|
|
150
|
+
if text_length(render(mid)) <= budget:
|
|
151
|
+
lo = mid
|
|
152
|
+
else:
|
|
153
|
+
hi = mid - 1
|
|
154
|
+
best = max(best, lo)
|
|
155
|
+
return best
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def fit_section_text(
|
|
159
|
+
render: Callable[[int, int], str],
|
|
160
|
+
*,
|
|
161
|
+
rows: int,
|
|
162
|
+
names: int,
|
|
163
|
+
name_breaks: Sequence[int] = (),
|
|
164
|
+
budget: int = MAX_TEXT_CHARS,
|
|
165
|
+
) -> str:
|
|
166
|
+
"""Fit a section block's text into ``budget`` by shedding content.
|
|
167
|
+
|
|
168
|
+
``render(rows, names)`` must build the complete block text showing the first
|
|
169
|
+
``rows`` table rows and the first ``names`` entries of each repository name
|
|
170
|
+
list, *including* the notes accounting for whatever it left out. Because
|
|
171
|
+
both are plain prefix counts, every reduction here flows back through the
|
|
172
|
+
same ``truncate``-shaped ``(shown, hidden)`` accounting the other surfaces
|
|
173
|
+
use, so the "… and N more" tallies stay honest instead of being tracked by a
|
|
174
|
+
second, parallel mechanism.
|
|
175
|
+
|
|
176
|
+
Content is shed in ascending order of value. Repository **name lists** go
|
|
177
|
+
first: they are pure enumeration, they are the part that grows without bound
|
|
178
|
+
on a large organisation, and dropping them costs nothing that matters --
|
|
179
|
+
their *counts* live on separate summary lines that always survive. **Table
|
|
180
|
+
rows** go next, and only as far as needed; they are ordered worst-first, so
|
|
181
|
+
a prefix is the most valuable part of the table.
|
|
182
|
+
|
|
183
|
+
Rows are shed rather than split across additional blocks. Blocks are a
|
|
184
|
+
scarce *global* resource -- 50 for the whole digest, shared by every
|
|
185
|
+
organisation -- so spending them on one oversized table would evict other
|
|
186
|
+
organisations from the message entirely, trading a partial table for total
|
|
187
|
+
data loss elsewhere. Slack is a brevity-first surface and the digest links
|
|
188
|
+
the full report whenever a usable URL is configured, so shedding rows and
|
|
189
|
+
saying so is the right degradation.
|
|
190
|
+
|
|
191
|
+
One probe decides which of the two is actually at fault. If the block still
|
|
192
|
+
will not fit with *no* names at all, then no name allowance can save it and
|
|
193
|
+
the rows are the problem, so the name search is skipped entirely rather than
|
|
194
|
+
rebuilding a large table across a range of allowances that cannot help.
|
|
195
|
+
|
|
196
|
+
``name_breaks`` carries the length of each repository name list, marking
|
|
197
|
+
where the name allowance stops behaving monotonically; see
|
|
198
|
+
:func:`_largest_fitting`.
|
|
199
|
+
"""
|
|
200
|
+
text = render(rows, names)
|
|
201
|
+
if text_length(text) <= budget:
|
|
202
|
+
return text
|
|
203
|
+
if text_length(render(rows, 0)) <= budget:
|
|
204
|
+
names = _largest_fitting(lambda n: render(rows, n), names, budget, name_breaks)
|
|
205
|
+
return render(rows, names)
|
|
206
|
+
rows = _largest_fitting(lambda n: render(n, 0), rows, budget)
|
|
207
|
+
return clamp(render(rows, 0), budget)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _ellipsize(text: str, budget: int) -> str:
|
|
211
|
+
"""Cut plain text to ``budget``, marking the cut with an ellipsis.
|
|
212
|
+
|
|
213
|
+
For the header and fallback strings, where :func:`clamp`'s code-fence
|
|
214
|
+
handling and multi-line note would be out of place, and as :func:`clamp`'s
|
|
215
|
+
own fallback when the budget cannot hold that note. A budget with no room
|
|
216
|
+
even for the ellipsis yields the empty string: there is nothing truthful
|
|
217
|
+
left to say in zero characters.
|
|
218
|
+
"""
|
|
219
|
+
if text_length(text) <= budget:
|
|
220
|
+
return text
|
|
221
|
+
if budget <= 0:
|
|
222
|
+
return ""
|
|
223
|
+
room = budget - 1
|
|
224
|
+
cut = text[:room]
|
|
225
|
+
# Re-measure: the budget is in UTF-16 units but the slice is by code point.
|
|
226
|
+
while cut and text_length(cut) > room:
|
|
227
|
+
cut = cut[:-1]
|
|
228
|
+
return f"{cut}…"
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def header_block(text: str) -> dict:
|
|
232
|
+
"""A header block sized to Slack's plain_text ceiling.
|
|
233
|
+
|
|
234
|
+
The heading embeds a configured organisation name. GitHub caps its own
|
|
235
|
+
logins well below the limit, but the name reaching here comes from the
|
|
236
|
+
tool's configuration rather than from GitHub, so it is measured rather
|
|
237
|
+
than assumed.
|
|
238
|
+
"""
|
|
239
|
+
return {
|
|
240
|
+
"type": "header",
|
|
241
|
+
"text": {"type": "plain_text", "text": _ellipsize(text, MAX_HEADER_CHARS)},
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def context_block(text: str) -> dict:
|
|
246
|
+
"""A context block sized to Slack's text object ceiling.
|
|
247
|
+
|
|
248
|
+
Context elements carry the caller-supplied ``pages_url``, which nothing
|
|
249
|
+
validates for length, so the same 3,000-character limit applies here as to
|
|
250
|
+
a section body. Callers that build a link must check it fits *before*
|
|
251
|
+
passing it in and omit it otherwise: a clamped URL points somewhere other
|
|
252
|
+
than the report, which is a wrong answer rather than a missing one.
|
|
253
|
+
"""
|
|
254
|
+
return {"type": "context", "elements": [{"type": "mrkdwn", "text": clamp(text)}]}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def fallback_text(text: str) -> str:
|
|
258
|
+
"""The top-level notification text, sized to Slack's ceiling.
|
|
259
|
+
|
|
260
|
+
It concatenates every organisation name in the digest, so it grows with the
|
|
261
|
+
configuration even though the limit is generous.
|
|
262
|
+
"""
|
|
263
|
+
return _ellipsize(text, MAX_FALLBACK_CHARS)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def enforce_block_limit(blocks: list[dict], pages_url: str | None) -> list[dict]:
|
|
267
|
+
"""Cap blocks at Slack's per-message limit, noting any truncation.
|
|
268
|
+
|
|
269
|
+
A digest covering many orgs can exceed 50 blocks, which makes Slack reject
|
|
270
|
+
the entire message (no digest delivered). Keep the first blocks and replace
|
|
271
|
+
the overflow with a single note pointing at the full report.
|
|
272
|
+
"""
|
|
273
|
+
if len(blocks) <= MAX_BLOCKS:
|
|
274
|
+
return blocks
|
|
275
|
+
note = f"… digest truncated to Slack's {MAX_BLOCKS}-block limit."
|
|
276
|
+
if pages_url:
|
|
277
|
+
linked = (
|
|
278
|
+
f"… digest truncated to Slack's {MAX_BLOCKS}-block limit; "
|
|
279
|
+
f"<{pages_url}|view the full report>."
|
|
280
|
+
)
|
|
281
|
+
# Prefer the linked note, but fall back to the bare one rather than
|
|
282
|
+
# clamping a URL into something that no longer resolves.
|
|
283
|
+
if text_length(linked) <= MAX_TEXT_CHARS:
|
|
284
|
+
note = linked
|
|
285
|
+
kept = blocks[: MAX_BLOCKS - 1]
|
|
286
|
+
kept.append(context_block(note))
|
|
287
|
+
return kept
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/gating.py
RENAMED
|
File without changes
|
{github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/gitctx.py
RENAMED
|
File without changes
|
{github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/issues.py
RENAMED
|
File without changes
|
|
File without changes
|
{github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/report.py
RENAMED
|
File without changes
|
|
File without changes
|
{github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/runner.py
RENAMED
|
File without changes
|
{github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/scope.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|