diff --git a/.gitignore b/.gitignore index 6f72f89..361b949 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ # Go workspace file go.work go.work.sum +pmg # env file .env diff --git a/cmd/ecosystems/npm.go b/cmd/ecosystems/npm.go index 21767ab..3a8ec48 100644 --- a/cmd/ecosystems/npm.go +++ b/cmd/ecosystems/npm.go @@ -1,15 +1,18 @@ package ecosystems import ( + "context" _ "embed" "fmt" "os" "strings" + "time" packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" "github.com/safedep/dry/log" "github.com/safedep/pmg/pkg/analyser" "github.com/safedep/pmg/pkg/common" + "github.com/safedep/pmg/pkg/common/utils" "github.com/spf13/cobra" ) @@ -23,25 +26,45 @@ var arboristJs string func NewNpmCommand() *cobra.Command { cmd := &cobra.Command{ - Use: "npm", + Use: "npm [action] [package]", Short: "Scan packages from npm registry", + Args: cobra.MinimumNArgs(2), RunE: func(cmd *cobra.Command, args []string) error { action = args[0] packageName = args[1] - if action == "install" { + + validActions := map[string]bool{"install": true, "i": true, "add": true} + if validActions[action] { err := wrapNpm() if err != nil { - // TODO - log.Fatalf("wrapNpm: ", err.Error()) + log.Errorf("Failed to wrap npm: %v", err) + return err } + return nil } - return nil + + // For non-install actions, just pass through to npm + npmPath, err := utils.GetInterpreterPath("npm") + if err != nil { + return fmt.Errorf("npm not found: %w", err) + } + + return utils.ExecCmd(npmPath, args) }, } return cmd } func wrapNpm() error { + if packageName == "" { + return fmt.Errorf("package name cannot be empty") + } + + // Setup context with timeout for API calls + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + // Extract package information outputFile, err := common.RunPkgExtractor(common.ExtractorOptions{ PackageName: packageName, ScriptContent: arboristJs, @@ -50,56 +73,91 @@ func wrapNpm() error { Args: []string{}, }) if err != nil { - return err + return fmt.Errorf("failed to extract package info: %w", err) } + // Clean up the temporary file when done + defer os.Remove(outputFile) data, err := os.ReadFile(outputFile) if err != nil { - return fmt.Errorf("Error while reading package output file: %s", err.Error()) + return fmt.Errorf("error while reading package output file: %w", err) } - lines := strings.SplitSeq(string(data), "\n") - for line := range lines { + maliciousPkgs := make(map[string]string) + + client, err := analyser.GetMalwareAnalysisClient() + if err != nil { + return fmt.Errorf("error while creating a malware analysis client: %w", err) + } + + lines := strings.Split(string(data), "\n") + for _, line := range lines { line = strings.TrimSpace(line) if line == "" || !strings.Contains(line, "@") { continue } - parts := strings.SplitN(line, "@", 2) - if len(parts) != 2 { - log.Debugf("Invalid package line:", line) + idx := strings.LastIndex(line, "@") + if idx <= 0 { + log.Debugf("Invalid package line: %s", line) continue } - name := parts[0] - version := parts[1] + name := line[:idx] + version := line[idx+1:] - client, err := analyser.GetMalwareAnalysisClient() + resp, err := analyser.SubmitPackageForAnalysis(ctx, client, packagev1.Ecosystem_ECOSYSTEM_NPM, name, version) if err != nil { - return fmt.Errorf("Error while creating a malware analysis client: %s", err.Error()) - } - - resp, err := analyser.SubmitPackageForAnalysis(client, packagev1.Ecosystem_ECOSYSTEM_NPM, name, version) - if err != nil { - log.Debugf("Failed to analyze %s@%s: %v\n", name, version, err) - continue - } - log.Debugf("Submitted %s@%s | Analysis ID: %s\n", name, version, resp.GetAnalysisId()) - - reportResp, err := analyser.GetAnalysisReport(client, resp.GetAnalysisId()) - if err != nil { - log.Debugf("Failed to get analysis report for %s:%s %v\n", name, resp.GetAnalysisId(), err) + log.Debugf("Failed to analyze %s@%s: %v", name, version, err) continue } - _ = reportResp.GetReport() + reportResp, err := analyser.GetAnalysisReport(ctx, client, resp.GetAnalysisId()) + if err != nil { + log.Debugf("Failed to get analysis report for %s:%s %v", name, resp.GetAnalysisId(), err) + continue + } + + report := reportResp.GetReport() + if report == nil { + log.Debugf("Empty report received for %s", name) + continue + } + + inference := report.GetInference() + if inference == nil { + log.Debugf("No inference data for %s", name) + continue + } + + log.Infof("Inference for %s: isMalware=%v", name, inference.GetIsMalware()) + + if inference.GetIsMalware() { + maliciousPkgs[line] = inference.GetSummary() + } } - // TODO: // Get the npm PATH - // Check if any vulnerable package exists? - // If yes - Confirm with user to continue or not - // If no - Install the pkg & return - // If continue - Install the pkg + npmPath, err := utils.GetInterpreterPath("npm") + if err != nil { + return fmt.Errorf("npm not found: %w", err) + } + + // Check if any malicious package exists + if len(maliciousPkgs) > 0 { + if !utils.ConfirmInstallation(maliciousPkgs) { + log.Infof("Installation canceled due to security concerns") + return nil + } + log.Warnf("Continuing installation despite security warnings...") + } + + // Install the package and return + cmdArgs := []string{action, packageName} + if err = utils.ExecCmd(npmPath, cmdArgs); err != nil { + return fmt.Errorf("failed to execute npm command: %w", err) + } + + log.Infof("Successfully installed %s", packageName) return nil } diff --git a/go.mod b/go.mod index f00b557..58581cc 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,6 @@ require ( github.com/google/go-querystring v1.1.0 // indirect github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/joho/godotenv v1.5.1 // indirect github.com/spf13/pflag v1.0.6 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.57.0 // indirect go.opentelemetry.io/otel v1.32.0 // indirect diff --git a/go.sum b/go.sum index e4c9310..93fdb9f 100644 --- a/go.sum +++ b/go.sum @@ -46,8 +46,6 @@ github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDa github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= diff --git a/main.go b/main.go index 95e82bf..141615a 100644 --- a/main.go +++ b/main.go @@ -2,20 +2,18 @@ package main import ( "fmt" - "log" "os" - "github.com/joho/godotenv" + "github.com/safedep/dry/log" "github.com/safedep/pmg/cmd/ecosystems" "github.com/spf13/cobra" ) -func main() { - err := godotenv.Load(".env") - if err != nil { - log.Println("No .env file found or failed to load") - } +func init() { + log.Init("pmg-logger", "debug") +} +func main() { cmd := &cobra.Command{ Use: "pmg", TraverseChildren: true, diff --git a/pkg/analyser/analysis.go b/pkg/analyser/analysis.go index 2ed9301..ad8a8a0 100644 --- a/pkg/analyser/analysis.go +++ b/pkg/analyser/analysis.go @@ -10,7 +10,7 @@ import ( malysisv1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/services/malysis/v1" ) -func SubmitPackageForAnalysis(client malysisv1grpc.MalwareAnalysisServiceClient, +func SubmitPackageForAnalysis(ctx context.Context, client malysisv1grpc.MalwareAnalysisServiceClient, ecosystem packagev1.Ecosystem, name string, version string) (*malysisv1.AnalyzePackageResponse, error) { req := &malysisv1.AnalyzePackageRequest{ @@ -24,21 +24,21 @@ func SubmitPackageForAnalysis(client malysisv1grpc.MalwareAnalysisServiceClient, }, }, } - resp, err := client.AnalyzePackage(context.Background(), req) + resp, err := client.AnalyzePackage(ctx, req) if err != nil { - return nil, fmt.Errorf("failed to analyze %s@%s: %v", name, version, err) + return nil, fmt.Errorf("failed to analyze %s@%s: %w", name, version, err) } return resp, nil } -func GetAnalysisReport(client malysisv1grpc.MalwareAnalysisServiceClient, +func GetAnalysisReport(ctx context.Context, client malysisv1grpc.MalwareAnalysisServiceClient, analysisId string) (*malysisv1.GetAnalysisReportResponse, error) { analysisReportReq := &malysisv1.GetAnalysisReportRequest{ AnalysisId: analysisId, } - reportResp, err := client.GetAnalysisReport(context.Background(), analysisReportReq) + reportResp, err := client.GetAnalysisReport(ctx, analysisReportReq) if err != nil { - return nil, fmt.Errorf("failed to get analysis report: %v", err) + return nil, fmt.Errorf("failed to get analysis report: %w", err) } return reportResp, nil } diff --git a/pkg/common/extractor.go b/pkg/common/extractor.go index ab0c33d..02cc560 100644 --- a/pkg/common/extractor.go +++ b/pkg/common/extractor.go @@ -1,13 +1,12 @@ package common import ( - "bytes" "fmt" "os" - "os/exec" "path/filepath" "github.com/safedep/dry/crypto" + "github.com/safedep/pmg/pkg/common/utils" ) // ExtractorOptions holds configuration for running an extractor script @@ -21,10 +20,9 @@ type ExtractorOptions struct { // RunExtractor extracts an embedded script to a temp file and executes it func RunPkgExtractor(opts ExtractorOptions) (string, error) { - interpreterPath, err := exec.LookPath(opts.Interpreter) + interpreterPath, err := utils.GetInterpreterPath(opts.Interpreter) if err != nil { - return "", fmt.Errorf("interpreter '%s' not found in PATH: %s", - opts.Interpreter, err.Error()) + return "", err } // Create a temporary file for the embedded script @@ -52,16 +50,8 @@ func RunPkgExtractor(opts ExtractorOptions) (string, error) { // Build the command with all arguments cmdArgs := append([]string{scriptFile.Name(), opts.PackageName, outputFile}, opts.Args...) - cmd := exec.Command(interpreterPath, cmdArgs...) - - // Capture both stdout and stderr - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - return "", fmt.Errorf("error running extractor: %s\nStderr: %s", - err.Error(), stderr.String()) + if err = utils.ExecCmd(interpreterPath, cmdArgs); err != nil { + return "", err } return outputFile, nil diff --git a/pkg/common/grpc.go b/pkg/common/grpc.go index 47f6ad9..30ba140 100644 --- a/pkg/common/grpc.go +++ b/pkg/common/grpc.go @@ -3,15 +3,15 @@ package common import ( "fmt" "net/http" - "os" drygrpc "github.com/safedep/dry/adapters/grpc" + "github.com/safedep/pmg/pkg/common/utils" "google.golang.org/grpc" ) func NewCloudClientConnection() (*grpc.ClientConn, error) { - tok := os.Getenv("SAFEDEP_API_KEY") - tenantId := os.Getenv("SAFEDEP_TENANT_ID") + tok := utils.ApiKey() + tenantId := utils.TenantDomain() if tok == "" || tenantId == "" { return nil, fmt.Errorf("SAFEDEP_API_KEY and SAFEDEP_TENANT_ID must be set") } diff --git a/pkg/common/utils/command.go b/pkg/common/utils/command.go new file mode 100644 index 0000000..cbeabd3 --- /dev/null +++ b/pkg/common/utils/command.go @@ -0,0 +1,21 @@ +package utils + +import ( + "bytes" + "fmt" + "os/exec" +) + +func ExecCmd(name string, args []string) error { + cmd := exec.Command(name, args...) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("error running cmd %s: %s\nStderr: %s", name, + err.Error(), stderr.String()) + } + return nil +} diff --git a/pkg/common/utils/config.go b/pkg/common/utils/config.go new file mode 100644 index 0000000..e52c8d4 --- /dev/null +++ b/pkg/common/utils/config.go @@ -0,0 +1,11 @@ +package utils + +import "os" + +func ApiKey() string { + return os.Getenv("SAFEDEP_API_KEY") +} + +func TenantDomain() string { + return os.Getenv("SAFEDEP_TENANT_ID") +} diff --git a/pkg/common/utils/interpreter.go b/pkg/common/utils/interpreter.go new file mode 100644 index 0000000..69d5c42 --- /dev/null +++ b/pkg/common/utils/interpreter.go @@ -0,0 +1,14 @@ +package utils + +import ( + "fmt" + "os/exec" +) + +func GetInterpreterPath(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 +} diff --git a/pkg/common/utils/security_confirmation.go b/pkg/common/utils/security_confirmation.go new file mode 100644 index 0000000..15316b6 --- /dev/null +++ b/pkg/common/utils/security_confirmation.go @@ -0,0 +1,30 @@ +package utils + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/safedep/dry/log" +) + +func ConfirmInstallation(maliciousPkgs map[string]string) bool { + fmt.Printf("\nWARNING: %d potentially malicious packages detected!\n", len(maliciousPkgs)) + fmt.Println("The following packages have been flagged:") + + for name, reason := range maliciousPkgs { + fmt.Printf("- %s: %s\n", name, reason) + } + + fmt.Print("\nDo 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" +} diff --git a/pmg b/pmg deleted file mode 100644 index 5c3a8bb..0000000 Binary files a/pmg and /dev/null differ