Merge branch 'main' into feat/experimental-sandbox-support

Signed-off-by: Abhisek Datta <abhisek.datta@gmail.com>
This commit is contained in:
Abhisek Datta
2026-01-12 11:13:04 +05:30
committed by GitHub
36 changed files with 1377 additions and 347 deletions
+50
View File
@@ -217,6 +217,56 @@ jobs:
test -d node_modules/lodash test -d node_modules/lodash
cd .. && rm -rf yarn-test cd .. && rm -rf yarn-test
- name: Test NPX - Package Execution
run: |
echo "Testing NPX package execution..."
mkdir npx-test && cd npx-test
echo "Testing npx with a simple package..."
pmg npx cowsay@1.6.0 "Hello from pmg npx" | tee npx-output.txt
# Verification: cowsay output contains our message
grep -q "Hello from pmg npx" npx-output.txt
echo "Testing npx with --package flag..."
pmg npx --package cowsay@1.6.0 -- cowsay "Hello with package flag" | tee npx-pkg-output.txt
# Verification: package flag execution produces expected output
grep -q "Hello with package flag" npx-pkg-output.txt
echo "Testing npx dry-run mode..."
pmg --dry-run npx cowsay@1.6.0 "This should not execute" | tee npx-dry-output.txt
# Verification: dry-run should NOT produce cowsay ASCII art (cow face ^__^ should not appear)
! grep -q '\^__\^' npx-dry-output.txt
cd .. && rm -rf npx-test
- name: Test PNPX - Package Execution
run: |
echo "Testing PNPX package execution..."
mkdir pnpx-test && cd pnpx-test
echo "Testing pnpx with a simple package..."
pmg pnpx cowsay@1.6.0 "Hello from pmg pnpx" | tee pnpx-output.txt
# Verification: cowsay output contains our message
grep -q "Hello from pmg pnpx" pnpx-output.txt
echo "Testing pnpx with --package flag..."
pmg pnpx --package cowsay@1.6.0 -- cowsay "Hello with package flag" | tee pnpx-pkg-output.txt
# Verification: package flag execution produces expected output
grep -q "Hello with package flag" pnpx-pkg-output.txt
echo "Testing pnpx dry-run mode..."
pmg --dry-run pnpx cowsay@1.6.0 "This should not execute" | tee pnpx-dry-output.txt
# Verification: dry-run should NOT produce cowsay ASCII art (cow face ^__^ should not appear)
! grep -q '\^__\^' pnpx-dry-output.txt
cd .. && rm -rf pnpx-test
- name: Test Pip - Single Package & Manifest - name: Test Pip - Single Package & Manifest
run: | run: |
echo "Testing Pip single package installation..." echo "Testing Pip single package installation..."
+19 -24
View File
@@ -38,6 +38,8 @@ Set up `pmg` to protect your development environment from malicious packages:
pmg setup install pmg setup install
``` ```
> **Note:** Make sure to restart your terminal or source your shell's config file.
Continue using your favorite package manager as usual: Continue using your favorite package manager as usual:
```shell ```shell
@@ -70,6 +72,8 @@ PMG supports the following package managers:
| `pip` | ✅ Active | `pmg pip install <package>` | | `pip` | ✅ Active | `pmg pip install <package>` |
| `uv` | ✅ Active | `pmg uv add <package>` or `pmg uv pip install <package>` | | `uv` | ✅ Active | `pmg uv add <package>` or `pmg uv pip install <package>` |
| `poetry` | ✅ Active | `pmg poetry add <package>` | | `poetry` | ✅ Active | `pmg poetry add <package>` |
| `npx` | ✅ Active | `pmg npx <package> <action>` |
| `pnpx` | ✅ Active | `pmg pnpx <package> <action>` |
> Want us to support your favorite package manager? [Open an issue](https://github.com/safedep/pmg/issues) and let us know! > Want us to support your favorite package manager? [Open an issue](https://github.com/safedep/pmg/issues) and let us know!
@@ -108,11 +112,19 @@ Set up PMG to intercept package manager commands:
pmg setup install pmg setup install
``` ```
This command will: <details>
<summary>Custom config directory</summary>
```bash
PMG_CONFIG_DIR=/path/to/config pmg setup install
```
</details>
The setup command will:
- Create a `~/.pmg.rc` file containing package manager aliases - Create a `~/.pmg.rc` file containing package manager aliases
- Automatically add a source line to your shell configuration files - Automatically add a source line to your shell configuration files
- Supports bash, zsh and fish shell - Create a default config file. See [config template](config/config.template.yml)
> **Note**: After running `pmg setup install`, restart your terminal or run `source ~/.zshrc` (or your shell's config file) to activate the aliases. > **Note**: After running `pmg setup install`, restart your terminal or run `source ~/.zshrc` (or your shell's config file) to activate the aliases.
@@ -209,6 +221,11 @@ pmg npm install <package-name>
</details> </details>
## Advanced
- [Trusted Packages](docs/trusted-packages.md)
- [Experimental Proxy Mode](docs/proxy-mode.md)
## Contributing ## Contributing
Refer to [CONTRIBUTING.md](CONTRIBUTING.md) Refer to [CONTRIBUTING.md](CONTRIBUTING.md)
@@ -239,28 +256,6 @@ from non-PyPI locations.
</details> </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 ## Telemetry
`pmg` collects anonymous telemetry to help us understand how it is used and `pmg` collects anonymous telemetry to help us understand how it is used and
+59
View File
@@ -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)
}
+59
View File
@@ -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)
}
+4
View File
@@ -51,5 +51,9 @@ func executeBunFlow(ctx context.Context, args []string) error {
return fmt.Errorf("failed to create dependency resolver: %w", err) 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) return flows.Common(packageManager, packageResolver).Run(ctx, args, parsedCommand)
} }
+4
View File
@@ -52,5 +52,9 @@ func executePnpmFlow(ctx context.Context, args []string) error {
return fmt.Errorf("failed to create dependency resolver: %w", err) 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) return flows.Common(packageManager, packageResolver).Run(ctx, args, parsedCommand)
} }
+4
View File
@@ -51,5 +51,9 @@ func executeYarnFlow(ctx context.Context, args []string) error {
return fmt.Errorf("failed to create dependency resolver: %w", err) 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) return flows.Common(packageManager, packageResolver).Run(ctx, args, parsedCommand)
} }
+94
View File
@@ -0,0 +1,94 @@
package setup
import (
"fmt"
"strconv"
"github.com/safedep/pmg/config"
"github.com/safedep/pmg/internal/alias"
"github.com/safedep/pmg/internal/ui"
"github.com/safedep/pmg/internal/version"
"github.com/spf13/cobra"
)
func NewInfoCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "info",
Short: "Show information about PMG setup and configuration.",
RunE: func(cmd *cobra.Command, args []string) error {
err := executeSetupInfo()
if err != nil {
ui.ErrorExit(fmt.Errorf("failed to execute setup info: %w", err))
}
return nil
},
}
return cmd
}
func executeSetupInfo() error {
fmt.Print(ui.GeneratePMGBanner(version.Version, version.Commit))
// Configuration section
cfg := config.Get()
configEntries := make(map[string]string)
configEntries["Config File"] = cfg.ConfigFilePath()
configEntries["Proxy Mode"] = strconv.FormatBool(cfg.Config.ExperimentalProxyMode)
ui.PrintInfoSection("Configuration", configEntries)
// Shell Integration section
aliasCfg := alias.DefaultConfig()
rcFileManager, err := alias.NewDefaultRcFileManager(aliasCfg.RcFileName)
if err != nil {
return fmt.Errorf("failed to create alias manager: %w", err)
}
aliasManager := alias.New(aliasCfg, rcFileManager)
isInstalled, err := aliasManager.IsInstalled()
if err != nil {
isInstalled = false
}
shellEntries := make(map[string]string)
shell, err := alias.DetectShell()
if err != nil {
shell = "unknown"
}
shellEntries["Detected Shell"] = shell
shellEntries["Alias Installed"] = strconv.FormatBool(isInstalled)
ui.PrintInfoSection("Shell Integration", shellEntries)
// Security section
securityEntries := make(map[string]string)
trustedPackages := cfg.Config.TrustedPackages
trustedPackagesCount := len(trustedPackages)
if trustedPackagesCount > 3 {
purls := []string{}
for _, p := range trustedPackages[0:3] {
purls = append(purls, p.Purl)
}
trustedPackagesValue := fmt.Sprintf("%v ...and %d more", purls, trustedPackagesCount-3)
securityEntries["Trusted Packages"] = trustedPackagesValue
} else if trustedPackagesCount > 0 {
purls := []string{}
for _, p := range trustedPackages {
purls = append(purls, p.Purl)
}
securityEntries["Trusted Packages"] = fmt.Sprintf("%v", purls)
} else {
securityEntries["Trusted Packages"] = "None"
}
securityEntries["Event Logging"] = strconv.FormatBool(!cfg.Config.SkipEventLogging)
securityEntries["Event Log Directory"] = cfg.EventLogDir()
ui.PrintInfoSection("Security", securityEntries)
return nil
}
+1
View File
@@ -27,6 +27,7 @@ func NewSetupCommand() *cobra.Command {
setupCmd.AddCommand(NewInstallCommand()) setupCmd.AddCommand(NewInstallCommand())
setupCmd.AddCommand(NewRemoveCommand()) setupCmd.AddCommand(NewRemoveCommand())
setupCmd.AddCommand(NewInfoCommand())
return setupCmd return setupCmd
} }
+36
View File
@@ -0,0 +1,36 @@
# Proxy Mode
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
- 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
```bash
pmg --experimental-proxy-mode npm install lodash
```
## Configuration
To permanently enable proxy mode, add the following to your `config.yml` file:
```yaml
experimental_proxy_mode: true
```
## Supported Package Managers
| Package Manager | Status |
| --------------- | --------- |
| `npm` | ✅ Active |
| `npx` | ✅ Active |
| `pnpm` | ✅ Active |
| `pnpx` | ✅ Active |
| `bun` | ✅ Active |
| `yarn` | ✅ Active |
| `pip` | 🕒 Planned |
| `uv` | 🕒 Planned |
| `poetry` | 🕒 Planned |
+18
View File
@@ -0,0 +1,18 @@
# Trusted Packages
`pmg` allows you to trust a package. Trusted packages are not scanned and always allowed to be installed.
## Configuration
Trusted packages are configured in the `config.yml` file. See [config template](../config/config.template.yml) for the configuration schema.
If you don't have a `config.yml` file, you can create one by running `pmg setup install`.
### Example
```yaml
trusted_packages:
- 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"
```
+6 -4
View File
@@ -4,9 +4,12 @@ go 1.25.1
tool github.com/golangci/golangci-lint/cmd/golangci-lint 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 ( require (
buf.build/gen/go/safedep/api/grpc/go v1.5.1-20250418165058-162f6b0cc319.2 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 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/Masterminds/semver v1.5.0
github.com/elazarl/goproxy v1.7.2 github.com/elazarl/goproxy v1.7.2
github.com/fatih/color v1.18.0 github.com/fatih/color v1.18.0
@@ -19,9 +22,9 @@ require (
github.com/spf13/pflag v1.0.10 github.com/spf13/pflag v1.0.10
github.com/spf13/viper v1.21.0 github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
golang.org/x/sys v0.34.0 go.yaml.in/yaml/v3 v3.0.4
golang.org/x/term v0.34.0
google.golang.org/grpc v1.72.0 google.golang.org/grpc v1.72.0
gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
@@ -207,13 +210,12 @@ require (
go.uber.org/automaxprocs v1.6.0 // indirect go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.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 v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect
golang.org/x/mod v0.26.0 // indirect golang.org/x/mod v0.26.0 // indirect
golang.org/x/net v0.42.0 // indirect golang.org/x/net v0.42.0 // indirect
golang.org/x/sync v0.16.0 // indirect golang.org/x/sync v0.16.0 // indirect
golang.org/x/term v0.33.0 // indirect golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.28.0 // indirect golang.org/x/text v0.28.0 // indirect
golang.org/x/tools v0.35.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/expect v0.1.1-deprecated // indirect
+6 -4
View File
@@ -392,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/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 h1:TxAI6m/v01CL+kwIYE3RZsuxu01pbuGy3wOi3WyBT1E=
github.com/safedep/dry v0.0.0-20250514080944-bb77f30c7175/go.mod h1:Mdqx/Q2DhAcN38XiUNTGCC5MktofYDQW9Az7YWGEF0s= 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 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sanposhiho/wastedassign/v2 v2.1.0 h1:crurBF7fJKIORrV85u9UUpePDYGWnwvv3+A96WvwXT0= github.com/sanposhiho/wastedassign/v2 v2.1.0 h1:crurBF7fJKIORrV85u9UUpePDYGWnwvv3+A96WvwXT0=
@@ -619,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.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.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.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 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-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.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
@@ -629,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.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= 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.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= 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.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.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+19
View File
@@ -3,6 +3,7 @@ package guard
import ( import (
"context" "context"
"fmt" "fmt"
"io"
"os" "os"
"os/exec" "os/exec"
"slices" "slices"
@@ -37,6 +38,24 @@ type PackageManagerGuardInteraction struct {
// packages are passed as arguments. These are the packages that were detected as malicious. // 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. // Client code must perform the necessary error handling and termination of the process.
Block func(config *ui.BlockConfig) error 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 { type PackageManagerGuardConfig struct {
+29 -1
View File
@@ -95,7 +95,7 @@ func DefaultConfig() AliasConfig {
return AliasConfig{ return AliasConfig{
RcFileName: ".pmg.rc", RcFileName: ".pmg.rc",
PackageManagers: []string{"npm", "pip", "pip3", "pnpm", "bun", "uv", "yarn", "poetry"}, PackageManagers: []string{"npm", "pip", "pip3", "pnpm", "bun", "uv", "yarn", "poetry", "npx", "pnpx"},
Shells: shells, Shells: shells,
} }
} }
@@ -142,6 +142,34 @@ func (a *AliasManager) Remove() error {
return nil return nil
} }
// IsInstalled checks if the PMG aliases are sourced in any of the shell config files.
func (a *AliasManager) IsInstalled() (bool, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return false, err
}
for _, shell := range a.config.Shells {
configPath := filepath.Join(homeDir, shell.Path())
data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
continue
}
log.Warnf("Warning: could not read %s (%s)", shell.Name(), err)
continue
}
if strings.Contains(string(data), a.config.RcFileName) {
return true, nil
}
}
return false, nil
}
// buildAliases creates the alias strings for all configured package managers. // buildAliases creates the alias strings for all configured package managers.
func (a *AliasManager) buildAliases() []string { func (a *AliasManager) buildAliases() []string {
aliases := make([]string, 0, len(a.config.PackageManagers)) aliases := make([]string, 0, len(a.config.PackageManagers))
+18 -1
View File
@@ -1,6 +1,10 @@
package alias package alias
import "fmt" import (
"fmt"
"os"
"strings"
)
type Shell interface { type Shell interface {
Source(rcPath string) string Source(rcPath string) string
@@ -13,3 +17,16 @@ var commentForRemovingShellSource = "# remove aliases by running `pmg setup remo
func defaultShellSource(rcPath string) string { func defaultShellSource(rcPath string) string {
return fmt.Sprintf("%s \n[ -f '%s' ] && source '%s' # PMG source aliases\n", commentForRemovingShellSource, rcPath, rcPath) return fmt.Sprintf("%s \n[ -f '%s' ] && source '%s' # PMG source aliases\n", commentForRemovingShellSource, rcPath, rcPath)
} }
// DetectShell attempts to detect the current shell from the SHELL environment variable.
func DetectShell() (string, error) {
shellEnv := os.Getenv("SHELL")
if shellEnv == "" {
return "", fmt.Errorf("SHELL environment variable not set")
}
parts := strings.Split(shellEnv, "/")
shellName := parts[len(parts)-1]
return shellName, nil
}
+56
View File
@@ -0,0 +1,56 @@
package alias
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
)
func TestDetectShell(t *testing.T) {
cases := []struct {
name string
shellEnvValue string
want string
wantErr error
}{
{
name: "bash full path",
shellEnvValue: "/bin/bash",
want: "bash",
wantErr: nil,
},
{
name: "zsh full path",
shellEnvValue: "/bin/zsh",
want: "zsh",
wantErr: nil,
},
{
name: "bash only name",
shellEnvValue: "bash",
want: "bash",
wantErr: nil,
},
{
name: "when shell env is not set",
shellEnvValue: "",
want: "",
wantErr: fmt.Errorf("SHELL environment variable not set"),
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("SHELL", tc.shellEnvValue)
got, err := DetectShell()
if tc.wantErr != nil {
assert.ErrorContains(t, err, tc.wantErr.Error())
} else {
assert.NoError(t, err)
assert.Equal(t, tc.want, got)
}
})
}
}
+11
View File
@@ -11,6 +11,9 @@ const (
eventCommandUv = "pmg_command_uv" eventCommandUv = "pmg_command_uv"
eventCommandPoetry = "pmg_command_poetry" eventCommandPoetry = "pmg_command_poetry"
eventCommandNpx = "pmg_command_npx"
eventCommandPnpx = "pmg_command_pnpx"
eventPmgGenerateEnvDocker = "pmg_command_generate_env_docker" eventPmgGenerateEnvDocker = "pmg_command_generate_env_docker"
eventPmgGenerateEnvGitHubActions = "pmg_command_generate_env_github_actions" eventPmgGenerateEnvGitHubActions = "pmg_command_generate_env_github_actions"
eventPmgGenerateEnvGitLabCI = "pmg_command_generate_env_gitlab_ci" eventPmgGenerateEnvGitLabCI = "pmg_command_generate_env_gitlab_ci"
@@ -24,6 +27,14 @@ func TrackCommandNpm() {
TrackEvent(eventCommandNpm) TrackEvent(eventCommandNpm)
} }
func TrackCommandNpx() {
TrackEvent(eventCommandNpx)
}
func TrackCommandPnpx() {
TrackEvent(eventCommandPnpx)
}
func TrackCommandBun() { func TrackCommandBun() {
TrackEvent(eventCommandBun) TrackEvent(eventCommandBun)
} }
-33
View File
@@ -1,33 +0,0 @@
//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
@@ -1,78 +0,0 @@
//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
}
@@ -1,114 +0,0 @@
//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)
}
}
+175 -59
View File
@@ -2,16 +2,20 @@ package flows
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"io"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"sync"
"time" "time"
"github.com/safedep/dry/log" "github.com/safedep/dry/log"
"github.com/safedep/pmg/analyzer" "github.com/safedep/pmg/analyzer"
"github.com/safedep/pmg/config" "github.com/safedep/pmg/config"
"github.com/safedep/pmg/guard" "github.com/safedep/pmg/guard"
"github.com/safedep/pmg/internal/pty"
"github.com/safedep/pmg/internal/ui" "github.com/safedep/pmg/internal/ui"
"github.com/safedep/pmg/packagemanager" "github.com/safedep/pmg/packagemanager"
"github.com/safedep/pmg/proxy" "github.com/safedep/pmg/proxy"
@@ -35,6 +39,15 @@ func ProxyFlow(pm packagemanager.PackageManager, packageResolver packagemanager.
// Run executes the proxy-based flow // Run executes the proxy-based flow
func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagemanager.ParsedCommand) error { 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() cfg := config.Get()
// Check if dry-run mode is enabled // Check if dry-run mode is enabled
@@ -83,24 +96,16 @@ func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagema
defer close(confirmationChan) defer close(confirmationChan)
// Create interaction callbacks for user prompts // Create interaction callbacks for user prompts
interaction := guard.PackageManagerGuardInteraction{ // Note: We use a pointer so we can later inject the input reader via SetInput
SetStatus: ui.SetStatus, interaction := &guard.PackageManagerGuardInteraction{
ClearStatus: ui.ClearStatus, SetStatus: ui.SetStatus,
ShowWarning: ui.ShowWarning, ClearStatus: ui.ClearStatus,
GetConfirmationOnMalware: ui.GetConfirmationOnMalware, ShowWarning: ui.ShowWarning,
Block: ui.Block, 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 // Create ecosystem-specific interceptor using factory
factory := interceptors.NewInterceptorFactory(malysisAnalyzer, cache, confirmationChan, interaction) factory := interceptors.NewInterceptorFactory(malysisAnalyzer, cache, confirmationChan)
interceptor, err := factory.CreateInterceptor(ecosystem) interceptor, err := factory.CreateInterceptor(ecosystem)
if err != nil { if err != nil {
return fmt.Errorf("failed to create interceptor for %s: %w", ecosystem.String(), err) return fmt.Errorf("failed to create interceptor for %s: %w", ecosystem.String(), err)
@@ -128,8 +133,15 @@ func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagema
log.Infof("Proxy server started on %s", proxyAddr) log.Infof("Proxy server started on %s", proxyAddr)
log.Infof("Running %s with proxy protection enabled", f.pm.Name()) 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 // 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 // setupCACertificate generates or loads a CA certificate for MITM
@@ -212,21 +224,11 @@ func (f *proxyFlow) createAndStartProxyServer(
return proxyServer, proxyAddr, nil return proxyServer, proxyAddr, nil
} }
// executeWithProxy executes the package manager command with proxy environment variables func (f *proxyFlow) setupEnvForProxy(proxyAddr, caCertPath string) []string {
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) proxyURL := fmt.Sprintf("http://%s", proxyAddr)
// Create command env := os.Environ()
cmd := exec.CommandContext(ctx, parsedCmd.Command.Exe, parsedCmd.Command.Args...) env = append(env,
// 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("HTTP_PROXY=%s", proxyURL),
fmt.Sprintf("HTTPS_PROXY=%s", proxyURL), fmt.Sprintf("HTTPS_PROXY=%s", proxyURL),
fmt.Sprintf("NODE_EXTRA_CA_CERTS=%s", caCertPath), fmt.Sprintf("NODE_EXTRA_CA_CERTS=%s", caCertPath),
@@ -238,40 +240,35 @@ func (f *proxyFlow) executeWithProxy(ctx context.Context, parsedCmd *packagemana
fmt.Sprintf("PIP_PROXY=%s", proxyURL), 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.Stdin = os.Stdin
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr cmd.Stderr = os.Stderr
pmName := f.pm.Name() go interceptors.HandleConfirmationRequests(
result, err := executor.ApplySandbox(ctx, cmd, pmName) confirmationChan,
if err != nil { interaction,
return fmt.Errorf("failed to apply sandbox: %w", err) nil,
} )
defer result.Close()
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
},
})
// Only run the command if the sandbox didn't already execute it // Only run the command if the sandbox didn't already execute it
if result.ShouldRun() { if result.ShouldRun() {
@@ -288,3 +285,122 @@ func (f *proxyFlow) executeWithProxy(ctx context.Context, parsedCmd *packagemana
log.Debugf("Command completed successfully") log.Debugf("Command completed successfully")
return nil 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
}
+115
View File
@@ -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)
}
+175
View File
@@ -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
}
+3 -4
View File
@@ -9,7 +9,6 @@ import (
var ( var (
brandPinkRed = color.RGB(219, 39, 119).Add(color.Bold).SprintFunc() // #DB2777 Brand Pink brandPinkRed = color.RGB(219, 39, 119).Add(color.Bold).SprintFunc() // #DB2777 Brand Pink
whiteDim = color.New(color.Faint).SprintFunc() whiteDim = color.New(color.Faint).SprintFunc()
whiteBold = color.New(color.Bold).SprintFunc()
) )
func GeneratePMGBanner(version, commit string) string { func GeneratePMGBanner(version, commit string) string {
@@ -21,8 +20,8 @@ func GeneratePMGBanner(version, commit string) string {
commit = commit[:6] commit = commit[:6]
} }
return fmt.Sprintf("%s \t%s: %s %s: %s\n\n", brandPinkRed(pmgASCIIText), return fmt.Sprintf("%s %s: %s %s: %s\n\n", brandPinkRed(pmgASCIIText),
whiteDim("version"), whiteBold(version), whiteDim("version"), Colors.Bold(version),
whiteDim("commit"), whiteBold(commit), whiteDim("commit"), Colors.Bold(commit),
) )
} }
+2
View File
@@ -10,6 +10,7 @@ type TerminalColors struct {
Yellow ColorFn Yellow ColorFn
Cyan ColorFn Cyan ColorFn
Green ColorFn Green ColorFn
Bold ColorFn
} }
var Colors = TerminalColors{ var Colors = TerminalColors{
@@ -18,4 +19,5 @@ var Colors = TerminalColors{
Yellow: color.New(color.FgYellow).SprintfFunc(), Yellow: color.New(color.FgYellow).SprintfFunc(),
Cyan: color.New(color.FgCyan).SprintfFunc(), Cyan: color.New(color.FgCyan).SprintfFunc(),
Green: color.New(color.FgGreen).SprintfFunc(), Green: color.New(color.FgGreen).SprintfFunc(),
Bold: color.New(color.Bold).SprintfFunc(),
} }
+25
View File
@@ -0,0 +1,25 @@
package ui
import (
"fmt"
"sort"
)
// PrintInfoSection prints a formatted block of key-value information.
func PrintInfoSection(title string, entries map[string]string) {
fmt.Println()
fmt.Println(Colors.Cyan(title))
fmt.Println(Colors.Normal("--------------------"))
// Sort keys for consistent output
keys := make([]string, 0, len(entries))
for k := range entries {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Printf("%-25s: %s\n", Colors.Bold(k), entries[k])
}
}
+21 -11
View File
@@ -1,7 +1,9 @@
package ui package ui
import ( import (
"bufio"
"fmt" "fmt"
"io"
"os" "os"
"strings" "strings"
@@ -76,7 +78,15 @@ func SetStatus(status string) {
StartSpinnerWithColor(fmt.Sprintf("️ %s", status), Colors.Green) 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) { 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() StopSpinner()
fmt.Println() fmt.Println()
@@ -87,19 +97,19 @@ func GetConfirmationOnMalware(malwarePackages []*analyzer.PackageVersionAnalysis
fmt.Println() fmt.Println()
fmt.Print(Colors.Yellow("Do you want to continue with the installation? (y/N) ")) fmt.Print(Colors.Yellow("Do you want to continue with the installation? (y/N) "))
var response string // Use Scanner on the provided reader to support PTY input routing
scanner := bufio.NewScanner(reader)
// We don't care about the error here because we will return false if scanner.Scan() {
// if the user doesn't provide a valid response response := strings.ToLower(strings.TrimSpace(scanner.Text()))
_, _ = fmt.Scanln(&response) if response == "y" || response == "yes" || (len(response) > 0 && response[0] == 'y') {
return true, nil
if len(response) == 0 { }
return false, nil
} }
response = strings.ToLower(response) // Check for scanner errors, but don't treat them as fatal
if response == "y" || response == "yes" || response[0] == 'y' { if err := scanner.Err(); err != nil {
return true, nil // On EOF or interrupted read, just return false (deny)
return false, nil
} }
return false, nil return false, nil
+3
View File
@@ -5,6 +5,7 @@ import (
"os" "os"
"github.com/safedep/dry/log" "github.com/safedep/dry/log"
"github.com/safedep/pmg/cmd/executors"
"github.com/safedep/pmg/cmd/npm" "github.com/safedep/pmg/cmd/npm"
"github.com/safedep/pmg/cmd/pypi" "github.com/safedep/pmg/cmd/pypi"
"github.com/safedep/pmg/cmd/setup" "github.com/safedep/pmg/cmd/setup"
@@ -94,6 +95,8 @@ func main() {
cmd.AddCommand(npm.NewPnpmCommand()) cmd.AddCommand(npm.NewPnpmCommand())
cmd.AddCommand(npm.NewBunCommand()) cmd.AddCommand(npm.NewBunCommand())
cmd.AddCommand(npm.NewYarnCommand()) cmd.AddCommand(npm.NewYarnCommand())
cmd.AddCommand(executors.NewNpxCommand())
cmd.AddCommand(executors.NewPnpxCommand())
cmd.AddCommand(pypi.NewPipCommand()) cmd.AddCommand(pypi.NewPipCommand())
cmd.AddCommand(pypi.NewPip3Command()) cmd.AddCommand(pypi.NewPip3Command())
cmd.AddCommand(pypi.NewUvCommand()) cmd.AddCommand(pypi.NewUvCommand())
+125
View File
@@ -0,0 +1,125 @@
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)
}
}
// For both npx and pnpx, the first positional argument is typically
// the package to execute (e.g., `npx cowsay@1.6.0` or `pnpx cowsay@1.6.0`).
// However, if -p/--package flags are provided, the first positional arg
// is the binary to run, not the package (e.g., `npx -p typescript tsc`).
if len(flagSet.Args()) > 0 && len(packages) == 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
}
+234
View File
@@ -0,0 +1,234 @@
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: "package as first positional arg",
command: "npx create-react-app 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, "create-react-app", parsed.InstallTargets[0].PackageVersion.Package.Name)
assert.Equal(t, []string{"create-react-app", "my-app"}, parsed.Command.Args)
},
},
{
name: "package with version as first positional arg",
command: "npx cowsay@1.6.0 hello",
assert: func(t *testing.T, parsed *ParsedCommand, err error) {
assert.NoError(t, err)
assert.Equal(t, 1, len(parsed.InstallTargets))
assert.Equal(t, "cowsay", parsed.InstallTargets[0].PackageVersion.Package.Name)
assert.Equal(t, "1.6.0", parsed.InstallTargets[0].PackageVersion.Version)
},
},
{
name: "package via -p flag with different binary",
command: "npx -p typescript tsc --version",
assert: func(t *testing.T, parsed *ParsedCommand, err error) {
assert.NoError(t, err)
assert.Equal(t, 1, len(parsed.InstallTargets))
assert.Equal(t, "typescript", parsed.InstallTargets[0].PackageVersion.Package.Name)
assert.Empty(t, parsed.InstallTargets[0].PackageVersion.Version)
// tsc is the binary, not a package
assert.Equal(t, []string{"-p", "typescript", "tsc", "--version"}, 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)
})
}
}
+3 -3
View File
@@ -132,7 +132,7 @@ func (p *pipCommandParser) ParseCommand(args []string) (*ParsedCommand, error) {
flagSet := pflag.NewFlagSet(p.config.CommandName, pflag.ContinueOnError) flagSet := pflag.NewFlagSet(p.config.CommandName, pflag.ContinueOnError)
flagSet.SetOutput(io.Discard) flagSet.SetOutput(io.Discard)
flagSet.ParseErrorsWhitelist.UnknownFlags = true flagSet.ParseErrorsAllowlist.UnknownFlags = true
// Define flags // Define flags
var requirementFiles []string var requirementFiles []string
@@ -252,7 +252,7 @@ func (u *uvCommandParser) ParseCommand(args []string) (*ParsedCommand, error) {
// Set up flag parsing // Set up flag parsing
flagSet := pflag.NewFlagSet("uv", pflag.ContinueOnError) flagSet := pflag.NewFlagSet("uv", pflag.ContinueOnError)
flagSet.SetOutput(io.Discard) flagSet.SetOutput(io.Discard)
flagSet.ParseErrorsWhitelist.UnknownFlags = true flagSet.ParseErrorsAllowlist.UnknownFlags = true
var manifestFiles []string var manifestFiles []string
@@ -349,7 +349,7 @@ func (p *poetryCommandParser) ParseCommand(args []string) (*ParsedCommand, error
// Set up flag parsing // Set up flag parsing
flagSet := pflag.NewFlagSet("poetry", pflag.ContinueOnError) flagSet := pflag.NewFlagSet("poetry", pflag.ContinueOnError)
flagSet.ParseErrorsWhitelist.UnknownFlags = true flagSet.ParseErrorsAllowlist.UnknownFlags = true
flagSet.SetOutput(io.Discard) flagSet.SetOutput(io.Discard)
err := flagSet.Parse(installArgs) err := flagSet.Parse(installArgs)
+1 -1
View File
@@ -31,7 +31,7 @@ type ConfirmationHook struct {
// //
// The function will exit when the confirmation channel is closed. // The function will exit when the confirmation channel is closed.
func HandleConfirmationRequests(confirmationChan chan *ConfirmationRequest, func HandleConfirmationRequests(confirmationChan chan *ConfirmationRequest,
interaction guard.PackageManagerGuardInteraction, hooks *ConfirmationHook) { interaction *guard.PackageManagerGuardInteraction, hooks *ConfirmationHook) {
if hooks == nil { if hooks == nil {
hooks = &ConfirmationHook{} hooks = &ConfirmationHook{}
} }
+2 -2
View File
@@ -118,7 +118,7 @@ func TestHandleConfirmationRequests(t *testing.T) {
} }
confirmationChan := make(chan *ConfirmationRequest, 1) confirmationChan := make(chan *ConfirmationRequest, 1)
go HandleConfirmationRequests(confirmationChan, interaction, hooks) go HandleConfirmationRequests(confirmationChan, &interaction, hooks)
pkgVersion := mockPackageVersion("test-package", "1.0.0") pkgVersion := mockPackageVersion("test-package", "1.0.0")
analysisResult := mockAnalysisResult() analysisResult := mockAnalysisResult()
@@ -159,7 +159,7 @@ func TestHandleConfirmationRequests_MultipleSequential(t *testing.T) {
} }
confirmationChan := make(chan *ConfirmationRequest, 3) confirmationChan := make(chan *ConfirmationRequest, 3)
go HandleConfirmationRequests(confirmationChan, interaction, nil) go HandleConfirmationRequests(confirmationChan, &interaction, nil)
pkgVersion1 := mockPackageVersion("package-1", "1.0.0") pkgVersion1 := mockPackageVersion("package-1", "1.0.0")
analysisResult1 := mockAnalysisResult() analysisResult1 := mockAnalysisResult()
-5
View File
@@ -5,7 +5,6 @@ import (
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
"github.com/safedep/pmg/analyzer" "github.com/safedep/pmg/analyzer"
"github.com/safedep/pmg/guard"
"github.com/safedep/pmg/proxy" "github.com/safedep/pmg/proxy"
) )
@@ -14,7 +13,6 @@ type InterceptorFactory struct {
analyzer analyzer.PackageVersionAnalyzer analyzer analyzer.PackageVersionAnalyzer
cache AnalysisCache cache AnalysisCache
confirmationChan chan *ConfirmationRequest confirmationChan chan *ConfirmationRequest
interaction guard.PackageManagerGuardInteraction
} }
// NewInterceptorFactory creates a new interceptor factory with shared dependencies // NewInterceptorFactory creates a new interceptor factory with shared dependencies
@@ -22,13 +20,11 @@ func NewInterceptorFactory(
analyzer analyzer.PackageVersionAnalyzer, analyzer analyzer.PackageVersionAnalyzer,
cache AnalysisCache, cache AnalysisCache,
confirmationChan chan *ConfirmationRequest, confirmationChan chan *ConfirmationRequest,
interaction guard.PackageManagerGuardInteraction,
) *InterceptorFactory { ) *InterceptorFactory {
return &InterceptorFactory{ return &InterceptorFactory{
analyzer: analyzer, analyzer: analyzer,
cache: cache, cache: cache,
confirmationChan: confirmationChan, confirmationChan: confirmationChan,
interaction: interaction,
} }
} }
@@ -41,7 +37,6 @@ func (f *InterceptorFactory) CreateInterceptor(ecosystem packagev1.Ecosystem) (p
f.analyzer, f.analyzer,
f.cache, f.cache,
f.confirmationChan, f.confirmationChan,
f.interaction,
), nil ), nil
default: default:
-3
View File
@@ -6,7 +6,6 @@ import (
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
"github.com/safedep/dry/log" "github.com/safedep/dry/log"
"github.com/safedep/pmg/analyzer" "github.com/safedep/pmg/analyzer"
"github.com/safedep/pmg/guard"
"github.com/safedep/pmg/proxy" "github.com/safedep/pmg/proxy"
) )
@@ -30,14 +29,12 @@ func NewNpmRegistryInterceptor(
analyzer analyzer.PackageVersionAnalyzer, analyzer analyzer.PackageVersionAnalyzer,
cache AnalysisCache, cache AnalysisCache,
confirmationChan chan *ConfirmationRequest, confirmationChan chan *ConfirmationRequest,
interaction guard.PackageManagerGuardInteraction,
) *NpmRegistryInterceptor { ) *NpmRegistryInterceptor {
return &NpmRegistryInterceptor{ return &NpmRegistryInterceptor{
baseRegistryInterceptor: baseRegistryInterceptor{ baseRegistryInterceptor: baseRegistryInterceptor{
analyzer: analyzer, analyzer: analyzer,
cache: cache, cache: cache,
confirmationChan: confirmationChan, confirmationChan: confirmationChan,
interaction: interaction,
}, },
} }
} }