mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -29,6 +29,7 @@ func NewSetupCommand() *cobra.Command {
|
||||
setupCmd.AddCommand(NewInfoCommand())
|
||||
setupCmd.AddCommand(NewDoctorCommand())
|
||||
setupCmd.AddCommand(NewCertCommand())
|
||||
setupCmd.AddCommand(NewCacheCommand())
|
||||
|
||||
return setupCmd
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user