diff --git a/Backend/api/app/models/destination.py b/Backend/api/app/models/destination.py index 89446ea..38070ae 100644 --- a/Backend/api/app/models/destination.py +++ b/Backend/api/app/models/destination.py @@ -24,6 +24,10 @@ class Destination(Base): config_write_token_encrypted = Column(Text, nullable=True) # For putFile API powerquery_read_token_encrypted = Column(Text, nullable=True) # For PowerQuery Log Read Access + # S1 Management API (for asset lookups, agent queries) + s1_management_url = Column(String, nullable=True) # e.g., https://demo.sentinelone.net + s1_api_token_encrypted = Column(Text, nullable=True) # Encrypted S1 API Token (for /web/api/v2.1/agents) + # UAM Alert Ingest (Service Account - separate from HEC) uam_ingest_url = Column(String, nullable=True) # e.g., https://ingest.us1.sentinelone.net uam_account_id = Column(String, nullable=True) # SentinelOne account ID @@ -69,6 +73,10 @@ def to_dict(self, include_token=False, encryption_service=None): result['has_config_write_token'] = bool(self.config_write_token_encrypted) result['has_powerquery_read_token'] = bool(self.powerquery_read_token_encrypted) + # S1 Management API + result['s1_management_url'] = self.s1_management_url + result['has_s1_api_token'] = bool(self.s1_api_token_encrypted) + # UAM Alert Ingest settings result['uam_ingest_url'] = self.uam_ingest_url result['uam_account_id'] = self.uam_account_id diff --git a/Backend/api/app/routers/destinations.py b/Backend/api/app/routers/destinations.py index 673b5b9..1ba552c 100644 --- a/Backend/api/app/routers/destinations.py +++ b/Backend/api/app/routers/destinations.py @@ -28,6 +28,10 @@ class DestinationCreate(BaseModel): config_write_token: Optional[str] = Field(None, description="Config API token for reading and writing parsers") powerquery_read_token: Optional[str] = Field(None, description="PowerQuery Log Read Access token for querying SIEM data") + # S1 Management API + s1_management_url: Optional[str] = Field(None, description="S1 Management API URL for asset lookups (e.g., https://demo.sentinelone.net)") + s1_api_token: Optional[str] = Field(None, description="S1 API Token for management API calls (Settings → Users → API Token)") + # UAM Alert Ingest (Service Account) uam_ingest_url: Optional[str] = Field(None, description="UAM ingest URL (e.g., https://ingest.us1.sentinelone.net)") uam_account_id: Optional[str] = Field(None, description="SentinelOne account ID for S1-Scope header") @@ -48,6 +52,8 @@ class DestinationUpdate(BaseModel): config_api_url: Optional[str] = None config_write_token: Optional[str] = None powerquery_read_token: Optional[str] = None + s1_management_url: Optional[str] = None + s1_api_token: Optional[str] = None uam_ingest_url: Optional[str] = None uam_account_id: Optional[str] = None uam_site_id: Optional[str] = None @@ -72,6 +78,8 @@ class DestinationResponse(BaseModel): config_api_url: Optional[str] = None # Config API URL for parser management has_config_write_token: Optional[bool] = None # True if config API token is set has_powerquery_read_token: Optional[bool] = None # True if PowerQuery read token is set + s1_management_url: Optional[str] = None # S1 Management API URL + has_s1_api_token: Optional[bool] = None # True if S1 API token is set uam_ingest_url: Optional[str] = None # UAM ingest URL uam_account_id: Optional[str] = None # SentinelOne account ID uam_site_id: Optional[str] = None # Optional site ID @@ -153,6 +161,8 @@ async def create_destination( config_api_url=destination.config_api_url, config_write_token=destination.config_write_token, powerquery_read_token=destination.powerquery_read_token, + s1_management_url=destination.s1_management_url, + s1_api_token=destination.s1_api_token, uam_ingest_url=destination.uam_ingest_url, uam_account_id=destination.uam_account_id, uam_site_id=destination.uam_site_id, @@ -290,6 +300,8 @@ async def update_destination( config_api_url=update.config_api_url, config_write_token=update.config_write_token, powerquery_read_token=update.powerquery_read_token, + s1_management_url=update.s1_management_url, + s1_api_token=update.s1_api_token, uam_ingest_url=update.uam_ingest_url, uam_account_id=update.uam_account_id, uam_site_id=update.uam_site_id, @@ -472,3 +484,55 @@ async def get_destination_uam_token( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to decrypt UAM token" ) + + +@router.get("/{dest_id}/s1-api-token") +async def get_destination_s1_api_token( + dest_id: str, + session: AsyncSession = Depends(get_session), + auth_info: tuple = Depends(get_api_key) +): + """ + Get decrypted S1 API Token for a destination (internal use only) + + Returns the decrypted S1 API token along with the management URL + for making agent/asset lookups via the S1 management API + """ + service = DestinationService(session) + destination = await service.get_destination(dest_id) + if not destination: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Destination '{dest_id}' not found" + ) + + if destination.type != 'hec': + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Only HEC destinations have S1 API tokens" + ) + + if not destination.s1_api_token_encrypted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No S1 API Token found for this destination" + ) + + if not destination.s1_management_url: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No S1 Management URL configured for this destination" + ) + + try: + token = service.decrypt_token(destination.s1_api_token_encrypted) + return { + "token": token, + "s1_management_url": destination.s1_management_url, + } + except Exception as e: + logger.error(f"Failed to decrypt S1 API token: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to decrypt S1 API token" + ) diff --git a/Backend/api/app/services/alert_service.py b/Backend/api/app/services/alert_service.py index d9e091b..1af69aa 100644 --- a/Backend/api/app/services/alert_service.py +++ b/Backend/api/app/services/alert_service.py @@ -194,6 +194,59 @@ def _replace_dynamic(self, obj: Any, time_ms: int) -> None: elif isinstance(item, (dict, list)): self._replace_dynamic(item, time_ms) + def lookup_xdr_asset_id( + self, + s1_management_url: str, + api_token: str, + asset_name: str, + account_id: str, + site_id: Optional[str] = None, + ) -> Optional[str]: + """Look up an XDR Asset ID from the S1 management API. + + The XDR Asset ID (e.g., 'eimvmdpvax6mtmbpdbxtoaem5q') is the value + needed for resources[].uid to link alerts to real S1 agent assets. + + Args: + s1_management_url: S1 management console URL (e.g., https://demo.sentinelone.net) + api_token: S1 API token + asset_name: Asset hostname to look up (e.g., 'bridge') + account_id: S1 account ID + site_id: Optional site ID to scope the search + + Returns: + The XDR Asset ID string, or None if not found + """ + try: + url = f"{s1_management_url.rstrip('/')}/web/api/v2.1/xdr/assets" + params = {"accountIds": account_id} + if site_id: + params["siteIds"] = site_id + headers = { + "Authorization": f"ApiToken {api_token}", + "Content-Type": "application/json", + } + response = requests.get(url, headers=headers, params=params, timeout=15) + response.raise_for_status() + data = response.json() + assets = data.get("data", []) + + # Find the real agent asset (has 'agent' field), matching by name + for asset in assets: + if asset.get("name", "").lower() == asset_name.lower() and asset.get("agent"): + asset_id = asset.get("id", "") + logger.info( + f"XDR asset found: name={asset.get('name')} " + f"asset_id={asset_id} category={asset.get('category')}" + ) + return asset_id + + logger.warning(f"No XDR agent asset found for name={asset_name} (checked {len(assets)} assets)") + return None + except requests.exceptions.RequestException as e: + logger.error(f"XDR asset lookup failed for {asset_name}: {e}") + return None + def build_scope(self, account_id: str, site_id: Optional[str] = None) -> str: """Build the S1-Scope header value""" if site_id: diff --git a/Backend/api/app/services/destination_service.py b/Backend/api/app/services/destination_service.py index e386fa5..5efe349 100644 --- a/Backend/api/app/services/destination_service.py +++ b/Backend/api/app/services/destination_service.py @@ -76,6 +76,8 @@ async def create_destination( config_api_url: Optional[str] = None, config_write_token: Optional[str] = None, powerquery_read_token: Optional[str] = None, + s1_management_url: Optional[str] = None, + s1_api_token: Optional[str] = None, uam_ingest_url: Optional[str] = None, uam_account_id: Optional[str] = None, uam_site_id: Optional[str] = None, @@ -95,6 +97,8 @@ async def create_destination( config_api_url: Config API URL for parser management (e.g., https://xdr.us1.sentinelone.net) config_write_token: Config API token for reading and writing parsers (will be encrypted) powerquery_read_token: PowerQuery Log Read Access token for querying SIEM data (will be encrypted) + s1_management_url: S1 Management API URL for asset lookups (e.g., https://demo.sentinelone.net) + s1_api_token: S1 API Token for management API calls (will be encrypted) ip: Syslog IP (for syslog destinations) port: Syslog port (for syslog destinations) protocol: 'UDP' or 'TCP' (for syslog destinations) @@ -137,6 +141,10 @@ async def create_destination( destination.config_write_token_encrypted = self.encryption.encrypt(config_write_token) if powerquery_read_token: destination.powerquery_read_token_encrypted = self.encryption.encrypt(powerquery_read_token) + if s1_management_url: + destination.s1_management_url = s1_management_url.rstrip('/') + if s1_api_token: + destination.s1_api_token_encrypted = self.encryption.encrypt(s1_api_token) if uam_ingest_url: destination.uam_ingest_url = uam_ingest_url.rstrip('/') if uam_account_id: @@ -185,6 +193,8 @@ async def update_destination( config_api_url: Optional[str] = None, config_write_token: Optional[str] = None, powerquery_read_token: Optional[str] = None, + s1_management_url: Optional[str] = None, + s1_api_token: Optional[str] = None, uam_ingest_url: Optional[str] = None, uam_account_id: Optional[str] = None, uam_site_id: Optional[str] = None, @@ -212,6 +222,10 @@ async def update_destination( destination.config_write_token_encrypted = self.encryption.encrypt(config_write_token) if powerquery_read_token: destination.powerquery_read_token_encrypted = self.encryption.encrypt(powerquery_read_token) + if s1_management_url is not None: + destination.s1_management_url = s1_management_url.rstrip('/') if s1_management_url else None + if s1_api_token: + destination.s1_api_token_encrypted = self.encryption.encrypt(s1_api_token) if uam_ingest_url: destination.uam_ingest_url = uam_ingest_url.rstrip('/') if uam_account_id: diff --git a/Backend/scenarios/apollo_ransomware_scenario.py b/Backend/scenarios/apollo_ransomware_scenario.py index 96f6c08..50d0964 100644 --- a/Backend/scenarios/apollo_ransomware_scenario.py +++ b/Backend/scenarios/apollo_ransomware_scenario.py @@ -301,11 +301,19 @@ def send_phase_alert( alert["metadata"]["logged_time"] = time_ms alert["metadata"]["modified_time"] = time_ms - # Set user as the resource (uid must be a UUID for site-scoped alerts) - alert["resources"] = [{ - "uid": str(uuid.uuid4()), - "name": VICTIM_PROFILE["email"] - }] + # Set resource - use XDR Asset ID if available for linking to real endpoint + xdr_asset_id = uam_config.get('xdr_asset_id') + if xdr_asset_id: + resource_name = uam_config.get('xdr_asset_name', VICTIM_PROFILE["email"]) + alert["resources"] = [{ + "uid": xdr_asset_id, + "name": resource_name + }] + else: + alert["resources"] = [{ + "uid": str(uuid.uuid4()), + "name": VICTIM_PROFILE["email"] + }] # Apply overrides overrides = mapping.get("overrides", {}) @@ -673,6 +681,47 @@ def generate_apollo_ransomware_scenario(siem_context: Optional[Dict] = None) -> 'uam_service_token': uam_service_token, 'uam_site_id': uam_site_id, } + + # Look up bridge XDR Asset ID for linking alerts to real endpoint + s1_mgmt_url = os.getenv('S1_MANAGEMENT_URL', '') + s1_api_token = os.getenv('S1_API_TOKEN', '') + if s1_mgmt_url and s1_api_token: + bridge_name = VICTIM_PROFILE['machine_bridge'] + print(f"\nšŸ” Looking up XDR asset '{bridge_name}' for alert linking...") + try: + import urllib.request + import urllib.parse + # Use XDR assets endpoint — returns the Asset ID needed for resource UID linking + params = {"accountIds": uam_account_id} + if uam_site_id: + params["siteIds"] = uam_site_id + lookup_url = f"{s1_mgmt_url.rstrip('/')}/web/api/v2.1/xdr/assets?{urllib.parse.urlencode(params)}" + req = urllib.request.Request(lookup_url, headers={ + "Authorization": f"ApiToken {s1_api_token}", + "Content-Type": "application/json", + }) + with urllib.request.urlopen(req, timeout=15) as resp: + assets_data = json.loads(resp.read().decode()) + assets = assets_data.get("data", []) + # Find the real agent asset (has 'agent' field), matching by name + for asset in assets: + if asset.get("name", "").lower() == bridge_name.lower() and asset.get("agent"): + asset_id = asset.get("id", "") + agent_name = asset.get("name", "") + agent_uuid = asset.get("agent", {}).get("uuid", "") + print(f" āœ“ XDR Asset found: {agent_name}") + print(f" Asset ID: {asset_id}") + print(f" Agent UUID: {agent_uuid}") + print(f" Category: {asset.get('category')}") + uam_config['xdr_asset_id'] = asset_id + uam_config['xdr_asset_name'] = agent_name + break + else: + print(f" ⚠ No XDR agent asset found for '{bridge_name}'") + print(f" Found {len(assets)} total assets") + except Exception as e: + print(f" ⚠ XDR asset lookup failed: {e}") + print("\n🚨 ALERT DETONATION ENABLED") print(f" UAM Ingest: {uam_ingest_url}") print(f" Account ID: {uam_account_id}") diff --git a/Backend/test_alert_site.py b/Backend/test_alert_site.py new file mode 100644 index 0000000..f6b367d --- /dev/null +++ b/Backend/test_alert_site.py @@ -0,0 +1,392 @@ +#!/usr/bin/env python3 +""" +Test script for sending UAM alerts to SentinelOne at site scope. +Used for experimenting with resource/asset fields and payload structure. + +Usage: + python3 test_alert_site.py --template api/app/alerts/templates/advanced_sample_alert.json + python3 test_alert_site.py --minimal + python3 test_alert_site.py --minimal --resource-name "jeanluc@starfleet.com" --resource-type "user" + python3 test_alert_site.py --minimal --resource-name "bridge-workstation" --resource-type "endpoint" +""" + +import argparse +import gzip +import json +import os +import sys +import uuid +import time +from datetime import datetime, timezone +from urllib.request import Request, urlopen +from urllib.error import HTTPError, URLError + + +def load_template(path: str) -> dict: + """Load a JSON alert template and inject dynamic fields.""" + with open(path) as f: + alert = json.load(f) + + now_ms = int(datetime.now(timezone.utc).timestamp() * 1000) + + # Inject fresh finding UID + if "finding_info" not in alert: + alert["finding_info"] = {} + alert["finding_info"]["uid"] = str(uuid.uuid4()) + + # Inject timestamps + alert["time"] = now_ms + if "metadata" not in alert: + alert["metadata"] = {} + alert["metadata"]["logged_time"] = now_ms + alert["metadata"]["modified_time"] = now_ms + + # Generate UIDs for related events + if "related_events" in alert.get("finding_info", {}): + for event in alert["finding_info"]["related_events"]: + event["uid"] = str(uuid.uuid4()) + + # Generate UIDs for resources with placeholder + for resource in alert.get("resources", []): + if resource.get("uid") == "DYNAMIC_RESOURCE_UID": + resource["uid"] = str(uuid.uuid4()) + + return alert + + +def build_minimal_alert( + title: str = "Test Alert", + desc: str = "Test alert from CLI script", + resource_name: str = "test-endpoint", + resource_uid: str = None, + resource_type: str = None, + severity: str = None, + severity_id: int = None, + product_name: str = "HELIOS Test", + vendor_name: str = "RoarinPenguin", + extra_resource_fields: dict = None, +) -> dict: + """Build a minimal OCSF alert payload for testing.""" + now_ms = int(datetime.now(timezone.utc).timestamp() * 1000) + + resource = { + "uid": resource_uid or str(uuid.uuid4()), + "name": resource_name, + } + if resource_type: + resource["type"] = resource_type + if extra_resource_fields: + resource.update(extra_resource_fields) + + alert = { + "finding_info": { + "uid": str(uuid.uuid4()), + "title": title, + "desc": desc, + }, + "resources": [resource], + "category_uid": 2, + "class_uid": 99602001, + "class_name": "S1 Security Alert", + "type_uid": 9960200101, + "type_name": "S1 Security Alert: Create", + "category_name": "Findings", + "activity_id": 1, + "metadata": { + "version": "1.1.0", + "extension": { + "name": "s1", + "uid": "998", + "version": "0.1.0", + }, + "product": { + "name": product_name, + "vendor_name": vendor_name, + }, + "logged_time": now_ms, + "modified_time": now_ms, + }, + "time": now_ms, + "attack_surface_ids": [1], + "severity_id": severity_id or 4, + "state_id": 1, + "s1_classification_id": 1, + } + + if severity: + alert["severity"] = severity + + return alert + + +def send_alert(alert: dict, ingest_url: str, token: str, account_id: str, site_id: str = None) -> dict: + """Send an alert via UAM ingest API. Returns response info.""" + url = ingest_url.rstrip("/") + "/v1/alerts" + scope = account_id + if site_id: + scope = f"{account_id}:{site_id}" + + payload = json.dumps(alert).encode("utf-8") + gzipped = gzip.compress(payload) + + headers = { + "Authorization": f"Bearer {token}", + "S1-Scope": scope, + "Content-Encoding": "gzip", + "Content-Type": "application/json", + "S1-Trace-Id": "helios-ingest-uam:alwayslog", + } + + req = Request(url, data=gzipped, headers=headers, method="POST") + try: + with urlopen(req) as resp: + body = resp.read().decode("utf-8") + return {"status": resp.status, "body": body} + except HTTPError as e: + body = e.read().decode("utf-8") + return {"status": e.code, "body": body, "error": str(e)} + except URLError as e: + return {"status": 0, "body": "", "error": str(e)} + + +def print_alert_summary(alert: dict, label: str = ""): + """Pretty-print alert summary.""" + if label: + print(f"\n{'='*60}") + print(f" {label}") + print(f"{'='*60}") + print(f" Title: {alert.get('finding_info', {}).get('title', 'N/A')}") + print(f" Desc: {alert.get('finding_info', {}).get('desc', 'N/A')[:80]}") + resources = alert.get("resources", []) + for i, r in enumerate(resources): + print(f" Resource[{i}]:") + for k, v in r.items(): + print(f" {k}: {v}") + print(f" Severity: {alert.get('severity', 'N/A')} (id={alert.get('severity_id', 'N/A')})") + ts = alert.get("time", 0) + print(f" Time: {ts} ({datetime.fromtimestamp(ts/1000, tz=timezone.utc).isoformat()})") + payload = json.dumps(alert).encode("utf-8") + gzipped = gzip.compress(payload) + print(f" Size: {len(payload)} bytes -> {len(gzipped)} bytes (gzip)") + print(f"\n Full JSON:\n{json.dumps(alert, indent=2)}") + + +def main(): + parser = argparse.ArgumentParser(description="Test UAM alert sending to SentinelOne") + parser.add_argument("--ingest-url", default=os.environ.get("UAM_INGEST_URL", "https://ingest.us1.sentinelone.net")) + parser.add_argument("--token", default=os.environ.get("UAM_TOKEN")) + parser.add_argument("--account-id", default=os.environ.get("UAM_ACCOUNT_ID", "1908275390083300395")) + parser.add_argument("--site-id", default=os.environ.get("UAM_SITE_ID", "2178041589156878742")) + parser.add_argument("--no-site", action="store_true", help="Send to account scope only") + + # Template mode + parser.add_argument("--template", help="Path to JSON alert template file") + + # Minimal mode + parser.add_argument("--minimal", action="store_true", help="Use minimal alert payload") + parser.add_argument("--title", default="Test Alert") + parser.add_argument("--desc", default="Test alert from CLI script") + parser.add_argument("--resource-name", default="test-endpoint") + parser.add_argument("--resource-uid", default=None, help="Resource UID (default: auto UUID)") + parser.add_argument("--resource-type", default=None, help="Resource type field") + parser.add_argument("--severity", default=None) + parser.add_argument("--severity-id", type=int, default=4) + parser.add_argument("--product", default="HELIOS Test") + parser.add_argument("--vendor", default="RoarinPenguin") + + # Batch experiment mode + parser.add_argument("--experiment", action="store_true", help="Run a batch of resource experiments") + parser.add_argument("--delay", type=float, default=3.0, help="Delay between sends (seconds)") + + # Output + parser.add_argument("--dry-run", action="store_true", help="Print payload but don't send") + parser.add_argument("--json-field", help="Add arbitrary JSON field as key=value (can repeat)", action="append", default=[]) + + args = parser.parse_args() + + if not args.token: + print("ERROR: No token. Set UAM_TOKEN env var or use --token") + sys.exit(1) + + site_id = None if args.no_site else args.site_id + + if args.experiment: + run_experiments(args, site_id) + return + + # Build alert + if args.template: + alert = load_template(args.template) + label = f"Template: {args.template}" + elif args.minimal: + alert = build_minimal_alert( + title=args.title, + desc=args.desc, + resource_name=args.resource_name, + resource_uid=args.resource_uid, + resource_type=args.resource_type, + severity=args.severity, + severity_id=args.severity_id, + product_name=args.product, + vendor_name=args.vendor, + ) + label = "Minimal Alert" + else: + print("ERROR: Specify --template or --minimal") + sys.exit(1) + + # Apply extra JSON fields + for field in args.json_field: + key, _, value = field.partition("=") + keys = key.split(".") + current = alert + for k in keys[:-1]: + if k not in current: + current[k] = {} + current = current[k] + # Try to parse as JSON, fall back to string + try: + current[keys[-1]] = json.loads(value) + except (json.JSONDecodeError, ValueError): + current[keys[-1]] = value + + print_alert_summary(alert, label) + + if args.dry_run: + print("\n [DRY RUN - not sending]") + return + + print(f"\n Sending to {'site' if site_id else 'account'} scope...") + result = send_alert(alert, args.ingest_url, args.token, args.account_id, site_id) + print(f" Response: {result['status']} {result['body']}") + if result.get("error"): + print(f" Error: {result['error']}") + + +def run_experiments(args, site_id): + """Run a batch of resource field experiments to see what lands in SDL.""" + # Real bridge agent identifiers from S1 console: + # Agent UUID: a0a693e2-f325-4a47-a80e-798f97bbd96d + # Agent Asset ID: eimvmdpvax6mtmbpdbxtoaem5q + # UAM Asset ID (created by alerts): ivhhhtkbinovgccxxjk53zwnje + # Serial: 02J2ZH-PBPTK27Z + # Domain: STARFLEET + # GW IP: 206.198.150.53 + AGENT_UUID = "a0a693e2-f325-4a47-a80e-798f97bbd96d" + AGENT_ASSET_ID = "eimvmdpvax6mtmbpdbxtoaem5q" + UAM_ASSET_ID = "ivhhhtkbinovgccxxjk53zwnje" + SERIAL = "02J2ZH-PBPTK27Z" + + experiments = [ + { + "label": "E1: Agent UUID as resource uid", + "resource_name": "bridge", + "resource_uid": AGENT_UUID, + "resource_type": None, + "extra": {}, + }, + { + "label": "E2: Agent Asset ID as resource uid", + "resource_name": "bridge", + "resource_uid": AGENT_ASSET_ID, + "resource_type": None, + "extra": {}, + }, + { + "label": "E3: UAM Asset ID as resource uid", + "resource_name": "bridge", + "resource_uid": UAM_ASSET_ID, + "resource_type": None, + "extra": {}, + }, + { + "label": "E4: Agent UUID + type=endpoint", + "resource_name": "bridge", + "resource_uid": AGENT_UUID, + "resource_type": "endpoint", + "extra": {}, + }, + { + "label": "E5: Agent UUID + serial_number", + "resource_name": "bridge", + "resource_uid": AGENT_UUID, + "resource_type": None, + "extra": {"serial_number": SERIAL}, + }, + { + "label": "E6: Agent UUID + domain + ip", + "resource_name": "bridge", + "resource_uid": AGENT_UUID, + "resource_type": None, + "extra": {"domain": "STARFLEET", "ip": "206.198.150.53"}, + }, + { + "label": "E7: Agent UUID + agent_id field", + "resource_name": "bridge", + "resource_uid": AGENT_UUID, + "resource_type": None, + "extra": {"agent_id": AGENT_UUID}, + }, + { + "label": "E8: UAM Asset ID + type=endpoint + name=bridge", + "resource_name": "bridge", + "resource_uid": UAM_ASSET_ID, + "resource_type": "endpoint", + "extra": {}, + }, + { + "label": "E9: Random UUID + agent_id=Agent UUID", + "resource_name": "bridge", + "resource_uid": None, + "resource_type": None, + "extra": {"agent_id": AGENT_UUID}, + }, + { + "label": "E10: Random UUID + ext.s1.agent_id", + "resource_name": "bridge", + "resource_uid": None, + "resource_type": None, + "extra": {"ext": {"s1": {"agent_id": AGENT_UUID}}}, + }, + ] + + print(f"\n{'='*60}") + print(f" RESOURCE FIELD EXPERIMENTS ({len(experiments)} tests)") + print(f" Scope: {'site ' + site_id if site_id else 'account'}") + print(f" Delay: {args.delay}s between sends") + print(f"{'='*60}") + + for i, exp in enumerate(experiments): + alert = build_minimal_alert( + title=f"Asset Test - {exp['label']}", + desc=f"Testing resource fields: {exp['label']}", + resource_name=exp["resource_name"], + resource_uid=exp["resource_uid"], + resource_type=exp.get("resource_type"), + severity_id=4, + extra_resource_fields=exp.get("extra", {}), + ) + + print(f"\n--- {exp['label']} ---") + print(f" Resources: {json.dumps(alert['resources'], indent=4)}") + + if args.dry_run: + print(" [DRY RUN]") + else: + result = send_alert(alert, args.ingest_url, args.token, args.account_id, site_id) + print(f" Response: {result['status']} {result['body']}") + if result.get("error"): + print(f" Error: {result['error']}") + + if i < len(experiments) - 1 and not args.dry_run: + print(f" Waiting {args.delay}s...") + time.sleep(args.delay) + + print(f"\n{'='*60}") + print(f" Done! Check SDL for {len(experiments)} alerts.") + print(f" Search: tag = 'alert' in the last 15 minutes") + print(f"{'='*60}") + + +if __name__ == "__main__": + main() diff --git a/Frontend/log_generator_ui.py b/Frontend/log_generator_ui.py index ffde8e1..04b1645 100644 --- a/Frontend/log_generator_ui.py +++ b/Frontend/log_generator_ui.py @@ -248,6 +248,10 @@ def update_destination(dest_id): payload['config_write_token'] = data['config_write_token'] if data.get('powerquery_read_token'): payload['powerquery_read_token'] = data['powerquery_read_token'] + if 's1_management_url' in data: + payload['s1_management_url'] = data['s1_management_url'] + if data.get('s1_api_token'): + payload['s1_api_token'] = data['s1_api_token'] if data.get('uam_ingest_url'): payload['uam_ingest_url'] = data['uam_ingest_url'] if data.get('uam_account_id'): @@ -532,6 +536,69 @@ def execute_correlation_query(): return jsonify({'error': str(e), 'results': []}), 500 +@app.route('/xdr/assets', methods=['POST']) +def get_xdr_assets(): + """Fetch XDR assets from S1 management 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 API token 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. Go to Settings → Edit Destination.'}), 400 + s1_data = s1_resp.json() + s1_token = s1_data.get('token', '') + s1_mgmt_url = s1_data.get('s1_management_url', '') + + if not s1_token or not s1_mgmt_url: + return jsonify({'error': 'S1 Management URL or API Token missing'}), 400 + + # Get UAM account/site IDs from destination for scoping + 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() + + params = {} + if dest.get('uam_account_id'): + params['accountIds'] = dest['uam_account_id'] + if dest.get('uam_site_id'): + params['siteIds'] = dest['uam_site_id'] + + # Call XDR assets API + xdr_url = f"{s1_mgmt_url.rstrip('/')}/web/api/v2.1/xdr/assets" + xdr_resp = requests.get( + xdr_url, + headers={ + "Authorization": f"ApiToken {s1_token}", + "Content-Type": "application/json", + }, + params=params, + timeout=20 + ) + xdr_resp.raise_for_status() + assets_data = xdr_resp.json() + + return jsonify(assets_data) + except requests.exceptions.RequestException as e: + logger.error(f"XDR assets API call failed: {e}") + return jsonify({'error': f'XDR assets API call failed: {str(e)}'}), 500 + except Exception as e: + logger.error(f"Failed to fetch XDR assets: {e}") + return jsonify({'error': str(e)}), 500 + + @app.route('/correlation-scenarios/run', methods=['POST']) def run_correlation_scenario(): """Execute a correlation scenario with SIEM context""" @@ -732,6 +799,22 @@ def generate_and_stream(): env['UAM_SERVICE_TOKEN'] = uam_service_token if uam_site_id: env['UAM_SITE_ID'] = uam_site_id + # Resolve S1 API token for asset lookups + try: + 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: + s1_data = s1_resp.json() + env['S1_MANAGEMENT_URL'] = s1_data.get('s1_management_url', '') + env['S1_API_TOKEN'] = s1_data.get('token', '') + yield "INFO: šŸ”— S1 asset linking enabled (API token found)\n" + else: + yield "INFO: āš ļø S1 asset linking disabled (no S1 API token on destination)\n" + except Exception as s1e: + logger.warning(f"Could not resolve S1 API token: {s1e}") yield "INFO: 🚨 Alert detonation enabled (UAM credentials found)\n" else: yield "INFO: āš ļø Alert detonation disabled (no UAM credentials on destination)\n" @@ -1130,6 +1213,22 @@ def generate_and_stream(): env['UAM_SERVICE_TOKEN'] = uam_service_token if uam_site_id: env['UAM_SITE_ID'] = uam_site_id + # Resolve S1 API token for asset lookups + try: + 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: + s1_data = s1_resp.json() + env['S1_MANAGEMENT_URL'] = s1_data.get('s1_management_url', '') + env['S1_API_TOKEN'] = s1_data.get('token', '') + yield "INFO: šŸ”— S1 asset linking enabled (API token found)\n" + else: + yield "INFO: āš ļø S1 asset linking disabled (no S1 API token on destination)\n" + except Exception as s1e: + logger.warning(f"Could not resolve S1 API token: {s1e}") yield "INFO: 🚨 Alert detonation enabled (UAM credentials found)\n" else: yield "INFO: āš ļø Alert detonation disabled (no UAM credentials on destination)\n" diff --git a/Frontend/templates/log_generator.html b/Frontend/templates/log_generator.html index 0afa7ea..1edb190 100644 --- a/Frontend/templates/log_generator.html +++ b/Frontend/templates/log_generator.html @@ -733,12 +733,26 @@

šŸ” Run Query + + + +

Configure a Service Account token and scope for sending UAM alerts. This is separate from HEC.

+
+ + +

Management console URL — used for asset lookups to link alerts to endpoints

+
+
+ +
+ + +
+

For agent/asset lookups — generate from S1 Console → Settings → Users → API Token

+
@@ -1224,6 +1251,19 @@

Parser Sync & PowerQuery C

UAM Alert Configuration

+
+ + +

Management console URL — used for asset lookups to link alerts to endpoints

+
+
+ +
+ + +
+

For agent/asset lookups — generate from S1 Console → Settings → Users → API Token

+
@@ -1540,6 +1580,7 @@

Select Parser Version

document.getElementById('edit-dest-token').value = ''; document.getElementById('edit-config-api-url').value = d.config_api_url || 'https://xdr.us1.sentinelone.net'; document.getElementById('edit-config-write-token').value = ''; + document.getElementById('edit-s1-management-url').value = d.s1_management_url || ''; document.getElementById('edit-uam-ingest-url').value = d.uam_ingest_url || 'https://ingest.us1.sentinelone.net'; document.getElementById('edit-uam-account-id').value = d.uam_account_id || ''; document.getElementById('edit-uam-site-id').value = d.uam_site_id || ''; @@ -1685,7 +1726,11 @@

Select Parser Version

if (configWriteToken) payload.config_write_token = configWriteToken; if (powerqueryReadToken) payload.powerquery_read_token = powerqueryReadToken; - // Add UAM alert settings (optional) + // Add S1 Management URL, API Token, and UAM alert settings (optional) + const s1ManagementUrl = document.getElementById('dest-s1-management-url')?.value?.trim(); + if (s1ManagementUrl) payload.s1_management_url = s1ManagementUrl; + const s1ApiToken = document.getElementById('dest-s1-api-token')?.value?.trim(); + if (s1ApiToken) payload.s1_api_token = s1ApiToken; const uamIngestUrl = document.getElementById('dest-uam-ingest-url')?.value?.trim(); const uamAccountId = document.getElementById('dest-uam-account-id')?.value?.trim(); const uamSiteId = document.getElementById('dest-uam-site-id')?.value?.trim(); @@ -2093,6 +2138,8 @@

Select Parser Version

const configApiUrl = document.getElementById('edit-config-api-url').value.trim(); const configWriteToken = document.getElementById('edit-config-write-token').value.trim(); const powerqueryReadToken = document.getElementById('edit-powerquery-read-token').value.trim(); + const s1ManagementUrl = document.getElementById('edit-s1-management-url').value.trim(); + const s1ApiToken = document.getElementById('edit-s1-api-token').value.trim(); const uamIngestUrl = document.getElementById('edit-uam-ingest-url').value.trim(); const uamAccountId = document.getElementById('edit-uam-account-id').value.trim(); const uamSiteId = document.getElementById('edit-uam-site-id').value.trim(); @@ -2106,6 +2153,8 @@

Select Parser Version

if (configApiUrl) payload.config_api_url = configApiUrl; if (configWriteToken) payload.config_write_token = configWriteToken; if (powerqueryReadToken) payload.powerquery_read_token = powerqueryReadToken; + payload.s1_management_url = s1ManagementUrl; // Allow clearing with empty string + if (s1ApiToken) payload.s1_api_token = s1ApiToken; if (uamIngestUrl) payload.uam_ingest_url = uamIngestUrl; if (uamAccountId) payload.uam_account_id = uamAccountId; payload.uam_site_id = uamSiteId; // Allow clearing with empty string @@ -2885,6 +2934,10 @@

Select Parser Version

// Config API URL and PowerQuery token are now resolved from the selected destination const genCorrelationTraceIdBtn = document.getElementById('gen-correlation-trace-id'); const correlationTraceIdInput = document.getElementById('correlation-trace-id'); + const getXdrAssetsBtn = document.getElementById('get-xdr-assets-btn'); + const xdrAssetsPanel = document.getElementById('xdr-assets-panel'); + const xdrAssetsList = document.getElementById('xdr-assets-list'); + const xdrAssetsCount = document.getElementById('xdr-assets-count'); let correlationScenarios = []; let currentCorrelationScenario = null; @@ -2941,15 +2994,19 @@

Select Parser Version

runCorrelationQueryBtn.disabled = false; runCorrelationFallbackBtn.disabled = false; runCorrelationScenarioBtn.disabled = true; // Enabled after query + if (getXdrAssetsBtn) getXdrAssetsBtn.disabled = false; correlationAnchorsPanel.style.display = 'none'; correlationAnchorsList.innerHTML = ''; + if (xdrAssetsPanel) xdrAssetsPanel.style.display = 'none'; } else { correlationScenarioDetails.style.display = 'none'; correlationQuery.value = ''; runCorrelationQueryBtn.disabled = true; runCorrelationFallbackBtn.disabled = true; runCorrelationScenarioBtn.disabled = true; + if (getXdrAssetsBtn) getXdrAssetsBtn.disabled = true; + if (xdrAssetsPanel) xdrAssetsPanel.style.display = 'none'; } }); @@ -2960,6 +3017,91 @@

Select Parser Version

} }); + // Get XDR Assets + if (getXdrAssetsBtn) { + getXdrAssetsBtn.addEventListener('click', async () => { + const destinationId = destSelect.value; + if (!destinationId) { + alert('Please select a destination'); + return; + } + + getXdrAssetsBtn.disabled = true; + getXdrAssetsBtn.textContent = 'šŸ”„ Loading...'; + correlationOutputBox.innerText = 'Fetching XDR assets from S1 management API...\n'; + + try { + const res = await fetch('/xdr/assets', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ destination_id: destinationId }) + }); + + const data = await res.json(); + + if (data.error) { + correlationOutputBox.innerText += `\nāŒ ${data.error}\n`; + if (xdrAssetsPanel) xdrAssetsPanel.style.display = 'none'; + return; + } + + const assets = data.data || []; + correlationOutputBox.innerText += `āœ“ Found ${assets.length} assets\n`; + + if (xdrAssetsPanel && xdrAssetsList && xdrAssetsCount) { + xdrAssetsPanel.style.display = ''; + xdrAssetsCount.textContent = `${assets.length} assets`; + xdrAssetsList.innerHTML = ''; + + // Separate agent assets from device-only assets + const agentAssets = assets.filter(a => a.agent); + const deviceAssets = assets.filter(a => !a.agent); + + agentAssets.forEach(asset => { + const div = document.createElement('div'); + div.className = 'flex items-center gap-2 p-2 bg-[#1a1629] rounded border border-[#3c325c]'; + const ip = asset.agent?.lastReportedIp || ''; + const version = asset.agent?.agentVersion || ''; + const status = asset.agent?.networkStatus === 'connected' ? '🟢' : 'šŸ”“'; + div.innerHTML = ` + ${status} + ${asset.name || '?'} + | + ${asset.category || ''} + | + ${asset.id} + ${ip ? `| ${ip}` : ''} + ${version ? `v${version}` : ''} + `; + xdrAssetsList.appendChild(div); + }); + + deviceAssets.forEach(asset => { + const div = document.createElement('div'); + div.className = 'flex items-center gap-2 p-2 bg-[#1a1629] rounded border border-[#2a2040] opacity-60'; + div.innerHTML = ` + šŸ“± + ${asset.name || '?'} + | + ${asset.resourceType || asset.category || 'Device'} + | + ${asset.id} + `; + xdrAssetsList.appendChild(div); + }); + + correlationOutputBox.innerText += ` ${agentAssets.length} agent assets (real endpoints)\n`; + correlationOutputBox.innerText += ` ${deviceAssets.length} device assets (created by alerts)\n`; + } + } catch (err) { + correlationOutputBox.innerText += `\nāŒ Error: ${err.message}\n`; + } finally { + getXdrAssetsBtn.disabled = false; + getXdrAssetsBtn.textContent = 'šŸ–„ļø Get Assets'; + } + }); + } + // Generate trace ID if (genCorrelationTraceIdBtn && correlationTraceIdInput) { genCorrelationTraceIdBtn.addEventListener('click', () => {