github-security-report 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,147 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 The Linux Foundation
3
+ """CLI support: mode resolution, fail-threshold, and GitHub Actions I/O.
4
+
5
+ Pure helpers kept out of the Typer command so they can be unit-tested. See
6
+ ``docs/BRIEF.md`` sections 9-12.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import os
13
+ import re
14
+ import secrets
15
+ from enum import Enum
16
+
17
+ from github_security_report.models import RepoSignal
18
+ from github_security_report.severity import Severity
19
+
20
+ log = logging.getLogger(__name__)
21
+
22
+ # GitHub Actions output names are alphanumeric plus '-'/'_'. Validating the key
23
+ # (not just the value) stops a non-identifier key -- e.g. one containing a
24
+ # newline, '=' or '<<' -- from corrupting $GITHUB_OUTPUT or injecting outputs.
25
+ _OUTPUT_KEY = re.compile(r"^[A-Za-z_][A-Za-z0-9_-]*$")
26
+
27
+
28
+ class Mode(str, Enum):
29
+ ORG = "org"
30
+ REPO = "repo"
31
+
32
+
33
+ class ModeError(RuntimeError):
34
+ """Raised when an operating mode cannot be resolved."""
35
+
36
+
37
+ def resolve_mode(
38
+ requested: str,
39
+ *,
40
+ has_org_config: bool,
41
+ detected_repo: tuple[str, str] | None,
42
+ ) -> Mode:
43
+ """Resolve the operating mode, logging the outcome loudly.
44
+
45
+ Precedence: an explicit ``org``/``repo`` request wins; ``auto`` resolves to
46
+ org when org config is present, else repo when a repository was detected.
47
+ """
48
+ requested = requested.lower()
49
+ if requested == "org":
50
+ if not has_org_config:
51
+ raise ModeError("scope 'org' requires organisation configuration")
52
+ mode = Mode.ORG
53
+ elif requested == "repo":
54
+ if detected_repo is None:
55
+ raise ModeError("scope 'repo' requires a detected/--specified repository")
56
+ mode = Mode.REPO
57
+ elif requested == "auto":
58
+ if has_org_config:
59
+ mode = Mode.ORG
60
+ elif detected_repo is not None:
61
+ mode = Mode.REPO
62
+ else:
63
+ raise ModeError(
64
+ "cannot resolve scope: provide config (org mode) or run inside a "
65
+ "GitHub checkout (repo mode)"
66
+ )
67
+ else:
68
+ raise ModeError(f"unknown scope: {requested!r}")
69
+ log.info("resolved operating mode: %s", mode.value)
70
+ return mode
71
+
72
+
73
+ _THRESHOLDS = {
74
+ "none": None,
75
+ "low": Severity.LOW,
76
+ "medium": Severity.MEDIUM,
77
+ "high": Severity.HIGH,
78
+ "critical": Severity.CRITICAL,
79
+ }
80
+
81
+
82
+ def _max_severity(sig: RepoSignal) -> Severity | None:
83
+ c = sig.counts
84
+ if c.critical:
85
+ return Severity.CRITICAL
86
+ if c.high:
87
+ return Severity.HIGH
88
+ if c.medium:
89
+ return Severity.MEDIUM
90
+ if c.low:
91
+ return Severity.LOW
92
+ return None
93
+
94
+
95
+ def should_fail(signals: list[RepoSignal], threshold: str) -> bool:
96
+ """Whether the run should fail given a severity threshold (repo-mode gate).
97
+
98
+ ``none`` never fails; ``any`` fails on any offender; a severity name fails
99
+ when any offending signal has a finding at or above that severity.
100
+ """
101
+ threshold = threshold.lower()
102
+ if threshold == "none":
103
+ return False
104
+ if threshold == "any":
105
+ return any(s.is_offender for s in signals)
106
+ if threshold not in _THRESHOLDS:
107
+ raise ModeError(f"unknown fail threshold: {threshold!r}")
108
+ floor = _THRESHOLDS[threshold]
109
+ assert floor is not None
110
+ return any(
111
+ s.is_offender and (sev := _max_severity(s)) is not None and sev >= floor
112
+ for s in signals
113
+ )
114
+
115
+
116
+ def write_github_output(values: dict[str, str], path: str | None = None) -> None:
117
+ """Append ``key=value`` pairs to ``$GITHUB_OUTPUT`` (multiline-safe).
118
+
119
+ Multiline values use a unique random delimiter per value, regenerated if it
120
+ ever collides with the value, to prevent output-file injection.
121
+ """
122
+ target = path or os.environ.get("GITHUB_OUTPUT")
123
+ if not target:
124
+ return
125
+ with open(target, "a", encoding="utf-8") as handle:
126
+ for key, value in values.items():
127
+ if not _OUTPUT_KEY.match(key):
128
+ # A non-identifier key would corrupt the output file; skip it
129
+ # loudly rather than write something injectable.
130
+ log.error("refusing to write unsafe GITHUB_OUTPUT key: %r", key)
131
+ continue
132
+ if "\n" in value:
133
+ delimiter = f"ghadelim_{secrets.token_hex(16)}"
134
+ while delimiter in value:
135
+ delimiter = f"ghadelim_{secrets.token_hex(16)}"
136
+ handle.write(f"{key}<<{delimiter}\n{value}\n{delimiter}\n")
137
+ else:
138
+ handle.write(f"{key}={value}\n")
139
+
140
+
141
+ def append_step_summary(markdown: str, path: str | None = None) -> None:
142
+ """Append Markdown to ``$GITHUB_STEP_SUMMARY`` if available."""
143
+ target = path or os.environ.get("GITHUB_STEP_SUMMARY")
144
+ if not target:
145
+ return
146
+ with open(target, "a", encoding="utf-8") as handle:
147
+ handle.write(markdown.rstrip() + "\n")
@@ -0,0 +1,97 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 The Linux Foundation
3
+ """Repository scoping and exclusions.
4
+
5
+ Default in-scope set: non-archived, non-fork, non-template source repos. Test
6
+ repositories are excluded by **token-delimited** matching (a ``test``/``tests``
7
+ segment after splitting on ``-_./``), never a raw substring -- so ``latest``,
8
+ ``attestation`` and ``contest`` are not dropped. Archived and test repos never
9
+ appear in nag lists. Every exclusion is logged with its reason so nothing is
10
+ dropped silently. See ``docs/BRIEF.md`` section 7.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import re
17
+ from dataclasses import dataclass
18
+
19
+ from github_security_report.models import Repo
20
+
21
+ log = logging.getLogger(__name__)
22
+
23
+ _SEGMENT_SPLIT = re.compile(r"[-_./]+")
24
+ _TEST_SEGMENTS = {"test", "tests"}
25
+
26
+
27
+ def is_test_named(name: str) -> bool:
28
+ """True when a name segment is exactly ``test`` or ``tests``.
29
+
30
+ Token-delimited, not substring: ``test-action`` and ``foo_test`` match;
31
+ ``latest-tag``, ``attestation`` and ``contest`` do not.
32
+ """
33
+ segments = {seg.lower() for seg in _SEGMENT_SPLIT.split(name) if seg}
34
+ return bool(segments & _TEST_SEGMENTS)
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class ScopeDecision:
39
+ repo: Repo
40
+ included: bool
41
+ reason: str = ""
42
+
43
+
44
+ def decide(
45
+ repo: Repo,
46
+ *,
47
+ include_archived: bool = False,
48
+ include_test: bool = False,
49
+ exclude: frozenset[str] | set[str] | tuple[str, ...] = (),
50
+ ) -> ScopeDecision:
51
+ """Decide whether a single repository is in scope, with a reason."""
52
+ if repo.name in exclude:
53
+ return ScopeDecision(repo, False, "explicitly excluded")
54
+ if repo.fork:
55
+ return ScopeDecision(repo, False, "fork")
56
+ if repo.is_template:
57
+ return ScopeDecision(repo, False, "template")
58
+ if repo.archived and not include_archived:
59
+ return ScopeDecision(repo, False, "archived")
60
+ if is_test_named(repo.name) and not include_test:
61
+ return ScopeDecision(repo, False, "test repository")
62
+ return ScopeDecision(repo, True)
63
+
64
+
65
+ def filter_repos(
66
+ repos: list[Repo],
67
+ *,
68
+ include_archived: bool = False,
69
+ include_test: bool = False,
70
+ exclude: tuple[str, ...] = (),
71
+ ) -> list[Repo]:
72
+ """Return the in-scope repositories, logging every exclusion and reason."""
73
+ exclude_set = set(exclude)
74
+ kept: list[Repo] = []
75
+ for repo in repos:
76
+ decision = decide(
77
+ repo,
78
+ include_archived=include_archived,
79
+ include_test=include_test,
80
+ exclude=exclude_set,
81
+ )
82
+ if decision.included:
83
+ kept.append(repo)
84
+ else:
85
+ log.info("excluding %s: %s", repo.full_name, decision.reason)
86
+ log.info("%d repositories in scope (of %d)", len(kept), len(repos))
87
+ return kept
88
+
89
+
90
+ def in_nag_scope(repo: Repo) -> bool:
91
+ """Whether a repo may appear in nag lists.
92
+
93
+ Archived and test repos are **never** nagged -- you cannot, or would not,
94
+ enable tooling on them -- even when they are otherwise reported (e.g. under
95
+ ``include_archived``/``include_test``).
96
+ """
97
+ return not (repo.archived or is_test_named(repo.name))
@@ -0,0 +1,83 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 The Linux Foundation
3
+ """Severity scale and normalisation.
4
+
5
+ Phase 0 (see ``docs/phase0-findings.md``) established two severity vocabularies
6
+ in the code-scanning feed:
7
+
8
+ - ``rule.security_severity_level`` -- critical / high / medium / low -- used by
9
+ CodeQL and Scorecard, and the primary ranking key.
10
+ - ``rule.severity`` -- error / warning / note -- the SARIF level, the only axis
11
+ zizmor populates.
12
+
13
+ To present a single, uniform set of severity columns across every table (as the
14
+ design requires), the SARIF level is normalised onto the security scale when no
15
+ security severity is present: error -> high, warning -> medium, note -> low.
16
+ Dependabot's ``security_advisory.severity`` maps directly.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from enum import IntEnum
22
+
23
+
24
+ class Severity(IntEnum):
25
+ """Ordered severity. Higher value == more severe (worst-first sorting)."""
26
+
27
+ LOW = 1
28
+ MEDIUM = 2
29
+ HIGH = 3
30
+ CRITICAL = 4
31
+
32
+ @property
33
+ def label(self) -> str:
34
+ return self.name.lower()
35
+
36
+
37
+ # Direct names on the security-severity scale (CodeQL, Scorecard, Dependabot).
38
+ _SECURITY_NAMES: dict[str, Severity] = {
39
+ "critical": Severity.CRITICAL,
40
+ "high": Severity.HIGH,
41
+ "medium": Severity.MEDIUM,
42
+ "moderate": Severity.MEDIUM, # Dependabot uses "moderate"
43
+ "low": Severity.LOW,
44
+ }
45
+
46
+ # SARIF level -> security scale, used only as a fallback (zizmor).
47
+ _SARIF_LEVEL_NAMES: dict[str, Severity] = {
48
+ "error": Severity.HIGH,
49
+ "warning": Severity.MEDIUM,
50
+ "note": Severity.LOW,
51
+ "none": Severity.LOW,
52
+ }
53
+
54
+
55
+ def from_name(value: str | None) -> Severity | None:
56
+ """Parse a security-severity name (critical/high/medium/low/moderate)."""
57
+ if not value:
58
+ return None
59
+ return _SECURITY_NAMES.get(value.strip().lower())
60
+
61
+
62
+ def from_sarif_level(value: str | None) -> Severity | None:
63
+ """Parse a SARIF level (error/warning/note) onto the security scale."""
64
+ if not value:
65
+ return None
66
+ return _SARIF_LEVEL_NAMES.get(value.strip().lower())
67
+
68
+
69
+ def from_code_scanning(
70
+ security_severity_level: str | None,
71
+ sarif_severity: str | None,
72
+ ) -> Severity:
73
+ """Resolve a code-scanning alert's severity.
74
+
75
+ Prefers ``security_severity_level``; falls back to the SARIF ``severity``
76
+ (the zizmor case). Defaults to ``LOW`` when neither is recognised so a
77
+ finding is never silently dropped from ranking.
78
+ """
79
+ return (
80
+ from_name(security_severity_level)
81
+ or from_sarif_level(sarif_severity)
82
+ or Severity.LOW
83
+ )
@@ -0,0 +1,57 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Security reports</title>
7
+ <style>
8
+ * { margin: 0; padding: 0; box-sizing: border-box; }
9
+ body {
10
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;
11
+ line-height: 1.6; color: #333;
12
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
13
+ min-height: 100vh; padding: 2rem 1rem;
14
+ }
15
+ .container { max-width: 1100px; margin: 0 auto; }
16
+ header { background: #fff; border-radius: 12px; padding: 2rem; margin-bottom: 2rem;
17
+ box-shadow: 0 4px 6px rgba(0,0,0,0.1); }
18
+ header h1 { font-size: 2.25rem; color: #2d3748; }
19
+ header .subtitle { color: #718096; margin-top: 0.25rem; }
20
+ header .meta { color: #718096; font-size: 0.9rem; margin-top: 1rem;
21
+ padding-top: 1rem; border-top: 1px solid #e2e8f0; }
22
+ .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1.5rem; }
23
+ .card { background: #fff; border-radius: 12px; padding: 1.5rem; box-shadow: 0 4px 6px rgba(0,0,0,0.1);
24
+ transition: transform 0.2s ease, box-shadow 0.2s ease; }
25
+ .card:hover { transform: translateY(-4px); box-shadow: 0 8px 16px rgba(102,126,234,0.25); }
26
+ .card h2 { font-size: 1.25rem; color: #2d3748; }
27
+ .card .count { color: #718096; font-size: 0.9rem; margin: 0.5rem 0 1rem; }
28
+ .btn { display: inline-block; background: #667eea; color: #fff; padding: 0.5rem 1rem;
29
+ border-radius: 6px; text-decoration: none; font-weight: 500; }
30
+ .btn:hover { background: #5568d3; }
31
+ footer { text-align: center; color: #fff; padding: 2rem; font-size: 0.85rem; }
32
+ footer a { color: #fff; }
33
+ </style>
34
+ </head>
35
+ <body>
36
+ <div class="container">
37
+ <header>
38
+ <h1><span aria-hidden="true">🔐</span> Security reports</h1>
39
+ <p class="subtitle">Security and quality posture across GitHub organisations</p>
40
+ <div class="meta">Generated {{ generated_at }} · {{ orgs | length }} organisation(s)</div>
41
+ </header>
42
+ <div class="grid">
43
+ {% for org in orgs %}
44
+ <div class="card">
45
+ <h2>{{ org.name }}</h2>
46
+ <p class="count">{{ org.repo_count }} repositories</p>
47
+ <a class="btn" href="{{ org.slug }}/report.html">View report →</a>
48
+ </div>
49
+ {% endfor %}
50
+ </div>
51
+ <footer>
52
+ Generated by
53
+ <a href="https://github.com/lfreleng-actions/github-security-report-action">github-security-report</a>
54
+ </footer>
55
+ </div>
56
+ </body>
57
+ </html>
@@ -0,0 +1,162 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Security report: {{ org }}</title>
7
+ {#- Simple-DataTables: version-pinned with Subresource Integrity, so the
8
+ browser rejects a tampered/substituted CDN asset. -#}
9
+ <link rel="stylesheet"
10
+ href="https://cdn.jsdelivr.net/npm/simple-datatables@{{ datatables_version }}/dist/style.css"
11
+ integrity="{{ datatables_css_sri }}"
12
+ crossorigin="anonymous">
13
+ <style>
14
+ * { margin: 0; padding: 0; box-sizing: border-box; }
15
+ body {
16
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;
17
+ line-height: 1.6; color: #1a202c; background: #f7fafc; padding: 2rem 1rem;
18
+ }
19
+ .container { max-width: 1100px; margin: 0 auto; }
20
+ header {
21
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
22
+ color: #fff; border-radius: 12px; padding: 2rem; margin-bottom: 2rem;
23
+ }
24
+ header h1 { font-size: 2rem; }
25
+ header .meta { opacity: 0.9; font-size: 0.9rem; margin-top: 0.5rem; }
26
+ section { background: #fff; border-radius: 12px; padding: 1.5rem 2rem; margin-bottom: 1.5rem;
27
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
28
+ section h2 { color: #2d3748; border-bottom: 2px solid #e2e8f0; padding-bottom: 0.5rem; }
29
+ section h3 { color: #2d3748; margin-top: 1.5rem; font-size: 1.05rem; }
30
+ .note { color: #718096; font-size: 0.85rem; margin-top: 0.5rem; font-style: italic; }
31
+ table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
32
+ th, td { padding: 0.5rem 0.75rem; text-align: left; border-bottom: 1px solid #edf2f7; }
33
+ th { background: #f7fafc; }
34
+ td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
35
+ .clean { color: #2f855a; margin-top: 0.75rem; }
36
+ .nag-title { margin-top: 1rem; font-weight: 600; color: #b7791f; }
37
+ .nag { margin: 0.25rem 0 0 1.25rem; }
38
+ .unknown { color: #718096; font-size: 0.9rem; margin-top: 0.75rem; }
39
+ .empty { color: #718096; font-style: italic; margin-top: 0.5rem; }
40
+ .crit { color: #c53030; font-weight: 600; }
41
+ .partial-banner { background: #fffaf0; border: 1px solid #dd6b20; color: #9c4221;
42
+ border-radius: 8px; padding: 0.75rem 1rem; margin-bottom: 1.5rem; }
43
+ .excluded-banner { background: #ebf8ff; border: 1px solid #4299e1; color: #2c5282;
44
+ border-radius: 8px; padding: 0.75rem 1rem; margin-bottom: 1.5rem; }
45
+ footer { text-align: center; color: #718096; font-size: 0.85rem; padding: 1rem; }
46
+ </style>
47
+ </head>
48
+ <body>
49
+ <div class="container">
50
+ <header>
51
+ <h1><span aria-hidden="true">🔐</span> Security report: {{ org }}</h1>
52
+ <div class="meta">{{ repo_count }} repositories analysed · generated {{ generated_at }}</div>
53
+ </header>
54
+
55
+ {% if partial %}
56
+ <div class="partial-banner" role="alert">
57
+ <span aria-hidden="true">⚠️</span> <strong>Incomplete:</strong> the repository
58
+ listing could not be fully read, so some repositories may be missing from
59
+ this report.
60
+ </div>
61
+ {% endif %}
62
+
63
+ {% if excluded %}
64
+ <div class="excluded-banner">
65
+ <span aria-hidden="true">⏩</span> <strong>Excluded from analysis ({{ excluded_total }}):</strong>
66
+ {% for r in excluded %}<a href="{{ r.url }}">{{ r.name }}</a>{% if not loop.last %}, {% endif %}{% endfor %}{% if excluded_hidden %} … and {{ excluded_hidden }} more{% endif %}
67
+ </div>
68
+ {% endif %}
69
+
70
+ {% for s in sections %}
71
+ <section>
72
+ <h2>{{ s.title }}</h2>
73
+ {% if s.rows %}
74
+ <table class="dt-enabled">
75
+ <thead>
76
+ <tr>{% for col in s.columns %}<th class="{{ 'num' if not loop.first else '' }}">{{ col }}</th>{% endfor %}</tr>
77
+ </thead>
78
+ <tbody>
79
+ {% for row in s.rows %}
80
+ <tr>
81
+ <td><a href="{{ row.url }}">{{ row.name }}</a></td>
82
+ {% for cell in row.cells %}<td class="num">{{ cell }}</td>{% endfor %}
83
+ </tr>
84
+ {% endfor %}
85
+ </tbody>
86
+ </table>
87
+ {% if s.hidden %}<p class="note">… and {{ s.hidden }} more</p>{% endif %}
88
+ {% endif %}
89
+ {% if s.clean_count %}<p class="clean"><span aria-hidden="true">✅</span> {{ s.clean_count }} repositories clean</p>{% endif %}
90
+ {% if s.nag %}
91
+ <p class="nag-title">Not enabled — enable to appear in future reports:</p>
92
+ <ul class="nag">{% for r in s.nag %}<li><a href="{{ r.url }}">{{ r.name }}</a></li>{% endfor %}{% if s.nag_hidden %}<li>… and {{ s.nag_hidden }} more</li>{% endif %}</ul>
93
+ {% endif %}
94
+ {% if s.unknown_count %}<p class="unknown"><span aria-hidden="true">ℹ️</span> {{ s.unknown_count }} repositories with unknown status (insufficient permission or a transient read failure)</p>{% endif %}
95
+ {% if not (s.rows or s.clean_count or s.nag or s.unknown_count) %}<p class="empty">No data available.</p>{% endif %}
96
+ {% for t in s.extra_tables or [] %}
97
+ <h3>{{ t.title }}</h3>
98
+ {% if t.rows %}
99
+ <table class="dt-enabled">
100
+ <thead>
101
+ <tr>{% for col in t.columns %}<th>{{ col }}</th>{% endfor %}</tr>
102
+ </thead>
103
+ <tbody>
104
+ {% for row in t.rows %}
105
+ <tr>
106
+ <td><a href="{{ row.url }}">{{ row.name }}</a></td>
107
+ {% for cell in row.cells %}<td>{{ cell }}</td>{% endfor %}
108
+ </tr>
109
+ {% endfor %}
110
+ </tbody>
111
+ </table>
112
+ {% if t.hidden %}<p class="note">… and {{ t.hidden }} more</p>{% endif %}
113
+ {% elif t.empty_note %}<p class="clean"><span aria-hidden="true">✅</span> {{ t.empty_note }}</p>{% endif %}
114
+ {% if t.rows and t.note %}<p class="note">{{ t.note }}</p>{% endif %}
115
+ {% endfor %}
116
+ </section>
117
+ {% endfor %}
118
+
119
+ {% if releases %}
120
+ <section>
121
+ <h2>{{ releases.title }}</h2>
122
+ {% if releases.rows %}
123
+ <table class="dt-enabled">
124
+ <thead>
125
+ <tr>{% for col in releases.columns %}<th>{{ col }}</th>{% endfor %}</tr>
126
+ </thead>
127
+ <tbody>
128
+ {% for row in releases.rows %}
129
+ <tr>
130
+ <td><a href="{{ row.url }}">{{ row.name }}</a></td>
131
+ {% for cell in row.cells %}<td>{{ cell }}</td>{% endfor %}
132
+ </tr>
133
+ {% endfor %}
134
+ </tbody>
135
+ </table>
136
+ {% if releases.hidden %}<p class="note">… and {{ releases.hidden }} more</p>{% endif %}
137
+ {% elif releases.empty_note %}<p class="clean"><span aria-hidden="true">✅</span> {{ releases.empty_note }}</p>{% endif %}
138
+ {% if releases.rows and releases.note %}<p class="note">{{ releases.note }}</p>{% endif %}
139
+ </section>
140
+ {% endif %}
141
+
142
+ <footer>
143
+ Generated by
144
+ <a href="https://github.com/lfreleng-actions/github-security-report-action">github-security-report</a>
145
+ </footer>
146
+ </div>
147
+
148
+ <script src="https://cdn.jsdelivr.net/npm/simple-datatables@{{ datatables_version }}/dist/umd/simple-datatables.min.js"
149
+ integrity="{{ datatables_js_sri }}"
150
+ crossorigin="anonymous"
151
+ type="text/javascript"></script>
152
+ <script>
153
+ document.addEventListener('DOMContentLoaded', function () {
154
+ document.querySelectorAll('table.dt-enabled').forEach(function (table) {
155
+ if (table.querySelectorAll('tbody tr').length < 3) { return; }
156
+ try { new simpleDatatables.DataTable(table, { perPage: 25 }); }
157
+ catch (e) { console.error('DataTable init failed', e); }
158
+ });
159
+ });
160
+ </script>
161
+ </body>
162
+ </html>