Files
pmg/internal/ui/report.go
T
987bda5d6a feat: Add dependency cooldown for npm packages (#200)
* 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>
2026-04-08 21:04:17 +05:30

366 lines
9.8 KiB
Go

package ui
import (
"fmt"
"time"
"github.com/safedep/pmg/analyzer"
"github.com/safedep/pmg/internal/models"
)
// FlowType indicates which execution flow was used
type FlowType int
const (
FlowTypeGuard FlowType = iota
FlowTypeProxy
)
func (f FlowType) String() string {
switch f {
case FlowTypeGuard:
return "guard"
case FlowTypeProxy:
return "proxy"
default:
return "unknown"
}
}
// ExecutionOutcome represents the final result of the PMG execution
type ExecutionOutcome int
const (
OutcomeSuccess ExecutionOutcome = iota
OutcomeBlocked
OutcomeUserCancelled
OutcomeDryRun
OutcomeError
OutcomeInsecureBypass
)
func (o ExecutionOutcome) String() string {
switch o {
case OutcomeSuccess:
return "success"
case OutcomeBlocked:
return "blocked"
case OutcomeUserCancelled:
return "user_cancelled"
case OutcomeDryRun:
return "dry_run"
case OutcomeError:
return "error"
case OutcomeInsecureBypass:
return "insecure_bypass"
default:
return "unknown"
}
}
// ReportData captures execution statistics for the post-execution report.
// This is a pure data model with no rendering logic.
type ReportData struct {
// Execution metadata
PackageManagerName string
StartTime time.Time
Duration time.Duration
// Package statistics (consistent across guard and proxy flows)
TotalAnalyzed int
TrustedSkipped int
// Analysis breakdown
AllowedCount int
ConfirmedCount int
BlockedCount int
// Details for verbose mode
BlockedPackages []*analyzer.PackageVersionAnalysisResult
ConfirmedPackages []*analyzer.PackageVersionAnalysisResult
// Packages blocked by the dependency cooldown policy (proxy mode only)
CooldownBlockedPackages []models.CooldownBlock
// Configuration context
FlowType FlowType
DryRun bool
InsecureMode bool
TransitiveEnabled bool
ParanoidMode bool
SandboxEnabled bool
SandboxProfile string
// Outcome
Outcome ExecutionOutcome
}
// NewReportData creates a new ReportData with sensible defaults
func NewReportData() *ReportData {
return &ReportData{
StartTime: time.Now(),
Outcome: OutcomeSuccess,
}
}
// Finalize sets the duration based on start time
func (r *ReportData) Finalize() {
r.Duration = time.Since(r.StartTime)
}
// HasIssues returns true if any packages were blocked or required confirmation
func (r *ReportData) HasIssues() bool {
return r.BlockedCount > 0 || r.ConfirmedCount > 0 || len(r.CooldownBlockedPackages) > 0
}
// WasSuccessful returns true if execution completed without blocks or errors
func (r *ReportData) WasSuccessful() bool {
return r.Outcome == OutcomeSuccess || r.Outcome == OutcomeDryRun
}
// Report renders the execution report based on verbosity level.
// This is the public API - flows call this with collected data.
func Report(data *ReportData) {
data.Finalize()
StopSpinner()
switch verbosityLevel {
case VerbosityLevelSilent:
reportSilent(data)
case VerbosityLevelNormal:
reportNormal(data)
case VerbosityLevelVerbose:
reportVerbose(data)
}
}
// reportSilent only shows output on errors or blocks
// Normal successful execution produces no output
func reportSilent(data *ReportData) {
// Silent mode: no report output
}
// reportNormal shows minimal, assuring output
func reportNormal(data *ReportData) {
if data.Outcome == OutcomeDryRun {
return // Dry run already shows its own message
}
if data.Outcome == OutcomeError {
return // Error handling done elsewhere
}
if data.Outcome == OutcomeInsecureBypass {
// Security-sensitive: Always show warning when protection is bypassed
icon := Colors.Red("⚠")
message := "INSECURE MODE - Malware protection bypassed"
if data.TotalAnalyzed > 0 {
fmt.Printf("%s %s (%d packages installed without analysis)\n",
icon, Colors.Red(message), data.TotalAnalyzed)
} else {
fmt.Printf("%s %s\n", icon, Colors.Red(message))
}
return
}
if data.TotalAnalyzed == 0 {
// No packages analyzed (e.g., npm install with no new packages)
return
}
var icon string
var message string
switch data.Outcome {
case OutcomeBlocked:
if len(data.BlockedPackages) > 0 {
fmt.Println()
fmt.Printf("%s %s\n", Colors.Red("✗"), Colors.Red("Malicious package blocked"))
printMaliciousPackagesList(data.BlockedPackages)
fmt.Println()
}
if len(data.CooldownBlockedPackages) > 0 {
fmt.Println()
n := len(data.CooldownBlockedPackages)
fmt.Printf("%s %s\n",
Colors.Yellow("⊘"),
Colors.Yellow(fmt.Sprintf("Dependency cooldown — %s blocked", pluralizePackages(n))))
printCooldownPackagesList(data.CooldownBlockedPackages)
fmt.Println()
}
onlyCooldown := len(data.BlockedPackages) == 0 && len(data.CooldownBlockedPackages) > 0
if onlyCooldown {
icon = Colors.Yellow("⊘")
message = fmt.Sprintf("PMG: %s analyzed, %s blocked by cooldown",
pluralizePackages(data.TotalAnalyzed), pluralizePackages(data.BlockedCount))
} else {
icon = Colors.Red("✗")
message = fmt.Sprintf("PMG: %d packages analyzed, %d blocked",
data.TotalAnalyzed, data.BlockedCount)
}
case OutcomeUserCancelled:
icon = Colors.Yellow("✗")
message = fmt.Sprintf("PMG: %d packages analyzed, installation cancelled",
data.TotalAnalyzed)
default:
// Success case
if data.HasIssues() {
icon = Colors.Yellow("!")
message = fmt.Sprintf("PMG: %d packages analyzed (%d confirmed)",
data.TotalAnalyzed, data.ConfirmedCount)
} else {
icon = Colors.Green("✓")
message = fmt.Sprintf("PMG: %d packages analyzed", data.TotalAnalyzed)
}
}
fmt.Printf("%s %s\n", icon, Colors.Dim(message))
}
// reportVerbose shows detailed debugging information
func reportVerbose(data *ReportData) {
fmt.Println()
fmt.Println(Colors.Cyan("PMG Execution Report"))
fmt.Println(Colors.Normal("────────────────────────────────────────"))
// Outcome summary line
printOutcomeLine(data)
// Statistics section
fmt.Println()
if data.TrustedSkipped > 0 {
fmt.Printf(" %s %d analyzed (%d trusted skipped)\n",
Colors.Bold("Packages:"),
data.TotalAnalyzed,
data.TrustedSkipped)
} else {
fmt.Printf(" %s %d analyzed\n",
Colors.Bold("Packages:"),
data.TotalAnalyzed)
}
fmt.Printf(" %s %s (allowed: %d, confirmed: %d, blocked: %d)\n",
Colors.Bold("Analysis:"),
formatDuration(data.Duration),
data.AllowedCount,
data.ConfirmedCount,
data.BlockedCount)
// Configuration section
fmt.Println()
fmt.Printf(" %s %s | %s flow | transitive: %s | paranoid: %s\n",
Colors.Bold("Config:"),
data.PackageManagerName,
data.FlowType.String(),
boolToOnOff(data.TransitiveEnabled),
boolToOnOff(data.ParanoidMode))
if data.SandboxEnabled {
profile := data.SandboxProfile
if profile == "" {
profile = "default"
}
fmt.Printf(" %s enabled (%s)\n",
Colors.Bold("Sandbox:"),
profile)
}
// Show blocked/confirmed package details in verbose mode
if len(data.BlockedPackages) > 0 {
fmt.Println()
fmt.Println(Colors.Red(" Blocked packages:"))
for _, pkg := range data.BlockedPackages {
printPackageDetail(pkg)
}
}
if len(data.ConfirmedPackages) > 0 {
fmt.Println()
fmt.Println(Colors.Yellow(" User-confirmed packages:"))
for _, pkg := range data.ConfirmedPackages {
printPackageDetail(pkg)
}
}
if len(data.CooldownBlockedPackages) > 0 {
fmt.Println()
fmt.Println(Colors.Yellow(" Blocked by dependency cooldown:"))
for _, pkg := range data.CooldownBlockedPackages {
dateStr := ""
if !pkg.PublishDate.IsZero() {
dateStr = fmt.Sprintf(" (%s)", pkg.PublishDate.Format("2006-01-02"))
}
fmt.Printf(" %s %s\n", Colors.Yellow("⊘"), Colors.Yellow(fmt.Sprintf("%s@%s", pkg.Name, pkg.Version)))
fmt.Printf(" %s\n", Colors.Dim(fmt.Sprintf(
"Published %s ago%s — cooldown: %d days, available in %s",
pluralizeDays(pkg.DaysAgo), dateStr, pkg.CooldownDays, pluralizeDays(pkg.DaysLeft),
)))
fmt.Printf(" %s\n", Colors.Dim(fmt.Sprintf(
"Tip: wait %s for cooldown to expire",
pluralizeDays(pkg.DaysLeft),
)))
}
}
fmt.Println()
}
func printOutcomeLine(data *ReportData) {
switch data.Outcome {
case OutcomeSuccess:
fmt.Printf(" %s %s\n", Colors.Green("✓"), Colors.Green("Installation completed successfully"))
case OutcomeBlocked:
hasMalware := len(data.BlockedPackages) > 0
hasCooldown := len(data.CooldownBlockedPackages) > 0
switch {
case hasMalware && hasCooldown:
fmt.Printf(" %s %s\n", Colors.Red("✗"), Colors.Red("Installation blocked — malicious package detected + cooldown policy"))
case hasCooldown:
fmt.Printf(" %s %s\n", Colors.Yellow("⊘"), Colors.Yellow("Installation blocked — dependency cooldown policy"))
default:
fmt.Printf(" %s %s\n", Colors.Red("✗"), Colors.Red("Installation blocked — malicious package detected"))
}
case OutcomeUserCancelled:
fmt.Printf(" %s %s\n", Colors.Yellow("✗"), Colors.Yellow("Installation cancelled by user"))
case OutcomeDryRun:
fmt.Printf(" %s %s\n", Colors.Cyan("○"), Colors.Cyan("Dry run completed - no packages installed"))
case OutcomeError:
fmt.Printf(" %s %s\n", Colors.Red("✗"), Colors.Red("Execution failed with error"))
case OutcomeInsecureBypass:
fmt.Printf(" %s %s\n", Colors.Yellow("⚠"), Colors.Yellow("Installation completed (insecure mode - protection bypassed)"))
}
}
func printPackageDetail(pkg *analyzer.PackageVersionAnalysisResult) {
if pkg == nil || pkg.PackageVersion == nil {
return
}
name := pkg.PackageVersion.GetPackage().GetName()
version := pkg.PackageVersion.GetVersion()
fmt.Printf(" - %s@%s\n", name, version)
if pkg.ReferenceURL != "" {
fmt.Printf(" %s\n", Colors.Dim(pkg.ReferenceURL))
}
}
func formatDuration(d time.Duration) string {
if d < time.Second {
return fmt.Sprintf("%dms", d.Milliseconds())
}
return fmt.Sprintf("%.1fs", d.Seconds())
}
func boolToOnOff(b bool) string {
if b {
return "on"
}
return "off"
}