Allow vulnerability webhook to fire for fleet free (#39810)

**Related issue:** Resolves #29076

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements)

## Testing
- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
  * Vulnerability webhooks are now available for free-tier users.

* **Improvements**
* Refined webhook payload display for free-tier users by removing
certain advanced vulnerability metrics.
* Updated UI text descriptions in automation management to reflect
free-tier vulnerability scanning behavior.
* Simplified permission requirements for accessing automation management
features.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Konstantin Sykulev
2026-02-16 11:02:48 -06:00
committed by GitHub
parent 1d1e98c3d4
commit 3f8875cbdf
6 changed files with 211 additions and 16 deletions
+1
View File
@@ -0,0 +1 @@
* Updated logic to trigger vulnerability webhook when on fleet free tier
+12 -6
View File
@@ -206,14 +206,20 @@ func scanVulnerabilities(
vulns = append(vulns, govalDictVulns...)
vulns = append(vulns, customVulns...)
meta, err := ds.ListCVEs(ctx, config.RecentVulnerabilityMaxAge)
if err != nil {
errHandler(ctx, logger, "could not fetch CVE meta", err)
return nil
var recentV []fleet.SoftwareVulnerability
var matchingMeta map[string]fleet.CVEMeta
if license.IsPremium(ctx) {
meta, err := ds.ListCVEs(ctx, config.RecentVulnerabilityMaxAge)
if err != nil {
errHandler(ctx, logger, "could not fetch CVE meta", err)
return nil
}
recentV, matchingMeta = utils.RecentVulns(vulns, meta)
} else {
recentV = vulns
matchingMeta = make(map[string]fleet.CVEMeta)
}
recentV, matchingMeta := utils.RecentVulns(vulns, meta)
if len(recentV) > 0 {
switch vulnAutomationEnabled {
case "webhook":
+167
View File
@@ -596,6 +596,173 @@ func TestScanVulnerabilities(t *testing.T) {
require.Equal(t, 1, webhookCount)
}
func TestScanVulnerabilitiesFreeTier(t *testing.T) {
nettest.Run(t)
logger := logging.NewNopLogger()
ctx := context.Background()
var mu sync.Mutex
var webhookCVEs []string
svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var payload map[string]json.RawMessage
err := json.NewDecoder(r.Body).Decode(&payload)
require.NoError(t, err)
// Free tier payload
var vuln map[string]json.RawMessage
require.NoError(t, json.Unmarshal(payload["vulnerability"], &vuln))
require.NotContains(t, vuln, "epss_probability")
require.NotContains(t, vuln, "cvss_score")
require.NotContains(t, vuln, "cisa_known_exploit")
require.NotContains(t, vuln, "cve_published")
require.Contains(t, vuln, "cve")
require.Contains(t, vuln, "details_link")
require.Contains(t, vuln, "hosts_affected")
var cve string
require.NoError(t, json.Unmarshal(vuln["cve"], &cve))
mu.Lock()
webhookCVEs = append(webhookCVEs, cve)
mu.Unlock()
}))
appConfig := &fleet.AppConfig{
Features: fleet.Features{
EnableSoftwareInventory: true,
},
WebhookSettings: fleet.WebhookSettings{
VulnerabilitiesWebhook: fleet.VulnerabilitiesWebhookSettings{
Enable: true,
DestinationURL: svr.URL,
},
},
}
ds := new(mock.Store)
ds.InsertCVEMetaFunc = func(ctx context.Context, x []fleet.CVEMeta) error {
return nil
}
ds.AllSoftwareIteratorFunc = func(ctx context.Context, query fleet.SoftwareIterQueryOptions) (fleet.SoftwareIterator, error) {
iterator := &softwareIterator{
softwares: []*fleet.Software{
{
ID: 1,
Name: "Twisted",
Version: "22.2.0",
BundleIdentifier: "",
Source: "python_packages",
},
},
}
return iterator, nil
}
ds.ListSoftwareCPEsFunc = func(ctx context.Context) ([]fleet.SoftwareCPE, error) {
return []fleet.SoftwareCPE{
{
ID: 1,
SoftwareID: 1,
CPE: "cpe:2.3:a:twistedmatrix:twisted:22.2.0:*:*:*:*:python:*:*",
},
}, nil
}
ds.InsertSoftwareVulnerabilityFunc = func(ctx context.Context, vuln fleet.SoftwareVulnerability, src fleet.VulnerabilitySource) (bool, error) {
return true, nil
}
ds.UpsertSoftwareCPEsFunc = func(ctx context.Context, cpes []fleet.SoftwareCPE) (int64, error) {
return int64(0), nil
}
ds.DeleteSoftwareCPEsFunc = func(ctx context.Context, cpes []fleet.SoftwareCPE) (int64, error) {
return int64(0), nil
}
ds.DeleteOutOfDateVulnerabilitiesFunc = func(ctx context.Context, source fleet.VulnerabilitySource, olderThan time.Time) error {
return nil
}
ds.OSVersionsFunc = func(
ctx context.Context, teamFilter *fleet.TeamFilter, platform *string, name *string, version *string,
) (*fleet.OSVersions, error) {
return &fleet.OSVersions{
CountsUpdatedAt: time.Now(),
OSVersions: []fleet.OSVersion{
{HostsCount: 1, Name: "Ubuntu 22.04.1 LTS", Platform: "ubuntu"},
},
}, nil
}
ds.HostIDsByOSVersionFunc = func(ctx context.Context, osVersion fleet.OSVersion, offset int, limit int) ([]uint, error) {
if offset == 0 {
return []uint{1}, nil
}
return []uint{}, nil
}
ds.ListSoftwareForVulnDetectionFunc = func(ctx context.Context, filter fleet.VulnSoftwareFilter) ([]fleet.Software, error) {
return []fleet.Software{
{
ID: 1,
Name: "Twisted",
Version: "22.2.0",
BundleIdentifier: "",
Source: "python_packages",
},
}, nil
}
ds.ListSoftwareVulnerabilitiesByHostIDsSourceFunc = func(ctx context.Context, hostIDs []uint, source fleet.VulnerabilitySource) (map[uint][]fleet.SoftwareVulnerability, error) {
require.Equal(t, []uint{1}, hostIDs)
require.Equal(t, fleet.UbuntuOVALSource, source)
return map[uint][]fleet.SoftwareVulnerability{}, nil
}
ds.ListOperatingSystemsFunc = func(ctx context.Context) ([]fleet.OperatingSystem, error) {
return []fleet.OperatingSystem{
{
ID: 1,
Name: "Ubuntu",
Version: "22.04.1 LTS",
Arch: "x86_64",
KernelVersion: "5.10.124-linuxkit",
},
}, nil
}
ds.ListOperatingSystemsForPlatformFunc = func(ctx context.Context, platform string) ([]fleet.OperatingSystem, error) {
return []fleet.OperatingSystem{}, nil
}
ds.DeleteOutOfDateOSVulnerabilitiesFunc = func(ctx context.Context, src fleet.VulnerabilitySource, t time.Time) error {
return nil
}
ds.ListCVEsFunc = func(ctx context.Context, maxAge time.Duration) ([]fleet.CVEMeta, error) {
t.Error("ListCVEs should not be called on free tier")
return nil, nil
}
ds.HostVulnSummariesBySoftwareIDsFunc = func(ctx context.Context, softwareIDs []uint) ([]fleet.HostVulnerabilitySummary, error) {
return []fleet.HostVulnerabilitySummary{
{
ID: 1,
Hostname: "1",
DisplayName: "1",
},
}, nil
}
ds.IsHostConnectedToFleetMDMFunc = func(ctx context.Context, host *fleet.Host) (bool, error) {
return true, nil
}
vulnPath := filepath.Join("..", "..", "server", "vulnerabilities", "testdata")
vulnsConfig := config.VulnerabilitiesConfig{
DatabasesPath: vulnPath,
Periodicity: 10 * time.Second,
CurrentInstanceChecks: "auto",
DisableDataSync: true,
}
ctx = license.NewContext(ctx, &fleet.LicenseInfo{Tier: fleet.TierFree})
err := scanVulnerabilities(ctx, ds, logger, &vulnsConfig, appConfig, vulnPath)
require.NoError(t, err)
require.False(t, ds.DeleteSoftwareVulnerabilitiesFuncInvoked)
require.NotEmpty(t, webhookCVEs)
}
func TestUpdateVulnHostCounts(t *testing.T) {
logger := logging.NewNopLogger()
+1 -1
View File
@@ -327,7 +327,7 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => {
);
const renderPageActions = () => {
const canManageAutomations = isGlobalAdmin && isPremiumTier;
const canManageAutomations = isGlobalAdmin;
const canAddSoftware =
isGlobalAdmin || isGlobalMaintainer || isTeamAdmin || isTeamMaintainer;
@@ -133,7 +133,9 @@ const ManageAutomationsModal = ({
setSelectedIntegration,
] = useState<IIntegration>();
const { config: globalConfigFromContext } = useContext(AppContext);
const { config: globalConfigFromContext, isFreeTier } = useContext(
AppContext
);
const gitOpsModeEnabled = globalConfigFromContext?.gitops.gitops_mode_enabled;
const maxAgeInNanoseconds = isGlobalSWConfig(softwareConfig)
@@ -376,11 +378,20 @@ const ManageAutomationsModal = ({
return (
<>
<div className={`${baseClass}__software-automation-description`}>
A ticket will be created in your <b>Integration</b> if a detected
vulnerability (CVE) was published in the last{" "}
{recentVulnerabilityMaxAge ||
CONFIG_DEFAULT_RECENT_VULNERABILITY_MAX_AGE_IN_DAYS}{" "}
days.
{isFreeTier ? (
<>
A ticket will be created in your <b>Integration</b> for each
detected vulnerability (CVE).
</>
) : (
<>
A ticket will be created in your <b>Integration</b> if a detected
vulnerability (CVE) was published in the last{" "}
{recentVulnerabilityMaxAge ||
CONFIG_DEFAULT_RECENT_VULNERABILITY_MAX_AGE_IN_DAYS}{" "}
days.
</>
)}
</div>
{(jiraIntegrationsIndexed && jiraIntegrationsIndexed.length > 0) ||
(zendeskIntegrationsIndexed &&
@@ -427,9 +438,18 @@ const ManageAutomationsModal = ({
<>
<div className={`${baseClass}__software-automation-description`}>
<p>
A request will be sent to your configured <b>Destination URL</b> if
a detected vulnerability (CVE) was published in the last{" "}
{recentVulnerabilityMaxAge || "30"} days.
{isFreeTier ? (
<>
A request will be sent to your configured <b>Destination URL</b>{" "}
for each detected vulnerability (CVE).
</>
) : (
<>
A request will be sent to your configured <b>Destination URL</b>{" "}
if a detected vulnerability (CVE) was published in the last{" "}
{recentVulnerabilityMaxAge || "30"} days.
</>
)}
</p>
</div>
<InputField
@@ -65,6 +65,7 @@ const PreviewPayloadModal = ({
delete json.vulnerability.epss_probability;
delete json.vulnerability.cvss_score;
delete json.vulnerability.cisa_known_exploit;
delete json.vulnerability.cve_published;
}
return (