splunk-soar-sdk 3.8.1__py3-none-any.whl → 3.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,12 @@
1
1
  import itertools
2
- import types
3
2
  from collections.abc import Iterator
4
- from typing import Any, NotRequired, Union, get_args, get_origin
3
+ from typing import Any, NotRequired
5
4
 
6
- from pydantic import BaseModel, ConfigDict, Field
5
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
7
6
  from typing_extensions import TypedDict
8
7
 
9
8
  from soar_sdk.compat import remove_when_soar_newer_than
10
- from soar_sdk.field_utils import parse_json_schema_extra
9
+ from soar_sdk.field_utils import normalize_field_annotation, parse_json_schema_extra
11
10
  from soar_sdk.meta.datatypes import as_datatype
12
11
  from soar_sdk.shims.phantom.action_result import ActionResult as PhantomActionResult
13
12
 
@@ -164,13 +163,32 @@ class ActionOutput(BaseModel):
164
163
  ... ) # Model fields can't start with an underscore, so we're using an alias to create the proper JSON key
165
164
 
166
165
  Note:
167
- Fields cannot be Union or Optional types. Use specific types only.
166
+ Fields cannot be Union types other than Optional.
168
167
  Nested ActionOutput classes are supported for complex data structures.
169
168
  """
170
169
 
171
170
  # Allow instantiation with both field names and aliases for backward compatibility
172
171
  model_config = ConfigDict(populate_by_name=True)
173
172
 
173
+ @model_validator(mode="before")
174
+ @classmethod
175
+ def _apply_optional_defaults(cls, values: Any) -> Any: # noqa: ANN401
176
+ """Populate missing optional fields with ``None`` before validation."""
177
+ for field_name, field in cls.model_fields.items():
178
+ if field_name in values or (field.alias and field.alias in values):
179
+ continue
180
+
181
+ normalized = normalize_field_annotation(
182
+ field.annotation,
183
+ field_name=field_name,
184
+ context="Output",
185
+ allow_list=True,
186
+ )
187
+ if normalized.is_optional:
188
+ values[field_name] = None
189
+
190
+ return values
191
+
174
192
  @classmethod
175
193
  def _to_json_schema(
176
194
  cls,
@@ -193,7 +211,7 @@ class ActionOutput(BaseModel):
193
211
  OutputFieldSpecification objects describing each field in the schema.
194
212
 
195
213
  Raises:
196
- TypeError: If a field type cannot be serialized, is Union/Optional,
214
+ TypeError: If a field type cannot be serialized, is an unsupported Union,
197
215
  or if a nested ActionOutput type is encountered incorrectly.
198
216
 
199
217
  Note:
@@ -211,36 +229,18 @@ class ActionOutput(BaseModel):
211
229
  if field_type is None:
212
230
  continue
213
231
 
214
- datapath = parent_datapath + f".{field_name}"
215
-
216
- # Handle lists and optional types, even nested ones
217
- origin = get_origin(field_type)
218
- while origin in [list, Union, types.UnionType]:
219
- type_args = [
220
- arg
221
- for arg in get_args(field_type)
222
- if arg is not type(None) and arg is not None
223
- ]
224
-
225
- if origin is list:
226
- if len(type_args) != 1:
227
- raise TypeError(
228
- f"Output field {field_name} is invalid: List types must have exactly one non-null type argument."
229
- )
230
- datapath += ".*"
231
- else:
232
- if len(type_args) != 1:
233
- raise TypeError(
234
- f"Output field {field_name} is invalid: the only valid Union type is Optional, or Union[X, None]."
235
- )
236
-
237
- field_type = type_args[0]
238
- origin = get_origin(field_type)
239
-
240
- if not isinstance(field_type, type):
241
- raise TypeError(
242
- f"Output field {field_name} has invalid type annotation: {field_type}"
243
- )
232
+ normalized = normalize_field_annotation(
233
+ field_type,
234
+ field_name=field_name,
235
+ context="Output",
236
+ allow_list=True,
237
+ )
238
+
239
+ datapath = (
240
+ parent_datapath + f".{field_name}" + (".*" * normalized.list_depth)
241
+ )
242
+
243
+ field_type = normalized.base_type
244
244
 
245
245
  if issubclass(field_type, ActionOutput):
246
246
  # If the field is another ActionOutput, recursively call _to_json_schema
soar_sdk/asset.py CHANGED
@@ -9,7 +9,11 @@ from typing_extensions import TypedDict
9
9
  from soar_sdk.asset_state import AssetState
10
10
  from soar_sdk.compat import remove_when_soar_newer_than
11
11
  from soar_sdk.exceptions import AppContextRequired
12
- from soar_sdk.field_utils import parse_json_schema_extra
12
+ from soar_sdk.field_utils import (
13
+ normalize_field_annotation,
14
+ parse_json_schema_extra,
15
+ resolve_required,
16
+ )
13
17
  from soar_sdk.input_spec import AppConfig
14
18
  from soar_sdk.meta.datatypes import as_datatype
15
19
 
@@ -28,7 +32,7 @@ class FieldCategory(str, Enum):
28
32
 
29
33
  def AssetField(
30
34
  description: str | None = None,
31
- required: bool = True,
35
+ required: bool | None = None,
32
36
  default: Any | None = None, # noqa: ANN401
33
37
  value_list: list | None = None,
34
38
  sensitive: bool = False,
@@ -41,7 +45,9 @@ def AssetField(
41
45
  Args:
42
46
  description: Human-friendly label for the field shown in the asset form.
43
47
  required: Whether the field must be provided. When True and ``default`` is
44
- ``None``, the field is marked as required in the manifest.
48
+ ``None``, the field is marked as required in the manifest. Deprecated:
49
+ this will be removed in the next major version. Prefer Optional type
50
+ hints (``str | None``) to indicate optional fields.
45
51
  default: Default value for optional fields. Ignored when ``required`` is
46
52
  True and no explicit default is provided.
47
53
  value_list: Optional dropdown options presented to the user.
@@ -65,13 +71,20 @@ def AssetField(
65
71
  json_schema_extra["is_file"] = True
66
72
 
67
73
  # Use ... for required fields
68
- field_default: Any = ... if default is None and required else default
74
+ field_default: Any = (
75
+ ... if default is None and (required is True or required is None) else default
76
+ )
77
+ validate_default = None
78
+ if required is False and default is None:
79
+ # Preserve legacy optional behavior for non-optional type hints
80
+ validate_default = False
69
81
 
70
82
  return Field(
71
83
  default=field_default,
72
84
  description=description,
73
85
  alias=alias,
74
86
  json_schema_extra=json_schema_extra,
87
+ validate_default=validate_default,
75
88
  )
76
89
 
77
90
 
@@ -137,6 +150,25 @@ class BaseAsset(BaseModel):
137
150
  arbitrary_types_allowed=True,
138
151
  )
139
152
 
153
+ @model_validator(mode="before")
154
+ @classmethod
155
+ def _apply_optional_defaults(cls, values: Any) -> Any: # noqa: ANN401
156
+ """Populate missing optional fields with ``None`` before validation."""
157
+ for field_name, field in cls.model_fields.items():
158
+ if field_name in values or (field.alias and field.alias in values):
159
+ continue
160
+
161
+ normalized = normalize_field_annotation(
162
+ field.annotation,
163
+ field_name=field_name,
164
+ context="Asset field",
165
+ allow_list=False,
166
+ )
167
+ if normalized.is_optional:
168
+ values[field_name] = None
169
+
170
+ return values
171
+
140
172
  @model_validator(mode="before")
141
173
  @classmethod
142
174
  def validate_no_reserved_fields(cls, values: dict[str, Any]) -> dict[str, Any]:
@@ -211,8 +243,15 @@ class BaseAsset(BaseModel):
211
243
  if field_type is None:
212
244
  continue
213
245
 
246
+ normalized = normalize_field_annotation(
247
+ field_type,
248
+ field_name=field_name,
249
+ context="Asset field",
250
+ allow_list=False,
251
+ )
252
+
214
253
  try:
215
- type_name = as_datatype(field_type)
254
+ type_name = as_datatype(normalized.base_type)
216
255
  except TypeError as e:
217
256
  raise TypeError(
218
257
  f"Failed to serialize asset field {field_name}: {e}"
@@ -221,16 +260,16 @@ class BaseAsset(BaseModel):
221
260
  json_schema_extra = parse_json_schema_extra(field.json_schema_extra)
222
261
 
223
262
  if json_schema_extra.get("sensitive", False):
224
- if field_type is not str:
263
+ if normalized.base_type is not str:
225
264
  raise TypeError(
226
- f"Sensitive parameter {field_name} must be type str, not {field_type.__name__}"
265
+ f"Sensitive parameter {field_name} must be type str, not {normalized.base_type.__name__}"
227
266
  )
228
267
  type_name = "password"
229
268
 
230
269
  if json_schema_extra.get("is_file", False):
231
- if field_type is not str:
270
+ if normalized.base_type is not str:
232
271
  raise TypeError(
233
- f"File parameter {field_name} must be type str, not {field_type.__name__}"
272
+ f"File parameter {field_name} must be type str, not {normalized.base_type.__name__}"
234
273
  )
235
274
  type_name = "file"
236
275
 
@@ -239,7 +278,7 @@ class BaseAsset(BaseModel):
239
278
 
240
279
  params_field = AssetFieldSpecification(
241
280
  data_type=type_name,
242
- required=bool(json_schema_extra.get("required", True)),
281
+ required=resolve_required(json_schema_extra, normalized.is_optional),
243
282
  description=description,
244
283
  order=field_order,
245
284
  category=json_schema_extra.get("category", FieldCategory.CONNECTIVITY),
@@ -300,3 +339,29 @@ class BaseAsset(BaseModel):
300
339
  if self._ingest_state is None:
301
340
  raise AppContextRequired()
302
341
  return self._ingest_state
342
+
343
+
344
+ class ESIngestMixin:
345
+ """Mixin for apps that support ES polling (on_es_poll).
346
+
347
+ Add this mixin to your Asset class to include ES Ingest Settings fields.
348
+ These fields are configured in the ES UI and control how findings are created.
349
+
350
+ Example:
351
+ >>> class Asset(BaseAsset, ESIngestMixin):
352
+ ... server: str = AssetField(description="API server URL")
353
+ ... api_key: str = AssetField(description="API key", sensitive=True)
354
+ """
355
+
356
+ es_security_domain: str = AssetField(
357
+ required=False,
358
+ description="Security domain for ES findings",
359
+ default="threat",
360
+ category=FieldCategory.INGEST,
361
+ )
362
+ es_urgency: str = AssetField(
363
+ required=False,
364
+ description="Urgency level for ES findings",
365
+ default="medium",
366
+ category=FieldCategory.INGEST,
367
+ )
@@ -4,7 +4,7 @@ from logging import getLogger
4
4
  from typing import Any
5
5
 
6
6
  from soar_sdk.action_results import ActionOutput, OutputFieldSpecification
7
- from soar_sdk.field_utils import parse_json_schema_extra
7
+ from soar_sdk.field_utils import normalize_field_annotation, parse_json_schema_extra
8
8
  from soar_sdk.meta.datatypes import as_datatype
9
9
  from soar_sdk.params import Params
10
10
 
@@ -42,9 +42,18 @@ class OutputsSerializer:
42
42
  if annotation is None:
43
43
  continue
44
44
 
45
+ normalized = normalize_field_annotation(
46
+ annotation,
47
+ field_name=field_name,
48
+ context="Action parameter",
49
+ allow_list=False,
50
+ )
51
+
52
+ parameter_name = field.alias or field_name
53
+
45
54
  spec = OutputFieldSpecification(
46
- data_path=f"action_result.parameter.{field_name}",
47
- data_type=as_datatype(annotation),
55
+ data_path=f"action_result.parameter.{parameter_name}",
56
+ data_type=as_datatype(normalized.base_type),
48
57
  )
49
58
 
50
59
  json_schema_extra = parse_json_schema_extra(field.json_schema_extra)
@@ -1,5 +1,4 @@
1
1
  import ast
2
- import typing
3
2
  from collections.abc import Iterator
4
3
  from typing import ClassVar
5
4
 
@@ -8,7 +7,11 @@ from pydantic_core import PydanticUndefined
8
7
  from soar_sdk.action_results import ActionOutput
9
8
  from soar_sdk.cli.utils import normalize_field_name
10
9
  from soar_sdk.code_renderers.renderer import AstRenderer
11
- from soar_sdk.field_utils import parse_json_schema_extra
10
+ from soar_sdk.field_utils import (
11
+ normalize_field_annotation,
12
+ parse_json_schema_extra,
13
+ resolve_required,
14
+ )
12
15
  from soar_sdk.meta.actions import ActionMeta
13
16
  from soar_sdk.params import Params
14
17
 
@@ -171,6 +174,23 @@ class ActionRenderer(AstRenderer[ActionMeta]):
171
174
  """
172
175
  return self.context
173
176
 
177
+ @staticmethod
178
+ def _build_annotation(base_type: type, list_depth: int, required: bool) -> ast.expr:
179
+ annotation: ast.expr = ast.Name(id=base_type.__name__, ctx=ast.Load())
180
+ for _ in range(list_depth):
181
+ annotation = ast.Subscript(
182
+ value=ast.Name(id="list", ctx=ast.Load()),
183
+ slice=annotation,
184
+ ctx=ast.Load(),
185
+ )
186
+ if not required:
187
+ annotation = ast.BinOp(
188
+ left=annotation,
189
+ op=ast.BitOr(),
190
+ right=ast.Constant(value=None),
191
+ )
192
+ return annotation
193
+
174
194
  def render_ast(self) -> Iterator[ast.stmt]:
175
195
  """Generates the AST for the action.
176
196
 
@@ -284,16 +304,15 @@ class ActionRenderer(AstRenderer[ActionMeta]):
284
304
  if annotation is None:
285
305
  continue
286
306
 
287
- annotation_str = "{name}"
288
- while typing.get_origin(annotation) is list:
289
- annotation_str = f"list[{annotation_str}]"
290
- annotation = typing.get_args(annotation)[0]
291
-
292
- # Ensure annotation is a valid type after unwrapping
293
- if not isinstance(annotation, type):
294
- continue
307
+ normalized = normalize_field_annotation(
308
+ annotation,
309
+ field_name=field_name_str,
310
+ context="Output",
311
+ allow_list=True,
312
+ )
295
313
 
296
- annotation_str = annotation_str.format(name=annotation.__name__)
314
+ json_schema_extra = parse_json_schema_extra(field.json_schema_extra)
315
+ required = resolve_required(json_schema_extra, normalized.is_optional)
297
316
 
298
317
  field_name = normalize_field_name(field_name_str)
299
318
  if field.alias is not None and field.alias != field_name.normalized:
@@ -302,14 +321,16 @@ class ActionRenderer(AstRenderer[ActionMeta]):
302
321
 
303
322
  field_def_ast = ast.AnnAssign(
304
323
  target=ast.Name(id=field_name.normalized, ctx=ast.Store()),
305
- annotation=ast.Name(id=annotation_str, ctx=ast.Load()),
324
+ annotation=self._build_annotation(
325
+ normalized.base_type, normalized.list_depth, required
326
+ ),
306
327
  simple=1,
307
328
  )
308
329
 
309
- if isinstance(annotation, type) and issubclass(annotation, ActionOutput):
330
+ if issubclass(normalized.base_type, ActionOutput):
310
331
  # If the field is a Pydantic model, recursively print its fields
311
332
  # In Pydantic v2, use annotation directly (no field.type_)
312
- for model_ast in self.render_outputs_ast(annotation):
333
+ for model_ast in self.render_outputs_ast(normalized.base_type):
313
334
  model_tree[model_ast.name] = model_ast
314
335
 
315
336
  if field_name.modified:
@@ -325,7 +346,7 @@ class ActionRenderer(AstRenderer[ActionMeta]):
325
346
  )
326
347
  else:
327
348
  keywords = []
328
- extras = {**parse_json_schema_extra(field.json_schema_extra)}
349
+ extras = {**json_schema_extra}
329
350
 
330
351
  if extras or field_name.modified:
331
352
  extras["example_values"] = extras.pop("examples", None)
@@ -388,7 +409,18 @@ class ActionRenderer(AstRenderer[ActionMeta]):
388
409
  if field_def.annotation is None:
389
410
  continue
390
411
 
391
- field_type = ast.Name(id=field_def.annotation.__name__, ctx=ast.Load())
412
+ normalized = normalize_field_annotation(
413
+ field_def.annotation,
414
+ field_name=field_name,
415
+ context="Action parameter",
416
+ allow_list=False,
417
+ )
418
+
419
+ json_schema_extra = parse_json_schema_extra(field_def.json_schema_extra)
420
+ required = resolve_required(json_schema_extra, normalized.is_optional)
421
+ field_type = self._build_annotation(
422
+ normalized.base_type, normalized.list_depth, required
423
+ )
392
424
 
393
425
  param = ast.Call(
394
426
  func=ast.Name(id="Param", ctx=ast.Load()),
@@ -396,8 +428,6 @@ class ActionRenderer(AstRenderer[ActionMeta]):
396
428
  keywords=[],
397
429
  )
398
430
 
399
- json_schema_extra = parse_json_schema_extra(field_def.json_schema_extra)
400
-
401
431
  if field_def.description:
402
432
  param.keywords.append(
403
433
  ast.keyword(
@@ -405,10 +435,6 @@ class ActionRenderer(AstRenderer[ActionMeta]):
405
435
  value=ast.Constant(value=field_def.description),
406
436
  )
407
437
  )
408
- if not json_schema_extra.get("required", True):
409
- param.keywords.append(
410
- ast.keyword(arg="required", value=ast.Constant(value=False))
411
- )
412
438
  if json_schema_extra.get("primary", False):
413
439
  param.keywords.append(
414
440
  ast.keyword(arg="primary", value=ast.Constant(value=True))
@@ -445,6 +471,14 @@ class ActionRenderer(AstRenderer[ActionMeta]):
445
471
  ast.keyword(arg="allow_list", value=ast.Constant(value=True))
446
472
  )
447
473
 
474
+ if field_def.alias and field_def.alias != field_name:
475
+ param.keywords.append(
476
+ ast.keyword(
477
+ arg="alias",
478
+ value=ast.Constant(value=field_def.alias),
479
+ )
480
+ )
481
+
448
482
  field_def_ast = ast.AnnAssign(
449
483
  target=ast.Name(id=field_name, ctx=ast.Store()),
450
484
  annotation=field_type,
@@ -56,11 +56,15 @@ class AssetRenderer(AstRenderer[list[AssetContext]]):
56
56
 
57
57
  for field in self.context:
58
58
  field_name = ast.Name(id=field.name, ctx=ast.Store())
59
- field_type = ast.Name(id=field.py_type, ctx=ast.Load())
59
+ field_type: ast.expr = ast.Name(id=field.py_type, ctx=ast.Load())
60
+ if not field.required:
61
+ field_type = ast.BinOp(
62
+ left=field_type,
63
+ op=ast.BitOr(),
64
+ right=ast.Constant(value=None),
65
+ )
60
66
 
61
- field_kwargs = [
62
- ast.keyword(arg="required", value=ast.Constant(value=field.required)),
63
- ]
67
+ field_kwargs = []
64
68
  if field.description is not None:
65
69
  field_kwargs.append(
66
70
  ast.keyword(
soar_sdk/field_utils.py CHANGED
@@ -1,4 +1,6 @@
1
- from typing import Any
1
+ import types
2
+ from dataclasses import dataclass
3
+ from typing import Any, Union, get_args, get_origin
2
4
 
3
5
 
4
6
  def parse_json_schema_extra(json_schema_extra: Any) -> dict[str, Any]: # noqa: ANN401
@@ -6,3 +8,85 @@ def parse_json_schema_extra(json_schema_extra: Any) -> dict[str, Any]: # noqa:
6
8
  if callable(json_schema_extra):
7
9
  return {}
8
10
  return json_schema_extra or {}
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class NormalizedFieldType:
15
+ """Normalized field annotation details."""
16
+
17
+ base_type: type
18
+ list_depth: int
19
+ is_optional: bool
20
+
21
+
22
+ def normalize_field_annotation(
23
+ annotation: Any, # noqa: ANN401
24
+ *,
25
+ field_name: str,
26
+ context: str,
27
+ allow_list: bool,
28
+ ) -> NormalizedFieldType:
29
+ """Normalize an annotation by unwrapping Optional and list types.
30
+
31
+ Args:
32
+ annotation: The field annotation to normalize.
33
+ field_name: Name of the field for error reporting.
34
+ context: Context string for error reporting (e.g., "parameter").
35
+ allow_list: Whether list types are allowed.
36
+
37
+ Returns:
38
+ NormalizedFieldType describing the base type, list nesting, and optionality.
39
+
40
+ Raises:
41
+ TypeError: If unsupported unions or list shapes are encountered.
42
+ """
43
+ list_depth = 0
44
+ is_optional = False
45
+
46
+ while True:
47
+ origin = get_origin(annotation)
48
+ if origin is list:
49
+ type_args = get_args(annotation)
50
+ if len(type_args) != 1:
51
+ raise TypeError(
52
+ f"{context} field {field_name} is invalid: list types must have exactly one type argument."
53
+ )
54
+ list_depth += 1
55
+ annotation = type_args[0]
56
+ continue
57
+
58
+ if origin in (types.UnionType, Union):
59
+ # types.UnionType is for `X | Y` (Python 3.10+)
60
+ type_args = tuple(
61
+ arg for arg in get_args(annotation) if arg is not type(None)
62
+ )
63
+ if len(type_args) != 1:
64
+ raise TypeError(
65
+ f"{context} field {field_name} is invalid: only Optional[T] is supported for unions."
66
+ )
67
+ is_optional = True
68
+ annotation = type_args[0]
69
+ continue
70
+
71
+ break
72
+
73
+ if list_depth and not allow_list:
74
+ raise TypeError(
75
+ f"{context} field {field_name} is invalid: list types are not supported."
76
+ )
77
+
78
+ if not isinstance(annotation, type):
79
+ raise TypeError(
80
+ f"{context} field {field_name} has invalid type annotation: {annotation}"
81
+ )
82
+
83
+ return NormalizedFieldType(
84
+ base_type=annotation, list_depth=list_depth, is_optional=is_optional
85
+ )
86
+
87
+
88
+ def resolve_required(json_schema_extra: dict[str, Any], is_optional: bool) -> bool:
89
+ """Resolve required flag using JSON schema metadata and optionality."""
90
+ if "required" in json_schema_extra:
91
+ return bool(json_schema_extra.get("required"))
92
+ return not is_optional
@@ -21,20 +21,26 @@ class DrilldownDashboard(BaseModel):
21
21
 
22
22
 
23
23
  class Finding(BaseModel):
24
- """Represents a finding to be created during on_finding.
24
+ """Represents a finding to be created during on_es_poll.
25
25
 
26
26
  Findings are stored in ES and can be associated with SOAR containers/artifacts
27
27
  for investigation workflow.
28
+
29
+ Only rule_title and security_domain are required. All other fields are optional
30
+ and will use ES defaults if not provided.
28
31
  """
29
32
 
30
33
  model_config = ConfigDict(extra="forbid")
31
34
 
35
+ # Required fields
32
36
  rule_title: str
33
- rule_description: str
34
37
  security_domain: str
35
- risk_object: str
36
- risk_object_type: str
37
- risk_score: float
38
+
39
+ # Optional fields
40
+ rule_description: str | None = None
41
+ risk_object: str | None = None
42
+ risk_object_type: str | None = None
43
+ risk_score: float | None = None
38
44
  status: str | None = None
39
45
  urgency: str | None = None
40
46
  owner: str | None = None
soar_sdk/params.py CHANGED
@@ -1,12 +1,16 @@
1
1
  from typing import Any, ClassVar, NotRequired
2
2
 
3
- from pydantic import Field
3
+ from pydantic import Field, model_validator
4
4
  from pydantic.main import BaseModel
5
5
  from pydantic_core import PydanticUndefined
6
6
  from typing_extensions import TypedDict
7
7
 
8
8
  from soar_sdk.compat import remove_when_soar_newer_than
9
- from soar_sdk.field_utils import parse_json_schema_extra
9
+ from soar_sdk.field_utils import (
10
+ normalize_field_annotation,
11
+ parse_json_schema_extra,
12
+ resolve_required,
13
+ )
10
14
  from soar_sdk.meta.datatypes import as_datatype
11
15
 
12
16
  remove_when_soar_newer_than(
@@ -18,7 +22,7 @@ MAX_COUNT_VALUE = 4294967295
18
22
 
19
23
  def Param(
20
24
  description: str | None = None,
21
- required: bool = True,
25
+ required: bool | None = None,
22
26
  primary: bool = False,
23
27
  default: Any | None = None, # noqa: ANN401
24
28
  value_list: list | None = None,
@@ -40,6 +44,8 @@ def Param(
40
44
  This key also works in conjunction with value_list.
41
45
  :param required: Whether or not this parameter is mandatory for this action
42
46
  to function. If this parameter is not provided, the action fails.
47
+ Deprecated: this will be removed in the next major version. Prefer
48
+ using Optional type hints (``str | None``) to indicate optional fields.
43
49
  :param primary: Specifies if the action acts primarily on this parameter or not.
44
50
  It is used in conjunction with the contains field to display a list of contextual
45
51
  actions where the user clicks on a piece of data in the UI.
@@ -74,13 +80,21 @@ def Param(
74
80
  json_schema_extra["column_name"] = column_name
75
81
 
76
82
  # Use ... for required fields
77
- field_default: Any = ... if default is None and required else default
83
+ field_default: Any = (
84
+ ... if default is None and (required is True or required is None) else default
85
+ )
86
+
87
+ validate_default = None
88
+ if required is False and default is None:
89
+ # Preserve legacy optional behavior for non-optional type hints
90
+ validate_default = False
78
91
 
79
92
  return Field(
80
93
  default=field_default,
81
94
  description=description,
82
95
  alias=alias,
83
96
  json_schema_extra=json_schema_extra if json_schema_extra else None,
97
+ validate_default=validate_default,
84
98
  )
85
99
 
86
100
 
@@ -108,6 +122,25 @@ class Params(BaseModel):
108
122
  Params fields can be optional if desired, or optionally have a default value, CEF type, and other metadata defined in :func:`soar_sdk.params.Param`.
109
123
  """
110
124
 
125
+ @model_validator(mode="before")
126
+ @classmethod
127
+ def _apply_optional_defaults(cls, values: Any) -> Any: # noqa: ANN401
128
+ """Populate missing optional fields with ``None`` before validation."""
129
+ for field_name, field in cls.model_fields.items():
130
+ if field_name in values or (field.alias and field.alias in values):
131
+ continue
132
+
133
+ normalized = normalize_field_annotation(
134
+ field.annotation,
135
+ field_name=field_name,
136
+ context="Action parameter",
137
+ allow_list=False,
138
+ )
139
+ if normalized.is_optional:
140
+ values[field_name] = None
141
+
142
+ return values
143
+
111
144
  @staticmethod
112
145
  def _default_field_description(field_name: str) -> str:
113
146
  words = field_name.split("_")
@@ -123,8 +156,15 @@ class Params(BaseModel):
123
156
  if field_type is None:
124
157
  raise TypeError(f"Parameter {field_name} has no type annotation")
125
158
 
159
+ normalized = normalize_field_annotation(
160
+ field_type,
161
+ field_name=field_name,
162
+ context="Action parameter",
163
+ allow_list=False,
164
+ )
165
+
126
166
  try:
127
- type_name = as_datatype(field_type)
167
+ type_name = as_datatype(normalized.base_type)
128
168
  except TypeError as e:
129
169
  raise TypeError(
130
170
  f"Failed to serialize action parameter {field_name}: {e}"
@@ -133,9 +173,9 @@ class Params(BaseModel):
133
173
  json_schema_extra = parse_json_schema_extra(field.json_schema_extra)
134
174
 
135
175
  if json_schema_extra.get("sensitive", False):
136
- if field_type is not str:
176
+ if normalized.base_type is not str:
137
177
  raise TypeError(
138
- f"Sensitive parameter {field_name} must be type str, not {field_type.__name__}"
178
+ f"Sensitive parameter {field_name} must be type str, not {normalized.base_type.__name__}"
139
179
  )
140
180
  type_name = "password"
141
181
 
@@ -147,7 +187,7 @@ class Params(BaseModel):
147
187
  name=field_name,
148
188
  description=description,
149
189
  data_type=type_name,
150
- required=bool(json_schema_extra.get("required", True)),
190
+ required=resolve_required(json_schema_extra, normalized.is_optional),
151
191
  primary=bool(json_schema_extra.get("primary", False)),
152
192
  allow_list=bool(json_schema_extra.get("allow_list", False)),
153
193
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: splunk-soar-sdk
3
- Version: 3.8.1
3
+ Version: 3.9.0
4
4
  Summary: The official framework for developing and testing Splunk SOAR Apps
5
5
  Project-URL: Homepage, https://github.com/phantomcyber/splunk-soar-sdk
6
6
  Project-URL: Documentation, https://github.com/phantomcyber/splunk-soar-sdk
@@ -1,11 +1,11 @@
1
1
  soar_sdk/__init__.py,sha256=RzAng-ARqpK01SY82lNy4uYJFVG0yW6Q3CccEqbToJ4,726
2
2
  soar_sdk/abstract.py,sha256=GycJhTrSNDa7eDg8hOD7hJjIt5eHEykZhpza-jh_Veo,7787
3
- soar_sdk/action_results.py,sha256=VUsk41l8XC1Dlz4o2XrTiUc2MWLUTIVe6YqpkREEjiY,11912
3
+ soar_sdk/action_results.py,sha256=tN7D_E-2HCGfFn_wx_tSUtWT3nhNHB1lXKs7fqTiNkw,11692
4
4
  soar_sdk/actions_manager.py,sha256=8IYOi2k8i9LHXEhQVZ0Ig3IS1gD3iAmOJ9q0bi14g-o,7179
5
5
  soar_sdk/app.py,sha256=2bUWx1BgWS9Kwkp0aUzVWM8LTdVUq1JI2eXR0LhwEMU,37092
6
6
  soar_sdk/app_cli_runner.py,sha256=K1ATWyGs0iNgPfIjMthsN72laOXqXCFZNEXfuzAMOM4,11645
7
7
  soar_sdk/app_client.py,sha256=hbe1R2QwXDmoS4959a-ay9oylD1Qk-oPJvJRnxvICz0,6281
8
- soar_sdk/asset.py,sha256=CUCFjUVAawrk3hyGvQn_qNApqJx8J4VxzD--iGEE2pc,12123
8
+ soar_sdk/asset.py,sha256=1EFup0ZNMyE2ZwMJiyj1T3TL0E_gqzU3GT4Gi88Fnpk,14425
9
9
  soar_sdk/asset_state.py,sha256=qh4n8IoabVObIZXRPyM0zznwC5LcJpbADcybmDdQABc,2318
10
10
  soar_sdk/async_utils.py,sha256=Dz7RagIRjyIagA9vivHWSb18S96J2WOuDB8B5Zy64AE,1428
11
11
  soar_sdk/colors.py,sha256=--i_iXqfyITUz4O95HMjfZQGbwFZ34bLmBhtfpXXqlQ,1095
@@ -13,10 +13,10 @@ soar_sdk/compat.py,sha256=N4bG1wqISICV92K1jLx7v5JGrHC08Bdn3Gx3Cx1lEmE,3062
13
13
  soar_sdk/crypto.py,sha256=qiBMHUQqgn5lPI1DbujSj700s89FuLJrkQgCO9_eBn4,392
14
14
  soar_sdk/es_client.py,sha256=U2NhPwGcciCfZM7ofBnfbkbHivna7rkTScwXwa39_bg,1204
15
15
  soar_sdk/exceptions.py,sha256=413-AcIM7IMixoyVk_0yDaqsUhommb784uH5vSv18lU,2129
16
- soar_sdk/field_utils.py,sha256=Jb0HteUPd-CtuDM7rNXVLy4uRxl419zeDxY_oOpU8GM,287
16
+ soar_sdk/field_utils.py,sha256=qLCzAPlx4CPJ6pcy5hWWNFOB1XQMpgEdGS2_xL5F8YE,2913
17
17
  soar_sdk/input_spec.py,sha256=vvmE8AWk2VFRgsvh-Bn1eIMDAHkV-1Y73_8xM_8qVZI,4678
18
18
  soar_sdk/logging.py,sha256=Y_5RLT1Z0UcRTxDZpwXwDae7ZtwOs91Hs-UU4UOiMuk,11425
19
- soar_sdk/params.py,sha256=pvcQwzEJSSOM95RQ-JTfQrExzJMIKCKOVFdka6P9yYQ,10836
19
+ soar_sdk/params.py,sha256=UN2_w9Ipsk9Xtux313I5_lG3SAgWoYJD3fY0AQyU3YU,12296
20
20
  soar_sdk/paths.py,sha256=XhpanQCAiTXaulRx440oKu36mnll7P05TethHXgMpgQ,239
21
21
  soar_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
22
22
  soar_sdk/types.py,sha256=wN-zV27IamDR67hj4QDqOWA04EVnJwcFhWOighMYEJc,616
@@ -48,15 +48,15 @@ soar_sdk/cli/manifests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3
48
48
  soar_sdk/cli/manifests/cli.py,sha256=cly5xVdj4bBIdZVMQPIWTXRgUfd1ON3qKO-76Fwql18,524
49
49
  soar_sdk/cli/manifests/deserializers.py,sha256=kwgPAMgUEXtIn4AuQOh1nkLfWFqe4qnYPZ1czB-FQTU,16516
50
50
  soar_sdk/cli/manifests/processors.py,sha256=soiRTbfLQuetstt1Xk7vKmWzOUhgVON5JxjWMvnGN7w,5141
51
- soar_sdk/cli/manifests/serializers.py,sha256=ulpq3nS8g1YrIP371XoQC3_kpz-9v2Ln_mqPyMtpWn8,3632
51
+ soar_sdk/cli/manifests/serializers.py,sha256=5YbtaDuOqwxOK8K6S257AhwycoFPGl4ndd0o7Ci2qYo,3943
52
52
  soar_sdk/cli/package/cli.py,sha256=hdpVBRvVGWaYDYhpDILlA7LRhunfElLbNdh21jviKrM,10087
53
53
  soar_sdk/cli/package/utils.py,sha256=fl6PMcrdC2zA7A16byQuxxPyAI2Z-BqBLfLlF2ZNnQ4,1712
54
54
  soar_sdk/cli/test/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
55
55
  soar_sdk/cli/test/cli.py,sha256=iDrthN8L7B1RplLhq0EI69MndaOhvAXn7bqv3XzlfpM,7655
56
56
  soar_sdk/code_renderers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
57
- soar_sdk/code_renderers/action_renderer.py,sha256=JfIQpeqYlpr12WUjG5O_ciiJhtGHoBgKdRTe3kLkW5k,16490
57
+ soar_sdk/code_renderers/action_renderer.py,sha256=dlEWP5fKBnJkFm5qHRGnuawseTYMrtbBzVeIfp_NKQ8,17560
58
58
  soar_sdk/code_renderers/app_renderer.py,sha256=Ywt8iobl6phP_5O4RhCjPLFvfaSDPmaqGPI4GfM8Oyk,7791
59
- soar_sdk/code_renderers/asset_renderer.py,sha256=XT8oKu8Iu044pccd8Kq1adsuoQJVM0T88mGlTh2R2WE,3833
59
+ soar_sdk/code_renderers/asset_renderer.py,sha256=vyhmrYdHYFtzGJQxOKxr7WwK2AaVjbDTWzUqa_FOJtA,3961
60
60
  soar_sdk/code_renderers/renderer.py,sha256=a2OW5Dgz0I012qtVr9z26Kcji1i65WDnj0yd7jRoPVE,1229
61
61
  soar_sdk/code_renderers/toml_renderer.py,sha256=-zP8UzlYMCVVA5ex9slaNLeFTu4xLjkv88YLmRNLrTM,1505
62
62
  soar_sdk/code_renderers/templates/pyproject.toml.jinja,sha256=uH7SJhFD9_-kYzGHobwv04aV4Nfru1bquL30GoOirfg,3918
@@ -84,7 +84,7 @@ soar_sdk/models/__init__.py,sha256=YZVAcBguAlUsxAnBBL6jSguJEzf5PYCtdvbNyU1XfEU,3
84
84
  soar_sdk/models/artifact.py,sha256=G8hv9wPPoRgrAQzIf-YlCSjAlkHEcIPF389T1bo4yHw,1087
85
85
  soar_sdk/models/attachment_input.py,sha256=s2mkEsRVb52yqHtb4Q7FzC9j8A4-Q8W4wCDqMJQZ8cc,1043
86
86
  soar_sdk/models/container.py,sha256=Cnn-Grha8qUFHHBxLUcEvo81sC3z483oItJ4GhRiTmg,1528
87
- soar_sdk/models/finding.py,sha256=Evga9Jrp3TfSVdAQlAkZ7UHDkUjaQYicYYY1S5bIruY,1404
87
+ soar_sdk/models/finding.py,sha256=74tQySVi-pExAaVp2fbJt44a28tq2S0ny5C7Lsa7me8,1636
88
88
  soar_sdk/models/vault_attachment.py,sha256=sdRnQdPiwgaZDojpap4ohH7u1Q5TYGP-drs8Ko4p_aU,1073
89
89
  soar_sdk/models/view.py,sha256=BUuz6VVVe78hg7irGgZCbvBcycOmuPqplkagdi3T4Dg,779
90
90
  soar_sdk/shims/phantom/action_result.py,sha256=Nddc9oswAfHU7I2q0pLm3HZ2YiLUQZUEIqqAjToZWnM,1606
@@ -116,8 +116,8 @@ soar_sdk/views/components/pie_chart.py,sha256=LVTeHVJN6nf2vjUs9y7PDBhS0U1fKW750l
116
116
  soar_sdk/webhooks/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
117
117
  soar_sdk/webhooks/models.py,sha256=j3kbvYmcOlcj3gQYKtrv7iS-lDavMKYNLdCNMy_I2Hc,4542
118
118
  soar_sdk/webhooks/routing.py,sha256=OjezhuAb8wzW0MnbGSnIWeAH3uJcu-Sb7s3w9zoiPVM,6873
119
- splunk_soar_sdk-3.8.1.dist-info/METADATA,sha256=77J-AZGoWNo716WsgJyepjKsR9fxgZoJ-g_EdHBnjHI,7544
120
- splunk_soar_sdk-3.8.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
121
- splunk_soar_sdk-3.8.1.dist-info/entry_points.txt,sha256=CgBjo2ZWpYNkt9TgvToL26h2Tg1yt8FbvYTb5NVgNuc,51
122
- splunk_soar_sdk-3.8.1.dist-info/licenses/LICENSE,sha256=gNCGrGhrSQb1PUzBOByVUN1tvaliwLZfna-QU2r2hQ8,11345
123
- splunk_soar_sdk-3.8.1.dist-info/RECORD,,
119
+ splunk_soar_sdk-3.9.0.dist-info/METADATA,sha256=wfLVvI64ugKvPBMH7-hVXS7Qt3Y3BhNfH8MFECYW5Js,7544
120
+ splunk_soar_sdk-3.9.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
121
+ splunk_soar_sdk-3.9.0.dist-info/entry_points.txt,sha256=CgBjo2ZWpYNkt9TgvToL26h2Tg1yt8FbvYTb5NVgNuc,51
122
+ splunk_soar_sdk-3.9.0.dist-info/licenses/LICENSE,sha256=gNCGrGhrSQb1PUzBOByVUN1tvaliwLZfna-QU2r2hQ8,11345
123
+ splunk_soar_sdk-3.9.0.dist-info/RECORD,,