mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
* feat: Add dependency cooldown for npm packages Strip recently-published package versions from npm registry metadata responses so npm's resolver naturally falls back to older versions. Overrides the Accept header to force full packument responses (which include the "time" field needed for publish-date checks). Reports cooldown blocks only when all versions are stripped (remaining == 0), matching npm's --min-release-age behavior for silent fallback. * fix: Report oldest version in cooldown block (shortest wait) When all versions are blocked by cooldown, report the oldest version since it exits the cooldown window first — giving the user the shortest wait time instead of the longest. * fix: Handle resp.Body.Close error return for errcheck linter * test: Add dependency cooldown assertions to template config tests * fix: config template for dependency cooldown * fix: Prevent npm from caching cooldown-stripped metadata responses * fix: Restore body on ReadAll failure and log Close errors in response modifier * fix: Close response body before replacing to prevent connection leak * fix: Correct daysLeft ceiling math and update ContentLength on error recovery * fix: Clear Status on status code change and update ContentLength in modifier error path * refactor: address review comments on dependency cooldown PR - Make NpmCooldownHandler and constructor package-private - Pass cooldown days as parameter instead of reading config internally - Convert standalone functions to methods on npmCooldownHandler - Set Accept-Encoding: identity to prevent gzip responses breaking JSON parsing - Return 503 with descriptive message when upstream body read fails * fix: log errors in stripCooldownVersions instead of swallowing them * fix: Config preserve fallback defaults * fix: Code review fixes * fix: correct cooldown tip to show wait time instead of incorrect trusted_packages advice * fix: prevent integer overflow in cooldown duration calculation with large days values * refactor: deduplicate CooldownBlock into internal/models, fix misleading variable names - Move CooldownBlock struct to internal/models to eliminate duplication between proxy/interceptors and internal/ui packages - Simplify proxy_flow.go by using direct assignment instead of field copy - Rename latestStripped/latestDate to oldestVer/oldestDate for clarity * fix: Dependency Cooldown Check Encapsulation (#207) * fix: Encapsulate cooldown check * feat: Add --skip-dependency-cooldown override * fix: Code review fixes --------- Co-authored-by: Abhisek Datta <abhisek.datta@gmail.com>
260 lines
8.1 KiB
Go
260 lines
8.1 KiB
Go
package interceptors
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/safedep/dry/log"
|
|
"github.com/safedep/pmg/proxy"
|
|
)
|
|
|
|
// npmMetadataTimeSkipKeys are non-version keys present in the NPM metadata "time" object.
|
|
var npmMetadataTimeSkipKeys = map[string]bool{
|
|
"created": true,
|
|
"modified": true,
|
|
}
|
|
|
|
// npmCooldownHandler handles dependency cooldown for npm packages.
|
|
// It strips recently-published versions from metadata responses so npm's
|
|
// resolver naturally falls back to the latest eligible version.
|
|
type npmCooldownHandler struct {
|
|
statsCollector *AnalysisStatsCollector
|
|
}
|
|
|
|
func newNpmCooldownHandler(statsCollector *AnalysisStatsCollector) *npmCooldownHandler {
|
|
return &npmCooldownHandler{
|
|
statsCollector: statsCollector,
|
|
}
|
|
}
|
|
|
|
// HandleMetadataRequest overrides the Accept header to force the registry to return
|
|
// a full packument (which includes publish dates in the "time" field), then registers
|
|
// a response modifier that strips versions within the cooldown window.
|
|
func (h *npmCooldownHandler) HandleMetadataRequest(ctx *proxy.RequestContext, packageName string, cooldownDays int) (*proxy.InterceptorResponse, error) {
|
|
log.Debugf("[%s] Cooldown: registering metadata modifier for %s", ctx.RequestID, packageName)
|
|
|
|
// Force full packument so the response always contains the "time" field.
|
|
// Abbreviated metadata (Accept: application/vnd.npm.install-v1+json) omits it.
|
|
ctx.Headers.Set("Accept", "application/json")
|
|
|
|
// Prevent the server from compressing the response so we can parse the JSON body.
|
|
// Go's http.Transport only auto-decompresses when it added the Accept-Encoding
|
|
// header itself; since the client's original header is forwarded by the proxy,
|
|
// we'd get raw gzip bytes that fail JSON parsing.
|
|
ctx.Headers.Set("Accept-Encoding", "identity")
|
|
|
|
modifier := func(statusCode int, headers http.Header, body []byte) (int, http.Header, []byte, error) {
|
|
dates, err := h.parseMetadataTime(body)
|
|
if err != nil {
|
|
log.Warnf("[%s] Cooldown: failed to parse metadata time for %s: %v", ctx.RequestID, packageName, err)
|
|
return statusCode, headers, body, nil
|
|
}
|
|
|
|
log.Debugf("[%s] Cooldown: parsed %d publish dates for %s", ctx.RequestID, len(dates), packageName)
|
|
|
|
strippedBody, stripped, remaining := h.stripCooldownVersions(body, dates, cooldownDays)
|
|
if stripped > 0 {
|
|
log.Infof("[%s] Cooldown: stripped %d version(s) from %s metadata (%d days, %d eligible remain)",
|
|
ctx.RequestID, stripped, packageName, cooldownDays, remaining)
|
|
|
|
if remaining == 0 && h.statsCollector != nil {
|
|
oldestVer, oldestDate := h.oldestVersion(dates)
|
|
if oldestVer != "" {
|
|
_, daysAgo, daysLeft := h.isWithinCooldown(oldestDate, cooldownDays)
|
|
h.statsCollector.RecordCooldownBlocked(packageName, oldestVer, oldestDate, daysAgo, daysLeft, cooldownDays)
|
|
}
|
|
}
|
|
|
|
// Prevent npm from caching the modified response. Without this,
|
|
// npm would serve the stripped metadata from cache even after the
|
|
// cooldown window passes or settings change.
|
|
headers.Set("Cache-Control", "no-store")
|
|
|
|
return statusCode, headers, strippedBody, nil
|
|
}
|
|
|
|
return statusCode, headers, body, nil
|
|
}
|
|
|
|
return &proxy.InterceptorResponse{
|
|
Action: proxy.ActionModifyResponse,
|
|
ResponseModifier: modifier,
|
|
}, nil
|
|
}
|
|
|
|
// parseMetadataTime extracts version publish dates from an NPM package metadata body.
|
|
func (h *npmCooldownHandler) parseMetadataTime(body []byte) (map[string]time.Time, error) {
|
|
var metadata struct {
|
|
Time map[string]string `json:"time"`
|
|
}
|
|
|
|
if err := json.Unmarshal(body, &metadata); err != nil {
|
|
return nil, fmt.Errorf("failed to unmarshal npm metadata: %w", err)
|
|
}
|
|
|
|
if metadata.Time == nil {
|
|
return map[string]time.Time{}, nil
|
|
}
|
|
|
|
dates := make(map[string]time.Time, len(metadata.Time))
|
|
for version, dateStr := range metadata.Time {
|
|
if npmMetadataTimeSkipKeys[version] {
|
|
continue
|
|
}
|
|
|
|
t, err := time.Parse(time.RFC3339, dateStr)
|
|
if err != nil {
|
|
t, err = time.Parse("2006-01-02T15:04:05.000Z", dateStr)
|
|
if err != nil {
|
|
log.Debugf("Skipping unparseable publish date for version %s: %q", version, dateStr)
|
|
continue
|
|
}
|
|
}
|
|
|
|
dates[version] = t
|
|
}
|
|
|
|
return dates, nil
|
|
}
|
|
|
|
// stripCooldownVersions removes versions published within the cooldown window from the
|
|
// NPM metadata response. It strips entries from "versions", "time", and updates "dist-tags".
|
|
func (h *npmCooldownHandler) stripCooldownVersions(body []byte, dates map[string]time.Time, cooldownDays int) ([]byte, int, int) {
|
|
tooNew := make(map[string]bool)
|
|
for version, publishDate := range dates {
|
|
if withinCooldown, _, _ := h.isWithinCooldown(publishDate, cooldownDays); withinCooldown {
|
|
tooNew[version] = true
|
|
}
|
|
}
|
|
|
|
remaining := len(dates) - len(tooNew)
|
|
|
|
if len(tooNew) == 0 {
|
|
return body, 0, remaining
|
|
}
|
|
|
|
var metadata map[string]json.RawMessage
|
|
if err := json.Unmarshal(body, &metadata); err != nil {
|
|
log.Warnf("Cooldown: failed to unmarshal metadata body: %v", err)
|
|
return body, 0, remaining
|
|
}
|
|
|
|
if raw, ok := metadata["versions"]; ok {
|
|
var versions map[string]json.RawMessage
|
|
if err := json.Unmarshal(raw, &versions); err != nil {
|
|
log.Warnf("Cooldown: failed to unmarshal versions field: %v", err)
|
|
} else {
|
|
for v := range tooNew {
|
|
delete(versions, v)
|
|
}
|
|
if updated, err := json.Marshal(versions); err != nil {
|
|
log.Warnf("Cooldown: failed to marshal updated versions: %v", err)
|
|
} else {
|
|
metadata["versions"] = updated
|
|
}
|
|
}
|
|
}
|
|
|
|
if raw, ok := metadata["time"]; ok {
|
|
var timeMap map[string]string
|
|
if err := json.Unmarshal(raw, &timeMap); err != nil {
|
|
log.Warnf("Cooldown: failed to unmarshal time field: %v", err)
|
|
} else {
|
|
for v := range tooNew {
|
|
delete(timeMap, v)
|
|
}
|
|
if updated, err := json.Marshal(timeMap); err != nil {
|
|
log.Warnf("Cooldown: failed to marshal updated time: %v", err)
|
|
} else {
|
|
metadata["time"] = updated
|
|
}
|
|
}
|
|
}
|
|
|
|
if raw, ok := metadata["dist-tags"]; ok {
|
|
var distTags map[string]string
|
|
if err := json.Unmarshal(raw, &distTags); err != nil {
|
|
log.Warnf("Cooldown: failed to unmarshal dist-tags field: %v", err)
|
|
} else {
|
|
changed := false
|
|
for tag, version := range distTags {
|
|
if tooNew[version] {
|
|
latest := h.latestNonCooldownVersion(dates, tooNew)
|
|
if latest != "" {
|
|
distTags[tag] = latest
|
|
} else {
|
|
delete(distTags, tag)
|
|
}
|
|
changed = true
|
|
}
|
|
}
|
|
if changed {
|
|
if updated, err := json.Marshal(distTags); err != nil {
|
|
log.Warnf("Cooldown: failed to marshal updated dist-tags: %v", err)
|
|
} else {
|
|
metadata["dist-tags"] = updated
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
result, err := json.Marshal(metadata)
|
|
if err != nil {
|
|
log.Warnf("Cooldown: failed to marshal final metadata: %v", err)
|
|
return body, 0, remaining
|
|
}
|
|
|
|
return result, len(tooNew), remaining
|
|
}
|
|
|
|
// oldestVersion returns the version with the earliest publish date.
|
|
// When all versions are blocked by cooldown, this is the version closest
|
|
// to exiting the cooldown window (shortest wait for the user).
|
|
func (h *npmCooldownHandler) oldestVersion(dates map[string]time.Time) (string, time.Time) {
|
|
var oldest string
|
|
var oldestTime time.Time
|
|
|
|
for version, publishDate := range dates {
|
|
if oldestTime.IsZero() || publishDate.Before(oldestTime) {
|
|
oldest = version
|
|
oldestTime = publishDate
|
|
}
|
|
}
|
|
|
|
return oldest, oldestTime
|
|
}
|
|
|
|
// isWithinCooldown reports whether a version published at publishDate is still
|
|
// within the cooldown window of cooldownDays. It also returns the number of
|
|
// whole days since publication.
|
|
func (h *npmCooldownHandler) isWithinCooldown(publishDate time.Time, cooldownDays int) (withinCooldown bool, daysSincePublish int, daysRemaining int) {
|
|
daysSincePublish = int(time.Since(publishDate).Hours() / 24)
|
|
if daysSincePublish < 0 {
|
|
daysSincePublish = 0
|
|
}
|
|
daysRemaining = cooldownDays - daysSincePublish
|
|
if daysRemaining < 0 {
|
|
daysRemaining = 0
|
|
}
|
|
return daysSincePublish < cooldownDays, daysSincePublish, daysRemaining
|
|
}
|
|
|
|
func (h *npmCooldownHandler) latestNonCooldownVersion(dates map[string]time.Time, tooNew map[string]bool) string {
|
|
var latest string
|
|
var latestTime time.Time
|
|
|
|
for version, publishDate := range dates {
|
|
if tooNew[version] {
|
|
continue
|
|
}
|
|
if publishDate.After(latestTime) {
|
|
latest = version
|
|
latestTime = publishDate
|
|
}
|
|
}
|
|
|
|
return latest
|
|
}
|