package worker import ( "context" "encoding/json" "errors" "io" "io/ioutil" "net/http" "net/http/httptest" "testing" "github.com/fleetdm/fleet/v4/server/contexts/license" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mock" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/service/externalsvc" kitlog "github.com/go-kit/kit/log" zendesk "github.com/nukosuke/go-zendesk/zendesk" "github.com/stretchr/testify/require" ) func TestZendeskRun(t *testing.T) { ds := new(mock.Store) ds.HostsByCVEFunc = func(ctx context.Context, cve string) ([]*fleet.HostShort, error) { return []*fleet.HostShort{ { ID: 1, Hostname: "test", }, }, nil } ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { return &fleet.AppConfig{Integrations: fleet.Integrations{ Zendesk: []*fleet.ZendeskIntegration{ {EnableSoftwareVulnerabilities: true, EnableFailingPolicies: true}, }, }}, nil } ds.TeamFunc = func(ctx context.Context, tid uint) (*fleet.Team, error) { if tid != 123 { return nil, errors.New("unexpected team id") } return &fleet.Team{ ID: 123, Config: fleet.TeamConfig{ Integrations: fleet.TeamIntegrations{ Zendesk: []*fleet.TeamZendeskIntegration{ {EnableFailingPolicies: true}, }, }, }, }, nil } var expectedSubject, expectedDescription, expectedNotInDescription string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { w.WriteHeader(501) return } if r.URL.Path != "/api/v2/tickets.json" { w.WriteHeader(502) return } body, err := ioutil.ReadAll(r.Body) require.NoError(t, err) if expectedSubject != "" { require.Contains(t, string(body), expectedSubject) } if expectedDescription != "" { require.Contains(t, string(body), expectedDescription) } if expectedNotInDescription != "" { require.NotContains(t, string(body), expectedNotInDescription) } w.WriteHeader(http.StatusCreated) _, err = w.Write([]byte(`{}`)) require.NoError(t, err) })) defer srv.Close() client, err := externalsvc.NewZendeskTestClient(&externalsvc.ZendeskOptions{URL: srv.URL, GroupID: int64(123)}) require.NoError(t, err) cases := []struct { desc string licenseTier string payload string expectedSubject string expectedDescription string expectedNotInDescription string }{ { "old vuln format free", fleet.TierFree, `{"cve":"CVE-1234-5678"}`, `"subject":"Vulnerability CVE-1234-5678 detected on 1 host(s)"`, `"group_id":123`, "Probability of exploit", }, { "vuln free", fleet.TierFree, `{"vulnerability":{"cve":"CVE-1234-5678"}}`, `"subject":"Vulnerability CVE-1234-5678 detected on 1 host(s)"`, `"group_id":123`, "Probability of exploit", }, { "vuln with scores free", fleet.TierFree, `{"vulnerability":{"cve":"CVE-1234-5678","epss_probability":3.4,"cvss_score":50,"cisa_known_exploit":true}}`, `"subject":"Vulnerability CVE-1234-5678 detected on 1 host(s)"`, `"group_id":123`, "Probability of exploit", }, { "failing global policy", fleet.TierFree, `{"failing_policy":{"policy_id": 1, "policy_name": "test-policy", "hosts": [{"id": 123, "hostname": "host-123"}]}}`, `"subject":"test-policy policy failed on 1 host(s)"`, "\\u0026policy_id=1\\u0026policy_response=failing", "\\u0026team_id=", }, { "failing team policy", fleet.TierPremium, `{"failing_policy":{"policy_id": 2, "policy_name": "test-policy-2", "team_id": 123, "hosts": [{"id": 1, "hostname": "host-1"}, {"id": 2, "hostname": "host-2"}]}}`, `"subject":"test-policy-2 policy failed on 2 host(s)"`, "\\u0026team_id=123\\u0026policy_id=2\\u0026policy_response=failing", "", }, { "old vuln format premium", fleet.TierPremium, `{"cve":"CVE-1234-5678"}`, `"subject":"Vulnerability CVE-1234-5678 detected on 1 host(s)"`, `"group_id":123`, "Probability of exploit", }, { "vuln premium", fleet.TierPremium, `{"vulnerability":{"cve":"CVE-1234-5678"}}`, `"subject":"Vulnerability CVE-1234-5678 detected on 1 host(s)"`, `"group_id":123`, "Probability of exploit", }, { "vuln with scores premium", fleet.TierPremium, `{"vulnerability":{"cve":"CVE-1234-5678","epss_probability":3.4,"cvss_score":50,"cisa_known_exploit":true}}`, `"subject":"Vulnerability CVE-1234-5678 detected on 1 host(s)"`, "Probability of exploit", "", }, { "vuln with published date", fleet.TierPremium, `{"vulnerability":{"cve":"CVE-1234-5678","cve_published":"2012-04-23T18:25:43.511Z","epss_probability":3.4,"cvss_score":50,"cisa_known_exploit":true}}`, `"subject":"Vulnerability CVE-1234-5678 detected on 1 host(s)"`, "Published (reported by [NVD|https://nvd.nist.gov/]): 2012-04-23", "", }, } for _, c := range cases { t.Run(c.desc, func(t *testing.T) { zendesk := &Zendesk{ FleetURL: "https://fleetdm.com", Datastore: ds, Log: kitlog.NewNopLogger(), NewClientFunc: func(opts *externalsvc.ZendeskOptions) (ZendeskClient, error) { return client, nil }, } expectedSubject = c.expectedSubject expectedDescription = c.expectedDescription expectedNotInDescription = c.expectedNotInDescription err = zendesk.Run(license.NewContext(context.Background(), &fleet.LicenseInfo{Tier: c.licenseTier}), json.RawMessage(c.payload)) require.NoError(t, err) }) } } func TestZendeskQueueVulnJobs(t *testing.T) { ds := new(mock.Store) ctx := context.Background() logger := kitlog.NewNopLogger() t.Run("same vulnerability on multiple software only queue one job", func(t *testing.T) { var count int ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { count++ return job, nil } vulns := []fleet.SoftwareVulnerability{{ CVE: "CVE-1234-5678", SoftwareID: 1, }, { CVE: "CVE-1234-5678", SoftwareID: 2, }, { CVE: "CVE-1234-5678", SoftwareID: 2, }, { CVE: "CVE-1234-5678", SoftwareID: 3, }} meta := make(map[string]fleet.CVEMeta, len(vulns)) for _, v := range vulns { meta[v.CVE] = fleet.CVEMeta{CVE: v.CVE} } err := QueueZendeskVulnJobs(ctx, ds, logger, vulns, meta) require.NoError(t, err) require.True(t, ds.NewJobFuncInvoked) require.Equal(t, 1, count) }) t.Run("success", func(t *testing.T) { ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { return job, nil } theCVE := "CVE-1234-5678" meta := map[string]fleet.CVEMeta{ theCVE: {CVE: theCVE}, } err := QueueZendeskVulnJobs(ctx, ds, logger, []fleet.SoftwareVulnerability{{CVE: theCVE}}, meta) require.NoError(t, err) require.True(t, ds.NewJobFuncInvoked) }) t.Run("failure", func(t *testing.T) { ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { return nil, io.EOF } theCVE := "CVE-1234-5678" meta := map[string]fleet.CVEMeta{ theCVE: {CVE: theCVE}, } err := QueueZendeskVulnJobs(ctx, ds, logger, []fleet.SoftwareVulnerability{{CVE: theCVE}}, meta) require.Error(t, err) require.ErrorIs(t, err, io.EOF) require.True(t, ds.NewJobFuncInvoked) }) } func TestZendeskQueueFailingPolicyJob(t *testing.T) { ds := new(mock.Store) ctx := context.Background() logger := kitlog.NewNopLogger() t.Run("success global", func(t *testing.T) { ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { require.NotContains(t, string(*job.Args), `"team_id"`) return job, nil } err := QueueZendeskFailingPolicyJob(ctx, ds, logger, &fleet.Policy{PolicyData: fleet.PolicyData{ID: 1, Name: "p1"}}, []fleet.PolicySetHost{{ID: 1, Hostname: "h1"}}) require.NoError(t, err) require.True(t, ds.NewJobFuncInvoked) ds.NewJobFuncInvoked = false }) t.Run("success team", func(t *testing.T) { ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { require.Contains(t, string(*job.Args), `"team_id"`) return job, nil } err := QueueZendeskFailingPolicyJob(ctx, ds, logger, &fleet.Policy{PolicyData: fleet.PolicyData{ID: 1, Name: "p1", TeamID: ptr.Uint(2)}}, []fleet.PolicySetHost{{ID: 1, Hostname: "h1"}}) require.NoError(t, err) require.True(t, ds.NewJobFuncInvoked) ds.NewJobFuncInvoked = false }) t.Run("failure", func(t *testing.T) { ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { return nil, io.EOF } err := QueueZendeskFailingPolicyJob(ctx, ds, logger, &fleet.Policy{PolicyData: fleet.PolicyData{ID: 1, Name: "p1"}}, []fleet.PolicySetHost{{ID: 1, Hostname: "h1"}}) require.Error(t, err) require.ErrorIs(t, err, io.EOF) require.True(t, ds.NewJobFuncInvoked) ds.NewJobFuncInvoked = false }) t.Run("no host", func(t *testing.T) { ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { return job, nil } err := QueueZendeskFailingPolicyJob(ctx, ds, logger, &fleet.Policy{PolicyData: fleet.PolicyData{ID: 1, Name: "p1"}}, []fleet.PolicySetHost{}) require.NoError(t, err) require.False(t, ds.NewJobFuncInvoked) ds.NewJobFuncInvoked = false }) } type mockZendeskClient struct { opts externalsvc.ZendeskOptions tickets []zendesk.Ticket } func (c *mockZendeskClient) CreateZendeskTicket(ctx context.Context, ticket *zendesk.Ticket) (*zendesk.Ticket, error) { c.tickets = append(c.tickets, *ticket) return &zendesk.Ticket{}, nil } func (c *mockZendeskClient) ZendeskConfigMatches(opts *externalsvc.ZendeskOptions) bool { return c.opts == *opts } func TestZendeskRunClientUpdate(t *testing.T) { // test creation of client when config changes between 2 uses, and when integration is disabled. ds := new(mock.Store) var globalCount int ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { // failing policies is globally enabled globalCount++ return &fleet.AppConfig{Integrations: fleet.Integrations{ Zendesk: []*fleet.ZendeskIntegration{ {GroupID: 0, EnableFailingPolicies: true}, {GroupID: 1, EnableFailingPolicies: false}, // the team integration will use the group IDs 1-3 {GroupID: 2, EnableFailingPolicies: false}, {GroupID: 3, EnableFailingPolicies: false}, }, }}, nil } teamCfg := &fleet.Team{ ID: 123, Config: fleet.TeamConfig{ Integrations: fleet.TeamIntegrations{ Zendesk: []*fleet.TeamZendeskIntegration{ {GroupID: 1, EnableFailingPolicies: true}, }, }, }, } var teamCount int ds.TeamFunc = func(ctx context.Context, tid uint) (*fleet.Team, error) { teamCount++ if tid != 123 { return nil, errors.New("unexpected team id") } curCfg := *teamCfg zendesk0 := *teamCfg.Config.Integrations.Zendesk[0] // failing policies is enabled for team 123 the first time if zendesk0.GroupID == 1 { // the second time we change the project key zendesk0.GroupID = 2 teamCfg.Config.Integrations.Zendesk = []*fleet.TeamZendeskIntegration{&zendesk0} } else if zendesk0.GroupID == 2 { // the third time we disable it altogether zendesk0.GroupID = 3 zendesk0.EnableFailingPolicies = false teamCfg.Config.Integrations.Zendesk = []*fleet.TeamZendeskIntegration{&zendesk0} } return &curCfg, nil } var groupIDs []int64 var clients []*mockZendeskClient zendeskJob := &Zendesk{ FleetURL: "http://example.com", Datastore: ds, Log: kitlog.NewNopLogger(), NewClientFunc: func(opts *externalsvc.ZendeskOptions) (ZendeskClient, error) { // keep track of group IDs received in calls to NewClientFunc groupIDs = append(groupIDs, opts.GroupID) c := &mockZendeskClient{opts: *opts} clients = append(clients, c) return c, nil }, } ctx := license.NewContext(context.Background(), &fleet.LicenseInfo{Tier: fleet.TierFree}) // run it globally - it is enabled and will not change err := zendeskJob.Run(ctx, json.RawMessage(`{"failing_policy":{"policy_id": 1, "policy_name": "test-policy", "hosts": []}}`)) require.NoError(t, err) // run it for team 123 a first time err = zendeskJob.Run(ctx, json.RawMessage(`{"failing_policy":{"policy_id": 2, "policy_name": "test-policy-2", "team_id": 123, "hosts": []}}`)) require.NoError(t, err) // run it globally again - it will reuse the cached client err = zendeskJob.Run(ctx, json.RawMessage(`{"failing_policy":{"policy_id": 1, "policy_name": "test-policy", "hosts": [], "policy_critical": true}}`)) require.NoError(t, err) // run it for team 123 a second time err = zendeskJob.Run(ctx, json.RawMessage(`{"failing_policy":{"policy_id": 2, "policy_name": "test-policy-2", "team_id": 123, "hosts": []}}`)) require.NoError(t, err) // run it for team 123 a third time, this time integration is disabled err = zendeskJob.Run(ctx, json.RawMessage(`{"failing_policy":{"policy_id": 2, "policy_name": "test-policy-2", "team_id": 123, "hosts": []}}`)) require.NoError(t, err) // it should've created 3 clients - the global one, and the first 2 calls with team 123 require.Equal(t, []int64{0, 1, 2}, groupIDs) require.Equal(t, 5, globalCount) // app config is requested every time require.Equal(t, 3, teamCount) require.Len(t, clients, 3) require.Len(t, clients[0].tickets, 2) require.NotContains(t, clients[0].tickets[0].Comment.Body, "Critical") require.Contains(t, clients[0].tickets[1].Comment.Body, "Critical") require.Len(t, clients[1].tickets, 1) require.NotContains(t, clients[1].tickets[0].Comment.Body, "Critical") require.Len(t, clients[2].tickets, 1) require.NotContains(t, clients[2].tickets[0].Comment.Body, "Critical") }