add dependencies cooldown

This commit is contained in:
Sahilb315
2026-04-03 21:25:50 +05:30
parent 635e40cc1f
commit ad379de2f3
10 changed files with 983 additions and 45 deletions
+14
View File
@@ -76,6 +76,16 @@ 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 blocks installation of recently-published package versions.
DependencyCooldown DependencyCooldownConfig `mapstructure:"dependency_cooldown"`
}
// 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"`
}
// SandboxConfig configures the sandbox system for isolating package manager processes.
@@ -225,6 +235,10 @@ func DefaultConfig() RuntimeConfig {
Enabled: false,
EnforceAlways: false,
},
DependencyCooldown: DependencyCooldownConfig{
Enabled: true,
Days: 5,
},
},
DryRun: false,
InsecureInstallation: insecureInstallation,
+16
View File
@@ -137,3 +137,19 @@ sandbox:
uv:
enabled: true
profile: pypi-restrictive
# Dependency cooldown blocks installation of package versions published within a configurable
# time window. Malicious packages are often caught within the first few days of publication,
# so enforcing a cooldown period reduces exposure to supply chain attacks.
#
# When a package version is published within the cooldown window, PMG will block the
# installation with a message indicating when the cooldown expires.
#
# Applies to all packages universally (new and existing dependencies alike).
# Currently supported: NPM in proxy mode only.
dependency_cooldown:
# Enable dependency cooldown. Default is true.
enabled: true
# Cooldown period in days. Default is 5.
days: 5
+11
View File
@@ -180,6 +180,17 @@ func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagema
reportData.BlockedPackages = statsCollector.GetBlockedPackages()
reportData.ConfirmedPackages = statsCollector.GetConfirmedPackages()
for _, cb := range statsCollector.GetCooldownBlocks() {
reportData.CooldownBlockedPackages = append(reportData.CooldownBlockedPackages, ui.CooldownBlockedPackage{
Name: cb.Name,
Version: cb.Version,
PublishDate: cb.PublishDate,
DaysAgo: cb.DaysAgo,
DaysLeft: cb.DaysLeft,
CooldownDays: cb.CooldownDays,
})
}
// Set outcome based on execution result using shared inference logic
reportData.Outcome = inferOutcome(cfg.InsecureInstallation, cfg.DryRun, reportData.BlockedCount, stats.UserCancelledCount, executionError)
+90 -17
View File
@@ -57,6 +57,16 @@ func (o ExecutionOutcome) String() string {
}
}
// CooldownBlockedPackage is a package blocked by the dependency cooldown policy.
type CooldownBlockedPackage struct {
Name string
Version string
PublishDate time.Time
DaysAgo int
DaysLeft int
CooldownDays int
}
// ReportData captures execution statistics for the post-execution report.
// This is a pure data model with no rendering logic.
type ReportData struct {
@@ -78,6 +88,9 @@ type ReportData struct {
BlockedPackages []*analyzer.PackageVersionAnalysisResult
ConfirmedPackages []*analyzer.PackageVersionAnalysisResult
// Packages blocked by the dependency cooldown policy (proxy mode only)
CooldownBlockedPackages []CooldownBlockedPackage
// Configuration context
FlowType FlowType
DryRun bool
@@ -106,7 +119,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,28 +185,46 @@ 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: %s analyzed, %s blocked",
pluralizePackages(data.TotalAnalyzed), pluralizePackages(data.BlockedCount))
}
case OutcomeUserCancelled:
icon = Colors.Yellow("✗")
message = fmt.Sprintf("PMG: %d packages analyzed, installation cancelled",
data.TotalAnalyzed)
message = fmt.Sprintf("PMG: %s analyzed, installation cancelled",
pluralizePackages(data.TotalAnalyzed))
default:
// Success case
if data.HasIssues() {
icon = Colors.Yellow("!")
message = fmt.Sprintf("PMG: %d packages analyzed (%d confirmed)",
data.TotalAnalyzed, data.ConfirmedCount)
message = fmt.Sprintf("PMG: %s analyzed (%s confirmed)",
pluralizePackages(data.TotalAnalyzed), pluralizePackages(data.ConfirmedCount))
} else {
icon = Colors.Green("✓")
message = fmt.Sprintf("PMG: %d packages analyzed", data.TotalAnalyzed)
message = fmt.Sprintf("PMG: %s analyzed", pluralizePackages(data.TotalAnalyzed))
}
}
@@ -217,9 +248,9 @@ func reportVerbose(data *ReportData) {
data.TotalAnalyzed,
data.TrustedSkipped)
} else {
fmt.Printf(" %s %d analyzed\n",
fmt.Printf(" %s %s analyzed\n",
Colors.Bold("Packages:"),
data.TotalAnalyzed)
pluralizePackages(data.TotalAnalyzed))
}
fmt.Printf(" %s %s (allowed: %d, confirmed: %d, blocked: %d)\n",
@@ -251,12 +282,29 @@ func reportVerbose(data *ReportData) {
// Show blocked/confirmed package details in verbose mode
if len(data.BlockedPackages) > 0 {
fmt.Println()
fmt.Println(Colors.Red(" Blocked packages:"))
fmt.Println(Colors.Red(" Blocked packages (malware):"))
for _, pkg := range data.BlockedPackages {
printPackageDetail(pkg)
}
}
if len(data.CooldownBlockedPackages) > 0 {
fmt.Println()
fmt.Println(Colors.Yellow(" Blocked by dependency cooldown:"))
for _, pkg := range data.CooldownBlockedPackages {
dateStr := ""
if !pkg.PublishDate.IsZero() {
dateStr = fmt.Sprintf(" (%s)", pkg.PublishDate.Format("2006-01-02"))
}
fmt.Printf(" %s %s\n", Colors.Yellow("⊘"), Colors.Yellow(fmt.Sprintf("%s@%s", pkg.Name, pkg.Version)))
fmt.Printf(" %s\n", Colors.Dim(fmt.Sprintf(
"Published %s ago%s — cooldown: %d days, available in %s",
pluralizeDays(pkg.DaysAgo), dateStr, pkg.CooldownDays, pluralizeDays(pkg.DaysLeft),
)))
fmt.Printf(" %s\n", Colors.Dim("Tip: add to trusted_packages in your PMG config to bypass"))
}
}
if len(data.ConfirmedPackages) > 0 {
fmt.Println()
fmt.Println(Colors.Yellow(" User-confirmed packages:"))
@@ -273,7 +321,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:
@@ -312,3 +369,19 @@ func boolToOnOff(b bool) string {
}
return "off"
}
// pluralizePackages returns "1 package" or "N packages" with correct grammar.
func pluralizePackages(n int) string {
if n == 1 {
return "1 package"
}
return fmt.Sprintf("%d packages", n)
}
// pluralizeDays returns "1 day" or "N days" with correct grammar.
// func pluralizeDays(n int) string {
// if n == 1 {
// return "1 day"
// }
// return fmt.Sprintf("%d days", n)
// }
+27
View File
@@ -141,6 +141,33 @@ func Fatalf(msg string, args ...interface{}) {
os.Exit(1)
}
func printCooldownPackagesList(packages []CooldownBlockedPackage) {
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("Tip: add to trusted_packages in your PMG config to bypass"))
}
}
func pluralizeDays(n int) string {
if n == 1 {
return "1 day"
}
return fmt.Sprintf("%d days", n)
}
func printMaliciousPackagesList(malwarePackages []*analyzer.PackageVersionAnalysisResult) {
for _, mp := range malwarePackages {
fmt.Println()
+400
View File
@@ -0,0 +1,400 @@
package interceptors
import (
"encoding/json"
"net/http"
"net/url"
"testing"
"time"
"github.com/safedep/dry/log"
"github.com/safedep/pmg/config"
"github.com/safedep/pmg/proxy"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// setCooldownConfig sets the global cooldown config for the duration of a test
// and restores the original value via t.Cleanup.
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
}
// TestParseNpmMetadataTime verifies that publish dates are correctly extracted from
// the "time" field of NPM package metadata, with skip keys omitted.
func TestParseNpmMetadataTime(t *testing.T) {
tests := []struct {
name string
body string
expectCount int
expectErr bool
}{
{
name: "valid metadata with multiple versions",
body: `{
"name": "lodash",
"time": {
"created": "2012-04-23T16:52:34.248Z",
"modified": "2024-01-15T10:30:00.000Z",
"1.0.0": "2012-04-23T16:52:34.248Z",
"2.0.0": "2013-05-10T12:00:00.000Z",
"4.17.21": "2021-02-20T15:42:16.000Z"
}
}`,
expectCount: 3,
expectErr: false,
},
{
name: "metadata without time field",
body: `{"name": "lodash"}`,
expectCount: 0,
expectErr: false,
},
{
name: "only skip keys present in time",
body: `{
"time": {
"created": "2012-04-23T16:52:34.248Z",
"modified": "2024-01-15T10:30:00.000Z"
}
}`,
expectCount: 0,
expectErr: false,
},
{
name: "invalid JSON body",
body: `not valid json`,
expectCount: 0,
expectErr: true,
},
{
name: "unparseable date values are skipped",
body: `{
"time": {
"1.0.0": "2024-01-15T10:30:00.000Z",
"2.0.0": "not-a-date-at-all"
}
}`,
expectCount: 1,
expectErr: false,
},
{
name: "RFC3339 format without milliseconds is accepted",
body: `{
"time": {
"1.0.0": "2024-01-15T10:30:00Z"
}
}`,
expectCount: 1,
expectErr: false,
},
{
name: "millisecond precision format is accepted",
body: `{
"time": {
"1.0.0": "2024-01-15T10:30:00.000Z"
}
}`,
expectCount: 1,
expectErr: false,
},
{
name: "empty body",
body: ``,
expectCount: 0,
expectErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dates, err := parseNpmMetadataTime([]byte(tt.body))
if tt.expectErr {
assert.Error(t, err)
} else {
require.NoError(t, err)
assert.Len(t, dates, tt.expectCount)
}
})
}
}
// TestParseNpmMetadataTime_CorrectDates verifies that the parsed dates match the
// exact timestamps from the metadata JSON.
func TestParseNpmMetadataTime_CorrectDates(t *testing.T) {
body := `{
"name": "lodash",
"time": {
"created": "2012-04-23T16:52:34.248Z",
"modified": "2024-01-15T10:30:00.000Z",
"4.17.20": "2021-01-07T14:24:45.000Z",
"4.17.21": "2021-02-20T15:42:16.000Z"
}
}`
dates, err := parseNpmMetadataTime([]byte(body))
require.NoError(t, err)
require.Len(t, dates, 2)
expected4_17_20, err := time.Parse("2006-01-02T15:04:05.000Z", "2021-01-07T14:24:45.000Z")
require.NoError(t, err)
assert.Equal(t, expected4_17_20, dates["4.17.20"])
expected4_17_21, err := time.Parse("2006-01-02T15:04:05.000Z", "2021-02-20T15:42:16.000Z")
require.NoError(t, err)
assert.Equal(t, expected4_17_21, dates["4.17.21"])
_, hasCreated := dates["created"]
assert.False(t, hasCreated, "created key must be skipped")
_, hasModified := dates["modified"]
assert.False(t, hasModified, "modified key must be skipped")
}
// TestNpmCooldown_BlocksRecentPackage verifies that a metadata response for a package
// with a recently published version strips that version from the response.
func TestNpmCooldown_BlocksRecentPackage(t *testing.T) {
setCooldownConfig(t, config.DependencyCooldownConfig{Enabled: true, Days: 5})
interceptor := NewNpmRegistryInterceptor(nil, NewInMemoryAnalysisCache(), nil, nil)
ctx := &proxy.RequestContext{
Hostname: "registry.npmjs.org",
URL: mustParseURL("https://registry.npmjs.org/lodash"),
}
resp, err := interceptor.HandleRequest(ctx)
require.NoError(t, err)
require.Equal(t, proxy.ActionModifyResponse, resp.Action)
recentDate := time.Now().Add(-2 * 24 * time.Hour).UTC().Format(time.RFC3339)
body, _ := json.Marshal(map[string]any{
"name": "lodash",
"dist-tags": map[string]string{"latest": "4.18.0"},
"versions": map[string]any{"4.17.21": map[string]string{"version": "4.17.21"}, "4.18.0": map[string]string{"version": "4.18.0"}},
"time": map[string]string{"4.17.21": "2021-02-20T15:42:16.000Z", "4.18.0": recentDate},
})
_, _, outBody, err := resp.ResponseModifier(http.StatusOK, http.Header{}, body)
require.NoError(t, err)
var result map[string]json.RawMessage
require.NoError(t, json.Unmarshal(outBody, &result))
var versions map[string]json.RawMessage
require.NoError(t, json.Unmarshal(result["versions"], &versions))
assert.NotContains(t, versions, "4.18.0", "recent version should be stripped")
assert.Contains(t, versions, "4.17.21")
var distTags map[string]string
require.NoError(t, json.Unmarshal(result["dist-tags"], &distTags))
assert.Equal(t, "4.17.21", distTags["latest"], "dist-tags.latest should point to oldest eligible version")
}
// TestNpmCooldown_AllowsOldPackage verifies that a metadata response with no versions
// in the cooldown window is returned unchanged.
func TestNpmCooldown_AllowsOldPackage(t *testing.T) {
setCooldownConfig(t, config.DependencyCooldownConfig{Enabled: true, Days: 5})
interceptor := NewNpmRegistryInterceptor(nil, NewInMemoryAnalysisCache(), nil, nil)
ctx := &proxy.RequestContext{
Hostname: "registry.npmjs.org",
URL: mustParseURL("https://registry.npmjs.org/lodash"),
}
resp, err := interceptor.HandleRequest(ctx)
require.NoError(t, err)
require.Equal(t, proxy.ActionModifyResponse, resp.Action)
body, _ := json.Marshal(map[string]any{
"name": "lodash",
"dist-tags": map[string]string{"latest": "4.17.21"},
"versions": map[string]any{"4.17.21": map[string]string{"version": "4.17.21"}},
"time": map[string]string{"4.17.21": "2021-02-20T15:42:16.000Z"},
})
_, _, outBody, err := resp.ResponseModifier(http.StatusOK, http.Header{}, body)
require.NoError(t, err)
assert.Equal(t, body, outBody, "body should be unchanged when no versions are in cooldown")
}
// TestNpmCooldown_DisabledByConfig verifies that when cooldown is disabled, metadata
// requests are allowed through without modification.
func TestNpmCooldown_DisabledByConfig(t *testing.T) {
setCooldownConfig(t, config.DependencyCooldownConfig{Enabled: false, Days: 5})
interceptor := NewNpmRegistryInterceptor(nil, NewInMemoryAnalysisCache(), nil, nil)
ctx := &proxy.RequestContext{
Hostname: "registry.npmjs.org",
URL: mustParseURL("https://registry.npmjs.org/lodash"),
}
resp, err := interceptor.HandleRequest(ctx)
require.NoError(t, err)
// Cooldown disabled: metadata requests must be allowed through without a modifier
assert.Equal(t, proxy.ActionAllow, resp.Action)
assert.Nil(t, resp.ResponseModifier)
}
// TestNpmCooldown_MetadataResponseRegistersModifier verifies that a metadata request
// when cooldown is enabled returns ActionModifyResponse with a modifier set.
func TestNpmCooldown_MetadataResponseRegistersModifier(t *testing.T) {
setCooldownConfig(t, config.DependencyCooldownConfig{Enabled: true, Days: 5})
interceptor := NewNpmRegistryInterceptor(nil, NewInMemoryAnalysisCache(), nil, nil)
ctx := &proxy.RequestContext{
Hostname: "registry.npmjs.org",
URL: mustParseURL("https://registry.npmjs.org/lodash"),
}
resp, err := interceptor.HandleRequest(ctx)
require.NoError(t, err)
assert.Equal(t, proxy.ActionModifyResponse, resp.Action)
assert.NotNil(t, resp.ResponseModifier, "response modifier must be set for metadata requests when cooldown is enabled")
}
// TestNpmCooldown_MetadataStripsRecentVersions verifies that the metadata modifier
// removes versions within the cooldown window from "versions", "time", and fixes "dist-tags".
// This is the key behavior for npm update: npm's resolver never sees the too-new versions
// and naturally falls back to the latest eligible version.
func TestNpmCooldown_MetadataStripsRecentVersions(t *testing.T) {
setCooldownConfig(t, config.DependencyCooldownConfig{Enabled: true, Days: 5})
interceptor := NewNpmRegistryInterceptor(nil, NewInMemoryAnalysisCache(), NewAnalysisStatsCollector(), nil)
ctx := &proxy.RequestContext{
Hostname: "registry.npmjs.org",
URL: mustParseURL("https://registry.npmjs.org/some-pkg"),
}
resp, err := interceptor.HandleRequest(ctx)
require.NoError(t, err)
require.NotNil(t, resp.ResponseModifier)
// Build metadata where 1.3.0 was published 1 day ago (within cooldown)
recentDate := time.Now().Add(-1 * 24 * time.Hour).UTC().Format("2006-01-02T15:04:05.000Z")
oldDate := "2024-01-15T10:30:00.000Z"
metadata := map[string]interface{}{
"name": "some-pkg",
"dist-tags": map[string]string{
"latest": "1.3.0",
},
"time": map[string]string{
"created": "2023-01-01T00:00:00.000Z",
"modified": recentDate,
"1.0.0": oldDate,
"1.2.0": "2024-06-01T00:00:00.000Z",
"1.3.0": recentDate,
},
"versions": map[string]interface{}{
"1.0.0": map[string]string{"version": "1.0.0"},
"1.2.0": map[string]string{"version": "1.2.0"},
"1.3.0": map[string]string{"version": "1.3.0"},
},
}
body, err := json.Marshal(metadata)
require.NoError(t, err)
_, _, outBody, modErr := resp.ResponseModifier(http.StatusOK, http.Header{}, body)
require.NoError(t, modErr)
// Parse the modified body
var result map[string]json.RawMessage
require.NoError(t, json.Unmarshal(outBody, &result))
// 1.3.0 should be stripped from "versions"
var versions map[string]json.RawMessage
require.NoError(t, json.Unmarshal(result["versions"], &versions))
assert.Contains(t, versions, "1.0.0")
assert.Contains(t, versions, "1.2.0")
assert.NotContains(t, versions, "1.3.0", "1.3.0 should be stripped from versions")
// 1.3.0 should be stripped from "time"
var timeMap map[string]string
require.NoError(t, json.Unmarshal(result["time"], &timeMap))
assert.Contains(t, timeMap, "1.0.0")
assert.Contains(t, timeMap, "1.2.0")
assert.NotContains(t, timeMap, "1.3.0", "1.3.0 should be stripped from time")
assert.Contains(t, timeMap, "created", "created should be preserved")
// dist-tags.latest should be updated to 1.2.0 (latest non-cooldown version)
var distTags map[string]string
require.NoError(t, json.Unmarshal(result["dist-tags"], &distTags))
assert.Equal(t, "1.2.0", distTags["latest"], "dist-tags.latest should point to latest non-cooldown version")
}
// TestStripCooldownVersions_AllVersionsTooNew verifies behavior when every version
// is within the cooldown window — all versions are stripped.
func TestStripCooldownVersions_AllVersionsTooNew(t *testing.T) {
now := time.Now()
dates := map[string]time.Time{
"1.0.0": now.Add(-1 * 24 * time.Hour),
"1.1.0": now.Add(-2 * 24 * time.Hour),
}
body, _ := json.Marshal(map[string]interface{}{
"name": "new-pkg",
"dist-tags": map[string]string{
"latest": "1.1.0",
},
"versions": map[string]interface{}{
"1.0.0": map[string]string{"version": "1.0.0"},
"1.1.0": map[string]string{"version": "1.1.0"},
},
"time": map[string]string{
"1.0.0": now.Add(-1 * 24 * time.Hour).Format(time.RFC3339),
"1.1.0": now.Add(-2 * 24 * time.Hour).Format(time.RFC3339),
},
})
result, stripped, remaining := stripCooldownVersions(body, dates, 5)
assert.Equal(t, 2, stripped)
assert.Equal(t, 0, remaining)
var parsed map[string]json.RawMessage
require.NoError(t, json.Unmarshal(result, &parsed))
var versions map[string]json.RawMessage
require.NoError(t, json.Unmarshal(parsed["versions"], &versions))
assert.Empty(t, versions, "all versions should be stripped")
// dist-tags should be empty since no eligible version exists
var distTags map[string]string
require.NoError(t, json.Unmarshal(parsed["dist-tags"], &distTags))
assert.Empty(t, distTags)
}
// TestStripCooldownVersions_NoVersionsTooNew verifies that when no versions are
// within the cooldown window, the body is returned unchanged.
func TestStripCooldownVersions_NoVersionsTooNew(t *testing.T) {
dates := map[string]time.Time{
"1.0.0": time.Now().Add(-30 * 24 * time.Hour),
"1.1.0": time.Now().Add(-20 * 24 * time.Hour),
}
body := []byte(`{"name":"pkg","versions":{"1.0.0":{},"1.1.0":{}},"time":{"1.0.0":"2024-01-01T00:00:00Z","1.1.0":"2024-02-01T00:00:00Z"}}`)
result, stripped, remaining := stripCooldownVersions(body, dates, 5)
assert.Equal(t, 0, stripped)
assert.Equal(t, 2, remaining)
assert.Equal(t, body, result, "body should be unchanged when no versions are in cooldown")
}
func mustParseURL(rawURL string) *url.URL {
u, err := url.Parse(rawURL)
if err != nil {
log.Errorf("mustParseURL: %s" + err.Error())
return nil
}
return u
}
+241 -16
View File
@@ -1,9 +1,15 @@
package interceptors
import (
"encoding/json"
"fmt"
"net/http"
"time"
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
"github.com/safedep/dry/log"
"github.com/safedep/pmg/analyzer"
"github.com/safedep/pmg/config"
"github.com/safedep/pmg/proxy"
)
@@ -30,8 +36,14 @@ var npmRegistryDomains = registryConfigMap{
},
}
// NpmRegistryInterceptor intercepts NPM registry requests and analyzes packages for malware
// It embeds baseRegistryInterceptor to reuse ecosystem agnostic functionality
// npmMetadataTimeSkipKeys are non-version keys present in the NPM metadata "time" object.
var npmMetadataTimeSkipKeys = map[string]bool{
"created": true,
"modified": true,
}
// NpmRegistryInterceptor intercepts NPM registry requests and analyzes packages for malware.
// It embeds baseRegistryInterceptor to reuse ecosystem agnostic functionality.
type NpmRegistryInterceptor struct {
baseRegistryInterceptor
}
@@ -75,37 +87,39 @@ func (i *NpmRegistryInterceptor) ShouldIntercept(ctx *proxy.RequestContext) bool
return npmRegistryDomains.ContainsHostname(ctx.Hostname)
}
// HandleRequest processes the request and returns response action
// We take a fail-open approach here, allowing requests that we can't parse the package information from the URL.
// HandleRequest processes the request and returns response action.
// We take a fail-open approach, allowing requests we can't parse.
func (i *NpmRegistryInterceptor) HandleRequest(ctx *proxy.RequestContext) (*proxy.InterceptorResponse, error) {
log.Debugf("[%s] Handling NPM registry request: %s", ctx.RequestID, ctx.URL.Path)
// Get registry configuration
config := npmRegistryDomains.GetConfigForHostname(ctx.Hostname)
if config == nil {
// Shouldn't happen if ShouldIntercept is working correctly
registryConfig := npmRegistryDomains.GetConfigForHostname(ctx.Hostname)
if registryConfig == nil {
log.Warnf("[%s] No registry config found for hostname: %s", ctx.RequestID, ctx.Hostname)
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
}
// Skip analysis for registries that are not supported for analysis
if !config.SupportedForAnalysis {
if !registryConfig.SupportedForAnalysis {
log.Debugf("[%s] Skipping analysis for %s registry (not supported for analysis): %s",
ctx.RequestID, config.Host, ctx.URL.String())
ctx.RequestID, registryConfig.Host, ctx.URL.String())
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
}
// Parse URL using registry-specific strategy
pkgInfo, err := config.Parser.ParseURL(ctx.URL.Path)
pkgInfo, err := registryConfig.Parser.ParseURL(ctx.URL.Path)
if err != nil {
log.Warnf("[%s] Failed to parse NPM registry URL %s for %s: %v",
ctx.RequestID, ctx.URL.Path, config.Host, err)
ctx.RequestID, ctx.URL.Path, registryConfig.Host, err)
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
}
// Only analyze tarball downloads (these have a specific version)
// Metadata requests (without version) are allowed through
depCooldownConfig := config.Get().Config.DependencyCooldown
// Metadata requests: strip versions within the cooldown window so npm's resolver
// naturally falls back to the latest eligible version.
if !pkgInfo.IsFileDownload() {
if depCooldownConfig.Enabled {
return i.handleMetadataRequest(ctx, pkgInfo.GetName())
}
log.Debugf("[%s] Skipping analysis for metadata request: %s", ctx.RequestID, pkgInfo.GetName())
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
}
@@ -123,3 +137,214 @@ func (i *NpmRegistryInterceptor) HandleRequest(ctx *proxy.RequestContext) (*prox
return i.handleAnalysisResult(ctx, packagev1.Ecosystem_ECOSYSTEM_NPM, pkgInfo.GetName(), pkgInfo.GetVersion(), result)
}
// handleMetadataRequest registers a response modifier that extracts publish dates from the
// NPM metadata JSON body, caches them, and strips versions within the cooldown window.
// By removing recent versions from "versions", "time", and "dist-tags", npm's resolver
// naturally falls back to the latest eligible version — matching the behavior of npm's
// own --min-release-age flag.
func (i *NpmRegistryInterceptor) handleMetadataRequest(ctx *proxy.RequestContext, packageName string) (*proxy.InterceptorResponse, error) {
log.Debugf("[%s] Registering metadata response modifier for %s", ctx.RequestID, packageName)
modifier := func(statusCode int, headers http.Header, body []byte) (int, http.Header, []byte, error) {
dates, err := parseNpmMetadataTime(body)
if err != nil {
log.Warnf("[%s] Failed to parse NPM metadata time for %s: %v", ctx.RequestID, packageName, err)
return statusCode, headers, body, nil
}
log.Debugf("[%s] Parsed %d publish dates for %s", ctx.RequestID, len(dates), packageName)
cooldownDays := config.Get().Config.DependencyCooldown.Days
strippedBody, stripped, remaining := stripCooldownVersions(body, dates, cooldownDays)
if stripped > 0 {
log.Infof("[%s] Stripped %d version(s) from %s metadata (cooldown: %d days, %d eligible remain)",
ctx.RequestID, stripped, packageName, cooldownDays, remaining)
// Only report a cooldown block when npm has no eligible version to fall back to.
// If older versions remain, npm resolves to them silently — nothing to report.
if remaining == 0 && i.statsCollector != nil {
// Report the most recently published (would-have-been-latest) stripped version
latestStripped, latestDate := mostRecentVersion(dates)
if latestStripped != "" {
cooldownDuration := time.Duration(cooldownDays) * 24 * time.Hour
age := time.Since(latestDate)
daysAgo := int(age.Hours() / 24)
daysLeft := int((cooldownDuration-age).Hours()/24) + 1
i.statsCollector.RecordCooldownBlocked(packageName, latestStripped, latestDate, daysAgo, daysLeft, cooldownDays)
}
}
return statusCode, headers, strippedBody, nil
}
return statusCode, headers, body, nil
}
return &proxy.InterceptorResponse{
Action: proxy.ActionModifyResponse,
ResponseModifier: modifier,
}, nil
}
// parseNpmMetadataTime extracts version publish dates from an NPM package metadata body.
// The NPM registry embeds a "time" object mapping version strings to RFC3339 timestamps.
// Non-version keys (created, modified) are skipped.
func parseNpmMetadataTime(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 {
// NPM sometimes uses millisecond precision: 2024-01-15T10:30:00.000Z
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"
// so npm's resolver naturally picks the latest eligible version.
// Returns the modified body, count of stripped versions, and count of remaining eligible versions.
// If no versions need stripping, returns the original body unchanged with counts (0, total).
func stripCooldownVersions(body []byte, dates map[string]time.Time, cooldownDays int) ([]byte, int, int) {
cooldownDuration := time.Duration(cooldownDays) * 24 * time.Hour
now := time.Now()
// Identify which versions to strip
tooNew := make(map[string]bool)
for version, publishDate := range dates {
if now.Sub(publishDate) < cooldownDuration {
tooNew[version] = true
}
}
remaining := len(dates) - len(tooNew)
if len(tooNew) == 0 {
return body, 0, remaining
}
// Parse into a generic map to preserve all fields we don't care about
var metadata map[string]json.RawMessage
if err := json.Unmarshal(body, &metadata); err != nil {
return body, 0, remaining
}
// Strip from "versions"
if raw, ok := metadata["versions"]; ok {
var versions map[string]json.RawMessage
if err := json.Unmarshal(raw, &versions); err == nil {
for v := range tooNew {
delete(versions, v)
}
if updated, err := json.Marshal(versions); err == nil {
metadata["versions"] = updated
}
}
}
// Strip from "time"
if raw, ok := metadata["time"]; ok {
var timeMap map[string]string
if err := json.Unmarshal(raw, &timeMap); err == nil {
for v := range tooNew {
delete(timeMap, v)
}
if updated, err := json.Marshal(timeMap); err == nil {
metadata["time"] = updated
}
}
}
// Fix "dist-tags" — if any tag points to a stripped version, update it to the
// latest remaining version
if raw, ok := metadata["dist-tags"]; ok {
var distTags map[string]string
if err := json.Unmarshal(raw, &distTags); err == nil {
changed := false
for tag, version := range distTags {
if tooNew[version] {
latest := latestNonCooldownVersion(dates, tooNew)
if latest != "" {
distTags[tag] = latest
} else {
delete(distTags, tag)
}
changed = true
}
}
if changed {
if updated, err := json.Marshal(distTags); err == nil {
metadata["dist-tags"] = updated
}
}
}
}
result, err := json.Marshal(metadata)
if err != nil {
return body, 0, remaining
}
return result, len(tooNew), remaining
}
// mostRecentVersion returns the version with the most recent publish date from dates.
func mostRecentVersion(dates map[string]time.Time) (string, time.Time) {
var latest string
var latestTime time.Time
for version, publishDate := range dates {
if publishDate.After(latestTime) {
latest = version
latestTime = publishDate
}
}
return latest, latestTime
}
// latestNonCooldownVersion finds the most recently published version that is NOT in the
// tooNew set.
func 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
}
+48 -5
View File
@@ -2,17 +2,29 @@ package interceptors
import (
"sync"
"time"
"github.com/safedep/pmg/analyzer"
)
// 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
}
// 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 +35,7 @@ type AnalysisStatsCollector struct {
stats AnalysisStats
blockedPackages []*analyzer.PackageVersionAnalysisResult
confirmedPackages []*analyzer.PackageVersionAnalysisResult
cooldownBlocks []CooldownBlock
}
// NewAnalysisStatsCollector creates a new stats collector
@@ -118,3 +131,33 @@ func (c *AnalysisStatsCollector) GetConfirmedPackages() []*analyzer.PackageVersi
copy(result, c.confirmedPackages)
return result
}
// RecordCooldownBlocked records a package blocked by the dependency cooldown policy.
// It increments both the cooldown-specific counter and the overall BlockedCount so that
// inferOutcome correctly treats cooldown-only sessions as OutcomeBlocked.
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, 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() []CooldownBlock {
c.mu.RLock()
defer c.mu.RUnlock()
result := make([]CooldownBlock, len(c.cooldownBlocks))
copy(result, c.cooldownBlocks)
return result
}
+34 -4
View File
@@ -1,12 +1,15 @@
package proxy
import (
"bytes"
"context"
"crypto/tls"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
@@ -494,10 +497,37 @@ 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
modifiedResp, err := applyResponseModifier(resp, modifier)
if err != nil {
log.Errorf("[%s] Response modifier failed for %s: %v", reqCtx.RequestID, ctx.Req.URL.String(), err)
return resp
}
return resp
return modifiedResp
})
}
// applyResponseModifier reads the full response body, passes it to the modifier function,
// and returns a new response with the modifier's output. Content-Length is updated to match.
func applyResponseModifier(resp *http.Response, modifier ResponseModifierFunc) (*http.Response, error) {
body, err := io.ReadAll(resp.Body)
if err != nil {
return resp, fmt.Errorf("failed to read response body: %w", err)
}
_ = resp.Body.Close()
newStatus, newHeaders, newBody, err := modifier(resp.StatusCode, resp.Header, body)
if err != nil {
// Restore original body so the response remains usable on error
resp.Body = io.NopCloser(bytes.NewReader(body))
return resp, fmt.Errorf("modifier returned error: %w", err)
}
resp.StatusCode = newStatus
resp.Header = newHeaders
resp.Body = io.NopCloser(bytes.NewReader(newBody))
resp.ContentLength = int64(len(newBody))
resp.Header.Set("Content-Length", strconv.Itoa(len(newBody)))
return resp, nil
}
+102 -3
View File
@@ -2,9 +2,13 @@ package proxy
import (
"crypto/tls"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"time"
@@ -128,9 +132,9 @@ func TestNormalizeRequestURLNilSafety(t *testing.T) {
func TestProxyWithLoopbackBypass(t *testing.T) {
tests := []struct {
name string
url string
shouldBypass bool
name string
url string
shouldBypass bool
}{
{"localhost bypassed", "http://localhost:9876/", true},
{"127.0.0.1 bypassed", "http://127.0.0.1:9876/", true},
@@ -225,3 +229,98 @@ func TestResponseProtoNormalisedToHTTP11(t *testing.T) {
assert.Equal(t, 1, resp.ProtoMajor, "response ProtoMajor should be 1 (HTTP/1.1)")
assert.Equal(t, 1, resp.ProtoMinor, "response ProtoMinor should be 1 (HTTP/1.1)")
}
func TestApplyResponseModifier(t *testing.T) {
tests := []struct {
name string
body string
modifier ResponseModifierFunc
expectedBody string
expectedStatus int
}{
{
name: "modifier returns body unchanged",
body: `{"name":"lodash"}`,
modifier: func(statusCode int, headers http.Header, body []byte) (int, http.Header, []byte, error) {
return statusCode, headers, body, nil
},
expectedBody: `{"name":"lodash"}`,
expectedStatus: http.StatusOK,
},
{
name: "modifier rewrites body",
body: `original`,
modifier: func(statusCode int, headers http.Header, body []byte) (int, http.Header, []byte, error) {
return statusCode, headers, []byte(`rewritten`), nil
},
expectedBody: `rewritten`,
expectedStatus: http.StatusOK,
},
{
name: "modifier changes status code",
body: `body`,
modifier: func(statusCode int, headers http.Header, body []byte) (int, http.Header, []byte, error) {
return http.StatusTeapot, headers, body, nil
},
expectedBody: `body`,
expectedStatus: http.StatusTeapot,
},
{
name: "modifier error restores original body",
body: `original`,
modifier: func(statusCode int, headers http.Header, body []byte) (int, http.Header, []byte, error) {
return 0, nil, nil, fmt.Errorf("modifier failed")
},
expectedBody: `original`,
expectedStatus: http.StatusOK,
},
{
name: "empty body handled",
body: ``,
modifier: func(statusCode int, headers http.Header, body []byte) (int, http.Header, []byte, error) {
return statusCode, headers, body, nil
},
expectedBody: ``,
expectedStatus: http.StatusOK,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp := &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(tt.body)),
}
got, _ := applyResponseModifier(resp, tt.modifier)
assert.Equal(t, tt.expectedStatus, got.StatusCode)
gotBody, err := io.ReadAll(got.Body)
require.NoError(t, err)
assert.Equal(t, tt.expectedBody, string(gotBody))
})
}
}
func TestApplyResponseModifier_ContentLengthUpdated(t *testing.T) {
original := `short`
replacement := `this is a longer body`
resp := &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{},
Body: io.NopCloser(strings.NewReader(original)),
ContentLength: int64(len(original)),
}
modifier := func(statusCode int, headers http.Header, body []byte) (int, http.Header, []byte, error) {
return statusCode, headers, []byte(replacement), nil
}
got, err := applyResponseModifier(resp, modifier)
require.NoError(t, err)
assert.Equal(t, int64(len(replacement)), got.ContentLength)
assert.Equal(t, strconv.Itoa(len(replacement)), got.Header.Get("Content-Length"))
}