Security and quality reporting across GitHub organisations
Project description
🔐 GitHub Security Report
Security and quality reporting (not scanning) across GitHub organisations. Aggregates existing signals — CodeQL, OpenSSF Scorecard, zizmor, aislop (AI slop), Dependabot, and secret scanning — and ranks the worst offenders so remediation effort goes where it is needed.
🗒️ Published reports
https://lfreleng-actions.github.io/github-security-report-action/
What it does
For each in-scope repository, every signal is classified into one of four states and rendered worst-first:
- Offenders — enabled with open findings (a ranked table row).
- Clean — enabled with zero findings (a count beneath the table).
- Not enabled — supported but switched off (a counted "disabled" footer line, with the affected repositories named).
- Unknown — indeterminate (insufficient permission), counted separately.
Every category renders the same standardised summary footer beneath its table: remediation-first count lines (failures, disabled, unknown, then the healthy pass line, then excluded). The pass line reads "All " when nothing needs attention, or "N " otherwise. The terminal and Slack stay brevity-first; the explanatory per-category description and documentation link are shown only on the richer Markdown and HTML (GitHub Pages) outputs.
The single GitHub code-scanning feed is partitioned by tool.name into CodeQL,
Scorecard, zizmor, and aislop; Scorecard prefers the external aggregate score
and falls back to code-scanning findings. See docs/BRIEF.md
and docs/phase0-findings.md for the full design and
the API research it is built on.
The workflow-driven signals (OpenSSF Scorecard, zizmor, aislop) only produce
data when an organisation has deployed supporting workflows. The tool checks
for that support cheaply before collecting (feature gating): an
organisation with no evidence of a tool — no ruleset requiring its workflow,
no alerts, no analyses on a sample of repositories — gets a single
⏩ Skipping feature: organisation support missing line for that section
instead of a nag list. See the
organisation scan setup guide for the required
workflows, and disable the check with report.gating: false if you want to
probe everything regardless.
Further sections report configuration posture and freshness as plain tables (org mode):
- Dependabot — three tables: repositories with vulnerability alerts not
enabled, repositories with security updates not enabled, and ecosystems
with no update
cooldownconfigured (mandatory; any value passes). - Releases / Tagging — repositories overdue a release or tag, ranked by
release/tag staleness (repository age never affects ordering; a repository
with no release or tag ranks highest). Repositories younger than
repo_min_age_days(default 28;0includes all) and those inreleases_excludeare omitted. A repository is flagged only when its newest release or tag is older thanrelease_max_age_days(default 60;0flags every eligible repository), so a repository released or tagged within that window counts as recently maintained and drops out of the table. - Private Vulnerability Reporting — repositories where GitHub's private
vulnerability reporting feature is not enabled, so security researchers
cannot privately disclose vulnerabilities. Probed per repository (GitHub
exposes no org-wide or GraphQL equivalent) and, like every other category,
always collected; hide it with the
private_vulnerability_reportingrender toggle.
Operating modes
| Mode | Token | Scope | Output |
|---|---|---|---|
org |
fine-grained PAT (single org) or classic PAT (multiple orgs) | one or more organisations | GitHub Pages + Slack + terminal |
repo |
GITHUB_TOKEN |
the current repository only | job summary + outputs + optional PR gate |
scope: auto resolves to org mode when configuration is supplied, otherwise
repo mode for the detected repository. The ephemeral GITHUB_TOKEN cannot read
org-wide security data, so org mode requires a PAT — see
Token permissions for the exact scopes.
Token permissions
Repo mode needs nothing beyond the workflow's ephemeral GITHUB_TOKEN. Org mode
needs a Personal Access Token; choose one of the two options below depending
on how many organisations the report covers.
Almost all required access is read-only. The tool degrades any read it is not permitted to make to an "unknown" status rather than reporting a repository as clean, so an under-scoped token surfaces as unknowns in the report instead of silently wrong results — start minimal and widen if you see unknowns.
The one exception is organisation-ruleset coverage. GitHub gates the
org-rulesets endpoint behind an org-admin permission (classic admin:org scope,
or fine-grained Administration write), even though the tool only reads it.
That coverage is optional: it detects tools enforced through an org ruleset
(for example a required-workflow or code-scanning ruleset). Without it that one
signal is skipped and every other part of the report is unaffected, so the
minimal tokens below omit it. Grant the org-admin permission only if you want
ruleset-based tool coverage.
Single organisation — fine-grained PAT
A fine-grained PAT is bound to one resource owner, so it works for a report covering a single organisation. Create it with Resource owner set to the organisation and Repository access set to All repositories, then grant:
Repository permissions (all Read-only):
| Permission | Used for |
|---|---|
| Metadata | Mandatory baseline; listing organisation repositories |
| Contents | .github/dependabot.yml, latest release, and tag dates |
| Dependabot alerts | Open Dependabot vulnerability alerts |
| Code scanning alerts | CodeQL / Scorecard / zizmor / aislop findings |
| Secret scanning alerts | Open secret-scanning alerts |
| Administration | Dependabot enablement + security-updates status, and effective branch rules |
Organization permissions:
| Permission | Access | Used for |
|---|---|---|
| Administration | Read and write | Optional — organisation rulesets (detect tools enforced through an org ruleset). GitHub gates this endpoint behind Administration write; omit it to keep the token read-only and skip ruleset-based tool coverage. |
Read-only is enough for everything except the optional ruleset coverage above. A fine-grained token cannot span organisations. For a report covering more than one org, use a classic PAT (below).
Multiple organisations — classic PAT
A classic PAT is authorised across every organisation its creator can access (subject to SSO authorisation), so a single token can report on multiple organisations. Grant these scopes:
| Scope | Used for |
|---|---|
repo |
Repository data, including private repositories |
security_events |
Code scanning, secret scanning, and Dependabot alerts (org-bulk and per-repo) |
read:org |
Listing organisation repositories |
admin:org |
Optional — reading organisation rulesets for ruleset-based tool coverage. GitHub gates GET /orgs/{org}/rulesets behind the full admin:org scope; read:org and write:org return 404. Omit it to skip that one signal; everything else is unaffected. |
For organisations that enforce SSO, the PAT must be SSO-authorised for each target organisation, or the org-level endpoints return
403(reported as unknown). Store the token as a secret (e.g.LFRELENG_ACTIONS_REPORT_PAT) and reference it by env-var name viatoken_env; never embed it in the config.
Usage
Org mode (scheduled report)
- name: "Security report"
id: report
uses: lfreleng-actions/github-security-report-action@v0.1.0
with:
scope: "org"
config: "${{ secrets.GSR_CONFIG || vars.GSR_CONFIG }}"
token: "${{ secrets.LFRELENG_ACTIONS_REPORT_PAT }}"
# Must match the per-org "token_env" in your config (below).
token_env: "LFRELENG_ACTIONS_REPORT_PAT"
output_dir: "site"
pages_url: "https://lfreleng-actions.github.io/github-security-report-action/"
A ready-to-use scheduled workflow lives in
.github/workflows/reporting.yaml: it runs
daily at 09:00 UTC, publishes to GitHub Pages every day, and posts a Slack
digest only on the configured report_day (default Tuesday).
Repo mode (PR gate)
- name: "Security report"
uses: lfreleng-actions/github-security-report-action@v0.1.0
with:
scope: "repo"
token: "${{ github.token }}"
fail_threshold: "high" # fail the job on any open high/critical finding
# requires: permissions: { security-events: read }
Configuration
Configuration is JSON, supplied as a plain vars. entry or base64-encoded in a
secrets. entry (base64 only to stop JSON braces tripping GitHub's log
redaction — it is encoding, not encryption). Tokens are referenced by
environment-variable name, never embedded.
{
"slack": { "channel": "releng-scm", "report_day": "tuesday" },
"report": {
"top_n": 10,
"top_n_report": 10,
"top_n_cli": 10,
"top_n_slack": 10,
"include_archived": false,
"include_test": false,
"repo_min_age_days": 28,
"release_max_age_days": 60
},
"organizations": [
{
"name": "lfreleng-actions",
"token_env": "LFRELENG_ACTIONS_REPORT_PAT",
"exclude": ["actions-template"],
"releases_exclude": ["internal-only-repo"]
}
]
}
report_day accepts a single weekday, a list of weekdays, "never", or
"always".
top_n controls how many offenders are shown per signal. It is the shared
default for all three outputs; set any of top_n_report (GitHub Pages),
top_n_cli (terminal), or top_n_slack (Slack digest) to override an
individual output. Set a value to 0 to remove the limit entirely and show
every offender. Each can also be set at the CLI with --top-n,
--top-n-report, --top-n-cli, and --top-n-slack.
The Releases / Tagging section has two independent freshness levers:
report.repo_min_age_days(default28,0= include all) is a grace period that omits brand-new repositories — those created within that many days — before a release or tag is expected of them. CLI:--repo-min-age-days.report.release_max_age_days(default60;0= flag everything) is the release-staleness threshold: a repository is only flagged when its newest release or tag is older than that many days (a repository with neither is always flagged). Tune it to match your release cadence so actively released repositories drop out of the table. CLI:--release-max-age-days.
The per-org releases_exclude (CLI --releases-exclude, repeatable) drops
named repositories from the section entirely.
The former
release_min_age_dayskey was a misleading name forrepo_min_age_days(it gates repository age, not release age). It is still accepted as a deprecated alias and emits a warning; preferrepo_min_age_days.
The per-org exclude list removes repositories from analysis entirely; they are
reported as excluded (distinct from "not enabled"), so an intentional
exclusion is visible rather than silently dropped.
Per-category render toggles
Every reporting category can be switched on or off, globally and per output
surface, under report.categories. Data is always collected; these toggles
govern presentation only. Each category key takes an enabled switch (highest
precedence — false hides it everywhere) and a lower-precedence outputs map
for the four surfaces (cli, slack, markdown, html). Everything defaults
to true, so an omitted category or key stays fully enabled. A category is
rendered on a surface only when enabled and that surface's toggle are
both true.
{
"report": {
"categories": {
"zizmor": { "enabled": false },
"releases": { "outputs": { "cli": false, "slack": false } }
}
},
"organizations": [{ "name": "lfreleng-actions" }]
}
The example above hides Zizmor on every surface, and keeps Releases / Tagging
out of the terminal and Slack while still publishing it to the Markdown and HTML
Pages output. The valid category keys are: codeql, scorecard, zizmor,
aislop, dependabot_alerts, secret_scanning, dependabot_alerts_enabled,
dependabot_updates_enabled, dependabot_cooldown, releases,
mutable_releases, private_vulnerability_reporting. Like the other report
settings, categories can be set
globally and overridden per organisation (overrides merge key-by-key, so
flipping one output leaves the rest untouched). The machine-readable
report.json artifact always contains the complete dataset, regardless of these
toggles.
When several organisations share one Slack channel they render into a single
combined digest, so the per-org Slack toggles are unioned for that channel: a
category appears if any contributing org would show it on Slack. An org-level
Slack disable therefore does not suppress a category in a shared-channel digest
unless every org sharing that channel also disables it (this mirrors the
most-generous top_n rule applied to the same grouping). The terminal, Markdown
and HTML surfaces are per-org and are not affected by this union.
Organisation feature gating
The workflow-driven signals (OpenSSF Scorecard, zizmor, aislop) need
organisation-deployed workflows before they produce any data (see the
organisation scan setup guide). By default the tool
runs a cheap support check per organisation before collecting each of them:
evidence is an org ruleset requiring the tool's workflow, existing
code-scanning alerts from the tool, analyses on a sample of repositories, or
(for Scorecard) an external scorecard.dev score. A signal with no evidence is
skipped — not probed per repository, not classified — and its section
shows a single ⏩ Skipping feature: organisation support missing line
linking the setup guide, on every output surface. Set report.gating to
false (globally or per organisation) to always probe everything:
{
"report": { "gating": false },
"organizations": [{ "name": "lfreleng-actions" }]
}
Gating decides collection; the per-category render toggles above decide presentation. A skipped section still renders (as the one-line notice) unless its category is also disabled.
Pass/fail severity cutoff
The severity-ranked signals (CodeQL, Scorecard, Zizmor, aislop, Dependabot
alerts) use a fail_severity cutoff to decide when a repository counts as a
failure. A repository is flagged as an offender only when it carries a finding
at or above the cutoff; findings below it fold into the clean count.
Severities run (lowest to highest) informational, low, medium, high,
critical — informational being the sub-low rung for SARIF none findings
and unclassifiable alerts. Zizmor's SARIF note findings normalise to low
(zizmor emits its Low findings at note, and the organisation scan pipeline's
--min-severity low floor keeps informational findings out of the uploaded
SARIF), matching the ruleset-enforced PR gate that blocks on note-and-above.
aislop populates the same SARIF level axis and normalises identically.
The global default cutoff is medium, so low and informational findings
pass. Zizmor and aislop default to low (only informational passes).
Override the cutoff per category under
report.categories.<key>.fail_severity:
{
"report": {
"categories": {
"codeql": { "fail_severity": "low" },
"zizmor": { "fail_severity": "informational" }
}
},
"organizations": [{ "name": "lfreleng-actions" }]
}
slack.channel is optional. The action's slack_channel input (wired to the
SLACK_CHANNEL_ID variable in reporting.yaml) overrides it, so the channel
can live as an org/repo variable rather than in the config JSON. It must be the
channel ID (C0…), not the name.
Config file location
For local use you can drop the same JSON at a conventional per-user path and run
with no flags — it is picked up automatically when no --config,
--config-data, or --org is given (instead of erroring):
$XDG_CONFIG_HOME/github-security-report/config.json
# or, when XDG_CONFIG_HOME is unset:
~/.config/github-security-report/config.json
An explicit --config, --config-data, or --org always takes precedence, and
the action itself never reads this path (it is supplied configuration directly).
Secrets stay out of the file: reference the token by environment-variable name
via token_env (e.g. LFRELENG_ACTIONS_REPORT_PAT, exported in your shell or sourced
from a secrets file) — the channel ID is the only Slack value the file holds,
and the Slack bot token is consumed by the workflow, not the CLI.
Inputs
| Name | Required | Default | Description |
|---|---|---|---|
scope |
No | auto |
auto, org, or repo |
config |
No | — | JSON config (raw or base64) |
org |
No | — | Single organisation (shorthand for org mode) |
repo |
No | detected | owner/name for repo mode |
token |
No | ${{ github.token }} |
PAT (org mode) or GITHUB_TOKEN (repo mode) |
token_env |
No | GITHUB_TOKEN |
Env var name the token is exported under. In org mode it must match the per-org token_env in your config (e.g. LFRELENG_ACTIONS_REPORT_PAT), otherwise the tool looks up an unset variable and reports no token. |
output_dir |
No | — | Directory for Pages output (org mode) |
pages_url |
No | — | Published Pages URL (used in the Slack link) |
slack_channel |
No | — | Slack channel ID; overrides the config slack.channel (e.g. the SLACK_CHANNEL_ID variable) |
top_n |
No | 10 |
Offenders per signal across all outputs (shared default; 0 = no limit) |
top_n_report |
No | — | Offenders per signal in the GitHub Pages output (0 = no limit; overrides top_n) |
top_n_cli |
No | — | Offenders per signal in the terminal output (0 = no limit; overrides top_n) |
top_n_slack |
No | — | Offenders per signal in the Slack digest (0 = no limit; overrides top_n) |
fail_threshold |
No | none |
none/low/medium/high/critical/any (repo mode) |
force_notify |
No | false |
Post to Slack regardless of report_day |
tool_version |
No | "" |
Published PyPI version to install. Empty (the default) uses the Dependabot-managed pin in .github/runtime-pin/requirements.txt; set a specific version to override. Ignored on pull requests or when use_local_source is true (both run from source) |
use_local_source |
No | false |
Run from the checked-out source instead of PyPI (for testing unreleased code from any event) |
Outputs
| Name | Description |
|---|---|
should_notify |
Whether today is a Slack notification day |
slack_payload |
Prebuilt Slack chat.postMessage payload (JSON) |
failed |
Whether the repo-mode fail threshold was met |
Running locally
The tool is published to PyPI and runs with uvx. Inside a Git checkout with a
GITHUB_TOKEN exported, it auto-detects the repository (preferring the
upstream remote, then origin) and prints a Rich table report:
export GITHUB_TOKEN=ghp_your_token
uvx github-security-report report
# Or org mode locally with a PAT:
uvx github-security-report report --org lfreleng-actions
Remediation
The remediate subcommand is the in-tool counterpart to the report: it runs the
same collection, then switches on each selected security feature wherever a
repository has it confirmed off. Only the offenders the report already
surfaces are acted on — repositories whose state could not be read are counted
as unknown and are never written to, so remediation never blind-writes.
It is dry run by default (these are privileged writes); pass --apply to
make changes. A single write-capable org-admin token (from --token-env,
default GITHUB_TOKEN) drives both the read and the writes across every
configured organisation, so it bypasses the per-org read-only token_env in the
config.
# An org-admin token is required: a classic PAT with the `repo` scope
# (administers repository security settings) plus `read:org` to enumerate repos.
source ~/.secrets.github.classic.god # exports $GITHUB_TOKEN
# Dry run (default): preview every change, touch nothing.
uvx github-security-report remediate --org lfreleng-actions
# Apply: enable every remediable feature that is off, across all configured orgs.
uvx github-security-report remediate \
--config ~/.config/github-security-report/config.json --apply
# Limit to specific categories (repeatable).
uvx github-security-report remediate --org lfreleng-actions \
--category codeql --category private_vulnerability_reporting --apply
The remediable categories are the simple on/off features with a documented enablement endpoint:
--category |
Enables |
|---|---|
codeql |
CodeQL default setup (provisioned asynchronously) |
secret_scanning |
Secret scanning |
dependabot_alerts_enabled |
Dependabot vulnerability alerts |
dependabot_updates_enabled |
Dependabot security updates (plus alerts) |
private_vulnerability_reporting |
Private vulnerability reporting |
Qualitative findings (Scorecard, zizmor, open Dependabot alerts, cooldown,
release freshness/mutability) are reported but not auto-remediated. Remediation
is organisation-scoped (--scope org, the default and only supported scope).
Bulk Remediation Scripts
The standalone scripts below predate the remediate subcommand and remain for
ad-hoc, single-feature runs. For most workflows, prefer remediate above.
The report ends with nag lists — repositories where a supported feature is
switched off. Where GitHub exposes the relevant toggle through its REST API,
the scripts/ directory ships standalone helpers that clear a whole
nag list in one pass instead of clicking through each repository's settings.
They reuse the tool's own scoping rules
(src/github_security_report/scope.py),
so they act on exactly the repositories the report does. See
scripts/README.md for full details.
Each script is a self-contained PEP 723
program: uv run resolves its inline dependencies on the fly — no project
install required.
enable_dependabot_security_updates.py
Enables Dependabot security updates (and the prerequisite alerts) across an organisation, clearing the "Dependabot: Security Updates" nag list. It reads the current state of each repository, enables the feature where it is off, and verifies the result.
# An org-admin token is required: a classic PAT with the `repo` scope
# (administers repository security settings) plus `read:org` to enumerate repos.
source ~/.secrets.github.classic.god # exports $GITHUB_TOKEN
# Dry run (default): preview every change, touch nothing.
uv run scripts/enable_dependabot_security_updates.py \
--config ~/.config/github-security-report/config.json
# Apply: switch the feature on for every in-scope repository.
uv run scripts/enable_dependabot_security_updates.py \
--config ~/.config/github-security-report/config.json --apply
--config reads the organisation name and exclusions straight from the
reporting tool's JSON config, so the script and the report never drift. The
operation is dry-run by default (these are privileged writes) and reversible
via DELETE /repos/{owner}/{repo}/automated-security-fixes.
Development
uv sync --extra dev
uv run pytest
uv run ruff check src/ tests/
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file github_security_report-0.7.0.tar.gz.
File metadata
- Download URL: github_security_report-0.7.0.tar.gz
- Upload date:
- Size: 98.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.2.0 CPython/3.12.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c9e42380ec86b130db71f685a926947b223e4a4809036a003d78383d1d05b142
|
|
| MD5 |
fa9a17e809121727fb7b4ff0e1a6f8d1
|
|
| BLAKE2b-256 |
57ae6790993888a5d9c8c528168ae0b1d1007c4475c5a5d6c48c53e9a00d2b22
|
Provenance
The following attestation bundles were made for github_security_report-0.7.0.tar.gz:
Publisher:
build-test-release.yaml on lfreleng-actions/github-security-report-action
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
github_security_report-0.7.0.tar.gz -
Subject digest:
c9e42380ec86b130db71f685a926947b223e4a4809036a003d78383d1d05b142 - Sigstore transparency entry: 2167396683
- Sigstore integration time:
-
Permalink:
lfreleng-actions/github-security-report-action@48f0f9a60538b70851efddb1c29654785e4e4cbf -
Branch / Tag:
refs/tags/v0.7.0 - Owner: https://github.com/lfreleng-actions
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
build-test-release.yaml@48f0f9a60538b70851efddb1c29654785e4e4cbf -
Trigger Event:
push
-
Statement type:
File details
Details for the file github_security_report-0.7.0-py3-none-any.whl.
File metadata
- Download URL: github_security_report-0.7.0-py3-none-any.whl
- Upload date:
- Size: 107.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.2.0 CPython/3.12.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b508fe830c411c2ffdaed7627d1f9c2510fcf13f0d32bfd612c02468da4b2587
|
|
| MD5 |
3d49caac24c4c4c297771f83e1d8d4a9
|
|
| BLAKE2b-256 |
ba387c53ab13d19c70fec1c82483f371f65361e9b1c668b5d6e9a99686212131
|
Provenance
The following attestation bundles were made for github_security_report-0.7.0-py3-none-any.whl:
Publisher:
build-test-release.yaml on lfreleng-actions/github-security-report-action
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
github_security_report-0.7.0-py3-none-any.whl -
Subject digest:
b508fe830c411c2ffdaed7627d1f9c2510fcf13f0d32bfd612c02468da4b2587 - Sigstore transparency entry: 2167396706
- Sigstore integration time:
-
Permalink:
lfreleng-actions/github-security-report-action@48f0f9a60538b70851efddb1c29654785e4e4cbf -
Branch / Tag:
refs/tags/v0.7.0 - Owner: https://github.com/lfreleng-actions
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
build-test-release.yaml@48f0f9a60538b70851efddb1c29654785e4e4cbf -
Trigger Event:
push
-
Statement type: