mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
* feat(uvx): add uvx (uv tool run) package executor Adds support for `uvx`, implemented as a PyPI Executor alongside pipx. uvx is an alias for `uv tool run`: it installs a tool into an ephemeral environment and runs it, so it has no install/list subcommand and the first positional argument (or --from) is the package to audit. Parsing highlights: - --from overrides the positional command as the package to audit - --with packages are audited as additional environment dependencies - name@version shorthand (ruff@0.3.0, ruff@latest) is normalized - flag parsing stops at the tool name so the tool's own flags are not misread as uvx options; uvx's value/boolean flags are registered so none greedily consume the package positional - VCS/URL/local-path specs are skipped for registry auditing Wires up command registration, analytics, shell alias/shim, cloud audit mapping, a dedicated `uvx` sandbox profile (UV_*/PIP_* env, uv cache and tool dirs), config policy, docs, unit tests and an E2E workflow step. Closes #326 https://claude.ai/code/session_011hyLxq7oWJX5Dp4tCEfG19 * chore(uvx): align docs and base profile with uvx support Incorporates the low-risk, non-parser improvements from the community PR #345 (author non-responsive) into our implementation: - list uvx (and the previously-missing pipx) as PyPI managers in the pypi-restrictive base profile package_managers and its README, so the base profile applies directly when selected via --sandbox-profile - document uvx in docs/github-action.md and docs/proxy-mode.md - add version / IsExplicitVersion assertions to the uvx parser tests Our pflag-based parser is kept as-is: unlike #345 it audits --with packages and handles all uvx short flags (e.g. -w), both of which the community PR misses. * fix(uvx): skip interpreter requests; use require in tests Addresses review feedback on PR #357: - uvx interpreter requests (`uvx python`, `uvx python@3.12`, `uvx pypy`, ...) launch an isolated interpreter rather than installing a PyPI tool. Treating the positional as a package made the guard flow resolve/analyze pkg:pypi/python (and python==3.12), which could wrongly block or fail a valid invocation. Skip these for the positional; --with packages on the same command are still audited. - Use require.NoError / require.Len for fatal assertions in the uvx tests, matching the repo's testing convention, so a failure stops the subtest before a nil dereference instead of panicking. * docs(uvx): document fail-open and --with-requirements trade-offs Record the two deliberate parsing decisions raised in review as in-code trade-off comments (no behavior change): - unknown flags are tolerated (fail open), consistent with the other executors; the residual gap only affects non-proxy guard mode since the default proxy flow intercepts every registry download. - --with-requirements / --with-editable values are consumed but not expanded into audit targets; expanding them needs manifest-extractor and guard changes, tracked as follow-up. Proxy mode still covers them. * docs(uvx): drop --with-requirements limitation note Per maintainer review: guard mode is being deprecated and auditing the contents of an existing requirements file is a scanner's responsibility, not PMG's. Remove the "known limitation / follow-up" note; the flags stay registered only so their values are not mistaken for the tool positional. --------- Co-authored-by: Claude <noreply@anthropic.com>
234 lines
7.4 KiB
Go
234 lines
7.4 KiB
Go
package packagemanager
|
|
|
|
import (
|
|
"io"
|
|
"slices"
|
|
|
|
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
|
|
"github.com/spf13/pflag"
|
|
)
|
|
|
|
type PypiPackageExecutorConfig struct {
|
|
CommandName string
|
|
InstallCommands []string
|
|
NonDownloadCommands []string
|
|
|
|
// ImplicitRun marks executors that always run a tool with no install
|
|
// subcommand (uvx, i.e. `uv tool run`). For these the first positional
|
|
// argument (or --from) is the package to audit. See parseUvxCommand.
|
|
ImplicitRun bool
|
|
}
|
|
|
|
func DefaultPipxPackageExecutorConfig() PypiPackageExecutorConfig {
|
|
return PypiPackageExecutorConfig{
|
|
CommandName: "pipx",
|
|
InstallCommands: []string{"install", "inject", "run", "upgrade", "upgrade-all", "reinstall", "reinstall-all"},
|
|
NonDownloadCommands: []string{
|
|
"list", "uninstall", "uninstall-all", "completions", "uninject", "ensurepath", "environment",
|
|
},
|
|
}
|
|
}
|
|
|
|
type pypiPackageExecutor struct {
|
|
Config PypiPackageExecutorConfig
|
|
}
|
|
|
|
func NewPypiPackageExecutor(config PypiPackageExecutorConfig) (*pypiPackageExecutor, error) {
|
|
return &pypiPackageExecutor{
|
|
Config: config,
|
|
}, nil
|
|
}
|
|
|
|
var _ PackageManager = &pypiPackageExecutor{}
|
|
|
|
func (p *pypiPackageExecutor) Name() string {
|
|
return p.Config.CommandName
|
|
}
|
|
|
|
func (p *pypiPackageExecutor) Ecosystem() packagev1.Ecosystem {
|
|
return packagev1.Ecosystem_ECOSYSTEM_PYPI
|
|
}
|
|
|
|
func (p *pypiPackageExecutor) ParseCommand(args []string) (*ParsedCommand, error) {
|
|
if len(args) > 0 && args[0] == p.Config.CommandName {
|
|
args = args[1:]
|
|
}
|
|
|
|
command := Command{Exe: p.Config.CommandName, Args: args}
|
|
|
|
// uvx (uv tool run) has no install subcommand; the tool is always run.
|
|
if p.Config.ImplicitRun {
|
|
return p.parseUvxCommand(command, args)
|
|
}
|
|
|
|
if len(args) < 1 {
|
|
return &ParsedCommand{Command: command}, nil
|
|
}
|
|
|
|
// pipx run <pkg> downloads and executes a package without globally installing it.
|
|
// We extract the package name so it can be audited before execution.
|
|
if args[0] == "run" {
|
|
return p.parseRunCommand(command, args[1:])
|
|
}
|
|
|
|
// pipx inject <target-venv> <pkg1> [<pkg2> ...] injects packages into an
|
|
// existing venv. The first positional arg is the target venv (already installed),
|
|
// not a package to audit — we skip it and only audit the injected packages.
|
|
if args[0] == "inject" {
|
|
return p.parseInjectCommand(command, args[1:])
|
|
}
|
|
|
|
var installCmdIndex = -1
|
|
for idx, arg := range args {
|
|
if slices.Contains(p.Config.InstallCommands, arg) {
|
|
installCmdIndex = idx
|
|
break
|
|
}
|
|
}
|
|
|
|
if installCmdIndex == -1 {
|
|
return &ParsedCommand{Command: command, IsKnownNonDownloadCommand: IsFirstNonFlagArgInList(args, p.Config.NonDownloadCommands)}, nil
|
|
}
|
|
|
|
installArgs := args[installCmdIndex+1:]
|
|
|
|
flagSet := pflag.NewFlagSet("pipx install", pflag.ContinueOnError)
|
|
flagSet.ParseErrorsAllowlist.UnknownFlags = true
|
|
flagSet.SetOutput(io.Discard)
|
|
|
|
// Define known pipx install flags. We register flags that take values to prevent
|
|
// their values from being misidentified as package names, and boolean flags
|
|
// to prevent the flag itself from being treated as an unknown argument.
|
|
// registers --pip-args, --python, --spec so their values aren't picked up as packages
|
|
setupCommonPipxFlags(flagSet)
|
|
flagSet.Bool("force", false, "")
|
|
flagSet.Bool("include-deps", false, "")
|
|
flagSet.Bool("system-site-packages", false, "")
|
|
|
|
err := flagSet.Parse(installArgs)
|
|
if err != nil {
|
|
return &ParsedCommand{Command: command}, nil
|
|
}
|
|
|
|
packages := flagSet.Args()
|
|
return p.buildInstallTargets(command, packages)
|
|
}
|
|
|
|
// parseRunCommand handles `pipx run [flags] <package> [args...]`.
|
|
// Only the first positional argument is the package; the rest are arguments
|
|
// to the executed program.
|
|
func (p *pypiPackageExecutor) parseRunCommand(command Command, runArgs []string) (*ParsedCommand, error) {
|
|
if len(runArgs) == 0 {
|
|
return &ParsedCommand{Command: command}, nil
|
|
}
|
|
|
|
flagSet := pflag.NewFlagSet("pipx run", pflag.ContinueOnError)
|
|
flagSet.ParseErrorsAllowlist.UnknownFlags = true
|
|
flagSet.SetOutput(io.Discard)
|
|
|
|
// Define known pipx run flags. We register flags that take values to prevent
|
|
// their values from being misidentified as package names, and boolean flags
|
|
// to prevent the flag itself from being treated as an unknown argument.
|
|
// registers --pip-args, --python, --spec so their values aren't picked up as packages
|
|
_, _, specPkg := setupCommonPipxFlags(flagSet)
|
|
flagSet.Bool("no-cache", false, "")
|
|
|
|
err := flagSet.Parse(runArgs)
|
|
if err != nil {
|
|
return &ParsedCommand{Command: command}, nil
|
|
}
|
|
|
|
// If --spec is provided, that's the package to audit, not the positional arg
|
|
if *specPkg != "" {
|
|
return p.buildInstallTargets(command, []string{*specPkg})
|
|
}
|
|
|
|
packages := flagSet.Args()
|
|
if len(packages) == 0 {
|
|
return &ParsedCommand{Command: command}, nil
|
|
}
|
|
|
|
// Only the first positional arg is the package
|
|
return p.buildInstallTargets(command, []string{packages[0]})
|
|
}
|
|
|
|
// parseInjectCommand handles `pipx inject [flags] <target-venv> <pkg1> [<pkg2> ...]`.
|
|
// The first positional argument is the target venv (already installed, not audited).
|
|
// Subsequent positional arguments are the packages being injected.
|
|
func (p *pypiPackageExecutor) parseInjectCommand(command Command, injectArgs []string) (*ParsedCommand, error) {
|
|
if len(injectArgs) == 0 {
|
|
return &ParsedCommand{Command: command}, nil
|
|
}
|
|
|
|
flagSet := pflag.NewFlagSet("pipx inject", pflag.ContinueOnError)
|
|
flagSet.ParseErrorsAllowlist.UnknownFlags = true
|
|
flagSet.SetOutput(io.Discard)
|
|
|
|
// Define known pipx inject flags. We register flags that take values to prevent
|
|
// their values from being misidentified as package names, and boolean flags
|
|
// to prevent the flag itself from being treated as an unknown argument.
|
|
// registers --pip-args, --python, --spec so their values aren't picked up as packages
|
|
setupCommonPipxFlags(flagSet)
|
|
flagSet.Bool("force", false, "")
|
|
flagSet.Bool("include-apps", false, "")
|
|
flagSet.Bool("include-deps", false, "")
|
|
|
|
err := flagSet.Parse(injectArgs)
|
|
if err != nil {
|
|
return &ParsedCommand{Command: command}, nil
|
|
}
|
|
|
|
packages := flagSet.Args()
|
|
if len(packages) < 2 {
|
|
// Need at least target-venv + one package to inject
|
|
return &ParsedCommand{Command: command}, nil
|
|
}
|
|
|
|
// Skip the first positional arg (target venv), audit the rest
|
|
return p.buildInstallTargets(command, packages[1:])
|
|
}
|
|
|
|
// buildInstallTargets creates install targets from a list of package specifiers.
|
|
func (p *pypiPackageExecutor) buildInstallTargets(command Command, packages []string) (*ParsedCommand, error) {
|
|
var installTargets []*PackageInstallTarget
|
|
|
|
for _, pkg := range packages {
|
|
packageName, version, extras, err := pypiParsePackageInfo(pkg)
|
|
if err != nil {
|
|
return nil, ErrFailedToParsePackage.Wrap(err)
|
|
}
|
|
|
|
isExplicit := version != ""
|
|
version, err = pypiGetMatchingVersion(packageName, version)
|
|
if err != nil {
|
|
return nil, ErrFailedToResolveVersion.Wrap(err)
|
|
}
|
|
|
|
installTargets = append(installTargets, &PackageInstallTarget{
|
|
PackageVersion: &packagev1.PackageVersion{
|
|
Package: &packagev1.Package{
|
|
Ecosystem: packagev1.Ecosystem_ECOSYSTEM_PYPI,
|
|
Name: packageName,
|
|
},
|
|
Version: version,
|
|
},
|
|
Extras: extras,
|
|
IsExplicitVersion: isExplicit,
|
|
})
|
|
}
|
|
|
|
return &ParsedCommand{
|
|
Command: command,
|
|
InstallTargets: installTargets,
|
|
IsManifestInstall: false,
|
|
}, nil
|
|
}
|
|
|
|
func setupCommonPipxFlags(flagSet *pflag.FlagSet) (pipArgs, pythonPath, specPkg *string) {
|
|
pipArgs = flagSet.String("pip-args", "", "")
|
|
pythonPath = flagSet.String("python", "", "")
|
|
specPkg = flagSet.String("spec", "", "")
|
|
return
|
|
}
|
|
|