package interceptors import ( "encoding/json" "fmt" "net/http" "strings" "time" packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" "github.com/safedep/dry/log" pmgconfig "github.com/safedep/pmg/config" "github.com/safedep/pmg/proxy" ) const pypiSimpleAPIContentType = "application/vnd.pypi.simple.v1+json" // pypiCooldownHandler handles dependency cooldown for PyPI packages. // It strips recently-published file entries from PEP 691 Simple API responses // so pip's resolver naturally falls back to the latest eligible version. type pypiCooldownHandler struct { statsCollector *AnalysisStatsCollector } func newPypiCooldownHandler(statsCollector *AnalysisStatsCollector) *pypiCooldownHandler { return &pypiCooldownHandler{statsCollector: statsCollector} } // HandleMetadataRequest overrides the Accept header to force a PEP 691 JSON response, // then registers a response modifier that strips files for versions within the cooldown window. // If the client does not support PEP 691 (pip < 22.3), cooldown is skipped to avoid // returning a content type the client cannot parse. Skip-list semantics are // handled here so callers do not need to consult the config. func (h *pypiCooldownHandler) HandleMetadataRequest(ctx *proxy.RequestContext, packageName string, cooldownDays int, pinnedVersion string) (*proxy.InterceptorResponse, error) { // Skip-list entries use the canonical (lowercase, _/. → -) name to match // the same form used for pinned-version lookups. canonical := denormalizePyPIPackageName(packageName) skip := pmgconfig.CooldownSkip(packagev1.Ecosystem_ECOSYSTEM_PYPI, canonical) if skip.SkipAll { // Whole package is on the cooldown skip list: pass metadata through // unmodified. The file download still hits analyzePackage, so malware // analysis is preserved. return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil } log.Debugf("[%s] Cooldown: registering metadata modifier for %s", ctx.RequestID, packageName) originalAccept := ctx.Headers.Get("Accept") clientSupportsPEP691 := strings.Contains(originalAccept, pypiSimpleAPIContentType) if !clientSupportsPEP691 { log.Warnf("[%s] Cooldown: client does not support PEP 691 JSON (Accept: %s), "+ "cooldown cannot be enforced for %s. Upgrade pip to 22.3+ for cooldown support.", ctx.RequestID, originalAccept, packageName) return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil } // Force PEP 691 JSON so we receive upload-time per file entry. ctx.Headers.Set("Accept", pypiSimpleAPIContentType) // Prevent compression so the response body can be parsed as JSON directly. ctx.Headers.Set("Accept-Encoding", "identity") // Strip conditional-GET headers so PyPI cannot return 304 Not Modified. // A 304 has no body — the modifier would receive an empty body, fail to parse // it as JSON, and fail-open, letting the client use its cached (unfiltered) // response. Removing these forces a full 200 response on every request. ctx.Headers.Del("If-None-Match") ctx.Headers.Del("If-Modified-Since") modifier := func(statusCode int, headers http.Header, body []byte) (int, http.Header, []byte, error) { dates, err := h.parsePEP691Files(body) if err != nil { log.Warnf("[%s] Cooldown: failed to parse PEP 691 metadata for %s: %v", ctx.RequestID, packageName, err) return statusCode, headers, body, nil } log.Debugf("[%s] Cooldown: parsed %d versions for %s", ctx.RequestID, len(dates), packageName) exempt := cooldownExemptVersions(packagev1.Ecosystem_ECOSYSTEM_PYPI, canonical, skip, dates, cooldownDays) auditCooldownSkips(ctx.RequestID, packagev1.Ecosystem_ECOSYSTEM_PYPI, canonical, exempt) strippedBody, stripped, remaining := h.stripCooldownFiles(body, dates, cooldownDays, exempt.all) 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) recordCooldownStats(h.statsCollector, packagev1.Ecosystem_ECOSYSTEM_PYPI, packageName, pinnedVersion, dates, remaining, cooldownDays) 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 } // parsePEP691Files extracts the earliest upload-time per version from a PEP 691 JSON body. // Files with missing or unparseable upload-time are skipped (treated as eligible). // Multiple files for the same version (sdist + wheels) use the earliest upload-time. func (h *pypiCooldownHandler) parsePEP691Files(body []byte) (map[string]time.Time, error) { var resp struct { Files []struct { Filename string `json:"filename"` UploadTime string `json:"upload-time"` } `json:"files"` } if err := json.Unmarshal(body, &resp); err != nil { return nil, fmt.Errorf("failed to unmarshal PEP 691 response: %w", err) } dates := make(map[string]time.Time) for _, f := range resp.Files { if f.UploadTime == "" { log.Debugf("Cooldown: skipping file %s with missing upload-time", f.Filename) continue } t, err := parsePEP691UploadTime(f.UploadTime) if err != nil { log.Debugf("Cooldown: skipping file %s with unparseable upload-time %q: %v", f.Filename, f.UploadTime, err) continue } pkgInfo, err := parseFilename(f.Filename) if err != nil { log.Debugf("Cooldown: skipping file %s with unparseable filename: %v", f.Filename, err) continue } version := pkgInfo.GetVersion() if version == "" { continue } // Use the earliest upload-time across all files for a given version if existing, ok := dates[version]; !ok || t.Before(existing) { dates[version] = t } } return dates, nil } // stripCooldownFiles removes all file entries for versions within the cooldown window // from a PEP 691 JSON body. Returns the modified body, number of versions stripped, // and number of versions remaining. func (h *pypiCooldownHandler) stripCooldownFiles(body []byte, dates map[string]time.Time, cooldownDays int, exemptVersions map[string]bool) ([]byte, int, int) { tooNew := make(map[string]bool) for version, uploadDate := range dates { // Version-pinned skip entries are never stripped, even inside the window. if exemptVersions[version] { continue } if within, _, _ := cooldownIsWithinWindow(uploadDate, cooldownDays); within { tooNew[version] = true } } remaining := len(dates) - len(tooNew) if len(tooNew) == 0 { return body, 0, remaining } var resp map[string]json.RawMessage if err := json.Unmarshal(body, &resp); err != nil { log.Warnf("Cooldown: failed to unmarshal PEP 691 body for stripping: %v", err) return body, 0, remaining } rawFiles, ok := resp["files"] if !ok { return body, 0, remaining } var files []json.RawMessage if err := json.Unmarshal(rawFiles, &files); err != nil { log.Warnf("Cooldown: failed to unmarshal files array: %v", err) return body, 0, remaining } filtered := make([]json.RawMessage, 0, len(files)) for _, rawFile := range files { var f struct { Filename string `json:"filename"` } if err := json.Unmarshal(rawFile, &f); err != nil { // Keep files we cannot parse to avoid accidentally dropping valid entries filtered = append(filtered, rawFile) continue } pkgInfo, err := parseFilename(f.Filename) if err != nil { // unparseable filename — keep it (fail-open) filtered = append(filtered, rawFile) continue } if tooNew[pkgInfo.GetVersion()] { continue // strip } filtered = append(filtered, rawFile) } updatedFiles, err := json.Marshal(filtered) if err != nil { log.Warnf("Cooldown: failed to marshal filtered files array: %v", err) return body, 0, remaining } resp["files"] = updatedFiles result, err := json.Marshal(resp) if err != nil { log.Warnf("Cooldown: failed to marshal final PEP 691 response: %v", err) return body, 0, remaining } return result, len(tooNew), remaining } // parsePEP691UploadTime parses the ISO 8601 upload-time field from PEP 691 responses. // Example: "2023-05-22T15:12:44.000000+00:00" func parsePEP691UploadTime(s string) (time.Time, error) { if t, err := time.Parse(time.RFC3339Nano, s); err == nil { return t, nil } return time.Parse(time.RFC3339, s) }