mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
Generalise Proxy Mode Registry Config (#149)
* refactor: generalise registry config * add test cases & add defensive check for domain match * fix linter
This commit is contained in:
@@ -1,34 +1,32 @@
|
|||||||
package interceptors
|
package interceptors
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
|
||||||
|
|
||||||
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
|
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
|
||||||
"github.com/safedep/dry/log"
|
"github.com/safedep/dry/log"
|
||||||
"github.com/safedep/pmg/analyzer"
|
"github.com/safedep/pmg/analyzer"
|
||||||
"github.com/safedep/pmg/proxy"
|
"github.com/safedep/pmg/proxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
var npmRegistryDomains = map[string]*npmRegistryConfig{
|
var npmRegistryDomains = registryConfigMap{
|
||||||
"registry.npmjs.org": {
|
"registry.npmjs.org": {
|
||||||
Host: "registry.npmjs.org",
|
Host: "registry.npmjs.org",
|
||||||
SupportedForAnalysis: true,
|
SupportedForAnalysis: true,
|
||||||
RegistryParser: npmParser{},
|
Parser: npmParser{},
|
||||||
},
|
},
|
||||||
"registry.yarnpkg.com": {
|
"registry.yarnpkg.com": {
|
||||||
Host: "registry.yarnpkg.com",
|
Host: "registry.yarnpkg.com",
|
||||||
SupportedForAnalysis: true,
|
SupportedForAnalysis: true,
|
||||||
RegistryParser: npmParser{},
|
Parser: npmParser{},
|
||||||
},
|
},
|
||||||
"npm.pkg.github.com": {
|
"npm.pkg.github.com": {
|
||||||
Host: "npm.pkg.github.com",
|
Host: "npm.pkg.github.com",
|
||||||
SupportedForAnalysis: false, // Skip analysis for now (private packages, auth complexity)
|
SupportedForAnalysis: false, // Skip analysis for now (private packages, auth complexity)
|
||||||
RegistryParser: githubParser{},
|
Parser: npmGithubParser{},
|
||||||
},
|
},
|
||||||
"pkg-npm.githubusercontent.com": {
|
"pkg-npm.githubusercontent.com": {
|
||||||
Host: "pkg-npm.githubusercontent.com",
|
Host: "pkg-npm.githubusercontent.com",
|
||||||
SupportedForAnalysis: false, // Skip analysis (blob storage, redirected downloads)
|
SupportedForAnalysis: false, // Skip analysis (blob storage, redirected downloads)
|
||||||
RegistryParser: githubBlobParser{},
|
Parser: npmGithubBlobParser{},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,18 +62,7 @@ func (i *NpmRegistryInterceptor) Name() string {
|
|||||||
|
|
||||||
// ShouldIntercept determines if this interceptor should handle the given request
|
// ShouldIntercept determines if this interceptor should handle the given request
|
||||||
func (i *NpmRegistryInterceptor) ShouldIntercept(ctx *proxy.RequestContext) bool {
|
func (i *NpmRegistryInterceptor) ShouldIntercept(ctx *proxy.RequestContext) bool {
|
||||||
if _, exists := npmRegistryDomains[ctx.Hostname]; exists {
|
return npmRegistryDomains.ContainsHostname(ctx.Hostname)
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check subdomain match
|
|
||||||
for domain := range npmRegistryDomains {
|
|
||||||
if strings.HasSuffix(ctx.Hostname, "."+domain) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleRequest processes the request and returns response action
|
// HandleRequest processes the request and returns response action
|
||||||
@@ -84,7 +71,7 @@ func (i *NpmRegistryInterceptor) HandleRequest(ctx *proxy.RequestContext) (*prox
|
|||||||
log.Debugf("[%s] Handling NPM registry request: %s", ctx.RequestID, ctx.URL.Path)
|
log.Debugf("[%s] Handling NPM registry request: %s", ctx.RequestID, ctx.URL.Path)
|
||||||
|
|
||||||
// Get registry configuration
|
// Get registry configuration
|
||||||
config := getNpmRegistryConfigForHostname(ctx.Hostname)
|
config := npmRegistryDomains.GetConfigForHostname(ctx.Hostname)
|
||||||
if config == nil {
|
if config == nil {
|
||||||
// Shouldn't happen if ShouldIntercept is working correctly
|
// Shouldn't happen if ShouldIntercept is working correctly
|
||||||
log.Warnf("[%s] No registry config found for hostname: %s", ctx.RequestID, ctx.Hostname)
|
log.Warnf("[%s] No registry config found for hostname: %s", ctx.RequestID, ctx.Hostname)
|
||||||
@@ -99,7 +86,7 @@ func (i *NpmRegistryInterceptor) HandleRequest(ctx *proxy.RequestContext) (*prox
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse URL using registry-specific strategy
|
// Parse URL using registry-specific strategy
|
||||||
pkgInfo, err := config.RegistryParser.ParseURL(ctx.URL.Path)
|
pkgInfo, err := config.Parser.ParseURL(ctx.URL.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warnf("[%s] Failed to parse NPM registry URL %s for %s: %v",
|
log.Warnf("[%s] Failed to parse NPM registry URL %s for %s: %v",
|
||||||
ctx.RequestID, ctx.URL.Path, config.Host, err)
|
ctx.RequestID, ctx.URL.Path, config.Host, err)
|
||||||
@@ -108,21 +95,21 @@ func (i *NpmRegistryInterceptor) HandleRequest(ctx *proxy.RequestContext) (*prox
|
|||||||
|
|
||||||
// Only analyze tarball downloads (these have a specific version)
|
// Only analyze tarball downloads (these have a specific version)
|
||||||
// Metadata requests (without version) are allowed through
|
// Metadata requests (without version) are allowed through
|
||||||
if !pkgInfo.IsTarball {
|
if !pkgInfo.IsFileDownload() {
|
||||||
log.Debugf("[%s] Skipping analysis for metadata request: %s", ctx.RequestID, pkgInfo.Name)
|
log.Debugf("[%s] Skipping analysis for metadata request: %s", ctx.RequestID, pkgInfo.GetName())
|
||||||
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
|
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := i.baseRegistryInterceptor.analyzePackage(
|
result, err := i.baseRegistryInterceptor.analyzePackage(
|
||||||
ctx,
|
ctx,
|
||||||
packagev1.Ecosystem_ECOSYSTEM_NPM,
|
packagev1.Ecosystem_ECOSYSTEM_NPM,
|
||||||
pkgInfo.Name,
|
pkgInfo.GetName(),
|
||||||
pkgInfo.Version,
|
pkgInfo.GetVersion(),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("[%s] Failed to analyze package %s@%s: %v", ctx.RequestID, pkgInfo.Name, pkgInfo.Version, err)
|
log.Errorf("[%s] Failed to analyze package %s@%s: %v", ctx.RequestID, pkgInfo.GetName(), pkgInfo.GetVersion(), err)
|
||||||
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
|
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return i.baseRegistryInterceptor.handleAnalysisResult(ctx, packagev1.Ecosystem_ECOSYSTEM_NPM, pkgInfo.Name, pkgInfo.Version, result)
|
return i.handleAnalysisResult(ctx, packagev1.Ecosystem_ECOSYSTEM_NPM, pkgInfo.GetName(), pkgInfo.GetVersion(), result)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
package interceptors
|
|
||||||
|
|
||||||
import "strings"
|
|
||||||
|
|
||||||
// npmRegistryConfig defines configuration for npm registry endpoints
|
|
||||||
type npmRegistryConfig struct {
|
|
||||||
// Hostname
|
|
||||||
Host string
|
|
||||||
|
|
||||||
// Whether this registry is supported for malware analysis
|
|
||||||
SupportedForAnalysis bool
|
|
||||||
|
|
||||||
// Parser for the registry
|
|
||||||
RegistryParser npmRegistryURLParser
|
|
||||||
}
|
|
||||||
|
|
||||||
// getNpmRegistryConfigForHostname returns the configuration for a hostname (with subdomain matching)
|
|
||||||
func getNpmRegistryConfigForHostname(hostname string) *npmRegistryConfig {
|
|
||||||
// Check exact match first
|
|
||||||
if config, exists := npmRegistryDomains[hostname]; exists {
|
|
||||||
return config
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check subdomain match: hostname could be "cdn.registry.npmjs.org" matching "registry.npmjs.org"
|
|
||||||
for endpoint, config := range npmRegistryDomains {
|
|
||||||
if strings.HasSuffix(hostname, "."+endpoint) {
|
|
||||||
return config
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -7,30 +7,51 @@ import (
|
|||||||
|
|
||||||
// npmPackageInfo represents parsed package information from an NPM registry URL
|
// npmPackageInfo represents parsed package information from an NPM registry URL
|
||||||
type npmPackageInfo struct {
|
type npmPackageInfo struct {
|
||||||
Name string
|
name string
|
||||||
Version string
|
version string
|
||||||
IsTarball bool
|
isTarball bool
|
||||||
IsScoped bool
|
isScoped bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// npmRegistryURLParser defines the interface for parsing registry-specific URLs
|
// Ensure npmPackageInfo implements packageInfo interface
|
||||||
type npmRegistryURLParser interface {
|
var _ packageInfo = (*npmPackageInfo)(nil)
|
||||||
ParseURL(urlPath string) (*npmPackageInfo, error)
|
|
||||||
|
// GetName returns the package name
|
||||||
|
func (n *npmPackageInfo) GetName() string {
|
||||||
|
return n.name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetVersion returns the package version
|
||||||
|
func (n *npmPackageInfo) GetVersion() string {
|
||||||
|
return n.version
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsFileDownload returns true if this is a tarball download
|
||||||
|
func (n *npmPackageInfo) IsFileDownload() bool {
|
||||||
|
return n.isTarball
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsScoped returns true if this is a scoped package (@scope/name)
|
||||||
|
func (n *npmPackageInfo) IsScoped() bool {
|
||||||
|
return n.isScoped
|
||||||
|
}
|
||||||
|
|
||||||
|
// npmParser parses standard NPM registry URL paths (registry.npmjs.org, registry.yarnpkg.com)
|
||||||
type npmParser struct{}
|
type npmParser struct{}
|
||||||
|
|
||||||
// parseNpmRegistryURL parses standard NPM registry URL paths (registry.npmjs.org, registry.yarnpkg.com)
|
// Ensure npmParser implements RegistryURLParser interface
|
||||||
// This function handles the standard npm registry URL format.
|
var _ registryURLParser = npmParser{}
|
||||||
|
|
||||||
|
// ParseURL parses standard NPM registry URL paths
|
||||||
//
|
//
|
||||||
// Supported URL patterns:
|
// Supported URL patterns:
|
||||||
// - /package -> {Name: "package", Version: ""}
|
// - /package -> {name: "package", version: ""}
|
||||||
// - /package/1.0.0 -> {Name: "package", Version: "1.0.0"}
|
// - /package/1.0.0 -> {name: "package", version: "1.0.0"}
|
||||||
// - /@scope/package -> {Name: "@scope/package", Version: "", IsScoped: true}
|
// - /@scope/package -> {name: "@scope/package", version: "", isScoped: true}
|
||||||
// - /@scope/package/1.0.0 -> {Name: "@scope/package", Version: "1.0.0", IsScoped: true}
|
// - /@scope/package/1.0.0 -> {name: "@scope/package", version: "1.0.0", isScoped: true}
|
||||||
// - /package/-/package-1.0.0.tgz -> {Name: "package", Version: "1.0.0", IsTarball: true}
|
// - /package/-/package-1.0.0.tgz -> {name: "package", version: "1.0.0", isTarball: true}
|
||||||
// - /@scope/package/-/@scope-package-1.0.0.tgz -> {Name: "@scope/package", Version: "1.0.0", IsTarball: true, IsScoped: true}
|
// - /@scope/package/-/@scope-package-1.0.0.tgz -> {name: "@scope/package", version: "1.0.0", isTarball: true, isScoped: true}
|
||||||
func (n npmParser) ParseURL(urlPath string) (*npmPackageInfo, error) {
|
func (n npmParser) ParseURL(urlPath string) (packageInfo, error) {
|
||||||
// Remove leading and trailing slashes
|
// Remove leading and trailing slashes
|
||||||
urlPath = strings.Trim(urlPath, "/")
|
urlPath = strings.Trim(urlPath, "/")
|
||||||
|
|
||||||
@@ -51,29 +72,37 @@ func (n npmParser) ParseURL(urlPath string) (*npmPackageInfo, error) {
|
|||||||
return parseUnscopedPackageURL(segments)
|
return parseUnscopedPackageURL(segments)
|
||||||
}
|
}
|
||||||
|
|
||||||
type githubParser struct{}
|
// npmGithubParser parses GitHub npm registry URLs
|
||||||
|
type npmGithubParser struct{}
|
||||||
|
|
||||||
|
// Ensure npmGithubParser implements RegistryURLParser interface
|
||||||
|
var _ registryURLParser = npmGithubParser{}
|
||||||
|
|
||||||
// ParseURL implements RegistryURLParser for GitHub npm registry
|
// ParseURL implements RegistryURLParser for GitHub npm registry
|
||||||
func (g githubParser) ParseURL(urlPath string) (*npmPackageInfo, error) {
|
func (g npmGithubParser) ParseURL(urlPath string) (packageInfo, error) {
|
||||||
// For now, just allow all GitHub npm registry requests through without analysis
|
// For now, just allow all GitHub npm registry requests through without analysis
|
||||||
// TODO: Implement proper GitHub npm registry URL parsing when analysis is enabled
|
// TODO: Implement proper GitHub npm registry URL parsing when analysis is enabled
|
||||||
// GitHub URLs follow patterns:
|
// GitHub URLs follow patterns:
|
||||||
// - /download/@owner/package/version/hash.tgz -> {Name: "package", Version: "1.0.0", IsTarball: true}
|
// - /download/@owner/package/version/hash.tgz -> {name: "package", version: "1.0.0", isTarball: true}
|
||||||
// - /@owner/package (metadata requests)
|
// - /@owner/package (metadata requests)
|
||||||
return &npmPackageInfo{
|
return &npmPackageInfo{
|
||||||
IsTarball: false, // Mark as non-tarball to skip analysis
|
isTarball: false, // Mark as non-tarball to skip analysis
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type githubBlobParser struct{}
|
// npmGithubBlobParser parses GitHub blob storage URLs
|
||||||
|
type npmGithubBlobParser struct{}
|
||||||
|
|
||||||
|
// Ensure npmGithubBlobParser implements RegistryURLParser interface
|
||||||
|
var _ registryURLParser = npmGithubBlobParser{}
|
||||||
|
|
||||||
// ParseURL implements RegistryURLParser for GitHub blob storage
|
// ParseURL implements RegistryURLParser for GitHub blob storage
|
||||||
func (g githubBlobParser) ParseURL(urlPath string) (*npmPackageInfo, error) {
|
func (g npmGithubBlobParser) ParseURL(urlPath string) (packageInfo, error) {
|
||||||
// For now, just allow all GitHub blob storage requests through without analysis
|
// For now, just allow all GitHub blob storage requests through without analysis
|
||||||
// TODO: Implement proper GitHub blob storage URL parsing when analysis is enabled
|
// TODO: Implement proper GitHub blob storage URL parsing when analysis is enabled
|
||||||
// Pattern: /npmregistryv2prod/blobs/{blob_id}/{package_name}/{version}/***
|
// Pattern: /npmregistryv2prod/blobs/{blob_id}/{package_name}/{version}/***
|
||||||
return &npmPackageInfo{
|
return &npmPackageInfo{
|
||||||
IsTarball: false, // Mark as non-tarball to skip analysis
|
isTarball: false, // Mark as non-tarball to skip analysis
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,8 +121,8 @@ func parseScopedPackageURL(segments []string) (*npmPackageInfo, error) {
|
|||||||
fullName := scope + "/" + packageName
|
fullName := scope + "/" + packageName
|
||||||
|
|
||||||
info := &npmPackageInfo{
|
info := &npmPackageInfo{
|
||||||
Name: fullName,
|
name: fullName,
|
||||||
IsScoped: true,
|
isScoped: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Just the scoped package name: /@scope/package
|
// Just the scoped package name: /@scope/package
|
||||||
@@ -112,14 +141,14 @@ func parseScopedPackageURL(segments []string) (*npmPackageInfo, error) {
|
|||||||
return nil, fmt.Errorf("failed to extract version from tarball %s: %w", tarballName, err)
|
return nil, fmt.Errorf("failed to extract version from tarball %s: %w", tarballName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
info.Version = version
|
info.version = version
|
||||||
info.IsTarball = true
|
info.isTarball = true
|
||||||
return info, nil
|
return info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Version metadata: /@scope/package/1.0.0
|
// Version metadata: /@scope/package/1.0.0
|
||||||
if len(segments) == 3 {
|
if len(segments) == 3 {
|
||||||
info.Version = segments[2]
|
info.version = segments[2]
|
||||||
return info, nil
|
return info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,8 +168,8 @@ func parseUnscopedPackageURL(segments []string) (*npmPackageInfo, error) {
|
|||||||
packageName := segments[0]
|
packageName := segments[0]
|
||||||
|
|
||||||
info := &npmPackageInfo{
|
info := &npmPackageInfo{
|
||||||
Name: packageName,
|
name: packageName,
|
||||||
IsScoped: false,
|
isScoped: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Just the package name: /package
|
// Just the package name: /package
|
||||||
@@ -159,14 +188,14 @@ func parseUnscopedPackageURL(segments []string) (*npmPackageInfo, error) {
|
|||||||
return nil, fmt.Errorf("failed to extract version from tarball %s: %w", tarballName, err)
|
return nil, fmt.Errorf("failed to extract version from tarball %s: %w", tarballName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
info.Version = version
|
info.version = version
|
||||||
info.IsTarball = true
|
info.isTarball = true
|
||||||
return info, nil
|
return info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Version metadata: /package/1.0.0
|
// Version metadata: /package/1.0.0
|
||||||
if len(segments) == 2 {
|
if len(segments) == 2 {
|
||||||
info.Version = segments[1]
|
info.version = segments[1]
|
||||||
return info, nil
|
return info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -274,10 +274,16 @@ func TestParseNpmRegistryURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, tt.wantName, got.Name)
|
assert.Equal(t, tt.wantName, got.GetName())
|
||||||
assert.Equal(t, tt.wantVersion, got.Version)
|
assert.Equal(t, tt.wantVersion, got.GetVersion())
|
||||||
assert.Equal(t, tt.wantIsTarball, got.IsTarball)
|
assert.Equal(t, tt.wantIsTarball, got.IsFileDownload())
|
||||||
assert.Equal(t, tt.wantIsScoped, got.IsScoped)
|
|
||||||
|
// Check scoped status via type assertion - must succeed for npm packages
|
||||||
|
npmInfo, ok := got.(*npmPackageInfo)
|
||||||
|
assert.True(t, ok, "expected *npmPackageInfo type")
|
||||||
|
if ok {
|
||||||
|
assert.Equal(t, tt.wantIsScoped, npmInfo.IsScoped())
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -305,7 +311,7 @@ func TestGithubParser_ParseURL(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
parser := githubParser{}
|
parser := npmGithubParser{}
|
||||||
got, err := parser.ParseURL(tt.urlPath)
|
got, err := parser.ParseURL(tt.urlPath)
|
||||||
|
|
||||||
if tt.wantErr {
|
if tt.wantErr {
|
||||||
@@ -314,7 +320,7 @@ func TestGithubParser_ParseURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, tt.wantIsTarball, got.IsTarball)
|
assert.Equal(t, tt.wantIsTarball, got.IsFileDownload())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -336,7 +342,7 @@ func TestGithubBlobParser_ParseURL(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
parser := githubBlobParser{}
|
parser := npmGithubBlobParser{}
|
||||||
got, err := parser.ParseURL(tt.urlPath)
|
got, err := parser.ParseURL(tt.urlPath)
|
||||||
|
|
||||||
if tt.wantErr {
|
if tt.wantErr {
|
||||||
@@ -345,7 +351,7 @@ func TestGithubBlobParser_ParseURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, tt.wantIsTarball, got.IsTarball)
|
assert.Equal(t, tt.wantIsTarball, got.IsFileDownload())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package interceptors
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// packageInfo represents parsed package information from a registry URL.
|
||||||
|
// All ecosystem-specific package info types must implement this interface.
|
||||||
|
type packageInfo interface {
|
||||||
|
// GetName returns the package name
|
||||||
|
GetName() string
|
||||||
|
|
||||||
|
// GetVersion returns the package version (may be empty for metadata requests)
|
||||||
|
GetVersion() string
|
||||||
|
|
||||||
|
// IsFileDownload returns true if this is a file download request (tarball, wheel, etc.)
|
||||||
|
// Returns false for metadata requests (package index, version info, etc.)
|
||||||
|
IsFileDownload() bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// registryURLParser parses registry-specific URLs to extract package information.
|
||||||
|
// Each registry (npm, pypi, etc.) implements this interface with its own URL parsing logic.
|
||||||
|
type registryURLParser interface {
|
||||||
|
// ParseURL parses a URL path and returns package information.
|
||||||
|
// Returns an error if the URL cannot be parsed.
|
||||||
|
ParseURL(urlPath string) (packageInfo, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// registryConfig defines configuration for a package registry endpoint.
|
||||||
|
// This is the common configuration structure used by all ecosystem interceptors.
|
||||||
|
type registryConfig struct {
|
||||||
|
// Host is the hostname of the registry
|
||||||
|
Host string
|
||||||
|
|
||||||
|
// SupportedForAnalysis indicates whether this registry supports malware analysis.
|
||||||
|
// Some registries (like private registries or test instances) may not support analysis.
|
||||||
|
SupportedForAnalysis bool
|
||||||
|
|
||||||
|
// Parser is the URL parser for this registry
|
||||||
|
Parser registryURLParser
|
||||||
|
}
|
||||||
|
|
||||||
|
// registryConfigMap is a map of hostname to registry configuration
|
||||||
|
type registryConfigMap map[string]*registryConfig
|
||||||
|
|
||||||
|
// GetConfigForHostname returns the configuration for a hostname with subdomain matching support.
|
||||||
|
// It first checks for an exact match, then checks if the hostname is a subdomain of any configured registry.
|
||||||
|
func (m registryConfigMap) GetConfigForHostname(hostname string) *registryConfig {
|
||||||
|
// Check exact match first
|
||||||
|
if config, exists := m[hostname]; exists {
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check subdomain match: hostname could be "cdn.registry.example.org" matching "registry.example.org".
|
||||||
|
// Defensive: Since Go map iteration order is non-deterministic, if multiple endpoints could match
|
||||||
|
// (e.g., both "example.org" and "registry.example.org"), we select the longest (most specific) one
|
||||||
|
// to ensure consistent behavior. In practice, our configured endpoints don't overlap.
|
||||||
|
var bestConfig *registryConfig
|
||||||
|
bestLen := 0
|
||||||
|
for endpoint, config := range m {
|
||||||
|
if strings.HasSuffix(hostname, "."+endpoint) {
|
||||||
|
if len(endpoint) > bestLen {
|
||||||
|
bestLen = len(endpoint)
|
||||||
|
bestConfig = config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContainsHostname checks if the hostname matches any configured registry (exact or subdomain match)
|
||||||
|
func (m registryConfigMap) ContainsHostname(hostname string) bool {
|
||||||
|
return m.GetConfigForHostname(hostname) != nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
package interceptors
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mockParser is a simple parser for testing
|
||||||
|
type mockParser struct{}
|
||||||
|
|
||||||
|
func (m mockParser) ParseURL(urlPath string) (packageInfo, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryConfigMap_GetConfigForHostname_ExactMatch(t *testing.T) {
|
||||||
|
configMap := registryConfigMap{
|
||||||
|
"registry.example.org": {
|
||||||
|
Host: "registry.example.org",
|
||||||
|
SupportedForAnalysis: true,
|
||||||
|
Parser: mockParser{},
|
||||||
|
},
|
||||||
|
"other.example.org": {
|
||||||
|
Host: "other.example.org",
|
||||||
|
SupportedForAnalysis: false,
|
||||||
|
Parser: mockParser{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
hostname string
|
||||||
|
wantHost string
|
||||||
|
wantExists bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "exact match first registry",
|
||||||
|
hostname: "registry.example.org",
|
||||||
|
wantHost: "registry.example.org",
|
||||||
|
wantExists: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exact match second registry",
|
||||||
|
hostname: "other.example.org",
|
||||||
|
wantHost: "other.example.org",
|
||||||
|
wantExists: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no match",
|
||||||
|
hostname: "unknown.example.org",
|
||||||
|
wantHost: "",
|
||||||
|
wantExists: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
config := configMap.GetConfigForHostname(tt.hostname)
|
||||||
|
if !tt.wantExists {
|
||||||
|
assert.Nil(t, config)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assert.NotNil(t, config)
|
||||||
|
assert.Equal(t, tt.wantHost, config.Host)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryConfigMap_GetConfigForHostname_SubdomainMatch(t *testing.T) {
|
||||||
|
configMap := registryConfigMap{
|
||||||
|
"registry.example.org": {
|
||||||
|
Host: "registry.example.org",
|
||||||
|
SupportedForAnalysis: true,
|
||||||
|
Parser: mockParser{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
hostname string
|
||||||
|
wantHost string
|
||||||
|
wantExists bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "subdomain match",
|
||||||
|
hostname: "cdn.registry.example.org",
|
||||||
|
wantHost: "registry.example.org",
|
||||||
|
wantExists: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multi-level subdomain match",
|
||||||
|
hostname: "a.b.c.registry.example.org",
|
||||||
|
wantHost: "registry.example.org",
|
||||||
|
wantExists: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "partial match should not work",
|
||||||
|
hostname: "fakeregistry.example.org",
|
||||||
|
wantHost: "",
|
||||||
|
wantExists: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "different domain should not match",
|
||||||
|
hostname: "registry.other.org",
|
||||||
|
wantHost: "",
|
||||||
|
wantExists: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
config := configMap.GetConfigForHostname(tt.hostname)
|
||||||
|
if !tt.wantExists {
|
||||||
|
assert.Nil(t, config)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assert.NotNil(t, config)
|
||||||
|
assert.Equal(t, tt.wantHost, config.Host)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryConfigMap_GetConfigForHostname_LongestMatchPrecedence(t *testing.T) {
|
||||||
|
// Test that when multiple endpoints could match, the longest (most specific) is selected
|
||||||
|
configMap := registryConfigMap{
|
||||||
|
"example.org": {
|
||||||
|
Host: "example.org",
|
||||||
|
SupportedForAnalysis: false,
|
||||||
|
Parser: mockParser{},
|
||||||
|
},
|
||||||
|
"registry.example.org": {
|
||||||
|
Host: "registry.example.org",
|
||||||
|
SupportedForAnalysis: true,
|
||||||
|
Parser: mockParser{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
hostname string
|
||||||
|
wantHost string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "should match longer endpoint",
|
||||||
|
hostname: "cdn.registry.example.org",
|
||||||
|
wantHost: "registry.example.org",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "should match shorter when longer doesn't apply",
|
||||||
|
hostname: "other.example.org",
|
||||||
|
wantHost: "example.org",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
config := configMap.GetConfigForHostname(tt.hostname)
|
||||||
|
assert.NotNil(t, config)
|
||||||
|
assert.Equal(t, tt.wantHost, config.Host)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryConfigMap_GetConfigForHostname_ExactMatchTakesPrecedence(t *testing.T) {
|
||||||
|
// Exact match should always take precedence over subdomain match
|
||||||
|
configMap := registryConfigMap{
|
||||||
|
"example.org": {
|
||||||
|
Host: "example.org",
|
||||||
|
SupportedForAnalysis: false,
|
||||||
|
Parser: mockParser{},
|
||||||
|
},
|
||||||
|
"cdn.example.org": {
|
||||||
|
Host: "cdn.example.org",
|
||||||
|
SupportedForAnalysis: true,
|
||||||
|
Parser: mockParser{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
config := configMap.GetConfigForHostname("cdn.example.org")
|
||||||
|
assert.NotNil(t, config)
|
||||||
|
assert.Equal(t, "cdn.example.org", config.Host)
|
||||||
|
assert.True(t, config.SupportedForAnalysis, "exact match should be selected, not subdomain match")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryConfigMap_ContainsHostname(t *testing.T) {
|
||||||
|
configMap := registryConfigMap{
|
||||||
|
"registry.example.org": {
|
||||||
|
Host: "registry.example.org",
|
||||||
|
SupportedForAnalysis: true,
|
||||||
|
Parser: mockParser{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
hostname string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "exact match",
|
||||||
|
hostname: "registry.example.org",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "subdomain match",
|
||||||
|
hostname: "cdn.registry.example.org",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no match",
|
||||||
|
hostname: "unknown.org",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "partial match should not work",
|
||||||
|
hostname: "fakeregistry.example.org",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := configMap.ContainsHostname(tt.hostname)
|
||||||
|
assert.Equal(t, tt.want, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryConfigMap_EmptyMap(t *testing.T) {
|
||||||
|
configMap := registryConfigMap{}
|
||||||
|
|
||||||
|
assert.Nil(t, configMap.GetConfigForHostname("any.host.org"))
|
||||||
|
assert.False(t, configMap.ContainsHostname("any.host.org"))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user