mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 21:47:20 +00:00
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #39265 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [x] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes ## Testing - [x] Added/updated automated tests - [x] Where appropriate, [automated tests simulate multiple hosts and test for host isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing) (updates to one hosts's records do not affect another) - [x] QA'd all new/changed functionality manually ## New Fleet configuration settings - [ ] Setting(s) is/are explicitly excluded from GitOps If you didn't check the box above, follow this checklist for GitOps-enabled settings: - [x] Verified that the setting is exported via `fleetctl generate-gitops` - [x] Verified the setting is documented in a separate PR to [the GitOps documentation](https://github.com/fleetdm/fleet/blob/main/docs/Configuration/yaml-files.md#L485) - [x] Verified that the setting is cleared on the server if it is not supplied in a YAML file (or that it is documented as being optional) - [x] Verified that any relevant UI is disabled when GitOps mode is enabled
342 lines
12 KiB
Go
342 lines
12 KiB
Go
package spec
|
|
|
|
import (
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/fleetdm/fleet/v4/pkg/testutils"
|
|
"github.com/hashicorp/go-multierror"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestSplitYaml(t *testing.T) {
|
|
in := `
|
|
---
|
|
- Document
|
|
#---
|
|
--- Document2
|
|
---
|
|
Document3
|
|
`
|
|
|
|
docs := SplitYaml(in)
|
|
require.Equal(t, 3, len(docs))
|
|
assert.Equal(t, "- Document\n#---", docs[0])
|
|
assert.Equal(t, "Document2", docs[1])
|
|
assert.Equal(t, "Document3", docs[2])
|
|
}
|
|
|
|
func gitRootPath(t *testing.T) string {
|
|
path, err := exec.Command("git", "rev-parse", "--show-toplevel").Output()
|
|
require.NoError(t, err)
|
|
return strings.TrimSpace(string(path))
|
|
}
|
|
|
|
func loadSpec(t *testing.T, relativePaths ...string) []byte {
|
|
b, err := os.ReadFile(filepath.Join(
|
|
append([]string{gitRootPath(t)}, relativePaths...)...,
|
|
))
|
|
require.NoError(t, err)
|
|
return b
|
|
}
|
|
|
|
func TestGroupFromBytesWithStdLib(t *testing.T) {
|
|
stdQueryLib := loadSpec(t,
|
|
"docs", "01-Using-Fleet", "standard-query-library", "standard-query-library.yml",
|
|
)
|
|
g, err := GroupFromBytes(stdQueryLib)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, g.Queries)
|
|
require.NotEmpty(t, g.Policies)
|
|
}
|
|
|
|
func TestGroupFromBytesWithMacOS13CISQueries(t *testing.T) {
|
|
cisQueries := loadSpec(t,
|
|
"ee", "cis", "macos-13", "cis-policy-queries.yml",
|
|
)
|
|
g, err := GroupFromBytes(cisQueries)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, g.Policies)
|
|
}
|
|
|
|
func TestGroupFromBytesWithWin10CISQueries(t *testing.T) {
|
|
cisQueries := loadSpec(t,
|
|
"ee", "cis", "win-10", "cis-policy-queries.yml",
|
|
)
|
|
g, err := GroupFromBytes(cisQueries)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, g.Policies)
|
|
}
|
|
|
|
func TestGroupFromBytesMissingFields(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
in []byte
|
|
want string
|
|
}{
|
|
{
|
|
"missing spec",
|
|
[]byte(`
|
|
---
|
|
apiVersion: v1
|
|
kind: team
|
|
`),
|
|
`Missing required fields ("spec") on provided "team" configuration.`,
|
|
},
|
|
{
|
|
"missing spec and kind",
|
|
[]byte(`
|
|
---
|
|
apiVersion: v1
|
|
`),
|
|
`Missing required fields ("spec", "kind") on provided configuration`,
|
|
},
|
|
{
|
|
"missing spec and empty string kind",
|
|
[]byte(`
|
|
---
|
|
apiVersion: v1
|
|
kind: ""
|
|
`),
|
|
`Missing required fields ("spec", "kind") on provided configuration`,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
_, err := GroupFromBytes(tt.in)
|
|
require.ErrorContains(t, err, tt.want)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEscapeString(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
s string
|
|
expResult string
|
|
}{
|
|
{`$foo`, `$foo`}, // nothing to escape
|
|
{`bar$foo`, `bar$foo`}, // nothing to escape
|
|
{`bar${foo}`, `bar${foo}`}, // nothing to escape
|
|
{`\$foo`, `$PREVENT_ESCAPING_foo`}, // escaping
|
|
{`bar\$foo`, `bar$PREVENT_ESCAPING_foo`}, // escaping
|
|
{`\\$foo`, `\\$foo`}, // no escaping
|
|
{`bar\\$foo`, `bar\\$foo`}, // no escaping
|
|
{`\\\$foo`, `\$PREVENT_ESCAPING_foo`}, // escaping
|
|
{`bar\\\$foo`, `bar\$PREVENT_ESCAPING_foo`}, // escaping
|
|
{`bar\\\${foo}bar`, `bar\$PREVENT_ESCAPING_{foo}bar`}, // escaping
|
|
{`\\\\$foo`, `\\\\$foo`}, // no escaping
|
|
{`bar\\\\$foo`, `bar\\\\$foo`}, // no escaping
|
|
{`bar\\\\${foo}`, `bar\\\\${foo}`}, // no escaping
|
|
} {
|
|
result := escapeString(tc.s, "PREVENT_ESCAPING_")
|
|
require.Equal(t, tc.expResult, result)
|
|
}
|
|
}
|
|
|
|
func checkMultiErrors(t *testing.T, errs ...string) func(err error) {
|
|
return func(err error) {
|
|
me, ok := err.(*multierror.Error)
|
|
require.True(t, ok)
|
|
require.Len(t, me.Errors, len(errs))
|
|
for i, err := range me.Errors {
|
|
require.Equal(t, errs[i], err.Error())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExpandEnv(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
environment map[string]string
|
|
s string
|
|
expResult string
|
|
checkErr func(error)
|
|
}{
|
|
{map[string]string{"foo": "1"}, `$foo`, `1`, nil},
|
|
{map[string]string{"foo": "1"}, `$foo $FLEET_VAR_BAR ${FLEET_VAR_BAR}x ${foo}`, `1 $FLEET_VAR_BAR ${FLEET_VAR_BAR}x 1`, nil},
|
|
{map[string]string{"foo": ""}, `$foo`, ``, nil},
|
|
{map[string]string{"foo": "", "bar": "", "zoo": ""}, `$foo${bar}$zoo`, ``, nil},
|
|
{map[string]string{}, `$foo`, ``, checkMultiErrors(t, "environment variable \"foo\" not set")},
|
|
{map[string]string{"foo": "1"}, `$foo$bar`, ``, checkMultiErrors(t, "environment variable \"bar\" not set")},
|
|
{
|
|
map[string]string{"bar": "1"},
|
|
`$foo $bar $zoo`, ``,
|
|
checkMultiErrors(t, "environment variable \"foo\" not set", "environment variable \"zoo\" not set"),
|
|
},
|
|
{map[string]string{"foo": "4", "bar": "2"}, `$foo$bar`, `42`, nil},
|
|
{map[string]string{"foo": "42", "bar": ""}, `$foo$bar`, `42`, nil},
|
|
{map[string]string{}, `$$`, ``, checkMultiErrors(t, "environment variable \"$\" not set")},
|
|
{map[string]string{"foo": "1"}, `$$foo`, ``, checkMultiErrors(t, "environment variable \"$\" not set")},
|
|
{map[string]string{"foo": "1"}, `\$${foo}`, `$1`, nil},
|
|
{map[string]string{}, `\$foo`, `$foo`, nil}, // escaped
|
|
{map[string]string{"foo": "1"}, `\\$foo`, `\\1`, nil}, // not escaped
|
|
{map[string]string{}, `\\\$foo`, `\$foo`, nil}, // escaped
|
|
{map[string]string{}, `\\\$foo$`, `\$foo$`, nil}, // escaped
|
|
{map[string]string{}, `bar\\\$foo$`, `bar\$foo$`, nil}, // escaped
|
|
{map[string]string{"foo": "1"}, `$foo var`, `1 var`, nil}, // not escaped
|
|
{map[string]string{"foo": "1"}, `${foo}var`, `1var`, nil}, // not escaped
|
|
{map[string]string{"foo": "1"}, `\${foo}var`, `${foo}var`, nil}, // escaped
|
|
{map[string]string{"foo": ""}, `${foo}var`, `var`, nil},
|
|
{map[string]string{"foo": "", "$": "2"}, `${$}${foo}var`, `2var`, nil},
|
|
{map[string]string{}, `${foo}var`, ``, checkMultiErrors(t, "environment variable \"foo\" not set")},
|
|
{map[string]string{}, `foo PREVENT_ESCAPING_bar $ FLEET_VAR_`, `foo PREVENT_ESCAPING_bar $ FLEET_VAR_`, nil}, // nothing to replace
|
|
{
|
|
map[string]string{"foo": "BAR"},
|
|
`\$FLEET_VAR_$foo \${FLEET_VAR_$foo} \${FLEET_VAR_${foo}2}`,
|
|
`$FLEET_VAR_BAR ${FLEET_VAR_BAR} ${FLEET_VAR_BAR2}`, nil,
|
|
}, // nested variables
|
|
{
|
|
map[string]string{},
|
|
"$fleet_var_test", // Somehow, variables can be lowercased when coming in from time to time
|
|
"$fleet_var_test", // Should not be replaced
|
|
nil,
|
|
},
|
|
{
|
|
map[string]string{"custom_secret": "test&123"},
|
|
"<Add>$custom_secret</Add>",
|
|
"<Add>test&123</Add>",
|
|
nil,
|
|
},
|
|
} {
|
|
// save the current env before clearing it.
|
|
testutils.SaveEnv(t)
|
|
os.Clearenv()
|
|
for k, v := range tc.environment {
|
|
_ = os.Setenv(k, v)
|
|
}
|
|
result, err := ExpandEnv(tc.s)
|
|
if tc.checkErr == nil {
|
|
require.NoError(t, err)
|
|
} else {
|
|
tc.checkErr(err)
|
|
}
|
|
require.Equal(t, tc.expResult, result)
|
|
}
|
|
}
|
|
|
|
func TestLookupEnvSecrets(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
environment map[string]string
|
|
s string
|
|
expResult map[string]string
|
|
checkErr func(error)
|
|
}{
|
|
{map[string]string{"foo": "1"}, `$foo`, map[string]string{}, nil},
|
|
{map[string]string{"FLEET_SECRET_foo": "1"}, `$FLEET_SECRET_foo`, map[string]string{"FLEET_SECRET_foo": "1"}, nil},
|
|
{
|
|
map[string]string{"foo": "1"},
|
|
`$FLEET_SECRET_foo`,
|
|
map[string]string{},
|
|
checkMultiErrors(t, "environment variable \"FLEET_SECRET_foo\" not set"),
|
|
},
|
|
{map[string]string{"FLEET_SECRET_foo": "test&123"}, `<Add>$FLEET_SECRET_foo</Add>`, map[string]string{"FLEET_SECRET_foo": "test&123"}, nil},
|
|
} {
|
|
// save the current env before clearing it.
|
|
testutils.SaveEnv(t)
|
|
os.Clearenv()
|
|
for k, v := range tc.environment {
|
|
_ = os.Setenv(k, v)
|
|
}
|
|
secretsMap := make(map[string]string)
|
|
err := LookupEnvSecrets(tc.s, secretsMap)
|
|
if tc.checkErr == nil {
|
|
require.NoError(t, err)
|
|
} else {
|
|
tc.checkErr(err)
|
|
}
|
|
require.Equal(t, tc.expResult, secretsMap)
|
|
}
|
|
}
|
|
|
|
// TestExpandEnvBytesIncludingSecrets tests that FLEET_SECRET_ variables are expanded when using ExpandEnvBytesIncludingSecrets
|
|
func TestExpandEnvBytesIncludingSecrets(t *testing.T) {
|
|
t.Setenv("FLEET_SECRET_API_KEY", "secret123")
|
|
t.Setenv("NORMAL_VAR", "normalvalue")
|
|
t.Setenv("FLEET_VAR_HOST", "hostname")
|
|
t.Setenv("FLEET_SECRET_XML", "secret&123")
|
|
|
|
input := []byte(`API Key: $FLEET_SECRET_API_KEY
|
|
Normal: $NORMAL_VAR
|
|
Fleet Var: $FLEET_VAR_HOST
|
|
Missing: $FLEET_SECRET_MISSING`)
|
|
|
|
result, err := ExpandEnvBytesIncludingSecrets(input)
|
|
require.NoError(t, err)
|
|
|
|
expected := `API Key: secret123
|
|
Normal: normalvalue
|
|
Fleet Var: $FLEET_VAR_HOST
|
|
Missing: $FLEET_SECRET_MISSING`
|
|
|
|
assert.Equal(t, expected, string(result))
|
|
|
|
// Verify that FLEET_VAR_ is not expanded (reserved for server)
|
|
assert.Contains(t, string(result), "$FLEET_VAR_HOST")
|
|
// Verify that FLEET_SECRET_ is expanded
|
|
assert.Contains(t, string(result), "secret123")
|
|
assert.NotContains(t, string(result), "$FLEET_SECRET_API_KEY")
|
|
// Verify that missing secrets are left as-is
|
|
assert.Contains(t, string(result), "$FLEET_SECRET_MISSING")
|
|
|
|
xmlInput := []byte(`<Add>$FLEET_SECRET_XML</Add>`)
|
|
xmlResult, err := ExpandEnvBytesIncludingSecrets(xmlInput)
|
|
require.NoError(t, err)
|
|
|
|
expectedXML := `<Add>secret&123</Add>`
|
|
assert.Equal(t, expectedXML, string(xmlResult))
|
|
}
|
|
|
|
func TestGetExclusionZones(t *testing.T) {
|
|
testCases := []struct {
|
|
fixturePath []string
|
|
expected map[[2]int]string
|
|
}{
|
|
{
|
|
[]string{"testdata", "policies", "policies.yml"},
|
|
map[[2]int]string{
|
|
{46, 106}: " description: This policy should always fail.\n resolution:",
|
|
{93, 155}: " resolution: There is no resolution for this policy.\n query:",
|
|
{268, 328}: " description: This policy should always pass.\n resolution:",
|
|
{315, 678}: " resolution: |\n Automated method:\n Ask your system administrator to deploy the following script which will ensure proper Security Auditing Retention:\n cp /etc/security/audit_control ./tmp.txt; origExpire=$(cat ./tmp.txt | grep expire-after); sed \"s/${origExpire}/expire-after:60d OR 5G/\" ./tmp.txt > /etc/security/audit_control; rm ./tmp.txt;\n query:",
|
|
},
|
|
},
|
|
{
|
|
[]string{"testdata", "global_config_no_paths.yml"},
|
|
map[[2]int]string{
|
|
{942, 1025}: " description: Collect osquery performance stats directly from osquery\n query:", //
|
|
{1830, 1894}: " description: This policy should always fail.\n resolution:", //
|
|
{1879, 1945}: " resolution: There is no resolution for this policy.\n query:", //
|
|
{2062, 2126}: " description: This policy should always pass.\n resolution:", //
|
|
{2111, 2177}: " resolution: There is no resolution for this policy.\n query:", //
|
|
{2470, 2534}: " description: This policy should always fail.\n resolution:", //
|
|
{2519, 2585}: " resolution: There is no resolution for this policy.\n query:", //
|
|
{2689, 2753}: " description: This policy should always fail.\n resolution:", //
|
|
{2738, 3111}: " resolution: |\n Automated method:\n Ask your system administrator to deploy the following script which will ensure proper Security Auditing Retention:\n cp /etc/security/audit_control ./tmp.txt; origExpire=$(cat ./tmp.txt | grep expire-after); sed \"s/${origExpire}/expire-after:60d OR 5G/\" ./tmp.txt > /etc/security/audit_control; rm ./tmp.txt;\n query:",
|
|
{6178, 6225}: " description: A cool global label\n query:", //
|
|
{6322, 6368}: " description: A fly global label\n hosts:", //
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tC := range testCases {
|
|
fPath := filepath.Join(tC.fixturePath...)
|
|
|
|
t.Run(fPath, func(t *testing.T) {
|
|
fContents, err := os.ReadFile(fPath)
|
|
require.NoError(t, err)
|
|
|
|
contents := string(fContents)
|
|
actual := getExclusionZones(contents)
|
|
require.Equal(t, len(tC.expected), len(actual))
|
|
|
|
for pos, text := range tC.expected {
|
|
assert.Contains(t, actual, pos)
|
|
assert.Equal(t, contents[pos[0]:pos[1]], text, pos)
|
|
}
|
|
})
|
|
}
|
|
}
|