#!/usr/bin/env python3
"""audit_secret_scopes.py — the CIR-02 durable fix: secret event-scope audit.

Woodpecker org/repo secrets carry an EVENT SCOPE (the set of pipeline events —
push / pull_request / tag / manual / cron / … — a secret may be injected for). A
pipeline that references a secret on an event the secret is NOT scoped for gets an
EMPTY secret on that event, so the step (e.g. the SSH clone) fails — and an
error-status pipeline runs zero steps, so nothing gates and nothing pages. That
is exactly what killed mgf-cost-watch's cron on 2026-07-13/14 (CIR-02): the clone
keys were not scoped for ``cron``.

This audit is the standing, checkable invariant. For every ACTIVATED repo on
Woodpecker (paged through ``GET /api/repos?all=true``), it:

  1. reads the repo's committed ``.woodpecker.yml`` from the local sibling
     checkout under the federation root (the drift-check pattern);
  2. extracts every referenced secret and the ``when:`` event classes each is
     referenced under (pipeline-level ``when``, narrowed by any step-level
     ``when``);
  3. reads the secret's ALLOWED events live from the Woodpecker API
     (``GET /api/orgs/{id}/secrets`` + ``GET /api/repos/{id}/secrets`` — both
     expose the ``events`` scope in v3.8.0; repo secrets shadow org secrets);
  4. flags every referenced-event ∉ allowed-events mismatch, and every secret
     referenced but defined at neither org nor repo level.

Exit status: 0 if every referenced secret is scoped for every event it is used
on; non-zero on ANY mismatch or undefined-secret reference. Run it after any
org/repo secret add / rotate / **re-scope** (the cross-family CI contract,
``docs/recipes/cross-family-ci-contract.md``), and wire it into the estate
watcher so a breaking re-scope is caught before the next scheduled run.

Environment / token: identical to ``ping_sibling_ci.py`` — ``$WOODPECKER_TOKEN``
→ ``my_stuff/woodpecker_token.txt`` fallback; the token EXPIRES (→ 401), re-mint
per MGF_INFRA.md §2 (host-side, it's ours). The value is never printed.

Usage:
  python scripts/audit_secret_scopes.py            # audit the live fleet
  python scripts/audit_secret_scopes.py --json      # machine-readable report
  python scripts/audit_secret_scopes.py --fed-root DIR  # override the checkout root
"""

from __future__ import annotations

import argparse
import json
import os
import re
import sys
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
    from collections.abc import Callable, Iterable, Sequence

DEFAULT_WP_URL = "https://195.15.241.48.sslip.io"
DEFAULT_OWNER = "magogi-admin"
PER_PAGE = 50

SECTION_KEYS = ("clone", "steps", "services")

#: ``event: push`` / ``event: [push, pull_request]`` / inline ``{ event: tag }``.
_EVENT_RE = re.compile(r"event:\s*(\[[^\]]*\]|[^,}#\n]+)")
#: ``from_secret: name`` in either block form or ``{ from_secret: name }`` flow form.
_FROM_SECRET_RE = re.compile(r"from_secret:\s*['\"]?([A-Za-z0-9_.\-]+)")
_NAME_RE = re.compile(r"(?:-\s+)?name:\s*(.+)")
_SECRETS_RE = re.compile(r"secrets:\s*(.*)")
_DASH_ITEM_RE = re.compile(r"-\s+(.+)")


# --------------------------------------------------------------------------- #
# Pipeline parsing (stdlib only — mgf-standard is a zero-dependency pure leaf,
# and its fleet scripts do not import a YAML library). A focused, indentation-
# aware reader that extracts exactly what the audit needs: per-step event scope
# + referenced secrets. Covered by unit tests against every real fleet shape.
# --------------------------------------------------------------------------- #


@dataclass(frozen=True)
class SecretRef:
    """One reference to ``secret`` in ``step``, exposed to ``events``."""

    secret: str
    step: str
    events: frozenset[str]


@dataclass(frozen=True)
class Record:
    indent: int
    stripped: str
    is_comment: bool
    raw: str


def _records(text: str) -> list[Record]:
    out: list[Record] = []
    for raw in text.splitlines():
        if not raw.strip():
            continue
        stripped = raw.lstrip(" ")
        out.append(Record(len(raw) - len(stripped), stripped, stripped.startswith("#"), raw))
    return out


def _events_in(lines: Iterable[str]) -> set[str]:
    """Every event named in ``lines`` (scalar, flow-list, or inline-mapping form)."""
    events: set[str] = set()
    for ln in lines:
        for m in _EVENT_RE.finditer(ln):
            val = m.group(1).strip()
            parts = val[1:-1].split(",") if val.startswith("[") else [val]
            for p in parts:
                tok = p.strip().strip("'\"")
                if tok:
                    events.add(tok)
    return events


def _pipeline_events(recs: list[Record]) -> frozenset[str]:
    """The top-level ``when:`` event set (empty = no top-level event constraint)."""
    for i, rec in enumerate(recs):
        if rec.indent == 0 and not rec.is_comment and rec.stripped.startswith("when:"):
            block = [rec.raw]
            for nxt in recs[i + 1 :]:
                if nxt.indent == 0:
                    break
                if not nxt.is_comment:
                    block.append(nxt.raw)
            return frozenset(_events_in(block))
    return frozenset()


def _iter_step_blocks(recs: list[Record]) -> list[list[Record]]:
    """Split ``clone:``/``steps:``/``services:`` sections into per-step record blocks."""
    blocks: list[list[Record]] = []
    i, n = 0, len(recs)
    while i < n:
        rec = recs[i]
        key = rec.stripped.split(":", 1)[0]
        if rec.indent == 0 and not rec.is_comment and key in SECTION_KEYS:
            body: list[Record] = []
            j = i + 1
            while j < n and recs[j].indent > 0:
                body.append(recs[j])
                j += 1
            blocks.extend(_split_items(body))
            i = j
        else:
            i += 1
    return blocks


def _split_items(body: list[Record]) -> list[list[Record]]:
    dash_indent = min(
        (r.indent for r in body if not r.is_comment and r.stripped.startswith("- ")),
        default=None,
    )
    if dash_indent is None:
        return []
    items: list[list[Record]] = []
    cur: list[Record] | None = None
    for rec in body:
        if rec.indent == dash_indent and not rec.is_comment and rec.stripped.startswith("- "):
            if cur is not None:
                items.append(cur)
            cur = [rec]
        elif cur is not None:
            cur.append(rec)
    if cur is not None:
        items.append(cur)
    return items


def _step_name(block: list[Record]) -> str:
    for rec in block:
        if rec.is_comment:
            continue
        m = _NAME_RE.match(rec.stripped)
        if m:
            return m.group(1).strip().strip("'\"")
    return "?"


def _step_events(block: list[Record]) -> set[str]:
    """The step's own ``when:`` event set, or empty if it does not constrain events."""
    dash_indent = block[0].indent
    when_indent = dash_indent + 2
    for k, rec in enumerate(block):
        if rec.is_comment or rec.indent != when_indent or not rec.stripped.startswith("when:"):
            continue
        inline = _events_in([rec.raw])
        if inline:
            return inline
        # block form: collect the ``when:`` sub-block, stopping at the first dedent
        # back to (or under) the ``when:`` key.
        sub: list[str] = []
        for r in block[k + 1 :]:
            if r.indent <= when_indent:
                break
            if not r.is_comment:
                sub.append(r.raw)
        return _events_in(sub)
    return set()


def _step_secrets(block: list[Record]) -> list[str]:
    secrets: list[str] = []
    for k, rec in enumerate(block):
        if rec.is_comment:
            continue
        secrets.extend(_FROM_SECRET_RE.findall(rec.raw))
        sm = _SECRETS_RE.match(rec.stripped)
        if not sm:
            continue
        rest = sm.group(1).strip()
        if rest.startswith("["):
            secrets.extend(t.strip().strip("'\"") for t in rest[1:-1].split(",") if t.strip())
        elif rest and not rest.startswith("#"):
            secrets.append(rest.strip("'\""))
        else:  # block list: following ``- name`` items, deeper than the ``secrets:`` key
            for nxt in block[k + 1 :]:
                if nxt.indent <= rec.indent:
                    break
                if nxt.is_comment:
                    continue
                dm = _DASH_ITEM_RE.match(nxt.stripped)
                if dm:
                    secrets.append(dm.group(1).strip().strip("'\""))
    return secrets


def parse_pipeline(text: str) -> tuple[frozenset[str], list[SecretRef]]:
    """Parse a ``.woodpecker.yml`` into (pipeline events, secret references).

    A secret's effective event scope is the step's own ``when:`` events if it
    constrains events, else the pipeline-level ``when:`` events.
    """
    recs = _records(text)
    pevents = _pipeline_events(recs)
    refs: list[SecretRef] = []
    for block in _iter_step_blocks(recs):
        name = _step_name(block)
        sevents = _step_events(block)
        eff = frozenset(sevents) if sevents else pevents
        for secret in _step_secrets(block):
            refs.append(SecretRef(secret=secret, step=name, events=eff))
    return pevents, refs


# --------------------------------------------------------------------------- #
# The comparison (pure).
# --------------------------------------------------------------------------- #


@dataclass
class Finding:
    secret: str
    referenced: list[str]
    kind: str  # "scope" | "undefined"
    allowed: list[str] | None = None
    bad_events: list[str] = field(default_factory=list)

    def describe(self) -> str:
        if self.kind == "undefined":
            return (
                f"secret {self.secret!r} referenced on {self.referenced} but defined at "
                "NEITHER org nor repo level (injects empty on every event)"
            )
        return (
            f"secret {self.secret!r} referenced on {sorted(self.bad_events)} but scoped only for "
            f"{self.allowed} (used on {self.referenced})"
        )


def audit_refs(refs: Sequence[SecretRef], allowed: dict[str, frozenset[str]]) -> list[Finding]:
    """Compare referenced (secret, events) against the allowed event scopes.

    ``allowed`` maps a defined secret name to its allowed event set (repo secrets
    already merged over org secrets by the caller). A secret referenced on an
    event that is UNCONSTRAINED (no top-level ``when`` and no step ``when`` — an
    empty event set) is skipped: its exposure cannot be proven, so it is not a
    finding.
    """
    aggregated: dict[str, set[str]] = {}
    for ref in refs:
        if ref.events:
            aggregated.setdefault(ref.secret, set()).update(ref.events)
        else:
            aggregated.setdefault(ref.secret, set())
    findings: list[Finding] = []
    for secret in sorted(aggregated):
        events = aggregated[secret]
        if secret not in allowed:
            findings.append(Finding(secret=secret, referenced=sorted(events), kind="undefined"))
            continue
        allow = allowed[secret]
        bad = sorted(e for e in events if e not in allow)
        if bad:
            findings.append(
                Finding(
                    secret=secret,
                    referenced=sorted(events),
                    kind="scope",
                    allowed=sorted(allow),
                    bad_events=bad,
                )
            )
    return findings


# --------------------------------------------------------------------------- #
# HTTP layer (injectable ``api`` so the unit tests run with zero network).
# --------------------------------------------------------------------------- #


def _repo_root() -> Path:
    return Path(__file__).resolve().parent.parent


def _token(explicit: str | None) -> str | None:
    if explicit:
        return explicit
    if os.environ.get("WOODPECKER_TOKEN"):
        return os.environ["WOODPECKER_TOKEN"]
    stored = _repo_root() / "my_stuff" / "woodpecker_token.txt"
    if stored.is_file():
        return stored.read_text(encoding="utf-8").strip()
    return None


def _api(url: str, token: str, method: str = "GET", payload: dict | None = None) -> Any:
    data = json.dumps(payload).encode() if payload is not None else None
    req = urllib.request.Request(
        url,
        data=data,
        method=method,
        headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
    )
    with urllib.request.urlopen(req, timeout=30) as resp:  # fixed https host
        body = resp.read().decode()
        return json.loads(body) if body.strip() else {}


def fetch_activated_repos(
    base: str, token: str, api: Callable[..., Any], owner: str | None = None
) -> list[dict]:
    """Page through ``GET /api/repos?all=true`` until empty; keep the active repos.

    The fleet is >50 repos, so pagination is mandatory — a single page silently
    drops the tail (the exact false-alarm §8 of the review warns about).
    """
    collected: list[dict] = []
    page = 1
    while True:
        batch = api(f"{base}/api/repos?all=true&page={page}&perPage={PER_PAGE}", token)
        if not batch:
            break
        collected.extend(batch)
        page += 1
    repos = [r for r in collected if r.get("active")]
    if owner:
        repos = [r for r in repos if str(r.get("full_name", "")).split("/")[0] == owner]
    return repos


def _secret_scopes(raw: Any) -> dict[str, frozenset[str]]:
    return {s["name"]: frozenset(s.get("events") or []) for s in (raw or [])}


def fetch_org_secrets(
    base: str, org_id: int, token: str, api: Callable[..., Any]
) -> dict[str, frozenset[str]]:
    return _secret_scopes(api(f"{base}/api/orgs/{org_id}/secrets", token))


def fetch_repo_secrets(
    base: str, repo_id: int, token: str, api: Callable[..., Any]
) -> dict[str, frozenset[str]]:
    return _secret_scopes(api(f"{base}/api/repos/{repo_id}/secrets", token))


# --------------------------------------------------------------------------- #
# Orchestration + reporting.
# --------------------------------------------------------------------------- #


@dataclass
class RepoResult:
    name: str
    repo_id: int
    findings: list[Finding] = field(default_factory=list)
    n_refs: int = 0
    skipped: str = ""  # non-empty => audited nothing, with the reason


def audit_fleet(
    base: str,
    owner: str,
    token: str,
    fed_root: Path,
    api: Callable[..., Any] = _api,
) -> list[RepoResult]:
    org = api(f"{base}/api/orgs/lookup/{owner}", token)
    org_secrets = fetch_org_secrets(base, int(org["id"]), token, api)
    repos = fetch_activated_repos(base, token, api, owner=owner)
    results: list[RepoResult] = []
    for repo in sorted(repos, key=lambda r: str(r.get("full_name", ""))):
        name = str(repo.get("full_name", "")).split("/")[-1]
        repo_id = int(repo["id"])
        wp = fed_root / name / ".woodpecker.yml"
        if not wp.is_file():
            results.append(RepoResult(name, repo_id, skipped="no local .woodpecker.yml"))
            continue
        _, refs = parse_pipeline(wp.read_text(encoding="utf-8"))
        allowed = {**org_secrets, **fetch_repo_secrets(base, repo_id, token, api)}
        results.append(
            RepoResult(name, repo_id, findings=audit_refs(refs, allowed), n_refs=len(refs))
        )
    return results


def _print_report(results: list[RepoResult]) -> int:
    flagged = [r for r in results if r.findings]
    skipped = [r for r in results if r.skipped]
    audited = [r for r in results if not r.skipped]
    print(f"Secret event-scope audit — {len(audited)} repo(s) audited, {len(skipped)} skipped\n")
    for r in sorted(audited, key=lambda r: r.name):
        if r.findings:
            print(f"  ✗ {r.name} ({r.n_refs} secret ref(s))")
            for f in r.findings:
                print(f"      - {f.describe()}")
        else:
            print(f"  ✓ {r.name} ({r.n_refs} secret ref(s))")
    if skipped:
        print("\n  skipped:")
        for r in sorted(skipped, key=lambda r: r.name):
            print(f"    - {r.name}: {r.skipped}")
    n_findings = sum(len(r.findings) for r in flagged)
    print(
        f"\n{len(flagged)} repo(s) with findings; {n_findings} mismatch(es)/undefined total."
        if flagged
        else "\nClean — every referenced secret is scoped for every event it is used on."
    )
    return 1 if flagged else 0


def _json_report(results: list[RepoResult]) -> dict:
    return {
        "repos": [
            {
                "name": r.name,
                "repo_id": r.repo_id,
                "n_refs": r.n_refs,
                "skipped": r.skipped or None,
                "findings": [
                    {
                        "secret": f.secret,
                        "kind": f.kind,
                        "referenced": f.referenced,
                        "allowed": f.allowed,
                        "bad_events": f.bad_events,
                    }
                    for f in r.findings
                ],
            }
            for r in results
        ],
        "clean": not any(r.findings for r in results),
    }


def main(argv: Sequence[str] | None = None) -> int:
    ap = argparse.ArgumentParser(
        description="CIR-02 secret/event-scope audit: referenced secrets vs Woodpecker scopes.",
    )
    ap.add_argument("--owner", default=DEFAULT_OWNER, help=f"forge owner (default {DEFAULT_OWNER})")
    ap.add_argument(
        "--url",
        default=os.environ.get("WOODPECKER_URL", DEFAULT_WP_URL),
        help="Woodpecker base URL",
    )
    ap.add_argument(
        "--fed-root",
        type=Path,
        default=None,
        help="federation checkout root (default: the parent of mgf-standard)",
    )
    ap.add_argument("--token", default=None, help="WP API token (else env / my_stuff file)")
    ap.add_argument("--json", action="store_true", help="emit a machine-readable JSON report")
    args = ap.parse_args(argv)

    token = _token(args.token)
    if not token:
        sys.exit(
            "audit_secret_scopes: no token — set $WOODPECKER_TOKEN or "
            "my_stuff/woodpecker_token.txt (mint per MGF_INFRA §2)."
        )
    fed_root = args.fed_root or _repo_root().parent

    try:
        results = audit_fleet(args.url, args.owner, token, fed_root)
    except urllib.error.HTTPError as e:
        if e.code == 401:
            sys.exit("audit_secret_scopes: 401 — token expired; mint per MGF_INFRA §2.")
        sys.exit(f"audit_secret_scopes: Woodpecker API HTTP {e.code}")
    except urllib.error.URLError as e:
        sys.exit(f"audit_secret_scopes: Woodpecker unreachable ({e.reason})")

    if args.json:
        print(json.dumps(_json_report(results), indent=2, sort_keys=True))
        return 1 if any(r.findings for r in results) else 0
    return _print_report(results)


if __name__ == "__main__":
    raise SystemExit(main())
