mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
`pmg setup doctor` reported npm protection as OK on a machine with no npm installed. Two compounding defects: 1. The availability gate used a plain `exec.LookPath`, which resolves the PMG shim on PATH rather than the real binary, so the "skip" branch never fired. It now uses `shim.ResolveRealBinary` (PATH with shim dirs stripped), matching the runner, so a missing real binary correctly yields WARN "not available". 2. The result was inferred purely from a non-zero exit, so PackageManagerNotFound (exit 127) and other failures were misread as a successful block. The check now requires PMG's block headline in the captured output before reporting PASS; other non-zero exits report WARN with the error surfaced. The block headline is extracted into `ui.MalwareBlockedHeadline` so the doctor's marker stays in sync with what PMG prints across its block-output sites.
155 lines
4.3 KiB
Go
155 lines
4.3 KiB
Go
package doctor
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/safedep/dry/log"
|
|
"github.com/safedep/pmg/internal/shim"
|
|
"github.com/safedep/pmg/internal/ui"
|
|
)
|
|
|
|
type ProtectionTestCase struct {
|
|
PackageManager string
|
|
Package string
|
|
InstallArgs []string
|
|
NeedsVenv bool
|
|
}
|
|
|
|
func ProtectionTestCases() []ProtectionTestCase {
|
|
return []ProtectionTestCase{
|
|
{
|
|
PackageManager: "npm",
|
|
Package: "safedep-test-pkg@0.1.3",
|
|
InstallArgs: []string{"npm", "install", "--no-cache", "--prefer-online", "safedep-test-pkg@0.1.3"},
|
|
},
|
|
{
|
|
PackageManager: "pip",
|
|
Package: "safedep-test-pkg==0.0.4",
|
|
InstallArgs: []string{"pip", "install", "--no-cache-dir", "safedep-test-pkg==0.0.4"},
|
|
NeedsVenv: true,
|
|
},
|
|
}
|
|
}
|
|
|
|
func RunProtectionCheck(tc ProtectionTestCase, pmgBinary string) CheckResult {
|
|
// Resolve the real package manager the same way the runner does — PATH with
|
|
// PMG shim dirs stripped. A plain exec.LookPath would find the PMG shim on
|
|
// PATH and report the manager as available even when the real binary is absent
|
|
if _, err := shim.ResolveRealBinary(tc.PackageManager); err != nil {
|
|
return CheckResult{
|
|
Status: StatusWarn,
|
|
Message: fmt.Sprintf("%s not available — skipping protection test for %s", tc.PackageManager, tc.Package),
|
|
}
|
|
}
|
|
|
|
tmpDir, err := os.MkdirTemp("", "pmg-doctor-*")
|
|
if err != nil {
|
|
return CheckResult{
|
|
Status: StatusWarn,
|
|
Message: fmt.Sprintf("Could not create temp dir for %s test: %v", tc.PackageManager, err),
|
|
}
|
|
}
|
|
defer func() {
|
|
if err := os.RemoveAll(tmpDir); err != nil {
|
|
log.Warnf("failed to clean up temp dir %s: %v", tmpDir, err)
|
|
}
|
|
}()
|
|
|
|
env := os.Environ()
|
|
|
|
if tc.NeedsVenv {
|
|
venvDir, venvErr := setupVenv(tmpDir)
|
|
if venvErr != nil {
|
|
return CheckResult{
|
|
Status: StatusWarn,
|
|
Message: fmt.Sprintf("Could not create venv for %s test: %v", tc.PackageManager, venvErr),
|
|
}
|
|
}
|
|
venvBin := filepath.Join(venvDir, "bin")
|
|
env = prependPath(env, venvBin)
|
|
}
|
|
|
|
cmd := exec.Command(pmgBinary, tc.InstallArgs...)
|
|
cmd.Dir = tmpDir
|
|
cmd.Env = env
|
|
|
|
output, runErr := cmd.CombinedOutput()
|
|
return evaluateProtectionResult(tc.PackageManager, tc.Package, string(output), runErr)
|
|
}
|
|
|
|
func setupVenv(baseDir string) (string, error) {
|
|
venvDir := filepath.Join(baseDir, "venv")
|
|
cmd := exec.Command("python3", "-m", "venv", venvDir)
|
|
if output, err := cmd.CombinedOutput(); err != nil {
|
|
return "", fmt.Errorf("venv creation failed: %w\n%s", err, string(output))
|
|
}
|
|
return venvDir, nil
|
|
}
|
|
|
|
func prependPath(env []string, dir string) []string {
|
|
result := make([]string, 0, len(env))
|
|
for _, e := range env {
|
|
if strings.HasPrefix(e, "PATH=") {
|
|
e = fmt.Sprintf("PATH=%s%c%s", dir, filepath.ListSeparator, e[5:])
|
|
}
|
|
result = append(result, e)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func evaluateProtectionResult(pm string, pkg string, output string, err error) CheckResult {
|
|
if err == nil {
|
|
return CheckResult{
|
|
Status: StatusFail,
|
|
Message: fmt.Sprintf("Failed to block %s/%s — package was installed instead of blocked", pm, pkg),
|
|
}
|
|
}
|
|
|
|
if isExecutableNotFound(err) {
|
|
return CheckResult{
|
|
Status: StatusWarn,
|
|
Message: fmt.Sprintf("%s not available — skipping protection test for %s", pm, pkg),
|
|
}
|
|
}
|
|
|
|
// A non-zero exit alone is not proof of a block: PackageManagerNotFound,
|
|
// proxy/CA setup failures, and config errors all exit non-zero too. Require
|
|
// PMG's block headline in the output before declaring protection working.
|
|
// The headline is emitted only for malware blocks, not cooldown-only blocks.
|
|
if strings.Contains(output, ui.MalwareBlockedHeadline) {
|
|
return CheckResult{
|
|
Status: StatusPass,
|
|
Message: fmt.Sprintf("Malicious package blocked (%s/%s)", pm, pkg),
|
|
}
|
|
}
|
|
|
|
return CheckResult{
|
|
Status: StatusWarn,
|
|
Message: fmt.Sprintf("Install failed without a malware block (%v)", err),
|
|
}
|
|
}
|
|
|
|
func isExecutableNotFound(err error) bool {
|
|
if execErr, ok := err.(*exec.Error); ok {
|
|
return execErr.Err == exec.ErrNotFound
|
|
}
|
|
return false
|
|
}
|
|
|
|
func CheckShimScripts(shimDir string, managers []string) (found []string, missing []string) {
|
|
for _, pm := range managers {
|
|
shimPath := filepath.Join(shimDir, pm)
|
|
info, err := os.Stat(shimPath)
|
|
if err != nil || info.Mode()&0o111 == 0 {
|
|
missing = append(missing, pm)
|
|
continue
|
|
}
|
|
found = append(found, pm)
|
|
}
|
|
return found, missing
|
|
}
|