mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
feat(analyzer): analysis cache contract (MalysisCache interface + config) (#334)
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
package analyzer
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
|
||||
)
|
||||
|
||||
// MalysisCache is the contract for a caching layer over package malware-analysis
|
||||
// verdicts. It lets a repeat analysis of the same package version be served
|
||||
// without a fresh backend round-trip, which is the dominant cost when
|
||||
// re-installing an already-screened dependency graph.
|
||||
//
|
||||
// Implementations are injected by the caller and may be backed by any store
|
||||
// (filesystem, sqlite, in-memory, ...). An implementation owns its own
|
||||
// expiry/TTL policy and must be safe for concurrent use by multiple goroutines.
|
||||
//
|
||||
// The cache stores and returns whatever verdicts it is given; the decision of
|
||||
// which verdicts are safe to cache (e.g. only ALLOW) belongs to the caller, not
|
||||
// the cache.
|
||||
type MalysisCache interface {
|
||||
// Get returns the cached analysis result for the given package version.
|
||||
// The boolean is false on a miss, which includes an absent or expired
|
||||
// entry. A non-nil error indicates a backend failure; callers should treat
|
||||
// it as a miss and fall back to a fresh analysis rather than failing the
|
||||
// operation.
|
||||
Get(ctx context.Context, pkg *packagev1.PackageVersion) (*PackageVersionAnalysisResult, bool, error)
|
||||
|
||||
// Set stores an analysis result for the given package version. Caching is
|
||||
// best-effort: a non-nil error means the verdict could not be persisted and
|
||||
// callers should continue without failing.
|
||||
Set(ctx context.Context, pkg *packagev1.PackageVersion, result *PackageVersionAnalysisResult) error
|
||||
}
|
||||
@@ -97,11 +97,47 @@ type Config struct {
|
||||
|
||||
DependencyCooldown DependencyCooldownConfig `mapstructure:"dependency_cooldown"`
|
||||
|
||||
// AnalysisCache configures the optional cross-run cache of malware-analysis
|
||||
// verdicts, so repeat installs of an already-screened dependency graph skip
|
||||
// the per-package analysis round-trip.
|
||||
AnalysisCache AnalysisCacheConfig `mapstructure:"analysis_cache"`
|
||||
|
||||
Cloud CloudConfig `mapstructure:"cloud"`
|
||||
|
||||
Proxy ProxyConfig `mapstructure:"proxy"`
|
||||
}
|
||||
|
||||
// AnalysisCacheConfig is the umbrella for per-analyzer cross-run caches. Caching
|
||||
// is analyzer-specific — each analyzer decides what is safe to cache — so config
|
||||
// is nested per analyzer rather than shared. Today only the Malysis (malware)
|
||||
// analyzer has a cache; future analyzers can add their own sub-config here.
|
||||
type AnalysisCacheConfig struct {
|
||||
// Malysis configures the cross-run cache for the Malysis malware analyzer.
|
||||
Malysis MalysisCacheConfig `mapstructure:"malysis"`
|
||||
}
|
||||
|
||||
// MalysisCacheConfig configures a persistent, cross-run cache of package
|
||||
// malware-analysis verdicts produced by the Malysis analyzer.
|
||||
//
|
||||
// By default PMG keeps an in-memory analysis cache that lives only for the
|
||||
// duration of a single invocation, so every install re-screens the whole
|
||||
// resolved graph against the analysis backend. When Enabled, clean (ALLOW)
|
||||
// verdicts are additionally persisted on disk and reused across runs, which
|
||||
// makes repeat installs of an unchanged graph fast.
|
||||
//
|
||||
// Security trade-off: a version that was clean when first screened but is later
|
||||
// flagged as malicious is served from cache (and thus allowed) until its entry
|
||||
// expires; TTL bounds that exposure window. Only ALLOW verdicts are cached —
|
||||
// suspicious, malicious, and tenant-excluded verdicts are always re-evaluated.
|
||||
// Disabled by default.
|
||||
type MalysisCacheConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
|
||||
// TTL is how long a cached verdict remains valid. A non-positive TTL
|
||||
// disables persistence (entries are always treated as a miss).
|
||||
TTL time.Duration `mapstructure:"ttl"`
|
||||
}
|
||||
|
||||
// CloudConfig configures audit event sync to SafeDep Cloud.
|
||||
type CloudConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
@@ -268,6 +304,7 @@ type RuntimeConfig struct {
|
||||
sandboxProfileDir string
|
||||
sandboxOverlayDir string
|
||||
sandboxViolationCacheDir string
|
||||
cacheDir string
|
||||
viper *viper.Viper
|
||||
}
|
||||
|
||||
@@ -342,6 +379,15 @@ func (r *RuntimeConfig) SandboxViolationCacheDir() string {
|
||||
return r.sandboxViolationCacheDir
|
||||
}
|
||||
|
||||
// CacheDir returns the path to the PMG cache root directory. This follows the
|
||||
// platform cache convention (XDG cache dir on Linux, ~/Library/Caches on macOS,
|
||||
// %LOCALAPPDATA% on Windows) and is overridable via PMG_CACHE_DIR. Caching
|
||||
// layers (e.g. the analysis cache) should store regenerable data here rather
|
||||
// than under the config directory.
|
||||
func (r *RuntimeConfig) CacheDir() string {
|
||||
return r.cacheDir
|
||||
}
|
||||
|
||||
func (r *RuntimeConfig) IsProxyModeEnabled() bool {
|
||||
return r.Config.Proxy.Enabled
|
||||
}
|
||||
@@ -396,6 +442,12 @@ func DefaultConfig() RuntimeConfig {
|
||||
Enabled: true,
|
||||
Days: 5,
|
||||
},
|
||||
AnalysisCache: AnalysisCacheConfig{
|
||||
Malysis: MalysisCacheConfig{
|
||||
Enabled: false,
|
||||
TTL: 24 * time.Hour,
|
||||
},
|
||||
},
|
||||
Cloud: CloudConfig{
|
||||
Enabled: false,
|
||||
AutoSync: CloudAutoSyncConfig{
|
||||
@@ -472,6 +524,11 @@ func initConfig() {
|
||||
panic(fmt.Errorf("failed to get sandbox overlay directory: %w", err))
|
||||
}
|
||||
|
||||
cacheRootDir, err := cacheDir()
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to get cache directory: %w", err))
|
||||
}
|
||||
|
||||
globalConfig.configDir = configDir
|
||||
globalConfig.configFilePath = activeConfigPath
|
||||
globalConfig.userConfigFilePath = userConfigPath
|
||||
@@ -479,6 +536,7 @@ func initConfig() {
|
||||
globalConfig.sandboxProfileDir = sandboxProfileDir
|
||||
globalConfig.sandboxOverlayDir = sandboxOverlayDir
|
||||
globalConfig.sandboxViolationCacheDir = sandboxViolationCacheDir
|
||||
globalConfig.cacheDir = cacheRootDir
|
||||
|
||||
// A globally managed config enforces lockdown only when it opts in via
|
||||
// global_lockdown, read straight from the file so it cannot be flipped by
|
||||
|
||||
@@ -182,6 +182,23 @@ dependency_cooldown:
|
||||
# reason: "Pin a specific just-published build"
|
||||
skip: []
|
||||
|
||||
# Persistent analysis cache (opt-in). Caching is analyzer-specific, so config is
|
||||
# nested per analyzer; today only the Malysis (malware) analyzer has a cache.
|
||||
#
|
||||
# By default PMG re-screens the whole resolved graph against the analysis
|
||||
# backend on every install. When enabled, clean (ALLOW) verdicts are cached on
|
||||
# disk and reused across runs, so repeat installs of an unchanged graph are
|
||||
# fast. Only ALLOW verdicts are cached — suspicious, malicious, and
|
||||
# tenant-excluded verdicts are always re-evaluated.
|
||||
#
|
||||
# Trade-off: a version that was clean when first screened but is later flagged
|
||||
# malicious is served from cache until its entry expires; `ttl` bounds that
|
||||
# window. Keep `ttl` short if you prefer freshness over speed.
|
||||
analysis_cache:
|
||||
malysis:
|
||||
enabled: false
|
||||
ttl: 24h
|
||||
|
||||
# Cloud sync configuration.
|
||||
# When enabled, PMG audit events are synced to SafeDep Cloud for centralized visibility.
|
||||
# Requires SAFEDEP_API_KEY and SAFEDEP_TENANT_ID environment variables for authentication.
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# Analysis Cache
|
||||
|
||||
PMG screens every package in the resolved dependency graph against the malware
|
||||
analysis backend. By default this screening cache is **in-memory and per-run**:
|
||||
it is empty at the start of each `install`, so every install re-screens the whole
|
||||
graph — even when the package store is warm and nothing changed. For large or
|
||||
frequently re-installed graphs this dominates wall-clock time.
|
||||
|
||||
The persistent analysis cache stores clean verdicts across runs and reuses them,
|
||||
so a repeat install of an unchanged graph skips the per-package analysis
|
||||
round-trip.
|
||||
|
||||
> **Status:** this page describes the analysis-cache contract and configuration.
|
||||
> The caching layer is defined as the `analyzer.MalysisCache` interface so it can
|
||||
> be backed by different stores; a concrete persistent implementation (sqlite)
|
||||
> lands in a follow-up. Until then, `enabled` is inert.
|
||||
|
||||
## How It Works
|
||||
|
||||
- The cache is modeled as the `analyzer.MalysisCache` interface — a pluggable
|
||||
contract the analyzer layer reads through. A concrete backend (e.g. sqlite) is
|
||||
injected by the caller, so the abstraction is decoupled from any single store.
|
||||
- Persistent verdicts live under the platform **cache** directory (`config.CacheDir()`
|
||||
— XDG cache dir on Linux, `~/Library/Caches` on macOS, `%LOCALAPPDATA%` on
|
||||
Windows; overridable via `PMG_CACHE_DIR`), not the config directory, since they
|
||||
are regenerable.
|
||||
- **Only clean (`ALLOW`) verdicts are cached.** Suspicious, malicious, and
|
||||
tenant-excluded verdicts are never persisted and are always re-evaluated.
|
||||
- Each entry expires after `ttl`.
|
||||
|
||||
## Configuration
|
||||
|
||||
Disabled by default. Caching is analyzer-specific, so it is configured per
|
||||
analyzer under `analysis_cache`; today only the Malysis (malware) analyzer has a
|
||||
cache. Enable it in `config.yml`:
|
||||
|
||||
```yaml
|
||||
analysis_cache:
|
||||
malysis:
|
||||
enabled: true
|
||||
ttl: 24h
|
||||
```
|
||||
|
||||
- `enabled` — turn the persistent cache on/off.
|
||||
- `ttl` — how long a cached verdict stays valid (Go duration, e.g. `30m`, `24h`,
|
||||
`168h`). A non-positive `ttl` disables persistence (every lookup is a miss),
|
||||
making the cache behave like the default in-memory one.
|
||||
|
||||
## Security Trade-off
|
||||
|
||||
Caching a verdict means trusting it for up to `ttl` without re-checking. A
|
||||
package version that was clean when first screened but is **later flagged as
|
||||
malicious** will be served from cache — and therefore allowed — until its entry
|
||||
expires. `ttl` bounds that exposure window.
|
||||
|
||||
Because only `ALLOW` verdicts are cached, a package that is currently flagged is
|
||||
never cached and is always re-evaluated. Choose `ttl` to balance install speed
|
||||
against how quickly you want newly-published malware verdicts to take effect. If
|
||||
in doubt, keep the cache disabled (the default) or use a short `ttl`.
|
||||
|
||||
## Requirements
|
||||
|
||||
The analysis cache applies to [proxy mode](proxy-mode.md). It is independent of
|
||||
[dependency cooldown](dependency-cooldown.md): cooldown decides which *versions*
|
||||
are eligible to install, while the analysis cache remembers malware verdicts for
|
||||
versions that were already screened.
|
||||
Reference in New Issue
Block a user