[Security Solution] Extend the /upgrade/_perform API endpoint's contract migrating to Zod (#189790)

Partially addresses (contract change only):
https://github.com/elastic/kibana/issues/166376

Created in favour of: https://github.com/elastic/kibana/pull/189187
(closed)

## Summary

- Extends contract as described in the
[POC](https://github.com/elastic/kibana/pull/144060), migrating from
`io-ts` to Zod (search for `Perform rule upgrade`)
- Uses new types in endpoint, but functionality remains unchaged.

### For maintainers

- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
This commit is contained in:
Juan Pablo Djeredjian 2024-08-05 18:08:58 +02:00 committed by GitHub
parent 986e760756
commit 8d550b0ad2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 395 additions and 98 deletions

View file

@ -5,11 +5,17 @@
* 2.0.
*/
export interface AggregatedPrebuiltRuleError {
message: string;
status_code?: number;
rules: Array<{
rule_id: string;
name?: string;
}>;
}
import { z } from 'zod';
import { RuleName, RuleSignatureId } from '../../model/rule_schema/common_attributes.gen';
export type AggregatedPrebuiltRuleError = z.infer<typeof AggregatedPrebuiltRuleError>;
export const AggregatedPrebuiltRuleError = z.object({
message: z.string(),
status_code: z.number().optional(),
rules: z.array(
z.object({
rule_id: RuleSignatureId,
name: RuleName.optional(),
})
),
});

View file

@ -0,0 +1,204 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { expectParseError, expectParseSuccess, stringifyZodError } from '@kbn/zod-helpers';
import {
PickVersionValues,
RuleUpgradeSpecifier,
UpgradeSpecificRulesRequest,
UpgradeAllRulesRequest,
PerformRuleUpgradeResponseBody,
PerformRuleUpgradeRequestBody,
} from './perform_rule_upgrade_route';
describe('Perform Rule Upgrade Route Schemas', () => {
describe('PickVersionValues', () => {
test('validates correct enum values', () => {
const validValues = ['BASE', 'CURRENT', 'TARGET', 'MERGED'];
validValues.forEach((value) => {
const result = PickVersionValues.safeParse(value);
expectParseSuccess(result);
expect(result.data).toBe(value);
});
});
test('rejects invalid enum values', () => {
const invalidValues = ['RESOLVED', 'MALFORMED_STRING'];
invalidValues.forEach((value) => {
const result = PickVersionValues.safeParse(value);
expectParseError(result);
expect(stringifyZodError(result.error)).toMatchInlineSnapshot(
`"Invalid enum value. Expected 'BASE' | 'CURRENT' | 'TARGET' | 'MERGED', received '${value}'"`
);
});
});
});
describe('RuleUpgradeSpecifier', () => {
const validSpecifier = {
rule_id: 'rule-1',
revision: 1,
version: 1,
pick_version: 'TARGET',
};
test('validates a valid upgrade specifier without fields property', () => {
const result = RuleUpgradeSpecifier.safeParse(validSpecifier);
expectParseSuccess(result);
expect(result.data).toEqual(validSpecifier);
});
test('validates a valid upgrade specifier with a fields property', () => {
const specifierWithFields = {
...validSpecifier,
fields: {
name: {
pick_version: 'CURRENT',
},
},
};
const result = RuleUpgradeSpecifier.safeParse(specifierWithFields);
expectParseSuccess(result);
expect(result.data).toEqual(specifierWithFields);
});
test('rejects upgrade specifier with invalid pick_version rule_id', () => {
const invalid = { ...validSpecifier, rule_id: 123 };
const result = RuleUpgradeSpecifier.safeParse(invalid);
expectParseError(result);
expect(stringifyZodError(result.error)).toMatchInlineSnapshot(
`"rule_id: Expected string, received number"`
);
});
});
describe('UpgradeSpecificRulesRequest', () => {
const validRequest = {
mode: 'SPECIFIC_RULES',
rules: [
{
rule_id: 'rule-1',
revision: 1,
version: 1,
},
],
};
test('validates a correct upgrade specific rules request', () => {
const result = UpgradeSpecificRulesRequest.safeParse(validRequest);
expectParseSuccess(result);
expect(result.data).toEqual(validRequest);
});
test('rejects invalid mode', () => {
const invalid = { ...validRequest, mode: 'INVALID_MODE' };
const result = UpgradeSpecificRulesRequest.safeParse(invalid);
expectParseError(result);
expect(stringifyZodError(result.error)).toMatchInlineSnapshot(
`"mode: Invalid literal value, expected \\"SPECIFIC_RULES\\""`
);
});
test('rejects paylaod with missing rules array', () => {
const invalid = { ...validRequest, rules: undefined };
const result = UpgradeSpecificRulesRequest.safeParse(invalid);
expectParseError(result);
expect(stringifyZodError(result.error)).toMatchInlineSnapshot(`"rules: Required"`);
});
});
describe('UpgradeAllRulesRequest', () => {
const validRequest = {
mode: 'ALL_RULES',
};
test('validates a correct upgrade all rules request', () => {
const result = UpgradeAllRulesRequest.safeParse(validRequest);
expectParseSuccess(result);
expect(result.data).toEqual(validRequest);
});
test('allows optional pick_version', () => {
const withPickVersion = { ...validRequest, pick_version: 'BASE' };
const result = UpgradeAllRulesRequest.safeParse(withPickVersion);
expectParseSuccess(result);
expect(result.data).toEqual(withPickVersion);
});
});
describe('PerformRuleUpgradeRequestBody', () => {
test('validates a correct upgrade specific rules request', () => {
const validRequest = {
mode: 'SPECIFIC_RULES',
pick_version: 'BASE',
rules: [
{
rule_id: 'rule-1',
revision: 1,
version: 1,
},
],
};
const result = PerformRuleUpgradeRequestBody.safeParse(validRequest);
expectParseSuccess(result);
expect(result.data).toEqual(validRequest);
});
test('validates a correct upgrade all rules request', () => {
const validRequest = {
mode: 'ALL_RULES',
pick_version: 'BASE',
};
const result = PerformRuleUpgradeRequestBody.safeParse(validRequest);
expectParseSuccess(result);
expect(result.data).toEqual(validRequest);
});
test('rejects invalid mode', () => {
const invalid = { mode: 'INVALID_MODE' };
const result = PerformRuleUpgradeRequestBody.safeParse(invalid);
expectParseError(result);
expect(stringifyZodError(result.error)).toMatchInlineSnapshot(
`"mode: Invalid discriminator value. Expected 'ALL_RULES' | 'SPECIFIC_RULES'"`
);
});
});
});
describe('PerformRuleUpgradeResponseBody', () => {
const validResponse = {
summary: {
total: 1,
succeeded: 1,
skipped: 0,
failed: 0,
},
results: {
updated: [],
skipped: [],
},
errors: [],
};
test('validates a correct perform rule upgrade response', () => {
const result = PerformRuleUpgradeResponseBody.safeParse(validResponse);
expectParseSuccess(result);
expect(result.data).toEqual(validResponse);
});
test('rejects missing required fields', () => {
const propsToDelete = Object.keys(validResponse);
propsToDelete.forEach((deletedProp) => {
const invalidResponse = Object.fromEntries(
Object.entries(validResponse).filter(([key]) => key !== deletedProp)
);
const result = PerformRuleUpgradeResponseBody.safeParse(invalidResponse);
expectParseError(result);
expect(stringifyZodError(result.error)).toMatchInlineSnapshot(`"${deletedProp}: Required"`);
});
});
});

View file

@ -5,94 +5,176 @@
* 2.0.
*/
import { enumeration } from '@kbn/securitysolution-io-ts-types';
import * as t from 'io-ts';
import { z } from 'zod';
import type { RuleResponse } from '../../model';
import type { AggregatedPrebuiltRuleError } from '../model';
import {
RuleSignatureId,
RuleVersion,
RuleName,
RuleTagArray,
RuleDescription,
Severity,
SeverityMapping,
RiskScore,
RiskScoreMapping,
RuleReferenceArray,
RuleFalsePositiveArray,
ThreatArray,
InvestigationGuide,
SetupGuide,
RelatedIntegrationArray,
RequiredFieldArray,
MaxSignals,
BuildingBlockType,
RuleIntervalFrom,
RuleInterval,
RuleExceptionList,
RuleNameOverride,
TimestampOverride,
TimestampOverrideFallbackDisabled,
TimelineTemplateId,
TimelineTemplateTitle,
IndexPatternArray,
DataViewId,
RuleQuery,
QueryLanguage,
RuleFilterArray,
SavedQueryId,
KqlQueryLanguage,
} from '../../model/rule_schema/common_attributes.gen';
import {
MachineLearningJobId,
AnomalyThreshold,
} from '../../model/rule_schema/specific_attributes/ml_attributes.gen';
import {
ThreatQuery,
ThreatMapping,
ThreatIndex,
ThreatFilters,
ThreatIndicatorPath,
} from '../../model/rule_schema/specific_attributes/threat_match_attributes.gen';
import {
NewTermsFields,
HistoryWindowStart,
} from '../../model/rule_schema/specific_attributes/new_terms_attributes.gen';
import { RuleResponse } from '../../model/rule_schema/rule_schemas.gen';
import { AggregatedPrebuiltRuleError } from '../model';
export enum PickVersionValues {
BASE = 'BASE',
CURRENT = 'CURRENT',
TARGET = 'TARGET',
}
export type PickVersionValues = z.infer<typeof PickVersionValues>;
export const PickVersionValues = z.enum(['BASE', 'CURRENT', 'TARGET', 'MERGED']);
export type PickVersionValuesEnum = typeof PickVersionValues.enum;
export const PickVersionValuesEnum = PickVersionValues.enum;
export const TPickVersionValues = enumeration('PickVersionValues', PickVersionValues);
const createUpgradeFieldSchema = <T extends z.ZodType>(fieldSchema: T) =>
z
.discriminatedUnion('pick_version', [
z.object({
pick_version: PickVersionValues,
}),
z.object({
pick_version: z.literal('RESOLVED'),
resolved_value: fieldSchema,
}),
])
.optional();
export const RuleUpgradeSpecifier = t.exact(
t.intersection([
t.type({
rule_id: t.string,
/**
* This parameter is needed for handling race conditions with Optimistic Concurrency Control.
* Two or more users can call upgrade/_review and upgrade/_perform endpoints concurrently.
* Also, in general the time between these two calls can be anything.
* The idea is to only allow the user to install a rule if the user has reviewed the exact version
* of it that had been returned from the _review endpoint. If the version changed on the BE,
* upgrade/_perform endpoint will return a version mismatch error for this rule.
*/
revision: t.number,
/**
* The target version to upgrade to.
*/
version: t.number,
}),
t.partial({
pick_version: TPickVersionValues,
}),
])
);
export type RuleUpgradeSpecifier = t.TypeOf<typeof RuleUpgradeSpecifier>;
export type RuleUpgradeSpecifier = z.infer<typeof RuleUpgradeSpecifier>;
export const RuleUpgradeSpecifier = z.object({
rule_id: RuleSignatureId,
revision: z.number(),
version: RuleVersion,
pick_version: PickVersionValues.optional(),
// Fields that can be customized during the upgrade workflow
// as decided in: https://github.com/elastic/kibana/issues/186544
fields: z
.object({
name: createUpgradeFieldSchema(RuleName),
tags: createUpgradeFieldSchema(RuleTagArray),
description: createUpgradeFieldSchema(RuleDescription),
severity: createUpgradeFieldSchema(Severity),
severity_mapping: createUpgradeFieldSchema(SeverityMapping),
risk_score: createUpgradeFieldSchema(RiskScore),
risk_score_mapping: createUpgradeFieldSchema(RiskScoreMapping),
references: createUpgradeFieldSchema(RuleReferenceArray),
false_positives: createUpgradeFieldSchema(RuleFalsePositiveArray),
threat: createUpgradeFieldSchema(ThreatArray),
note: createUpgradeFieldSchema(InvestigationGuide),
setup: createUpgradeFieldSchema(SetupGuide),
related_integrations: createUpgradeFieldSchema(RelatedIntegrationArray),
required_fields: createUpgradeFieldSchema(RequiredFieldArray),
max_signals: createUpgradeFieldSchema(MaxSignals),
building_block_type: createUpgradeFieldSchema(BuildingBlockType),
from: createUpgradeFieldSchema(RuleIntervalFrom),
interval: createUpgradeFieldSchema(RuleInterval),
exceptions_list: createUpgradeFieldSchema(RuleExceptionList),
rule_name_override: createUpgradeFieldSchema(RuleNameOverride),
timestamp_override: createUpgradeFieldSchema(TimestampOverride),
timestamp_override_fallback_disabled: createUpgradeFieldSchema(
TimestampOverrideFallbackDisabled
),
timeline_id: createUpgradeFieldSchema(TimelineTemplateId),
timeline_title: createUpgradeFieldSchema(TimelineTemplateTitle),
index: createUpgradeFieldSchema(IndexPatternArray),
data_view_id: createUpgradeFieldSchema(DataViewId),
query: createUpgradeFieldSchema(RuleQuery),
language: createUpgradeFieldSchema(QueryLanguage),
filters: createUpgradeFieldSchema(RuleFilterArray),
saved_id: createUpgradeFieldSchema(SavedQueryId),
machine_learning_job_id: createUpgradeFieldSchema(MachineLearningJobId),
anomaly_threshold: createUpgradeFieldSchema(AnomalyThreshold),
threat_query: createUpgradeFieldSchema(ThreatQuery),
threat_mapping: createUpgradeFieldSchema(ThreatMapping),
threat_index: createUpgradeFieldSchema(ThreatIndex),
threat_filters: createUpgradeFieldSchema(ThreatFilters),
threat_indicator_path: createUpgradeFieldSchema(ThreatIndicatorPath),
threat_language: createUpgradeFieldSchema(KqlQueryLanguage),
new_terms_fields: createUpgradeFieldSchema(NewTermsFields),
history_window_start: createUpgradeFieldSchema(HistoryWindowStart),
})
.optional(),
});
export type UpgradeSpecificRulesRequest = t.TypeOf<typeof UpgradeSpecificRulesRequest>;
export const UpgradeSpecificRulesRequest = t.exact(
t.intersection([
t.type({
mode: t.literal(`SPECIFIC_RULES`),
rules: t.array(RuleUpgradeSpecifier),
}),
t.partial({
pick_version: TPickVersionValues,
}),
])
);
export type UpgradeSpecificRulesRequest = z.infer<typeof UpgradeSpecificRulesRequest>;
export const UpgradeSpecificRulesRequest = z.object({
mode: z.literal('SPECIFIC_RULES'),
rules: z.array(RuleUpgradeSpecifier),
pick_version: PickVersionValues.optional(),
});
export const UpgradeAllRulesRequest = t.exact(
t.intersection([
t.type({
mode: t.literal(`ALL_RULES`),
}),
t.partial({
pick_version: TPickVersionValues,
}),
])
);
export type UpgradeAllRulesRequest = z.infer<typeof UpgradeAllRulesRequest>;
export const UpgradeAllRulesRequest = z.object({
mode: z.literal('ALL_RULES'),
pick_version: PickVersionValues.optional(),
});
export const PerformRuleUpgradeRequestBody = t.union([
export type SkipRuleUpgradeReason = z.infer<typeof SkipRuleUpgradeReason>;
export const SkipRuleUpgradeReason = z.enum(['RULE_UP_TO_DATE']);
export type SkipRuleUpgradeReasonEnum = typeof SkipRuleUpgradeReason.enum;
export const SkipRuleUpgradeReasonEnum = SkipRuleUpgradeReason.enum;
export type SkippedRuleUpgrade = z.infer<typeof SkippedRuleUpgrade>;
export const SkippedRuleUpgrade = z.object({
rule_id: z.string(),
reason: SkipRuleUpgradeReason,
});
export type PerformRuleUpgradeResponseBody = z.infer<typeof PerformRuleUpgradeResponseBody>;
export const PerformRuleUpgradeResponseBody = z.object({
summary: z.object({
total: z.number(),
succeeded: z.number(),
skipped: z.number(),
failed: z.number(),
}),
results: z.object({
updated: z.array(RuleResponse),
skipped: z.array(SkippedRuleUpgrade),
}),
errors: z.array(AggregatedPrebuiltRuleError),
});
export type PerformRuleUpgradeRequestBody = z.infer<typeof PerformRuleUpgradeRequestBody>;
export const PerformRuleUpgradeRequestBody = z.discriminatedUnion('mode', [
UpgradeAllRulesRequest,
UpgradeSpecificRulesRequest,
]);
export type PerformRuleUpgradeRequestBody = t.TypeOf<typeof PerformRuleUpgradeRequestBody>;
export enum SkipRuleUpgradeReason {
RULE_UP_TO_DATE = 'RULE_UP_TO_DATE',
}
export interface SkippedRuleUpgrade {
rule_id: string;
reason: SkipRuleUpgradeReason;
}
export interface PerformRuleUpgradeResponseBody {
summary: {
total: number;
succeeded: number;
skipped: number;
failed: number;
};
results: {
updated: RuleResponse[];
skipped: SkippedRuleUpgrade[];
};
errors: AggregatedPrebuiltRuleError[];
}

View file

@ -6,11 +6,12 @@
*/
import { transformError } from '@kbn/securitysolution-es-utils';
import { buildRouteValidationWithZod } from '@kbn/zod-helpers';
import {
PERFORM_RULE_UPGRADE_URL,
SkipRuleUpgradeReason,
PerformRuleUpgradeRequestBody,
PickVersionValues,
PickVersionValuesEnum,
SkipRuleUpgradeReasonEnum,
} from '../../../../../../common/api/detection_engine/prebuilt_rules';
import type {
PerformRuleUpgradeResponseBody,
@ -18,7 +19,6 @@ import type {
} from '../../../../../../common/api/detection_engine/prebuilt_rules';
import { assertUnreachable } from '../../../../../../common/utility_types';
import type { SecuritySolutionPluginRouter } from '../../../../../types';
import { buildRouteValidation } from '../../../../../utils/build_validation/route_validation';
import type { PromisePoolError } from '../../../../../utils/promise_pool';
import { buildSiemResponse } from '../../../routes/utils';
import { aggregatePrebuiltRuleErrors } from '../../logic/aggregate_prebuilt_rule_errors';
@ -48,7 +48,7 @@ export const performRuleUpgradeRoute = (router: SecuritySolutionPluginRouter) =>
version: '1',
validate: {
request: {
body: buildRouteValidation(PerformRuleUpgradeRequestBody),
body: buildRouteValidationWithZod(PerformRuleUpgradeRequestBody),
},
},
},
@ -63,7 +63,8 @@ export const performRuleUpgradeRoute = (router: SecuritySolutionPluginRouter) =>
const ruleAssetsClient = createPrebuiltRuleAssetsClient(soClient);
const ruleObjectsClient = createPrebuiltRuleObjectsClient(rulesClient);
const { mode, pick_version: globalPickVersion = PickVersionValues.TARGET } = request.body;
const { mode, pick_version: globalPickVersion = PickVersionValuesEnum.TARGET } =
request.body;
const fetchErrors: Array<PromisePoolError<{ rule_id: string }>> = [];
const targetRules: PrebuiltRuleAsset[] = [];
@ -105,7 +106,7 @@ export const performRuleUpgradeRoute = (router: SecuritySolutionPluginRouter) =>
if (!upgradeableRuleIds.has(rule.rule_id)) {
skippedRules.push({
rule_id: rule.rule_id,
reason: SkipRuleUpgradeReason.RULE_UP_TO_DATE,
reason: SkipRuleUpgradeReasonEnum.RULE_UP_TO_DATE,
});
return;
}
@ -132,7 +133,7 @@ export const performRuleUpgradeRoute = (router: SecuritySolutionPluginRouter) =>
const rulePickVersion =
versionSpecifiersMap?.get(current.rule_id)?.pick_version ?? globalPickVersion;
switch (rulePickVersion) {
case PickVersionValues.BASE:
case PickVersionValuesEnum.BASE:
const baseVersion = ruleVersionsMap.get(current.rule_id)?.base;
if (baseVersion) {
targetRules.push({ ...baseVersion, version: target.version });
@ -143,10 +144,14 @@ export const performRuleUpgradeRoute = (router: SecuritySolutionPluginRouter) =>
});
}
break;
case PickVersionValues.CURRENT:
case PickVersionValuesEnum.CURRENT:
targetRules.push({ ...current, version: target.version });
break;
case PickVersionValues.TARGET:
case PickVersionValuesEnum.TARGET:
targetRules.push(target);
break;
case PickVersionValuesEnum.MERGED:
// TODO: Implement functionality to handle MERGED
targetRules.push(target);
break;
default: