Skip to content

Commit

Permalink
[Security Solution] Swap rule unions out for discriminated unions to …
Browse files Browse the repository at this point in the history
…improve validation error messages (#171452)

**Epics:** elastic/security-team#8058,
elastic/security-team#6726 (internal)
**Partially addresses:**
elastic/security-team#7991 (internal)

## Summary

The main benefit of this PR is shown in `rule_request_schema.test.ts`,
where the error messages are now more accurate and concise. With regular
unions, `zod` has to try validating the input against all schemas in the
union and reports the errors from every schema in the union. Switching
to discriminated unions, with `type` as the discriminator, allows `zod`
to pick the right rule type schema from the union and only validate
against that rule type. This means the error message reports that either
the discriminator is invalid, in any case where `type` is not valid, or
if `type` is valid but another field is wrong for that type of rule then
the error message is the validation result from only that rule type.

To make it possible to use discriminated unions, we need to switch from
using zod's `.and()` for intersections to `.merge()` because `.and()`
returns an intersection type that is incompatible with discriminated
unions in zod. Similarly, we need to remove the usage of `.transform()`
because it returns a ZodEffect that is incompatible with `.merge()`.

Instead of using `.transform()` to turn properties from optional to
possibly undefined, we can use `requiredOptional` explicitly in specific
places to convert the types. Similarly, the `RequiredOptional` type can
be used on the return type of conversion functions between API and
internal schemas to enforce that all properties are explicitly specified
in the conversion.

Future work:
- better alignment of codegen with OpenAPI definitions of anyOf/oneOf.
https://swagger.io/docs/specification/data-models/oneof-anyof-allof-not/#oneof
oneOf requires that the input match exactly one schema from the list,
which is different from z.union. anyOf should be z.union, oneOf should
be z.discriminatedUnion
- flatten the schema structure further to avoid `Type instantiation is
excessively deep and possibly infinite`. Seems to be a common issue with
zod (microsoft/TypeScript#34933) Limiting the
number of `.merge` and other zod operations needed to build a particular
schema object seems to help resolve the error. Combining
`ResponseRequiredFields` and `ResponseOptionalFields` into a single
object rather than merging them solved the immediate problem. However,
we may still be near the depth limit. Changing `RuleResponse` as seen
below also solved the problem in testing, and may give us more headroom
for future changes if we apply techniques like this here and in other
places. The difference here is that `SharedResponseProps` is only
intersected with the type specific schemas after they're combined in a
discriminated union, whereas in `main` we merge `SharedResponseProps`
with each individual schema then merge them all together.
- combine other Required and Optional schemas, like
QueryRuleRequiredFields and QueryRuleOptionalFields

```ts
export type RuleResponse = z.infer<typeof RuleResponse>;
export const RuleResponse = SharedResponseProps.and(z.discriminatedUnion('type', [
  EqlRuleResponseFields,
  QueryRuleResponseFields,
  SavedQueryRuleResponseFields,
  ThresholdRuleResponseFields,
  ThreatMatchRuleResponseFields,
  MachineLearningRuleResponseFields,
  NewTermsRuleResponseFields,
  EsqlRuleResponseFields,
]));
```

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
  • Loading branch information
marshallmain and kibanamachine authored Nov 29, 2023
1 parent 12a8e8b commit 6073eb6
Show file tree
Hide file tree
Showing 35 changed files with 272 additions and 333 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -12,34 +12,41 @@
{{~#if (defined default)}}.default({{{toJSON default}}}){{/if~}}
{{~#if (eq x-modify "partial")}}.partial(){{/if~}}
{{~#if (eq x-modify "required")}}.required(){{/if~}}
{{~#if (eq x-modify "requiredOptional")}}.transform(requiredOptional){{/if~}}
{{~/if~}}

{{~#if allOf~}}
{{~#each allOf~}}
{{~#if @first~}}
{{> zod_schema_item }}
{{~else~}}
.and({{> zod_schema_item }})
.merge({{> zod_schema_item }})
{{~/if~}}
{{~/each~}}
{{~/if~}}

{{~#if anyOf~}}
z.union([
{{~#each anyOf~}}
{{#if discriminator}}
z.discriminatedUnion('{{discriminator.propertyName}}', [
{{else}}
z.union([
{{/if}}
{{~#each anyOf~}}
{{~> zod_schema_item ~}},
{{~/each~}}
{{~/each~}}
])
{{~#if nullable}}.nullable(){{/if~}}
{{~#if (eq requiredBool false)}}.optional(){{/if~}}
{{~/if~}}

{{~#if oneOf~}}
z.union([
{{~#each oneOf~}}
{{#if discriminator}}
z.discriminatedUnion('{{discriminator.propertyName}}', [
{{else}}
z.union([
{{/if}}
{{~#each oneOf~}}
{{~> zod_schema_item ~}},
{{~/each~}}
{{~/each~}}
])
{{~#if nullable}}.nullable(){{/if~}}
{{~#if (eq requiredBool false)}}.optional(){{/if~}}
Expand Down Expand Up @@ -97,7 +104,6 @@ z.unknown()
{{~/if~}}
{{~#if (eq x-modify "partial")}}.partial(){{/if~}}
{{~#if (eq x-modify "required")}}.required(){{/if~}}
{{~#if (eq x-modify "requiredOptional")}}.transform(requiredOptional){{/if~}}
{{~/inline~}}

{{~#*inline "type_string"~}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
*/

import { z } from 'zod';
import { requiredOptional } from '@kbn/zod-helpers';

/*
* NOTICE: Do not edit this file manually.
Expand All @@ -27,48 +26,42 @@ export const EcsMapping = z.object({}).catchall(
);

export type OsqueryQuery = z.infer<typeof OsqueryQuery>;
export const OsqueryQuery = z
.object({
/**
* Query ID
*/
id: z.string(),
/**
* Query to execute
*/
query: z.string(),
ecs_mapping: EcsMapping.optional(),
/**
* Query version
*/
version: z.string().optional(),
platform: z.string().optional(),
removed: z.boolean().optional(),
snapshot: z.boolean().optional(),
})
.transform(requiredOptional);
export const OsqueryQuery = z.object({
/**
* Query ID
*/
id: z.string(),
/**
* Query to execute
*/
query: z.string(),
ecs_mapping: EcsMapping.optional(),
/**
* Query version
*/
version: z.string().optional(),
platform: z.string().optional(),
removed: z.boolean().optional(),
snapshot: z.boolean().optional(),
});

export type OsqueryParams = z.infer<typeof OsqueryParams>;
export const OsqueryParams = z
.object({
query: z.string().optional(),
ecs_mapping: EcsMapping.optional(),
queries: z.array(OsqueryQuery).optional(),
pack_id: z.string().optional(),
saved_query_id: z.string().optional(),
})
.transform(requiredOptional);
export const OsqueryParams = z.object({
query: z.string().optional(),
ecs_mapping: EcsMapping.optional(),
queries: z.array(OsqueryQuery).optional(),
pack_id: z.string().optional(),
saved_query_id: z.string().optional(),
});

export type OsqueryParamsCamelCase = z.infer<typeof OsqueryParamsCamelCase>;
export const OsqueryParamsCamelCase = z
.object({
query: z.string().optional(),
ecsMapping: EcsMapping.optional(),
queries: z.array(OsqueryQuery).optional(),
packId: z.string().optional(),
savedQueryId: z.string().optional(),
})
.transform(requiredOptional);
export const OsqueryParamsCamelCase = z.object({
query: z.string().optional(),
ecsMapping: EcsMapping.optional(),
queries: z.array(OsqueryQuery).optional(),
packId: z.string().optional(),
savedQueryId: z.string().optional(),
});

export type OsqueryResponseAction = z.infer<typeof OsqueryResponseAction>;
export const OsqueryResponseAction = z.object({
Expand All @@ -83,12 +76,10 @@ export const RuleResponseOsqueryAction = z.object({
});

export type EndpointParams = z.infer<typeof EndpointParams>;
export const EndpointParams = z
.object({
command: z.literal('isolate'),
comment: z.string().optional(),
})
.transform(requiredOptional);
export const EndpointParams = z.object({
command: z.literal('isolate'),
comment: z.string().optional(),
});

export type EndpointResponseAction = z.infer<typeof EndpointResponseAction>;
export const EndpointResponseAction = z.object({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ components:
required:
- id
- query
x-modify: requiredOptional

OsqueryParams:
type: object
Expand All @@ -66,7 +65,6 @@ components:
type: string
saved_query_id:
type: string
x-modify: requiredOptional

OsqueryParamsCamelCase:
type: object
Expand All @@ -83,7 +81,6 @@ components:
type: string
savedQueryId:
type: string
x-modify: requiredOptional

OsqueryResponseAction:
type: object
Expand Down Expand Up @@ -123,7 +120,6 @@ components:
type: string
required:
- command
x-modify: requiredOptional

EndpointResponseAction:
type: object
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

import { z } from 'zod';
import { requiredOptional, isValidDateMath } from '@kbn/zod-helpers';
import { isValidDateMath } from '@kbn/zod-helpers';

/*
* NOTICE: Do not edit this file manually.
Expand Down Expand Up @@ -94,14 +94,12 @@ export const RiskScore = z.number().int().min(0).max(100);
*/
export type RiskScoreMapping = z.infer<typeof RiskScoreMapping>;
export const RiskScoreMapping = z.array(
z
.object({
field: z.string(),
operator: z.literal('equals'),
value: z.string(),
risk_score: RiskScore.optional(),
})
.transform(requiredOptional)
z.object({
field: z.string(),
operator: z.literal('equals'),
value: z.string(),
risk_score: RiskScore.optional(),
})
);

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,6 @@ components:
- field
- operator
- value
x-modify: requiredOptional
description: Overrides generated alerts' risk_score with a value from the source event

Severity:
Expand Down
Loading

0 comments on commit 6073eb6

Please sign in to comment.