diff --git a/build/opt.go b/build/opt.go index ed9ca5a02ed3..d61533c51beb 100644 --- a/build/opt.go +++ b/build/opt.go @@ -65,6 +65,30 @@ var sendGitQueryAsInput = sync.OnceValue(func() bool { return false }) +// defaultPolicyEnabled reports whether the builtin default source policy is +// enabled via the BUILDX_DEFAULT_POLICY environment variable. It is opt-in +// for now; a future release may flip the default to on. +var defaultPolicyEnabled = sync.OnceValue(func() bool { + if v, ok := os.LookupEnv("BUILDX_DEFAULT_POLICY"); ok { + if vv, err := strconv.ParseBool(v); err == nil { + return vv + } + } + return false +}) + +// policyExplicitlyDisabled reports whether the user passed `--policy +// disabled=true`, which suppresses both user-defined and builtin default +// policies. +func policyExplicitlyDisabled(configs []buildflags.PolicyConfig) bool { + for _, cfg := range configs { + if cfg.Disabled { + return true + } + } + return false +} + type policyProgressLogger struct { ch chan *client.SolveStatus done chan struct{} @@ -624,6 +648,22 @@ func configureSourcePolicy(ctx context.Context, np *noderesolver.ResolvedNode, o if err != nil { return nil, err } + + // Prepend the builtin default policy when enabled and not explicitly + // disabled. The default policy verifies trust for Docker-managed images + // (docker/dockerfile, docker/dockerfile-upstream) that may be implicitly + // loaded during a build, and passes through any other source so user + // policies retain full control. + if defaultPolicyEnabled() && !policyExplicitlyDisabled(opt.Policy) { + builtin := policyOpt{ + Files: []policyFileSpec{{ + Filename: policy.DefaultPolicyFilename, + Data: policy.DefaultPolicyData(), + }}, + } + popts = append([]policyOpt{builtin}, popts...) + } + if len(popts) == 0 { so.SourcePolicyProvider = nil return nil, nil diff --git a/policy/default.go b/policy/default.go new file mode 100644 index 000000000000..188969cb6e16 --- /dev/null +++ b/policy/default.go @@ -0,0 +1,17 @@ +package policy + +import ( + _ "embed" +) + +// DefaultPolicyFilename is the synthetic filename used for the embedded +// default policy when it is loaded as a regular policy file. +const DefaultPolicyFilename = "buildx_default_policy.rego" + +//go:embed default.rego +var defaultPolicyModule []byte + +// DefaultPolicyData returns the embedded default policy module bytes. +func DefaultPolicyData() []byte { + return defaultPolicyModule +} diff --git a/policy/default.rego b/policy/default.rego new file mode 100644 index 000000000000..2e89c1bab554 --- /dev/null +++ b/policy/default.rego @@ -0,0 +1,162 @@ +package docker + +# Default policy embedded in Buildx. It verifies trust for images shipped +# by Docker that may be implicitly loaded during a build: +# +# - docker/dockerfile +# - docker/dockerfile-upstream +# - docker/buildkit-syft-scanner +# +# Any image outside this managed set is allowed and passes through to user +# policies unchanged. Access by digest is always allowed. For tag-based +# access the rules below enforce a signed release from the expected GitHub +# source repository using the existing docker_github_builder_signature +# helper from builtins.rego. + +is_dockerfile if { + input.image + input.image.fullRepo == "docker.io/docker/dockerfile" +} + +is_dockerfile if { + input.image + input.image.fullRepo == "docker.io/docker/dockerfile-upstream" +} + +is_syft_scanner if { + input.image + input.image.fullRepo == "docker.io/docker/buildkit-syft-scanner" +} + +dockerfile_floating_tag(tag) if tag == "latest" +dockerfile_floating_tag(tag) if tag == "labs" +dockerfile_floating_tag(tag) if tag == "master" + +dockerfile_tag_requires_sig(tag) if dockerfile_floating_tag(tag) +dockerfile_tag_requires_sig(tag) if version_tag_ge(tag, 1, 21) + +syft_scanner_floating_tag(tag) if tag == "latest" + +syft_scanner_tag_requires_sig(tag) if syft_scanner_floating_tag(tag) +syft_scanner_tag_requires_sig(tag) if version_tag_ge(tag, 1, 10) + + +default_policy_deny_msgs contains msg if { + is_dockerfile + tag := input.image.tag + tag != "" + dockerfile_tag_requires_sig(tag) + not dockerfile_sig_ok(tag) + msg := sprintf("image %s is not allowed by default policy: a verified docker-github-builder signature is required for %s tag", [input.image.ref, input.image.tag]) +} + +default_policy_deny_msgs contains msg if { + is_syft_scanner + tag := input.image.tag + tag != "" + syft_scanner_tag_requires_sig(tag) + not syft_scanner_sig_ok(tag) + msg := sprintf("image %s is not allowed by default policy: a verified docker-github-builder signature is required for %s tag", [input.image.ref, input.image.tag]) +} + +dockerfile_sig_ok(tag) if { + dockerfile_floating_tag(tag) + some sig in input.image.signatures + docker_github_builder_signature(sig, "moby/buildkit") +} + +dockerfile_sig_ok(tag) if { + not dockerfile_floating_tag(tag) + some sig in input.image.signatures + docker_github_builder_signature(sig, "moby/buildkit") + dockerfile_sig_ref_matches(sig, tag) +} + +syft_scanner_sig_ok(tag) if { + syft_scanner_floating_tag(tag) + some sig in input.image.signatures + docker_github_builder_signature(sig, "docker/buildkit-syft-scanner") +} + +syft_scanner_sig_ok(tag) if { + not syft_scanner_floating_tag(tag) + some sig in input.image.signatures + docker_github_builder_signature(sig, "docker/buildkit-syft-scanner") + syft_scanner_sig_ref_matches(sig, tag) +} + + +decision := { + "allow": count(default_policy_deny_msgs) == 0, + "deny_msg": [msg | some msg in default_policy_deny_msgs], +} + +# ---- helpers ---- + +# parse_version returns [major, minor] when tag matches a version pattern +# like "1", "1.21", "1.21.0", "1.21.0-labs". For a major-only tag such as +# "1", the minor component is treated as effectively unbounded so floating +# major tags are handled like the newest release in that major line. +parse_version(tag) := [maj, min] if { + m := regex.find_all_string_submatch_n(`^(\d+)\.(\d+)(?:\.\d+)?(?:-labs)?$`, tag, 1) + count(m) == 1 + maj := to_number(m[0][1]) + min := to_number(m[0][2]) +} + +parse_version(tag) := [maj, 999999] if { + m := regex.find_all_string_submatch_n(`^(\d+)(?:-labs)?$`, tag, 1) + count(m) == 1 + maj := to_number(m[0][1]) +} + +version_tag_ge(tag, target_major, _) if { + v := parse_version(tag) + v[0] > target_major +} + +version_tag_ge(tag, target_major, target_minor) if { + v := parse_version(tag) + v[0] == target_major + v[1] >= target_minor +} + +dockerfile_sig_ref_matches(sig, tag) if { + sig_ref_matches(sig.signer.sourceRepositoryRef, tag, "refs/tags/dockerfile/") +} + +syft_scanner_sig_ref_matches(sig, tag) if { + ref := trim_prefix(sig.signer.sourceRepositoryRef, "refs/tags/") + ref != sig.signer.sourceRepositoryRef + version_tag_selector_matches(tag, ref) +} + +sig_ref_matches(ref, tag, prefix) if { + stripped_ref := trim_prefix(ref, prefix) + stripped_ref != ref + tag_labs := endswith(tag, "-labs") + ref_labs := endswith(stripped_ref, "-labs") + tag_labs == ref_labs + version_tag_selector_matches( + trim_suffix(tag, "-labs"), + trim_suffix(stripped_ref, "-labs"), + ) +} + +version_tag_selector_matches(selector, candidate) if { + selector == candidate +} + +version_tag_selector_matches(selector, candidate) if { + m := regex.find_all_string_submatch_n(`^(\d+)\.(\d+)$`, selector, 1) + count(m) == 1 + parse_version(selector) == parse_version(candidate) +} + +version_tag_selector_matches(selector, candidate) if { + m := regex.find_all_string_submatch_n(`^(\d+)$`, selector, 1) + count(m) == 1 + sel := parse_version(selector) + cand := parse_version(candidate) + sel[0] == cand[0] +} diff --git a/policy/default_test.go b/policy/default_test.go new file mode 100644 index 000000000000..749accb04abd --- /dev/null +++ b/policy/default_test.go @@ -0,0 +1,402 @@ +package policy + +import ( + "context" + "testing" + "time" + + gwpb "github.com/moby/buildkit/frontend/gateway/pb" + "github.com/moby/buildkit/solver/pb" + moby_buildkit_v1_sourcepolicy "github.com/moby/buildkit/sourcepolicy/pb" + "github.com/moby/buildkit/sourcepolicy/policysession" + policyimage "github.com/moby/policy-helpers/image" + policytypes "github.com/moby/policy-helpers/types" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/sigstore/sigstore-go/pkg/fulcio/certificate" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/require" +) + +// makeDefaultPolicy returns a Policy instance backed by the embedded +// default policy module, optionally wired to a mock signature verifier +// returning the supplied SignatureInfo for image attestations. +func makeDefaultPolicy(t *testing.T, sigInfo *policytypes.SignatureInfo) *Policy { + t.Helper() + + var verifierProvider PolicyVerifierProvider + if sigInfo != nil { + verifierProvider = func() (PolicyVerifier, error) { + return &mockPolicyVerifier{ + verifyImage: func(_ context.Context, _ policyimage.ReferrersProvider, _ ocispecs.Descriptor, _ *ocispecs.Platform) (*policytypes.SignatureInfo, error) { + return sigInfo, nil + }, + }, nil + } + } + return NewPolicy(Opt{ + Files: []File{{ + Filename: DefaultPolicyFilename, + Data: DefaultPolicyData(), + }}, + Log: func(level logrus.Level, msg string) { + t.Logf("[%s] %s", level, msg) + }, + VerifierProvider: verifierProvider, + }) +} + +// dockerGithubBuilderSig returns a SignatureInfo that satisfies the +// docker_github_builder_signature helper for the given source repository +// and ref. Pass an empty ref to omit the SourceRepositoryRef field. +func dockerGithubBuilderSig(sourceRepo, sourceRef string) *policytypes.SignatureInfo { + return &policytypes.SignatureInfo{ + Kind: policytypes.KindDockerGithubBuilder, + SignatureType: policytypes.SignatureBundleV03, + Timestamps: []policytypes.TimestampVerificationResult{ + {Type: "rekor", URI: "https://rekor.sigstore.dev", Timestamp: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)}, + }, + Signer: &certificate.Summary{ + CertificateIssuer: "CN=sigstore-intermediate,O=sigstore.dev", + Extensions: certificate.Extensions{ + Issuer: "https://token.actions.githubusercontent.com", + SourceRepositoryURI: "https://github.com/" + sourceRepo, + SourceRepositoryRef: sourceRef, + RunnerEnvironment: "github-hosted", + }, + }, + } +} + +// runDefaultPolicyImage evaluates the default policy against the given image +// reference. An attestation chain and an empty image config are always +// supplied so that the policy can fully resolve metadata and produce a +// decision (rather than requesting more data via the next response). +func runDefaultPolicyImage(t *testing.T, p *Policy, ref string) *policysession.DecisionResponse { + t.Helper() + src := &gwpb.ResolveSourceMetaResponse{ + Source: &pb.SourceOp{Identifier: "docker-image://" + ref}, + Image: &gwpb.ResolveSourceImageResponse{ + Digest: "sha256:abababababababababababababababababababababababababababababababab", + Config: []byte(`{"created":"2024-01-01T00:00:00Z","config":{}}`), + AttestationChain: newTestAttestationChain(t), + }, + } + resp, _, err := p.CheckPolicy(context.Background(), &policysession.CheckPolicyRequest{ + Platform: &pb.Platform{OS: "linux", Architecture: "amd64"}, + Source: src, + }) + require.NoError(t, err) + require.NotNil(t, resp) + return resp +} + +func TestDefaultPolicyAllowsNonImageSources(t *testing.T) { + p := makeDefaultPolicy(t, nil) + src := &gwpb.ResolveSourceMetaResponse{ + Source: &pb.SourceOp{Identifier: "https://example.com/foo.tar.gz"}, + HTTP: &gwpb.ResolveSourceHTTPResponse{ + Checksum: "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + }, + } + resp, _, err := p.CheckPolicy(context.Background(), &policysession.CheckPolicyRequest{ + Source: src, + }) + require.NoError(t, err) + require.Equal(t, moby_buildkit_v1_sourcepolicy.PolicyAction_ALLOW, resp.Action) +} + +func TestDefaultPolicyImages(t *testing.T) { + testCases := []struct { + name string + sig *policytypes.SignatureInfo + ref string + allow bool + denyMsg string + }{ + { + name: "allows_unrelated_images", + ref: "alpine:latest", + allow: true, + }, + { + name: "dockerfile_digest_only_always_allowed", + ref: "docker/dockerfile@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + allow: true, + }, + { + name: "dockerfile_tagged_digest_denied", + ref: "docker/dockerfile:1.21.0@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + denyMsg: "signature is required for 1.21.0 tag", + }, + { + name: "dockerfile_old_version_allowed_unsigned", + ref: "docker/dockerfile:1.20.0", + allow: true, + }, + { + name: "dockerfile_new_version_requires_signature", + ref: "docker/dockerfile:1.21.0", + denyMsg: "signature is required for 1.21.0 tag", + }, + { + name: "dockerfile_new_minor_version_requires_signature", + ref: "docker/dockerfile:1.21", + denyMsg: "signature is required for 1.21 tag", + }, + { + name: "dockerfile_new_major_version_requires_signature", + ref: "docker/dockerfile:1", + denyMsg: "signature is required for 1 tag", + }, + { + name: "dockerfile_new_version_allowed_with_matching_signature", + sig: dockerGithubBuilderSig("moby/buildkit", "refs/tags/dockerfile/1.21.0"), + ref: "docker/dockerfile:1.21.0", + allow: true, + }, + { + name: "dockerfile_new_minor_version_allowed_with_matching_patch_signature", + sig: dockerGithubBuilderSig("moby/buildkit", "refs/tags/dockerfile/1.21.0"), + ref: "docker/dockerfile:1.21", + allow: true, + }, + { + name: "dockerfile_new_version_allowed_with_matching_signature_labs", + sig: dockerGithubBuilderSig("moby/buildkit", "refs/tags/dockerfile/1.21.0-labs"), + ref: "docker/dockerfile:1.21.0-labs", + allow: true, + }, + { + name: "dockerfile_new_version_allowed_with_matching_signature_labs2", + sig: dockerGithubBuilderSig("moby/buildkit", "refs/tags/dockerfile/1.21.0-labs"), + ref: "docker/dockerfile:1.21-labs", + allow: true, + }, + { + name: "dockerfile_new_version_allowed_with_matching_signature_labs3", + sig: dockerGithubBuilderSig("moby/buildkit", "refs/tags/dockerfile/1.21.0-labs"), + ref: "docker/dockerfile:1-labs", + allow: true, + }, + { + name: "dockerfile_new_version_allowed_with_matching_signature_labs4", + sig: dockerGithubBuilderSig("moby/buildkit", "refs/tags/dockerfile/1.21.0-labs"), + ref: "docker/dockerfile:labs", + allow: true, + }, + { + name: "dockerfile_new_version_denied_with_nonlabs_signature_for_labs_tag", + sig: dockerGithubBuilderSig("moby/buildkit", "refs/tags/dockerfile/1.21.0"), + ref: "docker/dockerfile:1-labs", + denyMsg: "signature is required for 1-labs tag", + }, + { + name: "dockerfile_new_version_denied_with_mismatched_ref_labs", + sig: dockerGithubBuilderSig("moby/buildkit", "refs/tags/dockerfile/1.22.0-labs"), + ref: "docker/dockerfile:1.21.0-labs", + denyMsg: "signature is required for 1.21.0-labs tag", + }, + { + name: "dockerfile_new_version_denied_with_nonlabs_signature_for_exact_labs_tag", + sig: dockerGithubBuilderSig("moby/buildkit", "refs/tags/dockerfile/1.21.0"), + ref: "docker/dockerfile:1.21.0-labs", + denyMsg: "signature is required for 1.21.0-labs tag", + }, + { + name: "dockerfile_new_version_denied_with_wrong_signature_repo", + sig: dockerGithubBuilderSig("docker/dockerfile", "refs/tags/dockerfile/1.21.0"), + ref: "docker/dockerfile:1.21.0", + denyMsg: "signature is required for 1.21.0 tag", + }, + { + name: "dockerfile_upstream_new_version_denied_with_wrong_signature_repo", + sig: dockerGithubBuilderSig("docker/dockerfile-upstream", "refs/tags/dockerfile/1.21.0"), + ref: "docker/dockerfile-upstream:1.21.0", + denyMsg: "signature is required for 1.21.0 tag", + }, + { + name: "dockerfile_upstream_new_version_requires_signature", + ref: "docker/dockerfile-upstream:1.21.0", + denyMsg: "signature is required for 1.21.0 tag", + }, + { + name: "dockerfile_upstream_new_version_allowed_with_matching_signature", + sig: dockerGithubBuilderSig("moby/buildkit", "refs/tags/dockerfile/1.21.0"), + ref: "docker/dockerfile-upstream:1.21.0", + allow: true, + }, + { + name: "dockerfile_upstream_new_version_denied_with_mismatched_ref", + sig: dockerGithubBuilderSig("moby/buildkit", "refs/tags/dockerfile/1.22.0"), + ref: "docker/dockerfile-upstream:1.21.0", + denyMsg: "signature is required for 1.21.0 tag", + }, + { + name: "dockerfile_new_version_denied_with_mismatched_ref", + sig: dockerGithubBuilderSig("moby/buildkit", "refs/tags/dockerfile/1.22.0"), + ref: "docker/dockerfile:1.21.0", + denyMsg: "signature is required for 1.21.0 tag", + }, + { + name: "dockerfile_new_minor_version_denied_with_newer_patch_ref", + sig: dockerGithubBuilderSig("moby/buildkit", "refs/tags/dockerfile/1.23.0"), + ref: "docker/dockerfile:1.22", + denyMsg: "signature is required for 1.22 tag", + }, + { + name: "dockerfile_new_version_denied_with_mismatched_ref_labs", + sig: dockerGithubBuilderSig("moby/buildkit", "refs/tags/dockerfile/1.22.0-labs"), + ref: "docker/dockerfile:1.21.0-labs", + denyMsg: "signature is required for 1.21.0-labs tag", + }, + { + name: "dockerfile_new_version_denied_with_wrong_signature_tag", + sig: dockerGithubBuilderSig("moby/buildkit", "refs/tags/1.21.0"), + ref: "docker/dockerfile:1.21.0", + denyMsg: "signature is required for 1.21.0 tag", + }, + { + name: "dockerfile_latest_allowed_with_signature_any_ref", + sig: dockerGithubBuilderSig("moby/buildkit", "refs/tags/dockerfile/1.30.0"), + ref: "docker/dockerfile:latest", + allow: true, + }, + { + name: "dockerfile_latest_denied_without_signature", + ref: "docker/dockerfile:latest", + denyMsg: "signature is required for latest tag", + }, + { + name: "dockerfile_upstream_latest_allowed_with_signature_any_ref", + sig: dockerGithubBuilderSig("moby/buildkit", "refs/tags/dockerfile/1.30.0"), + ref: "docker/dockerfile-upstream:latest", + allow: true, + }, + { + name: "dockerfile_upstream_latest_denied_without_signature", + ref: "docker/dockerfile-upstream:latest", + denyMsg: "signature is required for latest tag", + }, + { + name: "dockerfile_upstream_master_allowed_with_signature_any_ref", + sig: dockerGithubBuilderSig("moby/buildkit", "refs/heads/master"), + ref: "docker/dockerfile-upstream:master", + allow: true, + }, + { + name: "dockerfile_upstream_master_denied_without_signature", + ref: "docker/dockerfile-upstream:master", + denyMsg: "signature is required for master tag", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + p := makeDefaultPolicy(t, tc.sig) + resp := runDefaultPolicyImage(t, p, tc.ref) + if tc.allow { + require.Equal(t, moby_buildkit_v1_sourcepolicy.PolicyAction_ALLOW, resp.Action) + require.Empty(t, resp.DenyMessages) + return + } + + require.Equal(t, moby_buildkit_v1_sourcepolicy.PolicyAction_DENY, resp.Action) + require.Len(t, resp.DenyMessages, 1) + require.Contains(t, resp.DenyMessages[0].Message, tc.denyMsg) + }) + } +} + +func TestDefaultPolicySyftScannerImages(t *testing.T) { + testCases := []struct { + name string + sig *policytypes.SignatureInfo + ref string + allow bool + denyMsg string + }{ + { + name: "syft_scanner_old_version_allowed_unsigned", + ref: "docker/buildkit-syft-scanner:1.9.0", + allow: true, + }, + { + name: "syft_scanner_new_version_requires_signature", + ref: "docker/buildkit-syft-scanner:1.10.0", + denyMsg: "signature is required for 1.10.0 tag", + }, + { + name: "syft_scanner_new_minor_version_requires_signature", + ref: "docker/buildkit-syft-scanner:1.10", + denyMsg: "signature is required for 1.10 tag", + }, + { + name: "syft_scanner_new_major_version_requires_signature", + ref: "docker/buildkit-syft-scanner:1", + denyMsg: "signature is required for 1 tag", + }, + { + name: "syft_scanner_new_version_allowed_with_matching_signature", + sig: dockerGithubBuilderSig("docker/buildkit-syft-scanner", "refs/tags/1.10.0"), + ref: "docker/buildkit-syft-scanner:1.10.0", + allow: true, + }, + { + name: "syft_scanner_new_minor_version_allowed_with_matching_patch_signature", + sig: dockerGithubBuilderSig("docker/buildkit-syft-scanner", "refs/tags/1.10.0"), + ref: "docker/buildkit-syft-scanner:1.10", + allow: true, + }, + { + name: "syft_scanner_new_major_version_allowed_with_matching_minor_signature", + sig: dockerGithubBuilderSig("docker/buildkit-syft-scanner", "refs/tags/1.10.0"), + ref: "docker/buildkit-syft-scanner:1", + allow: true, + }, + { + name: "syft_scanner_new_version_denied_with_wrong_signature_repo", + sig: dockerGithubBuilderSig("moby/buildkit", "refs/tags/1.10.0"), + ref: "docker/buildkit-syft-scanner:1.10.0", + denyMsg: "signature is required for 1.10.0 tag", + }, + { + name: "syft_scanner_new_version_denied_with_mismatched_ref", + sig: dockerGithubBuilderSig("docker/buildkit-syft-scanner", "refs/tags/1.11.0"), + ref: "docker/buildkit-syft-scanner:1.10.0", + denyMsg: "signature is required for 1.10.0 tag", + }, + { + name: "syft_scanner_new_minor_version_denied_with_newer_patch_ref", + sig: dockerGithubBuilderSig("docker/buildkit-syft-scanner", "refs/tags/1.11.0"), + ref: "docker/buildkit-syft-scanner:1.10", + denyMsg: "signature is required for 1.10 tag", + }, + { + name: "syft_scanner_latest_allowed_with_signature_any_ref", + sig: dockerGithubBuilderSig("docker/buildkit-syft-scanner", "refs/tags/1.10.0"), + ref: "docker/buildkit-syft-scanner:latest", + allow: true, + }, + { + name: "syft_scanner_latest_denied_without_signature", + ref: "docker/buildkit-syft-scanner:latest", + denyMsg: "signature is required for latest tag", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + p := makeDefaultPolicy(t, tc.sig) + resp := runDefaultPolicyImage(t, p, tc.ref) + if tc.allow { + require.Equal(t, moby_buildkit_v1_sourcepolicy.PolicyAction_ALLOW, resp.Action) + require.Empty(t, resp.DenyMessages) + return + } + + require.Equal(t, moby_buildkit_v1_sourcepolicy.PolicyAction_DENY, resp.Action) + require.Len(t, resp.DenyMessages, 1) + require.Contains(t, resp.DenyMessages[0].Message, tc.denyMsg) + }) + } +}