Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,14 +212,20 @@ Timestamp: 2022-06-16 10:17:40 -0700 PDT
trufflehog github --org=trufflesecurity --results=verified
```

## 3: Scan a GitHub Repo for only verified secrets and get JSON output
## 3: Scan a GitHub Repo for only verified secrets and get structured output

Command:
JSON:

```bash
trufflehog git https://github.com/trufflesecurity/test_keys --results=verified --json
```

Or Markdown report that you can drop into documentation or ticketing system:

```bash
trufflehog git https://github.com/trufflesecurity/test_keys --results=verified --markdown > findings.md
```

Expected output:

```
Expand Down Expand Up @@ -448,6 +454,7 @@ Flags:
--log-level=0 Logging verbosity on a scale of 0 (info) to 5 (trace). Can be disabled with "-1".
--profile Enables profiling and sets a pprof and fgprof server on :18066.
-j, --json Output in JSON format.
--markdown Output in Markdown format.
--json-legacy Use the pre-v3.0 JSON format. Only works with git, gitlab, and github sources.
--github-actions Output in GitHub Actions format.
--concurrency=20 Number of concurrent workers.
Expand Down
3 changes: 3 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,7 @@ trufflehog filesystem --config=$PWD/generic.yml $PWD

# to filter so that _only_ generic credentials are logged:
trufflehog filesystem --config=$PWD/generic.yml --json --no-verification $PWD | awk '/generic-api-key/{print $0}'

# capture a Markdown report:
trufflehog filesystem --config=$PWD/generic.yml --markdown --no-verification $PWD > findings.md
```
18 changes: 17 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ var (
profile = cli.Flag("profile", "Enables profiling and sets a pprof and fgprof server on :18066.").Bool()
localDev = cli.Flag("local-dev", "Hidden feature to disable overseer for local dev.").Hidden().Bool()
jsonOut = cli.Flag("json", "Output in JSON format.").Short('j').Bool()
markdownOut = cli.Flag("markdown", "Output in Markdown format.").Bool()
jsonLegacy = cli.Flag("json-legacy", "Use the pre-v3.0 JSON format. Only works with git, gitlab, and github sources.").Bool()
gitHubActionsFormat = cli.Flag("github-actions", "Output in GitHub Actions format.").Bool()
concurrency = cli.Flag("concurrency", "Number of concurrent workers.").Default(strconv.Itoa(runtime.NumCPU())).Int()
Expand Down Expand Up @@ -508,13 +509,15 @@ func run(state overseer.State) {
printer = new(output.LegacyJSONPrinter)
case *jsonOut:
printer = new(output.JSONPrinter)
case *markdownOut:
printer = output.NewMarkdownPrinter(nil)
case *gitHubActionsFormat:
printer = new(output.GitHubActionsPrinter)
default:
printer = new(output.PlainPrinter)
}

if !*jsonLegacy && !*jsonOut {
if !*jsonLegacy && !*jsonOut && !*markdownOut {
fmt.Fprintf(os.Stderr, "🐷🔑🐷 TruffleHog. Unearth your secrets. 🐷🔑🐷\n\n")
}

Expand Down Expand Up @@ -571,13 +574,19 @@ func run(state overseer.State) {
if err := compareScans(ctx, cmd, engConf); err != nil {
logFatal(err, "error comparing detection strategies")
}
if err := closePrinter(printer); err != nil {
logFatal(err, "failed to close printer")
}
return
}

metrics, err := runSingleScan(ctx, cmd, engConf)
if err != nil {
logFatal(err, "error running scan")
}
if err := closePrinter(printer); err != nil {
logFatal(err, "failed to close printer")
}

verificationCacheMetricsSnapshot := struct {
Hits int32
Expand Down Expand Up @@ -1190,6 +1199,13 @@ func commaSeparatedToSlice(s []string) []string {
return result
}

func closePrinter(printer engine.Printer) error {
if closer, ok := printer.(interface{ Close() error }); ok {
return closer.Close()
}
return nil
}

func printAverageDetectorTime(e *engine.Engine) {
fmt.Fprintln(
os.Stderr,
Expand Down
19 changes: 6 additions & 13 deletions pkg/output/github_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,19 +29,12 @@ func (p *GitHubActionsPrinter) Print(_ context.Context, r *detectors.ResultWithM
return fmt.Errorf("could not marshal result: %w", err)
}

for _, data := range meta {
for k, v := range data {
if k == "line" {
if line, ok := v.(float64); ok {
out.StartLine = int64(line)
}
}
if k == "file" {
if filename, ok := v.(string); ok {
out.Filename = filename
}
}
}
file, hasFile, _, lineNum, hasLine := extractFileLine(meta)
if hasLine {
out.StartLine = int64(lineNum)
}
if hasFile {
out.Filename = file
}

verifiedStatus := "unverified"
Expand Down
31 changes: 31 additions & 0 deletions pkg/output/helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package output

import "fmt"

// extractFileLine walks over the metadata map created by structToMap and
// extracts the file name and line number when present. This logic is shared by
// multiple printers so that they stay consistent.
func extractFileLine(meta map[string]map[string]any) (file string, hasFile bool, line string, lineNum int, hasLine bool) {
file = "n/a"
line = "n/a"

for _, data := range meta {
for k, v := range data {
if k == "line" {
if l, ok := v.(float64); ok {
lineNum = int(l)
line = fmt.Sprintf("%d", lineNum)
hasLine = true
}
}
if k == "file" {
if filename, ok := v.(string); ok {
file = filename
hasFile = true
}
}
}
}

return
}
173 changes: 173 additions & 0 deletions pkg/output/markdown.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
package output

import (
"bytes"
"fmt"
"io"
"os"
"sort"
"strings"
"sync"

"github.com/trufflesecurity/trufflehog/v3/pkg/context"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
)

// MarkdownPrinter renders TruffleHog findings into a Markdown document with
// dedicated tables for verified and unverified secrets. It buffers the rows
// while scanning and flushes the final report when Close is invoked.
type MarkdownPrinter struct {
mu sync.Mutex

out io.Writer

verified []markdownRow
unverified []markdownRow

seenVerified map[string]struct{}
seenUnverified map[string]struct{}
}

// markdownRow represents a single table entry in the Markdown report.
type markdownRow struct {
Detector string
File string
Line string
Redacted string

lineNum int
hasLine bool
}

func (r markdownRow) key() string {
return fmt.Sprintf("%s|%s|%s|%s", r.Detector, r.File, r.Line, r.Redacted)
}

// NewMarkdownPrinter builds a MarkdownPrinter that writes to out. When out is
// nil, stdout is used.
func NewMarkdownPrinter(out io.Writer) *MarkdownPrinter {
if out == nil {
out = os.Stdout
}
return &MarkdownPrinter{
out: out,
seenVerified: make(map[string]struct{}),
seenUnverified: make(map[string]struct{}),
}
}

// Print collects each result so the final Markdown doc can include per-section
// counts and tables before the buffered results are rendered in Close.
func (p *MarkdownPrinter) Print(_ context.Context, r *detectors.ResultWithMetadata) error {
meta, err := structToMap(r.SourceMetadata.Data)
if err != nil {
return fmt.Errorf("could not marshal result: %w", err)
}

file, hasFile, line, lineNum, hasLine := extractFileLine(meta)

if !hasFile {
file = "n/a"
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant fallback already handled by helper function

Low Severity

The check if !hasFile { file = "n/a" } is redundant. The extractFileLine function in helpers.go already initializes file to "n/a" as its default value (line 9), and only updates it when a file is actually found. When hasFile is false, file is already "n/a", making this conditional assignment unnecessary.

Fix in Cursor Fix in Web


row := markdownRow{
Detector: sanitize(r.DetectorType.String()),
File: sanitize(file),
Line: sanitize(line),
Redacted: sanitize(r.Redacted),
lineNum: lineNum,
hasLine: hasLine,
}

p.mu.Lock()
defer p.mu.Unlock()

key := row.key()

if r.Verified {
if _, ok := p.seenVerified[key]; ok {
return nil
}
p.seenVerified[key] = struct{}{}
p.verified = append(p.verified, row)
} else {
if _, ok := p.seenUnverified[key]; ok {
return nil
}
p.seenUnverified[key] = struct{}{}
p.unverified = append(p.unverified, row)
}
return nil
}

// Close renders the buffered findings to Markdown. Close should be invoked by
// the output manager once scanning finishes.
func (p *MarkdownPrinter) Close() error {
p.mu.Lock()
defer p.mu.Unlock()

doc := renderMarkdown(p.verified, p.unverified)
if doc == "" {
return nil
}
if _, err := fmt.Fprint(p.out, doc); err != nil {
return fmt.Errorf("write markdown: %w", err)
}
return nil
}

// renderMarkdown mirrors templates/trufflehog_report.py by emitting a title,
// optional sections for verified/unverified findings, and per-section counts.
func renderMarkdown(verified, unverified []markdownRow) string {
if len(verified) == 0 && len(unverified) == 0 {
return ""
}

var buf bytes.Buffer
buf.WriteString("# TruffleHog Findings\n\n")
writeSection := func(title string, rows []markdownRow) {
if len(rows) == 0 {
return
}
sort.SliceStable(rows, func(i, j int) bool {
if rows[i].Detector != rows[j].Detector {
return rows[i].Detector < rows[j].Detector
}
if rows[i].File != rows[j].File {
return rows[i].File < rows[j].File
}
if rows[i].hasLine != rows[j].hasLine {
return rows[i].hasLine
}
if rows[i].lineNum != rows[j].lineNum {
return rows[i].lineNum < rows[j].lineNum
}
return rows[i].Line < rows[j].Line
})

fmt.Fprintf(&buf, "## %s (%d)\n", title, len(rows))
buf.WriteString("| Detector | File | Line | Redacted |\n")
buf.WriteString("| --- | --- | --- | --- |\n")
for _, row := range rows {
fmt.Fprintf(&buf, "| %s | %s | %s | %s |\n", row.Detector, row.File, row.Line, row.Redacted)
}
buf.WriteString("\n")
}

writeSection("Verified Findings", verified)
writeSection("Unverified Findings", unverified)

return strings.TrimRight(buf.String(), "\n") + "\n"
}

var sanitizer = strings.NewReplacer("\r", " ", "\n", " ", "|", "\\|", " ", " ")

func sanitize(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "n/a"
}
return sanitizer.Replace(value)
}


16 changes: 14 additions & 2 deletions pkg/tui/pages/source_configure/trufflehog_configure.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ func GetTrufflehogConfiguration() truffleCmdModel {
Placeholder: "false",
}

markdownOutput := textinputs.InputConfig{
Label: "Markdown output",
Key: "markdown",
Required: false,
Help: "Output results to Markdown",
Placeholder: "false",
}

excludeDetectors := textinputs.InputConfig{
Label: "Exclude detectors",
Key: "exclude_detectors",
Expand All @@ -53,13 +61,17 @@ func GetTrufflehogConfiguration() truffleCmdModel {
Placeholder: strconv.Itoa(runtime.NumCPU()),
}

return truffleCmdModel{textinputs.New([]textinputs.InputConfig{jsonOutput, verification, verifiedResults, excludeDetectors, concurrency}).SetSkip(true)}
return truffleCmdModel{textinputs.New([]textinputs.InputConfig{jsonOutput, markdownOutput, verification, verifiedResults, excludeDetectors, concurrency}).SetSkip(true)}
}

func (m truffleCmdModel) Cmd() string {
var command []string
inputs := m.GetInputs()

if isTrue(inputs["markdown"].Value) {
command = append(command, "--markdown")
}

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TUI allows conflicting output format flags simultaneously

Low Severity

The TUI configuration allows users to enable both --markdown and --json output formats simultaneously without any validation or warning. When both are enabled, the Cmd() function appends both flags to the command, but main.go's switch statement checks *jsonOut before *markdownOut, causing JSON to silently take precedence. Users enabling both options may unexpectedly receive JSON output instead of the Markdown they also selected.

Additional Locations (1)

Fix in Cursor Fix in Web

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's common pattern for all printers. If neccesary, should be fixed in separate PR.

if isTrue(inputs["json"].Value) {
command = append(command, "--json")
}
Expand All @@ -86,7 +98,7 @@ func (m truffleCmdModel) Cmd() string {

func (m truffleCmdModel) Summary() string {
summary := strings.Builder{}
keys := []string{"no-verification", "only-verified", "json", "exclude_detectors", "concurrency"}
keys := []string{"no-verification", "only-verified", "json", "markdown", "exclude_detectors", "concurrency"}

inputs := m.GetInputs()
labels := m.GetLabels()
Expand Down