From 61b112f219ffdfd1b39adfa7e92f9c0085eb71fa Mon Sep 17 00:00:00 2001 From: Harish Seshadri Date: Fri, 25 Sep 2026 08:17:02 -0700 Subject: [PATCH 1/4] feat(fleet): enforce per-module required-minimum pin floors Consumers pin central Dagger modules at exact SHAs, and fleet policy checked that each pin was well-formed but not that it was new enough. Repos could stay pinned below a mandatory fix (dd19871, hseshadr/ci#46) without anyone noticing, which blocked the aml-filter release. REQUIRED_MINIMUM in fleet_policy.py holds a reviewed floor per central module, starting with portfolio-foundation -> dd19871. The GitHub reader compares every floored module revision in a consumer's resolved Dagger graph against the floor (compare/...) and against central main (compare/...main). Both must be ahead or identical. Otherwise, including when there is no common history or the evidence is missing, the scan reports pin-below-required-minimum and fails. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015oBArfm762nN1r4F4Fst5a --- .dagger/src/ci/fleet_policy.py | 73 +++++++++++++++++++ .dagger/src/ci/github_fleet.py | 45 +++++++++++- .dagger/tests/test_fleet_minimum_pin.py | 97 +++++++++++++++++++++++++ .dagger/tests/test_fleet_policy.py | 32 ++++++++ .dagger/tests/test_github_fleet.py | 43 +++++++++++ CHANGELOG.md | 3 + README.md | 2 + docs/dagger-modules.md | 25 +++++++ 8 files changed, 318 insertions(+), 2 deletions(-) create mode 100644 .dagger/tests/test_fleet_minimum_pin.py diff --git a/.dagger/src/ci/fleet_policy.py b/.dagger/src/ci/fleet_policy.py index 565750a..3adb7a2 100644 --- a/.dagger/src/ci/fleet_policy.py +++ b/.dagger/src/ci/fleet_policy.py @@ -103,6 +103,13 @@ re.IGNORECASE, ) DYNAMIC_SECRET_REFERENCE: Final = re.compile(r"secrets\s*\[\s*(?!['\"])") +# Oldest hseshadr/ci commit each central module may be pinned at. Raise a floor in the same PR +# that ships a fix every consumer must run. cloudflare-pages and python-package embed +# portfolio-foundation at their own revision, so the foundation floor covers them too. +REQUIRED_MINIMUM: Final[Mapping[str, str]] = MappingProxyType( + {"portfolio-foundation": "dd19871486588b1582e432b7bc1f2cfffb296340"} +) +DESCENDANT_STATUSES: Final = frozenset(("ahead", "identical")) APPROVED_PUBLISHER_MODULES: Final = frozenset(("github.com/hseshadr/ci/modules/npm-publisher",)) type Scalar = str | bool | int type RemoteIdentity = tuple[str, str, str, str] @@ -284,6 +291,20 @@ class RepositoryExpectation: grandfathered_until: date | None = None +@validated_dataclass(config=BOUNDARY_CONFIG) +class PinAncestry: + """GitHub compare evidence for one central module pin. + + ``floor_status`` is ``compare/...`` and ``main_status`` is + ``compare/...main``; ``unrelated`` records a compare without a common ancestor. + """ + + floor: str + pin: str + floor_status: str + main_status: str + + @validated_dataclass(config=BOUNDARY_CONFIG) class RepositorySnapshot: """All authoritative source, protection, and integration evidence.""" @@ -301,6 +322,7 @@ class RepositorySnapshot: missing_dagger_configs: tuple[str, ...] = Field(default_factory=tuple) environments: tuple[DeploymentEnvironment, ...] = Field(default_factory=tuple) repository_secret_names: tuple[str, ...] = Field(default_factory=tuple) + pin_ancestry: tuple[PinAncestry, ...] = Field(default_factory=tuple) @dataclass(frozen=True) @@ -431,6 +453,7 @@ def validate_dagger_graph( if graph_has_cycle(snapshot.dagger_configs): findings.append(finding("dagger-dependency-cycle", "dagger.json", "dependency cycle")) findings.extend(validate_shared_requirement(snapshot.dagger_configs, expectation)) + findings.extend(validate_minimum_pins(snapshot.dagger_configs, snapshot.pin_ancestry)) return tuple(findings) @@ -1820,3 +1843,53 @@ def validate_control_plane(snapshot: RepositorySnapshot) -> tuple[PolicyFinding, def allowed_check_apps() -> frozenset[str]: """Return execution ownership plus the reviewed advisory-only integration.""" return frozenset(("github-actions", "gitguardian")) + + +def required_minimum_pins(configs: tuple[DaggerConfig, ...]) -> tuple[tuple[str, str], ...]: + """Return each distinct (floor, pin) pair that needs ancestry evidence.""" + pairs = (floored_pin(config) for config in configs) + return tuple(dict.fromkeys(pair for pair in pairs if pair is not None)) + + +def floored_pin(config: DaggerConfig) -> tuple[str, str] | None: + """Return (floor, pin) for one exact hseshadr/ci module config with a floor.""" + remote = parse_pinned_remote(config.identity) + if remote is None or remote[:2] != ("hseshadr", "ci"): + return None + floor = REQUIRED_MINIMUM.get(remote[2].removeprefix("modules/")) + return None if floor is None else (floor, remote[3]) + + +def validate_minimum_pins( + configs: tuple[DaggerConfig, ...], ancestry: tuple[PinAncestry, ...] +) -> tuple[PolicyFinding, ...]: + """Require every floored central pin to be on main and descend from its floor.""" + evidence = {(item.floor, item.pin): item for item in ancestry} + pairs = required_minimum_pins(configs) + return tuple( + minimum_finding(floor, pin, evidence.get((floor, pin))) + for floor, pin in pairs + if not pin_meets_floor(evidence.get((floor, pin))) + ) + + +def minimum_finding(floor: str, pin: str, item: PinAncestry | None) -> PolicyFinding: + """Name the stale central pin and the ancestry fact that failed.""" + return finding("pin-below-required-minimum", pin, minimum_message(floor, item)) + + +def pin_meets_floor(item: PinAncestry | None) -> bool: + """Accept only a pin at or after its floor that main also contains.""" + if item is None: + return False + return {item.floor_status, item.main_status} <= DESCENDANT_STATUSES + + +def minimum_message(floor: str, item: PinAncestry | None) -> str: + """Explain which ancestry fact failed without guessing missing evidence.""" + if item is None: + return f"no ancestry evidence against required minimum {floor}" + return ( + f"must descend from required minimum {floor} on main " + f"(floor...pin={item.floor_status}, pin...main={item.main_status})" + ) diff --git a/.dagger/src/ci/github_fleet.py b/.dagger/src/ci/github_fleet.py index 595a367..f297dbe 100644 --- a/.dagger/src/ci/github_fleet.py +++ b/.dagger/src/ci/github_fleet.py @@ -20,12 +20,14 @@ DaggerConfig, DaggerDependency, DeploymentEnvironment, + PinAncestry, Protection, RepositorySnapshot, RequiredCheck, SourceFile, local_dependency_path, parse_pinned_remote, + required_minimum_pins, ) BOUNDARY_CONFIG: Final = ConfigDict(frozen=True, extra="ignore", strict=True) @@ -33,6 +35,7 @@ MODULE_PREFIXES: Final = (".dagger/src/", "dagger/src/") HTTP_OK: Final = 200 HTTP_NOT_FOUND: Final = 404 +CENTRAL_REPOSITORY: Final = "repos/hseshadr/ci" @dataclass(frozen=True) @@ -155,6 +158,7 @@ class SourceEvidence: sources: tuple[SourceFile, ...] configs: tuple[DaggerConfig, ...] missing: tuple[str, ...] + ancestry: tuple[PinAncestry, ...] @dataclass(frozen=True) @@ -175,6 +179,13 @@ class CommitPayload: sha: str +@validated_dataclass(config=BOUNDARY_CONFIG) +class ComparePayload: + """GitHub compare ancestry status (ahead, behind, identical, or diverged).""" + + status: str + + @validated_dataclass(config=BOUNDARY_CONFIG) class TreeEntry: """One recursive Git tree entry.""" @@ -380,7 +391,32 @@ def read_source_evidence( identity = f"github.com/{owner}/{name}@{sha}" location = ModuleLocation(base, sha, "dagger.json", identity) configs, missing = read_dagger_graph(transport, location, tree) - return SourceEvidence(name, sha, sources, configs, missing) + ancestry = read_pin_ancestry(transport, configs) + return SourceEvidence(name, sha, sources, configs, missing, ancestry) + + +def read_pin_ancestry( + transport: GitHubTransport, configs: tuple[DaggerConfig, ...] +) -> tuple[PinAncestry, ...]: + """Compare every floored central pin against its floor and central main.""" + return tuple( + PinAncestry( + floor=floor, + pin=pin, + floor_status=read_compare_status(transport, floor, pin), + main_status=read_compare_status(transport, pin, "main"), + ) + for floor, pin in required_minimum_pins(configs) + ) + + +def read_compare_status(transport: GitHubTransport, base: str, head: str) -> str: + """Return GitHub's ancestry status; a 404 means no common history.""" + path = f"{CENTRAL_REPOSITORY}/compare/{base}...{head}?per_page=1" + response = transport.get(path) + if response.status == HTTP_NOT_FOUND: + return "unrelated" + return parse_model(response, path, ComparePayload).status def assert_main_stable(transport: GitHubTransport, base: str, expected_sha: str) -> None: @@ -404,7 +440,11 @@ def read_snapshot_parts( def read_model[T](transport: GitHubTransport, path: str, model: type[T]) -> T: """Validate one successful GitHub response against its exact schema.""" - response = transport.get(path) + return parse_model(transport.get(path), path, model) + + +def parse_model[T](response: HttpResponse, path: str, model: type[T]) -> T: + """Validate one already-read GitHub response against its exact schema.""" if response.status != HTTP_OK: raise access_error(path, response.status) try: @@ -751,6 +791,7 @@ def create_snapshot(parts: SnapshotParts, projection: SnapshotProjection) -> Rep missing_dagger_configs=evidence.missing, environments=parts.environments, repository_secret_names=parts.repository_secrets, + pin_ancestry=evidence.ancestry, ) diff --git a/.dagger/tests/test_fleet_minimum_pin.py b/.dagger/tests/test_fleet_minimum_pin.py new file mode 100644 index 0000000..f0df6ff --- /dev/null +++ b/.dagger/tests/test_fleet_minimum_pin.py @@ -0,0 +1,97 @@ +"""Required-minimum floors for central Dagger module pins.""" + +from __future__ import annotations + +import pytest + +from ci.fleet_policy import ( + REQUIRED_MINIMUM, + DaggerConfig, + PinAncestry, + validate_minimum_pins, +) + +FLOOR = "dd19871486588b1582e432b7bc1f2cfffb296340" +NEWER = "9d491851" + "0" * 32 +OLDER = "1963264" + "0" * 33 +FOUNDATION = "github.com/hseshadr/ci/modules/portfolio-foundation@" + + +def _config(identity: str) -> DaggerConfig: + path = identity.partition("hseshadr/ci/")[2].rpartition("@")[0] + "/dagger.json" + return DaggerConfig(identity=identity, path=path, name="shared", engine_version="v0.21.8") + + +def _codes(pin: str, *ancestry: PinAncestry) -> tuple[str, ...]: + configs = (_config(FOUNDATION + pin),) + return tuple(item.code for item in validate_minimum_pins(configs, ancestry)) + + +def test_should_floor_foundation_at_rerun_skew_fix_when_reviewed() -> None: + # Given the reviewed mandatory fix hseshadr/ci#46 (dd19871) + # Then the floor is that exact literal commit and no other module has one + assert dict(REQUIRED_MINIMUM) == {"portfolio-foundation": FLOOR} + + +def test_should_accept_pin_when_equal_to_floor() -> None: + # Given a consumer pinned exactly at the floor, which is on main + evidence = PinAncestry(floor=FLOOR, pin=FLOOR, floor_status="identical", main_status="ahead") + + # When the floor is evaluated, then no finding is reported + assert _codes(FLOOR, evidence) == () + + +def test_should_accept_pin_when_descended_from_floor() -> None: + # Given a consumer pinned at a main commit newer than the floor + evidence = PinAncestry(floor=FLOOR, pin=NEWER, floor_status="ahead", main_status="identical") + + # When the floor is evaluated, then no finding is reported + assert _codes(NEWER, evidence) == () + + +def test_should_reject_pin_when_older_than_floor() -> None: + # Given a consumer pinned at a main commit that predates the mandatory fix + evidence = PinAncestry(floor=FLOOR, pin=OLDER, floor_status="behind", main_status="ahead") + + # Then the stale release gate is a failing finding + assert _codes(OLDER, evidence) == ("pin-below-required-minimum",) + + +@pytest.mark.parametrize( + ("floor_status", "main_status"), + [("ahead", "diverged"), ("diverged", "diverged"), ("unrelated", "unrelated")], +) +def test_should_reject_pin_when_off_main_or_unrelated(floor_status: str, main_status: str) -> None: + # Given a pin on an unmerged branch, a diverged branch, or unrelated history + pin = "e" * 40 + evidence = PinAncestry(floor=FLOOR, pin=pin, floor_status=floor_status, main_status=main_status) + + # Then it cannot satisfy the floor + assert _codes(pin, evidence) == ("pin-below-required-minimum",) + + +def test_should_fail_closed_when_ancestry_evidence_is_absent() -> None: + # Given a floored module pin whose ancestry was never read + # Then the missing evidence is itself the finding + assert _codes("e" * 40) == ("pin-below-required-minimum",) + + +def test_should_ignore_module_when_it_has_no_floor() -> None: + # Given a central module with no reviewed floor and no ancestry evidence + configs = (_config("github.com/hseshadr/ci/modules/unknown-module@" + "e" * 40),) + + # Then there is nothing to compare and no finding + assert validate_minimum_pins(configs, ()) == () + + +def test_should_ignore_config_when_consumer_owned() -> None: + # Given the consumer's own root config shares the module directory name + config = DaggerConfig( + identity="github.com/hseshadr/example/modules/portfolio-foundation@" + "e" * 40, + path="modules/portfolio-foundation/dagger.json", + name="example", + engine_version="v0.21.8", + ) + + # Then only hseshadr/ci modules are floored + assert validate_minimum_pins((config,), ()) == () diff --git a/.dagger/tests/test_fleet_policy.py b/.dagger/tests/test_fleet_policy.py index 6a8b870..98855ff 100644 --- a/.dagger/tests/test_fleet_policy.py +++ b/.dagger/tests/test_fleet_policy.py @@ -6,10 +6,12 @@ import pytest from ci.fleet_policy import ( + REQUIRED_MINIMUM, CheckRun, DaggerConfig, DaggerDependency, DeploymentEnvironment, + PinAncestry, Protection, RepositoryExpectation, RepositorySnapshot, @@ -26,6 +28,15 @@ DOWNLOAD = "4" * 40 PYPI = "5" * 40 HEAD_SHA = "${{ github.event.workflow_run.head_sha }}" +CURRENT_PINS = tuple( + PinAncestry( + floor=REQUIRED_MINIMUM["portfolio-foundation"], + pin=pin, + floor_status="ahead", + main_status="ahead", + ) + for pin in ("b" * 40, SHA) +) MODULE = """ @object_type @@ -190,6 +201,7 @@ def _snapshot( check_apps=("github-actions",), codeql_default_state="not-configured", legacy_references=(), + pin_ancestry=CURRENT_PINS, ) @@ -1761,3 +1773,23 @@ def test_should_grandfather_only_missing_shared_module_until_expiry() -> None: assert "missing-shared-module" not in active assert "mutable-action" in active assert "missing-shared-module" in expired + + +def test_should_report_stale_foundation_pin_through_repository_contract() -> None: + # Given an otherwise valid consumer whose foundation pin predates the required floor + source = "github.com/hseshadr/ci/modules/portfolio-foundation@" + "b" * 40 + stale = PinAncestry( + floor=REQUIRED_MINIMUM["portfolio-foundation"], + pin="b" * 40, + floor_status="behind", + main_status="ahead", + ) + snapshot = replace( + _snapshot(INGRESS), dagger_configs=_shared_configs(source), pin_ancestry=(stale,) + ) + + # When the complete repository contract is evaluated + codes = _shared_codes(snapshot) + + # Then the stale release gate is the only failure + assert codes == ("pin-below-required-minimum",) diff --git a/.dagger/tests/test_github_fleet.py b/.dagger/tests/test_github_fleet.py index ef871f4..a424238 100644 --- a/.dagger/tests/test_github_fleet.py +++ b/.dagger/tests/test_github_fleet.py @@ -638,3 +638,46 @@ def test_should_fail_closed_on_invalid_exact_dagger_metadata( # When the boundary validates that config with pytest.raises(FleetAccessError, match=message): read_repository(FakeTransport(responses), "hseshadr", "example") + + +FLOOR = "dd19871486588b1582e432b7bc1f2cfffb296340" +COMPARE_FLOOR = f"repos/hseshadr/ci/compare/{FLOOR}...{'b' * 40}?per_page=1" +COMPARE_MAIN = f"repos/hseshadr/ci/compare/{'b' * 40}...main?per_page=1" + + +def test_should_read_floor_and_main_ancestry_for_floored_central_pin() -> None: + # Given GitHub compare evidence for the consumer's central foundation pin + responses = _responses() + responses[COMPARE_FLOOR] = _json({"status": "behind", "ahead_by": 0}) + responses[COMPARE_MAIN] = _json({"status": "ahead"}) + + # When the repository is read + snapshot = read_repository(FakeTransport(responses), "hseshadr", "example") + + # Then both comparisons are typed ancestry evidence for policy + evidence = snapshot.pin_ancestry + assert [(item.floor, item.pin) for item in evidence] == [(FLOOR, "b" * 40)] + assert (evidence[0].floor_status, evidence[0].main_status) == ("behind", "ahead") + + +def test_should_record_unrelated_history_when_compare_has_no_common_ancestor() -> None: + # Given GitHub cannot compare the pin because it shares no history with the floor + responses = _responses() + + # When the repository is read (both compares answer 404) + snapshot = read_repository(FakeTransport(responses), "hseshadr", "example") + + # Then the pin is recorded as unrelated rather than silently accepted + assert snapshot.pin_ancestry[0].floor_status == "unrelated" + assert snapshot.pin_ancestry[0].main_status == "unrelated" + + +def test_should_fail_closed_when_compare_endpoint_errors() -> None: + # Given the compare endpoint is unavailable + responses = _responses() + responses[COMPARE_FLOOR] = _json({}, status=500) + + # When the repository is read + # Then the scan fails instead of guessing ancestry + with pytest.raises(FleetAccessError, match="compare"): + read_repository(FakeTransport(responses), "hseshadr", "example") diff --git a/CHANGELOG.md b/CHANGELOG.md index ced7115..960340e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ### Added +- Per-module required-minimum pin floors: the fleet scan reports + `pin-below-required-minimum` for any consumer whose central module pin is not on `main` at + or after the reviewed floor (`portfolio-foundation` ≥ `dd19871`, hseshadr/ci#46). - Reusable `portfolio-foundation` and `cloudflare-pages` Dagger modules for exact source identity, repository safety, deterministic artifact evidence, exact-green authorization, and fail-closed Pages delivery. diff --git a/README.md b/README.md index 9185a41..a32a767 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,8 @@ For every exact consumer `main`, the scanner requires: - managed CodeQL default setup is disabled; - no independent execution app controls the build or deploy path; - no live workflow executes a retired `hseshadr/ci` reusable control. +- every pinned central module is on `hseshadr/ci` `main` and at or after its reviewed + required-minimum floor ([details](docs/dagger-modules.md#required-minimum-pins)). GitGuardian is allowed only as a non-required advisory observer. diff --git a/docs/dagger-modules.md b/docs/dagger-modules.md index 653d8bb..1d097d8 100644 --- a/docs/dagger-modules.md +++ b/docs/dagger-modules.md @@ -49,6 +49,31 @@ shape (the example SHA is illustrative): plus `main`, `latest`, version tags, shortened SHAs, uppercase hexadecimal, and every other mutable or non-canonical dependency reference. +### Required minimum pins + +An exact pin is not enough on its own: a consumer can stay pinned below a fix it must have, and +nothing breaks until a release gate trips on the old bug. Fleet policy therefore keeps a +reviewed floor per central module in `REQUIRED_MINIMUM` (`.dagger/src/ci/fleet_policy.py`): + +| Module | Floor | Why | +| --- | --- | --- | +| `portfolio-foundation` | `dd19871486588b1582e432b7bc1f2cfffb296340` | `greenMain` tolerates GitHub rerun `created_at` skew (#46); older pins can block a release. | + +`cloudflare-pages` and `python-package` load `portfolio-foundation` from their own revision, so +the foundation floor also applies to pins of those modules. + +For every floored module revision in a consumer's resolved Dagger graph, the scanner asks +GitHub `compare/...` and `compare/...main` on `hseshadr/ci`. Both must answer +`ahead` or `identical`: the pin is at or after the floor **and** on central `main`. Anything +else (`behind`, `diverged`, no common history, or missing evidence) is a +`pin-below-required-minimum` finding that fails the check. + +**When you ship a fix every consumer must run, raise the floor in the same PR.** Set the +module's `REQUIRED_MINIMUM` entry to the fix commit, update the literal pinned in +`.dagger/tests/test_fleet_minimum_pin.py`, and add a row above. Non-mandatory changes do not +move the floor. After merge, the fleet scan names every consumer still below it, and each one +needs a bump PR. + This remote-pin rule applies to consumers. Central CI intentionally keeps its foundation as a local same-tree dependency so it validates the module bytes in the current commit; a remote self-pin would instead validate an older published copy. From 05001155db5ee19d76a0189d3b01413e80cf0bc9 Mon Sep 17 00:00:00 2001 From: Harish Seshadri Date: Fri, 25 Sep 2026 08:36:01 -0700 Subject: [PATCH 2/4] feat(fleet): cover agentic consumers and fail on uncovered ones The fleet scan only checked repositories listed by hand in repository_expectations. agentic-saga and agentic-context-service pin hseshadr/ci modules but were never listed, so no fleet rule ran on them. - Add both to repository_expectations with the full consumer contract (sole Dagger check, conversation resolution, no rollout exception; both already declare shared foundation). - New fleet_coverage module: list every public hseshadr repository, read its default-branch dagger.json, and report uncovered-consumer for any active repository that pins a github.com/hseshadr/ci module but is missing from the list. Listing or config read errors fail closed. - scan_repository now turns FleetAccessError into an evidence-unreadable finding, so one unreadable repository (agentic-context-service has no branch protection on main) no longer hides every later result. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015oBArfm762nN1r4F4Fst5a --- .dagger/src/ci/fleet.py | 32 +++- .dagger/src/ci/fleet_coverage.py | 101 ++++++++++++ .dagger/tests/test_fleet.py | 4 +- .dagger/tests/test_fleet_coverage.py | 237 +++++++++++++++++++++++++++ CHANGELOG.md | 4 + README.md | 5 +- docs/dagger-modules.md | 16 ++ 7 files changed, 390 insertions(+), 9 deletions(-) create mode 100644 .dagger/src/ci/fleet_coverage.py create mode 100644 .dagger/tests/test_fleet_coverage.py diff --git a/.dagger/src/ci/fleet.py b/.dagger/src/ci/fleet.py index c35aea9..d6d9c3f 100644 --- a/.dagger/src/ci/fleet.py +++ b/.dagger/src/ci/fleet.py @@ -5,8 +5,11 @@ from dataclasses import dataclass from datetime import date -from ci.fleet_policy import PolicyFinding, RepositoryExpectation, validate_repository -from ci.github_fleet import GitHubHttpTransport, read_repository +from ci.fleet_coverage import coverage_results +from ci.fleet_policy import PolicyFinding, RepositoryExpectation, finding, validate_repository +from ci.github_fleet import FleetAccessError, GitHubHttpTransport, GitHubTransport, read_repository + +OWNER = "hseshadr" @dataclass(frozen=True) @@ -21,6 +24,8 @@ class RepositoryResult: def repository_expectations(include_central: bool) -> tuple[RepositoryExpectation, ...]: """Return the reviewed fleet contract for the current rollout phase.""" consumers = ( + expectation("agentic-context-service"), + expectation("agentic-saga"), expectation("almamesh", grandfathered_until=date(2026, 12, 15)), expectation("aml-filter", grandfathered_until=date(2026, 12, 31)), expectation("assay", linear_history=True, grandfathered_until=date(2026, 11, 30)), @@ -57,15 +62,28 @@ def expectation_for(name: str) -> RepositoryExpectation: def scan_fleet(token: str, include_central: bool) -> tuple[RepositoryResult, ...]: """Read and evaluate each repository from authoritative exact-main evidence.""" - transport = GitHubHttpTransport(token) + return scan_fleet_with(GitHubHttpTransport(token), include_central) + + +def scan_fleet_with( + transport: GitHubTransport, include_central: bool +) -> tuple[RepositoryResult, ...]: + """Prove coverage of every discovered consumer, then evaluate each reviewed one.""" + reviewed = tuple(item.name for item in repository_expectations(True)) + uncovered = coverage_results(transport, OWNER, reviewed) + coverage = tuple(RepositoryResult(item.name, "", item.findings) for item in uncovered) expectations = repository_expectations(include_central) - return tuple(scan_repository(transport, item) for item in expectations) + return coverage + tuple(scan_repository(transport, item) for item in expectations) def scan_repository( - transport: GitHubHttpTransport, expectation_: RepositoryExpectation + transport: GitHubTransport, expectation_: RepositoryExpectation ) -> RepositoryResult: - """Evaluate one exact-main repository against its reviewed contract.""" - snapshot = read_repository(transport, "hseshadr", expectation_.name) + """Evaluate one repository, turning unreadable evidence into a failing finding.""" + try: + snapshot = read_repository(transport, OWNER, expectation_.name) + except FleetAccessError as error: + unreadable = finding("evidence-unreadable", "github", str(error)) + return RepositoryResult(expectation_.name, "", (unreadable,)) findings = validate_repository(snapshot, expectation_) return RepositoryResult(snapshot.name, snapshot.sha, findings) diff --git a/.dagger/src/ci/fleet_coverage.py b/.dagger/src/ci/fleet_coverage.py new file mode 100644 index 0000000..2389e28 --- /dev/null +++ b/.dagger/src/ci/fleet_coverage.py @@ -0,0 +1,101 @@ +"""Discover every Dagger consumer of the central modules and prove fleet coverage. + +The reviewed fleet contract (`repository_expectations`) is a hand-maintained list, so a +new consumer silently escapes every fleet check until someone remembers to add it. This +module closes that gap: it lists the owner's repositories, reads each default-branch +`dagger.json`, and fails the scan for any consumer the contract does not name. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Final + +from pydantic.dataclasses import dataclass as validated_dataclass + +from ci.fleet_policy import PolicyFinding, finding +from ci.github_fleet import ( + BOUNDARY_CONFIG, + HTTP_NOT_FOUND, + GitHubTransport, + decode_source, + parse_content_response, + parse_dagger_config, + read_model, +) + +UNCOVERED_CODE: Final = "uncovered-consumer" +CENTRAL_PREFIXES: Final = ("github.com/hseshadr/ci/", "github.com/hseshadr/ci@") +PAGE_SIZE: Final = 100 + + +@validated_dataclass(config=BOUNDARY_CONFIG) +class OwnedRepositoryPayload: + """One entry of the owner's public repository listing.""" + + name: str + archived: bool + default_branch: str + + +@dataclass(frozen=True) +class UncoveredConsumer: + """One discovered consumer that the reviewed fleet contract does not name.""" + + name: str + findings: tuple[PolicyFinding, ...] + + +def discover_consumers(transport: GitHubTransport, owner: str) -> tuple[str, ...]: + """Return every active repository whose default-branch dagger.json pins hseshadr/ci.""" + return tuple( + repository.name + for repository in list_repositories(transport, owner) + if not repository.archived and consumes_central(transport, owner, repository) + ) + + +def list_repositories(transport: GitHubTransport, owner: str) -> tuple[OwnedRepositoryPayload, ...]: + """Read every page of the owner's repository listing, failing closed on any error.""" + collected: list[OwnedRepositoryPayload] = [] + page = 1 + while True: + path = f"users/{owner}/repos?type=owner&per_page={PAGE_SIZE}&page={page}" + batch = read_model(transport, path, tuple[OwnedRepositoryPayload, ...]) + collected.extend(batch) + if len(batch) < PAGE_SIZE: + return tuple(collected) + page += 1 + + +def consumes_central( + transport: GitHubTransport, owner: str, repository: OwnedRepositoryPayload +) -> bool: + """Return whether one default-branch dagger.json declares a central module.""" + base = f"repos/{owner}/{repository.name}" + path = f"{base}/contents/dagger.json?ref={repository.default_branch}" + response = transport.get(path) + if response.status == HTTP_NOT_FOUND: + return False + source = decode_source(parse_content_response(response, path), "dagger.json", base) + config = parse_dagger_config(source) + return any(item.source.startswith(CENTRAL_PREFIXES) for item in config.dependencies) + + +def uncovered_consumers(discovered: tuple[str, ...], reviewed: tuple[str, ...]) -> tuple[str, ...]: + """Return discovered consumers absent from the reviewed fleet contract.""" + return tuple(name for name in discovered if name not in reviewed) + + +def coverage_results( + transport: GitHubTransport, owner: str, reviewed: tuple[str, ...] +) -> tuple[UncoveredConsumer, ...]: + """Build one failing result per discovered consumer the fleet scan would skip.""" + missing = uncovered_consumers(discover_consumers(transport, owner), reviewed) + return tuple(UncoveredConsumer(name, (uncovered_finding(name),)) for name in missing) + + +def uncovered_finding(name: str) -> PolicyFinding: + """Name the exact fix for one consumer that escapes the fleet scan.""" + message = f"{name} pins hseshadr/ci modules but is missing from repository_expectations" + return finding(UNCOVERED_CODE, "dagger.json", message) diff --git a/.dagger/tests/test_fleet.py b/.dagger/tests/test_fleet.py index 5d72633..a3164a2 100644 --- a/.dagger/tests/test_fleet.py +++ b/.dagger/tests/test_fleet.py @@ -10,8 +10,10 @@ def test_should_enforce_exact_consumer_set_when_central_is_not_main() -> None: # When the immutable fleet expectations are selected expectations = repository_expectations(include_central) - # Then exactly the seven migrated consumers require sole Dagger + # Then exactly the nine migrated consumers require sole Dagger assert tuple(item.name for item in expectations) == ( + "agentic-context-service", + "agentic-saga", "almamesh", "aml-filter", "assay", diff --git a/.dagger/tests/test_fleet_coverage.py b/.dagger/tests/test_fleet_coverage.py new file mode 100644 index 0000000..1a2710a --- /dev/null +++ b/.dagger/tests/test_fleet_coverage.py @@ -0,0 +1,237 @@ +from __future__ import annotations + +import base64 +import json +from dataclasses import dataclass + +import pytest +from test_github_fleet import FakeTransport as ExactMainTransport +from test_github_fleet import _responses as exact_main_responses + +import ci.fleet +from ci.fleet import ( + expectation, + expectation_for, + repository_expectations, + scan_fleet, + scan_fleet_with, + scan_repository, +) +from ci.fleet_coverage import ( + UNCOVERED_CODE, + coverage_results, + discover_consumers, + uncovered_consumers, +) +from ci.github_fleet import FleetAccessError, HttpResponse + +OWNER = "hseshadr" +CI_SOURCE = "github.com/hseshadr/ci/modules/portfolio-foundation@" + "a" * 40 +OTHER_SOURCE = "github.com/dagger/dagger/modules/wolfi@" + "b" * 40 +LISTING = "users/hseshadr/repos?type=owner&per_page=100&page={page}" + +# The live 2026-09-25 scan's exact set of hseshadr repos whose default-branch +# dagger.json references github.com/hseshadr/ci. A new consumer must be added here +# and to repository_expectations in the same change. +KNOWN_CONSUMERS = ( + "agentic-context-service", + "agentic-saga", + "almamesh", + "aml-filter", + "assay", + "edge-proc", + "edge-reco", + "edgeproc-core", + "privacy-core", +) + + +@dataclass(frozen=True) +class FakeTransport: + """Return exact fixture responses; every unknown path is a 404.""" + + responses: dict[str, HttpResponse] + + def get(self, path: str) -> HttpResponse: + return self.responses.get(path, HttpResponse(status=404, body="{}")) + + +def _json(value: object, status: int = 200) -> HttpResponse: + return HttpResponse(status=status, body=json.dumps(value)) + + +def _repo(name: str, *, archived: bool = False) -> dict[str, object]: + return {"name": name, "archived": archived, "default_branch": "main"} + + +def _config(name: str, *sources: str) -> HttpResponse: + dependencies = [{"name": f"dep{index}", "source": item} for index, item in enumerate(sources)] + sdk = {"source": "python"} + config = {"name": name, "engineVersion": "v0.21.8", "sdk": sdk, "dependencies": dependencies} + text = json.dumps(config) + encoded = base64.b64encode(text.encode()).decode() + return _json({"type": "file", "path": "dagger.json", "encoding": "base64", "content": encoded}) + + +def _config_path(name: str) -> str: + return f"repos/hseshadr/{name}/contents/dagger.json?ref=main" + + +def _fleet_responses() -> dict[str, HttpResponse]: + first_page = [_repo(f"filler-{index}") for index in range(98)] + first_page += [_repo("consumer"), _repo("unrelated")] + second_page = [_repo("no-dagger"), _repo("retired", archived=True), _repo("covered")] + return { + LISTING.format(page=1): _json(first_page), + LISTING.format(page=2): _json(second_page), + _config_path("consumer"): _config("consumer", CI_SOURCE), + _config_path("unrelated"): _config("unrelated", OTHER_SOURCE), + _config_path("retired"): _config("retired", CI_SOURCE), + _config_path("covered"): _config("covered", OTHER_SOURCE, CI_SOURCE), + } + + +def test_should_discover_every_active_ci_consumer_across_listing_pages() -> None: + # Given a two-page owner listing with consumers, non-consumers and an archived consumer + transport = FakeTransport(_fleet_responses()) + + # When consumers are discovered from default-branch dagger.json evidence + discovered = discover_consumers(transport, OWNER) + + # Then only active repositories that pin hseshadr/ci modules are returned + assert discovered == ("consumer", "covered") + + +def test_should_report_consumer_missing_from_fleet_expectations() -> None: + # Given a discovered consumer that the reviewed fleet contract does not name + transport = FakeTransport(_fleet_responses()) + reviewed = ("assay", "covered") + + # When coverage is evaluated against discovery + results = coverage_results(transport, OWNER, reviewed) + + # Then only the uncovered consumer fails the scan, with an actionable finding + assert [(item.name, [f.code for f in item.findings]) for item in results] == [ + ("consumer", [UNCOVERED_CODE]) + ] + assert results[0].findings[0].path == "dagger.json" + assert "repository_expectations" in results[0].findings[0].message + + +def test_should_fail_closed_when_owner_listing_is_unreadable() -> None: + # Given a listing endpoint that refuses the read + transport = FakeTransport({LISTING.format(page=1): _json({}, status=403)}) + + # When discovery runs + # Then the scan cannot claim complete coverage + with pytest.raises(FleetAccessError, match="403"): + discover_consumers(transport, OWNER) + + +def test_should_fail_closed_when_consumer_config_is_unreadable() -> None: + # Given a repository whose dagger.json read fails with something other than 404 + responses = { + LISTING.format(page=1): _json([_repo("flaky")]), + _config_path("flaky"): _json({}, status=500), + } + + # When discovery runs + # Then the unreadable config is an error, never a silent non-consumer + with pytest.raises(FleetAccessError, match="500"): + discover_consumers(FakeTransport(responses), OWNER) + + +def test_should_cover_every_known_ci_consumer_in_reviewed_expectations() -> None: + # Given the live-discovered consumer set (stubbed; the hosted scan rediscovers it) + names = tuple(item.name for item in repository_expectations(True)) + + # When coverage is computed against the reviewed fleet contract + missing = uncovered_consumers(KNOWN_CONSUMERS, names) + + # Then no consumer escapes the fleet scan + assert missing == () + + +@pytest.mark.parametrize("name", ["agentic-context-service", "agentic-saga"]) +def test_should_hold_agentic_consumers_to_the_sole_dagger_contract(name: str) -> None: + # Given an agentic consumer that already declares shared foundation + # When its reviewed expectation is selected + item = expectation_for(name) + + # Then it gets the full consumer contract with no rollout exception + assert item.required_contexts == ("Dagger",) + assert item.conversation_resolution is True + assert item.linear_history is False + assert item.shared_foundation_required is True + assert item.grandfathered_until is None + + +def test_should_report_unreadable_repository_as_finding_and_keep_scanning() -> None: + # Given a consumer whose protection endpoint 404s (for example, unprotected main) + transport = FakeTransport({}) + + # When that one repository is scanned + result = scan_repository(transport, expectation_for("agentic-context-service")) + + # Then the failure is a named finding, so later repositories are still evaluated + assert result.name == "agentic-context-service" + assert [item.code for item in result.findings] == ["evidence-unreadable"] + assert "404" in result.findings[0].message + + +def test_should_fail_hosted_scan_for_uncovered_consumer_before_reviewed_repositories() -> None: + # Given a live listing where one consumer is unknown to the reviewed contract + responses = { + LISTING.format(page=1): _json([_repo("newcomer"), _repo("assay")]), + _config_path("newcomer"): _config("newcomer", CI_SOURCE), + _config_path("assay"): _config("assay", CI_SOURCE), + } + + # When the whole fleet scan runs (reviewed repositories are unreadable here) + results = scan_fleet_with(FakeTransport(responses), include_central=False) + + # Then the uncovered consumer is reported and every reviewed repository still runs + assert results[0].name == "newcomer" + assert [item.code for item in results[0].findings] == [UNCOVERED_CODE] + reviewed = tuple(item.name for item in repository_expectations(False)) + assert tuple(item.name for item in results[1:]) == reviewed + assert all(item.findings for item in results) + + +def test_should_evaluate_readable_repository_against_its_contract() -> None: + # Given complete exact-main evidence for one reviewed repository + transport = ExactMainTransport(exact_main_responses()) + + # When that repository is scanned + result = scan_repository(transport, expectation("example")) + + # Then its identity is the exact main SHA and no evidence error is reported + assert (result.name, result.sha) == ("example", "a" * 40) + assert "evidence-unreadable" not in {item.code for item in result.findings} + + +def test_should_reject_unknown_fleet_repository_name() -> None: + # Given a name the reviewed contract does not contain + # When its expectation is requested + # Then the caller is told instead of receiving a default contract + with pytest.raises(ValueError, match="unknown fleet repository: nope"): + expectation_for("nope") + + +def test_should_scan_hosted_fleet_through_authenticated_transport( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Given a hosted run that supplies only a token + seen: list[object] = [] + + def fake_scan(transport: object, include_central: bool) -> tuple[()]: + seen.append((type(transport).__name__, include_central)) + return () + + monkeypatch.setattr(ci.fleet, "scan_fleet_with", fake_scan) + + # When the fleet is scanned + scan_fleet("token", include_central=True) + + # Then the real GitHub transport performs discovery and every repository read + assert seen == [("GitHubHttpTransport", True)] diff --git a/CHANGELOG.md b/CHANGELOG.md index ced7115..a864b5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,10 @@ evidence. - Optional typed Git authorization for exact private-repository history in `portfolio-foundation`, kept inside Dagger's secret boundary. +- Fleet coverage: `agentic-saga` and `agentic-context-service` join the fleet scan, and every + scan now discovers `hseshadr/ci` consumers from default-branch `dagger.json` and fails with + `uncovered-consumer` for any that are not listed. Unreadable repositories become an + `evidence-unreadable` finding, so they no longer stop the scan. ### Changed diff --git a/README.md b/README.md index 9185a41..aed4e20 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ dagger call fleet --github-token=env:GITHUB_TOKEN --include-central The first command runs central quality and security checks. The second reads exact `main` state from GitHub for: +- `agentic-context-service` +- `agentic-saga` - `almamesh` - `aml-filter` - `assay` @@ -32,7 +34,8 @@ The first command runs central quality and security checks. The second reads exa - `privacy-core` - `ci` -Any inaccessible or incomplete evidence is an error. A scan that inspected nothing +It also fails if any other `hseshadr` repository pins a `github.com/hseshadr/ci` module but +is missing from that list. Any inaccessible or incomplete evidence is an error. A scan that inspected nothing cannot report success. ## Reuse the Dagger legos diff --git a/docs/dagger-modules.md b/docs/dagger-modules.md index 653d8bb..a908597 100644 --- a/docs/dagger-modules.md +++ b/docs/dagger-modules.md @@ -372,6 +372,22 @@ authoritative fleet evidence before merge, followed by exact-main evidence after the merged SHA against `^[0-9a-f]{40}$` and record it in the durable release ledger; a temporary file alone is not release evidence. +## Fleet coverage + +The fleet scan checks only the repositories named in `repository_expectations` +(`.dagger/src/ci/fleet.py`). That list is written by hand, so a new consumer would otherwise +escape every fleet check. To close that gap, each hosted scan first lists every public +`hseshadr` repository, reads its default-branch `dagger.json`, and reports +`uncovered-consumer` for any active repository that pins a `github.com/hseshadr/ci` module but is +missing from the list. The scan then fails. + +- **When you onboard a consumer, add it to `repository_expectations` in the same PR.** Also add + it to `KNOWN_CONSUMERS` in `.dagger/tests/test_fleet_coverage.py`. +- Archived repositories are skipped. Private repositories are not listed, so they are not + discovered. +- A repository whose evidence cannot be read (for example, `main` has no branch protection) + gets an `evidence-unreadable` finding. The scan keeps going and still fails. + ## Release status Shipped in this central change: From 49b2716e2e71a22546c1abef56e3795807870a93 Mon Sep 17 00:00:00 2001 From: Harish Seshadri Date: Fri, 25 Sep 2026 09:31:36 -0700 Subject: [PATCH 3/4] feat(fleet): module-owned publisher lineage and no expressions in Dagger args Publisher lineage moves out of consumer `run:` steps into two portfolio-foundation functions. release-lineage and release-provenance fail unless GitHub's run records show a successful release-candidate.yml dispatch for exactly the expected SHA, run by this repository's in-progress main publish.yml, with main containing both commits. That blocks a dispatch on a tag named `main` from publishing its own bytes. release-provenance also returns npm's provenance context, built from the publish run record. The fleet policy accepts that exact leading step (publisher-lineage for anything weaker) and a consumer publisher loaded at `@${{ github.sha }}`, so edgeproc-core and privacy-core can publish with no shell step and no exemption. It also reports dagger-args-expression for `${{ inputs.* }}`, `${{ github.event.* }}` or `${{ github.head_ref }}` in any dagger-for-github input the action pastes into bash. Fixtures that pasted the workflow_run head SHA into args as compliant now pass it through env; the policy reports the old shape. Fixes #49 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015oBArfm762nN1r4F4Fst5a --- .dagger/src/ci/fleet_policy.py | 108 ++++++- .dagger/tests/test_fleet_policy.py | 9 +- .dagger/tests/test_fleet_release_lineage.py | 243 ++++++++++++++ CHANGELOG.md | 13 + docs/dagger-modules.md | 53 +++ .../src/portfolio_foundation/github.py | 3 +- .../src/portfolio_foundation/lineage.py | 297 +++++++++++++++++ .../.dagger/src/portfolio_foundation/main.py | 32 +- .../.dagger/tests/test_lineage.py | 303 ++++++++++++++++++ .../.dagger/tests/test_public_schema.py | 22 ++ 10 files changed, 1070 insertions(+), 13 deletions(-) create mode 100644 .dagger/tests/test_fleet_release_lineage.py create mode 100644 modules/portfolio-foundation/.dagger/src/portfolio_foundation/lineage.py create mode 100644 modules/portfolio-foundation/.dagger/tests/test_lineage.py diff --git a/.dagger/src/ci/fleet_policy.py b/.dagger/src/ci/fleet_policy.py index 3adb7a2..95bbed9 100644 --- a/.dagger/src/ci/fleet_policy.py +++ b/.dagger/src/ci/fleet_policy.py @@ -111,6 +111,36 @@ ) DESCENDANT_STATUSES: Final = frozenset(("ahead", "identical")) APPROVED_PUBLISHER_MODULES: Final = frozenset(("github.com/hseshadr/ci/modules/npm-publisher",)) +# dagger-for-github pastes every `with:` input except `module` (passed as INPUT_MODULE env) +# into bash, so a caller-controlled expression there is script injection (#49). +ATTACKER_EXPRESSION: Final = re.compile( + r"\$\{\{(?:(?!\}\}).)*?(? tuple[PolicyFinding, ...]: """Dispatch a job to its only accepted execution shape.""" - common = validate_steps(path, job.steps) + validate_action_steps( - path, job.steps, engine_version + common = ( + validate_steps(path, job.steps) + + validate_action_steps(path, job.steps, engine_version) + + validate_dagger_expressions(path, job.steps) ) names = tuple(map(action_name, job.steps)) if UPLOAD_ACTION in names: @@ -1026,6 +1058,27 @@ def validate_steps(path: str, steps: tuple[WorkflowStep, ...]) -> tuple[PolicyFi return tuple(findings) +def validate_dagger_expressions( + path: str, steps: tuple[WorkflowStep, ...] +) -> tuple[PolicyFinding, ...]: + """Reject caller-controlled expressions in any Dagger input pasted into bash.""" + return tuple( + finding("dagger-args-expression", path, f"{key} carries a caller-controlled expression") + for step in steps + if action_name(step) == DAGGER_ACTION + for key in script_inputs_with_expressions(step) + ) + + +def script_inputs_with_expressions(step: WorkflowStep) -> tuple[str, ...]: + """Return every script-pasted input that carries a caller-controlled expression.""" + return tuple( + key + for key, value in sorted(step.with_.items()) + if key != "module" and ATTACKER_EXPRESSION.search(scalar_text(value)) + ) + + def validate_ingress( path: str, steps: tuple[WorkflowStep, ...], engine_version: str | None = None ) -> tuple[PolicyFinding, ...]: @@ -1151,18 +1204,52 @@ def candidate_identity_is_weak(step: WorkflowStep) -> bool: def validate_publisher(path: str, job: WorkflowJob, repository: str) -> tuple[PolicyFinding, ...]: """Accept only source-free artifact transport into one OIDC publisher.""" + lineage, steps = split_lineage(job.steps) findings: list[PolicyFinding] = [] findings.extend(validate_publisher_permissions(path, job)) findings.extend(validate_publisher_source(path, job.steps)) - findings.extend(validate_download(path, job.steps)) - names = tuple(map(action_name, job.steps)) + findings.extend(() if lineage is None else validate_lineage(path, lineage)) + findings.extend(validate_download(path, steps)) + names = tuple(map(action_name, steps)) if PYPI_ACTION in names: - findings.extend(validate_pypi(path, job.steps, repository)) + findings.extend(validate_pypi(path, steps, repository)) else: - findings.extend(validate_npm(path, job.steps, repository)) + findings.extend(validate_npm(path, steps, repository)) return tuple(findings) +def split_lineage( + steps: tuple[WorkflowStep, ...], +) -> tuple[WorkflowStep | None, tuple[WorkflowStep, ...]]: + """Separate a leading central lineage proof from the publisher transport.""" + if steps and is_lineage_step(steps[0]): + return steps[0], steps[1:] + return None, steps + + +def is_lineage_step(step: WorkflowStep) -> bool: + """Return whether a step loads the central lineage module.""" + module = scalar_text(step.with_.get("module")) + return action_name(step) == DAGGER_ACTION and module.startswith(LINEAGE_MODULE_PREFIX) + + +def validate_lineage(path: str, step: WorkflowStep) -> tuple[PolicyFinding, ...]: + """Require the exact literal-pinned lineage call bound to the triggering run.""" + environment = {key: scalar_text(value) for key, value in step.env.items()} + if lineage_call_is_exact(step) and environment == LINEAGE_ENV: + return () + return (finding("publisher-lineage", path, "exact central lineage call required"),) + + +def lineage_call_is_exact(step: WorkflowStep) -> bool: + """Return whether the step is the literal-pinned central lineage call.""" + values = {key: scalar_text(value) for key, value in step.with_.items()} + if set(values) != {"version", "verb", "module", "args"}: + return False + module = LINEAGE_MODULE.fullmatch(values["module"]) is not None + return (values["verb"], module, values["args"] in LINEAGE_CALLS) == ("call", True, True) + + def validate_publisher_permissions(path: str, job: WorkflowJob) -> tuple[PolicyFinding, ...]: """Limit publisher authority to artifact read and OIDC minting.""" minimal = {"actions": "read", "id-token": "write"} @@ -1282,11 +1369,14 @@ def remote_dagger_module(steps: tuple[WorkflowStep, ...]) -> str: def publisher_module_is_authorized(module: str, repository: str) -> bool: - """Accept exact consumer candidates or an approved literal central publisher.""" - candidate = f"github.com/hseshadr/{repository}@${{{{ github.event.workflow_run.head_sha }}}}" + """Accept the consumer at the candidate or main SHA, or an approved central publisher.""" + own = ( + f"github.com/hseshadr/{repository}@${{{{ github.event.workflow_run.head_sha }}}}", + f"github.com/hseshadr/{repository}@${{{{ github.sha }}}}", + ) base, separator, revision = module.rpartition("@") literal = separator == "@" and base in APPROVED_PUBLISHER_MODULES - return module == candidate or (literal and re.fullmatch(r"[0-9a-f]{40}", revision) is not None) + return module in own or (literal and re.fullmatch(r"[0-9a-f]{40}", revision) is not None) def oidc_arguments_are_typed(step: WorkflowStep) -> bool: diff --git a/.dagger/tests/test_fleet_policy.py b/.dagger/tests/test_fleet_policy.py index 98855ff..a5ace2d 100644 --- a/.dagger/tests/test_fleet_policy.py +++ b/.dagger/tests/test_fleet_policy.py @@ -139,8 +139,11 @@ def publish_npm( attestations: true """ +# Event values reach dagger-for-github's bash only as quoted env vars (#49). This fixture +# used to paste `--expected-sha=${{ github.event.workflow_run.head_sha }}` into args and +# call it compliant; the policy now reports that as `dagger-args-expression`. NPM_ARGS = ( - f"publish-npm --candidate=candidate --expected-sha={HEAD_SHA} " + 'publish-npm --candidate=candidate --expected-sha="$HEAD_SHA" ' "--oidc-url=env:ACTIONS_ID_TOKEN_REQUEST_URL " "--oidc-token=env:ACTIONS_ID_TOKEN_REQUEST_TOKEN" ) @@ -163,6 +166,8 @@ def publish_npm( github-token: ${{{{ github.token }}}} run-id: ${{{{ github.event.workflow_run.id }}}} - uses: dagger/dagger-for-github@{DAGGER} + env: + HEAD_SHA: {HEAD_SHA} with: version: "0.21.8" verb: call @@ -511,7 +516,7 @@ def test_should_accept_exact_remote_dagger_plan_before_official_pypi() -> None: version: "0.21.8" verb: call module: github.com/hseshadr/example@{HEAD_SHA} - args: pypi-required --candidate=release --expected-sha={HEAD_SHA} + args: pypi-required --candidate=release --expected-sha="$HEAD_SHA" """ bridge = PYPI_BRIDGE.replace( f" - uses: pypa/gh-action-pypi-publish@{PYPI}", diff --git a/.dagger/tests/test_fleet_release_lineage.py b/.dagger/tests/test_fleet_release_lineage.py new file mode 100644 index 0000000..5dd461b --- /dev/null +++ b/.dagger/tests/test_fleet_release_lineage.py @@ -0,0 +1,243 @@ +"""Fleet policy: script-free Dagger args and the central publisher-lineage shape (#49).""" + +from __future__ import annotations + +import json + +import pytest + +from ci.fleet_policy import SourceFile, validate_workflow + +DAGGER = "27b130bf0f79a7f6fbbbe0fbca6760dc9bb40a77" +CHECKOUT = "1" * 40 +DOWNLOAD = "4" * 40 +PYPI = "5" * 40 +CI_PIN = "e" * 40 +LINEAGE_MODULE = f"github.com/hseshadr/ci/modules/portfolio-foundation@{CI_PIN}" +LINEAGE_ARGUMENTS = ( + '--github-token=env:GH_TOKEN --repository="$GITHUB_REPOSITORY" --run-id="$RUN_ID" ' + '--head-sha="$HEAD_SHA" --publish-run-id="$GITHUB_RUN_ID"' +) +LINEAGE_CALL = f"release-lineage {LINEAGE_ARGUMENTS}" +PROVENANCE_CALL = f"release-provenance {LINEAGE_ARGUMENTS} export --path=github-context.json" +LINEAGE_ENV = """ env: + GH_TOKEN: ${{ github.token }} + RUN_ID: ${{ github.event.workflow_run.id }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} +""" +HEADER = """name: Publish +on: + workflow_run: + workflows: [Dagger release candidate] + types: [completed] +permissions: + contents: read +jobs: + publish: + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + id-token: write + steps: +""" +DOWNLOAD_STEP = f""" - uses: actions/download-artifact@{DOWNLOAD} + with: + name: example-${{{{ github.event.workflow_run.head_sha }}}} + path: release + github-token: ${{{{ github.token }}}} + run-id: ${{{{ github.event.workflow_run.id }}}} +""" +PYPI_STEP = f""" - uses: pypa/gh-action-pypi-publish@{PYPI} + with: + packages-dir: release/dist + attestations: true +""" +NPM_PUBLISHER = f""" - uses: dagger/dagger-for-github@{DAGGER} + env: + HEAD_SHA: ${{{{ github.event.workflow_run.head_sha }}}} + with: + version: "0.21.8" + verb: call + module: github.com/hseshadr/example@${{{{ github.sha }}}} + args: >- + publish --candidate=release --expected-sha="$HEAD_SHA" + --oidc-url=env:ACTIONS_ID_TOKEN_REQUEST_URL + --oidc-token=env:ACTIONS_ID_TOKEN_REQUEST_TOKEN + --github-context=github-context.json +""" + + +def _lineage(call: str, *, module: str = LINEAGE_MODULE, env: str = LINEAGE_ENV) -> str: + return f""" - uses: dagger/dagger-for-github@{DAGGER} +{env} with: + version: "0.21.8" + verb: call + module: {module} + args: {call} +""" + + +def _codes(text: str) -> tuple[str, ...]: + source = SourceFile(path=".github/workflows/publish.yml", text=text) + return tuple(item.code for item in validate_workflow(source, "v0.21.8", "example")) + + +def _ingress(args: str, extra: str = "") -> str: + return f"""name: Dagger +on: [push] +permissions: + contents: read +jobs: + dagger: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@{CHECKOUT} + with: + persist-credentials: false + - uses: dagger/dagger-for-github@{DAGGER} + env: + TAG: ${{{{ inputs.tag }}}} + with: + version: "0.21.8" + verb: call + args: {args} +{extra}""" + + +@pytest.mark.parametrize( + "args", + [ + "release-candidate --tag=${{ inputs.tag }}", + "release-candidate --tag=${{inputs.tag}}", + "release-candidate --tag=${{ INPUTS.tag }}", + "release-candidate --tag=${{ github.event.inputs.tag }}", + "publish --expected-sha=${{ github.event.workflow_run.head_sha }}", + "publish --title=${{ github.event['pull_request'].title }}", + "publish --context=${{ toJSON(github.event) }}", + "ci --ref=${{ github.head_ref }}", + "ci --ref=${{ format('{0}', inputs.tag) }}", + ], +) +def test_should_reject_attacker_controlled_expressions_in_dagger_args(args: str) -> None: + # Given dagger-for-github args (pasted into bash) carrying a caller-controlled expression + codes = _codes(_ingress(json.dumps(args))) + + # Then the fleet policy reports the script-injection sink + assert "dagger-args-expression" in codes + + +@pytest.mark.parametrize("key", ["call", "shell", "dagger-flags", "workdir", "cloud-token"]) +def test_should_reject_attacker_expressions_in_every_script_pasted_input(key: str) -> None: + # Given an expression in another dagger-for-github input the action pastes into bash + workflow = _ingress("ci", f" {key}: x ${{{{ inputs.tag }}}}\n") + + # Then it is the same injection + assert "dagger-args-expression" in _codes(workflow) + + +@pytest.mark.parametrize( + "args", + [ + 'release-candidate --tag="$TAG" --commit-sha="$GITHUB_SHA"', + "ci --commit-sha=${{ github.sha }}", + "ci --event=${{ github.event_name }}", + "ci --inputs-file=inputs.json", + ], +) +def test_should_accept_env_quoted_values_and_runner_owned_expressions(args: str) -> None: + # Given args whose only values are quoted env vars or GitHub-owned identities + codes = _codes(_ingress(json.dumps(args))) + + # Then no injection finding is reported + assert "dagger-args-expression" not in codes + + +def test_should_accept_the_module_input_which_the_action_passes_through_env() -> None: + # Given the reviewed candidate-bound module expression (INPUT_MODULE env, not script) + workflow = ( + HEADER + + DOWNLOAD_STEP + + NPM_PUBLISHER.replace("${{ github.sha }}", "${{ github.event.workflow_run.head_sha }}") + ) + + # Then it is not an args expression + assert "dagger-args-expression" not in _codes(workflow) + + +def test_should_accept_central_lineage_before_official_pypi() -> None: + # Given edgeproc-core's shape: prove lineage, download the exact candidate, publish + workflow = HEADER + _lineage(LINEAGE_CALL) + DOWNLOAD_STEP + PYPI_STEP + + # Then the policy reports nothing: no shell step, no exemption + assert _codes(workflow) == () + + +def test_should_accept_central_provenance_before_a_main_pinned_npm_publisher() -> None: + # Given privacy-core's shape: lineage-gated provenance file, download, main publisher + workflow = HEADER + _lineage(PROVENANCE_CALL) + DOWNLOAD_STEP + NPM_PUBLISHER + + # Then the policy reports nothing + assert _codes(workflow) == () + + +@pytest.mark.parametrize( + ("call", "module", "env"), + [ + # A hard-coded run id proves some other, older run instead of the triggering one. + (LINEAGE_CALL.replace('"$RUN_ID"', "123"), LINEAGE_MODULE, LINEAGE_ENV), + # The candidate's own SHA would let the tagged commit vouch for itself. + ( + LINEAGE_CALL, + LINEAGE_MODULE, + LINEAGE_ENV.replace("workflow_run.id", "workflow_run.run_number"), + ), + (LINEAGE_CALL.replace('"$HEAD_SHA"', '"$GITHUB_SHA"'), LINEAGE_MODULE, LINEAGE_ENV), + (LINEAGE_CALL, "github.com/hseshadr/ci/modules/portfolio-foundation@main", LINEAGE_ENV), + (f"green-main {LINEAGE_ARGUMENTS}", LINEAGE_MODULE, LINEAGE_ENV), + (LINEAGE_CALL, LINEAGE_MODULE, ""), + ], +) +def test_should_reject_any_lineage_step_that_is_not_the_exact_central_call( + call: str, module: str, env: str +) -> None: + # Given a first Dagger step that looks like lineage but proves something weaker + workflow = HEADER + _lineage(call, module=module, env=env) + DOWNLOAD_STEP + PYPI_STEP + + # Then the publisher is rejected + assert "publisher-lineage" in _codes(workflow) + + +def test_should_reject_a_lineage_step_with_extra_script_inputs() -> None: + # Given the exact call plus an extra input the action would paste into bash + step = _lineage(LINEAGE_CALL) + " dagger-flags: --progress plain\n" + + # Then it is not the exact central call + assert "publisher-lineage" in _codes(HEADER + step + DOWNLOAD_STEP + PYPI_STEP) + + +def test_should_not_treat_a_lookalike_module_as_the_central_lineage() -> None: + # Given the lineage call loaded from a fork of hseshadr/ci + module = f"github.com/attacker/ci/modules/portfolio-foundation@{CI_PIN}" + workflow = HEADER + _lineage(LINEAGE_CALL, module=module) + DOWNLOAD_STEP + PYPI_STEP + + # Then it is an unapproved Dagger step in the PyPI bridge + assert "pypi-shape" in _codes(workflow) + + +def test_should_still_reject_a_repository_publisher_loaded_from_an_arbitrary_sha() -> None: + # Given the consumer publisher loaded from a literal SHA rather than main's own commit + workflow = HEADER + DOWNLOAD_STEP + NPM_PUBLISHER.replace("${{ github.sha }}", CI_PIN) + + # Then the publisher module identity is still rejected + assert "publisher-module-identity" in _codes(workflow) + + +def test_should_reject_a_shell_lineage_step_as_before() -> None: + # Given the pre-#49 shell lineage step + shell = """ - name: Verify the candidate's lineage + shell: bash + run: gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID" +""" + # Then shell stays forbidden: the module function is the only compliant shape + assert "shell-step" in _codes(HEADER + shell + DOWNLOAD_STEP + PYPI_STEP) diff --git a/CHANGELOG.md b/CHANGELOG.md index b830b4f..efdf5fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ ### Added +- Publisher lineage as a module function (hseshadr/ci#49): `portfolio-foundation` gains + `release-lineage` and `release-provenance`. They fail unless the candidate run is a + successful `release-candidate.yml` dispatch for exactly the expected SHA and `main` + contains that SHA. This blocks a dispatch on a tag named `main` from publishing its own + bytes. `release-provenance` also returns npm's GitHub Actions provenance context, built from + the publish run record. The fleet policy accepts this as a leading publisher step + (`publisher-lineage` for anything weaker), and accepts a consumer publisher loaded at + `@${{ github.sha }}`. Publishers no longer need a `run:` step for lineage. +- `dagger-args-expression`: the fleet policy rejects `${{ inputs.* }}`, + `${{ github.event.* }}` and `${{ github.head_ref }}` in any `dagger-for-github` input the + action pastes into bash. Pass the value through `env:` and quote it. Test fixtures that + pasted `${{ github.event.workflow_run.head_sha }}` into args as "compliant" now use + `--expected-sha="$HEAD_SHA"`. - Per-module required-minimum pin floors: the fleet scan reports `pin-below-required-minimum` for any consumer whose central module pin is not on `main` at or after the reviewed floor (`portfolio-foundation` ≥ `dd19871`, hseshadr/ci#46). diff --git a/docs/dagger-modules.md b/docs/dagger-modules.md index 7edc9dd..8fe50f0 100644 --- a/docs/dagger-modules.md +++ b/docs/dagger-modules.md @@ -413,6 +413,59 @@ missing from the list. The scan then fails. - A repository whose evidence cannot be read (for example, `main` has no branch protection) gets an `evidence-unreadable` finding. The scan keeps going and still fails. +## Publisher lineage + +**TL;DR:** before a `workflow_run` publisher trusts a candidate artifact, it calls +`portfolio-foundation`'s `release-lineage` (PyPI) or `release-provenance` (npm) at a literal +`hseshadr/ci` SHA. The call fails unless GitHub's own run records show the candidate came +from `main`. + +**Why:** the publisher's `head_branch == default_branch` gate also passes for a +`workflow_dispatch` on a *tag* named `main`. That tag's commit, and the +`release-candidate.yml` it runs, are whatever the tagger wrote. Without a lineage check, +the `main` publisher would publish those bytes over OIDC (hseshadr/ci#49). + +The function reads the triggering run and the running publish run, then requires all of: + +- the candidate run is a successful `workflow_dispatch` of `release-candidate.yml` in this + repository, for exactly `HEAD_SHA`; +- the publish run is this repository's in-progress `publish.yml` `workflow_run` on `main`; +- `compare/HEAD_SHA...publish_sha` and `compare/publish_sha...branches/main` are `ahead` or + `identical`. The branch SHA comes from the `branches/main` endpoint, so a tag named `main` + cannot stand in for the branch. + +`release-provenance` then returns `github-context.json`, the GitHub Actions context npm writes +into its SLSA provenance. It is built from the publish run record, not from caller text. + +The fleet policy accepts exactly this leading step and nothing weaker (`publisher-lineage`): + +```yaml + - uses: dagger/dagger-for-github@27b130bf0f79a7f6fbbbe0fbca6760dc9bb40a77 # v8.4.1 + env: + GH_TOKEN: ${{ github.token }} + RUN_ID: ${{ github.event.workflow_run.id }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + with: + version: "0.21.8" + verb: call + module: github.com/hseshadr/ci/modules/portfolio-foundation@<40-hex ci SHA> + args: release-lineage --github-token=env:GH_TOKEN --repository="$GITHUB_REPOSITORY" --run-id="$RUN_ID" --head-sha="$HEAD_SHA" --publish-run-id="$GITHUB_RUN_ID" +``` + +For npm, use `release-provenance` with the same arguments plus +`export --path=github-context.json`, then load the repository's own publisher at +`github.com/hseshadr/@${{ github.sha }}` (the `main` commit the workflow runs on, never +the candidate's SHA). The steps are then lineage → download → publish, with no `run:` step. + +**Expressions in Dagger inputs.** `dagger-for-github` pastes `args`, `call`, `shell`, +`dagger-flags`, `workdir`, and `cloud-token` into bash. The policy reports +`dagger-args-expression` for any `${{ inputs.* }}`, `${{ github.event.* }}` or +`${{ github.head_ref }}` there. Pass the value through `env:` and quote it: `--tag="$TAG"`. +`module` is exempt because the action passes it as the `INPUT_MODULE` environment variable. + +Not yet enforced: the policy accepts the lineage step but does not require it, so a +publisher without it still passes. Requiring it waits until every publisher has migrated. + ## Release status Shipped in this central change: diff --git a/modules/portfolio-foundation/.dagger/src/portfolio_foundation/github.py b/modules/portfolio-foundation/.dagger/src/portfolio_foundation/github.py index be9c451..20c2010 100644 --- a/modules/portfolio-foundation/.dagger/src/portfolio_foundation/github.py +++ b/modules/portfolio-foundation/.dagger/src/portfolio_foundation/github.py @@ -46,6 +46,7 @@ r"|/actions/jobs/[1-9][0-9]*" r"|/actions/runs/[1-9][0-9]*" r"|/actions/runs/[1-9][0-9]*/attempts/[1-9][0-9]*" + r"|/compare/[0-9a-f]{40}\.\.\.[0-9a-f]{40}" r")" ) NEXT_LINK_PATTERN: Final = re.compile(r'<([^>]+)>;\s*rel="next"') @@ -98,7 +99,7 @@ class DuplicateGreenCheckError(GitHubPolicyError): @dataclass(frozen=True) class ApiTarget: - """A validated relative path for one of the four read-only GitHub queries.""" + """A validated relative path for one of the fixed read-only GitHub queries.""" value: str diff --git a/modules/portfolio-foundation/.dagger/src/portfolio_foundation/lineage.py b/modules/portfolio-foundation/.dagger/src/portfolio_foundation/lineage.py new file mode 100644 index 0000000..579d3d9 --- /dev/null +++ b/modules/portfolio-foundation/.dagger/src/portfolio_foundation/lineage.py @@ -0,0 +1,297 @@ +"""Fail-closed release lineage: a publisher ships only a candidate that main contains. + +A `workflow_run` publisher gated on `head_branch == default_branch` also fires for a +dispatch on a TAG named `main`, whose tagged commit (and its release workflow) the +tagger wrote. Before any publisher trusts the candidate artifact, this proves from +GitHub's own run records that: + +- the candidate run is a successful `workflow_dispatch` of `release-candidate.yml` in + this repository for exactly the expected SHA; +- the publish run is this repository's running `publish.yml` `workflow_run` on `main`; +- main's history contains the candidate SHA and the publisher's commit. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Final + +import dagger +from pydantic import Field, JsonValue + +from .github import ( + ApiTarget, + ClosedPayload, + GitHubApi, + GitHubCredentialError, + GitHubPolicyError, + _GitHubRestApi, + _json_object, + _main_sha, + _model, + _object, + _project, + _required, +) +from .identity import FullSha, RepositoryRef + +CANDIDATE_WORKFLOW: Final = ".github/workflows/release-candidate.yml" +PUBLISH_WORKFLOW: Final = ".github/workflows/publish.yml" +DESCENDANT_STATUSES: Final = frozenset(("ahead", "identical")) +SERVER_URL: Final = "https://github.com" +RUN_FIELDS: Final = ( + "id", + "name", + "path", + "head_branch", + "head_sha", + "status", + "conclusion", + "event", + "run_attempt", +) + + +class OwnerPayload(ClosedPayload): # type: ignore[explicit-any] # Pydantic v2 base stub + """Projected repository owner identity.""" + + id: int = Field(gt=0) + + +class RunRepositoryPayload(ClosedPayload): # type: ignore[explicit-any] # Pydantic v2 base stub + """Projected repository identity of a workflow run.""" + + id: int = Field(gt=0) + full_name: str + owner: OwnerPayload + + +class HeadRepositoryPayload(ClosedPayload): # type: ignore[explicit-any] # Pydantic v2 base stub + """Projected repository that supplied a run's head commit.""" + + full_name: str + + +class LineageRunPayload(ClosedPayload): # type: ignore[explicit-any] # Pydantic v2 base stub + """Projected workflow-run identity for candidate and publish runs.""" + + id: int = Field(gt=0) + name: str + path: str + head_branch: str + head_sha: str + status: str + conclusion: str | None + event: str + run_attempt: int = Field(gt=0) + repository: RunRepositoryPayload + head_repository: HeadRepositoryPayload + + +class ComparePayload(ClosedPayload): # type: ignore[explicit-any] # Pydantic v2 base stub + """Projected ancestry relation between two commits.""" + + status: str + + +@dataclass(frozen=True) +class LineageRequest: + """Typed lineage inputs: one repository, candidate run, SHA, and publish run.""" + + repository: RepositoryRef + candidate_run_id: int + head_sha: FullSha + publish_run_id: int + + @classmethod + def parse( + cls, repository: str, candidate_run_id: int, head_sha: str, publish_run_id: int + ) -> LineageRequest: + """Reject malformed identities before any provider request.""" + if min(candidate_run_id, publish_run_id) <= 0: + raise ValueError("lineage run ids must be positive") + try: + parsed = RepositoryRef.parse(repository), FullSha(head_sha) + except ValueError: + raise ValueError("lineage repository and head SHA must be canonical") from None + return cls(parsed[0], candidate_run_id, parsed[1], publish_run_id) + + @property + def base(self) -> str: + """Return the repository API path.""" + return f"/repos/{self.repository.owner}/{self.repository.name}" + + @property + def full_name(self) -> str: + """Return canonical owner/repository text.""" + return f"{self.repository.owner}/{self.repository.name}" + + +@dataclass(frozen=True) +class LineageEvidence: + """Proven lineage plus the authoritative publish run it was proven for.""" + + repository: str + candidate_run_id: int + publish_run_id: int + head_sha: str + main_sha: str + branch_sha: str + publisher: LineageRunPayload + + def to_json(self) -> str: + """Render the proven identities without the raw run record.""" + values = { + "repository": self.repository, + "candidate_run_id": self.candidate_run_id, + "publish_run_id": self.publish_run_id, + "head_sha": self.head_sha, + "main_sha": self.main_sha, + "branch_sha": self.branch_sha, + } + return json.dumps(values) + + +async def release_lineage(github_token: dagger.Secret, request: LineageRequest) -> str: + """Verify lineage with a typed secret and return the evidence as JSON.""" + return (await _verified(github_token, request)).to_json() + + +async def release_provenance(github_token: dagger.Secret, request: LineageRequest) -> str: + """Verify lineage, then render npm's provenance context from the publish run.""" + return provenance_context(await _verified(github_token, request)) + + +async def _verified(github_token: dagger.Secret, request: LineageRequest) -> LineageEvidence: + token = await github_token.plaintext() + if not token: + raise GitHubCredentialError + return await verify_release_lineage_from_api(_GitHubRestApi(token), request) + + +async def verify_release_lineage_from_api( + api: GitHubApi, request: LineageRequest +) -> LineageEvidence: + """Apply the lineage policy to a read-only API adapter.""" + candidate = await _run(api, request, request.candidate_run_id) + _require_candidate(candidate, request) + publisher = await _run(api, request, request.publish_run_id) + _require_publisher(publisher, request) + main_sha = publisher.head_sha + await _require_contained(api, request, request.head_sha.value, main_sha) + branch_sha = await _main_sha(api, request.repository) + await _require_contained(api, request, main_sha, branch_sha) + return LineageEvidence( + request.full_name, + request.candidate_run_id, + request.publish_run_id, + request.head_sha.value, + main_sha, + branch_sha, + publisher, + ) + + +def provenance_context(evidence: LineageEvidence) -> str: + """Render the GitHub Actions context npm writes into its SLSA provenance.""" + run = evidence.publisher + ref = f"refs/heads/{run.head_branch}" + context = { + "GITHUB_EVENT_NAME": run.event, + "GITHUB_REF": ref, + "GITHUB_REPOSITORY": run.repository.full_name, + "GITHUB_REPOSITORY_ID": str(run.repository.id), + "GITHUB_REPOSITORY_OWNER_ID": str(run.repository.owner.id), + "GITHUB_RUN_ATTEMPT": str(run.run_attempt), + "GITHUB_RUN_ID": str(run.id), + "GITHUB_SERVER_URL": SERVER_URL, + "GITHUB_SHA": run.head_sha, + "GITHUB_WORKFLOW": run.name, + "GITHUB_WORKFLOW_REF": f"{run.repository.full_name}/{PUBLISH_WORKFLOW}@{ref}", + "RUNNER_ENVIRONMENT": "github-hosted", + } + return json.dumps(context, indent=2) + "\n" + + +async def _run(api: GitHubApi, request: LineageRequest, run_id: int) -> LineageRunPayload: + page = await api.get(ApiTarget(f"{request.base}/actions/runs/{run_id}")) + return _model(LineageRunPayload, _project_run(_json_object(page.body))) + + +def _project_run(payload: dict[str, JsonValue]) -> dict[str, JsonValue]: + repository = _object(_required(payload, "repository")) + owner = _project(_object(_required(repository, "owner")), ("id",)) + projected = _project(repository, ("id", "full_name")) | {"owner": owner} + head = _project(_object(_required(payload, "head_repository")), ("full_name",)) + return _project(payload, RUN_FIELDS) | {"repository": projected, "head_repository": head} + + +def _candidate_identity(run: LineageRunPayload) -> tuple[object, ...]: + return ( + run.id, + run.head_sha, + run.event, + run.status, + run.conclusion, + run.path.partition("@")[0], + run.repository.full_name, + run.head_repository.full_name, + ) + + +def _require_candidate(run: LineageRunPayload, request: LineageRequest) -> None: + expected = ( + request.candidate_run_id, + request.head_sha.value, + "workflow_dispatch", + "completed", + "success", + CANDIDATE_WORKFLOW, + request.full_name, + request.full_name, + ) + if _candidate_identity(run) != expected: + raise GitHubPolicyError("candidate run is not a successful release dispatch of this SHA") + + +def _publisher_identity(run: LineageRunPayload) -> tuple[object, ...]: + return ( + run.id, + run.event, + run.status, + run.head_branch, + run.path.partition("@")[0], + run.repository.full_name, + run.head_repository.full_name, + ) + + +def _require_publisher(run: LineageRunPayload, request: LineageRequest) -> None: + expected = ( + request.publish_run_id, + "workflow_run", + "in_progress", + "main", + PUBLISH_WORKFLOW, + request.full_name, + request.full_name, + ) + if _publisher_identity(run) != expected or not _is_full_sha(run.head_sha): + raise GitHubPolicyError("publish run is not this repository's running main publisher") + + +def _is_full_sha(value: str) -> bool: + try: + return FullSha(value).value == value + except ValueError: + return False + + +async def _require_contained( + api: GitHubApi, request: LineageRequest, ancestor: str, descendant: str +) -> None: + page = await api.get(ApiTarget(f"{request.base}/compare/{ancestor}...{descendant}")) + compare = _model(ComparePayload, _project(_json_object(page.body), ("status",))) + if compare.status not in DESCENDANT_STATUSES: + raise GitHubPolicyError(f"commit {ancestor} is not contained in main") diff --git a/modules/portfolio-foundation/.dagger/src/portfolio_foundation/main.py b/modules/portfolio-foundation/.dagger/src/portfolio_foundation/main.py index 4ae60e1..ce672d7 100644 --- a/modules/portfolio-foundation/.dagger/src/portfolio_foundation/main.py +++ b/modules/portfolio-foundation/.dagger/src/portfolio_foundation/main.py @@ -3,7 +3,7 @@ from __future__ import annotations import dagger -from dagger import function, object_type +from dagger import dag, function, object_type from .artifact import ( envelope_directory, @@ -14,8 +14,11 @@ from .github import CheckEvidence, resolve_green_main from .guard import build_guard from .identity import CommitIdentity, FullSha, RepositoryRef +from .lineage import LineageRequest, release_lineage, release_provenance from .source import SourceBinding, bind_dagger_source, dagger_history +PROVENANCE_FILE = "github-context.json" + @object_type class PortfolioFoundation: @@ -80,6 +83,33 @@ async def green_main(self, github_token: dagger.Secret, repository: str) -> Chec """Resolve exact-green main evidence using a typed secret.""" return await resolve_green_main(github_token, RepositoryRef.parse(repository)) + @function(cache="never") # type: ignore[call-overload,untyped-decorator] # SDK stub gap + async def release_lineage( + self, + github_token: dagger.Secret, + repository: str, + run_id: int, + head_sha: str, + publish_run_id: int, + ) -> str: + """Prove a release candidate run was built from main before anything publishes it.""" + request = LineageRequest.parse(repository, run_id, head_sha, publish_run_id) + return await release_lineage(github_token, request) + + @function(cache="never") # type: ignore[call-overload,untyped-decorator] # SDK stub gap + async def release_provenance( + self, + github_token: dagger.Secret, + repository: str, + run_id: int, + head_sha: str, + publish_run_id: int, + ) -> dagger.File: + """Prove lineage, then return npm's provenance context for the publish run.""" + request = LineageRequest.parse(repository, run_id, head_sha, publish_run_id) + context = await release_provenance(github_token, request) + return dag.directory().with_new_file(PROVENANCE_FILE, context).file(PROVENANCE_FILE) + async def _source_binding( source: dagger.Directory, diff --git a/modules/portfolio-foundation/.dagger/tests/test_lineage.py b/modules/portfolio-foundation/.dagger/tests/test_lineage.py new file mode 100644 index 0000000..f929054 --- /dev/null +++ b/modules/portfolio-foundation/.dagger/tests/test_lineage.py @@ -0,0 +1,303 @@ +from __future__ import annotations + +import asyncio +import json +from collections.abc import Mapping + +import pytest + +from portfolio_foundation.github import ( + ApiTarget, + GitHubCredentialError, + GitHubPolicyError, + GitHubResponseError, + HttpPage, +) +from portfolio_foundation.lineage import ( + LineageEvidence, + LineageRequest, + provenance_context, + release_lineage, + release_provenance, + verify_release_lineage_from_api, +) + +REPOSITORY = "owner/repository" +BASE = f"/repos/{REPOSITORY}" +CANDIDATE_RUN = 111 +PUBLISH_RUN = 222 +HEAD = "a" * 40 +MAIN = "b" * 40 +BRANCH = "c" * 40 +ATTACKER = "d" * 40 + +type Json = dict[str, object] + + +def _repository(full_name: str = REPOSITORY) -> Json: + return {"id": 42, "full_name": full_name, "owner": {"id": 7}} + + +def _candidate(**overrides: object) -> Json: + run: Json = { + "id": CANDIDATE_RUN, + "name": "Dagger release candidate", + "path": ".github/workflows/release-candidate.yml", + "head_branch": "main", + "head_sha": HEAD, + "status": "completed", + "conclusion": "success", + "event": "workflow_dispatch", + "run_attempt": 1, + "repository": _repository(), + "head_repository": {"full_name": REPOSITORY}, + } + return run | overrides + + +def _publisher(**overrides: object) -> Json: + run: Json = { + "id": PUBLISH_RUN, + "name": "Publish (npm, OIDC)", + "path": ".github/workflows/publish.yml", + "head_branch": "main", + "head_sha": MAIN, + "status": "in_progress", + "conclusion": None, + "event": "workflow_run", + "run_attempt": 2, + "repository": _repository(), + "head_repository": {"full_name": REPOSITORY}, + } + return run | overrides + + +class FakeApi: + def __init__(self, pages: Mapping[str, object]) -> None: + self._pages = pages + self.requested: list[str] = [] + + async def get(self, target: ApiTarget) -> HttpPage: + self.requested.append(target.value) + return HttpPage(json.dumps(self._pages[target.value])) + + +class FakeSecret: + def __init__(self, plaintext: str) -> None: + self._plaintext = plaintext + + async def plaintext(self) -> str: + return self._plaintext + + +def _pages( + *, + candidate: Json | None = None, + publisher: Json | None = None, + head_status: str = "ahead", + main_status: str = "identical", + head: str = HEAD, +) -> dict[str, object]: + return { + f"{BASE}/actions/runs/{CANDIDATE_RUN}": candidate or _candidate(), + f"{BASE}/actions/runs/{PUBLISH_RUN}": publisher or _publisher(), + f"{BASE}/compare/{head}...{MAIN}": {"status": head_status}, + f"{BASE}/branches/main": {"name": "main", "commit": {"sha": BRANCH}}, + f"{BASE}/compare/{MAIN}...{BRANCH}": {"status": main_status}, + } + + +def _request(head: str = HEAD) -> LineageRequest: + return LineageRequest.parse(REPOSITORY, CANDIDATE_RUN, head, PUBLISH_RUN) + + +def _verify(pages: Mapping[str, object], head: str = HEAD) -> LineageEvidence: + return asyncio.run(verify_release_lineage_from_api(FakeApi(pages), _request(head))) + + +def test_should_accept_a_candidate_dispatched_on_main_and_contained_in_main() -> None: + # Given a successful dispatch whose commit main already contains + api = FakeApi(_pages()) + + # When lineage is verified + evidence = asyncio.run(verify_release_lineage_from_api(api, _request())) + + # Then the evidence binds the candidate, the publisher commit, and live main + assert (evidence.head_sha, evidence.main_sha, evidence.branch_sha) == (HEAD, MAIN, BRANCH) + assert json.loads(evidence.to_json()) == { + "repository": REPOSITORY, + "candidate_run_id": CANDIDATE_RUN, + "publish_run_id": PUBLISH_RUN, + "head_sha": HEAD, + "main_sha": MAIN, + "branch_sha": BRANCH, + } + assert f"{BASE}/compare/{HEAD}...{MAIN}" in api.requested + + +@pytest.mark.parametrize("status", ["diverged", "behind"]) +def test_should_reject_a_tag_named_main_whose_commit_is_not_on_main(status: str) -> None: + # Given a dispatch on a TAG named `main`: head_branch reads "main" and the run + # succeeded, but the tagged commit is attacker-authored and not in main's history + pages = _pages(candidate=_candidate(head_sha=ATTACKER), head=ATTACKER, head_status=status) + + # When / Then the publisher refuses before any artifact is trusted + with pytest.raises(GitHubPolicyError, match="not contained in main"): + _verify(pages, head=ATTACKER) + + +def test_should_reject_a_candidate_run_for_a_different_sha() -> None: + # Given the event names one SHA but the run record built another + pages = _pages(candidate=_candidate(head_sha=ATTACKER)) + + # When / Then + with pytest.raises(GitHubPolicyError, match="candidate run"): + _verify(pages) + + +def test_should_reject_a_publisher_commit_that_main_does_not_contain() -> None: + # Given a publisher commit that has left (or never joined) main + pages = _pages(main_status="diverged") + + # When / Then + with pytest.raises(GitHubPolicyError, match="not contained in main"): + _verify(pages) + + +@pytest.mark.parametrize( + "override", + [ + {"event": "push"}, + {"status": "in_progress"}, + {"conclusion": "failure"}, + {"conclusion": None}, + {"path": ".github/workflows/other.yml"}, + {"path": ".github/workflows/release-candidate.yml.evil"}, + {"repository": _repository("attacker/repository")}, + {"head_repository": {"full_name": "attacker/repository"}}, + {"id": CANDIDATE_RUN + 1}, + ], +) +def test_should_reject_any_candidate_run_that_is_not_a_successful_release_dispatch( + override: Json, +) -> None: + # Given a candidate run record that differs in one identity field + pages = _pages(candidate=_candidate(**override)) + + # When / Then + with pytest.raises(GitHubPolicyError, match="candidate run"): + _verify(pages) + + +def test_should_accept_a_dispatch_path_carrying_its_ref_suffix() -> None: + # Given GitHub's `path@ref` form for a dispatched run + pages = _pages(candidate=_candidate(path=".github/workflows/release-candidate.yml@main")) + + # When / Then + assert _verify(pages) is not None + + +@pytest.mark.parametrize( + "override", + [ + {"event": "workflow_dispatch"}, + {"status": "completed"}, + {"path": ".github/workflows/release-candidate.yml"}, + {"head_branch": "feature"}, + {"repository": _repository("attacker/repository")}, + {"id": PUBLISH_RUN + 1}, + ], +) +def test_should_reject_a_publish_run_that_is_not_this_repositorys_running_publisher( + override: Json, +) -> None: + # Given a publish run record that is not the live main publish.yml workflow_run + pages = _pages(publisher=_publisher(**override)) + + # When / Then + with pytest.raises(GitHubPolicyError, match="publish run"): + _verify(pages) + + +def test_should_reject_a_publish_run_whose_sha_is_not_a_full_sha() -> None: + pages = _pages(publisher=_publisher(head_sha="B" * 40)) + + with pytest.raises(GitHubPolicyError, match="publish run"): + _verify(pages) + + +def test_should_reject_a_response_that_omits_an_identity_field() -> None: + candidate = _candidate() + del candidate["head_repository"] + + with pytest.raises(GitHubResponseError): + _verify(_pages(candidate=candidate)) + + +@pytest.mark.parametrize( + ("repository", "run_id", "head", "publish_run"), + [ + ("owner", CANDIDATE_RUN, HEAD, PUBLISH_RUN), + (REPOSITORY, 0, HEAD, PUBLISH_RUN), + (REPOSITORY, CANDIDATE_RUN, HEAD[:7], PUBLISH_RUN), + (REPOSITORY, CANDIDATE_RUN, "A" * 40, PUBLISH_RUN), + (REPOSITORY, CANDIDATE_RUN, HEAD, -1), + ], +) +def test_should_reject_malformed_lineage_inputs_before_any_request( + repository: str, run_id: int, head: str, publish_run: int +) -> None: + with pytest.raises(ValueError, match="lineage"): + LineageRequest.parse(repository, run_id, head, publish_run) + + +def test_should_derive_the_npm_provenance_context_from_the_publish_run_record() -> None: + # Given verified lineage and the authoritative publish run record + evidence = _verify(_pages()) + + # When the npm provenance context is rendered + context = json.loads(provenance_context(evidence)) + + # Then it carries exactly the GitHub Actions values npm writes into provenance + assert context == { + "GITHUB_EVENT_NAME": "workflow_run", + "GITHUB_REF": "refs/heads/main", + "GITHUB_REPOSITORY": REPOSITORY, + "GITHUB_REPOSITORY_ID": "42", + "GITHUB_REPOSITORY_OWNER_ID": "7", + "GITHUB_RUN_ATTEMPT": "2", + "GITHUB_RUN_ID": str(PUBLISH_RUN), + "GITHUB_SERVER_URL": "https://github.com", + "GITHUB_SHA": MAIN, + "GITHUB_WORKFLOW": "Publish (npm, OIDC)", + "GITHUB_WORKFLOW_REF": f"{REPOSITORY}/.github/workflows/publish.yml@refs/heads/main", + "RUNNER_ENVIRONMENT": "github-hosted", + } + + +def test_should_read_the_typed_token_and_verify_through_the_rest_adapter( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Given the REST adapter is replaced by the fake provider + tokens: list[str] = [] + + def adapter(token: str) -> FakeApi: + tokens.append(token) + return FakeApi(_pages()) + + monkeypatch.setattr("portfolio_foundation.lineage._GitHubRestApi", adapter) + secret = FakeSecret("token-value") + + # When both public entry points run + lineage = asyncio.run(release_lineage(secret, _request())) # type: ignore[arg-type] + context = asyncio.run(release_provenance(secret, _request())) # type: ignore[arg-type] + + # Then each read the typed secret once and returned its rendered evidence + assert tokens == ["token-value", "token-value"] + assert json.loads(lineage)["head_sha"] == HEAD + assert json.loads(context)["GITHUB_SHA"] == MAIN + + +def test_should_refuse_an_empty_token() -> None: + with pytest.raises(GitHubCredentialError): + asyncio.run(release_lineage(FakeSecret(""), _request())) # type: ignore[arg-type] diff --git a/modules/portfolio-foundation/.dagger/tests/test_public_schema.py b/modules/portfolio-foundation/.dagger/tests/test_public_schema.py index c8455b0..2e12535 100644 --- a/modules/portfolio-foundation/.dagger/tests/test_public_schema.py +++ b/modules/portfolio-foundation/.dagger/tests/test_public_schema.py @@ -55,6 +55,28 @@ (("github_token", "dagger.Secret"), ("repository", "str")), "CheckEvidence", ), + ( + "release_lineage", + ( + ("github_token", "dagger.Secret"), + ("repository", "str"), + ("run_id", "int"), + ("head_sha", "str"), + ("publish_run_id", "int"), + ), + "str", + ), + ( + "release_provenance", + ( + ("github_token", "dagger.Secret"), + ("repository", "str"), + ("run_id", "int"), + ("head_sha", "str"), + ("publish_run_id", "int"), + ), + "dagger.File", + ), ) From 3de1c4bef2558fd6610b6dda1504b657de7a954d Mon Sep 17 00:00:00 2001 From: Harish Seshadri Date: Fri, 25 Sep 2026 09:43:45 -0700 Subject: [PATCH 4/4] refactor(foundation): keep lineage helpers within 15 lines Split evidence construction and the repository part of the provenance context into named helpers (python-quality function-length rule). The provenance JSON keys are now emitted sorted. Behavior is unchanged; lineage.py stays at 100% line and branch coverage. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015oBArfm762nN1r4F4Fst5a --- .../src/portfolio_foundation/lineage.py | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/modules/portfolio-foundation/.dagger/src/portfolio_foundation/lineage.py b/modules/portfolio-foundation/.dagger/src/portfolio_foundation/lineage.py index 579d3d9..0ae9156 100644 --- a/modules/portfolio-foundation/.dagger/src/portfolio_foundation/lineage.py +++ b/modules/portfolio-foundation/.dagger/src/portfolio_foundation/lineage.py @@ -182,27 +182,24 @@ async def verify_release_lineage_from_api( await _require_contained(api, request, request.head_sha.value, main_sha) branch_sha = await _main_sha(api, request.repository) await _require_contained(api, request, main_sha, branch_sha) - return LineageEvidence( - request.full_name, - request.candidate_run_id, - request.publish_run_id, - request.head_sha.value, - main_sha, - branch_sha, - publisher, - ) + return _evidence(request, branch_sha, publisher) + + +def _evidence( + request: LineageRequest, branch_sha: str, publisher: LineageRunPayload +) -> LineageEvidence: + runs = (request.candidate_run_id, request.publish_run_id) + shas = (request.head_sha.value, publisher.head_sha, branch_sha) + return LineageEvidence(request.full_name, *runs, *shas, publisher) def provenance_context(evidence: LineageEvidence) -> str: """Render the GitHub Actions context npm writes into its SLSA provenance.""" run = evidence.publisher ref = f"refs/heads/{run.head_branch}" - context = { + context = _repository_context(run) | { "GITHUB_EVENT_NAME": run.event, "GITHUB_REF": ref, - "GITHUB_REPOSITORY": run.repository.full_name, - "GITHUB_REPOSITORY_ID": str(run.repository.id), - "GITHUB_REPOSITORY_OWNER_ID": str(run.repository.owner.id), "GITHUB_RUN_ATTEMPT": str(run.run_attempt), "GITHUB_RUN_ID": str(run.id), "GITHUB_SERVER_URL": SERVER_URL, @@ -211,7 +208,15 @@ def provenance_context(evidence: LineageEvidence) -> str: "GITHUB_WORKFLOW_REF": f"{run.repository.full_name}/{PUBLISH_WORKFLOW}@{ref}", "RUNNER_ENVIRONMENT": "github-hosted", } - return json.dumps(context, indent=2) + "\n" + return json.dumps(dict(sorted(context.items())), indent=2) + "\n" + + +def _repository_context(run: LineageRunPayload) -> dict[str, str]: + return { + "GITHUB_REPOSITORY": run.repository.full_name, + "GITHUB_REPOSITORY_ID": str(run.repository.id), + "GITHUB_REPOSITORY_OWNER_ID": str(run.repository.owner.id), + } async def _run(api: GitHubApi, request: LineageRequest, run_id: int) -> LineageRunPayload: