@cryptotaxi247 / CoPilot / commits / af565adf

fix: alert ingestion broken under Pydantic 2 (silent _-prefix drop) + better logs (#858)

Two related fixes — the underlying bug is also why "better logs" was requested. The reporter's symptom: ERROR | app.incidents.routes.incident_alert:create_alert_auto_route:278 Failed to create alert ... 'GenericAlertModel' object has no attribute '_source' Root cause: Pydantic 2 (which we migrated to in #849) treats names beginning with an underscore as PrivateAttr declarations and silently drops them from input parsing. GenericAlertModel was defined with `_index: str`, `_id: str`, `_version: int`, `_source: GenericSourceModel` to match Elasticsearch's JSON shape. Under Pydantic 1 those were normal fields; under Pydantic 2 every one of them gets stripped at parse time, leaving an empty model. Downstream code then accesses `alert_details._source.process_id` and gets AttributeError because the object has no `_source` attribute (private attrs aren't even discoverable via getattr unless the model declared them as PrivateAttr explicitly, which it didn't). Three places in the codebase had this exact bug: - backend/app/incidents/schema/incident_alert.py:GenericAlertModel (the active one — flowing through every Graylog event ingestion) - backend/app/integrations/alert_escalation/schema/escalate_alert.py:GenericAlertModel (currently no live imports — only commented in ask_socfortress — but defined and broken in the same way) - backend/app/agents/velociraptor/schema/agents.py:Organization (the `_client_config: ClientConfig` field, never accessed in the codebase so no observable bug, but fixed for consistency) The fix uses Pydantic-2-idiomatic `Field(alias="_index")` so the Elasticsearch-shaped JSON keys still parse, while the Python attributes are exposed under their non-underscore names. `populate_by_name=True` is added to model_config so input still accepts both shapes during any transition. Code changes: - 3 model definitions: rename the private-looking fields and add Field(alias=...) + populate_by_name=True - 10 accessor sites updated: backend/app/incidents/services/incident_alert.py (8 sites) backend/app/threat_intel/routes/socfortress.py (2 sites) All replace `alert_details._source/_index/_id/_version` → `alert_details.source/index/id/version`. Better error logging (the original ask): - The `except Exception as e: logger.error(f"... {e}")` in create_alert_auto_route now branches: - For ValidationError, logs each field-level error with location path, error type, message, and the offending input (truncated). - For anything else, uses logger.opt(exception=True).error to attach the full traceback so AttributeError-class bugs (like this one was) are immediately diagnosable from logs. Verified locally: - GenericAlertModel parses an Elasticsearch-shaped dict and exposes the expected fields: m = GenericAlertModel(**{"_index":"i","_id":"a","_version":1, "_source":{"agent_name":"x","timestamp":"t"}}) m.index == "i"; m.source.agent_name == "x" - Validation error path produces the structured per-field log: Caught 4 validation errors: field=_id type=missing msg=Field required input={...} field=_version type=int_parsing msg=... input='not-an-int' field=_source.timestamp type=missing msg=Field required input={...} field=_source.agent_name type=string_type msg=... input=42 - Fresh deploy: backend boots clean, admin login HTTP 200, no regressions Co-authored-by: taylor_socfortress <taylor.walton@socfortress.co> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

taylorcopilot committed May 8, 2026 at 11:48 UTC af565adf87e8779c280e066d2646328f7e15a9c8
6 files changed +55 -22
backend/app/agents/velociraptor/schema/agents.py
+4 -1
@@ -101,7 +101,10 @@ class ClientConfig(BaseModel):
101 class Organization(BaseModel):
102 Name: str
103 OrgId: str
104 - _client_config: ClientConfig
104 + # Pydantic 2 silently drops leading-underscore field annotations as
105 + # PrivateAttr; alias the JSON key so it's actually parsed.
106 + client_config: ClientConfig = Field(alias="_client_config")
107 + model_config = ConfigDict(populate_by_name=True)
108
109
110 class VelociraptorOrganizations(BaseModel):
backend/app/incidents/routes/incident_alert.py
+24 -1
@@ -7,6 +7,7 @@ from fastapi import HTTPException
7 from fastapi import Query
8 from fastapi import Security
9 from loguru import logger
10 +from pydantic import ValidationError
11 from sqlalchemy.ext.asyncio import AsyncSession
12
13 from app.active_response.routes.graylog import verify_graylog_header
@@ -274,8 +275,30 @@ async def create_alert_auto_route(
275 batch_created += 1
276 total_created += 1
277
278 + except ValidationError as e:
279 + # Pydantic validation failure — log per-field detail so a misshapen
280 + # index document is fast to diagnose (which field, what was provided,
281 + # which constraint failed).
282 + logger.error(
283 + f"Failed to create alert {alert.id} from index {alert.index}: "
284 + f"Pydantic validation failed in {len(e.errors())} field(s)",
285 + )
286 + for err in e.errors():
287 + loc = ".".join(str(x) for x in err.get("loc", ()))
288 + inp = err.get("input", "<not captured>")
289 + inp_repr = repr(inp)[:200] # truncate verbose inputs
290 + logger.error(
291 + f" field={loc} type={err.get('type')} msg={err.get('msg')} input={inp_repr}",
292 + )
293 + batch_failed += 1
294 + total_failed += 1
295 except Exception as e:
278 - logger.error(f"Failed to create alert {alert.id} from index {alert.index}: {e}")
296 + # Any other error — preserve the full traceback so the source line
297 + # of the failure is in the log, not just the message.
298 + logger.opt(exception=True).error(
299 + f"Failed to create alert {alert.id} from index {alert.index}: "
300 + f"{type(e).__name__}: {e}",
301 + )
302 batch_failed += 1
303 total_failed += 1
304
backend/app/incidents/schema/incident_alert.py
+9 -5
@@ -117,10 +117,14 @@ class GenericSourceModel(BaseModel):
117
118
119 class GenericAlertModel(BaseModel):
120 - _index: str
121 - _id: str
122 - _version: int
123 - _source: GenericSourceModel # Nested model
120 + # NOTE: Pydantic 2 treats names with a leading underscore as PrivateAttr
121 + # and silently drops them from input parsing. Elasticsearch hits use
122 + # `_index`, `_id`, `_version`, `_source` as JSON keys, so we expose those
123 + # via aliases and access them as `index`/`id`/`version`/`source` in code.
124 + index: str = Field(alias="_index")
125 + id: str = Field(alias="_id")
126 + version: int = Field(alias="_version")
127 + source: GenericSourceModel = Field(alias="_source")
128 asset_type_id: Optional[int] = Field(
129 None,
130 description="The asset type id of the alert which is needed for when we add the asset to IRIS.",
@@ -141,7 +145,7 @@ class GenericAlertModel(BaseModel):
145 None,
146 description="The type of the alert to be used when creating the CoPilot alert.",
147 )
144 - model_config = ConfigDict(extra="allow")
148 + model_config = ConfigDict(extra="allow", populate_by_name=True)
149
150
151 class AlertDetailsResponse(BaseModel):
backend/app/incidents/services/incident_alert.py
+8 -8
@@ -1260,15 +1260,15 @@ async def create_alert(
1260 logger.info(f"Creating alert {alert.alert_id} in CoPilot")
1261 alert_details = await get_single_alert_details(alert_details=alert)
1262 await validate_syslog_type_source(alert_details.syslog_type, session)
1263 - customer_code = await get_customer_code(dict(alert_details._source), session=session)
1263 + customer_code = await get_customer_code(dict(alert_details.source), session=session)
1264 logger.info(f"Customer code: {customer_code}")
1265 customer_alert_creation_settings = await is_customer_code_valid(customer_code=customer_code, session=session)
1266 logger.info(f"Customer creation settings: {customer_alert_creation_settings}")
1267 alert_payload = await build_alert_payload(
1268 alert_details.syslog_type,
1269 - alert_details._index,
1270 - alert_details._id,
1271 - alert_details._source.to_dict(),
1269 + alert_details.index,
1270 + alert_details.id,
1271 + alert_details.source.to_dict(),
1272 session,
1273 )
1274 if simga_alert is not None:
@@ -1323,13 +1323,13 @@ async def retrieve_alert_timeline(alert: CreateAlertRequestRoute, session: Async
1323 return threshold_timeline
1324
1325 alert_details = await get_alert_details(alert)
1326 - if alert_details._source.process_id is not None:
1327 - alert_timestamp = alert_details._source.timestamp
1326 + if alert_details.source.process_id is not None:
1327 + alert_timestamp = alert_details.source.timestamp
1328 start_of_day, end_of_day = calculate_day_range(alert_timestamp)
1329 return await fetch_alert_timeline(
1330 alert.index_name,
1331 - alert_details._source.process_id,
1332 - alert_details._source.agent_name,
1331 + alert_details.source.process_id,
1332 + alert_details.source.agent_name,
1333 start_of_day,
1334 end_of_day,
1335 )
backend/app/integrations/alert_escalation/schema/escalate_alert.py
+8 -5
@@ -80,10 +80,13 @@ class GenericSourceModel(BaseModel):
80
81
82 class GenericAlertModel(BaseModel):
83 - _index: str
84 - _id: str
85 - _version: int
86 - _source: GenericSourceModel # Nested model
83 + # NOTE: Pydantic 2 treats names with a leading underscore as PrivateAttr
84 + # and silently drops them from input parsing. Aliased to keep accepting
85 + # the Elasticsearch-shaped keys while exposing usable Python attributes.
86 + index: str = Field(alias="_index")
87 + id: str = Field(alias="_id")
88 + version: int = Field(alias="_version")
89 + source: GenericSourceModel = Field(alias="_source")
90 asset_type_id: Optional[int] = Field(
91 None,
92 description="The asset type id of the alert which is needed for when we add the asset to IRIS.",
@@ -108,7 +111,7 @@ class GenericAlertModel(BaseModel):
111 "No autogenerated syslog_level found",
112 description="The timefield of the alert to be used when creating the IRIS alert.",
113 )
111 - model_config = ConfigDict(extra="allow")
114 + model_config = ConfigDict(extra="allow", populate_by_name=True)
115
116
117 # Sample data from `get_single_alert_details`
backend/app/threat_intel/routes/socfortress.py
+2 -2
@@ -454,7 +454,7 @@ async def ai_anaylze_alert_socfortress(
454
455 ai_request = SocfortressAiAlertRequest(
456 integration="SOCFORTRESS AI",
457 - alert_payload=alert_details._source.model_dump(),
457 + alert_payload=alert_details.source.model_dump(),
458 )
459
460 socfortress_lookup = await socfortress_ai_alert_lookup(
@@ -492,7 +492,7 @@ async def ai_wazuh_exclusion_rule_socfortress(
492
493 ai_request = SocfortressAiAlertRequest(
494 integration="SOCFORTRESS AI",
495 - alert_payload=alert_details._source.model_dump(),
495 + alert_payload=alert_details.source.model_dump(),
496 )
497
498 logger.info(f"Sending request: {request}")