fix(threat-actors): fix 500 on search + populate motivation from STIX
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
kitos
2026-05-29 14:09:04 +02:00
parent 7d856bef43
commit e49eca0b24
2 changed files with 49 additions and 3 deletions

View File

@@ -116,6 +116,38 @@ def _extract_mitre_url(external_references: list) -> str | None:
return 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]: def _parse_intrusion_sets(objects: list) -> list[dict]:
"""Parse STIX intrusion-set objects into ThreatActor dicts.""" """Parse STIX intrusion-set objects into ThreatActor dicts."""
actors = [] actors = []
@@ -139,6 +171,11 @@ def _parse_intrusion_sets(objects: list) -> list[dict]:
description = obj.get("description", "") 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) # Extract references (non-MITRE)
references = [] references = []
for ref in ext_refs: for ref in ext_refs:
@@ -159,6 +196,8 @@ def _parse_intrusion_sets(objects: list) -> list[dict]:
"references": references[:20], # cap to avoid bloat "references": references[:20], # cap to avoid bloat
"first_seen": obj.get("first_seen"), "first_seen": obj.get("first_seen"),
"last_seen": obj.get("last_seen"), "last_seen": obj.get("last_seen"),
"motivation": motivation,
"sophistication": sophistication,
}) })
logger.info("Parsed %d intrusion-sets (threat actors)", len(actors)) 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.references = actor_dict["references"]
db_actor.first_seen = actor_dict.get("first_seen") db_actor.first_seen = actor_dict.get("first_seen")
db_actor.last_seen = actor_dict.get("last_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 stix_to_db_actor[stix_id] = db_actor
actors_skipped += 1 actors_skipped += 1
else: else:
@@ -288,6 +332,8 @@ def sync(db: Session) -> dict:
references=actor_dict["references"], references=actor_dict["references"],
first_seen=actor_dict.get("first_seen"), first_seen=actor_dict.get("first_seen"),
last_seen=actor_dict.get("last_seen"), last_seen=actor_dict.get("last_seen"),
motivation=actor_dict.get("motivation"),
sophistication=actor_dict.get("sophistication"),
is_active=True, is_active=True,
) )
db.add(db_actor) db.add(db_actor)

View File

@@ -10,7 +10,7 @@ from __future__ import annotations
from typing import Any from typing import Any
from sqlalchemy import case, func, or_ from sqlalchemy import case, cast, func, or_, Text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.domain.errors import EntityNotFoundError from app.domain.errors import EntityNotFoundError
@@ -49,7 +49,7 @@ def list_actors(
or_( or_(
ThreatActor.name.ilike(pattern), ThreatActor.name.ilike(pattern),
ThreatActor.description.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: if target_sectors:
query = query.filter( query = query.filter(
func.cast(ThreatActor.target_sectors, func.text()).ilike( cast(ThreatActor.target_sectors, Text).ilike(
f"%{escape_like(target_sectors)}%" f"%{escape_like(target_sectors)}%"
) )
) )