Skip to content

Commit

Permalink
[SIEM migrations] Changes comments format from direct string to objec…
Browse files Browse the repository at this point in the history
…t with message, author and date (requires mapping update) (elastic#11538) (elastic#207859)

## Summary

[Internal link](elastic/security-team#10820)
to the feature details

This PR updates `.kibana-siem-rule-migrations-rules-*` schema.

Previously we would store comments as an array of strings which is not
future proof. To make sure that we can handle cases like adding comments
by different users at different time we extended comment to be an
object:

```
{
  message: string;
  created_by: string;
  created_at: data;
}
```

`created_by` field can be either a user profile ID or predefined
constant string `assistant` to indicate comments generated by LLM.

> [!NOTE]  
> This feature needs `siemMigrationsEnabled` experimental flag enabled
to work.
  • Loading branch information
e40pud authored Jan 23, 2025
1 parent 1d36d65 commit ef4a481
Show file tree
Hide file tree
Showing 14 changed files with 161 additions and 44 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

import type { Severity } from '@kbn/securitysolution-io-ts-alerting-types';

export const SIEM_MIGRATIONS_ASSISTANT_USER = 'assistant';

export const SIEM_MIGRATIONS_PATH = '/internal/siem_migrations' as const;
export const SIEM_RULE_MIGRATIONS_PATH = `${SIEM_MIGRATIONS_PATH}/rules` as const;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,11 +153,30 @@ export const RuleMigrationStatus = z.enum(['pending', 'processing', 'completed',
export type RuleMigrationStatusEnum = typeof RuleMigrationStatus.enum;
export const RuleMigrationStatusEnum = RuleMigrationStatus.enum;

/**
* The comment for the migration
*/
export type RuleMigrationComment = z.infer<typeof RuleMigrationComment>;
export const RuleMigrationComment = z.object({
/**
* The comment for the migration
*/
message: z.string(),
/**
* The moment of creation
*/
created_at: z.string(),
/**
* The user profile ID of the user who created the comment or `assistant` if it was generated by the LLM
*/
created_by: z.string(),
});

/**
* The comments for the migration including a summary from the LLM in markdown.
*/
export type RuleMigrationComments = z.infer<typeof RuleMigrationComments>;
export const RuleMigrationComments = z.array(z.string());
export const RuleMigrationComments = z.array(RuleMigrationComment);

/**
* The rule migration document object.
Expand All @@ -173,7 +192,7 @@ export const RuleMigrationData = z.object({
*/
migration_id: NonEmptyString,
/**
* The username of the user who created the migration.
* The user profile ID of the user who created the migration.
*/
created_by: NonEmptyString,
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ components:
description: The migration id.
$ref: '../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString'
created_by:
description: The username of the user who created the migration.
description: The user profile ID of the user who created the migration.
$ref: '../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString'
original_rule:
description: The original rule to migrate.
Expand Down Expand Up @@ -300,11 +300,30 @@ components:
- completed
- failed

RuleMigrationComment:
type: object
description: The comment for the migration
required:
- message
- created_at
- created_by
properties:
message:
type: string
description: The comment for the migration
created_at:
type: string
description: The moment of creation
created_by:
type: string
description: The user profile ID of the user who created the comment or `assistant` if it was generated by the LLM

RuleMigrationComments:
type: array
description: The comments for the migration including a summary from the LLM in markdown.
items:
type: string
description: The comments for the migration
$ref: '#/components/schemas/RuleMigrationComment'

UpdateRuleMigrationData:
type: object
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,35 +10,73 @@ import type { EuiCommentProps } from '@elastic/eui';
import { EuiCommentList, EuiMarkdownFormat, EuiSpacer } from '@elastic/eui';
import moment from 'moment';
import { AssistantAvatar } from '@kbn/ai-assistant-icon';
import { UserAvatar } from '@kbn/user-profile-components';
import { USER_AVATAR_ITEM_TEST_ID } from '../../../../../../common/components/user_profiles/test_ids';
import { useBulkGetUserProfiles } from '../../../../../../common/components/user_profiles/use_bulk_get_user_profiles';
import { type RuleMigration } from '../../../../../../../common/siem_migrations/model/rule_migration.gen';
import { RuleTranslationResult } from '../../../../../../../common/siem_migrations/constants';
import {
RuleTranslationResult,
SIEM_MIGRATIONS_ASSISTANT_USER,
} from '../../../../../../../common/siem_migrations/constants';
import * as i18n from './translations';

interface SummaryTabProps {
ruleMigration: RuleMigration;
}

export const SummaryTab: React.FC<SummaryTabProps> = React.memo(({ ruleMigration }) => {
const timestamp = useMemo(
// Date formats https://momentjs.com/docs/#/displaying/format/
() => moment(ruleMigration['@timestamp']).format('ll'),
[ruleMigration]
);
const userProfileIds = useMemo<Set<string>>(() => {
if (!ruleMigration.comments) {
return new Set();
}
return ruleMigration.comments.reduce((acc, { created_by: createdBy }) => {
if (createdBy !== SIEM_MIGRATIONS_ASSISTANT_USER) acc.add(createdBy);
return acc;
}, new Set<string>());
}, [ruleMigration.comments]);
const { isLoading: isLoadingUserProfiles, data: userProfiles } = useBulkGetUserProfiles({
uids: userProfileIds,
});

const comments: EuiCommentProps[] | undefined = useMemo(() => {
return ruleMigration.comments?.map((comment) => {
return {
username: i18n.ASSISTANT_USERNAME,
timelineAvatarAriaLabel: i18n.ASSISTANT_USERNAME,
timelineAvatar: <AssistantAvatar name="machine" size="l" color="subdued" />,
event:
ruleMigration.translation_result === RuleTranslationResult.UNTRANSLATABLE
? i18n.COMMENT_EVENT_UNTRANSLATABLE
: i18n.COMMENT_EVENT_TRANSLATED,
timestamp,
children: <EuiMarkdownFormat textSize="s">{comment}</EuiMarkdownFormat>,
};
});
}, [ruleMigration, timestamp]);
if (isLoadingUserProfiles) {
return undefined;
}
return ruleMigration.comments?.map(
({ message, created_at: createdAt, created_by: createdBy }) => {
const profile = userProfiles?.find(({ uid }) => uid === createdBy);
const isCreatedByAssistant = createdBy === SIEM_MIGRATIONS_ASSISTANT_USER || !profile;
const username = isCreatedByAssistant
? i18n.ASSISTANT_USERNAME
: profile.user.full_name ?? profile.user.username;
return {
username,
timelineAvatarAriaLabel: username,
timelineAvatar: isCreatedByAssistant ? (
<AssistantAvatar name="machine" size="l" color="subdued" />
) : (
<UserAvatar
data-test-subj={USER_AVATAR_ITEM_TEST_ID(username)}
user={profile?.user}
avatar={profile?.data.avatar}
size={'l'}
/>
),
event:
ruleMigration.translation_result === RuleTranslationResult.UNTRANSLATABLE
? i18n.COMMENT_EVENT_UNTRANSLATABLE
: i18n.COMMENT_EVENT_TRANSLATED,
timestamp: moment(createdAt).format('ll'), // Date formats https://momentjs.com/docs/#/displaying/format/
children: <EuiMarkdownFormat textSize="s">{message}</EuiMarkdownFormat>,
};
}
);
}, [
isLoadingUserProfiles,
ruleMigration.comments,
ruleMigration.translation_result,
userProfiles,
]);

return (
<>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export const StatusBadge: React.FC<StatusBadgeProps> = React.memo(
// Failed
if (migrationRule.status === RuleMigrationStatusEnum.failed) {
const tooltipMessage = migrationRule.comments?.length
? migrationRule.comments[0]
? migrationRule.comments[0].message
: i18n.RULE_STATUS_FAILED;
return (
<EuiToolTip content={tooltipMessage}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@ export const ruleMigrationsFieldMap: FieldMap<SchemaFieldMapKeys<Omit<RuleMigrat
'elastic_rule.prebuilt_rule_id': { type: 'keyword', required: false },
'elastic_rule.id': { type: 'keyword', required: false },
translation_result: { type: 'keyword', required: false },
comments: { type: 'text', array: true, required: false },
comments: { type: 'object', array: true, required: false },
'comments.message': { type: 'keyword', required: true },
'comments.created_at': { type: 'date', required: true },
'comments.created_by': { type: 'keyword', required: true }, // use 'assistant' for llm
updated_at: { type: 'date', required: false },
updated_by: { type: 'keyword', required: false },
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import type { RuleMigrationsRetriever } from '../../../retrievers';
import type { ChatModel } from '../../../util/actions_client_chat';
import type { GraphNode } from '../../types';
import { MATCH_PREBUILT_RULE_PROMPT } from './prompts';
import { cleanMarkdown } from '../../../util/comments';
import { cleanMarkdown, generateAssistantComment } from '../../../util/comments';

interface GetMatchPrebuiltRuleNodeParams {
model: ChatModel;
Expand All @@ -42,7 +42,13 @@ export const getMatchPrebuiltRuleNode = ({
techniqueIds.join(',')
);
if (prebuiltRules.length === 0) {
return { comments: ['## Prebuilt Rule Matching Summary\nNo related prebuilt rule found.'] };
return {
comments: [
generateAssistantComment(
'## Prebuilt Rule Matching Summary\nNo related prebuilt rule found.'
),
],
};
}

const outputParser = new JsonOutputParser();
Expand All @@ -68,7 +74,9 @@ export const getMatchPrebuiltRuleNode = ({
splunk_rule: JSON.stringify(splunkRule, null, 2),
})) as GetMatchedRuleResponse;

const comments = response.summary ? [cleanMarkdown(response.summary)] : undefined;
const comments = response.summary
? [generateAssistantComment(cleanMarkdown(response.summary))]
: undefined;

if (response.match) {
const matchedRule = prebuiltRules.find((r) => r.name === response.match);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { getEsqlKnowledgeBase } from '../../../../../util/esql_knowledge_base_ca
import type { GraphNode } from '../../types';
import { SIEM_RULE_MIGRATION_CIM_ECS_MAP } from './cim_ecs_map';
import { ESQL_TRANSLATE_ECS_MAPPING_PROMPT } from './prompts';
import { cleanMarkdown } from '../../../../../util/comments';
import { cleanMarkdown, generateAssistantComment } from '../../../../../util/comments';

interface GetEcsMappingNodeParams {
inferenceClient: InferenceClient;
Expand Down Expand Up @@ -48,7 +48,7 @@ export const getEcsMappingNode = ({

return {
response,
comments: [cleanMarkdown(ecsSummary)],
comments: [generateAssistantComment(cleanMarkdown(ecsSummary))],
translation_finalized: true,
translation_result: translationResult,
elastic_rule: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type { RuleMigrationsRetriever } from '../../../../../retrievers';
import type { ChatModel } from '../../../../../util/actions_client_chat';
import type { GraphNode } from '../../../../types';
import { REPLACE_QUERY_RESOURCE_PROMPT, getResourcesContext } from './prompts';
import { cleanMarkdown } from '../../../../../util/comments';
import { cleanMarkdown, generateAssistantComment } from '../../../../../util/comments';

interface GetInlineQueryNodeParams {
model: ChatModel;
Expand All @@ -28,7 +28,7 @@ export const getInlineQueryNode = ({
// Check before to avoid unnecessary LLM calls
let unsupportedComment = getUnsupportedComment(query);
if (unsupportedComment) {
return { comments: [unsupportedComment] };
return { comments: [generateAssistantComment(unsupportedComment)] };
}

const resources = await ruleMigrationsRetriever.resources.getResources(state.original_rule);
Expand All @@ -51,10 +51,13 @@ export const getInlineQueryNode = ({
// Check after replacing in case the replacements made it untranslatable
unsupportedComment = getUnsupportedComment(query);
if (unsupportedComment) {
return { comments: [unsupportedComment] };
return { comments: [generateAssistantComment(unsupportedComment)] };
}

return { inline_query: query, comments: [cleanMarkdown(inliningSummary)] };
return {
inline_query: query,
comments: [generateAssistantComment(cleanMarkdown(inliningSummary))],
};
}
return { inline_query: query };
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type { RuleMigrationsRetriever } from '../../../../../retrievers';
import type { ChatModel } from '../../../../../util/actions_client_chat';
import type { GraphNode } from '../../types';
import { MATCH_INTEGRATION_PROMPT } from './prompts';
import { cleanMarkdown } from '../../../../../util/comments';
import { cleanMarkdown, generateAssistantComment } from '../../../../../util/comments';

interface GetRetrieveIntegrationsNodeParams {
model: ChatModel;
Expand All @@ -31,7 +31,13 @@ export const getRetrieveIntegrationsNode = ({

const integrations = await ruleMigrationsRetriever.integrations.getIntegrations(query);
if (integrations.length === 0) {
return { comments: ['## Integration Matching Summary\nNo related integration found.'] };
return {
comments: [
generateAssistantComment(
'## Integration Matching Summary\nNo related integration found.'
),
],
};
}

const outputParser = new JsonOutputParser();
Expand All @@ -55,7 +61,9 @@ export const getRetrieveIntegrationsNode = ({
splunk_rule: JSON.stringify(splunkRule, null, 2),
})) as GetMatchedIntegrationResponse;

const comments = response.summary ? [cleanMarkdown(response.summary)] : undefined;
const comments = response.summary
? [generateAssistantComment(cleanMarkdown(response.summary))]
: undefined;

if (response.match) {
const matchedIntegration = integrations.find((r) => r.title === response.match);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type { InferenceClient } from '@kbn/inference-plugin/server';
import { getEsqlKnowledgeBase } from '../../../../../util/esql_knowledge_base_caller';
import type { GraphNode } from '../../types';
import { ESQL_SYNTAX_TRANSLATION_PROMPT } from './prompts';
import { cleanMarkdown } from '../../../../../util/comments';
import { cleanMarkdown, generateAssistantComment } from '../../../../../util/comments';

interface GetTranslateRuleNodeParams {
inferenceClient: InferenceClient;
Expand Down Expand Up @@ -47,7 +47,7 @@ export const getTranslateRuleNode = ({

return {
response,
comments: [cleanMarkdown(translationSummary)],
comments: [generateAssistantComment(cleanMarkdown(translationSummary))],
elastic_rule: {
integration_ids: [integrationId],
query: esqlQuery,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import type {
RuleMigrationTaskStopResult,
} from './types';
import { ActionsClientChat } from './util/actions_client_chat';
import { generateAssistantComment } from './util/comments';

const ITERATION_BATCH_SIZE = 15 as const;
const ITERATION_SLEEP_SECONDS = 10 as const;
Expand Down Expand Up @@ -146,7 +147,7 @@ export class RuleMigrationsTaskClient {
);
await this.data.rules.saveError({
...ruleMigration,
comments: [`Error migrating rule: ${error.message}`],
comments: [generateAssistantComment(`Error migrating rule: ${error.message}`)],
});
}
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,18 @@
* 2.0.
*/

import { SIEM_MIGRATIONS_ASSISTANT_USER } from '../../../../../../common/siem_migrations/constants';
import type { RuleMigrationComment } from '../../../../../../common/siem_migrations/model/rule_migration.gen';

export const cleanMarkdown = (markdown: string): string => {
// Use languages known by the code block plugin
return markdown.replaceAll('```esql', '```sql').replaceAll('```spl', '```splunk-spl');
};

export const generateAssistantComment = (message: string): RuleMigrationComment => {
return {
message,
created_at: new Date().toISOString(),
created_by: SIEM_MIGRATIONS_ASSISTANT_USER,
};
};
Loading

0 comments on commit ef4a481

Please sign in to comment.