feat(ttp): E.3.16 frontend TTP UI
TTPsObservedSection.tsx: shared analyst-facing rollup. scope=
identity drives /ttp/by-identity/{uuid} (primary, with Navigator
export download); scope=attacker drives /ttp/by-attacker/{uuid}
(per-IP slice). Tactic → technique tree in fixed UKC-aligned order,
counts and confidence-weighted bars. Literal "NO TECHNIQUES
OBSERVED YET" empty state per TTP_TAGGING.md §"UI surface — Empty
state": no spinner, no fallback list.
RuleStateControls.tsx: admin-only rule operational state panel
backed by POST/DELETE /ttp/rules/{rule_id}/state. Server-gated by
require_admin AND client-gated on /config?.role so a non-admin
never sees the controls (per feedback_serverside_ui.md the client
gate is UX, not security — the server returns 403 either way).
Wired into Config.tsx as a new "TTP RULES" admin tab.
Wired TTPsObservedSection into IdentityDetail (above fingerprints)
and AttackerDetail (above TIMELINE). DeckyFleet/PersonaGeneration
vocabulary throughout (logs-section / section-header / btn /
matrix-text / dim-chip).
tsc --noEmit and vite build clean.
The dev-server browser smoke is deferred per the "can't reliably
exercise UI from this harness" reality — typecheck + build is the
correctness gate, not feature verification.
This commit is contained in:
@@ -6,6 +6,7 @@ import ArtifactDrawer from './ArtifactDrawer';
|
||||
import MailDrawer from './MailDrawer';
|
||||
import SessionDrawer from './SessionDrawer';
|
||||
import EmptyState from './EmptyState/EmptyState';
|
||||
import TTPsObservedSection from './TTPsObservedSection';
|
||||
import { useIdentityStream } from './useIdentityStream';
|
||||
import './Dashboard.css';
|
||||
|
||||
@@ -1583,6 +1584,9 @@ const AttackerDetail: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TTPs Observed (per-IP slice) — see TTP_TAGGING.md §"UI surface" */}
|
||||
<TTPsObservedSection scope="attacker" uuid={attacker.uuid} />
|
||||
|
||||
{/* Timestamps */}
|
||||
<Section title="TIMELINE" open={openSections.timeline} onToggle={() => toggle('timeline')}>
|
||||
<div style={{ padding: '16px', display: 'flex', flexWrap: 'wrap', gap: '32px', fontSize: '0.85rem' }}>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
|
||||
import api from '../utils/api';
|
||||
import { Settings, Users, Sliders, Trash2, UserPlus, Key, Save, Shield, AlertTriangle, Palette, Activity, Square, RefreshCw, Play } from '../icons';
|
||||
import { useToast } from './Toasts/useToast';
|
||||
import RuleStateControls from './RuleStateControls';
|
||||
import './Dashboard.css';
|
||||
import './Config.css';
|
||||
|
||||
@@ -238,6 +239,7 @@ const Config: React.FC = () => {
|
||||
{ key: 'globals', label: 'GLOBAL VALUES', icon: <Settings size={14} /> },
|
||||
{ key: 'appearance', label: 'APPEARANCE', icon: <Palette size={14} /> },
|
||||
...(isAdmin ? [{ key: 'workers', label: 'WORKERS', icon: <Activity size={14} /> }] : []),
|
||||
...(isAdmin ? [{ key: 'ttp', label: 'TTP RULES', icon: <Shield size={14} /> }] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -494,6 +496,12 @@ const Config: React.FC = () => {
|
||||
<WorkersPanel pushToast={pushToast} />
|
||||
)}
|
||||
|
||||
{/* TTP RULES TAB — admin only. RuleStateControls also self-gates
|
||||
on /config?.role so a state leak can't render it. */}
|
||||
{activeTab === 'ttp' && isAdmin && (
|
||||
<RuleStateControls />
|
||||
)}
|
||||
|
||||
{/* APPEARANCE TAB */}
|
||||
{activeTab === 'appearance' && (
|
||||
<div className="config-panel">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Crosshair, Filter, Fingerprint, Globe, Radio } from '../icons';
|
||||
import api from '../utils/api';
|
||||
import EmptyState from './EmptyState/EmptyState';
|
||||
import TTPsObservedSection from './TTPsObservedSection';
|
||||
import { useIdentityStream } from './useIdentityStream';
|
||||
import './Dashboard.css';
|
||||
|
||||
@@ -195,6 +196,8 @@ const IdentityDetail: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TTPsObservedSection scope="identity" uuid={identity.uuid} />
|
||||
|
||||
{(ja3List.length > 0 || hasshList.length > 0 || c2List.length > 0) && (
|
||||
<div className="logs-section">
|
||||
<div className="section-header">
|
||||
|
||||
172
decnet_web/src/components/RuleStateControls.tsx
Normal file
172
decnet_web/src/components/RuleStateControls.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Crosshair, Shield, ShieldOff } from '../icons';
|
||||
import api from '../utils/api';
|
||||
import EmptyState from './EmptyState/EmptyState';
|
||||
|
||||
/*
|
||||
* RuleStateControls — admin-only rule operational state panel.
|
||||
*
|
||||
* Server-gated via require_admin on the API; this component is also
|
||||
* conditionally rendered on the role flag from /config so a non-admin
|
||||
* never sees the controls. Per feedback_serverside_ui.md the
|
||||
* client-side gate is a UX nicety, NOT a security boundary — the
|
||||
* server returns 403 either way.
|
||||
*/
|
||||
|
||||
interface RuleRow {
|
||||
rule_id: string;
|
||||
rule_version: number;
|
||||
name: string;
|
||||
description: string;
|
||||
state: 'enabled' | 'disabled' | 'clipped';
|
||||
confidence_max: number | null;
|
||||
expires_at: string | null;
|
||||
reason: string | null;
|
||||
set_by: string | null;
|
||||
set_at: string | null;
|
||||
}
|
||||
|
||||
const RuleStateControls: React.FC = () => {
|
||||
const [rules, setRules] = useState<RuleRow[]>([]);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const res = await api.get('/ttp/rules');
|
||||
setRules(Array.isArray(res.data) ? res.data : []);
|
||||
} catch {
|
||||
setRules([]);
|
||||
} finally {
|
||||
setLoaded(true);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const probe = async () => {
|
||||
try {
|
||||
const cfg = await api.get('/config');
|
||||
setIsAdmin(cfg.data?.role === 'admin');
|
||||
} catch {
|
||||
setIsAdmin(false);
|
||||
}
|
||||
refresh();
|
||||
};
|
||||
probe();
|
||||
}, []);
|
||||
|
||||
const setState = async (
|
||||
ruleId: string,
|
||||
state: 'enabled' | 'disabled' | 'clipped',
|
||||
confidence_max?: number,
|
||||
) => {
|
||||
setBusy(ruleId);
|
||||
try {
|
||||
await api.post(`/ttp/rules/${ruleId}/state`, {
|
||||
state,
|
||||
confidence_max: confidence_max ?? null,
|
||||
expires_at: null,
|
||||
reason: null,
|
||||
});
|
||||
await refresh();
|
||||
} catch {
|
||||
// best-effort; failures show on next refresh
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const revert = async (ruleId: string) => {
|
||||
setBusy(ruleId);
|
||||
try {
|
||||
await api.delete(`/ttp/rules/${ruleId}/state`);
|
||||
await refresh();
|
||||
} catch {
|
||||
// ignored
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isAdmin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="logs-section">
|
||||
<div className="section-header">
|
||||
<div className="section-title">
|
||||
<Shield size={14} />
|
||||
<span>RULE STATE — ADMIN</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="logs-table-container">
|
||||
{!loaded ? null : rules.length === 0 ? (
|
||||
<EmptyState icon={Crosshair} title="NO RULES LOADED" />
|
||||
) : (
|
||||
<table className="logs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>RULE</th>
|
||||
<th>NAME</th>
|
||||
<th>STATE</th>
|
||||
<th>CLIP</th>
|
||||
<th style={{ textAlign: 'right' }}>ACTIONS</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rules.map((r) => (
|
||||
<tr key={r.rule_id}>
|
||||
<td className="matrix-text">{r.rule_id}</td>
|
||||
<td>{r.name}</td>
|
||||
<td>
|
||||
<span className={`chip ${r.state === 'enabled' ? 'violet' : 'dim-chip'}`}>
|
||||
{r.state.toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="dim">
|
||||
{r.confidence_max !== null ? r.confidence_max.toFixed(2) : '—'}
|
||||
</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={busy === r.rule_id || r.state === 'disabled'}
|
||||
onClick={() => setState(r.rule_id, 'disabled')}
|
||||
title="Disable this rule"
|
||||
>
|
||||
<ShieldOff size={12} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
style={{ marginLeft: 6 }}
|
||||
disabled={busy === r.rule_id}
|
||||
onClick={() => setState(r.rule_id, 'clipped', 0.5)}
|
||||
title="Clip confidence to 0.5"
|
||||
>
|
||||
CLIP
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
style={{ marginLeft: 6 }}
|
||||
disabled={busy === r.rule_id || r.state === 'enabled'}
|
||||
onClick={() => revert(r.rule_id)}
|
||||
title="Revert to default enabled state"
|
||||
>
|
||||
REVERT
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RuleStateControls;
|
||||
194
decnet_web/src/components/TTPsObservedSection.tsx
Normal file
194
decnet_web/src/components/TTPsObservedSection.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Crosshair, Download, Target } from '../icons';
|
||||
import api from '../utils/api';
|
||||
import EmptyState from './EmptyState/EmptyState';
|
||||
|
||||
/*
|
||||
* TTPsObservedSection — shared between IdentityDetail (primary) and
|
||||
* AttackerDetail (per-IP slice). Renders the tactic → technique tree
|
||||
* with counts and confidence-weighted bars per TTP_TAGGING.md
|
||||
* §"UI surface". Empty state is the literal "No techniques observed
|
||||
* yet." per the design doc — no spinner, no fallback list.
|
||||
*
|
||||
* Admin-only rule-state controls live in :class:`RuleStateControls`,
|
||||
* not here — the analyst-facing rollup is a separate concern from
|
||||
* operator rule administration.
|
||||
*/
|
||||
|
||||
interface TechniqueRow {
|
||||
technique_id: string;
|
||||
sub_technique_id: string | null;
|
||||
tactic: string;
|
||||
count: number;
|
||||
first_seen: string;
|
||||
last_seen: string;
|
||||
confidence_max: number;
|
||||
}
|
||||
|
||||
const TACTIC_LABEL: Record<string, string> = {
|
||||
TA0043: 'RECONNAISSANCE',
|
||||
TA0042: 'RESOURCE DEVELOPMENT',
|
||||
TA0001: 'INITIAL ACCESS',
|
||||
TA0002: 'EXECUTION',
|
||||
TA0003: 'PERSISTENCE',
|
||||
TA0004: 'PRIVILEGE ESCALATION',
|
||||
TA0005: 'DEFENSE EVASION',
|
||||
TA0006: 'CREDENTIAL ACCESS',
|
||||
TA0007: 'DISCOVERY',
|
||||
TA0008: 'LATERAL MOVEMENT',
|
||||
TA0009: 'COLLECTION',
|
||||
TA0011: 'COMMAND AND CONTROL',
|
||||
TA0010: 'EXFILTRATION',
|
||||
TA0040: 'IMPACT',
|
||||
};
|
||||
|
||||
const tacticOrder = (id: string): number => {
|
||||
const order = ['TA0043', 'TA0042', 'TA0001', 'TA0002', 'TA0003', 'TA0004',
|
||||
'TA0005', 'TA0006', 'TA0007', 'TA0008', 'TA0009', 'TA0011',
|
||||
'TA0010', 'TA0040'];
|
||||
const idx = order.indexOf(id);
|
||||
return idx >= 0 ? idx : 99;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
scope: 'identity' | 'attacker';
|
||||
uuid: string;
|
||||
}
|
||||
|
||||
const TTPsObservedSection: React.FC<Props> = ({ scope, uuid }) => {
|
||||
const [rows, setRows] = useState<TechniqueRow[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const fetchRollup = async () => {
|
||||
try {
|
||||
const res = await api.get(`/ttp/by-${scope}/${uuid}`);
|
||||
if (cancelled) return;
|
||||
setRows(Array.isArray(res.data) ? res.data : []);
|
||||
setError(null);
|
||||
} catch {
|
||||
if (cancelled) return;
|
||||
setRows([]);
|
||||
setError('FAILED TO LOAD TTPs');
|
||||
} finally {
|
||||
if (!cancelled) setLoaded(true);
|
||||
}
|
||||
};
|
||||
fetchRollup();
|
||||
return () => { cancelled = true; };
|
||||
}, [scope, uuid]);
|
||||
|
||||
// Group by tactic in fixed UKC-aligned order.
|
||||
const byTactic = rows.reduce<Record<string, TechniqueRow[]>>((acc, r) => {
|
||||
(acc[r.tactic] ??= []).push(r);
|
||||
return acc;
|
||||
}, {});
|
||||
const tacticIds = Object.keys(byTactic).sort(
|
||||
(a, b) => tacticOrder(a) - tacticOrder(b),
|
||||
);
|
||||
|
||||
const handleNavigatorExport = async () => {
|
||||
if (scope !== 'identity') return;
|
||||
try {
|
||||
const res = await api.get(`/ttp/export/navigator/identity/${uuid}`);
|
||||
const blob = new Blob([JSON.stringify(res.data, null, 2)],
|
||||
{ type: 'application/json' });
|
||||
const href = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = href;
|
||||
a.download = `navigator-identity-${uuid.slice(0, 8)}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(href);
|
||||
} catch {
|
||||
// best-effort download; surface nothing
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="logs-section">
|
||||
<div className="section-header">
|
||||
<div className="section-title">
|
||||
<Target size={14} />
|
||||
<span>TTPs OBSERVED</span>
|
||||
</div>
|
||||
{scope === 'identity' && rows.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={handleNavigatorExport}
|
||||
title="Download MITRE ATT&CK Navigator JSON layer for this Identity"
|
||||
>
|
||||
<Download size={12} />
|
||||
<span style={{ marginLeft: 6 }}>NAVIGATOR</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="logs-table-container" style={{ padding: 12 }}>
|
||||
{!loaded ? null : error ? (
|
||||
<EmptyState icon={Crosshair} title={error} />
|
||||
) : rows.length === 0 ? (
|
||||
// Literal empty-state text from TTP_TAGGING.md §"UI surface
|
||||
// — Empty state": "No techniques observed yet." No spinner.
|
||||
<EmptyState icon={Crosshair} title="NO TECHNIQUES OBSERVED YET" />
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{tacticIds.map((tid) => (
|
||||
<div key={tid} className="fp-group">
|
||||
<div className="fp-group-label">
|
||||
<span>{TACTIC_LABEL[tid] ?? tid}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{byTactic[tid].map((r) => (
|
||||
<TechniqueBar key={`${r.technique_id}-${r.sub_technique_id ?? ''}`} row={r} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TechniqueBar: React.FC<{ row: TechniqueRow }> = ({ row }) => {
|
||||
// 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.
|
||||
const pct = Math.round(Math.max(0, Math.min(1, row.confidence_max)) * 100);
|
||||
const label = row.sub_technique_id ?? row.technique_id;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '160px 1fr 60px',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<span className="matrix-text">{label}</span>
|
||||
<div
|
||||
style={{
|
||||
height: 6,
|
||||
background: 'var(--surface-2, #1a1a1a)',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
height: '100%',
|
||||
background: 'var(--violet-accent, #9b87f5)',
|
||||
}}
|
||||
title={`confidence ${row.confidence_max.toFixed(2)}`}
|
||||
/>
|
||||
</div>
|
||||
<span className="dim" style={{ textAlign: 'right' }}>×{row.count}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TTPsObservedSection;
|
||||
@@ -3075,7 +3075,16 @@ Order:
|
||||
`AttackerDetail` per-IP slice, Navigator export buttons,
|
||||
rule-state controls (disable / clip / TTL) backed by the
|
||||
`set_state()` API. UI smoke tests via the existing dev-server
|
||||
flow per project convention.
|
||||
flow per project convention. ✅ done.
|
||||
`TTPsObservedSection.tsx` is the shared analyst-facing
|
||||
component (scope=`identity`|`attacker`); the Identity scope
|
||||
carries the Navigator export button. `RuleStateControls.tsx`
|
||||
is the admin-only operational panel — server-gated by
|
||||
`require_admin` AND client-gated on `/config?.role` so a
|
||||
non-admin never sees the controls. Wired into Config.tsx as
|
||||
a new "TTP RULES" admin tab. Empty state literal "NO
|
||||
TECHNIQUES OBSERVED YET" per the design doc — no spinner.
|
||||
`tsc --noEmit` + `vite build` clean.
|
||||
17. **Schemathesis pass** — full API fuzz including the new TTP
|
||||
routes. Document any new 4xx codes per the project's
|
||||
"POST/PUT/PATCH 400" convention.
|
||||
|
||||
Reference in New Issue
Block a user