feat(web): Credentials view + inspector

New /credentials page mirroring the Bounty Vault pattern: list view
with search, dynamic service segment chips, plaintext vs hashed
secret rendering, and an inspector drawer with copyable SHA-256 +
service-fields JSON. Sidebar entry uses the Lock icon to keep
Bounty's Archive/Key visual identity distinct.
This commit is contained in:
2026-04-25 07:51:31 -04:00
parent 4566146d50
commit 4ea4b0be53
5 changed files with 652 additions and 1 deletions

View File

@@ -21,6 +21,7 @@ const Attackers = lazy(() => import('./components/Attackers'));
const AttackerDetail = lazy(() => import('./components/AttackerDetail')); const AttackerDetail = lazy(() => import('./components/AttackerDetail'));
const Config = lazy(() => import('./components/Config')); const Config = lazy(() => import('./components/Config'));
const Bounty = lazy(() => import('./components/Bounty')); const Bounty = lazy(() => import('./components/Bounty'));
const Credentials = lazy(() => import('./components/Credentials'));
const RemoteUpdates = lazy(() => import('./components/RemoteUpdates')); const RemoteUpdates = lazy(() => import('./components/RemoteUpdates'));
const SwarmHosts = lazy(() => import('./components/SwarmHosts')); const SwarmHosts = lazy(() => import('./components/SwarmHosts'));
const MazeNET = lazy(() => import('./components/MazeNET/MazeNET')); const MazeNET = lazy(() => import('./components/MazeNET/MazeNET'));
@@ -109,6 +110,7 @@ const AuthedShell: React.FC<AuthedShellProps> = ({ onLogout, onSearch, searchQue
<Route path="/live-logs" element={<LiveLogs />} /> <Route path="/live-logs" element={<LiveLogs />} />
<Route path="/webhooks" element={<Webhooks />} /> <Route path="/webhooks" element={<Webhooks />} />
<Route path="/bounty" element={<Bounty />} /> <Route path="/bounty" element={<Bounty />} />
<Route path="/credentials" element={<Credentials />} />
<Route path="/attackers" element={<Attackers />} /> <Route path="/attackers" element={<Attackers />} />
<Route path="/attackers/:id" element={<AttackerDetail />} /> <Route path="/attackers/:id" element={<AttackerDetail />} />
<Route path="/config" element={<Config />} /> <Route path="/config" element={<Config />} />

View File

@@ -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); }

View File

@@ -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<CredentialEntry[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [searchInput, setSearchInput] = useState(query);
const searchRef = useRef<HTMLInputElement | null>(null);
useFocusSearch(searchRef);
const [selected, setSelected] = useState<CredentialEntry | null>(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<string>();
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 (
<div className="credentials-root">
<div className="page-header">
<div className="page-title-group">
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Lock size={22} className="violet-accent" />
<h1>CREDENTIAL VAULT</h1>
</div>
<span className="page-sub">
{total.toLocaleString()} CAPTURED · {plaintextCount} PLAINTEXT · {hashedCount} CHALLENGED
</span>
</div>
</div>
<form className="controls-row" onSubmit={handleSearch}>
<div className="search-container">
<Search size={14} className="search-icon" />
<input
ref={searchRef}
type="text"
placeholder="Filter by IP, decky, principal, secret..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
/>
</div>
<div className="seg-group" role="tablist">
<button
type="button"
className={serviceFilter === '' ? 'active' : ''}
onClick={() => setService('')}
>
ALL
</button>
{services.map(svc => (
<button
key={svc}
type="button"
className={serviceFilter === svc ? 'active' : ''}
onClick={() => setService(svc)}
>
{svc.toUpperCase()}
</button>
))}
</div>
</form>
<div className="logs-section">
<div className="section-header">
<div className="section-title">
<Filter size={14} />
<span>{total.toLocaleString()} CREDENTIALS CAPTURED</span>
</div>
<div className="section-actions">
<div className="pager">
<span className="dim">Page {page} of {totalPages}</span>
<button disabled={page <= 1} onClick={() => setPage(page - 1)} aria-label="Previous page">
<ChevronLeft size={14} />
</button>
<button disabled={page >= totalPages} onClick={() => setPage(page + 1)} aria-label="Next page">
<ChevronRight size={14} />
</button>
</div>
</div>
</div>
<div className="logs-table-container">
<table className="logs-table">
<thead>
<tr>
<th>LAST SEEN</th>
<th>DECKY</th>
<th>SVC</th>
<th>ATTACKER</th>
<th>PRINCIPAL</th>
<th>SECRET</th>
<th>KIND</th>
<th>HITS</th>
<th></th>
</tr>
</thead>
<tbody>
{creds.length > 0 ? creds.map(c => {
const isPlain = c.secret_kind === 'plaintext';
const secretText = isPlain
? (c.secret_printable ?? '—')
: truncHash(c.secret_sha256, 16);
return (
<tr key={c.id} className="clickable" onClick={() => setSelected(c)}>
<td className="dim" style={{ fontSize: '0.72rem', whiteSpace: 'nowrap' }}>
{new Date(c.last_seen).toLocaleTimeString()}
</td>
<td className="violet-accent">{c.decky_name}</td>
<td><span className="chip dim-chip">{c.service}</span></td>
<td>
<span
className="matrix-text attacker-link"
onClick={(e) => {
e.stopPropagation();
navigate(`/attackers?q=${encodeURIComponent(c.attacker_ip)}`);
}}
>
{c.attacker_ip}
</span>
</td>
<td className="principal-cell">
{c.principal ?? <span className="dim"></span>}
</td>
<td>
<span className={`secret-cell${isPlain ? '' : ' hashed'}`} title={secretText}>
{secretText}
</span>
</td>
<td>
<span className={`chip ${isPlain ? 'matrix' : 'violet'}`}>
{c.secret_kind.toUpperCase()}
</span>
</td>
<td>
<span className="attempt-pill">{c.attempt_count}</span>
</td>
<td style={{ textAlign: 'right', opacity: 0.4 }}>
<ChevR size={14} />
</td>
</tr>
);
}) : (
<tr>
<td colSpan={9}>
<EmptyState
icon={Target}
title={loading ? 'RETRIEVING CREDENTIALS…' : 'NO CREDENTIALS YET'}
hint={loading ? undefined : 'captured auth attempts will land here'}
/>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{selected && (
<CredentialsInspector
cred={selected}
onClose={() => setSelected(null)}
onSelectAttacker={(ip) => {
setSelected(null);
navigate(`/attackers?q=${encodeURIComponent(ip)}`);
}}
/>
)}
</div>
);
};
export default Credentials;

View File

@@ -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<Props> = ({ 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 (
<div
className="credentials-drawer-backdrop"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div className="credentials-drawer">
<div className="bd-head">
<h3>
<Lock size={14} />
<span>CREDENTIAL #{cred.id}</span>
</h3>
<button className="close-btn" onClick={onClose} aria-label="Close">
<X size={16} />
</button>
</div>
<div className="bd-body">
<div className="kvs">
<div className="k">SECRET KIND</div>
<div className="v">
<span className={`chip ${isPlain ? 'matrix' : 'violet'}`}>
{cred.secret_kind.toUpperCase()}
</span>
</div>
<div className="k">OUTCOME</div>
<div className="v">
{cred.outcome
? <span className="chip dim-chip">{cred.outcome.toUpperCase()}</span>
: <span className="dim"></span>}
</div>
<div className="k">DECKY</div>
<div className="v violet-accent">{cred.decky_name}</div>
<div className="k">SERVICE</div>
<div className="v"><span className="chip dim-chip">{cred.service}</span></div>
<div className="k">PRINCIPAL</div>
<div className="v">{cred.principal ?? <span className="dim"></span>}</div>
<div className="k">ATTACKER</div>
<div className="v">
<span
className="attacker-link"
onClick={() => onSelectAttacker(cred.attacker_ip)}
>
{cred.attacker_ip}
</span>
</div>
<div className="k">ATTEMPTS</div>
<div className="v">{cred.attempt_count}</div>
<div className="k">FIRST SEEN</div>
<div className="v">{new Date(cred.first_seen).toLocaleString()}</div>
<div className="k">LAST SEEN</div>
<div className="v">{new Date(cred.last_seen).toLocaleString()}</div>
</div>
<div>
<div className="type-label">{isPlain ? 'PLAINTEXT SECRET' : 'OBSERVED RESPONSE'}</div>
<pre className="code-block">
<span className="ck">printable:</span>{' '}
<span className="cs">{cred.secret_printable ?? '—'}</span>{'\n'}
<span className="ck">b64:</span>{' '}
<span className="cs">{cred.secret_b64 ?? '—'}</span>
</pre>
</div>
<div>
<div className="type-label">SECRET SHA-256</div>
<div className="hash-row">
<span className="hash-text">{cred.secret_sha256}</span>
<button
className="icon-btn"
onClick={() => copy(cred.secret_sha256, 'HASH')}
aria-label="Copy hash"
>
<Copy size={12} />
</button>
</div>
</div>
{cred.fields && Object.keys(cred.fields || {}).length > 0 && (
<div>
<div className="type-label">SERVICE FIELDS</div>
<pre className="code-block">{JSON.stringify(cred.fields, null, 2)}</pre>
</div>
)}
<div>
<div className="type-label">EXPORT</div>
<div className="bd-actions">
<button className="btn ghost" onClick={copyJson}><Copy size={12} /> COPY JSON</button>
<button className="btn ghost" onClick={stubMisp}><Send size={12} /> SEND TO MISP</button>
<button className="btn ghost" onClick={stubBlocklist}><Ban size={12} /> BLOCKLIST IP</button>
</div>
</div>
</div>
</div>
</div>
);
};
export default CredentialsInspector;

View File

@@ -3,7 +3,7 @@ import { NavLink, useLocation } from 'react-router-dom';
import { import {
Menu, X, Search, Activity, LayoutDashboard, Terminal, Settings, LogOut, Menu, X, Search, Activity, LayoutDashboard, Terminal, Settings, LogOut,
Server, Archive, Package, Network, ChevronDown, ChevronRight, HardDrive, Server, Archive, Package, Network, ChevronDown, ChevronRight, HardDrive,
ShieldAlert, Bell, Webhook, ShieldAlert, Bell, Webhook, Lock,
} from '../icons'; } from '../icons';
import { prefetchRoute } from '../routePrefetch'; import { prefetchRoute } from '../routePrefetch';
import './Layout.css'; import './Layout.css';
@@ -29,6 +29,7 @@ const ROUTE_LABELS: Record<string, string> = {
'/live-logs': 'LIVE LOGS', '/live-logs': 'LIVE LOGS',
'/webhooks': 'WEBHOOKS', '/webhooks': 'WEBHOOKS',
'/bounty': 'BOUNTY', '/bounty': 'BOUNTY',
'/credentials': 'CREDENTIALS',
'/attackers': 'ATTACKERS', '/attackers': 'ATTACKERS',
'/config': 'CONFIG', '/config': 'CONFIG',
'/swarm-updates': 'REMOTE UPDATES', '/swarm-updates': 'REMOTE UPDATES',
@@ -124,6 +125,7 @@ const Layout: React.FC<LayoutProps> = ({
/> />
</NavGroup> </NavGroup>
<NavItem to="/bounty" icon={<Archive size={20} />} label="Bounty" open={sidebarOpen} /> <NavItem to="/bounty" icon={<Archive size={20} />} label="Bounty" open={sidebarOpen} />
<NavItem to="/credentials" icon={<Lock size={20} />} label="Credentials" open={sidebarOpen} />
<NavItem to="/attackers" icon={<Activity size={20} />} label="Attackers" open={sidebarOpen} /> <NavItem to="/attackers" icon={<Activity size={20} />} label="Attackers" open={sidebarOpen} />
<NavGroup label="SWARM" icon={<Network size={20} />} open={sidebarOpen}> <NavGroup label="SWARM" icon={<Network size={20} />} open={sidebarOpen}>
<NavItem to="/swarm/hosts" icon={<HardDrive size={18} />} label="SWARM Hosts" open={sidebarOpen} indent /> <NavItem to="/swarm/hosts" icon={<HardDrive size={18} />} label="SWARM Hosts" open={sidebarOpen} indent />