Metadata-Version: 2.4
Name: github-security-report
Version: 0.4.0
Summary: Security and quality reporting across GitHub organisations
Project-URL: Homepage, https://github.com/lfreleng-actions/github-security-report-action
Project-URL: Repository, https://github.com/lfreleng-actions/github-security-report-action
Project-URL: Issues, https://github.com/lfreleng-actions/github-security-report-action/issues
Author-email: The Linux Foundation <mwatkins@linuxfoundation.org>
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: codeql,dependabot,github,github-actions,linux-foundation,reporting,scorecard,secret-scanning,security,zizmor
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Security
Classifier: Topic :: Software Development :: Quality Assurance
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: httpx[http2]==0.28.1
Requires-Dist: jinja2==3.1.6
Requires-Dist: jsonschema==4.26.0
Requires-Dist: pyyaml==6.0.3
Requires-Dist: rich==15.0.0
Requires-Dist: typer==0.26.7
Provides-Extra: dev
Requires-Dist: mypy==2.1.0; extra == 'dev'
Requires-Dist: pytest-asyncio==1.4.0; extra == 'dev'
Requires-Dist: pytest-cov==7.1.0; extra == 'dev'
Requires-Dist: pytest==9.1.1; extra == 'dev'
Requires-Dist: respx==0.23.1; extra == 'dev'
Requires-Dist: ruff==0.15.18; extra == 'dev'
Requires-Dist: syrupy==5.3.2; extra == 'dev'
Requires-Dist: types-jsonschema==4.26.0.20260518; extra == 'dev'
Requires-Dist: types-pyyaml==6.0.12.20250915; extra == 'dev'
Description-Content-Type: text/markdown

<!--
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: 2026 The Linux Foundation
-->

# 🔐 GitHub Security Report

<!-- prettier-ignore-start -->
<!-- markdownlint-disable-next-line MD013 -->
[![Linux Foundation](https://img.shields.io/badge/Linux-Foundation-blue)](https://linuxfoundation.org/) [![Source Code](https://img.shields.io/badge/GitHub-100000?logo=github&logoColor=white&color=blue)](https://github.com/lfreleng-actions/github-security-report-action) [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
<!-- prettier-ignore-end -->

> Security and quality **reporting** (not scanning) across GitHub
> organisations. Aggregates existing signals — CodeQL, OpenSSF Scorecard,
> zizmor, 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 <state>"** when
nothing needs attention, or **"N <state>"** 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, and zizmor; Scorecard prefers the external aggregate score and falls
back to code-scanning findings. See [`docs/BRIEF.md`](docs/BRIEF.md) and
[`docs/phase0-findings.md`](docs/phase0-findings.md) for the full design and the
API research it is built on.

Two 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 `cooldown` configured (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; `0` includes all) and those in
  `releases_exclude` are omitted. A repository is flagged only when its newest
  release or tag is older than `release_max_age_days` (default 60; `0` flags
  every eligible repository), so a repository released or tagged within that
  window counts as recently maintained and drops out of the table.

## 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](#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 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. `SECURITY_REPORT_PAT`) and
> reference it by env-var name via `token_env`; never embed it in the config.

## Usage

### Org mode (scheduled report)

```yaml
- 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.SECURITY_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`](.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)

```yaml
- 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.

```json
{
  "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": "GITHUB_TOKEN",
      "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` (default `28`, `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` (default `60`; `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_days` key was a misleading name for
> `repo_min_age_days` (it gates *repository* age, not *release* age). It is
> still accepted as a deprecated alias and emits a warning; prefer
> `repo_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.

```json
{
  "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`,
`dependabot_alerts`, `secret_scanning`, `dependabot_alerts_enabled`,
`dependabot_updates_enabled`, `dependabot_cooldown`, `releases`,
`mutable_releases`. 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.

### Pass/fail severity cutoff

The severity-ranked signals (CodeQL, Scorecard, Zizmor, 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 new sub-low rung that SARIF `note`/`none` findings
(the bulk of a tool like Zizmor) normalise to.

The global default cutoff is `medium`, so `low` and `informational` findings
pass. Zizmor defaults to `low` (only `informational` passes). Override the
cutoff per category under `report.categories.<key>.fail_severity`:

```json
{
  "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):

```text
$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. `SECURITY_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

<!-- markdownlint-disable MD013 -->

| 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 tool reads the token from |
| `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) |

<!-- markdownlint-enable MD013 -->

## 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:

```bash
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
```

## Bulk Remediation Scripts

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/`](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`](src/github_security_report/scope.py)),
so they act on exactly the repositories the report does. See
[`scripts/README.md`](scripts/README.md) for full details.

Each script is a self-contained [PEP 723](https://peps.python.org/pep-0723/)
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.

```bash
# An org-admin token is required (classic PAT with repo admin / admin:org).
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

```bash
uv sync --extra dev
uv run pytest
uv run ruff check src/ tests/
```
