src/app.py¶
This document will dive deeper into the initial structure of the app.py file when starting working with Apps.
The file consists of a few main parts:
Here’s an example app.py file which uses a wide variety of the features available in the SDK:
1from collections.abc import Generator, Iterator
2from datetime import UTC, datetime
3from pathlib import Path
4from zoneinfo import ZoneInfo
5
6from soar_sdk.abstract import SOARClient
7from soar_sdk.action_results import ActionOutput, MakeRequestOutput, OutputField
8from soar_sdk.app import App
9from soar_sdk.asset import AssetField, BaseAsset, FieldCategory
10from soar_sdk.logging import getLogger
11from soar_sdk.models.artifact import Artifact
12from soar_sdk.models.container import Container
13from soar_sdk.models.finding import Finding, FindingAttachment, FindingEmail
14from soar_sdk.params import (
15 MakeRequestParams,
16 OnESPollParams,
17 OnPollParams,
18 Param,
19 Params,
20)
21
22logger = getLogger()
23
24APP_ID = "9b388c08-67de-4ca4-817f-26f8fb7cbf55"
25AUTH_FILESYSTEM_MARKER = "papp-37866-auth-filesystem-marker"
26CACHE_FILESYSTEM_MARKER = "papp-37866-cache-filesystem-marker"
27INGEST_FILESYSTEM_MARKER = "papp-37866-ingest-filesystem-marker"
28
29SAMPLE_EMAILS = [
30 {
31 "from": "phishing@malicious-domain.example.com",
32 "to": "employee1@company.example.com",
33 "subject": "Urgent: Verify your account",
34 "date": None,
35 "body": (
36 "Dear user,\n\n"
37 "Your account has been compromised. Please click the link below "
38 "to verify your identity immediately.\n\n"
39 "https://malicious-login.example.com/verify?token=abc123\n\n"
40 "Regards,\nIT Support"
41 ),
42 "urls": [
43 "https://malicious-login.example.com/verify?token=abc123",
44 ],
45 "attachment_name": "invoice.pdf",
46 "attachment_data": b"fake pdf attachment content",
47 },
48 {
49 "from": "spam@spoofed-bank.example.com",
50 "to": "employee2@company.example.com",
51 "subject": "Your payment is overdue",
52 "date": None,
53 "body": (
54 "Hello,\n\n"
55 "We noticed an outstanding balance on your account. "
56 "Please review the attached statement and submit payment.\n\n"
57 "https://fake-payment.example.com/pay\n\n"
58 "Thank you,\nBilling Department"
59 ),
60 "urls": [
61 "https://fake-payment.example.com/pay",
62 ],
63 "attachment_name": "statement.xlsx",
64 "attachment_data": b"fake spreadsheet content",
65 },
66]
67
68
69class Asset(BaseAsset):
70 base_url: str = AssetField(default="https://example")
71 api_key: str = AssetField(sensitive=True, description="API key for authentication")
72 secret_alias: str = AssetField(
73 sensitive=True,
74 alias="bearer_token",
75 description="Secret asset param with an alias",
76 )
77 key_header: str = AssetField(
78 default="Authorization",
79 value_list=["Authorization", "X-API-Key"],
80 description="Header for API key authentication",
81 )
82 timezone: ZoneInfo
83 timezone_with_default: ZoneInfo = AssetField(
84 default=ZoneInfo("America/Denver"), category=FieldCategory.ACTION
85 )
86
87
88app = App(
89 asset_cls=Asset,
90 name="example_app",
91 appid=APP_ID,
92 app_type="sandbox",
93 product_vendor="Splunk Inc.",
94 logo="logo.svg",
95 logo_dark="logo_dark.svg",
96 product_name="Example App",
97 publisher="Splunk Inc.",
98 min_phantom_version="6.2.2.134",
99)
100
101
102@app.test_connectivity()
103def test_connectivity(soar: SOARClient, asset: Asset) -> None:
104 soar.get("rest/version")
105 container_id = soar.get_executing_container_id()
106 logger.info(f"current executing container's container_id is: {container_id}")
107 asset_id = soar.get_asset_id()
108 logger.info(f"current executing container's asset_id is: {asset_id}")
109 logger.info(f"testing connectivity against {asset.base_url}")
110 logger.debug("hello")
111 logger.warning("this is a warning")
112 logger.progress("this is a progress message")
113 logger.info(f"secret_alias value is {asset.secret_alias}")
114 assert asset.secret_alias == "example bearer"
115
116
117class ActionOutputSummary(ActionOutput):
118 is_success: bool
119
120
121@app.action()
122def test_summary_with_list_output(
123 params: Params, asset: Asset, soar: SOARClient
124) -> list[ActionOutput]:
125 soar.set_summary(ActionOutputSummary(is_success=True))
126 return [ActionOutput(), ActionOutput()]
127
128
129@app.action()
130def test_empty_list_output(
131 params: Params, asset: Asset, soar: SOARClient
132) -> list[ActionOutput]:
133 return []
134
135
136class JsonOutput(ActionOutput):
137 name: str = OutputField(example_values=["John", "Jane", "Jim"], column_name="Name")
138 age: int = OutputField(example_values=[25, 30, 35], column_name="Age")
139
140
141class TableParams(Params):
142 company_name: str = Param(column_name="Company Name", default="Splunk")
143
144
145@app.action(render_as="json")
146def test_json_output(params: Params, asset: Asset, soar: SOARClient) -> JsonOutput:
147 return JsonOutput(name="John", age=25)
148
149
150@app.action(render_as="table")
151def test_table_output(
152 params: TableParams, asset: Asset, soar: SOARClient
153) -> JsonOutput:
154 return JsonOutput(name="John", age=25)
155
156
157from .actions.reverse_string import render_reverse_string_view
158
159app.register_action(
160 "actions.reverse_string:reverse_string",
161 action_type="investigate",
162 read_only=True,
163 verbose="Reverses a string.",
164 view_template="reverse_string.html",
165 view_handler=render_reverse_string_view,
166)
167
168
169app.register_action(
170 "actions.permissive_action:permissive_reverse_string",
171 action_type="investigate",
172 verbose="Reverses a string but doesn't care if it gets all its output fields.",
173 view_template="reverse_string.html",
174 view_handler=render_reverse_string_view,
175)
176
177from .actions.generate_category import render_statistics_chart
178
179app.register_action(
180 "actions.generate_category:generate_statistics",
181 action_type="investigate",
182 verbose="Generate statistics with pie chart reusable component.",
183 view_handler=render_statistics_chart,
184)
185
186
187class MakeRequestParamsCustom(MakeRequestParams):
188 endpoint: str = Param(
189 description="The endpoint to send the request to. Base url is already included in the endpoint.",
190 required=True,
191 )
192
193
194@app.make_request()
195def http_action(params: MakeRequestParamsCustom, asset: Asset) -> MakeRequestOutput:
196 logger.info(f"HTTP action triggered with params: {params}")
197 return MakeRequestOutput(
198 status_code=200,
199 response_body=f"Base url is {asset.base_url}",
200 )
201
202
203@app.on_poll()
204def on_poll(
205 params: OnPollParams, soar: SOARClient, asset: Asset
206) -> Iterator[Container | Artifact]:
207 if params.is_manual_poll():
208 logger.info("Manual poll (poll now) detected")
209 else:
210 logger.info("Scheduled poll detected")
211
212 # Create container first for artifacts
213 yield Container(
214 name="Network Alerts",
215 description="Some network-related alerts",
216 severity="medium",
217 )
218
219 # Simulate collecting 2 network artifacts that will be put in the network alerts container
220 for i in range(1, 3):
221 logger.info(f"Processing network artifact {i}")
222
223 alert_id = f"testalert-{datetime.now(UTC).strftime('%Y%m%d')}-{i}"
224 artifact = Artifact(
225 name=f"Network Alert {i}",
226 label="alert",
227 severity="medium",
228 source_data_identifier=alert_id,
229 type="network",
230 description=f"Example network alert {i} from polling operation",
231 data={
232 "alert_id": alert_id,
233 "source_ip": f"10.0.0.{i}",
234 "destination_ip": "192.168.0.1",
235 "protocol": "TCP",
236 },
237 )
238
239 yield artifact
240
241
242@app.on_es_poll()
243def on_es_poll(
244 params: OnESPollParams, soar: SOARClient, asset: Asset
245) -> Generator[Finding, int | None]:
246 for i, email_data in enumerate(SAMPLE_EMAILS, start=1):
247 logger.info(f"Processing test finding {i}")
248
249 date_str = datetime.now(UTC).strftime("%a, %d %b %Y %H:%M:%S +0000")
250 raw_eml = (
251 f"From: {email_data['from']}\r\n"
252 f"To: {email_data['to']}\r\n"
253 f"Subject: {email_data['subject']}\r\n"
254 f"Date: {date_str}\r\n"
255 f"MIME-Version: 1.0\r\n"
256 f"Content-Type: text/plain; charset=utf-8\r\n"
257 f"\r\n"
258 f"{email_data['body']}"
259 )
260
261 yield Finding(
262 rule_title=f"Test Finding {i}: {email_data['subject']}",
263 email=FindingEmail(
264 headers={
265 "From": email_data["from"],
266 "To": email_data["to"],
267 "Subject": email_data["subject"],
268 "Date": date_str,
269 "Content-Type": "text/plain; charset=utf-8",
270 },
271 body=email_data["body"],
272 urls=email_data["urls"],
273 ),
274 attachments=[
275 FindingAttachment(
276 file_name=f"email_{i}.eml",
277 data=raw_eml.encode("utf-8"),
278 is_raw_email=True,
279 ),
280 FindingAttachment(
281 file_name=email_data["attachment_name"],
282 data=email_data["attachment_data"],
283 is_raw_email=False,
284 ),
285 ],
286 )
287
288
289app.register_action(
290 "actions.async_action:async_process",
291 action_type="investigate",
292 verbose="Processes a message asynchronously with concurrent HTTP requests.",
293)
294
295app.register_action(
296 "actions.async_action:sync_process",
297 action_type="investigate",
298 verbose="Processes a message synchronously with sequential HTTP requests.",
299)
300
301
302class GeneratorActionOutput(ActionOutput):
303 iteration: int
304
305
306class GeneratorActionSummary(ActionOutput):
307 total_iterations: int
308
309
310class StageVaultTmpFileParams(Params):
311 file_name: str
312 file_content: str
313
314
315class StageVaultTmpFileOutput(ActionOutput):
316 file_path: str
317
318
319@app.action(summary_type=GeneratorActionSummary)
320def generator_action(
321 params: Params, soar: SOARClient[GeneratorActionSummary], asset: Asset
322) -> Iterator[GeneratorActionOutput]:
323 """Generates a sequence of numbers."""
324 logger.info(f"Generator action triggered with params: {params}")
325 for i in range(5):
326 yield GeneratorActionOutput(iteration=i)
327 soar.set_summary(GeneratorActionSummary(total_iterations=5))
328
329
330@app.action()
331def write_state(params: Params, soar: SOARClient, asset: Asset) -> ActionOutput:
332 asset.cache_state.clear()
333 assert asset.cache_state == {}
334 asset.cache_state["value"] = "banana"
335 return ActionOutput()
336
337
338@app.action()
339def read_state(params: Params, soar: SOARClient, asset: Asset) -> ActionOutput:
340 assert asset.cache_state == {"value": "banana"}
341 return ActionOutput()
342
343
344class FilesystemStateOutput(ActionOutput):
345 state_file_path: str
346 raw_state_json: str
347
348
349def _asset_state_file_path(soar: SOARClient, asset: Asset) -> Path:
350 return Path(asset.cache_state.backend.get_state_dir()) / (
351 f"{soar.get_asset_id()}_state.json"
352 )
353
354
355@app.action(read_only=False)
356def write_filesystem_state(
357 params: Params, soar: SOARClient, asset: Asset
358) -> ActionOutput:
359 asset.auth_state.put_all({"auth_marker": AUTH_FILESYSTEM_MARKER})
360 asset.cache_state.put_all({"cache_marker": CACHE_FILESYSTEM_MARKER})
361 asset.ingest_state.put_all({"ingest_marker": INGEST_FILESYSTEM_MARKER})
362 return ActionOutput()
363
364
365@app.action()
366def read_filesystem_state(
367 params: Params, soar: SOARClient, asset: Asset
368) -> FilesystemStateOutput:
369 state_file_path = _asset_state_file_path(soar, asset)
370 return FilesystemStateOutput(
371 state_file_path=str(state_file_path),
372 raw_state_json=state_file_path.read_text(encoding="utf-8"),
373 )
374
375
376@app.action(read_only=False)
377def stage_vault_tmp_file(
378 params: StageVaultTmpFileParams, soar: SOARClient, asset: Asset
379) -> StageVaultTmpFileOutput:
380 vault_tmp_dir = Path(soar.vault.get_vault_tmp_dir())
381 file_path = vault_tmp_dir / Path(params.file_name).name
382 file_path.write_text(params.file_content, encoding="utf-8")
383 return StageVaultTmpFileOutput(file_path=str(file_path))
384
385
386if __name__ == "__main__":
387 app.cli()
Components of the app.py File¶
Let’s dive deeper into each part of the app.py file above:
Logger Initialization¶
10from soar_sdk.logging import getLogger
11from soar_sdk.models.artifact import Artifact
12from soar_sdk.models.container import Container
13from soar_sdk.models.finding import Finding, FindingAttachment, FindingEmail
14from soar_sdk.params import (
15 MakeRequestParams,
16 OnESPollParams,
17 OnPollParams,
18 Param,
19 Params,
20)
21
22logger = getLogger()
The SDK provides a logging interface via the getLogger() function. This is a standard Python logger which is pre-configured to work with either the local CLI or the Splunk SOAR platform. Within the platform,
logger.debug()andlogger.warning()messages are written to thespawn.logfile atDEBUGlevel.logger.error()andlogger.critical()messages are written to thespawn.logfile atERRORlevel.logger.info()messages are sent to the Splunk SOAR platform as persistent action progress messages, visible in the UI.logger.progress()messages are sent to the Splunk SOAR platform as transient action progress messages, visible in the UI, but overwritten by subsequent progress messages.
When running locally via the CLI, all log messages are printed to the console, in colors corresponding to their log level.
Asset Definition¶
69class Asset(BaseAsset):
70 base_url: str = AssetField(default="https://example")
71 api_key: str = AssetField(sensitive=True, description="API key for authentication")
72 secret_alias: str = AssetField(
73 sensitive=True,
74 alias="bearer_token",
75 description="Secret asset param with an alias",
76 )
77 key_header: str = AssetField(
78 default="Authorization",
79 value_list=["Authorization", "X-API-Key"],
80 description="Header for API key authentication",
81 )
82 timezone: ZoneInfo
83 timezone_with_default: ZoneInfo = AssetField(
84 default=ZoneInfo("America/Denver"), category=FieldCategory.ACTION
85 )
Apps should define an asset class to hold configuration information for the app. The asset class should be a pydantic model that inherits from BaseAsset and defines the app’s configuration fields. Fields requiring metadata should be defined using an instance of AssetField(). The SDK uses this information to generate the asset configuration form in the Splunk SOAR platform UI.
App Initialization¶
88app = App(
89 asset_cls=Asset,
90 name="example_app",
91 appid=APP_ID,
92 app_type="sandbox",
93 product_vendor="Splunk Inc.",
94 logo="logo.svg",
95 logo_dark="logo_dark.svg",
96 product_name="Example App",
97 publisher="Splunk Inc.",
98 min_phantom_version="6.2.2.134",
99)
This is how you initialize the basic App instance. The app object will be used to register actions, views, and/or webhooks. Keep in mind this object variable and its path are referenced by pyproject.toml so the Splunk SOAR platform knows where the app instance is provided.
Action Definitions¶
Actions are defined as standalone functions, with a few important rules and recommendations.
Action Metadata¶
Action definition carry with them important metadata which is used by the Splunk SOAR platform to present the action in the UI, and to generate the app’s manifest. Often, this metadata can be derived automatically from the action function’s signature:
The action’s “identifier” is, by default, the name of the action function (e.g.
my_action).The action’s “name” is, by default, the action function’s name with spaces instead of underscores (e.g.
my action).The action’s “description” is, by default, the action function’s docstring.
The action’s “type” is, by default,
genericunless the action is one of the reserved names liketest connectivityoron poll.
Note
By convention, action names should be lowercase, with 2-3 words. Keep action names short but descriptive, and avoid using the name of the app or external service in action names. Where feasible, it’s recommended to consider reusing action names across different apps (e.g. get email) to provide a more consistent user experience.
Action Arguments¶
There is a magic element, similar to pytest fixtures, in the action arguments. The type hints for the argument definitions of an action function are critical to this mechanism. The rules are as follows:
The first positional argument of an action function must be the
paramsargument, and its type hint must be a Pydantic model inheriting fromParams. The position and type of this argument are required. The nameparamsis a convention, but not strictly required.If an action function has any argument named
soar, at runtime the SDK will provide an instance of aSOARClientimplementation as that argument, which is already authenticated with Splunk SOAR. The type hint for this argument should beSOARClient.If an action function has any argument named
asset, at runtime the SDK will provide an instance of the app’s asset class, populated with the asset configuration for the current action run. The type hint for this argument should be the app’s asset class.
Note
The special actions which define their own decorators have stricter rules about the type of the params argument. For example, the on poll action must take an OnPollParams instance as its params argument, and test connectivity must take no params argument at all.
Action Returns¶
An action’s return type annotation is critical for the Splunk SOAR platform to understand, via datapaths, what an action’s output looks like. In practice, this means that you must define a class inheriting from ActionOutput to represent the action’s output, and then return an instance of that class from your action function:
from soar_sdk.action_results import ActionOutput
class MyActionOutput(ActionOutput):
field1: str
field2: int
@app.action()
def my_action(params: MyActionParams) -> MyActionOutput:
# action logic here
return MyActionOutput(field1="value", field2=42)
Advanced Return Types¶
For more advanced use cases, an action’s return type can be a list, Iterator, or AsyncGenerator that yields multiple ActionOutput objects:
@app.action()
def my_action_list(params: MyActionParams) -> list[MyActionOutput]:
# action logic here
return [
MyActionOutput(field1="value1", field2=1),
MyActionOutput(field1="value2", field2=2)
]
from typing import Iterator
@app.action()
def my_action_iterator(params: MyActionParams) -> Iterator[MyActionOutput]:
# action logic here
yield MyActionOutput(field1="value1", field2=1)
yield MyActionOutput(field1="value2", field2=2)
from typing import AsyncGenerator
@app.action()
async def my_action_async_generator(
params: MyActionParams,
asset: Asset,
) -> AsyncGenerator[MyActionOutput]:
async with client = httpx.AsyncClient() as client:
async for i in range(10):
response = await client.get(
f"{asset.base_url}/data",
params={"page": i}
)
yield MyActionOutput(**response.json())
test connectivity Action¶
102@app.test_connectivity()
103def test_connectivity(soar: SOARClient, asset: Asset) -> None:
104 soar.get("rest/version")
105 container_id = soar.get_executing_container_id()
106 logger.info(f"current executing container's container_id is: {container_id}")
107 asset_id = soar.get_asset_id()
108 logger.info(f"current executing container's asset_id is: {asset_id}")
109 logger.info(f"testing connectivity against {asset.base_url}")
110 logger.debug("hello")
111 logger.warning("this is a warning")
112 logger.progress("this is a progress message")
113 logger.info(f"secret_alias value is {asset.secret_alias}")
114 assert asset.secret_alias == "example bearer"
All apps must register exactly one test connectivity action in order to be considered valid by Splunk SOAR. This action takes no parameters, and is used to verify that the app and its associated asset configuration are working correctly. Running test connectivity on the Splunk SOAR platform should answer the questions:
Can the app connect to the external service?
Can the app authenticate with the external service?
Does the app have the necessary permissions to perform its actions?
A successful test connectivity action should return None, and a failure should raise an ActionFailure with a descriptive error message.
on poll Action¶
203@app.on_poll()
204def on_poll(
205 params: OnPollParams, soar: SOARClient, asset: Asset
206) -> Iterator[Container | Artifact]:
207 if params.is_manual_poll():
208 logger.info("Manual poll (poll now) detected")
209 else:
210 logger.info("Scheduled poll detected")
211
212 # Create container first for artifacts
213 yield Container(
214 name="Network Alerts",
215 description="Some network-related alerts",
216 severity="medium",
217 )
218
219 # Simulate collecting 2 network artifacts that will be put in the network alerts container
220 for i in range(1, 3):
221 logger.info(f"Processing network artifact {i}")
222
223 alert_id = f"testalert-{datetime.now(UTC).strftime('%Y%m%d')}-{i}"
224 artifact = Artifact(
225 name=f"Network Alert {i}",
226 label="alert",
227 severity="medium",
228 source_data_identifier=alert_id,
229 type="network",
230 description=f"Example network alert {i} from polling operation",
231 data={
232 "alert_id": alert_id,
233 "source_ip": f"10.0.0.{i}",
234 "destination_ip": "192.168.0.1",
235 "protocol": "TCP",
236 },
237 )
238
239 yield artifact
on poll is another special action that apps may choose to implement. This action always takes an OnPollParams instance as its parameter. If defined, this action will be called in order to ingest new data into the Splunk SOAR platform. The action should yield Container and/or Artifact instances representing the new data to be ingested. The SDK will handle actually creating the containers and artifacts in the platform.
Make Request Action¶
194@app.make_request()
195def http_action(params: MakeRequestParamsCustom, asset: Asset) -> MakeRequestOutput:
196 logger.info(f"HTTP action triggered with params: {params}")
197 return MakeRequestOutput(
198 status_code=200,
199 response_body=f"Base url is {asset.base_url}",
200 )
Apps may define a special “make request” action, which can be used to interact with the underlying external service’s REST API directly. Having this action available can be useful when there are parts of the REST API that don’t have dedicated actions implemented in the app.
We create an action by decorating a function with the app.action decorator. The default action_type
is generic, so usually you will not have to provide this argument for the decorator. This is not the
case for the test action type though, so we provide this type here explicitly.
Custom Actions¶
Actions can be registered one of two ways:
Using the action() decorator to decorate a standalone function.
319@app.action(summary_type=GeneratorActionSummary)
320def generator_action(
321 params: Params, soar: SOARClient[GeneratorActionSummary], asset: Asset
322) -> Iterator[GeneratorActionOutput]:
323 """Generates a sequence of numbers."""
324 logger.info(f"Generator action triggered with params: {params}")
325 for i in range(5):
326 yield GeneratorActionOutput(iteration=i)
327 soar.set_summary(GeneratorActionSummary(total_iterations=5))
Using the register_action() method to register a function which may be defined in another module.
The two methods are functionally equivalent. The decorator method is often more convenient for simple actions, while the registration method may be preferable for larger apps where actions are defined in separate modules. Apps may use either or both methods to register their actions.
Action Synchronization¶
Use ActionLock when an action must coordinate
with other action runs. For example, a read-modify-write operation can prevent
overlapping executions for every asset that points to the same server:
from soar_sdk.meta.actions import ActionLock
@app.action(
lock=ActionLock(
data_path="configuration.server",
timeout=600,
)
)
def update_configuration(
params: UpdateConfigurationParams,
soar: SOARClient,
asset: Asset,
) -> UpdateConfigurationOutput:
...
An ActionLock is always enabled and exclusive. The SDK emits
enabled=true and concurrency=false in the app manifest, restricting
each resolved lock name to one action run at a time.
data_path may identify an action parameter, an asset configuration field,
or a constant lock name. Actions that resolve to the same lock name are
serialized. If data_path is omitted, Splunk SOAR uses the asset as the lock
name. timeout limits how long the platform waits to acquire the lock.
The older enable_concurrency_lock=True argument is deprecated but remains
supported for backward compatibility. Remove the argument to retain the
platform’s default concurrency behavior. Use lock=ActionLock() only when
exclusive action locking is required.
Note
soar-apps convert does not translate existing manifest lock
metadata. Review synchronization requirements and add ActionLock
explicitly after conversion when exclusive locking is required.
App CLI Invocation¶
386if __name__ == "__main__":
387 app.cli()
A generic invocation to the app’s cli() method, which enables running the app actions directly from command line. The app template created by soarapps init includes this snippet by default, and it is recommended to keep it in order to facilitate local testing and debugging of your app actions.