diff --git a/cmd/setup/info.go b/cmd/setup/info.go index 68cf552..acf40f5 100644 --- a/cmd/setup/info.go +++ b/cmd/setup/info.go @@ -87,6 +87,8 @@ func executeSetupInfo() error { 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 Log Directory"] = cfg.EventLogDir() diff --git a/config/cobra.go b/config/cobra.go index 2e496cd..25eda7d 100644 --- a/config/cobra.go +++ b/config/cobra.go @@ -6,6 +6,8 @@ import ( "github.com/spf13/cobra" ) +var skipDependencyCooldown bool + // sandboxAllowRaw holds the raw --sandbox-allow flag values before parsing. var sandboxAllowRaw []string @@ -39,10 +41,22 @@ func ApplyCobraFlags(cmd *cobra.Command) { cmd.PersistentFlags().StringArrayVar(&sandboxAllowRaw, "sandbox-allow", 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 _ = 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 // and stores the validated overrides in the global config. This must be called // after cobra flag parsing is complete (e.g., in PersistentPreRun). diff --git a/config/config.go b/config/config.go index d4d6a3e..2e500a6 100644 --- a/config/config.go +++ b/config/config.go @@ -76,6 +76,8 @@ type Config struct { // Sandbox enables sandboxing of package manager processes with controlled filesystem, // network, and process execution access. Provides defense-in-depth against supply chain attacks. Sandbox SandboxConfig `mapstructure:"sandbox"` + + DependencyCooldown DependencyCooldownConfig `mapstructure:"dependency_cooldown"` } // SandboxConfig configures the sandbox system for isolating package manager processes. @@ -96,6 +98,13 @@ type SandboxConfig struct { 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 // a profile name to a path. type SandboxPolicyTemplate struct { @@ -225,6 +234,10 @@ func DefaultConfig() RuntimeConfig { Enabled: false, EnforceAlways: false, }, + DependencyCooldown: DependencyCooldownConfig{ + Enabled: true, + Days: 5, + }, }, DryRun: false, InsecureInstallation: insecureInstallation, diff --git a/config/config.template.yml b/config/config.template.yml index 3db1c09..f7d016e 100644 --- a/config/config.template.yml +++ b/config/config.template.yml @@ -137,3 +137,9 @@ sandbox: uv: enabled: true profile: pypi-restrictive + +# Dependency cooldown blocks installation of package versions published within +# a configurable time window. +dependency_cooldown: + enabled: true + days: 5 diff --git a/config/config_template_test.go b/config/config_template_test.go index 7174348..79cea31 100644 --- a/config/config_template_test.go +++ b/config/config_template_test.go @@ -61,4 +61,7 @@ func TestTemplateMatchesDefaults(t *testing.T) { first := parsed.TrustedPackages[0] assert.NotEmpty(t, first.Purl, "first trusted package has empty purl") 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") } diff --git a/config/config_test.go b/config/config_test.go index 69b22c6..791404f 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -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) { tmpDir := t.TempDir() t.Setenv("PMG_CONFIG_DIR", tmpDir) diff --git a/config/viper.go b/config/viper.go index 16fcf7b..12aa026 100644 --- a/config/viper.go +++ b/config/viper.go @@ -35,11 +35,20 @@ func loadViperConfig() error { return fmt.Errorf("failed to read config file %s: %w", configPath, err) } - var loadedConfig Config - if err := v.Unmarshal(&loadedConfig); err != nil { + // Unmarshal into a copy of the current defaults (not a zero-value struct) so that + // 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) } - globalConfig.Config = loadedConfig + globalConfig.Config = merged return nil } diff --git a/internal/flows/proxy_flow.go b/internal/flows/proxy_flow.go index 45901d1..1879852 100644 --- a/internal/flows/proxy_flow.go +++ b/internal/flows/proxy_flow.go @@ -180,6 +180,8 @@ func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagema reportData.BlockedPackages = statsCollector.GetBlockedPackages() reportData.ConfirmedPackages = statsCollector.GetConfirmedPackages() + reportData.CooldownBlockedPackages = statsCollector.GetCooldownBlocks() + // Set outcome based on execution result using shared inference logic reportData.Outcome = inferOutcome(cfg.InsecureInstallation, cfg.DryRun, reportData.BlockedCount, stats.UserCancelledCount, executionError) diff --git a/internal/models/cooldown.go b/internal/models/cooldown.go new file mode 100644 index 0000000..3a66264 --- /dev/null +++ b/internal/models/cooldown.go @@ -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 +} diff --git a/internal/ui/info.go b/internal/ui/info.go index dbaf7ca..6399ebb 100644 --- a/internal/ui/info.go +++ b/internal/ui/info.go @@ -20,7 +20,8 @@ func PrintInfoSection(title string, entries map[string]string) { sort.Strings(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]) } } diff --git a/internal/ui/report.go b/internal/ui/report.go index eb2b5df..3c42963 100644 --- a/internal/ui/report.go +++ b/internal/ui/report.go @@ -5,6 +5,7 @@ import ( "time" "github.com/safedep/pmg/analyzer" + "github.com/safedep/pmg/internal/models" ) // FlowType indicates which execution flow was used @@ -78,6 +79,9 @@ type ReportData struct { 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 @@ -106,7 +110,7 @@ func (r *ReportData) Finalize() { // HasIssues returns true if any packages were blocked or required confirmation 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 @@ -172,15 +176,33 @@ func reportNormal(data *ReportData) { switch data.Outcome { case OutcomeBlocked: - fmt.Println() - fmt.Printf("%s %s\n", Colors.Red("✗"), Colors.Red("Malicious package blocked")) + if len(data.BlockedPackages) > 0 { + fmt.Println() + fmt.Printf("%s %s\n", Colors.Red("✗"), Colors.Red("Malicious package blocked")) + printMaliciousPackagesList(data.BlockedPackages) + fmt.Println() + } - 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() + } - icon = Colors.Red("✗") - message = fmt.Sprintf("PMG: %d packages analyzed, %d blocked", - data.TotalAnalyzed, data.BlockedCount) + 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", @@ -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() } @@ -273,7 +315,16 @@ func printOutcomeLine(data *ReportData) { case OutcomeSuccess: fmt.Printf(" %s %s\n", Colors.Green("✓"), Colors.Green("Installation completed successfully")) 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: fmt.Printf(" %s %s\n", Colors.Yellow("✗"), Colors.Yellow("Installation cancelled by user")) case OutcomeDryRun: diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 9378851..6b2faa1 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/safedep/pmg/analyzer" + "github.com/safedep/pmg/internal/models" ) // 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. func termWidthFormatText(text string, maxWidth int) string { // Replace all newlines with spaces so that we can split the text into words diff --git a/main.go b/main.go index 2e7f3c9..10bed10 100644 --- a/main.go +++ b/main.go @@ -84,6 +84,8 @@ func main() { ui.Fatalf("failed to initialize event logging: %v", eventlogErr) } + config.FinalizeDependencyCooldownOverride() + // Parse and validate --sandbox-allow flags after all flags are resolved if err := config.FinalizeSandboxAllowOverrides(); err != nil { ui.Fatalf("pmg: %v", err) diff --git a/proxy/interceptors/npm_cooldown.go b/proxy/interceptors/npm_cooldown.go new file mode 100644 index 0000000..c0e2b3c --- /dev/null +++ b/proxy/interceptors/npm_cooldown.go @@ -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 +} diff --git a/proxy/interceptors/npm_cooldown_test.go b/proxy/interceptors/npm_cooldown_test.go new file mode 100644 index 0000000..98e50d8 --- /dev/null +++ b/proxy/interceptors/npm_cooldown_test.go @@ -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") + }) + } +} diff --git a/proxy/interceptors/npm_registry.go b/proxy/interceptors/npm_registry.go index 6cc683a..7ddd075 100644 --- a/proxy/interceptors/npm_registry.go +++ b/proxy/interceptors/npm_registry.go @@ -4,6 +4,7 @@ import ( packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" "github.com/safedep/dry/log" "github.com/safedep/pmg/analyzer" + pmgconfig "github.com/safedep/pmg/config" "github.com/safedep/pmg/proxy" ) @@ -34,6 +35,7 @@ var npmRegistryDomains = registryConfigMap{ // It embeds baseRegistryInterceptor to reuse ecosystem agnostic functionality type NpmRegistryInterceptor struct { baseRegistryInterceptor + cooldownHandler *npmCooldownHandler } var _ proxy.Interceptor = (*NpmRegistryInterceptor)(nil) @@ -54,6 +56,7 @@ func NewNpmRegistryInterceptor( confirmationChan: confirmationChan, 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 } - // Only analyze tarball downloads (these have a specific version) - // Metadata requests (without version) are allowed through + depCooldownConfig := pmgconfig.Get().Config.DependencyCooldown + 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()) return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil } diff --git a/proxy/interceptors/stats.go b/proxy/interceptors/stats.go index 4cd058e..24f3c4b 100644 --- a/proxy/interceptors/stats.go +++ b/proxy/interceptors/stats.go @@ -2,17 +2,20 @@ 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 + TotalAnalyzed int + AllowedCount int + ConfirmedCount int + BlockedCount int + UserCancelledCount int + CooldownBlockedCount int } // AnalysisStatsCollector tracks analysis statistics during proxy execution. @@ -23,6 +26,7 @@ type AnalysisStatsCollector struct { stats AnalysisStats blockedPackages []*analyzer.PackageVersionAnalysisResult confirmedPackages []*analyzer.PackageVersionAnalysisResult + cooldownBlocks []models.CooldownBlock } // NewAnalysisStatsCollector creates a new stats collector @@ -118,3 +122,31 @@ func (c *AnalysisStatsCollector) GetConfirmedPackages() []*analyzer.PackageVersi 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 +} diff --git a/proxy/proxy.go b/proxy/proxy.go index 8518b7b..a257015 100644 --- a/proxy/proxy.go +++ b/proxy/proxy.go @@ -1,9 +1,11 @@ package proxy import ( + "bytes" "context" "crypto/tls" "fmt" + "io" "net" "net/http" "net/url" @@ -518,9 +520,33 @@ func (ps *proxyServer) registerHandlers() { return resp } - // TODO: Implement response body modification - // This requires buffering the response body, modifying it, and creating a new response - // For now, lets skip it + body, err := io.ReadAll(resp.Body) + if closeErr := resp.Body.Close(); closeErr != nil { + 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 })