mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
* fix proxy mode failing for GH private packages * skip analysis for private packages for proxy mode * introduce npmRegistryConfig and support for handling multiple parsers in future * refactor name and unexport npm config functions * rm unused function * rename & unexport npmRegistryURLParser * add e2e for malicious pkg blocked using proxy mode
240 lines
7.9 KiB
Go
240 lines
7.9 KiB
Go
package interceptors
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// npmPackageInfo represents parsed package information from an NPM registry URL
|
|
type npmPackageInfo struct {
|
|
Name string
|
|
Version string
|
|
IsTarball bool
|
|
IsScoped bool
|
|
}
|
|
|
|
// npmRegistryURLParser defines the interface for parsing registry-specific URLs
|
|
type npmRegistryURLParser interface {
|
|
ParseURL(urlPath string) (*npmPackageInfo, error)
|
|
}
|
|
|
|
type npmParser struct{}
|
|
|
|
// parseNpmRegistryURL parses standard NPM registry URL paths (registry.npmjs.org, registry.yarnpkg.com)
|
|
// This function handles the standard npm registry URL format.
|
|
//
|
|
// Supported URL patterns:
|
|
// - /package -> {Name: "package", Version: ""}
|
|
// - /package/1.0.0 -> {Name: "package", Version: "1.0.0"}
|
|
// - /@scope/package -> {Name: "@scope/package", Version: "", 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}
|
|
// - /@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) {
|
|
// Remove leading and trailing slashes
|
|
urlPath = strings.Trim(urlPath, "/")
|
|
|
|
if urlPath == "" {
|
|
return nil, fmt.Errorf("empty URL path")
|
|
}
|
|
|
|
// Split path into segments
|
|
segments := strings.Split(urlPath, "/")
|
|
|
|
// Check if this is a scoped package (starts with @)
|
|
isScoped := len(segments) > 0 && strings.HasPrefix(segments[0], "@")
|
|
|
|
if isScoped {
|
|
return parseScopedPackageURL(segments)
|
|
}
|
|
|
|
return parseUnscopedPackageURL(segments)
|
|
}
|
|
|
|
type githubParser struct{}
|
|
|
|
// ParseURL implements RegistryURLParser for GitHub npm registry
|
|
func (g githubParser) ParseURL(urlPath string) (*npmPackageInfo, error) {
|
|
// For now, just allow all GitHub npm registry requests through without analysis
|
|
// TODO: Implement proper GitHub npm registry URL parsing when analysis is enabled
|
|
// GitHub URLs follow patterns:
|
|
// - /download/@owner/package/version/hash.tgz -> {Name: "package", Version: "1.0.0", IsTarball: true}
|
|
// - /@owner/package (metadata requests)
|
|
return &npmPackageInfo{
|
|
IsTarball: false, // Mark as non-tarball to skip analysis
|
|
}, nil
|
|
}
|
|
|
|
type githubBlobParser struct{}
|
|
|
|
// ParseURL implements RegistryURLParser for GitHub blob storage
|
|
func (g githubBlobParser) ParseURL(urlPath string) (*npmPackageInfo, error) {
|
|
// For now, just allow all GitHub blob storage requests through without analysis
|
|
// TODO: Implement proper GitHub blob storage URL parsing when analysis is enabled
|
|
// Pattern: /npmregistryv2prod/blobs/{blob_id}/{package_name}/{version}/***
|
|
return &npmPackageInfo{
|
|
IsTarball: false, // Mark as non-tarball to skip analysis
|
|
}, nil
|
|
}
|
|
|
|
// parseScopedPackageURL parses a scoped package URL
|
|
// Patterns:
|
|
// - [@scope, package] -> @scope/package
|
|
// - [@scope, package, version] -> @scope/package@version
|
|
// - [@scope, package, -, tarball.tgz] -> @scope/package@version (extract from tarball)
|
|
func parseScopedPackageURL(segments []string) (*npmPackageInfo, error) {
|
|
if len(segments) < 2 {
|
|
return nil, fmt.Errorf("invalid scoped package URL: not enough segments")
|
|
}
|
|
|
|
scope := segments[0]
|
|
packageName := segments[1]
|
|
fullName := scope + "/" + packageName
|
|
|
|
info := &npmPackageInfo{
|
|
Name: fullName,
|
|
IsScoped: true,
|
|
}
|
|
|
|
// Just the scoped package name: /@scope/package
|
|
if len(segments) == 2 {
|
|
return info, nil
|
|
}
|
|
|
|
// Check if this is a tarball download: /@scope/package/-/tarball.tgz
|
|
if len(segments) == 4 && segments[2] == "-" {
|
|
tarballName := segments[3]
|
|
|
|
// Extract version from tarball filename
|
|
// Format: @scope-package-1.0.0.tgz
|
|
version, err := extractVersionFromScopedTarball(scope, packageName, tarballName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to extract version from tarball %s: %w", tarballName, err)
|
|
}
|
|
|
|
info.Version = version
|
|
info.IsTarball = true
|
|
return info, nil
|
|
}
|
|
|
|
// Version metadata: /@scope/package/1.0.0
|
|
if len(segments) == 3 {
|
|
info.Version = segments[2]
|
|
return info, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("invalid scoped package URL format: unexpected number of segments %d", len(segments))
|
|
}
|
|
|
|
// parseUnscopedPackageURL parses an unscoped package URL
|
|
// Patterns:
|
|
// - [package] -> package
|
|
// - [package, version] -> package@version
|
|
// - [package, -, tarball.tgz] -> package@version (extract from tarball)
|
|
func parseUnscopedPackageURL(segments []string) (*npmPackageInfo, error) {
|
|
if len(segments) == 0 {
|
|
return nil, fmt.Errorf("invalid unscoped package URL: no segments")
|
|
}
|
|
|
|
packageName := segments[0]
|
|
|
|
info := &npmPackageInfo{
|
|
Name: packageName,
|
|
IsScoped: false,
|
|
}
|
|
|
|
// Just the package name: /package
|
|
if len(segments) == 1 {
|
|
return info, nil
|
|
}
|
|
|
|
// Check if this is a tarball download: /package/-/package-1.0.0.tgz
|
|
if len(segments) == 3 && segments[1] == "-" {
|
|
tarballName := segments[2]
|
|
|
|
// Extract version from tarball filename
|
|
// Format: package-1.0.0.tgz
|
|
version, err := extractVersionFromTarball(packageName, tarballName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to extract version from tarball %s: %w", tarballName, err)
|
|
}
|
|
|
|
info.Version = version
|
|
info.IsTarball = true
|
|
return info, nil
|
|
}
|
|
|
|
// Version metadata: /package/1.0.0
|
|
if len(segments) == 2 {
|
|
info.Version = segments[1]
|
|
return info, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("invalid unscoped package URL format: unexpected number of segments %d", len(segments))
|
|
}
|
|
|
|
// extractVersionFromTarball extracts version from a tarball filename
|
|
// Expected format: package-name-1.0.0.tgz
|
|
func extractVersionFromTarball(packageName, tarballName string) (string, error) {
|
|
// Expected format: {packageName}-{version}.tgz
|
|
expectedPrefix := packageName + "-"
|
|
|
|
if !strings.HasPrefix(tarballName, expectedPrefix) {
|
|
return "", fmt.Errorf("tarball name %s does not match package name %s", tarballName, packageName)
|
|
}
|
|
|
|
if !strings.HasSuffix(tarballName, ".tgz") {
|
|
return "", fmt.Errorf("tarball name %s does not end with .tgz", tarballName)
|
|
}
|
|
|
|
// Extract version by removing prefix and suffix
|
|
version := strings.TrimPrefix(tarballName, expectedPrefix)
|
|
version = strings.TrimSuffix(version, ".tgz")
|
|
|
|
if version == "" {
|
|
return "", fmt.Errorf("could not extract version from tarball %s", tarballName)
|
|
}
|
|
|
|
return version, nil
|
|
}
|
|
|
|
// extractVersionFromScopedTarball extracts version from a scoped package tarball filename
|
|
// NPM registry uses two different formats for scoped package tarballs:
|
|
// Format 1: {scope}-{package}-{version}.tgz (e.g., types-node-18.0.0.tgz for @types/node)
|
|
// Format 2: {package}-{version}.tgz (e.g., studio-core-licensed-0.0.0.tgz for @prisma/studio-core-licensed)
|
|
func extractVersionFromScopedTarball(scope, packageName, tarballName string) (string, error) {
|
|
if !strings.HasSuffix(tarballName, ".tgz") {
|
|
return "", fmt.Errorf("tarball name %s does not end with .tgz", tarballName)
|
|
}
|
|
|
|
scopeWithoutAt := strings.TrimPrefix(scope, "@")
|
|
|
|
// Try Format 1: {scope}-{packageName}-{version}.tgz
|
|
expectedPrefixWithScope := scopeWithoutAt + "-" + packageName + "-"
|
|
if strings.HasPrefix(tarballName, expectedPrefixWithScope) {
|
|
version := strings.TrimPrefix(tarballName, expectedPrefixWithScope)
|
|
version = strings.TrimSuffix(version, ".tgz")
|
|
|
|
if version == "" {
|
|
return "", fmt.Errorf("could not extract version from tarball %s", tarballName)
|
|
}
|
|
|
|
return version, nil
|
|
}
|
|
|
|
// Try Format 2: {packageName}-{version}.tgz
|
|
expectedPrefixWithoutScope := packageName + "-"
|
|
if strings.HasPrefix(tarballName, expectedPrefixWithoutScope) {
|
|
version := strings.TrimPrefix(tarballName, expectedPrefixWithoutScope)
|
|
version = strings.TrimSuffix(version, ".tgz")
|
|
|
|
if version == "" {
|
|
return "", fmt.Errorf("could not extract version from tarball %s", tarballName)
|
|
}
|
|
|
|
return version, nil
|
|
}
|
|
|
|
return "", fmt.Errorf("tarball name %s does not match expected formats for scoped package %s/%s", tarballName, scope, packageName)
|
|
}
|