Skip to content
This repository has been archived by the owner on Jan 16, 2025. It is now read-only.

Commit

Permalink
feat: improve formatting with a summary of dependencies
Browse files Browse the repository at this point in the history
Closes: #20
  • Loading branch information
yrobla committed May 8, 2024
1 parent 9f64553 commit 0f47489
Show file tree
Hide file tree
Showing 2 changed files with 84 additions and 98 deletions.
167 changes: 74 additions & 93 deletions pkg/trustyapi/trustyapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -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("<a id='details-%s'></a>\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 {
Expand All @@ -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
Expand All @@ -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("| <a href='https://www.trustypkg.dev/%s/%s'><h3>%s</h3></a> | %.2f |", ecosystem, dep, dep, result.Summary.Score))
if result.Provenance.Description.Provenance.Issuer != "" {
reportBuilder.WriteString("<img src='https://cd.foundation/wp-content/uploads/sites/78/2023/05/sigstore_stacked-color-1024x698.png' alt='Sigstore' height='25'> |")
}
reportBuilder.WriteString("<br />\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("<details>\n")
reportBuilder.WriteString(fmt.Sprintf("<summary>📉 <b>Trusty Score: %.2f %s</b></summary>\n\n", result.Summary.Score, getScoreIcon(result.Summary.Score, globalThreshold)))
reportBuilder.WriteString(fmt.Sprintf("<summary><b>Trusty Score: %.2f %s</b></summary><br />\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)))
Expand All @@ -206,10 +188,10 @@ func ProcessDependency(dep string, ecosystem string, globalThreshold float64, re

// write provenance information
reportBuilder.WriteString("<details>\n")
reportBuilder.WriteString("<summary><strong>Proof of origin (Provenance)</strong></summary>\n\n") // Ensure two newlines after summary

if result.Provenance.Description.Provenance.Issuer != "" {
reportBuilder.WriteString("<summary><strong>Proof of origin (Provenance)</strong>&nbsp;&nbsp;\n")
reportBuilder.WriteString("<img src='https://cd.foundation/wp-content/uploads/sites/78/2023/05/sigstore_stacked-color-1024x698.png' alt='Sigstore' height='35'></summary>\n\n")
reportBuilder.WriteString("Built and signed with sigstore using GitHub Actions.\n")
reportBuilder.WriteString("Built and signed with sigstore using GitHub Actions.<br />\n\n")
reportBuilder.WriteString("| | |\n")
reportBuilder.WriteString("| --- | --- |\n")
reportBuilder.WriteString(fmt.Sprintf("| Source repo | %s |\n", result.Provenance.Description.Provenance.SourceRepo))
Expand All @@ -218,21 +200,20 @@ 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("<summary><strong>Proof of origin (Provenance)</strong>&nbsp;&nbsp;\n")
reportBuilder.WriteString("<img src='https://www.trustypkg.dev/stacklok-horizontal.svg' alt='Stacklok' height='15'></summary>\n\n")
reportBuilder.WriteString("<br />\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("\n[Learn more about source of origin provenance](https://docs.stacklok.com/trusty/understand/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("</details>\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("<details>\n")
reportBuilder.WriteString("<summary><strong>Alternative Packages</strong> 💡</summary>\n\n")
reportBuilder.WriteString("<summary><strong>Alternative Packages</strong> 💡</summary><br />\n\n")
reportBuilder.WriteString("| Package | Score | Trusty Link |\n")
reportBuilder.WriteString("| ------- | ----- | ---------- |\n")
for _, alt := range result.Alternatives.Packages {
Expand All @@ -256,7 +237,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.
Expand Down
15 changes: 10 additions & 5 deletions pkg/trustyapi/trustyapi_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package trustyapi

import (
"fmt"
"log"
"strings"
"testing"
Expand All @@ -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")
}
Expand All @@ -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)
Expand All @@ -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)
}
Expand All @@ -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)
}
Expand All @@ -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'")
}
Expand All @@ -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")
}
Expand Down

0 comments on commit 0f47489

Please sign in to comment.