feat: Refactor PMG to Maintain Separation of Concerns and Clean Architecture (#19)

* feat: Add separate package manager and resolver

* fix: Npm dependency resolver

* feat: Add analyzer for malysis query

* feat: Add package manager guard as the orchestrator

* feat: Add PMG to orchestrate installation

* Add concurrent scan execution

* Introduce package manager interaction abstraction

* feat: Add UI port for guard

* Remove refactored source files

* Update README

* fix: CI script for multi-arch build

* ci: goreleaser CI fix

* fix: npm command parser to extract package names

* feat: Introduce global config primitive

* fix: Close results channel for clean goroutine exit

* ci: Add container image releaser

* test: Improve test for npm resolver

* refactor: Analyzer to generalise

* Improve UI with additional info

* fix: Goreleaser config

* fix: npm resolver bug

* fix: Fail when command exec workflow fails

* fix: Bug with transitive dependency resolution

* fix: Synchronize common data update in dependency resolver

* chore: Improve log handling

* docs: Update README

* fix: UI text wrapping

* fix: UI handling bugs

* feat: Use concurrent dependency resolver
This commit is contained in:
Abhisek Datta
2025-05-15 16:50:59 +05:30
committed by GitHub
parent 8b46964c7a
commit e86b6ef056
47 changed files with 2562 additions and 1788 deletions
-90
View File
@@ -1,90 +0,0 @@
package analyser
import (
"context"
"fmt"
"sync"
"buf.build/gen/go/safedep/api/grpc/go/safedep/services/malysis/v1/malysisv1grpc"
malysisv1pb "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/malysis/v1"
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
malysisv1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/services/malysis/v1"
"github.com/safedep/dry/log"
"github.com/safedep/pmg/internal/ui"
"github.com/safedep/pmg/pkg/models"
vetUtils "github.com/safedep/vet/pkg/common/utils"
)
type PackageAnalyser struct {
MaliciousPkgs map[string]string
Client malysisv1grpc.MalwareAnalysisServiceClient
Ctx context.Context
MaliciousPkgsMutex sync.Mutex
ProgressTracker ui.ProgressTracker
Ecosystem packagev1.Ecosystem
}
func New(client malysisv1grpc.MalwareAnalysisServiceClient, ctx context.Context, ecosystem packagev1.Ecosystem) *PackageAnalyser {
return &PackageAnalyser{
MaliciousPkgs: make(map[string]string),
Client: client,
Ctx: ctx,
MaliciousPkgsMutex: sync.Mutex{},
Ecosystem: ecosystem,
}
}
func (ap *PackageAnalyser) Handler() vetUtils.WorkQueueFn[models.Package] {
return func(q *vetUtils.WorkQueue[models.Package], item models.Package) error {
reportResp, err := QueryPackageAnalysis(ap.Ctx, ap.Client,
ap.Ecosystem, item.Name, item.Version)
if err != nil {
log.Debugf("Failed to analyze %s@%s: %v", item.Name, item.Version, err)
return nil
}
report := reportResp.GetReport()
if report == nil {
log.Debugf("Empty report received for %s", item.Name)
return nil
}
inference := report.GetInference()
if inference == nil {
log.Debugf("No inference data for %s", item.Name)
return nil
}
log.Infof("Inference for %s: isMalware=%v", item.Name, inference.GetIsMalware())
if inference.GetIsMalware() {
ap.MaliciousPkgsMutex.Lock()
ap.MaliciousPkgs[fmt.Sprintf("%s@%s", item.Name, item.Version)] = inference.GetSummary()
ap.MaliciousPkgsMutex.Unlock()
}
ui.IncrementProgress(ap.ProgressTracker, 1)
return nil
}
}
func QueryPackageAnalysis(ctx context.Context, client malysisv1grpc.MalwareAnalysisServiceClient, ecosystem packagev1.Ecosystem, name string,
version string) (*malysisv1.QueryPackageAnalysisResponse, error) {
resp, err := client.QueryPackageAnalysis(ctx, &malysisv1.QueryPackageAnalysisRequest{
Target: &malysisv1pb.PackageAnalysisTarget{
PackageVersion: &packagev1.PackageVersion{
Package: &packagev1.Package{
Ecosystem: ecosystem,
Name: name,
},
Version: version,
},
},
})
if err != nil {
return nil, fmt.Errorf("failed to analyze %s@%s: %w", name, version, err)
}
return resp, nil
}
-16
View File
@@ -1,16 +0,0 @@
package analyser
import (
"fmt"
"buf.build/gen/go/safedep/api/grpc/go/safedep/services/malysis/v1/malysisv1grpc"
"github.com/safedep/pmg/pkg/common"
)
func GetMalwareAnalysisClient() (malysisv1grpc.MalwareAnalysisServiceClient, error) {
cc, err := common.NewCloudClientConnection()
if err != nil {
return nil, fmt.Errorf("failed to create gRPC client: %v", err)
}
return malysisv1grpc.NewMalwareAnalysisServiceClient(cc), nil
}
-87
View File
@@ -1,87 +0,0 @@
package common
import (
"fmt"
"os"
"path/filepath"
"github.com/safedep/dry/crypto"
"github.com/safedep/pmg/pkg/common/utils"
"github.com/safedep/pmg/pkg/models"
)
// ExtractorOptions holds configuration for running an extractor script
type ExtractorOptions struct {
ScriptContent string // The script content
ScriptType string // File extension like "js", "py", etc.
Interpreter string // What interpreter to use (e.g., "node", "python")
PackageName string // Name of the package to analyze
Args []string // Additional arguments to pass to the script
Env map[string]string // Environment variables to pass to the script
}
func FlattenDependencyTree(node *models.DependencyNode) []string {
result := make([]string, 0)
seen := make(map[string]bool)
var flatten func(*models.DependencyNode)
flatten = func(n *models.DependencyNode) {
key := fmt.Sprintf("%s@%s", n.Name, n.Version)
if seen[key] {
return
}
seen[key] = true
result = append(result, fmt.Sprintf("%s@%s", n.Name, n.Version))
for _, dep := range n.Dependencies {
flatten(dep)
}
}
flatten(node)
return result
}
// RunExtractor extracts an embedded script to a temp file and executes it
func RunPkgExtractor(opts ExtractorOptions) (string, error) {
interpreterPath, err := utils.GetExecutablePath(opts.Interpreter)
if err != nil {
return "", err
}
// Create a temporary file for the embedded script
scriptFile, err := os.CreateTemp("", fmt.Sprintf("registry-extractor-*.%s", opts.ScriptType))
if err != nil {
return "", fmt.Errorf("failed to create temporary script file: %s", err.Error())
}
defer os.Remove(scriptFile.Name())
// Write the embedded script to the temporary file
if _, err = scriptFile.WriteString(opts.ScriptContent); err != nil {
return "", fmt.Errorf("failed to write script to temporary file: %s", err.Error())
}
if err = scriptFile.Close(); err != nil {
return "", fmt.Errorf("failed to close temporary script file: %s", err.Error())
}
// Create a file with random name which will contain the output
randomFileName, err := crypto.RandomString(12, "abcdefghijklmnopqrstuvwxyz0123456789")
if err != nil {
return "", fmt.Errorf("failed to generate random string: %s", err.Error())
}
outputFile := filepath.Join(os.TempDir(), randomFileName+".txt")
// Build the command with all arguments
cmdArgs := append([]string{scriptFile.Name(), opts.PackageName, outputFile}, opts.Args...)
var env []string
for key, value := range opts.Env {
env = append(env, fmt.Sprintf("%s=%s", key, value))
}
if err = utils.ExecCmd(interpreterPath, cmdArgs, env); err != nil {
return "", err
}
return outputFile, nil
}
-25
View File
@@ -1,25 +0,0 @@
package common
import (
"fmt"
"net/http"
drygrpc "github.com/safedep/dry/adapters/grpc"
"google.golang.org/grpc"
)
func NewCloudClientConnection() (*grpc.ClientConn, error) {
cc, err := newGrpcClient(http.Header{}, "", "pmg-pkg-scan", "community-api.safedep.io", "443")
if err != nil {
return nil, fmt.Errorf("failed to create gRPC client: %v", err)
}
return cc, nil
}
func newGrpcClient(headers http.Header, token, clientName, host, port string) (*grpc.ClientConn, error) {
cc, err := drygrpc.GrpcClient(clientName, host, port, token, headers, []grpc.DialOption{})
if err != nil {
return nil, err
}
return cc, nil
}
-18
View File
@@ -1,18 +0,0 @@
package utils
import (
"os"
"os/exec"
)
func ExecCmd(name string, args, env []string) error {
cmd := exec.Command(name, args...)
cmd.Env = append(os.Environ(), env...)
// Connect to standard streams
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
return cmd.Run()
}
-51
View File
@@ -1,51 +0,0 @@
package utils
import (
"fmt"
"os"
"strings"
)
func ApiKey() string {
return os.Getenv("SAFEDEP_API_KEY")
}
func TenantDomain() string {
return os.Getenv("SAFEDEP_TENANT_ID")
}
func NpmAuthToken() string {
return os.Getenv("NPM_AUTH_TOKEN")
}
func ValidateEnvVars() error {
apiKey := ApiKey()
tenantId := TenantDomain()
var missingVars []string
if apiKey == "" {
missingVars = append(missingVars, "SAFEDEP_API_KEY")
}
if tenantId == "" {
missingVars = append(missingVars, "SAFEDEP_TENANT_ID")
}
if len(missingVars) > 0 {
return fmt.Errorf(`
SafeDep configuration incomplete
Missing environment variables:
%s
To enable package scanning:
1. Export these variables in your terminal:
export %s=your_api_key
export %s=your_tenant_id
2. Or add them to your shell profile file
For more information, visit: https://docs.safedep.io/cloud/quickstart
`, strings.Join(missingVars, "\n "), missingVars[0], missingVars[len(missingVars)-1])
}
return nil
}
-14
View File
@@ -1,14 +0,0 @@
package utils
import (
"fmt"
"os/exec"
)
func GetExecutablePath(name string) (string, error) {
path, err := exec.LookPath(name)
if err != nil {
return "", fmt.Errorf("interpreter '%s' not found in PATH: %s", name, err.Error())
}
return path, nil
}
-54
View File
@@ -1,54 +0,0 @@
package utils
import (
"regexp"
"strings"
)
var (
headerBulletRegex = regexp.MustCompile(`(?m)^(#{1,6}\s+|[-*]\s{1,}|\d+\.\s+|>\s+)`)
inlineCodeRegex = regexp.MustCompile("`{1,3}([^`]*)`{1,3}")
horizontalRuleRegex = regexp.MustCompile(`(?m)^\s*(-{3,}|\*{3,}|\_{3,})\s*$`)
boldItalicRegex = regexp.MustCompile(`(?:\*\*\*|___)(.*?)(?:\*\*\*|___)`)
boldRegex = regexp.MustCompile(`(?:\*\*|__)(.*?)(?:\*\*|__)`)
italicRegex = regexp.MustCompile(`(?:\*|_)(.*?)(?:\*|_)`)
strikethroughRegex = regexp.MustCompile(`~~([^~]+)~~`)
inlineLinkRegex = regexp.MustCompile(`\[([^\]]+)\]\((\S+?)\)`)
imageRegex = regexp.MustCompile(`!\[([^\]]*)\]\((\S+?)\)`)
extraSpacesRegex = regexp.MustCompile(`\s+`)
)
func removeMarkdown(text string) string {
// Remove bold italic (***bolditalic*** or ___bolditalic___)
text = boldItalicRegex.ReplaceAllString(text, "$1")
// Remove bold (**bold** or __bold__)
text = boldRegex.ReplaceAllString(text, "$1")
// Remove italic (*italic* or _italic_)
text = italicRegex.ReplaceAllString(text, "$1")
// Remove strikethrough (~~text~~)
text = strikethroughRegex.ReplaceAllString(text, "$1")
// Remove inline code (`code`)
text = inlineCodeRegex.ReplaceAllString(text, "$1")
// Remove links [text](url)
text = inlineLinkRegex.ReplaceAllString(text, "$1")
// Remove images ![alt](url)
text = imageRegex.ReplaceAllString(text, "$1")
// Remove horizontal rules
text = horizontalRuleRegex.ReplaceAllString(text, "")
// Remove headers, blockquotes, bullets (e.g., ### Heading, > Quote, - Item)
text = headerBulletRegex.ReplaceAllString(text, "")
// Normalize extra spaces
text = extraSpacesRegex.ReplaceAllString(text, " ")
// Trim leading/trailing whitespace
return strings.TrimSpace(text)
}
-64
View File
@@ -1,64 +0,0 @@
package utils
import (
"fmt"
"strings"
)
// ParseNpmInstallArgs parses npm install command arguments and returns
// separated flags and packages. It expects args to include the full command
// including "npm" and "install" at the start
func ParseNpmInstallArgs(args []string) ([]string, []string) {
var flags []string
var packages []string
for _, arg := range args {
if strings.HasPrefix(arg, "-") {
flags = append(flags, arg)
} else {
packages = append(packages, arg)
}
}
return flags, packages
}
func CleanVersion(version string) string {
version = strings.TrimPrefix(version, "^")
version = strings.TrimPrefix(version, "~")
if version == "*" {
return "latest"
}
return version
}
func ParsePackageInfo(input string) (packageName, version string, err error) {
if input == "" {
return "", "", fmt.Errorf("package info cannot be empty")
}
input = strings.TrimSpace(input)
if strings.HasPrefix(input, "@") {
lastAtIndex := strings.LastIndex(input, "@")
if lastAtIndex > 0 {
packageName = strings.TrimSpace(input[:lastAtIndex])
version = strings.TrimSpace(input[lastAtIndex+1:])
return packageName, version, nil
}
// If no version specifier, return the whole input as package name
return strings.TrimSpace(input), "", nil
}
pkg := strings.Split(input, "@")
if len(pkg) == 2 {
packageName = strings.TrimSpace(pkg[0])
version = strings.TrimSpace(pkg[1])
return packageName, version, nil
}
if len(pkg) == 1 {
packageName = strings.TrimSpace(pkg[0])
return packageName, "", nil
}
return "", "", fmt.Errorf("invalid format: expected 'package' OR 'package@version', got '%s'", input)
}
-35
View File
@@ -1,35 +0,0 @@
package utils
import (
"bufio"
"fmt"
"os"
"strings"
"github.com/safedep/dry/log"
)
func ConfirmInstallation(maliciousPkgs map[string]string) bool {
fmt.Printf("\n%s\n", colors.Red("⚠️ WARNING: %d potentially malicious packages detected!", len(maliciousPkgs)))
fmt.Println(colors.Yellow("The following packages have been flagged:"))
for name, reason := range maliciousPkgs {
fmt.Printf("%s %s: %s\n",
colors.Cyan("•"), // bullet point
colors.Yellow(name),
removeMarkdown(reason),
)
}
fmt.Print("\n", colors.Green("Do you want to continue with installation? (y/N): "))
reader := bufio.NewReader(os.Stdin)
response, err := reader.ReadString('\n')
if err != nil {
log.Errorf("Failed to read user input: %v", err)
return false
}
response = strings.ToLower(strings.TrimSpace(response))
return response == "y" || response == "yes"
}
-17
View File
@@ -1,17 +0,0 @@
package utils
import "github.com/fatih/color"
type TerminalColors struct {
Red func(format string, a ...interface{}) string
Yellow func(format string, a ...interface{}) string
Cyan func(format string, a ...interface{}) string
Green func(format string, a ...interface{}) string
}
var colors = TerminalColors{
Red: color.New(color.FgRed, color.Bold).SprintfFunc(),
Yellow: color.New(color.FgYellow).SprintfFunc(),
Cyan: color.New(color.FgCyan).SprintfFunc(),
Green: color.New(color.FgGreen).SprintfFunc(),
}
-21
View File
@@ -1,21 +0,0 @@
package utils
func IsInstallCommand(pkgManager, cmd string) bool {
validActions := map[string]map[string]bool{
"npm": {
"install": true,
"i": true,
"add": true,
},
"pnpm": {
"add": true,
"install": true,
"i": true,
},
}
if actions, exists := validActions[pkgManager]; exists {
return actions[cmd]
}
return false
}
-212
View File
@@ -1,212 +0,0 @@
package utils
import (
"strings"
"testing"
)
func TestCleanVersion(t *testing.T) {
tests := []struct {
name string
version string
expected string
}{
{
name: "caret version",
version: "^1.2.3",
expected: "1.2.3",
},
{
name: "tilde version",
version: "~1.2.3",
expected: "1.2.3",
},
{
name: "exact version",
version: "1.2.3",
expected: "1.2.3",
},
{
name: "asterisk version",
version: "*",
expected: "latest",
},
{
name: "empty version",
version: "",
expected: "",
},
{
name: "both caret and tilde",
version: "^~1.2.3",
expected: "1.2.3",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := CleanVersion(tt.version)
if result != tt.expected {
t.Errorf("CleanVersion(%q) = %q, want %q", tt.version, result, tt.expected)
}
})
}
}
func TestParsePackageInfo(t *testing.T) {
tests := []struct {
name string
input string
wantPackage string
wantVersion string
wantErr bool
errorContains string
}{
{
name: "simple package",
input: "express",
wantPackage: "express",
wantVersion: "",
wantErr: false,
},
{
name: "package with version",
input: "express@4.17.1",
wantPackage: "express",
wantVersion: "4.17.1",
wantErr: false,
},
{
name: "scoped package",
input: "@angular/core",
wantPackage: "@angular/core",
wantVersion: "",
wantErr: false,
},
{
name: "scoped package with version",
input: "@angular/core@12.0.0",
wantPackage: "@angular/core",
wantVersion: "12.0.0",
wantErr: false,
},
{
name: "package with caret version",
input: "react@^17.0.2",
wantPackage: "react",
wantVersion: "^17.0.2",
wantErr: false,
},
{
name: "package with tilde version",
input: "lodash@~4.17.21",
wantPackage: "lodash",
wantVersion: "~4.17.21",
wantErr: false,
},
{
name: "empty input",
input: "",
wantPackage: "",
wantVersion: "",
wantErr: true,
errorContains: "package info cannot be empty",
},
{
name: "invalid format with multiple @",
input: "pkg@1.0.0@2.0.0",
wantPackage: "",
wantVersion: "",
wantErr: true,
errorContains: "invalid format",
},
{
name: "package with spaces",
input: " express@4.17.1 ",
wantPackage: "express",
wantVersion: "4.17.1",
wantErr: false,
},
{
name: "scoped package with spaces",
input: " @types/node@14.14.31 ",
wantPackage: "@types/node",
wantVersion: "14.14.31",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
packageName, version, err := ParsePackageInfo(tt.input)
// Check error
if tt.wantErr {
if err == nil {
t.Errorf("ParsePackageInfo(%q) expected error, got nil", tt.input)
return
}
if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) {
t.Errorf("ParsePackageInfo(%q) error = %v, want error containing %q", tt.input, err, tt.errorContains)
}
return
}
if err != nil {
t.Errorf("ParsePackageInfo(%q) unexpected error: %v", tt.input, err)
return
}
// Check package name
if packageName != tt.wantPackage {
t.Errorf("ParsePackageInfo(%q) package = %q, want %q", tt.input, packageName, tt.wantPackage)
}
// Check version
if version != tt.wantVersion {
t.Errorf("ParsePackageInfo(%q) version = %q, want %q", tt.input, version, tt.wantVersion)
}
})
}
}
func TestRemoveMarkdown(t *testing.T) {
tests := []struct {
input string
expected string
}{
// Bold
{"This is **bold** text", "This is bold text"},
{"This is __bold__ text", "This is bold text"},
// Italic
{"This is *italic* text", "This is italic text"},
{"This is _italic_ text", "This is italic text"},
// Code
{"This is `code` inline", "This is code inline"},
// Link
{"Click [here](https://example.com)", "Click here"},
// Headings
{"# Heading 1", "Heading 1"},
{"### Subheading", "Subheading"},
// Combined formatting
{"__*bold and italic*__", "bold and italic"},
{"This is **bold** and `code`", "This is bold and code"},
// No markdown
{"Just plain text", "Just plain text"},
// Complex mixed
{"### Title\nSome **bold** text and a [link](http://url.com).", "Title Some bold text and a link."},
}
for _, tt := range tests {
result := removeMarkdown(tt.input)
if result != tt.expected {
t.Errorf("removeMarkdown(%q) = %q; want %q", tt.input, result, tt.expected)
}
}
}
-26
View File
@@ -1,26 +0,0 @@
package models
import (
"fmt"
)
type Package struct {
Name string
Version string
}
func (p Package) Id() string {
return fmt.Sprintf("%s@%s", p.Name, p.Version)
}
type PackageInfo struct {
Name string `json:"name"`
Version string `json:"version"`
Dependencies map[string]string `json:"dependencies"`
}
type DependencyNode struct {
Name string
Version string
Dependencies map[string]*DependencyNode
}
-110
View File
@@ -1,110 +0,0 @@
package registry
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/safedep/pmg/pkg/models"
)
// RegistryClient defines the interface for making requests to a registry
type RegistryClient interface {
// FetchPackageInfo fetches metadata for a specific package version
FetchPackageInfo(ctx context.Context, pkg models.Package) (*models.PackageInfo, error)
// GetLatestVersion fetches the latest version for a package
GetLatestVersion(ctx context.Context, packageName string) (string, error)
}
// HttpRegistryClient is a basic HTTP client for registry APIs
type HttpRegistryClient struct {
httpClient *http.Client
urlFormat string
parser func([]byte) (*models.PackageInfo, error)
}
// NewHttpRegistryClient creates a new HTTP registry client
func NewHttpRegistryClient(
timeout time.Duration,
urlFormat string,
parser func([]byte) (*models.PackageInfo, error),
) *HttpRegistryClient {
return &HttpRegistryClient{
httpClient: &http.Client{Timeout: timeout},
urlFormat: urlFormat,
parser: parser,
}
}
// FetchPackageInfo fetches package metadata from the registry
func (c *HttpRegistryClient) FetchPackageInfo(ctx context.Context, pkg models.Package) (*models.PackageInfo, error) {
url := fmt.Sprintf(c.urlFormat, pkg.Name, pkg.Version)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("making request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("registry returned status: %s", resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading response body: %w", err)
}
return c.parser(body)
}
// GetLatestVersion fetches the latest version for an NPM package
func (c *HttpRegistryClient) GetLatestVersion(ctx context.Context, packageName string) (string, error) {
// For NPM, we can get latest version by querying the base package URL
url := fmt.Sprintf("https://registry.npmjs.org/%s", packageName)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", fmt.Errorf("creating request: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("making request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("registry returned status: %s", resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("reading response body: %w", err)
}
// Parse the response to get the latest version
var pkgData struct {
DistTags struct {
Latest string `json:"latest"`
} `json:"dist-tags"`
}
if err := json.Unmarshal(body, &pkgData); err != nil {
return "", fmt.Errorf("parsing package info: %w", err)
}
if pkgData.DistTags.Latest == "" {
return "", fmt.Errorf("no latest version found for package %s", packageName)
}
return pkgData.DistTags.Latest, nil
}
-38
View File
@@ -1,38 +0,0 @@
package registry
import (
"fmt"
"time"
)
// RegistryType represents different package registries
type RegistryType string
const (
RegistryNPM RegistryType = "npm"
RegistryPNPM RegistryType = "pnpm"
RegistryPyPI RegistryType = "pypi"
RegistryGo RegistryType = "go"
)
// FetcherFactory creates appropriate fetchers based on registry type
type FetcherFactory struct {
timeout time.Duration
}
// NewFetcherFactory creates a new factory for registry fetchers
func NewFetcherFactory(timeout time.Duration) *FetcherFactory {
return &FetcherFactory{
timeout: timeout,
}
}
// CreateFetcher returns a fetcher for the specified registry type
func (ff *FetcherFactory) CreateFetcher(registryType RegistryType) (Fetcher, error) {
switch registryType {
case RegistryNPM, RegistryPNPM:
return NewNpmFetcher(ff.timeout), nil
default:
return nil, fmt.Errorf("unsupported registry type: %s", registryType)
}
}
-84
View File
@@ -1,84 +0,0 @@
// Package registry provides interfaces and implementations for fetching dependencies
// from various package registries (npm, pypi, go, etc.)
package registry
import (
"context"
"fmt"
"sync"
"sync/atomic"
"github.com/safedep/pmg/internal/ui"
"github.com/safedep/pmg/pkg/models"
)
// Fetcher defines the interface for registry dependency fetchers
type Fetcher interface {
// GetDependencyTree returns the complete dependency tree for a package
GetDependencyTree(ctx context.Context, pkg models.Package) (*models.DependencyNode, error)
// GetFlattenedDependencies returns a list of all dependencies as package@version strings
GetFlattenedDependencies(ctx context.Context, packageName, version string) ([]string, error)
}
// BaseFetcher implements common functionality for all registry fetchers
type BaseFetcher struct {
visitedMu sync.RWMutex
visited map[string]bool
client RegistryClient
progressTracker ui.ProgressTracker
fetchedDeps int32
}
// NewBaseFetcher creates a new BaseFetcher with the specified registry client
func NewBaseFetcher(client RegistryClient) *BaseFetcher {
return &BaseFetcher{
visited: make(map[string]bool),
client: client,
}
}
func (bf *BaseFetcher) SetProgressTracker(tracker ui.ProgressTracker) {
bf.progressTracker = tracker
atomic.StoreInt32(&bf.fetchedDeps, 0)
}
// isVisited checks if a package has already been visited
func (bf *BaseFetcher) isVisited(key string) bool {
bf.visitedMu.RLock()
defer bf.visitedMu.RUnlock()
return bf.visited[key]
}
// markVisited marks a package as visited
func (bf *BaseFetcher) markVisited(key string) {
bf.visitedMu.Lock()
defer bf.visitedMu.Unlock()
bf.visited[key] = true
}
// cacheKey generates a unique key for a package
func cacheKey(pkg models.Package) string {
return fmt.Sprintf("%s@%s", pkg.Name, pkg.Version)
}
// flattenDependencyTree recursively converts a dependency tree to a flat list of strings
func flattenDependencyTree(node *models.DependencyNode, result *[]string) {
if node == nil {
return
}
depString := fmt.Sprintf("%s@%s", node.Name, node.Version)
*result = append(*result, depString)
for _, dep := range node.Dependencies {
flattenDependencyTree(dep, result)
}
}
// resetVisited resets the visited packages map
func (bf *BaseFetcher) resetVisited() {
bf.visitedMu.Lock()
defer bf.visitedMu.Unlock()
bf.visited = make(map[string]bool)
}
-175
View File
@@ -1,175 +0,0 @@
package registry
import (
"context"
"encoding/json"
"fmt"
"sync"
"sync/atomic"
"time"
"github.com/safedep/dry/log"
"github.com/safedep/pmg/internal/ui"
"github.com/safedep/pmg/pkg/common/utils"
"github.com/safedep/pmg/pkg/models"
)
// NpmFetcher fetches dependencies from NPM registry
type NpmFetcher struct {
*BaseFetcher
}
func (nf *NpmFetcher) incrementProgress() {
if nf.progressTracker != nil {
atomic.AddInt32(&nf.fetchedDeps, 1)
// Update progress message to show number of packages fetched
ui.SetPinnedMessageOnProgressWriter(fmt.Sprintf("Fetched %d packages", atomic.LoadInt32(&nf.fetchedDeps)))
}
}
// NewNpmFetcher creates a new NPM registry fetcher
func NewNpmFetcher(timeout time.Duration) *NpmFetcher {
client := NewHttpRegistryClient(
timeout,
"https://registry.npmjs.org/%s/%s",
parseNpmPackageInfo,
)
return &NpmFetcher{
BaseFetcher: NewBaseFetcher(client),
}
}
// parseNpmPackageInfo parses NPM package information from JSON
func parseNpmPackageInfo(data []byte) (*models.PackageInfo, error) {
var packageInfo models.PackageInfo
if err := json.Unmarshal(data, &packageInfo); err != nil {
return nil, fmt.Errorf("parsing package info: %w", err)
}
return &packageInfo, nil
}
// GetDependencyTree fetches the complete dependency tree for an NPM package
func (nf *NpmFetcher) GetDependencyTree(ctx context.Context, pkg models.Package) (*models.DependencyNode, error) {
return nf.fetchDependenciesConcurrent(ctx, pkg)
}
// GetFlattenedDependencies returns a flat list of all dependencies as strings
func (nf *NpmFetcher) GetFlattenedDependencies(ctx context.Context, packageName, version string) ([]string, error) {
// Reset the visited map to ensure we get a complete tree
nf.resetVisited()
// Get the complete dependency tree
tree, err := nf.GetDependencyTree(ctx, models.Package{
Name: packageName,
Version: version,
})
if err != nil {
return nil, fmt.Errorf("failed to fetch dependency tree: %w", err)
}
// Convert tree to flat list
var dependencies []string
flattenDependencyTree(tree, &dependencies)
// Remove duplicates if needed
uniqueDeps := make(map[string]bool)
var result []string
for _, dep := range dependencies {
if !uniqueDeps[dep] {
uniqueDeps[dep] = true
result = append(result, dep)
}
}
return result, nil
}
// fetchDependenciesConcurrent recursively fetches package dependencies concurrently
func (nf *NpmFetcher) fetchDependenciesConcurrent(ctx context.Context, pkg models.Package) (*models.DependencyNode, error) {
key := cacheKey(pkg)
if nf.isVisited(key) {
return &models.DependencyNode{
Name: pkg.Name,
Version: pkg.Version,
}, nil
}
nf.markVisited(key)
packageInfo, err := nf.client.FetchPackageInfo(ctx, pkg)
if err != nil {
return nil, fmt.Errorf("failed to fetch package info for %s: %w", pkg.Name, err)
}
nf.incrementProgress()
dependencies := packageInfo.Dependencies
node := &models.DependencyNode{
Name: pkg.Name,
Version: pkg.Version,
Dependencies: make(map[string]*models.DependencyNode),
}
if len(dependencies) == 0 {
return node, nil
}
// Process dependencies concurrently
type result struct {
name string
node *models.DependencyNode
err error
}
var wg sync.WaitGroup
resultChan := make(chan result, len(dependencies))
for depName, depVersion := range dependencies {
// Check if context is canceled
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
// Continue processing
}
wg.Add(1)
go func(name, version string) {
defer wg.Done()
version = utils.CleanVersion(version)
depNode, err := nf.fetchDependenciesConcurrent(ctx, models.Package{Name: name, Version: version})
resultChan <- result{name, depNode, err}
}(depName, depVersion)
}
// Wait for all goroutines to complete in a separate goroutine
go func() {
wg.Wait()
close(resultChan)
}()
// Collect results
for res := range resultChan {
if res.err != nil {
log.Warnf("Failed to fetch dependency %s: %v", res.name, res.err)
continue
}
node.Dependencies[res.name] = res.node
}
return node, nil
}
func (nf *NpmFetcher) ResolveVersion(ctx context.Context, packageName, version string) (string, error) {
if version != "" {
return version, nil
}
latestVersion, err := nf.client.GetLatestVersion(ctx, packageName)
if err != nil {
return "", fmt.Errorf("failed to get latest version for %s: %w", packageName, err)
}
return latestVersion, nil
}
-11
View File
@@ -1,11 +0,0 @@
package wrapper
import "errors"
const (
ErrPackageInstallationDeny = "PACKAGE_INSTALLATION_DENIED"
)
var (
ErrPackageInstall = errors.New(ErrPackageInstallationDeny)
)
-182
View File
@@ -1,182 +0,0 @@
package wrapper
import (
"context"
"errors"
"fmt"
"time"
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
"github.com/fatih/color"
"github.com/safedep/dry/log"
"github.com/safedep/pmg/internal/ui"
"github.com/safedep/pmg/pkg/analyser"
"github.com/safedep/pmg/pkg/common/utils"
"github.com/safedep/pmg/pkg/models"
"github.com/safedep/pmg/pkg/registry"
vetUtils "github.com/safedep/vet/pkg/common/utils"
)
type PackageManagerWrapper struct {
RegistryType registry.RegistryType
Flags []string
Action string
PackageNames []string
currentPackage string
PackagesToInstall []string
}
func NewPackageManagerWrapper(registryType registry.RegistryType, flags []string, packageNames []string, action string) *PackageManagerWrapper {
return &PackageManagerWrapper{
RegistryType: registryType,
PackageNames: packageNames,
Flags: flags,
Action: action,
}
}
func (pmw *PackageManagerWrapper) Wrap() error {
if len(pmw.PackageNames) == 0 {
return fmt.Errorf("no packages specified")
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
// Scan all packages first
for _, pkg := range pmw.PackageNames {
ui.StartProgressWriter()
var DefaultProgressTotal = 1
pmw.currentPackage = pkg
progressTracker := ui.TrackProgress(fmt.Sprintf("Scanning %s", pkg), DefaultProgressTotal)
if err := pmw.scanAndInstall(ctx, progressTracker); err != nil {
if errors.Is(err, ErrPackageInstall) {
log.Warnf("Skipping package %s due to ErrPackageInstall: %v", pkg, err)
continue
}
return err
}
pmw.PackagesToInstall = append(pmw.PackagesToInstall, pkg)
ui.StopProgressWriter()
}
if len(pmw.PackagesToInstall) == 0 {
log.Infof("No packages were installed due to security concerns")
return nil
}
// Execute installation after all scans complete
if err := pmw.executeInstallation(); err != nil {
return err
}
log.Infof("Successfully installed all packages")
return nil
}
func (pmw *PackageManagerWrapper) scanAndInstall(ctx context.Context, progressTracker ui.ProgressTracker) error {
factory := registry.NewFetcherFactory(10 * time.Second)
fetcher, err := factory.CreateFetcher(pmw.RegistryType)
if err != nil {
return err
}
name, version, err := utils.ParsePackageInfo(pmw.currentPackage)
if err != nil {
return err
}
if version == "" {
version, err = pmw.resolveLatestVersion(ctx, fetcher, name)
if err != nil {
return err
}
pmw.currentPackage = fmt.Sprintf("%s@%s", name, version)
}
// Get dependencies with progress tracking
npmFetcher := fetcher.(*registry.NpmFetcher)
npmFetcher.SetProgressTracker(progressTracker)
deps, err := npmFetcher.GetFlattenedDependencies(ctx, name, version)
if err != nil {
return err
}
// Set progress for analysis phase
ui.IncrementTrackerTotal(progressTracker, int64(len(deps)))
if err := pmw.analyzeDependencies(ctx, deps, progressTracker); err != nil {
return err
}
return nil
}
func (pmw *PackageManagerWrapper) resolveLatestVersion(ctx context.Context, fetcher registry.Fetcher, name string) (string, error) {
log.Infof("No version specified for %s, fetching latest version...", name)
version, err := fetcher.(*registry.NpmFetcher).ResolveVersion(ctx, name, "")
if err != nil {
return "", err
}
log.Infof("Latest version of %s is %s", name, version)
return version, nil
}
func (pmw *PackageManagerWrapper) analyzeDependencies(ctx context.Context, deps []string, progressTracker ui.ProgressTracker) error {
client, err := analyser.GetMalwareAnalysisClient()
if err != nil {
return fmt.Errorf("error while creating a malware analysis client: %w", err)
}
pkgAnalyser := analyser.New(client, ctx, packagev1.Ecosystem_ECOSYSTEM_NPM)
pkgAnalyser.ProgressTracker = progressTracker
handler := pkgAnalyser.Handler()
queue := vetUtils.NewWorkQueue[models.Package](100, 10, handler)
queue.Start()
defer queue.Stop()
for _, dep := range deps {
name, version, err := utils.ParsePackageInfo(dep)
if err != nil {
log.Errorf("Error while parsing info of package %s", name)
continue
}
queue.Add(models.Package{
Name: name,
Version: version,
})
}
queue.Wait()
ui.MarkTrackerAsDone(progressTracker)
ui.StopProgressWriter()
if len(pkgAnalyser.MaliciousPkgs) > 0 {
if !utils.ConfirmInstallation(pkgAnalyser.MaliciousPkgs) {
log.Infof("Installation canceled due to security concerns")
return ErrPackageInstall
}
yellow := color.New(color.FgYellow, color.Bold).SprintfFunc()
log.Warnf(yellow("Continuing installation despite security warnings..."))
}
return nil
}
func (pmw *PackageManagerWrapper) executeInstallation() error {
execPath, err := utils.GetExecutablePath(string(pmw.RegistryType))
if err != nil {
return fmt.Errorf("%s not found: %w", pmw.RegistryType, err)
}
cmdArgs := []string{pmw.Action}
cmdArgs = append(cmdArgs, pmw.Flags...)
cmdArgs = append(cmdArgs, pmw.PackagesToInstall...)
if err = utils.ExecCmd(execPath, cmdArgs, []string{}); err != nil {
return fmt.Errorf("failed to execute %s command: %w", pmw.RegistryType, err)
}
return nil
}