package cmd import ( "fmt" "io" "os" "path/filepath" "strings" "sync/atomic" "time" "github.com/rs/zerolog" "github.com/rs/zerolog/log" "github.com/spf13/cobra" "github.com/spf13/viper" "github.com/zricethezav/gitleaks/v8/config" "github.com/zricethezav/gitleaks/v8/detect" "github.com/zricethezav/gitleaks/v8/report" ) const banner = ` ○ │╲ │ ○ ○ ░ ░ gitleaks ` const configDescription = `config file path order of precedence: 1. --config/-c 2. env var GITLEAKS_CONFIG 3. (target path)/.gitleaks.toml If none of the three options are used, then gitleaks will use the default config` var rootCmd = &cobra.Command{ Use: "gitleaks", Short: "Gitleaks scans code, past or present, for secrets", Version: Version, } const ( BYTE = 1.0 KILOBYTE = BYTE * 1000 MEGABYTE = KILOBYTE * 1000 GIGABYTE = MEGABYTE * 1000 ) func init() { cobra.OnInitialize(initLog) rootCmd.PersistentFlags().StringP("config", "c", "", configDescription) rootCmd.PersistentFlags().Int("exit-code", 1, "exit code when leaks have been encountered") rootCmd.PersistentFlags().StringP("report-path", "r", "", "report file") rootCmd.PersistentFlags().StringP("report-format", "f", "json", "output format (json, jsonextra, csv, junit, sarif, template)") rootCmd.PersistentFlags().StringP("report-template", "", "", "template file used to generate the report (implies --report-format=template)") rootCmd.PersistentFlags().StringP("baseline-path", "b", "", "path to baseline with issues that can be ignored") rootCmd.PersistentFlags().StringP("log-level", "l", "info", "log level (trace, debug, info, warn, error, fatal)") rootCmd.PersistentFlags().BoolP("verbose", "v", false, "show verbose output from scan") rootCmd.PersistentFlags().BoolP("no-color", "", false, "turn off color for verbose output") rootCmd.PersistentFlags().Int("max-target-megabytes", 0, "files larger than this will be skipped") rootCmd.PersistentFlags().BoolP("ignore-gitleaks-allow", "", false, "ignore gitleaks:allow comments") rootCmd.PersistentFlags().Uint("redact", 0, "redact secrets from logs and stdout. To redact only parts of the secret just apply a percent value from 0..100. For example --redact=20 (default 100%)") rootCmd.Flag("redact").NoOptDefVal = "100" rootCmd.PersistentFlags().Bool("no-banner", false, "suppress banner") rootCmd.PersistentFlags().StringSlice("enable-rule", []string{}, "only enable specific rules by id") rootCmd.PersistentFlags().StringP("gitleaks-ignore-path", "i", ".", "path to .gitleaksignore file or folder containing one") rootCmd.PersistentFlags().Int("max-decode-depth", 0, "allow recursive decoding up to this depth (default \"0\", no decoding is done)") err := viper.BindPFlag("config", rootCmd.PersistentFlags().Lookup("config")) if err != nil { log.Fatal().Msgf("err binding config %s", err.Error()) } } func initLog() { zerolog.SetGlobalLevel(zerolog.InfoLevel) ll, err := rootCmd.Flags().GetString("log-level") if err != nil { log.Fatal().Msg(err.Error()) } switch strings.ToLower(ll) { case "trace": zerolog.SetGlobalLevel(zerolog.TraceLevel) case "debug": zerolog.SetGlobalLevel(zerolog.DebugLevel) case "info": zerolog.SetGlobalLevel(zerolog.InfoLevel) case "warn": zerolog.SetGlobalLevel(zerolog.WarnLevel) case "err", "error": zerolog.SetGlobalLevel(zerolog.ErrorLevel) case "fatal": zerolog.SetGlobalLevel(zerolog.FatalLevel) default: zerolog.SetGlobalLevel(zerolog.InfoLevel) } } func initConfig(source string) { hideBanner, err := rootCmd.Flags().GetBool("no-banner") if err != nil { log.Fatal().Msg(err.Error()) } if !hideBanner { _, _ = fmt.Fprint(os.Stderr, banner) } cfgPath, err := rootCmd.Flags().GetString("config") if err != nil { log.Fatal().Msg(err.Error()) } if cfgPath != "" { viper.SetConfigFile(cfgPath) log.Debug().Msgf("using gitleaks config %s from `--config`", cfgPath) } else if os.Getenv("GITLEAKS_CONFIG") != "" { envPath := os.Getenv("GITLEAKS_CONFIG") viper.SetConfigFile(envPath) log.Debug().Msgf("using gitleaks config from GITLEAKS_CONFIG env var: %s", envPath) } else { fileInfo, err := os.Stat(source) if err != nil { log.Fatal().Msg(err.Error()) } if !fileInfo.IsDir() { log.Debug().Msgf("unable to load gitleaks config from %s since --source=%s is a file, using default config", filepath.Join(source, ".gitleaks.toml"), 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, ".gitleaks.toml")); os.IsNotExist(err) { log.Debug().Msgf("no gitleaks config found in path %s, using default gitleaks config", filepath.Join(source, ".gitleaks.toml")) viper.SetConfigType("toml") if err = viper.ReadConfig(strings.NewReader(config.DefaultConfig)); err != nil { log.Fatal().Msgf("err reading default config toml %s", err.Error()) } return } else { log.Debug().Msgf("using existing gitleaks config %s from `(--source)/.gitleaks.toml`", filepath.Join(source, ".gitleaks.toml")) } viper.AddConfigPath(source) viper.SetConfigName(".gitleaks") viper.SetConfigType("toml") } if err := viper.ReadInConfig(); err != nil { log.Fatal().Msgf("unable to load gitleaks config, err: %s", err) } } func Execute() { if err := rootCmd.Execute(); err != nil { if strings.Contains(err.Error(), "unknown flag") { // exit code 126: Command invoked cannot execute os.Exit(126) } log.Fatal().Msg(err.Error()) } } func Config(cmd *cobra.Command) config.Config { 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") return cfg } func Detector(cmd *cobra.Command, cfg config.Config, source string) *detect.Detector { var err error // Setup common detector detector := detect.NewDetector(cfg) if detector.MaxDecodeDepth, err = cmd.Flags().GetInt("max-decode-depth"); err != nil { log.Fatal().Err(err).Msg("") } // set color flag at first if detector.NoColor, err = cmd.Flags().GetBool("no-color"); err != nil { log.Fatal().Err(err).Msg("") } // also init logger again without color if detector.NoColor { log.Logger = log.Output(zerolog.ConsoleWriter{ Out: os.Stderr, NoColor: detector.NoColor, }) } detector.Config.Path, err = cmd.Flags().GetString("config") if err != nil { log.Fatal().Err(err).Msg("") } // if config path is not set, then use the {source}/.gitleaks.toml path. // note that there may not be a `{source}/.gitleaks.toml` file, this is ok. if detector.Config.Path == "" { detector.Config.Path = filepath.Join(source, ".gitleaks.toml") } // 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().GetUint("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 ignore gitleaks:allow flag if detector.IgnoreGitleaksAllow, err = cmd.Flags().GetBool("ignore-gitleaks-allow"); err != nil { log.Fatal().Err(err).Msg("") } gitleaksIgnorePath, err := cmd.Flags().GetString("gitleaks-ignore-path") if err != nil { log.Fatal().Err(err).Msg("could not get .gitleaksignore path") } if fileExists(gitleaksIgnorePath) { if err = detector.AddGitleaksIgnore(gitleaksIgnorePath); err != nil { log.Fatal().Err(err).Msg("could not call AddGitleaksIgnore") } } if fileExists(filepath.Join(gitleaksIgnorePath, ".gitleaksignore")) { if err = detector.AddGitleaksIgnore(filepath.Join(gitleaksIgnorePath, ".gitleaksignore")); err != nil { log.Fatal().Err(err).Msg("could not call AddGitleaksIgnore") } } if fileExists(filepath.Join(source, ".gitleaksignore")) { if err = detector.AddGitleaksIgnore(filepath.Join(source, ".gitleaksignore")); err != nil { log.Fatal().Err(err).Msg("could not call AddGitleaksIgnore") } } // 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 of a gitleaks report generated using the default format: %s", err) } } // If set, only apply rules that are defined in the flag rules, _ := cmd.Flags().GetStringSlice("enable-rule") if len(rules) > 0 { log.Info().Msg("Overriding enabled rules: " + strings.Join(rules, ", ")) ruleOverride := make(map[string]config.Rule) for _, ruleName := range rules { if r, ok := cfg.Rules[ruleName]; ok { ruleOverride[ruleName] = r } else { log.Fatal().Msgf("Requested rule %s not found in rules", ruleName) } } detector.Config.Rules = ruleOverride } // Validate report settings. reportPath := mustGetStringFlag("report-path") if reportPath != "" { if reportPath != report.StdoutReportPath { // Ensure the path is writable. if f, err := os.Create(reportPath); err != nil { log.Fatal().Err(err).Msgf("Report path is not writable: %s", reportPath) } else { _ = f.Close() _ = os.Remove(reportPath) } } // Build report writer. var ( reporter report.Reporter reportFormat = mustGetStringFlag("report-format") reportTemplate = mustGetStringFlag("report-template") ) switch strings.TrimSpace(strings.ToLower(reportFormat)) { case "csv": reporter = &report.CsvReporter{} case "json": reporter = &report.JsonReporter{} case "junit": reporter = &report.JunitReporter{} case "sarif": reporter = &report.SarifReporter{ OrderedRules: cfg.GetOrderedRules(), } case "template": if reporter, err = report.NewTemplateReporter(reportTemplate); err != nil { log.Fatal().Err(err).Msg("Invalid report template") } default: log.Fatal().Msgf("unknown report format %s", reportFormat) } // Sanity check. if reportTemplate != "" && reportFormat != "template" { log.Fatal().Msgf("Report format must be 'template' if --report-template is specified") } detector.ReportPath = reportPath detector.Reporter = reporter } return detector } func mustGetStringFlag(name string) string { reportPath, err := rootCmd.Flags().GetString(name) if err != nil { log.Fatal().Msg(err.Error()) } return reportPath } func bytesConvert(bytes uint64) string { unit := "" value := float32(bytes) switch { case bytes >= GIGABYTE: unit = "GB" value = value / GIGABYTE case bytes >= MEGABYTE: unit = "MB" value = value / MEGABYTE case bytes >= KILOBYTE: unit = "KB" value = value / KILOBYTE case bytes >= BYTE: unit = "bytes" case bytes == 0: return "0" } stringValue := strings.TrimSuffix( fmt.Sprintf("%.2f", value), ".00", ) return fmt.Sprintf("%s %s", stringValue, unit) } func findingSummaryAndExit(detector *detect.Detector, findings []report.Finding, exitCode int, start time.Time, err error) { totalBytes := atomic.LoadUint64(&detector.TotalBytes) bytesMsg := fmt.Sprintf("scanned ~%d bytes (%s)", totalBytes, bytesConvert(totalBytes)) if err == nil { log.Info().Msgf("%s in %s", bytesMsg, 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().Msg(bytesMsg) 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") } } // write report if desired if detector.Reporter != nil && len(findings) > 0 { var ( file io.WriteCloser reportErr error ) if detector.ReportPath == report.StdoutReportPath { file = os.Stdout } else { // Open the file. if file, reportErr = os.Create(detector.ReportPath); reportErr != nil { goto ReportEnd } defer func() { _ = file.Close() }() } // Write to the file. if reportErr = detector.Reporter.Write(file, findings); reportErr != nil { goto ReportEnd } ReportEnd: if reportErr != nil { log.Fatal().Err(reportErr).Msg("failed to write report") } } if err != nil { os.Exit(1) } if len(findings) != 0 { os.Exit(exitCode) } } func fileExists(fileName string) bool { // check for a .gitleaksignore 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() }