Skip to content

feat: add severity to UFM SARIF rule and result properties - #595

Open
danskmt wants to merge 1 commit into
mainfrom
feat/CLI-1330-add-severity-to-ufm-sarif
Open

feat: add severity to UFM SARIF rule and result properties#595
danskmt wants to merge 1 commit into
mainfrom
feat/CLI-1330-add-severity-to-ufm-sarif

Conversation

@danskmt

@danskmt danskmt commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

Description

Adds severity information to the UFM SARIF template so that consumers have a single, consistent place to read the effective severity — including critical, which has no native SARIF level (the SARIF 2.1.0 spec only defines error, warning, note, none).

Changes:

  • ufm.sarif.tmpl: adds "severity" to each rule's properties and a new "properties" block on each result containing "severity" (effective, post-policy) and conditionally a "snykPolicy/v1" block when a policy severity override exists.
  • issues.go: adds GetOriginalSeverity() to the Issue interface, extracting the pre-policy severity from the prior field in policy_modifications. Also accepts both /rating/severity and /attributes/rating/severity pointers.
  • mocks/issues.go: adds GetOriginalSeverity mock to stay in sync with the interface.
  • Test data: updated all expected SARIF outputs to include the new severity fields.

Note: the snykPolicy/v1 rendering path (when a severity override includes a prior value) is plumbed but not yet covered by test data — there is a TODO in the code. No existing API responses observed include prior in policy_modifications yet.

CLI-1330

Checklist

  • Tests added and all succeed (make test)
  • Regenerated mocks, etc. (make generate)
  • Linted (make lint)
  • Test your changes work for the CLI
    1. Clone / pull the latest CLI main.
    2. Run go get github.com/snyk/go-application-framework@YOUR_LATEST_GAF_COMMIT in the cliv2 directory.
      • Tip: for local testing, you can uncomment the line near the bottom of the CLI's go.mod to point to your local GAF code.
    3. Run go mod tidy in the cliv2 directory.
    4. Run the CLI tests and do any required manual testing.
    5. Open a PR in the CLI repo now with the go.mod and go.sum changes.
    • Once this PR is merged, repeat these steps, but pointing to the latest GAF commit on main and update your CLI PR.

@danskmt
danskmt requested review from a team as code owners April 24, 2026 14:54
@snyk-io

snyk-io Bot commented Apr 24, 2026

Copy link
Copy Markdown

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues
Code Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@snyk-io

snyk-io Bot commented Apr 24, 2026

Copy link
Copy Markdown

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues
Code Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@snyk-pr-review-bot

This comment has been minimized.

@danskmt
danskmt force-pushed the feat/CLI-1330-add-severity-to-ufm-sarif branch from 54a8d3e to 5be40ce Compare April 24, 2026 14:55
@snyk-pr-review-bot

This comment has been minimized.

@danskmt
danskmt force-pushed the feat/CLI-1330-add-severity-to-ufm-sarif branch from 5be40ce to 49ca020 Compare April 24, 2026 15:23
@snyk-pr-review-bot

Copy link
Copy Markdown

PR Reviewer Guide 🔍

🧪 PR contains tests
🔒 No security concerns identified
⚡ No major issues detected
📚 Repository Context Analyzed

This review considered 12 relevant code sections from 10 files (average relevance: 0.85)

Comment on lines +584 to 595
// extractEffectiveSeverity extracts effective severity from policy modifications.
// It also extracts the original (pre-policy) severity from the "prior" field of
// the policy modification, which is used to render the snykPolicy/v1 block in
// SARIF output (see ufm.sarif.tmpl).
//
// TODO(CLI-1330): The originalSeverity extraction from mod.Prior is currently
// untested — no existing test data includes a policy_modifications entry with
// a "prior" value. Add a test case with a severity override that includes
// "prior": "<original severity>" to cover the snykPolicy/v1 SARIF rendering.
// Also verify which pointer the API actually uses ("/rating/severity" per the
// spec example vs "/attributes/rating/severity" seen in some test data).
func (b *issueBuilder) extractEffectiveSeverity(finding *FindingData) {

@danskmt danskmt Apr 27, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO here.
Currently we have no tests that contains non-empty policy_modifications.

continue
}
if b.effectiveSeverity == "" && finding.Attributes.Rating.Severity != "" {
b.effectiveSeverity = string(finding.Attributes.Rating.Severity)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review — Should Fix (behavior change): This drops the mod.Prior != nil precondition that previously gated assigning effectiveSeverity. On origin/main the assignment only happened when a prior value existed; now effectiveSeverity is set for any /rating/severity (or /attributes/rating/severity) modification regardless of whether Prior is present.

For a finding where the builder-derived b.severity differs from finding.Attributes.Rating.Severity and the policy mod has a nil Prior, GetEffectiveSeverity() now returns the rating value instead of falling back to b.severity — and that flows into the new result-level properties.severity in the SARIF output. No current fixture exercises this divergent case (the only severity-mod fixture has both values equal), so the change is invisible to tests. Please confirm this is intentional and pin it with a test for both prior-present and prior-absent mods.

— AI review

if mod.Pointer == "/rating/severity" && mod.Prior != nil {
if b.effectiveSeverity == "" && finding.Attributes.Rating.Severity != "" {
b.effectiveSeverity = string(finding.Attributes.Rating.Severity)
isSeverityMod := mod.Pointer == "/rating/severity" || mod.Pointer == "/attributes/rating/severity"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review — Should Fix (untested path + unconfirmed contract): The dual-pointer match (/rating/severity || /attributes/rating/severity) plus the downstream originalSeverity extraction and the snykPolicy/v1 SARIF block (template lines ~204-210) ship with zero test coverage — your own TODO(CLI-1330) confirms no fixture contains a policy_modifications entry with a prior value, and snykPolicy appears in none of the regenerated testdata.

Two concrete risks on this never-exercised path: (1) the API pointer spelling is a documented guess (hence matching both), so a wrong/absent pointer silently no-ops the feature; (2) a non-string Prior (object/number) fails the (*mod.Prior).(string) assertion and silently omits the whole block. Recommend resolving the real pointer against the API contract (keep only the confirmed branch) and adding a golden fixture with a real severity override so this branch is exercised before merge — otherwise the headline feature lands dark.

— AI review

{{- end }}
{{- end }},
"properties": {
"severity": {{ getQuotedString $issue.GetEffectiveSeverity }}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review — Should Fix (inconsistent severity sources): This result-level properties.severity uses GetEffectiveSeverity (post-policy), but the SARIF result "level" (line ~78) and the rule-level properties.severity (line ~47) use GetSeverity (raw/pre-policy). When a policy overrides a finding's severity, the same result advertises a level derived from the original severity while properties.severity (and the snykPolicy/v1 block right below) reflect the effective one — contradictory signals in one object.

The raw-at-rule / effective-at-result split may be deliberate (rules are deduped across findings), but it is currently implicit and untested. Please add a brief comment documenting the intent, and a fixture with an actual override that pins the level vs properties.severity relationship.

— AI review

{{- $originalSeverity := $issue.GetOriginalSeverity }}
{{- if $originalSeverity }},
"snykPolicy/v1": {
"originalLevel": {{ getQuotedString (severityToSarifLevel $originalSeverity) }},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review — Suggestion: severityToSarifLevel is case-sensitive and only maps low/medium/high/critical, returning "unmapped" otherwise. Here it runs on the raw originalSeverity string taken straight from mod.Prior, so a genuine override whose prior value is none, other, or non-lowercase (e.g. "High") renders "originalLevel": "unmapped" — an invalid SARIF level from otherwise-correct API data. (The result-level level at line ~78 is safe because it consumes the lowercased Severity enum; this new path is unprotected.) Worth normalizing casing / handling the full enum once the override path gets a fixture.

— AI review

{{- if $originalSeverity }},
"snykPolicy/v1": {
"originalLevel": {{ getQuotedString (severityToSarifLevel $originalSeverity) }},
"severity": {{ getQuotedString $issue.GetEffectiveSeverity }},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review — Suggestion: snykPolicy/v1.severity here emits the identical GetEffectiveSeverity value already emitted at the enclosing properties.severity (line ~202). It's redundant and a sync hazard — any future change to effective-severity semantics must be edited in both places or they silently diverge. The snykPolicy/v1 block's purpose is to describe the override (originalLevel/originalSeverity); consider dropping the duplicated effective severity from it.

— AI review

@basti-snyk

Copy link
Copy Markdown
Contributor

ℹ️ Automated AI review summary (non-blocking) — comment-only, no approval. Four reviewer passes (semantic, adversarial, security, code-review) reached strong consensus.

Verdict: CONTESTED — the core severity property addition is correct and well-tested via the regenerated fixtures (assert.JSONEq genuinely asserts the new values). The contested part is the snykPolicy/v1 / original-severity feature, which ships untested.

Should Fix (posted inline):

  • Dropped mod.Prior != nil guard in extractEffectiveSeverity — silent behavior change to GetEffectiveSeverity.
  • snykPolicy/v1 block + dual JSON-pointer match are entirely untested; API pointer spelling unconfirmed (per the TODO(CLI-1330)).
  • Result level (raw severity) vs result properties.severity (effective severity) diverge under a policy override.

Suggestions (posted inline): originalLevel can render "unmapped" for none/other/non-lowercase prior values; snykPolicy/v1.severity duplicates properties.severity.

Suggestion (out-of-diff): the regenerated .sarif.json testdata diff is largely cosmetic reformatting (single-line tags → multi-line, indentation), inflating the change to ~1900 lines and making fixture-faithfulness review by eye impractical. Consider separating the formatting churn or regenerating with the formatter the existing fixtures used so only severity lines change.

Security: clean — no secrets introduced; all dynamic template values are strconv.Quote-escaped via getQuotedString, so no JSON/template injection. The one Snyk secrets hit in webgoat.sarif.json is a pre-existing false positive in CVE help-text, unchanged by this PR.

— AI review

b.effectiveSeverity = string(finding.Attributes.Rating.Severity)
}
if b.originalSeverity == "" && mod.Prior != nil {
if prior, ok := (*mod.Prior).(string); ok {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should Fix — the snykPolicy/v1 / originalSeverity path ships untested and on an unconfirmed contract.

This new extraction (and the SARIF block it feeds at ufm.sarif.tmpl ~204-210) is the headline of this PR, yet no fixture exercises it: no policy_modifications entry in any *.testresult.json carries a prior, snykPolicy appears in none of the regenerated SARIF outputs, and no test references GetOriginalSeverity. Your own TODO(CLI-1330) above (line 589) calls this out. Two concrete correctness risks live in this untested code:

  1. Unconfirmed pointer (line 600). isSeverityMod matches both /rating/severity and /attributes/rating/severity as a guess. If the API emits a third form, originalSeverity stays empty, the template {{if $originalSeverity}} is false, and the feature is a silent no-op — with no failing test to reveal it.
  2. Non-string Prior silently drops the block. (*mod.Prior).(string) is a comma-ok assertion; if the API ever serializes the prior severity as a non-string (object/number), the assertion fails, originalSeverity stays empty, and the entire snykPolicy/v1 block disappears for a finding that was policy-modified — no error, no log.

Suggest adding a testresult fixture with a real severity override carrying "prior": "<severity>", regenerating the SARIF so it contains a rendered snykPolicy/v1 block, and asserting on it — before this path goes live. Confirm the real API pointer and collapse the dual-match to the verified one.

— AI review

{{- $originalSeverity := $issue.GetOriginalSeverity }}
{{- if $originalSeverity }},
"snykPolicy/v1": {
"originalLevel": {{ getQuotedString (severityToSarifLevel $originalSeverity) }},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should Fix — originalLevel can render "unmapped" from otherwise-valid API data.

severityToSarifLevel (pkg/utils/sarif/sarif.go:34) is case-sensitive and maps only low/medium/high/critical, returning "unmapped" otherwise. Here it runs on the raw, unnormalized originalSeverity taken straight from mod.Prior. The testapi.Severity enum also defines none and other, so a policy override from/to one of those — or any non-lowercase value ("High") — emits "originalLevel": "unmapped", an invalid SARIF level. The result-level level field is safe because it consumes the lowercased Severity enum; this new path is unprotected. Normalize casing / handle the full enum when the override path gets its fixture.

— AI review

{{- end }}
{{- end }},
"properties": {
"severity": {{ getQuotedString $issue.GetEffectiveSeverity }}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ Non-blocking suggestions on the new properties block.

  • Rule-vs-result severity divergence (undocumented). Rule-level properties.severity (line 47) uses GetSeverity (raw/pre-policy) while this result-level properties.severity uses GetEffectiveSeverity (post-policy), and the result level (line 78) also uses raw severity. For a policy-overridden finding the same SARIF document then advertises a raw level alongside an effective properties.severity — contradictory signals unless intended. If deliberate, a one-line comment at each call site stating which severity each field carries would prevent a future "cleanup" from regressing it.
  • Duplicate severity in the policy block (line 207). snykPolicy/v1.severity is byte-identical to this outer properties.severity (both GetEffectiveSeverity). The block's purpose is the override delta (originalLevel/originalSeverity); the duplicated effective severity carries no extra info and is a sync hazard. Consider dropping it.
  • Empty severity renders "severity": "". GetSeverity/GetEffectiveSeverity may return empty (per the interface doc); an unrated finding then emits a literal empty string rather than omitting the key, while the sibling level maps empty to "unmapped". Decide deliberately (omit via {{- with }} or normalize) and add an unrated-finding fixture.

— AI review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants