From 2ca932d0638935d88d976894909d490b1bb292d8 Mon Sep 17 00:00:00 2001 From: Yolanda Robla Date: Wed, 8 May 2024 16:19:24 +0200 Subject: [PATCH] feat: improve formatting with a summary of dependencies Closes: #20 --- pkg/trustyapi/trustyapi.go | 164 ++++++++++++++------------------ pkg/trustyapi/trustyapi_test.go | 15 ++- 2 files changed, 82 insertions(+), 97 deletions(-) diff --git a/pkg/trustyapi/trustyapi.go b/pkg/trustyapi/trustyapi.go index 09a6bc0..252c6d3 100644 --- a/pkg/trustyapi/trustyapi.go +++ b/pkg/trustyapi/trustyapi.go @@ -16,116 +16,78 @@ package trustyapi import ( - "context" "encoding/json" "fmt" "io" "log" - "os" "time" "net/http" "net/url" "strings" - - "github.com/google/go-github/v60/github" ) +type DependencyDetails struct { + Name string + Score float64 + IsMalicious bool + IsDeprecated bool + IsArchived bool +} + func GenerateReportContent(dependencies []string, ecosystem string, globalThreshold float64, repoActivityThreshold float64, authorActivityThreshold float64, provenanceThreshold float64, typosquattingThreshold float64, failOnMalicious bool, failOnDeprecated bool, failOnArchived bool) (string, bool) { + var ( - failedReportBuilder strings.Builder - successReportBuilder strings.Builder - failAction bool // Flag to track if the GitHub Action should fail + failedReports []string + successReports []string + failedDetails []string + successDetails []string + failAction bool ) - failedReportBuilder.WriteString("## 🔴 Failed Dependency Checks\n\n") - successReportBuilder.WriteString("## 🟢 Successful Dependency Checks\n\n") - - // The following loop generates the report for each dependency and then adds - // it to the existing reportBuilder, between the header and footer. + // Process each dependency and categorize them for _, dep := range dependencies { log.Printf("Analyzing dependency: %s\n", dep) - report, shouldFail := ProcessDependency(dep, ecosystem, globalThreshold, repoActivityThreshold, authorActivityThreshold, provenanceThreshold, typosquattingThreshold, + report, shouldFail, depDetails := ProcessDependency(dep, ecosystem, globalThreshold, repoActivityThreshold, authorActivityThreshold, provenanceThreshold, typosquattingThreshold, failOnMalicious, failOnDeprecated, failOnArchived) + depDetailsReport := fmt.Sprintf("\n%s\n\n", depDetails.Name, report) + if shouldFail { - if strings.TrimSpace(report) != "" { - failedReportBuilder.WriteString(report) - } failAction = true + failedReports = append(failedReports, fmt.Sprintf("| [%s](#details-%s) | %.2f | %v | %v | %v |\n", depDetails.Name, depDetails.Name, depDetails.Score, getBoolIcon(depDetails.IsMalicious, true), + getBoolIcon(depDetails.IsArchived, true), getBoolIcon(depDetails.IsDeprecated, true))) + failedDetails = append(failedDetails, depDetailsReport) } else { - if strings.TrimSpace(report) != "" { - successReportBuilder.WriteString(report) - } + successReports = append(successReports, fmt.Sprintf("| [%s](#details-%s) | %.2f |\n", depDetails.Name, depDetails.Name, depDetails.Score)) + successDetails = append(successDetails, depDetailsReport) } } - finalReportBuilder := strings.Builder{} + // Build the final report + var finalReportBuilder strings.Builder finalReportBuilder.WriteString("## 🐻 Trusty Dependency Analysis Action Report \n\n") - if failedReportBuilder.Len() > len("## 🔴 Failed Dependency Checks\n\n") { - finalReportBuilder.WriteString(failedReportBuilder.String()) - finalReportBuilder.WriteString("\n") + finalReportBuilder.WriteString("## 🔴 Failed Dependencies Summary\n\n| Name | Trusty Score | Malicious | Archived | Deprecated |\n| ---- | ------------ | --------- | -------- | ----------- |\n") + for _, report := range failedReports { + finalReportBuilder.WriteString(report) } - if successReportBuilder.Len() > len("## 🟢 Successful Dependency Checks\n\n") { - finalReportBuilder.WriteString(successReportBuilder.String()) + finalReportBuilder.WriteString("## 🟢 Successful Dependencies Summary\n\n| Name | Trusty Score |\n| ---- | ------------ |\n") + for _, report := range successReports { + finalReportBuilder.WriteString(report) } - - finalReportBuilder.WriteString("> 🌟 If you like this action, why not try out [Minder](https://github.com/stacklok/minder), the secure supply chain platform. It has vastly more protections and is also free (as in :beer:) to opensource projects.\n") - - // Build the comment body from the report builder - commentBody := finalReportBuilder.String() - - return commentBody, failAction - -} - -// BuildReport analyzes the dependencies of a PR and generates a report based on their Trusty scores. -// It takes the following parameters: -// - ctx: The context.Context for the function. -// - ghClient: A pointer to a github.Client for interacting with the GitHub API. -// - owner: The owner of the repository. -// - repo: The name of the repository. -// - prNumber: The number of the pull request. -// - dependencies: A slice of strings representing the dependencies to be analyzed. -// - ecosystem: The ecosystem of the dependencies (e.g., "npm", "pip", "maven"). -// - scoreThreshold: The threshold for Trusty scores below which a warning will be generated. -// -// The function generates a report and posts it as a comment on the pull request. -func BuildReport(ctx context.Context, - ghClient *github.Client, - owner, - repo string, - prNumber int, - dependencies []string, - ecosystem string, - globalThreshold float64, - repoActivityThreshold float64, - authorActivityThreshold float64, - provenanceThreshold float64, - typosquattingThreshold float64, - failOnMalicious bool, - failOnDeprecated bool, - failOnArchived bool) { - - reportContent, failAction := GenerateReportContent(dependencies, ecosystem, globalThreshold, repoActivityThreshold, authorActivityThreshold, provenanceThreshold, typosquattingThreshold, - failOnMalicious, failOnDeprecated, failOnArchived) - - if strings.TrimSpace(reportContent) != "## 🐻 Trusty Dependency Analysis Action Report \n\n" { - _, _, err := ghClient.Issues.CreateComment(ctx, owner, repo, prNumber, &github.IssueComment{Body: &reportContent}) - if err != nil { - log.Printf("error posting comment to PR: %v\n", err) - } else { - log.Printf("posted comment to PR: %s/%s#%d\n", owner, repo, prNumber) - } - } else { - log.Println("No report content to post, skipping comment.") + finalReportBuilder.WriteString("\n### Detailed Information for Failed Dependencies\n") + for _, detail := range failedDetails { + finalReportBuilder.WriteString(detail) } - - if failAction { - log.Println("Failing the GitHub Action due to dependencies not meeting the required criteria.") - os.Exit(1) + finalReportBuilder.WriteString("\n### Detailed Information for Successful Dependencies\n") + for _, detail := range successDetails { + finalReportBuilder.WriteString(detail) } + + finalReportBuilder.WriteString("\n> 🌟 If you like this action, why not try out [Minder](https://github.com/stacklok/minder), the secure supply chain platform. It has vastly more protections and is also free (as in :beer:) to opensource projects.\n") + + return finalReportBuilder.String(), failAction } func getScoreIcon(score float64, threshold float64) string { @@ -152,8 +114,9 @@ func getBoolIcon(b bool, fail bool) string { // whether it is malicious, deprecated or archived, and recommended alternative packages if available. // The function returns the formatted report as a string. func ProcessDependency(dep string, ecosystem string, globalThreshold float64, repoActivityThreshold float64, authorActivityThreshold float64, provenanceThreshold float64, typosquattingThreshold float64, - failOnMalicious bool, failOnDeprecated bool, failOnArchived bool) (string, bool) { + failOnMalicious bool, failOnDeprecated bool, failOnArchived bool) (string, bool, DependencyDetails) { var reportBuilder strings.Builder + var details DependencyDetails shouldFail := false // Construct the query URL for the API request @@ -178,24 +141,43 @@ func ProcessDependency(dep string, ecosystem string, globalThreshold float64, re log.Printf("Processing result for dependency: %s\n", dep) } + details = DependencyDetails{ + Name: dep, + Score: result.Summary.Score, + IsMalicious: result.PackageData.Origin == "malicious", + IsDeprecated: result.PackageData.IsDeprecated, + IsArchived: result.PackageData.Archived, + } // Format the report using Markdown - reportBuilder.WriteString(fmt.Sprintf("### :package: [%s](https://www.trustypkg.dev/%s/%s) - %.2f\n\n", dep, ecosystem, dep, result.Summary.Score)) + if result.Provenance.Description.Provenance.Issuer != "" { + reportBuilder.WriteString("| | | |\n") + reportBuilder.WriteString("| --- | --- | --- |\n") + } else { + reportBuilder.WriteString("| | |\n") + reportBuilder.WriteString("| --- | --- |\n") + + } + reportBuilder.WriteString(fmt.Sprintf("|

%s

| %.2f |", ecosystem, dep, dep, result.Summary.Score)) + if result.Provenance.Description.Provenance.Issuer != "" { + reportBuilder.WriteString("Sigstore |") + } + reportBuilder.WriteString("
\n") // Highlight if the package is malicious, deprecated or archived if result.PackageData.Origin == "malicious" { - reportBuilder.WriteString(fmt.Sprintf("⚠ **Malicious** (This package is marked as Malicious. Proceed with extreme caution!) %s\n", getBoolIcon(result.PackageData.Origin == "malicious", failOnMalicious))) + reportBuilder.WriteString(fmt.Sprintf("| ⚠ **Malicious** (This package is marked as Malicious. Proceed with extreme caution!) | %s |\n", getBoolIcon(result.PackageData.Origin == "malicious", failOnMalicious))) } if result.PackageData.IsDeprecated { - reportBuilder.WriteString(fmt.Sprintf("⚠ **Deprecated** (This package is marked as Deprecated. Proceed with caution!) %s\n", getBoolIcon(result.PackageData.IsDeprecated, failOnDeprecated))) + reportBuilder.WriteString(fmt.Sprintf("| ⚠ **Deprecated** (This package is marked as Deprecated. Proceed with caution!) | %s |\n", getBoolIcon(result.PackageData.IsDeprecated, failOnDeprecated))) } if result.PackageData.Archived { - reportBuilder.WriteString(fmt.Sprintf("⚠ **Archived** (This package is marked as Archived. Proceed with caution!) %s\n", getBoolIcon(result.PackageData.Archived, failOnArchived))) + reportBuilder.WriteString(fmt.Sprintf("| ⚠ **Archived** (This package is marked as Archived. Proceed with caution!) | %s |\n", getBoolIcon(result.PackageData.Archived, failOnArchived))) } // scores reportBuilder.WriteString("
\n") - reportBuilder.WriteString(fmt.Sprintf("📉 Trusty Score: %.2f %s\n\n", result.Summary.Score, getScoreIcon(result.Summary.Score, globalThreshold))) + reportBuilder.WriteString(fmt.Sprintf("Trusty Score: %.2f %s
\n\n", result.Summary.Score, getScoreIcon(result.Summary.Score, globalThreshold))) reportBuilder.WriteString("| Category | Score | Passed |\n") reportBuilder.WriteString("| --- | --- | --- |\n") reportBuilder.WriteString(fmt.Sprintf("| Repo activity | `%.2f` | %s |\n", result.Summary.Description.ActivityRepo, getScoreIcon(result.Summary.Description.ActivityRepo, repoActivityThreshold))) @@ -207,9 +189,8 @@ func ProcessDependency(dep string, ecosystem string, globalThreshold float64, re // write provenance information reportBuilder.WriteString("
\n") if result.Provenance.Description.Provenance.Issuer != "" { - reportBuilder.WriteString("Proof of origin (Provenance)  \n") - reportBuilder.WriteString("Sigstore\n\n") - reportBuilder.WriteString("Built and signed with sigstore using GitHub Actions.\n") + reportBuilder.WriteString("Proof of origin (Provenance)\n") + reportBuilder.WriteString("
Built and signed with sigstore using GitHub Actions.
\n\n") reportBuilder.WriteString("| | |\n") reportBuilder.WriteString("| --- | --- |\n") reportBuilder.WriteString(fmt.Sprintf("| Source repo | %s |\n", result.Provenance.Description.Provenance.SourceRepo)) @@ -218,8 +199,7 @@ func ProcessDependency(dep string, ecosystem string, globalThreshold float64, re reportBuilder.WriteString(fmt.Sprintf("| Rekor Public Ledger | %s |\n", result.Provenance.Description.Provenance.Transparency)) } else { // need to write regular provenance info - reportBuilder.WriteString("Proof of origin (Provenance)  \n") - reportBuilder.WriteString("Stacklok\n\n") + reportBuilder.WriteString("Proof of origin (Provenance)  
\n") reportBuilder.WriteString("| | |\n") reportBuilder.WriteString("| --- | --- |\n") reportBuilder.WriteString(fmt.Sprintf("| Number of versions | %.0f |\n", result.Provenance.Description.Hp.Versions)) @@ -232,7 +212,7 @@ func ProcessDependency(dep string, ecosystem string, globalThreshold float64, re // Include alternative packages in a Markdown table if available and if the package is deprecated, archived or malicious if result.Alternatives.Packages != nil && len(result.Alternatives.Packages) > 0 { reportBuilder.WriteString("
\n") - reportBuilder.WriteString("Alternative Packages 💡\n\n") + reportBuilder.WriteString("Alternative Packages 💡
\n\n") reportBuilder.WriteString("| Package | Score | Trusty Link |\n") reportBuilder.WriteString("| ------- | ----- | ---------- |\n") for _, alt := range result.Alternatives.Packages { @@ -256,7 +236,7 @@ func ProcessDependency(dep string, ecosystem string, globalThreshold float64, re shouldFail = true } - return reportBuilder.String(), shouldFail + return reportBuilder.String(), shouldFail, details } // fetchPackageData fetches package data from the specified request URL for a given dependency and ecosystem. diff --git a/pkg/trustyapi/trustyapi_test.go b/pkg/trustyapi/trustyapi_test.go index 359e5c1..26c1ae9 100644 --- a/pkg/trustyapi/trustyapi_test.go +++ b/pkg/trustyapi/trustyapi_test.go @@ -1,6 +1,7 @@ package trustyapi import ( + "fmt" "log" "strings" "testing" @@ -12,6 +13,7 @@ func TestReportBuilder(t *testing.T) { result, failAction := GenerateReportContent(dependencies, "npm", 5.0, 5.0, 5.0, 5.0, 5.0, true, true, true) // fmt.Println(result) // this is normally used to display and validate the report output, uncomment for debugging + fmt.Println(result) if result == "" { t.Errorf("Report is empty") } @@ -35,10 +37,13 @@ func TestProcessGoDependencies(t *testing.T) { for i, dep := range dependencies { log.Printf("Analyzing dependency: %s\n", dep) - report, shouldFail := ProcessDependency(dep, ecosystem, repoActivityThreshold, authorActivityThreshold, provenanceThreshold, typosquattingThreshold, scoreThreshold, true, true, true) + report, shouldFail, dependencyDetails := ProcessDependency(dep, ecosystem, repoActivityThreshold, authorActivityThreshold, provenanceThreshold, typosquattingThreshold, scoreThreshold, true, true, true) if shouldFail != expectedFail[i] { t.Errorf("Dependency %s failed check unexpectedly, expected %v, got %v", dep, expectedFail[i], shouldFail) } + if dependencyDetails.Name != dep { + t.Errorf("Dependency name mismatch, expected %s, got %s", dep, dependencyDetails.Name) + } if dep == "github.com/Tinkoff/libvirt-exporter" { if !strings.Contains(report, "Archived") { t.Errorf("Expected report to contain 'Archived' for %s", dep) @@ -57,7 +62,7 @@ func TestProcessDeprecatedDependencies(t *testing.T) { for _, dep := range dependencies { log.Printf("Analyzing dependency: %s\n", dep) - report, _ := ProcessDependency(dep, ecosystem, scoreThreshold, 0.0, 0.0, 0.0, 0.0, true, true, true) + report, _, _ := ProcessDependency(dep, ecosystem, scoreThreshold, 0.0, 0.0, 0.0, 0.0, true, true, true) if !strings.Contains(report, "Deprecated") { t.Errorf("Expected report to contain 'Deprecated' for %s", dep) } @@ -73,7 +78,7 @@ func TestProcessMaliciousDependencies(t *testing.T) { for _, dep := range dependencies { log.Printf("Analyzing dependency: %s\n", dep) - report, _ := ProcessDependency(dep, ecosystem, scoreThreshold, 0.0, 0.0, 0.0, 0.0, true, true, true) + report, _, _ := ProcessDependency(dep, ecosystem, scoreThreshold, 0.0, 0.0, 0.0, 0.0, true, true, true) if !strings.Contains(report, "Malicious") { t.Errorf("Expected report to contain 'Malicious' for %s", dep) } @@ -85,7 +90,7 @@ func TestProcessSigstoreProvenance(t *testing.T) { ecosystem := "npm" scoreThreshold := 5.0 - report, _ := ProcessDependency("sigstore", ecosystem, scoreThreshold, 0.0, 0.0, 0.0, 0.0, true, true, true) + report, _, _ := ProcessDependency("sigstore", ecosystem, scoreThreshold, 0.0, 0.0, 0.0, 0.0, true, true, true) if !strings.Contains(report, "sigstore") { t.Errorf("Expected report to contain 'sigstore'") } @@ -104,7 +109,7 @@ func TestProcessHistoricalProvenance(t *testing.T) { ecosystem := "npm" scoreThreshold := 5.0 - report, _ := ProcessDependency("openpgp", ecosystem, scoreThreshold, 0.0, 0.0, 0.0, 0.0, true, true, true) + report, _, _ := ProcessDependency("openpgp", ecosystem, scoreThreshold, 0.0, 0.0, 0.0, 0.0, true, true, true) if !strings.Contains(report, "Number of versions") { t.Errorf("Versions for historical provenance not populated") }