Files
DECNET/decnet_web/src/components/Credentials.tsx

488 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, RefreshCw,
} from '../icons';
import api from '../utils/api';
import CredentialsInspector from './CredentialsInspector';
import type { CredentialEntry } from './CredentialsInspector';
import CredentialReuseInspector from './CredentialReuseInspector';
import type { CredentialReuseRow } from './CredentialReuseInspector';
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 CREDS_LIMIT = 50;
const REUSE_LIMIT = 25;
const REUSE_MAP_CAP = 500;
type Tab = 'creds' | 'reuse';
const reuseKey = (sha: string, kind: string, principal: string | null): string =>
`${sha}|${kind}|${principal ?? ''}`;
const Credentials: React.FC = () => {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const query = searchParams.get('q') || '';
const serviceFilter = searchParams.get('service') || '';
const tab = (searchParams.get('tab') === 'reuse' ? 'reuse' : 'creds') as Tab;
const page = parseInt(searchParams.get('page') || '1');
const [creds, setCreds] = useState<CredentialEntry[]>([]);
const [credsTotal, setCredsTotal] = useState(0);
const [reuseRows, setReuseRows] = useState<CredentialReuseRow[]>([]);
const [reuseTotal, setReuseTotal] = useState(0);
const [reuseMap, setReuseMap] = useState<Map<string, { id: string; target_count: number }>>(new Map());
const [loading, setLoading] = useState(true);
const [searchInput, setSearchInput] = useState(query);
const searchRef = useRef<HTMLInputElement | null>(null);
useFocusSearch(searchRef);
const [selectedCred, setSelectedCred] = useState<CredentialEntry | null>(null);
const [selectedReuse, setSelectedReuse] = useState<CredentialReuseRow | null>(null);
const [refreshTick, setRefreshTick] = useState(0);
const [sortCol, setSortCol] = useState<string>('');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
// ── Fetch credentials (CREDS tab + always for badge totals)
useEffect(() => {
if (tab !== 'creds') return;
let cancelled = false;
(async () => {
setLoading(true);
try {
const offset = (page - 1) * CREDS_LIMIT;
let url = `/credentials?limit=${CREDS_LIMIT}&offset=${offset}`;
if (query) url += `&search=${encodeURIComponent(query)}`;
if (serviceFilter) url += `&service=${encodeURIComponent(serviceFilter)}`;
const res = await api.get(url);
if (cancelled) return;
setCreds(res.data.data);
setCredsTotal(res.data.total);
} catch (err) {
console.error('Failed to fetch credentials', err);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [tab, query, serviceFilter, page, refreshTick]);
// ── Fetch reuse rows (REUSE tab)
useEffect(() => {
if (tab !== 'reuse') return;
let cancelled = false;
(async () => {
setLoading(true);
try {
const offset = (page - 1) * REUSE_LIMIT;
const res = await api.get(`/credential-reuse?limit=${REUSE_LIMIT}&offset=${offset}`);
if (cancelled) return;
setReuseRows(res.data.data);
setReuseTotal(res.data.total);
} catch (err) {
console.error('Failed to fetch credential-reuse', err);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [tab, page, refreshTick]);
// ── Build reuse-map for the badge column on the CREDS tab
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await api.get(`/credential-reuse?limit=${REUSE_MAP_CAP}&offset=0`);
if (cancelled) return;
const m = new Map<string, { id: string; target_count: number }>();
(res.data.data as CredentialReuseRow[]).forEach(r => {
m.set(reuseKey(r.secret_sha256, r.secret_kind, r.principal), {
id: r.id,
target_count: r.target_count,
});
});
setReuseMap(m);
} catch {
/* badge column degrades silently to "—" */
}
})();
return () => { cancelled = true; };
}, [refreshTick]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setSearchParams({ q: searchInput, service: serviceFilter, tab, page: '1' });
};
const setPage = (p: number) =>
setSearchParams({ q: query, service: serviceFilter, tab, page: p.toString() });
const setService = (s: string) =>
setSearchParams({ q: query, service: s, tab, page: '1' });
const setTab = (t: Tab) =>
setSearchParams({ q: query, service: serviceFilter, tab: t, page: '1' });
const limit = tab === 'creds' ? CREDS_LIMIT : REUSE_LIMIT;
const total = tab === 'creds' ? credsTotal : reuseTotal;
const totalPages = Math.max(1, Math.ceil(total / limit));
// Service chips derived from visible creds page
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;
const handleSortCol = (col: string) => {
if (sortCol === col) {
if (sortDir === 'asc') setSortDir('desc');
else { setSortCol(''); setSortDir('asc'); }
} else {
setSortCol(col);
setSortDir('asc');
}
};
const sortedCreds = useMemo(() => {
if (!sortCol) return creds;
return [...creds].sort((a, b) => {
let av: string | number = '';
let bv: string | number = '';
if (sortCol === 'seen') { av = a.last_seen; bv = b.last_seen; }
else if (sortCol === 'decky') { av = a.decky_name; bv = b.decky_name; }
else if (sortCol === 'svc') { av = a.service; bv = b.service; }
else if (sortCol === 'attacker') { av = a.attacker_ip; bv = b.attacker_ip; }
else if (sortCol === 'principal') { av = a.principal ?? ''; bv = b.principal ?? ''; }
else if (sortCol === 'kind') { av = a.secret_kind; bv = b.secret_kind; }
else if (sortCol === 'hits') { av = a.attempt_count; bv = b.attempt_count; }
const cmp = typeof av === 'number' && typeof bv === 'number'
? av - bv
: String(av).localeCompare(String(bv));
return sortDir === 'asc' ? cmp : -cmp;
});
}, [creds, sortCol, sortDir]);
const sortedReuseRows = useMemo(() => {
if (!sortCol) return reuseRows;
return [...reuseRows].sort((a, b) => {
let av: string | number = '';
let bv: string | number = '';
if (sortCol === 'seen') { av = a.last_seen; bv = b.last_seen; }
else if (sortCol === 'principal') { av = a.principal ?? ''; bv = b.principal ?? ''; }
else if (sortCol === 'kind') { av = a.secret_kind; bv = b.secret_kind; }
else if (sortCol === 'targets') { av = a.target_count; bv = b.target_count; }
else if (sortCol === 'attempts') { av = a.attempt_count; bv = b.attempt_count; }
const cmp = typeof av === 'number' && typeof bv === 'number'
? av - bv
: String(av).localeCompare(String(bv));
return sortDir === 'asc' ? cmp : -cmp;
});
}, [reuseRows, sortCol, sortDir]);
const SortTh: React.FC<{ col: string; children: React.ReactNode }> = ({ col, children }) => (
<th
style={{ cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap' }}
onClick={() => handleSortCol(col)}
>
{children}
{sortCol === col ? (sortDir === 'asc' ? ' ▲' : ' ▼') : ''}
</th>
);
const openReuseFromCred = async (key: string) => {
const hit = reuseMap.get(key);
if (!hit) return;
try {
const res = await api.get(`/credential-reuse/${hit.id}`);
setSelectedReuse(res.data as CredentialReuseRow);
} catch (err) {
console.error('Failed to fetch reuse detail', err);
}
};
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">
{tab === 'creds'
? `${credsTotal.toLocaleString()} CAPTURED · ${plaintextCount} PLAINTEXT · ${hashedCount} CHALLENGED`
: `${reuseTotal.toLocaleString()} REUSE FINDINGS`}
</span>
</div>
</div>
{tab === 'creds' && (
<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="seg-group" role="tablist" style={{ marginBottom: 12 }}>
<button
type="button"
className={tab === 'creds' ? 'active' : ''}
onClick={() => setTab('creds')}
>
CREDS
</button>
<button
type="button"
className={tab === 'reuse' ? 'active' : ''}
onClick={() => setTab('reuse')}
>
REUSE
</button>
</div>
<div className="logs-section">
<div className="section-header">
<div className="section-title">
<Filter size={14} />
<span>
{tab === 'creds'
? `${credsTotal.toLocaleString()} CREDENTIALS CAPTURED`
: `${reuseTotal.toLocaleString()} REUSE FINDINGS`}
</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>
<button onClick={() => setRefreshTick(t => t + 1)} aria-label="Refresh">
<RefreshCw size={14} />
</button>
</div>
</div>
</div>
<div className="logs-table-container">
{tab === 'creds' ? (
<table className="logs-table">
<thead>
<tr>
<SortTh col="seen">LAST SEEN</SortTh>
<SortTh col="decky">DECKY</SortTh>
<SortTh col="svc">SVC</SortTh>
<SortTh col="attacker">ATTACKER</SortTh>
<SortTh col="principal">PRINCIPAL</SortTh>
<th>SECRET</th>
<SortTh col="kind">KIND</SortTh>
<SortTh col="hits">HITS</SortTh>
<th>REUSE</th>
<th></th>
</tr>
</thead>
<tbody>
{sortedCreds.length > 0 ? sortedCreds.map(c => {
const isPlain = c.secret_kind === 'plaintext';
const secretText = isPlain
? (c.secret_printable ?? '—')
: truncHash(c.secret_sha256, 16);
const key = reuseKey(c.secret_sha256, c.secret_kind, c.principal);
const reuseHit = reuseMap.get(key);
return (
<tr key={c.id} className="clickable" onClick={() => setSelectedCred(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>
{reuseHit ? (
<span
className="attempt-pill"
style={{ cursor: 'pointer', color: 'var(--violet)' }}
title="Open reuse finding"
onClick={(e) => {
e.stopPropagation();
openReuseFromCred(key);
}}
>
×{reuseHit.target_count}
</span>
) : (
<span className="dim"></span>
)}
</td>
<td style={{ textAlign: 'right', opacity: 0.4 }}>
<ChevR size={14} />
</td>
</tr>
);
}) : (
<tr>
<td colSpan={10}>
<EmptyState
icon={Target}
title={loading ? 'RETRIEVING CREDENTIALS…' : 'NO CREDENTIALS YET'}
hint={loading ? undefined : 'captured auth attempts will land here'}
/>
</td>
</tr>
)}
</tbody>
</table>
) : (
<table className="logs-table">
<thead>
<tr>
<SortTh col="seen">LAST SEEN</SortTh>
<SortTh col="principal">PRINCIPAL</SortTh>
<SortTh col="kind">KIND</SortTh>
<SortTh col="targets">TARGETS</SortTh>
<SortTh col="attempts">ATTEMPTS</SortTh>
<th>DECKIES</th>
<th>SERVICES</th>
<th></th>
</tr>
</thead>
<tbody>
{sortedReuseRows.length > 0 ? sortedReuseRows.map(r => {
const isPlain = r.secret_kind === 'plaintext';
const moreDeckies = Math.max(0, r.deckies.length - 3);
const moreServices = Math.max(0, r.services.length - 3);
return (
<tr key={r.id} className="clickable" onClick={() => setSelectedReuse(r)}>
<td className="dim" style={{ fontSize: '0.72rem', whiteSpace: 'nowrap' }}>
{new Date(r.last_seen).toLocaleTimeString()}
</td>
<td className="principal-cell">
{r.principal ?? <span className="dim"></span>}
</td>
<td>
<span className={`chip ${isPlain ? 'matrix' : 'violet'}`}>
{r.secret_kind.toUpperCase()}
</span>
</td>
<td><span className="attempt-pill">{r.target_count}</span></td>
<td><span className="attempt-pill">{r.attempt_count}</span></td>
<td>
{r.deckies.slice(0, 3).map(d => (
<span key={d} className="chip dim-chip" style={{ marginRight: 4 }}>{d}</span>
))}
{moreDeckies > 0 && <span className="dim">+{moreDeckies}</span>}
</td>
<td>
{r.services.slice(0, 3).map(s => (
<span key={s} className="chip dim-chip" style={{ marginRight: 4 }}>{s}</span>
))}
{moreServices > 0 && <span className="dim">+{moreServices}</span>}
</td>
<td style={{ textAlign: 'right', opacity: 0.4 }}>
<ChevR size={14} />
</td>
</tr>
);
}) : (
<tr>
<td colSpan={8}>
<EmptyState
icon={Target}
title={loading ? 'RETRIEVING REUSE…' : 'NO REUSE FINDINGS YET'}
hint={loading ? undefined : 'a credential captured on ≥2 deckies will land here'}
/>
</td>
</tr>
)}
</tbody>
</table>
)}
</div>
</div>
{selectedCred && (
<CredentialsInspector
cred={selectedCred}
onClose={() => setSelectedCred(null)}
onSelectAttacker={(ip) => {
setSelectedCred(null);
navigate(`/attackers?q=${encodeURIComponent(ip)}`);
}}
/>
)}
{selectedReuse && (
<CredentialReuseInspector
row={selectedReuse}
onClose={() => setSelectedReuse(null)}
/>
)}
</div>
);
};
export default Credentials;