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
189 changes: 189 additions & 0 deletions controllers/dependencyfirewall/cache_stats.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
// Copyright (C) 2026 l3montree GmbH
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.

package dependencyfirewall

import (
"errors"
"fmt"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"time"

"github.com/l3montree-dev/devguard/shared"
"github.com/labstack/echo/v4"
)

// CacheStats describes aggregate storage usage for a slice of the dependency
// proxy on-disk cache (one ecosystem, the OCI manifest/blob split, or the
// overall total).
type CacheStats struct {
SizeBytes int64 `json:"sizeBytes"`
Entries int `json:"entries"`
OldestEntry *time.Time `json:"oldestEntry,omitempty"`
NewestEntry *time.Time `json:"newestEntry,omitempty"`
}

// OCISubStats further splits the OCI cache into manifests and blobs.
type OCISubStats struct {
Manifests CacheStats `json:"manifests"`
Blobs CacheStats `json:"blobs"`
}

// CacheStatsResponse is the JSON returned by GetCacheStats.
type CacheStatsResponse struct {
CacheDir string `json:"cacheDir"`
TotalSize int64 `json:"totalSizeBytes"`
TotalEntries int `json:"totalEntries"`
OldestEntry *time.Time `json:"oldestEntry,omitempty"`
NewestEntry *time.Time `json:"newestEntry,omitempty"`
ByEcosystem map[string]CacheStats `json:"byEcosystem"`
OCIBreakdown OCISubStats `json:"ociBreakdown"`
}

// companionSuffixes are sidecar files written next to a cached payload. They
// count toward disk usage but not toward entry counts.
var companionSuffixes = []string{".sha256", ".releasetime", ".contenttype", ".digest"}

func isCompanionFile(name string) bool {
for _, s := range companionSuffixes {
if strings.HasSuffix(name, s) {
return true
}
}
return false
}

// GetCacheStats walks the on-disk dependency proxy cache and returns aggregate
// storage statistics, both overall and broken down per ecosystem. The OCI
// subtree is split further into manifests vs blobs.
func (d *DependencyProxyController) GetCacheStats(ctx shared.Context) error {
resp, err := d.CollectCacheStats()
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to read cache stats: %v", err))
}
return ctx.JSON(http.StatusOK, resp)
}

func (d *DependencyProxyController) CollectCacheStats() (CacheStatsResponse, error) {
resp := CacheStatsResponse{
CacheDir: d.cacheDir,
ByEcosystem: map[string]CacheStats{},
}

if _, err := os.Stat(d.cacheDir); err != nil {
if errors.Is(err, fs.ErrNotExist) {
return resp, nil
}
return resp, err
}

ecoStats := map[string]*CacheStats{}
total := &CacheStats{}
ociMan := &CacheStats{}
ociBlob := &CacheStats{}

walkErr := filepath.Walk(d.cacheDir, func(path string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}

rel, relErr := filepath.Rel(d.cacheDir, path)
if relErr != nil {
return nil
}
relSlash := filepath.ToSlash(rel)
parts := strings.SplitN(relSlash, "/", 2)
if len(parts) < 2 {
// File directly under cache root — not produced by the proxy code paths,
// but keep accounting honest by including its size in the total.
total.SizeBytes += info.Size()
updateTimes(total, info.ModTime())
return nil
}
eco := parts[0]
stats, ok := ecoStats[eco]
if !ok {
stats = &CacheStats{}
ecoStats[eco] = stats
}

size := info.Size()
mod := info.ModTime()
companion := isCompanionFile(info.Name())

stats.SizeBytes += size
updateTimes(stats, mod)
if !companion {
stats.Entries++
}

total.SizeBytes += size
updateTimes(total, mod)
if !companion {
total.Entries++
}

if eco == "oci" {
switch {
case strings.Contains(relSlash, "/manifests/"):
ociMan.SizeBytes += size
updateTimes(ociMan, mod)
if !companion {
ociMan.Entries++
}
case strings.Contains(relSlash, "/blobs/"):
ociBlob.SizeBytes += size
updateTimes(ociBlob, mod)
if !companion {
ociBlob.Entries++
}
}
}

return nil
})
if walkErr != nil {
return resp, walkErr
}

for eco, s := range ecoStats {
resp.ByEcosystem[eco] = *s
}
resp.TotalSize = total.SizeBytes
resp.TotalEntries = total.Entries
resp.OldestEntry = total.OldestEntry
resp.NewestEntry = total.NewestEntry
resp.OCIBreakdown = OCISubStats{Manifests: *ociMan, Blobs: *ociBlob}

return resp, nil
}

func updateTimes(s *CacheStats, mod time.Time) {
if s.OldestEntry == nil || mod.Before(*s.OldestEntry) {
t := mod
s.OldestEntry = &t
}
if s.NewestEntry == nil || mod.After(*s.NewestEntry) {
t := mod
s.NewestEntry = &t
}
}
1 change: 1 addition & 0 deletions router/org_router.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ func NewOrgRouter(

organizationRouter.GET("/config-files/:config-file/", orgController.GetConfigFile)
organizationRouter.GET("/dependency-proxy-urls/", dependencyProxyController.GetDependencyProxyURLs)
organizationRouter.GET("/dependency-proxy/cache-stats/", dependencyProxyController.GetCacheStats, middlewares.NeededScope([]string{"manage"}), middlewares.OrganizationAccessControlMiddleware(shared.ObjectOrganization, shared.ActionUpdate)) // admin-only: storage consumption + per-ecosystem breakdown of the shared dep/oci proxy cache
organizationRouter.PUT("/config-files/:config-file/", orgController.UpdateConfigFile, middlewares.NeededScope([]string{"manage"}), middlewares.OrganizationAccessControlMiddleware(shared.ObjectOrganization, shared.ActionUpdate))
organizationRouter.GET("/trigger-sync/", externalEntityProviderService.TriggerSync)
organizationRouter.GET("/settings/", orgController.AdminSettings, middlewares.NeededScope([]string{"manage"}), middlewares.OrganizationAccessControlMiddleware(shared.ObjectOrganization, shared.ActionUpdate))
Expand Down
96 changes: 96 additions & 0 deletions tests/dependency_proxy_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,102 @@ func TestDependencyProxyControllerMaliciousPackageRemoval(t *testing.T) {
})
}

func TestDependencyProxyControllerCacheStats(t *testing.T) {
tempDir := t.TempDir()

config := dependencyfirewall.DependencyProxyCache{
CacheDir: tempDir,
}

checker, err := vulndb.NewMaliciousPackageChecker(nil)
require.NoError(t, err)

controller := dependencyfirewall.NewDependencyProxyController(nil, config, checker, nil, nil, nil)

writeFile := func(rel string, size int) {
path := filepath.Join(tempDir, filepath.FromSlash(rel))
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0755))
buf := make([]byte, size)
require.NoError(t, os.WriteFile(path, buf, 0644))
}

// Layout mirrors what the proxy actually writes:
// <cacheDir>/<eco>/<package-path>[.companion]
writeFile("npm/lodash/-/lodash-4.17.21.tgz", 1000)
writeFile("npm/lodash/-/lodash-4.17.21.tgz.sha256", 64)
writeFile("npm/lodash/-/lodash-4.17.21.tgz.releasetime", 30)
writeFile("go/cloud.google.com/go/@v/v0.110.0.zip", 2000)
writeFile("pypi/packages/abc/requests-2.31.0.tar.gz", 500)
writeFile("oci/docker.io/library/nginx/manifests/latest", 800)
writeFile("oci/docker.io/library/nginx/manifests/latest.contenttype", 40)
writeFile("oci/docker.io/library/nginx/manifests/latest.digest", 71)
writeFile("oci/docker.io/library/nginx/blobs/sha256_aaa", 4000)
writeFile("oci/docker.io/library/nginx/blobs/sha256_aaa.sha256", 64)

stats, err := controller.CollectCacheStats()
require.NoError(t, err)

t.Run("cache dir is reported", func(t *testing.T) {
assert.Equal(t, tempDir, stats.CacheDir)
})

t.Run("totals sum every file but count only payloads", func(t *testing.T) {
// 1000+64+30 + 2000 + 500 + 800+40+71 + 4000+64 = 8569
assert.Equal(t, int64(8569), stats.TotalSize)
// payloads: lodash tgz, go zip, requests tar, oci manifest, oci blob = 5
assert.Equal(t, 5, stats.TotalEntries)
})

t.Run("per-ecosystem breakdown", func(t *testing.T) {
require.Contains(t, stats.ByEcosystem, "npm")
require.Contains(t, stats.ByEcosystem, "go")
require.Contains(t, stats.ByEcosystem, "pypi")
require.Contains(t, stats.ByEcosystem, "oci")

assert.Equal(t, int64(1094), stats.ByEcosystem["npm"].SizeBytes)
assert.Equal(t, 1, stats.ByEcosystem["npm"].Entries)

assert.Equal(t, int64(2000), stats.ByEcosystem["go"].SizeBytes)
assert.Equal(t, 1, stats.ByEcosystem["go"].Entries)

assert.Equal(t, int64(500), stats.ByEcosystem["pypi"].SizeBytes)
assert.Equal(t, 1, stats.ByEcosystem["pypi"].Entries)

assert.Equal(t, int64(4975), stats.ByEcosystem["oci"].SizeBytes)
assert.Equal(t, 2, stats.ByEcosystem["oci"].Entries)
})

t.Run("oci breakdown splits manifests vs blobs", func(t *testing.T) {
assert.Equal(t, int64(911), stats.OCIBreakdown.Manifests.SizeBytes)
assert.Equal(t, 1, stats.OCIBreakdown.Manifests.Entries)
assert.Equal(t, int64(4064), stats.OCIBreakdown.Blobs.SizeBytes)
assert.Equal(t, 1, stats.OCIBreakdown.Blobs.Entries)
})

t.Run("oldest and newest entry timestamps are set", func(t *testing.T) {
require.NotNil(t, stats.OldestEntry)
require.NotNil(t, stats.NewestEntry)
assert.False(t, stats.OldestEntry.After(*stats.NewestEntry))
})
}

func TestDependencyProxyControllerCacheStatsMissingDir(t *testing.T) {
missing := filepath.Join(t.TempDir(), "does-not-exist")
config := dependencyfirewall.DependencyProxyCache{CacheDir: missing}

checker, err := vulndb.NewMaliciousPackageChecker(nil)
require.NoError(t, err)

controller := dependencyfirewall.NewDependencyProxyController(nil, config, checker, nil, nil, nil)

stats, err := controller.CollectCacheStats()
require.NoError(t, err)
assert.Equal(t, missing, stats.CacheDir)
assert.Zero(t, stats.TotalSize)
assert.Zero(t, stats.TotalEntries)
assert.Empty(t, stats.ByEcosystem)
}

func TestDependencyProxyControllerExtractNPMVersion(t *testing.T) {
tempDir := t.TempDir()

Expand Down