Files
pmg/proxy/interceptors/npm_registry.go
T
2026-04-03 21:25:50 +05:30

351 lines
11 KiB
Go

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"
)
var npmRegistryDomains = registryConfigMap{
"registry.npmjs.org": {
Host: "registry.npmjs.org",
SupportedForAnalysis: true,
Parser: npmParser{},
},
"registry.yarnpkg.com": {
Host: "registry.yarnpkg.com",
SupportedForAnalysis: true,
Parser: npmParser{},
},
"npm.pkg.github.com": {
Host: "npm.pkg.github.com",
SupportedForAnalysis: false, // Skip analysis for now (private packages, auth complexity)
Parser: npmGithubParser{},
},
"pkg-npm.githubusercontent.com": {
Host: "pkg-npm.githubusercontent.com",
SupportedForAnalysis: false, // Skip analysis (blob storage, redirected downloads)
Parser: npmGithubBlobParser{},
},
}
// 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
}
var _ proxy.Interceptor = (*NpmRegistryInterceptor)(nil)
var _ proxy.MITMDecider = (*NpmRegistryInterceptor)(nil)
// NewNpmRegistryInterceptor creates a new NPM registry interceptor
func NewNpmRegistryInterceptor(
analyzer analyzer.PackageVersionAnalyzer,
cache AnalysisCache,
statsCollector *AnalysisStatsCollector,
confirmationChan chan *ConfirmationRequest,
) *NpmRegistryInterceptor {
return &NpmRegistryInterceptor{
baseRegistryInterceptor: baseRegistryInterceptor{
analyzer: analyzer,
cache: cache,
statsCollector: statsCollector,
confirmationChan: confirmationChan,
},
}
}
// Name returns the interceptor name for logging
func (i *NpmRegistryInterceptor) Name() string {
return "npm-registry-interceptor"
}
func (i *NpmRegistryInterceptor) ShouldMITM(ctx *proxy.RequestContext) bool {
config := npmRegistryDomains.GetConfigForHostname(ctx.Hostname)
if config == nil {
return false
}
return config.SupportedForAnalysis
}
// ShouldIntercept determines if this interceptor should handle the given request
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, 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)
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
}
if !registryConfig.SupportedForAnalysis {
log.Debugf("[%s] Skipping analysis for %s registry (not supported for analysis): %s",
ctx.RequestID, registryConfig.Host, ctx.URL.String())
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
}
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, registryConfig.Host, err)
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
}
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
}
result, err := i.baseRegistryInterceptor.analyzePackage(
ctx,
packagev1.Ecosystem_ECOSYSTEM_NPM,
pkgInfo.GetName(),
pkgInfo.GetVersion(),
)
if err != nil {
log.Errorf("[%s] Failed to analyze package %s@%s: %v", ctx.RequestID, pkgInfo.GetName(), pkgInfo.GetVersion(), err)
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
}
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
}