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 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>
734 lines
25 KiB
Go
734 lines
25 KiB
Go
package interceptors
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/url"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/safedep/pmg/config"
|
|
"github.com/safedep/pmg/proxy"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func setCooldownConfig(t *testing.T, cfg config.DependencyCooldownConfig) {
|
|
t.Helper()
|
|
orig := config.Get().Config.DependencyCooldown
|
|
t.Cleanup(func() { config.Get().Config.DependencyCooldown = orig })
|
|
config.Get().Config.DependencyCooldown = cfg
|
|
}
|
|
|
|
func mustParseURL(rawURL string) *url.URL {
|
|
u, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
panic("mustParseURL: " + err.Error())
|
|
}
|
|
return u
|
|
}
|
|
|
|
func TestParseNpmMetadataTime(t *testing.T) {
|
|
handler := newNpmCooldownHandler(nil)
|
|
|
|
tests := []struct {
|
|
name string
|
|
body []byte
|
|
expectedCount int
|
|
expectError bool
|
|
}{
|
|
{
|
|
name: "valid metadata with 3 versions",
|
|
body: []byte(`{
|
|
"time": {
|
|
"created": "2020-01-01T00:00:00.000Z",
|
|
"modified": "2024-01-01T00:00:00.000Z",
|
|
"1.0.0": "2020-06-01T00:00:00.000Z",
|
|
"1.0.1": "2021-06-01T00:00:00.000Z",
|
|
"1.0.2": "2022-06-01T00:00:00.000Z"
|
|
}
|
|
}`),
|
|
expectedCount: 3,
|
|
},
|
|
{
|
|
name: "metadata without time field",
|
|
body: []byte(`{"name":"foo","version":"1.0.0"}`),
|
|
expectedCount: 0,
|
|
},
|
|
{
|
|
name: "only skip keys",
|
|
body: []byte(`{"time":{"created":"2020-01-01T00:00:00.000Z","modified":"2024-01-01T00:00:00.000Z"}}`),
|
|
expectedCount: 0,
|
|
},
|
|
{
|
|
name: "invalid JSON",
|
|
body: []byte(`not-json`),
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "unparseable dates skipped",
|
|
body: []byte(`{
|
|
"time": {
|
|
"1.0.0": "not-a-date",
|
|
"1.0.1": "2022-06-01T00:00:00.000Z"
|
|
}
|
|
}`),
|
|
expectedCount: 1,
|
|
},
|
|
{
|
|
name: "RFC3339 without millis",
|
|
body: []byte(`{
|
|
"time": {
|
|
"1.0.0": "2022-06-01T00:00:00Z"
|
|
}
|
|
}`),
|
|
expectedCount: 1,
|
|
},
|
|
{
|
|
name: "millis precision",
|
|
body: []byte(`{
|
|
"time": {
|
|
"1.0.0": "2022-06-01T00:00:00.000Z"
|
|
}
|
|
}`),
|
|
expectedCount: 1,
|
|
},
|
|
{
|
|
name: "empty body",
|
|
body: []byte(``),
|
|
expectError: true,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
dates, err := handler.parseMetadataTime(tc.body)
|
|
if tc.expectError {
|
|
assert.Error(t, err)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tc.expectedCount, len(dates))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseNpmMetadataTime_CorrectDates(t *testing.T) {
|
|
handler := newNpmCooldownHandler(nil)
|
|
|
|
body := []byte(`{
|
|
"time": {
|
|
"created": "2021-01-01T00:00:00.000Z",
|
|
"modified": "2024-01-01T00:00:00.000Z",
|
|
"4.17.20": "2021-02-17T12:00:00.000Z",
|
|
"4.17.21": "2021-05-19T12:00:00.000Z"
|
|
}
|
|
}`)
|
|
|
|
dates, err := handler.parseMetadataTime(body)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, 2, len(dates))
|
|
|
|
d4_17_20, ok := dates["4.17.20"]
|
|
require.True(t, ok, "expected 4.17.20 in dates")
|
|
assert.Equal(t, 2021, d4_17_20.Year())
|
|
assert.Equal(t, time.February, d4_17_20.Month())
|
|
assert.Equal(t, 17, d4_17_20.Day())
|
|
|
|
d4_17_21, ok := dates["4.17.21"]
|
|
require.True(t, ok, "expected 4.17.21 in dates")
|
|
assert.Equal(t, 2021, d4_17_21.Year())
|
|
assert.Equal(t, time.May, d4_17_21.Month())
|
|
assert.Equal(t, 19, d4_17_21.Day())
|
|
|
|
_, hasCreated := dates["created"]
|
|
assert.False(t, hasCreated)
|
|
_, hasModified := dates["modified"]
|
|
assert.False(t, hasModified)
|
|
}
|
|
|
|
func buildTestPackument(versions map[string]time.Time, distTags map[string]string) []byte {
|
|
timeMap := map[string]string{
|
|
"created": "2020-01-01T00:00:00.000Z",
|
|
"modified": "2024-01-01T00:00:00.000Z",
|
|
}
|
|
versionsMap := map[string]any{}
|
|
for v, t := range versions {
|
|
timeMap[v] = t.Format(time.RFC3339)
|
|
versionsMap[v] = map[string]any{"version": v}
|
|
}
|
|
|
|
packument := map[string]any{
|
|
"name": "testpkg",
|
|
"time": timeMap,
|
|
"versions": versionsMap,
|
|
"dist-tags": distTags,
|
|
}
|
|
b, _ := json.Marshal(packument)
|
|
return b
|
|
}
|
|
|
|
func TestStripCooldownVersions_MixedVersions(t *testing.T) {
|
|
handler := newNpmCooldownHandler(nil)
|
|
now := time.Now()
|
|
versions := map[string]time.Time{
|
|
"1.0.0": now.Add(-30 * 24 * time.Hour), // old
|
|
"1.0.1": now.Add(-10 * 24 * time.Hour), // old
|
|
"1.0.2": now.Add(-1 * 24 * time.Hour), // too new (within 5d cooldown)
|
|
}
|
|
distTags := map[string]string{"latest": "1.0.2"}
|
|
body := buildTestPackument(versions, distTags)
|
|
|
|
dates, err := handler.parseMetadataTime(body)
|
|
require.NoError(t, err)
|
|
|
|
newBody, stripped, remaining := handler.stripCooldownVersions(body, dates, 5, nil)
|
|
assert.Equal(t, 1, stripped)
|
|
assert.Equal(t, 2, remaining)
|
|
|
|
var result map[string]json.RawMessage
|
|
require.NoError(t, json.Unmarshal(newBody, &result))
|
|
|
|
var resultVersions map[string]json.RawMessage
|
|
require.NoError(t, json.Unmarshal(result["versions"], &resultVersions))
|
|
assert.NotContains(t, resultVersions, "1.0.2")
|
|
assert.Contains(t, resultVersions, "1.0.0")
|
|
assert.Contains(t, resultVersions, "1.0.1")
|
|
|
|
var resultDistTags map[string]string
|
|
require.NoError(t, json.Unmarshal(result["dist-tags"], &resultDistTags))
|
|
// latest should be repaired to the highest stable eligible version
|
|
assert.Equal(t, "1.0.1", resultDistTags["latest"])
|
|
|
|
var resultTime map[string]string
|
|
require.NoError(t, json.Unmarshal(result["time"], &resultTime))
|
|
assert.Contains(t, resultTime, "created")
|
|
assert.Contains(t, resultTime, "modified")
|
|
assert.NotContains(t, resultTime, "1.0.2")
|
|
assert.Contains(t, resultTime, "1.0.0")
|
|
assert.Contains(t, resultTime, "1.0.1")
|
|
}
|
|
|
|
func TestStripCooldownVersions_AllVersionsTooNew(t *testing.T) {
|
|
handler := newNpmCooldownHandler(nil)
|
|
now := time.Now()
|
|
versions := map[string]time.Time{
|
|
"1.0.0": now.Add(-1 * 24 * time.Hour), // too new
|
|
"1.0.1": now.Add(-2 * 24 * time.Hour), // too new
|
|
}
|
|
distTags := map[string]string{"latest": "1.0.1"}
|
|
body := buildTestPackument(versions, distTags)
|
|
|
|
dates, err := handler.parseMetadataTime(body)
|
|
require.NoError(t, err)
|
|
|
|
newBody, stripped, remaining := handler.stripCooldownVersions(body, dates, 5, nil)
|
|
assert.Equal(t, 2, stripped)
|
|
assert.Equal(t, 0, remaining)
|
|
|
|
var result map[string]json.RawMessage
|
|
require.NoError(t, json.Unmarshal(newBody, &result))
|
|
|
|
var resultDistTags map[string]string
|
|
require.NoError(t, json.Unmarshal(result["dist-tags"], &resultDistTags))
|
|
// No eligible version exists, dist-tag should be removed
|
|
assert.Empty(t, resultDistTags)
|
|
}
|
|
|
|
func TestStripCooldownVersions_NoVersionsTooNew(t *testing.T) {
|
|
handler := newNpmCooldownHandler(nil)
|
|
now := time.Now()
|
|
versions := map[string]time.Time{
|
|
"1.0.0": now.Add(-10 * 24 * time.Hour), // old enough
|
|
"1.0.1": now.Add(-20 * 24 * time.Hour), // old enough
|
|
}
|
|
distTags := map[string]string{"latest": "1.0.0"}
|
|
body := buildTestPackument(versions, distTags)
|
|
|
|
dates, err := handler.parseMetadataTime(body)
|
|
require.NoError(t, err)
|
|
|
|
newBody, stripped, remaining := handler.stripCooldownVersions(body, dates, 5, nil)
|
|
assert.Equal(t, 0, stripped)
|
|
assert.Equal(t, 2, remaining)
|
|
assert.Equal(t, body, newBody) // body unchanged
|
|
}
|
|
|
|
func TestStripCooldownVersions_SingleVersionInCooldown(t *testing.T) {
|
|
handler := newNpmCooldownHandler(nil)
|
|
now := time.Now()
|
|
versions := map[string]time.Time{
|
|
"1.0.0": now.Add(-1 * 24 * time.Hour), // too new
|
|
}
|
|
distTags := map[string]string{"latest": "1.0.0"}
|
|
body := buildTestPackument(versions, distTags)
|
|
|
|
dates, err := handler.parseMetadataTime(body)
|
|
require.NoError(t, err)
|
|
|
|
_, stripped, remaining := handler.stripCooldownVersions(body, dates, 5, nil)
|
|
assert.Equal(t, 1, stripped)
|
|
assert.Equal(t, 0, remaining)
|
|
}
|
|
|
|
func TestStripCooldownVersions_MalformedJSON(t *testing.T) {
|
|
handler := newNpmCooldownHandler(nil)
|
|
body := []byte(`not-json`)
|
|
dates := map[string]time.Time{"1.0.0": time.Now().Add(-1 * time.Hour)}
|
|
|
|
newBody, stripped, _ := handler.stripCooldownVersions(body, dates, 5, nil)
|
|
assert.Equal(t, 0, stripped)
|
|
assert.Equal(t, body, newBody)
|
|
}
|
|
|
|
// Regression for #275: when the stable version that dist-tags.latest points to is
|
|
// stripped, latest must be repaired to the highest *stable* eligible version — never
|
|
// a more-recently-published prerelease or platform-specific build (e.g. -win32-arm64).
|
|
func TestStripCooldownVersions_LatestRepairedToStableNotPlatform(t *testing.T) {
|
|
handler := newNpmCooldownHandler(nil)
|
|
now := time.Now()
|
|
day := 24 * time.Hour
|
|
versions := map[string]time.Time{
|
|
"0.131.0": now.Add(-40 * day), // old stable
|
|
"0.132.0": now.Add(-30 * day), // old stable — expected latest after repair
|
|
"0.132.5-win32-arm64": now.Add(-6 * day), // eligible platform build, newer than 0.132.0
|
|
"0.133.0": now.Add(-1 * day), // too new stable (current latest)
|
|
"0.133.0-win32-arm64": now.Add(-1 * day), // too new platform build
|
|
}
|
|
distTags := map[string]string{"latest": "0.133.0"}
|
|
body := buildTestPackument(versions, distTags)
|
|
|
|
dates, err := handler.parseMetadataTime(body)
|
|
require.NoError(t, err)
|
|
|
|
newBody, _, _ := handler.stripCooldownVersions(body, dates, 5, nil)
|
|
|
|
var result map[string]json.RawMessage
|
|
require.NoError(t, json.Unmarshal(newBody, &result))
|
|
|
|
var resultDistTags map[string]string
|
|
require.NoError(t, json.Unmarshal(result["dist-tags"], &resultDistTags))
|
|
assert.Equal(t, "0.132.0", resultDistTags["latest"],
|
|
"latest must be the highest stable eligible version, not a platform/prerelease build")
|
|
}
|
|
|
|
// Non-latest dist-tags whose target is stripped should be removed, not rewritten to
|
|
// an unrelated version.
|
|
func TestStripCooldownVersions_NonLatestTagRemovedWhenStripped(t *testing.T) {
|
|
handler := newNpmCooldownHandler(nil)
|
|
now := time.Now()
|
|
day := 24 * time.Hour
|
|
versions := map[string]time.Time{
|
|
"1.0.0": now.Add(-30 * day), // eligible stable
|
|
"2.0.0-beta.1": now.Add(-1 * day), // too new prerelease
|
|
}
|
|
distTags := map[string]string{"latest": "1.0.0", "next": "2.0.0-beta.1"}
|
|
body := buildTestPackument(versions, distTags)
|
|
|
|
dates, err := handler.parseMetadataTime(body)
|
|
require.NoError(t, err)
|
|
|
|
newBody, _, _ := handler.stripCooldownVersions(body, dates, 5, nil)
|
|
|
|
var result map[string]json.RawMessage
|
|
require.NoError(t, json.Unmarshal(newBody, &result))
|
|
|
|
var resultDistTags map[string]string
|
|
require.NoError(t, json.Unmarshal(result["dist-tags"], &resultDistTags))
|
|
assert.Equal(t, "1.0.0", resultDistTags["latest"], "eligible latest tag should be untouched")
|
|
assert.NotContains(t, resultDistTags, "next", "stripped non-latest tag should be removed")
|
|
}
|
|
|
|
// A repaired latest must point to a version that still exists in the "versions"
|
|
// object. A version present only in "time" (e.g. an unpublished version whose
|
|
// timestamp lingers) must not be promoted to latest, or npm would get a dangling tag.
|
|
func TestStripCooldownVersions_LatestRepairSkipsVersionsMissingFromPackument(t *testing.T) {
|
|
handler := newNpmCooldownHandler(nil)
|
|
old := time.Now().Add(-30 * 24 * time.Hour).Format(time.RFC3339)
|
|
tooNew := time.Now().Add(-1 * 24 * time.Hour).Format(time.RFC3339)
|
|
|
|
// "9.9.9" appears in time but NOT in versions; "2.0.0" (latest) is in cooldown.
|
|
body := []byte(`{
|
|
"name": "testpkg",
|
|
"dist-tags": {"latest": "2.0.0"},
|
|
"versions": {
|
|
"1.0.0": {"version": "1.0.0"},
|
|
"1.0.1": {"version": "1.0.1"},
|
|
"2.0.0": {"version": "2.0.0"}
|
|
},
|
|
"time": {
|
|
"1.0.0": "` + old + `",
|
|
"1.0.1": "` + old + `",
|
|
"9.9.9": "` + old + `",
|
|
"2.0.0": "` + tooNew + `"
|
|
}
|
|
}`)
|
|
|
|
dates, err := handler.parseMetadataTime(body)
|
|
require.NoError(t, err)
|
|
|
|
newBody, _, _ := handler.stripCooldownVersions(body, dates, 5, nil)
|
|
|
|
var result map[string]json.RawMessage
|
|
require.NoError(t, json.Unmarshal(newBody, &result))
|
|
|
|
var resultDistTags map[string]string
|
|
require.NoError(t, json.Unmarshal(result["dist-tags"], &resultDistTags))
|
|
assert.Equal(t, "1.0.1", resultDistTags["latest"],
|
|
"latest must come from versions present in the packument, not a time-only entry")
|
|
}
|
|
|
|
// Repairing latest must respect the maintainer's dist-tag lineage: when latest is
|
|
// pinned to an older line while a higher stable major lives under another channel
|
|
// (e.g. next), stripping the fresh latest must fall back within the blessed line,
|
|
// not promote the unrelated higher major.
|
|
func TestStripCooldownVersions_LatestRepairStaysWithinBlessedLineage(t *testing.T) {
|
|
handler := newNpmCooldownHandler(nil)
|
|
now := time.Now()
|
|
day := 24 * time.Hour
|
|
versions := map[string]time.Time{
|
|
"1.4.0": now.Add(-40 * day), // eligible — previous blessed release
|
|
"1.5.0": now.Add(-1 * day), // fresh — current latest, stripped
|
|
"2.0.0": now.Add(-30 * day), // eligible higher major, published under `next`
|
|
}
|
|
distTags := map[string]string{"latest": "1.5.0", "next": "2.0.0"}
|
|
body := buildTestPackument(versions, distTags)
|
|
|
|
dates, err := handler.parseMetadataTime(body)
|
|
require.NoError(t, err)
|
|
|
|
newBody, _, _ := handler.stripCooldownVersions(body, dates, 5, nil)
|
|
|
|
var result map[string]json.RawMessage
|
|
require.NoError(t, json.Unmarshal(newBody, &result))
|
|
|
|
var resultDistTags map[string]string
|
|
require.NoError(t, json.Unmarshal(result["dist-tags"], &resultDistTags))
|
|
assert.Equal(t, "1.4.0", resultDistTags["latest"],
|
|
"latest must stay within the lineage it was pinned to, not jump to a higher major")
|
|
assert.Equal(t, "2.0.0", resultDistTags["next"], "eligible non-latest tag should be untouched")
|
|
}
|
|
|
|
func makeTestRequestContext(rawURL string) *proxy.RequestContext {
|
|
u := mustParseURL(rawURL)
|
|
return &proxy.RequestContext{
|
|
URL: u,
|
|
Method: "GET",
|
|
Headers: http.Header{},
|
|
Hostname: u.Host,
|
|
RequestID: "test-req-1",
|
|
StartTime: time.Now(),
|
|
}
|
|
}
|
|
|
|
func TestNpmCooldown_HandleMetadataRequest_OverridesHeaders(t *testing.T) {
|
|
collector := NewAnalysisStatsCollector()
|
|
handler := newNpmCooldownHandler(collector)
|
|
|
|
ctx := makeTestRequestContext("https://registry.npmjs.org/lodash")
|
|
ctx.Headers.Set("Accept", "application/vnd.npm.install-v1+json")
|
|
ctx.Headers.Set("Accept-Encoding", "gzip")
|
|
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, "")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, proxy.ActionModifyResponse, resp.Action)
|
|
assert.Equal(t, "application/json", ctx.Headers.Get("Accept"))
|
|
assert.Equal(t, "identity", ctx.Headers.Get("Accept-Encoding"))
|
|
assert.Empty(t, ctx.Headers.Get("If-None-Match"))
|
|
assert.Empty(t, ctx.Headers.Get("If-Modified-Since"))
|
|
}
|
|
|
|
func TestNpmCooldown_HandleMetadataRequest_StripsRecentVersions(t *testing.T) {
|
|
now := time.Now()
|
|
versions := map[string]time.Time{
|
|
"1.0.0": now.Add(-30 * 24 * time.Hour), // old
|
|
"1.0.1": now.Add(-1 * 24 * time.Hour), // too new
|
|
}
|
|
distTags := map[string]string{"latest": "1.0.1"}
|
|
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)
|
|
|
|
newStatus, newHeaders, newBody, err := resp.ResponseModifier(200, http.Header{}, body)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 200, newStatus)
|
|
_ = newHeaders
|
|
|
|
var result map[string]json.RawMessage
|
|
require.NoError(t, json.Unmarshal(newBody, &result))
|
|
|
|
var resultVersions map[string]json.RawMessage
|
|
require.NoError(t, json.Unmarshal(result["versions"], &resultVersions))
|
|
assert.Contains(t, resultVersions, "1.0.0")
|
|
assert.NotContains(t, resultVersions, "1.0.1")
|
|
|
|
var resultDistTags map[string]string
|
|
require.NoError(t, json.Unmarshal(result["dist-tags"], &resultDistTags))
|
|
assert.Equal(t, "1.0.0", resultDistTags["latest"])
|
|
}
|
|
|
|
func TestNpmCooldown_HandleMetadataRequest_NoVersionsInCooldown(t *testing.T) {
|
|
now := time.Now()
|
|
versions := map[string]time.Time{
|
|
"1.0.0": now.Add(-30 * 24 * time.Hour), // old
|
|
"1.0.1": now.Add(-20 * 24 * time.Hour), // old
|
|
}
|
|
distTags := map[string]string{"latest": "1.0.1"}
|
|
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)
|
|
assert.Equal(t, body, newBody)
|
|
}
|
|
|
|
func TestNpmCooldown_HandleMetadataRequest_AllVersionsInCooldown_RecordsStats(t *testing.T) {
|
|
now := time.Now()
|
|
versions := map[string]time.Time{
|
|
"1.0.0": now.Add(-1 * 24 * time.Hour), // too new
|
|
}
|
|
distTags := map[string]string{"latest": "1.0.0"}
|
|
body := buildTestPackument(versions, distTags)
|
|
|
|
collector := NewAnalysisStatsCollector()
|
|
handler := newNpmCooldownHandler(collector)
|
|
ctx := makeTestRequestContext("https://registry.npmjs.org/newpkg")
|
|
|
|
resp, err := handler.HandleMetadataRequest(ctx, "newpkg", 5, "")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, resp.ResponseModifier)
|
|
|
|
_, _, _, err = resp.ResponseModifier(200, http.Header{}, body)
|
|
require.NoError(t, err)
|
|
|
|
blocks := collector.GetCooldownBlocks()
|
|
require.Len(t, blocks, 1)
|
|
assert.Equal(t, "newpkg", blocks[0].Name)
|
|
assert.Equal(t, "1.0.0", blocks[0].Version)
|
|
assert.Equal(t, 5, blocks[0].CooldownDays)
|
|
|
|
stats := collector.GetStats()
|
|
assert.Equal(t, 1, stats.CooldownBlockedCount)
|
|
assert.Equal(t, 1, stats.BlockedCount)
|
|
}
|
|
|
|
func TestNpmCooldown_HandleMetadataRequest_AllVersionsInCooldown_ReportsOldestVersion(t *testing.T) {
|
|
now := time.Now()
|
|
versions := map[string]time.Time{
|
|
"1.0.0": now.Add(-90 * 24 * time.Hour), // oldest — closest to exiting cooldown
|
|
"2.0.0": now.Add(-30 * 24 * time.Hour),
|
|
"2.1.0": now.Add(-1 * 24 * time.Hour), // newest — farthest from exiting cooldown
|
|
}
|
|
distTags := map[string]string{"latest": "2.1.0"}
|
|
body := buildTestPackument(versions, distTags)
|
|
|
|
collector := NewAnalysisStatsCollector()
|
|
handler := newNpmCooldownHandler(collector)
|
|
ctx := makeTestRequestContext("https://registry.npmjs.org/multipkg")
|
|
|
|
resp, err := handler.HandleMetadataRequest(ctx, "multipkg", 100, "")
|
|
require.NoError(t, err)
|
|
|
|
_, _, _, err = resp.ResponseModifier(200, http.Header{}, body)
|
|
require.NoError(t, err)
|
|
|
|
blocks := collector.GetCooldownBlocks()
|
|
require.Len(t, blocks, 1)
|
|
assert.Equal(t, "1.0.0", blocks[0].Version, "should report oldest version (closest to exiting cooldown)")
|
|
}
|
|
|
|
func TestNpmCooldown_HandleMetadataRequest_MalformedJSON_FailOpen(t *testing.T) {
|
|
body := []byte(`not-json`)
|
|
collector := NewAnalysisStatsCollector()
|
|
handler := newNpmCooldownHandler(collector)
|
|
ctx := makeTestRequestContext("https://registry.npmjs.org/badpkg")
|
|
|
|
resp, err := handler.HandleMetadataRequest(ctx, "badpkg", 5, "")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, resp.ResponseModifier)
|
|
|
|
_, _, newBody, err := resp.ResponseModifier(200, http.Header{}, body)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, body, newBody)
|
|
}
|
|
|
|
func TestNpmCooldown_HandleMetadataRequest_PinnedVersionInCooldown_RecordsStats(t *testing.T) {
|
|
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), // too new (within 5d cooldown)
|
|
}
|
|
distTags := map[string]string{"latest": "2.0.0"}
|
|
body := buildTestPackument(versions, distTags)
|
|
|
|
collector := NewAnalysisStatsCollector()
|
|
handler := newNpmCooldownHandler(collector)
|
|
ctx := makeTestRequestContext("https://registry.npmjs.org/testpkg")
|
|
|
|
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "2.0.0")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, resp.ResponseModifier)
|
|
|
|
_, _, _, err = resp.ResponseModifier(200, http.Header{}, body)
|
|
require.NoError(t, err)
|
|
|
|
blocks := collector.GetCooldownBlocks()
|
|
require.Len(t, blocks, 1)
|
|
assert.Equal(t, "testpkg", blocks[0].Name)
|
|
assert.Equal(t, "2.0.0", blocks[0].Version)
|
|
assert.Equal(t, 5, blocks[0].CooldownDays)
|
|
|
|
stats := collector.GetStats()
|
|
assert.Equal(t, 1, stats.CooldownBlockedCount)
|
|
assert.Equal(t, 1, stats.BlockedCount)
|
|
}
|
|
|
|
func TestNpmCooldown_HandleMetadataRequest_PinnedVersionNotInCooldown_NoBlock(t *testing.T) {
|
|
now := time.Now()
|
|
versions := map[string]time.Time{
|
|
"1.0.0": now.Add(-30 * 24 * time.Hour), // old
|
|
"2.0.0": now.Add(-1 * 24 * time.Hour), // too new
|
|
}
|
|
distTags := map[string]string{"latest": "2.0.0"}
|
|
body := buildTestPackument(versions, distTags)
|
|
|
|
collector := NewAnalysisStatsCollector()
|
|
handler := newNpmCooldownHandler(collector)
|
|
ctx := makeTestRequestContext("https://registry.npmjs.org/testpkg")
|
|
|
|
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "1.0.0")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, resp.ResponseModifier)
|
|
|
|
_, _, _, err = resp.ResponseModifier(200, http.Header{}, body)
|
|
require.NoError(t, err)
|
|
|
|
blocks := collector.GetCooldownBlocks()
|
|
assert.Empty(t, blocks)
|
|
}
|
|
|
|
func TestNpmCooldown_HandleMetadataRequest_UnpinnedWithRemainingVersions_NoBlock(t *testing.T) {
|
|
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), // too new
|
|
}
|
|
distTags := map[string]string{"latest": "2.0.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)
|
|
|
|
_, _, _, err = resp.ResponseModifier(200, http.Header{}, body)
|
|
require.NoError(t, err)
|
|
|
|
blocks := collector.GetCooldownBlocks()
|
|
assert.Empty(t, blocks)
|
|
}
|
|
|
|
func TestNpmCooldown_InterceptorDelegation_CooldownEnabled(t *testing.T) {
|
|
setCooldownConfig(t, config.DependencyCooldownConfig{Enabled: true, Days: 5})
|
|
|
|
interceptor := NewNpmRegistryInterceptor(nil, NewInMemoryAnalysisCache(), NewAnalysisStatsCollector(), make(chan *ConfirmationRequest, 1), InterceptorContext{})
|
|
|
|
ctx := makeTestRequestContext("https://registry.npmjs.org/lodash")
|
|
ctx.Hostname = "registry.npmjs.org"
|
|
ctx.Headers.Set("Accept", "application/vnd.npm.install-v1+json")
|
|
|
|
resp, err := interceptor.HandleRequest(ctx)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, proxy.ActionModifyResponse, resp.Action)
|
|
assert.Equal(t, "application/json", ctx.Headers.Get("Accept"))
|
|
}
|
|
|
|
func TestNpmCooldown_InterceptorDelegation_CooldownDisabled(t *testing.T) {
|
|
setCooldownConfig(t, config.DependencyCooldownConfig{Enabled: false, Days: 5})
|
|
|
|
interceptor := NewNpmRegistryInterceptor(nil, NewInMemoryAnalysisCache(), NewAnalysisStatsCollector(), make(chan *ConfirmationRequest, 1), InterceptorContext{})
|
|
|
|
ctx := makeTestRequestContext("https://registry.npmjs.org/lodash")
|
|
ctx.Hostname = "registry.npmjs.org"
|
|
ctx.Headers.Set("Accept", "application/vnd.npm.install-v1+json")
|
|
|
|
resp, err := interceptor.HandleRequest(ctx)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, proxy.ActionAllow, resp.Action)
|
|
// Accept header should NOT be modified when cooldown is disabled
|
|
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})
|
|
|
|
// Use InsecureInstallation to skip the analyzer (which would fail without a real backend)
|
|
origInsecure := config.Get().InsecureInstallation
|
|
config.Get().InsecureInstallation = true
|
|
t.Cleanup(func() { config.Get().InsecureInstallation = origInsecure })
|
|
|
|
interceptor := NewNpmRegistryInterceptor(nil, NewInMemoryAnalysisCache(), NewAnalysisStatsCollector(), make(chan *ConfirmationRequest, 1), InterceptorContext{})
|
|
|
|
// Tarball URL has a version component
|
|
ctx := makeTestRequestContext("https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz")
|
|
ctx.Hostname = "registry.npmjs.org"
|
|
|
|
resp, err := interceptor.HandleRequest(ctx)
|
|
require.NoError(t, err)
|
|
// Tarball should be allowed (bypasses cooldown, goes to analysis)
|
|
assert.Equal(t, proxy.ActionAllow, resp.Action)
|
|
// Accept header should not be set to application/json for tarball requests
|
|
assert.NotEqual(t, proxy.ActionModifyResponse, resp.Action)
|
|
}
|