splunk-soar-sdk 3.2.3__py3-none-any.whl → 3.3.1__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.
soar_sdk/abstract.py CHANGED
@@ -90,7 +90,7 @@ class SOARClient(Generic[SummaryType]):
90
90
  headers=headers,
91
91
  cookies=cookies,
92
92
  timeout=timeout,
93
- auth=auth,
93
+ auth=auth or httpx.USE_CLIENT_DEFAULT,
94
94
  follow_redirects=follow_redirects,
95
95
  extensions=extensions,
96
96
  )
@@ -125,7 +125,7 @@ class SOARClient(Generic[SummaryType]):
125
125
  json=json,
126
126
  params=params,
127
127
  cookies=cookies,
128
- auth=auth, # type: ignore[arg-type]
128
+ auth=auth or httpx.USE_CLIENT_DEFAULT,
129
129
  timeout=timeout,
130
130
  follow_redirects=follow_redirects,
131
131
  extensions=extensions,
@@ -161,7 +161,7 @@ class SOARClient(Generic[SummaryType]):
161
161
  json=json,
162
162
  params=params,
163
163
  cookies=cookies,
164
- auth=auth, # type: ignore[arg-type]
164
+ auth=auth or httpx.USE_CLIENT_DEFAULT,
165
165
  timeout=timeout,
166
166
  follow_redirects=follow_redirects,
167
167
  extensions=extensions,
@@ -189,7 +189,7 @@ class SOARClient(Generic[SummaryType]):
189
189
  params=params,
190
190
  headers=headers,
191
191
  cookies=cookies,
192
- auth=auth, # type: ignore[arg-type]
192
+ auth=auth or httpx.USE_CLIENT_DEFAULT,
193
193
  timeout=timeout,
194
194
  follow_redirects=follow_redirects,
195
195
  extensions=extensions,
@@ -1,5 +1,6 @@
1
1
  from typing import Any
2
2
  import os
3
+ from pathlib import Path
3
4
 
4
5
  from soar_sdk.compat import remove_when_soar_newer_than
5
6
  from soar_sdk.input_spec import InputSpecification
@@ -12,11 +13,6 @@ from soar_sdk.shims.phantom.action_result import ActionResult as PhantomActionRe
12
13
  from soar_sdk.shims.phantom.install_info import is_onprem_broker_install
13
14
  from soar_sdk.logging import getLogger
14
15
 
15
-
16
- _INGEST_STATE_KEY = "ingestion_state"
17
- _AUTH_STATE_KEY = "auth_state"
18
- _CACHE_STATE_KEY = "asset_cache"
19
-
20
16
  logger = getLogger()
21
17
 
22
18
 
@@ -27,9 +23,7 @@ class ActionsManager(BaseConnector):
27
23
  super().__init__()
28
24
 
29
25
  self._actions: dict[str, Action] = {}
30
- self.ingestion_state: dict = {}
31
- self.auth_state: dict = {}
32
- self.asset_cache: dict = {}
26
+ self.__app_dir: Path | None = None
33
27
 
34
28
  def get_action(self, identifier: str) -> Action | None:
35
29
  """Convenience method for getting an Action callable from its identifier.
@@ -95,35 +89,6 @@ class ActionsManager(BaseConnector):
95
89
  else:
96
90
  raise RuntimeError(f"Action {action_id} not found.")
97
91
 
98
- def initialize(self) -> bool:
99
- """Load asset state into memory at initialization, splitting it into 3 categories.
100
-
101
- Asset state is used to store data that needs to be accessed across actions.
102
- Chiefly, it is used to store ingestion state, authentication state, and/or
103
- used as an asset cache. Returns True only to conform with the BaseConnector interface.
104
- """
105
- state = self.load_state() or {}
106
- self.ingestion_state = state.get(_INGEST_STATE_KEY, {})
107
- self.auth_state = state.get(_AUTH_STATE_KEY, {})
108
- self.asset_cache = state.get(_CACHE_STATE_KEY, {})
109
-
110
- return True
111
-
112
- def finalize(self) -> bool:
113
- """Save asset state from memory into persistent storage at finalization.
114
-
115
- Joins the SDK's 3 categories of asset state into a single dictionary, conforming
116
- to the platform's expectations, and saves it.
117
- Returns True only to conform with the BaseConnector interface.
118
- """
119
- state = {
120
- _INGEST_STATE_KEY: self.ingestion_state,
121
- _AUTH_STATE_KEY: self.auth_state,
122
- _CACHE_STATE_KEY: self.asset_cache,
123
- }
124
- self.save_state(state)
125
- return True
126
-
127
92
  def add_result(self, action_result: PhantomActionResult) -> PhantomActionResult:
128
93
  """Wrapper for BaseConnector's add_action_result method."""
129
94
  return self.add_action_result(action_result)
@@ -145,6 +110,10 @@ class ActionsManager(BaseConnector):
145
110
 
146
111
  Returns APP_HOME directly on brokers, which contains the correct SDK app path.
147
112
  """
113
+ # If the app dir has been overridden by calling `override_app_dir`, return that
114
+ if self.__app_dir:
115
+ return self.__app_dir.as_posix()
116
+
148
117
  # Remove when 7.1.0 is the min supported broker version
149
118
  remove_when_soar_newer_than("7.1.1")
150
119
  # On AB, APP_HOME is set by spawn to the full app path at runtime
@@ -165,3 +134,10 @@ class ActionsManager(BaseConnector):
165
134
  def get_soar_base_url(cls) -> str:
166
135
  """Get the base URL of the Splunk SOAR instance this app is running on."""
167
136
  return cls._get_phantom_base_url()
137
+
138
+ def override_app_dir(self, app_dir: Path) -> None:
139
+ """Request that the given app_dir be used, instead of whatever super().get_app_dir() returns.
140
+
141
+ 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.
142
+ """
143
+ self.__app_dir = app_dir
soar_sdk/app.py CHANGED
@@ -31,6 +31,7 @@ from soar_sdk.types import Action
31
31
  from soar_sdk.webhooks.routing import Router
32
32
  from soar_sdk.webhooks.models import WebhookRequest, WebhookResponse
33
33
  from soar_sdk.exceptions import ActionRegistrationError
34
+ from soar_sdk.asset_state import AssetState
34
35
 
35
36
  import uuid
36
37
  from soar_sdk.decorators import (
@@ -134,6 +135,8 @@ class App:
134
135
  self.actions_manager: ActionsManager = ActionsManager()
135
136
  self.soar_client: SOARClient = AppClient()
136
137
 
138
+ self.app_root = Path(inspect.stack()[1].filename).parent.parent
139
+
137
140
  def get_actions(self) -> dict[str, Action]:
138
141
  """Returns the list of actions registered in the app."""
139
142
  return self.actions_manager.get_actions()
@@ -224,6 +227,15 @@ class App:
224
227
  """Returns the asset instance for the app."""
225
228
  if not hasattr(self, "_asset"):
226
229
  self._asset = self.asset_cls.model_validate(self._raw_asset_config)
230
+
231
+ asset_id = self.soar_client.get_asset_id()
232
+ self._asset._auth_state = AssetState(self.actions_manager, "auth", asset_id)
233
+ self._asset._cache_state = AssetState(
234
+ self.actions_manager, "cache", asset_id
235
+ )
236
+ self._asset._ingest_state = AssetState(
237
+ self.actions_manager, "ingest", asset_id
238
+ )
227
239
  return self._asset
228
240
 
229
241
  def register_action(
@@ -799,6 +811,7 @@ class App:
799
811
  _, soar_auth_token = soar_rest_client.session.headers["Cookie"].split("=")
800
812
  asset_id = soar_rest_client.asset_id
801
813
  soar_base_url = soar_rest_client.base_url
814
+ soar_base_url = soar_base_url.removesuffix("/rest")
802
815
  soar_auth = SOARClientAuth(
803
816
  user_session_token=soar_auth_token,
804
817
  base_url=soar_base_url,
@@ -817,6 +830,8 @@ class App:
817
830
  else:
818
831
  normalized_query[key] = [value]
819
832
 
833
+ self.actions_manager.override_app_dir(self.app_root)
834
+ self.actions_manager._load_app_json()
820
835
  request = WebhookRequest(
821
836
  method=method,
822
837
  headers=headers,
soar_sdk/app_client.py CHANGED
@@ -93,6 +93,7 @@ class AppClient(SOARClient[SummaryType]):
93
93
  verify=False, # noqa: S501
94
94
  )
95
95
  if session_id:
96
+ self._client.cookies.set("sessionid", session_id)
96
97
  self.__login()
97
98
  else:
98
99
  if soar_auth.username:
@@ -106,7 +107,7 @@ class AppClient(SOARClient[SummaryType]):
106
107
  self._client.headers.update({"Cookie": update_cookies})
107
108
 
108
109
  def __login(self) -> None:
109
- response = self._client.get("/login")
110
+ response = self._client.get("/login", follow_redirects=True)
110
111
  response.raise_for_status()
111
112
  self.csrf_token = response.cookies.get("csrftoken") or ""
112
113
  self._client.cookies.update(response.cookies)
soar_sdk/asset.py CHANGED
@@ -11,6 +11,8 @@ from soar_sdk.compat import remove_when_soar_newer_than
11
11
  from soar_sdk.meta.datatypes import as_datatype
12
12
  from soar_sdk.input_spec import AppConfig
13
13
  from soar_sdk.field_utils import parse_json_schema_extra
14
+ from soar_sdk.asset_state import AssetState
15
+ from soar_sdk.exceptions import AppContextRequired
14
16
 
15
17
  remove_when_soar_newer_than(
16
18
  "7.0.0", "NotRequired from typing_extensions is in typing in Python 3.11+"
@@ -283,3 +285,28 @@ class BaseAsset(BaseModel):
283
285
  for field_name, field in cls.model_fields.items()
284
286
  if field.annotation is ZoneInfo
285
287
  }
288
+
289
+ _auth_state: AssetState | None = None
290
+ _cache_state: AssetState | None = None
291
+ _ingest_state: AssetState | None = None
292
+
293
+ @property
294
+ def auth_state(self) -> AssetState:
295
+ """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."""
296
+ if self._auth_state is None:
297
+ raise AppContextRequired()
298
+ return self._auth_state
299
+
300
+ @property
301
+ def cache_state(self) -> AssetState:
302
+ """A place to cache miscellaneous data between action runs. This data is stored by the SOAR service, and is encrypted at rest."""
303
+ if self._cache_state is None:
304
+ raise AppContextRequired()
305
+ return self._cache_state
306
+
307
+ @property
308
+ def ingest_state(self) -> AssetState:
309
+ """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."""
310
+ if self._ingest_state is None:
311
+ raise AppContextRequired()
312
+ return self._ingest_state
@@ -0,0 +1,54 @@
1
+ import json
2
+ from collections.abc import MutableMapping, Iterator
3
+
4
+ from soar_sdk.shims.phantom.base_connector import BaseConnector
5
+ from soar_sdk.shims.phantom.encryption_helper import encryption_helper
6
+
7
+
8
+ AssetStateKeyType = str
9
+ AssetStateValueType = str | bool | int | float | None
10
+ AssetStateType = dict[AssetStateKeyType, AssetStateValueType]
11
+
12
+
13
+ class AssetState(MutableMapping[AssetStateKeyType, AssetStateValueType]):
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."""
15
+
16
+ def __init__(self, backend: BaseConnector, state_key: str, asset_id: str) -> None:
17
+ self.backend = backend
18
+ self.state_key = state_key
19
+ self.asset_id = asset_id
20
+
21
+ def get_all(self) -> AssetStateType:
22
+ """Get the entirety of this part of the asset state."""
23
+ state = self.backend.load_state() or {}
24
+ if not (part_encrypted := state.get(self.state_key)):
25
+ return {}
26
+ part_json = encryption_helper.decrypt(part_encrypted, self.asset_id)
27
+ return json.loads(part_json)
28
+
29
+ def put_all(self, new_value: AssetStateType) -> None:
30
+ """Entirely replace this part of the asset state."""
31
+ part_json = json.dumps(new_value)
32
+ part_encrypted = encryption_helper.encrypt(part_json, salt=self.asset_id)
33
+ state = self.backend.load_state() or {}
34
+ state[self.state_key] = part_encrypted
35
+ self.backend.save_state(state)
36
+
37
+ def __getitem__(self, key: AssetStateKeyType) -> AssetStateValueType:
38
+ return self.get_all()[key]
39
+
40
+ def __setitem__(self, key: AssetStateKeyType, value: AssetStateValueType) -> None:
41
+ s = self.get_all()
42
+ s[key] = value
43
+ self.put_all(s)
44
+
45
+ def __delitem__(self, key: AssetStateKeyType) -> None:
46
+ s = self.get_all()
47
+ del s[key]
48
+ self.put_all(s)
49
+
50
+ def __iter__(self) -> Iterator[AssetStateKeyType]:
51
+ yield from self.get_all().keys()
52
+
53
+ def __len__(self) -> int:
54
+ return len(self.get_all().keys())
soar_sdk/cli/cli.py CHANGED
@@ -5,6 +5,7 @@ from soar_sdk.paths import SDK_ROOT
5
5
  from soar_sdk.cli.manifests.cli import manifests
6
6
  from soar_sdk.cli.package.cli import package
7
7
  from soar_sdk.cli.init.cli import init, convert
8
+ from soar_sdk.cli.test.cli import test
8
9
 
9
10
  CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
10
11
  HELP = """A command-line tool for helping with SOAR Apps development"""
@@ -18,6 +19,7 @@ app.add_typer(manifests, name="manifests")
18
19
  app.add_typer(package, name="package")
19
20
  app.add_typer(init, name="init")
20
21
  app.add_typer(convert, name="convert")
22
+ app.add_typer(test, name="test")
21
23
 
22
24
 
23
25
  @app.command("version")
@@ -68,6 +68,11 @@ class ManifestProcessor:
68
68
 
69
69
  if app.webhook_meta is not None:
70
70
  app_meta.webhook = app.webhook_meta
71
+ module_name = self.get_module_dot_path(app_meta.main_module)
72
+ app_instance_name = app_meta.main_module.split(":")[-1]
73
+ app_meta.webhook.handler = (
74
+ f"{module_name}.{app_instance_name}.handle_webhook"
75
+ )
71
76
 
72
77
  return app_meta
73
78
 
@@ -206,7 +206,11 @@ def build(
206
206
 
207
207
 
208
208
  async def upload_app(
209
- soar_instance: str, username: str, password: str, app_tarball: Path
209
+ soar_instance: str,
210
+ username: str,
211
+ password: str,
212
+ app_tarball: Path,
213
+ force: bool = False,
210
214
  ) -> httpx.Response:
211
215
  """Asynchronously upload an app tgz to a Splunk SOAR system, via REST API."""
212
216
  base_url = (
@@ -217,12 +221,14 @@ async def upload_app(
217
221
 
218
222
  payload = {"app": app_tarball.read_bytes()}
219
223
  async with phantom_get_login_session(base_url, username, password) as client:
220
- response = await phantom_install_app(client, "app_install", payload)
224
+ response = await phantom_install_app(client, "app_install", payload, force)
221
225
  return response
222
226
 
223
227
 
224
228
  @package.command()
225
- def install(app_tarball: Path, soar_instance: str, username: str = "") -> None:
229
+ def install(
230
+ app_tarball: Path, soar_instance: str, username: str = "", force: bool = False
231
+ ) -> None:
226
232
  """Install the app tgz to the specified Splunk SOAR system.
227
233
 
228
234
  ..note:
@@ -243,7 +249,7 @@ def install(app_tarball: Path, soar_instance: str, username: str = "") -> None:
243
249
  password = typer.prompt("Please enter your SOAR password", hide_input=True)
244
250
 
245
251
  app_install_request = asyncio.run(
246
- upload_app(soar_instance, username, password, app_tarball)
252
+ upload_app(soar_instance, username, password, app_tarball, force)
247
253
  )
248
254
 
249
255
  try:
@@ -14,37 +14,38 @@ async def phantom_get_login_session(
14
14
  base_url=base_url,
15
15
  verify=False, # noqa: S501
16
16
  timeout=timeout,
17
+ auth=(username, password), # Use HTTP Basic Auth
17
18
  ) as client:
18
- # get the cookies from the get method
19
- response = await client.get("/login")
19
+ # Get CSRF token by hitting home page (follow redirects)
20
+ response = await client.get("/", follow_redirects=True)
20
21
  response.raise_for_status()
21
22
  csrf_token = response.cookies.get("csrftoken")
23
+ if not csrf_token:
24
+ raise RuntimeError("Could not obtain CSRF token from SOAR instance")
22
25
  client.cookies.update(response.cookies)
23
26
 
24
- await client.post(
25
- "/login",
26
- data={
27
- "username": username,
28
- "password": password,
29
- "csrfmiddlewaretoken": csrf_token,
30
- },
31
- headers={"Referer": f"{base_url}/login"},
32
- )
33
-
34
27
  yield client
35
28
 
36
29
 
37
30
  async def phantom_install_app(
38
- client: httpx.AsyncClient, endpoint: str, files: dict[str, bytes]
31
+ client: httpx.AsyncClient,
32
+ endpoint: str,
33
+ files: dict[str, bytes],
34
+ force: bool = False,
39
35
  ) -> httpx.Response:
40
36
  """Send a POST request with a CSRF token to the specified endpoint using an authenticated token."""
41
37
  csrftoken = client.cookies.get("csrftoken")
38
+ if not csrftoken:
39
+ raise RuntimeError("CSRF token not found in cookies")
42
40
 
43
41
  response = await client.post(
44
42
  endpoint,
45
43
  files=files,
46
- data={"csrfmiddlewaretoken": csrftoken},
47
- headers={"Referer": f"{client.base_url}/{endpoint}"},
44
+ data={"csrfmiddlewaretoken": csrftoken, "forced_installation": force},
45
+ headers={
46
+ "Referer": f"{client.base_url}/",
47
+ "X-CSRFToken": csrftoken,
48
+ },
48
49
  )
49
50
 
50
51
  return response
@@ -36,8 +36,3 @@ def context_directory(path: Path) -> Iterator[None]:
36
36
  yield
37
37
  finally:
38
38
  os.chdir(original_dir)
39
-
40
-
41
- def relative_to_cwd(path: Path) -> str:
42
- """Reinterpret the given path as relative to the current working directory."""
43
- return path.relative_to(Path.cwd()).as_posix()
File without changes
@@ -0,0 +1,296 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Annotated
8
+
9
+ import typer
10
+ from rich.console import Console
11
+
12
+ test = typer.Typer(
13
+ help="Run unit and integration tests",
14
+ )
15
+
16
+ console = Console()
17
+
18
+
19
+ @test.command()
20
+ def unit(
21
+ parallel: Annotated[
22
+ bool,
23
+ typer.Option(
24
+ "--parallel/--no-parallel",
25
+ "-p",
26
+ help="Run tests in parallel using pytest-xdist",
27
+ ),
28
+ ] = True,
29
+ coverage: Annotated[
30
+ bool,
31
+ typer.Option(
32
+ "--coverage",
33
+ "-c",
34
+ help="Run with coverage reporting",
35
+ ),
36
+ ] = False,
37
+ verbose: Annotated[
38
+ bool,
39
+ typer.Option(
40
+ "--verbose",
41
+ "-v",
42
+ help="Verbose test output",
43
+ ),
44
+ ] = False,
45
+ test_path: Annotated[
46
+ Path | None,
47
+ typer.Option(
48
+ "--test-path",
49
+ "-t",
50
+ help="Path to specific test file or directory",
51
+ ),
52
+ ] = None,
53
+ junit_xml: Annotated[
54
+ Path | None,
55
+ typer.Option(
56
+ "--junit-xml",
57
+ help="Path to save JUnit XML test results",
58
+ ),
59
+ ] = None,
60
+ ) -> None:
61
+ """Run unit tests.
62
+
63
+ This command runs the unit test suite, excluding integration tests.
64
+ By default, tests run in parallel for faster execution.
65
+
66
+ Examples:
67
+ # Run all unit tests in parallel
68
+ soarapps test unit
69
+
70
+ # Run unit tests with coverage
71
+ soarapps test unit --coverage
72
+
73
+ # Run specific test file
74
+ soarapps test unit -t tests/test_decorators.py
75
+
76
+ # Run without parallelism
77
+ soarapps test unit --no-parallel
78
+ """
79
+ pytest_args = [
80
+ sys.executable,
81
+ "-m",
82
+ "pytest",
83
+ ]
84
+
85
+ if test_path:
86
+ pytest_args.append(str(test_path))
87
+
88
+ pytest_args.extend(
89
+ [
90
+ "-m",
91
+ "not integration",
92
+ "--tb=short",
93
+ "--color=yes",
94
+ "-o",
95
+ "addopts=",
96
+ ]
97
+ )
98
+
99
+ if parallel:
100
+ pytest_args.extend(["-n", "auto"])
101
+
102
+ if not coverage:
103
+ pytest_args.append("--no-cov")
104
+
105
+ if verbose:
106
+ pytest_args.append("-v")
107
+
108
+ if junit_xml:
109
+ pytest_args.append(f"--junitxml={junit_xml}")
110
+
111
+ console.print("[bold green]Running unit tests[/bold green]")
112
+ if test_path:
113
+ console.print(f"[dim]Test path: {test_path}[/dim]")
114
+ console.print(f"[dim]Parallel: {parallel}[/dim]")
115
+ console.print(f"[dim]Coverage: {coverage}[/dim]")
116
+
117
+ try:
118
+ result = subprocess.run( # noqa: S603
119
+ pytest_args,
120
+ check=False,
121
+ )
122
+ if result.returncode != 0:
123
+ console.print(f"[red]Tests failed with exit code {result.returncode}[/red]")
124
+ raise typer.Exit(result.returncode)
125
+ else:
126
+ console.print("[bold green]All tests passed![/bold green]")
127
+ except KeyboardInterrupt:
128
+ console.print("[yellow]Tests interrupted by user[/yellow]")
129
+ raise typer.Exit(130) from None
130
+
131
+
132
+ @test.command()
133
+ def integration(
134
+ instance_ip: Annotated[
135
+ str,
136
+ typer.Argument(
137
+ help="SOAR instance IP address or hostname",
138
+ ),
139
+ ],
140
+ username: Annotated[
141
+ str | None,
142
+ typer.Option(
143
+ "--username",
144
+ "-u",
145
+ help="SOAR instance username",
146
+ envvar="PHANTOM_USERNAME",
147
+ ),
148
+ ] = None,
149
+ password: Annotated[
150
+ str | None,
151
+ typer.Option(
152
+ "--password",
153
+ "-p",
154
+ help="SOAR instance password",
155
+ envvar="PHANTOM_PASSWORD",
156
+ ),
157
+ ] = None,
158
+ retries: Annotated[
159
+ int,
160
+ typer.Option(
161
+ "--retries",
162
+ "-r",
163
+ help="Number of test retries on failure",
164
+ ),
165
+ ] = 2,
166
+ automation_broker: Annotated[
167
+ str | None,
168
+ typer.Option(
169
+ "--automation-broker",
170
+ "-ab",
171
+ help="Automation broker name for on-prem tests",
172
+ envvar="AUTOMATION_BROKER_NAME",
173
+ ),
174
+ ] = None,
175
+ force_automation_broker: Annotated[
176
+ bool,
177
+ typer.Option(
178
+ "--force-automation-broker",
179
+ help="Force use of automation broker for all tests",
180
+ envvar="FORCE_AUTOMATION_BROKER",
181
+ ),
182
+ ] = False,
183
+ verbose: Annotated[
184
+ bool,
185
+ typer.Option(
186
+ "--verbose",
187
+ "-v",
188
+ help="Verbose test output",
189
+ ),
190
+ ] = False,
191
+ test_path: Annotated[
192
+ Path | None,
193
+ typer.Option(
194
+ "--test-path",
195
+ "-t",
196
+ help="Path to specific test file or directory",
197
+ ),
198
+ ] = None,
199
+ junit_xml: Annotated[
200
+ Path | None,
201
+ typer.Option(
202
+ "--junit-xml",
203
+ help="Path to save JUnit XML test results",
204
+ ),
205
+ ] = None,
206
+ ) -> None:
207
+ """Run integration tests against a SOAR instance.
208
+
209
+ This command runs the integration test suite against a specified SOAR instance.
210
+ Tests run similar to the GitHub CI workflow.
211
+
212
+ Examples:
213
+ # Run integration tests against a specific instance
214
+ soarapps test integration 10.1.19.88 -u admin -p password
215
+
216
+ # Run tests with automation broker
217
+ soarapps test integration 10.1.19.88 --automation-broker my-broker
218
+
219
+ # Run specific test file
220
+ soarapps test integration 10.1.19.88 -t tests/integration/test_example_app.py
221
+
222
+ # Save test results to file
223
+ soarapps test integration 10.1.19.88 --junit-xml results.xml
224
+ """
225
+ if not username:
226
+ console.print(
227
+ "[red]Error: Username is required (use -u or PHANTOM_USERNAME env var)[/red]"
228
+ )
229
+ raise typer.Exit(1)
230
+
231
+ if not password:
232
+ console.print(
233
+ "[red]Error: Password is required (use -p or PHANTOM_PASSWORD env var)[/red]"
234
+ )
235
+ raise typer.Exit(1)
236
+
237
+ phantom_url = f"https://{instance_ip}"
238
+
239
+ env = os.environ.copy()
240
+ env["PHANTOM_URL"] = phantom_url
241
+ env["PHANTOM_USERNAME"] = username
242
+ env["PHANTOM_PASSWORD"] = password
243
+
244
+ if automation_broker:
245
+ env["AUTOMATION_BROKER_NAME"] = automation_broker
246
+
247
+ if force_automation_broker:
248
+ env["FORCE_AUTOMATION_BROKER"] = "true"
249
+
250
+ test_dir = Path("tests/integration/")
251
+ if test_path:
252
+ test_dir = test_path
253
+
254
+ pytest_args = [
255
+ sys.executable,
256
+ "-m",
257
+ "pytest",
258
+ str(test_dir),
259
+ "-m",
260
+ "integration",
261
+ "--no-cov",
262
+ "--tb=short",
263
+ "--color=yes",
264
+ "-o",
265
+ "addopts=",
266
+ f"--reruns={retries}",
267
+ ]
268
+
269
+ if verbose:
270
+ pytest_args.append("-v")
271
+
272
+ if junit_xml:
273
+ pytest_args.append(f"--junitxml={junit_xml}")
274
+
275
+ console.print(
276
+ f"[bold green]Running integration tests against {instance_ip}[/bold green]"
277
+ )
278
+ console.print(f"[dim]Test directory: {test_dir}[/dim]")
279
+ console.print(f"[dim]Retries: {retries}[/dim]")
280
+ if automation_broker:
281
+ console.print(f"[dim]Automation broker: {automation_broker}[/dim]")
282
+
283
+ try:
284
+ result = subprocess.run( # noqa: S603
285
+ pytest_args,
286
+ env=env,
287
+ check=False,
288
+ )
289
+ if result.returncode != 0:
290
+ console.print(f"[red]Tests failed with exit code {result.returncode}[/red]")
291
+ raise typer.Exit(result.returncode)
292
+ else:
293
+ console.print("[bold green]All tests passed![/bold green]")
294
+ except KeyboardInterrupt:
295
+ console.print("[yellow]Tests interrupted by user[/yellow]")
296
+ raise typer.Exit(130) from None
@@ -2,7 +2,6 @@ import inspect
2
2
  from functools import wraps
3
3
  from pathlib import Path
4
4
 
5
- from soar_sdk.cli.path_utils import relative_to_cwd
6
5
  from soar_sdk.webhooks.models import WebhookRequest, WebhookResponse, WebhookHandler
7
6
  from soar_sdk.meta.webhooks import WebhookRouteMeta
8
7
  from soar_sdk.async_utils import run_async_if_needed
@@ -47,7 +46,9 @@ class WebhookDecorator:
47
46
 
48
47
  stack = inspect.stack()
49
48
  declaration_path_absolute = stack[1].filename
50
- declaration_path = relative_to_cwd(Path(declaration_path_absolute))
49
+ declaration_path = (
50
+ Path(declaration_path_absolute).relative_to(self.app.app_root).as_posix()
51
+ )
51
52
  _, declaration_lineno = inspect.getsourcelines(function)
52
53
 
53
54
  self.app.webhook_router.add_route(
soar_sdk/exceptions.py CHANGED
@@ -51,6 +51,15 @@ class ActionRegistrationError(Exception):
51
51
  super().__init__(f"Error registering action: {action}")
52
52
 
53
53
 
54
+ class AppContextRequired(Exception):
55
+ """Exception raised when trying to access certain features outside the proper context."""
56
+
57
+ def __init__(self) -> None:
58
+ super().__init__(
59
+ "This feature is only available in the context of an action run or webhook handler."
60
+ )
61
+
62
+
54
63
  __all__ = [
55
64
  "ActionFailure",
56
65
  "ActionRegistrationError",
soar_sdk/meta/actions.py CHANGED
@@ -39,6 +39,15 @@ class ActionMeta(BaseModel):
39
39
  "type": self.render_as,
40
40
  }
41
41
 
42
+ if self.view_handler:
43
+ module = self.view_handler.__module__
44
+ module_parts = module.split(".")
45
+ if len(module_parts) > 1:
46
+ relative_module = ".".join(module_parts[1:])
47
+ else:
48
+ relative_module = module
49
+ data["render"]["view"] = f"{relative_module}.{self.view_handler.__name__}"
50
+
42
51
  # Remove view_handler from the output since in render
43
52
  data.pop("view_handler", None)
44
53
  data.pop("render_as", None)
@@ -27,6 +27,8 @@ if TYPE_CHECKING or not _soar_is_available:
27
27
  self.action_results: list[ActionResult] = []
28
28
  self.__conn_result: ConnectorResult
29
29
  self.__conn_result = ConnectorResult()
30
+ self.__state: dict = {}
31
+ self.__app_json: dict = {}
30
32
 
31
33
  @staticmethod
32
34
  def _get_phantom_base_url() -> str:
@@ -121,10 +123,10 @@ if TYPE_CHECKING or not _soar_is_available:
121
123
  return self.config
122
124
 
123
125
  def save_state(self, state: dict) -> None:
124
- self.state = state
126
+ self.__state = state
125
127
 
126
128
  def load_state(self) -> dict:
127
- return self.state
129
+ return self.__state
128
130
 
129
131
  def _set_csrf_info(self, token: str, referer: str) -> None:
130
132
  pass
@@ -140,5 +142,8 @@ if TYPE_CHECKING or not _soar_is_available:
140
142
  remove_when_soar_newer_than("7.1.1")
141
143
  return Path.cwd().as_posix()
142
144
 
145
+ def _load_app_json(self) -> None:
146
+ pass
147
+
143
148
 
144
149
  __all__ = ["BaseConnector"]
@@ -11,16 +11,18 @@ if TYPE_CHECKING or not _soar_is_available:
11
11
  import base64
12
12
 
13
13
  class encryption_helper: # type: ignore[no-redef]
14
- """Simulated encryption helper for environments without BaseConnector."""
14
+ """Simulated encryption helper for environments without BaseConnector.
15
+
16
+ Salt values are optional, as newer versions of SOAR no longer accept them."""
15
17
 
16
18
  @staticmethod
17
- def encrypt(plain: str, salt: str) -> str:
19
+ def encrypt(plain: str, salt: str = "unused-salt") -> str:
18
20
  """Simulates the behavior of encryption_helper.encrypt."""
19
21
  salted = plain + ":" + salt
20
22
  return base64.b64encode(salted.encode("utf-8")).decode("utf-8")
21
23
 
22
24
  @staticmethod
23
- def decrypt(cipher: str, salt: str) -> str:
25
+ def decrypt(cipher: str, salt: str = "unused-salt") -> str:
24
26
  """Simulate the behavior of encryption_helper.decrypt."""
25
27
 
26
28
  if len(cipher) == 0:
@@ -29,7 +31,7 @@ if TYPE_CHECKING or not _soar_is_available:
29
31
  "Parameter validation failed: Invalid length for parameter SecretId, value: 0, valid min length: 1"
30
32
  )
31
33
  decoded = base64.b64decode(cipher.encode("utf-8")).decode("utf-8")
32
- plain, decrypted_salt = decoded.split(":", 1)
34
+ plain, decrypted_salt = decoded.rsplit(":", 1)
33
35
  if salt != decrypted_salt:
34
36
  raise ValueError("Salt does not match")
35
37
  return plain
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: splunk-soar-sdk
3
- Version: 3.2.3
3
+ Version: 3.3.1
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,16 +1,17 @@
1
1
  soar_sdk/__init__.py,sha256=RzAng-ARqpK01SY82lNy4uYJFVG0yW6Q3CccEqbToJ4,726
2
- soar_sdk/abstract.py,sha256=3oJvWljqDTr2nucf-9IdBRMDJwRnDe6nISYKyGuLMxw,7752
2
+ soar_sdk/abstract.py,sha256=0iNvoPNX1DA7ZffYU5Tg7chuQBoiNOXMK1WumHA_1T4,7786
3
3
  soar_sdk/action_results.py,sha256=ajlsed6ZkCyOTra4SFy31iYZO2a6mlvBK2-zIyO09q4,11928
4
- soar_sdk/actions_manager.py,sha256=Ce4zU06QJlbTP3TONfxsS4CWuykcZpQ0mw3ir8ThLsw,6663
5
- soar_sdk/app.py,sha256=xCdBHQgzdIwfyYRHSGFRYpEu8nKPMsULZCrdm5CbRrY,34512
4
+ soar_sdk/actions_manager.py,sha256=0moWQuKNGMFZqhiINtEoLw45SNHhFbC0jzZ9Puv6wgc,5820
5
+ soar_sdk/app.py,sha256=7pJ-t9uQFaIxCTfHXy9hLRRgnRz9y03bMBObCrsNe_g,35185
6
6
  soar_sdk/app_cli_runner.py,sha256=tdglXCIRZS3dc3P18Tha7yUJQX9zIDxJFdST02LL9xY,11644
7
- soar_sdk/app_client.py,sha256=p5dsgxmMK61qLFIn7pATEQkVWldTtQhJPcMs4Idv2Lw,6197
8
- soar_sdk/asset.py,sha256=b4-f7r3JSfR-nUzalpgJnoXAIEHQ34yvIuoyoAFvye0,11055
7
+ soar_sdk/app_client.py,sha256=maoIR0NqACdjb-0noWIyjAz3LMTtvi4EBMO5dBclab0,6282
8
+ soar_sdk/asset.py,sha256=y9ar5yXA8AKCUuVXnLpRn4BYDklf0mxzB24Hw9q58QY,12255
9
+ soar_sdk/asset_state.py,sha256=u71oUfIH0LOZQ2bNkYsC2A_dZp3KA1JzMTd0ujDd3Qw,2102
9
10
  soar_sdk/async_utils.py,sha256=6JtSDd_RKKT85TaUjxofZTKrZr24z74WSljkX47KZqg,1429
10
11
  soar_sdk/colors.py,sha256=--i_iXqfyITUz4O95HMjfZQGbwFZ34bLmBhtfpXXqlQ,1095
11
12
  soar_sdk/compat.py,sha256=-Z9i9azaW4w0ZGEX2GoukseDglLNRWiBsV7auJAyZbs,3061
12
13
  soar_sdk/crypto.py,sha256=qiBMHUQqgn5lPI1DbujSj700s89FuLJrkQgCO9_eBn4,392
13
- soar_sdk/exceptions.py,sha256=bzydqdpzwG5VaVkZBiUEcf9Wp0clMdzqdUBZHzpfqT0,1830
14
+ soar_sdk/exceptions.py,sha256=413-AcIM7IMixoyVk_0yDaqsUhommb784uH5vSv18lU,2129
14
15
  soar_sdk/field_utils.py,sha256=Jb0HteUPd-CtuDM7rNXVLy4uRxl419zeDxY_oOpU8GM,287
15
16
  soar_sdk/input_spec.py,sha256=79MFGF2IkAAoWpHmZYP0VqUBu10SkvmF_RPmXkf8bQ4,4677
16
17
  soar_sdk/logging.py,sha256=30cUxU7Tjf_OY4rUOrvjDoqPH7pcFydrdDbcG4ppY_s,11424
@@ -30,18 +31,20 @@ soar_sdk/app_templates/basic_app/logo_dark.svg,sha256=PTxIs_1CKK9ZY3v-K1QoGwaUng
30
31
  soar_sdk/app_templates/basic_app/uv.lock,sha256=AfgaIBg88KH-0iyXpCXacXAwHYKm0c-on2gWXjV9L-Y,80216
31
32
  soar_sdk/app_templates/basic_app/src/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
32
33
  soar_sdk/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
33
- soar_sdk/cli/cli.py,sha256=62n6b41bjXILtkqLTpeID8JyDYMbkfXNV1LCo52YUsA,926
34
- soar_sdk/cli/path_utils.py,sha256=rFJDAe-sTC_6KgSiidlD3JcpygFmyHAEsn1j_6eWpIc,1263
34
+ soar_sdk/cli/cli.py,sha256=dMb9yPyYphvr9wtk2oG8fEw6n_p4WZ7liK5u6-H8ppg,998
35
+ soar_sdk/cli/path_utils.py,sha256=gNqvSlltDGvbydpOm5RKgjwLs7J9YTqqmyiBIXxaX7c,1087
35
36
  soar_sdk/cli/utils.py,sha256=GNZLFAMH_BKQo2-D5GvweWxeuR7DfR8eMZS4-sJUggU,1441
36
37
  soar_sdk/cli/init/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
37
38
  soar_sdk/cli/init/cli.py,sha256=LWwGb_N5KPpKjnk8Kn3IppSrGsHQb65CvDn2NXPokAE,14618
38
39
  soar_sdk/cli/manifests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
39
40
  soar_sdk/cli/manifests/cli.py,sha256=cly5xVdj4bBIdZVMQPIWTXRgUfd1ON3qKO-76Fwql18,524
40
41
  soar_sdk/cli/manifests/deserializers.py,sha256=TjcmPvcfr1auGkf3hBRwjVc0dSuuDNmH5jMOqXHz27s,16515
41
- soar_sdk/cli/manifests/processors.py,sha256=PbzARZvMUCkQrrzjRVVOsb0dTV_u5BMXx52e3t0V9-w,4797
42
+ soar_sdk/cli/manifests/processors.py,sha256=uuW_5gCPgpUB3o5L3KMo_eqw9Jm5ps1ga4ZqiYLtEJE,5061
42
43
  soar_sdk/cli/manifests/serializers.py,sha256=hSfOcBNhv7s437A7t5BM1NXG5dfjKmh_xbHXQTuBklA,3632
43
- soar_sdk/cli/package/cli.py,sha256=8AWItAAXzfjJMRLwDIRbrN9cQhy7clBX7WrOmdcGClw,9499
44
- soar_sdk/cli/package/utils.py,sha256=zZmpWg76Vb3rtGn28aPhPHt1JUWR6eIByU6DTTRC3ng,1584
44
+ soar_sdk/cli/package/cli.py,sha256=l2AygRmUcvmiXbQhRxSnz8lEK6_nLgWoKpwxY44r9SE,9578
45
+ soar_sdk/cli/package/utils.py,sha256=2qLGpR77t6KJABg4q2iMc2ywOChW4W5D10Ct58v89Is,1711
46
+ soar_sdk/cli/test/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
47
+ soar_sdk/cli/test/cli.py,sha256=iDrthN8L7B1RplLhq0EI69MndaOhvAXn7bqv3XzlfpM,7655
45
48
  soar_sdk/code_renderers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
46
49
  soar_sdk/code_renderers/action_renderer.py,sha256=nPbB3SJLm4VDu_zuwY6vO_LgVg1tvW5ZTrzeS7JdyF8,16489
47
50
  soar_sdk/code_renderers/app_renderer.py,sha256=OCNNhXc36Amcg1kUUayyyzWx_M2RY8W8wvy0ppujLaE,7791
@@ -56,9 +59,9 @@ soar_sdk/decorators/on_es_poll.py,sha256=5wiAcboG45ut7dLr0ZRcKOU01yIBfBrq00laGtv
56
59
  soar_sdk/decorators/on_poll.py,sha256=H_aijFWKeZKwiS72Vsa9uolmj8sziGW2lZF6EhjEDjs,8276
57
60
  soar_sdk/decorators/test_connectivity.py,sha256=tZt7w-BnZUpxCyixblXts4tsUp8z-Kmz-JGJ5i5LQs8,3564
58
61
  soar_sdk/decorators/view_handler.py,sha256=_qjfk2nQxPwraldjRIl4DWdW-tvANJfdVDU_lLA3UvE,7075
59
- soar_sdk/decorators/webhook.py,sha256=buQ9OXVHYc9AwOVoTC0E4fszhmKniW8rmPvaRpKCZko,2369
62
+ soar_sdk/decorators/webhook.py,sha256=PE37CcGzGYykJpU9AVDVtajENLrOIUA0su28lVDQ76M,2366
60
63
  soar_sdk/meta/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
61
- soar_sdk/meta/actions.py,sha256=YadG8Zkc0SWmcG5-Dj4NyWlYYAh4CyJksLYp_GmNZNM,1818
64
+ soar_sdk/meta/actions.py,sha256=UDf_0cZ8lsAhkyhDIWonneNshUG9pzciG1Go9e7NJ6k,2189
62
65
  soar_sdk/meta/adapters.py,sha256=KjSYIUtkCz2eesA_vhsNCjfi5C-Uz71tbSuDIjhuB8U,1112
63
66
  soar_sdk/meta/app.py,sha256=eZlM8GIY1B_o-RzJrRNCNVEQSx0sFupxZqCM7sIWGv4,2777
64
67
  soar_sdk/meta/datatypes.py,sha256=piR-oBVAATiRciXSdVE7XaqjUZTgSaOvTEqcOcNvCS0,795
@@ -73,10 +76,10 @@ soar_sdk/models/vault_attachment.py,sha256=IQPX239OFClVfOKr9nHIu9Is55cXWBaOgM2lG
73
76
  soar_sdk/models/view.py,sha256=frfbNdWfzc0XjiU3CY79zBJxvzUsgLdFmphVeZ6QqTc,777
74
77
  soar_sdk/shims/phantom/action_result.py,sha256=yDiV2f3kt5G9UYejpe0JFeo651f3Uv-fTSoIlfg3DGg,1606
75
78
  soar_sdk/shims/phantom/app.py,sha256=PpNj9FoXjyj6r5w9S2fpElKFS6EcBIqsnpaTSvnIzyI,303
76
- soar_sdk/shims/phantom/base_connector.py,sha256=-uoPGNu0J6Cm8-BMvvlwbaKGTyImbh4dCJXz6HFDiNs,4734
79
+ soar_sdk/shims/phantom/base_connector.py,sha256=85K1vlWXNPHPrHSYDLYiz0aOHD_dKUGHOra_FSSj17o,4873
77
80
  soar_sdk/shims/phantom/connector_result.py,sha256=T6eDXdMyblWB0Xa3RW4ojuhy9wJmWZTpY8Oojl5sYYk,641
78
81
  soar_sdk/shims/phantom/consts.py,sha256=eq6AIuDhb2Z-CJORwv98D3JbcIOW8CC673zx5dNPFKU,404
79
- soar_sdk/shims/phantom/encryption_helper.py,sha256=kiZNwBMgrhvVtNjKVP7ZswEn4DXxH8gTC-9tuxjVzTc,1386
82
+ soar_sdk/shims/phantom/encryption_helper.py,sha256=20VqqSFuftjB8bMriP6mjgvYWpYqYZyokYzq_aydkqU,1503
80
83
  soar_sdk/shims/phantom/install_info.py,sha256=hR3W8pKlNUVlE1jOwGT_o2f6uTGQknPG3GLRDHrGSUQ,994
81
84
  soar_sdk/shims/phantom/json_keys.py,sha256=QgDO5qGlcNNqJBsolJQs0UOW1sa8xMYIubqfCXeP4C4,528
82
85
  soar_sdk/shims/phantom/ph_ipc.py,sha256=RSL_qB64OSsy0B8AvqDh_biR9lqAHKdRkIj973UQfoU,1381
@@ -100,8 +103,8 @@ soar_sdk/views/components/pie_chart.py,sha256=LVTeHVJN6nf2vjUs9y7PDBhS0U1fKW750l
100
103
  soar_sdk/webhooks/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
101
104
  soar_sdk/webhooks/models.py,sha256=PG9SDs5xXqtFndm5C8AsJOTYXU5v_UTY7SpYosWT_CA,4542
102
105
  soar_sdk/webhooks/routing.py,sha256=MnzbnIDy2uG_5mJzsTeX-NsE6QYvzyqEGbHmEFj-DG8,6900
103
- splunk_soar_sdk-3.2.3.dist-info/METADATA,sha256=Efb648n3bteCzbGGNio5f3hU57YeKqOeGVZgI9dMxSs,7334
104
- splunk_soar_sdk-3.2.3.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
105
- splunk_soar_sdk-3.2.3.dist-info/entry_points.txt,sha256=CgBjo2ZWpYNkt9TgvToL26h2Tg1yt8FbvYTb5NVgNuc,51
106
- splunk_soar_sdk-3.2.3.dist-info/licenses/LICENSE,sha256=gNCGrGhrSQb1PUzBOByVUN1tvaliwLZfna-QU2r2hQ8,11345
107
- splunk_soar_sdk-3.2.3.dist-info/RECORD,,
106
+ splunk_soar_sdk-3.3.1.dist-info/METADATA,sha256=3Y2eWE1VKaxWd8ZDzrvflA5jt2ZCX6LdwR2JRUe7VeY,7334
107
+ splunk_soar_sdk-3.3.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
108
+ splunk_soar_sdk-3.3.1.dist-info/entry_points.txt,sha256=CgBjo2ZWpYNkt9TgvToL26h2Tg1yt8FbvYTb5NVgNuc,51
109
+ splunk_soar_sdk-3.3.1.dist-info/licenses/LICENSE,sha256=gNCGrGhrSQb1PUzBOByVUN1tvaliwLZfna-QU2r2hQ8,11345
110
+ splunk_soar_sdk-3.3.1.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.27.0
2
+ Generator: hatchling 1.28.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any