diff --git a/docs/node-fleet-setup.md b/docs/node-fleet-setup.md index 3843d08..7aa9818 100644 --- a/docs/node-fleet-setup.md +++ b/docs/node-fleet-setup.md @@ -46,9 +46,9 @@ Use a branch ref for live defaults or a full commit SHA for fixed policy. The inventory ref used to fetch this list is independent of the defaults ref inside it. See [shared defaults](shared-defaults.md) for inheritance rules. -This repository's [inventory](../defaults/node-repositories.json) lists ten public -repositories: Hydra, OmegaConf, and eight personal Node projects. Repositories -that are not installed yet remain listed so their missing setup stays visible. +This repository's [inventory](../defaults/node-repositories.json) lists Hydra and +OmegaConf. Repositories that are not installed yet can also remain in an +inventory so their missing setup stays visible. Other maintainers can keep their own inventories in their own repositories; no registration here is required. @@ -107,7 +107,12 @@ secrets: The optional token goes only to the scanner. The reporter uses the host's built-in token. Shared defaults sources remain public, as required by the -maintenance resolver. No private key or secret content is inspected. +maintenance resolver. Pinned central workflow repositories must also be public: +the scanner reads their metadata and files without authentication. The optional +token remains in use for the inventory, listed repositories, and their Actions +status. This avoids applying the central organization's token policies to a +credential intended for your private repositories. No private key or secret +content is inspected. ## 3. Run and inspect results diff --git a/node/runtime/fleet.py b/node/runtime/fleet.py index e51b500..b0f55b7 100644 --- a/node/runtime/fleet.py +++ b/node/runtime/fleet.py @@ -66,6 +66,8 @@ def __init__(self, token=""): self.defaults_lock = Lock() # Defaults remain public and unauthenticated, as in the individual audit. self.cached_defaults = functools.cache(CONFIGURATION["public_read"]) + # Public workflow metadata must not inherit private repository credentials. + self.public_github = GitHub() if token else self def read_defaults(self, url, accept="application/json"): # Resolve each shared ref once, even when several repositories need it. @@ -442,7 +444,7 @@ def check_caller(root, config, selected, github): raise ValueError( "Caller must serialize runs with concurrency and cancel-in-progress: false" ) - source = github.snapshot(match[1], match[2]) + source = github.public_github.snapshot(match[1], match[2]) contract = CONFIGURATION["json_object"]( source.read("node/contract.json"), "pinned Node contract" ) diff --git a/node/tests/test_fleet_setup.py b/node/tests/test_fleet_setup.py index 25207f1..edacdd7 100644 --- a/node/tests/test_fleet_setup.py +++ b/node/tests/test_fleet_setup.py @@ -3,9 +3,12 @@ import base64 import copy import hashlib +import io import json import runpy import subprocess +import urllib.error +import urllib.request from concurrent.futures import ThreadPoolExecutor from pathlib import Path from threading import Barrier, Event, Lock @@ -884,6 +887,58 @@ def opener(handler): assert observed[0].get_header("Authorization") == "Bearer test-token" +@pytest.mark.parametrize("central_error", [None, 403]) +def test_public_workflow_reads_do_not_inherit_private_monitoring_token( + monkeypatch, central_error +): + server = GitHub("npm") + observed = [] + + class Opener: + def open(self, request, timeout): + path = urlparse(request.full_url).path.lstrip("/") + if urlparse(request.full_url).query: + path += "?" + urlparse(request.full_url).query + authorization = request.get_header("Authorization") + observed.append((path, authorization)) + if path.startswith("repos/owner/actions"): + # Reproduce an organization rejecting an otherwise valid PAT. + if authorization or central_error: + raise urllib.error.HTTPError( + request.full_url, 403, "organization policy", {}, None + ) + else: + # Inventory, project files and Actions state still need the PAT. + assert authorization == "Bearer test-token" + return io.BytesIO(json.dumps(server.get(path)).encode()) + + monkeypatch.setattr(urllib.request, "build_opener", lambda handler: Opener()) + github = FLEET["GitHub"]("test-token") + github.read_defaults = server.public + source = { + "repository": "owner/settings", + "path": "defaults/repos.json", + "ref": "main", + } + report = FLEET["check_fleet"](github, source) + result = report["results"][0] + assert result["status"] == ("unverified" if central_error else "ok") + central_reads = [ + item for item in observed if item[0].startswith("repos/owner/actions") + ] + assert central_reads and all(auth is None for _, auth in central_reads) + if central_error: + assert "HTTP 403" in result["diagnostic"] + assert len(central_reads) == 1 # No retry with the private credential. + else: + assert any("git/blobs/" in path for path, _ in central_reads) + assert any("actions/workflows/" in path for path, _ in observed) + # Repeated installations share the unauthenticated pinned snapshot. + before = len(observed) + assert FLEET["check_fleet"](github, source)["status"] == "ok" + assert len(observed) == before + + def test_pinned_workflow_must_support_the_resolved_toolchain(): github = GitHub() support = json.loads(github.repos["owner/actions"]["node/runtime/toolchain.json"])