-
Notifications
You must be signed in to change notification settings - Fork 8.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[Security Solution] Swap rule unions out for discriminated unions to improve validation error messages #171452
[Security Solution] Swap rule unions out for discriminated unions to improve validation error messages #171452
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice writeup; much obliged. Detection engine changes are minimal; mostly just the moved requiredOptional
calls and all the test updates (which look great). Nice discriminator
s, as well 😉 .
LGTM
Pinging @elastic/security-detections-response (Team:Detections and Resp) |
Pinging @elastic/security-solution (Team: SecuritySolution) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@marshallmain thank you for improving error messages 🙏
Error messages look much better now 👍 And I'm really happy you got rid of x-modify: requireOptional
.
We definitely need to keep an eye on our schemas complexity to avoid Type instantiation is excessively deep and possibly infinite
. Zod is well know for complex typing and one of the slowest libraries for type inference due to huge flexibility. Interesting if TS can be tuned too to allow "deeper" types.
The only concern I have is related to params.meta as { [x: string]: {} | undefined }
. Basically recursive RequiredOptional
changes type from Record<string, unknown>
to Record<string, {} | undefined>
. It looks like it should be some solution like define SnakeCase
types explicitly and define modified types there. Or refrain from recursive RequiredOptional
.
(Btw there is an util type to transform camel case to snake case.)
@@ -49,7 +49,6 @@ components: | |||
required: | |||
- id | |||
- query | |||
x-modify: requiredOptional |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you for finally removing requiredOptional
from the schema 👍
@@ -28,7 +28,13 @@ | |||
*/ | |||
export type RequiredOptional<T> = { [K in keyof T]-?: [T[K]] } extends infer U | |||
? U extends Record<keyof U, [unknown]> | |||
? { [K in keyof U]: U[K][0] } | |||
? { | |||
[K in keyof U]: Record<string, unknown> extends U[K][0] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why did you need to make RequiredOptional
recursive?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was experimenting with this during the hackathon to make it easier to ensure that as we add more object types to the rule schema no fields get missed during the conversion between snake and camel case. Alert suppression fields, for example, are in the alert_suppression
object and some of those sub-fields are optional. We can use the non-recursive RequiredOptional
on the return type of convertAlertSuppressionToSnake
to cover that conversion. But, it could be useful to have a recursive RequiredOptional
so we don't have to specify RequiredOptional
on all the conversion functions for sub-objects as well, only at the top level converter from camel-rule to snake-rule.
It's not necessary for this PR though so I'll remove it and we can try that separately.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this:
export type RequiredOptional<T> = { [K in keyof T]-?: [T[K]] } extends infer U
? U extends Record<keyof U, [unknown]>
? {
[K in keyof U]: U[K][0] extends Record<string, unknown> | undefined
? undefined extends U[K][0]
? RequiredOptional<U[K][0]> | undefined
: RequiredOptional<U[K][0]>
: U[K][0];
}
: never
: never;
works better, but I'm still not 100% sure that it handles all the type edge cases correctly. Mostly putting it here for posterity in case I or someone else comes back to try this again later.
@@ -657,7 +657,7 @@ export const commonParamsCamelToSnake = (params: BaseRuleParams) => { | |||
output_index: params.outputIndex, | |||
timeline_id: params.timelineId, | |||
timeline_title: params.timelineTitle, | |||
meta: params.meta, | |||
meta: params.meta as { [x: string]: {} | undefined }, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Type casting here is quite obscure. I only managed to understand why it's necessary after switching to the branch. Recursive RequiredOptional
unintentionally updates types to be an object instead of unknown.
Can we restrain from making RequiredOptional
recursive?
Another option to avoid this type casting is to update generator to generate something like .catchall(z.object({}))
instead .catchall(z.unknown())
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, the recursive update was not necessary for the discriminated unions so I removed it for this PR
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@marshallmain Thank you for addressing my comments!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Defend Workflows (Osquery) changes lgtm 👍
@elasticmachine merge upstream |
💚 Build Succeeded
Metrics [docs]Async chunks
History
To update your PR or re-run it, just comment with: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Reviewed the changes and copied the "future work" from the PR description to https://github.com/elastic/security-team/issues/7991.
This is a great improvement, thank you @marshallmain 🙏
Epics: https://github.com/elastic/security-team/issues/8058, https://github.com/elastic/security-team/issues/6726 (internal)
Partially addresses: https://github.com/elastic/security-team/issues/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, withtype
as the discriminator, allowszod
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 wheretype
is not valid, or iftype
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 userequiredOptional
explicitly in specific places to convert the types. Similarly, theRequiredOptional
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:
Type instantiation is excessively deep and possibly infinite
. Seems to be a common issue with zod (“Type instantiation is excessively deep and possibly infinite” but only in a large codebase 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. CombiningResponseRequiredFields
andResponseOptionalFields
into a single object rather than merging them solved the immediate problem. However, we may still be near the depth limit. ChangingRuleResponse
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 thatSharedResponseProps
is only intersected with the type specific schemas after they're combined in a discriminated union, whereas inmain
we mergeSharedResponseProps
with each individual schema then merge them all together.