mirror of
https://github.com/fleetdm/fleet
synced 2026-05-22 16:39:01 +00:00
for #31182 # Details This PR implements the "Action Required" state for Windows host disk encryption. This includes updates to reporting for: * disk encryption summary (`GET /fleet/disk_encryption`) * config profiles summary (`GET /configuration_profiles/summary`) * config profile status ( `GET /configuration_profiles/{profile_uuid}/status`) For disk encryption summary, the statuses are now determined according to [the rules in the Figma](https://www.figma.com/design/XbhlPuEJxQtOgTZW9EOJZp/-28133-Enforce-BitLocker-PIN?node-id=5484-928&t=JB13g8zQ2QDVEmPB-0). TL;DR if the criteria for "verified" or "verifying" are set, but a required PIN is not set, we report a host as "action required". For profiles, I followed what seems to be the existing pattern and set the profile status to "pending" if the disk encryption status is "action required". This is what we do for hosts with the "enforcing" or "removing enforcement" statuses. A lot of the changes in these files are due to the creation of the `fleet.DiskEncryptionConfig` struct to hold info about disk encryption config, and passing variables of that type to various functions instead of passing a `bool` to indicate whether encryption is enabled. Other than that, the functional changes are constrained to a few files. > Note: to get the "require bitlocker pin" UI, compile the front end with: ``` SHOW_BITLOCKER_PIN_OPTION=true NODE_ENV=development yarn run webpack --progress --watch ``` # Checklist for submitter If some of the following don't apply, delete the relevant line. - [ ] 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. Changelog will be added when feature is complete. - [X] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) ## 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) - [ ] QA'd all new/changed functionality manually Could use some help testing this end-to-end. I was able to test the banners showing up correctly, but testing the Disk Encryption table requires some Windows-MDM-fu (I just get all zeroes). ## Database migrations - [X] Checked table schema to confirm autoupdate - [X] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [X] 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`).
411 lines
14 KiB
Go
411 lines
14 KiB
Go
package mysql
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/fleetdm/fleet/v4/server/contexts/ctxdb"
|
|
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
"github.com/jmoiron/sqlx"
|
|
)
|
|
|
|
func (ds *Datastore) NewAppConfig(ctx context.Context, info *fleet.AppConfig) (*fleet.AppConfig, error) {
|
|
info.ApplyDefaultsForNewInstalls()
|
|
|
|
if err := ds.SaveAppConfig(ctx, info); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "new app config")
|
|
}
|
|
|
|
return info, nil
|
|
}
|
|
|
|
func (ds *Datastore) GetCurrentTime(ctx context.Context) (time.Time, error) {
|
|
now := time.Now() // fall back to server time if we get an error
|
|
err := sqlx.GetContext(ctx, ds.reader(ctx), &now, `SELECT NOW()`)
|
|
if err != nil {
|
|
return now, ctxerr.Wrap(ctx, err, "getting current time")
|
|
}
|
|
return now, nil
|
|
}
|
|
|
|
func (ds *Datastore) AppConfig(ctx context.Context) (*fleet.AppConfig, error) {
|
|
return appConfigDB(ctx, ds.reader(ctx))
|
|
}
|
|
|
|
func appConfigDB(ctx context.Context, q sqlx.QueryerContext) (*fleet.AppConfig, error) {
|
|
info := &fleet.AppConfig{}
|
|
var bytes []byte
|
|
err := sqlx.GetContext(ctx, q, &bytes, `SELECT json_value FROM app_config_json LIMIT 1`)
|
|
if err != nil && err != sql.ErrNoRows {
|
|
return nil, ctxerr.Wrap(ctx, err, "selecting app config")
|
|
}
|
|
if err == sql.ErrNoRows {
|
|
return &fleet.AppConfig{}, nil
|
|
}
|
|
|
|
info.ApplyDefaults()
|
|
|
|
err = json.Unmarshal(bytes, info)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "unmarshaling config")
|
|
}
|
|
return info, nil
|
|
}
|
|
|
|
func (ds *Datastore) SaveAppConfig(ctx context.Context, info *fleet.AppConfig) error {
|
|
return ds.withTx(ctx, func(tx sqlx.ExtContext) error {
|
|
err := ds.saveCAAssets(ctx, tx, info)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "saving CA assets")
|
|
}
|
|
|
|
configBytes, err := json.Marshal(info)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "marshaling config")
|
|
}
|
|
|
|
_, err = tx.ExecContext(ctx,
|
|
`INSERT INTO app_config_json(json_value) VALUES(?) ON DUPLICATE KEY UPDATE json_value = VALUES(json_value)`,
|
|
configBytes,
|
|
)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "insert app_config_json")
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// saveCAAssets encrypts and saves the CA assets (passwords, API tokens, etc.) to the database.
|
|
func (ds *Datastore) saveCAAssets(ctx context.Context, tx sqlx.ExtContext, info *fleet.AppConfig) error {
|
|
if info.Integrations.NDESSCEPProxy.Valid {
|
|
if info.Integrations.NDESSCEPProxy.Set &&
|
|
info.Integrations.NDESSCEPProxy.Value.Password != "" &&
|
|
info.Integrations.NDESSCEPProxy.Value.Password != fleet.MaskedPassword {
|
|
err := ds.insertOrReplaceConfigAsset(ctx, tx, fleet.MDMConfigAsset{
|
|
Name: fleet.MDMAssetNDESPassword,
|
|
Value: []byte(info.Integrations.NDESSCEPProxy.Value.Password),
|
|
})
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "processing NDES SCEP proxy password")
|
|
}
|
|
}
|
|
info.Integrations.NDESSCEPProxy.Value.Password = fleet.MaskedPassword
|
|
}
|
|
|
|
if info.Integrations.DigiCert.Valid || info.Integrations.CustomSCEPProxy.Valid {
|
|
tokensToSave := make([]fleet.CAConfigAsset, 0, len(info.Integrations.DigiCert.Value)+len(info.Integrations.CustomSCEPProxy.Value))
|
|
if info.Integrations.DigiCert.Valid {
|
|
for i, ca := range info.Integrations.DigiCert.Value {
|
|
if ca.APIToken != "" && ca.APIToken != fleet.MaskedPassword {
|
|
tokensToSave = append(tokensToSave, fleet.CAConfigAsset{
|
|
Name: ca.Name,
|
|
Value: []byte(ca.APIToken),
|
|
Type: fleet.CAConfigDigiCert,
|
|
})
|
|
}
|
|
info.Integrations.DigiCert.Value[i].APIToken = fleet.MaskedPassword
|
|
}
|
|
}
|
|
|
|
if info.Integrations.CustomSCEPProxy.Valid {
|
|
for i, ca := range info.Integrations.CustomSCEPProxy.Value {
|
|
if ca.Challenge != "" && ca.Challenge != fleet.MaskedPassword {
|
|
tokensToSave = append(tokensToSave, fleet.CAConfigAsset{
|
|
Name: ca.Name,
|
|
Value: []byte(ca.Challenge),
|
|
Type: fleet.CAConfigCustomSCEPProxy,
|
|
})
|
|
}
|
|
info.Integrations.CustomSCEPProxy.Value[i].Challenge = fleet.MaskedPassword
|
|
}
|
|
}
|
|
err := ds.saveCAConfigAssets(ctx, tx, tokensToSave)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "saving CA assets")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (ds *Datastore) InsertOrReplaceMDMConfigAsset(ctx context.Context, asset fleet.MDMConfigAsset) error {
|
|
return ds.insertOrReplaceConfigAsset(ctx, ds.writer(ctx), asset)
|
|
}
|
|
|
|
func (ds *Datastore) insertOrReplaceConfigAsset(ctx context.Context, tx sqlx.ExtContext, asset fleet.MDMConfigAsset) error {
|
|
assets, err := ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{asset.Name}, tx)
|
|
if err != nil {
|
|
if fleet.IsNotFound(err) {
|
|
return ds.InsertMDMConfigAssets(ctx, []fleet.MDMConfigAsset{asset}, tx)
|
|
}
|
|
return ctxerr.Wrap(ctx, err, "get all mdm config assets by name")
|
|
}
|
|
if len(assets) == 0 {
|
|
// Should never happen
|
|
return ctxerr.New(ctx, fmt.Sprintf("no asset found for name %s", asset.Name))
|
|
}
|
|
currentAsset, ok := assets[asset.Name]
|
|
if !ok {
|
|
// Should never happen
|
|
return ctxerr.New(ctx, fmt.Sprintf("asset not found for name %s", asset.Name))
|
|
}
|
|
if !bytes.Equal(currentAsset.Value, asset.Value) {
|
|
return ds.ReplaceMDMConfigAssets(ctx, []fleet.MDMConfigAsset{asset}, tx)
|
|
}
|
|
// asset already exists and is the same, so not need to update
|
|
return nil
|
|
}
|
|
|
|
func (ds *Datastore) SetAndroidEnabledAndConfigured(ctx context.Context, configured bool) error {
|
|
ctx = ctxdb.RequirePrimary(ctx, true)
|
|
appConfig, err := ds.AppConfig(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
appConfig.MDM.AndroidEnabledAndConfigured = configured
|
|
return ds.SaveAppConfig(ctx, appConfig)
|
|
}
|
|
|
|
func (ds *Datastore) VerifyEnrollSecret(ctx context.Context, secret string) (*fleet.EnrollSecret, error) {
|
|
var s fleet.EnrollSecret
|
|
err := sqlx.GetContext(ctx, ds.reader(ctx), &s, "SELECT team_id FROM enroll_secrets WHERE secret = ?", secret)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ctxerr.Wrap(ctx, notFound("EnrollSecret"), "no matching secret found")
|
|
}
|
|
return nil, ctxerr.Wrap(ctx, err, "verify enroll secret")
|
|
}
|
|
|
|
return &s, nil
|
|
}
|
|
|
|
func (ds *Datastore) IsEnrollSecretAvailable(ctx context.Context, secret string, isNew bool, teamID *uint) (bool, error) {
|
|
secretTeamID := sql.NullInt64{}
|
|
err := sqlx.GetContext(ctx, ds.reader(ctx), &secretTeamID, "SELECT team_id FROM enroll_secrets WHERE secret = ?", secret)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return true, nil
|
|
}
|
|
return false, ctxerr.Wrap(ctx, err, "check enroll secret availability")
|
|
}
|
|
if isNew {
|
|
// Secret is already in use, so a new team can't use it
|
|
return false, nil
|
|
}
|
|
// Secret is in use, but we're checking if it's already assigned to the team
|
|
if (teamID == nil && !secretTeamID.Valid) || (teamID != nil && secretTeamID.Valid && uint(secretTeamID.Int64) == *teamID) { //nolint:gosec // dismiss G115
|
|
return true, nil
|
|
}
|
|
|
|
// Secret is in use by another team or globally
|
|
return false, nil
|
|
}
|
|
|
|
func (ds *Datastore) ApplyEnrollSecrets(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error {
|
|
return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
|
|
return applyEnrollSecretsDB(ctx, tx, teamID, secrets)
|
|
})
|
|
}
|
|
|
|
func applyEnrollSecretsDB(ctx context.Context, q sqlx.ExtContext, teamID *uint, secrets []*fleet.EnrollSecret) error {
|
|
// NOTE: this is called from within a transaction (either from
|
|
// ApplyEnrollSecrets or saveTeamSecretsDB). We don't do a simple DELETE then
|
|
// INSERT as we need to keep the existing created_at timestamps of
|
|
// already-existing secrets. We also can't do a DELETE unused ones and then
|
|
// UPSERT new ones, because we need to fail the INSERT if the secret already
|
|
// exists for a different team or globally (i.e. the `secret` column is
|
|
// unique across all values of team_id, NULL or not). An "ON DUPLICATE KEY
|
|
// UPDATE" clause would silence such errors.
|
|
//
|
|
// For this reason, we first read the existing secrets to have their
|
|
// created_at timestamps, then we delete and re-insert them, failing the call
|
|
// if the insert failed (due to a secret existing at a different team/global
|
|
// level).
|
|
|
|
var args []interface{}
|
|
teamWhere := "team_id IS NULL"
|
|
if teamID != nil {
|
|
teamWhere = "team_id = ?"
|
|
args = append(args, *teamID)
|
|
}
|
|
|
|
// first, load the existing secrets and their created_at timestamp
|
|
const loadStmt = `SELECT secret, created_at FROM enroll_secrets WHERE `
|
|
var existingSecrets []*fleet.EnrollSecret
|
|
if err := sqlx.SelectContext(ctx, q, &existingSecrets, loadStmt+teamWhere, args...); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "load existing secrets")
|
|
}
|
|
secretsCreatedAt := make(map[string]*time.Time, len(existingSecrets))
|
|
for _, es := range existingSecrets {
|
|
es := es
|
|
secretsCreatedAt[es.Secret] = &es.CreatedAt
|
|
}
|
|
|
|
// next, remove all existing secrets for that team or global
|
|
const delStmt = `DELETE FROM enroll_secrets WHERE `
|
|
if _, err := q.ExecContext(ctx, delStmt+teamWhere, args...); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "clear before insert")
|
|
}
|
|
|
|
newSecrets := make([]string, len(secrets))
|
|
for i, s := range secrets {
|
|
newSecrets[i] = s.Secret
|
|
}
|
|
|
|
// finally, insert the new secrets, using the existing created_at timestamp
|
|
// if available.
|
|
const insStmt = `INSERT INTO enroll_secrets (secret, team_id, created_at) VALUES %s`
|
|
if len(newSecrets) > 0 {
|
|
var args []interface{}
|
|
defaultCreatedAt := time.Now()
|
|
sql := fmt.Sprintf(insStmt, strings.TrimSuffix(strings.Repeat(`(?,?,?),`, len(newSecrets)), ","))
|
|
|
|
for _, s := range secrets {
|
|
secretCreatedAt := defaultCreatedAt
|
|
if ts := secretsCreatedAt[s.Secret]; ts != nil {
|
|
secretCreatedAt = *ts
|
|
}
|
|
args = append(args, s.Secret, teamID, secretCreatedAt)
|
|
}
|
|
if _, err := q.ExecContext(ctx, sql, args...); err != nil {
|
|
if IsDuplicate(err) {
|
|
// Obfuscate the secret in the error message
|
|
err = alreadyExists("secret", fleet.MaskedPassword)
|
|
}
|
|
return ctxerr.Wrap(ctx, err, "insert secrets")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (ds *Datastore) GetEnrollSecrets(ctx context.Context, teamID *uint) ([]*fleet.EnrollSecret, error) {
|
|
return getEnrollSecretsDB(ctx, ds.reader(ctx), teamID)
|
|
}
|
|
|
|
func getEnrollSecretsDB(ctx context.Context, q sqlx.QueryerContext, teamID *uint) ([]*fleet.EnrollSecret, error) {
|
|
var args []interface{}
|
|
sql := "SELECT secret, team_id, created_at FROM enroll_secrets WHERE "
|
|
// MySQL requires comparing NULL with IS. NULL = NULL evaluates to FALSE.
|
|
if teamID == nil {
|
|
sql += "team_id IS NULL"
|
|
} else {
|
|
sql += "team_id = ?"
|
|
args = append(args, teamID)
|
|
}
|
|
var secrets []*fleet.EnrollSecret
|
|
if err := sqlx.SelectContext(ctx, q, &secrets, sql, args...); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "get secrets")
|
|
}
|
|
return secrets, nil
|
|
}
|
|
|
|
func (ds *Datastore) AggregateEnrollSecretPerTeam(ctx context.Context) ([]*fleet.EnrollSecret, error) {
|
|
query := `
|
|
SELECT
|
|
COALESCE((
|
|
SELECT
|
|
es.secret
|
|
FROM
|
|
enroll_secrets es
|
|
WHERE
|
|
es.team_id = t.id
|
|
ORDER BY
|
|
es.created_at DESC LIMIT 1), '') as secret,
|
|
t.id as team_id
|
|
FROM
|
|
teams t
|
|
UNION
|
|
(
|
|
SELECT
|
|
COALESCE(secret, '') as secret, team_id
|
|
FROM
|
|
enroll_secrets
|
|
WHERE
|
|
team_id IS NULL
|
|
ORDER BY
|
|
created_at DESC LIMIT 1)
|
|
`
|
|
var secrets []*fleet.EnrollSecret
|
|
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &secrets, query); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "get secrets")
|
|
}
|
|
return secrets, nil
|
|
}
|
|
|
|
func (ds *Datastore) GetConfigEnableDiskEncryption(ctx context.Context, teamID *uint) (fleet.DiskEncryptionConfig, error) {
|
|
if teamID != nil && *teamID > 0 {
|
|
tc, err := ds.TeamMDMConfig(ctx, *teamID)
|
|
if err != nil {
|
|
return fleet.DiskEncryptionConfig{}, err
|
|
}
|
|
return fleet.DiskEncryptionConfig{
|
|
Enabled: tc.EnableDiskEncryption,
|
|
BitLockerPINRequired: tc.RequireBitLockerPIN,
|
|
}, nil
|
|
}
|
|
ac, err := ds.AppConfig(ctx)
|
|
if err != nil {
|
|
return fleet.DiskEncryptionConfig{}, err
|
|
}
|
|
return fleet.DiskEncryptionConfig{
|
|
Enabled: ac.MDM.EnableDiskEncryption.Value,
|
|
BitLockerPINRequired: ac.MDM.RequireBitLockerPIN.Value,
|
|
}, nil
|
|
}
|
|
|
|
func (ds *Datastore) ApplyYaraRules(ctx context.Context, rules []fleet.YaraRule) error {
|
|
return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
|
|
return applyYaraRulesDB(ctx, tx, rules)
|
|
})
|
|
}
|
|
|
|
func applyYaraRulesDB(ctx context.Context, q sqlx.ExtContext, rules []fleet.YaraRule) error {
|
|
const delStmt = "DELETE FROM yara_rules"
|
|
if _, err := q.ExecContext(ctx, delStmt); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "clear before insert")
|
|
}
|
|
|
|
if len(rules) > 0 {
|
|
const insStmt = `INSERT INTO yara_rules (name, contents) VALUES %s`
|
|
var args []interface{}
|
|
sql := fmt.Sprintf(insStmt, strings.TrimSuffix(strings.Repeat(`(?, ?),`, len(rules)), ","))
|
|
for _, r := range rules {
|
|
args = append(args, r.Name, r.Contents)
|
|
}
|
|
|
|
if _, err := q.ExecContext(ctx, sql, args...); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "insert yara rules")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (ds *Datastore) GetYaraRules(ctx context.Context) ([]fleet.YaraRule, error) {
|
|
sql := "SELECT name, contents FROM yara_rules"
|
|
rules := []fleet.YaraRule{}
|
|
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &rules, sql); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "get yara rules")
|
|
}
|
|
return rules, nil
|
|
}
|
|
|
|
func (ds *Datastore) YaraRuleByName(ctx context.Context, name string) (*fleet.YaraRule, error) {
|
|
query := "SELECT name, contents FROM yara_rules WHERE name = ?"
|
|
rule := fleet.YaraRule{}
|
|
if err := sqlx.GetContext(ctx, ds.reader(ctx), &rule, query, name); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ctxerr.Wrap(ctx, notFound("YaraRule"), "no yara rule with provided name")
|
|
}
|
|
return nil, ctxerr.Wrap(ctx, err, "get yara rule by name")
|
|
}
|
|
return &rule, nil
|
|
}
|