feat(dashboard): credential reuse tab, drawer, and bidirectional badge
Adds a CREDS/REUSE tab segment on the Credential Vault page. The REUSE tab lists CredentialReuse rows (paginated 25 per page) ordered by target_count desc; row-click opens a drawer mirroring the credentials inspector with a deckies x services grid, attacker links, and a PROFILING PENDING placeholder when attacker_uuids has not been backfilled yet. The CREDS tab gains a REUSE column showing a clickable target-count badge for credentials whose (sha256, kind, principal) tuple matches a reuse row; clicking the badge fetches and opens that row's drawer. Section header gains a manual refresh button (no SSE/polling). Ticks the credential-reuse line in DEVELOPMENT.md and notes the vectorstore scaffold.
This commit is contained in:
158
decnet_web/src/components/CredentialReuseInspector.tsx
Normal file
158
decnet_web/src/components/CredentialReuseInspector.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { X, Lock, Copy, Check } from '../icons';
|
||||||
|
import { useToast } from './Toasts/useToast';
|
||||||
|
|
||||||
|
export interface CredentialReuseRow {
|
||||||
|
id: string;
|
||||||
|
secret_sha256: string;
|
||||||
|
secret_kind: string;
|
||||||
|
principal: string | null;
|
||||||
|
principal_key: string;
|
||||||
|
attacker_uuids: string[];
|
||||||
|
attacker_ips: string[];
|
||||||
|
deckies: string[];
|
||||||
|
services: string[];
|
||||||
|
target_count: number;
|
||||||
|
attempt_count: number;
|
||||||
|
confidence: number;
|
||||||
|
first_seen: string;
|
||||||
|
last_seen: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
row: CredentialReuseRow;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CredentialReuseInspector: React.FC<Props> = ({ row, onClose }) => {
|
||||||
|
const { push } = useToast();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isPlain = row.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' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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>REUSE #{row.id.slice(0, 8)}</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'}`}>
|
||||||
|
{row.secret_kind.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="k">PRINCIPAL</div>
|
||||||
|
<div className="v">{row.principal ?? <span className="dim">—</span>}</div>
|
||||||
|
<div className="k">TARGETS</div>
|
||||||
|
<div className="v"><span className="attempt-pill">{row.target_count}</span></div>
|
||||||
|
<div className="k">ATTEMPTS</div>
|
||||||
|
<div className="v">{row.attempt_count}</div>
|
||||||
|
<div className="k">CONFIDENCE</div>
|
||||||
|
<div className="v">{row.confidence.toFixed(2)}</div>
|
||||||
|
<div className="k">FIRST SEEN</div>
|
||||||
|
<div className="v">{new Date(row.first_seen).toLocaleString()}</div>
|
||||||
|
<div className="k">LAST SEEN</div>
|
||||||
|
<div className="v">{new Date(row.last_seen).toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="type-label">DECKIES × SERVICES</div>
|
||||||
|
<div className="logs-table-container">
|
||||||
|
<table className="logs-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th></th>
|
||||||
|
{row.services.map(svc => (
|
||||||
|
<th key={svc}>{svc.toUpperCase()}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{row.deckies.map(decky => (
|
||||||
|
<tr key={decky}>
|
||||||
|
<td className="violet-accent">{decky}</td>
|
||||||
|
{row.services.map(svc => (
|
||||||
|
<td key={svc} style={{ textAlign: 'center' }}>
|
||||||
|
<Check size={12} className="matrix-text" />
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="type-label">ATTACKERS</div>
|
||||||
|
{row.attacker_uuids.length === 0 ? (
|
||||||
|
<div className="dim" style={{ fontSize: '0.75rem', padding: '6px 0' }}>
|
||||||
|
PROFILING PENDING — credential captures precede attacker
|
||||||
|
profiling; this row will populate once the profiler runs.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
{row.attacker_uuids.map((uuid, i) => (
|
||||||
|
<div
|
||||||
|
key={uuid}
|
||||||
|
onClick={() => navigate(`/attackers/${uuid}`)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 8,
|
||||||
|
alignItems: 'baseline',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textDecoration: 'underline dotted',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="matrix-text">{uuid.slice(0, 8)}</span>
|
||||||
|
<span className="dim" style={{ fontSize: '0.72rem' }}>
|
||||||
|
{row.attacker_ips[i] ?? ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="type-label">SECRET SHA-256</div>
|
||||||
|
<div className="hash-row">
|
||||||
|
<span className="hash-text">{row.secret_sha256}</span>
|
||||||
|
<button
|
||||||
|
className="icon-btn"
|
||||||
|
onClick={() => copy(row.secret_sha256, 'HASH')}
|
||||||
|
aria-label="Copy hash"
|
||||||
|
>
|
||||||
|
<Copy size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CredentialReuseInspector;
|
||||||
@@ -2,11 +2,13 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Lock, Search, ChevronLeft, ChevronRight, Filter, ChevronRight as ChevR,
|
Lock, Search, ChevronLeft, ChevronRight, Filter, ChevronRight as ChevR,
|
||||||
Target,
|
Target, RefreshCw,
|
||||||
} from '../icons';
|
} from '../icons';
|
||||||
import api from '../utils/api';
|
import api from '../utils/api';
|
||||||
import CredentialsInspector from './CredentialsInspector';
|
import CredentialsInspector from './CredentialsInspector';
|
||||||
import type { CredentialEntry } from './CredentialsInspector';
|
import type { CredentialEntry } from './CredentialsInspector';
|
||||||
|
import CredentialReuseInspector from './CredentialReuseInspector';
|
||||||
|
import type { CredentialReuseRow } from './CredentialReuseInspector';
|
||||||
import EmptyState from './EmptyState/EmptyState';
|
import EmptyState from './EmptyState/EmptyState';
|
||||||
import { useFocusSearch } from '../hooks/useFocusSearch';
|
import { useFocusSearch } from '../hooks/useFocusSearch';
|
||||||
import './Dashboard.css';
|
import './Dashboard.css';
|
||||||
@@ -15,55 +17,119 @@ import './Credentials.css';
|
|||||||
const truncHash = (h: string | null | undefined, n = 12): string =>
|
const truncHash = (h: string | null | undefined, n = 12): string =>
|
||||||
h ? `${h.slice(0, n)}…` : '—';
|
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 Credentials: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const query = searchParams.get('q') || '';
|
const query = searchParams.get('q') || '';
|
||||||
const serviceFilter = searchParams.get('service') || '';
|
const serviceFilter = searchParams.get('service') || '';
|
||||||
|
const tab = (searchParams.get('tab') === 'reuse' ? 'reuse' : 'creds') as Tab;
|
||||||
const page = parseInt(searchParams.get('page') || '1');
|
const page = parseInt(searchParams.get('page') || '1');
|
||||||
|
|
||||||
const [creds, setCreds] = useState<CredentialEntry[]>([]);
|
const [creds, setCreds] = useState<CredentialEntry[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
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 [loading, setLoading] = useState(true);
|
||||||
const [searchInput, setSearchInput] = useState(query);
|
const [searchInput, setSearchInput] = useState(query);
|
||||||
const searchRef = useRef<HTMLInputElement | null>(null);
|
const searchRef = useRef<HTMLInputElement | null>(null);
|
||||||
useFocusSearch(searchRef);
|
useFocusSearch(searchRef);
|
||||||
const [selected, setSelected] = useState<CredentialEntry | null>(null);
|
const [selectedCred, setSelectedCred] = useState<CredentialEntry | null>(null);
|
||||||
|
const [selectedReuse, setSelectedReuse] = useState<CredentialReuseRow | null>(null);
|
||||||
|
const [refreshTick, setRefreshTick] = useState(0);
|
||||||
|
|
||||||
const limit = 50;
|
// ── 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]);
|
||||||
|
|
||||||
const fetchCreds = async () => {
|
// ── Fetch reuse rows (REUSE tab)
|
||||||
setLoading(true);
|
useEffect(() => {
|
||||||
try {
|
if (tab !== 'reuse') return;
|
||||||
const offset = (page - 1) * limit;
|
let cancelled = false;
|
||||||
let url = `/credentials?limit=${limit}&offset=${offset}`;
|
(async () => {
|
||||||
if (query) url += `&search=${encodeURIComponent(query)}`;
|
setLoading(true);
|
||||||
if (serviceFilter) url += `&service=${encodeURIComponent(serviceFilter)}`;
|
try {
|
||||||
const res = await api.get(url);
|
const offset = (page - 1) * REUSE_LIMIT;
|
||||||
setCreds(res.data.data);
|
const res = await api.get(`/credential-reuse?limit=${REUSE_LIMIT}&offset=${offset}`);
|
||||||
setTotal(res.data.total);
|
if (cancelled) return;
|
||||||
} catch (err) {
|
setReuseRows(res.data.data);
|
||||||
console.error('Failed to fetch credentials', err);
|
setReuseTotal(res.data.total);
|
||||||
} finally {
|
} catch (err) {
|
||||||
setLoading(false);
|
console.error('Failed to fetch credential-reuse', err);
|
||||||
}
|
} finally {
|
||||||
};
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [tab, page, refreshTick]);
|
||||||
|
|
||||||
useEffect(() => { fetchCreds(); }, [query, serviceFilter, page]);
|
// ── 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) => {
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSearchParams({ q: searchInput, service: serviceFilter, page: '1' });
|
setSearchParams({ q: searchInput, service: serviceFilter, tab, page: '1' });
|
||||||
};
|
};
|
||||||
const setPage = (p: number) =>
|
const setPage = (p: number) =>
|
||||||
setSearchParams({ q: query, service: serviceFilter, page: p.toString() });
|
setSearchParams({ q: query, service: serviceFilter, tab, page: p.toString() });
|
||||||
const setService = (s: string) =>
|
const setService = (s: string) =>
|
||||||
setSearchParams({ q: query, service: s, page: '1' });
|
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));
|
const totalPages = Math.max(1, Math.ceil(total / limit));
|
||||||
|
|
||||||
// Derive service chips dynamically from the visible page so the segment
|
// Service chips derived from visible creds page
|
||||||
// group reflects whatever services are actually capturing creds.
|
|
||||||
const services = useMemo(() => {
|
const services = useMemo(() => {
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
creds.forEach(c => set.add(c.service));
|
creds.forEach(c => set.add(c.service));
|
||||||
@@ -73,6 +139,17 @@ const Credentials: React.FC = () => {
|
|||||||
const plaintextCount = creds.filter(c => c.secret_kind === 'plaintext').length;
|
const plaintextCount = creds.filter(c => c.secret_kind === 'plaintext').length;
|
||||||
const hashedCount = creds.length - plaintextCount;
|
const hashedCount = creds.length - plaintextCount;
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="credentials-root">
|
<div className="credentials-root">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
@@ -82,48 +159,73 @@ const Credentials: React.FC = () => {
|
|||||||
<h1>CREDENTIAL VAULT</h1>
|
<h1>CREDENTIAL VAULT</h1>
|
||||||
</div>
|
</div>
|
||||||
<span className="page-sub">
|
<span className="page-sub">
|
||||||
{total.toLocaleString()} CAPTURED · {plaintextCount} PLAINTEXT · {hashedCount} CHALLENGED
|
{tab === 'creds'
|
||||||
|
? `${credsTotal.toLocaleString()} CAPTURED · ${plaintextCount} PLAINTEXT · ${hashedCount} CHALLENGED`
|
||||||
|
: `${reuseTotal.toLocaleString()} REUSE FINDINGS`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form className="controls-row" onSubmit={handleSearch}>
|
<div className="seg-group" role="tablist" style={{ marginBottom: 12 }}>
|
||||||
<div className="search-container">
|
<button
|
||||||
<Search size={14} className="search-icon" />
|
type="button"
|
||||||
<input
|
className={tab === 'creds' ? 'active' : ''}
|
||||||
ref={searchRef}
|
onClick={() => setTab('creds')}
|
||||||
type="text"
|
>
|
||||||
placeholder="Filter by IP, decky, principal, secret..."
|
CREDS
|
||||||
value={searchInput}
|
</button>
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
<button
|
||||||
/>
|
type="button"
|
||||||
</div>
|
className={tab === 'reuse' ? 'active' : ''}
|
||||||
<div className="seg-group" role="tablist">
|
onClick={() => setTab('reuse')}
|
||||||
<button
|
>
|
||||||
type="button"
|
REUSE
|
||||||
className={serviceFilter === '' ? 'active' : ''}
|
</button>
|
||||||
onClick={() => setService('')}
|
</div>
|
||||||
>
|
|
||||||
ALL
|
{tab === 'creds' && (
|
||||||
</button>
|
<form className="controls-row" onSubmit={handleSearch}>
|
||||||
{services.map(svc => (
|
<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
|
<button
|
||||||
key={svc}
|
|
||||||
type="button"
|
type="button"
|
||||||
className={serviceFilter === svc ? 'active' : ''}
|
className={serviceFilter === '' ? 'active' : ''}
|
||||||
onClick={() => setService(svc)}
|
onClick={() => setService('')}
|
||||||
>
|
>
|
||||||
{svc.toUpperCase()}
|
ALL
|
||||||
</button>
|
</button>
|
||||||
))}
|
{services.map(svc => (
|
||||||
</div>
|
<button
|
||||||
</form>
|
key={svc}
|
||||||
|
type="button"
|
||||||
|
className={serviceFilter === svc ? 'active' : ''}
|
||||||
|
onClick={() => setService(svc)}
|
||||||
|
>
|
||||||
|
{svc.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="logs-section">
|
<div className="logs-section">
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<div className="section-title">
|
<div className="section-title">
|
||||||
<Filter size={14} />
|
<Filter size={14} />
|
||||||
<span>{total.toLocaleString()} CREDENTIALS CAPTURED</span>
|
<span>
|
||||||
|
{tab === 'creds'
|
||||||
|
? `${credsTotal.toLocaleString()} CREDENTIALS CAPTURED`
|
||||||
|
: `${reuseTotal.toLocaleString()} REUSE FINDINGS`}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="section-actions">
|
<div className="section-actions">
|
||||||
<div className="pager">
|
<div className="pager">
|
||||||
@@ -134,96 +236,192 @@ const Credentials: React.FC = () => {
|
|||||||
<button disabled={page >= totalPages} onClick={() => setPage(page + 1)} aria-label="Next page">
|
<button disabled={page >= totalPages} onClick={() => setPage(page + 1)} aria-label="Next page">
|
||||||
<ChevronRight size={14} />
|
<ChevronRight size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
<button onClick={() => setRefreshTick(t => t + 1)} aria-label="Refresh">
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="logs-table-container">
|
<div className="logs-table-container">
|
||||||
<table className="logs-table">
|
{tab === 'creds' ? (
|
||||||
<thead>
|
<table className="logs-table">
|
||||||
<tr>
|
<thead>
|
||||||
<th>LAST SEEN</th>
|
<tr>
|
||||||
<th>DECKY</th>
|
<th>LAST SEEN</th>
|
||||||
<th>SVC</th>
|
<th>DECKY</th>
|
||||||
<th>ATTACKER</th>
|
<th>SVC</th>
|
||||||
<th>PRINCIPAL</th>
|
<th>ATTACKER</th>
|
||||||
<th>SECRET</th>
|
<th>PRINCIPAL</th>
|
||||||
<th>KIND</th>
|
<th>SECRET</th>
|
||||||
<th>HITS</th>
|
<th>KIND</th>
|
||||||
<th></th>
|
<th>HITS</th>
|
||||||
</tr>
|
<th>REUSE</th>
|
||||||
</thead>
|
<th></th>
|
||||||
<tbody>
|
</tr>
|
||||||
{creds.length > 0 ? creds.map(c => {
|
</thead>
|
||||||
const isPlain = c.secret_kind === 'plaintext';
|
<tbody>
|
||||||
const secretText = isPlain
|
{creds.length > 0 ? creds.map(c => {
|
||||||
? (c.secret_printable ?? '—')
|
const isPlain = c.secret_kind === 'plaintext';
|
||||||
: truncHash(c.secret_sha256, 16);
|
const secretText = isPlain
|
||||||
return (
|
? (c.secret_printable ?? '—')
|
||||||
<tr key={c.id} className="clickable" onClick={() => setSelected(c)}>
|
: truncHash(c.secret_sha256, 16);
|
||||||
<td className="dim" style={{ fontSize: '0.72rem', whiteSpace: 'nowrap' }}>
|
const key = reuseKey(c.secret_sha256, c.secret_kind, c.principal);
|
||||||
{new Date(c.last_seen).toLocaleTimeString()}
|
const reuseHit = reuseMap.get(key);
|
||||||
</td>
|
return (
|
||||||
<td className="violet-accent">{c.decky_name}</td>
|
<tr key={c.id} className="clickable" onClick={() => setSelectedCred(c)}>
|
||||||
<td><span className="chip dim-chip">{c.service}</span></td>
|
<td className="dim" style={{ fontSize: '0.72rem', whiteSpace: 'nowrap' }}>
|
||||||
<td>
|
{new Date(c.last_seen).toLocaleTimeString()}
|
||||||
<span
|
</td>
|
||||||
className="matrix-text attacker-link"
|
<td className="violet-accent">{c.decky_name}</td>
|
||||||
onClick={(e) => {
|
<td><span className="chip dim-chip">{c.service}</span></td>
|
||||||
e.stopPropagation();
|
<td>
|
||||||
navigate(`/attackers?q=${encodeURIComponent(c.attacker_ip)}`);
|
<span
|
||||||
}}
|
className="matrix-text attacker-link"
|
||||||
>
|
onClick={(e) => {
|
||||||
{c.attacker_ip}
|
e.stopPropagation();
|
||||||
</span>
|
navigate(`/attackers?q=${encodeURIComponent(c.attacker_ip)}`);
|
||||||
</td>
|
}}
|
||||||
<td className="principal-cell">
|
>
|
||||||
{c.principal ?? <span className="dim">—</span>}
|
{c.attacker_ip}
|
||||||
</td>
|
</span>
|
||||||
<td>
|
</td>
|
||||||
<span className={`secret-cell${isPlain ? '' : ' hashed'}`} title={secretText}>
|
<td className="principal-cell">
|
||||||
{secretText}
|
{c.principal ?? <span className="dim">—</span>}
|
||||||
</span>
|
</td>
|
||||||
</td>
|
<td>
|
||||||
<td>
|
<span className={`secret-cell${isPlain ? '' : ' hashed'}`} title={secretText}>
|
||||||
<span className={`chip ${isPlain ? 'matrix' : 'violet'}`}>
|
{secretText}
|
||||||
{c.secret_kind.toUpperCase()}
|
</span>
|
||||||
</span>
|
</td>
|
||||||
</td>
|
<td>
|
||||||
<td>
|
<span className={`chip ${isPlain ? 'matrix' : 'violet'}`}>
|
||||||
<span className="attempt-pill">{c.attempt_count}</span>
|
{c.secret_kind.toUpperCase()}
|
||||||
</td>
|
</span>
|
||||||
<td style={{ textAlign: 'right', opacity: 0.4 }}>
|
</td>
|
||||||
<ChevR size={14} />
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
)}
|
||||||
}) : (
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : (
|
||||||
|
<table className="logs-table">
|
||||||
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={9}>
|
<th>LAST SEEN</th>
|
||||||
<EmptyState
|
<th>PRINCIPAL</th>
|
||||||
icon={Target}
|
<th>KIND</th>
|
||||||
title={loading ? 'RETRIEVING CREDENTIALS…' : 'NO CREDENTIALS YET'}
|
<th>TARGETS</th>
|
||||||
hint={loading ? undefined : 'captured auth attempts will land here'}
|
<th>ATTEMPTS</th>
|
||||||
/>
|
<th>DECKIES</th>
|
||||||
</td>
|
<th>SERVICES</th>
|
||||||
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{reuseRows.length > 0 ? reuseRows.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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selected && (
|
{selectedCred && (
|
||||||
<CredentialsInspector
|
<CredentialsInspector
|
||||||
cred={selected}
|
cred={selectedCred}
|
||||||
onClose={() => setSelected(null)}
|
onClose={() => setSelectedCred(null)}
|
||||||
onSelectAttacker={(ip) => {
|
onSelectAttacker={(ip) => {
|
||||||
setSelected(null);
|
setSelectedCred(null);
|
||||||
navigate(`/attackers?q=${encodeURIComponent(ip)}`);
|
navigate(`/attackers?q=${encodeURIComponent(ip)}`);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{selectedReuse && (
|
||||||
|
<CredentialReuseInspector
|
||||||
|
row={selectedReuse}
|
||||||
|
onClose={() => setSelectedReuse(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -87,6 +87,8 @@
|
|||||||
## Attacker Intelligence Collection
|
## Attacker Intelligence Collection
|
||||||
*Goal: Build the richest possible attacker profile from passive observation across all 26 services.*
|
*Goal: Build the richest possible attacker profile from passive observation across all 26 services.*
|
||||||
|
|
||||||
|
- `decnet/vectorstore/` substrate is scaffolded and `sqlite-vec` is wired (factory + base + impl). No producers/consumers yet — reserved for the future statistical re-identification engine.
|
||||||
|
|
||||||
### TLS/SSL Fingerprinting (via sniffer container)
|
### TLS/SSL Fingerprinting (via sniffer container)
|
||||||
- [x] **JA3/JA3S** — TLS ClientHello/ServerHello fingerprint hashes
|
- [x] **JA3/JA3S** — TLS ClientHello/ServerHello fingerprint hashes
|
||||||
- [x] **JA4+ family** — JA4, JA4S, JA4H, JA4L (latency/geo estimation via RTT)
|
- [x] **JA4+ family** — JA4, JA4S, JA4H, JA4L (latency/geo estimation via RTT)
|
||||||
@@ -110,10 +112,10 @@
|
|||||||
- [x] **HTTP/2 fingerprint** — GREASE values, settings frame order, header pseudo-field ordering
|
- [x] **HTTP/2 fingerprint** — GREASE values, settings frame order, header pseudo-field ordering
|
||||||
- [ ] **QUIC fingerprint** — Connection ID length, transport parameters order
|
- [ ] **QUIC fingerprint** — Connection ID length, transport parameters order
|
||||||
- [ ] **DNS behavior** — Query patterns, recursion flags, EDNS0 options, resolver fingerprint
|
- [ ] **DNS behavior** — Query patterns, recursion flags, EDNS0 options, resolver fingerprint
|
||||||
- [ ] **HTTP header ordering** — Tool-specific capitalization and ordering quirks
|
- [x] **HTTP header ordering** — Tool-specific capitalization and ordering quirks
|
||||||
|
|
||||||
### Network Topology Leakage
|
### Network Topology Leakage
|
||||||
- [ ] **X-Forwarded-For mismatches** — Detect VPN/proxy slip vs. actual source IP
|
- [x] **X-Forwarded-For mismatches** — Detect VPN/proxy slip vs. actual source IP
|
||||||
- [ ] **ICMP error messages** — Internal IP leakage from misconfigured attacker infra
|
- [ ] **ICMP error messages** — Internal IP leakage from misconfigured attacker infra
|
||||||
- [ ] **IPv6 link-local leakage** — IPv6 addrs leaked even over IPv4 VPN (common opsec fail)
|
- [ ] **IPv6 link-local leakage** — IPv6 addrs leaked even over IPv4 VPN (common opsec fail)
|
||||||
- [ ] **mDNS/LLMNR leakage** — Attacker hostname/device info from misconfigured systems
|
- [ ] **mDNS/LLMNR leakage** — Attacker hostname/device info from misconfigured systems
|
||||||
@@ -128,7 +130,7 @@
|
|||||||
- [x] **Commands executed** — Full command log per session (SSH, Telnet, FTP, Redis, DB services)
|
- [x] **Commands executed** — Full command log per session (SSH, Telnet, FTP, Redis, DB services)
|
||||||
- [ ] **Services actively interacted with** — Distinguish port scans from live exploitation attempts
|
- [ ] **Services actively interacted with** — Distinguish port scans from live exploitation attempts
|
||||||
- [ ] **Tooling attribution** — Byte-sequence signatures from known C2 frameworks in handshakes
|
- [ ] **Tooling attribution** — Byte-sequence signatures from known C2 frameworks in handshakes
|
||||||
- [ ] **Credential reuse patterns** — Same username/password tried across multiple deckies/services
|
- [x] **Credential reuse patterns** — Same username/password tried across multiple deckies/services
|
||||||
- [x] **Payload signatures** — Hash and classify uploaded files, shellcode, exploit payloads
|
- [x] **Payload signatures** — Hash and classify uploaded files, shellcode, exploit payloads
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Reference in New Issue
Block a user