mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
* feat: Add dependency cooldown for npm packages Strip recently-published package versions from npm registry metadata responses so npm's resolver naturally falls back to older versions. Overrides the Accept header to force full packument responses (which include the "time" field needed for publish-date checks). Reports cooldown blocks only when all versions are stripped (remaining == 0), matching npm's --min-release-age behavior for silent fallback. * fix: Report oldest version in cooldown block (shortest wait) When all versions are blocked by cooldown, report the oldest version since it exits the cooldown window first — giving the user the shortest wait time instead of the longest. * fix: Handle resp.Body.Close error return for errcheck linter * test: Add dependency cooldown assertions to template config tests * fix: config template for dependency cooldown * fix: Prevent npm from caching cooldown-stripped metadata responses * fix: Restore body on ReadAll failure and log Close errors in response modifier * fix: Close response body before replacing to prevent connection leak * fix: Correct daysLeft ceiling math and update ContentLength on error recovery * fix: Clear Status on status code change and update ContentLength in modifier error path * refactor: address review comments on dependency cooldown PR - Make NpmCooldownHandler and constructor package-private - Pass cooldown days as parameter instead of reading config internally - Convert standalone functions to methods on npmCooldownHandler - Set Accept-Encoding: identity to prevent gzip responses breaking JSON parsing - Return 503 with descriptive message when upstream body read fails * fix: log errors in stripCooldownVersions instead of swallowing them * fix: Config preserve fallback defaults * fix: Code review fixes * fix: correct cooldown tip to show wait time instead of incorrect trusted_packages advice * fix: prevent integer overflow in cooldown duration calculation with large days values * refactor: deduplicate CooldownBlock into internal/models, fix misleading variable names - Move CooldownBlock struct to internal/models to eliminate duplication between proxy/interceptors and internal/ui packages - Simplify proxy_flow.go by using direct assignment instead of field copy - Rename latestStripped/latestDate to oldestVer/oldestDate for clarity * fix: Dependency Cooldown Check Encapsulation (#207) * fix: Encapsulate cooldown check * feat: Add --skip-dependency-cooldown override * fix: Code review fixes --------- Co-authored-by: Abhisek Datta <abhisek.datta@gmail.com>
234 lines
6.1 KiB
Go
234 lines
6.1 KiB
Go
package ui
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/safedep/pmg/analyzer"
|
|
"github.com/safedep/pmg/internal/models"
|
|
)
|
|
|
|
// 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 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")
|
|
}
|