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 Iterator
2from datetime import UTC, datetime
3from zoneinfo import ZoneInfo
4
5from soar_sdk.abstract import SOARClient
6from soar_sdk.action_results import ActionOutput, MakeRequestOutput, OutputField
7from soar_sdk.app import App
8from soar_sdk.asset import AssetField, BaseAsset
9from soar_sdk.logging import getLogger
10from soar_sdk.models.artifact import Artifact
11from soar_sdk.models.attachment_input import AttachmentInput
12from soar_sdk.models.container import Container
13from soar_sdk.models.finding import Finding
14from soar_sdk.params import (
15 MakeRequestParams,
16 OnESPollParams,
17 OnPollParams,
18 Param,
19 Params,
20)
21
22logger = getLogger()
23
24# Test mail template for ES findings
25SAMPLE_EMAIL_TEMPLATE = """From: suspicious@example.com
26To: {user}
27Subject: Suspicious Activity Detected
28Date: {date}
29
30This is a suspicious email that triggered the risk threshold.
31Event ID: {event_id}
32"""
33
34# Test event data CSV for attachments
35SAMPLE_EVENTS_CSV = """timestamp,user,action,risk_score
36{timestamp},{{user}},login_failed,{{risk_score}}
37{timestamp},{{user}},access_denied,{{secondary_score}}
38"""
39
40
41class Asset(BaseAsset):
42 base_url: str = AssetField(default="https://example")
43 api_key: str = AssetField(sensitive=True, description="API key for authentication")
44 key_header: str = AssetField(
45 default="Authorization",
46 value_list=["Authorization", "X-API-Key"],
47 description="Header for API key authentication",
48 )
49 timezone: ZoneInfo
50 timezone_with_default: ZoneInfo = AssetField(default=ZoneInfo("America/Denver"))
51
52
53app = App(
54 asset_cls=Asset,
55 name="example_app",
56 appid="9b388c08-67de-4ca4-817f-26f8fb7cbf55",
57 app_type="sandbox",
58 product_vendor="Splunk Inc.",
59 logo="logo.svg",
60 logo_dark="logo_dark.svg",
61 product_name="Example App",
62 publisher="Splunk Inc.",
63 min_phantom_version="6.2.2.134",
64)
65
66
67@app.test_connectivity()
68def test_connectivity(soar: SOARClient, asset: Asset) -> None:
69 soar.get("rest/version")
70 container_id = soar.get_executing_container_id()
71 logger.info(f"current executing container's container_id is: {container_id}")
72 asset_id = soar.get_asset_id()
73 logger.info(f"current executing container's asset_id is: {asset_id}")
74 logger.info(f"testing connectivity against {asset.base_url}")
75 logger.debug("hello")
76 logger.warning("this is a warning")
77 logger.progress("this is a progress message")
78
79
80class ActionOutputSummary(ActionOutput):
81 is_success: bool
82
83
84@app.action()
85def test_summary_with_list_output(
86 params: Params, asset: Asset, soar: SOARClient
87) -> list[ActionOutput]:
88 soar.set_summary(ActionOutputSummary(is_success=True))
89 return [ActionOutput(), ActionOutput()]
90
91
92@app.action()
93def test_empty_list_output(
94 params: Params, asset: Asset, soar: SOARClient
95) -> list[ActionOutput]:
96 return []
97
98
99class JsonOutput(ActionOutput):
100 name: str = OutputField(example_values=["John", "Jane", "Jim"], column_name="Name")
101 age: int = OutputField(example_values=[25, 30, 35], column_name="Age")
102
103
104class TableParams(Params):
105 company_name: str = Param(column_name="Company Name", default="Splunk")
106
107
108@app.action(render_as="json")
109def test_json_output(params: Params, asset: Asset, soar: SOARClient) -> JsonOutput:
110 return JsonOutput(name="John", age=25)
111
112
113@app.action(render_as="table")
114def test_table_output(
115 params: TableParams, asset: Asset, soar: SOARClient
116) -> JsonOutput:
117 return JsonOutput(name="John", age=25)
118
119
120from .actions.reverse_string import render_reverse_string_view
121
122app.register_action(
123 "actions.reverse_string:reverse_string",
124 action_type="investigate",
125 verbose="Reverses a string.",
126 view_template="reverse_string.html",
127 view_handler=render_reverse_string_view,
128)
129
130from .actions.generate_category import render_statistics_chart
131
132app.register_action(
133 "actions.generate_category:generate_statistics",
134 action_type="investigate",
135 verbose="Generate statistics with pie chart reusable component.",
136 view_handler=render_statistics_chart,
137)
138
139
140class MakeRequestParamsCustom(MakeRequestParams):
141 endpoint: str = Param(
142 description="The endpoint to send the request to. Base url is already included in the endpoint.",
143 required=True,
144 )
145
146
147@app.make_request()
148def http_action(params: MakeRequestParamsCustom, asset: Asset) -> MakeRequestOutput:
149 logger.info(f"HTTP action triggered with params: {params}")
150 return MakeRequestOutput(
151 status_code=200,
152 response_body=f"Base url is {asset.base_url}",
153 )
154
155
156@app.on_poll()
157def on_poll(
158 params: OnPollParams, soar: SOARClient, asset: Asset
159) -> Iterator[Container | Artifact]:
160 if params.is_manual_poll():
161 logger.info("Manual poll (poll now) detected")
162 else:
163 logger.info("Scheduled poll detected")
164
165 # Create container first for artifacts
166 yield Container(
167 name="Network Alerts",
168 description="Some network-related alerts",
169 severity="medium",
170 )
171
172 # Simulate collecting 2 network artifacts that will be put in the network alerts container
173 for i in range(1, 3):
174 logger.info(f"Processing network artifact {i}")
175
176 alert_id = f"testalert-{datetime.now(UTC).strftime('%Y%m%d')}-{i}"
177 artifact = Artifact(
178 name=f"Network Alert {i}",
179 label="alert",
180 severity="medium",
181 source_data_identifier=alert_id,
182 type="network",
183 description=f"Example network alert {i} from polling operation",
184 data={
185 "alert_id": alert_id,
186 "source_ip": f"10.0.0.{i}",
187 "destination_ip": "192.168.0.1",
188 "protocol": "TCP",
189 },
190 )
191
192 yield artifact
193
194
195@app.on_es_poll()
196def on_es_poll(
197 params: OnESPollParams, soar: SOARClient, asset: Asset
198) -> Iterator[tuple[Finding, list[AttachmentInput]]]:
199 for i in range(1, 3):
200 logger.info(f"Processing ES finding {i}")
201
202 finding = Finding(
203 rule_title=f"Risk threshold exceeded for user-{i}",
204 rule_description="Risk Threshold Exceeded for an object over a 24 hour period",
205 security_domain="threat",
206 risk_object=f"user{i}@example.com",
207 risk_object_type="user",
208 risk_score=75.0 + (i * 10),
209 status="New",
210 urgency="medium",
211 )
212
213 # Attach evidence
214 email_evidence = SAMPLE_EMAIL_TEMPLATE.format(
215 user=f"user{i}@example.com",
216 date=datetime.now(UTC).strftime("%a, %d %b %Y %H:%M:%S +0000"),
217 event_id=f"EVT-{i:04d}",
218 )
219
220 event_data = SAMPLE_EVENTS_CSV.format(
221 timestamp=datetime.now(UTC).isoformat()
222 ).format(
223 user=f"user{i}@example.com",
224 risk_score=75.0 + (i * 10),
225 secondary_score=50.0 + (i * 5),
226 )
227
228 attachments = [
229 AttachmentInput(
230 file_content=email_evidence,
231 file_name=f"suspicious_email_user{i}.eml",
232 metadata={"type": "email_evidence", "source": "investigation_mailbox"},
233 ),
234 AttachmentInput(
235 file_content=event_data,
236 file_name=f"risk_events_user{i}.csv",
237 metadata={"type": "event_log", "event_count": "2"},
238 ),
239 ]
240
241 yield (finding, attachments)
242
243
244app.register_action(
245 "actions.async_action:async_process",
246 action_type="investigate",
247 verbose="Processes a message asynchronously with concurrent HTTP requests.",
248)
249
250app.register_action(
251 "actions.async_action:sync_process",
252 action_type="investigate",
253 verbose="Processes a message synchronously with sequential HTTP requests.",
254)
255
256
257class GeneratorActionOutput(ActionOutput):
258 iteration: int
259
260
261class GeneratorActionSummary(ActionOutput):
262 total_iterations: int
263
264
265@app.action(summary_type=GeneratorActionSummary)
266def generator_action(
267 params: Params, soar: SOARClient[GeneratorActionSummary], asset: Asset
268) -> Iterator[GeneratorActionOutput]:
269 """Generates a sequence of numbers."""
270 logger.info(f"Generator action triggered with params: {params}")
271 for i in range(5):
272 yield GeneratorActionOutput(iteration=i)
273 soar.set_summary(GeneratorActionSummary(total_iterations=5))
274
275
276@app.action()
277def write_state(params: Params, soar: SOARClient, asset: Asset) -> ActionOutput:
278 asset.cache_state.clear()
279 assert asset.cache_state == {}
280 asset.cache_state["value"] = "banana"
281 return ActionOutput()
282
283
284@app.action()
285def read_state(params: Params, soar: SOARClient, asset: Asset) -> ActionOutput:
286 assert asset.cache_state == {"value": "banana"}
287 return ActionOutput()
288
289
290if __name__ == "__main__":
291 app.cli()
Components of the app.py File¶
Let’s dive deeper into each part of the app.py file above:
Logger Initialization¶
9from soar_sdk.logging import getLogger
10from soar_sdk.models.artifact import Artifact
11from soar_sdk.models.attachment_input import AttachmentInput
12from soar_sdk.models.container import Container
13from soar_sdk.models.finding import Finding
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¶
41class Asset(BaseAsset):
42 base_url: str = AssetField(default="https://example")
43 api_key: str = AssetField(sensitive=True, description="API key for authentication")
44 key_header: str = AssetField(
45 default="Authorization",
46 value_list=["Authorization", "X-API-Key"],
47 description="Header for API key authentication",
48 )
49 timezone: ZoneInfo
50 timezone_with_default: ZoneInfo = AssetField(default=ZoneInfo("America/Denver"))
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¶
53app = App(
54 asset_cls=Asset,
55 name="example_app",
56 appid="9b388c08-67de-4ca4-817f-26f8fb7cbf55",
57 app_type="sandbox",
58 product_vendor="Splunk Inc.",
59 logo="logo.svg",
60 logo_dark="logo_dark.svg",
61 product_name="Example App",
62 publisher="Splunk Inc.",
63 min_phantom_version="6.2.2.134",
64)
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¶
67@app.test_connectivity()
68def test_connectivity(soar: SOARClient, asset: Asset) -> None:
69 soar.get("rest/version")
70 container_id = soar.get_executing_container_id()
71 logger.info(f"current executing container's container_id is: {container_id}")
72 asset_id = soar.get_asset_id()
73 logger.info(f"current executing container's asset_id is: {asset_id}")
74 logger.info(f"testing connectivity against {asset.base_url}")
75 logger.debug("hello")
76 logger.warning("this is a warning")
77 logger.progress("this is a progress message")
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¶
156@app.on_poll()
157def on_poll(
158 params: OnPollParams, soar: SOARClient, asset: Asset
159) -> Iterator[Container | Artifact]:
160 if params.is_manual_poll():
161 logger.info("Manual poll (poll now) detected")
162 else:
163 logger.info("Scheduled poll detected")
164
165 # Create container first for artifacts
166 yield Container(
167 name="Network Alerts",
168 description="Some network-related alerts",
169 severity="medium",
170 )
171
172 # Simulate collecting 2 network artifacts that will be put in the network alerts container
173 for i in range(1, 3):
174 logger.info(f"Processing network artifact {i}")
175
176 alert_id = f"testalert-{datetime.now(UTC).strftime('%Y%m%d')}-{i}"
177 artifact = Artifact(
178 name=f"Network Alert {i}",
179 label="alert",
180 severity="medium",
181 source_data_identifier=alert_id,
182 type="network",
183 description=f"Example network alert {i} from polling operation",
184 data={
185 "alert_id": alert_id,
186 "source_ip": f"10.0.0.{i}",
187 "destination_ip": "192.168.0.1",
188 "protocol": "TCP",
189 },
190 )
191
192 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¶
147@app.make_request()
148def http_action(params: MakeRequestParamsCustom, asset: Asset) -> MakeRequestOutput:
149 logger.info(f"HTTP action triggered with params: {params}")
150 return MakeRequestOutput(
151 status_code=200,
152 response_body=f"Base url is {asset.base_url}",
153 )
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.
265@app.action(summary_type=GeneratorActionSummary)
266def generator_action(
267 params: Params, soar: SOARClient[GeneratorActionSummary], asset: Asset
268) -> Iterator[GeneratorActionOutput]:
269 """Generates a sequence of numbers."""
270 logger.info(f"Generator action triggered with params: {params}")
271 for i in range(5):
272 yield GeneratorActionOutput(iteration=i)
273 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.
App CLI Invocation¶
290if __name__ == "__main__":
291 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.