feat: Add Support for Proxy Based Npm Interceptor (#87)

* feat: Add experimental proxy based npm interceptor

* refactor: Analysis cache

* ci: Add E2E for npm proxy

* fix: Handle dry-run in proxy flow

* fix: Handle special case for scope package name

* fix: Misc fixes

* fix: Code review fixes

* fix: Code review fixes

* refactor: Reusable code into base registry interceptor

* Pause npm process during user confirmation (#90)

* pause npm process when prompting user for confirmation

* disable progress bar

* fix logging and close chan on return

* update use of deprecated field

* refactor: Separation of concerns for handling process state

* fix: Safe permission for cert file

* fix: Handle nil check for interaction hook

* fix: Add test for base registry

* Fix goreleaser for windows build (#93)

* introduce platform specific process control

* rename common.go to common_flow.go

* feat: Add support for pause resume on windows

* fix: Code review fixes

* test: Add confirmation handler tests

---------

Co-authored-by: Sahil Bansal <bansalsahil315@gmail.com>
This commit is contained in:
Abhisek Datta
2026-01-07 13:22:08 +05:30
committed by GitHub
co-authored by Sahil Bansal
parent 20c854e473
commit 779deeb23d
24 changed files with 2373 additions and 116 deletions
+18
View File
@@ -35,6 +35,24 @@ jobs:
with:
token: ${{ secrets.CODECOV_TOKEN }}
test-windows:
timeout-minutes: 15
runs-on: windows-latest
steps:
- name: Checkout Source
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Set up Go
uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5
with:
go-version-file: go.mod
- name: Build and Test
run: |
go mod tidy
go build -o pmg.exe main.go
go test -v ./internal/flows/...
e2e-test:
runs-on: ubuntu-latest
timeout-minutes: 15
+43
View File
@@ -101,6 +101,49 @@ jobs:
test -d node_modules/lodash
cd .. && rm -rf npm-test
- name: Test NPM - Experimental Proxy Mode
run: |
echo "Testing NPM with experimental proxy-based interception..."
mkdir npm-proxy-test && cd npm-proxy-test
pmg npm init -y
echo "Testing proxy mode single package installation..."
pmg --experimental-proxy-mode npm install express@5.2.1
pmg --experimental-proxy-mode npm install lodash@4.17.21
# Verification: packages installed via proxy mode
test -d node_modules/express
test -d node_modules/lodash
grep -q '"express"' package.json
grep -q '"lodash"' package.json
echo "Testing proxy mode manifest installation..."
rm -rf node_modules package-lock.json
pmg --experimental-proxy-mode npm install
# Verification: manifest install via proxy mode works
test -f package-lock.json
test -d node_modules/express
test -d node_modules/lodash
echo "Testing proxy mode with scoped package..."
pmg --experimental-proxy-mode npm install @types/node@18.0.0
# Verification: scoped package installed via proxy
test -d node_modules/@types
test -d node_modules/@types/node
grep -q '"@types/node"' package.json
echo "Testing proxy mode dry-run (should not create files)..."
rm -rf node_modules package-lock.json
pmg --experimental-proxy-mode --dry-run npm install
# Verification: dry-run doesn't create files even in proxy mode
test ! -d node_modules
test ! -f package-lock.json
cd .. && rm -rf npm-proxy-test
- name: Test PNPM - Single Package & Manifest
run: |
echo "Testing PNPM single package installation..."
+51 -26
View File
@@ -47,30 +47,33 @@ uv pip install <package-name>
## 📑 Table of Contents
- [Package Manager Guard (PMG)](#package-manager-guard-pmg)
- [🔥 PMG in Action](#-pmg-in-action)
- [📦 TL;DR](#-tldr)
- [📑 Table of Contents](#-table-of-contents)
- [🔥 Features](#-features)
- [Supported Package Managers](#supported-package-managers)
- [Installation](#installation)
- [Homebrew](#homebrew)
- [Binaries](#binaries)
- [Build from Source](#build-from-source)
- [Setup](#setup)
- [Install Aliases](#install-aliases)
- [Remove Aliases](#remove-aliases)
- [Usage](#usage)
- [Recommended: Automated Setup](#recommended-automated-setup)
- [Alternative: Manual Commands](#alternative-manual-commands)
- [Lockfile Installation](#lockfile-installation)
- [Silent Mode](#silent-mode)
- [Dry Run](#dry-run)
- [Verbose Mode](#verbose-mode)
- [Debugging](#debugging)
- [Environment Variables](#environment-variables)
- [🤝 Contributing](#-contributing)
- [🚫 Limitations](#-limitations)
- [Package Manager Guard (PMG)](#package-manager-guard-pmg)
- [🔥 PMG in Action](#-pmg-in-action)
- [📦 TL;DR](#-tldr)
- [📑 Table of Contents](#-table-of-contents)
- [🔥 Features](#-features)
- [Supported Package Managers](#supported-package-managers)
- [Installation](#installation)
- [Homebrew](#homebrew)
- [Binaries](#binaries)
- [Build from Source](#build-from-source)
- [Setup](#setup)
- [Install Aliases](#install-aliases)
- [Remove Aliases](#remove-aliases)
- [Usage](#usage)
- [Recommended: Automated Setup](#recommended-automated-setup)
- [Alternative: Manual Commands](#alternative-manual-commands)
- [Lockfile Installation](#lockfile-installation)
- [Active Scanning](#active-scanning)
- [Silent Mode](#silent-mode)
- [Dry Run](#dry-run)
- [Verbose Mode](#verbose-mode)
- [Debugging](#debugging)
- [Environment Variables](#environment-variables)
- [PMG\_INSECURE\_INSTALLATION](#pmg_insecure_installation)
- [🤝 Contributing](#-contributing)
- [🚫 Limitations](#-limitations)
- [Telemetry](#telemetry)
## 🔥 Features
@@ -84,8 +87,8 @@ uv pip install <package-name>
PMG supports the following package managers:
| Package Manager | Status | Command |
| --------------- | --------- | -------------------------------------------------------- |
| Package Manager | Status | Command |
| --------------- | -------- | -------------------------------------------------------- |
| `npm` | ✅ Active | `pmg npm install <package>` |
| `pnpm` | ✅ Active | `pmg pnpm add <package>` |
| `bun` | ✅ Active | `pmg bun add <package>` |
@@ -309,6 +312,28 @@ from non-PyPI locations.
</details>
<details>
<summary>Windows support for experimental proxy mode</summary>
PMG's experimental proxy mode (enabled with `--experimental-proxy-mode` flag) is supported on Windows
with the following considerations:
**Process Suspension**: PMG uses the Windows NT API (NtSuspendProcess/NtResumeProcess) to pause the
package manager during security prompts. This prevents console output from being mixed with interactive
prompts. While this API is widely used by tools like Process Explorer and is stable, it has some known
limitations:
- **Race condition**: Threads created during suspension are not suspended (very unlikely for brief suspensions)
- **Permission requirements**: Requires Windows Vista or later (all currently supported Windows versions)
- **Antivirus detection**: Some antivirus software may flag the use of NtSuspendProcess as suspicious behavior,
which is a false positive (PMG only uses it to pause its own child processes during user interaction)
**Fallback Behavior**: If process suspension fails (e.g., due to insufficient permissions), PMG continues
with the security prompt but console output from the package manager may be mixed with the prompt text.
This doesn't affect security functionality.
</details>
## Telemetry
`pmg` collects anonymous telemetry to help us understand how it is used and
+4
View File
@@ -51,5 +51,9 @@ func executeNpmFlow(ctx context.Context, args []string) error {
return fmt.Errorf("failed to create dependency resolver: %w", err)
}
if config.Config.ExperimentalProxyMode {
return flows.ProxyFlow(packageManager, packageResolver).Run(ctx, args, parsedCommand)
}
return flows.Common(packageManager, packageResolver).Run(ctx, args, parsedCommand)
}
+2
View File
@@ -19,4 +19,6 @@ func ApplyCobraFlags(cmd *cobra.Command) {
globalConfig.Config.Paranoid, "Perform active scanning of unknown packages (slow)")
cmd.PersistentFlags().BoolVar(&globalConfig.Config.SkipEventLogging, "skip-event-log",
globalConfig.Config.SkipEventLogging, "Skip event logging")
cmd.PersistentFlags().BoolVar(&globalConfig.Config.ExperimentalProxyMode, "experimental-proxy-mode",
globalConfig.Config.ExperimentalProxyMode, "Use experimental proxy-based interception (EXPERIMENTAL)")
}
+5
View File
@@ -52,6 +52,10 @@ type Config struct {
// EventLogRetentionDays is the number of days to retain event logs.
EventLogRetentionDays int `mapstructure:"event_log_retention_days"`
// ExperimentalProxyMode enables experimental proxy-based package interception.
// When enabled, PMG starts a proxy server and intercepts package manager requests in real-time.
ExperimentalProxyMode bool `mapstructure:"experimental_proxy_mode"`
}
// TrustedPackage is a package that is trusted by the user and will be ignored by the security guardrails.
@@ -106,6 +110,7 @@ func DefaultConfig() RuntimeConfig {
Paranoid: false,
EventLogRetentionDays: 7,
SkipEventLogging: false,
ExperimentalProxyMode: false,
TrustedPackages: []TrustedPackage{},
},
DryRun: false,
+7
View File
@@ -23,6 +23,13 @@ skip_event_logging: false
# This is the number of days to retain event logs.
event_log_retention_days: 7
# Experimental proxy mode. Default is false.
# When enabled, PMG uses an experimental proxy-based interception approach instead of the
# default guard-based analysis. The proxy intercepts package manager requests in real-time
# and analyzes packages as they are downloaded. This is an experimental feature and may not
# work in all environments.
experimental_proxy_mode: false
# Trusted packages are packages that are trusted by the user and will be ignored by the security guardrails.
# This is useful for packages that are known to be safe and are used in the application.
# Example:
+34 -89
View File
@@ -26,21 +26,17 @@ func TestLoggerInitialization(t *testing.T) {
// Initialize logger
err := InitializeWithDir(logDir)
if err != nil {
t.Fatalf("Failed to initialize logger: %v", err)
}
assert.NoError(t, err, "Failed to initialize logger")
defer Close()
// Check that directory was created
if _, err := os.Stat(logDir); os.IsNotExist(err) {
t.Errorf("Log directory was not created: %s", logDir)
}
_, err = os.Stat(logDir)
assert.False(t, os.IsNotExist(err), "Log directory was not created: %s", logDir)
// Check that log file was created
expectedLogFile := filepath.Join(logDir, time.Now().Format("20060102")+"-pmg.log")
if _, err := os.Stat(expectedLogFile); os.IsNotExist(err) {
t.Errorf("Log file was not created: %s", expectedLogFile)
}
_, err = os.Stat(expectedLogFile)
assert.False(t, os.IsNotExist(err), "Log file was not created: %s", expectedLogFile)
}
func TestLogEvent(t *testing.T) {
@@ -50,9 +46,7 @@ func TestLogEvent(t *testing.T) {
// Initialize logger
err := reinitializeForTest(logDir)
if err != nil {
t.Fatalf("Failed to initialize logger: %v", err)
}
assert.NoError(t, err, "Failed to initialize logger")
defer Close()
// Log an event
@@ -68,31 +62,21 @@ func TestLogEvent(t *testing.T) {
}
err = LogEvent(event)
if err != nil {
t.Fatalf("Failed to log event: %v", err)
}
assert.NoError(t, err, "Failed to log event")
// Read the log file and verify the event was written
logFilePath := filepath.Join(logDir, time.Now().Format("20060102")+"-pmg.log")
data, err := os.ReadFile(logFilePath)
if err != nil {
t.Fatalf("Failed to read log file: %v", err)
}
assert.NoError(t, err, "Failed to read log file")
// Parse the JSON
var loggedEvent Event
err = json.Unmarshal(data, &loggedEvent)
if err != nil {
t.Fatalf("Failed to parse logged event: %v", err)
}
assert.NoError(t, err, "Failed to parse logged event")
// Verify the event
if loggedEvent.EventType != EventTypeMalwareBlocked {
t.Errorf("Expected event type %s, got %s", EventTypeMalwareBlocked, loggedEvent.EventType)
}
if loggedEvent.PackageName != "evil-package" {
t.Errorf("Expected package name 'evil-package', got '%s'", loggedEvent.PackageName)
}
assert.Equal(t, EventTypeMalwareBlocked, loggedEvent.EventType)
assert.Equal(t, "evil-package", loggedEvent.PackageName)
}
func TestLogMalwareBlocked(t *testing.T) {
@@ -102,9 +86,7 @@ func TestLogMalwareBlocked(t *testing.T) {
// Initialize logger
err := reinitializeForTest(logDir)
if err != nil {
t.Fatalf("Failed to initialize logger: %v", err)
}
assert.NoError(t, err, "Failed to initialize logger")
defer Close()
// Log malware blocked event
@@ -113,25 +95,15 @@ func TestLogMalwareBlocked(t *testing.T) {
// Read and verify
logFilePath := filepath.Join(logDir, time.Now().Format("20060102")+"-pmg.log")
data, err := os.ReadFile(logFilePath)
if err != nil {
t.Fatalf("Failed to read log file: %v", err)
}
assert.NoError(t, err, "Failed to read log file")
var event Event
err = json.Unmarshal(data, &event)
if err != nil {
t.Fatalf("Failed to parse event: %v", err)
}
assert.NoError(t, err, "Failed to parse event")
if event.EventType != EventTypeMalwareBlocked {
t.Errorf("Expected event type %s, got %s", EventTypeMalwareBlocked, event.EventType)
}
if event.PackageName != "malicious-pkg" {
t.Errorf("Expected package 'malicious-pkg', got '%s'", event.PackageName)
}
if event.Ecosystem != "pypi" {
t.Errorf("Expected ecosystem 'pypi', got '%s'", event.Ecosystem)
}
assert.Equal(t, EventTypeMalwareBlocked, event.EventType)
assert.Equal(t, "malicious-pkg", event.PackageName)
assert.Equal(t, "pypi", event.Ecosystem)
}
func TestInitializeWithFile(t *testing.T) {
@@ -148,9 +120,7 @@ func TestInitializeWithFile(t *testing.T) {
// Reset for custom file
once = sync.Once{}
err = InitializeWithFile(logFile)
if err != nil {
t.Fatalf("Failed to initialize logger with file: %v", err)
}
assert.NoError(t, err, "Failed to initialize logger with file")
defer Close()
// Log an event
@@ -163,33 +133,20 @@ func TestInitializeWithFile(t *testing.T) {
}
err = LogEvent(event)
if err != nil {
t.Fatalf("Failed to log event: %v", err)
}
assert.NoError(t, err, "Failed to log event")
// Verify the custom log file was created and contains the event
if _, err := os.Stat(logFile); os.IsNotExist(err) {
t.Errorf("Custom log file was not created: %s", logFile)
}
_, err = os.Stat(logFile)
assert.False(t, os.IsNotExist(err), "Custom log file was not created: %s", logFile)
data, err := os.ReadFile(logFile)
if err != nil {
t.Fatalf("Failed to read custom log file: %v", err)
}
if len(data) == 0 {
t.Error("Custom log file is empty")
}
assert.NoError(t, err, "Failed to read custom log file")
assert.NotEmpty(t, data, "Custom log file is empty")
var loggedEvent Event
err = json.Unmarshal(data, &loggedEvent)
if err != nil {
t.Fatalf("Failed to parse logged event: %v", err)
}
if loggedEvent.PackageName != "test-package" {
t.Errorf("Expected package 'test-package', got '%s'", loggedEvent.PackageName)
}
assert.NoError(t, err, "Failed to parse logged event")
assert.Equal(t, "test-package", loggedEvent.PackageName)
}
func TestCleanupOldLogs(t *testing.T) {
@@ -197,38 +154,28 @@ func TestCleanupOldLogs(t *testing.T) {
tmpDir := t.TempDir()
logDir := filepath.Join(tmpDir, ".pmg", "logs")
err := os.MkdirAll(logDir, 0755)
if err != nil {
t.Fatalf("Failed to create log directory: %v", err)
}
assert.NoError(t, err, "Failed to create log directory")
// Create old log files
oldDate := time.Now().AddDate(0, 0, -10)
oldLogFile := filepath.Join(logDir, oldDate.Format("20060102")+"-pmg.log")
err = os.WriteFile(oldLogFile, []byte("old log"), 0644)
if err != nil {
t.Fatalf("Failed to create old log file: %v", err)
}
assert.NoError(t, err, "Failed to create old log file")
// Change the modification time to make it appear old
oldTime := time.Now().AddDate(0, 0, -10)
err = os.Chtimes(oldLogFile, oldTime, oldTime)
if err != nil {
t.Fatalf("Failed to change file time: %v", err)
}
assert.NoError(t, err, "Failed to change file time")
// Create a recent log file
recentLogFile := filepath.Join(logDir, time.Now().Format("20060102")+"-pmg.log")
err = os.WriteFile(recentLogFile, []byte("recent log"), 0644)
if err != nil {
t.Fatalf("Failed to create recent log file: %v", err)
}
assert.NoError(t, err, "Failed to create recent log file")
// Initialize logger (which triggers cleanup)
logger := &fileWithRotationLogger{}
err = logger.init(logDir)
if err != nil {
t.Fatalf("Failed to initialize logger: %v", err)
}
assert.NoError(t, err, "Failed to initialize logger")
defer logger.Close()
@@ -236,12 +183,10 @@ func TestCleanupOldLogs(t *testing.T) {
time.Sleep(100 * time.Millisecond)
// Check that old file was deleted
if _, err := os.Stat(oldLogFile); !os.IsNotExist(err) {
t.Error("Old log file should have been deleted")
}
_, err = os.Stat(oldLogFile)
assert.True(t, os.IsNotExist(err), "Old log file should have been deleted")
// Check that recent file still exists
if _, err := os.Stat(recentLogFile); os.IsNotExist(err) {
t.Error("Recent log file should still exist")
}
_, err = os.Stat(recentLogFile)
assert.False(t, os.IsNotExist(err), "Recent log file should still exist")
}
+33
View File
@@ -0,0 +1,33 @@
//go:build !windows
// +build !windows
package flows
import (
"os/exec"
"syscall"
)
func platformPauseProcess(cmd *exec.Cmd) error {
if cmd == nil || cmd.Process == nil {
return nil
}
if err := cmd.Process.Signal(syscall.SIGSTOP); err != nil {
return err
}
return nil
}
func platformResumeProcess(cmd *exec.Cmd) error {
if cmd == nil || cmd.Process == nil {
return nil
}
if err := cmd.Process.Signal(syscall.SIGCONT); err != nil {
return err
}
return nil
}
+78
View File
@@ -0,0 +1,78 @@
//go:build windows
// +build windows
package flows
import (
"fmt"
"os/exec"
"golang.org/x/sys/windows"
)
var (
modntdll = windows.NewLazySystemDLL("ntdll.dll")
procNtSuspendProcess = modntdll.NewProc("NtSuspendProcess")
procNtResumeProcess = modntdll.NewProc("NtResumeProcess")
)
// platformPauseProcess suspends the process using Windows NT API.
//
// Known limitations:
// - Race condition: threads created during suspension are not suspended
// - Remote thread injection still possible (very rare)
//
// For PMG's use case (brief suspension during user prompts), these limitations
// are acceptable.
//
// References:
// - gopsutil: https://github.com/shirou/gopsutil/blob/master/process/process_windows.go
// - Analysis: https://github.com/diversenok/Suspending-Techniques
func platformPauseProcess(cmd *exec.Cmd) error {
if cmd == nil || cmd.Process == nil {
return nil
}
handle, err := windows.OpenProcess(
windows.PROCESS_SUSPEND_RESUME,
false,
uint32(cmd.Process.Pid),
)
if err != nil {
return fmt.Errorf("failed to open process for suspension: %w", err)
}
defer windows.CloseHandle(handle)
r1, _, _ := procNtSuspendProcess.Call(uintptr(handle))
if r1 != 0 {
return fmt.Errorf("NtSuspendProcess failed with NTSTATUS=0x%.8X", r1)
}
return nil
}
// platformResumeProcess resumes a suspended process using Windows NT API.
func platformResumeProcess(cmd *exec.Cmd) error {
if cmd == nil || cmd.Process == nil {
return nil
}
handle, err := windows.OpenProcess(
windows.PROCESS_SUSPEND_RESUME,
false,
uint32(cmd.Process.Pid),
)
if err != nil {
return fmt.Errorf("failed to open process for resumption: %w", err)
}
defer windows.CloseHandle(handle)
r1, _, _ := procNtResumeProcess.Call(uintptr(handle))
if r1 != 0 {
return fmt.Errorf("NtResumeProcess failed with NTSTATUS=0x%.8X", r1)
}
return nil
}
@@ -0,0 +1,114 @@
//go:build windows
// +build windows
package flows
import (
"os/exec"
"runtime"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestPlatformPauseResumeProcess(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("Windows-only test")
}
cmd := exec.Command("ping", "127.0.0.1", "-n", "60")
err := cmd.Start()
assert.NoError(t, err)
defer cmd.Process.Kill()
time.Sleep(100 * time.Millisecond)
err = platformPauseProcess(cmd)
assert.NoError(t, err)
time.Sleep(500 * time.Millisecond)
err = platformResumeProcess(cmd)
assert.NoError(t, err)
}
func TestPlatformPauseProcessNil(t *testing.T) {
err := platformPauseProcess(nil)
assert.NoError(t, err)
}
func TestPlatformResumeProcessNil(t *testing.T) {
err := platformResumeProcess(nil)
assert.NoError(t, err)
}
func TestPlatformPauseProcessNilProcess(t *testing.T) {
cmd := exec.Command("ping", "127.0.0.1")
err := platformPauseProcess(cmd)
assert.NoError(t, err)
}
func TestPlatformResumeProcessNilProcess(t *testing.T) {
cmd := exec.Command("ping", "127.0.0.1")
err := platformResumeProcess(cmd)
assert.NoError(t, err)
}
func TestPlatformPauseProcessExited(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("Windows-only test")
}
cmd := exec.Command("cmd", "/c", "exit 0")
err := cmd.Start()
assert.NoError(t, err)
defer cmd.Process.Kill()
err = platformPauseProcess(cmd)
assert.NoError(t, err)
}
func TestPlatformResumeProcessExited(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("Windows-only test")
}
cmd := exec.Command("cmd", "/c", "exit 0")
err := cmd.Start()
assert.NoError(t, err)
defer cmd.Process.Kill()
err = platformResumeProcess(cmd)
assert.NoError(t, err)
}
func TestPlatformPauseResumeMultipleTimes(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("Windows-only test")
}
cmd := exec.Command("ping", "127.0.0.1", "-n", "60")
err := cmd.Start()
assert.NoError(t, err)
defer cmd.Process.Kill()
time.Sleep(100 * time.Millisecond)
for i := 0; i < 3; i++ {
err = platformPauseProcess(cmd)
assert.NoError(t, err)
time.Sleep(100 * time.Millisecond)
err = platformResumeProcess(cmd)
assert.NoError(t, err)
time.Sleep(100 * time.Millisecond)
}
}
+279
View File
@@ -0,0 +1,279 @@
package flows
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"time"
"github.com/safedep/dry/log"
"github.com/safedep/pmg/analyzer"
"github.com/safedep/pmg/config"
"github.com/safedep/pmg/guard"
"github.com/safedep/pmg/internal/ui"
"github.com/safedep/pmg/packagemanager"
"github.com/safedep/pmg/proxy"
"github.com/safedep/pmg/proxy/certmanager"
"github.com/safedep/pmg/proxy/interceptors"
)
type proxyFlow struct {
pm packagemanager.PackageManager
packageResolver packagemanager.PackageResolver
}
// ProxyFlow creates a new proxy-based flow for package manager protection
func ProxyFlow(pm packagemanager.PackageManager, packageResolver packagemanager.PackageResolver) *proxyFlow {
return &proxyFlow{
pm: pm,
packageResolver: packageResolver,
}
}
// Run executes the proxy-based flow
func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagemanager.ParsedCommand) error {
cfg := config.Get()
// Check if dry-run mode is enabled
if cfg.DryRun {
ui.SetStatus("Running in dry-run mode (proxy mode)")
log.Infof("Dry-run mode: Would execute %s with experimental proxy protection", f.pm.Name())
log.Infof("Dry-run mode: Command would be: %s %v", parsedCmd.Command.Exe, parsedCmd.Command.Args)
ui.ClearStatus()
return nil
}
ui.SetStatus("Initializing experimental proxy mode...")
// Setup CA certificate for MITM
caCert, caCertPath, err := f.setupCACertificate()
if err != nil {
return fmt.Errorf("failed to setup CA certificate for proxy mode: %w", err)
}
defer func() {
// Clean up temporary CA certificate file
if caCertPath != "" {
if err := os.Remove(caCertPath); err != nil {
log.Errorf("Failed to remove CA certificate file: %v", err)
}
}
}()
// Create certificate manager
certMgr, err := f.createCertificateManager(caCert)
if err != nil {
return fmt.Errorf("failed to create certificate manager: %w", err)
}
// Create analyzer
malysisAnalyzer, err := f.createAnalyzer()
if err != nil {
return fmt.Errorf("failed to create analyzer: %w", err)
}
// Create analysis cache
cache := interceptors.NewInMemoryAnalysisCache()
// Create confirmation channel and start confirmation handler
confirmationChan := make(chan *interceptors.ConfirmationRequest, 10)
defer close(confirmationChan)
// Create interaction callbacks for user prompts
interaction := guard.PackageManagerGuardInteraction{
SetStatus: ui.SetStatus,
ClearStatus: ui.ClearStatus,
ShowWarning: ui.ShowWarning,
GetConfirmationOnMalware: ui.GetConfirmationOnMalware,
Block: ui.Block,
}
// Get the ecosystem from the package manager
ecosystem := f.pm.Ecosystem()
// Check if proxy mode is supported for this ecosystem
if !interceptors.IsSupported(ecosystem) {
return fmt.Errorf("proxy mode is not supported for %s", ecosystem.String())
}
// Create ecosystem-specific interceptor using factory
factory := interceptors.NewInterceptorFactory(malysisAnalyzer, cache, confirmationChan, interaction)
interceptor, err := factory.CreateInterceptor(ecosystem)
if err != nil {
return fmt.Errorf("failed to create interceptor for %s: %w", ecosystem.String(), err)
}
log.Debugf("Created %s interceptor for ecosystem %s", interceptor.Name(), ecosystem.String())
// Create and start proxy server
proxyServer, proxyAddr, err := f.createAndStartProxyServer(certMgr, interceptor)
if err != nil {
return fmt.Errorf("failed to start proxy server: %w", err)
}
// Ensure proxy is stopped on exit
defer func() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := proxyServer.Stop(shutdownCtx); err != nil {
log.Errorf("Failed to stop proxy server: %v", err)
}
}()
ui.ClearStatus()
log.Infof("Proxy server started on %s", proxyAddr)
log.Infof("Running %s with proxy protection enabled", f.pm.Name())
// Execute the package manager command with proxy environment variables
return f.executeWithProxy(ctx, parsedCmd, proxyAddr, caCertPath, confirmationChan, interaction)
}
// setupCACertificate generates or loads a CA certificate for MITM
func (f *proxyFlow) setupCACertificate() (*certmanager.Certificate, string, error) {
log.Debugf("Generating CA certificate for proxy MITM")
// Generate CA certificate
caConfig := certmanager.DefaultCertManagerConfig()
caCert, err := certmanager.GenerateCA(caConfig)
if err != nil {
return nil, "", fmt.Errorf("failed to generate CA certificate: %w", err)
}
// Write CA certificate to temporary file for package managers to trust
tempDir := os.TempDir()
caCertPath := filepath.Join(tempDir, fmt.Sprintf("pmg-ca-cert-%d.pem", os.Getpid()))
if err := os.WriteFile(caCertPath, caCert.Certificate, 0600); err != nil {
return nil, "", fmt.Errorf("failed to write CA certificate to %s: %w", caCertPath, err)
}
log.Debugf("CA certificate written to %s", caCertPath)
return caCert, caCertPath, nil
}
// createCertificateManager creates a certificate manager with the given CA certificate
func (f *proxyFlow) createCertificateManager(caCert *certmanager.Certificate) (certmanager.CertificateManager, error) {
caConfig := certmanager.DefaultCertManagerConfig()
certMgr, err := certmanager.NewCertificateManagerWithCA(caCert, caConfig)
if err != nil {
return nil, fmt.Errorf("failed to create certificate manager: %w", err)
}
return certMgr, nil
}
// createAnalyzer creates the malysis query analyzer
func (f *proxyFlow) createAnalyzer() (analyzer.PackageVersionAnalyzer, error) {
cfg := config.Get()
// Use paranoid mode (active scan) if enabled, otherwise use query mode
if cfg.Config.Paranoid {
log.Debugf("Creating malysis active scan analyzer (paranoid mode)")
return analyzer.NewMalysisActiveScanAnalyzer(analyzer.DefaultMalysisActiveScanAnalyzerConfig())
}
log.Debugf("Creating malysis query analyzer")
return analyzer.NewMalysisQueryAnalyzer(analyzer.MalysisQueryAnalyzerConfig{})
}
// createAndStartProxyServer creates and starts the proxy server with the given interceptor
func (f *proxyFlow) createAndStartProxyServer(
certMgr certmanager.CertificateManager,
interceptor proxy.Interceptor,
) (proxy.ProxyServer, string, error) {
proxyConfig := &proxy.ProxyConfig{
ListenAddr: "127.0.0.1:0",
CertManager: certMgr,
EnableMITM: true,
Interceptors: []proxy.Interceptor{interceptor},
ConnectTimeout: 30 * time.Second,
RequestTimeout: 5 * time.Minute,
}
proxyServer, err := proxy.NewProxyServer(proxyConfig)
if err != nil {
return nil, "", fmt.Errorf("failed to create proxy server: %w", err)
}
if err := proxyServer.Start(); err != nil {
return nil, "", fmt.Errorf("failed to start proxy server: %w", err)
}
proxyAddr := proxyServer.Address()
if proxyAddr == "" {
return nil, "", fmt.Errorf("proxy server started but address is empty")
}
return proxyServer, proxyAddr, nil
}
// executeWithProxy executes the package manager command with proxy environment variables
func (f *proxyFlow) executeWithProxy(ctx context.Context, parsedCmd *packagemanager.ParsedCommand,
proxyAddr, caCertPath string, confirmationChan chan *interceptors.ConfirmationRequest,
interaction guard.PackageManagerGuardInteraction,
) error {
// Build proxy URL
proxyURL := fmt.Sprintf("http://%s", proxyAddr)
// Create command
cmd := exec.CommandContext(ctx, parsedCmd.Command.Exe, parsedCmd.Command.Args...)
// Set proxy environment variables. This is what tells the executed command to use the proxy for communication.
// However, every package manager has its nuances and may require additional environment variables to be set.
cmd.Env = os.Environ()
cmd.Env = append(cmd.Env,
fmt.Sprintf("HTTP_PROXY=%s", proxyURL),
fmt.Sprintf("HTTPS_PROXY=%s", proxyURL),
fmt.Sprintf("NODE_EXTRA_CA_CERTS=%s", caCertPath),
fmt.Sprintf("http_proxy=%s", proxyURL),
fmt.Sprintf("https_proxy=%s", proxyURL),
fmt.Sprintf("SSL_CERT_FILE=%s", caCertPath),
fmt.Sprintf("REQUESTS_CA_BUNDLE=%s", caCertPath),
fmt.Sprintf("PIP_CERT=%s", caCertPath),
fmt.Sprintf("PIP_PROXY=%s", proxyURL),
"NPM_CONFIG_PROGRESS=false",
)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
log.Debugf("Executing command: %s %v", parsedCmd.Command.Exe, parsedCmd.Command.Args)
log.Debugf("Proxy environment: HTTP_PROXY=%s, HTTPS_PROXY=%s, NODE_EXTRA_CA_CERTS=%s", proxyURL, proxyURL, caCertPath)
// Start confirmation handler in goroutine. Use confirmation hooks to pause and resume the executed
// process to prevent stdout and stderr from being mixed up. Pause / resume is on a best effort basis.
// We do not consider it a critical error if pause / resume fails.
go interceptors.HandleConfirmationRequests(confirmationChan, interaction, &interceptors.ConfirmationHook{
BeforeInteraction: func([]*analyzer.PackageVersionAnalysisResult) error {
if err := platformPauseProcess(cmd); err != nil {
log.Warnf("Failed to pause process for user interaction: %v", err)
}
return nil
},
AfterInteraction: func([]*analyzer.PackageVersionAnalysisResult, bool) error {
if err := platformResumeProcess(cmd); err != nil {
log.Warnf("Failed to resume process after user interaction: %v", err)
}
return nil
},
})
err := cmd.Run()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
os.Exit(exitErr.ExitCode())
}
return fmt.Errorf("failed to execute %s: %w", f.pm.Name(), err)
}
log.Debugf("Command completed successfully")
return nil
}
+3 -1
View File
@@ -80,10 +80,12 @@ func (npm *npmPackageManager) ParseCommand(args []string) (*ParsedCommand, error
ManifestFiles: []string{},
}, nil
}
return &ParsedCommand{
Command: command,
}, nil
}
// Find the install command position
var installCmdIndex = -1
for idx, arg := range args {
@@ -108,7 +110,7 @@ func (npm *npmPackageManager) ParseCommand(args []string) (*ParsedCommand, error
flagSet := pflag.NewFlagSet(npm.Config.CommandName, pflag.ContinueOnError)
flagSet.SetOutput(io.Discard)
flagSet.ParseErrorsWhitelist.UnknownFlags = true
flagSet.ParseErrorsAllowlist.UnknownFlags = true
switch npm.Config.CommandName {
case "npm", "pnpm":
+168
View File
@@ -0,0 +1,168 @@
package interceptors
import (
"context"
"fmt"
"net/http"
"time"
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
"github.com/safedep/dry/log"
"github.com/safedep/pmg/analyzer"
"github.com/safedep/pmg/guard"
"github.com/safedep/pmg/proxy"
)
// baseRegistryInterceptor provides common functionality for registry interceptors
// It contains ecosystem-agnostic methods that can be reused by specific registry implementations
type baseRegistryInterceptor struct {
analyzer analyzer.PackageVersionAnalyzer
cache AnalysisCache
confirmationChan chan *ConfirmationRequest
interaction guard.PackageManagerGuardInteraction
}
var _ proxy.Interceptor = (*baseRegistryInterceptor)(nil)
// Name returns a default name - should be overridden by specific implementations
func (b *baseRegistryInterceptor) Name() string {
return "base-registry-interceptor"
}
// ShouldIntercept returns false by default - must be overridden by specific implementations
func (b *baseRegistryInterceptor) ShouldIntercept(ctx *proxy.RequestContext) bool {
return false
}
// HandleRequest returns allow by default - should be overridden by specific implementations
func (b *baseRegistryInterceptor) HandleRequest(ctx *proxy.RequestContext) (*proxy.InterceptorResponse, error) {
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
}
// analyzePackage analyzes a package using the configured analyzer with caching
// This method is ecosystem-agnostic and can be used by any registry interceptor
func (b *baseRegistryInterceptor) analyzePackage(
ctx *proxy.RequestContext,
ecosystem packagev1.Ecosystem,
packageName string,
packageVersion string,
) (*analyzer.PackageVersionAnalysisResult, error) {
if cached, ok := b.cache.Get(ecosystem.String(), packageName, packageVersion); ok {
log.Debugf("[%s] Using cached analysis result for %s@%s", ctx.RequestID, packageName, packageVersion)
return cached, nil
}
pkgVersion := &packagev1.PackageVersion{
Package: &packagev1.Package{
Ecosystem: ecosystem,
Name: packageName,
},
Version: packageVersion,
}
log.Debugf("[%s] Analyzing package %s@%s", ctx.RequestID, packageName, packageVersion)
analysisCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
result, err := b.analyzer.Analyze(analysisCtx, pkgVersion)
if err != nil {
return nil, fmt.Errorf("analyzer failed: %w", err)
}
b.cache.Set(ecosystem.String(), packageName, packageVersion, result)
log.Debugf("[%s] Analysis complete for %s@%s: action=%d", ctx.RequestID, packageName, packageVersion, result.Action)
return result, nil
}
// handleAnalysisResult processes the analysis result and returns appropriate response action
// This method is ecosystem agnostic and handles the analysis result uniformly
func (b *baseRegistryInterceptor) handleAnalysisResult(
ctx *proxy.RequestContext,
ecosystem packagev1.Ecosystem,
packageName string,
packageVersion string,
result *analyzer.PackageVersionAnalysisResult,
) (*proxy.InterceptorResponse, error) {
switch result.Action {
case analyzer.ActionBlock:
log.Warnf("[%s] Blocking malicious package %s@%s", ctx.RequestID, packageName, packageVersion)
message := fmt.Sprintf("Malicious package blocked: %s/%s@%s\n\nReason: %s\n\nReference: %s",
ecosystem.String(),
packageName, packageVersion,
result.Summary,
result.ReferenceURL)
return &proxy.InterceptorResponse{
Action: proxy.ActionBlock,
BlockCode: http.StatusForbidden,
BlockMessage: message,
}, nil
case analyzer.ActionConfirm:
log.Warnf("[%s] Package %s/%s@%s is suspicious, requesting user confirmation", ctx.RequestID, ecosystem.String(), packageName, packageVersion)
confirmed, err := b.requestUserConfirmation(ctx, result)
if err != nil {
log.Errorf("[%s] Failed to get user confirmation: %v", ctx.RequestID, err)
return &proxy.InterceptorResponse{
Action: proxy.ActionBlock,
BlockCode: http.StatusForbidden,
BlockMessage: fmt.Sprintf("Failed to get user confirmation for suspicious package %s/%s@%s", ecosystem.String(), packageName, packageVersion),
}, nil
}
if !confirmed {
log.Infof("[%s] User declined installation of suspicious package %s/%s@%s", ctx.RequestID, ecosystem.String(), packageName, packageVersion)
message := fmt.Sprintf("Installation blocked by user: %s/%s@%s\n\nReason: %s\n\nReference: %s",
ecosystem.String(),
packageName, packageVersion,
result.Summary,
result.ReferenceURL)
return &proxy.InterceptorResponse{
Action: proxy.ActionBlock,
BlockCode: http.StatusForbidden,
BlockMessage: message,
}, nil
}
log.Infof("[%s] User confirmed installation of suspicious package %s/%s@%s", ctx.RequestID, ecosystem.String(), packageName, packageVersion)
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
case analyzer.ActionAllow:
log.Debugf("[%s] Package %s/%s@%s is safe, allowing request", ctx.RequestID, ecosystem.String(), packageName, packageVersion)
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
default:
log.Warnf("[%s] Unknown analysis action %d for package %s/%s@%s, allowing by default", ctx.RequestID, result.Action, ecosystem.String(), packageName, packageVersion)
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
}
}
// requestUserConfirmation sends a confirmation request and blocks waiting for user response
func (b *baseRegistryInterceptor) requestUserConfirmation(
ctx *proxy.RequestContext,
result *analyzer.PackageVersionAnalysisResult,
) (bool, error) {
req := NewConfirmationRequest(result.PackageVersion, result)
select {
case b.confirmationChan <- req:
case <-time.After(5 * time.Second):
return false, fmt.Errorf("timeout sending confirmation request")
}
// Block waiting for user response
// Producer is responsible for closing the response channel to prevent goroutine leaks.
select {
case confirmed := <-req.ResponseChan:
return confirmed, nil
case <-time.After(5 * time.Minute):
return false, fmt.Errorf("timeout waiting for user confirmation")
}
}
+161
View File
@@ -0,0 +1,161 @@
package interceptors
import (
"net/http"
"net/url"
"testing"
"time"
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
"github.com/safedep/pmg/analyzer"
"github.com/safedep/pmg/guard"
"github.com/safedep/pmg/proxy"
"github.com/stretchr/testify/assert"
)
func TestBaseRegistryInterceptor_HandleAnalysisResult(t *testing.T) {
tests := []struct {
name string
ecosystem packagev1.Ecosystem
packageName string
packageVersion string
analysisResult *analyzer.PackageVersionAnalysisResult
userConfirms bool
expectedAction proxy.ResponseAction
expectedBlockCode int
expectBlockMessage bool
}{
{
name: "ActionBlock - malicious package",
ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM,
packageName: "malicious-pkg",
packageVersion: "1.0.0",
analysisResult: &analyzer.PackageVersionAnalysisResult{
Action: analyzer.ActionBlock,
Summary: "Contains known malware",
ReferenceURL: "https://example.com/malware-report",
},
expectedAction: proxy.ActionBlock,
expectedBlockCode: http.StatusForbidden,
expectBlockMessage: true,
},
{
name: "ActionConfirm - user confirms installation",
ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM,
packageName: "suspicious-pkg",
packageVersion: "2.0.0",
analysisResult: &analyzer.PackageVersionAnalysisResult{
Action: analyzer.ActionConfirm,
Summary: "Suspicious behavior detected",
ReferenceURL: "https://example.com/suspicious-report",
},
userConfirms: true,
expectedAction: proxy.ActionAllow,
expectedBlockCode: 0,
expectBlockMessage: false,
},
{
name: "ActionConfirm - user declines installation",
ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM,
packageName: "suspicious-pkg",
packageVersion: "2.0.0",
analysisResult: &analyzer.PackageVersionAnalysisResult{
Action: analyzer.ActionConfirm,
Summary: "Suspicious behavior detected",
ReferenceURL: "https://example.com/suspicious-report",
},
userConfirms: false,
expectedAction: proxy.ActionBlock,
expectedBlockCode: http.StatusForbidden,
expectBlockMessage: true,
},
// Note: Timeout test case is skipped as it would require waiting 5 minutes
// The timeout behavior is covered by the implementation but not tested here
// to keep tests fast
{
name: "ActionAllow - safe package",
ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM,
packageName: "safe-pkg",
packageVersion: "3.0.0",
analysisResult: &analyzer.PackageVersionAnalysisResult{
Action: analyzer.ActionAllow,
Summary: "Package is safe",
ReferenceURL: "https://example.com/safe-report",
},
expectedAction: proxy.ActionAllow,
expectedBlockCode: 0,
expectBlockMessage: false,
},
{
name: "ActionUnknown - default to allow",
ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM,
packageName: "unknown-pkg",
packageVersion: "4.0.0",
analysisResult: &analyzer.PackageVersionAnalysisResult{
Action: analyzer.ActionUnknown,
Summary: "Unknown action",
ReferenceURL: "https://example.com/unknown-report",
},
expectedAction: proxy.ActionAllow,
expectedBlockCode: 0,
expectBlockMessage: false,
},
{
name: "ActionBlock - pypi ecosystem",
ecosystem: packagev1.Ecosystem_ECOSYSTEM_PYPI,
packageName: "malicious-pypi-pkg",
packageVersion: "5.0.0",
analysisResult: &analyzer.PackageVersionAnalysisResult{
Action: analyzer.ActionBlock,
Summary: "Malicious PyPI package",
ReferenceURL: "https://example.com/pypi-malware",
},
expectedAction: proxy.ActionBlock,
expectedBlockCode: http.StatusForbidden,
expectBlockMessage: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
confirmationChan := make(chan *ConfirmationRequest, 1)
interaction := guard.PackageManagerGuardInteraction{}
base := &baseRegistryInterceptor{
confirmationChan: confirmationChan,
interaction: interaction,
}
parsedURL, _ := url.Parse("https://registry.npmjs.org/test")
ctx := &proxy.RequestContext{
URL: parsedURL,
Method: "GET",
Headers: make(http.Header),
RequestID: "test-request-id",
StartTime: time.Now(),
Data: make(map[string]interface{}),
}
if tt.analysisResult.Action == analyzer.ActionConfirm {
go func() {
req := <-confirmationChan
req.ResponseChan <- tt.userConfirms
close(req.ResponseChan)
}()
}
response, err := base.handleAnalysisResult(
ctx,
tt.ecosystem,
tt.packageName,
tt.packageVersion,
tt.analysisResult,
)
assert.NoError(t, err)
assert.Equal(t, tt.expectedAction, response.Action)
assert.Equal(t, tt.expectedBlockCode, response.BlockCode)
assert.Equal(t, tt.expectBlockMessage, response.BlockMessage != "")
})
}
}
+90
View File
@@ -0,0 +1,90 @@
package interceptors
import (
"fmt"
"sync"
"github.com/safedep/pmg/analyzer"
)
type AnalysisCache interface {
// Get retrieves a cached analysis result
Get(ecosystem, name, version string) (*analyzer.PackageVersionAnalysisResult, bool)
// Set stores an analysis result in the cache
Set(ecosystem, name, version string, result *analyzer.PackageVersionAnalysisResult)
}
type inMemoryAnalysisCache struct {
mu sync.RWMutex
cache map[string]*analyzer.PackageVersionAnalysisResult
}
var _ AnalysisCache = (*inMemoryAnalysisCache)(nil)
// NewInMemoryAnalysisCache creates a new in-memory analysis cache
func NewInMemoryAnalysisCache() *inMemoryAnalysisCache {
return &inMemoryAnalysisCache{
cache: make(map[string]*analyzer.PackageVersionAnalysisResult),
}
}
// cacheKey generates a cache key from ecosystem, name, and version
func (c *inMemoryAnalysisCache) cacheKey(ecosystem, name, version string) string {
return fmt.Sprintf("%s:%s:%s", ecosystem, name, version)
}
// Get retrieves a cached analysis result
// Returns the result and true if found, nil and false if not found
func (c *inMemoryAnalysisCache) Get(ecosystem, name, version string) (*analyzer.PackageVersionAnalysisResult, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
key := c.cacheKey(ecosystem, name, version)
result, ok := c.cache[key]
return result, ok
}
// Set stores an analysis result in the cache
func (c *inMemoryAnalysisCache) Set(ecosystem, name, version string, result *analyzer.PackageVersionAnalysisResult) {
c.mu.Lock()
defer c.mu.Unlock()
key := c.cacheKey(ecosystem, name, version)
c.cache[key] = result
}
// Clear removes all entries from the cache
func (c *inMemoryAnalysisCache) Clear() {
c.mu.Lock()
defer c.mu.Unlock()
c.cache = make(map[string]*analyzer.PackageVersionAnalysisResult)
}
// Size returns the number of entries in the cache
func (c *inMemoryAnalysisCache) Size() int {
c.mu.RLock()
defer c.mu.RUnlock()
return len(c.cache)
}
// Has checks if a cache entry exists for the given package
func (c *inMemoryAnalysisCache) Has(ecosystem, name, version string) bool {
c.mu.RLock()
defer c.mu.RUnlock()
key := c.cacheKey(ecosystem, name, version)
_, ok := c.cache[key]
return ok
}
// Delete removes a specific entry from the cache
func (c *inMemoryAnalysisCache) Delete(ecosystem, name, version string) {
c.mu.Lock()
defer c.mu.Unlock()
key := c.cacheKey(ecosystem, name, version)
delete(c.cache, key)
}
+334
View File
@@ -0,0 +1,334 @@
package interceptors
import (
"sync"
"testing"
"github.com/safedep/pmg/analyzer"
"github.com/stretchr/testify/assert"
)
func TestNewInMemoryAnalysisCache(t *testing.T) {
cache := NewInMemoryAnalysisCache()
assert.NotNil(t, cache)
assert.NotNil(t, cache.cache)
assert.Equal(t, 0, cache.Size())
}
func TestInMemoryAnalysisCache_SetAndGet(t *testing.T) {
tests := []struct {
name string
ecosystem string
pkgName string
version string
result *analyzer.PackageVersionAnalysisResult
}{
{
name: "npm package",
ecosystem: "npm",
pkgName: "lodash",
version: "4.17.21",
result: &analyzer.PackageVersionAnalysisResult{},
},
{
name: "pypi package",
ecosystem: "pypi",
pkgName: "requests",
version: "2.28.0",
result: &analyzer.PackageVersionAnalysisResult{},
},
{
name: "package with special characters",
ecosystem: "npm",
pkgName: "@babel/core",
version: "7.20.0",
result: &analyzer.PackageVersionAnalysisResult{},
},
{
name: "package with empty version",
ecosystem: "npm",
pkgName: "test-pkg",
version: "",
result: &analyzer.PackageVersionAnalysisResult{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cache := NewInMemoryAnalysisCache()
// Set value
cache.Set(tt.ecosystem, tt.pkgName, tt.version, tt.result)
// Get value
result, ok := cache.Get(tt.ecosystem, tt.pkgName, tt.version)
assert.True(t, ok)
assert.Equal(t, tt.result, result)
})
}
}
func TestInMemoryAnalysisCache_GetNonExistent(t *testing.T) {
cache := NewInMemoryAnalysisCache()
result, ok := cache.Get("npm", "nonexistent", "1.0.0")
assert.False(t, ok)
assert.Nil(t, result)
}
func TestInMemoryAnalysisCache_Has(t *testing.T) {
tests := []struct {
name string
setup func(*inMemoryAnalysisCache)
ecosystem string
pkgName string
version string
want bool
}{
{
name: "existing entry",
setup: func(c *inMemoryAnalysisCache) {
c.Set("npm", "lodash", "4.17.21", &analyzer.PackageVersionAnalysisResult{})
},
ecosystem: "npm",
pkgName: "lodash",
version: "4.17.21",
want: true,
},
{
name: "non-existing entry",
setup: func(c *inMemoryAnalysisCache) {},
ecosystem: "npm",
pkgName: "nonexistent",
version: "1.0.0",
want: false,
},
{
name: "different version",
setup: func(c *inMemoryAnalysisCache) {
c.Set("npm", "lodash", "4.17.21", &analyzer.PackageVersionAnalysisResult{})
},
ecosystem: "npm",
pkgName: "lodash",
version: "4.17.20",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cache := NewInMemoryAnalysisCache()
tt.setup(cache)
got := cache.Has(tt.ecosystem, tt.pkgName, tt.version)
assert.Equal(t, tt.want, got)
})
}
}
func TestInMemoryAnalysisCache_Delete(t *testing.T) {
tests := []struct {
name string
setup func(*inMemoryAnalysisCache)
deleteEco string
deletePkg string
deleteVer string
expectedSize int
shouldExist bool
}{
{
name: "delete existing entry",
setup: func(c *inMemoryAnalysisCache) {
c.Set("npm", "lodash", "4.17.21", &analyzer.PackageVersionAnalysisResult{})
c.Set("npm", "axios", "1.0.0", &analyzer.PackageVersionAnalysisResult{})
},
deleteEco: "npm",
deletePkg: "lodash",
deleteVer: "4.17.21",
expectedSize: 1,
shouldExist: false,
},
{
name: "delete non-existing entry",
setup: func(c *inMemoryAnalysisCache) {
c.Set("npm", "lodash", "4.17.21", &analyzer.PackageVersionAnalysisResult{})
},
deleteEco: "npm",
deletePkg: "nonexistent",
deleteVer: "1.0.0",
expectedSize: 1,
shouldExist: false,
},
{
name: "delete from empty cache",
setup: func(c *inMemoryAnalysisCache) {},
deleteEco: "npm",
deletePkg: "lodash",
deleteVer: "4.17.21",
expectedSize: 0,
shouldExist: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cache := NewInMemoryAnalysisCache()
tt.setup(cache)
cache.Delete(tt.deleteEco, tt.deletePkg, tt.deleteVer)
assert.Equal(t, tt.expectedSize, cache.Size())
assert.Equal(t, tt.shouldExist, cache.Has(tt.deleteEco, tt.deletePkg, tt.deleteVer))
})
}
}
func TestInMemoryAnalysisCache_Clear(t *testing.T) {
cache := NewInMemoryAnalysisCache()
// Add multiple entries
cache.Set("npm", "lodash", "4.17.21", &analyzer.PackageVersionAnalysisResult{})
cache.Set("npm", "axios", "1.0.0", &analyzer.PackageVersionAnalysisResult{})
cache.Set("pypi", "requests", "2.28.0", &analyzer.PackageVersionAnalysisResult{})
assert.Equal(t, 3, cache.Size())
// Clear cache
cache.Clear()
assert.Equal(t, 0, cache.Size())
assert.False(t, cache.Has("npm", "lodash", "4.17.21"))
assert.False(t, cache.Has("npm", "axios", "1.0.0"))
assert.False(t, cache.Has("pypi", "requests", "2.28.0"))
}
func TestInMemoryAnalysisCache_Size(t *testing.T) {
tests := []struct {
name string
setup func(*inMemoryAnalysisCache)
wantSize int
}{
{
name: "empty cache",
setup: func(c *inMemoryAnalysisCache) {},
wantSize: 0,
},
{
name: "single entry",
setup: func(c *inMemoryAnalysisCache) {
c.Set("npm", "lodash", "4.17.21", &analyzer.PackageVersionAnalysisResult{})
},
wantSize: 1,
},
{
name: "multiple entries",
setup: func(c *inMemoryAnalysisCache) {
c.Set("npm", "lodash", "4.17.21", &analyzer.PackageVersionAnalysisResult{})
c.Set("npm", "axios", "1.0.0", &analyzer.PackageVersionAnalysisResult{})
c.Set("pypi", "requests", "2.28.0", &analyzer.PackageVersionAnalysisResult{})
},
wantSize: 3,
},
{
name: "overwrite same entry",
setup: func(c *inMemoryAnalysisCache) {
c.Set("npm", "lodash", "4.17.21", &analyzer.PackageVersionAnalysisResult{})
c.Set("npm", "lodash", "4.17.21", &analyzer.PackageVersionAnalysisResult{})
},
wantSize: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cache := NewInMemoryAnalysisCache()
tt.setup(cache)
assert.Equal(t, tt.wantSize, cache.Size())
})
}
}
func TestInMemoryAnalysisCache_CacheKeyUniqueness(t *testing.T) {
cache := NewInMemoryAnalysisCache()
// Add entries with different combinations
cache.Set("npm", "lodash", "4.17.21", &analyzer.PackageVersionAnalysisResult{})
cache.Set("npm", "lodash", "4.17.20", &analyzer.PackageVersionAnalysisResult{})
cache.Set("pypi", "lodash", "4.17.21", &analyzer.PackageVersionAnalysisResult{})
// All should be unique entries
assert.Equal(t, 3, cache.Size())
assert.True(t, cache.Has("npm", "lodash", "4.17.21"))
assert.True(t, cache.Has("npm", "lodash", "4.17.20"))
assert.True(t, cache.Has("pypi", "lodash", "4.17.21"))
}
func TestInMemoryAnalysisCache_Concurrent(t *testing.T) {
cache := NewInMemoryAnalysisCache()
const numGoroutines = 100
const numOperations = 10
var wg sync.WaitGroup
wg.Add(numGoroutines)
// Run concurrent operations
for i := 0; i < numGoroutines; i++ {
go func(id int) {
defer wg.Done()
for j := 0; j < numOperations; j++ {
ecosystem := "npm"
pkgName := "pkg"
version := string(rune('0' + (id % 10)))
// Mix of operations
cache.Set(ecosystem, pkgName, version, &analyzer.PackageVersionAnalysisResult{})
cache.Get(ecosystem, pkgName, version)
cache.Has(ecosystem, pkgName, version)
cache.Size()
if j%5 == 0 {
cache.Delete(ecosystem, pkgName, version)
}
}
}(i)
}
wg.Wait()
// Cache should be in a valid state (no race conditions)
size := cache.Size()
assert.GreaterOrEqual(t, size, 0)
assert.LessOrEqual(t, size, numGoroutines*numOperations)
}
func TestInMemoryAnalysisCache_UpdateExistingEntry(t *testing.T) {
cache := NewInMemoryAnalysisCache()
// Set initial value
result1 := &analyzer.PackageVersionAnalysisResult{
Summary: "First analysis",
}
cache.Set("npm", "lodash", "4.17.21", result1)
// Get and verify
got1, ok := cache.Get("npm", "lodash", "4.17.21")
assert.True(t, ok)
assert.Equal(t, result1, got1)
// Update with new value
result2 := &analyzer.PackageVersionAnalysisResult{
Summary: "Updated analysis",
}
cache.Set("npm", "lodash", "4.17.21", result2)
// Verify updated value
got2, ok := cache.Get("npm", "lodash", "4.17.21")
assert.True(t, ok)
assert.Equal(t, result2, got2)
assert.NotEqual(t, result1, result2) // Should be different objects with different values
// Size should still be 1
assert.Equal(t, 1, cache.Size())
}
+95
View File
@@ -0,0 +1,95 @@
package interceptors
import (
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
"github.com/safedep/dry/log"
"github.com/safedep/pmg/analyzer"
"github.com/safedep/pmg/guard"
)
// ConfirmationRequest represents a request for user confirmation on a suspicious package
type ConfirmationRequest struct {
PackageVersion *packagev1.PackageVersion
AnalysisResult *analyzer.PackageVersionAnalysisResult
// ResponseChan is used to send the user's response back.
ResponseChan chan bool
}
// ConfirmationHook is a set of hooks that can be used to customize the confirmation process.
type ConfirmationHook struct {
// BeforeInteraction is called before the user interaction is started.
BeforeInteraction func([]*analyzer.PackageVersionAnalysisResult) error
// AfterInteraction is called after the user interaction is finished.
AfterInteraction func([]*analyzer.PackageVersionAnalysisResult, bool) error
}
// HandleConfirmationRequests processes confirmation requests sequentially
// This function should be run in a goroutine and will process requests
// from the confirmation channel one at a time, blocking on user input.
//
// The function will exit when the confirmation channel is closed.
func HandleConfirmationRequests(confirmationChan chan *ConfirmationRequest,
interaction guard.PackageManagerGuardInteraction, hooks *ConfirmationHook) {
if hooks == nil {
hooks = &ConfirmationHook{}
}
for req := range confirmationChan {
func() {
// The default response is false ie. user did not confirm the installation.
// The code here falls through and eventually sets this flag to true if user
// confirms the installation.
responseVal := false
// We must make sure to close the response channel to prevent goroutine leaks.
// Idiomatic go suggests that the writer should close the channel.
defer func() {
req.ResponseChan <- responseVal
close(req.ResponseChan)
}()
packageName := req.PackageVersion.GetPackage().GetName()
log.Debugf("Processing confirmation request for package %s", packageName)
// Hook to allow the caller to customize the confirmation process.
// Hook failures are non-fatal and will not break the confirmation process.
if hooks.BeforeInteraction != nil {
if err := hooks.BeforeInteraction([]*analyzer.PackageVersionAnalysisResult{req.AnalysisResult}); err != nil {
log.Errorf("Error before interaction for package %s: %v", packageName, err)
}
}
// Call the user interaction handler to get confirmation
// This blocks waiting for stdin input
confirmed, confirmationErr := interaction.GetConfirmationOnMalware([]*analyzer.PackageVersionAnalysisResult{req.AnalysisResult})
// Must guarantee to call the after interaction hook regardless of the confirmation error.
if hooks.AfterInteraction != nil {
if err := hooks.AfterInteraction([]*analyzer.PackageVersionAnalysisResult{req.AnalysisResult}, confirmed); err != nil {
log.Errorf("Error after interaction for package %s: %v", packageName, err)
}
}
if confirmationErr != nil {
log.Errorf("Error getting confirmation for package %s: %v", packageName, confirmationErr)
return
}
// Set the response value to the user's confirmation
responseVal = confirmed
}()
}
log.Debugf("Confirmation handler exiting (channel closed)")
}
// NewConfirmationRequest creates a new confirmation request with a response channel
func NewConfirmationRequest(pkgVersion *packagev1.PackageVersion, result *analyzer.PackageVersionAnalysisResult) *ConfirmationRequest {
return &ConfirmationRequest{
PackageVersion: pkgVersion,
AnalysisResult: result,
ResponseChan: make(chan bool, 1),
}
}
+208
View File
@@ -0,0 +1,208 @@
package interceptors
import (
"errors"
"testing"
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
"github.com/safedep/pmg/analyzer"
"github.com/safedep/pmg/guard"
"github.com/stretchr/testify/assert"
)
func mockPackageVersion(name, version string) *packagev1.PackageVersion {
return &packagev1.PackageVersion{
Package: &packagev1.Package{Name: name},
Version: version,
}
}
func mockAnalysisResult() *analyzer.PackageVersionAnalysisResult {
return &analyzer.PackageVersionAnalysisResult{
Action: analyzer.ActionConfirm,
Summary: "Test suspicious package",
}
}
func TestHandleConfirmationRequests(t *testing.T) {
tests := []struct {
name string
confirmationResponse bool
confirmationError error
beforeInteractionErr error
afterInteractionErr error
useNilHooks bool
expectedResponse bool
verifyHooksCalled bool
}{
{
name: "user confirms installation",
confirmationResponse: true,
confirmationError: nil,
useNilHooks: false,
expectedResponse: true,
verifyHooksCalled: true,
},
{
name: "user denies installation",
confirmationResponse: false,
confirmationError: nil,
useNilHooks: false,
expectedResponse: false,
verifyHooksCalled: true,
},
{
name: "confirmation error returns false",
confirmationResponse: false,
confirmationError: errors.New("confirmation failed"),
useNilHooks: false,
expectedResponse: false,
verifyHooksCalled: true,
},
{
name: "before interaction hook error is non-fatal",
confirmationResponse: true,
confirmationError: nil,
beforeInteractionErr: errors.New("before hook failed"),
useNilHooks: false,
expectedResponse: true,
verifyHooksCalled: true,
},
{
name: "after interaction hook error is non-fatal",
confirmationResponse: true,
confirmationError: nil,
afterInteractionErr: errors.New("after hook failed"),
useNilHooks: false,
expectedResponse: true,
verifyHooksCalled: true,
},
{
name: "nil hooks does not panic",
confirmationResponse: true,
confirmationError: nil,
useNilHooks: true,
expectedResponse: true,
verifyHooksCalled: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
beforeCalled := false
afterCalled := false
var afterConfirmedParam bool
interaction := guard.PackageManagerGuardInteraction{
GetConfirmationOnMalware: func(results []*analyzer.PackageVersionAnalysisResult) (bool, error) {
assert.Len(t, results, 1)
return tt.confirmationResponse, tt.confirmationError
},
}
var hooks *ConfirmationHook
if !tt.useNilHooks {
hooks = &ConfirmationHook{
BeforeInteraction: func(results []*analyzer.PackageVersionAnalysisResult) error {
beforeCalled = true
assert.Len(t, results, 1)
return tt.beforeInteractionErr
},
AfterInteraction: func(results []*analyzer.PackageVersionAnalysisResult, confirmed bool) error {
afterCalled = true
afterConfirmedParam = confirmed
assert.Len(t, results, 1)
return tt.afterInteractionErr
},
}
}
confirmationChan := make(chan *ConfirmationRequest, 1)
go HandleConfirmationRequests(confirmationChan, interaction, hooks)
pkgVersion := mockPackageVersion("test-package", "1.0.0")
analysisResult := mockAnalysisResult()
analysisResult.PackageVersion = pkgVersion
req := NewConfirmationRequest(pkgVersion, analysisResult)
confirmationChan <- req
response := <-req.ResponseChan
assert.Equal(t, tt.expectedResponse, response)
if tt.verifyHooksCalled {
assert.True(t, beforeCalled, "BeforeInteraction hook should be called")
assert.True(t, afterCalled, "AfterInteraction hook should be called")
if tt.confirmationError == nil {
assert.Equal(t, tt.confirmationResponse, afterConfirmedParam,
"AfterInteraction should receive correct confirmation status")
}
} else {
assert.False(t, beforeCalled, "BeforeInteraction hook should not be called when hooks are nil")
assert.False(t, afterCalled, "AfterInteraction hook should not be called when hooks are nil")
}
close(confirmationChan)
})
}
}
func TestHandleConfirmationRequests_MultipleSequential(t *testing.T) {
processedPackages := []string{}
interaction := guard.PackageManagerGuardInteraction{
GetConfirmationOnMalware: func(results []*analyzer.PackageVersionAnalysisResult) (bool, error) {
pkgName := results[0].PackageVersion.GetPackage().GetName()
processedPackages = append(processedPackages, pkgName)
return true, nil
},
}
confirmationChan := make(chan *ConfirmationRequest, 3)
go HandleConfirmationRequests(confirmationChan, interaction, nil)
pkgVersion1 := mockPackageVersion("package-1", "1.0.0")
analysisResult1 := mockAnalysisResult()
analysisResult1.PackageVersion = pkgVersion1
req1 := NewConfirmationRequest(pkgVersion1, analysisResult1)
pkgVersion2 := mockPackageVersion("package-2", "1.0.0")
analysisResult2 := mockAnalysisResult()
analysisResult2.PackageVersion = pkgVersion2
req2 := NewConfirmationRequest(pkgVersion2, analysisResult2)
pkgVersion3 := mockPackageVersion("package-3", "1.0.0")
analysisResult3 := mockAnalysisResult()
analysisResult3.PackageVersion = pkgVersion3
req3 := NewConfirmationRequest(pkgVersion3, analysisResult3)
confirmationChan <- req1
confirmationChan <- req2
confirmationChan <- req3
response1 := <-req1.ResponseChan
response2 := <-req2.ResponseChan
response3 := <-req3.ResponseChan
assert.True(t, response1)
assert.True(t, response2)
assert.True(t, response3)
assert.Equal(t, []string{"package-1", "package-2", "package-3"}, processedPackages)
close(confirmationChan)
}
func TestNewConfirmationRequest(t *testing.T) {
pkgVersion := mockPackageVersion("test-package", "1.0.0")
analysisResult := mockAnalysisResult()
analysisResult.PackageVersion = pkgVersion
req := NewConfirmationRequest(pkgVersion, analysisResult)
assert.NotNil(t, req)
assert.Equal(t, pkgVersion, req.PackageVersion)
assert.Equal(t, analysisResult, req.AnalysisResult)
assert.NotNil(t, req.ResponseChan)
assert.Equal(t, 1, cap(req.ResponseChan), "ResponseChan should have buffer size of 1")
}
+68
View File
@@ -0,0 +1,68 @@
package interceptors
import (
"fmt"
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
"github.com/safedep/pmg/analyzer"
"github.com/safedep/pmg/guard"
"github.com/safedep/pmg/proxy"
)
// InterceptorFactory creates ecosystem-specific interceptors for the proxy
type InterceptorFactory struct {
analyzer analyzer.PackageVersionAnalyzer
cache AnalysisCache
confirmationChan chan *ConfirmationRequest
interaction guard.PackageManagerGuardInteraction
}
// NewInterceptorFactory creates a new interceptor factory with shared dependencies
func NewInterceptorFactory(
analyzer analyzer.PackageVersionAnalyzer,
cache AnalysisCache,
confirmationChan chan *ConfirmationRequest,
interaction guard.PackageManagerGuardInteraction,
) *InterceptorFactory {
return &InterceptorFactory{
analyzer: analyzer,
cache: cache,
confirmationChan: confirmationChan,
interaction: interaction,
}
}
// CreateInterceptor creates an interceptor for the specified ecosystem
// Returns an error if the ecosystem is not supported for proxy-based interception
func (f *InterceptorFactory) CreateInterceptor(ecosystem packagev1.Ecosystem) (proxy.Interceptor, error) {
switch ecosystem {
case packagev1.Ecosystem_ECOSYSTEM_NPM:
return NewNpmRegistryInterceptor(
f.analyzer,
f.cache,
f.confirmationChan,
f.interaction,
), nil
default:
return nil, fmt.Errorf("proxy-based interception not yet supported for ecosystem: %s", ecosystem.String())
}
}
// SupportedEcosystems returns a list of ecosystems that support proxy-based interception
func SupportedEcosystems() []packagev1.Ecosystem {
return []packagev1.Ecosystem{
packagev1.Ecosystem_ECOSYSTEM_NPM,
}
}
// IsSupported checks if an ecosystem supports proxy-based interception
func IsSupported(ecosystem packagev1.Ecosystem) bool {
for _, supported := range SupportedEcosystems() {
if ecosystem == supported {
return true
}
}
return false
}
+91
View File
@@ -0,0 +1,91 @@
package interceptors
import (
"strings"
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
"github.com/safedep/dry/log"
"github.com/safedep/pmg/analyzer"
"github.com/safedep/pmg/guard"
"github.com/safedep/pmg/proxy"
)
var (
npmRegistryDomains = []string{
"registry.npmjs.org",
"registry.yarnpkg.com",
}
)
// NpmRegistryInterceptor intercepts NPM registry requests and analyzes packages for malware
// It embeds baseRegistryInterceptor to reuse ecosystem agnostic functionality
type NpmRegistryInterceptor struct {
baseRegistryInterceptor
}
var _ proxy.Interceptor = (*NpmRegistryInterceptor)(nil)
// NewNpmRegistryInterceptor creates a new NPM registry interceptor
func NewNpmRegistryInterceptor(
analyzer analyzer.PackageVersionAnalyzer,
cache AnalysisCache,
confirmationChan chan *ConfirmationRequest,
interaction guard.PackageManagerGuardInteraction,
) *NpmRegistryInterceptor {
return &NpmRegistryInterceptor{
baseRegistryInterceptor: baseRegistryInterceptor{
analyzer: analyzer,
cache: cache,
confirmationChan: confirmationChan,
interaction: interaction,
},
}
}
// Name returns the interceptor name for logging
func (i *NpmRegistryInterceptor) Name() string {
return "npm-registry-interceptor"
}
// ShouldIntercept determines if this interceptor should handle the given request
func (i *NpmRegistryInterceptor) ShouldIntercept(ctx *proxy.RequestContext) bool {
for _, domain := range npmRegistryDomains {
if ctx.Hostname == domain || strings.HasSuffix(ctx.Hostname, "."+domain) {
return true
}
}
return false
}
// HandleRequest processes the request and returns response action
// We take a fail-open approach here, allowing requests that we can't parse the package information from the URL.
func (i *NpmRegistryInterceptor) HandleRequest(ctx *proxy.RequestContext) (*proxy.InterceptorResponse, error) {
log.Debugf("[%s] Handling NPM registry request: %s", ctx.RequestID, ctx.URL.Path)
pkgInfo, err := parseNpmRegistryURL(ctx.URL.Path)
if err != nil {
log.Warnf("[%s] Failed to parse NPM registry URL %s: %v", ctx.RequestID, ctx.URL.Path, err)
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
}
// Only analyze tarball downloads (these have a specific version)
// Metadata requests (without version) are allowed through
if !pkgInfo.IsTarball {
log.Debugf("[%s] Skipping analysis for metadata request: %s", ctx.RequestID, pkgInfo.Name)
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
}
result, err := i.baseRegistryInterceptor.analyzePackage(
ctx,
packagev1.Ecosystem_ECOSYSTEM_NPM,
pkgInfo.Name,
pkgInfo.Version,
)
if err != nil {
log.Errorf("[%s] Failed to analyze package %s@%s: %v", ctx.RequestID, pkgInfo.Name, pkgInfo.Version, err)
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
}
return i.baseRegistryInterceptor.handleAnalysisResult(ctx, packagev1.Ecosystem_ECOSYSTEM_NPM, pkgInfo.Name, pkgInfo.Version, result)
}
+205
View File
@@ -0,0 +1,205 @@
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
}
// parseNpmRegistryURL parses an NPM registry URL path to extract package information
//
// 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 parseNpmRegistryURL(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)
}
// 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)
}
+282
View File
@@ -0,0 +1,282 @@
package interceptors
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestParseNpmRegistryURL(t *testing.T) {
tests := []struct {
name string
urlPath string
wantName string
wantVersion string
wantIsTarball bool
wantIsScoped bool
wantErr bool
}{
// Unscoped packages - metadata requests
{
name: "unscoped package without version",
urlPath: "/lodash",
wantName: "lodash",
wantVersion: "",
wantIsTarball: false,
wantIsScoped: false,
wantErr: false,
},
{
name: "unscoped package with version",
urlPath: "/lodash/4.17.21",
wantName: "lodash",
wantVersion: "4.17.21",
wantIsTarball: false,
wantIsScoped: false,
wantErr: false,
},
{
name: "unscoped package with leading slash",
urlPath: "/express",
wantName: "express",
wantVersion: "",
wantIsTarball: false,
wantIsScoped: false,
wantErr: false,
},
{
name: "unscoped package with trailing slash",
urlPath: "/react/",
wantName: "react",
wantVersion: "",
wantIsTarball: false,
wantIsScoped: false,
wantErr: false,
},
// Unscoped packages - tarball downloads
{
name: "unscoped package tarball",
urlPath: "/lodash/-/lodash-4.17.21.tgz",
wantName: "lodash",
wantVersion: "4.17.21",
wantIsTarball: true,
wantIsScoped: false,
wantErr: false,
},
{
name: "unscoped package tarball with prerelease",
urlPath: "/react/-/react-18.0.0-rc.1.tgz",
wantName: "react",
wantVersion: "18.0.0-rc.1",
wantIsTarball: true,
wantIsScoped: false,
wantErr: false,
},
{
name: "unscoped package tarball with build metadata",
urlPath: "/vue/-/vue-3.2.0+build123.tgz",
wantName: "vue",
wantVersion: "3.2.0+build123",
wantIsTarball: true,
wantIsScoped: false,
wantErr: false,
},
// Scoped packages - metadata requests
{
name: "scoped package without version",
urlPath: "/@types/node",
wantName: "@types/node",
wantVersion: "",
wantIsTarball: false,
wantIsScoped: true,
wantErr: false,
},
{
name: "scoped package with version",
urlPath: "/@types/node/18.0.0",
wantName: "@types/node",
wantVersion: "18.0.0",
wantIsTarball: false,
wantIsScoped: true,
wantErr: false,
},
{
name: "scoped package with complex scope",
urlPath: "/@babel/core",
wantName: "@babel/core",
wantVersion: "",
wantIsTarball: false,
wantIsScoped: true,
wantErr: false,
},
// Scoped packages - tarball downloads
{
name: "scoped package tarball",
urlPath: "/@types/node/-/types-node-18.0.0.tgz",
wantName: "@types/node",
wantVersion: "18.0.0",
wantIsTarball: true,
wantIsScoped: true,
wantErr: false,
},
{
name: "scoped package tarball with prerelease",
urlPath: "/@babel/core/-/babel-core-7.20.0-beta.1.tgz",
wantName: "@babel/core",
wantVersion: "7.20.0-beta.1",
wantIsTarball: true,
wantIsScoped: true,
wantErr: false,
},
{
name: "scoped package with hyphenated name",
urlPath: "/@angular/common-http/-/angular-common-http-15.0.0.tgz",
wantName: "@angular/common-http",
wantVersion: "15.0.0",
wantIsTarball: true,
wantIsScoped: true,
wantErr: false,
},
{
name: "scoped package tarball without scope prefix (Format 2)",
urlPath: "/@prisma/studio-core-licensed/-/studio-core-licensed-0.0.0-dev.202601011229.tgz",
wantName: "@prisma/studio-core-licensed",
wantVersion: "0.0.0-dev.202601011229",
wantIsTarball: true,
wantIsScoped: true,
wantErr: false,
},
{
name: "scoped package tarball with scope prefix (Format 1)",
urlPath: "/@types/node/-/types-node-20.0.0.tgz",
wantName: "@types/node",
wantVersion: "20.0.0",
wantIsTarball: true,
wantIsScoped: true,
wantErr: false,
},
// Error cases
{
name: "empty URL path",
urlPath: "",
wantName: "",
wantVersion: "",
wantIsTarball: false,
wantIsScoped: false,
wantErr: true,
},
{
name: "just slash",
urlPath: "/",
wantName: "",
wantVersion: "",
wantIsTarball: false,
wantIsScoped: false,
wantErr: true,
},
{
name: "scoped package missing package name",
urlPath: "/@types",
wantName: "",
wantVersion: "",
wantIsTarball: false,
wantIsScoped: false,
wantErr: true,
},
{
name: "unscoped package with too many segments",
urlPath: "/lodash/4.17.21/extra/segment",
wantName: "",
wantVersion: "",
wantIsTarball: false,
wantIsScoped: false,
wantErr: true,
},
{
name: "scoped package with too many segments",
urlPath: "/@types/node/18.0.0/extra/segment",
wantName: "",
wantVersion: "",
wantIsTarball: false,
wantIsScoped: false,
wantErr: true,
},
{
name: "malformed tarball - wrong prefix",
urlPath: "/lodash/-/react-4.17.21.tgz",
wantName: "",
wantVersion: "",
wantIsTarball: false,
wantIsScoped: false,
wantErr: true,
},
{
name: "malformed tarball - no .tgz extension",
urlPath: "/lodash/-/lodash-4.17.21.tar.gz",
wantName: "",
wantVersion: "",
wantIsTarball: false,
wantIsScoped: false,
wantErr: true,
},
{
name: "scoped tarball with wrong scope in filename",
urlPath: "/@types/node/-/babel-node-18.0.0.tgz",
wantName: "",
wantVersion: "",
wantIsTarball: false,
wantIsScoped: false,
wantErr: true,
},
// Edge cases
{
name: "package name with numbers",
urlPath: "/vue3",
wantName: "vue3",
wantVersion: "",
wantIsTarball: false,
wantIsScoped: false,
wantErr: false,
},
{
name: "package name with hyphens",
urlPath: "/express-validator",
wantName: "express-validator",
wantVersion: "",
wantIsTarball: false,
wantIsScoped: false,
wantErr: false,
},
{
name: "version with v prefix (uncommon but valid)",
urlPath: "/lodash/v4.17.21",
wantName: "lodash",
wantVersion: "v4.17.21",
wantIsTarball: false,
wantIsScoped: false,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseNpmRegistryURL(tt.urlPath)
if tt.wantErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wantName, got.Name)
assert.Equal(t, tt.wantVersion, got.Version)
assert.Equal(t, tt.wantIsTarball, got.IsTarball)
assert.Equal(t, tt.wantIsScoped, got.IsScoped)
})
}
}