chore: introduce Sarif to UFM - #684
Conversation
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 9293f7d. Configure here.
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Stale comment
Automated verification
Ran a 4-pass review (semantic analysis, adversarial review, security scan, code review) against this diff (base
0044a65...head4850156).Not auto-approved. Per this automation's policy, only PRs that are exclusively a dependency bump (e.g.
go.mod/go.sumonly) are auto-approved. This PR adds ~2,400 lines of new/changed logic across 10 files (new SARIF→UFM transform, presenter template/helpers,testapiissue builder changes) with nogo.mod/go.sumchanges, so it does not qualify — a human reviewer should approve this one.Oracle: local build/lint/test could not be run in this sandbox (repo requires a Go 1.26 toolchain that could not be downloaded here). CI's
Unit testsandsecurity-scanschecks are green for this exact head commit, which covers that gap.Security scan: manual review only — Snyk CLI/MCP auth was unavailable in this sandbox (no
SNYK_TOKEN). No exploitable issues found manually (bounds/nil checks on SARIF-derived data are consistently present, no injection/secrets surfaces introduced,go.mod/go.sumuntouched so no new dependency surface).Should Fix (4)
pkg/utils/ufm/transform_sarif.go:103— severity filter silently drops SARIF results with an "unmapped" level even when noseverityThresholdis passed (see inline comment).pkg/utils/ufm/transform_sarif.go:378—PolicyModification.Prioris derived fromOriginalLevelinstead ofOriginalSeverity, so it can never report"critical"(see inline comment).internal/presenters/templates/ufm.sarif.tmpl:437— the run-level"properties"block is entirely omitted when there's no coverage/report-url, unlike the parallel Local-Findings template which always renders it (see inline comment).internal/presenters/presenter_ufm_test.go:206— the regression test stripspriorityScorefrom both sides of the comparison instead of asserting the new value, so thetoIntbehavior change it introduces ships without golden-fixture verification (see inline comment).Suggestions (3)
pkg/apiclients/testapi/issues.go:777—processSecretsRuleProblem/processSnykCodeRuleProblemsilently flipproblemIDassignment from last-wins to first-wins, inconsistent with the untouched vuln/license processors (see inline comment).pkg/utils/ufm/transform_sarif.go:19— new publicTransformOption/WithSeverityThresholdonpkg/utils/ufm(load-bearing API) has no caller in this PR (see inline comment).internal/presenters/funcs.go:574—getExecutionFlowsFromIssueis also registered ingetCliTemplateFuncMap, where nothing calls it (see inline comment).Good coverage overall: the new SARIF→UFM mapping has thorough, edge-case-focused unit tests (severity thresholds, suppression round-tripping, code flows, policy modifications, ID-collision handling), and finding-ID generation correctly falls back to a deterministic UUID with a dedicated collision test.
Sent by Cursor Automation: Automatic PR verification
| {{- /* TODO: Add properties section for SAST (coverage) and upload results | ||
| {{- $coverage := getCoverageFromTestResult $result }} | ||
| {{- $reportURL := index $metadata "report-url" }} | ||
| {{- if or $coverage $reportURL }} |
There was a problem hiding this comment.
Should Fix: This {{- if or $coverage $reportURL }} guard omits the entire run-level "properties" key whenever there's no coverage data and no report URL. local_finding.sarif.tmpl's equivalent block has no such guard — it unconditionally renders "properties": {"coverage": [...]} (empty array when there's no coverage). Since Test_UfmPresenter_SarifFromSarifInput exists specifically to validate UFM/LocalFindings SARIF-output parity, any input with zero coverage entries and no report metadata will produce structurally different SARIF between the two pipelines.
Suggest: drop this guard and instead render "coverage": [] unconditionally when $coverage is empty, keeping uploadResult conditional only on $reportURL — mirroring the LF template.
| // processSecretsRuleProblem extracts data from a secrets rule problem | ||
| func (b *issueBuilder) processSecretsRuleProblem(problem *Problem) { | ||
| if id := problem.GetID(); id != "" { | ||
| if id := problem.GetID(); id != "" && b.problemID == "" { |
There was a problem hiding this comment.
Suggestion: This changes processSecretsRuleProblem's problemID assignment from last-wins (id != "") to first-wins (id != "" && b.problemID == ""), and the new processSnykCodeRuleProblem below uses the same first-wins guard — but the untouched processSnykVulnProblem/processSnykLicenseProblem still overwrite unconditionally (last-wins) for findings with multiple matching problems. This inconsistency isn't explained by the PR description and doesn't look required for SAST/code-rule support.
Suggest: either revert this guard to match the vuln/license last-wins convention, or add a comment explaining why secrets/code-rule problems intentionally differ.
| sarif_utils "github.com/snyk/go-application-framework/pkg/utils/sarif" | ||
| ) | ||
|
|
||
| type TransformOption func(*transformConfig) |
There was a problem hiding this comment.
Suggestion: TransformOption/WithSeverityThreshold add a public option to TransformToUFMFromSarif, but the only caller in this PR (TransformSarifToUFM in pkg/local_workflows/data_transformation_workflow.go) never passes it — no code in this diff calls WithSeverityThreshold. Per this repo's API stability rules, new pkg/ surface is load-bearing once shipped. The equivalent LocalFindings path has no such option; severity filtering there is a separate downstream workflow step.
Suggest: drop this option until a concrete caller needs it (YAGNI), or wire it through TransformSarifToUFM now if one is already planned.
| fnMap["isPendingFinding"] = isPendingFinding | ||
| fnMap["isIgnoredFinding"] = isIgnoredFinding | ||
| fnMap["hasSuppression"] = hasSuppression | ||
| fnMap["getExecutionFlowsFromIssue"] = getExecutionFlowsFromIssue |
There was a problem hiding this comment.
Suggestion: getExecutionFlowsFromIssue is registered here in getCliTemplateFuncMap (used by CLI/human-readable templates), but it's only ever called from ufm.sarif.tmpl:140, which uses getSarifTemplateFuncMap (where it's already registered at line 542). No template using getCliTemplateFuncMap calls it. Looks like a copy-paste leftover.
Suggest: remove this registration.
PR Reviewer Guide 🔍
|
There was a problem hiding this comment.
Automated verification
Ran a 4-pass review (semantic analysis, adversarial review, security scan, code review) against the diff base 0044a65...head 9481aff.
Not auto-approved. Per this automation's policy, only PRs that are exclusively a dependency bump (e.g. go.mod/go.sum only) are auto-approved. This PR touches 10 non-dependency files (~2,400 lines) implementing a new SARIF→UFM transform, presenter/template changes, and testapi issue-builder changes — it does not qualify, and this run also found data-correctness issues (below), so it is not approved.
Progress since the last run (head 4850156): the two prior Should-Fix findings on the severity filter and policy-modification severity mapping are fixed. The priorityScore golden-fixture gap is now mitigated by a dedicated TestTransformToUFMFromSarif_RiskScore unit test (partially addressed). The remaining prior findings (run-level properties omission, secrets problemID first-wins inconsistency, unused WithSeverityThreshold option, stray getExecutionFlowsFromIssue registration) are still open — see the existing review threads.
Oracle: local build/lint/test could not be run in this sandbox (this repo requires a Go 1.26 toolchain unavailable here; unrelated to this diff). CI's test/lint checks for this head commit should be treated as authoritative.
Security scan: manual review only — Snyk CLI/MCP auth was unavailable (no SNYK_TOKEN secret configured for this repo). No exploitable issues found manually: all new string interpolation into the SARIF/JSON template is escaped via strconv.Quote, SARIF-derived RuleIndex values are bounds-checked before use, and new deserialization uses typed encoding/json, not dynamic mechanisms.
Critical (2)
pkg/utils/ufm/transform_sarif.go:120— the input SARIF'sRuleIndexis copied verbatim into the finding, then rendered as the output document'sresult.ruleIndex(ufm.sarif.tmpl:144) against a deduplicated/reorderedrulesarray — producing an invalid SARIF document once the driver's rule catalog contains rules unrelated to the emitted subset (see inline comment).pkg/utils/ufm/transform_sarif.go:439— suppressionexpirationis silently dropped (no error, no log) whenever it isn't an RFC3339 timestamp; Snyk's own"15 days"-style relative expirations (present in this PR's ownwith-ignores.jsonfixture) never survive intoFindingData.Attributes.Suppression.ExpiresAtfor consumers reading the structured API directly (see inline comment).
Should Fix (2)
pkg/utils/ufm/transform_sarif.go:168— findings that share a rule ID but lack fingerprints collapse into a singleIssue(grouped byAttributes.Key, which falls back toRuleIDalone), silently dropping the second finding's distinct message/arguments/fingerprints from the rendered SARIF (see inline comment).internal/presenters/templates/ufm.sarif.tmpl:189— theRegionJSON's comma placement is gated on the next field's presence rather than what was actually emitted; a common SARIF shape (startColumnset,endLineabsent) renders"startLine": 5 "startColumn": 3with no comma — invalid JSON (see inline comment).
Suggestions (5)
internal/presenters/funcs.go:283—findingExtraLocal/suppressionExtraLocal/priorityScoreFactorLocalduplicatepkg/utils/ufm's exported types, justified by an import-cycle claim that doesn't hold when tracingpkg/utils/ufm's actual (non-test) imports — worth confirming before merge since it's a hand-synced duplication risk (see inline comment).internal/presenters/templates/ufm.sarif.tmpl:422— when$findingExtrais absent but multiple$policyModsare present, each is rendered as its own unkeyed"reason"entry with no separator, producing invalid/duplicate-key JSON (see inline comment).internal/utils/findings/findings.go:12— newly exportedFilterSeverityASCduplicates the pre-existing unexportedfilterSeverityASCininternal/presenters/components.go; the two must now be kept in sync by hand (see inline comment).internal/presenters/templates/ufm.sarif.tmpl:146— switching toGetEffectiveSeverityhas no observable effect today, sinceextractEffectiveSeverityalways sets it equal toseverity— worth a test asserting the two can actually diverge, or reverting the line.pkg/apiclients/testapi/issues.go:786—ruleShortDescriptionremains last-wins whileproblemID(line 777, from the still-open prior thread) is now first-wins for the same secrets problem loop — the two fields describing "the rule" for one issue can now disagree.
Good test coverage overall on the new transform (severity thresholds, suppression round-tripping, code flows, policy modifications, ID-collision handling via deterministic UUID fallback).
Sent by Cursor Automation: Automatic PR verification
| Arguments: res.Message.Arguments, | ||
| MessageText: res.Message.Text, | ||
| MessageMarkdown: res.Message.Markdown, | ||
| RuleIndex: res.RuleIndex, |
There was a problem hiding this comment.
Critical: RuleIndex: res.RuleIndex copies the index from the input SARIF's full tool.driver.rules array. ufm.sarif.tmpl:144 renders this value verbatim as the output document's result.ruleIndex, but the output's rules array is deduplicateIssues $issues "problemID" — only the rules that actually produced findings, in a different order/size than the input driver's full rule catalog. Once the input driver lists rules beyond the emitted subset (the normal case for real Snyk Code scans), the original index no longer points at the correct entry in the output rules array — an invalid SARIF document per spec, breaking any consumer that resolves rule metadata via ruleIndex instead of ruleId.
Fix: build a problemID → outputIndex map while iterating the deduplicated rules (in the template or in Go) and use that mapped index instead of the original SARIF's index.
| } | ||
|
|
||
| if suppression.Properties.Expiration != nil { | ||
| if t, err := time.Parse(time.RFC3339, *suppression.Properties.Expiration); err == nil { |
There was a problem hiding this comment.
Critical: This only sets ExpiresAt if err == nil from time.Parse(time.RFC3339, ...). Snyk's own relative-duration expirations (e.g. "15 days", present in this PR's own internal/presenters/testdata/with-ignores.json:678 fixture) fail this parse and are silently dropped — no error, no log, no fallback. The SARIF-render path avoids this because the template reads the raw string from FindingExtra.Suppression.Expiration instead, so the structured API (TestResult.Findings()) and the rendered SARIF diverge on identical input. The sibling Local Findings transform keeps Expiration as a plain string with no parsing/loss.
Fix: at minimum, don't silently discard unparseable values — log/surface the parse failure. Ideally interpret Snyk's relative-duration format relative to IgnoredOn to compute a real ExpiresAt.
| return testapi.FindingData{}, fmt.Errorf("failed to map locations: %w", err) | ||
| } | ||
|
|
||
| key := res.Fingerprints.Identity |
There was a problem hiding this comment.
Should Fix: key falls back to res.RuleID alone when there's no fingerprint. selectGrouper in issues.go always uses keyBasedIssueGrouper for SAST findings (this transform always sets FindingType: FindingTypeSast), which groups strictly by Attributes.Key. Two distinct SARIF results for the same rule without fingerprints (common for tools that don't emit stable fingerprints) get the same key and merge into one Issue; getFindingExtraFromIssue then only returns the first finding's message/arguments/fingerprints, silently dropping the second's data from the rendered output.
Fix: derive the fallback key from rule ID plus location (mirroring generateFindingID a few lines below), not RuleID alone.
| {{- if $physicalLoc.Region.StartColumn }} | ||
| "startColumn": {{ $physicalLoc.Region.StartColumn }}{{if $physicalLoc.Region.EndLine}},{{end}} | ||
| {{- end }} | ||
| "startLine": {{ $physicalLoc.Region.StartLine }}{{if $physicalLoc.Region.EndLine}},{{end}} |
There was a problem hiding this comment.
Should Fix: The comma after startLine is gated on $physicalLoc.Region.EndLine being present, and after endLine on StartColumn being present — but if EndLine is absent while StartColumn is set (a common single-line-span SARIF shape), you get "startLine": 5 "startColumn": 3 with no separating comma — invalid JSON. This is a variant of a pre-existing pattern (gate on the next field's presence, not on what was actually emitted), now reachable under a different, more common trigger since the field emission order changed in this PR.
Fix: track whether a field was actually emitted and gate each comma on that, rather than looking ahead to the next field.
|
|
||
| // findingExtraLocal mirrors the JSON shape of ufm.FindingExtra for deserialization | ||
| // when metadata arrives as map[string]interface{} (e.g. after JSON round-trip). | ||
| // Cannot import pkg/utils/ufm directly due to an import cycle. |
There was a problem hiding this comment.
Suggestion: This comment claims importing pkg/utils/ufm directly would create an import cycle, but tracing pkg/utils/ufm's non-test imports (internal/utils/findings, pkg/apiclients/testapi, pkg/configuration, pkg/local_workflows/json_schemas, pkg/utils/sarif, pkg/local_workflows/content_type, pkg/workflow) finds no path back to internal/presenters — only a _test.go file in ufm imports presenters, which doesn't create a build cycle. If that holds, findingExtraLocal/suppressionExtraLocal/priorityScoreFactorLocal are an unnecessary hand-synced duplicate of ufm.FindingExtra/SuppressionExtra/PriorityScoreFactor that will silently drift if a field is added to one side and not the other.
Suggest confirming whether the cycle is real; if not, import the types directly and drop the duplicates. If a cycle is found, please document the exact offending import path in the comment.
| "properties": { | ||
| {{- if $policyMods }} | ||
| "snykPolicy/v1": { | ||
| {{- range $pmod := $policyMods }} |
There was a problem hiding this comment.
Suggestion: This branch (reached when $findingExtra is absent but $policyMods is non-empty — a real, reachable combination for pre-existing Local-Findings-sourced UFM data) ranges over $policyMods emitting one unkeyed "reason" entry per element with no comma/separator. More than one policy modification produces {"reason": "a" "reason": "b"} — invalid JSON with duplicate keys.
Suggest using an index-based range with a comma condition, and confirming the intended SARIF shape when more than one policy modification exists on an issue.
| type FindingsFilterFunc func(local_models.FindingResource) bool | ||
|
|
||
| func filterSeverityASC(original []string, severityMinLevel string) []string { | ||
| func FilterSeverityASC(original []string, severityMinLevel string) []string { |
There was a problem hiding this comment.
Suggestion: This newly-exported FilterSeverityASC is a near-duplicate of the pre-existing unexported filterSeverityASC in internal/presenters/components.go, kept separate presumably to let pkg/utils/ufm reuse the logic without an import cycle. The two implementations now need to be kept in sync by hand.
Suggest having internal/presenters/components.go's filterSeverityASC delegate to this exported version instead of maintaining a parallel copy, if no cycle prevents it.



Description
This PR introduces a new transformation from Sarif to UFM to eventually replace Sarif to Local Findings.
Checklist
make test)make generate)make lint)go get github.com/snyk/go-application-framework@YOUR_LATEST_GAF_COMMITin thecliv2directory.go.modto point to your local GAF code.go mod tidyin thecliv2directory.go.modandgo.sumchanges.Note
Medium Risk
Large change to the code findings presentation and transformation path with parity tests against Local Findings; incorrect mapping could change SARIF output, severities, or ignores for CLI consumers.
Overview
Adds a SARIF → UFM path intended to replace the existing SARIF → Local Findings pipeline for code results, plus presenter and workflow wiring so UFM can round-trip SAST SARIF with richer fields.
Transform layer:
TransformToUFMFromSarifmaps SARIF results intotestapi.TestResult(SAST findings, suppressions, code flows, policy mods, priority score, fingerprints infinding-extrasmetadata).NewSarifTestResultimplements the in-memory result type;TransformSarifToUFMexposes the same transform from local workflows. Scan metadata (report URL, project/snapshot IDs) is applied viaTranslateMetadataToTestResult.SARIF output:
ufm.sarif.tmplnow renders Snyk Code rule metadata, executioncodeFlows, finding extras (messages, rule index, policy, priority score), coverage and upload metadata from test result metadata, and uses effective severity plus expanded suppression details when extras exist.Presenters: New template helpers read execution flows, policy mods, finding extras, coverage, and
snyk_code_ruleproblems from issues (with local structs to avoid an import cycle withufm).toIntacceptsuint16/uint32for priority scores.Tests: End-to-end cases assert normalized SARIF from SARIF → UFM → SARIF matches SARIF → LF → SARIF, plus JSON round-trip rendering and human/HTML output from SARIF fixtures.
Other:
issuesbuilder handlessnyk_code_ruleproblems;FilterSeverityASCis exported for shared severity filtering.Reviewed by Cursor Bugbot for commit 9481aff. Bugbot is set up for automated code reviews on this repo. Configure here.