diff --git a/AGENTS.md b/AGENTS.md index a48fcbd..dadee3f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index ba1eaf1..972d281 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/analyzer/malysis_cache_analyzer.go b/analyzer/malysis_cache_analyzer.go new file mode 100644 index 0000000..d5d7cfc --- /dev/null +++ b/analyzer/malysis_cache_analyzer.go @@ -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 +} diff --git a/analyzer/malysis_cache_analyzer_test.go b/analyzer/malysis_cache_analyzer_test.go new file mode 100644 index 0000000..b35b97b --- /dev/null +++ b/analyzer/malysis_cache_analyzer_test.go @@ -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") +} diff --git a/analyzer/malysis_factory.go b/analyzer/malysis_factory.go index 2478b5b..4182480 100644 --- a/analyzer/malysis_factory.go +++ b/analyzer/malysis_factory.go @@ -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) } diff --git a/analyzer/malysis_query.go b/analyzer/malysis_query.go index 2f1cbaa..8fad298 100644 --- a/analyzer/malysis_query.go +++ b/analyzer/malysis_query.go @@ -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 diff --git a/analyzer/malysiscache/malysiscache.go b/analyzer/malysiscache/malysiscache.go new file mode 100644 index 0000000..139f59b --- /dev/null +++ b/analyzer/malysiscache/malysiscache.go @@ -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 +} diff --git a/analyzer/malysiscache/malysiscache_test.go b/analyzer/malysiscache/malysiscache_test.go new file mode 100644 index 0000000..31a77fa --- /dev/null +++ b/analyzer/malysiscache/malysiscache_test.go @@ -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") +} diff --git a/analyzer/malysiscache/record.go b/analyzer/malysiscache/record.go new file mode 100644 index 0000000..66d57ae --- /dev/null +++ b/analyzer/malysiscache/record.go @@ -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 +} diff --git a/analyzer/malysiscache/record_test.go b/analyzer/malysiscache/record_test.go new file mode 100644 index 0000000..8e1cf43 --- /dev/null +++ b/analyzer/malysiscache/record_test.go @@ -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) +} diff --git a/cmd/setup/cache.go b/cmd/setup/cache.go new file mode 100644 index 0000000..a198c66 --- /dev/null +++ b/cmd/setup/cache.go @@ -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 +} diff --git a/cmd/setup/cache_test.go b/cmd/setup/cache_test.go new file mode 100644 index 0000000..959d564 --- /dev/null +++ b/cmd/setup/cache_test.go @@ -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") +} diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index f57c47a..4e00d18 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -29,6 +29,7 @@ func NewSetupCommand() *cobra.Command { setupCmd.AddCommand(NewInfoCommand()) setupCmd.AddCommand(NewDoctorCommand()) setupCmd.AddCommand(NewCertCommand()) + setupCmd.AddCommand(NewCacheCommand()) return setupCmd } diff --git a/config/config.go b/config/config.go index 951e883..c2de25d 100644 --- a/config/config.go +++ b/config/config.go @@ -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. diff --git a/config/config.template.yml b/config/config.template.yml index 465842d..fcb5e9c 100644 --- a/config/config.template.yml +++ b/config/config.template.yml @@ -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 diff --git a/config/config_test.go b/config/config_test.go index a94f508..7982c43 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -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) diff --git a/docs/caching.md b/docs/caching.md new file mode 100644 index 0000000..b305992 --- /dev/null +++ b/docs/caching.md @@ -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 +`/localdb/pmg.db`, where `` 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 +``` diff --git a/go.mod b/go.mod index 7333b3e..fa912f2 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 501588a..0eb8dee 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/go.work.sum b/go.work.sum index 8ab9ef7..92b2b74 100644 --- a/go.work.sum +++ b/go.work.sum @@ -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= diff --git a/internal/flows/proxy_flow.go b/internal/flows/proxy_flow.go index 54a2bca..1ac6e18 100644 --- a/internal/flows/proxy_flow.go +++ b/internal/flows/proxy_flow.go @@ -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