diff --git a/.github/data/required_actions_secrets.json b/.github/data/required_actions_secrets.json new file mode 100644 index 0000000..6b4bee0 --- /dev/null +++ b/.github/data/required_actions_secrets.json @@ -0,0 +1,22 @@ +{ + "schema": "szl.required-actions-secrets/v1", + "organization": "szl-holdings", + "requirements": [ + { + "repository": "killinchu", + "secret": "HF_WRITE_TOKEN" + }, + { + "repository": "a11oy", + "secret": "HF_TOKEN" + }, + { + "repository": "szl-lake", + "secret": "HF_TOKEN" + }, + { + "repository": ".github", + "secret": "HF_TOKEN" + } + ] +} diff --git a/.github/scripts/secret_health.py b/.github/scripts/secret_health.py new file mode 100644 index 0000000..2aad966 --- /dev/null +++ b/.github/scripts/secret_health.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +"""Audit required GitHub Actions secret names without accessing secret values.""" +from __future__ import annotations + +import argparse +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Iterable + +API = "https://api.github.com" +SCHEMA = "szl.actions-secret-health/v2" +EXIT_OK = 0 +EXIT_MISSING = 1 +EXIT_UNAVAILABLE = 2 + + +class AuditUnavailable(RuntimeError): + """Raised when the audit cannot enumerate secret names authoritatively.""" + + +@dataclass(frozen=True) +class Requirement: + repository: str + secret: str + + +def load_policy(path: Path) -> tuple[str, list[Requirement]]: + payload = json.loads(path.read_text(encoding="utf-8")) + if payload.get("schema") != "szl.required-actions-secrets/v1": + raise ValueError("unsupported required-secret policy schema") + organization = payload.get("organization") + if not isinstance(organization, str) or not organization.strip(): + raise ValueError("policy organization must be a non-empty string") + requirements: list[Requirement] = [] + for item in payload.get("requirements", []): + if not isinstance(item, dict): + raise ValueError("policy requirements must be objects") + repository = item.get("repository") + secret = item.get("secret") + if not isinstance(repository, str) or not repository.strip(): + raise ValueError("requirement repository must be non-empty") + if not isinstance(secret, str) or not secret.strip(): + raise ValueError("requirement secret must be non-empty") + requirements.append(Requirement(repository.strip(), secret.strip())) + if not requirements: + raise ValueError("policy must contain at least one requirement") + if len({(item.repository, item.secret) for item in requirements}) != len(requirements): + raise ValueError("policy contains duplicate requirements") + return organization.strip(), requirements + + +def _next_link(header: str) -> str | None: + for part in header.split(","): + section = part.strip() + if 'rel="next"' not in section: + continue + if section.startswith("<") and ">" in section: + return section[1 : section.index(">")] + return None + + +def github_secret_names(token: str, endpoint: str) -> set[str]: + """Return secret names from one GitHub listing endpoint. + + GitHub never returns secret values from these endpoints. Response bodies are + not included in raised errors so token-adjacent diagnostics cannot be logged. + """ + + names: set[str] = set() + url: str | None = f"{API}{endpoint}" + while url: + request = urllib.request.Request( + url, + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "szl-secret-health/2", + }, + ) + try: + with urllib.request.urlopen(request, timeout=30) as response: + payload = json.load(response) + link = response.headers.get("Link", "") + except urllib.error.HTTPError as exc: + raise AuditUnavailable(f"HTTP_{exc.code}") from exc + except Exception as exc: # noqa: BLE001 - reduced to a class-only receipt + raise AuditUnavailable(type(exc).__name__) from exc + if not isinstance(payload, dict): + raise AuditUnavailable("INVALID_RESPONSE") + secrets = payload.get("secrets") + if not isinstance(secrets, list): + raise AuditUnavailable("INVALID_RESPONSE") + for item in secrets: + name = item.get("name") if isinstance(item, dict) else None + if not isinstance(name, str) or not name: + raise AuditUnavailable("INVALID_SECRET_NAME") + names.add(name) + next_url = _next_link(link) + if next_url is not None and not next_url.startswith(f"{API}/"): + raise AuditUnavailable("INVALID_PAGINATION") + url = next_url + return names + + +def audit_requirements( + organization: str, + requirements: Iterable[Requirement], + fetch_names: Callable[[str], set[str]], +) -> tuple[list[dict[str, Any]], int]: + results: list[dict[str, Any]] = [] + exit_code = EXIT_OK + for requirement in requirements: + encoded_org = urllib.parse.quote(organization, safe="") + encoded_repo = urllib.parse.quote(requirement.repository, safe="") + repo_endpoint = ( + f"/repos/{encoded_org}/{encoded_repo}/actions/secrets?per_page=100" + ) + org_endpoint = ( + f"/repos/{encoded_org}/{encoded_repo}/actions/organization-secrets" + "?per_page=100" + ) + try: + repository_names = fetch_names(repo_endpoint) + organization_names = fetch_names(org_endpoint) + except AuditUnavailable as exc: + results.append( + { + "repository": requirement.repository, + "required_secret": requirement.secret, + "state": "UNAVAILABLE", + "error_class": str(exc), + } + ) + exit_code = EXIT_UNAVAILABLE + continue + + present = requirement.secret in repository_names | organization_names + results.append( + { + "repository": requirement.repository, + "required_secret": requirement.secret, + "state": "PRESENT" if present else "MISSING", + "error_class": None, + } + ) + if not present and exit_code != EXIT_UNAVAILABLE: + exit_code = EXIT_MISSING + return results, exit_code + + +def build_report( + *, + organization: str, + auth_source: str, + results: list[dict[str, Any]], + exit_code: int, +) -> dict[str, Any]: + counts = {"PRESENT": 0, "MISSING": 0, "UNAVAILABLE": 0} + for item in results: + counts[item["state"]] += 1 + return { + "schema": SCHEMA, + "organization": organization, + "auth_source": auth_source, + "state": ( + "VERIFIED" + if exit_code == EXIT_OK + else "MISSING" + if exit_code == EXIT_MISSING + else "UNAVAILABLE" + ), + "counts": counts, + "requirements": results, + "secret_values_requested": False, + "secret_values_recorded": False, + "token_value_recorded": False, + "token_metadata_recorded": False, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--policy", + default=".github/data/required_actions_secrets.json", + type=Path, + ) + parser.add_argument("--report", default="reports/secret-health.json", type=Path) + args = parser.parse_args() + + token = os.environ.get("GH_TOKEN", "") + auth_source = os.environ.get("SECRET_HEALTH_AUTH_SOURCE", "UNAVAILABLE") + try: + organization, requirements = load_policy(args.policy) + except (OSError, json.JSONDecodeError, ValueError) as exc: + print(f"secret-health policy invalid: {type(exc).__name__}", file=sys.stderr) + return EXIT_UNAVAILABLE + + if not token: + results = [ + { + "repository": item.repository, + "required_secret": item.secret, + "state": "UNAVAILABLE", + "error_class": "NO_MACHINE_IDENTITY", + } + for item in requirements + ] + exit_code = EXIT_UNAVAILABLE + else: + results, exit_code = audit_requirements( + organization, + requirements, + lambda endpoint: github_secret_names(token, endpoint), + ) + + report = build_report( + organization=organization, + auth_source=auth_source, + results=results, + exit_code=exit_code, + ) + args.report.parent.mkdir(parents=True, exist_ok=True) + args.report.write_text( + json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + print(json.dumps(report, indent=2, sort_keys=True)) + return exit_code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/test_secret_health.py b/.github/scripts/test_secret_health.py new file mode 100644 index 0000000..f031114 --- /dev/null +++ b/.github/scripts/test_secret_health.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Network-free regression tests for the secret-health auditor.""" +from __future__ import annotations + +import importlib.util +import json +import sys +import tempfile +import unittest +from pathlib import Path + +MODULE_PATH = Path(__file__).with_name("secret_health.py") +SPEC = importlib.util.spec_from_file_location("secret_health", MODULE_PATH) +assert SPEC and SPEC.loader +secret_health = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = secret_health +SPEC.loader.exec_module(secret_health) + + +class SecretHealthTests(unittest.TestCase): + def test_policy_is_versioned_unique_and_explicit(self) -> None: + policy = { + "schema": "szl.required-actions-secrets/v1", + "organization": "szl-holdings", + "requirements": [ + {"repository": "a11oy", "secret": "HF_TOKEN"}, + {"repository": "killinchu", "secret": "HF_WRITE_TOKEN"}, + ], + } + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "policy.json" + path.write_text(json.dumps(policy), encoding="utf-8") + organization, requirements = secret_health.load_policy(path) + self.assertEqual(organization, "szl-holdings") + self.assertEqual( + [(item.repository, item.secret) for item in requirements], + [("a11oy", "HF_TOKEN"), ("killinchu", "HF_WRITE_TOKEN")], + ) + + def test_duplicate_policy_requirement_is_rejected(self) -> None: + policy = { + "schema": "szl.required-actions-secrets/v1", + "organization": "szl-holdings", + "requirements": [ + {"repository": "a11oy", "secret": "HF_TOKEN"}, + {"repository": "a11oy", "secret": "HF_TOKEN"}, + ], + } + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "policy.json" + path.write_text(json.dumps(policy), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "duplicate"): + secret_health.load_policy(path) + + def test_repository_or_governed_org_name_satisfies_requirement(self) -> None: + requirements = [ + secret_health.Requirement("a11oy", "HF_TOKEN"), + secret_health.Requirement("killinchu", "HF_WRITE_TOKEN"), + ] + + def fetch_names(endpoint: str) -> set[str]: + if "/a11oy/actions/secrets" in endpoint: + return {"HF_TOKEN"} + if "/killinchu/actions/organization-secrets" in endpoint: + return {"HF_WRITE_TOKEN"} + return set() + + results, exit_code = secret_health.audit_requirements( + "szl-holdings", requirements, fetch_names + ) + self.assertEqual(exit_code, secret_health.EXIT_OK) + self.assertEqual([item["state"] for item in results], ["PRESENT", "PRESENT"]) + + def test_missing_name_is_distinct_from_unavailable_audit(self) -> None: + requirement = [secret_health.Requirement("a11oy", "HF_TOKEN")] + missing, missing_code = secret_health.audit_requirements( + "szl-holdings", requirement, lambda _endpoint: set() + ) + self.assertEqual(missing_code, secret_health.EXIT_MISSING) + self.assertEqual(missing[0]["state"], "MISSING") + + def unavailable(_endpoint: str) -> set[str]: + raise secret_health.AuditUnavailable("HTTP_403") + + unknown, unknown_code = secret_health.audit_requirements( + "szl-holdings", requirement, unavailable + ) + self.assertEqual(unknown_code, secret_health.EXIT_UNAVAILABLE) + self.assertEqual(unknown[0]["state"], "UNAVAILABLE") + self.assertEqual(unknown[0]["error_class"], "HTTP_403") + + def test_receipt_structurally_excludes_secret_and_token_material(self) -> None: + report = secret_health.build_report( + organization="szl-holdings", + auth_source="governed-fallback", + results=[ + { + "repository": "a11oy", + "required_secret": "HF_TOKEN", + "state": "PRESENT", + "error_class": None, + } + ], + exit_code=secret_health.EXIT_OK, + ) + encoded = json.dumps(report, sort_keys=True) + self.assertEqual(report["state"], "VERIFIED") + self.assertFalse(report["secret_values_requested"]) + self.assertFalse(report["secret_values_recorded"]) + self.assertFalse(report["token_value_recorded"]) + self.assertFalse(report["token_metadata_recorded"]) + self.assertNotIn("Bearer ", encoded) + self.assertNotIn("ghp_", encoded) + self.assertNotIn("github_pat_", encoded) + + def test_pagination_parser_is_bounded(self) -> None: + self.assertIsNone(secret_health._next_link("")) + self.assertEqual( + secret_health._next_link( + '; rel="next", ' + '; rel="last"' + ), + "https://api.github.com/resource?page=2", + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/.github/workflows/secret-health.yml b/.github/workflows/secret-health.yml new file mode 100644 index 0000000..c64a719 --- /dev/null +++ b/.github/workflows/secret-health.yml @@ -0,0 +1,134 @@ +# Organization-wide required GitHub Actions secret-name audit. +# +# Authentication is App-first and fail-closed. qillqaq is requested with only +# repository/organization Secrets read. Until that installation permission is +# approved, the existing governed SZL_GITHUB_TOKEN is used read-only. The retired +# SECRET_HEALTH_TOKEN PAT is never consumed. + +name: Governed Secret Health + +on: + schedule: + - cron: "30 6 * * *" + workflow_dispatch: {} + pull_request: + branches: [main] + paths: + - .github/workflows/secret-health.yml + - .github/data/required_actions_secrets.json + - .github/scripts/secret_health.py + - .github/scripts/test_secret_health.py + push: + branches: [main] + paths: + - .github/workflows/secret-health.yml + - .github/data/required_actions_secrets.json + - .github/scripts/secret_health.py + - .github/scripts/test_secret_health.py + +permissions: + contents: read + +concurrency: + group: governed-secret-health-${{ github.ref }} + cancel-in-progress: true + +jobs: + audit: + name: Governed organization secret-name health + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Checkout governed policy and auditor + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + fetch-depth: 1 + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - name: Compile and test the fail-closed contract + run: | + set -euo pipefail + python -m py_compile \ + .github/scripts/secret_health.py \ + .github/scripts/test_secret_health.py + python .github/scripts/test_secret_health.py + git diff --check + + - name: Mint preferred qillqaq secret-name reader + id: app-token + continue-on-error: true + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.QILLQAQ_CLIENT_ID }} + private-key: ${{ secrets.QILLQAQ_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + permission-metadata: read + permission-secrets: read + permission-organization-secrets: read + + - name: Select governed machine identity without serializing it + id: auth + env: + APP_OUTCOME: ${{ steps.app-token.outcome }} + APP_TOKEN: ${{ steps.app-token.outputs.token }} + GOVERNED_FALLBACK: ${{ secrets.SZL_GITHUB_TOKEN }} + run: | + set -euo pipefail + if [ "$APP_OUTCOME" = 'success' ] && [ -n "$APP_TOKEN" ]; then + echo 'source=qillqaq-app' >> "$GITHUB_OUTPUT" + echo 'available=true' >> "$GITHUB_OUTPUT" + elif [ -n "$GOVERNED_FALLBACK" ]; then + echo 'source=governed-fallback' >> "$GITHUB_OUTPUT" + echo 'available=true' >> "$GITHUB_OUTPUT" + else + echo 'source=UNAVAILABLE' >> "$GITHUB_OUTPUT" + echo 'available=false' >> "$GITHUB_OUTPUT" + fi + + - name: Audit required names and emit a redacted receipt + id: audit + env: + GH_TOKEN: ${{ steps.app-token.outputs.token || secrets.SZL_GITHUB_TOKEN }} + SECRET_HEALTH_AUTH_SOURCE: ${{ steps.auth.outputs.source }} + run: | + set +e + python .github/scripts/secret_health.py \ + --policy .github/data/required_actions_secrets.json \ + --report reports/secret-health.json + code=$? + echo "exit_code=$code" >> "$GITHUB_OUTPUT" + exit 0 + + - name: Upload immutable secret-name health receipt + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: governed-secret-health-${{ github.run_id }} + path: reports/secret-health.json + if-no-files-found: error + retention-days: 90 + + - name: Enforce verified coverage + if: always() + env: + EXIT_CODE: ${{ steps.audit.outputs.exit_code }} + AUTH_AVAILABLE: ${{ steps.auth.outputs.available }} + AUTH_SOURCE: ${{ steps.auth.outputs.source }} + run: | + set -euo pipefail + code="${EXIT_CODE:-2}" + if [ "$code" -ne 0 ]; then + echo "::error::secret-health is not verified (source=${AUTH_SOURCE:-UNAVAILABLE}, available=${AUTH_AVAILABLE:-false}, exit=$code)." + exit "$code" + fi + echo "Secret-name health verified through ${AUTH_SOURCE}."