diff --git a/cmd/attach.go b/cmd/attach.go new file mode 100644 index 00000000..93be510f --- /dev/null +++ b/cmd/attach.go @@ -0,0 +1,231 @@ +// 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" + "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/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" +) + +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 [attestation-files]...", + Short: "Attach an attestation file as an OCI referrer", + 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.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runAttachAttestation(cmd.Context(), ao, args) + }, + } + + ao.AddFlags(cmd) + return cmd +} + +// 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 + ref, err := name.ParseReference(imageRef) + if err != nil { + 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 { + 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) + } + 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 attachToSubject(ctx, ao, ref, originalDigest, attestationFiles) +} + +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{ + data: attestData, + } + + referrerImage, err = mutate.AppendLayers(referrerImage, layer) + if err != nil { + return fmt.Errorf("failed to append attestation layer: %w", err) + } + + emptyHash := v1.Hash{} + desc := v1.Descriptor{ + MediaType: types.MediaType("application/vnd.in-toto+json"), + Digest: subjectDigest, + Size: 0, + } + 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") + } + } + + 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/attach_test.go b/cmd/attach_test.go new file mode 100644 index 00000000..b2301c52 --- /dev/null +++ b/cmd/attach_test.go @@ -0,0 +1,148 @@ +// 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 ( + "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/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/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/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 new file mode 100644 index 00000000..ff521926 --- /dev/null +++ b/options/attach.go @@ -0,0 +1,28 @@ +// 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 { + ImageURI string + SkipVerification bool +} + +func (ao *AttachOptions) AddFlags(cmd *cobra.Command) { + 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") +}