package ui import ( "bufio" "fmt" "io" "os" "strings" "github.com/safedep/pmg/analyzer" ) // 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)) } func Fatalf(msg string, args ...interface{}) { ClearStatus() fmt.Println(Colors.Red(fmt.Sprintf(msg, args...))) os.Exit(1) } func printCooldownPackagesList(packages []CooldownBlockedPackage) { 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("Tip: add to trusted_packages in your PMG config to bypass")) } } func pluralizeDays(n int) string { if n == 1 { return "1 day" } return fmt.Sprintf("%d days", n) } 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))) } } } // 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") }