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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions cmd/troubleshoot/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ If no arguments are provided, specs are automatically loaded from the cluster by
autoFromEnv = false
}
}

if v.GetBool("auto-update") && autoFromEnv {
exe, err := os.Executable()
if err == nil {
Expand Down Expand Up @@ -114,6 +113,14 @@ If no arguments are provided, specs are automatically loaded from the cluster by
cmd.Flags().StringSlice("redactors", []string{}, "names of the additional redactors to use")
cmd.Flags().Bool("redact", true, "enable/disable default redactions")

// Tokenization flags
cmd.Flags().Bool("tokenize", false, "enable intelligent tokenization instead of simple masking (replaces ***HIDDEN*** with ***TOKEN_TYPE_HASH***)")
cmd.Flags().String("redaction-map", "", "generate redaction mapping file at specified path (enables token→original mapping for authorized access)")
cmd.Flags().Bool("encrypt-redaction-map", false, "encrypt the redaction mapping file using AES-256 (requires --redaction-map)")
cmd.Flags().String("token-prefix", "", "custom token prefix format (default: ***TOKEN_%s_%s***)")
cmd.Flags().Bool("verify-tokenization", false, "validation mode: verify tokenization setup without collecting data")
cmd.Flags().String("bundle-id", "", "custom bundle identifier for token correlation (auto-generated if not provided)")
cmd.Flags().Bool("tokenization-stats", false, "include detailed tokenization statistics in output")
cmd.Flags().Bool("interactive", true, "enable/disable interactive mode")
cmd.Flags().Bool("collect-without-permissions", true, "always generate a support bundle, even if it some require additional permissions")
cmd.Flags().StringSliceP("selector", "l", []string{"troubleshoot.sh/kind=support-bundle"}, "selector to filter on for loading additional support bundle specs found in secrets within the cluster")
Expand All @@ -125,11 +132,6 @@ If no arguments are provided, specs are automatically loaded from the cluster by
cmd.Flags().Bool("dry-run", false, "print support bundle spec without collecting anything")
cmd.Flags().Bool("auto-update", true, "enable automatic binary self-update check and install")

// Auto-upload flags
cmd.Flags().Bool("auto-upload", false, "automatically upload bundle after generation (auto-detects license and app from bundle)")
cmd.Flags().String("license-id", "", "license ID for upload (auto-detected from bundle if not provided)")
cmd.Flags().String("app-slug", "", "application slug for upload (auto-detected from bundle if not provided)")

// Auto-discovery flags
cmd.Flags().Bool("auto", false, "enable auto-discovery of foundational collectors. When used with YAML specs, adds foundational collectors to YAML collectors. When used alone, collects only foundational data")
cmd.Flags().Bool("include-images", false, "include container image metadata collection when using auto-discovery")
Expand Down
134 changes: 120 additions & 14 deletions cmd/troubleshoot/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"os/signal"
"path/filepath"
"reflect"
"strings"
"sync"
"time"

Expand All @@ -27,6 +28,7 @@ import (
"github.com/replicatedhq/troubleshoot/pkg/httputil"
"github.com/replicatedhq/troubleshoot/pkg/k8sutil"
"github.com/replicatedhq/troubleshoot/pkg/loader"
"github.com/replicatedhq/troubleshoot/pkg/redact"
"github.com/replicatedhq/troubleshoot/pkg/supportbundle"
"github.com/replicatedhq/troubleshoot/pkg/types"
"github.com/spf13/viper"
Expand Down Expand Up @@ -60,6 +62,10 @@ func runTroubleshoot(v *viper.Viper, args []string) error {
return errors.Wrap(err, "invalid auto-discovery configuration")
}

// Validate tokenization flags
if err := ValidateTokenizationFlags(v); err != nil {
return errors.Wrap(err, "invalid tokenization configuration")
}
// Apply auto-discovery if enabled
autoConfig := GetAutoDiscoveryConfig(v)
if autoConfig.Enabled {
Expand Down Expand Up @@ -185,9 +191,9 @@ func runTroubleshoot(v *viper.Viper, args []string) error {
}
case <-time.After(time.Millisecond * 100):
if currentDir == "" {
fmt.Printf("\r%s \u001b[36mCollecting support bundle\u001b[m %s", cursor.ClearEntireLine(), s.Next())
fmt.Printf("\r%s \033[36mCollecting support bundle\033[m %s", cursor.ClearEntireLine(), s.Next())
} else {
fmt.Printf("\r%s \u001b[36mCollecting support bundle\u001b[m %s %s", cursor.ClearEntireLine(), s.Next(), currentDir)
fmt.Printf("\r%s \033[36mCollecting support bundle\033[m %s %s", cursor.ClearEntireLine(), s.Next(), currentDir)
}
}
}
Expand All @@ -205,6 +211,15 @@ func runTroubleshoot(v *viper.Viper, args []string) error {
Redact: v.GetBool("redact"),
FromCLI: true,
RunHostCollectorsInPod: mainBundle.Spec.RunHostCollectorsInPod,

// Phase 4: Tokenization options
Tokenize: v.GetBool("tokenize"),
RedactionMapPath: v.GetString("redaction-map"),
EncryptRedactionMap: v.GetBool("encrypt-redaction-map"),
TokenPrefix: v.GetString("token-prefix"),
VerifyTokenization: v.GetBool("verify-tokenization"),
BundleID: v.GetString("bundle-id"),
TokenizationStats: v.GetBool("tokenization-stats"),
}

nonInteractiveOutput := analysisOutput{}
Expand All @@ -217,18 +232,6 @@ func runTroubleshoot(v *viper.Viper, args []string) error {
close(progressChan) // this removes the spinner in interactive mode
isProgressChanClosed = true

// Auto-upload if requested
if v.GetBool("auto-upload") {
licenseID := v.GetString("license-id")
appSlug := v.GetString("app-slug")

fmt.Fprintf(os.Stderr, "Auto-uploading bundle to replicated.app...\n")
if err := supportbundle.UploadBundleAutoDetect(response.ArchivePath, licenseID, appSlug); err != nil {
fmt.Fprintf(os.Stderr, "Auto-upload failed: %v\n", err)
fmt.Fprintf(os.Stderr, "You can manually upload the bundle using: support-bundle upload %s\n", response.ArchivePath)
}
}

if len(response.AnalyzerResults) > 0 {
if interactive {
if err := showInteractiveResults(mainBundle.Name, response.AnalyzerResults, response.ArchivePath); err != nil {
Expand Down Expand Up @@ -498,3 +501,106 @@ func (a *analysisOutput) FormattedAnalysisOutput() (outputJson string, err error
}
return string(formatted), nil
}

// ValidateTokenizationFlags validates tokenization flag combinations
func ValidateTokenizationFlags(v *viper.Viper) error {
// Verify tokenization mode early (before collection starts)
if v.GetBool("verify-tokenization") {
if err := VerifyTokenizationSetup(v); err != nil {
return errors.Wrap(err, "tokenization verification failed")
}
fmt.Println("✅ Tokenization verification passed")
os.Exit(0) // Exit after verification
}

// Encryption requires redaction map
if v.GetBool("encrypt-redaction-map") && v.GetString("redaction-map") == "" {
return errors.New("--encrypt-redaction-map requires --redaction-map to be specified")
}

// Redaction map requires tokenization or redaction to be enabled
if v.GetString("redaction-map") != "" {
if !v.GetBool("tokenize") && !v.GetBool("redact") {
return errors.New("--redaction-map requires either --tokenize or --redact to be enabled")
}
}

// Custom token prefix requires tokenization
if v.GetString("token-prefix") != "" && !v.GetBool("tokenize") {
return errors.New("--token-prefix requires --tokenize to be enabled")
}

// Bundle ID requires tokenization
if v.GetString("bundle-id") != "" && !v.GetBool("tokenize") {
return errors.New("--bundle-id requires --tokenize to be enabled")
}

// Tokenization stats requires tokenization
if v.GetBool("tokenization-stats") && !v.GetBool("tokenize") {
return errors.New("--tokenization-stats requires --tokenize to be enabled")
}

return nil
}

// VerifyTokenizationSetup verifies tokenization configuration without collecting data
func VerifyTokenizationSetup(v *viper.Viper) error {
fmt.Println("🔍 Verifying tokenization setup...")

// Test 1: Environment variable check
if v.GetBool("tokenize") {
os.Setenv("TROUBLESHOOT_TOKENIZATION", "true")
defer os.Unsetenv("TROUBLESHOOT_TOKENIZATION")
}

// Test 2: Tokenizer initialization
redact.ResetGlobalTokenizer()
tokenizer := redact.GetGlobalTokenizer()

if v.GetBool("tokenize") && !tokenizer.IsEnabled() {
return errors.New("tokenizer is not enabled despite --tokenize flag")
}

if !v.GetBool("tokenize") && tokenizer.IsEnabled() {
return errors.New("tokenizer is enabled despite --tokenize flag being false")
}

fmt.Printf(" ✅ Tokenizer state: %v\n", tokenizer.IsEnabled())

// Test 3: Token generation
if tokenizer.IsEnabled() {
testToken := tokenizer.TokenizeValue("test-secret", "verification")
if !tokenizer.ValidateToken(testToken) {
return errors.Errorf("generated test token is invalid: %s", testToken)
}
fmt.Printf(" ✅ Test token generated: %s\n", testToken)
}

// Test 4: Custom token prefix validation
if customPrefix := v.GetString("token-prefix"); customPrefix != "" {
if !strings.Contains(customPrefix, "%s") {
return errors.Errorf("custom token prefix must contain %%s placeholders: %s", customPrefix)
}
fmt.Printf(" ✅ Custom token prefix validated: %s\n", customPrefix)
}

// Test 5: Redaction map path validation
if mapPath := v.GetString("redaction-map"); mapPath != "" {
// Check if directory exists
dir := filepath.Dir(mapPath)
if _, err := os.Stat(dir); os.IsNotExist(err) {
return errors.Errorf("redaction map directory does not exist: %s", dir)
}
fmt.Printf(" ✅ Redaction map path validated: %s\n", mapPath)

// Test file creation (and cleanup)
testFile := mapPath + ".test"
if err := os.WriteFile(testFile, []byte("test"), 0600); err != nil {
return errors.Errorf("cannot create redaction map file: %v", err)
}
os.Remove(testFile)
fmt.Printf(" ✅ File creation permissions verified\n")
}

return nil
}
13 changes: 12 additions & 1 deletion pkg/redact/literal.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,18 @@ func (r literalRedactor) Redact(input io.Reader, path string) io.Reader {
lineNum++
line := scanner.Bytes()

clean := bytes.ReplaceAll(line, r.match, maskTextBytes)
var clean []byte
tokenizer := GetGlobalTokenizer()
if tokenizer.IsEnabled() {
// For literal redaction, we tokenize the matched value
matchStr := string(r.match)
context := r.redactName
token := tokenizer.TokenizeValueWithPath(matchStr, context, r.filePath)
clean = bytes.ReplaceAll(line, r.match, []byte(token))
} else {
// Use original masking behavior
clean = bytes.ReplaceAll(line, r.match, maskTextBytes)
}

// Append newline since scanner strips it
err = writeBytes(writer, clean, NEW_LINE)
Expand Down
13 changes: 11 additions & 2 deletions pkg/redact/multi_line.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ func (r *MultiLineRedactor) Redact(input io.Reader, path string) io.Reader {
writer.CloseWithError(err)
}()

substStr := []byte(getReplacementPattern(r.re2, r.maskText))
tokenizer := GetGlobalTokenizer()

reader := bufio.NewReader(input)
line1, line2, err := getNextTwoLines(reader, nil)
Expand Down Expand Up @@ -94,7 +94,16 @@ func (r *MultiLineRedactor) Redact(input io.Reader, path string) io.Reader {
continue
}
flushLastLine = false
clean := r.re2.ReplaceAll(line2, substStr)
var clean []byte
if tokenizer.IsEnabled() {
// Use tokenized replacement for line2 based on line1 context
context := r.redactName
clean = getTokenizedReplacementPatternWithPath(r.re2, line2, context, r.filePath)
} else {
// Use original masking behavior
substStr := []byte(getReplacementPattern(r.re2, r.maskText))
clean = r.re2.ReplaceAll(line2, substStr)
}

// Append newlines since scanner strips them
err = writeBytes(writer, line1, NEW_LINE, clean, NEW_LINE)
Expand Down
51 changes: 51 additions & 0 deletions pkg/redact/redact.go
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,57 @@ func getReplacementPattern(re *regexp.Regexp, maskText string) string {
return substStr
}

// getTokenizedReplacementPattern creates a replacement pattern that tokenizes matched groups
func getTokenizedReplacementPattern(re *regexp.Regexp, line []byte, context string) []byte {
return getTokenizedReplacementPatternWithPath(re, line, context, "")
}

// getTokenizedReplacementPatternWithPath creates a replacement pattern that tokenizes matched groups with file path tracking
func getTokenizedReplacementPatternWithPath(re *regexp.Regexp, line []byte, context, filePath string) []byte {
tokenizer := GetGlobalTokenizer()
if !tokenizer.IsEnabled() {
// Fallback to original behavior
return []byte(getReplacementPattern(re, MASK_TEXT))
}

// Find all matches and their submatches
matches := re.FindSubmatch(line)
if matches == nil {
return line // No match found
}

substStr := ""
for i, name := range re.SubexpNames() {
if i == 0 { // index 0 is the entire string
continue
}
if i >= len(matches) {
continue
}

if name == "" {
// Unnamed group - preserve as is
substStr = fmt.Sprintf("%s$%d", substStr, i)
} else if name == "mask" {
// This is the group to be tokenized
secretValue := string(matches[i])
if secretValue != "" {
// Use the path-aware tokenization method
token := tokenizer.TokenizeValueWithPath(secretValue, context, filePath)
substStr = fmt.Sprintf("%s%s", substStr, token)
} else {
substStr = fmt.Sprintf("%s%s", substStr, MASK_TEXT)
}
} else if name == "drop" {
// no-op, string is just dropped from result
} else {
// Named group - preserve as is
substStr = fmt.Sprintf("%s${%s}", substStr, name)
}
}
return re.ReplaceAll(line, []byte(substStr))
}

func readLine(r *bufio.Reader) ([]byte, error) {
var completeLine []byte
for {
Expand Down
14 changes: 11 additions & 3 deletions pkg/redact/single_line.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,11 @@ func (r *SingleLineRedactor) Redact(input io.Reader, path string) io.Reader {
}
}()

substStr := []byte(getReplacementPattern(r.re, r.maskText))

buf := make([]byte, constants.BUF_INIT_SIZE)
scanner := bufio.NewScanner(input)
scanner.Buffer(buf, constants.SCANNER_MAX_SIZE)

tokenizer := GetGlobalTokenizer()
lineNum := 0
for scanner.Scan() {
lineNum++
Expand Down Expand Up @@ -92,7 +91,16 @@ func (r *SingleLineRedactor) Redact(input io.Reader, path string) io.Reader {
continue
}

clean := r.re.ReplaceAll(line, substStr)
var clean []byte
if tokenizer.IsEnabled() {
// Use tokenized replacement - context comes from the redactor name which often indicates the secret type
context := r.redactName
clean = getTokenizedReplacementPatternWithPath(r.re, line, context, r.filePath)
} else {
// Use original masking behavior
substStr := []byte(getReplacementPattern(r.re, r.maskText))
clean = r.re.ReplaceAll(line, substStr)
}
// Append newline since scanner strips it
err = writeBytes(writer, clean, NEW_LINE)
if err != nil {
Expand Down
Loading
Loading