mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
feat: Add pmg setup doctor command (#290)
* feat: add doctor check runner core types and logic * feat: add doctor checks for config, binary, directory, and aliases * feat: add doctor checks for sandbox and security features * feat: add protection verification check using test malicious packages * feat: add summarized package manager availability check * feat: add pmg setup doctor command with compact output * feat: add PATH shim verification to doctor command * fix: improve doctor command UX and alias detection - Capitalize all check messages for consistent output - Dim passing checks, color warn/fail for visual clarity - Silence empty Cobra error output on doctor failure - Remove redundant pmg binary check (self-evident) - Fix alias IsInstalled to skip commented-out source lines - Improve protection failure message * refactor: remove package manager availability check from doctor * fix: handle os.RemoveAll error in doctor protection check * refactor: use table layout for setup doctor, extract shared table renderer Move renderTable, truncate, and visibleWidth helpers from cmd/sandbox to internal/ui so both sandbox and setup doctor share them. Rewrite setup doctor output to use the same table structure as sandbox doctor. Fix VisibleWidth to count runes instead of bytes for correct alignment with multi-byte UTF-8 characters. * docs: add pmg setup doctor to README, remove manual verification step * refactor: use constants for check names, rename and inline doctor helpers Address PR review comments: extract check name constants, rename CheckConfigFile to CheckFileExists and CheckDirectoryWritable to CheckDirectoryExists for reusability, inline trivial wrappers (CheckSandbox, CheckSecurityFeature, CheckProxyMode), and add fix hints for all checks with correct config keys. * refactor: inline simple doctor checks into command layer * fix: skip protection check when aliases and shims are inactive Protection checks now fail immediately when shell aliases and shims are both inactive, instead of falsely passing by running through the pmg binary directly. Also clean up summary messages to remove redundant fix hints and truncated paths.
This commit is contained in:
@@ -73,6 +73,12 @@ pmg setup install
|
||||
|
||||
> **Tip:** Re-run `pmg setup install` after upgrading PMG to pick up new configuration options.
|
||||
|
||||
Validate your installation and verify protection is working:
|
||||
|
||||
```bash
|
||||
pmg setup doctor
|
||||
```
|
||||
|
||||
### 3. Use
|
||||
|
||||
Run your package managers as usual, or let your AI coding agent run them. PMG sits in the path.
|
||||
@@ -83,26 +89,6 @@ npm install express
|
||||
pip install requests
|
||||
```
|
||||
|
||||
Verify PMG works by installing `safedep-test-pkg`. It's harmless, but SafeDep flags it as malicious so you can confirm the block path:
|
||||
|
||||
```bash
|
||||
npm --prefer-online --no-cache i safedep-test-pkg@0.1.3
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Expected output</summary>
|
||||
|
||||
```
|
||||
✗ Malicious package blocked
|
||||
|
||||
- safedep-test-pkg@0.1.3
|
||||
Reference: https://app.safedep.io/community/malysis/01KF5JYDND9XR94WNEJ2G74KY2
|
||||
|
||||
✗ PMG: 1 packages analyzed, 1 blocked
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Features
|
||||
|
||||
| Feature | Description |
|
||||
|
||||
+4
-76
@@ -6,8 +6,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/safedep/dry/usefulerror"
|
||||
"github.com/safedep/pmg/errcodes"
|
||||
@@ -128,88 +126,18 @@ func writeJSONIndent(out io.Writer, v any) error {
|
||||
return enc.Encode(v)
|
||||
}
|
||||
|
||||
// renderTable prints rows with ANSI-aware column alignment. The first row is
|
||||
// treated as a header. After each data row, optional continuation lines may be
|
||||
// emitted via the after callback (passed the data row index; -1 for header).
|
||||
func renderTable(out io.Writer, rows [][]string, after func(rowIdx int) error) error {
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
cols := len(rows[0])
|
||||
widths := make([]int, cols)
|
||||
for _, row := range rows {
|
||||
for i, cell := range row {
|
||||
if w := visibleWidth(cell); w > widths[i] {
|
||||
widths[i] = w
|
||||
}
|
||||
}
|
||||
}
|
||||
for rIdx, row := range rows {
|
||||
for i, cell := range row {
|
||||
if i == cols-1 {
|
||||
if _, err := fmt.Fprint(out, cell); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
pad := widths[i] - visibleWidth(cell)
|
||||
if _, err := fmt.Fprint(out, cell, strings.Repeat(" ", pad+2)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := fmt.Fprintln(out); err != nil {
|
||||
return err
|
||||
}
|
||||
if after != nil {
|
||||
dataIdx := rIdx - 1
|
||||
if err := after(dataIdx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return ui.RenderTable(out, rows, after)
|
||||
}
|
||||
|
||||
// firstColumnIndent returns blanks the width of the first column plus the
|
||||
// two-space padding renderTable uses, for continuation-line alignment.
|
||||
func firstColumnIndent(rows [][]string) string {
|
||||
if len(rows) == 0 {
|
||||
return ""
|
||||
}
|
||||
w := 0
|
||||
for _, row := range rows {
|
||||
if v := visibleWidth(row[0]); v > w {
|
||||
w = v
|
||||
}
|
||||
}
|
||||
return strings.Repeat(" ", w+2)
|
||||
}
|
||||
|
||||
var ansiEscapeRe = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`)
|
||||
|
||||
// visibleWidth returns the printable width of s with ANSI escape sequences
|
||||
// stripped — text/tabwriter counts escape bytes as visible chars, misaligning
|
||||
// colored cells.
|
||||
func visibleWidth(s string) int {
|
||||
return len(ansiEscapeRe.ReplaceAllString(s, ""))
|
||||
return ui.FirstColumnIndent(rows)
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
if n <= 3 {
|
||||
return s[:n]
|
||||
}
|
||||
return s[:n-3] + "..."
|
||||
return ui.Truncate(s, n)
|
||||
}
|
||||
|
||||
func truncateLeft(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
if n <= 3 {
|
||||
return s[len(s)-n:]
|
||||
}
|
||||
return "..." + s[len(s)-(n-3):]
|
||||
return ui.TruncateLeft(s, n)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
|
||||
"github.com/safedep/pmg/config"
|
||||
"github.com/safedep/pmg/internal/alias"
|
||||
"github.com/safedep/pmg/internal/doctor"
|
||||
"github.com/safedep/pmg/internal/shim"
|
||||
"github.com/safedep/pmg/internal/ui"
|
||||
"github.com/safedep/pmg/internal/version"
|
||||
"github.com/safedep/pmg/sandbox/platform"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
checkConfigFile = "config-file"
|
||||
checkEventLogDir = "event-log-dir"
|
||||
checkShellAliases = "shell-aliases"
|
||||
checkShimDirectory = "shim-directory"
|
||||
checkShimInPath = "shim-in-path"
|
||||
checkProxyMode = "proxy-mode"
|
||||
checkDependencyCooldown = "dependency-cooldown"
|
||||
checkEventLogging = "event-logging"
|
||||
checkSandbox = "sandbox"
|
||||
checkProtectionNpm = "protection-npm"
|
||||
checkProtectionPip = "protection-pip"
|
||||
)
|
||||
|
||||
func NewDoctorCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "doctor",
|
||||
Short: "Validate PMG installation and protection",
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fmt.Print(ui.GeneratePMGBanner(version.Version, version.Commit))
|
||||
err := executeDoctorChecks()
|
||||
if _, ok := err.(*doctorFailError); ok {
|
||||
cmd.SilenceErrors = true
|
||||
}
|
||||
return err
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type doctorFailError struct{}
|
||||
|
||||
func (e *doctorFailError) Error() string { return "" }
|
||||
func (e *doctorFailError) ExitCode() int { return 1 }
|
||||
|
||||
func executeDoctorChecks() error {
|
||||
cfg := config.Get()
|
||||
|
||||
coreResults := runCoreChecks(cfg)
|
||||
protectionResults := runProtectionChecks(coreResults)
|
||||
allResults := append(coreResults, protectionResults...)
|
||||
|
||||
printResults(allResults)
|
||||
|
||||
if doctor.HasFailures(allResults) {
|
||||
return &doctorFailError{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runCoreChecks(cfg *config.RuntimeConfig) []doctor.CheckResult {
|
||||
checks := []doctor.Check{
|
||||
{
|
||||
Name: checkConfigFile,
|
||||
Category: "Configuration",
|
||||
Run: func() doctor.CheckResult {
|
||||
if _, err := os.Stat(cfg.ConfigFilePath()); err != nil {
|
||||
return doctor.CheckResult{
|
||||
Status: doctor.StatusFail,
|
||||
Message: "Config file not found",
|
||||
}
|
||||
}
|
||||
return doctor.CheckResult{
|
||||
Status: doctor.StatusPass,
|
||||
Message: "Config file found",
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: checkEventLogDir,
|
||||
Category: "Configuration",
|
||||
Run: func() doctor.CheckResult {
|
||||
info, err := os.Stat(cfg.EventLogDir())
|
||||
if err != nil {
|
||||
return doctor.CheckResult{
|
||||
Status: doctor.StatusFail,
|
||||
Message: "Event log directory not found",
|
||||
}
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return doctor.CheckResult{
|
||||
Status: doctor.StatusFail,
|
||||
Message: "Event log path is not a directory",
|
||||
}
|
||||
}
|
||||
return doctor.CheckResult{
|
||||
Status: doctor.StatusPass,
|
||||
Message: "Event log directory found",
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: checkShellAliases,
|
||||
Category: "Shell Integration",
|
||||
Run: func() doctor.CheckResult {
|
||||
aliasCfg := alias.DefaultConfig()
|
||||
rcFileManager, err := alias.NewDefaultRcFileManager(aliasCfg.RcFileName)
|
||||
if err != nil {
|
||||
return doctor.CheckResult{
|
||||
Status: doctor.StatusWarn,
|
||||
Message: fmt.Sprintf("Could not check aliases: %v", err),
|
||||
}
|
||||
}
|
||||
aliasManager := alias.New(aliasCfg, rcFileManager)
|
||||
installed, err := aliasManager.IsInstalled()
|
||||
if err != nil {
|
||||
return doctor.CheckResult{
|
||||
Status: doctor.StatusWarn,
|
||||
Message: fmt.Sprintf("Could not determine alias status: %v", err),
|
||||
}
|
||||
}
|
||||
if !installed {
|
||||
return doctor.CheckResult{
|
||||
Status: doctor.StatusFail,
|
||||
Message: "Aliases not installed",
|
||||
}
|
||||
}
|
||||
return doctor.CheckResult{
|
||||
Status: doctor.StatusPass,
|
||||
Message: "Shell aliases installed",
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: checkShimDirectory,
|
||||
Category: "Shell Integration",
|
||||
Run: func() doctor.CheckResult {
|
||||
sm, err := shim.NewDefaultShimManager()
|
||||
if err != nil {
|
||||
return doctor.CheckResult{
|
||||
Status: doctor.StatusWarn,
|
||||
Message: fmt.Sprintf("Could not check shims: %v", err),
|
||||
}
|
||||
}
|
||||
shimDir := sm.GetBinDir()
|
||||
info, err := os.Stat(shimDir)
|
||||
if err != nil || !info.IsDir() {
|
||||
return doctor.CheckResult{
|
||||
Status: doctor.StatusFail,
|
||||
Message: "Shim directory not found",
|
||||
}
|
||||
}
|
||||
return doctor.CheckResult{
|
||||
Status: doctor.StatusPass,
|
||||
Message: "Shim directory found",
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: checkShimInPath,
|
||||
Category: "Shell Integration",
|
||||
Run: func() doctor.CheckResult {
|
||||
sm, err := shim.NewDefaultShimManager()
|
||||
if err != nil {
|
||||
return doctor.CheckResult{
|
||||
Status: doctor.StatusWarn,
|
||||
Message: fmt.Sprintf("Could not check shims: %v", err),
|
||||
}
|
||||
}
|
||||
shimDir := sm.GetBinDir()
|
||||
if slices.Contains(filepath.SplitList(os.Getenv("PATH")), shimDir) {
|
||||
return doctor.CheckResult{
|
||||
Status: doctor.StatusPass,
|
||||
Message: "Shim directory is in PATH",
|
||||
}
|
||||
}
|
||||
return doctor.CheckResult{
|
||||
Status: doctor.StatusFail,
|
||||
Message: "Shim directory not in PATH",
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: checkProxyMode,
|
||||
Category: "Security",
|
||||
Run: func() doctor.CheckResult {
|
||||
if cfg.IsProxyModeEnabled() {
|
||||
return doctor.CheckResult{
|
||||
Status: doctor.StatusPass,
|
||||
Message: "Proxy mode is enabled",
|
||||
}
|
||||
}
|
||||
return doctor.CheckResult{
|
||||
Status: doctor.StatusFail,
|
||||
Message: "Proxy mode is disabled",
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: checkDependencyCooldown,
|
||||
Category: "Security",
|
||||
Run: func() doctor.CheckResult {
|
||||
if cfg.Config.DependencyCooldown.Enabled {
|
||||
return doctor.CheckResult{
|
||||
Status: doctor.StatusPass,
|
||||
Message: "Dependency cooldown is enabled",
|
||||
}
|
||||
}
|
||||
return doctor.CheckResult{
|
||||
Status: doctor.StatusWarn,
|
||||
Message: "Dependency cooldown is disabled",
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: checkEventLogging,
|
||||
Category: "Security",
|
||||
Run: func() doctor.CheckResult {
|
||||
if !cfg.Config.SkipEventLogging {
|
||||
return doctor.CheckResult{
|
||||
Status: doctor.StatusPass,
|
||||
Message: "Event logging is enabled",
|
||||
}
|
||||
}
|
||||
return doctor.CheckResult{
|
||||
Status: doctor.StatusWarn,
|
||||
Message: "Event logging is disabled",
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: checkSandbox,
|
||||
Category: "Security",
|
||||
Run: func() doctor.CheckResult {
|
||||
sb, err := platform.NewSandbox()
|
||||
available := err == nil && sb != nil && sb.IsAvailable()
|
||||
if !cfg.Config.Sandbox.Enabled {
|
||||
return doctor.CheckResult{
|
||||
Status: doctor.StatusWarn,
|
||||
Message: "Sandbox is disabled",
|
||||
}
|
||||
}
|
||||
if !available {
|
||||
return doctor.CheckResult{
|
||||
Status: doctor.StatusFail,
|
||||
Message: "Sandbox enabled but no driver available on this platform",
|
||||
}
|
||||
}
|
||||
return doctor.CheckResult{
|
||||
Status: doctor.StatusPass,
|
||||
Message: fmt.Sprintf("Sandbox enabled (%s)", sb.Name()),
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
return doctor.RunChecks(checks)
|
||||
}
|
||||
|
||||
func runProtectionChecks(coreResults []doctor.CheckResult) []doctor.CheckResult {
|
||||
if !isInterceptionActive(coreResults) {
|
||||
var results []doctor.CheckResult
|
||||
for _, tc := range doctor.ProtectionTestCases() {
|
||||
results = append(results, doctor.CheckResult{
|
||||
Name: fmt.Sprintf("protection-%s", tc.PackageManager),
|
||||
Category: "Protection",
|
||||
Status: doctor.StatusFail,
|
||||
Message: "Aliases and shims not active",
|
||||
})
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
pmgBinary, err := os.Executable()
|
||||
if err != nil {
|
||||
pmgBinary = "pmg"
|
||||
}
|
||||
|
||||
var results []doctor.CheckResult
|
||||
for _, tc := range doctor.ProtectionTestCases() {
|
||||
result := doctor.RunProtectionCheck(tc, pmgBinary)
|
||||
result.Category = "Protection"
|
||||
result.Name = fmt.Sprintf("protection-%s", tc.PackageManager)
|
||||
results = append(results, result)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func isInterceptionActive(coreResults []doctor.CheckResult) bool {
|
||||
for _, r := range coreResults {
|
||||
if r.Name == checkShellAliases && r.Status == doctor.StatusPass {
|
||||
return true
|
||||
}
|
||||
if r.Name == checkShimInPath && r.Status == doctor.StatusPass {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var checkDisplayNames = map[string]string{
|
||||
checkConfigFile: "Config file",
|
||||
checkEventLogDir: "Event log directory",
|
||||
checkShellAliases: "Shell aliases",
|
||||
checkShimDirectory: "Shim directory",
|
||||
checkShimInPath: "Shim in PATH",
|
||||
checkProxyMode: "Proxy mode",
|
||||
checkDependencyCooldown: "Dependency cooldown",
|
||||
checkEventLogging: "Event logging",
|
||||
checkSandbox: "Sandbox",
|
||||
checkProtectionNpm: "npm protection",
|
||||
checkProtectionPip: "pip protection",
|
||||
}
|
||||
|
||||
var checkFixes = map[string]string{
|
||||
checkConfigFile: "pmg setup install",
|
||||
checkEventLogDir: "pmg setup install",
|
||||
checkShellAliases: "pmg setup install",
|
||||
checkShimDirectory: "pmg setup install",
|
||||
checkShimInPath: "Restart shell or source config",
|
||||
checkProxyMode: "Set proxy.enabled: true in config",
|
||||
checkSandbox: "Set sandbox.enabled: true in config",
|
||||
checkDependencyCooldown: "Set dependency_cooldown.enabled: true in config",
|
||||
checkEventLogging: "Set skip_event_logging: false in config",
|
||||
checkProtectionNpm: "pmg setup install",
|
||||
checkProtectionPip: "pmg setup install",
|
||||
}
|
||||
|
||||
func printResults(results []doctor.CheckResult) {
|
||||
fmt.Println()
|
||||
fmt.Println(ui.Colors.Cyan("Setup Diagnostics"))
|
||||
fmt.Println(ui.Colors.Normal("--------------------"))
|
||||
|
||||
rows := [][]string{{
|
||||
ui.Colors.Bold("STATUS"),
|
||||
ui.Colors.Bold("CHECK"),
|
||||
ui.Colors.Bold("SUMMARY"),
|
||||
ui.Colors.Bold("FIX"),
|
||||
}}
|
||||
for _, r := range results {
|
||||
fix := ui.Colors.Dim("—")
|
||||
if r.Status != doctor.StatusPass {
|
||||
fix = fixHint(r.Name)
|
||||
}
|
||||
rows = append(rows, []string{
|
||||
statusBadge(r.Status),
|
||||
displayName(r.Name),
|
||||
ui.Truncate(r.Message, 60),
|
||||
fix,
|
||||
})
|
||||
}
|
||||
|
||||
if err := ui.RenderTable(os.Stdout, rows, nil); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "render error: %v\n", err)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
printSummaryLine(results)
|
||||
}
|
||||
|
||||
func statusBadge(s doctor.CheckStatus) string {
|
||||
switch s {
|
||||
case doctor.StatusPass:
|
||||
return ui.Colors.Green("OK")
|
||||
case doctor.StatusWarn:
|
||||
return ui.Colors.Yellow("WARN")
|
||||
case doctor.StatusFail:
|
||||
return ui.Colors.Red("FAIL")
|
||||
default:
|
||||
return "?"
|
||||
}
|
||||
}
|
||||
|
||||
func displayName(name string) string {
|
||||
if dn, ok := checkDisplayNames[name]; ok {
|
||||
return dn
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func fixHint(name string) string {
|
||||
if fix, ok := checkFixes[name]; ok {
|
||||
return fix
|
||||
}
|
||||
return ui.Colors.Dim("—")
|
||||
}
|
||||
|
||||
func printSummaryLine(results []doctor.CheckResult) {
|
||||
passCount, warnCount, failCount := 0, 0, 0
|
||||
for _, r := range results {
|
||||
switch r.Status {
|
||||
case doctor.StatusPass:
|
||||
passCount++
|
||||
case doctor.StatusWarn:
|
||||
warnCount++
|
||||
case doctor.StatusFail:
|
||||
failCount++
|
||||
}
|
||||
}
|
||||
|
||||
summary := fmt.Sprintf("%d passed", passCount)
|
||||
if warnCount > 0 {
|
||||
summary += fmt.Sprintf(", %d warnings", warnCount)
|
||||
}
|
||||
if failCount > 0 {
|
||||
summary += fmt.Sprintf(", %d failed", failCount)
|
||||
fmt.Printf("%s %s\n", ui.Colors.Red("FAIL"), ui.Colors.Red(summary))
|
||||
} else if warnCount > 0 {
|
||||
fmt.Printf("%s %s\n", ui.Colors.Yellow("WARN"), ui.Colors.Yellow(summary))
|
||||
} else {
|
||||
fmt.Printf("%s %s\n", ui.Colors.Green("OK"), ui.Colors.Green(summary))
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ func NewSetupCommand() *cobra.Command {
|
||||
setupCmd.AddCommand(NewInstallCommand())
|
||||
setupCmd.AddCommand(NewRemoveCommand())
|
||||
setupCmd.AddCommand(NewInfoCommand())
|
||||
setupCmd.AddCommand(NewDoctorCommand())
|
||||
|
||||
return setupCmd
|
||||
}
|
||||
|
||||
@@ -163,8 +163,14 @@ func (a *AliasManager) IsInstalled() (bool, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.Contains(string(data), a.config.RcFileName) {
|
||||
return true, nil
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(trimmed, a.config.RcFileName) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/safedep/dry/log"
|
||||
)
|
||||
|
||||
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.1.4",
|
||||
InstallArgs: []string{"pip", "install", "--no-cache-dir", "safedep-test-pkg==0.1.4"},
|
||||
NeedsVenv: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func RunProtectionCheck(tc ProtectionTestCase, pmgBinary string) CheckResult {
|
||||
if _, err := exec.LookPath(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
|
||||
|
||||
_, runErr := cmd.CombinedOutput()
|
||||
return evaluateProtectionResult(tc.PackageManager, tc.Package, 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, err error) CheckResult {
|
||||
if err != nil {
|
||||
if isExecutableNotFound(err) {
|
||||
return CheckResult{
|
||||
Status: StatusWarn,
|
||||
Message: fmt.Sprintf("%s not available — skipping protection test for %s", pm, pkg),
|
||||
}
|
||||
}
|
||||
return CheckResult{
|
||||
Status: StatusPass,
|
||||
Message: fmt.Sprintf("Malicious package blocked (%s/%s)", pm, pkg),
|
||||
}
|
||||
}
|
||||
return CheckResult{
|
||||
Status: StatusFail,
|
||||
Message: fmt.Sprintf("Failed to block %s/%s — package was installed instead of blocked", pm, pkg),
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEvaluateProtectionResult(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pm string
|
||||
pkg string
|
||||
err error
|
||||
wantStatus CheckStatus
|
||||
}{
|
||||
{
|
||||
name: "blocked",
|
||||
pm: "npm",
|
||||
pkg: "safedep-test-pkg@0.1.3",
|
||||
err: fmt.Errorf("exit status 1"),
|
||||
wantStatus: StatusPass,
|
||||
},
|
||||
{
|
||||
name: "not blocked",
|
||||
pm: "npm",
|
||||
pkg: "safedep-test-pkg@0.1.3",
|
||||
err: nil,
|
||||
wantStatus: StatusFail,
|
||||
},
|
||||
{
|
||||
name: "pm not found",
|
||||
pm: "npm",
|
||||
pkg: "safedep-test-pkg@0.1.3",
|
||||
err: &exec.Error{Name: "npm", Err: exec.ErrNotFound},
|
||||
wantStatus: StatusWarn,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := evaluateProtectionResult(tt.pm, tt.pkg, tt.err)
|
||||
assert.Equal(t, tt.wantStatus, result.Status)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtectionTestCases(t *testing.T) {
|
||||
cases := ProtectionTestCases()
|
||||
require.GreaterOrEqual(t, len(cases), 2)
|
||||
|
||||
hasNpm := false
|
||||
hasPip := false
|
||||
for _, tc := range cases {
|
||||
if tc.PackageManager == "npm" {
|
||||
hasNpm = true
|
||||
assert.False(t, tc.NeedsVenv)
|
||||
assert.Contains(t, tc.InstallArgs, "--no-cache")
|
||||
assert.Contains(t, tc.InstallArgs, "--prefer-online")
|
||||
}
|
||||
if tc.PackageManager == "pip" {
|
||||
hasPip = true
|
||||
assert.True(t, tc.NeedsVenv)
|
||||
assert.Contains(t, tc.InstallArgs, "--no-cache-dir")
|
||||
}
|
||||
}
|
||||
assert.True(t, hasNpm)
|
||||
assert.True(t, hasPip)
|
||||
}
|
||||
|
||||
func TestPrependPath(t *testing.T) {
|
||||
env := []string{"HOME=/home/user", "PATH=/usr/bin:/bin", "TERM=xterm"}
|
||||
result := prependPath(env, "/tmp/venv/bin")
|
||||
|
||||
require.Len(t, result, 3)
|
||||
assert.Equal(t, "HOME=/home/user", result[0])
|
||||
assert.True(t, strings.HasPrefix(result[1], "PATH=/tmp/venv/bin"))
|
||||
assert.Contains(t, result[1], "/usr/bin:/bin")
|
||||
assert.Equal(t, "TERM=xterm", result[2])
|
||||
}
|
||||
|
||||
func TestSetupVenv(t *testing.T) {
|
||||
if _, err := exec.LookPath("python3"); err != nil {
|
||||
t.Skip("python3 not available")
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
venvDir, err := setupVenv(tmpDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
pipPath := filepath.Join(venvDir, "bin", "pip")
|
||||
_, err = os.Stat(pipPath)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestCheckShimScripts(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
shimDir := filepath.Join(tmpDir, ".pmg", "bin")
|
||||
require.NoError(t, os.MkdirAll(shimDir, 0o755))
|
||||
|
||||
shimPath := filepath.Join(shimDir, "npm")
|
||||
require.NoError(t, os.WriteFile(shimPath, []byte("#!/bin/sh\nexec pmg npm \"$@\""), 0o755))
|
||||
|
||||
found, missing := CheckShimScripts(shimDir, []string{"npm", "pip"})
|
||||
assert.Equal(t, []string{"npm"}, found)
|
||||
assert.Equal(t, []string{"pip"}, missing)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package doctor
|
||||
|
||||
type CheckStatus int
|
||||
|
||||
const (
|
||||
StatusPass CheckStatus = iota
|
||||
StatusWarn
|
||||
StatusFail
|
||||
)
|
||||
|
||||
type CheckResult struct {
|
||||
Name string
|
||||
Category string
|
||||
Status CheckStatus
|
||||
Message string
|
||||
}
|
||||
|
||||
type Check struct {
|
||||
Name string
|
||||
Category string
|
||||
Run func() CheckResult
|
||||
}
|
||||
|
||||
func RunChecks(checks []Check) []CheckResult {
|
||||
results := make([]CheckResult, 0, len(checks))
|
||||
for _, c := range checks {
|
||||
result := c.Run()
|
||||
result.Name = c.Name
|
||||
result.Category = c.Category
|
||||
results = append(results, result)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func HasFailures(results []CheckResult) bool {
|
||||
for _, r := range results {
|
||||
if r.Status == StatusFail {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func CategorySummary(results []CheckResult) map[string]CheckStatus {
|
||||
summary := make(map[string]CheckStatus)
|
||||
for _, r := range results {
|
||||
current, exists := summary[r.Category]
|
||||
if !exists || r.Status > current {
|
||||
summary[r.Category] = r.Status
|
||||
}
|
||||
}
|
||||
return summary
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRunChecks_AllPass(t *testing.T) {
|
||||
checks := []Check{
|
||||
{
|
||||
Name: "test-pass-1",
|
||||
Category: "Test",
|
||||
Run: func() CheckResult {
|
||||
return CheckResult{Status: StatusPass, Message: "all good"}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "test-pass-2",
|
||||
Category: "Test",
|
||||
Run: func() CheckResult {
|
||||
return CheckResult{Status: StatusPass, Message: "also good"}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
results := RunChecks(checks)
|
||||
require.Len(t, results, 2)
|
||||
assert.Equal(t, StatusPass, results[0].Status)
|
||||
assert.Equal(t, StatusPass, results[1].Status)
|
||||
assert.False(t, HasFailures(results))
|
||||
}
|
||||
|
||||
func TestRunChecks_WithFailure(t *testing.T) {
|
||||
checks := []Check{
|
||||
{
|
||||
Name: "test-pass",
|
||||
Category: "Test",
|
||||
Run: func() CheckResult {
|
||||
return CheckResult{Status: StatusPass, Message: "ok"}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "test-fail",
|
||||
Category: "Test",
|
||||
Run: func() CheckResult {
|
||||
return CheckResult{Status: StatusFail, Message: "broken"}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
results := RunChecks(checks)
|
||||
require.Len(t, results, 2)
|
||||
assert.True(t, HasFailures(results))
|
||||
}
|
||||
|
||||
func TestRunChecks_WarnDoesNotCountAsFailure(t *testing.T) {
|
||||
checks := []Check{
|
||||
{
|
||||
Name: "test-warn",
|
||||
Category: "Test",
|
||||
Run: func() CheckResult {
|
||||
return CheckResult{Status: StatusWarn, Message: "maybe"}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
results := RunChecks(checks)
|
||||
require.Len(t, results, 1)
|
||||
assert.Equal(t, StatusWarn, results[0].Status)
|
||||
assert.False(t, HasFailures(results))
|
||||
}
|
||||
|
||||
func TestRunChecks_PreservesCheckMetadata(t *testing.T) {
|
||||
checks := []Check{
|
||||
{
|
||||
Name: "my-check",
|
||||
Category: "My Category",
|
||||
Run: func() CheckResult {
|
||||
return CheckResult{Status: StatusPass, Message: "details"}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
results := RunChecks(checks)
|
||||
require.Len(t, results, 1)
|
||||
assert.Equal(t, "my-check", results[0].Name)
|
||||
assert.Equal(t, "My Category", results[0].Category)
|
||||
assert.Equal(t, "details", results[0].Message)
|
||||
}
|
||||
|
||||
func TestCategorySummary_AllPass(t *testing.T) {
|
||||
results := []CheckResult{
|
||||
{Category: "A", Status: StatusPass},
|
||||
{Category: "A", Status: StatusPass},
|
||||
}
|
||||
summary := CategorySummary(results)
|
||||
require.Len(t, summary, 1)
|
||||
assert.Equal(t, StatusPass, summary["A"])
|
||||
}
|
||||
|
||||
func TestCategorySummary_FailOverridesWarn(t *testing.T) {
|
||||
results := []CheckResult{
|
||||
{Category: "A", Status: StatusPass},
|
||||
{Category: "A", Status: StatusWarn},
|
||||
{Category: "A", Status: StatusFail},
|
||||
}
|
||||
summary := CategorySummary(results)
|
||||
assert.Equal(t, StatusFail, summary["A"])
|
||||
}
|
||||
|
||||
func TestCategorySummary_WarnOverridesPass(t *testing.T) {
|
||||
results := []CheckResult{
|
||||
{Category: "A", Status: StatusPass},
|
||||
{Category: "A", Status: StatusWarn},
|
||||
}
|
||||
summary := CategorySummary(results)
|
||||
assert.Equal(t, StatusWarn, summary["A"])
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var ansiEscapeRe = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`)
|
||||
|
||||
func VisibleWidth(s string) int {
|
||||
return utf8.RuneCountInString(ansiEscapeRe.ReplaceAllString(s, ""))
|
||||
}
|
||||
|
||||
func RenderTable(out io.Writer, rows [][]string, after func(rowIdx int) error) error {
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
cols := len(rows[0])
|
||||
widths := make([]int, cols)
|
||||
for _, row := range rows {
|
||||
for i, cell := range row {
|
||||
if w := VisibleWidth(cell); w > widths[i] {
|
||||
widths[i] = w
|
||||
}
|
||||
}
|
||||
}
|
||||
for rIdx, row := range rows {
|
||||
for i, cell := range row {
|
||||
if i == cols-1 {
|
||||
if _, err := fmt.Fprint(out, cell); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
pad := widths[i] - VisibleWidth(cell)
|
||||
if _, err := fmt.Fprint(out, cell, strings.Repeat(" ", pad+2)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := fmt.Fprintln(out); err != nil {
|
||||
return err
|
||||
}
|
||||
if after != nil {
|
||||
dataIdx := rIdx - 1
|
||||
if err := after(dataIdx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func FirstColumnIndent(rows [][]string) string {
|
||||
if len(rows) == 0 {
|
||||
return ""
|
||||
}
|
||||
w := 0
|
||||
for _, row := range rows {
|
||||
if v := VisibleWidth(row[0]); v > w {
|
||||
w = v
|
||||
}
|
||||
}
|
||||
return strings.Repeat(" ", w+2)
|
||||
}
|
||||
|
||||
func Truncate(s string, n int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) <= n {
|
||||
return s
|
||||
}
|
||||
if n <= 3 {
|
||||
return string(runes[:n])
|
||||
}
|
||||
return string(runes[:n-3]) + "..."
|
||||
}
|
||||
|
||||
func TruncateLeft(s string, n int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) <= n {
|
||||
return s
|
||||
}
|
||||
if n <= 3 {
|
||||
return string(runes[len(runes)-n:])
|
||||
}
|
||||
return "..." + string(runes[len(runes)-(n-3):])
|
||||
}
|
||||
Reference in New Issue
Block a user