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" 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/jedib0t/go-pretty/v6/table" "github.com/safedep/dry/adapters" "github.com/safedep/dry/api/pb" "github.com/safedep/dry/utils" "github.com/spf13/cobra" "github.com/safedep/vet/internal/analytics" "github.com/safedep/vet/internal/auth" "github.com/safedep/vet/internal/ui" "github.com/safedep/vet/pkg/common/registry" vetutils "github.com/safedep/vet/pkg/common/utils" "github.com/safedep/vet/pkg/malysis" "github.com/safedep/vet/pkg/reporter" ) var ( malwareAnalysisPackageUrl string malwareAnalysisTimeout time.Duration malwareAnalysisReportJSON string malwareAnalysisReportOSV string malwareAnalysisNoWait bool malwareReportOSVFinderName string malwareReportOSVContacts []string malwareReportOSVReferenceURL string malwareReportOSVUseRange bool ) 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.Flags().StringVar(&malwareAnalysisReportOSV, "report-osv", "", "Dir path to save malware analysis report in OSV format and ossf/malicious-packages format") cmd.Flags().BoolVar(&malwareAnalysisNoWait, "no-wait", false, "Do not wait for malware analysis to complete") cmd.Flags().StringVar(&malwareReportOSVFinderName, "report-osv-finder-name", "", "Finder name for malware analysis report in OSV format") cmd.Flags().StringSliceVar(&malwareReportOSVContacts, "report-osv-contacts", []string{}, "Contacts for malware analysis report in OSV format (URL, email, etc.)") cmd.Flags().StringVar(&malwareReportOSVReferenceURL, "report-osv-reference-url", "", "Custom reference URL for malware analysis report (defaults to app.safedep.io)") cmd.Flags().BoolVar(&malwareReportOSVUseRange, "report-osv-with-ranges", false, "Use range-based versioning in OSV report (default: use explicit versions)") _ = cmd.MarkFlagRequired("purl") return cmd } func executeMalwareAnalysis() error { analytics.TrackCommandInspectMalwareAnalysis() err := auth.Verify() if err != nil { return fmt.Errorf("access to Malicious Package Analysis requires an API key. " + "For more details: https://docs.safedep.io/cloud/quickstart/") } 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 } githubClient, err := adapters.NewGithubClient(adapters.DefaultGitHubClientConfig()) if err != nil { return fmt.Errorf("failed to create GitHub client: %v", err) } versionResolver, err := registry.NewPackageVersionResolver(githubClient) if err != nil { return fmt.Errorf("failed to create package version resolver: %v", err) } packageVersion := purl.PackageVersion() // If package version is empty or latest replace it with actual literal latest version // Reference: https://github.com/safedep/vet/issues/446 if packageVersion.GetVersion() == "" || packageVersion.GetVersion() == "latest" { ui.PrintMsg("Resolving package version") version, err := versionResolver.ResolvePackageLatestVersion(purl.Ecosystem(), purl.Name()) if err != nil { return fmt.Errorf("failed to resolve package latest version: %v", err) } ui.PrintSuccess("Resolved package version: %s", version) packageVersion.Version = version } ctx := context.Background() ctx, cancelFun := context.WithTimeout(ctx, malwareAnalysisTimeout) defer cancelFun() // For GitHub Actions packages, we need to resolve the commit hash if packageVersion.GetPackage().GetEcosystem() == packagev1.Ecosystem_ECOSYSTEM_GITHUB_ACTIONS { ui.PrintMsg("Resolving commit hash for GitHub Actions package") commitHash, err := resolveGitHubActionsCommitHash(ctx, packageVersion) if err != nil { return fmt.Errorf("failed to resolve commit hash for GitHub Actions package: %v", err) } ui.PrintSuccess("Resolved commit hash for GitHub Actions package: %s", commitHash) packageVersion.Version = commitHash } analyzePackageResponse, err := service.AnalyzePackage(ctx, &malysisv1.AnalyzePackageRequest{ Target: &malysisv1pb.PackageAnalysisTarget{ PackageVersion: 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()) if malwareAnalysisNoWait { return nil } ui.StartSpinner("Waiting for malware analysis to complete") var report *malysisv1pb.Report var verificationRecord *malysisv1pb.VerificationRecord 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() verificationRecord = reportResponse.GetVerificationRecord() 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") if malwareAnalysisReportJSON != "" { ui.PrintMsg("Generating JSON report") err = writeJSONReport(report) if err != nil { ui.PrintError("Failed to render malware analysis report in JSON format: %v", err) } } if malwareAnalysisReportOSV != "" { if !report.GetInference().GetIsMalware() { ui.PrintWarning("Report is not malware, skipping OSV report generation") return nil } else { ui.PrintMsg("Generating OSV report in: %s", malwareAnalysisReportOSV) err = writeOSVReport(report) if err != nil { ui.PrintError("Failed to render malware analysis report in OSV format: %v", err) } } } return renderMalwareAnalysisReport(malwareAnalysisPackageUrl, analyzePackageResponse.GetAnalysisId(), report, verificationRecord) } func writeOSVReport(report *malysisv1pb.Report) error { err := os.MkdirAll(malwareAnalysisReportOSV, 0o755) if err != nil { return fmt.Errorf("failed to create directory: %v", err) } generator, err := malysis.NewOpenSSFMaliciousPackageReportGenerator(malysis.OpenSSFMaliciousPackageReportGeneratorConfig{ Dir: malwareAnalysisReportOSV, }) if err != nil { return fmt.Errorf("failed to create OpenSSF malicious package report generator: %v", err) } err = generator.GenerateReport(context.Background(), report, malysis.OpenSSFMaliciousPackageReportParams{ FinderName: malwareReportOSVFinderName, Contacts: malwareReportOSVContacts, ReferenceURL: malwareReportOSVReferenceURL, UseRange: malwareReportOSVUseRange, }) if err != nil { return fmt.Errorf("failed to generate OpenSSF malicious package report: %v", err) } return nil } func writeJSONReport(report *malysisv1pb.Report) error { data, err := utils.ToPbJson(report, " ") if err != nil { return err } return os.WriteFile(malwareAnalysisReportJSON, []byte(data), 0o644) } func renderMalwareAnalysisReport(purl string, analysisId string, report *malysisv1pb.Report, vr *malysisv1pb.VerificationRecord, ) 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 := reporter.InfoBgText(" SAFE ") if report.GetInference().GetIsMalware() { if vr != nil && vr.IsMalware { status = reporter.CriticalBgText(" MALICIOUS ") } else { status = reporter.WarningBgText(" SUSPICIOUS ") } } confidence := report.GetInference().GetConfidence().String() confidence = strings.TrimPrefix(confidence, "CONFIDENCE_") tbl.AppendRow(table.Row{purl, status, confidence}) tbl.Render() fmt.Println() fmt.Println(reporter.WarningText(fmt.Sprintf("** The full report is available at: %s", reportVisualizationUrl(analysisId)))) fmt.Println() return nil } func reportVisualizationUrl(analysisId string) string { return malysis.ReportURL(analysisId) } func resolveGitHubActionsCommitHash(ctx context.Context, packageVersion *packagev1.PackageVersion) (string, error) { gha, err := adapters.NewGithubClient(adapters.DefaultGitHubClientConfig()) if err != nil { return "", fmt.Errorf("failed to create GitHub client: %v", err) } parts := strings.Split(packageVersion.GetPackage().GetName(), "/") if len(parts) != 2 { return "", fmt.Errorf("invalid repository name: %s - should be in the format /", packageVersion.GetPackage().GetName()) } owner := parts[0] repo := parts[1] return vetutils.ResolveGitHubRepositoryCommitSHA(ctx, gha, owner, repo, packageVersion.GetVersion()) }