splunk-soar-sdk 3.6.1__py3-none-any.whl → 3.8.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,4 +1,7 @@
1
+ import json
1
2
  import os
3
+ import shutil
4
+ import tempfile
2
5
  from pathlib import Path
3
6
  from typing import Any
4
7
 
@@ -135,3 +138,40 @@ class ActionsManager(BaseConnector):
135
138
  This is useful for contexts such as Webhooks, where the app dir isn't necessarily the cwd, but we still need to load the app JSON reliably.
136
139
  """
137
140
  self.__app_dir = app_dir
141
+
142
+ def _get_state_file_path(self, asset_id: str) -> Path:
143
+ """Get the state file path for an asset."""
144
+ return Path(self.get_state_dir()) / f"{asset_id}_state.json"
145
+
146
+ def load_state_from_file(self, asset_id: str) -> dict:
147
+ """Load state directly from file."""
148
+ state_file = self._get_state_file_path(asset_id)
149
+ if state_file.exists():
150
+ return json.loads(state_file.read_text())
151
+ return {}
152
+
153
+ def save_state_to_file(self, asset_id: str, state: dict) -> None:
154
+ """Save state directly to file using atomic write."""
155
+ state_file = self._get_state_file_path(asset_id)
156
+ state_file.parent.mkdir(parents=True, exist_ok=True)
157
+
158
+ fd, tmp_path = tempfile.mkstemp(dir=state_file.parent)
159
+ tmp_file = Path(tmp_path)
160
+ try:
161
+ with os.fdopen(fd, "w") as f:
162
+ f.write(json.dumps(state))
163
+ shutil.move(tmp_file, state_file)
164
+ except Exception:
165
+ if tmp_file.exists():
166
+ tmp_file.unlink()
167
+ raise
168
+
169
+ def reload_state_from_file(self, asset_id: str) -> dict:
170
+ """Reload state from file and update in-memory state.
171
+
172
+ Needed for OAuth flow where one process (webhook) updates the state file and another process (action) needs to see those changes.
173
+ """
174
+ state = self.load_state_from_file(asset_id)
175
+ if state:
176
+ self.save_state(state)
177
+ return state
soar_sdk/app.py CHANGED
@@ -6,6 +6,7 @@ import uuid
6
6
  from collections.abc import Callable, Iterator
7
7
  from pathlib import Path
8
8
  from typing import Any
9
+ from urllib.parse import urlparse
9
10
  from zoneinfo import ZoneInfo
10
11
 
11
12
  from soar_sdk.abstract import SOARClient, SOARClientAuth
@@ -226,12 +227,16 @@ class App:
226
227
  self._asset = self.asset_cls.model_validate(self._raw_asset_config)
227
228
 
228
229
  asset_id = self.soar_client.get_asset_id()
229
- self._asset._auth_state = AssetState(self.actions_manager, "auth", asset_id)
230
+ app_id = str(self.app_meta_info["appid"])
231
+
232
+ self._asset._auth_state = AssetState(
233
+ self.actions_manager, "auth", asset_id, app_id=app_id
234
+ )
230
235
  self._asset._cache_state = AssetState(
231
- self.actions_manager, "cache", asset_id
236
+ self.actions_manager, "cache", asset_id, app_id=app_id
232
237
  )
233
238
  self._asset._ingest_state = AssetState(
234
- self.actions_manager, "ingest", asset_id
239
+ self.actions_manager, "ingest", asset_id, app_id=app_id
235
240
  )
236
241
  return self._asset
237
242
 
@@ -789,6 +794,47 @@ class App:
789
794
  """Decorator for registering a webhook handler."""
790
795
  return WebhookDecorator(self, url_pattern, allowed_methods)
791
796
 
797
+ def get_webhook_url(self, route: str) -> str:
798
+ """Build the full URL for a webhook route (used for OAuth flow)."""
799
+ system_info = self.soar_client.get("rest/system_info").json()
800
+ base_url = system_info.get("base_url", "").rstrip("/")
801
+ parsed = urlparse(base_url)
802
+
803
+ webhook_port = self._get_webhook_port()
804
+ webhook_base = f"{parsed.scheme}://{parsed.hostname}:{webhook_port}"
805
+
806
+ config = self.actions_manager.get_config()
807
+ directory = config.get(
808
+ "directory", f"{self.app_meta_info['name']}_{self.app_meta_info['appid']}"
809
+ )
810
+ asset_id = str(self.soar_client.get_asset_id())
811
+
812
+ return f"{webhook_base}/webhook/{directory}/{asset_id}/{route}"
813
+
814
+ def _get_webhook_port(self) -> int:
815
+ """Get the webhook port from the feature flag configuration."""
816
+ try:
817
+ response = self.soar_client.get("rest/feature_flag/webhooks")
818
+ if response.status_code == 200:
819
+ data = response.json()
820
+ config = data.get("config", {})
821
+ if port := config.get("webhooks_port"):
822
+ return int(port)
823
+ except Exception: # noqa: S110
824
+ pass
825
+ return 3500
826
+
827
+ def _load_webhook_state(self, asset_id: str) -> None:
828
+ """Load state from file for webhooks."""
829
+ state = self.actions_manager.load_state_from_file(asset_id)
830
+ if state:
831
+ self.actions_manager.save_state(state)
832
+
833
+ def _save_webhook_state(self, asset_id: str) -> None:
834
+ """Save state to file for webhooks."""
835
+ state = self.actions_manager.load_state() or {}
836
+ self.actions_manager.save_state_to_file(asset_id, state)
837
+
792
838
  def handle_webhook(
793
839
  self,
794
840
  method: str,
@@ -813,7 +859,7 @@ class App:
813
859
  user_session_token=soar_auth_token,
814
860
  base_url=soar_base_url,
815
861
  )
816
- self.soar_client.update_client(soar_auth, asset_id)
862
+ self.soar_client.update_client(soar_auth, str(asset_id))
817
863
 
818
864
  normalized_query = {}
819
865
  for key, value in query.items():
@@ -829,6 +875,7 @@ class App:
829
875
 
830
876
  self.actions_manager.override_app_dir(self.app_root)
831
877
  self.actions_manager._load_app_json()
878
+ self._load_webhook_state(str(asset_id))
832
879
  request = WebhookRequest(
833
880
  method=method,
834
881
  headers=headers,
@@ -846,4 +893,5 @@ class App:
846
893
  raise TypeError(
847
894
  f"Webhook handler must return a WebhookResponse, got {type(response)}"
848
895
  )
896
+ self._save_webhook_state(str(asset_id))
849
897
  return response.model_dump()
soar_sdk/asset.py CHANGED
@@ -1,3 +1,4 @@
1
+ from enum import Enum
1
2
  from typing import Any, NotRequired
2
3
  from zoneinfo import ZoneInfo
3
4
 
@@ -17,6 +18,14 @@ remove_when_soar_newer_than(
17
18
  )
18
19
 
19
20
 
21
+ class FieldCategory(str, Enum):
22
+ """Categories used to group asset configuration fields in the SOAR UI."""
23
+
24
+ CONNECTIVITY = "connectivity"
25
+ ACTION = "action"
26
+ INGEST = "ingest"
27
+
28
+
20
29
  def AssetField(
21
30
  description: str | None = None,
22
31
  required: bool = True,
@@ -24,33 +33,36 @@ def AssetField(
24
33
  value_list: list | None = None,
25
34
  sensitive: bool = False,
26
35
  alias: str | None = None,
36
+ category: FieldCategory = FieldCategory.CONNECTIVITY,
37
+ is_file: bool = False,
27
38
  ) -> Any: # noqa: ANN401
28
- """Representation of an asset configuration field.
29
-
30
- The field needs extra metadata that is later used for the configuration of the app.
31
- This function takes care of the required information for the manifest JSON file and fills in defaults.
39
+ """Define an asset configuration field with SOAR-specific metadata.
32
40
 
33
41
  Args:
34
- description: A short description of this parameter. The description is shown
35
- in the asset form as the input's title.
36
- required: Whether or not this config key is mandatory for this asset to function.
37
- If this configuration is not provided, actions cannot be executed on the app.
38
- value_list: To allow the user to choose from a pre-defined list of values
39
- displayed in a drop-down for this configuration key, specify them as a list
40
- for example, ["one", "two", "three"].
41
- sensitive: When True, the field is treated as a password and will be encrypted
42
- and hidden from logs.
42
+ description: Human-friendly label for the field shown in the asset form.
43
+ required: Whether the field must be provided. When True and ``default`` is
44
+ ``None``, the field is marked as required in the manifest.
45
+ default: Default value for optional fields. Ignored when ``required`` is
46
+ True and no explicit default is provided.
47
+ value_list: Optional dropdown options presented to the user.
48
+ sensitive: Marks the field as secret so it is encrypted and hidden from logs.
49
+ alias: Alternate name to emit in the manifest instead of the attribute name.
50
+ category: Grouping used to organize fields in the SOAR UI.
51
+ is_file: Marks the field as a file upload field. The field must be typed as
52
+ str and will receive the file contents as a string.
43
53
 
44
54
  Returns:
45
- The FieldInfo object as pydantic.Field.
55
+ A Pydantic ``Field`` carrying the metadata needed for manifest generation.
46
56
  """
47
- json_schema_extra: dict[str, Any] = {}
57
+ json_schema_extra: dict[str, Any] = {"category": category}
48
58
  if required is not None:
49
59
  json_schema_extra["required"] = required
50
60
  if value_list is not None:
51
61
  json_schema_extra["value_list"] = value_list
52
62
  if sensitive is not None:
53
63
  json_schema_extra["sensitive"] = sensitive
64
+ if is_file:
65
+ json_schema_extra["is_file"] = True
54
66
 
55
67
  # Use ... for required fields
56
68
  field_default: Any = ... if default is None and required else default
@@ -59,7 +71,7 @@ def AssetField(
59
71
  default=field_default,
60
72
  description=description,
61
73
  alias=alias,
62
- json_schema_extra=json_schema_extra if json_schema_extra else None,
74
+ json_schema_extra=json_schema_extra,
63
75
  )
64
76
 
65
77
 
@@ -80,6 +92,7 @@ class AssetFieldSpecification(TypedDict):
80
92
  """
81
93
 
82
94
  data_type: str
95
+ category: FieldCategory
83
96
  description: NotRequired[str]
84
97
  required: NotRequired[bool]
85
98
  default: NotRequired[str | int | float | bool]
@@ -114,7 +127,10 @@ class BaseAsset(BaseModel):
114
127
 
115
128
  Note:
116
129
  Field names cannot start with "_reserved_" or use names reserved by
117
- the SOAR platform to avoid conflicts with internal fields.
130
+ the SOAR platform to avoid conflicts with internal fields. The runtime
131
+ attaches ``auth_state``, ``cache_state``, and ``ingest_state`` when an
132
+ app context is available; accessing them without that context raises
133
+ ``AppContextRequired``.
118
134
  """
119
135
 
120
136
  model_config = ConfigDict(
@@ -124,25 +140,15 @@ class BaseAsset(BaseModel):
124
140
  @model_validator(mode="before")
125
141
  @classmethod
126
142
  def validate_no_reserved_fields(cls, values: dict[str, Any]) -> dict[str, Any]:
127
- """Prevents subclasses from defining fields starting with "_reserved_".
128
-
129
- This validator ensures that asset field names don't conflict with
130
- platform-reserved fields or internal SOAR configuration fields.
143
+ """Prevent subclasses from using names reserved by the platform.
131
144
 
132
- Args:
133
- values: Dictionary of field values being validated.
134
-
135
- Returns:
136
- The validated values dictionary.
145
+ The validator inspects annotated field names to ensure they do not start
146
+ with ``_reserved_`` and do not collide with fields injected by the SOAR
147
+ service (see ``AppConfig``). The ``values`` argument is unused but kept
148
+ for Pydantic compatibility.
137
149
 
138
150
  Raises:
139
- ValueError: If a field name starts with "_reserved_" or conflicts
140
- with platform-reserved field names.
141
-
142
- Note:
143
- The SOAR platform injects fields like "_reserved_credential_management"
144
- into asset configs, so this prevents the entire "_reserved_" namespace
145
- from being used in user-defined assets.
151
+ ValueError: If a reserved or injected field name is used.
146
152
  """
147
153
  for field_name in cls.__annotations__:
148
154
  # The platform injects fields like "_reserved_credential_management" into asset configs,
@@ -183,33 +189,20 @@ class BaseAsset(BaseModel):
183
189
 
184
190
  @classmethod
185
191
  def to_json_schema(cls) -> dict[str, AssetFieldSpecification]:
186
- """Generate a JSON schema representation of the asset configuration.
192
+ """Generate manifest-ready schema entries from the asset definition.
187
193
 
188
- Converts the Pydantic model fields into a format compatible with SOAR's
189
- asset configuration system. This includes data type mapping, validation
190
- rules, and UI hints for the SOAR platform.
194
+ Each field is converted into a SOAR manifest dictionary that includes the
195
+ data type, requirement flag, default value, dropdown options, and an order
196
+ index. Alias names are honored when present. Sensitive fields are emitted
197
+ as ``password`` data types and must be annotated as ``str``. Defaults are
198
+ serialized directly, with ``ZoneInfo`` defaults represented by their key.
191
199
 
192
200
  Returns:
193
- A dictionary mapping field names to their schema specifications,
194
- including data types, descriptions, requirements, and other metadata.
201
+ Mapping of field (or alias) names to schema specifications.
195
202
 
196
203
  Raises:
197
- TypeError: If a field type cannot be serialized or if a sensitive
198
- field is not of type str.
199
-
200
- Example:
201
- >>> class MyAsset(BaseAsset):
202
- ... host: str = AssetField(description="Server hostname")
203
- ... port: int = AssetField(description="Server port", default=443)
204
- >>> schema = MyAsset.to_json_schema()
205
- >>> schema["host"]["data_type"]
206
- 'string'
207
- >>> schema["host"]["required"]
208
- True
209
-
210
- Note:
211
- Sensitive fields are automatically converted to "password" type
212
- regardless of their Python type annotation, and must be str type.
204
+ TypeError: If a field type cannot be serialized or a sensitive field is
205
+ not declared as ``str``.
213
206
  """
214
207
  params: dict[str, AssetFieldSpecification] = {}
215
208
 
@@ -234,6 +227,13 @@ class BaseAsset(BaseModel):
234
227
  )
235
228
  type_name = "password"
236
229
 
230
+ if json_schema_extra.get("is_file", False):
231
+ if field_type is not str:
232
+ raise TypeError(
233
+ f"File parameter {field_name} must be type str, not {field_type.__name__}"
234
+ )
235
+ type_name = "file"
236
+
237
237
  if not (description := field.description):
238
238
  description = cls._default_field_description(field_name)
239
239
 
@@ -242,6 +242,7 @@ class BaseAsset(BaseModel):
242
242
  required=bool(json_schema_extra.get("required", True)),
243
243
  description=description,
244
244
  order=field_order,
245
+ category=json_schema_extra.get("category", FieldCategory.CONNECTIVITY),
245
246
  )
246
247
 
247
248
  if (default := field.default) not in (PydanticUndefined, None):
@@ -258,12 +259,7 @@ class BaseAsset(BaseModel):
258
259
 
259
260
  @classmethod
260
261
  def fields_requiring_decryption(cls) -> set[str]:
261
- """Set of fields that require decryption.
262
-
263
- Returns:
264
- A set of field names that are marked as sensitive and need
265
- decryption before use.
266
- """
262
+ """Return attribute names marked as sensitive (aliases are ignored)."""
267
263
  return {
268
264
  field_name
269
265
  for field_name, field in cls.model_fields.items()
@@ -273,11 +269,7 @@ class BaseAsset(BaseModel):
273
269
 
274
270
  @classmethod
275
271
  def timezone_fields(cls) -> set[str]:
276
- """Set of fields that use the ZoneInfo type.
277
-
278
- Returns:
279
- A set of field names that use the ZoneInfo type.
280
- """
272
+ """Return attribute names typed as ``ZoneInfo`` (aliases are ignored)."""
281
273
  return {
282
274
  field_name
283
275
  for field_name, field in cls.model_fields.items()
@@ -290,21 +282,21 @@ class BaseAsset(BaseModel):
290
282
 
291
283
  @property
292
284
  def auth_state(self) -> AssetState:
293
- """A place to store authentication data, such as session and refresh tokens, between action runs. This data is stored by the SOAR service, and is encrypted at rest."""
285
+ """Authentication state persisted by SOAR (encrypted at rest); raises if no app context."""
294
286
  if self._auth_state is None:
295
287
  raise AppContextRequired()
296
288
  return self._auth_state
297
289
 
298
290
  @property
299
291
  def cache_state(self) -> AssetState:
300
- """A place to cache miscellaneous data between action runs. This data is stored by the SOAR service, and is encrypted at rest."""
292
+ """Cache for miscellaneous data persisted by SOAR (encrypted at rest); raises if no app context."""
301
293
  if self._cache_state is None:
302
294
  raise AppContextRequired()
303
295
  return self._cache_state
304
296
 
305
297
  @property
306
298
  def ingest_state(self) -> AssetState:
307
- """A place to store ingestion information, such as checkpoints, between action runs. This data is stored by the SOAR service, and is encrypted at rest."""
299
+ """Ingestion checkpoints persisted by SOAR (encrypted at rest); raises if no app context."""
308
300
  if self._ingest_state is None:
309
301
  raise AppContextRequired()
310
302
  return self._ingest_state
soar_sdk/asset_state.py CHANGED
@@ -1,24 +1,34 @@
1
1
  import json
2
2
  from collections.abc import Iterator, MutableMapping
3
+ from typing import Any
3
4
 
4
5
  from soar_sdk.shims.phantom.base_connector import BaseConnector
5
6
  from soar_sdk.shims.phantom.encryption_helper import encryption_helper
6
7
 
7
8
  AssetStateKeyType = str
8
- AssetStateValueType = str | bool | int | float | None
9
+ AssetStateValueType = Any
9
10
  AssetStateType = dict[AssetStateKeyType, AssetStateValueType]
10
11
 
11
12
 
12
13
  class AssetState(MutableMapping[AssetStateKeyType, AssetStateValueType]):
13
14
  """An adapter to the asset state stored within SOAR. The state can be split into multiple partitions; this object represents a single partition. State is automatically encrypted at rest."""
14
15
 
15
- def __init__(self, backend: BaseConnector, state_key: str, asset_id: str) -> None:
16
+ def __init__(
17
+ self,
18
+ backend: BaseConnector,
19
+ state_key: str,
20
+ asset_id: str,
21
+ app_id: str | None = None,
22
+ ) -> None:
16
23
  self.backend = backend
17
24
  self.state_key = state_key
18
25
  self.asset_id = asset_id
26
+ self.app_id = app_id
19
27
 
20
- def get_all(self) -> AssetStateType:
28
+ def get_all(self, *, force_reload: bool = False) -> AssetStateType:
21
29
  """Get the entirety of this part of the asset state."""
30
+ if force_reload:
31
+ self.backend.reload_state_from_file(self.asset_id)
22
32
  state = self.backend.load_state() or {}
23
33
  if not (part_encrypted := state.get(self.state_key)):
24
34
  return {}
@@ -0,0 +1,41 @@
1
+ from soar_sdk.auth.client import (
2
+ CertificateOAuthClient,
3
+ OAuthClientError,
4
+ SOARAssetOAuthClient,
5
+ )
6
+ from soar_sdk.auth.factories import (
7
+ create_oauth_auth,
8
+ create_oauth_callback_handler,
9
+ create_oauth_client,
10
+ )
11
+ from soar_sdk.auth.flows import (
12
+ AuthorizationCodeFlow,
13
+ ClientCredentialsFlow,
14
+ )
15
+ from soar_sdk.auth.httpx_auth import (
16
+ BasicAuth,
17
+ OAuthBearerAuth,
18
+ StaticTokenAuth,
19
+ )
20
+ from soar_sdk.auth.models import (
21
+ CertificateCredentials,
22
+ OAuthConfig,
23
+ OAuthToken,
24
+ )
25
+
26
+ __all__ = [
27
+ "AuthorizationCodeFlow",
28
+ "BasicAuth",
29
+ "CertificateCredentials",
30
+ "CertificateOAuthClient",
31
+ "ClientCredentialsFlow",
32
+ "OAuthBearerAuth",
33
+ "OAuthClientError",
34
+ "OAuthConfig",
35
+ "OAuthToken",
36
+ "SOARAssetOAuthClient",
37
+ "StaticTokenAuth",
38
+ "create_oauth_auth",
39
+ "create_oauth_callback_handler",
40
+ "create_oauth_client",
41
+ ]