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:

  1. Logger initialization

  2. Asset definition

  3. App initialization

  4. Actions definitions

  5. App CLI invocation

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    verbose="Reverses a string.",
163    view_template="reverse_string.html",
164    view_handler=render_reverse_string_view,
165)
166
167
168app.register_action(
169    "actions.permissive_action:permissive_reverse_string",
170    action_type="investigate",
171    verbose="Reverses a string but doesn't care if it gets all its output fields.",
172    view_template="reverse_string.html",
173    view_handler=render_reverse_string_view,
174)
175
176from .actions.generate_category import render_statistics_chart
177
178app.register_action(
179    "actions.generate_category:generate_statistics",
180    action_type="investigate",
181    verbose="Generate statistics with pie chart reusable component.",
182    view_handler=render_statistics_chart,
183)
184
185
186class MakeRequestParamsCustom(MakeRequestParams):
187    endpoint: str = Param(
188        description="The endpoint to send the request to. Base url is already included in the endpoint.",
189        required=True,
190    )
191
192
193@app.make_request()
194def http_action(params: MakeRequestParamsCustom, asset: Asset) -> MakeRequestOutput:
195    logger.info(f"HTTP action triggered with params: {params}")
196    return MakeRequestOutput(
197        status_code=200,
198        response_body=f"Base url is {asset.base_url}",
199    )
200
201
202@app.on_poll()
203def on_poll(
204    params: OnPollParams, soar: SOARClient, asset: Asset
205) -> Iterator[Container | Artifact]:
206    if params.is_manual_poll():
207        logger.info("Manual poll (poll now) detected")
208    else:
209        logger.info("Scheduled poll detected")
210
211    # Create container first for artifacts
212    yield Container(
213        name="Network Alerts",
214        description="Some network-related alerts",
215        severity="medium",
216    )
217
218    # Simulate collecting 2 network artifacts that will be put in the network alerts container
219    for i in range(1, 3):
220        logger.info(f"Processing network artifact {i}")
221
222        alert_id = f"testalert-{datetime.now(UTC).strftime('%Y%m%d')}-{i}"
223        artifact = Artifact(
224            name=f"Network Alert {i}",
225            label="alert",
226            severity="medium",
227            source_data_identifier=alert_id,
228            type="network",
229            description=f"Example network alert {i} from polling operation",
230            data={
231                "alert_id": alert_id,
232                "source_ip": f"10.0.0.{i}",
233                "destination_ip": "192.168.0.1",
234                "protocol": "TCP",
235            },
236        )
237
238        yield artifact
239
240
241@app.on_es_poll()
242def on_es_poll(
243    params: OnESPollParams, soar: SOARClient, asset: Asset
244) -> Generator[Finding, int | None]:
245    for i, email_data in enumerate(SAMPLE_EMAILS, start=1):
246        logger.info(f"Processing test finding {i}")
247
248        date_str = datetime.now(UTC).strftime("%a, %d %b %Y %H:%M:%S +0000")
249        raw_eml = (
250            f"From: {email_data['from']}\r\n"
251            f"To: {email_data['to']}\r\n"
252            f"Subject: {email_data['subject']}\r\n"
253            f"Date: {date_str}\r\n"
254            f"MIME-Version: 1.0\r\n"
255            f"Content-Type: text/plain; charset=utf-8\r\n"
256            f"\r\n"
257            f"{email_data['body']}"
258        )
259
260        yield Finding(
261            rule_title=f"Test Finding {i}: {email_data['subject']}",
262            email=FindingEmail(
263                headers={
264                    "From": email_data["from"],
265                    "To": email_data["to"],
266                    "Subject": email_data["subject"],
267                    "Date": date_str,
268                    "Content-Type": "text/plain; charset=utf-8",
269                },
270                body=email_data["body"],
271                urls=email_data["urls"],
272            ),
273            attachments=[
274                FindingAttachment(
275                    file_name=f"email_{i}.eml",
276                    data=raw_eml.encode("utf-8"),
277                    is_raw_email=True,
278                ),
279                FindingAttachment(
280                    file_name=email_data["attachment_name"],
281                    data=email_data["attachment_data"],
282                    is_raw_email=False,
283                ),
284            ],
285        )
286
287
288app.register_action(
289    "actions.async_action:async_process",
290    action_type="investigate",
291    verbose="Processes a message asynchronously with concurrent HTTP requests.",
292)
293
294app.register_action(
295    "actions.async_action:sync_process",
296    action_type="investigate",
297    verbose="Processes a message synchronously with sequential HTTP requests.",
298)
299
300
301class GeneratorActionOutput(ActionOutput):
302    iteration: int
303
304
305class GeneratorActionSummary(ActionOutput):
306    total_iterations: int
307
308
309class StageVaultTmpFileParams(Params):
310    file_name: str
311    file_content: str
312
313
314class StageVaultTmpFileOutput(ActionOutput):
315    file_path: str
316
317
318@app.action(summary_type=GeneratorActionSummary)
319def generator_action(
320    params: Params, soar: SOARClient[GeneratorActionSummary], asset: Asset
321) -> Iterator[GeneratorActionOutput]:
322    """Generates a sequence of numbers."""
323    logger.info(f"Generator action triggered with params: {params}")
324    for i in range(5):
325        yield GeneratorActionOutput(iteration=i)
326    soar.set_summary(GeneratorActionSummary(total_iterations=5))
327
328
329@app.action()
330def write_state(params: Params, soar: SOARClient, asset: Asset) -> ActionOutput:
331    asset.cache_state.clear()
332    assert asset.cache_state == {}
333    asset.cache_state["value"] = "banana"
334    return ActionOutput()
335
336
337@app.action()
338def read_state(params: Params, soar: SOARClient, asset: Asset) -> ActionOutput:
339    assert asset.cache_state == {"value": "banana"}
340    return ActionOutput()
341
342
343class FilesystemStateOutput(ActionOutput):
344    state_file_path: str
345    raw_state_json: str
346
347
348def _asset_state_file_path(soar: SOARClient, asset: Asset) -> Path:
349    return Path(asset.cache_state.backend.get_state_dir()) / (
350        f"{soar.get_asset_id()}_state.json"
351    )
352
353
354@app.action(read_only=False)
355def write_filesystem_state(
356    params: Params, soar: SOARClient, asset: Asset
357) -> ActionOutput:
358    asset.auth_state.put_all({"auth_marker": AUTH_FILESYSTEM_MARKER})
359    asset.cache_state.put_all({"cache_marker": CACHE_FILESYSTEM_MARKER})
360    asset.ingest_state.put_all({"ingest_marker": INGEST_FILESYSTEM_MARKER})
361    return ActionOutput()
362
363
364@app.action()
365def read_filesystem_state(
366    params: Params, soar: SOARClient, asset: Asset
367) -> FilesystemStateOutput:
368    state_file_path = _asset_state_file_path(soar, asset)
369    return FilesystemStateOutput(
370        state_file_path=str(state_file_path),
371        raw_state_json=state_file_path.read_text(encoding="utf-8"),
372    )
373
374
375@app.action(read_only=False)
376def stage_vault_tmp_file(
377    params: StageVaultTmpFileParams, soar: SOARClient, asset: Asset
378) -> StageVaultTmpFileOutput:
379    vault_tmp_dir = Path(soar.vault.get_vault_tmp_dir())
380    file_path = vault_tmp_dir / Path(params.file_name).name
381    file_path.write_text(params.file_content, encoding="utf-8")
382    return StageVaultTmpFileOutput(file_path=str(file_path))
383
384
385if __name__ == "__main__":
386    app.cli()

Components of the app.py File

Let’s dive deeper into each part of the app.py file above:

Logger Initialization

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() and logger.warning() messages are written to the spawn.log file at DEBUG level.

  • logger.error() and logger.critical() messages are written to the spawn.log file at ERROR level.

  • 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

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

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, generic unless the action is one of the reserved names like test connectivity or on 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 params argument, and its type hint must be a Pydantic model inheriting from Params. The position and type of this argument are required. The name params is a convention, but not strictly required.

  • If an action function has any argument named soar, at runtime the SDK will provide an instance of a SOARClient implementation as that argument, which is already authenticated with Splunk SOAR. The type hint for this argument should be SOARClient.

  • 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

Test connectivity action definition
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

on poll action definition
202@app.on_poll()
203def on_poll(
204    params: OnPollParams, soar: SOARClient, asset: Asset
205) -> Iterator[Container | Artifact]:
206    if params.is_manual_poll():
207        logger.info("Manual poll (poll now) detected")
208    else:
209        logger.info("Scheduled poll detected")
210
211    # Create container first for artifacts
212    yield Container(
213        name="Network Alerts",
214        description="Some network-related alerts",
215        severity="medium",
216    )
217
218    # Simulate collecting 2 network artifacts that will be put in the network alerts container
219    for i in range(1, 3):
220        logger.info(f"Processing network artifact {i}")
221
222        alert_id = f"testalert-{datetime.now(UTC).strftime('%Y%m%d')}-{i}"
223        artifact = Artifact(
224            name=f"Network Alert {i}",
225            label="alert",
226            severity="medium",
227            source_data_identifier=alert_id,
228            type="network",
229            description=f"Example network alert {i} from polling operation",
230            data={
231                "alert_id": alert_id,
232                "source_ip": f"10.0.0.{i}",
233                "destination_ip": "192.168.0.1",
234                "protocol": "TCP",
235            },
236        )
237
238        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

Make request action definition
193@app.make_request()
194def http_action(params: MakeRequestParamsCustom, asset: Asset) -> MakeRequestOutput:
195    logger.info(f"HTTP action triggered with params: {params}")
196    return MakeRequestOutput(
197        status_code=200,
198        response_body=f"Base url is {asset.base_url}",
199    )

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.

decorated action definition
318@app.action(summary_type=GeneratorActionSummary)
319def generator_action(
320    params: Params, soar: SOARClient[GeneratorActionSummary], asset: Asset
321) -> Iterator[GeneratorActionOutput]:
322    """Generates a sequence of numbers."""
323    logger.info(f"Generator action triggered with params: {params}")
324    for i in range(5):
325        yield GeneratorActionOutput(iteration=i)
326    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

App CLI invocation
385if __name__ == "__main__":
386    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.