From ca54661ad9a46d845bee988316296f41673d35cd Mon Sep 17 00:00:00 2001 From: jmorascalyr <42879226+jmorascalyr@users.noreply.github.com> Date: Sat, 21 Feb 2026 13:15:31 -0700 Subject: [PATCH 1/2] feat: Add Okta OCSF parser support and enhance M365 collaboration parser field mappings - Added okta_ocsf_logs parser to SCENARIO_SOURCE_TO_PARSER and SOURCETYPE_MAP_OVERRIDES mappings - Updated Finance Employee MFA Fatigue Attack scenario to use okta_ocsf_logs instead of okta_authentication - Added okta_ocsf_logs to JSON_PRODUCTS list for JSON format handling - Rewrote okta_ocsf_logs parser with proper JSON formatting and OCSF field mappings (actor.user, src_endpoint, http_request, activity_name, status --- .../api/app/services/parser_sync_service.py | 1 + Backend/api/app/services/scenario_service.py | 6 +- Backend/event_generators/shared/hec_sender.py | 2 + .../microsoft_365_collaboration.json | 60 +++ .../okta_ocsf_logs-latest/okta_ocsf_logs.json | 366 +++++++++--------- .../scenarios/finance_mfa_fatigue_scenario.py | 243 +++++++++++- .../okta_ocsf_logs-latest/okta_ocsf_logs.conf | 366 +++++++++--------- 7 files changed, 649 insertions(+), 395 deletions(-) diff --git a/Backend/api/app/services/parser_sync_service.py b/Backend/api/app/services/parser_sync_service.py index a79c20b..4841ef7 100644 --- a/Backend/api/app/services/parser_sync_service.py +++ b/Backend/api/app/services/parser_sync_service.py @@ -32,6 +32,7 @@ SCENARIO_SOURCE_TO_PARSER = { # Identity & Access "okta_authentication": "okta_authentication-latest", + "okta_ocsf_logs": "okta_ocsf_logs-latest", "microsoft_azuread": "microsoft_azuread-latest", "microsoft_azure_ad_signin": "microsoft_azure_ad_signin-latest", diff --git a/Backend/api/app/services/scenario_service.py b/Backend/api/app/services/scenario_service.py index 6dad8e4..339fdf5 100644 --- a/Backend/api/app/services/scenario_service.py +++ b/Backend/api/app/services/scenario_service.py @@ -120,10 +120,10 @@ def __init__(self): "name": "Finance Employee MFA Fatigue Attack", "description": "8-day scenario with baseline behavior, MFA fatigue attack from Russia, OneDrive exfiltration, and SOAR response", "phases": [ - {"name": "Normal Behavior (Days 1-7)", "generators": ["okta_authentication", "microsoft_azuread", "microsoft_365_collaboration"], "duration": 7}, - {"name": "MFA Fatigue Attack", "generators": ["okta_authentication"], "duration": 1}, + {"name": "Normal Behavior (Days 1-7)", "generators": ["okta_ocsf_logs", "microsoft_azuread", "microsoft_365_collaboration"], "duration": 7}, + {"name": "MFA Fatigue Attack", "generators": ["okta_ocsf_logs"], "duration": 1}, {"name": "Data Exfiltration", "generators": ["microsoft_365_collaboration"], "duration": 1}, - {"name": "Detection & Response", "generators": ["okta_authentication"], "duration": 1} + {"name": "Detection & Response", "generators": ["okta_ocsf_logs"], "duration": 1} ] }, "insider_cloud_download_exfiltration": { diff --git a/Backend/event_generators/shared/hec_sender.py b/Backend/event_generators/shared/hec_sender.py index 14e358c..30c67f6 100644 --- a/Backend/event_generators/shared/hec_sender.py +++ b/Backend/event_generators/shared/hec_sender.py @@ -850,6 +850,7 @@ def _send_batch(lines: list, is_json: bool, product: str): # Identity and access management "okta_authentication": "okta_authentication-latest", + "okta_ocsf_logs": "okta_ocsf_logs-latest", "microsoft_azuread": "microsoft_azuread-latest", "microsoft_azure_ad": "microsoft_azure_ad_logs-latest", "microsoft_azure_ad_signin": "microsoft_azure_ad_signin-latest", @@ -1051,6 +1052,7 @@ def _build_qs(product: str) -> str: "zscaler", # JSON format for gron parser "microsoft_azuread", "okta_authentication", + "okta_ocsf_logs", # "crowdstrike_falcon", # Returns CEF format, not JSON "cyberark_pas", "darktrace", diff --git a/Backend/parsers/community/microsoft_365_collaboration-latest/microsoft_365_collaboration.json b/Backend/parsers/community/microsoft_365_collaboration-latest/microsoft_365_collaboration.json index 2d5869a..93eb0ff 100644 --- a/Backend/parsers/community/microsoft_365_collaboration-latest/microsoft_365_collaboration.json +++ b/Backend/parsers/community/microsoft_365_collaboration-latest/microsoft_365_collaboration.json @@ -86,12 +86,36 @@ "type": "iso8601TimestampToEpochSec" } }, + { + "copy": { + "from": "unmapped.TimeStamp", + "to": "metadata.original_time" + } + }, + { + "copy": { + "from": "unmapped.UserId", + "to": "user.email_addr" + } + }, + { + "copy": { + "from": "unmapped.UserId", + "to": "user.uid" + } + }, { "rename": { "from": "unmapped.UserId", "to": "actor.user.email_addr" } }, + { + "copy": { + "from": "unmapped.Operation", + "to": "status_detail" + } + }, { "rename": { "from": "unmapped.Operation", @@ -104,20 +128,56 @@ "to": "src_endpoint.url.url_string" } }, + { + "copy": { + "from": "unmapped.ObjectId", + "to": "process.file.path" + } + }, { "rename": { "from": "unmapped.ObjectId", "to": "file.path" } }, + { + "copy": { + "from": "unmapped.FileName", + "to": "process.file.name" + } + }, { "rename": { "from": "unmapped.FileName", "to": "file.name" } }, + { + "copy": { + "from": "unmapped.FileSize", + "to": "process.file.size" + } + }, + { + "rename": { + "from": "unmapped.FileSize", + "to": "file.size" + } + }, { "rename": { + "from": "unmapped.EventType", + "to": "event.type" + } + }, + { + "copy": { + "from": "unmapped.TargetUser", + "to": "unmapped.target_user" + } + }, + { + "copy": { "from": "unmapped.TargetUser", "to": "user.email_addr" } diff --git a/Backend/parsers/community/okta_ocsf_logs-latest/okta_ocsf_logs.json b/Backend/parsers/community/okta_ocsf_logs-latest/okta_ocsf_logs.json index f2fcc9c..de331f0 100644 --- a/Backend/parsers/community/okta_ocsf_logs-latest/okta_ocsf_logs.json +++ b/Backend/parsers/community/okta_ocsf_logs-latest/okta_ocsf_logs.json @@ -1,191 +1,179 @@ { - attributes: { - source: "okta" - "dataSource.category": "security", - "dataSource.name": "Okta", - "dataSource.vendor": "Okta", + "attributes": { + "dataSource.category": "security", + "dataSource.name": "Okta", + "dataSource.vendor": "Okta" + }, + "formats": [ + { + "format": ".*published", + "attributes": { + "category_uid": "3", + "category_name": "Audit Activity", + "class_uid": "3006", + "class_name": "Access Activity", + "severity_id": "99", + "activity_id": "99", + "type_uid": "300699", + "metadata.product.vendor_name": "Okta", + "metadata.product.name": "Okta", + "metadata.version": "1.0.0-rc.2" + } }, - formats: [ - { - format: ".*${parse=dottedJson}{attrBlacklist=target}$" - rewrites: [ - { - input: "actor.id", - output: "user.account_uid", - match: ".*", - replace: "$0" - }, - { - input: "actor.type", - output: "user.account_type", - match: ".*", - replace: "$0" - }, - { - input: "actor.alternateId", - output: "user.email_addr", - match: ".*", - replace: "$0" - }, - { - input: "actor.displayName", - output: "user.name", - match: ".*", - replace: "$0" - }, - { - input: "authenticationContext.authenticationStep", - output: "authenticationStep", - match: ".*", - replace: "$0" - }, - { - input: "authenticationContext.externalSessionId", - output: "externalSessionId", - match: ".*", - replace: "$0" - }, - { - input: "client.ipAddress", - output: "client.ip", - match: ".*", - replace: "$0" - }, - { - input: "client.userAgent.browser", - output: "client.browser", - match: ".*", - replace: "$0" - }, - { - input: "client.userAgent.os", - output: "clinet.os", - match: ".*", - replace: "$0" - }, - { - input: "client.userAgent.rawUserAgent", - output: "client.userAgent", - match: ".*", - replace: "$0" - }, - { - input: "client.zone", - output: "client.location.zone", - match: ".*", - replace: "$0" - }, - { - input: "client.geographicalContext.city", - output: "client.location.city", - match: ".*", - replace: "$0" - }, - { - input: "client.geographicalContext.country", - output: "client.location.country", - match: ".*", - replace: "$0" - }, - { - input: "client.geographicalContext.geolocation.lat", - output: "client.location.lat", - match: ".*", - replace: "$0" - }, - { - input: "client.geographicalContext.geolocation.lon", - output: "client.location.lon", - match: ".*", - replace: "$0" - }, - { - input: "client.geographicalContext.postalCode", - output: "client.location.postal_code", - match: ".*", - replace: "$0" - }, - { - input: "client.geographicalContext.state", - output: "client.location.state", - match: ".*", - replace: "$0" - }, - { - input: "debugContext.debugData.requestUri", - output: "observables", - match: ".*", - replace: "$0" - }, - { - input: "debugContext.debugData.state", - output: "status", - match: ".*", - replace: "$0" - }, - { - input: "displayMessage", - output: "msg", - match: ".*", - replace: "$0" - }, - { - input: "eventType", - output: "category_name", - match: ".*", - replace: "$0" - }, - { - input: "legacyEventType", - output: "legacy_category_name", - match: ".*", - replace: "$0" - }, - { - input: "outcome.result", - output: "result", - match: ".*", - replace: "$0" - }, - { - input: "published", - output: "time", - match: ".*", - replace: "$0" - }, - { - input: "transaction.id", - output: "type_uid", - match: ".*", - replace: "$0" - }, - { - input: "transaction.type", - output: "type_name", - match: ".*", - replace: "$0" - }, - { - input: "version", - output: "metadata.version", - match: ".*", - replace: "$0" - }, - { - input: "uuid", - output: "activity_id", - match: ".*", - replace: "$0" - }, - { - input: "time", - output: "timestamp", - match: ".*", - replace: "$0" - } - ] - }, - {format: ".*target\": \\[$target.=json{parse=dottedJson}$"} - - ] - } \ No newline at end of file + { + "format": ".*${parse=dottedJson}{attrBlacklist=(detailEntry|type|authenticationStep|target|request|userId|timeUnit|timeSpan|threshold|requestId|rateLimitSecondsToReset|rateLimitScopeType|rateLimitBucketUuid|dtHash|securityContext|requestApiTokenId)}$", + "rewrites": [ + { + "input": "actor.alternateId", + "output": "actor.user.email_addr", + "match": ".*", + "replace": "$0" + }, + { + "input": "actor.displayName", + "output": "actor.user.name", + "match": ".*", + "replace": "$0" + }, + { + "input": "actor.id", + "output": "actor.user.uid", + "match": ".*", + "replace": "$0" + }, + { + "input": "actor.type", + "output": "actor.user.type", + "match": ".*", + "replace": "$0" + }, + { + "input": "client.ipAddress", + "output": "src_endpoint.ip", + "match": ".*", + "replace": "$0" + }, + { + "input": "client.device", + "output": "unmapped.client.device", + "match": ".*", + "replace": "$0" + }, + { + "input": "client.id", + "output": "src_endpoint.uid", + "match": ".*", + "replace": "$0" + }, + { + "input": "client.zone", + "output": "src_endpoint.domain", + "match": ".*", + "replace": "$0" + }, + { + "input": "client.geographicalContext.city", + "output": "src_endpoint.location.city", + "match": ".*", + "replace": "$0" + }, + { + "input": "client.geographicalContext.country", + "output": "src_endpoint.location.country", + "match": ".*", + "replace": "$0" + }, + { + "input": "client.geographicalContext.postalCode", + "output": "src_endpoint.location.postal_code", + "match": ".*", + "replace": "$0" + }, + { + "input": "client.geographicalContext.state", + "output": "src_endpoint.location.region", + "match": ".*", + "replace": "$0" + }, + { + "input": "securityContext.isp", + "output": "src_endpoint.location.isp", + "match": ".*", + "replace": "$0" + }, + { + "input": "client.userAgent.rawUserAgent", + "output": "http_request.user_agent", + "match": ".*", + "replace": "$0" + }, + { + "input": "eventType", + "output": "unmapped.eventType", + "match": ".*", + "replace": "$0" + }, + { + "input": "eventType", + "output": "event.type", + "match": ".*", + "replace": "$0" + }, + { + "input": "eventType", + "output": "activity_name", + "match": ".*", + "replace": "$0" + }, + { + "input": "legacyEventType", + "output": "unmapped.legacyEventType", + "match": ".*", + "replace": "$0" + }, + { + "input": "outcome.result", + "output": "status", + "match": ".*", + "replace": "$0" + }, + { + "input": "outcome.reason", + "output": "status_detail", + "match": ".*", + "replace": "$0" + }, + { + "input": "_time", + "output": "timestamp", + "match": ".*", + "replace": "$0" + }, + { + "input": "published", + "output": "time", + "match": ".*", + "replace": "$0" + }, + { + "input": "displayMessage", + "output": "message", + "match": ".*", + "replace": "$0" + }, + { + "input": "uuid", + "output": "metadata.uid", + "match": ".*", + "replace": "$0" + }, + { + "input": "severity", + "output": "severity", + "match": ".*", + "replace": "$0" + } + ] + } + ] +} \ No newline at end of file diff --git a/Backend/scenarios/finance_mfa_fatigue_scenario.py b/Backend/scenarios/finance_mfa_fatigue_scenario.py index c0c57bc..b6e5350 100644 --- a/Backend/scenarios/finance_mfa_fatigue_scenario.py +++ b/Backend/scenarios/finance_mfa_fatigue_scenario.py @@ -29,8 +29,15 @@ import os import errno import random +import copy +import gzip +import uuid from datetime import datetime, timezone, timedelta -from typing import Dict, List +from typing import Dict, List, Optional + +import requests + +backend_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # Add event_generators to path sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'event_generators')) @@ -38,7 +45,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'event_generators', 'cloud_infrastructure')) # Import generators -from okta_authentication import okta_authentication_log +from okta_system_log import okta_system_log from microsoft_azuread import azuread_log from microsoft_365_collaboration import microsoft_365_collaboration_log @@ -60,6 +67,171 @@ "timezone_offset": 10 # Moscow is UTC+3, Denver is UTC-7 = 10 hour difference } +# Alert configuration for scenario phases +# Maps scenario detection phases to existing UAM alert templates with overrides +ALERT_PHASE_MAPPING = { + "mfa_fatigue": { + "template": "o365_brute_force_success", + "offset_minutes": 0, + "overrides": { + "finding_info.title": "HELIOS - Okta MFA Fatigue Attack Detected", + "finding_info.desc": f"15 consecutive MFA push requests detected for {JAKE_PROFILE['email']} within 15 minutes from {ATTACKER_PROFILE['ip']} ({ATTACKER_PROFILE['location']}), followed by user acceptance. MITRE ATT&CK: T1621 - Multi-Factor Authentication Request Generation.", + "severity_id": 5, + "severity": "critical", + } + }, + "impossible_traveler": { + "template": "o365_noncompliant_login", + "offset_minutes": 1, + "overrides": { + "finding_info.title": "HELIOS - Impossible Traveler Detected", + "finding_info.desc": f"Login from {ATTACKER_PROFILE['location']} ({ATTACKER_PROFILE['ip']}) detected for {JAKE_PROFILE['email']} 30 minutes after Denver login. Geographic distance: 8,000+ miles. MITRE ATT&CK: T1078 - Valid Accounts.", + "severity_id": 5, + "severity": "critical", + } + }, + "ueba_irregular_login": { + "template": "o365_sneaky_2fa", + "offset_minutes": 2, + "overrides": { + "finding_info.title": "HELIOS - UEBA Irregular Login Pattern", + "finding_info.desc": f"Login detected for {JAKE_PROFILE['email']} at 7:30 PM from {ATTACKER_PROFILE['ip']} - outside normal working hours (8 AM - 5 PM). Baseline deviation: 11.5 hours. Risk score: 85. MITRE ATT&CK: T1078 - Valid Accounts.", + "severity_id": 4, + "severity": "high", + } + }, + "data_exfiltration": { + "template": "sharepoint_data_exfil_alert", + "offset_minutes": 3, + "overrides": { + "finding_info.title": "HELIOS - Irregular Data Download Activity", + "finding_info.desc": f"27 sensitive financial documents downloaded by {JAKE_PROFILE['email']} from {ATTACKER_PROFILE['ip']} ({ATTACKER_PROFILE['location']}) in 30 minutes - 15x normal daily average. Data volume: 4.2 GB. Sensitive data types: PII, Financial Records, Client Data. MITRE ATT&CK: T1530 - Data from Cloud Storage Object.", + "severity_id": 5, + "severity": "critical", + } + }, +} + + +def load_alert_template(template_id: str) -> Optional[Dict]: + """Load an alert template JSON from the templates directory""" + candidate_dirs = [ + os.path.join(backend_dir, 'api', 'app', 'alerts', 'templates'), # local dev + os.path.join(backend_dir, 'app', 'alerts', 'templates'), # Docker + ] + for templates_dir in candidate_dirs: + template_path = os.path.join(templates_dir, f"{template_id}.json") + if os.path.exists(template_path): + with open(template_path, 'r') as f: + return json.load(f) + print(f" āš ļø Template not found: {template_id}.json (searched {candidate_dirs})") + return None + + +def send_phase_alert( + phase_name: str, + alert_time: datetime, + uam_config: dict +) -> bool: + """Send alert for a specific phase with correct timing. + + Standalone implementation — loads template from disk and sends + directly via requests + gzip. No AlertService dependency. + """ + if phase_name not in ALERT_PHASE_MAPPING: + return False + + mapping = ALERT_PHASE_MAPPING[phase_name] + + # Load template + template = load_alert_template(mapping["template"]) + if not template: + return False + + alert = copy.deepcopy(template) + + # Calculate alert timestamp + offset_time = alert_time + timedelta(minutes=mapping["offset_minutes"]) + time_ms = int(offset_time.timestamp() * 1000) + + # Inject fresh UID + if "finding_info" not in alert: + alert["finding_info"] = {} + alert["finding_info"]["uid"] = str(uuid.uuid4()) + + # Set timestamps + alert["time"] = time_ms + if "metadata" not in alert: + alert["metadata"] = {} + alert["metadata"]["logged_time"] = time_ms + alert["metadata"]["modified_time"] = time_ms + + # Set resource to Jake's email with a consistent GUID + email_asset_uid = uam_config.get('email_asset_uid') + if not email_asset_uid: + email_asset_uid = str(uuid.uuid5(uuid.NAMESPACE_DNS, JAKE_PROFILE["email"])) + uam_config['email_asset_uid'] = email_asset_uid + alert["resources"] = [{ + "uid": email_asset_uid, + "name": JAKE_PROFILE["email"] + }] + + # Apply overrides + overrides = mapping.get("overrides", {}) + for key, value in overrides.items(): + if "." in key: + keys = key.split(".") + current = alert + for k in keys[:-1]: + if k not in current: + current[k] = {} + current = current[k] + current[keys[-1]] = value + else: + alert[key] = value + + # Send alert via UAM ingest API + try: + ingest_url = uam_config['uam_ingest_url'].rstrip('/') + '/v1/alerts' + scope = uam_config['uam_account_id'] + if uam_config.get('uam_site_id'): + scope = f"{scope}:{uam_config['uam_site_id']}" + + headers = { + "Authorization": f"Bearer {uam_config['uam_service_token']}", + "S1-Scope": scope, + "Content-Encoding": "gzip", + "Content-Type": "application/json", + "S1-Trace-Id": "helios-ingest-uam:alwayslog", + } + + payload = json.dumps(alert).encode("utf-8") + gzipped = gzip.compress(payload) + + print(f"\n šŸ“¤ Alert Details:") + print(f" Template: {mapping['template']}") + print(f" Title: {alert.get('finding_info', {}).get('title', 'N/A')}") + print(f" User: {JAKE_PROFILE['email']}") + print(f" Time: {offset_time.isoformat()} ({time_ms}ms)") + print(f" URL: {ingest_url}") + print(f" Scope: {scope}") + print(f" Payload: {len(payload)} bytes -> {len(gzipped)} bytes (gzip)") + + resp = requests.post(ingest_url, headers=headers, data=gzipped, timeout=30) + + print(f" Response: {resp.status_code} {resp.reason}") + if resp.content: + print(f" Body: {resp.text[:200]}") + + return resp.status_code == 202 + + except Exception as e: + print(f" āœ— Alert send failed: {e}") + import traceback + traceback.print_exc() + return False + + def get_scenario_time(base_time: datetime, day: int, hour: int, minute: int = 0, second: int = 0) -> str: """Calculate timestamp for scenario event""" event_time = base_time + timedelta(days=day, hours=hour, minutes=minute, seconds=second) @@ -80,7 +252,7 @@ def generate_normal_day_events(base_time: datetime, day: int) -> List[Dict]: # Morning login (8:30 AM) login_time = get_scenario_time(base_time, day, 8, 30) - okta_login_str = okta_authentication_log() + okta_login_str = okta_system_log() okta_login = json.loads(okta_login_str) if isinstance(okta_login_str, str) else okta_login_str # Customize for normal login and set published to scenario timestamp okta_login['published'] = login_time @@ -96,7 +268,7 @@ def generate_normal_day_events(base_time: datetime, day: int) -> List[Dict]: okta_login['displayMessage'] = 'User successfully authenticated' okta_login['severity'] = 'INFO' - events.append(create_event(login_time, "okta_authentication", "normal_behavior", okta_login)) + events.append(create_event(login_time, "okta_ocsf_logs", "normal_behavior", okta_login)) # Azure AD sign-in azuread_signin_str = azuread_log() @@ -159,7 +331,7 @@ def generate_mfa_fatigue_attack(base_time: datetime) -> List[Dict]: # IMPOSSIBLE TRAVELER: Normal Denver login at 7:00 PM denver_login_time = get_scenario_time(base_time, day, 19, 0) # 7:00 PM - okta_denver_str = okta_authentication_log() + okta_denver_str = okta_system_log() okta_denver = json.loads(okta_denver_str) if isinstance(okta_denver_str, str) else okta_denver_str okta_denver['published'] = denver_login_time okta_denver['eventType'] = 'user.session.start' @@ -174,7 +346,7 @@ def generate_mfa_fatigue_attack(base_time: datetime) -> List[Dict]: okta_denver['displayMessage'] = 'Evening login from Denver office' okta_denver['severity'] = 'INFO' - events.append(create_event(denver_login_time, "okta_authentication", "normal_behavior", okta_denver)) + events.append(create_event(denver_login_time, "okta_ocsf_logs", "normal_behavior", okta_denver)) # Azure AD sign-in from Denver at 7:00 PM azuread_denver_str = azuread_log() @@ -197,7 +369,7 @@ def generate_mfa_fatigue_attack(base_time: datetime) -> List[Dict]: attempt_time = get_scenario_time(base_time, day, attack_start_hour, attack_start_minute + i) # Failed Okta MFA attempt - okta_mfa_str = okta_authentication_log() + okta_mfa_str = okta_system_log() okta_mfa = json.loads(okta_mfa_str) if isinstance(okta_mfa_str, str) else okta_mfa_str okta_mfa['published'] = attempt_time okta_mfa['eventType'] = 'user.mfa.challenge' @@ -212,11 +384,11 @@ def generate_mfa_fatigue_attack(base_time: datetime) -> List[Dict]: okta_mfa['displayMessage'] = f'MFA push request #{i+1} - Waiting for user approval' okta_mfa['severity'] = 'WARN' - events.append(create_event(attempt_time, "okta_authentication", "mfa_fatigue", okta_mfa)) + events.append(create_event(attempt_time, "okta_ocsf_logs", "mfa_fatigue", okta_mfa)) # User accepts MFA (attempt #14) accept_time = get_scenario_time(base_time, day, attack_start_hour, attack_start_minute + 14) - okta_success_str = okta_authentication_log() + okta_success_str = okta_system_log() okta_success = json.loads(okta_success_str) if isinstance(okta_success_str, str) else okta_success_str okta_success['published'] = accept_time okta_success['eventType'] = 'user.mfa.challenge' @@ -231,11 +403,11 @@ def generate_mfa_fatigue_attack(base_time: datetime) -> List[Dict]: okta_success['displayMessage'] = 'User approved MFA push - Access granted' okta_success['severity'] = 'INFO' - events.append(create_event(accept_time, "okta_authentication", "initial_access", okta_success)) + events.append(create_event(accept_time, "okta_ocsf_logs", "initial_access", okta_success)) # Session start immediately after successful MFA (30 seconds later) session_time = get_scenario_time(base_time, day, attack_start_hour, attack_start_minute + 14, 30) - okta_session_str = okta_authentication_log() + okta_session_str = okta_system_log() okta_session = json.loads(okta_session_str) if isinstance(okta_session_str, str) else okta_session_str okta_session['published'] = session_time okta_session['eventType'] = 'user.session.start' @@ -250,12 +422,12 @@ def generate_mfa_fatigue_attack(base_time: datetime) -> List[Dict]: okta_session['displayMessage'] = 'Session established after MFA' okta_session['severity'] = 'INFO' - events.append(create_event(session_time, "okta_authentication", "initial_access", okta_session)) + events.append(create_event(session_time, "okta_ocsf_logs", "initial_access", okta_session)) print(f" āœ“ MFA accepted after 15 attempts") # Attacker tries to access Okta Admin Console - BLOCKED (1 minute later) admin_attempt_time = get_scenario_time(base_time, day, attack_start_hour, attack_start_minute + 15, 30) - okta_admin_str = okta_authentication_log() + okta_admin_str = okta_system_log() okta_admin = json.loads(okta_admin_str) if isinstance(okta_admin_str, str) else okta_admin_str okta_admin['published'] = admin_attempt_time okta_admin['eventType'] = 'user.session.access_admin_app' @@ -271,7 +443,7 @@ def generate_mfa_fatigue_attack(base_time: datetime) -> List[Dict]: okta_admin['displayMessage'] = 'User attempted to access Okta admin console but was denied' okta_admin['severity'] = 'WARN' - events.append(create_event(admin_attempt_time, "okta_authentication", "initial_access", okta_admin)) + events.append(create_event(admin_attempt_time, "okta_ocsf_logs", "initial_access", okta_admin)) print(f" āœ“ Failed attempt to access Okta admin console from Moscow") # Azure AD sign-in from Russia @@ -600,6 +772,33 @@ def generate_finance_mfa_fatigue_scenario(): # Start scenario 8 days ago base_time = datetime.now(timezone.utc) - timedelta(days=8) + # Initialize alert detonation from env vars + alerts_enabled = os.getenv('SCENARIO_ALERTS_ENABLED', 'false').lower() == 'true' + uam_config = None + + if alerts_enabled: + uam_ingest_url = os.getenv('UAM_INGEST_URL', '') + uam_account_id = os.getenv('UAM_ACCOUNT_ID', '') + uam_service_token = os.getenv('UAM_SERVICE_TOKEN', '') + uam_site_id = os.getenv('UAM_SITE_ID', '') + + if uam_ingest_url and uam_account_id and uam_service_token: + uam_config = { + 'uam_ingest_url': uam_ingest_url, + 'uam_account_id': uam_account_id, + 'uam_service_token': uam_service_token, + 'uam_site_id': uam_site_id, + } + print("\n🚨 ALERT DETONATION ENABLED") + print(f" UAM Ingest: {uam_ingest_url}") + print(f" Account ID: {uam_account_id}") + if uam_site_id: + print(f" Site ID: {uam_site_id}") + print("=" * 80) + else: + print("āš ļø SCENARIO_ALERTS_ENABLED=true but UAM credentials missing") + alerts_enabled = False + all_events = [] # Phase 1: Normal Behavior Baseline (Days 1-7) @@ -637,6 +836,22 @@ def generate_finance_mfa_fatigue_scenario(): all_events.extend(detection_events) print(f"\nTotal detection/response events: {len(detection_events)}") + # Send UAM alerts for each detection phase + if alerts_enabled and uam_config: + # Detection time = Day 8, 8:15 PM (same as SOAR detections) + detection_time = base_time + timedelta(days=7, hours=20, minutes=15) + print(f"\nšŸ”” SENDING UAM ALERTS") + alert_phases = [ + ("mfa_fatigue", "MFA Fatigue Attack"), + ("impossible_traveler", "Impossible Traveler"), + ("ueba_irregular_login", "UEBA Irregular Login"), + ("data_exfiltration", "Data Exfiltration"), + ] + for alert_key, alert_desc in alert_phases: + print(f" šŸ“¤ {alert_desc}...", end=" ") + success = send_phase_alert(alert_key, detection_time, uam_config) + print(f"{'āœ“' if success else 'āœ—'}") + # Sort all events by timestamp all_events.sort(key=lambda x: x['timestamp']) diff --git a/Backend/utilities/parsers/community_new/ai-siem-main/parsers/community/okta_ocsf_logs-latest/okta_ocsf_logs.conf b/Backend/utilities/parsers/community_new/ai-siem-main/parsers/community/okta_ocsf_logs-latest/okta_ocsf_logs.conf index f2fcc9c..de331f0 100644 --- a/Backend/utilities/parsers/community_new/ai-siem-main/parsers/community/okta_ocsf_logs-latest/okta_ocsf_logs.conf +++ b/Backend/utilities/parsers/community_new/ai-siem-main/parsers/community/okta_ocsf_logs-latest/okta_ocsf_logs.conf @@ -1,191 +1,179 @@ { - attributes: { - source: "okta" - "dataSource.category": "security", - "dataSource.name": "Okta", - "dataSource.vendor": "Okta", + "attributes": { + "dataSource.category": "security", + "dataSource.name": "Okta", + "dataSource.vendor": "Okta" + }, + "formats": [ + { + "format": ".*published", + "attributes": { + "category_uid": "3", + "category_name": "Audit Activity", + "class_uid": "3006", + "class_name": "Access Activity", + "severity_id": "99", + "activity_id": "99", + "type_uid": "300699", + "metadata.product.vendor_name": "Okta", + "metadata.product.name": "Okta", + "metadata.version": "1.0.0-rc.2" + } }, - formats: [ - { - format: ".*${parse=dottedJson}{attrBlacklist=target}$" - rewrites: [ - { - input: "actor.id", - output: "user.account_uid", - match: ".*", - replace: "$0" - }, - { - input: "actor.type", - output: "user.account_type", - match: ".*", - replace: "$0" - }, - { - input: "actor.alternateId", - output: "user.email_addr", - match: ".*", - replace: "$0" - }, - { - input: "actor.displayName", - output: "user.name", - match: ".*", - replace: "$0" - }, - { - input: "authenticationContext.authenticationStep", - output: "authenticationStep", - match: ".*", - replace: "$0" - }, - { - input: "authenticationContext.externalSessionId", - output: "externalSessionId", - match: ".*", - replace: "$0" - }, - { - input: "client.ipAddress", - output: "client.ip", - match: ".*", - replace: "$0" - }, - { - input: "client.userAgent.browser", - output: "client.browser", - match: ".*", - replace: "$0" - }, - { - input: "client.userAgent.os", - output: "clinet.os", - match: ".*", - replace: "$0" - }, - { - input: "client.userAgent.rawUserAgent", - output: "client.userAgent", - match: ".*", - replace: "$0" - }, - { - input: "client.zone", - output: "client.location.zone", - match: ".*", - replace: "$0" - }, - { - input: "client.geographicalContext.city", - output: "client.location.city", - match: ".*", - replace: "$0" - }, - { - input: "client.geographicalContext.country", - output: "client.location.country", - match: ".*", - replace: "$0" - }, - { - input: "client.geographicalContext.geolocation.lat", - output: "client.location.lat", - match: ".*", - replace: "$0" - }, - { - input: "client.geographicalContext.geolocation.lon", - output: "client.location.lon", - match: ".*", - replace: "$0" - }, - { - input: "client.geographicalContext.postalCode", - output: "client.location.postal_code", - match: ".*", - replace: "$0" - }, - { - input: "client.geographicalContext.state", - output: "client.location.state", - match: ".*", - replace: "$0" - }, - { - input: "debugContext.debugData.requestUri", - output: "observables", - match: ".*", - replace: "$0" - }, - { - input: "debugContext.debugData.state", - output: "status", - match: ".*", - replace: "$0" - }, - { - input: "displayMessage", - output: "msg", - match: ".*", - replace: "$0" - }, - { - input: "eventType", - output: "category_name", - match: ".*", - replace: "$0" - }, - { - input: "legacyEventType", - output: "legacy_category_name", - match: ".*", - replace: "$0" - }, - { - input: "outcome.result", - output: "result", - match: ".*", - replace: "$0" - }, - { - input: "published", - output: "time", - match: ".*", - replace: "$0" - }, - { - input: "transaction.id", - output: "type_uid", - match: ".*", - replace: "$0" - }, - { - input: "transaction.type", - output: "type_name", - match: ".*", - replace: "$0" - }, - { - input: "version", - output: "metadata.version", - match: ".*", - replace: "$0" - }, - { - input: "uuid", - output: "activity_id", - match: ".*", - replace: "$0" - }, - { - input: "time", - output: "timestamp", - match: ".*", - replace: "$0" - } - ] - }, - {format: ".*target\": \\[$target.=json{parse=dottedJson}$"} - - ] - } \ No newline at end of file + { + "format": ".*${parse=dottedJson}{attrBlacklist=(detailEntry|type|authenticationStep|target|request|userId|timeUnit|timeSpan|threshold|requestId|rateLimitSecondsToReset|rateLimitScopeType|rateLimitBucketUuid|dtHash|securityContext|requestApiTokenId)}$", + "rewrites": [ + { + "input": "actor.alternateId", + "output": "actor.user.email_addr", + "match": ".*", + "replace": "$0" + }, + { + "input": "actor.displayName", + "output": "actor.user.name", + "match": ".*", + "replace": "$0" + }, + { + "input": "actor.id", + "output": "actor.user.uid", + "match": ".*", + "replace": "$0" + }, + { + "input": "actor.type", + "output": "actor.user.type", + "match": ".*", + "replace": "$0" + }, + { + "input": "client.ipAddress", + "output": "src_endpoint.ip", + "match": ".*", + "replace": "$0" + }, + { + "input": "client.device", + "output": "unmapped.client.device", + "match": ".*", + "replace": "$0" + }, + { + "input": "client.id", + "output": "src_endpoint.uid", + "match": ".*", + "replace": "$0" + }, + { + "input": "client.zone", + "output": "src_endpoint.domain", + "match": ".*", + "replace": "$0" + }, + { + "input": "client.geographicalContext.city", + "output": "src_endpoint.location.city", + "match": ".*", + "replace": "$0" + }, + { + "input": "client.geographicalContext.country", + "output": "src_endpoint.location.country", + "match": ".*", + "replace": "$0" + }, + { + "input": "client.geographicalContext.postalCode", + "output": "src_endpoint.location.postal_code", + "match": ".*", + "replace": "$0" + }, + { + "input": "client.geographicalContext.state", + "output": "src_endpoint.location.region", + "match": ".*", + "replace": "$0" + }, + { + "input": "securityContext.isp", + "output": "src_endpoint.location.isp", + "match": ".*", + "replace": "$0" + }, + { + "input": "client.userAgent.rawUserAgent", + "output": "http_request.user_agent", + "match": ".*", + "replace": "$0" + }, + { + "input": "eventType", + "output": "unmapped.eventType", + "match": ".*", + "replace": "$0" + }, + { + "input": "eventType", + "output": "event.type", + "match": ".*", + "replace": "$0" + }, + { + "input": "eventType", + "output": "activity_name", + "match": ".*", + "replace": "$0" + }, + { + "input": "legacyEventType", + "output": "unmapped.legacyEventType", + "match": ".*", + "replace": "$0" + }, + { + "input": "outcome.result", + "output": "status", + "match": ".*", + "replace": "$0" + }, + { + "input": "outcome.reason", + "output": "status_detail", + "match": ".*", + "replace": "$0" + }, + { + "input": "_time", + "output": "timestamp", + "match": ".*", + "replace": "$0" + }, + { + "input": "published", + "output": "time", + "match": ".*", + "replace": "$0" + }, + { + "input": "displayMessage", + "output": "message", + "match": ".*", + "replace": "$0" + }, + { + "input": "uuid", + "output": "metadata.uid", + "match": ".*", + "replace": "$0" + }, + { + "input": "severity", + "output": "severity", + "match": ".*", + "replace": "$0" + } + ] + } + ] +} \ No newline at end of file From 83353fd68cc74bb83c55cf33e0fdca32defb2f6d Mon Sep 17 00:00:00 2001 From: jmorascalyr <42879226+jmorascalyr@users.noreply.github.com> Date: Sat, 21 Feb 2026 19:56:23 -0700 Subject: [PATCH 2/2] feat: Add Threat Intelligence IOC management UI and API integration - Added threat_intel router to main.py API includes - Created /threat-intel/types, /threat-intel/list, and /threat-intel/send endpoints in Frontend - Added TI tab to navigation with shield icon - Implemented comprehensive IOC form with support for all IOC types (DNS, IPV4, IPV6, URL, SHA256, SHA1, MD5, Domain) - Added advanced fields for severity, external ID, risk score, MITRE tactics, threat actors, campaign names, intrusion sets --- Backend/api/app/main.py | 8 +- Backend/api/app/routers/threat_intel.py | 218 ++++++ .../api/app/services/threat_intel_service.py | 361 ++++++++++ Frontend/log_generator_ui.py | 192 +++++ Frontend/templates/log_generator.html | 653 ++++++++++++++++++ 5 files changed, 1431 insertions(+), 1 deletion(-) create mode 100644 Backend/api/app/routers/threat_intel.py create mode 100644 Backend/api/app/services/threat_intel_service.py diff --git a/Backend/api/app/main.py b/Backend/api/app/main.py index 2cfcd21..4ea1853 100644 --- a/Backend/api/app/main.py +++ b/Backend/api/app/main.py @@ -16,7 +16,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent.parent)) from app.core.config import settings -from app.routers import generators, parsers, health, scenarios, export, metrics, search, categories, destinations, uploads, parser_sync, alerts +from app.routers import generators, parsers, health, scenarios, export, metrics, search, categories, destinations, uploads, parser_sync, alerts, threat_intel from app.routers import settings as settings_router from app.utils.logging import setup_logging from app.core.simple_auth import validate_api_keys_config @@ -243,6 +243,12 @@ async def root(): tags=["alerts"] ) +app.include_router( + threat_intel.router, + prefix=f"{settings.API_V1_STR}/threat-intel", + tags=["threat-intel"] +) + if __name__ == "__main__": import uvicorn uvicorn.run( diff --git a/Backend/api/app/routers/threat_intel.py b/Backend/api/app/routers/threat_intel.py new file mode 100644 index 0000000..a0f6bbf --- /dev/null +++ b/Backend/api/app/routers/threat_intel.py @@ -0,0 +1,218 @@ +""" +Threat Intelligence API endpoints +=================================== + +Send IOCs to SentinelOne via the S1 Management API. +Uses ApiToken auth (same as XDR assets), not UAM ingest. +""" +from fastapi import APIRouter, HTTPException, Depends +from pydantic import BaseModel, Field +from typing import Optional, Dict, Any, List +import logging + +from app.core.simple_auth import require_read_access, require_write_access +from app.models.responses import BaseResponse +from app.services.threat_intel_service import threat_intel_service + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +class IOCItem(BaseModel): + """A single IOC entry""" + type: str = Field(..., description="IOC type (DNS, IPV4, IPV6, URL, SHA256, SHA1, MD5, Domain)") + value: str = Field(..., description="The IOC value (IP, domain, hash, URL, etc.)") + method: str = Field("EQUALS", description="Match method") + source: str = Field("HELIOS", description="Source name") + creator: str = Field("HELIOS", description="Creator name") + name: Optional[str] = Field(None, description="IOC display name") + description: Optional[str] = Field(None, description="IOC description") + severity: Optional[int] = Field(None, ge=0, le=100, description="Severity (0-100)") + original_risk_score: Optional[int] = Field(None, description="Original risk score") + valid_until: Optional[str] = Field(None, description="Expiry datetime (ISO format)") + creation_time: Optional[str] = Field(None, description="Creation datetime (ISO format)") + external_id: Optional[str] = Field(None, description="External reference ID") + pattern: Optional[str] = Field(None, description="Detection pattern") + pattern_type: Optional[str] = Field(None, description="Pattern language (e.g. STIX)") + metadata: Optional[str] = Field(None, description="Additional metadata string") + reference: Optional[List[str]] = Field(None, description="External references") + intrusion_sets: Optional[List[str]] = Field(None, description="Associated intrusion sets") + campaign_names: Optional[List[str]] = Field(None, description="Associated campaigns") + malware_names: Optional[List[str]] = Field(None, description="Associated malware names") + mitre_tactic: Optional[List[str]] = Field(None, description="MITRE ATT&CK tactics") + threat_actors: Optional[List[str]] = Field(None, description="Threat actor names") + labels: Optional[List[str]] = Field(None, description="Labels/tags") + category: Optional[List[str]] = Field(None, description="IOC categories") + threat_actor_types: Optional[List[str]] = Field(None, description="Threat actor type classifications") + + +class ThreatIntelSendRequest(BaseModel): + """Request model for sending IOCs""" + s1_management_url: str = Field(..., description="S1 management console URL") + api_token: str = Field(..., description="S1 API token") + auth_type: str = Field("ApiToken", description="Auth type: 'ApiToken' or 'Bearer'") + account_id: Optional[str] = Field(None, description="Account ID for scoping") + site_id: Optional[str] = Field(None, description="Site ID for scoping") + iocs: List[IOCItem] = Field(..., min_length=1, description="List of IOCs to send") + + +class ThreatIntelCustomRequest(BaseModel): + """Request model for sending custom IOC JSON""" + s1_management_url: str = Field(..., description="S1 management console URL") + api_token: str = Field(..., description="S1 API token") + auth_type: str = Field("ApiToken", description="Auth type: 'ApiToken' or 'Bearer'") + account_id: Optional[str] = Field(None, description="Account ID for scoping") + site_id: Optional[str] = Field(None, description="Site ID for scoping") + iocs_json: List[Dict[str, Any]] = Field(..., description="Raw IOC dicts to send") + + +class ThreatIntelGetRequest(BaseModel): + """Request model for fetching IOCs (POST body with creds)""" + s1_management_url: str = Field(..., description="S1 management console URL") + api_token: str = Field(..., description="S1 API token") + auth_type: str = Field("ApiToken", description="Auth type: 'ApiToken' or 'Bearer'") + account_id: Optional[str] = Field(None, description="Account ID for scoping") + site_id: Optional[str] = Field(None, description="Site ID for scoping") + ioc_type: Optional[str] = Field(None, description="Filter by IOC type") + value: Optional[str] = Field(None, description="Filter by value (contains)") + source: Optional[str] = Field(None, description="Filter by source (contains)") + creator: Optional[str] = Field(None, description="Filter by creator (contains)") + limit: int = Field(100, ge=1, le=1000, description="Max results per page") + cursor: Optional[str] = Field(None, description="Pagination cursor") + + +@router.get("/types", response_model=BaseResponse) +async def get_ioc_types( + _: str = Depends(require_read_access) +): + """Get supported IOC types, methods, and threat actor types""" + return BaseResponse( + success=True, + data=threat_intel_service.list_ioc_types() + ) + + +@router.post("/list", response_model=BaseResponse) +async def list_iocs( + req: ThreatIntelGetRequest, + _: str = Depends(require_read_access) +): + """ + List IOCs from the SentinelOne Threat Intelligence API. + + Uses POST to securely pass API credentials in the body. + Proxies GET /web/api/v2.1/threat-intelligence/iocs on S1. + """ + try: + result = threat_intel_service.get_iocs( + s1_management_url=req.s1_management_url, + api_token=req.api_token, + auth_type=req.auth_type, + account_ids=req.account_id, + site_ids=req.site_id, + ioc_type=req.ioc_type, + value=req.value, + source=req.source, + creator=req.creator, + limit=req.limit, + cursor=req.cursor, + ) + + return BaseResponse( + success=result.get("success", False), + data=result, + ) + except Exception as e: + logger.error(f"Failed to list IOCs: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/send", response_model=BaseResponse) +async def send_iocs( + req: ThreatIntelSendRequest, + _: str = Depends(require_write_access) +): + """ + Send IOCs to the SentinelOne Threat Intelligence API. + + Uses the S1 Management API with ApiToken authentication. + IOCs are added to the private TI repository for third-party matching. + """ + try: + # Build IOC dicts from the structured request + ioc_dicts = [] + for ioc in req.iocs: + ioc_dict = threat_intel_service.build_ioc( + ioc_type=ioc.type, + value=ioc.value, + method=ioc.method, + source=ioc.source, + creator=ioc.creator, + name=ioc.name, + description=ioc.description, + severity=ioc.severity, + original_risk_score=ioc.original_risk_score, + valid_until=ioc.valid_until, + creation_time=ioc.creation_time, + external_id=ioc.external_id, + pattern=ioc.pattern, + pattern_type=ioc.pattern_type, + metadata=ioc.metadata, + reference=ioc.reference, + intrusion_sets=ioc.intrusion_sets, + campaign_names=ioc.campaign_names, + malware_names=ioc.malware_names, + mitre_tactic=ioc.mitre_tactic, + threat_actors=ioc.threat_actors, + labels=ioc.labels, + category=ioc.category, + threat_actor_types=ioc.threat_actor_types, + ) + ioc_dicts.append(ioc_dict) + + result = threat_intel_service.send_iocs( + s1_management_url=req.s1_management_url, + api_token=req.api_token, + iocs=ioc_dicts, + auth_type=req.auth_type, + account_ids=req.account_id, + site_ids=req.site_id, + ) + + return BaseResponse( + success=result.get("success", False), + data=result, + ) + except Exception as e: + logger.error(f"Failed to send IOCs: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/send-custom", response_model=BaseResponse) +async def send_custom_iocs( + req: ThreatIntelCustomRequest, + _: str = Depends(require_write_access) +): + """ + Send custom IOC JSON to the SentinelOne Threat Intelligence API. + + Accepts raw IOC dicts and sends them directly without building. + """ + try: + result = threat_intel_service.send_iocs( + s1_management_url=req.s1_management_url, + api_token=req.api_token, + iocs=req.iocs_json, + auth_type=req.auth_type, + account_ids=req.account_id, + site_ids=req.site_id, + ) + + return BaseResponse( + success=result.get("success", False), + data=result, + ) + except Exception as e: + logger.error(f"Failed to send custom IOCs: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) diff --git a/Backend/api/app/services/threat_intel_service.py b/Backend/api/app/services/threat_intel_service.py new file mode 100644 index 0000000..8b44de7 --- /dev/null +++ b/Backend/api/app/services/threat_intel_service.py @@ -0,0 +1,361 @@ +""" +Threat Intelligence service for sending IOCs to SentinelOne +============================================================= + +Uses the S1 Management API to create IOCs in the private TI repository. +Endpoint: POST {s1_management_url}/web/api/v2.1/threat-intelligence/iocs +Auth: ApiToken +""" +import json +import logging +from datetime import datetime, timedelta, timezone +from typing import Dict, Any, List, Optional + +import requests + +logger = logging.getLogger(__name__) + +# Supported IOC types +IOC_TYPES = [ + "DNS", + "IPV4", + "IPV6", + "URL", + "SHA256", + "SHA1", + "MD5", + "Domain", +] + +# Supported match methods +IOC_METHODS = ["EQUALS"] + +# Supported threat actor types +THREAT_ACTOR_TYPES = [ + "Nation-state", + "Criminal", + "Hacktivist", + "Insider", + "APT", + "Script kiddies", +] + + +class ThreatIntelService: + """Service for managing Threat Intelligence IOCs via S1 Management API""" + + def list_ioc_types(self) -> Dict[str, Any]: + """Return supported IOC types, methods, and threat actor types""" + return { + "types": IOC_TYPES, + "methods": IOC_METHODS, + "threat_actor_types": THREAT_ACTOR_TYPES, + } + + def build_ioc( + self, + ioc_type: str, + value: str, + source: str = "HELIOS", + creator: str = "HELIOS", + method: str = "EQUALS", + name: Optional[str] = None, + description: Optional[str] = None, + severity: Optional[int] = None, + original_risk_score: Optional[int] = None, + valid_until: Optional[str] = None, + creation_time: Optional[str] = None, + external_id: Optional[str] = None, + pattern: Optional[str] = None, + pattern_type: Optional[str] = None, + metadata: Optional[str] = None, + reference: Optional[List[str]] = None, + intrusion_sets: Optional[List[str]] = None, + campaign_names: Optional[List[str]] = None, + malware_names: Optional[List[str]] = None, + mitre_tactic: Optional[List[str]] = None, + threat_actors: Optional[List[str]] = None, + labels: Optional[List[str]] = None, + category: Optional[List[str]] = None, + threat_actor_types: Optional[List[str]] = None, + ) -> Dict[str, Any]: + """ + Build a single IOC dict for the S1 TI API. + + Args: + ioc_type: IOC type (DNS, IPV4, URL, SHA256, etc.) + value: The IOC value (IP address, domain, hash, etc.) + source: Source name + creator: Creator name + method: Match method (EQUALS) + name: IOC display name + description: IOC description + severity: Severity score (0-100) + original_risk_score: Original risk score + valid_until: Expiry datetime ISO string + creation_time: Creation datetime ISO string + external_id: External reference ID + pattern: Detection pattern + pattern_type: Pattern language (e.g. STIX) + metadata: Additional metadata string + reference: External references + intrusion_sets: Associated intrusion sets + campaign_names: Associated campaigns + malware_names: Associated malware + mitre_tactic: MITRE ATT&CK tactics + threat_actors: Threat actor names + labels: Labels/tags + category: IOC categories + threat_actor_types: Threat actor type classifications + + Returns: + IOC dict ready for the S1 TI API data array + """ + now = datetime.now(timezone.utc) + + ioc = { + "type": ioc_type, + "value": value, + "method": method, + "source": source, + "creator": creator, + "creationTime": creation_time or now.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + "validUntil": valid_until or (now + timedelta(days=90)).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + } + + if name: + ioc["name"] = name + if description: + ioc["description"] = description + if severity is not None: + ioc["severity"] = severity + if original_risk_score is not None: + ioc["originalRiskScore"] = original_risk_score + if external_id: + ioc["externalId"] = external_id + if pattern: + ioc["pattern"] = pattern + if pattern_type: + ioc["patternType"] = pattern_type + if metadata: + ioc["metadata"] = metadata + if reference: + ioc["reference"] = reference + if intrusion_sets: + ioc["intrusionSets"] = intrusion_sets + if campaign_names: + ioc["campaignNames"] = campaign_names + if malware_names: + ioc["malwareNames"] = malware_names + if mitre_tactic: + ioc["mitreTactic"] = mitre_tactic + if threat_actors: + ioc["threatActors"] = threat_actors + if labels: + ioc["labels"] = labels + if category: + ioc["category"] = category + if threat_actor_types: + ioc["threatActorTypes"] = threat_actor_types + + return ioc + + def send_iocs( + self, + s1_management_url: str, + api_token: str, + iocs: List[Dict[str, Any]], + auth_type: str = "ApiToken", + account_ids: Optional[str] = None, + site_ids: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Send IOCs to the SentinelOne Threat Intelligence API. + + Args: + s1_management_url: S1 management console URL + api_token: Token for auth + iocs: List of IOC dicts (from build_ioc or custom) + auth_type: 'ApiToken' or 'Bearer' + account_ids: Optional account ID for scoping + site_ids: Optional site ID for scoping + + Returns: + Dict with success status, response data, and details + """ + url = f"{s1_management_url.rstrip('/')}/web/api/v2.1/threat-intelligence/iocs" + + # S1 API: if siteIds is passed, use site scope only (don't also pass accountIds) + params = {} + if site_ids: + params["siteIds"] = site_ids + elif account_ids: + params["accountIds"] = account_ids + + headers = { + "Authorization": f"{auth_type} {api_token}", + "Content-Type": "application/json", + } + + payload = { + "filter": {}, + "data": iocs, + } + + logger.info( + "TI IOC send: url=%s ioc_count=%d account=%s site=%s", + url, len(iocs), account_ids, site_ids, + ) + + try: + response = requests.post( + url, + headers=headers, + params=params, + json=payload, + timeout=30, + ) + response.raise_for_status() + + resp_data = {} + if response.content: + try: + resp_data = response.json() + except Exception: + resp_data = {"raw": response.text[:500]} + + logger.info( + "TI IOC response: status=%s body=%s", + response.status_code, + json.dumps(resp_data)[:500], + ) + + return { + "success": True, + "status": response.status_code, + "status_text": response.reason, + "data": resp_data, + "ioc_count": len(iocs), + } + except requests.exceptions.HTTPError as err: + error_body = "" + try: + error_body = err.response.text + except Exception: + pass + logger.error("TI IOC HTTP error: %s - %s", err, error_body) + return { + "success": False, + "status": err.response.status_code if err.response is not None else 0, + "error": str(err), + "detail": error_body, + } + except requests.exceptions.RequestException as err: + logger.error("TI IOC request error: %s", err) + return { + "success": False, + "status": 0, + "error": str(err), + } + + + def get_iocs( + self, + s1_management_url: str, + api_token: str, + auth_type: str = "ApiToken", + account_ids: Optional[str] = None, + site_ids: Optional[str] = None, + ioc_type: Optional[str] = None, + value: Optional[str] = None, + source: Optional[str] = None, + creator: Optional[str] = None, + limit: int = 100, + cursor: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Fetch IOCs from the SentinelOne Threat Intelligence API. + + Args: + s1_management_url: S1 management console URL + api_token: Token for auth + auth_type: 'ApiToken' or 'Bearer' + account_ids: Optional account ID for scoping + site_ids: Optional site ID for scoping + ioc_type: Filter by IOC type + value: Filter by IOC value (contains) + source: Filter by source + creator: Filter by creator + limit: Max results per page + cursor: Pagination cursor + + Returns: + Dict with success status, IOC data, and pagination info + """ + url = f"{s1_management_url.rstrip('/')}/web/api/v2.1/threat-intelligence/iocs" + + # S1 TI GET endpoint returns 403 with scope params for multi-scope API tokens + # Don't pass accountIds/siteIds — the API uses the token's inherent scope + params = {"limit": limit} + if ioc_type: + params["type"] = ioc_type + if value: + params["value__contains"] = value + if source: + params["source__contains"] = source + if creator: + params["creator__contains"] = creator + if cursor: + params["cursor"] = cursor + + headers = { + "Authorization": f"{auth_type} {api_token}", + "Content-Type": "application/json", + } + + logger.info( + "TI IOC get: url=%s auth=%s type=%s limit=%d", + url, auth_type, ioc_type, limit, + ) + + try: + response = requests.get( + url, + headers=headers, + params=params, + timeout=30, + ) + response.raise_for_status() + + resp_data = response.json() if response.content else {} + + return { + "success": True, + "status": response.status_code, + "data": resp_data.get("data", []), + "pagination": resp_data.get("pagination", {}), + } + except requests.exceptions.HTTPError as err: + error_body = "" + try: + error_body = err.response.text + except Exception: + pass + logger.error("TI IOC GET error: %s - %s", err, error_body) + return { + "success": False, + "status": err.response.status_code if err.response is not None else 0, + "error": str(err), + "detail": error_body, + } + except requests.exceptions.RequestException as err: + logger.error("TI IOC GET request error: %s", err) + return { + "success": False, + "status": 0, + "error": str(err), + } + + +# Singleton instance +threat_intel_service = ThreatIntelService() diff --git a/Frontend/log_generator_ui.py b/Frontend/log_generator_ui.py index 04b1645..4c645ef 100644 --- a/Frontend/log_generator_ui.py +++ b/Frontend/log_generator_ui.py @@ -2362,6 +2362,198 @@ def send_custom_alert(): return jsonify({'error': str(e)}), 500 +# ============================================================================= +# THREAT INTELLIGENCE ENDPOINTS +# ============================================================================= + +@app.route('/threat-intel/types', methods=['GET']) +def get_ti_types(): + """Get supported IOC types""" + try: + resp = requests.get( + f"{API_BASE_URL}/api/v1/threat-intel/types", + headers=_get_api_headers(), + timeout=10 + ) + if resp.status_code == 200: + return jsonify(resp.json().get('data', {})) + else: + return jsonify({'types': [], 'methods': []}) + except Exception as e: + logger.error(f"Failed to fetch TI types: {e}") + return jsonify({'types': [], 'methods': []}) + + +@app.route('/threat-intel/list', methods=['POST']) +def list_threat_intel(): + """List IOCs from SentinelOne TI API via destination credentials""" + try: + data = request.get_json(silent=True) or {} + destination_id = data.get('destination_id') + + if not destination_id: + return jsonify({'error': 'No destination_id provided'}), 400 + + # Resolve S1 management URL from destination + s1_resp = requests.get( + f"{API_BASE_URL}/api/v1/destinations/{destination_id}/s1-api-token", + headers=_get_api_headers(), + timeout=10 + ) + if s1_resp.status_code != 200: + return jsonify({'error': 'No S1 API Token configured for this destination.'}), 400 + s1_data = s1_resp.json() + s1_mgmt_url = s1_data.get('s1_management_url', '') + + if not s1_mgmt_url: + return jsonify({'error': 'S1 Management URL missing'}), 400 + + # Try UAM service token (Bearer) — works for multi-scope users + # Fall back to S1 API token (ApiToken) if service token not available + uam_resp = requests.get( + f"{API_BASE_URL}/api/v1/destinations/{destination_id}/uam-token", + headers=_get_api_headers(), + timeout=10 + ) + if uam_resp.status_code == 200: + uam_data = uam_resp.json() + api_token = uam_data.get('token', '') + auth_type = 'Bearer' + else: + api_token = s1_data.get('token', '') + auth_type = 'ApiToken' + + if not api_token: + return jsonify({'error': 'No token available (tried UAM service token and S1 API token)'}), 400 + + payload = { + 's1_management_url': s1_mgmt_url, + 'api_token': api_token, + 'auth_type': auth_type, + 'ioc_type': data.get('ioc_type'), + 'value': data.get('value'), + 'source': data.get('source'), + 'creator': data.get('creator'), + 'limit': data.get('limit', 100), + 'cursor': data.get('cursor'), + } + + headers = _get_api_headers() + headers['Content-Type'] = 'application/json' + + resp = requests.post( + f"{API_BASE_URL}/api/v1/threat-intel/list", + headers=headers, + json=payload, + timeout=30 + ) + + if resp.status_code == 200: + return jsonify(resp.json().get('data', {})) + else: + error_msg = resp.text + try: + error_msg = resp.json().get('detail', resp.text) + except Exception: + pass + return jsonify({'error': error_msg}), resp.status_code + except Exception as e: + logger.error(f"Failed to list TI IOCs: {e}") + return jsonify({'error': str(e)}), 500 + + +@app.route('/threat-intel/send', methods=['POST']) +def send_threat_intel(): + """Send IOCs to SentinelOne TI API via destination credentials""" + try: + data = request.get_json(silent=True) or {} + destination_id = data.pop('destination_id', None) + + if not destination_id: + return jsonify({'error': 'No destination_id provided'}), 400 + + # Resolve S1 management URL + s1_resp = requests.get( + f"{API_BASE_URL}/api/v1/destinations/{destination_id}/s1-api-token", + headers=_get_api_headers(), + timeout=10 + ) + if s1_resp.status_code != 200: + return jsonify({'error': 'No S1 API Token configured for this destination. Go to Settings → Edit Destination.'}), 400 + s1_data = s1_resp.json() + s1_mgmt_url = s1_data.get('s1_management_url', '') + + if not s1_mgmt_url: + return jsonify({'error': 'S1 Management URL missing'}), 400 + + # Try UAM service token (Bearer) first, fall back to S1 API token (ApiToken) + uam_resp = requests.get( + f"{API_BASE_URL}/api/v1/destinations/{destination_id}/uam-token", + headers=_get_api_headers(), + timeout=10 + ) + if uam_resp.status_code == 200: + uam_data = uam_resp.json() + api_token = uam_data.get('token', '') + auth_type = 'Bearer' + else: + api_token = s1_data.get('token', '') + auth_type = 'ApiToken' + + if not api_token: + return jsonify({'error': 'No token available (tried UAM service token and S1 API token)'}), 400 + + # Get account/site IDs from destination + dest_resp = requests.get( + f"{API_BASE_URL}/api/v1/destinations/{destination_id}", + headers=_get_api_headers(), + timeout=10 + ) + if dest_resp.status_code != 200: + return jsonify({'error': 'Failed to fetch destination details'}), 400 + dest = dest_resp.json() + + # Build the backend request + payload = { + 's1_management_url': s1_mgmt_url, + 'api_token': api_token, + 'auth_type': auth_type, + 'account_id': dest.get('uam_account_id'), + 'site_id': dest.get('uam_site_id'), + } + + # Check if this is a custom JSON send or structured IOC send + if 'iocs_json' in data: + payload['iocs_json'] = data['iocs_json'] + endpoint = 'send-custom' + else: + payload['iocs'] = data.get('iocs', []) + endpoint = 'send' + + headers = _get_api_headers() + headers['Content-Type'] = 'application/json' + + resp = requests.post( + f"{API_BASE_URL}/api/v1/threat-intel/{endpoint}", + headers=headers, + json=payload, + timeout=60 + ) + + if resp.status_code == 200: + return jsonify(resp.json().get('data', {})) + else: + error_msg = resp.text + try: + error_msg = resp.json().get('detail', resp.text) + except Exception: + pass + return jsonify({'error': error_msg}), resp.status_code + except Exception as e: + logger.error(f"Failed to send TI IOCs: {e}") + return jsonify({'error': str(e)}), 500 + + @app.route('/alerts/resolve-uam/', methods=['GET']) def resolve_uam_credentials(dest_id): """Resolve UAM credentials (token, account_id, site_id, ingest_url) from a destination""" diff --git a/Frontend/templates/log_generator.html b/Frontend/templates/log_generator.html index 1edb190..322210f 100644 --- a/Frontend/templates/log_generator.html +++ b/Frontend/templates/log_generator.html @@ -307,6 +307,10 @@ + + + + + +
+

TI Output

+
+ +
+
+ + + + +
+
+ ā‘¢ Destination +
+ + +
+ + + + + + + +
+ List Filters +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+

+ ā“˜ TI uses the S1 Management API (ApiToken auth). Configure S1 Management URL & API Token in Settings → Edit Destination. +

+
+
+
+ +