From 31f23fd06520809424ff2cdc25e0c3af62282dff Mon Sep 17 00:00:00 2001 From: Sahil Bansal Date: Fri, 9 Jan 2026 22:03:42 +0530 Subject: [PATCH] Add support for package executors and support for PTY handling (#100) * define contract for package executors * introduce npx executor * add npx and pnpx cmd support * fix typo * rm PackageExecutor and depend on PackageManager interface * add support for PTY to handle parent-child process interaction * refactor PTY handling in proxy flow * enforce interactiveSession interface check * close reader explicitly and clean npm version for pkg executors * rm interaction from interceptors * add docs and wait for outputRouter before exit * add support for non interactive TTY for proxy mode * add support for CI env var check for non interactive tty proxy mode * update readme to include npx, pnpx support * Update internal/flows/proxy_flow.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Sahil Bansal * update ptyx lib * fix docs typo --------- Signed-off-by: Sahil Bansal Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 2 + cmd/executors/npx.go | 59 +++++++ cmd/executors/pnpx.go | 59 +++++++ docs/proxy-mode.md | 14 +- docs/trusted-packages.md | 3 +- go.mod | 12 +- go.sum | 21 ++- guard/guard.go | 19 ++ internal/analytics/event.go | 11 ++ internal/flows/proxy_flow.go | 226 ++++++++++++++++++------ internal/pty/router.go | 115 ++++++++++++ internal/pty/session.go | 175 ++++++++++++++++++ internal/ui/ui.go | 32 ++-- main.go | 3 + packagemanager/npm_executor.go | 123 +++++++++++++ packagemanager/npm_executor_test.go | 211 ++++++++++++++++++++++ packagemanager/pypi.go | 6 +- proxy/interceptors/confirmation.go | 2 +- proxy/interceptors/confirmation_test.go | 4 +- proxy/interceptors/factory.go | 5 - proxy/interceptors/npm_registry.go | 3 - 21 files changed, 1009 insertions(+), 96 deletions(-) create mode 100644 cmd/executors/npx.go create mode 100644 cmd/executors/pnpx.go create mode 100644 internal/pty/router.go create mode 100644 internal/pty/session.go create mode 100644 packagemanager/npm_executor.go create mode 100644 packagemanager/npm_executor_test.go diff --git a/README.md b/README.md index 95cb586..c2c207c 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,8 @@ PMG supports the following package managers: | `pip` | ✅ Active | `pmg pip install ` | | `uv` | ✅ Active | `pmg uv add ` or `pmg uv pip install ` | | `poetry` | ✅ Active | `pmg poetry add ` | +| `npx` | ✅ Active | `pmg npx ` | +| `pnpx` | ✅ Active | `pmg pnpx ` | > Want us to support your favorite package manager? [Open an issue](https://github.com/safedep/pmg/issues) and let us know! diff --git a/cmd/executors/npx.go b/cmd/executors/npx.go new file mode 100644 index 0000000..685d196 --- /dev/null +++ b/cmd/executors/npx.go @@ -0,0 +1,59 @@ +package executors + +import ( + "context" + "fmt" + + "github.com/safedep/pmg/config" + "github.com/safedep/pmg/internal/analytics" + "github.com/safedep/pmg/internal/flows" + "github.com/safedep/pmg/internal/ui" + "github.com/safedep/pmg/packagemanager" + "github.com/spf13/cobra" +) + +func NewNpxCommand() *cobra.Command { + return &cobra.Command{ + Use: "npx [package] [action]", + Short: "Guard npx package executor", + DisableFlagParsing: true, + RunE: func(cmd *cobra.Command, args []string) error { + err := executeNpxFlow(cmd.Context(), args) + if err != nil { + ui.ErrorExit(err) + } + + return nil + }, + } +} + +func executeNpxFlow(ctx context.Context, args []string) error { + analytics.TrackCommandNpx() + packageExecutor, err := packagemanager.NewNpmPackageExecutor(packagemanager.DefaultNpxPackageExecutorConfig()) + if err != nil { + return fmt.Errorf("failed to create npx package executor proxy: %w", err) + } + + config := config.Get() + parsedCommand, err := packageExecutor.ParseCommand(args) + if err != nil { + return fmt.Errorf("failed to parse command: %w", err) + } + + packageResolverConfig := packagemanager.NewDefaultNpmDependencyResolverConfig() + packageResolverConfig.IncludeTransitiveDependencies = config.Config.Transitive + packageResolverConfig.TransitiveDepth = config.Config.TransitiveDepth + packageResolverConfig.IncludeDevDependencies = config.Config.IncludeDevDependencies + + packageResolver, err := packagemanager.NewNpmDependencyResolver(packageResolverConfig) + if err != nil { + return fmt.Errorf("failed to create dependency resolver: %w", err) + } + + if config.Config.ExperimentalProxyMode { + return flows.ProxyFlow(packageExecutor, packageResolver).Run(ctx, args, parsedCommand) + } + + return flows.Common(packageExecutor, packageResolver).Run(ctx, args, parsedCommand) +} diff --git a/cmd/executors/pnpx.go b/cmd/executors/pnpx.go new file mode 100644 index 0000000..c68e0a7 --- /dev/null +++ b/cmd/executors/pnpx.go @@ -0,0 +1,59 @@ +package executors + +import ( + "context" + "fmt" + + "github.com/safedep/pmg/config" + "github.com/safedep/pmg/internal/analytics" + "github.com/safedep/pmg/internal/flows" + "github.com/safedep/pmg/internal/ui" + "github.com/safedep/pmg/packagemanager" + "github.com/spf13/cobra" +) + +func NewPnpxCommand() *cobra.Command { + return &cobra.Command{ + Use: "pnpx [package] [action]", + Short: "Guard pnpx package executor", + DisableFlagParsing: true, + RunE: func(cmd *cobra.Command, args []string) error { + err := executePnpxFlow(cmd.Context(), args) + if err != nil { + ui.ErrorExit(err) + } + + return nil + }, + } +} + +func executePnpxFlow(ctx context.Context, args []string) error { + analytics.TrackCommandPnpx() + packageExecutor, err := packagemanager.NewNpmPackageExecutor(packagemanager.DefaultPnpxPackageExecutorConfig()) + if err != nil { + return fmt.Errorf("failed to create pnpx package executor proxy: %w", err) + } + + config := config.Get() + parsedCommand, err := packageExecutor.ParseCommand(args) + if err != nil { + return fmt.Errorf("failed to parse command: %w", err) + } + + packageResolverConfig := packagemanager.NewDefaultNpmDependencyResolverConfig() + packageResolverConfig.IncludeTransitiveDependencies = config.Config.Transitive + packageResolverConfig.TransitiveDepth = config.Config.TransitiveDepth + packageResolverConfig.IncludeDevDependencies = config.Config.IncludeDevDependencies + + packageResolver, err := packagemanager.NewNpmDependencyResolver(packageResolverConfig) + if err != nil { + return fmt.Errorf("failed to create dependency resolver: %w", err) + } + + if config.Config.ExperimentalProxyMode { + return flows.ProxyFlow(packageExecutor, packageResolver).Run(ctx, args, parsedCommand) + } + + return flows.Common(packageExecutor, packageResolver).Run(ctx, args, parsedCommand) +} diff --git a/docs/proxy-mode.md b/docs/proxy-mode.md index fd8d983..fb53117 100644 --- a/docs/proxy-mode.md +++ b/docs/proxy-mode.md @@ -3,9 +3,9 @@ PMG supports an experimental proxy based interception as an alternative to the current optimistic dependency resolution. When enabled via `--experimental-proxy-mode` flag: - PMG starts a micro-proxy server on a random localhost port -- Run `npm` and other supported package managers configured to use the proxy -- Intercept package registry requests and analyze packages as they are downloaded -- Block malicious packages and allow trusted packages to be installed +- Runs `npm` and other supported package managers configured to use the proxy +- Intercepts package registry requests and analyzes packages as they are downloaded +- Blocks malicious packages and allows trusted packages to be installed ## Usage @@ -26,11 +26,11 @@ experimental_proxy_mode: true | Package Manager | Status | | --------------- | --------- | | `npm` | ✅ Active | -| `npx` | 🕒 Planned | -| `yarn` | 🕒 Planned | +| `npx` | ✅ Active | +| `pnpx` | ✅ Active | | `pnpm` | 🕒 Planned | -| `pnpx` | 🕒 Planned | +| `yarn` | 🕒 Planned | | `bun` | 🕒 Planned | | `pip` | 🕒 Planned | | `uv` | 🕒 Planned | -| `poetry` | 🕒 Planned | \ No newline at end of file +| `poetry` | 🕒 Planned | diff --git a/docs/trusted-packages.md b/docs/trusted-packages.md index 3bfd59e..2290be9 100644 --- a/docs/trusted-packages.md +++ b/docs/trusted-packages.md @@ -11,9 +11,8 @@ If you don't have a `config.yml` file, you can create one by running `pmg setup ```yaml trusted_packages: - - purl: pkg:npm/safedep/pmg + - purl: pkg:npm/@safedep/pmg reason: "All versions of PMG are trusted" - purl: pkg:npm/express@4.18.0 reason: "Version 4.18.0 of Express is a trusted package" ``` - diff --git a/go.mod b/go.mod index a0a3413..6ad2b60 100644 --- a/go.mod +++ b/go.mod @@ -4,24 +4,28 @@ go 1.25.1 tool github.com/golangci/golangci-lint/cmd/golangci-lint +replace github.com/KennethanCeyer/ptyx v0.2.0 => github.com/safedep/ptyx v0.2.0 + require ( buf.build/gen/go/safedep/api/grpc/go v1.5.1-20250418165058-162f6b0cc319.2 buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.6-20250418165058-162f6b0cc319.1 + github.com/KennethanCeyer/ptyx v0.2.0 github.com/Masterminds/semver v1.5.0 github.com/elazarl/goproxy v1.7.2 github.com/fatih/color v1.18.0 github.com/google/osv-scalibr v0.2.1 github.com/google/uuid v1.6.0 github.com/jedib0t/go-pretty/v6 v6.6.7 - github.com/mitchellh/mapstructure v1.5.0 github.com/posthog/posthog-go v1.5.12 github.com/safedep/dry v0.0.0-20250514080944-bb77f30c7175 github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 + go.yaml.in/yaml/v3 v3.0.4 + golang.org/x/sys v0.35.0 + golang.org/x/term v0.34.0 google.golang.org/grpc v1.72.0 - google.golang.org/protobuf v1.36.6 ) require ( @@ -207,19 +211,17 @@ require ( go.uber.org/automaxprocs v1.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect golang.org/x/mod v0.26.0 // indirect golang.org/x/net v0.42.0 // indirect golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.34.0 // indirect - golang.org/x/term v0.33.0 // indirect golang.org/x/text v0.28.0 // indirect golang.org/x/tools v0.35.0 // indirect golang.org/x/tools/go/expect v0.1.1-deprecated // indirect golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e // indirect + google.golang.org/protobuf v1.36.6 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index 2969483..bfc0e50 100644 --- a/go.sum +++ b/go.sum @@ -84,6 +84,8 @@ github.com/ckaznocha/intrange v0.3.0 h1:VqnxtK32pxgkhJgYQEeOArVidIPg+ahLP7WBOXZd github.com/ckaznocha/intrange v0.3.0/go.mod h1:+I/o2d2A1FBHgGELbGxzIcyd3/9l9DuwjM8FsbSS3Lo= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42 h1:Om6kYQYDUk5wWbT0t0q6pvyM49i9XZAv9dDrkDA7gjk= +github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/curioswitch/go-reassign v0.3.0 h1:dh3kpQHuADL3cobV/sSGETA8DOv457dwl+fbBAhrQPs= github.com/curioswitch/go-reassign v0.3.0/go.mod h1:nApPCCTtqLJN/s8HfItCcKV0jIPwluBOvZP+dsJGA88= @@ -102,7 +104,12 @@ github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVo github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M= +github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= +github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= +github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q= github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= @@ -308,8 +315,6 @@ github.com/mgechev/revive v1.7.0 h1:JyeQ4yO5K8aZhIKf5rec56u0376h8AlKNQEmjfkjKlY= github.com/mgechev/revive v1.7.0/go.mod h1:qZnwcNhoguE58dfi96IJeSTPeZQejNeoMQLUZGi4SW4= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= -github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/moricho/tparallel v0.3.2 h1:odr8aZVFA3NZrNybggMkYO3rgPRcqjeQUlBBFVxKHTI= github.com/moricho/tparallel v0.3.2/go.mod h1:OQ+K3b4Ln3l2TZveGCywybl68glfLEwFGqvnjok8b+U= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= @@ -343,6 +348,8 @@ github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -385,6 +392,8 @@ github.com/ryanrolds/sqlclosecheck v0.5.1 h1:dibWW826u0P8jNLsLN+En7+RqWWTYrjCB9f github.com/ryanrolds/sqlclosecheck v0.5.1/go.mod h1:2g3dUjoS6AL4huFdv6wn55WpLIDjY7ZgUR4J8HOO/XQ= github.com/safedep/dry v0.0.0-20250514080944-bb77f30c7175 h1:TxAI6m/v01CL+kwIYE3RZsuxu01pbuGy3wOi3WyBT1E= github.com/safedep/dry v0.0.0-20250514080944-bb77f30c7175/go.mod h1:Mdqx/Q2DhAcN38XiUNTGCC5MktofYDQW9Az7YWGEF0s= +github.com/safedep/ptyx v0.2.0 h1:4M3YlVVB25ze0ANnY8JgOznhMFjiGMQP/pSDbIHclik= +github.com/safedep/ptyx v0.2.0/go.mod h1:aLBSWDiEko9wd9zJj0hE8LiXpbLXM1t5QxuRWF22U4c= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/sanposhiho/wastedassign/v2 v2.1.0 h1:crurBF7fJKIORrV85u9UUpePDYGWnwvv3+A96WvwXT0= @@ -612,8 +621,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= @@ -622,8 +631,8 @@ golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= -golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= -golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= +golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= +golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= diff --git a/guard/guard.go b/guard/guard.go index 2d43343..a119ace 100644 --- a/guard/guard.go +++ b/guard/guard.go @@ -3,6 +3,7 @@ package guard import ( "context" "fmt" + "io" "os" "os/exec" "slices" @@ -36,6 +37,24 @@ type PackageManagerGuardInteraction struct { // packages are passed as arguments. These are the packages that were detected as malicious. // Client code must perform the necessary error handling and termination of the process. Block func(config *ui.BlockConfig) error + + // inputReader is the reader to use for user input during confirmations. + // If nil, os.Stdin is used. This is set via SetInput to allow PTY input routing. + inputReader io.Reader +} + +// SetInput sets the input reader for user confirmations. +// This allows the PTY switchboard to route input to the prompt during confirmations. +func (i *PackageManagerGuardInteraction) SetInput(r io.Reader) { + i.inputReader = r +} + +// Reader returns the configured input reader, or os.Stdin if none is set. +func (i *PackageManagerGuardInteraction) Reader() io.Reader { + if i.inputReader != nil { + return i.inputReader + } + return os.Stdin } type PackageManagerGuardConfig struct { diff --git a/internal/analytics/event.go b/internal/analytics/event.go index 25bb075..2fc3460 100644 --- a/internal/analytics/event.go +++ b/internal/analytics/event.go @@ -11,6 +11,9 @@ const ( eventCommandUv = "pmg_command_uv" eventCommandPoetry = "pmg_command_poetry" + eventCommandNpx = "pmg_command_npx" + eventCommandPnpx = "pmg_command_pnpx" + eventPmgGenerateEnvDocker = "pmg_command_generate_env_docker" eventPmgGenerateEnvGitHubActions = "pmg_command_generate_env_github_actions" eventPmgGenerateEnvGitLabCI = "pmg_command_generate_env_gitlab_ci" @@ -24,6 +27,14 @@ func TrackCommandNpm() { TrackEvent(eventCommandNpm) } +func TrackCommandNpx() { + TrackEvent(eventCommandNpx) +} + +func TrackCommandPnpx() { + TrackEvent(eventCommandPnpx) +} + func TrackCommandBun() { TrackEvent(eventCommandBun) } diff --git a/internal/flows/proxy_flow.go b/internal/flows/proxy_flow.go index 238fbf9..29103d2 100644 --- a/internal/flows/proxy_flow.go +++ b/internal/flows/proxy_flow.go @@ -2,16 +2,20 @@ package flows import ( "context" + "errors" "fmt" + "io" "os" "os/exec" "path/filepath" + "sync" "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/pty" "github.com/safedep/pmg/internal/ui" "github.com/safedep/pmg/packagemanager" "github.com/safedep/pmg/proxy" @@ -34,6 +38,15 @@ func ProxyFlow(pm packagemanager.PackageManager, packageResolver packagemanager. // Run executes the proxy-based flow func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagemanager.ParsedCommand) error { + + // 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()) + } + cfg := config.Get() // Check if dry-run mode is enabled @@ -82,24 +95,16 @@ func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagema 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()) + // Note: We use a pointer so we can later inject the input reader via SetInput + interaction := &guard.PackageManagerGuardInteraction{ + SetStatus: ui.SetStatus, + ClearStatus: ui.ClearStatus, + ShowWarning: ui.ShowWarning, + Block: ui.Block, } // Create ecosystem-specific interceptor using factory - factory := interceptors.NewInterceptorFactory(malysisAnalyzer, cache, confirmationChan, interaction) + factory := interceptors.NewInterceptorFactory(malysisAnalyzer, cache, confirmationChan) interceptor, err := factory.CreateInterceptor(ecosystem) if err != nil { return fmt.Errorf("failed to create interceptor for %s: %w", ecosystem.String(), err) @@ -127,8 +132,15 @@ func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagema log.Infof("Proxy server started on %s", proxyAddr) log.Infof("Running %s with proxy protection enabled", f.pm.Name()) + proxyEnv := f.setupEnvForProxy(proxyAddr, caCertPath) + + if !pty.IsInteractiveTerminal() { + // Execute the package manager command with proxy environment variables for non PTY or non-interactive TTY + return f.executeWithProxyForNonInteractiveTTY(ctx, parsedCmd, proxyEnv, confirmationChan, interaction) + } + // Execute the package manager command with proxy environment variables - return f.executeWithProxy(ctx, parsedCmd, proxyAddr, caCertPath, confirmationChan, interaction) + return f.executeWithProxy(ctx, parsedCmd, proxyEnv, confirmationChan, interaction) } // setupCACertificate generates or loads a CA certificate for MITM @@ -211,21 +223,11 @@ func (f *proxyFlow) createAndStartProxyServer( 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 +func (f *proxyFlow) setupEnvForProxy(proxyAddr, caCertPath string) []string { 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, + env := os.Environ() + env = append(env, fmt.Sprintf("HTTP_PROXY=%s", proxyURL), fmt.Sprintf("HTTPS_PROXY=%s", proxyURL), fmt.Sprintf("NODE_EXTRA_CA_CERTS=%s", caCertPath), @@ -237,32 +239,35 @@ func (f *proxyFlow) executeWithProxy(ctx context.Context, parsedCmd *packagemana fmt.Sprintf("PIP_PROXY=%s", proxyURL), ) + return env +} + +// executeWithProxyForNonInteractiveTTY runs the command without PTY (for CI/non-interactive environments) +func (f *proxyFlow) executeWithProxyForNonInteractiveTTY( + ctx context.Context, + parsedCmd *packagemanager.ParsedCommand, + env []string, + confirmationChan chan *interceptors.ConfirmationRequest, + interaction *guard.PackageManagerGuardInteraction, +) error { + log.Debugf("Executing proxy for non interactive TTY") + + // For non-interactive terminals, we enforce suspicious packages as malicious + interaction.GetConfirmationOnMalware = func(malwarePackages []*analyzer.PackageVersionAnalysisResult) (bool, error) { + return false, nil + } + + cmd := exec.CommandContext(ctx, parsedCmd.Command.Exe, parsedCmd.Command.Args...) + cmd.Env = append(env, "CI=true") 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 - }, - }) + go interceptors.HandleConfirmationRequests( + confirmationChan, + interaction, + nil, + ) err := cmd.Run() if err != nil { @@ -276,3 +281,122 @@ func (f *proxyFlow) executeWithProxy(ctx context.Context, parsedCmd *packagemana log.Debugf("Command completed successfully") return nil } + +// executeWithProxy executes the package manager command with proxy environment variables. +func (f *proxyFlow) executeWithProxy( + ctx context.Context, + parsedCmd *packagemanager.ParsedCommand, + env []string, + confirmationChan chan *interceptors.ConfirmationRequest, + interaction *guard.PackageManagerGuardInteraction, +) error { + log.Debugf("Executing proxy for interactive TTY") + + // Set the confirmation handler to use the interaction's reader + // This allows PTY input routing during proxy mode + interaction.GetConfirmationOnMalware = func(malwarePackages []*analyzer.PackageVersionAnalysisResult) (bool, error) { + return ui.GetConfirmationOnMalwareWithReader(malwarePackages, interaction.Reader()) + } + + sessionConfig := pty.NewSessionConfig(parsedCmd.Command.Exe, parsedCmd.Command.Args, env) + + sess, err := pty.NewSession(ctx, sessionConfig) + if err != nil { + return fmt.Errorf("failed to create pty session: %w", err) + } + defer sess.Close() + + outputRouter, err := pty.NewOutputRouter(os.Stdout) + if err != nil { + return fmt.Errorf("failed to create output router: %w", err) + } + + var wg sync.WaitGroup + wg.Go(func() { + io.Copy(outputRouter, sess.PtyReader()) + }) + + inputRouter, err := pty.NewInputRouter(sess.PtyWriter()) + if err != nil { + return fmt.Errorf("failed to create input router: %w", err) + } + + promptReader, promptWriter := io.Pipe() + defer func() { + promptWriter.Close() + promptReader.Close() + }() + + // Note: This goroutine cannot be cleanly cancelled because os.Stdin.Read() is + // a blocking syscall that doesn't support timeouts or cancellation. This is a + // known limitation. The goroutine will exit when the process terminates, which + // is acceptable for a CLI tool. For long-running servers, stdin reading should + // be handled differently. + go inputRouter.ReadLoop(os.Stdin) + + go interceptors.HandleConfirmationRequests( + confirmationChan, + interaction, + &interceptors.ConfirmationHook{ + BeforeInteraction: func(_ []*analyzer.PackageVersionAnalysisResult) error { + // Pause printing the child output + outputRouter.Pause() + + // Restore "Cooked" mode so user can type normally with echo + if err := sess.SetCookedMode(); err != nil { + return fmt.Errorf("failed to set cooked mode: %w", err) + } + + // Force cursor visible (ANSI escape sequence) + fmt.Fprint(os.Stdout, "\033[?25h") + + // Switch Input: Route keystrokes to the Prompt Pipe + inputRouter.RouteToPrompt(promptWriter) + + // Inject the Reader into the Interaction for the confirmation prompt + interaction.SetInput(promptReader) + + return nil + }, + AfterInteraction: func(_ []*analyzer.PackageVersionAnalysisResult, _ bool) error { + // Switch input back to PTY + inputRouter.RouteToPTY() + + // Restore "Raw" mode for the PTY + if err := sess.SetRawMode(); err != nil { + return fmt.Errorf("failed to set raw mode: %w", err) + } + + // Clear the interaction input (back to default) + interaction.SetInput(nil) + + // Flush buffered output and resume live output + outputRouter.Resume() + + return nil + }, + }, + ) + + err = sess.Wait() + + // Wait for the routers to copy all the remaining data + wg.Wait() + + if err != nil { + var exitErr *pty.ExitError + if errors.As(err, &exitErr) { + // Close writer and reader + promptWriter.Close() + promptReader.Close() + + // Close the session + sess.Close() + + os.Exit(exitErr.Code) + } + return err + } + + return nil +} diff --git a/internal/pty/router.go b/internal/pty/router.go new file mode 100644 index 0000000..b877a10 --- /dev/null +++ b/internal/pty/router.go @@ -0,0 +1,115 @@ +package pty + +import ( + "bytes" + "io" + "sync" + "sync/atomic" +) + +// OutputRouter manages buffered vs live output. +type OutputRouter struct { + mu sync.Mutex + stdout io.Writer + buffer bytes.Buffer + buffering bool +} + +func NewOutputRouter(out io.Writer) (*OutputRouter, error) { + return &OutputRouter{ + stdout: out, + }, nil +} + +func (r *OutputRouter) Write(p []byte) (n int, err error) { + r.mu.Lock() + defer r.mu.Unlock() + + if r.buffering { + // We are in "Prompt Mode", so save this output for later. + // If we printed it now, it would mess up the confirmation prompt. + return r.buffer.Write(p) + } + + // Normal mode: just print it to stdout. + return r.stdout.Write(p) +} + +// Pause starts buffering output. Call this before showing a confirmation prompt. +func (r *OutputRouter) Pause() { + r.mu.Lock() + defer r.mu.Unlock() + r.buffering = true +} + +// Resume stops buffering, flushes any buffered output, and resumes live output. +// Call this after the confirmation prompt is complete. +func (r *OutputRouter) Resume() { + r.mu.Lock() + defer r.mu.Unlock() + + // Flush any buffered output + if r.buffer.Len() > 0 { + _, _ = io.Copy(r.stdout, &r.buffer) + r.buffer.Reset() + } + + r.buffering = false +} + +// writerDest wraps io.Writer for use with atomic.Pointer +// (atomic.Value panics on nil interface stores) +type writerDest struct { + w io.Writer +} + +// InputRouter manages routing stdin to either PTY or a prompt pipe. +// Only ONE goroutine should call ReadLoop(). +type InputRouter struct { + dest atomic.Pointer[writerDest] + defaultDst io.Writer // PTY writer +} + +func NewInputRouter(ptyWriter io.Writer) (*InputRouter, error) { + return &InputRouter{ + defaultDst: ptyWriter, + }, nil +} + +// ReadLoop continuously reads from src and routes data to the current destination. +// +// IMPORTANT: Only ONE goroutine should call ReadLoop() because: +// 1. Multiple readers on the same source (e.g., stdin) cause data splitting - +// one goroutine might read "hel" while another reads "lo\n" +// 2. Concurrent routing decisions create race conditions on the destination +// 3. User input becomes unpredictably interleaved between readers +// +// This function blocks until src returns an error (e.g., EOF). +func (r *InputRouter) ReadLoop(src io.Reader) { + buf := make([]byte, 1024) + for { + nr, err := src.Read(buf) + if err != nil { + return + } + + // Check where to route the data + if dest := r.dest.Load(); dest != nil { + // Send confirmation prompt response to the pipe. (PMG) + _, _ = dest.w.Write(buf[:nr]) + } else { + // Send response to the child PTY. + _, _ = r.defaultDst.Write(buf[:nr]) + } + } +} + +// RouteToPrompt switches input to go to the given writer (prompt pipe) +func (r *InputRouter) RouteToPrompt(w io.Writer) { + r.dest.Store(&writerDest{w: w}) +} + +// RouteToPTY switches input back to the PTY (default) +func (r *InputRouter) RouteToPTY() { + r.dest.Store(nil) +} diff --git a/internal/pty/session.go b/internal/pty/session.go new file mode 100644 index 0000000..1b69580 --- /dev/null +++ b/internal/pty/session.go @@ -0,0 +1,175 @@ +package pty + +import ( + "context" + "fmt" + "io" + "os" + "strings" + + "github.com/KennethanCeyer/ptyx" + "golang.org/x/term" +) + +// InteractiveSession manages a PTY-based command execution with +// support for input/output routing and terminal mode switching. +type InteractiveSession interface { + // PtyWriter returns the writer to send input to the child process + PtyWriter() io.Writer + + // PtyReader returns the reader to receive output from the child process + PtyReader() io.Reader + + // SetRawMode puts terminal in raw mode (for PTY passthrough) + SetRawMode() error + + // SetCookedMode restores normal terminal mode (for prompts) + SetCookedMode() error + + // Wait blocks until the child process exits + // Returns ExitError if process exited with non-zero code + Wait() error + + // Close cleans up resources (PTY, terminal state) + Close() error +} + +// IsInteractiveTerminal returns true if stdin is a real terminal (TTY). +// Returns false in CI environments (when the "CI" env var set to "true"), +// when input is piped, or in non-interactive shells. +func IsInteractiveTerminal() bool { + if ci := os.Getenv("CI"); ci != "" && strings.ToLower(ci) == "true" { + return false + } + + return term.IsTerminal(int(os.Stdin.Fd())) +} + +var _ InteractiveSession = &session{} + +type session struct { + console ptyx.Console + spawn ptyx.Session + oldState ptyx.RawState // Saved terminal state for restoration +} + +// SessionConfig holds options for creating a session +type SessionConfig struct { + Command string + Args []string + Env []string +} + +func NewSessionConfig(cmd string, args, env []string) SessionConfig { + return SessionConfig{ + Command: cmd, + Args: args, + Env: env, + } +} + +// NewSession creates a new interactive PTY session. +// The terminal is put into raw mode automatically. +func NewSession(ctx context.Context, cfg SessionConfig) (InteractiveSession, error) { + if cfg.Command == "" { + return nil, fmt.Errorf("pty session requires command") + } + + // 1. Create console + c, err := ptyx.NewConsole() + if err != nil { + return nil, fmt.Errorf("failed to create console: %w", err) + } + c.EnableVT() + + // 2. Set raw mode, save old state + oldState, err := c.MakeRaw() + if err != nil { + c.Close() + return nil, fmt.Errorf("failed to set raw mode: %w", err) + } + + // 3. Get terminal size + cols, rows := c.Size() + + // 4. Spawn the process + s, err := ptyx.Spawn(ctx, ptyx.SpawnOpts{ + Prog: cfg.Command, + Args: cfg.Args, + Cols: cols, + Rows: rows, + Env: cfg.Env, + }) + if err != nil { + c.Restore(oldState) + c.Close() + return nil, fmt.Errorf("failed to spawn: %w", err) + } + + return &session{ + console: c, + spawn: s, + oldState: oldState, + }, nil +} + +func (s *session) PtyWriter() io.Writer { return s.spawn.PtyWriter() } +func (s *session) PtyReader() io.Reader { return s.spawn.PtyReader() } + +func (s *session) SetRawMode() error { + _, err := s.console.MakeRaw() + return err +} + +func (s *session) SetCookedMode() error { + return s.console.Restore(s.oldState) +} + +func (s *session) Wait() error { + err := s.spawn.Wait() + if err != nil { + if exitErr, ok := err.(*ptyx.ExitError); ok { + return &ExitError{Code: exitErr.ExitCode, Err: err} + } + return &ExitError{Code: -1, Err: err} + } + return nil +} + +func (s *session) Close() error { + // Always restore terminal state + if s.oldState != nil { + _ = s.console.Restore(s.oldState) + } + + if s.spawn != nil { + _ = s.spawn.Close() + } + + if s.console != nil { + _ = s.console.Close() + } + + return nil +} + +// ExitError is returned when the child process exits with non-zero code +type ExitError struct { + Code int + Err error // Underlying error from ptyx +} + +func (e *ExitError) Error() string { + if e.Code != 0 { + return fmt.Sprintf("process exited with code %d", e.Code) + } + if e.Err != nil { + return e.Err.Error() + } + return "unknown process error" +} + +// Unwrap allows errors.Is and errors.As to work +func (e *ExitError) Unwrap() error { + return e.Err +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 12f75e7..156c556 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -1,7 +1,9 @@ package ui import ( + "bufio" "fmt" + "io" "os" "strings" @@ -76,7 +78,15 @@ func SetStatus(status string) { StartSpinnerWithColor(fmt.Sprintf("ℹ️ %s", status), Colors.Green) } +// GetConfirmationOnMalware prompts the user to confirm installation of suspicious packages. +// It reads from os.Stdin. Use GetConfirmationOnMalwareWithReader for custom input sources. func GetConfirmationOnMalware(malwarePackages []*analyzer.PackageVersionAnalysisResult) (bool, error) { + return GetConfirmationOnMalwareWithReader(malwarePackages, os.Stdin) +} + +// GetConfirmationOnMalwareWithReader prompts the user to confirm installation of suspicious packages. +// It reads from the provided reader, allowing for PTY input routing during proxy mode. +func GetConfirmationOnMalwareWithReader(malwarePackages []*analyzer.PackageVersionAnalysisResult, reader io.Reader) (bool, error) { StopSpinner() fmt.Println() @@ -87,19 +97,19 @@ func GetConfirmationOnMalware(malwarePackages []*analyzer.PackageVersionAnalysis fmt.Println() fmt.Print(Colors.Yellow("Do you want to continue with the installation? (y/N) ")) - var response string - - // We don't care about the error here because we will return false - // if the user doesn't provide a valid response - _, _ = fmt.Scanln(&response) - - if len(response) == 0 { - return false, nil + // Use Scanner on the provided reader to support PTY input routing + scanner := bufio.NewScanner(reader) + if scanner.Scan() { + response := strings.ToLower(strings.TrimSpace(scanner.Text())) + if response == "y" || response == "yes" || (len(response) > 0 && response[0] == 'y') { + return true, nil + } } - response = strings.ToLower(response) - if response == "y" || response == "yes" || response[0] == 'y' { - return true, nil + // Check for scanner errors, but don't treat them as fatal + if err := scanner.Err(); err != nil { + // On EOF or interrupted read, just return false (deny) + return false, nil } return false, nil diff --git a/main.go b/main.go index 946c9b7..ac1ce92 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ import ( "os" "github.com/safedep/dry/log" + "github.com/safedep/pmg/cmd/executors" "github.com/safedep/pmg/cmd/npm" "github.com/safedep/pmg/cmd/pypi" "github.com/safedep/pmg/cmd/setup" @@ -94,6 +95,8 @@ func main() { cmd.AddCommand(npm.NewPnpmCommand()) cmd.AddCommand(npm.NewBunCommand()) cmd.AddCommand(npm.NewYarnCommand()) + cmd.AddCommand(executors.NewNpxCommand()) + cmd.AddCommand(executors.NewPnpxCommand()) cmd.AddCommand(pypi.NewPipCommand()) cmd.AddCommand(pypi.NewPip3Command()) cmd.AddCommand(pypi.NewUvCommand()) diff --git a/packagemanager/npm_executor.go b/packagemanager/npm_executor.go new file mode 100644 index 0000000..c1aa1c5 --- /dev/null +++ b/packagemanager/npm_executor.go @@ -0,0 +1,123 @@ +package packagemanager + +import ( + "io" + "slices" + "strings" + + packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" + "github.com/spf13/pflag" +) + +type NpmPackageExecutorConfig struct { + CommandName string +} + +func DefaultNpxPackageExecutorConfig() NpmPackageExecutorConfig { + return NpmPackageExecutorConfig{ + CommandName: "npx", + } +} + +func DefaultPnpxPackageExecutorConfig() NpmPackageExecutorConfig { + return NpmPackageExecutorConfig{ + CommandName: "pnpx", + } +} + +type npmPackageExecutor struct { + Config NpmPackageExecutorConfig +} + +func NewNpmPackageExecutor(config NpmPackageExecutorConfig) (*npmPackageExecutor, error) { + return &npmPackageExecutor{ + Config: config, + }, nil +} + +var _ PackageManager = &npmPackageExecutor{} + +func (n *npmPackageExecutor) Name() string { + return n.Config.CommandName +} + +func (n *npmPackageExecutor) Ecosystem() packagev1.Ecosystem { + return packagev1.Ecosystem_ECOSYSTEM_NPM +} + +func (n *npmPackageExecutor) ParseCommand(args []string) (*ParsedCommand, error) { + if len(args) > 0 && (args[0] == "npx" || args[0] == "pnpx") { + args = args[1:] + } + + command := Command{Exe: n.Config.CommandName, Args: args} + + if len(args) < 1 { + return &ParsedCommand{ + Command: command, + }, nil + } + + flagSet := pflag.NewFlagSet(n.Config.CommandName, pflag.ContinueOnError) + flagSet.SetOutput(io.Discard) + flagSet.ParseErrorsAllowlist.UnknownFlags = true + + var packages []string + switch n.Config.CommandName { + case "npx": + flagSet.StringArrayVarP(&packages, "package", "p", []string{}, "Package List") + case "pnpx": + flagSet.StringArrayVar(&packages, "package", []string{}, "Package List") + } + + err := flagSet.Parse(args) + if err != nil { + return &ParsedCommand{Command: command}, nil + } + + for _, arg := range flagSet.Args() { + // Append the scoped package + if strings.HasPrefix(arg, "@") && !slices.Contains(packages, arg) { + packages = append(packages, arg) + } + } + + // Unlike npx, pnpx does not separate package and binary; + // the first arg is always an install target. + if n.Config.CommandName == "pnpx" && len(flagSet.Args()) > 0 { + pkg := flagSet.Args()[0] + if !slices.Contains(packages, pkg) { + packages = append(packages, pkg) + } + } + + var installTargets []*PackageInstallTarget + + for _, pkg := range packages { + packageName, version, err := npmParsePackageInfo(pkg) + if err != nil { + return nil, ErrFailedToParsePackage.Wrap(err) + } + + if version != "" { + version = npmCleanVersion(version) + } + + installTarget := &PackageInstallTarget{ + PackageVersion: &packagev1.PackageVersion{ + Package: &packagev1.Package{ + Ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM, + Name: packageName, + }, + Version: version, + }, + } + + installTargets = append(installTargets, installTarget) + } + + return &ParsedCommand{ + Command: command, + InstallTargets: installTargets, + }, nil +} diff --git a/packagemanager/npm_executor_test.go b/packagemanager/npm_executor_test.go new file mode 100644 index 0000000..ef80096 --- /dev/null +++ b/packagemanager/npm_executor_test.go @@ -0,0 +1,211 @@ +package packagemanager + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNpxExecutorParseCommand(t *testing.T) { + cases := []struct { + name string + command string + assert func(t *testing.T, parsed *ParsedCommand, err error) + }{ + { + name: "bare npx invocation", + command: "npx", + assert: func(t *testing.T, parsed *ParsedCommand, err error) { + assert.NoError(t, err) + assert.NotNil(t, parsed) + assert.Equal(t, 0, len(parsed.InstallTargets)) + }, + }, + { + name: "scoped package via -p flag", + command: "npx -p @types/node", + assert: func(t *testing.T, parsed *ParsedCommand, err error) { + assert.NoError(t, err) + assert.Equal(t, 1, len(parsed.InstallTargets)) + assert.Equal(t, "@types/node", parsed.InstallTargets[0].PackageVersion.Package.Name) + assert.Empty(t, parsed.InstallTargets[0].PackageVersion.Version) + }, + }, + { + name: "scoped package with version", + command: "npx @types/node@1.2.3", + assert: func(t *testing.T, parsed *ParsedCommand, err error) { + assert.NoError(t, err) + assert.Equal(t, 1, len(parsed.InstallTargets)) + assert.Equal(t, "@types/node", parsed.InstallTargets[0].PackageVersion.Package.Name) + assert.Equal(t, "1.2.3", parsed.InstallTargets[0].PackageVersion.Version) + }, + }, + { + name: "non-package npx command", + command: "npx create-react-app my-app", + assert: func(t *testing.T, parsed *ParsedCommand, err error) { + assert.NoError(t, err) + assert.Equal(t, 0, len(parsed.InstallTargets)) + assert.Equal(t, []string{"create-react-app", "my-app"}, parsed.Command.Args) + }, + }, + { + name: "single package using -p flag npx command with binary", + command: "npx -p tsx my-app", + assert: func(t *testing.T, parsed *ParsedCommand, err error) { + assert.NoError(t, err) + assert.Equal(t, 1, len(parsed.InstallTargets)) + assert.Equal(t, "tsx", parsed.InstallTargets[0].PackageVersion.Package.Name) + assert.Empty(t, parsed.InstallTargets[0].PackageVersion.Version) + }, + }, + { + name: "single package using --package flag npx command with binary", + command: "npx --package=tsx my-app", + assert: func(t *testing.T, parsed *ParsedCommand, err error) { + assert.NoError(t, err) + assert.Equal(t, 1, len(parsed.InstallTargets)) + assert.Equal(t, "tsx", parsed.InstallTargets[0].PackageVersion.Package.Name) + assert.Empty(t, parsed.InstallTargets[0].PackageVersion.Version) + }, + }, + { + name: "multiple scoped packages via flags and args", + command: "npx -p @types/node @react@2.0.0", + assert: func(t *testing.T, parsed *ParsedCommand, err error) { + assert.NoError(t, err) + assert.Equal(t, 2, len(parsed.InstallTargets)) + assert.Equal(t, "@types/node", parsed.InstallTargets[0].PackageVersion.Package.Name) + assert.Empty(t, parsed.InstallTargets[0].PackageVersion.Version) + assert.Equal(t, "@react", parsed.InstallTargets[1].PackageVersion.Package.Name) + assert.Equal(t, "2.0.0", parsed.InstallTargets[1].PackageVersion.Version) + }, + }, + { + name: "multiple packages via flags and scoped", + command: "npx @types/node -p react@2.0.0", + assert: func(t *testing.T, parsed *ParsedCommand, err error) { + assert.NoError(t, err) + assert.Equal(t, 2, len(parsed.InstallTargets)) + assert.Equal(t, "@types/node", parsed.InstallTargets[1].PackageVersion.Package.Name) + assert.Empty(t, parsed.InstallTargets[1].PackageVersion.Version) + assert.Equal(t, "react", parsed.InstallTargets[0].PackageVersion.Package.Name) + assert.Equal(t, "2.0.0", parsed.InstallTargets[0].PackageVersion.Version) + }, + }, + { + name: "multiple packages via flags and args", + command: "npx -p node -p react@2.0.0", + assert: func(t *testing.T, parsed *ParsedCommand, err error) { + assert.NoError(t, err) + assert.Equal(t, 2, len(parsed.InstallTargets)) + assert.Equal(t, "node", parsed.InstallTargets[0].PackageVersion.Package.Name) + assert.Empty(t, parsed.InstallTargets[0].PackageVersion.Version) + assert.Equal(t, "react", parsed.InstallTargets[1].PackageVersion.Package.Name) + assert.Equal(t, "2.0.0", parsed.InstallTargets[1].PackageVersion.Version) + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + exec := &npmPackageExecutor{Config: DefaultNpxPackageExecutorConfig()} + parsed, err := exec.ParseCommand(strings.Split(tc.command, " ")) + tc.assert(t, parsed, err) + }) + } +} + +func TestPnpxExecutorParseCommand(t *testing.T) { + cases := []struct { + name string + command string + assert func(t *testing.T, parsed *ParsedCommand, err error) + }{ + { + name: "bare pnpx invocation", + command: "pnpx", + assert: func(t *testing.T, parsed *ParsedCommand, err error) { + assert.NoError(t, err) + assert.NotNil(t, parsed) + assert.Equal(t, 0, len(parsed.InstallTargets)) + }, + }, + { + name: "scoped package via package flag", + command: "pnpx --package=@types/node", + assert: func(t *testing.T, parsed *ParsedCommand, err error) { + assert.NoError(t, err) + assert.Equal(t, 1, len(parsed.InstallTargets)) + assert.Equal(t, "@types/node", parsed.InstallTargets[0].PackageVersion.Package.Name) + assert.Empty(t, parsed.InstallTargets[0].PackageVersion.Version) + }, + }, + { + name: "scoped package with version", + command: "pnpx @types/node@1.2.3", + assert: func(t *testing.T, parsed *ParsedCommand, err error) { + assert.NoError(t, err) + assert.Equal(t, 1, len(parsed.InstallTargets)) + assert.Equal(t, "@types/node", parsed.InstallTargets[0].PackageVersion.Package.Name) + assert.Equal(t, "1.2.3", parsed.InstallTargets[0].PackageVersion.Version) + }, + }, + { + name: "single package command", + command: "pnpx tsx my-app", + assert: func(t *testing.T, parsed *ParsedCommand, err error) { + assert.NoError(t, err) + assert.Equal(t, 1, len(parsed.InstallTargets)) + assert.Equal(t, "tsx", parsed.InstallTargets[0].PackageVersion.Package.Name) + assert.Empty(t, parsed.InstallTargets[0].PackageVersion.Version) + }, + }, + { + name: "multiple scoped packages via flags and args", + command: "pnpx --package @types/node @react@2.0.0", + assert: func(t *testing.T, parsed *ParsedCommand, err error) { + assert.NoError(t, err) + assert.Equal(t, 2, len(parsed.InstallTargets)) + assert.Equal(t, "@types/node", parsed.InstallTargets[0].PackageVersion.Package.Name) + assert.Empty(t, parsed.InstallTargets[0].PackageVersion.Version) + assert.Equal(t, "@react", parsed.InstallTargets[1].PackageVersion.Package.Name) + assert.Equal(t, "2.0.0", parsed.InstallTargets[1].PackageVersion.Version) + }, + }, + { + name: "multiple packages via flags and scoped", + command: "pnpx @types/node --package react@2.0.0", + assert: func(t *testing.T, parsed *ParsedCommand, err error) { + assert.NoError(t, err) + assert.Equal(t, 2, len(parsed.InstallTargets)) + assert.Equal(t, "@types/node", parsed.InstallTargets[1].PackageVersion.Package.Name) + assert.Empty(t, parsed.InstallTargets[1].PackageVersion.Version) + assert.Equal(t, "react", parsed.InstallTargets[0].PackageVersion.Package.Name) + assert.Equal(t, "2.0.0", parsed.InstallTargets[0].PackageVersion.Version) + }, + }, + { + name: "multiple packages via flags and args", + command: "pnpx --package node --package react@2.0.0", + assert: func(t *testing.T, parsed *ParsedCommand, err error) { + assert.NoError(t, err) + assert.Equal(t, 2, len(parsed.InstallTargets)) + assert.Equal(t, "node", parsed.InstallTargets[0].PackageVersion.Package.Name) + assert.Empty(t, parsed.InstallTargets[0].PackageVersion.Version) + assert.Equal(t, "react", parsed.InstallTargets[1].PackageVersion.Package.Name) + assert.Equal(t, "2.0.0", parsed.InstallTargets[1].PackageVersion.Version) + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + exec := &npmPackageExecutor{Config: DefaultPnpxPackageExecutorConfig()} + parsed, err := exec.ParseCommand(strings.Split(tc.command, " ")) + tc.assert(t, parsed, err) + }) + } +} diff --git a/packagemanager/pypi.go b/packagemanager/pypi.go index 0ec4f5c..c803556 100644 --- a/packagemanager/pypi.go +++ b/packagemanager/pypi.go @@ -132,7 +132,7 @@ func (p *pipCommandParser) ParseCommand(args []string) (*ParsedCommand, error) { flagSet := pflag.NewFlagSet(p.config.CommandName, pflag.ContinueOnError) flagSet.SetOutput(io.Discard) - flagSet.ParseErrorsWhitelist.UnknownFlags = true + flagSet.ParseErrorsAllowlist.UnknownFlags = true // Define flags var requirementFiles []string @@ -252,7 +252,7 @@ func (u *uvCommandParser) ParseCommand(args []string) (*ParsedCommand, error) { // Set up flag parsing flagSet := pflag.NewFlagSet("uv", pflag.ContinueOnError) flagSet.SetOutput(io.Discard) - flagSet.ParseErrorsWhitelist.UnknownFlags = true + flagSet.ParseErrorsAllowlist.UnknownFlags = true var manifestFiles []string @@ -349,7 +349,7 @@ func (p *poetryCommandParser) ParseCommand(args []string) (*ParsedCommand, error // Set up flag parsing flagSet := pflag.NewFlagSet("poetry", pflag.ContinueOnError) - flagSet.ParseErrorsWhitelist.UnknownFlags = true + flagSet.ParseErrorsAllowlist.UnknownFlags = true flagSet.SetOutput(io.Discard) err := flagSet.Parse(installArgs) diff --git a/proxy/interceptors/confirmation.go b/proxy/interceptors/confirmation.go index 0876368..3079a6c 100644 --- a/proxy/interceptors/confirmation.go +++ b/proxy/interceptors/confirmation.go @@ -31,7 +31,7 @@ type ConfirmationHook struct { // // The function will exit when the confirmation channel is closed. func HandleConfirmationRequests(confirmationChan chan *ConfirmationRequest, - interaction guard.PackageManagerGuardInteraction, hooks *ConfirmationHook) { + interaction *guard.PackageManagerGuardInteraction, hooks *ConfirmationHook) { if hooks == nil { hooks = &ConfirmationHook{} } diff --git a/proxy/interceptors/confirmation_test.go b/proxy/interceptors/confirmation_test.go index 8816dee..ccf4c82 100644 --- a/proxy/interceptors/confirmation_test.go +++ b/proxy/interceptors/confirmation_test.go @@ -118,7 +118,7 @@ func TestHandleConfirmationRequests(t *testing.T) { } confirmationChan := make(chan *ConfirmationRequest, 1) - go HandleConfirmationRequests(confirmationChan, interaction, hooks) + go HandleConfirmationRequests(confirmationChan, &interaction, hooks) pkgVersion := mockPackageVersion("test-package", "1.0.0") analysisResult := mockAnalysisResult() @@ -159,7 +159,7 @@ func TestHandleConfirmationRequests_MultipleSequential(t *testing.T) { } confirmationChan := make(chan *ConfirmationRequest, 3) - go HandleConfirmationRequests(confirmationChan, interaction, nil) + go HandleConfirmationRequests(confirmationChan, &interaction, nil) pkgVersion1 := mockPackageVersion("package-1", "1.0.0") analysisResult1 := mockAnalysisResult() diff --git a/proxy/interceptors/factory.go b/proxy/interceptors/factory.go index 0605fe2..4460938 100644 --- a/proxy/interceptors/factory.go +++ b/proxy/interceptors/factory.go @@ -5,7 +5,6 @@ import ( 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" ) @@ -14,7 +13,6 @@ type InterceptorFactory struct { analyzer analyzer.PackageVersionAnalyzer cache AnalysisCache confirmationChan chan *ConfirmationRequest - interaction guard.PackageManagerGuardInteraction } // NewInterceptorFactory creates a new interceptor factory with shared dependencies @@ -22,13 +20,11 @@ func NewInterceptorFactory( analyzer analyzer.PackageVersionAnalyzer, cache AnalysisCache, confirmationChan chan *ConfirmationRequest, - interaction guard.PackageManagerGuardInteraction, ) *InterceptorFactory { return &InterceptorFactory{ analyzer: analyzer, cache: cache, confirmationChan: confirmationChan, - interaction: interaction, } } @@ -41,7 +37,6 @@ func (f *InterceptorFactory) CreateInterceptor(ecosystem packagev1.Ecosystem) (p f.analyzer, f.cache, f.confirmationChan, - f.interaction, ), nil default: diff --git a/proxy/interceptors/npm_registry.go b/proxy/interceptors/npm_registry.go index b4f7f8b..5701e5b 100644 --- a/proxy/interceptors/npm_registry.go +++ b/proxy/interceptors/npm_registry.go @@ -6,7 +6,6 @@ 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" "github.com/safedep/pmg/proxy" ) @@ -30,14 +29,12 @@ 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, }, } }