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_atis 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: dismissedin the weekly bucket filters built byby_mttr_over_timeinee/lib/search/elastic/vulnerability_aggregations.rb, by adding amust_notterm clause on the bucket'sbool. - Use
::Enums::Vulnerability.vulnerability_states[:dismissed]for the value, matching how the same method already derives sizes from::Enums::Vulnerability.severity_levelsand::Enums::Vulnerability.report_types. - Add a unit spec in
ee/spec/lib/search/elastic/vulnerability_aggregations_spec.rbasserting the clause is present on every weekly bucket.
Follow-up
- The finder spec on !257453 (merged) has a
dismissed_onlyfixture that has neitherresolved_atnorundetected_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.