fix(threat-actors): fix 500 on search + populate motivation from STIX
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
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:
@@ -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)
|
||||||
|
|||||||
@@ -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)}%"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user