Files
Sahil BansalandGitHub 46803f8e70 fix(doctor): stop npm protection passing when the real package manager is absent (#378)
`pmg setup doctor` reported npm protection as OK on a machine with no npm
installed. Two compounding defects:

1. The availability gate used a plain `exec.LookPath`, which resolves the PMG
   shim on PATH rather than the real binary, so the "skip" branch never fired.
   It now uses `shim.ResolveRealBinary` (PATH with shim dirs stripped), matching
   the runner, so a missing real binary correctly yields WARN "not available".

2. The result was inferred purely from a non-zero exit, so PackageManagerNotFound
   (exit 127) and other failures were misread as a successful block. The check
   now requires PMG's block headline in the captured output before reporting
   PASS; other non-zero exits report WARN with the error surfaced.

The block headline is extracted into `ui.MalwareBlockedHeadline` so the doctor's
marker stays in sync with what PMG prints across its block-output sites.
2026-07-15 09:11:52 +05:30

296 lines
8.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(MalwareBlockedHeadline))
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(termWidthFormatTextIndent(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),
)))
}
}
// printAdvisoryMessage renders the org-configured advisory_message as an info
// note attached to the block output. Callers are responsible for surrounding
// blank lines. No-op when the message is empty.
func printAdvisoryMessage(message string) {
if message == "" {
return
}
fmt.Printf(" %s %s\n", Colors.Cyan(""), Colors.Cyan(termWidthFormatTextIndent(message, 76, " ")))
}
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)
}
// termWidthFormatTextIndent wraps text at maxWidth and indents continuation
// lines so wrapped output stays aligned with the first line.
func termWidthFormatTextIndent(text string, maxWidth int, indent string) string {
return strings.ReplaceAll(termWidthFormatText(text, maxWidth), "\n", "\n"+indent)
}
// 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")
}