splunk-soar-sdk 3.6.1__py3-none-any.whl → 3.7.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,27 +33,25 @@ 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,
27
37
  ) -> 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.
38
+ """Define an asset configuration field with SOAR-specific metadata.
32
39
 
33
40
  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.
41
+ description: Human-friendly label for the field shown in the asset form.
42
+ required: Whether the field must be provided. When True and ``default`` is
43
+ ``None``, the field is marked as required in the manifest.
44
+ default: Default value for optional fields. Ignored when ``required`` is
45
+ True and no explicit default is provided.
46
+ value_list: Optional dropdown options presented to the user.
47
+ sensitive: Marks the field as secret so it is encrypted and hidden from logs.
48
+ alias: Alternate name to emit in the manifest instead of the attribute name.
49
+ category: Grouping used to organize fields in the SOAR UI.
43
50
 
44
51
  Returns:
45
- The FieldInfo object as pydantic.Field.
52
+ A Pydantic ``Field`` carrying the metadata needed for manifest generation.
46
53
  """
47
- json_schema_extra: dict[str, Any] = {}
54
+ json_schema_extra: dict[str, Any] = {"category": category}
48
55
  if required is not None:
49
56
  json_schema_extra["required"] = required
50
57
  if value_list is not None:
@@ -59,7 +66,7 @@ def AssetField(
59
66
  default=field_default,
60
67
  description=description,
61
68
  alias=alias,
62
- json_schema_extra=json_schema_extra if json_schema_extra else None,
69
+ json_schema_extra=json_schema_extra,
63
70
  )
64
71
 
65
72
 
@@ -80,6 +87,7 @@ class AssetFieldSpecification(TypedDict):
80
87
  """
81
88
 
82
89
  data_type: str
90
+ category: FieldCategory
83
91
  description: NotRequired[str]
84
92
  required: NotRequired[bool]
85
93
  default: NotRequired[str | int | float | bool]
@@ -114,7 +122,10 @@ class BaseAsset(BaseModel):
114
122
 
115
123
  Note:
116
124
  Field names cannot start with "_reserved_" or use names reserved by
117
- the SOAR platform to avoid conflicts with internal fields.
125
+ the SOAR platform to avoid conflicts with internal fields. The runtime
126
+ attaches ``auth_state``, ``cache_state``, and ``ingest_state`` when an
127
+ app context is available; accessing them without that context raises
128
+ ``AppContextRequired``.
118
129
  """
119
130
 
120
131
  model_config = ConfigDict(
@@ -124,25 +135,15 @@ class BaseAsset(BaseModel):
124
135
  @model_validator(mode="before")
125
136
  @classmethod
126
137
  def validate_no_reserved_fields(cls, values: dict[str, Any]) -> dict[str, Any]:
127
- """Prevents subclasses from defining fields starting with "_reserved_".
138
+ """Prevent subclasses from using names reserved by the platform.
128
139
 
129
- This validator ensures that asset field names don't conflict with
130
- platform-reserved fields or internal SOAR configuration fields.
131
-
132
- Args:
133
- values: Dictionary of field values being validated.
134
-
135
- Returns:
136
- The validated values dictionary.
140
+ The validator inspects annotated field names to ensure they do not start
141
+ with ``_reserved_`` and do not collide with fields injected by the SOAR
142
+ service (see ``AppConfig``). The ``values`` argument is unused but kept
143
+ for Pydantic compatibility.
137
144
 
138
145
  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.
146
+ ValueError: If a reserved or injected field name is used.
146
147
  """
147
148
  for field_name in cls.__annotations__:
148
149
  # The platform injects fields like "_reserved_credential_management" into asset configs,
@@ -183,33 +184,20 @@ class BaseAsset(BaseModel):
183
184
 
184
185
  @classmethod
185
186
  def to_json_schema(cls) -> dict[str, AssetFieldSpecification]:
186
- """Generate a JSON schema representation of the asset configuration.
187
+ """Generate manifest-ready schema entries from the asset definition.
187
188
 
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.
189
+ Each field is converted into a SOAR manifest dictionary that includes the
190
+ data type, requirement flag, default value, dropdown options, and an order
191
+ index. Alias names are honored when present. Sensitive fields are emitted
192
+ as ``password`` data types and must be annotated as ``str``. Defaults are
193
+ serialized directly, with ``ZoneInfo`` defaults represented by their key.
191
194
 
192
195
  Returns:
193
- A dictionary mapping field names to their schema specifications,
194
- including data types, descriptions, requirements, and other metadata.
196
+ Mapping of field (or alias) names to schema specifications.
195
197
 
196
198
  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.
199
+ TypeError: If a field type cannot be serialized or a sensitive field is
200
+ not declared as ``str``.
213
201
  """
214
202
  params: dict[str, AssetFieldSpecification] = {}
215
203
 
@@ -242,6 +230,7 @@ class BaseAsset(BaseModel):
242
230
  required=bool(json_schema_extra.get("required", True)),
243
231
  description=description,
244
232
  order=field_order,
233
+ category=json_schema_extra.get("category", FieldCategory.CONNECTIVITY),
245
234
  )
246
235
 
247
236
  if (default := field.default) not in (PydanticUndefined, None):
@@ -258,12 +247,7 @@ class BaseAsset(BaseModel):
258
247
 
259
248
  @classmethod
260
249
  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
- """
250
+ """Return attribute names marked as sensitive (aliases are ignored)."""
267
251
  return {
268
252
  field_name
269
253
  for field_name, field in cls.model_fields.items()
@@ -273,11 +257,7 @@ class BaseAsset(BaseModel):
273
257
 
274
258
  @classmethod
275
259
  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
- """
260
+ """Return attribute names typed as ``ZoneInfo`` (aliases are ignored)."""
281
261
  return {
282
262
  field_name
283
263
  for field_name, field in cls.model_fields.items()
@@ -290,21 +270,21 @@ class BaseAsset(BaseModel):
290
270
 
291
271
  @property
292
272
  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."""
273
+ """Authentication state persisted by SOAR (encrypted at rest); raises if no app context."""
294
274
  if self._auth_state is None:
295
275
  raise AppContextRequired()
296
276
  return self._auth_state
297
277
 
298
278
  @property
299
279
  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."""
280
+ """Cache for miscellaneous data persisted by SOAR (encrypted at rest); raises if no app context."""
301
281
  if self._cache_state is None:
302
282
  raise AppContextRequired()
303
283
  return self._cache_state
304
284
 
305
285
  @property
306
286
  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."""
287
+ """Ingestion checkpoints persisted by SOAR (encrypted at rest); raises if no app context."""
308
288
  if self._ingest_state is None:
309
289
  raise AppContextRequired()
310
290
  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
+ ]