diff --git a/docs/cli/gitsign_attest.md b/docs/cli/gitsign_attest.md index aa3fb0fc..6e4d0648 100644 --- a/docs/cli/gitsign_attest.md +++ b/docs/cli/gitsign_attest.md @@ -12,7 +12,7 @@ gitsign attest [flags] -f, --filepath string attestation filepath -h, --help help for attest --objtype string [commit | tree] - Git object type to attest (default "commit") - --type string specify a predicate type (slsaprovenance|link|spdx|spdxjson|cyclonedx|vuln|custom) or an URI (default "custom") + --type string specify a predicate type URI ``` ### SEE ALSO diff --git a/go.mod b/go.mod index c66cb786..d2d8c0f5 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/go-openapi/strfmt v0.23.0 github.com/go-openapi/swag v0.23.0 github.com/google/go-cmp v0.6.0 + github.com/in-toto/attestation v1.1.0 github.com/in-toto/in-toto-golang v0.9.0 github.com/jonboulle/clockwork v0.5.0 github.com/mattn/go-tty v0.0.7 diff --git a/internal/attest/attest.go b/internal/attest/attest.go index cd74b53b..26d73744 100644 --- a/internal/attest/attest.go +++ b/internal/attest/attest.go @@ -32,9 +32,9 @@ import ( "github.com/go-git/go-git/v5/plumbing/object" "github.com/go-git/go-git/v5/storage" "github.com/go-openapi/strfmt" + spb "github.com/in-toto/attestation/go/v1" "github.com/jonboulle/clockwork" "github.com/sigstore/cosign/v2/cmd/cosign/cli/sign" - "github.com/sigstore/cosign/v2/pkg/cosign/attestation" "github.com/sigstore/cosign/v2/pkg/types" utils "github.com/sigstore/gitsign/internal" gitsignconfig "github.com/sigstore/gitsign/internal/config" @@ -42,11 +42,15 @@ import ( "github.com/sigstore/rekor/pkg/generated/models" dssesig "github.com/sigstore/sigstore/pkg/signature/dsse" signatureoptions "github.com/sigstore/sigstore/pkg/signature/options" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/types/known/structpb" ) const ( - CommitRef = "refs/attestations/commits" - TreeRef = "refs/attestations/trees" + CommitRef = "refs/attestations/commits" + TreeRef = "refs/attestations/trees" + DigestTypeCommit = "gitCommit" + DigestTypeTree = "gitTree" ) var ( @@ -57,18 +61,20 @@ var ( type rekorUpload func(ctx context.Context, rekorClient *rekorclient.Rekor, signature []byte, pemBytes []byte) (*models.LogEntryAnon, error) type Attestor struct { - repo *git.Repository - sv *sign.SignerVerifier - rekorFn rekorUpload - config *gitsignconfig.Config + repo *git.Repository + sv *sign.SignerVerifier + rekorFn rekorUpload + config *gitsignconfig.Config + digestType string } -func NewAttestor(repo *git.Repository, sv *sign.SignerVerifier, rekorFn rekorUpload, config *gitsignconfig.Config) *Attestor { +func NewAttestor(repo *git.Repository, sv *sign.SignerVerifier, rekorFn rekorUpload, config *gitsignconfig.Config, digestType string) *Attestor { return &Attestor{ - repo: repo, - sv: sv, - rekorFn: rekorFn, - config: config, + repo: repo, + sv: sv, + rekorFn: rekorFn, + config: config, + digestType: digestType, } } @@ -111,7 +117,7 @@ func NewNamedReader(r io.Reader, name string) Reader { // refName: What ref to write to (e.g. refs/attestations/commits) // sha: Commit SHA you are attesting to. // input: Attestation file input. -// attType: Attestation type. See [attestation.GenerateStatement] for allowed values. +// attType: Attestation (predicate) type URI corresponding to the input (predicate). func (a *Attestor) WriteAttestation(ctx context.Context, refName string, sha plumbing.Hash, input Reader, attType string) (plumbing.Hash, error) { b, err := io.ReadAll(input) if err != nil { @@ -226,18 +232,32 @@ func encode(store storage.Storer, enc Encoder) (plumbing.Hash, error) { return store.SetEncodedObject(obj) } +func generateStatement(pred []byte, attType string, digestType string, sha plumbing.Hash) (*spb.Statement, error) { + sub := []*spb.ResourceDescriptor{{ + Digest: map[string]string{digestType: sha.String()}, + }} + + var predPb structpb.Struct + err := json.Unmarshal(pred, &predPb) + if err != nil { + return nil, err + } + + return &spb.Statement{ + Type: spb.StatementTypeUri, + Subject: sub, + PredicateType: attType, + Predicate: &predPb, + }, nil +} + func (a *Attestor) signPayload(ctx context.Context, sha plumbing.Hash, b []byte, attType string) ([]byte, error) { - // Generate attestation - sh, err := attestation.GenerateStatement(attestation.GenerateOpts{ - Predicate: bytes.NewBuffer(b), - Type: attType, - Digest: sha.String(), - Time: clock.Now, - }) + sh, err := generateStatement(b, attType, a.digestType, sha) + if err != nil { return nil, err } - payload, err := json.Marshal(sh) + payload, err := protojson.Marshal(sh) if err != nil { return nil, err } @@ -248,6 +268,7 @@ func (a *Attestor) signPayload(ctx context.Context, sha plumbing.Hash, b []byte, } rekorHost, rekorBasePath := utils.StripURL(a.config.Rekor) + tc := &rekorclient.TransportConfig{ Host: rekorHost, BasePath: rekorBasePath, diff --git a/internal/attest/attest_test.go b/internal/attest/attest_test.go index 96e3d0d1..4f8d0b39 100644 --- a/internal/attest/attest_test.go +++ b/internal/attest/attest_test.go @@ -19,11 +19,11 @@ import ( "context" "encoding/base64" "encoding/json" + "fmt" "io" "os" "path/filepath" "testing" - "text/template" "time" "github.com/go-git/go-billy/v5" @@ -43,10 +43,6 @@ import ( "github.com/sigstore/sigstore/pkg/signature" ) -var ( - tmpl = template.Must(template.ParseFiles("testdata/test.json.provenance")) -) - func TestMain(m *testing.M) { clock = clockwork.NewFakeClockAt(time.Date(1984, time.April, 4, 0, 0, 0, 0, time.UTC)) os.Exit(m.Run()) @@ -77,33 +73,33 @@ func TestAttestCommitRef(t *testing.T) { t.Fatal(err) } - attestor := NewAttestor(repo, sv, fakeRekor, cfg) + attestor := NewAttestor(repo, sv, fakeRekor, cfg, DigestTypeCommit) - fc := []fileContent{ - { - Name: filepath.Join(sha.String(), "test.json"), - Content: readFile(t, "testdata/test.json"), - }, + ad := []gitAttestData{ { - Name: filepath.Join(sha.String(), "test.json.sig"), - Content: generateAttestation(t, sha), + sha: sha, + predName: "test.json", + predicate: readFile(t, "testdata/test.json"), + attName: "test.json.sig", + attestation: generateAttestation(t, "gitCommit", sha), }, } + t.Run("base", func(t *testing.T) { - attest1, err := attestor.WriteAttestation(ctx, CommitRef, sha, NewNamedReader(bytes.NewBufferString(content), name), "custom") + attest1, err := attestor.WriteAttestation(ctx, CommitRef, sha, NewNamedReader(bytes.NewBufferString(content), name), "custom-pred-type") if err != nil { t.Fatalf("WriteAttestation: %v", err) } - verifyContent(t, repo, attest1, fc) + verifyContent(t, repo, attest1, ad) }) t.Run("noop", func(t *testing.T) { // Write same attestation to the same commit - should be a no-op. - attest2, err := attestor.WriteAttestation(ctx, CommitRef, sha, NewNamedReader(bytes.NewBufferString(content), name), "custom") + attest2, err := attestor.WriteAttestation(ctx, CommitRef, sha, NewNamedReader(bytes.NewBufferString(content), name), "custom-pred-type") if err != nil { t.Fatalf("WriteAttestation: %v", err) } - verifyContent(t, repo, attest2, fc) + verifyContent(t, repo, attest2, ad) }) t.Run("new commit", func(t *testing.T) { @@ -117,21 +113,20 @@ func TestAttestCommitRef(t *testing.T) { t.Fatal(err) } - attest3, err := attestor.WriteAttestation(ctx, CommitRef, sha, NewNamedReader(bytes.NewBufferString(content), name), "custom") + attest3, err := attestor.WriteAttestation(ctx, CommitRef, sha, NewNamedReader(bytes.NewBufferString(content), name), "custom-pred-type") if err != nil { t.Fatalf("WriteAttestation: %v", err) } - fc = append(fc, - fileContent{ - Name: filepath.Join(sha.String(), "test.json"), - Content: content, - }, - fileContent{ - Name: filepath.Join(sha.String(), "test.json.sig"), - Content: generateAttestation(t, sha), + ad = append(ad, + gitAttestData{ + sha: sha, + predName: "test.json", + predicate: readFile(t, "testdata/test.json"), + attName: "test.json.sig", + attestation: generateAttestation(t, "gitCommit", sha), }, ) - verifyContent(t, repo, attest3, fc) + verifyContent(t, repo, attest3, ad) }) } @@ -157,33 +152,32 @@ func TestAttestTreeRef(t *testing.T) { cfg, _ := gitsignconfig.Get() - attestor := NewAttestor(repo, sv, fakeRekor, cfg) + attestor := NewAttestor(repo, sv, fakeRekor, cfg, DigestTypeTree) - fc := []fileContent{ + ad := []gitAttestData{ { - Name: filepath.Join(sha.String(), "test.json"), - Content: readFile(t, "testdata/test.json"), - }, - { - Name: filepath.Join(sha.String(), "test.json.sig"), - Content: generateAttestation(t, sha), + sha: sha, + predName: "test.json", + predicate: readFile(t, "testdata/test.json"), + attName: "test.json.sig", + attestation: generateAttestation(t, "gitTree", sha), }, } t.Run("base", func(t *testing.T) { - attest1, err := attestor.WriteAttestation(ctx, TreeRef, sha, NewNamedReader(bytes.NewBufferString(content), name), "custom") + attest1, err := attestor.WriteAttestation(ctx, TreeRef, sha, NewNamedReader(bytes.NewBufferString(content), name), "custom-pred-type") if err != nil { t.Fatalf("WriteAttestation: %v", err) } - verifyContent(t, repo, attest1, fc) + verifyContent(t, repo, attest1, ad) }) t.Run("noop", func(t *testing.T) { // Write same attestation to the same commit - should be a no-op. - attest2, err := attestor.WriteAttestation(ctx, TreeRef, sha, NewNamedReader(bytes.NewBufferString(content), name), "custom") + attest2, err := attestor.WriteAttestation(ctx, TreeRef, sha, NewNamedReader(bytes.NewBufferString(content), name), "custom-pred-type") if err != nil { t.Fatalf("WriteAttestation: %v", err) } - verifyContent(t, repo, attest2, fc) + verifyContent(t, repo, attest2, ad) }) t.Run("new commit same tree", func(t *testing.T) { @@ -197,42 +191,44 @@ func TestAttestTreeRef(t *testing.T) { } sha = resolveTree(t, repo, sha) - attest3, err := attestor.WriteAttestation(ctx, TreeRef, sha, NewNamedReader(bytes.NewBufferString(content), name), "custom") + attest3, err := attestor.WriteAttestation(ctx, TreeRef, sha, NewNamedReader(bytes.NewBufferString(content), name), "custom-pred-type") if err != nil { t.Fatalf("WriteAttestation: %v", err) } - verifyContent(t, repo, attest3, fc) + verifyContent(t, repo, attest3, ad) }) t.Run("new commit new tree", func(t *testing.T) { // Make a new commit, write new attestation. sha = resolveTree(t, repo, writeRepo(t, w, fs, "testdata/bar.txt")) - attest3, err := attestor.WriteAttestation(ctx, TreeRef, sha, NewNamedReader(bytes.NewBufferString(content), name), "custom") + attest3, err := attestor.WriteAttestation(ctx, TreeRef, sha, NewNamedReader(bytes.NewBufferString(content), name), "custom-pred-type") if err != nil { t.Fatalf("WriteAttestation: %v", err) } - fc = append(fc, - fileContent{ - Name: filepath.Join(sha.String(), "test.json"), - Content: content, - }, - fileContent{ - Name: filepath.Join(sha.String(), "test.json.sig"), - Content: generateAttestation(t, sha), + ad = append(ad, + gitAttestData{ + sha: sha, + predName: "test.json", + predicate: readFile(t, "testdata/test.json"), + attName: "test.json.sig", + attestation: generateAttestation(t, "gitTree", sha), }, ) - verifyContent(t, repo, attest3, fc) + verifyContent(t, repo, attest3, ad) }) } -type fileContent struct { - Name string - Content string +type gitAttestData struct { + sha plumbing.Hash + predName string + predicate string + attName string + attestation string } -func verifyContent(t *testing.T, repo *git.Repository, sha plumbing.Hash, want []fileContent) { +func verifyContent(t *testing.T, repo *git.Repository, sha plumbing.Hash, want []gitAttestData) { t.Helper() commit, err := repo.CommitObject(sha) @@ -240,32 +236,40 @@ func verifyContent(t *testing.T, repo *git.Repository, sha plumbing.Hash, want [ t.Fatal(err) } - files, err := commit.Files() - if err != nil { - t.Fatal(err) - } - - got := []fileContent{} - if err := files.ForEach(func(c *object.File) error { - content, err := c.Contents() + for _, w := range want { + // We'll just check the raw predicate file that was written, + // that doesn't get marshaled so it should be untouched. + fname := fmt.Sprintf("%v/%v", w.sha, w.predName) + gotPredFile, err := commit.File(fname) if err != nil { - return err + t.Fatal(err) + } + gotPred, err := gotPredFile.Contents() + if err != nil { + t.Fatal(err) + } + diff := cmp.Diff(w.predicate, gotPred) + if diff != "" { + t.Errorf("fname %v does not match: %v", fname, diff) } - got = append(got, fileContent{ - Name: c.Name, - Content: content, - }) - - return nil - }); err != nil { - t.Fatal(err) - } - - if diff := cmp.Diff(want, got, cmpopts.SortSlices(func(i, j fileContent) bool { - return i.Name < j.Name - })); diff != "" { - t.Error(diff) + // The attestation does get marshalled though, so we can't do + // a simple diff, instead we'll need to parse things... + fname = fmt.Sprintf("%v/%v", w.sha, w.attName) + gotE := readDsse(t, commit, fname) + wantE := parseDsse(t, w.attestation) + // Ignore payload because we're going to handle that special + diff = cmp.Diff(gotE, wantE, cmpopts.IgnoreFields(dsse.Envelope{}, "Payload")) + if diff != "" { + t.Errorf("fname %v does not match: %v", fname, diff) + } + // Now let's check the payload. + gotJ := parsePayload(t, gotE) + wantJ := parsePayload(t, wantE) + diff = cmp.Diff(gotJ, wantJ) + if diff != "" { + t.Errorf("fname payload %v does not match: %v", fname, diff) + } } } @@ -286,6 +290,40 @@ func fakeRekor(_ context.Context, _ *client.Rekor, _, _ []byte) (*models.LogEntr }, nil } +func parsePayload(t *testing.T, d *dsse.Envelope) interface{} { + p, err := base64.StdEncoding.DecodeString(d.Payload) + if err != nil { + t.Fatal(err) + } + var j interface{} + err = json.Unmarshal(p, &j) + if err != nil { + t.Fatal(err) + } + return j +} + +func parseDsse(t *testing.T, content string) *dsse.Envelope { + var e dsse.Envelope + err := json.Unmarshal([]byte(content), &e) + if err != nil { + t.Fatal(err) + } + return &e +} + +func readDsse(t *testing.T, commit *object.Commit, fname string) *dsse.Envelope { + f, err := commit.File(fname) + if err != nil { + t.Fatal(err) + } + c, err := f.Contents() + if err != nil { + t.Fatal(err) + } + return parseDsse(t, c) +} + func readFile(t *testing.T, path string) string { t.Helper() @@ -317,17 +355,17 @@ func writeRepo(t *testing.T, w *git.Worktree, fs billy.Filesystem, path string) return sha } -func generateAttestation(t *testing.T, h plumbing.Hash) string { +func generateAttestation(t *testing.T, digestType string, h plumbing.Hash) string { t.Helper() - b := new(bytes.Buffer) - if err := tmpl.Execute(b, h); err != nil { - t.Fatal(err) - } + statement := fmt.Sprintf( + `{"_type":"https://in-toto.io/Statement/v1","subject":[{"digest":{"%s":"%s"}}],"predicateType":"custom-pred-type","predicate":{"foo":"bar"}}`, + digestType, + h.String()) att := dsse.Envelope{ PayloadType: "application/vnd.in-toto+json", - Payload: base64.StdEncoding.EncodeToString(bytes.TrimSpace(b.Bytes())), + Payload: base64.StdEncoding.EncodeToString([]byte(statement)), Signatures: []dsse.Signature{{Sig: "dGFjb2NhdA=="}}, } diff --git a/internal/attest/testdata/test.json.provenance b/internal/attest/testdata/test.json.provenance deleted file mode 100644 index bf5ca61b..00000000 --- a/internal/attest/testdata/test.json.provenance +++ /dev/null @@ -1 +0,0 @@ -{"_type":"https://in-toto.io/Statement/v0.1","predicateType":"https://cosign.sigstore.dev/attestation/v1","subject":[{"name":"","digest":{"sha256":"{{.}}"}}],"predicate":{"Data":"{\"foo\":\"bar\"}","Timestamp":"1984-04-04T00:00:00Z"}} diff --git a/internal/commands/attest/attest.go b/internal/commands/attest/attest.go index 76a7fcbe..78735fe2 100644 --- a/internal/commands/attest/attest.go +++ b/internal/commands/attest/attest.go @@ -46,7 +46,7 @@ type options struct { func (o *options) AddFlags(cmd *cobra.Command) { cmd.Flags().StringVar(&o.FlagObjectType, "objtype", FlagObjectTypeCommit, "[commit | tree] - Git object type to attest") cmd.Flags().StringVarP(&o.FlagPath, "filepath", "f", "", "attestation filepath") - cmd.Flags().StringVar(&o.FlagAttestationType, "type", "", `specify a predicate type (slsaprovenance|link|spdx|spdxjson|cyclonedx|vuln|custom) or an URI (default "custom")`) + cmd.Flags().StringVar(&o.FlagAttestationType, "type", "", `specify a predicate type URI`) } func (o *options) Run(ctx context.Context) error { @@ -63,6 +63,7 @@ func (o *options) Run(ctx context.Context) error { // If we're attaching the attestation to a tree, resolve the tree SHA. sha := head.Hash() refName := attCommitRef + digestType := attest.DigestTypeCommit if o.FlagObjectType == FlagObjectTypeTree { commit, err := repo.CommitObject(head.Hash()) if err != nil { @@ -71,6 +72,7 @@ func (o *options) Run(ctx context.Context) error { sha = commit.TreeHash refName = attTreeRef + digestType = attest.DigestTypeTree } sv, err := sign.SignerFromKeyOpts(ctx, "", "", cosignopts.KeyOpts{ @@ -84,7 +86,7 @@ func (o *options) Run(ctx context.Context) error { } defer sv.Close() - attestor := attest.NewAttestor(repo, sv, cosign.TLogUploadInTotoAttestation, o.Config) + attestor := attest.NewAttestor(repo, sv, cosign.TLogUploadInTotoAttestation, o.Config, digestType) out, err := attestor.WriteFile(ctx, refName, sha, o.FlagPath, o.FlagAttestationType) if err != nil {