From e2c8b77546cc5d3cbcf2eed248df791f2ccd1d86 Mon Sep 17 00:00:00 2001 From: anti Date: Mon, 27 Apr 2026 13:27:14 -0400 Subject: [PATCH] feat(web): canary tokens page (under AUTOMATION) New /canary-tokens route, lazy-loaded and gated behind the existing auth flow. Wired into the AUTOMATION NavGroup beside Orchestrator and Persona Generation, using the Target icon. Two components: - CanaryTokens.tsx: list + filter (text + state), stats summary, Tokens / Blobs tab switcher, inline CreateModal + UploadModal. Alt+C opens the create modal (per feedback_linux_meta_key). Drag- drop blob upload, server-sniffed MIME drives the instrumenter. - CanaryTokenDrawer.tsx: per-token detail panel matching the MailDrawer.tsx visual format (right-side drawer, --bg/--border/ --dim/--text CSS vars, X close, focus trap + escape key, monospace metadata table, paginated callback history). Backdrop close uses target===currentTarget instead of stopPropagation on the panel (per feedback_react_stop_propagation_native_delegation). Preview button downloads the deterministically re-derived instrumented bytes; revoke button hits DELETE with a confirm prompt. Type-checks clean (npx tsc --noEmit). --- decnet_web/src/App.tsx | 6 + .../src/components/CanaryTokenDrawer.tsx | 315 ++++++++ decnet_web/src/components/CanaryTokens.tsx | 710 ++++++++++++++++++ decnet_web/src/components/Layout.tsx | 3 + 4 files changed, 1034 insertions(+) create mode 100644 decnet_web/src/components/CanaryTokenDrawer.tsx create mode 100644 decnet_web/src/components/CanaryTokens.tsx diff --git a/decnet_web/src/App.tsx b/decnet_web/src/App.tsx index 1efcb748..dfbb90c7 100644 --- a/decnet_web/src/App.tsx +++ b/decnet_web/src/App.tsx @@ -25,6 +25,10 @@ const Campaigns = lazy(() => import('./components/Campaigns')); const CampaignDetail = lazy(() => import('./components/CampaignDetail')); const Orchestrator = lazy(() => import('./components/Orchestrator')); const PersonaGeneration = lazy(() => import('./components/PersonaGeneration')); +const CanaryTokens = lazy(() => import('./components/CanaryTokens')); +const TopologyPersonaGeneration = lazy(() => + import('./components/PersonaGeneration').then((m) => ({ default: m.TopologyPersonaGeneration })), +); const Config = lazy(() => import('./components/Config')); const Bounty = lazy(() => import('./components/Bounty')); const Credentials = lazy(() => import('./components/Credentials')); @@ -125,6 +129,8 @@ const AuthedShell: React.FC = ({ onLogout, onSearch, searchQue } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/decnet_web/src/components/CanaryTokenDrawer.tsx b/decnet_web/src/components/CanaryTokenDrawer.tsx new file mode 100644 index 00000000..eccd86e6 --- /dev/null +++ b/decnet_web/src/components/CanaryTokenDrawer.tsx @@ -0,0 +1,315 @@ +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; + 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; + 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 }) => ( +
+
{label}
+
{value ?? }
+
+); + +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 = { + planted: '#00ff88', + revoked: 'var(--dim-color)', + failed: '#ff5555', +}; + +const KIND_LABEL: Record = { + http: 'HTTP CALLBACK', + dns: 'DNS CALLBACK', + aws_passive: 'AWS PASSIVE', +}; + +const CanaryTokenDrawer: React.FC = ({ token, onClose, onRevoked }) => { + const panelRef = useRef(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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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' + ? `/c/${token.callback_token}` + : token.kind === 'dns' + ? `${token.callback_token}.` + : '— (passive bait, no callback)'; + + return ( +
{ 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, + }} + > +
+
+
+
+ CANARY TOKEN · {token.decky_name} +
+
+ {token.placement_path} +
+
+ +
+ +
+ + {token.state.toUpperCase()} · {KIND_LABEL[token.kind]} · {token.trigger_count} {token.trigger_count === 1 ? 'hit' : 'hits'} + {token.state === 'failed' && token.last_error && · {token.last_error}} +
+ +
+ {previewable && ( + + )} + {token.state === 'planted' && ( + + )} +
+ {error && ( +
{error}
+ )} + +
+

+ METADATA +

+ {token.uuid}} /> + + + + {token.callback_token}} /> + {callbackUrl}} /> + + + + +
+ +
+

+ + CALLBACK HISTORY ({triggers.length}{triggers.length === 200 ? '+' : ''}) +

+ {loading &&
loading…
} + {!loading && triggers.length === 0 && ( +
+ No callbacks yet. The slug will start firing if the artifact gets exfiltrated and opened. +
+ )} +
+ {triggers.map((t) => ( +
+
+ {t.src_ip} + {fmt(t.occurred_at)} +
+ {t.user_agent && ( +
+ UA · {t.user_agent} +
+ )} + {t.request_path && ( +
+ HTTP · {t.request_path} +
+ )} + {t.dns_qname && ( +
+ DNS · {t.dns_qname} +
+ )} + {t.attacker_id && ( +
+ attacker · {t.attacker_id} +
+ )} +
+ ))} +
+
+
+
+ ); +}; + +export default CanaryTokenDrawer; diff --git a/decnet_web/src/components/CanaryTokens.tsx b/decnet_web/src/components/CanaryTokens.tsx new file mode 100644 index 00000000..d2fd8412 --- /dev/null +++ b/decnet_web/src/components/CanaryTokens.tsx @@ -0,0 +1,710 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { + Target, Plus, Upload, X, AlertTriangle, Search, +} from '../icons'; +import api from '../utils/api'; +import { useEscapeKey } from '../hooks/useEscapeKey'; +import { useFocusTrap } from '../hooks/useFocusTrap'; +import CanaryTokenDrawer, { CanaryTokenRow } from './CanaryTokenDrawer'; + +interface BlobRow { + uuid: string; + sha256: string; + filename: string; + content_type: string; + size_bytes: number; + uploaded_by: string; + uploaded_at: string; + token_count: number; +} + +const KNOWN_GENERATORS = [ + 'git_config', 'env_file', 'ssh_key', 'aws_creds', 'honeydoc', +] as const; +type GeneratorName = typeof KNOWN_GENERATORS[number]; + +const KIND_OPTIONS: Array<{ value: 'http' | 'dns' | 'aws_passive'; label: string }> = [ + { value: 'http', label: 'HTTP callback' }, + { value: 'dns', label: 'DNS callback' }, + { value: 'aws_passive', label: 'AWS passive (no callback)' }, +]; + +function extractError(err: unknown, fallback: string): string { + const e = err as { response?: { status?: number; data?: { detail?: string } } }; + if (e?.response?.data?.detail) return e.response.data.detail; + if (e?.response?.status === 403) return 'Insufficient permissions (admin only).'; + if (e?.response?.status === 401) return 'Session expired — please log in again.'; + return fallback; +} + +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())}`; +} + +function fmtBytes(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KiB`; + return `${(n / 1024 / 1024).toFixed(1)} MiB`; +} + +const STATE_COLOR = { + planted: '#00ff88', + revoked: 'var(--dim-color)', + failed: '#ff5555', +}; + +// ─── CREATE MODAL ────────────────────────────────────────────────────────── + +interface CreateModalProps { + blobs: BlobRow[]; + onClose: () => void; + onCreated: (token: CanaryTokenRow) => void; +} + +const CreateModal: React.FC = ({ blobs, onClose, onCreated }) => { + const panelRef = useRef(null); + useEscapeKey(onClose, true); + useFocusTrap(panelRef, true); + + const [decky, setDecky] = useState(''); + const [kind, setKind] = useState<'http' | 'dns' | 'aws_passive'>('http'); + const [path, setPath] = useState('/home/admin/.aws/credentials'); + const [source, setSource] = useState<'generator' | 'blob'>('generator'); + const [generator, setGenerator] = useState('aws_creds'); + const [blobUuid, setBlobUuid] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async () => { + setError(null); + if (!decky.trim()) return setError('decky_name required.'); + if (!path.trim().startsWith('/')) return setError('placement_path must be absolute.'); + if (source === 'blob' && !blobUuid) return setError('Pick a blob or switch to Generator.'); + setSubmitting(true); + try { + const body: Record = { + decky_name: decky.trim(), + kind, + placement_path: path.trim(), + }; + if (source === 'generator') body.generator = generator; + else body.blob_uuid = blobUuid; + const res = await api.post('/canary/tokens', body); + onCreated(res.data); + } catch (err) { + setError(extractError(err, 'Create failed.')); + } finally { + setSubmitting(false); + } + }; + + return ( +
{ if (e.target === e.currentTarget) onClose(); }} + style={{ + position: 'fixed', inset: 0, + backgroundColor: 'rgba(0,0,0,0.6)', + display: 'flex', justifyContent: 'center', alignItems: 'center', + zIndex: 1000, + }} + > +
+
+
NEW CANARY TOKEN
+ +
+ + + setDecky(e.target.value)} + placeholder="web1" + autoFocus + style={INPUT_STYLE} + /> + + + + + + + + setPath(e.target.value)} + placeholder="/home/admin/.aws/credentials" + style={{ ...INPUT_STYLE, fontFamily: 'monospace' }} + /> + + +
+ {(['generator', 'blob'] as const).map((s) => ( + + ))} +
+ + {source === 'generator' && ( + + + + )} + + {source === 'blob' && ( + + {blobs.length === 0 ? ( +
+ No blobs uploaded yet. Use "Upload artifact" on the main page first. +
+ ) : ( + + )} +
+ )} + + {error && ( +
{error}
+ )} + +
+ + +
+
+
+ ); +}; + +// ─── BLOB UPLOAD MODAL ───────────────────────────────────────────────────── + +interface UploadModalProps { + onClose: () => void; + onUploaded: (blob: BlobRow) => void; +} + +const UploadModal: React.FC = ({ onClose, onUploaded }) => { + const panelRef = useRef(null); + useEscapeKey(onClose, true); + useFocusTrap(panelRef, true); + + const [file, setFile] = useState(null); + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(null); + const [dragOver, setDragOver] = useState(false); + + const handleSubmit = async () => { + if (!file) return setError('Pick a file first.'); + setUploading(true); + setError(null); + try { + const fd = new FormData(); + fd.append('file', file); + const res = await api.post('/canary/blobs', fd, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + onUploaded(res.data); + } catch (err) { + setError(extractError(err, 'Upload failed.')); + } finally { + setUploading(false); + } + }; + + return ( +
{ if (e.target === e.currentTarget) onClose(); }} + style={{ + position: 'fixed', inset: 0, + backgroundColor: 'rgba(0,0,0,0.6)', + display: 'flex', justifyContent: 'center', alignItems: 'center', + zIndex: 1000, + }} + > +
+
+
UPLOAD CANARY ARTIFACT
+ +
+ +
{ e.preventDefault(); setDragOver(true); }} + onDragLeave={() => setDragOver(false)} + onDrop={(e) => { + e.preventDefault(); + setDragOver(false); + const f = e.dataTransfer.files?.[0]; + if (f) setFile(f); + }} + style={{ + border: `2px dashed ${dragOver ? 'var(--accent-color, #00ff88)' : 'var(--border-color, #30363d)'}`, + padding: '32px', + textAlign: 'center', + marginBottom: '16px', + cursor: 'pointer', + background: dragOver ? 'rgba(0, 255, 136, 0.05)' : 'transparent', + }} + onClick={() => document.getElementById('canary-blob-input')?.click()} + > + +
+ {file ? `${file.name} (${fmtBytes(file.size)})` : 'Drop a file here or click to browse'} +
+ {!file && ( +
+ DOCX · XLSX · PDF · HTML · PNG/JPEG · plain configs +
+ )} + setFile(e.target.files?.[0] || null)} + /> +
+ +
+ + DECNET injects the callback server-side; the original bytes stay on the master. +
+ + {error && ( +
{error}
+ )} + +
+ + +
+
+
+ ); +}; + +// ─── MAIN PAGE ───────────────────────────────────────────────────────────── + +const CanaryTokens: React.FC = () => { + const [tokens, setTokens] = useState([]); + const [blobs, setBlobs] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [tab, setTab] = useState<'tokens' | 'blobs'>('tokens'); + const [filter, setFilter] = useState(''); + const [stateFilter, setStateFilter] = useState<'all' | 'planted' | 'revoked' | 'failed'>('all'); + + const [showCreate, setShowCreate] = useState(false); + const [showUpload, setShowUpload] = useState(false); + const [drawerToken, setDrawerToken] = useState(null); + + const loadAll = async () => { + setLoading(true); + setError(null); + try { + const [t, b] = await Promise.all([ + api.get('/canary/tokens'), + api.get('/canary/blobs').catch(() => ({ data: { blobs: [] } })), // viewers can't list blobs + ]); + setTokens(t.data.tokens || []); + setBlobs(b.data.blobs || []); + } catch (err) { + setError(extractError(err, 'Failed to load canary tokens.')); + } finally { + setLoading(false); + } + }; + + useEffect(() => { loadAll(); }, []); + + // Alt+C — open the create modal (per feedback_linux_meta_key). + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.altKey && e.key.toLowerCase() === 'c' && !showCreate && !showUpload && !drawerToken) { + e.preventDefault(); + setShowCreate(true); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [showCreate, showUpload, drawerToken]); + + const visibleTokens = useMemo(() => { + return tokens.filter((t) => { + if (stateFilter !== 'all' && t.state !== stateFilter) return false; + if (!filter) return true; + const f = filter.toLowerCase(); + return ( + t.decky_name.toLowerCase().includes(f) || + t.placement_path.toLowerCase().includes(f) || + t.callback_token.toLowerCase().includes(f) || + (t.generator || '').toLowerCase().includes(f) || + (t.instrumenter || '').toLowerCase().includes(f) + ); + }); + }, [tokens, filter, stateFilter]); + + const counts = useMemo(() => { + const c = { planted: 0, revoked: 0, failed: 0, hits: 0 }; + for (const t of tokens) { + c[t.state] += 1; + c.hits += t.trigger_count; + } + return c; + }, [tokens]); + + const handleDeleteBlob = async (uuid: string) => { + if (!window.confirm('Delete this blob? Refused if any token still references it.')) return; + try { + await api.delete(`/canary/blobs/${encodeURIComponent(uuid)}`); + setBlobs((prev) => prev.filter((b) => b.uuid !== uuid)); + } catch (err) { + alert(extractError(err, 'Delete failed.')); + } + }; + + return ( +
+
+
+
AUTOMATION
+
+ CANARY TOKENS +
+
+
+ + +
+
+ +
+ + + + + +
+ +
+ {(['tokens', 'blobs'] as const).map((t) => ( + + ))} +
+ + {tab === 'tokens' && ( + <> +
+
+ + setFilter(e.target.value)} + placeholder="Filter by decky / path / slug / generator…" + style={{ ...INPUT_STYLE, paddingLeft: '32px', marginBottom: 0 }} + /> +
+ +
+ + {loading &&
loading…
} + {error &&
{error}
} + {!loading && visibleTokens.length === 0 && ( +
+ {tokens.length === 0 + ? 'No canary tokens yet. Click NEW TOKEN to plant one, or UPLOAD ARTIFACT to start with an operator-supplied document.' + : 'No tokens match the current filter.'} +
+ )} +
+ {visibleTokens.map((t) => ( + + ))} +
+ + )} + + {tab === 'blobs' && ( + <> + {blobs.length === 0 && ( +
+ No uploaded artifacts. Click UPLOAD ARTIFACT to add one. +
+ )} +
+ {blobs.map((b) => ( +
+ + {b.filename} + + {b.content_type} + {fmtBytes(b.size_bytes)} + {fmt(b.uploaded_at)} + +
+ ))} +
+ + )} + + {showCreate && ( + setShowCreate(false)} + onCreated={(t) => { + setTokens((prev) => [t, ...prev]); + setShowCreate(false); + }} + /> + )} + {showUpload && ( + setShowUpload(false)} + onUploaded={(b) => { + setBlobs((prev) => prev.some((x) => x.uuid === b.uuid) ? prev : [b, ...prev]); + setShowUpload(false); + }} + /> + )} + {drawerToken && ( + setDrawerToken(null)} + onRevoked={(uuid) => { + setTokens((prev) => prev.map((t) => + t.uuid === uuid ? { ...t, state: 'revoked' } : t, + )); + setDrawerToken(null); + }} + /> + )} +
+ ); +}; + +// ─── small style helpers ─────────────────────────────────────────────────── + +const INPUT_STYLE: React.CSSProperties = { + width: '100%', + padding: '8px 10px', + marginBottom: '12px', + background: 'rgba(255,255,255,0.03)', + border: '1px solid var(--border-color, #30363d)', + color: 'var(--text-color)', + fontSize: '0.85rem', +}; + +const BTN_PRIMARY: React.CSSProperties = { + padding: '8px 14px', + border: '1px solid var(--accent-color, #00ff88)', + background: 'var(--accent-color, #00ff88)', + color: 'var(--bg-color, #0d1117)', + cursor: 'pointer', + fontSize: '0.8rem', + textTransform: 'uppercase', + letterSpacing: '0.05em', + fontWeight: 'bold', +}; + +const BTN_GHOST: React.CSSProperties = { + padding: '8px 14px', + border: '1px solid var(--text-color)', + background: 'transparent', + color: 'var(--text-color)', + cursor: 'pointer', + fontSize: '0.8rem', + textTransform: 'uppercase', + letterSpacing: '0.05em', +}; + +const Field: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => ( +
+
+ {label.toUpperCase()} +
+ {children} +
+); + +const Stat: React.FC<{ label: string; value: number | string; color: string }> = ({ label, value, color }) => ( +
+
{label}
+
{value}
+
+); + +export default CanaryTokens; diff --git a/decnet_web/src/components/Layout.tsx b/decnet_web/src/components/Layout.tsx index 62a22866..6f3b2df2 100644 --- a/decnet_web/src/components/Layout.tsx +++ b/decnet_web/src/components/Layout.tsx @@ -4,6 +4,7 @@ import { Menu, X, Search, Activity, LayoutDashboard, Terminal, Settings, LogOut, Server, Archive, Package, Network, ChevronDown, ChevronRight, HardDrive, ShieldAlert, Bell, Webhook, Lock, Crosshair, Fingerprint, Zap, Cpu, Mail, + Target, } from '../icons'; import { prefetchRoute } from '../routePrefetch'; import './Layout.css'; @@ -35,6 +36,7 @@ const ROUTE_LABELS: Record = { '/campaigns': 'CAMPAIGNS', '/orchestrator': 'ORCHESTRATOR', '/persona-generation': 'PERSONA GENERATION', + '/canary-tokens': 'CANARY TOKENS', '/config': 'CONFIG', '/swarm-updates': 'REMOTE UPDATES', '/swarm/hosts': 'SWARM HOSTS', @@ -140,6 +142,7 @@ const Layout: React.FC = ({ } open={sidebarOpen}> } label="Orchestrator" open={sidebarOpen} indent /> } label="Persona Generation" open={sidebarOpen} indent /> + } label="Canary Tokens" open={sidebarOpen} indent /> } open={sidebarOpen}> } label="SWARM Hosts" open={sidebarOpen} indent />