feat: Add malysis cache implementation with proxy flow integration (#346)

* feat: Add malysis cache implementation with proxy flow integration

* fix: Code review fixes
This commit is contained in:
Abhisek Datta
2026-06-22 10:12:02 +05:30
committed by GitHub
parent 327c9c7068
commit d360e75897
21 changed files with 820 additions and 21 deletions
+4
View File
@@ -1,5 +1,9 @@
# PMG - Development Guide
**DO NOT USE UNNECESSARY CODE COMMENTS** - The code is read and written by humans who are proficient
in Go programming language. Write idiomatic Go code following DRY and SOLID principles. DO NOT SHY
AWAY FROM PROPOSING REFACTORING THAT IMPROVES THE CODE BASE.
## Build & Test
```bash
+1
View File
@@ -260,6 +260,7 @@ PMG builds are reproducible and signed.
- [Configuration](docs/config.md)
- [Trusted Packages Configuration](docs/trusted-packages.md)
- [Dependency Cooldown](docs/dependency-cooldown.md)
- [Caching](docs/caching.md)
- [Proxy Mode Architecture](docs/proxy-mode.md)
- [Certificate Authority](docs/cert.md)
- [Sandboxing](docs/sandbox.md)
+40
View File
@@ -0,0 +1,40 @@
package analyzer
import (
"context"
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
"github.com/safedep/dry/log"
)
// malysisCachingAnalyzer is a read-through cache decorator over a PackageVersionAnalyzer.
type malysisCachingAnalyzer struct {
PackageVersionAnalyzer
cache MalysisCache
}
func newMalysisCachingAnalyzer(next PackageVersionAnalyzer, cache MalysisCache) *malysisCachingAnalyzer {
return &malysisCachingAnalyzer{PackageVersionAnalyzer: next, cache: cache}
}
func (c *malysisCachingAnalyzer) Analyze(ctx context.Context, pkg *packagev1.PackageVersion) (*PackageVersionAnalysisResult, error) {
name, version := pkg.GetPackage().GetName(), pkg.GetVersion()
if result, ok, err := c.cache.Get(ctx, pkg); err != nil {
log.Warnf("malysis cache lookup failed, falling back to live analysis: %v", err)
} else if ok {
log.Debugf("malysis cache hit: %s@%s", name, version)
return result, nil
}
log.Debugf("malysis cache miss: %s@%s", name, version)
result, err := c.PackageVersionAnalyzer.Analyze(ctx, pkg)
if err != nil {
return nil, err
}
if err := c.cache.Set(ctx, pkg, result); err != nil {
log.Warnf("malysis cache store failed: %v", err)
}
return result, nil
}
+85
View File
@@ -0,0 +1,85 @@
package analyzer
import (
"context"
"errors"
"testing"
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type fakeCache struct {
getResult *PackageVersionAnalysisResult
getOK bool
getErr error
setErr error
setCalls int
}
func (f *fakeCache) Get(context.Context, *packagev1.PackageVersion) (*PackageVersionAnalysisResult, bool, error) {
return f.getResult, f.getOK, f.getErr
}
func (f *fakeCache) Set(context.Context, *packagev1.PackageVersion, *PackageVersionAnalysisResult) error {
f.setCalls++
return f.setErr
}
type fakeAnalyzer struct {
result *PackageVersionAnalysisResult
err error
callCount int
}
func (f *fakeAnalyzer) Name() string { return "fake" }
func (f *fakeAnalyzer) Analyze(context.Context, *packagev1.PackageVersion) (*PackageVersionAnalysisResult, error) {
f.callCount++
return f.result, f.err
}
func TestMalysisCachingAnalyzer_Hit(t *testing.T) {
cached := &PackageVersionAnalysisResult{Action: ActionAllow, AnalysisID: "cached"}
next := &fakeAnalyzer{}
a := newMalysisCachingAnalyzer(next, &fakeCache{getResult: cached, getOK: true})
got, err := a.Analyze(context.Background(), &packagev1.PackageVersion{})
require.NoError(t, err)
assert.Equal(t, "cached", got.AnalysisID)
assert.Equal(t, 0, next.callCount, "hit must not delegate")
}
func TestMalysisCachingAnalyzer_MissDelegatesAndSets(t *testing.T) {
live := &PackageVersionAnalysisResult{Action: ActionAllow, AnalysisID: "live"}
next := &fakeAnalyzer{result: live}
fc := &fakeCache{}
a := newMalysisCachingAnalyzer(next, fc)
got, err := a.Analyze(context.Background(), &packagev1.PackageVersion{})
require.NoError(t, err)
assert.Equal(t, "live", got.AnalysisID)
assert.Equal(t, 1, next.callCount)
assert.Equal(t, 1, fc.setCalls)
}
func TestMalysisCachingAnalyzer_FailSoft(t *testing.T) {
live := &PackageVersionAnalysisResult{Action: ActionAllow, AnalysisID: "live"}
next := &fakeAnalyzer{result: live}
// Get error => treat as miss and delegate; Set error => still return result.
a := newMalysisCachingAnalyzer(next, &fakeCache{getErr: errors.New("boom"), setErr: errors.New("boom")})
got, err := a.Analyze(context.Background(), &packagev1.PackageVersion{})
require.NoError(t, err)
assert.Equal(t, "live", got.AnalysisID)
assert.Equal(t, 1, next.callCount)
}
func TestMalysisCachingAnalyzer_AnalyzeErrorNotCached(t *testing.T) {
next := &fakeAnalyzer{err: errors.New("network")}
fc := &fakeCache{}
a := newMalysisCachingAnalyzer(next, fc)
_, err := a.Analyze(context.Background(), &packagev1.PackageVersion{})
require.Error(t, err)
assert.Equal(t, 0, fc.setCalls, "errored analysis must not be cached")
}
+10 -2
View File
@@ -28,7 +28,7 @@ func newMalysisAnalyzer(config MalysisQueryAnalyzerConfig,
creds, closeResolver, err := resolveCredentials()
if err != nil {
log.Debugf("SafeDep Cloud credentials unavailable, using community malysis analyzer: %v", err)
return community, nil
return withCache(community, config), nil
}
defer func() {
if closeErr := closeResolver(); closeErr != nil {
@@ -42,5 +42,13 @@ func newMalysisAnalyzer(config MalysisQueryAnalyzerConfig,
}
log.Debugf("SafeDep Cloud credentials found, using authenticated malysis analyzer with community fallback")
return newMalysisFallbackAnalyzer(authenticated, community), nil
return withCache(newMalysisFallbackAnalyzer(authenticated, community), config), nil
}
// withCache wraps a in a read-through cache decorator when one is configured.
func withCache(a PackageVersionAnalyzer, config MalysisQueryAnalyzerConfig) PackageVersionAnalyzer {
if config.Cache == nil {
return a
}
return newMalysisCachingAnalyzer(a, config.Cache)
}
+5 -1
View File
@@ -21,7 +21,11 @@ const (
communityMalysisPort = "443"
)
type MalysisQueryAnalyzerConfig struct{}
type MalysisQueryAnalyzerConfig struct {
// Cache, when non-nil, enables a persistent read-through verdict cache
// applied as a decorator by newMalysisAnalyzer. nil = no caching.
Cache MalysisCache
}
type malysisQueryAnalyzer struct {
client malysisv1grpc.MalwareAnalysisServiceClient
+149
View File
@@ -0,0 +1,149 @@
// Package malysiscache is a localdb-backed persistent cache of benign Malysis
// verdicts. It implements analyzer.MalysisCache. It owns only its localdb
// module schema; the DB file location is owned by the config package and the
// Manager lifecycle by the composition root.
package malysiscache
import (
"context"
"database/sql"
"errors"
"time"
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
"github.com/safedep/dry/localdb"
"github.com/safedep/dry/log"
"github.com/safedep/pmg/analyzer"
"github.com/safedep/pmg/config"
)
var _ analyzer.MalysisCache = (*Cache)(nil)
const moduleName = "malysis_cache"
// Descriptor is the localdb module contract: the verdicts table. Migrations are
// append-only — never edit or reorder an existing entry.
func Descriptor() localdb.Descriptor {
return localdb.Descriptor{
Name: moduleName,
Migrations: []string{
`CREATE TABLE malysis_cache_verdicts (
ecosystem TEXT NOT NULL,
name TEXT NOT NULL,
version TEXT NOT NULL,
analysis_id TEXT,
reference_url TEXT,
summary TEXT,
created_at INTEGER NOT NULL,
expires_at INTEGER,
PRIMARY KEY (ecosystem, name, version)
)`,
},
}
}
type Cache struct {
db *sql.DB
cfg config.MalysisCacheConfig
now func() time.Time
}
func New(store *localdb.Store, cfg config.MalysisCacheConfig) *Cache {
return &Cache{db: store.DB(), cfg: cfg, now: time.Now}
}
type Stats struct {
Count int
Oldest time.Time
Newest time.Time
}
func (c *Cache) Stats(ctx context.Context) (Stats, error) {
var s Stats
var oldest, newest sql.NullInt64
err := c.db.QueryRowContext(ctx,
`SELECT COUNT(*), MIN(created_at), MAX(created_at) FROM malysis_cache_verdicts`).
Scan(&s.Count, &oldest, &newest)
if err != nil {
return Stats{}, err
}
if oldest.Valid {
s.Oldest = time.Unix(oldest.Int64, 0)
}
if newest.Valid {
s.Newest = time.Unix(newest.Int64, 0)
}
return s, nil
}
func (c *Cache) Clear(ctx context.Context) error {
_, err := c.db.ExecContext(ctx, `DELETE FROM malysis_cache_verdicts`)
return err
}
func (c *Cache) Get(ctx context.Context, pkg *packagev1.PackageVersion) (*analyzer.PackageVersionAnalysisResult, bool, error) {
eco, name, version := packageKey(pkg)
var analysisID, referenceURL, summary string
var createdAt int64
var expiresAt sql.NullInt64
err := c.db.QueryRowContext(ctx,
`SELECT analysis_id, reference_url, summary, created_at, expires_at
FROM malysis_cache_verdicts WHERE ecosystem=? AND name=? AND version=?`,
eco, name, version).
Scan(&analysisID, &referenceURL, &summary, &createdAt, &expiresAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
if c.expired(createdAt, expiresAt) {
if _, derr := c.db.ExecContext(ctx,
`DELETE FROM malysis_cache_verdicts WHERE ecosystem=? AND name=? AND version=?`,
eco, name, version); derr != nil {
log.Warnf("malysiscache: failed to delete expired entry: %v", derr)
}
return nil, false, nil
}
result, ok := reconstruct(eco, name, version, analysisID, referenceURL, summary)
if !ok {
return nil, false, nil
}
return result, true, nil
}
func (c *Cache) Set(ctx context.Context, pkg *packagev1.PackageVersion, result *analyzer.PackageVersionAnalysisResult) error {
if !cacheable(result) {
return nil
}
// Non-positive TTL disables persistence (MalysisCacheConfig.TTL contract).
if c.cfg.TTL <= 0 {
return nil
}
eco, name, version := packageKey(pkg)
// expires_at stays NULL in v1; a future backend hint populates it.
_, err := c.db.ExecContext(ctx,
`INSERT INTO malysis_cache_verdicts
(ecosystem, name, version, analysis_id, reference_url, summary, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, NULL)
ON CONFLICT(ecosystem, name, version) DO UPDATE SET
analysis_id = excluded.analysis_id,
reference_url = excluded.reference_url,
summary = excluded.summary,
created_at = excluded.created_at,
expires_at = excluded.expires_at`,
eco, name, version, result.AnalysisID, result.ReferenceURL, result.Summary, c.now().Unix())
return err
}
func (c *Cache) expired(createdAt int64, expiresAt sql.NullInt64) bool {
exp := createdAt + int64(c.cfg.TTL.Seconds())
if expiresAt.Valid {
exp = expiresAt.Int64
}
return c.now().Unix() >= exp
}
+108
View File
@@ -0,0 +1,108 @@
package malysiscache
import (
"context"
"testing"
"time"
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
"github.com/safedep/dry/localdb"
"github.com/safedep/pmg/analyzer"
"github.com/safedep/pmg/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newTestCache(t *testing.T) *Cache {
t.Helper()
mgr := localdb.New(localdb.Config{Dir: t.TempDir(), FileName: "pmg.db"})
t.Cleanup(func() { require.NoError(t, mgr.Close()) })
store, err := mgr.Store(context.Background(), Descriptor())
require.NoError(t, err)
return New(store, config.MalysisCacheConfig{TTL: 24 * time.Hour})
}
func TestStatsAndClearEmpty(t *testing.T) {
c := newTestCache(t)
s, err := c.Stats(context.Background())
require.NoError(t, err)
assert.Equal(t, 0, s.Count)
assert.True(t, s.Oldest.IsZero())
require.NoError(t, c.Clear(context.Background()))
}
func allow(name, version string) *analyzer.PackageVersionAnalysisResult {
return &analyzer.PackageVersionAnalysisResult{
Action: analyzer.ActionAllow,
AnalysisID: "aid-" + version,
ReferenceURL: "https://ref/" + version,
Summary: "clean",
}
}
func TestSetGetRoundTrip(t *testing.T) {
c := newTestCache(t)
ctx := context.Background()
p := pkg(packagev1.Ecosystem_ECOSYSTEM_NPM, "left-pad", "1.0.0")
require.NoError(t, c.Set(ctx, p, allow("left-pad", "1.0.0")))
got, ok, err := c.Get(ctx, p)
require.NoError(t, err)
require.True(t, ok)
assert.Equal(t, "aid-1.0.0", got.AnalysisID)
assert.Equal(t, analyzer.ActionAllow, got.Action)
assert.Equal(t, "left-pad", got.PackageVersion.GetPackage().GetName())
}
func TestSetSkipsWriteWhenTTLNonPositive(t *testing.T) {
mgr := localdb.New(localdb.Config{Dir: t.TempDir(), FileName: "pmg.db"})
t.Cleanup(func() { require.NoError(t, mgr.Close()) })
store, err := mgr.Store(context.Background(), Descriptor())
require.NoError(t, err)
c := New(store, config.MalysisCacheConfig{TTL: 0})
ctx := context.Background()
p := pkg(packagev1.Ecosystem_ECOSYSTEM_NPM, "left-pad", "1.0.0")
require.NoError(t, c.Set(ctx, p, allow("left-pad", "1.0.0")))
s, err := c.Stats(ctx)
require.NoError(t, err)
assert.Equal(t, 0, s.Count, "non-positive TTL disables persistence: nothing written")
}
func TestSetSkipsNonCacheable(t *testing.T) {
c := newTestCache(t)
ctx := context.Background()
p := pkg(packagev1.Ecosystem_ECOSYSTEM_NPM, "evil", "9.9.9")
excluded := &analyzer.PackageVersionAnalysisResult{Action: analyzer.ActionAllow, IsMalware: true, IsExcluded: true}
require.NoError(t, c.Set(ctx, p, excluded))
_, ok, err := c.Get(ctx, p)
require.NoError(t, err)
assert.False(t, ok, "excluded-malware must never be cached")
}
func TestGetMissAndExpiry(t *testing.T) {
c := newTestCache(t)
ctx := context.Background()
p := pkg(packagev1.Ecosystem_ECOSYSTEM_PYPI, "requests", "2.0.0")
_, ok, err := c.Get(ctx, p)
require.NoError(t, err)
assert.False(t, ok)
require.NoError(t, c.Set(ctx, p, allow("requests", "2.0.0")))
// Advance the clock past the TTL: the entry is expired and lazily deleted.
c.now = func() time.Time { return time.Now().Add(25 * time.Hour) }
_, ok, err = c.Get(ctx, p)
require.NoError(t, err)
assert.False(t, ok)
s, err := c.Stats(ctx)
require.NoError(t, err)
assert.Equal(t, 0, s.Count, "expired row should be lazily deleted")
}
+59
View File
@@ -0,0 +1,59 @@
package malysiscache
import (
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
"github.com/safedep/pmg/analyzer"
)
// cacheable is the security predicate: only a clean benign allow is ever
// persisted. ActionAllow alone is NOT sufficient — applyExclusion downgrades a
// confirmed-malicious package to ActionAllow while leaving IsMalware=true and
// setting IsExcluded=true. A tenant exclusion is auth-scoped and revocable, not
// a property of the artifact, so it must never be cached.
func cacheable(r *analyzer.PackageVersionAnalysisResult) bool {
return r != nil && r.Action == analyzer.ActionAllow && !r.IsMalware && !r.IsExcluded
}
// packageKey extracts the verbatim cache key. No canonicalization: the cache
// keys on whatever identity the pipeline already uses.
func packageKey(pkg *packagev1.PackageVersion) (eco, name, version string) {
return pkg.GetPackage().GetEcosystem().String(),
pkg.GetPackage().GetName(),
pkg.GetVersion()
}
// ecosystemFromString maps the stored enum name back to the enum. ok is false
// for an unknown name (a row written by a newer binary that knows an ecosystem
// this one does not).
func ecosystemFromString(s string) (packagev1.Ecosystem, bool) {
v, ok := packagev1.Ecosystem_value[s]
if !ok {
return packagev1.Ecosystem_ECOSYSTEM_UNSPECIFIED, false
}
return packagev1.Ecosystem(v), true
}
// reconstruct rebuilds a cached ALLOW verdict from a row. ok is false when the
// ecosystem name is unknown, so the caller treats it as a miss. The malware /
// exclusion fields are hard-coded false: cacheable() guarantees only clean
// benign allows were ever stored.
func reconstruct(eco, name, version, analysisID, referenceURL, summary string) (*analyzer.PackageVersionAnalysisResult, bool) {
ecosystem, ok := ecosystemFromString(eco)
if !ok {
return nil, false
}
pv := &packagev1.PackageVersion{}
pv.SetPackage(&packagev1.Package{})
pv.GetPackage().SetName(name)
pv.GetPackage().SetEcosystem(ecosystem)
pv.SetVersion(version)
return &analyzer.PackageVersionAnalysisResult{
PackageVersion: pv,
AnalysisID: analysisID,
ReferenceURL: referenceURL,
Action: analyzer.ActionAllow,
Summary: summary,
}, true
}
+56
View File
@@ -0,0 +1,56 @@
package malysiscache
import (
"testing"
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
"github.com/safedep/pmg/analyzer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func pkg(eco packagev1.Ecosystem, name, version string) *packagev1.PackageVersion {
pv := &packagev1.PackageVersion{}
pv.SetPackage(&packagev1.Package{})
pv.GetPackage().SetName(name)
pv.GetPackage().SetEcosystem(eco)
pv.SetVersion(version)
return pv
}
func TestCacheable(t *testing.T) {
cases := []struct {
name string
in *analyzer.PackageVersionAnalysisResult
want bool
}{
{"clean allow", &analyzer.PackageVersionAnalysisResult{Action: analyzer.ActionAllow}, true},
{"block", &analyzer.PackageVersionAnalysisResult{Action: analyzer.ActionBlock}, false},
{"allow but malware", &analyzer.PackageVersionAnalysisResult{Action: analyzer.ActionAllow, IsMalware: true}, false},
{"excluded malware downgrade", &analyzer.PackageVersionAnalysisResult{Action: analyzer.ActionAllow, IsMalware: true, IsExcluded: true}, false},
{"nil", nil, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, cacheable(tc.in))
})
}
}
func TestKeyAndReconstruct(t *testing.T) {
eco, name, ver := packageKey(pkg(packagev1.Ecosystem_ECOSYSTEM_NPM, "left-pad", "1.0.0"))
assert.Equal(t, "ECOSYSTEM_NPM", eco)
assert.Equal(t, "left-pad", name)
assert.Equal(t, "1.0.0", ver)
res, ok := reconstruct(eco, name, ver, "aid", "https://ref", "ok")
require.True(t, ok)
assert.Equal(t, analyzer.ActionAllow, res.Action)
assert.False(t, res.IsMalware)
assert.Equal(t, packagev1.Ecosystem_ECOSYSTEM_NPM, res.PackageVersion.GetPackage().GetEcosystem())
assert.Equal(t, "left-pad", res.PackageVersion.GetPackage().GetName())
assert.Equal(t, "1.0.0", res.PackageVersion.GetVersion())
_, ok = reconstruct("ECOSYSTEM_FROM_THE_FUTURE", name, ver, "", "", "")
assert.False(t, ok)
}
+127
View File
@@ -0,0 +1,127 @@
package setup
import (
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"github.com/safedep/dry/localdb"
"github.com/safedep/dry/log"
"github.com/safedep/pmg/analyzer/malysiscache"
"github.com/safedep/pmg/config"
"github.com/spf13/cobra"
)
// NewCacheCommand returns the `pmg setup cache` command tree.
func NewCacheCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "cache",
Short: "Inspect and clear PMG's persistent analysis cache",
RunE: func(cmd *cobra.Command, _ []string) error { return cmd.Help() },
}
cmd.AddCommand(newCacheStatusCommand())
cmd.AddCommand(newCacheClearCommand())
return cmd
}
func newCacheStatusCommand() *cobra.Command {
return &cobra.Command{
Use: "status",
Short: "Show analysis cache path, state, TTL, and entry count",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, _ []string) error {
return runCacheStatus(cmd.Context(), config.Get(), os.Stdout)
},
}
}
func newCacheClearCommand() *cobra.Command {
return &cobra.Command{
Use: "clear",
Short: "Delete all cached analysis verdicts",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, _ []string) error {
return runCacheClear(cmd.Context(), config.Get(), os.Stdout)
},
}
}
// openCache opens the shared localdb and the malysis cache module. exists is
// false when the DB file is absent (nothing cached yet); the caller decides how
// to report that without creating the file.
func openCache(ctx context.Context, cfg *config.RuntimeConfig) (cache *malysiscache.Cache, closeFn func(), exists bool, err error) {
dbPath := filepath.Join(cfg.LocalDBDir(), cfg.LocalDBFileName())
if _, statErr := os.Stat(dbPath); statErr != nil {
if errors.Is(statErr, os.ErrNotExist) {
return nil, func() {}, false, nil
}
return nil, func() {}, false, statErr
}
mgr := localdb.New(localdb.Config{Dir: cfg.LocalDBDir(), FileName: cfg.LocalDBFileName()})
store, serr := mgr.Store(ctx, malysiscache.Descriptor())
if serr != nil {
if cerr := mgr.Close(); cerr != nil {
log.Warnf("failed to close localdb: %v", cerr)
}
return nil, func() {}, true, serr
}
closeFn = func() {
if cerr := mgr.Close(); cerr != nil {
log.Warnf("failed to close localdb: %v", cerr)
}
}
return malysiscache.New(store, cfg.Config.AnalysisCache.Malysis), closeFn, true, nil
}
func runCacheStatus(ctx context.Context, cfg *config.RuntimeConfig, out io.Writer) error {
mc := cfg.Config.AnalysisCache.Malysis
dbPath := filepath.Join(cfg.LocalDBDir(), cfg.LocalDBFileName())
cache, closeFn, exists, err := openCache(ctx, cfg)
if err != nil {
return fmt.Errorf("open analysis cache: %w", err)
}
defer closeFn()
count := 0
if exists {
stats, serr := cache.Stats(ctx)
if serr != nil {
return fmt.Errorf("read analysis cache: %w", serr)
}
count = stats.Count
}
if _, err := fmt.Fprintf(out, "Path: %s\nEnabled: %v\nTTL: %s\nEntries: %d\n",
dbPath, mc.Enabled, mc.TTL, count); err != nil {
return err
}
return nil
}
func runCacheClear(ctx context.Context, cfg *config.RuntimeConfig, out io.Writer) error {
cache, closeFn, exists, err := openCache(ctx, cfg)
if err != nil {
return fmt.Errorf("open analysis cache: %w", err)
}
defer closeFn()
if !exists {
if _, werr := fmt.Fprintln(out, "Analysis cache is already empty."); werr != nil {
return werr
}
return nil
}
if err := cache.Clear(ctx); err != nil {
return fmt.Errorf("clear analysis cache: %w", err)
}
if _, werr := fmt.Fprintln(out, "Analysis cache cleared."); werr != nil {
return werr
}
return nil
}
+25
View File
@@ -0,0 +1,25 @@
package setup
import (
"bytes"
"context"
"testing"
"github.com/safedep/pmg/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRunCache_NoFile(t *testing.T) {
t.Setenv("PMG_CACHE_DIR", t.TempDir())
config.Reload()
cfg := config.Get()
var out bytes.Buffer
require.NoError(t, runCacheStatus(context.Background(), cfg, &out))
assert.Contains(t, out.String(), "Entries: 0")
out.Reset()
require.NoError(t, runCacheClear(context.Background(), cfg, &out))
assert.Contains(t, out.String(), "already empty")
}
+1
View File
@@ -29,6 +29,7 @@ func NewSetupCommand() *cobra.Command {
setupCmd.AddCommand(NewInfoCommand())
setupCmd.AddCommand(NewDoctorCommand())
setupCmd.AddCommand(NewCertCommand())
setupCmd.AddCommand(NewCacheCommand())
return setupCmd
}
+36
View File
@@ -50,6 +50,12 @@ const (
// Default sandbox violation cache directory is relative to the cache root.
pmgDefaultSandboxViolationCacheDir = "sandbox/violations"
// Default localdb directory is relative to the cache root.
pmgDefaultLocalDBDir = "localdb"
// Default localdb file name for PMG's shared SQLite database.
pmgDefaultLocalDBFileName = "pmg.db"
// Config file name.
// Important: The config file path and the schema should be backward compatible. In case of breaking config
// changes, we must introduce a new file name and a migration path.
@@ -306,6 +312,7 @@ type RuntimeConfig struct {
sandboxProfileDir string
sandboxOverlayDir string
sandboxViolationCacheDir string
localDBDir string
cacheDir string
viper *viper.Viper
}
@@ -390,6 +397,18 @@ func (r *RuntimeConfig) CacheDir() string {
return r.cacheDir
}
// LocalDBDir returns the directory holding PMG's shared localdb SQLite file.
// localdb writes sibling -wal/-shm files here, so the Dir and FileName are
// exposed separately to match localdb.Config rather than as a joined path.
func (r *RuntimeConfig) LocalDBDir() string {
return r.localDBDir
}
// LocalDBFileName returns the file name of PMG's shared localdb SQLite file.
func (r *RuntimeConfig) LocalDBFileName() string {
return pmgDefaultLocalDBFileName
}
func (r *RuntimeConfig) IsProxyModeEnabled() bool {
return r.Config.Proxy.Enabled
}
@@ -531,6 +550,11 @@ func initConfig() {
panic(fmt.Errorf("failed to get cache directory: %w", err))
}
localDBDir, err := localDBDir()
if err != nil {
panic(fmt.Errorf("failed to get localdb directory: %w", err))
}
globalConfig.configDir = configDir
globalConfig.configFilePath = activeConfigPath
globalConfig.userConfigFilePath = userConfigPath
@@ -538,6 +562,7 @@ func initConfig() {
globalConfig.sandboxProfileDir = sandboxProfileDir
globalConfig.sandboxOverlayDir = sandboxOverlayDir
globalConfig.sandboxViolationCacheDir = sandboxViolationCacheDir
globalConfig.localDBDir = localDBDir
globalConfig.cacheDir = cacheRootDir
// A globally managed config enforces lockdown only when it opts in via
@@ -735,6 +760,17 @@ func sandboxViolationCacheDir() (string, error) {
return filepath.Join(cacheDir, pmgDefaultSandboxViolationCacheDir), nil
}
// localDBDir computes the directory holding PMG's shared localdb SQLite file
// and its WAL/shm siblings.
func localDBDir() (string, error) {
cacheDir, err := cacheDir()
if err != nil {
return "", fmt.Errorf("failed to get cache directory: %w", err)
}
return filepath.Join(cacheDir, pmgDefaultLocalDBDir), nil
}
// Get returns the global configuration.
// This is the public API for the configuration package. This package should guarantee
// that this function will never return nil.
+3
View File
@@ -199,6 +199,9 @@ dependency_cooldown:
# 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 is SafeDep's threat intelligence feed.
# Set cache TTL to be lower than dependency cooldown period to have a compensating
# control in place for newly published packages whose verdict is cached.
malysis:
enabled: false
ttl: 24h
+10
View File
@@ -266,6 +266,16 @@ func TestConfigureSandbox(t *testing.T) {
}
}
func TestLocalDBLocation(t *testing.T) {
dir := t.TempDir()
t.Setenv("PMG_CACHE_DIR", dir)
initConfig()
cfg := Get()
assert.Equal(t, filepath.Join(dir, "localdb"), cfg.LocalDBDir())
assert.Equal(t, "pmg.db", cfg.LocalDBFileName())
}
func TestWriteTemplateConfigMergesExistingConfig(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("PMG_CONFIG_DIR", tmpDir)
+59
View File
@@ -0,0 +1,59 @@
# Caching
PMG can cache package analysis results across runs to reduce repeat-install
latency. Caching is opt-in and disabled by default.
## Storage
PMG's on-disk caches share a single SQLite database at
`<cache-dir>/localdb/pmg.db`, where `<cache-dir>` follows the platform
convention:
| Platform | Default cache dir |
|----------|-------------------|
| macOS | `~/Library/Caches/safedep/pmg` |
| Linux | `$XDG_CACHE_HOME/safedep/pmg` (or `~/.cache/safedep/pmg`) |
| Windows | `%LOCALAPPDATA%\safedep\pmg` |
Override the location with the `PMG_CACHE_DIR` environment variable. The cache
is local to the machine and safe to delete at any time.
## Malysis analysis cache
Persists **benign** Malysis (malware analysis) verdicts on disk, so a package
version that was already screened is not re-analyzed on every install.
Only benign verdicts are cached, and each entry expires after a TTL. Malicious,
suspicious, and tenant-excluded verdicts are never cached and are always
re-evaluated. Keep the TTL below your `dependency_cooldown` window so newly
published packages remain covered by cooldown if a cached verdict goes stale.
### Enable
In `config.yml`:
```yaml
analysis_cache:
malysis:
enabled: true
ttl: 24h
```
Or from the CLI:
```bash
pmg config set analysis_cache.malysis.enabled true
pmg config set analysis_cache.malysis.ttl 24h
pmg config edit # open the config file directly
```
- `enabled` (default `false`) — turn the persistent cache on.
- `ttl` (default `24h`) — how long a cached benign verdict is reused, measured
from when it was fetched. A non-positive value disables persistence.
### Manage
```bash
pmg setup cache status # show path, enabled state, TTL, and entry count
pmg setup cache clear # delete all cached verdicts
```
+4 -5
View File
@@ -4,7 +4,7 @@ go 1.25.1
require (
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-20260618082119-01127207dee4.1
buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.11-20260620084912-77c7bb923ddb.1
github.com/Masterminds/semver v1.5.0
github.com/elazarl/goproxy v1.8.1
github.com/fatih/color v1.18.0
@@ -16,13 +16,14 @@ require (
github.com/landlock-lsm/go-landlock v0.7.0
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2
github.com/posthog/posthog-go v1.5.12
github.com/safedep/dry v0.0.0-20260524092302-4815730a17cf
github.com/safedep/dry v0.0.0-20260620130340-2af76e505537
github.com/safedep/ptyx v0.2.1-0.20260529140457-d1f745842a6a
github.com/sony/gobreaker/v2 v2.4.0
github.com/spf13/cobra v1.9.1
github.com/spf13/pflag v1.0.10
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
golang.org/x/net v0.51.0
golang.org/x/sync v0.20.0
golang.org/x/sys v0.43.0
golang.org/x/term v0.42.0
@@ -33,8 +34,7 @@ require (
require (
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/safedep/api/connectrpc/go v1.20.0-20260618082119-01127207dee4.1 // indirect
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 // indirect
github.com/BurntSushi/toml v1.5.0 // indirect
github.com/Masterminds/semver/v3 v3.3.1 // indirect
github.com/caarlos0/env/v11 v11.3.1 // indirect
@@ -84,7 +84,6 @@ require (
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
+6 -10
View File
@@ -1,15 +1,11 @@
al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho=
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/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/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 h1:s6hzCXtND/ICdGPTMGk7C+/BFlr2Jg5GyH0NKf4XGXg=
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM=
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/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-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=
buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.11-20260620084912-77c7bb923ddb.1 h1:AYEqYqmDeF99lbHGJYjyzACLhJhwF9cJVcNCdl3vwYQ=
buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.11-20260620084912-77c7bb923ddb.1/go.mod h1:I8E+sZXJNqzWBtSlRGCoiEorLSRiix50h2R/66aBzME=
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 v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
@@ -178,8 +174,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/safedep/dry v0.0.0-20260524092302-4815730a17cf h1:1PorZhAZWANkKup1Ao4kS8s+L6a+dmR+G0waIQonjdY=
github.com/safedep/dry v0.0.0-20260524092302-4815730a17cf/go.mod h1:tKhOr0osgpefdBbxG8n4H5gYH1GLXidKS+hzNbRQ9LQ=
github.com/safedep/dry v0.0.0-20260620130340-2af76e505537 h1:IiUlF9LzpoTUdV9RqsstiFRkPdP8fFQmCfbAHoNii0k=
github.com/safedep/dry v0.0.0-20260620130340-2af76e505537/go.mod h1:OUa+lopsqWFoDCzy3/DXqzi8JDl3g2q82JeskLq/Tpk=
github.com/safedep/ptyx v0.2.1-0.20260529140457-d1f745842a6a h1:oJu4dgmz/weiU3CMhFKiXd5zwgvPwPsm20MzG/uAt0s=
github.com/safedep/ptyx v0.2.1-0.20260529140457-d1f745842a6a/go.mod h1:fyt+PACz6dtEoqsnE0BPPv/lHpuBG/8zkDqeIVcyRY4=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
+4
View File
@@ -1,6 +1,8 @@
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.2-20240508200655-46a4cf4ba109.1/go.mod h1:cz5A7G0AEk7z2kciJ1jK72u/8kRVlcfAhi2B9jYoMZw=
buf.build/gen/go/safedep/api/connectrpc/go v1.20.0-20260620084912-77c7bb923ddb.1/go.mod h1:7HRi2R20XR03np5l5oGKAa/RRFVeuFtyiE/loG1/P+4=
buf.build/go/protovalidate v1.2.0/go.mod h1:7rYiQEhqvAipoazpVNBBH2S2f8bjG4huMVy1V2Yofn4=
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
cloud.google.com/go v0.121.2/go.mod h1:nRFlrHq39MNVWu+zESP2PosMWA0ryJw8KUBZ2iZpxbw=
cloud.google.com/go/auth v0.16.1/go.mod h1:1howDHJ5IETh/LwYs3ZxvlkXF48aSqqJUM+5o02dNOI=
@@ -37,6 +39,7 @@ github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOL
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
@@ -123,6 +126,7 @@ github.com/goark/go-cvss v1.6.7/go.mod h1:qsmYCGTQnQqW/Lq1Z3lRCEarKD++nx7C+KgsG0
github.com/gohugoio/hashstructure v0.5.0/go.mod h1:Ser0TniXuu/eauYmrwM4o64EBvySxNzITEOLlm4igec=
github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/cel-go v0.28.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8=
github.com/google/go-containerregistry v0.19.1/go.mod h1:YCMFNQeeXeLF+dnhhWkqDItx/JSkH01j1Kis4PsjzFI=
github.com/google/go-cpy v0.0.0-20211218193943-a9c933c06932/go.mod h1:cC6EdPbj/17GFCPDK39NRarlMI+kt+O60S12cNB5J9Y=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
+28 -3
View File
@@ -8,8 +8,10 @@ import (
"path/filepath"
"time"
"github.com/safedep/dry/localdb"
"github.com/safedep/dry/log"
"github.com/safedep/pmg/analyzer"
"github.com/safedep/pmg/analyzer/malysiscache"
"github.com/safedep/pmg/config"
"github.com/safedep/pmg/guard"
"github.com/safedep/pmg/internal/audit"
@@ -135,8 +137,31 @@ func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagema
return fmt.Errorf("failed to create certificate manager: %w", err)
}
// Optional persistent analysis cache. Disposable: any failure degrades to
// running uncached, never blocks the install.
var malysisCache analyzer.MalysisCache
cacheCfg := cfg.Config.AnalysisCache.Malysis
if cacheCfg.Enabled && cacheCfg.TTL > 0 {
mgr := localdb.New(localdb.Config{
Dir: cfg.LocalDBDir(),
FileName: cfg.LocalDBFileName(),
})
defer func() {
if cerr := mgr.Close(); cerr != nil {
log.Warnf("failed to close localdb: %v", cerr)
}
}()
store, serr := mgr.Store(ctx, malysiscache.Descriptor())
if serr != nil {
log.Warnf("analysis cache unavailable, continuing without it: %v", serr)
} else {
malysisCache = malysiscache.New(store, cacheCfg)
}
}
// Create analyzer
malysisAnalyzer, err := f.createAnalyzer()
malysisAnalyzer, err := f.createAnalyzer(malysisCache)
if err != nil {
return fmt.Errorf("failed to create analyzer: %w", err)
}
@@ -381,9 +406,9 @@ func (f *proxyFlow) createCertificateManager(caCert *certmanager.Certificate) (c
}
// createAnalyzer creates the malysis query analyzer
func (f *proxyFlow) createAnalyzer() (analyzer.PackageVersionAnalyzer, error) {
func (f *proxyFlow) createAnalyzer(cache analyzer.MalysisCache) (analyzer.PackageVersionAnalyzer, error) {
log.Debugf("Creating malysis query analyzer")
return analyzer.NewMalysisAnalyzer(analyzer.MalysisQueryAnalyzerConfig{})
return analyzer.NewMalysisAnalyzer(analyzer.MalysisQueryAnalyzerConfig{Cache: cache})
}
// createAndStartProxyServer creates and starts the proxy server with the given interceptor