mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
feat(cooldown): respect trusted_packages in dependency cooldown (#342)
* feat(cooldown): respect trusted_packages in dependency cooldown Trusted packages are now treated as a superset waiver that bypasses every PMG control (malware analysis, cooldown, and any future controls). A globally trusted package is automatically exempt from the cooldown window and no longer needs a duplicate entry in dependency_cooldown.skip. The skip list remains the narrower, cooldown-only waiver for packages that must bypass the cooldown wait but still be malware-scanned. * refactor(cooldown): tag skip reason and audit-log skipped packages Address review feedback on #342: - Restore cooldownSkip to a pure single-list function (SRP); the merge into trusted_packages now happens in a separate mergeCooldownSkip step, driven by the exported CooldownSkip wrapper. - Extend CooldownSkipInfo with a CooldownSkipReason (TrustedPackage / CooldownSkipList) on both SkipAll and per-version entries, so callers can tell apart the broad waiver from the cooldown-only one. When both lists match the same package, trusted_packages wins. - Add audit.LogCooldownSkipped and emit it from the npm and PyPI interceptors on the SkipAll path, alongside the existing info log, carrying the source list as the reason. * refactor(cooldown): inline list merge, audit per-version exemptions Address further review feedback: - Drop the separate mergeCooldownSkip helper; cooldownSkip now writes into a shared *CooldownSkipInfo and is called twice from CooldownSkip (cooldown skip list first, trusted_packages on top so trusted entries override the reason on overlap). - Audit log every exemption, not just SkipAll: a new auditCooldownSkip helper in proxy/interceptors/cooldown.go emits one event per match (package-wide or per-version), each tagged with its source list. LogCooldownSkipped gains a version argument for the per-version case. - Cover the trusted_packages reason path in TestCooldownSkip. * fix(cooldown): avoid double-auditing trusted package exemptions auditCooldownSkip now only emits EventTypeCooldownSkipped for entries that came from dependency_cooldown.skip. Trusted-package exemptions already get an EventTypeInstallTrustedAllowed event at tarball-download time (proxy/interceptors/base_registry.go), so emitting a cooldown event for them too would double-count the same waiver. * emit trusted and cooldown skip events to cloud * fix tests * refactor(cooldown): return value from collectCooldownSkip, short-circuit on trusted SkipAll Address PR review feedback: - Rename cooldownSkip to collectCooldownSkip and return CooldownSkipInfo instead of mutating an input pointer. - Add mergeCooldownSkip to combine per-list results with trusted_packages taking precedence on overlap. - CooldownSkip now consults trusted_packages first and returns immediately on a package-wide trusted exemption (DC skip list cannot add anything). - Extend tests to cover disjoint pinned entries across both lists and the case where DC version-less subsumes a trusted pinned entry. * fix(audit): address cooldown review feedback * fix(cooldown): audit cooldown skips at download time with concrete version Backend rejects PackageVersion messages without a version, and audit logs should reflect the runtime fact (a specific version was skipped) rather than the config rule. Move the audit emission from metadata-request handling to download-request handling, where the concrete version is known, and require version in LogCooldownSkipped. * chore(audit): drop dead scope assignment in LogCooldownSkipped * refactor(cooldown): move skip-list logic into cooldown handlers Registry interceptors no longer compute CooldownSkip or branch on SkipAll; they just call HandleMetadataRequest. The npm and pypi cooldown handlers own the skip lookup, the package-wide exemption short-circuit, and (for pypi) the canonical-name denormalization. Also align LogCooldownSkipped with other LogXxx signatures by taking *packagev1.PackageVersion. * fix: Simplify audit logging for dependency cooldown skip * refactor: Simplify cooldown handling and maintain separation of concepts for trusted and DC skip packages * fix: Code review fixes * fix: Emit cooldown skipped audit event ONLY when an in-window version is skipped --------- Co-authored-by: Abhisek Datta <abhisek.datta@gmail.com>
This commit is contained in:
co-authored by
Abhisek Datta
parent
c17b941ac3
commit
327c9c7068
@@ -71,6 +71,36 @@ func (b *baseRegistryInterceptor) HandleRequest(ctx *proxy.RequestContext) (*pro
|
||||
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
|
||||
}
|
||||
|
||||
// fastAllow short-circuits the request when no security control should run for
|
||||
// this concrete version: insecure-installation mode (analysis globally off) or a
|
||||
// trusted package (waives every control). It returns (response, true) when it
|
||||
// handled the request; (nil, false) otherwise. Insecure is checked first to
|
||||
// preserve the previous ordering inside analyzePackage.
|
||||
func (b *baseRegistryInterceptor) fastAllow(
|
||||
ctx *proxy.RequestContext,
|
||||
ecosystem packagev1.Ecosystem,
|
||||
name, version string,
|
||||
) (*proxy.InterceptorResponse, bool) {
|
||||
pkgVersion := &packagev1.PackageVersion{
|
||||
Package: &packagev1.Package{Ecosystem: ecosystem, Name: name},
|
||||
Version: version,
|
||||
}
|
||||
|
||||
if config.Get().InsecureInstallation {
|
||||
log.Debugf("[%s] Skipping insecure installation", ctx.RequestID)
|
||||
audit.LogInstallInsecureBypass(pkgVersion)
|
||||
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, true
|
||||
}
|
||||
|
||||
if config.IsTrustedPackageRef(ecosystem, name, version) {
|
||||
log.Debugf("[%s] Skipping trusted package: %s/%s@%s", ctx.RequestID, ecosystem.String(), name, version)
|
||||
audit.LogInstallTrustedAllowed(pkgVersion)
|
||||
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, true
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// analyzePackage analyzes a package using the configured analyzer with caching
|
||||
// This method is ecosystem-agnostic and can be used by any registry interceptor
|
||||
func (b *baseRegistryInterceptor) analyzePackage(
|
||||
@@ -79,7 +109,6 @@ func (b *baseRegistryInterceptor) analyzePackage(
|
||||
packageName string,
|
||||
packageVersion string,
|
||||
) (*analyzer.PackageVersionAnalysisResult, error) {
|
||||
// Check if package is trusted before analyzing
|
||||
pkgVersion := &packagev1.PackageVersion{
|
||||
Package: &packagev1.Package{
|
||||
Ecosystem: ecosystem,
|
||||
@@ -88,29 +117,6 @@ func (b *baseRegistryInterceptor) analyzePackage(
|
||||
Version: packageVersion,
|
||||
}
|
||||
|
||||
if cfg := config.Get(); cfg.InsecureInstallation {
|
||||
log.Debugf("[%s] Skipping insecure installation", ctx.RequestID)
|
||||
|
||||
audit.LogInstallInsecureBypass(pkgVersion)
|
||||
|
||||
return &analyzer.PackageVersionAnalysisResult{
|
||||
PackageVersion: pkgVersion,
|
||||
Action: analyzer.ActionAllow,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if config.IsTrustedPackage(pkgVersion) {
|
||||
log.Debugf("[%s] Skipping trusted package: %s/%s@%s",
|
||||
ctx.RequestID, ecosystem.String(), packageName, packageVersion)
|
||||
|
||||
audit.LogInstallTrustedAllowed(pkgVersion)
|
||||
|
||||
return &analyzer.PackageVersionAnalysisResult{
|
||||
PackageVersion: pkgVersion,
|
||||
Action: analyzer.ActionAllow,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if cached, ok := b.cache.Get(ecosystem.String(), packageName, packageVersion); ok {
|
||||
log.Debugf("[%s] Using cached analysis result for %s@%s", ctx.RequestID, packageName, packageVersion)
|
||||
return cached, nil
|
||||
|
||||
@@ -8,10 +8,58 @@ import (
|
||||
|
||||
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
|
||||
"github.com/safedep/pmg/analyzer"
|
||||
pmgconfig "github.com/safedep/pmg/config"
|
||||
"github.com/safedep/pmg/proxy"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func setTrustedPackagesForTest(t *testing.T, pkgs []pmgconfig.TrustedPackage) {
|
||||
t.Helper()
|
||||
orig := pmgconfig.Get().Config.TrustedPackages
|
||||
pmgconfig.Get().Config.TrustedPackages = pkgs
|
||||
require.NoError(t, pmgconfig.PreprocessTrustedPackages(&pmgconfig.Get().Config), "setTrustedPackagesForTest: preprocess")
|
||||
t.Cleanup(func() {
|
||||
pmgconfig.Get().Config.TrustedPackages = orig
|
||||
assert.NoError(t, pmgconfig.PreprocessTrustedPackages(&pmgconfig.Get().Config))
|
||||
})
|
||||
}
|
||||
|
||||
func TestFastAllow_TrustedReturnsAllow(t *testing.T) {
|
||||
setTrustedPackagesForTest(t, []pmgconfig.TrustedPackage{{Purl: "pkg:npm/trusted-pkg"}})
|
||||
|
||||
b := &baseRegistryInterceptor{}
|
||||
ctx := makeTestRequestContext("https://registry.npmjs.org/trusted-pkg/-/trusted-pkg-1.0.0.tgz")
|
||||
|
||||
resp, ok := b.fastAllow(ctx, packagev1.Ecosystem_ECOSYSTEM_NPM, "trusted-pkg", "1.0.0")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, proxy.ActionAllow, resp.Action)
|
||||
}
|
||||
|
||||
func TestFastAllow_UntrustedReturnsFalse(t *testing.T) {
|
||||
setTrustedPackagesForTest(t, nil)
|
||||
|
||||
b := &baseRegistryInterceptor{}
|
||||
ctx := makeTestRequestContext("https://registry.npmjs.org/x/-/x-1.0.0.tgz")
|
||||
|
||||
resp, ok := b.fastAllow(ctx, packagev1.Ecosystem_ECOSYSTEM_NPM, "x", "1.0.0")
|
||||
assert.False(t, ok)
|
||||
assert.Nil(t, resp)
|
||||
}
|
||||
|
||||
func TestFastAllow_InsecureReturnsAllow(t *testing.T) {
|
||||
orig := pmgconfig.Get().InsecureInstallation
|
||||
pmgconfig.Get().InsecureInstallation = true
|
||||
t.Cleanup(func() { pmgconfig.Get().InsecureInstallation = orig })
|
||||
|
||||
b := &baseRegistryInterceptor{}
|
||||
ctx := makeTestRequestContext("https://registry.npmjs.org/any-pkg/-/any-pkg-1.0.0.tgz")
|
||||
|
||||
resp, ok := b.fastAllow(ctx, packagev1.Ecosystem_ECOSYSTEM_NPM, "any-pkg", "1.0.0")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, proxy.ActionAllow, resp.Action)
|
||||
}
|
||||
|
||||
func TestBaseRegistryInterceptor_HandleAnalysisResult(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -6,9 +6,67 @@ import (
|
||||
|
||||
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
|
||||
"github.com/Masterminds/semver"
|
||||
"github.com/safedep/dry/log"
|
||||
pmgconfig "github.com/safedep/pmg/config"
|
||||
"github.com/safedep/pmg/internal/audit"
|
||||
)
|
||||
|
||||
// cooldownExemptions describes the in-window versions that survive cooldown
|
||||
// stripping and why. all is the full exempt set; skipListed is the subset
|
||||
// attributable to dependency_cooldown.skip and is the only set that produces an
|
||||
// audit event — trusted versions surface as install_trusted_allowed via the
|
||||
// proxy fast-allow gate at download time.
|
||||
type cooldownExemptions struct {
|
||||
all map[string]bool
|
||||
skipListed []string
|
||||
}
|
||||
|
||||
// cooldownExemptVersions classifies the versions that must survive stripping
|
||||
// even though they fall within the cooldown window: those trusted via
|
||||
// trusted_packages or on the dependency_cooldown.skip list. Only in-window
|
||||
// versions are examined, bounding the per-version trusted lookup to recent
|
||||
// releases rather than the full (potentially large) version history. A version
|
||||
// that is both trusted and skip-listed is attributed to trusted, mirroring the
|
||||
// download-path precedence where the fast-allow gate wins.
|
||||
func cooldownExemptVersions(ecosystem packagev1.Ecosystem, name string, skip pmgconfig.CooldownSkipInfo, dates map[string]time.Time, cooldownDays int) cooldownExemptions {
|
||||
exempt := cooldownExemptions{all: make(map[string]bool)}
|
||||
for v, publishDate := range dates {
|
||||
if within, _, _ := cooldownIsWithinWindow(publishDate, cooldownDays); !within {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case pmgconfig.IsTrustedPackageRef(ecosystem, name, v):
|
||||
exempt.all[v] = true
|
||||
case skip.SkipAll:
|
||||
// A whole-package skip is one waiver, not a per-version exemption:
|
||||
// keep the version but never emit per-version audit events.
|
||||
// HandleMetadataRequest also short-circuits this case upstream.
|
||||
exempt.all[v] = true
|
||||
case skip.ExemptsVersion(v):
|
||||
exempt.all[v] = true
|
||||
exempt.skipListed = append(exempt.skipListed, v)
|
||||
}
|
||||
}
|
||||
return exempt
|
||||
}
|
||||
|
||||
// auditCooldownSkips emits one dependency_cooldown_skipped audit event per
|
||||
// version that the skip list exempted from an active cooldown window. It is
|
||||
// called from the metadata modifier, where publish dates are known, so the
|
||||
// event only fires for versions a live cooldown would otherwise have stripped.
|
||||
func auditCooldownSkips(requestID string, ecosystem packagev1.Ecosystem, name string, exempt cooldownExemptions) {
|
||||
for _, version := range exempt.skipListed {
|
||||
log.Infof("[%s] Cooldown: %s@%s exempt by %s", requestID, name, version, audit.CooldownSkipReason)
|
||||
|
||||
pv := &packagev1.PackageVersion{}
|
||||
pv.SetPackage(&packagev1.Package{})
|
||||
pv.GetPackage().SetName(name)
|
||||
pv.GetPackage().SetEcosystem(ecosystem)
|
||||
pv.SetVersion(version)
|
||||
audit.LogCooldownSkipped(pv)
|
||||
}
|
||||
}
|
||||
|
||||
// cooldownIsWithinWindow reports whether a version published at publishDate is still
|
||||
// within the cooldown window of cooldownDays. Returns withinCooldown, daysSincePublish,
|
||||
// and daysRemaining.
|
||||
|
||||
@@ -4,9 +4,85 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
|
||||
pmgconfig "github.com/safedep/pmg/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCooldownExemptVersions(t *testing.T) {
|
||||
setTrustedPackagesForTest(t, []pmgconfig.TrustedPackage{
|
||||
{Purl: "pkg:npm/pkg@2.0.0"},
|
||||
{Purl: "pkg:npm/pkg@3.0.0"}, // both trusted and skip-listed below
|
||||
})
|
||||
|
||||
now := time.Now()
|
||||
day := 24 * time.Hour
|
||||
dates := map[string]time.Time{
|
||||
"1.0.0": now.Add(-1 * day), // in window, skip-listed -> audited
|
||||
"2.0.0": now.Add(-1 * day), // in window, trusted -> exempt, not audited
|
||||
"3.0.0": now.Add(-1 * day), // in window, trusted + skip-listed -> trusted wins
|
||||
"4.0.0": now.Add(-100 * day), // skip-listed but out of window -> not exempt
|
||||
"5.0.0": now.Add(-1 * day), // in window, neither -> stripped
|
||||
}
|
||||
skip := pmgconfig.CooldownSkipInfo{Versions: map[string]bool{
|
||||
"1.0.0": true,
|
||||
"3.0.0": true,
|
||||
"4.0.0": true,
|
||||
}}
|
||||
|
||||
exempt := cooldownExemptVersions(packagev1.Ecosystem_ECOSYSTEM_NPM, "pkg", skip, dates, 5)
|
||||
|
||||
assert.ElementsMatch(t, []string{"1.0.0", "2.0.0", "3.0.0"}, keysOf(exempt.all))
|
||||
// Only the in-window, skip-listed, non-trusted version is audited.
|
||||
assert.Equal(t, []string{"1.0.0"}, exempt.skipListed)
|
||||
}
|
||||
|
||||
// An out-of-window skip-listed version, or a zero-day cooldown, must not be
|
||||
// reported as a cooldown bypass: nothing was actually within an active window.
|
||||
func TestCooldownExemptVersions_NoActiveWindow(t *testing.T) {
|
||||
setTrustedPackagesForTest(t, nil)
|
||||
|
||||
now := time.Now()
|
||||
day := 24 * time.Hour
|
||||
dates := map[string]time.Time{"1.0.0": now.Add(-100 * day)}
|
||||
skip := pmgconfig.CooldownSkipInfo{Versions: map[string]bool{"1.0.0": true}}
|
||||
|
||||
oldVersion := cooldownExemptVersions(packagev1.Ecosystem_ECOSYSTEM_NPM, "pkg", skip, dates, 5)
|
||||
assert.Empty(t, oldVersion.skipListed)
|
||||
assert.Empty(t, oldVersion.all)
|
||||
|
||||
freshDates := map[string]time.Time{"1.0.0": now.Add(-1 * day)}
|
||||
zeroDay := cooldownExemptVersions(packagev1.Ecosystem_ECOSYSTEM_NPM, "pkg", skip, freshDates, 0)
|
||||
assert.Empty(t, zeroDay.skipListed)
|
||||
assert.Empty(t, zeroDay.all)
|
||||
}
|
||||
|
||||
// A whole-package skip (version-less entry) exempts every version but must not
|
||||
// produce per-version audit events — it is a single package-level waiver.
|
||||
func TestCooldownExemptVersions_SkipAll(t *testing.T) {
|
||||
setTrustedPackagesForTest(t, nil)
|
||||
|
||||
now := time.Now()
|
||||
day := 24 * time.Hour
|
||||
dates := map[string]time.Time{
|
||||
"1.0.0": now.Add(-1 * day),
|
||||
"2.0.0": now.Add(-1 * day),
|
||||
}
|
||||
skip := pmgconfig.CooldownSkipInfo{SkipAll: true}
|
||||
|
||||
exempt := cooldownExemptVersions(packagev1.Ecosystem_ECOSYSTEM_NPM, "pkg", skip, dates, 5)
|
||||
assert.ElementsMatch(t, []string{"1.0.0", "2.0.0"}, keysOf(exempt.all))
|
||||
assert.Empty(t, exempt.skipListed, "whole-package skip must not emit per-version events")
|
||||
}
|
||||
|
||||
func keysOf(m map[string]bool) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func TestCooldownIsWithinWindow(t *testing.T) {
|
||||
now := time.Now()
|
||||
day := 24 * time.Hour
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -32,8 +33,17 @@ func newNpmCooldownHandler(statsCollector *AnalysisStatsCollector) *npmCooldownH
|
||||
|
||||
// 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, pinnedVersion string, exemptVersions map[string]bool) (*proxy.InterceptorResponse, error) {
|
||||
// a response modifier that strips versions within the cooldown window. Skip-list
|
||||
// semantics are handled here so callers do not need to consult the config.
|
||||
func (h *npmCooldownHandler) HandleMetadataRequest(ctx *proxy.RequestContext, packageName string, cooldownDays int, pinnedVersion string) (*proxy.InterceptorResponse, error) {
|
||||
skip := pmgconfig.CooldownSkip(packagev1.Ecosystem_ECOSYSTEM_NPM, packageName)
|
||||
if skip.SkipAll {
|
||||
// Whole package is on the cooldown skip list: pass metadata through
|
||||
// unmodified. The tarball 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)
|
||||
|
||||
// Force full packument so the response always contains the "time" field.
|
||||
@@ -62,7 +72,9 @@ func (h *npmCooldownHandler) HandleMetadataRequest(ctx *proxy.RequestContext, pa
|
||||
|
||||
log.Debugf("[%s] Cooldown: parsed %d publish dates for %s", ctx.RequestID, len(dates), packageName)
|
||||
|
||||
strippedBody, stripped, remaining := h.stripCooldownVersions(body, dates, cooldownDays, exemptVersions)
|
||||
exempt := cooldownExemptVersions(packagev1.Ecosystem_ECOSYSTEM_NPM, packageName, skip, dates, cooldownDays)
|
||||
auditCooldownSkips(ctx.RequestID, packagev1.Ecosystem_ECOSYSTEM_NPM, packageName, exempt)
|
||||
strippedBody, stripped, remaining := h.stripCooldownVersions(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)
|
||||
|
||||
@@ -432,7 +432,7 @@ func TestNpmCooldown_HandleMetadataRequest_OverridesHeaders(t *testing.T) {
|
||||
ctx.Headers.Set("If-None-Match", `"abc123"`)
|
||||
ctx.Headers.Set("If-Modified-Since", "Wed, 01 Jan 2025 00:00:00 GMT")
|
||||
|
||||
resp, err := handler.HandleMetadataRequest(ctx, "lodash", 5, "", nil)
|
||||
resp, err := handler.HandleMetadataRequest(ctx, "lodash", 5, "")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, proxy.ActionModifyResponse, resp.Action)
|
||||
assert.Equal(t, "application/json", ctx.Headers.Get("Accept"))
|
||||
@@ -454,7 +454,7 @@ func TestNpmCooldown_HandleMetadataRequest_StripsRecentVersions(t *testing.T) {
|
||||
handler := newNpmCooldownHandler(collector)
|
||||
ctx := makeTestRequestContext("https://registry.npmjs.org/testpkg")
|
||||
|
||||
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "", nil)
|
||||
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp.ResponseModifier)
|
||||
|
||||
@@ -489,7 +489,7 @@ func TestNpmCooldown_HandleMetadataRequest_NoVersionsInCooldown(t *testing.T) {
|
||||
handler := newNpmCooldownHandler(collector)
|
||||
ctx := makeTestRequestContext("https://registry.npmjs.org/testpkg")
|
||||
|
||||
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "", nil)
|
||||
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp.ResponseModifier)
|
||||
|
||||
@@ -510,7 +510,7 @@ func TestNpmCooldown_HandleMetadataRequest_AllVersionsInCooldown_RecordsStats(t
|
||||
handler := newNpmCooldownHandler(collector)
|
||||
ctx := makeTestRequestContext("https://registry.npmjs.org/newpkg")
|
||||
|
||||
resp, err := handler.HandleMetadataRequest(ctx, "newpkg", 5, "", nil)
|
||||
resp, err := handler.HandleMetadataRequest(ctx, "newpkg", 5, "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp.ResponseModifier)
|
||||
|
||||
@@ -542,7 +542,7 @@ func TestNpmCooldown_HandleMetadataRequest_AllVersionsInCooldown_ReportsOldestVe
|
||||
handler := newNpmCooldownHandler(collector)
|
||||
ctx := makeTestRequestContext("https://registry.npmjs.org/multipkg")
|
||||
|
||||
resp, err := handler.HandleMetadataRequest(ctx, "multipkg", 100, "", nil)
|
||||
resp, err := handler.HandleMetadataRequest(ctx, "multipkg", 100, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, _, _, err = resp.ResponseModifier(200, http.Header{}, body)
|
||||
@@ -559,7 +559,7 @@ func TestNpmCooldown_HandleMetadataRequest_MalformedJSON_FailOpen(t *testing.T)
|
||||
handler := newNpmCooldownHandler(collector)
|
||||
ctx := makeTestRequestContext("https://registry.npmjs.org/badpkg")
|
||||
|
||||
resp, err := handler.HandleMetadataRequest(ctx, "badpkg", 5, "", nil)
|
||||
resp, err := handler.HandleMetadataRequest(ctx, "badpkg", 5, "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp.ResponseModifier)
|
||||
|
||||
@@ -581,7 +581,7 @@ func TestNpmCooldown_HandleMetadataRequest_PinnedVersionInCooldown_RecordsStats(
|
||||
handler := newNpmCooldownHandler(collector)
|
||||
ctx := makeTestRequestContext("https://registry.npmjs.org/testpkg")
|
||||
|
||||
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "2.0.0", nil)
|
||||
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "2.0.0")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp.ResponseModifier)
|
||||
|
||||
@@ -612,7 +612,7 @@ func TestNpmCooldown_HandleMetadataRequest_PinnedVersionNotInCooldown_NoBlock(t
|
||||
handler := newNpmCooldownHandler(collector)
|
||||
ctx := makeTestRequestContext("https://registry.npmjs.org/testpkg")
|
||||
|
||||
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "1.0.0", nil)
|
||||
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "1.0.0")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp.ResponseModifier)
|
||||
|
||||
@@ -636,7 +636,7 @@ func TestNpmCooldown_HandleMetadataRequest_UnpinnedWithRemainingVersions_NoBlock
|
||||
handler := newNpmCooldownHandler(collector)
|
||||
ctx := makeTestRequestContext("https://registry.npmjs.org/testpkg")
|
||||
|
||||
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "", nil)
|
||||
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp.ResponseModifier)
|
||||
|
||||
@@ -678,6 +678,38 @@ func TestNpmCooldown_InterceptorDelegation_CooldownDisabled(t *testing.T) {
|
||||
assert.Equal(t, "application/vnd.npm.install-v1+json", ctx.Headers.Get("Accept"))
|
||||
}
|
||||
|
||||
func TestNpmCooldown_HandleMetadataRequest_PreservesTrustedVersion(t *testing.T) {
|
||||
setTrustedPackagesForTest(t, []config.TrustedPackage{{Purl: "pkg:npm/testpkg@2.0.0"}})
|
||||
|
||||
now := time.Now()
|
||||
versions := map[string]time.Time{
|
||||
"1.0.0": now.Add(-30 * 24 * time.Hour), // old — eligible
|
||||
"2.0.0": now.Add(-1 * 24 * time.Hour), // fresh, trusted — must be preserved
|
||||
"2.1.0": now.Add(-1 * 24 * time.Hour), // fresh, untrusted — must be stripped
|
||||
}
|
||||
distTags := map[string]string{"latest": "2.1.0"}
|
||||
body := buildTestPackument(versions, distTags)
|
||||
|
||||
collector := NewAnalysisStatsCollector()
|
||||
handler := newNpmCooldownHandler(collector)
|
||||
ctx := makeTestRequestContext("https://registry.npmjs.org/testpkg")
|
||||
|
||||
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp.ResponseModifier)
|
||||
|
||||
_, _, newBody, err := resp.ResponseModifier(200, http.Header{}, body)
|
||||
require.NoError(t, err)
|
||||
|
||||
var meta struct {
|
||||
Versions map[string]json.RawMessage `json:"versions"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(newBody, &meta))
|
||||
assert.Contains(t, meta.Versions, "2.0.0", "trusted fresh version must be preserved")
|
||||
assert.NotContains(t, meta.Versions, "2.1.0", "untrusted fresh version must be stripped")
|
||||
assert.Contains(t, meta.Versions, "1.0.0", "old version must be preserved")
|
||||
}
|
||||
|
||||
func TestNpmCooldown_TarballRequestBypassesCooldown(t *testing.T) {
|
||||
setCooldownConfig(t, config.DependencyCooldownConfig{Enabled: true, Days: 5})
|
||||
|
||||
|
||||
@@ -113,23 +113,20 @@ func (i *NpmRegistryInterceptor) HandleRequest(ctx *proxy.RequestContext) (*prox
|
||||
|
||||
if !pkgInfo.IsFileDownload() {
|
||||
if depCooldownConfig.Enabled {
|
||||
skip := pmgconfig.CooldownSkip(packagev1.Ecosystem_ECOSYSTEM_NPM, pkgInfo.GetName())
|
||||
if skip.SkipAll {
|
||||
// Whole package is on the cooldown skip list: let the metadata pass
|
||||
// through unmodified. The tarball download still hits analyzePackage,
|
||||
// so malware analysis is preserved.
|
||||
log.Infof("[%s] Cooldown: package %s is on the skip list (dependency_cooldown.skip); malware analysis still applies", ctx.RequestID, pkgInfo.GetName())
|
||||
if pmgconfig.IsTrustedPackageAllVersions(packagev1.Ecosystem_ECOSYSTEM_NPM, pkgInfo.GetName()) {
|
||||
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
|
||||
}
|
||||
|
||||
// Version-pinned skip entries (if any) are preserved during stripping.
|
||||
return i.cooldownHandler.HandleMetadataRequest(ctx, pkgInfo.GetName(), depCooldownConfig.Days, i.execContext.PinnedVersions[pkgInfo.GetName()], skip.Versions)
|
||||
return i.cooldownHandler.HandleMetadataRequest(ctx, pkgInfo.GetName(), depCooldownConfig.Days, i.execContext.PinnedVersions[pkgInfo.GetName()])
|
||||
}
|
||||
|
||||
log.Debugf("[%s] Skipping analysis for metadata request: %s", ctx.RequestID, pkgInfo.GetName())
|
||||
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
|
||||
}
|
||||
|
||||
if resp, ok := i.fastAllow(ctx, packagev1.Ecosystem_ECOSYSTEM_NPM, pkgInfo.GetName(), pkgInfo.GetVersion()); ok {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
result, err := i.analyzePackage(
|
||||
ctx,
|
||||
packagev1.Ecosystem_ECOSYSTEM_NPM,
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -28,8 +29,20 @@ func newPypiCooldownHandler(statsCollector *AnalysisStatsCollector) *pypiCooldow
|
||||
// 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.
|
||||
func (h *pypiCooldownHandler) HandleMetadataRequest(ctx *proxy.RequestContext, packageName string, cooldownDays int, pinnedVersion string, exemptVersions map[string]bool) (*proxy.InterceptorResponse, error) {
|
||||
// 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")
|
||||
@@ -62,7 +75,9 @@ func (h *pypiCooldownHandler) HandleMetadataRequest(ctx *proxy.RequestContext, p
|
||||
|
||||
log.Debugf("[%s] Cooldown: parsed %d versions for %s", ctx.RequestID, len(dates), packageName)
|
||||
|
||||
strippedBody, stripped, remaining := h.stripCooldownFiles(body, dates, cooldownDays, exemptVersions)
|
||||
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)
|
||||
|
||||
@@ -357,7 +357,7 @@ func TestPyPICooldown_HandleMetadataRequest_OverridesHeaders(t *testing.T) {
|
||||
ctx.Headers.Set("If-None-Match", `"abc123"`)
|
||||
ctx.Headers.Set("If-Modified-Since", "Wed, 01 Jan 2025 00:00:00 GMT")
|
||||
|
||||
resp, err := handler.HandleMetadataRequest(ctx, "requests", 5, "", nil)
|
||||
resp, err := handler.HandleMetadataRequest(ctx, "requests", 5, "")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, proxy.ActionModifyResponse, resp.Action)
|
||||
assert.Equal(t, "application/vnd.pypi.simple.v1+json", ctx.Headers.Get("Accept"))
|
||||
@@ -371,7 +371,7 @@ func TestPyPICooldown_HandleMetadataRequest_ClientWithoutPEP691_SkipsCooldown(t
|
||||
ctx := makeTestRequestContext("https://pypi.org/simple/requests/")
|
||||
ctx.Headers.Set("Accept", "text/html")
|
||||
|
||||
resp, err := handler.HandleMetadataRequest(ctx, "requests", 5, "", nil)
|
||||
resp, err := handler.HandleMetadataRequest(ctx, "requests", 5, "")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, proxy.ActionAllow, resp.Action)
|
||||
assert.Nil(t, resp.ResponseModifier)
|
||||
@@ -383,7 +383,7 @@ func TestPyPICooldown_HandleMetadataRequest_NonJSONResponse_FailOpen(t *testing.
|
||||
ctx := makeTestRequestContext("https://pypi.org/simple/requests/")
|
||||
ctx.Headers.Set("Accept", pypiSimpleAPIContentType)
|
||||
|
||||
resp, err := handler.HandleMetadataRequest(ctx, "requests", 5, "", nil)
|
||||
resp, err := handler.HandleMetadataRequest(ctx, "requests", 5, "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp.ResponseModifier)
|
||||
|
||||
@@ -410,7 +410,7 @@ func TestPyPICooldown_HandleMetadataRequest_StripsRecentVersions(t *testing.T) {
|
||||
ctx := makeTestRequestContext("https://pypi.org/simple/testpkg/")
|
||||
ctx.Headers.Set("Accept", pypiSimpleAPIContentType)
|
||||
|
||||
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "", nil)
|
||||
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp.ResponseModifier)
|
||||
|
||||
@@ -448,7 +448,7 @@ func TestPyPICooldown_HandleMetadataRequest_AllVersionsInCooldown_RecordsStats(t
|
||||
ctx := makeTestRequestContext("https://pypi.org/simple/newpkg/")
|
||||
ctx.Headers.Set("Accept", pypiSimpleAPIContentType)
|
||||
|
||||
resp, err := handler.HandleMetadataRequest(ctx, "newpkg", 5, "", nil)
|
||||
resp, err := handler.HandleMetadataRequest(ctx, "newpkg", 5, "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp.ResponseModifier)
|
||||
|
||||
@@ -482,7 +482,7 @@ func TestPyPICooldown_HandleMetadataRequest_NoVersionsInCooldown_BodyUnchanged(t
|
||||
ctx := makeTestRequestContext("https://pypi.org/simple/testpkg/")
|
||||
ctx.Headers.Set("Accept", pypiSimpleAPIContentType)
|
||||
|
||||
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "", nil)
|
||||
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp.ResponseModifier)
|
||||
|
||||
@@ -499,7 +499,7 @@ func TestPyPICooldown_HandleMetadataRequest_MalformedJSON_FailOpen(t *testing.T)
|
||||
ctx := makeTestRequestContext("https://pypi.org/simple/badpkg/")
|
||||
ctx.Headers.Set("Accept", pypiSimpleAPIContentType)
|
||||
|
||||
resp, err := handler.HandleMetadataRequest(ctx, "badpkg", 5, "", nil)
|
||||
resp, err := handler.HandleMetadataRequest(ctx, "badpkg", 5, "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp.ResponseModifier)
|
||||
|
||||
@@ -526,7 +526,7 @@ func TestPyPICooldown_HandleMetadataRequest_PinnedVersionInCooldown_RecordsStats
|
||||
ctx := makeTestRequestContext("https://pypi.org/simple/testpkg/")
|
||||
ctx.Headers.Set("Accept", pypiSimpleAPIContentType)
|
||||
|
||||
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "2.0.0", nil)
|
||||
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "2.0.0")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp.ResponseModifier)
|
||||
|
||||
@@ -561,7 +561,7 @@ func TestPyPICooldown_HandleMetadataRequest_PinnedVersionNotInCooldown_NoBlock(t
|
||||
ctx := makeTestRequestContext("https://pypi.org/simple/testpkg/")
|
||||
ctx.Headers.Set("Accept", pypiSimpleAPIContentType)
|
||||
|
||||
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "1.0.0", nil)
|
||||
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "1.0.0")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp.ResponseModifier)
|
||||
|
||||
@@ -589,7 +589,7 @@ func TestPyPICooldown_HandleMetadataRequest_UnpinnedWithRemainingVersions_NoBloc
|
||||
ctx := makeTestRequestContext("https://pypi.org/simple/testpkg/")
|
||||
ctx.Headers.Set("Accept", pypiSimpleAPIContentType)
|
||||
|
||||
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "", nil)
|
||||
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp.ResponseModifier)
|
||||
|
||||
@@ -663,6 +663,49 @@ func TestPyPICooldown_JSONAPIRequest_NotIntercepted(t *testing.T) {
|
||||
assert.Equal(t, "application/json", ctx.Headers.Get("Accept"))
|
||||
}
|
||||
|
||||
func TestPyPICooldown_HandleMetadataRequest_PreservesTrustedVersion(t *testing.T) {
|
||||
// testpkg@2.0.0 is trusted; 2.1.0 is not. Both are fresh (in cooldown).
|
||||
setTrustedPackagesForTest(t, []config.TrustedPackage{{Purl: "pkg:pypi/testpkg@2.0.0"}})
|
||||
|
||||
now := time.Now()
|
||||
day := 24 * time.Hour
|
||||
versions := map[string]time.Time{
|
||||
"1.0.0": now.Add(-30 * day), // old — eligible
|
||||
"2.0.0": now.Add(-1 * day), // fresh, trusted — must be preserved
|
||||
"2.1.0": now.Add(-1 * day), // fresh, untrusted — must be stripped
|
||||
}
|
||||
body := buildTestPEP691Response(versions)
|
||||
|
||||
handler := newPypiCooldownHandler(NewAnalysisStatsCollector())
|
||||
ctx := makeTestRequestContext("https://pypi.org/simple/testpkg/")
|
||||
ctx.Headers.Set("Accept", pypiSimpleAPIContentType)
|
||||
|
||||
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp.ResponseModifier)
|
||||
|
||||
headers := http.Header{}
|
||||
headers.Set("Content-Type", "application/vnd.pypi.simple.v1+json")
|
||||
|
||||
_, _, retBody, err := resp.ResponseModifier(200, headers, body)
|
||||
require.NoError(t, err)
|
||||
|
||||
var result struct {
|
||||
Files []struct {
|
||||
Filename string `json:"filename"`
|
||||
} `json:"files"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(retBody, &result))
|
||||
|
||||
filenames := make([]string, 0, len(result.Files))
|
||||
for _, f := range result.Files {
|
||||
filenames = append(filenames, f.Filename)
|
||||
}
|
||||
assert.Contains(t, filenames, "testpkg-2.0.0.tar.gz", "trusted fresh version must be preserved")
|
||||
assert.NotContains(t, filenames, "testpkg-2.1.0.tar.gz", "untrusted fresh version must be stripped")
|
||||
assert.Contains(t, filenames, "testpkg-1.0.0.tar.gz", "old version must be preserved")
|
||||
}
|
||||
|
||||
func TestPyPICooldown_FileDownloadBypassesCooldown(t *testing.T) {
|
||||
setCooldownConfig(t, config.DependencyCooldownConfig{Enabled: true, Days: 5})
|
||||
|
||||
|
||||
@@ -126,20 +126,10 @@ func (i *PypiRegistryInterceptor) HandleRequest(ctx *proxy.RequestContext) (*pro
|
||||
// for version resolution. JSON API requests (/pypi/{pkg}/json) are allowed through;
|
||||
// they have a different response structure and pip does not use them for installs.
|
||||
if depCooldownConfig.Enabled && strings.HasPrefix(ctx.URL.Path, "/simple/") {
|
||||
// Match the skip list against the normalized name (lowercase, _/. → -),
|
||||
// the same canonical form used for pinned-version lookups, so a PURL
|
||||
// like pkg:pypi/My_Pkg reliably matches the resolved request name.
|
||||
skip := pmgconfig.CooldownSkip(packagev1.Ecosystem_ECOSYSTEM_PYPI, denormalizePyPIPackageName(pkgInfo.GetName()))
|
||||
if skip.SkipAll {
|
||||
// Whole package is on the cooldown skip list: pass metadata through
|
||||
// unmodified. The tarball download still hits analyzePackage, so
|
||||
// malware analysis is preserved.
|
||||
log.Infof("[%s] Cooldown: package %s is on the skip list (dependency_cooldown.skip); malware analysis still applies", ctx.RequestID, pkgInfo.GetName())
|
||||
if pmgconfig.IsTrustedPackageAllVersions(packagev1.Ecosystem_ECOSYSTEM_PYPI, denormalizePyPIPackageName(pkgInfo.GetName())) {
|
||||
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
|
||||
}
|
||||
|
||||
// Version-pinned skip entries (if any) are preserved during stripping.
|
||||
return i.cooldownHandler.HandleMetadataRequest(ctx, pkgInfo.GetName(), depCooldownConfig.Days, i.execContext.PinnedVersions[pkgInfo.GetName()], skip.Versions)
|
||||
return i.cooldownHandler.HandleMetadataRequest(ctx, pkgInfo.GetName(), depCooldownConfig.Days, i.execContext.PinnedVersions[pkgInfo.GetName()])
|
||||
}
|
||||
|
||||
log.Debugf("[%s] Skipping analysis for metadata request: %s", ctx.RequestID, pkgInfo.GetName())
|
||||
@@ -153,6 +143,14 @@ func (i *PypiRegistryInterceptor) HandleRequest(ctx *proxy.RequestContext) (*pro
|
||||
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
|
||||
}
|
||||
|
||||
// Canonical name is used for identity checks (trusted, cooldown); raw name
|
||||
// is kept for analyzePackage so malware analysis sees the original form.
|
||||
canonicalName := denormalizePyPIPackageName(pkgInfo.GetName())
|
||||
|
||||
if resp, ok := i.fastAllow(ctx, packagev1.Ecosystem_ECOSYSTEM_PYPI, canonicalName, pkgInfo.GetVersion()); ok {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Get file type for logging if available
|
||||
fileType := ""
|
||||
if pypiInfo, ok := pkgInfo.(*pypiPackageInfo); ok {
|
||||
|
||||
Reference in New Issue
Block a user