Files
pmg/main.go
T
b19473945b Add experimental Go module proxy support (#358)
* feat: add experimental Go module support via pmg go

Adds Go modules as a proxy-guarded ecosystem, opt-in only: the command
runs solely when invoked explicitly as `pmg go ...` and is deliberately
excluded from setup aliases and PATH shims so existing users are
unaffected.

- packagemanager: goPackageManager with fail-safe command classification
  (vet/fix excluded from non-download since they can fetch on a cold
  cache) and pinned-version extraction where only canonical semver
  counts as explicit.
- GOPROXY normalization (fail-closed): effective GOPROXY read via
  `go env` (honors go env -w), rebuilt comma-joined with `direct`
  dropped so a 403 block is terminal and nothing silently falls back to
  unanalyzed VCS fetches. GOPRIVATE/GONOPROXY surface a warning;
  GOINSECURE is cleared. Contributed to the proxy flow through a new
  ProxyRoutingProvider hook (extra child env + dynamic MITM hosts).
- Go interceptor with dynamic host matching from the user's effective
  GOPROXY via InterceptorContext.GoProxyHosts. Malware analysis runs on
  .zip only (the sole endpoint that delivers code); .info/.mod/@latest/
  list pass through; /sumdb/ traffic and sum.golang.org are never
  touched so checksum-db verification stays intact; golang.org/toolchain
  is allowed on Go's own checksum verification.
- Dependency cooldown: publish time captured from .info responses
  (body unmodified), in-window .zip blocked with 403; fails open for
  cooldown only when the publish time was never observed.
- Cert gate: on macOS/Windows `pmg go` fails fast with actionable
  guidance unless the persisted PMG CA is OS-trusted (Go ignores
  SSL_CERT_FILE there); Linux works via the injected bundle.
- proxye2e: GOPROXY-protocol mock registry, Go driver and 10 hermetic
  cases (allow/block/confirm, case-escaped paths, cooldown block and
  fail-open, toolchain, sumdb passthrough).

Verified end-to-end on Linux: `pmg go get github.com/google/uuid@v1.6.0`
MITMs proxy.golang.org, analyzes the decoded module at the .zip fetch,
and go.sum verification succeeds through the tunneled checksum db.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014xuhBeTVpfU4SdqVaarvuK

* fix(go): address review findings on experimental Go support

- Drop fmt/clean from NonDownloadCommands: both load packages via go
  list and can download modules on a cold cache, which would bypass the
  proxy under install_only.
- Support GOPROXY entries with a base path (e.g. corp Athens/JFrog at
  https://corp/goproxy): the interceptor now receives host -> base URL
  and strips the path prefix before parsing module URLs, so verdicts
  and cooldown key on the real module path.
- Default unschemed GOPROXY entries to https, matching go's own
  behavior, so corp mirrors configured as bare hosts are intercepted
  instead of silently unanalyzed.
- Memoize the final verdict per module zip: go re-requests a failed
  zip during go get's load phase, which double-recorded stats (the
  report showed the same blocked module twice) and would have
  re-prompted on Confirm verdicts.
- Fetch .info out-of-band on a cooldown cache miss: go serves .info
  from its local module cache on any machine that used go before PMG,
  which silently disabled cooldown. Failure of the side-fetch still
  fails open for cooldown only.
- Move the noop package resolver into packagemanager.

Verified live: cold-cache cooldown block now records once; warm-cache
rerun is blocked via the side-fetch instead of failing open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014xuhBeTVpfU4SdqVaarvuK

* docs: collapse Go proxy-mode details by default

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014xuhBeTVpfU4SdqVaarvuK

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-03 18:19:31 +05:30

243 lines
7.8 KiB
Go

package main
import (
"fmt"
"os"
"runtime"
"strings"
"github.com/safedep/dry/log"
"github.com/safedep/pmg/cmd/cloud"
configCmd "github.com/safedep/pmg/cmd/config"
"github.com/safedep/pmg/cmd/executors"
golangCmd "github.com/safedep/pmg/cmd/golang"
landlockCmd "github.com/safedep/pmg/cmd/landlock"
"github.com/safedep/pmg/cmd/npm"
proxyCmd "github.com/safedep/pmg/cmd/proxy"
"github.com/safedep/pmg/cmd/pypi"
sandboxCmd "github.com/safedep/pmg/cmd/sandbox"
"github.com/safedep/pmg/cmd/setup"
"github.com/safedep/pmg/cmd/version"
"github.com/safedep/pmg/config"
"github.com/safedep/pmg/internal/analytics"
"github.com/safedep/pmg/internal/audit"
"github.com/safedep/pmg/internal/eventlog"
"github.com/safedep/pmg/internal/ui"
appVersion "github.com/safedep/pmg/internal/version"
"github.com/spf13/cobra"
)
var (
debug bool
silent bool
verbose bool
logFile string
)
func setLogEnv(key, value string) {
if err := os.Setenv(key, value); err != nil {
log.Warnf("failed to set %s: %v", key, err)
}
}
func main() {
cmd := &cobra.Command{
Use: "pmg",
TraverseChildren: true,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
// Always set this first because we will override the log
// level if debug or verbose is set
if logFile != "" {
setLogEnv("APP_LOG_FILE", logFile)
setLogEnv("APP_LOG_LEVEL", "info")
}
// Set the log level when debug is enabled
if debug {
setLogEnv("APP_LOG_LEVEL", "debug")
}
// Skip stdout logging when debugging is not enabled
if !debug {
setLogEnv("APP_LOG_SKIP_STDOUT_LOGGER", "true")
}
// Apply config-based verbosity first
switch config.Get().Config.Verbosity {
case config.VerbositySilent:
ui.SetVerbosityLevel(ui.VerbosityLevelSilent)
case config.VerbosityVerbose:
ui.SetVerbosityLevel(ui.VerbosityLevelVerbose)
default:
ui.SetVerbosityLevel(ui.VerbosityLevelNormal)
}
// CLI flags override config
if silent && verbose {
ui.Fatalf("pmg: --silent and --verbose cannot be used together")
}
if silent {
ui.SetVerbosityLevel(ui.VerbosityLevelSilent)
} else if verbose {
ui.SetVerbosityLevel(ui.VerbosityLevelVerbose)
}
log.InitZapLogger("pmg", "cli")
// Refuse flags that would override a globally managed config before any
// config-dependent initialization (event logging, audit) runs, so a
// managed flag like --skip-event-log cannot take effect first.
if err := config.RejectManagedFlagOverrides(cmd); err != nil {
ui.ErrorExit(err)
}
// Initialize event logging
var eventlogErr error
if logFile != "" {
// If a custom log file is specified, use it for event logging too
eventlogErr = eventlog.InitializeWithFile(logFile)
} else {
// Otherwise use the default log directory
eventlogErr = eventlog.Initialize()
}
if eventlogErr != nil {
ui.Fatalf("failed to initialize event logging: %v", eventlogErr)
}
if err := audit.Initialize(config.Get()); err != nil {
ui.ErrorExit(err)
}
// The proxy daemon delivers events itself (periodic sync + shutdown
// flush), so suppress the detached background auto-sync for proxy
// commands. It would otherwise spawn a redundant child that contends
// for the sync lock and, from `pmg proxy stop`, routes through the
// now-stopped proxy. We suppress the spawn directly rather than
// flipping AutoSync.Enabled, because the daemon's periodic sync
// honors that flag.
if isProxyCommand(cmd) {
audit.SuppressBackgroundSync()
}
config.FinalizeDependencyCooldownOverride()
// Parse and validate --sandbox-allow flags after all flags are resolved
if err := config.FinalizeSandboxAllowOverrides(); err != nil {
ui.Fatalf("pmg: %v", err)
}
if debug {
logDebugContext()
}
},
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return cmd.Help()
}
return fmt.Errorf("pmg: %s is not a valid command", args[0])
},
}
cmd.PersistentFlags().StringVar(&logFile, "log", "", "Log file to write to")
cmd.PersistentFlags().BoolVar(&silent, "silent", false, "Silent mode for invisible experience")
cmd.PersistentFlags().BoolVar(&verbose, "verbose", false, "Verbose mode for more information")
cmd.PersistentFlags().BoolVar(&debug, "debug", false, "Enable debug logging (defaults to stdout)")
// Apply config flags to the command. This allows for overriding the configuration at runtime
// using the command line.
config.ApplyCobraFlags(cmd)
cmd.AddCommand(npm.NewNpmCommand())
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())
cmd.AddCommand(pypi.NewPoetryCommand())
cmd.AddCommand(executors.NewPipxCommand())
cmd.AddCommand(executors.NewUvxCommand())
cmd.AddCommand(golangCmd.NewGoCommand())
cmd.AddCommand(proxyCmd.NewProxyCommand())
cmd.AddCommand(version.NewVersionCommand())
cmd.AddCommand(setup.NewSetupCommand())
cmd.AddCommand(setup.NewRemoveCommand())
cmd.AddCommand(sandboxCmd.NewCommand())
cmd.AddCommand(cloud.NewCloudCommand())
cmd.AddCommand(configCmd.NewConfigCommand())
if subcmd := landlockCmd.NewLandlockSandboxExecCommand(); subcmd != nil {
cmd.AddCommand(subcmd)
}
if subcmd := landlockCmd.NewLandlockShimCommand(); subcmd != nil {
cmd.AddCommand(subcmd)
}
// Print Banner on --help / -h
cmd.SetHelpFunc(func(command *cobra.Command, args []string) {
fmt.Print(ui.GeneratePMGBanner(appVersion.Version, appVersion.Commit))
fmt.Println(command.UsageString())
})
defer func() {
if err := eventlog.Close(); err != nil {
log.Warnf("failed to close eventlog: %v", err)
}
}()
// Defers run LIFO. The spawn must observe the parent's released SQLite
// handle, so we declare it BEFORE audit.Close's defer (it then runs AFTER
// audit.Close at exit time).
defer func() {
audit.MaybeSpawnBackgroundSync(config.Get())
}()
defer func() {
if err := audit.Close(); err != nil {
log.Warnf("failed to close audit system: %v", err)
}
}()
// Analytics are best-effort. Do not flush on exit because the PostHog
// client can block the CLI while draining its queue.
analytics.TrackCommandRun()
analytics.TrackCI()
if err := cmd.Execute(); err != nil {
type exitCoder interface{ ExitCode() int }
if ec, ok := err.(exitCoder); ok {
os.Exit(ec.ExitCode())
}
os.Exit(1)
}
}
func logDebugContext() {
cfg := config.Get()
log.Debugf("Command: pmg %s", strings.Join(os.Args[1:], " "))
log.Debugf("PMG %s (commit: %s) running on %s/%s with %s",
appVersion.Version, appVersion.Commit, runtime.GOOS, runtime.GOARCH, runtime.Version())
log.Debugf("Using config file: %s", cfg.ConfigFilePath())
log.Debugf("Proxy mode enabled: %t, install only: %t", cfg.IsProxyModeEnabled(), cfg.Config.Proxy.InstallOnly)
log.Debugf("Sandbox enabled: %t, enforce always: %t", cfg.Config.Sandbox.Enabled, cfg.Config.Sandbox.EnforceAlways)
log.Debugf("Transitive analysis enabled: %t (depth: %d), paranoid: %t", cfg.Config.Transitive, cfg.Config.TransitiveDepth, cfg.Config.Paranoid)
log.Debugf("Dependency cooldown enabled: %t (days: %d)", cfg.Config.DependencyCooldown.Enabled, cfg.Config.DependencyCooldown.Days)
log.Debugf("Cloud sync enabled: %t, telemetry disabled: %t", cfg.Config.Cloud.Enabled, cfg.Config.DisableTelemetry)
log.Debugf("Dry run: %t, insecure installation: %t, trusted packages: %d",
cfg.DryRun, cfg.InsecureInstallation, len(cfg.Config.TrustedPackages))
}
// isProxyCommand reports whether cmd is `pmg proxy` or one of its subcommands.
func isProxyCommand(cmd *cobra.Command) bool {
for c := cmd; c != nil; c = c.Parent() {
if c.Name() == "proxy" {
return true
}
}
return false
}