diff --git a/.github/actions/hostname-scan/action.yml b/.github/actions/hostname-scan/action.yml new file mode 100644 index 0000000..63c7c4f --- /dev/null +++ b/.github/actions/hostname-scan/action.yml @@ -0,0 +1,59 @@ +name: hostname-scan +description: > + Fail when a change adds a private-network hostname or address (mDNS/LAN + names, tailnet names, RFC 1918 / CGNAT / link-local IPv4, ULA / link-local + IPv6, or a name from the run-time denylist). Findings are redacted in the log. + Requires a checkout with full history (fetch-depth: 0). + +inputs: + mode: + description: "diff (default: lines added by this PR / queue entry / push), all, or history" + required: false + default: diff + allowlist: + description: Repo-relative allowlist file (path: / value: lines) + required: false + default: .github/hostname-scan-allow.txt + denylist: + description: > + Newline-separated regular expressions for names that must never appear + (pass an org secret; never commit real names). + required: false + default: "" + +runs: + using: composite + steps: + - name: Scan for private hostnames and addresses + shell: bash + env: + MODE: ${{ inputs.mode }} + ALLOWLIST: ${{ inputs.allowlist }} + HOSTNAME_SCAN_DENYLIST: ${{ inputs.denylist }} + EVENT: ${{ github.event_name }} + PR_BASE: ${{ github.event.pull_request.base.sha }} + PR_HEAD: ${{ github.event.pull_request.head.sha }} + MQ_BASE: ${{ github.event.merge_group.base_sha }} + MQ_HEAD: ${{ github.event.merge_group.head_sha }} + PUSH_BEFORE: ${{ github.event.before }} + PUSH_AFTER: ${{ github.event.after }} + SCANNER: ${{ github.action_path }}/hostname_scan.py + run: | + set -euo pipefail + case "$MODE" in + all) exec python3 "$SCANNER" --all --allowlist "$ALLOWLIST" ;; + history) exec python3 "$SCANNER" --history --allowlist "$ALLOWLIST" ;; + diff) ;; + *) echo "::error::hostname-scan: unknown mode '$MODE'"; exit 2 ;; + esac + case "$EVENT" in + pull_request|pull_request_target) base=$PR_BASE head=$PR_HEAD ;; + merge_group) base=$MQ_BASE head=$MQ_HEAD ;; + push) base=$PUSH_BEFORE head=$PUSH_AFTER ;; + *) base="" head="" ;; + esac + # A new branch push has no "before"; scan the whole tree instead. + if [ -z "$base" ] || [ "$base" = 0000000000000000000000000000000000000000 ]; then + exec python3 "$SCANNER" --all --allowlist "$ALLOWLIST" + fi + exec python3 "$SCANNER" --diff "$base" "$head" --allowlist "$ALLOWLIST" diff --git a/.github/actions/hostname-scan/hostname_scan.py b/.github/actions/hostname-scan/hostname_scan.py new file mode 100644 index 0000000..3fbd070 --- /dev/null +++ b/.github/actions/hostname-scan/hostname_scan.py @@ -0,0 +1,315 @@ +#!/usr/bin/env python3 +"""hostname-scan: refuse private-network hostnames and addresses in a repo. + +A public repo must not carry the shape of a private network: mDNS/LAN host +names (``*.local``, ``*.lan``, ``*.internal``, ``*.home.arpa``, +consumer-router domains such as ``*.fritz.box`` ...), tailnet names +(``*.ts.net``), RFC 1918 / CGNAT / link-local IPv4, and ULA / link-local +IPv6. Real home-network names are NOT listed here -- they would leak the very +thing this check exists to keep out. Supply them at run time through the +``HOSTNAME_SCAN_DENYLIST`` environment variable (newline-separated regular +expressions, typically from an org secret). + +Modes (exactly one): + --diff BASE HEAD scan lines ADDED between BASE and HEAD (PR / merge queue) + --all scan every tracked file at HEAD + --history scan every line ever added on any ref; prints counts per + category only, never values (for a make-public review) + +Exit status: 0 clean, 1 findings, 2 usage or git error. + +Findings are printed as GitHub annotations with the value REDACTED, because +CI logs of a public repo are public. + +Allowlist (default ``.github/hostname-scan-allow.txt``), one entry per line: + path: skip files matching the fnmatch glob + value: ignore findings whose value fully matches (case-insensitive) + # comment / blank lines ignored +A line containing ``hostname-scan: allow`` is skipped (inline pragma). + +Compatible with Python 3.9 (stdlib only). +""" + +import argparse +import fnmatch +import ipaddress +import os +import re +import subprocess +import sys +from collections import Counter +from typing import Iterable, Iterator, List, NamedTuple, Optional, Pattern, Tuple + +DEFAULT_ALLOWLIST = ".github/hostname-scan-allow.txt" +PRAGMA = "hostname-scan: allow" + +# Private / special-use names. ``home.arpa`` is RFC 8375, ``internal`` is the +# ICANN-reserved private TLD, ``local`` is mDNS (RFC 6762). +PRIVATE_SUFFIXES = ( + "local", + "lan", + "home", + "internal", + "intranet", + "localdomain", + "home.arpa", + "ts.net", + "fritz.box", + "speedport.ip", +) + +_LABEL = r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?" +HOST_RE = re.compile( + r"(? None: + self.path_globs = path_globs + self.value_res = value_res + + @classmethod + def load(cls, disk_path: Optional[str], rel_path: Optional[str] = None) -> "Allowlist": + """Read the allowlist at ``disk_path``; ``rel_path`` (its repo-relative + name) is always skipped, since the allowlist names what it allows.""" + globs = [] # type: List[str] + values = [] # type: List[Pattern] + if disk_path and os.path.isfile(disk_path): + with open(disk_path, encoding="utf-8") as fh: + for raw in fh: + entry = raw.strip() + if not entry or entry.startswith("#"): + continue + kind, _, rest = entry.partition(":") + rest = rest.strip() + if kind == "path" and rest: + globs.append(rest) + elif kind == "value" and rest: + values.append(re.compile(rest, re.IGNORECASE)) + else: + raise ValueError("bad allowlist entry: %r" % entry) + if rel_path: + globs.append(rel_path) + return cls(globs, values) + + def skips_path(self, path: str) -> bool: + return any(fnmatch.fnmatch(path, g) for g in self.path_globs) + + def allows_value(self, value: str) -> bool: + return any(r.fullmatch(value) for r in self.value_res) + + +def compile_denylist(text: str) -> List[Pattern]: + return [re.compile(p.strip(), re.IGNORECASE) for p in text.splitlines() if p.strip()] + + +def scan_line(line: str, denylist: Iterable[Pattern] = ()) -> List[Tuple[str, str]]: + """Return (category, value) pairs for one line of text.""" + if PRAGMA in line: + return [] + hits = [] # type: List[Tuple[str, str]] + + for m in HOST_RE.finditer(line): + host = m.group(1) + first = host.split(".", 1)[0].lower() + if first in CODE_RECEIVERS: + continue + if line[m.end():m.end() + 1] == "(": + continue + hits.append(("private-hostname", host)) + + for m in IPV4_RE.finditer(line): + try: + addr = ipaddress.ip_address(m.group(1)) + except ValueError: + continue + for category, net in IPV4_NETS: + if addr in net: + hits.append((category, m.group(1))) + break + + for m in IPV6_CANDIDATE_RE.finditer(line): + token = m.group(1).rstrip(":") + if token.count(":") < 2 and "::" not in m.group(1): + continue + for candidate in (m.group(1), token, token + "::"): + try: + addr6 = ipaddress.ip_address(candidate) + except ValueError: + continue + for category, net in IPV6_NETS: + if addr6 in net: + hits.append((category, m.group(1))) + break + break + + for pattern in denylist: + for m in pattern.finditer(line): + hits.append(("denylisted-name", m.group(0))) + + return hits + + +def redact(category: str, value: str) -> str: + """Show the shape of a finding without publishing it (CI logs are public).""" + if category == "denylisted-name": + return "[denylisted]" + if category.endswith("ipv6"): + return value.split(":", 1)[0] + ":…" + if category.endswith("ipv4"): + return ".".join(value.split(".")[:2] + ["x", "x"]) + lowered = value.lower() + for suffix in sorted(PRIVATE_SUFFIXES, key=len, reverse=True): + if lowered.endswith("." + suffix): + return value[:1] + "***." + suffix + return value[:1] + "***" + + +def git(args: List[str], cwd: str) -> str: + proc = subprocess.run( + ["git"] + args, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + universal_newlines=True, errors="replace", + ) + if proc.returncode != 0: + raise RuntimeError("git %s failed: %s" % (" ".join(args), proc.stderr.strip())) + return proc.stdout + + +HUNK_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@") + + +def added_lines(diff_text: str) -> Iterator[Tuple[str, int, str]]: + """Yield (path, line_no, text) for each '+' line of a unified diff.""" + path = None # type: Optional[str] + line_no = 0 + for raw in diff_text.splitlines(): + if raw.startswith("+++ "): + target = raw[4:] + path = None if target == "/dev/null" else target[2:] if target.startswith("b/") else target + continue + if raw.startswith("--- ") or raw.startswith("diff --git"): + continue + hunk = HUNK_RE.match(raw) + if hunk: + line_no = int(hunk.group(1)) + continue + if path is None: + continue + if raw.startswith("+"): + yield path, line_no, raw[1:] + line_no += 1 + elif raw.startswith(" "): + line_no += 1 + + +def scan_pairs(pairs: Iterable[Tuple[str, int, str]], allow: Allowlist, + denylist: Iterable[Pattern]) -> List[Finding]: + deny = list(denylist) + out = [] # type: List[Finding] + for path, line_no, text in pairs: + if allow.skips_path(path): + continue + for category, value in scan_line(text, deny): + if not allow.allows_value(value): + out.append(Finding(path, line_no, category, value)) + return out + + +def tracked_lines(root: str) -> Iterator[Tuple[str, int, str]]: + for path in git(["ls-files", "-z"], root).split("\0"): + if not path: + continue + full = os.path.join(root, path) + if not os.path.isfile(full): + continue + with open(full, "rb") as fh: + data = fh.read() + if b"\0" in data[:8192]: + continue # binary + for i, text in enumerate(data.decode("utf-8", "replace").splitlines(), 1): + yield path, i, text + + +def history_added_lines(root: str) -> Iterator[Tuple[str, int, str]]: + log = git(["log", "--all", "-p", "--no-color", "--no-ext-diff", "-U0", "--format=commit %H"], root) + return added_lines(log) + + +def emit(findings: List[Finding], out=None) -> None: + out = out or sys.stdout + for f in findings: + out.write("::error file=%s,line=%d,title=hostname-scan::%s %s\n" + % (f.path, f.line, f.category, redact(f.category, f.value))) + + +def main(argv: Optional[List[str]] = None) -> int: + ap = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0]) + mode = ap.add_mutually_exclusive_group(required=True) + mode.add_argument("--diff", nargs=2, metavar=("BASE", "HEAD")) + mode.add_argument("--all", action="store_true") + mode.add_argument("--history", action="store_true") + ap.add_argument("--root", default=".") + ap.add_argument("--allowlist", default=DEFAULT_ALLOWLIST) + args = ap.parse_args(argv) + + try: + allow = Allowlist.load(os.path.join(args.root, args.allowlist), args.allowlist) + deny = compile_denylist(os.environ.get("HOSTNAME_SCAN_DENYLIST", "")) + if args.diff: + base, head = args.diff + text = git(["diff", "--no-color", "--no-ext-diff", "-U0", "--diff-filter=AMRC", + "%s...%s" % (base, head)], args.root) + findings = scan_pairs(added_lines(text), allow, deny) + elif args.all: + findings = scan_pairs(tracked_lines(args.root), allow, deny) + else: + findings = scan_pairs(history_added_lines(args.root), allow, deny) + except (RuntimeError, ValueError, re.error) as exc: + sys.stderr.write("hostname-scan: %s\n" % exc) + return 2 + + if args.history: + counts = Counter(f.category for f in findings) + files = len({f.path for f in findings}) + for category, n in sorted(counts.items()): + print("%-18s %d" % (category, n)) + print("total %d finding(s) in %d path(s) across history" % (len(findings), files)) + else: + emit(findings) + print("hostname-scan: %d finding(s)" % len(findings)) + return 1 if findings else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/actions/hostname-scan/test_hostname_scan.py b/.github/actions/hostname-scan/test_hostname_scan.py new file mode 100644 index 0000000..1c397b2 --- /dev/null +++ b/.github/actions/hostname-scan/test_hostname_scan.py @@ -0,0 +1,225 @@ +"""Tests for hostname_scan.py (stdlib unittest, Python 3.9+). + +Every private-looking fixture is assembled at run time (``_ip``, ``_host``) so +this file's own diff passes the scanner it tests -- the repo dogfoods the check. +""" + +import io +import os +import subprocess +import sys +import tempfile +import unittest +from contextlib import redirect_stdout + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import hostname_scan as hs # noqa: E402 + + +def _ip(*parts): + return ".".join(str(p) for p in parts) + + +def _host(*labels): + return ".".join(labels) + + +def _v6(*hextets): + return ":".join(hextets) + + +def cats(line, deny=()): + return [c for c, _ in hs.scan_line(line, deny)] + + +class ScanLineDetects(unittest.TestCase): + def test_rfc1918(self): + for addr in (_ip(10, 0, 0, 7), _ip(172, 20, 1, 1), _ip(192, 168, 1, 23)): + self.assertEqual(cats("host = %s:8080" % addr), ["rfc1918-ipv4"], addr) + + def test_cgnat_and_link_local(self): + self.assertEqual(cats("peer " + _ip(100, 101, 2, 3)), ["cgnat-ipv4"]) + self.assertEqual(cats("ll " + _ip(169, 254, 10, 1)), ["link-local-ipv4"]) + + def test_private_suffixes(self): + for suffix in ("local", "lan", "internal", "home.arpa", "ts.net", "localdomain", "fritz.box"): + name = _host("nas", suffix) + self.assertEqual(cats("ssh %s/share" % name), ["private-hostname"], name) + + def test_ipv6_ula_and_link_local(self): + self.assertEqual(cats("addr " + _v6("fd12", "3456", "789a", "", "1")), ["ula-ipv6"]) + self.assertEqual(cats("addr " + _v6("fe80", "", "1")), ["link-local-ipv6"]) + self.assertEqual(cats("prefix %s/64" % _v6("fd00", "ab", "", "")), ["ula-ipv6"]) + + def test_denylist(self): + deny = hs.compile_denylist("secret-box\n\n") + self.assertEqual(cats("ping secret-box now", deny), ["denylisted-name"]) + + +class ScanLineNegativeControls(unittest.TestCase): + """Values that look similar but are not private infrastructure.""" + + def test_documentation_and_public_ipv4(self): + for addr in (_ip(192, 0, 2, 10), _ip(198, 51, 100, 4), _ip(203, 0, 113, 9), + _ip(8, 8, 8, 8), _ip(127, 0, 0, 1), _ip(172, 32, 0, 1)): + self.assertEqual(cats("addr " + addr), [], addr) + + def test_version_strings(self): + self.assertEqual(cats("v" + _ip(10, 0, 0, 1, 2)), []) + self.assertEqual(cats("bump to " + _ip(1, 10, 0, 1)), []) + + def test_filenames_and_code(self): + for line in ("cp config.local.yml x", "x = self.local", "tls = threading.local()", + "see example.com", "obj.home(", "my.localhost.example"): + self.assertEqual(cats(line), [], line) + + def test_public_and_loopback_ipv6(self): + for line in ("addr " + _v6("2001", "db8", "", "1"), "lo ::1", "time 12:30:45"): + self.assertEqual(cats(line), [], line) + + def test_pragma(self): + self.assertEqual(cats("%s # hostname-scan: allow" % _ip(10, 1, 1, 1)), []) + + +class Redaction(unittest.TestCase): + def test_never_prints_full_value(self): + cases = [("rfc1918-ipv4", _ip(192, 168, 7, 42)), + ("private-hostname", _host("printer", "lan")), + ("ula-ipv6", _v6("fd12", "3456", "", "9")), + ("denylisted-name", "secret-box")] + for category, value in cases: + out = hs.redact(category, value) + self.assertNotIn(value, out) + self.assertEqual(hs.redact("private-hostname", _host("printer", "lan")), "p***.lan") + self.assertEqual(hs.redact("rfc1918-ipv4", _ip(192, 168, 7, 42)), "192.168.x.x") + + +class DiffParsing(unittest.TestCase): + def test_added_lines_tracks_numbers(self): + diff = "\n".join([ + "diff --git a/f.txt b/f.txt", + "--- a/f.txt", + "+++ b/f.txt", + "@@ -1,0 +5,2 @@", + "+one", + "+two", + "@@ -9 +20 @@", + "-gone", + "+three", + ]) + self.assertEqual(list(hs.added_lines(diff)), + [("f.txt", 5, "one"), ("f.txt", 6, "two"), ("f.txt", 20, "three")]) + + def test_deleted_file_is_ignored(self): + diff = "--- a/x\n+++ /dev/null\n@@ -1 +0,0 @@\n-" + _ip(10, 0, 0, 1) + self.assertEqual(list(hs.added_lines(diff)), []) + + +class EndToEnd(unittest.TestCase): + """Drive main() against a throwaway git repo.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = self.tmp.name + self._git("init", "-q", "-b", "main") + self._git("config", "user.email", "t@example.com") + self._git("config", "user.name", "t") + self._git("config", "commit.gpgsign", "false") + self._write("README.md", "hello\n") + self._commit("base") + self.base = self._git("rev-parse", "HEAD").strip() + + def tearDown(self): + self.tmp.cleanup() + + def _git(self, *args): + return subprocess.run(["git"] + list(args), cwd=self.root, check=True, + stdout=subprocess.PIPE, universal_newlines=True).stdout + + def _write(self, rel, text): + full = os.path.join(self.root, rel) + os.makedirs(os.path.dirname(full), exist_ok=True) + with open(full, "w", encoding="utf-8") as fh: + fh.write(text) + + def _commit(self, msg): + self._git("add", "-A") + self._git("commit", "-q", "-m", msg) + + def _run(self, *argv): + buf = io.StringIO() + with redirect_stdout(buf): + code = hs.main(list(argv) + ["--root", self.root]) + return code, buf.getvalue() + + def test_clean_diff_passes(self): + self._write("doc.md", "see %s\n" % _ip(192, 0, 2, 1)) + self._commit("doc") + code, out = self._run("--diff", self.base, "HEAD") + self.assertEqual(code, 0, out) + + def test_dirty_diff_fails_with_redacted_annotation(self): + name = _host("nas", "lan") + self._write("deploy/hosts.txt", "a\n%s\n" % name) + self._commit("leak") + code, out = self._run("--diff", self.base, "HEAD") + self.assertEqual(code, 1) + self.assertIn("::error file=deploy/hosts.txt,line=2,", out) + self.assertNotIn(name, out) + + def test_preexisting_value_not_flagged_by_diff(self): + self._write("old.txt", _ip(10, 9, 9, 9) + "\n") + self._commit("old") + mid = self._git("rev-parse", "HEAD").strip() + self._write("new.txt", "fine\n") + self._commit("new") + self.assertEqual(self._run("--diff", mid, "HEAD")[0], 0) + self.assertEqual(self._run("--all")[0], 1) + + def test_allowlist_path_and_value(self): + self._write("fixtures/a.txt", _ip(10, 0, 0, 1) + "\n") + self._write("b.txt", "metadata %s\n" % _host("metadata", "google", "internal")) + self._write(hs.DEFAULT_ALLOWLIST, + "# fixtures are synthetic\npath:fixtures/*\nvalue:metadata\\.google\\.internal\n") + self._commit("allow") + code, out = self._run("--diff", self.base, "HEAD") + self.assertEqual(code, 0, out) + + def test_bad_allowlist_is_usage_error(self): + self._write(hs.DEFAULT_ALLOWLIST, "nonsense\n") + self._commit("bad") + self.assertEqual(self._run("--all")[0], 2) + + def test_history_reports_counts_only(self): + name = _host("box", "local") + self._write("x.txt", name + "\n") + self._commit("leak") + self._write("x.txt", "removed\n") + self._commit("fix") + code, out = self._run("--history") + self.assertEqual(code, 1) + self.assertIn("private-hostname", out) + self.assertNotIn(name, out) + self.assertEqual(self._run("--all")[0], 0) + + def test_denylist_from_env(self): + self._write("n.txt", "connect to secret-box\n") + self._commit("n") + old = os.environ.get("HOSTNAME_SCAN_DENYLIST") + os.environ["HOSTNAME_SCAN_DENYLIST"] = "secret-box" + try: + code, out = self._run("--diff", self.base, "HEAD") + finally: + if old is None: + del os.environ["HOSTNAME_SCAN_DENYLIST"] + else: + os.environ["HOSTNAME_SCAN_DENYLIST"] = old + self.assertEqual(code, 1) + self.assertNotIn("secret-box", out) + + def test_bad_ref_is_usage_error(self): + self.assertEqual(self._run("--diff", "nope", "HEAD")[0], 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9177225..db8d8bb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,24 @@ jobs: node-version: '20' cache: 'npm' - run: npm ci - - run: npx eslint src/ index.js functions.js __tests__/ + - run: npx eslint src/ index.js functions.js __tests__/ scripts/ + + # The org hostname scanner temper distributes (rule 4): its own tests, the + # Python 3.9 floor checked mechanically, then the action run on this repo's + # own diff — temper is the first repo the check gates. + hostname-scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Scanner self-test + run: python3 -m unittest discover -s .github/actions/hostname-scan -p 'test_*.py' -v + - name: Python 3.9 floor + run: pipx run --spec vermin==1.9.2 vermin -t=3.9- --no-tips --violations .github/actions/hostname-scan/ + - name: Scan this change + uses: ./.github/actions/hostname-scan ci: runs-on: ubuntu-latest diff --git a/README.md b/README.md index 7fd0459..498f306 100644 --- a/README.md +++ b/README.md @@ -129,9 +129,19 @@ All behavior is controlled by [`config.yml`](config.yml). Key sections: | `templates` / `codeowners` | PR/issue templates and CODEOWNERS paths | | `ai_review` | AI review endpoint, model, prompt, and limits | | `auto_merge` | Auto-merge rules for Dependabot and bot PRs | +| `standards` | The org floor: binding required checks, no admin bypass, signed commits, update-branch settings, release-tag rulesets, required workflows (hostname-scan), per-repo check overrides | See the file itself for the full schema and defaults. +### Dry-run before any sync + +`node scripts/cli.js plan` reports, per repo, the drift from `standards` and +the exact writes a sync would send — and sends none of them. It runs temper's +real apply code against an octokit that passes reads through (throttled to +1 req/s, `--cache DIR` to persist them) and records every write. A plain +token is enough: `GITHUB_TOKEN=$(gh auth token) node scripts/cli.js plan --repo kiln`. +`node scripts/cli.js audit --snapshots DIR` evaluates a saved sweep offline. + ## Development See [DEVELOPMENT.md](DEVELOPMENT.md) for the full development guide. diff --git a/__tests__/integration/branch-protection.test.js b/__tests__/integration/branch-protection.test.js index 0d393c3..bc7d719 100644 --- a/__tests__/integration/branch-protection.test.js +++ b/__tests__/integration/branch-protection.test.js @@ -49,8 +49,13 @@ describe('branch-protection', () => { enforce_admins: true }); - // Only the PUT call should have been made, no POST/DELETE for signatures - expect(octokit.request).toHaveBeenCalledTimes(1); + // A GET of the existing protection (to keep its contexts) and the PUT; + // no POST/DELETE for signatures. + const routes = octokit.request.mock.calls.map((c) => c[0]); + expect(routes).toEqual([ + 'GET /repos/{owner}/{repo}/branches/{branch}/protection', + 'PUT /repos/{owner}/{repo}/branches/{branch}/protection' + ]); expect(octokit.request).toHaveBeenCalledWith( 'PUT /repos/{owner}/{repo}/branches/{branch}/protection', expect.anything() @@ -100,3 +105,35 @@ describe('branch-protection', () => { }); }); }); + +describe('applyBranchProtection keeps per-repo contexts', () => { + it('unions the contexts the branch already requires into the PUT (never wipes them)', async () => { + const octokit = { + request: jest.fn().mockImplementation((route) => { + if (route.startsWith('GET ')) { + return Promise.resolve({ + data: { required_status_checks: { contexts: ['Format', 'Test'], checks: [{ context: 'Miri' }] } } + }); + } + return Promise.resolve({ status: 200, data: {} }); + }) + }; + await applyBranchProtection(octokit, 'o', 'r', 'main', { + required_status_checks: { strict: true, contexts: [] } + }); + const put = octokit.request.mock.calls.find((c) => c[0].startsWith('PUT ')); + expect(put[1].required_status_checks.contexts.sort()).toEqual(['Format', 'Miri', 'Test']); + }); + + it('treats an unprotected branch (404) as no existing contexts', async () => { + const octokit = { + request: jest.fn().mockImplementation((route) => { + if (route.startsWith('GET ')) return Promise.reject(Object.assign(new Error('nf'), { status: 404 })); + return Promise.resolve({ status: 200, data: {} }); + }) + }; + await applyBranchProtection(octokit, 'o', 'r', 'main', { required_status_checks: { contexts: ['ci'] } }); + const put = octokit.request.mock.calls.find((c) => c[0].startsWith('PUT ')); + expect(put[1].required_status_checks.contexts).toEqual(['ci']); + }); +}); diff --git a/__tests__/integration/plan.test.js b/__tests__/integration/plan.test.js new file mode 100644 index 0000000..e3ff0ab --- /dev/null +++ b/__tests__/integration/plan.test.js @@ -0,0 +1,103 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { planOrg, auditSnapshots, renderPlan } from '../../src/plan.js'; +import { createDryRunOctokit } from '../../src/dry-run.js'; +import { _setConfigForTesting, getConfig } from '../../src/config.js'; + +const notFound = () => Object.assign(new Error('Not Found'), { status: 404 }); + +/** A read-only fake GitHub: one org with one bare repo and one fork. */ +function fakeGitHub() { + const repos = [ + { name: 'bare', full_name: 'o/bare', owner: { login: 'o' }, default_branch: 'main', fork: false, archived: false, + visibility: 'public', private: false, allow_merge_commit: true, allow_squash_merge: true, + allow_rebase_merge: false, allow_auto_merge: true, allow_update_branch: true }, + { name: 'up', full_name: 'o/up', owner: { login: 'o' }, default_branch: 'main', fork: true, archived: false, + visibility: 'public' }, + { name: 'gone', full_name: 'o/gone', owner: { login: 'o' }, archived: true } + ]; + return { + request: jest.fn().mockImplementation(async (route, p) => { + if (!route.startsWith('GET ')) throw new Error(`real octokit received a write: ${route}`); + if (route === 'GET /orgs/{org}/repos') return { status: 200, data: p.page === 1 ? repos : [] }; + if (route === 'GET /repos/{owner}/{repo}') return { status: 200, data: repos.find((r) => r.name === p.repo) }; + if (route.endsWith('/rulesets') || route.endsWith('/labels') || route.endsWith('/pulls')) return { status: 200, data: [] }; + if (route.includes('/rules/branches/')) return { status: 200, data: [] }; + throw notFound(); + }) + }; +} + +describe('planOrg (dry-run)', () => { + let saved; + beforeAll(() => { saved = getConfig(); }); + afterAll(() => { _setConfigForTesting(saved); }); + + it('plans the writes a sync would make, per repo, and sends none of them', async () => { + const real = fakeGitHub(); + const dry = createDryRunOctokit(real, { minIntervalMs: 0 }); + const plan = await planOrg(real, 'o', { dryOctokit: dry }); + + expect(plan.results.map((r) => r.repo)).toEqual(['bare', 'up']); // archived skipped, like sync + for (const [route] of real.request.mock.calls) expect(route.startsWith('GET ')).toBe(true); + + const bare = plan.results.find((r) => r.repo === 'bare'); + expect(bare.drift).toBe(true); + const urls = bare.writes.map((w) => `${w.method} ${w.url}`); + expect(urls).toContain('PATCH /repos/o/bare'); + expect(bare.writes.find((w) => w.url === '/repos/o/bare' && w.method === 'PATCH').body.allow_update_branch).toBe(false); + const rulesets = bare.writes.filter((w) => w.url === '/repos/o/bare/rulesets').map((w) => w.body.name); + expect(rulesets).toEqual(['temper-default-branch-protection', 'temper-release-tags-immutable', 'temper-release-tags-create']); + expect(bare.findings.find((f) => f.rule === 'required_checks')).toMatchObject({ drift: true, fix: 'owner' }); + + const fork = plan.results.find((r) => r.repo === 'up'); + expect(fork.scope).toBe('fork'); + // Forks: merge settings / labels only, never a branch or tag ruleset. + expect(fork.writes.filter((w) => w.url.includes('/rulesets') || w.url.includes('/protection'))).toEqual([]); + + const text = renderPlan(plan.results); + expect(text).toMatch(/## bare — enforced, DRIFT/); + expect(text).toMatch(/-> PATCH \/repos\/o\/bare/); + }); + + it('restricts to --repo names', async () => { + const real = fakeGitHub(); + const plan = await planOrg(real, 'o', { dryOctokit: createDryRunOctokit(real, { minIntervalMs: 0 }), repos: ['up'] }); + expect(plan.results.map((r) => r.repo)).toEqual(['up']); + }); + + it('with standards disabled, the plan falls back to the legacy enforcement path', async () => { + const cfg = structuredClone(saved); + cfg.standards.enabled = false; + _setConfigForTesting(cfg); + try { + const real = fakeGitHub(); + const plan = await planOrg(real, 'o', { dryOctokit: createDryRunOctokit(real, { minIntervalMs: 0 }), repos: ['bare'] }); + const names = plan.results[0].writes.filter((w) => w.url.endsWith('/rulesets')).map((w) => w.body.name); + expect(names).toEqual(['temper-default-branch-protection']); + } finally { + _setConfigForTesting(saved); + } + }); +}); + +describe('auditSnapshots (offline)', () => { + it('evaluates every repo dir in a sweep cache', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'temper-audit-')); + const d = path.join(root, 'solo'); + fs.mkdirSync(d); + const w = (f, v) => fs.writeFileSync(path.join(d, f), JSON.stringify(v)); + w('repo.json', { status: 200, body: { name: 'solo', default_branch: 'main', allow_update_branch: false, + allow_merge_commit: true, allow_squash_merge: true, allow_rebase_merge: false, allow_auto_merge: true } }); + w('protection.json', { status: 404, body: { message: 'Branch not protected' } }); + w('branch_rules.json', { status: 200, body: [] }); + w('rulesets_list.json', { status: 200, body: [] }); + w('workflows.json', { status: 404, body: { message: 'Not Found' } }); + const out = auditSnapshots(root); + expect(out).toHaveLength(1); + expect(out[0]).toMatchObject({ repo: 'solo', scope: 'enforced', drift: true }); + expect(auditSnapshots(root, { repos: ['other'] })).toEqual([]); + fs.rmSync(root, { recursive: true, force: true }); + }); +}); diff --git a/__tests__/integration/rulesets.test.js b/__tests__/integration/rulesets.test.js index 2a1acb0..4d0b537 100644 --- a/__tests__/integration/rulesets.test.js +++ b/__tests__/integration/rulesets.test.js @@ -186,3 +186,42 @@ describe('applyRulesetFromBranchProtection', () => { expect(result.action).toBe('created'); }); }); + +describe('rulesetsMatch sees bypass actors and target', () => { + const base = translateBranchProtectionToRuleset({ require_signed_commits: true }); + + it('matches itself (positive control)', () => { + expect(rulesetsMatch({ ...base }, base)).toBe(true); + }); + + it('an admin bypass on otherwise identical rules is drift', () => { + const actual = { + ...base, + bypass_actors: [{ actor_id: 5, actor_type: 'RepositoryRole', bypass_mode: 'always' }] + }; + expect(rulesetsMatch(actual, base)).toBe(false); + }); + + it('a tag ruleset never matches a branch ruleset of the same rules', () => { + expect(rulesetsMatch({ ...base, target: 'tag' }, base)).toBe(false); + }); +}); + +describe('rulesetsMatch ignores parameters GitHub adds on its own', () => { + const desired = translateBranchProtectionToRuleset({ required_pull_request_reviews: {} }); + + it('extra server-side defaults are not drift', () => { + const actual = structuredClone(desired); + Object.assign(actual.rules.find((r) => r.type === 'pull_request').parameters, { + allowed_merge_methods: ['merge', 'squash', 'rebase'], + required_reviewers: [] + }); + expect(rulesetsMatch(actual, desired)).toBe(true); + }); + + it('a changed parameter temper sets is still drift', () => { + const actual = structuredClone(desired); + actual.rules.find((r) => r.type === 'pull_request').parameters.required_approving_review_count = 2; + expect(rulesetsMatch(actual, desired)).toBe(false); + }); +}); diff --git a/__tests__/unit/dry-run.test.js b/__tests__/unit/dry-run.test.js new file mode 100644 index 0000000..498a75d --- /dev/null +++ b/__tests__/unit/dry-run.test.js @@ -0,0 +1,108 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { createDryRunOctokit } from '../../src/dry-run.js'; + +function fakeReal(handler = () => ({ status: 200, data: { ok: true } })) { + return { request: jest.fn().mockImplementation(async (route, params) => handler(route, params)) }; +} + +const noSleep = () => Promise.resolve(); + +describe('createDryRunOctokit', () => { + it('never sends a write: PATCH/PUT/POST/DELETE are recorded, the real octokit is not called', async () => { + const real = fakeReal(); + const dry = createDryRunOctokit(real, { minIntervalMs: 0, sleep: noSleep }); + await dry.request('PATCH /repos/{owner}/{repo}', { owner: 'o', repo: 'r', allow_update_branch: false }); + await dry.request('PUT /repos/{owner}/{repo}/rulesets/{ruleset_id}', { owner: 'o', repo: 'r', ruleset_id: 9, bypass_actors: [] }); + await dry.request('POST /repos/{owner}/{repo}/rulesets', { owner: 'o', repo: 'r', name: 'x' }); + await dry.request('DELETE /repos/{owner}/{repo}/labels/{name}', { owner: 'o', repo: 'r', name: 'a b' }); + await dry.request('/repos/{owner}/{repo}', { method: 'patch', owner: 'o', repo: 'r' }); + expect(real.request).not.toHaveBeenCalled(); + expect(dry.writes.map((w) => `${w.method} ${w.url}`)).toEqual([ + 'PATCH /repos/o/r', + 'PUT /repos/o/r/rulesets/9', + 'POST /repos/o/r/rulesets', + 'DELETE /repos/o/r/labels/a%20b', + 'PATCH /repos/o/r' + ]); + expect(dry.writes[0].body).toEqual({ allow_update_branch: false }); + }); + + it('exposes no other octokit surface a write could slip through', () => { + const dry = createDryRunOctokit(fakeReal()); + expect(dry.repos).toBeUndefined(); + expect(dry.rest).toBeUndefined(); + expect(dry.graphql).toBeUndefined(); + }); + + it('refuses to paginate a write', async () => { + const dry = createDryRunOctokit(fakeReal(), { minIntervalMs: 0 }); + await expect(dry.paginate('POST /repos/{owner}/{repo}/issues', {})).rejects.toThrow(/refusing/); + }); + + it('hashes file contents instead of printing them', async () => { + const dry = createDryRunOctokit(fakeReal(), { minIntervalMs: 0 }); + await dry.request('PUT /repos/{owner}/{repo}/contents/{path}', { + owner: 'o', repo: 'r', path: 'a.yml', content: Buffer.from('hello').toString('base64') + }); + expect(dry.writes[0].body.content).toMatch(/^<5 bytes sha256:[0-9a-f]{12}>$/); + }); + + it('passes GETs through, throttled to the minimum interval', async () => { + const real = fakeReal(); + const waits = []; + const dry = createDryRunOctokit(real, { minIntervalMs: 1000, sleep: async (ms) => { waits.push(ms); } }); + await dry.request('GET /repos/{owner}/{repo}', { owner: 'o', repo: 'r' }); + await dry.request('GET /repos/{owner}/{repo}', { owner: 'o', repo: 'q' }); + expect(real.request).toHaveBeenCalledTimes(2); + expect(waits.length).toBe(1); + expect(waits[0]).toBeGreaterThan(900); + }); + + it('caches GETs (and their errors) on disk so a rerun sends nothing', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'temper-dry-')); + const real = fakeReal((route) => { + if (route.includes('protection')) throw Object.assign(new Error('Branch not protected'), { status: 404 }); + return { status: 200, data: { n: 1 } }; + }); + const first = createDryRunOctokit(real, { cacheDir: dir, minIntervalMs: 0 }); + await first.request('GET /repos/{owner}/{repo}', { owner: 'o', repo: 'r' }); + await expect(first.request('GET /x/protection', {})).rejects.toMatchObject({ status: 404 }); + const second = createDryRunOctokit(real, { cacheDir: dir, minIntervalMs: 0 }); + expect((await second.request('GET /repos/{owner}/{repo}', { owner: 'o', repo: 'r' })).data).toEqual({ n: 1 }); + await expect(second.request('GET /x/protection', {})).rejects.toMatchObject({ status: 404 }); + expect(real.request).toHaveBeenCalledTimes(2); + expect(second.stats).toEqual({ live: 0, cached: 2 }); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('backs off and retries on a secondary rate limit, but not on a plan 403', async () => { + let n = 0; + const real = fakeReal((route) => { + if (route.includes('limited') && n++ === 0) { + throw Object.assign(new Error('You have exceeded a secondary rate limit'), { status: 403 }); + } + if (route.includes('plan')) throw Object.assign(new Error('Upgrade to GitHub Pro'), { status: 403 }); + return { status: 200, data: 'ok' }; + }); + const waits = []; + const dry = createDryRunOctokit(real, { minIntervalMs: 0, sleep: async (ms) => { waits.push(ms); } }); + expect((await dry.request('GET /limited', {})).data).toBe('ok'); + expect(waits).toContain(60000); + await expect(dry.request('GET /plan', {})).rejects.toMatchObject({ status: 403 }); + }); + + it('paginates until a short page', async () => { + const real = fakeReal((route, p) => ({ status: 200, data: p.page === 1 ? [1, 2] : [3] })); + const dry = createDryRunOctokit(real, { minIntervalMs: 0 }); + expect(await dry.paginate('GET /orgs/{org}/repos', { org: 'o', per_page: 2 })).toEqual([1, 2, 3]); + }); + + it('drainWrites hands over and clears the recorded writes', async () => { + const dry = createDryRunOctokit(fakeReal(), { minIntervalMs: 0 }); + await dry.request('POST /a', {}); + expect(dry.drainWrites()).toHaveLength(1); + expect(dry.writes).toHaveLength(0); + }); +}); diff --git a/__tests__/unit/standards.test.js b/__tests__/unit/standards.test.js new file mode 100644 index 0000000..34510c9 --- /dev/null +++ b/__tests__/unit/standards.test.js @@ -0,0 +1,460 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import yaml from 'js-yaml'; +import { + getStandards, + validateStandards, + deriveState, + evaluateRepo, + hasDrift, + intendedContexts, + buildManagedRuleset, + buildTagImmutableRuleset, + buildTagCreateRuleset, + enforceStandards, + fetchSnapshot, + loadSnapshotDir, + renderWorkflowTemplate +} from '../../src/standards.js'; +import { validateConfig } from '../../src/schema.js'; + +const REPO_CONFIG = yaml.load(fs.readFileSync(path.join(__dirname, '..', '..', 'config.yml'), 'utf8')); +const ADMIN = { actor_id: 5, actor_type: 'RepositoryRole', bypass_mode: 'always' }; + +/** Standards as config.yml defines them, plus a pinned workflow for tests. */ +function std(overrides = {}) { + return { ...getStandards(REPO_CONFIG), ...overrides }; +} + +const ok = (body) => ({ status: 200, body }); +const err = (status, message) => ({ status, body: { message } }); + +const tagRulesets = () => [ + { id: 901, name: 'immutable', target: 'tag', enforcement: 'active', source_type: 'Repository', + conditions: { ref_name: { include: ['refs/tags/v*'], exclude: [] } }, + rules: [{ type: 'deletion' }, { type: 'non_fast_forward' }, { type: 'update' }], bypass_actors: [] }, + { id: 902, name: 'create', target: 'tag', enforcement: 'active', source_type: 'Repository', + conditions: { ref_name: { include: ['refs/tags/v*'], exclude: [] } }, + rules: [{ type: 'creation' }], bypass_actors: [ADMIN] } +]; + +/** A repo that meets every standard: the positive control. */ +function compliantSnapshot(name = 'good') { + const managed = { + id: 100, name: 'temper-default-branch-protection', target: 'branch', enforcement: 'active', + source_type: 'Repository', conditions: { ref_name: { include: ['~DEFAULT_BRANCH'], exclude: [] } }, + rules: [{ type: 'deletion' }, { type: 'non_fast_forward' }, { type: 'required_signatures' }, + { type: 'pull_request', parameters: {} }, + { type: 'required_status_checks', parameters: { strict_required_status_checks_policy: false, + required_status_checks: [{ context: 'build' }, { context: 'hostname-scan' }] } }], + bypass_actors: [] + }; + return { + name, + owner: 'pulseengine', + repo: ok({ name, default_branch: 'main', private: false, fork: false, archived: false, + allow_merge_commit: true, allow_squash_merge: true, allow_rebase_merge: false, + allow_auto_merge: true, allow_update_branch: false, delete_branch_on_merge: true }), + protection: ok({ + required_status_checks: { strict: false, contexts: ['build'], checks: [{ context: 'build' }] }, + enforce_admins: { enabled: true }, + required_signatures: { enabled: true }, + required_pull_request_reviews: { required_approving_review_count: 0 }, + required_linear_history: { enabled: false } + }), + branchRules: ok(managed.rules.map((r) => ({ ...r, ruleset_id: 100, ruleset_source_type: 'Repository' }))), + rulesets: { status: 200, items: [managed, ...tagRulesets()] }, + workflows: ok([{ name: 'ci.yml', path: '.github/workflows/ci.yml' }, + { name: 'hostname-scan.yml', path: '.github/workflows/hostname-scan.yml' }]) + }; +} + +const drifted = (ev) => ev.findings.filter((f) => f.drift).map((f) => f.rule).sort(); + +describe('config.yml standards', () => { + it('the shipped config.yml validates, standards included', () => { + expect(validateConfig(REPO_CONFIG)).toEqual({ valid: true, errors: [] }); + expect(getStandards(REPO_CONFIG).enabled).toBe(true); + }); + + describe('negative controls: a config violating the floor is rejected', () => { + it('enforce_admins false contradicts no_bypass', () => { + const cfg = structuredClone(REPO_CONFIG); + cfg.branch_protection.default.enforce_admins = false; + expect(validateConfig(cfg).errors).toContain( + 'standards.no_bypass requires branch_protection.default.enforce_admins: true'); + cfg.branch_protection.default.enforce_admins = true; // restore by re-editing + expect(validateConfig(cfg).valid).toBe(true); + }); + + it('signed commits off contradicts required_signatures', () => { + const cfg = structuredClone(REPO_CONFIG); + cfg.branch_protection.default.require_signed_commits = false; + expect(validateConfig(cfg).valid).toBe(false); + cfg.branch_protection.default.require_signed_commits = true; + expect(validateConfig(cfg).valid).toBe(true); + }); + + it('rebase merges are rejected alongside required signatures', () => { + const cfg = structuredClone(REPO_CONFIG); + cfg.settings.merge.allow_rebase_merge = true; + expect(validateConfig(cfg).valid).toBe(false); + }); + + it('a workflow pin must be a full commit sha', () => { + const cfg = structuredClone(REPO_CONFIG); + cfg.standards.required_workflows[0].pin = 'main'; + expect(validateConfig(cfg).errors.join()).toMatch(/40-hex/); + cfg.standards.required_workflows[0].pin = 'a'.repeat(40); + expect(validateConfig(cfg).valid).toBe(true); + }); + + it('rejects malformed blocks', () => { + expect(validateStandards({ standards: 'yes' })).toEqual(['standards must be an object']); + expect(validateStandards({ standards: { enabled: false, merge_queue: { repos: 'x' } } })).toEqual([]); + const e = validateStandards({ + branch_protection: { default: { enforce_admins: true, require_signed_commits: true } }, + standards: { enabled: true, merge_queue: { repos: 'x' }, required_workflows: [{ name: 'x' }], + repos: { a: { required_checks: 'build' } } } + }); + expect(e).toEqual(expect.arrayContaining([ + 'standards.merge_queue.repos must be an array', + 'standards.required_workflows[0] needs name, context, path and template', + 'standards.repos.a.required_checks must be an array' + ])); + }); + }); +}); + +describe('evaluateRepo', () => { + it('positive control: a compliant repo has no drift', () => { + const ev = evaluateRepo(compliantSnapshot(), std()); + expect(ev.scope).toBe('enforced'); + expect(drifted(ev)).toEqual([]); + expect(hasDrift(ev)).toBe(false); + }); + + it('negative control: breaking each standard shows drift; re-editing restores', () => { + const snap = compliantSnapshot(); + // Checks stay binding through the bypass-free ruleset, so only no_bypass drifts. + snap.protection.body.enforce_admins.enabled = false; + expect(drifted(evaluateRepo(snap, std()))).toEqual(['no_bypass']); + snap.protection.body.enforce_admins.enabled = true; + + snap.rulesets.items[0].bypass_actors = [ADMIN]; + const ev = evaluateRepo(snap, std()); + expect(drifted(ev)).toEqual(['no_bypass', 'required_checks']); + expect(ev.findings.find((f) => f.rule === 'no_bypass').actual).toMatch(/RepositoryRole:5\/always/); + snap.rulesets.items[0].bypass_actors = []; + + snap.protection.body.required_signatures.enabled = false; + snap.branchRules.body = snap.branchRules.body.filter((r) => r.type !== 'required_signatures'); + expect(drifted(evaluateRepo(snap, std()))).toEqual(['required_signatures']); + + expect(hasDrift(evaluateRepo(compliantSnapshot(), std()))).toBe(false); + }); + + it('classic enforce_admins off + an inert ruleset = admin bypass, checks not binding', () => { + const snap = compliantSnapshot('r1'); + snap.protection.body.enforce_admins.enabled = false; + snap.protection.body.required_pull_request_reviews = null; + snap.rulesets.items = [{ id: 7, name: 'main', target: 'branch', enforcement: 'active', + source_type: 'Repository', conditions: { ref_name: { include: [], exclude: [] } }, + rules: [{ type: 'pull_request' }], bypass_actors: [] }]; + snap.branchRules.body = []; + const ev = evaluateRepo(snap, std()); + const byRule = Object.fromEntries(ev.findings.map((f) => [f.rule, f])); + expect(byRule.no_bypass.drift).toBe(true); + expect(byRule.no_bypass.note).toMatch(/inert ruleset.*"main"/); + expect(byRule.required_checks.actual).toBe('1 required, 0 binding admins'); + expect(byRule.required_checks.fix).toBe('auto'); + expect(byRule.pull_request.drift).toBe(true); + expect(byRule.tag_protection.drift).toBe(true); + }); + + it('no required checks anywhere is an owner decision, never an invented name', () => { + const snap = compliantSnapshot('bare'); + snap.protection = err(404, 'Branch not protected'); + snap.branchRules.body = snap.branchRules.body.filter((r) => r.type !== 'required_status_checks'); + snap.workflows = ok([{ name: 'ci.yml', path: '.github/workflows/ci.yml' }]); + const f = evaluateRepo(snap, std()).findings.find((x) => x.rule === 'required_checks'); + expect(f).toMatchObject({ drift: true, fix: 'owner' }); + expect(intendedContexts(deriveState(snap), std())).toEqual([]); + }); + + it('a per-repo override supplies the checks', () => { + const snap = compliantSnapshot('bare'); + snap.protection = err(404, 'Branch not protected'); + snap.branchRules.body = []; + const s = std({ repos: { bare: { required_checks: ['test', 'lint'] } } }); + const f = evaluateRepo(snap, s).findings.find((x) => x.rule === 'required_checks'); + expect(f).toMatchObject({ drift: true, fix: 'auto', intended: 'non-empty, binding (3)' }); + }); + + it('queue repo: strict up-to-date is drift only once the queue is live', () => { + const snap = compliantSnapshot('q'); + snap.protection.body.required_status_checks.strict = true; + const s = std({ merge_queue_repos: ['q'] }); + let ev = evaluateRepo(snap, s); + expect(drifted(ev)).toEqual(['merge_queue']); + expect(ev.findings.find((f) => f.rule === 'update_branch' && f.intended.startsWith('strict')).note) + .toMatch(/same change that turns the queue on/); + + snap.branchRules.body.push({ type: 'merge_queue', ruleset_id: 55, parameters: {} }); + ev = evaluateRepo(snap, s); + expect(drifted(ev)).toEqual(['update_branch']); + expect(ev.findings.find((f) => f.rule === 'update_branch' && f.drift).fix).toBe('auto'); + }); + + it('allow_update_branch true is rule-3 drift', () => { + const snap = compliantSnapshot(); + snap.repo.body.allow_update_branch = true; + expect(drifted(evaluateRepo(snap, std()))).toEqual(['update_branch']); + }); + + it('immutable tags without a creation restriction is partial', () => { + const snap = compliantSnapshot('r2'); + snap.rulesets.items = snap.rulesets.items.filter((r) => r.id !== 902); + const f = evaluateRepo(snap, std()).findings.find((x) => x.rule === 'tag_protection'); + expect(f).toMatchObject({ drift: true, note: 'missing: creation restricted' }); + }); + + it('a tag ruleset that admins can bypass does not make tags immutable', () => { + const snap = compliantSnapshot(); + snap.rulesets.items.find((r) => r.id === 901).bypass_actors = [ADMIN]; + expect(evaluateRepo(snap, std()).findings.find((x) => x.rule === 'tag_protection').note) + .toMatch(/immutable/); + }); + + it('evaluate-mode or excluded tag rulesets do not count', () => { + const snap = compliantSnapshot(); + snap.rulesets.items.find((r) => r.id === 901).enforcement = 'evaluate'; + snap.rulesets.items.find((r) => r.id === 902).conditions.ref_name.exclude = ['refs/tags/v**']; + expect(evaluateRepo(snap, std()).findings.find((x) => x.rule === 'tag_protection').note) + .toBe('missing: immutable (update+deletion, no bypass); creation restricted'); + }); + + it('private repo on GitHub Free is unenforceable, but repo settings are still evaluated', () => { + const snap = compliantSnapshot('priv'); + const msg = 'gh: Upgrade to GitHub Pro or make this repository public to enable this feature. (HTTP 403)'; + snap.repo.body.private = true; + snap.repo.body.allow_auto_merge = false; + snap.protection = err(403, msg); + snap.branchRules = err(403, msg); + snap.rulesets = { status: 403, body: { message: msg }, items: [] }; + const ev = evaluateRepo(snap, std()); + expect(ev.scope).toBe('unenforceable'); + expect(ev.blocker).toMatch(/GitHub Free/); + expect(drifted(ev)).toEqual(['auto_merge']); + expect(ev.findings.find((f) => f.rule === 'auto_merge').fix).toBe('blocked'); + }); + + it('a secondary-rate-limit 403 is NOT mistaken for the plan block', () => { + const snap = compliantSnapshot(); + snap.protection = err(403, 'You have exceeded a secondary rate limit'); + expect(evaluateRepo(snap, std()).scope).toBe('enforced'); + }); + + it('forks are out of scope by default; archived repos are skipped', () => { + const fork = compliantSnapshot('upstream-fork'); + fork.repo.body.fork = true; + expect(evaluateRepo(fork, std())).toMatchObject({ scope: 'fork', findings: [] }); + expect(evaluateRepo(fork, std({ include_forks: true })).scope).toBe('enforced'); + const arch = compliantSnapshot('old'); + arch.repo.body.archived = true; + expect(evaluateRepo(arch, std()).scope).toBe('archived'); + }); + + it('report_only: findings are reported but nothing is enforced', async () => { + const snap = compliantSnapshot('held'); + snap.protection.body.enforce_admins.enabled = false; + const s = std({ repos: { held: { report_only: 'bot pushes to main' } } }); + const ev = evaluateRepo(snap, s); + expect(ev).toMatchObject({ scope: 'held', blocker: 'report-only: bot pushes to main' }); + expect(drifted(ev)).toContain('no_bypass'); + const kit = recordingOctokit(); + expect((await enforceStandards(kit, 'o', REPO_CONFIG.branch_protection.default, snap, s, {})).actions).toEqual([]); + expect(kit.calls).toEqual([]); + expect(validateStandards({ branch_protection: { default: { enforce_admins: true, require_signed_commits: true } }, + standards: { enabled: true, repos: { x: { report_only: '' } } } })) + .toEqual(['standards.repos.x.report_only must be a non-empty reason string']); + }); + + it('hostname-scan: absent stub with no pin is blocked; with a pin it is auto', () => { + const snap = compliantSnapshot(); + snap.workflows = ok([{ name: 'ci.yml', path: '.github/workflows/ci.yml' }]); + snap.branchRules.body[4].parameters.required_status_checks = [{ context: 'build' }]; + const f1 = evaluateRepo(snap, std()).findings.find((x) => x.rule === 'hostname_scan'); + expect(f1).toMatchObject({ drift: true, fix: 'blocked' }); + const pinned = std({ required_workflows: [{ ...std().required_workflows[0], pin: 'b'.repeat(40) }] }); + expect(evaluateRepo(snap, pinned).findings.find((x) => x.rule === 'hostname_scan').fix).toBe('auto'); + }); + + it('merge-method drift is reported against config, not a hardcoded rebase-only ideal', () => { + const snap = compliantSnapshot(); + snap.repo.body.allow_rebase_merge = true; + const f = evaluateRepo(snap, std()).findings.filter((x) => x.rule === 'merge_methods' && x.drift); + expect(f).toHaveLength(1); + expect(f[0].intended).toBe('allow_rebase_merge=false'); + }); +}); + +describe('managed and tag rulesets', () => { + it('managed ruleset carries existing contexts, binds admins, keeps strictness off a queue', () => { + const st = deriveState(compliantSnapshot('r')); + const rs = buildManagedRuleset(REPO_CONFIG.branch_protection.default, st, std()); + expect(rs.bypass_actors).toEqual([]); + const checks = rs.rules.find((r) => r.type === 'required_status_checks').parameters; + expect(checks.required_status_checks.map((c) => c.context).sort()).toEqual(['build', 'hostname-scan']); + expect(checks.strict_required_status_checks_policy).toBe(false); + expect(rs.rules.map((r) => r.type)).toContain('required_signatures'); + }); + + it('tag rulesets: immutable has no bypass; create bypass is the admin allowlist', () => { + expect(buildTagImmutableRuleset(std())).toMatchObject({ target: 'tag', bypass_actors: [] }); + expect(buildTagCreateRuleset(std()).bypass_actors).toEqual([ADMIN]); + expect(buildTagCreateRuleset(std()).rules).toEqual([{ type: 'creation' }]); + }); + + it('renders the stub template with pin and branch', () => { + expect(renderWorkflowTemplate('a@__TEMPER_PIN__ [__TEMPER_DEFAULT_BRANCH__]', { pin: 'c'.repeat(40), defaultBranch: 'master' })) + .toBe(`a@${'c'.repeat(40)} [master]`); + }); +}); + +function recordingOctokit(existingRulesets = []) { + const calls = []; + return { + calls, + paginate: jest.fn().mockResolvedValue(existingRulesets), + request: jest.fn().mockImplementation((route, params) => { + calls.push([route, params]); + if (route.startsWith('POST /repos/{owner}/{repo}/rulesets')) return Promise.resolve({ data: { id: 1 } }); + if (route.startsWith('GET /repos/{owner}/{repo}/rulesets/{ruleset_id}')) { + return Promise.resolve({ data: existingRulesets.find((r) => r.id === params.ruleset_id) }); + } + if (route.startsWith('GET /repos/{owner}/{repo}/git/ref')) return Promise.resolve({ data: { object: { sha: 'x' } } }); + if (route === 'GET /repos/{owner}/{repo}') return Promise.resolve({ data: { default_branch: 'main' } }); + if (route.startsWith('GET /repos/{owner}/{repo}/contents')) return Promise.reject(Object.assign(new Error('nf'), { status: 404 })); + return Promise.resolve({ status: 200, data: { number: 3 } }); + }) + }; +} + +describe('enforceStandards', () => { + const bp = REPO_CONFIG.branch_protection.default; + + it('classic bypass: narrow legacy fixes, managed ruleset with carried contexts, tag rulesets; never PUT protection', async () => { + const snap = compliantSnapshot('r1'); + snap.protection.body.enforce_admins.enabled = false; + snap.rulesets.items = []; + snap.branchRules.body = []; + const kit = recordingOctokit(); + const { actions } = await enforceStandards(kit, 'pulseengine', bp, snap, std(), {}); + const routes = kit.calls.map((c) => c[0]); + expect(routes).toContain('POST /repos/{owner}/{repo}/branches/{branch}/protection/enforce_admins'); + expect(routes).not.toContain('PUT /repos/{owner}/{repo}/branches/{branch}/protection'); + const created = kit.calls.filter((c) => c[0] === 'POST /repos/{owner}/{repo}/rulesets').map((c) => c[1]); + expect(created.map((r) => r.name)).toEqual([ + 'temper-default-branch-protection', 'temper-release-tags-immutable', 'temper-release-tags-create']); + const checks = created[0].rules.find((r) => r.type === 'required_status_checks'); + expect(checks.parameters.required_status_checks).toEqual([{ context: 'build' }, { context: 'hostname-scan' }]); + expect(actions).toEqual(expect.arrayContaining(['classic protection: enforce_admins on'])); + }); + + it('strips bypass actors from a foreign repo ruleset, keeping its rules', async () => { + const snap = compliantSnapshot('r'); + const foreign = { id: 300, name: 'their-ruleset', target: 'branch', enforcement: 'active', source_type: 'Repository', + conditions: { ref_name: { include: ['~DEFAULT_BRANCH'], exclude: [] } }, + rules: [{ type: 'required_status_checks', parameters: { required_status_checks: [{ context: 'x' }] } }], + bypass_actors: [ADMIN] }; + snap.rulesets.items.push(foreign); + snap.branchRules.body.push({ type: 'required_status_checks', ruleset_id: 300, parameters: foreign.rules[0].parameters }); + const kit = recordingOctokit([snap.rulesets.items[0]]); + await enforceStandards(kit, 'pulseengine', bp, snap, std(), {}); + const put = kit.calls.find((c) => c[0] === 'PUT /repos/{owner}/{repo}/rulesets/{ruleset_id}' && c[1].ruleset_id === 300); + expect(put[1]).toMatchObject({ bypass_actors: [], rules: foreign.rules, name: 'their-ruleset' }); + expect(kit.calls.some((c) => c[0].startsWith('PUT') && c[1].ruleset_id === 300 && c[1].bypass_actors.length)).toBe(false); + }); + + it('does not touch an org-sourced ruleset (reported for the owner instead)', async () => { + const snap = compliantSnapshot('r'); + snap.rulesets.items.push({ id: 400, name: 'org', target: 'branch', enforcement: 'active', source_type: 'Organization', + conditions: {}, rules: [{ type: 'deletion' }], bypass_actors: [ADMIN] }); + snap.branchRules.body.push({ type: 'deletion', ruleset_id: 400 }); + expect(evaluateRepo(snap, std()).findings.find((f) => f.rule === 'no_bypass').fix).toBe('owner'); + const kit = recordingOctokit([snap.rulesets.items[0]]); + await enforceStandards(kit, 'pulseengine', bp, snap, std(), {}); + expect(kit.calls.some((c) => c[1]?.ruleset_id === 400 && c[0].startsWith('PUT'))).toBe(false); + }); + + it('switches classic strict off on a live queue repo via the narrow endpoint', async () => { + const snap = compliantSnapshot('q'); + snap.protection.body.required_status_checks.strict = true; + snap.branchRules.body.push({ type: 'merge_queue', ruleset_id: 55 }); + const kit = recordingOctokit([snap.rulesets.items[0]]); + await enforceStandards(kit, 'pulseengine', bp, snap, std({ merge_queue_repos: ['q'] }), {}); + const patch = kit.calls.find((c) => c[0] === 'PATCH /repos/{owner}/{repo}/branches/{branch}/protection/required_status_checks'); + expect(patch[1]).toMatchObject({ strict: false }); + }); + + it('opens the stub PR only when pinned, rendering the pin in', async () => { + const snap = compliantSnapshot('r'); + snap.workflows = ok([]); + const sha = 'd'.repeat(40); + const pinned = std({ required_workflows: [{ ...std().required_workflows[0], pin: sha }] }); + const readTemplate = jest.fn().mockReturnValue('uses: x@__TEMPER_PIN__'); + const kit = recordingOctokit([snap.rulesets.items[0]]); + const { actions } = await enforceStandards(kit, 'pulseengine', REPO_CONFIG.branch_protection.default, snap, pinned, { readTemplate }); + expect(actions).toContain('PR: add .github/workflows/hostname-scan.yml'); + const put = kit.calls.find((c) => c[0] === 'PUT /repos/{owner}/{repo}/contents/{path}'); + expect(Buffer.from(put[1].content, 'base64').toString()).toBe(`uses: x@${sha}`); + + const kit2 = recordingOctokit([snap.rulesets.items[0]]); + await enforceStandards(kit2, 'pulseengine', REPO_CONFIG.branch_protection.default, snap, std(), { readTemplate }); + expect(kit2.calls.some((c) => c[0].startsWith('POST /repos/{owner}/{repo}/pulls'))).toBe(false); + }); + + it('does nothing on unenforceable or fork repos', async () => { + const snap = compliantSnapshot('w'); + snap.repo.body.fork = true; + const kit = recordingOctokit(); + expect((await enforceStandards(kit, 'o', bp, snap, std(), {})).actions).toEqual([]); + expect(kit.calls).toEqual([]); + }); +}); + +describe('snapshots', () => { + it('fetchSnapshot records statuses instead of throwing', async () => { + const kit = { + request: jest.fn().mockImplementation((route) => { + if (route === 'GET /repos/{owner}/{repo}') return Promise.resolve({ status: 200, data: { default_branch: 'main' } }); + if (route.endsWith('/protection')) return Promise.reject(Object.assign(new Error('Branch not protected'), { status: 404 })); + if (route.endsWith('/rulesets')) return Promise.resolve({ status: 200, data: [{ id: 1 }] }); + if (route.endsWith('{ruleset_id}')) return Promise.resolve({ status: 200, data: { id: 1, name: 'x', bypass_actors: [] } }); + return Promise.resolve({ status: 200, data: [] }); + }) + }; + const snap = await fetchSnapshot(kit, 'o', 'r'); + expect(snap.protection).toEqual({ status: 404, body: { message: 'Branch not protected' } }); + expect(snap.rulesets.items).toEqual([{ id: 1, name: 'x', bypass_actors: [] }]); + }); + + it('loadSnapshotDir reads a sweep cache and evaluates like the live snapshot', () => { + const live = compliantSnapshot('fromdisk'); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'temper-snap-')); + const repoDir = path.join(dir, 'fromdisk'); + fs.mkdirSync(repoDir); + const w = (f, v) => fs.writeFileSync(path.join(repoDir, f), JSON.stringify(v)); + w('repo.json', live.repo); + w('protection.json', live.protection); + w('branch_rules.json', live.branchRules); + w('rulesets_list.json', ok(live.rulesets.items.map((r) => ({ id: r.id, name: r.name })))); + for (const r of live.rulesets.items) w(`ruleset_${r.id}.json`, ok(r)); + w('workflows.json', live.workflows); + const fromDisk = loadSnapshotDir(repoDir); + expect(evaluateRepo(fromDisk, std())).toEqual(evaluateRepo(live, std())); + fs.rmSync(dir, { recursive: true, force: true }); + }); +}); diff --git a/config.yml b/config.yml index 4fb89c3..f9d47c5 100644 --- a/config.yml +++ b/config.yml @@ -18,6 +18,12 @@ settings: allow_rebase_merge: false delete_branch_on_merge: true allow_auto_merge: true + # "Always suggest updating pull request branches" off. The button's rebase + # mode rewrites a PR's signed commits unsigned (merge then BLOCKED on + # required signatures, pulseengine.eu#236); the merge queue already tests + # every entry against the latest main. The API endpoint itself stays + # callable — no repo setting can block it. + allow_update_branch: false forks: merge: @@ -59,6 +65,79 @@ branch_protection: allow_deletions: false require_signed_commits: true +# Org standards — the floor temper enforces and reports drift against. +# `node scripts/cli.js plan` shows, per repo, the drift and the exact writes a +# sync would make (read-only). Rule numbers match the 2026-10-30 owner decision: +# 1 never merge around red -> required_checks + no_bypass +# 2 signed commits -> required_signatures +# 3 update-branch -> settings.merge.allow_update_branch + queue strict +# 4 no private hostnames -> required_workflows: hostname_scan +# 5 tag only from main -> tag_protection (WHO may create v* tags; that the +# tagged commit is green is the release workflow's job) +standards: + enabled: true + # Forks mirror upstream history; requiring our signatures/checks there would + # block syncing. With this false, forks get merge settings and labels only — + # no branch or tag rulesets (existing ones are left as they are). + include_forks: false + required_signatures: true + # enforce_admins on classic protection AND no bypass actors on any ruleset + # that applies to the default branch. + no_bypass: true + # Strip bypass actors from repo rulesets temper does not manage. Shown in the + # plan per ruleset before any apply. + fix_foreign_bypass: true + # Non-empty required checks. Temper never invents check names: it carries + # the contexts a repo already requires into its managed ruleset (where they + # bind admins), or uses standards.repos..required_checks. + require_status_checks: true + require_pull_request: true + merge_queue: + # Intended queue repos. Audit-only: creating a queue is a per-repo rollout + # (every required workflow needs a merge_group trigger first). + repos: [pulseengine.eu, rivet, relay, spar, loom, varve, gale, meld] + # The queue re-tests against the latest main; strict only adds + # update-branch pressure and ~N^2/2 CI runs. + strict_up_to_date: false + tag_protection: + enabled: true + include: ["refs/tags/v*"] + # Two rulesets (the shape ordeal#186 runs today): one makes release tags + # immutable for everyone (no bypass); one restricts creation, and its + # bypass list is the allowlist of who may cut a release tag. A repo that + # already has an equivalent ruleset is left alone. + immutable_name: temper-release-tags-immutable + create_name: temper-release-tags-create + create_bypass_actors: + - actor_type: RepositoryRole # 5 = admin + actor_id: 5 + bypass_mode: always + required_workflows: + - name: hostname_scan + context: hostname-scan + path: .github/workflows/hostname-scan.yml + template: templates/workflows/hostname-scan.yml + # Commit sha of pulseengine/temper that carries + # .github/actions/hostname-scan. Unset until this lands on main; while + # unset, the stub is not distributed and the plan reports "blocked". + pin: null + # Per-repo required checks, where the repo requires none today. Names are + # the check runs the repo's PR workflows actually report (verified on main + # 2026-10-30). Every other repo carries over what it already requires. + repos: + temper: + required_checks: [test, lint, ci] # temper#76 + .github: + required_checks: [ci, lint, test] + # report_only: drift is reported, nothing is enforced, until the reason + # is resolved and the line removed. + pulseengine-layers: + report_only: >- + deposit.yml (and the layer scanner) commit as github-actions[bot] and + push straight to main; a PR + signed-commits ruleset would break the + deposit pipeline. Route those commits through PRs (or a signing app + identity on a bypass list) first. + # Repository Rulesets (modern replacement for legacy branch protection). # When enabled, Temper applies a Repository Ruleset derived from # branch_protection.default. Rulesets target ~DEFAULT_BRANCH so they apply on diff --git a/package.json b/package.json index 8a7c3e5..74dff0a 100644 --- a/package.json +++ b/package.json @@ -8,11 +8,12 @@ "start": "node index.js", "dev": "nodemon index.js", "test": "jest --config jest.config.cjs", - "lint": "eslint src/ index.js functions.js __tests__/", + "lint": "eslint src/ index.js functions.js __tests__/ scripts/", "build": "bazel build //...", "bazel:test": "bazel test //...", "docker:build": "docker build -t temper .", - "setup": "node scripts/setup.js" + "setup": "node scripts/setup.js", + "plan": "node scripts/cli.js plan" }, "keywords": [ "github", diff --git a/scripts/cli.js b/scripts/cli.js index 677f468..feb58a2 100644 --- a/scripts/cli.js +++ b/scripts/cli.js @@ -5,12 +5,18 @@ import { createAppAuth } from '@octokit/auth-app'; import { Octokit } from '@octokit/rest'; import { fileURLToPath } from 'url'; import path from 'path'; +import fs from 'fs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); dotenvConfig({ path: path.join(__dirname, '..', '.env'), quiet: true }); // ── Octokit Setup ──────────────────────────────────────────────────── async function getOctokit() { + // A plain token (e.g. `GITHUB_TOKEN=$(gh auth token)`) is enough for the + // read-only `plan` / `org` / `repo` commands; the App is used otherwise. + if (process.env.GITHUB_TOKEN && !process.env.APP_ID) { + return new Octokit({ auth: process.env.GITHUB_TOKEN }); + } const appId = process.env.APP_ID; const privateKey = (process.env.PRIVATE_KEY || '').replace(/\\n/g, '\n'); const org = process.env.ORGANIZATION || 'pulseengine'; @@ -224,6 +230,46 @@ async function cmdSync(octokit, org) { } } +// ── Plan (dry-run) ─────────────────────────────────────────────────── + +function flag(args, name, fallback = null) { + const i = args.indexOf(name); + return i >= 0 && args[i + 1] !== undefined ? args[i + 1] : fallback; +} + +async function cmdPlan(octokit, org, args) { + const { planOrg, renderPlan } = await import('../src/plan.js'); + const { setLogger } = await import('../src/logger.js'); + const pino = (await import('pino')).default; + setLogger(pino({ level: process.env.LOG_LEVEL || 'silent' })); + const repos = flag(args, '--repo') ? flag(args, '--repo').split(',') : null; + const plan = await planOrg(octokit, org, { + repos, + cacheDir: flag(args, '--cache'), + minIntervalMs: Number(flag(args, '--interval', '1000')) + }); + process.stdout.write(renderPlan(plan.results, { showOk: args.includes('--all') })); + const writes = plan.results.reduce((n, r) => n + r.writes.length, 0); + const drifted = plan.results.filter((r) => r.drift).length; + console.log(`\n${plan.results.length} repos, ${drifted} with drift, ${writes} planned write(s); ` + + `${plan.stats.live} live GET(s), ${plan.stats.cached} cached. Nothing was sent.`); + if (flag(args, '--json')) fs.writeFileSync(flag(args, '--json'), JSON.stringify(plan, null, 2)); + if (args.includes('--fail-on-drift') && drifted > 0) process.exitCode = 3; +} + +async function cmdAudit(org, args) { + const { auditSnapshots, renderPlan } = await import('../src/plan.js'); + const dir = flag(args, '--snapshots'); + if (!dir) throw new Error('audit needs --snapshots '); + const repos = flag(args, '--repo') ? flag(args, '--repo').split(',') : null; + const results = auditSnapshots(dir, { repos }); + process.stdout.write(renderPlan(results, { showOk: args.includes('--all') })); + const drifted = results.filter((r) => r.drift).length; + console.log(`\n${results.length} repos, ${drifted} with drift (offline audit of ${dir}).`); + if (flag(args, '--json')) fs.writeFileSync(flag(args, '--json'), JSON.stringify(results, null, 2)); + if (args.includes('--fail-on-drift') && drifted > 0) process.exitCode = 3; +} + // ── Main ───────────────────────────────────────────────────────────── async function main() { const args = process.argv.slice(2); @@ -238,13 +284,19 @@ async function main() { node scripts/cli.js org health Summary of all repos node scripts/cli.js org repos Detailed per-repo table node scripts/cli.js repo check Check a single repo - node scripts/cli.js sync Sync all repos to config + node scripts/cli.js sync Sync all repos to config (WRITES) + node scripts/cli.js plan [--repo a,b] [--cache DIR] [--json FILE] [--all] [--fail-on-drift] + Dry-run: drift + the writes a sync would make (sends nothing) + node scripts/cli.js audit --snapshots DIR [--repo a,b] [--json FILE] [--all] [--fail-on-drift] + Offline drift report from a sweep cache `); process.exit(0); } const org = process.env.ORGANIZATION || 'pulseengine'; + if (cmd === 'audit') return cmdAudit(org, args); const octokit = await getOctokit(); + if (cmd === 'plan') return cmdPlan(octokit, org, args); if (cmd === 'org' && sub === 'health') return cmdOrgHealth(octokit, org); if (cmd === 'org' && sub === 'repos') return cmdOrgRepos(octokit, org); diff --git a/src/branch-protection.js b/src/branch-protection.js index 944c6c8..24296e0 100644 --- a/src/branch-protection.js +++ b/src/branch-protection.js @@ -5,12 +5,18 @@ async function applyBranchProtection(octokit, owner, repo, branch, protectionCon try { getLogger().info(`Applying branch protection to ${owner}/${repo}@${branch}`); + // PUT replaces classic protection wholesale. Keep the contexts the repo + // already requires, or a sync would silently empty a per-repo gate + // (e.g. a 19-context list) down to the org's empty default. + const existingContexts = await getExistingContexts(octokit, owner, repo, branch); const requiredStatusChecks = protectionConfig.required_status_checks === null ? null : { strict: protectionConfig.required_status_checks?.strict || false, - contexts: protectionConfig.required_status_checks?.contexts || [] + contexts: [ + ...new Set([...(protectionConfig.required_status_checks?.contexts || []), ...existingContexts]) + ] }; await octokit.request('PUT /repos/{owner}/{repo}/branches/{branch}/protection', { @@ -39,6 +45,21 @@ async function applyBranchProtection(octokit, owner, repo, branch, protectionCon } } +async function getExistingContexts(octokit, owner, repo, branch) { + try { + const { data } = await octokit.request('GET /repos/{owner}/{repo}/branches/{branch}/protection', { + owner, + repo, + branch + }); + const rsc = data?.required_status_checks; + return [...(rsc?.contexts || []), ...(rsc?.checks || []).map((c) => c.context)]; + } catch (error) { + if (error.status === 404) return []; + throw error; + } +} + async function setRequiredSignatures(octokit, owner, repo, branch, enabled) { try { if (enabled) { diff --git a/src/dry-run.js b/src/dry-run.js new file mode 100644 index 0000000..6ee09db --- /dev/null +++ b/src/dry-run.js @@ -0,0 +1,151 @@ +/** + * Dry-run octokit: GETs go to GitHub (throttled, optionally cached on disk); + * every other method is RECORDED and never sent. + * + * The plan is produced by running temper's real apply code + * (`configureRepository`) against this object, so the recorded writes are by + * construction exactly what an apply would send — there is no separate + * "planner" to drift from the applier. Only `request` and `paginate` exist: + * any other octokit surface (e.g. `octokit.repos.update`) is undefined here and + * throws, so a write cannot slip past the recorder through a side door. + */ + +import fs from 'fs'; +import path from 'path'; +import crypto from 'crypto'; + +const READ_METHODS = new Set(['GET', 'HEAD']); + +function methodOf(route, params) { + const m = /^([A-Z]+)\s/.exec(route); + return (m ? m[1] : params?.method || 'GET').toUpperCase(); +} + +function expand(route, params) { + const url = route.replace(/^[A-Z]+\s+/, ''); + return url.replace(/\{(\w+)\}/g, (_, k) => (params[k] !== undefined ? encodeURIComponent(params[k]) : `{${k}}`)); +} + +function stable(value) { + if (Array.isArray(value)) return value.map(stable); + if (value && typeof value === 'object') { + const out = {}; + for (const k of Object.keys(value).sort()) out[k] = stable(value[k]); + return out; + } + return value; +} + +/** Summarise a write's body for the plan; file contents are hashed, not printed. */ +function summarise(params) { + const body = {}; + for (const [k, v] of Object.entries(params || {})) { + if (['owner', 'repo', 'headers', 'request'].includes(k)) continue; + if (k === 'content' && typeof v === 'string') { + body.content = `<${Buffer.from(v, 'base64').length} bytes sha256:${crypto + .createHash('sha256') + .update(Buffer.from(v, 'base64')) + .digest('hex') + .slice(0, 12)}>`; + } else { + body[k] = v; + } + } + return body; +} + +const RATE_LIMIT_RE = /secondary rate limit|rate limit exceeded|abuse detection/i; + +/** + * @param {object} real - a real Octokit (only its `request` is used, for reads) + * @param {object} [opts] + * @param {number} [opts.minIntervalMs=1000] - minimum gap between live GETs + * @param {string|null} [opts.cacheDir] - persist GET responses (and errors) here + * @param {Function} [opts.sleep] - injectable for tests + */ +export function createDryRunOctokit(real, { minIntervalMs = 1000, cacheDir = null, sleep, maxRetries = 3 } = {}) { + const wait = sleep || ((ms) => new Promise((r) => setTimeout(r, ms))); + const writes = []; + const stats = { live: 0, cached: 0 }; + let last = 0; + + if (cacheDir) fs.mkdirSync(cacheDir, { recursive: true }); + + function cachePath(route, params) { + const key = JSON.stringify([route, stable(params || {})]); + return path.join(cacheDir, crypto.createHash('sha256').update(key).digest('hex') + '.json'); + } + + async function liveGet(route, params) { + for (let attempt = 0; ; attempt++) { + const gap = last + minIntervalMs - Date.now(); + if (gap > 0) await wait(gap); + last = Date.now(); + stats.live++; + try { + const res = await real.request(route, params); + return { status: res.status, data: res.data, headers: { link: res.headers?.link } }; + } catch (err) { + const status = err.status || 0; + if ((status === 403 || status === 429) && RATE_LIMIT_RE.test(err.message || '') && attempt < maxRetries) { + await wait(60000 * (attempt + 1)); + continue; + } + return { error: { status, message: err.message } }; + } + } + } + + async function get(route, params) { + let res; + const file = cacheDir ? cachePath(route, params) : null; + if (file && fs.existsSync(file)) { + res = JSON.parse(fs.readFileSync(file, 'utf8')); + stats.cached++; + } else { + res = await liveGet(route, params); + if (file) fs.writeFileSync(file, JSON.stringify({ route, params, ...res })); + } + if (res.error) { + const e = new Error(res.error.message); + e.status = res.error.status; + throw e; + } + return { status: res.status, data: res.data, headers: res.headers || {} }; + } + + async function request(route, params = {}) { + const method = methodOf(route, params); + if (READ_METHODS.has(method)) return get(route, params); + writes.push({ method, url: expand(route, params), body: summarise(params) }); + // Plausible empty answers so callers that read a write's result keep going. + return { status: 0, data: { dry_run: true, id: 0, number: 0, object: { sha: '0'.repeat(40) } }, headers: {} }; + } + + async function paginate(route, params = {}) { + if (!READ_METHODS.has(methodOf(route, params))) { + throw new Error(`dry-run: refusing to paginate a write (${route})`); + } + const perPage = params.per_page || 100; + const all = []; + for (let page = 1; page < 100; page++) { + const res = await get(route, { ...params, per_page: perPage, page }); + const items = Array.isArray(res.data) ? res.data : res.data?.items || res.data?.workflow_runs || []; + all.push(...items); + if (items.length < perPage) break; + } + return all; + } + + return { + request, + paginate, + isDryRun: true, + writes, + stats, + /** Take (and clear) the writes recorded since the last call. */ + drainWrites() { + return writes.splice(0, writes.length); + } + }; +} diff --git a/src/organization.js b/src/organization.js index f406253..f8770e8 100644 --- a/src/organization.js +++ b/src/organization.js @@ -44,6 +44,24 @@ async function pLimitMap(items, concurrency, fn) { const SYNC_CONCURRENCY = 3; +/** + * The repos a sync configures. Shared with the dry-run planner so the plan + * covers exactly the set an apply would touch. + */ +function selectSyncTargets(repos) { + return repos.filter((repo) => { + if (repo.archived) { + getLogger().info(`Skipping archived repository: ${repo.full_name}`); + return false; + } + if (isControlSurfaceRepo(repo.full_name)) { + getLogger().info(`Skipping control-surface repository: ${repo.full_name}`); + return false; + } + return true; + }); +} + async function synchronizeAllRepositories(octokit, org) { try { getLogger().info(`Starting synchronization for organization: ${org}`); @@ -56,17 +74,7 @@ async function synchronizeAllRepositories(octokit, org) { getLogger().info(`Found ${repos.length} repositories to synchronize (concurrency=${SYNC_CONCURRENCY})`); - const todo = repos.filter((repo) => { - if (repo.archived) { - getLogger().info(`Skipping archived repository: ${repo.full_name}`); - return false; - } - if (isControlSurfaceRepo(repo.full_name)) { - getLogger().info(`Skipping control-surface repository: ${repo.full_name}`); - return false; - } - return true; - }); + const todo = selectSyncTargets(repos); await pLimitMap(todo, SYNC_CONCURRENCY, async (repo) => { getLogger().info(`Processing repository: ${repo.full_name}`); @@ -370,6 +378,7 @@ async function generateOrganizationAnalysisReport(octokit, org) { } export { + selectSyncTargets, checkOrganizationMembership, synchronizeAllRepositories, analyzeOrganizationRepositories, diff --git a/src/plan.js b/src/plan.js new file mode 100644 index 0000000..4ca812e --- /dev/null +++ b/src/plan.js @@ -0,0 +1,89 @@ +/** + * Dry-run planner: for each repo a sync would touch, report standards drift + * and the exact writes the sync would send — without sending any. + * + * Drift comes from `evaluateRepo` (the one drift function). Writes come from + * running the real `configureRepository` against the recording octokit from + * `dry-run.js`, so nothing here re-implements apply logic. + */ + +import fs from 'fs'; +import path from 'path'; +import { getConfig } from './config.js'; +import { configureRepository } from './repository.js'; +import { selectSyncTargets } from './organization.js'; +import { createDryRunOctokit } from './dry-run.js'; +import { getStandards, fetchSnapshot, evaluateRepo, hasDrift, loadSnapshotDir } from './standards.js'; + +/** + * @param {object} realOctokit - used for reads only + * @param {string} org + * @param {object} [opts] + * @param {string[]} [opts.repos] - restrict to these repo names + * @param {string|null} [opts.cacheDir] + * @param {number} [opts.minIntervalMs] + * @param {object} [opts.dryOctokit] - inject (tests) + */ +export async function planOrg(realOctokit, org, { repos = null, cacheDir = null, minIntervalMs = 1000, dryOctokit } = {}) { + const dry = dryOctokit || createDryRunOctokit(realOctokit, { cacheDir, minIntervalMs }); + const std = getStandards(getConfig()); + const all = await dry.paginate('GET /orgs/{org}/repos', { org, type: 'all', per_page: 100 }); + const targets = selectSyncTargets(all) + .filter((r) => !repos || repos.includes(r.name)) + .sort((a, b) => a.name.localeCompare(b.name)); + + const results = []; + for (const repo of targets) { + dry.drainWrites(); + const snap = await fetchSnapshot(dry, org, repo.name); + const evaluation = evaluateRepo(snap, std); + dry.drainWrites(); + const res = await configureRepository(dry, repo); + const writes = dry.drainWrites(); + results.push({ + repo: repo.name, + visibility: repo.visibility, + fork: repo.fork, + scope: evaluation.scope, + blocker: evaluation.blocker, + drift: hasDrift(evaluation), + findings: evaluation.findings, + writes, + error: res.success ? null : res.error + }); + } + return { org, generated_at: new Date().toISOString(), stats: dry.stats, results }; +} + +/** Offline: evaluate sweep-cache snapshot dirs (no writes plan, no network). */ +export function auditSnapshots(rootDir, { repos = null } = {}) { + const std = getStandards(getConfig()); + const names = fs + .readdirSync(rootDir) + .filter((n) => fs.statSync(path.join(rootDir, n)).isDirectory()) + .filter((n) => !repos || repos.includes(n)) + .sort(); + return names.map((n) => { + const ev = evaluateRepo(loadSnapshotDir(path.join(rootDir, n)), std); + return { repo: n, scope: ev.scope, blocker: ev.blocker, drift: hasDrift(ev), findings: ev.findings }; + }); +} + +/** Render plan/audit results as plain text. */ +export function renderPlan(results, { showOk = false } = {}) { + const lines = []; + for (const r of results) { + const head = `${r.repo}${r.visibility === 'private' ? ' (private)' : ''}${r.fork ? ' (fork)' : ''}`; + lines.push(`\n## ${head} — ${r.scope}${r.drift ? ', DRIFT' : ''}${r.error ? `, ERROR: ${r.error}` : ''}`); + if (r.blocker) lines.push(` blocker: ${r.blocker}`); + for (const f of r.findings) { + if (!f.drift && !showOk) continue; + lines.push(` ${f.drift ? 'x' : 'ok'} ${f.rule}: intended ${f.intended} | actual ${f.actual}${f.fix ? ` | fix: ${f.fix}` : ''}${f.note ? ` | ${f.note}` : ''}`); + } + for (const w of r.writes || []) { + const body = JSON.stringify(w.body); + lines.push(` -> ${w.method} ${w.url} ${body.length > 400 ? body.slice(0, 400) + '…' : body}`); + } + } + return lines.join('\n') + '\n'; +} diff --git a/src/repository.js b/src/repository.js index 9331261..70e37f0 100644 --- a/src/repository.js +++ b/src/repository.js @@ -1,3 +1,6 @@ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; import { normalizeRepoInput, getDefaultBranch } from './helpers.js'; import { getLogger } from './logger.js'; import { @@ -11,6 +14,7 @@ import { } from './config.js'; import { applyBranchProtection } from './branch-protection.js'; import { applyRulesetFromBranchProtection } from './rulesets.js'; +import { getStandards, fetchSnapshot, enforceStandards } from './standards.js'; import { applyTemplates, applyCodeowners } from './templates.js'; import { synchronizeIssueLabels } from './labels.js'; import { @@ -19,6 +23,34 @@ import { fixDependabotPRLabels } from './dependabot.js'; +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const readTemplate = (rel) => fs.readFileSync(path.resolve(ROOT, rel), 'utf8'); + +/** + * Apply the org standards (config.yml `standards:`) to one repo. Forks get no + * branch enforcement unless `standards.include_forks` is set (their default + * branch mirrors upstream); private repos on GitHub Free are skipped for + * branch enforcement, which their plan does not expose. + */ +async function applyStandards(octokit, owner, repo, defaultBranch, protectionConfig, std, { skipWorkflows }) { + const snap = await fetchSnapshot(octokit, owner, repo); + const stdForRun = skipWorkflows ? { ...std, required_workflows: [] } : std; + const { evaluation, actions } = await enforceStandards(octokit, owner, protectionConfig, snap, stdForRun, { + readTemplate + }); + if (evaluation.scope === 'fork') { + // Report-only: a ruleset requiring our signatures / non-fast-forward on a + // fork's default branch breaks syncing it from upstream. + getLogger().info(`Standards: ${owner}/${repo} is a fork — branch enforcement skipped (include_forks: false)`); + return { mode: 'fork', actions: [] }; + } + if (evaluation.scope === 'unenforceable') { + getLogger().warn(`Standards not enforceable on ${owner}/${repo}: ${evaluation.blocker}`); + } + for (const a of actions) getLogger().info(`standards ${owner}/${repo}: ${a}`); + return { mode: 'standards', scope: evaluation.scope, actions }; +} + /** * Apply branch-level enforcement (rulesets when enabled, legacy branch protection * otherwise). Rulesets work on empty repos; legacy does not — that's the whole @@ -105,7 +137,14 @@ async function configureRepository( const protectionConfig = mergePullRequestRules( getBranchProtectionConfig(repoInfo) || {} ); - await applyBranchEnforcement(octokit, owner, repo, defaultBranch, protectionConfig); + const std = getStandards(config); + if (std.enabled) { + await applyStandards(octokit, owner, repo, defaultBranch, protectionConfig, std, { + skipWorkflows: skipBranchScopedWork + }); + } else { + await applyBranchEnforcement(octokit, owner, repo, defaultBranch, protectionConfig); + } } const targetLabels = getTargetIssueLabels(); diff --git a/src/rulesets.js b/src/rulesets.js index 1aac029..3451833 100644 --- a/src/rulesets.js +++ b/src/rulesets.js @@ -110,10 +110,27 @@ function canonicalize(value) { export function rulesetsMatch(actual, desired) { if (!actual) return false; if (actual.enforcement !== desired.enforcement) return false; + if ((actual.target || 'branch') !== (desired.target || 'branch')) return false; + // A ruleset whose rules match but which lets admins bypass them is drift: + // "never merge around red" depends on the bypass list as much as the rules. + if (JSON.stringify(canonicalize(actual.bypass_actors || [])) !== JSON.stringify(canonicalize(desired.bypass_actors || []))) { + return false; + } if (JSON.stringify(canonicalize(actual.conditions)) !== JSON.stringify(canonicalize(desired.conditions))) { return false; } - if (JSON.stringify(normalizeRules(actual.rules)) !== JSON.stringify(normalizeRules(desired.rules))) { + // GitHub fills in parameters temper never sets (allowed_merge_methods, + // required_reviewers, ...). Compare only the parameters temper specifies, + // or every sync re-PUTs an unchanged ruleset. + const wanted = new Map((desired.rules || []).map((r) => [r.type, r.parameters])); + const projected = (actual.rules || []).map((r) => { + const want = wanted.get(r.type); + if (!want || !r.parameters) return r; + const parameters = {}; + for (const k of Object.keys(want)) parameters[k] = r.parameters[k]; + return { type: r.type, parameters }; + }); + if (JSON.stringify(normalizeRules(projected)) !== JSON.stringify(normalizeRules(desired.rules))) { return false; } return true; diff --git a/src/schema.js b/src/schema.js index 782a8b5..0a7898b 100644 --- a/src/schema.js +++ b/src/schema.js @@ -1,3 +1,5 @@ +import { validateStandards } from './standards-schema.js'; + export function validateConfig(config) { const errors = []; @@ -19,7 +21,14 @@ export function validateConfig(config) { if (config.settings?.merge) { const merge = config.settings.merge; - const boolFields = ['allow_merge_commit', 'allow_squash_merge', 'allow_rebase_merge', 'delete_branch_on_merge']; + const boolFields = [ + 'allow_merge_commit', + 'allow_squash_merge', + 'allow_rebase_merge', + 'delete_branch_on_merge', + 'allow_auto_merge', + 'allow_update_branch' + ]; for (const field of boolFields) { if (merge[field] !== undefined && typeof merge[field] !== 'boolean') { errors.push(`settings.merge.${field} must be a boolean`); @@ -222,5 +231,7 @@ export function validateConfig(config) { } } + errors.push(...validateStandards(config)); + return { valid: errors.length === 0, errors }; } diff --git a/src/standards-schema.js b/src/standards-schema.js new file mode 100644 index 0000000..7bd858a --- /dev/null +++ b/src/standards-schema.js @@ -0,0 +1,47 @@ +/** + * Validation for config.yml `standards:`. Kept free of imports so schema.js + * (loaded while config.js initialises) can use it without an import cycle. + */ + +export const SHA_RE = /^[0-9a-f]{40}$/; + +/** Config-level floor: a standards block that contradicts itself is an error. */ +export function validateStandards(config = {}) { + const errors = []; + const s = config?.standards; + if (s === undefined) return errors; + if (typeof s !== 'object' || s === null) return ['standards must be an object']; + if (!s.enabled) return errors; + + const bp = config?.branch_protection?.default || {}; + if (s.no_bypass !== false && bp.enforce_admins !== true) { + errors.push('standards.no_bypass requires branch_protection.default.enforce_admins: true'); + } + if (s.required_signatures !== false && bp.require_signed_commits !== true && bp.required_signatures !== true) { + errors.push('standards.required_signatures requires branch_protection.default.require_signed_commits: true'); + } + if (config?.settings?.merge?.allow_rebase_merge === true && s.required_signatures !== false) { + errors.push('settings.merge.allow_rebase_merge must be false with required signatures (rebase merges land unsigned commits)'); + } + if (s.merge_queue?.repos !== undefined && !Array.isArray(s.merge_queue.repos)) { + errors.push('standards.merge_queue.repos must be an array'); + } + for (const [i, wf] of (s.required_workflows || []).entries()) { + if (!wf?.name || !wf?.context || !wf?.path || !wf?.template) { + errors.push(`standards.required_workflows[${i}] needs name, context, path and template`); + } + if (wf?.pin !== undefined && wf.pin !== null && !SHA_RE.test(String(wf.pin))) { + errors.push(`standards.required_workflows[${i}].pin must be a 40-hex commit sha or null`); + } + } + for (const [name, r] of Object.entries(s.repos || {})) { + if (r?.required_checks !== undefined && !Array.isArray(r.required_checks)) { + errors.push(`standards.repos.${name}.required_checks must be an array`); + } + if (r?.report_only !== undefined && (typeof r.report_only !== 'string' || !r.report_only)) { + errors.push(`standards.repos.${name}.report_only must be a non-empty reason string`); + } + } + return errors; +} + diff --git a/src/standards.js b/src/standards.js new file mode 100644 index 0000000..8a65a42 --- /dev/null +++ b/src/standards.js @@ -0,0 +1,560 @@ +/** + * Org standards: one definition of "intended", one drift function, one apply. + * + * snapshot — everything GitHub reports about a repo's enforcement state + * (repo settings, classic protection, every ruleset in full, + * the effective rules on the default branch, workflow files). + * evaluate — snapshot × standards → findings (rule / intended / actual / + * drift / how it gets fixed). Pure; the dashboard, the CLI and + * the dry-run all read this, so there is no second definition. + * enforce — the writes that close the auto-fixable drift. The dry-run runs + * this exact function against an octokit that records writes + * instead of sending them, so the plan is what apply would do. + * + * What the rules mean (config.yml `standards:` documents the knobs): + * required_checks a non-empty set of required status checks that binds + * admins (rule 1: never merge around red). + * no_bypass classic protection has enforce_admins, and no ruleset + * on the default branch lists bypass actors (rule 1). + * required_signatures signed commits on the default branch (rule 2). + * update_branch "Always suggest updating PR branches" off; strict + * up-to-date off on merge-queue repos (rule 3). + * hostname_scan the hostname-scan stub is present and required (rule 4). + * tag_protection an active tag ruleset covers release tags (rule 5). + * pull_request, merge_queue, merge_methods, auto_merge supporting settings. + */ + +import fs from 'fs'; +import path from 'path'; +import { getLogger } from './logger.js'; +import { createConfigurationPR } from './github-api.js'; +import { + ensureRepositoryRuleset, + translateBranchProtectionToRuleset, + TEMPER_RULESET_NAME +} from './rulesets.js'; + +export const TEMPER_TAG_IMMUTABLE_NAME = 'temper-release-tags-immutable'; +export const TEMPER_TAG_CREATE_NAME = 'temper-release-tags-create'; +import { SHA_RE } from './standards-schema.js'; + +export { validateStandards } from './standards-schema.js'; + +// RepositoryRole 5 is the built-in "admin" role. +const DEFAULT_TAG_CREATE_BYPASS = [{ actor_id: 5, actor_type: 'RepositoryRole', bypass_mode: 'always' }]; + +export function getStandards(config = {}) { + const s = config?.standards || {}; + const tp = s.tag_protection || {}; + return { + enabled: s.enabled === true, + include_forks: s.include_forks === true, + required_signatures: s.required_signatures !== false, + no_bypass: s.no_bypass !== false, + fix_foreign_bypass: s.fix_foreign_bypass !== false, + require_status_checks: s.require_status_checks !== false, + require_pull_request: s.require_pull_request !== false, + merge_queue_repos: s.merge_queue?.repos || [], + queue_strict: s.merge_queue?.strict_up_to_date ?? false, + merge: config?.settings?.merge || {}, + tag_protection: { + enabled: tp.enabled !== false, + include: tp.include || ['refs/tags/v*'], + immutable_name: tp.immutable_name || TEMPER_TAG_IMMUTABLE_NAME, + create_name: tp.create_name || TEMPER_TAG_CREATE_NAME, + create_bypass_actors: tp.create_bypass_actors || DEFAULT_TAG_CREATE_BYPASS + }, + required_workflows: s.required_workflows || [], + repos: s.repos || {}, + managed_ruleset_name: config?.rulesets?.name || TEMPER_RULESET_NAME + }; +} + +// ── Snapshot ──────────────────────────────────────────────────────────────── + +async function getWithStatus(octokit, route, params) { + try { + const res = await octokit.request(route, params); + return { status: res.status || 200, body: res.data }; + } catch (err) { + return { status: err.status || 0, body: { message: err.message } }; + } +} + +/** Fetch a repo's enforcement snapshot (read-only). */ +export async function fetchSnapshot(octokit, owner, name) { + const repo = await getWithStatus(octokit, 'GET /repos/{owner}/{repo}', { owner, repo: name }); + const branch = repo.body?.default_branch || 'main'; + const p = { owner, repo: name, branch }; + const protection = await getWithStatus(octokit, 'GET /repos/{owner}/{repo}/branches/{branch}/protection', p); + const branchRules = await getWithStatus(octokit, 'GET /repos/{owner}/{repo}/rules/branches/{branch}', { + ...p, + per_page: 100 + }); + const list = await getWithStatus(octokit, 'GET /repos/{owner}/{repo}/rulesets', { + owner, + repo: name, + includes_parents: true, + per_page: 100 + }); + const items = []; + if (list.status === 200 && Array.isArray(list.body)) { + for (const rs of list.body) { + const full = await getWithStatus(octokit, 'GET /repos/{owner}/{repo}/rulesets/{ruleset_id}', { + owner, + repo: name, + ruleset_id: rs.id + }); + items.push(full.status === 200 ? full.body : { ...rs, _fetch_status: full.status }); + } + } + const workflows = await getWithStatus(octokit, 'GET /repos/{owner}/{repo}/contents/{path}', { + owner, + repo: name, + path: '.github/workflows', + ref: branch + }); + return { + name, + owner, + repo, + protection, + branchRules, + rulesets: { status: list.status, body: list.status === 200 ? undefined : list.body, items }, + workflows + }; +} + +/** + * Load a snapshot from a sweep cache directory (one dir per repo holding + * repo.json, protection.json, branch_rules.json, rulesets_list.json, + * ruleset_.json, workflows.json — each `{status, body}`). Lets the + * evaluation run offline on data gathered by any read-only sweep. + */ +export function loadSnapshotDir(dir, owner = 'pulseengine') { + const read = (f) => JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')); + const list = read('rulesets_list.json'); + const items = list.status === 200 ? (list.body || []).map((r) => { + const f = `ruleset_${r.id}.json`; + if (!fs.existsSync(path.join(dir, f))) return { ...r, _fetch_status: 0 }; + const full = read(f); + return full.status === 200 ? full.body : { ...r, _fetch_status: full.status }; + }) : []; + return { + name: path.basename(dir), + owner, + repo: read('repo.json'), + protection: read('protection.json'), + branchRules: read('branch_rules.json'), + rulesets: { status: list.status, body: list.status === 200 ? undefined : list.body, items }, + workflows: read('workflows.json') + }; +} + +// ── Derivation ────────────────────────────────────────────────────────────── + +const uniq = (xs) => [...new Set(xs)]; + +function globToRegExp(glob) { + const esc = (part) => part.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*'); + return new RegExp('^' + glob.split('**').map(esc).join('.*') + '$'); +} + +function tagRulesetCovers(rs, sampleRef) { + const inc = rs?.conditions?.ref_name?.include || []; + const exc = rs?.conditions?.ref_name?.exclude || []; + const hit = (pats) => pats.some((p) => p === '~ALL' || globToRegExp(p).test(sampleRef)); + return hit(inc) && !hit(exc); +} + +/** Facts derived from a snapshot, independent of what is intended. */ +export function deriveState(snap) { + const repo = snap.repo?.body || {}; + const prot = snap.protection?.status === 200 ? snap.protection.body : null; + const rules = snap.branchRules?.status === 200 && Array.isArray(snap.branchRules.body) ? snap.branchRules.body : []; + const rulesets = snap.rulesets?.items || []; + const ruleTypes = new Set(rules.map((r) => r.type)); + const applyingIds = new Set(rules.map((r) => r.ruleset_id).filter((x) => x !== undefined)); + const branchRulesets = rulesets.filter((r) => applyingIds.has(r.id)); + + const legacyContexts = prot + ? uniq([ + ...(prot.required_status_checks?.contexts || []), + ...(prot.required_status_checks?.checks || []).map((c) => c.context) + ]) + : []; + const enforceAdmins = prot ? prot.enforce_admins?.enabled === true : null; + + const bypassOf = (rs) => (Array.isArray(rs.bypass_actors) ? rs.bypass_actors : null); + const rulesetBypass = branchRulesets + .filter((rs) => (bypassOf(rs) === null ? true : bypassOf(rs).length > 0)) + .map((rs) => ({ + id: rs.id, + name: rs.name, + source_type: rs.source_type, + actors: bypassOf(rs), + current_user_can_bypass: rs.current_user_can_bypass + })); + const bypassIds = new Set(rulesetBypass.map((b) => b.id)); + + const rulesetContexts = []; + const bindingRulesetContexts = []; + let rulesetStrict = false; + for (const r of rules.filter((x) => x.type === 'required_status_checks')) { + const ctxs = (r.parameters?.required_status_checks || []).map((c) => c.context); + rulesetContexts.push(...ctxs); + if (!bypassIds.has(r.ruleset_id)) bindingRulesetContexts.push(...ctxs); + if (r.parameters?.strict_required_status_checks_policy) rulesetStrict = true; + } + const bindingContexts = uniq([...(enforceAdmins ? legacyContexts : []), ...bindingRulesetContexts]); + + const workflowFiles = + snap.workflows?.status === 200 && Array.isArray(snap.workflows.body) + ? snap.workflows.body.map((f) => f.path || `.github/workflows/${f.name}`) + : []; + + // Active branch rulesets whose ref include list is empty match no branch: + // they look like protection in the UI but enforce nothing. + const inertRulesets = rulesets + .filter((r) => (r.target || 'branch') === 'branch' && r.enforcement === 'active' && + Array.isArray(r.conditions?.ref_name?.include) && r.conditions.ref_name.include.length === 0) + .map((r) => r.name); + const tagRulesets = rulesets.filter((r) => r.target === 'tag'); + // Release tags are protected when (a) an active, bypass-free ruleset makes + // them immutable (update + deletion) and (b) an active ruleset restricts + // their creation — whose bypass list IS the allowlist of who may tag. + const coveringTags = tagRulesets.filter((r) => r.enforcement === 'active' && tagRulesetCovers(r, 'refs/tags/v1.0.0')); + const ruleIn = (r, t) => (r.rules || []).some((x) => x.type === t); + const releaseTagsImmutable = coveringTags.some( + (r) => Array.isArray(r.bypass_actors) && r.bypass_actors.length === 0 && ruleIn(r, 'update') && ruleIn(r, 'deletion') + ); + const releaseTagCreateRestricted = coveringTags.some((r) => ruleIn(r, 'creation')); + + const forbidden = [snap.protection, snap.rulesets].some( + (x) => x?.status === 403 && /upgrade to github pro|make this repository public/i.test(x?.body?.message || '') + ); + + return { + name: snap.name, + branch: repo.default_branch || 'main', + private: repo.private === true, + fork: repo.fork === true, + archived: repo.archived === true, + planBlocked: forbidden, + protectionStatus: snap.protection?.status, + hasLegacy: Boolean(prot), + enforceAdmins, + legacyContexts, + legacyStrict: prot ? prot.required_status_checks?.strict === true : false, + legacySignatures: prot ? prot.required_signatures?.enabled === true : false, + legacyPullRequest: prot ? Boolean(prot.required_pull_request_reviews) : false, + legacyLinear: prot ? prot.required_linear_history?.enabled === true : false, + ruleTypes: [...ruleTypes].sort(), + rulesetContexts: uniq(rulesetContexts), + bindingContexts, + allContexts: uniq([...legacyContexts, ...rulesetContexts]), + rulesetStrict, + rulesetBypass, + inertRulesets, + branchRulesets: branchRulesets.map((r) => ({ id: r.id, name: r.name, source_type: r.source_type })), + mergeQueue: ruleTypes.has('merge_queue'), + signatures: (prot ? prot.required_signatures?.enabled === true : false) || ruleTypes.has('required_signatures'), + pullRequest: (prot ? Boolean(prot.required_pull_request_reviews) : false) || ruleTypes.has('pull_request'), + linearHistory: (prot ? prot.required_linear_history?.enabled === true : false) || ruleTypes.has('required_linear_history'), + workflowFiles, + tagRulesets: tagRulesets.map((r) => ({ + id: r.id, + name: r.name, + enforcement: r.enforcement, + include: r.conditions?.ref_name?.include || [], + rules: (r.rules || []).map((x) => x.type), + bypass_actors: r.bypass_actors ?? null + })), + releaseTagsImmutable, + releaseTagCreateRestricted, + settings: { + allow_merge_commit: repo.allow_merge_commit, + allow_squash_merge: repo.allow_squash_merge, + allow_rebase_merge: repo.allow_rebase_merge, + allow_auto_merge: repo.allow_auto_merge, + allow_update_branch: repo.allow_update_branch, + delete_branch_on_merge: repo.delete_branch_on_merge + } + }; +} + +// ── Evaluation ────────────────────────────────────────────────────────────── + +/** The required-status-check contexts temper intends for a repo. */ +export function intendedContexts(state, std) { + const override = std.repos?.[state.name]?.required_checks; + const base = Array.isArray(override) ? override : state.allContexts; + const wf = std.required_workflows + .filter((w) => state.workflowFiles.includes(w.path)) + .map((w) => w.context); + return uniq([...base, ...wf]); +} + +function finding(rule, intended, actual, drift, fix = null, note = '') { + return { rule, intended, actual, drift, fix: drift ? fix : null, note }; +} + +const fmt = (v) => (v === undefined || v === null ? 'unknown' : String(v)); + +/** + * Evaluate one repo snapshot against the standards. + * `fix` is 'auto' (temper apply closes it), 'owner' (a decision or a name + * temper cannot invent), or 'blocked' (a precondition is missing). + */ +export function evaluateRepo(snap, std) { + const st = deriveState(snap); + const out = { repo: st.name, scope: 'enforced', blocker: null, findings: [], state: st }; + + if (st.archived) { + out.scope = 'archived'; + return out; + } + if (st.fork && !std.include_forks) { + out.scope = 'fork'; + out.blocker = 'fork of an upstream project: excluded from the floor (standards.include_forks: false)'; + return out; + } + const f = out.findings; + const queue = std.merge_queue_repos.includes(st.name); + // A repo whose own automation the floor would break: report, never enforce. + const hold = std.repos?.[st.name]?.report_only; + if (hold) { + out.scope = 'held'; + out.blocker = `report-only: ${hold}`; + } + + // Repo settings work on every plan, so they are evaluated even when blocked. + const m = std.merge; + for (const key of ['allow_merge_commit', 'allow_squash_merge', 'allow_rebase_merge']) { + if (m[key] !== undefined) { + f.push(finding('merge_methods', `${key}=${m[key]}`, `${key}=${fmt(st.settings[key])}`, st.settings[key] !== m[key], 'auto')); + } + } + if (m.allow_auto_merge !== undefined) { + f.push(finding('auto_merge', fmt(m.allow_auto_merge), fmt(st.settings.allow_auto_merge), + st.settings.allow_auto_merge !== m.allow_auto_merge, st.planBlocked ? 'blocked' : 'auto', + st.planBlocked ? 'auto-merge on a private repo needs a paid plan' : '')); + } + if (m.allow_update_branch !== undefined) { + f.push(finding('update_branch', `allow_update_branch=${m.allow_update_branch}`, + `allow_update_branch=${fmt(st.settings.allow_update_branch)}`, + st.settings.allow_update_branch !== m.allow_update_branch, 'auto')); + } + + if (st.planBlocked) { + out.scope = 'unenforceable'; + out.blocker = 'private repo on GitHub Free: branch protection and rulesets return 403'; + return out; + } + + const contexts = intendedContexts(st, std); + if (std.require_status_checks) { + const empty = st.allContexts.length === 0; + f.push(finding('required_checks', + contexts.length ? `non-empty, binding (${contexts.length})` : 'non-empty, binding', + empty ? 'none' : `${st.allContexts.length} required, ${st.bindingContexts.length} binding admins`, + contexts.length === 0 || st.bindingContexts.length < contexts.length, + contexts.length === 0 ? 'owner' : 'auto', + contexts.length === 0 ? 'no required checks to carry over: owner must name them (standards.repos..required_checks)' : '')); + } + + if (std.no_bypass) { + const legacyBypass = st.hasLegacy && st.enforceAdmins !== true; + const parts = []; + if (st.hasLegacy) parts.push(`enforce_admins=${fmt(st.enforceAdmins)}`); + for (const b of st.rulesetBypass) { + const who = b.actors === null ? 'unknown actors' : b.actors.map((a) => `${a.actor_type}:${a.actor_id ?? '-'}/${a.bypass_mode}`).join(','); + parts.push(`ruleset "${b.name}" bypass=${who}`); + } + const foreign = st.rulesetBypass.filter((b) => b.name !== std.managed_ruleset_name); + f.push(finding('no_bypass', 'enforce_admins=true, no bypass actors', + parts.join('; ') || 'no classic protection, no bypass actors', + legacyBypass || st.rulesetBypass.length > 0, + foreign.some((b) => b.source_type && b.source_type !== 'Repository') ? 'owner' + : (foreign.length && !std.fix_foreign_bypass ? 'owner' : 'auto'), + st.inertRulesets.length ? `inert ruleset(s) matching no branch: ${st.inertRulesets.map((n) => `"${n}"`).join(', ')}` : '')); + } + + if (std.required_signatures) { + f.push(finding('required_signatures', 'true', fmt(st.signatures), !st.signatures, 'auto')); + } + if (std.require_pull_request) { + f.push(finding('pull_request', 'true', fmt(st.pullRequest), !st.pullRequest, 'auto')); + } + + // Strict up-to-date is only switched off where a queue actually runs: the + // queue is what re-tests against the latest main. Elsewhere temper keeps the + // repo's current strictness (turning it on costs ~N^2/2 CI runs; turning it + // off without a queue merges PRs never tested against main). + const strictActual = st.legacyStrict || st.rulesetStrict; + if (st.mergeQueue && (st.allContexts.length > 0 || contexts.length > 0)) { + const want = std.queue_strict; + f.push(finding('update_branch', `strict_up_to_date=${want} (queue repo)`, + `strict_up_to_date=${strictActual}`, strictActual !== want, + st.rulesetStrict ? 'owner' : 'auto', + strictActual ? 'the queue already tests against the latest main; strict only adds update-branch pressure' : '')); + } else if (queue && strictActual) { + f.push(finding('update_branch', 'strict_up_to_date=false once the queue is live', + 'strict_up_to_date=true (no queue yet)', false, null, 'switch strict off in the same change that turns the queue on')); + } + + f.push(finding('merge_queue', queue ? 'present' : 'not required', st.mergeQueue ? 'present' : 'absent', + queue && !st.mergeQueue, 'owner', + queue && !st.mergeQueue ? 'per-repo rollout: every required workflow needs a merge_group trigger first' : '')); + + for (const wf of std.required_workflows) { + const present = st.workflowFiles.includes(wf.path); + const required = st.allContexts.includes(wf.context); + const pinned = SHA_RE.test(String(wf.pin || '')); + f.push(finding(wf.name, 'stub present and required', `stub ${present ? 'present' : 'absent'}, ${required ? 'required' : 'not required'}`, + !present || !required, !present && !pinned ? 'blocked' : 'auto', + !present && !pinned ? `standards.required_workflows "${wf.name}".pin is unset (pin to a merged temper sha)` : '')); + } + + if (std.tag_protection.enabled) { + const desc = st.tagRulesets.length + ? st.tagRulesets.map((t) => `"${t.name}" ${t.enforcement} [${t.include.join(',')}] rules=${t.rules.join('+')}`).join('; ') + : 'none'; + const missing = [!st.releaseTagsImmutable && 'immutable (update+deletion, no bypass)', + !st.releaseTagCreateRestricted && 'creation restricted'].filter(Boolean); + f.push(finding('tag_protection', `${std.tag_protection.include.join(',')}: immutable + creation restricted`, + desc, missing.length > 0, 'auto', missing.length ? `missing: ${missing.join('; ')}` : '')); + } + + return out; +} + +export function hasDrift(evaluation) { + return evaluation.findings.some((x) => x.drift); +} + +// ── Enforcement ───────────────────────────────────────────────────────────── + +/** Release tags can never be moved or deleted — by anyone. */ +export function buildTagImmutableRuleset(std) { + return { + name: std.tag_protection.immutable_name, + target: 'tag', + enforcement: 'active', + conditions: { ref_name: { include: std.tag_protection.include, exclude: [] } }, + rules: [{ type: 'deletion' }, { type: 'non_fast_forward' }, { type: 'update' }], + bypass_actors: [] + }; +} + +/** Only the bypass list (default: repo admins) may create release tags. */ +export function buildTagCreateRuleset(std) { + return { + name: std.tag_protection.create_name, + target: 'tag', + enforcement: 'active', + conditions: { ref_name: { include: std.tag_protection.include, exclude: [] } }, + rules: [{ type: 'creation' }], + bypass_actors: std.tag_protection.create_bypass_actors + }; +} + +export function buildManagedRuleset(protectionConfig, state, std) { + const contexts = intendedContexts(state, std); + const strict = state.mergeQueue ? std.queue_strict : state.legacyStrict || state.rulesetStrict; + return translateBranchProtectionToRuleset( + { + ...protectionConfig, + require_signed_commits: std.required_signatures || protectionConfig.require_signed_commits, + required_status_checks: { + strict, + contexts + } + }, + std.managed_ruleset_name + ); +} + +export function renderWorkflowTemplate(text, { pin, defaultBranch }) { + return text.replace(/__TEMPER_PIN__/g, pin).replace(/__TEMPER_DEFAULT_BRANCH__/g, defaultBranch); +} + +/** + * Close the auto-fixable drift for one repo. Never replaces classic protection + * wholesale (that would wipe per-repo contexts); every legacy change uses the + * narrow sub-resource endpoint. + * + * @returns {Promise} human-readable list of actions taken + */ +export async function enforceStandards(octokit, owner, protectionConfig, snap, std, { readTemplate } = {}) { + const evaluation = evaluateRepo(snap, std); + const st = evaluation.state; + const repo = st.name; + const branch = st.branch; + const done = []; + if (evaluation.scope !== 'enforced') return { evaluation, actions: done }; + + if (st.hasLegacy) { + if (std.no_bypass && st.enforceAdmins !== true) { + await octokit.request('POST /repos/{owner}/{repo}/branches/{branch}/protection/enforce_admins', { owner, repo, branch }); + done.push('classic protection: enforce_admins on'); + } + if (std.required_signatures && !st.legacySignatures) { + await octokit.request('POST /repos/{owner}/{repo}/branches/{branch}/protection/required_signatures', { owner, repo, branch }); + done.push('classic protection: required_signatures on'); + } + if (st.mergeQueue && st.legacyStrict && std.queue_strict === false) { + await octokit.request('PATCH /repos/{owner}/{repo}/branches/{branch}/protection/required_status_checks', { + owner, repo, branch, strict: false + }); + done.push('classic protection: strict up-to-date off (merge-queue repo)'); + } + } + + const managed = buildManagedRuleset(protectionConfig, st, std); + const r = await ensureRepositoryRuleset(octokit, owner, repo, managed); + if (r.action !== 'unchanged') done.push(`ruleset "${managed.name}": ${r.action}`); + + if (std.no_bypass && std.fix_foreign_bypass) { + for (const b of st.rulesetBypass) { + if (b.name === std.managed_ruleset_name || (b.source_type && b.source_type !== 'Repository')) continue; + const full = (snap.rulesets.items || []).find((x) => x.id === b.id); + await octokit.request('PUT /repos/{owner}/{repo}/rulesets/{ruleset_id}', { + owner, + repo, + ruleset_id: b.id, + name: full.name, + target: full.target, + enforcement: full.enforcement, + conditions: full.conditions, + rules: full.rules, + bypass_actors: [] + }); + done.push(`ruleset "${b.name}": bypass actors removed`); + } + } + + if (std.tag_protection.enabled) { + const wanted = [ + !st.releaseTagsImmutable && buildTagImmutableRuleset(std), + !st.releaseTagCreateRestricted && buildTagCreateRuleset(std) + ].filter(Boolean); + for (const desired of wanted) { + const t = await ensureRepositoryRuleset(octokit, owner, repo, desired); + if (t.action !== 'unchanged') done.push(`ruleset "${desired.name}": ${t.action}`); + } + } + + for (const wf of std.required_workflows) { + if (st.workflowFiles.includes(wf.path)) continue; + if (!SHA_RE.test(String(wf.pin || ''))) { + getLogger().info(`Skipping ${wf.name} stub for ${owner}/${repo}: pin unset`); + continue; + } + const content = renderWorkflowTemplate(readTemplate(wf.template), { pin: wf.pin, defaultBranch: branch }); + await createConfigurationPR(octokit, owner, repo, wf.path, content, `ci: add ${wf.name} (temper standard)`); + done.push(`PR: add ${wf.path}`); + } + + return { evaluation, actions: done }; +} diff --git a/templates/workflows/hostname-scan.yml b/templates/workflows/hostname-scan.yml new file mode 100644 index 0000000..4f6f673 --- /dev/null +++ b/templates/workflows/hostname-scan.yml @@ -0,0 +1,34 @@ +# Managed by temper (pulseengine/temper). Edits are overwritten on sync; +# per-repo exceptions go in .github/hostname-scan-allow.txt instead. +# +# The job name `hostname-scan` is a required status check. It must run on +# merge_group too, and must never get a `paths` filter: a required check that +# does not report hangs the PR / queue entry. +name: hostname-scan + +on: + pull_request: + merge_group: + push: + branches: [__TEMPER_DEFAULT_BRANCH__] + +permissions: + contents: read + +concurrency: + group: hostname-scan-${{ github.head_ref || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + hostname-scan: + name: hostname-scan + runs-on: ${{ github.event.pull_request.head.repo.fork && 'ubuntu-latest' || fromJSON('["self-hosted","linux","x64","light"]') }} + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: pulseengine/temper/.github/actions/hostname-scan@__TEMPER_PIN__ + with: + denylist: ${{ secrets.HOSTNAME_SCAN_DENYLIST }}