diff --git a/.github/config/ssl_domains.yaml b/.github/config/ssl_domains.yaml new file mode 100644 index 00000000000000..6010b9094e04fc --- /dev/null +++ b/.github/config/ssl_domains.yaml @@ -0,0 +1,9 @@ +warning_days: 21 +domains: + - bazel.build + - bcr.bazel.build + - blog.bazel.build + - dashboard.bazel.build + - mirror.bazel.build + - registry.bazel.build + - releases.bazel.build diff --git a/.github/scripts/check_ssl.py b/.github/scripts/check_ssl.py new file mode 100644 index 00000000000000..7624ee32f76439 --- /dev/null +++ b/.github/scripts/check_ssl.py @@ -0,0 +1,138 @@ +# Copyright 2026 The Bazel Authors. All rights reserved. +# +# Licensed 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 to monitor SSL certificate expiration for Bazel domains.""" + +import datetime +import json +import os +import socket +import ssl +import sys +from typing import Dict, List, Optional, Tuple + +import certifi +import yaml + +# Constants +DEFAULT_CONFIG_PATH = os.path.join( + os.path.dirname(__file__), "../config/ssl_domains.yaml" +) +DEFAULT_WARNING_DAYS = 21 +HTTPS_PORT = 443 +SOCKET_TIMEOUT_SECONDS = 5 + + +class SSLMonitor: + """Class to manage and check SSL certificate expirations.""" + + def __init__(self, config_path: str = DEFAULT_CONFIG_PATH): + self.config_path = config_path + self.config = self._load_config() + self.warning_days = self.config.get("warning_days", DEFAULT_WARNING_DAYS) + self.domains = self.config.get("domains", []) + self.context = ssl.create_default_context(cafile=certifi.where()) + + def _load_config(self) -> Dict: + """Loads the YAML configuration file.""" + if not os.path.exists(self.config_path): + print(f"Error: Config file not found at {self.config_path}") + sys.exit(1) + + try: + with open(self.config_path, "r", encoding="utf-8") as f: + config = yaml.safe_load(f) + return config if isinstance(config, dict) else {} + except yaml.YAMLError as exc: + print(f"Error parsing YAML config: {exc}") + sys.exit(1) + + def get_days_until_expiration(self, domain: str) -> int: + """Connects to the domain and retrieves days until certificate expiration.""" + with socket.create_connection( + (domain, HTTPS_PORT), timeout=SOCKET_TIMEOUT_SECONDS + ) as sock: + with self.context.wrap_socket(sock, server_hostname=domain) as ssock: + cert = ssock.getpeercert() + if not cert: + raise ValueError(f"No certificate found for {domain}") + + expires_str = cert["notAfter"] + # Format: '%b %d %H:%M:%S %Y GMT' (e.g., 'Mar 26 20:13:28 2026 GMT') + expires_dt = datetime.datetime.strptime( + expires_str, "%b %d %H:%M:%S %Y GMT" + ).replace(tzinfo=datetime.timezone.utc) + + delta = expires_dt - datetime.datetime.now(datetime.timezone.utc) + return delta.days + + def check_all(self) -> List[str]: + """Checks all configured domains and prints the status.""" + failed_domains = [] + print(f"Checking {len(self.domains)} domains (Threshold: < {self.warning_days} days)...\n") + print(f"{'DOMAIN':<35} | {'DAYS LEFT':<10} | {'STATUS'}") + print("-" * 65) + + for domain in self.domains: + try: + days_left = self.get_days_until_expiration(domain) + status = "✅ OK" + if days_left < self.warning_days: + status = "❌ EXPIRING SOON" + failed_domains.append(f"{domain} ({days_left} days left)") + print(f"{domain:<35} | {days_left:<10} | {status}") + except (OSError, ValueError) as e: + # Catch broad exceptions to ensure we try all domains + error_msg = str(e) + print(f"{domain:<35} | {'ERROR':<10} | 🚨 {error_msg}") + failed_domains.append(f"{domain} ({error_msg})") + + return failed_domains + + +def report_failures(failures: List[str]): + """Reports failures to stdout and GitHub Step Summary if available.""" + if not failures: + print("\nAll certificates look good.") + return + + summary = "\n".join([f"- {d}" for d in failures]) + + # GitHub Action specific summary + if "GITHUB_STEP_SUMMARY" in os.environ: + try: + with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as fh: + fh.write("### 🚨 SSL Certificates Expiring Soon or Failing\n") + fh.write(summary + "\n") + except IOError as e: + print(f"Warning: Could not write to GITHUB_STEP_SUMMARY: {e}") + + print("\nCRITICAL ISSUES FOUND:") + print(summary) + sys.exit(1) + + +def main(): + + monitor = SSLMonitor() + if not monitor.domains: + print("Error: No domains found in config.") + sys.exit(1) + + failures = monitor.check_all() + report_failures(failures) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/manage_ssl_issue.js b/.github/scripts/manage_ssl_issue.js new file mode 100644 index 00000000000000..5a7686d4aa99da --- /dev/null +++ b/.github/scripts/manage_ssl_issue.js @@ -0,0 +1,64 @@ +// Copyright 2026 The Bazel Authors. All rights reserved. +// +// Licensed 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. + +module.exports = async ({github, context}) => { + const fs = require("fs"); + const output = fs.readFileSync("ssl_output.txt", "utf8"); + const title = "🚨 Urgent: SSL Certificates Expiring"; + + // Find existing open issues created by this workflow + const issues = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: "open", + creator: "github-actions[bot]" + }); + + const existingIssue = issues.data.find(i => i.title === title); + const body = [ + "### SSL Certificate Warning", + "", + "The automated monitor has detected SSL issues.", + "", + "#### Details:", + "```", + output, + "```", + "", + "**Action Required:**", + "1. Check `.github/config/ssl_domains.yaml` to verify the domain list.", + "2. Renew the certificates.", + "", + `[Workflow Run Log](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})`, + "", + "cc: @bazelbuild/bazel-oss" + ].join("\n"); + + if (existingIssue) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: existingIssue.number, + body: "SSL check failed again. Latest status:\n\n" + body + }); + } else { + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + body: body, + labels: ["breakage", "P0", "team-OSS", "infrastructure"] + }); + } +} diff --git a/.github/workflows/ssl-monitor.yml b/.github/workflows/ssl-monitor.yml new file mode 100644 index 00000000000000..0d5ec94b936c5c --- /dev/null +++ b/.github/workflows/ssl-monitor.yml @@ -0,0 +1,52 @@ +name: SSL Certificate Monitor + +on: + schedule: + - cron: "0 8 * * *" # Runs daily at 08:00 UTC + workflow_dispatch: # Allows manual trigger + pull_request: # Validates changes to the monitor itself + paths: + - ".github/workflows/ssl-monitor.yml" + - ".github/scripts/check_ssl.py" + - ".github/scripts/manage_ssl_issue.js" + - ".github/config/ssl_domains.yaml" + +permissions: + contents: read + issues: write + +jobs: + check-ssl-certs: + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: pip install PyYAML certifi + + - name: Run SSL Check + id: check_script + run: | + # Capture output to a file and set a flag if the script fails + python .github/scripts/check_ssl.py > ssl_output.txt 2>&1 || echo "SSL_CHECK_FAILED=true" >> $GITHUB_ENV + cat ssl_output.txt + + - name: Manage SSL Issue on Failure + if: env.SSL_CHECK_FAILED == 'true' + uses: actions/github-script@v7 + with: + script: | + const script = require('./.github/scripts/manage_ssl_issue.js') + await script({github, context}) + + - name: Fail workflow if SSL issues found + if: env.SSL_CHECK_FAILED == 'true' + run: | + echo "SSL check failed. See script output and created/updated GitHub issue." + exit 1