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 3d79823..e54855e 100644 --- a/docs/dagger-modules.md +++ b/docs/dagger-modules.md @@ -479,6 +479,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..232c3de --- /dev/null +++ b/modules/portfolio-foundation/.dagger/src/portfolio_foundation/lineage.py @@ -0,0 +1,302 @@ +"""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 _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 = _repository_context(run) | { + "GITHUB_EVENT_NAME": run.event, + "GITHUB_REF": ref, + "GITHUB_RUN_ATTEMPT": str(run.run_attempt), + "GITHUB_RUN_ID": str(run.id), + "GITHUB_SHA": run.head_sha, + "GITHUB_WORKFLOW": run.name, + "GITHUB_WORKFLOW_REF": f"{run.repository.full_name}/{PUBLISH_WORKFLOW}@{ref}", + } + 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), + "GITHUB_SERVER_URL": SERVER_URL, + "RUNNER_ENVIRONMENT": "github-hosted", + } + + +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", + ), )