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