From 16b6aadc9e7aae62b9ef38189334ad242e79e573 Mon Sep 17 00:00:00 2001 From: cx-rui-gomes <71653902+cx-rui-gomes@users.noreply.github.com> Date: Wed, 29 Oct 2025 18:38:43 +0000 Subject: [PATCH 1/6] add human console output --- README.md | 6 +- cmd/config.go | 11 ++- cmd/config_test.go | 8 +- cmd/main.go | 3 +- engine/engine.go | 7 ++ lib/reporting/human.go | 187 +++++++++++++++++++++++++++++++++++ lib/reporting/report.go | 23 ++++- lib/reporting/report_test.go | 85 ++++++++++++++++ plugins/filesystem.go | 2 +- 9 files changed, 320 insertions(+), 12 deletions(-) create mode 100644 lib/reporting/human.go diff --git a/README.md b/README.md index caca8617..9cd9e633 100644 --- a/README.md +++ b/README.md @@ -205,7 +205,7 @@ Flags: --regex stringArray custom regexes to apply to the scan, must be valid Go regex --report-path strings path to generate report files. The output format will be determined by the file extension (.json, .yaml, .sarif) --rule strings select rules by name or tag to apply to this scan - --stdout-format string stdout output format, available formats are: json, yaml, sarif (default "yaml") + --stdout-format string stdout output format, available formats are: json, yaml, sarif, human (default "human") --validate trigger additional validation to check if discovered secrets are valid or invalid -v, --version version for 2ms @@ -401,10 +401,12 @@ The following table describes the global flags that can be used together with an |--regex | stringArray | | Custom regexes to apply to the scan. Must be valid Go regex. | |--report-path | strings | | Path to generate report files. The output format will be determined by the file extension (.json, .yaml, .sarif) | |--rule | strings | | Select rules by name or tag to apply to this scan. | -|--stdout-format | string | yaml | Stdout output format, available formats are: json, yaml, sarif | +|--stdout-format | string | human | Stdout output format, available formats are: json, yaml, sarif, human | |--validate | | | Trigger additional validation to check if discovered secrets are valid or invalid. SEE BELOW | |-v, --version | | | Version of 2ms that is running. | +> The default `human` format prints a curated console summary. Use `--stdout-format yaml` (or json/sarif) when you need the previous structured output. + ### Validity Check Adding the `--validate` flag checks the validity of the secrets found. For example, if a Github token is found, it will check if the token is valid by making a request to the Github API. We will use the least intrusive method possible to check the validity of the secret. diff --git a/cmd/config.go b/cmd/config.go index fb4cd74b..f0587644 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -65,15 +65,16 @@ func setupLogging() { } 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 +93,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, "human", "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 9cef8ff8..2b2f2fd5 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" diff --git a/engine/engine.go b/engine/engine.go index 29bc48a0..78812e7c 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 } @@ -723,6 +726,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) }) @@ -739,4 +743,7 @@ func (e *Engine) Scan(pluginName string) { func (e *Engine) Wait() { e.wg.Wait() + if !e.startTime.IsZero() { + e.Report.SetScanDuration(time.Since(e.startTime)) + } } diff --git a/lib/reporting/human.go b/lib/reporting/human.go new file mode 100644 index 00000000..61e068d2 --- /dev/null +++ b/lib/reporting/human.go @@ -0,0 +1,187 @@ +package reporting + +import ( + "fmt" + "sort" + "strings" + "time" + + "github.com/checkmarx/2ms/v4/lib/secrets" +) + +const ( + colorPrimary = "\033[36m" + colorSecondary = "\033[35m" + colorHighlight = "\033[33m" + colorFileLabel = "\033[32m" + colorReset = "\033[0m" + statusCompleted = "2ms scanning..." +) + +func writeHuman(report *Report) (string, error) { + var builder strings.Builder + + builder.WriteString(colorPrimary) + builder.WriteString(statusCompleted) + builder.WriteString(colorReset + "\n\n") + + totalSecrets := report.TotalSecretsFound + results := report.GetResults() + + secretsBySource := make(map[string][]*secrets.Secret) + uniqueRules := make(map[string]struct{}) + + for _, list := range results { + for _, secret := range list { + if secret == nil { + continue + } + source := secret.Source + secretsBySource[source] = append(secretsBySource[source], secret) + if secret.RuleID != "" { + uniqueRules[secret.RuleID] = struct{}{} + } + } + } + + if totalSecrets == 0 { + builder.WriteString("No secrets were detected during this scan.\n\n") + } else { + sources := make([]string, 0, len(secretsBySource)) + for source := range secretsBySource { + sources = append(sources, source) + } + sort.Strings(sources) + + for _, source := range sources { + displaySource := source + if displaySource == "" { + displaySource = "(source not provided)" + } + + fmt.Fprintf(&builder, "%sFile:%s %s%s%s\n", colorFileLabel, colorReset, colorHighlight, displaySource, colorReset) + + secretsSlice := secretsBySource[source] + sort.Slice(secretsSlice, func(i, j int) bool { + if secretsSlice[i].StartLine != secretsSlice[j].StartLine { + return secretsSlice[i].StartLine < secretsSlice[j].StartLine + } + if secretsSlice[i].StartColumn != secretsSlice[j].StartColumn { + return secretsSlice[i].StartColumn < secretsSlice[j].StartColumn + } + return secretsSlice[i].RuleID < secretsSlice[j].RuleID + }) + + for idx, secret := range secretsSlice { + appendSecretDetails(&builder, secret) + if idx < len(secretsSlice)-1 { + builder.WriteString("\n") + } + } + + builder.WriteString("\n") + } + } + + builder.WriteString(colorSecondary + "Totals" + colorReset + "\n") + builder.WriteString(colorSecondary + "------" + colorReset + "\n") + fmt.Fprintf(&builder, "%sItems scanned:%s %d\n", colorSecondary, colorReset, report.TotalItemsScanned) + fmt.Fprintf(&builder, "%sSecrets found:%s %d\n", colorSecondary, colorReset, totalSecrets) + if totalSecrets > 0 { + fmt.Fprintf(&builder, "%sFiles with secrets:%s %d\n", colorSecondary, colorReset, len(secretsBySource)) + fmt.Fprintf(&builder, "%sTriggered rules:%s %d\n", colorSecondary, colorReset, len(uniqueRules)) + } + fmt.Fprintf(&builder, "%sScan duration:%s %s\n", colorSecondary, colorReset, formatDuration(report.GetScanDuration())) + + return strings.TrimRight(builder.String(), "\n"), nil +} + +func appendSecretDetails(builder *strings.Builder, secret *secrets.Secret) { + fmt.Fprintf(builder, " - %sRule:%s %s\n", colorSecondary, colorReset, fallback(secret.RuleID, "unknown")) + fmt.Fprintf(builder, " %sSecret ID:%s %s\n", colorSecondary, colorReset, fallback(secret.ID, "n/a")) + fmt.Fprintf(builder, " %sLocation:%s %s\n", colorSecondary, colorReset, formatLocation(secret)) + + if status := strings.TrimSpace(string(secret.ValidationStatus)); status != "" { + fmt.Fprintf(builder, " %sValidation:%s %s\n", colorSecondary, colorReset, status) + } + + if secret.CvssScore > 0 { + fmt.Fprintf(builder, " %sCVSS score:%s %.1f\n", colorSecondary, colorReset, secret.CvssScore) + } + + if snippet := trimmedSnippet(secret.LineContent); snippet != "" { + fmt.Fprintf(builder, " %sSnippet:%s %s\n", colorSecondary, colorReset, snippet) + } + + if remediation := strings.TrimSpace(secret.RuleDescription); remediation != "" { + fmt.Fprintf(builder, " %sRemediation:%s %s\n", colorSecondary, colorReset, remediation) + } +} + +func fallback(value, defaultValue string) string { + if strings.TrimSpace(value) == "" { + return defaultValue + } + return value +} + +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() +} diff --git a/lib/reporting/report.go b/lib/reporting/report.go index b4a3b5f0..18a6ce7e 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,8 @@ func (r *Report) GetOutput(format string, cfg *config.Config) (string, error) { output, err = writeYaml(r) case sarifFormat: output, err = writeSarif(r, cfg) + case humanFormat: + output, err = writeHuman(r) } return output, err } @@ -119,6 +126,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 12c038ce..07532c4f 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" @@ -444,3 +446,86 @@ 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"}) + assert.NoError(t, err) + + clean := stripANSI(output) + + assert.Contains(t, clean, "2ms scanning...") + assert.Contains(t, clean, "No secrets were detected during this scan.") + assert.Contains(t, clean, "Items scanned: 3") + assert.Contains(t, clean, "Secrets found: 0") + assert.Contains(t, clean, "Totals") + assert.Contains(t, clean, "Scan duration: 1.5s") + }) + + 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, "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, "Validation: Valid") + assert.Contains(t, clean, "CVSS score: 7.5") + assert.Contains(t, clean, `Snippet: api_key = "value"`) + assert.Contains(t, clean, "Remediation: 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, "Scan duration: 1.23s") + }) +} + +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 From b3ef3a78fb11a0863ba212e73a2d7a1bb8e243cd Mon Sep 17 00:00:00 2001 From: cx-rui-gomes <71653902+cx-rui-gomes@users.noreply.github.com> Date: Thu, 30 Oct 2025 12:28:59 +0000 Subject: [PATCH 2/6] refactor output --- lib/reporting/human.go | 191 ++++++++++++++++++++++------------- lib/reporting/report.go | 6 +- lib/reporting/report_test.go | 9 +- 3 files changed, 134 insertions(+), 72 deletions(-) diff --git a/lib/reporting/human.go b/lib/reporting/human.go index 61e068d2..fcd84b9e 100644 --- a/lib/reporting/human.go +++ b/lib/reporting/human.go @@ -10,24 +10,101 @@ import ( ) const ( - colorPrimary = "\033[36m" - colorSecondary = "\033[35m" - colorHighlight = "\033[33m" - colorFileLabel = "\033[32m" - colorReset = "\033[0m" - statusCompleted = "2ms scanning..." + scanTriggered = "2ms by Checkmarx scanning..." + iconTask = "▸" + iconSuccess = "✔" + iconContext = "→" + iconTotals = "→" + defaultVersion = "0.0.0" ) -func writeHuman(report *Report) (string, error) { +func writeHuman(report *Report, version string) (string, error) { var builder strings.Builder - builder.WriteString(colorPrimary) - builder.WriteString(statusCompleted) - builder.WriteString(colorReset + "\n\n") - + scanDuration := report.GetScanDuration() + secretsBySource, uniqueRules := groupSecrets(report.GetResults()) totalSecrets := report.TotalSecretsFound - results := report.GetResults() + writeHeader(&builder, version) + writeFindings(&builder, totalSecrets, secretsBySource) + writeTotals(&builder, report.TotalItemsScanned, totalSecrets, len(secretsBySource), uniqueRules, scanDuration) + writeFooter(&builder, scanDuration) + + return strings.TrimRight(builder.String(), "\n"), nil +} + +func writeHeader(builder *strings.Builder, version string) { + versionInfo := strings.TrimSpace(version) + + builder.WriteString(iconTask) + builder.WriteString(" Executing scan\n") + builder.WriteString(iconSuccess) + builder.WriteString(" Status: ") + builder.WriteString(scanTriggered) + if versionInfo != "" && versionInfo != defaultVersion { + builder.WriteString(" (version ") + builder.WriteString(versionInfo) + builder.WriteString(")") + } + builder.WriteString("\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 { + displaySource := source + if displaySource == "" { + displaySource = "(source not provided)" + } + + fmt.Fprintf(builder, " - File: %s\n", displaySource) + + 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, duration time.Duration) { + builder.WriteString(iconTotals) + 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) + } + fmt.Fprintf(builder, " - Scan duration: %s\n", formatDuration(duration)) +} + +func writeFooter(builder *strings.Builder, duration time.Duration) { + builder.WriteString("\n") + 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{}) @@ -36,85 +113,56 @@ func writeHuman(report *Report) (string, error) { if secret == nil { continue } - source := secret.Source - secretsBySource[source] = append(secretsBySource[source], secret) + secretsBySource[secret.Source] = append(secretsBySource[secret.Source], secret) if secret.RuleID != "" { uniqueRules[secret.RuleID] = struct{}{} } } } - if totalSecrets == 0 { - builder.WriteString("No secrets were detected during this scan.\n\n") - } else { - sources := make([]string, 0, len(secretsBySource)) - for source := range secretsBySource { - sources = append(sources, source) - } - sort.Strings(sources) - - for _, source := range sources { - displaySource := source - if displaySource == "" { - displaySource = "(source not provided)" - } - - fmt.Fprintf(&builder, "%sFile:%s %s%s%s\n", colorFileLabel, colorReset, colorHighlight, displaySource, colorReset) - - secretsSlice := secretsBySource[source] - sort.Slice(secretsSlice, func(i, j int) bool { - if secretsSlice[i].StartLine != secretsSlice[j].StartLine { - return secretsSlice[i].StartLine < secretsSlice[j].StartLine - } - if secretsSlice[i].StartColumn != secretsSlice[j].StartColumn { - return secretsSlice[i].StartColumn < secretsSlice[j].StartColumn - } - return secretsSlice[i].RuleID < secretsSlice[j].RuleID - }) - - for idx, secret := range secretsSlice { - appendSecretDetails(&builder, secret) - if idx < len(secretsSlice)-1 { - builder.WriteString("\n") - } - } - - builder.WriteString("\n") - } - } + return secretsBySource, len(uniqueRules) +} - builder.WriteString(colorSecondary + "Totals" + colorReset + "\n") - builder.WriteString(colorSecondary + "------" + colorReset + "\n") - fmt.Fprintf(&builder, "%sItems scanned:%s %d\n", colorSecondary, colorReset, report.TotalItemsScanned) - fmt.Fprintf(&builder, "%sSecrets found:%s %d\n", colorSecondary, colorReset, totalSecrets) - if totalSecrets > 0 { - fmt.Fprintf(&builder, "%sFiles with secrets:%s %d\n", colorSecondary, colorReset, len(secretsBySource)) - fmt.Fprintf(&builder, "%sTriggered rules:%s %d\n", colorSecondary, colorReset, len(uniqueRules)) +func sortedSources(secretsBySource map[string][]*secrets.Secret) []string { + sources := make([]string, 0, len(secretsBySource)) + for source := range secretsBySource { + sources = append(sources, source) } - fmt.Fprintf(&builder, "%sScan duration:%s %s\n", colorSecondary, colorReset, formatDuration(report.GetScanDuration())) + sort.Strings(sources) + return sources +} - return strings.TrimRight(builder.String(), "\n"), nil +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, " - %sRule:%s %s\n", colorSecondary, colorReset, fallback(secret.RuleID, "unknown")) - fmt.Fprintf(builder, " %sSecret ID:%s %s\n", colorSecondary, colorReset, fallback(secret.ID, "n/a")) - fmt.Fprintf(builder, " %sLocation:%s %s\n", colorSecondary, colorReset, formatLocation(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, " %sValidation:%s %s\n", colorSecondary, colorReset, status) + fmt.Fprintf(builder, " Validation: %s\n", status) } if secret.CvssScore > 0 { - fmt.Fprintf(builder, " %sCVSS score:%s %.1f\n", colorSecondary, colorReset, secret.CvssScore) + fmt.Fprintf(builder, " CVSS score: %.1f\n", secret.CvssScore) } if snippet := trimmedSnippet(secret.LineContent); snippet != "" { - fmt.Fprintf(builder, " %sSnippet:%s %s\n", colorSecondary, colorReset, snippet) + fmt.Fprintf(builder, " Snippet: %s\n", snippet) } if remediation := strings.TrimSpace(secret.RuleDescription); remediation != "" { - fmt.Fprintf(builder, " %sRemediation:%s %s\n", colorSecondary, colorReset, remediation) + fmt.Fprintf(builder, " Remediation: %s\n", remediation) } } @@ -125,6 +173,13 @@ func fallback(value, defaultValue string) string { 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 diff --git a/lib/reporting/report.go b/lib/reporting/report.go index 18a6ce7e..ce651596 100644 --- a/lib/reporting/report.go +++ b/lib/reporting/report.go @@ -97,7 +97,11 @@ func (r *Report) GetOutput(format string, cfg *config.Config) (string, error) { case sarifFormat: output, err = writeSarif(r, cfg) case humanFormat: - output, err = writeHuman(r) + version := "" + if cfg != nil { + version = cfg.Version + } + output, err = writeHuman(r, version) } return output, err } diff --git a/lib/reporting/report_test.go b/lib/reporting/report_test.go index 07532c4f..6eeee33c 100644 --- a/lib/reporting/report_test.go +++ b/lib/reporting/report_test.go @@ -457,13 +457,14 @@ func TestGetOutputHuman(t *testing.T) { report.SetScanDuration(1500 * time.Millisecond) - output, err := report.GetOutput("human", &config.Config{Name: "report", Version: "1"}) + 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 scanning...") - assert.Contains(t, clean, "No secrets were detected during this scan.") + assert.Contains(t, clean, "2ms by Checkmarx scanning...") + assert.Contains(t, clean, "(version 1.0.0)") + 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") @@ -507,6 +508,7 @@ func TestGetOutputHuman(t *testing.T) { 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") @@ -521,6 +523,7 @@ func TestGetOutputHuman(t *testing.T) { assert.Contains(t, clean, "Triggered rules: 1") assert.NotContains(t, clean, "Metadata:") assert.Contains(t, clean, "Scan duration: 1.23s") + assert.Contains(t, clean, "Done in 1.23s.") }) } From 44163df5452d800894a4615bea4ecc0b128c9240 Mon Sep 17 00:00:00 2001 From: cx-rui-gomes <71653902+cx-rui-gomes@users.noreply.github.com> Date: Thu, 30 Oct 2025 12:45:08 +0000 Subject: [PATCH 3/6] applying minor changes --- lib/reporting/human.go | 22 +++++++++------------- lib/reporting/report_test.go | 6 ++---- 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/lib/reporting/human.go b/lib/reporting/human.go index fcd84b9e..d23aa215 100644 --- a/lib/reporting/human.go +++ b/lib/reporting/human.go @@ -10,11 +10,10 @@ import ( ) const ( - scanTriggered = "2ms by Checkmarx scanning..." + scanTriggered = " 2ms by Checkmarx scanning..." iconTask = "▸" iconSuccess = "✔" iconContext = "→" - iconTotals = "→" defaultVersion = "0.0.0" ) @@ -27,7 +26,7 @@ func writeHuman(report *Report, version string) (string, error) { writeHeader(&builder, version) writeFindings(&builder, totalSecrets, secretsBySource) - writeTotals(&builder, report.TotalItemsScanned, totalSecrets, len(secretsBySource), uniqueRules, scanDuration) + writeTotals(&builder, report.TotalItemsScanned, totalSecrets, len(secretsBySource), uniqueRules) writeFooter(&builder, scanDuration) return strings.TrimRight(builder.String(), "\n"), nil @@ -37,9 +36,6 @@ func writeHeader(builder *strings.Builder, version string) { versionInfo := strings.TrimSpace(version) builder.WriteString(iconTask) - builder.WriteString(" Executing scan\n") - builder.WriteString(iconSuccess) - builder.WriteString(" Status: ") builder.WriteString(scanTriggered) if versionInfo != "" && versionInfo != defaultVersion { builder.WriteString(" (version ") @@ -87,8 +83,8 @@ func writeFindings(builder *strings.Builder, totalSecrets int, secretsBySource m } } -func writeTotals(builder *strings.Builder, itemsScanned, totalSecrets, fileCount, ruleCount int, duration time.Duration) { - builder.WriteString(iconTotals) +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) @@ -96,12 +92,12 @@ func writeTotals(builder *strings.Builder, itemsScanned, totalSecrets, fileCount fmt.Fprintf(builder, " - Files with secrets: %d\n", fileCount) fmt.Fprintf(builder, " - Triggered rules: %d\n", ruleCount) } - fmt.Fprintf(builder, " - Scan duration: %s\n", formatDuration(duration)) } func writeFooter(builder *strings.Builder, duration time.Duration) { builder.WriteString("\n") - fmt.Fprintf(builder, "Done in %s.", formatDuration(duration)) + builder.WriteString(iconSuccess) + fmt.Fprintf(builder, " Done in %s.", formatDuration(duration)) } func groupSecrets(results map[string][]*secrets.Secret) (map[string][]*secrets.Secret, int) { @@ -150,7 +146,7 @@ func appendSecretDetails(builder *strings.Builder, secret *secrets.Secret) { fmt.Fprintf(builder, " Location: %s\n", formatLocation(secret)) if status := strings.TrimSpace(string(secret.ValidationStatus)); status != "" { - fmt.Fprintf(builder, " Validation: %s\n", status) + fmt.Fprintf(builder, " Validity: %s\n", status) } if secret.CvssScore > 0 { @@ -161,8 +157,8 @@ func appendSecretDetails(builder *strings.Builder, secret *secrets.Secret) { fmt.Fprintf(builder, " Snippet: %s\n", snippet) } - if remediation := strings.TrimSpace(secret.RuleDescription); remediation != "" { - fmt.Fprintf(builder, " Remediation: %s\n", remediation) + if ruleDescription := strings.TrimSpace(secret.RuleDescription); ruleDescription != "" { + fmt.Fprintf(builder, " Description: %s\n", ruleDescription) } } diff --git a/lib/reporting/report_test.go b/lib/reporting/report_test.go index 6eeee33c..94fe4450 100644 --- a/lib/reporting/report_test.go +++ b/lib/reporting/report_test.go @@ -468,7 +468,6 @@ func TestGetOutputHuman(t *testing.T) { assert.Contains(t, clean, "Items scanned: 3") assert.Contains(t, clean, "Secrets found: 0") assert.Contains(t, clean, "Totals") - assert.Contains(t, clean, "Scan duration: 1.5s") }) t.Run("secret details", func(t *testing.T) { @@ -513,16 +512,15 @@ func TestGetOutputHuman(t *testing.T) { 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, "Validation: Valid") + 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, "Remediation: Rotate the key and scrub it from history.") + 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, "Scan duration: 1.23s") assert.Contains(t, clean, "Done in 1.23s.") }) } From 1cc036d0dd84ea5bc15a9a9668b7960e26f3f76d Mon Sep 17 00:00:00 2001 From: cx-rui-gomes <71653902+cx-rui-gomes@users.noreply.github.com> Date: Thu, 30 Oct 2025 14:46:12 +0000 Subject: [PATCH 4/6] add missing newline --- lib/reporting/human.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/reporting/human.go b/lib/reporting/human.go index d23aa215..977b4e0f 100644 --- a/lib/reporting/human.go +++ b/lib/reporting/human.go @@ -42,7 +42,7 @@ func writeHeader(builder *strings.Builder, version string) { builder.WriteString(versionInfo) builder.WriteString(")") } - builder.WriteString("\n") + builder.WriteString("\n\n") } func writeFindings(builder *strings.Builder, totalSecrets int, secretsBySource map[string][]*secrets.Secret) { From d03a8948d34810221bab398f5b62381e45ba5b74 Mon Sep 17 00:00:00 2001 From: cx-rui-gomes <71653902+cx-rui-gomes@users.noreply.github.com> Date: Wed, 31 Dec 2025 16:51:53 +0000 Subject: [PATCH 5/6] add human format to git history results --- README.md | 4 +++- cmd/config.go | 15 ++++++++++++++- cmd/main.go | 15 ++++++++------- lib/reporting/human.go | 28 ++++++++++++++++++++++++++++ lib/reporting/report_test.go | 31 +++++++++++++++++++++++++++++++ 5 files changed, 84 insertions(+), 9 deletions(-) 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 f0587644..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,6 +71,12 @@ 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 { stdoutRegex := regexp.MustCompile(stdoutFormatRegexpPattern) if !stdoutRegex.MatchString(stdout) { @@ -93,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, "human", "stdout output format, available formats are: json, yaml, sarif, human") + 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/main.go b/cmd/main.go index 4ebce831..a0e23a8c 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -35,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/lib/reporting/human.go b/lib/reporting/human.go index 977b4e0f..0964c7c1 100644 --- a/lib/reporting/human.go +++ b/lib/reporting/human.go @@ -63,12 +63,19 @@ func writeFindings(builder *strings.Builder, totalSecrets int, secretsBySource m 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) @@ -236,3 +243,24 @@ func formatDuration(duration time.Duration) 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_test.go b/lib/reporting/report_test.go index 2f9fcc05..3646164b 100644 --- a/lib/reporting/report_test.go +++ b/lib/reporting/report_test.go @@ -611,6 +611,37 @@ func TestGetOutputHuman(t *testing.T) { 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`) From 6a73127e7d0c5dfde9c7a4c502c8554e4d029a06 Mon Sep 17 00:00:00 2001 From: cx-rui-gomes <71653902+cx-rui-gomes@users.noreply.github.com> Date: Wed, 31 Dec 2025 17:13:05 +0000 Subject: [PATCH 6/6] removing versioning --- lib/reporting/human.go | 16 ++++------------ lib/reporting/report_test.go | 1 - 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/lib/reporting/human.go b/lib/reporting/human.go index 0964c7c1..4b6fca7a 100644 --- a/lib/reporting/human.go +++ b/lib/reporting/human.go @@ -10,11 +10,10 @@ import ( ) const ( - scanTriggered = " 2ms by Checkmarx scanning..." - iconTask = "▸" - iconSuccess = "✔" - iconContext = "→" - defaultVersion = "0.0.0" + scanTriggered = " 2ms by Checkmarx scanning..." + iconTask = "▸" + iconSuccess = "✔" + iconContext = "→" ) func writeHuman(report *Report, version string) (string, error) { @@ -33,15 +32,8 @@ func writeHuman(report *Report, version string) (string, error) { } func writeHeader(builder *strings.Builder, version string) { - versionInfo := strings.TrimSpace(version) - builder.WriteString(iconTask) builder.WriteString(scanTriggered) - if versionInfo != "" && versionInfo != defaultVersion { - builder.WriteString(" (version ") - builder.WriteString(versionInfo) - builder.WriteString(")") - } builder.WriteString("\n\n") } diff --git a/lib/reporting/report_test.go b/lib/reporting/report_test.go index 3646164b..2a90b94a 100644 --- a/lib/reporting/report_test.go +++ b/lib/reporting/report_test.go @@ -551,7 +551,6 @@ func TestGetOutputHuman(t *testing.T) { clean := stripANSI(output) assert.Contains(t, clean, "2ms by Checkmarx scanning...") - assert.Contains(t, clean, "(version 1.0.0)") assert.Contains(t, clean, "Findings: none") assert.Contains(t, clean, "Items scanned: 3") assert.Contains(t, clean, "Secrets found: 0")