diff --git a/.gds/bundle.lock.yaml b/.gds/bundle.lock.yaml index 0b0c5d2..fe8d619 100644 --- a/.gds/bundle.lock.yaml +++ b/.gds/bundle.lock.yaml @@ -4,14 +4,14 @@ schema_version: 1 bundle: version: "0.9.7-dev" release_sequence: 0 - source_tree_digest: "sha256:864917988777e8b9f61f87f08e4f08d94173e37c1352373332b5e02e76562f97" - digest: "sha256:eb2048f08b6e1dbc84d89792a6e3f658f6258ff7c7e3b11c87fd5c2fbd48b0de" + source_tree_digest: "sha256:624cc8e3d9318169eb170ba7cb677b06e11a7ff68bef59b86bb7318bd8652ce8" + digest: "sha256:d5b8988c4c228533eea9f8c22b5e080088494d380ff01688f999d285236c9360" projection: - input_digest: "sha256:f2fb932fd0598eaf673a8cd2b3232f73f7c5b3cfad377fb807dda30ec134e977" - output_digest: "sha256:0caadd7bca7495045f29598f2908a15bab55a2c421b10202095ff99e3c833d69" + input_digest: "sha256:55d339ca3fd58f5eba0795c38f68a878fe03c4e4d6b0f149d3c273dea56c5b8b" + output_digest: "sha256:daf1d7ea532a9ed356eeb053f73bc08cb5b3fe4e863ead0f440b4492c2f06ba4" files: - path: ".gds/compiled-policy.json" digest: "sha256:9f498788bdc34e52a0ab793c536e0e6a7b360c2e1a20446cbf03ed51986cdc6f" - path: ".github/workflows/gds-ci.yml" - digest: "sha256:275145c60b6f99f946085685a66b7eabb4a693043588cc55a87250401af28612" + digest: "sha256:18dfb90210b2fb933ae5141aabfecee9ec074fdb74d412aea5a8234dd406cdc7" diff --git a/.github/workflows/gds-ci.yml b/.github/workflows/gds-ci.yml index 20051f0..f397eef 100644 --- a/.github/workflows/gds-ci.yml +++ b/.github/workflows/gds-ci.yml @@ -1,8 +1,8 @@ # GENERATED FILE - DO NOT EDIT DIRECTLY # generator: gds # bundle: 0.9.7-dev -# source-tree-digest: sha256:864917988777e8b9f61f87f08e4f08d94173e37c1352373332b5e02e76562f97 -# input-digest: sha256:f2fb932fd0598eaf673a8cd2b3232f73f7c5b3cfad377fb807dda30ec134e977 +# source-tree-digest: sha256:624cc8e3d9318169eb170ba7cb677b06e11a7ff68bef59b86bb7318bd8652ce8 +# input-digest: sha256:55d339ca3fd58f5eba0795c38f68a878fe03c4e4d6b0f149d3c273dea56c5b8b # output-digest: sha256:b9bf3d0c64c0fb371596e7d090e82e62aebbfde91929115fc15fb28644e4fd38 # edit-source: # - .gds/repository.yaml diff --git a/.github/workflows/release-bundle.yml b/.github/workflows/release-bundle.yml index 544670a..196b0eb 100644 --- a/.github/workflows/release-bundle.yml +++ b/.github/workflows/release-bundle.yml @@ -176,6 +176,7 @@ jobs: --sequence "$RELEASE_SEQUENCE" \ --minimum-cli-version "$MINIMUM_CLI_VERSION" \ --source-ref "refs/tags/$RELEASE_TAG" \ + --trigger-ref "$GITHUB_REF" \ --go-binary "$GO_BINARY" \ > "$RUNNER_TEMP/gds-release-result.json" go run ./core/cmd/gds-release-builder \ @@ -395,10 +396,11 @@ jobs: --arg version "$RELEASE_VERSION" \ --argjson sequence "$RELEASE_SEQUENCE" \ --arg commit "$GITHUB_SHA" \ - --arg ref "$GITHUB_REF" \ + --arg ref "refs/tags/$RELEASE_TAG" \ + --arg trigger "$GITHUB_REF" \ --argjson run_id "$GITHUB_RUN_ID" \ --slurpfile failed "$failed_jobs" \ - '{schema_version:1,status:"failed",bundle_version:$version,release_sequence:$sequence,source_commit:$commit,source_ref:$ref,workflow_run_id:$run_id,failed_jobs:$failed[0],superseded_by:null}' \ + '{schema_version:1,status:"failed",bundle_version:$version,release_sequence:$sequence,source_commit:$commit,source_ref:$ref,trigger_ref:$trigger,workflow_run_id:$run_id,failed_jobs:$failed[0],superseded_by:null}' \ > "$envelope" if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then gh release upload "$RELEASE_TAG" "$envelope" --repo "$GITHUB_REPOSITORY" diff --git a/core/bundle/build.go b/core/bundle/build.go index ee22dc1..4d2fa3a 100644 --- a/core/bundle/build.go +++ b/core/bundle/build.go @@ -122,15 +122,24 @@ func Build( if err != nil { return Candidate{}, []domain.Finding{bundleFinding("GDS_BUNDLE_ARCHIVE_FAILED", err)} } + // The attestation binds the ref the release run was triggered on, not the + // release boundary tag the build checked out. When the resolve step creates + // the tag inside the same run those differ, and the identity digest must + // cover the ref the signer will actually record. + attestationRef := options.SourceRef + if options.TriggerRef != "" { + attestationRef = options.TriggerRef + } identityDigest := digestJSON(map[string]any{ "owner": trust.Source.Owner, "repository": trust.Source.Repository, - "workflow": options.Workflow, "ref": options.SourceRef, + "workflow": options.Workflow, "ref": attestationRef, "source_commit": options.SourceCommit, }) envelope := ReleaseEnvelope{ SchemaVersion: domain.SchemaVersion, BundleVersion: options.BundleVersion, ReleaseSequence: options.ReleaseSequence, SourceCommit: options.SourceCommit, SourceRef: options.SourceRef, + TriggerRef: options.TriggerRef, ExecutableFiles: executableFiles, ManifestDigest: digest(manifestBytes), ArtifactDigest: digest(artifact), ExpectedAttestationIdentityDigest: identityDigest, @@ -325,7 +334,8 @@ func writeArchive(files []sourceFile, manifest []byte) ([]byte, error) { func validateBuildOptions(options BuildOptions, trust TrustPolicy) *domain.Finding { if options.ReleaseSequence < trust.Release.MinimumReleaseSequence || !contains(trust.Source.AllowedWorkflows, options.Workflow) || - !allowedRef(trust.Source.AllowedRefs, options.SourceRef) { + !allowedRef(trust.Source.AllowedRefs, options.SourceRef) || + (options.TriggerRef != "" && !allowedRef(trust.Source.AllowedRefs, options.TriggerRef)) { finding := bundleFinding( "GDS_BUNDLE_BUILD_POLICY_BLOCKED", fmt.Errorf("release sequence, workflow, or ref is outside trust policy"), diff --git a/core/bundle/types.go b/core/bundle/types.go index 94302a6..bc31745 100644 --- a/core/bundle/types.go +++ b/core/bundle/types.go @@ -84,12 +84,16 @@ type SupplyChain struct { } type ReleaseEnvelope struct { - SchemaVersion int `json:"schema_version"` - BundleVersion string `json:"bundle_version"` - ReleaseSequence int `json:"release_sequence"` - Channel string `json:"channel,omitempty"` - SourceCommit string `json:"source_commit"` - SourceRef string `json:"source_ref"` + SchemaVersion int `json:"schema_version"` + BundleVersion string `json:"bundle_version"` + ReleaseSequence int `json:"release_sequence"` + Channel string `json:"channel,omitempty"` + SourceCommit string `json:"source_commit"` + SourceRef string `json:"source_ref"` + // TriggerRef records the ref the release run was triggered on, which is + // the ref the provenance attestation cryptographically binds. It differs + // from SourceRef whenever the release tag is created inside the same run. + TriggerRef string `json:"trigger_ref,omitempty"` ExecutableFiles int `json:"executable_files"` ManifestDigest string `json:"manifest_digest"` ArtifactDigest string `json:"artifact_digest"` @@ -103,6 +107,7 @@ type BuildOptions struct { MinimumCLIVersion string Workflow string SourceRef string + TriggerRef string TrackedSources []string AdditionalFiles []AdditionalFile } diff --git a/core/bundle/verify.go b/core/bundle/verify.go index 1d38762..a50f1d3 100644 --- a/core/bundle/verify.go +++ b/core/bundle/verify.go @@ -28,10 +28,17 @@ func Verify( "GDS_BUNDLE_ATTESTATION_INVALID", "Artifact provenance or digest was not verified.", )) } + // The attestation binds the ref the run was triggered on; when the release + // tag is created inside that same run, TriggerRef is the only ref the + // signer could have recorded. + expectedRef := envelope.SourceRef + if envelope.TriggerRef != "" { + expectedRef = envelope.TriggerRef + } if evidence.SourceOwner != trust.Source.Owner || evidence.SourceRepository != trust.Source.Repository || !contains(trust.Source.AllowedWorkflows, evidence.Workflow) || - !allowedRef(trust.Source.AllowedRefs, evidence.SourceRef) || evidence.SourceRef != envelope.SourceRef || + !allowedRef(trust.Source.AllowedRefs, evidence.SourceRef) || evidence.SourceRef != expectedRef || evidence.SourceCommit != envelope.SourceCommit { findings = append(findings, verificationFinding( "GDS_BUNDLE_ATTESTATION_IDENTITY_MISMATCH", diff --git a/core/cmd/gds-release-builder/main.go b/core/cmd/gds-release-builder/main.go index b193a4a..5107d02 100644 --- a/core/cmd/gds-release-builder/main.go +++ b/core/cmd/gds-release-builder/main.go @@ -30,14 +30,13 @@ func run(ctx context.Context, arguments []string, stdout, stderr io.Writer) int verifyDirectory := "" verifyTrustedRoot := "" trustPolicy := "" - extractEvidenceArchive := "" - extractEvidenceDestination := "" flags.StringVar(&request.Root, "root", ".", "exact clean GDS Git worktree root") flags.StringVar(&request.OutputDirectory, "output", "", "new release output directory") flags.StringVar(&request.Version, "version", "", "release SemVer without a v prefix") flags.IntVar(&request.ReleaseSequence, "sequence", 0, "monotonic release sequence") flags.StringVar(&request.MinimumCLIVersion, "minimum-cli-version", "", "minimum compatible CLI SemVer") flags.StringVar(&request.SourceRef, "source-ref", "", "exact refs/heads/* or refs/tags/* source ref") + flags.StringVar(&request.TriggerRef, "trigger-ref", "", "ref the release run was triggered on; the attestation binds it") flags.StringVar( &request.GoBinary, "go-binary", "", "absolute Go executable (defaults to the running toolchain GOROOT when available)", @@ -45,8 +44,6 @@ func run(ctx context.Context, arguments []string, stdout, stderr io.Writer) int flags.StringVar(&verifyDirectory, "verify-directory", "", "verify an existing release output directory") flags.StringVar(&verifyTrustedRoot, "verify-trusted-root", "", "verify one offline trusted-root.jsonl") flags.StringVar(&trustPolicy, "trust-policy", "", "independent local consumer trust policy") - flags.StringVar(&extractEvidenceArchive, "extract-harness-evidence-archive", "", "bounded harness evidence tar.gz") - flags.StringVar(&extractEvidenceDestination, "extract-harness-evidence-destination", "", "new private evidence directory") if err := flags.Parse(arguments); err != nil || flags.NArg() != 0 { return writeFailure(stderr, "GDS_RELEASE_ARGUMENTS_INVALID", "Release builder arguments are invalid.", 4) } @@ -54,16 +51,6 @@ func run(ctx context.Context, arguments []string, stdout, stderr io.Writer) int if err != nil { return writeFailure(stderr, "GDS_RELEASE_SCHEMAS_UNAVAILABLE", "Embedded schemas are unavailable.", 14) } - if extractEvidenceArchive != "" || extractEvidenceDestination != "" { - if extractEvidenceArchive == "" || extractEvidenceDestination == "" || flags.NArg() != 0 || - request.OutputDirectory != "" || verifyDirectory != "" || verifyTrustedRoot != "" || trustPolicy != "" { - return writeFailure(stderr, "GDS_RELEASE_ARGUMENTS_CONFLICT", "Evidence extraction requires exactly one archive and destination.", 4) - } - if err := releasebuilder.MaterializeHarnessEvidenceArchive(extractEvidenceArchive, extractEvidenceDestination); err != nil { - return writeFailureDetail(stderr, "GDS_HARNESS_EVIDENCE_ARCHIVE_INVALID", "Harness evidence archive was rejected.", err, 2) - } - return writeResult(stdout, map[string]any{"status": "materialized", "directory": extractEvidenceDestination}) - } if verifyDirectory != "" { if request.OutputDirectory != "" || request.Version != "" || request.ReleaseSequence != 0 || verifyTrustedRoot != "" || trustPolicy != "" { diff --git a/core/releasebuilder/builder.go b/core/releasebuilder/builder.go index 9da1ec2..776cdaf 100644 --- a/core/releasebuilder/builder.go +++ b/core/releasebuilder/builder.go @@ -119,6 +119,7 @@ func Build(ctx context.Context, request Request, schemas *validation.Set) (resul SourceCommit: source.Commit, MinimumCLIVersion: request.MinimumCLIVersion, Workflow: trust.Source.AllowedWorkflows[0], SourceRef: source.Ref, + TriggerRef: request.TriggerRef, TrackedSources: trackedSources, AdditionalFiles: additional, } first, findings := bundle.Build(root, options, trust, schemas) diff --git a/core/releasebuilder/harness_archive.go b/core/releasebuilder/harness_archive.go deleted file mode 100644 index 91b8196..0000000 --- a/core/releasebuilder/harness_archive.go +++ /dev/null @@ -1,271 +0,0 @@ -package releasebuilder - -import ( - "archive/tar" - "bufio" - "bytes" - "compress/gzip" - "crypto/rand" - "encoding/hex" - "errors" - "fmt" - "io" - "os" - "path" - "path/filepath" - "slices" - "strings" - "syscall" - - "golang.org/x/sys/unix" -) - -const ( - maxHarnessEvidenceArchiveSize = 8 << 20 - maxHarnessEvidenceMemberSize = 2 << 20 - maxHarnessEvidenceTotalSize = 8 << 20 -) - -var harnessEvidenceMembers = []string{ - "antigravity.json", "claude-code.json", "codex.json", - "cursor.json", "grok-build.json", "manifest.json", - "opencode.json", "pi.json", -} - -// MaterializeHarnessEvidenceArchive validates every header before extracting -// from the same bounded in-memory snapshot into a new private directory. The -// destination becomes visible only after the second complete validation pass. -func MaterializeHarnessEvidenceArchive(archivePath, destination string) (returnErr error) { - return materializeHarnessEvidenceArchive(archivePath, destination, harnessArchiveHooks{}) -} - -type harnessArchiveHooks struct { - afterParentInspect func() - beforePublish func() -} - -type harnessTransactionBoundary struct { - path string - destination string - identity os.FileInfo - root *os.Root -} - -func materializeHarnessEvidenceArchive( - archivePath, destination string, - hooks harnessArchiveHooks, -) (returnErr error) { - pathInfo, err := os.Lstat(archivePath) - if err != nil || !pathInfo.Mode().IsRegular() || pathInfo.Mode()&os.ModeSymlink != 0 || - pathInfo.Size() < 1 || pathInfo.Size() > maxHarnessEvidenceArchiveSize { - return errors.New("harness evidence archive is not a bounded regular file") - } - archive, err := os.Open(archivePath) - if err != nil { - return errors.New("harness evidence archive is unavailable") - } - defer archive.Close() - info, err := archive.Stat() - if err != nil || !info.Mode().IsRegular() || !os.SameFile(pathInfo, info) || - info.Size() < 1 || info.Size() > maxHarnessEvidenceArchiveSize { - return errors.New("harness evidence archive is not a bounded regular file") - } - raw, err := io.ReadAll(io.LimitReader(archive, maxHarnessEvidenceArchiveSize+1)) - if err != nil || int64(len(raw)) != info.Size() || len(raw) > maxHarnessEvidenceArchiveSize { - return errors.New("harness evidence archive changed or exceeded its bound while reading") - } - if err := inspectHarnessEvidenceArchive(raw, nil); err != nil { - return err - } - - boundary, err := openHarnessTransactionBoundary(destination, hooks.afterParentInspect) - if err != nil { - return err - } - defer boundary.root.Close() - stage, err := makeHarnessEvidenceStage(boundary.root) - if err != nil { - return err - } - defer func() { - if stage != "" { - returnErr = errors.Join(returnErr, boundary.root.RemoveAll(stage)) - } - }() - stageInfo, err := boundary.root.Lstat(stage) - if err != nil || !stageInfo.IsDir() || stageInfo.Mode().Perm() != 0o700 { - return errors.New("harness evidence stage identity is invalid") - } - stageRoot, err := boundary.root.OpenRoot(stage) - if err != nil { - return fmt.Errorf("open harness evidence stage: %w", err) - } - defer stageRoot.Close() - openedStage, err := stageRoot.Stat(".") - if err != nil || !os.SameFile(stageInfo, openedStage) { - return errors.New("harness evidence stage changed during secure open") - } - if err := inspectHarnessEvidenceArchive(raw, stageRoot); err != nil { - return err - } - if hooks.beforePublish != nil { - hooks.beforePublish() - } - if err := boundary.revalidate(); err != nil { - return err - } - if _, err := boundary.root.Lstat(boundary.destination); err == nil || !os.IsNotExist(err) { - return errors.New("harness evidence destination appeared before publication") - } - if err := boundary.root.Rename(stage, boundary.destination); err != nil { - return fmt.Errorf("publish staged harness evidence: %w", err) - } - published, err := boundary.root.Lstat(boundary.destination) - if err != nil || !os.SameFile(stageInfo, published) { - return errors.New("published harness evidence identity differs from the staged directory") - } - stage = "" - return nil -} - -func openHarnessTransactionBoundary(destination string, afterInspect func()) (*harnessTransactionBoundary, error) { - destination, err := filepath.Abs(destination) - if err != nil || filepath.Clean(destination) != destination { - return nil, errors.New("harness evidence destination is invalid") - } - parentPath := filepath.Dir(destination) - destinationName := filepath.Base(destination) - if destinationName == "." || destinationName == ".." || strings.ContainsAny(destinationName, `/\\`) { - return nil, errors.New("harness evidence destination name is invalid") - } - parentInfo, err := os.Lstat(parentPath) - if err != nil || !privateOwnedDirectory(parentInfo) { - return nil, errors.New("harness evidence destination parent is not an owned non-writable real directory") - } - if afterInspect != nil { - afterInspect() - } - root, err := os.OpenRoot(parentPath) - if err != nil { - return nil, fmt.Errorf("open harness evidence transaction root: %w", err) - } - opened, err := root.Stat(".") - if err != nil || !os.SameFile(parentInfo, opened) { - root.Close() - return nil, errors.New("harness evidence transaction root changed during secure open") - } - boundary := &harnessTransactionBoundary{ - path: parentPath, destination: destinationName, identity: parentInfo, root: root, - } - if err := boundary.revalidate(); err != nil { - root.Close() - return nil, err - } - if _, err := root.Lstat(destinationName); err == nil || !os.IsNotExist(err) { - root.Close() - return nil, errors.New("harness evidence destination must not exist") - } - return boundary, nil -} - -func privateOwnedDirectory(info os.FileInfo) bool { - if info == nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm()&0o022 != 0 { - return false - } - stat, ok := info.Sys().(*syscall.Stat_t) - return ok && int(stat.Uid) == os.Geteuid() -} - -func (boundary *harnessTransactionBoundary) revalidate() error { - opened, openedErr := boundary.root.Stat(".") - current, currentErr := os.Lstat(boundary.path) - if openedErr != nil || currentErr != nil || !privateOwnedDirectory(current) || - !os.SameFile(boundary.identity, opened) || !os.SameFile(boundary.identity, current) { - return errors.New("harness evidence transaction root identity changed") - } - return nil -} - -func makeHarnessEvidenceStage(root *os.Root) (string, error) { - for attempt := 0; attempt < 128; attempt++ { - var random [16]byte - if _, err := rand.Read(random[:]); err != nil { - return "", err - } - name := ".gds-harness-evidence-" + hex.EncodeToString(random[:]) - if err := root.Mkdir(name, 0o700); err == nil { - return name, nil - } else if !os.IsExist(err) { - return "", err - } - } - return "", errors.New("cannot allocate a unique harness evidence stage") -} - -func inspectHarnessEvidenceArchive(raw []byte, stage *os.Root) error { - buffered := bufio.NewReader(bytes.NewReader(raw)) - compressed, err := gzip.NewReader(buffered) - if err != nil { - return fmt.Errorf("open harness evidence gzip stream: %w", err) - } - compressed.Multistream(false) - reader := tar.NewReader(compressed) - seen := make(map[string]struct{}, len(harnessEvidenceMembers)) - total := int64(0) - for { - header, err := reader.Next() - if errors.Is(err, io.EOF) { - break - } - if err != nil { - return fmt.Errorf("read harness evidence tar header: %w", err) - } - name := header.Name - if name == "" || strings.Contains(name, "\\") || path.IsAbs(name) || path.Clean(name) != name || - strings.HasPrefix(name, "../") || !slices.Contains(harnessEvidenceMembers, name) { - return fmt.Errorf("harness evidence archive contains unexpected path %q", name) - } - if _, duplicate := seen[name]; duplicate { - return fmt.Errorf("harness evidence archive repeats path %q", name) - } - if header.Typeflag != tar.TypeReg && header.Typeflag != tar.TypeRegA { - return fmt.Errorf("harness evidence archive member %q is not a regular file", name) - } - if header.Linkname != "" { - return fmt.Errorf("harness evidence archive member %q declares a link target", name) - } - if header.Size < 2 || header.Size > maxHarnessEvidenceMemberSize || total > maxHarnessEvidenceTotalSize-header.Size { - return fmt.Errorf("harness evidence archive member %q exceeds size limits", name) - } - total += header.Size - if stage == nil { - if _, err := io.CopyN(io.Discard, reader, header.Size); err != nil { - return fmt.Errorf("validate harness evidence member %q: %w", name, err) - } - } else { - output, err := stage.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL|unix.O_NOFOLLOW, 0o600) - if err != nil { - return fmt.Errorf("create staged harness evidence member %q: %w", name, err) - } - written, copyErr := io.CopyN(output, reader, header.Size) - closeErr := output.Close() - if copyErr != nil || written != header.Size || closeErr != nil { - return fmt.Errorf("materialize harness evidence member %q: %w", name, errors.Join(copyErr, closeErr)) - } - } - seen[name] = struct{}{} - } - if len(seen) != len(harnessEvidenceMembers) { - return fmt.Errorf("harness evidence archive contains %d exact members, want %d", len(seen), len(harnessEvidenceMembers)) - } - if _, err := io.Copy(io.Discard, compressed); err != nil { - return fmt.Errorf("finish harness evidence gzip stream: %w", err) - } - if err := compressed.Close(); err != nil { - return fmt.Errorf("verify harness evidence gzip stream: %w", err) - } - if _, err := buffered.ReadByte(); !errors.Is(err, io.EOF) { - return errors.New("harness evidence archive contains trailing compressed data") - } - return nil -} diff --git a/core/releasebuilder/harness_archive_test.go b/core/releasebuilder/harness_archive_test.go deleted file mode 100644 index 5003df3..0000000 --- a/core/releasebuilder/harness_archive_test.go +++ /dev/null @@ -1,428 +0,0 @@ -package releasebuilder - -import ( - "archive/tar" - "bytes" - "compress/gzip" - "fmt" - "os" - "path/filepath" - "slices" - "strings" - "syscall" - "testing" -) - -type archiveMember struct { - name string - typeflag byte - linkname string - content []byte - size int64 -} - -// privateTempDir returns a temporary directory the harness evidence boundary -// will accept as a destination parent under any process umask. -// -// t.TempDir creates its leaf with os.Mkdir(dir, 0777), so the umask decides the -// result: 0755 under the 0022 CI runners use, 0775 under the 0002 that Debian -// and Ubuntu set by default. privateOwnedDirectory requires Perm()&0o022 == 0, -// so the same test passes on one developer's machine and fails on another's for -// a reason that has nothing to do with the code under test. Any test that needs -// its parent accepted must state the mode instead of inheriting it. -func privateTempDir(t *testing.T) string { - t.Helper() - directory := t.TempDir() - if err := os.Chmod(directory, 0o700); err != nil { - t.Fatal(err) - } - return directory -} - -func TestMaterializeHarnessEvidenceArchive(t *testing.T) { - root := privateTempDir(t) - archivePath := filepath.Join(root, "evidence.tar.gz") - writeHarnessArchive(t, archivePath, validHarnessArchiveMembers()) - destination := filepath.Join(root, "records") - if err := MaterializeHarnessEvidenceArchive(archivePath, destination); err != nil { - t.Fatal(err) - } - info, err := os.Stat(destination) - if err != nil || info.Mode().Perm() != 0o700 { - t.Fatalf("destination mode = %v, err = %v", info.Mode().Perm(), err) - } - for _, name := range harnessEvidenceMembers { - info, err := os.Stat(filepath.Join(destination, name)) - if err != nil || !info.Mode().IsRegular() || info.Mode().Perm() != 0o600 { - t.Fatalf("member %s mode = %v, err = %v", name, info.Mode(), err) - } - } - if err := MaterializeHarnessEvidenceArchive(archivePath, destination); err == nil || - !strings.Contains(err.Error(), "must not exist") { - t.Fatalf("existing destination error = %v", err) - } -} - -func TestHarnessEvidenceArchiveMembersAreExactlyActiveSevenPlusManifest(t *testing.T) { - want := []string{ - "antigravity.json", "claude-code.json", "codex.json", - "cursor.json", "grok-build.json", "manifest.json", - "opencode.json", "pi.json", - } - if !slices.Equal(harnessEvidenceMembers, want) { - t.Fatalf("archive members = %v, want exact active-seven set %v", harnessEvidenceMembers, want) - } -} - -func TestHarnessEvidenceTransactionAcceptsPrivateDirectoriesUnderPlatformTemp(t *testing.T) { - root, err := os.MkdirTemp("", "gds-harness-boundary-") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(root) - archivePath := filepath.Join(root, "evidence.tar.gz") - writeHarnessArchive(t, archivePath, validHarnessArchiveMembers()) - if err := MaterializeHarnessEvidenceArchive(archivePath, filepath.Join(root, "records")); err != nil { - t.Fatal(err) - } -} - -// The acceptance path must depend on the destination parent's actual mode and -// on nothing ambient. Issue #171 closed this class in core/materialize and -// core/app but left this package, where it stayed invisible because CI runners -// happen to use 0022. Asserting across the masks that matter is what keeps the -// three fixes from becoming four. -// -// This test mutates process-global state and must stay serial: Go resumes -// t.Parallel tests only after the serial ones in their parent finish, and this -// package has parallel tests elsewhere. Do not add t.Parallel here. -func TestMaterializeHarnessEvidenceArchiveIsIndependentOfProcessUmask(t *testing.T) { - for _, mask := range []int{0o000, 0o002, 0o022, 0o077} { - t.Run(fmt.Sprintf("umask-%04o", mask), func(t *testing.T) { - previous := syscall.Umask(mask) - defer syscall.Umask(previous) - root := privateTempDir(t) - archivePath := filepath.Join(root, "evidence.tar.gz") - writeHarnessArchive(t, archivePath, validHarnessArchiveMembers()) - destination := filepath.Join(root, "records") - if err := MaterializeHarnessEvidenceArchive(archivePath, destination); err != nil { - t.Fatal(err) - } - info, err := os.Stat(destination) - if err != nil || info.Mode().Perm() != 0o700 { - t.Fatalf("destination mode = %v, err = %v", info.Mode().Perm(), err) - } - for _, name := range harnessEvidenceMembers { - member, err := os.Stat(filepath.Join(destination, name)) - if err != nil || !member.Mode().IsRegular() || member.Mode().Perm() != 0o600 { - t.Fatalf("member %s mode = %v, err = %v", name, member.Mode(), err) - } - } - }) - } -} - -// A parent that only passes because the ambient umask cleared its group and -// other write bits is the exact shape of the defect above. Prove the guard -// rejects it, so a future test cannot reintroduce t.TempDir as an accepted -// parent and pass on a 0022 machine. -func TestHarnessEvidenceTransactionRejectsGroupWritableParent(t *testing.T) { - root := t.TempDir() - parent := filepath.Join(root, "group-writable") - if err := os.Mkdir(parent, 0o700); err != nil { - t.Fatal(err) - } - if err := os.Chmod(parent, 0o775); err != nil { - t.Fatal(err) - } - archivePath := filepath.Join(root, "evidence.tar.gz") - writeHarnessArchive(t, archivePath, validHarnessArchiveMembers()) - destination := filepath.Join(parent, "records") - if err := MaterializeHarnessEvidenceArchive(archivePath, destination); err == nil { - t.Fatal("group-writable destination parent was accepted") - } - assertPathAbsent(t, destination) -} - -func TestHarnessEvidenceTransactionRejectsUnsafeParents(t *testing.T) { - t.Run("direct-world-writable", func(t *testing.T) { - root := t.TempDir() - shared := filepath.Join(root, "shared") - if err := os.Mkdir(shared, 0o700); err != nil { - t.Fatal(err) - } - if err := os.Chmod(shared, 0o777); err != nil { - t.Fatal(err) - } - archivePath := filepath.Join(root, "evidence.tar.gz") - writeHarnessArchive(t, archivePath, validHarnessArchiveMembers()) - if err := MaterializeHarnessEvidenceArchive(archivePath, filepath.Join(shared, "records")); err == nil { - t.Fatal("world-writable destination parent was accepted") - } - }) - t.Run("symlink", func(t *testing.T) { - root := t.TempDir() - realParent := filepath.Join(root, "real") - if err := os.Mkdir(realParent, 0o700); err != nil { - t.Fatal(err) - } - linkedParent := filepath.Join(root, "linked") - if err := os.Symlink(realParent, linkedParent); err != nil { - t.Fatal(err) - } - archivePath := filepath.Join(root, "evidence.tar.gz") - writeHarnessArchive(t, archivePath, validHarnessArchiveMembers()) - if err := MaterializeHarnessEvidenceArchive(archivePath, filepath.Join(linkedParent, "records")); err == nil { - t.Fatal("symlink destination parent was accepted") - } - }) - t.Run("wrong-owner", func(t *testing.T) { - info, err := os.Stat(t.TempDir()) - if err != nil { - t.Fatal(err) - } - if privateOwnedDirectory(fileInfoWithStat{FileInfo: info, stat: &syscall.Stat_t{Uid: uint32(os.Geteuid() + 1)}}) { - t.Fatal("directory owned by a different uid was accepted") - } - }) -} - -func TestHarnessEvidenceTransactionRejectsAncestorReplacement(t *testing.T) { - for _, point := range []string{"open", "publish"} { - t.Run(point, func(t *testing.T) { - root := t.TempDir() - parent := filepath.Join(root, "owned") - if err := os.Mkdir(parent, 0o700); err != nil { - t.Fatal(err) - } - archivePath := filepath.Join(root, "evidence.tar.gz") - writeHarnessArchive(t, archivePath, validHarnessArchiveMembers()) - replaced := false - replace := func() { - if replaced { - return - } - replaced = true - if err := os.Rename(parent, parent+"-moved"); err != nil { - t.Fatal(err) - } - if err := os.Mkdir(parent, 0o700); err != nil { - t.Fatal(err) - } - } - hooks := harnessArchiveHooks{} - if point == "open" { - hooks.afterParentInspect = replace - } else { - hooks.beforePublish = replace - } - err := materializeHarnessEvidenceArchive(archivePath, filepath.Join(parent, "records"), hooks) - if err == nil { - t.Fatal("replaced transaction ancestor was accepted") - } - if _, err := os.Lstat(filepath.Join(parent, "records")); !os.IsNotExist(err) { - t.Fatalf("replacement received published records: %v", err) - } - }) - } -} - -type fileInfoWithStat struct { - os.FileInfo - stat *syscall.Stat_t -} - -func (info fileInfoWithStat) Sys() any { return info.stat } - -func TestHarnessEvidenceArchiveRejectsUnsafeMembersBeforeMaterialization(t *testing.T) { - types := []struct { - name string - typeflag byte - linkname string - }{ - {"symlink", tar.TypeSymlink, "../outside"}, - {"hardlink", tar.TypeLink, "manifest.json"}, - {"character-device", tar.TypeChar, ""}, - {"block-device", tar.TypeBlock, ""}, - {"fifo", tar.TypeFifo, ""}, - {"socket", byte('s'), ""}, - {"regular-with-link-target", tar.TypeReg, "../outside"}, - } - for _, candidate := range types { - t.Run(candidate.name, func(t *testing.T) { - members := validHarnessArchiveMembers() - members[0].typeflag = candidate.typeflag - members[0].linkname = candidate.linkname - assertHarnessArchiveRejectedWithoutDestination(t, members) - }) - } -} - -func TestHarnessEvidenceArchiveRejectsPathAndSetViolations(t *testing.T) { - tests := map[string]func([]archiveMember) []archiveMember{ - "duplicate": func(m []archiveMember) []archiveMember { return append(m, m[0]) }, - "extra": func(m []archiveMember) []archiveMember { - return append(m, archiveMember{name: "extra.json", typeflag: tar.TypeReg, content: []byte("{}")}) - }, - "missing": func(m []archiveMember) []archiveMember { return m[:len(m)-1] }, - "absolute": func(m []archiveMember) []archiveMember { m[0].name = "/manifest.json"; return m }, - "traversal": func(m []archiveMember) []archiveMember { m[0].name = "../manifest.json"; return m }, - "non-normalized": func(m []archiveMember) []archiveMember { m[0].name = "./claude-code.json"; return m }, - "backslash-alias": func(m []archiveMember) []archiveMember { - m[0].name = "folder\\claude-code.json" - return m - }, - } - for name, mutate := range tests { - t.Run(name, func(t *testing.T) { - assertHarnessArchiveRejectedWithoutDestination(t, mutate(validHarnessArchiveMembers())) - }) - } -} - -func TestHarnessEvidenceArchiveRejectsSizeAndStreamViolations(t *testing.T) { - t.Run("archive-symlink", func(t *testing.T) { - root := t.TempDir() - realPath := filepath.Join(root, "real.tar.gz") - writeHarnessArchive(t, realPath, validHarnessArchiveMembers()) - archivePath := filepath.Join(root, "evidence.tar.gz") - if err := os.Symlink(realPath, archivePath); err != nil { - t.Fatal(err) - } - if err := MaterializeHarnessEvidenceArchive(archivePath, filepath.Join(root, "records")); err == nil { - t.Fatal("archive symlink was accepted") - } - }) - t.Run("compressed-archive", func(t *testing.T) { - root := t.TempDir() - archivePath := filepath.Join(root, "evidence.tar.gz") - file, err := os.OpenFile(archivePath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) - if err != nil { - t.Fatal(err) - } - if err := file.Truncate(maxHarnessEvidenceArchiveSize + 1); err != nil { - t.Fatal(err) - } - if err := file.Close(); err != nil { - t.Fatal(err) - } - destination := filepath.Join(root, "records") - if err := MaterializeHarnessEvidenceArchive(archivePath, destination); err == nil { - t.Fatal("oversized compressed archive was accepted") - } - assertPathAbsent(t, destination) - }) - t.Run("per-member", func(t *testing.T) { - members := validHarnessArchiveMembers() - members[0].size = maxHarnessEvidenceMemberSize + 1 - assertHarnessArchiveRejectedWithoutDestination(t, members) - }) - t.Run("total", func(t *testing.T) { - members := validHarnessArchiveMembers() - for index := range members { - members[index].content = bytes.Repeat([]byte{'x'}, maxHarnessEvidenceMemberSize) - } - assertHarnessArchiveRejectedWithoutDestination(t, members) - }) - t.Run("truncated", func(t *testing.T) { - root := t.TempDir() - archivePath := filepath.Join(root, "evidence.tar.gz") - writeHarnessArchive(t, archivePath, validHarnessArchiveMembers()) - raw, err := os.ReadFile(archivePath) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(archivePath, raw[:len(raw)-8], 0o600); err != nil { - t.Fatal(err) - } - destination := filepath.Join(root, "records") - if err := MaterializeHarnessEvidenceArchive(archivePath, destination); err == nil { - t.Fatal("truncated archive was accepted") - } - assertPathAbsent(t, destination) - }) - t.Run("trailing-stream", func(t *testing.T) { - root := t.TempDir() - archivePath := filepath.Join(root, "evidence.tar.gz") - writeHarnessArchive(t, archivePath, validHarnessArchiveMembers()) - file, err := os.OpenFile(archivePath, os.O_APPEND|os.O_WRONLY, 0) - if err != nil { - t.Fatal(err) - } - _, writeErr := file.Write([]byte("trailing")) - closeErr := file.Close() - if writeErr != nil || closeErr != nil { - t.Fatalf("append trailing data: %v / %v", writeErr, closeErr) - } - destination := filepath.Join(root, "records") - if err := MaterializeHarnessEvidenceArchive(archivePath, destination); err == nil { - t.Fatal("archive with trailing data was accepted") - } - assertPathAbsent(t, destination) - }) -} - -func assertHarnessArchiveRejectedWithoutDestination(t *testing.T, members []archiveMember) { - t.Helper() - root := t.TempDir() - archivePath := filepath.Join(root, "evidence.tar.gz") - writeHarnessArchive(t, archivePath, members) - destination := filepath.Join(root, "records") - if err := MaterializeHarnessEvidenceArchive(archivePath, destination); err == nil { - t.Fatal("unsafe archive was accepted") - } - assertPathAbsent(t, destination) -} - -func assertPathAbsent(t *testing.T, path string) { - t.Helper() - if _, err := os.Lstat(path); !os.IsNotExist(err) { - t.Fatalf("path %s exists after rejection: %v", path, err) - } -} - -func validHarnessArchiveMembers() []archiveMember { - members := make([]archiveMember, 0, len(harnessEvidenceMembers)) - for _, name := range harnessEvidenceMembers { - members = append(members, archiveMember{name: name, typeflag: tar.TypeReg, content: []byte("{}")}) - } - return members -} - -func writeHarnessArchive(t *testing.T, target string, members []archiveMember) { - t.Helper() - file, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) - if err != nil { - t.Fatal(err) - } - gzipWriter := gzip.NewWriter(file) - tarWriter := tar.NewWriter(gzipWriter) - for _, member := range members { - size := member.size - if size == 0 && (member.typeflag == tar.TypeReg || member.typeflag == tar.TypeRegA) { - size = int64(len(member.content)) - } - header := &tar.Header{Name: member.name, Mode: 0o600, Size: size, Typeflag: member.typeflag, Linkname: member.linkname} - if err := tarWriter.WriteHeader(header); err != nil { - t.Fatal(err) - } - if member.typeflag == tar.TypeReg || member.typeflag == tar.TypeRegA { - content := member.content - if int64(len(content)) < size { - content = append(content, bytes.Repeat([]byte{'x'}, int(size)-len(content))...) - } - if _, err := tarWriter.Write(content[:size]); err != nil { - t.Fatal(err) - } - } - } - if err := tarWriter.Close(); err != nil { - t.Fatal(err) - } - if err := gzipWriter.Close(); err != nil { - t.Fatal(err) - } - if err := file.Close(); err != nil { - t.Fatal(err) - } -} diff --git a/core/releasebuilder/output.go b/core/releasebuilder/output.go index 091d790..80e8d82 100644 --- a/core/releasebuilder/output.go +++ b/core/releasebuilder/output.go @@ -278,6 +278,7 @@ func VerifyDirectory(directory string, schemas *validation.Set) (DirectoryVerifi SchemaVersion: domain.SchemaVersion, Status: "verified", Version: envelope.BundleVersion, ReleaseSequence: envelope.ReleaseSequence, SourceCommit: envelope.SourceCommit, SourceRef: envelope.SourceRef, + TriggerRef: envelope.TriggerRef, ArtifactName: artifactName, ArtifactDigest: envelope.ArtifactDigest, ManifestDigest: envelope.ManifestDigest, SBOMDigest: digestBytes(contents[releaseSBOMName]), Reproducible: true, Files: files, diff --git a/core/releasebuilder/types.go b/core/releasebuilder/types.go index 95e2509..3c830da 100644 --- a/core/releasebuilder/types.go +++ b/core/releasebuilder/types.go @@ -23,7 +23,10 @@ type Request struct { ReleaseSequence int MinimumCLIVersion string SourceRef string - GoBinary string + // TriggerRef is the ref the release run was triggered on; the provenance + // attestation binds it rather than the release tag created in the same run. + TriggerRef string + GoBinary string } type Source struct { @@ -62,6 +65,7 @@ type DirectoryVerification struct { ReleaseSequence int `json:"release_sequence"` SourceCommit string `json:"source_commit"` SourceRef string `json:"source_ref"` + TriggerRef string `json:"trigger_ref,omitempty"` ArtifactName string `json:"artifact_name"` ArtifactDigest string `json:"artifact_digest"` ManifestDigest string `json:"manifest_digest"` diff --git a/core/releasebuilder/workflow_contract_test.go b/core/releasebuilder/workflow_contract_test.go index 2227893..b837060 100644 --- a/core/releasebuilder/workflow_contract_test.go +++ b/core/releasebuilder/workflow_contract_test.go @@ -95,6 +95,7 @@ func TestHostedReleaseWorkflowUsesOutputOutsideSourceRoot(t *testing.T) { `${{ runner.temp }}/gds-release-output/release-evidence`, `RELEASE_SEQUENCE: ${{ needs.resolve.outputs.sequence }}`, `--source-ref "refs/tags/$RELEASE_TAG"`, + `--trigger-ref "$GITHUB_REF"`, `ref: ${{ needs.resolve.outputs.tag }}`, `--latest`, `name: Record failed release evidence`, diff --git a/core/releaseconsumer/verify.go b/core/releaseconsumer/verify.go index 850e108..158e2cb 100644 --- a/core/releaseconsumer/verify.go +++ b/core/releaseconsumer/verify.go @@ -110,10 +110,17 @@ func (verifier Verifier) Verify( "GDS_RELEASE_TRUSTED_ROOT_NOT_PROVEN", "Offline trusted root does not match the independent local trust policy.", )} } + // The attestation binds the ref the release run was triggered on; when the + // release tag is created inside that same run the envelope records it + // separately and it is the ref the verifier must demand. + attestationRef := directory.SourceRef + if directory.TriggerRef != "" { + attestationRef = directory.TriggerRef + } evidence, err := verifier.Attestations.Verify(ctx, AttestationRequest{ ReleaseDirectory: request.ReleaseDirectory, EvidenceDirectory: request.EvidenceDirectory, ArtifactName: directory.ArtifactName, ArtifactDigest: directory.ArtifactDigest, - SourceCommit: directory.SourceCommit, SourceRef: directory.SourceRef, + SourceCommit: directory.SourceCommit, SourceRef: attestationRef, SourceOwner: trust.Source.Owner, SourceRepository: trust.Source.Repository, Workflow: trust.Source.AllowedWorkflows[0], }) diff --git a/docs/contracts/bundle-release-v1.md b/docs/contracts/bundle-release-v1.md index fba27f7..dd279cb 100644 --- a/docs/contracts/bundle-release-v1.md +++ b/docs/contracts/bundle-release-v1.md @@ -101,6 +101,10 @@ from full portable-bundle completeness. ADR 0016 defines the non-self-referential layers. The detached envelope binds artifact digest, manifest digest, version, monotonic sequence, source commit, exact source ref, executable count, and expected attestation identity. +When the release tag is created inside the same run that builds the artifact, +the attestation binds the run's trigger ref rather than the tag; the envelope +records that ref as `trigger_ref` and the expected attestation identity digest +is computed over it. The six-file directory verifier rejects any missing, extra, symlinked, renamed, oversized, or digest-mismatched member. diff --git a/go.mod b/go.mod index 7c21f0b..2e22bb2 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,6 @@ require ( github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 github.com/spf13/cobra v1.10.2 go.yaml.in/yaml/v4 v4.0.0-rc.6 - golang.org/x/sys v0.48.0 modernc.org/sqlite v1.58.0 ) @@ -20,6 +19,7 @@ require ( github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/spf13/pflag v1.0.10 // indirect + golang.org/x/sys v0.48.0 // indirect golang.org/x/text v0.42.0 // indirect modernc.org/libc v1.75.7 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/schemas/v1/release-envelope.schema.json b/schemas/v1/release-envelope.schema.json index 521e953..976bd5a 100644 --- a/schemas/v1/release-envelope.schema.json +++ b/schemas/v1/release-envelope.schema.json @@ -35,6 +35,9 @@ "source_ref": { "$ref": "common.schema.json#/$defs/sourceRef" }, + "trigger_ref": { + "$ref": "common.schema.json#/$defs/sourceRef" + }, "executable_files": { "type": "integer", "minimum": 0 diff --git a/schemas/v1/release-failure-envelope.schema.json b/schemas/v1/release-failure-envelope.schema.json index 06a35ea..03b120f 100644 --- a/schemas/v1/release-failure-envelope.schema.json +++ b/schemas/v1/release-failure-envelope.schema.json @@ -23,6 +23,7 @@ "channel": {"enum": ["canary", "stable", "frozen"]}, "source_commit": {"$ref": "common.schema.json#/$defs/sourceCommit"}, "source_ref": {"$ref": "common.schema.json#/$defs/sourceRef"}, + "trigger_ref": {"$ref": "common.schema.json#/$defs/sourceRef"}, "workflow_run_id": {"type": "integer", "minimum": 1}, "failed_jobs": { "type": "array",