From 10496a89b6bd20fd2bdcffba3160884c73c6bbe1 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Mon, 30 Mar 2026 06:27:09 +0200 Subject: [PATCH] Add actions-audit.py script for auditing Apache repo security tooling Adds a new utility script that audits apache/ GitHub repositories for baseline Actions security configurations (dependabot, CodeQL, zizmor, allowlist-check) and can create PRs to add missing ones. Key features: - Uses GraphQL to batch-fetch workflow file contents per repo - Dry-run mode shows detailed preview of what PRs would contain - Prints zizmor findings so users can see issues before creating PRs - Skips secrets-outside-env zizmor rule (too noisy for initial rollout) - Includes zizmor error output in PR body when workflows are commented out --- README.md | 105 ++++ pyproject.toml | 1 + utils/actions-audit.py | 1268 ++++++++++++++++++++++++++++++++++++++++ utils/pyproject.toml | 4 + uv.lock | 8 +- 5 files changed, 1382 insertions(+), 4 deletions(-) create mode 100644 utils/actions-audit.py diff --git a/README.md b/README.md index 1336743f..f3135ceb 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ This repository hosts GitHub Actions developed by the ASF community and approved - [Dependabot Cooldown Period](#dependabot-cooldown-period) - [Manual Version Addition](#manual-addition-of-specific-versions) - [Removing a Version](#removing-a-version-manually) +- [Auditing Repositories for Actions Security Tooling](#auditing-repositories-for-actions-security-tooling) ## Submitting an Action @@ -285,3 +286,107 @@ If you add older version of the action and want to set an expiration date for it The infrastructure team will prioritize these removal requests and may take additional steps to notify affected projects if necessary. For 'regular' removals (not security responses), you can use `./utils/action-usage.sh someorg/theaction` to see if/how an action is still used anywhere in the ASF, and create a 'regular' PR removing it from `actions.yml` (or adding an expiration date) when it is no longer used. + +## Auditing Repositories for Actions Security Tooling + +Recent security breaches have shown that GitHub Actions can fail silently, leaving repositories vulnerable without any visible indication. The `actions-audit.py` script helps ensure that all Apache repositories using GitHub Actions have a baseline set of security tooling in place. + +### Why This Matters + +GitHub Actions workflows can introduce security risks in several ways: +- **Unpinned or unreviewed action versions** may contain malicious code or vulnerabilities +- **Missing static analysis** means workflow misconfigurations (secret exposure, injection vulnerabilities) go undetected +- **No dependabot** means action versions never get updated, accumulating known vulnerabilities over time + +The audit script checks each repository for four security configurations and can automatically open PRs to add any that are missing: + +| Check | What it does | +|-------|-------------| +| **Dependabot** | Keeps GitHub Actions dependencies up to date with a 4-day cooldown to avoid overwhelming reviewers | +| **CodeQL** | Runs static analysis on workflow files to detect security issues in Actions syntax | +| **Zizmor** | Specialized scanner for GitHub Actions anti-patterns: credential leaks, injection vulnerabilities, excessive permissions | +| **ASF Allowlist Check** | Ensures every action used is on the ASF Infrastructure approved allowlist | + +### Prerequisites + +- **Python 3.11+** and [**uv**](https://docs.astral.sh/uv/) **>= 0.9.17** (dependencies are managed inline via PEP 723). Make sure your uv is up to date — depending on how you installed it, run `uv self update`, `pip install --upgrade uv`, `pipx upgrade uv`, or `brew upgrade uv` +- **`gh`** (GitHub CLI, authenticated via `gh auth login`) — or provide a `--github-token` with `repo` scope and use `--no-gh` +- **`zizmor`** ([install instructions](https://docs.zizmor.dev/installation/)) — required for PR creation mode; not needed for `--dry-run`. If missing, zizmor pre-checks are skipped with a warning + +### Usage + +Always start with `--dry-run` to see what the script would do without making any changes: + +```bash +# Audit all repos for a specific PMC (prefix before first '-' in repo name) +uv run utils/actions-audit.py --dry-run --pmc spark --max-num 10 + +# Audit multiple PMCs +uv run utils/actions-audit.py --dry-run --pmc kafka --pmc flink + +# Audit the first 50 repos (no PMC filter) +uv run utils/actions-audit.py --dry-run --max-num 50 + +# Increase GraphQL page size for fewer API round-trips +uv run utils/actions-audit.py --dry-run --max-num 200 --batch-size 100 +``` + +When satisfied with the dry-run output, remove `--dry-run` to create PRs: + +```bash +# Create PRs for spark repos missing security tooling +uv run utils/actions-audit.py --pmc spark --max-num 10 +``` + +#### Options + +| Flag | Description | +|------|-------------| +| `--pmc PMC` | Filter by PMC prefix (repeatable). The prefix is the text before the first `-` in the repo name, e.g. `spark` matches `spark`, `spark-connect-go`, `spark-docker`. | +| `--dry-run` | Report findings without creating PRs or branches. | +| `--max-num N` | Maximum number of repositories to check (0 = unlimited, default). | +| `--batch-size N` | Number of repos to fetch per GraphQL request (default: 50, max: 100). | +| `--github-token TOKEN` | GitHub token. Defaults to `GH_TOKEN` or `GITHUB_TOKEN` environment variable. | +| `--no-gh` | Use Python `requests` instead of the `gh` CLI for all API calls. Requires `--github-token` or a token env var. | + +#### How PMC Filtering Works + +The `--pmc` flag matches repos by prefix: the text before the first hyphen in the repository name. For example, `--pmc spark` matches `apache/spark`, `apache/spark-connect-go`, and `apache/spark-docker`. If the repo name has no hyphen, the full name is used as the prefix. + +The script downloads the list of known PMCs from `whimsy.apache.org` on first run and caches it locally (`~/.cache/asf-actions-audit/pmc-list.json`) for 24 hours. If a `--pmc` value doesn't match any known PMC, a warning is printed but it is still used as a prefix filter. + +#### What the PRs Contain + +For each repository that is missing one or more checks, the script creates a single PR on a branch named `asf-actions-security-audit` containing only the missing files: + +- `.github/dependabot.yml` — created or updated to include the `github-actions` ecosystem with a 4-day cooldown +- `.github/workflows/codeql-analysis.yml` — CodeQL scanning for the `actions` language +- `.github/workflows/zizmor.yml` — Zizmor scanning with SARIF upload +- `.github/workflows/allowlist-check.yml` — ASF allowlist verification on workflow changes + +#### Zizmor Pre-Check + +Before creating a PR, the script runs `zizmor` against the repository's existing workflow files. If zizmor finds errors, the **CodeQL and Zizmor workflow files are added but commented out**, with instructions explaining: +- That zizmor found existing issues in the workflows +- How to auto-fix common issues (`zizmor --fix .github/workflows/`) +- That the PMC should uncomment the workflows and fix remaining issues in a follow-up PR + +This avoids creating PRs that would immediately fail CI due to pre-existing problems. + +#### Interactive Confirmation + +When not in `--dry-run` mode, the script prompts for confirmation before creating each PR: + +``` + Create PR for apache/spark? + Will add: dependabot, codeql, zizmor, allowlist-check + Proceed? [yes/no/quit] (yes): +``` + +- **yes** (default) — create the PR +- **no** — skip this repository and continue to the next +- **quit** — stop processing entirely and print the summary + +#### Idempotency + +The script is safe to re-run. Before creating a PR for a repository, it checks whether a PR with the branch name `asf-actions-security-audit` already exists — open, closed, or merged — and skips the repo if so. diff --git a/pyproject.toml b/pyproject.toml index 5baf5b69..a53b2015 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,4 +31,5 @@ dev = [ ] [tool.uv] +required-version = ">=0.9.17" exclude-newer = "4 days" diff --git a/utils/actions-audit.py b/utils/actions-audit.py new file mode 100644 index 00000000..08993f40 --- /dev/null +++ b/utils/actions-audit.py @@ -0,0 +1,1268 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "requests>=2.31", +# "rich>=13.0", +# "pyyaml>=6.0", +# ] +# /// + +""" +Audit Apache GitHub repositories for proper GitHub Actions security configurations. + +Checks each repo for: dependabot (github-actions with cooldown), CodeQL analysis, +zizmor workflow scanning, and ASF allowlist-check. Creates PRs to add missing configs. + +Usage: + uv run actions-audit.py --dry-run --pmc spark --max-num 5 + uv run actions-audit.py --pmc kafka --pmc flink --max-num 10 + uv run actions-audit.py --dry-run --max-num 50 --batch-size 100 +""" + +import argparse +import base64 +import json +import os +import shutil +import subprocess +import sys +import tempfile +import time +from dataclasses import dataclass, field +from pathlib import Path + +import requests +import yaml +from rich.console import Console +from rich.prompt import Prompt +from rich.table import Table + +console = Console(stderr=True) + +GITHUB_API = "https://api.github.com" +PMC_CACHE_DIR = Path("~/.cache/asf-actions-audit").expanduser() +PMC_CACHE_FILE = PMC_CACHE_DIR / "pmc-list.json" +PMC_CACHE_MAX_AGE = 86400 # 1 day + +BRANCH_NAME = "asf-actions-security-audit" + +# Apache license header for generated files +APACHE_LICENSE_HEADER = """\ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +""" + +DEPENDABOT_TEMPLATE = """\ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + cooldown: + default-days: 4 +""" + +DEPENDABOT_ACTIONS_ENTRY = { + "package-ecosystem": "github-actions", + "directory": "/", + "schedule": {"interval": "weekly"}, + "cooldown": {"default-days": 4}, +} + +CODEQL_TEMPLATE = """\ +{license} +--- +name: "CodeQL" + +on: # yamllint disable-line rule:truthy + pull_request: + branches: ['{default_branch}'] + push: + branches: ['{default_branch}'] + schedule: + - cron: '0 2 * * *' + +permissions: + contents: read +concurrency: + group: codeql-${{{{ github.event.pull_request.number || github.ref }}}} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze + runs-on: ["ubuntu-22.04"] + permissions: + actions: read + contents: read + pull-requests: read + security-events: write + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: actions + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 +""" + +ZIZMOR_TEMPLATE = """\ +{license} +--- +name: "Zizmor" + +on: # yamllint disable-line rule:truthy + push: + branches: ['{default_branch}'] + paths: ['.github/workflows/**'] + pull_request: + paths: ['.github/workflows/**'] + +permissions: + contents: read + security-events: write + +jobs: + zizmor: + name: Zizmor + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Run Zizmor + uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2 + with: + sarif: results.sarif + config: .github/zizmor.yml + + - name: Upload SARIF + if: always() + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: results.sarif +""" + +ZIZMOR_CONFIG_TEMPLATE = """\ +rules: + secrets-outside-env: + disable: true +""" + +ALLOWLIST_TEMPLATE = """\ +{license} +--- +name: "ASF Actions Allowlist Check" + +on: # yamllint disable-line rule:truthy + pull_request: + paths: ['.github/workflows/**'] + push: + branches: ['{default_branch}'] + paths: ['.github/workflows/**'] + +permissions: + contents: read + +jobs: + allowlist-check: + name: Allowlist Check + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Check ASF Allowlist + uses: apache/infrastructure-actions/allowlist-check@main +""" + + +@dataclass +class AuditResult: + repo: str + default_branch: str = "main" + default_branch_sha: str = "" + has_workflows: bool = False + missing_dependabot: bool = False + missing_dependabot_actions: bool = False + missing_dependabot_cooldown: bool = False + existing_dependabot_content: str | None = None + existing_dependabot_sha: str | None = None + missing_codeql: bool = False + missing_zizmor: bool = False + missing_allowlist: bool = False + zizmor_has_errors: bool = False + zizmor_output: str = "" + pr_url: str = "" + error: str = "" + skipped: str = "" + workflow_files: list[str] = field(default_factory=list) + workflow_contents: dict[str, str] = field(default_factory=dict) + + @property + def needs_pr(self) -> bool: + return ( + self.missing_dependabot + or self.missing_dependabot_actions + or self.missing_dependabot_cooldown + or self.missing_codeql + or self.missing_zizmor + or self.missing_allowlist + ) + + @property + def missing_items(self) -> list[str]: + items = [] + if self.missing_dependabot: + items.append("dependabot") + elif self.missing_dependabot_actions: + items.append("dependabot (actions ecosystem)") + elif self.missing_dependabot_cooldown: + items.append("dependabot (cooldown)") + if self.missing_codeql: + items.append("codeql") + if self.missing_zizmor: + items.append("zizmor") + if self.missing_allowlist: + items.append("allowlist-check") + return items + + +class GitHubClient: + """GitHub API client — uses either gh CLI or requests with a token.""" + + def __init__(self, token: str | None = None, use_requests: bool = False, + verbose: bool = False): + self.token = token + self._use_requests = use_requests or token is not None + self.verbose = verbose + + def _headers(self) -> dict: + return { + "Authorization": f"token {self.token}", + "Accept": "application/vnd.github+json", + } + + def _gh_api(self, endpoint: str, method: str = "GET", + input_data: str | None = None) -> dict | list | None: + cmd = ["gh", "api", endpoint] + if method != "GET": + cmd.extend(["--method", method]) + if input_data is not None: + cmd.extend(["--input", "-"]) + result = subprocess.run( + cmd, capture_output=True, text=True, + input=input_data, + ) + if result.returncode == 0 and result.stdout.strip(): + return json.loads(result.stdout) + return None + + def _get(self, endpoint: str) -> dict | list | None: + if self._use_requests: + resp = requests.get(f"{GITHUB_API}/{endpoint}", headers=self._headers()) + if resp.ok: + return resp.json() + return None + return self._gh_api(endpoint) + + def _post(self, endpoint: str, data: dict) -> dict | None: + if self._use_requests: + resp = requests.post( + f"{GITHUB_API}/{endpoint}", headers=self._headers(), json=data, + ) + if resp.ok or resp.status_code == 201: + return resp.json() + console.print(f"[red]POST {endpoint} failed: {resp.status_code} {resp.text[:200]}[/]") + return None + return self._gh_api(endpoint, method="POST", input_data=json.dumps(data)) + + def _put(self, endpoint: str, data: dict) -> dict | None: + if self._use_requests: + resp = requests.put( + f"{GITHUB_API}/{endpoint}", headers=self._headers(), json=data, + ) + if resp.ok or resp.status_code == 201: + return resp.json() + console.print(f"[red]PUT {endpoint} failed: {resp.status_code} {resp.text[:200]}[/]") + return None + return self._gh_api(endpoint, method="PUT", input_data=json.dumps(data)) + + def graphql(self, query: str, variables: dict) -> dict | None: + if self.verbose: + console.print(f"[dim]GraphQL query:[/]") + console.print(f"[dim]{query.strip()}[/]") + console.print(f"[dim]Variables: {json.dumps(variables)}[/]") + if self._use_requests: + resp = requests.post( + f"{GITHUB_API}/graphql", + headers=self._headers(), + json={"query": query, "variables": variables}, + ) + if resp.ok: + return resp.json() + console.print(f"[red]GraphQL failed: {resp.status_code} {resp.text[:200]}[/]") + return None + # Build gh api graphql command + cmd = ["gh", "api", "graphql", "-f", f"query={query}"] + for k, v in variables.items(): + if isinstance(v, int): + cmd.extend(["-F", f"{k}={v}"]) + elif v is None: + continue + else: + cmd.extend(["-f", f"{k}={v}"]) + result = subprocess.run(cmd, capture_output=True, text=True) + if self.verbose: + console.print(f"[dim]GraphQL response (rc={result.returncode}):[/]") + if result.stdout.strip(): + console.print(f"[dim]{result.stdout[:500]}[/]") + if result.stderr.strip(): + console.print(f"[dim]stderr: {result.stderr[:500]}[/]") + if result.returncode == 0 and result.stdout.strip(): + return json.loads(result.stdout) + if result.returncode != 0 and not self.verbose: + console.print(f"[red]GraphQL call failed (rc={result.returncode}): {result.stderr[:200]}[/]") + return None + + def get_repos_page(self, org: str, batch_size: int, + cursor: str | None = None) -> tuple[list[dict], bool, str | None]: + """Fetch a page of repos with workflow info via GraphQL.""" + query = """ + query($org: String!, $batchSize: Int!, $cursor: String) { + organization(login: $org) { + repositories(first: $batchSize, after: $cursor, + orderBy: {field: NAME, direction: ASC}) { + pageInfo { hasNextPage endCursor } + nodes { + name + isArchived + isDisabled + defaultBranchRef { + name + target { oid } + } + workflows: object(expression: "HEAD:.github/workflows") { + ... on Tree { entries { name } } + } + dependabot: object(expression: "HEAD:.github/dependabot.yml") { + ... on Blob { text } + } + } + } + } + } + """ + variables: dict = {"org": org, "batchSize": batch_size} + if cursor: + variables["cursor"] = cursor + + data = self.graphql(query, variables) + if not data or "data" not in data: + return [], False, None + + repos_data = data["data"]["organization"]["repositories"] + page_info = repos_data["pageInfo"] + return ( + repos_data["nodes"], + page_info["hasNextPage"], + page_info["endCursor"], + ) + + def search_repos_page(self, org: str, pmc_prefix: str, batch_size: int, + cursor: str | None = None) -> tuple[list[dict], bool, str | None]: + """Search for repos by name prefix via GraphQL search. + + Uses GitHub search to find repos matching the PMC prefix, avoiding + pagination through the entire org. Client-side prefix filtering is + still needed since search matches anywhere in the name. + """ + query = """ + query($searchQuery: String!, $batchSize: Int!, $cursor: String) { + search(query: $searchQuery, type: REPOSITORY, first: $batchSize, after: $cursor) { + repositoryCount + pageInfo { hasNextPage endCursor } + nodes { + ... on Repository { + name + isArchived + isDisabled + defaultBranchRef { + name + target { oid } + } + workflows: object(expression: "HEAD:.github/workflows") { + ... on Tree { entries { name } } + } + dependabot: object(expression: "HEAD:.github/dependabot.yml") { + ... on Blob { text } + } + } + } + } + } + """ + search_query = f"org:{org} {pmc_prefix} in:name fork:true" + variables: dict = {"searchQuery": search_query, "batchSize": batch_size} + if cursor: + variables["cursor"] = cursor + + data = self.graphql(query, variables) + if not data or "data" not in data: + return [], False, None + + search_data = data["data"]["search"] + page_info = search_data["pageInfo"] + # Filter out empty nodes (can happen with search union types) + nodes = [n for n in search_data["nodes"] if n and n.get("name")] + return ( + nodes, + page_info["hasNextPage"], + page_info["endCursor"], + ) + + def get_workflow_contents(self, owner: str, repo: str, + filenames: list[str]) -> dict[str, str]: + """Fetch all workflow file contents in a single GraphQL query. + + Returns a dict mapping filename -> content for files that exist. + Uses dynamically aliased object() expressions to fetch each file's + blob text in one round-trip. + """ + if not filenames: + return {} + + # Build aliased fields: wf0: object(expression: "HEAD:.github/workflows/foo.yml") { ... on Blob { text } } + # Alias names must be valid GraphQL identifiers (letters, digits, underscore) + fragments = [] + alias_map = {} # alias -> filename + for i, name in enumerate(filenames): + alias = f"wf{i}" + alias_map[alias] = name + escaped = name.replace("\\", "\\\\").replace('"', '\\"') + fragments.append( + f'{alias}: object(expression: "HEAD:.github/workflows/{escaped}") ' + f"{{ ... on Blob {{ text }} }}" + ) + + fields = "\n ".join(fragments) + query = f""" + query($owner: String!, $repo: String!) {{ + repository(owner: $owner, name: $repo) {{ + {fields} + }} + }} + """ + data = self.graphql(query, {"owner": owner, "repo": repo}) + if not data or "data" not in data: + return {} + + repo_data = data["data"]["repository"] + result = {} + for alias, filename in alias_map.items(): + obj = repo_data.get(alias) + if obj and isinstance(obj, dict) and obj.get("text"): + result[filename] = obj["text"] + return result + + def get_file_content(self, owner: str, repo: str, path: str) -> tuple[str | None, str | None]: + """Fetch file content and its blob SHA. Returns (content, sha) or (None, None).""" + data = self._get(f"repos/{owner}/{repo}/contents/{path}") + if isinstance(data, dict) and data.get("content"): + content = base64.b64decode(data["content"]).decode("utf-8") + return content, data.get("sha") + return None, None + + def create_branch(self, owner: str, repo: str, branch: str, sha: str) -> bool: + data = self._post(f"repos/{owner}/{repo}/git/refs", { + "ref": f"refs/heads/{branch}", + "sha": sha, + }) + return data is not None + + def create_or_update_file(self, owner: str, repo: str, path: str, + content: str, message: str, branch: str, + sha: str | None = None) -> bool: + payload: dict = { + "message": message, + "content": base64.b64encode(content.encode()).decode(), + "branch": branch, + } + if sha: + payload["sha"] = sha + data = self._put(f"repos/{owner}/{repo}/contents/{path}", payload) + return data is not None + + def create_pull_request(self, owner: str, repo: str, title: str, body: str, + head: str, base: str) -> str | None: + """Create a PR. Returns the HTML URL or None.""" + data = self._post(f"repos/{owner}/{repo}/pulls", { + "title": title, + "body": body, + "head": head, + "base": base, + }) + if isinstance(data, dict): + return data.get("html_url") + return None + + def check_existing_pr(self, owner: str, repo: str, + head_branch: str) -> tuple[str | None, str | None]: + """Check if a PR with the given head branch exists (any state). + + Returns (url, state) where state is 'open', 'closed', or 'merged', or (None, None). + """ + # Check all states — open first, then closed (which includes merged) + for state in ("open", "closed"): + data = self._get( + f"repos/{owner}/{repo}/pulls?state={state}&head={owner}:{head_branch}" + ) + if isinstance(data, list) and data: + pr = data[0] + pr_state = "merged" if pr.get("merged_at") else pr["state"] + return pr.get("html_url"), pr_state + return None, None + + def check_branch_exists(self, owner: str, repo: str, branch: str) -> bool: + data = self._get(f"repos/{owner}/{repo}/git/ref/heads/{branch}") + return data is not None and isinstance(data, dict) and "ref" in data + + def delete_branch(self, owner: str, repo: str, branch: str) -> bool: + if self._use_requests: + resp = requests.delete( + f"{GITHUB_API}/repos/{owner}/{repo}/git/refs/heads/{branch}", + headers=self._headers(), + ) + return resp.ok or resp.status_code == 204 + result = subprocess.run( + ["gh", "api", f"repos/{owner}/{repo}/git/refs/heads/{branch}", + "--method", "DELETE"], + capture_output=True, text=True, + ) + return result.returncode == 0 + + +def get_pmc_list() -> list[str]: + """Get list of Apache PMC names, with file caching.""" + if PMC_CACHE_FILE.exists(): + age = time.time() - PMC_CACHE_FILE.stat().st_mtime + if age < PMC_CACHE_MAX_AGE: + return json.loads(PMC_CACHE_FILE.read_text()) + + console.print("[dim]Fetching PMC list from whimsy.apache.org...[/]") + try: + resp = requests.get( + "https://whimsy.apache.org/public/committee-info.json", + timeout=30, + ) + resp.raise_for_status() + data = resp.json() + # The committees key contains a dict of PMC name -> info + pmcs = sorted(data.get("committees", {}).keys()) + except Exception as e: + console.print(f"[yellow]Warning: Could not fetch PMC list: {e}[/]") + if PMC_CACHE_FILE.exists(): + console.print("[yellow]Using stale cache.[/]") + return json.loads(PMC_CACHE_FILE.read_text()) + return [] + + PMC_CACHE_DIR.mkdir(parents=True, exist_ok=True) + PMC_CACHE_FILE.write_text(json.dumps(pmcs)) + console.print(f"[dim]Cached {len(pmcs)} PMCs.[/]") + return pmcs + + +def repo_pmc_prefix(repo_name: str) -> str: + """Extract PMC prefix from repo name (text before first '-').""" + return repo_name.split("-")[0] if "-" in repo_name else repo_name + + +def check_dependabot(dependabot_text: str | None) -> tuple[bool, bool, bool]: + """Check dependabot config. Returns (missing_entirely, missing_actions, missing_cooldown).""" + if not dependabot_text: + return True, False, False + + try: + config = yaml.safe_load(dependabot_text) + except yaml.YAMLError: + return True, False, False + + if not isinstance(config, dict) or "updates" not in config: + return True, False, False + + for entry in config.get("updates", []): + if entry.get("package-ecosystem") == "github-actions": + cooldown = entry.get("cooldown", {}) + if isinstance(cooldown, dict) and cooldown.get("default-days", 0) >= 4: + return False, False, False + return False, False, True + + return False, True, False + + +def check_workflow_for_action(content: str, action_ref: str) -> bool: + """Check if a workflow file content references a given action.""" + return action_ref in content + + +def run_zizmor_check(workflow_contents: dict[str, str]) -> tuple[bool, str]: + """Run zizmor on already-fetched workflow contents. Returns (has_errors, output).""" + zizmor_bin = shutil.which("zizmor") + if not zizmor_bin: + return False, "zizmor not found in PATH, skipping check" + + if not workflow_contents: + return False, "no workflow files to check" + + with tempfile.TemporaryDirectory() as tmpdir: + workflows_dir = Path(tmpdir) / ".github" / "workflows" + workflows_dir.mkdir(parents=True) + + for name, content in workflow_contents.items(): + (workflows_dir / name).write_text(content) + + # Write config to skip secrets-outside-env (too noisy, low signal) + config_path = Path(tmpdir) / "zizmor.yml" + config_path.write_text(ZIZMOR_CONFIG_TEMPLATE) + + result = subprocess.run( + [zizmor_bin, "--config", str(config_path), str(workflows_dir)], + capture_output=True, text=True, + timeout=120, + ) + return result.returncode != 0, result.stdout + result.stderr + + +def _print_zizmor_output(output: str) -> None: + """Print zizmor output, filtering out INFO/completion noise.""" + for line in output.strip().splitlines(): + # Skip INFO log lines and empty lines + stripped = line.strip() + if stripped.startswith("INFO ") or stripped.startswith("🌈"): + continue + console.print(f" [dim] | {line}[/]") + + +def comment_out_workflow(content: str, reason_lines: list[str]) -> str: + """Comment out an entire workflow file with explanatory header.""" + lines = [] + for reason in reason_lines: + lines.append(f"# {reason}") + lines.append("#") + for line in content.splitlines(): + if line.strip(): + lines.append(f"# {line}") + else: + lines.append("#") + return "\n".join(lines) + "\n" + + +def build_dependabot_content(existing_content: str | None) -> str: + """Build dependabot.yml content, merging with existing if present.""" + if not existing_content: + return DEPENDABOT_TEMPLATE + + try: + config = yaml.safe_load(existing_content) + except yaml.YAMLError: + return DEPENDABOT_TEMPLATE + + if not isinstance(config, dict): + return DEPENDABOT_TEMPLATE + + updates = config.get("updates", []) + + # Check if github-actions ecosystem already exists + for entry in updates: + if entry.get("package-ecosystem") == "github-actions": + # Just add/fix cooldown + entry["cooldown"] = {"default-days": 4} + return yaml.dump(config, default_flow_style=False, sort_keys=False) + + # Add new entry + updates.append(DEPENDABOT_ACTIONS_ENTRY) + config["updates"] = updates + return yaml.dump(config, default_flow_style=False, sort_keys=False) + + +def build_pr_body(result: AuditResult) -> str: + """Build the PR description body.""" + items_added = [] + if result.missing_dependabot or result.missing_dependabot_actions or result.missing_dependabot_cooldown: + items_added.append( + "- **Dependabot** (`.github/dependabot.yml`): Automated dependency updates for " + "GitHub Actions with a 4-day cooldown to allow time for review." + ) + if result.missing_codeql: + items_added.append( + "- **CodeQL Analysis** (`.github/workflows/codeql-analysis.yml`): Static analysis " + "scanning for GitHub Actions workflow syntax and security issues." + ) + if result.missing_zizmor: + items_added.append( + "- **Zizmor** (`.github/workflows/zizmor.yml`): Specialized scanner for GitHub " + "Actions security anti-patterns and misconfigurations." + ) + if result.missing_allowlist: + items_added.append( + "- **ASF Allowlist Check** (`.github/workflows/allowlist-check.yml`): Ensures all " + "actions used are on the ASF Infrastructure approved allowlist." + ) + + body = f"""\ +## Improve GitHub Actions Security Tooling + +Due to recent security breaches and the fact that GitHub Actions can fail +silently, ASF Infrastructure is rolling out baseline security checks for +all repositories that use GitHub Actions. + +This PR adds the following (where missing): + +{chr(10).join(items_added)} +""" + + if result.zizmor_has_errors: + body += """ +> [!IMPORTANT] +> **Zizmor found existing issues in this repository's workflows.** +> The **CodeQL** and **Zizmor** workflows have been added but are **commented out** +> because enabling them now would immediately flag existing issues. +> +> To enable them: +> 1. Run `zizmor --fix .github/workflows/` to auto-fix common issues +> 2. Manually fix remaining issues flagged by zizmor +> 3. Uncomment the CodeQL and Zizmor workflow files +> 4. Open a follow-up PR +> +> We kindly ask the PMC to follow up with this after merging this PR. + +
+Zizmor errors that need to be fixed + +``` +""" + # Include filtered zizmor output + for line in result.zizmor_output.strip().splitlines(): + stripped = line.strip() + if stripped.startswith("INFO ") or stripped.startswith("🌈"): + continue + body += line + "\n" + body += """``` + +
+""" + + body += """ +--- +Generated by [`apache/infrastructure-actions`](https://github.com/apache/infrastructure-actions) actions-audit +""" + return body + + +def dry_run_preview(gh: GitHubClient, owner: str, result: AuditResult) -> None: + """Show what would happen if a PR were created for this repo.""" + repo = result.repo + + # Run zizmor pre-check to show accurate preview + if result.missing_zizmor or result.missing_codeql: + result.zizmor_has_errors, result.zizmor_output = run_zizmor_check( + result.workflow_contents, + ) + + # Files that would be created/updated + files: list[tuple[str, str]] = [] + if result.missing_dependabot: + files.append(("create", ".github/dependabot.yml")) + elif result.missing_dependabot_actions or result.missing_dependabot_cooldown: + files.append(("update", ".github/dependabot.yml")) + if result.missing_codeql: + status = "create (commented out)" if result.zizmor_has_errors else "create" + files.append((status, ".github/workflows/codeql-analysis.yml")) + if result.missing_zizmor: + status = "create (commented out)" if result.zizmor_has_errors else "create" + files.append((status, ".github/workflows/zizmor.yml")) + files.append(("create", ".github/zizmor.yml")) + if result.missing_allowlist: + files.append(("create", ".github/workflows/allowlist-check.yml")) + + console.print(f" [dim]Would create PR: \"Add GitHub Actions security tooling\"[/]") + console.print(f" [dim]Branch: {BRANCH_NAME} -> {result.default_branch}[/]") + for action, path in files: + console.print(f" [dim] {action}: {path}[/]") + if result.zizmor_has_errors: + console.print(f" [yellow] zizmor found errors — CodeQL and Zizmor would be commented out[/]") + _print_zizmor_output(result.zizmor_output) + + +def confirm_pr_creation(owner: str, result: AuditResult) -> str: + """Ask user to confirm PR creation. Returns 'yes', 'no', or 'quit'.""" + console.print(f" [bold]Create PR for {owner}/{result.repo}?[/]") + console.print(f" Will add: {', '.join(result.missing_items)}") + answer = Prompt.ask( + " Proceed?", + choices=["yes", "no", "quit"], + default="yes", + console=console, + ) + return answer + + +def audit_repo(gh: GitHubClient, owner: str, node: dict) -> AuditResult: + """Run all audit checks on a single repo.""" + repo_name = node["name"] + result = AuditResult(repo=repo_name) + + # Default branch info + default_ref = node.get("defaultBranchRef") + if not default_ref: + result.skipped = "no default branch" + return result + + result.default_branch = default_ref["name"] + result.default_branch_sha = default_ref["target"]["oid"] + + # Check for workflows + workflows_obj = node.get("workflows") + if not workflows_obj or not isinstance(workflows_obj, dict): + result.skipped = "no workflows" + return result + + entries = workflows_obj.get("entries", []) + if not entries: + result.skipped = "no workflow files" + return result + + result.has_workflows = True + result.workflow_files = [e["name"] for e in entries if e["name"].endswith((".yml", ".yaml"))] + + # Check dependabot (already fetched via GraphQL) + dependabot_obj = node.get("dependabot") + dependabot_text = None + if dependabot_obj and isinstance(dependabot_obj, dict): + dependabot_text = dependabot_obj.get("text") + + missing_dep, missing_actions, missing_cooldown = check_dependabot(dependabot_text) + result.missing_dependabot = missing_dep + result.missing_dependabot_actions = missing_actions + result.missing_dependabot_cooldown = missing_cooldown + result.existing_dependabot_content = dependabot_text + + # Fetch all workflow file contents in a single GraphQL query + result.workflow_contents = gh.get_workflow_contents(owner, repo_name, result.workflow_files) + + # Check workflow contents for the three action references + codeql_found = False + zizmor_found = False + allowlist_found = False + + for content in result.workflow_contents.values(): + if not codeql_found and check_workflow_for_action(content, "github/codeql-action"): + if "actions" in content: + codeql_found = True + if not zizmor_found and check_workflow_for_action(content, "zizmorcore/zizmor-action"): + zizmor_found = True + if not allowlist_found and check_workflow_for_action( + content, "apache/infrastructure-actions/allowlist-check" + ): + allowlist_found = True + + if codeql_found and zizmor_found and allowlist_found: + break + + result.missing_codeql = not codeql_found + result.missing_zizmor = not zizmor_found + result.missing_allowlist = not allowlist_found + + return result + + +def create_pr(gh: GitHubClient, owner: str, result: AuditResult) -> str | None: + """Create a PR with missing configurations. Returns PR URL or None.""" + repo = result.repo + + # Check if branch exists (leftover from previous run) + if gh.check_branch_exists(owner, repo, BRANCH_NAME): + gh.delete_branch(owner, repo, BRANCH_NAME) + + # Run zizmor pre-check using already-fetched workflow contents + if result.missing_zizmor or result.missing_codeql: + console.print(f" [dim]Running zizmor pre-check on {repo}...[/]") + result.zizmor_has_errors, result.zizmor_output = run_zizmor_check( + result.workflow_contents, + ) + if result.zizmor_has_errors: + console.print(f" [yellow]Zizmor found errors in {repo} — will comment out CodeQL and Zizmor workflows[/]") + _print_zizmor_output(result.zizmor_output) + + # Create branch + if not gh.create_branch(owner, repo, BRANCH_NAME, result.default_branch_sha): + result.error = "failed to create branch" + return None + + default_branch = result.default_branch + + # Add missing files + if result.missing_dependabot or result.missing_dependabot_actions or result.missing_dependabot_cooldown: + new_content = build_dependabot_content(result.existing_dependabot_content) + # Need to get the SHA if updating existing file + sha = None + if not result.missing_dependabot: + # File exists, get its SHA on the branch + _, sha = gh.get_file_content(owner, repo, ".github/dependabot.yml") + msg = "Add GitHub Actions dependabot configuration" if result.missing_dependabot else \ + "Update dependabot: add github-actions ecosystem with cooldown" + if not gh.create_or_update_file(owner, repo, ".github/dependabot.yml", + new_content, msg, BRANCH_NAME, sha): + result.error = "failed to create dependabot.yml" + return None + + if result.missing_codeql: + content = CODEQL_TEMPLATE.format( + license=APACHE_LICENSE_HEADER, default_branch=default_branch, + ) + if result.zizmor_has_errors: + content = comment_out_workflow(content, [ + "NOTE: This workflow is commented out because zizmor found existing", + "errors in this repository's GitHub Actions workflows.", + "", + "CodeQL for Actions depends on clean workflow syntax. Please:", + "1. Run 'zizmor --fix .github/workflows/' to auto-fix common issues", + "2. Manually fix remaining issues flagged by zizmor", + "3. Uncomment this file and open a follow-up PR", + ]) + if not gh.create_or_update_file(owner, repo, ".github/workflows/codeql-analysis.yml", + content, "Add CodeQL analysis workflow for Actions", + BRANCH_NAME): + result.error = "failed to create codeql-analysis.yml" + return None + + if result.missing_zizmor: + content = ZIZMOR_TEMPLATE.format( + license=APACHE_LICENSE_HEADER, default_branch=default_branch, + ) + if result.zizmor_has_errors: + content = comment_out_workflow(content, [ + "NOTE: This workflow is commented out because zizmor found existing", + "errors in this repository's GitHub Actions workflows.", + "", + "Zizmor can fix many of these issues automatically. Please:", + "1. Run 'zizmor --fix .github/workflows/' to auto-fix common issues", + "2. Manually fix remaining issues", + "3. Uncomment this file and open a follow-up PR", + ]) + if not gh.create_or_update_file(owner, repo, ".github/workflows/zizmor.yml", + content, "Add Zizmor workflow scanning", + BRANCH_NAME): + result.error = "failed to create zizmor.yml" + return None + # Add zizmor config file (skips secrets-outside-env) + if not gh.create_or_update_file(owner, repo, ".github/zizmor.yml", + ZIZMOR_CONFIG_TEMPLATE, + "Add zizmor configuration", + BRANCH_NAME): + result.error = "failed to create zizmor.yml config" + return None + + if result.missing_allowlist: + content = ALLOWLIST_TEMPLATE.format( + license=APACHE_LICENSE_HEADER, default_branch=default_branch, + ) + if not gh.create_or_update_file(owner, repo, ".github/workflows/allowlist-check.yml", + content, "Add ASF allowlist check workflow", + BRANCH_NAME): + result.error = "failed to create allowlist-check.yml" + return None + + # Create PR + title = "Add GitHub Actions security tooling" + body = build_pr_body(result) + pr_url = gh.create_pull_request(owner, repo, title, body, BRANCH_NAME, default_branch) + if not pr_url: + result.error = "failed to create PR" + return None + + result.pr_url = pr_url + return pr_url + + +def main(): + parser = argparse.ArgumentParser( + description="Audit Apache GitHub repos for proper Actions security configurations.", + ) + parser.add_argument( + "--pmc", action="append", default=[], + help="Filter by PMC prefix (repeatable). Prefix is text before first '-' in repo name.", + ) + parser.add_argument( + "--dry-run", action="store_true", + help="Report findings without creating PRs.", + ) + parser.add_argument( + "--max-num", type=int, default=0, + help="Maximum number of repos to check (0=unlimited).", + ) + parser.add_argument( + "--batch-size", type=int, default=50, + help="GraphQL page size for repo queries (default: 50).", + ) + parser.add_argument( + "--github-token", default=os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN"), + help="GitHub token (default: GH_TOKEN or GITHUB_TOKEN env var).", + ) + parser.add_argument( + "--no-gh", action="store_true", + help="Use requests library instead of gh CLI.", + ) + parser.add_argument( + "--verbose", action="store_true", + help="Print GraphQL queries and other debug information.", + ) + args = parser.parse_args() + + # Validate prerequisites + if not args.no_gh and not args.github_token and not shutil.which("gh"): + console.print("[red]Error: gh CLI not found. Install it or provide --github-token.[/]") + sys.exit(1) + + if not args.dry_run and not shutil.which("zizmor"): + console.print("[yellow]Warning: zizmor not found in PATH. Zizmor pre-checks will be skipped.[/]") + + gh = GitHubClient(token=args.github_token, use_requests=args.no_gh, + verbose=args.verbose) + + # Load PMC filter + pmc_filter = set() + if args.pmc: + pmc_list = get_pmc_list() + for pmc_name in args.pmc: + pmc_lower = pmc_name.lower() + if pmc_lower not in [p.lower() for p in pmc_list]: + console.print(f"[yellow]Warning: '{pmc_name}' not found in PMC list. Using as prefix anyway.[/]") + pmc_filter.add(pmc_lower) + + owner = "apache" + cursor = None + checked = 0 + quit_requested = False + results: list[AuditResult] = [] + + console.print(f"[bold]Auditing apache/ repositories...[/]") + if pmc_filter: + console.print(f"[dim]PMC filter: {', '.join(sorted(pmc_filter))}[/]") + if args.dry_run: + console.print("[dim]Dry run mode — no PRs will be created.[/]") + console.print() + + def process_node(node: dict) -> AuditResult | None: + """Process a single repo node. Returns None if skipped without counting.""" + nonlocal checked, quit_requested + + repo_name = node["name"] + + # Skip archived/disabled + if node.get("isArchived") or node.get("isDisabled"): + return None + + # PMC filter (still needed for search results — search matches anywhere in name) + if pmc_filter: + prefix = repo_pmc_prefix(repo_name) + if prefix.lower() not in pmc_filter: + return None + + checked += 1 + console.print(f"[bold]{checked}.[/] {owner}/{repo_name} ", end="") + + try: + result = audit_repo(gh, owner, node) + except Exception as e: + result = AuditResult(repo=repo_name, error=str(e)) + + if result.skipped: + console.print(f"[dim]— skipped: {result.skipped}[/]") + return result + + if result.error: + console.print(f"[red]— error: {result.error}[/]") + return result + + if not result.needs_pr: + console.print("[green]— compliant[/]") + return result + + missing_str = ", ".join(result.missing_items) + console.print(f"[yellow]— missing: {missing_str}[/]") + + # Check for existing PR (open, closed, or merged) + existing_url, existing_state = gh.check_existing_pr( + owner, repo_name, BRANCH_NAME, + ) + if existing_url: + result.skipped = f"PR already {existing_state}: {existing_url}" + console.print(f" [dim]Skipped — PR already {existing_state}: {existing_url}[/]") + return result + + if args.dry_run: + # Show what would happen without making changes + dry_run_preview(gh, owner, result) + else: + # Ask for confirmation + answer = confirm_pr_creation(owner, result) + if answer == "quit": + console.print(" [dim]Quitting...[/]") + quit_requested = True + return result + if answer == "no": + console.print(" [dim]Skipped by user.[/]") + result.skipped = "skipped by user" + return result + + try: + pr_url = create_pr(gh, owner, result) + if pr_url: + console.print(f" [green]PR created: {pr_url}[/]") + elif result.skipped: + console.print(f" [dim]{result.skipped}[/]") + elif result.error: + console.print(f" [red]Error: {result.error}[/]") + except Exception as e: + result.error = str(e) + console.print(f" [red]Error creating PR: {e}[/]") + + return result + + def process_page(nodes: list[dict]) -> bool: + """Process a page of repo nodes. Returns True if should stop.""" + nonlocal checked, quit_requested + for node in nodes: + if args.max_num and checked >= args.max_num: + return True + if quit_requested: + return True + result = process_node(node) + if result: + results.append(result) + return False + + if pmc_filter: + # Use targeted search per PMC prefix — much faster than paging all repos + seen_repos: set[str] = set() + for pmc_prefix in sorted(pmc_filter): + cursor = None + while True: + if args.max_num and checked >= args.max_num: + break + if quit_requested: + break + nodes, has_next, cursor = gh.search_repos_page( + owner, pmc_prefix, args.batch_size, cursor, + ) + if not nodes: + break + # Deduplicate across PMC prefixes + unique_nodes = [] + for node in nodes: + name = node.get("name", "") + if name not in seen_repos: + seen_repos.add(name) + unique_nodes.append(node) + if process_page(unique_nodes): + break + if not has_next: + break + else: + cursor = None + while True: + nodes, has_next, cursor = gh.get_repos_page(owner, args.batch_size, cursor) + if not nodes: + break + if process_page(nodes): + break + if not has_next: + break + + # Summary table + console.print() + table = Table(title=f"Audit Summary ({checked} repos checked)") + table.add_column("Repo", style="bold") + table.add_column("Dependabot") + table.add_column("CodeQL") + table.add_column("Zizmor") + table.add_column("Allowlist") + table.add_column("Status") + + for r in results: + if r.skipped and not r.has_workflows: + continue # Don't show repos without workflows + + def status_icon(missing: bool) -> str: + return "[red]MISSING[/]" if missing else "[green]OK[/]" + + dep_missing = r.missing_dependabot or r.missing_dependabot_actions or r.missing_dependabot_cooldown + dep_status = status_icon(dep_missing) if r.has_workflows else "[dim]—[/]" + + if r.error: + status = f"[red]Error: {r.error[:40]}[/]" + elif r.pr_url: + status = f"[green]PR: {r.pr_url}[/]" + elif r.skipped: + status = f"[dim]{r.skipped}[/]" + elif r.needs_pr: + status = "[yellow]Dry run[/]" if args.dry_run else "[yellow]Needs PR[/]" + else: + status = "[green]Compliant[/]" + + table.add_row( + f"apache/{r.repo}", + dep_status, + status_icon(r.missing_codeql) if r.has_workflows else "[dim]—[/]", + status_icon(r.missing_zizmor) if r.has_workflows else "[dim]—[/]", + status_icon(r.missing_allowlist) if r.has_workflows else "[dim]—[/]", + status, + ) + + console.print(table) + + # Print totals + total_with_workflows = sum(1 for r in results if r.has_workflows) + total_compliant = sum(1 for r in results if r.has_workflows and not r.needs_pr and not r.error) + total_needs_pr = sum(1 for r in results if r.needs_pr) + total_prs_created = sum(1 for r in results if r.pr_url) + total_errors = sum(1 for r in results if r.error) + + console.print(f"\n[bold]Totals:[/]") + console.print(f" Repos with workflows: {total_with_workflows}") + console.print(f" Compliant: [green]{total_compliant}[/]") + console.print(f" Needing updates: [yellow]{total_needs_pr}[/]") + if total_prs_created: + console.print(f" PRs created: [green]{total_prs_created}[/]") + if total_errors: + console.print(f" Errors: [red]{total_errors}[/]") + + +if __name__ == "__main__": + main() diff --git a/utils/pyproject.toml b/utils/pyproject.toml index 20278803..09e3ee10 100644 --- a/utils/pyproject.toml +++ b/utils/pyproject.toml @@ -29,3 +29,7 @@ dependencies = [ [project.scripts] verify-action-build = "verify_action_build:main" + +[tool.uv] +required-version = ">=0.9.17" +exclude-newer = "4 days" diff --git a/uv.lock b/uv.lock index 261ab5c6..771f05a4 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.13" [options] -exclude-newer = "2026-03-30T00:55:54.930441693Z" +exclude-newer = "2026-03-26T23:08:35.254903Z" exclude-newer-span = "P4D" [[package]] @@ -72,11 +72,11 @@ wheels = [ [[package]] name = "pygments" -version = "2.20.0" +version = "2.19.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] [[package]]