diff --git a/app/controlplane/extensions/core/smtp/v1/README.md b/app/controlplane/extensions/core/smtp/v1/README.md new file mode 100644 index 000000000..caf66ea3c --- /dev/null +++ b/app/controlplane/extensions/core/smtp/v1/README.md @@ -0,0 +1,26 @@ +# SMTP Fan-out Extension + +With this extension, you can send an email for every workflow run and attestation. + +## How to use it + +In the following example, we will use the AWS SES service. + +1. To get started, you need to register the extension in your Chainloop organization. +``` +chainloop integration registered add smtp --options user=AHDHSYEE7e73,password=kjsdfda8asd****,host=email-smtp.us-east-1.amazonaws.com,port=587,to=platform-team@example.com,from=notifier@example.com +``` + +2. When attaching the integration to your workflow, you have the option to specify CC: + +``` +chainloop workflow integration attach --workflow $WID --integration $IID --options cc=security@example.com +``` + +cc is optional: + +``` +chainloop workflow integration attach --workflow $WID --integration $IID +``` + +Starting now, every time a workflow run occurs, an email notification will be sent containing the details of the run and attestation. \ No newline at end of file diff --git a/app/controlplane/extensions/core/smtp/v1/extension.go b/app/controlplane/extensions/core/smtp/v1/extension.go new file mode 100644 index 000000000..2bda34ba6 --- /dev/null +++ b/app/controlplane/extensions/core/smtp/v1/extension.go @@ -0,0 +1,229 @@ +// +// Copyright 2023 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package smtp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + nsmtp "net/smtp" + + "github.com/chainloop-dev/chainloop/app/controlplane/extensions/sdk/v1" + "github.com/go-kratos/kratos/v2/log" + "github.com/in-toto/in-toto-golang/in_toto" +) + +type Integration struct { + *sdk.FanOutIntegration +} + +type registrationRequest struct { + To string `json:"to" jsonschema:"format=email,description=The email address to send the email to."` + From string `json:"from" jsonschema:"format=email,description=The email address of the sender."` + User string `json:"user" jsonschema:"minLength=1,description=The username to use for the SMTP authentication."` + Password string `json:"password" jsonschema:"description=The password to use for the SMTP authentication."` + Host string `json:"host" jsonschema:"description=The host to use for the SMTP authentication."` + // TODO: Make the port an integer + Port string `json:"port" jsonschema:"description=The port to use for the SMTP authentication"` +} + +type registrationState struct { + To string `json:"to"` + From string `json:"from"` + User string `json:"user"` + Host string `json:"host"` + Port string `json:"port"` +} + +type attachmentRequest struct { + CC string `json:"cc,omitempty" jsonschema:"format=email,description=The email address of the carbon copy recipient."` +} + +type attachmentState struct { + CC string `json:"cc"` +} + +func New(l log.Logger) (sdk.FanOut, error) { + base, err := sdk.NewFanOut( + &sdk.NewParams{ + ID: "smtp", + Version: "0.1", + Logger: l, + InputSchema: &sdk.InputSchema{ + Registration: registrationRequest{}, + Attachment: attachmentRequest{}, + }, + }, + sdk.WithEnvelope(), + ) + + if err != nil { + return nil, err + } + + return &Integration{base}, nil +} + +// Register is executed when a operator wants to register a specific instance of this integration with their Chainloop organization +func (i *Integration) Register(_ context.Context, req *sdk.RegistrationRequest) (*sdk.RegistrationResponse, error) { + i.Logger.Info("registration requested") + + // Unmarshal the request + var request *registrationRequest + if err := sdk.FromConfig(req.Payload, &request); err != nil { + return nil, fmt.Errorf("invalid registration request: %w", err) + } + + to, from, user, password, host, port := request.To, request.From, request.User, request.Password, request.Host, request.Port + + // validate and notify + subject := "[chainloop] New SMTP integration added!" + tpl := ` + We successfully registered a new SMTP integration in your Chainloop organization. + + Extension: %s version: %s + - Host: %s + - Port: %s + - User: %s + - From: %s + - To: %s + ` + body := fmt.Sprintf(tpl, i.Describe().ID, i.Describe().Version, host, port, user, from, to) + err := sendEmail(host, port, user, password, from, to, "", subject, body) + if err != nil { + return nil, fmt.Errorf("sending an email: %w", err) + } + + response := &sdk.RegistrationResponse{} + rawConfig, err := sdk.ToConfig(®istrationState{To: to, From: from, User: user, Host: host, Port: port}) + if err != nil { + return nil, fmt.Errorf("marshalling configuration: %w", err) + } + response.Configuration = rawConfig + response.Credentials = &sdk.Credentials{Password: password} + + return response, nil +} + +// Attachment is executed when to attach a registered instance of this integration to a specific workflow +func (i *Integration) Attach(_ context.Context, req *sdk.AttachmentRequest) (*sdk.AttachmentResponse, error) { + i.Logger.Info("attachment requested") + + // Parse the request that has already been validated against the input schema + var request *attachmentRequest + if err := sdk.FromConfig(req.Payload, &request); err != nil { + return nil, fmt.Errorf("invalid attachment request: %w", err) + } + + response := &sdk.AttachmentResponse{} + rawConfig, err := sdk.ToConfig(&attachmentState{CC: request.CC}) + if err != nil { + return nil, fmt.Errorf("marshalling configuration: %w", err) + } + response.Configuration = rawConfig + + return response, nil +} + +// Send the SBOM to the configured Dependency Track instance +func (i *Integration) Execute(_ context.Context, req *sdk.ExecutionRequest) error { + i.Logger.Info("execution requested") + + if err := validateExecuteRequest(req); err != nil { + return fmt.Errorf("running validation for workflow id %s: %w", req.WorkflowID, err) + } + + var rc *registrationState + if err := sdk.FromConfig(req.RegistrationInfo.Configuration, &rc); err != nil { + return errors.New("invalid registration configuration") + } + + var ac *attachmentState + if err := sdk.FromConfig(req.AttachmentInfo.Configuration, &ac); err != nil { + return errors.New("invalid attachment configuration") + } + + // get the attestation + decodedPayload, err := req.Input.DSSEnvelope.DecodeB64Payload() + if err != nil { + return err + } + statement := &in_toto.Statement{} + if err := json.Unmarshal(decodedPayload, statement); err != nil { + return fmt.Errorf("un-marshaling predicate: %w", err) + } + jsonBytes, err := json.MarshalIndent(statement, "", " ") + if err != nil { + return fmt.Errorf("error marshaling JSON: %w", err) + } + + // send the email + to, from, user, password, host, port := rc.To, rc.From, rc.User, req.RegistrationInfo.Credentials.Password, rc.Host, rc.Port + subject := "[chainloop] New workflow run finished successfully!" + tpl := `A new workflow run finished successfully! + +# Workflow: %s + +# in-toto statement: + %s + +This email has been delivered via integration %s version %s. + ` + body := fmt.Sprintf(tpl, req.WorkflowID, jsonBytes, i.Describe().ID, i.Describe().Version) + err = sendEmail(host, port, user, password, from, to, ac.CC, subject, body) + if err != nil { + return fmt.Errorf("sending an email: %w", err) + } + + return nil +} + +func validateExecuteRequest(req *sdk.ExecutionRequest) error { + if req == nil || req.Input == nil || req.Input.DSSEnvelope == nil { + return errors.New("invalid input") + } + + if req.RegistrationInfo == nil || req.RegistrationInfo.Configuration == nil { + return errors.New("missing registration configuration") + } + + if req.RegistrationInfo.Credentials == nil { + return errors.New("missing credentials") + } + + if req.AttachmentInfo == nil || req.AttachmentInfo.Configuration == nil { + return errors.New("missing attachment configuration") + } + + return nil +} + +func sendEmail(host string, port string, user, password, from, to, cc, subject, body string) error { + message := "From: " + from + "\n" + + "To: " + to + "\n" + + "CC: " + cc + "\n" + + "Subject: " + subject + "\n\n" + + body + + auth := nsmtp.PlainAuth("", user, password, host) + err := nsmtp.SendMail(host+":"+port, auth, from, []string{to}, []byte(message)) + if err != nil { + return fmt.Errorf("error sending email: %w", err) + } + + return nil +} diff --git a/app/controlplane/extensions/core/smtp/v1/extension_test.go b/app/controlplane/extensions/core/smtp/v1/extension_test.go new file mode 100644 index 000000000..7b613139e --- /dev/null +++ b/app/controlplane/extensions/core/smtp/v1/extension_test.go @@ -0,0 +1,106 @@ +// +// Copyright 2023 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package smtp + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateRegistrationInput(t *testing.T) { + testCases := []struct { + name string + input map[string]interface{} + errMsg string + }{ + { + name: "missing properties", + input: map[string]interface{}{}, + errMsg: "missing properties: 'to', 'from', 'user'", + }, + { + name: "valid request", + input: map[string]interface{}{"from": "test@example.com", "to": "test@example.com", "host": "smtp.service.example.com", "port": "25", "user": "test", "password": "test"}, + }, + { + name: "invalid email", + input: map[string]interface{}{"from": "testexample.com", "to": "test@example.com", "host": "smtp.service.example.com", "port": "25", "user": "test", "password": "test"}, + errMsg: "is not valid 'email'", + }, + } + + integration, err := New(nil) + require.NoError(t, err) + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + payload, err := json.Marshal(tc.input) + require.NoError(t, err) + err = integration.ValidateRegistrationRequest(payload) + if tc.errMsg != "" { + assert.ErrorContains(t, err, tc.errMsg) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestValidateAttachmentInput(t *testing.T) { + testCases := []struct { + name string + input map[string]interface{} + errMsg string + }{ + { + name: "valid with no optional cc", + input: map[string]interface{}{}, + }, + { + name: "valid cc format", + input: map[string]interface{}{"cc": "test@example.com"}, + }, + { + name: "invalid cc format", + input: map[string]interface{}{"cc": "testexample.com"}, + errMsg: "is not valid 'email'", + }, + } + + integration, err := New(nil) + require.NoError(t, err) + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + payload, err := json.Marshal(tc.input) + require.NoError(t, err) + err = integration.ValidateAttachmentRequest(payload) + if tc.errMsg != "" { + assert.ErrorContains(t, err, tc.errMsg) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestNewIntegration(t *testing.T) { + _, err := New(nil) + assert.NoError(t, err) +} diff --git a/app/controlplane/extensions/extensions.go b/app/controlplane/extensions/extensions.go index 90697481b..cc4e2b166 100644 --- a/app/controlplane/extensions/extensions.go +++ b/app/controlplane/extensions/extensions.go @@ -17,6 +17,7 @@ package extensions import ( "github.com/chainloop-dev/chainloop/app/controlplane/extensions/core/dependencytrack/v1" + "github.com/chainloop-dev/chainloop/app/controlplane/extensions/core/smtp/v1" "github.com/chainloop-dev/chainloop/app/controlplane/extensions/sdk/v1" "github.com/chainloop-dev/chainloop/internal/servicelogger" "github.com/go-kratos/kratos/v2/log" @@ -32,6 +33,7 @@ func Load(l log.Logger) (sdk.AvailableExtensions, error) { // Eventually this will be dynamically loaded from a directory toEnable := []sdk.FanOutFactory{ dependencytrack.New, + smtp.New, } // Initialize and load the extensions