mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
* 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>
159 lines
4.3 KiB
Go
159 lines
4.3 KiB
Go
package packagemanager
|
|
|
|
import (
|
|
"slices"
|
|
"strings"
|
|
|
|
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
|
|
"golang.org/x/mod/semver"
|
|
)
|
|
|
|
type GoPackageManagerConfig struct {
|
|
CommandName string
|
|
|
|
// InstallCommands accept module@version args on the command line, used to
|
|
// extract pinned versions for cooldown reporting.
|
|
InstallCommands []string
|
|
|
|
// NonDownloadCommands never load packages and therefore never fetch
|
|
// modules. Deliberately minimal: fmt/clean/vet/fix/doc/mod-graph load
|
|
// packages and can download already-required modules on a cold cache even
|
|
// under -mod=readonly, so they are excluded on purpose and run with the
|
|
// proxy.
|
|
NonDownloadCommands []string
|
|
}
|
|
|
|
func DefaultGoPackageManagerConfig() GoPackageManagerConfig {
|
|
return GoPackageManagerConfig{
|
|
CommandName: "go",
|
|
InstallCommands: []string{"get", "install", "run"},
|
|
NonDownloadCommands: []string{"version", "env", "help"},
|
|
}
|
|
}
|
|
|
|
type goPackageManager struct {
|
|
Config GoPackageManagerConfig
|
|
}
|
|
|
|
func NewGoPackageManager(config GoPackageManagerConfig) (*goPackageManager, error) {
|
|
return &goPackageManager{Config: config}, nil
|
|
}
|
|
|
|
var _ PackageManager = &goPackageManager{}
|
|
|
|
func (g *goPackageManager) Name() string {
|
|
return g.Config.CommandName
|
|
}
|
|
|
|
func (g *goPackageManager) Ecosystem() packagev1.Ecosystem {
|
|
return packagev1.Ecosystem_ECOSYSTEM_GO
|
|
}
|
|
|
|
func (g *goPackageManager) ParseCommand(args []string) (*ParsedCommand, error) {
|
|
if len(args) > 0 && args[0] == g.Config.CommandName {
|
|
args = args[1:]
|
|
}
|
|
|
|
parsed := &ParsedCommand{Command: Command{Exe: g.Config.CommandName, Args: args}}
|
|
|
|
subcmd, rest := goFirstNonFlagArg(args)
|
|
if subcmd == "" {
|
|
return parsed, nil
|
|
}
|
|
|
|
if slices.Contains(g.Config.NonDownloadCommands, subcmd) {
|
|
parsed.IsKnownNonDownloadCommand = true
|
|
return parsed, nil
|
|
}
|
|
|
|
if slices.Contains(g.Config.InstallCommands, subcmd) {
|
|
parsed.InstallTargets = goRemoteModuleTargets(rest)
|
|
return parsed, nil
|
|
}
|
|
|
|
if subcmd == "mod" {
|
|
modCmd, modRest := goFirstNonFlagArg(rest)
|
|
switch modCmd {
|
|
case "tidy":
|
|
parsed.IsManifestInstall = true
|
|
parsed.ManifestFiles = []string{"go.mod"}
|
|
case "download":
|
|
parsed.InstallTargets = goRemoteModuleTargets(modRest)
|
|
if len(parsed.InstallTargets) == 0 {
|
|
parsed.IsManifestInstall = true
|
|
parsed.ManifestFiles = []string{"go.mod"}
|
|
}
|
|
}
|
|
}
|
|
|
|
return parsed, nil
|
|
}
|
|
|
|
func goFirstNonFlagArg(args []string) (string, []string) {
|
|
for i, arg := range args {
|
|
if strings.HasPrefix(arg, "-") {
|
|
continue
|
|
}
|
|
return arg, args[i+1:]
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
// goRemoteModuleTargets extracts remote module targets (module[@version]) from
|
|
// command args, skipping flags, local paths and meta-patterns. Version queries
|
|
// (@latest, branch names, commit hashes) are passed through for go to resolve;
|
|
// only canonical semver counts as an explicit version for cooldown reporting.
|
|
func goRemoteModuleTargets(args []string) []*PackageInstallTarget {
|
|
var targets []*PackageInstallTarget
|
|
|
|
for _, arg := range args {
|
|
if strings.HasPrefix(arg, "-") || !isGoRemoteModuleTarget(arg) {
|
|
continue
|
|
}
|
|
|
|
name, version := arg, ""
|
|
if at := strings.LastIndex(arg, "@"); at > 0 {
|
|
name, version = arg[:at], arg[at+1:]
|
|
}
|
|
|
|
targets = append(targets, &PackageInstallTarget{
|
|
PackageVersion: &packagev1.PackageVersion{
|
|
Package: &packagev1.Package{
|
|
Ecosystem: packagev1.Ecosystem_ECOSYSTEM_GO,
|
|
Name: name,
|
|
},
|
|
Version: version,
|
|
},
|
|
IsExplicitVersion: semver.IsValid(version) && semver.Canonical(version) == version,
|
|
})
|
|
}
|
|
|
|
return targets
|
|
}
|
|
|
|
// isGoRemoteModuleTarget discriminates a remote module target from a local
|
|
// path or meta-pattern: a remote target's first path segment is a domain
|
|
// (contains a dot), so `go install ./cmd/foo` and `go build ./...` yield no
|
|
// targets while `go get github.com/x/y@v1.2.3` does.
|
|
func isGoRemoteModuleTarget(target string) bool {
|
|
target = strings.TrimSpace(target)
|
|
if target == "" || target == "." || target == ".." {
|
|
return false
|
|
}
|
|
|
|
if strings.HasPrefix(target, "./") || strings.HasPrefix(target, "../") || strings.HasPrefix(target, "/") {
|
|
return false
|
|
}
|
|
|
|
if strings.Contains(target, `\`) {
|
|
return false
|
|
}
|
|
|
|
firstSegment, _, _ := strings.Cut(target, "/")
|
|
if firstSegment == "..." {
|
|
return false
|
|
}
|
|
|
|
return strings.Contains(firstSegment, ".")
|
|
}
|