Files
DECNET/decnet_web/src/components/CanaryTokenDrawer.tsx
anti c942d4d333 feat(ui): scope canary tokens to MazeNET topology deckies
CanaryTokens.tsx grows a Fleet/MazeNET toggle in the create modal.  In
topology mode we hydrate /topologies?status=active for the topology
picker, then GET /topologies/{id} on selection to repopulate the decky
picker — topology deckies have a different shape than fleet's /deckies
endpoint.

The tokens table gains a SCOPE column (chip: 'fleet' / 'topology'),
and a third filter dropdown alongside state.  The drawer's metadata
section shows a Scope row with a clickable jump-link back to the
MazeNET view at the right topology.

CanaryTokenRow grows a topology_id field so the drawer/list can
discriminate without re-fetching.
2026-04-28 23:04:13 -04:00

333 lines
13 KiB
TypeScript

import React, { useEffect, useRef, useState } from 'react';
import { X, Download, AlertTriangle, Trash2, Eye } from '../icons';
import api from '../utils/api';
import { useEscapeKey } from '../hooks/useEscapeKey';
import { useFocusTrap } from '../hooks/useFocusTrap';
export interface CanaryTokenRow {
uuid: string;
kind: 'http' | 'dns' | 'aws_passive';
decky_name: string;
// Set when the token targets a MazeNET topology decky. Null/absent
// for fleet tokens. Drives the "scope" badge in the list and the
// topology jump-link in the drawer.
topology_id: string | null;
blob_uuid: string | null;
instrumenter: string | null;
generator: string | null;
placement_path: string;
callback_token: string;
placed_at: string;
last_triggered_at: string | null;
trigger_count: number;
created_by: string;
state: 'planted' | 'revoked' | 'failed';
last_error: string | null;
}
interface CanaryTrigger {
uuid: string;
token_uuid: string;
occurred_at: string;
src_ip: string;
user_agent: string | null;
request_path: string | null;
dns_qname: string | null;
headers: Record<string, string>;
attacker_id: string | null;
}
interface Props {
token: CanaryTokenRow;
onClose: () => void;
onRevoked: (uuid: string) => void;
}
const Row: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => (
<div style={{ display: 'flex', gap: '12px', padding: '6px 0', borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
<div style={{ minWidth: '140px', color: 'var(--dim-color)', fontSize: '0.75rem', textTransform: 'uppercase' }}>{label}</div>
<div style={{ flex: 1, fontSize: '0.85rem', wordBreak: 'break-all' }}>{value ?? <span style={{ opacity: 0.4 }}></span>}</div>
</div>
);
function fmt(iso: string | null): string {
if (!iso) return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
const STATE_COLOR: Record<CanaryTokenRow['state'], string> = {
planted: '#00ff88',
revoked: 'var(--dim-color)',
failed: '#ff5555',
};
const KIND_LABEL: Record<CanaryTokenRow['kind'], string> = {
http: 'HTTP CALLBACK',
dns: 'DNS CALLBACK',
aws_passive: 'AWS PASSIVE',
};
const CanaryTokenDrawer: React.FC<Props> = ({ token, onClose, onRevoked }) => {
const panelRef = useRef<HTMLDivElement | null>(null);
useEscapeKey(onClose, true);
useFocusTrap(panelRef, true);
useEffect(() => {
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}, []);
const [triggers, setTriggers] = useState<CanaryTrigger[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [downloading, setDownloading] = useState(false);
const [revoking, setRevoking] = useState(false);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
api.get(`/canary/tokens/${encodeURIComponent(token.uuid)}/triggers?limit=200`)
.then((res) => { if (!cancelled) setTriggers(res.data.triggers || []); })
.catch((err) => {
if (cancelled) return;
const status = err?.response?.status;
setError(
status === 403 ? 'Viewer role required.' :
status === 404 ? 'Token has been deleted.' :
'Failed to load triggers.'
);
})
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [token.uuid]);
const handleDownloadPreview = async () => {
setDownloading(true);
setError(null);
try {
const res = await api.get(
`/canary/tokens/${encodeURIComponent(token.uuid)}/preview`,
{ responseType: 'blob' },
);
const blobUrl = URL.createObjectURL(res.data);
const a = document.createElement('a');
a.href = blobUrl;
a.download = token.placement_path.split('/').pop() || `canary-${token.callback_token}.bin`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(blobUrl);
} catch (err: any) {
const status = err?.response?.status;
setError(
status === 403 ? 'Admin role required to preview.' :
status === 409 ? 'Token has no preview-able bytes (passive aws_creds, or blob deleted).' :
'Preview failed.'
);
} finally {
setDownloading(false);
}
};
const handleRevoke = async () => {
if (!window.confirm(`Revoke canary token on ${token.decky_name}? This unlinks the file and stops the slug from resolving.`)) return;
setRevoking(true);
setError(null);
try {
await api.delete(`/canary/tokens/${encodeURIComponent(token.uuid)}`);
onRevoked(token.uuid);
} catch (err: any) {
const status = err?.response?.status;
setError(
status === 403 ? 'Admin role required to revoke.' :
status === 404 ? 'Token already gone.' :
'Revoke failed.'
);
} finally {
setRevoking(false);
}
};
const previewable = token.kind !== 'aws_passive';
const callbackUrl = token.kind === 'http'
? `<canary-host>/c/${token.callback_token}`
: token.kind === 'dns'
? `${token.callback_token}.<dns-zone>`
: '— (passive bait, no callback)';
return (
<div
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
style={{
position: 'fixed', inset: 0,
backgroundColor: 'rgba(0,0,0,0.6)',
display: 'flex', justifyContent: 'flex-end',
zIndex: 1000,
}}
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
style={{
width: 'min(640px, 100%)', height: '100%',
backgroundColor: 'var(--bg-color, #0d1117)',
borderLeft: '1px solid var(--border-color, #30363d)',
padding: '24px', overflowY: 'auto',
color: 'var(--text-color)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<div>
<div style={{ fontSize: '0.7rem', color: 'var(--dim-color)', letterSpacing: '0.1em' }}>
CANARY TOKEN · {token.decky_name}
</div>
<div style={{ fontSize: '1rem', fontWeight: 'bold', marginTop: '4px', wordBreak: 'break-all' }}>
{token.placement_path}
</div>
</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'var(--text-color)', cursor: 'pointer' }}>
<X size={20} />
</button>
</div>
<div style={{
display: 'flex', alignItems: 'center', gap: '8px',
padding: '8px 12px', marginBottom: '16px',
border: `1px solid ${STATE_COLOR[token.state]}33`,
backgroundColor: `${STATE_COLOR[token.state]}11`,
fontSize: '0.75rem', color: STATE_COLOR[token.state],
}}>
<AlertTriangle size={14} />
{token.state.toUpperCase()} · {KIND_LABEL[token.kind]} · {token.trigger_count} {token.trigger_count === 1 ? 'hit' : 'hits'}
{token.state === 'failed' && token.last_error && <span style={{ color: '#ff5555' }}>· {token.last_error}</span>}
</div>
<div style={{ display: 'flex', gap: '8px', marginBottom: '20px', flexWrap: 'wrap' }}>
{previewable && (
<button
onClick={handleDownloadPreview}
disabled={downloading}
style={{
display: 'flex', alignItems: 'center', gap: '8px',
padding: '8px 14px',
border: '1px solid var(--text-color)',
background: 'transparent', color: 'var(--text-color)',
cursor: downloading ? 'wait' : 'pointer',
opacity: downloading ? 0.5 : 1,
}}
>
<Download size={14} /> {downloading ? 'DOWNLOADING…' : 'PREVIEW BYTES'}
</button>
)}
{token.state === 'planted' && (
<button
onClick={handleRevoke}
disabled={revoking}
style={{
display: 'flex', alignItems: 'center', gap: '8px',
padding: '8px 14px',
border: '1px solid #ff5555',
background: 'transparent', color: '#ff5555',
cursor: revoking ? 'wait' : 'pointer',
opacity: revoking ? 0.5 : 1,
}}
>
<Trash2 size={14} /> {revoking ? 'REVOKING…' : 'REVOKE'}
</button>
)}
</div>
{error && (
<div style={{ color: '#ff5555', fontSize: '0.8rem', marginBottom: '16px' }}>{error}</div>
)}
<section style={{ marginBottom: '24px' }}>
<h3 style={{ fontSize: '0.8rem', letterSpacing: '0.1em', color: 'var(--dim-color)', marginBottom: '8px' }}>
METADATA
</h3>
<Row label="UUID" value={<code>{token.uuid}</code>} />
<Row label="Decky" value={token.decky_name} />
<Row
label="Scope"
value={token.topology_id ? (
<a
href={`/mazenet?topology=${encodeURIComponent(token.topology_id)}`}
style={{ color: 'var(--accent-color, #00ff88)' }}
>
topology · {token.topology_id.slice(0, 8)}
</a>
) : (
<span style={{ opacity: 0.6 }}>fleet</span>
)}
/>
<Row label="Kind" value={KIND_LABEL[token.kind]} />
<Row label="Source" value={token.generator ? `generator: ${token.generator}` : token.instrumenter ? `instrumenter: ${token.instrumenter}` : '—'} />
<Row label="Slug" value={<code>{token.callback_token}</code>} />
<Row label="Callback" value={<code>{callbackUrl}</code>} />
<Row label="Placed at" value={fmt(token.placed_at)} />
<Row label="Last hit" value={fmt(token.last_triggered_at)} />
<Row label="Trigger count" value={token.trigger_count} />
<Row label="Created by" value={token.created_by} />
</section>
<section>
<h3 style={{ fontSize: '0.8rem', letterSpacing: '0.1em', color: 'var(--dim-color)', marginBottom: '8px' }}>
<Eye size={14} style={{ verticalAlign: 'middle', marginRight: '6px' }} />
CALLBACK HISTORY ({triggers.length}{triggers.length === 200 ? '+' : ''})
</h3>
{loading && <div style={{ fontSize: '0.8rem', opacity: 0.6 }}>loading</div>}
{!loading && triggers.length === 0 && (
<div style={{ fontSize: '0.8rem', opacity: 0.6 }}>
No callbacks yet. The slug will start firing if the artifact gets exfiltrated and opened.
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{triggers.map((t) => (
<div
key={t.uuid}
style={{
padding: '8px 12px',
border: '1px solid rgba(255,255,255,0.08)',
background: 'rgba(255,255,255,0.02)',
fontSize: '0.8rem',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '4px', fontFamily: 'monospace' }}>
<span>{t.src_ip}</span>
<span style={{ color: 'var(--dim-color)' }}>{fmt(t.occurred_at)}</span>
</div>
{t.user_agent && (
<div style={{ fontSize: '0.7rem', color: 'var(--dim-color)', fontFamily: 'monospace', wordBreak: 'break-all' }}>
UA · {t.user_agent}
</div>
)}
{t.request_path && (
<div style={{ fontSize: '0.7rem', color: 'var(--dim-color)', fontFamily: 'monospace', wordBreak: 'break-all' }}>
HTTP · {t.request_path}
</div>
)}
{t.dns_qname && (
<div style={{ fontSize: '0.7rem', color: 'var(--dim-color)', fontFamily: 'monospace', wordBreak: 'break-all' }}>
DNS · {t.dns_qname}
</div>
)}
{t.attacker_id && (
<div style={{ fontSize: '0.7rem', color: '#00ff88', fontFamily: 'monospace', wordBreak: 'break-all' }}>
attacker · {t.attacker_id}
</div>
)}
</div>
))}
</div>
</section>
</div>
</div>
);
};
export default CanaryTokenDrawer;