Skip to content

Commit fadafb4

Browse files
committed
feat: handle evidence paths for both text and JSON output
- human-readable output: aggregates by Snyk ID for both the issue list and the Open Issues count. - json output: create a distinct vulnerability for each evidence of type dependency_path so that Snyk-to-html can aggregate and count them. - json output: "uniqueCount" is the total of unique Snyk IDs seen. new expectations: - "total issues" match between human readable and snyk-to-html output. - "uniqueCount" in JSON also matches "total issues" above. - "vulnerable dependency paths" in snyk-to-html matches "vulnerable paths" in human-readable output
1 parent 6f3ac76 commit fadafb4

File tree

5 files changed

+205
-285
lines changed

5 files changed

+205
-285
lines changed

internal/commands/ostest/test_execution.go

Lines changed: 135 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import (
55
"encoding/json"
66
std_errors "errors"
77
"fmt"
8-
"math"
98
"sort"
109
"strings"
1110

@@ -61,17 +60,33 @@ func RunTest(
6160
orgSlugOrID = orgID
6261
}
6362

64-
uniqueCount := calculateUniqueIssueCount(findingsData)
63+
allFindingsData := findingsData
64+
vulnerablePathsCount := calculateVulnerablePathsCount(allFindingsData)
65+
66+
// Deep copy findings for consolidation to avoid modifying the original slice,
67+
// which would affect the JSON output.
68+
b, err := json.Marshal(allFindingsData)
69+
if err != nil {
70+
return nil, nil, fmt.Errorf("failed to marshal findings for deep copy: %w", err)
71+
}
72+
var findingsForConsolidation []testapi.FindingData
73+
if err := json.Unmarshal(b, &findingsForConsolidation); err != nil {
74+
return nil, nil, fmt.Errorf("failed to unmarshal findings for deep copy: %w", err)
75+
}
76+
77+
consolidatedFindings := consolidateFindings(findingsForConsolidation, logger)
78+
//nolint:gosec // G115: integer overflow is not a concern here
79+
uniqueCount := int32(len(consolidatedFindings))
6580

6681
// The summary is always needed for the exit code calculation.
67-
standardSummary, summaryData, summaryErr := NewSummaryData(finalResult, logger, targetDir)
68-
if summaryErr != nil && !std_errors.Is(summaryErr, ErrNoSummaryData) {
69-
// Log the error but continue, as this is not fatal.
82+
standardSummary, summaryData, summaryErr := NewSummaryDataFromFindings(consolidatedFindings, logger, targetDir)
83+
if summaryErr != nil {
84+
// Log other errors but continue, as this is not fatal.
7085
logger.Warn().Err(summaryErr).Msg("Failed to create test summary for exit code handling")
7186
}
7287

7388
legacyParams := &transform.SnykSchemaToLegacyParams{
74-
Findings: findingsData,
89+
Findings: allFindingsData,
7590
TestResult: finalResult,
7691
OrgSlugOrID: orgSlugOrID,
7792
ProjectName: projectName,
@@ -84,7 +99,7 @@ func RunTest(
8499
Logger: logger,
85100
}
86101

87-
return prepareOutput(ictx, findingsData, standardSummary, summaryData, legacyParams)
102+
return prepareOutput(ictx, consolidatedFindings, standardSummary, summaryData, legacyParams, vulnerablePathsCount)
88103
}
89104

90105
// executeTest runs the test and returns the results.
@@ -147,6 +162,7 @@ func prepareOutput(
147162
standardSummary *json_schemas.TestSummary,
148163
summaryData workflow.Data,
149164
params *transform.SnykSchemaToLegacyParams,
165+
vulnerablePathsCount int,
150166
) (*definitions.LegacyVulnerabilityResponse, []workflow.Data, error) {
151167
config := ictx.GetConfiguration()
152168
var outputData []workflow.Data
@@ -180,12 +196,13 @@ func prepareOutput(
180196

181197
if standardSummary != nil {
182198
extendedPayload := presenters.SummaryPayload{
183-
Summary: standardSummary,
184-
DependencyCount: params.DepCount,
185-
PackageManager: params.PackageManager,
186-
ProjectName: params.ProjectName,
187-
DisplayTargetFile: params.DisplayTargetFile,
188-
UniqueCount: params.UniqueCount,
199+
Summary: standardSummary,
200+
DependencyCount: params.DepCount,
201+
PackageManager: params.PackageManager,
202+
ProjectName: params.ProjectName,
203+
DisplayTargetFile: params.DisplayTargetFile,
204+
UniqueCount: params.UniqueCount,
205+
VulnerablePathsCount: vulnerablePathsCount,
189206
}
190207

191208
extendedPayloadBytes, marshalErr := json.Marshal(extendedPayload)
@@ -198,62 +215,42 @@ func prepareOutput(
198215
return legacyVulnResponse, outputData, nil
199216
}
200217

201-
// getSeverityCount safely retrieves the count for a given severity from a summary.
202-
func getSeverityCount(summary *testapi.FindingSummary, severity string) uint32 {
203-
if summary == nil || summary.CountBy == nil {
204-
return 0
205-
}
206-
if severityCounts, ok := (*summary.CountBy)["severity"]; ok {
207-
return severityCounts[severity]
208-
}
209-
return 0
210-
}
211-
212-
// extractSeverityKeys returns a map of severity keys present in the summaries.
213-
func extractSeverityKeys(summaries ...*testapi.FindingSummary) map[string]bool {
214-
keys := make(map[string]bool)
215-
for _, summary := range summaries {
216-
if summary != nil && summary.CountBy != nil {
217-
if severityCounts, ok := (*summary.CountBy)["severity"]; ok {
218-
for severity := range severityCounts {
219-
keys[severity] = true
220-
}
221-
}
218+
// NewSummaryDataFromFindings creates a workflow.Data object containing a json_schemas.TestSummary
219+
// from a list of findings. This is used for downstream processing, like determining
220+
// the CLI exit code.
221+
func NewSummaryDataFromFindings(
222+
findings []testapi.FindingData,
223+
_ *zerolog.Logger,
224+
path string,
225+
) (*json_schemas.TestSummary, workflow.Data, error) {
226+
if len(findings) == 0 {
227+
testSummary := json_schemas.NewTestSummary("open-source", path)
228+
testSummary.Results = []json_schemas.TestSummaryResult{}
229+
testSummary.SeverityOrderAsc = []string{"low", "medium", "high", "critical"}
230+
summaryBytes, err := json.Marshal(testSummary)
231+
if err != nil {
232+
return nil, nil, fmt.Errorf("failed to marshal empty test summary: %w", err)
222233
}
234+
return testSummary, NewWorkflowData(content_type.TEST_SUMMARY, summaryBytes), nil
223235
}
224-
return keys
225-
}
226-
227-
// NewSummaryData creates a workflow.Data object containing a json_schemas.TestSummary
228-
// from a testapi.TestResult. This is used for downstream processing, like determining
229-
// the CLI exit code.
230-
func NewSummaryData(testResult testapi.TestResult, _ *zerolog.Logger, path string) (*json_schemas.TestSummary, workflow.Data, error) {
231-
rawSummary := testResult.GetRawSummary()
232-
effectiveSummary := testResult.GetEffectiveSummary()
233236

234-
if rawSummary == nil || effectiveSummary == nil {
235-
return nil, nil, fmt.Errorf("test result missing summary information")
237+
severityCounts := make(map[string]int)
238+
for _, finding := range findings {
239+
if finding.Attributes == nil {
240+
continue
241+
}
242+
severity := string(finding.Attributes.Rating.Severity)
243+
severityCounts[severity]++
236244
}
237245

238-
severityKeys := extractSeverityKeys(rawSummary, effectiveSummary)
239-
240-
var summaryResults []json_schemas.TestSummaryResult
241-
for severity := range severityKeys {
242-
total := getSeverityCount(rawSummary, severity)
243-
open := getSeverityCount(effectiveSummary, severity)
244-
245-
if total > 0 || open > 0 {
246-
ignored := 0
247-
if total > open {
248-
ignored = int(total - open)
249-
}
250-
summaryResults = append(summaryResults, json_schemas.TestSummaryResult{
251-
Severity: severity,
252-
Total: int(total),
253-
Open: int(open),
254-
Ignored: ignored,
255-
})
256-
}
246+
summaryResults := make([]json_schemas.TestSummaryResult, 0, len(severityCounts))
247+
for severity, count := range severityCounts {
248+
summaryResults = append(summaryResults, json_schemas.TestSummaryResult{
249+
Severity: severity,
250+
Total: count,
251+
Open: count, // For this summary, all found issues are considered open.
252+
Ignored: 0,
253+
})
257254
}
258255

259256
// Sort results for consistent output, matching the standard CLI order.
@@ -275,40 +272,92 @@ func NewSummaryData(testResult testapi.TestResult, _ *zerolog.Logger, path strin
275272
return testSummary, summaryWorkflowData, nil
276273
}
277274

278-
// calculateUniqueIssueCount iterates through findings to determine the number of unique issues.
279-
// A unique issue is identified by its Snyk ID (e.g., SNYK-JS-LODASH-12345).
280-
func calculateUniqueIssueCount(findings []testapi.FindingData) int32 {
281-
issueIDs := make(map[string]bool)
275+
// consolidateFindings consolidates findings with the same Snyk ID into a single finding
276+
// with all the evidence and locations from the original findings.
277+
func consolidateFindings(findings []testapi.FindingData, logger *zerolog.Logger) []testapi.FindingData {
278+
consolidatedFindings := make(map[string]testapi.FindingData)
279+
var orderedKeys []string
280+
282281
for _, finding := range findings {
283-
var snykID string
284-
// A finding can have multiple problems (e.g., a CVE and a Snyk ID).
285-
// We iterate through them to find the canonical Snyk ID for uniqueness.
286-
for _, problem := range finding.Attributes.Problems {
287-
var id string
282+
snykID := getSnykID(finding)
283+
if snykID == "" {
284+
// If a finding has no Snyk ID, treat it as unique.
285+
if finding.Id == nil {
286+
logger.Error().Msg("finding is missing an ID")
287+
continue
288+
}
289+
snykID = finding.Id.String()
290+
}
291+
292+
if existingFinding, exists := consolidatedFindings[snykID]; !exists {
293+
consolidatedFindings[snykID] = finding
294+
orderedKeys = append(orderedKeys, snykID)
295+
} else {
296+
// Deep copy to avoid modifying the map's shared object directly
297+
newFinding := existingFinding
298+
if newFinding.Attributes == nil && finding.Attributes != nil {
299+
newFinding.Attributes = &testapi.FindingAttributes{}
300+
}
301+
302+
if finding.Attributes != nil {
303+
newFinding.Attributes.Evidence = append(newFinding.Attributes.Evidence, finding.Attributes.Evidence...)
304+
newFinding.Attributes.Locations = append(newFinding.Attributes.Locations, finding.Attributes.Locations...)
305+
}
306+
consolidatedFindings[snykID] = newFinding
307+
}
308+
}
309+
310+
result := make([]testapi.FindingData, len(orderedKeys))
311+
for i, key := range orderedKeys {
312+
result[i] = consolidatedFindings[key]
313+
}
314+
return result
315+
}
316+
317+
// getSnykID extracts the canonical Snyk ID from a finding.
318+
// TODO This needs to use attributes.Key.
319+
func getSnykID(finding testapi.FindingData) string {
320+
if finding.Attributes == nil || len(finding.Attributes.Problems) == 0 {
321+
return ""
322+
}
288323

289-
// The problem is a union type, so we need to check which type it is.
324+
for _, problem := range finding.Attributes.Problems {
325+
var id string
326+
disc, err := problem.Discriminator()
327+
if err != nil {
328+
continue
329+
}
330+
331+
if disc == string(testapi.SnykVuln) {
290332
if p, err := problem.AsSnykVulnProblem(); err == nil {
291333
id = p.Id
292-
} else if p, err := problem.AsSnykLicenseProblem(); err == nil {
293-
id = p.Id
294334
}
295-
296-
if id != "" {
297-
snykID = id
298-
break // Found a Snyk ID, we can stop searching for this finding.
335+
} else if disc == string(testapi.SnykLicense) {
336+
if p, err := problem.AsSnykLicenseProblem(); err == nil {
337+
id = p.Id
299338
}
300339
}
301340

302-
if snykID != "" {
303-
issueIDs[snykID] = true
341+
if id != "" {
342+
return id
304343
}
305344
}
345+
return ""
346+
}
306347

307-
count := len(issueIDs)
308-
if count > math.MaxInt32 {
309-
return math.MaxInt32
348+
func calculateVulnerablePathsCount(findings []testapi.FindingData) int {
349+
var count int
350+
for _, finding := range findings {
351+
if finding.Attributes == nil {
352+
continue
353+
}
354+
for _, evidence := range finding.Attributes.Evidence {
355+
if disc, err := evidence.Discriminator(); err == nil && disc == string(testapi.DependencyPath) {
356+
count++
357+
}
358+
}
310359
}
311-
return int32(count)
360+
return count
312361
}
313362

314363
// NewWorkflowData creates a workflow.Data object with the given content type and data.

0 commit comments

Comments
 (0)