Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/44746-set-vuln-filters-in-gitops
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Added ability to set default Vulnerability Exposure chart filters via GitOps.
20 changes: 20 additions & 0 deletions docs/Configuration/yaml-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).

Expand All @@ -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
Expand Down
23 changes: 23 additions & 0 deletions ee/server/service/teams.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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 (
Expand Down
18 changes: 18 additions & 0 deletions frontend/interfaces/charts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions frontend/interfaces/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions frontend/pages/DashboardPage/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -945,6 +945,9 @@ const DashboardPage = ({ router, location }: IDashboardProps): JSX.Element => {
<ChartCard
currentTeamId={teamIdForApi}
historicalDataEnabled={historicalDataEnabled}
filterDefaults={
featuresConfig?.vulnerability_exposure_historical_reporting
}
/>
</Card>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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({
Comment on lines +234 to +240
exclude_vulnerabilities: ["CVE-2025-50897"],
});
expect(filters.excludeCVEs).toEqual(["CVE-2025-50897"]);
});
});
52 changes: 48 additions & 4 deletions frontend/pages/DashboardPage/cards/ChartCard/ChartCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
HistoricalDataConfigKey,
CVE_SOFTWARE_CATEGORIES,
ALL_CVE_SOFTWARE_CATEGORY_VALUES,
IVulnExposureFilterDefaults,
} from "interfaces/charts";

import { AppContext } from "context/app";
Expand Down Expand Up @@ -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,
Comment on lines +63 to +83
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;
Expand Down Expand Up @@ -166,17 +203,21 @@ const filterTooltip = (
interface IChartCardProps {
currentTeamId?: number;
historicalDataEnabled?: Record<HistoricalDataConfigKey, boolean>;
// 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<ChartFilterTab>("hosts");
const [chartFilters, setChartFilters] = useState<IChartFilterState>(
DEFAULT_CHART_FILTERS
const [chartFilters, setChartFilters] = useState<IChartFilterState>(() =>
buildInitialChartFilters(filterDefaults)
);

const openFilterModal = (tab: ChartFilterTab = "hosts") => {
Expand Down Expand Up @@ -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);

Expand Down
Loading
Loading