2026-01-27 17:50:26 +05:30
|
|
|
package ui
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"fmt"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/safedep/pmg/analyzer"
|
2026-04-08 21:04:17 +05:30
|
|
|
"github.com/safedep/pmg/internal/models"
|
2026-01-27 17:50:26 +05:30
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
|
2026-04-08 21:04:17 +05:30
|
|
|
// Packages blocked by the dependency cooldown policy (proxy mode only)
|
|
|
|
|
CooldownBlockedPackages []models.CooldownBlock
|
|
|
|
|
|
2026-01-27 17:50:26 +05:30
|
|
|
// 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 {
|
2026-04-08 21:04:17 +05:30
|
|
|
return r.BlockedCount > 0 || r.ConfirmedCount > 0 || len(r.CooldownBlockedPackages) > 0
|
2026-01-27 17:50:26 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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()
|
|
|
|
|
|
2026-02-11 09:22:18 +05:30
|
|
|
StopSpinner()
|
|
|
|
|
|
2026-01-27 17:50:26 +05:30
|
|
|
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:
|
2026-04-08 21:04:17 +05:30
|
|
|
if len(data.BlockedPackages) > 0 {
|
|
|
|
|
fmt.Println()
|
|
|
|
|
fmt.Printf("%s %s\n", Colors.Red("✗"), Colors.Red("Malicious package blocked"))
|
|
|
|
|
printMaliciousPackagesList(data.BlockedPackages)
|
|
|
|
|
fmt.Println()
|
|
|
|
|
}
|
2026-02-11 09:22:18 +05:30
|
|
|
|
2026-04-08 21:04:17 +05:30
|
|
|
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()
|
|
|
|
|
}
|
2026-02-11 09:22:18 +05:30
|
|
|
|
2026-04-08 21:04:17 +05:30
|
|
|
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)
|
|
|
|
|
}
|
2026-01-27 17:50:26 +05:30
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-08 21:04:17 +05:30
|
|
|
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),
|
|
|
|
|
)))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-27 17:50:26 +05:30
|
|
|
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:
|
2026-04-08 21:04:17 +05:30
|
|
|
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"))
|
|
|
|
|
}
|
2026-01-27 17:50:26 +05:30
|
|
|
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"
|
|
|
|
|
}
|