Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,53 @@ describe('createRuleDataSchema', () => {
});
});

describe('recovery_policy', () => {
it('accepts recovery_policy with type "no_breach"', () => {
const result = createRuleDataSchema.safeParse({
...validCreateData,
recovery_policy: { type: 'no_breach' },
});
expect(result.success).toBe(true);
});

it('accepts recovery_policy with type "query" when query.base is provided', () => {
const result = createRuleDataSchema.safeParse({
...validCreateData,
recovery_policy: {
type: 'query',
query: { base: 'FROM logs-* | LIMIT 1' },
},
});
expect(result.success).toBe(true);
});

it('rejects recovery_policy with type "query" when query is missing', () => {
const result = createRuleDataSchema.safeParse({
...validCreateData,
recovery_policy: { type: 'query' },
});
expect(result.success).toBe(false);
expect(result.error?.issues[0].path).toEqual(['recovery_policy', 'query', 'base']);
});

it('rejects recovery_policy with type "query" when query.base is missing', () => {
const result = createRuleDataSchema.safeParse({
...validCreateData,
recovery_policy: { type: 'query', query: {} },
});
expect(result.success).toBe(false);
expect(result.error?.issues[0].path).toEqual(['recovery_policy', 'query', 'base']);
});

it('rejects recovery_policy with type "query" when query.base is empty', () => {
const result = createRuleDataSchema.safeParse({
...validCreateData,
recovery_policy: { type: 'query', query: { base: '' } },
});
expect(result.success).toBe(false);
});
});

describe('required fields', () => {
it.each(['kind', 'metadata', 'schedule', 'evaluation'] as const)(
'rejects when required field "%s" is missing',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,15 +80,16 @@ const evaluationSchema = z

/** Recovery policy (optional) */

export const recoveryPolicyTypeSchema = z.enum(['query', 'no_breach']);
export const recoveryPolicyType = recoveryPolicyTypeSchema.enum;
export type RecoveryPolicyType = z.infer<typeof recoveryPolicyTypeSchema>;

const recoveryPolicySchema = z
.object({
type: z.enum(['query', 'no_breach']).describe('Recovery detection type.'),
type: recoveryPolicyTypeSchema.describe('Recovery detection type.'),
query: z
.object({
base: esqlQuerySchema
.optional()
.describe('Base ES|QL query for recovery (or reference to evaluation.query.base).'),
condition: z.string().max(5000).optional().describe('Recovery condition (WHERE clause).'),
base: esqlQuerySchema.optional().describe('Base ES|QL query for recovery.'),
})
.strict()
.optional()
Expand Down Expand Up @@ -196,7 +197,16 @@ export const createRuleDataSchema = z
.refine((data) => !data.no_data || data.evaluation.query.condition != null, {
message: 'evaluation.query.condition is required when no_data is configured.',
path: ['evaluation', 'query', 'condition'],
});
})
.refine(
(data) =>
data.recovery_policy?.type !== 'query' ||
(data.recovery_policy.query?.base != null && data.recovery_policy.query.base.length > 0),
{
message: 'recovery_policy.query.base is required when recovery_policy.type is "query".',
path: ['recovery_policy', 'query', 'base'],
}
);

export type CreateRuleData = z.infer<typeof createRuleDataSchema>;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ export interface RuleResponse {
type: 'query' | 'no_breach';
query?: {
base?: string;
condition?: string;
};
};
state_transition?: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@
*/

import type { EsqlEsqlResult } from '@elastic/elasticsearch/lib/api/types';
import { buildAlertEventsFromEsqlResponse } from './build_alert_events';
import {
buildAlertEventsFromEsqlResponse,
buildRecoveryAlertEvents,
buildQueryRecoveryAlertEvents,
} from './build_alert_events';

describe('buildAlertEventsFromEsqlResponse', () => {
beforeAll(() => {
Expand Down Expand Up @@ -61,3 +65,206 @@ describe('buildAlertEventsFromEsqlResponse', () => {
expect(doc1.group_hash).not.toEqual(doc2.group_hash);
});
});

describe('buildRecoveryAlertEvents', () => {
beforeAll(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2025-01-01T00:00:00.000Z'));
});

afterAll(() => {
jest.useRealTimers();
});

it('creates recovered events for active groups not in the breached set', () => {
const events = buildRecoveryAlertEvents({
ruleId: 'rule-123',
ruleVersion: 1,
activeGroupHashes: [{ group_hash: 'hash-a' }, { group_hash: 'hash-b' }],
breachedGroupHashes: new Set(['hash-a']),
scheduledTimestamp: '2024-12-31T23:59:00.000Z',
});

expect(events).toHaveLength(1);
expect(events[0]).toEqual({
'@timestamp': '2025-01-01T00:00:00.000Z',
scheduled_timestamp: '2024-12-31T23:59:00.000Z',
rule: { id: 'rule-123', version: 1 },
group_hash: 'hash-b',
data: {},
status: 'recovered',
source: 'internal',
type: 'signal',
});
});

it('returns empty array when all active groups are still breaching', () => {
const events = buildRecoveryAlertEvents({
ruleId: 'rule-123',
ruleVersion: 1,
activeGroupHashes: [{ group_hash: 'hash-a' }],
breachedGroupHashes: new Set(['hash-a']),
scheduledTimestamp: '2024-12-31T23:59:00.000Z',
});

expect(events).toEqual([]);
});

it('returns recovered events for all active groups when none are breaching', () => {
const events = buildRecoveryAlertEvents({
ruleId: 'rule-123',
ruleVersion: 1,
activeGroupHashes: [{ group_hash: 'hash-a' }, { group_hash: 'hash-b' }],
breachedGroupHashes: new Set(),
scheduledTimestamp: '2024-12-31T23:59:00.000Z',
});

expect(events).toHaveLength(2);
expect(events.map((e) => e.group_hash)).toEqual(['hash-a', 'hash-b']);
expect(events.every((e) => e.status === 'recovered')).toBe(true);
});

it('returns empty array when there are no active groups', () => {
const events = buildRecoveryAlertEvents({
ruleId: 'rule-123',
ruleVersion: 1,
activeGroupHashes: [],
breachedGroupHashes: new Set(['hash-a']),
scheduledTimestamp: '2024-12-31T23:59:00.000Z',
});

expect(events).toEqual([]);
});
});

describe('buildQueryRecoveryAlertEvents', () => {
beforeAll(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2025-01-01T00:00:00.000Z'));
});

afterAll(() => {
jest.useRealTimers();
});

it('creates recovered events for active groups matching the recovery query', () => {
const esqlResponse: EsqlEsqlResult = {
columns: [
{ name: 'host.name', type: 'keyword' },
{ name: 'status', type: 'keyword' },
],
values: [['host-a', 'ok']],
};

// Build a breached event first to know the expected group_hash
const breachedEvents = buildAlertEventsFromEsqlResponse({
ruleId: 'rule-123',
ruleVersion: 1,
spaceId: 'default',
ruleAttributes: { grouping: { fields: ['host.name'] } },
esqlResponse: {
columns: [{ name: 'host.name', type: 'keyword' }],
values: [['host-a']],
},
scheduledTimestamp: '2024-12-31T23:59:00.000Z',
});

const activeGroupHash = breachedEvents[0].group_hash;

const events = buildQueryRecoveryAlertEvents({
ruleId: 'rule-123',
ruleVersion: 1,
spaceId: 'default',
ruleAttributes: { grouping: { fields: ['host.name'] } },
activeGroupHashes: [{ group_hash: activeGroupHash }],
esqlResponse,
scheduledTimestamp: '2024-12-31T23:59:00.000Z',
});

expect(events).toHaveLength(1);
expect(events[0]).toEqual({
'@timestamp': '2025-01-01T00:00:00.000Z',
scheduled_timestamp: '2024-12-31T23:59:00.000Z',
rule: { id: 'rule-123', version: 1 },
group_hash: activeGroupHash,
data: { 'host.name': 'host-a', status: 'ok' },
status: 'recovered',
source: 'internal',
type: 'signal',
});
});

it('returns empty array when recovery query returns no rows', () => {
const events = buildQueryRecoveryAlertEvents({
ruleId: 'rule-123',
ruleVersion: 1,
spaceId: 'default',
ruleAttributes: { grouping: { fields: ['host.name'] } },
activeGroupHashes: [{ group_hash: 'hash-a' }],
esqlResponse: { columns: [], values: [] },
scheduledTimestamp: '2024-12-31T23:59:00.000Z',
});

expect(events).toEqual([]);
});

it('ignores recovery query rows that do not match any active group', () => {
const esqlResponse: EsqlEsqlResult = {
columns: [{ name: 'host.name', type: 'keyword' }],
values: [['host-unknown']],
};

const events = buildQueryRecoveryAlertEvents({
ruleId: 'rule-123',
ruleVersion: 1,
spaceId: 'default',
ruleAttributes: { grouping: { fields: ['host.name'] } },
activeGroupHashes: [{ group_hash: 'hash-not-matching' }],
esqlResponse,
scheduledTimestamp: '2024-12-31T23:59:00.000Z',
});

expect(events).toEqual([]);
});

it('deduplicates when multiple recovery rows produce the same group hash', () => {
const esqlResponse: EsqlEsqlResult = {
columns: [
{ name: 'host.name', type: 'keyword' },
{ name: 'msg', type: 'keyword' },
],
values: [
['host-a', 'recovered-1'],
['host-a', 'recovered-2'],
],
};

const breachedEvents = buildAlertEventsFromEsqlResponse({
ruleId: 'rule-123',
ruleVersion: 1,
spaceId: 'default',
ruleAttributes: { grouping: { fields: ['host.name'] } },
esqlResponse: {
columns: [{ name: 'host.name', type: 'keyword' }],
values: [['host-a']],
},
scheduledTimestamp: '2024-12-31T23:59:00.000Z',
});

const activeGroupHash = breachedEvents[0].group_hash;

const events = buildQueryRecoveryAlertEvents({
ruleId: 'rule-123',
ruleVersion: 1,
spaceId: 'default',
ruleAttributes: { grouping: { fields: ['host.name'] } },
activeGroupHashes: [{ group_hash: activeGroupHash }],
esqlResponse,
scheduledTimestamp: '2024-12-31T23:59:00.000Z',
});

expect(events).toHaveLength(1);
expect(events[0].group_hash).toBe(activeGroupHash);
expect(events[0].data).toEqual({ 'host.name': 'host-a', msg: 'recovered-1' });
});
});
Loading