Skip to content

fix(notifications): stop the severity filter from eating approval events - #412

Merged
0xmanhnv merged 1 commit into
developfrom
fix/severity-filter-eats-approvals
Aug 4, 2026
Merged

fix(notifications): stop the severity filter from eating approval events#412
0xmanhnv merged 1 commit into
developfrom
fix/severity-filter-eats-approvals

Conversation

@0xmanhnv

@0xmanhnv 0xmanhnv commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

All three approval_* notifications are undeliverable on a default configuration. A finding awaiting a decision blocks forever and nobody is told.

Two gates, and the second silently defeats the first

ShouldNotifyEventType   empty enabled_event_types -> platform defaults
ShouldNotify            empty enabled_severities  -> critical + high ONLY

DefaultEnabledEventTypes() deliberately turns EventTypeApprovalRequested on, with the reasoning recorded right next to it:

"An approval request is addressed to a human; if it reaches nobody the finding stays blocked indefinitely. The approved/rejected outcomes are deliberately NOT default-on..."

The enqueue sites then stamp a constant severity:

event severity site survives default filter?
approval_requested medium vulnerability_service.go:2355 no
approval_rejected medium vulnerability_service.go:2542 no
approval_approved low vulnerability_service.go:2458 no

Someone opened the event-type gate on purpose; the severity gate closed it again. The exact outcome that comment exists to prevent is what happens today.

I swept every enqueue site to check whether this was wider — it is not. sla_breach (high) and finding_priority_escalated (mapped from priority class) survive; new_finding / new_exposure / finding_assigned pass the real finding severity, which is correct. The approval trio is the whole of it.

Why not just bump the constants to "high"

Because that launders a workflow event through a field that does not apply to it, and the next person reading Severity: "high" on an approval request would reasonably conclude a high-severity finding was involved.

The actual root cause is that EnqueueParams.Severity carries two different things:

  • For finding-shaped events it is the finding severity. An operator leaving the filter at its default is saying "only critical and high findings" — honoring that is the entire point of the filter.
  • For approval lifecycle events it is a constant picked by the enqueue site. It describes no finding, and no operator ever asked to suppress it.

So SeverityFilterApplies(EventType) decides per event type whether the severity gate is meaningful. The approval trio is exempt and remains fully controllable through the event-type filter — the switch that actually means "I do not want these".

SendNotificationInput gains EventType so the single-integration path makes the same decision as the broadcast path. Empty is treated as filterable, so every existing caller keeps its current behavior.

Completeness gate

A new event type must now be classified as severity-bearing or not, or the build fails naming it:

event type "sla_breach" is not classified. Decide whether its EnqueueParams.Severity
is a real finding severity (add it to `filterable`) or a constant picked by the enqueue
site (add it to `notFilterable` AND to SeverityFilterApplies). Inheriting the default
silently is how approval_requested became undeliverable.

Verified to fire, not just present — I removed one classification and confirmed it fails and names the offender, then restored it. Inheriting the default silently is exactly how this bug happened.

Verification

  • New tests pin both halves together: the event-type gate passes AND the severity gate no longer drops it, on an empty (default) config. The test asserts up front that "medium" really is excluded by the default severity set, so it cannot pass vacuously.
  • The filter still applies to new_finding, new_exposure, finding_assigned, finding_priority_escalated, sla_breach — a fix that delivers everything is not a fix.
  • GOWORK=off go build ./... — ok
  • GOWORK=off go test ./... against app_test — all green
  • GOWORK=off make lint-ci — clean

Not verified end-to-end: there are no integration_notification_extensions rows on the live database, so I could not observe a real Slack/Teams delivery. This is proven at the domain + dispatch layer, not against a live channel.

All three approval_* notifications are undeliverable on a default
configuration, so a finding awaiting a decision blocks forever and nobody is
told.

Two independent gates run before a notification is delivered:

  ShouldNotifyEventType  empty enabled_event_types -> the platform defaults
  ShouldNotify           empty enabled_severities  -> critical + high only

DefaultEnabledEventTypes deliberately turns EventTypeApprovalRequested ON. The
reasoning is recorded next to it: "An approval request is addressed to a human;
if it reaches nobody the finding stays blocked indefinitely."

The enqueue site then stamps a constant severity — "medium" for requested and
rejected, "low" for approved (finding/vulnerability_service.go:2355, :2458,
:2542). Neither is in the default critical+high set, so the severity gate drops
every one of them. The event-type gate was opened on purpose and the severity
gate closed it again, producing exactly the outcome that comment exists to
prevent.

The root cause is that EnqueueParams.Severity carries two different things. For
new_finding, sla_breach and friends it IS the finding's severity, and an
operator who leaves the filter at its default is saying "only critical and high
findings" — honoring that is the whole point. For approval lifecycle events it
is a constant chosen by the enqueue site that describes no finding at all, and
no operator ever asked to suppress it.

So the fix is not to bump the constants to "high", which would just launder a
workflow event through a field that does not apply to it. SeverityFilterApplies
decides per event type whether the severity gate is meaningful, and the
approval trio is exempt. They remain fully controllable through the event-type
filter, which is the switch that actually means "I do not want these".

SendNotificationInput gains EventType so the single-integration path makes the
same decision as the broadcast path. Empty is treated as filterable, so every
existing caller keeps its current behavior.

Adds a completeness gate: a new event type must be classified as
severity-bearing or not, and the build fails if one is added without that
decision. Verified to fire by removing a classification — it names the offending
event type. Inheriting the default silently is how this bug happened.
@0xmanhnv
0xmanhnv merged commit 91e573f into develop Aug 4, 2026
17 checks passed
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.

1 participant