Skip to content

Commit b929010

Browse files
authored
Merge branch 'main' into identity-attack-cross-platform
2 parents 1a9e00f + bfcb28b commit b929010

6 files changed

Lines changed: 1961 additions & 1194 deletions

File tree

Backend/api/app/services/scenario_service.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,15 @@ def __init__(self):
149149
{"name": "Data Exfiltration", "generators": ["microsoft_365_collaboration"], "duration": 10}
150150
]
151151
},
152+
"tor_user": {
153+
"id": "tor_user",
154+
"name": "Tor User",
155+
"description": "Palo Alto firewall logs showing Tor usage followed by Okta authentication for the same user. Triggers Tor-usage detections with cross-source pivoting.",
156+
"phases": [
157+
{"name": "Tor Browsing", "generators": ["paloalto_firewall"], "duration": 5},
158+
{"name": "Okta Login", "generators": ["okta_authentication"], "duration": 5}
159+
]
160+
},
152161
"hr_phishing_pdf_c2": {
153162
"id": "hr_phishing_pdf_c2",
154163
"name": "HR Phishing PDF -> PowerShell -> Scheduled Task -> C2",
@@ -422,6 +431,26 @@ async def _execute_correlation_scenario(
422431
async def _execute_scenario(self, execution_id: str, scenario: Dict[str, Any]):
423432
"""Execute scenario in background"""
424433
try:
434+
# Special handling: invoke sender for Tor User scenario
435+
if scenario.get("id") == "tor_user":
436+
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
437+
scenarios_dir = os.path.join(base_dir, "scenarios")
438+
if scenarios_dir not in sys.path:
439+
sys.path.insert(0, scenarios_dir)
440+
try:
441+
sender = importlib.import_module("tor_user_sender")
442+
workers_env = os.getenv("S1_HEC_WORKERS", "4")
443+
workers = int(workers_env) if workers_env.isdigit() else 4
444+
await asyncio.to_thread(sender.send_tor_user, workers)
445+
self.running_scenarios[execution_id]["status"] = "completed"
446+
self.running_scenarios[execution_id]["completed_at"] = datetime.utcnow().isoformat()
447+
return
448+
except Exception as e:
449+
logger.error(f"Failed to run Tor User scenario sender: {e}")
450+
self.running_scenarios[execution_id]["status"] = "failed"
451+
self.running_scenarios[execution_id]["error"] = str(e)
452+
return
453+
425454
# Special handling: invoke sender for HR phishing scenario to generate and send real events
426455
if scenario.get("id") == "hr_phishing_pdf_c2":
427456
# Compute repo root and add scenarios path for import

Backend/event_generators/shared/hec_sender.py

Lines changed: 145 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -608,7 +608,9 @@ def _post(url, headers=None, data=None, json=None, timeout=10):
608608
'verify': DEFAULT_VERIFY_TLS,
609609
'tls_low': DEFAULT_TLS_LOW,
610610
'auth_scheme': None,
611-
'session': None
611+
'session': None,
612+
'raw_fallback': False,
613+
'event_gzip_supported': None,
612614
}
613615

614616
# Batch mode controls
@@ -788,32 +790,102 @@ def _send_batch(lines: list, is_json: bool, product: str):
788790
POST = _CONNECTION_CACHE['session']
789791
headers_auth = {**HEADERS}
790792
headers_auth["Authorization"] = f"{_CONNECTION_CACHE['auth_scheme']} {HEC_TOKEN}"
791-
# Both endpoints use text/plain with gzip for batched events
793+
794+
def _raise_with_batch_diag(resp_obj, target_url: str):
795+
try:
796+
resp_obj.raise_for_status()
797+
except Exception:
798+
if _VERBOSITY in ('info', 'verbose', 'debug'):
799+
body_preview = (resp_obj.text or "")[:500]
800+
print(f"[BATCH] HTTP {resp_obj.status_code} from {target_url}", flush=True)
801+
if body_preview:
802+
print(f"[BATCH] Error body: {body_preview}", flush=True)
803+
sys.stdout.flush()
804+
raise
805+
806+
def _post_event_line_with_fallback(line_str: str, target_url: str):
807+
line_bytes = line_str.encode('utf-8')
808+
gzip_supported = _CONNECTION_CACHE.get('event_gzip_supported')
809+
810+
# Try gzip first unless we've already learned this endpoint rejects it.
811+
if gzip_supported is not False:
812+
gz_line = gzip.compress(line_bytes, compresslevel=1)
813+
gz_headers = {**headers_auth, "Content-Type": "application/json", "Content-Encoding": "gzip"}
814+
gz_resp = POST(target_url, headers=gz_headers, data=gz_line, timeout=30)
815+
try:
816+
gz_resp.raise_for_status()
817+
_CONNECTION_CACHE['event_gzip_supported'] = True
818+
return
819+
except Exception:
820+
# Fall back to plain JSON for content/parse/media-type failures.
821+
status = getattr(gz_resp, "status_code", None)
822+
if status not in (400, 415, 422):
823+
_raise_with_batch_diag(gz_resp, target_url)
824+
if _CONNECTION_CACHE.get('event_gzip_supported') is not False and _VERBOSITY in ('info', 'verbose', 'debug'):
825+
print("[BATCH] /event gzip rejected, falling back to plain JSON", flush=True)
826+
sys.stdout.flush()
827+
828+
plain_headers = {**headers_auth, "Content-Type": "application/json"}
829+
plain_resp = POST(target_url, headers=plain_headers, data=line_bytes, timeout=30)
830+
_raise_with_batch_diag(plain_resp, target_url)
831+
_CONNECTION_CACHE['event_gzip_supported'] = False
792832
body = "\n".join(lines).encode('utf-8')
793-
794-
# Use fast compression (level 1) for high throughput - trades compression ratio for speed
795-
# Level 1 is ~10x faster than default level 9, with only ~10% larger output
833+
834+
# Use fast compression (level 1) for high throughput on /raw batches.
835+
# For /event batches, send plain JSON to maximize compatibility with
836+
# pipeline front-ends that reject gzip-encoded /event payloads.
796837
gz = gzip.compress(body, compresslevel=1)
797-
headers = {**headers_auth, "Content-Type": "text/plain", "Content-Encoding": "gzip"}
838+
839+
if _VERBOSITY in ('info', 'verbose', 'debug'):
840+
print(f"[BATCH] Flushing {len(lines)} events ({len(body)} bytes raw)", flush=True)
841+
sys.stdout.flush()
798842

799843
if is_json:
800-
# JSON products to /event endpoint
844+
# JSON products to /event endpoint.
845+
# Send each queued event line individually (not NDJSON body).
846+
# Try gzip first; if rejected, auto-fallback to plain JSON.
801847
url = _CONNECTION_CACHE['event_base']
848+
for line in lines:
849+
_post_event_line_with_fallback(line, url)
850+
if _VERBOSITY == 'debug':
851+
print(f"[BATCH] Sent {len(lines)} /event items successfully", flush=True)
852+
sys.stdout.flush()
853+
return
854+
elif not _CONNECTION_CACHE['raw_fallback']:
855+
# Raw products: try /raw first
856+
raw_url = f"{_CONNECTION_CACHE['raw_base']}?{_build_qs(product)}"
857+
raw_headers = {**headers_auth, "Content-Type": "text/plain", "Content-Encoding": "gzip"}
858+
try:
859+
resp = POST(raw_url, headers=raw_headers, data=gz, timeout=30)
860+
resp.raise_for_status()
861+
if _VERBOSITY == 'debug':
862+
print(f"[BATCH] Response: {resp.status_code} - {resp.text[:200] if resp.text else 'OK'}", flush=True)
863+
sys.stdout.flush()
864+
return
865+
except Exception as raw_err:
866+
if _VERBOSITY in ('info', 'verbose', 'debug'):
867+
print(f"[BATCH] /raw failed ({type(raw_err).__name__}: {raw_err}), switching to /event with _raw wrapper", flush=True)
868+
sys.stdout.flush()
869+
_CONNECTION_CACHE['raw_fallback'] = True
870+
# Fall through to _raw JSON wrapper below
871+
event_lines = [json.dumps(_envelope({"_raw": l}, product, {}, None), separators=(",", ":")) for l in lines]
872+
url = _CONNECTION_CACHE['event_base']
873+
for line in event_lines:
874+
_post_event_line_with_fallback(line, url)
875+
if _VERBOSITY == 'debug':
876+
print(f"[BATCH] Sent {len(event_lines)} /event+_raw items successfully", flush=True)
877+
sys.stdout.flush()
878+
return
802879
else:
803-
# Raw/syslog products to /raw endpoint
804-
url = f"{_CONNECTION_CACHE['raw_base']}?{_build_qs(product)}"
805-
806-
# Show batch flush in info mode and above (not debug only)
807-
if _VERBOSITY in ('info', 'verbose', 'debug'):
808-
print(f"[BATCH] Flushing {len(lines)} events ({len(gz)} bytes compressed)", flush=True)
809-
sys.stdout.flush()
810-
811-
resp = POST(url, headers=headers, data=gz, timeout=30)
812-
resp.raise_for_status()
813-
814-
if _VERBOSITY == 'debug':
815-
print(f"[BATCH] Response: {resp.status_code} - {resp.text[:200] if resp.text else 'OK'}", flush=True)
816-
sys.stdout.flush()
880+
# raw_fallback already cached — go straight to /event with _raw wrapper
881+
event_lines = [json.dumps(_envelope({"_raw": l}, product, {}, None), separators=(",", ":")) for l in lines]
882+
url = _CONNECTION_CACHE['event_base']
883+
for line in event_lines:
884+
_post_event_line_with_fallback(line, url)
885+
if _VERBOSITY == 'debug':
886+
print(f"[BATCH] Sent {len(event_lines)} /event+_raw items successfully", flush=True)
887+
sys.stdout.flush()
888+
return
817889

818890
SOURCETYPE_MAP_OVERRIDES = {
819891
# ===== FIXED PARSER MAPPINGS (Based on actual parser directory names) =====
@@ -1196,15 +1268,16 @@ def send_one(line, product: str, attr_fields: dict, event_time: float | None = N
11961268
env_raw = base + "/raw"
11971269
bases = []
11981270
if env_event and env_raw:
1271+
# Explicit URL configured — use only that; don't fall back to other regions
1272+
# (tokens are region-specific and cross-region fallback would also fail)
11991273
bases.append((env_event, env_raw))
1200-
bases.extend([
1201-
("https://ingest.us1.sentinelone.net/services/collector/event",
1202-
"https://ingest.us1.sentinelone.net/services/collector/raw"),
1203-
("https://ingest.usea1.sentinelone.net/services/collector/event",
1204-
"https://ingest.usea1.sentinelone.net/services/collector/raw"),
1205-
("https://ingest.sentinelone.net/services/collector/event",
1206-
"https://ingest.sentinelone.net/services/collector/raw"),
1207-
])
1274+
else:
1275+
bases.extend([
1276+
("https://ingest.us1.sentinelone.net/services/collector/event",
1277+
"https://ingest.us1.sentinelone.net/services/collector/raw"),
1278+
("https://ingest.usea1.sentinelone.net/services/collector/event",
1279+
"https://ingest.usea1.sentinelone.net/services/collector/raw"),
1280+
])
12081281

12091282
# Try verification/TLS combinations (secure → low TLS → insecure as last resort)
12101283
combos = [
@@ -1251,6 +1324,12 @@ def send_one(line, product: str, attr_fields: dict, event_time: float | None = N
12511324
payload = _envelope(line, product, attr_fields, event_time)
12521325
headers = {**headers_auth, "Content-Type": "application/json"}
12531326
resp = POST(url, headers=headers, json=payload, timeout=10)
1327+
elif _CONNECTION_CACHE['raw_fallback']:
1328+
url = _CONNECTION_CACHE['event_base']
1329+
raw_str = line if isinstance(line, str) else json.dumps(line, separators=(",", ":"))
1330+
payload = _envelope({"_raw": raw_str}, product, attr_fields, event_time)
1331+
headers = {**headers_auth, "Content-Type": "application/json"}
1332+
resp = POST(url, headers=headers, json=payload, timeout=10)
12541333
else:
12551334
url = f"{_CONNECTION_CACHE['raw_base']}?{_build_qs(product)}"
12561335
payload = line
@@ -1323,6 +1402,43 @@ def send_one(line, product: str, attr_fields: dict, event_time: float | None = N
13231402
# On SSL/connection errors, continue to next combo/base
13241403
continue
13251404

1405+
# For raw products: retry all bases using /event with _raw JSON wrapper as fallback
1406+
if product not in JSON_PRODUCTS:
1407+
for event_base, raw_base in bases:
1408+
for verify, tls_low in combos:
1409+
POST = _make_poster(verify=verify, tls_low=tls_low)
1410+
for scheme in auth_schemes:
1411+
headers_auth = {**HEADERS}
1412+
headers_auth["Authorization"] = f"{scheme} {HEC_TOKEN}"
1413+
raw_str = line if isinstance(line, str) else json.dumps(line, separators=(",", ":"))
1414+
try:
1415+
url = event_base
1416+
payload = _envelope({"_raw": raw_str}, product, attr_fields, event_time)
1417+
headers = {**headers_auth, "Content-Type": "application/json"}
1418+
if DEBUG:
1419+
print(f"[DEBUG] Sending to {url} (_raw fallback)")
1420+
resp = POST(url, headers=headers, json=payload, timeout=10)
1421+
if resp.status_code in (401, 403) and scheme == auth_schemes[0]:
1422+
continue
1423+
resp.raise_for_status()
1424+
_CONNECTION_CACHE['configured'] = True
1425+
_CONNECTION_CACHE['event_base'] = event_base
1426+
_CONNECTION_CACHE['raw_base'] = raw_base
1427+
_CONNECTION_CACHE['verify'] = verify
1428+
_CONNECTION_CACHE['tls_low'] = tls_low
1429+
_CONNECTION_CACHE['auth_scheme'] = scheme
1430+
_CONNECTION_CACHE['session'] = POST
1431+
_CONNECTION_CACHE['raw_fallback'] = True
1432+
if _VERBOSITY in ('info', 'verbose', 'debug'):
1433+
print(f"[INFO] /raw not supported, using /event+_raw wrapper for {product}", flush=True)
1434+
try:
1435+
return resp.json()
1436+
except ValueError:
1437+
return {"status": "OK", "code": resp.status_code}
1438+
except Exception as e:
1439+
last_error = e
1440+
continue
1441+
13261442
# If all attempts failed, raise last error for visibility
13271443
if last_error:
13281444
raise last_error

0 commit comments

Comments
 (0)