From a1a52d1784df6fa7980a6fafa5e630a06e34f504 Mon Sep 17 00:00:00 2001 From: "trukhin.com" Date: Wed, 9 Sep 2026 17:21:02 +0200 Subject: [PATCH 1/3] feat(safepush): verify native GitHub CI provenance Signed-off-by: trukhin.com --- .github/safepush.json | 54 ++ cmd/cloudring-safepush/main.go | 130 +++++ cmd/cloudring-safepush/main_test.go | 90 +++ docs/ci-checks.md | 51 +- docs/safepush.md | 128 +++++ pkg/safepush/checks.go | 157 ++++++ pkg/safepush/checks_test.go | 330 +++++++++++ pkg/safepush/doc.go | 19 + pkg/safepush/github.go | 748 +++++++++++++++++++++++++ pkg/safepush/github_test.go | 718 ++++++++++++++++++++++++ pkg/safepush/policy.go | 155 +++++ pkg/safepush/policy_test.go | 79 +++ pkg/safepush/repository_policy_test.go | 117 ++++ pkg/safepush/workflow_source.go | 130 +++++ pkg/safepush/workflow_source_test.go | 129 +++++ 15 files changed, 3009 insertions(+), 26 deletions(-) create mode 100644 .github/safepush.json create mode 100644 cmd/cloudring-safepush/main.go create mode 100644 cmd/cloudring-safepush/main_test.go create mode 100644 docs/safepush.md create mode 100644 pkg/safepush/checks.go create mode 100644 pkg/safepush/checks_test.go create mode 100644 pkg/safepush/doc.go create mode 100644 pkg/safepush/github.go create mode 100644 pkg/safepush/github_test.go create mode 100644 pkg/safepush/policy.go create mode 100644 pkg/safepush/policy_test.go create mode 100644 pkg/safepush/repository_policy_test.go create mode 100644 pkg/safepush/workflow_source.go create mode 100644 pkg/safepush/workflow_source_test.go diff --git a/.github/safepush.json b/.github/safepush.json new file mode 100644 index 0000000..dbb5878 --- /dev/null +++ b/.github/safepush.json @@ -0,0 +1,54 @@ +{ + "schemaVersion": "cloudring.safepush.github/v1", + "repository": "opencloudtech/CloudRING", + "repositoryId": 1288130713, + "workflows": [ + { + "path": ".github/workflows/ci.yml", + "jobs": [ + "docscheck", + "go-build-module-integrity", + "go-test", + "go-test-race", + "go-vet", + "ocs-conformance", + "ocs-validate", + "platform-manifests", + "postgres-integration", + "source-safety", + "windows-go-test" + ] + }, + { + "path": ".github/workflows/cla-dco.yml", + "jobs": [ + "cla", + "dco" + ] + }, + { + "path": ".github/workflows/license.yml", + "jobs": [ + "license-check" + ] + }, + { + "path": ".github/workflows/security.yml", + "jobs": [ + "codeql", + "gosec", + "govulncheck", + "secret-scan" + ] + }, + { + "path": ".github/workflows/supply-chain.yml", + "jobs": [ + "supply-chain-policy" + ] + } + ], + "excludedWorkflows": [ + ".github/workflows/release-provenance.yml" + ] +} diff --git a/cmd/cloudring-safepush/main.go b/cmd/cloudring-safepush/main.go new file mode 100644 index 0000000..1ed6f01 --- /dev/null +++ b/cmd/cloudring-safepush/main.go @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) IURII TRUKHIN 2012-2022, Elena Trukhina 2023-2026. Project and trademarks: Elena Trukhina ZZP. + +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "net/http" + "os" + "os/signal" + "time" + + "github.com/opencloudtech/CloudRING/pkg/safepush" +) + +type githubVerifier func(context.Context, *http.Client, string, safepush.GitHubPolicy, safepush.GitHubCandidate) error +type githubPolicyReader func(context.Context, *http.Client, string, string, string) (safepush.GitHubPolicy, error) + +func main() { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + os.Exit(run(ctx, os.Args[1:], os.Getenv, os.Stdout, os.Stderr, safepush.ReadGitHubPolicy, safepush.VerifyGitHub)) +} + +// The native required workflow owns these arguments. There is deliberately no +// caller-supplied check-results file, success flag, API host, or policy override. +func run(ctx context.Context, args []string, getenv func(string) string, stdout, stderr io.Writer, read githubPolicyReader, verify githubVerifier) int { + if len(args) == 0 || args[0] != "github" { + fmt.Fprintln(stderr, "usage: cloudring-safepush github --repository OWNER/REPO --pr NUMBER --head SHA --base SHA --branch main|master") + return 2 + } + flags := flag.NewFlagSet("github", flag.ContinueOnError) + flags.SetOutput(io.Discard) + repository := flags.String("repository", "", "target GitHub repository") + pr := flags.Int("pr", 0, "pull request number") + head := flags.String("head", "", "exact proposed commit") + base := flags.String("base", "", "exact accepted target commit") + branch := flags.String("branch", "", "protected target branch") + timeout := flags.Duration("timeout", 20*time.Minute, "bounded wait for native CI, at most 30 minutes") + if flags.Parse(args[1:]) != nil || flags.NArg() != 0 || *repository == "" || *pr <= 0 || + *head == "" || *base == "" || (*branch != "main" && *branch != "master") || + *timeout <= 0 || *timeout > 30*time.Minute { + fmt.Fprintln(stderr, "SafePush request is invalid") + return 2 + } + candidate := safepush.GitHubCandidate{PullRequest: *pr, HeadSHA: *head, BaseSHA: *base, TargetBranch: *branch} + if safepush.ValidateGitHubCandidate(*repository, candidate) != nil { + fmt.Fprintln(stderr, "SafePush request is invalid") + return 2 + } + bearer := getenv("GITHUB_TOKEN") + if bearer == "" { + fmt.Fprintln(stderr, "SafePush requires the workflow's read-only GITHUB_TOKEN") + return 2 + } + ctx, cancel := context.WithTimeout(ctx, *timeout) + defer cancel() + client := &http.Client{Timeout: 30 * time.Second} + policy, err := read(ctx, client, bearer, *repository, *base) + if err != nil { + fmt.Fprintln(stderr, "SafePush blocked:", safeDiagnostic(err)) + return 1 + } + if policy.Repository != *repository { + fmt.Fprintln(stderr, "SafePush blocked: accepted policy belongs to another repository") + return 1 + } + err = waitForChecks(ctx, 30*time.Second, func() error { return verify(ctx, client, bearer, policy, candidate) }) + if err != nil { + fmt.Fprintln(stderr, "SafePush blocked:", safeDiagnostic(err)) + return 1 + } + if _, err := fmt.Fprintln(stdout, "SafePush: the exact required CI checks passed for the current candidate and base"); err != nil { + return 1 + } + return 0 +} + +func safeDiagnostic(err error) string { + switch { + case errors.Is(err, context.Canceled): + return "request canceled" + case errors.Is(err, context.DeadlineExceeded): + return "verification deadline exceeded" + case errors.Is(err, safepush.ErrPolicy): + return "accepted repository policy is invalid" + case errors.Is(err, safepush.ErrGitHubAPI): + return "GitHub API is unavailable or returned an invalid response" + case errors.Is(err, safepush.ErrGitHubSource): + return "workflow or policy source does not match the accepted revision" + case errors.Is(err, safepush.ErrGitHubState): + return "pull request or protected target is stale or changed during verification" + case errors.Is(err, safepush.ErrGitHubRun): + return "CI run, job, or step provenance is invalid" + case errors.Is(err, safepush.ErrMissingCheck): + return "a required CI job is missing" + case errors.Is(err, safepush.ErrUnexpectedCheck), errors.Is(err, safepush.ErrDuplicateObservation): + return "the CI job set is unexpected or ambiguous" + case errors.Is(err, safepush.ErrCheckNotSuccessful): + return "a required CI job did not complete successfully" + default: + return "verification failed" + } +} + +func waitForChecks(ctx context.Context, interval time.Duration, verify func() error) error { + for { + if err := ctx.Err(); err != nil { + return err + } + err := verify() + if contextErr := ctx.Err(); contextErr != nil { + return contextErr + } + if !errors.Is(err, safepush.ErrGitHubPending) { + return err + } + timer := time.NewTimer(interval) + select { + case <-ctx.Done(): + timer.Stop() + return ctx.Err() + case <-timer.C: + } + } +} diff --git a/cmd/cloudring-safepush/main_test.go b/cmd/cloudring-safepush/main_test.go new file mode 100644 index 0000000..56e234a --- /dev/null +++ b/cmd/cloudring-safepush/main_test.go @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) IURII TRUKHIN 2012-2022, Elena Trukhina 2023-2026. Project and trademarks: Elena Trukhina ZZP. + +package main + +import ( + "bytes" + "context" + "errors" + "net/http" + "strings" + "testing" + "time" + + "github.com/opencloudtech/CloudRING/pkg/safepush" +) + +func TestPendingIsTheOnlyRetriableAdmissionResult(t *testing.T) { + for _, terminal := range []error{nil, safepush.ErrCheckNotSuccessful, safepush.ErrPolicy, errors.New("unavailable")} { + calls := 0 + err := waitForChecks(context.Background(), time.Nanosecond, func() error { + calls++ + if calls == 1 { + return safepush.ErrGitHubPending + } + return terminal + }) + if calls != 2 || !errors.Is(err, terminal) { + t.Fatalf("calls=%d error=%v", calls, err) + } + } +} + +func TestCancellationStopsPendingAdmission(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + calls := 0 + err := waitForChecks(ctx, time.Hour, func() error { calls++; cancel(); return safepush.ErrGitHubPending }) + if !errors.Is(err, context.Canceled) || calls != 1 { + t.Fatalf("calls=%d error=%v", calls, err) + } +} + +func TestSuccessAfterCancellationIsNotAccepted(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + err := waitForChecks(ctx, time.Nanosecond, func() error { cancel(); return nil }) + if !errors.Is(err, context.Canceled) { + t.Fatalf("late success was accepted: %v", err) + } +} + +func TestCommandDoesNotEchoSensitiveProviderErrors(t *testing.T) { + args := []string{"github", "--repository", "synthetic/project", "--pr", "1", "--head", strings.Repeat("a", 40), "--base", strings.Repeat("b", 40), "--branch", "main"} + marker := "synthetic-confidential-marker" + read := func(context.Context, *http.Client, string, string, string) (safepush.GitHubPolicy, error) { + return safepush.GitHubPolicy{Repository: "synthetic/project"}, nil + } + for _, failure := range []error{nil, errors.New(marker)} { + var out, errout bytes.Buffer + code := run(context.Background(), args, func(string) string { return marker }, &out, &errout, read, + func(context.Context, *http.Client, string, safepush.GitHubPolicy, safepush.GitHubCandidate) error { + return failure + }) + if (code == 0) != (failure == nil) { + t.Fatalf("unexpected exit %d", code) + } + if strings.Contains(out.String()+errout.String(), marker) { + t.Fatal("provider error leaked sensitive input") + } + } +} + +func TestInvalidInvocationMakesNoProviderCall(t *testing.T) { + for _, args := range [][]string{ + {}, {"github", "--trusted=true"}, + {"github", "--repository", "synthetic/project", "--pr", "1", "--head", "a", "--base", "b", "--branch", "feature"}, + {"github", "--repository", "synthetic/project", "--pr", "1", "--head", "a", "--base", "b", "--branch", "main", "--timeout", "31m"}, + {"github", "--repository", "synthetic/project", "--pr", "1", "--head", "a", "--base", "main", "--branch", "main"}, + {"github", "--repository", "synthetic/../project", "--pr", "1", "--head", strings.Repeat("a", 40), "--base", strings.Repeat("b", 40), "--branch", "main"}, + {"github", "--repository", "synthetic/project", "--pr", "1", "--head", strings.Repeat("0", 40), "--base", strings.Repeat("b", 40), "--branch", "main"}, + } { + var out bytes.Buffer + read := func(context.Context, *http.Client, string, string, string) (safepush.GitHubPolicy, error) { + t.Fatal("provider called for invalid request") + return safepush.GitHubPolicy{}, nil + } + if code := run(context.Background(), args, func(string) string { return "synthetic-test-value" }, &out, &out, read, nil); code != 2 { + t.Fatalf("exit=%d", code) + } + } +} diff --git a/docs/ci-checks.md b/docs/ci-checks.md index eb2fb12..3832338 100644 --- a/docs/ci-checks.md +++ b/docs/ci-checks.md @@ -1,8 +1,7 @@ # CloudRING CI Checks -Public CI for a clean clone runs these read-only checks on the latest security -patch of the minimum supported Go release (1.25) and runs unit tests again on -the current supported release (1.26): +Public CI for a clean clone runs these read-only checks with the workflow's +pinned Go toolchain, currently Go 1.26.8: ```bash go mod download @@ -31,9 +30,9 @@ The public CI contract covers these checks: | Check | Contract | | --- | --- | -| Go tests | The public module must pass tests on supported minimum/current Go releases, plus race, vet, read-only module graph, and build checks. | +| Go tests | The complete public module must pass tests with the pinned CI toolchain, plus race, vet, read-only module graph, and build checks. | | PostgreSQL integration | Transactional-state CAS, migrations, concurrent writers, and the public runtime/session journey must pass against real digest-pinned PostgreSQL. The pinned Linux test container also exercises interrupted guest database directory setup and ownership rejection as UID 0; it asserts that identity before running these filesystem tests. | -| Windows | The same unit suite is run on `windows-latest` as a portability signal. Native Windows support is not a release-readiness blocker for the current goal. | +| Windows | The same unit suite runs on `windows-latest` and is included in the SafePush pre-merge policy. A passing suite does not establish native Windows release readiness. | | OCS validation | Every shipped connector package selected by the shared CI package list must pass `go run ./cmd/ocsctl validate`. | | OCS conformance | The same exact shipped connector packages must pass `go run ./cmd/ocsctl conformance`; validation cannot be green for an artifact that CI omits from conformance. | | Synthetic reference image | The digest-pinned `Containerfile` must build and its local mock-provider self-check must pass. | @@ -66,27 +65,27 @@ runtime image, credential context, conditional skip, and `continue-on-error`. Changing a reviewed workflow requires changing its recorded digest in the same review. -This repository check is defense in depth, not a self-authenticating policy: a -pull request controls the workflow revision that evaluates that pull request. -The server-side trust control for external contributors is protected `main` -with an up-to-date branch, one approving owner review, stale-review dismissal, -last-push approval, required conversations and checks, and no force-push or -branch deletion. The project founder and lead maintainer, `@trukhinyuri`, is -the final acceptance authority. Contributor-authored changes require that -owner review; automated or AI-assisted review does not replace the founder's -decision. - -GitHub does not let a pull-request author submit the approval required by the -same branch rule. While the repository has a single founder/administrator, a -founder-authored pull request may therefore use the administrator merge path -after a recorded founder review of the exact head SHA. This narrow owner path -still requires every configured status check to succeed on the current head, -an up-to-date base, resolved conversations, source-safety approval, and a -post-merge `main` verification. It does not authorize direct pushes, skipped -checks, force-pushes, branch deletion, or bypasses for other contributors. -Adding another administrator changes this trust assumption and requires an -explicit governance and branch-protection review before that access is -granted. +This repository check is defense in depth: a pull request controls the +workflow revision that evaluates that pull request. The separate +[SafePush verifier and deployment contract](safepush.md) bind CI observations +to the accepted policy, workflow sources, repository, pull request and commit. +The verifier must run from trusted immutable source and be required by the +hosting platform. Its presence in the repository does not enable that control. + +The required acceptance policy is an up-to-date protected target, every +required test passing and an owner-approved merge. Both `main` and `master` +must be covered; ordinary working branches remain unrestricted. The project +founder and lead maintainer, `@trukhinyuri`, is the final acceptance authority. +Automated or AI-assisted review does not replace that decision. Native review +rules must dismiss stale approvals and prevent unauthorized dismissal. + +Only the explicitly designated owner may bypass test and review requirements; +future delegates require an explicit policy change. Administrator or +Maintainer status alone must not grant that exception. This owner path also +handles the platform's prohibition on approving one's own pull request. +Force-push and deletion controls remain separate from the test/review +exception. The actual installed rules and both ordinary and bypass behavior +must be read back and tested before claiming SafePush is enabled. ## Release provenance diff --git a/docs/safepush.md b/docs/safepush.md new file mode 100644 index 0000000..22d7fff --- /dev/null +++ b/docs/safepush.md @@ -0,0 +1,128 @@ +# SafePush admission + +SafePush checks whether the exact proposed source has completed the full test +policy before a protected branch can change. It belongs in trusted CI and the +hosting platform's admission controls. A local hook is an optional early check; +disabling it must never permit a protected update. + +## Implementation and enforcement are separate + +`pkg/safepush` provides the check evaluator and a native GitHub CI adapter. +`cloudring-safepush github` reads `.github/safepush.json` from the exact accepted +target commit through the GitHub API, then verifies the current pull request +and its CI runs. It does not read a candidate-supplied success report, execute +candidate code, approve a review, publish a status, merge, or update a ref. + +The policy is a complete list of pre-merge workflows and jobs. In particular, +Windows tests are included even when a historical branch rule omitted them. +Excluded workflows must be explicitly listed and must not contain required +pre-merge tests. The release workflow runs after acceptance and is checked +separately as part of release delivery. + +Only actual completed-success jobs satisfy the policy. A missing job, an +unexpected job, a duplicate, a skipped or neutral result, an earlier successful +attempt hiding a later failure, an unrelated same-named workflow, or a changed +head/base must block the result. An API failure or incomplete response is not +evidence that there are no failing checks. + +The adapter also reads each accepted required workflow blob and checks its +Git object hash before parsing the YAML. A job or step may omit +`continue-on-error` or set it to the literal boolean `false`. Allowed failures +cannot be identified reliably from API conclusions alone. YAML aliases, +anchors, merge keys, duplicate keys and multiple documents are unsupported +and block verification. + +The native adapter owns pagination, repository/run/job identity, source +binding, selection of the current attempt and final state readback. The pure +`VerifyChecks` helper checks consistency of those observations; it is not an +authentication or enforcement boundary on its own. Passing a metadata check +also does not prove that a modified test still tests the right behavior. Human +review remains necessary. + +GitHub observations require an open, non-draft PR and the current target as +an ancestor of its head. Every selected run must be associated with that exact +PR, head, base and target. These API reads are not atomic with a later merge, +and the run's head and PR association alone do not prove the historical tested +merge commit. Native target freshness and owner review remain required; the +deployment must qualify its event and checkout behavior before activation. + +## GitHub deployment contract + +For repositories that require pull requests, the organization must require the +SafePush workflow from an immutable accepted CloudRING source commit. A +candidate-owned wrapper calling a reusable workflow is insufficient: the +candidate could delete or replace the wrapper. Requiring only the GitHub +Actions app and a check name also does not identify a particular workflow. + +The trusted workflow must check out only its own immutable source, run on a +GitHub-hosted runner, and use read-only contents, Actions, checks and pull +request access. It must not execute candidate scripts or actions, consume +candidate artifacts or shared build caches, or receive publishing credentials. +The CLI takes the current candidate identity from the native event and reads +the accepted policy itself: + +```sh +cloudring-safepush github \ + --repository "$TARGET_REPOSITORY" \ + --pr "$PULL_REQUEST_NUMBER" \ + --head "$CANDIDATE_SHA" \ + --base "$ACCEPTED_BASE_SHA" \ + --branch "$TARGET_BRANCH" +``` + +The job supplies its read-only `GITHUB_TOKEN`. Only pending CI is retried, for +a bounded time. Completed failures, mismatched identity, changed source, +authentication failure and malformed responses stop admission immediately. + +Native rules must independently enforce the owner's approval, dismiss stale +reviews and prevent unauthorized review dismissal. Both `main` and `master` +need protection. Test/review bypass identities must be explicit; assigning an +administrator role must not automatically add a SafePush exception. Force +push and deletion controls should remain separate from test/review exceptions. +Existing native code scanning checks remain required alongside SafePush; +generated provider workflows must not be silently relabeled as trusted +repository files. + +Keep every underlying required CI check in the native rule as well. An old +successful aggregate cannot revoke itself if a later attempt on the same head +fails after the verifier exits. Activation must test that a later failing +attempt blocks acceptance even when an earlier aggregate was successful. + +GitHub's required-workflow rule blocks direct pushes and supports PR and merge +queue events. It must not be presented as accepting arbitrary prechecked direct +pushes. A merge queue needs its own exact merge-group observation contract +before being enabled; a PR-only verifier cannot validate it. + +## GitLab deployment contract + +The tests must run on qualified nonpersonal Linux CI infrastructure with an +immutable trusted configuration. A candidate-local `.gitlab-ci.yml` include +cannot make a policy mandatory, because another candidate can remove or +override it. Pipeline and approval observations must come from GitLab, not +forwarded variables asserting that every gate passed. + +On GitLab Community Edition, successful-pipeline merge settings do not check +direct pushes, and role-based branch permissions cannot isolate one reviewer +when the project inherits other Maintainers and Owners. A green approval job +is also insufficient: the approval can be revoked after the pipeline passes. +Where native enforcement cannot express the policy, a server admission hook +must perform the final current-state decision before the ref changes. + +Such a hook must be installed and owned by the GitLab administrator, outside +candidate-controlled files. Its allowlist must scope it to the intended +CloudRING project IDs and protected refs. It must preserve unrestricted +ordinary branch pushes, reject absent/ambiguous/stale CI or review evidence, +verify the exact old/new commit relationship, and support only explicitly +authorized bypass identities. It must not use a personal workstation or +execute candidate code with its admission credentials. + +## Activation evidence + +Committing these sources or passing local tests does not enable server +enforcement. Record the actual installed immutable revision and re-read the +hosting platform's rules. Use an identity without bypass to verify that an +ordinary branch push succeeds while missing/failed/skipped tests, an +unapproved change, altered CI policy and stale commit evidence cannot advance +`main` or `master`. Verify a real approved all-green change, the intended owner +exception and unchanged out-of-scope repositories. Preserve an explicit +incomplete state until those checks have passed. diff --git a/pkg/safepush/checks.go b/pkg/safepush/checks.go new file mode 100644 index 0000000..17f086a --- /dev/null +++ b/pkg/safepush/checks.go @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) IURII TRUKHIN 2012-2022, Elena Trukhina 2023-2026. Project and trademarks: Elena Trukhina ZZP. + +package safepush + +import ( + "errors" + "strings" + "unicode" + "unicode/utf8" +) + +// Verification errors contain fixed categories, never input values or CI logs. +// When several defects are present, VerifyChecks returns the first applicable +// category in this declaration order, independent of slice ordering. +var ( + ErrInvalidHeadSHA = errors.New("safepush: invalid head SHA") + ErrNoRequiredChecks = errors.New("safepush: no required checks") + ErrInvalidRequiredCheck = errors.New("safepush: invalid required check identity") + ErrDuplicateRequiredCheck = errors.New("safepush: duplicate required check identity") + ErrInvalidObservation = errors.New("safepush: invalid observation identity") + ErrHeadMismatch = errors.New("safepush: observation head does not match candidate") + ErrDuplicateObservation = errors.New("safepush: duplicate observation identity") + ErrUnexpectedCheck = errors.New("safepush: unexpected check identity") + ErrMissingCheck = errors.New("safepush: missing required check") + ErrCheckNotSuccessful = errors.New("safepush: check did not complete successfully") +) + +// CheckIdentity identifies one job in an adapter-verified immutable workflow. +// Source binds the workflow path and its trusted revision or content, rather +// than a display name. The adapter must establish that binding using native API +// records; setting Source alone is not evidence of authenticity. +// +// Both fields are compared exactly, without case folding, trimming, or Unicode +// normalization. They must be nonblank valid UTF-8 without control characters. +type CheckIdentity struct { + Source string `json:"source"` + Job string `json:"job"` +} + +// CheckObservation is one native CI job result for the selected run attempt. +// Status and Conclusion use the canonical pair "completed" and "success" only +// when the provider reports actual successful completion. The adapter must not +// translate skipped, neutral, allowed-failure, or absent results to that pair. +type CheckObservation struct { + Check CheckIdentity `json:"check"` + HeadSHA string `json:"headSHA"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` +} + +// VerifyChecks requires exactly one successful observation for every required +// identity and no other observations. The required set must not be empty. +// Every observation must match the exact candidate headSHA, which must be a +// nonzero 40- or 64-character lowercase hexadecimal Git object ID. +// +// Only Status == "completed" and Conclusion == "success" passes. Duplicate +// observations are ambiguous even when they agree; the adapter must select the +// authoritative run attempt before calling this function. Inputs are not +// modified, and nil means only that these consistency checks passed. See the +// package documentation for the adapter's remaining authorization obligations. +func VerifyChecks(headSHA string, required []CheckIdentity, observations []CheckObservation) error { + if !validHeadSHA(headSHA) { + return ErrInvalidHeadSHA + } + if len(required) == 0 { + return ErrNoRequiredChecks + } + + requiredSet := make(map[CheckIdentity]struct{}, len(required)) + var invalidRequired, duplicateRequired bool + for _, check := range required { + if !validCheckIdentity(check) { + invalidRequired = true + } + if _, exists := requiredSet[check]; exists { + duplicateRequired = true + } + requiredSet[check] = struct{}{} + } + if invalidRequired { + return ErrInvalidRequiredCheck + } + if duplicateRequired { + return ErrDuplicateRequiredCheck + } + + observedSet := make(map[CheckIdentity]struct{}, len(observations)) + var invalidObservation, headMismatch, duplicateObservation, unexpected, notSuccessful bool + for _, observation := range observations { + if !validCheckIdentity(observation.Check) { + invalidObservation = true + } + if observation.HeadSHA != headSHA { + headMismatch = true + } + if _, exists := observedSet[observation.Check]; exists { + duplicateObservation = true + } + observedSet[observation.Check] = struct{}{} + if _, exists := requiredSet[observation.Check]; !exists { + unexpected = true + } + if observation.Status != "completed" || observation.Conclusion != "success" { + notSuccessful = true + } + } + + switch { + case invalidObservation: + return ErrInvalidObservation + case headMismatch: + return ErrHeadMismatch + case duplicateObservation: + return ErrDuplicateObservation + case unexpected: + return ErrUnexpectedCheck + case len(observedSet) != len(requiredSet): + return ErrMissingCheck + case notSuccessful: + return ErrCheckNotSuccessful + default: + return nil + } +} + +func validHeadSHA(value string) bool { + if len(value) != 40 && len(value) != 64 { + return false + } + var nonzero bool + for _, char := range []byte(value) { + if (char < '0' || char > '9') && (char < 'a' || char > 'f') { + return false + } + if char != '0' { + nonzero = true + } + } + return nonzero +} + +func validCheckIdentity(identity CheckIdentity) bool { + return validIdentityPart(identity.Source) && validIdentityPart(identity.Job) +} + +func validIdentityPart(value string) bool { + if !utf8.ValidString(value) || strings.TrimSpace(value) == "" { + return false + } + for _, char := range value { + if unicode.IsControl(char) { + return false + } + } + return true +} diff --git a/pkg/safepush/checks_test.go b/pkg/safepush/checks_test.go new file mode 100644 index 0000000..3fd6024 --- /dev/null +++ b/pkg/safepush/checks_test.go @@ -0,0 +1,330 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) IURII TRUKHIN 2012-2022, Elena Trukhina 2023-2026. Project and trademarks: Elena Trukhina ZZP. + +package safepush + +import ( + "errors" + "reflect" + "slices" + "strings" + "testing" +) + +func TestVerifyChecksAcceptsExactSuccessfulJobs(t *testing.T) { + t.Parallel() + for _, length := range []int{40, 64} { + head := strings.Repeat("ab", length/2) + t.Run(head, func(t *testing.T) { + t.Parallel() + required, observations := successfulChecks(head) + // API order is not evidence, and the same job name in two verified + // workflows denotes two distinct requirements. + slices.Reverse(observations) + beforeRequired := slices.Clone(required) + beforeObserved := slices.Clone(observations) + if err := VerifyChecks(head, required, observations); err != nil { + t.Fatalf("complete successful evidence rejected: %v", err) + } + if !reflect.DeepEqual(required, beforeRequired) || !reflect.DeepEqual(observations, beforeObserved) { + t.Fatal("verification mutated its inputs") + } + }) + } +} + +func TestVerifyChecksRejectsIncompleteOrAmbiguousEvidence(t *testing.T) { + t.Parallel() + tests := []struct { + name string + change func([]CheckIdentity, []CheckObservation) ([]CheckIdentity, []CheckObservation) + want error + }{ + { + name: "empty policy cannot disable admission", + change: func(_ []CheckIdentity, observations []CheckObservation) ([]CheckIdentity, []CheckObservation) { + return nil, observations + }, + want: ErrNoRequiredChecks, + }, + { + name: "no observations", + change: func(required []CheckIdentity, _ []CheckObservation) ([]CheckIdentity, []CheckObservation) { + return required, nil + }, + want: ErrMissingCheck, + }, + { + name: "one missing job despite other green jobs", + change: func(required []CheckIdentity, observations []CheckObservation) ([]CheckIdentity, []CheckObservation) { + return required, observations[1:] + }, + want: ErrMissingCheck, + }, + { + name: "same job name from another workflow is not evidence", + change: func(required []CheckIdentity, observations []CheckObservation) ([]CheckIdentity, []CheckObservation) { + observations[0].Check.Source = ".github/workflows/other.yml@" + strings.Repeat("cd", 20) + return required, observations + }, + want: ErrUnexpectedCheck, + }, + { + name: "different workflow revision is not evidence", + change: func(required []CheckIdentity, observations []CheckObservation) ([]CheckIdentity, []CheckObservation) { + observations[0].Check.Source = ".github/workflows/go.yml@" + strings.Repeat("ef", 20) + return required, observations + }, + want: ErrUnexpectedCheck, + }, + { + name: "green result for stale head", + change: func(required []CheckIdentity, observations []CheckObservation) ([]CheckIdentity, []CheckObservation) { + observations[0].HeadSHA = strings.Repeat("cd", 20) + return required, observations + }, + want: ErrHeadMismatch, + }, + { + name: "observation lacks head", + change: func(required []CheckIdentity, observations []CheckObservation) ([]CheckIdentity, []CheckObservation) { + observations[0].HeadSHA = "" + return required, observations + }, + want: ErrHeadMismatch, + }, + { + name: "duplicate policy job", + change: func(required []CheckIdentity, observations []CheckObservation) ([]CheckIdentity, []CheckObservation) { + return append(required, required[0]), observations + }, + want: ErrDuplicateRequiredCheck, + }, + { + name: "duplicate successful observations", + change: func(required []CheckIdentity, observations []CheckObservation) ([]CheckIdentity, []CheckObservation) { + return required, append(observations, observations[0]) + }, + want: ErrDuplicateObservation, + }, + { + name: "green attempt cannot hide failed attempt", + change: func(required []CheckIdentity, observations []CheckObservation) ([]CheckIdentity, []CheckObservation) { + failed := observations[0] + failed.Conclusion = "failure" + return required, append(observations, failed) + }, + want: ErrDuplicateObservation, + }, + { + name: "unconfigured successful job", + change: func(required []CheckIdentity, observations []CheckObservation) ([]CheckIdentity, []CheckObservation) { + extra := observations[0] + extra.Check.Job = "new test suite" + return required, append(observations, extra) + }, + want: ErrUnexpectedCheck, + }, + { + name: "unconfigured failed job cannot be ignored", + change: func(required []CheckIdentity, observations []CheckObservation) ([]CheckIdentity, []CheckObservation) { + extra := observations[0] + extra.Check.Job = "new test suite" + extra.Conclusion = "failure" + return required, append(observations, extra) + }, + want: ErrUnexpectedCheck, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + head := strings.Repeat("ab", 20) + required, observations := successfulChecks(head) + required, observations = test.change(required, observations) + if err := VerifyChecks(head, required, observations); !errors.Is(err, test.want) { + t.Fatalf("got %v, want %v", err, test.want) + } + // Selecting whichever duplicate/defect the API returned first must + // not change the decision or its category. + slices.Reverse(required) + slices.Reverse(observations) + if err := VerifyChecks(head, required, observations); !errors.Is(err, test.want) { + t.Fatalf("reordered input: got %v, want %v", err, test.want) + } + }) + } +} + +func TestVerifyChecksRejectsEveryNonSuccessState(t *testing.T) { + t.Parallel() + tests := []struct { + status string + conclusion string + }{ + {status: "pending"}, + {status: "queued"}, + {status: "running"}, + {status: "in_progress"}, + {status: "waiting"}, + {status: "requested"}, + {status: "cancelled"}, + {status: "skipped"}, + {status: "success"}, + {status: "completed"}, + {status: "completed", conclusion: "failure"}, + {status: "completed", conclusion: "cancelled"}, + {status: "completed", conclusion: "skipped"}, + {status: "completed", conclusion: "neutral"}, + {status: "completed", conclusion: "timed_out"}, + {status: "completed", conclusion: "action_required"}, + {status: "completed", conclusion: "stale"}, + {status: "completed", conclusion: "startup_failure"}, + {status: "completed", conclusion: "unknown"}, + {status: "in_progress", conclusion: "success"}, + {status: "unknown", conclusion: "success"}, + {status: "Completed", conclusion: "success"}, + {status: "completed", conclusion: "Success"}, + {status: " completed", conclusion: "success"}, + {status: "completed", conclusion: "success "}, + {}, + } + for _, test := range tests { + t.Run(test.status+"/"+test.conclusion, func(t *testing.T) { + t.Parallel() + head := strings.Repeat("ab", 20) + required, observations := successfulChecks(head) + observations[1].Status = test.status + observations[1].Conclusion = test.conclusion + if err := VerifyChecks(head, required, observations); !errors.Is(err, ErrCheckNotSuccessful) { + t.Fatalf("non-success result: got %v, want %v", err, ErrCheckNotSuccessful) + } + }) + } +} + +func TestVerifyChecksRejectsMalformedHeadEvenWhenObservationsAgree(t *testing.T) { + t.Parallel() + for _, head := range []string{ + "", "main", strings.Repeat("a", 39), strings.Repeat("a", 41), + strings.Repeat("a", 63), strings.Repeat("a", 65), strings.Repeat("A", 40), + strings.Repeat("A", 64), strings.Repeat("g", 40), strings.Repeat("0", 40), + strings.Repeat("0", 64), strings.Repeat("a", 39) + "\n", strings.Repeat("a", 39) + " ", + } { + t.Run(head, func(t *testing.T) { + t.Parallel() + required, observations := successfulChecks(head) + if err := VerifyChecks(head, required, observations); !errors.Is(err, ErrInvalidHeadSHA) { + t.Fatalf("malformed candidate: got %v, want %v", err, ErrInvalidHeadSHA) + } + }) + } +} + +func TestVerifyChecksRejectsMalformedIdentities(t *testing.T) { + t.Parallel() + for _, value := range []string{"", " ", "\u00a0", "\x00job", "job\n", "job\tname", "job\x7f", "\xff"} { + t.Run(value, func(t *testing.T) { + t.Parallel() + for _, field := range []string{"source", "job"} { + for _, location := range []string{"required", "observation"} { + head := strings.Repeat("ab", 20) + required, observations := successfulChecks(head) + identity := &required[0] + want := ErrInvalidRequiredCheck + if location == "observation" { + identity = &observations[0].Check + want = ErrInvalidObservation + } + if field == "source" { + identity.Source = value + } else { + identity.Job = value + } + if err := VerifyChecks(head, required, observations); !errors.Is(err, want) { + t.Fatalf("%s %s: got %v, want %v", location, field, err, want) + } + } + } + }) + } +} + +func TestVerifyChecksDoesNotNormalizeIdentities(t *testing.T) { + t.Parallel() + head := strings.Repeat("ab", 20) + for _, pair := range [][2]string{ + {"unit", "Unit"}, + {"unit", " unit"}, + {"unit", "unit "}, + {"caf\u00e9", "cafe\u0301"}, + } { + for _, field := range []string{"source", "job"} { + required := []CheckIdentity{{Source: "workflow", Job: "unit"}} + observation := CheckObservation{Check: required[0], HeadSHA: head, Status: "completed", Conclusion: "success"} + if field == "source" { + required[0].Source = pair[0] + observation.Check.Source = pair[1] + } else { + required[0].Job = pair[0] + observation.Check.Job = pair[1] + } + if err := VerifyChecks(head, required, []CheckObservation{observation}); !errors.Is(err, ErrUnexpectedCheck) { + t.Fatalf("%s identity was normalized: got %v, want %v", field, err, ErrUnexpectedCheck) + } + // Meaningful spaces and Unicode are legal when their exact bytes + // agree; validation must not silently rewrite a native job name. + required[0] = observation.Check + if err := VerifyChecks(head, required, []CheckObservation{observation}); err != nil { + t.Fatalf("exact %s identity rejected: %v", field, err) + } + } + } +} + +func TestVerifyChecksPreservesIdentityFieldBoundaries(t *testing.T) { + t.Parallel() + head := strings.Repeat("ab", 20) + required := []CheckIdentity{{Source: "workflow/a", Job: "b"}, {Source: "workflow", Job: "a/b"}} + observations := []CheckObservation{ + {Check: required[0], HeadSHA: head, Status: "completed", Conclusion: "success"}, + {Check: required[1], HeadSHA: head, Status: "completed", Conclusion: "success"}, + } + if err := VerifyChecks(head, required, observations); err != nil { + t.Fatalf("distinct structured identities collided: %v", err) + } +} + +func TestVerifyChecksErrorsNeverEchoObservationValues(t *testing.T) { + t.Parallel() + head := strings.Repeat("ab", 20) + required, observations := successfulChecks(head) + const untrustedValue = "untrusted-response-marker" + observations[0].HeadSHA = untrustedValue + observations[0].Status = untrustedValue + observations[0].Conclusion = untrustedValue + observations[0].Check.Source = untrustedValue + observations[0].Check.Job = untrustedValue + err := VerifyChecks(head, required, observations) + if !errors.Is(err, ErrHeadMismatch) { + t.Fatalf("got %v, want %v", err, ErrHeadMismatch) + } + if strings.Contains(err.Error(), untrustedValue) { + t.Fatal("error exposed raw observation data") + } +} + +func successfulChecks(head string) ([]CheckIdentity, []CheckObservation) { + goWorkflow := ".github/workflows/go.yml@" + strings.Repeat("cd", 20) + securityWorkflow := ".github/workflows/security.yml@" + strings.Repeat("ef", 20) + required := []CheckIdentity{ + {Source: goWorkflow, Job: "unit (linux, go1.26.8)"}, + {Source: goWorkflow, Job: "race (linux, go1.26.8)"}, + {Source: securityWorkflow, Job: "unit (linux, go1.26.8)"}, + } + observations := make([]CheckObservation, len(required)) + for index, check := range required { + observations[index] = CheckObservation{Check: check, HeadSHA: head, Status: "completed", Conclusion: "success"} + } + return required, observations +} diff --git a/pkg/safepush/doc.go b/pkg/safepush/doc.go new file mode 100644 index 0000000..da19ff1 --- /dev/null +++ b/pkg/safepush/doc.go @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) IURII TRUKHIN 2012-2022, Elena Trukhina 2023-2026. Project and trademarks: Elena Trukhina ZZP. + +// Package safepush provides deterministic checks over CI observations. +// +// VerifyChecks is a pure consistency check, not an enforcement boundary or a +// complete authorization decision. Its inputs cannot prove their own origin, +// freshness, or completeness. In particular, a source identity, caller assertion, +// or signature alone does not establish that a workflow actually ran. +// +// The native adapter must read the trusted required-job policy and complete, +// paginated run/job records from the owning CI service. It must verify the +// immutable workflow source and execution bindings before constructing source +// identities, and resolve run attempts without choosing an older green result. +// It must include every job in that scope, except exclusions explicitly permitted +// by the trusted policy. Branch enforcement, review approval, bypass authority, +// and revalidation when the candidate or target changes remain outside this +// helper. +package safepush diff --git a/pkg/safepush/github.go b/pkg/safepush/github.go new file mode 100644 index 0000000..ee42996 --- /dev/null +++ b/pkg/safepush/github.go @@ -0,0 +1,748 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) IURII TRUKHIN 2012-2022, Elena Trukhina 2023-2026. Project and trademarks: Elena Trukhina ZZP. + +package safepush + +import ( + "context" + "crypto/sha1" // #nosec G505 -- Git object identifiers, not signatures. + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "mime" + "net/http" + "net/url" + "reflect" + "strconv" + "strings" + "time" + + "github.com/opencloudtech/CloudRING/internal/strictjson" +) + +// Fixed error categories never expose credentials, remote response bodies, +// repository contents, URLs, or candidate-controlled names. +var ( + ErrGitHubPending = errors.New("safepush: required GitHub runs are pending") + ErrGitHubAPI = errors.New("safepush: GitHub API read failed") + ErrGitHubCandidate = errors.New("safepush: invalid GitHub candidate") + ErrGitHubState = errors.New("safepush: GitHub candidate or target state changed") + ErrGitHubSource = errors.New("safepush: GitHub workflow source is not trusted") + ErrGitHubRun = errors.New("safepush: GitHub run provenance is invalid") +) + +const ( + githubAPIOrigin = "https://api.github.com" + githubActionsID = int64(15368) + githubPageSize = 100 + githubListLimit = 1000 +) + +type githubAPI struct { + client *http.Client + bearerValue string + repo string +} + +type githubRepository struct { + ID int64 `json:"id"` + FullName string `json:"full_name"` + Archived bool `json:"archived"` + Disabled bool `json:"disabled"` +} + +type githubRef struct { + Ref string `json:"ref"` + SHA string `json:"sha"` + Repo githubRepository `json:"repo"` +} + +type githubPullRequest struct { + ID int64 `json:"id"` + Number int `json:"number"` + State string `json:"state"` + Draft bool `json:"draft"` + Merged bool `json:"merged"` + Head githubRef `json:"head"` + Base githubRef `json:"base"` +} + +type githubBranch struct { + Name string `json:"name"` + Protected bool `json:"protected"` + Commit struct { + SHA string `json:"sha"` + } `json:"commit"` +} + +type githubTreeEntry struct { + Path string `json:"path"` + Mode string `json:"mode"` + Type string `json:"type"` + SHA string `json:"sha"` +} + +type githubTree struct { + SHA string `json:"sha"` + Truncated *bool `json:"truncated"` + Tree []githubTreeEntry `json:"tree"` +} + +type githubWorkflow struct { + ID int64 `json:"id"` + Path string `json:"path"` + State string `json:"state"` +} + +type githubRun struct { + ID int64 `json:"id"` + WorkflowID int64 `json:"workflow_id"` + RunNumber int64 `json:"run_number"` + RunAttempt int `json:"run_attempt"` + CheckSuiteID int64 `json:"check_suite_id"` + Path string `json:"path"` + Event string `json:"event"` + HeadSHA string `json:"head_sha"` + HeadBranch string `json:"head_branch"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + Repository githubRepository `json:"repository"` + HeadRepository githubRepository `json:"head_repository"` + ReferencedWorkflows []json.RawMessage `json:"referenced_workflows"` + PullRequests []githubRunPR `json:"pull_requests"` +} + +type githubRunPR struct { + ID int64 `json:"id"` + Number int `json:"number"` + URL string `json:"url"` + Head githubRef `json:"head"` + Base githubRef `json:"base"` +} + +type githubStep struct { + Name string `json:"name"` + Number int `json:"number"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` +} + +type githubJob struct { + ID int64 `json:"id"` + RunID int64 `json:"run_id"` + RunAttempt int `json:"run_attempt"` + HeadSHA string `json:"head_sha"` + HeadBranch string `json:"head_branch"` + Name string `json:"name"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + CheckRunURL string `json:"check_run_url"` + Steps []githubStep `json:"steps"` +} + +type githubCheckRun struct { + ID int64 `json:"id"` + Name string `json:"name"` + HeadSHA string `json:"head_sha"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + CheckSuite struct { + ID int64 `json:"id"` + } `json:"check_suite"` + App struct { + ID int64 `json:"id"` + Slug string `json:"slug"` + } `json:"app"` +} + +// ReadGitHubPolicy reads the accepted base's regular policy blob without a +// checkout or contents-API symlink dereference. VerifyGitHub subsequently proves +// that this base is still the current protected target. The caller must obtain +// repository/baseSHA from its trusted workflow event, not candidate output. +func ReadGitHubPolicy(ctx context.Context, client *http.Client, token, repository, baseSHA string) (GitHubPolicy, error) { + if !repositoryName.MatchString(repository) || strings.HasSuffix(repository, "/.") || + strings.HasSuffix(repository, "/..") || !validHeadSHA(baseSHA) { + return GitHubPolicy{}, ErrPolicy + } + api, err := newGitHubAPI(client, token, repository) + if err != nil { + return GitHubPolicy{}, err + } + ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + var repo githubRepository + if err = api.get(ctx, "", nil, &repo); err != nil { + return GitHubPolicy{}, err + } + if repo.ID <= 0 || repo.FullName != repository || repo.Archived || repo.Disabled { + return GitHubPolicy{}, ErrGitHubState + } + tree, err := api.readTree(ctx, baseSHA) + if err != nil { + return GitHubPolicy{}, err + } + entry, exists := tree[".github/safepush.json"] + if !exists || entry.Type != "blob" || entry.Mode != "100644" { + return GitHubPolicy{}, ErrGitHubSource + } + contents, err := api.readGitBlob(ctx, entry, strictjson.MaxDocumentBytes) + if err != nil { + return GitHubPolicy{}, err + } + policy, err := DecodeGitHubPolicy(contents) + if err != nil { + return GitHubPolicy{}, ErrPolicy + } + if policy.Repository != repo.FullName || policy.RepositoryID != repo.ID { + return GitHubPolicy{}, ErrGitHubSource + } + if ctx.Err() != nil { + return GitHubPolicy{}, ErrGitHubAPI + } + return policy, nil +} + +// VerifyGitHub verifies native PR/run/job provenance using the accepted policy. +// It executes no candidate code. Both required and excluded workflow files, and +// .github/scripts, must match the accepted base. This deliberately does not +// authenticate generated dynamic workflows or reusable workflow chains. +// +// Base must be an ancestor of head. With unchanged workflow sources this avoids +// claiming a stale PR test-merge tested newer candidate bytes. Native required +// reviews and branch rules remain necessary: these API reads are not an atomic +// ref-update transaction and do not prove the semantic adequacy of tests. +func VerifyGitHub(ctx context.Context, client *http.Client, token string, policy GitHubPolicy, candidate GitHubCandidate) error { + if err := ValidateGitHubPolicy(policy); err != nil { + return err + } + if ValidateGitHubCandidate(policy.Repository, candidate) != nil { + return ErrGitHubCandidate + } + api, err := newGitHubAPI(client, token, policy.Repository) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + var repo githubRepository + if err = api.get(ctx, "", nil, &repo); err != nil { + return err + } + if repo.ID != policy.RepositoryID || repo.FullName != policy.Repository || repo.Archived || repo.Disabled { + return ErrGitHubState + } + pr, err := api.readCandidate(ctx, policy, candidate) + if err != nil { + return err + } + if err = api.requireBaseAncestor(ctx, candidate); err != nil { + return err + } + baseTree, err := api.readTree(ctx, candidate.BaseSHA) + if err != nil { + return err + } + headTree, err := api.readTree(ctx, candidate.HeadSHA) + if err != nil { + return err + } + if err = verifyGitHubSources(policy, baseTree, headTree); err != nil { + return err + } + for _, workflow := range policy.Workflows { + contents, readErr := api.readGitBlob(ctx, baseTree[workflow.Path], maxWorkflowSourceBytes) + if readErr != nil { + return readErr + } + if err = verifyWorkflowSource(contents); err != nil { + return err + } + } + workflows, err := api.readWorkflows(ctx, policy) + if err != nil { + return err + } + runs, err := api.selectRuns(ctx, policy, pr, workflows) + if err != nil { + return err + } + var required []CheckIdentity + var observations []CheckObservation + for _, workflow := range policy.Workflows { + source := workflow.Path + "@" + baseTree[workflow.Path].SHA + for _, job := range workflow.Jobs { + required = append(required, CheckIdentity{Source: source, Job: job}) + } + items, collectErr := api.readObservations(ctx, runs[workflow.Path], source) + if collectErr != nil { + return collectErr + } + observations = append(observations, items...) + } + if err = VerifyChecks(candidate.HeadSHA, required, observations); err != nil { + return err + } + finalWorkflows, err := api.readWorkflows(ctx, policy) + if err != nil { + return err + } + if !reflect.DeepEqual(workflows, finalWorkflows) { + return ErrGitHubState + } + finalRuns, err := api.selectRuns(ctx, policy, pr, finalWorkflows) + if err != nil { + // A newly selected run or attempt invalidates this collection, even if + // it is pending. A new admission attempt must collect a new snapshot. + if errors.Is(err, ErrGitHubPending) { + return ErrGitHubState + } + return err + } + if !reflect.DeepEqual(runs, finalRuns) { + return ErrGitHubState + } + for _, workflow := range policy.Workflows { + run := runs[workflow.Path] + var finalRun githubRun + if err = api.get(ctx, "/actions/runs/"+strconv.FormatInt(run.ID, 10), nil, &finalRun); err != nil { + return err + } + if !reflect.DeepEqual(run, finalRun) { + return ErrGitHubState + } + } + finalPR, err := api.readCandidate(ctx, policy, candidate) + if err != nil { + return err + } + if !reflect.DeepEqual(pr, finalPR) { + return ErrGitHubState + } + if ctx.Err() != nil { + return ErrGitHubAPI + } + return nil +} + +func newGitHubAPI(client *http.Client, token, repository string) (*githubAPI, error) { + if token == "" { + return nil, ErrGitHubAPI + } + for _, value := range token { + if value <= 0x20 || value >= 0x7f { + return nil, ErrGitHubAPI + } + } + if client == nil { + client = http.DefaultClient + } + copyClient := *client + copyClient.Jar = nil + copyClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse } + if copyClient.Timeout == 0 || copyClient.Timeout > 30*time.Second { + copyClient.Timeout = 30 * time.Second + } + return &githubAPI{client: ©Client, bearerValue: token, repo: repository}, nil +} + +func (api *githubAPI) get(ctx context.Context, suffix string, query url.Values, destination any) error { + u := url.URL{Scheme: "https", Host: "api.github.com", Path: "/repos/" + api.repo + suffix, RawQuery: query.Encode()} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return ErrGitHubAPI + } + req.Header.Set("Authorization", "Bearer "+api.bearerValue) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + req.Header.Set("User-Agent", "CloudRING-SafePush") + resp, err := api.client.Do(req) + if err != nil { + return ErrGitHubAPI + } + defer resp.Body.Close() + mediaType, _, mediaErr := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if resp.StatusCode != http.StatusOK || mediaErr != nil || + (mediaType != "application/json" && mediaType != "application/vnd.github+json") || + resp.ContentLength > strictjson.MaxDocumentBytes { + return ErrGitHubAPI + } + data, err := strictjson.Read(resp.Body) + if err != nil || strictjson.Decode(data, destination) != nil { + return ErrGitHubAPI + } + return nil +} + +func githubList[T any](ctx context.Context, api *githubAPI, suffix, key string, filters url.Values) ([]T, error) { + var result []T + declared := -1 + for page := 1; page <= githubListLimit/githubPageSize; page++ { + query := make(url.Values) + for name, values := range filters { + query[name] = append([]string(nil), values...) + } + query.Set("per_page", strconv.Itoa(githubPageSize)) + query.Set("page", strconv.Itoa(page)) + var envelope map[string]json.RawMessage + if err := api.get(ctx, suffix, query, &envelope); err != nil { + return nil, err + } + var total int + if raw := envelope["total_count"]; len(raw) == 0 || string(raw) == "null" || json.Unmarshal(raw, &total) != nil || total < 0 || total > githubListLimit { + return nil, ErrGitHubAPI + } + for _, flag := range []string{"truncated", "incomplete_results"} { + if raw, found := envelope[flag]; found && string(raw) != "false" { + return nil, ErrGitHubAPI + } + } + if declared != -1 && declared != total { + return nil, ErrGitHubState + } + declared = total + var items []T + if json.Unmarshal(envelope[key], &items) != nil || items == nil { + return nil, ErrGitHubAPI + } + if len(items) != min(githubPageSize, total-len(result)) { + return nil, ErrGitHubAPI + } + result = append(result, items...) + if len(result) == total { + return result, nil + } + } + return nil, ErrGitHubAPI +} + +func (api *githubAPI) readCandidate(ctx context.Context, policy GitHubPolicy, candidate GitHubCandidate) (githubPullRequest, error) { + var pr githubPullRequest + if err := api.get(ctx, "/pulls/"+strconv.Itoa(candidate.PullRequest), nil, &pr); err != nil { + return pr, err + } + if pr.ID <= 0 || pr.Number != candidate.PullRequest || pr.State != "open" || pr.Draft || pr.Merged || + pr.Head.SHA != candidate.HeadSHA || !validJobName(pr.Head.Ref) || pr.Head.Repo.ID <= 0 || + !repositoryName.MatchString(pr.Head.Repo.FullName) || + pr.Base.SHA != candidate.BaseSHA || pr.Base.Ref != candidate.TargetBranch || + pr.Base.Repo.ID != policy.RepositoryID || pr.Base.Repo.FullName != policy.Repository { + return pr, ErrGitHubState + } + var branch githubBranch + if err := api.get(ctx, "/branches/"+candidate.TargetBranch, nil, &branch); err != nil { + return pr, err + } + if branch.Name != candidate.TargetBranch || branch.Commit.SHA != candidate.BaseSHA || !branch.Protected { + return pr, ErrGitHubState + } + return pr, nil +} + +func (api *githubAPI) requireBaseAncestor(ctx context.Context, candidate GitHubCandidate) error { + var comparison struct { + Status string `json:"status"` + AheadBy *int `json:"ahead_by"` + BehindBy *int `json:"behind_by"` + Base struct { + SHA string `json:"sha"` + } `json:"base_commit"` + MergeBase struct { + SHA string `json:"sha"` + } `json:"merge_base_commit"` + } + if err := api.get(ctx, "/compare/"+candidate.BaseSHA+"..."+candidate.HeadSHA, url.Values{"per_page": {"1"}}, &comparison); err != nil { + return err + } + if comparison.Status != "ahead" || comparison.AheadBy == nil || *comparison.AheadBy <= 0 || + comparison.BehindBy == nil || *comparison.BehindBy != 0 || + comparison.Base.SHA != candidate.BaseSHA || comparison.MergeBase.SHA != candidate.BaseSHA { + return ErrGitHubState + } + return nil +} + +func (api *githubAPI) readTree(ctx context.Context, sha string) (map[string]githubTreeEntry, error) { + var commit struct { + SHA string `json:"sha"` + Tree struct { + SHA string `json:"sha"` + } `json:"tree"` + } + if err := api.get(ctx, "/git/commits/"+sha, nil, &commit); err != nil { + return nil, err + } + if commit.SHA != sha || !validHeadSHA(commit.Tree.SHA) { + return nil, ErrGitHubSource + } + var response githubTree + if err := api.get(ctx, "/git/trees/"+commit.Tree.SHA, url.Values{"recursive": {"1"}}, &response); err != nil { + return nil, err + } + if response.SHA != commit.Tree.SHA || response.Truncated == nil || *response.Truncated || response.Tree == nil { + return nil, ErrGitHubSource + } + entries := make(map[string]githubTreeEntry, len(response.Tree)) + for _, entry := range response.Tree { + if !validJobName(entry.Path) || !validHeadSHA(entry.SHA) { + return nil, ErrGitHubSource + } + if _, duplicate := entries[entry.Path]; duplicate { + return nil, ErrGitHubSource + } + entries[entry.Path] = entry + } + return entries, nil +} + +func (api *githubAPI) readGitBlob(ctx context.Context, entry githubTreeEntry, limit int) ([]byte, error) { + if entry.Type != "blob" || (entry.Mode != "100644" && entry.Mode != "100755") || + !validHeadSHA(entry.SHA) || limit <= 0 || limit > strictjson.MaxDocumentBytes { + return nil, ErrGitHubSource + } + var blob struct { + SHA string `json:"sha"` + Encoding string `json:"encoding"` + Size *int `json:"size"` + Content string `json:"content"` + } + if err := api.get(ctx, "/git/blobs/"+entry.SHA, nil, &blob); err != nil { + return nil, err + } + if blob.SHA != entry.SHA || blob.Encoding != "base64" || blob.Size == nil || *blob.Size <= 0 || *blob.Size > limit { + return nil, ErrGitHubSource + } + contents, err := base64.StdEncoding.DecodeString(blob.Content) + if err != nil || len(contents) != *blob.Size || gitBlobID(contents, len(entry.SHA)) != entry.SHA { + return nil, ErrGitHubSource + } + if ctx.Err() != nil { + return nil, ErrGitHubAPI + } + return contents, nil +} + +func verifyGitHubSources(policy GitHubPolicy, base, head map[string]githubTreeEntry) error { + expected := make(map[string]bool) + for _, workflow := range policy.Workflows { + expected[workflow.Path] = true + } + for _, excluded := range policy.ExcludedWorkflows { + expected[excluded] = true + } + guarded := func(entries map[string]githubTreeEntry) (map[string]githubTreeEntry, error) { + result := make(map[string]githubTreeEntry) + found := make(map[string]bool) + for name, entry := range entries { + if name == ".github/workflows" || strings.HasPrefix(name, ".github/workflows/") || + name == ".github/scripts" || strings.HasPrefix(name, ".github/scripts/") || name == ".github/workflow-policy.json" { + if (entry.Type != "blob" || (entry.Mode != "100644" && entry.Mode != "100755")) && + (entry.Type != "tree" || entry.Mode != "040000") { + return nil, ErrGitHubSource + } + result[name] = entry + } + if strings.HasPrefix(name, ".github/workflows/") && entry.Type != "tree" && + (strings.HasSuffix(name, ".yml") || strings.HasSuffix(name, ".yaml")) { + if !validWorkflowPath(name) || !expected[name] { + return nil, ErrGitHubSource + } + found[name] = true + } + } + if len(found) != len(expected) { + return nil, ErrGitHubSource + } + return result, nil + } + trusted, err := guarded(base) + if err != nil { + return err + } + candidate, err := guarded(head) + if err != nil { + return err + } + if !reflect.DeepEqual(trusted, candidate) { + return ErrGitHubSource + } + return nil +} + +func (api *githubAPI) readWorkflows(ctx context.Context, policy GitHubPolicy) (map[string]githubWorkflow, error) { + items, err := githubList[githubWorkflow](ctx, api, "/actions/workflows", "workflows", nil) + if err != nil { + return nil, err + } + expected := make(map[string]bool) + for _, workflow := range policy.Workflows { + expected[workflow.Path] = true + } + for _, excluded := range policy.ExcludedWorkflows { + expected[excluded] = false + } + result := make(map[string]githubWorkflow) + ids := make(map[int64]bool) + for _, item := range items { + if item.ID <= 0 || ids[item.ID] { + return nil, ErrGitHubSource + } + ids[item.ID] = true + if strings.HasPrefix(item.Path, "dynamic/") { + continue // Native generated scanning remains a separate provider rule. + } + required, exists := expected[item.Path] + if !exists { + return nil, ErrGitHubSource + } + if _, duplicate := result[item.Path]; duplicate || (required && item.State != "active") { + return nil, ErrGitHubSource + } + result[item.Path] = item + } + if len(result) != len(expected) { + return nil, ErrGitHubSource + } + return result, nil +} + +func (api *githubAPI) selectRuns(ctx context.Context, policy GitHubPolicy, pr githubPullRequest, workflows map[string]githubWorkflow) (map[string]githubRun, error) { + items, err := githubList[githubRun](ctx, api, "/actions/runs", "workflow_runs", url.Values{"event": {"pull_request"}, "head_sha": {pr.Head.SHA}}) + if err != nil { + return nil, err + } + result := make(map[string]githubRun) + ids := make(map[int64]bool) + requiredPaths := make(map[string]bool) + requiredIDs := make(map[int64]bool) + for _, workflow := range policy.Workflows { + requiredPaths[workflow.Path] = true + requiredIDs[workflows[workflow.Path].ID] = true + } + for _, item := range items { + // Excluded release/verifier runs and provider-injected workflows cannot + // satisfy a test identity. In particular do not wait on this gate itself. + if !requiredPaths[item.Path] && !requiredIDs[item.WorkflowID] { + continue + } + workflow, exists := workflows[item.Path] + if !exists || item.ID <= 0 || ids[item.ID] || item.RunNumber <= 0 || item.RunAttempt <= 0 || item.CheckSuiteID <= 0 || + item.WorkflowID != workflow.ID || item.Event != "pull_request" || + item.HeadSHA != pr.Head.SHA || item.HeadBranch != pr.Head.Ref || + item.Repository.ID != policy.RepositoryID || item.Repository.FullName != policy.Repository || + item.HeadRepository.ID != pr.Head.Repo.ID || item.HeadRepository.FullName != pr.Head.Repo.FullName || + len(item.ReferencedWorkflows) != 0 || !api.runMatchesPR(item, pr) { + return nil, ErrGitHubRun + } + ids[item.ID] = true + prior, exists := result[item.Path] + if exists && item.RunNumber == prior.RunNumber { + return nil, ErrGitHubRun + } + if !exists || item.RunNumber > prior.RunNumber { + result[item.Path] = item + } + } + pending := false + for _, workflow := range policy.Workflows { + run, exists := result[workflow.Path] + if !exists { + pending = true + continue + } + switch run.Status { + case "completed": + if run.Conclusion != "success" { + return nil, ErrCheckNotSuccessful + } + case "queued", "in_progress", "waiting", "requested", "pending": + pending = true + default: + return nil, ErrGitHubRun + } + } + if pending { + return nil, ErrGitHubPending + } + return result, nil +} + +func (api *githubAPI) runMatchesPR(run githubRun, pr githubPullRequest) bool { + // A head SHA can be reused by another PR or after retargeting. Do not treat + // the run's head alone as an association with this exact PR/base snapshot. + // Empty associations occur for closed or otherwise unavailable PRs; those + // cannot provide the provenance needed for this open-PR admission. + if len(run.PullRequests) != 1 { + return false + } + linked := run.PullRequests[0] + return linked.ID == pr.ID && linked.Number == pr.Number && + linked.URL == githubAPIOrigin+"/repos/"+api.repo+"/pulls/"+strconv.Itoa(pr.Number) && + linked.Head.SHA == pr.Head.SHA && linked.Head.Ref == pr.Head.Ref && linked.Head.Repo.ID == pr.Head.Repo.ID && + linked.Base.SHA == pr.Base.SHA && linked.Base.Ref == pr.Base.Ref && linked.Base.Repo.ID == pr.Base.Repo.ID +} + +func (api *githubAPI) readObservations(ctx context.Context, run githubRun, source string) ([]CheckObservation, error) { + suffix := fmt.Sprintf("/actions/runs/%d/attempts/%d/jobs", run.ID, run.RunAttempt) + jobs, err := githubList[githubJob](ctx, api, suffix, "jobs", nil) + if err != nil { + return nil, err + } + checks, err := githubList[githubCheckRun](ctx, api, fmt.Sprintf("/check-suites/%d/check-runs", run.CheckSuiteID), "check_runs", url.Values{"filter": {"latest"}}) + if err != nil { + return nil, err + } + checkByID := make(map[int64]githubCheckRun) + for _, check := range checks { + if check.ID <= 0 || checkByID[check.ID].ID != 0 || check.CheckSuite.ID != run.CheckSuiteID || + check.App.ID != githubActionsID || check.App.Slug != "github-actions" || check.HeadSHA != run.HeadSHA || + check.Status != "completed" || check.Conclusion != "success" { + return nil, ErrGitHubRun + } + checkByID[check.ID] = check + } + var observations []CheckObservation + jobIDs := make(map[int64]bool) + usedChecks := make(map[int64]bool) + for _, job := range jobs { + if job.ID <= 0 || jobIDs[job.ID] || job.RunID != run.ID || job.RunAttempt != run.RunAttempt || + job.HeadSHA != run.HeadSHA || job.HeadBranch != run.HeadBranch || len(job.Steps) == 0 { + return nil, ErrGitHubRun + } + jobIDs[job.ID] = true + prefix := githubAPIOrigin + "/repos/" + api.repo + "/check-runs/" + rawID, found := strings.CutPrefix(job.CheckRunURL, prefix) + checkID, parseErr := strconv.ParseInt(rawID, 10, 64) + if !found || parseErr != nil || checkID <= 0 || rawID != strconv.FormatInt(checkID, 10) || usedChecks[checkID] { + return nil, ErrGitHubRun + } + check, exists := checkByID[checkID] + if !exists || check.Name != job.Name { + return nil, ErrGitHubRun + } + usedChecks[checkID] = true + stepNumbers := make(map[int]bool) + for _, step := range job.Steps { + if step.Number <= 0 || stepNumbers[step.Number] || !validJobName(step.Name) || step.Status != "completed" || step.Conclusion != "success" { + return nil, ErrGitHubRun + } + stepNumbers[step.Number] = true + } + observations = append(observations, CheckObservation{Check: CheckIdentity{Source: source, Job: job.Name}, HeadSHA: job.HeadSHA, Status: job.Status, Conclusion: job.Conclusion}) + } + if len(usedChecks) != len(checkByID) { + return nil, ErrGitHubRun + } + return observations, nil +} + +func gitBlobID(contents []byte, length int) string { + object := append([]byte(fmt.Sprintf("blob %d\x00", len(contents))), contents...) + if length == 40 { + digest := sha1.Sum(object) // #nosec G401 -- required Git object identifier. + return hex.EncodeToString(digest[:]) + } + digest := sha256.Sum256(object) + return hex.EncodeToString(digest[:]) +} diff --git a/pkg/safepush/github_test.go b/pkg/safepush/github_test.go new file mode 100644 index 0000000..c766ff4 --- /dev/null +++ b/pkg/safepush/github_test.go @@ -0,0 +1,718 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) IURII TRUKHIN 2012-2022, Elena Trukhina 2023-2026. Project and trademarks: Elena Trukhina ZZP. + +package safepush + +import ( + "context" + "crypto/sha1" // #nosec G505 -- synthetic Git object fixture identifiers. + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "strconv" + "strings" + "sync" + "testing" + + "github.com/opencloudtech/CloudRING/internal/strictjson" +) + +const ( + ghTestHead = "1111111111111111111111111111111111111111" + ghTestBase = "2222222222222222222222222222222222222222" + ghTestHeadTree = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ghTestBaseTree = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ghTestBlob = "e69e9a60e26d1cd37de67076728528819fec5844" + ghTestOther = "9999999999999999999999999999999999999999" + ghTestPolicyID = "85b9611e54998ddb6ae044af1c6622072c9fa29a" + ghTestPolicy = `{"schemaVersion":"cloudring.safepush.github/v1","repository":"owner/repo","repositoryId":42,"workflows":[{"path":".github/workflows/ci.yml","jobs":["unit","race"]}],"excludedWorkflows":[".github/workflows/release.yml"]}` + ghTestWorkflow = `name: CI +on: pull_request +jobs: + unit: + runs-on: ubuntu-latest + steps: + - name: Run tests + run: go test ./... + race: + runs-on: ubuntu-latest + steps: + - name: Run tests + run: go test -race ./... +` +) + +// These are HTTP JSON fixtures shaped from the provider API, with deliberately +// distinct commit, tree and blob IDs. The adapter always addresses the official +// HTTPS origin; only this test transport connects it to an isolated TLS server. +type githubHTTPSFixture struct { + t *testing.T + client *http.Client + responses map[string]any + mu sync.Mutex + counts map[string]int + requests []string + mutate func(*http.Request, int, map[string]any) + reply func(http.ResponseWriter, *http.Request, int) bool + policy GitHubPolicy + candidate GitHubCandidate +} + +type githubFixtureTransport struct { + t *testing.T + base http.RoundTripper + target *url.URL +} + +func (transport githubFixtureTransport) RoundTrip(request *http.Request) (*http.Response, error) { + if request.URL.Scheme != "https" || request.URL.Host != "api.github.com" || request.URL.User != nil { + transport.t.Error("adapter attempted a non-official API origin") + return nil, errors.New("unexpected test origin") + } + copyRequest := request.Clone(request.Context()) + copyURL := *request.URL + copyURL.Host = transport.target.Host + copyRequest.URL = ©URL + copyRequest.Host = transport.target.Host + response, err := transport.base.RoundTrip(copyRequest) + if response != nil { + response.Request = request + } + return response, err +} + +func newGitHubHTTPSFixture(t *testing.T) *githubHTTPSFixture { + t.Helper() + fixture := &githubHTTPSFixture{t: t, responses: make(map[string]any), counts: make(map[string]int)} + if err := json.Unmarshal([]byte(ghTestPolicy), &fixture.policy); err != nil { + t.Fatal(err) + } + fixture.candidate = GitHubCandidate{PullRequest: 7, HeadSHA: ghTestHead, BaseSHA: ghTestBase, TargetBranch: "main"} + repo := map[string]any{"id": 42, "full_name": "owner/repo", "archived": false, "disabled": false} + fixture.responses[""] = repo + fixture.responses["/pulls/7"] = map[string]any{ + "id": 71, "number": 7, "state": "open", "draft": false, "merged": false, + "head": map[string]any{"ref": "feature", "sha": ghTestHead, "repo": repo}, + "base": map[string]any{"ref": "main", "sha": ghTestBase, "repo": repo}, + } + fixture.responses["/branches/main"] = map[string]any{"name": "main", "protected": true, "commit": map[string]any{"sha": ghTestBase}} + fixture.responses["/compare/"+ghTestBase+"..."+ghTestHead] = map[string]any{ + "status": "ahead", "ahead_by": 1, "behind_by": 0, + "base_commit": map[string]any{"sha": ghTestBase}, "merge_base_commit": map[string]any{"sha": ghTestBase}, + } + for commit, tree := range map[string]string{ghTestBase: ghTestBaseTree, ghTestHead: ghTestHeadTree} { + fixture.responses["/git/commits/"+commit] = map[string]any{"sha": commit, "tree": map[string]any{"sha": tree}} + fixture.responses["/git/trees/"+tree] = map[string]any{"sha": tree, "truncated": false, "tree": []any{ + map[string]any{"path": ".github/workflows/ci.yml", "mode": "100644", "type": "blob", "sha": ghTestBlob}, + map[string]any{"path": ".github/workflows/release.yml", "mode": "100644", "type": "blob", "sha": "4444444444444444444444444444444444444444"}, + map[string]any{"path": ".github/scripts/guard.sh", "mode": "100755", "type": "blob", "sha": "5555555555555555555555555555555555555555"}, + map[string]any{"path": ".github/safepush.json", "mode": "100644", "type": "blob", "sha": ghTestPolicyID}, + }} + } + fixture.responses["/git/blobs/"+ghTestPolicyID] = map[string]any{"sha": ghTestPolicyID, "encoding": "base64", "size": len(ghTestPolicy), "content": base64.StdEncoding.EncodeToString([]byte(ghTestPolicy))} + fixture.responses["/git/blobs/"+ghTestBlob] = map[string]any{"sha": ghTestBlob, "encoding": "base64", "size": 232, "content": base64.StdEncoding.EncodeToString([]byte(ghTestWorkflow))} + fixture.responses["/actions/workflows"] = ghTestEnvelope("workflows", []any{ + map[string]any{"id": 101, "path": ".github/workflows/ci.yml", "state": "active"}, + map[string]any{"id": 102, "path": ".github/workflows/release.yml", "state": "active"}, + map[string]any{"id": 103, "path": "dynamic/github-code-scanning/codeql", "state": "active"}, + }) + run := map[string]any{ + "id": 201, "workflow_id": 101, "run_number": 11, "run_attempt": 1, "check_suite_id": 301, + "path": ".github/workflows/ci.yml", "event": "pull_request", "head_sha": ghTestHead, "head_branch": "feature", + "status": "completed", "conclusion": "success", "repository": repo, "head_repository": repo, + "pull_requests": []any{map[string]any{ + "id": 71, "number": 7, "url": "https://api.github.com/repos/owner/repo/pulls/7", + "head": map[string]any{"ref": "feature", "sha": ghTestHead, "repo": map[string]any{"id": 42}}, + "base": map[string]any{"ref": "main", "sha": ghTestBase, "repo": map[string]any{"id": 42}}, + }}, "referenced_workflows": []any{}, + } + fixture.responses["/actions/runs"] = ghTestEnvelope("workflow_runs", []any{run}) + fixture.responses["/actions/runs/201"] = run + fixture.setJobs([]string{"unit", "race"}) + server := httptest.NewTLSServer(http.HandlerFunc(fixture.serveHTTP)) + t.Cleanup(server.Close) + target, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + fixture.client = &http.Client{Transport: githubFixtureTransport{t: t, base: server.Client().Transport, target: target}} + return fixture +} + +func ghTestEnvelope(key string, items []any) map[string]any { + return map[string]any{"total_count": len(items), key: items} +} + +func (fixture *githubHTTPSFixture) setJobs(names []string) { + jobs, checks := make([]any, 0, len(names)), make([]any, 0, len(names)) + for index, name := range names { + id := 401 + index + jobs = append(jobs, map[string]any{ + "id": id, "run_id": 201, "run_attempt": 1, "head_sha": ghTestHead, "head_branch": "feature", "name": name, + "status": "completed", "conclusion": "success", "check_run_url": fmt.Sprintf("https://api.github.com/repos/owner/repo/check-runs/%d", id), + "steps": []any{ + map[string]any{"name": "Set up job", "number": 1, "status": "completed", "conclusion": "success"}, + map[string]any{"name": "Run tests", "number": 3, "status": "completed", "conclusion": "success"}, + map[string]any{"name": "Complete job", "number": 7, "status": "completed", "conclusion": "success"}, + }, + }) + checks = append(checks, map[string]any{ + "id": id, "name": name, "head_sha": ghTestHead, "status": "completed", "conclusion": "success", + "check_suite": map[string]any{"id": 301}, "app": map[string]any{"id": 15368, "slug": "github-actions"}, + }) + } + fixture.responses["/actions/runs/201/attempts/1/jobs"] = ghTestEnvelope("jobs", jobs) + fixture.responses["/check-suites/301/check-runs"] = ghTestEnvelope("check_runs", checks) +} + +func (fixture *githubHTTPSFixture) setRequiredWorkflow(contents string) { + // The baseline uses a separately computed known Git blob hash. Mutations + // produce another native-shaped object so the parser, not a hash mismatch, + // rejects a workflow whose failed outcomes could have success conclusions. + digest := sha1.New() // #nosec G401 -- synthetic Git object identifier. + _, _ = fmt.Fprintf(digest, "blob %d%c%s", len(contents), 0, contents) + objectID := fmt.Sprintf("%x", digest.Sum(nil)) + for _, treeID := range []string{ghTestBaseTree, ghTestHeadTree} { + tree := fixture.responses["/git/trees/"+treeID].(map[string]any) + ghTestFirst(tree, "tree")["sha"] = objectID + } + fixture.responses["/git/blobs/"+objectID] = map[string]any{ + "sha": objectID, "encoding": "base64", "size": len(contents), + "content": base64.StdEncoding.EncodeToString([]byte(contents)), + } +} + +func (fixture *githubHTTPSFixture) serveHTTP(writer http.ResponseWriter, request *http.Request) { + fixture.mu.Lock() + defer fixture.mu.Unlock() + suffix := strings.TrimPrefix(request.URL.Path, "/repos/owner/repo") + fixture.counts[suffix]++ + count := fixture.counts[suffix] + fixture.requests = append(fixture.requests, request.URL.RequestURI()) + if request.Method != http.MethodGet || request.Header.Get("Authorization") != "Bearer test-token" { + fixture.t.Error("unexpected method or synthetic authorization header") + } + if suffix == "/actions/runs" && (request.URL.Query().Get("event") != "pull_request" || request.URL.Query().Get("head_sha") != ghTestHead) { + fixture.t.Error("run query lacks exact event and candidate filter") + } + writer.Header().Set("Content-Type", "application/json") + if fixture.reply != nil && fixture.reply(writer, request, count) { + return + } + original, exists := fixture.responses[suffix] + if !exists { + fixture.t.Errorf("unexpected API route: %s", suffix) + writer.WriteHeader(http.StatusNotFound) + return + } + data, err := json.Marshal(original) + if err != nil { + fixture.t.Error(err) + return + } + var response map[string]any + if err = json.Unmarshal(data, &response); err != nil { + fixture.t.Error(err) + return + } + if pageText := request.URL.Query().Get("page"); pageText != "" { + page, pageErr := strconv.Atoi(pageText) + if pageErr != nil || page <= 0 || request.URL.Query().Get("per_page") != "100" { + fixture.t.Error("invalid pagination request") + writer.WriteHeader(http.StatusBadRequest) + return + } + for _, key := range []string{"workflows", "workflow_runs", "jobs", "check_runs"} { + if all, ok := response[key].([]any); ok { + start := min((page-1)*100, len(all)) + response[key] = all[start:min(start+100, len(all))] + } + } + } + if fixture.mutate != nil { + fixture.mutate(request, count, response) + } + if err = json.NewEncoder(writer).Encode(response); err != nil { + fixture.t.Error(err) + } +} + +func (fixture *githubHTTPSFixture) verify() error { + return VerifyGitHub(context.Background(), fixture.client, "test-token", fixture.policy, fixture.candidate) +} + +func ghTestFirst(response map[string]any, key string) map[string]any { + return response[key].([]any)[0].(map[string]any) +} + +func TestGitHubHTTPSAdmissionAndTrustedPolicy(t *testing.T) { + fixture := newGitHubHTTPSFixture(t) + policy, err := ReadGitHubPolicy(context.Background(), fixture.client, "test-token", "owner/repo", ghTestBase) + if err != nil || !reflect.DeepEqual(policy, fixture.policy) { + t.Fatalf("policy result = %#v, %v", policy, err) + } + if err = fixture.verify(); err != nil { + t.Fatal(err) + } + fixture.mu.Lock() + defer fixture.mu.Unlock() + for _, required := range []string{"/git/commits/" + ghTestBase, "/git/trees/" + ghTestBaseTree, "/git/trees/" + ghTestHeadTree, "/git/blobs/" + ghTestBlob, "/actions/runs/201/attempts/1/jobs", "/check-suites/301/check-runs", "/actions/runs/201"} { + if fixture.counts[required] == 0 { + t.Errorf("missing native binding read: %s", required) + } + } + if fixture.counts["/pulls/7"] != 2 || fixture.counts["/branches/main"] != 2 || fixture.counts["/actions/runs"] != 2 { + t.Error("successful admission must re-read PR, protected target, and latest runs") + } +} + +func TestGitHubSuccessConclusionsCannotHideContinueOnError(t *testing.T) { + for _, scope := range []string{"job", "step"} { + for _, value := range []string{"true", "${{ false }}", "null", "'false'", "false"} { + t.Run(scope+"_"+value, func(t *testing.T) { + fixture := newGitHubHTTPSFixture(t) + var contents string + if scope == "job" { + contents = strings.Replace(ghTestWorkflow, " steps:\n", " continue-on-error: "+value+"\n steps:\n", 1) + } else { + contents = strings.Replace(ghTestWorkflow, " run: go test ./...\n", " run: go test ./...\n continue-on-error: "+value+"\n", 1) + } + fixture.setRequiredWorkflow(contents) + // Base and head both have this exact blob. Every REST workflow, + // job, check and step still reports completed/success, which is + // realistic for a failed step with continue-on-error: true. + err := fixture.verify() + if value == "false" { + if err != nil { + t.Fatal(err) + } + return + } + if !errors.Is(err, ErrGitHubSource) { + t.Fatalf("API success admitted unsafe accepted workflow: %v", err) + } + if fixture.counts["/actions/runs"] != 0 { + t.Fatal("unsafe source was not rejected before trusting run statuses") + } + }) + } + } +} + +func TestGitHubWorkflowBlobMustMatchTrustedObject(t *testing.T) { + fixture := newGitHubHTTPSFixture(t) + fixture.mutate = func(request *http.Request, _ int, response map[string]any) { + if strings.HasSuffix(request.URL.Path, "/git/blobs/"+ghTestBlob) { + response["content"] = base64.StdEncoding.EncodeToString([]byte(strings.Replace(ghTestWorkflow, "unit", "evil", 1))) + } + } + if err := fixture.verify(); !errors.Is(err, ErrGitHubSource) { + t.Fatalf("unverified workflow blob admitted: %v", err) + } +} + +func TestGitHubOptionalReferencedWorkflowsAndExcludedGate(t *testing.T) { + fixture := newGitHubHTTPSFixture(t) + fixture.mutate = func(request *http.Request, _ int, response map[string]any) { + if strings.HasSuffix(request.URL.Path, "/actions/runs") { + delete(ghTestFirst(response, "workflow_runs"), "referenced_workflows") + response["workflow_runs"] = append(response["workflow_runs"].([]any), map[string]any{"id": 900, "workflow_id": 999, "path": "organization/required-verifier.yml", "status": "in_progress"}) + response["total_count"] = 2 + } + if strings.HasSuffix(request.URL.Path, "/actions/runs/201") { + delete(response, "referenced_workflows") + } + } + if err := fixture.verify(); err != nil { + t.Fatal(err) + } +} + +func TestGitHubPendingIsOnlyUnfinishedRequiredRuns(t *testing.T) { + for _, status := range []string{"absent", "queued", "in_progress", "waiting", "requested", "pending"} { + t.Run(status, func(t *testing.T) { + fixture := newGitHubHTTPSFixture(t) + fixture.mutate = func(request *http.Request, _ int, response map[string]any) { + if !strings.HasSuffix(request.URL.Path, "/actions/runs") { + return + } + if status == "absent" { + response["total_count"], response["workflow_runs"] = 0, []any{} + } else { + run := ghTestFirst(response, "workflow_runs") + run["status"], run["conclusion"] = status, nil + } + } + if err := fixture.verify(); !errors.Is(err, ErrGitHubPending) { + t.Fatalf("got %v, want pending", err) + } + if fixture.counts["/actions/runs/201/attempts/1/jobs"] != 0 { + t.Fatal("fetched jobs before required runs completed") + } + }) + } + for _, conclusion := range []string{"failure", "cancelled", "skipped", "neutral", "timed_out", "action_required", ""} { + t.Run("completed_"+conclusion, func(t *testing.T) { + fixture := newGitHubHTTPSFixture(t) + fixture.mutate = func(request *http.Request, _ int, response map[string]any) { + if strings.HasSuffix(request.URL.Path, "/actions/runs") { + ghTestFirst(response, "workflow_runs")["conclusion"] = conclusion + } + } + if err := fixture.verify(); !errors.Is(err, ErrCheckNotSuccessful) { + t.Fatalf("got %v, want completed failure", err) + } + }) + } +} + +func TestGitHubRejectsSpoofingAndIncompleteExecution(t *testing.T) { + cases := []struct { + name string + suffix string + change func(map[string]any) + }{ + {"repository_name_reused", "", func(v map[string]any) { v["id"] = 999 }}, + {"unprotected_target", "/branches/main", func(v map[string]any) { v["protected"] = false }}, + {"wrong_pr_head", "/pulls/7", func(v map[string]any) { v["head"].(map[string]any)["sha"] = ghTestOther }}, + {"stale_pr_base", "/pulls/7", func(v map[string]any) { v["base"].(map[string]any)["sha"] = ghTestOther }}, + {"draft_pr", "/pulls/7", func(v map[string]any) { v["draft"] = true }}, + {"closed_pr", "/pulls/7", func(v map[string]any) { v["state"] = "closed" }}, + {"diverged_head", "/compare/" + ghTestBase + "..." + ghTestHead, func(v map[string]any) { v["behind_by"] = 1 }}, + {"wrong_commit_tree_binding", "/git/commits/" + ghTestHead, func(v map[string]any) { v["sha"] = ghTestOther }}, + {"wrong_tree_sha", "/git/trees/" + ghTestHeadTree, func(v map[string]any) { v["sha"] = ghTestHead }}, + {"truncated_tree", "/git/trees/" + ghTestHeadTree, func(v map[string]any) { v["truncated"] = true }}, + {"changed_required_workflow", "/git/trees/" + ghTestHeadTree, func(v map[string]any) { ghTestFirst(v, "tree")["sha"] = ghTestOther }}, + {"changed_excluded_workflow", "/git/trees/" + ghTestHeadTree, func(v map[string]any) { v["tree"].([]any)[1].(map[string]any)["sha"] = ghTestOther }}, + {"changed_workflow_helper", "/git/trees/" + ghTestHeadTree, func(v map[string]any) { v["tree"].([]any)[2].(map[string]any)["sha"] = ghTestOther }}, + {"symlink_workflow", "/git/trees/" + ghTestHeadTree, func(v map[string]any) { ghTestFirst(v, "tree")["mode"] = "120000" }}, + {"extra_candidate_workflow", "/git/trees/" + ghTestHeadTree, func(v map[string]any) { + v["tree"] = append(v["tree"].([]any), map[string]any{"path": ".github/workflows/spoof.yml", "type": "blob", "mode": "100644", "sha": ghTestOther}) + }}, + {"missing_excluded_workflow", "/git/trees/" + ghTestHeadTree, func(v map[string]any) { v["tree"] = v["tree"].([]any)[:1] }}, + {"disabled_required_workflow", "/actions/workflows", func(v map[string]any) { ghTestFirst(v, "workflows")["state"] = "disabled_manually" }}, + {"wrong_workflow_id_same_name", "/actions/runs", func(v map[string]any) { ghTestFirst(v, "workflow_runs")["workflow_id"] = 103 }}, + {"wrong_run_event", "/actions/runs", func(v map[string]any) { ghTestFirst(v, "workflow_runs")["event"] = "push" }}, + {"wrong_run_head", "/actions/runs", func(v map[string]any) { ghTestFirst(v, "workflow_runs")["head_sha"] = ghTestOther }}, + {"wrong_run_repository", "/actions/runs", func(v map[string]any) { ghTestFirst(v, "workflow_runs")["repository"].(map[string]any)["id"] = 43 }}, + {"missing_pr_association", "/actions/runs", func(v map[string]any) { ghTestFirst(v, "workflow_runs")["pull_requests"] = []any{} }}, + {"same_sha_other_pr", "/actions/runs", func(v map[string]any) { ghTestFirst(ghTestFirst(v, "workflow_runs"), "pull_requests")["number"] = 8 }}, + {"wrong_pr_native_id", "/actions/runs", func(v map[string]any) { ghTestFirst(ghTestFirst(v, "workflow_runs"), "pull_requests")["id"] = 72 }}, + {"retargeted_pr_run", "/actions/runs", func(v map[string]any) { + ghTestFirst(ghTestFirst(v, "workflow_runs"), "pull_requests")["base"].(map[string]any)["ref"] = "master" + }}, + {"stale_run_base", "/actions/runs", func(v map[string]any) { + ghTestFirst(ghTestFirst(v, "workflow_runs"), "pull_requests")["base"].(map[string]any)["sha"] = ghTestOther + }}, + {"wrong_run_base_repository", "/actions/runs", func(v map[string]any) { + ghTestFirst(ghTestFirst(v, "workflow_runs"), "pull_requests")["base"].(map[string]any)["repo"].(map[string]any)["id"] = 43 + }}, + {"wrong_associated_head", "/actions/runs", func(v map[string]any) { + ghTestFirst(ghTestFirst(v, "workflow_runs"), "pull_requests")["head"].(map[string]any)["sha"] = ghTestOther + }}, + {"reusable_chain_without_policy", "/actions/runs", func(v map[string]any) { + ghTestFirst(v, "workflow_runs")["referenced_workflows"] = []any{map[string]any{"path": "owner/other/workflow.yml"}} + }}, + {"unknown_run_status", "/actions/runs", func(v map[string]any) { ghTestFirst(v, "workflow_runs")["status"] = "unrecognized" }}, + {"duplicate_run", "/actions/runs", func(v map[string]any) { + v["workflow_runs"] = append(v["workflow_runs"].([]any), ghTestFirst(v, "workflow_runs")) + v["total_count"] = 2 + }}, + {"wrong_job_attempt", "/actions/runs/201/attempts/1/jobs", func(v map[string]any) { ghTestFirst(v, "jobs")["run_attempt"] = 2 }}, + {"wrong_job_run", "/actions/runs/201/attempts/1/jobs", func(v map[string]any) { ghTestFirst(v, "jobs")["run_id"] = 202 }}, + {"wrong_job_head", "/actions/runs/201/attempts/1/jobs", func(v map[string]any) { ghTestFirst(v, "jobs")["head_sha"] = ghTestOther }}, + {"wrong_check_url_host", "/actions/runs/201/attempts/1/jobs", func(v map[string]any) { + ghTestFirst(v, "jobs")["check_run_url"] = "https://example.invalid/check-runs/401" + }}, + {"duplicate_job", "/actions/runs/201/attempts/1/jobs", func(v map[string]any) { v["jobs"].([]any)[1] = ghTestFirst(v, "jobs") }}, + {"empty_steps", "/actions/runs/201/attempts/1/jobs", func(v map[string]any) { ghTestFirst(v, "jobs")["steps"] = []any{} }}, + {"skipped_success_step", "/actions/runs/201/attempts/1/jobs", func(v map[string]any) { ghTestFirst(ghTestFirst(v, "jobs"), "steps")["conclusion"] = "skipped" }}, + {"failed_step_conclusion", "/actions/runs/201/attempts/1/jobs", func(v map[string]any) { ghTestFirst(ghTestFirst(v, "jobs"), "steps")["conclusion"] = "failure" }}, + {"wrong_check_app", "/check-suites/301/check-runs", func(v map[string]any) { ghTestFirst(v, "check_runs")["app"].(map[string]any)["id"] = 123 }}, + {"wrong_check_suite", "/check-suites/301/check-runs", func(v map[string]any) { ghTestFirst(v, "check_runs")["check_suite"].(map[string]any)["id"] = 999 }}, + {"neutral_check", "/check-suites/301/check-runs", func(v map[string]any) { ghTestFirst(v, "check_runs")["conclusion"] = "neutral" }}, + {"check_job_name_mismatch", "/check-suites/301/check-runs", func(v map[string]any) { ghTestFirst(v, "check_runs")["name"] = "spoof" }}, + {"wrong_check_head", "/check-suites/301/check-runs", func(v map[string]any) { ghTestFirst(v, "check_runs")["head_sha"] = ghTestOther }}, + {"duplicate_check", "/check-suites/301/check-runs", func(v map[string]any) { v["check_runs"].([]any)[1] = ghTestFirst(v, "check_runs") }}, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + fixture := newGitHubHTTPSFixture(t) + fixture.mutate = func(request *http.Request, _ int, response map[string]any) { + if request.URL.Path == "/repos/owner/repo"+test.suffix { + test.change(response) + } + } + if err := fixture.verify(); err == nil || errors.Is(err, ErrGitHubPending) { + t.Fatalf("unsafe API fixture accepted or retried: %v", err) + } + }) + } +} + +func TestGitHubCompletedRunCannotOmitRequiredJobs(t *testing.T) { + fixture := newGitHubHTTPSFixture(t) + fixture.setJobs([]string{"unit"}) + if err := fixture.verify(); !errors.Is(err, ErrMissingCheck) { + t.Fatalf("got %v, want missing job failure", err) + } +} + +func TestGitHubLatestFailureCannotReuseOlderSuccess(t *testing.T) { + fixture := newGitHubHTTPSFixture(t) + fixture.mutate = func(request *http.Request, _ int, response map[string]any) { + if strings.HasSuffix(request.URL.Path, "/actions/runs") { + older := ghTestFirst(response, "workflow_runs") + newer := make(map[string]any) + for key, value := range older { + newer[key] = value + } + newer["id"], newer["run_number"], newer["conclusion"] = 202, 12, "failure" + response["workflow_runs"], response["total_count"] = []any{older, newer}, 2 + } + } + if err := fixture.verify(); !errors.Is(err, ErrCheckNotSuccessful) { + t.Fatalf("got %v, want newer failure", err) + } +} + +func TestGitHubStateMutationDuringCollection(t *testing.T) { + for _, target := range []string{"pr", "branch", "run_attempt", "latest_run", "workflow"} { + t.Run(target, func(t *testing.T) { + fixture := newGitHubHTTPSFixture(t) + fixture.mutate = func(request *http.Request, count int, response map[string]any) { + suffix := strings.TrimPrefix(request.URL.Path, "/repos/owner/repo") + switch { + case target == "pr" && suffix == "/pulls/7" && count == 2: + response["head"].(map[string]any)["sha"] = ghTestOther + case target == "branch" && suffix == "/branches/main" && count == 2: + response["commit"].(map[string]any)["sha"] = ghTestOther + case target == "run_attempt" && suffix == "/actions/runs/201": + response["run_attempt"] = 2 + case target == "latest_run" && suffix == "/actions/runs" && count == 2: + run := ghTestFirst(response, "workflow_runs") + run["run_attempt"], run["status"], run["conclusion"] = 2, "in_progress", nil + case target == "workflow" && suffix == "/actions/workflows" && count == 2: + ghTestFirst(response, "workflows")["id"] = 999 + } + } + if err := fixture.verify(); !errors.Is(err, ErrGitHubState) { + t.Fatalf("changed collection returned %v", err) + } + }) + } +} + +func TestGitHubBoundedCompletePagination(t *testing.T) { + for _, mode := range []string{"valid", "missing_page", "changed_total", "duplicate_across_pages", "over_limit"} { + t.Run(mode, func(t *testing.T) { + fixture := newGitHubHTTPSFixture(t) + names := make([]string, 101) + for index := range names { + names[index] = fmt.Sprintf("matrix-%d", index) + } + fixture.policy.Workflows[0].Jobs = names + fixture.setJobs(names) + fixture.mutate = func(request *http.Request, _ int, response map[string]any) { + if !strings.HasSuffix(request.URL.Path, "/jobs") { + return + } + if mode == "over_limit" { + response["total_count"] = 1001 + } + if request.URL.Query().Get("page") != "2" { + return + } + switch mode { + case "missing_page": + response["jobs"] = []any{} + case "changed_total": + response["total_count"] = 102 + case "duplicate_across_pages": + ghTestFirst(response, "jobs")["id"] = 401 + } + } + err := fixture.verify() + if mode == "valid" && err != nil { + t.Fatal(err) + } + if mode != "valid" && (err == nil || errors.Is(err, ErrGitHubPending)) { + t.Fatalf("incomplete or ambiguous pagination admitted: %v", err) + } + }) + } +} + +func TestGitHubHTTPFailuresAreBoundedAndRedacted(t *testing.T) { + for _, mode := range []string{"redirect", "forbidden", "duplicate_json", "trailing_json", "content_type", "short_body", "oversized"} { + t.Run(mode, func(t *testing.T) { + fixture := newGitHubHTTPSFixture(t) + redirects := 0 + fixture.client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { redirects++; return nil } + fixture.reply = func(writer http.ResponseWriter, request *http.Request, _ int) bool { + if request.URL.Path != "/repos/owner/repo" { + return false + } + switch mode { + case "redirect": + writer.Header().Set("Location", "https://example.invalid/test-token") + writer.WriteHeader(http.StatusFound) + case "forbidden": + writer.WriteHeader(http.StatusForbidden) + _, _ = fmt.Fprint(writer, `{"message":"test-token opaque-failure-sentinel"}`) + case "duplicate_json": + _, _ = fmt.Fprint(writer, `{"id":42,"id":999,"full_name":"owner/repo"}`) + case "trailing_json": + _, _ = fmt.Fprint(writer, `{"id":42,"full_name":"owner/repo"}{"extra":true}`) + case "content_type": + writer.Header().Set("Content-Type", "text/html") + _, _ = fmt.Fprint(writer, `{"id":42,"full_name":"owner/repo"}`) + case "short_body": + writer.Header().Set("Content-Length", "1000") + _, _ = fmt.Fprint(writer, `{"id":42}`) + case "oversized": + writer.Header().Set("Content-Length", strconv.Itoa(strictjson.MaxDocumentBytes+1)) + _, _ = fmt.Fprint(writer, strings.Repeat(" ", strictjson.MaxDocumentBytes+1)) + } + return true + } + err := fixture.verify() + if !errors.Is(err, ErrGitHubAPI) || strings.Contains(err.Error(), "test-token") || strings.Contains(err.Error(), "opaque-failure-sentinel") || strings.Contains(err.Error(), "example.invalid") { + t.Fatalf("unredacted or unexpected HTTP error: %v", err) + } + if redirects != 0 { + t.Fatal("adapter followed a redirect with its credential") + } + }) + } +} + +func TestReadGitHubPolicyRejectsSymlinksAndForgedBlobs(t *testing.T) { + for _, mode := range []string{"symlink", "executable", "blob_hash", "encoding", "size", "repository_id"} { + t.Run(mode, func(t *testing.T) { + fixture := newGitHubHTTPSFixture(t) + fixture.mutate = func(request *http.Request, _ int, response map[string]any) { + if strings.HasSuffix(request.URL.Path, "/git/trees/"+ghTestBaseTree) && (mode == "symlink" || mode == "executable") { + entry := response["tree"].([]any)[3].(map[string]any) + if mode == "symlink" { + entry["mode"] = "120000" + } else { + entry["mode"] = "100755" + } + } + if mode == "repository_id" && request.URL.Path == "/repos/owner/repo" { + response["id"] = 99 + } + if strings.HasSuffix(request.URL.Path, "/git/blobs/"+ghTestPolicyID) { + switch mode { + case "blob_hash": + response["content"] = base64.StdEncoding.EncodeToString([]byte(strings.Replace(ghTestPolicy, "unit", "evil", 1))) + case "encoding": + response["encoding"] = "utf-8" + case "size": + response["size"] = 1 + } + } + } + if _, err := ReadGitHubPolicy(context.Background(), fixture.client, "test-token", "owner/repo", ghTestBase); err == nil || errors.Is(err, ErrGitHubPending) { + t.Fatalf("unsafe policy accepted: %v", err) + } + }) + } +} + +func TestReadGitHubPolicyRejectsUnknownFieldsAfterBlobVerification(t *testing.T) { + fixture := newGitHubHTTPSFixture(t) + const policyID = "fc446526e44b5189d82ea86595794637c3b8128f" + contents := ghTestPolicy[:len(ghTestPolicy)-1] + `,"untrusted":true}` + tree := fixture.responses["/git/trees/"+ghTestBaseTree].(map[string]any) + tree["tree"].([]any)[3].(map[string]any)["sha"] = policyID + fixture.responses["/git/blobs/"+policyID] = map[string]any{ + "sha": policyID, "encoding": "base64", "size": 236, + "content": base64.StdEncoding.EncodeToString([]byte(contents)), + } + if _, err := ReadGitHubPolicy(context.Background(), fixture.client, "test-token", "owner/repo", ghTestBase); !errors.Is(err, ErrPolicy) { + t.Fatalf("correctly hashed policy with unknown field returned %v", err) + } +} + +func TestGitHubRejectsInvalidInputBeforeHTTP(t *testing.T) { + fixture := newGitHubHTTPSFixture(t) + fixture.candidate.HeadSHA = "invalid" + if err := fixture.verify(); !errors.Is(err, ErrGitHubCandidate) { + t.Fatalf("got %v", err) + } + if len(fixture.requests) != 0 { + t.Fatal("sent request for invalid candidate") + } + if _, err := ReadGitHubPolicy(context.Background(), fixture.client, "test-token", "owner/../repo", ghTestBase); !errors.Is(err, ErrPolicy) { + t.Fatalf("got %v", err) + } + if len(fixture.requests) != 0 { + t.Fatal("sent request for invalid repository") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := ReadGitHubPolicy(ctx, fixture.client, "test-token", "owner/repo", ghTestBase); !errors.Is(err, ErrGitHubAPI) { + t.Fatalf("got %v", err) + } +} + +type githubCancelOnCloseBody struct { + io.ReadCloser + cancel context.CancelFunc +} + +func (body githubCancelOnCloseBody) Close() error { + err := body.ReadCloser.Close() + body.cancel() + return err +} + +type githubCancelAfterReadTransport struct { + base http.RoundTripper + suffix string + nth int + count int + cancel context.CancelFunc +} + +func (transport *githubCancelAfterReadTransport) RoundTrip(request *http.Request) (*http.Response, error) { + response, err := transport.base.RoundTrip(request) + if strings.HasSuffix(request.URL.Path, transport.suffix) { + transport.count++ + if err == nil && transport.count == transport.nth { + response.Body = githubCancelOnCloseBody{ReadCloser: response.Body, cancel: transport.cancel} + } + } + return response, err +} + +func TestGitHubCannotSucceedAfterFinalResponseCancellation(t *testing.T) { + for _, operation := range []string{"policy", "verify"} { + t.Run(operation, func(t *testing.T) { + fixture := newGitHubHTTPSFixture(t) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + transport := &githubCancelAfterReadTransport{base: fixture.client.Transport, cancel: cancel} + fixture.client.Transport = transport + var err error + if operation == "policy" { + transport.suffix, transport.nth = "/git/blobs/"+ghTestPolicyID, 1 + _, err = ReadGitHubPolicy(ctx, fixture.client, "test-token", "owner/repo", ghTestBase) + } else { + transport.suffix, transport.nth = "/branches/main", 2 + err = VerifyGitHub(ctx, fixture.client, "test-token", fixture.policy, fixture.candidate) + } + if !errors.Is(err, ErrGitHubAPI) || ctx.Err() == nil { + t.Fatalf("operation succeeded after cancellation: %v", err) + } + }) + } +} diff --git a/pkg/safepush/policy.go b/pkg/safepush/policy.go new file mode 100644 index 0000000..c740a78 --- /dev/null +++ b/pkg/safepush/policy.go @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) IURII TRUKHIN 2012-2022, Elena Trukhina 2023-2026. Project and trademarks: Elena Trukhina ZZP. + +package safepush + +import ( + "encoding/json" + "errors" + "path" + "regexp" + "strings" + "unicode" + "unicode/utf8" + + "github.com/opencloudtech/CloudRING/internal/strictjson" +) + +const GitHubPolicySchema = "cloudring.safepush.github/v1" + +var ( + ErrPolicy = errors.New("SafePush policy is invalid") + ErrRequest = errors.New("SafePush request is invalid") +) + +// GitHubPolicy is read from the accepted target commit by the trusted CI +// workflow. Loading a candidate's policy would let that candidate remove its +// own requirements. RepositoryID also prevents name reuse from changing scope. +type GitHubPolicy struct { + SchemaVersion string `json:"schemaVersion"` + Repository string `json:"repository"` + RepositoryID int64 `json:"repositoryId"` + Workflows []GitHubWorkflow `json:"workflows"` + ExcludedWorkflows []string `json:"excludedWorkflows"` +} + +// GitHubWorkflow identifies the complete expected job set of one test workflow. +// Path and job name together are the identity; a same-named job in another +// workflow cannot satisfy a requirement. +type GitHubWorkflow struct { + Path string `json:"path"` + Jobs []string `json:"jobs"` +} + +// GitHubCandidate binds one admission attempt to the PR and target snapshot. +// The adapter re-reads GitHub state; these values do not assert approval or +// successful execution and cannot replace native server protection. +type GitHubCandidate struct { + PullRequest int + HeadSHA string + BaseSHA string + TargetBranch string +} + +var repositoryName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}/[A-Za-z0-9_.-]{1,100}$`) + +// ValidateGitHubCandidate validates event arguments before any network access. +// Ref names are never accepted in place of immutable Git object identifiers. +func ValidateGitHubCandidate(repository string, candidate GitHubCandidate) error { + if !repositoryName.MatchString(repository) || strings.HasSuffix(repository, "/.") || + strings.HasSuffix(repository, "/..") || candidate.PullRequest <= 0 || + !validHeadSHA(candidate.HeadSHA) || !validHeadSHA(candidate.BaseSHA) || + candidate.HeadSHA == candidate.BaseSHA || + (candidate.TargetBranch != "main" && candidate.TargetBranch != "master") { + return ErrRequest + } + return nil +} + +// DecodeGitHubPolicy rejects unknown and duplicate fields, trailing documents, +// empty test sets, overlapping exclusions and ambiguous workflow identities. +func DecodeGitHubPolicy(data []byte) (GitHubPolicy, error) { + // encoding/json matches struct fields case-insensitively, including when + // DisallowUnknownFields is set. Inspect exact spellings before decoding so + // a second "Workflows" or "Jobs" cannot replace an earlier requirement. + var fields map[string]json.RawMessage + if strictjson.Decode(data, &fields) != nil || fields == nil { + return GitHubPolicy{}, ErrPolicy + } + for name := range fields { + switch name { + case "schemaVersion", "repository", "repositoryId", "workflows", "excludedWorkflows": + default: + return GitHubPolicy{}, ErrPolicy + } + } + var workflows []map[string]json.RawMessage + if json.Unmarshal(fields["workflows"], &workflows) != nil { + return GitHubPolicy{}, ErrPolicy + } + for _, workflow := range workflows { + for name := range workflow { + if name != "path" && name != "jobs" { + return GitHubPolicy{}, ErrPolicy + } + } + } + var policy GitHubPolicy + if json.Unmarshal(data, &policy) != nil || ValidateGitHubPolicy(policy) != nil { + return GitHubPolicy{}, ErrPolicy + } + return policy, nil +} + +func ValidateGitHubPolicy(policy GitHubPolicy) error { + if policy.SchemaVersion != GitHubPolicySchema || policy.RepositoryID <= 0 || + !repositoryName.MatchString(policy.Repository) || + strings.HasSuffix(policy.Repository, "/.") || strings.HasSuffix(policy.Repository, "/..") || + len(policy.Workflows) == 0 || len(policy.Workflows)+len(policy.ExcludedWorkflows) > 100 { + return ErrPolicy + } + seen := make(map[string]bool) + for _, workflow := range policy.Workflows { + if !validWorkflowPath(workflow.Path) || seen[workflow.Path] || len(workflow.Jobs) == 0 || len(workflow.Jobs) > 1000 { + return ErrPolicy + } + seen[workflow.Path] = true + jobs := make(map[string]bool) + for _, job := range workflow.Jobs { + if !validJobName(job) || jobs[job] { + return ErrPolicy + } + jobs[job] = true + } + } + for _, excluded := range policy.ExcludedWorkflows { + if !validWorkflowPath(excluded) || seen[excluded] { + return ErrPolicy + } + seen[excluded] = true + } + return nil +} + +func validWorkflowPath(value string) bool { + if len(value) > 255 || !strings.HasPrefix(value, ".github/workflows/") || path.Clean(value) != value { + return false + } + name := strings.TrimPrefix(value, ".github/workflows/") + if strings.ContainsAny(name, "/\\") || !(strings.HasSuffix(name, ".yml") || strings.HasSuffix(name, ".yaml")) { + return false + } + return validJobName(name) +} + +func validJobName(value string) bool { + if value == "" || len(value) > 512 || !utf8.ValidString(value) || strings.TrimSpace(value) != value { + return false + } + for _, r := range value { + if unicode.IsControl(r) { + return false + } + } + return true +} diff --git a/pkg/safepush/policy_test.go b/pkg/safepush/policy_test.go new file mode 100644 index 0000000..7422c11 --- /dev/null +++ b/pkg/safepush/policy_test.go @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) IURII TRUKHIN 2012-2022, Elena Trukhina 2023-2026. Project and trademarks: Elena Trukhina ZZP. + +package safepush + +import ( + "encoding/json" + "errors" + "strings" + "testing" +) + +func policyFixture() GitHubPolicy { + return GitHubPolicy{ + SchemaVersion: GitHubPolicySchema, + Repository: "synthetic/project", RepositoryID: 42, + Workflows: []GitHubWorkflow{{Path: ".github/workflows/ci.yml", Jobs: []string{"test", "race"}}}, + ExcludedWorkflows: []string{".github/workflows/release.yml"}, + } +} + +func TestPolicyRejectsAmbiguousOrWeakenedRequirements(t *testing.T) { + mutations := map[string]func(*GitHubPolicy){ + "wrong schema": func(p *GitHubPolicy) { p.SchemaVersion = "unknown" }, + "no repository identity": func(p *GitHubPolicy) { p.RepositoryID = 0 }, + "path traversal": func(p *GitHubPolicy) { p.Repository = "synthetic/../project" }, + "no tests": func(p *GitHubPolicy) { p.Workflows = nil }, + "empty jobs": func(p *GitHubPolicy) { p.Workflows[0].Jobs = nil }, + "duplicate workflow": func(p *GitHubPolicy) { p.Workflows = append(p.Workflows, p.Workflows[0]) }, + "duplicate job": func(p *GitHubPolicy) { p.Workflows[0].Jobs = []string{"test", "test"} }, + "excluded test workflow": func(p *GitHubPolicy) { p.ExcludedWorkflows = []string{p.Workflows[0].Path} }, + "duplicate exclusion": func(p *GitHubPolicy) { p.ExcludedWorkflows = append(p.ExcludedWorkflows, p.ExcludedWorkflows[0]) }, + "nested workflow path": func(p *GitHubPolicy) { p.Workflows[0].Path = ".github/workflows/nested/ci.yml" }, + "noncanonical workflow": func(p *GitHubPolicy) { p.Workflows[0].Path = ".github/workflows/../ci.yml" }, + "control in identity": func(p *GitHubPolicy) { p.Workflows[0].Jobs[0] = "test\npass" }, + "invisible job suffix": func(p *GitHubPolicy) { p.Workflows[0].Jobs[0] = "test " }, + } + for name, mutate := range mutations { + t.Run(name, func(t *testing.T) { + policy := policyFixture() + mutate(&policy) + data, err := json.Marshal(policy) + if err != nil { + t.Fatal(err) + } + if _, err := DecodeGitHubPolicy(data); !errors.Is(err, ErrPolicy) { + t.Fatalf("unsafe policy was accepted: %v", err) + } + }) + } +} + +func TestPolicyJSONHasOneClosedUnambiguousDocument(t *testing.T) { + data, err := json.Marshal(policyFixture()) + if err != nil { + t.Fatal(err) + } + if _, err := DecodeGitHubPolicy(data); err != nil { + t.Fatal(err) + } + for name, malformed := range map[string]string{ + "unknown field": strings.Replace(string(data), `"repositoryId":42`, `"repositoryId":42,"trusted":true`, 1), + "duplicate field": strings.Replace(string(data), `"repositoryId":42`, `"repositoryId":42,"repositoryId":42`, 1), + "case variant duplicate": strings.Replace(string(data), `"repositoryId":42`, `"repositoryId":42,"RepositoryId":43`, 1), + "weakened case variant workflows": strings.Replace(string(data), `"excludedWorkflows":`, `"Workflows":[{"path":".github/workflows/ci.yml","jobs":["test"]}],"excludedWorkflows":`, 1), + "weakened case variant jobs": strings.Replace(string(data), `"jobs":["test","race"]`, `"jobs":["test","race"],"Jobs":["test"]`, 1), + "unknown case variant schema": strings.Replace(string(data), `"schemaVersion":`, `"SchemaVersion":`, 1), + "unknown case variant path": strings.Replace(string(data), `"path":`, `"Path":`, 1), + "escaped duplicate field": strings.Replace(string(data), `"repositoryId":42`, `"repositoryId":42,"\u0072epositoryId":42`, 1), + "trailing object": string(data) + `{}`, + "wrong integer": strings.Replace(string(data), `"repositoryId":42`, `"repositoryId":42.5`, 1), + } { + t.Run(name, func(t *testing.T) { + if _, err := DecodeGitHubPolicy([]byte(malformed)); !errors.Is(err, ErrPolicy) { + t.Fatalf("ambiguous policy was accepted: %v", err) + } + }) + } +} diff --git a/pkg/safepush/repository_policy_test.go b/pkg/safepush/repository_policy_test.go new file mode 100644 index 0000000..1702762 --- /dev/null +++ b/pkg/safepush/repository_policy_test.go @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) IURII TRUKHIN 2012-2022, Elena Trukhina 2023-2026. Project and trademarks: Elena Trukhina ZZP. + +package safepush + +import ( + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +// This catches an added, removed, or renamed repository CI job being silently +// omitted from the admission policy. API provenance is tested separately. +func TestRepositoryPolicyCoversEveryPreMergeJob(t *testing.T) { + root := openRepositoryRoot(t) + data, err := root.ReadFile(".github/safepush.json") + if err != nil { + t.Fatal(err) + } + policy, err := DecodeGitHubPolicy(data) + if err != nil { + t.Fatal(err) + } + expectedPaths := make(map[string]bool) + for _, workflow := range policy.Workflows { + expectedPaths[workflow.Path] = true + data, err := root.ReadFile(filepath.FromSlash(workflow.Path)) + if err != nil { + t.Fatal(err) + } + var actual struct { + On map[string]any `yaml:"on"` + Jobs map[string]struct { + Name string `yaml:"name"` + } `yaml:"jobs"` + } + if err := yaml.Unmarshal(data, &actual); err != nil { + t.Fatal(err) + } + if _, exists := actual.On["pull_request"]; !exists { + t.Fatal("required test workflow no longer runs for pull requests") + } + var names []string + for id, job := range actual.Jobs { + name := job.Name + if name == "" { + name = id + } + if strings.Contains(name, "${{") { + t.Fatal("dynamic job name needs an explicit matrix policy") + } + names = append(names, name) + } + sort.Strings(names) + expected := append([]string(nil), workflow.Jobs...) + sort.Strings(expected) + if !reflect.DeepEqual(names, expected) { + t.Fatalf("workflow %s jobs differ from admission policy: actual=%v expected=%v", workflow.Path, names, expected) + } + } + for _, excluded := range policy.ExcludedWorkflows { + expectedPaths[excluded] = true + data, err := root.ReadFile(filepath.FromSlash(excluded)) + if err != nil { + t.Fatal(err) + } + var actual struct { + On map[string]any `yaml:"on"` + } + if err := yaml.Unmarshal(data, &actual); err != nil { + t.Fatal(err) + } + if len(actual.On) != 1 { + t.Fatal("excluded release workflow has unexpected triggers") + } + if _, exists := actual.On["push"]; !exists { + t.Fatal("excluded workflow is not a post-acceptance release workflow") + } + } + directory, err := root.Open(".github/workflows") + if err != nil { + t.Fatal(err) + } + entries, err := directory.ReadDir(-1) + if closeErr := directory.Close(); closeErr != nil { + t.Fatal(closeErr) + } + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + name := ".github/workflows/" + entry.Name() + if (strings.HasSuffix(name, ".yml") || strings.HasSuffix(name, ".yaml")) && !expectedPaths[name] { + t.Fatalf("workflow is absent from admission policy: %s", name) + } + } +} + +// Confine fixture reads to the checkout, including symlink resolution. +func openRepositoryRoot(t *testing.T) *os.Root { + t.Helper() + root, err := os.OpenRoot("../..") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := root.Close(); err != nil { + t.Error(err) + } + }) + return root +} diff --git a/pkg/safepush/workflow_source.go b/pkg/safepush/workflow_source.go new file mode 100644 index 0000000..d2f38d5 --- /dev/null +++ b/pkg/safepush/workflow_source.go @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) IURII TRUKHIN 2012-2022, Elena Trukhina 2023-2026. Project and trademarks: Elena Trukhina ZZP. + +package safepush + +import ( + "bytes" + "errors" + "io" + "strings" + + "gopkg.in/yaml.v3" +) + +const ( + maxWorkflowSourceBytes = 1 << 20 + maxWorkflowYAMLDepth = 64 + maxWorkflowYAMLNodes = 100000 +) + +// verifyWorkflowSource checks accepted, hash-verified workflow bytes. GitHub's +// REST step conclusion can be success after a failed outcome when the workflow +// permits continue-on-error, so successful API statuses alone are insufficient. +// This verifies that specific control; it does not prove test adequacy or the +// behavior of scripts/actions invoked by the workflow. +func verifyWorkflowSource(contents []byte) error { + if len(contents) == 0 || len(contents) > maxWorkflowSourceBytes { + return ErrGitHubSource + } + decoder := yaml.NewDecoder(bytes.NewReader(contents)) + var document yaml.Node + if decoder.Decode(&document) != nil { + return ErrGitHubSource + } + nodes := 0 + if !validWorkflowYAML(&document, 0, &nodes) || document.Kind != yaml.DocumentNode || + len(document.Content) != 1 || document.Content[0].Kind != yaml.MappingNode { + return ErrGitHubSource + } + var trailing yaml.Node + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + return ErrGitHubSource + } + jobs := workflowMappingValue(document.Content[0], "jobs") + if jobs == nil || jobs.Kind != yaml.MappingNode || len(jobs.Content) == 0 { + return ErrGitHubSource + } + for index := 1; index < len(jobs.Content); index += 2 { + job := jobs.Content[index] + if job.Kind != yaml.MappingNode || !workflowStopsOnError(job) { + return ErrGitHubSource + } + steps := workflowMappingValue(job, "steps") + if steps == nil || steps.Kind != yaml.SequenceNode || len(steps.Content) == 0 { + return ErrGitHubSource + } + for _, step := range steps.Content { + if step.Kind != yaml.MappingNode || !workflowStopsOnError(step) { + return ErrGitHubSource + } + } + } + return nil +} + +func workflowStopsOnError(mapping *yaml.Node) bool { + value := workflowMappingValue(mapping, "continue-on-error") + return value == nil || (value.Kind == yaml.ScalarNode && value.Tag == "!!bool" && + value.Style == 0 && value.Value == "false") +} + +func workflowMappingValue(mapping *yaml.Node, key string) *yaml.Node { + for index := 0; index < len(mapping.Content); index += 2 { + if strings.EqualFold(mapping.Content[index].Value, key) { + return mapping.Content[index+1] + } + } + return nil +} + +func validWorkflowYAML(node *yaml.Node, depth int, nodes *int) bool { + *nodes = *nodes + 1 + if depth > maxWorkflowYAMLDepth || *nodes > maxWorkflowYAMLNodes || node == nil || + node.Anchor != "" || node.Alias != nil { + return false + } + switch node.Kind { + case yaml.DocumentNode: + if depth != 0 || len(node.Content) != 1 { + return false + } + case yaml.MappingNode: + if node.Tag != "!!map" || len(node.Content)%2 != 0 { + return false + } + seen := make(map[string]bool, len(node.Content)/2) + for index := 0; index < len(node.Content); index += 2 { + key := node.Content[index] + if key.Kind != yaml.ScalarNode || key.Tag != "!!str" || !validJobName(key.Value) || key.Value == "<<" { + return false + } + identity := strings.ToLower(key.Value) + if seen[identity] { + return false + } + seen[identity] = true + } + case yaml.SequenceNode: + if node.Tag != "!!seq" { + return false + } + case yaml.ScalarNode: + if len(node.Content) != 0 { + return false + } + switch node.Tag { + case "!!str", "!!bool", "!!int", "!!float", "!!null": + default: + return false + } + default: + return false // Includes aliases, without traversing their targets. + } + for _, child := range node.Content { + if !validWorkflowYAML(child, depth+1, nodes) { + return false + } + } + return true +} diff --git a/pkg/safepush/workflow_source_test.go b/pkg/safepush/workflow_source_test.go new file mode 100644 index 0000000..dfd27fe --- /dev/null +++ b/pkg/safepush/workflow_source_test.go @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) IURII TRUKHIN 2012-2022, Elena Trukhina 2023-2026. Project and trademarks: Elena Trukhina ZZP. + +package safepush + +import ( + "errors" + "fmt" + "path/filepath" + "strings" + "testing" +) + +func TestWorkflowSourceRequiresLiteralStopOnError(t *testing.T) { + for _, scope := range []string{"job", "step"} { + for _, value := range []string{ + "true", "TRUE", "False", "FALSE", "0", "no", "off", "null", "~", "", + "'false'", `"false"`, "${{ false }}", "${{ matrix.experimental }}", + "!!str false", "!!bool false", "!custom false", "&disabled false", "[false]", "{value: false}", + } { + t.Run(scope+"_"+value, func(t *testing.T) { + contents := "jobs:\n test:\n runs-on: ubuntu-latest\n" + if scope == "job" { + contents += " continue-on-error: " + value + "\n" + } + contents += " steps:\n - run: go test ./...\n" + if scope == "step" { + contents += " continue-on-error: " + value + "\n" + } + if err := verifyWorkflowSource([]byte(contents)); !errors.Is(err, ErrGitHubSource) { + t.Fatalf("unsafe control was accepted: %v", err) + } + }) + } + } + for _, contents := range []string{ + ghTestWorkflow, + "jobs: {test: {continue-on-error: false, steps: [{run: 'exit 0', continue-on-error: false}]}}", + "---\n'jobs': {'test': {'steps': [{'run': 'exit 0', 'continue-on-error': false}]}}\n...\n# end\n", + } { + if err := verifyWorkflowSource([]byte(contents)); err != nil { + t.Fatalf("literal stop-on-error workflow rejected: %v", err) + } + } +} + +func TestWorkflowSourceRejectsAmbiguousYAML(t *testing.T) { + safe := "jobs: {test: {steps: [{run: 'exit 0'}]}}\n" + cases := map[string]string{ + "empty": "", + "comments_only": "# empty\n", + "root_sequence": "[{jobs: {}}]", + "jobs_sequence": "jobs: []", + "jobs_expression": "jobs: ${{ matrix.jobs }}", + "empty_jobs": "jobs: {}", + "null_job": "jobs: {test: null}", + "no_steps": "jobs: {test: {runs-on: ubuntu-latest}}", + "empty_steps": "jobs: {test: {steps: []}}", + "step_scalar": "jobs: {test: {steps: ['exit 0']}}", + "step_mapping": "jobs: {test: {steps: {run: 'exit 0'}}}", + "steps_expression": "jobs: {test: {steps: '${{ matrix.steps }}'}}", + "second_document": safe + "---\n" + safe, + "empty_second_doc": safe + "---\n", + "malformed_trailer": safe + "[not valid", + "duplicate_root": safe + "jobs: {test: {steps: [{run: 'exit 1', continue-on-error: true}]}}\n", + "duplicate_job": "jobs: {test: {continue-on-error: true, continue-on-error: false, steps: [{run: 'exit 1'}]}}", + "duplicate_step": "jobs: {test: {steps: [{continue-on-error: true, continue-on-error: false, run: 'exit 1'}]}}", + "case_duplicate": "jobs: {test: {steps: [{continue-on-error: false, CONTINUE-ON-ERROR: true, run: 'exit 1'}]}}", + "case_control": "jobs: {test: {steps: [{CONTINUE-ON-ERROR: true, run: 'exit 1'}]}}", + "escaped_key": `jobs: {test: {steps: [{"continue\u002don-error": true, run: 'exit 1'}]}}`, + "merge_job": "jobs: {test: {<<: {continue-on-error: true}, steps: [{run: 'exit 1'}]}}", + "quoted_merge": "jobs: {test: {'<<': {continue-on-error: true}, steps: [{run: 'exit 1'}]}}", + "merge_step": "jobs: {test: {steps: [{<<: {continue-on-error: true}, run: 'exit 1'}]}}", + "anchor_only": "defaults: &settings {continue-on-error: true}\n" + safe, + "alias_false": "setting: &disabled false\njobs: {test: {steps: [{run: 'exit 0', continue-on-error: *disabled}]}}", + "alias_steps": "setting: &steps [{run: 'exit 1', continue-on-error: true}]\njobs: {test: {steps: *steps}}", + "alias_cycle": "setting: &cycle [*cycle]\n" + safe, + "alias_expansion": "a: &a [false,false,false]\nb: &b [*a,*a,*a]\nc: [*b,*b,*b]\n" + safe, + "nonstring_key": "true: ignored\n" + safe, + "complex_key": "? [jobs, steps]\n: ignored\n" + safe, + "custom_mapping": "jobs: !custom {test: {steps: [{run: 'exit 0'}]}}", + "block_string": "jobs:\n test:\n continue-on-error: |\n false\n steps:\n - run: exit 0\n", + } + for name, contents := range cases { + t.Run(name, func(t *testing.T) { + if err := verifyWorkflowSource([]byte(contents)); !errors.Is(err, ErrGitHubSource) { + t.Fatalf("ambiguous YAML accepted: %v", err) + } + }) + } +} + +func TestWorkflowSourceParsingIsBounded(t *testing.T) { + if err := verifyWorkflowSource([]byte(strings.Repeat(" ", maxWorkflowSourceBytes+1))); !errors.Is(err, ErrGitHubSource) { + t.Fatal("oversized workflow accepted") + } + deep := "env: {nested: " + strings.Repeat("[", maxWorkflowYAMLDepth+1) + "false" + strings.Repeat("]", maxWorkflowYAMLDepth+1) + "}\n" + ghTestWorkflow + if err := verifyWorkflowSource([]byte(deep)); !errors.Is(err, ErrGitHubSource) { + t.Fatal("overly deep workflow accepted") + } + var many strings.Builder + many.WriteString("env: {") + for index := 0; index <= maxWorkflowYAMLNodes/2; index++ { + _, _ = fmt.Fprintf(&many, "k%d: 0,", index) + } + many.WriteString("}\n") + many.WriteString(ghTestWorkflow) + if many.Len() > maxWorkflowSourceBytes { + t.Fatal("node-limit fixture must exercise nodes rather than byte size") + } + if err := verifyWorkflowSource([]byte(many.String())); !errors.Is(err, ErrGitHubSource) { + t.Fatal("too many workflow nodes accepted") + } +} + +func TestWorkflowSourceAcceptsCurrentRequiredWorkflows(t *testing.T) { + root := openRepositoryRoot(t) + for _, name := range []string{"ci.yml", "cla-dco.yml", "license.yml", "security.yml", "supply-chain.yml"} { + t.Run(name, func(t *testing.T) { + contents, err := root.ReadFile(filepath.Join(".github", "workflows", name)) + if err != nil { + t.Fatal(err) + } + if err = verifyWorkflowSource(contents); err != nil { + t.Fatalf("current required workflow rejected: %v", err) + } + }) + } +} From 6513bec1dadc1ff9efc9f396df6e115c49c476b7 Mon Sep 17 00:00:00 2001 From: trukhinyuri Date: Thu, 24 Sep 2026 19:35:47 +0200 Subject: [PATCH 2/3] fix(ci): upgrade pinned etcdutl to 3.6.15 Signed-off-by: trukhinyuri --- .github/scripts/build-release-etcdutl.sh | 8 ++++---- .github/scripts/check-release-binaries.sh | 6 +++--- .github/workflows/release-provenance.yml | 16 ++++++++-------- contracts/etcd-recovery/README.md | 4 ++-- .../etcd-recovery/image-identity.schema.json | 4 ++-- contracts/etcd-recovery/receipt.schema.json | 4 ++-- contracts/etcd-recovery/request.schema.json | 4 ++-- docs/ci-checks.md | 2 +- docs/releasing.md | 2 +- pkg/backup/etcdrecovery/tool_linux_test.go | 2 +- pkg/backup/etcdrecovery/types.go | 4 ++-- 11 files changed, 28 insertions(+), 28 deletions(-) diff --git a/.github/scripts/build-release-etcdutl.sh b/.github/scripts/build-release-etcdutl.sh index 57f0f60..b45f9e1 100644 --- a/.github/scripts/build-release-etcdutl.sh +++ b/.github/scripts/build-release-etcdutl.sh @@ -13,9 +13,9 @@ if [[ -e "$1" || -L "$1" ]]; then exit 1 fi -version='3.6.14' -source_commit='fc04cf702b0a46c2fd85547a2be05705b100a496' -source_archive_sha256='c02ebbf6af5f9266f111009fb8d585649a9fcafe5788c200f5a28a7f00d438f0' +version='3.6.15' +source_commit='a3346427fbe6e456e9b6d6e429346481c6be7c71' +source_archive_sha256='32b1cb031a84067ae451cad1b060a07298bdd4e4ba290897ec0c73a02001e0bb' source_url="https://codeload.github.com/etcd-io/etcd/tar.gz/${source_commit}" go_version='go1.26.8' @@ -52,7 +52,7 @@ for attempt in 1 2; do test -d "${source_root}/etcdutl" # The verified source archive binds the complete tree; these checks also # prevent a version-label or module-path mismatch during a future pin bump. - grep -Eq '^[[:space:]]*Version[[:space:]]*=[[:space:]]*"3\.6\.14"$' \ + grep -Eq '^[[:space:]]*Version[[:space:]]*=[[:space:]]*"3\.6\.15"$' \ "${source_root}/api/version/version.go" grep -Fxq 'module go.etcd.io/etcd/etcdutl/v3' "${source_root}/etcdutl/go.mod" ( diff --git a/.github/scripts/check-release-binaries.sh b/.github/scripts/check-release-binaries.sh index e5e4375..edefc79 100644 --- a/.github/scripts/check-release-binaries.sh +++ b/.github/scripts/check-release-binaries.sh @@ -45,9 +45,9 @@ EOF # block in build-release-etcdutl.sh. That script builds the binary under # test from this exact source; drifting copies silently adjudicate against # the wrong source tree. -etcd_version='3.6.14' -source_commit='fc04cf702b0a46c2fd85547a2be05705b100a496' -source_archive_sha256='c02ebbf6af5f9266f111009fb8d585649a9fcafe5788c200f5a28a7f00d438f0' +etcd_version='3.6.15' +source_commit='a3346427fbe6e456e9b6d6e429346481c6be7c71' +source_archive_sha256='32b1cb031a84067ae451cad1b060a07298bdd4e4ba290897ec0c73a02001e0bb' go_version='go1.26.8' etcd_version_arg='' diff --git a/.github/workflows/release-provenance.yml b/.github/workflows/release-provenance.yml index dd105ed..45e0b6d 100644 --- a/.github/workflows/release-provenance.yml +++ b/.github/workflows/release-provenance.yml @@ -244,10 +244,10 @@ jobs: cp "${RUNNER_TEMP}/release-etcdutl/etcdutl-source.json" dist/ install -d -m 0755 dist/etcd-licenses cp "${RUNNER_TEMP}/release-etcdutl/etcd-LICENSE" dist/etcd-licenses/LICENSE - test "$(dist/etcd-recovery-worker/etcdutl version)" = $'etcdutl version: 3.6.14\nAPI version: 3.6' + test "$(dist/etcd-recovery-worker/etcdutl version)" = $'etcdutl version: 3.6.15\nAPI version: 3.6' tool_digest="$(sha256sum dist/etcd-recovery-worker/etcdutl | cut -d ' ' -f 1)" worker_digest="$(sha256sum dist/etcd-recovery-worker/cloudring-etcd-recovery-worker | cut -d ' ' -f 1)" - test "${tool_digest}" = '2b7fea46e31fda1989168fbd8f7ec73f5839155f4b1af7bab274950e337e996d' + test "${tool_digest}" = 'aaebd9806bd5d614b16de6bbff4aaf9d7c1986f48d52068e578d93451d000835' bash .github/scripts/check-release-binaries.sh dist/etcd-recovery-worker/* jq -n -cS \ --arg source_commit "${GITHUB_SHA}" \ @@ -278,10 +278,10 @@ jobs: { type: "application", name: "etcdutl", - version: "3.6.14", - purl: "pkg:github/etcd-io/etcd@v3.6.14", + version: "3.6.15", + purl: "pkg:github/etcd-io/etcd@v3.6.15", properties: [ - {name: "cloudring.upstream.source-commit", value: "fc04cf702b0a46c2fd85547a2be05705b100a496"}, + {name: "cloudring.upstream.source-commit", value: "a3346427fbe6e456e9b6d6e429346481c6be7c71"}, {name: "cloudring.upstream.build-compiler", value: "go1.26.8"} ], hashes: [{alg: "SHA-256", content: $tool_digest}] @@ -452,7 +452,7 @@ jobs: componentSbomSha256: $component_sbom_digest, containerfileSha256: $containerfile_digest, etcdutlSha256: $etcdutl_digest, - etcdutlVersion: "3.6.14", + etcdutlVersion: "3.6.15", imageDigest: $image_digest, imageName: "ghcr.io/opencloudtech/cloudring-etcd-recovery-worker", imageSbomSha256: $image_sbom_digest, @@ -482,8 +482,8 @@ jobs: (.imageDigest | test("^sha256:[0-9a-f]{64}$")) and (.imageSubjectDigest | test("^sha256:[0-9a-f]{64}$")) and (.workerExecutableSha256 | test("^[0-9a-f]{64}$")) and - .etcdutlVersion == "3.6.14" and - .etcdutlSha256 == "2b7fea46e31fda1989168fbd8f7ec73f5839155f4b1af7bab274950e337e996d" and + .etcdutlVersion == "3.6.15" and + .etcdutlSha256 == "aaebd9806bd5d614b16de6bbff4aaf9d7c1986f48d52068e578d93451d000835" and .baseImageName == "gcr.io/distroless/static-debian12:nonroot" and .baseImageDigest == "sha256:f5b485ea962d9bd1186b2f6b3a061191539b905b82ec395de78cbfae51f20e35" and (.containerfileSha256 | test("^[0-9a-f]{64}$")) and diff --git a/contracts/etcd-recovery/README.md b/contracts/etcd-recovery/README.md index 0cc1ce9..1cedfef 100644 --- a/contracts/etcd-recovery/README.md +++ b/contracts/etcd-recovery/README.md @@ -31,8 +31,8 @@ GET with system CA validation, no proxy and no redirect. Raw endpoint, bucket, object key/version and credentials never appear in tool arguments, environment, stdout, stderr or receipts. -`etcdutl` 3.6.14 is content-pinned to -`2b7fea46e31fda1989168fbd8f7ec73f5839155f4b1af7bab274950e337e996d` +`etcdutl` 3.6.15 is content-pinned to +`aaebd9806bd5d614b16de6bbff4aaf9d7c1986f48d52068e578d93451d000835` before execution. This executable is rebuilt from the unchanged official source with Go 1.26.8 and retained symbols using the [release build procedure](../../docs/releasing.md); its source and build identity are retained as `etcdutl-source.json`. diff --git a/contracts/etcd-recovery/image-identity.schema.json b/contracts/etcd-recovery/image-identity.schema.json index 96b680b..fdc8ffb 100644 --- a/contracts/etcd-recovery/image-identity.schema.json +++ b/contracts/etcd-recovery/image-identity.schema.json @@ -31,8 +31,8 @@ "imageDigest": {"$ref": "#/$defs/imageDigest"}, "imageSubjectDigest": {"$ref": "#/$defs/imageDigest"}, "workerExecutableSha256": {"$ref": "#/$defs/sha"}, - "etcdutlVersion": {"const": "3.6.14"}, - "etcdutlSha256": {"const": "2b7fea46e31fda1989168fbd8f7ec73f5839155f4b1af7bab274950e337e996d"}, + "etcdutlVersion": {"const": "3.6.15"}, + "etcdutlSha256": {"const": "aaebd9806bd5d614b16de6bbff4aaf9d7c1986f48d52068e578d93451d000835"}, "baseImageName": {"const": "gcr.io/distroless/static-debian12:nonroot"}, "baseImageDigest": {"const": "sha256:f5b485ea962d9bd1186b2f6b3a061191539b905b82ec395de78cbfae51f20e35"}, "containerfileSha256": {"$ref": "#/$defs/sha"}, diff --git a/contracts/etcd-recovery/receipt.schema.json b/contracts/etcd-recovery/receipt.schema.json index 86c4bde..d82536e 100644 --- a/contracts/etcd-recovery/receipt.schema.json +++ b/contracts/etcd-recovery/receipt.schema.json @@ -29,8 +29,8 @@ "required": ["name", "version", "executableSha256"], "properties": { "name": {"const": "etcdutl"}, - "version": {"const": "3.6.14"}, - "executableSha256": {"const": "2b7fea46e31fda1989168fbd8f7ec73f5839155f4b1af7bab274950e337e996d"} + "version": {"const": "3.6.15"}, + "executableSha256": {"const": "aaebd9806bd5d614b16de6bbff4aaf9d7c1986f48d52068e578d93451d000835"} } }, "sourceStatusSha256": {"$ref": "#/$defs/sha"}, diff --git a/contracts/etcd-recovery/request.schema.json b/contracts/etcd-recovery/request.schema.json index 85beed6..9ab8fa7 100644 --- a/contracts/etcd-recovery/request.schema.json +++ b/contracts/etcd-recovery/request.schema.json @@ -26,8 +26,8 @@ }, "workerExecutableSha256": {"$ref": "#/$defs/sha"}, "workerImageDigest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, - "etcdutlVersion": {"const": "3.6.14"}, - "etcdutlSha256": {"const": "2b7fea46e31fda1989168fbd8f7ec73f5839155f4b1af7bab274950e337e996d"}, + "etcdutlVersion": {"const": "3.6.15"}, + "etcdutlSha256": {"const": "aaebd9806bd5d614b16de6bbff4aaf9d7c1986f48d52068e578d93451d000835"}, "issuedAt": {"type": "string", "format": "date-time"}, "expiresAt": {"type": "string", "format": "date-time"}, "timeoutSeconds": {"type": "integer", "minimum": 1, "maximum": 1800} diff --git a/docs/ci-checks.md b/docs/ci-checks.md index 687b728..898b834 100644 --- a/docs/ci-checks.md +++ b/docs/ci-checks.md @@ -101,7 +101,7 @@ the bundle checksum, and creates GitHub artifact attestations. The recovery image job independently reproduces the worker binary and two OCI image layouts, checks their Linux AMD64 subject manifest digests are identical, and requires the separately pushed registry subject to match that reviewed digest. -The official `etcdutl` 3.6.14 source is rebuilt twice with Go 1.26.8 and +The official `etcdutl` 3.6.15 source is rebuilt twice with Go 1.26.8 and independent caches. The source archive, rebuilt binary, BuildKit, Dockerfile frontend, Buildx and Syft inputs are immutable-version or content pinned. The job publishes only `sha-`, creates a real Syft image-package SBOM plus a diff --git a/docs/releasing.md b/docs/releasing.md index cfeec27..be44b6a 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -35,7 +35,7 @@ the binary scan can identify compiled functions instead of falling back to reports for every package in a dependency module. The upstream recovery tool is rebuilt from the hash-pinned official etcd -3.6.14 source using the release compiler, without source or dependency patches. +3.6.15 source using the release compiler, without source or dependency patches. Two independent builds must match the binary digest pinned by the recovery worker. The separately attested `etcdutl-source.json` records the source commit, archive hash, compiler and resulting binary hash. The real offline snapshot diff --git a/pkg/backup/etcdrecovery/tool_linux_test.go b/pkg/backup/etcdrecovery/tool_linux_test.go index 57c4d83..68ea5cf 100644 --- a/pkg/backup/etcdrecovery/tool_linux_test.go +++ b/pkg/backup/etcdrecovery/tool_linux_test.go @@ -113,7 +113,7 @@ func main() { child := exec.Command("/bin/sleep", "30") if err := child.Start(); err != nil { os.Exit(2) } if err := os.WriteFile(filepath.Join(os.Getenv("TMPDIR"), "child.pid"), []byte(strconv.Itoa(child.Process.Pid)), 0600); err != nil { os.Exit(3) } - fmt.Println("etcdutl version: 3.6.14") + fmt.Println("etcdutl version: 3.6.15") fmt.Println("API version: 3.6") }` if err := os.WriteFile(source, []byte(helper), 0o600); err != nil { diff --git a/pkg/backup/etcdrecovery/types.go b/pkg/backup/etcdrecovery/types.go index efd09b2..91e2451 100644 --- a/pkg/backup/etcdrecovery/types.go +++ b/pkg/backup/etcdrecovery/types.go @@ -16,8 +16,8 @@ const ( ImageIdentitySchemaVersion = "cloudring.etcd-recovery.image-identity/v1" SandboxStateSchemaVersion = "cloudring.etcd-recovery.sandbox-state/v1" ToolName = "etcdutl" - ToolVersion = "3.6.14" - ToolSHA256 = "2b7fea46e31fda1989168fbd8f7ec73f5839155f4b1af7bab274950e337e996d" + ToolVersion = "3.6.15" + ToolSHA256 = "aaebd9806bd5d614b16de6bbff4aaf9d7c1986f48d52068e578d93451d000835" DefaultRequestPath = "/run/cloudring/request/request.json" DefaultArchivePath = "/run/cloudring/archive/snapshot.db" From ec1cc520fd4d5c2a890ab2b001429b2246ee5b94 Mon Sep 17 00:00:00 2001 From: trukhinyuri Date: Thu, 24 Sep 2026 19:51:44 +0200 Subject: [PATCH 3/3] fix(ci): sync release workflow policy digest Signed-off-by: trukhinyuri --- .github/workflow-policy.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflow-policy.json b/.github/workflow-policy.json index fcc7274..4c20e49 100644 --- a/.github/workflow-policy.json +++ b/.github/workflow-policy.json @@ -5,7 +5,7 @@ ".github/workflows/ci.yml": "680cb5fe8f2fff0989e33f5c2da93d19963d9fd8280967b2baa5e045ea2f4e49", ".github/workflows/cla-dco.yml": "5c3437cfa6ec997d97a3e9218a87114c1bb14f524e648d0df775f5bc6340430f", ".github/workflows/license.yml": "813e0774ad869ca4898449c0ba087b005a3187b32455d0dc2f3a62d89b4e8b81", - ".github/workflows/release-provenance.yml": "36712b212df0f999a1a699a41498030d5d634161d35de2a3ed3db80ffb7bbee9", + ".github/workflows/release-provenance.yml": "0c5617215ef38dd86037e97c88220ed4af9d865b97121ef3a4ef84cfa6c06d1d", ".github/workflows/security.yml": "0c77bba18ea6e3d1bd1cf3447549fc0bbb4aaa3a1f94a2763dd53a3f42390e9e", ".github/workflows/supply-chain.yml": "2d90a0d90de0f9dffa40005ae9134105ac9198bbee58416d570ed16f96e58097" }