blob: 27d6a4cd817de3513d47af773ee8be0a9599dd06 [file]
#
# 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.
#
# /// 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.
<details>
<summary>Zizmor errors that need to be fixed</summary>
```
"""
# 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 += """```
</details>
"""
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()