Skip to content

Commit d066ec8

Browse files
authored
Merge pull request #1 from jmorascalyr/alert_fix_the_second
Adding asset id discovery service for correlated alerts
2 parents a6ff0af + 9f8d442 commit d066ec8

8 files changed

Lines changed: 827 additions & 6 deletions

File tree

Backend/api/app/models/destination.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ class Destination(Base):
2424
config_write_token_encrypted = Column(Text, nullable=True) # For putFile API
2525
powerquery_read_token_encrypted = Column(Text, nullable=True) # For PowerQuery Log Read Access
2626

27+
# S1 Management API (for asset lookups, agent queries)
28+
s1_management_url = Column(String, nullable=True) # e.g., https://demo.sentinelone.net
29+
s1_api_token_encrypted = Column(Text, nullable=True) # Encrypted S1 API Token (for /web/api/v2.1/agents)
30+
2731
# UAM Alert Ingest (Service Account - separate from HEC)
2832
uam_ingest_url = Column(String, nullable=True) # e.g., https://ingest.us1.sentinelone.net
2933
uam_account_id = Column(String, nullable=True) # SentinelOne account ID
@@ -69,6 +73,10 @@ def to_dict(self, include_token=False, encryption_service=None):
6973
result['has_config_write_token'] = bool(self.config_write_token_encrypted)
7074
result['has_powerquery_read_token'] = bool(self.powerquery_read_token_encrypted)
7175

76+
# S1 Management API
77+
result['s1_management_url'] = self.s1_management_url
78+
result['has_s1_api_token'] = bool(self.s1_api_token_encrypted)
79+
7280
# UAM Alert Ingest settings
7381
result['uam_ingest_url'] = self.uam_ingest_url
7482
result['uam_account_id'] = self.uam_account_id

Backend/api/app/routers/destinations.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ class DestinationCreate(BaseModel):
2828
config_write_token: Optional[str] = Field(None, description="Config API token for reading and writing parsers")
2929
powerquery_read_token: Optional[str] = Field(None, description="PowerQuery Log Read Access token for querying SIEM data")
3030

31+
# S1 Management API
32+
s1_management_url: Optional[str] = Field(None, description="S1 Management API URL for asset lookups (e.g., https://demo.sentinelone.net)")
33+
s1_api_token: Optional[str] = Field(None, description="S1 API Token for management API calls (Settings → Users → API Token)")
34+
3135
# UAM Alert Ingest (Service Account)
3236
uam_ingest_url: Optional[str] = Field(None, description="UAM ingest URL (e.g., https://ingest.us1.sentinelone.net)")
3337
uam_account_id: Optional[str] = Field(None, description="SentinelOne account ID for S1-Scope header")
@@ -48,6 +52,8 @@ class DestinationUpdate(BaseModel):
4852
config_api_url: Optional[str] = None
4953
config_write_token: Optional[str] = None
5054
powerquery_read_token: Optional[str] = None
55+
s1_management_url: Optional[str] = None
56+
s1_api_token: Optional[str] = None
5157
uam_ingest_url: Optional[str] = None
5258
uam_account_id: Optional[str] = None
5359
uam_site_id: Optional[str] = None
@@ -72,6 +78,8 @@ class DestinationResponse(BaseModel):
7278
config_api_url: Optional[str] = None # Config API URL for parser management
7379
has_config_write_token: Optional[bool] = None # True if config API token is set
7480
has_powerquery_read_token: Optional[bool] = None # True if PowerQuery read token is set
81+
s1_management_url: Optional[str] = None # S1 Management API URL
82+
has_s1_api_token: Optional[bool] = None # True if S1 API token is set
7583
uam_ingest_url: Optional[str] = None # UAM ingest URL
7684
uam_account_id: Optional[str] = None # SentinelOne account ID
7785
uam_site_id: Optional[str] = None # Optional site ID
@@ -153,6 +161,8 @@ async def create_destination(
153161
config_api_url=destination.config_api_url,
154162
config_write_token=destination.config_write_token,
155163
powerquery_read_token=destination.powerquery_read_token,
164+
s1_management_url=destination.s1_management_url,
165+
s1_api_token=destination.s1_api_token,
156166
uam_ingest_url=destination.uam_ingest_url,
157167
uam_account_id=destination.uam_account_id,
158168
uam_site_id=destination.uam_site_id,
@@ -290,6 +300,8 @@ async def update_destination(
290300
config_api_url=update.config_api_url,
291301
config_write_token=update.config_write_token,
292302
powerquery_read_token=update.powerquery_read_token,
303+
s1_management_url=update.s1_management_url,
304+
s1_api_token=update.s1_api_token,
293305
uam_ingest_url=update.uam_ingest_url,
294306
uam_account_id=update.uam_account_id,
295307
uam_site_id=update.uam_site_id,
@@ -472,3 +484,55 @@ async def get_destination_uam_token(
472484
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
473485
detail="Failed to decrypt UAM token"
474486
)
487+
488+
489+
@router.get("/{dest_id}/s1-api-token")
490+
async def get_destination_s1_api_token(
491+
dest_id: str,
492+
session: AsyncSession = Depends(get_session),
493+
auth_info: tuple = Depends(get_api_key)
494+
):
495+
"""
496+
Get decrypted S1 API Token for a destination (internal use only)
497+
498+
Returns the decrypted S1 API token along with the management URL
499+
for making agent/asset lookups via the S1 management API
500+
"""
501+
service = DestinationService(session)
502+
destination = await service.get_destination(dest_id)
503+
if not destination:
504+
raise HTTPException(
505+
status_code=status.HTTP_404_NOT_FOUND,
506+
detail=f"Destination '{dest_id}' not found"
507+
)
508+
509+
if destination.type != 'hec':
510+
raise HTTPException(
511+
status_code=status.HTTP_400_BAD_REQUEST,
512+
detail="Only HEC destinations have S1 API tokens"
513+
)
514+
515+
if not destination.s1_api_token_encrypted:
516+
raise HTTPException(
517+
status_code=status.HTTP_404_NOT_FOUND,
518+
detail="No S1 API Token found for this destination"
519+
)
520+
521+
if not destination.s1_management_url:
522+
raise HTTPException(
523+
status_code=status.HTTP_400_BAD_REQUEST,
524+
detail="No S1 Management URL configured for this destination"
525+
)
526+
527+
try:
528+
token = service.decrypt_token(destination.s1_api_token_encrypted)
529+
return {
530+
"token": token,
531+
"s1_management_url": destination.s1_management_url,
532+
}
533+
except Exception as e:
534+
logger.error(f"Failed to decrypt S1 API token: {e}")
535+
raise HTTPException(
536+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
537+
detail="Failed to decrypt S1 API token"
538+
)

Backend/api/app/services/alert_service.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,59 @@ def _replace_dynamic(self, obj: Any, time_ms: int) -> None:
194194
elif isinstance(item, (dict, list)):
195195
self._replace_dynamic(item, time_ms)
196196

197+
def lookup_xdr_asset_id(
198+
self,
199+
s1_management_url: str,
200+
api_token: str,
201+
asset_name: str,
202+
account_id: str,
203+
site_id: Optional[str] = None,
204+
) -> Optional[str]:
205+
"""Look up an XDR Asset ID from the S1 management API.
206+
207+
The XDR Asset ID (e.g., 'eimvmdpvax6mtmbpdbxtoaem5q') is the value
208+
needed for resources[].uid to link alerts to real S1 agent assets.
209+
210+
Args:
211+
s1_management_url: S1 management console URL (e.g., https://demo.sentinelone.net)
212+
api_token: S1 API token
213+
asset_name: Asset hostname to look up (e.g., 'bridge')
214+
account_id: S1 account ID
215+
site_id: Optional site ID to scope the search
216+
217+
Returns:
218+
The XDR Asset ID string, or None if not found
219+
"""
220+
try:
221+
url = f"{s1_management_url.rstrip('/')}/web/api/v2.1/xdr/assets"
222+
params = {"accountIds": account_id}
223+
if site_id:
224+
params["siteIds"] = site_id
225+
headers = {
226+
"Authorization": f"ApiToken {api_token}",
227+
"Content-Type": "application/json",
228+
}
229+
response = requests.get(url, headers=headers, params=params, timeout=15)
230+
response.raise_for_status()
231+
data = response.json()
232+
assets = data.get("data", [])
233+
234+
# Find the real agent asset (has 'agent' field), matching by name
235+
for asset in assets:
236+
if asset.get("name", "").lower() == asset_name.lower() and asset.get("agent"):
237+
asset_id = asset.get("id", "")
238+
logger.info(
239+
f"XDR asset found: name={asset.get('name')} "
240+
f"asset_id={asset_id} category={asset.get('category')}"
241+
)
242+
return asset_id
243+
244+
logger.warning(f"No XDR agent asset found for name={asset_name} (checked {len(assets)} assets)")
245+
return None
246+
except requests.exceptions.RequestException as e:
247+
logger.error(f"XDR asset lookup failed for {asset_name}: {e}")
248+
return None
249+
197250
def build_scope(self, account_id: str, site_id: Optional[str] = None) -> str:
198251
"""Build the S1-Scope header value"""
199252
if site_id:

Backend/api/app/services/destination_service.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ async def create_destination(
7676
config_api_url: Optional[str] = None,
7777
config_write_token: Optional[str] = None,
7878
powerquery_read_token: Optional[str] = None,
79+
s1_management_url: Optional[str] = None,
80+
s1_api_token: Optional[str] = None,
7981
uam_ingest_url: Optional[str] = None,
8082
uam_account_id: Optional[str] = None,
8183
uam_site_id: Optional[str] = None,
@@ -95,6 +97,8 @@ async def create_destination(
9597
config_api_url: Config API URL for parser management (e.g., https://xdr.us1.sentinelone.net)
9698
config_write_token: Config API token for reading and writing parsers (will be encrypted)
9799
powerquery_read_token: PowerQuery Log Read Access token for querying SIEM data (will be encrypted)
100+
s1_management_url: S1 Management API URL for asset lookups (e.g., https://demo.sentinelone.net)
101+
s1_api_token: S1 API Token for management API calls (will be encrypted)
98102
ip: Syslog IP (for syslog destinations)
99103
port: Syslog port (for syslog destinations)
100104
protocol: 'UDP' or 'TCP' (for syslog destinations)
@@ -137,6 +141,10 @@ async def create_destination(
137141
destination.config_write_token_encrypted = self.encryption.encrypt(config_write_token)
138142
if powerquery_read_token:
139143
destination.powerquery_read_token_encrypted = self.encryption.encrypt(powerquery_read_token)
144+
if s1_management_url:
145+
destination.s1_management_url = s1_management_url.rstrip('/')
146+
if s1_api_token:
147+
destination.s1_api_token_encrypted = self.encryption.encrypt(s1_api_token)
140148
if uam_ingest_url:
141149
destination.uam_ingest_url = uam_ingest_url.rstrip('/')
142150
if uam_account_id:
@@ -185,6 +193,8 @@ async def update_destination(
185193
config_api_url: Optional[str] = None,
186194
config_write_token: Optional[str] = None,
187195
powerquery_read_token: Optional[str] = None,
196+
s1_management_url: Optional[str] = None,
197+
s1_api_token: Optional[str] = None,
188198
uam_ingest_url: Optional[str] = None,
189199
uam_account_id: Optional[str] = None,
190200
uam_site_id: Optional[str] = None,
@@ -212,6 +222,10 @@ async def update_destination(
212222
destination.config_write_token_encrypted = self.encryption.encrypt(config_write_token)
213223
if powerquery_read_token:
214224
destination.powerquery_read_token_encrypted = self.encryption.encrypt(powerquery_read_token)
225+
if s1_management_url is not None:
226+
destination.s1_management_url = s1_management_url.rstrip('/') if s1_management_url else None
227+
if s1_api_token:
228+
destination.s1_api_token_encrypted = self.encryption.encrypt(s1_api_token)
215229
if uam_ingest_url:
216230
destination.uam_ingest_url = uam_ingest_url.rstrip('/')
217231
if uam_account_id:

Backend/scenarios/apollo_ransomware_scenario.py

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -301,11 +301,19 @@ def send_phase_alert(
301301
alert["metadata"]["logged_time"] = time_ms
302302
alert["metadata"]["modified_time"] = time_ms
303303

304-
# Set user as the resource (uid must be a UUID for site-scoped alerts)
305-
alert["resources"] = [{
306-
"uid": str(uuid.uuid4()),
307-
"name": VICTIM_PROFILE["email"]
308-
}]
304+
# Set resource - use XDR Asset ID if available for linking to real endpoint
305+
xdr_asset_id = uam_config.get('xdr_asset_id')
306+
if xdr_asset_id:
307+
resource_name = uam_config.get('xdr_asset_name', VICTIM_PROFILE["email"])
308+
alert["resources"] = [{
309+
"uid": xdr_asset_id,
310+
"name": resource_name
311+
}]
312+
else:
313+
alert["resources"] = [{
314+
"uid": str(uuid.uuid4()),
315+
"name": VICTIM_PROFILE["email"]
316+
}]
309317

310318
# Apply overrides
311319
overrides = mapping.get("overrides", {})
@@ -673,6 +681,47 @@ def generate_apollo_ransomware_scenario(siem_context: Optional[Dict] = None) ->
673681
'uam_service_token': uam_service_token,
674682
'uam_site_id': uam_site_id,
675683
}
684+
685+
# Look up bridge XDR Asset ID for linking alerts to real endpoint
686+
s1_mgmt_url = os.getenv('S1_MANAGEMENT_URL', '')
687+
s1_api_token = os.getenv('S1_API_TOKEN', '')
688+
if s1_mgmt_url and s1_api_token:
689+
bridge_name = VICTIM_PROFILE['machine_bridge']
690+
print(f"\n🔍 Looking up XDR asset '{bridge_name}' for alert linking...")
691+
try:
692+
import urllib.request
693+
import urllib.parse
694+
# Use XDR assets endpoint — returns the Asset ID needed for resource UID linking
695+
params = {"accountIds": uam_account_id}
696+
if uam_site_id:
697+
params["siteIds"] = uam_site_id
698+
lookup_url = f"{s1_mgmt_url.rstrip('/')}/web/api/v2.1/xdr/assets?{urllib.parse.urlencode(params)}"
699+
req = urllib.request.Request(lookup_url, headers={
700+
"Authorization": f"ApiToken {s1_api_token}",
701+
"Content-Type": "application/json",
702+
})
703+
with urllib.request.urlopen(req, timeout=15) as resp:
704+
assets_data = json.loads(resp.read().decode())
705+
assets = assets_data.get("data", [])
706+
# Find the real agent asset (has 'agent' field), matching by name
707+
for asset in assets:
708+
if asset.get("name", "").lower() == bridge_name.lower() and asset.get("agent"):
709+
asset_id = asset.get("id", "")
710+
agent_name = asset.get("name", "")
711+
agent_uuid = asset.get("agent", {}).get("uuid", "")
712+
print(f" ✓ XDR Asset found: {agent_name}")
713+
print(f" Asset ID: {asset_id}")
714+
print(f" Agent UUID: {agent_uuid}")
715+
print(f" Category: {asset.get('category')}")
716+
uam_config['xdr_asset_id'] = asset_id
717+
uam_config['xdr_asset_name'] = agent_name
718+
break
719+
else:
720+
print(f" ⚠ No XDR agent asset found for '{bridge_name}'")
721+
print(f" Found {len(assets)} total assets")
722+
except Exception as e:
723+
print(f" ⚠ XDR asset lookup failed: {e}")
724+
676725
print("\n🚨 ALERT DETONATION ENABLED")
677726
print(f" UAM Ingest: {uam_ingest_url}")
678727
print(f" Account ID: {uam_account_id}")

0 commit comments

Comments
 (0)