feat(ttp): promote mitre_url to first-class TTPTag column + propagate everywhere

Phase 2 attached mitre_url to intel-emitted tags' evidence JSON;
Phase 3 promotes it to a real column populated for *every* tag —
intel, credential, behavioral, canary, identity, email, rule-engine —
from one source. Pre-v1, so the SQLModel field is added directly
without an Alembic migration.

- TTPTag gains mitre_url: Optional[str] (not indexed — derived
  deeplink, not a query target; technique_id is already indexed).
- _emit.py and rule_engine._evaluate_rules both populate mitre_url
  via attack_stix.mitre_url_for(sub_technique_id or technique_id).
  Sub-technique URL when present, else parent. The two construction
  sites stay separate because the rule_engine path carries per-emit
  span instrumentation that emit_tags() can't preserve without
  threading a span object through; minimal-change beats forced
  refactor here.
- intel_lifter strips mitre_url from evidence_extra in all four
  decision functions. The column is canonical now; duplicating in
  the JSON column would drift when the bundle moves. The unused
  TechniqueEmission import + tracking dicts removed too.
- IdentityTechniqueRow / TechniqueRollupRow / TTPTagDetailRow /
  CampaignTechniqueRow gain mitre_url: Optional[str].
- sqlmodel_repo/ttp.py:_mitre_url_for added; the 5 row-builder sites
  pass mitre_url=_mitre_url_for(sub_technique_id or technique_id)
  alongside the existing technique_name resolution.
- api_get_tag_details.py needs no change — list_tags_by_scope_and
  _technique already returns model_dump() rows that flow the new
  column through **row spread to TTPTagDetailRow.
- tests/ttp/test_emit_attaches_mitre_url.py covers both construction
  paths (top-level, sub-tech, unknown, multi-emit) and a regression
  test that intel_lifter evidence dicts no longer contain mitre_url.
This commit is contained in:
2026-05-09 06:40:08 -04:00
parent 9675f4bf92
commit 84a075e405
6 changed files with 178 additions and 55 deletions

View File

@@ -146,6 +146,15 @@ class TTPTag(SQLModel, table=True):
# ID cannot render deterministically in MITRE Navigator.
attack_release: str = Field(index=True)
# Canonical attack.mitre.org URL for this technique (or
# sub-technique when present). Resolved at insert via
# decnet.ttp.attack_stix.mitre_url_for from the loaded STIX
# bundle. Nullable because (a) the bundle may not be loaded in
# certain test paths and (b) a future release could deprecate
# a technique we have legacy tags for. Not indexed — derived
# deeplink, not a query target; technique_id is already indexed.
mitre_url: Optional[str] = Field(default=None)
created_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
index=True,
@@ -256,6 +265,7 @@ class TechniqueRollupRow(BaseModel):
tactic: str
count: int
last_seen: datetime
mitre_url: Optional[str] = None
class IdentityTechniqueRow(BaseModel):
@@ -278,6 +288,7 @@ class IdentityTechniqueRow(BaseModel):
first_seen: datetime
last_seen: datetime
confidence_max: float
mitre_url: Optional[str] = None
class TTPTagDetailRow(BaseModel):
@@ -306,6 +317,7 @@ class TTPTagDetailRow(BaseModel):
evidence: dict[str, Any] = Field(default_factory=dict)
attack_release: str
created_at: datetime
mitre_url: Optional[str] = None
class CampaignTechniqueRow(BaseModel):
@@ -320,6 +332,7 @@ class CampaignTechniqueRow(BaseModel):
count: int
identity_count: int
last_seen: datetime
mitre_url: Optional[str] = None
class RuleCatalogueRow(BaseModel):