diff --git a/decnet_web/src/components/CredentialReuseInspector.tsx b/decnet_web/src/components/CredentialReuseInspector.tsx new file mode 100644 index 00000000..dfc30b84 --- /dev/null +++ b/decnet_web/src/components/CredentialReuseInspector.tsx @@ -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 = ({ 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 ( +
{ if (e.target === e.currentTarget) onClose(); }} + > +
+
+

+ + REUSE #{row.id.slice(0, 8)} +

+ +
+
+
+
SECRET KIND
+
+ + {row.secret_kind.toUpperCase()} + +
+
PRINCIPAL
+
{row.principal ?? }
+
TARGETS
+
{row.target_count}
+
ATTEMPTS
+
{row.attempt_count}
+
CONFIDENCE
+
{row.confidence.toFixed(2)}
+
FIRST SEEN
+
{new Date(row.first_seen).toLocaleString()}
+
LAST SEEN
+
{new Date(row.last_seen).toLocaleString()}
+
+ +
+
DECKIES × SERVICES
+
+ + + + + {row.services.map(svc => ( + + ))} + + + + {row.deckies.map(decky => ( + + + {row.services.map(svc => ( + + ))} + + ))} + +
{svc.toUpperCase()}
{decky} + +
+
+
+ +
+
ATTACKERS
+ {row.attacker_uuids.length === 0 ? ( +
+ PROFILING PENDING — credential captures precede attacker + profiling; this row will populate once the profiler runs. +
+ ) : ( +
+ {row.attacker_uuids.map((uuid, i) => ( +
navigate(`/attackers/${uuid}`)} + style={{ + display: 'flex', + gap: 8, + alignItems: 'baseline', + cursor: 'pointer', + textDecoration: 'underline dotted', + }} + > + {uuid.slice(0, 8)} + + {row.attacker_ips[i] ?? ''} + +
+ ))} +
+ )} +
+ +
+
SECRET SHA-256
+
+ {row.secret_sha256} + +
+
+
+
+
+ ); +}; + +export default CredentialReuseInspector; diff --git a/decnet_web/src/components/Credentials.tsx b/decnet_web/src/components/Credentials.tsx index cb95f8a3..d88f9aa8 100644 --- a/decnet_web/src/components/Credentials.tsx +++ b/decnet_web/src/components/Credentials.tsx @@ -2,11 +2,13 @@ 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, + 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'; @@ -15,55 +17,119 @@ 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([]); - const [total, setTotal] = useState(0); + const [credsTotal, setCredsTotal] = useState(0); + const [reuseRows, setReuseRows] = useState([]); + const [reuseTotal, setReuseTotal] = useState(0); + const [reuseMap, setReuseMap] = useState>(new Map()); const [loading, setLoading] = useState(true); const [searchInput, setSearchInput] = useState(query); const searchRef = useRef(null); useFocusSearch(searchRef); - const [selected, setSelected] = useState(null); + const [selectedCred, setSelectedCred] = useState(null); + const [selectedReuse, setSelectedReuse] = useState(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 () => { - 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); - } - }; + // ── 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]); - 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(); + (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, page: '1' }); + setSearchParams({ q: searchInput, service: serviceFilter, tab, page: '1' }); }; 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) => - 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)); - // Derive service chips dynamically from the visible page so the segment - // group reflects whatever services are actually capturing creds. + // Service chips derived from visible creds page const services = useMemo(() => { const set = new Set(); 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 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 (
@@ -82,48 +159,73 @@ const Credentials: React.FC = () => {

CREDENTIAL VAULT

- {total.toLocaleString()} CAPTURED · {plaintextCount} PLAINTEXT · {hashedCount} CHALLENGED + {tab === 'creds' + ? `${credsTotal.toLocaleString()} CAPTURED · ${plaintextCount} PLAINTEXT · ${hashedCount} CHALLENGED` + : `${reuseTotal.toLocaleString()} REUSE FINDINGS`}
-
-
- - setSearchInput(e.target.value)} - /> -
-
- - {services.map(svc => ( +
+ + +
+ + {tab === 'creds' && ( + +
+ + setSearchInput(e.target.value)} + /> +
+
- ))} -
- + {services.map(svc => ( + + ))} +
+ + )}
- {total.toLocaleString()} CREDENTIALS CAPTURED + + {tab === 'creds' + ? `${credsTotal.toLocaleString()} CREDENTIALS CAPTURED` + : `${reuseTotal.toLocaleString()} REUSE FINDINGS`} +
@@ -134,96 +236,192 @@ const Credentials: React.FC = () => { +
- - - - - - - - - - - - - - - - {creds.length > 0 ? creds.map(c => { - const isPlain = c.secret_kind === 'plaintext'; - const secretText = isPlain - ? (c.secret_printable ?? '—') - : truncHash(c.secret_sha256, 16); - return ( - setSelected(c)}> - - - - - - - - - + {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 ( + setSelectedReuse(r)}> + + + + + + + + + + ); + }) : ( + + + + )} + +
LAST SEENDECKYSVCATTACKERPRINCIPALSECRETKINDHITS
- {new Date(c.last_seen).toLocaleTimeString()} - {c.decky_name}{c.service} - { - e.stopPropagation(); - navigate(`/attackers?q=${encodeURIComponent(c.attacker_ip)}`); - }} - > - {c.attacker_ip} - - - {c.principal ?? } - - - {secretText} - - - - {c.secret_kind.toUpperCase()} - - - {c.attempt_count} - - + {tab === 'creds' ? ( + + + + + + + + + + + + + + + + + {creds.length > 0 ? creds.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 ( + setSelectedCred(c)}> + + + + + + + + + + + + ); + }) : ( + + - ); - }) : ( + )} + +
LAST SEENDECKYSVCATTACKERPRINCIPALSECRETKINDHITSREUSE
+ {new Date(c.last_seen).toLocaleTimeString()} + {c.decky_name}{c.service} + { + e.stopPropagation(); + navigate(`/attackers?q=${encodeURIComponent(c.attacker_ip)}`); + }} + > + {c.attacker_ip} + + + {c.principal ?? } + + + {secretText} + + + + {c.secret_kind.toUpperCase()} + + + {c.attempt_count} + + {reuseHit ? ( + { + e.stopPropagation(); + openReuseFromCred(key); + }} + > + ×{reuseHit.target_count} + + ) : ( + + )} + + +
+
+ ) : ( + + - + + + + + + + + - )} - -
- - LAST SEENPRINCIPALKINDTARGETSATTEMPTSDECKIESSERVICES
+ +
+ {new Date(r.last_seen).toLocaleTimeString()} + + {r.principal ?? } + + + {r.secret_kind.toUpperCase()} + + {r.target_count}{r.attempt_count} + {r.deckies.slice(0, 3).map(d => ( + {d} + ))} + {moreDeckies > 0 && +{moreDeckies}} + + {r.services.slice(0, 3).map(s => ( + {s} + ))} + {moreServices > 0 && +{moreServices}} + + +
+ +
+ )}
- {selected && ( + {selectedCred && ( setSelected(null)} + cred={selectedCred} + onClose={() => setSelectedCred(null)} onSelectAttacker={(ip) => { - setSelected(null); + setSelectedCred(null); navigate(`/attackers?q=${encodeURIComponent(ip)}`); }} /> )} + + {selectedReuse && ( + setSelectedReuse(null)} + /> + )} ); }; diff --git a/development/DEVELOPMENT.md b/development/DEVELOPMENT.md index 55fdcb90..270f2e3e 100644 --- a/development/DEVELOPMENT.md +++ b/development/DEVELOPMENT.md @@ -87,6 +87,8 @@ ## Attacker Intelligence Collection *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) - [x] **JA3/JA3S** — TLS ClientHello/ServerHello fingerprint hashes - [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 - [ ] **QUIC fingerprint** — Connection ID length, transport parameters order - [ ] **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 -- [ ] **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 - [ ] **IPv6 link-local leakage** — IPv6 addrs leaked even over IPv4 VPN (common opsec fail) - [ ] **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) - [ ] **Services actively interacted with** — Distinguish port scans from live exploitation attempts - [ ] **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 ---