Feature 7559: Include 3rd party severity scores in the vulnerability web-hook payload (#7581)

Premium users should receive vulnerability scores as part of the web-hook payload.
This commit is contained in:
Juan Fernandez
2022-09-06 15:12:17 -04:00
committed by GitHub
parent 153ac49866
commit b6d084deec
14 changed files with 305 additions and 84 deletions
@@ -0,0 +1,2 @@
- Include the CVSS score, EPSS score, and known exploits properties in the vulnerability Webhook
payload only if the customer is premium.
+44 -31
View File
@@ -9,6 +9,7 @@ import (
"strings"
"time"
eewebhooks "github.com/fleetdm/fleet/v4/ee/server/webhooks"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
"github.com/fleetdm/fleet/v4/server"
"github.com/fleetdm/fleet/v4/server/config"
@@ -38,6 +39,7 @@ func cronVulnerabilities(
logger kitlog.Logger,
identifier string,
config *config.VulnerabilitiesConfig,
license *fleet.LicenseInfo,
) {
logger = kitlog.With(logger, "cron", lockKeyVulnerabilities)
@@ -110,7 +112,7 @@ func cronVulnerabilities(
}
if vulnPath != "" {
level.Info(logger).Log("msg", "scanning vulnerabilities")
if err := scanVulnerabilities(ctx, ds, logger, config, appConfig, vulnPath); err != nil {
if err := scanVulnerabilities(ctx, ds, logger, config, appConfig, vulnPath, license); err != nil {
errHandler(ctx, logger, "scanning vulnerabilities", err)
}
}
@@ -130,6 +132,7 @@ func scanVulnerabilities(
config *config.VulnerabilitiesConfig,
appConfig *fleet.AppConfig,
vulnPath string,
license *fleet.LicenseInfo,
) error {
level.Debug(logger).Log("msg", "creating vulnerabilities databases path", "databases_path", vulnPath)
err := os.MkdirAll(vulnPath, 0o755)
@@ -171,22 +174,31 @@ func scanVulnerabilities(
level.Debug(logger).Log("vulnAutomationEnabled", vulnAutomationEnabled)
collectVulns := vulnAutomationEnabled != ""
nvdVulns := checkNVDVulnerabilities(ctx, ds, logger, vulnPath, config, collectVulns)
ovalVulns := checkOvalVulnerabilities(ctx, ds, logger, vulnPath, config, collectVulns)
recentVulns := filterRecentVulns(ctx, ds, logger, nvdVulns, ovalVulns, config.RecentVulnerabilityMaxAge)
nvdVulns := checkNVDVulnerabilities(ctx, ds, logger, vulnPath, config, vulnAutomationEnabled != "")
ovalVulns := checkOvalVulnerabilities(ctx, ds, logger, vulnPath, config, vulnAutomationEnabled != "")
vulns, meta := recentVulns(ctx, ds, logger, nvdVulns, ovalVulns, config.RecentVulnerabilityMaxAge)
if len(recentVulns) > 0 {
if len(vulns) > 0 {
switch vulnAutomationEnabled {
case "webhook":
args := webhooks.VulnArgs{
Vulnerablities: vulns,
Meta: meta,
AppConfig: appConfig,
Time: time.Now(),
}
mapper := webhooks.NewMapper()
if license.IsPremium() {
mapper = eewebhooks.NewMapper()
}
// send recent vulnerabilities via webhook
if err := webhooks.TriggerVulnerabilitiesWebhook(
ctx,
ds,
kitlog.With(logger, "webhook", "vulnerabilities"),
recentVulns,
appConfig,
time.Now()); err != nil {
args,
mapper,
); err != nil {
errHandler(ctx, logger, "triggering vulnerabilities webhook", err)
}
@@ -196,7 +208,7 @@ func scanVulnerabilities(
ctx,
ds,
kitlog.With(logger, "jira", "vulnerabilities"),
recentVulns,
vulns,
); err != nil {
errHandler(ctx, logger, "queueing vulnerabilities to jira", err)
}
@@ -207,7 +219,7 @@ func scanVulnerabilities(
ctx,
ds,
kitlog.With(logger, "zendesk", "vulnerabilities"),
recentVulns,
vulns,
); err != nil {
errHandler(ctx, logger, "queueing vulnerabilities to Zendesk", err)
}
@@ -221,47 +233,48 @@ func scanVulnerabilities(
return nil
}
func filterRecentVulns(
// recentVulns filters both the vulnerabilities comming from NVD and OVAL based on 'maxAge'
// (any vulnerability older than 'maxAge' will be excluded). Returns the filtered vulnerabilities
// and their meta data.
func recentVulns(
ctx context.Context,
ds fleet.Datastore,
logger kitlog.Logger,
nvdVulns []fleet.SoftwareVulnerability,
ovalVulns []fleet.SoftwareVulnerability,
maxAge time.Duration,
) []fleet.SoftwareVulnerability {
) ([]fleet.SoftwareVulnerability, map[string]fleet.CVEMeta) {
if len(nvdVulns) == 0 && len(ovalVulns) == 0 {
return nil
return nil, nil
}
recent, err := ds.ListCVEs(ctx, maxAge)
meta, err := ds.ListCVEs(ctx, maxAge)
if err != nil {
errHandler(ctx, logger, "could not fetch recent CVEs", err)
return nil
errHandler(ctx, logger, "could not fetch CVE meta", err)
return nil, nil
}
lookup := make(map[string]bool)
for _, r := range recent {
lookup[r.CVE] = true
recent := make(map[string]fleet.CVEMeta)
for _, r := range meta {
recent[r.CVE] = r
}
filtered := make(map[string]fleet.SoftwareVulnerability)
seen := make(map[string]bool)
var vulns []fleet.SoftwareVulnerability
for _, v := range nvdVulns {
if _, ok := lookup[v.CVE]; ok {
filtered[v.Key()] = v
if _, ok := recent[v.CVE]; ok && !seen[v.Key()] {
seen[v.Key()] = true
vulns = append(vulns, v)
}
}
for _, v := range ovalVulns {
if _, ok := lookup[v.CVE]; ok {
filtered[v.Key()] = v
if _, ok := recent[v.CVE]; ok && !seen[v.Key()] {
seen[v.Key()] = true
vulns = append(vulns, v)
}
}
result := make([]fleet.SoftwareVulnerability, 0, len(filtered))
for _, v := range filtered {
result = append(result, v)
}
return result
return vulns, recent
}
func checkOvalVulnerabilities(
+19 -9
View File
@@ -17,8 +17,9 @@ func TestFilterRecentVulns(t *testing.T) {
ds := new(mock.Store)
logger := kitlog.NewNopLogger()
result := filterRecentVulns(ctx, ds, logger, nil, nil, 2*time.Hour)
require.Empty(t, result)
vulns, meta := recentVulns(ctx, ds, logger, nil, nil, 2*time.Hour)
require.Empty(t, vulns)
require.Empty(t, meta)
})
t.Run("filters both NVD and OVAL vulns based on max age", func(t *testing.T) {
@@ -26,12 +27,14 @@ func TestFilterRecentVulns(t *testing.T) {
ds := new(mock.Store)
logger := kitlog.NewNopLogger()
dsMeta := []fleet.CVEMeta{
{CVE: "cve-recent-1"},
{CVE: "cve-recent-2"},
{CVE: "cve-recent-3"},
}
ds.ListCVEsFunc = func(ctx context.Context, maxAge time.Duration) ([]fleet.CVEMeta, error) {
return []fleet.CVEMeta{
{CVE: "cve-recent-1"},
{CVE: "cve-recent-2"},
{CVE: "cve-recent-3"},
}, nil
return dsMeta, nil
}
ovalVulns := []fleet.SoftwareVulnerability{
@@ -56,11 +59,18 @@ func TestFilterRecentVulns(t *testing.T) {
}
var actual []string
result := filterRecentVulns(ctx, ds, logger, nvdVulns, ovalVulns, maxAge)
for _, r := range result {
vulns, meta := recentVulns(ctx, ds, logger, nvdVulns, ovalVulns, maxAge)
for _, r := range vulns {
actual = append(actual, r.CVE)
}
expectedMeta := map[string]fleet.CVEMeta{
"cve-recent-1": {CVE: "cve-recent-1"},
"cve-recent-2": {CVE: "cve-recent-2"},
"cve-recent-3": {CVE: "cve-recent-3"},
}
require.ElementsMatch(t, expected, actual)
require.Equal(t, expectedMeta, meta)
})
}
+2 -1
View File
@@ -674,7 +674,8 @@ func runCrons(
task.StartCollectors(ctx, kitlog.With(logger, "cron", "async_task"))
go cronVulnerabilities(
ctx, ds, kitlog.With(logger, "cron", "vulnerabilities"), ourIdentifier, &config.Vulnerabilities)
ctx, ds, kitlog.With(logger, "cron", "vulnerabilities"), ourIdentifier, &config.Vulnerabilities, license)
go cronWebhooks(ctx, ds, kitlog.With(logger, "cron", "webhooks"), ourIdentifier, failingPoliciesSet, 1*time.Hour)
go cronWorker(ctx, ds, kitlog.With(logger, "cron", "worker"), ourIdentifier)
}
+3 -3
View File
@@ -229,7 +229,7 @@ func TestCronVulnerabilitiesCreatesDatabasesPath(t *testing.T) {
CurrentInstanceChecks: "auto",
}
go cronVulnerabilities(ctx, ds, kitlog.NewNopLogger(), "AAA", &config)
go cronVulnerabilities(ctx, ds, kitlog.NewNopLogger(), "AAA", &config, &fleet.LicenseInfo{Tier: "premium"})
require.Eventually(t, func() bool {
info, err := os.Lstat(vulnPath)
@@ -266,7 +266,7 @@ func TestScanVulnerabilitiesMkdirFailsIfVulnPathIsFile(t *testing.T) {
CurrentInstanceChecks: "auto",
}
err = scanVulnerabilities(ctx, ds, logger, &config, appConfig, fileVulnPath)
err = scanVulnerabilities(ctx, ds, logger, &config, appConfig, fileVulnPath, &fleet.LicenseInfo{Tier: "premium"})
require.ErrorContains(t, err, "create vulnerabilities databases directory: mkdir")
}
@@ -301,7 +301,7 @@ func TestCronVulnerabilitiesSkipMkdirIfDisabled(t *testing.T) {
CurrentInstanceChecks: "1",
}
go cronVulnerabilities(ctx, ds, logger, "AAA", &config)
go cronVulnerabilities(ctx, ds, logger, "AAA", &config, &fleet.LicenseInfo{Tier: "premium"})
// Every cron tick is 10 seconds ... here we just wait for a loop interation and assert the vuln
// dir. was not created.
+3
View File
@@ -37,6 +37,9 @@ POST https://server.com/example
"vulnerability": {
"cve": "CVE-2014-9471",
"details_link": "https://nvd.nist.gov/vuln/detail/CVE-2014-9471",
"epss_probability": 0.7, // Premium feature only
"cvss_score": 5.7, // Premium feature only
"cisa_known_exploit": true, // Premium feature only
"hosts_affected": [
{
"id": 1,
+33
View File
@@ -0,0 +1,33 @@
package webhooks
import (
"net/url"
"github.com/fleetdm/fleet/v4/server/fleet"
fleetwebhooks "github.com/fleetdm/fleet/v4/server/webhooks"
)
type Mapper struct {
fleetwebhooks.Mapper
}
func NewMapper() fleetwebhooks.VulnMapper {
return &Mapper{}
}
func (m *Mapper) GetPayload(
hostBaseURL *url.URL,
hosts []*fleet.HostShort,
vuln fleet.SoftwareVulnerability,
meta fleet.CVEMeta,
) fleetwebhooks.WebhookPayload {
r := m.Mapper.GetPayload(hostBaseURL,
hosts,
vuln,
meta,
)
r.EPSSProbability = meta.EPSSProbability
r.CVSSScore = meta.CVSSScore
r.CISAKnownExploit = meta.CISAKnownExploit
return r
}
+33
View File
@@ -0,0 +1,33 @@
package webhooks
import (
"net/url"
"testing"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/stretchr/testify/require"
)
func TestGetPayload(t *testing.T) {
serverURL, err := url.Parse("http://mywebsite.com")
require.NoError(t, err)
vuln := fleet.SoftwareVulnerability{
CVE: "cve-1",
SoftwareID: 1,
}
meta := fleet.CVEMeta{
CVE: "cve-1",
CVSSScore: ptr.Float64(1),
EPSSProbability: ptr.Float64(0.5),
CISAKnownExploit: ptr.Bool(true),
}
sut := Mapper{}
result := sut.GetPayload(serverURL, nil, vuln, meta)
require.Equal(t, *meta.CISAKnownExploit, *result.CISAKnownExploit)
require.Equal(t, *meta.EPSSProbability, *result.EPSSProbability)
require.Equal(t, *meta.CVSSScore, *result.CVSSScore)
}
@@ -19,6 +19,9 @@ const PreviewPayloadModal = ({
vulnerability: {
cve: "CVE-2014-9471",
details_link: "https://nvd.nist.gov/vuln/detail/CVE-2014-9471",
epss_probability: 0.7, // Premium feature only
cvss_score: 5.7, // Premium feature only
cisa_known_exploit: true, // Premium feature only
hosts_affected: [
{
id: 1,
+67
View File
@@ -0,0 +1,67 @@
package webhooks
import (
"fmt"
"net/url"
"path"
"strconv"
"github.com/fleetdm/fleet/v4/server/fleet"
)
// VulnMapper used for mapping vulnerabilities and their associated data into the payload that
// will be sent via thrid party webhooks.
type VulnMapper interface {
GetPayload(*url.URL, []*fleet.HostShort, fleet.SoftwareVulnerability, fleet.CVEMeta) WebhookPayload
}
type hostPayloadPart struct {
ID uint `json:"id"`
Hostname string `json:"hostname"`
URL string `json:"url"`
}
type WebhookPayload struct {
CVE string `json:"cve"`
Link string `json:"details_link"`
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
Hosts []*hostPayloadPart `json:"hosts_affected"`
}
type Mapper struct{}
func NewMapper() VulnMapper {
return &Mapper{}
}
func (m *Mapper) getHostPayloadPart(
hostBaseURL *url.URL,
hosts []*fleet.HostShort,
) []*hostPayloadPart {
shortHosts := make([]*hostPayloadPart, len(hosts))
for i, h := range hosts {
hostURL := *hostBaseURL
hostURL.Path = path.Join(hostURL.Path, "hosts", strconv.Itoa(int(h.ID)))
shortHosts[i] = &hostPayloadPart{
ID: h.ID,
Hostname: h.Hostname,
URL: hostURL.String(),
}
}
return shortHosts
}
func (m *Mapper) GetPayload(
hostBaseURL *url.URL,
hosts []*fleet.HostShort,
vuln fleet.SoftwareVulnerability,
meta fleet.CVEMeta,
) WebhookPayload {
return WebhookPayload{
CVE: vuln.CVE,
Link: fmt.Sprintf("https://nvd.nist.gov/vuln/detail/%s", vuln.CVE),
Hosts: m.getHostPayloadPart(hostBaseURL, hosts),
}
}
+33
View File
@@ -0,0 +1,33 @@
package webhooks
import (
"net/url"
"testing"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/stretchr/testify/require"
)
func TestGetPaylaod(t *testing.T) {
serverURL, err := url.Parse("http://mywebsite.com")
require.NoError(t, err)
vuln := fleet.SoftwareVulnerability{
CVE: "cve-1",
SoftwareID: 1,
}
meta := fleet.CVEMeta{
CVE: "cve-1",
CVSSScore: ptr.Float64(1),
EPSSProbability: ptr.Float64(0.5),
CISAKnownExploit: ptr.Bool(true),
}
sut := Mapper{}
result := sut.GetPayload(serverURL, nil, vuln, meta)
require.Empty(t, result.CISAKnownExploit)
require.Empty(t, result.EPSSProbability)
require.Empty(t, result.CVSSScore)
}
+14
View File
@@ -0,0 +1,14 @@
package webhooks
import (
"time"
"github.com/fleetdm/fleet/v4/server/fleet"
)
type VulnArgs struct {
Vulnerablities []fleet.SoftwareVulnerability
Meta map[string]fleet.CVEMeta
AppConfig *fleet.AppConfig
Time time.Time
}
+13 -36
View File
@@ -2,10 +2,7 @@ package webhooks
import (
"context"
"fmt"
"net/url"
"path"
"strconv"
"time"
"github.com/fleetdm/fleet/v4/server"
@@ -20,18 +17,18 @@ func TriggerVulnerabilitiesWebhook(
ctx context.Context,
ds fleet.Datastore,
logger kitlog.Logger,
recentVulns []fleet.SoftwareVulnerability,
appConfig *fleet.AppConfig,
now time.Time,
args VulnArgs,
mapper VulnMapper,
) error {
vulnConfig := appConfig.WebhookSettings.VulnerabilitiesWebhook
vulnConfig := args.AppConfig.WebhookSettings.VulnerabilitiesWebhook
if !vulnConfig.Enable {
return nil
}
level.Debug(logger).Log("enabled", "true", "recentVulns", len(recentVulns))
level.Debug(logger).Log("enabled", "true", "recentVulns", len(args.Vulnerablities))
serverURL, err := url.Parse(appConfig.ServerSettings.ServerURL)
serverURL, err := url.Parse(args.AppConfig.ServerSettings.ServerURL)
if err != nil {
return ctxerr.Wrap(ctx, err, "invalid server url")
}
@@ -40,11 +37,11 @@ func TriggerVulnerabilitiesWebhook(
batchSize := vulnConfig.HostBatchSize
softwareIDsGroupedByCVE := make(map[string][]uint)
for _, v := range recentVulns {
for _, v := range args.Vulnerablities {
softwareIDsGroupedByCVE[v.CVE] = append(softwareIDsGroupedByCVE[v.CVE], v.SoftwareID)
}
for _, v := range recentVulns {
for _, v := range args.Vulnerablities {
softwareIDs := softwareIDsGroupedByCVE[v.CVE]
hosts, err := ds.HostsBySoftwareIDs(ctx, softwareIDs)
@@ -57,7 +54,8 @@ func TriggerVulnerabilitiesWebhook(
if batchSize > 0 && len(hosts) > batchSize {
limit = batchSize
}
if err := sendVulnerabilityHostBatch(ctx, targetURL, v.CVE, serverURL, hosts[:limit], now); err != nil {
payload := mapper.GetPayload(serverURL, hosts[:limit], v, args.Meta[v.CVE])
if err := sendVulnerabilityHostBatch(ctx, targetURL, payload, args.Time); err != nil {
return ctxerr.Wrap(ctx, err, "send vulnerability host batch")
}
hosts = hosts[limit:]
@@ -67,31 +65,10 @@ func TriggerVulnerabilitiesWebhook(
return nil
}
type vulnHostPayload struct {
ID uint `json:"id"`
Hostname string `json:"hostname"`
URL string `json:"url"`
}
func sendVulnerabilityHostBatch(ctx context.Context, targetURL, cve string, hostBaseURL *url.URL, hosts []*fleet.HostShort, now time.Time) error {
shortHosts := make([]*vulnHostPayload, len(hosts))
for i, h := range hosts {
hostURL := *hostBaseURL
hostURL.Path = path.Join(hostURL.Path, "hosts", strconv.Itoa(int(h.ID)))
shortHosts[i] = &vulnHostPayload{
ID: h.ID,
Hostname: h.Hostname,
URL: hostURL.String(),
}
}
func sendVulnerabilityHostBatch(ctx context.Context, targetURL string, vuln WebhookPayload, now time.Time) error {
payload := map[string]interface{}{
"timestamp": now,
"vulnerability": map[string]interface{}{
"cve": cve,
"details_link": fmt.Sprintf("https://nvd.nist.gov/vuln/detail/%s", cve),
"hosts_affected": shortHosts,
},
"timestamp": now,
"vulnerability": vuln,
}
if err := server.PostJSONWithTimeout(ctx, targetURL, &payload); err != nil {
+36 -4
View File
@@ -21,6 +21,7 @@ func TestTriggerVulnerabilitiesWebhook(t *testing.T) {
ctx := context.Background()
ds := new(mock.Store)
logger := kitlog.NewNopLogger()
mapper := Mapper{}
appCfg := &fleet.AppConfig{
WebhookSettings: fleet.WebhookSettings{
@@ -42,20 +43,38 @@ func TestTriggerVulnerabilitiesWebhook(t *testing.T) {
t.Run("disabled", func(t *testing.T) {
appCfg := *appCfg
appCfg.WebhookSettings.VulnerabilitiesWebhook.Enable = false
err := TriggerVulnerabilitiesWebhook(ctx, ds, logger, recentVulns, &appCfg, time.Now())
args := VulnArgs{
Vulnerablities: recentVulns,
Meta: nil,
AppConfig: &appCfg,
Time: time.Now(),
}
err := TriggerVulnerabilitiesWebhook(ctx, ds, logger, args, &mapper)
require.NoError(t, err)
})
t.Run("invalid server url", func(t *testing.T) {
appCfg := *appCfg
appCfg.ServerSettings.ServerURL = ":nope:"
err := TriggerVulnerabilitiesWebhook(ctx, ds, logger, recentVulns, &appCfg, time.Now())
args := VulnArgs{
Vulnerablities: recentVulns,
Meta: nil,
AppConfig: &appCfg,
Time: time.Now(),
}
err := TriggerVulnerabilitiesWebhook(ctx, ds, logger, args, &mapper)
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid server")
})
t.Run("empty recent vulns", func(t *testing.T) {
err := TriggerVulnerabilitiesWebhook(ctx, ds, logger, nil, appCfg, time.Now())
args := VulnArgs{
Vulnerablities: nil,
Meta: nil,
AppConfig: appCfg,
Time: time.Now(),
}
err := TriggerVulnerabilitiesWebhook(ctx, ds, logger, args, &mapper)
require.NoError(t, err)
})
@@ -85,36 +104,42 @@ func TestTriggerVulnerabilitiesWebhook(t *testing.T) {
cases := []struct {
name string
vulns []fleet.SoftwareVulnerability
meta map[string]fleet.CVEMeta
hosts []*fleet.HostShort
want string
}{
{
"1 vuln, 1 host",
[]fleet.SoftwareVulnerability{{CVE: cves[0], SoftwareID: 1}},
nil,
hosts[:1],
fmt.Sprintf("%s[%s]}}", jsonCVE1, jsonH1),
},
{
"1 vuln, 2 hosts",
[]fleet.SoftwareVulnerability{{CVE: cves[0], SoftwareID: 1}},
nil,
hosts[:2],
fmt.Sprintf("%s[%s,%s]}}", jsonCVE1, jsonH1, jsonH2),
},
{
"1 vuln, 3 hosts",
[]fleet.SoftwareVulnerability{{CVE: cves[0], SoftwareID: 1}},
nil,
hosts[:3],
fmt.Sprintf("%s[%s,%s]}}\n%s[%s]}}", jsonCVE1, jsonH1, jsonH2, jsonCVE1, jsonH3), // 2 requests, batch of 2 max
},
{
"1 vuln, 4 hosts",
[]fleet.SoftwareVulnerability{{CVE: cves[0], SoftwareID: 1}},
nil,
hosts[:4],
fmt.Sprintf("%s[%s,%s]}}\n%s[%s,%s]}}", jsonCVE1, jsonH1, jsonH2, jsonCVE1, jsonH3, jsonH4), // 2 requests, batch of 2 max
},
{
"2 vulns, 1 host each",
[]fleet.SoftwareVulnerability{{CVE: cves[0], SoftwareID: 1}, {CVE: cves[1], SoftwareID: 2}},
nil,
hosts[:1],
fmt.Sprintf("%s[%s]}}\n%s[%s]}}", jsonCVE1, jsonH1, jsonCVE2, jsonH1),
},
@@ -138,7 +163,14 @@ func TestTriggerVulnerabilitiesWebhook(t *testing.T) {
appCfg := *appCfg
appCfg.WebhookSettings.VulnerabilitiesWebhook.DestinationURL = srv.URL
err := TriggerVulnerabilitiesWebhook(ctx, ds, logger, c.vulns, &appCfg, now)
args := VulnArgs{
Vulnerablities: c.vulns,
Meta: c.meta,
AppConfig: &appCfg,
Time: now,
}
err := TriggerVulnerabilitiesWebhook(ctx, ds, logger, args, &mapper)
require.NoError(t, err)
assert.True(t, ds.HostsBySoftwareIDsFuncInvoked)