Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions internal/presenters/funcs.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"fmt"
"maps"
"os"
"reflect"
"slices"
"strconv"
Expand Down Expand Up @@ -386,10 +387,40 @@ func getDefaultTemplateFuncMap(config configuration.Configuration, ri runtimeinf
defaultMap["getTargetId"] = func(path string) (string, error) {
return target.GetTargetId(path, target.AutoDetectedTargetId, target.WithConfiguredRepository(config))
}
defaultMap["deduplicateIssues"] = deduplicateIssues

return defaultMap
}

type issueDedupeKey string

const (
DedupeByProblemID issueDedupeKey = "problemID"
DedupeByID issueDedupeKey = "id"
)

// deduplicateIssues returns a subset of issues with unique keys, preserving order (first-wins).
func deduplicateIssues(issues []testapi.Issue, key issueDedupeKey) []testapi.Issue {
seen := make(map[string]bool)
result := make([]testapi.Issue, 0, len(issues))
for _, issue := range issues {
var field string
switch key {
case DedupeByProblemID:
field = issue.GetProblemID()
case DedupeByID:
field = issue.GetID()
default:
fmt.Fprintf(os.Stderr, "warning: unsupported issueDedupeKey %q, skipping deduplication for issue\n", key)
}
Comment thread
danskmt marked this conversation as resolved.
if field != "" && !seen[field] {
seen[field] = true
result = append(result, issue)
}
}
return result
}

func convertTypeToIssueName(findingType testapi.FindingType) string {
// Map backend finding types to human-friendly issue group names
switch findingType {
Expand Down
64 changes: 62 additions & 2 deletions internal/presenters/presenter_ufm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,11 @@ func sortByID(arr []interface{}) {
})
}

// sortByRuleID sorts an array of results by their "ruleId" field
// sortByRuleID sorts results for stable comparison. Tiebreakers (in order):
// 1. ruleId — primary key
// 2. artifact URI — needed when multiple findings share the same rule but differ in file
// 3. fingerprint identity — needed when findings share both rule and file (e.g. same secret
// rule detected at different locations in the same file)
func sortByRuleID(arr []interface{}) {
sort.Slice(arr, func(i, j int) bool {
iMap, iOk := arr[i].(map[string]interface{})
Expand All @@ -141,10 +145,53 @@ func sortByRuleID(arr []interface{}) {
}
iID, _ := iMap["ruleId"].(string) //nolint:errcheck // test helper, ok to ignore
jID, _ := jMap["ruleId"].(string) //nolint:errcheck // test helper, ok to ignore
return iID < jID
if iID != jID {
return iID < jID
}
iURI := extractArtifactURI(iMap)
jURI := extractArtifactURI(jMap)
if iURI != jURI {
return iURI < jURI
}
return extractFingerprint(iMap) < extractFingerprint(jMap)
})
}

func extractFingerprint(result map[string]interface{}) string {
fps, ok := result["fingerprints"].(map[string]interface{})
if !ok {
return ""
}
if id, ok := fps["identity"].(string); ok {
return id
}
return ""
}

func extractArtifactURI(result map[string]interface{}) string {
locations, ok := result["locations"].([]interface{})
if !ok || len(locations) == 0 {
return ""
}
loc, ok := locations[0].(map[string]interface{})
if !ok {
return ""
}
physLoc, ok := loc["physicalLocation"].(map[string]interface{})
if !ok {
return ""
}
artLoc, ok := physLoc["artifactLocation"].(map[string]interface{})
if !ok {
return ""
}
uri, ok := artLoc["uri"].(string)
if !ok {
return ""
}
return uri
}

// normalizeHelpContent removes help.markdown content to avoid comparing test data descriptions
func normalizeHelpContent(run map[string]interface{}) {
normalizeRuleHelp(run)
Expand Down Expand Up @@ -554,6 +601,12 @@ func Test_UfmPresenter_Sarif(t *testing.T) {
testResultPath: "testdata/ufm/secrets.0findings.testresult.json",
ignoreSuppressions: true,
},
{
name: "secrets_duplicated_rules",
expectedSarifPath: "testdata/ufm/secrets.duplicated-sarif-rules.sarif.json",
testResultPath: "testdata/ufm/secrets.duplicated-sarif-rules.testresult.json",
ignoreSuppressions: true,
},
}

for _, tc := range testCases {
Expand Down Expand Up @@ -1133,6 +1186,13 @@ func Test_UfmPresenter_HumanReadable(t *testing.T) {
includeIgnores: false,
severityThreshold: "",
},
{
name: "secrets_duplicated_rules",
expectedPath: "testdata/ufm/secrets.duplicated-sarif-rules.human.readable",
testResultPath: "testdata/ufm/secrets.duplicated-sarif-rules.testresult.json",
includeIgnores: true,
severityThreshold: "",
},
}

for _, tc := range testCases {
Expand Down
8 changes: 4 additions & 4 deletions internal/presenters/templates/ufm.sarif.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@
"version" : "{{- getRuntimeInfo "version" }}",
"informationUri" : "https://docs.snyk.io/",
"rules" : [
{{- range $index, $issue := $issues }}
{{- $uniqueRules := deduplicateIssues $issues "problemID" }}
{{- $rulesSize := sub (len $uniqueRules) 1 }}
{{- range $index, $issue := $uniqueRules }}
{{- $tags := buildRuleTags $issue }}
{{- $cvssScore := getRuleCVSSScore $issue }}
{{- $problemID := $issue.GetProblemID }}
{{- if not $problemID }}{{ $problemID = $issue.GetID }}{{ end }}
{
"id": {{ getQuotedString $problemID }},
"shortDescription": {
Expand All @@ -46,7 +47,7 @@
"cvssv3_baseScore": {{ $cvssScore }},
"security-severity": {{ getQuotedString (printf "%.1f" $cvssScore) }}{{end}}
}
}{{if lt $index $issuesSize}},{{end}}
}{{if lt $index $rulesSize}},{{end}}
{{- end }}
]
{{- $dependencyCount := index $metadata "dependency-count" }}
Expand All @@ -71,7 +72,6 @@
{{- $ignoreDetails := $issue.GetIgnoreDetails }}
{{- $problemID := $issue.GetProblemID }}
{{- $fingerPrintID := $issue.GetID }}
{{- if not $problemID }}{{ $problemID = $issue.GetID }}{{ end }}
{
"ruleId": {{ getQuotedString $problemID }},
"level": {{ getQuotedString (severityToSarifLevel $issue.GetSeverity) }},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
Testing () ...

Open Secrets issues: 5

✗ [HIGH] Generic Secret Key
Finding ID: 00000000-0000-0000-0000-000000000000
Info: Detected a generic secret, which could lead to unauthorized access and sensitive data exposure.
Path: generic/file with spaces, line 2 to 2
Path: generic/file with spaces, line 3 to 3

✗ [HIGH] Generic Secret Key
Finding ID: 00000000-0000-0000-0000-000000000001
Info: Detected a generic secret, which could lead to unauthorized access and sensitive data exposure.
Path: generic/secret, line 2 to 2
Path: generic/secret, line 3 to 3

✗ [HIGH] Generic Secret Key
Finding ID: 00000000-0000-0000-0000-000000000002
Info: Detected a generic secret, which could lead to unauthorized access and sensitive data exposure.
Path: generic/config.json, line 2 to 2
Path: generic/config.json, line 3 to 3
Path: generic/config.json, line 4 to 4

✗ [HIGH] Generic Secret Key
Finding ID: 00000000-0000-0000-0000-000000000003
Info: Detected a generic secret, which could lead to unauthorized access and sensitive data exposure.
Path: generic/file with spaces, line 1 to 1

✗ [HIGH] Generic Secret Key
Finding ID: 00000000-0000-0000-0000-000000000004
Info: Detected a generic secret, which could lead to unauthorized access and sensitive data exposure.
Path: generic/secret, line 1 to 1

╭─────────────────────────────────────────────────────────╮
│ Test Summary │
│ │
│ Organization: My Org │
│ Test type: Secret Detection │
│ Project path: │
│ │
│ Total secrets issues: 5 │
│ Ignored: 0 [ 0 CRITICAL  0 HIGH  0 MEDIUM  0 LOW ] │
│ Open : 5 [ 0 CRITICAL  5 HIGH  0 MEDIUM  0 LOW ] │
╰─────────────────────────────────────────────────────────╯

Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
{
"$schema": "https://docs.oasis-open.org/sarif/sarif/v2.1.0/errata01/os/schemas/sarif-schema-2.1.0.json",
"runs": [
{
"automationDetails": {
"id": "Snyk/Secrets/0/*"
},
"results": [
{
"fingerprints": {
"identity": "00000000-0000-0000-0000-000000000002",
"snyk/asset/finding/v1": "00000000-0000-0000-0000-000000000002"
},
"level": "error",
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "generic/config.json"
},
"region": {
"endColumn": 48,
"endLine": 2,
"startColumn": 16,
"startLine": 2
}
}
}
],
"message": {
"text": "This file contains a high severity Generic Secret Key vulnerability."
},
"ruleId": "generic-secret"
},
{
"fingerprints": {
"identity": "00000000-0000-0000-0000-000000000000",
"snyk/asset/finding/v1": "00000000-0000-0000-0000-000000000000"
},
"level": "error",
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "generic/file with spaces"
},
"region": {
"endColumn": 99,
"endLine": 2,
"startColumn": 11,
"startLine": 2
}
}
}
],
"message": {
"text": "This file contains a high severity Generic Secret Key vulnerability."
},
"ruleId": "generic-secret"
},
{
"fingerprints": {
"identity": "00000000-0000-0000-0000-000000000003",
"snyk/asset/finding/v1": "00000000-0000-0000-0000-000000000003"
},
"level": "error",
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "generic/file with spaces"
},
"region": {
"endColumn": 29,
"endLine": 1,
"startColumn": 9,
"startLine": 1
}
}
}
],
"message": {
"text": "This file contains a high severity Generic Secret Key vulnerability."
},
"ruleId": "generic-secret"
},
{
"fingerprints": {
"identity": "00000000-0000-0000-0000-000000000001",
"snyk/asset/finding/v1": "00000000-0000-0000-0000-000000000001"
},
"level": "error",
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "generic/secret"
},
"region": {
"endColumn": 99,
"endLine": 2,
"startColumn": 11,
"startLine": 2
}
}
}
],
"message": {
"text": "This file contains a high severity Generic Secret Key vulnerability."
},
"ruleId": "generic-secret"
},
{
"fingerprints": {
"identity": "00000000-0000-0000-0000-000000000004",
"snyk/asset/finding/v1": "00000000-0000-0000-0000-000000000004"
},
"level": "error",
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "generic/secret"
},
"region": {
"endColumn": 29,
"endLine": 1,
"startColumn": 9,
"startLine": 1
}
}
}
],
"message": {
"text": "This file contains a high severity Generic Secret Key vulnerability."
},
"ruleId": "generic-secret"
}
],
"tool": {
"driver": {
"informationUri": "https://docs.snyk.io/",
"name": "Snyk Secrets",
"rules": [
{
"help": {
"markdown": "Detected a generic secret, which could lead to unauthorized access and sensitive data exposure.",
"text": ""
},
"id": "generic-secret",
"properties": {
"tags": [
"CWE-798",
"security"
]
},
"shortDescription": {
"text": "Generic Secret Key"
}
}
],
"semanticVersion": "1.1301.0",
"version": "1.1301.0"
}
}
}
],
"version": "2.1.0"
}
Loading