Files
pmg/proxy/interceptors/base_registry_test.go
T
327c9c7068 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>
2026-06-21 18:22:15 +05:30

207 lines
6.6 KiB
Go

package interceptors
import (
"net/http"
"net/url"
"testing"
"time"
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
ecosystem packagev1.Ecosystem
packageName string
packageVersion string
analysisResult *analyzer.PackageVersionAnalysisResult
userConfirms bool
expectedAction proxy.ResponseAction
expectedBlockCode int
expectBlockMessage bool
}{
{
name: "ActionBlock - malicious package",
ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM,
packageName: "malicious-pkg",
packageVersion: "1.0.0",
analysisResult: &analyzer.PackageVersionAnalysisResult{
Action: analyzer.ActionBlock,
Summary: "Contains known malware",
ReferenceURL: "https://example.com/malware-report",
},
expectedAction: proxy.ActionBlock,
expectedBlockCode: http.StatusForbidden,
expectBlockMessage: true,
},
{
name: "ActionConfirm - user confirms installation",
ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM,
packageName: "suspicious-pkg",
packageVersion: "2.0.0",
analysisResult: &analyzer.PackageVersionAnalysisResult{
Action: analyzer.ActionConfirm,
Summary: "Suspicious behavior detected",
ReferenceURL: "https://example.com/suspicious-report",
},
userConfirms: true,
expectedAction: proxy.ActionAllow,
expectedBlockCode: 0,
expectBlockMessage: false,
},
{
name: "ActionConfirm - user declines installation",
ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM,
packageName: "suspicious-pkg",
packageVersion: "2.0.0",
analysisResult: &analyzer.PackageVersionAnalysisResult{
Action: analyzer.ActionConfirm,
Summary: "Suspicious behavior detected",
ReferenceURL: "https://example.com/suspicious-report",
},
userConfirms: false,
expectedAction: proxy.ActionBlock,
expectedBlockCode: http.StatusForbidden,
expectBlockMessage: true,
},
// Note: Timeout test case is skipped as it would require waiting 5 minutes
// The timeout behavior is covered by the implementation but not tested here
// to keep tests fast
{
name: "ActionAllow - safe package",
ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM,
packageName: "safe-pkg",
packageVersion: "3.0.0",
analysisResult: &analyzer.PackageVersionAnalysisResult{
Action: analyzer.ActionAllow,
Summary: "Package is safe",
ReferenceURL: "https://example.com/safe-report",
},
expectedAction: proxy.ActionAllow,
expectedBlockCode: 0,
expectBlockMessage: false,
},
{
name: "ActionUnknown - default to allow",
ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM,
packageName: "unknown-pkg",
packageVersion: "4.0.0",
analysisResult: &analyzer.PackageVersionAnalysisResult{
Action: analyzer.ActionUnknown,
Summary: "Unknown action",
ReferenceURL: "https://example.com/unknown-report",
},
expectedAction: proxy.ActionAllow,
expectedBlockCode: 0,
expectBlockMessage: false,
},
{
name: "ActionBlock - pypi ecosystem",
ecosystem: packagev1.Ecosystem_ECOSYSTEM_PYPI,
packageName: "malicious-pypi-pkg",
packageVersion: "5.0.0",
analysisResult: &analyzer.PackageVersionAnalysisResult{
Action: analyzer.ActionBlock,
Summary: "Malicious PyPI package",
ReferenceURL: "https://example.com/pypi-malware",
},
expectedAction: proxy.ActionBlock,
expectedBlockCode: http.StatusForbidden,
expectBlockMessage: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
confirmationChan := make(chan *ConfirmationRequest, 1)
base := &baseRegistryInterceptor{
confirmationChan: confirmationChan,
}
parsedURL, _ := url.Parse("https://registry.npmjs.org/test")
ctx := &proxy.RequestContext{
URL: parsedURL,
Method: "GET",
Headers: make(http.Header),
RequestID: "test-request-id",
StartTime: time.Now(),
Data: make(map[string]interface{}),
}
if tt.analysisResult.Action == analyzer.ActionConfirm {
go func() {
req := <-confirmationChan
req.ResponseChan <- tt.userConfirms
close(req.ResponseChan)
}()
}
response, err := base.handleAnalysisResult(
ctx,
tt.ecosystem,
tt.packageName,
tt.packageVersion,
tt.analysisResult,
)
assert.NoError(t, err)
assert.Equal(t, tt.expectedAction, response.Action)
assert.Equal(t, tt.expectedBlockCode, response.BlockCode)
assert.Equal(t, tt.expectBlockMessage, response.BlockMessage != "")
})
}
}