mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
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>
This commit is contained in:
co-authored by
Abhisek Datta
parent
64b95b3ad0
commit
987bda5d6a
@@ -87,6 +87,8 @@ func executeSetupInfo() error {
|
|||||||
securityEntries["Trusted Packages"] = "None"
|
securityEntries["Trusted Packages"] = "None"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
securityEntries["Dependency Cooldown"] = strconv.FormatBool(cfg.Config.DependencyCooldown.Enabled)
|
||||||
|
securityEntries["Dependency Cooldown Days"] = strconv.Itoa(cfg.Config.DependencyCooldown.Days)
|
||||||
securityEntries["Event Logging"] = strconv.FormatBool(!cfg.Config.SkipEventLogging)
|
securityEntries["Event Logging"] = strconv.FormatBool(!cfg.Config.SkipEventLogging)
|
||||||
securityEntries["Event Log Directory"] = cfg.EventLogDir()
|
securityEntries["Event Log Directory"] = cfg.EventLogDir()
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var skipDependencyCooldown bool
|
||||||
|
|
||||||
// sandboxAllowRaw holds the raw --sandbox-allow flag values before parsing.
|
// sandboxAllowRaw holds the raw --sandbox-allow flag values before parsing.
|
||||||
var sandboxAllowRaw []string
|
var sandboxAllowRaw []string
|
||||||
|
|
||||||
@@ -39,10 +41,22 @@ func ApplyCobraFlags(cmd *cobra.Command) {
|
|||||||
cmd.PersistentFlags().StringArrayVar(&sandboxAllowRaw, "sandbox-allow",
|
cmd.PersistentFlags().StringArrayVar(&sandboxAllowRaw, "sandbox-allow",
|
||||||
nil, "Add runtime sandbox allow rule (type=value). Types: read, write, exec, net-connect, net-bind")
|
nil, "Add runtime sandbox allow rule (type=value). Types: read, write, exec, net-connect, net-bind")
|
||||||
|
|
||||||
|
cmd.PersistentFlags().BoolVar(&skipDependencyCooldown, "skip-dependency-cooldown",
|
||||||
|
false, "Skip dependency cooldown enforcement")
|
||||||
|
|
||||||
// Hide the experimental proxy mode flag but keep it for backward compatibility
|
// Hide the experimental proxy mode flag but keep it for backward compatibility
|
||||||
_ = cmd.PersistentFlags().MarkHidden("experimental-proxy-mode")
|
_ = cmd.PersistentFlags().MarkHidden("experimental-proxy-mode")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FinalizeDependencyCooldownOverride disables dependency cooldown in the global
|
||||||
|
// config when --skip-dependency-cooldown is set. Must be called after cobra
|
||||||
|
// flag parsing is complete.
|
||||||
|
func FinalizeDependencyCooldownOverride() {
|
||||||
|
if skipDependencyCooldown {
|
||||||
|
globalConfig.Config.DependencyCooldown.Enabled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// FinalizeSandboxAllowOverrides parses the raw --sandbox-allow flag values
|
// FinalizeSandboxAllowOverrides parses the raw --sandbox-allow flag values
|
||||||
// and stores the validated overrides in the global config. This must be called
|
// and stores the validated overrides in the global config. This must be called
|
||||||
// after cobra flag parsing is complete (e.g., in PersistentPreRun).
|
// after cobra flag parsing is complete (e.g., in PersistentPreRun).
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ type Config struct {
|
|||||||
// Sandbox enables sandboxing of package manager processes with controlled filesystem,
|
// Sandbox enables sandboxing of package manager processes with controlled filesystem,
|
||||||
// network, and process execution access. Provides defense-in-depth against supply chain attacks.
|
// network, and process execution access. Provides defense-in-depth against supply chain attacks.
|
||||||
Sandbox SandboxConfig `mapstructure:"sandbox"`
|
Sandbox SandboxConfig `mapstructure:"sandbox"`
|
||||||
|
|
||||||
|
DependencyCooldown DependencyCooldownConfig `mapstructure:"dependency_cooldown"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SandboxConfig configures the sandbox system for isolating package manager processes.
|
// SandboxConfig configures the sandbox system for isolating package manager processes.
|
||||||
@@ -96,6 +98,13 @@ type SandboxConfig struct {
|
|||||||
PolicyTemplates map[string]SandboxPolicyTemplate `mapstructure:"policy_templates"`
|
PolicyTemplates map[string]SandboxPolicyTemplate `mapstructure:"policy_templates"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DependencyCooldownConfig blocks installation of package versions published within a
|
||||||
|
// configurable time window, reducing exposure to supply chain attacks.
|
||||||
|
type DependencyCooldownConfig struct {
|
||||||
|
Enabled bool `mapstructure:"enabled"`
|
||||||
|
Days int `mapstructure:"days"`
|
||||||
|
}
|
||||||
|
|
||||||
// SandboxPolicyTemplate defines a template for a sandbox policy, used to map
|
// SandboxPolicyTemplate defines a template for a sandbox policy, used to map
|
||||||
// a profile name to a path.
|
// a profile name to a path.
|
||||||
type SandboxPolicyTemplate struct {
|
type SandboxPolicyTemplate struct {
|
||||||
@@ -225,6 +234,10 @@ func DefaultConfig() RuntimeConfig {
|
|||||||
Enabled: false,
|
Enabled: false,
|
||||||
EnforceAlways: false,
|
EnforceAlways: false,
|
||||||
},
|
},
|
||||||
|
DependencyCooldown: DependencyCooldownConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Days: 5,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
DryRun: false,
|
DryRun: false,
|
||||||
InsecureInstallation: insecureInstallation,
|
InsecureInstallation: insecureInstallation,
|
||||||
|
|||||||
@@ -137,3 +137,9 @@ sandbox:
|
|||||||
uv:
|
uv:
|
||||||
enabled: true
|
enabled: true
|
||||||
profile: pypi-restrictive
|
profile: pypi-restrictive
|
||||||
|
|
||||||
|
# Dependency cooldown blocks installation of package versions published within
|
||||||
|
# a configurable time window.
|
||||||
|
dependency_cooldown:
|
||||||
|
enabled: true
|
||||||
|
days: 5
|
||||||
|
|||||||
@@ -61,4 +61,7 @@ func TestTemplateMatchesDefaults(t *testing.T) {
|
|||||||
first := parsed.TrustedPackages[0]
|
first := parsed.TrustedPackages[0]
|
||||||
assert.NotEmpty(t, first.Purl, "first trusted package has empty purl")
|
assert.NotEmpty(t, first.Purl, "first trusted package has empty purl")
|
||||||
assert.NotEmpty(t, first.Reason, "first trusted package has empty reason")
|
assert.NotEmpty(t, first.Reason, "first trusted package has empty reason")
|
||||||
|
|
||||||
|
assert.Equal(t, def.DependencyCooldown.Enabled, parsed.DependencyCooldown.Enabled, "dependency_cooldown.enabled mismatch")
|
||||||
|
assert.Equal(t, def.DependencyCooldown.Days, parsed.DependencyCooldown.Days, "dependency_cooldown.days mismatch")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,64 @@ func TestConfigHasDefaultValues(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPartialConfigFallsBackToDefaults(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
t.Setenv("PMG_CONFIG_DIR", tmpDir)
|
||||||
|
|
||||||
|
configPath := filepath.Join(tmpDir, "config.yml")
|
||||||
|
|
||||||
|
// Write a minimal config that only sets a couple of fields,
|
||||||
|
// simulating a user who upgraded PMG without re-running setup
|
||||||
|
partialConfig := []byte("transitive: false\nparanoid: true\n")
|
||||||
|
err := os.WriteFile(configPath, partialConfig, 0o644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
initConfig()
|
||||||
|
config := Get()
|
||||||
|
|
||||||
|
// Explicitly set values should be respected
|
||||||
|
assert.Equal(t, false, config.Config.Transitive)
|
||||||
|
assert.Equal(t, true, config.Config.Paranoid)
|
||||||
|
|
||||||
|
// Missing keys should fall back to DefaultConfig() values, not Go zero values
|
||||||
|
defaults := DefaultConfig().Config
|
||||||
|
assert.Equal(t, defaults.TransitiveDepth, config.Config.TransitiveDepth)
|
||||||
|
assert.Equal(t, defaults.ProxyMode, config.Config.ProxyMode)
|
||||||
|
assert.Equal(t, defaults.Verbosity, config.Config.Verbosity)
|
||||||
|
assert.Equal(t, defaults.EventLogRetentionDays, config.Config.EventLogRetentionDays)
|
||||||
|
assert.Equal(t, defaults.DependencyCooldown.Enabled, config.Config.DependencyCooldown.Enabled)
|
||||||
|
assert.Equal(t, defaults.DependencyCooldown.Days, config.Config.DependencyCooldown.Days)
|
||||||
|
assert.Equal(t, defaults.Sandbox.Enabled, config.Config.Sandbox.Enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPartialConfigWithNestedOverride(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
t.Setenv("PMG_CONFIG_DIR", tmpDir)
|
||||||
|
|
||||||
|
configPath := filepath.Join(tmpDir, "config.yml")
|
||||||
|
|
||||||
|
// Override only one nested field; other nested and top-level fields should keep defaults
|
||||||
|
partialConfig := []byte("dependency_cooldown:\n days: 10\n")
|
||||||
|
err := os.WriteFile(configPath, partialConfig, 0o644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
initConfig()
|
||||||
|
config := Get()
|
||||||
|
|
||||||
|
defaults := DefaultConfig().Config
|
||||||
|
|
||||||
|
// Explicitly set nested value should be respected
|
||||||
|
assert.Equal(t, 10, config.Config.DependencyCooldown.Days)
|
||||||
|
|
||||||
|
// Sibling nested field should fall back to default
|
||||||
|
assert.Equal(t, defaults.DependencyCooldown.Enabled, config.Config.DependencyCooldown.Enabled)
|
||||||
|
|
||||||
|
// Top-level fields should fall back to defaults
|
||||||
|
assert.Equal(t, defaults.Transitive, config.Config.Transitive)
|
||||||
|
assert.Equal(t, defaults.TransitiveDepth, config.Config.TransitiveDepth)
|
||||||
|
assert.Equal(t, defaults.ProxyMode, config.Config.ProxyMode)
|
||||||
|
}
|
||||||
|
|
||||||
func TestWriteTemplateConfigMergesExistingConfig(t *testing.T) {
|
func TestWriteTemplateConfigMergesExistingConfig(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
t.Setenv("PMG_CONFIG_DIR", tmpDir)
|
t.Setenv("PMG_CONFIG_DIR", tmpDir)
|
||||||
|
|||||||
+12
-3
@@ -35,11 +35,20 @@ func loadViperConfig() error {
|
|||||||
return fmt.Errorf("failed to read config file %s: %w", configPath, err)
|
return fmt.Errorf("failed to read config file %s: %w", configPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var loadedConfig Config
|
// Unmarshal into a copy of the current defaults (not a zero-value struct) so that
|
||||||
if err := v.Unmarshal(&loadedConfig); err != nil {
|
// keys missing from the YAML retain their defaults. This is critical for users who
|
||||||
|
// upgrade PMG without re-running "pmg setup install" — their old config.yml won't have
|
||||||
|
// newer keys (e.g. dependency_cooldown), and those must fall back to defaults rather than
|
||||||
|
// silently becoming Go zero values (false/0/"").
|
||||||
|
//
|
||||||
|
// We use a copy rather than unmarshalling directly into globalConfig.Config so that
|
||||||
|
// on error the caller's "using defaults" fallback is truthful — globalConfig.Config
|
||||||
|
// stays in a clean default state instead of being partially overwritten.
|
||||||
|
merged := globalConfig.Config
|
||||||
|
if err := v.Unmarshal(&merged); err != nil {
|
||||||
return fmt.Errorf("failed to unmarshal config: %w", err)
|
return fmt.Errorf("failed to unmarshal config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
globalConfig.Config = loadedConfig
|
globalConfig.Config = merged
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,6 +180,8 @@ func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagema
|
|||||||
reportData.BlockedPackages = statsCollector.GetBlockedPackages()
|
reportData.BlockedPackages = statsCollector.GetBlockedPackages()
|
||||||
reportData.ConfirmedPackages = statsCollector.GetConfirmedPackages()
|
reportData.ConfirmedPackages = statsCollector.GetConfirmedPackages()
|
||||||
|
|
||||||
|
reportData.CooldownBlockedPackages = statsCollector.GetCooldownBlocks()
|
||||||
|
|
||||||
// Set outcome based on execution result using shared inference logic
|
// Set outcome based on execution result using shared inference logic
|
||||||
reportData.Outcome = inferOutcome(cfg.InsecureInstallation, cfg.DryRun, reportData.BlockedCount, stats.UserCancelledCount, executionError)
|
reportData.Outcome = inferOutcome(cfg.InsecureInstallation, cfg.DryRun, reportData.BlockedCount, stats.UserCancelledCount, executionError)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// CooldownBlock records a package blocked by the dependency cooldown policy.
|
||||||
|
type CooldownBlock struct {
|
||||||
|
Name string
|
||||||
|
Version string
|
||||||
|
PublishDate time.Time
|
||||||
|
DaysAgo int
|
||||||
|
DaysLeft int
|
||||||
|
CooldownDays int
|
||||||
|
}
|
||||||
+2
-1
@@ -20,7 +20,8 @@ func PrintInfoSection(title string, entries map[string]string) {
|
|||||||
sort.Strings(keys)
|
sort.Strings(keys)
|
||||||
|
|
||||||
for _, k := range keys {
|
for _, k := range keys {
|
||||||
fmt.Printf("%-25s: %s\n", Colors.Bold(k), entries[k])
|
padded := fmt.Sprintf("%-25s", k)
|
||||||
|
fmt.Printf("%s: %s\n", Colors.Bold(padded), entries[k])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+54
-3
@@ -5,6 +5,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/safedep/pmg/analyzer"
|
"github.com/safedep/pmg/analyzer"
|
||||||
|
"github.com/safedep/pmg/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FlowType indicates which execution flow was used
|
// FlowType indicates which execution flow was used
|
||||||
@@ -78,6 +79,9 @@ type ReportData struct {
|
|||||||
BlockedPackages []*analyzer.PackageVersionAnalysisResult
|
BlockedPackages []*analyzer.PackageVersionAnalysisResult
|
||||||
ConfirmedPackages []*analyzer.PackageVersionAnalysisResult
|
ConfirmedPackages []*analyzer.PackageVersionAnalysisResult
|
||||||
|
|
||||||
|
// Packages blocked by the dependency cooldown policy (proxy mode only)
|
||||||
|
CooldownBlockedPackages []models.CooldownBlock
|
||||||
|
|
||||||
// Configuration context
|
// Configuration context
|
||||||
FlowType FlowType
|
FlowType FlowType
|
||||||
DryRun bool
|
DryRun bool
|
||||||
@@ -106,7 +110,7 @@ func (r *ReportData) Finalize() {
|
|||||||
|
|
||||||
// HasIssues returns true if any packages were blocked or required confirmation
|
// HasIssues returns true if any packages were blocked or required confirmation
|
||||||
func (r *ReportData) HasIssues() bool {
|
func (r *ReportData) HasIssues() bool {
|
||||||
return r.BlockedCount > 0 || r.ConfirmedCount > 0
|
return r.BlockedCount > 0 || r.ConfirmedCount > 0 || len(r.CooldownBlockedPackages) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// WasSuccessful returns true if execution completed without blocks or errors
|
// WasSuccessful returns true if execution completed without blocks or errors
|
||||||
@@ -172,15 +176,33 @@ func reportNormal(data *ReportData) {
|
|||||||
|
|
||||||
switch data.Outcome {
|
switch data.Outcome {
|
||||||
case OutcomeBlocked:
|
case OutcomeBlocked:
|
||||||
|
if len(data.BlockedPackages) > 0 {
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Printf("%s %s\n", Colors.Red("✗"), Colors.Red("Malicious package blocked"))
|
fmt.Printf("%s %s\n", Colors.Red("✗"), Colors.Red("Malicious package blocked"))
|
||||||
|
|
||||||
printMaliciousPackagesList(data.BlockedPackages)
|
printMaliciousPackagesList(data.BlockedPackages)
|
||||||
fmt.Println()
|
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("✗")
|
icon = Colors.Red("✗")
|
||||||
message = fmt.Sprintf("PMG: %d packages analyzed, %d blocked",
|
message = fmt.Sprintf("PMG: %d packages analyzed, %d blocked",
|
||||||
data.TotalAnalyzed, data.BlockedCount)
|
data.TotalAnalyzed, data.BlockedCount)
|
||||||
|
}
|
||||||
case OutcomeUserCancelled:
|
case OutcomeUserCancelled:
|
||||||
icon = Colors.Yellow("✗")
|
icon = Colors.Yellow("✗")
|
||||||
message = fmt.Sprintf("PMG: %d packages analyzed, installation cancelled",
|
message = fmt.Sprintf("PMG: %d packages analyzed, installation cancelled",
|
||||||
@@ -265,6 +287,26 @@ func reportVerbose(data *ReportData) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()
|
fmt.Println()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,7 +315,16 @@ func printOutcomeLine(data *ReportData) {
|
|||||||
case OutcomeSuccess:
|
case OutcomeSuccess:
|
||||||
fmt.Printf(" %s %s\n", Colors.Green("✓"), Colors.Green("Installation completed successfully"))
|
fmt.Printf(" %s %s\n", Colors.Green("✓"), Colors.Green("Installation completed successfully"))
|
||||||
case OutcomeBlocked:
|
case OutcomeBlocked:
|
||||||
fmt.Printf(" %s %s\n", Colors.Red("✗"), Colors.Red("Installation blocked - malicious package detected"))
|
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:
|
case OutcomeUserCancelled:
|
||||||
fmt.Printf(" %s %s\n", Colors.Yellow("✗"), Colors.Yellow("Installation cancelled by user"))
|
fmt.Printf(" %s %s\n", Colors.Yellow("✗"), Colors.Yellow("Installation cancelled by user"))
|
||||||
case OutcomeDryRun:
|
case OutcomeDryRun:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/safedep/pmg/analyzer"
|
"github.com/safedep/pmg/analyzer"
|
||||||
|
"github.com/safedep/pmg/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The UI is internal to PMG and opinionated for the CLI.
|
// The UI is internal to PMG and opinionated for the CLI.
|
||||||
@@ -158,6 +159,43 @@ func printMaliciousPackagesList(malwarePackages []*analyzer.PackageVersionAnalys
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.
|
// Format the string to be maximum maxWidth. Use newlines to wrap the text.
|
||||||
func termWidthFormatText(text string, maxWidth int) string {
|
func termWidthFormatText(text string, maxWidth int) string {
|
||||||
// Replace all newlines with spaces so that we can split the text into words
|
// Replace all newlines with spaces so that we can split the text into words
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ func main() {
|
|||||||
ui.Fatalf("failed to initialize event logging: %v", eventlogErr)
|
ui.Fatalf("failed to initialize event logging: %v", eventlogErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
config.FinalizeDependencyCooldownOverride()
|
||||||
|
|
||||||
// Parse and validate --sandbox-allow flags after all flags are resolved
|
// Parse and validate --sandbox-allow flags after all flags are resolved
|
||||||
if err := config.FinalizeSandboxAllowOverrides(); err != nil {
|
if err := config.FinalizeSandboxAllowOverrides(); err != nil {
|
||||||
ui.Fatalf("pmg: %v", err)
|
ui.Fatalf("pmg: %v", err)
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
package interceptors
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/safedep/dry/log"
|
||||||
|
"github.com/safedep/pmg/proxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
// npmMetadataTimeSkipKeys are non-version keys present in the NPM metadata "time" object.
|
||||||
|
var npmMetadataTimeSkipKeys = map[string]bool{
|
||||||
|
"created": true,
|
||||||
|
"modified": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// npmCooldownHandler handles dependency cooldown for npm packages.
|
||||||
|
// It strips recently-published versions from metadata responses so npm's
|
||||||
|
// resolver naturally falls back to the latest eligible version.
|
||||||
|
type npmCooldownHandler struct {
|
||||||
|
statsCollector *AnalysisStatsCollector
|
||||||
|
}
|
||||||
|
|
||||||
|
func newNpmCooldownHandler(statsCollector *AnalysisStatsCollector) *npmCooldownHandler {
|
||||||
|
return &npmCooldownHandler{
|
||||||
|
statsCollector: statsCollector,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleMetadataRequest overrides the Accept header to force the registry to return
|
||||||
|
// a full packument (which includes publish dates in the "time" field), then registers
|
||||||
|
// a response modifier that strips versions within the cooldown window.
|
||||||
|
func (h *npmCooldownHandler) HandleMetadataRequest(ctx *proxy.RequestContext, packageName string, cooldownDays int) (*proxy.InterceptorResponse, error) {
|
||||||
|
log.Debugf("[%s] Cooldown: registering metadata modifier for %s", ctx.RequestID, packageName)
|
||||||
|
|
||||||
|
// Force full packument so the response always contains the "time" field.
|
||||||
|
// Abbreviated metadata (Accept: application/vnd.npm.install-v1+json) omits it.
|
||||||
|
ctx.Headers.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
// Prevent the server from compressing the response so we can parse the JSON body.
|
||||||
|
// Go's http.Transport only auto-decompresses when it added the Accept-Encoding
|
||||||
|
// header itself; since the client's original header is forwarded by the proxy,
|
||||||
|
// we'd get raw gzip bytes that fail JSON parsing.
|
||||||
|
ctx.Headers.Set("Accept-Encoding", "identity")
|
||||||
|
|
||||||
|
modifier := func(statusCode int, headers http.Header, body []byte) (int, http.Header, []byte, error) {
|
||||||
|
dates, err := h.parseMetadataTime(body)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("[%s] Cooldown: failed to parse metadata time for %s: %v", ctx.RequestID, packageName, err)
|
||||||
|
return statusCode, headers, body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debugf("[%s] Cooldown: parsed %d publish dates for %s", ctx.RequestID, len(dates), packageName)
|
||||||
|
|
||||||
|
strippedBody, stripped, remaining := h.stripCooldownVersions(body, dates, cooldownDays)
|
||||||
|
if stripped > 0 {
|
||||||
|
log.Infof("[%s] Cooldown: stripped %d version(s) from %s metadata (%d days, %d eligible remain)",
|
||||||
|
ctx.RequestID, stripped, packageName, cooldownDays, remaining)
|
||||||
|
|
||||||
|
if remaining == 0 && h.statsCollector != nil {
|
||||||
|
oldestVer, oldestDate := h.oldestVersion(dates)
|
||||||
|
if oldestVer != "" {
|
||||||
|
_, daysAgo, daysLeft := h.isWithinCooldown(oldestDate, cooldownDays)
|
||||||
|
h.statsCollector.RecordCooldownBlocked(packageName, oldestVer, oldestDate, daysAgo, daysLeft, cooldownDays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent npm from caching the modified response. Without this,
|
||||||
|
// npm would serve the stripped metadata from cache even after the
|
||||||
|
// cooldown window passes or settings change.
|
||||||
|
headers.Set("Cache-Control", "no-store")
|
||||||
|
|
||||||
|
return statusCode, headers, strippedBody, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return statusCode, headers, body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &proxy.InterceptorResponse{
|
||||||
|
Action: proxy.ActionModifyResponse,
|
||||||
|
ResponseModifier: modifier,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseMetadataTime extracts version publish dates from an NPM package metadata body.
|
||||||
|
func (h *npmCooldownHandler) parseMetadataTime(body []byte) (map[string]time.Time, error) {
|
||||||
|
var metadata struct {
|
||||||
|
Time map[string]string `json:"time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &metadata); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to unmarshal npm metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata.Time == nil {
|
||||||
|
return map[string]time.Time{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
dates := make(map[string]time.Time, len(metadata.Time))
|
||||||
|
for version, dateStr := range metadata.Time {
|
||||||
|
if npmMetadataTimeSkipKeys[version] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
t, err := time.Parse(time.RFC3339, dateStr)
|
||||||
|
if err != nil {
|
||||||
|
t, err = time.Parse("2006-01-02T15:04:05.000Z", dateStr)
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("Skipping unparseable publish date for version %s: %q", version, dateStr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dates[version] = t
|
||||||
|
}
|
||||||
|
|
||||||
|
return dates, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripCooldownVersions removes versions published within the cooldown window from the
|
||||||
|
// NPM metadata response. It strips entries from "versions", "time", and updates "dist-tags".
|
||||||
|
func (h *npmCooldownHandler) stripCooldownVersions(body []byte, dates map[string]time.Time, cooldownDays int) ([]byte, int, int) {
|
||||||
|
tooNew := make(map[string]bool)
|
||||||
|
for version, publishDate := range dates {
|
||||||
|
if withinCooldown, _, _ := h.isWithinCooldown(publishDate, cooldownDays); withinCooldown {
|
||||||
|
tooNew[version] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining := len(dates) - len(tooNew)
|
||||||
|
|
||||||
|
if len(tooNew) == 0 {
|
||||||
|
return body, 0, remaining
|
||||||
|
}
|
||||||
|
|
||||||
|
var metadata map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(body, &metadata); err != nil {
|
||||||
|
log.Warnf("Cooldown: failed to unmarshal metadata body: %v", err)
|
||||||
|
return body, 0, remaining
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw, ok := metadata["versions"]; ok {
|
||||||
|
var versions map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(raw, &versions); err != nil {
|
||||||
|
log.Warnf("Cooldown: failed to unmarshal versions field: %v", err)
|
||||||
|
} else {
|
||||||
|
for v := range tooNew {
|
||||||
|
delete(versions, v)
|
||||||
|
}
|
||||||
|
if updated, err := json.Marshal(versions); err != nil {
|
||||||
|
log.Warnf("Cooldown: failed to marshal updated versions: %v", err)
|
||||||
|
} else {
|
||||||
|
metadata["versions"] = updated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw, ok := metadata["time"]; ok {
|
||||||
|
var timeMap map[string]string
|
||||||
|
if err := json.Unmarshal(raw, &timeMap); err != nil {
|
||||||
|
log.Warnf("Cooldown: failed to unmarshal time field: %v", err)
|
||||||
|
} else {
|
||||||
|
for v := range tooNew {
|
||||||
|
delete(timeMap, v)
|
||||||
|
}
|
||||||
|
if updated, err := json.Marshal(timeMap); err != nil {
|
||||||
|
log.Warnf("Cooldown: failed to marshal updated time: %v", err)
|
||||||
|
} else {
|
||||||
|
metadata["time"] = updated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw, ok := metadata["dist-tags"]; ok {
|
||||||
|
var distTags map[string]string
|
||||||
|
if err := json.Unmarshal(raw, &distTags); err != nil {
|
||||||
|
log.Warnf("Cooldown: failed to unmarshal dist-tags field: %v", err)
|
||||||
|
} else {
|
||||||
|
changed := false
|
||||||
|
for tag, version := range distTags {
|
||||||
|
if tooNew[version] {
|
||||||
|
latest := h.latestNonCooldownVersion(dates, tooNew)
|
||||||
|
if latest != "" {
|
||||||
|
distTags[tag] = latest
|
||||||
|
} else {
|
||||||
|
delete(distTags, tag)
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
if updated, err := json.Marshal(distTags); err != nil {
|
||||||
|
log.Warnf("Cooldown: failed to marshal updated dist-tags: %v", err)
|
||||||
|
} else {
|
||||||
|
metadata["dist-tags"] = updated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := json.Marshal(metadata)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("Cooldown: failed to marshal final metadata: %v", err)
|
||||||
|
return body, 0, remaining
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, len(tooNew), remaining
|
||||||
|
}
|
||||||
|
|
||||||
|
// oldestVersion returns the version with the earliest publish date.
|
||||||
|
// When all versions are blocked by cooldown, this is the version closest
|
||||||
|
// to exiting the cooldown window (shortest wait for the user).
|
||||||
|
func (h *npmCooldownHandler) oldestVersion(dates map[string]time.Time) (string, time.Time) {
|
||||||
|
var oldest string
|
||||||
|
var oldestTime time.Time
|
||||||
|
|
||||||
|
for version, publishDate := range dates {
|
||||||
|
if oldestTime.IsZero() || publishDate.Before(oldestTime) {
|
||||||
|
oldest = version
|
||||||
|
oldestTime = publishDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return oldest, oldestTime
|
||||||
|
}
|
||||||
|
|
||||||
|
// isWithinCooldown reports whether a version published at publishDate is still
|
||||||
|
// within the cooldown window of cooldownDays. It also returns the number of
|
||||||
|
// whole days since publication.
|
||||||
|
func (h *npmCooldownHandler) isWithinCooldown(publishDate time.Time, cooldownDays int) (withinCooldown bool, daysSincePublish int, daysRemaining int) {
|
||||||
|
daysSincePublish = int(time.Since(publishDate).Hours() / 24)
|
||||||
|
if daysSincePublish < 0 {
|
||||||
|
daysSincePublish = 0
|
||||||
|
}
|
||||||
|
daysRemaining = cooldownDays - daysSincePublish
|
||||||
|
if daysRemaining < 0 {
|
||||||
|
daysRemaining = 0
|
||||||
|
}
|
||||||
|
return daysSincePublish < cooldownDays, daysSincePublish, daysRemaining
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *npmCooldownHandler) latestNonCooldownVersion(dates map[string]time.Time, tooNew map[string]bool) string {
|
||||||
|
var latest string
|
||||||
|
var latestTime time.Time
|
||||||
|
|
||||||
|
for version, publishDate := range dates {
|
||||||
|
if tooNew[version] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if publishDate.After(latestTime) {
|
||||||
|
latest = version
|
||||||
|
latestTime = publishDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return latest
|
||||||
|
}
|
||||||
@@ -0,0 +1,600 @@
|
|||||||
|
package interceptors
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/safedep/pmg/config"
|
||||||
|
"github.com/safedep/pmg/proxy"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setCooldownConfig(t *testing.T, cfg config.DependencyCooldownConfig) {
|
||||||
|
t.Helper()
|
||||||
|
orig := config.Get().Config.DependencyCooldown
|
||||||
|
t.Cleanup(func() { config.Get().Config.DependencyCooldown = orig })
|
||||||
|
config.Get().Config.DependencyCooldown = cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustParseURL(rawURL string) *url.URL {
|
||||||
|
u, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
panic("mustParseURL: " + err.Error())
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseNpmMetadataTime(t *testing.T) {
|
||||||
|
handler := newNpmCooldownHandler(nil)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
body []byte
|
||||||
|
expectedCount int
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid metadata with 3 versions",
|
||||||
|
body: []byte(`{
|
||||||
|
"time": {
|
||||||
|
"created": "2020-01-01T00:00:00.000Z",
|
||||||
|
"modified": "2024-01-01T00:00:00.000Z",
|
||||||
|
"1.0.0": "2020-06-01T00:00:00.000Z",
|
||||||
|
"1.0.1": "2021-06-01T00:00:00.000Z",
|
||||||
|
"1.0.2": "2022-06-01T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
}`),
|
||||||
|
expectedCount: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "metadata without time field",
|
||||||
|
body: []byte(`{"name":"foo","version":"1.0.0"}`),
|
||||||
|
expectedCount: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "only skip keys",
|
||||||
|
body: []byte(`{"time":{"created":"2020-01-01T00:00:00.000Z","modified":"2024-01-01T00:00:00.000Z"}}`),
|
||||||
|
expectedCount: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid JSON",
|
||||||
|
body: []byte(`not-json`),
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unparseable dates skipped",
|
||||||
|
body: []byte(`{
|
||||||
|
"time": {
|
||||||
|
"1.0.0": "not-a-date",
|
||||||
|
"1.0.1": "2022-06-01T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
}`),
|
||||||
|
expectedCount: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "RFC3339 without millis",
|
||||||
|
body: []byte(`{
|
||||||
|
"time": {
|
||||||
|
"1.0.0": "2022-06-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
}`),
|
||||||
|
expectedCount: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "millis precision",
|
||||||
|
body: []byte(`{
|
||||||
|
"time": {
|
||||||
|
"1.0.0": "2022-06-01T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
}`),
|
||||||
|
expectedCount: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty body",
|
||||||
|
body: []byte(``),
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
dates, err := handler.parseMetadataTime(tc.body)
|
||||||
|
if tc.expectError {
|
||||||
|
assert.Error(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tc.expectedCount, len(dates))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseNpmMetadataTime_CorrectDates(t *testing.T) {
|
||||||
|
handler := newNpmCooldownHandler(nil)
|
||||||
|
|
||||||
|
body := []byte(`{
|
||||||
|
"time": {
|
||||||
|
"created": "2021-01-01T00:00:00.000Z",
|
||||||
|
"modified": "2024-01-01T00:00:00.000Z",
|
||||||
|
"4.17.20": "2021-02-17T12:00:00.000Z",
|
||||||
|
"4.17.21": "2021-05-19T12:00:00.000Z"
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
dates, err := handler.parseMetadataTime(body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 2, len(dates))
|
||||||
|
|
||||||
|
d4_17_20, ok := dates["4.17.20"]
|
||||||
|
require.True(t, ok, "expected 4.17.20 in dates")
|
||||||
|
assert.Equal(t, 2021, d4_17_20.Year())
|
||||||
|
assert.Equal(t, time.February, d4_17_20.Month())
|
||||||
|
assert.Equal(t, 17, d4_17_20.Day())
|
||||||
|
|
||||||
|
d4_17_21, ok := dates["4.17.21"]
|
||||||
|
require.True(t, ok, "expected 4.17.21 in dates")
|
||||||
|
assert.Equal(t, 2021, d4_17_21.Year())
|
||||||
|
assert.Equal(t, time.May, d4_17_21.Month())
|
||||||
|
assert.Equal(t, 19, d4_17_21.Day())
|
||||||
|
|
||||||
|
_, hasCreated := dates["created"]
|
||||||
|
assert.False(t, hasCreated)
|
||||||
|
_, hasModified := dates["modified"]
|
||||||
|
assert.False(t, hasModified)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildTestPackument(versions map[string]time.Time, distTags map[string]string) []byte {
|
||||||
|
timeMap := map[string]string{
|
||||||
|
"created": "2020-01-01T00:00:00.000Z",
|
||||||
|
"modified": "2024-01-01T00:00:00.000Z",
|
||||||
|
}
|
||||||
|
versionsMap := map[string]any{}
|
||||||
|
for v, t := range versions {
|
||||||
|
timeMap[v] = t.Format(time.RFC3339)
|
||||||
|
versionsMap[v] = map[string]any{"version": v}
|
||||||
|
}
|
||||||
|
|
||||||
|
packument := map[string]any{
|
||||||
|
"name": "testpkg",
|
||||||
|
"time": timeMap,
|
||||||
|
"versions": versionsMap,
|
||||||
|
"dist-tags": distTags,
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(packument)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripCooldownVersions_MixedVersions(t *testing.T) {
|
||||||
|
handler := newNpmCooldownHandler(nil)
|
||||||
|
now := time.Now()
|
||||||
|
versions := map[string]time.Time{
|
||||||
|
"1.0.0": now.Add(-30 * 24 * time.Hour), // old
|
||||||
|
"1.0.1": now.Add(-10 * 24 * time.Hour), // old
|
||||||
|
"1.0.2": now.Add(-1 * 24 * time.Hour), // too new (within 5d cooldown)
|
||||||
|
}
|
||||||
|
distTags := map[string]string{"latest": "1.0.2"}
|
||||||
|
body := buildTestPackument(versions, distTags)
|
||||||
|
|
||||||
|
dates, err := handler.parseMetadataTime(body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
newBody, stripped, remaining := handler.stripCooldownVersions(body, dates, 5)
|
||||||
|
assert.Equal(t, 1, stripped)
|
||||||
|
assert.Equal(t, 2, remaining)
|
||||||
|
|
||||||
|
var result map[string]json.RawMessage
|
||||||
|
require.NoError(t, json.Unmarshal(newBody, &result))
|
||||||
|
|
||||||
|
var resultVersions map[string]json.RawMessage
|
||||||
|
require.NoError(t, json.Unmarshal(result["versions"], &resultVersions))
|
||||||
|
assert.NotContains(t, resultVersions, "1.0.2")
|
||||||
|
assert.Contains(t, resultVersions, "1.0.0")
|
||||||
|
assert.Contains(t, resultVersions, "1.0.1")
|
||||||
|
|
||||||
|
var resultDistTags map[string]string
|
||||||
|
require.NoError(t, json.Unmarshal(result["dist-tags"], &resultDistTags))
|
||||||
|
// latest should be updated to an older eligible version
|
||||||
|
assert.NotEqual(t, "1.0.2", resultDistTags["latest"])
|
||||||
|
|
||||||
|
var resultTime map[string]string
|
||||||
|
require.NoError(t, json.Unmarshal(result["time"], &resultTime))
|
||||||
|
assert.Contains(t, resultTime, "created")
|
||||||
|
assert.Contains(t, resultTime, "modified")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripCooldownVersions_AllVersionsTooNew(t *testing.T) {
|
||||||
|
handler := newNpmCooldownHandler(nil)
|
||||||
|
now := time.Now()
|
||||||
|
versions := map[string]time.Time{
|
||||||
|
"1.0.0": now.Add(-1 * 24 * time.Hour), // too new
|
||||||
|
"1.0.1": now.Add(-2 * 24 * time.Hour), // too new
|
||||||
|
}
|
||||||
|
distTags := map[string]string{"latest": "1.0.1"}
|
||||||
|
body := buildTestPackument(versions, distTags)
|
||||||
|
|
||||||
|
dates, err := handler.parseMetadataTime(body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
newBody, stripped, remaining := handler.stripCooldownVersions(body, dates, 5)
|
||||||
|
assert.Equal(t, 2, stripped)
|
||||||
|
assert.Equal(t, 0, remaining)
|
||||||
|
|
||||||
|
var result map[string]json.RawMessage
|
||||||
|
require.NoError(t, json.Unmarshal(newBody, &result))
|
||||||
|
|
||||||
|
var resultDistTags map[string]string
|
||||||
|
require.NoError(t, json.Unmarshal(result["dist-tags"], &resultDistTags))
|
||||||
|
// No eligible version exists, dist-tag should be removed
|
||||||
|
assert.Empty(t, resultDistTags)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripCooldownVersions_NoVersionsTooNew(t *testing.T) {
|
||||||
|
handler := newNpmCooldownHandler(nil)
|
||||||
|
now := time.Now()
|
||||||
|
versions := map[string]time.Time{
|
||||||
|
"1.0.0": now.Add(-10 * 24 * time.Hour), // old enough
|
||||||
|
"1.0.1": now.Add(-20 * 24 * time.Hour), // old enough
|
||||||
|
}
|
||||||
|
distTags := map[string]string{"latest": "1.0.0"}
|
||||||
|
body := buildTestPackument(versions, distTags)
|
||||||
|
|
||||||
|
dates, err := handler.parseMetadataTime(body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
newBody, stripped, remaining := handler.stripCooldownVersions(body, dates, 5)
|
||||||
|
assert.Equal(t, 0, stripped)
|
||||||
|
assert.Equal(t, 2, remaining)
|
||||||
|
assert.Equal(t, body, newBody) // body unchanged
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripCooldownVersions_SingleVersionInCooldown(t *testing.T) {
|
||||||
|
handler := newNpmCooldownHandler(nil)
|
||||||
|
now := time.Now()
|
||||||
|
versions := map[string]time.Time{
|
||||||
|
"1.0.0": now.Add(-1 * 24 * time.Hour), // too new
|
||||||
|
}
|
||||||
|
distTags := map[string]string{"latest": "1.0.0"}
|
||||||
|
body := buildTestPackument(versions, distTags)
|
||||||
|
|
||||||
|
dates, err := handler.parseMetadataTime(body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, stripped, remaining := handler.stripCooldownVersions(body, dates, 5)
|
||||||
|
assert.Equal(t, 1, stripped)
|
||||||
|
assert.Equal(t, 0, remaining)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripCooldownVersions_MalformedJSON(t *testing.T) {
|
||||||
|
handler := newNpmCooldownHandler(nil)
|
||||||
|
body := []byte(`not-json`)
|
||||||
|
dates := map[string]time.Time{"1.0.0": time.Now().Add(-1 * time.Hour)}
|
||||||
|
|
||||||
|
newBody, stripped, _ := handler.stripCooldownVersions(body, dates, 5)
|
||||||
|
assert.Equal(t, 0, stripped)
|
||||||
|
assert.Equal(t, body, newBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeTestRequestContext(rawURL string) *proxy.RequestContext {
|
||||||
|
u := mustParseURL(rawURL)
|
||||||
|
return &proxy.RequestContext{
|
||||||
|
URL: u,
|
||||||
|
Method: "GET",
|
||||||
|
Headers: http.Header{},
|
||||||
|
Hostname: u.Host,
|
||||||
|
RequestID: "test-req-1",
|
||||||
|
StartTime: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNpmCooldown_HandleMetadataRequest_OverridesHeaders(t *testing.T) {
|
||||||
|
collector := NewAnalysisStatsCollector()
|
||||||
|
handler := newNpmCooldownHandler(collector)
|
||||||
|
|
||||||
|
ctx := makeTestRequestContext("https://registry.npmjs.org/lodash")
|
||||||
|
ctx.Headers.Set("Accept", "application/vnd.npm.install-v1+json")
|
||||||
|
ctx.Headers.Set("Accept-Encoding", "gzip")
|
||||||
|
|
||||||
|
resp, err := handler.HandleMetadataRequest(ctx, "lodash", 5)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, proxy.ActionModifyResponse, resp.Action)
|
||||||
|
assert.Equal(t, "application/json", ctx.Headers.Get("Accept"))
|
||||||
|
assert.Equal(t, "identity", ctx.Headers.Get("Accept-Encoding"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNpmCooldown_HandleMetadataRequest_StripsRecentVersions(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
versions := map[string]time.Time{
|
||||||
|
"1.0.0": now.Add(-30 * 24 * time.Hour), // old
|
||||||
|
"1.0.1": now.Add(-1 * 24 * time.Hour), // too new
|
||||||
|
}
|
||||||
|
distTags := map[string]string{"latest": "1.0.1"}
|
||||||
|
body := buildTestPackument(versions, distTags)
|
||||||
|
|
||||||
|
collector := NewAnalysisStatsCollector()
|
||||||
|
handler := newNpmCooldownHandler(collector)
|
||||||
|
ctx := makeTestRequestContext("https://registry.npmjs.org/testpkg")
|
||||||
|
|
||||||
|
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, resp.ResponseModifier)
|
||||||
|
|
||||||
|
newStatus, newHeaders, newBody, err := resp.ResponseModifier(200, http.Header{}, body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 200, newStatus)
|
||||||
|
_ = newHeaders
|
||||||
|
|
||||||
|
var result map[string]json.RawMessage
|
||||||
|
require.NoError(t, json.Unmarshal(newBody, &result))
|
||||||
|
|
||||||
|
var resultVersions map[string]json.RawMessage
|
||||||
|
require.NoError(t, json.Unmarshal(result["versions"], &resultVersions))
|
||||||
|
assert.Contains(t, resultVersions, "1.0.0")
|
||||||
|
assert.NotContains(t, resultVersions, "1.0.1")
|
||||||
|
|
||||||
|
var resultDistTags map[string]string
|
||||||
|
require.NoError(t, json.Unmarshal(result["dist-tags"], &resultDistTags))
|
||||||
|
assert.Equal(t, "1.0.0", resultDistTags["latest"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNpmCooldown_HandleMetadataRequest_NoVersionsInCooldown(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
versions := map[string]time.Time{
|
||||||
|
"1.0.0": now.Add(-30 * 24 * time.Hour), // old
|
||||||
|
"1.0.1": now.Add(-20 * 24 * time.Hour), // old
|
||||||
|
}
|
||||||
|
distTags := map[string]string{"latest": "1.0.1"}
|
||||||
|
body := buildTestPackument(versions, distTags)
|
||||||
|
|
||||||
|
collector := NewAnalysisStatsCollector()
|
||||||
|
handler := newNpmCooldownHandler(collector)
|
||||||
|
ctx := makeTestRequestContext("https://registry.npmjs.org/testpkg")
|
||||||
|
|
||||||
|
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, resp.ResponseModifier)
|
||||||
|
|
||||||
|
_, _, newBody, err := resp.ResponseModifier(200, http.Header{}, body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, body, newBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNpmCooldown_HandleMetadataRequest_AllVersionsInCooldown_RecordsStats(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
versions := map[string]time.Time{
|
||||||
|
"1.0.0": now.Add(-1 * 24 * time.Hour), // too new
|
||||||
|
}
|
||||||
|
distTags := map[string]string{"latest": "1.0.0"}
|
||||||
|
body := buildTestPackument(versions, distTags)
|
||||||
|
|
||||||
|
collector := NewAnalysisStatsCollector()
|
||||||
|
handler := newNpmCooldownHandler(collector)
|
||||||
|
ctx := makeTestRequestContext("https://registry.npmjs.org/newpkg")
|
||||||
|
|
||||||
|
resp, err := handler.HandleMetadataRequest(ctx, "newpkg", 5)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, resp.ResponseModifier)
|
||||||
|
|
||||||
|
_, _, _, err = resp.ResponseModifier(200, http.Header{}, body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
blocks := collector.GetCooldownBlocks()
|
||||||
|
require.Len(t, blocks, 1)
|
||||||
|
assert.Equal(t, "newpkg", blocks[0].Name)
|
||||||
|
assert.Equal(t, "1.0.0", blocks[0].Version)
|
||||||
|
assert.Equal(t, 5, blocks[0].CooldownDays)
|
||||||
|
|
||||||
|
stats := collector.GetStats()
|
||||||
|
assert.Equal(t, 1, stats.CooldownBlockedCount)
|
||||||
|
assert.Equal(t, 1, stats.BlockedCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNpmCooldown_HandleMetadataRequest_AllVersionsInCooldown_ReportsOldestVersion(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
versions := map[string]time.Time{
|
||||||
|
"1.0.0": now.Add(-90 * 24 * time.Hour), // oldest — closest to exiting cooldown
|
||||||
|
"2.0.0": now.Add(-30 * 24 * time.Hour),
|
||||||
|
"2.1.0": now.Add(-1 * 24 * time.Hour), // newest — farthest from exiting cooldown
|
||||||
|
}
|
||||||
|
distTags := map[string]string{"latest": "2.1.0"}
|
||||||
|
body := buildTestPackument(versions, distTags)
|
||||||
|
|
||||||
|
collector := NewAnalysisStatsCollector()
|
||||||
|
handler := newNpmCooldownHandler(collector)
|
||||||
|
ctx := makeTestRequestContext("https://registry.npmjs.org/multipkg")
|
||||||
|
|
||||||
|
resp, err := handler.HandleMetadataRequest(ctx, "multipkg", 100)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, _, _, err = resp.ResponseModifier(200, http.Header{}, body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
blocks := collector.GetCooldownBlocks()
|
||||||
|
require.Len(t, blocks, 1)
|
||||||
|
assert.Equal(t, "1.0.0", blocks[0].Version, "should report oldest version (closest to exiting cooldown)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNpmCooldown_HandleMetadataRequest_MalformedJSON_FailOpen(t *testing.T) {
|
||||||
|
body := []byte(`not-json`)
|
||||||
|
collector := NewAnalysisStatsCollector()
|
||||||
|
handler := newNpmCooldownHandler(collector)
|
||||||
|
ctx := makeTestRequestContext("https://registry.npmjs.org/badpkg")
|
||||||
|
|
||||||
|
resp, err := handler.HandleMetadataRequest(ctx, "badpkg", 5)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, resp.ResponseModifier)
|
||||||
|
|
||||||
|
_, _, newBody, err := resp.ResponseModifier(200, http.Header{}, body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, body, newBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNpmCooldown_InterceptorDelegation_CooldownEnabled(t *testing.T) {
|
||||||
|
setCooldownConfig(t, config.DependencyCooldownConfig{Enabled: true, Days: 5})
|
||||||
|
|
||||||
|
interceptor := NewNpmRegistryInterceptor(nil, NewInMemoryAnalysisCache(), NewAnalysisStatsCollector(), make(chan *ConfirmationRequest, 1))
|
||||||
|
|
||||||
|
ctx := makeTestRequestContext("https://registry.npmjs.org/lodash")
|
||||||
|
ctx.Hostname = "registry.npmjs.org"
|
||||||
|
ctx.Headers.Set("Accept", "application/vnd.npm.install-v1+json")
|
||||||
|
|
||||||
|
resp, err := interceptor.HandleRequest(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, proxy.ActionModifyResponse, resp.Action)
|
||||||
|
assert.Equal(t, "application/json", ctx.Headers.Get("Accept"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNpmCooldown_InterceptorDelegation_CooldownDisabled(t *testing.T) {
|
||||||
|
setCooldownConfig(t, config.DependencyCooldownConfig{Enabled: false, Days: 5})
|
||||||
|
|
||||||
|
interceptor := NewNpmRegistryInterceptor(nil, NewInMemoryAnalysisCache(), NewAnalysisStatsCollector(), make(chan *ConfirmationRequest, 1))
|
||||||
|
|
||||||
|
ctx := makeTestRequestContext("https://registry.npmjs.org/lodash")
|
||||||
|
ctx.Hostname = "registry.npmjs.org"
|
||||||
|
ctx.Headers.Set("Accept", "application/vnd.npm.install-v1+json")
|
||||||
|
|
||||||
|
resp, err := interceptor.HandleRequest(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, proxy.ActionAllow, resp.Action)
|
||||||
|
// Accept header should NOT be modified when cooldown is disabled
|
||||||
|
assert.Equal(t, "application/vnd.npm.install-v1+json", ctx.Headers.Get("Accept"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNpmCooldown_TarballRequestBypassesCooldown(t *testing.T) {
|
||||||
|
setCooldownConfig(t, config.DependencyCooldownConfig{Enabled: true, Days: 5})
|
||||||
|
|
||||||
|
// Use InsecureInstallation to skip the analyzer (which would fail without a real backend)
|
||||||
|
origInsecure := config.Get().InsecureInstallation
|
||||||
|
config.Get().InsecureInstallation = true
|
||||||
|
t.Cleanup(func() { config.Get().InsecureInstallation = origInsecure })
|
||||||
|
|
||||||
|
interceptor := NewNpmRegistryInterceptor(nil, NewInMemoryAnalysisCache(), NewAnalysisStatsCollector(), make(chan *ConfirmationRequest, 1))
|
||||||
|
|
||||||
|
// Tarball URL has a version component
|
||||||
|
ctx := makeTestRequestContext("https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz")
|
||||||
|
ctx.Hostname = "registry.npmjs.org"
|
||||||
|
|
||||||
|
resp, err := interceptor.HandleRequest(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
// Tarball should be allowed (bypasses cooldown, goes to analysis)
|
||||||
|
assert.Equal(t, proxy.ActionAllow, resp.Action)
|
||||||
|
// Accept header should not be set to application/json for tarball requests
|
||||||
|
assert.NotEqual(t, proxy.ActionModifyResponse, resp.Action)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsWithinCooldown(t *testing.T) {
|
||||||
|
h := newNpmCooldownHandler(nil)
|
||||||
|
now := time.Now()
|
||||||
|
day := 24 * time.Hour
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
publishDate time.Time
|
||||||
|
cooldownDays int
|
||||||
|
wantWithinCooldown bool
|
||||||
|
wantDaysSincePublish int
|
||||||
|
wantDaysRemaining int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "published today with 30 day cooldown",
|
||||||
|
publishDate: now,
|
||||||
|
cooldownDays: 30,
|
||||||
|
wantWithinCooldown: true,
|
||||||
|
wantDaysSincePublish: 0,
|
||||||
|
wantDaysRemaining: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "published exactly at cooldown boundary",
|
||||||
|
publishDate: now.Add(-30 * day),
|
||||||
|
cooldownDays: 30,
|
||||||
|
wantWithinCooldown: false,
|
||||||
|
wantDaysSincePublish: 30,
|
||||||
|
wantDaysRemaining: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "published one day before cooldown expires",
|
||||||
|
publishDate: now.Add(-29 * day),
|
||||||
|
cooldownDays: 30,
|
||||||
|
wantWithinCooldown: true,
|
||||||
|
wantDaysSincePublish: 29,
|
||||||
|
wantDaysRemaining: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "published well beyond cooldown",
|
||||||
|
publishDate: now.Add(-365 * day),
|
||||||
|
cooldownDays: 30,
|
||||||
|
wantWithinCooldown: false,
|
||||||
|
wantDaysSincePublish: 365,
|
||||||
|
wantDaysRemaining: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zero cooldown days",
|
||||||
|
publishDate: now,
|
||||||
|
cooldownDays: 0,
|
||||||
|
wantWithinCooldown: false,
|
||||||
|
wantDaysSincePublish: 0,
|
||||||
|
wantDaysRemaining: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "future publish date is clamped to zero days",
|
||||||
|
publishDate: now.Add(5 * day),
|
||||||
|
cooldownDays: 30,
|
||||||
|
wantWithinCooldown: true,
|
||||||
|
wantDaysSincePublish: 0,
|
||||||
|
wantDaysRemaining: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "large cooldown days that previously caused overflow",
|
||||||
|
publishDate: now.Add(-10 * day),
|
||||||
|
cooldownDays: 1000000,
|
||||||
|
wantWithinCooldown: true,
|
||||||
|
wantDaysSincePublish: 10,
|
||||||
|
wantDaysRemaining: 999990,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "max int cooldown days does not overflow",
|
||||||
|
publishDate: now.Add(-1 * day),
|
||||||
|
cooldownDays: int(^uint(0) >> 1), // math.MaxInt
|
||||||
|
wantWithinCooldown: true,
|
||||||
|
wantDaysSincePublish: 1,
|
||||||
|
wantDaysRemaining: int(^uint(0)>>1) - 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "very old publish date well past cooldown",
|
||||||
|
publishDate: now.Add(-3652 * day),
|
||||||
|
cooldownDays: 30,
|
||||||
|
wantWithinCooldown: false,
|
||||||
|
wantDaysSincePublish: 3652,
|
||||||
|
wantDaysRemaining: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "one day cooldown with publish yesterday",
|
||||||
|
publishDate: now.Add(-1 * day),
|
||||||
|
cooldownDays: 1,
|
||||||
|
wantWithinCooldown: false,
|
||||||
|
wantDaysSincePublish: 1,
|
||||||
|
wantDaysRemaining: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "one day cooldown with publish today",
|
||||||
|
publishDate: now,
|
||||||
|
cooldownDays: 1,
|
||||||
|
wantWithinCooldown: true,
|
||||||
|
wantDaysSincePublish: 0,
|
||||||
|
wantDaysRemaining: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
withinCooldown, daysSincePublish, daysRemaining := h.isWithinCooldown(tt.publishDate, tt.cooldownDays)
|
||||||
|
assert.Equal(t, tt.wantWithinCooldown, withinCooldown, "withinCooldown")
|
||||||
|
assert.Equal(t, tt.wantDaysSincePublish, daysSincePublish, "daysSincePublish")
|
||||||
|
assert.Equal(t, tt.wantDaysRemaining, daysRemaining, "daysRemaining")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
|
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
|
||||||
"github.com/safedep/dry/log"
|
"github.com/safedep/dry/log"
|
||||||
"github.com/safedep/pmg/analyzer"
|
"github.com/safedep/pmg/analyzer"
|
||||||
|
pmgconfig "github.com/safedep/pmg/config"
|
||||||
"github.com/safedep/pmg/proxy"
|
"github.com/safedep/pmg/proxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ var npmRegistryDomains = registryConfigMap{
|
|||||||
// It embeds baseRegistryInterceptor to reuse ecosystem agnostic functionality
|
// It embeds baseRegistryInterceptor to reuse ecosystem agnostic functionality
|
||||||
type NpmRegistryInterceptor struct {
|
type NpmRegistryInterceptor struct {
|
||||||
baseRegistryInterceptor
|
baseRegistryInterceptor
|
||||||
|
cooldownHandler *npmCooldownHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ proxy.Interceptor = (*NpmRegistryInterceptor)(nil)
|
var _ proxy.Interceptor = (*NpmRegistryInterceptor)(nil)
|
||||||
@@ -54,6 +56,7 @@ func NewNpmRegistryInterceptor(
|
|||||||
confirmationChan: confirmationChan,
|
confirmationChan: confirmationChan,
|
||||||
circuitBreaker: newAnalyzerCircuitBreaker("malysis-analyzer-npm"),
|
circuitBreaker: newAnalyzerCircuitBreaker("malysis-analyzer-npm"),
|
||||||
},
|
},
|
||||||
|
cooldownHandler: newNpmCooldownHandler(statsCollector),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,9 +107,13 @@ func (i *NpmRegistryInterceptor) HandleRequest(ctx *proxy.RequestContext) (*prox
|
|||||||
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
|
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only analyze tarball downloads (these have a specific version)
|
depCooldownConfig := pmgconfig.Get().Config.DependencyCooldown
|
||||||
// Metadata requests (without version) are allowed through
|
|
||||||
if !pkgInfo.IsFileDownload() {
|
if !pkgInfo.IsFileDownload() {
|
||||||
|
if depCooldownConfig.Enabled {
|
||||||
|
return i.cooldownHandler.HandleMetadataRequest(ctx, pkgInfo.GetName(), depCooldownConfig.Days)
|
||||||
|
}
|
||||||
|
|
||||||
log.Debugf("[%s] Skipping analysis for metadata request: %s", ctx.RequestID, pkgInfo.GetName())
|
log.Debugf("[%s] Skipping analysis for metadata request: %s", ctx.RequestID, pkgInfo.GetName())
|
||||||
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
|
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ package interceptors
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/safedep/pmg/analyzer"
|
"github.com/safedep/pmg/analyzer"
|
||||||
|
"github.com/safedep/pmg/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AnalysisStats contains aggregated statistics from analysis results
|
// AnalysisStats contains aggregated statistics from analysis results
|
||||||
@@ -13,6 +15,7 @@ type AnalysisStats struct {
|
|||||||
ConfirmedCount int
|
ConfirmedCount int
|
||||||
BlockedCount int
|
BlockedCount int
|
||||||
UserCancelledCount int
|
UserCancelledCount int
|
||||||
|
CooldownBlockedCount int
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnalysisStatsCollector tracks analysis statistics during proxy execution.
|
// AnalysisStatsCollector tracks analysis statistics during proxy execution.
|
||||||
@@ -23,6 +26,7 @@ type AnalysisStatsCollector struct {
|
|||||||
stats AnalysisStats
|
stats AnalysisStats
|
||||||
blockedPackages []*analyzer.PackageVersionAnalysisResult
|
blockedPackages []*analyzer.PackageVersionAnalysisResult
|
||||||
confirmedPackages []*analyzer.PackageVersionAnalysisResult
|
confirmedPackages []*analyzer.PackageVersionAnalysisResult
|
||||||
|
cooldownBlocks []models.CooldownBlock
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAnalysisStatsCollector creates a new stats collector
|
// NewAnalysisStatsCollector creates a new stats collector
|
||||||
@@ -118,3 +122,31 @@ func (c *AnalysisStatsCollector) GetConfirmedPackages() []*analyzer.PackageVersi
|
|||||||
copy(result, c.confirmedPackages)
|
copy(result, c.confirmedPackages)
|
||||||
return result
|
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
|
||||||
|
}
|
||||||
|
|||||||
+29
-3
@@ -1,9 +1,11 @@
|
|||||||
package proxy
|
package proxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -518,9 +520,33 @@ func (ps *proxyServer) registerHandlers() {
|
|||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement response body modification
|
body, err := io.ReadAll(resp.Body)
|
||||||
// This requires buffering the response body, modifying it, and creating a new response
|
if closeErr := resp.Body.Close(); closeErr != nil {
|
||||||
// For now, lets skip it
|
log.Warnf("[%s] Failed to close response body: %v", reqCtx.RequestID, closeErr)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("[%s] Failed to read response body for modifier: %v", reqCtx.RequestID, err)
|
||||||
|
errMsg := []byte("PMG: failed to read response from upstream registry")
|
||||||
|
resp.StatusCode = http.StatusServiceUnavailable
|
||||||
|
resp.Status = ""
|
||||||
|
resp.Body = io.NopCloser(bytes.NewReader(errMsg))
|
||||||
|
resp.ContentLength = int64(len(errMsg))
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
newStatusCode, newHeaders, newBody, err := modifier(resp.StatusCode, resp.Header, body)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("[%s] Response modifier error: %v", reqCtx.RequestID, err)
|
||||||
|
resp.Body = io.NopCloser(bytes.NewReader(body))
|
||||||
|
resp.ContentLength = int64(len(body))
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.StatusCode = newStatusCode
|
||||||
|
resp.Status = ""
|
||||||
|
resp.Header = newHeaders
|
||||||
|
resp.Body = io.NopCloser(bytes.NewReader(newBody))
|
||||||
|
resp.ContentLength = int64(len(newBody))
|
||||||
|
|
||||||
return resp
|
return resp
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user