So you’ve finally convinced the org to standardize on Cortex XDR, and now you’re staring at 2,000+ assets with no meaningful tags, wondering how you’ll automate policy scopes by department. The console’s UI is, predictably, built for clicking through a few dozen endpoints, not for actual platform-scale operations. The API docs are a maze, and the thought of doing this manually is a special kind of torture.
I wrote a script to handle the bulk tagging via the (poorly documented) API. It’s not elegant, but it works, which is more than I can say for the built-in bulk edit features. You’ll need an API key with the right permissions and a CSV with at least `asset_hostname` and `target_tag` columns.
```python
import requests
import csv
import time
XDR_URL = "https://api.xdrurl.com"
API_KEY_ID = "your_key_id"
API_KEY = "your_secret_key"
def tag_assets(csv_filepath):
headers = {
"Authorization": f"{API_KEY_ID}/{API_KEY}",
"Content-Type": "application/json"
}
with open(csv_filepath, 'r') as f:
reader = csv.DictReader(f)
for row in reader:
# Fetch endpoint ID by hostname
search_payload = {
"request_data": {
"search_from": 0,
"search_to": 1,
"filters": [
{"field": "endpoint_name", "operator": "eq", "value": row['asset_hostname']}
]
}
}
search_resp = requests.post(f"{XDR_URL}/public_api/v1/endpoints/query", json=search_payload, headers=headers)
endpoint_id = search_resp.json().get('reply', {}).get('endpoints', [{}])[0].get('endpoint_id')
if not endpoint_id:
print(f"Endpoint not found: {row['asset_hostname']}")
continue
# Apply tag
tag_payload = {
"request_data": {
"endpoint_ids": [endpoint_id],
"tag": row['target_tag'],
"action": "add"
}
}
tag_resp = requests.post(f"{XDR_URL}/public_api/v1/endpoints/tags", json=tag_payload, headers=headers)
if tag_resp.status_code == 200:
print(f"Tagged {row['asset_hostname']} with {row['target_tag']}")
else:
print(f"Failed to tag {row['asset_hostname']}: {tag_resp.text}")
# Be polite to the rate limit
time.sleep(0.5)
if __name__ == "__main__":
tag_assets("asset_tags.csv")
```
A few things I learned the hard way:
- The API uses a bizarre `key_id/key` auth split. It feels like an afterthought.
- The `endpoints/query` endpoint is fussy about filters. If your hostnames aren't exact, you get nothing.
- There’s a rate limit, but they don’t tell you what it is. Hence the sleep.
- Tags added via API don’t always appear immediately in the UI. Give it a minute before you panic.
This isn’t a “best practice.” It’s a duct-tape solution because the product lacks proper bulk operations. But it beats tagging by hand until you finish that “platform engineering” initiative they keep talking about.
While I appreciate the effort to solve a real problem, I'd be concerned about the script's linear approach with 2,000+ assets. Calling the search API for every single hostname is going to be painfully slow and might hit rate limits. The API is likely designed for batch operations.
You should fetch all assets once, map the hostnames to IDs locally, then batch the tag updates. Even better, check if the API has a `/tags/assets/bulk` endpoint - most modern platforms hide one. Also, your current method has no idempotency or error handling; a single malformed hostname will stop the entire process and you won't know which ones succeeded.
I'd restructure it to use a proper task queue pattern and separate the identification phase from the mutation phase. Something like this for the core logic:
```python
# Pseudocode
all_assets = get_all_assets()
hostname_to_id = {a['hostname']: a['id'] for a in all_assets}
for row in csv_reader:
asset_id = hostname_to_id.get(row['asset_hostname'])
if asset_id:
queue_tag_update(asset_id, row['target_tag'])
```
infrastructure is code
You're absolutely right about the API calls, but I actually hit a quirk with that mapping approach last week. The asset list endpoint paginates after 100 results, and hostnames aren't guaranteed unique across different endpoint types in XDR. I had collisions where a server and a user's laptop shared a hostname. My workaround was to include the `agent_id` in the CSV to avoid ambiguity.
Still, batching is the way to go. The API does have a bulk tag endpoint, but it's hidden under the generic `/assets/tags` POST method - you pass an array of objects with `asset_id` and `tag_key`. Rate limiting is brutal though, I got throttled after about 300 requests even with batching. Had to add exponential backoff.
Error handling's crucial. I log each failure to a separate file with a timestamp and retry reason, then pick up from where it left off. The script's ugly now, but at least it's resilient.
edge cases matter
Hardcoding credentials in a script is a security audit finding waiting to happen. You're creating a permanent credential leak the moment that file touches a disk, especially in a shared environment.
The first few comments have the right idea about batching and error handling, but they're missing the core compliance issue. Use environment variables or a proper secrets manager, never plaintext keys. If this script is for a one-off run, your CI/CD pipeline should inject them. If it's recurring, you need a service account with controlled permissions.
Also, linear API calls will get you throttled. Batching isn't just for efficiency, it's for respecting the platform's limits so you don't get your key revoked mid-operation.
— geo
Your batch mapping suggestion is fundamentally correct for reducing API calls, but the pseudocode misses a critical data integrity issue when dealing with large inventories. The hostname to asset ID mapping is a one to many relationship in many EDR platforms, not one to one. As user992 noted, hostname collisions happen frequently across endpoint types.
Your approach also assumes `get_all_assets()` is a single, inexpensive call. For 2000+ assets, that's likely a paginated request series itself. You'd need to handle that loop, manage the response payload, and possibly deal with API limits on the *read* side before you even start the writes. A more resilient pattern is to first request a paginated list of all assets, store it in a local SQLite database with columns for hostname, agent_id, and asset_id, then perform a join between your CSV and that local dataset. This lets you handle collisions and audit mismatches.
The task queue separation is good in theory, but for a one-off bulk operation, it's often overkill. A simpler batching loop with a controlled concurrency limit (say, 10 requests per second) and retry logic for throttling errors is more pragmatic. The real failure point is not having a transactional rollback or a idempotent tag application method, which most APIs lack.
SQL is not dead.
Right, I can see the problem with searching for every hostname individually. But starting with that get_all_assets() call feels risky too, like others said, because of pagination and hostname collisions.
How do you decide on the batch size when you finally call that bulk tag endpoint? Is it just trial and error with the rate limits, or is there a rule of thumb?
PipelinePadawan
Batch size isn't something you guess at. You find it in the API documentation's rate limit policy, usually expressed as requests per minute or hour. If it's not documented, you start with a conservative number, like 50 per batch, and you implement exponential backoff with jitter right from the first run. The real rule of thumb is to assume the limit is lower than you think.
Paginating the initial asset list is unavoidable, but it's a known problem. You write a function that loops, fetches pages, and builds a local lookup table using a composite key like `hostname_agent_type` or `hostname_os_platform`. Yes, it's more work upfront, but it's a one-time cost that prevents the nightmare of mis-tagging a server when you meant a laptop.
Trial and error with rate limits in production is how you get your API key temporarily disabled and your migration project delayed by a week. Test against a sandbox tenant with a similar data volume first, or run your script against a small, known subset of assets to gauge the response headers for rate limit info.
Migrate once, test twice.