From eb45b0c8916615eb1f3947c940d14ec53fe05f76 Mon Sep 17 00:00:00 2001 From: "trukhin.com" Date: Wed, 9 Sep 2026 17:21:02 +0200 Subject: [PATCH] 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..fb4aa4a --- /dev/null +++ b/cmd/cloudring-safepush/main.go @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) Iurii Trukhin. + +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..507953b --- /dev/null +++ b/cmd/cloudring-safepush/main_test.go @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) Iurii Trukhin. + +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 5aec5e5..a6d5c7b 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.9: ```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..d9a250d --- /dev/null +++ b/pkg/safepush/checks.go @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) Iurii Trukhin. + +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..c2aaff1 --- /dev/null +++ b/pkg/safepush/checks_test.go @@ -0,0 +1,330 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) Iurii Trukhin. + +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..2e4cfc8 --- /dev/null +++ b/pkg/safepush/doc.go @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) Iurii Trukhin. + +// 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..c039cdd --- /dev/null +++ b/pkg/safepush/github.go @@ -0,0 +1,748 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) Iurii Trukhin. + +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..6b5d310 --- /dev/null +++ b/pkg/safepush/github_test.go @@ -0,0 +1,718 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) Iurii Trukhin. + +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..56375ce --- /dev/null +++ b/pkg/safepush/policy.go @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) Iurii Trukhin. + +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..e7a3dcd --- /dev/null +++ b/pkg/safepush/policy_test.go @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) Iurii Trukhin. + +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..37e2484 --- /dev/null +++ b/pkg/safepush/repository_policy_test.go @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) Iurii Trukhin. + +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..3036217 --- /dev/null +++ b/pkg/safepush/workflow_source.go @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) Iurii Trukhin. + +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..19f72f1 --- /dev/null +++ b/pkg/safepush/workflow_source_test.go @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) Iurii Trukhin. + +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) + } + }) + } +}