mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
feat(cooldown): respect trusted_packages in dependency cooldown (#342)
* feat(cooldown): respect trusted_packages in dependency cooldown Trusted packages are now treated as a superset waiver that bypasses every PMG control (malware analysis, cooldown, and any future controls). A globally trusted package is automatically exempt from the cooldown window and no longer needs a duplicate entry in dependency_cooldown.skip. The skip list remains the narrower, cooldown-only waiver for packages that must bypass the cooldown wait but still be malware-scanned. * refactor(cooldown): tag skip reason and audit-log skipped packages Address review feedback on #342: - Restore cooldownSkip to a pure single-list function (SRP); the merge into trusted_packages now happens in a separate mergeCooldownSkip step, driven by the exported CooldownSkip wrapper. - Extend CooldownSkipInfo with a CooldownSkipReason (TrustedPackage / CooldownSkipList) on both SkipAll and per-version entries, so callers can tell apart the broad waiver from the cooldown-only one. When both lists match the same package, trusted_packages wins. - Add audit.LogCooldownSkipped and emit it from the npm and PyPI interceptors on the SkipAll path, alongside the existing info log, carrying the source list as the reason. * refactor(cooldown): inline list merge, audit per-version exemptions Address further review feedback: - Drop the separate mergeCooldownSkip helper; cooldownSkip now writes into a shared *CooldownSkipInfo and is called twice from CooldownSkip (cooldown skip list first, trusted_packages on top so trusted entries override the reason on overlap). - Audit log every exemption, not just SkipAll: a new auditCooldownSkip helper in proxy/interceptors/cooldown.go emits one event per match (package-wide or per-version), each tagged with its source list. LogCooldownSkipped gains a version argument for the per-version case. - Cover the trusted_packages reason path in TestCooldownSkip. * fix(cooldown): avoid double-auditing trusted package exemptions auditCooldownSkip now only emits EventTypeCooldownSkipped for entries that came from dependency_cooldown.skip. Trusted-package exemptions already get an EventTypeInstallTrustedAllowed event at tarball-download time (proxy/interceptors/base_registry.go), so emitting a cooldown event for them too would double-count the same waiver. * emit trusted and cooldown skip events to cloud * fix tests * refactor(cooldown): return value from collectCooldownSkip, short-circuit on trusted SkipAll Address PR review feedback: - Rename cooldownSkip to collectCooldownSkip and return CooldownSkipInfo instead of mutating an input pointer. - Add mergeCooldownSkip to combine per-list results with trusted_packages taking precedence on overlap. - CooldownSkip now consults trusted_packages first and returns immediately on a package-wide trusted exemption (DC skip list cannot add anything). - Extend tests to cover disjoint pinned entries across both lists and the case where DC version-less subsumes a trusted pinned entry. * fix(audit): address cooldown review feedback * fix(cooldown): audit cooldown skips at download time with concrete version Backend rejects PackageVersion messages without a version, and audit logs should reflect the runtime fact (a specific version was skipped) rather than the config rule. Move the audit emission from metadata-request handling to download-request handling, where the concrete version is known, and require version in LogCooldownSkipped. * chore(audit): drop dead scope assignment in LogCooldownSkipped * refactor(cooldown): move skip-list logic into cooldown handlers Registry interceptors no longer compute CooldownSkip or branch on SkipAll; they just call HandleMetadataRequest. The npm and pypi cooldown handlers own the skip lookup, the package-wide exemption short-circuit, and (for pypi) the canonical-name denormalization. Also align LogCooldownSkipped with other LogXxx signatures by taking *packagev1.PackageVersion. * fix: Simplify audit logging for dependency cooldown skip * refactor: Simplify cooldown handling and maintain separation of concepts for trusted and DC skip packages * fix: Code review fixes * fix: Emit cooldown skipped audit event ONLY when an in-window version is skipped --------- Co-authored-by: Abhisek Datta <abhisek.datta@gmail.com>
This commit is contained in:
co-authored by
Abhisek Datta
parent
c17b941ac3
commit
327c9c7068
+5
-3
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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")},
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user