Skip to main content
Solved

Collecting case data - Triage and Investigation Agent verdicts

  • July 9, 2026
  • 2 replies
  • 14 views

donkos
Forum|alt.badge.img+9

Hello,

 

We have been using the Triage and Investigation Agent in Google SecOps SOAR using the automatic and manual triggers via the GUI (we’re having separate issues with adding it in as a playbook step).

We can collect case data, including case close codes for example which reflect manual verdicts on cases (benign, malicious etc.) - how can we collect data on the verdicts given by the agent? Is there an API? Something in Bigquery or cloud logs?

Best answer by cmmartin_google

Here’s an example script that joins TINA with Case data and outputs a CSV, perhaps may help you get started on how to bridge these two disconnected datasets, it queries the TINA (notebooks) API method, then joins this against Case data via native dashboard queries.

 

import csv
import json
import os
import sys
import concurrent.futures
import google.auth
import google.auth.transport.requests
import requests

CONFIG_FILE = "secops-config.json"
CSV_FILE = "tina_investigations_export.csv"

def get_credentials():
credentials, project = google.auth.default(
scopes=['https://www.googleapis.com/auth/cloud-platform']
)
auth_req = google.auth.transport.requests.Request()
credentials.refresh(auth_req)
return credentials

def fetch_notebooks_list(session, headers, project_id, customer_id, region, limit=50):
url = f"https://us-chronicle.googleapis.com/v1alpha/projects/{project_id}/locations/{region}/instances/{customer_id}/notebooks"
params = {"orderBy": "createTime desc", "pageSize": limit}
try:
r = session.get(url, headers=headers, params=params, timeout=15)
if r.status_code == 200:
return r.json().get("notebooks", [])
except Exception as e:
print(f"Error fetching notebooks list: {e}", file=sys.stderr)
return []

def fetch_notebook(session, headers, name):
url = f"https://us-chronicle.googleapis.com/v1alpha/{name}"
try:
r = session.get(url, headers=headers, timeout=10)
if r.status_code == 200:
return r.json()
except Exception as e:
print(f"Error fetching notebook {name}: {e}", file=sys.stderr)
return None

def fetch_investigation(session, headers, name):
url = f"https://us-chronicle.googleapis.com/v1alpha/{name}"
try:
r = session.get(url, headers=headers, timeout=10)
if r.status_code == 200:
return r.json()
except Exception as e:
print(f"Error fetching investigation {name}: {e}", file=sys.stderr)
return None

def scan_alert_legacy(session, headers, project_id, customer_id, region, alert_id):
url = f"https://us-chronicle.googleapis.com/v1alpha/projects/{project_id}/locations/{region}/instances/{customer_id}/legacy:legacyGetAlert?alertId={alert_id}&includeDetections=true"
try:
r = session.get(url, headers=headers, timeout=15)
if r.status_code == 200:
resp = r.json()
alert_obj = resp.get("alert", {})
case_name = alert_obj.get("caseName")
if case_name:
return alert_id, case_name
except Exception as e:
pass
return alert_id, None

def resolve_legacy_case_id(session, headers, project_id, customer_id, region, case_name):
url = f"https://us-chronicle.googleapis.com/v1alpha/projects/{project_id}/locations/{region}/instances/{customer_id}/legacy:legacyBatchGetCases?names={case_name}"
try:
cr = session.get(url, headers=headers, timeout=15)
if cr.status_code == 200:
cases_resp = cr.json()
cases = cases_resp.get("cases", [])
if cases:
case_obj = cases[0]
soar_info = case_obj.get("soarPlatformInfo", {})
numeric_id = soar_info.get("caseId")
priority = case_obj.get("priority", "UNKNOWN")
return numeric_id, priority
except Exception as e:
pass
return None, "UNKNOWN"

def run_mapping_query(session, headers, project_id, customer_id, region, alert_ids):
url = f"https://us-chronicle.googleapis.com/v1alpha/projects/{project_id}/locations/{region}/instances/{customer_id}/dashboardQueries:execute"
id_clauses = " or ".join([f'$alert_id = "{aid}"' for aid in alert_ids])
query_string = (
"$case_id = case.name\n"
"$alert_id = case.alerts.metadata.id\n"
"$priority = case.priority\n"
f"{id_clauses}\n"
"match:\n"
" $case_id, $alert_id, $priority\n"
)
payload = {
"query": {
"query": query_string,
"input": {
"relativeTime": {
"timeUnit": "DAY",
"startTimeVal": "365"
}
}
},
"filters": [],
"clearCache": True
}
try:
r = session.post(url, headers=headers, json=payload, timeout=60) # Increased timeout
if r.status_code == 200:
return r.json()
except Exception as e:
print(f"Error querying dashboard mapping: {e}", file=sys.stderr)
return None

def main():
print("==========================================================")
print("Standalone TINA to CSV Exporter")
print("==========================================================")

if not os.path.exists(CONFIG_FILE):
print(f"Error: {CONFIG_FILE} not found.", file=sys.stderr)
sys.exit(1)

with open(CONFIG_FILE, "r") as f:
config = json.load(f)
project_id = config.get("project_id")
customer_id = config.get("customer_id")
region = config.get("region", "us")

try:
creds = get_credentials()
headers = {
"Authorization": f"Bearer {creds.token}",
"Accept": "application/json",
"Content-Type": "application/json"
}
except Exception as e:
print(f"Authentication failed: {e}", file=sys.stderr)
sys.exit(1)

session = requests.Session()

print("1. Fetching latest notebooks...")
notebooks = fetch_notebooks_list(session, headers, project_id, customer_id, region, limit=50)
print(f"Retrieved {len(notebooks)} notebooks.")

print("2. Fetching detailed notebooks to extract investigation IDs...")
detailed_notebooks = []
investigation_names = set()
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
futures = {executor.submit(fetch_notebook, session, headers, nb["name"]): nb for nb in notebooks}
for future in concurrent.futures.as_completed(futures):
res = future.result()
if res:
detailed_notebooks.append(res)
for inv_name in res.get("investigations", []):
investigation_names.add(inv_name)

print(f"Found {len(investigation_names)} investigations.")

print("3. Fetching investigation details to get Alert IDs...")
investigations = []
all_alert_ids = set()
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
futures = {executor.submit(fetch_investigation, session, headers, name): name for name in investigation_names}
for future in concurrent.futures.as_completed(futures):
res = future.result()
if res:
investigations.append(res)
for aid in res.get("alerts", {}).get("ids", []):
all_alert_ids.add(aid)

print(f"Found {len(all_alert_ids)} unique alert IDs.")

print("4. Mapping Alert IDs to Case IDs via dashboardQueries...")
alert_to_case_id = {}
alert_to_priority = {}

alert_list = sorted(list(all_alert_ids))
batch_size = 30
unmapped_alerts = set(alert_list)

for start_idx in range(0, len(alert_list), batch_size):
batch_alerts = alert_list[start_idx:start_idx + batch_size]
res_json = run_mapping_query(session, headers, project_id, customer_id, region, batch_alerts)
if res_json:
results = res_json.get("results", [])
columns = {col["column"]: col.get("values", []) for col in results}
if columns and "alert_id" in columns and "case_id" in columns:
num_rows = len(columns["alert_id"])
for i in range(num_rows):
aid = columns["alert_id"][i].get("value", {}).get("stringVal")
case_guid = columns["case_id"][i].get("value", {}).get("stringVal")
priority = columns.get("priority", [])[i].get("value", {}).get("stringVal", "UNKNOWN") if "priority" in columns else "UNKNOWN"

# Extract numeric SOAR Case ID
links = columns["case_id"][i].get("value", {}).get("metadata", {}).get("links", [])
numeric_case_id = "UNKNOWN"
if links:
url_val = links[0].get("url", "")
if "/cases/" in url_val:
numeric_case_id = url_val.split("/cases/")[-1]

if aid and numeric_case_id != "UNKNOWN":
alert_to_case_id[aid] = numeric_case_id
alert_to_priority[aid] = priority
if aid in unmapped_alerts:
unmapped_alerts.remove(aid)

print(f"5. Fallback mapping for {len(unmapped_alerts)} alerts via legacy APIs...")
legacy_mappings = []
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
futures = {executor.submit(scan_alert_legacy, session, headers, project_id, customer_id, region, aid): aid for aid in unmapped_alerts}
for future in concurrent.futures.as_completed(futures):
aid, case_name = future.result()
if case_name:
legacy_mappings.append((aid, case_name))

if legacy_mappings:
for aid, case_name in legacy_mappings:
numeric_id, priority = resolve_legacy_case_id(session, headers, project_id, customer_id, region, case_name)
if numeric_id:
alert_to_case_id[aid] = numeric_id
alert_to_priority[aid] = priority
if aid in unmapped_alerts:
unmapped_alerts.remove(aid)

print(f"Remaining unmapped alerts: {len(unmapped_alerts)}")

print("6. Writing data to CSV...")
with open(CSV_FILE, mode='w', newline='', encoding='utf-8') as file:
writer = csv.writer(file)
writer.writerow(["Notebook Name", "Investigation ID", "Verdict", "Alert ID", "Case ID (SOAR)", "Priority"])

# Map Notebook to Investigations
inv_to_notebook = {}
for nb in detailed_notebooks:
for inv_name in nb.get("investigations", []):
inv_to_notebook[inv_name] = nb.get("name", "UNKNOWN")

row_count = 0
for inv in investigations:
inv_name = inv.get("name", "UNKNOWN")
inv_id = inv_name.split("/")[-1]
verdict = inv.get("verdict", "UNKNOWN")
notebook_name = inv_to_notebook.get(inv_name, "UNKNOWN").split("/")[-1]

alerts = inv.get("alerts", {}).get("ids", [])
if not alerts:
# Write a row even if no alerts
writer.writerow([notebook_name, inv_id, verdict, "NONE", "NONE", "UNKNOWN"])
row_count += 1
else:
for aid in alerts:
case_id = alert_to_case_id.get(aid, "NOT_FOUND")
priority = alert_to_priority.get(aid, "UNKNOWN")
writer.writerow([notebook_name, inv_id, verdict, aid, case_id, priority])
row_count += 1

print(f"Done! Successfully wrote {row_count} rows to {CSV_FILE}")

if __name__ == "__main__":
main()

 

2 replies

Forum|alt.badge.img+13

Here’s an example script that joins TINA with Case data and outputs a CSV, perhaps may help you get started on how to bridge these two disconnected datasets, it queries the TINA (notebooks) API method, then joins this against Case data via native dashboard queries.

 

import csv
import json
import os
import sys
import concurrent.futures
import google.auth
import google.auth.transport.requests
import requests

CONFIG_FILE = "secops-config.json"
CSV_FILE = "tina_investigations_export.csv"

def get_credentials():
credentials, project = google.auth.default(
scopes=['https://www.googleapis.com/auth/cloud-platform']
)
auth_req = google.auth.transport.requests.Request()
credentials.refresh(auth_req)
return credentials

def fetch_notebooks_list(session, headers, project_id, customer_id, region, limit=50):
url = f"https://us-chronicle.googleapis.com/v1alpha/projects/{project_id}/locations/{region}/instances/{customer_id}/notebooks"
params = {"orderBy": "createTime desc", "pageSize": limit}
try:
r = session.get(url, headers=headers, params=params, timeout=15)
if r.status_code == 200:
return r.json().get("notebooks", [])
except Exception as e:
print(f"Error fetching notebooks list: {e}", file=sys.stderr)
return []

def fetch_notebook(session, headers, name):
url = f"https://us-chronicle.googleapis.com/v1alpha/{name}"
try:
r = session.get(url, headers=headers, timeout=10)
if r.status_code == 200:
return r.json()
except Exception as e:
print(f"Error fetching notebook {name}: {e}", file=sys.stderr)
return None

def fetch_investigation(session, headers, name):
url = f"https://us-chronicle.googleapis.com/v1alpha/{name}"
try:
r = session.get(url, headers=headers, timeout=10)
if r.status_code == 200:
return r.json()
except Exception as e:
print(f"Error fetching investigation {name}: {e}", file=sys.stderr)
return None

def scan_alert_legacy(session, headers, project_id, customer_id, region, alert_id):
url = f"https://us-chronicle.googleapis.com/v1alpha/projects/{project_id}/locations/{region}/instances/{customer_id}/legacy:legacyGetAlert?alertId={alert_id}&includeDetections=true"
try:
r = session.get(url, headers=headers, timeout=15)
if r.status_code == 200:
resp = r.json()
alert_obj = resp.get("alert", {})
case_name = alert_obj.get("caseName")
if case_name:
return alert_id, case_name
except Exception as e:
pass
return alert_id, None

def resolve_legacy_case_id(session, headers, project_id, customer_id, region, case_name):
url = f"https://us-chronicle.googleapis.com/v1alpha/projects/{project_id}/locations/{region}/instances/{customer_id}/legacy:legacyBatchGetCases?names={case_name}"
try:
cr = session.get(url, headers=headers, timeout=15)
if cr.status_code == 200:
cases_resp = cr.json()
cases = cases_resp.get("cases", [])
if cases:
case_obj = cases[0]
soar_info = case_obj.get("soarPlatformInfo", {})
numeric_id = soar_info.get("caseId")
priority = case_obj.get("priority", "UNKNOWN")
return numeric_id, priority
except Exception as e:
pass
return None, "UNKNOWN"

def run_mapping_query(session, headers, project_id, customer_id, region, alert_ids):
url = f"https://us-chronicle.googleapis.com/v1alpha/projects/{project_id}/locations/{region}/instances/{customer_id}/dashboardQueries:execute"
id_clauses = " or ".join([f'$alert_id = "{aid}"' for aid in alert_ids])
query_string = (
"$case_id = case.name\n"
"$alert_id = case.alerts.metadata.id\n"
"$priority = case.priority\n"
f"{id_clauses}\n"
"match:\n"
" $case_id, $alert_id, $priority\n"
)
payload = {
"query": {
"query": query_string,
"input": {
"relativeTime": {
"timeUnit": "DAY",
"startTimeVal": "365"
}
}
},
"filters": [],
"clearCache": True
}
try:
r = session.post(url, headers=headers, json=payload, timeout=60) # Increased timeout
if r.status_code == 200:
return r.json()
except Exception as e:
print(f"Error querying dashboard mapping: {e}", file=sys.stderr)
return None

def main():
print("==========================================================")
print("Standalone TINA to CSV Exporter")
print("==========================================================")

if not os.path.exists(CONFIG_FILE):
print(f"Error: {CONFIG_FILE} not found.", file=sys.stderr)
sys.exit(1)

with open(CONFIG_FILE, "r") as f:
config = json.load(f)
project_id = config.get("project_id")
customer_id = config.get("customer_id")
region = config.get("region", "us")

try:
creds = get_credentials()
headers = {
"Authorization": f"Bearer {creds.token}",
"Accept": "application/json",
"Content-Type": "application/json"
}
except Exception as e:
print(f"Authentication failed: {e}", file=sys.stderr)
sys.exit(1)

session = requests.Session()

print("1. Fetching latest notebooks...")
notebooks = fetch_notebooks_list(session, headers, project_id, customer_id, region, limit=50)
print(f"Retrieved {len(notebooks)} notebooks.")

print("2. Fetching detailed notebooks to extract investigation IDs...")
detailed_notebooks = []
investigation_names = set()
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
futures = {executor.submit(fetch_notebook, session, headers, nb["name"]): nb for nb in notebooks}
for future in concurrent.futures.as_completed(futures):
res = future.result()
if res:
detailed_notebooks.append(res)
for inv_name in res.get("investigations", []):
investigation_names.add(inv_name)

print(f"Found {len(investigation_names)} investigations.")

print("3. Fetching investigation details to get Alert IDs...")
investigations = []
all_alert_ids = set()
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
futures = {executor.submit(fetch_investigation, session, headers, name): name for name in investigation_names}
for future in concurrent.futures.as_completed(futures):
res = future.result()
if res:
investigations.append(res)
for aid in res.get("alerts", {}).get("ids", []):
all_alert_ids.add(aid)

print(f"Found {len(all_alert_ids)} unique alert IDs.")

print("4. Mapping Alert IDs to Case IDs via dashboardQueries...")
alert_to_case_id = {}
alert_to_priority = {}

alert_list = sorted(list(all_alert_ids))
batch_size = 30
unmapped_alerts = set(alert_list)

for start_idx in range(0, len(alert_list), batch_size):
batch_alerts = alert_list[start_idx:start_idx + batch_size]
res_json = run_mapping_query(session, headers, project_id, customer_id, region, batch_alerts)
if res_json:
results = res_json.get("results", [])
columns = {col["column"]: col.get("values", []) for col in results}
if columns and "alert_id" in columns and "case_id" in columns:
num_rows = len(columns["alert_id"])
for i in range(num_rows):
aid = columns["alert_id"][i].get("value", {}).get("stringVal")
case_guid = columns["case_id"][i].get("value", {}).get("stringVal")
priority = columns.get("priority", [])[i].get("value", {}).get("stringVal", "UNKNOWN") if "priority" in columns else "UNKNOWN"

# Extract numeric SOAR Case ID
links = columns["case_id"][i].get("value", {}).get("metadata", {}).get("links", [])
numeric_case_id = "UNKNOWN"
if links:
url_val = links[0].get("url", "")
if "/cases/" in url_val:
numeric_case_id = url_val.split("/cases/")[-1]

if aid and numeric_case_id != "UNKNOWN":
alert_to_case_id[aid] = numeric_case_id
alert_to_priority[aid] = priority
if aid in unmapped_alerts:
unmapped_alerts.remove(aid)

print(f"5. Fallback mapping for {len(unmapped_alerts)} alerts via legacy APIs...")
legacy_mappings = []
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
futures = {executor.submit(scan_alert_legacy, session, headers, project_id, customer_id, region, aid): aid for aid in unmapped_alerts}
for future in concurrent.futures.as_completed(futures):
aid, case_name = future.result()
if case_name:
legacy_mappings.append((aid, case_name))

if legacy_mappings:
for aid, case_name in legacy_mappings:
numeric_id, priority = resolve_legacy_case_id(session, headers, project_id, customer_id, region, case_name)
if numeric_id:
alert_to_case_id[aid] = numeric_id
alert_to_priority[aid] = priority
if aid in unmapped_alerts:
unmapped_alerts.remove(aid)

print(f"Remaining unmapped alerts: {len(unmapped_alerts)}")

print("6. Writing data to CSV...")
with open(CSV_FILE, mode='w', newline='', encoding='utf-8') as file:
writer = csv.writer(file)
writer.writerow(["Notebook Name", "Investigation ID", "Verdict", "Alert ID", "Case ID (SOAR)", "Priority"])

# Map Notebook to Investigations
inv_to_notebook = {}
for nb in detailed_notebooks:
for inv_name in nb.get("investigations", []):
inv_to_notebook[inv_name] = nb.get("name", "UNKNOWN")

row_count = 0
for inv in investigations:
inv_name = inv.get("name", "UNKNOWN")
inv_id = inv_name.split("/")[-1]
verdict = inv.get("verdict", "UNKNOWN")
notebook_name = inv_to_notebook.get(inv_name, "UNKNOWN").split("/")[-1]

alerts = inv.get("alerts", {}).get("ids", [])
if not alerts:
# Write a row even if no alerts
writer.writerow([notebook_name, inv_id, verdict, "NONE", "NONE", "UNKNOWN"])
row_count += 1
else:
for aid in alerts:
case_id = alert_to_case_id.get(aid, "NOT_FOUND")
priority = alert_to_priority.get(aid, "UNKNOWN")
writer.writerow([notebook_name, inv_id, verdict, aid, case_id, priority])
row_count += 1

print(f"Done! Successfully wrote {row_count} rows to {CSV_FILE}")

if __name__ == "__main__":
main()

 


Forum|alt.badge.img+13

In case the above is overkill, the other option is the native TINA dashboard that shows Agent disposition for an investigation:

https://docs.cloud.google.com/chronicle/docs/secops/triage-investigation-agent-dashboards