Files
fleet/server/service/vulnerabilities.go
T
Victor Lyuboslavsky d83fd5f384 Fixed client-side errors being incorrectly reported as server errors in OTEL telemetry (#40051)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #40028 

# Checklist for submitter

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.

## Testing

- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

## Release Notes

* **Bug Fixes**
* Fixed telemetry misclassification where client-side errors were
incorrectly reported as server errors. Client-side errors and request
cancellations are now properly categorized for improved error tracking
and observability.

* **Tests**
* Added test coverage for client error detection and context
cancellation handling.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-19 16:06:00 -06:00

237 lines
6.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"fmt"
"net/http"
"regexp"
"time"
"github.com/fleetdm/fleet/v4/server/authz"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
)
var freeValidVulnSortColumns = []string{
"cve",
"hosts_count",
"host_count_updated_at",
"created_at",
}
type cveNotFoundError struct{}
var _ fleet.NotFoundError = (*cveNotFoundError)(nil)
func (p cveNotFoundError) Error() string {
return "This is not a known CVE. None of Fleets vulnerability sources are aware of this CVE."
}
func (p cveNotFoundError) IsNotFound() bool {
return true
}
func (p cveNotFoundError) IsClientError() bool {
return true
}
type listVulnerabilitiesRequest struct {
fleet.VulnListOptions
}
type listVulnerabilitiesResponse struct {
Vulnerabilities []fleet.VulnerabilityWithMetadata `json:"vulnerabilities"`
Count uint `json:"count"`
CountsUpdatedAt time.Time `json:"counts_updated_at"`
Meta *fleet.PaginationMetadata `json:"meta,omitempty"`
Err error `json:"error,omitempty"`
}
// Allow formats like: CVE-2017-12345, cve-2017-12345
var cveRegex = regexp.MustCompile(`(?i)^CVE-\d{4}-\d{4}\d*$`)
func (r listVulnerabilitiesResponse) Error() error { return r.Err }
func listVulnerabilitiesEndpoint(ctx context.Context, req interface{}, svc fleet.Service) (fleet.Errorer, error) {
request := req.(*listVulnerabilitiesRequest)
vulns, meta, err := svc.ListVulnerabilities(ctx, request.VulnListOptions)
if err != nil {
return listVulnerabilitiesResponse{Err: err}, nil
}
count, err := svc.CountVulnerabilities(ctx, request.VulnListOptions)
if err != nil {
return listVulnerabilitiesResponse{Err: err}, nil
}
updatedAt := time.Now()
for _, vuln := range vulns {
if vuln.HostsCountUpdatedAt.Before(updatedAt) {
updatedAt = vuln.HostsCountUpdatedAt
}
}
return listVulnerabilitiesResponse{
Vulnerabilities: vulns,
Meta: meta,
Count: count,
CountsUpdatedAt: updatedAt,
}, nil
}
func (svc *Service) ListVulnerabilities(ctx context.Context, opt fleet.VulnListOptions) ([]fleet.VulnerabilityWithMetadata, *fleet.PaginationMetadata, error) {
if err := svc.authz.Authorize(ctx, &fleet.AuthzSoftwareInventory{
TeamID: opt.TeamID,
}, fleet.ActionRead); err != nil {
return nil, nil, err
}
if len(opt.ValidSortColumns) == 0 {
opt.ValidSortColumns = freeValidVulnSortColumns
}
if !opt.HasValidSortColumn() {
return nil, nil, badRequest("invalid order key")
}
if opt.KnownExploit && !opt.IsEE {
return nil, nil, fleet.ErrMissingLicense
}
vulns, meta, err := svc.ds.ListVulnerabilities(ctx, opt)
if err != nil {
return nil, nil, err
}
for i, vuln := range vulns {
vulns[i].DetailsLink = fmt.Sprintf("https://nvd.nist.gov/vuln/detail/%s", vuln.CVE.CVE)
}
return vulns, meta, nil
}
func (svc *Service) CountVulnerabilities(ctx context.Context, opts fleet.VulnListOptions) (uint, error) {
if err := svc.authz.Authorize(ctx, &fleet.AuthzSoftwareInventory{
TeamID: opts.TeamID,
}, fleet.ActionRead); err != nil {
return 0, err
}
return svc.ds.CountVulnerabilities(ctx, opts)
}
func (svc *Service) IsCVEKnownToFleet(ctx context.Context, cve string) (bool, error) {
return svc.ds.IsCVEKnownToFleet(ctx, cve)
}
type getVulnerabilityRequest struct {
CVE string `url:"cve"`
TeamID *uint `query:"team_id,optional" renameto:"fleet_id"`
}
type getVulnerabilityResponse struct {
Vulnerability *fleet.VulnerabilityWithMetadata `json:"vulnerability"`
OSVersions []*fleet.VulnerableOS `json:"os_versions"`
Software []*fleet.VulnerableSoftware `json:"software"`
Err error `json:"error,omitempty"`
statusCode int
}
func (r getVulnerabilityResponse) Error() error { return r.Err }
func (r getVulnerabilityResponse) Status() int {
if r.statusCode == 0 {
return http.StatusOK
}
return r.statusCode
}
func getVulnerabilityEndpoint(ctx context.Context, req interface{}, svc fleet.Service) (fleet.Errorer, error) {
request := req.(*getVulnerabilityRequest)
vuln, known, err := svc.Vulnerability(ctx, request.CVE, request.TeamID, false)
if err != nil {
return getVulnerabilityResponse{Err: err}, nil
}
if vuln == nil && known {
// Return 204 status code if the vulnerability is known to Fleet but does not match any host software/OS
return getVulnerabilityResponse{statusCode: http.StatusNoContent}, nil
}
vuln.DetailsLink = fmt.Sprintf("https://nvd.nist.gov/vuln/detail/%s", vuln.CVE.CVE)
osVersions, _, err := svc.ListOSVersionsByCVE(ctx, vuln.CVE.CVE, request.TeamID)
if err != nil {
return getVulnerabilityResponse{Err: err}, nil
}
software, _, err := svc.ListSoftwareByCVE(ctx, vuln.CVE.CVE, request.TeamID)
if err != nil {
return getVulnerabilityResponse{Err: err}, nil
}
return getVulnerabilityResponse{
Vulnerability: vuln,
OSVersions: osVersions,
Software: software,
}, nil
}
func (svc *Service) Vulnerability(ctx context.Context, cve string, teamID *uint, useCVSScores bool) (vuln *fleet.VulnerabilityWithMetadata,
known bool, err error,
) {
if err := svc.authz.Authorize(ctx, &fleet.AuthzSoftwareInventory{TeamID: teamID}, fleet.ActionRead); err != nil {
return nil, false, err
}
if err := svc.authz.Authorize(ctx, &fleet.Host{TeamID: teamID}, fleet.ActionRead); err != nil {
return nil, false, err
}
if !cveRegex.Match([]byte(cve)) {
return nil, false, badRequest("That vulnerability (CVE) is not valid. Try updating your search to use CVE format: \"CVE-YYYY-<4 or more digits>\"")
}
if teamID != nil && *teamID != 0 {
exists, err := svc.ds.TeamExists(ctx, *teamID)
if err != nil {
return nil, false, ctxerr.Wrap(ctx, err, "checking if team exists")
} else if !exists {
return nil, false, authz.ForbiddenWithInternal("team does not exist", nil, nil, nil)
}
}
vuln, err = svc.ds.Vulnerability(ctx, cve, teamID, useCVSScores)
switch {
case fleet.IsNotFound(err):
var errKnown error
known, errKnown = svc.ds.IsCVEKnownToFleet(ctx, cve)
if errKnown != nil {
return nil, false, errKnown
}
if !known {
return nil, false, cveNotFoundError{}
}
case err != nil:
return nil, false, err
default:
known = true
}
return vuln, known, nil
}
func (svc *Service) ListOSVersionsByCVE(ctx context.Context, cve string, teamID *uint) (result []*fleet.VulnerableOS, updatedAt time.Time, err error) {
if err := svc.authz.Authorize(ctx, &fleet.Host{TeamID: teamID}, fleet.ActionRead); err != nil {
return nil, updatedAt, err
}
return svc.ds.OSVersionsByCVE(ctx, cve, teamID)
}
func (svc *Service) ListSoftwareByCVE(ctx context.Context, cve string, teamID *uint) (result []*fleet.VulnerableSoftware, updatedAt time.Time, err error) {
if err := svc.authz.Authorize(ctx, &fleet.AuthzSoftwareInventory{TeamID: teamID}, fleet.ActionRead); err != nil {
return nil, updatedAt, err
}
return svc.ds.SoftwareByCVE(ctx, cve, teamID)
}