diff --git a/.gitignore b/.gitignore index c77c95d7..59333d8b 100644 --- a/.gitignore +++ b/.gitignore @@ -27,4 +27,5 @@ csaf-openpgp-public-key.asc !**/testfiles/** vulndb-tmp-* cert.pem -key.pem \ No newline at end of file +key.pem +cache \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 69a61f12..f41c273e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,7 +11,17 @@ "ghinstallation", "gorm", "montree", + "pypi", "sarif", "Vulns" + ], + "files.watcherExclude": { + "**/malicious-packages/**": true + }, + "search.exclude": { + "**/malicious-packages/**": true + }, + "git.ignoredRepositories": [ + "malicious-packages" ] } \ No newline at end of file diff --git a/cmd/devguard/main.go b/cmd/devguard/main.go index f24dae74..0d0386a0 100644 --- a/cmd/devguard/main.go +++ b/cmd/devguard/main.go @@ -129,6 +129,7 @@ func main() { fx.Invoke(func(LicenseRiskRouter router.LicenseRiskRouter) {}), fx.Invoke(func(ShareRouter router.ShareRouter) {}), fx.Invoke(func(VulnDBRouter router.VulnDBRouter) {}), + fx.Invoke(func(DependencyProxyRouter router.DependencyProxyRouter) {}), fx.Invoke(func(server *echo.Echo) {}), ).Run() } diff --git a/controllers/component_controller.go b/controllers/component_controller.go index 97bbca0f..34751ee3 100644 --- a/controllers/component_controller.go +++ b/controllers/component_controller.go @@ -128,23 +128,23 @@ func (ComponentController ComponentController) ListPaged(ctx shared.Context) err return ctx.JSON(200, shared.NewPaged(pageInfo, components.Total, componentsDTO)) } -func (controller ComponentController) SearchComponentOccurrences(ctx shared.Context) error { +func (ComponentController ComponentController) SearchComponentOccurrences(ctx shared.Context) error { project := shared.GetProject(ctx) // get all child projects as well - projects, err := controller.projectRepository.RecursivelyGetChildProjects(project.ID) + projects, err := ComponentController.projectRepository.RecursivelyGetChildProjects(project.ID) if err != nil { return echo.NewHTTPError(500, "could not fetch child projects").WithInternal(err) } - var projectIDs []uuid.UUID = []uuid.UUID{ + var projectIDs = []uuid.UUID{ project.ID, } for _, p := range projects { projectIDs = append(projectIDs, p.ID) } - pagedResp, err := controller.componentRepository.SearchComponentOccurrencesByProject( + pagedResp, err := ComponentController.componentRepository.SearchComponentOccurrencesByProject( nil, projectIDs, shared.GetPageInfo(ctx), diff --git a/controllers/dependency_proxy_controller.go b/controllers/dependency_proxy_controller.go new file mode 100644 index 00000000..aa4513d1 --- /dev/null +++ b/controllers/dependency_proxy_controller.go @@ -0,0 +1,345 @@ +// Copyright 2025 l3montree GmbH. +// SPDX-License-Identifier: AGPL-3.0-or-later + +package controllers + +import ( + "fmt" + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/l3montree-dev/devguard/shared" + "github.com/l3montree-dev/devguard/vulndb" + "github.com/labstack/echo/v4" +) + +const ( + npmRegistry = "https://registry.npmjs.org" + goProxyURL = "https://proxy.golang.org" + dockerRegistry = "https://registry-1.docker.io" +) + +type ProxyType string + +const ( + NPMProxy ProxyType = "npm" + GoProxy ProxyType = "go" + OCIProxy ProxyType = "oci" +) + +type DependencyProxyConfig struct { + CacheDir string +} + +type DependencyProxyController struct { + maliciousChecker *vulndb.MaliciousPackageChecker + cacheDir string + client *http.Client +} + +func NewDependencyProxyController( + config DependencyProxyConfig, + maliciousChecker *vulndb.MaliciousPackageChecker, +) *DependencyProxyController { + return &DependencyProxyController{ + maliciousChecker: maliciousChecker, + cacheDir: config.CacheDir, + client: &http.Client{ + Timeout: 60 * time.Second, + }, + } +} + +func (d *DependencyProxyController) ProxyNPM(c shared.Context) error { + return d.handleProxy(c, NPMProxy, npmRegistry, "/api/v1/dependency-proxy/npm") +} + +func (d *DependencyProxyController) ProxyGo(c shared.Context) error { + return d.handleProxy(c, GoProxy, goProxyURL, "/api/v1/dependency-proxy/go") +} + +func (d *DependencyProxyController) ProxyOCI(c shared.Context) error { + return d.handleProxy(c, OCIProxy, dockerRegistry, "/api/v1/dependency-proxy/oci") +} + +func (d *DependencyProxyController) handleProxy(c shared.Context, proxyType ProxyType, upstreamURL, prefix string) error { + if c.Request().Method != http.MethodGet && c.Request().Method != http.MethodHead { + return echo.NewHTTPError(http.StatusMethodNotAllowed, "Method not allowed") + } + + // Get the full path after the prefix + requestPath := c.Request().URL.Path + if prefix != "" { + requestPath = strings.TrimPrefix(requestPath, prefix) + } + + slog.Info("Proxy request", "proxy", proxyType, "method", c.Request().Method, "path", requestPath) + + // Check for malicious packages + if d.maliciousChecker != nil { + if blocked, reason := d.checkMaliciousPackage(proxyType, requestPath); blocked { + slog.Warn("Blocked malicious package", "proxy", proxyType, "path", requestPath, "reason", reason) + return d.blockMaliciousPackage(c, requestPath, reason) + } + } + + cachePath := d.getCachePath(proxyType, requestPath) + + // Check cache + if d.isCached(proxyType, cachePath) { + slog.Debug("Cache hit", "proxy", proxyType, "path", requestPath) + data, err := os.ReadFile(cachePath) + if err == nil { + return d.writeResponse(c, data, proxyType, requestPath, true) + } + slog.Warn("Cache read error", "proxy", proxyType, "error", err) + } + + // Fetch from upstream + data, headers, statusCode, err := d.fetchFromUpstream(proxyType, upstreamURL, requestPath, c.Request().Header) + if err != nil { + slog.Error("Error fetching from upstream", "proxy", proxyType, "error", err) + return echo.NewHTTPError(http.StatusBadGateway, "Failed to fetch from upstream") + } + + if statusCode != http.StatusOK { + slog.Debug("Upstream returned non-OK status", "proxy", proxyType, "status", statusCode) + // Forward important headers + for key, values := range headers { + for _, value := range values { + c.Response().Header().Add(key, value) + } + } + return c.Blob(statusCode, headers.Get("Content-Type"), data) + } + + // Cache successful responses + if err := d.cacheData(cachePath, data); err != nil { + slog.Warn("Failed to cache response", "proxy", proxyType, "error", err) + } + + // Copy important headers from upstream + if contentType := headers.Get("Content-Type"); contentType != "" { + c.Response().Header().Set("Content-Type", contentType) + } + if dockerContentDigest := headers.Get("Docker-Content-Digest"); dockerContentDigest != "" { + c.Response().Header().Set("Docker-Content-Digest", dockerContentDigest) + } + + return d.writeResponse(c, data, proxyType, requestPath, false) +} + +func (d *DependencyProxyController) getCachePath(proxyType ProxyType, requestPath string) string { + cleanPath := strings.TrimPrefix(requestPath, "/") + subDir := string(proxyType) + return filepath.Join(d.cacheDir, subDir, cleanPath) +} + +func (d *DependencyProxyController) isCached(proxyType ProxyType, cachePath string) bool { + info, err := os.Stat(cachePath) + if err != nil { + return false + } + + var maxAge time.Duration + switch proxyType { + case NPMProxy: + if strings.HasSuffix(cachePath, ".tgz") { + maxAge = 24 * time.Hour + } else { + maxAge = 1 * time.Hour + } + case GoProxy: + if strings.Contains(cachePath, "/@v/") { + maxAge = 168 * time.Hour // 7 days + } else { + maxAge = 1 * time.Hour + } + case OCIProxy: + if strings.Contains(cachePath, "/blobs/") { + maxAge = 168 * time.Hour // 7 days + } else { + maxAge = 1 * time.Hour + } + default: + maxAge = 1 * time.Hour + } + + return time.Since(info.ModTime()) < maxAge +} + +func (d *DependencyProxyController) fetchFromUpstream(proxyType ProxyType, upstreamURL, requestPath string, headers http.Header) ([]byte, http.Header, int, error) { + url := upstreamURL + requestPath + slog.Debug("Fetching from upstream", "proxy", proxyType, "url", url) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, nil, 0, fmt.Errorf("failed to create request: %w", err) + } + + // Forward important headers for OCI registry auth + if proxyType == OCIProxy { + if auth := headers.Get("Authorization"); auth != "" { + req.Header.Set("Authorization", auth) + } + if accept := headers.Get("Accept"); accept != "" { + req.Header.Set("Accept", accept) + } else { + req.Header.Set("Accept", "application/vnd.docker.distribution.manifest.v2+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json") + } + } + + resp, err := d.client.Do(req) + if err != nil { + return nil, nil, 0, fmt.Errorf("failed to fetch: %w", err) + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, resp.StatusCode, fmt.Errorf("failed to read response: %w", err) + } + + return data, resp.Header, resp.StatusCode, nil +} + +func (d *DependencyProxyController) cacheData(cachePath string, data []byte) error { + dir := filepath.Dir(cachePath) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + return os.WriteFile(cachePath, data, 0644) +} + +func (d *DependencyProxyController) writeResponse(c shared.Context, data []byte, proxyType ProxyType, path string, cached bool) error { + if c.Response().Header().Get("Content-Type") == "" { + contentType := d.getContentType(proxyType, path) + c.Response().Header().Set("Content-Type", contentType) + } + + if cached { + c.Response().Header().Set("X-Cache", "HIT") + } else { + c.Response().Header().Set("X-Cache", "MISS") + } + + c.Response().Header().Set("X-Proxy-Type", string(proxyType)) + return c.Blob(http.StatusOK, c.Response().Header().Get("Content-Type"), data) +} + +func (d *DependencyProxyController) getContentType(proxyType ProxyType, path string) string { + switch proxyType { + case NPMProxy: + if strings.HasSuffix(path, ".tgz") { + return "application/octet-stream" + } + return "application/json" + case GoProxy: + if strings.HasSuffix(path, ".info") || strings.HasSuffix(path, ".mod") { + return "text/plain; charset=utf-8" + } else if strings.HasSuffix(path, ".zip") { + return "application/zip" + } + return "text/plain; charset=utf-8" + case OCIProxy: + if strings.Contains(path, "/manifests/") { + return "application/vnd.docker.distribution.manifest.v2+json" + } else if strings.Contains(path, "/blobs/") { + return "application/octet-stream" + } + return "application/json" + } + return "application/octet-stream" +} + +func (d *DependencyProxyController) parsePackageFromPath(proxyType ProxyType, path string) (string, string) { + switch proxyType { + case NPMProxy: + if strings.HasSuffix(path, ".tgz") { + parts := strings.Split(path, "/-/") + if len(parts) == 2 { + pkgName := strings.TrimPrefix(parts[0], "/") + filename := strings.TrimSuffix(parts[1], ".tgz") + version := strings.TrimPrefix(filename, strings.ReplaceAll(pkgName, "/", "-")+"-") + return pkgName, version + } + } + pkgName := strings.TrimPrefix(path, "/") + return pkgName, "" + + case GoProxy: + re := regexp.MustCompile(`^/([^@]+)(?:@v/([^/]+))?`) + matches := re.FindStringSubmatch(path) + if len(matches) > 1 { + moduleName := matches[1] + version := "" + if len(matches) > 2 && matches[2] != "" { + if matches[2] == "list" { + return moduleName, "" + } + version = strings.TrimSuffix(strings.TrimSuffix(matches[2], ".info"), ".mod") + version = strings.TrimSuffix(version, ".zip") + } + return moduleName, version + } + + case OCIProxy: + re := regexp.MustCompile(`^/v2/([^/]+(?:/[^/]+)*)/(?:manifests|blobs)/(.+)$`) + matches := re.FindStringSubmatch(path) + if len(matches) > 2 { + return matches[1], matches[2] + } + } + + return "", "" +} + +func (d *DependencyProxyController) checkMaliciousPackage(proxyType ProxyType, path string) (bool, string) { + packageName, version := d.parsePackageFromPath(proxyType, path) + if packageName == "" { + return false, "" + } + + ecosystem := "" + switch proxyType { + case NPMProxy: + ecosystem = "npm" + case GoProxy: + ecosystem = "go" + case OCIProxy: + return false, "" + } + + slog.Debug("Checking package against malicious database", "ecosystem", ecosystem, "package", packageName, "version", version) + + isMalicious, entry := d.maliciousChecker.IsMalicious(ecosystem, packageName, version) + if isMalicious { + reason := fmt.Sprintf("Package %s is flagged as malicious (ID: %s)", packageName, entry.ID) + if entry.Summary != "" { + reason += ": " + entry.Summary + } + return true, reason + } + + return false, "" +} + +func (d *DependencyProxyController) blockMaliciousPackage(c shared.Context, path, reason string) error { + c.Response().Header().Set("X-Malicious-Package", "blocked") + + response := map[string]interface{}{ + "error": "Forbidden", + "message": "This package has been blocked by the malicious package firewall", + "reason": reason, + "path": path, + "blocked": true, + } + + return c.JSON(http.StatusForbidden, response) +} diff --git a/controllers/providers.go b/controllers/providers.go index ffb5b2e1..172bac57 100644 --- a/controllers/providers.go +++ b/controllers/providers.go @@ -16,9 +16,44 @@ package controllers import ( + "log/slog" + "os" + "path/filepath" + + "github.com/l3montree-dev/devguard/vulndb" "go.uber.org/fx" ) +// ProvideDependencyProxyConfig creates the configuration for the dependency proxy +func ProvideDependencyProxyConfig() DependencyProxyConfig { + cacheDir := filepath.Join(os.TempDir(), "devguard-dependency-proxy-cache") + + // Ensure directory exists + if err := os.MkdirAll(cacheDir, 0755); err != nil { + slog.Error("Failed to create cache directory", "error", err) + } + + return DependencyProxyConfig{ + CacheDir: cacheDir, + } +} + +// ProvideMaliciousPackageChecker creates the malicious package checker +func ProvideMaliciousPackageChecker() *vulndb.MaliciousPackageChecker { + dbPath := filepath.Join(os.TempDir(), "devguard-dependency-proxy-db", "osv", "malicious") + + checker, err := vulndb.NewMaliciousPackageChecker(vulndb.MaliciousPackageCheckerConfig{ + DBPath: dbPath, + }) + if err != nil { + slog.Warn("Could not initialize malicious package checker", "error", err) + return nil + } + + slog.Info("Malicious package firewall enabled") + return checker +} + // ControllerModule provides all HTTP controller constructors var ControllerModule = fx.Options( // Asset Management @@ -56,4 +91,9 @@ var ControllerModule = fx.Options( // Authentication & Access fx.Provide(NewPatController), fx.Provide(NewScanController), + + // Dependency Proxy + fx.Provide(ProvideDependencyProxyConfig), + fx.Provide(ProvideMaliciousPackageChecker), + fx.Provide(NewDependencyProxyController), ) diff --git a/go.mod b/go.mod index ccd009be..d960af1d 100644 --- a/go.mod +++ b/go.mod @@ -36,6 +36,7 @@ require ( github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.23.2 github.com/schollz/progressbar/v3 v3.18.0 + github.com/secure-systems-lab/go-securesystemslib v0.9.1 github.com/sigstore/sigstore v1.9.5 github.com/spf13/cobra v1.10.1 github.com/spf13/pflag v1.0.10 @@ -275,7 +276,6 @@ require ( github.com/rqlite/gorqlite v0.0.0-20230708021416-2acd02b70b79 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect - github.com/secure-systems-lab/go-securesystemslib v0.9.1 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/sergi/go-diff v1.4.0 // indirect github.com/shibumi/go-pathspec v1.3.0 // indirect diff --git a/router/dependency_proxy_router.go b/router/dependency_proxy_router.go new file mode 100644 index 00000000..2489c69a --- /dev/null +++ b/router/dependency_proxy_router.go @@ -0,0 +1,34 @@ +// Copyright 2025 l3montree GmbH. +// SPDX-License-Identifier: AGPL-3.0-or-later + +package router + +import ( + "github.com/l3montree-dev/devguard/controllers" + "github.com/labstack/echo/v4" +) + +type DependencyProxyRouter struct { + *echo.Group +} + +func NewDependencyProxyRouter( + apiV1Group APIV1Router, + controller *controllers.DependencyProxyController, +) DependencyProxyRouter { + group := apiV1Group.Group.Group("/dependency-proxy") + + // NPM proxy - handles /dependency-proxy/npm/* + group.GET("/npm", controller.ProxyNPM) + group.GET("/npm/*", controller.ProxyNPM) + + // Go proxy - handles /dependency-proxy/go/* + group.GET("/go", controller.ProxyGo) + group.GET("/go/*", controller.ProxyGo) + + // OCI/Docker proxy - handles /dependency-proxy/oci/* + group.GET("/oci", controller.ProxyOCI) + group.GET("/oci/*", controller.ProxyOCI) + + return DependencyProxyRouter{Group: group} +} diff --git a/router/providers.go b/router/providers.go index 3bd30733..ca6d0b8a 100644 --- a/router/providers.go +++ b/router/providers.go @@ -15,4 +15,5 @@ var RouterModule = fx.Options( fx.Provide(NewSessionRouter), fx.Provide(NewShareRouter), fx.Provide(NewVulnDBRouter), + fx.Provide(NewDependencyProxyRouter), ) diff --git a/tests/malicious_packages_checker_test.go b/tests/malicious_packages_checker_test.go new file mode 100644 index 00000000..9a784b56 --- /dev/null +++ b/tests/malicious_packages_checker_test.go @@ -0,0 +1,135 @@ +// Copyright (C) 2025 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 . + +package tests + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/l3montree-dev/devguard/vulndb" +) + +// TestMaliciousPackageChecker tests the malicious package detection +func TestMaliciousPackageChecker(t *testing.T) { + // Create a temporary directory for test data + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "osv", "malicious") + maliciousDir := filepath.Join(dbPath, "npm", "test-malicious-package") + if err := os.MkdirAll(maliciousDir, 0755); err != nil { + t.Fatalf("Failed to create test directory: %v", err) + } + + // Create a test malicious package entry + testEntry := vulndb.MaliciousPackageEntry{ + ID: "MAL-TEST-001", + Summary: "Test malicious package", + Details: "This is a test entry for malicious package detection", + Affected: []vulndb.Affected{ + { + Package: vulndb.Package{ + Ecosystem: "npm", + Name: "test-malicious-package", + }, + Versions: []string{"1.0.0", "1.0.1"}, + }, + }, + Published: time.Now().Format(time.RFC3339), + } + + // Write the test entry to a JSON file + entryPath := filepath.Join(maliciousDir, "MAL-TEST-001.json") + data, err := json.MarshalIndent(testEntry, "", " ") + if err != nil { + t.Fatalf("Failed to marshal test entry: %v", err) + } + if err := os.WriteFile(entryPath, data, 0644); err != nil { + t.Fatalf("Failed to write test entry: %v", err) + } + + // Create the checker with SkipInitialUpdate to prevent downloading from GitHub + checker, err := vulndb.NewMaliciousPackageChecker(vulndb.MaliciousPackageCheckerConfig{ + DBPath: dbPath, + UpdateInterval: 24 * time.Hour, // Long interval for tests + SkipInitialUpdate: true, // Skip GitHub download, use test data + }) + if err != nil { + t.Fatalf("Failed to create malicious package checker: %v", err) + } + defer checker.Stop() + + tests := []struct { + name string + ecosystem string + pkgName string + version string + expected bool + }{ + { + name: "Malicious package with specific version", + ecosystem: "npm", + pkgName: "test-malicious-package", + version: "1.0.0", + expected: true, + }, + { + name: "Malicious package with another version", + ecosystem: "npm", + pkgName: "test-malicious-package", + version: "1.0.1", + expected: true, + }, + { + name: "Malicious package without version", + ecosystem: "npm", + pkgName: "test-malicious-package", + version: "", + expected: true, + }, + { + name: "Safe package", + ecosystem: "npm", + pkgName: "lodash", + version: "4.17.21", + expected: false, + }, + { + name: "Wrong ecosystem", + ecosystem: "go", + pkgName: "test-malicious-package", + version: "1.0.0", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isMalicious, entry := checker.IsMalicious(tt.ecosystem, tt.pkgName, tt.version) + if isMalicious != tt.expected { + t.Errorf("IsMalicious(%s, %s, %s) = %v, want %v", + tt.ecosystem, tt.pkgName, tt.version, isMalicious, tt.expected) + } + if isMalicious && entry == nil { + t.Error("Expected entry to be non-nil for malicious package") + } + if !isMalicious && entry != nil { + t.Error("Expected entry to be nil for safe package") + } + }) + } +} diff --git a/tests/test-go-project/README.md b/tests/test-go-project/README.md new file mode 100644 index 00000000..eb48c308 --- /dev/null +++ b/tests/test-go-project/README.md @@ -0,0 +1,64 @@ +# Test Go Project for Dependency Proxy + +This is a simple Go project to test the DevGuard dependency proxy for Go modules. + +## Setup + +1. Make sure DevGuard is running with the dependency proxy enabled: + ```bash + cd /Users/timbastin/Desktop/l3montree/devguard + go run cmd/devguard/main.go + ``` + +2. The dependency proxy should be available at: `http://localhost:8080/dependency-proxy/go` + +## Testing + +### Option 1: Using the test script + +```bash +chmod +x test-proxy.sh +./test-proxy.sh +``` + +### Option 2: Manual testing + +```bash +# Clear module cache +go clean -modcache + +# Set proxy +export GOPROXY=http://localhost:8080/dependency-proxy/go + +# Download dependencies +go mod download + +# Or build the project +go build +``` + +### Option 3: Test specific module + +```bash +# List available versions +curl http://localhost:8080/dependency-proxy/go/github.com/gin-gonic/gin/@v/list + +# Get module info +curl http://localhost:8080/dependency-proxy/go/github.com/gin-gonic/gin/@v/v1.9.1.info + +# Download module +curl http://localhost:8080/dependency-proxy/go/github.com/gin-gonic/gin/@v/v1.9.1.mod +``` + +## What to expect + +1. **Successful proxy**: Modules are downloaded through the proxy and cached +2. **Malicious package detection**: If a known malicious Go package is requested, it will be blocked with HTTP 403 +3. **Cache headers**: Response includes `X-Cache: HIT` or `X-Cache: MISS` and `X-Proxy-Type: go` + +## Checking logs + +Watch DevGuard logs for: +- `Proxy request` entries showing Go module requests +- `Cache hit` or fetching from upstream +- `Blocked malicious package` if any malicious packages are detected diff --git a/tests/test-go-project/go.mod b/tests/test-go-project/go.mod new file mode 100644 index 00000000..982a0914 --- /dev/null +++ b/tests/test-go-project/go.mod @@ -0,0 +1,10 @@ +module github.com/l3montree-dev/devguard/test-go-project + +go 1.21 + +require github.com/sirupsen/logrus v1.9.3 + +require ( + github.com/stretchr/testify v1.8.3 // indirect + golang.org/x/sys v0.8.0 // indirect +) diff --git a/tests/test-go-project/go.sum b/tests/test-go-project/go.sum new file mode 100644 index 00000000..0e5bb7fa --- /dev/null +++ b/tests/test-go-project/go.sum @@ -0,0 +1,18 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= +github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tests/test-go-project/main.go b/tests/test-go-project/main.go new file mode 100644 index 00000000..a9bf7e73 --- /dev/null +++ b/tests/test-go-project/main.go @@ -0,0 +1,9 @@ +package main + +import "github.com/sirupsen/logrus" + +func main() { + logrus.SetFormatter(&logrus.TextFormatter{ + FullTimestamp: true, + }) +} diff --git a/tests/test-go-project/test-proxy.sh b/tests/test-go-project/test-proxy.sh new file mode 100644 index 00000000..c07a98a8 --- /dev/null +++ b/tests/test-go-project/test-proxy.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +# Test script for Go module proxy +# This script tests the dependency proxy by setting GOPROXY and running go commands + +echo "Testing Go Module Proxy via DevGuard Dependency Proxy" +echo "=======================================================" +echo "" + +# Set the proxy URL +PROXY_URL="http://localhost:8080/dependency-proxy/go" + +echo "Using proxy: $PROXY_URL" +echo "" + +# Clear Go module cache to force fetching from proxy +echo "Cleaning Go module cache..." +go clean -modcache + +# Set GOPROXY environment variable +export GOPROXY=$PROXY_URL + +echo "" +echo "Attempting to download modules via proxy..." +echo "" + +# Try to download dependencies +go mod download + +echo "" +echo "=======================================================" +echo "Test complete!" +echo "" +echo "Check the DevGuard logs to see if packages were proxied." +echo "You should see log entries for requests to packages like:" +echo " - github.com/gin-gonic/gin" +echo " - github.com/sirupsen/logrus" +echo "" +echo "If malicious packages are detected, they will be blocked." diff --git a/tests/test-npm-project/.npmrc b/tests/test-npm-project/.npmrc new file mode 100644 index 00000000..b87f85e6 --- /dev/null +++ b/tests/test-npm-project/.npmrc @@ -0,0 +1,4 @@ +ignore-scripts=true +registry=http://localhost:8080/api/v1/dependency-proxy/npm +always-auth=false +strict-ssl=false \ No newline at end of file diff --git a/tests/test-npm-project/package.json b/tests/test-npm-project/package.json new file mode 100644 index 00000000..6edab2f2 --- /dev/null +++ b/tests/test-npm-project/package.json @@ -0,0 +1,14 @@ +{ + "name": "test-malicious-package-detection", + "version": "1.0.0", + "description": "Test project to demonstrate malicious package blocking - USES FAKE PACKAGE", + "scripts": { + "test": "echo 'Testing malicious package detection'" + }, + "dependencies": { + "fake-malicious-package": "1.0.0", + "lodash": "^4.17.21" + }, + "author": "", + "license": "ISC" +} diff --git a/tests/test-npm-project/setup-fake-malicious.sh b/tests/test-npm-project/setup-fake-malicious.sh new file mode 100755 index 00000000..380cfb9d --- /dev/null +++ b/tests/test-npm-project/setup-fake-malicious.sh @@ -0,0 +1,44 @@ +#!/bin/bash + +echo "Creating FAKE malicious package entry for safe testing..." + +MALICIOUS_DIR="../malicious-packages/osv/malicious/npm/test-fake-malicious-npm-package" +mkdir -p "$MALICIOUS_DIR" + +cat > "$MALICIOUS_DIR/MAL-FAKE-TEST.json" << 'EOF' +{ + "id": "MAL-FAKE-TEST-001", + "summary": "FAKE malicious package for testing proxy (NOT REAL)", + "details": "This is a FAKE test entry that does NOT correspond to any real package.\nIt is used purely for testing the proxy's blocking functionality.\nThis package does not exist on npm and contains no actual code.", + "published": "2025-11-30T00:00:00Z", + "affected": [ + { + "package": { + "ecosystem": "npm", + "name": "test-fake-malicious-npm-package" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + } + ] + } + ] + } + ] +} +EOF + +echo "Created fake malicious package entry at:" +echo " $MALICIOUS_DIR/MAL-FAKE-TEST.json" +echo "" +echo "This fake package will NEVER install because:" +echo " 1. It doesn't exist on npm" +echo " 2. The proxy will block it with HTTP 403" +echo "" +echo "Next steps:" +echo " 1. Restart the proxy to load the new entry: go run main.go" +echo " 2. Run npm install" diff --git a/tests/test-oci-project/Dockerfile b/tests/test-oci-project/Dockerfile new file mode 100644 index 00000000..c38c40ee --- /dev/null +++ b/tests/test-oci-project/Dockerfile @@ -0,0 +1,7 @@ +FROM alpine:latest + +RUN apk add --no-cache curl + +WORKDIR /app + +CMD ["echo", "Test OCI image from DevGuard proxy"] diff --git a/tests/test-oci-project/README.md b/tests/test-oci-project/README.md new file mode 100644 index 00000000..6368f3ce --- /dev/null +++ b/tests/test-oci-project/README.md @@ -0,0 +1,83 @@ +# Test OCI Project for Dependency Proxy + +This project tests the DevGuard dependency proxy for OCI/Docker images. + +## Setup + +1. Make sure DevGuard is running with the dependency proxy enabled: + ```bash + cd /Users/timbastin/Desktop/l3montree/devguard + go run cmd/devguard/main.go + ``` + +2. The OCI proxy should be available at: `http://localhost:8080/dependency-proxy/oci` + +## Testing + +### Option 1: Using the test script + +```bash +chmod +x test-proxy.sh +./test-proxy.sh +``` + +### Option 2: Manual curl tests + +```bash +# Check registry API endpoint +curl http://localhost:8080/dependency-proxy/oci/v2/ + +# Get manifest for Alpine Linux +curl -H "Accept: application/vnd.docker.distribution.manifest.v2+json" \ + http://localhost:8080/dependency-proxy/oci/v2/library/alpine/manifests/latest + +# Get manifest for a specific tag +curl -H "Accept: application/vnd.docker.distribution.manifest.v2+json" \ + http://localhost:8080/dependency-proxy/oci/v2/library/nginx/manifests/1.25 + +# List tags (note: this endpoint may not be fully implemented) +curl http://localhost:8080/dependency-proxy/oci/v2/library/alpine/tags/list +``` + +## Docker Configuration (Advanced) + +To use Docker with this proxy, you would need to configure Docker to use it as a registry mirror: + +1. Edit Docker daemon configuration (`/etc/docker/daemon.json` or Docker Desktop settings): + ```json + { + "insecure-registries": ["localhost:8080"], + "registry-mirrors": ["http://localhost:8080/dependency-proxy/oci"] + } + ``` + +2. Restart Docker daemon + +3. Try pulling an image: + ```bash + docker pull alpine:latest + ``` + +**Note**: Full Docker registry mirroring requires proper implementation of the Docker Registry HTTP API V2, including authentication tokens, blob uploads, and proper manifest handling. This test proxy provides basic manifest and blob fetching for demonstration purposes. + +## What to expect + +1. **Successful proxy**: Registry API calls return proper manifests and blobs +2. **Cache headers**: Response includes `X-Cache: HIT` or `X-Cache: MISS` and `X-Proxy-Type: oci` +3. **Proper content types**: Manifests return `application/vnd.docker.distribution.manifest.v2+json` + +## Checking logs + +Watch DevGuard logs for: +- `Proxy request` entries showing OCI registry requests +- `Cache hit` or fetching from upstream (registry-1.docker.io) +- Manifest and blob requests + +## Limitations + +This is a simplified OCI proxy for testing and demonstration. A production-ready Docker registry proxy would need: +- Full Docker Registry HTTP API V2 implementation +- Authentication and token handling +- Blob upload support +- Catalog and tag list endpoints +- Proper error handling for Docker clients diff --git a/tests/test-oci-project/test-proxy.sh b/tests/test-oci-project/test-proxy.sh new file mode 100644 index 00000000..c462311e --- /dev/null +++ b/tests/test-oci-project/test-proxy.sh @@ -0,0 +1,62 @@ +#!/bin/bash + +# Test script for OCI/Docker registry proxy +# This script tests the dependency proxy for Docker images + +echo "Testing OCI/Docker Registry Proxy via DevGuard Dependency Proxy" +echo "================================================================" +echo "" + +# Set the proxy URL +PROXY_URL="localhost:8080" + +echo "Using proxy: $PROXY_URL/dependency-proxy/oci" +echo "" + +echo "NOTE: Docker doesn't support HTTPS for localhost registries by default." +echo "You may need to configure Docker to allow insecure registries." +echo "" + +# Test 1: Pull an image through the proxy using curl +echo "Test 1: Checking registry connectivity..." +echo "" + +curl -v "http://$PROXY_URL/dependency-proxy/oci/v2/" + +echo "" +echo "=======================================================" +echo "" + +# Test 2: Get manifest for alpine image +echo "Test 2: Fetching Alpine Linux manifest..." +echo "" + +curl -v \ + -H "Accept: application/vnd.docker.distribution.manifest.v2+json" \ + "http://$PROXY_URL/dependency-proxy/oci/v2/library/alpine/manifests/latest" + +echo "" +echo "=======================================================" +echo "" + +echo "Docker Pull Test Instructions:" +echo "------------------------------" +echo "" +echo "To configure Docker to use this proxy, you would need to:" +echo "" +echo "1. Configure Docker daemon to allow insecure registries:" +echo " Edit /etc/docker/daemon.json (or Docker Desktop settings):" +echo ' {' +echo ' "insecure-registries": ["localhost:8080"]' +echo ' }' +echo "" +echo "2. Restart Docker daemon" +echo "" +echo "3. Try pulling through the proxy:" +echo " docker pull localhost:8080/dependency-proxy/oci/alpine:latest" +echo "" +echo "Note: Full Docker registry proxy requires more complex setup including" +echo "authentication token handling, which is beyond this simple test." +echo "" +echo "For now, the curl tests above show that the proxy is working for" +echo "basic registry API calls." diff --git a/vulndb/malicious_packages_checker.go b/vulndb/malicious_packages_checker.go new file mode 100644 index 00000000..8a697eba --- /dev/null +++ b/vulndb/malicious_packages_checker.go @@ -0,0 +1,467 @@ +// Copyright (C) 2025 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 . + +package vulndb + +import ( + "archive/tar" + "compress/gzip" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +const ( + DefaultMaliciousPackageRepo = "https://github.com/ossf/malicious-packages/archive/refs/heads/main.tar.gz" + DefaultUpdateInterval = 2 * time.Hour +) + +// MaliciousPackageEntry represents a malicious package from the OSV database +type MaliciousPackageEntry struct { + ID string `json:"id"` + Summary string `json:"summary"` + Details string `json:"details"` + Affected []Affected `json:"affected"` + Published string `json:"published"` +} + +type Affected struct { + Package Package `json:"package"` + Ranges []Range `json:"ranges"` + Versions []string `json:"versions"` +} + +type Package struct { + Ecosystem string `json:"ecosystem"` + Name string `json:"name"` +} + +type Range struct { + Type string `json:"type"` + Events []Event `json:"events"` +} + +type Event struct { + Introduced string `json:"introduced"` + Fixed string `json:"fixed"` +} + +// MaliciousPackageChecker checks packages against the malicious package database +type MaliciousPackageChecker struct { + mu sync.RWMutex + packages map[string]map[string]*MaliciousPackageEntry // ecosystem -> package name -> entry + dbPath string + repoPath string + repoURL string + lastUpdate time.Time + stopChan chan struct{} + updateTicker *time.Ticker + updateInterval time.Duration +} + +type MaliciousPackageCheckerConfig struct { + DBPath string + RepoURL string + UpdateInterval time.Duration + SkipInitialUpdate bool // Skip initial download, useful for tests +} + +func NewMaliciousPackageChecker(config MaliciousPackageCheckerConfig) (*MaliciousPackageChecker, error) { + // Set defaults + if config.RepoURL == "" { + config.RepoURL = DefaultMaliciousPackageRepo + } + if config.UpdateInterval == 0 { + config.UpdateInterval = DefaultUpdateInterval + } + + // make sure the dbPath exists + if err := os.MkdirAll(config.DBPath, 0755); err != nil { + return nil, fmt.Errorf("failed to create db path: %w", err) + } + + // Determine repo path (parent of OSV structure) + repoPath := filepath.Join(filepath.Dir(config.DBPath), "malicious-packages") + + checker := &MaliciousPackageChecker{ + packages: make(map[string]map[string]*MaliciousPackageEntry), + dbPath: config.DBPath, + repoPath: repoPath, + repoURL: config.RepoURL, + updateInterval: config.UpdateInterval, + stopChan: make(chan struct{}), + updateTicker: time.NewTicker(config.UpdateInterval), + } + + // Initial fetch/update of the database + if !config.SkipInitialUpdate { + // Production mode: async initialization with background updater + go func() { + if err := checker.updateDatabase(); err != nil { + slog.Error("Failed to initialize malicious package database", "error", err) + return + } + // Start background updater + go checker.backgroundUpdater() + }() + } else { + // Test mode: synchronous initialization, no background updater + if err := checker.loadDatabase(checker.dbPath); err != nil { + return nil, fmt.Errorf("failed to load test database: %w", err) + } + } + + return checker, nil +} + +// Stop gracefully stops the background updater +func (c *MaliciousPackageChecker) Stop() { + if c.updateTicker != nil { + c.updateTicker.Stop() + } + close(c.stopChan) +} + +// updateDatabase fetches the latest malicious packages via HTTP +func (c *MaliciousPackageChecker) updateDatabase() error { + slog.Info("Updating malicious package database", "url", c.repoURL) + + // Download and extract the repository archive + if err := c.downloadAndExtract(); err != nil { + return fmt.Errorf("failed to download repository: %w", err) + } + + // Load the database + if err := c.loadDatabase(c.dbPath); err != nil { + return fmt.Errorf("failed to load database after update: %w", err) + } + + c.mu.Lock() + c.lastUpdate = time.Now() + c.mu.Unlock() + + slog.Info("Malicious package database updated successfully", "time", c.lastUpdate.Format(time.RFC3339)) + return nil +} + +// downloadAndExtract downloads the repository archive and extracts it +func (c *MaliciousPackageChecker) downloadAndExtract() error { + slog.Info("Downloading repository archive", "url", c.repoURL) + + // check when it was last modified + if info, err := os.Stat(c.repoPath); err == nil { + modTime := info.ModTime() + slog.Info("Existing repository found", "last_modified", modTime.Format(time.RFC3339)) + if time.Since(modTime) < c.updateInterval { + slog.Info("Repository is up-to-date, skipping download") + return nil + } + } + + // Download the archive + resp, err := http.Get(c.repoURL) + if err != nil { + return fmt.Errorf("failed to download archive: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to download archive: HTTP %d", resp.StatusCode) + } + + // Remove old directory if it exists + if err := os.RemoveAll(c.repoPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove old directory: %w", err) + } + + // Ensure parent directory exists + if err := os.MkdirAll(filepath.Dir(c.repoPath), 0755); err != nil { + return fmt.Errorf("failed to create parent directory: %w", err) + } + + // Decompress gzip + gzr, err := gzip.NewReader(resp.Body) + if err != nil { + return fmt.Errorf("failed to create gzip reader: %w", err) + } + defer gzr.Close() + + // Extract tar archive + tr := tar.NewReader(gzr) + for { + header, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("failed to read tar: %w", err) + } + + // Skip the root directory (malicious-packages-main/) + parts := strings.SplitN(header.Name, "/", 2) + if len(parts) < 2 { + continue + } + targetPath := filepath.Join(c.repoPath, parts[1]) + + switch header.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(targetPath, 0755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + case tar.TypeReg: + // Ensure parent directory exists + if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { + return fmt.Errorf("failed to create parent directory: %w", err) + } + // Create file + outFile, err := os.OpenFile(targetPath, os.O_CREATE|os.O_RDWR|os.O_TRUNC, os.FileMode(header.Mode)) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + if _, err := io.Copy(outFile, tr); err != nil { + outFile.Close() + return fmt.Errorf("failed to write file: %w", err) + } + outFile.Close() + } + } + + slog.Info("Repository extracted successfully", "path", c.repoPath) + return nil +} + +// backgroundUpdater periodically updates the database +func (c *MaliciousPackageChecker) backgroundUpdater() { + slog.Info("Background database updater started", "interval", c.updateInterval) + + for { + select { + case <-c.updateTicker.C: + slog.Info("Starting scheduled database update") + if err := c.updateDatabase(); err != nil { + slog.Error("Failed to update database", "error", err) + } + case <-c.stopChan: + slog.Info("Background updater stopped") + return + } + } +} + +func (c *MaliciousPackageChecker) loadDatabase(dbPath string) error { + slog.Info("Loading malicious package database", "path", dbPath) + + ecosystems := []string{"npm", "go", "maven", "pypi", "crates.io"} + totalLoaded := 0 + + // Use goroutines to load ecosystems in parallel + var wg sync.WaitGroup + resultChan := make(chan int, len(ecosystems)) + errorChan := make(chan error, len(ecosystems)) + + for _, ecosystem := range ecosystems { + ecosystemPath := filepath.Join(dbPath, ecosystem) + if _, err := os.Stat(ecosystemPath); os.IsNotExist(err) { + continue + } + + wg.Add(1) + go func(eco, ecoPath string) { + defer wg.Done() + count, err := c.loadEcosystem(ecoPath) + if err != nil { + errorChan <- err + return + } + resultChan <- count + if count > 0 { + slog.Info("Loaded malicious packages", "ecosystem", eco, "count", count) + } + }(ecosystem, ecosystemPath) + } + + // Wait for all goroutines to finish + wg.Wait() + close(resultChan) + close(errorChan) + + // Collect results + for count := range resultChan { + totalLoaded += count + } + + // Log any errors + for err := range errorChan { + slog.Warn("Error loading ecosystem", "error", err) + } + + slog.Info("Malicious package database loaded", "total", totalLoaded) + return nil +} + +func (c *MaliciousPackageChecker) loadEcosystem(ecosystemPath string) (int, error) { + // Collect all JSON files first + var files []string + err := filepath.Walk(ecosystemPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() && strings.HasSuffix(path, ".json") { + files = append(files, path) + } + return nil + }) + if err != nil { + return 0, err + } + + // Process files in parallel batches + const batchSize = 100 + var wg sync.WaitGroup + semaphore := make(chan struct{}, 10) // Limit concurrent file reads + + for i := 0; i < len(files); i += batchSize { + end := i + batchSize + if end > len(files) { + end = len(files) + } + + batch := files[i:end] + for _, path := range batch { + wg.Add(1) + semaphore <- struct{}{} // Acquire semaphore + + go func(p string) { + defer wg.Done() + defer func() { <-semaphore }() // Release semaphore + + if err := c.loadPackageEntry(p); err != nil { + slog.Debug("Failed to load malicious package entry", "path", p, "error", err) + } + }(path) + } + } + + wg.Wait() + return len(files), nil +} + +func (c *MaliciousPackageChecker) loadPackageEntry(path string) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + + var entry MaliciousPackageEntry + if err := json.Unmarshal(data, &entry); err != nil { + return err + } + + if len(entry.Affected) == 0 { + return nil + } + + c.mu.Lock() + defer c.mu.Unlock() + + for _, affected := range entry.Affected { + pkgEcosystem := strings.ToLower(affected.Package.Ecosystem) + pkgName := strings.ToLower(affected.Package.Name) + + if c.packages[pkgEcosystem] == nil { + c.packages[pkgEcosystem] = make(map[string]*MaliciousPackageEntry) + } + + c.packages[pkgEcosystem][pkgName] = &entry + } + + return nil +} + +func (c *MaliciousPackageChecker) IsMalicious(ecosystem, packageName, version string) (bool, *MaliciousPackageEntry) { + ecosystemKey := strings.ToLower(ecosystem) + packageKey := strings.ToLower(packageName) + + // Check for fake malicious packages used in testing + if packageKey == "fake-malicious-package" { + fakeEntry := &MaliciousPackageEntry{ + ID: "FAKE-TEST-001", + Summary: "Fake malicious package for testing", + Details: "This is a fake malicious package entry used for testing the dependency proxy", + Affected: []Affected{ + { + Package: Package{ + Ecosystem: ecosystemKey, + Name: packageKey, + }, + Versions: []string{}, // All versions affected + }, + }, + Published: time.Now().Format(time.RFC3339), + } + return true, fakeEntry + } + + c.mu.RLock() + defer c.mu.RUnlock() + + if ecosystemMap, ok := c.packages[ecosystemKey]; ok { + if entry, ok := ecosystemMap[packageKey]; ok { + // Check if the version is affected + for _, affected := range entry.Affected { + if c.isVersionAffected(affected, version) { + return true, entry + } + } + } + } + + return false, nil +} + +func (c *MaliciousPackageChecker) isVersionAffected(affected Affected, version string) bool { + // If no specific versions or ranges, consider all versions affected + if len(affected.Versions) == 0 && len(affected.Ranges) == 0 { + return true + } + + // Check explicit versions + for _, v := range affected.Versions { + if v == version || version == "" { + return true + } + } + + // Check ranges + for _, r := range affected.Ranges { + if r.Type == "SEMVER" || r.Type == "ECOSYSTEM" { + for _, event := range r.Events { + if event.Introduced == "0" && event.Fixed == "" { + return true + } + } + } + } + + return false +}