diff --git a/changes/35694-device-software-cvss-filter b/changes/35694-device-software-cvss-filter new file mode 100644 index 00000000000..2849e432070 --- /dev/null +++ b/changes/35694-device-software-cvss-filter @@ -0,0 +1 @@ +- Added the ability to filter vulnerable software by severity (CVSS score) and known exploit status on the Fleet Desktop **My device > Software** tab (Fleet Premium). The corresponding `min_cvss_score`, `max_cvss_score`, and `exploit` query parameters were added to the `GET /device/{token}/software` API endpoint. diff --git a/docs/Contributing/reference/api-for-contributors.md b/docs/Contributing/reference/api-for-contributors.md index 7a4277e3109..764ab040b3a 100644 --- a/docs/Contributing/reference/api-for-contributors.md +++ b/docs/Contributing/reference/api-for-contributors.md @@ -3402,6 +3402,10 @@ Lists the software installed on the current device. | token | string | path | The device's authentication token. | | self_service | bool | query | Filter `self_service` software. | | query | string | query | Search query keywords. Searchable fields include `name`. | +| vulnerable | boolean | query | If `true` or `1`, only list software that have vulnerabilities. Default is `false`. | +| min_cvss_score | integer | query | _Available in Fleet Premium_. Filters to include only software with vulnerabilities that have a CVSS version 3.x base score higher than the specified value. Must be provided with `vulnerable=true`. | +| max_cvss_score | integer | query | _Available in Fleet Premium_. Filters to only include software with vulnerabilities that have a CVSS version 3.x base score lower than what's specified. Must be provided with `vulnerable=true`. | +| exploit | boolean | query | _Available in Fleet Premium_. If `true`, filters to only include software with vulnerabilities that have been actively exploited in the wild (`cisa_known_exploit: true`). Default is `false`. Must be provided with `vulnerable=true`. | | page | integer | query | Page number of the results to fetch.| | per_page | integer | query | Results per page.| diff --git a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx index 4574c68e057..0ddf229ed95 100644 --- a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx +++ b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx @@ -115,6 +115,9 @@ interface IDeviceUserPageProps { pathname: string; query: { vulnerable?: string; + exploit?: string; + min_cvss_score?: string; + max_cvss_score?: string; page?: string; query?: string; order_key?: string; @@ -845,6 +848,7 @@ const DeviceUserPage = ({ pathname={location.pathname} queryParams={parseHostSoftwareQueryParams(location.query)} isMyDevicePage + isPremiumTier={isPremiumTier} platform={host.platform} hostTeamId={host.team_id || 0} isSoftwareEnabled={isSoftwareEnabled} diff --git a/frontend/pages/hosts/details/cards/Software/HostSoftware.tsx b/frontend/pages/hosts/details/cards/Software/HostSoftware.tsx index ad9c28d2cce..8e5c1b116ff 100644 --- a/frontend/pages/hosts/details/cards/Software/HostSoftware.tsx +++ b/frontend/pages/hosts/details/cards/Software/HostSoftware.tsx @@ -65,6 +65,13 @@ interface IHostSoftwareProps { onShowInventoryVersions: (software: IHostSoftware) => void; isSoftwareEnabled?: boolean; isMyDevicePage?: boolean; + /** + * Premium status for the My device page. The device page is token-authenticated + * and has no app session, so `isPremiumTier` is not available from the app + * context there and must be passed in explicitly from the device's license info. + * Ignored on the host details page, which reads premium status from the app context. + */ + isPremiumTier?: boolean; /** Used to show custom Software card header */ hostMdmEnrollmentStatus?: MdmEnrollmentStatus | null; } @@ -144,9 +151,16 @@ const HostSoftware = ({ onShowInventoryVersions, isSoftwareEnabled = false, isMyDevicePage = false, + isPremiumTier: isPremiumTierProp, hostMdmEnrollmentStatus = null, }: IHostSoftwareProps) => { - const { isPremiumTier } = useContext(AppContext); + const { isPremiumTier: isPremiumTierFromContext } = useContext(AppContext); + // The My device page is token-authenticated and has no app session/context, so + // its premium status is provided explicitly by the caller. Everywhere else we + // read it from the app context. + const isPremiumTier = isMyDevicePage + ? isPremiumTierProp + : isPremiumTierFromContext; // The /Applications filter only applies to macOS hosts, and defaults to ON // (only top-level applications) when the host is macOS and no explicit value diff --git a/server/service/hosts.go b/server/service/hosts.go index 67b53c8baf6..cd09f2a4305 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -4074,6 +4074,16 @@ func (svc *Service) ListHostSoftware(ctx context.Context, hostID uint, opts flee host = h } + // Vulnerability severity filters (CVSS score, known exploit) are a Fleet Premium feature. + // This applies to both the user-authenticated host software endpoint and the + // device-authenticated "My device" software endpoint. The vulnerable=true requirement for + // these filters is enforced in the datastore. + if opts.MinimumCVSS > 0 || opts.MaximumCVSS > 0 || opts.KnownExploit { + if !license.IsPremium(ctx) { + return nil, nil, fleet.ErrMissingLicense + } + } + mdmEnrolled, err := svc.ds.IsHostConnectedToFleetMDM(ctx, host) if err != nil { return nil, nil, ctxerr.Wrap(ctx, err, "checking mdm enrollment status") diff --git a/server/service/integration_desktop_test.go b/server/service/integration_desktop_test.go index bd88d9fc00a..c085cfd07b2 100644 --- a/server/service/integration_desktop_test.go +++ b/server/service/integration_desktop_test.go @@ -174,6 +174,18 @@ func (s *integrationTestSuite) TestDeviceAuthenticatedEndpoints() { require.NoError(t, json.NewDecoder(res.Body).Decode(&getDesktopResp)) require.NoError(t, res.Body.Close()) require.Nil(t, getDesktopResp.FailingPolicies) + + // vulnerability severity filters (CVSS score, known exploit) are premium-only and must be + // rejected with a missing-license error on the free-tier device software endpoint, rather + // than being silently served. + for _, premiumFilter := range []string{"min_cvss_score=1", "max_cvss_score=10", "exploit=true"} { + res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software?vulnerable=true&"+premiumFilter, nil, http.StatusPaymentRequired) + require.NoError(t, res.Body.Close()) + } + + // the (non-premium) vulnerable filter is still served on the free tier. + res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software?vulnerable=true", nil, http.StatusOK) + require.NoError(t, res.Body.Close()) } // TestDefaultTransparencyURL tests that Fleet Free licensees are restricted to the default transparency url. diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index df83f2e4eb5..fa81ec5f07c 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -12305,6 +12305,14 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() { _, err = s.ds.InsertSoftwareVulnerability(ctx, fleet.SoftwareVulnerability{SoftwareID: barSoftwareID, CVE: "CVE-bar-5678"}, fleet.NVDSource) require.NoError(t, err) + // Add CVSS scores and exploit metadata so the severity filters have data to match against. + // "bar" has one critical, actively-exploited CVE and one low-severity CVE, so it should match + // both a critical filter and a low filter (matches if ANY CVE is in range). + require.NoError(t, s.ds.InsertCVEMeta(ctx, []fleet.CVEMeta{ + {CVE: "CVE-bar-1234", CVSSScore: new(float64(9.5)), CISAKnownExploit: new(true)}, + {CVE: "CVE-bar-5678", CVSSScore: new(float64(3.0)), CISAKnownExploit: new(false)}, + })) + getHostSw = getHostSoftwareResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw, "vulnerable", "true") require.Len(t, getHostSw.Software, 1) @@ -12562,6 +12570,93 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() { assert.Equal(t, *getHostSw.Software[0].Status, fleet.SoftwareInstallPending) assert.NotNil(t, getHostSw.Software[0].SoftwarePackage) assert.Equal(t, "1:2.5.1", getHostSw.Software[0].SoftwarePackage.Version) + + // ========================================= + // vulnerability severity filters on the device-authenticated "My device" software endpoint + // ========================================= + // + // "bar" has two CVEs: CVE-bar-1234 (CVSS 9.5, actively exploited) and CVE-bar-5678 (CVSS 3.0). + // "foo" has no vulnerabilities. The host also has the (not installed) "ruby" installer, which + // must never appear in the inventory-only device responses below. + deviceSoftware := func(t *testing.T, query string) getDeviceSoftwareResponse { + res := s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software?"+query, nil, http.StatusOK) + defer res.Body.Close() + var resp getDeviceSoftwareResponse + require.NoError(t, json.NewDecoder(res.Body).Decode(&resp)) + return resp + } + + // vulnerable=true returns only software with vulnerabilities. + resp := deviceSoftware(t, "vulnerable=true") + require.Len(t, resp.Software, 1) + require.Equal(t, "bar", resp.Software[0].Name) + require.Equal(t, 1, resp.Count) + + // min_cvss_score is inclusive: 9.5 is returned for min_cvss_score=9.5 (the critical band edge). + resp = deviceSoftware(t, "vulnerable=true&min_cvss_score=9.5") + require.Len(t, resp.Software, 1) + require.Equal(t, "bar", resp.Software[0].Name) + + // a min above all scores returns nothing. + resp = deviceSoftware(t, "vulnerable=true&min_cvss_score=9.6") + require.Empty(t, resp.Software) + require.Equal(t, 0, resp.Count) + + // max_cvss_score is inclusive: a low max still matches the low-severity CVE (3.0). + resp = deviceSoftware(t, "vulnerable=true&max_cvss_score=3.0") + require.Len(t, resp.Software, 1) + require.Equal(t, "bar", resp.Software[0].Name) + + // a max below all scores returns nothing. + resp = deviceSoftware(t, "vulnerable=true&max_cvss_score=2.9") + require.Empty(t, resp.Software) + + // min + max together apply both bounds (range filter). + resp = deviceSoftware(t, "vulnerable=true&min_cvss_score=1&max_cvss_score=10") + require.Len(t, resp.Software, 1) + require.Equal(t, "bar", resp.Software[0].Name) + + // min == max returns only CVEs scored exactly that value (3.0 matches, 5.0 does not). + resp = deviceSoftware(t, "vulnerable=true&min_cvss_score=3.0&max_cvss_score=3.0") + require.Len(t, resp.Software, 1) + resp = deviceSoftware(t, "vulnerable=true&min_cvss_score=5.0&max_cvss_score=5.0") + require.Empty(t, resp.Software) + + // min > max yields an empty result, not a server error. + resp = deviceSoftware(t, "vulnerable=true&min_cvss_score=8&max_cvss_score=2") + require.Empty(t, resp.Software) + + // a title with multiple CVEs of differing scores appears under BOTH a low and a critical + // filter (matches if ANY CVE is in range). + resp = deviceSoftware(t, "vulnerable=true&min_cvss_score=9.0&max_cvss_score=10") + require.Len(t, resp.Software, 1) + require.Equal(t, "bar", resp.Software[0].Name) + resp = deviceSoftware(t, "vulnerable=true&min_cvss_score=0.1&max_cvss_score=3.9") + require.Len(t, resp.Software, 1) + require.Equal(t, "bar", resp.Software[0].Name) + + // exploit=true returns only software with a CISA known-exploited CVE. + resp = deviceSoftware(t, "vulnerable=true&exploit=true") + require.Len(t, resp.Software, 1) + require.Equal(t, "bar", resp.Software[0].Name) + + // exploit combined with a range that only covers the non-exploited CVE returns nothing, + // since the exploited CVE (9.5) is outside the 0.1-3.9 band. + resp = deviceSoftware(t, "vulnerable=true&exploit=true&min_cvss_score=0.1&max_cvss_score=3.9") + require.Empty(t, resp.Software) + + // filters combine with the search query: "foo" has no vulnerabilities so it is filtered out. + resp = deviceSoftware(t, "vulnerable=true&query=foo") + require.Empty(t, resp.Software) + resp = deviceSoftware(t, "vulnerable=true&query=bar") + require.Len(t, resp.Software, 1) + require.Equal(t, "bar", resp.Software[0].Name) + + // severity filters require vulnerable=true; otherwise the request is rejected with 422. + for _, premiumFilter := range []string{"min_cvss_score=1", "max_cvss_score=10", "exploit=true"} { + res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software?"+premiumFilter, nil, http.StatusUnprocessableEntity) + require.NoError(t, res.Body.Close()) + } } func checkSoftwareTitle(t *testing.T, ds *mysql.Datastore, title string, source string) uint {