diff --git a/changes/44746-set-vuln-filters-in-gitops b/changes/44746-set-vuln-filters-in-gitops new file mode 100644 index 00000000000..6a393e6e96f --- /dev/null +++ b/changes/44746-set-vuln-filters-in-gitops @@ -0,0 +1 @@ +- Added ability to set default Vulnerability Exposure chart filters via GitOps. diff --git a/docs/Configuration/yaml-files.md b/docs/Configuration/yaml-files.md index 33aed7a948b..1c728e0a753 100644 --- a/docs/Configuration/yaml-files.md +++ b/docs/Configuration/yaml-files.md @@ -718,6 +718,12 @@ The `features` section of the configuration YAML lets you turn on/off Fleet feat - `vulnerabilities` — per-host software vulnerability data that drive the **Vulnerability exposure** dashboard chart. A dataset is collected for a given host only when the sub-key is `true` at both the global level (`org_settings.features.historical_data`) and the host's fleet level (`settings.features.historical_data`). Setting a sub-key to `false` at either level disables collection for the affected hosts. Flipping the global sub-key off disables it for every fleet, regardless of per-fleet settings. +- `vulnerability_exposure_historical_reporting` (Fleet Premium) sets the **default** filters applied to the **Vulnerability exposure** dashboard chart when the page loads. These are display defaults only — they do not change which data Fleet collects. A user can adjust the filters in the UI, but those changes are not saved; GitOps is the only way to persist them. Each key is optional; an omitted key uses the chart's built-in default. + - `software_filters` is the list of software categories to show. Valid values: `os` (operating system and kernel), `browsers` (Google Chrome, Safari, Mozilla Firefox, Brave, and Opera), `office` (Word, Excel, PowerPoint, and Outlook), and `adobe` (Acrobat, Flash, and Shockwave Player). Omit the key to include all categories; if the key is present it must list at least one category (an empty list is rejected). + - `cvss_min` / `cvss_max` filter by CVSS v3 base score (`0`–`10`). (Accepted and stored now; takes effect in a future release that adds the severity control.) + - `epss_min` / `epss_max` filter by probability of exploit (EPSS) score, expressed as `0`–`100`. + - `has_known_exploit`, when `true`, shows only vulnerabilities with a known exploit (CISA KEV). + - `exclude_vulnerabilities` is a list of CVE identifiers to exclude. Can be configured for "All fleets" (`org_settings`) and specific fleets (`settings`). @@ -734,6 +740,20 @@ org_settings: historical_data: uptime: true vulnerabilities: false + vulnerability_exposure_historical_reporting: + software_filters: + - os + - browsers + - office + - adobe + has_known_exploit: true + epss_min: 0 + epss_max: 100 + cvss_min: 9 + cvss_max: 10 + exclude_vulnerabilities: + - CVE-2025-50897 + - CVE-2025-76306 ``` ### fleet_desktop diff --git a/ee/server/service/teams.go b/ee/server/service/teams.go index 5cd6a75ed61..4646e822f67 100644 --- a/ee/server/service/teams.go +++ b/ee/server/service/teams.go @@ -1403,6 +1403,23 @@ func (svc *Service) ApplyTeamSpecs(ctx context.Context, specs []*fleet.TeamSpec, return idsByName, nil } +// validateVulnExposureFilters validates a team's vulnerability-exposure chart +// filter defaults (display-only defaults that seed the dashboard chart's +// filter controls; they do not affect data collection). Sparse/PATCH +// semantics: only present fields are checked. Teams are premium-only, so no +// separate license gate is required here. +func validateVulnExposureFilters(ctx context.Context, veFilters *fleet.VulnExposureFilterSettings) error { + if veFilters == nil { + return nil + } + invalid := &fleet.InvalidArgumentError{} + veFilters.Validate("team.settings.features", invalid) + if invalid.HasErrors() { + return ctxerr.Wrap(ctx, invalid) + } + return nil +} + func (svc *Service) createTeamFromSpec( ctx context.Context, spec *fleet.TeamSpec, @@ -1425,6 +1442,9 @@ func (svc *Service) createTeamFromSpec( return nil, err } } + if err := validateVulnExposureFilters(ctx, features.VulnerabilityExposureHistoricalReporting); err != nil { + return nil, err + } var macOSSettings fleet.MacOSSettings if err := svc.applyTeamMacOSSettings(ctx, spec, &macOSSettings); err != nil { @@ -1647,6 +1667,9 @@ func (svc *Service) editTeamFromSpec( return err } team.Config.Features = features + if err := validateVulnExposureFilters(ctx, team.Config.Features.VulnerabilityExposureHistoricalReporting); err != nil { + return err + } // Check OS update settings. var ( diff --git a/frontend/interfaces/charts.ts b/frontend/interfaces/charts.ts index 587a3424a75..0ca9dedb1b6 100644 --- a/frontend/interfaces/charts.ts +++ b/frontend/interfaces/charts.ts @@ -48,6 +48,24 @@ export const ALL_CVE_SOFTWARE_CATEGORY_VALUES = CVE_SOFTWARE_CATEGORIES.map( (c) => c.value as string ); +// Persisted, GitOps-managed default filter state for the Vulnerability exposure +// (CVE) chart, surfaced under features.vulnerability_exposure_historical_reporting. +// Every field is optional: an omitted (undefined) field means "use the chart's +// built-in default" for that control, while a present field seeds it. EPSS +// bounds are expressed as 0–100 (matching the UI; the chart API call converts +// to 0–1). Categories use the CVE_SOFTWARE_CATEGORIES values. cvss_min/cvss_max +// are persisted but not yet consumed by the dashboard (the severity control +// lands in #47326). +export interface IVulnExposureFilterDefaults { + software_filters?: string[]; + cvss_min?: number; + cvss_max?: number; + epss_min?: number; + epss_max?: number; + has_known_exploit?: boolean; + exclude_vulnerabilities?: string[]; +} + export interface IFormattedDataPoint { timestamp: string; label: string; diff --git a/frontend/interfaces/config.ts b/frontend/interfaces/config.ts index 7d4f27876b0..ead128a9bff 100644 --- a/frontend/interfaces/config.ts +++ b/frontend/interfaces/config.ts @@ -7,6 +7,7 @@ import { } from "interfaces/webhook"; import { IGlobalIntegrations } from "./integration"; import { EndUserLocalAccountType } from "./mdm"; +import { IVulnExposureFilterDefaults } from "./charts"; export interface ILicense { tier: string; @@ -130,6 +131,9 @@ export interface IConfigFeatures { uptime: boolean; vulnerabilities: boolean; }; + // GitOps-managed default filter state for the Vulnerability exposure chart. + // Optional/sparse: absent fields fall back to the chart's built-in defaults. + vulnerability_exposure_historical_reporting?: IVulnExposureFilterDefaults; } export interface IConfigServerSettings { diff --git a/frontend/pages/DashboardPage/DashboardPage.tsx b/frontend/pages/DashboardPage/DashboardPage.tsx index bc47c0c20a9..d46d80ddb55 100644 --- a/frontend/pages/DashboardPage/DashboardPage.tsx +++ b/frontend/pages/DashboardPage/DashboardPage.tsx @@ -945,6 +945,9 @@ const DashboardPage = ({ router, location }: IDashboardProps): JSX.Element => { diff --git a/frontend/pages/DashboardPage/cards/ChartCard/ChartCard.tests.tsx b/frontend/pages/DashboardPage/cards/ChartCard/ChartCard.tests.tsx index cccc40f85aa..a946b8ba16b 100644 --- a/frontend/pages/DashboardPage/cards/ChartCard/ChartCard.tests.tsx +++ b/frontend/pages/DashboardPage/cards/ChartCard/ChartCard.tests.tsx @@ -5,8 +5,9 @@ import { http, HttpResponse } from "msw"; import { createCustomRenderer, baseUrl } from "test/test-utils"; import mockServer from "test/mock-server"; +import { ALL_CVE_SOFTWARE_CATEGORY_VALUES } from "interfaces/charts"; -import ChartCard from "./ChartCard"; +import ChartCard, { buildInitialChartFilters } from "./ChartCard"; // Mock ResizeObserver for CheckerboardViz const MOCK_WIDTH = 600; @@ -199,3 +200,46 @@ describe("ChartCard", () => { ).not.toBeInTheDocument(); }); }); + +describe("buildInitialChartFilters", () => { + it("uses built-in defaults when no persisted defaults are provided", () => { + const filters = buildInitialChartFilters(undefined); + expect(filters.softwareFilters).toEqual([ + ...ALL_CVE_SOFTWARE_CATEGORY_VALUES, + ]); + expect(filters.knownExploit).toBe(false); + expect(filters.epssMin).toBe(""); + expect(filters.epssMax).toBe(""); + expect(filters.excludeCVEs).toEqual([]); + }); + + it("seeds present fields and falls back per-field for absent ones", () => { + const filters = buildInitialChartFilters({ + software_filters: ["browsers"], + has_known_exploit: true, + }); + expect(filters.softwareFilters).toEqual(["browsers"]); + expect(filters.knownExploit).toBe(true); + expect(filters.epssMin).toBe(""); + expect(filters.epssMax).toBe(""); + expect(filters.excludeCVEs).toEqual([]); + }); + + it("converts numeric EPSS bounds (0-100) to strings", () => { + const filters = buildInitialChartFilters({ epss_min: 0, epss_max: 90 }); + expect(filters.epssMin).toBe("0"); + expect(filters.epssMax).toBe("90"); + }); + + it("honors an explicit empty software_filters list as 'none'", () => { + const filters = buildInitialChartFilters({ software_filters: [] }); + expect(filters.softwareFilters).toEqual([]); + }); + + it("seeds the exclude-CVE list", () => { + const filters = buildInitialChartFilters({ + exclude_vulnerabilities: ["CVE-2025-50897"], + }); + expect(filters.excludeCVEs).toEqual(["CVE-2025-50897"]); + }); +}); diff --git a/frontend/pages/DashboardPage/cards/ChartCard/ChartCard.tsx b/frontend/pages/DashboardPage/cards/ChartCard/ChartCard.tsx index 5d3e231e737..dfffc96ce2b 100644 --- a/frontend/pages/DashboardPage/cards/ChartCard/ChartCard.tsx +++ b/frontend/pages/DashboardPage/cards/ChartCard/ChartCard.tsx @@ -28,6 +28,7 @@ import { HistoricalDataConfigKey, CVE_SOFTWARE_CATEGORIES, ALL_CVE_SOFTWARE_CATEGORY_VALUES, + IVulnExposureFilterDefaults, } from "interfaces/charts"; import { AppContext } from "context/app"; @@ -59,6 +60,42 @@ const DEFAULT_CHART_FILTERS: IChartFilterState = { excludeCVEs: [], }; +// Seed the chart's initial filter state from the persisted, GitOps-managed +// defaults. Sparse/per-field: an undefined field falls back to the built-in +// DEFAULT_CHART_FILTERS value, while a present field (including an explicit +// empty software_filters list, meaning "no categories") is respected. EPSS +// bounds are numbers (0–100) in the config and strings in the filter state. +// cvss_min/cvss_max are intentionally NOT wired — there is no severity control +// yet (#47326). +export const buildInitialChartFilters = ( + defaults?: IVulnExposureFilterDefaults +): IChartFilterState => { + if (!defaults) return DEFAULT_CHART_FILTERS; + return { + ...DEFAULT_CHART_FILTERS, + softwareFilters: + defaults.software_filters !== undefined + ? [...defaults.software_filters] + : DEFAULT_CHART_FILTERS.softwareFilters, + knownExploit: + defaults.has_known_exploit !== undefined + ? defaults.has_known_exploit + : DEFAULT_CHART_FILTERS.knownExploit, + epssMin: + defaults.epss_min !== undefined + ? String(defaults.epss_min) + : DEFAULT_CHART_FILTERS.epssMin, + epssMax: + defaults.epss_max !== undefined + ? String(defaults.epss_max) + : DEFAULT_CHART_FILTERS.epssMax, + excludeCVEs: + defaults.exclude_vulnerabilities !== undefined + ? [...defaults.exclude_vulnerabilities] + : DEFAULT_CHART_FILTERS.excludeCVEs, + }; +}; + const hasActiveHostFilters = (filters: IChartFilterState): boolean => { const hasHostFilter = filters.hostFilterMode !== "none" && filters.selectedHosts.length > 0; @@ -166,17 +203,21 @@ const filterTooltip = ( interface IChartCardProps { currentTeamId?: number; historicalDataEnabled?: Record; + // GitOps-managed default filter state for the current scope (org or fleet). + // Seeds the chart's filter controls on load; UI edits are not persisted. + filterDefaults?: IVulnExposureFilterDefaults; } const ChartCard = ({ currentTeamId, historicalDataEnabled, + filterDefaults, }: IChartCardProps): JSX.Element => { const [selectedMetric, setSelectedMetric] = useState("uptime"); const [showFilterModal, setShowFilterModal] = useState(false); const [initialTab, setInitialTab] = useState("hosts"); - const [chartFilters, setChartFilters] = useState( - DEFAULT_CHART_FILTERS + const [chartFilters, setChartFilters] = useState(() => + buildInitialChartFilters(filterDefaults) ); const openFilterModal = (tab: ChartFilterTab = "hosts") => { @@ -242,9 +283,12 @@ const ChartCard = ({ // Labels and selected hosts are team-scoped, so clear filters when the // active fleet changes to avoid submitting stale IDs under the new scope. + // Re-seed from the persisted defaults when the scope changes (fleet switch) + // or once the config/fleet data finishes loading. This also discards any + // ephemeral UI edits, matching the "UI edits are not saved" behavior. useEffect(() => { - setChartFilters(DEFAULT_CHART_FILTERS); - }, [currentTeamId]); + setChartFilters(buildInitialChartFilters(filterDefaults)); + }, [currentTeamId, filterDefaults]); const currentDataset = getDataset(selectedMetric); diff --git a/server/fleet/app.go b/server/fleet/app.go index 1a1d1a6f0a2..4198fff8833 100644 --- a/server/fleet/app.go +++ b/server/fleet/app.go @@ -865,6 +865,7 @@ func (c *AppConfig) Copy() *AppConfig { clone.Features.DetailQueryOverrides[k] = s } } + clone.Features.VulnerabilityExposureHistoricalReporting = c.Features.VulnerabilityExposureHistoricalReporting.Copy() if c.AgentOptions != nil { ao := make(json.RawMessage, len(*c.AgentOptions)) copy(ao, *c.AgentOptions) @@ -1417,12 +1418,44 @@ type Features struct { DetailQueryOverrides map[string]*string `json:"detail_query_overrides,omitempty"` //nolint:apiparamcheck // osquery detail-query overrides HistoricalData HistoricalDataSettings `json:"historical_data"` + // VulnerabilityExposureHistoricalReporting holds the GitOps-managed default + // filter state for the Vulnerability exposure dashboard chart. It is a + // display-only concern: it seeds the chart's filter controls on load and + // does NOT affect what vulnerability data is collected. Premium-only. + // + // All fields are pointers so the config has sparse/PATCH semantics: a field + // present in YAML is persisted and respected by the frontend, while an + // omitted field stays nil and the frontend falls back to its own built-in + // default for that control. + VulnerabilityExposureHistoricalReporting *VulnExposureFilterSettings `json:"vulnerability_exposure_historical_reporting,omitempty"` + ///////////////////////////////////////////////////////////////// // WARNING: If you add to this struct make sure it's taken into // account in the Features Clone implementation! ///////////////////////////////////////////////////////////////// } +// VulnExposureFilterSettings is the persisted default filter state for the +// Vulnerability exposure (CVE) dashboard chart. Field names/units mirror what +// the frontend consumes when seeding its filter controls: software categories +// use the canonical keys (os/browsers/office/adobe), EPSS bounds are expressed +// as 0–100 (the frontend converts to 0–1 only when calling the chart API). +// +// Every field is optional (nil = "not set, use the frontend default"). A +// present SoftwareFilters slice must list at least one category: a +// present-but-empty slice is rejected by Validate, because on the chart read +// path an empty selection collapses to "all categories" and so can never +// produce the empty chart it implies. +type VulnExposureFilterSettings struct { + SoftwareFilters *[]string `json:"software_filters,omitempty"` + CVSSMin *float64 `json:"cvss_min,omitempty"` + CVSSMax *float64 `json:"cvss_max,omitempty"` + EPSSMin *float64 `json:"epss_min,omitempty"` + EPSSMax *float64 `json:"epss_max,omitempty"` + HasKnownExploit *bool `json:"has_known_exploit,omitempty"` + ExcludeVulnerabilities *[]string `json:"exclude_vulnerabilities,omitempty"` +} + // HistoricalDataSettings controls per-dataset collection of the time-series // rollups that drive the dashboard charts. Each sub-key corresponds to a // chart dataset; `true` means collect, `false` means skip. @@ -1497,9 +1530,117 @@ func (f *Features) Copy() *Features { } } + clone.VulnerabilityExposureHistoricalReporting = f.VulnerabilityExposureHistoricalReporting.Copy() + + return &clone +} + +// Copy returns a deep copy of the settings, or nil if the receiver is nil. +func (v *VulnExposureFilterSettings) Copy() *VulnExposureFilterSettings { + if v == nil { + return nil + } + + var clone VulnExposureFilterSettings + + if v.CVSSMin != nil { + clone.CVSSMin = new(*v.CVSSMin) + } + if v.CVSSMax != nil { + clone.CVSSMax = new(*v.CVSSMax) + } + if v.EPSSMin != nil { + clone.EPSSMin = new(*v.EPSSMin) + } + if v.EPSSMax != nil { + clone.EPSSMax = new(*v.EPSSMax) + } + if v.HasKnownExploit != nil { + clone.HasKnownExploit = new(*v.HasKnownExploit) + } + if v.SoftwareFilters != nil { + sf := make([]string, len(*v.SoftwareFilters)) + copy(sf, *v.SoftwareFilters) + clone.SoftwareFilters = &sf + } + if v.ExcludeVulnerabilities != nil { + ev := make([]string, len(*v.ExcludeVulnerabilities)) + copy(ev, *v.ExcludeVulnerabilities) + clone.ExcludeVulnerabilities = &ev + } + return &clone } +// vulnExposureSoftwareCategories is the set of valid software_filters values. +// It mirrors the canonical CVE category keys defined in server/chart/api +// (CVECategoryOS/Browsers/Office/Adobe); kept as a local set here to avoid the +// base fleet package depending on the chart bounded context. +var vulnExposureSoftwareCategories = map[string]struct{}{ + "os": {}, + "browsers": {}, + "office": {}, + "adobe": {}, +} + +// vulnExposureCVERegex matches a CVE identifier, mirroring the pattern used in +// server/service (cveRegex). +var vulnExposureCVERegex = regexp.MustCompile(`(?i)^CVE-\d{4}-\d{4}\d*$`) + +// Validate checks only the fields that are present (non-nil). It is meant to be +// run against the incoming GitOps/PATCH payload, not against persisted state. +// Errors are appended to the provided invalid accumulator under keys prefixed +// with the supplied path (e.g. "org_settings.features" or +// ".settings.features"). +func (v *VulnExposureFilterSettings) Validate(prefix string, invalid *InvalidArgumentError) { + if v == nil { + return + } + key := func(field string) string { + return prefix + ".vulnerability_exposure_historical_reporting." + field + } + + if v.SoftwareFilters != nil { + // An empty list is rejected rather than treated as "no categories": + // on the chart read path an empty selection is indistinguishable from + // "no filter" and resolves to all categories, so it can never produce + // the empty chart it implies. Require at least one category instead. + if len(*v.SoftwareFilters) == 0 { + invalid.Append(key("software_filters"), "must include at least one software category (valid values: os, browsers, office, adobe)") + } + for _, c := range *v.SoftwareFilters { + if _, ok := vulnExposureSoftwareCategories[c]; !ok { + invalid.Append(key("software_filters"), fmt.Sprintf("invalid software category %q (valid values: os, browsers, office, adobe)", c)) + } + } + } + + validateBounds(invalid, key("cvss_min"), key("cvss_max"), v.CVSSMin, v.CVSSMax, 0, 10, "cvss") + validateBounds(invalid, key("epss_min"), key("epss_max"), v.EPSSMin, v.EPSSMax, 0, 100, "epss") + + if v.ExcludeVulnerabilities != nil { + for _, cve := range *v.ExcludeVulnerabilities { + if !vulnExposureCVERegex.MatchString(cve) { + invalid.Append(key("exclude_vulnerabilities"), fmt.Sprintf("invalid CVE identifier %q", cve)) + } + } + } +} + +// validateBounds checks an optional [min, max] score range: each present bound +// must fall within [lo, hi], and when both are present min must be <= max. +func validateBounds(invalid *InvalidArgumentError, minKey, maxKey string, minVal, maxVal *float64, lo, hi float64, label string) { + if minVal != nil && (*minVal < lo || *minVal > hi) { + invalid.Append(minKey, fmt.Sprintf("%s_min must be between %g and %g", label, lo, hi)) + } + if maxVal != nil && (*maxVal < lo || *maxVal > hi) { + invalid.Append(maxKey, fmt.Sprintf("%s_max must be between %g and %g", label, lo, hi)) + } + if minVal != nil && maxVal != nil && *minVal > *maxVal { + invalid.Append(minKey, fmt.Sprintf("%s_min must be less than or equal to %s_max", label, label)) + } +} + // FleetDesktopSettings contains settings used to configure Fleet Desktop. type FleetDesktopSettings struct { // TransparencyURL is the URL used for the “About Fleet” link in the Fleet Desktop menu. diff --git a/server/fleet/vuln_exposure_filters_test.go b/server/fleet/vuln_exposure_filters_test.go new file mode 100644 index 00000000000..4dcacae1ebe --- /dev/null +++ b/server/fleet/vuln_exposure_filters_test.go @@ -0,0 +1,132 @@ +package fleet + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVulnExposureFilterSettingsValidate(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + in *VulnExposureFilterSettings + wantErr bool + // substrings expected somewhere in the accumulated error + errContains []string + }{ + { + name: "nil is valid", + in: nil, + }, + { + name: "empty struct is valid (all absent)", + in: &VulnExposureFilterSettings{}, + }, + { + name: "valid full payload", + in: &VulnExposureFilterSettings{ + SoftwareFilters: &[]string{"os", "browsers", "office", "adobe"}, + CVSSMin: new(0.0), + CVSSMax: new(10.0), + EPSSMin: new(0.0), + EPSSMax: new(100.0), + HasKnownExploit: new(true), + ExcludeVulnerabilities: &[]string{"CVE-2025-50897", "cve-2024-1234"}, + }, + }, + { + name: "invalid software category", + in: &VulnExposureFilterSettings{SoftwareFilters: &[]string{"os", "bogus"}}, + wantErr: true, + errContains: []string{"software_filters", "bogus"}, + }, + { + name: "explicit empty software_filters is rejected (must select at least one)", + in: &VulnExposureFilterSettings{SoftwareFilters: &[]string{}}, + wantErr: true, + errContains: []string{"software_filters", "at least one"}, + }, + { + name: "cvss out of range", + in: &VulnExposureFilterSettings{CVSSMax: new(11.0)}, + wantErr: true, + errContains: []string{"cvss_max"}, + }, + { + name: "cvss min greater than max", + in: &VulnExposureFilterSettings{CVSSMin: new(8.0), CVSSMax: new(2.0)}, + wantErr: true, + errContains: []string{"cvss_min"}, + }, + { + name: "epss out of range", + in: &VulnExposureFilterSettings{EPSSMin: new(-1.0)}, + wantErr: true, + errContains: []string{"epss_min"}, + }, + { + name: "epss min greater than max", + in: &VulnExposureFilterSettings{EPSSMin: new(80.0), EPSSMax: new(20.0)}, + wantErr: true, + errContains: []string{"epss_min"}, + }, + { + name: "invalid CVE identifier", + in: &VulnExposureFilterSettings{ExcludeVulnerabilities: &[]string{"not-a-cve"}}, + wantErr: true, + errContains: []string{"exclude_vulnerabilities", "not-a-cve"}, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + invalid := &InvalidArgumentError{} + c.in.Validate("org_settings.features", invalid) + if c.wantErr { + require.True(t, invalid.HasErrors(), "expected validation errors") + msg := invalid.Error() + for _, sub := range c.errContains { + assert.Contains(t, msg, sub) + } + } else { + assert.False(t, invalid.HasErrors(), "unexpected validation errors: %v", invalid) + } + }) + } +} + +func TestVulnExposureFilterSettingsCopyIsIndependent(t *testing.T) { + t.Parallel() + + assert.Nil(t, (*VulnExposureFilterSettings)(nil).Copy()) + + orig := &VulnExposureFilterSettings{ + SoftwareFilters: &[]string{"os", "browsers"}, + CVSSMin: new(9.0), + EPSSMax: new(100.0), + HasKnownExploit: new(true), + ExcludeVulnerabilities: &[]string{"CVE-2025-50897"}, + } + clone := orig.Copy() + require.Equal(t, orig, clone) + require.NotNil(t, clone) + require.NotNil(t, clone.SoftwareFilters) + require.NotNil(t, clone.CVSSMin) + require.NotNil(t, clone.HasKnownExploit) + require.NotNil(t, clone.ExcludeVulnerabilities) + + // Mutating the clone's slices/scalars must not affect the original. + (*clone.SoftwareFilters)[0] = "adobe" + *clone.CVSSMin = 1.0 + *clone.HasKnownExploit = false + (*clone.ExcludeVulnerabilities)[0] = "CVE-0000-0000" + + require.NotNil(t, orig.CVSSMin) + assert.Equal(t, "os", (*orig.SoftwareFilters)[0]) + assert.InDelta(t, 9.0, *orig.CVSSMin, 0.0001) + assert.True(t, *orig.HasKnownExploit) + assert.Equal(t, "CVE-2025-50897", (*orig.ExcludeVulnerabilities)[0]) +} diff --git a/server/service/appconfig.go b/server/service/appconfig.go index faa4c546ab6..4f348b060e2 100644 --- a/server/service/appconfig.go +++ b/server/service/appconfig.go @@ -574,6 +574,22 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle return nil, ctxerr.Wrap(ctx, fleetDesktopSettingsInvalidErr) } + // Validate and premium-gate the vulnerability-exposure chart filter + // defaults. These are display-only defaults (they seed the dashboard + // chart's filter controls; they do not affect data collection) and are + // premium-only. Validation runs on the incoming payload with sparse/PATCH + // semantics: only fields explicitly present are checked. + if veFilters := newAppConfig.Features.VulnerabilityExposureHistoricalReporting; veFilters != nil { + if !lic.IsPremium() { + invalid.Append("org_settings.features.vulnerability_exposure_historical_reporting", ErrMissingLicense.Error()) + } else { + veFilters.Validate("org_settings.features", invalid) + } + if invalid.HasErrors() { + return nil, ctxerr.Wrap(ctx, invalid) + } + } + // Reject conflicting deprecated/new logo URL pairs and mirror them so // both forms are persisted with identical values. Done on the incoming // payload before merge so we surface the conflict at the source field. diff --git a/server/service/appconfig_test.go b/server/service/appconfig_test.go index 82c8f56b94f..bbb1ecb381b 100644 --- a/server/service/appconfig_test.go +++ b/server/service/appconfig_test.go @@ -153,6 +153,68 @@ func TestAppConfigAuth(t *testing.T) { } } +// TestModifyAppConfigVulnExposureFilters covers the GitOps wiring for the +// vulnerability-exposure chart filter defaults: the premium gate and the +// payload validation, both of which reject the apply before persisting. The +// happy-path persist round-trip is covered by integration tests. +func TestModifyAppConfigVulnExposureFilters(t *testing.T) { + setup := func(t *testing.T, tier string) (fleet.Service, context.Context, *mock.Store) { + ds := new(mock.Store) + cfg := config.TestConfig() + svc, ctx := newTestServiceWithConfig(t, ds, cfg, nil, nil, &TestServerOpts{ + License: &fleet.LicenseInfo{Tier: tier}, + }) + ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: new(fleet.RoleAdmin)}}) + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{ + OrgInfo: fleet.OrgInfo{OrgName: "Test"}, + ServerSettings: fleet.ServerSettings{ServerURL: "https://example.org"}, + }, nil + } + ds.SaveAppConfigFunc = func(ctx context.Context, conf *fleet.AppConfig) error { return nil } + return svc, ctx, ds + } + + payload := `{"features":{"vulnerability_exposure_historical_reporting":{%s}}}` + + t.Run("free tier rejects the feature", func(t *testing.T) { + svc, ctx, ds := setup(t, fleet.TierFree) + body := fmt.Sprintf(payload, `"has_known_exploit":true`) + _, err := svc.ModifyAppConfig(ctx, []byte(body), fleet.ApplySpecOptions{}) + require.Error(t, err) + require.Contains(t, err.Error(), "vulnerability_exposure_historical_reporting") + require.False(t, ds.SaveAppConfigFuncInvoked, "config should not be saved when rejected") + }) + + t.Run("premium rejects an invalid software category", func(t *testing.T) { + svc, ctx, ds := setup(t, fleet.TierPremium) + body := fmt.Sprintf(payload, `"software_filters":["os","bogus"]`) + _, err := svc.ModifyAppConfig(ctx, []byte(body), fleet.ApplySpecOptions{}) + require.Error(t, err) + require.Contains(t, err.Error(), "software_filters") + require.False(t, ds.SaveAppConfigFuncInvoked, "config should not be saved when rejected") + }) + + t.Run("premium rejects inverted EPSS bounds", func(t *testing.T) { + svc, ctx, ds := setup(t, fleet.TierPremium) + body := fmt.Sprintf(payload, `"epss_min":80,"epss_max":20`) + _, err := svc.ModifyAppConfig(ctx, []byte(body), fleet.ApplySpecOptions{}) + require.Error(t, err) + require.Contains(t, err.Error(), "epss_min") + require.False(t, ds.SaveAppConfigFuncInvoked, "config should not be saved when rejected") + }) + + t.Run("premium rejects an empty software_filters list", func(t *testing.T) { + svc, ctx, ds := setup(t, fleet.TierPremium) + body := fmt.Sprintf(payload, `"software_filters":[]`) + _, err := svc.ModifyAppConfig(ctx, []byte(body), fleet.ApplySpecOptions{}) + require.Error(t, err) + require.Contains(t, err.Error(), "software_filters") + require.Contains(t, err.Error(), "at least one") + require.False(t, ds.SaveAppConfigFuncInvoked, "config should not be saved when rejected") + }) +} + // TestVersion tests that all users can access the version endpoint. func TestVersion(t *testing.T) { ds := new(mock.Store) diff --git a/tools/cloner-check/generated_files/appconfig.txt b/tools/cloner-check/generated_files/appconfig.txt index d8ad3a23854..a8cbc651f4f 100644 --- a/tools/cloner-check/generated_files/appconfig.txt +++ b/tools/cloner-check/generated_files/appconfig.txt @@ -44,6 +44,14 @@ github.com/fleetdm/fleet/v4/server/fleet/Features DetailQueryOverrides map[strin github.com/fleetdm/fleet/v4/server/fleet/Features HistoricalData fleet.HistoricalDataSettings github.com/fleetdm/fleet/v4/server/fleet/HistoricalDataSettings Uptime bool github.com/fleetdm/fleet/v4/server/fleet/HistoricalDataSettings Vulnerabilities bool +github.com/fleetdm/fleet/v4/server/fleet/Features VulnerabilityExposureHistoricalReporting *fleet.VulnExposureFilterSettings +github.com/fleetdm/fleet/v4/server/fleet/VulnExposureFilterSettings SoftwareFilters *[]string +github.com/fleetdm/fleet/v4/server/fleet/VulnExposureFilterSettings CVSSMin *float64 +github.com/fleetdm/fleet/v4/server/fleet/VulnExposureFilterSettings CVSSMax *float64 +github.com/fleetdm/fleet/v4/server/fleet/VulnExposureFilterSettings EPSSMin *float64 +github.com/fleetdm/fleet/v4/server/fleet/VulnExposureFilterSettings EPSSMax *float64 +github.com/fleetdm/fleet/v4/server/fleet/VulnExposureFilterSettings HasKnownExploit *bool +github.com/fleetdm/fleet/v4/server/fleet/VulnExposureFilterSettings ExcludeVulnerabilities *[]string github.com/fleetdm/fleet/v4/server/fleet/AppConfig DeprecatedHostSettings *fleet.Features github.com/fleetdm/fleet/v4/server/fleet/AppConfig AgentOptions *json.RawMessage github.com/fleetdm/fleet/v4/server/fleet/AppConfig SMTPTest bool diff --git a/tools/cloner-check/generated_files/features.txt b/tools/cloner-check/generated_files/features.txt index 73d3ad11e97..704029c4714 100644 --- a/tools/cloner-check/generated_files/features.txt +++ b/tools/cloner-check/generated_files/features.txt @@ -5,3 +5,11 @@ github.com/fleetdm/fleet/v4/server/fleet/Features DetailQueryOverrides map[strin github.com/fleetdm/fleet/v4/server/fleet/Features HistoricalData fleet.HistoricalDataSettings github.com/fleetdm/fleet/v4/server/fleet/HistoricalDataSettings Uptime bool github.com/fleetdm/fleet/v4/server/fleet/HistoricalDataSettings Vulnerabilities bool +github.com/fleetdm/fleet/v4/server/fleet/Features VulnerabilityExposureHistoricalReporting *fleet.VulnExposureFilterSettings +github.com/fleetdm/fleet/v4/server/fleet/VulnExposureFilterSettings SoftwareFilters *[]string +github.com/fleetdm/fleet/v4/server/fleet/VulnExposureFilterSettings CVSSMin *float64 +github.com/fleetdm/fleet/v4/server/fleet/VulnExposureFilterSettings CVSSMax *float64 +github.com/fleetdm/fleet/v4/server/fleet/VulnExposureFilterSettings EPSSMin *float64 +github.com/fleetdm/fleet/v4/server/fleet/VulnExposureFilterSettings EPSSMax *float64 +github.com/fleetdm/fleet/v4/server/fleet/VulnExposureFilterSettings HasKnownExploit *bool +github.com/fleetdm/fleet/v4/server/fleet/VulnExposureFilterSettings ExcludeVulnerabilities *[]string diff --git a/tools/cloner-check/generated_files/teamconfig.txt b/tools/cloner-check/generated_files/teamconfig.txt index a3b7e6c2993..f8e8ac36697 100644 --- a/tools/cloner-check/generated_files/teamconfig.txt +++ b/tools/cloner-check/generated_files/teamconfig.txt @@ -98,6 +98,14 @@ github.com/fleetdm/fleet/v4/server/fleet/Features DetailQueryOverrides map[strin github.com/fleetdm/fleet/v4/server/fleet/Features HistoricalData fleet.HistoricalDataSettings github.com/fleetdm/fleet/v4/server/fleet/HistoricalDataSettings Uptime bool github.com/fleetdm/fleet/v4/server/fleet/HistoricalDataSettings Vulnerabilities bool +github.com/fleetdm/fleet/v4/server/fleet/Features VulnerabilityExposureHistoricalReporting *fleet.VulnExposureFilterSettings +github.com/fleetdm/fleet/v4/server/fleet/VulnExposureFilterSettings SoftwareFilters *[]string +github.com/fleetdm/fleet/v4/server/fleet/VulnExposureFilterSettings CVSSMin *float64 +github.com/fleetdm/fleet/v4/server/fleet/VulnExposureFilterSettings CVSSMax *float64 +github.com/fleetdm/fleet/v4/server/fleet/VulnExposureFilterSettings EPSSMin *float64 +github.com/fleetdm/fleet/v4/server/fleet/VulnExposureFilterSettings EPSSMax *float64 +github.com/fleetdm/fleet/v4/server/fleet/VulnExposureFilterSettings HasKnownExploit *bool +github.com/fleetdm/fleet/v4/server/fleet/VulnExposureFilterSettings ExcludeVulnerabilities *[]string github.com/fleetdm/fleet/v4/server/fleet/TeamConfig Scripts optjson.Slice[string] github.com/fleetdm/fleet/v4/pkg/optjson/Slice[string] Set bool github.com/fleetdm/fleet/v4/pkg/optjson/Slice[string] Valid bool