feat(ttp): inspector drawer surfaces evidence + rule_id behind each technique

The TTPsObservedSection rollup tells the operator "we saw T1059" but
not why. Click any technique row → side drawer opens listing every
ttp_tag row in scope with the persisted evidence JSON, firing
rule_id / rule_version, source_kind / source_id, confidence, and
created_at. Mirrors the CredentialReuseInspector / BountyInspector
pattern (drawer-backdrop + bd-head/bd-body + kvs grid).

Backend:
- New `GET /api/v1/ttp/tags/by-{scope}/{uuid}/{technique_id}`
  (`scope ∈ {identity, attacker, session}`, optional
  `?sub_technique_id=`, `?limit=` capped to 1000). Returns raw
  TTPTag rows newest-first.
- New `TTPTagDetailRow` Pydantic model + re-export.
- New repo method `list_tags_by_scope_and_technique` on
  TTPMixin (+ abstract on BaseRepository) — single query branched
  on scope; identity scope projects through `Attacker.identity_id`
  the same way `list_techniques_by_identity` does.
- Tests: evidence round-trips, sub_technique filter, JWT-required,
  empty scope, unknown scope rejected.

Frontend:
- New `TTPInspector.tsx` + `TTPInspector.css` (violet accent, slide
  animation, focus-trapped panel matching the existing inspector
  family).
- `TTPsObservedSection`'s TechniqueBar is now click+keyboard
  activatable; clicking opens the inspector for that
  (technique, sub_technique) tuple.

mypy clean. 532 passed in the targeted sweep.
This commit is contained in:
2026-05-02 02:55:05 -04:00
parent c4e29e3bf9
commit 42e9492118
11 changed files with 661 additions and 2 deletions

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
import { Crosshair, Download, Target } from '../icons';
import api from '../utils/api';
import EmptyState from './EmptyState/EmptyState';
import TTPInspector from './TTPInspector';
/*
* TTPsObservedSection — shared between IdentityDetail (primary) and
@@ -59,6 +60,7 @@ const TTPsObservedSection: React.FC<Props> = ({ scope, uuid }) => {
const [rows, setRows] = useState<TechniqueRow[]>([]);
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<TechniqueRow | null>(null);
useEffect(() => {
let cancelled = false;
@@ -141,7 +143,11 @@ const TTPsObservedSection: React.FC<Props> = ({ scope, uuid }) => {
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{byTactic[tid].map((r) => (
<TechniqueBar key={`${r.technique_id}-${r.sub_technique_id ?? ''}`} row={r} />
<TechniqueBar
key={`${r.technique_id}-${r.sub_technique_id ?? ''}`}
row={r}
onClick={() => setSelected(r)}
/>
))}
</div>
</div>
@@ -149,11 +155,26 @@ const TTPsObservedSection: React.FC<Props> = ({ scope, uuid }) => {
</div>
)}
</div>
{selected && (
<TTPInspector
scope={scope}
uuid={uuid}
techniqueId={selected.technique_id}
subTechniqueId={selected.sub_technique_id}
tactic={selected.tactic}
count={selected.count}
confidenceMax={selected.confidence_max}
onClose={() => setSelected(null)}
/>
)}
</div>
);
};
const TechniqueBar: React.FC<{ row: TechniqueRow }> = ({ row }) => {
const TechniqueBar: React.FC<{
row: TechniqueRow;
onClick: () => void;
}> = ({ row, onClick }) => {
// Confidence bar: 0..1 mapped to 0..100% width. Values below 0.3
// can never appear (repo confidence floor) so the bar always shows
// some non-trivial fill.
@@ -161,12 +182,27 @@ const TechniqueBar: React.FC<{ row: TechniqueRow }> = ({ row }) => {
const label = row.sub_technique_id ?? row.technique_id;
return (
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick();
}
}}
title="Click to inspect underlying tags + evidence"
style={{
display: 'grid',
gridTemplateColumns: '160px 1fr 60px',
gap: 8,
alignItems: 'center',
cursor: 'pointer',
padding: '2px 4px',
borderRadius: 2,
}}
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(155,135,245,0.06)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
>
<span className="matrix-text">{label}</span>
<div