From e391de792cd9381d5f9b59fe55563d23081e3852 Mon Sep 17 00:00:00 2001 From: Danil Silantyev Date: Sun, 20 Sep 2026 01:06:50 +0500 Subject: [PATCH 1/2] fix(release): bind attestations to the workflow trigger ref The automatic pipeline creates the release tag inside the same run that builds the artifact, so GitHub provenance binds the push trigger ref (refs/heads/main), not the tag. The envelope now records both refs: source_ref stays the exact release boundary and trigger_ref carries the ref the signer actually recorded. The expected attestation identity digest and all verifiers use trigger_ref when present and fall back to source_ref for legacy envelopes. Also drops the harness-evidence archive extraction flags left over from the removed evidence-input flow. --- .github/workflows/release-bundle.yml | 6 +- core/bundle/build.go | 14 +- core/bundle/types.go | 17 +- core/bundle/verify.go | 9 +- core/cmd/gds-release-builder/main.go | 15 +- core/releasebuilder/builder.go | 1 + core/releasebuilder/harness_archive.go | 271 ----------- core/releasebuilder/harness_archive_test.go | 428 ------------------ core/releasebuilder/output.go | 1 + core/releasebuilder/types.go | 6 +- core/releasebuilder/workflow_contract_test.go | 1 + core/releaseconsumer/verify.go | 9 +- docs/contracts/bundle-release-v1.md | 4 + go.mod | 2 +- schemas/v1/release-envelope.schema.json | 3 + .../v1/release-failure-envelope.schema.json | 1 + 16 files changed, 61 insertions(+), 727 deletions(-) delete mode 100644 core/releasebuilder/harness_archive.go delete mode 100644 core/releasebuilder/harness_archive_test.go 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", From e30c9b146b29223b47018baef14e1634137bad80 Mon Sep 17 00:00:00 2001 From: Danil Silantyev Date: Sun, 20 Sep 2026 01:09:25 +0500 Subject: [PATCH 2/2] chore: restamp development lock after trigger_ref schema change --- .gds/bundle.lock.yaml | 10 +++++----- .github/workflows/gds-ci.yml | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) 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