Skip to content

Commit

Permalink
*: Add verify-mergeable
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 20, 2024
1 parent 6a6def9 commit bbc6425
Show file tree
Hide file tree
Showing 8 changed files with 394 additions and 9 deletions.
1 change: 1 addition & 0 deletions docs/cli/gittuf.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_verify-mergeable.md

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

2 changes: 2 additions & 0 deletions internal/cmd/root/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/gittuf/gittuf/internal/cmd/profile"
"github.com/gittuf/gittuf/internal/cmd/rsl"
"github.com/gittuf/gittuf/internal/cmd/trust"
"github.com/gittuf/gittuf/internal/cmd/verifymergeable"
"github.com/gittuf/gittuf/internal/cmd/verifyref"
"github.com/gittuf/gittuf/internal/cmd/version"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -93,6 +94,7 @@ func New() *cobra.Command {
cmd.AddCommand(trust.New())
cmd.AddCommand(policy.New())
cmd.AddCommand(rsl.New())
cmd.AddCommand(verifymergeable.New())
cmd.AddCommand(verifyref.New())
cmd.AddCommand(version.New())

Expand Down
45 changes: 45 additions & 0 deletions internal/cmd/verifymergeable/verifymergeable.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// SPDX-License-Identifier: Apache-2.0

package verifymergeable

import (
"github.com/gittuf/gittuf/internal/repository"
"github.com/spf13/cobra"
)

type options struct {
baseBranch string
}

func (o *options) AddFlags(cmd *cobra.Command) {
cmd.Flags().StringVar(
&o.baseBranch,
"into",
"",
"base branch to merge into",
)
cmd.MarkFlagRequired("into") //nolint:errcheck
}

func (o *options) Run(cmd *cobra.Command, args []string) error {
repo, err := repository.LoadRepository()
if err != nil {
return err
}

return repo.VerifyMergeable(cmd.Context(), o.baseBranch, args[0])
}

func New() *cobra.Command {
o := &options{}
cmd := &cobra.Command{
Use: "verify-mergeable",
Short: "Verify if a branch can be merged into another using gittuf policies",
Args: cobra.ExactArgs(1),
RunE: o.Run,
DisableAutoGenTag: true,
}
o.AddFlags(cmd)

return cmd
}
206 changes: 197 additions & 9 deletions internal/policy/verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,186 @@ func VerifyRefFull(ctx context.Context, repo *gitinterface.Repository, target st
return latestEntry.TargetID, VerifyRelativeForRef(ctx, repo, firstEntry, nil, firstEntry, latestEntry, target)
}

func VerifyMergeable(ctx context.Context, repo *gitinterface.Repository, targetRef, featureRef string) (bool, error) {
slog.Debug("Loading policy...")
policyState, err := LoadCurrentState(ctx, repo, PolicyRef)
if err != nil {
return false, err
}

slog.Debug("Loading current set of attestations...")
attestationsState, err := attestations.LoadCurrentAttestations(repo)
if err != nil {
return false, err
}

slog.Debug(fmt.Sprintf("Identifying latest RSL entry for '%s'...", targetRef))
targetEntry, _, err := rsl.GetLatestUnskippedReferenceEntryForRef(repo, targetRef)
if err != nil {
return false, err
}

slog.Debug(fmt.Sprintf("Identifying latest RSL entry for '%s'...", featureRef))
featureEntry, _, err := rsl.GetLatestUnskippedReferenceEntryForRef(repo, featureRef)
if err != nil {
return false, err
}

mergeTreeID, err := repo.GetMergeTree(targetEntry.TargetID, featureEntry.TargetID)
if err != nil {
return false, err
}

authorization, githubApproval, err := getAttestationsForIndex(repo, attestationsState, targetRef, targetEntry.TargetID, mergeTreeID)
if err != nil {
return false, err
}

var (
rslEntrySignatureNeededForThreshold = false

gitNamespaceVerified = false
pathNamespaceVerified = true // Assume paths are verified until we find out otherwise
)

verifiers, err := policyState.FindVerifiersForPath(fmt.Sprintf("%s:%s", gitReferenceRuleScheme, targetRef))
if err != nil {
return false, err
}
if len(verifiers) == 0 {
gitNamespaceVerified = true
}

for _, verifier := range verifiers {
// Try verifying twice
// Once with threshold as it is
// Once with threshold-- (to account for signature on gitObj)
err := verifier.Verify(ctx, gitinterface.ZeroHash, authorization, githubApproval)
if err == nil {
// we've reached threshold with just the approvals
gitNamespaceVerified = true
break
} else if !errors.Is(err, ErrVerifierConditionsUnmet) {
// Unexpected error
return false, err
}

// If threshold == 1 and we have approval without gitObj signature, we'll have exited above
// Only try threshold-- if threshold > 1
if verifier.Threshold() > 1 {
// Make a copy before mutating verifier
verifier := verifier
verifier.threshold--

err := verifier.Verify(ctx, gitinterface.ZeroHash, authorization, githubApproval)
if err == nil {
rslEntrySignatureNeededForThreshold = true
gitNamespaceVerified = true
break
} else if !errors.Is(err, ErrVerifierConditionsUnmet) {
return false, err
}
}
}

if !gitNamespaceVerified {
return false, fmt.Errorf("not enough approvals to meet Git namespace policies, %w", ErrUnauthorizedSignature)
}

hasFileRule, err := policyState.hasFileRule()
if err != nil {
return false, err
}
if !hasFileRule {
return rslEntrySignatureNeededForThreshold, nil
}

// Verify modified files
commitIDs, err := repo.GetCommitsBetweenRange(featureEntry.TargetID, targetEntry.TargetID)
if err != nil {
return false, err
}

commitsVerified := make([]bool, len(commitIDs))
for i, commitID := range commitIDs {
// Assume the commit's paths are verified, if a path is left unverified,
// we flip this later.
commitsVerified[i] = true

paths, err := repo.GetFilePathsChangedByCommit(commitID)
if err != nil {
return false, err
}

pathsVerified := make([]bool, len(paths))
verifiedUsing := ""
for j, path := range paths {
verifiers, err := policyState.FindVerifiersForPath(fmt.Sprintf("%s:%s", fileRuleScheme, path))
if err != nil {
return false, err
}

if len(verifiers) == 0 {
pathsVerified[j] = true
continue
}

if len(verifiedUsing) > 0 {
// We've already verified and identified commit signature, we
// can just check if that verifier is trusted for the new path.
// If not found, we don't make any assumptions about it being a
// failure in case of name mismatches. So, the signature check
// proceeds as usual.
for _, verifier := range verifiers {
if verifier.Name() == verifiedUsing {
pathsVerified[j] = true
break
}
}
}

if pathsVerified[j] {
continue
}

for _, verifier := range verifiers {
err := verifier.Verify(ctx, commitID, authorization, githubApproval)
if err == nil {
// Signature verification succeeded
pathsVerified[j] = true
verifiedUsing = verifier.Name()
break
} else if !errors.Is(err, ErrVerifierConditionsUnmet) {
// Unexpected error
return false, err
}
}
}

for _, p := range pathsVerified {
if !p {
// Flip earlier assumption that commit paths are verified as we
// find that at least one path wasn't verified successfully
commitsVerified[i] = false
break
}
}
}

for _, c := range commitsVerified {
if !c {
pathNamespaceVerified = false
break
}
}

if !pathNamespaceVerified {
return false, fmt.Errorf("not enough approvals to meet file namespace policies, %w", ErrUnauthorizedSignature)
}

return rslEntrySignatureNeededForThreshold, nil
}

// VerifyRefFromEntry performs verification for the reference from a specific
// RSL entry. The expected Git ID for the ref in the latest RSL entry is
// returned if the policy verification is successful.
Expand Down Expand Up @@ -446,10 +626,6 @@ func verifyEntry(ctx context.Context, repo *gitinterface.Repository, policy *Sta
// If not found, we don't make any assumptions about it being a
// failure in case of name mismatches. So, the signature check
// proceeds as usual.
//
// FIXME: this is probably a vuln as a rule name may re-occur
// without being met by a target delegation in different
// policies
for _, verifier := range verifiers {
if verifier.Name() == verifiedUsing {
pathsVerified[j] = true
Expand Down Expand Up @@ -584,13 +760,11 @@ func getAttestations(repo *gitinterface.Repository, attestationsState *attestati
}

firstEntry := false

priorRefEntry, _, err := rsl.GetLatestReferenceEntryForRefBefore(repo, entry.RefName, entry.ID)
if err != nil {
if !errors.Is(err, rsl.ErrRSLEntryNotFound) {
return nil, nil, err
}

firstEntry = true
}

Expand All @@ -604,14 +778,24 @@ func getAttestations(repo *gitinterface.Repository, attestationsState *attestati
return nil, nil, err
}

authorizationAttestation, err := attestationsState.GetReferenceAuthorizationFor(repo, entry.RefName, fromID.String(), entryTreeID.String())
return getAttestationsForIndex(repo, attestationsState, entry.RefName, fromID, entryTreeID)
}

func getAttestationsForIndex(repo *gitinterface.Repository, attestationsState *attestations.Attestations, refName string, fromID, toID gitinterface.Hash) (*sslibdsse.Envelope, *sslibdsse.Envelope, error) {
if attestationsState == nil {
return nil, nil, nil
}

slog.Debug(fmt.Sprintf("Loading reference authorization for '%s' from '%s' to '%s'...", refName, fromID.String(), toID.String()))
authorizationAttestation, err := attestationsState.GetReferenceAuthorizationFor(repo, refName, fromID.String(), toID.String())
if err != nil {
if !errors.Is(err, attestations.ErrAuthorizationNotFound) {
return nil, nil, err
}
}

githubApprovalAttestation, err := attestationsState.GetGitHubPullRequestApprovalAttestationFor(repo, entry.RefName, fromID.String(), entryTreeID.String())
slog.Debug(fmt.Sprintf("Loading GitHub pull request approval attestation for '%s' from '%s' to '%s'...", refName, fromID.String(), toID.String()))
githubApprovalAttestation, err := attestationsState.GetGitHubPullRequestApprovalAttestationFor(repo, refName, fromID.String(), toID.String())
if err != nil {
if !errors.Is(err, attestations.ErrGitHubPullRequestApprovalAttestationNotFound) {
return nil, nil, err
Expand Down Expand Up @@ -707,6 +891,10 @@ func (v *Verifier) Verify(ctx context.Context, gitObjectID gitinterface.Hash, au
return nil
}

// TODO: this isn't always an authorization attestation, so the github
// approval is a weird case
// Maybe this shouldn't be aware of the special predicate?

if authorizationAttestationEnv != nil {
// Second, verify signatures on the attestation, subtracting the threshold
// by 1 to account for a verified Git signature
Expand All @@ -733,7 +921,7 @@ func (v *Verifier) Verify(ctx context.Context, gitObjectID gitinterface.Hash, au
if err != nil {
if !strings.Contains(err.Error(), "accepted signatures do not match threshold") {
// we may yet meet threshold with the approval attestation
return ErrVerifierConditionsUnmet
return fmt.Errorf("unexpected error: %w", err)
}
}
for _, ak := range acceptedKeys {
Expand Down
Loading

0 comments on commit bbc6425

Please sign in to comment.