From d716265641cd377a9457ace8ffdaa22c24d2de90 Mon Sep 17 00:00:00 2001 From: Scott Gress Date: Tue, 6 May 2025 15:25:44 -0500 Subject: [PATCH] Add "generate-gitops" command (#28555) For #27476 # Checklist for submitter - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files) for more information. - [X] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) # Details This PR adds a new command `generate-gitops` to the `fleetctl` tool. The purpose of this command is to output GitOps-ready files that can then be used with `fleetctl-gitops`. The general usage of the command is: ``` fleectl generate-gitops --dir /path/to/dir/to/add/files/to ``` By default, the outputted files will not contain sensitive data, but will instead add comments where the data needs to be replaced by a user. In cases where sensitive data is redacted, the tool outputs warnings to the user indicating which keys need to be updated. The tool uses existing APIs to gather data for use in generating configuration files. In some cases new API client methods needed to be added to support the tool: * ListConfigurationProfiles * GetProfileContents * GetScriptContents * GetSoftwareTitleByID Additionally, the response for the /api/latest/fleet/software/batch endpoint was updated slightly to return `HashSHA256` for the software installers. This allows policies that automatically install software to refer to that software by hash. Other options that we may or may not choose to document at this time: * `--insecure`: outputs sensitive data in plaintext instead of leaving comments * `--print`: prints the output to stdout instead of writing files * `--key`: outputs the value at a keypath to stdout, e.g. `--key agent_options.config` * `--team`: only generates config for the specified team name * `--force`: overwrites files in the given directory (defaults to false, which errors if the dir is not empty) # Technical notes The command is implemented using a `GenerateGitopsCommand` type which holds some state (like a list of software and scripts encountered) as well as a Fleet client instance (which may be a mock instance for tests) and the CLI context (containing things like flags and output writers). The actual "action" of the CLI command calls the `Run()` method of the `GenerateGitopsCommand` var, which delegates most of the work to other methods like `generateOrgSettings()`, `generateControls()`, etc. Wherever possible, the subroutines use reflection to translate Go struct fields into JSON property names. This guarantees that the correct keys are written to config files, and protects against the unlikely event of keys changing. When sensitive data is encountered, the subroutines call `AddComment()` to get a new token to add to the config files. These tokens are replaced with comments like `# TODO - Add your enrollment secrets here` in the final output. # Known issues / TODOs: * The `macos_setup` configuration is not output by this tool yet. More planning is required for this. In the meantime, if the tool detects that `macos_setup` is configured on the server, it outputs a key with an invalid value and prints a warning to the user that they'll need to configure it themselves. * `yara_rules` are not output yet. The tool adds a warning that if you have Yara rules (which you can only upload via GitOps right now) that you'll have to migrate them manually. Supporting this will require a new API that we'll have to discuss the authz for, so punting on it for now. * Fleet maintained apps are not supported by GitOps yet (coming in https://github.com/fleetdm/fleet/issues/24469). In the meantime, this tool will output a `fleet_maintained_apps` key and trigger a warning, and GitOps will fail if that key is present. --------- Co-authored-by: Lucas Manuel Rodriguez Co-authored-by: Noah Talerman <47070608+noahtalerman@users.noreply.github.com> --- changes/27476-add-generate-gitops-cmd | 1 + cmd/fleetctl/fleetctl.go | 1 + cmd/fleetctl/generate_gitops.go | 1294 +++++++++++++++++ cmd/fleetctl/generate_gitops_test.go | 948 ++++++++++++ .../testdata/generateGitops/appConfig.json | 308 ++++ .../expectedGlobalControls.yaml | 34 + .../expectedGlobalPolicies.yaml | 12 + .../generateGitops/expectedGlobalQueries.yaml | 13 + .../generateGitops/expectedLabels.yaml | 11 + .../expectedOrgSettings-insecure.yaml | 121 ++ .../generateGitops/expectedOrgSettings.yaml | 121 ++ .../generateGitops/expectedTeamControls.yaml | 5 + .../generateGitops/expectedTeamPolicies.yaml | 9 + .../generateGitops/expectedTeamQueries.yaml | 10 + .../expectedTeamSettings-insecure.yaml | 26 + .../generateGitops/expectedTeamSettings.yaml | 26 + .../generateGitops/expectedTeamSoftware.yaml | 19 + .../testdata/generateGitops/teamConfig.json | 242 +++ .../generateGitops/test_dir_free/default.yml | 178 +++ .../profiles/global-macos-json-profile.json | 1 + ...al-macos-mobileconfig-profile.mobileconfig | 1 + .../lib/profiles/global-windows-profile.xml | 1 + .../test_dir_premium/default.yml | 176 +++ .../lib/no-team/scripts/Script Z.ps1 | 2 + ...am-macos-mobileconfig-profile.mobileconfig | 1 + ...oftware-package-darwin-preinstallquery.yml | 1 + .../lib/team-a/scripts/Script B.ps1 | 1 + .../my-software-package-darwin-install | 1 + .../my-software-package-darwin-postinstall | 1 + .../my-software-package-darwin-uninstall | 1 + .../test_dir_premium/teams/no-team.yml | 33 + .../test_dir_premium/teams/team-a.yml | 105 ++ pkg/spec/gitops.go | 13 +- pkg/spec/gitops_test.go | 2 +- server/datastore/mysql/software_installers.go | 5 +- server/datastore/mysql/software_titles.go | 3 +- server/fleet/software.go | 5 +- server/fleet/software_installer.go | 6 +- server/service/client.go | 15 +- server/service/client_profiles.go | 35 + server/service/client_scripts.go | 22 + server/service/client_software.go | 17 + 42 files changed, 3816 insertions(+), 11 deletions(-) create mode 100644 changes/27476-add-generate-gitops-cmd create mode 100644 cmd/fleetctl/generate_gitops.go create mode 100644 cmd/fleetctl/generate_gitops_test.go create mode 100644 cmd/fleetctl/testdata/generateGitops/appConfig.json create mode 100644 cmd/fleetctl/testdata/generateGitops/expectedGlobalControls.yaml create mode 100644 cmd/fleetctl/testdata/generateGitops/expectedGlobalPolicies.yaml create mode 100644 cmd/fleetctl/testdata/generateGitops/expectedGlobalQueries.yaml create mode 100644 cmd/fleetctl/testdata/generateGitops/expectedLabels.yaml create mode 100644 cmd/fleetctl/testdata/generateGitops/expectedOrgSettings-insecure.yaml create mode 100644 cmd/fleetctl/testdata/generateGitops/expectedOrgSettings.yaml create mode 100644 cmd/fleetctl/testdata/generateGitops/expectedTeamControls.yaml create mode 100644 cmd/fleetctl/testdata/generateGitops/expectedTeamPolicies.yaml create mode 100644 cmd/fleetctl/testdata/generateGitops/expectedTeamQueries.yaml create mode 100644 cmd/fleetctl/testdata/generateGitops/expectedTeamSettings-insecure.yaml create mode 100644 cmd/fleetctl/testdata/generateGitops/expectedTeamSettings.yaml create mode 100644 cmd/fleetctl/testdata/generateGitops/expectedTeamSoftware.yaml create mode 100644 cmd/fleetctl/testdata/generateGitops/teamConfig.json create mode 100644 cmd/fleetctl/testdata/generateGitops/test_dir_free/default.yml create mode 100644 cmd/fleetctl/testdata/generateGitops/test_dir_free/lib/profiles/global-macos-json-profile.json create mode 100644 cmd/fleetctl/testdata/generateGitops/test_dir_free/lib/profiles/global-macos-mobileconfig-profile.mobileconfig create mode 100644 cmd/fleetctl/testdata/generateGitops/test_dir_free/lib/profiles/global-windows-profile.xml create mode 100644 cmd/fleetctl/testdata/generateGitops/test_dir_premium/default.yml create mode 100644 cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/no-team/scripts/Script Z.ps1 create mode 100644 cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/team-a/profiles/team-macos-mobileconfig-profile.mobileconfig create mode 100644 cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/team-a/queries/my-software-package-darwin-preinstallquery.yml create mode 100644 cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/team-a/scripts/Script B.ps1 create mode 100644 cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/team-a/scripts/my-software-package-darwin-install create mode 100644 cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/team-a/scripts/my-software-package-darwin-postinstall create mode 100644 cmd/fleetctl/testdata/generateGitops/test_dir_premium/lib/team-a/scripts/my-software-package-darwin-uninstall create mode 100644 cmd/fleetctl/testdata/generateGitops/test_dir_premium/teams/no-team.yml create mode 100644 cmd/fleetctl/testdata/generateGitops/test_dir_premium/teams/team-a.yml 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 {