fleet/server/chart/internal/service/service.go
Scott Gress 4334017b38
Add Vulnerabilities exposure dataset (#44124)
<!-- 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 -->
2026-04-29 09:30:31 -05:00

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)))
}
}