mirror of
https://github.com/empayre/fleet.git
synced 2024-11-06 00:45:19 +00:00
fleetctl gitops (#16535)
Add `fleetctl gitops` command for #13643 Code review video: https://www.loom.com/share/7941c51c709b44ccafd618dd05837d99?sid=27b923d7-1393-4396-bac7-30616b2d6de9 fleet-gitops PR that also needs review: https://github.com/fleetdm/fleet-gitops/pull/26 Working global/team gitops configs that can be used for testing: https://github.com/fleetdm/fleet-gitops/tree/victor/fixing-configs # Checklist for submitter If some of the following don't apply, delete the relevant line. <!-- Note that API documentation changes are now addressed by the product design team. --> - [x] Changes file added for user-visible changes in `changes/` or `orbit/changes/`. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality
This commit is contained in:
parent
6aedcf97be
commit
e4d5e27dd9
2
changes/13643-fleetctl-gitops
Normal file
2
changes/13643-fleetctl-gitops
Normal file
@ -0,0 +1,2 @@
|
||||
Added fleetctl gitops command:
|
||||
- Synchronize Fleet configuration with provided file. This command is intended to be used in a GitOps workflow.
|
@ -77,7 +77,7 @@ func applyCommand() *cli.Command {
|
||||
opts.TeamForPolicies = policiesTeamName
|
||||
}
|
||||
baseDir := filepath.Dir(flFilename)
|
||||
err = fleetClient.ApplyGroup(c.Context, specs, baseDir, logf, opts)
|
||||
_, err = fleetClient.ApplyGroup(c.Context, specs, baseDir, logf, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -265,7 +265,7 @@ spec:
|
||||
},
|
||||
}
|
||||
|
||||
require.Equal(t, "[+] applied 1 teams\n", runAppForTest(t, []string{"apply", "-f", filename}))
|
||||
assert.Contains(t, runAppForTest(t, []string{"apply", "-f", filename}), "[+] applied 1 teams\n")
|
||||
// enroll secret not provided, so left unchanged
|
||||
assert.Equal(t, []*fleet.EnrollSecret{{Secret: "AAA"}}, enrolledSecretsCalled[uint(42)])
|
||||
assert.False(t, ds.ApplyEnrollSecretsFuncInvoked)
|
||||
@ -379,7 +379,7 @@ spec:
|
||||
},
|
||||
}
|
||||
|
||||
require.Equal(t, "[+] applied 1 teams\n", runAppForTest(t, []string{"apply", "-f", filename}))
|
||||
assert.Contains(t, runAppForTest(t, []string{"apply", "-f", filename}), "[+] applied 1 teams\n")
|
||||
// agent options still cleared
|
||||
assert.Nil(t, teamsByName["team1"].Config.AgentOptions)
|
||||
// macos settings and updates are now cleared.
|
||||
@ -869,12 +869,12 @@ func TestApplyPolicies(t *testing.T) {
|
||||
func TestApplyPoliciesValidation(t *testing.T) {
|
||||
// Team Policy Spec
|
||||
filename := writeTmpYml(t, duplicateTeamPolicySpec)
|
||||
errorMsg := `applying policies: policy names must be globally unique. Please correct policy "Is Gatekeeper enabled on macOS devices?" and try again.`
|
||||
errorMsg := `applying policies: policy names must be unique. Please correct policy "Is Gatekeeper enabled on macOS devices?" and try again.`
|
||||
runAppCheckErr(t, []string{"apply", "-f", filename}, errorMsg)
|
||||
|
||||
// Global Policy Spec
|
||||
filename = writeTmpYml(t, duplicateGlobalPolicySpec)
|
||||
errorMsg = `applying policies: policy names must be globally unique. Please correct policy "Is Gatekeeper enabled on macOS devices?" and try again.`
|
||||
errorMsg = `applying policies: policy names must be unique. Please correct policy "Is Gatekeeper enabled on macOS devices?" and try again.`
|
||||
runAppCheckErr(t, []string{"apply", "-f", filename}, errorMsg)
|
||||
}
|
||||
|
||||
@ -1168,10 +1168,10 @@ spec:
|
||||
`, mobileConfigPath))
|
||||
|
||||
// first apply with dry-run
|
||||
require.Equal(t, "[+] would've applied 1 teams\n", runAppForTest(t, []string{"apply", "-f", name, "--dry-run"}))
|
||||
assert.Contains(t, runAppForTest(t, []string{"apply", "-f", name, "--dry-run"}), "[+] would've applied 1 teams\n")
|
||||
|
||||
// then apply for real
|
||||
require.Equal(t, "[+] applied 1 teams\n", runAppForTest(t, []string{"apply", "-f", name}))
|
||||
assert.Contains(t, runAppForTest(t, []string{"apply", "-f", name}), "[+] applied 1 teams\n")
|
||||
assert.JSONEq(t, string(json.RawMessage(`{"config":{"views":{"foo":"qux"}}}`)), string(*savedTeam.Config.AgentOptions))
|
||||
assert.Equal(t, fleet.TeamMDM{
|
||||
EnableDiskEncryption: false,
|
||||
@ -1204,10 +1204,10 @@ spec:
|
||||
`, emptySetupAsst))
|
||||
|
||||
// first apply with dry-run
|
||||
require.Equal(t, "[+] would've applied 1 teams\n", runAppForTest(t, []string{"apply", "-f", name, "--dry-run"}))
|
||||
assert.Contains(t, runAppForTest(t, []string{"apply", "-f", name, "--dry-run"}), "[+] would've applied 1 teams\n")
|
||||
|
||||
// then apply for real
|
||||
require.Equal(t, "[+] applied 1 teams\n", runAppForTest(t, []string{"apply", "-f", name}))
|
||||
assert.Contains(t, runAppForTest(t, []string{"apply", "-f", name}), "[+] applied 1 teams\n")
|
||||
require.True(t, ds.GetMDMAppleSetupAssistantFuncInvoked)
|
||||
require.True(t, ds.SetOrUpdateMDMAppleSetupAssistantFuncInvoked)
|
||||
require.True(t, ds.NewJobFuncInvoked)
|
||||
@ -1243,10 +1243,10 @@ spec:
|
||||
`, bootstrapURL))
|
||||
|
||||
// first apply with dry-run
|
||||
require.Equal(t, "[+] would've applied 1 teams\n", runAppForTest(t, []string{"apply", "-f", name, "--dry-run"}))
|
||||
assert.Contains(t, runAppForTest(t, []string{"apply", "-f", name, "--dry-run"}), "[+] would've applied 1 teams\n")
|
||||
|
||||
// then apply for real
|
||||
require.Equal(t, "[+] applied 1 teams\n", runAppForTest(t, []string{"apply", "-f", name}))
|
||||
assert.Contains(t, runAppForTest(t, []string{"apply", "-f", name}), "[+] applied 1 teams\n")
|
||||
// all left untouched, only bootstrap package added
|
||||
assert.Equal(t, fleet.TeamMDM{
|
||||
EnableDiskEncryption: false,
|
||||
|
@ -108,6 +108,7 @@ func createApp(
|
||||
mdmCommand(),
|
||||
upgradePacksCommand(),
|
||||
runScriptCommand(),
|
||||
gitopsCommand(),
|
||||
}
|
||||
return app
|
||||
}
|
||||
|
@ -2214,7 +2214,7 @@ func TestGetTeamsYAMLAndApply(t *testing.T) {
|
||||
actualYaml := runAppForTest(t, []string{"get", "teams", "--yaml"})
|
||||
yamlFilePath := writeTmpYml(t, actualYaml)
|
||||
|
||||
require.Equal(t, "[+] applied 2 teams\n", runAppForTest(t, []string{"apply", "-f", yamlFilePath}))
|
||||
assert.Contains(t, runAppForTest(t, []string{"apply", "-f", yamlFilePath}), "[+] applied 2 teams\n")
|
||||
}
|
||||
|
||||
func TestGetMDMCommandResults(t *testing.T) {
|
||||
|
71
cmd/fleetctl/gitops.go
Normal file
71
cmd/fleetctl/gitops.go
Normal file
@ -0,0 +1,71 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/fleetdm/fleet/v4/pkg/spec"
|
||||
"github.com/urfave/cli/v2"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func gitopsCommand() *cli.Command {
|
||||
var (
|
||||
flFilename string
|
||||
flDryRun bool
|
||||
)
|
||||
return &cli.Command{
|
||||
Name: "gitops",
|
||||
Usage: "Synchronize Fleet configuration with provided file. This command is intended to be used in a GitOps workflow.",
|
||||
UsageText: `fleetctl gitops [options]`,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "f",
|
||||
EnvVars: []string{"FILENAME"},
|
||||
Value: "",
|
||||
Destination: &flFilename,
|
||||
Usage: "The file with the GitOps configuration",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "dry-run",
|
||||
EnvVars: []string{"DRY_RUN"},
|
||||
Destination: &flDryRun,
|
||||
Usage: "Do not apply the file, just validate it",
|
||||
},
|
||||
configFlag(),
|
||||
contextFlag(),
|
||||
debugFlag(),
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
if flFilename == "" {
|
||||
return errors.New("-f must be specified")
|
||||
}
|
||||
b, err := os.ReadFile(flFilename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fleetClient, err := clientFromCLI(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
baseDir := filepath.Dir(flFilename)
|
||||
config, err := spec.GitOpsFromBytes(b, baseDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logf := func(format string, a ...interface{}) {
|
||||
_, _ = fmt.Fprintf(c.App.Writer, format, a...)
|
||||
}
|
||||
err = fleetClient.DoGitOps(c.Context, config, baseDir, logf, flDryRun)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if flDryRun {
|
||||
_, _ = fmt.Fprintf(c.App.Writer, "[!] gitops dry run succeeded\n")
|
||||
} else {
|
||||
_, _ = fmt.Fprintf(c.App.Writer, "[!] gitops succeeded\n")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
516
cmd/fleetctl/gitops_test.go
Normal file
516
cmd/fleetctl/gitops_test.go
Normal file
@ -0,0 +1,516 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/fleetdm/fleet/v4/server/config"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
|
||||
nanomdm_mock "github.com/fleetdm/fleet/v4/server/mock/nanomdm"
|
||||
"github.com/fleetdm/fleet/v4/server/service"
|
||||
"github.com/micromdm/nanodep/tokenpki"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const teamName = "Team Test"
|
||||
|
||||
func TestBasicGlobalGitOps(t *testing.T) {
|
||||
_, ds := runServerWithMockedDS(t)
|
||||
|
||||
ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { return nil }
|
||||
ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error {
|
||||
return nil
|
||||
}
|
||||
ds.ListGlobalPoliciesFunc = func(ctx context.Context, opts fleet.ListOptions) ([]*fleet.Policy, error) { return nil, nil }
|
||||
ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, error) { return nil, nil }
|
||||
|
||||
// Mock appConfig
|
||||
savedAppConfig := &fleet.AppConfig{}
|
||||
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
||||
return &fleet.AppConfig{}, nil
|
||||
}
|
||||
ds.SaveAppConfigFunc = func(ctx context.Context, config *fleet.AppConfig) error {
|
||||
savedAppConfig = config
|
||||
return nil
|
||||
}
|
||||
var enrolledSecrets []*fleet.EnrollSecret
|
||||
ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error {
|
||||
enrolledSecrets = secrets
|
||||
return nil
|
||||
}
|
||||
|
||||
tmpFile, err := os.CreateTemp(t.TempDir(), "*.yml")
|
||||
require.NoError(t, err)
|
||||
|
||||
const (
|
||||
fleetServerURL = "https://fleet.example.com"
|
||||
orgName = "GitOps Test"
|
||||
)
|
||||
t.Setenv("FLEET_SERVER_URL", fleetServerURL)
|
||||
|
||||
_, err = tmpFile.WriteString(
|
||||
`
|
||||
controls:
|
||||
queries:
|
||||
policies:
|
||||
agent_options:
|
||||
org_settings:
|
||||
server_settings:
|
||||
server_url: $FLEET_SERVER_URL
|
||||
org_info:
|
||||
contact_url: https://example.com/contact
|
||||
org_logo_url: ""
|
||||
org_logo_url_light_background: ""
|
||||
org_name: ${ORG_NAME}
|
||||
secrets:
|
||||
`,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
// No file
|
||||
var errWriter strings.Builder
|
||||
_, err = runAppNoChecks([]string{"gitops", tmpFile.Name()})
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, err.Error(), "-f must be specified")
|
||||
|
||||
// Bad file
|
||||
errWriter.Reset()
|
||||
_, err = runAppNoChecks([]string{"gitops", "-f", "fileDoesNotExist.yml"})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no such file or directory")
|
||||
|
||||
// Empty file
|
||||
errWriter.Reset()
|
||||
badFile, err := os.CreateTemp(t.TempDir(), "*.yml")
|
||||
require.NoError(t, err)
|
||||
_, err = runAppNoChecks([]string{"gitops", "-f", badFile.Name()})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "errors occurred")
|
||||
|
||||
// DoGitOps error
|
||||
t.Setenv("ORG_NAME", "")
|
||||
_, err = runAppNoChecks([]string{"gitops", "-f", tmpFile.Name()})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "organization name must be present")
|
||||
|
||||
// Dry run
|
||||
t.Setenv("ORG_NAME", orgName)
|
||||
_ = runAppForTest(t, []string{"gitops", "-f", tmpFile.Name(), "--dry-run"})
|
||||
assert.Equal(t, fleet.AppConfig{}, *savedAppConfig, "AppConfig should be empty")
|
||||
|
||||
// Real run
|
||||
_ = runAppForTest(t, []string{"gitops", "-f", tmpFile.Name()})
|
||||
assert.Equal(t, orgName, savedAppConfig.OrgInfo.OrgName)
|
||||
assert.Equal(t, fleetServerURL, savedAppConfig.ServerSettings.ServerURL)
|
||||
assert.Empty(t, enrolledSecrets)
|
||||
}
|
||||
|
||||
func TestBasicTeamGitOps(t *testing.T) {
|
||||
license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)}
|
||||
_, ds := runServerWithMockedDS(
|
||||
t, &service.TestServerOpts{
|
||||
License: license,
|
||||
},
|
||||
)
|
||||
|
||||
const secret = "TestSecret"
|
||||
|
||||
ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { return nil }
|
||||
ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error {
|
||||
return nil
|
||||
}
|
||||
ds.ListTeamPoliciesFunc = func(
|
||||
ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions,
|
||||
) (teamPolicies []*fleet.Policy, inheritedPolicies []*fleet.Policy, err error) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, error) { return nil, nil }
|
||||
team := &fleet.Team{
|
||||
ID: 1,
|
||||
CreatedAt: time.Now(),
|
||||
Name: teamName,
|
||||
}
|
||||
ds.TeamByNameFunc = func(ctx context.Context, name string) (*fleet.Team, error) {
|
||||
if name == teamName {
|
||||
return team, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
ds.TeamFunc = func(ctx context.Context, tid uint) (*fleet.Team, error) {
|
||||
if tid == team.ID {
|
||||
return team, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
var savedTeam *fleet.Team
|
||||
ds.SaveTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) {
|
||||
savedTeam = team
|
||||
return team, nil
|
||||
}
|
||||
|
||||
var enrolledSecrets []*fleet.EnrollSecret
|
||||
ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error {
|
||||
enrolledSecrets = secrets
|
||||
return nil
|
||||
}
|
||||
|
||||
tmpFile, err := os.CreateTemp(t.TempDir(), "*.yml")
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Setenv("TEST_SECRET", secret)
|
||||
|
||||
_, err = tmpFile.WriteString(
|
||||
`
|
||||
controls:
|
||||
queries:
|
||||
policies:
|
||||
agent_options:
|
||||
name: ${TEST_TEAM_NAME}
|
||||
team_settings:
|
||||
secrets: [{"secret":"${TEST_SECRET}"}]
|
||||
`,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
// DoGitOps error
|
||||
t.Setenv("TEST_TEAM_NAME", "")
|
||||
_, err = runAppNoChecks([]string{"gitops", "-f", tmpFile.Name()})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "'name' is required")
|
||||
|
||||
// Dry run
|
||||
t.Setenv("TEST_TEAM_NAME", teamName)
|
||||
_ = runAppForTest(t, []string{"gitops", "-f", tmpFile.Name(), "--dry-run"})
|
||||
assert.Nil(t, savedTeam)
|
||||
|
||||
// Real run
|
||||
_ = runAppForTest(t, []string{"gitops", "-f", tmpFile.Name()})
|
||||
require.NotNil(t, savedTeam)
|
||||
assert.Equal(t, teamName, savedTeam.Name)
|
||||
require.Len(t, enrolledSecrets, 1)
|
||||
assert.Equal(t, secret, enrolledSecrets[0].Secret)
|
||||
}
|
||||
|
||||
func TestFullGlobalGitOps(t *testing.T) {
|
||||
// mdm test configuration must be set so that activating windows MDM works.
|
||||
testCert, testKey, err := apple_mdm.NewSCEPCACertKey()
|
||||
require.NoError(t, err)
|
||||
testCertPEM := tokenpki.PEMCertificate(testCert.Raw)
|
||||
testKeyPEM := tokenpki.PEMRSAPrivateKey(testKey)
|
||||
fleetCfg := config.TestConfig()
|
||||
config.SetTestMDMConfig(t, &fleetCfg, testCertPEM, testKeyPEM, nil, "../../server/service/testdata")
|
||||
|
||||
// License is not needed because we are not using any premium features in our config.
|
||||
_, ds := runServerWithMockedDS(
|
||||
t, &service.TestServerOpts{
|
||||
MDMStorage: new(nanomdm_mock.Storage),
|
||||
MDMPusher: mockPusher{},
|
||||
FleetConfig: &fleetCfg,
|
||||
},
|
||||
)
|
||||
|
||||
var appliedScripts []*fleet.Script
|
||||
ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error {
|
||||
appliedScripts = scripts
|
||||
return nil
|
||||
}
|
||||
ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error {
|
||||
return nil
|
||||
}
|
||||
var appliedMacProfiles []*fleet.MDMAppleConfigProfile
|
||||
var appliedWinProfiles []*fleet.MDMWindowsConfigProfile
|
||||
ds.BatchSetMDMProfilesFunc = func(
|
||||
ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile,
|
||||
) error {
|
||||
appliedMacProfiles = macProfiles
|
||||
appliedWinProfiles = winProfiles
|
||||
return nil
|
||||
}
|
||||
ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string) error {
|
||||
return nil
|
||||
}
|
||||
ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) {
|
||||
return job, nil
|
||||
}
|
||||
|
||||
// Policies
|
||||
policy := fleet.Policy{}
|
||||
policy.ID = 1
|
||||
policy.Name = "Policy to delete"
|
||||
policyDeleted := false
|
||||
ds.ListGlobalPoliciesFunc = func(ctx context.Context, opts fleet.ListOptions) ([]*fleet.Policy, error) {
|
||||
return []*fleet.Policy{&policy}, nil
|
||||
}
|
||||
ds.PoliciesByIDFunc = func(ctx context.Context, ids []uint) (map[uint]*fleet.Policy, error) {
|
||||
if slices.Contains(ids, 1) {
|
||||
return map[uint]*fleet.Policy{1: &policy}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
ds.DeleteGlobalPoliciesFunc = func(ctx context.Context, ids []uint) ([]uint, error) {
|
||||
policyDeleted = true
|
||||
assert.Equal(t, []uint{policy.ID}, ids)
|
||||
return ids, nil
|
||||
}
|
||||
var appliedPolicySpecs []*fleet.PolicySpec
|
||||
ds.ApplyPolicySpecsFunc = func(ctx context.Context, authorID uint, specs []*fleet.PolicySpec) error {
|
||||
appliedPolicySpecs = specs
|
||||
return nil
|
||||
}
|
||||
|
||||
// Queries
|
||||
query := fleet.Query{}
|
||||
query.ID = 1
|
||||
query.Name = "Query to delete"
|
||||
queryDeleted := false
|
||||
ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, error) {
|
||||
return []*fleet.Query{&query}, nil
|
||||
}
|
||||
ds.DeleteQueriesFunc = func(ctx context.Context, ids []uint) (uint, error) {
|
||||
queryDeleted = true
|
||||
assert.Equal(t, []uint{query.ID}, ids)
|
||||
return 1, nil
|
||||
}
|
||||
ds.QueryFunc = func(ctx context.Context, id uint) (*fleet.Query, error) {
|
||||
if id == query.ID {
|
||||
return &query, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
var appliedQueries []*fleet.Query
|
||||
ds.QueryByNameFunc = func(ctx context.Context, teamID *uint, name string) (*fleet.Query, error) {
|
||||
return nil, ¬FoundError{}
|
||||
}
|
||||
ds.ApplyQueriesFunc = func(
|
||||
ctx context.Context, authorID uint, queries []*fleet.Query, queriesToDiscardResults map[uint]struct{},
|
||||
) error {
|
||||
appliedQueries = queries
|
||||
return nil
|
||||
}
|
||||
|
||||
// Mock appConfig
|
||||
savedAppConfig := &fleet.AppConfig{}
|
||||
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
||||
return &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true}}, nil
|
||||
}
|
||||
ds.SaveAppConfigFunc = func(ctx context.Context, config *fleet.AppConfig) error {
|
||||
savedAppConfig = config
|
||||
return nil
|
||||
}
|
||||
var enrolledSecrets []*fleet.EnrollSecret
|
||||
ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error {
|
||||
enrolledSecrets = secrets
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
fleetServerURL = "https://fleet.example.com"
|
||||
orgName = "GitOps Test"
|
||||
)
|
||||
t.Setenv("FLEET_SERVER_URL", fleetServerURL)
|
||||
t.Setenv("ORG_NAME", orgName)
|
||||
|
||||
// Dry run
|
||||
file := "./testdata/gitops/global_config_no_paths.yml"
|
||||
_ = runAppForTest(t, []string{"gitops", "-f", file, "--dry-run"})
|
||||
assert.Equal(t, fleet.AppConfig{}, *savedAppConfig, "AppConfig should be empty")
|
||||
assert.Len(t, enrolledSecrets, 0)
|
||||
assert.Len(t, appliedPolicySpecs, 0)
|
||||
assert.Len(t, appliedQueries, 0)
|
||||
assert.Len(t, appliedScripts, 0)
|
||||
assert.Len(t, appliedMacProfiles, 0)
|
||||
assert.Len(t, appliedWinProfiles, 0)
|
||||
|
||||
// Real run
|
||||
_ = runAppForTest(t, []string{"gitops", "-f", file})
|
||||
assert.Equal(t, orgName, savedAppConfig.OrgInfo.OrgName)
|
||||
assert.Equal(t, fleetServerURL, savedAppConfig.ServerSettings.ServerURL)
|
||||
assert.Contains(t, string(*savedAppConfig.AgentOptions), "distributed_denylist_duration")
|
||||
assert.Len(t, enrolledSecrets, 2)
|
||||
assert.True(t, policyDeleted)
|
||||
assert.Len(t, appliedPolicySpecs, 5)
|
||||
assert.True(t, queryDeleted)
|
||||
assert.Len(t, appliedQueries, 3)
|
||||
assert.Len(t, appliedScripts, 1)
|
||||
assert.Len(t, appliedMacProfiles, 1)
|
||||
assert.Len(t, appliedWinProfiles, 1)
|
||||
}
|
||||
|
||||
func TestFullTeamGitOps(t *testing.T) {
|
||||
license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)}
|
||||
|
||||
// mdm test configuration must be set so that activating windows MDM works.
|
||||
testCert, testKey, err := apple_mdm.NewSCEPCACertKey()
|
||||
require.NoError(t, err)
|
||||
testCertPEM := tokenpki.PEMCertificate(testCert.Raw)
|
||||
testKeyPEM := tokenpki.PEMRSAPrivateKey(testKey)
|
||||
fleetCfg := config.TestConfig()
|
||||
config.SetTestMDMConfig(t, &fleetCfg, testCertPEM, testKeyPEM, nil, "../../server/service/testdata")
|
||||
|
||||
// License is not needed because we are not using any premium features in our config.
|
||||
_, ds := runServerWithMockedDS(
|
||||
t, &service.TestServerOpts{
|
||||
License: license,
|
||||
MDMStorage: new(nanomdm_mock.Storage),
|
||||
MDMPusher: mockPusher{},
|
||||
FleetConfig: &fleetCfg,
|
||||
},
|
||||
)
|
||||
|
||||
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
||||
return &fleet.AppConfig{
|
||||
MDM: fleet.MDM{
|
||||
EnabledAndConfigured: true,
|
||||
WindowsEnabledAndConfigured: true,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
var appliedScripts []*fleet.Script
|
||||
ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error {
|
||||
appliedScripts = scripts
|
||||
return nil
|
||||
}
|
||||
ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error {
|
||||
return nil
|
||||
}
|
||||
var appliedMacProfiles []*fleet.MDMAppleConfigProfile
|
||||
var appliedWinProfiles []*fleet.MDMWindowsConfigProfile
|
||||
ds.BatchSetMDMProfilesFunc = func(
|
||||
ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile,
|
||||
) error {
|
||||
appliedMacProfiles = macProfiles
|
||||
appliedWinProfiles = winProfiles
|
||||
return nil
|
||||
}
|
||||
ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string) error {
|
||||
return nil
|
||||
}
|
||||
ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) {
|
||||
return job, nil
|
||||
}
|
||||
|
||||
// Team
|
||||
team := &fleet.Team{
|
||||
ID: 1,
|
||||
CreatedAt: time.Now(),
|
||||
Name: teamName,
|
||||
}
|
||||
ds.TeamByNameFunc = func(ctx context.Context, name string) (*fleet.Team, error) {
|
||||
if name == teamName {
|
||||
return team, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
ds.TeamFunc = func(ctx context.Context, tid uint) (*fleet.Team, error) {
|
||||
if tid == team.ID {
|
||||
return team, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
var savedTeam *fleet.Team
|
||||
ds.SaveTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) {
|
||||
savedTeam = team
|
||||
return team, nil
|
||||
}
|
||||
|
||||
// Policies
|
||||
policy := fleet.Policy{}
|
||||
policy.ID = 1
|
||||
policy.Name = "Policy to delete"
|
||||
policy.TeamID = &team.ID
|
||||
policyDeleted := false
|
||||
ds.ListTeamPoliciesFunc = func(
|
||||
ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions,
|
||||
) (teamPolicies []*fleet.Policy, inheritedPolicies []*fleet.Policy, err error) {
|
||||
return []*fleet.Policy{&policy}, nil, nil
|
||||
}
|
||||
ds.PoliciesByIDFunc = func(ctx context.Context, ids []uint) (map[uint]*fleet.Policy, error) {
|
||||
if slices.Contains(ids, 1) {
|
||||
return map[uint]*fleet.Policy{1: &policy}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
ds.DeleteTeamPoliciesFunc = func(ctx context.Context, teamID uint, IDs []uint) ([]uint, error) {
|
||||
policyDeleted = true
|
||||
assert.Equal(t, []uint{policy.ID}, IDs)
|
||||
return []uint{policy.ID}, nil
|
||||
}
|
||||
var appliedPolicySpecs []*fleet.PolicySpec
|
||||
ds.ApplyPolicySpecsFunc = func(ctx context.Context, authorID uint, specs []*fleet.PolicySpec) error {
|
||||
appliedPolicySpecs = specs
|
||||
return nil
|
||||
}
|
||||
|
||||
// Queries
|
||||
query := fleet.Query{}
|
||||
query.ID = 1
|
||||
query.TeamID = &team.ID
|
||||
query.Name = "Query to delete"
|
||||
queryDeleted := false
|
||||
ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, error) {
|
||||
return []*fleet.Query{&query}, nil
|
||||
}
|
||||
ds.DeleteQueriesFunc = func(ctx context.Context, ids []uint) (uint, error) {
|
||||
queryDeleted = true
|
||||
assert.Equal(t, []uint{query.ID}, ids)
|
||||
return 1, nil
|
||||
}
|
||||
ds.QueryFunc = func(ctx context.Context, id uint) (*fleet.Query, error) {
|
||||
if id == query.ID {
|
||||
return &query, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
var appliedQueries []*fleet.Query
|
||||
ds.QueryByNameFunc = func(ctx context.Context, teamID *uint, name string) (*fleet.Query, error) {
|
||||
return nil, ¬FoundError{}
|
||||
}
|
||||
ds.ApplyQueriesFunc = func(
|
||||
ctx context.Context, authorID uint, queries []*fleet.Query, queriesToDiscardResults map[uint]struct{},
|
||||
) error {
|
||||
appliedQueries = queries
|
||||
return nil
|
||||
}
|
||||
|
||||
var enrolledSecrets []*fleet.EnrollSecret
|
||||
ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error {
|
||||
enrolledSecrets = secrets
|
||||
return nil
|
||||
}
|
||||
|
||||
t.Setenv("TEST_TEAM_NAME", teamName)
|
||||
|
||||
// Dry run
|
||||
file := "./testdata/gitops/team_config_no_paths.yml"
|
||||
_ = runAppForTest(t, []string{"gitops", "-f", file, "--dry-run"})
|
||||
assert.Nil(t, savedTeam)
|
||||
assert.Len(t, enrolledSecrets, 0)
|
||||
assert.Len(t, appliedPolicySpecs, 0)
|
||||
assert.Len(t, appliedQueries, 0)
|
||||
assert.Len(t, appliedScripts, 0)
|
||||
assert.Len(t, appliedMacProfiles, 0)
|
||||
assert.Len(t, appliedWinProfiles, 0)
|
||||
|
||||
// Real run
|
||||
_ = runAppForTest(t, []string{"gitops", "-f", file})
|
||||
require.NotNil(t, savedTeam)
|
||||
assert.Equal(t, teamName, savedTeam.Name)
|
||||
assert.Contains(t, string(*savedTeam.Config.AgentOptions), "distributed_denylist_duration")
|
||||
assert.True(t, savedTeam.Config.Features.EnableHostUsers)
|
||||
assert.Equal(t, 30, savedTeam.Config.HostExpirySettings.HostExpiryWindow)
|
||||
assert.True(t, savedTeam.Config.MDM.EnableDiskEncryption)
|
||||
assert.Len(t, enrolledSecrets, 2)
|
||||
assert.True(t, policyDeleted)
|
||||
assert.Len(t, appliedPolicySpecs, 5)
|
||||
assert.True(t, queryDeleted)
|
||||
assert.Len(t, appliedQueries, 3)
|
||||
assert.Len(t, appliedScripts, 1)
|
||||
assert.Len(t, appliedMacProfiles, 1)
|
||||
assert.Len(t, appliedWinProfiles, 1)
|
||||
}
|
@ -342,7 +342,7 @@ Use the stop and reset subcommands to manage the server and dependencies once st
|
||||
}
|
||||
// this only applies standard queries, the base directory is not used,
|
||||
// so pass in the current working directory.
|
||||
err = client.ApplyGroup(c.Context, specs, ".", logf, fleet.ApplySpecOptions{})
|
||||
_, err = client.ApplyGroup(c.Context, specs, ".", logf, fleet.ApplySpecOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
175
cmd/fleetctl/testdata/gitops/global_config_no_paths.yml
vendored
Normal file
175
cmd/fleetctl/testdata/gitops/global_config_no_paths.yml
vendored
Normal file
@ -0,0 +1,175 @@
|
||||
# Test config
|
||||
controls: # Controls added to "No team"
|
||||
macos_settings:
|
||||
custom_settings:
|
||||
- path: ./lib/macos-password.mobileconfig
|
||||
windows_settings:
|
||||
custom_settings:
|
||||
- path: ./lib/windows-screenlock.xml
|
||||
scripts:
|
||||
- path: ./lib/collect-fleetd-logs.sh
|
||||
enable_disk_encryption: false
|
||||
macos_migration:
|
||||
enable: false
|
||||
mode: ""
|
||||
webhook_url: ""
|
||||
macos_setup:
|
||||
bootstrap_package: null
|
||||
enable_end_user_authentication: false
|
||||
macos_setup_assistant: null
|
||||
macos_updates:
|
||||
deadline: null
|
||||
minimum_version: null
|
||||
windows_enabled_and_configured: true
|
||||
windows_updates:
|
||||
deadline_days: null
|
||||
grace_period_days: null
|
||||
queries:
|
||||
- name: Scheduled query stats
|
||||
description: Collect osquery performance stats directly from osquery
|
||||
query: SELECT *,
|
||||
(SELECT value from osquery_flags where name = 'pack_delimiter') AS delimiter
|
||||
FROM osquery_schedule;
|
||||
interval: 0
|
||||
platform: darwin,linux,windows
|
||||
min_osquery_version: all
|
||||
observer_can_run: false
|
||||
automations_enabled: false
|
||||
logging: snapshot
|
||||
- name: orbit_info
|
||||
query: SELECT * from orbit_info;
|
||||
interval: 0
|
||||
platform: darwin,linux,windows
|
||||
min_osquery_version: all
|
||||
observer_can_run: false
|
||||
automations_enabled: true
|
||||
logging: snapshot
|
||||
- name: osquery_info
|
||||
query: SELECT * from osquery_info;
|
||||
interval: 604800 # 1 week
|
||||
platform: darwin,linux,windows,chrome
|
||||
min_osquery_version: all
|
||||
observer_can_run: false
|
||||
automations_enabled: true
|
||||
logging: snapshot
|
||||
policies:
|
||||
- name: 😊 Failing policy
|
||||
platform: linux
|
||||
description: This policy should always fail.
|
||||
resolution: There is no resolution for this policy.
|
||||
query: SELECT 1 FROM osquery_info WHERE start_time < 0;
|
||||
- name: Passing policy
|
||||
platform: linux,windows,darwin,chrome
|
||||
description: This policy should always pass.
|
||||
resolution: There is no resolution for this policy.
|
||||
query: SELECT 1;
|
||||
- name: No root logins (macOS, Linux)
|
||||
platform: linux,darwin
|
||||
query: SELECT 1 WHERE NOT EXISTS (SELECT * FROM last
|
||||
WHERE username = "root"
|
||||
AND time > (( SELECT unix_time FROM time ) - 3600 ))
|
||||
critical: true
|
||||
- name: 🔥 Failing policy
|
||||
platform: linux
|
||||
description: This policy should always fail.
|
||||
resolution: There is no resolution for this policy.
|
||||
query: SELECT 1 FROM osquery_info WHERE start_time < 0;
|
||||
- name: 😊😊 Failing policy
|
||||
platform: linux
|
||||
description: This policy should always fail.
|
||||
resolution: There is no resolution for this policy.
|
||||
query: SELECT 1 FROM osquery_info WHERE start_time < 0;
|
||||
agent_options:
|
||||
command_line_flags:
|
||||
distributed_denylist_duration: 0
|
||||
config:
|
||||
decorators:
|
||||
load:
|
||||
- SELECT uuid AS host_uuid FROM system_info;
|
||||
- SELECT hostname AS hostname FROM system_info;
|
||||
options:
|
||||
disable_distributed: false
|
||||
distributed_interval: 10
|
||||
distributed_plugin: tls
|
||||
distributed_tls_max_attempts: 3
|
||||
logger_tls_endpoint: /api/v1/osquery/log
|
||||
pack_delimiter: /
|
||||
org_settings:
|
||||
server_settings:
|
||||
debug_host_ids:
|
||||
- 10728
|
||||
deferred_save_host: false
|
||||
enable_analytics: true
|
||||
live_query_disabled: false
|
||||
query_reports_disabled: false
|
||||
scripts_disabled: false
|
||||
server_url: $FLEET_SERVER_URL
|
||||
org_info:
|
||||
contact_url: https://fleetdm.com/company/contact
|
||||
org_logo_url: ""
|
||||
org_logo_url_light_background: ""
|
||||
org_name: $ORG_NAME
|
||||
smtp_settings:
|
||||
authentication_method: authmethod_plain
|
||||
authentication_type: authtype_username_password
|
||||
configured: false
|
||||
domain: ""
|
||||
enable_smtp: false
|
||||
enable_ssl_tls: true
|
||||
enable_start_tls: true
|
||||
password: ""
|
||||
port: 587
|
||||
sender_address: ""
|
||||
server: ""
|
||||
user_name: ""
|
||||
verify_ssl_certs: true
|
||||
sso_settings:
|
||||
enable_jit_provisioning: false
|
||||
enable_jit_role_sync: false
|
||||
enable_sso: true
|
||||
enable_sso_idp_login: false
|
||||
entity_id: https://saml.example.com/entityid
|
||||
idp_image_url: ""
|
||||
idp_name: MockSAML
|
||||
issuer_uri: ""
|
||||
metadata: ""
|
||||
metadata_url: https://mocksaml.com/api/saml/metadata
|
||||
integrations:
|
||||
jira: []
|
||||
zendesk: []
|
||||
mdm:
|
||||
apple_bm_default_team: ""
|
||||
end_user_authentication:
|
||||
entity_id: ""
|
||||
idp_name: ""
|
||||
issuer_uri: ""
|
||||
metadata: ""
|
||||
metadata_url: ""
|
||||
webhook_settings:
|
||||
failing_policies_webhook:
|
||||
destination_url: https://host.docker.internal:8080/bozo
|
||||
enable_failing_policies_webhook: false
|
||||
host_batch_size: 0
|
||||
policy_ids: []
|
||||
host_status_webhook:
|
||||
days_count: 0
|
||||
destination_url: ""
|
||||
enable_host_status_webhook: false
|
||||
host_percentage: 0
|
||||
interval: 24h0m0s
|
||||
vulnerabilities_webhook:
|
||||
destination_url: ""
|
||||
enable_vulnerabilities_webhook: false
|
||||
host_batch_size: 0
|
||||
fleet_desktop: # Applies to Fleet Premium only
|
||||
transparency_url: https://fleetdm.com/transparency
|
||||
host_expiry_settings: # Applies to all teams
|
||||
host_expiry_enabled: false
|
||||
features: # Features added to all teams
|
||||
enable_host_users: true
|
||||
enable_software_inventory: true
|
||||
vulnerability_settings:
|
||||
databases_path: ""
|
||||
secrets: # These secrets are used to enroll hosts to the "All teams" team
|
||||
- secret: SampleSecret123
|
||||
- secret: ABC
|
7
cmd/fleetctl/testdata/gitops/lib/collect-fleetd-logs.sh
vendored
Normal file
7
cmd/fleetctl/testdata/gitops/lib/collect-fleetd-logs.sh
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
cp /var/log/orbit/orbit.stderr.log ~/Library/Logs/Fleet/fleet-desktop.log /Users/Shared
|
||||
|
||||
echo "Successfully copied fleetd logs to the /Users/Shared folder."
|
||||
|
||||
echo "To retrieve logs, ask the end user to open Finder and in the menu bar select Go > Go to Folder."
|
||||
|
||||
echo "Then, ask the end user to type in /Users/Shared, press Return, and locate orbit.stderr.log (Orbit logs) and fleet-desktop.log (Fleet Desktop logs) files."
|
55
cmd/fleetctl/testdata/gitops/lib/macos-password.mobileconfig
vendored
Normal file
55
cmd/fleetctl/testdata/gitops/lib/macos-password.mobileconfig
vendored
Normal file
@ -0,0 +1,55 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PayloadContent</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>PayloadDescription</key>
|
||||
<string>Configures Passcode settings</string>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>Passcode</string>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>com.github.erikberglund.ProfileCreator.F7CF282E-D91B-44E9-922F-A719634F9C8E.com.apple.mobiledevice.passwordpolicy.231DFC90-D5A7-41B8-9246-564056048AC5</string>
|
||||
<key>PayloadOrganization</key>
|
||||
<string></string>
|
||||
<key>PayloadType</key>
|
||||
<string>com.apple.mobiledevice.passwordpolicy</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>231DFC90-D5A7-41B8-9246-564056048AC5</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
<key>allowSimple</key>
|
||||
<true/>
|
||||
<key>forcePIN</key>
|
||||
<true/>
|
||||
<key>maxFailedAttempts</key>
|
||||
<integer>11</integer>
|
||||
<key>maxGracePeriod</key>
|
||||
<integer>1</integer>
|
||||
<key>maxInactivity</key>
|
||||
<integer>15</integer>
|
||||
<key>minLength</key>
|
||||
<integer>10</integer>
|
||||
<key>requireAlphanumeric</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</array>
|
||||
<key>PayloadDescription</key>
|
||||
<string>Configures our Macs to require passwords that are 10 character long</string>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>Password policy - require 10 characters</string>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>com.github.erikberglund.ProfileCreator.F7CF282E-D91B-44E9-922F-A719634F9C8E</string>
|
||||
<key>PayloadOrganization</key>
|
||||
<string>FleetDM</string>
|
||||
<key>PayloadScope</key>
|
||||
<string>System</string>
|
||||
<key>PayloadType</key>
|
||||
<string>Configuration</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>F7CF282E-D91B-44E9-922F-A719634F9C8E</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</plist>
|
48
cmd/fleetctl/testdata/gitops/lib/windows-screenlock.xml
vendored
Normal file
48
cmd/fleetctl/testdata/gitops/lib/windows-screenlock.xml
vendored
Normal file
@ -0,0 +1,48 @@
|
||||
<Replace>
|
||||
<!-- Enforce screenlock -->
|
||||
<Item>
|
||||
<Meta>
|
||||
<Format xmlns="syncml:metinf">int</Format>
|
||||
</Meta>
|
||||
<Target>
|
||||
<LocURI>./Device/Vendor/MSFT/Policy/Config/DeviceLock/DevicePasswordEnabled</LocURI>
|
||||
</Target>
|
||||
<Data>0</Data>
|
||||
</Item>
|
||||
</Replace>
|
||||
<Replace>
|
||||
<!-- Enforce screenlock after 15 minutes -->
|
||||
<Item>
|
||||
<Meta>
|
||||
<Format xmlns="syncml:metinf">int</Format>
|
||||
</Meta>
|
||||
<Target>
|
||||
<LocURI>./Device/Vendor/MSFT/Policy/Config/DeviceLock/MaxInactivityTimeDeviceLock</LocURI>
|
||||
</Target>
|
||||
<Data>15</Data>
|
||||
</Item>
|
||||
</Replace>
|
||||
<Replace>
|
||||
<!-- Enforce PIN or password length (10 characters) -->
|
||||
<Item>
|
||||
<Meta>
|
||||
<Format xmlns="syncml:metinf">int</Format>
|
||||
</Meta>
|
||||
<Target>
|
||||
<LocURI>./Device/Vendor/MSFT/Policy/Config/DeviceLock/MinDevicePasswordLength</LocURI>
|
||||
</Target>
|
||||
<Data>10</Data>
|
||||
</Item>
|
||||
</Replace>
|
||||
<Replace>
|
||||
<!-- Enforce PIN or password has at least one lowercase letter and at least one number -->
|
||||
<Item>
|
||||
<Meta>
|
||||
<Format xmlns="syncml:metinf">int</Format>
|
||||
</Meta>
|
||||
<Target>
|
||||
<LocURI>./Device/Vendor/MSFT/Policy/Config/DeviceLock/MinDevicePasswordComplexCharacters</LocURI>
|
||||
</Target>
|
||||
<Data>2</Data>
|
||||
</Item>
|
||||
</Replace>
|
111
cmd/fleetctl/testdata/gitops/team_config_no_paths.yml
vendored
Normal file
111
cmd/fleetctl/testdata/gitops/team_config_no_paths.yml
vendored
Normal file
@ -0,0 +1,111 @@
|
||||
name: "${TEST_TEAM_NAME}"
|
||||
team_settings:
|
||||
secrets:
|
||||
- secret: "SampleSecret123"
|
||||
- secret: "ABC"
|
||||
webhook_settings:
|
||||
failing_policies_webhook:
|
||||
enable_failing_policies_webhook: true
|
||||
destination_url: https://example.tines.com/webhook
|
||||
policy_ids: [1, 2, 3, 4, 5, 6 ,7, 8, 9]
|
||||
features:
|
||||
enable_host_users: true
|
||||
enable_software_inventory: true
|
||||
host_expiry_settings:
|
||||
host_expiry_enabled: true
|
||||
host_expiry_window: 30
|
||||
agent_options:
|
||||
command_line_flags:
|
||||
distributed_denylist_duration: 0
|
||||
config:
|
||||
decorators:
|
||||
load:
|
||||
- SELECT uuid AS host_uuid FROM system_info;
|
||||
- SELECT hostname AS hostname FROM system_info;
|
||||
options:
|
||||
disable_distributed: false
|
||||
distributed_interval: 10
|
||||
distributed_plugin: tls
|
||||
distributed_tls_max_attempts: 3
|
||||
logger_tls_endpoint: /api/v1/osquery/log
|
||||
pack_delimiter: /
|
||||
controls:
|
||||
macos_settings:
|
||||
custom_settings:
|
||||
- path: ./lib/macos-password.mobileconfig
|
||||
windows_settings:
|
||||
custom_settings:
|
||||
- path: ./lib/windows-screenlock.xml
|
||||
scripts:
|
||||
- path: ./lib/collect-fleetd-logs.sh
|
||||
enable_disk_encryption: true
|
||||
macos_migration:
|
||||
enable: false
|
||||
mode: ""
|
||||
webhook_url: ""
|
||||
macos_setup:
|
||||
bootstrap_package: null
|
||||
enable_end_user_authentication: false
|
||||
macos_setup_assistant: null
|
||||
macos_updates:
|
||||
deadline: null
|
||||
minimum_version: null
|
||||
windows_enabled_and_configured: true
|
||||
windows_updates:
|
||||
deadline_days: null
|
||||
grace_period_days: null
|
||||
queries:
|
||||
- name: Scheduled query stats
|
||||
description: Collect osquery performance stats directly from osquery
|
||||
query: SELECT *,
|
||||
(SELECT value from osquery_flags where name = 'pack_delimiter') AS delimiter
|
||||
FROM osquery_schedule;
|
||||
interval: 0
|
||||
platform: darwin,linux,windows
|
||||
min_osquery_version: all
|
||||
observer_can_run: false
|
||||
automations_enabled: false
|
||||
logging: snapshot
|
||||
- name: orbit_info
|
||||
query: SELECT * from orbit_info;
|
||||
interval: 0
|
||||
platform: darwin,linux,windows
|
||||
min_osquery_version: all
|
||||
observer_can_run: false
|
||||
automations_enabled: true
|
||||
logging: snapshot
|
||||
- name: osquery_info
|
||||
query: SELECT * from osquery_info;
|
||||
interval: 604800 # 1 week
|
||||
platform: darwin,linux,windows,chrome
|
||||
min_osquery_version: all
|
||||
observer_can_run: false
|
||||
automations_enabled: true
|
||||
logging: snapshot
|
||||
policies:
|
||||
- name: 😊 Failing policy
|
||||
platform: linux
|
||||
description: This policy should always fail.
|
||||
resolution: There is no resolution for this policy.
|
||||
query: SELECT 1 FROM osquery_info WHERE start_time < 0;
|
||||
- name: Passing policy
|
||||
platform: linux,windows,darwin,chrome
|
||||
description: This policy should always pass.
|
||||
resolution: There is no resolution for this policy.
|
||||
query: SELECT 1;
|
||||
- name: No root logins (macOS, Linux)
|
||||
platform: linux,darwin
|
||||
query: SELECT 1 WHERE NOT EXISTS (SELECT * FROM last
|
||||
WHERE username = "root"
|
||||
AND time > (( SELECT unix_time FROM time ) - 3600 ))
|
||||
critical: true
|
||||
- name: 🔥 Failing policy
|
||||
platform: linux
|
||||
description: This policy should always fail.
|
||||
resolution: There is no resolution for this policy.
|
||||
query: SELECT 1 FROM osquery_info WHERE start_time < 0;
|
||||
- name: 😊😊 Failing policy
|
||||
platform: linux
|
||||
description: This policy should always fail.
|
||||
resolution: There is no resolution for this policy.
|
||||
query: SELECT 1 FROM osquery_info WHERE start_time < 0;
|
@ -804,16 +804,13 @@ func (svc *Service) ApplyTeamSpecs(ctx context.Context, specs []*fleet.TeamSpec,
|
||||
})
|
||||
}
|
||||
|
||||
if applyOpts.DryRun {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
idsByName := make(map[string]uint, len(details))
|
||||
if len(details) > 0 {
|
||||
for _, tm := range details {
|
||||
idsByName[tm.Name] = tm.ID
|
||||
}
|
||||
|
||||
if !applyOpts.DryRun {
|
||||
if err := svc.ds.NewActivity(
|
||||
ctx,
|
||||
authz.UserFromContext(ctx),
|
||||
@ -824,6 +821,7 @@ func (svc *Service) ApplyTeamSpecs(ctx context.Context, specs []*fleet.TeamSpec,
|
||||
return nil, ctxerr.Wrap(ctx, err, "create activity for team spec")
|
||||
}
|
||||
}
|
||||
}
|
||||
return idsByName, nil
|
||||
}
|
||||
|
||||
|
492
pkg/spec/gitops.go
Normal file
492
pkg/spec/gitops.go
Normal file
@ -0,0 +1,492 @@
|
||||
package spec
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/ghodss/yaml"
|
||||
"github.com/hashicorp/go-multierror"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
type BaseItem struct {
|
||||
Path *string `json:"path"`
|
||||
}
|
||||
|
||||
type Controls struct {
|
||||
BaseItem
|
||||
MacOSUpdates interface{} `json:"macos_updates"`
|
||||
MacOSSettings interface{} `json:"macos_settings"`
|
||||
MacOSSetup interface{} `json:"macos_setup"`
|
||||
MacOSMigration interface{} `json:"macos_migration"`
|
||||
|
||||
WindowsUpdates interface{} `json:"windows_updates"`
|
||||
WindowsSettings interface{} `json:"windows_settings"`
|
||||
WindowsEnabledAndConfigured interface{} `json:"windows_enabled_and_configured"`
|
||||
|
||||
EnableDiskEncryption interface{} `json:"enable_disk_encryption"`
|
||||
|
||||
Scripts []BaseItem `json:"scripts"`
|
||||
}
|
||||
|
||||
type Policy struct {
|
||||
BaseItem
|
||||
fleet.PolicySpec
|
||||
}
|
||||
|
||||
type Query struct {
|
||||
BaseItem
|
||||
fleet.QuerySpec
|
||||
}
|
||||
|
||||
type GitOps struct {
|
||||
TeamID *uint
|
||||
TeamName *string
|
||||
TeamSettings map[string]interface{}
|
||||
OrgSettings map[string]interface{}
|
||||
AgentOptions *json.RawMessage
|
||||
Controls Controls
|
||||
Policies []*fleet.PolicySpec
|
||||
Queries []*fleet.QuerySpec
|
||||
}
|
||||
|
||||
// GitOpsFromBytes parses a GitOps yaml file.
|
||||
func GitOpsFromBytes(b []byte, baseDir string) (*GitOps, error) {
|
||||
var top map[string]json.RawMessage
|
||||
b = []byte(os.ExpandEnv(string(b))) // replace $var and ${var} with env values
|
||||
if err := yaml.Unmarshal(b, &top); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal file %w: \n", err)
|
||||
}
|
||||
|
||||
var multiError *multierror.Error
|
||||
result := &GitOps{}
|
||||
|
||||
topKeys := []string{"name", "team_settings", "org_settings", "agent_options", "controls", "policies", "queries"}
|
||||
for k := range top {
|
||||
if !slices.Contains(topKeys, k) {
|
||||
multiError = multierror.Append(multiError, fmt.Errorf("unknown top-level field: %s", k))
|
||||
}
|
||||
}
|
||||
|
||||
// Figure out if this is an org or team settings file
|
||||
teamRaw, teamOk := top["name"]
|
||||
teamSettingsRaw, teamSettingsOk := top["team_settings"]
|
||||
orgSettingsRaw, orgOk := top["org_settings"]
|
||||
if orgOk {
|
||||
if teamOk || teamSettingsOk {
|
||||
multiError = multierror.Append(multiError, errors.New("'org_settings' cannot be used with 'name' or 'team_settings'"))
|
||||
} else {
|
||||
multiError = parseOrgSettings(orgSettingsRaw, result, baseDir, multiError)
|
||||
}
|
||||
} else if teamOk && teamSettingsOk {
|
||||
multiError = parseName(teamRaw, result, multiError)
|
||||
multiError = parseTeamSettings(teamSettingsRaw, result, baseDir, multiError)
|
||||
} else {
|
||||
multiError = multierror.Append(multiError, errors.New("either 'org_settings' or 'name' and 'team_settings' is required"))
|
||||
}
|
||||
|
||||
// Validate the required top level options
|
||||
multiError = parseControls(top, result, baseDir, multiError)
|
||||
multiError = parseAgentOptions(top, result, baseDir, multiError)
|
||||
multiError = parsePolicies(top, result, baseDir, multiError)
|
||||
multiError = parseQueries(top, result, baseDir, multiError)
|
||||
|
||||
return result, multiError.ErrorOrNil()
|
||||
}
|
||||
|
||||
func parseName(raw json.RawMessage, result *GitOps, multiError *multierror.Error) *multierror.Error {
|
||||
if err := json.Unmarshal(raw, &result.TeamName); err != nil {
|
||||
return multierror.Append(multiError, fmt.Errorf("failed to unmarshal name: %v", err))
|
||||
}
|
||||
if result.TeamName == nil || *result.TeamName == "" {
|
||||
return multierror.Append(multiError, errors.New("team 'name' is required"))
|
||||
}
|
||||
if !isASCII(*result.TeamName) {
|
||||
multiError = multierror.Append(multiError, fmt.Errorf("team name must be in ASCII: %s", *result.TeamName))
|
||||
}
|
||||
return multiError
|
||||
}
|
||||
|
||||
func parseOrgSettings(raw json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error {
|
||||
var orgSettingsTop BaseItem
|
||||
if err := json.Unmarshal(raw, &orgSettingsTop); err != nil {
|
||||
return multierror.Append(multiError, fmt.Errorf("failed to unmarshal org_settings: %v", err))
|
||||
}
|
||||
noError := true
|
||||
if orgSettingsTop.Path != nil {
|
||||
fileBytes, err := os.ReadFile(resolveApplyRelativePath(baseDir, *orgSettingsTop.Path))
|
||||
if err != nil {
|
||||
noError = false
|
||||
multiError = multierror.Append(multiError, fmt.Errorf("failed to read org settings file %s: %v", *orgSettingsTop.Path, err))
|
||||
} else {
|
||||
fileBytes = []byte(os.ExpandEnv(string(fileBytes)))
|
||||
var pathOrgSettings BaseItem
|
||||
if err := yaml.Unmarshal(fileBytes, &pathOrgSettings); err != nil {
|
||||
noError = false
|
||||
multiError = multierror.Append(
|
||||
multiError, fmt.Errorf("failed to unmarshal org settings file %s: %v", *orgSettingsTop.Path, err),
|
||||
)
|
||||
} else {
|
||||
if pathOrgSettings.Path != nil {
|
||||
noError = false
|
||||
multiError = multierror.Append(
|
||||
multiError,
|
||||
fmt.Errorf("nested paths are not supported: %s in %s", *pathOrgSettings.Path, *orgSettingsTop.Path),
|
||||
)
|
||||
} else {
|
||||
raw = fileBytes
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if noError {
|
||||
if err := yaml.Unmarshal(raw, &result.OrgSettings); err != nil {
|
||||
// This error is currently unreachable because we know the file is valid YAML when we checked for nested path
|
||||
multiError = multierror.Append(multiError, fmt.Errorf("failed to unmarshal org settings: %v", err))
|
||||
} else {
|
||||
multiError = parseSecrets(result, multiError)
|
||||
}
|
||||
// TODO: Validate that integrations.(jira|zendesk)[].api_token is not empty or fleet.MaskedPassword
|
||||
}
|
||||
return multiError
|
||||
}
|
||||
|
||||
func parseTeamSettings(raw json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error {
|
||||
var teamSettingsTop BaseItem
|
||||
if err := json.Unmarshal(raw, &teamSettingsTop); err != nil {
|
||||
return multierror.Append(multiError, fmt.Errorf("failed to unmarshal team_settings: %v", err))
|
||||
}
|
||||
noError := true
|
||||
if teamSettingsTop.Path != nil {
|
||||
fileBytes, err := os.ReadFile(resolveApplyRelativePath(baseDir, *teamSettingsTop.Path))
|
||||
if err != nil {
|
||||
noError = false
|
||||
multiError = multierror.Append(multiError, fmt.Errorf("failed to read team settings file %s: %v", *teamSettingsTop.Path, err))
|
||||
} else {
|
||||
fileBytes = []byte(os.ExpandEnv(string(fileBytes)))
|
||||
var pathTeamSettings BaseItem
|
||||
if err := yaml.Unmarshal(fileBytes, &pathTeamSettings); err != nil {
|
||||
noError = false
|
||||
multiError = multierror.Append(
|
||||
multiError, fmt.Errorf("failed to unmarshal team settings file %s: %v", *teamSettingsTop.Path, err),
|
||||
)
|
||||
} else {
|
||||
if pathTeamSettings.Path != nil {
|
||||
noError = false
|
||||
multiError = multierror.Append(
|
||||
multiError,
|
||||
fmt.Errorf("nested paths are not supported: %s in %s", *pathTeamSettings.Path, *teamSettingsTop.Path),
|
||||
)
|
||||
} else {
|
||||
raw = fileBytes
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if noError {
|
||||
if err := yaml.Unmarshal(raw, &result.TeamSettings); err != nil {
|
||||
// This error is currently unreachable because we know the file is valid YAML when we checked for nested path
|
||||
multiError = multierror.Append(multiError, fmt.Errorf("failed to unmarshal team settings: %v", err))
|
||||
} else {
|
||||
multiError = parseSecrets(result, multiError)
|
||||
}
|
||||
}
|
||||
return multiError
|
||||
}
|
||||
|
||||
func parseSecrets(result *GitOps, multiError *multierror.Error) *multierror.Error {
|
||||
var rawSecrets interface{}
|
||||
var ok bool
|
||||
if result.TeamName == nil {
|
||||
rawSecrets, ok = result.OrgSettings["secrets"]
|
||||
if !ok {
|
||||
return multierror.Append(multiError, errors.New("'org_settings.secrets' is required"))
|
||||
}
|
||||
} else {
|
||||
rawSecrets, ok = result.TeamSettings["secrets"]
|
||||
if !ok {
|
||||
return multierror.Append(multiError, errors.New("'team_settings.secrets' is required"))
|
||||
}
|
||||
}
|
||||
var enrollSecrets []*fleet.EnrollSecret
|
||||
if rawSecrets != nil {
|
||||
secrets, ok := rawSecrets.([]interface{})
|
||||
if !ok {
|
||||
return multierror.Append(multiError, errors.New("'secrets' must be a list of secret items"))
|
||||
}
|
||||
for _, enrollSecret := range secrets {
|
||||
var secret string
|
||||
var secretInterface interface{}
|
||||
secretMap, ok := enrollSecret.(map[string]interface{})
|
||||
if ok {
|
||||
secretInterface, ok = secretMap["secret"]
|
||||
}
|
||||
if ok {
|
||||
secret, ok = secretInterface.(string)
|
||||
}
|
||||
if !ok || secret == "" {
|
||||
multiError = multierror.Append(
|
||||
multiError, errors.New("each item in 'secrets' must have a 'secret' key containing an ASCII string value"),
|
||||
)
|
||||
break
|
||||
}
|
||||
enrollSecrets = append(
|
||||
enrollSecrets, &fleet.EnrollSecret{Secret: secret},
|
||||
)
|
||||
}
|
||||
}
|
||||
if result.TeamName == nil {
|
||||
result.OrgSettings["secrets"] = enrollSecrets
|
||||
} else {
|
||||
result.TeamSettings["secrets"] = enrollSecrets
|
||||
}
|
||||
return multiError
|
||||
}
|
||||
|
||||
func parseAgentOptions(top map[string]json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error {
|
||||
agentOptionsRaw, ok := top["agent_options"]
|
||||
if !ok {
|
||||
return multierror.Append(multiError, errors.New("'agent_options' is required"))
|
||||
}
|
||||
var agentOptionsTop BaseItem
|
||||
if err := json.Unmarshal(agentOptionsRaw, &agentOptionsTop); err != nil {
|
||||
multiError = multierror.Append(multiError, fmt.Errorf("failed to unmarshal agent_options: %v", err))
|
||||
} else {
|
||||
if agentOptionsTop.Path == nil {
|
||||
result.AgentOptions = &agentOptionsRaw
|
||||
} else {
|
||||
fileBytes, err := os.ReadFile(resolveApplyRelativePath(baseDir, *agentOptionsTop.Path))
|
||||
if err != nil {
|
||||
return multierror.Append(multiError, fmt.Errorf("failed to read agent options file %s: %v", *agentOptionsTop.Path, err))
|
||||
}
|
||||
fileBytes = []byte(os.ExpandEnv(string(fileBytes)))
|
||||
var pathAgentOptions BaseItem
|
||||
if err := yaml.Unmarshal(fileBytes, &pathAgentOptions); err != nil {
|
||||
return multierror.Append(
|
||||
multiError, fmt.Errorf("failed to unmarshal agent options file %s: %v", *agentOptionsTop.Path, err),
|
||||
)
|
||||
}
|
||||
if pathAgentOptions.Path != nil {
|
||||
return multierror.Append(
|
||||
multiError,
|
||||
fmt.Errorf("nested paths are not supported: %s in %s", *pathAgentOptions.Path, *agentOptionsTop.Path),
|
||||
)
|
||||
}
|
||||
var raw json.RawMessage
|
||||
if err := yaml.Unmarshal(fileBytes, &raw); err != nil {
|
||||
// This error is currently unreachable because we know the file is valid YAML when we checked for nested path
|
||||
return multierror.Append(
|
||||
multiError, fmt.Errorf("failed to unmarshal agent options file %s: %v", *agentOptionsTop.Path, err),
|
||||
)
|
||||
}
|
||||
result.AgentOptions = &raw
|
||||
}
|
||||
}
|
||||
return multiError
|
||||
}
|
||||
|
||||
func parseControls(top map[string]json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error {
|
||||
controlsRaw, ok := top["controls"]
|
||||
if !ok {
|
||||
return multierror.Append(multiError, errors.New("'controls' is required"))
|
||||
}
|
||||
var controlsTop Controls
|
||||
if err := json.Unmarshal(controlsRaw, &controlsTop); err != nil {
|
||||
return multierror.Append(multiError, fmt.Errorf("failed to unmarshal controls: %v", err))
|
||||
}
|
||||
if controlsTop.Path == nil {
|
||||
result.Controls = controlsTop
|
||||
} else {
|
||||
fileBytes, err := os.ReadFile(resolveApplyRelativePath(baseDir, *controlsTop.Path))
|
||||
if err != nil {
|
||||
return multierror.Append(multiError, fmt.Errorf("failed to read controls file %s: %v", *controlsTop.Path, err))
|
||||
}
|
||||
fileBytes = []byte(os.ExpandEnv(string(fileBytes)))
|
||||
var pathControls Controls
|
||||
if err := yaml.Unmarshal(fileBytes, &pathControls); err != nil {
|
||||
return multierror.Append(multiError, fmt.Errorf("failed to unmarshal controls file %s: %v", *controlsTop.Path, err))
|
||||
}
|
||||
if pathControls.Path != nil {
|
||||
return multierror.Append(
|
||||
multiError,
|
||||
fmt.Errorf("nested paths are not supported: %s in %s", *pathControls.Path, *controlsTop.Path),
|
||||
)
|
||||
}
|
||||
result.Controls = pathControls
|
||||
}
|
||||
return multiError
|
||||
}
|
||||
|
||||
func parsePolicies(top map[string]json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error {
|
||||
policiesRaw, ok := top["policies"]
|
||||
if !ok {
|
||||
return multierror.Append(multiError, errors.New("'policies' key is required"))
|
||||
}
|
||||
var policies []Policy
|
||||
if err := json.Unmarshal(policiesRaw, &policies); err != nil {
|
||||
return multierror.Append(multiError, fmt.Errorf("failed to unmarshal policies: %v", err))
|
||||
}
|
||||
for _, item := range policies {
|
||||
item := item
|
||||
if item.Path == nil {
|
||||
result.Policies = append(result.Policies, &item.PolicySpec)
|
||||
} else {
|
||||
fileBytes, err := os.ReadFile(resolveApplyRelativePath(baseDir, *item.Path))
|
||||
if err != nil {
|
||||
multiError = multierror.Append(multiError, fmt.Errorf("failed to read policies file %s: %v", *item.Path, err))
|
||||
continue
|
||||
}
|
||||
fileBytes = []byte(os.ExpandEnv(string(fileBytes)))
|
||||
var pathPolicies []*Policy
|
||||
if err := yaml.Unmarshal(fileBytes, &pathPolicies); err != nil {
|
||||
multiError = multierror.Append(multiError, fmt.Errorf("failed to unmarshal policies file %s: %v", *item.Path, err))
|
||||
continue
|
||||
}
|
||||
for _, pp := range pathPolicies {
|
||||
pp := pp
|
||||
if pp != nil {
|
||||
if pp.Path != nil {
|
||||
multiError = multierror.Append(
|
||||
multiError, fmt.Errorf("nested paths are not supported: %s in %s", *pp.Path, *item.Path),
|
||||
)
|
||||
} else {
|
||||
result.Policies = append(result.Policies, &pp.PolicySpec)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Make sure team name is correct, and do additional validation
|
||||
for _, item := range result.Policies {
|
||||
if item.Name == "" {
|
||||
multiError = multierror.Append(multiError, errors.New("policy name is required for each policy"))
|
||||
}
|
||||
if item.Query == "" {
|
||||
multiError = multierror.Append(multiError, errors.New("policy query is required for each policy"))
|
||||
}
|
||||
if result.TeamName != nil {
|
||||
item.Team = *result.TeamName
|
||||
} else {
|
||||
item.Team = ""
|
||||
}
|
||||
}
|
||||
duplicates := getDuplicateNames(
|
||||
result.Policies, func(p *fleet.PolicySpec) string {
|
||||
return p.Name
|
||||
},
|
||||
)
|
||||
if len(duplicates) > 0 {
|
||||
multiError = multierror.Append(multiError, fmt.Errorf("duplicate policy names: %v", duplicates))
|
||||
}
|
||||
return multiError
|
||||
}
|
||||
|
||||
func parseQueries(top map[string]json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error {
|
||||
queriesRaw, ok := top["queries"]
|
||||
if !ok {
|
||||
return multierror.Append(multiError, errors.New("'queries' key is required"))
|
||||
}
|
||||
var queries []Query
|
||||
if err := json.Unmarshal(queriesRaw, &queries); err != nil {
|
||||
return multierror.Append(multiError, fmt.Errorf("failed to unmarshal queries: %v", err))
|
||||
}
|
||||
for _, item := range queries {
|
||||
item := item
|
||||
if item.Path == nil {
|
||||
result.Queries = append(result.Queries, &item.QuerySpec)
|
||||
} else {
|
||||
fileBytes, err := os.ReadFile(resolveApplyRelativePath(baseDir, *item.Path))
|
||||
if err != nil {
|
||||
multiError = multierror.Append(multiError, fmt.Errorf("failed to read queries file %s: %v", *item.Path, err))
|
||||
continue
|
||||
}
|
||||
fileBytes = []byte(os.ExpandEnv(string(fileBytes)))
|
||||
var pathQueries []*Query
|
||||
if err := yaml.Unmarshal(fileBytes, &pathQueries); err != nil {
|
||||
multiError = multierror.Append(multiError, fmt.Errorf("failed to unmarshal queries file %s: %v", *item.Path, err))
|
||||
continue
|
||||
}
|
||||
for _, pq := range pathQueries {
|
||||
pq := pq
|
||||
if pq != nil {
|
||||
if pq.Path != nil {
|
||||
multiError = multierror.Append(
|
||||
multiError, fmt.Errorf("nested paths are not supported: %s in %s", *pq.Path, *item.Path),
|
||||
)
|
||||
} else {
|
||||
result.Queries = append(result.Queries, &pq.QuerySpec)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Make sure team name is correct and do additional validation
|
||||
for _, q := range result.Queries {
|
||||
if q.Name == "" {
|
||||
multiError = multierror.Append(multiError, errors.New("query name is required for each query"))
|
||||
}
|
||||
if q.Query == "" {
|
||||
multiError = multierror.Append(multiError, errors.New("query SQL query is required for each query"))
|
||||
}
|
||||
// Don't use non-ASCII
|
||||
if !isASCII(q.Name) {
|
||||
multiError = multierror.Append(multiError, fmt.Errorf("query name must be in ASCII: %s", q.Name))
|
||||
}
|
||||
if result.TeamName != nil {
|
||||
q.TeamName = *result.TeamName
|
||||
} else {
|
||||
q.TeamName = ""
|
||||
}
|
||||
}
|
||||
duplicates := getDuplicateNames(
|
||||
result.Queries, func(q *fleet.QuerySpec) string {
|
||||
return q.Name
|
||||
},
|
||||
)
|
||||
if len(duplicates) > 0 {
|
||||
multiError = multierror.Append(multiError, fmt.Errorf("duplicate query names: %v", duplicates))
|
||||
}
|
||||
return multiError
|
||||
}
|
||||
|
||||
func getDuplicateNames[T any](slice []T, getComparableString func(T) string) []string {
|
||||
// We are using the allKeys map as a set here. True means the item is a duplicate.
|
||||
allKeys := make(map[string]bool)
|
||||
var duplicates []string
|
||||
for _, item := range slice {
|
||||
name := getComparableString(item)
|
||||
if isDuplicate, exists := allKeys[name]; exists {
|
||||
// If this name hasn't already been marked as a duplicate.
|
||||
if !isDuplicate {
|
||||
duplicates = append(duplicates, name)
|
||||
}
|
||||
allKeys[name] = true
|
||||
} else {
|
||||
allKeys[name] = false
|
||||
}
|
||||
}
|
||||
return duplicates
|
||||
}
|
||||
|
||||
func isASCII(s string) bool {
|
||||
for _, c := range s {
|
||||
if c > unicode.MaxASCII {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// resolves the paths to an absolute path relative to the baseDir, which should
|
||||
// be the path of the YAML file where the relative paths were specified. If the
|
||||
// path is already absolute, it is left untouched.
|
||||
func resolveApplyRelativePath(baseDir string, path string) string {
|
||||
if baseDir == "" || filepath.IsAbs(path) {
|
||||
return path
|
||||
}
|
||||
return filepath.Join(baseDir, path)
|
||||
}
|
649
pkg/spec/gitops_test.go
Normal file
649
pkg/spec/gitops_test.go
Normal file
@ -0,0 +1,649 @@
|
||||
package spec
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var topLevelOptions = map[string]string{
|
||||
"controls": "controls:",
|
||||
"queries": "queries:",
|
||||
"policies": "policies:",
|
||||
"agent_options": "agent_options:",
|
||||
"org_settings": `
|
||||
org_settings:
|
||||
server_settings:
|
||||
server_url: https://fleet.example.com
|
||||
org_info:
|
||||
contact_url: https://example.com/contact
|
||||
org_logo_url: ""
|
||||
org_logo_url_light_background: ""
|
||||
org_name: Test Org
|
||||
secrets:
|
||||
`,
|
||||
}
|
||||
|
||||
var teamLevelOptions = map[string]string{
|
||||
"controls": "controls:",
|
||||
"queries": "queries:",
|
||||
"policies": "policies:",
|
||||
"agent_options": "agent_options:",
|
||||
"name": "name: TeamName",
|
||||
"team_settings": `
|
||||
team_settings:
|
||||
secrets:
|
||||
`,
|
||||
}
|
||||
|
||||
func TestValidGitOpsYaml(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := map[string]struct {
|
||||
filePath string
|
||||
isTeam bool
|
||||
}{
|
||||
"global_config_no_paths": {
|
||||
filePath: "testdata/global_config_no_paths.yml",
|
||||
},
|
||||
"global_config_with_paths": {
|
||||
filePath: "testdata/global_config.yml",
|
||||
},
|
||||
"team_config_no_paths": {
|
||||
filePath: "testdata/team_config_no_paths.yml",
|
||||
isTeam: true,
|
||||
},
|
||||
"team_config_with_paths": {
|
||||
filePath: "testdata/team_config.yml",
|
||||
isTeam: true,
|
||||
},
|
||||
}
|
||||
|
||||
for name, test := range tests {
|
||||
test := test
|
||||
name := name
|
||||
t.Run(
|
||||
name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
dat, err := os.ReadFile(test.filePath)
|
||||
require.NoError(t, err)
|
||||
gitops, err := GitOpsFromBytes(dat, "./testdata")
|
||||
require.NoError(t, err)
|
||||
|
||||
if test.isTeam {
|
||||
// Check team settings
|
||||
assert.Equal(t, "Team1", *gitops.TeamName)
|
||||
assert.Contains(t, gitops.TeamSettings, "webhook_settings")
|
||||
assert.Contains(t, gitops.TeamSettings, "host_expiry_settings")
|
||||
assert.Contains(t, gitops.TeamSettings, "features")
|
||||
assert.Contains(t, gitops.TeamSettings, "secrets")
|
||||
secrets, ok := gitops.TeamSettings["secrets"]
|
||||
assert.True(t, ok, "secrets not found")
|
||||
require.Len(t, secrets.([]*fleet.EnrollSecret), 2)
|
||||
assert.Equal(t, "SampleSecret123", secrets.([]*fleet.EnrollSecret)[0].Secret)
|
||||
assert.Equal(t, "ABC", secrets.([]*fleet.EnrollSecret)[1].Secret)
|
||||
} else {
|
||||
// Check org settings
|
||||
serverSettings, ok := gitops.OrgSettings["server_settings"]
|
||||
assert.True(t, ok, "server_settings not found")
|
||||
assert.Equal(t, "https://fleet.example.com", serverSettings.(map[string]interface{})["server_url"])
|
||||
assert.Contains(t, gitops.OrgSettings, "org_info")
|
||||
assert.Contains(t, gitops.OrgSettings, "smtp_settings")
|
||||
assert.Contains(t, gitops.OrgSettings, "sso_settings")
|
||||
assert.Contains(t, gitops.OrgSettings, "integrations")
|
||||
assert.Contains(t, gitops.OrgSettings, "mdm")
|
||||
assert.Contains(t, gitops.OrgSettings, "webhook_settings")
|
||||
assert.Contains(t, gitops.OrgSettings, "fleet_desktop")
|
||||
assert.Contains(t, gitops.OrgSettings, "host_expiry_settings")
|
||||
assert.Contains(t, gitops.OrgSettings, "features")
|
||||
assert.Contains(t, gitops.OrgSettings, "vulnerability_settings")
|
||||
assert.Contains(t, gitops.OrgSettings, "secrets")
|
||||
secrets, ok := gitops.OrgSettings["secrets"]
|
||||
assert.True(t, ok, "secrets not found")
|
||||
require.Len(t, secrets.([]*fleet.EnrollSecret), 2)
|
||||
assert.Equal(t, "SampleSecret123", secrets.([]*fleet.EnrollSecret)[0].Secret)
|
||||
assert.Equal(t, "ABC", secrets.([]*fleet.EnrollSecret)[1].Secret)
|
||||
}
|
||||
|
||||
// Check controls
|
||||
_, ok := gitops.Controls.MacOSSettings.(map[string]interface{})
|
||||
assert.True(t, ok, "macos_settings not found")
|
||||
_, ok = gitops.Controls.WindowsSettings.(map[string]interface{})
|
||||
assert.True(t, ok, "windows_settings not found")
|
||||
_, ok = gitops.Controls.EnableDiskEncryption.(bool)
|
||||
assert.True(t, ok, "enable_disk_encryption not found")
|
||||
_, ok = gitops.Controls.MacOSMigration.(map[string]interface{})
|
||||
assert.True(t, ok, "macos_migration not found")
|
||||
_, ok = gitops.Controls.MacOSSetup.(map[string]interface{})
|
||||
assert.True(t, ok, "macos_setup not found")
|
||||
_, ok = gitops.Controls.MacOSUpdates.(map[string]interface{})
|
||||
assert.True(t, ok, "macos_updates not found")
|
||||
_, ok = gitops.Controls.WindowsEnabledAndConfigured.(bool)
|
||||
assert.True(t, ok, "windows_enabled_and_configured not found")
|
||||
_, ok = gitops.Controls.WindowsUpdates.(map[string]interface{})
|
||||
assert.True(t, ok, "windows_updates not found")
|
||||
|
||||
// Check agent options
|
||||
assert.NotNil(t, gitops.AgentOptions)
|
||||
assert.Contains(t, string(*gitops.AgentOptions), "distributed_denylist_duration")
|
||||
|
||||
// Check queries
|
||||
require.Len(t, gitops.Queries, 3)
|
||||
assert.Equal(t, "Scheduled query stats", gitops.Queries[0].Name)
|
||||
assert.Equal(t, "orbit_info", gitops.Queries[1].Name)
|
||||
assert.Equal(t, "osquery_info", gitops.Queries[2].Name)
|
||||
|
||||
// Check policies
|
||||
require.Len(t, gitops.Policies, 5)
|
||||
assert.Equal(t, "😊 Failing policy", gitops.Policies[0].Name)
|
||||
assert.Equal(t, "Passing policy", gitops.Policies[1].Name)
|
||||
assert.Equal(t, "No root logins (macOS, Linux)", gitops.Policies[2].Name)
|
||||
assert.Equal(t, "🔥 Failing policy", gitops.Policies[3].Name)
|
||||
assert.Equal(t, "😊😊 Failing policy", gitops.Policies[4].Name)
|
||||
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDuplicatePolicyNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
config := getGlobalConfig([]string{"policies"})
|
||||
config += `
|
||||
policies:
|
||||
- name: My policy
|
||||
platform: linux
|
||||
query: SELECT 1 FROM osquery_info WHERE start_time < 0;
|
||||
- name: My policy
|
||||
platform: windows
|
||||
query: SELECT 1;
|
||||
`
|
||||
_, err := GitOpsFromBytes([]byte(config), "")
|
||||
assert.ErrorContains(t, err, "duplicate policy names")
|
||||
}
|
||||
|
||||
func TestDuplicateQueryNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
config := getGlobalConfig([]string{"queries"})
|
||||
config += `
|
||||
queries:
|
||||
- name: orbit_info
|
||||
query: SELECT * from orbit_info;
|
||||
interval: 0
|
||||
platform: darwin,linux,windows
|
||||
min_osquery_version: all
|
||||
observer_can_run: false
|
||||
automations_enabled: true
|
||||
logging: snapshot
|
||||
- name: orbit_info
|
||||
query: SELECT 1;
|
||||
interval: 300
|
||||
platform: windows
|
||||
min_osquery_version: all
|
||||
observer_can_run: false
|
||||
automations_enabled: true
|
||||
logging: snapshot
|
||||
`
|
||||
_, err := GitOpsFromBytes([]byte(config), "")
|
||||
assert.ErrorContains(t, err, "duplicate query names")
|
||||
}
|
||||
|
||||
func TestUnicodeQueryNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
config := getGlobalConfig([]string{"queries"})
|
||||
config += `
|
||||
queries:
|
||||
- name: 😊 orbit_info
|
||||
query: SELECT * from orbit_info;
|
||||
interval: 0
|
||||
platform: darwin,linux,windows
|
||||
min_osquery_version: all
|
||||
observer_can_run: false
|
||||
automations_enabled: true
|
||||
logging: snapshot
|
||||
`
|
||||
_, err := GitOpsFromBytes([]byte(config), "")
|
||||
assert.ErrorContains(t, err, "query name must be in ASCII")
|
||||
}
|
||||
|
||||
func TestUnicodeTeamName(t *testing.T) {
|
||||
t.Parallel()
|
||||
config := getTeamConfig([]string{"name"})
|
||||
config += `name: 😊 TeamName`
|
||||
_, err := GitOpsFromBytes([]byte(config), "")
|
||||
assert.ErrorContains(t, err, "team name must be in ASCII")
|
||||
}
|
||||
|
||||
func TestMixingGlobalAndTeamConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Mixing org_settings and team name
|
||||
config := getGlobalConfig(nil)
|
||||
config += "name: TeamName\n"
|
||||
_, err := GitOpsFromBytes([]byte(config), "")
|
||||
assert.ErrorContains(t, err, "'org_settings' cannot be used with 'name' or 'team_settings'")
|
||||
|
||||
// Mixing org_settings and team_settings
|
||||
config = getGlobalConfig(nil)
|
||||
config += "team_settings:\n secrets: []\n"
|
||||
_, err = GitOpsFromBytes([]byte(config), "")
|
||||
assert.ErrorContains(t, err, "'org_settings' cannot be used with 'name' or 'team_settings'")
|
||||
|
||||
// Mixing org_settings and team name and team_settings
|
||||
config = getGlobalConfig(nil)
|
||||
config += "name: TeamName\n"
|
||||
config += "team_settings:\n secrets: []\n"
|
||||
_, err = GitOpsFromBytes([]byte(config), "")
|
||||
assert.ErrorContains(t, err, "'org_settings' cannot be used with 'name' or 'team_settings'")
|
||||
|
||||
}
|
||||
|
||||
func TestInvalidGitOpsYaml(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Bad YAML
|
||||
_, err := GitOpsFromBytes([]byte("bad:\nbad"), "")
|
||||
assert.ErrorContains(t, err, "failed to unmarshal")
|
||||
|
||||
for _, name := range []string{"global", "team"} {
|
||||
t.Run(
|
||||
name, func(t *testing.T) {
|
||||
isTeam := name == "team"
|
||||
getConfig := getGlobalConfig
|
||||
if isTeam {
|
||||
getConfig = getTeamConfig
|
||||
}
|
||||
|
||||
if isTeam {
|
||||
// Invalid top level key
|
||||
config := getConfig(nil)
|
||||
config += "unknown_key:\n"
|
||||
_, err = GitOpsFromBytes([]byte(config), "")
|
||||
assert.ErrorContains(t, err, "unknown top-level field")
|
||||
|
||||
// Invalid team name
|
||||
config = getConfig([]string{"name"})
|
||||
config += "name: [2]\n"
|
||||
_, err = GitOpsFromBytes([]byte(config), "")
|
||||
assert.ErrorContains(t, err, "failed to unmarshal name")
|
||||
|
||||
// Missing team name
|
||||
config = getConfig([]string{"name"})
|
||||
config += "name:\n"
|
||||
_, err = GitOpsFromBytes([]byte(config), "")
|
||||
assert.ErrorContains(t, err, "'name' is required")
|
||||
|
||||
// Invalid team_settings
|
||||
config = getConfig([]string{"team_settings"})
|
||||
config += "team_settings:\n path: [2]\n"
|
||||
_, err = GitOpsFromBytes([]byte(config), "")
|
||||
assert.ErrorContains(t, err, "failed to unmarshal team_settings")
|
||||
|
||||
// Invalid team_settings in a separate file
|
||||
tmpFile, err := os.CreateTemp(t.TempDir(), "*team_settings.yml")
|
||||
require.NoError(t, err)
|
||||
_, err = tmpFile.WriteString("[2]")
|
||||
require.NoError(t, err)
|
||||
config = getConfig([]string{"team_settings"})
|
||||
config += fmt.Sprintf("%s:\n path: %s\n", "team_settings", tmpFile.Name())
|
||||
_, err = GitOpsFromBytes([]byte(config), "")
|
||||
assert.ErrorContains(t, err, "failed to unmarshal team settings file")
|
||||
|
||||
// Invalid secrets 1
|
||||
config = getConfig([]string{"team_settings"})
|
||||
config += "team_settings:\n secrets: bad\n"
|
||||
_, err = GitOpsFromBytes([]byte(config), "")
|
||||
assert.ErrorContains(t, err, "must be a list of secret items")
|
||||
|
||||
// Invalid secrets 2
|
||||
config = getConfig([]string{"team_settings"})
|
||||
config += "team_settings:\n secrets: [2]\n"
|
||||
_, err = GitOpsFromBytes([]byte(config), "")
|
||||
assert.ErrorContains(t, err, "must have a 'secret' key")
|
||||
|
||||
// Missing secrets
|
||||
config = getConfig([]string{"team_settings"})
|
||||
config += "team_settings:\n"
|
||||
_, err = GitOpsFromBytes([]byte(config), "")
|
||||
assert.ErrorContains(t, err, "'team_settings.secrets' is required")
|
||||
} else {
|
||||
// Invalid org_settings
|
||||
config := getConfig([]string{"org_settings"})
|
||||
config += "org_settings:\n path: [2]\n"
|
||||
_, err = GitOpsFromBytes([]byte(config), "")
|
||||
assert.ErrorContains(t, err, "failed to unmarshal org_settings")
|
||||
|
||||
// Invalid org_settings in a separate file
|
||||
tmpFile, err := os.CreateTemp(t.TempDir(), "*org_settings.yml")
|
||||
require.NoError(t, err)
|
||||
_, err = tmpFile.WriteString("[2]")
|
||||
require.NoError(t, err)
|
||||
config = getConfig([]string{"org_settings"})
|
||||
config += fmt.Sprintf("%s:\n path: %s\n", "org_settings", tmpFile.Name())
|
||||
_, err = GitOpsFromBytes([]byte(config), "")
|
||||
assert.ErrorContains(t, err, "failed to unmarshal org settings file")
|
||||
|
||||
// Invalid secrets 1
|
||||
config = getConfig([]string{"org_settings"})
|
||||
config += "org_settings:\n secrets: bad\n"
|
||||
_, err = GitOpsFromBytes([]byte(config), "")
|
||||
assert.ErrorContains(t, err, "must be a list of secret items")
|
||||
|
||||
// Invalid secrets 2
|
||||
config = getConfig([]string{"org_settings"})
|
||||
config += "org_settings:\n secrets: [2]\n"
|
||||
_, err = GitOpsFromBytes([]byte(config), "")
|
||||
assert.ErrorContains(t, err, "must have a 'secret' key")
|
||||
|
||||
// Missing secrets
|
||||
config = getConfig([]string{"org_settings"})
|
||||
config += "org_settings:\n"
|
||||
_, err = GitOpsFromBytes([]byte(config), "")
|
||||
assert.ErrorContains(t, err, "'org_settings.secrets' is required")
|
||||
}
|
||||
|
||||
// Invalid agent_options
|
||||
config := getConfig([]string{"agent_options"})
|
||||
config += "agent_options:\n path: [2]\n"
|
||||
_, err = GitOpsFromBytes([]byte(config), "")
|
||||
assert.ErrorContains(t, err, "failed to unmarshal agent_options")
|
||||
|
||||
// Invalid agent_options in a separate file
|
||||
tmpFile, err := os.CreateTemp(t.TempDir(), "*agent_options.yml")
|
||||
require.NoError(t, err)
|
||||
_, err = tmpFile.WriteString("[2]")
|
||||
require.NoError(t, err)
|
||||
config = getConfig([]string{"agent_options"})
|
||||
config += fmt.Sprintf("%s:\n path: %s\n", "agent_options", tmpFile.Name())
|
||||
_, err = GitOpsFromBytes([]byte(config), "")
|
||||
assert.ErrorContains(t, err, "failed to unmarshal agent options file")
|
||||
|
||||
// Invalid controls
|
||||
config = getConfig([]string{"controls"})
|
||||
config += "controls:\n path: [2]\n"
|
||||
_, err = GitOpsFromBytes([]byte(config), "")
|
||||
assert.ErrorContains(t, err, "failed to unmarshal controls")
|
||||
|
||||
// Invalid controls in a separate file
|
||||
tmpFile, err = os.CreateTemp(t.TempDir(), "*controls.yml")
|
||||
require.NoError(t, err)
|
||||
_, err = tmpFile.WriteString("[2]")
|
||||
require.NoError(t, err)
|
||||
config = getConfig([]string{"controls"})
|
||||
config += fmt.Sprintf("%s:\n path: %s\n", "controls", tmpFile.Name())
|
||||
_, err = GitOpsFromBytes([]byte(config), "")
|
||||
assert.ErrorContains(t, err, "failed to unmarshal controls file")
|
||||
|
||||
// Invalid policies
|
||||
config = getConfig([]string{"policies"})
|
||||
config += "policies:\n path: [2]\n"
|
||||
_, err = GitOpsFromBytes([]byte(config), "")
|
||||
assert.ErrorContains(t, err, "failed to unmarshal policies")
|
||||
|
||||
// Invalid policies in a separate file
|
||||
tmpFile, err = os.CreateTemp(t.TempDir(), "*policies.yml")
|
||||
require.NoError(t, err)
|
||||
_, err = tmpFile.WriteString("[2]")
|
||||
require.NoError(t, err)
|
||||
config = getConfig([]string{"policies"})
|
||||
config += fmt.Sprintf("%s:\n - path: %s\n", "policies", tmpFile.Name())
|
||||
_, err = GitOpsFromBytes([]byte(config), "")
|
||||
assert.ErrorContains(t, err, "failed to unmarshal policies file")
|
||||
|
||||
// Policy name missing
|
||||
config = getConfig([]string{"policies"})
|
||||
config += "policies:\n - query: SELECT 1;\n"
|
||||
_, err = GitOpsFromBytes([]byte(config), "")
|
||||
assert.ErrorContains(t, err, "name is required")
|
||||
|
||||
// Policy query missing
|
||||
config = getConfig([]string{"policies"})
|
||||
config += "policies:\n - name: Test Policy\n"
|
||||
_, err = GitOpsFromBytes([]byte(config), "")
|
||||
assert.ErrorContains(t, err, "query is required")
|
||||
|
||||
// Invalid queries
|
||||
config = getConfig([]string{"queries"})
|
||||
config += "queries:\n path: [2]\n"
|
||||
_, err = GitOpsFromBytes([]byte(config), "")
|
||||
assert.ErrorContains(t, err, "failed to unmarshal queries")
|
||||
|
||||
// Invalid policies in a separate file
|
||||
tmpFile, err = os.CreateTemp(t.TempDir(), "*queries.yml")
|
||||
require.NoError(t, err)
|
||||
_, err = tmpFile.WriteString("[2]")
|
||||
require.NoError(t, err)
|
||||
config = getConfig([]string{"queries"})
|
||||
config += fmt.Sprintf("%s:\n - path: %s\n", "queries", tmpFile.Name())
|
||||
_, err = GitOpsFromBytes([]byte(config), "")
|
||||
assert.ErrorContains(t, err, "failed to unmarshal queries file")
|
||||
|
||||
// Query name missing
|
||||
config = getConfig([]string{"queries"})
|
||||
config += "queries:\n - query: SELECT 1;\n"
|
||||
_, err = GitOpsFromBytes([]byte(config), "")
|
||||
assert.ErrorContains(t, err, "name is required")
|
||||
|
||||
// Query SQL query missing
|
||||
config = getConfig([]string{"queries"})
|
||||
config += "queries:\n - name: Test Query\n"
|
||||
_, err = GitOpsFromBytes([]byte(config), "")
|
||||
assert.ErrorContains(t, err, "query is required")
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopLevelGitOpsValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := map[string]struct {
|
||||
optsToExclude []string
|
||||
shouldPass bool
|
||||
isTeam bool
|
||||
}{
|
||||
"all_present_global": {
|
||||
optsToExclude: []string{},
|
||||
shouldPass: true,
|
||||
},
|
||||
"all_present_team": {
|
||||
optsToExclude: []string{},
|
||||
shouldPass: true,
|
||||
isTeam: true,
|
||||
},
|
||||
"missing_all": {
|
||||
optsToExclude: []string{"controls", "queries", "policies", "agent_options", "org_settings"},
|
||||
},
|
||||
"missing_controls": {
|
||||
optsToExclude: []string{"controls"},
|
||||
},
|
||||
"missing_queries": {
|
||||
optsToExclude: []string{"queries"},
|
||||
},
|
||||
"missing_policies": {
|
||||
optsToExclude: []string{"policies"},
|
||||
},
|
||||
"missing_agent_options": {
|
||||
optsToExclude: []string{"agent_options"},
|
||||
},
|
||||
"missing_org_settings": {
|
||||
optsToExclude: []string{"org_settings"},
|
||||
},
|
||||
"missing_name": {
|
||||
optsToExclude: []string{"name"},
|
||||
isTeam: true,
|
||||
},
|
||||
"missing_team_settings": {
|
||||
optsToExclude: []string{"team_settings"},
|
||||
isTeam: true,
|
||||
},
|
||||
}
|
||||
for name, test := range tests {
|
||||
t.Run(
|
||||
name, func(t *testing.T) {
|
||||
var config string
|
||||
if test.isTeam {
|
||||
config = getTeamConfig(test.optsToExclude)
|
||||
} else {
|
||||
config = getGlobalConfig(test.optsToExclude)
|
||||
}
|
||||
_, err := GitOpsFromBytes([]byte(config), "")
|
||||
if test.shouldPass {
|
||||
assert.NoError(t, err)
|
||||
} else {
|
||||
assert.ErrorContains(t, err, "is required")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitOpsNullArrays(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
config := getGlobalConfig([]string{"queries", "policies"})
|
||||
config += "queries: null\npolicies: ~\n"
|
||||
gitops, err := GitOpsFromBytes([]byte(config), "")
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, gitops.Queries)
|
||||
assert.Nil(t, gitops.Policies)
|
||||
}
|
||||
|
||||
func TestGitOpsPaths(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := map[string]struct {
|
||||
isArray bool
|
||||
isTeam bool
|
||||
goodConfig string
|
||||
}{
|
||||
"org_settings": {
|
||||
isArray: false,
|
||||
goodConfig: "secrets: []\n",
|
||||
},
|
||||
"team_settings": {
|
||||
isArray: false,
|
||||
isTeam: true,
|
||||
goodConfig: "secrets: []\n",
|
||||
},
|
||||
"controls": {
|
||||
isArray: false,
|
||||
goodConfig: "windows_enabled_and_configured: true\n",
|
||||
},
|
||||
"queries": {
|
||||
isArray: true,
|
||||
goodConfig: "[]",
|
||||
},
|
||||
"policies": {
|
||||
isArray: true,
|
||||
goodConfig: "[]",
|
||||
},
|
||||
"agent_options": {
|
||||
isArray: false,
|
||||
goodConfig: "name: value\n",
|
||||
},
|
||||
}
|
||||
|
||||
for name, test := range tests {
|
||||
test := test
|
||||
name := name
|
||||
t.Run(
|
||||
name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
getConfig := getGlobalConfig
|
||||
if test.isTeam {
|
||||
getConfig = getTeamConfig
|
||||
}
|
||||
|
||||
// Test an absolute top level path
|
||||
tmpFile, err := os.CreateTemp(t.TempDir(), "*good.yml")
|
||||
require.NoError(t, err)
|
||||
_, err = tmpFile.WriteString(test.goodConfig)
|
||||
require.NoError(t, err)
|
||||
config := getConfig([]string{name})
|
||||
if test.isArray {
|
||||
config += fmt.Sprintf("%s:\n - path: %s\n", name, tmpFile.Name())
|
||||
} else {
|
||||
config += fmt.Sprintf("%s:\n path: %s\n", name, tmpFile.Name())
|
||||
}
|
||||
_, err = GitOpsFromBytes([]byte(config), "")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test a relative top level path
|
||||
config = getConfig([]string{name})
|
||||
dir, file := filepath.Split(tmpFile.Name())
|
||||
if test.isArray {
|
||||
config += fmt.Sprintf("%s:\n - path: ./%s\n", name, file)
|
||||
} else {
|
||||
config += fmt.Sprintf("%s:\n path: ./%s\n", name, file)
|
||||
}
|
||||
_, err = GitOpsFromBytes([]byte(config), dir)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test a bad path
|
||||
config = getConfig([]string{name})
|
||||
if test.isArray {
|
||||
config += fmt.Sprintf("%s:\n - path: ./%s\n", name, "doesNotExist.yml")
|
||||
} else {
|
||||
config += fmt.Sprintf("%s:\n path: ./%s\n", name, "doesNotExist.yml")
|
||||
}
|
||||
_, err = GitOpsFromBytes([]byte(config), dir)
|
||||
assert.ErrorContains(t, err, "no such file or directory")
|
||||
|
||||
// Test a bad file -- cannot be unmarshalled
|
||||
tmpFileBad, err := os.CreateTemp(t.TempDir(), "*invalid.yml")
|
||||
require.NoError(t, err)
|
||||
_, err = tmpFileBad.WriteString("bad:\nbad")
|
||||
require.NoError(t, err)
|
||||
config = getConfig([]string{name})
|
||||
if test.isArray {
|
||||
config += fmt.Sprintf("%s:\n - path: %s\n", name, tmpFileBad.Name())
|
||||
} else {
|
||||
config += fmt.Sprintf("%s:\n path: %s\n", name, tmpFileBad.Name())
|
||||
}
|
||||
_, err = GitOpsFromBytes([]byte(config), "")
|
||||
assert.ErrorContains(t, err, "failed to unmarshal")
|
||||
|
||||
// Test a nested path -- bad
|
||||
tmpFileBad, err = os.CreateTemp(t.TempDir(), "*bad.yml")
|
||||
require.NoError(t, err)
|
||||
if test.isArray {
|
||||
_, err = tmpFileBad.WriteString(fmt.Sprintf("- path: %s\n", tmpFile.Name()))
|
||||
} else {
|
||||
_, err = tmpFileBad.WriteString(fmt.Sprintf("path: %s\n", tmpFile.Name()))
|
||||
}
|
||||
require.NoError(t, err)
|
||||
config = getConfig([]string{name})
|
||||
dir, file = filepath.Split(tmpFileBad.Name())
|
||||
if test.isArray {
|
||||
config += fmt.Sprintf("%s:\n - path: ./%s\n", name, file)
|
||||
} else {
|
||||
config += fmt.Sprintf("%s:\n path: ./%s\n", name, file)
|
||||
}
|
||||
_, err = GitOpsFromBytes([]byte(config), dir)
|
||||
assert.ErrorContains(t, err, "nested paths are not supported")
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func getGlobalConfig(optsToExclude []string) string {
|
||||
return getBaseConfig(topLevelOptions, optsToExclude)
|
||||
}
|
||||
|
||||
func getTeamConfig(optsToExclude []string) string {
|
||||
return getBaseConfig(teamLevelOptions, optsToExclude)
|
||||
}
|
||||
|
||||
func getBaseConfig(options map[string]string, optsToExclude []string) string {
|
||||
var config string
|
||||
for key, value := range options {
|
||||
if !slices.Contains(optsToExclude, key) {
|
||||
config += value + "\n"
|
||||
}
|
||||
}
|
||||
return config
|
||||
}
|
14
pkg/spec/testdata/agent-options.yml
vendored
Normal file
14
pkg/spec/testdata/agent-options.yml
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
command_line_flags:
|
||||
distributed_denylist_duration: 0
|
||||
config:
|
||||
decorators:
|
||||
load:
|
||||
- SELECT uuid AS host_uuid FROM system_info;
|
||||
- SELECT hostname AS hostname FROM system_info;
|
||||
options:
|
||||
disable_distributed: false
|
||||
distributed_interval: 10
|
||||
distributed_plugin: tls
|
||||
distributed_tls_max_attempts: 3
|
||||
logger_tls_endpoint: /api/v1/osquery/log
|
||||
pack_delimiter: /
|
24
pkg/spec/testdata/controls.yml
vendored
Normal file
24
pkg/spec/testdata/controls.yml
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
macos_settings:
|
||||
custom_settings:
|
||||
- path: ./lib/macos-password.mobileconfig
|
||||
windows_settings:
|
||||
custom_settings:
|
||||
- path: ./lib/windows-screenlock.xml
|
||||
scripts:
|
||||
- path: ./lib/collect-fleetd-logs.sh
|
||||
enable_disk_encryption: true
|
||||
macos_migration:
|
||||
enable: false
|
||||
mode: ""
|
||||
webhook_url: ""
|
||||
macos_setup:
|
||||
bootstrap_package: null
|
||||
enable_end_user_authentication: false
|
||||
macos_setup_assistant: null
|
||||
macos_updates:
|
||||
deadline: null
|
||||
minimum_version: null
|
||||
windows_enabled_and_configured: true
|
||||
windows_updates:
|
||||
deadline_days: null
|
||||
grace_period_days: null
|
0
pkg/spec/testdata/empty.yml
vendored
Normal file
0
pkg/spec/testdata/empty.yml
vendored
Normal file
27
pkg/spec/testdata/global_config.yml
vendored
Normal file
27
pkg/spec/testdata/global_config.yml
vendored
Normal file
@ -0,0 +1,27 @@
|
||||
# Test config
|
||||
controls: # Controls added to "No team"
|
||||
path: ./controls.yml
|
||||
queries:
|
||||
- path: ./top.queries.yml
|
||||
- path: ./empty.yml
|
||||
- name: osquery_info
|
||||
query: SELECT * from osquery_info;
|
||||
interval: 604800 # 1 week
|
||||
platform: darwin,linux,windows,chrome
|
||||
min_osquery_version: all
|
||||
observer_can_run: false
|
||||
automations_enabled: true
|
||||
logging: snapshot
|
||||
policies:
|
||||
- path: ./top.policies.yml
|
||||
- path: ./top.policies2.yml
|
||||
- path: ./empty.yml
|
||||
- name: 😊😊 Failing policy
|
||||
platform: linux
|
||||
description: This policy should always fail.
|
||||
resolution: There is no resolution for this policy.
|
||||
query: SELECT 1 FROM osquery_info WHERE start_time < 0;
|
||||
agent_options:
|
||||
path: ./agent-options.yml
|
||||
org_settings:
|
||||
path: ./org-settings.yml
|
181
pkg/spec/testdata/global_config_no_paths.yml
vendored
Normal file
181
pkg/spec/testdata/global_config_no_paths.yml
vendored
Normal file
@ -0,0 +1,181 @@
|
||||
# Test config
|
||||
controls: # Controls added to "No team"
|
||||
macos_settings:
|
||||
custom_settings:
|
||||
- path: ./lib/macos-password.mobileconfig
|
||||
windows_settings:
|
||||
custom_settings:
|
||||
- path: ./lib/windows-screenlock.xml
|
||||
scripts:
|
||||
- path: ./lib/collect-fleetd-logs.sh
|
||||
enable_disk_encryption: true
|
||||
macos_migration:
|
||||
enable: false
|
||||
mode: ""
|
||||
webhook_url: ""
|
||||
macos_setup:
|
||||
bootstrap_package: null
|
||||
enable_end_user_authentication: false
|
||||
macos_setup_assistant: null
|
||||
macos_updates:
|
||||
deadline: null
|
||||
minimum_version: null
|
||||
windows_enabled_and_configured: true
|
||||
windows_updates:
|
||||
deadline_days: null
|
||||
grace_period_days: null
|
||||
queries:
|
||||
- name: Scheduled query stats
|
||||
description: Collect osquery performance stats directly from osquery
|
||||
query: SELECT *,
|
||||
(SELECT value from osquery_flags where name = 'pack_delimiter') AS delimiter
|
||||
FROM osquery_schedule;
|
||||
interval: 0
|
||||
platform: darwin,linux,windows
|
||||
min_osquery_version: all
|
||||
observer_can_run: false
|
||||
automations_enabled: false
|
||||
logging: snapshot
|
||||
- name: orbit_info
|
||||
query: SELECT * from orbit_info;
|
||||
interval: 0
|
||||
platform: darwin,linux,windows
|
||||
min_osquery_version: all
|
||||
observer_can_run: false
|
||||
automations_enabled: true
|
||||
logging: snapshot
|
||||
- name: osquery_info
|
||||
query: SELECT * from osquery_info;
|
||||
interval: 604800 # 1 week
|
||||
platform: darwin,linux,windows,chrome
|
||||
min_osquery_version: all
|
||||
observer_can_run: false
|
||||
automations_enabled: true
|
||||
logging: snapshot
|
||||
policies:
|
||||
- name: 😊 Failing policy
|
||||
platform: linux
|
||||
description: This policy should always fail.
|
||||
resolution: There is no resolution for this policy.
|
||||
query: SELECT 1 FROM osquery_info WHERE start_time < 0;
|
||||
- name: Passing policy
|
||||
platform: linux,windows,darwin,chrome
|
||||
description: This policy should always pass.
|
||||
resolution: There is no resolution for this policy.
|
||||
query: SELECT 1;
|
||||
- name: No root logins (macOS, Linux)
|
||||
platform: linux,darwin
|
||||
query: SELECT 1 WHERE NOT EXISTS (SELECT * FROM last
|
||||
WHERE username = "root"
|
||||
AND time > (( SELECT unix_time FROM time ) - 3600 ))
|
||||
critical: true
|
||||
- name: 🔥 Failing policy
|
||||
platform: linux
|
||||
description: This policy should always fail.
|
||||
resolution: There is no resolution for this policy.
|
||||
query: SELECT 1 FROM osquery_info WHERE start_time < 0;
|
||||
- name: 😊😊 Failing policy
|
||||
platform: linux
|
||||
description: This policy should always fail.
|
||||
resolution: There is no resolution for this policy.
|
||||
query: SELECT 1 FROM osquery_info WHERE start_time < 0;
|
||||
agent_options:
|
||||
command_line_flags:
|
||||
distributed_denylist_duration: 0
|
||||
config:
|
||||
decorators:
|
||||
load:
|
||||
- SELECT uuid AS host_uuid FROM system_info;
|
||||
- SELECT hostname AS hostname FROM system_info;
|
||||
options:
|
||||
disable_distributed: false
|
||||
distributed_interval: 10
|
||||
distributed_plugin: tls
|
||||
distributed_tls_max_attempts: 3
|
||||
logger_tls_endpoint: /api/v1/osquery/log
|
||||
pack_delimiter: /
|
||||
org_settings:
|
||||
server_settings:
|
||||
debug_host_ids:
|
||||
- 10728
|
||||
deferred_save_host: false
|
||||
enable_analytics: true
|
||||
live_query_disabled: false
|
||||
query_reports_disabled: false
|
||||
scripts_disabled: false
|
||||
server_url: https://fleet.example.com
|
||||
org_info:
|
||||
contact_url: https://fleetdm.com/company/contact
|
||||
org_logo_url: ""
|
||||
org_logo_url_light_background: ""
|
||||
org_name: Fleet Device Management
|
||||
smtp_settings:
|
||||
authentication_method: authmethod_plain
|
||||
authentication_type: authtype_username_password
|
||||
configured: false
|
||||
domain: ""
|
||||
enable_smtp: false
|
||||
enable_ssl_tls: true
|
||||
enable_start_tls: true
|
||||
password: ""
|
||||
port: 587
|
||||
sender_address: ""
|
||||
server: ""
|
||||
user_name: ""
|
||||
verify_ssl_certs: true
|
||||
sso_settings:
|
||||
enable_jit_provisioning: false
|
||||
enable_jit_role_sync: false
|
||||
enable_sso: true
|
||||
enable_sso_idp_login: false
|
||||
entity_id: https://saml.example.com/entityid
|
||||
idp_image_url: ""
|
||||
idp_name: MockSAML
|
||||
issuer_uri: ""
|
||||
metadata: ""
|
||||
metadata_url: https://mocksaml.com/api/saml/metadata
|
||||
integrations:
|
||||
jira:
|
||||
- api_token: JIRA_TOKEN
|
||||
enable_failing_policies: true
|
||||
enable_software_vulnerabilities: false
|
||||
project_key: JIR
|
||||
url: https://fleetdm.atlassian.net
|
||||
username: reed@fleetdm.com
|
||||
zendesk: []
|
||||
mdm:
|
||||
apple_bm_default_team: ""
|
||||
end_user_authentication:
|
||||
entity_id: ""
|
||||
idp_name: ""
|
||||
issuer_uri: ""
|
||||
metadata: ""
|
||||
metadata_url: ""
|
||||
webhook_settings:
|
||||
failing_policies_webhook:
|
||||
destination_url: https://host.docker.internal:8080/bozo
|
||||
enable_failing_policies_webhook: false
|
||||
host_batch_size: 0
|
||||
policy_ids: []
|
||||
host_status_webhook:
|
||||
days_count: 0
|
||||
destination_url: ""
|
||||
enable_host_status_webhook: false
|
||||
host_percentage: 0
|
||||
interval: 24h0m0s
|
||||
vulnerabilities_webhook:
|
||||
destination_url: ""
|
||||
enable_vulnerabilities_webhook: false
|
||||
host_batch_size: 0
|
||||
fleet_desktop: # Applies to Fleet Premium only
|
||||
transparency_url: https://fleetdm.com/transparency
|
||||
host_expiry_settings: # Applies to all teams
|
||||
host_expiry_enabled: false
|
||||
features: # Features added to all teams
|
||||
enable_host_users: true
|
||||
enable_software_inventory: true
|
||||
vulnerability_settings:
|
||||
databases_path: ""
|
||||
secrets: # These secrets are used to enroll hosts to the "All teams" team
|
||||
- secret: SampleSecret123
|
||||
- secret: ABC
|
84
pkg/spec/testdata/org-settings.yml
vendored
Normal file
84
pkg/spec/testdata/org-settings.yml
vendored
Normal file
@ -0,0 +1,84 @@
|
||||
server_settings:
|
||||
debug_host_ids:
|
||||
- 10728
|
||||
deferred_save_host: false
|
||||
enable_analytics: true
|
||||
live_query_disabled: false
|
||||
query_reports_disabled: false
|
||||
scripts_disabled: false
|
||||
server_url: https://fleet.example.com
|
||||
org_info:
|
||||
contact_url: https://fleetdm.com/company/contact
|
||||
org_logo_url: ""
|
||||
org_logo_url_light_background: ""
|
||||
org_name: Fleet Device Management
|
||||
smtp_settings:
|
||||
authentication_method: authmethod_plain
|
||||
authentication_type: authtype_username_password
|
||||
configured: false
|
||||
domain: ""
|
||||
enable_smtp: false
|
||||
enable_ssl_tls: true
|
||||
enable_start_tls: true
|
||||
password: ""
|
||||
port: 587
|
||||
sender_address: ""
|
||||
server: ""
|
||||
user_name: ""
|
||||
verify_ssl_certs: true
|
||||
sso_settings:
|
||||
enable_jit_provisioning: false
|
||||
enable_jit_role_sync: false
|
||||
enable_sso: true
|
||||
enable_sso_idp_login: false
|
||||
entity_id: https://saml.example.com/entityid
|
||||
idp_image_url: ""
|
||||
idp_name: MockSAML
|
||||
issuer_uri: ""
|
||||
metadata: ""
|
||||
metadata_url: https://mocksaml.com/api/saml/metadata
|
||||
integrations:
|
||||
jira:
|
||||
- api_token: JIRA_TOKEN
|
||||
enable_failing_policies: true
|
||||
enable_software_vulnerabilities: false
|
||||
project_key: JIR
|
||||
url: https://fleetdm.atlassian.net
|
||||
username: reed@fleetdm.com
|
||||
zendesk: []
|
||||
mdm:
|
||||
apple_bm_default_team: ""
|
||||
end_user_authentication:
|
||||
entity_id: ""
|
||||
idp_name: ""
|
||||
issuer_uri: ""
|
||||
metadata: ""
|
||||
metadata_url: ""
|
||||
webhook_settings:
|
||||
failing_policies_webhook:
|
||||
destination_url: https://host.docker.internal:8080/bozo
|
||||
enable_failing_policies_webhook: false
|
||||
host_batch_size: 0
|
||||
policy_ids: []
|
||||
host_status_webhook:
|
||||
days_count: 0
|
||||
destination_url: ""
|
||||
enable_host_status_webhook: false
|
||||
host_percentage: 0
|
||||
interval: 24h0m0s
|
||||
vulnerabilities_webhook:
|
||||
destination_url: ""
|
||||
enable_vulnerabilities_webhook: false
|
||||
host_batch_size: 0
|
||||
fleet_desktop: # Applies to Fleet Premium only
|
||||
transparency_url: https://fleetdm.com/transparency
|
||||
host_expiry_settings: # Applies to all teams
|
||||
host_expiry_enabled: false
|
||||
features: # Features added to all teams
|
||||
enable_host_users: true
|
||||
enable_software_inventory: true
|
||||
vulnerability_settings:
|
||||
databases_path: ""
|
||||
secrets: # These secrets are used to enroll hosts to the "All teams" team
|
||||
- secret: SampleSecret123
|
||||
- secret: ABC
|
14
pkg/spec/testdata/team-settings.yml
vendored
Normal file
14
pkg/spec/testdata/team-settings.yml
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
secrets:
|
||||
- secret: "SampleSecret123"
|
||||
- secret: "ABC"
|
||||
webhook_settings:
|
||||
failing_policies_webhook:
|
||||
enable_failing_policies_webhook: true
|
||||
destination_url: https://example.tines.com/webhook
|
||||
policy_ids: [1, 2, 3, 4, 5, 6 ,7, 8, 9]
|
||||
features:
|
||||
enable_host_users: true
|
||||
enable_software_inventory: true
|
||||
host_expiry_settings:
|
||||
host_expiry_enabled: true
|
||||
host_expiry_window: 30
|
27
pkg/spec/testdata/team_config.yml
vendored
Normal file
27
pkg/spec/testdata/team_config.yml
vendored
Normal file
@ -0,0 +1,27 @@
|
||||
name: Team1
|
||||
team_settings:
|
||||
path: ./team-settings.yml
|
||||
agent_options:
|
||||
path: ./agent-options.yml
|
||||
controls:
|
||||
path: ./controls.yml
|
||||
queries:
|
||||
- path: ./top.queries.yml
|
||||
- path: ./empty.yml
|
||||
- name: osquery_info
|
||||
query: SELECT * from osquery_info;
|
||||
interval: 604800 # 1 week
|
||||
platform: darwin,linux,windows,chrome
|
||||
min_osquery_version: all
|
||||
observer_can_run: false
|
||||
automations_enabled: true
|
||||
logging: snapshot
|
||||
policies:
|
||||
- path: ./top.policies.yml
|
||||
- path: ./top.policies2.yml
|
||||
- path: ./empty.yml
|
||||
- name: 😊😊 Failing policy
|
||||
platform: linux
|
||||
description: This policy should always fail.
|
||||
resolution: There is no resolution for this policy.
|
||||
query: SELECT 1 FROM osquery_info WHERE start_time < 0;
|
111
pkg/spec/testdata/team_config_no_paths.yml
vendored
Normal file
111
pkg/spec/testdata/team_config_no_paths.yml
vendored
Normal file
@ -0,0 +1,111 @@
|
||||
name: Team1
|
||||
team_settings:
|
||||
secrets:
|
||||
- secret: "SampleSecret123"
|
||||
- secret: "ABC"
|
||||
webhook_settings:
|
||||
failing_policies_webhook:
|
||||
enable_failing_policies_webhook: true
|
||||
destination_url: https://example.tines.com/webhook
|
||||
policy_ids: [1, 2, 3, 4, 5, 6 ,7, 8, 9]
|
||||
features:
|
||||
enable_host_users: true
|
||||
enable_software_inventory: true
|
||||
host_expiry_settings:
|
||||
host_expiry_enabled: true
|
||||
host_expiry_window: 30
|
||||
agent_options:
|
||||
command_line_flags:
|
||||
distributed_denylist_duration: 0
|
||||
config:
|
||||
decorators:
|
||||
load:
|
||||
- SELECT uuid AS host_uuid FROM system_info;
|
||||
- SELECT hostname AS hostname FROM system_info;
|
||||
options:
|
||||
disable_distributed: false
|
||||
distributed_interval: 10
|
||||
distributed_plugin: tls
|
||||
distributed_tls_max_attempts: 3
|
||||
logger_tls_endpoint: /api/v1/osquery/log
|
||||
pack_delimiter: /
|
||||
controls:
|
||||
macos_settings:
|
||||
custom_settings:
|
||||
- path: ./lib/macos-password.mobileconfig
|
||||
windows_settings:
|
||||
custom_settings:
|
||||
- path: ./lib/windows-screenlock.xml
|
||||
scripts:
|
||||
- path: ./lib/collect-fleetd-logs.sh
|
||||
enable_disk_encryption: true
|
||||
macos_setup:
|
||||
bootstrap_package: null
|
||||
enable_end_user_authentication: false
|
||||
macos_setup_assistant: null
|
||||
macos_updates:
|
||||
deadline: null
|
||||
minimum_version: null
|
||||
windows_updates:
|
||||
deadline_days: null
|
||||
grace_period_days: null
|
||||
macos_migration:
|
||||
enable: false
|
||||
mode: ""
|
||||
webhook_url: ""
|
||||
windows_enabled_and_configured: true
|
||||
queries:
|
||||
- name: Scheduled query stats
|
||||
description: Collect osquery performance stats directly from osquery
|
||||
query: SELECT *,
|
||||
(SELECT value from osquery_flags where name = 'pack_delimiter') AS delimiter
|
||||
FROM osquery_schedule;
|
||||
interval: 0
|
||||
platform: darwin,linux,windows
|
||||
min_osquery_version: all
|
||||
observer_can_run: false
|
||||
automations_enabled: false
|
||||
logging: snapshot
|
||||
- name: orbit_info
|
||||
query: SELECT * from orbit_info;
|
||||
interval: 0
|
||||
platform: darwin,linux,windows
|
||||
min_osquery_version: all
|
||||
observer_can_run: false
|
||||
automations_enabled: true
|
||||
logging: snapshot
|
||||
- name: osquery_info
|
||||
query: SELECT * from osquery_info;
|
||||
interval: 604800 # 1 week
|
||||
platform: darwin,linux,windows,chrome
|
||||
min_osquery_version: all
|
||||
observer_can_run: false
|
||||
automations_enabled: true
|
||||
logging: snapshot
|
||||
policies:
|
||||
- name: 😊 Failing policy
|
||||
platform: linux
|
||||
description: This policy should always fail.
|
||||
resolution: There is no resolution for this policy.
|
||||
query: SELECT 1 FROM osquery_info WHERE start_time < 0;
|
||||
- name: Passing policy
|
||||
platform: linux,windows,darwin,chrome
|
||||
description: This policy should always pass.
|
||||
resolution: There is no resolution for this policy.
|
||||
query: SELECT 1;
|
||||
- name: No root logins (macOS, Linux)
|
||||
platform: linux,darwin
|
||||
query: SELECT 1 WHERE NOT EXISTS (SELECT * FROM last
|
||||
WHERE username = "root"
|
||||
AND time > (( SELECT unix_time FROM time ) - 3600 ))
|
||||
critical: true
|
||||
- name: 🔥 Failing policy
|
||||
platform: linux
|
||||
description: This policy should always fail.
|
||||
resolution: There is no resolution for this policy.
|
||||
query: SELECT 1 FROM osquery_info WHERE start_time < 0;
|
||||
- name: 😊😊 Failing policy
|
||||
platform: linux
|
||||
description: This policy should always fail.
|
||||
resolution: There is no resolution for this policy.
|
||||
query: SELECT 1 FROM osquery_info WHERE start_time < 0;
|
16
pkg/spec/testdata/top.policies.yml
vendored
Normal file
16
pkg/spec/testdata/top.policies.yml
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
- name: 😊 Failing policy
|
||||
platform: linux
|
||||
description: This policy should always fail.
|
||||
resolution: There is no resolution for this policy.
|
||||
query: SELECT 1 FROM osquery_info WHERE start_time < 0;
|
||||
- name: Passing policy
|
||||
platform: linux,windows,darwin,chrome
|
||||
description: This policy should always pass.
|
||||
resolution: There is no resolution for this policy.
|
||||
query: SELECT 1;
|
||||
- name: No root logins (macOS, Linux)
|
||||
platform: linux,darwin
|
||||
query: SELECT 1 WHERE NOT EXISTS (SELECT * FROM last
|
||||
WHERE username = "root"
|
||||
AND time > (( SELECT unix_time FROM time ) - 3600 ))
|
||||
critical: true
|
5
pkg/spec/testdata/top.policies2.yml
vendored
Normal file
5
pkg/spec/testdata/top.policies2.yml
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
- name: 🔥 Failing policy
|
||||
platform: linux
|
||||
description: This policy should always fail.
|
||||
resolution: There is no resolution for this policy.
|
||||
query: SELECT 1 FROM osquery_info WHERE start_time < 0;
|
19
pkg/spec/testdata/top.queries.yml
vendored
Normal file
19
pkg/spec/testdata/top.queries.yml
vendored
Normal file
@ -0,0 +1,19 @@
|
||||
- name: Scheduled query stats
|
||||
description: Collect osquery performance stats directly from osquery
|
||||
query: SELECT *,
|
||||
(SELECT value from osquery_flags where name = 'pack_delimiter') AS delimiter
|
||||
FROM osquery_schedule;
|
||||
interval: 0
|
||||
platform: darwin,linux,windows
|
||||
min_osquery_version: all
|
||||
observer_can_run: false
|
||||
automations_enabled: false
|
||||
logging: snapshot
|
||||
- name: orbit_info
|
||||
query: SELECT * from orbit_info;
|
||||
interval: 0
|
||||
platform: darwin,linux,windows
|
||||
min_osquery_version: all
|
||||
observer_can_run: false
|
||||
automations_enabled: true
|
||||
logging: snapshot
|
@ -599,7 +599,6 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs
|
||||
checksum
|
||||
) VALUES ( ?, ?, ?, ?, ?, (SELECT IFNULL(MIN(id), NULL) FROM teams WHERE name = ?), ?, ?, %s)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name = VALUES(name),
|
||||
query = VALUES(query),
|
||||
description = VALUES(description),
|
||||
author_id = VALUES(author_id),
|
||||
@ -610,12 +609,6 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs
|
||||
)
|
||||
for _, spec := range specs {
|
||||
|
||||
// Validate that the team is not being changed
|
||||
err := validatePolicyTeamChange(ctx, ds, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
res, err := tx.ExecContext(ctx,
|
||||
query, spec.Name, spec.Query, spec.Description, authorID, spec.Resolution, spec.Team, spec.Platform, spec.Critical,
|
||||
)
|
||||
@ -637,44 +630,6 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs
|
||||
})
|
||||
}
|
||||
|
||||
// Changing the team of a policy is not allowed, so check if the policy exists
|
||||
// and return an error if the team is changing
|
||||
func validatePolicyTeamChange(ctx context.Context, ds *Datastore, spec *fleet.PolicySpec) error {
|
||||
policy, err := ds.PolicyByName(ctx, spec.Name)
|
||||
if err != nil {
|
||||
if !fleet.IsNotFound(err) {
|
||||
return ctxerr.Wrap(ctx, err, "Error fetching policy by name")
|
||||
}
|
||||
// If no rows found there is no policy to validate against
|
||||
return nil
|
||||
}
|
||||
|
||||
// If the policy exists
|
||||
if policy != nil {
|
||||
// Check if the policy is global
|
||||
if policy.TeamID == nil {
|
||||
if spec.Team != "" {
|
||||
return ctxerr.Wrap(ctx, &fleet.BadRequestError{
|
||||
Message: fmt.Sprintf("cannot change the team of an existing global policy"),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// If it's not global, fetch the team name and compare
|
||||
team, err := ds.Team(ctx, *policy.TeamID)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "Error fetching team by ID")
|
||||
}
|
||||
|
||||
if spec.Team != team.Name {
|
||||
return ctxerr.Wrap(ctx, &fleet.BadRequestError{
|
||||
Message: fmt.Sprintf("cannot change the team of an existing policy"),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func amountPoliciesDB(ctx context.Context, db sqlx.QueryerContext) (int, error) {
|
||||
var amount int
|
||||
err := sqlx.GetContext(ctx, db, &amount, `SELECT count(*) FROM policies`)
|
||||
|
@ -1369,14 +1369,16 @@ func testApplyPolicySpec(t *testing.T, ds *Datastore) {
|
||||
assert.Equal(t, "some other resolution updated", *teamPolicies[0].Resolution)
|
||||
assert.Equal(t, "windows", teamPolicies[0].Platform)
|
||||
|
||||
// Test error when modifying team on existing policy
|
||||
require.Error(t, ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{
|
||||
// Creating the same policy for a different team is allowed.
|
||||
require.NoError(
|
||||
t, ds.ApplyPolicySpecs(
|
||||
ctx, user1.ID, []*fleet.PolicySpec{
|
||||
{
|
||||
Name: "query1",
|
||||
Query: "select 1 from updated again;",
|
||||
Description: "query1 desc updated again",
|
||||
Resolution: "some resolution updated again",
|
||||
Team: "team1", // Modifying teams on existing policies is not allowed
|
||||
Team: "team1",
|
||||
Platform: "",
|
||||
},
|
||||
}))
|
||||
|
@ -228,14 +228,18 @@ func (p PolicySpec) Verify() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// return first duplicate name of policies or empty string if no duplicates found
|
||||
// FirstDuplicatePolicySpecName returns first duplicate name of policies (in a team) or empty string if no duplicates found
|
||||
func FirstDuplicatePolicySpecName(specs []*PolicySpec) string {
|
||||
names := make(map[string]struct{})
|
||||
teams := make(map[string]map[string]struct{})
|
||||
for _, spec := range specs {
|
||||
if _, ok := names[spec.Name]; ok {
|
||||
if team, ok := teams[spec.Team]; ok {
|
||||
if _, ok = team[spec.Name]; ok {
|
||||
return spec.Name
|
||||
}
|
||||
names[spec.Name] = struct{}{}
|
||||
team[spec.Name] = struct{}{}
|
||||
} else {
|
||||
teams[spec.Team] = map[string]struct{}{spec.Name: {}}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
@ -881,7 +881,10 @@ type Service interface {
|
||||
|
||||
// BatchSetMDMProfiles replaces the custom Windows/macOS profiles for a specified
|
||||
// team or for hosts with no team.
|
||||
BatchSetMDMProfiles(ctx context.Context, teamID *uint, teamName *string, profiles []MDMProfileBatchPayload, dryRun bool, skipBulkPending bool) error
|
||||
BatchSetMDMProfiles(
|
||||
ctx context.Context, teamID *uint, teamName *string, profiles []MDMProfileBatchPayload, dryRun bool, skipBulkPending bool,
|
||||
assumeEnabled bool,
|
||||
) error
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Common MDM
|
||||
|
@ -322,7 +322,7 @@ func (c *Client) ApplyGroup(
|
||||
baseDir string,
|
||||
logf func(format string, args ...interface{}),
|
||||
opts fleet.ApplySpecOptions,
|
||||
) error {
|
||||
) (map[string]uint, error) {
|
||||
logfn := func(format string, args ...interface{}) {
|
||||
if logf != nil {
|
||||
logf(format, args...)
|
||||
@ -335,7 +335,7 @@ func (c *Client) ApplyGroup(
|
||||
logfn("[!] ignoring queries, dry run mode only supported for 'config' and 'team' specs\n")
|
||||
} else {
|
||||
if err := c.ApplyQueries(specs.Queries); err != nil {
|
||||
return fmt.Errorf("applying queries: %w", err)
|
||||
return nil, fmt.Errorf("applying queries: %w", err)
|
||||
}
|
||||
logfn("[+] applied %d queries\n", len(specs.Queries))
|
||||
}
|
||||
@ -346,7 +346,7 @@ func (c *Client) ApplyGroup(
|
||||
logfn("[!] ignoring labels, dry run mode only supported for 'config' and 'team' specs\n")
|
||||
} else {
|
||||
if err := c.ApplyLabels(specs.Labels); err != nil {
|
||||
return fmt.Errorf("applying labels: %w", err)
|
||||
return nil, fmt.Errorf("applying labels: %w", err)
|
||||
}
|
||||
logfn("[+] applied %d labels\n", len(specs.Labels))
|
||||
}
|
||||
@ -358,7 +358,9 @@ func (c *Client) ApplyGroup(
|
||||
} else {
|
||||
// Policy names must be unique, return error if duplicate policy names are found
|
||||
if policyName := fleet.FirstDuplicatePolicySpecName(specs.Policies); policyName != "" {
|
||||
return fmt.Errorf("applying policies: policy names must be globally unique. Please correct policy %q and try again.", policyName)
|
||||
return nil, fmt.Errorf(
|
||||
"applying policies: policy names must be unique. Please correct policy %q and try again.", policyName,
|
||||
)
|
||||
}
|
||||
|
||||
// If set, override the team in all the policies.
|
||||
@ -368,7 +370,7 @@ func (c *Client) ApplyGroup(
|
||||
}
|
||||
}
|
||||
if err := c.ApplyPolicies(specs.Policies); err != nil {
|
||||
return fmt.Errorf("applying policies: %w", err)
|
||||
return nil, fmt.Errorf("applying policies: %w", err)
|
||||
}
|
||||
logfn("[+] applied %d policies\n", len(specs.Policies))
|
||||
}
|
||||
@ -379,7 +381,7 @@ func (c *Client) ApplyGroup(
|
||||
logfn("[!] ignoring packs, dry run mode only supported for 'config' and 'team' specs\n")
|
||||
} else {
|
||||
if err := c.ApplyPacks(specs.Packs); err != nil {
|
||||
return fmt.Errorf("applying packs: %w", err)
|
||||
return nil, fmt.Errorf("applying packs: %w", err)
|
||||
}
|
||||
logfn("[+] applied %d packs\n", len(specs.Packs))
|
||||
}
|
||||
@ -400,33 +402,44 @@ func (c *Client) ApplyGroup(
|
||||
if (windowsCustomSettings != nil && macosCustomSettings != nil) || len(allCustomSettings) > 0 {
|
||||
fileContents, err := getProfilesContents(baseDir, allCustomSettings)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
if err := c.ApplyNoTeamProfiles(fileContents, opts); err != nil {
|
||||
return fmt.Errorf("applying custom settings: %w", err)
|
||||
// Figure out if MDM should be enabled.
|
||||
assumeEnabled := false
|
||||
// This cast is safe because we've already checked AppConfig when extracting custom settings
|
||||
mdmConfigMap, ok := specs.AppConfig.(map[string]interface{})["mdm"].(map[string]interface{})
|
||||
if ok {
|
||||
mdmEnabled, ok := mdmConfigMap["windows_enabled_and_configured"]
|
||||
if ok {
|
||||
assumeEnabled, ok = mdmEnabled.(bool)
|
||||
assumeEnabled = ok && assumeEnabled
|
||||
}
|
||||
}
|
||||
if err := c.ApplyNoTeamProfiles(fileContents, opts, assumeEnabled); err != nil {
|
||||
return nil, fmt.Errorf("applying custom settings: %w", err)
|
||||
}
|
||||
}
|
||||
if macosSetup := extractAppCfgMacOSSetup(specs.AppConfig); macosSetup != nil {
|
||||
if macosSetup.BootstrapPackage.Value != "" {
|
||||
pkg, err := c.ValidateBootstrapPackageFromURL(macosSetup.BootstrapPackage.Value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("applying fleet config: %w", err)
|
||||
return nil, fmt.Errorf("applying fleet config: %w", err)
|
||||
}
|
||||
|
||||
if !opts.DryRun {
|
||||
if err := c.EnsureBootstrapPackage(pkg, uint(0)); err != nil {
|
||||
return fmt.Errorf("applying fleet config: %w", err)
|
||||
return nil, fmt.Errorf("applying fleet config: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if macosSetup.MacOSSetupAssistant.Value != "" {
|
||||
content, err := c.validateMacOSSetupAssistant(resolveApplyRelativePath(baseDir, macosSetup.MacOSSetupAssistant.Value))
|
||||
if err != nil {
|
||||
return fmt.Errorf("applying fleet config: %w", err)
|
||||
return nil, fmt.Errorf("applying fleet config: %w", err)
|
||||
}
|
||||
if !opts.DryRun {
|
||||
if err := c.uploadMacOSSetupAssistant(content, nil, macosSetup.MacOSSetupAssistant.Value); err != nil {
|
||||
return fmt.Errorf("applying fleet config: %w", err)
|
||||
return nil, fmt.Errorf("applying fleet config: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -437,7 +450,7 @@ func (c *Client) ApplyGroup(
|
||||
for i, f := range files {
|
||||
b, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
return fmt.Errorf("applying fleet config: %w", err)
|
||||
return nil, fmt.Errorf("applying fleet config: %w", err)
|
||||
}
|
||||
scriptPayloads[i] = fleet.ScriptPayload{
|
||||
ScriptContents: b,
|
||||
@ -445,11 +458,11 @@ func (c *Client) ApplyGroup(
|
||||
}
|
||||
}
|
||||
if err := c.ApplyNoTeamScripts(scriptPayloads, opts); err != nil {
|
||||
return fmt.Errorf("applying custom settings: %w", err)
|
||||
return nil, fmt.Errorf("applying custom settings: %w", err)
|
||||
}
|
||||
}
|
||||
if err := c.ApplyAppConfig(specs.AppConfig, opts); err != nil {
|
||||
return fmt.Errorf("applying fleet config: %w", err)
|
||||
return nil, fmt.Errorf("applying fleet config: %w", err)
|
||||
}
|
||||
if opts.DryRun {
|
||||
logfn("[+] would've applied fleet config\n")
|
||||
@ -460,15 +473,16 @@ func (c *Client) ApplyGroup(
|
||||
|
||||
if specs.EnrollSecret != nil {
|
||||
if opts.DryRun {
|
||||
logfn("[!] ignoring enroll secrets, dry run mode only supported for 'config' and 'team' specs\n")
|
||||
logfn("[+] would've applied enroll secrets\n")
|
||||
} else {
|
||||
if err := c.ApplyEnrollSecretSpec(specs.EnrollSecret); err != nil {
|
||||
return fmt.Errorf("applying enroll secrets: %w", err)
|
||||
return nil, fmt.Errorf("applying enroll secrets: %w", err)
|
||||
}
|
||||
logfn("[+] applied enroll secrets\n")
|
||||
}
|
||||
}
|
||||
|
||||
var teamIDsByName map[string]uint
|
||||
if len(specs.Teams) > 0 {
|
||||
// extract the teams' custom settings and resolve the files immediately, so
|
||||
// that any non-existing file error is found before applying the specs.
|
||||
@ -478,7 +492,7 @@ func (c *Client) ApplyGroup(
|
||||
for k, paths := range tmMDMSettings {
|
||||
fileContents, err := getProfilesContents(baseDir, paths)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
tmFileContents[k] = fileContents
|
||||
}
|
||||
@ -490,14 +504,14 @@ func (c *Client) ApplyGroup(
|
||||
if setup.BootstrapPackage.Value != "" {
|
||||
bp, err := c.ValidateBootstrapPackageFromURL(setup.BootstrapPackage.Value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("applying teams: %w", err)
|
||||
return nil, fmt.Errorf("applying teams: %w", err)
|
||||
}
|
||||
tmBootstrapPackages[k] = bp
|
||||
}
|
||||
if setup.MacOSSetupAssistant.Value != "" {
|
||||
b, err := c.validateMacOSSetupAssistant(resolveApplyRelativePath(baseDir, setup.MacOSSetupAssistant.Value))
|
||||
if err != nil {
|
||||
return fmt.Errorf("applying teams: %w", err)
|
||||
return nil, fmt.Errorf("applying teams: %w", err)
|
||||
}
|
||||
tmMacSetupAssistants[k] = b
|
||||
}
|
||||
@ -511,7 +525,7 @@ func (c *Client) ApplyGroup(
|
||||
for i, f := range files {
|
||||
b, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
return fmt.Errorf("applying fleet config: %w", err)
|
||||
return nil, fmt.Errorf("applying fleet config: %w", err)
|
||||
}
|
||||
scriptPayloads[i] = fleet.ScriptPayload{
|
||||
ScriptContents: b,
|
||||
@ -523,15 +537,22 @@ func (c *Client) ApplyGroup(
|
||||
|
||||
// Next, apply the teams specs before saving the profiles, so that any
|
||||
// non-existing team gets created.
|
||||
teamIDsByName, err := c.ApplyTeams(specs.Teams, opts)
|
||||
var err error
|
||||
teamIDsByName, err = c.ApplyTeams(specs.Teams, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("applying teams: %w", err)
|
||||
return nil, fmt.Errorf("applying teams: %w", err)
|
||||
}
|
||||
|
||||
if len(tmFileContents) > 0 {
|
||||
for tmName, profs := range tmFileContents {
|
||||
teamID, ok := teamIDsByName[tmName]
|
||||
if opts.DryRun && (teamID == 0 || !ok) {
|
||||
logfn("[+] would've applied MDM profiles for new team %s\n", tmName)
|
||||
} else {
|
||||
logfn("[+] applying MDM profiles for team %s\n", tmName)
|
||||
if err := c.ApplyTeamProfiles(tmName, profs, opts); err != nil {
|
||||
return fmt.Errorf("applying custom settings for team %q: %w", tmName, err)
|
||||
return nil, fmt.Errorf("applying custom settings for team %q: %w", tmName, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -539,12 +560,12 @@ func (c *Client) ApplyGroup(
|
||||
for tmName, tmID := range teamIDsByName {
|
||||
if bp, ok := tmBootstrapPackages[tmName]; ok {
|
||||
if err := c.EnsureBootstrapPackage(bp, tmID); err != nil {
|
||||
return fmt.Errorf("uploading bootstrap package for team %q: %w", tmName, err)
|
||||
return nil, fmt.Errorf("uploading bootstrap package for team %q: %w", tmName, err)
|
||||
}
|
||||
}
|
||||
if b, ok := tmMacSetupAssistants[tmName]; ok {
|
||||
if err := c.uploadMacOSSetupAssistant(b, &tmID, tmMacSetup[tmName].MacOSSetupAssistant.Value); err != nil {
|
||||
return fmt.Errorf("uploading macOS setup assistant for team %q: %w", tmName, err)
|
||||
return nil, fmt.Errorf("uploading macOS setup assistant for team %q: %w", tmName, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -552,7 +573,7 @@ func (c *Client) ApplyGroup(
|
||||
if len(tmScriptsPayloads) > 0 {
|
||||
for tmName, scripts := range tmScriptsPayloads {
|
||||
if err := c.ApplyTeamScripts(tmName, scripts, opts); err != nil {
|
||||
return fmt.Errorf("applying scripts for team %q: %w", tmName, err)
|
||||
return nil, fmt.Errorf("applying scripts for team %q: %w", tmName, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -568,12 +589,12 @@ func (c *Client) ApplyGroup(
|
||||
logfn("[!] ignoring user roles, dry run mode only supported for 'config' and 'team' specs\n")
|
||||
} else {
|
||||
if err := c.ApplyUsersRoleSecretSpec(specs.UsersRoles); err != nil {
|
||||
return fmt.Errorf("applying user roles: %w", err)
|
||||
return nil, fmt.Errorf("applying user roles: %w", err)
|
||||
}
|
||||
logfn("[+] applied user roles\n")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return teamIDsByName, nil
|
||||
}
|
||||
|
||||
func extractAppCfgMacOSSetup(appCfg any) *fleet.MacOSSetup {
|
||||
@ -832,3 +853,179 @@ func extractTmSpecsMacOSSetup(tmSpecs []json.RawMessage) map[string]*fleet.MacOS
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// DoGitOps applies the GitOps config to Fleet.
|
||||
func (c *Client) DoGitOps(
|
||||
ctx context.Context,
|
||||
config *spec.GitOps,
|
||||
baseDir string,
|
||||
logf func(format string, args ...interface{}),
|
||||
dryRun bool,
|
||||
) error {
|
||||
var err error
|
||||
logFn := func(format string, args ...interface{}) {
|
||||
if logf != nil {
|
||||
logf(format, args...)
|
||||
}
|
||||
}
|
||||
group := spec.Group{}
|
||||
scripts := make([]interface{}, len(config.Controls.Scripts))
|
||||
for i, script := range config.Controls.Scripts {
|
||||
scripts[i] = *script.Path
|
||||
}
|
||||
var mdmAppConfig map[string]interface{}
|
||||
var team map[string]interface{}
|
||||
if config.TeamName == nil {
|
||||
group.AppConfig = config.OrgSettings
|
||||
group.EnrollSecret = &fleet.EnrollSecretSpec{Secrets: config.OrgSettings["secrets"].([]*fleet.EnrollSecret)}
|
||||
group.AppConfig.(map[string]interface{})["agent_options"] = config.AgentOptions
|
||||
delete(config.OrgSettings, "secrets") // secrets are applied separately in Client.ApplyGroup
|
||||
if _, ok := group.AppConfig.(map[string]interface{})["mdm"]; !ok {
|
||||
group.AppConfig.(map[string]interface{})["mdm"] = map[string]interface{}{}
|
||||
}
|
||||
mdmAppConfig = group.AppConfig.(map[string]interface{})["mdm"].(map[string]interface{})
|
||||
mdmAppConfig["macos_migration"] = config.Controls.MacOSMigration
|
||||
mdmAppConfig["windows_enabled_and_configured"] = config.Controls.WindowsEnabledAndConfigured
|
||||
group.AppConfig.(map[string]interface{})["scripts"] = scripts
|
||||
} else {
|
||||
team = make(map[string]interface{})
|
||||
team["name"] = *config.TeamName
|
||||
team["agent_options"] = config.AgentOptions
|
||||
if hostExpirySettings, ok := config.TeamSettings["host_expiry_settings"]; ok {
|
||||
team["host_expiry_settings"] = hostExpirySettings
|
||||
}
|
||||
if features, ok := config.TeamSettings["features"]; ok {
|
||||
team["features"] = features
|
||||
}
|
||||
team["scripts"] = scripts
|
||||
team["secrets"] = config.TeamSettings["secrets"]
|
||||
team["mdm"] = map[string]interface{}{}
|
||||
mdmAppConfig = team["mdm"].(map[string]interface{})
|
||||
}
|
||||
// Common controls settings between org and team settings
|
||||
mdmAppConfig["macos_settings"] = config.Controls.MacOSSettings
|
||||
mdmAppConfig["macos_updates"] = config.Controls.MacOSUpdates
|
||||
mdmAppConfig["macos_setup"] = config.Controls.MacOSSetup
|
||||
mdmAppConfig["windows_updates"] = config.Controls.WindowsUpdates
|
||||
mdmAppConfig["windows_settings"] = config.Controls.WindowsSettings
|
||||
mdmAppConfig["enable_disk_encryption"] = config.Controls.EnableDiskEncryption
|
||||
if config.TeamName != nil {
|
||||
rawTeam, err := json.Marshal(team)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error marshalling team spec: %w", err)
|
||||
}
|
||||
group.Teams = []json.RawMessage{rawTeam}
|
||||
}
|
||||
|
||||
// Apply org settings, scripts, enroll secrets, and controls
|
||||
teamIDsByName, err := c.ApplyGroup(ctx, &group, baseDir, logf, fleet.ApplySpecOptions{DryRun: dryRun})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if config.TeamName != nil {
|
||||
teamID, ok := teamIDsByName[*config.TeamName]
|
||||
if !ok || teamID == 0 {
|
||||
if dryRun {
|
||||
logFn("[+] would've added any policies/queries to new team %s\n", *config.TeamName)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("team %s not created", *config.TeamName)
|
||||
}
|
||||
config.TeamID = &teamID
|
||||
}
|
||||
|
||||
err = c.doGitOpsPolicies(config, logFn, dryRun)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = c.doGitOpsQueries(config, logFn, dryRun)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) doGitOpsPolicies(config *spec.GitOps, logFn func(format string, args ...interface{}), dryRun bool) error {
|
||||
// Get the ids and names of current policies to figure out which ones to delete
|
||||
policies, err := c.GetPolicies(config.TeamID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting current policies: %w", err)
|
||||
}
|
||||
if len(config.Policies) > 0 {
|
||||
numPolicies := len(config.Policies)
|
||||
logFn("[+] syncing %d policies\n", numPolicies)
|
||||
if !dryRun {
|
||||
// Note: We are reusing the spec flow here for adding/updating policies, instead of creating a new flow for GitOps.
|
||||
if err := c.ApplyPolicies(config.Policies); err != nil {
|
||||
return fmt.Errorf("error applying policies: %w", err)
|
||||
}
|
||||
logFn("[+] synced %d policies\n", numPolicies)
|
||||
}
|
||||
}
|
||||
var policiesToDelete []uint
|
||||
for _, oldItem := range policies {
|
||||
found := false
|
||||
for _, newItem := range config.Policies {
|
||||
if oldItem.Name == newItem.Name {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
policiesToDelete = append(policiesToDelete, oldItem.ID)
|
||||
fmt.Printf("[-] deleting policy %s\n", oldItem.Name)
|
||||
}
|
||||
}
|
||||
if len(policiesToDelete) > 0 {
|
||||
logFn("[-] deleting %d policies\n", len(policiesToDelete))
|
||||
if !dryRun {
|
||||
if err := c.DeletePolicies(config.TeamID, policiesToDelete); err != nil {
|
||||
return fmt.Errorf("error deleting policies: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) doGitOpsQueries(config *spec.GitOps, logFn func(format string, args ...interface{}), dryRun bool) error {
|
||||
// Get the ids and names of current queries to figure out which ones to delete
|
||||
queries, err := c.GetQueries(config.TeamID, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting current queries: %w", err)
|
||||
}
|
||||
if len(config.Queries) > 0 {
|
||||
numQueries := len(config.Queries)
|
||||
logFn("[+] syncing %d queries\n", numQueries)
|
||||
if !dryRun {
|
||||
// Note: We are reusing the spec flow here for adding/updating queries, instead of creating a new flow for GitOps.
|
||||
if err := c.ApplyQueries(config.Queries); err != nil {
|
||||
return fmt.Errorf("error applying queries: %w", err)
|
||||
}
|
||||
logFn("[+] synced %d queries\n", numQueries)
|
||||
}
|
||||
}
|
||||
var queriesToDelete []uint
|
||||
for _, oldQuery := range queries {
|
||||
found := false
|
||||
for _, newQuery := range config.Queries {
|
||||
if oldQuery.Name == newQuery.Name {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
queriesToDelete = append(queriesToDelete, oldQuery.ID)
|
||||
fmt.Printf("[-] deleting query %s\n", oldQuery.Name)
|
||||
}
|
||||
}
|
||||
if len(queriesToDelete) > 0 {
|
||||
logFn("[-] deleting %d queries\n", len(queriesToDelete))
|
||||
if !dryRun {
|
||||
if err := c.DeleteQueries(queriesToDelete); err != nil {
|
||||
return fmt.Errorf("error deleting queries: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
@ -14,9 +14,16 @@ func (c *Client) ApplyAppConfig(payload interface{}, opts fleet.ApplySpecOptions
|
||||
|
||||
// ApplyNoTeamProfiles sends the list of profiles to be applied for the hosts
|
||||
// in no team.
|
||||
func (c *Client) ApplyNoTeamProfiles(profiles []fleet.MDMProfileBatchPayload, opts fleet.ApplySpecOptions) error {
|
||||
func (c *Client) ApplyNoTeamProfiles(profiles []fleet.MDMProfileBatchPayload, opts fleet.ApplySpecOptions, assumeEnabled bool) error {
|
||||
verb, path := "POST", "/api/latest/fleet/mdm/profiles/batch"
|
||||
return c.authenticatedRequestWithQuery(map[string]interface{}{"profiles": profiles}, verb, path, nil, opts.RawQuery())
|
||||
query := opts.RawQuery()
|
||||
if assumeEnabled {
|
||||
if query != "" {
|
||||
query += "&"
|
||||
}
|
||||
query += "assume_enabled=true"
|
||||
}
|
||||
return c.authenticatedRequestWithQuery(map[string]interface{}{"profiles": profiles}, verb, path, nil, query)
|
||||
}
|
||||
|
||||
// GetAppConfig fetches the application config from the server API
|
||||
|
@ -1,5 +1,10 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
)
|
||||
|
||||
func (c *Client) CreateGlobalPolicy(name, query, description, resolution, platform string) error {
|
||||
req := globalPolicyRequest{
|
||||
Name: name,
|
||||
@ -12,3 +17,44 @@ func (c *Client) CreateGlobalPolicy(name, query, description, resolution, platfo
|
||||
var responseBody globalPolicyResponse
|
||||
return c.authenticatedRequest(req, verb, path, &responseBody)
|
||||
}
|
||||
|
||||
// ApplyPolicies sends the list of Policies to be applied to the
|
||||
// Fleet instance.
|
||||
func (c *Client) ApplyPolicies(specs []*fleet.PolicySpec) error {
|
||||
req := applyPolicySpecsRequest{Specs: specs}
|
||||
verb, path := "POST", "/api/latest/fleet/spec/policies"
|
||||
var responseBody applyPolicySpecsResponse
|
||||
return c.authenticatedRequest(req, verb, path, &responseBody)
|
||||
}
|
||||
|
||||
// GetPolicies retrieves the list of Policies. Inherited policies are excluded.
|
||||
func (c *Client) GetPolicies(teamID *uint) ([]*fleet.Policy, error) {
|
||||
verb, path := "GET", ""
|
||||
if teamID != nil {
|
||||
path = fmt.Sprintf("/api/latest/fleet/teams/%d/policies", *teamID)
|
||||
} else {
|
||||
path = "/api/latest/fleet/policies"
|
||||
}
|
||||
// The response body also works for listTeamPoliciesResponse because they contain some of the same members.
|
||||
var responseBody listGlobalPoliciesResponse
|
||||
err := c.authenticatedRequest(nil, verb, path, &responseBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return responseBody.Policies, nil
|
||||
}
|
||||
|
||||
// DeletePolicies deletes several policies.
|
||||
func (c *Client) DeletePolicies(teamID *uint, IDs []uint) error {
|
||||
verb, path := "POST", ""
|
||||
req := deleteTeamPoliciesRequest{IDs: IDs}
|
||||
if teamID != nil {
|
||||
path = fmt.Sprintf("/api/latest/fleet/teams/%d/policies/delete", *teamID)
|
||||
req.TeamID = *teamID
|
||||
} else {
|
||||
path = "/api/latest/fleet/policies/delete"
|
||||
}
|
||||
// The response body also works for deleteTeamPoliciesResponse because they contain some of the same members.
|
||||
var responseBody deleteGlobalPoliciesResponse
|
||||
return c.authenticatedRequest(req, verb, path, &responseBody)
|
||||
}
|
||||
|
@ -52,3 +52,11 @@ func (c *Client) DeleteQuery(name string) error {
|
||||
var responseBody deleteQueryResponse
|
||||
return c.authenticatedRequest(nil, verb, path, &responseBody)
|
||||
}
|
||||
|
||||
// DeleteQueries deletes several queries.
|
||||
func (c *Client) DeleteQueries(IDs []uint) error {
|
||||
req := deleteQueriesRequest{IDs: IDs}
|
||||
verb, path := "POST", "/api/latest/fleet/queries/delete"
|
||||
var responseBody deleteQueriesResponse
|
||||
return c.authenticatedRequest(req, verb, path, &responseBody)
|
||||
}
|
||||
|
@ -74,15 +74,6 @@ func (c *Client) ApplyTeamProfiles(tmName string, profiles []fleet.MDMProfileBat
|
||||
return c.authenticatedRequestWithQuery(map[string]interface{}{"profiles": profiles}, verb, path, nil, query.Encode())
|
||||
}
|
||||
|
||||
// ApplyPolicies sends the list of Policies to be applied to the
|
||||
// Fleet instance.
|
||||
func (c *Client) ApplyPolicies(specs []*fleet.PolicySpec) error {
|
||||
req := applyPolicySpecsRequest{Specs: specs}
|
||||
verb, path := "POST", "/api/latest/fleet/spec/policies"
|
||||
var responseBody applyPolicySpecsResponse
|
||||
return c.authenticatedRequest(req, verb, path, &responseBody)
|
||||
}
|
||||
|
||||
// ApplyTeamScripts sends the list of scripts to be applied for the specified
|
||||
// team.
|
||||
func (c *Client) ApplyTeamScripts(tmName string, scripts []fleet.ScriptPayload, opts fleet.ApplySpecOptions) error {
|
||||
|
@ -317,8 +317,7 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() {
|
||||
}
|
||||
applyResp = applyTeamSpecsResponse{}
|
||||
s.DoJSON("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK, &applyResp, "dry_run", "true")
|
||||
// dry-run never returns id to name mappings as it may not have them
|
||||
require.Empty(t, applyResp.TeamIDsByName)
|
||||
assert.Equal(t, map[string]uint{teamName: team.ID}, applyResp.TeamIDsByName)
|
||||
|
||||
// dry-run with macos disk encryption set to true
|
||||
teamSpecs = map[string]any{
|
||||
|
@ -10647,7 +10647,7 @@ func (s *integrationMDMTestSuite) TestApplyTeamsMDMWindowsProfiles() {
|
||||
s.DoJSON("POST", "/api/latest/fleet/spec/teams", rawTeamSpec(`
|
||||
{ "windows_settings": { "custom_settings": null } }
|
||||
`), http.StatusOK, &applyResp, "dry_run", "true")
|
||||
require.Len(t, applyResp.TeamIDsByName, 0)
|
||||
assert.Equal(t, map[string]uint{team.Name: team.ID}, applyResp.TeamIDsByName)
|
||||
|
||||
teamResp = getTeamResponse{}
|
||||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
|
||||
|
@ -1396,6 +1396,7 @@ type batchSetMDMProfilesRequest struct {
|
||||
TeamID *uint `json:"-" query:"team_id,optional"`
|
||||
TeamName *string `json:"-" query:"team_name,optional"`
|
||||
DryRun bool `json:"-" query:"dry_run,optional"` // if true, apply validation but do not save changes
|
||||
AssumeEnabled bool `json:"-" query:"assume_enabled,optional"` // if true, assume MDM is enabled
|
||||
Profiles backwardsCompatProfilesParam `json:"profiles"`
|
||||
}
|
||||
|
||||
@ -1439,13 +1440,16 @@ func (r batchSetMDMProfilesResponse) Status() int { return http.StatusNoContent
|
||||
|
||||
func batchSetMDMProfilesEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
|
||||
req := request.(*batchSetMDMProfilesRequest)
|
||||
if err := svc.BatchSetMDMProfiles(ctx, req.TeamID, req.TeamName, req.Profiles, req.DryRun, false); err != nil {
|
||||
if err := svc.BatchSetMDMProfiles(ctx, req.TeamID, req.TeamName, req.Profiles, req.DryRun, false, req.AssumeEnabled); err != nil {
|
||||
return batchSetMDMProfilesResponse{Err: err}, nil
|
||||
}
|
||||
return batchSetMDMProfilesResponse{}, nil
|
||||
}
|
||||
|
||||
func (svc *Service) BatchSetMDMProfiles(ctx context.Context, tmID *uint, tmName *string, profiles []fleet.MDMProfileBatchPayload, dryRun, skipBulkPending bool) error {
|
||||
func (svc *Service) BatchSetMDMProfiles(
|
||||
ctx context.Context, tmID *uint, tmName *string, profiles []fleet.MDMProfileBatchPayload, dryRun, skipBulkPending bool,
|
||||
assumeEnabled bool,
|
||||
) error {
|
||||
var err error
|
||||
if tmID, tmName, err = svc.authorizeBatchProfiles(ctx, tmID, tmName); err != nil {
|
||||
return err
|
||||
@ -1455,6 +1459,9 @@ func (svc *Service) BatchSetMDMProfiles(ctx context.Context, tmID *uint, tmName
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "getting app config")
|
||||
}
|
||||
if assumeEnabled {
|
||||
appCfg.MDM.WindowsEnabledAndConfigured = true
|
||||
}
|
||||
|
||||
if err := validateProfiles(profiles); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "validating profiles")
|
||||
|
@ -1389,7 +1389,7 @@ func TestMDMBatchSetProfiles(t *testing.T) {
|
||||
}
|
||||
ctx = license.NewContext(ctx, &fleet.LicenseInfo{Tier: tier})
|
||||
|
||||
err := svc.BatchSetMDMProfiles(ctx, tt.teamID, tt.teamName, tt.profiles, false, false)
|
||||
err := svc.BatchSetMDMProfiles(ctx, tt.teamID, tt.teamName, tt.profiles, false, false, false)
|
||||
if tt.wantErr == "" {
|
||||
require.NoError(t, err)
|
||||
require.True(t, ds.BatchSetMDMProfilesFuncInvoked)
|
||||
|
@ -759,6 +759,10 @@ func (svc *Service) BatchSetScripts(ctx context.Context, maybeTmID *uint, maybeT
|
||||
if maybeTmID != nil || maybeTmName != nil {
|
||||
team, err := svc.EnterpriseOverrides.TeamByIDOrName(ctx, maybeTmID, maybeTmName)
|
||||
if err != nil {
|
||||
// If this is a dry run, the team may not have been created yet
|
||||
if dryRun && fleet.IsNotFound(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
teamID = &team.ID
|
||||
|
Loading…
Reference in New Issue
Block a user