package ui import ( "bufio" "fmt" "io" "os" "strings" "github.com/safedep/pmg/analyzer" "github.com/safedep/pmg/internal/models" "golang.org/x/term" ) // The UI is internal to PMG and opinionated for the CLI. // It is not intended to be used outside of PMG. type VerbosityLevel int const ( // PMG is hidden from the user except for errors // and when malicious packages are detected VerbosityLevelSilent VerbosityLevel = iota // Show minimal status updates VerbosityLevelNormal // Show verbose status updates and information including // information about malicious packages VerbosityLevelVerbose ) type BlockConfig struct { // ShowReference determines whether to show detailed information for suspicious packages. // If false, the details are omitted to avoid repeating information already shown to the user. ShowReference bool MalwarePackages []*analyzer.PackageVersionAnalysisResult } func NewDefaultBlockConfig() *BlockConfig { return &BlockConfig{ ShowReference: true, } } var verbosityLevel VerbosityLevel = VerbosityLevelNormal func SetVerbosityLevel(level VerbosityLevel) { verbosityLevel = level } func ClearStatus() { StopSpinner() fmt.Fprint(os.Stderr, "\r") } func Block(config *BlockConfig) error { return blockWithExit(config, true) } func BlockNoExit(config *BlockConfig) error { return blockWithExit(config, false) } func blockWithExit(config *BlockConfig, exit bool) error { StopSpinner() // We show the block message only in normal mode to avoid repeating information // already shown to the user in verbose mode as part of the reporting. if verbosityLevel != VerbosityLevelVerbose { fmt.Println() fmt.Printf("%s %s\n", Colors.Red("✗"), Colors.Red("Malicious package blocked")) if config.ShowReference { printMaliciousPackagesList(config.MalwarePackages) fmt.Println() } } if exit { os.Exit(1) } return nil } func SetStatus(status string) { if verbosityLevel == VerbosityLevelSilent { return } StopSpinner() StartSpinnerWithColor(status, Colors.Dim) } // GetConfirmationOnMalware prompts the user to confirm installation of suspicious packages. // It reads from os.Stdin. Use GetConfirmationOnMalwareWithReader for custom input sources. func GetConfirmationOnMalware(malwarePackages []*analyzer.PackageVersionAnalysisResult) (bool, error) { return GetConfirmationOnMalwareWithReader(malwarePackages, os.Stdin) } // GetConfirmationOnMalwareWithReader prompts the user to confirm installation of suspicious packages. // It reads from the provided reader, allowing for PTY input routing during proxy mode. func GetConfirmationOnMalwareWithReader(malwarePackages []*analyzer.PackageVersionAnalysisResult, reader io.Reader) (bool, error) { StopSpinner() fmt.Println() fmt.Printf("%s %s\n", Colors.Yellow("!"), Colors.Yellow(fmt.Sprintf("Suspicious package(s) detected: %d", len(malwarePackages)))) printMaliciousPackagesList(malwarePackages) fmt.Println() fmt.Print(Colors.Normal("Do you want to continue with the installation? (y/N) ")) // Use Scanner on the provided reader to support PTY input routing scanner := bufio.NewScanner(reader) if scanner.Scan() { response := strings.ToLower(strings.TrimSpace(scanner.Text())) if response == "y" || response == "yes" || (len(response) > 0 && response[0] == 'y') { return true, nil } } // Check for scanner errors, but don't treat them as fatal if err := scanner.Err(); err != nil { // On EOF or interrupted read, just return false (deny) return false, nil } return false, nil } func ShowWarning(message string) { // Print colored warning to stderr immediately - it won't be cleared by other output fmt.Fprintf(os.Stderr, "PMG: %s\n", Colors.Red(message)) } // Infof prints an informational message, suppressed in silent mode. func Infof(msg string, args ...interface{}) { if verbosityLevel == VerbosityLevelSilent { return } fmt.Println(fmt.Sprintf(msg, args...)) } // Successf prints a green success message, suppressed in silent mode. func Successf(msg string, args ...interface{}) { if verbosityLevel == VerbosityLevelSilent { return } fmt.Printf("%s %s\n", Colors.Green("✓"), fmt.Sprintf(msg, args...)) } // PromptInput prints a label and reads a line of visible input from stdin. func PromptInput(label string) (string, error) { fmt.Printf("%s %s", Colors.Cyan("›"), Colors.Bold(label)) scanner := bufio.NewScanner(os.Stdin) if scanner.Scan() { return strings.TrimSpace(scanner.Text()), nil } if err := scanner.Err(); err != nil { return "", err } return "", fmt.Errorf("no input received") } // PromptSecret prints a label and reads input from stdin with echo disabled. // Returns an error if stdin is not a terminal (e.g. piped input). func PromptSecret(label string) (string, error) { if !term.IsTerminal(int(os.Stdin.Fd())) { return "", fmt.Errorf("interactive terminal required for secure input") } fmt.Printf("%s %s", Colors.Cyan("▪"), Colors.Bold(label)) raw, err := term.ReadPassword(int(os.Stdin.Fd())) fmt.Println() // newline after hidden input if err != nil { return "", err } return strings.TrimSpace(string(raw)), nil } func Fatalf(msg string, args ...interface{}) { ClearStatus() fmt.Fprintln(os.Stderr, Colors.Red(fmt.Sprintf(msg, args...))) os.Exit(1) } func printMaliciousPackagesList(malwarePackages []*analyzer.PackageVersionAnalysisResult) { for _, mp := range malwarePackages { fmt.Println() fmt.Printf(" %s %s\n", Colors.Red("-"), Colors.Red(fmt.Sprintf("%s@%s", mp.PackageVersion.GetPackage().GetName(), mp.PackageVersion.GetVersion()))) if verbosityLevel == VerbosityLevelVerbose { fmt.Printf(" %s\n", Colors.Dim(termWidthFormatText(mp.Summary, 76))) } if mp.ReferenceURL != "" { fmt.Printf(" %s\n", Colors.Dim(fmt.Sprintf("Reference: %s", mp.ReferenceURL))) } } } func printCooldownPackagesList(packages []models.CooldownBlock) { for _, pkg := range packages { fmt.Println() fmt.Printf(" %s %s\n", Colors.Yellow("⊘"), Colors.Yellow(fmt.Sprintf("%s@%s", pkg.Name, pkg.Version))) dateStr := "" if !pkg.PublishDate.IsZero() { dateStr = fmt.Sprintf(" (%s)", pkg.PublishDate.Format("2006-01-02")) } fmt.Printf(" %s\n", Colors.Dim(fmt.Sprintf( "Published %s ago%s — available in %s", pluralizeDays(pkg.DaysAgo), dateStr, pluralizeDays(pkg.DaysLeft), ))) fmt.Printf(" %s\n", Colors.Dim(fmt.Sprintf( "Tip: wait %s for cooldown to expire", pluralizeDays(pkg.DaysLeft), ))) } } func pluralizeDays(n int) string { if n == 1 { return "1 day" } return fmt.Sprintf("%d days", n) } func pluralizePackages(n int) string { if n == 1 { return "1 package" } return fmt.Sprintf("%d packages", n) } // Format the string to be maximum maxWidth. Use newlines to wrap the text. func termWidthFormatText(text string, maxWidth int) string { // Replace all newlines with spaces so that we can split the text into words // This is to ensure that we don't split the text at the newlines text = strings.ReplaceAll(text, "\n", " ") words := strings.Split(text, " ") lines := []string{} currentLine := "" for i, word := range words { // Skip empty words that might result from multiple spaces if word == "" { continue } if i == 0 { // First word doesn't need a leading space currentLine = word } else if len(currentLine)+len(word)+1 > maxWidth { // +1 for the space we would add lines = append(lines, currentLine) currentLine = word } else { currentLine += " " + word } } // Don't forget to add the last line if currentLine != "" { lines = append(lines, currentLine) } return strings.Join(lines, "\n") }