mirror of
https://github.com/fleetdm/fleet
synced 2026-05-24 09:28:54 +00:00
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** For #43769 # Details Adds methods to collect data for the `cve` dataset. As with all sets this is collected at hourly granularity, but unlike the `uptime` set, the `cve` set uses the "snapshot" strategy so that we record at most one change (the most recent) per hour. For this first iteration, we are _recording_ data for all CVEs (i.e., which hosts were exposed to which CVEs at a given time), but we are only _reporting_ a subset of CVEs for the dashboard chart. See [this comment](https://github.com/fleetdm/fleet/pull/44124#discussion_r3155554405) for more info. # Checklist for submitter If some of the following don't apply, delete the relevant line. - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. - [X] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements), JS inline code is prevented especially for url redirects, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters. ## Testing - [X] Added/updated automated tests - [X] Where appropriate, [automated tests simulate multiple hosts and test for host isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing) (updates to one hosts's records do not affect another) - [X] QA'd all new/changed functionality manually - [X] Spot-checked the CVEs chosen by the `trackedCVESoftwareMatchers` and didn't find any outside of the expected - [X] With [front-end PR](https://github.com/fleetdm/fleet/pull/44261), generated chart: <img width="706" height="421" alt="image" src="https://github.com/user-attachments/assets/539d9877-6573-4406-a159-1d2a711a045f" /> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Host vulnerability (CVE) chart added to the dashboard; CVE chart data collection is now active. * Critical CVE tracking surfaces high-severity vulnerabilities. * **Improvements** * CVE chart refreshes every 3 hours (was daily) for more timely insights. * Snapshot collection reconciles and closes prior data during empty runs to keep charts accurate. * CVE queries may produce zero datapoints when no tracked CVEs exist, without affecting other metrics. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
230 lines
8.2 KiB
Go
230 lines
8.2 KiB
Go
// Package service provides the service implementation for the chart bounded context.
|
|
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/fleetdm/fleet/v4/server/chart"
|
|
"github.com/fleetdm/fleet/v4/server/chart/api"
|
|
"github.com/fleetdm/fleet/v4/server/chart/internal/types"
|
|
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
|
platform_authz "github.com/fleetdm/fleet/v4/server/platform/authz"
|
|
platform_http "github.com/fleetdm/fleet/v4/server/platform/http"
|
|
)
|
|
|
|
// Service is the chart bounded context service implementation.
|
|
type Service struct {
|
|
authz platform_authz.Authorizer
|
|
store types.Datastore
|
|
viewer api.ViewerProvider
|
|
datasets map[string]api.Dataset
|
|
hostCache *hostFilterCache
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// NewService creates a new chart service.
|
|
func NewService(authz platform_authz.Authorizer, store types.Datastore, viewerProvider api.ViewerProvider, logger *slog.Logger) *Service {
|
|
return &Service{
|
|
authz: authz,
|
|
store: store,
|
|
viewer: viewerProvider,
|
|
datasets: make(map[string]api.Dataset),
|
|
hostCache: newHostFilterCache(hostFilterCacheTTL),
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// Ensure Service implements api.Service at compile time.
|
|
var _ api.Service = (*Service)(nil)
|
|
|
|
func (s *Service) RegisterDataset(ds api.Dataset) {
|
|
s.datasets[ds.Name()] = ds
|
|
}
|
|
|
|
func (s *Service) CollectDatasets(ctx context.Context, now time.Time) error {
|
|
for name, dataset := range s.datasets {
|
|
if err := dataset.Collect(ctx, s.store, now); err != nil {
|
|
// Log and continue — don't let one dataset failure block others.
|
|
if s.logger != nil {
|
|
s.logger.ErrorContext(ctx, "collect chart dataset", "dataset", name, "err", ctxerr.Wrap(ctx, err, "collect chart dataset"))
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) GetChartData(ctx context.Context, metric string, opts api.RequestOpts) (*api.Response, error) {
|
|
// Resolve scope first: for authz we need the right action + subject, and
|
|
// for data we need the effective team set. Fail closed if there's no
|
|
// viewer — the authenticated middleware should have placed one in ctx.
|
|
isGlobal, viewerTeamIDs, err := s.viewer.ViewerScope(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Build the authz subject + action. Two distinct cases:
|
|
// - Explicit team_id: Host{TeamID: opts.TeamID} + ActionRead. Rego's
|
|
// read rule for hosts requires team_role(subject, object.team_id) to
|
|
// match, so a team user asking for a team they don't have a role on
|
|
// is rejected by policy (not by us). Global users pass via the
|
|
// global-role rules, which don't care about team_id.
|
|
// - No team_id: Host{} + ActionList. Rego's list rules pass global
|
|
// users unconditionally and pass team users who have a list-capable
|
|
// role on any of their teams. The service then scopes data below.
|
|
authzSubject := &api.Host{TeamID: opts.TeamID}
|
|
authzAction := platform_authz.ActionRead
|
|
if opts.TeamID == nil {
|
|
authzAction = platform_authz.ActionList
|
|
}
|
|
if err := s.authz.Authorize(ctx, authzSubject, authzAction); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
dataset, ok := s.datasets[metric]
|
|
if !ok {
|
|
return nil, &platform_http.BadRequestError{Message: fmt.Sprintf("unknown chart metric: %s", metric)}
|
|
}
|
|
|
|
// Validate days preset.
|
|
validDays := map[int]struct{}{1: {}, 7: {}, 14: {}, 30: {}}
|
|
if _, ok := validDays[opts.Days]; !ok {
|
|
return nil, &platform_http.BadRequestError{Message: fmt.Sprintf("invalid days value: %d (must be 1, 7, 14, or 30)", opts.Days)}
|
|
}
|
|
|
|
// Resolution must be 0 or a positive divisor of 24.
|
|
if opts.Resolution < 0 || (opts.Resolution != 0 && 24%opts.Resolution != 0) {
|
|
return nil, &platform_http.BadRequestError{Message: fmt.Sprintf("invalid resolution value: %d (must be 0 or a positive divisor of 24)", opts.Resolution)}
|
|
}
|
|
|
|
hours := opts.Resolution
|
|
if hours <= 0 {
|
|
hours = dataset.DefaultResolutionHours()
|
|
}
|
|
bucketSize := time.Duration(hours) * time.Hour
|
|
|
|
startDate, endDate := computeBucketRange(time.Now(), bucketSize, opts.Days, opts.TZOffsetMinutes)
|
|
|
|
// Build the host filter. The bitmap mask always encodes "currently visible
|
|
// hosts" — team scoping, label/platform/include/exclude, and incidentally
|
|
// dropping hosts deleted since the SCD rows were written.
|
|
hostFilter := &types.HostFilter{
|
|
TeamIDs: effectiveTeamIDs(opts.TeamID, isGlobal, viewerTeamIDs),
|
|
LabelIDs: opts.LabelIDs,
|
|
Platforms: opts.Platforms,
|
|
IncludeHostIDs: opts.IncludeHostIDs,
|
|
ExcludeHostIDs: opts.ExcludeHostIDs,
|
|
}
|
|
|
|
filterMask, err := s.hostCache.Get(ctx, hostFilter, func(ctx context.Context) ([]byte, error) {
|
|
hostIDs, err := s.store.GetHostIDsForFilter(ctx, hostFilter)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "fetch host IDs for chart filter")
|
|
}
|
|
return chart.HostIDsToBlob(hostIDs), nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var entityIDs []string
|
|
if metric == "cve" {
|
|
// TODO(iteration-2): replace with user-configurable filter from
|
|
// RequestOpts when dynamic CVE filtering ships.
|
|
entityIDs, err = s.store.TrackedCriticalCVEs(ctx)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "resolve tracked critical CVEs")
|
|
}
|
|
}
|
|
|
|
// entityIDs semantics at the storage layer: nil = no filter; non-nil empty
|
|
// = match nothing (produces zero-valued buckets). Do NOT convert empty to
|
|
// nil here.
|
|
data, err := s.store.GetSCDData(ctx, metric, startDate, endDate, bucketSize, dataset.SampleStrategy(), filterMask, entityIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &api.Response{
|
|
Metric: metric,
|
|
Visualization: dataset.DefaultVisualization(),
|
|
TotalHosts: chart.BlobPopcount(filterMask),
|
|
Resolution: formatResolution(bucketSize),
|
|
Days: opts.Days,
|
|
Filters: api.Filters{
|
|
TeamID: opts.TeamID,
|
|
LabelIDs: opts.LabelIDs,
|
|
Platforms: opts.Platforms,
|
|
IncludeHostIDs: opts.IncludeHostIDs,
|
|
ExcludeHostIDs: opts.ExcludeHostIDs,
|
|
},
|
|
Data: data,
|
|
}, nil
|
|
}
|
|
|
|
// effectiveTeamIDs decides the team scope applied at SQL time.
|
|
//
|
|
// explicit team_id? → just that team (authz rule above already ensured
|
|
// the caller has access to it, or is global)
|
|
// global user, no team_id → nil, meaning "no team filter"
|
|
// team user, no team_id → the viewer's accessible teams. Empty-but-non-nil
|
|
// here means the user has no teams at all; SQL
|
|
// emits 1=0 so they see nothing.
|
|
func effectiveTeamIDs(requestedTeamID *uint, isGlobal bool, viewerTeamIDs []uint) []uint {
|
|
if requestedTeamID != nil {
|
|
return []uint{*requestedTeamID}
|
|
}
|
|
if isGlobal {
|
|
return nil
|
|
}
|
|
// Return a non-nil slice even when empty — the SQL builder treats non-nil
|
|
// as "scoped" and emits a no-match clause, which is what we want for a
|
|
// team user with zero team memberships.
|
|
if viewerTeamIDs == nil {
|
|
return []uint{}
|
|
}
|
|
return viewerTeamIDs
|
|
}
|
|
|
|
func (s *Service) CleanupData(ctx context.Context, days int) error {
|
|
return s.store.CleanupSCDData(ctx, days)
|
|
}
|
|
|
|
// computeBucketRange returns a (startDate, endDate) UTC pair such that the
|
|
// GetSCDData walker will emit (days*24h)/bucketSize data points labeled at
|
|
// bucket boundaries aligned to the client's local time. The last label is
|
|
// endDate — i.e., the current (possibly ongoing) bucket in the client's tz.
|
|
func computeBucketRange(now time.Time, bucketSize time.Duration, days, tzOffsetMinutes int) (time.Time, time.Time) {
|
|
loc := time.FixedZone("client", -tzOffsetMinutes*60)
|
|
localNow := now.In(loc)
|
|
|
|
var alignedEnd time.Time
|
|
if bucketSize < 24*time.Hour {
|
|
// Align to the current local bucket within the day.
|
|
step := max(int(bucketSize/time.Hour), 1)
|
|
alignedHour := (localNow.Hour() / step) * step
|
|
alignedEnd = time.Date(localNow.Year(), localNow.Month(), localNow.Day(), alignedHour, 0, 0, 0, loc)
|
|
} else {
|
|
// Daily (or coarser) — align to the start of today's local day.
|
|
alignedEnd = time.Date(localNow.Year(), localNow.Month(), localNow.Day(), 0, 0, 0, 0, loc)
|
|
}
|
|
|
|
endDate := alignedEnd.UTC()
|
|
startDate := endDate.Add(-time.Duration(days) * 24 * time.Hour)
|
|
return startDate, endDate
|
|
}
|
|
|
|
func formatResolution(bucketSize time.Duration) string {
|
|
switch {
|
|
case bucketSize == time.Hour:
|
|
return "hourly"
|
|
case bucketSize == 24*time.Hour:
|
|
return "daily"
|
|
case bucketSize < 24*time.Hour:
|
|
return fmt.Sprintf("%d-hour", int(bucketSize/time.Hour))
|
|
default:
|
|
return fmt.Sprintf("%d-day", int(bucketSize/(24*time.Hour)))
|
|
}
|
|
}
|