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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Backend/api/app/models/destination.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
64 changes: 64 additions & 0 deletions Backend/api/app/routers/destinations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"
)
53 changes: 53 additions & 0 deletions Backend/api/app/services/alert_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
14 changes: 14 additions & 0 deletions Backend/api/app/services/destination_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
59 changes: 54 additions & 5 deletions Backend/scenarios/apollo_ransomware_scenario.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", {})
Expand Down Expand Up @@ -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}")
Expand Down
Loading
Loading