diff --git a/artifactory/utils/commandsummary/evidence_url.go b/artifactory/utils/commandsummary/evidence_url.go
new file mode 100644
index 000000000..50ca88bca
--- /dev/null
+++ b/artifactory/utils/commandsummary/evidence_url.go
@@ -0,0 +1,63 @@
+package commandsummary
+
+import (
+ "fmt"
+ "net/url"
+)
+
+const (
+ releaseBundleEvidenceFormat = "%sui/artifactory/lifecycle?range=Any+Time&bundleName=%s&repositoryKey=%s&releaseBundleVersion=%s&activeVersionTab=Evidence+Graph"
+ buildEvidenceFormat = "%sui/builds/%s/%s/%s/Evidence/%s?buildRepo=%s"
+ artifactEvidenceFormat = "%sui/repos/tree/Evidence/%s?clearFilter=true"
+)
+
+func GenerateEvidenceUrlByType(data EvidenceSummaryData, section summarySection) (string, error) {
+ switch data.SubjectType {
+ // Currently, it is not possible to generate a link to the evidence tab for packages in the Artifactory UI.
+ // The link will point to the lead artifact of the package instead.
+ // This logic will be updated once UI support is available
+ case SubjectTypePackage, SubjectTypeArtifact:
+ return generateArtifactEvidenceUrl(data.Subject, section)
+ case SubjectTypeReleaseBundle:
+ return generateReleaseBundleEvidenceUrl(data, section)
+ case SubjectTypeBuild:
+ return generateBuildEvidenceUrl(data, section)
+ default:
+ return generateArtifactEvidenceUrl(data.Subject, section)
+ }
+}
+
+func generateArtifactEvidenceUrl(pathInRt string, section summarySection) (string, error) {
+ urlStr := fmt.Sprintf(artifactEvidenceFormat, StaticMarkdownConfig.GetPlatformUrl(), pathInRt)
+ return addGitHubTrackingToUrl(urlStr, section)
+}
+
+func generateReleaseBundleEvidenceUrl(data EvidenceSummaryData, section summarySection) (string, error) {
+ if data.ReleaseBundleName == "" || data.ReleaseBundleVersion == "" {
+ return generateArtifactEvidenceUrl(data.Subject, section)
+ }
+
+ urlStr := fmt.Sprintf(releaseBundleEvidenceFormat,
+ StaticMarkdownConfig.GetPlatformUrl(),
+ data.ReleaseBundleName,
+ data.RepoKey,
+ data.ReleaseBundleVersion)
+
+ return addGitHubTrackingToUrl(urlStr, section)
+}
+
+func generateBuildEvidenceUrl(data EvidenceSummaryData, section summarySection) (string, error) {
+ if data.BuildName == "" || data.BuildNumber == "" || data.BuildTimestamp == "" {
+ return generateArtifactEvidenceUrl(data.Subject, section)
+ }
+
+ urlStr := fmt.Sprintf(buildEvidenceFormat,
+ StaticMarkdownConfig.GetPlatformUrl(),
+ url.QueryEscape(data.BuildName),
+ data.BuildNumber,
+ data.BuildTimestamp,
+ url.QueryEscape(data.BuildName),
+ data.RepoKey)
+
+ return addGitHubTrackingToUrl(urlStr, section)
+}
diff --git a/artifactory/utils/commandsummary/evidence_url_test.go b/artifactory/utils/commandsummary/evidence_url_test.go
new file mode 100644
index 000000000..352096985
--- /dev/null
+++ b/artifactory/utils/commandsummary/evidence_url_test.go
@@ -0,0 +1,141 @@
+package commandsummary
+
+import (
+ "os"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestGenerateEvidenceUrlByType(t *testing.T) {
+ // Set up test environment
+ originalWorkflow := os.Getenv(workflowEnvKey)
+ err := os.Setenv(workflowEnvKey, "JFrog CLI Core Tests")
+ if err != nil {
+ assert.FailNow(t, "Failed to set environment variable", err)
+ }
+ defer func() {
+ if originalWorkflow != "" {
+ err = os.Setenv(workflowEnvKey, originalWorkflow)
+ if err != nil {
+ assert.Fail(t, "Failed to restore workflow environment variable", err)
+ return
+ }
+ } else {
+ os.Unsetenv(workflowEnvKey)
+ }
+ }()
+
+ // Configure static markdown config for tests
+ StaticMarkdownConfig.setPlatformUrl("https://myplatform.com/")
+ StaticMarkdownConfig.setPlatformMajorVersion(7)
+
+ tests := []struct {
+ name string
+ data EvidenceSummaryData
+ expectedURL string
+ expectError bool
+ }{
+ {
+ name: "Package evidence URL",
+ data: EvidenceSummaryData{
+ Subject: "repo/path/package.jar",
+ SubjectType: SubjectTypePackage,
+ },
+ expectedURL: "https://myplatform.com/ui/repos/tree/Evidence/repo/path/package.jar?clearFilter=true&gh_job_id=JFrog+CLI+Core+Tests&gh_section=evidence&m=3&s=1",
+ },
+ {
+ name: "Artifact evidence URL",
+ data: EvidenceSummaryData{
+ Subject: "repo/path/artifact.txt",
+ SubjectType: SubjectTypeArtifact,
+ },
+ expectedURL: "https://myplatform.com/ui/repos/tree/Evidence/repo/path/artifact.txt?clearFilter=true&gh_job_id=JFrog+CLI+Core+Tests&gh_section=evidence&m=3&s=1",
+ },
+ {
+ name: "Release bundle evidence URL",
+ data: EvidenceSummaryData{
+ Subject: "release-bundles-v2/my-bundle/1.0.0/release-bundle.json.evd",
+ SubjectType: SubjectTypeReleaseBundle,
+ ReleaseBundleName: "my-bundle",
+ ReleaseBundleVersion: "1.0.0",
+ RepoKey: "release-bundles-v2",
+ },
+ expectedURL: "", // Will be checked with custom assertion
+ },
+ {
+ name: "Build evidence URL",
+ data: EvidenceSummaryData{
+ Subject: "artifactory-build-info/my-build/123/1234567890.json",
+ SubjectType: SubjectTypeBuild,
+ BuildName: "my-build",
+ BuildNumber: "123",
+ BuildTimestamp: "1234567890",
+ RepoKey: "artifactory-build-info",
+ },
+ expectedURL: "https://myplatform.com/ui/builds/my-build/123/1234567890/Evidence/my-build?buildRepo=artifactory-build-info&gh_job_id=JFrog+CLI+Core+Tests&gh_section=evidence&m=3&s=1",
+ },
+ {
+ name: "Build with special characters in name",
+ data: EvidenceSummaryData{
+ Subject: "artifactory-build-info/my build with spaces/123/1234567890.json",
+ SubjectType: SubjectTypeBuild,
+ BuildName: "my build with spaces",
+ BuildNumber: "123",
+ BuildTimestamp: "1234567890",
+ RepoKey: "artifactory-build-info",
+ },
+ expectedURL: "https://myplatform.com/ui/builds/my+build+with+spaces/123/1234567890/Evidence/my+build+with+spaces?buildRepo=artifactory-build-info&gh_job_id=JFrog+CLI+Core+Tests&gh_section=evidence&m=3&s=1",
+ },
+ {
+ name: "Invalid release bundle falls back to artifact URL",
+ data: EvidenceSummaryData{
+ Subject: "invalid/path",
+ SubjectType: SubjectTypeReleaseBundle,
+ },
+ expectedURL: "https://myplatform.com/ui/repos/tree/Evidence/invalid/path?clearFilter=true&gh_job_id=JFrog+CLI+Core+Tests&gh_section=evidence&m=3&s=1",
+ },
+ {
+ name: "Invalid build falls back to artifact URL",
+ data: EvidenceSummaryData{
+ Subject: "invalid/build/path",
+ SubjectType: SubjectTypeBuild,
+ },
+ expectedURL: "https://myplatform.com/ui/repos/tree/Evidence/invalid/build/path?clearFilter=true&gh_job_id=JFrog+CLI+Core+Tests&gh_section=evidence&m=3&s=1",
+ },
+ {
+ name: "Default type uses artifact URL",
+ data: EvidenceSummaryData{
+ Subject: "some/path/file.txt",
+ SubjectType: "", // Empty type should default to artifact
+ },
+ expectedURL: "https://myplatform.com/ui/repos/tree/Evidence/some/path/file.txt?clearFilter=true&gh_job_id=JFrog+CLI+Core+Tests&gh_section=evidence&m=3&s=1",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ url, err := GenerateEvidenceUrlByType(tt.data, evidenceSection)
+ if tt.expectError {
+ assert.Error(t, err)
+ } else {
+ assert.NoError(t, err)
+ if tt.expectedURL != "" {
+ assert.Equal(t, tt.expectedURL, url)
+ } else if tt.name == "Release bundle evidence URL" {
+ // Special handling for release bundle URL due to parameter ordering
+ assert.Contains(t, url, "https://myplatform.com/ui/artifactory/lifecycle?")
+ assert.Contains(t, url, "bundleName=my-bundle")
+ assert.Contains(t, url, "repositoryKey=release-bundles-v2")
+ assert.Contains(t, url, "releaseBundleVersion=1.0.0")
+ assert.Contains(t, url, "activeVersionTab=Evidence+Graph")
+ assert.Contains(t, url, "gh_job_id=JFrog+CLI+Core+Tests")
+ assert.Contains(t, url, "gh_section=evidence")
+ assert.Contains(t, url, "m=3")
+ assert.Contains(t, url, "s=1")
+ assert.Contains(t, url, "range=Any+Time")
+ }
+ }
+ })
+ }
+}
diff --git a/artifactory/utils/commandsummary/evidencesummary.go b/artifactory/utils/commandsummary/evidencesummary.go
new file mode 100644
index 000000000..5e87d5cf7
--- /dev/null
+++ b/artifactory/utils/commandsummary/evidencesummary.go
@@ -0,0 +1,151 @@
+package commandsummary
+
+import (
+ "fmt"
+ "github.com/jfrog/jfrog-client-go/utils/log"
+ "strings"
+ "time"
+)
+
+const evidenceHeaderSize = 3
+
+type EvidenceSummaryData struct {
+ Subject string `json:"subject"`
+ SubjectSha256 string `json:"subjectSha256"`
+ PredicateType string `json:"predicateType"`
+ PredicateSlug string `json:"predicateSlug"`
+ Verified bool `json:"verified"`
+ DisplayName string `json:"displayName,omitempty"`
+ SubjectType SubjectType `json:"subjectType"`
+ BuildName string `json:"buildName"`
+ BuildNumber string `json:"buildNumber"`
+ BuildTimestamp string `json:"buildTimestamp"`
+ ReleaseBundleName string `json:"releaseBundleName"`
+ ReleaseBundleVersion string `json:"releaseBundleVersion"`
+ RepoKey string `json:"repoKey"`
+ CreatedAt time.Time `json:"createdAt"`
+}
+
+type SubjectType string
+
+const (
+ SubjectTypeArtifact SubjectType = "artifact"
+ SubjectTypeBuild SubjectType = "build"
+ SubjectTypePackage SubjectType = "package"
+ SubjectTypeReleaseBundle SubjectType = "release-bundle"
+)
+
+type EvidenceSummary struct {
+ CommandSummary
+}
+
+func NewEvidenceSummary() (*CommandSummary, error) {
+ return New(&EvidenceSummary{}, "evidence")
+}
+
+func (es *EvidenceSummary) GetSummaryTitle() string {
+ return "🔎 Evidence"
+}
+
+func (es *EvidenceSummary) GenerateMarkdownFromFiles(dataFilePaths []string) (finalMarkdown string, err error) {
+ log.Debug("Generating evidence summary markdown.")
+ var evidenceData []EvidenceSummaryData
+ for _, filePath := range dataFilePaths {
+ var evidence EvidenceSummaryData
+ if err = UnmarshalFromFilePath(filePath, &evidence); err != nil {
+ log.Warn("Failed to unmarshal evidence data from file %s: %v", filePath, err)
+ return
+ }
+ evidenceData = append(evidenceData, evidence)
+ }
+
+ if len(evidenceData) == 0 {
+ return
+ }
+
+ tableMarkdown := es.generateEvidenceTable(evidenceData)
+ return WrapCollapsableMarkdown(es.GetSummaryTitle(), tableMarkdown, evidenceHeaderSize), nil
+}
+
+func (es *EvidenceSummary) generateEvidenceTable(evidenceData []EvidenceSummaryData) string {
+ var tableBuilder strings.Builder
+ tableBuilder.WriteString(es.getTableHeader())
+
+ for _, evidence := range evidenceData {
+ es.appendEvidenceRow(&tableBuilder, evidence)
+ }
+
+ tableBuilder.WriteString(" \n")
+ return tableBuilder.String()
+}
+
+func (es *EvidenceSummary) getTableHeader() string {
+ return "
| Evidence Subject | Evidence Type | Verification Status |
\n"
+}
+
+func (es *EvidenceSummary) appendEvidenceRow(tableBuilder *strings.Builder, evidence EvidenceSummaryData) {
+ subject := es.formatSubjectWithLink(evidence)
+ evidenceType := es.formatEvidenceType(evidence)
+ verificationStatus := es.formatVerificationStatus(evidence.Verified)
+
+ tableBuilder.WriteString(fmt.Sprintf("| %s | %s | %s |
\n", subject, evidenceType, verificationStatus))
+}
+
+func (es *EvidenceSummary) formatSubjectWithLink(evidence EvidenceSummaryData) string {
+ if evidence.Subject == "" {
+ return "evidence"
+ }
+
+ evidenceUrl, err := GenerateEvidenceUrlByType(evidence, evidenceSection)
+ if err != nil {
+ log.Warn("Failed to generate evidence URL: %v", err)
+ evidenceUrl = ""
+ }
+
+ displayName := evidence.DisplayName
+ if displayName == "" {
+ displayName = evidence.Subject
+ }
+
+ var viewLink string
+ subjectType := es.formatSubjectType(evidence.SubjectType)
+ if evidenceUrl != "" {
+ viewLink = fmt.Sprintf(`%s %s`, subjectType, evidenceUrl, displayName)
+ } else {
+ viewLink = fmt.Sprintf("%s %s", subjectType, displayName)
+ }
+
+ return viewLink
+}
+
+func (es *EvidenceSummary) formatVerificationStatus(verified bool) string {
+ if verified {
+ return fmt.Sprintf("%s Verified", "✅")
+ }
+ return fmt.Sprintf("%s Not Verified", "❌")
+}
+
+func (es *EvidenceSummary) formatEvidenceType(evidence EvidenceSummaryData) string {
+ if evidence.PredicateSlug == "" {
+ if evidence.PredicateType == "" {
+ return "⚠️ Unknown"
+ }
+ return evidence.PredicateType
+ }
+ return evidence.PredicateSlug
+}
+
+func (es *EvidenceSummary) formatSubjectType(subjectType SubjectType) string {
+ switch subjectType {
+ case SubjectTypePackage:
+ return "📦️"
+ case SubjectTypeBuild:
+ return "🛠️️"
+ case SubjectTypeReleaseBundle:
+ return "🧩"
+ case SubjectTypeArtifact:
+ return "📄"
+ default:
+ return ""
+ }
+}
diff --git a/artifactory/utils/commandsummary/evidencesummary_test.go b/artifactory/utils/commandsummary/evidencesummary_test.go
new file mode 100644
index 000000000..e69356b8f
--- /dev/null
+++ b/artifactory/utils/commandsummary/evidencesummary_test.go
@@ -0,0 +1,191 @@
+package commandsummary
+
+import (
+ "os"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+)
+
+const (
+ evidenceTable = "evidence.md"
+)
+
+func prepareEvidenceTest(t *testing.T) (*EvidenceSummary, func()) {
+ originalWorkflow := os.Getenv(workflowEnvKey)
+ err := os.Setenv(workflowEnvKey, "JFrog CLI Core Tests")
+ if err != nil {
+ assert.Fail(t, "Failed to set environment variable", err)
+ }
+
+ StaticMarkdownConfig.setPlatformUrl(testPlatformUrl)
+ StaticMarkdownConfig.setPlatformMajorVersion(7)
+ StaticMarkdownConfig.setExtendedSummary(false)
+
+ cleanup := func() {
+ StaticMarkdownConfig.setExtendedSummary(false)
+ StaticMarkdownConfig.setPlatformMajorVersion(0)
+ StaticMarkdownConfig.setPlatformUrl("")
+
+ if originalWorkflow != "" {
+ err = os.Setenv(workflowEnvKey, originalWorkflow)
+ if err != nil {
+ assert.Fail(t, "Failed to set workflow environment variable", err)
+ }
+ } else {
+ os.Unsetenv(workflowEnvKey)
+ }
+ }
+
+ evidenceSummary := &EvidenceSummary{}
+ return evidenceSummary, cleanup
+}
+
+func TestEvidenceTable(t *testing.T) {
+ evidenceSummary, cleanUp := prepareEvidenceTest(t)
+ defer func() {
+ cleanUp()
+ }()
+
+ createdTime := time.Date(2024, 12, 1, 10, 0, 0, 0, time.UTC)
+
+ var evidenceData = []EvidenceSummaryData{
+ {
+ Subject: "cli-sigstore-test/commons-1.0.0.txt",
+ SubjectSha256: "",
+ PredicateType: "in-toto",
+ PredicateSlug: "in-toto",
+ Verified: true,
+ DisplayName: "cli-sigstore-test/commons-1.0.0.txt",
+ SubjectType: SubjectTypeArtifact,
+ RepoKey: "cli-sigstore-test/commons-1.0.0.txt",
+ CreatedAt: createdTime,
+ },
+ }
+
+ t.Run("Extended Summary", func(t *testing.T) {
+ StaticMarkdownConfig.setExtendedSummary(true)
+ res := evidenceSummary.generateEvidenceTable(evidenceData)
+ testMarkdownOutput(t, getTestDataFile(t, evidenceTable), res)
+ })
+
+ t.Run("Basic Summary", func(t *testing.T) {
+ StaticMarkdownConfig.setExtendedSummary(false)
+ res := evidenceSummary.generateEvidenceTable(evidenceData)
+ testMarkdownOutput(t, getTestDataFile(t, evidenceTable), res)
+ })
+}
+
+func TestFormatSubjectType(t *testing.T) {
+ evidenceSummary := &EvidenceSummary{}
+
+ tests := []struct {
+ name string
+ subjectType SubjectType
+ expectedIcon string
+ }{
+ {
+ name: "Artifact",
+ subjectType: SubjectTypeArtifact,
+ expectedIcon: "📄",
+ },
+ {
+ name: "Package",
+ subjectType: SubjectTypePackage,
+ expectedIcon: "📦️",
+ },
+ {
+ name: "Build",
+ subjectType: SubjectTypeBuild,
+ expectedIcon: "🛠️️",
+ },
+ {
+ name: "Release Bundle",
+ subjectType: SubjectTypeReleaseBundle,
+ expectedIcon: "🧩",
+ },
+ {
+ name: "Unknown",
+ subjectType: SubjectType("unknown"),
+ expectedIcon: "",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := evidenceSummary.formatSubjectType(tt.subjectType)
+ assert.Equal(t, tt.expectedIcon, result)
+ })
+ }
+}
+
+func TestFormatVerificationStatus(t *testing.T) {
+ evidenceSummary := &EvidenceSummary{}
+
+ tests := []struct {
+ name string
+ verified bool
+ expected string
+ }{
+ {
+ name: "Verified",
+ verified: true,
+ expected: "✅ Verified",
+ },
+ {
+ name: "Not Verified",
+ verified: false,
+ expected: "❌ Not Verified",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := evidenceSummary.formatVerificationStatus(tt.verified)
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+func TestFormatEvidenceType(t *testing.T) {
+ evidenceSummary := &EvidenceSummary{}
+
+ tests := []struct {
+ name string
+ evidence EvidenceSummaryData
+ expectedType string
+ }{
+ {
+ name: "With PredicateSlug",
+ evidence: EvidenceSummaryData{
+ PredicateSlug: "in-toto",
+ PredicateType: "in-toto",
+ },
+ expectedType: "in-toto",
+ },
+ {
+ name: "Without PredicateSlug but with PredicateType",
+ evidence: EvidenceSummaryData{
+ PredicateSlug: "",
+ PredicateType: "https://slsa.dev/provenance/v0.2",
+ },
+ expectedType: "https://slsa.dev/provenance/v0.2",
+ },
+ {
+ name: "Without PredicateSlug and PredicateType",
+ evidence: EvidenceSummaryData{
+ PredicateSlug: "",
+ PredicateType: "",
+ },
+ expectedType: "⚠️ Unknown",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := evidenceSummary.formatEvidenceType(tt.evidence)
+ assert.Equal(t, tt.expectedType, result)
+ })
+ }
+}
diff --git a/artifactory/utils/commandsummary/utils.go b/artifactory/utils/commandsummary/utils.go
index 75b9541e9..a83457c0f 100644
--- a/artifactory/utils/commandsummary/utils.go
+++ b/artifactory/utils/commandsummary/utils.go
@@ -47,6 +47,7 @@ const (
artifactsSection summarySection = "artifacts"
packagesSection summarySection = "packages"
buildInfoSection summarySection = "buildInfo"
+ evidenceSection summarySection = "evidence"
)
const (
diff --git a/artifactory/utils/testdata/command_summaries/basic/evidence.md b/artifactory/utils/testdata/command_summaries/basic/evidence.md
new file mode 100644
index 000000000..b12858ec1
--- /dev/null
+++ b/artifactory/utils/testdata/command_summaries/basic/evidence.md
@@ -0,0 +1,3 @@
+
diff --git a/artifactory/utils/testdata/command_summaries/extended/evidence.md b/artifactory/utils/testdata/command_summaries/extended/evidence.md
new file mode 100644
index 000000000..b12858ec1
--- /dev/null
+++ b/artifactory/utils/testdata/command_summaries/extended/evidence.md
@@ -0,0 +1,3 @@
+