mirror of
https://github.com/LORDBABUINO/stealth.git
synced 2026-04-28 16:40:00 -07:00
Feat: Wire frontend to backend scan endpoint, replace UTXO report with findings
- Add GET /api/wallet/scan endpoint that shells out to detect.py - Add CORS config and detect.py script path to application.properties - walletService.js now calls the real scan endpoint instead of mock - Replace UtxoCard-based ReportScreen with FindingCard-based layout - FindingCard: collapsible card with data-driven details panel (address groups, string lists, key-value scalars) - VulnerabilityBadge: all 14 finding types labeled, severity lowercased, critical style added - ReportScreen: summary bar shows findings/warnings/txs analyzed; clean banner; separate warnings section
This commit is contained in:
135
frontend/src/components/FindingCard.jsx
Normal file
135
frontend/src/components/FindingCard.jsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { useState } from 'react'
|
||||
import styles from './FindingCard.module.css'
|
||||
import VulnerabilityBadge from './VulnerabilityBadge'
|
||||
|
||||
function TxRow({ txid }) {
|
||||
if (!txid) return null
|
||||
return (
|
||||
<div className={styles.txRow}>
|
||||
<span className={styles.txLabel}>txid</span>
|
||||
<span className={styles.txHash}>{txid.slice(0, 10)}…{txid.slice(-10)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AddressRow({ item }) {
|
||||
const { address, role, amount_btc, sats, script_type, ours } = item
|
||||
const tag = role ?? (script_type ? `${script_type}${ours != null ? (ours ? ' ·ours' : ' ·ext') : ''}` : null)
|
||||
const amount = amount_btc != null ? `${amount_btc} BTC` : sats != null ? `${sats} sats` : null
|
||||
return (
|
||||
<div className={styles.addrRow}>
|
||||
<span className={styles.addrHash}>{address}</span>
|
||||
{tag && <span className={styles.addrTag}>{tag}</span>}
|
||||
{amount && <span className={styles.addrAmount}>{amount}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AddrGroup({ label, items }) {
|
||||
if (!items?.length) return null
|
||||
return (
|
||||
<div className={styles.listGroup}>
|
||||
<div className={styles.groupLabel}>{label}</div>
|
||||
{items.map((item, i) => <AddressRow key={i} item={item} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StringList({ label, items }) {
|
||||
if (!items?.length) return null
|
||||
return (
|
||||
<div className={styles.listGroup}>
|
||||
<div className={styles.groupLabel}>{label}</div>
|
||||
<ul className={styles.strList}>
|
||||
{items.map((s, i) => <li key={i}>{s}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ScalarGroup({ data }) {
|
||||
const entries = Object.entries(data).filter(([, v]) => typeof v !== 'object')
|
||||
if (!entries.length) return null
|
||||
return (
|
||||
<dl className={styles.kvList}>
|
||||
{entries.map(([k, v]) => (
|
||||
<div key={k} className={styles.kvRow}>
|
||||
<dt className={styles.kvKey}>{k.replace(/_/g, ' ')}</dt>
|
||||
<dd className={styles.kvVal}>{String(v)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailsPanel({ details }) {
|
||||
if (!details || !Object.keys(details).length) return null
|
||||
|
||||
const skip = new Set()
|
||||
const parts = []
|
||||
|
||||
if (details.txid) {
|
||||
parts.push(<TxRow key="txid" txid={details.txid} />)
|
||||
skip.add('txid')
|
||||
}
|
||||
|
||||
if (typeof details.address === 'string') {
|
||||
parts.push(
|
||||
<div key="address" className={styles.addrRow}>
|
||||
<span className={styles.addrHash}>{details.address}</span>
|
||||
</div>
|
||||
)
|
||||
skip.add('address')
|
||||
}
|
||||
|
||||
const addrFields = [
|
||||
'our_addresses', 'change_outputs', 'received_outputs',
|
||||
'dust_inputs', 'normal_inputs', 'tainted_inputs', 'clean_inputs', 'inputs',
|
||||
]
|
||||
for (const f of addrFields) {
|
||||
if (Array.isArray(details[f]) && details[f].length) {
|
||||
parts.push(<AddrGroup key={f} label={f.replace(/_/g, ' ')} items={details[f]} />)
|
||||
skip.add(f)
|
||||
}
|
||||
}
|
||||
|
||||
if (details.funding_sources && typeof details.funding_sources === 'object' && !Array.isArray(details.funding_sources)) {
|
||||
const rows = Object.entries(details.funding_sources).map(([k, v]) => ({
|
||||
address: k,
|
||||
role: Array.isArray(v) ? v.join(', ') : String(v),
|
||||
}))
|
||||
parts.push(<AddrGroup key="funding_sources" label="funding sources" items={rows} />)
|
||||
skip.add('funding_sources')
|
||||
}
|
||||
|
||||
for (const f of ['reasons', 'patterns', 'signals', 'script_types']) {
|
||||
if (Array.isArray(details[f]) && details[f].length) {
|
||||
parts.push(<StringList key={f} label={f} items={details[f]} />)
|
||||
skip.add(f)
|
||||
}
|
||||
}
|
||||
|
||||
const rest = Object.fromEntries(Object.entries(details).filter(([k]) => !skip.has(k)))
|
||||
if (Object.keys(rest).length) {
|
||||
parts.push(<ScalarGroup key="kv" data={rest} />)
|
||||
}
|
||||
|
||||
return <div className={styles.details}>{parts}</div>
|
||||
}
|
||||
|
||||
export default function FindingCard({ finding }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<button className={styles.header} onClick={() => setOpen(o => !o)}>
|
||||
<div className={styles.left}>
|
||||
<VulnerabilityBadge type={finding.type} severity={finding.severity} />
|
||||
<span className={styles.description}>{finding.description}</span>
|
||||
</div>
|
||||
<span className={`${styles.chevron} ${open ? styles.open : ''}`}>›</span>
|
||||
</button>
|
||||
{open && <DetailsPanel details={finding.details} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
181
frontend/src/components/FindingCard.module.css
Normal file
181
frontend/src/components/FindingCard.module.css
Normal file
@@ -0,0 +1,181 @@
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: var(--text);
|
||||
transition: background var(--transition);
|
||||
}
|
||||
|
||||
.header:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
font-size: 18px;
|
||||
color: var(--text-muted);
|
||||
transition: transform var(--transition);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* Details panel */
|
||||
.details {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
/* Tx row */
|
||||
.txRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.txLabel {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.txHash {
|
||||
font-family: var(--font-data);
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Address rows */
|
||||
.listGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.groupLabel {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.addrRow {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.addrHash {
|
||||
font-family: var(--font-data);
|
||||
font-size: 11px;
|
||||
color: var(--text);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.addrTag {
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.addrAmount {
|
||||
font-family: var(--font-data);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* String list */
|
||||
.strList {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
padding-left: 14px;
|
||||
}
|
||||
|
||||
.strList li {
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.strList li::before {
|
||||
content: '—';
|
||||
position: absolute;
|
||||
left: -14px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Key-value */
|
||||
.kvList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.kvRow {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.kvKey {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
min-width: 130px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.kvVal {
|
||||
font-family: var(--font-data);
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
@@ -1,15 +1,26 @@
|
||||
import styles from './VulnerabilityBadge.module.css'
|
||||
|
||||
const LABELS = {
|
||||
ADDRESS_REUSE: 'Address Reuse',
|
||||
DUST_SPEND: 'Dust',
|
||||
CONSOLIDATION: 'Consolidation',
|
||||
CIOH: 'CIOH',
|
||||
ADDRESS_REUSE: 'Address Reuse',
|
||||
CIOH: 'CIOH',
|
||||
DUST: 'Dust',
|
||||
DUST_SPENDING: 'Dust Spend',
|
||||
CHANGE_DETECTION: 'Change Detect',
|
||||
CONSOLIDATION: 'Consolidation',
|
||||
SCRIPT_TYPE_MIXING: 'Script Mixing',
|
||||
CLUSTER_MERGE: 'Cluster Merge',
|
||||
UTXO_AGE_SPREAD: 'Age Spread',
|
||||
EXCHANGE_ORIGIN: 'Exchange Origin',
|
||||
TAINTED_UTXO_MERGE: 'Taint Merge',
|
||||
BEHAVIORAL_FINGERPRINT: 'Fingerprint',
|
||||
DORMANT_UTXOS: 'Dormant UTXOs',
|
||||
DIRECT_TAINT: 'Direct Taint',
|
||||
}
|
||||
|
||||
export default function VulnerabilityBadge({ type, severity }) {
|
||||
const cls = severity ? severity.toLowerCase() : 'low'
|
||||
return (
|
||||
<span className={`${styles.badge} ${styles[severity]}`}>
|
||||
<span className={`${styles.badge} ${styles[cls] ?? ''}`}>
|
||||
<span className={styles.dot} />
|
||||
{LABELS[type] ?? type}
|
||||
</span>
|
||||
|
||||
@@ -51,3 +51,14 @@
|
||||
background: var(--safe);
|
||||
box-shadow: 0 0 4px var(--safe);
|
||||
}
|
||||
|
||||
.critical {
|
||||
background: rgba(255, 23, 68, 0.2);
|
||||
color: #ff1744;
|
||||
border: 1px solid rgba(255, 23, 68, 0.4);
|
||||
}
|
||||
|
||||
.critical .dot {
|
||||
background: #ff1744;
|
||||
box-shadow: 0 0 6px #ff1744;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import UtxoCard from '../components/UtxoCard'
|
||||
import FindingCard from '../components/FindingCard'
|
||||
import styles from './ReportScreen.module.css'
|
||||
|
||||
function truncateDescriptor(desc) {
|
||||
@@ -7,7 +7,7 @@ function truncateDescriptor(desc) {
|
||||
}
|
||||
|
||||
export default function ReportScreen({ report, descriptor, onReset }) {
|
||||
const { summary, utxos } = report
|
||||
const { stats, findings, warnings, summary } = report
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
@@ -33,29 +33,54 @@ export default function ReportScreen({ report, descriptor, onReset }) {
|
||||
|
||||
{/* Summary bar */}
|
||||
<div className={styles.summaryBar}>
|
||||
<div className={`${styles.summaryCard} ${styles.total}`}>
|
||||
<div className={styles.summaryNumber}>{summary.total}</div>
|
||||
<div className={styles.summaryLabel}>Total UTXOs</div>
|
||||
</div>
|
||||
<div className={`${styles.summaryCard} ${styles.vulnerable}`}>
|
||||
<div className={styles.summaryNumber}>{summary.vulnerable}</div>
|
||||
<div className={styles.summaryLabel}>Vulnerable</div>
|
||||
<div className={styles.summaryNumber}>{summary.findings}</div>
|
||||
<div className={styles.summaryLabel}>Findings</div>
|
||||
</div>
|
||||
<div className={`${styles.summaryCard} ${styles.clean}`}>
|
||||
<div className={styles.summaryNumber}>{summary.clean}</div>
|
||||
<div className={styles.summaryLabel}>Clean</div>
|
||||
<div className={`${styles.summaryCard} ${styles.warn}`}>
|
||||
<div className={styles.summaryNumber}>{summary.warnings}</div>
|
||||
<div className={styles.summaryLabel}>Warnings</div>
|
||||
</div>
|
||||
<div className={`${styles.summaryCard} ${styles.total}`}>
|
||||
<div className={styles.summaryNumber}>{stats.transactions_analyzed}</div>
|
||||
<div className={styles.summaryLabel}>Txs Analyzed</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* UTXO list */}
|
||||
<div className={styles.listHeader}>
|
||||
<span className={styles.listTitle}>UTXO Analysis</span>
|
||||
</div>
|
||||
<div className={styles.utxoList}>
|
||||
{utxos.map((utxo) => (
|
||||
<UtxoCard key={`${utxo.txid}:${utxo.vout}`} utxo={utxo} />
|
||||
))}
|
||||
</div>
|
||||
{/* Clean banner */}
|
||||
{summary.clean && (
|
||||
<div className={styles.cleanBanner}>
|
||||
No privacy issues found — this wallet has a clean history.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Findings */}
|
||||
{findings.length > 0 && (
|
||||
<>
|
||||
<div className={styles.listHeader}>
|
||||
<span className={styles.listTitle}>Findings</span>
|
||||
</div>
|
||||
<div className={styles.findingList}>
|
||||
{findings.map((f, i) => (
|
||||
<FindingCard key={i} finding={f} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Warnings */}
|
||||
{warnings.length > 0 && (
|
||||
<>
|
||||
<div className={styles.listHeader} style={{ marginTop: 28 }}>
|
||||
<span className={styles.listTitle}>Warnings</span>
|
||||
</div>
|
||||
<div className={styles.findingList}>
|
||||
{warnings.map((w, i) => (
|
||||
<FindingCard key={i} finding={w} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -129,7 +129,26 @@
|
||||
border-color: rgba(0, 212, 170, 0.25);
|
||||
}
|
||||
|
||||
/* UTXO list */
|
||||
.warn .summaryNumber {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.warn {
|
||||
border-color: rgba(244, 162, 97, 0.25);
|
||||
}
|
||||
|
||||
/* Clean banner */
|
||||
.cleanBanner {
|
||||
background: var(--accent-dim);
|
||||
border: 1px solid rgba(0, 212, 170, 0.3);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px 18px;
|
||||
font-size: 13px;
|
||||
color: var(--accent);
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
/* Finding list */
|
||||
.listHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -145,8 +164,8 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.utxoList {
|
||||
.findingList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
export const analyzeWallet = async (descriptor) => {
|
||||
const res1 = await fetch('/api/wallet/analyze', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ descriptor }),
|
||||
})
|
||||
if (!res1.ok) throw new Error('Analysis request failed')
|
||||
const { analysisId } = await res1.json()
|
||||
|
||||
const res2 = await fetch(`/api/wallet/${analysisId}/utxos`)
|
||||
if (!res2.ok) throw new Error('Failed to fetch report')
|
||||
return res2.json()
|
||||
const res = await fetch(`/api/wallet/scan?descriptor=${encodeURIComponent(descriptor)}`)
|
||||
if (!res.ok) throw new Error('Analysis failed')
|
||||
return res.json()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user