Files
slsa-verifier/verifiers/internal/gha/builder.go
T
2024-08-12 20:52:44 +00:00

508 lines
17 KiB
Go

package gha
import (
"crypto/x509"
"encoding/asn1"
"fmt"
"net/url"
"strings"
fulcio "github.com/sigstore/fulcio/pkg/certificate"
serrors "github.com/slsa-framework/slsa-verifier/v2/errors"
"github.com/slsa-framework/slsa-verifier/v2/options"
ghacommon "github.com/slsa-framework/slsa-verifier/v2/verifiers/internal/gha/slsaprovenance/common"
"github.com/slsa-framework/slsa-verifier/v2/verifiers/utils"
)
var (
trustedBuilderRepository = "slsa-framework/slsa-github-generator"
e2eTestRepository = "slsa-framework/example-package"
jReleaserActionRepository = "jreleaser/release-action"
certOidcIssuer = "https://token.actions.githubusercontent.com"
githubCom = "github.com/"
httpsGithubCom = "https://" + githubCom
// This is used in sigstore-go's NewShortCertificateIdentity for validating the certificate. We
// do specific builder verification after this.
certSubjectRegexp = httpsGithubCom + "*"
)
var defaultArtifactTrustedReusableWorkflows = map[string]bool{
ghacommon.GenericGeneratorBuilderID: true,
ghacommon.GoBuilderID: true,
ghacommon.ContainerBasedBuilderID: true,
}
var defaultContainerTrustedReusableWorkflows = map[string]bool{
ghacommon.ContainerGeneratorBuilderID: true,
}
var defaultBYOBReusableWorkflows = map[string]bool{
ghacommon.GenericDelegatorBuilderID: true,
ghacommon.GenericLowPermsDelegatorBuilderID: true,
}
var JReleaserRepository = httpsGithubCom + jReleaserActionRepository
// VerifyCertficateSourceRepository verifies the source repository.
func VerifyCertficateSourceRepository(id *WorkflowIdentity,
sourceRepo string,
) error {
// The caller repository in the x509 extension is not fully qualified. It only contains
// {org}/{repository}.
expectedSource := strings.TrimPrefix(sourceRepo, "git+https://")
expectedSource = strings.TrimPrefix(expectedSource, githubCom)
if id.SourceRepository != expectedSource {
return fmt.Errorf("%w: expected source '%s', got '%s'", serrors.ErrorMismatchSource,
expectedSource, id.SourceRepository)
}
return nil
}
// VerifyBuilderIdentity verifies the signing certificate information.
// Builder IDs are verified against an expected builder ID provided in the
// builerOpts, or against the set of defaultBuilders provided. The identiy
// in the certificate corresponds to a GitHub workflow's path.
func VerifyBuilderIdentity(id *WorkflowIdentity,
builderOpts *options.BuilderOpts,
defaultBuilders map[string]bool,
) (*utils.TrustedBuilderID, bool, error) {
// Issuer verification.
// NOTE: this is necessary before we do any further verification.
if id.Issuer != certOidcIssuer {
return nil, false, fmt.Errorf("%w: %q", serrors.ErrorInvalidOIDCIssuer, id.Issuer)
}
// cert URI is https://github.com/org/repo/path/to/workflow@ref
// Remove '@' from Path
workflowID := id.SubjectWorkflowName()
workflowTag := id.SubjectWorkflowRef()
if workflowID == "" || workflowTag == "" {
return nil, false, fmt.Errorf("%w: workflow uri: %q", serrors.ErrorMalformedURI, id.SubjectWorkflow.String())
}
// Verify trusted workflow.
builderID, byob, err := verifyTrustedBuilderID(workflowID, workflowTag,
builderOpts.ExpectedID, defaultBuilders)
if err != nil {
return nil, byob, err
}
// Verify the ref is a full semantic version tag.
if err := verifyTrustedBuilderRef(id, workflowTag); err != nil {
return nil, byob, err
}
return builderID, byob, nil
}
// Verifies the builder ID at path against an expected builderID.
// If an expected builderID is not provided, uses the defaultBuilders.
func verifyTrustedBuilderID(certBuilderID, certTag string, expectedBuilderID *string, defaultTrustedBuilders map[string]bool) (*utils.TrustedBuilderID, bool, error) {
var trustedBuilderID *utils.TrustedBuilderID
var err error
// WARNING: we don't validate the tag here, because we need to allow
// refs/heads/main for e2e tests. See verifyTrustedBuilderRef().
// No builder ID provided by user: use the default trusted workflows.
if expectedBuilderID == nil || *expectedBuilderID == "" {
if _, ok := defaultTrustedBuilders[certBuilderID]; !ok {
return nil, false, fmt.Errorf("%w: %s with builderID provided: %t", serrors.ErrorUntrustedReusableWorkflow, certBuilderID, expectedBuilderID != nil)
}
// Construct the builderID using the certificate's builder's name and tag.
trustedBuilderID, err = utils.TrustedBuilderIDNew(certBuilderID+"@"+certTag, true)
if err != nil {
return nil, false, err
}
// Check if:
// - the builder in the cert is a BYOB builder
// - the caller trusts the BYOB builder
// If both are true, we don't match the user-provided builder ID
// against the certificate. Instead that will be done by the caller.
//
// This return of the delegator builderID enables non-compulsory
// builderID feature for BYOB builders by setting byob flag to true.
return trustedBuilderID, isTrustedDelegatorBuilder(trustedBuilderID, defaultTrustedBuilders), nil
}
// Verify the builderID.
// We only accept IDs on github.com.
trustedBuilderID, err = utils.TrustedBuilderIDNew(certBuilderID+"@"+certTag, true)
if err != nil {
return nil, false, err
}
// Check if:
// - the builder in the cert is a BYOB builder
// - the caller trusts the BYOB builder
// If both are true, we don't match the user-provided builder ID
// against the certificate. Instead that will be done by the caller.
if isTrustedDelegatorBuilder(trustedBuilderID, defaultTrustedBuilders) {
return trustedBuilderID, true, nil
}
// Not a BYOB builder. BuilderID provided by user should match the certificate.
// Note: the certificate builderID has the form `name@refs/tags/v1.2.3`,
// so we pass `allowRef = true`.
if err := trustedBuilderID.MatchesLoose(*expectedBuilderID, true); err != nil {
return nil, false, fmt.Errorf("%w: %v", serrors.ErrorUntrustedReusableWorkflow, err)
}
return trustedBuilderID, false, nil
}
func isTrustedDelegatorBuilder(certBuilder *utils.TrustedBuilderID, trustedBuilders map[string]bool) bool {
for byobBuilder := range defaultBYOBReusableWorkflows {
// Check that the certificate builder is a BYOB workflow.
if err := certBuilder.MatchesLoose(byobBuilder, true); err == nil {
// We found a delegator workflow that matches the certificate identity.
// Check that the BYOB builder is trusted by the caller.
if _, ok := trustedBuilders[byobBuilder]; !ok {
return false
}
return true
}
}
return false
}
// Only allow `@refs/heads/main` for the builder and the e2e tests that need to work at HEAD.
// This lets us use the pre-build builder binary generated during release (release happen at main).
// For other projects, we only allow semantic versions that map to a release.
func verifyTrustedBuilderRef(id *WorkflowIdentity, ref string) error {
if (id.SourceRepository == trustedBuilderRepository ||
id.SourceRepository == e2eTestRepository) &&
options.TestingEnabled() {
// // Allow verification on the main branch to support e2e tests.
if ref == "refs/heads/main" || strings.HasPrefix(ref, "refs/heads/ramonpetgrave64") {
return nil
}
return utils.IsValidBuilderTag(ref, true)
}
return utils.IsValidBuilderTag(ref, false)
}
func getExtension(cert *x509.Certificate, oid asn1.ObjectIdentifier, encoded bool) (string, error) {
for _, ext := range cert.Extensions {
if !ext.Id.Equal(oid) {
continue
}
if !encoded {
return string(ext.Value), nil
}
// Decode first.
var decoded string
rest, err := asn1.Unmarshal(ext.Value, &decoded)
if err != nil {
return "", fmt.Errorf("%w", err)
}
if len(rest) != 0 {
return "", fmt.Errorf("decoding has rest for oid %v", oid)
}
return decoded, nil
}
return "", nil
}
type Hosted int
const (
HostedSelf Hosted = iota
HostedGitHub
)
// WorkflowIdentity is a identity captured from a Fulcio certificate.
// See https://github.com/sigstore/fulcio/blob/main/docs/oid-info.md.
type WorkflowIdentity struct {
// The source repository
SourceRepository string
// The commit SHA where the workflow was triggered.
SourceSha1 string
// Ref of the source.
SourceRef *string
// ID of the source repository.
SourceID *string
// Source owner ID of repository.
SourceOwnerID *string
// Workflow path OIDC subject - ref of reuseable workflow or trigger workflow.
SubjectWorkflow *url.URL
// Subject commit sha1.
SubjectSha1 *string
// Hosted status of the subject.
SubjectHosted *Hosted
// BuildTrigger
BuildTrigger string
// Build config path, i.e. the trigger workflow.
BuildConfigPath *string
// Run ID
RunID *string
// Issuer
Issuer string
}
// SubjectWorkflowName returns the subject workflow without the git ref.
func (id *WorkflowIdentity) SubjectWorkflowName() string {
// NOTE: You should be able to copy a net.URL struct safely.
// See: https://github.com/golang/go/issues/38351
withoutRef := *id.SubjectWorkflow
withoutRef.Path = id.SubjectWorkflowPath()
return withoutRef.String()
}
// SubjectWorkflowPath returns the subject workflow without the server url.
func (id *WorkflowIdentity) SubjectWorkflowPath() string {
i := strings.LastIndex(id.SubjectWorkflow.Path, "@")
if i == -1 {
return id.SubjectWorkflow.Path
}
return id.SubjectWorkflow.Path[:i]
}
// SubjectWorkflowRef returns the ref for the subject workflow.
func (id *WorkflowIdentity) SubjectWorkflowRef() string {
i := strings.LastIndex(id.SubjectWorkflow.Path, "@")
if i == -1 {
return ""
}
return id.SubjectWorkflow.Path[i+1:]
}
func getHosted(cert *x509.Certificate) (*Hosted, error) {
runnerEnv, err := getExtension(cert, fulcio.OIDRunnerEnvironment, true)
if err != nil {
return nil, err
}
if runnerEnv == "github-hosted" {
r := HostedGitHub
return &r, nil
}
if runnerEnv == "self-hosted" {
r := HostedSelf
return &r, nil
}
return nil, nil
}
func validateClaimsEqual(deprecated, existing string) error {
if deprecated != "" && existing != "" && deprecated != existing {
return fmt.Errorf("%w: '%v' != '%v'", serrors.ErrorInvalidFormat, deprecated, existing)
}
if deprecated == "" && existing == "" {
return fmt.Errorf("%w: claims are empty", serrors.ErrorInvalidFormat)
}
return nil
}
func getAndValidateEqualClaims(cert *x509.Certificate, deprecatedOid, oid asn1.ObjectIdentifier) (string, error) {
deprecatedValue, err := getExtension(cert, deprecatedOid, false)
if err != nil {
return "", err
}
value, err := getExtension(cert, oid, true)
if err != nil {
return "", err
}
if err := validateClaimsEqual(deprecatedValue, value); err != nil {
return "", err
}
// New certificates.
if value != "" {
return value, nil
}
// Old certificates.
if deprecatedValue != "" {
return deprecatedValue, nil
}
// Both values are empty.
return "", fmt.Errorf("%w: empty fields %v and %v", serrors.ErrorInvalidCertificate,
deprecatedOid, oid)
}
// GetWorkflowFromCertificate gets the workflow identity from the Fulcio authenticated content.
// See https://github.com/sigstore/fulcio/blob/e763d76e3f7786b52db4b27ab87dc446da24895a/pkg/certificate/extensions.go.
// https://github.com/golangci/golangci-lint/issues/741#issuecomment-784171870.
//
//nolint:staticcheck // we want to disable SA1019 only to use deprecated methods but there is a bug in golangci-lint.
func GetWorkflowInfoFromCertificate(cert *x509.Certificate) (*WorkflowIdentity, error) {
if len(cert.URIs) == 0 {
return nil, fmt.Errorf("%w: missing URI information from certificate", serrors.ErrorInvalidFormat)
}
// 1.3.6.1.4.1.57264.1.2: DEPRECATED.
// https://github.com/sigstore/fulcio/blob/main/docs/oid-info.md#1361415726412--github-workflow-BuildTrigger-deprecated
// 1.3.6.1.4.1.57264.1.20 | Build Trigger
// https://github.com/sigstore/fulcio/blob/main/docs/oid-info.md#13614157264120--build-trigger
buildTrigger, err := getAndValidateEqualClaims(cert, fulcio.OIDGitHubWorkflowTrigger, fulcio.OIDBuildTrigger)
if err != nil {
return nil, err
}
// 1.3.6.1.4.1.57264.1.3: DEPRECATED.
// https://github.com/sigstore/fulcio/blob/main/docs/oid-info.md#1361415726413--github-workflow-sha-deprecated
// 1.3.6.1.4.1.57264.1.13 | Source Repository Digest
// https://github.com/sigstore/fulcio/blob/main/docs/oid-info.md#13614157264113--source-repository-digest
sourceSha1, err := getAndValidateEqualClaims(cert, fulcio.OIDGitHubWorkflowSHA, fulcio.OIDSourceRepositoryDigest)
if err != nil {
return nil, err
}
// 1.3.6.1.4.1.57264.1.19 | Build Config Digest
// https://github.com/sigstore/fulcio/blob/main/docs/oid-info.md#13614157264119--build-config-digest
buildConfigSha1, err := getExtension(cert, fulcio.OIDBuildConfigDigest, true)
if err != nil {
return nil, err
}
if err := validateClaimsEqual(sourceSha1, buildConfigSha1); err != nil {
return nil, err
}
// IssuerV1: 1.3.6.1.4.1.57264.1.1
// https://github.com/sigstore/fulcio/blob/main/docs/oid-info.md#1361415726411--issuer
// IssuerV2: 1.3.6.1.4.1.57264.1.8
// https://github.com/sigstore/fulcio/blob/main/docs/oid-info.md#1361415726418--issuer-v2
issuer, err := getAndValidateEqualClaims(cert, fulcio.OIDIssuer, fulcio.OIDIssuerV2)
if err != nil {
return nil, err
}
// 1.3.6.1.4.1.57264.1.5: DEPRECATED.
// https://github.com/sigstore/fulcio/blob/main/docs/oid-info.md#1361415726415--github-workflow-repository-deprecated
deprecatedSourceRepository, err := getExtension(cert, fulcio.OIDGitHubWorkflowRepository, false)
if err != nil {
return nil, err
}
// 1.3.6.1.4.1.57264.1.12 | Source Repository URI
// https://github.com/sigstore/fulcio/blob/main/docs/oid-info.md#13614157264112--source-repository-uri
sourceURI, err := getExtension(cert, fulcio.OIDSourceRepositoryURI, true)
if err != nil {
return nil, err
}
if deprecatedSourceRepository != "" && sourceURI != "" &&
httpsGithubCom+deprecatedSourceRepository != sourceURI {
return nil, fmt.Errorf("%w: '%v' != '%v'",
serrors.ErrorInvalidFormat, httpsGithubCom+deprecatedSourceRepository, sourceURI)
}
sourceRepository := strings.TrimPrefix(sourceURI, httpsGithubCom)
// Handle old certifcates.
if sourceRepository == "" {
sourceRepository = deprecatedSourceRepository
}
// 1.3.6.1.4.1.57264.1.10 | Build Signer Digest
// https://github.com/sigstore/fulcio/blob/main/docs/oid-info.md#13614157264110--build-signer-digest
subjectSha1, err := getExtension(cert, fulcio.OIDBuildSignerDigest, true)
if err != nil {
return nil, err
}
// 1.3.6.1.4.1.57264.1.11 | Runner Environment
// https://github.com/sigstore/fulcio/blob/main/docs/oid-info.md#13614157264111--runner-environment
subjectHosted, err := getHosted(cert)
if err != nil {
return nil, err
}
// 1.3.6.1.4.1.57264.1.14 | Source Repository Ref
// https://github.com/sigstore/fulcio/blob/main/docs/oid-info.md#13614157264114--source-repository-ref
sourceRef, err := getExtension(cert, fulcio.OIDSourceRepositoryRef, true)
if err != nil {
return nil, err
}
// 1.3.6.1.4.1.57264.1.15 | Source Repository Identifier
// https://github.com/sigstore/fulcio/blob/main/docs/oid-info.md#13614157264115--source-repository-identifier
sourceID, err := getExtension(cert, fulcio.OIDSourceRepositoryIdentifier, true)
if err != nil {
return nil, err
}
// 1.3.6.1.4.1.57264.1.17 | Source Repository Owner Identifier
// https://github.com/sigstore/fulcio/blob/main/docs/oid-info.md#13614157264117--source-repository-owner-identifier
sourceOwnerID, err := getExtension(cert, fulcio.OIDSourceRepositoryOwnerIdentifier, true)
if err != nil {
return nil, err
}
// 1.3.6.1.4.1.57264.1.18 | Build Config URI
// https://github.com/sigstore/fulcio/blob/main/docs/oid-info.md#13614157264118--build-config-uri
var buildConfigPath string
buildConfigURI, err := getExtension(cert, fulcio.OIDBuildConfigURI, true)
if err != nil {
return nil, err
}
if buildConfigURI != "" {
parts := strings.Split(buildConfigURI, "@")
if len(parts) != 2 {
return nil, fmt.Errorf("%w: %v",
serrors.ErrorInvalidFormat, buildConfigURI)
}
prefix := fmt.Sprintf("https://github.com/%v/", sourceRepository)
if !strings.HasPrefix(parts[0], prefix) {
return nil, fmt.Errorf("%w: prefix: %v",
serrors.ErrorInvalidFormat, parts[0])
}
buildConfigPath = strings.TrimPrefix(parts[0], prefix)
}
// 1.3.6.1.4.1.57264.1.21 | Run Invocation URI
// https://github.com/sigstore/fulcio/blob/main/docs/oid-info.md#13614157264121--run-invocation-uri
runURI, err := getExtension(cert, fulcio.OIDRunInvocationURI, true)
if err != nil {
return nil, err
}
runID := strings.TrimPrefix(runURI, fmt.Sprintf("https://github.com/%s/actions/runs/", sourceRepository))
// Subject path.
if !strings.HasPrefix(cert.URIs[0].Path, "/") {
return nil, fmt.Errorf("%w: %s", serrors.ErrorInvalidFormat, cert.URIs[0].Path)
}
subjectWorkflow := cert.URIs[0]
var pSubjectSha1, pSourceID, pSourceRef, pSourceOwnerID, pBuildConfigPath, pRunID *string
if subjectSha1 != "" {
pSubjectSha1 = &subjectSha1
}
if sourceID != "" {
pSourceID = &sourceID
}
if sourceRef != "" {
pSourceRef = &sourceRef
}
if sourceOwnerID != "" {
pSourceOwnerID = &sourceOwnerID
}
if buildConfigPath != "" {
pBuildConfigPath = &buildConfigPath
}
if runID != "" {
pRunID = &runID
}
return &WorkflowIdentity{
// Issuer.
Issuer: issuer,
// Subject
SubjectWorkflow: subjectWorkflow,
SubjectSha1: pSubjectSha1,
SubjectHosted: subjectHosted,
// Source.
SourceRepository: sourceRepository,
SourceSha1: sourceSha1,
SourceRef: pSourceRef,
SourceID: pSourceID,
SourceOwnerID: pSourceOwnerID,
// Build.
BuildTrigger: buildTrigger,
BuildConfigPath: pBuildConfigPath,
// Other.
RunID: pRunID,
}, nil
}