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,
|
// Sandbox enables sandboxing of package manager processes with controlled filesystem,
|
||||||
// network, and process execution access. Provides defense-in-depth against supply chain attacks.
|
// network, and process execution access. Provides defense-in-depth against supply chain attacks.
|
||||||
Sandbox SandboxConfig `mapstructure:"sandbox"`
|
Sandbox SandboxConfig `mapstructure:"sandbox"`
|
||||||
|
|
||||||
|
// DependencyCooldown 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.
|
// SandboxConfig configures the sandbox system for isolating package manager processes.
|
||||||
@@ -225,6 +235,10 @@ func DefaultConfig() RuntimeConfig {
|
|||||||
Enabled: false,
|
Enabled: false,
|
||||||
EnforceAlways: false,
|
EnforceAlways: false,
|
||||||
},
|
},
|
||||||
|
DependencyCooldown: DependencyCooldownConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Days: 5,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
DryRun: false,
|
DryRun: false,
|
||||||
InsecureInstallation: insecureInstallation,
|
InsecureInstallation: insecureInstallation,
|
||||||
|
|||||||
@@ -137,3 +137,19 @@ sandbox:
|
|||||||
uv:
|
uv:
|
||||||
enabled: true
|
enabled: true
|
||||||
profile: pypi-restrictive
|
profile: pypi-restrictive
|
||||||
|
|
||||||
|
# Dependency cooldown blocks installation of package versions published within a configurable
|
||||||
|
# time window. 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.BlockedPackages = statsCollector.GetBlockedPackages()
|
||||||
reportData.ConfirmedPackages = statsCollector.GetConfirmedPackages()
|
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
|
// Set outcome based on execution result using shared inference logic
|
||||||
reportData.Outcome = inferOutcome(cfg.InsecureInstallation, cfg.DryRun, reportData.BlockedCount, stats.UserCancelledCount, executionError)
|
reportData.Outcome = inferOutcome(cfg.InsecureInstallation, cfg.DryRun, reportData.BlockedCount, stats.UserCancelledCount, executionError)
|
||||||
|
|
||||||
|
|||||||
+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.
|
// ReportData captures execution statistics for the post-execution report.
|
||||||
// This is a pure data model with no rendering logic.
|
// This is a pure data model with no rendering logic.
|
||||||
type ReportData struct {
|
type ReportData struct {
|
||||||
@@ -78,6 +88,9 @@ type ReportData struct {
|
|||||||
BlockedPackages []*analyzer.PackageVersionAnalysisResult
|
BlockedPackages []*analyzer.PackageVersionAnalysisResult
|
||||||
ConfirmedPackages []*analyzer.PackageVersionAnalysisResult
|
ConfirmedPackages []*analyzer.PackageVersionAnalysisResult
|
||||||
|
|
||||||
|
// Packages blocked by the dependency cooldown policy (proxy mode only)
|
||||||
|
CooldownBlockedPackages []CooldownBlockedPackage
|
||||||
|
|
||||||
// Configuration context
|
// Configuration context
|
||||||
FlowType FlowType
|
FlowType FlowType
|
||||||
DryRun bool
|
DryRun bool
|
||||||
@@ -106,7 +119,7 @@ func (r *ReportData) Finalize() {
|
|||||||
|
|
||||||
// HasIssues returns true if any packages were blocked or required confirmation
|
// HasIssues returns true if any packages were blocked or required confirmation
|
||||||
func (r *ReportData) HasIssues() bool {
|
func (r *ReportData) HasIssues() bool {
|
||||||
return r.BlockedCount > 0 || r.ConfirmedCount > 0
|
return r.BlockedCount > 0 || r.ConfirmedCount > 0 || len(r.CooldownBlockedPackages) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// WasSuccessful returns true if execution completed without blocks or errors
|
// WasSuccessful returns true if execution completed without blocks or errors
|
||||||
@@ -172,28 +185,46 @@ func reportNormal(data *ReportData) {
|
|||||||
|
|
||||||
switch data.Outcome {
|
switch data.Outcome {
|
||||||
case OutcomeBlocked:
|
case OutcomeBlocked:
|
||||||
fmt.Println()
|
if len(data.BlockedPackages) > 0 {
|
||||||
fmt.Printf("%s %s\n", Colors.Red("✗"), Colors.Red("Malicious package blocked"))
|
fmt.Println()
|
||||||
|
fmt.Printf("%s %s\n", Colors.Red("✗"), Colors.Red("Malicious package blocked"))
|
||||||
|
printMaliciousPackagesList(data.BlockedPackages)
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
|
||||||
printMaliciousPackagesList(data.BlockedPackages)
|
if len(data.CooldownBlockedPackages) > 0 {
|
||||||
fmt.Println()
|
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("✗")
|
onlyCooldown := len(data.BlockedPackages) == 0 && len(data.CooldownBlockedPackages) > 0
|
||||||
message = fmt.Sprintf("PMG: %d packages analyzed, %d blocked",
|
if onlyCooldown {
|
||||||
data.TotalAnalyzed, data.BlockedCount)
|
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:
|
case OutcomeUserCancelled:
|
||||||
icon = Colors.Yellow("✗")
|
icon = Colors.Yellow("✗")
|
||||||
message = fmt.Sprintf("PMG: %d packages analyzed, installation cancelled",
|
message = fmt.Sprintf("PMG: %s analyzed, installation cancelled",
|
||||||
data.TotalAnalyzed)
|
pluralizePackages(data.TotalAnalyzed))
|
||||||
default:
|
default:
|
||||||
// Success case
|
// Success case
|
||||||
if data.HasIssues() {
|
if data.HasIssues() {
|
||||||
icon = Colors.Yellow("!")
|
icon = Colors.Yellow("!")
|
||||||
message = fmt.Sprintf("PMG: %d packages analyzed (%d confirmed)",
|
message = fmt.Sprintf("PMG: %s analyzed (%s confirmed)",
|
||||||
data.TotalAnalyzed, data.ConfirmedCount)
|
pluralizePackages(data.TotalAnalyzed), pluralizePackages(data.ConfirmedCount))
|
||||||
} else {
|
} else {
|
||||||
icon = Colors.Green("✓")
|
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.TotalAnalyzed,
|
||||||
data.TrustedSkipped)
|
data.TrustedSkipped)
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf(" %s %d analyzed\n",
|
fmt.Printf(" %s %s analyzed\n",
|
||||||
Colors.Bold("Packages:"),
|
Colors.Bold("Packages:"),
|
||||||
data.TotalAnalyzed)
|
pluralizePackages(data.TotalAnalyzed))
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf(" %s %s (allowed: %d, confirmed: %d, blocked: %d)\n",
|
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
|
// Show blocked/confirmed package details in verbose mode
|
||||||
if len(data.BlockedPackages) > 0 {
|
if len(data.BlockedPackages) > 0 {
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Println(Colors.Red(" Blocked packages:"))
|
fmt.Println(Colors.Red(" Blocked packages (malware):"))
|
||||||
for _, pkg := range data.BlockedPackages {
|
for _, pkg := range data.BlockedPackages {
|
||||||
printPackageDetail(pkg)
|
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 {
|
if len(data.ConfirmedPackages) > 0 {
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Println(Colors.Yellow(" User-confirmed packages:"))
|
fmt.Println(Colors.Yellow(" User-confirmed packages:"))
|
||||||
@@ -273,7 +321,16 @@ func printOutcomeLine(data *ReportData) {
|
|||||||
case OutcomeSuccess:
|
case OutcomeSuccess:
|
||||||
fmt.Printf(" %s %s\n", Colors.Green("✓"), Colors.Green("Installation completed successfully"))
|
fmt.Printf(" %s %s\n", Colors.Green("✓"), Colors.Green("Installation completed successfully"))
|
||||||
case OutcomeBlocked:
|
case OutcomeBlocked:
|
||||||
fmt.Printf(" %s %s\n", Colors.Red("✗"), Colors.Red("Installation blocked - malicious package detected"))
|
hasMalware := len(data.BlockedPackages) > 0
|
||||||
|
hasCooldown := len(data.CooldownBlockedPackages) > 0
|
||||||
|
switch {
|
||||||
|
case hasMalware && hasCooldown:
|
||||||
|
fmt.Printf(" %s %s\n", Colors.Red("✗"), Colors.Red("Installation blocked — malicious package detected + cooldown policy"))
|
||||||
|
case hasCooldown:
|
||||||
|
fmt.Printf(" %s %s\n", Colors.Yellow("⊘"), Colors.Yellow("Installation blocked — dependency cooldown policy"))
|
||||||
|
default:
|
||||||
|
fmt.Printf(" %s %s\n", Colors.Red("✗"), Colors.Red("Installation blocked — malicious package detected"))
|
||||||
|
}
|
||||||
case OutcomeUserCancelled:
|
case OutcomeUserCancelled:
|
||||||
fmt.Printf(" %s %s\n", Colors.Yellow("✗"), Colors.Yellow("Installation cancelled by user"))
|
fmt.Printf(" %s %s\n", Colors.Yellow("✗"), Colors.Yellow("Installation cancelled by user"))
|
||||||
case OutcomeDryRun:
|
case OutcomeDryRun:
|
||||||
@@ -312,3 +369,19 @@ func boolToOnOff(b bool) string {
|
|||||||
}
|
}
|
||||||
return "off"
|
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)
|
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) {
|
func printMaliciousPackagesList(malwarePackages []*analyzer.PackageVersionAnalysisResult) {
|
||||||
for _, mp := range malwarePackages {
|
for _, mp := range malwarePackages {
|
||||||
fmt.Println()
|
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
|
package interceptors
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
|
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
|
||||||
"github.com/safedep/dry/log"
|
"github.com/safedep/dry/log"
|
||||||
"github.com/safedep/pmg/analyzer"
|
"github.com/safedep/pmg/analyzer"
|
||||||
|
"github.com/safedep/pmg/config"
|
||||||
"github.com/safedep/pmg/proxy"
|
"github.com/safedep/pmg/proxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,8 +36,14 @@ var npmRegistryDomains = registryConfigMap{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// NpmRegistryInterceptor intercepts NPM registry requests and analyzes packages for malware
|
// npmMetadataTimeSkipKeys are non-version keys present in the NPM metadata "time" object.
|
||||||
// It embeds baseRegistryInterceptor to reuse ecosystem agnostic functionality
|
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 {
|
type NpmRegistryInterceptor struct {
|
||||||
baseRegistryInterceptor
|
baseRegistryInterceptor
|
||||||
}
|
}
|
||||||
@@ -75,37 +87,39 @@ func (i *NpmRegistryInterceptor) ShouldIntercept(ctx *proxy.RequestContext) bool
|
|||||||
return npmRegistryDomains.ContainsHostname(ctx.Hostname)
|
return npmRegistryDomains.ContainsHostname(ctx.Hostname)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleRequest processes the request and returns response action
|
// 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.
|
// We take a fail-open approach, allowing requests we can't parse.
|
||||||
func (i *NpmRegistryInterceptor) HandleRequest(ctx *proxy.RequestContext) (*proxy.InterceptorResponse, error) {
|
func (i *NpmRegistryInterceptor) HandleRequest(ctx *proxy.RequestContext) (*proxy.InterceptorResponse, error) {
|
||||||
log.Debugf("[%s] Handling NPM registry request: %s", ctx.RequestID, ctx.URL.Path)
|
log.Debugf("[%s] Handling NPM registry request: %s", ctx.RequestID, ctx.URL.Path)
|
||||||
|
|
||||||
// Get registry configuration
|
registryConfig := npmRegistryDomains.GetConfigForHostname(ctx.Hostname)
|
||||||
config := npmRegistryDomains.GetConfigForHostname(ctx.Hostname)
|
if registryConfig == nil {
|
||||||
if config == nil {
|
|
||||||
// Shouldn't happen if ShouldIntercept is working correctly
|
|
||||||
log.Warnf("[%s] No registry config found for hostname: %s", ctx.RequestID, ctx.Hostname)
|
log.Warnf("[%s] No registry config found for hostname: %s", ctx.RequestID, ctx.Hostname)
|
||||||
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
|
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip analysis for registries that are not supported for analysis
|
if !registryConfig.SupportedForAnalysis {
|
||||||
if !config.SupportedForAnalysis {
|
|
||||||
log.Debugf("[%s] Skipping analysis for %s registry (not supported for analysis): %s",
|
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
|
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse URL using registry-specific strategy
|
pkgInfo, err := registryConfig.Parser.ParseURL(ctx.URL.Path)
|
||||||
pkgInfo, err := config.Parser.ParseURL(ctx.URL.Path)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warnf("[%s] Failed to parse NPM registry URL %s for %s: %v",
|
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
|
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only analyze tarball downloads (these have a specific version)
|
depCooldownConfig := config.Get().Config.DependencyCooldown
|
||||||
// Metadata requests (without version) are allowed through
|
|
||||||
|
// Metadata requests: strip versions within the cooldown window so npm's resolver
|
||||||
|
// naturally falls back to the latest eligible version.
|
||||||
if !pkgInfo.IsFileDownload() {
|
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())
|
log.Debugf("[%s] Skipping analysis for metadata request: %s", ctx.RequestID, pkgInfo.GetName())
|
||||||
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
|
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
|
||||||
}
|
}
|
||||||
@@ -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)
|
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 (
|
import (
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/safedep/pmg/analyzer"
|
"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
|
// AnalysisStats contains aggregated statistics from analysis results
|
||||||
type AnalysisStats struct {
|
type AnalysisStats struct {
|
||||||
TotalAnalyzed int
|
TotalAnalyzed int
|
||||||
AllowedCount int
|
AllowedCount int
|
||||||
ConfirmedCount int
|
ConfirmedCount int
|
||||||
BlockedCount int
|
BlockedCount int
|
||||||
UserCancelledCount int
|
UserCancelledCount int
|
||||||
|
CooldownBlockedCount int
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnalysisStatsCollector tracks analysis statistics during proxy execution.
|
// AnalysisStatsCollector tracks analysis statistics during proxy execution.
|
||||||
@@ -23,6 +35,7 @@ type AnalysisStatsCollector struct {
|
|||||||
stats AnalysisStats
|
stats AnalysisStats
|
||||||
blockedPackages []*analyzer.PackageVersionAnalysisResult
|
blockedPackages []*analyzer.PackageVersionAnalysisResult
|
||||||
confirmedPackages []*analyzer.PackageVersionAnalysisResult
|
confirmedPackages []*analyzer.PackageVersionAnalysisResult
|
||||||
|
cooldownBlocks []CooldownBlock
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAnalysisStatsCollector creates a new stats collector
|
// NewAnalysisStatsCollector creates a new stats collector
|
||||||
@@ -118,3 +131,33 @@ func (c *AnalysisStatsCollector) GetConfirmedPackages() []*analyzer.PackageVersi
|
|||||||
copy(result, c.confirmedPackages)
|
copy(result, c.confirmedPackages)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RecordCooldownBlocked records a package blocked by the dependency cooldown policy.
|
||||||
|
// 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
|
package proxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -494,10 +497,37 @@ func (ps *proxyServer) registerHandlers() {
|
|||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement response body modification
|
modifiedResp, err := applyResponseModifier(resp, modifier)
|
||||||
// This requires buffering the response body, modifying it, and creating a new response
|
if err != nil {
|
||||||
// For now, lets skip it
|
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 (
|
import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -128,9 +132,9 @@ func TestNormalizeRequestURLNilSafety(t *testing.T) {
|
|||||||
|
|
||||||
func TestProxyWithLoopbackBypass(t *testing.T) {
|
func TestProxyWithLoopbackBypass(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
url string
|
url string
|
||||||
shouldBypass bool
|
shouldBypass bool
|
||||||
}{
|
}{
|
||||||
{"localhost bypassed", "http://localhost:9876/", true},
|
{"localhost bypassed", "http://localhost:9876/", true},
|
||||||
{"127.0.0.1 bypassed", "http://127.0.0.1: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.ProtoMajor, "response ProtoMajor should be 1 (HTTP/1.1)")
|
||||||
assert.Equal(t, 1, resp.ProtoMinor, "response ProtoMinor 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