diff --git a/cfg.yaml b/cfg.yaml index 846c5d5c..ebe1ff00 100644 --- a/cfg.yaml +++ b/cfg.yaml @@ -123,6 +123,6 @@ outputs: enable: false user: # Mandatory. E.g :johndoe@gmail.com" password: # Mandatory. Specify user API key - instance: # Mandatory. Name of ServiceN ow Instance + url: # Mandatory. ServiceNow instance URL (e.g. https://ven05031.service-now.com/ or https://fsadev.servicenowservices.com) board: # Specify the ServiceNow board name to open tickets on. Default is "incident" diff --git a/deploy/kubernetes/postee.yaml b/deploy/kubernetes/postee.yaml index 8977ce12..ea52d865 100644 --- a/deploy/kubernetes/postee.yaml +++ b/deploy/kubernetes/postee.yaml @@ -25,7 +25,7 @@ data: enable: false user: xxxxxxxxx password: xxxxxxxxxx - instance: xxxxxxxx + url: https://your-instance.service-now.com/ - type: slack name: my-slack enable: false diff --git a/outputs/servicenow.go b/outputs/servicenow.go index 560bcb14..11082632 100644 --- a/outputs/servicenow.go +++ b/outputs/servicenow.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "strconv" + "strings" "time" "github.com/pkg/errors" @@ -23,7 +24,8 @@ type ServiceNowOutput struct { Name string User string Password string - Instance string + Url string // ServiceNow instance URL (new behaviour). If set, used as-is; otherwise Instance + BaseServer is used (legacy). + Instance string // Legacy: instance name (e.g. dev12345) for https://.service-now.com/ Table string layoutProvider layout.LayoutProvider } @@ -38,9 +40,9 @@ func (sn *ServiceNowOutput) GetName() string { func (sn *ServiceNowOutput) CloneSettings() *data.OutputSettings { return &data.OutputSettings{ - Name: sn.Name, - User: sn.User, - //password + Name: sn.Name, + User: sn.User, + Url: sn.Url, InstanceName: sn.Instance, BoardName: sn.Table, Enable: true, @@ -52,7 +54,11 @@ func (sn *ServiceNowOutput) Init() error { sn.layoutProvider = new(formatting.HtmlProvider) log.Logger.Infof("Successfully initialized ServiceNow output %q", sn.Name) - log.Logger.Debugf("Your ServiceNow Table is %q on '%s.%s'", sn.Table, sn.Instance, servicenow.BaseServer) + if sn.Url != "" { + log.Logger.Debugf("Your ServiceNow Table is %q at %q (instance URL)", sn.Table, sn.Url) + } else { + log.Logger.Debugf("Your ServiceNow Table is %q on %s.%s (legacy)", sn.Table, sn.Instance, servicenow.BaseServer) + } return nil } @@ -90,13 +96,20 @@ func (sn *ServiceNowOutput) Send(content map[string]string) (data.OutputResponse return data.OutputResponse{}, errors.New("Error when trying to parse ServiceNow integration data") } - resp, err := servicenow.InsertRecordToTable(sn.User, sn.Password, sn.Instance, sn.Table, body) + resp, err := servicenow.InsertRecordToTable(sn.User, sn.Password, sn.Url, sn.Instance, sn.Table, body) if err != nil { log.Logger.Error("ServiceNow Error: ", err) return data.OutputResponse{}, errors.New("Failed inserting record to the ServiceNow table") } - ticketLink := fmt.Sprintf("https://%s.service-now.com/nav_to.do?uri=%s.do?sys_id=%s", sn.Instance, sn.Table, resp.SysID) + var baseURL string + if sn.Url != "" { + baseURL = strings.TrimSuffix(sn.Url, "/") + } else { + baseURL = fmt.Sprintf("https://%s.%s", sn.Instance, servicenow.BaseServer) + baseURL = strings.TrimSuffix(baseURL, "/") + } + ticketLink := fmt.Sprintf("%s/nav_to.do?uri=%s.do?sys_id=%s", baseURL, sn.Table, resp.SysID) log.Logger.Infof("Successfully sent a message via ServiceNow %q, ID %q, Link %q", sn.Name, resp.SysID, ticketLink) return data.OutputResponse{Key: resp.SysID, Url: ticketLink, Name: sn.Name}, nil } diff --git a/router/builders.go b/router/builders.go index ba1a403e..dfa7df56 100644 --- a/router/builders.go +++ b/router/builders.go @@ -53,8 +53,9 @@ func buildServiceNow(sourceSettings *data.OutputSettings) *outputs.ServiceNowOut Name: sourceSettings.Name, User: sourceSettings.User, Password: sourceSettings.Password, - Table: sourceSettings.BoardName, + Url: sourceSettings.Url, Instance: sourceSettings.InstanceName, + Table: sourceSettings.BoardName, } if len(serviceNow.Table) == 0 { serviceNow.Table = ServiceNowTableDefault diff --git a/router/initoutputs_test.go b/router/initoutputs_test.go index 200018d0..8e60b270 100644 --- a/router/initoutputs_test.go +++ b/router/initoutputs_test.go @@ -176,20 +176,39 @@ func TestBuildAndInitOtpt(t *testing.T) { "*outputs.WebhookOutput", }, { - "Simple ServiceNow output", + "Simple ServiceNow output (url)", data.OutputSettings{ - Name: "my-servicenow", + Name: "my-servicenow", + Type: "serviceNow", + User: "admin", + Password: "secret", + Url: "https://dev108148.service-now.com/", + BoardName: "incindent", + }, + map[string]interface{}{ + "User": "admin", + "Password": "secret", + "Url": "https://dev108148.service-now.com/", + "Table": "incindent", + }, + false, + "*outputs.ServiceNowOutput", + }, + { + "ServiceNow output (legacy, instance only)", + data.OutputSettings{ + Name: "my-servicenow-legacy", Type: "serviceNow", User: "admin", Password: "secret", InstanceName: "dev108148", - BoardName: "incindent", + BoardName: "incident", }, map[string]interface{}{ "User": "admin", "Password": "secret", "Instance": "dev108148", - "Table": "incindent", + "Table": "incident", }, false, "*outputs.ServiceNowOutput", diff --git a/servicenow/insert_table.go b/servicenow/insert_table.go index c168e900..c2df611d 100644 --- a/servicenow/insert_table.go +++ b/servicenow/insert_table.go @@ -7,16 +7,35 @@ import ( "fmt" "io/ioutil" "net/http" + "strings" "github.com/aquasecurity/postee/v2/utils" ) -func InsertRecordToTable(user, password, instance, table string, content []byte) (*ServiceNowResponse, error) { - url := fmt.Sprintf("https://%s.%s%s%s%s", - instance, BaseServer, baseApiUrl, tableApi, table) +// InsertRecordToTable posts a record to the given ServiceNow table. +// If instanceURL is non-empty, it is used as the instance root URL (new behaviour; e.g. https://ven05031.service-now.com/ or https://fsadev.servicenowservices.com). +// If instanceURL is empty, the URL is built from instance + BaseServer (legacy behaviour: https://.service-now.com/). +// At least one of instanceURL or instance must be non-empty. +func InsertRecordToTable(user, password, instanceURL, instance, table string, content []byte) (*ServiceNowResponse, error) { + if instanceURL == "" && instance == "" { + return nil, fmt.Errorf("InsertRecordToTable: either url (instance URL) or instance (legacy) must be set") + } + var tableURL string + if instanceURL != "" { + if !IsValidURL(instanceURL) { + return nil, fmt.Errorf("InsertRecordToTable: invalid ServiceNow instance URL format (must be http(s) with host)") + } + if !IsUrlNotLocalhost(instanceURL) { + return nil, fmt.Errorf("InsertRecordToTable: ServiceNow instance URL must not be localhost") + } + base := strings.TrimSuffix(instanceURL, "/") + tableURL = base + "/" + baseApiPath + table + } else { + tableURL = fmt.Sprintf("https://%s.%s%s%s", instance, BaseServer, baseApiPath, table) + } r := bytes.NewReader(content) client := http.DefaultClient - reg, err := http.NewRequest("POST", url, r) + reg, err := http.NewRequest("POST", tableURL, r) if err != nil { return nil, err } diff --git a/servicenow/servicenow_base.go b/servicenow/servicenow_base.go index e7dabf4f..80696a5d 100644 --- a/servicenow/servicenow_base.go +++ b/servicenow/servicenow_base.go @@ -1,9 +1,9 @@ package servicenow_api const ( - BaseServer = "service-now.com/" - baseApiUrl = "api/now/" - tableApi = "table/" + // BaseServer is used for legacy config when url is not provided (e.g. instance name + BaseServer). + BaseServer = "service-now.com/" + baseApiPath = "api/now/table/" ) type ServiceNowData struct { diff --git a/servicenow/urlvalidate.go b/servicenow/urlvalidate.go new file mode 100644 index 00000000..f41c864c --- /dev/null +++ b/servicenow/urlvalidate.go @@ -0,0 +1,30 @@ +package servicenow_api + +import ( + "net/url" + "regexp" + "strings" +) + +// IsValidURL returns true when the URL is valid: parseable, scheme "http" or "https", and non-empty host. +func IsValidURL(URL string) bool { + u, err := url.ParseRequestURI(URL) + if err != nil { + return false + } + if u.Scheme != "http" && u.Scheme != "https" { + return false + } + if u.Host == "" { + return false + } + return true +} + +var localhostURLPattern = regexp.MustCompile(`^http://localhost|^https://localhost|^localhost|127\.0\.0\.1|\[::\]`) + +// IsUrlNotLocalhost returns true when the URL does not refer to localhost (or 127.0.0.1, [::]). +// Used to reject localhost URLs for ServiceNow instance URL. +func IsUrlNotLocalhost(URL string) bool { + return !localhostURLPattern.MatchString(strings.ToLower(URL)) +} diff --git a/ui/frontend/src/components/OutputDetails.vue b/ui/frontend/src/components/OutputDetails.vue index 70162e60..6eb3251e 100644 --- a/ui/frontend/src/components/OutputDetails.vue +++ b/ui/frontend/src/components/OutputDetails.vue @@ -107,7 +107,7 @@ :description="getUrlDescription" :show="showUrl" :inputHandler="updateField" - :validator="v([url, required])" + :validator="getUrlValidator" /> @@ -366,13 +366,12 @@
@@ -434,6 +433,7 @@ const urlDescriptionByType = { teams: "Webhook's url", jira: 'Mandatory. E.g "https://johndoe.atlassian.net"', slack: "", + serviceNow: "If set, used as instance URL (recommended). Otherwise Instance name is used (legacy). E.g. https://ven05031.service-now.com/ or https://fsadev.servicenowservices.com", }; const typesWithCredentials = ["serviceNow", "email"]; //TODO add description strings @@ -477,6 +477,13 @@ export default { getUrlDescription() { return urlDescriptionByType[this.outputType]; }, + getUrlValidator() { + // ServiceNow: url is optional (legacy uses instance only) + if (this.outputType === "serviceNow") { + return this.v([this.url]); + } + return this.v([this.url, this.required]); + }, isServiceNow() { return this.outputType === "serviceNow"; },