@@ -19,21 +19,27 @@ import {
1919 runFindByIdEsqlQuery ,
2020 queryEsql ,
2121 esqlToObjects ,
22+ runFindByIdsEsqlQuery ,
2223} from '../latest_source_query' ;
2324import {
2425 DISCOVERIES_DATA_STREAM ,
2526 type Discovery ,
2627 type StoredDiscovery ,
2728 type discoveriesMappings ,
2829} from './data_stream' ;
30+ import {
31+ FIELD_DISCOVERY_ID ,
32+ FIELD_DISCOVERY_SLUG ,
33+ FIELD_CLOSES_DISCOVERY_ID ,
34+ } from '../field_names' ;
35+
36+ const CLEARED_IDS_CHUNK_SIZE = 250 ;
2937
3038export type DiscoveryDataStreamClient = IDataStreamClient <
3139 typeof discoveriesMappings ,
3240 StoredDiscovery
3341> ;
3442
35- const GROUP_BY_FIELD = 'discovery_slug' ;
36-
3743export class DiscoveryClient {
3844 constructor (
3945 private readonly clients : {
@@ -56,7 +62,7 @@ export class DiscoveryClient {
5662 space : this . clients . space ,
5763 options,
5864 index : DISCOVERIES_DATA_STREAM ,
59- groupBy : GROUP_BY_FIELD ,
65+ groupBy : FIELD_DISCOVERY_ID ,
6066 } ) ;
6167 }
6268
@@ -68,53 +74,106 @@ export class DiscoveryClient {
6874 space : this . clients . space ,
6975 options,
7076 index : DISCOVERIES_DATA_STREAM ,
71- groupBy : GROUP_BY_FIELD ,
77+ groupBy : FIELD_DISCOVERY_ID ,
78+ where : esql . exp `${ esql . col ( 'kind' ) } == ${ esql . str ( 'finding' ) } ` ,
7279 } ) ;
7380
74- if ( result . hits . length === 0 ) return result ;
81+ if ( ! result . hits . length ) return result ;
82+
83+ const clearedIds = await this . getClearedIds (
84+ result . hits . map ( ( h ) => h . discovery_id ) . filter ( ( id ) : id is string => Boolean ( id ) )
85+ ) ;
7586
76- const discoveredAtMap = await this . getDiscoveredAtMap ( options ) ;
7787 return {
7888 ...result ,
7989 hits : result . hits . map ( ( h ) => ( {
8090 ...h ,
81- discovered_at : discoveredAtMap . get ( h . discovery_slug ) ?? h [ '@timestamp' ] ,
91+ kind : clearedIds . has ( h . discovery_id ?? '' ) ? ( 'clearance' as const ) : h . kind ,
8292 } ) ) ,
8393 } ;
8494 }
8595
86- // Returns MIN(@timestamp) of kind: finding documents per discovery_slug for the given time range .
87- // A finding always precedes any clearance for the same slug, so MIN(@timestamp) = first investigation time.
88- private async getDiscoveredAtMap ( options : CommonSearchOptions ) : Promise < Map < string , string > > {
89- try {
90- let query = esql . from ( [ DISCOVERIES_DATA_STREAM ] ) . where ` ${ esql . col ( 'kibana.space_ids' ) } == ${
91- this . clients . space
92- } OR ${ esql . col ( 'kibana.space_ids' ) } IS NULL` ;
96+ // Returns the set of finding IDs that have been cleared .
97+ // Mirrors getProcessedIds in detection_client: a finding is cleared only when the latest
98+ // clearance doc timestamp is on or after the latest finding doc timestamp, so re-opened
99+ // findings (no newer clearance) are not reported as cleared.
100+ // Chunked at CLEARED_IDS_CHUNK_SIZE to match the getProcessedIds IN-clause guard.
101+ private async getClearedIds ( findingIds : string [ ] ) : Promise < Set < string > > {
102+ if ( ! findingIds . length ) return new Set ( ) ;
93103
94- if ( options . from !== undefined ) {
95- query = query . where `@timestamp >= TO_DATETIME(${ esql . str ( options . from ) } )` ;
96- }
97- if ( options . to !== undefined ) {
98- query = query . where `@timestamp <= TO_DATETIME(${ esql . str ( options . to ) } )` ;
104+ const cleared = new Set < string > ( ) ;
105+ for ( let i = 0 ; i < findingIds . length ; i += CLEARED_IDS_CHUNK_SIZE ) {
106+ const batch = findingIds . slice ( i , i + CLEARED_IDS_CHUNK_SIZE ) ;
107+ const idLiterals = batch . map ( ( id ) => esql . str ( id ) ) ;
108+ const kindFinding = esql . str ( 'finding' ) ;
109+ const kindClearance = esql . str ( 'clearance' ) ;
110+ // Use EVAL to normalize both doc types to the same "finding ID" for grouping:
111+ // finding docs: unified_id = discovery_id
112+ // clearance docs: unified_id = closes_discovery_id (references the original finding)
113+ const query = esql `FROM ${ DISCOVERIES_DATA_STREAM }
114+ | WHERE ${ esql . col ( 'kibana.space_ids' ) } == ${ esql . str ( this . clients . space ) } OR ${ esql . col (
115+ 'kibana.space_ids'
116+ ) } IS NULL
117+ | WHERE ${ esql . col ( 'kind' ) } IN (${ [ kindFinding , kindClearance ] } )
118+ | WHERE ${ esql . col ( 'discovery_id' ) } IN (${ idLiterals } ) OR ${ esql . col (
119+ 'closes_discovery_id'
120+ ) } IN (${ idLiterals } )
121+ | EVAL unified_id = CASE(${ esql . col ( 'kind' ) } == ${ kindFinding } , ${ esql . col (
122+ 'discovery_id'
123+ ) } , ${ esql . col ( 'closes_discovery_id' ) } )
124+ | STATS max_finding_ts = MAX(CASE(${ esql . col ( 'kind' ) } == ${ kindFinding } , @timestamp, null)),
125+ max_clearance_ts = MAX(CASE(${ esql . col (
126+ 'kind'
127+ ) } == ${ kindClearance } , @timestamp, null))
128+ BY unified_id
129+ | WHERE max_clearance_ts >= max_finding_ts OR max_finding_ts IS NULL
130+ | WHERE unified_id IS NOT NULL
131+ | KEEP unified_id` ;
132+ const response = await queryEsql ( { esClient : this . clients . esClient , query } ) ;
133+ const rows = esqlToObjects < { unified_id : string } > ( response ) ;
134+ for ( const r of rows ) {
135+ if ( r . unified_id ) cleared . add ( r . unified_id ) ;
99136 }
137+ }
138+ return cleared ;
139+ }
100140
101- query = query . where `${ esql . col ( 'kind' ) } == ${ esql . str ( 'finding' ) } ` ;
102- query = query . pipe `STATS discovered_at = MIN(@timestamp) BY ${ esql . col ( 'discovery_slug' ) } ` ;
141+ async findById ( discoveryId : string ) : Promise < { hits : Discovery [ ] } > {
142+ return runFindByIdEsqlQuery < Discovery > ( {
143+ esClient : this . clients . esClient ,
144+ space : this . clients . space ,
145+ index : DISCOVERIES_DATA_STREAM ,
146+ idField : FIELD_DISCOVERY_ID ,
147+ idValue : discoveryId ,
148+ } ) ;
149+ }
103150
104- const response = await queryEsql ( { esClient : this . clients . esClient , query } ) ;
105- const rows = esqlToObjects < { discovery_slug : string ; discovered_at : string } > ( response ) ;
106- return new Map ( rows . map ( ( r ) => [ r . discovery_slug , r . discovered_at ] ) ) ;
107- } catch ( error ) {
108- return new Map ( ) ;
109- }
151+ async findByIds ( discoveryIds : string [ ] ) : Promise < { hits : Discovery [ ] } > {
152+ return runFindByIdsEsqlQuery < Discovery > ( {
153+ esClient : this . clients . esClient ,
154+ space : this . clients . space ,
155+ index : DISCOVERIES_DATA_STREAM ,
156+ idField : FIELD_DISCOVERY_ID ,
157+ idValues : discoveryIds ,
158+ } ) ;
159+ }
160+
161+ async findByClosesDiscoveryId ( discoveryId : string ) : Promise < { hits : Discovery [ ] } > {
162+ return runFindByIdEsqlQuery < Discovery > ( {
163+ esClient : this . clients . esClient ,
164+ space : this . clients . space ,
165+ index : DISCOVERIES_DATA_STREAM ,
166+ idField : FIELD_CLOSES_DISCOVERY_ID ,
167+ idValue : discoveryId ,
168+ } ) ;
110169 }
111170
112171 async findBySlug ( slug : string ) : Promise < { hits : Discovery [ ] } > {
113172 return runFindByIdEsqlQuery < Discovery > ( {
114173 esClient : this . clients . esClient ,
115174 space : this . clients . space ,
116175 index : DISCOVERIES_DATA_STREAM ,
117- idField : 'discovery_slug' ,
176+ idField : FIELD_DISCOVERY_SLUG ,
118177 idValue : slug ,
119178 } ) ;
120179 }
0 commit comments