diff --git a/changes/27476-add-generate-gitops-cmd b/changes/27476-add-generate-gitops-cmd new file mode 100644 index 0000000000..2c7ff4d1c6 --- /dev/null +++ b/changes/27476-add-generate-gitops-cmd @@ -0,0 +1 @@ +- Added "generate-gitops" command to fleetctl \ No newline at end of file diff --git a/cmd/fleetctl/fleetctl.go b/cmd/fleetctl/fleetctl.go index fa1f8dd769..a7035d0ce8 100644 --- a/cmd/fleetctl/fleetctl.go +++ b/cmd/fleetctl/fleetctl.go @@ -110,6 +110,7 @@ func createApp( upgradePacksCommand(), runScriptCommand(), gitopsCommand(), + generateGitopsCommand(), } return app } diff --git a/cmd/fleetctl/generate_gitops.go b/cmd/fleetctl/generate_gitops.go new file mode 100644 index 0000000000..c53e1a5c61 --- /dev/null +++ b/cmd/fleetctl/generate_gitops.go @@ -0,0 +1,1294 @@ +package main + +import ( + "bytes" + "fmt" + "os" + pathUtils "path" + "reflect" + "regexp" + "strings" + "unicode" + + "github.com/fleetdm/fleet/v4/pkg/spec" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/fleetdm/fleet/v4/server/service" + "github.com/ghodss/yaml" + "github.com/urfave/cli/v2" +) + +type SecretWarning struct { + Filename string + Key string +} + +type Note struct { + Filename string + Note string +} + +type Messages struct { + SecretWarnings []SecretWarning + Notes []Note +} + +type Comment struct { + Filename string + Comment string + Token string +} + +type FileToWrite struct { + Path string + Content map[string]interface{} +} + +type Software struct { + Hash string + AppStoreId string + Comment string +} + +type teamToProcess struct { + ID *uint + Team *fleet.Team +} + +type generateGitopsClient interface { + GetAppConfig() (*fleet.EnrichedAppConfig, error) + GetEnrollSecretSpec() (*fleet.EnrollSecretSpec, error) + ListTeams(query string) ([]fleet.Team, error) + ListScripts(query string) ([]*fleet.Script, error) + ListConfigurationProfiles(teamID *uint) ([]*fleet.MDMConfigProfilePayload, error) + GetScriptContents(scriptID uint) ([]byte, error) + GetProfileContents(profileID string) ([]byte, error) + GetTeam(teamID uint) (*fleet.Team, error) + ListSoftwareTitles(query string) ([]fleet.SoftwareTitleListResult, error) + GetSoftwareTitleByID(ID uint, teamID *uint) (*fleet.SoftwareTitle, error) + GetPolicies(teamID *uint) ([]*fleet.Policy, error) + GetQueries(teamID *uint, name *string) ([]fleet.Query, error) + GetLabels() ([]*fleet.LabelSpec, error) + Me() (*fleet.User, error) +} + +// Given a struct type and a field name, return the JSON field name. +func jsonFieldName(t reflect.Type, fieldName string) string { + field, ok := t.FieldByName(fieldName) + if !ok { + panic(fieldName + " not found in " + t.Name()) + } + tag := field.Tag.Get("json") + parts := strings.Split(tag, ",") + name := parts[0] + + if name == "-" || name == "" { + panic(field.Name + " has no json tag") + } + return name +} + +// Given a dot-separated path, return the value at that key in a map. +func getValueAtKey(data map[string]interface{}, path string) (interface{}, bool) { + // Split the path into parts. + parts := strings.Split(path, ".") + var cur interface{} = data + + // Keep traversing the map using the keys in the path. + for _, key := range parts { + mp, ok := cur.(map[string]interface{}) + if !ok { + return nil, false + } + cur, ok = mp[key] + if !ok { + return nil, false + } + } + return cur, true +} + +type GenerateGitopsCommand struct { + Client generateGitopsClient + CLI *cli.Context + Messages Messages + FilesToWrite map[string]interface{} + Comments []Comment + AppConfig *fleet.EnrichedAppConfig + SoftwareList map[uint]Software + ScriptList map[uint]string +} + +func generateGitopsCommand() *cli.Command { + return &cli.Command{ + Name: "generate-gitops", + Usage: "Generate GitOps configuration files for Fleet.", + Description: "This command generates GitOps configuration files for Fleet.", + Action: createGenerateGitopsAction(nil), + Flags: []cli.Flag{ + configFlag(), + contextFlag(), + debugFlag(), + &cli.BoolFlag{ + Name: "insecure", + Usage: "Output sensitive information in plaintext.", + Value: false, + }, + &cli.StringFlag{ + Name: "key", + Usage: "A key to output the config value for.", + }, + &cli.StringFlag{ + Name: "team", + Usage: "(Premium only) The team to output configuration for. Omit to export all configuration. Use 'global' to export global settings, or 'no-team' to export settings for No Team.", + }, + &cli.StringFlag{ + Name: "dir", + Usage: "The root directory to write the files to.", + }, + &cli.BoolFlag{ + Name: "print", + Usage: "Output to stdout instead of the specified directory.", + }, + &cli.BoolFlag{ + Name: "force", + Usage: "Overwrite existing files.", + }, + }, + } +} + +// Create the action for the generate-gitops command, using a provided fleetClient. +func createGenerateGitopsAction(fleetClient generateGitopsClient) func(*cli.Context) error { + return func(c *cli.Context) error { + var err error + if fleetClient == nil { + fleetClient, err = clientFromCLI(c) + if err != nil { + return err + } + } + cmd := &GenerateGitopsCommand{ + Client: fleetClient, + CLI: c, + Messages: Messages{}, + FilesToWrite: make(map[string]interface{}), + SoftwareList: make(map[uint]Software), + ScriptList: make(map[uint]string), + } + return cmd.Run() + } +} + +// Execute the actual command. +func (cmd *GenerateGitopsCommand) Run() error { + // Either "key" or "dir" must be specified. + if cmd.CLI.String("key") == "" && cmd.CLI.String("dir") == "" { + fmt.Fprintf(cmd.CLI.App.ErrWriter, "Either --dir or --key must be specified\n") + return nil + } + // But not both. + if cmd.CLI.String("key") != "" && cmd.CLI.String("dir") != "" { + fmt.Fprintf(cmd.CLI.App.ErrWriter, "Only one of --dir or --key may be specified\n") + return nil + } + + var err error + + // User must be global admin. + me, err := cmd.Client.Me() + if err != nil { + fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error getting user: %s\n", err) + return ErrGeneric + } + if me.GlobalRole != nil && *me.GlobalRole != fleet.RoleAdmin { + fmt.Fprintf(cmd.CLI.App.ErrWriter, "You are not authorized to run this command. Please contact your administrator.\n") + return nil + } + + // Validate directory is empty (or --force is set). + if cmd.CLI.String("dir") != "" && !cmd.CLI.Bool("print") { + dir := cmd.CLI.String("dir") + _, err := os.Stat(dir) + if err != nil { + if !os.IsNotExist(err) { + fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error checking directory: %s\n", err) + return ErrGeneric + } + } else { + // Check if the directory is empty. + entries, err := os.ReadDir(dir) + if err != nil { + fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error reading directory: %s\n", err) + return ErrGeneric + } + if len(entries) > 0 && !cmd.CLI.Bool("force") { + fmt.Fprintf(cmd.CLI.App.ErrWriter, "Directory %s is not empty. Use --force to overwrite.\n", dir) + return nil + } + } + } + + fmt.Println("Generating GitOps configuration files...") + + cmd.AppConfig, err = cmd.Client.GetAppConfig() + if err != nil { + return err + } + + // Gather the list of teams to process, which may include some + // virtual teams (i.e. global and no-team). + var teamsToProcess []teamToProcess + globalTeam := teamToProcess{ + ID: nil, + Team: &fleet.Team{ + Name: "Global", + }, + } + noTeam := teamToProcess{ + ID: ptr.Uint(0), + Team: &fleet.Team{ + ID: 0, + Name: "No team", + }, + } + switch { + case cmd.CLI.String("team") == "global" || !cmd.AppConfig.License.IsPremium(): + teamsToProcess = []teamToProcess{globalTeam} + case cmd.CLI.String("team") == "no-team": + teamsToProcess = []teamToProcess{noTeam} + default: + // Get the list of teams. + teams, err := cmd.Client.ListTeams("") + if err != nil { + fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error getting teams: %s\n", err) + return ErrGeneric + } + // If a specific team is requested, find it. + if cmd.CLI.String("team") != "" { + transformedSelectedName := generateFilename(cmd.CLI.String("team")) + for _, team := range teams { + transformedTeamName := generateFilename(team.Name) + if transformedSelectedName == transformedTeamName { + teamsToProcess = []teamToProcess{{ + ID: &team.ID, + Team: &team, + }} + } + } + if len(teamsToProcess) == 0 { + fmt.Fprintf(cmd.CLI.App.ErrWriter, "Team %s not found\n", cmd.CLI.String("team")) + return nil + } + } else { + // Otherwise process all teams, including global and no-team. + teamsToProcess = make([]teamToProcess, len(teams)+2) + for i, team := range teams { + teamsToProcess[i] = teamToProcess{ + ID: &team.ID, + Team: &team, + } + } + teamsToProcess[len(teams)] = noTeam + teamsToProcess[len(teams)+1] = globalTeam + } + } + + // Iterate over the teams and generate the config files. + for _, teamToProcess := range teamsToProcess { + var teamFileName string + var fileName string + var team *fleet.Team + if teamToProcess.ID != nil { + team = teamToProcess.Team + } + // If it's a real team, start the filename with the team name. + if team != nil { + teamFileName = generateFilename(team.Name) + fileName = "teams/" + teamFileName + ".yml" + cmd.FilesToWrite[fileName] = map[string]interface{}{ + "name": team.Name, + } + } else { + fileName = "default.yml" + } + + // Set mdm to the global config by default. + // We'll override this for teams other than no-team. + mdmConfig := fleet.TeamMDM{ + EnableDiskEncryption: cmd.AppConfig.MDM.EnableDiskEncryption.Value, + MacOSUpdates: cmd.AppConfig.MDM.MacOSUpdates, + IOSUpdates: cmd.AppConfig.MDM.IOSUpdates, + IPadOSUpdates: cmd.AppConfig.MDM.IPadOSUpdates, + WindowsUpdates: cmd.AppConfig.MDM.WindowsUpdates, + MacOSSetup: cmd.AppConfig.MDM.MacOSSetup, + } + + if team == nil { + // Generate org settings, agent options and labels for the global config. + orgSettings, err := cmd.generateOrgSettings() + if err != nil { + fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error generating org settings: %s\n", err) + return ErrGeneric + } + + cmd.FilesToWrite["default.yml"] = map[string]interface{}{ + "org_settings": orgSettings, + } + + cmd.FilesToWrite[fileName].(map[string]interface{})["agent_options"] = cmd.AppConfig.AgentOptions + + // Generate labels. + labels, err := cmd.generateLabels() + if err != nil { + fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error generating labels: %s\n", err) + return ErrGeneric + } + cmd.FilesToWrite[fileName].(map[string]interface{})["labels"] = labels + + } else if team.ID != 0 { + // Generate team settings and agent options for the team. + teamSettings, err := cmd.generateTeamSettings(fileName, team) + if err != nil { + fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error generating org settings: %s\n", err) + return ErrGeneric + } + + cmd.FilesToWrite[fileName].(map[string]interface{})["team_settings"] = teamSettings + cmd.FilesToWrite[fileName].(map[string]interface{})["agent_options"] = team.Config.AgentOptions + + mdmConfig = team.Config.MDM + } + + // Generate controls. + // Only do this on the global team if we're on the free tier. + if teamToProcess.ID != nil || !cmd.AppConfig.License.IsPremium() { + controls, err := cmd.generateControls(teamToProcess.ID, teamFileName, &mdmConfig) + if err != nil { + fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error generating controls for %s: %s\n", teamFileName, err) + return ErrGeneric + } + cmd.FilesToWrite[fileName].(map[string]interface{})["controls"] = controls + } + + // Generate software. + if team != nil { + software, err := cmd.generateSoftware(fileName, team.ID, teamFileName) + if err != nil { + fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error generating software for %s: %s\n", teamFileName, err) + return ErrGeneric + } + if software == nil { + cmd.FilesToWrite[fileName].(map[string]interface{})["software"] = nil + } else { + cmd.FilesToWrite[fileName].(map[string]interface{})["software"] = software + } + } + + // Generate policies. + policies, err := cmd.generatePolicies(teamToProcess.ID, teamFileName) + if err != nil { + fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error generating policies for team %s: %s\n", team.Name, err) + return ErrGeneric + } + cmd.FilesToWrite[fileName].(map[string]interface{})["policies"] = policies + + if team == nil || team.ID != 0 { + // Generate queries (except for on No Team). + queries, err := cmd.generateQueries(teamToProcess.ID) + if err != nil { + fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error generating queries for team %s: %s\n", team.Name, err) + return ErrGeneric + } + cmd.FilesToWrite[fileName].(map[string]interface{})["queries"] = queries + } + } + + // If we're just looking to print out a specific key, attempt to do that now. + if cmd.CLI.String("key") != "" { + var fileName string + // If a team is specified, get the file for that team. + switch cmd.CLI.String("team") { + case "global": + fileName = "default.yml" + case "": + fileName = "default.yml" + case "no-team": + fileName = "teams/no-team.yml" + default: + teamFileName := generateFilename(cmd.CLI.String("team")) + fileName = "teams/" + teamFileName + ".yml" + } + + // Marshal and ummarshal the data to standardize the keys. + b, err := yaml.Marshal(cmd.FilesToWrite[fileName]) + if err != nil { + fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error marshaling settings: %s\n", err) + return ErrGeneric + } + var data map[string]interface{} + if err := yaml.Unmarshal(b, &data); err != nil { + fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error unmarshaling settings: %s\n", err) + return ErrGeneric + } + value, ok := getValueAtKey(data, cmd.CLI.String("key")) + if !ok { + fmt.Fprintf(cmd.CLI.App.ErrWriter, "Key %s not found in %s\n", cmd.CLI.String("key"), fileName) + return nil + } + b, err = yaml.Marshal(value) + if err != nil { + fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error marshaling value: %s\n", err) + return ErrGeneric + } + fmt.Fprintf(cmd.CLI.App.Writer, "%s", string(b)) + return nil + } + + emptyVal := regexp.MustCompile(`(?m):\s*(null|""|\[\]|\{\})\s*$`) + // Add comments to the result. + for path, fileToWrite := range cmd.FilesToWrite { + fullPath := fmt.Sprintf("%s/%s", cmd.CLI.String("dir"), path) + var b []byte + var err error + // If the filename ends in .yml, marshal it to YAML. + if strings.HasSuffix(path, ".yml") { + b, err = yaml.Marshal(fileToWrite) + if err != nil { + fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error marshaling file to write: %s\n", err) + return ErrGeneric + } + for _, comment := range cmd.Comments { + if comment.Filename == path { + b = bytes.ReplaceAll(b, + []byte(comment.Token), + []byte("# "+comment.Comment), + ) + } + } + // Replace any empty values with a blank. + b = emptyVal.ReplaceAll(b, []byte(":")) + } else { + b = []byte(fileToWrite.(string)) + } + + // If --print is set, print the file to stdout. + if cmd.CLI.Bool("print") { + fmt.Fprintf(cmd.CLI.App.Writer, "------------------------------------------------------------------\n%s\n------------------------------------------------------------------\n\n%+v\n\n", fullPath, string(b)) + } else { + // Ensure the dir exists + err = os.MkdirAll(pathUtils.Dir(fullPath), 0o755) + if err != nil { + fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error creating dir %s: %s\n\n", fullPath, err) + return ErrGeneric + } + // Write the file to the output directory. + err = os.WriteFile(fullPath, b, 0o644) + if err != nil { + fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error writing file %s: %s\n\n", fullPath, err) + return ErrGeneric + } + } + } + + fmt.Fprintf(cmd.CLI.App.Writer, "Config generation complete!\n") + if len(cmd.Messages.SecretWarnings) > 0 { + fmt.Fprintf(cmd.CLI.App.Writer, "Sensitive information was redacted in the following places, and will need to be replaced:\n") + for _, secretWarning := range cmd.Messages.SecretWarnings { + fmt.Fprintf(cmd.CLI.App.Writer, " • %s: %s\n", secretWarning.Filename, secretWarning.Key) + } + fmt.Fprintf(cmd.CLI.App.Writer, "\n") + } + + if cmd.CLI.String("team") == "global" || cmd.CLI.String("team") == "" { + cmd.Messages.Notes = append(cmd.Messages.Notes, Note{ + Filename: "default.yml", + Note: "Warning: YARA rules are not supported by this tool yet. If you have existing YARA rules, add them to the new default.yml file.", + }) + } + + if cmd.CLI.String("team") != "global" { + cmd.Messages.Notes = append(cmd.Messages.Notes, Note{ + Note: "Warning: Software categories are not supported by this tool yet. If you have added any categories to software items, add them to the appropriate team .yml file.", + }) + } + + if len(cmd.Messages.Notes) > 0 { + fmt.Fprintf(cmd.CLI.App.Writer, "Other notes:\n") + for _, note := range cmd.Messages.Notes { + if note.Filename != "" { + fmt.Fprintf(cmd.CLI.App.Writer, " • %s: %s\n", note.Filename, note.Note) + } else { + fmt.Fprintf(cmd.CLI.App.Writer, " • %s\n", note.Note) + } + } + } + + return nil +} + +// Add a comment to a file. The comment is added as a token in the map, which +// is replaced with the comment when the file is written. +func (cmd *GenerateGitopsCommand) AddComment(filename, comment string) string { + token := fmt.Sprintf("___GITOPS_COMMENT_%d___", len(cmd.Comments)) + cmd.Comments = append(cmd.Comments, Comment{ + Filename: filename, + Comment: comment, + Token: token, + }) + return token +} + +// Given a name, generate a filename by replacing spaces with dashes and +// removing any non-alphanumeric characters. +func generateFilename(name string) string { + fileName := strings.Map(func(r rune) rune { + switch { + case unicode.IsLetter(r) || unicode.IsDigit(r): + return unicode.ToLower(r) + case unicode.IsSpace(r): + return '-' + default: + return -1 + } + }, name) + // Strip any leading/trailing dashes using regex. + fileName = strings.Trim(fileName, "-") + return fileName +} + +var isJSON = regexp.MustCompile(`^\s*\{`) + +// Generate a filename for a profile based on its name and contents. +func generateProfileFilename(profile *fleet.MDMConfigProfilePayload, profileContentsString string) string { + fileName := generateFilename(profile.Name) + if profile.Platform == "darwin" { + if isJSON.MatchString(profileContentsString) { + fileName += ".json" + } else { + fileName += ".mobileconfig" + } + } else { + fileName += ".xml" + } + return fileName +} + +func (cmd *GenerateGitopsCommand) generateOrgSettings() (orgSettings map[string]interface{}, err error) { + t := reflect.TypeOf(fleet.EnrichedAppConfig{}) + orgSettings = map[string]interface{}{ + jsonFieldName(t, "Features"): cmd.AppConfig.Features, + jsonFieldName(t, "FleetDesktop"): cmd.AppConfig.FleetDesktop, + jsonFieldName(t, "HostExpirySettings"): cmd.AppConfig.HostExpirySettings, + jsonFieldName(t, "OrgInfo"): cmd.AppConfig.OrgInfo, + jsonFieldName(t, "ServerSettings"): cmd.AppConfig.ServerSettings, + jsonFieldName(t, "WebhookSettings"): cmd.AppConfig.WebhookSettings, + } + integrations, err := cmd.generateIntegrations("default.yml", &GlobalOrTeamIntegrations{GlobalIntegrations: &cmd.AppConfig.Integrations}) + if err != nil { + return nil, err + } + orgSettings[jsonFieldName(t, "Integrations")] = integrations + mdm, err := cmd.generateMDM(&cmd.AppConfig.MDM) + if err != nil { + return nil, err + } + orgSettings[jsonFieldName(t, "MDM")] = mdm + yaraRules, err := cmd.generateYaraRules(cmd.AppConfig.YaraRules) + if err != nil { + return nil, err + } + orgSettings[jsonFieldName(t, "YaraRules")] = yaraRules + + // If --insecure is set, add real secrets. + if cmd.CLI.Bool("insecure") { + enrollSecrets, err := cmd.Client.GetEnrollSecretSpec() + if err != nil { + return nil, err + } + secrets := make([]map[string]string, len(enrollSecrets.Secrets)) + for i, spec := range enrollSecrets.Secrets { + secrets[i] = map[string]string{"secret": spec.Secret} + } + orgSettings["secrets"] = secrets + } else { + orgSettings["secrets"] = []map[string]string{{"secret": cmd.AddComment("default.yml", "TODO: Add your enroll secrets here")}} + cmd.Messages.SecretWarnings = append(cmd.Messages.SecretWarnings, SecretWarning{ + Filename: "default.yml", + Key: "org_settings.secrets", + }) + } + + if (orgSettings)[jsonFieldName(t, "SSOSettings")], err = cmd.generateSSOSettings(cmd.AppConfig.SSOSettings); err != nil { + return nil, err + } + return orgSettings, nil +} + +func (cmd *GenerateGitopsCommand) generateSSOSettings(ssoSettings *fleet.SSOSettings) (map[string]interface{}, error) { + t := reflect.TypeOf(fleet.SSOSettings{}) + result := map[string]interface{}{ + jsonFieldName(t, "EnableSSO"): ssoSettings.EnableSSO, + jsonFieldName(t, "IDPName"): ssoSettings.IDPName, + jsonFieldName(t, "IDPImageURL"): ssoSettings.IDPImageURL, + jsonFieldName(t, "EntityID"): ssoSettings.EntityID, + jsonFieldName(t, "Metadata"): ssoSettings.Metadata, + jsonFieldName(t, "MetadataURL"): ssoSettings.MetadataURL, + jsonFieldName(t, "EnableSSOIdPLogin"): ssoSettings.EnableSSOIdPLogin, + } + if cmd.AppConfig.License.IsPremium() { + result[jsonFieldName(t, "EnableJITProvisioning")] = ssoSettings.EnableJITProvisioning + } + if !cmd.CLI.Bool("insecure") { + if ssoSettings.Metadata != "" { + result[jsonFieldName(t, "Metadata")] = cmd.AddComment("default.yml", "TODO: Add your SSO metadata here") + cmd.Messages.SecretWarnings = append(cmd.Messages.SecretWarnings, SecretWarning{ + Filename: "default.yml", + Key: "org_settings.sso_settings.metadata", + }) + + } + if ssoSettings.MetadataURL != "" { + result[jsonFieldName(t, "MetadataURL")] = cmd.AddComment("default.yml", "TODO: Add your SSO metadata URL here") + cmd.Messages.SecretWarnings = append(cmd.Messages.SecretWarnings, SecretWarning{ + Filename: "default.yml", + Key: "org_settings.sso_settings.metadata_url", + }) + } + } + return result, nil +} + +type GlobalOrTeamIntegrations struct { + GlobalIntegrations *fleet.Integrations `json:"global_integrations,omitempty"` + TeamIntegrations *fleet.TeamIntegrations `json:"team_integrations,omitempty"` +} + +func (cmd *GenerateGitopsCommand) generateIntegrations(filePath string, integrations *GlobalOrTeamIntegrations) (map[string]interface{}, error) { + // Rather than crawling through the whole struct, we'll marshall/unmarshall it + // to get the keys we want. + b, err := yaml.Marshal(integrations) + if err != nil { + fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error marshaling integrations: %s\n", err) + return nil, err + } + var result map[string]interface{} + if err := yaml.Unmarshal(b, &result); err != nil { + fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error unmarshaling integrations: %s\n", err) + return nil, err + } + if result["global_integrations"] != nil { + result = result["global_integrations"].(map[string]interface{}) + } else { + result = result["team_integrations"].(map[string]interface{}) + if result["google_calendar"] != nil { + result = map[string]interface{}{ + "google_calendar": result["google_calendar"], + } + } else { + result = nil + } + // Team integrations don't have secrets right now, so just return as-is. + return result, nil + } + // Obfuscate secrets if not in insecure mode. + if !cmd.CLI.Bool("insecure") { + if googleCalendar, ok := result["google_calendar"]; ok && googleCalendar != nil { + for _, intg := range googleCalendar.([]interface{}) { + if apiKeyJson, ok := intg.(map[string]interface{})["api_key_json"]; ok { + apiKeyJson.(map[string]interface{})["private_key"] = cmd.AddComment(filePath, "TODO: Add your Google Calendar API key JSON here") + cmd.Messages.SecretWarnings = append(cmd.Messages.SecretWarnings, SecretWarning{ + Filename: "default.yml", + Key: "integrations.google_calendar.api_key_json.private_key", + }) + } + } + } + if jira, ok := result["jira"]; ok && jira != nil { + for _, intg := range jira.([]interface{}) { + intg.(map[string]interface{})["api_token"] = cmd.AddComment(filePath, "TODO: Add your Jira API token here") + cmd.Messages.SecretWarnings = append(cmd.Messages.SecretWarnings, SecretWarning{ + Filename: "default.yml", + Key: "integrations.jira.api_token", + }) + } + } + if zendesk, ok := result["zendesk"]; ok && zendesk != nil { + for _, intg := range zendesk.([]interface{}) { + intg.(map[string]interface{})["api_token"] = cmd.AddComment(filePath, "TODO: Add your Zendesk API token here") + cmd.Messages.SecretWarnings = append(cmd.Messages.SecretWarnings, SecretWarning{ + Filename: "default.yml", + Key: "integrations.zendesk.api_token", + }) + } + } + if digicert, ok := result["digicert"]; ok && digicert != nil { + for _, intg := range digicert.([]interface{}) { + intg.(map[string]interface{})["api_token"] = cmd.AddComment(filePath, "TODO: Add your Digicert API token here") + cmd.Messages.SecretWarnings = append(cmd.Messages.SecretWarnings, SecretWarning{ + Filename: "default.yml", + Key: "integrations.digicert.api_token", + }) + } + } + if ndes_scep_proxy, ok := result["ndes_scep_proxy"]; ok && ndes_scep_proxy != nil { + ndes_scep_proxy.(map[string]interface{})["password"] = cmd.AddComment(filePath, "TODO: Add your NDES SCEP proxy password here") + cmd.Messages.SecretWarnings = append(cmd.Messages.SecretWarnings, SecretWarning{ + Filename: "default.yml", + Key: "integrations.ndes_scep_proxy.password", + }) + } + if custom_scep_proxy, ok := result["custom_scep_proxy"]; ok && custom_scep_proxy != nil { + for _, intg := range custom_scep_proxy.([]interface{}) { + intg.(map[string]interface{})["challenge"] = cmd.AddComment(filePath, "TODO: Add your custom SCEP proxy challenge here") + cmd.Messages.SecretWarnings = append(cmd.Messages.SecretWarnings, SecretWarning{ + Filename: "default.yml", + Key: "integrations.custom_scep_proxy.challenge", + }) + } + } + } + + return result, nil +} + +func (cmd *GenerateGitopsCommand) generateMDM(mdm *fleet.MDM) (map[string]interface{}, error) { + t := reflect.TypeOf(fleet.MDM{}) + result := map[string]interface{}{ + jsonFieldName(t, "AppleServerURL"): mdm.AppleServerURL, + jsonFieldName(t, "EndUserAuthentication"): mdm.EndUserAuthentication, + } + if cmd.AppConfig.License.IsPremium() { + result[jsonFieldName(t, "AppleBusinessManager")] = mdm.AppleBusinessManager + result[jsonFieldName(t, "VolumePurchasingProgram")] = mdm.VolumePurchasingProgram + } + + if !cmd.CLI.Bool("insecure") { + if auth, ok := result[jsonFieldName(t, "EndUserAuthentication")]; ok { + endUserAuth := auth.(fleet.MDMEndUserAuthentication) + if endUserAuth.Metadata != "" { + endUserAuth.Metadata = cmd.AddComment("default.yml", "TODO: Add your MDM end user auth metadata here") + cmd.Messages.SecretWarnings = append(cmd.Messages.SecretWarnings, SecretWarning{ + Filename: "default.yml", + Key: "mdm.end_user_authentication.metadata", + }) + } + if endUserAuth.MetadataURL != "" { + endUserAuth.MetadataURL = cmd.AddComment("default.yml", "TODO: Add your MDM end user auth metadata URL here") + cmd.Messages.SecretWarnings = append(cmd.Messages.SecretWarnings, SecretWarning{ + Filename: "default.yml", + Key: "mdm.end_user_authentication.metadata_url", + }) + } + result[jsonFieldName(t, "EndUserAuthentication")] = endUserAuth + } + } + return result, nil +} + +func (cmd *GenerateGitopsCommand) generateYaraRules(yaraRules []fleet.YaraRule) (map[string]interface{}, error) { + // TODC -- come up with a way to export Yara rules. + return map[string]interface{}{}, nil +} + +func (cmd *GenerateGitopsCommand) generateTeamSettings(filePath string, team *fleet.Team) (teamSettings map[string]interface{}, err error) { + t := reflect.TypeOf(fleet.TeamConfig{}) + teamSettings = map[string]interface{}{ + jsonFieldName(t, "Features"): team.Config.Features, + jsonFieldName(t, "HostExpirySettings"): team.Config.HostExpirySettings, + jsonFieldName(t, "WebhookSettings"): team.Config.WebhookSettings, + } + integrations, err := cmd.generateIntegrations(filePath, &GlobalOrTeamIntegrations{TeamIntegrations: &team.Config.Integrations}) + if err != nil { + return nil, err + } + teamSettings[jsonFieldName(t, "Integrations")] = integrations + // If --insecure is set, add real secrets. + if cmd.CLI.Bool("insecure") { + secrets := make([]map[string]string, len(team.Secrets)) + for i, spec := range team.Secrets { + secrets[i] = map[string]string{"secret": spec.Secret} + } + teamSettings["secrets"] = secrets + } else { + teamSettings["secrets"] = []map[string]string{{"secret": cmd.AddComment(filePath, "TODO: Add your enroll secrets here")}} + cmd.Messages.SecretWarnings = append(cmd.Messages.SecretWarnings, SecretWarning{ + Filename: filePath, + Key: "team_settings.secrets", + }) + } + return teamSettings, nil +} + +func (cmd *GenerateGitopsCommand) generateControls(teamId *uint, teamName string, teamMdm *fleet.TeamMDM) (map[string]interface{}, error) { + t := reflect.TypeOf(spec.GitOpsControls{}) + result := map[string]interface{}{} + + if teamId != nil { + scripts, err := cmd.generateScripts(teamId, teamName) + if err != nil { + fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error generating scripts: %s\n", err) + return nil, err + } + result[jsonFieldName(t, "Scripts")] = scripts + } + + profiles, err := cmd.generateProfiles(teamId, teamName) + if err != nil { + fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error generating profiles: %s\n", err) + return nil, err + } + if profiles != nil { + if len(profiles["apple_profiles"].([]map[string]interface{})) > 0 { + result[jsonFieldName(t, "MacOSSettings")] = map[string]interface{}{ + "custom_settings": profiles["apple_profiles"], + } + } + if len(profiles["windows_profiles"].([]map[string]interface{})) > 0 { + result[jsonFieldName(t, "WindowsSettings")] = map[string]interface{}{ + "custom_settings": profiles["windows_profiles"], + } + } + } + + if teamMdm != nil && cmd.AppConfig.License.IsPremium() { + mdmT := reflect.TypeOf(fleet.TeamMDM{}) + + result[jsonFieldName(mdmT, "EnableDiskEncryption")] = teamMdm.EnableDiskEncryption + result[jsonFieldName(mdmT, "MacOSUpdates")] = teamMdm.MacOSUpdates + result[jsonFieldName(mdmT, "IOSUpdates")] = teamMdm.IOSUpdates + result[jsonFieldName(mdmT, "IPadOSUpdates")] = teamMdm.IPadOSUpdates + result[jsonFieldName(mdmT, "WindowsUpdates")] = teamMdm.WindowsUpdates + + if teamId == nil || *teamId == 0 { + mdmT := reflect.TypeOf(fleet.MDM{}) + result[jsonFieldName(mdmT, "WindowsMigrationEnabled")] = cmd.AppConfig.MDM.WindowsMigrationEnabled + result[jsonFieldName(mdmT, "MacOSMigration")] = cmd.AppConfig.MDM.MacOSMigration + } + if cmd.AppConfig.MDM.WindowsEnabledAndConfigured { + result["windows_enabled_and_configured"] = cmd.AppConfig.MDM.WindowsEnabledAndConfigured + } + + // TODO -- add an IsSet() method to MacOSSSetup to encapsulate this logic. + if teamMdm.MacOSSetup.BootstrapPackage.Value != "" || teamMdm.MacOSSetup.EnableEndUserAuthentication || teamMdm.MacOSSetup.MacOSSetupAssistant.Value != "" || teamMdm.MacOSSetup.Script.Value != "" || (teamMdm.MacOSSetup.Software.Valid && len(teamMdm.MacOSSetup.Software.Value) > 0) { + result[jsonFieldName(mdmT, "MacOSSetup")] = "TODO: update with your macos_setup configuration" + cmd.Messages.Notes = append(cmd.Messages.Notes, Note{ + Filename: teamName, + Note: "The macos_setup configuration is not supported by this tool yet. To configure it, please follow the Fleet documentation at https://fleetdm.com/docs/configuration/yaml-files#macos-setup", + }) + } + } + + return result, nil +} + +func (cmd *GenerateGitopsCommand) generateProfiles(teamId *uint, teamName string) (map[string]interface{}, error) { + // Get profiles. + profiles, err := cmd.Client.ListConfigurationProfiles(teamId) + if err != nil { + if strings.Contains(err.Error(), fleet.MDMNotConfiguredMessage) { + return nil, nil + } + + fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error getting profiles: %v\n", err) + return nil, err + } + if len(profiles) == 0 { + return nil, nil + } + appleProfilesSlice := make([]map[string]interface{}, 0) + windowsProfilesSlice := make([]map[string]interface{}, 0) + for _, profile := range profiles { + profileSpec := map[string]interface{}{} + // Parse any labels. + if profile.LabelsIncludeAll != nil { + labels := make([]string, len(profile.LabelsIncludeAll)) + for i, label := range profile.LabelsIncludeAll { + labels[i] = label.LabelName + } + profileSpec["labels_include_all"] = labels + } + if profile.LabelsIncludeAny != nil { + labels := make([]string, len(profile.LabelsIncludeAny)) + for i, label := range profile.LabelsIncludeAny { + labels[i] = label.LabelName + } + profileSpec["labels_include_any"] = labels + } + if profile.LabelsExcludeAny != nil { + labels := make([]string, len(profile.LabelsExcludeAny)) + for i, label := range profile.LabelsExcludeAny { + labels[i] = label.LabelName + } + profileSpec["labels_exclude_any"] = labels + } + + // Download the profile contents. + profileContents, err := cmd.Client.GetProfileContents(profile.ProfileUUID) + if err != nil { + fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error getting profile contents: %s\n", err) + return nil, err + } + profileContentsString := string(profileContents) + + fileName := fmt.Sprintf("profiles/%s", generateProfileFilename(profile, profileContentsString)) + if teamId == nil { + fileName = fmt.Sprintf("lib/%s", fileName) + } else { + fileName = fmt.Sprintf("lib/%s/%s", teamName, fileName) + } + + cmd.FilesToWrite[fileName] = profileContentsString + var path string + if teamId == nil { + path = fmt.Sprintf("./%s", fileName) + } else { + path = fmt.Sprintf("../%s", fileName) + } + + profileSpec["path"] = path + + if profile.Platform == "darwin" { + appleProfilesSlice = append(appleProfilesSlice, profileSpec) + } else { + windowsProfilesSlice = append(windowsProfilesSlice, profileSpec) + } + } + + return map[string]interface{}{ + "apple_profiles": appleProfilesSlice, + "windows_profiles": windowsProfilesSlice, + }, nil +} + +func (cmd *GenerateGitopsCommand) generateScripts(teamId *uint, teamName string) ([]map[string]interface{}, error) { + // Get scripts. + query := "" + if teamId != nil { + query = fmt.Sprintf("team_id=%d", *teamId) + } + scripts, err := cmd.Client.ListScripts(query) + if err != nil { + fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error getting scripts: %s\n", err) + return nil, err + } + if len(scripts) == 0 { + return nil, nil + } + + scriptSlice := make([]map[string]interface{}, len(scripts)) + // For each script, get the contents and add a new file for output. + for i, script := range scripts { + fileName := fmt.Sprintf("scripts/%s", script.Name) + if teamId == nil { + fileName = fmt.Sprintf("lib/%s", fileName) + } else { + fileName = fmt.Sprintf("lib/%s/%s", teamName, fileName) + } + scriptContents, err := cmd.Client.GetScriptContents(script.ID) + if err != nil { + fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error getting script contents: %s\n", err) + return nil, err + } + cmd.FilesToWrite[fileName] = string(scriptContents) + var path string + if teamId == nil { + path = fmt.Sprintf("./%s", fileName) + } else { + path = fmt.Sprintf("../%s", fileName) + } + scriptSlice[i] = map[string]interface{}{ + "path": path, + } + cmd.ScriptList[script.ID] = path + } + return scriptSlice, nil +} + +func (cmd *GenerateGitopsCommand) generatePolicies(teamId *uint, filePath string) ([]map[string]interface{}, error) { + policies, err := cmd.Client.GetPolicies(teamId) + if err != nil { + fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error getting policies: %s\n", err) + return nil, err + } + if len(policies) == 0 { + return nil, nil + } + t := reflect.TypeOf(fleet.Policy{}) + result := make([]map[string]interface{}, len(policies)) + for i, policy := range policies { + policySpec := map[string]interface{}{ + jsonFieldName(t, "Name"): policy.Name, + jsonFieldName(t, "Description"): policy.Description, + jsonFieldName(t, "Resolution"): policy.Resolution, + jsonFieldName(t, "Query"): policy.Query, + jsonFieldName(t, "Platform"): policy.Platform, + jsonFieldName(t, "Critical"): policy.Critical, + jsonFieldName(t, "CalendarEventsEnabled"): policy.CalendarEventsEnabled, + } + // Handle software automation. + if policy.InstallSoftware != nil { + if software, ok := cmd.SoftwareList[policy.InstallSoftware.SoftwareTitleID]; ok { + policySpec["install_software"] = map[string]interface{}{ + "hash_sha256": software.Hash + " " + software.Comment, + } + } else { + policySpec["install_software"] = map[string]interface{}{ + "hash_sha256": cmd.AddComment(filePath, "TODO: Add your hash_sha256 here"), + } + cmd.Messages.Notes = append(cmd.Messages.Notes, Note{ + Filename: filePath, + Note: fmt.Sprintf("Warning: policy %s software (install_software) has no hash_sha256. This is required for GitOps to work. Please add the hash_sha256 manually.", policy.Name), + }) + } + } + // Handle script automation. + if policy.RunScript != nil { + if scriptPath, ok := cmd.ScriptList[policy.RunScript.ID]; ok { + policySpec["run_script"] = map[string]interface{}{ + "path": scriptPath, + } + } + } + // Parse any labels. + if policy.LabelsIncludeAny != nil { + labels := make([]string, len(policy.LabelsIncludeAny)) + for i, label := range policy.LabelsIncludeAny { + labels[i] = label.LabelName + } + policySpec["labels_include_any"] = labels + } + if policy.LabelsExcludeAny != nil { + labels := make([]string, len(policy.LabelsExcludeAny)) + for i, label := range policy.LabelsExcludeAny { + labels[i] = label.LabelName + } + policySpec["labels_exclude_any"] = labels + } + result[i] = policySpec + } + return result, nil +} + +func (cmd *GenerateGitopsCommand) generateQueries(teamId *uint) ([]map[string]interface{}, error) { + queries, err := cmd.Client.GetQueries(teamId, nil) + if err != nil { + fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error getting queries: %s\n", err) + return nil, err + } + if len(queries) == 0 { + return nil, nil + } + t := reflect.TypeOf(fleet.Query{}) + result := make([]map[string]interface{}, len(queries)) + for i, query := range queries { + querySpec := map[string]interface{}{ + jsonFieldName(t, "Name"): query.Name, + jsonFieldName(t, "Description"): query.Description, + jsonFieldName(t, "Query"): query.Query, + jsonFieldName(t, "Platform"): query.Platform, + jsonFieldName(t, "Interval"): query.Interval, + jsonFieldName(t, "ObserverCanRun"): query.ObserverCanRun, + jsonFieldName(t, "AutomationsEnabled"): query.AutomationsEnabled, + jsonFieldName(t, "MinOsqueryVersion"): query.MinOsqueryVersion, + jsonFieldName(t, "Logging"): query.Logging, + jsonFieldName(t, "DiscardData"): query.DiscardData, + } + + // Parse any labels. + if query.LabelsIncludeAny != nil { + labels := make([]string, len(query.LabelsIncludeAny)) + for i, label := range query.LabelsIncludeAny { + labels[i] = label.LabelName + } + querySpec["labels_include_any"] = labels + } + + result[i] = querySpec + } + return result, nil +} + +func (cmd *GenerateGitopsCommand) generateSoftware(filePath string, teamId uint, teamFilename string) (map[string]interface{}, error) { + query := fmt.Sprintf("available_for_install=1&team_id=%d", teamId) + software, err := cmd.Client.ListSoftwareTitles(query) + if err != nil { + fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error getting software: %s\n", err) + return nil, err + } + if len(software) == 0 { + return nil, nil + } + result := make(map[string]interface{}) + packages := make([]map[string]interface{}, 0) + appStoreApps := make([]map[string]interface{}, 0) + for _, sw := range software { + versions := make([]string, len(sw.Versions)) + for j, version := range sw.Versions { + versions[j] = version.Version + } + softwareSpec := make(map[string]interface{}) + switch { + case sw.SoftwarePackage != nil: + pkgName := "" + if sw.SoftwarePackage.Name != "" { + pkgName = fmt.Sprintf(" (%s)", sw.SoftwarePackage.Name) + } + comment := cmd.AddComment(filePath, fmt.Sprintf("%s%s version %s", sw.Name, pkgName, strings.Join(versions, ", "))) + if sw.HashSHA256 == nil { + cmd.Messages.Notes = append(cmd.Messages.Notes, Note{ + Filename: filePath, + Note: fmt.Sprintf("Warning: software %s has no hash_sha256. This is required for GitOps to work. Please add it manually.", sw.Name), + }) + softwareSpec["hash_sha256"] = cmd.AddComment(filePath, "TODO: Add your hash_sha256 here") + } else { + softwareSpec["hash_sha256"] = *sw.HashSHA256 + " " + comment + cmd.SoftwareList[sw.ID] = Software{ + Hash: *sw.HashSHA256, + Comment: comment, + } + } + case sw.AppStoreApp != nil: + softwareSpec["app_store_id"] = sw.AppStoreApp.AppStoreID + default: + fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error: software %s has no software package or app store app\n", sw.Name) + continue + } + + softwareTitle, err := cmd.Client.GetSoftwareTitleByID(sw.ID, &teamId) + if err != nil { + fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error getting software title %s: %s\n", sw.Name, err) + return nil, err + } + + if softwareTitle.SoftwarePackage != nil { + filenamePrefix := generateFilename(sw.Name) + "-" + sw.SoftwarePackage.Platform + if softwareTitle.SoftwarePackage.InstallScript != "" { + script := softwareTitle.SoftwarePackage.InstallScript + fileName := fmt.Sprintf("lib/%s/scripts/%s", teamFilename, filenamePrefix+"-install") + path := fmt.Sprintf("../%s", fileName) + softwareSpec["install_script"] = map[string]interface{}{ + "path": path, + } + cmd.FilesToWrite[fileName] = script + } + + if softwareTitle.SoftwarePackage.PostInstallScript != "" { + script := softwareTitle.SoftwarePackage.PostInstallScript + fileName := fmt.Sprintf("lib/%s/scripts/%s", teamFilename, filenamePrefix+"-postinstall") + path := fmt.Sprintf("../%s", fileName) + softwareSpec["post_install_script"] = map[string]interface{}{ + "path": path, + } + cmd.FilesToWrite[fileName] = script + } + + if softwareTitle.SoftwarePackage.UninstallScript != "" { + script := softwareTitle.SoftwarePackage.UninstallScript + fileName := fmt.Sprintf("lib/%s/scripts/%s", teamFilename, filenamePrefix+"-uninstall") + path := fmt.Sprintf("../%s", fileName) + softwareSpec["uninstall_script"] = map[string]interface{}{ + "path": path, + } + cmd.FilesToWrite[fileName] = script + } + + if softwareTitle.SoftwarePackage.PreInstallQuery != "" { + query := softwareTitle.SoftwarePackage.PreInstallQuery + fileName := fmt.Sprintf("lib/%s/queries/%s", teamFilename, filenamePrefix+"-preinstallquery.yml") + path := fmt.Sprintf("../%s", fileName) + softwareSpec["pre_install_query"] = map[string]interface{}{ + "path": path, + } + cmd.FilesToWrite[fileName] = []map[string]interface{}{{ + "query": query, + }} + } + + if softwareTitle.SoftwarePackage.SelfService { + softwareSpec["self_service"] = softwareTitle.SoftwarePackage.SelfService + } + } + + if cmd.AppConfig.License.IsPremium() { + var labels []fleet.SoftwareScopeLabel + var labelKey string + if softwareTitle.SoftwarePackage != nil { + if len(softwareTitle.SoftwarePackage.LabelsIncludeAny) > 0 { + labels = softwareTitle.SoftwarePackage.LabelsIncludeAny + labelKey = "labels_include_any" + } + if len(softwareTitle.SoftwarePackage.LabelsExcludeAny) > 0 { + labels = softwareTitle.SoftwarePackage.LabelsExcludeAny + labelKey = "labels_exclude_any" + } + } else { + if len(softwareTitle.AppStoreApp.LabelsIncludeAny) > 0 { + labels = softwareTitle.AppStoreApp.LabelsIncludeAny + labelKey = "labels_include_any" + } + if len(softwareTitle.AppStoreApp.LabelsExcludeAny) > 0 { + labels = softwareTitle.AppStoreApp.LabelsExcludeAny + labelKey = "labels_exclude_any" + } + } + if len(labels) > 0 { + labelsList := make([]string, len(labels)) + for i, label := range labels { + labelsList[i] = label.LabelName + } + softwareSpec[labelKey] = labelsList + } + } + + if sw.SoftwarePackage != nil { + packages = append(packages, softwareSpec) + } else { + appStoreApps = append(appStoreApps, softwareSpec) + } + } + if len(packages) > 0 { + result["packages"] = packages + } + if len(appStoreApps) > 0 { + result["app_store_apps"] = appStoreApps + } + // TODO -- add FMA apps to the result. Currently they will be output using hashes. + return result, nil +} + +func (cmd *GenerateGitopsCommand) generateLabels() ([]map[string]interface{}, error) { + labels, err := cmd.Client.GetLabels() + if err != nil { + fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error getting labels: %s\n", err) + return nil, err + } + if len(labels) == 0 { + return nil, nil + } + t := reflect.TypeOf(fleet.LabelSpec{}) + result := make([]map[string]interface{}, 0) + for _, label := range labels { + if label.LabelType != fleet.LabelTypeRegular { + continue + } + labelSpec := map[string]interface{}{ + jsonFieldName(t, "Name"): label.Name, + jsonFieldName(t, "Description"): label.Description, + jsonFieldName(t, "LabelMembershipType"): label.LabelMembershipType, + } + if label.Platform != "" { + labelSpec[jsonFieldName(t, "Platform")] = label.Platform + } + if label.LabelMembershipType == fleet.LabelMembershipTypeDynamic { + labelSpec[jsonFieldName(t, "Query")] = label.Query + } else { + labelSpec[jsonFieldName(t, "Hosts")] = label.Hosts + } + + result = append(result, labelSpec) + } + return result, nil +} + +var _ generateGitopsClient = (*service.Client)(nil) diff --git a/cmd/fleetctl/generate_gitops_test.go b/cmd/fleetctl/generate_gitops_test.go new file mode 100644 index 0000000000..8705d28bb8 --- /dev/null +++ b/cmd/fleetctl/generate_gitops_test.go @@ -0,0 +1,948 @@ +// filepath: cmd/fleetctl/generate_gitops_test.go +package main + +import ( + "bytes" + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/ghodss/yaml" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v2" +) + +type MockClient struct { + IsFree bool +} + +func (c *MockClient) GetAppConfig() (*fleet.EnrichedAppConfig, error) { + b, err := os.ReadFile("./testdata/generateGitops/appConfig.json") + if err != nil { + return nil, err + } + var appConfig fleet.EnrichedAppConfig + if err := json.Unmarshal(b, &appConfig); err != nil { + return nil, err + } + if c.IsFree == true { + appConfig.License.Tier = fleet.TierFree + } + return &appConfig, nil +} + +func (MockClient) GetEnrollSecretSpec() (*fleet.EnrollSecretSpec, error) { + spec := &fleet.EnrollSecretSpec{ + Secrets: []*fleet.EnrollSecret{ + { + Secret: "some-secret-number-one", + }, + { + Secret: "some-secret-number-two", + }, + }, + } + return spec, nil +} + +func (MockClient) ListTeams(query string) ([]fleet.Team, error) { + b, err := os.ReadFile("./testdata/generateGitops/teamConfig.json") + if err != nil { + return nil, err + } + var config fleet.TeamConfig + if err := json.Unmarshal(b, &config); err != nil { + return nil, err + } + teams := []fleet.Team{ + { + ID: 1, + Name: "Team A", + Config: config, + Secrets: []*fleet.EnrollSecret{ + { + Secret: "some-team-secret", + }, + }, + }, + } + return teams, nil +} + +func (MockClient) ListScripts(query string) ([]*fleet.Script, error) { + switch query { + case "team_id=1": + return []*fleet.Script{{ + ID: 2, + TeamID: ptr.Uint(1), + Name: "Script B.ps1", + ScriptContentID: 2, + }}, nil + case "team_id=0": + return []*fleet.Script{{ + ID: 3, + TeamID: ptr.Uint(0), + Name: "Script Z.ps1", + ScriptContentID: 3, + }}, nil + default: + return nil, fmt.Errorf("unexpected query: %s", query) + } +} + +func (MockClient) ListConfigurationProfiles(teamID *uint) ([]*fleet.MDMConfigProfilePayload, error) { + if teamID == nil { + return []*fleet.MDMConfigProfilePayload{ + { + ProfileUUID: "global-macos-mobileconfig-profile-uuid", + Name: "Global MacOS MobileConfig Profile", + Platform: "darwin", + Identifier: "com.example.global-macos-mobileconfig-profile", + LabelsIncludeAll: []fleet.ConfigurationProfileLabel{{ + LabelName: "Label A", + }, { + LabelName: "Label B", + }}, + }, + { + ProfileUUID: "global-macos-json-profile-uuid", + Name: "Global MacOS JSON Profile", + Platform: "darwin", + Identifier: "com.example.global-macos-json-profile", + LabelsExcludeAny: []fleet.ConfigurationProfileLabel{{ + LabelName: "Label C", + }}, + }, + { + ProfileUUID: "global-windows-profile-uuid", + Name: "Global Windows Profile", + Platform: "windows", + Identifier: "com.example.global-windows-profile", + LabelsIncludeAny: []fleet.ConfigurationProfileLabel{{ + LabelName: "Label D", + }}, + }, + }, nil + } + if *teamID == 1 { + return []*fleet.MDMConfigProfilePayload{ + { + ProfileUUID: "test-mobileconfig-profile-uuid", + Name: "Team MacOS MobileConfig Profile", + Platform: "darwin", + Identifier: "com.example.team-macos-mobileconfig-profile", + }, + }, nil + } + if *teamID == 0 { + return nil, nil + } + return nil, fmt.Errorf("unexpected team ID: %v", *teamID) +} + +func (MockClient) GetScriptContents(scriptID uint) ([]byte, error) { + if scriptID == 2 { + return []byte("pop goes the weasel!"), nil + } + if scriptID == 3 { + return []byte("#!/usr/bin/env pwsh\necho \"Hello from Script B!\""), nil + } + return nil, errors.New("script not found") +} + +func (MockClient) GetProfileContents(profileID string) ([]byte, error) { + switch profileID { + case "global-macos-mobileconfig-profile-uuid": + return []byte("global macos mobileconfig profile"), nil + case "global-macos-json-profile-uuid": + return []byte(`{"profile": "global macos json profile"}`), nil + case "global-windows-profile-uuid": + return []byte("global windows profile"), nil + case "test-mobileconfig-profile-uuid": + return []byte("test mobileconfig profile"), nil + } + return nil, errors.New("profile not found") +} + +func (MockClient) GetTeam(teamID uint) (*fleet.Team, error) { + if teamID == 1 { + b, err := os.ReadFile("./testdata/generateGitops/teamConfig.json") + if err != nil { + return nil, err + } + var config fleet.TeamConfig + if err := json.Unmarshal(b, &config); err != nil { + return nil, err + } + return &fleet.Team{ + ID: 1, + Name: "Test Team", + Config: config, + Secrets: []*fleet.EnrollSecret{ + { + Secret: "some-team-secret", + }, + }, + }, nil + } + + return nil, errors.New("team not found") +} + +func (MockClient) ListSoftwareTitles(query string) ([]fleet.SoftwareTitleListResult, error) { + switch query { + case "available_for_install=1&team_id=1": + return []fleet.SoftwareTitleListResult{ + { + ID: 1, + Name: "My Software Package", + Versions: []fleet.SoftwareVersion{{ + ID: 1, + Version: "1.0.0", + }, { + ID: 2, + Version: "2.0.0", + }}, + HashSHA256: ptr.String("software-package-hash"), + SoftwarePackage: &fleet.SoftwarePackageOrApp{ + Name: "my-software.pkg", + Platform: "darwin", + }, + }, + { + ID: 2, + Name: "My App Store App", + Versions: []fleet.SoftwareVersion{{ + ID: 3, + Version: "5.6.7", + }, { + ID: 4, + Version: "8.9.10", + }}, + AppStoreApp: &fleet.SoftwarePackageOrApp{ + AppStoreID: "com.example.team-software", + }, + HashSHA256: ptr.String("app-store-app-hash"), + }, + }, nil + case "available_for_install=1&team_id=0": + return []fleet.SoftwareTitleListResult{}, nil + default: + return nil, fmt.Errorf("unexpected query: %s", query) + } +} + +func (MockClient) GetPolicies(teamID *uint) ([]*fleet.Policy, error) { + if teamID == nil { + return []*fleet.Policy{ + { + PolicyData: fleet.PolicyData{ + ID: 1, + Name: "Global Policy", + Query: "SELECT * FROM global_policy WHERE id = 1", + Resolution: ptr.String("Do a global thing"), + Description: "This is a global policy", + Platform: "darwin", + LabelsIncludeAny: []fleet.LabelIdent{{ + LabelName: "Label A", + }, { + LabelName: "Label B", + }}, + }, + InstallSoftware: &fleet.PolicySoftwareTitle{ + SoftwareTitleID: 1, + }, + }, + }, nil + } + return []*fleet.Policy{ + { + PolicyData: fleet.PolicyData{ + ID: 1, + Name: "Team Policy", + Query: "SELECT * FROM team_policy WHERE id = 1", + Resolution: ptr.String("Do a team thing"), + Description: "This is a team policy", + Platform: "linux,windows", + }, + RunScript: &fleet.PolicyScript{ + ID: 1, + }, + }, + }, nil +} + +func (MockClient) GetQueries(teamID *uint, name *string) ([]fleet.Query, error) { + if teamID == nil { + return []fleet.Query{ + { + ID: 1, + Name: "Global Query", + Query: "SELECT * FROM global_query WHERE id = 1", + Description: "This is a global query", + Platform: "darwin", + Interval: 3600, + ObserverCanRun: true, + AutomationsEnabled: true, + LabelsIncludeAny: []fleet.LabelIdent{{ + LabelName: "Label A", + }, { + LabelName: "Label B", + }}, + MinOsqueryVersion: "1.2.3", + Logging: "stdout", + }, + }, nil + } + return []fleet.Query{ + { + ID: 1, + Name: "Team Query", + Query: "SELECT * FROM team_query WHERE id = 1", + Description: "This is a team query", + Platform: "linux,windows", + Interval: 1800, + ObserverCanRun: false, + AutomationsEnabled: true, + MinOsqueryVersion: "4.5.6", + Logging: "stderr", + }, + }, nil +} + +//nolint:gocritic // ignore captLocal +func (MockClient) GetSoftwareTitleByID(ID uint, teamID *uint) (*fleet.SoftwareTitle, error) { + switch ID { + case 1: + if *teamID != 1 { + return nil, errors.New("team ID mismatch") + } + return &fleet.SoftwareTitle{ + ID: 1, + SoftwarePackage: &fleet.SoftwareInstaller{ + LabelsIncludeAny: []fleet.SoftwareScopeLabel{{ + LabelName: "Label A", + }, { + LabelName: "Label B", + }}, + PreInstallQuery: "SELECT * FROM pre_install_query", + InstallScript: "foo", + PostInstallScript: "bar", + UninstallScript: "baz", + SelfService: true, + Platform: "darwin", + }, + }, nil + case 2: + if *teamID != 1 { + return nil, errors.New("team ID mismatch") + } + return &fleet.SoftwareTitle{ + ID: 2, + AppStoreApp: &fleet.VPPAppStoreApp{ + LabelsExcludeAny: []fleet.SoftwareScopeLabel{{ + LabelName: "Label C", + }, { + LabelName: "Label D", + }}, + }, + }, nil + default: + return nil, errors.New("software title not found") + } +} + +func (MockClient) GetLabels() ([]*fleet.LabelSpec, error) { + return []*fleet.LabelSpec{{ + Name: "Label A", + Platform: "linux,macos", + Description: "Label A description", + LabelMembershipType: fleet.LabelMembershipTypeDynamic, + Query: "SELECT * FROM osquery_info", + }, { + Name: "Label B", + Description: "Label B description", + LabelMembershipType: fleet.LabelMembershipTypeManual, + Hosts: []string{"host1", "host2"}, + }}, nil +} + +func (MockClient) Me() (*fleet.User, error) { + return &fleet.User{ + ID: 1, + Name: "Test User", + Email: "test@example.com", + GlobalRole: ptr.String("admin"), + }, nil +} + +func compareDirs(t *testing.T, sourceDir, targetDir string) { + err := filepath.WalkDir(sourceDir, func(srcPath string, d os.DirEntry, walkErr error) error { + if d.IsDir() { + return nil + } + + relPath, err := filepath.Rel(sourceDir, srcPath) + require.NoError(t, err, "Error getting relative path: %v", err) + + tgtPath := filepath.Join(targetDir, relPath) + + targetInfo, err := os.Stat(tgtPath) + require.NoError(t, err, "Error getting target file info: %v", err) + require.False(t, targetInfo.IsDir(), "Expected file but found directory: %s", tgtPath) + + srcData, err := os.ReadFile(srcPath) + require.NoError(t, err, "Error reading source file: %v", err) + + tgtData, err := os.ReadFile(tgtPath) + require.NoError(t, err, "Error reading target file: %v", err) + + require.Equal(t, string(srcData), string(tgtData), "File contents do not match for %s", relPath) + + return nil + }) + if err != nil { + t.Fatalf("Error walking source directory: %v", err) + } +} + +func TestGenerateGitops(t *testing.T) { + fleetClient := &MockClient{} + action := createGenerateGitopsAction(fleetClient) + buf := new(bytes.Buffer) + tempDir := os.TempDir() + "/" + uuid.New().String() + flagSet := flag.NewFlagSet("test", flag.ContinueOnError) + flagSet.String("dir", tempDir, "") + + cliContext := cli.NewContext(&cli.App{ + Name: "test", + Usage: "test", + Writer: buf, + ErrWriter: buf, + }, flagSet, nil) + err := action(cliContext) + require.NoError(t, err, buf.String()) + + compareDirs(t, "./testdata/generateGitops/test_dir_premium", tempDir) + + t.Cleanup(func() { + if err := os.RemoveAll(tempDir); err != nil { + t.Fatalf("failed to remove temp dir: %v", err) + } + }) +} + +func TestGenerateGitopsFree(t *testing.T) { + fleetClient := &MockClient{} + fleetClient.IsFree = true + action := createGenerateGitopsAction(fleetClient) + buf := new(bytes.Buffer) + tempDir := os.TempDir() + "/" + uuid.New().String() + flagSet := flag.NewFlagSet("test", flag.ContinueOnError) + flagSet.String("dir", tempDir, "") + + cliContext := cli.NewContext(&cli.App{ + Name: "test", + Usage: "test", + Writer: buf, + ErrWriter: buf, + }, flagSet, nil) + err := action(cliContext) + require.NoError(t, err, buf.String()) + + compareDirs(t, "./testdata/generateGitops/test_dir_free", tempDir) + + t.Cleanup(func() { + if err := os.RemoveAll(tempDir); err != nil { + t.Fatalf("failed to remove temp dir: %v", err) + } + }) +} + +func TestGenerateOrgSettings(t *testing.T) { + // Get the test app config. + fleetClient := &MockClient{} + appConfig, err := fleetClient.GetAppConfig() + require.NoError(t, err) + + // Create the command. + cmd := &GenerateGitopsCommand{ + Client: fleetClient, + CLI: cli.NewContext(&cli.App{}, nil, nil), + Messages: Messages{}, + FilesToWrite: make(map[string]interface{}), + AppConfig: appConfig, + } + + // Generate the org settings. + // Note that nested keys here may be strings, + // so we'll JSON marshal and unmarshal to a map for comparison. + orgSettingsRaw, err := cmd.generateOrgSettings() + require.NoError(t, err) + require.NotNil(t, orgSettingsRaw) + var orgSettings map[string]interface{} + b, err := yaml.Marshal(orgSettingsRaw) + require.NoError(t, err) + fmt.Println("Org settings raw:\n", string(b)) // Debugging line + err = yaml.Unmarshal(b, &orgSettings) + require.NoError(t, err) + + // Get the expected org settings YAML. + b, err = os.ReadFile("./testdata/generateGitops/expectedOrgSettings.yaml") + require.NoError(t, err) + var expectedAppConfig map[string]interface{} + err = yaml.Unmarshal(b, &expectedAppConfig) + require.NoError(t, err) + + // Compare. + require.Equal(t, expectedAppConfig, orgSettings) +} + +func TestGenerateOrgSettingsInsecure(t *testing.T) { + // Get the test app config. + fleetClient := &MockClient{} + appConfig, err := fleetClient.GetAppConfig() + require.NoError(t, err) + + flagSet := flag.NewFlagSet("test", flag.ContinueOnError) + flagSet.Bool("insecure", true, "Output sensitive information in plaintext.") + // Create the command. + cmd := &GenerateGitopsCommand{ + Client: fleetClient, + CLI: cli.NewContext(&cli.App{}, flagSet, nil), + Messages: Messages{}, + FilesToWrite: make(map[string]interface{}), + AppConfig: appConfig, + } + + // Generate the org settings. + // Note that nested keys here may be strings, + // so we'll JSON marshal and unmarshal to a map for comparison. + orgSettingsRaw, err := cmd.generateOrgSettings() + require.NoError(t, err) + require.NotNil(t, orgSettingsRaw) + var orgSettings map[string]interface{} + b, err := yaml.Marshal(orgSettingsRaw) + require.NoError(t, err) + fmt.Println("Org settings raw:\n", string(b)) // Debugging line + err = yaml.Unmarshal(b, &orgSettings) + require.NoError(t, err) + + // Get the expected org settings YAML. + b, err = os.ReadFile("./testdata/generateGitops/expectedOrgSettings-insecure.yaml") + require.NoError(t, err) + var expectedAppConfig map[string]interface{} + err = yaml.Unmarshal(b, &expectedAppConfig) + require.NoError(t, err) + + // Compare. + require.Equal(t, expectedAppConfig, orgSettings) +} + +func TestGenerateTeamSettings(t *testing.T) { + // Get the test team. + fleetClient := &MockClient{} + team, err := fleetClient.GetTeam(1) + require.NoError(t, err) + + // Create the command. + cmd := &GenerateGitopsCommand{ + Client: fleetClient, + CLI: cli.NewContext(&cli.App{}, nil, nil), + Messages: Messages{}, + FilesToWrite: make(map[string]interface{}), + AppConfig: nil, + } + + // Generate the org settings. + // Note that nested keys here may be strings, + // so we'll JSON marshal and unmarshal to a map for comparison. + TeamSettingsRaw, err := cmd.generateTeamSettings("team.yml", team) + require.NoError(t, err) + require.NotNil(t, TeamSettingsRaw) + var TeamSettings map[string]interface{} + b, err := yaml.Marshal(TeamSettingsRaw) + require.NoError(t, err) + fmt.Println("Team settings raw:\n", string(b)) // Debugging line + err = yaml.Unmarshal(b, &TeamSettings) + require.NoError(t, err) + + // Get the expected org settings YAML. + b, err = os.ReadFile("./testdata/generateGitops/expectedTeamSettings.yaml") + require.NoError(t, err) + var expectedAppConfig map[string]interface{} + err = yaml.Unmarshal(b, &expectedAppConfig) + require.NoError(t, err) + + // Compare. + require.Equal(t, expectedAppConfig, TeamSettings) +} + +func TestGenerateTeamSettingsInsecure(t *testing.T) { + // Get the test team. + fleetClient := &MockClient{} + team, err := fleetClient.GetTeam(1) + require.NoError(t, err) + + flagSet := flag.NewFlagSet("test", flag.ContinueOnError) + flagSet.Bool("insecure", true, "Output sensitive information in plaintext.") + // Create the command. + cmd := &GenerateGitopsCommand{ + Client: fleetClient, + CLI: cli.NewContext(&cli.App{}, flagSet, nil), + Messages: Messages{}, + FilesToWrite: make(map[string]interface{}), + AppConfig: nil, + } + + // Generate the org settings. + // Note that nested keys here may be strings, + // so we'll JSON marshal and unmarshal to a map for comparison. + TeamSettingsRaw, err := cmd.generateTeamSettings("team.yml", team) + require.NoError(t, err) + require.NotNil(t, TeamSettingsRaw) + var TeamSettings map[string]interface{} + b, err := yaml.Marshal(TeamSettingsRaw) + require.NoError(t, err) + fmt.Println("Team settings raw:\n", string(b)) // Debugging line + err = yaml.Unmarshal(b, &TeamSettings) + require.NoError(t, err) + + // Get the expected org settings YAML. + b, err = os.ReadFile("./testdata/generateGitops/expectedTeamSettings-insecure.yaml") + require.NoError(t, err) + var expectedAppConfig map[string]interface{} + err = yaml.Unmarshal(b, &expectedAppConfig) + require.NoError(t, err) + + // Compare. + require.Equal(t, expectedAppConfig, TeamSettings) +} + +func TestGenerateControls(t *testing.T) { + // Get the test app config. + fleetClient := &MockClient{} + appConfig, err := fleetClient.GetAppConfig() + require.NoError(t, err) + + // Create the command. + cmd := &GenerateGitopsCommand{ + Client: fleetClient, + CLI: cli.NewContext(cli.NewApp(), nil, nil), + Messages: Messages{}, + FilesToWrite: make(map[string]interface{}), + AppConfig: appConfig, + ScriptList: make(map[uint]string), + } + + // Generate global controls. + // Note that nested keys here may be strings, + // so we'll JSON marshal and unmarshal to a map for comparison. + mdmConfig := fleet.TeamMDM{ + EnableDiskEncryption: appConfig.MDM.EnableDiskEncryption.Value, + MacOSUpdates: appConfig.MDM.MacOSUpdates, + IOSUpdates: appConfig.MDM.IOSUpdates, + IPadOSUpdates: appConfig.MDM.IPadOSUpdates, + WindowsUpdates: appConfig.MDM.WindowsUpdates, + MacOSSetup: appConfig.MDM.MacOSSetup, + } + controlsRaw, err := cmd.generateControls(nil, "", &mdmConfig) + require.NoError(t, err) + require.NotNil(t, controlsRaw) + var controls map[string]interface{} + b, err := yaml.Marshal(controlsRaw) + require.NoError(t, err) + fmt.Println("Controls raw:\n", string(b)) // Debugging line + err = yaml.Unmarshal(b, &controls) + require.NoError(t, err) + + // Get the expected org settings YAML. + b, err = os.ReadFile("./testdata/generateGitops/expectedGlobalControls.yaml") + require.NoError(t, err) + var expectedControls map[string]interface{} + err = yaml.Unmarshal(b, &expectedControls) + require.NoError(t, err) + + // Compare. + require.Equal(t, expectedControls, controls) + + // Generate controls for a team. + // Note that nested keys here may be strings, + // so we'll JSON marshal and unmarshal to a map for comparison. + controlsRaw, err = cmd.generateControls(ptr.Uint(1), "some_team", nil) + require.NoError(t, err) + require.NotNil(t, controlsRaw) + b, err = yaml.Marshal(controlsRaw) + require.NoError(t, err) + fmt.Println("Controls raw:\n", string(b)) // Debugging line + err = yaml.Unmarshal(b, &controls) + require.NoError(t, err) + + // Get the expected org settings YAML. + b, err = os.ReadFile("./testdata/generateGitops/expectedTeamControls.yaml") + require.NoError(t, err) + err = yaml.Unmarshal(b, &expectedControls) + require.NoError(t, err) + + if fileContents, ok := cmd.FilesToWrite["lib/profiles/global-macos-mobileconfig-profile.mobileconfig"]; ok { + require.Equal(t, "global macos mobileconfig profile", fileContents) + } else { + t.Fatalf("Expected file not found") + } + + if fileContents, ok := cmd.FilesToWrite["lib/profiles/global-macos-json-profile.json"]; ok { + require.Equal(t, `{"profile": "global macos json profile"}`, fileContents) + } else { + t.Fatalf("Expected file not found") + } + + if fileContents, ok := cmd.FilesToWrite["lib/profiles/global-windows-profile.xml"]; ok { + require.Equal(t, "global windows profile", fileContents) + } else { + t.Fatalf("Expected file not found") + } + + if fileContents, ok := cmd.FilesToWrite["lib/some_team/profiles/team-macos-mobileconfig-profile.mobileconfig"]; ok { + require.Equal(t, "test mobileconfig profile", fileContents) + } else { + t.Fatalf("Expected file not found") + } + + if fileContents, ok := cmd.FilesToWrite["lib/some_team/scripts/Script B.ps1"]; ok { + require.Equal(t, "pop goes the weasel!", fileContents) + } else { + t.Fatalf("Expected file not found") + } + + // Compare. + require.Equal(t, expectedControls, controls) +} + +func TestGenerateSoftware(t *testing.T) { + // Get the test app config. + fleetClient := &MockClient{} + appConfig, err := fleetClient.GetAppConfig() + require.NoError(t, err) + + // Create the command. + cmd := &GenerateGitopsCommand{ + Client: fleetClient, + CLI: cli.NewContext(cli.NewApp(), nil, nil), + Messages: Messages{}, + FilesToWrite: make(map[string]interface{}), + AppConfig: appConfig, + SoftwareList: make(map[uint]Software), + } + + softwareRaw, err := cmd.generateSoftware("team.yml", 1, "some-team") + require.NoError(t, err) + require.NotNil(t, softwareRaw) + var software map[string]interface{} + b, err := yaml.Marshal(softwareRaw) + require.NoError(t, err) + fmt.Println("software raw:\n", string(b)) // Debugging line + err = yaml.Unmarshal(b, &software) + require.NoError(t, err) + + // Get the expected org settings YAML. + b, err = os.ReadFile("./testdata/generateGitops/expectedTeamSoftware.yaml") + require.NoError(t, err) + var expectedSoftware map[string]interface{} + err = yaml.Unmarshal(b, &expectedSoftware) + require.NoError(t, err) + + // Compare. + require.Equal(t, expectedSoftware, software) + + if fileContents, ok := cmd.FilesToWrite["lib/some-team/scripts/my-software-package-darwin-install"]; ok { + require.Equal(t, "foo", fileContents) + } else { + t.Fatalf("Expected file not found") + } + + if fileContents, ok := cmd.FilesToWrite["lib/some-team/scripts/my-software-package-darwin-postinstall"]; ok { + require.Equal(t, "bar", fileContents) + } else { + t.Fatalf("Expected file not found") + } + + if fileContents, ok := cmd.FilesToWrite["lib/some-team/scripts/my-software-package-darwin-uninstall"]; ok { + require.Equal(t, "baz", fileContents) + } else { + t.Fatalf("Expected file not found") + } + + if fileContents, ok := cmd.FilesToWrite["lib/some-team/queries/my-software-package-darwin-preinstallquery.yml"]; ok { + require.Equal(t, []map[string]interface{}{{ + "query": "SELECT * FROM pre_install_query", + }}, fileContents) + } else { + t.Fatalf("Expected file not found") + } +} + +func TestGeneratePolicies(t *testing.T) { + // Get the test app config. + fleetClient := &MockClient{} + appConfig, err := fleetClient.GetAppConfig() + require.NoError(t, err) + + // Create the command. + cmd := &GenerateGitopsCommand{ + Client: fleetClient, + CLI: cli.NewContext(cli.NewApp(), nil, nil), + Messages: Messages{}, + FilesToWrite: make(map[string]interface{}), + AppConfig: appConfig, + SoftwareList: map[uint]Software{ + 1: { + Hash: "team-software-hash", + Comment: "__TEAM_SOFTWARE_COMMENT_TOKEN__", + }, + }, + ScriptList: map[uint]string{ + 1: "/path/to/script1.sh", + }, + } + + policiesRaw, err := cmd.generatePolicies(nil, "default.yml") + require.NoError(t, err) + require.NotNil(t, policiesRaw) + var policies []map[string]interface{} + b, err := yaml.Marshal(policiesRaw) + require.NoError(t, err) + fmt.Println("policies raw:\n", string(b)) // Debugging line + err = yaml.Unmarshal(b, &policies) + require.NoError(t, err) + + // Get the expected org settings YAML. + b, err = os.ReadFile("./testdata/generateGitops/expectedGlobalPolicies.yaml") + require.NoError(t, err) + var expectedPolicies []map[string]interface{} + err = yaml.Unmarshal(b, &expectedPolicies) + require.NoError(t, err) + + // Compare. + require.Equal(t, expectedPolicies, policies) + + // Generate policies for a team. + // Note that nested keys here may be strings, + // so we'll JSON marshal and unmarshal to a map for comparison. + policiesRaw, err = cmd.generatePolicies(ptr.Uint(1), "some_team") + require.NoError(t, err) + require.NotNil(t, policiesRaw) + b, err = yaml.Marshal(policiesRaw) + require.NoError(t, err) + fmt.Println("policies raw:\n", string(b)) // Debugging line + err = yaml.Unmarshal(b, &policies) + require.NoError(t, err) + + // Get the expected org settings YAML. + b, err = os.ReadFile("./testdata/generateGitops/expectedTeamPolicies.yaml") + require.NoError(t, err) + err = yaml.Unmarshal(b, &expectedPolicies) + require.NoError(t, err) + + // Compare. + require.Equal(t, expectedPolicies, policies) +} + +func TestGenerateQueries(t *testing.T) { + // Get the test app config. + fleetClient := &MockClient{} + appConfig, err := fleetClient.GetAppConfig() + require.NoError(t, err) + + // Create the command. + cmd := &GenerateGitopsCommand{ + Client: fleetClient, + CLI: cli.NewContext(cli.NewApp(), nil, nil), + Messages: Messages{}, + FilesToWrite: make(map[string]interface{}), + AppConfig: appConfig, + } + + queriesRaw, err := cmd.generateQueries(nil) + require.NoError(t, err) + require.NotNil(t, queriesRaw) + var queries []map[string]interface{} + b, err := yaml.Marshal(queriesRaw) + require.NoError(t, err) + fmt.Println("queries raw:\n", string(b)) // Debugging line + err = yaml.Unmarshal(b, &queries) + require.NoError(t, err) + + // Get the expected org settings YAML. + b, err = os.ReadFile("./testdata/generateGitops/expectedGlobalQueries.yaml") + require.NoError(t, err) + var expectedQueries []map[string]interface{} + err = yaml.Unmarshal(b, &expectedQueries) + require.NoError(t, err) + + // Compare. + require.Equal(t, expectedQueries, queries) + + // Generate queries for a team. + // Note that nested keys here may be strings, + // so we'll JSON marshal and unmarshal to a map for comparison. + queriesRaw, err = cmd.generateQueries(ptr.Uint(1)) + require.NoError(t, err) + require.NotNil(t, queriesRaw) + b, err = yaml.Marshal(queriesRaw) + require.NoError(t, err) + fmt.Println("queries raw:\n", string(b)) // Debugging line + err = yaml.Unmarshal(b, &queries) + require.NoError(t, err) + + // Get the expected org settings YAML. + b, err = os.ReadFile("./testdata/generateGitops/expectedTeamQueries.yaml") + require.NoError(t, err) + err = yaml.Unmarshal(b, &expectedQueries) + require.NoError(t, err) + + // Compare. + require.Equal(t, expectedQueries, queries) +} + +func TestGenerateLabels(t *testing.T) { + // Get the test app config. + fleetClient := &MockClient{} + appConfig, err := fleetClient.GetAppConfig() + require.NoError(t, err) + + // Create the command. + cmd := &GenerateGitopsCommand{ + Client: fleetClient, + CLI: cli.NewContext(cli.NewApp(), nil, nil), + Messages: Messages{}, + FilesToWrite: make(map[string]interface{}), + AppConfig: appConfig, + } + + labelsRaw, err := cmd.generateLabels() + require.NoError(t, err) + require.NotNil(t, labelsRaw) + var labels []map[string]interface{} + b, err := yaml.Marshal(labelsRaw) + require.NoError(t, err) + fmt.Println("labels raw:\n", string(b)) // Debugging line + err = yaml.Unmarshal(b, &labels) + require.NoError(t, err) + + // Get the expected org settings YAML. + b, err = os.ReadFile("./testdata/generateGitops/expectedLabels.yaml") + require.NoError(t, err) + var expectedlabels []map[string]interface{} + err = yaml.Unmarshal(b, &expectedlabels) + require.NoError(t, err) + + // Compare. + require.Equal(t, expectedlabels, labels) +} diff --git a/cmd/fleetctl/testdata/generateGitops/appConfig.json b/cmd/fleetctl/testdata/generateGitops/appConfig.json new file mode 100644 index 0000000000..5974075695 --- /dev/null +++ b/cmd/fleetctl/testdata/generateGitops/appConfig.json @@ -0,0 +1,308 @@ +{ + "update_interval": { + "osquery_detail": 3600000000000, + "osquery_policy": 3600000000000 + }, + "vulnerabilities": { + "databases_path": "/home/fleet", + "periodicity": 3600000000000, + "cpe_database_url": "", + "cpe_translations_url": "", + "cve_feed_prefix_url": "", + "current_instance_checks": "auto", + "disable_data_sync": false, + "recent_vulnerability_max_age": 2592000000000000, + "disable_win_os_vulnerabilities": false + }, + "license": { + "tier": "premium", + "organization": "fleet", + "device_count": 1000000, + "expiration": "2031-10-16T00:00:00Z", + "note": "dogfood env license" + }, + "logging": { + "debug": true, + "json": true, + "result": { + "plugin": "firehose", + "config": { + "region": "us-east-2", + "status_stream": "osquery_status", + "result_stream": "osquery_results", + "audit_stream": "fleet_audit" + } + }, + "status": { + "plugin": "firehose", + "config": { + "region": "us-east-2", + "status_stream": "osquery_status", + "result_stream": "osquery_results", + "audit_stream": "fleet_audit" + } + }, + "audit": { + "plugin": "firehose", + "config": { + "region": "us-east-2", + "status_stream": "osquery_status", + "result_stream": "osquery_results", + "audit_stream": "fleet_audit" + } + } + }, + "email": { + "backend": "ses", + "config": { + "region": "", + "source_arn": "some-ses-arn" + } + }, + "android_enabled": true, + "org_info": { + "org_name": "Fleet", + "org_logo_url": "http://some-org-logo-url.com", + "org_logo_url_light_background": "http://some-org-logo-url-light-background.com", + "contact_url": "https://fleetdm.com/company/contact" + }, + "server_settings": { + "server_url": "https://dogfood.fleetdm.com", + "live_query_disabled": false, + "enable_analytics": true, + "debug_host_ids": [ + 1, + 3 + ], + "deferred_save_host": false, + "query_reports_disabled": false, + "scripts_disabled": false, + "ai_features_disabled": false, + "query_report_cap": 1 + }, + "smtp_settings": { + "enable_smtp": false, + "configured": false, + "sender_address": "", + "server": "localhost", + "port": 587, + "authentication_type": "authtype_username_password", + "user_name": "", + "password": "", + "enable_ssl_tls": false, + "authentication_method": "authmethod_plain", + "domain": "", + "verify_ssl_certs": false, + "enable_start_tls": false + }, + "host_expiry_settings": { + "host_expiry_enabled": false, + "host_expiry_window": 59995 + }, + "activity_expiry_settings": { + "activity_expiry_enabled": false, + "activity_expiry_window": 30 + }, + "features": { + "enable_host_users": true, + "enable_software_inventory": true, + "additional_queries": { + "time": "SELECT * FROM time", + "macs": "SELECT mac FROM interface_details" + }, + "detail_query_overrides": { + "users": null, + "mdm": "SELECT enrolled, server_url, installed_from_dep, payload_identifier FROM mdm;" + } + }, + "agent_options": { + "config": { + "options": { + "pack_delimiter": "/", + "logger_tls_period": 10, + "distributed_plugin": "tls", + "disable_distributed": false, + "logger_tls_endpoint": "/api/osquery/log", + "distributed_interval": 10, + "distributed_tls_max_attempts": 3 + }, + "decorators": { + "load": [ + "SELECT uuid AS host_uuid FROM system_info;", + "SELECT hostname AS hostname FROM system_info;" + ] + } + } + }, + "sso_settings": { + "entity_id": "dogfood.fleetdm.com", + "issuer_uri": "https://some-sso-issuer-uri.com", + "metadata": "some-sso-metadata", + "metadata_url": "http://some-sso-metadata-url.com", + "idp_name": "some-idp-name", + "idp_image_url": "http://some-sso-idp-image-url.com", + "enable_sso": true, + "enable_sso_idp_login": false, + "enable_jit_provisioning": true, + "enable_jit_role_sync": false + }, + "fleet_desktop": { + "transparency_url": "https://fleetdm.com/transparency" + }, + "vulnerability_settings": { + "databases_path": "" + }, + "webhook_settings": { + "activities_webhook": { + "enable_activities_webhook": true, + "destination_url": "https://some-activities-webhook-url.com" + }, + "host_status_webhook": { + "enable_host_status_webhook": true, + "destination_url": "https://some-host-status-webhook-url.com", + "host_percentage": 20, + "days_count": 5 + }, + "failing_policies_webhook": { + "enable_failing_policies_webhook": true, + "destination_url": "https://some-failing-policies-webhook-url.com", + "policy_ids": [], + "host_batch_size": 2 + }, + "vulnerabilities_webhook": { + "enable_vulnerabilities_webhook": true, + "destination_url": "https://some-vulerabilities-webhook-url.com", + "host_batch_size": 3 + }, + "interval": "6h0m0s" + }, + "integrations": { + "jira": [ + { + "url": "https://some-jira-url.com", + "username": "some-jira-username", + "api_token": "some-jira-api-token", + "project_key": "some-jira-project-key" + } + ], + "zendesk": [ + { + "url": "https://some-zendesk-url.com", + "email": "some-zendesk-email@example.com", + "api_token": "some-zendesk-api-token", + "group_id": 123456789 + } + ], + "google_calendar": [ + { + "domain": "fleetdm.com", + "api_key_json": { + "owl": "hoot" + } + } + ], + "digicert": [ + { + "name": "some-digicert-name", + "url": "https://some-digicert-url.com", + "api_token": "some-digicert-api-token", + "profile_id": "some-digicert-profile-id", + "certificate_common_name": "some-digicert-certificate-common-name", + "certificate_user_principal_names": [ + "some-digicert-certificate-user-principal-name", + "some-other-digicert-certificate-user-principal-name" + ], + "certificate_seat_id": "some-digicert-certificate-seat-id" + } + ], + "ndes_scep_proxy": { + "url": "https://some-ndes-scep-proxy-url.com", + "admin_url": "https://some-ndes-admin-url.com", + "username": "some-ndes-username", + "password": "some-ndes-password" + }, + "custom_scep_proxy": [ + { + "name": "some-custom-scep-proxy-name", + "url": "https://some-custom-scep-proxy-url.com", + "challenge": "some-custom-scep-proxy-challenge" + } + ] + }, + "mdm": { + "apple_server_url": "http://some-apple-server-url.com", + "apple_business_manager": [ + { + "organization_name": "Fleet Device Management Inc.", + "macos_team": "💻 Workstations", + "ios_team": "📱🏢 Company-owned mobile devices", + "ipados_team": "📱🏢 Company-owned mobile devices" + } + ], + "apple_bm_enabled_and_configured": true, + "apple_bm_terms_expired": false, + "enabled_and_configured": true, + "macos_updates": { + "minimum_version": "15.1", + "deadline": "2024-12-31" + }, + "ios_updates": { + "minimum_version": "18.1", + "deadline": "2025-12-31" + }, + "ipados_updates": { + "minimum_version": "18.2", + "deadline": "2026-12-31" + }, + "windows_updates": { + "deadline_days": 5, + "grace_period_days": 2 + }, + "macos_settings": { + "custom_settings": null + }, + "macos_setup": { + "bootstrap_package": "some-bootstrap-package", + "enable_end_user_authentication": true, + "macos_setup_assistant": "", + "enable_release_device_manually": false, + "script": "", + "software": [] + }, + "macos_migration": { + "enable": true, + "mode": "voluntary", + "webhook_url": "https://some-macos-migration-webhook-url.com" + }, + "windows_migration_enabled": true, + "end_user_authentication": { + "entity_id": "some-mdm-entity-id.com", + "issuer_uri": "https://some-mdm-issuer-uri.com", + "metadata": "some-mdm-metadata", + "metadata_url": "http://some-mdm-metadata-url.com", + "idp_name": "some-other-idp-name" + }, + "windows_enabled_and_configured": true, + "enable_disk_encryption": true, + "windows_settings": { + "custom_settings": [] + }, + "volume_purchasing_program": [ + { + "location": "Fleet Device Management Inc.", + "teams": [ + "💻 Workstations", + "💻🐣 Workstations (canary)", + "📱🏢 Company-owned mobile devices", + "📱🔐 Personal mobile devices" + ] + } + ], + "android_enabled_and_configured": true + }, + "gitops": { + "gitops_mode_enabled": false, + "repository_url": "https://github.com/fleetdm/fleet/tree/main/it-and-security" + }, + "scripts": [] +} \ No newline at end of file diff --git a/cmd/fleetctl/testdata/generateGitops/expectedGlobalControls.yaml b/cmd/fleetctl/testdata/generateGitops/expectedGlobalControls.yaml new file mode 100644 index 0000000000..804eb25832 --- /dev/null +++ b/cmd/fleetctl/testdata/generateGitops/expectedGlobalControls.yaml @@ -0,0 +1,34 @@ +macos_settings: + custom_settings: + - labels_include_all: + - Label A + - Label B + path: ./lib/profiles/global-macos-mobileconfig-profile.mobileconfig + - labels_exclude_any: + - Label C + path: ./lib/profiles/global-macos-json-profile.json +windows_settings: + custom_settings: + - labels_include_any: + - Label D + path: ./lib/profiles/global-windows-profile.xml +macos_updates: + minimum_version: "15.1" + deadline: "2024-12-31" +ios_updates: + minimum_version: "18.1" + deadline: "2025-12-31" +ipados_updates: + minimum_version: "18.2" + deadline: "2026-12-31" +windows_updates: + deadline_days: 5 + grace_period_days: 2 +windows_enabled_and_configured: true +windows_migration_enabled: true +enable_disk_encryption: true +macos_setup: "TODO: update with your macos_setup configuration" +macos_migration: # Available in Fleet Premium + enable: true + mode: voluntary + webhook_url: https://some-macos-migration-webhook-url.com \ No newline at end of file diff --git a/cmd/fleetctl/testdata/generateGitops/expectedGlobalPolicies.yaml b/cmd/fleetctl/testdata/generateGitops/expectedGlobalPolicies.yaml new file mode 100644 index 0000000000..72c2a343c7 --- /dev/null +++ b/cmd/fleetctl/testdata/generateGitops/expectedGlobalPolicies.yaml @@ -0,0 +1,12 @@ +- calendar_events_enabled: false + critical: false + description: This is a global policy + install_software: + hash_sha256: team-software-hash __TEAM_SOFTWARE_COMMENT_TOKEN__ + labels_include_any: + - Label A + - Label B + name: Global Policy + platform: darwin + query: SELECT * FROM global_policy WHERE id = 1 + resolution: Do a global thing \ No newline at end of file diff --git a/cmd/fleetctl/testdata/generateGitops/expectedGlobalQueries.yaml b/cmd/fleetctl/testdata/generateGitops/expectedGlobalQueries.yaml new file mode 100644 index 0000000000..fcfa479144 --- /dev/null +++ b/cmd/fleetctl/testdata/generateGitops/expectedGlobalQueries.yaml @@ -0,0 +1,13 @@ +- automations_enabled: true + logging: stdout + min_osquery_version: 1.2.3 + description: This is a global query + interval: 3600 + labels_include_any: + - Label A + - Label B + name: Global Query + observer_can_run: true + platform: darwin + query: SELECT * FROM global_query WHERE id = 1 + discard_data: false \ No newline at end of file diff --git a/cmd/fleetctl/testdata/generateGitops/expectedLabels.yaml b/cmd/fleetctl/testdata/generateGitops/expectedLabels.yaml new file mode 100644 index 0000000000..34a75cc6e1 --- /dev/null +++ b/cmd/fleetctl/testdata/generateGitops/expectedLabels.yaml @@ -0,0 +1,11 @@ +- name: Label A + description: Label A description + label_membership_type: dynamic + query: SELECT * FROM osquery_info + platform: linux,macos +- name: Label B + description: Label B description + label_membership_type: manual + hosts: + - host1 + - host2 diff --git a/cmd/fleetctl/testdata/generateGitops/expectedOrgSettings-insecure.yaml b/cmd/fleetctl/testdata/generateGitops/expectedOrgSettings-insecure.yaml new file mode 100644 index 0000000000..517cc37800 --- /dev/null +++ b/cmd/fleetctl/testdata/generateGitops/expectedOrgSettings-insecure.yaml @@ -0,0 +1,121 @@ +features: + enable_host_users: true + enable_software_inventory: true + additional_queries: + time: "SELECT * FROM time" + macs: "SELECT mac FROM interface_details" + detail_query_overrides: + users: + mdm: "SELECT enrolled, server_url, installed_from_dep, payload_identifier FROM mdm;" +fleet_desktop: + transparency_url: https://fleetdm.com/transparency +host_expiry_settings: + host_expiry_enabled: false + host_expiry_window: 59995 +integrations: + custom_scep_proxy: + - challenge: some-custom-scep-proxy-challenge + name: some-custom-scep-proxy-name + url: https://some-custom-scep-proxy-url.com + digicert: + - api_token: some-digicert-api-token + certificate_common_name: some-digicert-certificate-common-name + certificate_seat_id: some-digicert-certificate-seat-id + certificate_user_principal_names: + - some-digicert-certificate-user-principal-name + - some-other-digicert-certificate-user-principal-name + name: some-digicert-name + profile_id: some-digicert-profile-id + url: https://some-digicert-url.com + google_calendar: + - api_key_json: + owl: hoot + domain: fleetdm.com + jira: + - api_token: some-jira-api-token + enable_failing_policies: false + enable_software_vulnerabilities: false + project_key: some-jira-project-key + url: https://some-jira-url.com + username: some-jira-username + ndes_scep_proxy: + admin_url: https://some-ndes-admin-url.com + password: some-ndes-password + url: https://some-ndes-scep-proxy-url.com + username: some-ndes-username + zendesk: + - api_token: some-zendesk-api-token + email: some-zendesk-email@example.com + enable_failing_policies: false + enable_software_vulnerabilities: false + group_id: 123456789 + url: https://some-zendesk-url.com +mdm: + apple_business_manager: + - ios_team: "\U0001F4F1\U0001F3E2 Company-owned mobile devices" + ipados_team: "\U0001F4F1\U0001F3E2 Company-owned mobile devices" + macos_team: "\U0001F4BB Workstations" + organization_name: Fleet Device Management Inc. + apple_server_url: http://some-apple-server-url.com + end_user_authentication: + entity_id: some-mdm-entity-id.com + idp_name: some-other-idp-name + issuer_uri: https://some-mdm-issuer-uri.com + metadata: some-mdm-metadata + metadata_url: http://some-mdm-metadata-url.com + volume_purchasing_program: + - location: Fleet Device Management Inc. + teams: + - "\U0001F4BB Workstations" + - "\U0001F4BB\U0001F423 Workstations (canary)" + - "\U0001F4F1\U0001F3E2 Company-owned mobile devices" + - "\U0001F4F1\U0001F510 Personal mobile devices" +org_info: + contact_url: https://fleetdm.com/company/contact + org_logo_url: http://some-org-logo-url.com + org_logo_url_light_background: http://some-org-logo-url-light-background.com + org_name: Fleet +secrets: +- secret: some-secret-number-one +- secret: some-secret-number-two +server_settings: + ai_features_disabled: false + debug_host_ids: + - 1 + - 3 + deferred_save_host: false + enable_analytics: true + live_query_disabled: false + query_report_cap: 1 + query_reports_disabled: false + scripts_disabled: false + server_url: https://dogfood.fleetdm.com +sso_settings: + enable_jit_provisioning: true + enable_sso: true + enable_sso_idp_login: false + entity_id: dogfood.fleetdm.com + idp_image_url: http://some-sso-idp-image-url.com + idp_name: some-idp-name + metadata: some-sso-metadata + metadata_url: http://some-sso-metadata-url.com +webhook_settings: + activities_webhook: + destination_url: https://some-activities-webhook-url.com + enable_activities_webhook: true + failing_policies_webhook: + destination_url: https://some-failing-policies-webhook-url.com + enable_failing_policies_webhook: true + host_batch_size: 2 + policy_ids: [] + host_status_webhook: + days_count: 5 + destination_url: https://some-host-status-webhook-url.com + enable_host_status_webhook: true + host_percentage: 20 + interval: 6h0m0s + vulnerabilities_webhook: + destination_url: https://some-vulerabilities-webhook-url.com + enable_vulnerabilities_webhook: true + host_batch_size: 3 +yara_rules: {} diff --git a/cmd/fleetctl/testdata/generateGitops/expectedOrgSettings.yaml b/cmd/fleetctl/testdata/generateGitops/expectedOrgSettings.yaml new file mode 100644 index 0000000000..1c7dcf9042 --- /dev/null +++ b/cmd/fleetctl/testdata/generateGitops/expectedOrgSettings.yaml @@ -0,0 +1,121 @@ +features: + enable_host_users: true + enable_software_inventory: true + additional_queries: + time: "SELECT * FROM time" + macs: "SELECT mac FROM interface_details" + detail_query_overrides: + users: + mdm: "SELECT enrolled, server_url, installed_from_dep, payload_identifier FROM mdm;" +fleet_desktop: + transparency_url: https://fleetdm.com/transparency +host_expiry_settings: + host_expiry_enabled: false + host_expiry_window: 59995 +integrations: + custom_scep_proxy: + - challenge: ___GITOPS_COMMENT_5___ + name: some-custom-scep-proxy-name + url: https://some-custom-scep-proxy-url.com + digicert: + - api_token: ___GITOPS_COMMENT_3___ + certificate_common_name: some-digicert-certificate-common-name + certificate_seat_id: some-digicert-certificate-seat-id + certificate_user_principal_names: + - some-digicert-certificate-user-principal-name + - some-other-digicert-certificate-user-principal-name + name: some-digicert-name + profile_id: some-digicert-profile-id + url: https://some-digicert-url.com + google_calendar: + - api_key_json: + owl: hoot + private_key: ___GITOPS_COMMENT_0___ + domain: fleetdm.com + jira: + - api_token: ___GITOPS_COMMENT_1___ + enable_failing_policies: false + enable_software_vulnerabilities: false + project_key: some-jira-project-key + url: https://some-jira-url.com + username: some-jira-username + ndes_scep_proxy: + admin_url: https://some-ndes-admin-url.com + password: ___GITOPS_COMMENT_4___ + url: https://some-ndes-scep-proxy-url.com + username: some-ndes-username + zendesk: + - api_token: ___GITOPS_COMMENT_2___ + email: some-zendesk-email@example.com + enable_failing_policies: false + enable_software_vulnerabilities: false + group_id: 123456789 + url: https://some-zendesk-url.com +mdm: + apple_business_manager: + - ios_team: "\U0001F4F1\U0001F3E2 Company-owned mobile devices" + ipados_team: "\U0001F4F1\U0001F3E2 Company-owned mobile devices" + macos_team: "\U0001F4BB Workstations" + organization_name: Fleet Device Management Inc. + apple_server_url: http://some-apple-server-url.com + end_user_authentication: + entity_id: some-mdm-entity-id.com + idp_name: some-other-idp-name + issuer_uri: https://some-mdm-issuer-uri.com + metadata: ___GITOPS_COMMENT_6___ + metadata_url: ___GITOPS_COMMENT_7___ + volume_purchasing_program: + - location: Fleet Device Management Inc. + teams: + - "\U0001F4BB Workstations" + - "\U0001F4BB\U0001F423 Workstations (canary)" + - "\U0001F4F1\U0001F3E2 Company-owned mobile devices" + - "\U0001F4F1\U0001F510 Personal mobile devices" +org_info: + contact_url: https://fleetdm.com/company/contact + org_logo_url: http://some-org-logo-url.com + org_logo_url_light_background: http://some-org-logo-url-light-background.com + org_name: Fleet +secrets: +- secret: ___GITOPS_COMMENT_8___ +server_settings: + ai_features_disabled: false + debug_host_ids: + - 1 + - 3 + deferred_save_host: false + enable_analytics: true + live_query_disabled: false + query_report_cap: 1 + query_reports_disabled: false + scripts_disabled: false + server_url: https://dogfood.fleetdm.com +sso_settings: + enable_jit_provisioning: true + enable_sso: true + enable_sso_idp_login: false + entity_id: dogfood.fleetdm.com + idp_image_url: http://some-sso-idp-image-url.com + idp_name: some-idp-name + metadata: ___GITOPS_COMMENT_9___ + metadata_url: ___GITOPS_COMMENT_10___ +webhook_settings: + activities_webhook: + destination_url: https://some-activities-webhook-url.com + enable_activities_webhook: true + failing_policies_webhook: + destination_url: https://some-failing-policies-webhook-url.com + enable_failing_policies_webhook: true + host_batch_size: 2 + policy_ids: [] + host_status_webhook: + days_count: 5 + destination_url: https://some-host-status-webhook-url.com + enable_host_status_webhook: true + host_percentage: 20 + interval: 6h0m0s + vulnerabilities_webhook: + destination_url: https://some-vulerabilities-webhook-url.com + enable_vulnerabilities_webhook: true + host_batch_size: 3 +yara_rules: {} \ No newline at end of file diff --git a/cmd/fleetctl/testdata/generateGitops/expectedTeamControls.yaml b/cmd/fleetctl/testdata/generateGitops/expectedTeamControls.yaml new file mode 100644 index 0000000000..c6423e555a --- /dev/null +++ b/cmd/fleetctl/testdata/generateGitops/expectedTeamControls.yaml @@ -0,0 +1,5 @@ +macos_settings: + custom_settings: + - path: ../lib/some_team/profiles/team-macos-mobileconfig-profile.mobileconfig +scripts: +- path: ../lib/some_team/scripts/Script B.ps1 \ No newline at end of file diff --git a/cmd/fleetctl/testdata/generateGitops/expectedTeamPolicies.yaml b/cmd/fleetctl/testdata/generateGitops/expectedTeamPolicies.yaml new file mode 100644 index 0000000000..7dfcc6879b --- /dev/null +++ b/cmd/fleetctl/testdata/generateGitops/expectedTeamPolicies.yaml @@ -0,0 +1,9 @@ +- calendar_events_enabled: false + critical: false + description: This is a team policy + name: Team Policy + platform: linux,windows + query: SELECT * FROM team_policy WHERE id = 1 + resolution: Do a team thing + run_script: + path: /path/to/script1.sh \ No newline at end of file diff --git a/cmd/fleetctl/testdata/generateGitops/expectedTeamQueries.yaml b/cmd/fleetctl/testdata/generateGitops/expectedTeamQueries.yaml new file mode 100644 index 0000000000..f55970732a --- /dev/null +++ b/cmd/fleetctl/testdata/generateGitops/expectedTeamQueries.yaml @@ -0,0 +1,10 @@ +- automations_enabled: true + logging: stderr + min_osquery_version: 4.5.6 + discard_data: false + description: This is a team query + interval: 1800 + name: Team Query + observer_can_run: false + platform: linux,windows + query: SELECT * FROM team_query WHERE id = 1 \ No newline at end of file diff --git a/cmd/fleetctl/testdata/generateGitops/expectedTeamSettings-insecure.yaml b/cmd/fleetctl/testdata/generateGitops/expectedTeamSettings-insecure.yaml new file mode 100644 index 0000000000..a1616be62b --- /dev/null +++ b/cmd/fleetctl/testdata/generateGitops/expectedTeamSettings-insecure.yaml @@ -0,0 +1,26 @@ +features: + enable_host_users: true + enable_software_inventory: true +host_expiry_settings: + host_expiry_enabled: false + host_expiry_window: 1 +integrations: + google_calendar: + enable_calendar_events: true + webhook_url: https://some-team-google-calendar-webhook.com +secrets: +- secret: some-team-secret +webhook_settings: + failing_policies_webhook: + destination_url: https://some-team-failing_policies-webhook.com + enable_failing_policies_webhook: false + host_batch_size: 4 + policy_ids: + - 1 + - 2 + - 3 + host_status_webhook: + days_count: 3 + destination_url: https://some-team-host-status-webhook.com + enable_host_status_webhook: false + host_percentage: 2 \ No newline at end of file diff --git a/cmd/fleetctl/testdata/generateGitops/expectedTeamSettings.yaml b/cmd/fleetctl/testdata/generateGitops/expectedTeamSettings.yaml new file mode 100644 index 0000000000..9410c9cf68 --- /dev/null +++ b/cmd/fleetctl/testdata/generateGitops/expectedTeamSettings.yaml @@ -0,0 +1,26 @@ +features: + enable_host_users: true + enable_software_inventory: true +host_expiry_settings: + host_expiry_enabled: false + host_expiry_window: 1 +integrations: + google_calendar: + enable_calendar_events: true + webhook_url: https://some-team-google-calendar-webhook.com +secrets: +- secret: ___GITOPS_COMMENT_0___ +webhook_settings: + failing_policies_webhook: + destination_url: https://some-team-failing_policies-webhook.com + enable_failing_policies_webhook: false + host_batch_size: 4 + policy_ids: + - 1 + - 2 + - 3 + host_status_webhook: + days_count: 3 + destination_url: https://some-team-host-status-webhook.com + enable_host_status_webhook: false + host_percentage: 2 \ No newline at end of file diff --git a/cmd/fleetctl/testdata/generateGitops/expectedTeamSoftware.yaml b/cmd/fleetctl/testdata/generateGitops/expectedTeamSoftware.yaml new file mode 100644 index 0000000000..2d1e860485 --- /dev/null +++ b/cmd/fleetctl/testdata/generateGitops/expectedTeamSoftware.yaml @@ -0,0 +1,19 @@ +packages: + - hash_sha256: software-package-hash ___GITOPS_COMMENT_0___ + install_script: + path: ../lib/some-team/scripts/my-software-package-darwin-install + labels_include_any: + - Label A + - Label B + post_install_script: + path: ../lib/some-team/scripts/my-software-package-darwin-postinstall + pre_install_query: + path: ../lib/some-team/queries/my-software-package-darwin-preinstallquery.yml + self_service: true + uninstall_script: + path: ../lib/some-team/scripts/my-software-package-darwin-uninstall +app_store_apps: + - app_store_id: com.example.team-software + labels_exclude_any: + - Label C + - Label D \ No newline at end of file diff --git a/cmd/fleetctl/testdata/generateGitops/teamConfig.json b/cmd/fleetctl/testdata/generateGitops/teamConfig.json new file mode 100644 index 0000000000..3028c3c8d1 --- /dev/null +++ b/cmd/fleetctl/testdata/generateGitops/teamConfig.json @@ -0,0 +1,242 @@ +{ + "id": 1, + "created_at": "2025-02-05T23:33:39Z", + "name": "Test Team", + "description": "A test team", + "agent_options": { + "config": { + "options": { + "pack_delimiter": "/", + "logger_tls_period": 10, + "distributed_plugin": "tls", + "disable_distributed": false, + "logger_tls_endpoint": "/api/osquery/log", + "distributed_interval": 10, + "distributed_tls_max_attempts": 3 + }, + "decorators": { + "load": [ + "SELECT uuid AS host_uuid FROM system_info;", + "SELECT hostname AS hostname FROM system_info;" + ] + } + }, + "update_channels": { + "orbit": "edge", + "desktop": "edge", + "osqueryd": "edge" + } + }, + "host_expiry_settings": { + "host_expiry_enabled": false, + "host_expiry_window": 1 + }, + "webhook_settings": { + "host_status_webhook": { + "enable_host_status_webhook": false, + "destination_url": "https://some-team-host-status-webhook.com", + "host_percentage": 2, + "days_count": 3 + }, + "failing_policies_webhook": { + "enable_failing_policies_webhook": false, + "destination_url": "https://some-team-failing_policies-webhook.com", + "policy_ids": [ + 1, + 2, + 3 + ], + "host_batch_size": 4 + } + }, + "integrations": { + "jira": [ + { + "url": "https://some-team-jira-url.com", + "username": "some-team-jira-username", + "api_token": "some-team-jira-api-token", + "project_key": "some-team-jira-project-key" + } + ], + "zendesk": [ + { + "url": "https://some-team-zendesk-url.com", + "email": "some-team-zendesk-email@example.com", + "api_token": "some-team-zendesk-api-token", + "group_id": 123456789 + } + ], + "google_calendar": { + "enable_calendar_events": true, + "webhook_url": "https://some-team-google-calendar-webhook.com" + }, + "digicert": [ + { + "name": "some-team-digicert-name", + "url": "https://some-team-digicert-url.com", + "api_token": "some-team-digicert-api-token", + "profile_id": "some-team-digicert-profile-id", + "certificate_common_name": "some-team-digicert-certificate-common-name", + "certificate_user_principal_names": [ + "some-team-digicert-certificate-user-principal-name", + "some-team-other-digicert-certificate-user-principal-name" + ], + "certificate_seat_id": "some-team-digicert-certificate-seat-id" + } + ], + "ndes_scep_proxy": { + "url": "https://some-team-ndes-scep-proxy-url.com", + "admin_url": "https://some-team-ndes-admin-url.com", + "username": "some-team-ndes-username", + "password": "some-team-ndes-password" + }, + "custom_scep_proxy": [ + { + "name": "some-team-custom-scep-proxy-name", + "url": "https://some-team-custom-scep-proxy-url.com", + "challenge": "some-team-custom-scep-proxy-challenge" + } + ] + }, + "features": { + "enable_host_users": true, + "enable_software_inventory": true + }, + "mdm": { + "enable_disk_encryption": true, + "macos_updates": { + "minimum_version": "95.1", + "deadline": "2020-12-31" + }, + "ios_updates": { + "minimum_version": "98.1", + "deadline": "2021-12-31" + }, + "ipados_updates": { + "minimum_version": "98.2", + "deadline": "2022-12-31" + }, + "windows_updates": { + "deadline_days": 95, + "grace_period_days": 92 + }, + "macos_settings": { + "custom_settings": null + }, + "macos_setup": { + "bootstrap_package": "", + "enable_end_user_authentication": false, + "macos_setup_assistant": "", + "enable_release_device_manually": false, + "script": "", + "software": [] + }, + "windows_settings": { + "custom_settings": null + } + }, + "scripts": [ + "/home/runner/work/fleet/fleet/it-and-security/lib/macos/scripts/uninstall-fleetd-macos.sh", + "/home/runner/work/fleet/fleet/it-and-security/lib/windows/scripts/uninstall-fleetd-windows.ps1", + "/home/runner/work/fleet/fleet/it-and-security/lib/linux/scripts/uninstall-fleetd-linux.sh", + "/home/runner/work/fleet/fleet/it-and-security/lib/linux/scripts/install-fleet-desktop-required-extension.sh" + ], + "software": { + "packages": [ + { + "url": "https://zoom.us/client/6.3.10.7150/zoom_amd64.deb", + "self_service": true, + "pre_install_query": { + "path": "" + }, + "install_script": { + "path": "" + }, + "post_install_script": { + "path": "" + }, + "uninstall_script": { + "path": "" + }, + "labels_include_any": [ + "Debian-based Linux hosts" + ], + "labels_exclude_any": null, + "referenced_yaml_path": "/home/runner/work/fleet/fleet/it-and-security/lib/linux/software/zoom-deb.yml" + }, + { + "url": "https://zoom.us/client/6.3.10.7150/zoom_x86_64.rpm", + "self_service": true, + "pre_install_query": { + "path": "" + }, + "install_script": { + "path": "" + }, + "post_install_script": { + "path": "" + }, + "uninstall_script": { + "path": "" + }, + "labels_include_any": [ + "RPM-based Linux hosts" + ], + "labels_exclude_any": null, + "referenced_yaml_path": "/home/runner/work/fleet/fleet/it-and-security/lib/linux/software/zoom-rpm.yml" + }, + { + "url": "https://downloads.slack-edge.com/desktop-releases/linux/x64/4.41.105/slack-desktop-4.41.105-amd64.deb", + "self_service": true, + "pre_install_query": { + "path": "" + }, + "install_script": { + "path": "" + }, + "post_install_script": { + "path": "" + }, + "uninstall_script": { + "path": "" + }, + "labels_include_any": [ + "Debian-based Linux hosts" + ], + "labels_exclude_any": null, + "referenced_yaml_path": "/home/runner/work/fleet/fleet/it-and-security/lib/linux/software/slack-deb.yml" + }, + { + "url": "https://downloads.slack-edge.com/desktop-releases/linux/x64/4.41.105/slack-4.41.105-0.1.el8.x86_64.rpm", + "self_service": true, + "pre_install_query": { + "path": "" + }, + "install_script": { + "path": "" + }, + "post_install_script": { + "path": "" + }, + "uninstall_script": { + "path": "" + }, + "labels_include_any": [ + "RPM-based Linux hosts" + ], + "labels_exclude_any": null, + "referenced_yaml_path": "/home/runner/work/fleet/fleet/it-and-security/lib/linux/software/slack-rpm.yml" + } + ], + "app_store_apps": null + }, + "user_count": 5, + "host_count": 24, + "secrets": [ + { + "secret": "some-team-secret", + "created_at": "2025-02-05T23:33:40Z", + "team_id": 270 + } + ] +} \ No newline at end of file diff --git a/cmd/fleetctl/testdata/generateGitops/test_dir_free/default.yml b/cmd/fleetctl/testdata/generateGitops/test_dir_free/default.yml new file mode 100644 index 0000000000..d6628556bf --- /dev/null +++ b/cmd/fleetctl/testdata/generateGitops/test_dir_free/default.yml @@ -0,0 +1,178 @@ +agent_options: + 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/osquery/log + logger_tls_period: 10 + pack_delimiter: / +controls: + macos_settings: + custom_settings: + - labels_include_all: + - Label A + - Label B + path: ./lib/profiles/global-macos-mobileconfig-profile.mobileconfig + - labels_exclude_any: + - Label C + path: ./lib/profiles/global-macos-json-profile.json + windows_settings: + custom_settings: + - labels_include_any: + - Label D + path: ./lib/profiles/global-windows-profile.xml +labels: +- description: Label A description + label_membership_type: dynamic + name: Label A + platform: linux,macos + query: SELECT * FROM osquery_info +- description: Label B description + hosts: + - host1 + - host2 + label_membership_type: manual + name: Label B +org_settings: + features: + additional_queries: + macs: SELECT mac FROM interface_details + time: SELECT * FROM time + detail_query_overrides: + mdm: SELECT enrolled, server_url, installed_from_dep, payload_identifier FROM + mdm; + users: + enable_host_users: true + enable_software_inventory: true + fleet_desktop: + transparency_url: https://fleetdm.com/transparency + host_expiry_settings: + host_expiry_enabled: false + host_expiry_window: 59995 + integrations: + custom_scep_proxy: + - challenge: # TODO: Add your custom SCEP proxy challenge here + name: some-custom-scep-proxy-name + url: https://some-custom-scep-proxy-url.com + digicert: + - api_token: # TODO: Add your Digicert API token here + certificate_common_name: some-digicert-certificate-common-name + certificate_seat_id: some-digicert-certificate-seat-id + certificate_user_principal_names: + - some-digicert-certificate-user-principal-name + - some-other-digicert-certificate-user-principal-name + name: some-digicert-name + profile_id: some-digicert-profile-id + url: https://some-digicert-url.com + google_calendar: + - api_key_json: + owl: hoot + private_key: # TODO: Add your Google Calendar API key JSON here + domain: fleetdm.com + jira: + - api_token: # TODO: Add your Jira API token here + enable_failing_policies: false + enable_software_vulnerabilities: false + project_key: some-jira-project-key + url: https://some-jira-url.com + username: some-jira-username + ndes_scep_proxy: + admin_url: https://some-ndes-admin-url.com + password: # TODO: Add your NDES SCEP proxy password here + url: https://some-ndes-scep-proxy-url.com + username: some-ndes-username + zendesk: + - api_token: # TODO: Add your Zendesk API token here + email: some-zendesk-email@example.com + enable_failing_policies: false + enable_software_vulnerabilities: false + group_id: 123456789 + url: https://some-zendesk-url.com + mdm: + apple_server_url: http://some-apple-server-url.com + end_user_authentication: + entity_id: some-mdm-entity-id.com + idp_name: some-other-idp-name + issuer_uri: https://some-mdm-issuer-uri.com + metadata: # TODO: Add your MDM end user auth metadata here + metadata_url: # TODO: Add your MDM end user auth metadata URL here + org_info: + contact_url: https://fleetdm.com/company/contact + org_logo_url: http://some-org-logo-url.com + org_logo_url_light_background: http://some-org-logo-url-light-background.com + org_name: Fleet + secrets: + - secret: # TODO: Add your enroll secrets here + server_settings: + ai_features_disabled: false + debug_host_ids: + - 1 + - 3 + deferred_save_host: false + enable_analytics: true + live_query_disabled: false + query_report_cap: 1 + query_reports_disabled: false + scripts_disabled: false + server_url: https://dogfood.fleetdm.com + sso_settings: + enable_sso: true + enable_sso_idp_login: false + entity_id: dogfood.fleetdm.com + idp_image_url: http://some-sso-idp-image-url.com + idp_name: some-idp-name + metadata: # TODO: Add your SSO metadata here + metadata_url: # TODO: Add your SSO metadata URL here + webhook_settings: + activities_webhook: + destination_url: https://some-activities-webhook-url.com + enable_activities_webhook: true + failing_policies_webhook: + destination_url: https://some-failing-policies-webhook-url.com + enable_failing_policies_webhook: true + host_batch_size: 2 + policy_ids: + host_status_webhook: + days_count: 5 + destination_url: https://some-host-status-webhook-url.com + enable_host_status_webhook: true + host_percentage: 20 + interval: 6h0m0s + vulnerabilities_webhook: + destination_url: https://some-vulerabilities-webhook-url.com + enable_vulnerabilities_webhook: true + host_batch_size: 3 + yara_rules: +policies: +- calendar_events_enabled: false + critical: false + description: This is a global policy + install_software: + hash_sha256: ___GITOPS_COMMENT_11___ + labels_include_any: + - Label A + - Label B + name: Global Policy + platform: darwin + query: SELECT * FROM global_policy WHERE id = 1 + resolution: Do a global thing +queries: +- automations_enabled: true + description: This is a global query + discard_data: false + interval: 3600 + labels_include_any: + - Label A + - Label B + logging: stdout + min_osquery_version: 1.2.3 + name: Global Query + observer_can_run: true + platform: darwin + query: SELECT * FROM global_query WHERE id = 1 diff --git a/cmd/fleetctl/testdata/generateGitops/test_dir_free/lib/profiles/global-macos-json-profile.json b/cmd/fleetctl/testdata/generateGitops/test_dir_free/lib/profiles/global-macos-json-profile.json new file mode 100644 index 0000000000..877de0ffd0 --- /dev/null +++ b/cmd/fleetctl/testdata/generateGitops/test_dir_free/lib/profiles/global-macos-json-profile.json @@ -0,0 +1 @@ +{"profile": "global macos json profile"} \ No newline at end of file diff --git a/cmd/fleetctl/testdata/generateGitops/test_dir_free/lib/profiles/global-macos-mobileconfig-profile.mobileconfig b/cmd/fleetctl/testdata/generateGitops/test_dir_free/lib/profiles/global-macos-mobileconfig-profile.mobileconfig new file mode 100644 index 0000000000..d2ce5ba191 --- /dev/null +++ b/cmd/fleetctl/testdata/generateGitops/test_dir_free/lib/profiles/global-macos-mobileconfig-profile.mobileconfig @@ -0,0 +1 @@ +global macos mobileconfig profile \ No newline at end of file diff --git a/cmd/fleetctl/testdata/generateGitops/test_dir_free/lib/profiles/global-windows-profile.xml b/cmd/fleetctl/testdata/generateGitops/test_dir_free/lib/profiles/global-windows-profile.xml new file mode 100644 index 0000000000..c594eb9abf --- /dev/null +++ b/cmd/fleetctl/testdata/generateGitops/test_dir_free/lib/profiles/global-windows-profile.xml @@ -0,0 +1 @@ +global windows profile \ No newline at end of file diff --git a/cmd/fleetctl/testdata/generateGitops/test_dir_premium/default.yml b/cmd/fleetctl/testdata/generateGitops/test_dir_premium/default.yml new file mode 100644 index 0000000000..f885a337c5 --- /dev/null +++ b/cmd/fleetctl/testdata/generateGitops/test_dir_premium/default.yml @@ -0,0 +1,176 @@ +agent_options: + 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/osquery/log + logger_tls_period: 10 + pack_delimiter: / +labels: +- description: Label A description + label_membership_type: dynamic + name: Label A + platform: linux,macos + query: SELECT * FROM osquery_info +- description: Label B description + hosts: + - host1 + - host2 + label_membership_type: manual + name: Label B +org_settings: + features: + additional_queries: + macs: SELECT mac FROM interface_details + time: SELECT * FROM time + detail_query_overrides: + mdm: SELECT enrolled, server_url, installed_from_dep, payload_identifier FROM + mdm; + users: + enable_host_users: true + enable_software_inventory: true + fleet_desktop: + transparency_url: https://fleetdm.com/transparency + host_expiry_settings: + host_expiry_enabled: false + host_expiry_window: 59995 + integrations: + custom_scep_proxy: + - challenge: # TODO: Add your custom SCEP proxy challenge here + name: some-custom-scep-proxy-name + url: https://some-custom-scep-proxy-url.com + digicert: + - api_token: # TODO: Add your Digicert API token here + certificate_common_name: some-digicert-certificate-common-name + certificate_seat_id: some-digicert-certificate-seat-id + certificate_user_principal_names: + - some-digicert-certificate-user-principal-name + - some-other-digicert-certificate-user-principal-name + name: some-digicert-name + profile_id: some-digicert-profile-id + url: https://some-digicert-url.com + google_calendar: + - api_key_json: + owl: hoot + private_key: # TODO: Add your Google Calendar API key JSON here + domain: fleetdm.com + jira: + - api_token: # TODO: Add your Jira API token here + enable_failing_policies: false + enable_software_vulnerabilities: false + project_key: some-jira-project-key + url: https://some-jira-url.com + username: some-jira-username + ndes_scep_proxy: + admin_url: https://some-ndes-admin-url.com + password: # TODO: Add your NDES SCEP proxy password here + url: https://some-ndes-scep-proxy-url.com + username: some-ndes-username + zendesk: + - api_token: # TODO: Add your Zendesk API token here + email: some-zendesk-email@example.com + enable_failing_policies: false + enable_software_vulnerabilities: false + group_id: 123456789 + url: https://some-zendesk-url.com + mdm: + apple_business_manager: + - ios_team: "\U0001F4F1\U0001F3E2 Company-owned mobile devices" + ipados_team: "\U0001F4F1\U0001F3E2 Company-owned mobile devices" + macos_team: "\U0001F4BB Workstations" + organization_name: Fleet Device Management Inc. + apple_server_url: http://some-apple-server-url.com + end_user_authentication: + entity_id: some-mdm-entity-id.com + idp_name: some-other-idp-name + issuer_uri: https://some-mdm-issuer-uri.com + metadata: # TODO: Add your MDM end user auth metadata here + metadata_url: # TODO: Add your MDM end user auth metadata URL here + volume_purchasing_program: + - location: Fleet Device Management Inc. + teams: + - "\U0001F4BB Workstations" + - "\U0001F4BB\U0001F423 Workstations (canary)" + - "\U0001F4F1\U0001F3E2 Company-owned mobile devices" + - "\U0001F4F1\U0001F510 Personal mobile devices" + org_info: + contact_url: https://fleetdm.com/company/contact + org_logo_url: http://some-org-logo-url.com + org_logo_url_light_background: http://some-org-logo-url-light-background.com + org_name: Fleet + secrets: + - secret: # TODO: Add your enroll secrets here + server_settings: + ai_features_disabled: false + debug_host_ids: + - 1 + - 3 + deferred_save_host: false + enable_analytics: true + live_query_disabled: false + query_report_cap: 1 + query_reports_disabled: false + scripts_disabled: false + server_url: https://dogfood.fleetdm.com + sso_settings: + enable_jit_provisioning: true + enable_sso: true + enable_sso_idp_login: false + entity_id: dogfood.fleetdm.com + idp_image_url: http://some-sso-idp-image-url.com + idp_name: some-idp-name + metadata: # TODO: Add your SSO metadata here + metadata_url: # TODO: Add your SSO metadata URL here + webhook_settings: + activities_webhook: + destination_url: https://some-activities-webhook-url.com + enable_activities_webhook: true + failing_policies_webhook: + destination_url: https://some-failing-policies-webhook-url.com + enable_failing_policies_webhook: true + host_batch_size: 2 + policy_ids: + host_status_webhook: + days_count: 5 + destination_url: https://some-host-status-webhook-url.com + enable_host_status_webhook: true + host_percentage: 20 + interval: 6h0m0s + vulnerabilities_webhook: + destination_url: https://some-vulerabilities-webhook-url.com + enable_vulnerabilities_webhook: true + host_batch_size: 3 + yara_rules: +policies: +- calendar_events_enabled: false + critical: false + description: This is a global policy + install_software: + hash_sha256: software-package-hash ___GITOPS_COMMENT_1___ + labels_include_any: + - Label A + - Label B + name: Global Policy + platform: darwin + query: SELECT * FROM global_policy WHERE id = 1 + resolution: Do a global thing +queries: +- automations_enabled: true + description: This is a global query + discard_data: false + interval: 3600 + labels_include_any: + - Label A + - Label B + logging: stdout + min_osquery_version: 1.2.3 + name: Global Query + observer_can_run: true + platform: darwin + query: SELECT * FROM global_query WHERE id = 1 diff --git a/cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/no-team/scripts/Script Z.ps1 b/cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/no-team/scripts/Script Z.ps1 new file mode 100644 index 0000000000..c7feac2a65 --- /dev/null +++ b/cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/no-team/scripts/Script Z.ps1 @@ -0,0 +1,2 @@ +#!/usr/bin/env pwsh +echo "Hello from Script B!" \ No newline at end of file diff --git a/cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/team-a/profiles/team-macos-mobileconfig-profile.mobileconfig b/cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/team-a/profiles/team-macos-mobileconfig-profile.mobileconfig new file mode 100644 index 0000000000..d8f74b96ff --- /dev/null +++ b/cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/team-a/profiles/team-macos-mobileconfig-profile.mobileconfig @@ -0,0 +1 @@ +test mobileconfig profile \ No newline at end of file diff --git a/cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/team-a/queries/my-software-package-darwin-preinstallquery.yml b/cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/team-a/queries/my-software-package-darwin-preinstallquery.yml new file mode 100644 index 0000000000..4bbb2f72cc --- /dev/null +++ b/cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/team-a/queries/my-software-package-darwin-preinstallquery.yml @@ -0,0 +1 @@ +- query: SELECT * FROM pre_install_query diff --git a/cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/team-a/scripts/Script B.ps1 b/cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/team-a/scripts/Script B.ps1 new file mode 100644 index 0000000000..df0abd7097 --- /dev/null +++ b/cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/team-a/scripts/Script B.ps1 @@ -0,0 +1 @@ +pop goes the weasel! \ No newline at end of file diff --git a/cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/team-a/scripts/my-software-package-darwin-install b/cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/team-a/scripts/my-software-package-darwin-install new file mode 100644 index 0000000000..1910281566 --- /dev/null +++ b/cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/team-a/scripts/my-software-package-darwin-install @@ -0,0 +1 @@ +foo \ No newline at end of file diff --git a/cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/team-a/scripts/my-software-package-darwin-postinstall b/cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/team-a/scripts/my-software-package-darwin-postinstall new file mode 100644 index 0000000000..ba0e162e1c --- /dev/null +++ b/cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/team-a/scripts/my-software-package-darwin-postinstall @@ -0,0 +1 @@ +bar \ No newline at end of file diff --git a/cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/team-a/scripts/my-software-package-darwin-uninstall b/cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/team-a/scripts/my-software-package-darwin-uninstall new file mode 100644 index 0000000000..3f95386662 --- /dev/null +++ b/cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/team-a/scripts/my-software-package-darwin-uninstall @@ -0,0 +1 @@ +baz \ No newline at end of file diff --git a/cmd/fleetctl/testdata/generateGitops/test_dir_premium/teams/no-team.yml b/cmd/fleetctl/testdata/generateGitops/test_dir_premium/teams/no-team.yml new file mode 100644 index 0000000000..4879c5fba1 --- /dev/null +++ b/cmd/fleetctl/testdata/generateGitops/test_dir_premium/teams/no-team.yml @@ -0,0 +1,33 @@ +controls: + enable_disk_encryption: true + ios_updates: + deadline: "2025-12-31" + minimum_version: "18.1" + ipados_updates: + deadline: "2026-12-31" + minimum_version: "18.2" + macos_migration: + enable: true + mode: voluntary + webhook_url: https://some-macos-migration-webhook-url.com + macos_setup: 'TODO: update with your macos_setup configuration' + macos_updates: + deadline: "2024-12-31" + minimum_version: "15.1" + scripts: + - path: ../lib/no-team/scripts/Script Z.ps1 + windows_enabled_and_configured: true + windows_migration_enabled: true + windows_updates: + deadline_days: 5 + grace_period_days: 2 +name: No team +policies: +- calendar_events_enabled: false + critical: false + description: This is a team policy + name: Team Policy + platform: linux,windows + query: SELECT * FROM team_policy WHERE id = 1 + resolution: Do a team thing +software: \ No newline at end of file diff --git a/cmd/fleetctl/testdata/generateGitops/test_dir_premium/teams/team-a.yml b/cmd/fleetctl/testdata/generateGitops/test_dir_premium/teams/team-a.yml new file mode 100644 index 0000000000..242c83d3f8 --- /dev/null +++ b/cmd/fleetctl/testdata/generateGitops/test_dir_premium/teams/team-a.yml @@ -0,0 +1,105 @@ +agent_options: + 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/osquery/log + logger_tls_period: 10 + pack_delimiter: / + update_channels: + desktop: edge + orbit: edge + osqueryd: edge +controls: + enable_disk_encryption: true + ios_updates: + deadline: "2021-12-31" + minimum_version: "98.1" + ipados_updates: + deadline: "2022-12-31" + minimum_version: "98.2" + macos_settings: + custom_settings: + - path: ../lib/team-a/profiles/team-macos-mobileconfig-profile.mobileconfig + macos_updates: + deadline: "2020-12-31" + minimum_version: "95.1" + scripts: + - path: ../lib/team-a/scripts/Script B.ps1 + windows_enabled_and_configured: true + windows_updates: + deadline_days: 95 + grace_period_days: 92 +name: Team A +policies: +- calendar_events_enabled: false + critical: false + description: This is a team policy + name: Team Policy + platform: linux,windows + query: SELECT * FROM team_policy WHERE id = 1 + resolution: Do a team thing +queries: +- automations_enabled: true + description: This is a team query + discard_data: false + interval: 1800 + logging: stderr + min_osquery_version: 4.5.6 + name: Team Query + observer_can_run: false + platform: linux,windows + query: SELECT * FROM team_query WHERE id = 1 +software: + app_store_apps: + - app_store_id: com.example.team-software + labels_exclude_any: + - Label C + - Label D + packages: + - hash_sha256: software-package-hash # My Software Package (my-software.pkg) version 1.0.0, 2.0.0 + install_script: + path: ../lib/team-a/scripts/my-software-package-darwin-install + labels_include_any: + - Label A + - Label B + post_install_script: + path: ../lib/team-a/scripts/my-software-package-darwin-postinstall + pre_install_query: + path: ../lib/team-a/queries/my-software-package-darwin-preinstallquery.yml + self_service: true + uninstall_script: + path: ../lib/team-a/scripts/my-software-package-darwin-uninstall +team_settings: + features: + enable_host_users: true + enable_software_inventory: true + host_expiry_settings: + host_expiry_enabled: false + host_expiry_window: 1 + integrations: + google_calendar: + enable_calendar_events: true + webhook_url: https://some-team-google-calendar-webhook.com + secrets: + - secret: # TODO: Add your enroll secrets here + webhook_settings: + failing_policies_webhook: + destination_url: https://some-team-failing_policies-webhook.com + enable_failing_policies_webhook: false + host_batch_size: 4 + policy_ids: + - 1 + - 2 + - 3 + host_status_webhook: + days_count: 3 + destination_url: https://some-team-host-status-webhook.com + enable_host_status_webhook: false + host_percentage: 2 diff --git a/pkg/spec/gitops.go b/pkg/spec/gitops.go index 4a165f0b02..eeb0d474f9 100644 --- a/pkg/spec/gitops.go +++ b/pkg/spec/gitops.go @@ -76,6 +76,7 @@ type PolicyRunScript struct { type PolicyInstallSoftware struct { PackagePath string `json:"package_path"` AppStoreID string `json:"app_store_id"` + HashSHA256 string `json:"hash_sha256"` } type Query struct { @@ -94,8 +95,9 @@ type SoftwarePackage struct { } type Software struct { - Packages []SoftwarePackage `json:"packages"` - AppStoreApps []fleet.TeamSpecAppStoreApp `json:"app_store_apps"` + Packages []SoftwarePackage `json:"packages"` + AppStoreApps []fleet.TeamSpecAppStoreApp `json:"app_store_apps"` + FleetMaintainedApps []fleet.MaintainedApp `json:"fleet_maintained_apps"` } type GitOps struct { @@ -816,8 +818,8 @@ func parsePolicyInstallSoftware(baseDir string, teamName *string, policy *Policy if policy.InstallSoftware != nil && (policy.InstallSoftware.PackagePath != "" || policy.InstallSoftware.AppStoreID != "") && teamName == nil { return errors.New("install_software can only be set on team policies") } - if policy.InstallSoftware.PackagePath == "" && policy.InstallSoftware.AppStoreID == "" { - return errors.New("install_software must include either a package path or app store app ID") + if policy.InstallSoftware.PackagePath == "" && policy.InstallSoftware.AppStoreID == "" && policy.InstallSoftware.HashSHA256 == "" { + return errors.New("install_software must include either a package_path, an app_store_id or a hash_sha256") } if policy.InstallSoftware.PackagePath != "" && policy.InstallSoftware.AppStoreID != "" { return errors.New("install_software must have only one of package_path or app_store_id") @@ -967,6 +969,9 @@ func parseSoftware(top map[string]json.RawMessage, result *GitOps, baseDir strin return multierror.Append(multiError, fmt.Errorf("failed to unmarshall softwarespec: %v", err)) } } + if software.FleetMaintainedApps != nil { + return multierror.Append(multiError, errors.New("Fleet maintained apps are not currently supported in GitOps")) + } for _, item := range software.AppStoreApps { item := item if item.AppStoreID == "" { diff --git a/pkg/spec/gitops_test.go b/pkg/spec/gitops_test.go index 4a74c7d344..729cb50e2f 100644 --- a/pkg/spec/gitops_test.go +++ b/pkg/spec/gitops_test.go @@ -929,7 +929,7 @@ policies: package_path: ` _, err = gitOpsFromString(t, config) - assert.ErrorContains(t, err, "must include either a package path or app store app ID") + assert.ErrorContains(t, err, "install_software must include either a package_path, an app_store_id or a hash_sha256") config = getTeamConfig([]string{"policies"}) config += ` diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index f3c40b10ec..be5c74697c 100644 --- a/server/datastore/mysql/software_installers.go +++ b/server/datastore/mysql/software_installers.go @@ -654,6 +654,7 @@ SELECT si.team_id, si.title_id, si.storage_id, + si.fleet_maintained_app_id, si.package_ids, si.filename, si.extension, @@ -2166,7 +2167,9 @@ func (ds *Datastore) GetSoftwareInstallers(ctx context.Context, teamID uint) ([] SELECT team_id, title_id, - url + url, + storage_id as hash_sha256, + fleet_maintained_app_id FROM software_installers WHERE global_or_team_id = ? diff --git a/server/datastore/mysql/software_titles.go b/server/datastore/mysql/software_titles.go index ce285103a9..0940ec28c1 100644 --- a/server/datastore/mysql/software_titles.go +++ b/server/datastore/mysql/software_titles.go @@ -321,6 +321,7 @@ SELECT si.url AS package_url, si.install_during_setup as package_install_during_setup, si.storage_id as package_storage_id, + si.fleet_maintained_app_id, vat.self_service as vpp_app_self_service, vat.adam_id as vpp_app_adam_id, vat.install_during_setup as vpp_install_during_setup, @@ -338,7 +339,7 @@ LEFT JOIN software_titles_host_counts sthc ON sthc.software_title_id = st.id AND WHERE %s -- placeholder for filter based on software installed on hosts + software installers AND (%s) -GROUP BY st.id, package_self_service, package_name, package_version, package_platform, package_url, package_install_during_setup, package_storage_id, vpp_app_self_service, vpp_app_adam_id, vpp_app_version, vpp_app_platform, vpp_app_icon_url, vpp_install_during_setup` +GROUP BY st.id, package_self_service, package_name, package_version, package_platform, package_url, package_install_during_setup, package_storage_id, fleet_maintained_app_id, vpp_app_self_service, vpp_app_adam_id, vpp_app_version, vpp_app_platform, vpp_app_icon_url, vpp_install_during_setup` cveJoinType := "LEFT" if opt.VulnerableOnly { diff --git a/server/fleet/software.go b/server/fleet/software.go index 26c24e31e9..25784df6d3 100644 --- a/server/fleet/software.go +++ b/server/fleet/software.go @@ -239,8 +239,9 @@ type SoftwareTitleListResult struct { // BundleIdentifier is used by Apple installers to uniquely identify // the software installed. It's surfaced in software_titles to match // with existing software entries. - BundleIdentifier *string `json:"bundle_identifier,omitempty" db:"bundle_identifier"` - HashSHA256 *string `json:"hash_sha256,omitempty" db:"package_storage_id"` + BundleIdentifier *string `json:"bundle_identifier,omitempty" db:"bundle_identifier"` + HashSHA256 *string `json:"hash_sha256,omitempty" db:"package_storage_id"` + FleetMaintainedAppID *uint `json:"fleet_maintained_app_id,omitempty" db:"fleet_maintained_app_id"` } type SoftwareTitleListOptions struct { diff --git a/server/fleet/software_installer.go b/server/fleet/software_installer.go index 36974a4e0e..6057e90062 100644 --- a/server/fleet/software_installer.go +++ b/server/fleet/software_installer.go @@ -137,7 +137,7 @@ type SoftwareInstaller struct { // URL is the source URL for this installer (set when uploading via batch/gitops). URL string `json:"url" db:"url"` // FleetMaintainedAppID is the related Fleet-maintained app for this installer (if not nil). - FleetMaintainedAppID *uint `json:"-" db:"fleet_maintained_app_id"` + FleetMaintainedAppID *uint `json:"fleet_maintained_app_id" db:"fleet_maintained_app_id"` // AutomaticInstallPolicies is the list of policies that trigger automatic // installation of this software. AutomaticInstallPolicies []AutomaticInstallPolicy `json:"automatic_install_policies" db:"-"` @@ -161,6 +161,10 @@ type SoftwarePackageResponse struct { TitleID *uint `json:"title_id" db:"title_id"` // URL is the source URL for this installer (set when uploading via batch/gitops). URL string `json:"url" db:"url"` + // HashSHA256 is the SHA256 hash of the software installer. + HashSHA256 string `json:"hash_sha256" db:"hash_sha256"` + // ID of the Fleet Maintained App this package uses, if any + FleetMaintainedAppID *uint `json:"fleet_maintained_app_id" db:"fleet_maintained_app_id"` } // VPPAppResponse is the response type used when applying app store apps by batch. diff --git a/server/service/client.go b/server/service/client.go index 3166c05825..ab7ac81529 100644 --- a/server/service/client.go +++ b/server/service/client.go @@ -2097,18 +2097,20 @@ func (c *Client) doGitOpsPolicies(config *spec.GitOps, teamSoftwareInstallers [] // Get software titles of packages for the team. softwareTitleIDsByInstallerURL := make(map[string]uint) softwareTitleIDsByAppStoreAppID := make(map[string]uint) + softwareTitleIDsByHash := make(map[string]uint) for _, softwareInstaller := range teamSoftwareInstallers { if softwareInstaller.TitleID == nil { // Should not happen, but to not panic we just log a warning. logFn("[!] software installer without title id: team_id=%d, url=%s\n", *teamID, softwareInstaller.URL) continue } - if softwareInstaller.URL == "" { + if softwareInstaller.URL == "" && softwareInstaller.HashSHA256 == "" { // Should not happen because we previously applied packages via gitops, but to not panic we just log a warning. logFn("[!] software installer without url: team_id=%d, title_id=%d\n", *teamID, *softwareInstaller.TitleID) continue } softwareTitleIDsByInstallerURL[softwareInstaller.URL] = *softwareInstaller.TitleID + softwareTitleIDsByHash[softwareInstaller.HashSHA256] = *softwareInstaller.TitleID } for _, vppApp := range teamVPPApps { if vppApp.Platform != fleet.MacOSPlatform { @@ -2155,6 +2157,17 @@ func (c *Client) doGitOpsPolicies(config *spec.GitOps, teamSoftwareInstallers [] } config.Policies[i].SoftwareTitleID = &softwareTitleID } + if config.Policies[i].InstallSoftware.HashSHA256 != "" { + softwareTitleID, ok := softwareTitleIDsByHash[config.Policies[i].InstallSoftware.HashSHA256] + if !ok { + // Should not happen because software packages are uploaded first. + if !dryRun { + logFn("[!] software hash without software title ID: %s\n", config.Policies[i].InstallSoftware.HashSHA256) + } + continue + } + config.Policies[i].SoftwareTitleID = &softwareTitleID + } } // Get scripts for the team. diff --git a/server/service/client_profiles.go b/server/service/client_profiles.go index 360adef2d7..49c75a6102 100644 --- a/server/service/client_profiles.go +++ b/server/service/client_profiles.go @@ -36,6 +36,41 @@ func (c *Client) ListProfiles(teamID *uint) ([]*fleet.MDMAppleConfigProfile, err return responseBody.ConfigProfiles, nil } +func (c *Client) ListConfigurationProfiles(teamID *uint) ([]*fleet.MDMConfigProfilePayload, error) { + verb, path := "GET", "/api/latest/fleet/configuration_profiles" + query := make(url.Values) + if teamID != nil { + query.Add("team_id", strconv.FormatUint(uint64(*teamID), 10)) + } + var responseBody listMDMConfigProfilesResponse + if err := c.authenticatedRequestWithQuery(nil, verb, path, &responseBody, query.Encode()); err != nil { + return nil, err + } + return responseBody.Profiles, nil +} + +// Get the contents of a saved profile. +func (c *Client) GetProfileContents(profileID string) ([]byte, error) { + verb, path := "GET", "/api/latest/fleet/mdm/profiles/"+profileID + response, err := c.AuthenticatedDo(verb, path, "alt=media", nil) + if err != nil { + return nil, fmt.Errorf("%s %s: %w", verb, path, err) + } + defer response.Body.Close() + err = c.parseResponse(verb, path, response, nil) + if err != nil { + return nil, fmt.Errorf("%s %s: %w", verb, path, err) + } + if response.StatusCode != http.StatusNoContent { + b, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("reading response body: %w", err) + } + return b, nil + } + return nil, nil +} + func (c *Client) AddProfile(teamID uint, configurationProfile []byte) (uint, error) { if c.token == "" { return 0, errors.New("authentication token is empty") diff --git a/server/service/client_scripts.go b/server/service/client_scripts.go index 893abd3faf..87260af164 100644 --- a/server/service/client_scripts.go +++ b/server/service/client_scripts.go @@ -229,3 +229,25 @@ func (c *Client) ListScripts(query string) ([]*fleet.Script, error) { } return responseBody.Scripts, nil } + +// Get the contents of a saved script. +func (c *Client) GetScriptContents(scriptID uint) ([]byte, error) { + verb, path := "GET", "/api/latest/fleet/scripts/"+fmt.Sprint(scriptID) + response, err := c.AuthenticatedDo(verb, path, "alt=media", nil) + if err != nil { + return nil, fmt.Errorf("%s %s: %w", verb, path, err) + } + defer response.Body.Close() + err = c.parseResponse(verb, path, response, nil) + if err != nil { + return nil, fmt.Errorf("parsing script response: %w", err) + } + if response.StatusCode != http.StatusNoContent { + b, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("reading response body: %w", err) + } + return b, nil + } + return nil, nil +} diff --git a/server/service/client_software.go b/server/service/client_software.go index e3e12e873d..48bb487f9e 100644 --- a/server/service/client_software.go +++ b/server/service/client_software.go @@ -31,6 +31,23 @@ func (c *Client) ListSoftwareTitles(query string) ([]fleet.SoftwareTitleListResu return responseBody.SoftwareTitles, nil } +// GetSoftwareTitleByID retrieves a software title by ID. +// +//nolint:gocritic // ignore captLocal +func (c *Client) GetSoftwareTitleByID(ID uint, teamID *uint) (*fleet.SoftwareTitle, error) { + var query string + if teamID != nil { + query = fmt.Sprintf("team_id=%d", *teamID) + } + verb, path := "GET", "/api/latest/fleet/software/titles/"+fmt.Sprint(ID) + var responseBody getSoftwareTitleResponse + err := c.authenticatedRequestWithQuery(nil, verb, path, &responseBody, query) + if err != nil { + return nil, err + } + return responseBody.SoftwareTitle, nil +} + func (c *Client) ApplyNoTeamSoftwareInstallers(softwareInstallers []fleet.SoftwareInstallerPayload, opts fleet.ApplySpecOptions) ([]fleet.SoftwarePackageResponse, error) { query, err := url.ParseQuery(opts.RawQuery()) if err != nil {