mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 21:47:20 +00:00
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #40015 # Details Activates deprecation warnings for old API params and CLI args, updates tests that would generate warnings (except for tests explicitly designed to generate warnings). The expectation from here on is that Fleet UI usage should not generate any deprecation warnings in the server logs, nor should the output from `generate-gitops` generate any warnings when fed into `gitops`. # 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. ## Testing - [X] Added/updated automated tests - [ ] QA'd all new/changed functionality manually - [X] clicked around in an mdm-enabled instance, turned setup experience features on and off, saw no server warnings - [X] did `fleetctl generate-gitops` on mdm-enabled instance, saw no server or cli warnings - [X] did `fleetctl gitops` on mdm-enabled instance, saw no server or cli warnings
510 lines
16 KiB
Go
510 lines
16 KiB
Go
// Package spec contains functionality to parse "Fleet specs" yaml files
|
|
// (which are concatenated yaml files) that can be applied to a Fleet server.
|
|
package spec
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"errors"
|
|
"fmt"
|
|
"maps"
|
|
"os"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
"github.com/fleetdm/fleet/v4/server/platform/endpointer"
|
|
"github.com/fleetdm/fleet/v4/server/platform/logging"
|
|
"github.com/ghodss/yaml"
|
|
"github.com/hashicorp/go-multierror"
|
|
)
|
|
|
|
var yamlSeparator = regexp.MustCompile(`(?m:^---[\t ]*)`)
|
|
|
|
var (
|
|
envOverridesMu sync.RWMutex
|
|
envOverrides map[string]string
|
|
)
|
|
|
|
// SetEnvOverrides sets environment variable overrides that take precedence over
|
|
// os.LookupEnv during env expansion in GitOps file parsing. Pass nil to clear.
|
|
func SetEnvOverrides(overrides map[string]string) {
|
|
envOverridesMu.Lock()
|
|
defer envOverridesMu.Unlock()
|
|
if overrides == nil {
|
|
envOverrides = nil
|
|
return
|
|
}
|
|
envOverrides = make(map[string]string, len(overrides))
|
|
maps.Copy(envOverrides, overrides)
|
|
}
|
|
|
|
// lookupEnv checks env overrides first, then falls back to os.LookupEnv.
|
|
func lookupEnv(key string) (string, bool) {
|
|
envOverridesMu.RLock()
|
|
if envOverrides != nil {
|
|
if v, ok := envOverrides[key]; ok {
|
|
envOverridesMu.RUnlock()
|
|
return v, true
|
|
}
|
|
}
|
|
envOverridesMu.RUnlock()
|
|
return os.LookupEnv(key)
|
|
}
|
|
|
|
// Group holds a set of "specs" that can be applied to a Fleet server.
|
|
type Group struct {
|
|
Queries []*fleet.QuerySpec
|
|
Teams []json.RawMessage
|
|
Packs []*fleet.PackSpec
|
|
Labels []*fleet.LabelSpec
|
|
Policies []*fleet.PolicySpec
|
|
Software []*fleet.SoftwarePackageSpec
|
|
// This needs to be interface{} to allow for the patch logic. Otherwise we send a request that looks to the
|
|
// server like the user explicitly set the zero values.
|
|
AppConfig interface{}
|
|
EnrollSecret *fleet.EnrollSecretSpec
|
|
UsersRoles *fleet.UsersRoleSpec
|
|
TeamsDryRunAssumptions *fleet.TeamSpecsDryRunAssumptions
|
|
CertificateAuthorities *fleet.GroupedCertificateAuthorities
|
|
}
|
|
|
|
// Metadata holds the metadata for a single YAML section/item.
|
|
type Metadata struct {
|
|
Kind string `json:"kind"`
|
|
Version string `json:"apiVersion"`
|
|
Spec json.RawMessage `json:"spec"`
|
|
}
|
|
|
|
// rewriteNewToOldKeys uses RewriteDeprecatedKeys to rewrite new (renameto)
|
|
// key names back to old (json tag) names so that structs can be unmarshaled
|
|
// correctly when input uses the new key names.
|
|
func rewriteNewToOldKeys(raw json.RawMessage, target any) (json.RawMessage, map[string]string, error) {
|
|
rules := endpointer.ExtractAliasRules(target)
|
|
if len(rules) == 0 {
|
|
return raw, nil, nil
|
|
}
|
|
result, deprecatedKeysMap, err := endpointer.RewriteDeprecatedKeys(raw, rules)
|
|
if err != nil {
|
|
return nil, nil, err // fall back to original on error
|
|
}
|
|
return result, deprecatedKeysMap, nil
|
|
}
|
|
|
|
type GroupFromBytesOpts struct {
|
|
LogFn func(format string, args ...any)
|
|
}
|
|
|
|
// GroupFromBytes parses a Group from concatenated YAML specs.
|
|
func GroupFromBytes(b []byte, options ...GroupFromBytesOpts) (*Group, error) {
|
|
// Get optional logger.
|
|
var logFn func(format string, args ...any)
|
|
if len(options) > 0 {
|
|
logFn = options[0].LogFn
|
|
}
|
|
|
|
specs := &Group{}
|
|
for _, specItem := range SplitYaml(string(b)) {
|
|
var s Metadata
|
|
if err := yaml.Unmarshal([]byte(specItem), &s); err != nil {
|
|
return nil, fmt.Errorf("failed to unmarshal spec item %w: \n%s", err, specItem)
|
|
}
|
|
|
|
kind := strings.ToLower(s.Kind)
|
|
|
|
if s.Spec == nil {
|
|
if kind == "" {
|
|
return nil, errors.New(`Missing required fields ("spec", "kind") on provided configuration.`)
|
|
}
|
|
return nil, fmt.Errorf(`Missing required fields ("spec") on provided %q configuration.`, s.Kind)
|
|
}
|
|
|
|
var deprecatedKeysMap map[string]string
|
|
switch kind {
|
|
case fleet.QueryKind, fleet.ReportKind:
|
|
if logFn != nil && kind == fleet.QueryKind {
|
|
logFn("[!] `kind: query` is deprecated, please use `kind: report` instead.\n")
|
|
}
|
|
var err error
|
|
s.Spec, deprecatedKeysMap, err = rewriteNewToOldKeys(s.Spec, fleet.QuerySpec{})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("in %s spec: %w", kind, err)
|
|
}
|
|
var querySpec *fleet.QuerySpec
|
|
if err := yaml.Unmarshal(s.Spec, &querySpec); err != nil {
|
|
return nil, fmt.Errorf("unmarshaling %s spec: %w", kind, err)
|
|
}
|
|
specs.Queries = append(specs.Queries, querySpec)
|
|
|
|
case fleet.PackKind:
|
|
var err error
|
|
s.Spec, deprecatedKeysMap, err = rewriteNewToOldKeys(s.Spec, fleet.PackSpec{})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("in %s spec: %w", kind, err)
|
|
}
|
|
var packSpec *fleet.PackSpec
|
|
if err := yaml.Unmarshal(s.Spec, &packSpec); err != nil {
|
|
return nil, fmt.Errorf("unmarshaling %s spec: %w", kind, err)
|
|
}
|
|
specs.Packs = append(specs.Packs, packSpec)
|
|
|
|
case fleet.LabelKind:
|
|
var err error
|
|
s.Spec, deprecatedKeysMap, err = rewriteNewToOldKeys(s.Spec, fleet.LabelSpec{})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("in %s spec: %w", kind, err)
|
|
}
|
|
var labelSpec *fleet.LabelSpec
|
|
if err := yaml.Unmarshal(s.Spec, &labelSpec); err != nil {
|
|
return nil, fmt.Errorf("unmarshaling %s spec: %w", kind, err)
|
|
}
|
|
// Distinguish between hosts key omitted (nil, preserve membership)
|
|
// and hosts key present with null value (clear all hosts). Both
|
|
// unmarshal to nil, so check the raw YAML for key presence.
|
|
if labelSpec.Hosts == nil {
|
|
if hostsKeyPresent(s.Spec) {
|
|
labelSpec.Hosts = []string{}
|
|
}
|
|
}
|
|
specs.Labels = append(specs.Labels, labelSpec)
|
|
|
|
case fleet.PolicyKind:
|
|
var err error
|
|
s.Spec, deprecatedKeysMap, err = rewriteNewToOldKeys(s.Spec, fleet.PolicySpec{})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("in %s spec: %w", kind, err)
|
|
}
|
|
var policySpec *fleet.PolicySpec
|
|
if err := yaml.Unmarshal(s.Spec, &policySpec); err != nil {
|
|
return nil, fmt.Errorf("unmarshaling %s spec: %w", kind, err)
|
|
}
|
|
specs.Policies = append(specs.Policies, policySpec)
|
|
|
|
case fleet.AppConfigKind:
|
|
var err error
|
|
s.Spec, deprecatedKeysMap, err = rewriteNewToOldKeys(s.Spec, fleet.AppConfig{})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("in %s spec: %w", kind, err)
|
|
}
|
|
if specs.AppConfig != nil {
|
|
return nil, errors.New("config defined twice in the same file")
|
|
}
|
|
|
|
var appConfigSpec interface{}
|
|
if err := yaml.Unmarshal(s.Spec, &appConfigSpec); err != nil {
|
|
return nil, fmt.Errorf("unmarshaling %s spec: %w", kind, err)
|
|
}
|
|
specs.AppConfig = appConfigSpec
|
|
|
|
case fleet.EnrollSecretKind:
|
|
if specs.AppConfig != nil {
|
|
return nil, errors.New("enroll_secret defined twice in the same file")
|
|
}
|
|
|
|
var enrollSecretSpec *fleet.EnrollSecretSpec
|
|
if err := yaml.Unmarshal(s.Spec, &enrollSecretSpec); err != nil {
|
|
return nil, fmt.Errorf("unmarshaling %s spec: %w", kind, err)
|
|
}
|
|
specs.EnrollSecret = enrollSecretSpec
|
|
|
|
case fleet.UserRolesKind:
|
|
var err error
|
|
s.Spec, deprecatedKeysMap, err = rewriteNewToOldKeys(s.Spec, fleet.UsersRoleSpec{})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("in %s spec: %w", kind, err)
|
|
}
|
|
var userRoleSpec *fleet.UsersRoleSpec
|
|
if err := yaml.Unmarshal(s.Spec, &userRoleSpec); err != nil {
|
|
return nil, fmt.Errorf("unmarshaling %s spec: %w", kind, err)
|
|
}
|
|
specs.UsersRoles = userRoleSpec
|
|
|
|
case fleet.TeamKind, fleet.FleetKind:
|
|
if logFn != nil && kind == fleet.TeamKind {
|
|
logFn("[!] `kind: team` is deprecated, please use `kind: fleet` instead.\n")
|
|
}
|
|
// unmarshal to a raw map as we don't want to strip away unknown/invalid
|
|
// fields at this point - that validation is done in the apply spec/teams
|
|
// endpoint so that it is enforced for both the API and the CLI.
|
|
rawTeam := make(map[string]json.RawMessage)
|
|
if err := yaml.Unmarshal(s.Spec, &rawTeam); err != nil {
|
|
return nil, fmt.Errorf("unmarshaling %s spec: %w", kind, err)
|
|
}
|
|
teamRaw := rawTeam["team"]
|
|
var err error
|
|
teamRaw, deprecatedKeysMap, err = rewriteNewToOldKeys(teamRaw, fleet.TeamSpec{})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("in %s spec: %w", kind, err)
|
|
}
|
|
specs.Teams = append(specs.Teams, teamRaw)
|
|
|
|
default:
|
|
return nil, fmt.Errorf("unknown kind %q", s.Kind)
|
|
}
|
|
|
|
if logFn != nil && len(deprecatedKeysMap) > 0 && logging.TopicEnabled(logging.DeprecatedFieldTopic) {
|
|
oldKeys := make([]string, 0, len(deprecatedKeysMap))
|
|
for oldKey := range deprecatedKeysMap {
|
|
oldKeys = append(oldKeys, oldKey)
|
|
}
|
|
sort.Strings(oldKeys)
|
|
for _, oldKey := range oldKeys {
|
|
logFn(fmt.Sprintf("[!] In %s: `%s` is deprecated, please use `%s` instead.\n", kind, oldKey, deprecatedKeysMap[oldKey]))
|
|
}
|
|
}
|
|
}
|
|
return specs, nil
|
|
}
|
|
|
|
// SplitYaml splits a text file into separate yaml documents divided by ---
|
|
func SplitYaml(in string) []string {
|
|
var out []string
|
|
for _, chunk := range yamlSeparator.Split(in, -1) {
|
|
chunk = strings.TrimSpace(chunk)
|
|
if chunk == "" {
|
|
continue
|
|
}
|
|
out = append(out, chunk)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func generateRandomString(sizeBytes int) string {
|
|
b := make([]byte, sizeBytes)
|
|
if _, err := rand.Read(b); err != nil {
|
|
panic(err)
|
|
}
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
// secretHandling defines how to handle FLEET_SECRET_ variables
|
|
type secretHandling int
|
|
|
|
const (
|
|
// secretsReject returns an error if FLEET_SECRET_ variables are found
|
|
secretsReject secretHandling = iota
|
|
// secretsIgnore leaves FLEET_SECRET_ variables as-is (for server to handle)
|
|
secretsIgnore
|
|
// secretsExpand expands FLEET_SECRET_ variables (for client-side validation only)
|
|
secretsExpand
|
|
)
|
|
|
|
func ExpandEnv(s string) (string, error) {
|
|
out, err := expandEnv(s, secretsReject)
|
|
return out, err
|
|
}
|
|
|
|
// expandEnv expands environment variables for a gitops file.
|
|
// $ can be escaped with a backslash, e.g. \$VAR
|
|
// \$ can be escaped with another backslash, etc., e.g. \\\$VAR
|
|
// $FLEET_VAR_XXX will not be expanded. These variables are expanded on the server.
|
|
// The secretMode parameter controls how $FLEET_SECRET_XXX variables are handled.
|
|
func expandEnv(s string, secretMode secretHandling) (string, error) {
|
|
// Generate a random escaping prefix that doesn't exist in s.
|
|
var preventEscapingPrefix string
|
|
for {
|
|
preventEscapingPrefix = "PREVENT_ESCAPING_" + generateRandomString(8)
|
|
if !strings.Contains(s, preventEscapingPrefix) {
|
|
break
|
|
}
|
|
}
|
|
|
|
s = escapeString(s, preventEscapingPrefix)
|
|
exclusionZones := getExclusionZones(s)
|
|
documentIsXML := strings.HasPrefix(strings.TrimSpace(s), "<") // We need to be more aggressive here, to also escape XML in Windows profiles which does not begin with <?xml
|
|
|
|
escapeXMLValues := func(value string, env string) (string, error) {
|
|
// Escape XML special characters
|
|
var b strings.Builder
|
|
xmlErr := xml.EscapeText(&b, []byte(value))
|
|
if xmlErr != nil {
|
|
return "", fmt.Errorf("failed to XML escape fleet secret %s", env)
|
|
}
|
|
value = b.String()
|
|
return value, nil
|
|
}
|
|
|
|
var err *multierror.Error
|
|
s = fleet.MaybeExpand(s, func(env string, startPos, endPos int) (string, bool) {
|
|
switch {
|
|
case strings.HasPrefix(env, preventEscapingPrefix):
|
|
return "$" + strings.TrimPrefix(env, preventEscapingPrefix), true
|
|
case strings.HasPrefix(strings.ToUpper(env), fleet.ServerVarPrefix):
|
|
// Don't expand fleet vars -- they will be expanded on the server
|
|
return "", false
|
|
case strings.HasPrefix(env, fleet.ServerSecretPrefix):
|
|
switch secretMode {
|
|
case secretsExpand:
|
|
// Expand secrets for client-side validation
|
|
v, ok := lookupEnv(env)
|
|
if ok {
|
|
if !documentIsXML {
|
|
return v, true
|
|
}
|
|
|
|
v, xmlErr := escapeXMLValues(v, env)
|
|
if xmlErr != nil {
|
|
err = multierror.Append(err, xmlErr)
|
|
return "", false
|
|
}
|
|
return v, true
|
|
}
|
|
// If secret not found, leave as-is for server to handle
|
|
return "", false
|
|
case secretsReject:
|
|
err = multierror.Append(err, fmt.Errorf("environment variables with %q prefix are only allowed in profiles and scripts: %q",
|
|
fleet.ServerSecretPrefix, env))
|
|
return "", false
|
|
default:
|
|
// Leave as-is for server to handle
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
// Don't expand fleet vars if they are inside an 'exclusion' zone,
|
|
// i.e. 'description' or 'resolution'....
|
|
for _, z := range exclusionZones {
|
|
if startPos >= z[0] && endPos <= z[1] {
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
v, ok := lookupEnv(env)
|
|
if !ok {
|
|
err = multierror.Append(err, fmt.Errorf("environment variable %q not set", env))
|
|
return "", false
|
|
}
|
|
if !documentIsXML {
|
|
return v, true
|
|
}
|
|
|
|
v, xmlErr := escapeXMLValues(v, env)
|
|
if xmlErr != nil {
|
|
err = multierror.Append(err, xmlErr)
|
|
return "", false
|
|
}
|
|
return v, true
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func ExpandEnvBytes(b []byte) ([]byte, error) {
|
|
s, err := ExpandEnv(string(b))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return []byte(s), nil
|
|
}
|
|
|
|
func ExpandEnvBytesIgnoreSecrets(b []byte) ([]byte, error) {
|
|
s, err := expandEnv(string(b), secretsIgnore)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return []byte(s), nil
|
|
}
|
|
|
|
// ExpandEnvBytesIncludingSecrets expands environment variables including FLEET_SECRET_ variables.
|
|
// This should only be used for client-side validation where the actual secrets are needed temporarily.
|
|
// The expanded secrets are never sent to the server.
|
|
// Missing FLEET_SECRET_ variables do not fail the method; they are just not expanded.
|
|
func ExpandEnvBytesIncludingSecrets(b []byte) ([]byte, error) {
|
|
s, err := expandEnv(string(b), secretsExpand)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return []byte(s), nil
|
|
}
|
|
|
|
// LookupEnvSecrets only looks up FLEET_SECRET_XXX environment variables.
|
|
// This is used for finding secrets in profiles and scripts. The original string is not modified.
|
|
// A map of secret names to raw (unescaped) values is updated.
|
|
// XML escaping is intentionally NOT done here — it is handled server-side during
|
|
// secret expansion (see expandEmbeddedSecrets in secret_variables.go).
|
|
func LookupEnvSecrets(s string, secretsMap map[string]string) error {
|
|
if secretsMap == nil {
|
|
return errors.New("secretsMap cannot be nil")
|
|
}
|
|
|
|
var err *multierror.Error
|
|
_ = fleet.MaybeExpand(s, func(env string, startPos, endPos int) (string, bool) {
|
|
if strings.HasPrefix(env, fleet.ServerSecretPrefix) {
|
|
// lookup the secret and save it, but don't replace
|
|
v, ok := lookupEnv(env)
|
|
if !ok {
|
|
err = multierror.Append(err, fmt.Errorf("environment variable %q not set", env))
|
|
return "", false
|
|
}
|
|
|
|
secretsMap[env] = v
|
|
}
|
|
return "", false
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var escapePattern = regexp.MustCompile(`(\\+\$)`)
|
|
|
|
func escapeString(s string, preventEscapingPrefix string) string {
|
|
return escapePattern.ReplaceAllStringFunc(s, func(match string) string {
|
|
if len(match)%2 != 0 {
|
|
return match
|
|
}
|
|
return strings.Repeat("\\", (len(match)/2)-1) + "$" + preventEscapingPrefix
|
|
})
|
|
}
|
|
|
|
// getExclusionZones returns which positions inside 's' should be
|
|
// excluded from variable interpolation.
|
|
func getExclusionZones(s string) [][2]int {
|
|
// We need a different pattern per section because
|
|
// the delimiting end pattern ((?:^\s+\w+:|\z)) includes the next
|
|
// section token, meaning the matching logic won't work in case
|
|
// we have a 'resolution:' followed by a 'description:' or
|
|
// vice versa, and we try using something like (?:resolution:|description:)
|
|
toExclude := []string{
|
|
"resolution",
|
|
"description",
|
|
}
|
|
patterns := make([]*regexp.Regexp, 0, len(toExclude))
|
|
for _, e := range toExclude {
|
|
pattern := fmt.Sprintf(`(?m)^\s*(?:%s:)(.|[\r\n])*?(?:^\s+\w+:|\z)`, e)
|
|
patterns = append(patterns, regexp.MustCompile(pattern))
|
|
}
|
|
|
|
var zones [][2]int
|
|
for _, pattern := range patterns {
|
|
result := pattern.FindAllStringIndex(s, -1)
|
|
for _, r := range result {
|
|
zones = append(zones, [2]int{r[0], r[1]})
|
|
}
|
|
}
|
|
return zones
|
|
}
|
|
|
|
// hostsKeyPresent checks if the "hosts" key is present in raw spec bytes.
|
|
// The input may be YAML or JSON; YAML is converted to JSON before inspection.
|
|
// Used to distinguish between an omitted hosts key (nil, no-op) and an
|
|
// explicit hosts key with null value (should clear hosts).
|
|
func hostsKeyPresent(rawBytes []byte) bool {
|
|
jsonBytes, err := yaml.YAMLToJSON(rawBytes)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
var raw map[string]json.RawMessage
|
|
if err := json.Unmarshal(jsonBytes, &raw); err != nil {
|
|
return false
|
|
}
|
|
_, ok := raw["hosts"]
|
|
return ok
|
|
}
|