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:
@@ -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<AuthedShellProps> = ({ onLogout, onSearch, searchQue
|
||||
<Route path="/live-logs" element={<LiveLogs />} />
|
||||
<Route path="/webhooks" element={<Webhooks />} />
|
||||
<Route path="/bounty" element={<Bounty />} />
|
||||
<Route path="/credentials" element={<Credentials />} />
|
||||
<Route path="/attackers" element={<Attackers />} />
|
||||
<Route path="/attackers/:id" element={<AttackerDetail />} />
|
||||
<Route path="/config" element={<Config />} />
|
||||
|
||||
274
decnet_web/src/components/Credentials.css
Normal file
274
decnet_web/src/components/Credentials.css
Normal 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); }
|
||||
231
decnet_web/src/components/Credentials.tsx
Normal file
231
decnet_web/src/components/Credentials.tsx
Normal 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;
|
||||
142
decnet_web/src/components/CredentialsInspector.tsx
Normal file
142
decnet_web/src/components/CredentialsInspector.tsx
Normal 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;
|
||||
@@ -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<string, string> = {
|
||||
'/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<LayoutProps> = ({
|
||||
/>
|
||||
</NavGroup>
|
||||
<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} />
|
||||
<NavGroup label="SWARM" icon={<Network size={20} />} open={sidebarOpen}>
|
||||
<NavItem to="/swarm/hosts" icon={<HardDrive size={18} />} label="SWARM Hosts" open={sidebarOpen} indent />
|
||||
|
||||
Reference in New Issue
Block a user