feat(ttp): split bash CMD evidence into structured uid/user/src/pwd/cmd rows
The inspector was dumping the whole `CMD uid=0 user=root src=… pwd=…
cmd=nmap -p- 192.168.1.0/24` syslog body into a single ``command_text``
blob. ANTI: "I'd like to separate the fields." Done — three layers
work together:
1. Collector session aggregator: new `_parse_cmd_msg` splits the bash
PROMPT_COMMAND msg into `{uid, user, src, pwd, command}`. The
session-ended envelope's per-command dict now carries the
structured fields, with `command_text` set to just the cmd= value
(preserving embedded whitespace — `nmap -p- 1.2.3.0/24` etc.).
2. Rule engine: per-source_kind auxiliary evidence list
(`_AUX_EVIDENCE_FIELDS`). For `command` events the engine
automatically promotes uid/user/src/pwd into the persisted
`evidence` dict on top of the rule's explicit `evidence_fields`.
Engine-controlled, not per-rule — adding a new aux field is one
line here, not a 30-rule YAML sweep, and rule authors can't
accidentally drop it.
3. TTPInspector frontend: evidence renders as a structured
`kvs` grid (UID / USER / SRC / PWD / CMD rows) instead of
pretty-printed JSON. Primary-order list keeps shell fields at
the top; everything else falls below alphabetically so unfamiliar
evidence shapes still surface predictably.
Tests:
- session_aggregator pins the structured-fields emit (uid/user/src/
pwd/command_text without "CMD" prefix, embedded whitespace
preserved).
- rule_engine_tagger pins the aux-field auto-promotion + the
no-`None`-leakage path when payload doesn't carry an aux key.
This commit is contained in:
@@ -131,6 +131,36 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ttp-evidence-kvs {
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
padding: 8px 10px;
|
||||
font-family: var(--mono, ui-monospace, monospace);
|
||||
font-size: 0.74rem;
|
||||
display: grid;
|
||||
grid-template-columns: 60px 1fr;
|
||||
column-gap: 12px;
|
||||
row-gap: 3px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ttp-evidence-k {
|
||||
color: var(--dim-color);
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.66rem;
|
||||
align-self: baseline;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.ttp-evidence-v {
|
||||
color: var(--matrix);
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.ttp-empty {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
|
||||
@@ -158,8 +158,63 @@ const TTPInspector: React.FC<Props> = ({
|
||||
);
|
||||
};
|
||||
|
||||
// Evidence keys we promote to the top of the per-card key/value
|
||||
// table for shell-command tags. Order matters — these render in
|
||||
// the listed order; everything else goes after, alphabetically.
|
||||
const _EVIDENCE_PRIMARY_ORDER = [
|
||||
'uid', 'user', 'src', 'pwd', 'cmd', 'command', 'command_text',
|
||||
];
|
||||
|
||||
const _EVIDENCE_LABEL: Record<string, string> = {
|
||||
uid: 'UID',
|
||||
user: 'USER',
|
||||
src: 'SRC',
|
||||
pwd: 'PWD',
|
||||
cmd: 'CMD',
|
||||
command: 'CMD',
|
||||
command_text: 'CMD',
|
||||
};
|
||||
|
||||
interface EvidenceRow {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
function flattenEvidence(evidence: Record<string, unknown>): EvidenceRow[] {
|
||||
const seen = new Set<string>();
|
||||
const rows: EvidenceRow[] = [];
|
||||
const stringify = (v: unknown): string => {
|
||||
if (v === null || v === undefined) return '—';
|
||||
if (typeof v === 'string') return v;
|
||||
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
|
||||
return JSON.stringify(v);
|
||||
};
|
||||
for (const k of _EVIDENCE_PRIMARY_ORDER) {
|
||||
if (k in evidence && !seen.has(k)) {
|
||||
seen.add(k);
|
||||
rows.push({
|
||||
key: k,
|
||||
label: _EVIDENCE_LABEL[k] ?? k.toUpperCase(),
|
||||
value: stringify(evidence[k]),
|
||||
});
|
||||
}
|
||||
}
|
||||
const remaining = Object.keys(evidence)
|
||||
.filter((k) => !seen.has(k))
|
||||
.sort();
|
||||
for (const k of remaining) {
|
||||
rows.push({
|
||||
key: k,
|
||||
label: _EVIDENCE_LABEL[k] ?? k.toUpperCase(),
|
||||
value: stringify(evidence[k]),
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
const TTPTagCard: React.FC<{ row: TTPTagDetailRow }> = ({ row }) => {
|
||||
const evidenceText = JSON.stringify(row.evidence ?? {}, null, 2);
|
||||
const evidenceRows = flattenEvidence(row.evidence ?? {});
|
||||
return (
|
||||
<div className="ttp-tag-card">
|
||||
<div className="ttp-card-head">
|
||||
@@ -186,7 +241,18 @@ const TTPTagCard: React.FC<{ row: TTPTagDetailRow }> = ({ row }) => {
|
||||
<div className="k">ATT&CK</div>
|
||||
<div className="v">{row.attack_release}</div>
|
||||
</div>
|
||||
<pre className="ttp-evidence">{evidenceText}</pre>
|
||||
{evidenceRows.length === 0 ? (
|
||||
<div className="ttp-empty" style={{ padding: '8px' }}>—</div>
|
||||
) : (
|
||||
<div className="ttp-evidence-kvs">
|
||||
{evidenceRows.map((r) => (
|
||||
<React.Fragment key={r.key}>
|
||||
<div className="ttp-evidence-k">{r.label}</div>
|
||||
<div className="ttp-evidence-v">{r.value}</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user