From e49eca0b247f2adaada5247c81da5f0b8819c248 Mon Sep 17 00:00:00 2001 From: kitos Date: Fri, 29 May 2026 14:09:04 +0200 Subject: [PATCH] fix(threat-actors): fix 500 on search + populate motivation from STIX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. fix(search 500): func.cast(col, func.text()) is invalid SQLAlchemy — replaced with cast(col, Text) for both aliases and target_sectors JSONB columns. Generating correct CAST(col AS TEXT) SQL. 2. feat(motivation): extract primary_motivation and sophistication from STIX intrusion-set objects during MITRE sync. Added _normalize_motivation() to map STIX vocabulary → simplified frontend values (espionage / financial / destruction / hacktivism). Both create and update paths now set these fields. Run MITRE sync to backfill existing actors. Co-Authored-By: Claude Sonnet 4.6 --- .../services/threat_actor_import_service.py | 46 +++++++++++++++++++ backend/app/services/threat_actor_service.py | 6 +-- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/backend/app/services/threat_actor_import_service.py b/backend/app/services/threat_actor_import_service.py index b086e06..4b37605 100644 --- a/backend/app/services/threat_actor_import_service.py +++ b/backend/app/services/threat_actor_import_service.py @@ -116,6 +116,38 @@ def _extract_mitre_url(external_references: list) -> str | None: return None +# Map STIX primary_motivation vocabulary → simplified frontend values +_MOTIVATION_MAP: dict[str, str] = { + # Espionage / nation-state + "espionage": "espionage", + "national-security": "espionage", + "political": "espionage", + # Financial + "financial": "financial", + "financial-gain": "financial", + "personal-gain": "financial", + "organizational-gain": "financial", + # Destruction / disruption + "destruction": "destruction", + "disruption": "destruction", + "coercion": "destruction", + "dominance": "destruction", + # Hacktivism / ideology + "ideology": "hacktivism", + "hacktivism": "hacktivism", + "notoriety": "hacktivism", + "personal-satisfaction": "hacktivism", + "revenge": "hacktivism", +} + + +def _normalize_motivation(raw: str | None) -> str | None: + """Normalize a STIX primary_motivation value to the Aegis vocabulary.""" + if not raw: + return None + return _MOTIVATION_MAP.get(raw.lower().strip()) + + def _parse_intrusion_sets(objects: list) -> list[dict]: """Parse STIX intrusion-set objects into ThreatActor dicts.""" actors = [] @@ -139,6 +171,11 @@ def _parse_intrusion_sets(objects: list) -> list[dict]: description = obj.get("description", "") + # Extract primary_motivation and sophistication from STIX object + raw_motivation = obj.get("primary_motivation") + motivation = _normalize_motivation(raw_motivation) + sophistication = obj.get("sophistication") # e.g. "advanced", "expert" + # Extract references (non-MITRE) references = [] for ref in ext_refs: @@ -159,6 +196,8 @@ def _parse_intrusion_sets(objects: list) -> list[dict]: "references": references[:20], # cap to avoid bloat "first_seen": obj.get("first_seen"), "last_seen": obj.get("last_seen"), + "motivation": motivation, + "sophistication": sophistication, }) logger.info("Parsed %d intrusion-sets (threat actors)", len(actors)) @@ -275,6 +314,11 @@ def sync(db: Session) -> dict: db_actor.references = actor_dict["references"] db_actor.first_seen = actor_dict.get("first_seen") db_actor.last_seen = actor_dict.get("last_seen") + # Update enrichment fields if available + if actor_dict.get("motivation"): + db_actor.motivation = actor_dict["motivation"] + if actor_dict.get("sophistication"): + db_actor.sophistication = actor_dict["sophistication"] stix_to_db_actor[stix_id] = db_actor actors_skipped += 1 else: @@ -288,6 +332,8 @@ def sync(db: Session) -> dict: references=actor_dict["references"], first_seen=actor_dict.get("first_seen"), last_seen=actor_dict.get("last_seen"), + motivation=actor_dict.get("motivation"), + sophistication=actor_dict.get("sophistication"), is_active=True, ) db.add(db_actor) diff --git a/backend/app/services/threat_actor_service.py b/backend/app/services/threat_actor_service.py index 03db9ab..c8aa18f 100644 --- a/backend/app/services/threat_actor_service.py +++ b/backend/app/services/threat_actor_service.py @@ -10,7 +10,7 @@ from __future__ import annotations from typing import Any -from sqlalchemy import case, func, or_ +from sqlalchemy import case, cast, func, or_, Text from sqlalchemy.orm import Session from app.domain.errors import EntityNotFoundError @@ -49,7 +49,7 @@ def list_actors( or_( ThreatActor.name.ilike(pattern), ThreatActor.description.ilike(pattern), - func.cast(ThreatActor.aliases, func.text()).ilike(pattern), + cast(ThreatActor.aliases, Text).ilike(pattern), ) ) @@ -64,7 +64,7 @@ def list_actors( if target_sectors: query = query.filter( - func.cast(ThreatActor.target_sectors, func.text()).ilike( + cast(ThreatActor.target_sectors, Text).ilike( f"%{escape_like(target_sectors)}%" ) )