// MIT License // Copyright (c) 2019 Zachary Rice // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. package cmd import ( _ "embed" "fmt" "io/ioutil" "os" "os/exec" "path/filepath" "strings" "time" "github.com/Infisical/infisical-merge/config" "github.com/Infisical/infisical-merge/detect" "github.com/Infisical/infisical-merge/packages/util" "github.com/Infisical/infisical-merge/report" "github.com/manifoldco/promptui" "github.com/posthog/posthog-go" "github.com/rs/zerolog/log" "github.com/spf13/cobra" "github.com/spf13/viper" ) const configDescription = `config file path order of precedence: 1. --config flag 2. env var INFISICAL_SCAN_CONFIG 3. (--source/-s)/.infisical-scan.toml If none of the three options are used, then Infisical will use the default scan config` //go:embed pre-commit-script/pre-commit.sh var preCommitTemplate []byte //go:embed pre-commit-script/pre-commit-without-bang.sh var preCommitTemplateAppend []byte const ( defaultHooksPath = ".git/hooks/" preCommitFile = "pre-commit" ) func init() { // scan flag for only scan command scanCmd.Flags().String("log-opts", "", "git log options") scanCmd.Flags().Bool("no-git", false, "treat git repo as a regular directory and scan those files, --log-opts has no effect on the scan when --no-git is set") scanCmd.Flags().Bool("pipe", false, "scan input from stdin, ex: `cat some_file | infisical scan --pipe`") scanCmd.Flags().Bool("follow-symlinks", false, "scan files that are symlinks to other files") // global scan flags scanCmd.PersistentFlags().StringP("config", "c", "", configDescription) scanCmd.PersistentFlags().Int("exit-code", 1, "exit code when leaks have been encountered") scanCmd.PersistentFlags().StringP("source", "s", ".", "path to source") scanCmd.PersistentFlags().StringP("report-path", "r", "", "report file") scanCmd.PersistentFlags().StringP("report-format", "f", "json", "output format (json, csv, sarif)") scanCmd.PersistentFlags().StringP("baseline-path", "b", "", "path to baseline with issues that can be ignored") scanCmd.PersistentFlags().BoolP("verbose", "v", false, "show verbose output from scan (which file, where in the file, what secret)") scanCmd.PersistentFlags().BoolP("no-color", "", false, "turn off color for verbose output") scanCmd.PersistentFlags().Int("max-target-megabytes", 0, "files larger than this will be skipped") scanCmd.PersistentFlags().Bool("redact", false, "redact secrets from logs and stdout") // scan git changes command flags scanGitChangesCmd.Flags().Bool("staged", false, "detect secrets in a --staged state") scanGitChangesCmd.Flags().String("log-opts", "", "git log options") // find config source err := viper.BindPFlag("config", scanCmd.PersistentFlags().Lookup("config")) if err != nil { log.Fatal().Msgf("err binding config %s", err.Error()) } // add flags to main scanCmd.AddCommand(scanGitChangesCmd) rootCmd.AddCommand(scanCmd) installCmd.Flags().Bool("pre-commit-hook", false, "installs pre commit hook for Git repository") scanCmd.AddCommand(installCmd) } func initScanConfig(cmd *cobra.Command) { cfgPath, err := cmd.Flags().GetString("config") if err != nil { log.Fatal().Msg(err.Error()) } if cfgPath != "" { viper.SetConfigFile(cfgPath) log.Debug().Msgf("using scan config %s from `--config`", cfgPath) } else if os.Getenv(config.DefaultScanConfigEnvName) != "" { envPath := os.Getenv(config.DefaultScanConfigEnvName) viper.SetConfigFile(envPath) log.Debug().Msgf("using scan config from %s env var: %s", config.DefaultScanConfigEnvName, envPath) } else { source, err := cmd.Flags().GetString("source") if err != nil { log.Fatal().Msg(err.Error()) } fileInfo, err := os.Stat(source) if err != nil { log.Fatal().Msg(err.Error()) } if !fileInfo.IsDir() { log.Debug().Msgf("unable to load scan config from %s since --source=%s is a file, using default config", filepath.Join(source, config.DefaultScanConfigFileName), source) viper.SetConfigType("toml") if err = viper.ReadConfig(strings.NewReader(config.DefaultConfig)); err != nil { log.Fatal().Msgf("err reading toml %s", err.Error()) } return } if _, err := os.Stat(filepath.Join(source, config.DefaultScanConfigFileName)); os.IsNotExist(err) { log.Debug().Msgf("no scan config found in path %s, using default scan config", filepath.Join(source, config.DefaultScanConfigFileName)) viper.SetConfigType("toml") if err = viper.ReadConfig(strings.NewReader(config.DefaultConfig)); err != nil { log.Fatal().Msgf("err reading default scan config toml %s", err.Error()) } return } else { log.Debug().Msgf("using existing scan config %s from `(--source)/%s`", filepath.Join(source, config.DefaultScanConfigFileName), config.DefaultScanConfigFileName) } viper.AddConfigPath(source) viper.SetConfigName(config.DefaultScanConfigFileName) viper.SetConfigType("toml") } if err := viper.ReadInConfig(); err != nil { log.Fatal().Msgf("unable to load scan config, err: %s", err) } } var installCmd = &cobra.Command{ Use: "install", Short: "Install scanning scripts and tools. Use --help flag to see all options", Args: cobra.ExactArgs(0), Run: func(cmd *cobra.Command, args []string) { installPrecommit := cmd.Flags().Changed("pre-commit-hook") if installPrecommit { hooksPath, err := getHooksPath() if err != nil { fmt.Printf("Error: %s\n", err) return } if hooksPath != ".git/hooks" { defaultHookOverride, err := overrideDefaultHooksPath(hooksPath) if err != nil { fmt.Printf("Error: %s\n", err) } if defaultHookOverride { ConfigureGitHooksPath() log.Info().Msgf("To switch back previous githooks manager run: git config core.hooksPath %s\n", hooksPath) return } else { log.Warn().Msgf("To automatically configure this hook, you need to switch the path of the Hooks. Alternatively, you can manually configure this hook by setting your pre-commit script to run command [infisical scan git-changes -v --staged].\n") return } } err = createOrUpdatePreCommitFile(hooksPath) if err != nil { fmt.Printf("Error: %s\n", err) return } log.Info().Msgf("Pre-commit hook successfully added. Infisical scan should now run on each commit you make\n") Telemetry.CaptureEvent("cli-command:install --pre-commit-hook", posthog.NewProperties().Set("version", util.CLI_VERSION)) return } }} var scanCmd = &cobra.Command{ Use: "scan", Short: "Scan for leaked secrets in git history, directories, and files", Run: func(cmd *cobra.Command, args []string) { initScanConfig(cmd) var ( vc config.ViperConfig findings []report.Finding err error ) // Load config if err = viper.Unmarshal(&vc); err != nil { log.Fatal().Err(err).Msg("Failed to load config") } cfg, err := vc.Translate() if err != nil { log.Fatal().Err(err).Msg("Failed to load config") } cfg.Path, _ = cmd.Flags().GetString("config") // start timer start := time.Now() // Setup detector detector := detect.NewDetector(cfg) detector.Config.Path, err = cmd.Flags().GetString("config") if err != nil { log.Fatal().Err(err).Msg("") } source, err := cmd.Flags().GetString("source") if err != nil { log.Fatal().Err(err).Msg("") } // if config path is not set, then use the {source}/.infisical-scan.toml path. // note that there may not be a `{source}/.infisical-scan.toml` file, this is ok. if detector.Config.Path == "" { detector.Config.Path = filepath.Join(source, config.DefaultScanConfigFileName) } // set verbose flag if detector.Verbose, err = cmd.Flags().GetBool("verbose"); err != nil { log.Fatal().Err(err).Msg("") } // set redact flag if detector.Redact, err = cmd.Flags().GetBool("redact"); err != nil { log.Fatal().Err(err).Msg("") } if detector.MaxTargetMegaBytes, err = cmd.Flags().GetInt("max-target-megabytes"); err != nil { log.Fatal().Err(err).Msg("") } // set color flag if detector.NoColor, err = cmd.Flags().GetBool("no-color"); err != nil { log.Fatal().Err(err).Msg("") } if fileExists(filepath.Join(source, config.DefaultInfisicalIgnoreFineName)) { if err = detector.AddGitleaksIgnore(filepath.Join(source, config.DefaultInfisicalIgnoreFineName)); err != nil { log.Fatal().Err(err).Msg("could not call AddInfisicalIgnore") } } // ignore findings from the baseline (an existing report in json format generated earlier) baselinePath, _ := cmd.Flags().GetString("baseline-path") if baselinePath != "" { err = detector.AddBaseline(baselinePath, source) if err != nil { log.Error().Msgf("Could not load baseline. The path must point to report generated by `infisical scan` using the default format: %s", err) } } // set follow symlinks flag if detector.FollowSymlinks, err = cmd.Flags().GetBool("follow-symlinks"); err != nil { log.Fatal().Err(err).Msg("") } // set exit code exitCode, err := cmd.Flags().GetInt("exit-code") if err != nil { log.Fatal().Err(err).Msg("could not get exit code") } // determine what type of scan: // - git: scan the history of the repo // - no-git: scan files by treating the repo as a plain directory noGit, err := cmd.Flags().GetBool("no-git") if err != nil { log.Fatal().Err(err).Msg("could not call GetBool() for no-git") } fromPipe, err := cmd.Flags().GetBool("pipe") if err != nil { log.Fatal().Err(err) } log.Info().Msgf("scanning for exposed secrets...") // start the detector scan if noGit { findings, err = detector.DetectFiles(source) if err != nil { // don't exit on error, just log it log.Error().Err(err).Msg("") } } else if fromPipe { findings, err = detector.DetectReader(os.Stdin, 10) if err != nil { // log fatal to exit, no need to continue since a report // will not be generated when scanning from a pipe...for now log.Fatal().Err(err).Msg("") } } else { var logOpts string logOpts, err = cmd.Flags().GetString("log-opts") if err != nil { log.Fatal().Err(err).Msg("") } findings, err = detector.DetectGit(source, logOpts, detect.DetectType) if err != nil { // don't exit on error, just log it log.Error().Err(err).Msg("") } } // log info about the scan if err == nil { log.Info().Msgf("scan completed in %s", FormatDuration(time.Since(start))) if len(findings) != 0 { log.Warn().Msgf("leaks found: %d", len(findings)) } else { log.Info().Msg("no leaks found") } } else { log.Warn().Msgf("partial scan completed in %s", FormatDuration(time.Since(start))) if len(findings) != 0 { log.Warn().Msgf("%d leaks found in partial scan", len(findings)) } else { log.Warn().Msg("no leaks found in partial scan") } } Telemetry.CaptureEvent("cli-command:scan", posthog.NewProperties().Set("risks", len(findings)).Set("version", util.CLI_VERSION)) // write report if desired reportPath, _ := cmd.Flags().GetString("report-path") ext, _ := cmd.Flags().GetString("report-format") if reportPath != "" { if err := report.Write(findings, cfg, ext, reportPath); err != nil { log.Fatal().Err(err).Msg("could not write") } } if err != nil { os.Exit(1) } if len(findings) != 0 { os.Exit(exitCode) } }, } var scanGitChangesCmd = &cobra.Command{ Use: "git-changes", Short: "Scan for secrets in uncommitted changes in a git repo", Run: func(cmd *cobra.Command, args []string) { initScanConfig(cmd) var vc config.ViperConfig if err := viper.Unmarshal(&vc); err != nil { log.Fatal().Err(err).Msg("Failed to load config") } cfg, err := vc.Translate() if err != nil { log.Fatal().Err(err).Msg("Failed to load config") } cfg.Path, _ = cmd.Flags().GetString("config") exitCode, _ := cmd.Flags().GetInt("exit-code") staged, _ := cmd.Flags().GetBool("staged") start := time.Now() // Setup detector detector := detect.NewDetector(cfg) detector.Config.Path, err = cmd.Flags().GetString("config") if err != nil { log.Fatal().Err(err).Msg("") } source, err := cmd.Flags().GetString("source") if err != nil { log.Fatal().Err(err).Msg("") } // if config path is not set, then use the {source}/.infisical-scan.toml path. // note that there may not be a `{source}/.infisical-scan.toml` file, this is ok. if detector.Config.Path == "" { detector.Config.Path = filepath.Join(source, config.DefaultScanConfigFileName) } // set verbose flag if detector.Verbose, err = cmd.Flags().GetBool("verbose"); err != nil { log.Fatal().Err(err).Msg("") } // set redact flag if detector.Redact, err = cmd.Flags().GetBool("redact"); err != nil { log.Fatal().Err(err).Msg("") } if detector.MaxTargetMegaBytes, err = cmd.Flags().GetInt("max-target-megabytes"); err != nil { log.Fatal().Err(err).Msg("") } // set color flag if detector.NoColor, err = cmd.Flags().GetBool("no-color"); err != nil { log.Fatal().Err(err).Msg("") } if fileExists(filepath.Join(source, config.DefaultInfisicalIgnoreFineName)) { if err = detector.AddGitleaksIgnore(filepath.Join(source, config.DefaultInfisicalIgnoreFineName)); err != nil { log.Fatal().Err(err).Msg("could not call AddInfisicalIgnore") } } // get log options for git scan logOpts, err := cmd.Flags().GetString("log-opts") if err != nil { log.Fatal().Err(err).Msg("") } log.Info().Msgf("scanning for exposed secrets...") // start git scan var findings []report.Finding if staged { findings, err = detector.DetectGit(source, logOpts, detect.ProtectStagedType) } else { findings, err = detector.DetectGit(source, logOpts, detect.ProtectType) } if err != nil { // don't exit on error, just log it log.Error().Err(err).Msg("") } // log info about the scan log.Info().Msgf("scan completed in %s", FormatDuration(time.Since(start))) if len(findings) != 0 { log.Warn().Msgf("leaks found: %d", len(findings)) } else { log.Info().Msg("no leaks found") } Telemetry.CaptureEvent("cli-command:scan git-changes", posthog.NewProperties().Set("risks", len(findings)).Set("version", util.CLI_VERSION)) reportPath, _ := cmd.Flags().GetString("report-path") ext, _ := cmd.Flags().GetString("report-format") if reportPath != "" { if err = report.Write(findings, cfg, ext, reportPath); err != nil { log.Fatal().Err(err).Msg("") } } if len(findings) != 0 { os.Exit(exitCode) } }, } func fileExists(fileName string) bool { // check for a .infisicalignore file info, err := os.Stat(fileName) if err != nil && !os.IsNotExist(err) { return false } if info != nil && err == nil { if !info.IsDir() { return true } } return false } func FormatDuration(d time.Duration) string { scale := 100 * time.Second // look for the max scale that is smaller than d for scale > d { scale = scale / 10 } return d.Round(scale / 100).String() } func overrideDefaultHooksPath(managedHook string) (bool, error) { YES := "Yes" NO := "No" options := []string{YES, NO} optionsPrompt := promptui.Select{ Label: fmt.Sprintf("Your hooks path is set to [%s] but needs to be [.git/hooks] for automatic configuration. Would you like to switch? ", managedHook), Items: options, Size: 2, } _, selectedOption, err := optionsPrompt.Run() if err != nil { return false, err } return selectedOption == YES, err } func ConfigureGitHooksPath() { cmd := exec.Command("git", "config", "core.hooksPath", ".git/hooks") cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { log.Fatal().Msgf("Failed to configure git hooks path: %v", err) } } // GetGitRoot returns the root directory of the current Git repository. func GetGitRoot() (string, error) { cmd := exec.Command("git", "rev-parse", "--show-toplevel") output, err := cmd.Output() if err != nil { return "", fmt.Errorf("failed to get git root directory: %w", err) } gitRoot := strings.TrimSpace(string(output)) // Remove any trailing newline return gitRoot, nil } func getHooksPath() (string, error) { out, err := exec.Command("git", "config", "core.hooksPath").Output() if err != nil { if len(out) == 0 { out = []byte(".git/hooks") // set the default hook } else { log.Error().Msgf("Failed to get Git hooks path: %s\nOutput: %s\n", err, out) } } hooksPath := strings.TrimSpace(string(out)) return hooksPath, nil } func createOrUpdatePreCommitFile(hooksPath string) error { // File doesn't exist, create a new one rootGitRepoPath, err := GetGitRoot() if err != nil { return err } filePath := fmt.Sprintf("%s/%s/%s", rootGitRepoPath, hooksPath, preCommitFile) _, err = os.Stat(filePath) if err == nil { // File already exists, check if it contains the managed comments content, err := ioutil.ReadFile(filePath) if err != nil { return fmt.Errorf("failed to read pre-commit file: %s", err) } if strings.Contains(string(content), "# MANAGED BY INFISICAL CLI (Do not modify): START") && strings.Contains(string(content), "# MANAGED BY INFISICAL CLI (Do not modify): END") { return nil } // File already exists, append the template content file, err := os.OpenFile(filePath, os.O_APPEND|os.O_WRONLY, 0755) if err != nil { return fmt.Errorf("failed to open pre-commit file: %s", err) } defer file.Close() _, err = file.Write(preCommitTemplateAppend) if err != nil { return fmt.Errorf("failed to append to pre-commit file: %s", err) } } else if os.IsNotExist(err) { err = os.WriteFile(filePath, preCommitTemplate, 0755) if err != nil { return fmt.Errorf("failed to create pre-commit file: %s", err) } } else { // Error occurred while checking file status return fmt.Errorf("failed to check pre-commit file status: %s", err) } return nil }