Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions controllers/dependencyfirewall/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,12 @@ import (
"net/http"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"time"

"github.com/google/uuid"
)

// cacheValue is the payload stored under a cache key. releaseTime,
Expand All @@ -48,9 +51,21 @@ type cacheEntry struct {
storedAt time.Time
}

type CacheStats struct {
SizeBytes int64 `json:"sizeBytes"`
MaxBytes int64 `json:"maxBytes"`
Entries int `json:"entries"`
Hits int `json:"hits"`
Misses int `json:"misses"`
Evictions int `json:"evictions"`
}

type cache struct {
mu sync.Mutex
currentSize int
hits int
misses int
evictions int
// maxSize is the maximum size of the cache in bytes.
maxSize int
basePath string
Expand All @@ -68,9 +83,77 @@ func newCache(basePath string, sizeInMB int) *cache {
}
}

type orgCaches struct {
mu sync.Mutex
basePath string
sizeMB int
caches map[uuid.UUID]*cache
}

func newOrgCaches(baseDir string, sizeInMB int) *orgCaches {
entries, _ := os.ReadDir(baseDir)
ecosystemList := []string{"npm_", "pypi_", "go_", "maven_", "composer_", "deb_", "oci_"}
for _, entry := range entries {
if !entry.IsDir() {
if slices.ContainsFunc(ecosystemList, func(p string) bool { return strings.HasPrefix(entry.Name(), p) }) {
if err := os.Remove(filepath.Join(baseDir, entry.Name())); err != nil {
slog.Warn("failed to remove entry in cache", "error", err)
}
}
}
}

return &orgCaches{
basePath: baseDir,
sizeMB: sizeInMB,
caches: make(map[uuid.UUID]*cache),
}
}

func (o *orgCaches) forOrg(orgID uuid.UUID) (*cache, error) {
if orgID == uuid.Nil {
return nil, fmt.Errorf("no orgID found")
}
o.mu.Lock()
defer o.mu.Unlock()
e, exists := o.caches[orgID]
if exists {
return e, nil
}
path := filepath.Join(o.basePath, orgID.String())
if err := os.RemoveAll(path); err != nil {
return nil, err
}
if err := os.MkdirAll(path, 0755); err != nil {
return nil, err
}
c := newCache(path, o.sizeMB)
o.caches[orgID] = c
return c, nil
}

func (o *orgCaches) Stats() CacheStats {
o.mu.Lock()
defer o.mu.Unlock()
totalStats := CacheStats{}
for _, c := range o.caches {
s := c.Stats()
totalStats.SizeBytes += s.SizeBytes
totalStats.MaxBytes += s.MaxBytes
totalStats.Entries += s.Entries
totalStats.Hits += s.Hits
totalStats.Misses += s.Misses
totalStats.Evictions += s.Evictions
}
return totalStats
}

func (c *cache) Get(key string) (cacheValue, bool) {
c.mu.Lock()
e, exists := c.cache[key]
if !exists {
c.misses++
}
c.mu.Unlock()
if !exists {
return cacheValue{}, false
Expand All @@ -92,6 +175,7 @@ func (c *cache) Get(key string) (cacheValue, bool) {
c.mu.Lock()
// Update the LRU map to mark this entry as recently used
c.lru[key] = time.Now()
c.hits++
c.mu.Unlock()
return cacheValue{
data: content,
Expand Down Expand Up @@ -180,6 +264,7 @@ func (c *cache) Set(key string, v cacheValue) error {
if oldestKey == "" {
break // No more entries to evict
}
c.evictions++
c.removeLocked(oldestKey)
}

Expand Down Expand Up @@ -239,3 +324,16 @@ func bypassCache(r *http.Request) bool {
}
return strings.EqualFold(strings.TrimSpace(r.Header.Get("Pragma")), "no-cache")
}

func (c *cache) Stats() CacheStats {
c.mu.Lock()
defer c.mu.Unlock()
return CacheStats{
SizeBytes: int64(c.currentSize),
MaxBytes: int64(c.maxSize),
Entries: len(c.cache),
Hits: c.hits,
Misses: c.misses,
Evictions: c.evictions,
}
}
60 changes: 60 additions & 0 deletions controllers/dependencyfirewall/cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,68 @@ import (
"net/http/httptest"
"os"
"testing"

"github.com/google/uuid"
)

func TestOrgCachesReturnsSameCacheForSameOrg(t *testing.T) {
o := newOrgCaches(t.TempDir(), 10)
orgID := uuid.New()

first, err := o.forOrg(orgID)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
second, err := o.forOrg(orgID)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

if first != second {
t.Fatal("expected the same cache instance for the same org")
}
}

func TestOrgCachesReturnsDifferentCacheForDifferentOrgs(t *testing.T) {
o := newOrgCaches(t.TempDir(), 10)
orgID := uuid.New()
orgID2 := uuid.New()

first, err := o.forOrg(orgID)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
second, err := o.forOrg(orgID2)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

if first == second {
t.Fatal("expected different cache instance for the two orgs")
}
}

func TestOrgCachesIsolatesEntriesBetweenOrgs(t *testing.T) {
o := newOrgCaches(t.TempDir(), 10)

orgA, err := o.forOrg(uuid.New())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
orgB, err := o.forOrg(uuid.New())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

if err := orgA.Set("pkg", cacheValue{data: []byte("org a content")}); err != nil {
t.Fatalf("unexpected error: %v", err)
}

if _, ok := orgB.Get("pkg"); ok {
t.Fatal("expected cache miss for other org")
}
}

func TestCacheSetAndGet(t *testing.T) {
c := newCache(t.TempDir(), 10)
data := []byte("test package content")
Expand Down
15 changes: 10 additions & 5 deletions controllers/dependencyfirewall/composer.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,11 @@ func (d *ComposerDependencyProxyController) proxyComposerDist(c shared.Context)
return echo.NewHTTPError(http.StatusInternalServerError, "failed to load dependency proxy configuration")
}

orgCache, err := d.caches.forOrg(configs.OrgID)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to load cache").WithInternal(err)
}

requestPath := composer.trimPrefix(c.Request().URL.Path)

ctx, span := depProxyTracer.Start(c.Request().Context(), "dependency-proxy.composer",
Expand All @@ -213,7 +218,7 @@ func (d *ComposerDependencyProxyController) proxyComposerDist(c shared.Context)
c.SetRequest(c.Request().WithContext(ctx))

cacheKey := "composer/" + requestPath
if err := d.cache.ValidateKey(cacheKey); err != nil {
if err := orgCache.ValidateKey(cacheKey); err != nil {
slog.Warn("Invalid cache path", "proxy", "composer", "path", requestPath, "error", err)
return echo.NewHTTPError(http.StatusBadRequest, "invalid package path")
}
Expand All @@ -232,12 +237,12 @@ func (d *ComposerDependencyProxyController) proxyComposerDist(c shared.Context)
}
if status != 0 {
slog.Warn("Blocked malicious package", "proxy", "composer", "path", requestPath, "status", status, "reason", reason)
d.cache.Remove(cacheKey)
orgCache.Remove(cacheKey)
return d.blockMaliciousPackage(c, composer, requestPath, reason, status)
}

if !bypassCache(c.Request()) && d.cache.Fresh(cacheKey, composerCacheTTL(requestPath)) {
if entry, ok := d.cache.Get(cacheKey); ok {
if !bypassCache(c.Request()) && orgCache.Fresh(cacheKey, composerCacheTTL(requestPath)) {
if entry, ok := orgCache.Get(cacheKey); ok {
slog.Debug("Cache hit", "proxy", "composer", "path", requestPath)
if configs.MinReleaseAge > 0 && time.Since(entry.releaseTime) < time.Duration(configs.MinReleaseAge)*time.Hour {
return d.blockTooNewPackage(c, composer, requestPath, entry.releaseTime, configs.MinReleaseAge)
Expand Down Expand Up @@ -287,7 +292,7 @@ func (d *ComposerDependencyProxyController) proxyComposerDist(c shared.Context)
}

if !releaseTime.IsZero() {
if err := d.cache.Set(cacheKey, cacheValue{data: data, releaseTime: releaseTime, contentType: headers.Get("Content-Type")}); err != nil {
if err := orgCache.Set(cacheKey, cacheValue{data: data, releaseTime: releaseTime, contentType: headers.Get("Content-Type")}); err != nil {
slog.Warn("Failed to cache response", "proxy", "composer", "error", err)
}
}
Expand Down
46 changes: 40 additions & 6 deletions controllers/dependencyfirewall/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,15 @@ func trimWithRegex(path string, re *regexp.Regexp) string {

type DependencyProxyCache struct {
CacheDir string
// MaxSizeMB bounds the size of the in-memory-tracked, disk-backed package
// cache. Defaults to 1024 (1GB) if unset.
// MaxSizeMB bounds the size of each organization's in-memory-tracked,
// disk-backed package cache. Defaults to 1024 (1GB) per organization if unset.
MaxSizeMB int
}

type DependencyProxyConfigs struct {
Rules []string `json:"rules"`
MinReleaseAge int `json:"minReleaseAge"` // in hours
Rules []string `json:"rules"`
MinReleaseAge int `json:"minReleaseAge"` // in hours
OrgID uuid.UUID `json:"-"`
}

type DependencyProxyController struct {
Expand All @@ -69,7 +70,7 @@ type DependencyProxyController struct {
orgRepository shared.OrganizationRepository
dependencyProxyService shared.DependencyProxySecretService
maliciousChecker shared.MaliciousPackageChecker
cache *cache
caches *orgCaches
client *http.Client
}

Expand All @@ -88,7 +89,7 @@ func NewDependencyProxyController(
return &DependencyProxyController{
dependencyProxyService: dependencyProxyService,
maliciousChecker: maliciousChecker,
cache: newCache(config.CacheDir, config.MaxSizeMB),
caches: newOrgCaches(config.CacheDir, config.MaxSizeMB),
assetRepository: assetRepository,
projectRepository: projectRepository,
orgRepository: orgRepository,
Expand Down Expand Up @@ -166,6 +167,32 @@ func (d *DependencyProxyController) GetOrgDependencyProxyURLs(ctx shared.Context
return d.GetDependencyProxyURLs(ctx)
}

// @Summary Get organization dependency proxy cache statistics
// @Tags Dependency Firewall
// @Security CookieAuth
// @Security PATAuth
// @Security BearerAuth
// @Param organization path string true "organization slug"
// @Success 200 {object} dependencyfirewall.CacheStats
// @Router /organizations/{organization}/dependency-proxy/cache-stats/ [get]
func (d *DependencyProxyController) GetOrgCacheStats(ctx shared.Context) error {
org := shared.GetOrg(ctx)
e, err := d.caches.forOrg(org.ID)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get cache stats for org").WithInternal(err)
}
return ctx.JSON(http.StatusOK, e.Stats())
}

// @Summary Get instance-wide dependency proxy cache statistics
// @Tags Admin
// @Security AdminSignedAuth
// @Success 200 {object} dependencyfirewall.CacheStats
// @Router /admin/statistics/dependency-proxy-cache/ [get]
func (d *DependencyProxyController) GetInstanceCacheStats(ctx shared.Context) error {
return ctx.JSON(http.StatusOK, d.caches.Stats())
}

// @Summary Get project dependency proxy URLs
// @Tags Dependency Firewall
// @Security CookieAuth
Expand Down Expand Up @@ -271,18 +298,25 @@ func (d *DependencyProxyController) LoadConfigsBySecret(c shared.Context, secret
return configs, fmt.Errorf("failed to read asset: %w", err)
}
configFilesJSON = asset.ConfigFiles["dependency-proxy-configs"]
project, err := d.projectRepository.Read(c.Request().Context(), nil, asset.ProjectID) // nosemgrep: bola-controller-read-without-tenant-check -- uuid comes from a secret-authenticated proxy token lookup (not a user-controlled path param); the secret already scopes the request to this tenant
if err != nil {
return configs, fmt.Errorf("failed to read project: %w", err)
}
configs.OrgID = project.OrganizationID
case "project":
project, err := d.projectRepository.Read(c.Request().Context(), nil, uuid) // nosemgrep: bola-controller-read-without-tenant-check -- uuid comes from a secret-authenticated proxy token lookup (not a user-controlled path param); the secret already scopes the request to this tenant
if err != nil {
return configs, fmt.Errorf("failed to read project: %w", err)
}
configFilesJSON = project.ConfigFiles["dependency-proxy-configs"]
configs.OrgID = project.OrganizationID
case "organization":
org, err := d.orgRepository.Read(c.Request().Context(), nil, uuid) // nosemgrep: bola-controller-read-without-tenant-check -- uuid comes from a secret-authenticated proxy token lookup (not a user-controlled path param); the secret already scopes the request to this tenant
if err != nil {
return configs, fmt.Errorf("failed to read organization: %w", err)
}
configFilesJSON = org.ConfigFiles["dependency-proxy-configs"]
configs.OrgID = org.ID
default:
return configs, fmt.Errorf("invalid proxy scope: %s", scope)
}
Expand Down
Loading