mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 
|
||||
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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package wrapper
|
||||
|
||||
import "errors"
|
||||
|
||||
const (
|
||||
ErrPackageInstallationDeny = "PACKAGE_INSTALLATION_DENIED"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrPackageInstall = errors.New(ErrPackageInstallationDeny)
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user