Skip to content

Commit 11c5917

Browse files
vitaliidmkibanamachinecursoragent
authored
[Security Solution][Detections] Add rule type specific fields in agent builder inline attachment (#264669)
## Summary Renders rule-type-specific fields in the Agent Builder inline attachment card so users can review the definition-level details the AI agent produced for each rule type. ### Changes - **Threshold rules**: threshold field(s), value (`>= N`), cardinality (reused `Threshold` component) - **Indicator match rules**: indicator index patterns (reused `ThreatIndex`), indicator index query (copyable), indicator mapping (reused `constructThreatMappingDescription`) - **Machine learning rules**: ML job ID(s), anomaly score threshold - **New terms rules**: fields (reused `NewTermsFields`), history window size - **EQL rules**: event category override, tiebreaker field, timestamp field - **ES|QL rules**: ES|QL query display with syntax highlighting - **Saved query rules**: saved query name display - **Filters**: renders rule filters as badges with support for phrase, exists, range, negated, and aliased filters - **Copyable queries**: all query code blocks (main query and threat query) are copyable - **Aligned labels**: uses exact labels from the rule details page — "Custom query", "EQL query", "ES|QL query", "Saved query", "Index patterns" - **Consistent font size**: all field labels and values use the same `size="s"` styling - **Refactored structure**: split into focused files under `attachment_types/rule/` (rule_attachment, rule_type_details, filters_display) ### Approach - `MachineLearningJobList` intentionally not reused (depends on `useSecurityJobs()` hook / ML context unavailable in Agent Builder sidebar) - Filters rendered as lightweight badges via `getFilterLabel()` utility (existing `Filters` component requires `useDataView` hook and data plugin services not available in Agent Builder sidebar) - Added `@elastic/security-detection-engine` as CODEOWNERS for the `rule/` folder ### Testing 97 unit tests across 3 suites covering all rule types, edge cases, `getFilterLabel` (12 tests), `FiltersDisplay` (5 tests), and integration tests. ## Screenshots ### Threshold rule ![Threshold rule](https://raw.githubusercontent.com/vitaliidm/kibana/screenshots-264669/.github/screenshots/agent_builder_threshold.png) ### EQL rule ![EQL rule](https://raw.githubusercontent.com/vitaliidm/kibana/screenshots-264669/.github/screenshots/agent_builder_eql.png) ### ES|QL rule ![ES|QL rule](https://raw.githubusercontent.com/vitaliidm/kibana/screenshots-264669/.github/screenshots/agent_builder_esql.png) ### New Terms rule ![New Terms rule](https://raw.githubusercontent.com/vitaliidm/kibana/screenshots-264669/.github/screenshots/agent_builder_new_terms.png) ### Indicator Match rule ![Indicator Match rule](https://raw.githubusercontent.com/vitaliidm/kibana/screenshots-264669/.github/screenshots/agent_builder_threat_match.png) ### Machine Learning rule ![Machine Learning rule](https://raw.githubusercontent.com/vitaliidm/kibana/screenshots-264669/.github/screenshots/agent_builder_ml.png) ### Saved Query rule ![Saved Query rule](https://raw.githubusercontent.com/vitaliidm/kibana/screenshots-264669/.github/screenshots/agent_builder_saved_query.png) ## References Closes elastic/security-team#16598 Made with [Cursor](https://cursor.com) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 0f0ce5d commit 11c5917

9 files changed

Lines changed: 1824 additions & 101 deletions

File tree

.github/CODEOWNERS

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2791,6 +2791,8 @@ src/platform/packages/shared/kbn-connector-schemas/thehive @elastic/kibana-cases
27912791
/x-pack/solutions/security/test/security_solution_cypress/cypress/support @elastic/security-detection-engine @elastic/security-detection-rule-management @elastic/security-threat-hunting
27922792
/x-pack/solutions/security/test/security_solution_cypress/cypress/urls @elastic/security-threat-hunting @elastic/security-detection-engine
27932793

2794+
/x-pack/solutions/security/plugins/security_solution/public/agent_builder/attachment_types/rule @elastic/security-threat-hunting @elastic/security-generative-ai @elastic/security-detection-engine
2795+
27942796
/x-pack/solutions/security/plugins/security_solution/common/test @elastic/security-detection-engine @elastic/security-detection-rule-management @elastic/security-threat-hunting
27952797

27962798
/x-pack/solutions/security/plugins/security_solution/public/common/components/callouts @elastic/security-detection-rule-management

x-pack/solutions/security/plugins/security_solution/public/agent_builder/attachment_types/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ export const registerRuleAttachment = ({
138138
}): void => {
139139
void import(
140140
/* webpackChunkName: "security_rule_attachment" */
141-
'./rule_attachment'
141+
'./rule'
142142
).then(({ registerRuleAttachment: register }) => {
143143
register({ attachments, application, aiRuleCreation, uiSettings });
144144
});
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import React from 'react';
9+
import { render, screen } from '@testing-library/react';
10+
import type { Filter } from '@kbn/es-query';
11+
import { FiltersDisplay, getFilterLabel } from './filters_display';
12+
13+
describe('getFilterLabel', () => {
14+
describe('alias handling', () => {
15+
it('ignores key and type when alias is present', () => {
16+
const filter: Filter = {
17+
meta: { alias: 'Alias wins', key: 'host.name', type: 'phrase', value: 'ignored' },
18+
};
19+
expect(getFilterLabel(filter)).toBe('Alias wins');
20+
});
21+
});
22+
23+
describe('phrase type', () => {
24+
it('handles numeric params.query', () => {
25+
const filter: Filter = {
26+
meta: { key: 'http.response.status_code', type: 'phrase', params: { query: 404 } },
27+
};
28+
expect(getFilterLabel(filter)).toBe('http.response.status_code: 404');
29+
});
30+
31+
it('handles boolean params.query', () => {
32+
const filter: Filter = {
33+
meta: { key: 'event.ingested', type: 'phrase', params: { query: true } },
34+
};
35+
expect(getFilterLabel(filter)).toBe('event.ingested: true');
36+
});
37+
});
38+
39+
describe('phrases type', () => {
40+
it('prepends NOT for negated phrases filter', () => {
41+
const filter: Filter = {
42+
meta: { key: 'status', negate: true, type: 'phrases', params: ['active', 'pending'] },
43+
};
44+
expect(getFilterLabel(filter)).toBe('NOT status: active, pending');
45+
});
46+
});
47+
48+
describe('range type', () => {
49+
it('formats open-ended upper bound with lte only', () => {
50+
const filter: Filter = {
51+
meta: { key: 'risk_score', type: 'range', params: { lte: 100 } },
52+
};
53+
expect(getFilterLabel(filter)).toBe('risk_score: <= 100');
54+
});
55+
56+
it('formats open-ended upper bound with lt only', () => {
57+
const filter: Filter = {
58+
meta: { key: 'risk_score', type: 'range', params: { lt: 100 } },
59+
};
60+
expect(getFilterLabel(filter)).toBe('risk_score: < 100');
61+
});
62+
63+
it('prefers lte over lt when both are present', () => {
64+
const filter: Filter = {
65+
meta: { key: 'bytes', type: 'range', params: { lte: 500, lt: 501 } },
66+
};
67+
expect(getFilterLabel(filter)).toBe('bytes: <= 500');
68+
});
69+
70+
it('prepends NOT for negated range filter', () => {
71+
const filter: Filter = {
72+
meta: { key: 'bytes', negate: true, type: 'range', params: { gte: 100, lte: 500 } },
73+
};
74+
expect(getFilterLabel(filter)).toBe('NOT bytes: >= 100 AND <= 500');
75+
});
76+
});
77+
78+
describe('missing key fallback', () => {
79+
it('prepends NOT for negated filter with no key', () => {
80+
const filter: Filter = { meta: { negate: true }, query: { match_all: {} } };
81+
expect(getFilterLabel(filter)).toBe('NOT {"match_all":{}}');
82+
});
83+
});
84+
85+
describe('unknown/custom type fallback', () => {
86+
it('shows key with value from meta.value', () => {
87+
const filter: Filter = {
88+
meta: { key: 'event.action', type: 'custom', value: 'login' },
89+
};
90+
expect(getFilterLabel(filter)).toBe('event.action: login');
91+
});
92+
});
93+
});
94+
95+
describe('FiltersDisplay', () => {
96+
it('renders nothing when all filters are invalid (no meta property)', () => {
97+
const filters = [{ something: 'else' }, { query: { match_all: {} } }];
98+
const { container } = render(<FiltersDisplay filters={filters} />);
99+
expect(container).toBeEmptyDOMElement();
100+
});
101+
102+
it('renders range filter in badge', () => {
103+
const filters = [{ meta: { key: 'bytes', type: 'range', params: { gte: 100, lte: 500 } } }];
104+
105+
render(<FiltersDisplay filters={filters} />);
106+
107+
expect(screen.getByText('bytes: >= 100 AND <= 500')).toBeInTheDocument();
108+
});
109+
});
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import React from 'react';
9+
import { i18n } from '@kbn/i18n';
10+
import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui';
11+
import type { Filter } from '@kbn/es-query';
12+
13+
const FILTERS_LABEL = i18n.translate(
14+
'xpack.securitySolution.detectionEngine.createRule.filtersLabel',
15+
{ defaultMessage: 'Filters' }
16+
);
17+
const SectionHeading: React.FC<{ children: React.ReactNode }> = ({ children }) => (
18+
<EuiText size="s">
19+
<strong>{children}</strong>
20+
</EuiText>
21+
);
22+
23+
const formatRangeFilter = (key: string, params: Record<string, unknown>): string => {
24+
const parts: string[] = [];
25+
if (params.gte !== undefined) {
26+
parts.push(`>= ${params.gte}`);
27+
} else if (params.gt !== undefined) {
28+
parts.push(`> ${params.gt}`);
29+
}
30+
if (params.lte !== undefined) {
31+
parts.push(`<= ${params.lte}`);
32+
} else if (params.lt !== undefined) {
33+
parts.push(`< ${params.lt}`);
34+
}
35+
return `${key}: ${parts.join(' AND ')}`;
36+
};
37+
38+
const resolveParamValue = (params: Filter['meta']['params']): string => {
39+
if (params == null) return '';
40+
if (typeof params === 'string' || typeof params === 'number' || typeof params === 'boolean') {
41+
return String(params);
42+
}
43+
if (Array.isArray(params)) return params.join(', ');
44+
if (typeof params === 'object' && 'query' in params) return String(params.query);
45+
return JSON.stringify(params);
46+
};
47+
48+
const formatPhraseFilter = (
49+
key: string,
50+
value: Filter['meta']['value'],
51+
params: Filter['meta']['params']
52+
): string => {
53+
const display = typeof value === 'string' ? value : resolveParamValue(params);
54+
return `${key}: ${display}`;
55+
};
56+
57+
export const getFilterLabel = (filter: Filter): string => {
58+
if (filter.meta?.alias) {
59+
return filter.meta.alias;
60+
}
61+
const { key, negate, type, params, value } = filter.meta ?? {};
62+
const prefix = negate ? 'NOT ' : '';
63+
64+
if (!key) {
65+
return `${prefix}${JSON.stringify(filter.query ?? filter)}`;
66+
}
67+
68+
if (type === 'exists') {
69+
return `${prefix}${key}: exists`;
70+
}
71+
72+
if (type === 'phrase' || type === 'phrases') {
73+
return `${prefix}${formatPhraseFilter(key, value, params)}`;
74+
}
75+
76+
if (type === 'range' && params && typeof params === 'object' && !Array.isArray(params)) {
77+
return `${prefix}${formatRangeFilter(key, params as Record<string, unknown>)}`;
78+
}
79+
80+
const displayValue = typeof value === 'string' ? value : resolveParamValue(params);
81+
return displayValue ? `${prefix}${key}: ${displayValue}` : `${prefix}${key}`;
82+
};
83+
84+
export const FiltersDisplay: React.FC<{ filters: unknown[] }> = ({ filters }) => {
85+
const validFilters = filters.filter(
86+
(f): f is Filter => f != null && typeof f === 'object' && 'meta' in f
87+
);
88+
if (validFilters.length === 0) {
89+
return null;
90+
}
91+
92+
return (
93+
<>
94+
<SectionHeading>{FILTERS_LABEL}</SectionHeading>
95+
<EuiSpacer size="xs" />
96+
<EuiFlexGroup responsive={false} gutterSize="xs" wrap>
97+
{validFilters.map((filter, idx) => (
98+
<EuiFlexItem grow={false} key={idx}>
99+
<EuiBadge color="hollow">{getFilterLabel(filter)}</EuiBadge>
100+
</EuiFlexItem>
101+
))}
102+
</EuiFlexGroup>
103+
</>
104+
);
105+
};
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
export { registerRuleAttachment } from './rule_attachment';

x-pack/solutions/security/plugins/security_solution/public/agent_builder/attachment_types/rule_attachment.test.ts renamed to x-pack/solutions/security/plugins/security_solution/public/agent_builder/attachment_types/rule/rule_attachment.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,13 @@ import type { AttachmentServiceStartContract } from '@kbn/agent-builder-browser/
1111
import { ActionButtonType } from '@kbn/agent-builder-browser/attachments';
1212
import { ENABLE_ESQL } from '@kbn/esql-utils';
1313
import { RULES_FEATURE_LATEST } from '@kbn/security-solution-features/constants';
14-
import { AiRuleCreationService } from '../../detection_engine/common/ai_rule_creation_store';
14+
import { AiRuleCreationService } from '../../../detection_engine/common/ai_rule_creation_store';
1515
import {
1616
createRuleAttachmentDefinition,
1717
isOnRuleFormPage,
1818
registerRuleAttachment,
1919
} from './rule_attachment';
20-
import { SecurityAgentBuilderAttachments } from '../../../common/constants';
20+
import { SecurityAgentBuilderAttachments } from '../../../../common/constants';
2121

2222
const validRule = {
2323
name: 'Test Rule',
@@ -177,7 +177,7 @@ describe('createRuleAttachmentDefinition', () => {
177177
expect(buttons).toEqual([]);
178178
});
179179

180-
it('returns empty array when parsed rule has no name', () => {
180+
it('returns action buttons for rule without name', () => {
181181
const application = makeApplication(true);
182182
const definition = createRuleAttachmentDefinition({
183183
application,
@@ -187,7 +187,7 @@ describe('createRuleAttachmentDefinition', () => {
187187
const buttons = definition.getActionButtons!(
188188
makeActionButtonsParams(JSON.stringify({ type: 'query' })) as never
189189
);
190-
expect(buttons).toEqual([]);
190+
expect(buttons).toHaveLength(1);
191191
});
192192

193193
it('returns empty array when user lacks edit capabilities', () => {

0 commit comments

Comments
 (0)