diff --git a/changes/issue-7675-add-vuln-scores-to-integrations b/changes/issue-7675-add-vuln-scores-to-integrations new file mode 100644 index 0000000000..13a5f68fbe --- /dev/null +++ b/changes/issue-7675-add-vuln-scores-to-integrations @@ -0,0 +1 @@ +* Added vulnerability scores to Jira and Zendesk integrations for Fleet Premium users. diff --git a/cmd/fleet/cron.go b/cmd/fleet/cron.go index 8409b5ab84..d2be42795a 100644 --- a/cmd/fleet/cron.go +++ b/cmd/fleet/cron.go @@ -202,6 +202,7 @@ func scanVulnerabilities( ds, kitlog.With(logger, "jira", "vulnerabilities"), vulns, + meta, ); err != nil { errHandler(ctx, logger, "queueing vulnerabilities to jira", err) } @@ -213,6 +214,7 @@ func scanVulnerabilities( ds, kitlog.With(logger, "zendesk", "vulnerabilities"), vulns, + meta, ); err != nil { errHandler(ctx, logger, "queueing vulnerabilities to Zendesk", err) } @@ -468,6 +470,7 @@ func startIntegrationsSchedule( instanceID string, ds fleet.Datastore, logger kitlog.Logger, + license *fleet.LicenseInfo, ) (*schedule.Schedule, error) { const ( name = "integrations" @@ -484,11 +487,13 @@ func startIntegrationsSchedule( Datastore: ds, Log: logger, NewClientFunc: newJiraClient, + License: license, } zendesk := &worker.Zendesk{ Datastore: ds, Log: logger, NewClientFunc: newZendeskClient, + License: license, } // leave the url empty for now, will be filled when the lock is acquired with // the up-to-date config. diff --git a/cmd/fleet/serve.go b/cmd/fleet/serve.go index 21f28d3620..c1883fa606 100644 --- a/cmd/fleet/serve.go +++ b/cmd/fleet/serve.go @@ -471,7 +471,7 @@ the way that the Fleet server works. if _, err := startAutomationsSchedule(ctx, instanceID, ds, logger, 5*time.Minute, failingPolicySet); err != nil { initFatal(err, "failed to register automations schedule") } - if _, err := startIntegrationsSchedule(ctx, instanceID, ds, logger); err != nil { + if _, err := startIntegrationsSchedule(ctx, instanceID, ds, logger, license); err != nil { initFatal(err, "failed to register integrations schedule") } if config.MDMApple.Enable { diff --git a/server/worker/automation_failer_test.go b/server/worker/automation_failer_test.go index 8a854e6b47..96c84aa61e 100644 --- a/server/worker/automation_failer_test.go +++ b/server/worker/automation_failer_test.go @@ -62,6 +62,7 @@ func TestJiraFailer(t *testing.T) { FleetURL: "http://example.com", Datastore: ds, Log: kitlog.NewNopLogger(), + License: &fleet.LicenseInfo{Tier: fleet.TierFree}, NewClientFunc: func(opts *externalsvc.JiraOptions) (JiraClient, error) { return failer, nil }, @@ -120,6 +121,7 @@ func TestZendeskFailer(t *testing.T) { FleetURL: "http://example.com", Datastore: ds, Log: kitlog.NewNopLogger(), + License: &fleet.LicenseInfo{Tier: fleet.TierFree}, NewClientFunc: func(opts *externalsvc.ZendeskOptions) (ZendeskClient, error) { return failer, nil }, diff --git a/server/worker/jira.go b/server/worker/jira.go index 0c008c5eea..5ad5ab41e9 100644 --- a/server/worker/jira.go +++ b/server/worker/jira.go @@ -30,10 +30,25 @@ var jiraTemplates = struct { `Vulnerability {{ .CVE }} detected on {{ len .Hosts }} host(s)`, )), - // Jira uses wiki markup in the v2 api. - VulnDescription: template.Must(template.New("").Parse( + // Jira uses wiki markup in the v2 api. See + // https://jira.atlassian.com/secure/WikiRendererHelpAction.jspa?section=all + // for some reference. The `\\` marks force a newline to have the desired spacing + // around the scores, when present. + VulnDescription: template.Must(template.New("").Funcs(template.FuncMap{ + // CISAKnownExploit is *bool, so any condition check on it in the template + // will test if nil or not, and not its actual boolean value. Hence, "deref". + "deref": func(b *bool) bool { return *b }, + }).Parse( `See vulnerability (CVE) details in National Vulnerability Database (NVD) here: [{{ .CVE }}|{{ .NVDURL }}{{ .CVE }}]. +{{ if .IsPremium }}{{ if .EPSSProbability }}\\Probability of exploit (reported by [FIRST.org/epss|https://www.first.org/epss/]): {{ .EPSSProbability }} +{{ end }} +{{ if .CVSSScore }}CVSS score (reported by [NVD|https://nvd.nist.gov/]): {{ .CVSSScore }} +{{ end }} +{{ if .CISAKnownExploit }}Known exploits (reported by [CISA|https://www.cisa.gov/known-exploited-vulnerabilities-catalog]): {{ if deref .CISAKnownExploit }}Yes{{ else }}No{{ end }} +\\ +{{ end }}{{ end }} + Affected hosts: {{ $end := len .Hosts }}{{ if gt $end 50 }}{{ $end = 50 }}{{ end }} @@ -76,6 +91,13 @@ type jiraVulnTplArgs struct { FleetURL string CVE string Hosts []*fleet.HostShort + + IsPremium bool + + // the following fields are only included in the ticket for premium licenses. + EPSSProbability *float64 + CVSSScore *float64 + CISAKnownExploit *bool } type jiraFailingPoliciesTplArgs struct { @@ -98,6 +120,7 @@ type Jira struct { FleetURL string Datastore fleet.Datastore Log kitlog.Logger + License *fleet.LicenseInfo NewClientFunc func(*externalsvc.JiraOptions) (JiraClient, error) // mu protects concurrent access to clientsCache, so that the job processor @@ -199,7 +222,10 @@ func (j *Jira) getClient(ctx context.Context, args jiraArgs) (JiraClient, error) // jiraArgs are the arguments for the Jira integration job. type jiraArgs struct { + // CVE is deprecated but kept for backwards compatibility (there may be jobs + // enqueued in that format to process). CVE string `json:"cve,omitempty"` + Vulnerability *vulnArgs `json:"vulnerability,omitempty"` FailingPolicy *failingPolicyArgs `json:"failing_policy,omitempty"` } @@ -239,16 +265,28 @@ func (j *Jira) Run(ctx context.Context, argsJSON json.RawMessage) error { } func (j *Jira) runVuln(ctx context.Context, cli JiraClient, args jiraArgs) error { - hosts, err := j.Datastore.HostsByCVE(ctx, args.CVE) + vargs := args.Vulnerability + if vargs == nil { + // support the old format of vulnerability args, where only the CVE + // is provided. + vargs = &vulnArgs{ + CVE: args.CVE, + } + } + hosts, err := j.Datastore.HostsByCVE(ctx, vargs.CVE) if err != nil { return ctxerr.Wrap(ctx, err, "find hosts by cve") } tplArgs := &jiraVulnTplArgs{ - NVDURL: nvdCVEURL, - FleetURL: j.FleetURL, - CVE: args.CVE, - Hosts: hosts, + NVDURL: nvdCVEURL, + FleetURL: j.FleetURL, + CVE: vargs.CVE, + Hosts: hosts, + IsPremium: j.License.IsPremium(), + EPSSProbability: vargs.EPSSProbability, + CVSSScore: vargs.CVSSScore, + CISAKnownExploit: vargs.CISAKnownExploit, } createdIssue, err := j.createTemplatedIssue(ctx, cli, jiraTemplates.VulnSummary, jiraTemplates.VulnDescription, tplArgs) @@ -257,7 +295,7 @@ func (j *Jira) runVuln(ctx context.Context, cli JiraClient, args jiraArgs) error } level.Debug(j.Log).Log( "msg", "created jira issue for cve", - "cve", args.CVE, + "cve", vargs.CVE, "issue_id", createdIssue.ID, "issue_key", createdIssue.Key, ) @@ -324,7 +362,13 @@ func (j *Jira) createTemplatedIssue(ctx context.Context, cli JiraClient, summary // QueueJiraVulnJobs queues the Jira vulnerability jobs to process asynchronously // via the worker. -func QueueJiraVulnJobs(ctx context.Context, ds fleet.Datastore, logger kitlog.Logger, recentVulns []fleet.SoftwareVulnerability) error { +func QueueJiraVulnJobs( + ctx context.Context, + ds fleet.Datastore, + logger kitlog.Logger, + recentVulns []fleet.SoftwareVulnerability, + cveMeta map[string]fleet.CVEMeta, +) error { level.Info(logger).Log("enabled", "true", "recentVulns", len(recentVulns)) // for troubleshooting, log in debug level the CVEs that we will process @@ -343,7 +387,13 @@ func QueueJiraVulnJobs(ctx context.Context, ds fleet.Datastore, logger kitlog.Lo } for cve := range uniqCVEs { - job, err := QueueJob(ctx, ds, jiraName, jiraArgs{CVE: cve}) + args := vulnArgs{CVE: cve} + if meta, ok := cveMeta[cve]; ok { + args.EPSSProbability = meta.EPSSProbability + args.CVSSScore = meta.CVSSScore + args.CISAKnownExploit = meta.CISAKnownExploit + } + job, err := QueueJob(ctx, ds, jiraName, jiraArgs{Vulnerability: &args}) if err != nil { return ctxerr.Wrap(ctx, err, "queueing job") } diff --git a/server/worker/jira_test.go b/server/worker/jira_test.go index 7269e6ef82..ca22ae1582 100644 --- a/server/worker/jira_test.go +++ b/server/worker/jira_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "io" "io/ioutil" "net/http" @@ -73,6 +74,7 @@ func TestJiraRun(t *testing.T) { require.Contains(t, string(body), expectedDescription) } if expectedNotInDescription != "" { + fmt.Println(string(body)) require.NotContains(t, string(body), expectedNotInDescription) } @@ -96,37 +98,100 @@ func TestJiraRun(t *testing.T) { client, err := externalsvc.NewJiraClient(&externalsvc.JiraOptions{BaseURL: srv.URL}) require.NoError(t, err) - jira := &Jira{ - FleetURL: "http://example.com", - Datastore: ds, - Log: kitlog.NewNopLogger(), - NewClientFunc: func(opts *externalsvc.JiraOptions) (JiraClient, error) { - return client, nil + cases := []struct { + desc string + licenseTier string + payload string + expectedSummary string + expectedDescription string + expectedNotInDescription string + }{ + { + "old vuln format free", + fleet.TierFree, + `{"cve":"CVE-1234-5678"}`, + `"summary":"Vulnerability CVE-1234-5678 detected on 1 host(s)"`, + "Affected hosts:", + "Probability of exploit", + }, + { + "vuln free", + fleet.TierFree, + `{"vulnerability":{"cve":"CVE-1234-5678"}}`, + `"summary":"Vulnerability CVE-1234-5678 detected on 1 host(s)"`, + "Affected hosts:", + "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}}`, + `"summary":"Vulnerability CVE-1234-5678 detected on 1 host(s)"`, + "Affected hosts:", + "Probability of exploit", + }, + { + "failing global policy", + fleet.TierFree, + `{"failing_policy":{"policy_id": 1, "policy_name": "test-policy", "hosts": []}}`, + `"summary":"test-policy policy failed on 0 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": "test-1"}, {"id": 2, "hostname": "test-2"}]}}`, + `"summary":"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"}`, + `"summary":"Vulnerability CVE-1234-5678 detected on 1 host(s)"`, + "Affected hosts:", + "Probability of exploit", + }, + { + "vuln premium", + fleet.TierPremium, + `{"vulnerability":{"cve":"CVE-1234-5678"}}`, + `"summary":"Vulnerability CVE-1234-5678 detected on 1 host(s)"`, + "Affected hosts:", + "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}}`, + `"summary":"Vulnerability CVE-1234-5678 detected on 1 host(s)"`, + "Probability of exploit", + "", }, } - t.Run("vuln", func(t *testing.T) { - expectedSummary = `"summary":"Vulnerability CVE-1234-5678 detected on 1 host(s)"` - expectedDescription, expectedNotInDescription = "", "" - err = jira.Run(context.Background(), json.RawMessage(`{"cve":"CVE-1234-5678"}`)) - require.NoError(t, err) - }) + for _, c := range cases { + t.Run(c.desc, func(t *testing.T) { + jira := &Jira{ + FleetURL: "https://fleetdm.com", + Datastore: ds, + Log: kitlog.NewNopLogger(), + License: &fleet.LicenseInfo{Tier: c.licenseTier}, + NewClientFunc: func(opts *externalsvc.JiraOptions) (JiraClient, error) { + return client, nil + }, + } - t.Run("failing global policy", func(t *testing.T) { - expectedSummary = `"summary":"test-policy policy failed on 0 host(s)"` - expectedDescription = "\\u0026policy_id=1\\u0026policy_response=failing" // ampersand gets rendered as \u0026 in json string - expectedNotInDescription = "\\u0026team_id=" - err = jira.Run(context.Background(), json.RawMessage(`{"failing_policy":{"policy_id": 1, "policy_name": "test-policy", "hosts": []}}`)) - require.NoError(t, err) - }) + expectedSummary = c.expectedSummary + expectedDescription = c.expectedDescription + expectedNotInDescription = c.expectedNotInDescription + err = jira.Run(context.Background(), json.RawMessage(c.payload)) + require.NoError(t, err) + }) + } - t.Run("failing team policy", func(t *testing.T) { - expectedSummary = `"summary":"test-policy-2 policy failed on 2 host(s)"` - expectedDescription = "\\u0026team_id=123\\u0026policy_id=2\\u0026policy_response=failing" // ampersand gets rendered as \u0026 in json string - expectedNotInDescription = "" - err = jira.Run(context.Background(), json.RawMessage(`{"failing_policy":{"policy_id": 2, "policy_name": "test-policy-2", "team_id": 123, "hosts": [{"id": 1, "hostname": "test-1"}, {"id": 2, "hostname": "test-2"}]}}`)) - require.NoError(t, err) - }) } func TestJiraQueueVulnJobs(t *testing.T) { @@ -153,8 +218,12 @@ func TestJiraQueueVulnJobs(t *testing.T) { 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 := QueueJiraVulnJobs(ctx, ds, logger, vulns) + err := QueueJiraVulnJobs(ctx, ds, logger, vulns, meta) require.NoError(t, err) require.True(t, ds.NewJobFuncInvoked) require.Equal(t, 1, count) @@ -164,7 +233,11 @@ func TestJiraQueueVulnJobs(t *testing.T) { ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { return job, nil } - err := QueueJiraVulnJobs(ctx, ds, logger, []fleet.SoftwareVulnerability{{CVE: "CVE-1234-5678"}}) + theCVE := "CVE-1234-5678" + meta := map[string]fleet.CVEMeta{ + theCVE: {CVE: theCVE}, + } + err := QueueJiraVulnJobs(ctx, ds, logger, []fleet.SoftwareVulnerability{{CVE: theCVE}}, meta) require.NoError(t, err) require.True(t, ds.NewJobFuncInvoked) }) @@ -173,7 +246,11 @@ func TestJiraQueueVulnJobs(t *testing.T) { ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { return nil, io.EOF } - err := QueueJiraVulnJobs(ctx, ds, logger, []fleet.SoftwareVulnerability{{CVE: "CVE-1234-5678"}}) + theCVE := "CVE-1234-5678" + meta := map[string]fleet.CVEMeta{ + theCVE: {CVE: theCVE}, + } + err := QueueJiraVulnJobs(ctx, ds, logger, []fleet.SoftwareVulnerability{{CVE: theCVE}}, meta) require.Error(t, err) require.ErrorIs(t, err, io.EOF) require.True(t, ds.NewJobFuncInvoked) @@ -304,6 +381,7 @@ func TestJiraRunClientUpdate(t *testing.T) { FleetURL: "http://example.com", Datastore: ds, Log: kitlog.NewNopLogger(), + License: &fleet.LicenseInfo{Tier: fleet.TierFree}, NewClientFunc: func(opts *externalsvc.JiraOptions) (JiraClient, error) { // keep track of project keys received in calls to NewClientFunc projectKeys = append(projectKeys, opts.ProjectKey) diff --git a/server/worker/worker.go b/server/worker/worker.go index 9970b194c4..d64e526ec5 100644 --- a/server/worker/worker.go +++ b/server/worker/worker.go @@ -43,6 +43,15 @@ type failingPolicyArgs struct { TeamID *uint `json:"team_id,omitempty"` } +// vulnArgs are the args common to all integrations that can process +// vulnerabilities. +type vulnArgs struct { + CVE string `json:"cve,omitempty"` + EPSSProbability *float64 `json:"epss_probability,omitempty"` // Premium feature only + CVSSScore *float64 `json:"cvss_score,omitempty"` // Premium feature only + CISAKnownExploit *bool `json:"cisa_known_exploit,omitempty"` // Premium feature only +} + // Worker runs jobs. NOT SAFE FOR CONCURRENT USE. type Worker struct { ds fleet.Datastore diff --git a/server/worker/zendesk.go b/server/worker/zendesk.go index da1d3fc3db..be65e1e3c4 100644 --- a/server/worker/zendesk.go +++ b/server/worker/zendesk.go @@ -30,9 +30,26 @@ var zendeskTemplates = struct { `Vulnerability {{ .CVE }} detected on {{ len .Hosts }} host(s)`, )), - VulnDescription: template.Must(template.New("").Parse( + // Zendesk uses markdown for formatting. Some reference documentation about + // it can be found here: + // https://support.zendesk.com/hc/en-us/articles/4408846544922-Formatting-text-with-Markdown + VulnDescription: template.Must(template.New("").Funcs(template.FuncMap{ + // CISAKnownExploit is *bool, so any condition check on it in the template + // will test if nil or not, and not its actual boolean value. Hence, "deref". + "deref": func(b *bool) bool { return *b }, + }).Parse( `See vulnerability (CVE) details in National Vulnerability Database (NVD) here: [{{ .CVE }}]({{ .NVDURL }}{{ .CVE }}). +{{ if .IsPremium }}{{ if .EPSSProbability }} +  +Probability of exploit (reported by [FIRST.org/epss](https://www.first.org/epss/)): {{ .EPSSProbability }} +{{ end }} +{{ if .CVSSScore }}CVSS score (reported by [NVD](https://nvd.nist.gov/)): {{ .CVSSScore }} +{{ end }} +{{ if .CISAKnownExploit }}Known exploits (reported by [CISA](https://www.cisa.gov/known-exploited-vulnerabilities-catalog)): {{ if deref .CISAKnownExploit }}Yes{{ else }}No{{ end }} +  +{{ end }}{{ end }} + Affected hosts: {{ $end := len .Hosts }}{{ if gt $end 50 }}{{ $end = 50 }}{{ end }} @@ -43,8 +60,8 @@ Affected hosts: View the affected software and more affected hosts: 1. Go to the [Software]({{ .FleetURL }}/software/manage) page in Fleet. -2. Above the list of software, in the *Search software* box, enter "{{ .CVE }}". -3. Hover over the affected software and select *View all hosts*. +2. Above the list of software, in the **Search software** box, enter "{{ .CVE }}". +3. Hover over the affected software and select **View all hosts**. ---- @@ -75,6 +92,13 @@ type zendeskVulnTplArgs struct { FleetURL string CVE string Hosts []*fleet.HostShort + + IsPremium bool + + // the following fields are only included in the ticket for premium licenses. + EPSSProbability *float64 + CVSSScore *float64 + CISAKnownExploit *bool } type zendeskFailingPoliciesTplArgs struct { @@ -97,6 +121,7 @@ type Zendesk struct { FleetURL string Datastore fleet.Datastore Log kitlog.Logger + License *fleet.LicenseInfo NewClientFunc func(*externalsvc.ZendeskOptions) (ZendeskClient, error) // mu protects concurrent access to clientsCache, so that the job processor @@ -199,7 +224,10 @@ func (z *Zendesk) Name() string { // zendeskArgs are the arguments for the Zendesk integration job. type zendeskArgs struct { + // CVE is deprecated but kept for backwards compatibility (there may be jobs + // enqueued in that format to process). CVE string `json:"cve,omitempty"` + Vulnerability *vulnArgs `json:"vulnerability,omitempty"` FailingPolicy *failingPolicyArgs `json:"failing_policy,omitempty"` } @@ -239,16 +267,28 @@ func (z *Zendesk) Run(ctx context.Context, argsJSON json.RawMessage) error { } func (z *Zendesk) runVuln(ctx context.Context, cli ZendeskClient, args zendeskArgs) error { - hosts, err := z.Datastore.HostsByCVE(ctx, args.CVE) + vargs := args.Vulnerability + if vargs == nil { + // support the old format of vulnerability args, where only the CVE + // is provided. + vargs = &vulnArgs{ + CVE: args.CVE, + } + } + hosts, err := z.Datastore.HostsByCVE(ctx, vargs.CVE) if err != nil { return ctxerr.Wrap(ctx, err, "find hosts by cve") } tplArgs := &zendeskVulnTplArgs{ - NVDURL: nvdCVEURL, - FleetURL: z.FleetURL, - CVE: args.CVE, - Hosts: hosts, + NVDURL: nvdCVEURL, + FleetURL: z.FleetURL, + CVE: vargs.CVE, + Hosts: hosts, + IsPremium: z.License.IsPremium(), + EPSSProbability: vargs.EPSSProbability, + CVSSScore: vargs.CVSSScore, + CISAKnownExploit: vargs.CISAKnownExploit, } createdTicket, err := z.createTemplatedTicket(ctx, cli, zendeskTemplates.VulnSummary, zendeskTemplates.VulnDescription, tplArgs) @@ -257,7 +297,7 @@ func (z *Zendesk) runVuln(ctx context.Context, cli ZendeskClient, args zendeskAr } level.Debug(z.Log).Log( "msg", "created zendesk ticket for cve", - "cve", args.CVE, + "cve", vargs.CVE, "ticket_id", createdTicket.ID, ) return nil @@ -317,7 +357,13 @@ func (z *Zendesk) createTemplatedTicket(ctx context.Context, cli ZendeskClient, // QueueZendeskVulnJobs queues the Zendesk vulnerability jobs to process asynchronously // via the worker. -func QueueZendeskVulnJobs(ctx context.Context, ds fleet.Datastore, logger kitlog.Logger, recentVulns []fleet.SoftwareVulnerability) error { +func QueueZendeskVulnJobs( + ctx context.Context, + ds fleet.Datastore, + logger kitlog.Logger, + recentVulns []fleet.SoftwareVulnerability, + cveMeta map[string]fleet.CVEMeta, +) error { level.Info(logger).Log("enabled", "true", "recentVulns", len(recentVulns)) // for troubleshooting, log in debug level the CVEs that we will process @@ -336,7 +382,13 @@ func QueueZendeskVulnJobs(ctx context.Context, ds fleet.Datastore, logger kitlog } for cve := range uniqCVEs { - job, err := QueueJob(ctx, ds, zendeskName, zendeskArgs{CVE: cve}) + args := vulnArgs{CVE: cve} + if meta, ok := cveMeta[cve]; ok { + args.EPSSProbability = meta.EPSSProbability + args.CVSSScore = meta.CVSSScore + args.CISAKnownExploit = meta.CISAKnownExploit + } + job, err := QueueJob(ctx, ds, zendeskName, zendeskArgs{Vulnerability: &args}) if err != nil { return ctxerr.Wrap(ctx, err, "queueing job") } diff --git a/server/worker/zendesk_test.go b/server/worker/zendesk_test.go index 849750bc34..59e7e2ed71 100644 --- a/server/worker/zendesk_test.go +++ b/server/worker/zendesk_test.go @@ -83,38 +83,99 @@ func TestZendeskRun(t *testing.T) { client, err := externalsvc.NewZendeskTestClient(&externalsvc.ZendeskOptions{URL: srv.URL, GroupID: int64(123)}) require.NoError(t, err) - zendesk := &Zendesk{ - FleetURL: "https://fleetdm.com", - Datastore: ds, - Log: kitlog.NewNopLogger(), - NewClientFunc: func(opts *externalsvc.ZendeskOptions) (ZendeskClient, error) { - return client, nil + 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", + "", }, } - t.Run("vuln", func(t *testing.T) { - expectedSubject = `"subject":"Vulnerability CVE-1234-5678 detected on 1 host(s)"` - expectedDescription = `"group_id":123` - expectedNotInDescription = "" - err = zendesk.Run(context.Background(), json.RawMessage(`{"cve":"CVE-1234-5678"}`)) - require.NoError(t, err) - }) + for _, c := range cases { + t.Run(c.desc, func(t *testing.T) { + zendesk := &Zendesk{ + FleetURL: "https://fleetdm.com", + Datastore: ds, + Log: kitlog.NewNopLogger(), + License: &fleet.LicenseInfo{Tier: c.licenseTier}, + NewClientFunc: func(opts *externalsvc.ZendeskOptions) (ZendeskClient, error) { + return client, nil + }, + } - t.Run("failing global policy", func(t *testing.T) { - expectedSubject = `"subject":"test-policy policy failed on 1 host(s)"` - expectedDescription = "\\u0026policy_id=1\\u0026policy_response=failing" // ampersand gets rendered as \u0026 in json string - expectedNotInDescription = "\\u0026team_id=" - err = zendesk.Run(context.Background(), json.RawMessage(`{"failing_policy":{"policy_id": 1, "policy_name": "test-policy", "hosts": [{"id": 123, "hostname": "host-123"}]}}`)) - require.NoError(t, err) - }) - - t.Run("failing team policy", func(t *testing.T) { - expectedSubject = `"subject":"test-policy-2 policy failed on 2 host(s)"` - expectedDescription = "\\u0026team_id=123\\u0026policy_id=2\\u0026policy_response=failing" // ampersand gets rendered as \u0026 in json string - expectedNotInDescription = "" - err = zendesk.Run(context.Background(), json.RawMessage(`{"failing_policy":{"policy_id": 2, "policy_name": "test-policy-2", "team_id": 123, "hosts": [{"id": 1, "hostname": "host-1"}, {"id": 2, "hostname": "host-2"}]}}`)) - require.NoError(t, err) - }) + expectedSubject = c.expectedSubject + expectedDescription = c.expectedDescription + expectedNotInDescription = c.expectedNotInDescription + err = zendesk.Run(context.Background(), json.RawMessage(c.payload)) + require.NoError(t, err) + }) + } } func TestZendeskQueueVulnJobs(t *testing.T) { @@ -141,8 +202,12 @@ func TestZendeskQueueVulnJobs(t *testing.T) { 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) + err := QueueZendeskVulnJobs(ctx, ds, logger, vulns, meta) require.NoError(t, err) require.True(t, ds.NewJobFuncInvoked) require.Equal(t, 1, count) @@ -152,7 +217,11 @@ func TestZendeskQueueVulnJobs(t *testing.T) { ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { return job, nil } - err := QueueZendeskVulnJobs(ctx, ds, logger, []fleet.SoftwareVulnerability{{CVE: "CVE-1234-5678"}}) + 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) }) @@ -161,7 +230,11 @@ func TestZendeskQueueVulnJobs(t *testing.T) { ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { return nil, io.EOF } - err := QueueZendeskVulnJobs(ctx, ds, logger, []fleet.SoftwareVulnerability{{CVE: "CVE-1234-5678"}}) + 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) @@ -292,6 +365,7 @@ func TestZendeskRunClientUpdate(t *testing.T) { FleetURL: "http://example.com", Datastore: ds, Log: kitlog.NewNopLogger(), + License: &fleet.LicenseInfo{Tier: fleet.TierFree}, NewClientFunc: func(opts *externalsvc.ZendeskOptions) (ZendeskClient, error) { // keep track of group IDs received in calls to NewClientFunc groupIDs = append(groupIDs, opts.GroupID) diff --git a/tools/jira-integration/main.go b/tools/jira-integration/main.go index 30e30671d7..b805850a9f 100644 --- a/tools/jira-integration/main.go +++ b/tools/jira-integration/main.go @@ -28,13 +28,23 @@ func main() { jiraProjectKey = flag.String("jira-project-key", "", "The Jira project key") fleetURL = flag.String("fleet-url", "https://localhost:8080", "The Fleet server URL") cve = flag.String("cve", "", "The CVE to create a Jira issue for") + epssProbability = flag.Float64("epss-probability", 0, "The EPSS Probability score of the CVE") + cvssScore = flag.Float64("cvss-score", 0, "The CVSS score of the CVE") + cisaKnownExploit = flag.Bool("cisa-known-exploit", false, "Whether CISA reported it as a known exploit") hostsCount = flag.Int("hosts-count", 1, "The number of hosts to match the CVE or failing policy") failingPolicyID = flag.Int("failing-policy-id", 0, "The failing policy ID") failingPolicyTeamID = flag.Int("failing-policy-team-id", 0, "The Team ID of the failing policy") + premiumLicense = flag.Bool("premium", false, "Whether to simulate a premium user or not") ) flag.Parse() + // keep set of flags that were provided, to handle those that can be absent + setFlags := make(map[string]bool) + flag.CommandLine.Visit(func(f *flag.Flag) { + setFlags[f.Name] = true + }) + if *jiraURL == "" { fmt.Fprintf(os.Stderr, "-jira-url is required") os.Exit(1) @@ -72,7 +82,7 @@ func main() { ds.HostsByCVEFunc = func(ctx context.Context, cve string) ([]*fleet.HostShort, error) { hosts := make([]*fleet.HostShort, *hostsCount) for i := 0; i < *hostsCount; i++ { - hosts[i] = &fleet.HostShort{ID: uint(i + 1), Hostname: fmt.Sprintf("host-test-%d", i+1)} + hosts[i] = &fleet.HostShort{ID: uint(i + 1), Hostname: fmt.Sprintf("host-test-%d", i+1), DisplayName: fmt.Sprintf("host-test-%d", i+1)} } return hosts, nil } @@ -110,10 +120,15 @@ func main() { }, nil } + license := &fleet.LicenseInfo{Tier: fleet.TierFree} + if *premiumLicense { + license.Tier = fleet.TierPremium + } jira := &worker.Jira{ FleetURL: *fleetURL, Datastore: ds, Log: logger, + License: license, NewClientFunc: func(opts *externalsvc.JiraOptions) (worker.JiraClient, error) { return externalsvc.NewJiraClient(opts) }, @@ -121,7 +136,31 @@ func main() { var argsJSON json.RawMessage if *cve != "" { - argsJSON = json.RawMessage(fmt.Sprintf(`{"cve":%q}`, *cve)) + vulnArgs := struct { + CVE string `json:"cve,omitempty"` + EPSSProbability *float64 `json:"epss_probability,omitempty"` + CVSSScore *float64 `json:"cvss_score,omitempty"` + CISAKnownExploit *bool `json:"cisa_known_exploit,omitempty"` + }{ + CVE: *cve, + } + if setFlags["epss-probability"] { + vulnArgs.EPSSProbability = epssProbability + } + if setFlags["cvss-score"] { + vulnArgs.CVSSScore = cvssScore + } + if setFlags["cisa-known-exploit"] { + vulnArgs.CISAKnownExploit = cisaKnownExploit + } + + b, err := json.Marshal(vulnArgs) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to marshal vulnerability args: %v", err) + os.Exit(1) + } + argsJSON = json.RawMessage(fmt.Sprintf(`{"vulnerability":%s}`, string(b))) + } else if *failingPolicyID > 0 { jsonStr := fmt.Sprintf(`{"failing_policy":{"policy_id": %d, "policy_name": "test-policy-%[1]d", `, *failingPolicyID) if *failingPolicyTeamID > 0 { diff --git a/tools/zendesk-integration/main.go b/tools/zendesk-integration/main.go index 454854ca4d..d629a9506a 100644 --- a/tools/zendesk-integration/main.go +++ b/tools/zendesk-integration/main.go @@ -28,13 +28,23 @@ func main() { zendeskGroupID = flag.Int64("zendesk-group-id", 0, "The Zendesk group id") fleetURL = flag.String("fleet-url", "https://localhost:8080", "The Fleet server URL") cve = flag.String("cve", "", "The CVE to create a Zendesk issue for") + epssProbability = flag.Float64("epss-probability", 0, "The EPSS Probability score of the CVE") + cvssScore = flag.Float64("cvss-score", 0, "The CVSS score of the CVE") + cisaKnownExploit = flag.Bool("cisa-known-exploit", false, "Whether CISA reported it as a known exploit") hostsCount = flag.Int("hosts-count", 1, "The number of hosts to match the CVE or failing policy") failingPolicyID = flag.Int("failing-policy-id", 0, "The failing policy ID") failingPolicyTeamID = flag.Int("failing-policy-team-id", 0, "The Team ID of the failing policy") + premiumLicense = flag.Bool("premium", false, "Whether to simulate a premium user or not") ) flag.Parse() + // keep set of flags that were provided, to handle those that can be absent + setFlags := make(map[string]bool) + flag.CommandLine.Visit(func(f *flag.Flag) { + setFlags[f.Name] = true + }) + if *zendeskURL == "" { fmt.Fprintf(os.Stderr, "-zendesk-url is required") os.Exit(1) @@ -72,7 +82,7 @@ func main() { ds.HostsByCVEFunc = func(ctx context.Context, cve string) ([]*fleet.HostShort, error) { hosts := make([]*fleet.HostShort, *hostsCount) for i := 0; i < *hostsCount; i++ { - hosts[i] = &fleet.HostShort{ID: uint(i + 1), Hostname: fmt.Sprintf("host-test-%d", i+1)} + hosts[i] = &fleet.HostShort{ID: uint(i + 1), Hostname: fmt.Sprintf("host-test-%d", i+1), DisplayName: fmt.Sprintf("host-test-%d", i+1)} } return hosts, nil } @@ -110,10 +120,15 @@ func main() { }, nil } + license := &fleet.LicenseInfo{Tier: fleet.TierFree} + if *premiumLicense { + license.Tier = fleet.TierPremium + } zendesk := &worker.Zendesk{ FleetURL: *fleetURL, Datastore: ds, Log: logger, + License: license, NewClientFunc: func(opts *externalsvc.ZendeskOptions) (worker.ZendeskClient, error) { return externalsvc.NewZendeskClient(opts) }, @@ -121,7 +136,31 @@ func main() { var argsJSON json.RawMessage if *cve != "" { - argsJSON = json.RawMessage(fmt.Sprintf(`{"cve":%q}`, *cve)) + vulnArgs := struct { + CVE string `json:"cve,omitempty"` + EPSSProbability *float64 `json:"epss_probability,omitempty"` + CVSSScore *float64 `json:"cvss_score,omitempty"` + CISAKnownExploit *bool `json:"cisa_known_exploit,omitempty"` + }{ + CVE: *cve, + } + if setFlags["epss-probability"] { + vulnArgs.EPSSProbability = epssProbability + } + if setFlags["cvss-score"] { + vulnArgs.CVSSScore = cvssScore + } + if setFlags["cisa-known-exploit"] { + vulnArgs.CISAKnownExploit = cisaKnownExploit + } + + b, err := json.Marshal(vulnArgs) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to marshal vulnerability args: %v", err) + os.Exit(1) + } + argsJSON = json.RawMessage(fmt.Sprintf(`{"vulnerability":%s}`, string(b))) + } else if *failingPolicyID > 0 { jsonStr := fmt.Sprintf(`{"failing_policy":{"policy_id": %d, "policy_name": "test-policy-%[1]d", `, *failingPolicyID) if *failingPolicyTeamID > 0 {