Exclude dismissed vulnerabilities from the MTTR aggregation

Summary

The backend epic (&23458) states that dismissed vulnerabilities never count as remediated. This is confirmed on the parent epic (&19830):

  • c_0 reviewer, 2026-09-10: "otherwise we risk creating a perverse incentive where teams just dismiss issues to make their numbers look better"
  • PM, 2026-09-14: "Dismissed vulnerabilities. Recommendation: Excluded. Resolved / no longer detected only."

This rule is currently not enforced at any layer. This issue is Phase 2 of the backend epic and is blocked by !256204 (merged), which introduces the aggregation being changed here.

Context

remediated_at is populated at index time as min(resolved_at, undetected_since), with no check on vulnerability state. See earliest_remediation_timestamp in ee/lib/search/elastic/references/vulnerability.rb, and the mirror in ee/lib/search/elastic/references/vulnerabilities/read.rb, merged in !254343 (merged).

undetected_since is set whenever a vulnerability's latest detection transition is not detected. That is independent of the vulnerability's state. See ee/lib/search/elastic/preloaders/vulnerability/undetected_since.rb.

The by_mttr_over_time aggregation filters each weekly bucket on a remediated_at range plus exists: detected_at, and nothing else. MttrOverTimeFinder passes no state filter, and BaseFinder#states returns nil unless params[:state] is given.

Net effect: a vulnerability that is dismissed and later stops being detected gets remediated_at populated, and is counted as a remediation. It inflates count and skews mttr for that week.

A vulnerability that is only dismissed, with no resolved_at and no undetected_since, already has a null remediated_at and is already excluded. The gap is specifically the dismissed-and-no-longer-detected case.

Why the fix is at query time rather than index time:

  • remediated_at is a general-purpose index field that other features may use, so it should keep its literal meaning.
  • A vulnerability's state can change after the document is indexed.
  • The backfill has already run (!254937 (merged), merged 2026-09-24), so changing index-time semantics would mean re-running it.

Tasks

  • Exclude state: dismissed in the weekly bucket filters built by by_mttr_over_time in ee/lib/search/elastic/vulnerability_aggregations.rb, by adding a must_not term clause on the bucket's bool.
  • Use ::Enums::Vulnerability.vulnerability_states[:dismissed] for the value, matching how the same method already derives sizes from ::Enums::Vulnerability.severity_levels and ::Enums::Vulnerability.report_types.
  • Add a unit spec in ee/spec/lib/search/elastic/vulnerability_aggregations_spec.rb asserting the clause is present on every weekly bucket.

Follow-up

  • The finder spec on !257453 (merged) has a dismissed_only fixture that has neither resolved_at nor undetected_since, so it passes trivially and does not exercise this path. When that merge request is rebased, it should cover a dismissed-and-no-longer-detected vulnerability end to end.