From 031060551007ab5dff67d5e28c77ca19a0cfe05c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 1 Dec 2025 14:04:57 +0000 Subject: [PATCH 1/7] Initial plan From 9217e65101cf499b2987faec3f2a56f41c0f777a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 1 Dec 2025 14:17:09 +0000 Subject: [PATCH 2/7] Add platform-specific builds for goreleaser and Makefile Co-authored-by: frjcomp <107982661+frjcomp@users.noreply.github.com> --- .gitignore | 10 ++ Makefile | 64 +++++++++-- cmd/pipeleak-bitbucket/main.go | 193 +++++++++++++++++++++++++++++++++ cmd/pipeleak-devops/main.go | 193 +++++++++++++++++++++++++++++++++ cmd/pipeleak-gitea/main.go | 193 +++++++++++++++++++++++++++++++++ cmd/pipeleak-github/main.go | 193 +++++++++++++++++++++++++++++++++ cmd/pipeleak-gitlab/main.go | 193 +++++++++++++++++++++++++++++++++ goreleaser.yaml | 75 ++++++++++++- 8 files changed, 1099 insertions(+), 15 deletions(-) create mode 100644 cmd/pipeleak-bitbucket/main.go create mode 100644 cmd/pipeleak-devops/main.go create mode 100644 cmd/pipeleak-gitea/main.go create mode 100644 cmd/pipeleak-github/main.go create mode 100644 cmd/pipeleak-gitlab/main.go diff --git a/.gitignore b/.gitignore index 00c506d8..369de95b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,16 @@ cli-docs site /pipeleak /pipeleak.exe +/pipeleak-gitlab +/pipeleak-gitlab.exe +/pipeleak-github +/pipeleak-github.exe +/pipeleak-bitbucket +/pipeleak-bitbucket.exe +/pipeleak-devops +/pipeleak-devops.exe +/pipeleak-gitea +/pipeleak-gitea.exe coverage.out coverage.html .vscode diff --git a/Makefile b/Makefile index c553034a..7437c6f9 100644 --- a/Makefile +++ b/Makefile @@ -1,25 +1,60 @@ -.PHONY: help build test test-unit test-e2e lint clean coverage coverage-html serve-docs +.PHONY: help build build-all build-gitlab build-github build-bitbucket build-devops build-gitea test test-unit test-e2e lint clean coverage coverage-html serve-docs # Default target help: @echo "Pipeleak Makefile" @echo "" @echo "Available targets:" - @echo " make build - Build the pipeleak binary" - @echo " make test - Run all tests (unit + e2e)" - @echo " make test-unit - Run unit tests only" - @echo " make test-e2e - Run e2e tests (builds binary first)" - @echo " make coverage - Generate test coverage report" - @echo " make coverage-html - Generate and open HTML coverage report" - @echo " make lint - Run golangci-lint" - @echo " make serve-docs - Generate and serve CLI documentation" - @echo " make clean - Remove built artifacts" - -# Build the pipeleak binary + @echo " make build - Build the main pipeleak binary" + @echo " make build-all - Build all binaries (main + platform-specific)" + @echo " make build-gitlab - Build GitLab-specific binary" + @echo " make build-github - Build GitHub-specific binary" + @echo " make build-bitbucket - Build BitBucket-specific binary" + @echo " make build-devops - Build Azure DevOps-specific binary" + @echo " make build-gitea - Build Gitea-specific binary" + @echo " make test - Run all tests (unit + e2e)" + @echo " make test-unit - Run unit tests only" + @echo " make test-e2e - Run e2e tests (builds binary first)" + @echo " make coverage - Generate test coverage report" + @echo " make coverage-html - Generate and open HTML coverage report" + @echo " make lint - Run golangci-lint" + @echo " make serve-docs - Generate and serve CLI documentation" + @echo " make clean - Remove built artifacts" + +# Build the main pipeleak binary build: @echo "Building pipeleak..." go build -o pipeleak ./cmd/pipeleak +# Build GitLab-specific binary +build-gitlab: + @echo "Building pipeleak-gitlab..." + go build -o pipeleak-gitlab ./cmd/pipeleak-gitlab + +# Build GitHub-specific binary +build-github: + @echo "Building pipeleak-github..." + go build -o pipeleak-github ./cmd/pipeleak-github + +# Build BitBucket-specific binary +build-bitbucket: + @echo "Building pipeleak-bitbucket..." + go build -o pipeleak-bitbucket ./cmd/pipeleak-bitbucket + +# Build Azure DevOps-specific binary +build-devops: + @echo "Building pipeleak-devops..." + go build -o pipeleak-devops ./cmd/pipeleak-devops + +# Build Gitea-specific binary +build-gitea: + @echo "Building pipeleak-gitea..." + go build -o pipeleak-gitea ./cmd/pipeleak-gitea + +# Build all binaries +build-all: build build-gitlab build-github build-bitbucket build-devops build-gitea + @echo "All binaries built successfully" + # Run all tests test: test-unit test-e2e @@ -96,4 +131,9 @@ serve-docs: build clean: @echo "Cleaning up..." rm -f pipeleak pipeleak.exe coverage.out coverage.html + rm -f pipeleak-gitlab pipeleak-gitlab.exe + rm -f pipeleak-github pipeleak-github.exe + rm -f pipeleak-bitbucket pipeleak-bitbucket.exe + rm -f pipeleak-devops pipeleak-devops.exe + rm -f pipeleak-gitea pipeleak-gitea.exe go clean -cache -testcache diff --git a/cmd/pipeleak-bitbucket/main.go b/cmd/pipeleak-bitbucket/main.go new file mode 100644 index 00000000..83457906 --- /dev/null +++ b/cmd/pipeleak-bitbucket/main.go @@ -0,0 +1,193 @@ +package main + +import ( + "encoding/json" + "io" + "os" + + "github.com/CompassSecurity/pipeleak/internal/cmd/bitbucket" + "github.com/CompassSecurity/pipeleak/pkg/logging" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +// Version information - set via ldflags during build +var ( + Version = "dev" + Commit = "none" + Date = "unknown" +) + +var ( + originalTermState *term.State + JsonLogoutput bool + LogFile string + LogColor bool + LogDebug bool + LogLevel string +) + +// TerminalRestorer is a function that can be called to restore terminal state +var TerminalRestorer func() + +type TerminalRestoringWriter struct { + underlying io.Writer +} + +func (w *TerminalRestoringWriter) Write(p []byte) (n int, err error) { + var logEntry map[string]interface{} + if err := json.Unmarshal(p, &logEntry); err == nil { + if level, ok := logEntry["level"].(string); ok && level == "fatal" { + _, _ = w.underlying.Write(p) + restoreTerminalState() + os.Exit(1) + } + } + return w.underlying.Write(p) +} + +func main() { + saveTerminalState() + defer restoreTerminalState() + + TerminalRestorer = restoreTerminalState + + rootCmd := newRootCmd() + err := rootCmd.Execute() + if err != nil { + os.Exit(1) + } +} + +func newRootCmd() *cobra.Command { + bbCmd := bitbucket.NewBitBucketRootCmd() + bbCmd.Use = "pipeleak-bitbucket" + bbCmd.Short = "Scan BitBucket Pipelines logs and artifacts for secrets" + bbCmd.Long = `Pipeleak-BitBucket is a tool designed to scan BitBucket Pipelines job output logs and artifacts for potential secrets. + +This is a standalone binary for BitBucket-specific functionality.` + bbCmd.Version = Version + bbCmd.GroupID = "" + + bbCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { + initLogger(cmd) + setGlobalLogLevel(cmd) + go logging.ShortcutListeners(nil) + } + + bbCmd.PersistentFlags().BoolVarP(&JsonLogoutput, "json", "", false, "Use JSON as log output format") + bbCmd.PersistentFlags().StringVarP(&LogFile, "logfile", "l", "", "Log output to a file") + bbCmd.PersistentFlags().BoolVarP(&LogDebug, "verbose", "v", false, "Enable debug logging (shortcut for --log-level=debug)") + bbCmd.PersistentFlags().StringVar(&LogLevel, "log-level", "", "Set log level globally (debug, info, warn, error). Example: --log-level=warn") + bbCmd.PersistentFlags().BoolVar(&LogColor, "color", true, "Enable colored log output (auto-disabled when using --logfile)") + + bbCmd.SetVersionTemplate(`{{.Version}} +`) + + return bbCmd +} + +func saveTerminalState() { + if term.IsTerminal(int(os.Stdin.Fd())) { + state, err := term.GetState(int(os.Stdin.Fd())) + if err == nil { + originalTermState = state + } + } +} + +func restoreTerminalState() { + if originalTermState != nil { + _ = term.Restore(int(os.Stdin.Fd()), originalTermState) + } +} + +// FatalHook is a zerolog hook that restores terminal state before fatal exits +type FatalHook struct{} + +func (h FatalHook) Run(e *zerolog.Event, level zerolog.Level, msg string) { + if level == zerolog.FatalLevel { + if TerminalRestorer != nil { + TerminalRestorer() + } + } +} + +func initLogger(cmd *cobra.Command) { + defaultOut := os.Stdout + colorEnabled := LogColor + + if LogFile != "" { + // #nosec G304 - User-provided log file path via --log-file flag, user controls their own filesystem + runLogFile, err := os.OpenFile( + LogFile, + os.O_APPEND|os.O_CREATE|os.O_WRONLY, + 0600, + ) + if err != nil { + panic(err) + } + defaultOut = runLogFile + + rootFlags := cmd.Root().PersistentFlags() + if !rootFlags.Changed("color") { + colorEnabled = false + } + } + + fatalHook := FatalHook{} + + if JsonLogoutput { + hitWriter := &logging.HitLevelWriter{} + hitWriter.SetOutput(defaultOut) + logging.SetGlobalHitWriter(hitWriter) + log.Logger = zerolog.New(hitWriter).With().Timestamp().Logger().Hook(fatalHook) + } else { + output := zerolog.ConsoleWriter{ + Out: defaultOut, + TimeFormat: "2006-01-02T15:04:05Z07:00", + NoColor: !colorEnabled, + } + hitWriter := &logging.HitLevelWriter{} + hitWriter.SetOutput(&output) + logging.SetGlobalHitWriter(hitWriter) + log.Logger = zerolog.New(hitWriter).With().Timestamp().Logger().Hook(fatalHook) + } +} + +func setGlobalLogLevel(cmd *cobra.Command) { + if LogLevel != "" { + switch LogLevel { + case "trace": + zerolog.SetGlobalLevel(zerolog.TraceLevel) + log.Trace().Msg("Log level set to trace (explicit)") + case "debug": + zerolog.SetGlobalLevel(zerolog.DebugLevel) + log.Debug().Msg("Log level set to debug (explicit)") + case "info": + zerolog.SetGlobalLevel(zerolog.InfoLevel) + log.Info().Msg("Log level set to info (explicit)") + case "warn": + zerolog.SetGlobalLevel(zerolog.WarnLevel) + log.Warn().Msg("Log level set to warn (explicit)") + case "error": + zerolog.SetGlobalLevel(zerolog.ErrorLevel) + log.Error().Msg("Log level set to error (explicit)") + default: + zerolog.SetGlobalLevel(zerolog.InfoLevel) + log.Warn().Str("logLevelSpecified", LogLevel).Msg("Invalid log level, defaulting to info") + } + return + } + + if LogDebug { + zerolog.SetGlobalLevel(zerolog.DebugLevel) + log.Debug().Msg("Log level set to debug (-v)") + return + } + + zerolog.SetGlobalLevel(zerolog.InfoLevel) + log.Info().Msg("Log level set to info (default)") +} diff --git a/cmd/pipeleak-devops/main.go b/cmd/pipeleak-devops/main.go new file mode 100644 index 00000000..63f7e014 --- /dev/null +++ b/cmd/pipeleak-devops/main.go @@ -0,0 +1,193 @@ +package main + +import ( + "encoding/json" + "io" + "os" + + "github.com/CompassSecurity/pipeleak/internal/cmd/devops" + "github.com/CompassSecurity/pipeleak/pkg/logging" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +// Version information - set via ldflags during build +var ( + Version = "dev" + Commit = "none" + Date = "unknown" +) + +var ( + originalTermState *term.State + JsonLogoutput bool + LogFile string + LogColor bool + LogDebug bool + LogLevel string +) + +// TerminalRestorer is a function that can be called to restore terminal state +var TerminalRestorer func() + +type TerminalRestoringWriter struct { + underlying io.Writer +} + +func (w *TerminalRestoringWriter) Write(p []byte) (n int, err error) { + var logEntry map[string]interface{} + if err := json.Unmarshal(p, &logEntry); err == nil { + if level, ok := logEntry["level"].(string); ok && level == "fatal" { + _, _ = w.underlying.Write(p) + restoreTerminalState() + os.Exit(1) + } + } + return w.underlying.Write(p) +} + +func main() { + saveTerminalState() + defer restoreTerminalState() + + TerminalRestorer = restoreTerminalState + + rootCmd := newRootCmd() + err := rootCmd.Execute() + if err != nil { + os.Exit(1) + } +} + +func newRootCmd() *cobra.Command { + adCmd := devops.NewAzureDevOpsRootCmd() + adCmd.Use = "pipeleak-devops" + adCmd.Short = "Scan Azure DevOps Pipelines logs and artifacts for secrets" + adCmd.Long = `Pipeleak-DevOps is a tool designed to scan Azure DevOps Pipelines job output logs and artifacts for potential secrets. + +This is a standalone binary for Azure DevOps-specific functionality.` + adCmd.Version = Version + adCmd.GroupID = "" + + adCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { + initLogger(cmd) + setGlobalLogLevel(cmd) + go logging.ShortcutListeners(nil) + } + + adCmd.PersistentFlags().BoolVarP(&JsonLogoutput, "json", "", false, "Use JSON as log output format") + adCmd.PersistentFlags().StringVarP(&LogFile, "logfile", "l", "", "Log output to a file") + adCmd.PersistentFlags().BoolVarP(&LogDebug, "verbose", "v", false, "Enable debug logging (shortcut for --log-level=debug)") + adCmd.PersistentFlags().StringVar(&LogLevel, "log-level", "", "Set log level globally (debug, info, warn, error). Example: --log-level=warn") + adCmd.PersistentFlags().BoolVar(&LogColor, "color", true, "Enable colored log output (auto-disabled when using --logfile)") + + adCmd.SetVersionTemplate(`{{.Version}} +`) + + return adCmd +} + +func saveTerminalState() { + if term.IsTerminal(int(os.Stdin.Fd())) { + state, err := term.GetState(int(os.Stdin.Fd())) + if err == nil { + originalTermState = state + } + } +} + +func restoreTerminalState() { + if originalTermState != nil { + _ = term.Restore(int(os.Stdin.Fd()), originalTermState) + } +} + +// FatalHook is a zerolog hook that restores terminal state before fatal exits +type FatalHook struct{} + +func (h FatalHook) Run(e *zerolog.Event, level zerolog.Level, msg string) { + if level == zerolog.FatalLevel { + if TerminalRestorer != nil { + TerminalRestorer() + } + } +} + +func initLogger(cmd *cobra.Command) { + defaultOut := os.Stdout + colorEnabled := LogColor + + if LogFile != "" { + // #nosec G304 - User-provided log file path via --log-file flag, user controls their own filesystem + runLogFile, err := os.OpenFile( + LogFile, + os.O_APPEND|os.O_CREATE|os.O_WRONLY, + 0600, + ) + if err != nil { + panic(err) + } + defaultOut = runLogFile + + rootFlags := cmd.Root().PersistentFlags() + if !rootFlags.Changed("color") { + colorEnabled = false + } + } + + fatalHook := FatalHook{} + + if JsonLogoutput { + hitWriter := &logging.HitLevelWriter{} + hitWriter.SetOutput(defaultOut) + logging.SetGlobalHitWriter(hitWriter) + log.Logger = zerolog.New(hitWriter).With().Timestamp().Logger().Hook(fatalHook) + } else { + output := zerolog.ConsoleWriter{ + Out: defaultOut, + TimeFormat: "2006-01-02T15:04:05Z07:00", + NoColor: !colorEnabled, + } + hitWriter := &logging.HitLevelWriter{} + hitWriter.SetOutput(&output) + logging.SetGlobalHitWriter(hitWriter) + log.Logger = zerolog.New(hitWriter).With().Timestamp().Logger().Hook(fatalHook) + } +} + +func setGlobalLogLevel(cmd *cobra.Command) { + if LogLevel != "" { + switch LogLevel { + case "trace": + zerolog.SetGlobalLevel(zerolog.TraceLevel) + log.Trace().Msg("Log level set to trace (explicit)") + case "debug": + zerolog.SetGlobalLevel(zerolog.DebugLevel) + log.Debug().Msg("Log level set to debug (explicit)") + case "info": + zerolog.SetGlobalLevel(zerolog.InfoLevel) + log.Info().Msg("Log level set to info (explicit)") + case "warn": + zerolog.SetGlobalLevel(zerolog.WarnLevel) + log.Warn().Msg("Log level set to warn (explicit)") + case "error": + zerolog.SetGlobalLevel(zerolog.ErrorLevel) + log.Error().Msg("Log level set to error (explicit)") + default: + zerolog.SetGlobalLevel(zerolog.InfoLevel) + log.Warn().Str("logLevelSpecified", LogLevel).Msg("Invalid log level, defaulting to info") + } + return + } + + if LogDebug { + zerolog.SetGlobalLevel(zerolog.DebugLevel) + log.Debug().Msg("Log level set to debug (-v)") + return + } + + zerolog.SetGlobalLevel(zerolog.InfoLevel) + log.Info().Msg("Log level set to info (default)") +} diff --git a/cmd/pipeleak-gitea/main.go b/cmd/pipeleak-gitea/main.go new file mode 100644 index 00000000..2fc6be87 --- /dev/null +++ b/cmd/pipeleak-gitea/main.go @@ -0,0 +1,193 @@ +package main + +import ( + "encoding/json" + "io" + "os" + + "github.com/CompassSecurity/pipeleak/internal/cmd/gitea" + "github.com/CompassSecurity/pipeleak/pkg/logging" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +// Version information - set via ldflags during build +var ( + Version = "dev" + Commit = "none" + Date = "unknown" +) + +var ( + originalTermState *term.State + JsonLogoutput bool + LogFile string + LogColor bool + LogDebug bool + LogLevel string +) + +// TerminalRestorer is a function that can be called to restore terminal state +var TerminalRestorer func() + +type TerminalRestoringWriter struct { + underlying io.Writer +} + +func (w *TerminalRestoringWriter) Write(p []byte) (n int, err error) { + var logEntry map[string]interface{} + if err := json.Unmarshal(p, &logEntry); err == nil { + if level, ok := logEntry["level"].(string); ok && level == "fatal" { + _, _ = w.underlying.Write(p) + restoreTerminalState() + os.Exit(1) + } + } + return w.underlying.Write(p) +} + +func main() { + saveTerminalState() + defer restoreTerminalState() + + TerminalRestorer = restoreTerminalState + + rootCmd := newRootCmd() + err := rootCmd.Execute() + if err != nil { + os.Exit(1) + } +} + +func newRootCmd() *cobra.Command { + giteaCmd := gitea.NewGiteaRootCmd() + giteaCmd.Use = "pipeleak-gitea" + giteaCmd.Short = "Scan Gitea Actions logs and artifacts for secrets" + giteaCmd.Long = `Pipeleak-Gitea is a tool designed to scan Gitea Actions job output logs and artifacts for potential secrets. + +This is a standalone binary for Gitea-specific functionality.` + giteaCmd.Version = Version + giteaCmd.GroupID = "" + + giteaCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { + initLogger(cmd) + setGlobalLogLevel(cmd) + go logging.ShortcutListeners(nil) + } + + giteaCmd.PersistentFlags().BoolVarP(&JsonLogoutput, "json", "", false, "Use JSON as log output format") + giteaCmd.PersistentFlags().StringVarP(&LogFile, "logfile", "l", "", "Log output to a file") + giteaCmd.PersistentFlags().BoolVarP(&LogDebug, "verbose", "v", false, "Enable debug logging (shortcut for --log-level=debug)") + giteaCmd.PersistentFlags().StringVar(&LogLevel, "log-level", "", "Set log level globally (debug, info, warn, error). Example: --log-level=warn") + giteaCmd.PersistentFlags().BoolVar(&LogColor, "color", true, "Enable colored log output (auto-disabled when using --logfile)") + + giteaCmd.SetVersionTemplate(`{{.Version}} +`) + + return giteaCmd +} + +func saveTerminalState() { + if term.IsTerminal(int(os.Stdin.Fd())) { + state, err := term.GetState(int(os.Stdin.Fd())) + if err == nil { + originalTermState = state + } + } +} + +func restoreTerminalState() { + if originalTermState != nil { + _ = term.Restore(int(os.Stdin.Fd()), originalTermState) + } +} + +// FatalHook is a zerolog hook that restores terminal state before fatal exits +type FatalHook struct{} + +func (h FatalHook) Run(e *zerolog.Event, level zerolog.Level, msg string) { + if level == zerolog.FatalLevel { + if TerminalRestorer != nil { + TerminalRestorer() + } + } +} + +func initLogger(cmd *cobra.Command) { + defaultOut := os.Stdout + colorEnabled := LogColor + + if LogFile != "" { + // #nosec G304 - User-provided log file path via --log-file flag, user controls their own filesystem + runLogFile, err := os.OpenFile( + LogFile, + os.O_APPEND|os.O_CREATE|os.O_WRONLY, + 0600, + ) + if err != nil { + panic(err) + } + defaultOut = runLogFile + + rootFlags := cmd.Root().PersistentFlags() + if !rootFlags.Changed("color") { + colorEnabled = false + } + } + + fatalHook := FatalHook{} + + if JsonLogoutput { + hitWriter := &logging.HitLevelWriter{} + hitWriter.SetOutput(defaultOut) + logging.SetGlobalHitWriter(hitWriter) + log.Logger = zerolog.New(hitWriter).With().Timestamp().Logger().Hook(fatalHook) + } else { + output := zerolog.ConsoleWriter{ + Out: defaultOut, + TimeFormat: "2006-01-02T15:04:05Z07:00", + NoColor: !colorEnabled, + } + hitWriter := &logging.HitLevelWriter{} + hitWriter.SetOutput(&output) + logging.SetGlobalHitWriter(hitWriter) + log.Logger = zerolog.New(hitWriter).With().Timestamp().Logger().Hook(fatalHook) + } +} + +func setGlobalLogLevel(cmd *cobra.Command) { + if LogLevel != "" { + switch LogLevel { + case "trace": + zerolog.SetGlobalLevel(zerolog.TraceLevel) + log.Trace().Msg("Log level set to trace (explicit)") + case "debug": + zerolog.SetGlobalLevel(zerolog.DebugLevel) + log.Debug().Msg("Log level set to debug (explicit)") + case "info": + zerolog.SetGlobalLevel(zerolog.InfoLevel) + log.Info().Msg("Log level set to info (explicit)") + case "warn": + zerolog.SetGlobalLevel(zerolog.WarnLevel) + log.Warn().Msg("Log level set to warn (explicit)") + case "error": + zerolog.SetGlobalLevel(zerolog.ErrorLevel) + log.Error().Msg("Log level set to error (explicit)") + default: + zerolog.SetGlobalLevel(zerolog.InfoLevel) + log.Warn().Str("logLevelSpecified", LogLevel).Msg("Invalid log level, defaulting to info") + } + return + } + + if LogDebug { + zerolog.SetGlobalLevel(zerolog.DebugLevel) + log.Debug().Msg("Log level set to debug (-v)") + return + } + + zerolog.SetGlobalLevel(zerolog.InfoLevel) + log.Info().Msg("Log level set to info (default)") +} diff --git a/cmd/pipeleak-github/main.go b/cmd/pipeleak-github/main.go new file mode 100644 index 00000000..990a088a --- /dev/null +++ b/cmd/pipeleak-github/main.go @@ -0,0 +1,193 @@ +package main + +import ( + "encoding/json" + "io" + "os" + + "github.com/CompassSecurity/pipeleak/internal/cmd/github" + "github.com/CompassSecurity/pipeleak/pkg/logging" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +// Version information - set via ldflags during build +var ( + Version = "dev" + Commit = "none" + Date = "unknown" +) + +var ( + originalTermState *term.State + JsonLogoutput bool + LogFile string + LogColor bool + LogDebug bool + LogLevel string +) + +// TerminalRestorer is a function that can be called to restore terminal state +var TerminalRestorer func() + +type TerminalRestoringWriter struct { + underlying io.Writer +} + +func (w *TerminalRestoringWriter) Write(p []byte) (n int, err error) { + var logEntry map[string]interface{} + if err := json.Unmarshal(p, &logEntry); err == nil { + if level, ok := logEntry["level"].(string); ok && level == "fatal" { + _, _ = w.underlying.Write(p) + restoreTerminalState() + os.Exit(1) + } + } + return w.underlying.Write(p) +} + +func main() { + saveTerminalState() + defer restoreTerminalState() + + TerminalRestorer = restoreTerminalState + + rootCmd := newRootCmd() + err := rootCmd.Execute() + if err != nil { + os.Exit(1) + } +} + +func newRootCmd() *cobra.Command { + ghCmd := github.NewGitHubRootCmd() + ghCmd.Use = "pipeleak-github" + ghCmd.Short = "Scan GitHub Actions logs and artifacts for secrets" + ghCmd.Long = `Pipeleak-GitHub is a tool designed to scan GitHub Actions job output logs and artifacts for potential secrets. + +This is a standalone binary for GitHub-specific functionality.` + ghCmd.Version = Version + ghCmd.GroupID = "" + + ghCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { + initLogger(cmd) + setGlobalLogLevel(cmd) + go logging.ShortcutListeners(nil) + } + + ghCmd.PersistentFlags().BoolVarP(&JsonLogoutput, "json", "", false, "Use JSON as log output format") + ghCmd.PersistentFlags().StringVarP(&LogFile, "logfile", "l", "", "Log output to a file") + ghCmd.PersistentFlags().BoolVarP(&LogDebug, "verbose", "v", false, "Enable debug logging (shortcut for --log-level=debug)") + ghCmd.PersistentFlags().StringVar(&LogLevel, "log-level", "", "Set log level globally (debug, info, warn, error). Example: --log-level=warn") + ghCmd.PersistentFlags().BoolVar(&LogColor, "color", true, "Enable colored log output (auto-disabled when using --logfile)") + + ghCmd.SetVersionTemplate(`{{.Version}} +`) + + return ghCmd +} + +func saveTerminalState() { + if term.IsTerminal(int(os.Stdin.Fd())) { + state, err := term.GetState(int(os.Stdin.Fd())) + if err == nil { + originalTermState = state + } + } +} + +func restoreTerminalState() { + if originalTermState != nil { + _ = term.Restore(int(os.Stdin.Fd()), originalTermState) + } +} + +// FatalHook is a zerolog hook that restores terminal state before fatal exits +type FatalHook struct{} + +func (h FatalHook) Run(e *zerolog.Event, level zerolog.Level, msg string) { + if level == zerolog.FatalLevel { + if TerminalRestorer != nil { + TerminalRestorer() + } + } +} + +func initLogger(cmd *cobra.Command) { + defaultOut := os.Stdout + colorEnabled := LogColor + + if LogFile != "" { + // #nosec G304 - User-provided log file path via --log-file flag, user controls their own filesystem + runLogFile, err := os.OpenFile( + LogFile, + os.O_APPEND|os.O_CREATE|os.O_WRONLY, + 0600, + ) + if err != nil { + panic(err) + } + defaultOut = runLogFile + + rootFlags := cmd.Root().PersistentFlags() + if !rootFlags.Changed("color") { + colorEnabled = false + } + } + + fatalHook := FatalHook{} + + if JsonLogoutput { + hitWriter := &logging.HitLevelWriter{} + hitWriter.SetOutput(defaultOut) + logging.SetGlobalHitWriter(hitWriter) + log.Logger = zerolog.New(hitWriter).With().Timestamp().Logger().Hook(fatalHook) + } else { + output := zerolog.ConsoleWriter{ + Out: defaultOut, + TimeFormat: "2006-01-02T15:04:05Z07:00", + NoColor: !colorEnabled, + } + hitWriter := &logging.HitLevelWriter{} + hitWriter.SetOutput(&output) + logging.SetGlobalHitWriter(hitWriter) + log.Logger = zerolog.New(hitWriter).With().Timestamp().Logger().Hook(fatalHook) + } +} + +func setGlobalLogLevel(cmd *cobra.Command) { + if LogLevel != "" { + switch LogLevel { + case "trace": + zerolog.SetGlobalLevel(zerolog.TraceLevel) + log.Trace().Msg("Log level set to trace (explicit)") + case "debug": + zerolog.SetGlobalLevel(zerolog.DebugLevel) + log.Debug().Msg("Log level set to debug (explicit)") + case "info": + zerolog.SetGlobalLevel(zerolog.InfoLevel) + log.Info().Msg("Log level set to info (explicit)") + case "warn": + zerolog.SetGlobalLevel(zerolog.WarnLevel) + log.Warn().Msg("Log level set to warn (explicit)") + case "error": + zerolog.SetGlobalLevel(zerolog.ErrorLevel) + log.Error().Msg("Log level set to error (explicit)") + default: + zerolog.SetGlobalLevel(zerolog.InfoLevel) + log.Warn().Str("logLevelSpecified", LogLevel).Msg("Invalid log level, defaulting to info") + } + return + } + + if LogDebug { + zerolog.SetGlobalLevel(zerolog.DebugLevel) + log.Debug().Msg("Log level set to debug (-v)") + return + } + + zerolog.SetGlobalLevel(zerolog.InfoLevel) + log.Info().Msg("Log level set to info (default)") +} diff --git a/cmd/pipeleak-gitlab/main.go b/cmd/pipeleak-gitlab/main.go new file mode 100644 index 00000000..7bb465b5 --- /dev/null +++ b/cmd/pipeleak-gitlab/main.go @@ -0,0 +1,193 @@ +package main + +import ( + "encoding/json" + "io" + "os" + + "github.com/CompassSecurity/pipeleak/internal/cmd/gitlab" + "github.com/CompassSecurity/pipeleak/pkg/logging" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +// Version information - set via ldflags during build +var ( + Version = "dev" + Commit = "none" + Date = "unknown" +) + +var ( + originalTermState *term.State + JsonLogoutput bool + LogFile string + LogColor bool + LogDebug bool + LogLevel string +) + +// TerminalRestorer is a function that can be called to restore terminal state +var TerminalRestorer func() + +type TerminalRestoringWriter struct { + underlying io.Writer +} + +func (w *TerminalRestoringWriter) Write(p []byte) (n int, err error) { + var logEntry map[string]interface{} + if err := json.Unmarshal(p, &logEntry); err == nil { + if level, ok := logEntry["level"].(string); ok && level == "fatal" { + _, _ = w.underlying.Write(p) + restoreTerminalState() + os.Exit(1) + } + } + return w.underlying.Write(p) +} + +func main() { + saveTerminalState() + defer restoreTerminalState() + + TerminalRestorer = restoreTerminalState + + rootCmd := newRootCmd() + err := rootCmd.Execute() + if err != nil { + os.Exit(1) + } +} + +func newRootCmd() *cobra.Command { + glCmd := gitlab.NewGitLabRootCmd() + glCmd.Use = "pipeleak-gitlab" + glCmd.Short = "Scan GitLab CI/CD logs and artifacts for secrets" + glCmd.Long = `Pipeleak-GitLab is a tool designed to scan GitLab CI/CD job output logs and artifacts for potential secrets. + +This is a standalone binary for GitLab-specific functionality.` + glCmd.Version = Version + glCmd.GroupID = "" + + glCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { + initLogger(cmd) + setGlobalLogLevel(cmd) + go logging.ShortcutListeners(nil) + } + + glCmd.PersistentFlags().BoolVarP(&JsonLogoutput, "json", "", false, "Use JSON as log output format") + glCmd.PersistentFlags().StringVarP(&LogFile, "logfile", "l", "", "Log output to a file") + glCmd.PersistentFlags().BoolVarP(&LogDebug, "verbose", "v", false, "Enable debug logging (shortcut for --log-level=debug)") + glCmd.PersistentFlags().StringVar(&LogLevel, "log-level", "", "Set log level globally (debug, info, warn, error). Example: --log-level=warn") + glCmd.PersistentFlags().BoolVar(&LogColor, "color", true, "Enable colored log output (auto-disabled when using --logfile)") + + glCmd.SetVersionTemplate(`{{.Version}} +`) + + return glCmd +} + +func saveTerminalState() { + if term.IsTerminal(int(os.Stdin.Fd())) { + state, err := term.GetState(int(os.Stdin.Fd())) + if err == nil { + originalTermState = state + } + } +} + +func restoreTerminalState() { + if originalTermState != nil { + _ = term.Restore(int(os.Stdin.Fd()), originalTermState) + } +} + +// FatalHook is a zerolog hook that restores terminal state before fatal exits +type FatalHook struct{} + +func (h FatalHook) Run(e *zerolog.Event, level zerolog.Level, msg string) { + if level == zerolog.FatalLevel { + if TerminalRestorer != nil { + TerminalRestorer() + } + } +} + +func initLogger(cmd *cobra.Command) { + defaultOut := os.Stdout + colorEnabled := LogColor + + if LogFile != "" { + // #nosec G304 - User-provided log file path via --log-file flag, user controls their own filesystem + runLogFile, err := os.OpenFile( + LogFile, + os.O_APPEND|os.O_CREATE|os.O_WRONLY, + 0600, + ) + if err != nil { + panic(err) + } + defaultOut = runLogFile + + rootFlags := cmd.Root().PersistentFlags() + if !rootFlags.Changed("color") { + colorEnabled = false + } + } + + fatalHook := FatalHook{} + + if JsonLogoutput { + hitWriter := &logging.HitLevelWriter{} + hitWriter.SetOutput(defaultOut) + logging.SetGlobalHitWriter(hitWriter) + log.Logger = zerolog.New(hitWriter).With().Timestamp().Logger().Hook(fatalHook) + } else { + output := zerolog.ConsoleWriter{ + Out: defaultOut, + TimeFormat: "2006-01-02T15:04:05Z07:00", + NoColor: !colorEnabled, + } + hitWriter := &logging.HitLevelWriter{} + hitWriter.SetOutput(&output) + logging.SetGlobalHitWriter(hitWriter) + log.Logger = zerolog.New(hitWriter).With().Timestamp().Logger().Hook(fatalHook) + } +} + +func setGlobalLogLevel(cmd *cobra.Command) { + if LogLevel != "" { + switch LogLevel { + case "trace": + zerolog.SetGlobalLevel(zerolog.TraceLevel) + log.Trace().Msg("Log level set to trace (explicit)") + case "debug": + zerolog.SetGlobalLevel(zerolog.DebugLevel) + log.Debug().Msg("Log level set to debug (explicit)") + case "info": + zerolog.SetGlobalLevel(zerolog.InfoLevel) + log.Info().Msg("Log level set to info (explicit)") + case "warn": + zerolog.SetGlobalLevel(zerolog.WarnLevel) + log.Warn().Msg("Log level set to warn (explicit)") + case "error": + zerolog.SetGlobalLevel(zerolog.ErrorLevel) + log.Error().Msg("Log level set to error (explicit)") + default: + zerolog.SetGlobalLevel(zerolog.InfoLevel) + log.Warn().Str("logLevelSpecified", LogLevel).Msg("Invalid log level, defaulting to info") + } + return + } + + if LogDebug { + zerolog.SetGlobalLevel(zerolog.DebugLevel) + log.Debug().Msg("Log level set to debug (-v)") + return + } + + zerolog.SetGlobalLevel(zerolog.InfoLevel) + log.Info().Msg("Log level set to info (default)") +} diff --git a/goreleaser.yaml b/goreleaser.yaml index 287fcb9e..85ae1611 100644 --- a/goreleaser.yaml +++ b/goreleaser.yaml @@ -1,8 +1,7 @@ version: 2 project_name: pipeleak builds: - - - id: "pipeleak" + - id: "pipeleak" main: ./cmd/pipeleak binary: pipeleak goos: @@ -15,4 +14,74 @@ builds: - -s -w - -X github.com/CompassSecurity/pipeleak/internal/cmd.Version={{.Version}} - -X github.com/CompassSecurity/pipeleak/internal/cmd.Commit={{.Commit}} - - -X github.com/CompassSecurity/pipeleak/internal/cmd.Date={{.Date}} \ No newline at end of file + - -X github.com/CompassSecurity/pipeleak/internal/cmd.Date={{.Date}} + - id: "pipeleak-gitlab" + main: ./cmd/pipeleak-gitlab + binary: pipeleak-gitlab + goos: + - linux + - windows + goarch: + - amd64 + - arm64 + ldflags: + - -s -w + - -X main.Version={{.Version}} + - -X main.Commit={{.Commit}} + - -X main.Date={{.Date}} + - id: "pipeleak-github" + main: ./cmd/pipeleak-github + binary: pipeleak-github + goos: + - linux + - windows + goarch: + - amd64 + - arm64 + ldflags: + - -s -w + - -X main.Version={{.Version}} + - -X main.Commit={{.Commit}} + - -X main.Date={{.Date}} + - id: "pipeleak-bitbucket" + main: ./cmd/pipeleak-bitbucket + binary: pipeleak-bitbucket + goos: + - linux + - windows + goarch: + - amd64 + - arm64 + ldflags: + - -s -w + - -X main.Version={{.Version}} + - -X main.Commit={{.Commit}} + - -X main.Date={{.Date}} + - id: "pipeleak-devops" + main: ./cmd/pipeleak-devops + binary: pipeleak-devops + goos: + - linux + - windows + goarch: + - amd64 + - arm64 + ldflags: + - -s -w + - -X main.Version={{.Version}} + - -X main.Commit={{.Commit}} + - -X main.Date={{.Date}} + - id: "pipeleak-gitea" + main: ./cmd/pipeleak-gitea + binary: pipeleak-gitea + goos: + - linux + - windows + goarch: + - amd64 + - arm64 + ldflags: + - -s -w + - -X main.Version={{.Version}} + - -X main.Commit={{.Commit}} + - -X main.Date={{.Date}} \ No newline at end of file From 0df2d5f3f10c78d6ba5dd623e309fe42a07769e7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Dec 2025 06:55:30 +0000 Subject: [PATCH 3/7] Extract shared code to internal/cmd/common package and update descriptions Co-authored-by: frjcomp <107982661+frjcomp@users.noreply.github.com> --- cmd/pipeleak-bitbucket/main.go | 177 ++---------------------------- cmd/pipeleak-devops/main.go | 177 ++---------------------------- cmd/pipeleak-gitea/main.go | 177 ++---------------------------- cmd/pipeleak-github/main.go | 177 ++---------------------------- cmd/pipeleak-gitlab/main.go | 177 ++---------------------------- goreleaser.yaml | 30 +++--- internal/cmd/common/common.go | 189 +++++++++++++++++++++++++++++++++ 7 files changed, 234 insertions(+), 870 deletions(-) create mode 100644 internal/cmd/common/common.go diff --git a/cmd/pipeleak-bitbucket/main.go b/cmd/pipeleak-bitbucket/main.go index 83457906..f44be584 100644 --- a/cmd/pipeleak-bitbucket/main.go +++ b/cmd/pipeleak-bitbucket/main.go @@ -1,193 +1,28 @@ package main import ( - "encoding/json" - "io" - "os" - "github.com/CompassSecurity/pipeleak/internal/cmd/bitbucket" - "github.com/CompassSecurity/pipeleak/pkg/logging" - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" + "github.com/CompassSecurity/pipeleak/internal/cmd/common" "github.com/spf13/cobra" - "golang.org/x/term" -) - -// Version information - set via ldflags during build -var ( - Version = "dev" - Commit = "none" - Date = "unknown" -) - -var ( - originalTermState *term.State - JsonLogoutput bool - LogFile string - LogColor bool - LogDebug bool - LogLevel string ) -// TerminalRestorer is a function that can be called to restore terminal state -var TerminalRestorer func() - -type TerminalRestoringWriter struct { - underlying io.Writer -} - -func (w *TerminalRestoringWriter) Write(p []byte) (n int, err error) { - var logEntry map[string]interface{} - if err := json.Unmarshal(p, &logEntry); err == nil { - if level, ok := logEntry["level"].(string); ok && level == "fatal" { - _, _ = w.underlying.Write(p) - restoreTerminalState() - os.Exit(1) - } - } - return w.underlying.Write(p) -} - func main() { - saveTerminalState() - defer restoreTerminalState() - - TerminalRestorer = restoreTerminalState - - rootCmd := newRootCmd() - err := rootCmd.Execute() - if err != nil { - os.Exit(1) - } + common.Run(newRootCmd()) } func newRootCmd() *cobra.Command { bbCmd := bitbucket.NewBitBucketRootCmd() bbCmd.Use = "pipeleak-bitbucket" bbCmd.Short = "Scan BitBucket Pipelines logs and artifacts for secrets" - bbCmd.Long = `Pipeleak-BitBucket is a tool designed to scan BitBucket Pipelines job output logs and artifacts for potential secrets. - -This is a standalone binary for BitBucket-specific functionality.` - bbCmd.Version = Version + bbCmd.Long = `Pipeleak-BitBucket scans CI/CD logs and artifacts to detect leaked secrets and pivot from them.` + bbCmd.Version = common.Version bbCmd.GroupID = "" - bbCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { - initLogger(cmd) - setGlobalLogLevel(cmd) - go logging.ShortcutListeners(nil) - } - - bbCmd.PersistentFlags().BoolVarP(&JsonLogoutput, "json", "", false, "Use JSON as log output format") - bbCmd.PersistentFlags().StringVarP(&LogFile, "logfile", "l", "", "Log output to a file") - bbCmd.PersistentFlags().BoolVarP(&LogDebug, "verbose", "v", false, "Enable debug logging (shortcut for --log-level=debug)") - bbCmd.PersistentFlags().StringVar(&LogLevel, "log-level", "", "Set log level globally (debug, info, warn, error). Example: --log-level=warn") - bbCmd.PersistentFlags().BoolVar(&LogColor, "color", true, "Enable colored log output (auto-disabled when using --logfile)") + common.SetupPersistentPreRun(bbCmd) + common.AddCommonFlags(bbCmd) bbCmd.SetVersionTemplate(`{{.Version}} `) return bbCmd } - -func saveTerminalState() { - if term.IsTerminal(int(os.Stdin.Fd())) { - state, err := term.GetState(int(os.Stdin.Fd())) - if err == nil { - originalTermState = state - } - } -} - -func restoreTerminalState() { - if originalTermState != nil { - _ = term.Restore(int(os.Stdin.Fd()), originalTermState) - } -} - -// FatalHook is a zerolog hook that restores terminal state before fatal exits -type FatalHook struct{} - -func (h FatalHook) Run(e *zerolog.Event, level zerolog.Level, msg string) { - if level == zerolog.FatalLevel { - if TerminalRestorer != nil { - TerminalRestorer() - } - } -} - -func initLogger(cmd *cobra.Command) { - defaultOut := os.Stdout - colorEnabled := LogColor - - if LogFile != "" { - // #nosec G304 - User-provided log file path via --log-file flag, user controls their own filesystem - runLogFile, err := os.OpenFile( - LogFile, - os.O_APPEND|os.O_CREATE|os.O_WRONLY, - 0600, - ) - if err != nil { - panic(err) - } - defaultOut = runLogFile - - rootFlags := cmd.Root().PersistentFlags() - if !rootFlags.Changed("color") { - colorEnabled = false - } - } - - fatalHook := FatalHook{} - - if JsonLogoutput { - hitWriter := &logging.HitLevelWriter{} - hitWriter.SetOutput(defaultOut) - logging.SetGlobalHitWriter(hitWriter) - log.Logger = zerolog.New(hitWriter).With().Timestamp().Logger().Hook(fatalHook) - } else { - output := zerolog.ConsoleWriter{ - Out: defaultOut, - TimeFormat: "2006-01-02T15:04:05Z07:00", - NoColor: !colorEnabled, - } - hitWriter := &logging.HitLevelWriter{} - hitWriter.SetOutput(&output) - logging.SetGlobalHitWriter(hitWriter) - log.Logger = zerolog.New(hitWriter).With().Timestamp().Logger().Hook(fatalHook) - } -} - -func setGlobalLogLevel(cmd *cobra.Command) { - if LogLevel != "" { - switch LogLevel { - case "trace": - zerolog.SetGlobalLevel(zerolog.TraceLevel) - log.Trace().Msg("Log level set to trace (explicit)") - case "debug": - zerolog.SetGlobalLevel(zerolog.DebugLevel) - log.Debug().Msg("Log level set to debug (explicit)") - case "info": - zerolog.SetGlobalLevel(zerolog.InfoLevel) - log.Info().Msg("Log level set to info (explicit)") - case "warn": - zerolog.SetGlobalLevel(zerolog.WarnLevel) - log.Warn().Msg("Log level set to warn (explicit)") - case "error": - zerolog.SetGlobalLevel(zerolog.ErrorLevel) - log.Error().Msg("Log level set to error (explicit)") - default: - zerolog.SetGlobalLevel(zerolog.InfoLevel) - log.Warn().Str("logLevelSpecified", LogLevel).Msg("Invalid log level, defaulting to info") - } - return - } - - if LogDebug { - zerolog.SetGlobalLevel(zerolog.DebugLevel) - log.Debug().Msg("Log level set to debug (-v)") - return - } - - zerolog.SetGlobalLevel(zerolog.InfoLevel) - log.Info().Msg("Log level set to info (default)") -} diff --git a/cmd/pipeleak-devops/main.go b/cmd/pipeleak-devops/main.go index 63f7e014..91d5c859 100644 --- a/cmd/pipeleak-devops/main.go +++ b/cmd/pipeleak-devops/main.go @@ -1,193 +1,28 @@ package main import ( - "encoding/json" - "io" - "os" - + "github.com/CompassSecurity/pipeleak/internal/cmd/common" "github.com/CompassSecurity/pipeleak/internal/cmd/devops" - "github.com/CompassSecurity/pipeleak/pkg/logging" - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" "github.com/spf13/cobra" - "golang.org/x/term" -) - -// Version information - set via ldflags during build -var ( - Version = "dev" - Commit = "none" - Date = "unknown" -) - -var ( - originalTermState *term.State - JsonLogoutput bool - LogFile string - LogColor bool - LogDebug bool - LogLevel string ) -// TerminalRestorer is a function that can be called to restore terminal state -var TerminalRestorer func() - -type TerminalRestoringWriter struct { - underlying io.Writer -} - -func (w *TerminalRestoringWriter) Write(p []byte) (n int, err error) { - var logEntry map[string]interface{} - if err := json.Unmarshal(p, &logEntry); err == nil { - if level, ok := logEntry["level"].(string); ok && level == "fatal" { - _, _ = w.underlying.Write(p) - restoreTerminalState() - os.Exit(1) - } - } - return w.underlying.Write(p) -} - func main() { - saveTerminalState() - defer restoreTerminalState() - - TerminalRestorer = restoreTerminalState - - rootCmd := newRootCmd() - err := rootCmd.Execute() - if err != nil { - os.Exit(1) - } + common.Run(newRootCmd()) } func newRootCmd() *cobra.Command { adCmd := devops.NewAzureDevOpsRootCmd() adCmd.Use = "pipeleak-devops" adCmd.Short = "Scan Azure DevOps Pipelines logs and artifacts for secrets" - adCmd.Long = `Pipeleak-DevOps is a tool designed to scan Azure DevOps Pipelines job output logs and artifacts for potential secrets. - -This is a standalone binary for Azure DevOps-specific functionality.` - adCmd.Version = Version + adCmd.Long = `Pipeleak-DevOps scans CI/CD logs and artifacts to detect leaked secrets and pivot from them.` + adCmd.Version = common.Version adCmd.GroupID = "" - adCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { - initLogger(cmd) - setGlobalLogLevel(cmd) - go logging.ShortcutListeners(nil) - } - - adCmd.PersistentFlags().BoolVarP(&JsonLogoutput, "json", "", false, "Use JSON as log output format") - adCmd.PersistentFlags().StringVarP(&LogFile, "logfile", "l", "", "Log output to a file") - adCmd.PersistentFlags().BoolVarP(&LogDebug, "verbose", "v", false, "Enable debug logging (shortcut for --log-level=debug)") - adCmd.PersistentFlags().StringVar(&LogLevel, "log-level", "", "Set log level globally (debug, info, warn, error). Example: --log-level=warn") - adCmd.PersistentFlags().BoolVar(&LogColor, "color", true, "Enable colored log output (auto-disabled when using --logfile)") + common.SetupPersistentPreRun(adCmd) + common.AddCommonFlags(adCmd) adCmd.SetVersionTemplate(`{{.Version}} `) return adCmd } - -func saveTerminalState() { - if term.IsTerminal(int(os.Stdin.Fd())) { - state, err := term.GetState(int(os.Stdin.Fd())) - if err == nil { - originalTermState = state - } - } -} - -func restoreTerminalState() { - if originalTermState != nil { - _ = term.Restore(int(os.Stdin.Fd()), originalTermState) - } -} - -// FatalHook is a zerolog hook that restores terminal state before fatal exits -type FatalHook struct{} - -func (h FatalHook) Run(e *zerolog.Event, level zerolog.Level, msg string) { - if level == zerolog.FatalLevel { - if TerminalRestorer != nil { - TerminalRestorer() - } - } -} - -func initLogger(cmd *cobra.Command) { - defaultOut := os.Stdout - colorEnabled := LogColor - - if LogFile != "" { - // #nosec G304 - User-provided log file path via --log-file flag, user controls their own filesystem - runLogFile, err := os.OpenFile( - LogFile, - os.O_APPEND|os.O_CREATE|os.O_WRONLY, - 0600, - ) - if err != nil { - panic(err) - } - defaultOut = runLogFile - - rootFlags := cmd.Root().PersistentFlags() - if !rootFlags.Changed("color") { - colorEnabled = false - } - } - - fatalHook := FatalHook{} - - if JsonLogoutput { - hitWriter := &logging.HitLevelWriter{} - hitWriter.SetOutput(defaultOut) - logging.SetGlobalHitWriter(hitWriter) - log.Logger = zerolog.New(hitWriter).With().Timestamp().Logger().Hook(fatalHook) - } else { - output := zerolog.ConsoleWriter{ - Out: defaultOut, - TimeFormat: "2006-01-02T15:04:05Z07:00", - NoColor: !colorEnabled, - } - hitWriter := &logging.HitLevelWriter{} - hitWriter.SetOutput(&output) - logging.SetGlobalHitWriter(hitWriter) - log.Logger = zerolog.New(hitWriter).With().Timestamp().Logger().Hook(fatalHook) - } -} - -func setGlobalLogLevel(cmd *cobra.Command) { - if LogLevel != "" { - switch LogLevel { - case "trace": - zerolog.SetGlobalLevel(zerolog.TraceLevel) - log.Trace().Msg("Log level set to trace (explicit)") - case "debug": - zerolog.SetGlobalLevel(zerolog.DebugLevel) - log.Debug().Msg("Log level set to debug (explicit)") - case "info": - zerolog.SetGlobalLevel(zerolog.InfoLevel) - log.Info().Msg("Log level set to info (explicit)") - case "warn": - zerolog.SetGlobalLevel(zerolog.WarnLevel) - log.Warn().Msg("Log level set to warn (explicit)") - case "error": - zerolog.SetGlobalLevel(zerolog.ErrorLevel) - log.Error().Msg("Log level set to error (explicit)") - default: - zerolog.SetGlobalLevel(zerolog.InfoLevel) - log.Warn().Str("logLevelSpecified", LogLevel).Msg("Invalid log level, defaulting to info") - } - return - } - - if LogDebug { - zerolog.SetGlobalLevel(zerolog.DebugLevel) - log.Debug().Msg("Log level set to debug (-v)") - return - } - - zerolog.SetGlobalLevel(zerolog.InfoLevel) - log.Info().Msg("Log level set to info (default)") -} diff --git a/cmd/pipeleak-gitea/main.go b/cmd/pipeleak-gitea/main.go index 2fc6be87..fa4822a0 100644 --- a/cmd/pipeleak-gitea/main.go +++ b/cmd/pipeleak-gitea/main.go @@ -1,193 +1,28 @@ package main import ( - "encoding/json" - "io" - "os" - + "github.com/CompassSecurity/pipeleak/internal/cmd/common" "github.com/CompassSecurity/pipeleak/internal/cmd/gitea" - "github.com/CompassSecurity/pipeleak/pkg/logging" - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" "github.com/spf13/cobra" - "golang.org/x/term" -) - -// Version information - set via ldflags during build -var ( - Version = "dev" - Commit = "none" - Date = "unknown" -) - -var ( - originalTermState *term.State - JsonLogoutput bool - LogFile string - LogColor bool - LogDebug bool - LogLevel string ) -// TerminalRestorer is a function that can be called to restore terminal state -var TerminalRestorer func() - -type TerminalRestoringWriter struct { - underlying io.Writer -} - -func (w *TerminalRestoringWriter) Write(p []byte) (n int, err error) { - var logEntry map[string]interface{} - if err := json.Unmarshal(p, &logEntry); err == nil { - if level, ok := logEntry["level"].(string); ok && level == "fatal" { - _, _ = w.underlying.Write(p) - restoreTerminalState() - os.Exit(1) - } - } - return w.underlying.Write(p) -} - func main() { - saveTerminalState() - defer restoreTerminalState() - - TerminalRestorer = restoreTerminalState - - rootCmd := newRootCmd() - err := rootCmd.Execute() - if err != nil { - os.Exit(1) - } + common.Run(newRootCmd()) } func newRootCmd() *cobra.Command { giteaCmd := gitea.NewGiteaRootCmd() giteaCmd.Use = "pipeleak-gitea" giteaCmd.Short = "Scan Gitea Actions logs and artifacts for secrets" - giteaCmd.Long = `Pipeleak-Gitea is a tool designed to scan Gitea Actions job output logs and artifacts for potential secrets. - -This is a standalone binary for Gitea-specific functionality.` - giteaCmd.Version = Version + giteaCmd.Long = `Pipeleak-Gitea scans CI/CD logs and artifacts to detect leaked secrets and pivot from them.` + giteaCmd.Version = common.Version giteaCmd.GroupID = "" - giteaCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { - initLogger(cmd) - setGlobalLogLevel(cmd) - go logging.ShortcutListeners(nil) - } - - giteaCmd.PersistentFlags().BoolVarP(&JsonLogoutput, "json", "", false, "Use JSON as log output format") - giteaCmd.PersistentFlags().StringVarP(&LogFile, "logfile", "l", "", "Log output to a file") - giteaCmd.PersistentFlags().BoolVarP(&LogDebug, "verbose", "v", false, "Enable debug logging (shortcut for --log-level=debug)") - giteaCmd.PersistentFlags().StringVar(&LogLevel, "log-level", "", "Set log level globally (debug, info, warn, error). Example: --log-level=warn") - giteaCmd.PersistentFlags().BoolVar(&LogColor, "color", true, "Enable colored log output (auto-disabled when using --logfile)") + common.SetupPersistentPreRun(giteaCmd) + common.AddCommonFlags(giteaCmd) giteaCmd.SetVersionTemplate(`{{.Version}} `) return giteaCmd } - -func saveTerminalState() { - if term.IsTerminal(int(os.Stdin.Fd())) { - state, err := term.GetState(int(os.Stdin.Fd())) - if err == nil { - originalTermState = state - } - } -} - -func restoreTerminalState() { - if originalTermState != nil { - _ = term.Restore(int(os.Stdin.Fd()), originalTermState) - } -} - -// FatalHook is a zerolog hook that restores terminal state before fatal exits -type FatalHook struct{} - -func (h FatalHook) Run(e *zerolog.Event, level zerolog.Level, msg string) { - if level == zerolog.FatalLevel { - if TerminalRestorer != nil { - TerminalRestorer() - } - } -} - -func initLogger(cmd *cobra.Command) { - defaultOut := os.Stdout - colorEnabled := LogColor - - if LogFile != "" { - // #nosec G304 - User-provided log file path via --log-file flag, user controls their own filesystem - runLogFile, err := os.OpenFile( - LogFile, - os.O_APPEND|os.O_CREATE|os.O_WRONLY, - 0600, - ) - if err != nil { - panic(err) - } - defaultOut = runLogFile - - rootFlags := cmd.Root().PersistentFlags() - if !rootFlags.Changed("color") { - colorEnabled = false - } - } - - fatalHook := FatalHook{} - - if JsonLogoutput { - hitWriter := &logging.HitLevelWriter{} - hitWriter.SetOutput(defaultOut) - logging.SetGlobalHitWriter(hitWriter) - log.Logger = zerolog.New(hitWriter).With().Timestamp().Logger().Hook(fatalHook) - } else { - output := zerolog.ConsoleWriter{ - Out: defaultOut, - TimeFormat: "2006-01-02T15:04:05Z07:00", - NoColor: !colorEnabled, - } - hitWriter := &logging.HitLevelWriter{} - hitWriter.SetOutput(&output) - logging.SetGlobalHitWriter(hitWriter) - log.Logger = zerolog.New(hitWriter).With().Timestamp().Logger().Hook(fatalHook) - } -} - -func setGlobalLogLevel(cmd *cobra.Command) { - if LogLevel != "" { - switch LogLevel { - case "trace": - zerolog.SetGlobalLevel(zerolog.TraceLevel) - log.Trace().Msg("Log level set to trace (explicit)") - case "debug": - zerolog.SetGlobalLevel(zerolog.DebugLevel) - log.Debug().Msg("Log level set to debug (explicit)") - case "info": - zerolog.SetGlobalLevel(zerolog.InfoLevel) - log.Info().Msg("Log level set to info (explicit)") - case "warn": - zerolog.SetGlobalLevel(zerolog.WarnLevel) - log.Warn().Msg("Log level set to warn (explicit)") - case "error": - zerolog.SetGlobalLevel(zerolog.ErrorLevel) - log.Error().Msg("Log level set to error (explicit)") - default: - zerolog.SetGlobalLevel(zerolog.InfoLevel) - log.Warn().Str("logLevelSpecified", LogLevel).Msg("Invalid log level, defaulting to info") - } - return - } - - if LogDebug { - zerolog.SetGlobalLevel(zerolog.DebugLevel) - log.Debug().Msg("Log level set to debug (-v)") - return - } - - zerolog.SetGlobalLevel(zerolog.InfoLevel) - log.Info().Msg("Log level set to info (default)") -} diff --git a/cmd/pipeleak-github/main.go b/cmd/pipeleak-github/main.go index 990a088a..d1443cd9 100644 --- a/cmd/pipeleak-github/main.go +++ b/cmd/pipeleak-github/main.go @@ -1,193 +1,28 @@ package main import ( - "encoding/json" - "io" - "os" - + "github.com/CompassSecurity/pipeleak/internal/cmd/common" "github.com/CompassSecurity/pipeleak/internal/cmd/github" - "github.com/CompassSecurity/pipeleak/pkg/logging" - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" "github.com/spf13/cobra" - "golang.org/x/term" -) - -// Version information - set via ldflags during build -var ( - Version = "dev" - Commit = "none" - Date = "unknown" -) - -var ( - originalTermState *term.State - JsonLogoutput bool - LogFile string - LogColor bool - LogDebug bool - LogLevel string ) -// TerminalRestorer is a function that can be called to restore terminal state -var TerminalRestorer func() - -type TerminalRestoringWriter struct { - underlying io.Writer -} - -func (w *TerminalRestoringWriter) Write(p []byte) (n int, err error) { - var logEntry map[string]interface{} - if err := json.Unmarshal(p, &logEntry); err == nil { - if level, ok := logEntry["level"].(string); ok && level == "fatal" { - _, _ = w.underlying.Write(p) - restoreTerminalState() - os.Exit(1) - } - } - return w.underlying.Write(p) -} - func main() { - saveTerminalState() - defer restoreTerminalState() - - TerminalRestorer = restoreTerminalState - - rootCmd := newRootCmd() - err := rootCmd.Execute() - if err != nil { - os.Exit(1) - } + common.Run(newRootCmd()) } func newRootCmd() *cobra.Command { ghCmd := github.NewGitHubRootCmd() ghCmd.Use = "pipeleak-github" ghCmd.Short = "Scan GitHub Actions logs and artifacts for secrets" - ghCmd.Long = `Pipeleak-GitHub is a tool designed to scan GitHub Actions job output logs and artifacts for potential secrets. - -This is a standalone binary for GitHub-specific functionality.` - ghCmd.Version = Version + ghCmd.Long = `Pipeleak-GitHub scans CI/CD logs and artifacts to detect leaked secrets and pivot from them.` + ghCmd.Version = common.Version ghCmd.GroupID = "" - ghCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { - initLogger(cmd) - setGlobalLogLevel(cmd) - go logging.ShortcutListeners(nil) - } - - ghCmd.PersistentFlags().BoolVarP(&JsonLogoutput, "json", "", false, "Use JSON as log output format") - ghCmd.PersistentFlags().StringVarP(&LogFile, "logfile", "l", "", "Log output to a file") - ghCmd.PersistentFlags().BoolVarP(&LogDebug, "verbose", "v", false, "Enable debug logging (shortcut for --log-level=debug)") - ghCmd.PersistentFlags().StringVar(&LogLevel, "log-level", "", "Set log level globally (debug, info, warn, error). Example: --log-level=warn") - ghCmd.PersistentFlags().BoolVar(&LogColor, "color", true, "Enable colored log output (auto-disabled when using --logfile)") + common.SetupPersistentPreRun(ghCmd) + common.AddCommonFlags(ghCmd) ghCmd.SetVersionTemplate(`{{.Version}} `) return ghCmd } - -func saveTerminalState() { - if term.IsTerminal(int(os.Stdin.Fd())) { - state, err := term.GetState(int(os.Stdin.Fd())) - if err == nil { - originalTermState = state - } - } -} - -func restoreTerminalState() { - if originalTermState != nil { - _ = term.Restore(int(os.Stdin.Fd()), originalTermState) - } -} - -// FatalHook is a zerolog hook that restores terminal state before fatal exits -type FatalHook struct{} - -func (h FatalHook) Run(e *zerolog.Event, level zerolog.Level, msg string) { - if level == zerolog.FatalLevel { - if TerminalRestorer != nil { - TerminalRestorer() - } - } -} - -func initLogger(cmd *cobra.Command) { - defaultOut := os.Stdout - colorEnabled := LogColor - - if LogFile != "" { - // #nosec G304 - User-provided log file path via --log-file flag, user controls their own filesystem - runLogFile, err := os.OpenFile( - LogFile, - os.O_APPEND|os.O_CREATE|os.O_WRONLY, - 0600, - ) - if err != nil { - panic(err) - } - defaultOut = runLogFile - - rootFlags := cmd.Root().PersistentFlags() - if !rootFlags.Changed("color") { - colorEnabled = false - } - } - - fatalHook := FatalHook{} - - if JsonLogoutput { - hitWriter := &logging.HitLevelWriter{} - hitWriter.SetOutput(defaultOut) - logging.SetGlobalHitWriter(hitWriter) - log.Logger = zerolog.New(hitWriter).With().Timestamp().Logger().Hook(fatalHook) - } else { - output := zerolog.ConsoleWriter{ - Out: defaultOut, - TimeFormat: "2006-01-02T15:04:05Z07:00", - NoColor: !colorEnabled, - } - hitWriter := &logging.HitLevelWriter{} - hitWriter.SetOutput(&output) - logging.SetGlobalHitWriter(hitWriter) - log.Logger = zerolog.New(hitWriter).With().Timestamp().Logger().Hook(fatalHook) - } -} - -func setGlobalLogLevel(cmd *cobra.Command) { - if LogLevel != "" { - switch LogLevel { - case "trace": - zerolog.SetGlobalLevel(zerolog.TraceLevel) - log.Trace().Msg("Log level set to trace (explicit)") - case "debug": - zerolog.SetGlobalLevel(zerolog.DebugLevel) - log.Debug().Msg("Log level set to debug (explicit)") - case "info": - zerolog.SetGlobalLevel(zerolog.InfoLevel) - log.Info().Msg("Log level set to info (explicit)") - case "warn": - zerolog.SetGlobalLevel(zerolog.WarnLevel) - log.Warn().Msg("Log level set to warn (explicit)") - case "error": - zerolog.SetGlobalLevel(zerolog.ErrorLevel) - log.Error().Msg("Log level set to error (explicit)") - default: - zerolog.SetGlobalLevel(zerolog.InfoLevel) - log.Warn().Str("logLevelSpecified", LogLevel).Msg("Invalid log level, defaulting to info") - } - return - } - - if LogDebug { - zerolog.SetGlobalLevel(zerolog.DebugLevel) - log.Debug().Msg("Log level set to debug (-v)") - return - } - - zerolog.SetGlobalLevel(zerolog.InfoLevel) - log.Info().Msg("Log level set to info (default)") -} diff --git a/cmd/pipeleak-gitlab/main.go b/cmd/pipeleak-gitlab/main.go index 7bb465b5..edf289c6 100644 --- a/cmd/pipeleak-gitlab/main.go +++ b/cmd/pipeleak-gitlab/main.go @@ -1,193 +1,28 @@ package main import ( - "encoding/json" - "io" - "os" - + "github.com/CompassSecurity/pipeleak/internal/cmd/common" "github.com/CompassSecurity/pipeleak/internal/cmd/gitlab" - "github.com/CompassSecurity/pipeleak/pkg/logging" - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" "github.com/spf13/cobra" - "golang.org/x/term" -) - -// Version information - set via ldflags during build -var ( - Version = "dev" - Commit = "none" - Date = "unknown" -) - -var ( - originalTermState *term.State - JsonLogoutput bool - LogFile string - LogColor bool - LogDebug bool - LogLevel string ) -// TerminalRestorer is a function that can be called to restore terminal state -var TerminalRestorer func() - -type TerminalRestoringWriter struct { - underlying io.Writer -} - -func (w *TerminalRestoringWriter) Write(p []byte) (n int, err error) { - var logEntry map[string]interface{} - if err := json.Unmarshal(p, &logEntry); err == nil { - if level, ok := logEntry["level"].(string); ok && level == "fatal" { - _, _ = w.underlying.Write(p) - restoreTerminalState() - os.Exit(1) - } - } - return w.underlying.Write(p) -} - func main() { - saveTerminalState() - defer restoreTerminalState() - - TerminalRestorer = restoreTerminalState - - rootCmd := newRootCmd() - err := rootCmd.Execute() - if err != nil { - os.Exit(1) - } + common.Run(newRootCmd()) } func newRootCmd() *cobra.Command { glCmd := gitlab.NewGitLabRootCmd() glCmd.Use = "pipeleak-gitlab" glCmd.Short = "Scan GitLab CI/CD logs and artifacts for secrets" - glCmd.Long = `Pipeleak-GitLab is a tool designed to scan GitLab CI/CD job output logs and artifacts for potential secrets. - -This is a standalone binary for GitLab-specific functionality.` - glCmd.Version = Version + glCmd.Long = `Pipeleak-GitLab scans CI/CD logs and artifacts to detect leaked secrets and pivot from them.` + glCmd.Version = common.Version glCmd.GroupID = "" - glCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { - initLogger(cmd) - setGlobalLogLevel(cmd) - go logging.ShortcutListeners(nil) - } - - glCmd.PersistentFlags().BoolVarP(&JsonLogoutput, "json", "", false, "Use JSON as log output format") - glCmd.PersistentFlags().StringVarP(&LogFile, "logfile", "l", "", "Log output to a file") - glCmd.PersistentFlags().BoolVarP(&LogDebug, "verbose", "v", false, "Enable debug logging (shortcut for --log-level=debug)") - glCmd.PersistentFlags().StringVar(&LogLevel, "log-level", "", "Set log level globally (debug, info, warn, error). Example: --log-level=warn") - glCmd.PersistentFlags().BoolVar(&LogColor, "color", true, "Enable colored log output (auto-disabled when using --logfile)") + common.SetupPersistentPreRun(glCmd) + common.AddCommonFlags(glCmd) glCmd.SetVersionTemplate(`{{.Version}} `) return glCmd } - -func saveTerminalState() { - if term.IsTerminal(int(os.Stdin.Fd())) { - state, err := term.GetState(int(os.Stdin.Fd())) - if err == nil { - originalTermState = state - } - } -} - -func restoreTerminalState() { - if originalTermState != nil { - _ = term.Restore(int(os.Stdin.Fd()), originalTermState) - } -} - -// FatalHook is a zerolog hook that restores terminal state before fatal exits -type FatalHook struct{} - -func (h FatalHook) Run(e *zerolog.Event, level zerolog.Level, msg string) { - if level == zerolog.FatalLevel { - if TerminalRestorer != nil { - TerminalRestorer() - } - } -} - -func initLogger(cmd *cobra.Command) { - defaultOut := os.Stdout - colorEnabled := LogColor - - if LogFile != "" { - // #nosec G304 - User-provided log file path via --log-file flag, user controls their own filesystem - runLogFile, err := os.OpenFile( - LogFile, - os.O_APPEND|os.O_CREATE|os.O_WRONLY, - 0600, - ) - if err != nil { - panic(err) - } - defaultOut = runLogFile - - rootFlags := cmd.Root().PersistentFlags() - if !rootFlags.Changed("color") { - colorEnabled = false - } - } - - fatalHook := FatalHook{} - - if JsonLogoutput { - hitWriter := &logging.HitLevelWriter{} - hitWriter.SetOutput(defaultOut) - logging.SetGlobalHitWriter(hitWriter) - log.Logger = zerolog.New(hitWriter).With().Timestamp().Logger().Hook(fatalHook) - } else { - output := zerolog.ConsoleWriter{ - Out: defaultOut, - TimeFormat: "2006-01-02T15:04:05Z07:00", - NoColor: !colorEnabled, - } - hitWriter := &logging.HitLevelWriter{} - hitWriter.SetOutput(&output) - logging.SetGlobalHitWriter(hitWriter) - log.Logger = zerolog.New(hitWriter).With().Timestamp().Logger().Hook(fatalHook) - } -} - -func setGlobalLogLevel(cmd *cobra.Command) { - if LogLevel != "" { - switch LogLevel { - case "trace": - zerolog.SetGlobalLevel(zerolog.TraceLevel) - log.Trace().Msg("Log level set to trace (explicit)") - case "debug": - zerolog.SetGlobalLevel(zerolog.DebugLevel) - log.Debug().Msg("Log level set to debug (explicit)") - case "info": - zerolog.SetGlobalLevel(zerolog.InfoLevel) - log.Info().Msg("Log level set to info (explicit)") - case "warn": - zerolog.SetGlobalLevel(zerolog.WarnLevel) - log.Warn().Msg("Log level set to warn (explicit)") - case "error": - zerolog.SetGlobalLevel(zerolog.ErrorLevel) - log.Error().Msg("Log level set to error (explicit)") - default: - zerolog.SetGlobalLevel(zerolog.InfoLevel) - log.Warn().Str("logLevelSpecified", LogLevel).Msg("Invalid log level, defaulting to info") - } - return - } - - if LogDebug { - zerolog.SetGlobalLevel(zerolog.DebugLevel) - log.Debug().Msg("Log level set to debug (-v)") - return - } - - zerolog.SetGlobalLevel(zerolog.InfoLevel) - log.Info().Msg("Log level set to info (default)") -} diff --git a/goreleaser.yaml b/goreleaser.yaml index 85ae1611..6ee94bd9 100644 --- a/goreleaser.yaml +++ b/goreleaser.yaml @@ -26,9 +26,9 @@ builds: - arm64 ldflags: - -s -w - - -X main.Version={{.Version}} - - -X main.Commit={{.Commit}} - - -X main.Date={{.Date}} + - -X github.com/CompassSecurity/pipeleak/internal/cmd/common.Version={{.Version}} + - -X github.com/CompassSecurity/pipeleak/internal/cmd/common.Commit={{.Commit}} + - -X github.com/CompassSecurity/pipeleak/internal/cmd/common.Date={{.Date}} - id: "pipeleak-github" main: ./cmd/pipeleak-github binary: pipeleak-github @@ -40,9 +40,9 @@ builds: - arm64 ldflags: - -s -w - - -X main.Version={{.Version}} - - -X main.Commit={{.Commit}} - - -X main.Date={{.Date}} + - -X github.com/CompassSecurity/pipeleak/internal/cmd/common.Version={{.Version}} + - -X github.com/CompassSecurity/pipeleak/internal/cmd/common.Commit={{.Commit}} + - -X github.com/CompassSecurity/pipeleak/internal/cmd/common.Date={{.Date}} - id: "pipeleak-bitbucket" main: ./cmd/pipeleak-bitbucket binary: pipeleak-bitbucket @@ -54,9 +54,9 @@ builds: - arm64 ldflags: - -s -w - - -X main.Version={{.Version}} - - -X main.Commit={{.Commit}} - - -X main.Date={{.Date}} + - -X github.com/CompassSecurity/pipeleak/internal/cmd/common.Version={{.Version}} + - -X github.com/CompassSecurity/pipeleak/internal/cmd/common.Commit={{.Commit}} + - -X github.com/CompassSecurity/pipeleak/internal/cmd/common.Date={{.Date}} - id: "pipeleak-devops" main: ./cmd/pipeleak-devops binary: pipeleak-devops @@ -68,9 +68,9 @@ builds: - arm64 ldflags: - -s -w - - -X main.Version={{.Version}} - - -X main.Commit={{.Commit}} - - -X main.Date={{.Date}} + - -X github.com/CompassSecurity/pipeleak/internal/cmd/common.Version={{.Version}} + - -X github.com/CompassSecurity/pipeleak/internal/cmd/common.Commit={{.Commit}} + - -X github.com/CompassSecurity/pipeleak/internal/cmd/common.Date={{.Date}} - id: "pipeleak-gitea" main: ./cmd/pipeleak-gitea binary: pipeleak-gitea @@ -82,6 +82,6 @@ builds: - arm64 ldflags: - -s -w - - -X main.Version={{.Version}} - - -X main.Commit={{.Commit}} - - -X main.Date={{.Date}} \ No newline at end of file + - -X github.com/CompassSecurity/pipeleak/internal/cmd/common.Version={{.Version}} + - -X github.com/CompassSecurity/pipeleak/internal/cmd/common.Commit={{.Commit}} + - -X github.com/CompassSecurity/pipeleak/internal/cmd/common.Date={{.Date}} \ No newline at end of file diff --git a/internal/cmd/common/common.go b/internal/cmd/common/common.go new file mode 100644 index 00000000..0c964547 --- /dev/null +++ b/internal/cmd/common/common.go @@ -0,0 +1,189 @@ +// Package common provides shared functionality for pipeleak platform-specific binaries. +package common + +import ( + "encoding/json" + "io" + "os" + + "github.com/CompassSecurity/pipeleak/pkg/logging" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +// Version information - set via ldflags during build +var ( + Version = "dev" + Commit = "none" + Date = "unknown" +) + +// Log configuration +var ( + originalTermState *term.State + JsonLogoutput bool + LogFile string + LogColor bool + LogDebug bool + LogLevel string +) + +// TerminalRestorer is a function that can be called to restore terminal state +var TerminalRestorer func() + +// TerminalRestoringWriter wraps an io.Writer to restore terminal state on fatal logs +type TerminalRestoringWriter struct { + underlying io.Writer +} + +func (w *TerminalRestoringWriter) Write(p []byte) (n int, err error) { + var logEntry map[string]interface{} + if err := json.Unmarshal(p, &logEntry); err == nil { + if level, ok := logEntry["level"].(string); ok && level == "fatal" { + _, _ = w.underlying.Write(p) + RestoreTerminalState() + os.Exit(1) + } + } + return w.underlying.Write(p) +} + +// FatalHook is a zerolog hook that restores terminal state before fatal exits +type FatalHook struct{} + +func (h FatalHook) Run(e *zerolog.Event, level zerolog.Level, msg string) { + if level == zerolog.FatalLevel { + if TerminalRestorer != nil { + TerminalRestorer() + } + } +} + +// SaveTerminalState saves the current terminal state for later restoration +func SaveTerminalState() { + if term.IsTerminal(int(os.Stdin.Fd())) { + state, err := term.GetState(int(os.Stdin.Fd())) + if err == nil { + originalTermState = state + } + } +} + +// RestoreTerminalState restores the terminal to its saved state +func RestoreTerminalState() { + if originalTermState != nil { + _ = term.Restore(int(os.Stdin.Fd()), originalTermState) + } +} + +// InitLogger initializes the zerolog logger with the configured options +func InitLogger(cmd *cobra.Command) { + defaultOut := os.Stdout + colorEnabled := LogColor + + if LogFile != "" { + // #nosec G304 - User-provided log file path via --log-file flag, user controls their own filesystem + runLogFile, err := os.OpenFile( + LogFile, + os.O_APPEND|os.O_CREATE|os.O_WRONLY, + 0600, + ) + if err != nil { + panic(err) + } + defaultOut = runLogFile + + rootFlags := cmd.Root().PersistentFlags() + if !rootFlags.Changed("color") { + colorEnabled = false + } + } + + fatalHook := FatalHook{} + + if JsonLogoutput { + hitWriter := &logging.HitLevelWriter{} + hitWriter.SetOutput(defaultOut) + logging.SetGlobalHitWriter(hitWriter) + log.Logger = zerolog.New(hitWriter).With().Timestamp().Logger().Hook(fatalHook) + } else { + output := zerolog.ConsoleWriter{ + Out: defaultOut, + TimeFormat: "2006-01-02T15:04:05Z07:00", + NoColor: !colorEnabled, + } + hitWriter := &logging.HitLevelWriter{} + hitWriter.SetOutput(&output) + logging.SetGlobalHitWriter(hitWriter) + log.Logger = zerolog.New(hitWriter).With().Timestamp().Logger().Hook(fatalHook) + } +} + +// SetGlobalLogLevel sets the global log level based on the configured options +func SetGlobalLogLevel(cmd *cobra.Command) { + if LogLevel != "" { + switch LogLevel { + case "trace": + zerolog.SetGlobalLevel(zerolog.TraceLevel) + log.Trace().Msg("Log level set to trace (explicit)") + case "debug": + zerolog.SetGlobalLevel(zerolog.DebugLevel) + log.Debug().Msg("Log level set to debug (explicit)") + case "info": + zerolog.SetGlobalLevel(zerolog.InfoLevel) + log.Info().Msg("Log level set to info (explicit)") + case "warn": + zerolog.SetGlobalLevel(zerolog.WarnLevel) + log.Warn().Msg("Log level set to warn (explicit)") + case "error": + zerolog.SetGlobalLevel(zerolog.ErrorLevel) + log.Error().Msg("Log level set to error (explicit)") + default: + zerolog.SetGlobalLevel(zerolog.InfoLevel) + log.Warn().Str("logLevelSpecified", LogLevel).Msg("Invalid log level, defaulting to info") + } + return + } + + if LogDebug { + zerolog.SetGlobalLevel(zerolog.DebugLevel) + log.Debug().Msg("Log level set to debug (-v)") + return + } + + zerolog.SetGlobalLevel(zerolog.InfoLevel) + log.Info().Msg("Log level set to info (default)") +} + +// AddCommonFlags adds the common logging and output flags to a cobra command +func AddCommonFlags(cmd *cobra.Command) { + cmd.PersistentFlags().BoolVarP(&JsonLogoutput, "json", "", false, "Use JSON as log output format") + cmd.PersistentFlags().StringVarP(&LogFile, "logfile", "l", "", "Log output to a file") + cmd.PersistentFlags().BoolVarP(&LogDebug, "verbose", "v", false, "Enable debug logging (shortcut for --log-level=debug)") + cmd.PersistentFlags().StringVar(&LogLevel, "log-level", "", "Set log level globally (debug, info, warn, error). Example: --log-level=warn") + cmd.PersistentFlags().BoolVar(&LogColor, "color", true, "Enable colored log output (auto-disabled when using --logfile)") +} + +// SetupPersistentPreRun sets up the PersistentPreRun handler for logging initialization +func SetupPersistentPreRun(cmd *cobra.Command) { + cmd.PersistentPreRun = func(c *cobra.Command, args []string) { + InitLogger(c) + SetGlobalLogLevel(c) + go logging.ShortcutListeners(nil) + } +} + +// Run executes the common startup sequence and runs the provided root command +func Run(rootCmd *cobra.Command) { + SaveTerminalState() + defer RestoreTerminalState() + + TerminalRestorer = RestoreTerminalState + + err := rootCmd.Execute() + if err != nil { + os.Exit(1) + } +} From b388815988619b6402a6eab07ca0e47366f8466c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Dec 2025 07:04:48 +0000 Subject: [PATCH 4/7] Skip platform-specific builds on non-release (PR/snapshot) builds Co-authored-by: frjcomp <107982661+frjcomp@users.noreply.github.com> --- goreleaser.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/goreleaser.yaml b/goreleaser.yaml index 6ee94bd9..0acef035 100644 --- a/goreleaser.yaml +++ b/goreleaser.yaml @@ -15,7 +15,9 @@ builds: - -X github.com/CompassSecurity/pipeleak/internal/cmd.Version={{.Version}} - -X github.com/CompassSecurity/pipeleak/internal/cmd.Commit={{.Commit}} - -X github.com/CompassSecurity/pipeleak/internal/cmd.Date={{.Date}} + # Platform-specific builds - only built on release (tagged builds) - id: "pipeleak-gitlab" + skip: '{{ not .IsRelease }}' main: ./cmd/pipeleak-gitlab binary: pipeleak-gitlab goos: @@ -30,6 +32,7 @@ builds: - -X github.com/CompassSecurity/pipeleak/internal/cmd/common.Commit={{.Commit}} - -X github.com/CompassSecurity/pipeleak/internal/cmd/common.Date={{.Date}} - id: "pipeleak-github" + skip: '{{ not .IsRelease }}' main: ./cmd/pipeleak-github binary: pipeleak-github goos: @@ -44,6 +47,7 @@ builds: - -X github.com/CompassSecurity/pipeleak/internal/cmd/common.Commit={{.Commit}} - -X github.com/CompassSecurity/pipeleak/internal/cmd/common.Date={{.Date}} - id: "pipeleak-bitbucket" + skip: '{{ not .IsRelease }}' main: ./cmd/pipeleak-bitbucket binary: pipeleak-bitbucket goos: @@ -58,6 +62,7 @@ builds: - -X github.com/CompassSecurity/pipeleak/internal/cmd/common.Commit={{.Commit}} - -X github.com/CompassSecurity/pipeleak/internal/cmd/common.Date={{.Date}} - id: "pipeleak-devops" + skip: '{{ not .IsRelease }}' main: ./cmd/pipeleak-devops binary: pipeleak-devops goos: @@ -72,6 +77,7 @@ builds: - -X github.com/CompassSecurity/pipeleak/internal/cmd/common.Commit={{.Commit}} - -X github.com/CompassSecurity/pipeleak/internal/cmd/common.Date={{.Date}} - id: "pipeleak-gitea" + skip: '{{ not .IsRelease }}' main: ./cmd/pipeleak-gitea binary: pipeleak-gitea goos: From 22ab91646e945198d170268b9d7547f5d923b79c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Dec 2025 07:11:39 +0000 Subject: [PATCH 5/7] Add HIT log level coloring and cross-platform newline handling to common package Co-authored-by: frjcomp <107982661+frjcomp@users.noreply.github.com> --- internal/cmd/common/common.go | 94 ++++++++++++++++++++++++++++++++--- 1 file changed, 88 insertions(+), 6 deletions(-) diff --git a/internal/cmd/common/common.go b/internal/cmd/common/common.go index 0c964547..3b79054c 100644 --- a/internal/cmd/common/common.go +++ b/internal/cmd/common/common.go @@ -2,10 +2,14 @@ package common import ( + "bytes" "encoding/json" "io" "os" + "runtime" + "time" + "github.com/CompassSecurity/pipeleak/pkg/format" "github.com/CompassSecurity/pipeleak/pkg/logging" "github.com/rs/zerolog" "github.com/rs/zerolog/log" @@ -33,6 +37,38 @@ var ( // TerminalRestorer is a function that can be called to restore terminal state var TerminalRestorer func() +// CustomWriter wraps an os.File with proper cross-platform newline handling +type CustomWriter struct { + Writer *os.File +} + +func (cw *CustomWriter) Write(p []byte) (n int, err error) { + originalLen := len(p) + + if bytes.HasSuffix(p, []byte("\n")) { + p = bytes.TrimSuffix(p, []byte("\n")) + } + + // necessary as to: https://github.com/rs/zerolog/blob/master/log.go#L474 + newlineChars := []byte("\n") + if runtime.GOOS == "windows" { + newlineChars = []byte("\n\r") + } + + modified := append(p, newlineChars...) + + written, err := cw.Writer.Write(modified) + if err != nil { + return 0, err + } + + if written != len(modified) { + return 0, io.ErrShortWrite + } + + return originalLen, nil +} + // TerminalRestoringWriter wraps an io.Writer to restore terminal state on fatal logs type TerminalRestoringWriter struct { underlying io.Writer @@ -80,7 +116,7 @@ func RestoreTerminalState() { // InitLogger initializes the zerolog logger with the configured options func InitLogger(cmd *cobra.Command) { - defaultOut := os.Stdout + defaultOut := &CustomWriter{Writer: os.Stdout} colorEnabled := LogColor if LogFile != "" { @@ -88,12 +124,12 @@ func InitLogger(cmd *cobra.Command) { runLogFile, err := os.OpenFile( LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, - 0600, + format.FileUserReadWrite, ) if err != nil { panic(err) } - defaultOut = runLogFile + defaultOut = &CustomWriter{Writer: runLogFile} rootFlags := cmd.Root().PersistentFlags() if !rootFlags.Changed("color") { @@ -104,16 +140,20 @@ func InitLogger(cmd *cobra.Command) { fatalHook := FatalHook{} if JsonLogoutput { + // For JSON output, wrap with HitLevelWriter to transform level field hitWriter := &logging.HitLevelWriter{} hitWriter.SetOutput(defaultOut) logging.SetGlobalHitWriter(hitWriter) log.Logger = zerolog.New(hitWriter).With().Timestamp().Logger().Hook(fatalHook) } else { + // For console output, use custom FormatLevel to color the hit level output := zerolog.ConsoleWriter{ - Out: defaultOut, - TimeFormat: "2006-01-02T15:04:05Z07:00", - NoColor: !colorEnabled, + Out: defaultOut, + TimeFormat: time.RFC3339, + NoColor: !colorEnabled, + FormatLevel: formatLevelWithHitColor(colorEnabled), } + // Wrap with HitLevelWriter to transform JSON before ConsoleWriter processes it hitWriter := &logging.HitLevelWriter{} hitWriter.SetOutput(&output) logging.SetGlobalHitWriter(hitWriter) @@ -121,6 +161,48 @@ func InitLogger(cmd *cobra.Command) { } } +// formatLevelWithHitColor returns a custom level formatter that adds a distinct color for the "hit" level. +// The hit level uses magenta (color 35) to distinguish it from other log levels. +func formatLevelWithHitColor(colorEnabled bool) zerolog.Formatter { + return func(i interface{}) string { + var level string + if ll, ok := i.(string); ok { + level = ll + } else { + return "" + } + + if !colorEnabled { + return level + } + + // Custom color for hit level - using bright magenta (35) to stand out + if level == "hit" { + return "\x1b[35m" + level + "\x1b[0m" + } + + // Use zerolog's default colors for other levels + switch level { + case "trace": + return "\x1b[90m" + level + "\x1b[0m" + case "debug": + return level + case "info": + return "\x1b[32m" + level + "\x1b[0m" + case "warn": + return "\x1b[33m" + level + "\x1b[0m" + case "error": + return "\x1b[31m" + level + "\x1b[0m" + case "fatal": + return "\x1b[31m" + level + "\x1b[0m" + case "panic": + return "\x1b[31m" + level + "\x1b[0m" + default: + return level + } + } +} + // SetGlobalLogLevel sets the global log level based on the configured options func SetGlobalLogLevel(cmd *cobra.Command) { if LogLevel != "" { From f5c4b34797effe93c09406d274621129a37b2a75 Mon Sep 17 00:00:00 2001 From: frjcomp <107982661+frjcomp@users.noreply.github.com> Date: Tue, 2 Dec 2025 07:41:45 +0000 Subject: [PATCH 6/7] fix releaser --- goreleaser.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/goreleaser.yaml b/goreleaser.yaml index 0acef035..8473a872 100644 --- a/goreleaser.yaml +++ b/goreleaser.yaml @@ -17,7 +17,7 @@ builds: - -X github.com/CompassSecurity/pipeleak/internal/cmd.Date={{.Date}} # Platform-specific builds - only built on release (tagged builds) - id: "pipeleak-gitlab" - skip: '{{ not .IsRelease }}' + skip: "{{ .IsSnapshot }}" main: ./cmd/pipeleak-gitlab binary: pipeleak-gitlab goos: @@ -32,7 +32,7 @@ builds: - -X github.com/CompassSecurity/pipeleak/internal/cmd/common.Commit={{.Commit}} - -X github.com/CompassSecurity/pipeleak/internal/cmd/common.Date={{.Date}} - id: "pipeleak-github" - skip: '{{ not .IsRelease }}' + skip: "{{ .IsSnapshot }}" main: ./cmd/pipeleak-github binary: pipeleak-github goos: @@ -47,7 +47,7 @@ builds: - -X github.com/CompassSecurity/pipeleak/internal/cmd/common.Commit={{.Commit}} - -X github.com/CompassSecurity/pipeleak/internal/cmd/common.Date={{.Date}} - id: "pipeleak-bitbucket" - skip: '{{ not .IsRelease }}' + skip: "{{ .IsSnapshot }}" main: ./cmd/pipeleak-bitbucket binary: pipeleak-bitbucket goos: @@ -62,7 +62,7 @@ builds: - -X github.com/CompassSecurity/pipeleak/internal/cmd/common.Commit={{.Commit}} - -X github.com/CompassSecurity/pipeleak/internal/cmd/common.Date={{.Date}} - id: "pipeleak-devops" - skip: '{{ not .IsRelease }}' + skip: "{{ .IsSnapshot }}" main: ./cmd/pipeleak-devops binary: pipeleak-devops goos: @@ -77,7 +77,7 @@ builds: - -X github.com/CompassSecurity/pipeleak/internal/cmd/common.Commit={{.Commit}} - -X github.com/CompassSecurity/pipeleak/internal/cmd/common.Date={{.Date}} - id: "pipeleak-gitea" - skip: '{{ not .IsRelease }}' + skip: "{{ .IsSnapshot }}" main: ./cmd/pipeleak-gitea binary: pipeleak-gitea goos: @@ -90,4 +90,4 @@ builds: - -s -w - -X github.com/CompassSecurity/pipeleak/internal/cmd/common.Version={{.Version}} - -X github.com/CompassSecurity/pipeleak/internal/cmd/common.Commit={{.Commit}} - - -X github.com/CompassSecurity/pipeleak/internal/cmd/common.Date={{.Date}} \ No newline at end of file + - -X github.com/CompassSecurity/pipeleak/internal/cmd/common.Date={{.Date}} From 4eadf743a1fbee7b370c54c9d3b8eb43ff6c6a6e Mon Sep 17 00:00:00 2001 From: frjcomp <107982661+frjcomp@users.noreply.github.com> Date: Tue, 2 Dec 2025 08:00:19 +0000 Subject: [PATCH 7/7] update cont --- CONTRIBUTING.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8a1226d2..33af314f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,6 +11,7 @@ The fastest way to start contributing is using GitHub Codespaces: 3. Click "Create codespace on main" (or your branch) The codespace will automatically: + - Set up Go 1.24+ environment - Install golangci-lint for code linting - Install Python and MkDocs for documentation @@ -32,22 +33,31 @@ If you prefer local development: ### Setup Steps 1. Clone the repository: + ```bash git clone https://github.com/CompassSecurity/pipeleak.git cd pipeleak ``` 2. Install golangci-lint: + ```bash go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest ``` 3. Download dependencies: + ```bash go mod download ``` -4. Build the binary: +4. Run it: + + ```bash + go run cmd/pipeleak/main.go + ``` + +5. Optional: Build the binary: ```bash make build ```