From 7daa0728abb4a37542df4c3e7761c756998478ec Mon Sep 17 00:00:00 2001 From: Abhisek Datta Date: Thu, 19 Dec 2024 14:50:57 +0530 Subject: [PATCH] feat: Integrate with SafeDep Malware Analysis Service (#299) * feat: Add support for malware analysis service integration * feat: Update malware analysis command to poll for report * fix: Spinner handling in malware analysis command * fix: Malware analysis output table * fix: Confidence string handling --- cmd/inspect/main.go | 20 ++++++ cmd/inspect/malware.go | 160 +++++++++++++++++++++++++++++++++++++++++ go.mod | 6 +- go.sum | 12 ++-- internal/auth/auth.go | 17 +++++ internal/auth/grpc.go | 4 ++ internal/ui/spinner.go | 8 ++- main.go | 14 ++++ 8 files changed, 231 insertions(+), 10 deletions(-) create mode 100644 cmd/inspect/main.go create mode 100644 cmd/inspect/malware.go diff --git a/cmd/inspect/main.go b/cmd/inspect/main.go new file mode 100644 index 00000000..13306ca5 --- /dev/null +++ b/cmd/inspect/main.go @@ -0,0 +1,20 @@ +package inspect + +import ( + "github.com/spf13/cobra" +) + +func NewPackageInspectCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "inspect", + Short: "Inspect an OSS package", + Long: `Inspect an OSS package using deep inspection and analysis. + This command will integrate with local and remote analysis services.`, + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } + + cmd.AddCommand(newPackageMalwareInspectCommand()) + return cmd +} diff --git a/cmd/inspect/malware.go b/cmd/inspect/malware.go new file mode 100644 index 00000000..a23a6596 --- /dev/null +++ b/cmd/inspect/malware.go @@ -0,0 +1,160 @@ +package inspect + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + "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" + malysisv1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/services/malysis/v1" + "github.com/jedib0t/go-pretty/v6/table" + "github.com/jedib0t/go-pretty/v6/text" + "github.com/safedep/dry/api/pb" + "github.com/safedep/dry/utils" + "github.com/safedep/vet/internal/auth" + "github.com/safedep/vet/internal/ui" + "github.com/spf13/cobra" +) + +var ( + malwareAnalysisPackageUrl string + malwareAnalysisTimeout time.Duration + malwareAnalysisReportJSON string +) + +func newPackageMalwareInspectCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "malware", + Short: "Inspect an OSS package for malware", + Long: `Inspect an OSS package for malware using SafeDep Malware Analysis API`, + RunE: func(cmd *cobra.Command, args []string) error { + err := executeMalwareAnalysis() + if err != nil { + ui.PrintError("Failed: %v", err) + } + + return nil + }, + } + + cmd.Flags().StringVar(&malwareAnalysisPackageUrl, "purl", "", + "Package URL to inspect for malware") + cmd.Flags().DurationVar(&malwareAnalysisTimeout, "timeout", 5*time.Minute, + "Timeout for malware analysis") + cmd.Flags().StringVar(&malwareAnalysisReportJSON, "report-json", "", + "Path to save malware analysis report in JSON format") + + _ = cmd.MarkFlagRequired("purl") + + return cmd +} + +func executeMalwareAnalysis() error { + cc, err := auth.MalwareAnalysisClientConnection("malware-analysis") + if err != nil { + return err + } + + service := malysisv1grpc.NewMalwareAnalysisServiceClient(cc) + + purl, err := pb.NewPurlPackageVersion(malwareAnalysisPackageUrl) + if err != nil { + return err + } + + ctx := context.Background() + ctx, cancelFun := context.WithTimeout(ctx, malwareAnalysisTimeout) + + defer cancelFun() + + analyzePackageResponse, err := service.AnalyzePackage(ctx, &malysisv1.AnalyzePackageRequest{ + Target: &malysisv1pb.PackageAnalysisTarget{ + PackageVersion: purl.PackageVersion(), + }, + }) + + if err != nil { + return fmt.Errorf("failed to submit package for malware analysis: %v", err) + } + + ui.PrintMsg("Submitted package for malware analysis with ID: %s", + analyzePackageResponse.GetAnalysisId()) + + ui.StartSpinner("Waiting for malware analysis to complete") + var report *malysisv1pb.Report + + for { + reportResponse, err := service.GetAnalysisReport(ctx, &malysisv1.GetAnalysisReportRequest{ + AnalysisId: analyzePackageResponse.GetAnalysisId(), + }) + + if err != nil { + return fmt.Errorf("failed to get malware analysis report: %v", err) + } + + if reportResponse.GetStatus() == malysisv1.AnalysisStatus_ANALYSIS_STATUS_FAILED { + return fmt.Errorf("malware analysis failed: %s", reportResponse.GetErrorMessage()) + } + + if reportResponse.GetStatus() == malysisv1.AnalysisStatus_ANALYSIS_STATUS_COMPLETED { + report = reportResponse.GetReport() + break + } + + time.Sleep(5 * time.Second) + } + + ui.StopSpinner() + + if report == nil { + return fmt.Errorf("malware analysis report is empty") + } + + ui.PrintSuccess("Malware analysis completed successfully") + + err = renderToJSON(report) + if err != nil { + ui.PrintError("Failed to render malware analysis report in JSON format: %v", err) + } + + return renderMalwareAnalysisReport(malwareAnalysisPackageUrl, report) +} + +func renderToJSON(report *malysisv1pb.Report) error { + if malwareAnalysisReportJSON == "" { + return nil + } + + data, err := utils.ToPbJson(report, " ") + if err != nil { + return err + } + + return os.WriteFile(malwareAnalysisReportJSON, []byte(data), 0644) +} + +func renderMalwareAnalysisReport(purl string, report *malysisv1pb.Report) error { + ui.PrintMsg("Malware analysis report for package: %s", purl) + + tbl := table.NewWriter() + tbl.SetOutputMirror(os.Stdout) + tbl.SetStyle(table.StyleLight) + + tbl.AppendHeader(table.Row{"Package URL", "Status", "Confidence"}) + + status := text.FgHiGreen.Sprint("SAFE") + if report.GetInference().GetIsMalware() { + status = text.FgHiRed.Sprint("MALWARE") + } + + confidence := report.GetInference().GetConfidence().String() + confidence = strings.TrimPrefix(confidence, "CONFIDENCE_") + + tbl.AppendRow(table.Row{purl, status, confidence}) + tbl.Render() + + return nil +} diff --git a/go.mod b/go.mod index aeeae57d..bab34d3b 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,8 @@ module github.com/safedep/vet go 1.23.2 require ( - buf.build/gen/go/safedep/api/grpc/go v1.5.1-20241117092917-2fd4dbf7c52e.1 - buf.build/gen/go/safedep/api/protocolbuffers/go v1.35.2-20241117092917-2fd4dbf7c52e.1 + buf.build/gen/go/safedep/api/grpc/go v1.5.1-20241127172711-d314452ec756.1 + buf.build/gen/go/safedep/api/protocolbuffers/go v1.35.2-20241127172711-d314452ec756.1 github.com/AlecAivazis/survey/v2 v2.3.7 github.com/CycloneDX/cyclonedx-go v0.9.1 github.com/anchore/syft v1.16.0 @@ -25,7 +25,7 @@ require ( github.com/oklog/ulid/v2 v2.1.0 github.com/owenrumney/go-sarif/v2 v2.3.3 github.com/package-url/packageurl-go v0.1.3 - github.com/safedep/dry v0.0.0-20241112071106-48fc2b8dc770 + github.com/safedep/dry v0.0.0-20241128083908-2f8ecd48dc2c github.com/sirupsen/logrus v1.9.3 github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82 github.com/spdx/tools-golang v0.5.5 diff --git a/go.sum b/go.sum index 80ef5895..0fa9ffa4 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,9 @@ buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.35.2-20240920164238-5a7b106cbb87.1 h1:7QIeAuTdLp173vC/9JojRMDFcpmqtoYrxPmvdHAOynw= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.35.2-20240920164238-5a7b106cbb87.1/go.mod h1:mnHCFccv4HwuIAOHNGdiIc5ZYbBCvbTWZcodLN5wITI= -buf.build/gen/go/safedep/api/grpc/go v1.5.1-20241117092917-2fd4dbf7c52e.1 h1:4UzreoJwRj2SKKfAyEaBbeF1aDocSRCqI7JNBN6IvgM= -buf.build/gen/go/safedep/api/grpc/go v1.5.1-20241117092917-2fd4dbf7c52e.1/go.mod h1:U1Qaal56kRbId8vgExO27c9qU9OnNenB4+PzV1+RXxQ= -buf.build/gen/go/safedep/api/protocolbuffers/go v1.35.2-20241117092917-2fd4dbf7c52e.1 h1:dxJ6SGFwwCx8l0wjA2JooSHUW7NtEiOqNgRsVFlrveE= -buf.build/gen/go/safedep/api/protocolbuffers/go v1.35.2-20241117092917-2fd4dbf7c52e.1/go.mod h1:471Oa35fKGZy8W6WOLV4R80UcWKMYaXNmIfApMvimf4= +buf.build/gen/go/safedep/api/grpc/go v1.5.1-20241127172711-d314452ec756.1 h1:ecda01eNlmjlQKIaiFIpV/VLbG3xeCh94TiEazvaGwc= +buf.build/gen/go/safedep/api/grpc/go v1.5.1-20241127172711-d314452ec756.1/go.mod h1:M1mSl+19TDBAGjd/L4t7UoCVQ0GlMYDwC2B3iqkpaDI= +buf.build/gen/go/safedep/api/protocolbuffers/go v1.35.2-20241127172711-d314452ec756.1 h1:J4oZ6bbe+gZzFXnJMUcM8BrSKh0FMLdPNpDwWizLeXg= +buf.build/gen/go/safedep/api/protocolbuffers/go v1.35.2-20241127172711-d314452ec756.1/go.mod h1:471Oa35fKGZy8W6WOLV4R80UcWKMYaXNmIfApMvimf4= cel.dev/expr v0.18.0 h1:CJ6drgk+Hf96lkLikr4rFf19WrU0BOWEihyZnI2TAzo= cel.dev/expr v0.18.0/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= @@ -786,8 +786,8 @@ github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99 github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= -github.com/safedep/dry v0.0.0-20241112071106-48fc2b8dc770 h1:NMcL4K+9IC0/LON8qxgtDrg7JcFkyoqI9NQUSFONios= -github.com/safedep/dry v0.0.0-20241112071106-48fc2b8dc770/go.mod h1:6HqxN60ZNx2ReiodmwkSbP6LlmAV7N8WJ/tlF6TLVU4= +github.com/safedep/dry v0.0.0-20241128083908-2f8ecd48dc2c h1:qUSfzPPlEcLKF6cKvkkXU6ddu4NGSz0UdS7Xjcrenvw= +github.com/safedep/dry v0.0.0-20241128083908-2f8ecd48dc2c/go.mod h1:dtGFDAnRo+WqwEyqPc2hTwuVGwWLq2jHnP4Q8BO1u7g= github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig= github.com/sagikazarmark/locafero v0.6.0 h1:ON7AQg37yzcRPU69mt7gwhFEBwxI6P9T4Qu3N51bwOk= github.com/sagikazarmark/locafero v0.6.0/go.mod h1:77OmuIc6VTraTXKXIs/uvUxKGUXjE1GbemJYHqdNjX0= diff --git a/internal/auth/auth.go b/internal/auth/auth.go index c64ff145..b7221d27 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -15,6 +15,7 @@ const ( apiV2UrlEnvKey = "VET_INSIGHTS_API_V2_URL" // gitleaks:allow syncUrlEnvKey = "VET_SYNC_API_URL" controlPlaneUrlEnvKey = "VET_CONTROL_PLANE_API_URL" + dataPlaneUrlEnvKey = "VET_DATA_PLANE_API_URL" apiKeyEnvKey = "VET_API_KEY" apiKeyAlternateEnvKey = "VET_INSIGHTS_API_KEY" communityModeEnvKey = "VET_COMMUNITY_MODE" @@ -24,6 +25,7 @@ const ( defaultCommunityApiUrl = "https://api.safedep.io/insights-community/v1" // gRPC service base URL. + defaultDataPlaneApiUrl = "https://api.safedep.io" defaultSyncApiUrl = "https://api.safedep.io" defaultInsightsApiV2Url = "https://api.safedep.io" defaultControlPlaneApiUrl = "https://cloud.safedep.io" @@ -41,6 +43,7 @@ type Config struct { ApiUrl string `yaml:"api_url"` ApiKey string `yaml:"api_key"` Community bool `yaml:"community"` + DataPlaneApiUrl string `yaml:"data_plane_api_url"` ControlPlaneApiUrl string `yaml:"control_api_url"` SyncApiUrl string `yaml:"sync_api_url"` InsightsApiV2Url string `yaml:"insights_api_v2_url"` @@ -61,6 +64,7 @@ func DefaultConfig() Config { return Config{ ApiUrl: defaultApiUrl, Community: false, + DataPlaneApiUrl: defaultDataPlaneApiUrl, ControlPlaneApiUrl: defaultControlPlaneApiUrl, SyncApiUrl: defaultSyncApiUrl, InsightsApiV2Url: defaultInsightsApiV2Url, @@ -157,6 +161,19 @@ func CloudRefreshToken() string { return "" } +func DataPlaneUrl() string { + envOverride := os.Getenv(dataPlaneUrlEnvKey) + if envOverride != "" { + return envOverride + } + + if (globalConfig != nil) && (globalConfig.DataPlaneApiUrl != "") { + return globalConfig.ApiUrl + } + + return defaultDataPlaneApiUrl +} + func SyncApiUrl() string { envOverride := os.Getenv(syncUrlEnvKey) if envOverride != "" { diff --git a/internal/auth/grpc.go b/internal/auth/grpc.go index 28a8b223..bf94ab8a 100644 --- a/internal/auth/grpc.go +++ b/internal/auth/grpc.go @@ -26,6 +26,10 @@ func InsightsV2ClientConnection(name string) (*grpc.ClientConn, error) { return cloudClientConnection(name, InsightsApiV2Url(), ApiKey()) } +func MalwareAnalysisClientConnection(name string) (*grpc.ClientConn, error) { + return cloudClientConnection(name, DataPlaneUrl(), ApiKey()) +} + func cloudClientConnection(name, loc, tok string) (*grpc.ClientConn, error) { parsedUrl, err := url.Parse(loc) if err != nil { diff --git a/internal/ui/spinner.go b/internal/ui/spinner.go index 3147a3be..28e20498 100644 --- a/internal/ui/spinner.go +++ b/internal/ui/spinner.go @@ -32,7 +32,13 @@ func StartSpinner(msg string) { } func StopSpinner() { - spinnerChan <- true + // Gracefully handle the case where the spinner is already stopped + // and the channel is closed, yet client code calls StopSpinner() again. + defer func() { + _ = recover() + }() + + close(spinnerChan) fmt.Printf("\r") fmt.Println() diff --git a/main.go b/main.go index 62b77cf1..75a2d6a5 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,7 @@ import ( "github.com/safedep/dry/utils" "github.com/safedep/vet/cmd/cloud" + "github.com/safedep/vet/cmd/inspect" "github.com/safedep/vet/internal/ui" "github.com/safedep/vet/pkg/common/logger" "github.com/safedep/vet/pkg/exceptions" @@ -60,6 +61,10 @@ func main() { cmd.AddCommand(newConnectCommand()) cmd.AddCommand(cloud.NewCloudCommand()) + if checkIfPackageInspectCommandEnabled() { + cmd.AddCommand(inspect.NewPackageInspectCommand()) + } + cobra.OnInitialize(func() { printBanner() loadExceptions() @@ -108,6 +113,15 @@ func printBanner() { ui.PrintBanner(banner) } +func checkIfPackageInspectCommandEnabled() bool { + bRet, err := strconv.ParseBool(os.Getenv("VET_ENABLE_PACKAGE_INSPECT_COMMAND")) + if err != nil { + return false + } + + return bRet +} + // Redirect to file or discard log if empty func redirectLogToFile(path string) { logger.Debugf("Redirecting logger output to: %s", path)