#!/usr/bin/env python3 """Verify one untrusted FindMate GitHub owner-profile submission safely.""" from __future__ import annotations import argparse import hmac import importlib.util import json import re import sys from pathlib import Path from urllib.error import HTTPError, URLError from urllib.parse import urlparse from urllib.request import Request, urlopen REPOSITORY = "merc1305/findMate" ISSUE_NUMBER = 2 PROFILE_REPLY_MARKER = "FINDMATE_OWNER_PROFILE_V1" MAX_PROFILE_BYTES = 65_536 GITHUB_BLOB_PATTERN = re.compile( r"^/" r"(?P[A-Za-z0-9][A-Za-z0-9-]{0,38})/" r"(?P[A-Za-z0-9._-]{1,100})/" r"blob/" r"(?P[0-9a-fA-F]{40})/" r"(?P[A-Za-z0-9._/-]+\.json)$" ) REASON_MESSAGES = { "comment_deleted": ( "The source owner-profile comment was deleted and is revoked." ), "comment_shape": ( "The marked comment is missing its own-owner declaration, inline or " "linked profile, canonical SHA-256, or expiry." ), "event_scope": "The event does not belong to the canonical FindMate issue.", "profile_download_failed": ( "The immutable GitHub profile could not be downloaded within the safety limits." ), "profile_expiry_mismatch": ( "The expiry in the comment does not match the validated profile." ), "profile_hash_mismatch": ( "The canonical SHA-256 in the comment does not match the validated profile." ), "profile_json_invalid": ( "The linked or inline profile is not a valid JSON object." ), "profile_too_large": ( "The linked or inline profile exceeds its safety limit." ), "profile_url_requires_immutable_github_blob": ( "The profile URL must be a github.com blob URL pinned to a full 40-character " "commit SHA." ), "profile_validation_failed": ( "The profile failed schema, privacy, consent, or expiry validation." ), } class SubmissionError(ValueError): """Raised for a bounded, public-safe submission validation failure.""" def __init__(self, code: str): if code not in REASON_MESSAGES: raise ValueError(f"Unsupported submission error code: {code}") super().__init__(REASON_MESSAGES[code]) self.code = code def load_sibling_module(module_name: str, filename: str): path = Path(__file__).with_name(filename) spec = importlib.util.spec_from_file_location(module_name, path) if spec is None or spec.loader is None: raise RuntimeError(f"Cannot load required module: {filename}") module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module GITHUB_THREAD = load_sibling_module("_findmate_github_thread", "github_thread.py") PROFILE_VALIDATOR = load_sibling_module( "_findmate_profile_validator", "validate_profile.py", ) def immutable_raw_profile_url(profile_url: str) -> str: parsed = urlparse(profile_url) try: port = parsed.port except ValueError as exc: raise SubmissionError( "profile_url_requires_immutable_github_blob" ) from exc if ( parsed.scheme != "https" or parsed.hostname != "github.com" or port is not None or parsed.username or parsed.password or parsed.query or parsed.fragment ): raise SubmissionError("profile_url_requires_immutable_github_blob") match = GITHUB_BLOB_PATTERN.fullmatch(parsed.path) if match is None: raise SubmissionError("profile_url_requires_immutable_github_blob") path_parts = match.group("path").split("/") if any(part in {"", ".", ".."} for part in path_parts): raise SubmissionError("profile_url_requires_immutable_github_blob") return ( "https://raw.githubusercontent.com/" f"{match.group('owner')}/{match.group('repo')}/" f"{match.group('commit').lower()}/{match.group('path')}" ) def download_profile(raw_url: str) -> dict: request = Request( raw_url, headers={ "Accept": "application/json,text/plain;q=0.9", "User-Agent": "findmate-owner-profile-verifier/1.0", }, ) try: with urlopen(request, timeout=15) as response: final = urlparse(response.geturl()) if ( final.scheme != "https" or final.hostname != "raw.githubusercontent.com" or final.username or final.password ): raise SubmissionError("profile_download_failed") declared_length = response.headers.get("Content-Length") if declared_length: try: if int(declared_length) > MAX_PROFILE_BYTES: raise SubmissionError("profile_too_large") except ValueError as exc: raise SubmissionError("profile_download_failed") from exc raw = response.read(MAX_PROFILE_BYTES + 1) except SubmissionError: raise except (HTTPError, URLError, TimeoutError, OSError) as exc: raise SubmissionError("profile_download_failed") from exc if len(raw) > MAX_PROFILE_BYTES: raise SubmissionError("profile_too_large") try: value = json.loads(raw.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError) as exc: raise SubmissionError("profile_json_invalid") from exc if not isinstance(value, dict): raise SubmissionError("profile_json_invalid") return value def rejected(code: str) -> dict: return { "eligible": False, "reason_code": code, "message": REASON_MESSAGES[code], } def verify_comment(body: str, *, profile_loader=download_profile) -> dict: _, inline_error = GITHUB_THREAD.extract_inline_profile(body) if inline_error: return rejected(inline_error) submissions = GITHUB_THREAD.extract_marked_comments([{"body": body}]) if len(submissions) != 1 or not submissions[0]["syntactically_eligible"]: return rejected("comment_shape") submission = submissions[0] try: if submission["profile_source"] == "inline": profile = submission["inline_profile"] else: raw_url = immutable_raw_profile_url(submission["profile_url"]) profile = profile_loader(raw_url) validation = PROFILE_VALIDATOR.validate_profile(profile) except SubmissionError as exc: return rejected(exc.code) except PROFILE_VALIDATOR.ValidationError: return rejected("profile_validation_failed") if not hmac.compare_digest( submission["canonical_profile_sha256"], validation["canonical_sha256"], ): return rejected("profile_hash_mismatch") if submission["expires_on"] != validation["expires_on"]: return rejected("profile_expiry_mismatch") return { "eligible": True, "reason_code": None, "message": ( "Schema, privacy, consent, expiry, source, and canonical hash " "checks passed." ), "profile_source": submission["profile_source"], "alias": validation["alias"], "expires_on": validation["expires_on"], "canonical_sha256": validation["canonical_sha256"], } def verify_event(event: dict, *, profile_loader=download_profile) -> dict: repository = event.get("repository") issue = event.get("issue") comment = event.get("comment") if ( not isinstance(repository, dict) or repository.get("full_name") != REPOSITORY or not isinstance(issue, dict) or issue.get("number") != ISSUE_NUMBER or not isinstance(comment, dict) or isinstance(comment.get("id"), bool) or not isinstance(comment.get("id"), int) ): return rejected("event_scope") if event.get("action") == "deleted": result = rejected("comment_deleted") result["revoked"] = True result["source_marked"] = False result.update( { "repository": REPOSITORY, "issue_number": ISSUE_NUMBER, "source_comment_id": comment["id"], } ) return result body = comment.get("body") if not isinstance(body, str): result = rejected("comment_shape") source_marked = False else: source_marked = body.startswith(f"{PROFILE_REPLY_MARKER}\n") result = verify_comment(body, profile_loader=profile_loader) result.update( { "revoked": False, "source_marked": source_marked, "repository": REPOSITORY, "issue_number": ISSUE_NUMBER, "source_comment_id": comment["id"], } ) return result def read_event(path: Path) -> dict: try: value = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: raise RuntimeError(f"Cannot read GitHub event: {exc}") from exc if not isinstance(value, dict): raise RuntimeError("GitHub event must be a JSON object") return value def write_result(path: Path, result: dict) -> None: if path.is_symlink(): raise RuntimeError(f"Refusing to write through symlink: {path}") path.parent.mkdir(parents=True, exist_ok=True) path.write_text( json.dumps(result, indent=2, ensure_ascii=False, sort_keys=True) + "\n", encoding="utf-8", ) def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--event", required=True, type=Path) parser.add_argument("--output", required=True, type=Path) return parser.parse_args() def main() -> int: args = parse_args() try: result = verify_event(read_event(args.event)) write_result(args.output, result) except RuntimeError as exc: print(f"error: {exc}", file=sys.stderr) return 2 return 0 if __name__ == "__main__": raise SystemExit(main())