Skip to content

Commit

Permalink
*: Add support for GitHub pull request approval attestation
Browse files Browse the repository at this point in the history
Signed-off-by: Aditya Sirish A Yelgundhalli <ayelgundhall@bloomberg.net>
  • Loading branch information
adityasaky committed Jun 21, 2024
1 parent b894da7 commit 6b53015
Show file tree
Hide file tree
Showing 29 changed files with 1,611 additions and 115 deletions.
1 change: 1 addition & 0 deletions docs/cli/gittuf_dev.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 32 additions & 0 deletions docs/cli/gittuf_dev_attest-github-approval.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions docs/cli/gittuf_trust.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 33 additions & 0 deletions docs/cli/gittuf_trust_add-github-app-key.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 30 additions & 0 deletions docs/cli/gittuf_trust_github-app-approvals.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 28 additions & 0 deletions docs/cli/gittuf_trust_remove-github-app-key.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 18 additions & 7 deletions internal/attestations/attestations.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@ import (
)

const (
Ref = "refs/gittuf/attestations"
referenceAuthorizationsTreeEntryName = "reference-authorizations"
githubPullRequestAttestationsTreeEntryName = "github-pull-requests"
initialCommitMessage = "Initial commit"
defaultCommitMessage = "Update attestations"
Ref = "refs/gittuf/attestations"

referenceAuthorizationsTreeEntryName = "reference-authorizations"
githubPullRequestAttestationsTreeEntryName = "github-pull-requests"
githubPullRequestApprovalAttestationsTreeEntryName = "github-pull-request-approvals"

initialCommitMessage = "Initial commit"
defaultCommitMessage = "Update attestations"
)

// Attestations tracks all the attestations in a gittuf repository.
Expand All @@ -36,6 +39,8 @@ type Attestations struct {
// `<ref-path>/<commit-id>`, where `ref-path` is the absolute ref path, and
// `commit-id` is the ID of the merged commit.
githubPullRequestAttestations map[string]gitinterface.Hash

githubPullRequestApprovalAttestations map[string]gitinterface.Hash
}

// LoadCurrentAttestations inspects the repository's attestations namespace and
Expand Down Expand Up @@ -78,8 +83,9 @@ func LoadAttestationsForEntry(repo *gitinterface.Repository, entry *rsl.Referenc
}

attestations := &Attestations{
referenceAuthorizations: map[string]gitinterface.Hash{},
githubPullRequestAttestations: map[string]gitinterface.Hash{},
referenceAuthorizations: map[string]gitinterface.Hash{},
githubPullRequestAttestations: map[string]gitinterface.Hash{},
githubPullRequestApprovalAttestations: map[string]gitinterface.Hash{},
}

for name, blobID := range treeContents {
Expand All @@ -88,6 +94,8 @@ func LoadAttestationsForEntry(repo *gitinterface.Repository, entry *rsl.Referenc
attestations.referenceAuthorizations[strings.TrimPrefix(name, referenceAuthorizationsTreeEntryName+"/")] = blobID
case strings.HasPrefix(name, githubPullRequestAttestationsTreeEntryName+"/"):
attestations.githubPullRequestAttestations[strings.TrimPrefix(name, githubPullRequestAttestationsTreeEntryName+"/")] = blobID
case strings.HasPrefix(name, githubPullRequestApprovalAttestationsTreeEntryName+"/"):
attestations.githubPullRequestApprovalAttestations[strings.TrimPrefix(name, githubPullRequestApprovalAttestationsTreeEntryName+"/")] = blobID
}
}

Expand All @@ -111,6 +119,9 @@ func (a *Attestations) Commit(repo *gitinterface.Repository, commitMessage strin
for name, blobID := range a.githubPullRequestAttestations {
allAttestations[path.Join(githubPullRequestAttestationsTreeEntryName, name)] = blobID
}
for name, blobID := range a.githubPullRequestApprovalAttestations {
allAttestations[path.Join(githubPullRequestApprovalAttestationsTreeEntryName, name)] = blobID
}

attestationsTreeID, err := treeBuilder.WriteRootTreeFromBlobIDs(allAttestations)
if err != nil {
Expand Down
13 changes: 1 addition & 12 deletions internal/attestations/authorization.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import (
"github.com/gittuf/gittuf/internal/gitinterface"
ita "github.com/in-toto/attestation/go/v1"
sslibdsse "github.com/secure-systems-lab/go-securesystemslib/dsse"
"google.golang.org/protobuf/types/known/structpb"
)

const (
Expand Down Expand Up @@ -48,17 +47,7 @@ func NewReferenceAuthorization(targetRef, fromRevisionID, targetTreeID string) (
TargetTreeID: targetTreeID,
}

predicateBytes, err := json.Marshal(predicate)
if err != nil {
return nil, err
}

predicateInterface := &map[string]any{}
if err := json.Unmarshal(predicateBytes, predicateInterface); err != nil {
return nil, err
}

predicateStruct, err := structpb.NewStruct(*predicateInterface)
predicateStruct, err := predicateToPBStruct(predicate)
if err != nil {
return nil, err
}
Expand Down
128 changes: 126 additions & 2 deletions internal/attestations/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,29 @@ package attestations

import (
"encoding/json"
"errors"
"fmt"
"path"
"sort"

"github.com/gittuf/gittuf/internal/common/set"
"github.com/gittuf/gittuf/internal/gitinterface"
"github.com/gittuf/gittuf/internal/tuf"
"github.com/google/go-github/v61/github"
ita "github.com/in-toto/attestation/go/v1"
sslibdsse "github.com/secure-systems-lab/go-securesystemslib/dsse"
"google.golang.org/protobuf/types/known/structpb"
)

const (
GitHubPullRequestPredicateType = "https://gittuf.dev/github-pull-request/v0.1"
digestGitCommitKey = "gitCommit"
GitHubPullRequestPredicateType = "https://gittuf.dev/github-pull-request/v0.1"
GitHubPullRequestApprovalPredicateType = "https://gittuf.dev/github-pull-request-approval/v0.1"
digestGitCommitKey = "gitCommit"
)

var (
ErrInvalidGitHubPullRequestApprovalAttestation = errors.New("the GitHub pull request approval attestation does not match expected details")
ErrGitHubPullRequestApprovalAttestationNotFound = errors.New("requested GitHub pull request approval attestation not found")
)

func NewGitHubPullRequestAttestation(owner, repository string, pullRequestNumber int, commitID string, pullRequest *github.PullRequest) (*ita.Statement, error) {
Expand Down Expand Up @@ -72,3 +82,117 @@ func (a *Attestations) SetGitHubPullRequestAuthorization(repo *gitinterface.Repo
func GitHubPullRequestAttestationPath(refName, commitID string) string {
return path.Join(refName, commitID)
}

// GitHubPullRequestApprovalAttestation is similar to a
// `ReferenceAuthorization`, except that it records a pull request's approvers
// inside the predicate (defined here).
type GitHubPullRequestApprovalAttestation struct {
Approvers []*tuf.Key `json:"approvers"`
*ReferenceAuthorization
}

// NewGitHubPullRequestApprovalAttestation creates a new GitHub pull request
// approval attestation for the provided information. The attestation is
// embedded in an in-toto "statement" and returned with the appropriate
// "predicate type" set. The `fromTargetID` and `toTargetID` specify the change
// to `targetRef` that is approved on the corresponding GitHub pull request.
func NewGitHubPullRequestApprovalAttestation(targetRef, fromRevisionID, targetTreeID string, approvers []*tuf.Key) (*ita.Statement, error) {
approversSet := set.NewSet[string]()
approversFiltered := make([]*tuf.Key, 0, len(approvers))
for _, approver := range approvers {
approver := approver
if approversSet.Has(approver.KeyID) {
continue
}
approversSet.Add(approver.KeyID)
approversFiltered = append(approversFiltered, approver)
}

approvers = approversFiltered
sort.Slice(approvers, func(i, j int) bool {
return approvers[i].KeyID < approvers[j].KeyID
})

predicate := &GitHubPullRequestApprovalAttestation{
ReferenceAuthorization: &ReferenceAuthorization{
TargetRef: targetRef,
FromRevisionID: fromRevisionID,
TargetTreeID: targetTreeID,
},
Approvers: approvers,
}

predicateStruct, err := predicateToPBStruct(predicate)
if err != nil {
return nil, err
}

return &ita.Statement{
Type: ita.StatementTypeUri,
Subject: []*ita.ResourceDescriptor{
{
Digest: map[string]string{digestGitTreeKey: targetTreeID},
},
},
PredicateType: GitHubPullRequestApprovalPredicateType,
Predicate: predicateStruct,
}, nil
}

// SetGitHubPullRequestApprovalAttestation writes the new GitHub pull request
// approval attestation to the object store and tracks it in the current
// attestations state.
func (a *Attestations) SetGitHubPullRequestApprovalAttestation(repo *gitinterface.Repository, env *sslibdsse.Envelope, refName, fromRevisionID, targetTreeID string) error {
if err := validateGitHubPullRequestApprovalAttestation(env, refName, fromRevisionID, targetTreeID); err != nil {
return errors.Join(ErrInvalidGitHubPullRequestApprovalAttestation, err)
}

envBytes, err := json.Marshal(env)
if err != nil {
return err
}

blobID, err := repo.WriteBlob(envBytes)
if err != nil {
return err
}

if a.githubPullRequestApprovalAttestations == nil {
a.githubPullRequestApprovalAttestations = map[string]gitinterface.Hash{}
}

a.githubPullRequestApprovalAttestations[GitHubPullRequestApprovalAttestationPath(refName, fromRevisionID, targetTreeID)] = blobID
return nil
}

// GetGitHubPullRequestApprovalAttestationFor returns the requested GitHub pull
// request approval attestation.
func (a *Attestations) GetGitHubPullRequestApprovalAttestationFor(repo *gitinterface.Repository, refName, fromRevisionID, targetTreeID string) (*sslibdsse.Envelope, error) {
blobID, has := a.githubPullRequestApprovalAttestations[GitHubPullRequestApprovalAttestationPath(refName, fromRevisionID, targetTreeID)]
if !has {
return nil, ErrGitHubPullRequestApprovalAttestationNotFound
}

envBytes, err := repo.ReadBlob(blobID)
if err != nil {
return nil, err
}

env := &sslibdsse.Envelope{}
if err := json.Unmarshal(envBytes, env); err != nil {
return nil, err
}

return env, nil
}

// GitHubPullRequestApprovalAttestationPath returns the expected path on-disk
// for the GitHub pull request approval attestation. For now, this attestation
// type is stored using the same format as a reference authorization.
func GitHubPullRequestApprovalAttestationPath(refName, fromID, toID string) string {
return ReferenceAuthorizationPath(refName, fromID, toID)
}

func validateGitHubPullRequestApprovalAttestation(env *sslibdsse.Envelope, targetRef, fromRevisionID, targetTreeID string) error {
return validateReferenceAuthorization(env, targetRef, fromRevisionID, targetTreeID)
}
Loading

0 comments on commit 6b53015

Please sign in to comment.