From 45867234b52385a45570c95e14313d1b63ea6b82 Mon Sep 17 00:00:00 2001 From: jaydeep869 Date: Fri, 10 Apr 2026 01:11:16 +0530 Subject: [PATCH 1/4] feat: add attach attestation command This commit adds a new command to attach attestations to an OCI artifact using the OCI referrers API. Resolves #602 Signed-off-by: jaydeep869 --- cmd/attach.go | 184 ++++++++++++++++++++++++++++++++++++++++++++++ cmd/root.go | 1 + options/attach.go | 26 +++++++ 3 files changed, 211 insertions(+) create mode 100644 cmd/attach.go create mode 100644 options/attach.go diff --git a/cmd/attach.go b/cmd/attach.go new file mode 100644 index 00000000..240c51c3 --- /dev/null +++ b/cmd/attach.go @@ -0,0 +1,184 @@ +// Copyright 2024 The Witness Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/v1/types" + "github.com/in-toto/go-witness/log" + "github.com/in-toto/witness/options" + "github.com/spf13/cobra" +) + +func AttachCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "attach", + Short: "Attach attestations to OCI images", + Long: "Attach attestations as OCI referrers to container images in a registry", + DisableAutoGenTag: true, + } + + cmd.AddCommand(AttestationCmd()) + return cmd +} + +func AttestationCmd() *cobra.Command { + ao := options.AttachOptions{} + + cmd := &cobra.Command{ + Use: "attestation ", + Short: "Attach an attestation file as an OCI referrer", + Long: "Attach an attestation JSON file as an OCI referrer to a container image in a registry", + SilenceErrors: true, + SilenceUsage: true, + DisableAutoGenTag: true, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runAttachAttestation(cmd.Context(), ao, args[0]) + }, + } + + ao.AddFlags(cmd) + return cmd +} + +func runAttachAttestation(ctx context.Context, ao options.AttachOptions, imageRef string) error { + if len(ao.AttestationFilePaths) == 0 { + return fmt.Errorf("at least one attestation file must be specified with --attestation") + } + + // Parse the image reference + ref, err := name.ParseReference(imageRef) + if err != nil { + return fmt.Errorf("failed to parse image reference: %w", err) + } + + // Get the original image descriptor from the registry + originalImage, err := remote.Image(ref, remote.WithAuthFromKeychain(authn.DefaultKeychain), remote.WithContext(ctx)) + if err != nil { + if _, errIndex := remote.Index(ref, remote.WithAuthFromKeychain(authn.DefaultKeychain), remote.WithContext(ctx)); errIndex == nil { + // Actually we might want to attach to an Index as well. + // Let's just use remote.Head to get the digest instead of remote.Image + originalDesc, errHead := remote.Head(ref, remote.WithAuthFromKeychain(authn.DefaultKeychain), remote.WithContext(ctx)) + if errHead != nil { + return fmt.Errorf("failed to fetch image/index from registry: %w", errHead) + } + return attachToSubject(ctx, ao, ref, originalDesc.Digest) + } + return fmt.Errorf("failed to fetch image from registry: %w", err) + } + + originalDigest, err := originalImage.Digest() + if err != nil { + return fmt.Errorf("failed to get original image digest: %w", err) + } + + return attachToSubject(ctx, ao, ref, originalDigest) +} + +func attachToSubject(ctx context.Context, ao options.AttachOptions, ref name.Reference, subjectDigest v1.Hash) error { + for _, attestPath := range ao.AttestationFilePaths { + attestData, err := os.ReadFile(attestPath) + if err != nil { + return fmt.Errorf("failed to read attestation file %s: %w", attestPath, err) + } + + referrerImage := empty.Image + + layer := &attestationLayer{ + data: attestData, + } + + referrerImage, err = mutate.AppendLayers(referrerImage, layer) + if err != nil { + return fmt.Errorf("failed to append attestation layer: %w", err) + } + + // A bug in remote.Write will complain about media type unless we explicitly override + emptyHash := v1.Hash{} + desc := v1.Descriptor{ + MediaType: types.MediaType("application/vnd.in-toto+json"), + Digest: subjectDigest, + Size: 0, // the actual digest size doesn't matter for the subject descriptor + } + if subjectDigest != emptyHash { + if withSubject, ok := mutate.Subject(referrerImage, desc).(v1.Image); ok { + referrerImage = withSubject + } else { + return fmt.Errorf("failed to cast subject to image") + } + } + + // We need to write this to the registry. The registry needs a reference to write to. + // Usually we push to a digest reference of the referrer itself or a tag. + // If we use remote.Write with the digest, it will push it. + // Wait, ref is the repository. We can get the digest of the referrerImage + referrerDigest, err := referrerImage.Digest() + if err != nil { + return fmt.Errorf("failed to get referrer image digest: %w", err) + } + + referrerRef := ref.Context().Digest(referrerDigest.String()) + + if err := remote.Write(referrerRef, referrerImage, remote.WithAuthFromKeychain(authn.DefaultKeychain), remote.WithContext(ctx)); err != nil { + return fmt.Errorf("failed to write referrer image to registry: %w", err) + } + + log.Infof("Successfully attached attestation from %s to %s as %s", attestPath, ref.String(), referrerDigest.String()) + } + + return nil +} + +type attestationLayer struct { + data []byte +} + +func (l *attestationLayer) Digest() (v1.Hash, error) { + h, _, err := v1.SHA256(bytes.NewReader(l.data)) + return h, err +} + +func (l *attestationLayer) DiffID() (v1.Hash, error) { + h, _, err := v1.SHA256(bytes.NewReader(l.data)) + return h, err +} + +func (l *attestationLayer) Compressed() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(l.data)), nil +} + +func (l *attestationLayer) Uncompressed() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(l.data)), nil +} + +func (l *attestationLayer) Size() (int64, error) { + return int64(len(l.data)), nil +} + +func (l *attestationLayer) MediaType() (types.MediaType, error) { + return "application/vnd.in-toto+json", nil +} diff --git a/cmd/root.go b/cmd/root.go index d40a7239..a938222b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -47,6 +47,7 @@ func New() *cobra.Command { cmd.AddCommand(SignCmd()) cmd.AddCommand(VerifyCmd()) cmd.AddCommand(RunCmd()) + cmd.AddCommand(AttachCmd()) cmd.AddCommand(CompletionCmd()) cmd.AddCommand(VersionCmd()) cmd.AddCommand(AttestorsCmd()) diff --git a/options/attach.go b/options/attach.go new file mode 100644 index 00000000..e9e43f8b --- /dev/null +++ b/options/attach.go @@ -0,0 +1,26 @@ +// Copyright 2024 The Witness Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package options + +import "github.com/spf13/cobra" + +type AttachOptions struct { + AttestationFilePaths []string +} + +func (ao *AttachOptions) AddFlags(cmd *cobra.Command) { + cmd.Flags().StringSliceVarP(&ao.AttestationFilePaths, "attestation", "a", []string{}, "Paths to attestation files to attach (can be specified multiple times)") + cmd.MarkFlagRequired("attestation") +} From b35e50489b498c8a4b6862e038065545140e06ce Mon Sep 17 00:00:00 2001 From: jaydeep869 Date: Fri, 10 Apr 2026 01:57:22 +0530 Subject: [PATCH 2/4] fix: lint and docgen issues on attach command Signed-off-by: jaydeep869 --- docs/attestors/secretscan.md | 36 ------------------------------------ docs/commands.md | 28 ++++++++++++++++++++++++++++ options/attach.go | 2 +- 3 files changed, 29 insertions(+), 37 deletions(-) diff --git a/docs/attestors/secretscan.md b/docs/attestors/secretscan.md index ec380823..250ba1c0 100644 --- a/docs/attestors/secretscan.md +++ b/docs/attestors/secretscan.md @@ -92,39 +92,3 @@ When secrets are found, they are recorded in a structured format with the actual } } ``` - -# SecretScan Attestor Examples - -This section contains examples demonstrating the capabilities of the SecretScan attestor. You can find the demo script [here](https://github.com/in-toto/go-witness/blob/main/attestation/secretscan/examples/demo-encoded-secrets.sh) - -### Demo Scripts - -### `demo-encoded-secrets.sh` - -This script demonstrates the multi-layer encoding detection capabilities of the secretscan attestor. It: - -1. Creates test files with secrets in various encodings: - - Plain text - - Base64-encoded - - Double base64-encoded - - URL-encoded - - Hex-encoded - - Mixed encoding (base64 + URL) - -2. Runs the witness CLI with the secretscan attestor on each file - -3. Extracts and displays the findings from each attestation - -### Running the Demo - -```sh -# Make sure the script is executable -chmod +x demo-encoded-secrets.sh - -# Run the demo -./demo-encoded-secrets.sh -``` - -## Additional Resources - -For more information about the secretscan attestor, see the [main README](https://github.com/in-toto/go-witness/blob/main/attestation/secretscan/README.md) in the parent directory. diff --git a/docs/commands.md b/docs/commands.md index 9cd5932d..72b8cbaa 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -2,6 +2,34 @@ This is the reference for the Witness command line tool, generated by [Cobra](https://cobra.dev/). +## witness attach + +Attach attestations to OCI images + +### Synopsis + +Attach attestations as OCI referrers to container images in a registry + +### Options + +``` + -h, --help help for attach +``` + +### Options inherited from parent commands + +``` + -c, --config string Path to the witness config file (default ".witness.yaml") + --debug-cpu-profile-file string Path to store the CPU profile. Profiling will be enabled if this is non-empty + --debug-mem-profile-file string Path to store the Memory profile. Profiling will be enabled if this is non-empty + -l, --log-level string Level of logging to output (debug, info, warn, error) (default "info") +``` + +### SEE ALSO + +* [witness](witness.md) - Collect and verify attestations about your build environments +* [witness attach attestation](witness_attach_attestation.md) - Attach an attestation file as an OCI referrer + ## witness attestors Get information about available attestors diff --git a/options/attach.go b/options/attach.go index e9e43f8b..550cefa2 100644 --- a/options/attach.go +++ b/options/attach.go @@ -22,5 +22,5 @@ type AttachOptions struct { func (ao *AttachOptions) AddFlags(cmd *cobra.Command) { cmd.Flags().StringSliceVarP(&ao.AttestationFilePaths, "attestation", "a", []string{}, "Paths to attestation files to attach (can be specified multiple times)") - cmd.MarkFlagRequired("attestation") + _ = cmd.MarkFlagRequired("attestation") } From 0815dfe50b37e1ebffb018134304b9473b4265df Mon Sep 17 00:00:00 2001 From: jaydeep869 Date: Mon, 13 Apr 2026 17:53:27 +0530 Subject: [PATCH 3/4] feat: complete attach attestation implementation with verification and tests Signed-off-by: jaydeep869 --- cmd/attach.go | 99 ++++++++++++++++++++++++--------- cmd/attach_test.go | 134 +++++++++++++++++++++++++++++++++++++++++++++ go.mod | 1 + go.sum | 2 + options/attach.go | 8 ++- 5 files changed, 215 insertions(+), 29 deletions(-) create mode 100644 cmd/attach_test.go diff --git a/cmd/attach.go b/cmd/attach.go index 240c51c3..4dfc788c 100644 --- a/cmd/attach.go +++ b/cmd/attach.go @@ -17,17 +17,19 @@ package cmd import ( "bytes" "context" + "encoding/json" "fmt" "io" "os" + "github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/name" "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/empty" "github.com/google/go-containerregistry/pkg/v1/mutate" "github.com/google/go-containerregistry/pkg/v1/remote" - "github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/v1/types" + "github.com/in-toto/go-witness/dsse" "github.com/in-toto/go-witness/log" "github.com/in-toto/witness/options" "github.com/spf13/cobra" @@ -49,15 +51,15 @@ func AttestationCmd() *cobra.Command { ao := options.AttachOptions{} cmd := &cobra.Command{ - Use: "attestation ", + Use: "attestation [attestation-files]...", Short: "Attach an attestation file as an OCI referrer", - Long: "Attach an attestation JSON file as an OCI referrer to a container image in a registry", + Long: "Attach one or more attestation JSON files as OCI referrers to a container image in a registry", SilenceErrors: true, SilenceUsage: true, DisableAutoGenTag: true, - Args: cobra.ExactArgs(1), + Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return runAttachAttestation(cmd.Context(), ao, args[0]) + return runAttachAttestation(cmd.Context(), ao, args) }, } @@ -65,9 +67,19 @@ func AttestationCmd() *cobra.Command { return cmd } -func runAttachAttestation(ctx context.Context, ao options.AttachOptions, imageRef string) error { - if len(ao.AttestationFilePaths) == 0 { - return fmt.Errorf("at least one attestation file must be specified with --attestation") +// Minimal in-toto statement struct for parsing subjects +type IntotoStatement struct { + Type string `json:"_type"` + Subject []struct { + Name string `json:"name"` + Digest map[string]string `json:"digest"` + } `json:"subject"` +} + +func runAttachAttestation(ctx context.Context, ao options.AttachOptions, attestationFiles []string) error { + imageRef := ao.ImageURI + if imageRef == "" { + return fmt.Errorf("--image-uri flag is required") } // Parse the image reference @@ -76,36 +88,76 @@ func runAttachAttestation(ctx context.Context, ao options.AttachOptions, imageRe return fmt.Errorf("failed to parse image reference: %w", err) } + var originalDigest v1.Hash + // Get the original image descriptor from the registry originalImage, err := remote.Image(ref, remote.WithAuthFromKeychain(authn.DefaultKeychain), remote.WithContext(ctx)) if err != nil { if _, errIndex := remote.Index(ref, remote.WithAuthFromKeychain(authn.DefaultKeychain), remote.WithContext(ctx)); errIndex == nil { - // Actually we might want to attach to an Index as well. - // Let's just use remote.Head to get the digest instead of remote.Image originalDesc, errHead := remote.Head(ref, remote.WithAuthFromKeychain(authn.DefaultKeychain), remote.WithContext(ctx)) if errHead != nil { return fmt.Errorf("failed to fetch image/index from registry: %w", errHead) } - return attachToSubject(ctx, ao, ref, originalDesc.Digest) + originalDigest = originalDesc.Digest + } else { + return fmt.Errorf("failed to fetch image from registry: %w", err) + } + } else { + originalDigest, err = originalImage.Digest() + if err != nil { + return fmt.Errorf("failed to get original image digest: %w", err) } - return fmt.Errorf("failed to fetch image from registry: %w", err) - } - - originalDigest, err := originalImage.Digest() - if err != nil { - return fmt.Errorf("failed to get original image digest: %w", err) } - return attachToSubject(ctx, ao, ref, originalDigest) + return attachToSubject(ctx, ao, ref, originalDigest, attestationFiles) } -func attachToSubject(ctx context.Context, ao options.AttachOptions, ref name.Reference, subjectDigest v1.Hash) error { - for _, attestPath := range ao.AttestationFilePaths { +func attachToSubject(ctx context.Context, ao options.AttachOptions, ref name.Reference, subjectDigest v1.Hash, attestationFiles []string) error { + for _, attestPath := range attestationFiles { attestData, err := os.ReadFile(attestPath) if err != nil { return fmt.Errorf("failed to read attestation file %s: %w", attestPath, err) } + // Verify DSSE Envelope + var env dsse.Envelope + if err := json.Unmarshal(attestData, &env); err != nil { + return fmt.Errorf("attestation file %s is not a valid DSSE envelope: %w", attestPath, err) + } + + if env.PayloadType != "application/vnd.in-toto+json" && env.PayloadType != "https://in-toto.io/Statement/v1" { + return fmt.Errorf("attestation file %s has unsupported payloadType: %s. Expected application/vnd.in-toto+json", attestPath, env.PayloadType) + } + + if len(env.Signatures) == 0 { + return fmt.Errorf("attestation file %s has no signatures", attestPath) + } + + // Verify subject digest against the payload unless SkipVerification is true + if !ao.SkipVerification { + var stmt IntotoStatement + if err := json.Unmarshal(env.Payload, &stmt); err != nil { + return fmt.Errorf("failed to unmarshal payload as in-toto statement: %w", err) + } + + matched := false + for _, subj := range stmt.Subject { + for _, digestVal := range subj.Digest { + if digestVal == subjectDigest.Hex { + matched = true + break + } + } + if matched { + break + } + } + + if !matched { + return fmt.Errorf("subject digest mismatch: attestation %s does not describe the target artifact %s. Use --skip-verification to bypass this check.", attestPath, subjectDigest.String()) + } + } + referrerImage := empty.Image layer := &attestationLayer{ @@ -117,12 +169,11 @@ func attachToSubject(ctx context.Context, ao options.AttachOptions, ref name.Ref return fmt.Errorf("failed to append attestation layer: %w", err) } - // A bug in remote.Write will complain about media type unless we explicitly override emptyHash := v1.Hash{} desc := v1.Descriptor{ MediaType: types.MediaType("application/vnd.in-toto+json"), Digest: subjectDigest, - Size: 0, // the actual digest size doesn't matter for the subject descriptor + Size: 0, } if subjectDigest != emptyHash { if withSubject, ok := mutate.Subject(referrerImage, desc).(v1.Image); ok { @@ -132,10 +183,6 @@ func attachToSubject(ctx context.Context, ao options.AttachOptions, ref name.Ref } } - // We need to write this to the registry. The registry needs a reference to write to. - // Usually we push to a digest reference of the referrer itself or a tag. - // If we use remote.Write with the digest, it will push it. - // Wait, ref is the repository. We can get the digest of the referrerImage referrerDigest, err := referrerImage.Digest() if err != nil { return fmt.Errorf("failed to get referrer image digest: %w", err) diff --git a/cmd/attach_test.go b/cmd/attach_test.go new file mode 100644 index 00000000..9f6e9a08 --- /dev/null +++ b/cmd/attach_test.go @@ -0,0 +1,134 @@ +package cmd + +import ( + "context" + "encoding/json" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/registry" + + "github.com/google/go-containerregistry/pkg/v1/random" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/in-toto/go-witness/dsse" + "github.com/in-toto/witness/options" + "github.com/stretchr/testify/require" +) + +func TestAttachAttestation(t *testing.T) { + // Setup in-memory registry + s := httptest.NewServer(registry.New()) + defer s.Close() + + ctx := context.Background() + + // Push a random image to the registry to test against + img, err := random.Image(1024, 1) + require.NoError(t, err) + imgDigest, err := img.Digest() + require.NoError(t, err) + + refStr := s.URL[7:] + "/test-image:latest" // strip http:// + ref, err := name.ParseReference(refStr) + require.NoError(t, err) + + err = remote.Write(ref, img, remote.WithContext(ctx)) + require.NoError(t, err) + + // Create temporary directory for our test attestations + tempDir := t.TempDir() + + tests := []struct { + name string + payloadType string + noSignatures bool + subjectDigest string + skipVerification bool + expectErr string + }{ + { + name: "matching subject digest", + payloadType: "application/vnd.in-toto+json", + subjectDigest: imgDigest.Hex, + }, + { + name: "mismatching subject digest with skip verification", + payloadType: "application/vnd.in-toto+json", + subjectDigest: "wrongdigest", + skipVerification: true, + }, + { + name: "mismatching subject digest fails", + payloadType: "application/vnd.in-toto+json", + subjectDigest: "wrongdigest", + expectErr: "subject digest mismatch", + }, + { + name: "unsupported payload type", + payloadType: "application/vnd.other", + subjectDigest: imgDigest.Hex, + expectErr: "unsupported payloadType", + }, + { + name: "missing signatures", + payloadType: "application/vnd.in-toto+json", + subjectDigest: imgDigest.Hex, + noSignatures: true, + expectErr: "has no signatures", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var stmt IntotoStatement + stmt.Type = "https://in-toto.io/Statement/v1" + + stmt.Subject = append(stmt.Subject, struct { + Name string `json:"name"` + Digest map[string]string `json:"digest"` + }{ + Name: "test-artifact", + Digest: map[string]string{ + "sha256": tc.subjectDigest, // hex without sha256: prepended + }, + }) + + stmtBytes, err := json.Marshal(stmt) + require.NoError(t, err) + + env := dsse.Envelope{ + PayloadType: tc.payloadType, + Payload: stmtBytes, + } + if !tc.noSignatures { + env.Signatures = append(env.Signatures, dsse.Signature{ + Signature: []byte("dummy-sig"), + KeyID: "dummy-key", + }) + } + + envBytes, err := json.Marshal(env) + require.NoError(t, err) + + attestPath := filepath.Join(tempDir, tc.name+".json") + err = os.WriteFile(attestPath, envBytes, 0644) + require.NoError(t, err) + + ao := options.AttachOptions{ + ImageURI: refStr, + SkipVerification: tc.skipVerification, + } + + err = runAttachAttestation(ctx, ao, []string{attestPath}) + if tc.expectErr != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.expectErr) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/go.mod b/go.mod index aefb7c3b..d7435f4b 100644 --- a/go.mod +++ b/go.mod @@ -264,6 +264,7 @@ require ( k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect + oras.land/oras-go/v2 v2.6.0 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect diff --git a/go.sum b/go.sum index 714fc381..9441708b 100644 --- a/go.sum +++ b/go.sum @@ -814,6 +814,8 @@ k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZ k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc= +oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= diff --git a/options/attach.go b/options/attach.go index 550cefa2..ff521926 100644 --- a/options/attach.go +++ b/options/attach.go @@ -17,10 +17,12 @@ package options import "github.com/spf13/cobra" type AttachOptions struct { - AttestationFilePaths []string + ImageURI string + SkipVerification bool } func (ao *AttachOptions) AddFlags(cmd *cobra.Command) { - cmd.Flags().StringSliceVarP(&ao.AttestationFilePaths, "attestation", "a", []string{}, "Paths to attestation files to attach (can be specified multiple times)") - _ = cmd.MarkFlagRequired("attestation") + cmd.Flags().StringVarP(&ao.ImageURI, "image-uri", "i", "", "Container image URI to attach attestations to (required)") + _ = cmd.MarkFlagRequired("image-uri") + cmd.Flags().BoolVar(&ao.SkipVerification, "skip-verification", false, "Skip checking if the attestation subject matches the image digest") } From 209a3c6a5a349b7eb6885733f5e022135bc7c3b9 Mon Sep 17 00:00:00 2001 From: jaydeep869 Date: Mon, 13 Apr 2026 18:00:32 +0530 Subject: [PATCH 4/4] fix: resolve lint and license boilerplate issues from CI Signed-off-by: jaydeep869 --- cmd/attach.go | 6 +++--- cmd/attach_test.go | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/cmd/attach.go b/cmd/attach.go index 4dfc788c..93be510f 100644 --- a/cmd/attach.go +++ b/cmd/attach.go @@ -154,7 +154,7 @@ func attachToSubject(ctx context.Context, ao options.AttachOptions, ref name.Ref } if !matched { - return fmt.Errorf("subject digest mismatch: attestation %s does not describe the target artifact %s. Use --skip-verification to bypass this check.", attestPath, subjectDigest.String()) + return fmt.Errorf("subject digest mismatch: attestation %s does not describe the target artifact %s (use --skip-verification to bypass this check)", attestPath, subjectDigest.String()) } } @@ -187,9 +187,9 @@ func attachToSubject(ctx context.Context, ao options.AttachOptions, ref name.Ref if err != nil { return fmt.Errorf("failed to get referrer image digest: %w", err) } - + referrerRef := ref.Context().Digest(referrerDigest.String()) - + if err := remote.Write(referrerRef, referrerImage, remote.WithAuthFromKeychain(authn.DefaultKeychain), remote.WithContext(ctx)); err != nil { return fmt.Errorf("failed to write referrer image to registry: %w", err) } diff --git a/cmd/attach_test.go b/cmd/attach_test.go index 9f6e9a08..b2301c52 100644 --- a/cmd/attach_test.go +++ b/cmd/attach_test.go @@ -1,3 +1,17 @@ +// Copyright 2024 The Witness Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package cmd import (