mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
add dependencies cooldown
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
// }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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"))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user