diff --git a/decnet_web/src/App.tsx b/decnet_web/src/App.tsx index 8e32bb09..c8f0757e 100644 --- a/decnet_web/src/App.tsx +++ b/decnet_web/src/App.tsx @@ -21,6 +21,7 @@ const Attackers = lazy(() => import('./components/Attackers')); const AttackerDetail = lazy(() => import('./components/AttackerDetail')); const Config = lazy(() => import('./components/Config')); const Bounty = lazy(() => import('./components/Bounty')); +const Credentials = lazy(() => import('./components/Credentials')); const RemoteUpdates = lazy(() => import('./components/RemoteUpdates')); const SwarmHosts = lazy(() => import('./components/SwarmHosts')); const MazeNET = lazy(() => import('./components/MazeNET/MazeNET')); @@ -109,6 +110,7 @@ const AuthedShell: React.FC = ({ onLogout, onSearch, searchQue } /> } /> } /> + } /> } /> } /> } /> diff --git a/decnet_web/src/components/Credentials.css b/decnet_web/src/components/Credentials.css new file mode 100644 index 00000000..911296b6 --- /dev/null +++ b/decnet_web/src/components/Credentials.css @@ -0,0 +1,274 @@ +.credentials-root { + display: flex; + flex-direction: column; + gap: 20px; +} + +/* Buttons scoped under root (mirrors DeckyFleet/LiveLogs pattern) */ +.credentials-root .btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 7px 14px; + font-family: inherit; + font-size: 0.78rem; + letter-spacing: 1.5px; + background: transparent; + border: 1px solid var(--matrix); + color: var(--matrix); + cursor: pointer; + transition: all 0.3s ease; +} +.credentials-root .btn:hover { background: var(--matrix); color: #000; box-shadow: var(--matrix-glow); } +.credentials-root .btn.violet { border-color: var(--violet); color: var(--violet); } +.credentials-root .btn.violet:hover { background: var(--violet); color: #000; box-shadow: var(--violet-glow); } +.credentials-root .btn.ghost { border-color: var(--border); color: var(--matrix); opacity: 0.7; } +.credentials-root .btn.ghost:hover { opacity: 1; border-color: var(--matrix); background: transparent; box-shadow: var(--matrix-glow); } +.credentials-root .btn:disabled { opacity: 0.3; cursor: not-allowed; } + +/* Header controls */ +.credentials-root .controls-row { + display: flex; + gap: 12px; + align-items: stretch; +} +.credentials-root .controls-row .search-container { flex: 1; max-width: none; } + +/* Segmented service filter */ +.credentials-root .seg-group { + display: flex; + border: 1px solid var(--border); + background: var(--panel); + flex-wrap: wrap; +} +.credentials-root .seg-group button { + padding: 8px 14px; + font-size: 0.68rem; + letter-spacing: 1.5px; + border: none; + border-right: 1px solid var(--border); + background: transparent; + color: rgba(0, 255, 65, 0.6); + cursor: pointer; + font-family: inherit; +} +.credentials-root .seg-group button:last-child { border-right: none; } +.credentials-root .seg-group button.active { + background: var(--violet-tint-10); + color: var(--violet); +} +.credentials-root .seg-group button:hover:not(.active) { color: var(--matrix); } + +/* Table row interactivity */ +.credentials-root .logs-table tr.clickable { cursor: pointer; } +.credentials-root .logs-table tr.clickable:hover { background: rgba(238, 130, 238, 0.04); } +.credentials-root .logs-table td .attacker-link { + text-decoration: underline dotted; + cursor: pointer; +} +.credentials-root .logs-table td .data-preview { + font-size: 0.74rem; + opacity: 0.7; + max-width: 400px; + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.credentials-root .logs-table td .secret-cell { + font-family: var(--font-mono); + font-size: 0.78rem; + color: var(--matrix); + max-width: 320px; + display: inline-block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: middle; +} +.credentials-root .logs-table td .secret-cell.hashed { + opacity: 0.7; + color: rgba(238, 130, 238, 0.85); +} +.credentials-root .logs-table td .principal-cell { + font-size: 0.8rem; +} +.credentials-root .logs-table td .attempt-pill { + font-size: 0.7rem; + padding: 2px 8px; + border: 1px solid var(--border); + letter-spacing: 1px; + opacity: 0.85; +} + +/* Empty state */ +.credentials-root .empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + padding: 50px 20px; + opacity: 0.45; +} +.credentials-root .empty-state .type-label { + font-size: 0.7rem; + letter-spacing: 2px; +} + +/* Pagination */ +.credentials-root .pager { display: flex; align-items: center; gap: 12px; font-size: 0.7rem; } +.credentials-root .pager button { + padding: 4px; + border: 1px solid var(--border); + background: transparent; + color: var(--matrix); + display: flex; + cursor: pointer; +} +.credentials-root .pager button:disabled { opacity: 0.3; cursor: not-allowed; } +.credentials-root .pager button:hover:not(:disabled) { border-color: var(--accent); } + +/* ── Drawer ────────────────────────────────────────────── */ +.credentials-drawer-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + justify-content: flex-end; + z-index: 1000; + animation: cd-fade 0.15s ease; +} +@keyframes cd-fade { from { opacity: 0; } to { opacity: 1; } } + +.credentials-drawer { + width: min(620px, 100%); + height: 100%; + background: var(--bg); + border-left: 1px solid var(--violet); + box-shadow: -12px 0 40px rgba(238, 130, 238, 0.1); + overflow-y: auto; + display: flex; + flex-direction: column; + animation: cd-slide 0.2s ease; +} +@keyframes cd-slide { from { transform: translateX(30px); opacity: 0.6; } to { transform: none; opacity: 1; } } + +.credentials-drawer .bd-head { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid var(--border); +} +.credentials-drawer .bd-head h3 { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 0.9rem; + letter-spacing: 3px; + color: var(--violet); + margin: 0; +} +.credentials-drawer .close-btn { + background: transparent; + border: 1px solid var(--border); + color: var(--matrix); + display: flex; + padding: 4px; + cursor: pointer; +} +.credentials-drawer .close-btn:hover { border-color: var(--accent); } + +.credentials-drawer .bd-body { + padding: 20px; + display: flex; + flex-direction: column; + gap: 20px; +} + +.credentials-drawer .kvs { + display: grid; + grid-template-columns: 130px 1fr; + gap: 10px 12px; + font-size: 0.8rem; +} +.credentials-drawer .kvs .k { + opacity: 0.55; + font-size: 0.7rem; + letter-spacing: 1.5px; +} +.credentials-drawer .kvs .v { word-break: break-all; } +.credentials-drawer .kvs .attacker-link { + text-decoration: underline dotted; + cursor: pointer; + color: var(--matrix); +} +.credentials-drawer .violet-accent { color: var(--violet); } + +.credentials-drawer .type-label { + font-size: 0.68rem; + letter-spacing: 2px; + opacity: 0.6; + margin-bottom: 8px; +} + +.credentials-drawer .code-block { + background: var(--panel); + border: 1px solid var(--border); + border-left: 2px solid var(--violet); + padding: 12px 14px; + font-family: var(--font-mono); + font-size: 0.78rem; + color: var(--matrix); + white-space: pre-wrap; + word-break: break-all; + margin: 0; + overflow-x: auto; +} +.credentials-drawer .code-block .ck { color: rgba(238, 130, 238, 0.9); } +.credentials-drawer .code-block .cs { color: var(--matrix); } + +.credentials-drawer .hash-row { + display: flex; + align-items: center; + gap: 8px; +} +.credentials-drawer .hash-row .hash-text { + font-family: var(--font-mono); + font-size: 0.72rem; + color: var(--matrix); + word-break: break-all; + flex: 1; +} +.credentials-drawer .icon-btn { + background: transparent; + border: 1px solid var(--border); + color: var(--matrix); + padding: 4px 6px; + display: inline-flex; + cursor: pointer; +} +.credentials-drawer .icon-btn:hover { border-color: var(--accent); } + +.credentials-drawer .bd-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.credentials-drawer .btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 7px 14px; + font-family: inherit; + font-size: 0.78rem; + letter-spacing: 1.5px; + background: transparent; + border: 1px solid var(--border); + color: var(--matrix); + cursor: pointer; + transition: all 0.3s ease; + opacity: 0.8; +} +.credentials-drawer .btn.ghost:hover { opacity: 1; border-color: var(--matrix); box-shadow: var(--matrix-glow); } diff --git a/decnet_web/src/components/Credentials.tsx b/decnet_web/src/components/Credentials.tsx new file mode 100644 index 00000000..cb95f8a3 --- /dev/null +++ b/decnet_web/src/components/Credentials.tsx @@ -0,0 +1,231 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { useSearchParams, useNavigate } from 'react-router-dom'; +import { + Lock, Search, ChevronLeft, ChevronRight, Filter, ChevronRight as ChevR, + Target, +} from '../icons'; +import api from '../utils/api'; +import CredentialsInspector from './CredentialsInspector'; +import type { CredentialEntry } from './CredentialsInspector'; +import EmptyState from './EmptyState/EmptyState'; +import { useFocusSearch } from '../hooks/useFocusSearch'; +import './Dashboard.css'; +import './Credentials.css'; + +const truncHash = (h: string | null | undefined, n = 12): string => + h ? `${h.slice(0, n)}…` : '—'; + +const Credentials: React.FC = () => { + const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); + const query = searchParams.get('q') || ''; + const serviceFilter = searchParams.get('service') || ''; + const page = parseInt(searchParams.get('page') || '1'); + + const [creds, setCreds] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [searchInput, setSearchInput] = useState(query); + const searchRef = useRef(null); + useFocusSearch(searchRef); + const [selected, setSelected] = useState(null); + + const limit = 50; + + const fetchCreds = async () => { + setLoading(true); + try { + const offset = (page - 1) * limit; + let url = `/credentials?limit=${limit}&offset=${offset}`; + if (query) url += `&search=${encodeURIComponent(query)}`; + if (serviceFilter) url += `&service=${encodeURIComponent(serviceFilter)}`; + const res = await api.get(url); + setCreds(res.data.data); + setTotal(res.data.total); + } catch (err) { + console.error('Failed to fetch credentials', err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { fetchCreds(); }, [query, serviceFilter, page]); + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + setSearchParams({ q: searchInput, service: serviceFilter, page: '1' }); + }; + const setPage = (p: number) => + setSearchParams({ q: query, service: serviceFilter, page: p.toString() }); + const setService = (s: string) => + setSearchParams({ q: query, service: s, page: '1' }); + + const totalPages = Math.max(1, Math.ceil(total / limit)); + + // Derive service chips dynamically from the visible page so the segment + // group reflects whatever services are actually capturing creds. + const services = useMemo(() => { + const set = new Set(); + creds.forEach(c => set.add(c.service)); + return Array.from(set).sort(); + }, [creds]); + + const plaintextCount = creds.filter(c => c.secret_kind === 'plaintext').length; + const hashedCount = creds.length - plaintextCount; + + return ( +
+
+
+
+ +

CREDENTIAL VAULT

+
+ + {total.toLocaleString()} CAPTURED · {plaintextCount} PLAINTEXT · {hashedCount} CHALLENGED + +
+
+ +
+
+ + setSearchInput(e.target.value)} + /> +
+
+ + {services.map(svc => ( + + ))} +
+
+ +
+
+
+ + {total.toLocaleString()} CREDENTIALS CAPTURED +
+
+
+ Page {page} of {totalPages} + + +
+
+
+ +
+ + + + + + + + + + + + + + + + {creds.length > 0 ? creds.map(c => { + const isPlain = c.secret_kind === 'plaintext'; + const secretText = isPlain + ? (c.secret_printable ?? '—') + : truncHash(c.secret_sha256, 16); + return ( + setSelected(c)}> + + + + + + + + + + + ); + }) : ( + + + + )} + +
LAST SEENDECKYSVCATTACKERPRINCIPALSECRETKINDHITS
+ {new Date(c.last_seen).toLocaleTimeString()} + {c.decky_name}{c.service} + { + e.stopPropagation(); + navigate(`/attackers?q=${encodeURIComponent(c.attacker_ip)}`); + }} + > + {c.attacker_ip} + + + {c.principal ?? } + + + {secretText} + + + + {c.secret_kind.toUpperCase()} + + + {c.attempt_count} + + +
+ +
+
+
+ + {selected && ( + setSelected(null)} + onSelectAttacker={(ip) => { + setSelected(null); + navigate(`/attackers?q=${encodeURIComponent(ip)}`); + }} + /> + )} +
+ ); +}; + +export default Credentials; diff --git a/decnet_web/src/components/CredentialsInspector.tsx b/decnet_web/src/components/CredentialsInspector.tsx new file mode 100644 index 00000000..90825845 --- /dev/null +++ b/decnet_web/src/components/CredentialsInspector.tsx @@ -0,0 +1,142 @@ +import React from 'react'; +import { X, Lock, Copy, Send, Ban } from '../icons'; +import { useToast } from './Toasts/useToast'; + +export interface CredentialEntry { + id: number; + attacker_ip: string; + decky_name: string; + service: string; + principal: string | null; + secret_kind: string; + secret_sha256: string; + secret_b64: string | null; + secret_printable: string | null; + outcome: string | null; + fields: any; + first_seen: string; + last_seen: string; + attempt_count: number; +} + +interface Props { + cred: CredentialEntry; + onClose: () => void; + onSelectAttacker: (ip: string) => void; +} + +const CredentialsInspector: React.FC = ({ cred, onClose, onSelectAttacker }) => { + const { push } = useToast(); + const isPlain = cred.secret_kind === 'plaintext'; + + const copy = async (text: string, label: string) => { + try { + await navigator.clipboard.writeText(text); + push({ text: `${label} COPIED`, tone: 'matrix', icon: 'copy' }); + } catch { + push({ text: 'CLIPBOARD BLOCKED', tone: 'alert', icon: 'alert-triangle' }); + } + }; + + const copyJson = () => copy(JSON.stringify(cred, null, 2), 'JSON'); + const stubMisp = () => push({ text: 'MISP NOT CONFIGURED', tone: 'violet', icon: 'info' }); + const stubBlocklist = () => push({ text: 'BLOCKLIST NOT WIRED', tone: 'violet', icon: 'info' }); + + return ( +
{ if (e.target === e.currentTarget) onClose(); }} + > +
+
+

+ + CREDENTIAL #{cred.id} +

+ +
+
+
+
SECRET KIND
+
+ + {cred.secret_kind.toUpperCase()} + +
+
OUTCOME
+
+ {cred.outcome + ? {cred.outcome.toUpperCase()} + : } +
+
DECKY
+
{cred.decky_name}
+
SERVICE
+
{cred.service}
+
PRINCIPAL
+
{cred.principal ?? }
+
ATTACKER
+
+ onSelectAttacker(cred.attacker_ip)} + > + {cred.attacker_ip} + +
+
ATTEMPTS
+
{cred.attempt_count}
+
FIRST SEEN
+
{new Date(cred.first_seen).toLocaleString()}
+
LAST SEEN
+
{new Date(cred.last_seen).toLocaleString()}
+
+ +
+
{isPlain ? 'PLAINTEXT SECRET' : 'OBSERVED RESPONSE'}
+
+              printable:{' '}
+              {cred.secret_printable ?? '—'}{'\n'}
+              b64:{' '}
+              {cred.secret_b64 ?? '—'}
+            
+
+ +
+
SECRET SHA-256
+
+ {cred.secret_sha256} + +
+
+ + {cred.fields && Object.keys(cred.fields || {}).length > 0 && ( +
+
SERVICE FIELDS
+
{JSON.stringify(cred.fields, null, 2)}
+
+ )} + +
+
EXPORT
+
+ + + +
+
+
+
+
+ ); +}; + +export default CredentialsInspector; diff --git a/decnet_web/src/components/Layout.tsx b/decnet_web/src/components/Layout.tsx index 27b4591e..d1df614f 100644 --- a/decnet_web/src/components/Layout.tsx +++ b/decnet_web/src/components/Layout.tsx @@ -3,7 +3,7 @@ import { NavLink, useLocation } from 'react-router-dom'; import { Menu, X, Search, Activity, LayoutDashboard, Terminal, Settings, LogOut, Server, Archive, Package, Network, ChevronDown, ChevronRight, HardDrive, - ShieldAlert, Bell, Webhook, + ShieldAlert, Bell, Webhook, Lock, } from '../icons'; import { prefetchRoute } from '../routePrefetch'; import './Layout.css'; @@ -29,6 +29,7 @@ const ROUTE_LABELS: Record = { '/live-logs': 'LIVE LOGS', '/webhooks': 'WEBHOOKS', '/bounty': 'BOUNTY', + '/credentials': 'CREDENTIALS', '/attackers': 'ATTACKERS', '/config': 'CONFIG', '/swarm-updates': 'REMOTE UPDATES', @@ -124,6 +125,7 @@ const Layout: React.FC = ({ /> } label="Bounty" open={sidebarOpen} /> + } label="Credentials" open={sidebarOpen} /> } label="Attackers" open={sidebarOpen} /> } open={sidebarOpen}> } label="SWARM Hosts" open={sidebarOpen} indent />