Files
pmg/proxy/interceptors/stats.go
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

153 lines
4.1 KiB
Go

package interceptors
import (
"sync"
"time"
"github.com/safedep/pmg/analyzer"
"github.com/safedep/pmg/internal/models"
)
// AnalysisStats contains aggregated statistics from analysis results
type AnalysisStats struct {
TotalAnalyzed int
AllowedCount int
ConfirmedCount int
BlockedCount int
UserCancelledCount int
CooldownBlockedCount int
}
// AnalysisStatsCollector tracks analysis statistics during proxy execution.
// It is separate from the cache to allow different cache implementations
// without coupling them to reporting concerns.
type AnalysisStatsCollector struct {
mu sync.RWMutex
stats AnalysisStats
blockedPackages []*analyzer.PackageVersionAnalysisResult
confirmedPackages []*analyzer.PackageVersionAnalysisResult
cooldownBlocks []models.CooldownBlock
}
// NewAnalysisStatsCollector creates a new stats collector
func NewAnalysisStatsCollector() *AnalysisStatsCollector {
return &AnalysisStatsCollector{}
}
// RecordAllowed records a package that was allowed (safe)
func (c *AnalysisStatsCollector) RecordAllowed(result *analyzer.PackageVersionAnalysisResult) {
if result == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
c.stats.TotalAnalyzed++
c.stats.AllowedCount++
}
// RecordBlocked records a package that was automatically blocked (ActionBlock)
func (c *AnalysisStatsCollector) RecordBlocked(result *analyzer.PackageVersionAnalysisResult) {
if result == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
c.stats.TotalAnalyzed++
c.stats.BlockedCount++
c.blockedPackages = append(c.blockedPackages, result)
}
// RecordUserCancelled records a package that was blocked because user declined confirmation (ActionConfirm declined)
func (c *AnalysisStatsCollector) RecordUserCancelled(result *analyzer.PackageVersionAnalysisResult) {
if result == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
c.stats.TotalAnalyzed++
c.stats.UserCancelledCount++
// User cancelled packages are counted as blocked as well
c.stats.BlockedCount++
c.blockedPackages = append(c.blockedPackages, result)
}
// RecordConfirmed records a package where user confirmed installation despite warning
func (c *AnalysisStatsCollector) RecordConfirmed(result *analyzer.PackageVersionAnalysisResult) {
if result == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
c.stats.TotalAnalyzed++
c.stats.ConfirmedCount++
c.confirmedPackages = append(c.confirmedPackages, result)
}
// GetStats returns the current statistics
func (c *AnalysisStatsCollector) GetStats() AnalysisStats {
c.mu.RLock()
defer c.mu.RUnlock()
return c.stats
}
// GetBlockedPackages returns all blocked packages
func (c *AnalysisStatsCollector) GetBlockedPackages() []*analyzer.PackageVersionAnalysisResult {
c.mu.RLock()
defer c.mu.RUnlock()
// Return a copy to avoid race conditions
result := make([]*analyzer.PackageVersionAnalysisResult, len(c.blockedPackages))
copy(result, c.blockedPackages)
return result
}
// GetConfirmedPackages returns all confirmed packages
func (c *AnalysisStatsCollector) GetConfirmedPackages() []*analyzer.PackageVersionAnalysisResult {
c.mu.RLock()
defer c.mu.RUnlock()
// Return a copy to avoid race conditions
result := make([]*analyzer.PackageVersionAnalysisResult, len(c.confirmedPackages))
copy(result, c.confirmedPackages)
return result
}
// RecordCooldownBlocked records a package blocked by the dependency cooldown policy.
func (c *AnalysisStatsCollector) RecordCooldownBlocked(name, version string, publishDate time.Time, daysAgo, daysLeft, cooldownDays int) {
c.mu.Lock()
defer c.mu.Unlock()
c.stats.TotalAnalyzed++
c.stats.BlockedCount++
c.stats.CooldownBlockedCount++
c.cooldownBlocks = append(c.cooldownBlocks, models.CooldownBlock{
Name: name,
Version: version,
PublishDate: publishDate,
DaysAgo: daysAgo,
DaysLeft: daysLeft,
CooldownDays: cooldownDays,
})
}
// GetCooldownBlocks returns all packages blocked by the cooldown policy.
func (c *AnalysisStatsCollector) GetCooldownBlocks() []models.CooldownBlock {
c.mu.RLock()
defer c.mu.RUnlock()
result := make([]models.CooldownBlock, len(c.cooldownBlocks))
copy(result, c.cooldownBlocks)
return result
}