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:
Sahil Bansal
2026-06-21 18:22:15 +05:30
committed by GitHub
co-authored by Abhisek Datta
parent c17b941ac3
commit 327c9c7068
27 changed files with 569 additions and 152 deletions
+5 -3
View File
@@ -186,9 +186,11 @@ type DependencyCooldownConfig struct {
Days int `mapstructure:"days"` Days int `mapstructure:"days"`
// Skip is a per-control skip list of packages exempt from the cooldown // Skip is a per-control skip list of packages exempt from the cooldown
// window. It is independent of the top-level trusted_packages: it waives ONLY // window. Unlike the top-level trusted_packages (which waives every PMG
// the cooldown wait, never malware analysis, so a fast-tracked package is // control — malware analysis, cooldown, and any future controls — and is
// still scanned. Intended for first-party / internal packages that must be // already honored here), an entry on this list waives ONLY the cooldown
// wait, never malware analysis, so a fast-tracked package is still
// scanned. Intended for first-party / internal packages that must be
// installed immediately on release. // installed immediately on release.
// //
// Matching: a PURL without a version skips cooldown for ALL versions of the // Matching: a PURL without a version skips cooldown for ALL versions of the
+9 -5
View File
@@ -167,11 +167,15 @@ dependency_cooldown:
enabled: true enabled: true
days: 5 days: 5
# Per-control skip list of packages exempt from the cooldown window. This is # Per-control skip list of packages exempt from the cooldown window.
# independent of the top-level trusted_packages above (which waives malware # Packages here are STILL malware-scanned — only the cooldown wait is waived.
# analysis): packages here are STILL malware-scanned — only the cooldown wait # Use it for first-party / internal packages that must be installed immediately
# is waived. Use it for first-party / internal packages that must be installed # on release (e.g. to sanity-test a freshly published version).
# immediately on release (e.g. to sanity-test a freshly published version). #
# To bypass every PMG control (malware analysis, cooldown, and any future
# controls) for a package, add it to the top-level trusted_packages list
# above instead. Trusted packages are automatically cooldown-exempt; you do
# not need to repeat them here.
# #
# A PURL without a version skips cooldown for ALL versions of the package; a # A PURL without a version skips cooldown for ALL versions of the package; a
# PURL with a version skips cooldown for that version only. Example: # PURL with a version skips cooldown for that version only. Example:
+40 -34
View File
@@ -7,6 +7,40 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func setGlobalForTest(t *testing.T, cfg *Config) {
t.Helper()
prev := globalConfig
globalConfig = &RuntimeConfig{Config: *cfg}
t.Cleanup(func() { globalConfig = prev })
}
func TestIsTrustedPackageRef(t *testing.T) {
cfg := &Config{TrustedPackages: []TrustedPackage{
{Purl: "pkg:npm/all-versions"},
{Purl: "pkg:npm/pinned@1.0.0"},
}}
_ = preprocessTrustedPackages(cfg)
setGlobalForTest(t, cfg)
assert.True(t, IsTrustedPackageRef(packagev1.Ecosystem_ECOSYSTEM_NPM, "all-versions", "9.9.9"))
assert.True(t, IsTrustedPackageRef(packagev1.Ecosystem_ECOSYSTEM_NPM, "pinned", "1.0.0"))
assert.False(t, IsTrustedPackageRef(packagev1.Ecosystem_ECOSYSTEM_NPM, "pinned", "2.0.0"))
assert.False(t, IsTrustedPackageRef(packagev1.Ecosystem_ECOSYSTEM_NPM, "other", "1.0.0"))
}
func TestIsTrustedPackageAllVersions(t *testing.T) {
cfg := &Config{TrustedPackages: []TrustedPackage{
{Purl: "pkg:npm/all-versions"},
{Purl: "pkg:npm/pinned@1.0.0"},
}}
_ = preprocessTrustedPackages(cfg)
setGlobalForTest(t, cfg)
assert.True(t, IsTrustedPackageAllVersions(packagev1.Ecosystem_ECOSYSTEM_NPM, "all-versions"))
assert.False(t, IsTrustedPackageAllVersions(packagev1.Ecosystem_ECOSYSTEM_NPM, "pinned"))
assert.False(t, IsTrustedPackageAllVersions(packagev1.Ecosystem_ECOSYSTEM_NPM, "absent"))
}
func TestCooldownSkip(t *testing.T) { func TestCooldownSkip(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -117,42 +151,14 @@ func TestCooldownSkipInfo_ExemptsVersion(t *testing.T) {
assert.False(t, none.ExemptsVersion("1.0.0")) assert.False(t, none.ExemptsVersion("1.0.0"))
} }
// TestCooldownSkipAndTrustedPackagesAreIndependent verifies the cooldown skip func TestCooldownSkipIsSkipListOnly(t *testing.T) {
// list and the top-level trusted_packages do not leak into each other: a
// cooldown-skipped package is NOT trusted for malware analysis, and a
// malware-trusted package is NOT cooldown-skipped.
func TestCooldownSkipAndTrustedPackagesAreIndependent(t *testing.T) {
cfg := &Config{ cfg := &Config{
TrustedPackages: []TrustedPackage{ TrustedPackages: []TrustedPackage{{Purl: "pkg:npm/trusted-only"}},
{Purl: "pkg:npm/malware-trusted", Reason: "waives analysis only"}, DependencyCooldown: DependencyCooldownConfig{Skip: []TrustedPackage{{Purl: "pkg:npm/cooldown-only"}}},
},
DependencyCooldown: DependencyCooldownConfig{
Skip: []TrustedPackage{
{Purl: "pkg:npm/cooldown-skipped", Reason: "waives cooldown only"},
},
},
} }
_ = preprocessTrustedPackages(cfg) _ = preprocessTrustedPackages(cfg)
setGlobalForTest(t, cfg)
cooldownSkipped := &packagev1.PackageVersion{ assert.False(t, CooldownSkip(packagev1.Ecosystem_ECOSYSTEM_NPM, "trusted-only").SkipAll, "trusted_packages must not leak into CooldownSkip")
Package: &packagev1.Package{Name: "cooldown-skipped", Ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM}, assert.True(t, CooldownSkip(packagev1.Ecosystem_ECOSYSTEM_NPM, "cooldown-only").SkipAll)
Version: "1.0.0",
}
malwareTrusted := &packagev1.PackageVersion{
Package: &packagev1.Package{Name: "malware-trusted", Ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM},
Version: "1.0.0",
}
// Cooldown-skipped package is still malware-analyzed (not in trusted_packages).
assert.False(t, isTrustedPackageVersion(cfg.TrustedPackages, cooldownSkipped),
"cooldown-skipped package must NOT waive malware analysis")
assert.True(t, cooldownSkip(cfg.DependencyCooldown.Skip, packagev1.Ecosystem_ECOSYSTEM_NPM, "cooldown-skipped").SkipAll,
"cooldown-skipped package must skip the cooldown window")
// Malware-trusted package is still subject to cooldown (not in the skip list).
assert.True(t, isTrustedPackageVersion(cfg.TrustedPackages, malwareTrusted),
"malware-trusted package must waive malware analysis")
skip := cooldownSkip(cfg.DependencyCooldown.Skip, packagev1.Ecosystem_ECOSYSTEM_NPM, "malware-trusted")
assert.False(t, skip.SkipAll, "malware-trusted package must NOT skip cooldown")
assert.Nil(t, skip.Versions)
} }
+38 -20
View File
@@ -13,31 +13,43 @@ func IsTrustedPackage(pkgVersion *packagev1.PackageVersion) bool {
return isTrustedPackageVersion(Get().Config.TrustedPackages, pkgVersion) return isTrustedPackageVersion(Get().Config.TrustedPackages, pkgVersion)
} }
// IsTrustedPackageRef reports whether a specific package version is trusted.
func IsTrustedPackageRef(ecosystem packagev1.Ecosystem, name, version string) bool {
return isTrustedPackageVersion(Get().Config.TrustedPackages, &packagev1.PackageVersion{
Package: &packagev1.Package{Ecosystem: ecosystem, Name: name},
Version: version,
})
}
// IsTrustedPackageAllVersions reports whether every version of a package is
// trusted. It checks with an empty version, which matches only a version-less
// trusted entry — never a version-pinned one.
func IsTrustedPackageAllVersions(ecosystem packagev1.Ecosystem, name string) bool {
return IsTrustedPackageRef(ecosystem, name, "")
}
// CooldownSkipInfo describes how a package is exempted from the dependency // CooldownSkipInfo describes how a package is exempted from the dependency
// cooldown window by the dependency_cooldown.skip list. // cooldown window by the dependency_cooldown.skip list. It is independent of
// trusted_packages, which is honored separately as a global waiver.
type CooldownSkipInfo struct { type CooldownSkipInfo struct {
// SkipAll is true when a version-less skip entry matches: every version of // SkipAll is true when a version-less entry matches: every version of the
// the package is exempt from the cooldown window. // package is exempt from the cooldown window.
SkipAll bool SkipAll bool
// Versions holds the specific versions exempted by version-pinned skip // Versions holds the specific versions exempted by version-pinned entries.
// entries. Only meaningful when SkipAll is false; nil when there are none. // Only meaningful when SkipAll is false; nil when there are none.
Versions map[string]bool Versions map[string]bool
} }
// ExemptsVersion reports whether the given version is exempt from cooldown, // ExemptsVersion reports whether the given version is exempt from cooldown.
// either because the whole package is skipped or because that specific version
// is listed.
func (s CooldownSkipInfo) ExemptsVersion(version string) bool { func (s CooldownSkipInfo) ExemptsVersion(version string) bool {
return s.SkipAll || s.Versions[version] return s.SkipAll || s.Versions[version]
} }
// CooldownSkip returns how a package (by ecosystem and name) is exempted from // CooldownSkip returns how a package is exempted from the dependency cooldown
// the dependency cooldown window via dependency_cooldown.skip. // window via dependency_cooldown.skip. A version-less entry exempts every
// // version; a version-pinned entry exempts only that version. The skip list
// The skip list waives ONLY the cooldown wait — exempt packages are still // waives ONLY the cooldown wait — exempt packages are still malware-analyzed.
// subject to malware analysis. A skip entry without a version exempts every
// version of the package; an entry with a version exempts only that version.
func CooldownSkip(ecosystem packagev1.Ecosystem, name string) CooldownSkipInfo { func CooldownSkip(ecosystem packagev1.Ecosystem, name string) CooldownSkipInfo {
return cooldownSkip(Get().Config.DependencyCooldown.Skip, ecosystem, name) return cooldownSkip(Get().Config.DependencyCooldown.Skip, ecosystem, name)
} }
@@ -55,6 +67,11 @@ func cooldownSkip(skip []TrustedPackage, ecosystem packagev1.Ecosystem, name str
if v.version == "" { if v.version == "" {
info.SkipAll = true info.SkipAll = true
info.Versions = nil
continue
}
if info.SkipAll {
continue continue
} }
@@ -64,15 +81,16 @@ func cooldownSkip(skip []TrustedPackage, ecosystem packagev1.Ecosystem, name str
info.Versions[v.version] = true info.Versions[v.version] = true
} }
// A version-less entry skips every version, so per-version entries are
// redundant — drop them so SkipAll is the single source of truth.
if info.SkipAll {
info.Versions = nil
}
return info return info
} }
// PreprocessTrustedPackages pre-parses all PURL strings in the trusted package
// lists. Exported for use in cross-package tests that install synthetic configs
// without going through Load.
func PreprocessTrustedPackages(cfg *Config) error {
return preprocessTrustedPackages(cfg)
}
// preprocessTrustedPackages pre-parses all PURL strings in the trusted package // preprocessTrustedPackages pre-parses all PURL strings in the trusted package
// lists (both the top-level guardrail list and the cooldown-exemption list). // lists (both the top-level guardrail list and the cooldown-exemption list).
// This is called once during config load to avoid repeated parsing during // This is called once during config load to avoid repeated parsing during
+12 -8
View File
@@ -37,15 +37,19 @@ dependency_cooldown:
``` ```
The skip list is a **per-control exemption**: packages on it **skip only the The skip list is a **per-control exemption**: packages on it **skip only the
cooldown window — they are still analyzed for malware.** It is independent of the cooldown window — they are still analyzed for malware.** Use it when you want a
top-level [`trusted_packages`](trusted-packages.md), which waives malware package to bypass cooldown but still go through every other security control.
analysis. There is a single definition of a trusted package (the top-level list);
this is just a cooldown skip list.
| List | Waives malware analysis | Waives cooldown | If you want a package to bypass **every** control PMG enforces — malware
| --- | --- | --- | analysis, dependency cooldown, and any future policies — add it to the
| `trusted_packages` (top level) | yes | no | top-level [`trusted_packages`](trusted-packages.md) list instead. A globally
| `dependency_cooldown.skip` | no | yes | trusted package is automatically exempted from the cooldown window without
needing a separate entry here.
| List | Waives malware analysis | Waives cooldown | Waives future controls |
| --- | --- | --- | --- |
| `trusted_packages` (top level) | yes | yes | yes |
| `dependency_cooldown.skip` | no | yes | no |
Matching: Matching:
+10 -1
View File
@@ -1,6 +1,15 @@
# Trusted Packages # Trusted Packages
`pmg` allows you to trust a package. Trusted packages are not scanned and always allowed to be installed. `pmg` allows you to trust a package. A trusted package bypasses **every**
control PMG enforces: it is not scanned for malware, it is exempt from the
[dependency cooldown](dependency-cooldown.md) window, and it will be exempted
from any future controls PMG adds. Use this list for packages you fully vouch
for — typically first-party packages or vendored internal dependencies.
If you only want to waive a single control (for example, install a package
immediately without waiting out the cooldown, but still have it analyzed for
malware), use the per-control skip list for that control instead — e.g.
[`dependency_cooldown.skip`](dependency-cooldown.md#exempting-specific-packages).
## Configuration ## Configuration
+2 -1
View File
@@ -4,7 +4,7 @@ go 1.25.1
require ( require (
buf.build/gen/go/safedep/api/grpc/go v1.6.2-20260528074646-b9e182189444.1 buf.build/gen/go/safedep/api/grpc/go v1.6.2-20260528074646-b9e182189444.1
buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.11-20260528074646-b9e182189444.1 buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.11-20260618082119-01127207dee4.1
github.com/Masterminds/semver v1.5.0 github.com/Masterminds/semver v1.5.0
github.com/elazarl/goproxy v1.8.1 github.com/elazarl/goproxy v1.8.1
github.com/fatih/color v1.18.0 github.com/fatih/color v1.18.0
@@ -34,6 +34,7 @@ require (
require ( require (
al.essio.dev/pkg/shellescape v1.5.1 // indirect al.essio.dev/pkg/shellescape v1.5.1 // indirect
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20240508200655-46a4cf4ba109.1 // indirect buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20240508200655-46a4cf4ba109.1 // indirect
buf.build/gen/go/safedep/api/connectrpc/go v1.20.0-20260618082119-01127207dee4.1 // indirect
github.com/BurntSushi/toml v1.5.0 // indirect github.com/BurntSushi/toml v1.5.0 // indirect
github.com/Masterminds/semver/v3 v3.3.1 // indirect github.com/Masterminds/semver/v3 v3.3.1 // indirect
github.com/caarlos0/env/v11 v11.3.1 // indirect github.com/caarlos0/env/v11 v11.3.1 // indirect
+4
View File
@@ -2,10 +2,14 @@ al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXy
al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890=
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20240508200655-46a4cf4ba109.1 h1:NXwdBG3BiC6xWH4iG3csbT+JHF9u1jl5ThDhumCnKnk= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20240508200655-46a4cf4ba109.1 h1:NXwdBG3BiC6xWH4iG3csbT+JHF9u1jl5ThDhumCnKnk=
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20240508200655-46a4cf4ba109.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20240508200655-46a4cf4ba109.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM=
buf.build/gen/go/safedep/api/connectrpc/go v1.20.0-20260618082119-01127207dee4.1 h1:STdkWBeTnT9TT8TjN/DhUhe3Uf0ZovSNEOm0X19VgXY=
buf.build/gen/go/safedep/api/connectrpc/go v1.20.0-20260618082119-01127207dee4.1/go.mod h1:PHhPcNWKDHnL53n/Ycqg1eURXs/JFMk1SVdFRBrqYwo=
buf.build/gen/go/safedep/api/grpc/go v1.6.2-20260528074646-b9e182189444.1 h1:zpjFPeuPS4AdzfOMlwDSVWwxrRBrkL0ul0gV65RYzh8= buf.build/gen/go/safedep/api/grpc/go v1.6.2-20260528074646-b9e182189444.1 h1:zpjFPeuPS4AdzfOMlwDSVWwxrRBrkL0ul0gV65RYzh8=
buf.build/gen/go/safedep/api/grpc/go v1.6.2-20260528074646-b9e182189444.1/go.mod h1:8pVZh4owzo4YXcKvFvdWEYGr4k/1VHGR0h39XHsuHD4= buf.build/gen/go/safedep/api/grpc/go v1.6.2-20260528074646-b9e182189444.1/go.mod h1:8pVZh4owzo4YXcKvFvdWEYGr4k/1VHGR0h39XHsuHD4=
buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.11-20260528074646-b9e182189444.1 h1:2Ws+lb98zkYNJ4dwRbjJYviSY5mI80eA+GJc3NyY+rc= buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.11-20260528074646-b9e182189444.1 h1:2Ws+lb98zkYNJ4dwRbjJYviSY5mI80eA+GJc3NyY+rc=
buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.11-20260528074646-b9e182189444.1/go.mod h1:I8E+sZXJNqzWBtSlRGCoiEorLSRiix50h2R/66aBzME= buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.11-20260528074646-b9e182189444.1/go.mod h1:I8E+sZXJNqzWBtSlRGCoiEorLSRiix50h2R/66aBzME=
buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.11-20260618082119-01127207dee4.1 h1:8Fuiw/QnwIcOjzRk3cA7lHRX/d9utFDPEgi7OSsPg0E=
buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.11-20260618082119-01127207dee4.1/go.mod h1:I8E+sZXJNqzWBtSlRGCoiEorLSRiix50h2R/66aBzME=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
+2
View File
@@ -1,3 +1,4 @@
buf.build/gen/go/bufbuild/protovalidate/connectrpc/go v1.20.0-20240508200655-46a4cf4ba109.1/go.mod h1:hR/w+cb6VNC4j0Rf91uvB2CMAcwIJ1cYxnGFwsP/w4k=
buf.build/gen/go/bufbuild/protovalidate/grpc/go v1.6.1-20240508200655-46a4cf4ba109.1/go.mod h1:12iIaR0LjReZQXXxBXMzWTMUIN6n4Y3HuSTwPpUQYSg= buf.build/gen/go/bufbuild/protovalidate/grpc/go v1.6.1-20240508200655-46a4cf4ba109.1/go.mod h1:12iIaR0LjReZQXXxBXMzWTMUIN6n4Y3HuSTwPpUQYSg=
buf.build/gen/go/bufbuild/protovalidate/grpc/go v1.6.2-20240508200655-46a4cf4ba109.1/go.mod h1:cz5A7G0AEk7z2kciJ1jK72u/8kRVlcfAhi2B9jYoMZw= buf.build/gen/go/bufbuild/protovalidate/grpc/go v1.6.2-20240508200655-46a4cf4ba109.1/go.mod h1:cz5A7G0AEk7z2kciJ1jK72u/8kRVlcfAhi2B9jYoMZw=
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
@@ -9,6 +10,7 @@ cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCS
cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U= cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U=
cloud.google.com/go/profiler v0.4.3/go.mod h1:3xFodugWfPIQZWFcXdUmfa+yTiiyQ8fWrdT+d2Sg4J0= cloud.google.com/go/profiler v0.4.3/go.mod h1:3xFodugWfPIQZWFcXdUmfa+yTiiyQ8fWrdT+d2Sg4J0=
cloud.google.com/go/storage v1.55.0/go.mod h1:ztSmTTwzsdXe5syLVS0YsbFxXuvEmEyZj7v7zChEmuY= cloud.google.com/go/storage v1.55.0/go.mod h1:ztSmTTwzsdXe5syLVS0YsbFxXuvEmEyZj7v7zChEmuY=
connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4=
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
deps.dev/api/v3 v3.0.0-20250307021655-d811e36f9cad/go.mod h1:o6PwfRErnKxbT8Sdij64ZMiTqMNxPsYwfymiyjXPh7A= deps.dev/api/v3 v3.0.0-20250307021655-d811e36f9cad/go.mod h1:o6PwfRErnKxbT8Sdij64ZMiTqMNxPsYwfymiyjXPh7A=
deps.dev/api/v3alpha v0.0.0-20250429014815-ac0aa6a085fa/go.mod h1:qL3BV/n9j5WptUB9hWZrWM6yISapPTUYIQmHMWrGEmk= deps.dev/api/v3alpha v0.0.0-20250429014815-ac0aa6a085fa/go.mod h1:qL3BV/n9j5WptUB9hWZrWM6yISapPTUYIQmHMWrGEmk=
+27 -9
View File
@@ -87,7 +87,7 @@ func LogMalwareBlocked(pv *packagev1.PackageVersion, reason, analysisID, referen
AnalysisID: analysisID, AnalysisID: analysisID,
IsMalware: isMalware, IsMalware: isMalware,
IsVerified: isVerified, IsVerified: isVerified,
Details: map[string]interface{}{ Details: map[string]any{
"reason": reason, "reason": reason,
"analysis_id": analysisID, "analysis_id": analysisID,
"reference_url": referenceURL, "reference_url": referenceURL,
@@ -121,7 +121,7 @@ func LogInstallAllowed(pv *packagev1.PackageVersion, packageCount int) {
Type: EventTypeInstallAllowed, Type: EventTypeInstallAllowed,
Message: fmt.Sprintf("Installation allowed for %s@%s (%d packages analyzed)", pkgName(pv), pkgVersion(pv), packageCount), Message: fmt.Sprintf("Installation allowed for %s@%s (%d packages analyzed)", pkgName(pv), pkgVersion(pv), packageCount),
PackageVersion: pv, PackageVersion: pv,
Details: map[string]interface{}{ Details: map[string]any{
"packages_analyzed": packageCount, "packages_analyzed": packageCount,
}, },
PackageCount: packageCount, PackageCount: packageCount,
@@ -163,7 +163,7 @@ func LogInstallStarted(packageManager string, args []string) {
logEvent(AuditEvent{ logEvent(AuditEvent{
Type: EventTypeInstallStarted, Type: EventTypeInstallStarted,
Message: fmt.Sprintf("Starting package installation with %s", packageManager), Message: fmt.Sprintf("Starting package installation with %s", packageManager),
Details: map[string]interface{}{ Details: map[string]any{
"package_manager": packageManager, "package_manager": packageManager,
"arguments": args, "arguments": args,
}, },
@@ -177,8 +177,8 @@ func LogInstallStarted(packageManager string, args []string) {
} }
// LogProxyHostObserved records an outbound host observed by the proxy that is not a known registry. // LogProxyHostObserved records an outbound host observed by the proxy that is not a known registry.
func LogProxyHostObserved(hostname, method, reason string, details map[string]interface{}) { func LogProxyHostObserved(hostname, method, reason string, details map[string]any) {
base := map[string]interface{}{ base := map[string]any{
"hostname": hostname, "hostname": hostname,
"method": method, "method": method,
"reason": reason, "reason": reason,
@@ -211,12 +211,30 @@ func LogDependencyCooldown(pv *packagev1.PackageVersion, publishDate time.Time,
} }
} }
// CooldownSkipReason is the only source that produces a dependency_cooldown_skipped
// event; trusted-package exemptions surface as install_trusted_allowed instead.
const CooldownSkipReason = "dependency_cooldown.skip"
// LogCooldownSkipped records that a specific package version was exempted from
// the dependency cooldown window by the dependency_cooldown.skip list.
func LogCooldownSkipped(pv *packagev1.PackageVersion) {
logEvent(AuditEvent{
Type: EventTypeCooldownSkipped,
Message: fmt.Sprintf("Cooldown skipped for %s@%s", pkgName(pv), pkgVersion(pv)),
PackageVersion: pv,
Reason: CooldownSkipReason,
Details: map[string]any{
"reason": CooldownSkipReason,
},
})
}
// LogSandboxOverride records that runtime sandbox policy overrides were applied. // LogSandboxOverride records that runtime sandbox policy overrides were applied.
func LogSandboxOverride(sandboxProfile string, overrides []map[string]string) { func LogSandboxOverride(sandboxProfile string, overrides []map[string]string) {
logEvent(AuditEvent{ logEvent(AuditEvent{
Type: EventTypeSandboxOverride, Type: EventTypeSandboxOverride,
Message: fmt.Sprintf("Sandbox runtime overrides applied (%d rules)", len(overrides)), Message: fmt.Sprintf("Sandbox runtime overrides applied (%d rules)", len(overrides)),
Details: map[string]interface{}{ Details: map[string]any{
"sandbox_profile": sandboxProfile, "sandbox_profile": sandboxProfile,
"sandbox_runtime_overrides": overrides, "sandbox_runtime_overrides": overrides,
}, },
@@ -234,7 +252,7 @@ func LogError(message string, err error) {
} }
if err != nil { if err != nil {
event.Details = map[string]interface{}{ event.Details = map[string]any{
"error": err.Error(), "error": err.Error(),
} }
} }
@@ -280,9 +298,9 @@ func LogSessionComplete(outcome Outcome, flowType FlowType) {
}) })
} }
func mergeDetails(base, extra map[string]interface{}) map[string]interface{} { func mergeDetails(base, extra map[string]any) map[string]any {
if base == nil { if base == nil {
base = make(map[string]interface{}) base = make(map[string]any)
} }
for k, v := range extra { for k, v := range extra {
base[k] = v base[k] = v
+15
View File
@@ -232,6 +232,21 @@ func TestLogInstallTrustedAllowedIncrementsSession(t *testing.T) {
assert.Equal(t, uint32(1), sess.totalAnalyzed) assert.Equal(t, uint32(1), sess.totalAnalyzed)
} }
func TestLogCooldownSkippedEmitsEventWithReason(t *testing.T) {
s := &mockSink{}
a := newAuditor(s)
setGlobal(a)
defer resetGlobal()
LogCooldownSkipped(testPackageVersion("pkg", "1.0", "npm"))
events := s.getEvents()
require.Len(t, events, 1)
assert.Equal(t, EventTypeCooldownSkipped, events[0].Type)
assert.Equal(t, "dependency_cooldown.skip", events[0].Reason)
assert.Equal(t, "dependency_cooldown.skip", events[0].Details["reason"])
}
func TestLogSessionCompleteDispatchesEvent(t *testing.T) { func TestLogSessionCompleteDispatchesEvent(t *testing.T) {
s := &mockSink{} s := &mockSink{}
a := newAuditor(s) a := newAuditor(s)
+4
View File
@@ -14,6 +14,10 @@ func (s *cloudSink) translateToPmgEvents(event AuditEvent) []*controltowerv1.Pmg
return []*controltowerv1.PmgEvent{newPackageDecisionEvent(event, controltowerv1.PmgPackageAction_PMG_PACKAGE_ACTION_BLOCKED)} return []*controltowerv1.PmgEvent{newPackageDecisionEvent(event, controltowerv1.PmgPackageAction_PMG_PACKAGE_ACTION_BLOCKED)}
case EventTypeMalwareConfirmed: case EventTypeMalwareConfirmed:
return []*controltowerv1.PmgEvent{newPackageDecisionEvent(event, controltowerv1.PmgPackageAction_PMG_PACKAGE_ACTION_CONFIRMED)} return []*controltowerv1.PmgEvent{newPackageDecisionEvent(event, controltowerv1.PmgPackageAction_PMG_PACKAGE_ACTION_CONFIRMED)}
case EventTypeCooldownSkipped:
return []*controltowerv1.PmgEvent{newPackageDecisionEvent(event, controltowerv1.PmgPackageAction_PMG_PACKAGE_ACTION_COOLDOWN_SKIPPED)}
case EventTypeInstallTrustedAllowed:
return []*controltowerv1.PmgEvent{newPackageDecisionEvent(event, controltowerv1.PmgPackageAction_PMG_PACKAGE_ACTION_TRUSTED)}
case EventTypeInstallInsecureBypass: case EventTypeInstallInsecureBypass:
// PmgInsecureBypass is a session-level aggregate (package manager + total bypassed count), // PmgInsecureBypass is a session-level aggregate (package manager + total bypassed count),
// not a per-package event. It is emitted as part of EventTypeSessionComplete when // not a per-package event. It is emitted as part of EventTypeSessionComplete when
+40 -1
View File
@@ -61,6 +61,46 @@ func TestTranslateMalwareConfirmed(t *testing.T) {
assert.False(t, decision.GetIsVerified()) assert.False(t, decision.GetIsVerified())
} }
func TestTranslateCooldownSkipped(t *testing.T) {
event := AuditEvent{
Type: EventTypeCooldownSkipped,
PackageVersion: testPackageVersion("exempt-pkg", "1.0.0", "npm"),
Reason: "dependency_cooldown.skip",
}
results := testSink.translateToPmgEvents(event)
require.Len(t, results, 1)
result := results[0]
assert.Equal(t, controltowerv1.PmgEventType_PMG_EVENT_TYPE_PACKAGE_DECISION, result.GetEventType())
require.True(t, result.HasPackageDecision())
decision := result.GetPackageDecision()
assert.Equal(t, controltowerv1.PmgPackageAction_PMG_PACKAGE_ACTION_COOLDOWN_SKIPPED, decision.GetAction())
require.NotNil(t, decision.GetPackageVersion(), "package_version must be propagated to the cloud sink")
assert.Equal(t, "exempt-pkg", decision.GetPackageVersion().GetPackage().GetName())
assert.Equal(t, "1.0.0", decision.GetPackageVersion().GetVersion())
}
func TestTranslateInstallTrustedAllowed(t *testing.T) {
event := AuditEvent{
Type: EventTypeInstallTrustedAllowed,
PackageVersion: testPackageVersion("trusted-pkg", "2.0.0", "npm"),
}
results := testSink.translateToPmgEvents(event)
require.Len(t, results, 1)
result := results[0]
assert.Equal(t, controltowerv1.PmgEventType_PMG_EVENT_TYPE_PACKAGE_DECISION, result.GetEventType())
require.True(t, result.HasPackageDecision())
decision := result.GetPackageDecision()
assert.Equal(t, controltowerv1.PmgPackageAction_PMG_PACKAGE_ACTION_TRUSTED, decision.GetAction())
require.NotNil(t, decision.GetPackageVersion())
assert.Equal(t, "trusted-pkg", decision.GetPackageVersion().GetPackage().GetName())
}
func TestTranslateInsecureBypassReturnsEmpty(t *testing.T) { func TestTranslateInsecureBypassReturnsEmpty(t *testing.T) {
event := AuditEvent{ event := AuditEvent{
Type: EventTypeInstallInsecureBypass, Type: EventTypeInstallInsecureBypass,
@@ -181,7 +221,6 @@ func TestTranslateUnsupportedEventReturnsEmpty(t *testing.T) {
EventTypeDependencyResolved, EventTypeDependencyResolved,
EventTypeInstallStarted, EventTypeInstallStarted,
EventTypeInstallAllowed, EventTypeInstallAllowed,
EventTypeInstallTrustedAllowed,
EventTypeInstallInsecureBypass, EventTypeInstallInsecureBypass,
} }
+1
View File
@@ -56,6 +56,7 @@ const (
EventTypeInstallInsecureBypass EventType = "install_insecure_bypass" EventTypeInstallInsecureBypass EventType = "install_insecure_bypass"
EventTypeProxyHostObserved EventType = "proxy_host_observed" EventTypeProxyHostObserved EventType = "proxy_host_observed"
EventTypeDependencyCooldown EventType = "dependency_cooldown" EventTypeDependencyCooldown EventType = "dependency_cooldown"
EventTypeCooldownSkipped EventType = "dependency_cooldown_skipped"
EventTypeSandboxOverride EventType = "sandbox_override" EventTypeSandboxOverride EventType = "sandbox_override"
EventTypeError EventType = "error" EventTypeError EventType = "error"
EventTypeSessionComplete EventType = "session_complete" EventTypeSessionComplete EventType = "session_complete"
+2
View File
@@ -69,6 +69,8 @@ func mapEventType(t EventType) eventlog.EventType {
return eventlog.EventTypeProxyHostObserved return eventlog.EventTypeProxyHostObserved
case EventTypeDependencyCooldown: case EventTypeDependencyCooldown:
return eventlog.EventTypeDependencyCooldown return eventlog.EventTypeDependencyCooldown
case EventTypeCooldownSkipped:
return eventlog.EventTypeCooldownSkipped
case EventTypeSandboxOverride: case EventTypeSandboxOverride:
return eventlog.EventTypeSandboxOverride return eventlog.EventTypeSandboxOverride
case EventTypeError: case EventTypeError:
+2
View File
@@ -21,6 +21,8 @@ func TestEventlogSinkTranslatesAllEventTypes(t *testing.T) {
{"dependency_resolved", EventTypeDependencyResolved, eventlog.EventTypeDependencyResolved}, {"dependency_resolved", EventTypeDependencyResolved, eventlog.EventTypeDependencyResolved},
{"install_insecure_bypass", EventTypeInstallInsecureBypass, eventlog.EventTypeInstallInsecureBypass}, {"install_insecure_bypass", EventTypeInstallInsecureBypass, eventlog.EventTypeInstallInsecureBypass},
{"proxy_host_observed", EventTypeProxyHostObserved, eventlog.EventTypeProxyHostObserved}, {"proxy_host_observed", EventTypeProxyHostObserved, eventlog.EventTypeProxyHostObserved},
{"dependency_cooldown", EventTypeDependencyCooldown, eventlog.EventTypeDependencyCooldown},
{"dependency_cooldown_skipped", EventTypeCooldownSkipped, eventlog.EventTypeCooldownSkipped},
{"sandbox_override", EventTypeSandboxOverride, eventlog.EventTypeSandboxOverride}, {"sandbox_override", EventTypeSandboxOverride, eventlog.EventTypeSandboxOverride},
{"error", EventTypeError, eventlog.EventTypeError}, {"error", EventTypeError, eventlog.EventTypeError},
{"session_complete", EventTypeSessionComplete, eventlog.EventType("session_complete")}, {"session_complete", EventTypeSessionComplete, eventlog.EventType("session_complete")},
+1
View File
@@ -26,6 +26,7 @@ const (
EventTypeInstallInsecureBypass EventType = "install_insecure_bypass" EventTypeInstallInsecureBypass EventType = "install_insecure_bypass"
EventTypeProxyHostObserved EventType = "proxy_host_observed" EventTypeProxyHostObserved EventType = "proxy_host_observed"
EventTypeDependencyCooldown EventType = "dependency_cooldown" EventTypeDependencyCooldown EventType = "dependency_cooldown"
EventTypeCooldownSkipped EventType = "dependency_cooldown_skipped"
EventTypeSandboxOverride EventType = "sandbox_override" EventTypeSandboxOverride EventType = "sandbox_override"
EventTypeError EventType = "error" EventTypeError EventType = "error"
) )
+30 -24
View File
@@ -71,6 +71,36 @@ func (b *baseRegistryInterceptor) HandleRequest(ctx *proxy.RequestContext) (*pro
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil 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 // analyzePackage analyzes a package using the configured analyzer with caching
// This method is ecosystem-agnostic and can be used by any registry interceptor // This method is ecosystem-agnostic and can be used by any registry interceptor
func (b *baseRegistryInterceptor) analyzePackage( func (b *baseRegistryInterceptor) analyzePackage(
@@ -79,7 +109,6 @@ func (b *baseRegistryInterceptor) analyzePackage(
packageName string, packageName string,
packageVersion string, packageVersion string,
) (*analyzer.PackageVersionAnalysisResult, error) { ) (*analyzer.PackageVersionAnalysisResult, error) {
// Check if package is trusted before analyzing
pkgVersion := &packagev1.PackageVersion{ pkgVersion := &packagev1.PackageVersion{
Package: &packagev1.Package{ Package: &packagev1.Package{
Ecosystem: ecosystem, Ecosystem: ecosystem,
@@ -88,29 +117,6 @@ func (b *baseRegistryInterceptor) analyzePackage(
Version: packageVersion, 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 { 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) log.Debugf("[%s] Using cached analysis result for %s@%s", ctx.RequestID, packageName, packageVersion)
return cached, nil return cached, nil
+48
View File
@@ -8,10 +8,58 @@ import (
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
"github.com/safedep/pmg/analyzer" "github.com/safedep/pmg/analyzer"
pmgconfig "github.com/safedep/pmg/config"
"github.com/safedep/pmg/proxy" "github.com/safedep/pmg/proxy"
"github.com/stretchr/testify/assert" "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) { func TestBaseRegistryInterceptor_HandleAnalysisResult(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
+58
View File
@@ -6,9 +6,67 @@ import (
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
"github.com/Masterminds/semver" "github.com/Masterminds/semver"
"github.com/safedep/dry/log"
pmgconfig "github.com/safedep/pmg/config"
"github.com/safedep/pmg/internal/audit" "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 // cooldownIsWithinWindow reports whether a version published at publishDate is still
// within the cooldown window of cooldownDays. Returns withinCooldown, daysSincePublish, // within the cooldown window of cooldownDays. Returns withinCooldown, daysSincePublish,
// and daysRemaining. // and daysRemaining.
+76
View File
@@ -4,9 +4,85 @@ import (
"testing" "testing"
"time" "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" "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) { func TestCooldownIsWithinWindow(t *testing.T) {
now := time.Now() now := time.Now()
day := 24 * time.Hour day := 24 * time.Hour
+15 -3
View File
@@ -8,6 +8,7 @@ import (
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
"github.com/safedep/dry/log" "github.com/safedep/dry/log"
pmgconfig "github.com/safedep/pmg/config"
"github.com/safedep/pmg/proxy" "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 // 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 full packument (which includes publish dates in the "time" field), then registers
// a response modifier that strips versions within the cooldown window. // a response modifier that strips versions within the cooldown window. Skip-list
func (h *npmCooldownHandler) HandleMetadataRequest(ctx *proxy.RequestContext, packageName string, cooldownDays int, pinnedVersion string, exemptVersions map[string]bool) (*proxy.InterceptorResponse, error) { // 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) log.Debugf("[%s] Cooldown: registering metadata modifier for %s", ctx.RequestID, packageName)
// Force full packument so the response always contains the "time" field. // 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) 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 { if stripped > 0 {
log.Infof("[%s] Cooldown: stripped %d version(s) from %s metadata (%d days, %d eligible remain)", log.Infof("[%s] Cooldown: stripped %d version(s) from %s metadata (%d days, %d eligible remain)",
ctx.RequestID, stripped, packageName, cooldownDays, remaining) ctx.RequestID, stripped, packageName, cooldownDays, remaining)
+41 -9
View File
@@ -432,7 +432,7 @@ func TestNpmCooldown_HandleMetadataRequest_OverridesHeaders(t *testing.T) {
ctx.Headers.Set("If-None-Match", `"abc123"`) ctx.Headers.Set("If-None-Match", `"abc123"`)
ctx.Headers.Set("If-Modified-Since", "Wed, 01 Jan 2025 00:00:00 GMT") 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) require.NoError(t, err)
assert.Equal(t, proxy.ActionModifyResponse, resp.Action) assert.Equal(t, proxy.ActionModifyResponse, resp.Action)
assert.Equal(t, "application/json", ctx.Headers.Get("Accept")) assert.Equal(t, "application/json", ctx.Headers.Get("Accept"))
@@ -454,7 +454,7 @@ func TestNpmCooldown_HandleMetadataRequest_StripsRecentVersions(t *testing.T) {
handler := newNpmCooldownHandler(collector) handler := newNpmCooldownHandler(collector)
ctx := makeTestRequestContext("https://registry.npmjs.org/testpkg") 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.NoError(t, err)
require.NotNil(t, resp.ResponseModifier) require.NotNil(t, resp.ResponseModifier)
@@ -489,7 +489,7 @@ func TestNpmCooldown_HandleMetadataRequest_NoVersionsInCooldown(t *testing.T) {
handler := newNpmCooldownHandler(collector) handler := newNpmCooldownHandler(collector)
ctx := makeTestRequestContext("https://registry.npmjs.org/testpkg") 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.NoError(t, err)
require.NotNil(t, resp.ResponseModifier) require.NotNil(t, resp.ResponseModifier)
@@ -510,7 +510,7 @@ func TestNpmCooldown_HandleMetadataRequest_AllVersionsInCooldown_RecordsStats(t
handler := newNpmCooldownHandler(collector) handler := newNpmCooldownHandler(collector)
ctx := makeTestRequestContext("https://registry.npmjs.org/newpkg") 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.NoError(t, err)
require.NotNil(t, resp.ResponseModifier) require.NotNil(t, resp.ResponseModifier)
@@ -542,7 +542,7 @@ func TestNpmCooldown_HandleMetadataRequest_AllVersionsInCooldown_ReportsOldestVe
handler := newNpmCooldownHandler(collector) handler := newNpmCooldownHandler(collector)
ctx := makeTestRequestContext("https://registry.npmjs.org/multipkg") 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) require.NoError(t, err)
_, _, _, err = resp.ResponseModifier(200, http.Header{}, body) _, _, _, err = resp.ResponseModifier(200, http.Header{}, body)
@@ -559,7 +559,7 @@ func TestNpmCooldown_HandleMetadataRequest_MalformedJSON_FailOpen(t *testing.T)
handler := newNpmCooldownHandler(collector) handler := newNpmCooldownHandler(collector)
ctx := makeTestRequestContext("https://registry.npmjs.org/badpkg") 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.NoError(t, err)
require.NotNil(t, resp.ResponseModifier) require.NotNil(t, resp.ResponseModifier)
@@ -581,7 +581,7 @@ func TestNpmCooldown_HandleMetadataRequest_PinnedVersionInCooldown_RecordsStats(
handler := newNpmCooldownHandler(collector) handler := newNpmCooldownHandler(collector)
ctx := makeTestRequestContext("https://registry.npmjs.org/testpkg") 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.NoError(t, err)
require.NotNil(t, resp.ResponseModifier) require.NotNil(t, resp.ResponseModifier)
@@ -612,7 +612,7 @@ func TestNpmCooldown_HandleMetadataRequest_PinnedVersionNotInCooldown_NoBlock(t
handler := newNpmCooldownHandler(collector) handler := newNpmCooldownHandler(collector)
ctx := makeTestRequestContext("https://registry.npmjs.org/testpkg") 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.NoError(t, err)
require.NotNil(t, resp.ResponseModifier) require.NotNil(t, resp.ResponseModifier)
@@ -636,7 +636,7 @@ func TestNpmCooldown_HandleMetadataRequest_UnpinnedWithRemainingVersions_NoBlock
handler := newNpmCooldownHandler(collector) handler := newNpmCooldownHandler(collector)
ctx := makeTestRequestContext("https://registry.npmjs.org/testpkg") 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.NoError(t, err)
require.NotNil(t, resp.ResponseModifier) 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")) 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) { func TestNpmCooldown_TarballRequestBypassesCooldown(t *testing.T) {
setCooldownConfig(t, config.DependencyCooldownConfig{Enabled: true, Days: 5}) setCooldownConfig(t, config.DependencyCooldownConfig{Enabled: true, Days: 5})
+6 -9
View File
@@ -113,23 +113,20 @@ func (i *NpmRegistryInterceptor) HandleRequest(ctx *proxy.RequestContext) (*prox
if !pkgInfo.IsFileDownload() { if !pkgInfo.IsFileDownload() {
if depCooldownConfig.Enabled { if depCooldownConfig.Enabled {
skip := pmgconfig.CooldownSkip(packagev1.Ecosystem_ECOSYSTEM_NPM, pkgInfo.GetName()) if pmgconfig.IsTrustedPackageAllVersions(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())
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
} }
return i.cooldownHandler.HandleMetadataRequest(ctx, pkgInfo.GetName(), depCooldownConfig.Days, i.execContext.PinnedVersions[pkgInfo.GetName()])
// 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)
} }
log.Debugf("[%s] Skipping analysis for metadata request: %s", ctx.RequestID, pkgInfo.GetName()) log.Debugf("[%s] Skipping analysis for metadata request: %s", ctx.RequestID, pkgInfo.GetName())
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil 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( result, err := i.analyzePackage(
ctx, ctx,
packagev1.Ecosystem_ECOSYSTEM_NPM, packagev1.Ecosystem_ECOSYSTEM_NPM,
+18 -3
View File
@@ -9,6 +9,7 @@ import (
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
"github.com/safedep/dry/log" "github.com/safedep/dry/log"
pmgconfig "github.com/safedep/pmg/config"
"github.com/safedep/pmg/proxy" "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, // 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. // 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 // If the client does not support PEP 691 (pip < 22.3), cooldown is skipped to avoid
// returning a content type the client cannot parse. // returning a content type the client cannot parse. Skip-list semantics are
func (h *pypiCooldownHandler) HandleMetadataRequest(ctx *proxy.RequestContext, packageName string, cooldownDays int, pinnedVersion string, exemptVersions map[string]bool) (*proxy.InterceptorResponse, error) { // 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) log.Debugf("[%s] Cooldown: registering metadata modifier for %s", ctx.RequestID, packageName)
originalAccept := ctx.Headers.Get("Accept") 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) 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 { if stripped > 0 {
log.Infof("[%s] Cooldown: stripped %d version(s) from %s metadata (%d days, %d eligible remain)", log.Infof("[%s] Cooldown: stripped %d version(s) from %s metadata (%d days, %d eligible remain)",
ctx.RequestID, stripped, packageName, cooldownDays, remaining) ctx.RequestID, stripped, packageName, cooldownDays, remaining)
+53 -10
View File
@@ -357,7 +357,7 @@ func TestPyPICooldown_HandleMetadataRequest_OverridesHeaders(t *testing.T) {
ctx.Headers.Set("If-None-Match", `"abc123"`) ctx.Headers.Set("If-None-Match", `"abc123"`)
ctx.Headers.Set("If-Modified-Since", "Wed, 01 Jan 2025 00:00:00 GMT") 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) require.NoError(t, err)
assert.Equal(t, proxy.ActionModifyResponse, resp.Action) assert.Equal(t, proxy.ActionModifyResponse, resp.Action)
assert.Equal(t, "application/vnd.pypi.simple.v1+json", ctx.Headers.Get("Accept")) 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 := makeTestRequestContext("https://pypi.org/simple/requests/")
ctx.Headers.Set("Accept", "text/html") 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) require.NoError(t, err)
assert.Equal(t, proxy.ActionAllow, resp.Action) assert.Equal(t, proxy.ActionAllow, resp.Action)
assert.Nil(t, resp.ResponseModifier) assert.Nil(t, resp.ResponseModifier)
@@ -383,7 +383,7 @@ func TestPyPICooldown_HandleMetadataRequest_NonJSONResponse_FailOpen(t *testing.
ctx := makeTestRequestContext("https://pypi.org/simple/requests/") ctx := makeTestRequestContext("https://pypi.org/simple/requests/")
ctx.Headers.Set("Accept", pypiSimpleAPIContentType) 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.NoError(t, err)
require.NotNil(t, resp.ResponseModifier) require.NotNil(t, resp.ResponseModifier)
@@ -410,7 +410,7 @@ func TestPyPICooldown_HandleMetadataRequest_StripsRecentVersions(t *testing.T) {
ctx := makeTestRequestContext("https://pypi.org/simple/testpkg/") ctx := makeTestRequestContext("https://pypi.org/simple/testpkg/")
ctx.Headers.Set("Accept", pypiSimpleAPIContentType) 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.NoError(t, err)
require.NotNil(t, resp.ResponseModifier) require.NotNil(t, resp.ResponseModifier)
@@ -448,7 +448,7 @@ func TestPyPICooldown_HandleMetadataRequest_AllVersionsInCooldown_RecordsStats(t
ctx := makeTestRequestContext("https://pypi.org/simple/newpkg/") ctx := makeTestRequestContext("https://pypi.org/simple/newpkg/")
ctx.Headers.Set("Accept", pypiSimpleAPIContentType) 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.NoError(t, err)
require.NotNil(t, resp.ResponseModifier) require.NotNil(t, resp.ResponseModifier)
@@ -482,7 +482,7 @@ func TestPyPICooldown_HandleMetadataRequest_NoVersionsInCooldown_BodyUnchanged(t
ctx := makeTestRequestContext("https://pypi.org/simple/testpkg/") ctx := makeTestRequestContext("https://pypi.org/simple/testpkg/")
ctx.Headers.Set("Accept", pypiSimpleAPIContentType) 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.NoError(t, err)
require.NotNil(t, resp.ResponseModifier) 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 := makeTestRequestContext("https://pypi.org/simple/badpkg/")
ctx.Headers.Set("Accept", pypiSimpleAPIContentType) 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.NoError(t, err)
require.NotNil(t, resp.ResponseModifier) require.NotNil(t, resp.ResponseModifier)
@@ -526,7 +526,7 @@ func TestPyPICooldown_HandleMetadataRequest_PinnedVersionInCooldown_RecordsStats
ctx := makeTestRequestContext("https://pypi.org/simple/testpkg/") ctx := makeTestRequestContext("https://pypi.org/simple/testpkg/")
ctx.Headers.Set("Accept", pypiSimpleAPIContentType) 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.NoError(t, err)
require.NotNil(t, resp.ResponseModifier) require.NotNil(t, resp.ResponseModifier)
@@ -561,7 +561,7 @@ func TestPyPICooldown_HandleMetadataRequest_PinnedVersionNotInCooldown_NoBlock(t
ctx := makeTestRequestContext("https://pypi.org/simple/testpkg/") ctx := makeTestRequestContext("https://pypi.org/simple/testpkg/")
ctx.Headers.Set("Accept", pypiSimpleAPIContentType) 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.NoError(t, err)
require.NotNil(t, resp.ResponseModifier) require.NotNil(t, resp.ResponseModifier)
@@ -589,7 +589,7 @@ func TestPyPICooldown_HandleMetadataRequest_UnpinnedWithRemainingVersions_NoBloc
ctx := makeTestRequestContext("https://pypi.org/simple/testpkg/") ctx := makeTestRequestContext("https://pypi.org/simple/testpkg/")
ctx.Headers.Set("Accept", pypiSimpleAPIContentType) 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.NoError(t, err)
require.NotNil(t, resp.ResponseModifier) 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")) 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) { func TestPyPICooldown_FileDownloadBypassesCooldown(t *testing.T) {
setCooldownConfig(t, config.DependencyCooldownConfig{Enabled: true, Days: 5}) setCooldownConfig(t, config.DependencyCooldownConfig{Enabled: true, Days: 5})
+10 -12
View File
@@ -126,20 +126,10 @@ func (i *PypiRegistryInterceptor) HandleRequest(ctx *proxy.RequestContext) (*pro
// for version resolution. JSON API requests (/pypi/{pkg}/json) are allowed through; // 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. // they have a different response structure and pip does not use them for installs.
if depCooldownConfig.Enabled && strings.HasPrefix(ctx.URL.Path, "/simple/") { if depCooldownConfig.Enabled && strings.HasPrefix(ctx.URL.Path, "/simple/") {
// Match the skip list against the normalized name (lowercase, _/. → -), if pmgconfig.IsTrustedPackageAllVersions(packagev1.Ecosystem_ECOSYSTEM_PYPI, denormalizePyPIPackageName(pkgInfo.GetName())) {
// 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())
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
} }
return i.cooldownHandler.HandleMetadataRequest(ctx, pkgInfo.GetName(), depCooldownConfig.Days, i.execContext.PinnedVersions[pkgInfo.GetName()])
// 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)
} }
log.Debugf("[%s] Skipping analysis for metadata request: %s", ctx.RequestID, 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 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 // Get file type for logging if available
fileType := "" fileType := ""
if pypiInfo, ok := pkgInfo.(*pypiPackageInfo); ok { if pypiInfo, ok := pkgInfo.(*pypiPackageInfo); ok {