diff --git a/README.md b/README.md index e8df00c28e7b..8eb709a7fba1 100644 --- a/README.md +++ b/README.md @@ -450,6 +450,8 @@ Flags: -j, --json Output in JSON 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. + --github-actions-step-summary + Output a summary to the GitHub Actions step summary. --concurrency=20 Number of concurrent workers. --no-verification Don't verify the results. --results=RESULTS Specifies which type(s) of results to output: verified (confirmed valid by API), unknown (verification failed due to error), unverified (detected but not verified), filtered_unverified (unverified but would have been filtered out). Defaults to all types. diff --git a/action.yml b/action.yml index 2acb0ad23f81..fe23d3caba93 100644 --- a/action.yml +++ b/action.yml @@ -22,6 +22,10 @@ inputs: default: "latest" description: Scan with this trufflehog cli version. required: false + generate_step_summary: + default: "true" + description: Output results to the GitHub Actions step summary. + required: false branding: icon: "shield" color: "green" @@ -29,78 +33,90 @@ branding: runs: using: "composite" steps: - - shell: bash - working-directory: ${{ inputs.path }} - env: - BASE: ${{ inputs.base }} - HEAD: ${{ inputs.head }} - ARGS: ${{ inputs.extra_args }} - COMMIT_IDS: ${{ toJson(github.event.commits.*.id) }} - VERSION: ${{ inputs.version }} - run: | - ########################################## - ## ADVANCED USAGE ## - ## Scan by BASE & HEAD user inputs ## - ## If BASE == HEAD, exit with error ## - ########################################## - # Check if jq is installed, if not, install it - if ! command -v jq &> /dev/null - then - echo "jq could not be found, installing..." - apt-get -y update && apt-get install -y jq - fi + - shell: bash + working-directory: ${{ inputs.path }} + env: + BASE: ${{ inputs.base }} + HEAD: ${{ inputs.head }} + ARGS: ${{ inputs.extra_args }} + COMMIT_IDS: ${{ toJson(github.event.commits.*.id) }} + VERSION: ${{ inputs.version }} + GITHUB_STEP_SUMMARY_ENABLED: ${{ inputs.generate_step_summary }} + run: | + ########################################## + ## ADVANCED USAGE ## + ## Scan by BASE & HEAD user inputs ## + ## If BASE == HEAD, exit with error ## + ########################################## + # Check if jq is installed, if not, install it + if ! command -v jq &> /dev/null + then + echo "jq could not be found, installing..." + apt-get -y update && apt-get install -y jq + fi - git status >/dev/null # make sure we are in a git repository - if [ -n "$BASE" ] || [ -n "$HEAD" ]; then - if [ -n "$BASE" ]; then - base_commit=$(git rev-parse "$BASE" 2>/dev/null) || true - else - base_commit="" - fi - if [ -n "$HEAD" ]; then - head_commit=$(git rev-parse "$HEAD" 2>/dev/null) || true - else - head_commit="" - fi - if [ "$base_commit" == "$head_commit" ] ; then - echo "::error::BASE and HEAD commits are the same. TruffleHog won't scan anything. Please see documentation (https://github.com/trufflesecurity/trufflehog#octocat-trufflehog-github-action)." - exit 1 - fi - ########################################## - ## Scan commits based on event type ## - ########################################## + git status >/dev/null # make sure we are in a git repository + if [ -n "$BASE" ] || [ -n "$HEAD" ]; then + if [ -n "$BASE" ]; then + base_commit=$(git rev-parse "$BASE" 2>/dev/null) || true else - if [ "${{ github.event_name }}" == "push" ]; then - COMMIT_LENGTH=$(printenv COMMIT_IDS | jq length) - if [ $COMMIT_LENGTH == "0" ]; then - echo "No commits to scan" - exit 0 - fi - HEAD=${{ github.event.after }} - if [ ${{ github.event.before }} == "0000000000000000000000000000000000000000" ]; then - BASE="" - else - BASE=${{ github.event.before }} - fi - elif [ "${{ github.event_name }}" == "workflow_dispatch" ] || [ "${{ github.event_name }}" == "schedule" ]; then + base_commit="" + fi + if [ -n "$HEAD" ]; then + head_commit=$(git rev-parse "$HEAD" 2>/dev/null) || true + else + head_commit="" + fi + if [ "$base_commit" == "$head_commit" ] ; then + echo "::error::BASE and HEAD commits are the same. TruffleHog won't scan anything. Please see documentation (https://github.com/trufflesecurity/trufflehog#octocat-trufflehog-github-action)." + exit 1 + fi + ########################################## + ## Scan commits based on event type ## + ########################################## + else + if [ "${{ github.event_name }}" == "push" ]; then + COMMIT_LENGTH=$(printenv COMMIT_IDS | jq length) + if [ $COMMIT_LENGTH == "0" ]; then + echo "No commits to scan" + exit 0 + fi + HEAD=${{ github.event.after }} + if [ ${{ github.event.before }} == "0000000000000000000000000000000000000000" ]; then BASE="" - HEAD="" - elif [ "${{ github.event_name }}" == "pull_request" ]; then - BASE=${{github.event.pull_request.base.sha}} - HEAD=${{github.event.pull_request.head.sha}} + else + BASE=${{ github.event.before }} fi + elif [ "${{ github.event_name }}" == "workflow_dispatch" ] || [ "${{ github.event_name }}" == "schedule" ]; then + BASE="" + HEAD="" + elif [ "${{ github.event_name }}" == "pull_request" ]; then + BASE=${{github.event.pull_request.base.sha}} + HEAD=${{github.event.pull_request.head.sha}} fi - ########################################## - ## Run TruffleHog ## - ########################################## - docker run --rm -v .:/tmp -w /tmp \ - ghcr.io/trufflesecurity/trufflehog:${VERSION} \ - git file:///tmp/ \ - --since-commit \ - ${BASE:-''} \ - --branch \ - ${HEAD:-''} \ - --fail \ - --no-update \ - --github-actions \ - ${ARGS:-''} + fi + ########################################## + ## Run TruffleHog ## + ########################################## + STEP_SUMMARY_FLAG="" + if [ "$GITHUB_STEP_SUMMARY_ENABLED" == "true" ]; then + STEP_SUMMARY_FLAG="--github-actions-step-summary" + fi + docker run --rm -v .:/tmp -w /tmp \ + -e GITHUB_STEP_SUMMARY=/tmp/.step_summary \ + ghcr.io/trufflesecurity/trufflehog:${VERSION} \ + git file:///tmp/ \ + --since-commit \ + ${BASE:-''} \ + --branch \ + ${HEAD:-''} \ + --fail \ + --no-update \ + --github-actions \ + ${STEP_SUMMARY_FLAG} \ + ${ARGS:-''} + # Append the step summary file if it exists + if [ -f ".step_summary" ]; then + cat .step_summary >> $GITHUB_STEP_SUMMARY + rm .step_summary + fi diff --git a/main.go b/main.go index 00ca4f7dfb56..e98968623b66 100644 --- a/main.go +++ b/main.go @@ -47,20 +47,21 @@ var ( cli = kingpin.New("TruffleHog", "TruffleHog is a tool for finding credentials.") cmd string // https://github.com/trufflesecurity/trufflehog/blob/main/CONTRIBUTING.md#logging-in-trufflehog - logLevel = cli.Flag("log-level", `Logging verbosity on a scale of 0 (info) to 5 (trace). Can be disabled with "-1".`).Default("0").Int() - debug = cli.Flag("debug", "Run in debug mode.").Hidden().Bool() - trace = cli.Flag("trace", "Run in trace mode.").Hidden().Bool() - 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() - 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() - noVerification = cli.Flag("no-verification", "Don't verify the results.").Bool() - onlyVerified = cli.Flag("only-verified", "Only output verified results.").Hidden().Bool() - results = cli.Flag("results", "Specifies which type(s) of results to output: verified (confirmed valid by API), unknown (verification failed due to error), unverified (detected but not verified), filtered_unverified (unverified but would have been filtered out). Defaults to verified,unverified,unknown.").String() - noColor = cli.Flag("no-color", "Disable colorized output").Bool() - noColour = cli.Flag("no-colour", "Alias for --no-color").Hidden().Bool() + logLevel = cli.Flag("log-level", `Logging verbosity on a scale of 0 (info) to 5 (trace). Can be disabled with "-1".`).Default("0").Int() + debug = cli.Flag("debug", "Run in debug mode.").Hidden().Bool() + trace = cli.Flag("trace", "Run in trace mode.").Hidden().Bool() + 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() + 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() + gitHubActionsStepSummaryFormat = cli.Flag("github-actions-step-summary", "Output a summary to the GitHub Actions step summary.").Bool() + concurrency = cli.Flag("concurrency", "Number of concurrent workers.").Default(strconv.Itoa(runtime.NumCPU())).Int() + noVerification = cli.Flag("no-verification", "Don't verify the results.").Bool() + onlyVerified = cli.Flag("only-verified", "Only output verified results.").Hidden().Bool() + results = cli.Flag("results", "Specifies which type(s) of results to output: verified (confirmed valid by API), unknown (verification failed due to error), unverified (detected but not verified), filtered_unverified (unverified but would have been filtered out). Defaults to verified,unverified,unknown.").String() + noColor = cli.Flag("no-color", "Disable colorized output").Bool() + noColour = cli.Flag("no-colour", "Alias for --no-color").Hidden().Bool() allowVerificationOverlap = cli.Flag("allow-verification-overlap", "Allow verification of similar credentials across detectors").Bool() filterUnverified = cli.Flag("filter-unverified", "Only output first unverified result per chunk per detector if there are more than one results.").Bool() @@ -510,6 +511,8 @@ func run(state overseer.State) { printer = new(output.JSONPrinter) case *gitHubActionsFormat: printer = new(output.GitHubActionsPrinter) + case *gitHubActionsStepSummaryFormat: + printer = new(output.GitHubActionsStepSummaryPrinter) default: printer = new(output.PlainPrinter) } @@ -579,6 +582,13 @@ func run(state overseer.State) { logFatal(err, "error running scan") } + // Finish the printer if it implements Finisher (e.g., for step summary output). + if dispatcher, ok := engConf.Dispatcher.(*engine.PrinterDispatcher); ok { + if err := dispatcher.Finish(); err != nil { + logger.Error(err, "error finishing printer") + } + } + verificationCacheMetricsSnapshot := struct { Hits int32 Misses int32 diff --git a/pkg/engine/engine.go b/pkg/engine/engine.go index dfe41ab1763a..31531b04e07c 100644 --- a/pkg/engine/engine.go +++ b/pkg/engine/engine.go @@ -82,6 +82,12 @@ type Printer interface { Print(ctx context.Context, r *detectors.ResultWithMetadata) error } +// Finisher is an optional interface that Printers can implement to perform cleanup +// or write final output after all results have been printed. +type Finisher interface { + Finish() error +} + // PrinterDispatcher wraps an existing Printer implementation and adapts it to the ResultsDispatcher interface. type PrinterDispatcher struct{ printer Printer } @@ -93,6 +99,14 @@ func (p *PrinterDispatcher) Dispatch(ctx context.Context, result detectors.Resul return p.printer.Print(ctx, &result) } +// Finish calls Finish on the underlying printer if it implements the Finisher interface. +func (p *PrinterDispatcher) Finish() error { + if f, ok := p.printer.(Finisher); ok { + return f.Finish() + } + return nil +} + // Config used to configure the engine. type Config struct { // Number of concurrent scanner workers, diff --git a/pkg/output/github_actions_step_summary.go b/pkg/output/github_actions_step_summary.go new file mode 100644 index 000000000000..66db3e314b27 --- /dev/null +++ b/pkg/output/github_actions_step_summary.go @@ -0,0 +1,233 @@ +package output + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "strings" + "sync" + + "github.com/trufflesecurity/trufflehog/v3/pkg/context" + "github.com/trufflesecurity/trufflehog/v3/pkg/detectors" + "github.com/trufflesecurity/trufflehog/v3/pkg/giturl" + "github.com/trufflesecurity/trufflehog/v3/pkg/pb/detectorspb" +) + +// GitHubActionsStepSummaryPrinter is a printer that outputs to the GitHub Actions step summary. +// It produces a Markdown table with links to the specific locations where secrets were found. +type GitHubActionsStepSummaryPrinter struct { + mu sync.Mutex + file *os.File + headerWritten bool + dedupeCache map[string]struct{} + resultCount int + verifiedCount int + unverifiedCount int +} + +type stepSummaryResult struct { + DetectorType string + Verified bool + Filename string + Commit string + Link string + Line int64 + Column int64 +} + +func (p *GitHubActionsStepSummaryPrinter) Print(_ context.Context, r *detectors.ResultWithMetadata) error { + p.mu.Lock() + defer p.mu.Unlock() + + // Initialize on first call + if p.dedupeCache == nil { + p.dedupeCache = make(map[string]struct{}) + } + + // Open the step summary file if not already open + if p.file == nil { + summaryPath := os.Getenv("GITHUB_STEP_SUMMARY") + if summaryPath == "" { + // Fall back to stdout if not in GitHub Actions + p.file = os.Stdout + } else { + var err error + p.file, err = os.OpenFile(summaryPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("could not open step summary file: %w", err) + } + } + } + + out := stepSummaryResult{ + DetectorType: r.Result.DetectorType.String(), + Verified: r.Result.Verified, + } + + // Extract metadata from the source + if r.SourceMetadata != nil { + // Try to get GitHub-specific metadata first + if github := r.SourceMetadata.GetGithub(); github != nil { + out.Filename = github.GetFile() + out.Commit = github.GetCommit() + out.Link = github.GetLink() + out.Line = github.GetLine() + } else if gitlab := r.SourceMetadata.GetGitlab(); gitlab != nil { + out.Filename = gitlab.GetFile() + out.Commit = gitlab.GetCommit() + out.Link = gitlab.GetLink() + out.Line = gitlab.GetLine() + } else if git := r.SourceMetadata.GetGit(); git != nil { + out.Filename = git.GetFile() + out.Commit = git.GetCommit() + out.Line = git.GetLine() + // Git source doesn't have a link, construct one if possible + if git.GetRepository() != "" { + out.Link = giturl.GenerateLink(git.GetRepository(), git.GetCommit(), git.GetFile(), git.GetLine()) + } + } else if bitbucket := r.SourceMetadata.GetBitbucket(); bitbucket != nil { + out.Filename = bitbucket.GetFile() + out.Commit = bitbucket.GetCommit() + out.Link = bitbucket.GetLink() + out.Line = bitbucket.GetLine() + } else if filesystem := r.SourceMetadata.GetFilesystem(); filesystem != nil { + out.Filename = filesystem.GetFile() + out.Link = filesystem.GetLink() + out.Line = filesystem.GetLine() + } else { + // Fall back to generic metadata extraction + meta, err := structToMap(r.SourceMetadata.Data) + if err == nil { + for _, data := range meta { + if line, ok := data["line"].(float64); ok { + out.Line = int64(line) + } + if filename, ok := data["file"].(string); ok { + out.Filename = filename + } + if commit, ok := data["commit"].(string); ok { + out.Commit = commit + } + if link, ok := data["link"].(string); ok { + out.Link = link + } + } + } + } + } + + // Create deduplication key + verifiedStatus := "unverified" + if out.Verified { + verifiedStatus = "verified" + } + + key := fmt.Sprintf("%s:%s:%s:%s:%d", out.DetectorType, verifiedStatus, out.Filename, out.Commit, out.Line) + h := sha256.New() + h.Write([]byte(key)) + hashKey := hex.EncodeToString(h.Sum(nil)) + + if _, ok := p.dedupeCache[hashKey]; ok { + return nil + } + p.dedupeCache[hashKey] = struct{}{} + + // Write header on first result + if !p.headerWritten { + fmt.Fprintln(p.file, "## 🐷🔑 TruffleHog Secrets Scan Results") + fmt.Fprintln(p.file, "") + fmt.Fprintln(p.file, "| Secret Type | Status | File | Line |") + fmt.Fprintln(p.file, "|-------------|--------|------|------|") + p.headerWritten = true + } + + // Format verified status with emoji + statusEmoji := "🔲 Unverified" + if out.Verified { + statusEmoji = "✅ Verified" + p.verifiedCount++ + } else { + p.unverifiedCount++ + } + p.resultCount++ + + // Format the file location with optional link + fileLocation := formatFileLocation(out.Filename, out.Commit, out.Link) + + // Add name to detector type if available + detectorDisplay := out.DetectorType + if nameValue, ok := r.Result.ExtraData["name"]; ok { + detectorDisplay = fmt.Sprintf("%s (%s)", out.DetectorType, nameValue) + } + + // Include encoding info if not plain + if r.DecoderType != detectorspb.DecoderType_PLAIN { + detectorDisplay = fmt.Sprintf("%s [%s]", detectorDisplay, r.DecoderType.String()) + } + + // Write the table row + fmt.Fprintf(p.file, "| %s | %s | %s | %d |\n", + escapeMarkdown(detectorDisplay), + statusEmoji, + fileLocation, + out.Line) + + return nil +} + +// Finish writes the summary footer with counts +func (p *GitHubActionsStepSummaryPrinter) Finish() error { + p.mu.Lock() + defer p.mu.Unlock() + + if p.file == nil { + return nil + } + + if p.resultCount > 0 { + fmt.Fprintln(p.file, "") + fmt.Fprintf(p.file, "### Summary\n") + fmt.Fprintf(p.file, "- **Total secrets found:** %d\n", p.resultCount) + fmt.Fprintf(p.file, "- **Verified:** %d\n", p.verifiedCount) + fmt.Fprintf(p.file, "- **Unverified:** %d\n", p.unverifiedCount) + } else if p.headerWritten { + fmt.Fprintln(p.file, "") + fmt.Fprintln(p.file, "✅ No secrets found!") + } + + // Close the file if it's not stdout + if p.file != os.Stdout { + return p.file.Close() + } + return nil +} + +// formatFileLocation creates a markdown link for the file if a link is available +func formatFileLocation(filename, commit, link string) string { + if filename == "" { + filename = "unknown" + } + + displayName := filename + if commit != "" { + // Show short commit hash + shortCommit := commit + if len(commit) > 7 { + shortCommit = commit[:7] + } + displayName = fmt.Sprintf("%s @ %s", filename, shortCommit) + } + + if link != "" { + return fmt.Sprintf("[%s](%s)", escapeMarkdown(displayName), link) + } + return escapeMarkdown(displayName) +} + +// escapeMarkdown escapes special markdown characters in text +func escapeMarkdown(text string) string { + // Escape pipe characters as they break table formatting + text = strings.ReplaceAll(text, "|", "\\|") + return text +}