diff --git a/README.md b/README.md index 15f56ae4..aec31a6b 100644 --- a/README.md +++ b/README.md @@ -248,7 +248,7 @@ Global flags work with every subcommand. Combine them with configuration files a |------|------|---------|-------------| | `--config` | string | | Path to a YAML or JSON configuration file. | | `--log-level` | string | `info` | Logging level: `trace`, `debug`, `info`, `warn`, `error`, `fatal`, or `none`. | -| `--stdout-format` | string | `yaml` | `yaml`, `json`, or `sarif` output on stdout. | +| `--stdout-format` | string | `yaml` | `yaml`, `json`, `sarif`, or `human` output on stdout. | | `--report-path` | string slice | | Write findings to one or more files; format is inferred from the extension. | | `--ignore-on-exit` | enum | `none` | Control exit codes: `all`, `results`, `errors`, or `none`. | | `--max-target-megabytes` | int | `0` | Skip files larger than the threshold (0 disables the check). | @@ -291,6 +291,8 @@ You can still override values via CLI flags; the CLI always wins over config val --stdout-format json \ --report-path build/2ms.sarif \ --report-path build/2ms.yaml + +Set `--stdout-format human` for a terse, human-friendly summary on the console (great for local runs), while still writing machine-readable reports via `--report-path`. ``` SARIF reports plug directly into GitHub Advanced Security or other code-scanning dashboards. All outputs include rule metadata, severity scores, file locations, and (when enabled) validation status. diff --git a/cmd/config.go b/cmd/config.go index fb4cd74b..67fc787e 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -17,6 +17,8 @@ var ( errInvalidReportExtension = fmt.Errorf("invalid report extension") ) +const humanStdoutFormat = "human" + func processFlags(rootCmd *cobra.Command) error { configFilePath, err := rootCmd.PersistentFlags().GetString(configFileFlag) if err != nil { @@ -32,6 +34,11 @@ func processFlags(rootCmd *cobra.Command) error { } // Apply all flag mappings immediately + if logLevelFlag := rootCmd.PersistentFlags().Lookup(logLevelFlagName); logLevelFlag != nil { + logLevelUserDefined = logLevelFlag.Changed + } + applyDefaultLogLevelForHumanFormat() + engineConfigVar.ScanConfig.WithValidation = validateVar if len(customRegexRuleVar) > 0 { engineConfigVar.CustomRegexPatterns = customRegexRuleVar @@ -64,16 +71,23 @@ func setupLogging() { log.Logger = log.Logger.Level(logLevel) } +func applyDefaultLogLevelForHumanFormat() { + if strings.EqualFold(stdoutFormatVar, humanStdoutFormat) && !logLevelUserDefined { + logLevelVar = "warn" + } +} + func validateFormat(stdout string, reportPath []string) error { - r := regexp.MustCompile(outputFormatRegexpPattern) - if !(r.MatchString(stdout)) { - return fmt.Errorf(`%w: %s, available formats are: json, yaml and sarif`, errInvalidOutputFormat, stdout) + stdoutRegex := regexp.MustCompile(stdoutFormatRegexpPattern) + if !stdoutRegex.MatchString(stdout) { + return fmt.Errorf(`%w: %s, available formats are: json, yaml, sarif, human`, errInvalidOutputFormat, stdout) } + reportRegex := regexp.MustCompile(reportFormatRegexpPattern) for _, path := range reportPath { fileExtension := filepath.Ext(path) format := strings.TrimPrefix(fileExtension, ".") - if !(r.MatchString(format)) { + if !reportRegex.MatchString(format) { return fmt.Errorf(`%w: %s, available extensions are: json, yaml and sarif`, errInvalidReportExtension, format) } } @@ -92,7 +106,7 @@ func setupFlags(rootCmd *cobra.Command) { "path to generate report files. The output format will be determined by the file extension (.json, .yaml, .sarif)") rootCmd.PersistentFlags(). - StringVar(&stdoutFormatVar, stdoutFormatFlagName, "yaml", "stdout output format, available formats are: json, yaml, sarif") + StringVar(&stdoutFormatVar, stdoutFormatFlagName, "yaml", "stdout output format, available formats are: json, yaml, sarif, human") rootCmd.PersistentFlags(). StringArrayVar(&customRegexRuleVar, customRegexRuleFlagName, []string{}, "custom regexes to apply to the scan, must be valid Go regex") diff --git a/cmd/config_test.go b/cmd/config_test.go index 692f4d63..8a391628 100644 --- a/cmd/config_test.go +++ b/cmd/config_test.go @@ -37,6 +37,12 @@ func TestValidateFormat(t *testing.T) { reportPath: []string{"report.sarif"}, expectedErr: nil, }, + { + name: "valid output format human", + stdoutFormatVar: "human", + reportPath: []string{"report.yaml"}, + expectedErr: nil, + }, { name: "invalid output format", stdoutFormatVar: "invalid", @@ -46,7 +52,7 @@ func TestValidateFormat(t *testing.T) { { name: "invalid report extension", stdoutFormatVar: "json", - reportPath: []string{"report.invalid"}, + reportPath: []string{"report.human"}, expectedErr: errInvalidReportExtension, }, } diff --git a/cmd/main.go b/cmd/main.go index 70342515..a0e23a8c 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -16,7 +16,8 @@ import ( var Version = "0.0.0" const ( - outputFormatRegexpPattern = `^(ya?ml|json|sarif)$` + stdoutFormatRegexpPattern = `^(ya?ml|json|sarif|human)$` + reportFormatRegexpPattern = `^(ya?ml|json|sarif)$` configFileFlag = "config" logLevelFlagName = "log-level" @@ -34,13 +35,14 @@ const ( ) var ( - logLevelVar string - reportPathVar []string - stdoutFormatVar string - customRegexRuleVar []string - ignoreOnExitVar = ignoreOnExitNone - engineConfigVar engine.EngineConfig - validateVar bool + logLevelVar string + reportPathVar []string + stdoutFormatVar string + customRegexRuleVar []string + ignoreOnExitVar = ignoreOnExitNone + engineConfigVar engine.EngineConfig + validateVar bool + logLevelUserDefined bool ) const envPrefix = "2MS" diff --git a/engine/engine.go b/engine/engine.go index efe12b43..fe151974 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -15,6 +15,7 @@ import ( "strings" "sync" "text/tabwriter" + "time" "github.com/checkmarx/2ms/v4/engine/chunk" "github.com/checkmarx/2ms/v4/engine/extra" @@ -78,6 +79,8 @@ type Engine struct { ScanConfig resources.ScanConfig + startTime time.Time + wg conc.WaitGroup } @@ -734,6 +737,7 @@ func (e *Engine) GetCvssScoreWithoutValidationCh() chan *secrets.Secret { } func (e *Engine) Scan(pluginName string) { + e.startTime = time.Now() e.wg.Go(func() { e.processItems(pluginName) }) @@ -750,6 +754,9 @@ func (e *Engine) Scan(pluginName string) { func (e *Engine) Wait() { e.wg.Wait() + if !e.startTime.IsZero() { + e.Report.SetScanDuration(time.Since(e.startTime)) + } } // isSecretFromConfluenceResourceIdentifier reports whether a regex match found in a line diff --git a/lib/reporting/human.go b/lib/reporting/human.go new file mode 100644 index 00000000..4b6fca7a --- /dev/null +++ b/lib/reporting/human.go @@ -0,0 +1,258 @@ +package reporting + +import ( + "fmt" + "sort" + "strings" + "time" + + "github.com/checkmarx/2ms/v4/lib/secrets" +) + +const ( + scanTriggered = " 2ms by Checkmarx scanning..." + iconTask = "▸" + iconSuccess = "✔" + iconContext = "→" +) + +func writeHuman(report *Report, version string) (string, error) { + var builder strings.Builder + + scanDuration := report.GetScanDuration() + secretsBySource, uniqueRules := groupSecrets(report.GetResults()) + totalSecrets := report.TotalSecretsFound + + writeHeader(&builder, version) + writeFindings(&builder, totalSecrets, secretsBySource) + writeTotals(&builder, report.TotalItemsScanned, totalSecrets, len(secretsBySource), uniqueRules) + writeFooter(&builder, scanDuration) + + return strings.TrimRight(builder.String(), "\n"), nil +} + +func writeHeader(builder *strings.Builder, version string) { + builder.WriteString(iconTask) + builder.WriteString(scanTriggered) + builder.WriteString("\n\n") +} + +func writeFindings(builder *strings.Builder, totalSecrets int, secretsBySource map[string][]*secrets.Secret) { + builder.WriteString(iconContext) + if totalSecrets == 0 { + builder.WriteString(" Findings: none\n") + return + } + + fileCount := len(secretsBySource) + builder.WriteString(fmt.Sprintf( + " Findings: %d %s in %d %s\n", + totalSecrets, + pluralize(totalSecrets, "secret", "secrets"), + fileCount, + pluralize(fileCount, "file", "files"), + )) + + sources := sortedSources(secretsBySource) + for _, source := range sources { + commit, path, isGit := parseGitSource(source) + displaySource := source + if isGit { + displaySource = path + } + if displaySource == "" { + displaySource = "(source not provided)" + } + + fmt.Fprintf(builder, " - File: %s\n", displaySource) + if isGit { + fmt.Fprintf(builder, " Commit: %s\n", commit) + } + + secrets := secretsBySource[source] + sortSecrets(secrets) + + for idx, secret := range secrets { + appendSecretDetails(builder, secret) + if idx < len(secrets)-1 { + builder.WriteString("\n") + } + } + builder.WriteString("\n") + } +} + +func writeTotals(builder *strings.Builder, itemsScanned, totalSecrets, fileCount, ruleCount int) { + builder.WriteString(iconContext) + builder.WriteString(" Totals:\n") + fmt.Fprintf(builder, " - Items scanned: %d\n", itemsScanned) + fmt.Fprintf(builder, " - Secrets found: %d\n", totalSecrets) + if totalSecrets > 0 { + fmt.Fprintf(builder, " - Files with secrets: %d\n", fileCount) + fmt.Fprintf(builder, " - Triggered rules: %d\n", ruleCount) + } +} + +func writeFooter(builder *strings.Builder, duration time.Duration) { + builder.WriteString("\n") + builder.WriteString(iconSuccess) + fmt.Fprintf(builder, " Done in %s.", formatDuration(duration)) +} + +func groupSecrets(results map[string][]*secrets.Secret) (map[string][]*secrets.Secret, int) { + secretsBySource := make(map[string][]*secrets.Secret) + uniqueRules := make(map[string]struct{}) + + for _, list := range results { + for _, secret := range list { + if secret == nil { + continue + } + secretsBySource[secret.Source] = append(secretsBySource[secret.Source], secret) + if secret.RuleID != "" { + uniqueRules[secret.RuleID] = struct{}{} + } + } + } + + return secretsBySource, len(uniqueRules) +} + +func sortedSources(secretsBySource map[string][]*secrets.Secret) []string { + sources := make([]string, 0, len(secretsBySource)) + for source := range secretsBySource { + sources = append(sources, source) + } + sort.Strings(sources) + return sources +} + +func sortSecrets(secrets []*secrets.Secret) { + sort.Slice(secrets, func(i, j int) bool { + if secrets[i].StartLine != secrets[j].StartLine { + return secrets[i].StartLine < secrets[j].StartLine + } + if secrets[i].StartColumn != secrets[j].StartColumn { + return secrets[i].StartColumn < secrets[j].StartColumn + } + return secrets[i].RuleID < secrets[j].RuleID + }) +} + +func appendSecretDetails(builder *strings.Builder, secret *secrets.Secret) { + fmt.Fprintf(builder, " - Rule: %s\n", fallback(secret.RuleID, "unknown")) + fmt.Fprintf(builder, " Secret ID: %s\n", fallback(secret.ID, "n/a")) + fmt.Fprintf(builder, " Location: %s\n", formatLocation(secret)) + + if status := strings.TrimSpace(string(secret.ValidationStatus)); status != "" { + fmt.Fprintf(builder, " Validity: %s\n", status) + } + + if secret.CvssScore > 0 { + fmt.Fprintf(builder, " CVSS score: %.1f\n", secret.CvssScore) + } + + if snippet := trimmedSnippet(secret.LineContent); snippet != "" { + fmt.Fprintf(builder, " Snippet: %s\n", snippet) + } + + if ruleDescription := strings.TrimSpace(secret.RuleDescription); ruleDescription != "" { + fmt.Fprintf(builder, " Description: %s\n", ruleDescription) + } +} + +func fallback(value, defaultValue string) string { + if strings.TrimSpace(value) == "" { + return defaultValue + } + return value +} + +func pluralize(count int, singular, plural string) string { + if count == 1 { + return singular + } + return plural +} + +func formatLocation(secret *secrets.Secret) string { + var parts []string + + switch { + case secret.StartLine > 0 && secret.EndLine > 0: + if secret.StartLine == secret.EndLine { + parts = append(parts, fmt.Sprintf("line %d", secret.StartLine)) + } else { + parts = append(parts, fmt.Sprintf("lines %d-%d", secret.StartLine, secret.EndLine)) + } + case secret.StartLine > 0: + parts = append(parts, fmt.Sprintf("line %d", secret.StartLine)) + case secret.EndLine > 0: + parts = append(parts, fmt.Sprintf("line %d", secret.EndLine)) + } + + if column := formatColumnRange(secret.StartColumn, secret.EndColumn); column != "" { + parts = append(parts, column) + } + + if len(parts) == 0 { + return "n/a" + } + + return strings.Join(parts, ", ") +} + +func formatColumnRange(start, end int) string { + switch { + case start > 0 && end > 0 && start != end: + return fmt.Sprintf("columns %d-%d", start, end) + case start > 0: + return fmt.Sprintf("column %d", start) + case end > 0: + return fmt.Sprintf("column %d", end) + default: + return "" + } +} + +func trimmedSnippet(snippet string) string { + snippet = strings.TrimSpace(snippet) + const maxLen = 160 + if len(snippet) > maxLen { + return snippet[:maxLen-3] + "..." + } + return snippet +} + +func formatDuration(duration time.Duration) string { + if duration <= 0 { + return "0s" + } + + if duration < time.Second { + return duration.Round(time.Millisecond).String() + } + + return duration.Round(10 * time.Millisecond).String() +} + +func parseGitSource(source string) (commit string, path string, isGit bool) { + const prefix = "git show " + if !strings.HasPrefix(source, prefix) { + return "", "", false + } + + remainder := strings.TrimPrefix(source, prefix) + parts := strings.SplitN(remainder, ":", 2) + if len(parts) != 2 { + return "", "", false + } + + commit = strings.TrimSpace(parts[0]) + path = parts[1] + if commit == "" || path == "" { + return "", "", false + } + + return commit, path, true +} diff --git a/lib/reporting/report.go b/lib/reporting/report.go index b4a3b5f0..ce651596 100644 --- a/lib/reporting/report.go +++ b/lib/reporting/report.go @@ -1,14 +1,15 @@ package reporting import ( + "fmt" "os" "path/filepath" "strings" "sync" + "time" "github.com/checkmarx/2ms/v4/lib/config" "github.com/checkmarx/2ms/v4/lib/secrets" - "github.com/rs/zerolog/log" ) const ( @@ -16,6 +17,7 @@ const ( longYamlFormat = "yaml" shortYamlFormat = "yml" sarifFormat = "sarif" + humanFormat = "human" ) type IReport interface { @@ -28,12 +30,15 @@ type IReport interface { GetTotalSecretsFound() int IncTotalItemsScanned(n int) IncTotalSecretsFound(n int) + SetScanDuration(duration time.Duration) + GetScanDuration() time.Duration } type Report struct { TotalItemsScanned int `json:"totalItemsScanned"` TotalSecretsFound int `json:"totalSecretsFound"` Results map[string][]*secrets.Secret `json:"results"` + ScanDuration time.Duration `json:"-"` mu sync.RWMutex } @@ -50,7 +55,7 @@ func (r *Report) ShowReport(format string, cfg *config.Config) error { return err } - log.Info().Msg("\n" + output) + fmt.Println(output) return nil } @@ -91,6 +96,12 @@ func (r *Report) GetOutput(format string, cfg *config.Config) (string, error) { output, err = writeYaml(r) case sarifFormat: output, err = writeSarif(r, cfg) + case humanFormat: + version := "" + if cfg != nil { + version = cfg.Version + } + output, err = writeHuman(r, version) } return output, err } @@ -119,6 +130,18 @@ func (r *Report) IncTotalSecretsFound(n int) { r.TotalSecretsFound += n } +func (r *Report) SetScanDuration(duration time.Duration) { + r.mu.Lock() + defer r.mu.Unlock() + r.ScanDuration = duration +} + +func (r *Report) GetScanDuration() time.Duration { + r.mu.RLock() + defer r.mu.RUnlock() + return r.ScanDuration +} + func (r *Report) GetResults() map[string][]*secrets.Secret { r.mu.RLock() defer r.mu.RUnlock() diff --git a/lib/reporting/report_test.go b/lib/reporting/report_test.go index 864ffa22..2a90b94a 100644 --- a/lib/reporting/report_test.go +++ b/lib/reporting/report_test.go @@ -5,9 +5,11 @@ import ( "os" "path/filepath" "reflect" + "regexp" "sort" "strings" "testing" + "time" "github.com/checkmarx/2ms/v4/lib/config" "github.com/checkmarx/2ms/v4/lib/secrets" @@ -532,3 +534,117 @@ func TestGetOutputYAML(t *testing.T) { }) } } + +func TestGetOutputHuman(t *testing.T) { + t.Run("no secrets", func(t *testing.T) { + report := &Report{ + TotalItemsScanned: 3, + TotalSecretsFound: 0, + Results: map[string][]*secrets.Secret{}, + } + + report.SetScanDuration(1500 * time.Millisecond) + + output, err := report.GetOutput("human", &config.Config{Name: "report", Version: "1.0.0"}) + assert.NoError(t, err) + + clean := stripANSI(output) + + assert.Contains(t, clean, "2ms by Checkmarx scanning...") + assert.Contains(t, clean, "Findings: none") + assert.Contains(t, clean, "Items scanned: 3") + assert.Contains(t, clean, "Secrets found: 0") + assert.Contains(t, clean, "Totals") + }) + + t.Run("secret details", func(t *testing.T) { + report := &Report{ + TotalItemsScanned: 2, + TotalSecretsFound: 1, + Results: map[string][]*secrets.Secret{ + "secret-1": { + { + ID: "secret-1", + Source: "path/to/file.txt", + RuleID: "rule-123", + StartLine: 42, + EndLine: 42, + StartColumn: 3, + EndColumn: 10, + LineContent: " api_key = \"value\" ", + ValidationStatus: secrets.ValidResult, + CvssScore: 7.5, + RuleDescription: "Rotate the key and scrub it from history.", + ExtraDetails: map[string]interface{}{ + "secretDetails": map[string]interface{}{ + "name": "john", + "sub": "12345", + }, + "environment": "production", + }, + }, + }, + }, + } + + report.SetScanDuration(1234 * time.Millisecond) + + output, err := report.GetOutput("human", &config.Config{Name: "report", Version: "1"}) + assert.NoError(t, err) + + clean := stripANSI(output) + + assert.Contains(t, clean, "Findings: 1 secret in 1 file") + assert.Contains(t, clean, "File: path/to/file.txt") + assert.Contains(t, clean, "Rule: rule-123") + assert.Contains(t, clean, "Secret ID: secret-1") + assert.Contains(t, clean, "Location: line 42, columns 3-10") + assert.Contains(t, clean, "Validity: Valid") + assert.Contains(t, clean, "CVSS score: 7.5") + assert.Contains(t, clean, `Snippet: api_key = "value"`) + assert.Contains(t, clean, "Description: Rotate the key and scrub it from history.") + assert.Contains(t, clean, "Items scanned: 2") + assert.Contains(t, clean, "Secrets found: 1") + assert.Contains(t, clean, "Files with secrets: 1") + assert.Contains(t, clean, "Triggered rules: 1") + assert.NotContains(t, clean, "Metadata:") + assert.Contains(t, clean, "Done in 1.23s.") + }) + + t.Run("git source shows commit", func(t *testing.T) { + gitSource := "git show deadbeefdeadbeefdeadbeefdeadbeefdeadbeef:path/to/file.txt" + report := &Report{ + TotalItemsScanned: 1, + TotalSecretsFound: 1, + Results: map[string][]*secrets.Secret{ + gitSource: { + { + ID: "git-secret-1", + Source: gitSource, + RuleID: "git-rule", + StartLine: 5, + EndLine: 5, + StartColumn: 1, + EndColumn: 4, + LineContent: "test=1", + }, + }, + }, + } + + output, err := report.GetOutput("human", &config.Config{Name: "report", Version: "1"}) + assert.NoError(t, err) + + clean := stripANSI(output) + + assert.Contains(t, clean, "File: path/to/file.txt") + assert.Contains(t, clean, "Commit: deadbeefdeadbeefdeadbeefdeadbeefdeadbeef") + assert.Contains(t, clean, "Rule: git-rule") + }) +} + +var ansiRegexp = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +func stripANSI(value string) string { + return ansiRegexp.ReplaceAllString(value, "") +} diff --git a/plugins/filesystem.go b/plugins/filesystem.go index 2095001d..53a17bf3 100644 --- a/plugins/filesystem.go +++ b/plugins/filesystem.go @@ -34,7 +34,7 @@ func (p *FileSystemPlugin) DefineCommand(items chan ISourceItem, errors chan err Short: "Scan local folder", Long: "Scan local folder for sensitive information", Run: func(cmd *cobra.Command, args []string) { - log.Info().Msg("Folder plugin started") + log.Debug().Msg("Folder plugin started") fileList, err := p.getFiles() if err != nil { errors <- err