From b1f4fcecfd79f017b58a9727c356a52e85ce8770 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 | 276 ++++++++++++++++---------------- pkg/trustyapi/trustyapi_test.go | 13 +- 2 files changed, 150 insertions(+), 139 deletions(-) diff --git a/pkg/trustyapi/trustyapi.go b/pkg/trustyapi/trustyapi.go index 73d4776..672d5e2 100644 --- a/pkg/trustyapi/trustyapi.go +++ b/pkg/trustyapi/trustyapi.go @@ -31,129 +31,83 @@ import ( "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 - 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") - - // 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 { + successReports = append(successReports, fmt.Sprintf("| [%s](#details-%s) | %.2f |\n", depDetails.Name, depDetails.Name, depDetails.Score)) + successDetails = append(successDetails, depDetailsReport) } } - finalReportBuilder := strings.Builder{} - finalReportBuilder.WriteString("## Trusty Dependency Analysis Action \n\n") - finalReportBuilder.WriteString("> 🚀 Trusty Dependency Analysis Action has completed an analysis of the dependencies in this PR.\n\n") - if failedReportBuilder.Len() > len("### ❌ Failed Dependency Checks\n\n") { - finalReportBuilder.WriteString(failedReportBuilder.String()) - finalReportBuilder.WriteString("\n") + // Build the final report + var finalReportBuilder strings.Builder + finalReportBuilder.WriteString("## 🐻 Trusty Dependency Analysis Action Report \n\n") + finalReportBuilder.WriteString("## 🔴 Failed Dependencies Summary\n\n| Name | Trusty Score | Malicious | Archived | Deprecated |\n| ---- | ------------ | --------- | -------- | ----------- |\n") + for _, report := range failedReports { + 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 \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("## 🟢 Successful Dependencies Summary\n\n| Name | Trusty Score |\n| ---- | ------------ |\n") + for _, report := range successReports { + finalReportBuilder.WriteString(report) } - - 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 Failed Dependencies\n") + for _, detail := range failedDetails { + finalReportBuilder.WriteString(detail) } -} + 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") -// repoActivityThreshold represents the threshold value for repository activity. -// It is used to determine if a subfield has failed based on the repository's activity level. -// The value should be a float64 between 0 and 1, where 0 represents no activity and 1 represents maximum activity. -// Higher values indicate a higher threshold for considering a subfield as failed. -// Lower values indicate a lower threshold for considering a subfield as failed. -// The default value is 0.5. -// Example usage: hasAnySubfieldFailed(result, 0.5, authorActivityThreshold, provenanceThreshold, typosquattingThreshold) -// Returns true if any subfield has failed based on the repository's activity level, false otherwise. -// ... -func hasAnySubfieldFailed(result Package, repoActivityThreshold, authorActivityThreshold, provenanceThreshold, typosquattingThreshold float64) bool { - return result.Summary.Description.ActivityRepo < repoActivityThreshold || - result.Summary.Description.ActivityUser < authorActivityThreshold || - result.Summary.Description.Provenance < provenanceThreshold || - result.Summary.Description.Typosquatting < typosquattingThreshold + return finalReportBuilder.String(), failAction } -// getScoreIcon returns an icon based on the score and threshold. -// If the score is greater than or equal to the threshold, it returns "✅", -// otherwise it returns "❌". func getScoreIcon(score float64, threshold float64) string { - if score >= threshold { - return "✅" + scoreIcon := ":white_check_mark:" + if score < threshold { + scoreIcon = ":x:" } - return "❌" + return scoreIcon } -// getBoolIcon returns an icon string based on the boolean value and fail flag. -// If the boolean value is true and the fail flag is true, it returns "❌". -// Otherwise, it returns "✅". func getBoolIcon(b bool, fail bool) string { + icon := ":white_check_mark:" if b && fail { - return "❌" + icon = ":x:" } - return "✅" + return icon } // processDependency analyzes a dependency by making an API request to TrustyPkg.dev and returns a formatted report. @@ -164,8 +118,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 @@ -190,31 +145,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))) - } - - // Check if any subfields have failed - subfieldFailed := hasAnySubfieldFailed(result, repoActivityThreshold, authorActivityThreshold, provenanceThreshold, typosquattingThreshold) - summaryIcon := "✅" - if subfieldFailed || result.Summary.Score < globalThreshold { - summaryIcon = "❌" + 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, summaryIcon)) + 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))) @@ -225,39 +192,32 @@ func ProcessDependency(dep string, ecosystem string, globalThreshold float64, re // write provenance information reportBuilder.WriteString("
\n") + reportBuilder.WriteString("Proof of origin (Provenance)\n\n") // Ensure two newlines after summary + if result.Provenance.Description.Provenance.Issuer != "" { - reportBuilder.WriteString("Proof of origin (Sigstore)   \n\n\n") - reportBuilder.WriteString("

Built and signed with sigstore using GitHub Actions.

\n") - reportBuilder.WriteString("\n") - reportBuilder.WriteString("\n") - reportBuilder.WriteString(fmt.Sprintf("\n", result.Provenance.Description.Provenance.SourceRepo, result.Provenance.Description.Provenance.SourceRepo)) - reportBuilder.WriteString(fmt.Sprintf("\n", result.Provenance.Description.Provenance.Workflow)) - reportBuilder.WriteString(fmt.Sprintf("\n", result.Provenance.Description.Provenance.Issuer)) - reportBuilder.WriteString(fmt.Sprintf("\n", result.Provenance.Description.Provenance.Transparency, result.Provenance.Description.Provenance.Transparency)) + 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)) + reportBuilder.WriteString(fmt.Sprintf("| Github Action Workflow | %s |\n", result.Provenance.Description.Provenance.Workflow)) + reportBuilder.WriteString(fmt.Sprintf("| Issuer | %s |\n", result.Provenance.Description.Provenance.Issuer)) + reportBuilder.WriteString(fmt.Sprintf("| Rekor Public Ledger | %s |\n", result.Provenance.Description.Provenance.Transparency)) } else { // need to write regular provenance info - if result.Provenance.Description.Hp.Common > 2 { - reportBuilder.WriteString("Proof of origin (Git Tags)   \n\n\n") - reportBuilder.WriteString("

This package can be mapped to the source code repository, based on the density of Git tags/releases.

\n") - } else { - reportBuilder.WriteString("Proof of origin (Git Tags)   (failed)\n\n") - reportBuilder.WriteString("

This package could not be mapped to the source code repository based on the density of Git tags/releases.

\n") - } - - reportBuilder.WriteString("
AttributeDetails
Source repo%s
Github Action Workflow%s
Issuer%s
Rekor Public Ledger%s
\n") - reportBuilder.WriteString("\n") - reportBuilder.WriteString(fmt.Sprintf("\n", result.Provenance.Description.Hp.Versions)) - reportBuilder.WriteString(fmt.Sprintf("\n", result.Provenance.Description.Hp.Tags)) - reportBuilder.WriteString(fmt.Sprintf("\n", result.Provenance.Description.Hp.Common)) + reportBuilder.WriteString("
\n\n") + reportBuilder.WriteString("| | |\n") + reportBuilder.WriteString("| --- | --- |\n") + reportBuilder.WriteString(fmt.Sprintf("| Number of versions | %.0f |\n", result.Provenance.Description.Hp.Versions)) + reportBuilder.WriteString(fmt.Sprintf("| Number of Git Tags/Releases | %.0f |\n", result.Provenance.Description.Hp.Tags)) + reportBuilder.WriteString(fmt.Sprintf("| Number of versions matched to Git Tags/Releases | %.0f |\n", result.Provenance.Description.Hp.Common)) } - reportBuilder.WriteString("
AttributeCount
Number of versions%.0f
Number of Git Tags/Releases%.0f
Number of versions matched to Git Tags/Releases%.0f
\n") - reportBuilder.WriteString("\n

Learn more about source of origin provenance

\n") + reportBuilder.WriteString("\n[Learn more about source of origin provenance](https://docs.stacklok.com/trusty/understand/provenance)\n") // Ensure newlines around this link reportBuilder.WriteString("
\n") // 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 Package Recommendations 💡\n\n") + reportBuilder.WriteString("Alternative Packages 💡
\n\n") reportBuilder.WriteString("| Package | Score | Trusty Link |\n") reportBuilder.WriteString("| ------- | ----- | ---------- |\n") for _, alt := range result.Alternatives.Packages { @@ -281,7 +241,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. @@ -343,3 +303,51 @@ func fetchPackageData(requestURL, dep, ecosystem string, resultChan chan<- Packa } }() } + +// 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.") + } + + if failAction { + log.Println("Failing the GitHub Action due to dependencies not meeting the required criteria.") + os.Exit(1) + } +} diff --git a/pkg/trustyapi/trustyapi_test.go b/pkg/trustyapi/trustyapi_test.go index 359e5c1..a576964 100644 --- a/pkg/trustyapi/trustyapi_test.go +++ b/pkg/trustyapi/trustyapi_test.go @@ -35,10 +35,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 +60,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 +76,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 +88,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 +107,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") }