Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ parameters:
description: Specify the comment that needs to be added to alert.
is_mandatory: true
dynamic_results_metadata:
- result_example_path: resources/add_comment_to_alert_JsonResult_example.json
- result_example_path: resources/AddCommentToAlert_JsonResult_example.json
result_name: JsonResult
show_result: true
creator: admin
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ parameters:
description: If enabled, action will create an insight for every enriched asset.
is_mandatory: false
dynamic_results_metadata:
- result_example_path: resources/get_asset_details_JsonResult_example.json
- result_example_path: resources/GetAssetDetails_JsonResult_example.json
result_name: JsonResult
show_result: true
creator: admin
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ parameters:
description: 'Specify how many frameworks to return. Default: 50.'
is_mandatory: false
dynamic_results_metadata:
- result_example_path: resources/get_compliance_info_JsonResult_example.json
- result_example_path: resources/GetComplianceInfo_JsonResult_example.json
result_name: JsonResult
show_result: true
creator: admin
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ parameters:
Maximum: 10000.'
is_mandatory: false
dynamic_results_metadata:
- result_example_path: resources/get_vulnerability_details_JsonResult_example.json
- result_example_path: resources/GetVulnerabilityDetails_JsonResult_example.json
result_name: JsonResult
show_result: true
creator: admin
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ parameters:
return details.
is_mandatory: true
dynamic_results_metadata:
- result_example_path: resources/scan_assets_JsonResult_example.json
- result_example_path: resources/ScanAssets_JsonResult_example.json
result_name: JsonResult
show_result: true
creator: admin
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ parameters:
description: Specify what status to set for the alert.
is_mandatory: false
dynamic_results_metadata:
- result_example_path: resources/update_alert_JsonResult_example.json
- result_example_path: resources/UpdateAlert_JsonResult_example.json
result_name: JsonResult
show_result: true
creator: admin
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,21 @@
from soar_sdk.SiemplifyConnectorsDataModel import AlertInfo
from soar_sdk.SiemplifyUtils import output_handler, unix_now
from TIPCommon import (
UNIX_FORMAT,
extract_connector_param,
filter_old_alerts,
get_last_success_time,
is_approaching_timeout,
is_overflowed,
read_ids,
save_timestamp,
write_ids,
)

from ..core.constants import (
BLACKLIST_FILTER,
CONNECTOR_NAME,
CREATED_AT_LOOKBACK_HOURS,
DEFAULT_LIMIT,
DEFAULT_TIME_FRAME,
POSSIBLE_SEVERITIES,
STORED_IDS_LIMIT,
WHITELIST_FILTER,
)
from ..core.OrcaSecurityExceptions import OrcaSecurityInvalidParameterException
Expand Down Expand Up @@ -191,85 +189,157 @@ def main(is_test_run):
siemplify_logger=siemplify.LOGGER,
)

fetched_alerts = []
alerts = manager.get_alerts(
start_timestamp=get_last_success_time(
siemplify=siemplify,
offset_with_metric={"hours": hours_backwards},
time_format=UNIX_FORMAT,
),
limit=fetch_limit,
lowest_severity=lowest_severity,
categories=category_filter,
title_filter=siemplify.whitelist,
title_filter_type=(BLACKLIST_FILTER if whitelist_as_a_blacklist else WHITELIST_FILTER),
alert_types=alert_type_filter,
lowest_score=lowest_score,
# The watermark tracks last_sync (DB write time), so alerts that become
# visible or eligible after creation (e.g. Orca Score populated later) still
# enter the fetch window. The CreatedAt bound keeps updates of alerts older
# than the lookback out of the window, preserving "new alerts only" semantics.
lookback_ms = CREATED_AT_LOOKBACK_HOURS * 60 * 60 * 1000
saved_timestamp = siemplify.fetch_timestamp()
if saved_timestamp:
# Tolerate downtime up to the CreatedAt lookback - alerts older than
# that age out of the fetch window anyway
last_sync_cursor = max(saved_timestamp, unix_now() - lookback_ms)
else:
last_sync_cursor = unix_now() - hours_backwards * 60 * 60 * 1000
# On the first run the cursor reaches further back than the lookback, so
# honor "Max Hours Backwards" instead of capping the backfill. On later
# runs the cursor is already clamped to the lookback, so this is a no-op.
created_at_start = min(unix_now() - lookback_ms, last_sync_cursor)
siemplify.LOGGER.info(
f"Fetching alerts from last_sync cursor {last_sync_cursor}, "
f"created at or after {created_at_start}"
)

filtered_alerts = filter_old_alerts(siemplify, alerts, existing_ids, "alert_id")
siemplify.LOGGER.info(f"Fetched {len(filtered_alerts)} alerts")
existing_ids_set = set(existing_ids)
fetched_alerts = []
watermark = 0
offset = 0
stop_fetching = False

while not stop_fetching:
alerts = manager.get_alerts(
start_timestamp=created_at_start,
limit=fetch_limit,
lowest_severity=lowest_severity,
categories=category_filter,
title_filter=siemplify.whitelist,
title_filter_type=(BLACKLIST_FILTER if whitelist_as_a_blacklist else WHITELIST_FILTER),
alert_types=alert_type_filter,
lowest_score=lowest_score,
last_sync_start_timestamp=last_sync_cursor,
start_at_index=offset,
)
siemplify.LOGGER.info(
f"Fetched page of {len(alerts)} alerts from last_sync cursor {last_sync_cursor}, offset {offset}"
)

if is_test_run:
siemplify.LOGGER.info("This is a TEST run. Only 1 alert will be processed.")
filtered_alerts = filtered_alerts[:1]

for alert in filtered_alerts:
try:
if is_approaching_timeout(connector_starting_time, script_timeout):
siemplify.LOGGER.info("Timeout is approaching. Connector will gracefully exit")
break

if len(processed_alerts) >= fetch_limit:
# Provide slicing for the alerts amount.
siemplify.LOGGER.info(
"Reached max number of alerts cycle. No more alerts will be processed in this cycle."
if is_test_run:
siemplify.LOGGER.info("This is a TEST run. Only 1 alert will be processed.")
alerts = alerts[:1]
stop_fetching = True

for alert in alerts:
try:
if is_approaching_timeout(connector_starting_time, script_timeout):
siemplify.LOGGER.info("Timeout is approaching. Connector will gracefully exit")
stop_fetching = True
break

if alert.alert_id in existing_ids_set:
# Already ingested in a previous run - advance the watermark
# over it so the connector makes progress even when a page
# contains only duplicates
watermark = max(watermark, alert.last_sync_ms)
continue

if len(processed_alerts) >= fetch_limit:
# Provide slicing for the alerts amount.
siemplify.LOGGER.info(
"Reached max number of alerts cycle. No more alerts will be processed in this cycle."
)
stop_fetching = True
break

siemplify.LOGGER.info(f"Started processing alert {alert.alert_id}")
alert.set_events()

# Update existing alerts
existing_ids.append(alert.alert_id)
existing_ids_set.add(alert.alert_id)
fetched_alerts.append(alert)
watermark = max(watermark, alert.last_sync_ms)

alert_info = alert.get_alert_info(
alert_info=AlertInfo(),
environment_common=GetEnvironmentCommonFactory().create_environment_manager(
siemplify, environment_field_name, environment_regex_pattern
),
device_product_field=device_product_field,
)
break

siemplify.LOGGER.info(f"Started processing alert {alert.alert_id}")
alert.set_events()

# Update existing alerts
existing_ids.append(alert.alert_id)
fetched_alerts.append(alert)

alert_info = alert.get_alert_info(
alert_info=AlertInfo(),
environment_common=GetEnvironmentCommonFactory().create_environment_manager(
siemplify, environment_field_name, environment_regex_pattern
),
device_product_field=device_product_field,
)

if is_overflowed(siemplify, alert_info, is_test_run):
siemplify.LOGGER.info(
f"{alert_info.rule_generator}-{alert_info.ticket_id}-{alert_info.environment}"
f"-{alert_info.device_product} found as overflow alert. Skipping..."
if is_overflowed(siemplify, alert_info, is_test_run):
siemplify.LOGGER.info(
f"{alert_info.rule_generator}-{alert_info.ticket_id}-{alert_info.environment}"
f"-{alert_info.device_product} found as overflow alert. Skipping..."
)
# If is overflowed we should skip
continue

processed_alerts.append(alert_info)
siemplify.LOGGER.info(f"Alert {alert.alert_id} was created.")

except Exception as e:
# The watermark may advance past this alert via later alerts in
# the page, so it will not be fetched again - log it as dropped
# rather than letting it disappear silently.
siemplify.LOGGER.error(
f"Failed to process alert {alert.alert_id}. It will be dropped and not retried."
)
# If is overflowed we should skip
continue

processed_alerts.append(alert_info)
siemplify.LOGGER.info(f"Alert {alert.alert_id} was created.")

except Exception as e:
siemplify.LOGGER.error(f"Failed to process alert {alert.alert_id}")
siemplify.LOGGER.exception(e)

if is_test_run:
raise

siemplify.LOGGER.info(f"Finished processing alert {alert.alert_id}")
siemplify.LOGGER.exception(e)

if is_test_run:
raise

siemplify.LOGGER.info(f"Finished processing alert {alert.alert_id}")

if (
stop_fetching
or len(alerts) < fetch_limit
or len(processed_alerts) >= fetch_limit
):
# A short page means there are no more alerts in the window, and a
# full per-cycle quota means the next page would be discarded anyway
break

next_cursor = alerts[-1].last_sync_ms
if next_cursor <= last_sync_cursor:
# A full page within a single last_sync second (second-resolution
# ties) - the range start can't move, so page deeper with an offset.
# Never skip past the tie: rows beyond this page may be unseen.
# Tie ordering has no secondary sort key, so offsets are only
# meaningful within this run's back-to-back requests - do not carry
# the offset across runs. A row that shuffles out of view returns on
# its next last_sync rewrite, unless its CreatedAt has meanwhile aged
# out of the lookback window; dedup absorbs the rest.
offset += len(alerts)
else:
last_sync_cursor = next_cursor
offset = 0

if not is_test_run:
siemplify.LOGGER.info("Saving existing ids.")
write_ids(siemplify, existing_ids)
save_timestamp(
siemplify=siemplify,
alerts=fetched_alerts,
timestamp_key="created_at_ms",
)
if len(existing_ids) > STORED_IDS_LIMIT:
siemplify.LOGGER.info(
f"Alert ids cache exceeded {STORED_IDS_LIMIT} entries, oldest ids will be evicted. "
f"If this repeats every run, duplicate cases are possible for re-synced alerts."
)
write_ids(siemplify, existing_ids, stored_ids_limit=STORED_IDS_LIMIT)

if watermark:
siemplify.LOGGER.info(f"Saving last_sync watermark: {watermark}")
siemplify.save_timestamp(new_timestamp=watermark)
else:
siemplify.LOGGER.info("Timestamp is not updated since no alerts were handled")

siemplify.LOGGER.info(
f"Alerts processed: {len(processed_alerts)} out of {len(fetched_alerts)}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,10 @@ parameters:
default_value: 1
type: integer
description: Number of hours before the first connector iteration to retrieve
incidents from. This parameter applies to the initial connector iteration
after you enable the connector for the first time, or used as a fallback value
in cases where connector's last run timestamp expires.
incidents from. This parameter applies only to the initial connector iteration
after you enable the connector for the first time. On later iterations the
connector resumes from its last run position and tolerates downtime of up
to 3 hours.
is_mandatory: false
is_advanced: true
mode: script
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,14 +113,17 @@ def get_alerts(
title_filter_type: int | None = WHITELIST_FILTER,
alert_types: list[str] | None = None,
lowest_score: float | None = None,
last_sync_start_timestamp: int | None = None,
start_at_index: int = 0,
) -> list[Alert]:
"""Retrieve alerts from the API.
Builds an alert query payload with the provided filters (severity, categories,
title, type, score, etc.), sends it to the alerts endpoint, validates the
response, and parses it into a list of Alert objects.

Args:
start_timestamp (int): The start timestamp in milliseconds to fetch alerts.
start_timestamp (int): The start timestamp in milliseconds to filter
alerts by creation time.
limit (int): The maximum number of alerts to fetch.
lowest_severity (str): The lowest severity to filter by.
categories (list[str]): List of categories to filter by.
Expand All @@ -129,13 +132,19 @@ def get_alerts(
or BLACKLIST_FILTER. Defaults to WHITELIST_FILTER.
alert_types (list[str]): List of alert types to filter by.
lowest_score (float): The lowest score to filter by.
last_sync_start_timestamp (int): The start timestamp in milliseconds to
filter alerts by DB write time. When provided, results are ordered by
last_sync so it can be used as a pagination cursor.
start_at_index (int): The offset to start fetching results from. Used to
page within results sharing one last_sync value.

Returns:
list[Alert]: List of Alert objects.
"""
url: str = self._get_full_url("get_alerts")
payload: AlertQueryBuilder = (
AlertQueryBuilder(start_timestamp, limit)
AlertQueryBuilder(start_timestamp, limit, last_sync_start_timestamp)
.start_at_index(start_at_index)
.with_severity(lowest_severity)
.with_categories(categories)
.with_title_filter(title_filter, title_filter_type)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ def build_alert_object(raw_data):
details=alert_data.get("Details", {}).get("value"),
severity=alert_data.get("Severity", {}).get("value"),
created_at=alert_data.get("CreatedAt", {}).get("value"),
last_sync=alert_data.get("last_sync", {}).get("value"),
asset_name=asset_data.get("asset_name"),
asset_type=asset_data.get("asset_type"),
type_string=alert_data.get("AlertType", {}).get("value"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@
CONNECTOR_NAME = f"{INTEGRATION_DISPLAY_NAME} - Alerts Connector"
DEFAULT_TIME_FRAME = 1
DEFAULT_LIMIT = 100
# How far back in creation time an alert can still be ingested when resuming.
# Alerts can become eligible for fetching after creation (e.g. Orca Score
# populated later), but alerts older than this are treated as updates and never
# re-ingested. Does not restrict the first run, which honors "Max Hours Backwards".
CREATED_AT_LOOKBACK_HOURS = 3
# Dedup ID cache size - must cover all alerts fetched within the lookback window
STORED_IDS_LIMIT = 10000
DEFAULT_ASSET_LIMIT: int = 20
DEFAULT_RESULTS_LIMIT: int = 1000
DEFAULT_OFFSET: int = 0
Expand Down
Loading