diff --git a/cmd/ecosystems/npm.go b/cmd/ecosystems/npm.go deleted file mode 100644 index d7abacd..0000000 --- a/cmd/ecosystems/npm.go +++ /dev/null @@ -1,155 +0,0 @@ -package ecosystems - -import ( - "context" - _ "embed" - "fmt" - "os" - "time" - - "github.com/safedep/dry/log" - "github.com/safedep/pmg/internal/ui" - "github.com/safedep/pmg/pkg/analyser" - "github.com/safedep/pmg/pkg/common/utils" - "github.com/safedep/pmg/pkg/models" - "github.com/safedep/pmg/pkg/registry" - vetUtils "github.com/safedep/vet/pkg/common/utils" - "github.com/spf13/cobra" -) - -var ( - packageName string - action string - silentScan bool -) - -func NewNpmCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "npm [action] [package]", - Short: "Scan packages from npm registry", - Args: cobra.MinimumNArgs(2), - RunE: func(cmd *cobra.Command, args []string) error { - action = args[0] - packageName = args[1] - - validActions := map[string]bool{"install": true, "i": true, "add": true} - if validActions[action] { - err := wrapNpm() - if err != nil { - log.Errorf("Failed to wrap npm: %v", err) - os.Exit(1) - } - return nil - } - - // For non-install actions, just pass through to npm - npmPath, err := utils.GetExecutablePath("npm") - if err != nil { - return fmt.Errorf("npm not found: %w", err) - } - - return utils.ExecCmd(npmPath, args, []string{}) - }, - } - cmd.Flags().BoolVarP(&silentScan, "silent", "s", false, - "Silent scan to prevent rendering UI") - return cmd -} - -func wrapNpm() error { - if !silentScan { - ui.StartProgressWriter() - } - var progressTracker ui.ProgressTracker - - progressTracker = ui.TrackProgress(fmt.Sprintf("Scanning %s ", packageName), 1) - if packageName == "" { - return fmt.Errorf("package name cannot be empty") - } - - // Setup context with timeout for API calls - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) - defer cancel() - - factory := registry.NewFetcherFactory(10 * time.Second) - - // Get an NPM fetcher - npmFetcher, err := factory.CreateFetcher(registry.RegistryNPM) - if err != nil { - return err - } - name, version, err := utils.ParsePackageInfo(packageName) - - // If version is empty, get the latest version - if version == "" { - log.Infof("No version specified for %s, fetching latest version...", name) - version, err = npmFetcher.(*registry.NpmFetcher).ResolveVersion(ctx, name, version) - if err != nil { - return err - } - log.Infof("Latest version of %s is %s", name, version) - // Update packageName with resolved version for npm installation - packageName = fmt.Sprintf("%s@%s", name, version) - } - ui.IncrementProgress(progressTracker, 1) - - deps, err := npmFetcher.GetFlattenedDependencies(ctx, name, version) - ui.IncrementProgress(progressTracker, 1) - if err != nil { - return err - } - ui.IncrementTrackerTotal(progressTracker, int64(len(deps))) - client, err := analyser.GetMalwareAnalysisClient() - if err != nil { - return fmt.Errorf("error while creating a malware analysis client: %w", err) - } - pkgAnalyser := analyser.New(client, ctx) - - pkgAnalyser.ProgressTracker = progressTracker - handler := pkgAnalyser.Handler() - - // Create work queue with appropriate buffer size and concurrency - queue := vetUtils.NewWorkQueue[models.Package](100, 10, handler) - queue.Start() - defer queue.Stop() - - // Add packages to the queue - for _, dep := range deps { - name, version, err := utils.ParsePackageInfo(dep) - if err != nil { - log.Errorf("Error while parsing info of package %s", name) - continue - } - queue.Add(models.Package{ - Name: name, - Version: version, - }) - } - - // Wait for all analysis to complete - queue.Wait() - ui.MarkTrackerAsDone(progressTracker) - ui.StopProgressWriter() - - // Get the npm PATH and continue with installation - npmPath, err := utils.GetExecutablePath("npm") - if err != nil { - return fmt.Errorf("npm not found: %w", err) - } - - if len(pkgAnalyser.MaliciousPkgs) > 0 { - if !utils.ConfirmInstallation(pkgAnalyser.MaliciousPkgs) { - log.Infof("Installation canceled due to security concerns") - return nil - } - log.Warnf("Continuing installation despite security warnings...") - } - - cmdArgs := []string{action, packageName} - if err = utils.ExecCmd(npmPath, cmdArgs, []string{}); err != nil { - return fmt.Errorf("failed to execute npm command: %w", err) - } - - log.Infof("Successfully installed %s", packageName) - return nil -} diff --git a/cmd/npm/npm.go b/cmd/npm/npm.go new file mode 100644 index 0000000..8f8d582 --- /dev/null +++ b/cmd/npm/npm.go @@ -0,0 +1,49 @@ +package npm + +import ( + _ "embed" + "fmt" + "os" + + "github.com/safedep/pmg/pkg/common/utils" + "github.com/safedep/pmg/pkg/registry" + "github.com/safedep/pmg/pkg/wrapper" + "github.com/spf13/cobra" +) + +func NewNpmCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "npm [action] [package]", + Short: "Scan packages from npm registry", + DisableFlagParsing: true, + RunE: func(cmd *cobra.Command, args []string) error { + execPath, err := utils.GetExecutablePath(string(registry.RegistryNPM)) + if err != nil { + fmt.Fprintf(os.Stderr, "npm not found: %v\n", err) + return err + } + + if len(args) >= 2 && utils.IsInstallCommand(string(registry.RegistryNPM), args[0]) { + if err := utils.ValidateEnvVars(); err != nil { + return err + } + + pmw := wrapper.NewPackageManagerWrapper(registry.RegistryNPM) + pmw.Action = args[0] + pmw.PackageName = args[1] + + if err := pmw.Wrap(); err != nil { + os.Exit(1) + } + return nil + } + + if err := utils.ExecCmd(execPath, args, []string{}); err != nil { + os.Exit(1) + } + os.Exit(0) + return nil + }, + } + return cmd +} diff --git a/cmd/npm/pnpm.go b/cmd/npm/pnpm.go new file mode 100644 index 0000000..6ffe6a9 --- /dev/null +++ b/cmd/npm/pnpm.go @@ -0,0 +1,49 @@ +package npm + +import ( + _ "embed" + "fmt" + "os" + + "github.com/safedep/pmg/pkg/common/utils" + "github.com/safedep/pmg/pkg/registry" + "github.com/safedep/pmg/pkg/wrapper" + "github.com/spf13/cobra" +) + +func NewPnpmCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "pnpm [action] [package]", + Short: "Scan packages from npm registry", + DisableFlagParsing: true, + RunE: func(cmd *cobra.Command, args []string) error { + execPath, err := utils.GetExecutablePath(string(registry.RegistryPNPM)) + if err != nil { + fmt.Fprintf(os.Stderr, "pnpm not found: %v\n", err) + return err + } + + if len(args) >= 2 && utils.IsInstallCommand(string(registry.RegistryPNPM), args[0]) { + if err := utils.ValidateEnvVars(); err != nil { + return err + } + + pmw := wrapper.NewPackageManagerWrapper(registry.RegistryPNPM) + pmw.Action = args[0] + pmw.PackageName = args[1] + + if err := pmw.Wrap(); err != nil { + os.Exit(1) + } + return nil + } + + if err := utils.ExecCmd(execPath, args, []string{}); err != nil { + os.Exit(1) + } + os.Exit(0) + return nil + }, + } + return cmd +} diff --git a/main.go b/main.go index cba51d5..a85baf7 100644 --- a/main.go +++ b/main.go @@ -5,7 +5,7 @@ import ( "os" "github.com/safedep/dry/log" - "github.com/safedep/pmg/cmd/ecosystems" + "github.com/safedep/pmg/cmd/npm" "github.com/spf13/cobra" ) @@ -30,10 +30,10 @@ func main() { cmd.PersistentFlags().BoolVar(&debug, "debug", false, "Enable debug logging") - cmd.AddCommand(ecosystems.NewNpmCommand()) + cmd.AddCommand(npm.NewNpmCommand()) + cmd.AddCommand(npm.NewPnpmCommand()) if err := cmd.Execute(); err != nil { - fmt.Fprintln(os.Stderr, err) os.Exit(1) } } diff --git a/pkg/common/utils/command.go b/pkg/common/utils/command.go index bb0a287..8d59404 100644 --- a/pkg/common/utils/command.go +++ b/pkg/common/utils/command.go @@ -1,7 +1,6 @@ package utils import ( - "fmt" "os" "os/exec" ) @@ -10,8 +9,10 @@ func ExecCmd(name string, args, env []string) error { cmd := exec.Command(name, args...) cmd.Env = append(os.Environ(), env...) - if err := cmd.Run(); err != nil { - return fmt.Errorf("error running cmd %s: %s\n", name, err.Error()) - } - return nil + // Connect to standard streams + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + + return cmd.Run() } diff --git a/pkg/common/utils/config.go b/pkg/common/utils/config.go index 69017b5..f09e6dc 100644 --- a/pkg/common/utils/config.go +++ b/pkg/common/utils/config.go @@ -1,6 +1,10 @@ package utils -import "os" +import ( + "fmt" + "os" + "strings" +) func ApiKey() string { return os.Getenv("SAFEDEP_API_KEY") @@ -13,3 +17,35 @@ func TenantDomain() string { func NpmAuthToken() string { return os.Getenv("NPM_AUTH_TOKEN") } + +func ValidateEnvVars() error { + apiKey := ApiKey() + tenantId := TenantDomain() + var missingVars []string + + if apiKey == "" { + missingVars = append(missingVars, "SAFEDEP_API_KEY") + } + if tenantId == "" { + missingVars = append(missingVars, "SAFEDEP_TENANT_ID") + } + + if len(missingVars) > 0 { + return fmt.Errorf(` +SafeDep configuration incomplete + +Missing environment variables: + %s + +To enable package scanning: + 1. Export these variables in your terminal: + export %s=your_api_key + export %s=your_tenant_id + 2. Or add them to your shell profile file + +For more information, visit: https://docs.safedep.io/cloud/quickstart +`, strings.Join(missingVars, "\n "), missingVars[0], missingVars[len(missingVars)-1]) + } + + return nil +} diff --git a/pkg/common/utils/utils.go b/pkg/common/utils/utils.go index 62d977a..51e13e3 100644 --- a/pkg/common/utils/utils.go +++ b/pkg/common/utils/utils.go @@ -33,3 +33,23 @@ func ParsePackageInfo(input string) (packageName, version string, err error) { return "", "", fmt.Errorf("invalid format: expected 'package' OR 'package@version', got '%s'", input) } + +func IsInstallCommand(pkgManager, cmd string) bool { + validActions := map[string]map[string]bool{ + "npm": { + "install": true, + "i": true, + "add": true, + }, + "pnpm": { + "add": true, + "install": true, + "i": true, + }, + } + + if actions, exists := validActions[pkgManager]; exists { + return actions[cmd] + } + return false +} diff --git a/pkg/registry/factory.go b/pkg/registry/factory.go index 284eb98..2125dab 100644 --- a/pkg/registry/factory.go +++ b/pkg/registry/factory.go @@ -10,6 +10,7 @@ type RegistryType string const ( RegistryNPM RegistryType = "npm" + RegistryPNPM RegistryType = "pnpm" RegistryPyPI RegistryType = "pypi" RegistryGo RegistryType = "go" ) @@ -29,7 +30,7 @@ func NewFetcherFactory(timeout time.Duration) *FetcherFactory { // CreateFetcher returns a fetcher for the specified registry type func (ff *FetcherFactory) CreateFetcher(registryType RegistryType) (Fetcher, error) { switch registryType { - case RegistryNPM: + case RegistryNPM, RegistryPNPM: return NewNpmFetcher(ff.timeout), nil default: return nil, fmt.Errorf("unsupported registry type: %s", registryType) diff --git a/pkg/wrapper/npm_base.go b/pkg/wrapper/npm_base.go new file mode 100644 index 0000000..db96919 --- /dev/null +++ b/pkg/wrapper/npm_base.go @@ -0,0 +1,154 @@ +package wrapper + +import ( + "context" + "fmt" + "time" + + "github.com/safedep/dry/log" + "github.com/safedep/pmg/internal/ui" + "github.com/safedep/pmg/pkg/analyser" + "github.com/safedep/pmg/pkg/common/utils" + "github.com/safedep/pmg/pkg/models" + "github.com/safedep/pmg/pkg/registry" + vetUtils "github.com/safedep/vet/pkg/common/utils" +) + +type PackageManagerWrapper struct { + RegistryType registry.RegistryType + Action string + PackageName string +} + +func NewPackageManagerWrapper(registryType registry.RegistryType) *PackageManagerWrapper { + return &PackageManagerWrapper{ + RegistryType: registryType, + } +} + +func (pmw *PackageManagerWrapper) Wrap() error { + ui.StartProgressWriter() + + progressTracker := ui.TrackProgress(fmt.Sprintf("Scanning %s ", pmw.PackageName), 1) + if pmw.PackageName == "" { + return fmt.Errorf("package name cannot be empty") + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + if err := pmw.scanAndInstall(ctx, progressTracker); err != nil { + return err + } + + log.Infof("Successfully installed %s", pmw.PackageName) + return nil +} + +func (pmw *PackageManagerWrapper) scanAndInstall(ctx context.Context, progressTracker ui.ProgressTracker) error { + factory := registry.NewFetcherFactory(10 * time.Second) + fetcher, err := factory.CreateFetcher(pmw.RegistryType) + if err != nil { + return err + } + + name, version, err := utils.ParsePackageInfo(pmw.PackageName) + if err != nil { + return err + } + + if version == "" { + version, err = pmw.resolveLatestVersion(ctx, fetcher, name) + if err != nil { + return err + } + pmw.PackageName = fmt.Sprintf("%s@%s", name, version) + } + + deps, err := pmw.getDependencies(ctx, fetcher, name, version, progressTracker) + if err != nil { + return err + } + + if err := pmw.analyzeDependencies(ctx, deps, progressTracker); err != nil { + return err + } + + return pmw.executeInstallation() +} + +func (pmw *PackageManagerWrapper) resolveLatestVersion(ctx context.Context, fetcher registry.Fetcher, name string) (string, error) { + log.Infof("No version specified for %s, fetching latest version...", name) + version, err := fetcher.(*registry.NpmFetcher).ResolveVersion(ctx, name, "") + if err != nil { + return "", err + } + log.Infof("Latest version of %s is %s", name, version) + return version, nil +} + +func (pmw *PackageManagerWrapper) getDependencies(ctx context.Context, fetcher registry.Fetcher, name, version string, progressTracker ui.ProgressTracker) ([]string, error) { + ui.IncrementProgress(progressTracker, 1) + deps, err := fetcher.GetFlattenedDependencies(ctx, name, version) + ui.IncrementProgress(progressTracker, 1) + if err != nil { + return nil, err + } + ui.IncrementTrackerTotal(progressTracker, int64(len(deps))) + return deps, nil +} + +func (pmw *PackageManagerWrapper) analyzeDependencies(ctx context.Context, deps []string, progressTracker ui.ProgressTracker) error { + client, err := analyser.GetMalwareAnalysisClient() + if err != nil { + return fmt.Errorf("error while creating a malware analysis client: %w", err) + } + + pkgAnalyser := analyser.New(client, ctx) + pkgAnalyser.ProgressTracker = progressTracker + handler := pkgAnalyser.Handler() + + queue := vetUtils.NewWorkQueue[models.Package](100, 10, handler) + queue.Start() + defer queue.Stop() + + for _, dep := range deps { + name, version, err := utils.ParsePackageInfo(dep) + if err != nil { + log.Errorf("Error while parsing info of package %s", name) + continue + } + queue.Add(models.Package{ + Name: name, + Version: version, + }) + } + + queue.Wait() + ui.MarkTrackerAsDone(progressTracker) + ui.StopProgressWriter() + + if len(pkgAnalyser.MaliciousPkgs) > 0 { + if !utils.ConfirmInstallation(pkgAnalyser.MaliciousPkgs) { + log.Infof("Installation canceled due to security concerns") + return fmt.Errorf("installation canceled") + } + log.Warnf("Continuing installation despite security warnings...") + } + + return nil +} + +func (pmw *PackageManagerWrapper) executeInstallation() error { + execPath, err := utils.GetExecutablePath(string(pmw.RegistryType)) + if err != nil { + return fmt.Errorf("%s not found: %w", pmw.RegistryType, err) + } + + cmdArgs := []string{pmw.Action, pmw.PackageName} + if err = utils.ExecCmd(execPath, cmdArgs, []string{}); err != nil { + return fmt.Errorf("failed to execute %s command: %w", pmw.RegistryType, err) + } + + return nil +}