vet/cmd/inspect/malware.go
Oleksandr Redko 4e39cebe61
chore: add formatters to golangci-lint config (#643)
Signed-off-by: Oleksandr Redko <oleksandr.red+github@gmail.com>
2025-11-27 14:58:24 +05:30

311 lines
9.8 KiB
Go

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 <owner>/<repo>", packageVersion.GetPackage().GetName())
}
owner := parts[0]
repo := parts[1]
return vetutils.ResolveGitHubRepositoryCommitSHA(ctx,
gha, owner, repo, packageVersion.GetVersion())
}