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 #41815 ### Changes - Extracted patch policy creation to `pkg/patch_policy` - Added a `patch_query` column to the `software_installers` table - By default that column is empty, and patch policies will generate with the default query if so - On app manifest ingestion, the appropriate entry in `software_installers` will save the override "patch" query from the manifest in patch_query # 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. - [ ] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements), JS inline code is prevented especially for url redirects, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters. - [ ] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes ## Testing - [x] Added/updated automated tests - [ ] 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) - [ ] QA'd all new/changed functionality manually - Relied on integration test for FMA version pinning ## Database migrations - [x] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [ ] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [x] Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`).
774 lines
30 KiB
Go
774 lines
30 KiB
Go
package mysql
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/fleetdm/fleet/v4/pkg/optjson"
|
|
"github.com/fleetdm/fleet/v4/server/config"
|
|
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
|
"github.com/fleetdm/fleet/v4/server/contexts/license"
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
"github.com/fleetdm/fleet/v4/server/ptr"
|
|
"github.com/jmoiron/sqlx"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestStatistics(t *testing.T) {
|
|
ds := CreateMySQLDS(t)
|
|
|
|
cases := []struct {
|
|
name string
|
|
fn func(t *testing.T, ds *Datastore)
|
|
}{
|
|
{"ShouldSend", testStatisticsShouldSend},
|
|
{"ConditionalAccessStatistics", testConditionalAccessStatistics},
|
|
{"FleetMaintainedAppsInUse", testFleetMaintainedAppsInUse},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
defer TruncateTables(t, ds)
|
|
c.fn(t, ds)
|
|
})
|
|
}
|
|
}
|
|
|
|
func testStatisticsShouldSend(t *testing.T, ds *Datastore) {
|
|
eh := ctxerr.MockHandler{}
|
|
// Mock the error handler to always return an error
|
|
eh.RetrieveImpl = func(flush bool) ([]*ctxerr.StoredError, error) {
|
|
require.False(t, flush)
|
|
return []*ctxerr.StoredError{
|
|
{Count: 10, Chain: json.RawMessage(`[{"stack": ["a","b","c","d"]}]`)},
|
|
}, nil
|
|
}
|
|
ctxb := context.Background()
|
|
ctx := ctxerr.NewContext(ctxb, eh)
|
|
|
|
fleetConfig := config.FleetConfig{Osquery: config.OsqueryConfig{DetailUpdateInterval: 1 * time.Hour}}
|
|
|
|
premiumLicense := &fleet.LicenseInfo{Tier: fleet.TierPremium, Organization: "Fleet"}
|
|
freeLicense := &fleet.LicenseInfo{Tier: fleet.TierFree}
|
|
|
|
var builtinLabels int
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.GetContext(ctx, q, &builtinLabels, `SELECT COUNT(*) FROM labels`)
|
|
})
|
|
|
|
// First time running with no hosts
|
|
stats, shouldSend, err := ds.ShouldSendStatistics(license.NewContext(ctx, premiumLicense), time.Millisecond, fleetConfig)
|
|
require.NoError(t, err)
|
|
assert.True(t, shouldSend)
|
|
assert.Equal(t, "premium", stats.LicenseTier)
|
|
assert.Equal(t, "Fleet", stats.Organization)
|
|
assert.Equal(t, 0, stats.NumHostsEnrolled)
|
|
assert.Equal(t, 0, stats.NumHostsABMPending)
|
|
assert.Equal(t, 0, stats.NumUsers)
|
|
assert.Equal(t, 0, stats.NumSoftwareVersions)
|
|
assert.Equal(t, 0, stats.NumHostSoftwares)
|
|
assert.Equal(t, 0, stats.NumSoftwareTitles)
|
|
assert.Equal(t, 0, stats.NumHostSoftwareInstalledPaths)
|
|
assert.Equal(t, 0, stats.NumSoftwareCPEs)
|
|
assert.Equal(t, 0, stats.NumSoftwareCVEs)
|
|
assert.Equal(t, 0, stats.NumTeams)
|
|
assert.Equal(t, 0, stats.NumPolicies)
|
|
assert.Equal(t, 0, stats.NumQueries)
|
|
assert.Equal(t, builtinLabels, stats.NumLabels)
|
|
assert.Equal(t, false, stats.SoftwareInventoryEnabled)
|
|
assert.Equal(t, true, stats.SystemUsersEnabled)
|
|
assert.Equal(t, false, stats.VulnDetectionEnabled)
|
|
assert.Equal(t, false, stats.HostsStatusWebHookEnabled)
|
|
assert.Equal(t, 0, stats.NumWeeklyActiveUsers)
|
|
assert.Equal(t, 0, stats.NumWeeklyPolicyViolationDaysActual)
|
|
assert.Equal(t, 0, stats.NumWeeklyPolicyViolationDaysPossible)
|
|
assert.Equal(t, `[{"count":10,"loc":["a","b","c"]}]`, string(stats.StoredErrors))
|
|
assert.Equal(t, []fleet.HostsCountByOsqueryVersion{}, stats.HostsEnrolledByOsqueryVersion) // should be empty slice instead of nil
|
|
assert.Equal(t, []fleet.HostsCountByOrbitVersion{}, stats.HostsEnrolledByOrbitVersion) // should be empty slice instead of nil
|
|
assert.Equal(t, false, stats.MDMMacOsEnabled)
|
|
assert.Equal(t, false, stats.HostExpiryEnabled)
|
|
assert.Equal(t, false, stats.MDMWindowsEnabled)
|
|
assert.Equal(t, false, stats.MDMRecoveryLockPasswordEnabled)
|
|
assert.Equal(t, false, stats.LiveQueryDisabled)
|
|
assert.Equal(t, false, stats.AIFeaturesDisabled)
|
|
assert.Equal(t, false, stats.MaintenanceWindowsEnabled)
|
|
assert.Equal(t, false, stats.MaintenanceWindowsConfigured)
|
|
assert.Equal(t, 0, stats.NumHostsFleetDesktopEnabled)
|
|
assert.False(t, stats.OktaConditionalAccessConfigured)
|
|
assert.False(t, stats.ConditionalAccessBypassDisabled)
|
|
assert.False(t, stats.ConditionalAccessEnabled)
|
|
assert.False(t, stats.EntraConditionalAccessConfigured)
|
|
|
|
firstIdentifier := stats.AnonymousIdentifier
|
|
|
|
err = ds.RecordStatisticsSent(ctx)
|
|
require.NoError(t, err)
|
|
|
|
// Create new host for test
|
|
h1, err := ds.NewHost(ctx, &fleet.Host{
|
|
DetailUpdatedAt: time.Now(),
|
|
LabelUpdatedAt: time.Now(),
|
|
PolicyUpdatedAt: time.Now(),
|
|
SeenTime: time.Now(),
|
|
NodeKey: ptr.String("1"),
|
|
UUID: "1",
|
|
Hostname: "foo.local",
|
|
PrimaryIP: "192.168.1.1",
|
|
PrimaryMac: "30-65-EC-6F-C4-58",
|
|
OsqueryHostID: ptr.String("M"),
|
|
OsqueryVersion: "4.9.0",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Create host_orbit_info record for test
|
|
require.NoError(
|
|
t, ds.SetOrUpdateHostOrbitInfo(
|
|
ctx, h1.ID, "1.1.0", sql.NullString{String: "1.1.0", Valid: true}, sql.NullBool{Bool: true, Valid: true},
|
|
),
|
|
)
|
|
|
|
// Create two new users for test
|
|
u1, err := ds.NewUser(ctx, &fleet.User{
|
|
Password: []byte("foobar"),
|
|
AdminForcedPasswordReset: false,
|
|
Email: "baz@example.com",
|
|
SSOEnabled: false,
|
|
GlobalRole: ptr.String(fleet.RoleObserver),
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = ds.NewUser(ctx, &fleet.User{
|
|
Password: []byte("foobar"),
|
|
AdminForcedPasswordReset: false,
|
|
Email: "qux@example.com",
|
|
SSOEnabled: false,
|
|
GlobalRole: ptr.String(fleet.RoleObserver),
|
|
})
|
|
require.NoError(t, err)
|
|
// Create a session for user baz, but not qux (so only 1 is active)
|
|
_, err = ds.NewSession(ctx, u1.ID, 8)
|
|
require.NoError(t, err)
|
|
|
|
// Create new team for test
|
|
_, err = ds.NewTeam(ctx, &fleet.Team{
|
|
Name: "footeam",
|
|
Description: "team of foo",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Create new global policy for test
|
|
_, err = ds.NewGlobalPolicy(ctx, ptr.Uint(1), fleet.PolicyPayload{
|
|
Name: "testpolicy",
|
|
Query: "select 1;",
|
|
Description: "test policy desc",
|
|
Resolution: "test policy resolution",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Create new label for test
|
|
_, err = ds.NewLabel(ctx, &fleet.Label{
|
|
Name: "testlabel",
|
|
Query: "select 1;",
|
|
Platform: "darwin",
|
|
Description: "test label description",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Create new app cfg for test
|
|
cfg, err := ds.NewAppConfig(ctx, &fleet.AppConfig{
|
|
OrgInfo: fleet.OrgInfo{
|
|
OrgName: "Test",
|
|
OrgLogoURL: "localhost:8080/logo.png",
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Initialize policy violation days for test
|
|
pvdJSON, err := json.Marshal(PolicyViolationDays{FailingHostCount: 5, TotalHostCount: 10})
|
|
require.NoError(t, err)
|
|
_, err = ds.writer(ctx).ExecContext(ctx, `
|
|
INSERT INTO
|
|
aggregated_stats (id, global_stats, type, json_value, created_at, updated_at)
|
|
VALUES (?, ?, ?, CAST(? AS JSON), ?, ?)
|
|
ON DUPLICATE KEY UPDATE
|
|
json_value = VALUES(json_value),
|
|
updated_at = VALUES(updated_at)
|
|
`, 0, true, aggregatedStatsTypePolicyViolationsDays, pvdJSON, time.Now().Add(-48*time.Hour), time.Now().Add(-7*24*time.Hour))
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, err)
|
|
cfg.Features.EnableSoftwareInventory = false
|
|
cfg.Features.EnableHostUsers = false
|
|
cfg.VulnerabilitySettings.DatabasesPath = ""
|
|
cfg.WebhookSettings.HostStatusWebhook.Enable = true
|
|
cfg.MDM.EnabledAndConfigured = true
|
|
cfg.HostExpirySettings.HostExpiryEnabled = true
|
|
cfg.MDM.WindowsEnabledAndConfigured = true
|
|
cfg.ServerSettings.LiveQueryDisabled = true
|
|
err = ds.SaveAppConfig(ctx, cfg)
|
|
require.NoError(t, err)
|
|
|
|
time.Sleep(1100 * time.Millisecond) // ensure the DB timestamp is not in the same second
|
|
|
|
// Running with 1 host
|
|
stats, shouldSend, err = ds.ShouldSendStatistics(license.NewContext(ctx, premiumLicense), time.Millisecond, fleetConfig)
|
|
require.NoError(t, err)
|
|
assert.True(t, shouldSend)
|
|
assert.NotEmpty(t, stats.AnonymousIdentifier)
|
|
assert.NotEmpty(t, stats.FleetVersion)
|
|
assert.Equal(t, "premium", stats.LicenseTier)
|
|
assert.Equal(t, "Fleet", stats.Organization)
|
|
assert.Equal(t, 1, stats.NumHostsEnrolled)
|
|
assert.Equal(t, 0, stats.NumHostsABMPending)
|
|
assert.Equal(t, 2, stats.NumUsers)
|
|
assert.Equal(t, 0, stats.NumSoftwareVersions)
|
|
assert.Equal(t, 0, stats.NumHostSoftwares)
|
|
assert.Equal(t, 0, stats.NumSoftwareTitles)
|
|
assert.Equal(t, 0, stats.NumHostSoftwareInstalledPaths)
|
|
assert.Equal(t, 0, stats.NumSoftwareCPEs)
|
|
assert.Equal(t, 0, stats.NumSoftwareCVEs)
|
|
assert.Equal(t, 1, stats.NumTeams)
|
|
assert.Equal(t, 1, stats.NumPolicies)
|
|
assert.Equal(t, 0, stats.NumQueries)
|
|
assert.Equal(t, builtinLabels+1, stats.NumLabels)
|
|
assert.Equal(t, false, stats.SoftwareInventoryEnabled)
|
|
assert.Equal(t, false, stats.SystemUsersEnabled)
|
|
assert.Equal(t, false, stats.VulnDetectionEnabled)
|
|
assert.Equal(t, true, stats.HostsStatusWebHookEnabled)
|
|
assert.Equal(t, 1, stats.NumWeeklyActiveUsers)
|
|
assert.Equal(t, 5, stats.NumWeeklyPolicyViolationDaysActual)
|
|
assert.Equal(t, 10, stats.NumWeeklyPolicyViolationDaysPossible)
|
|
assert.Equal(t, `[{"count":10,"loc":["a","b","c"]}]`, string(stats.StoredErrors))
|
|
assert.Equal(t, []fleet.HostsCountByOsqueryVersion{{OsqueryVersion: "4.9.0", NumHosts: 1}}, stats.HostsEnrolledByOsqueryVersion)
|
|
assert.Equal(t, []fleet.HostsCountByOrbitVersion{{OrbitVersion: "1.1.0", NumHosts: 1}}, stats.HostsEnrolledByOrbitVersion)
|
|
assert.Equal(t, false, stats.AIFeaturesDisabled)
|
|
assert.Equal(t, false, stats.MaintenanceWindowsEnabled)
|
|
assert.Equal(t, false, stats.MaintenanceWindowsConfigured)
|
|
assert.Equal(t, 1, stats.NumHostsFleetDesktopEnabled)
|
|
assert.False(t, stats.OktaConditionalAccessConfigured)
|
|
assert.False(t, stats.ConditionalAccessBypassDisabled)
|
|
assert.False(t, stats.ConditionalAccessEnabled)
|
|
assert.False(t, stats.EntraConditionalAccessConfigured)
|
|
|
|
err = ds.RecordStatisticsSent(ctx)
|
|
require.NoError(t, err)
|
|
|
|
// If we try right away, it shouldn't ask to send
|
|
stats, shouldSend, err = ds.ShouldSendStatistics(license.NewContext(ctx, premiumLicense), fleet.StatisticsFrequency, fleetConfig)
|
|
require.NoError(t, err)
|
|
assert.False(t, shouldSend)
|
|
|
|
time.Sleep(1100 * time.Millisecond) // ensure the DB timestamp is not in the same second
|
|
|
|
// create a few more hosts, with platforms and os versions
|
|
_, err = ds.NewHost(ctx, &fleet.Host{
|
|
DetailUpdatedAt: time.Now(),
|
|
LabelUpdatedAt: time.Now(),
|
|
PolicyUpdatedAt: time.Now(),
|
|
SeenTime: time.Now(),
|
|
NodeKey: ptr.String("2"),
|
|
UUID: "2",
|
|
Hostname: "foo.local2",
|
|
PrimaryIP: "192.168.1.2",
|
|
PrimaryMac: "30-65-EC-6F-C4-59",
|
|
OsqueryHostID: ptr.String("S"),
|
|
Platform: "rhel",
|
|
OSVersion: "Fedora 35",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, err = ds.NewHost(ctx, &fleet.Host{
|
|
DetailUpdatedAt: time.Now(),
|
|
LabelUpdatedAt: time.Now(),
|
|
PolicyUpdatedAt: time.Now(),
|
|
SeenTime: time.Now(),
|
|
NodeKey: ptr.String("3"),
|
|
UUID: "3",
|
|
Hostname: "foo.local3",
|
|
PrimaryIP: "192.168.1.3",
|
|
PrimaryMac: "40-65-EC-6F-C4-59",
|
|
OsqueryHostID: ptr.String("T"),
|
|
Platform: "rhel",
|
|
OSVersion: "Fedora 35",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, err = ds.NewHost(ctx, &fleet.Host{
|
|
DetailUpdatedAt: time.Now(),
|
|
LabelUpdatedAt: time.Now(),
|
|
PolicyUpdatedAt: time.Now(),
|
|
SeenTime: time.Now(),
|
|
NodeKey: ptr.String("4"),
|
|
UUID: "4",
|
|
Hostname: "foo.local4",
|
|
PrimaryIP: "192.168.1.4",
|
|
PrimaryMac: "50-65-EC-6F-C4-59",
|
|
OsqueryHostID: ptr.String("U"),
|
|
Platform: "macos",
|
|
OSVersion: "10.11.12",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, err = ds.NewHost(ctx, &fleet.Host{
|
|
DetailUpdatedAt: time.Now(),
|
|
LabelUpdatedAt: time.Now(),
|
|
PolicyUpdatedAt: time.Now(),
|
|
SeenTime: time.Now(),
|
|
NodeKey: ptr.String("5"),
|
|
UUID: "5",
|
|
Hostname: "foo.local5",
|
|
PrimaryIP: "192.168.1.5",
|
|
PrimaryMac: "60-65-EC-6F-C4-59",
|
|
OsqueryHostID: ptr.String("V"),
|
|
Platform: "rhel",
|
|
OSVersion: "Fedora 36",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Lower the frequency to trigger an "outdated" sent
|
|
stats, shouldSend, err = ds.ShouldSendStatistics(license.NewContext(ctx, premiumLicense), time.Millisecond, fleetConfig)
|
|
require.NoError(t, err)
|
|
assert.True(t, shouldSend)
|
|
assert.Equal(t, firstIdentifier, stats.AnonymousIdentifier)
|
|
assert.Equal(t, "premium", stats.LicenseTier)
|
|
assert.Equal(t, "Fleet", stats.Organization)
|
|
assert.Equal(t, 5, stats.NumHostsEnrolled)
|
|
assert.Equal(t, 0, stats.NumHostsABMPending)
|
|
assert.Equal(t, 2, stats.NumUsers)
|
|
assert.Equal(t, 0, stats.NumQueries)
|
|
assert.Equal(t, 0, stats.NumSoftwareVersions)
|
|
assert.Equal(t, 0, stats.NumHostSoftwares)
|
|
assert.Equal(t, 0, stats.NumSoftwareTitles)
|
|
assert.Equal(t, 0, stats.NumHostSoftwareInstalledPaths)
|
|
assert.Equal(t, 0, stats.NumSoftwareCPEs)
|
|
assert.Equal(t, 0, stats.NumSoftwareCVEs)
|
|
assert.Equal(t, 0, stats.NumWeeklyActiveUsers) // no active user since last stats were sent
|
|
require.Len(t, stats.HostsEnrolledByOperatingSystem, 3) // empty platform, rhel and macos
|
|
assert.Equal(t, 5, stats.NumWeeklyPolicyViolationDaysActual)
|
|
require.ElementsMatch(t, []fleet.HostsCountByOSVersion{
|
|
{Version: "Fedora 35", NumEnrolled: 2},
|
|
{Version: "Fedora 36", NumEnrolled: 1},
|
|
}, stats.HostsEnrolledByOperatingSystem["rhel"])
|
|
require.ElementsMatch(t, []fleet.HostsCountByOSVersion{
|
|
{Version: "10.11.12", NumEnrolled: 1},
|
|
}, stats.HostsEnrolledByOperatingSystem["macos"])
|
|
require.ElementsMatch(t, []fleet.HostsCountByOSVersion{
|
|
{Version: "", NumEnrolled: 1},
|
|
}, stats.HostsEnrolledByOperatingSystem[""])
|
|
assert.Equal(t, `[{"count":10,"loc":["a","b","c"]}]`, string(stats.StoredErrors))
|
|
assert.Equal(t, false, stats.AIFeaturesDisabled)
|
|
assert.Equal(t, false, stats.MaintenanceWindowsEnabled)
|
|
assert.Equal(t, false, stats.MaintenanceWindowsConfigured)
|
|
assert.Equal(t, 1, stats.NumHostsFleetDesktopEnabled)
|
|
assert.False(t, stats.OktaConditionalAccessConfigured)
|
|
assert.False(t, stats.ConditionalAccessBypassDisabled)
|
|
assert.False(t, stats.ConditionalAccessEnabled)
|
|
assert.False(t, stats.EntraConditionalAccessConfigured)
|
|
|
|
// Create multiple new sessions for a single user
|
|
_, err = ds.NewSession(ctx, u1.ID, 8)
|
|
require.NoError(t, err)
|
|
_, err = ds.NewSession(ctx, u1.ID, 8)
|
|
require.NoError(t, err)
|
|
_, err = ds.NewSession(ctx, u1.ID, 8)
|
|
require.NoError(t, err)
|
|
|
|
// CleanupStatistics resets policy violation days
|
|
err = ds.CleanupStatistics(ctx)
|
|
require.NoError(t, err)
|
|
|
|
// wait a bit and resend statistics
|
|
time.Sleep(1100 * time.Millisecond) // ensure the DB timestamp is not in the same second
|
|
|
|
stats, shouldSend, err = ds.ShouldSendStatistics(license.NewContext(ctx, premiumLicense), time.Millisecond, fleetConfig)
|
|
require.NoError(t, err)
|
|
assert.True(t, shouldSend)
|
|
assert.Equal(t, stats.AnonymousIdentifier, firstIdentifier)
|
|
assert.Equal(t, "premium", stats.LicenseTier)
|
|
assert.Equal(t, "Fleet", stats.Organization)
|
|
assert.Equal(t, 5, stats.NumHostsEnrolled)
|
|
assert.Equal(t, 0, stats.NumHostsABMPending)
|
|
assert.Equal(t, 2, stats.NumUsers)
|
|
assert.Equal(t, 0, stats.NumQueries)
|
|
assert.Equal(t, 0, stats.NumSoftwareVersions)
|
|
assert.Equal(t, 0, stats.NumHostSoftwares)
|
|
assert.Equal(t, 0, stats.NumSoftwareTitles)
|
|
assert.Equal(t, 0, stats.NumHostSoftwareInstalledPaths)
|
|
assert.Equal(t, 0, stats.NumSoftwareCPEs)
|
|
assert.Equal(t, 0, stats.NumSoftwareCVEs)
|
|
assert.Equal(t, 1, stats.NumWeeklyActiveUsers)
|
|
assert.Equal(t, 0, stats.NumWeeklyPolicyViolationDaysActual)
|
|
assert.Equal(t, 0, stats.NumWeeklyPolicyViolationDaysPossible)
|
|
assert.Equal(t, `[{"count":10,"loc":["a","b","c"]}]`, string(stats.StoredErrors))
|
|
assert.Equal(t, false, stats.AIFeaturesDisabled)
|
|
assert.Equal(t, false, stats.MaintenanceWindowsEnabled)
|
|
assert.Equal(t, false, stats.MaintenanceWindowsConfigured)
|
|
assert.Equal(t, 1, stats.NumHostsFleetDesktopEnabled)
|
|
assert.False(t, stats.OktaConditionalAccessConfigured)
|
|
assert.False(t, stats.ConditionalAccessBypassDisabled)
|
|
assert.False(t, stats.ConditionalAccessEnabled)
|
|
assert.False(t, stats.EntraConditionalAccessConfigured)
|
|
|
|
// Add host to test hosts not responding stats
|
|
_, err = ds.NewHost(ctx, &fleet.Host{
|
|
DetailUpdatedAt: time.Now().Add(-3 * time.Hour),
|
|
LabelUpdatedAt: time.Now(),
|
|
PolicyUpdatedAt: time.Now(),
|
|
SeenTime: time.Now(),
|
|
NodeKey: ptr.String("6"),
|
|
UUID: "6",
|
|
Hostname: "non-responsive.local",
|
|
PrimaryIP: "192.168.1.6",
|
|
PrimaryMac: "30-65-EC-6F-C4-66",
|
|
OsqueryHostID: ptr.String("NR"),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
stats, shouldSend, err = ds.ShouldSendStatistics(license.NewContext(ctx, premiumLicense), time.Millisecond, fleetConfig)
|
|
require.NoError(t, err)
|
|
assert.True(t, shouldSend)
|
|
assert.Equal(t, firstIdentifier, stats.AnonymousIdentifier)
|
|
assert.Equal(t, "premium", stats.LicenseTier)
|
|
assert.Equal(t, "Fleet", stats.Organization)
|
|
assert.Equal(t, 6, stats.NumHostsEnrolled)
|
|
assert.Equal(t, 1, stats.NumHostsNotResponding)
|
|
|
|
// trigger again with a free license, organization should be "unknown"
|
|
time.Sleep(1100 * time.Millisecond) // ensure the DB timestamp is not in the same second
|
|
stats, shouldSend, err = ds.ShouldSendStatistics(license.NewContext(ctx, freeLicense), time.Millisecond, fleetConfig)
|
|
require.NoError(t, err)
|
|
assert.True(t, shouldSend)
|
|
assert.Equal(t, firstIdentifier, stats.AnonymousIdentifier)
|
|
assert.Equal(t, "free", stats.LicenseTier)
|
|
assert.Equal(t, "unknown", stats.Organization)
|
|
|
|
fleetConfig.Vulnerabilities = config.VulnerabilitiesConfig{DatabasesPath: "some/path/vulns"}
|
|
stats, shouldSend, err = ds.ShouldSendStatistics(license.NewContext(ctx, freeLicense), time.Millisecond, fleetConfig)
|
|
require.NoError(t, err)
|
|
assert.True(t, shouldSend)
|
|
assert.True(t, stats.VulnDetectionEnabled)
|
|
|
|
// Test conditional access statistics
|
|
cfg.ConditionalAccess = &fleet.ConditionalAccessSettings{
|
|
OktaIDPID: optjson.SetString("test-idp-id"),
|
|
OktaAssertionConsumerServiceURL: optjson.SetString("https://example.okta.com/sso/saml"),
|
|
OktaAudienceURI: optjson.SetString("https://example.okta.com"),
|
|
OktaCertificate: optjson.SetString("test-certificate"),
|
|
BypassDisabled: optjson.SetBool(true),
|
|
}
|
|
err = ds.SaveAppConfig(ctx, cfg)
|
|
require.NoError(t, err)
|
|
|
|
time.Sleep(1100 * time.Millisecond)
|
|
|
|
stats, shouldSend, err = ds.ShouldSendStatistics(license.NewContext(ctx, premiumLicense), time.Millisecond, fleetConfig)
|
|
require.NoError(t, err)
|
|
assert.True(t, shouldSend)
|
|
assert.True(t, stats.OktaConditionalAccessConfigured)
|
|
assert.True(t, stats.ConditionalAccessBypassDisabled)
|
|
|
|
// Update config: bypass enabled (BypassDisabled=false), Okta still configured
|
|
cfg.ConditionalAccess.BypassDisabled = optjson.SetBool(false)
|
|
err = ds.SaveAppConfig(ctx, cfg)
|
|
require.NoError(t, err)
|
|
|
|
time.Sleep(1100 * time.Millisecond)
|
|
|
|
stats, shouldSend, err = ds.ShouldSendStatistics(license.NewContext(ctx, premiumLicense), time.Millisecond, fleetConfig)
|
|
require.NoError(t, err)
|
|
assert.True(t, shouldSend)
|
|
assert.True(t, stats.OktaConditionalAccessConfigured)
|
|
assert.False(t, stats.ConditionalAccessBypassDisabled)
|
|
}
|
|
|
|
func testConditionalAccessStatistics(t *testing.T, ds *Datastore) {
|
|
eh := ctxerr.MockHandler{}
|
|
eh.RetrieveImpl = func(flush bool) ([]*ctxerr.StoredError, error) {
|
|
return nil, nil
|
|
}
|
|
ctxb := context.Background()
|
|
ctx := ctxerr.NewContext(ctxb, eh)
|
|
|
|
premiumLicense := &fleet.LicenseInfo{Tier: fleet.TierPremium, Organization: "Fleet"}
|
|
fleetConfig := config.FleetConfig{Osquery: config.OsqueryConfig{DetailUpdateInterval: 1 * time.Hour}}
|
|
|
|
// Initial state: nothing configured
|
|
stats, shouldSend, err := ds.ShouldSendStatistics(license.NewContext(ctx, premiumLicense), time.Millisecond, fleetConfig)
|
|
require.NoError(t, err)
|
|
assert.True(t, shouldSend)
|
|
assert.False(t, stats.ConditionalAccessEnabled)
|
|
assert.False(t, stats.EntraConditionalAccessConfigured)
|
|
|
|
err = ds.RecordStatisticsSent(ctx)
|
|
require.NoError(t, err)
|
|
time.Sleep(1100 * time.Millisecond)
|
|
|
|
// Enable conditional access on appconfig (for "No team")
|
|
cfg, err := ds.AppConfig(ctx)
|
|
require.NoError(t, err)
|
|
cfg.Integrations.ConditionalAccessEnabled = optjson.SetBool(true)
|
|
err = ds.SaveAppConfig(ctx, cfg)
|
|
require.NoError(t, err)
|
|
|
|
stats, shouldSend, err = ds.ShouldSendStatistics(license.NewContext(ctx, premiumLicense), time.Millisecond, fleetConfig)
|
|
require.NoError(t, err)
|
|
assert.True(t, shouldSend)
|
|
assert.True(t, stats.ConditionalAccessEnabled)
|
|
assert.False(t, stats.EntraConditionalAccessConfigured)
|
|
|
|
// Disable on appconfig
|
|
err = ds.RecordStatisticsSent(ctx)
|
|
require.NoError(t, err)
|
|
time.Sleep(1100 * time.Millisecond)
|
|
|
|
cfg.Integrations.ConditionalAccessEnabled = optjson.SetBool(false)
|
|
err = ds.SaveAppConfig(ctx, cfg)
|
|
require.NoError(t, err)
|
|
|
|
stats, shouldSend, err = ds.ShouldSendStatistics(license.NewContext(ctx, premiumLicense), time.Millisecond, fleetConfig)
|
|
require.NoError(t, err)
|
|
assert.True(t, shouldSend)
|
|
assert.False(t, stats.ConditionalAccessEnabled)
|
|
|
|
// Enable conditional access on a team
|
|
err = ds.RecordStatisticsSent(ctx)
|
|
require.NoError(t, err)
|
|
time.Sleep(1100 * time.Millisecond)
|
|
|
|
team, err := ds.NewTeam(ctx, &fleet.Team{
|
|
Name: "ca-team",
|
|
Description: "team with conditional access",
|
|
})
|
|
require.NoError(t, err)
|
|
team.Config.Integrations.ConditionalAccessEnabled = optjson.SetBool(true)
|
|
_, err = ds.SaveTeam(ctx, team)
|
|
require.NoError(t, err)
|
|
|
|
stats, shouldSend, err = ds.ShouldSendStatistics(license.NewContext(ctx, premiumLicense), time.Millisecond, fleetConfig)
|
|
require.NoError(t, err)
|
|
assert.True(t, shouldSend)
|
|
assert.True(t, stats.ConditionalAccessEnabled)
|
|
|
|
// Disable on team
|
|
err = ds.RecordStatisticsSent(ctx)
|
|
require.NoError(t, err)
|
|
time.Sleep(1100 * time.Millisecond)
|
|
|
|
team.Config.Integrations.ConditionalAccessEnabled = optjson.SetBool(false)
|
|
_, err = ds.SaveTeam(ctx, team)
|
|
require.NoError(t, err)
|
|
|
|
stats, shouldSend, err = ds.ShouldSendStatistics(license.NewContext(ctx, premiumLicense), time.Millisecond, fleetConfig)
|
|
require.NoError(t, err)
|
|
assert.True(t, shouldSend)
|
|
assert.False(t, stats.ConditionalAccessEnabled)
|
|
|
|
// Test Entra conditional access: create the integration but without setup done
|
|
err = ds.RecordStatisticsSent(ctx)
|
|
require.NoError(t, err)
|
|
time.Sleep(1100 * time.Millisecond)
|
|
|
|
fleetConfig.MicrosoftCompliancePartner = config.MicrosoftCompliancePartnerConfig{
|
|
ProxyAPIKey: "test-key",
|
|
}
|
|
err = ds.ConditionalAccessMicrosoftCreateIntegration(ctx, "test-tenant", "test-secret")
|
|
require.NoError(t, err)
|
|
|
|
stats, shouldSend, err = ds.ShouldSendStatistics(license.NewContext(ctx, premiumLicense), time.Millisecond, fleetConfig)
|
|
require.NoError(t, err)
|
|
assert.True(t, shouldSend)
|
|
assert.False(t, stats.EntraConditionalAccessConfigured) // setup not done yet
|
|
|
|
// Mark setup done
|
|
err = ds.RecordStatisticsSent(ctx)
|
|
require.NoError(t, err)
|
|
time.Sleep(1100 * time.Millisecond)
|
|
|
|
err = ds.ConditionalAccessMicrosoftMarkSetupDone(ctx)
|
|
require.NoError(t, err)
|
|
|
|
stats, shouldSend, err = ds.ShouldSendStatistics(license.NewContext(ctx, premiumLicense), time.Millisecond, fleetConfig)
|
|
require.NoError(t, err)
|
|
assert.True(t, shouldSend)
|
|
assert.True(t, stats.EntraConditionalAccessConfigured)
|
|
|
|
// Without the fleet config proxy key, should be false even with setup done
|
|
err = ds.RecordStatisticsSent(ctx)
|
|
require.NoError(t, err)
|
|
time.Sleep(1100 * time.Millisecond)
|
|
|
|
fleetConfig.MicrosoftCompliancePartner = config.MicrosoftCompliancePartnerConfig{}
|
|
stats, shouldSend, err = ds.ShouldSendStatistics(license.NewContext(ctx, premiumLicense), time.Millisecond, fleetConfig)
|
|
require.NoError(t, err)
|
|
assert.True(t, shouldSend)
|
|
assert.False(t, stats.EntraConditionalAccessConfigured)
|
|
}
|
|
|
|
func testFleetMaintainedAppsInUse(t *testing.T, ds *Datastore) {
|
|
ctx := context.Background()
|
|
|
|
// No fleet-maintained apps - should return empty slices (not nil)
|
|
macOSApps, windowsApps, err := fleetMaintainedAppsInUseDB(ctx, ds.reader(ctx))
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, macOSApps)
|
|
assert.NotNil(t, windowsApps)
|
|
assert.Empty(t, macOSApps)
|
|
assert.Empty(t, windowsApps)
|
|
|
|
appDarwin1, err := ds.UpsertMaintainedApp(ctx, &fleet.MaintainedApp{
|
|
Name: "Zoom",
|
|
Slug: "zoom/darwin",
|
|
Platform: "darwin",
|
|
UniqueIdentifier: "us.zoom.xos",
|
|
})
|
|
require.NoError(t, err)
|
|
appDarwin2, err := ds.UpsertMaintainedApp(ctx, &fleet.MaintainedApp{
|
|
Name: "Slack",
|
|
Slug: "slack/darwin",
|
|
Platform: "darwin",
|
|
UniqueIdentifier: "com.tinyspeck.slackmacgap",
|
|
})
|
|
require.NoError(t, err)
|
|
appWindows1, err := ds.UpsertMaintainedApp(ctx, &fleet.MaintainedApp{
|
|
Name: "Microsoft Teams",
|
|
Slug: "microsoft-teams/windows",
|
|
Platform: "windows",
|
|
UniqueIdentifier: "msteams",
|
|
})
|
|
require.NoError(t, err)
|
|
appWindows2, err := ds.UpsertMaintainedApp(ctx, &fleet.MaintainedApp{
|
|
Name: "Zoom",
|
|
Slug: "zoom/windows",
|
|
Platform: "windows",
|
|
UniqueIdentifier: "zoom-windows",
|
|
})
|
|
require.NoError(t, err)
|
|
appLinux, err := ds.UpsertMaintainedApp(ctx, &fleet.MaintainedApp{
|
|
Name: "Linux App",
|
|
Slug: "linux-app/linux",
|
|
Platform: "linux",
|
|
UniqueIdentifier: "linux.app",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Apps exist but no software installers - should still return empty
|
|
macOSApps, windowsApps, err = fleetMaintainedAppsInUseDB(ctx, ds.reader(ctx))
|
|
require.NoError(t, err)
|
|
assert.Empty(t, macOSApps)
|
|
assert.Empty(t, windowsApps)
|
|
|
|
// Create script content (required for software installers)
|
|
var installScriptID, uninstallScriptID int64
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
result, err := q.ExecContext(ctx, `INSERT INTO script_contents (md5_checksum, contents) VALUES (UNHEX(?), ?)`,
|
|
"d41d8cd98f00b204e9800998ecf8427e", "echo 'install'")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
installScriptID, _ = result.LastInsertId()
|
|
return nil
|
|
})
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
result, err := q.ExecContext(ctx, `INSERT INTO script_contents (md5_checksum, contents) VALUES (UNHEX(?), ?)`,
|
|
"e10adc3949ba59abbe56e057f20f883e", "echo 'uninstall'")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
uninstallScriptID, _ = result.LastInsertId()
|
|
return nil
|
|
})
|
|
|
|
// Create software installers that reference fleet-maintained apps
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, err := q.ExecContext(ctx, `
|
|
INSERT INTO software_installers (
|
|
team_id, global_or_team_id, filename, version, platform,
|
|
install_script_content_id, uninstall_script_content_id,
|
|
storage_id, package_ids, fleet_maintained_app_id, patch_query
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`, nil, 0, "zoom.pkg", "1.0", "darwin", installScriptID, uninstallScriptID, "storage1", "[]", appDarwin1.ID, "")
|
|
return err
|
|
})
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, err := q.ExecContext(ctx, `
|
|
INSERT INTO software_installers (
|
|
team_id, global_or_team_id, filename, version, platform,
|
|
install_script_content_id, uninstall_script_content_id,
|
|
storage_id, package_ids, fleet_maintained_app_id, patch_query
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`, nil, 0, "slack.pkg", "1.0", "darwin", installScriptID, uninstallScriptID, "storage2", "[]", appDarwin2.ID, "")
|
|
return err
|
|
})
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, err := q.ExecContext(ctx, `
|
|
INSERT INTO software_installers (
|
|
team_id, global_or_team_id, filename, version, platform,
|
|
install_script_content_id, uninstall_script_content_id,
|
|
storage_id, package_ids, fleet_maintained_app_id, patch_query
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`, nil, 0, "teams.exe", "1.0", "windows", installScriptID, uninstallScriptID, "storage3", "[]", appWindows1.ID, "")
|
|
return err
|
|
})
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, err := q.ExecContext(ctx, `
|
|
INSERT INTO software_installers (
|
|
team_id, global_or_team_id, filename, version, platform,
|
|
install_script_content_id, uninstall_script_content_id,
|
|
storage_id, package_ids, fleet_maintained_app_id, patch_query
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`, nil, 0, "zoom.exe", "1.0", "windows", installScriptID, uninstallScriptID, "storage4", "[]", appWindows2.ID, "")
|
|
return err
|
|
})
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, err := q.ExecContext(ctx, `
|
|
INSERT INTO software_installers (
|
|
team_id, global_or_team_id, filename, version, platform,
|
|
install_script_content_id, uninstall_script_content_id,
|
|
storage_id, package_ids, fleet_maintained_app_id, patch_query
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`, nil, 0, "linux.deb", "1.0", "linux", installScriptID, uninstallScriptID, "storage5", "[]", appLinux.ID, "")
|
|
return err
|
|
})
|
|
|
|
// Apps with installers - should return correct apps grouped by platform
|
|
macOSApps, windowsApps, err = fleetMaintainedAppsInUseDB(ctx, ds.reader(ctx))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"slack/darwin", "zoom/darwin"}, macOSApps)
|
|
assert.Equal(t, []string{"microsoft-teams/windows", "zoom/windows"}, windowsApps)
|
|
|
|
// Create duplicate installers for same app
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, err := q.ExecContext(ctx, `
|
|
INSERT INTO software_installers (
|
|
team_id, global_or_team_id, filename, version, platform,
|
|
install_script_content_id, uninstall_script_content_id,
|
|
storage_id, package_ids, fleet_maintained_app_id, patch_query
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`, nil, 0, "zoom-v2.pkg", "2.0", "darwin", installScriptID, uninstallScriptID, "storage6", "[]", appDarwin1.ID, "")
|
|
return err
|
|
})
|
|
macOSApps, windowsApps, err = fleetMaintainedAppsInUseDB(ctx, ds.reader(ctx))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"slack/darwin", "zoom/darwin"}, macOSApps)
|
|
assert.Equal(t, []string{"microsoft-teams/windows", "zoom/windows"}, windowsApps)
|
|
|
|
// Create an installer with NULL fleet_maintained_app_id (should be ignored)
|
|
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
|
_, err := q.ExecContext(ctx, `
|
|
INSERT INTO software_installers (
|
|
team_id, global_or_team_id, filename, version, platform,
|
|
install_script_content_id, uninstall_script_content_id,
|
|
storage_id, package_ids, fleet_maintained_app_id, patch_query
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`, nil, 0, "custom.pkg", "1.0", "darwin", installScriptID, uninstallScriptID, "storage7", "[]", nil, "")
|
|
return err
|
|
})
|
|
|
|
// Should return the same results (NULL fleet_maintained_app_id is filtered out)
|
|
macOSApps, windowsApps, err = fleetMaintainedAppsInUseDB(ctx, ds.reader(ctx))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"slack/darwin", "zoom/darwin"}, macOSApps)
|
|
assert.Equal(t, []string{"microsoft-teams/windows", "zoom/windows"}, windowsApps)
|
|
}
|