Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
390 lines
12 KiB
TypeScript
390 lines
12 KiB
TypeScript
import { memo, useEffect, useMemo, useState } from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
import { api } from '../../api/client';
|
|
import { HelpTip } from '../HelpTip';
|
|
import {
|
|
FLEET_SPREAD_PORTS,
|
|
PORT_BUNDLES,
|
|
RECON_PORT_HINTS,
|
|
SCAN_PROFILES,
|
|
crucibleSpreadLink,
|
|
curlInstallLine,
|
|
type PortBundleId,
|
|
type ScanProfileId,
|
|
} from '../../help/deployRecon';
|
|
import type {
|
|
ReconActionRow,
|
|
ReconFormFingerprintCard,
|
|
} from '../../help/deployRecon';
|
|
import {
|
|
buildAdminSurfaceMap,
|
|
buildFormFingerprintCards,
|
|
buildTechStackHints,
|
|
type AdminSurfaceMapRow,
|
|
} from '../../help/deployRecon';
|
|
import type {
|
|
ReconPortBanner,
|
|
ReconPortResult,
|
|
ReconScanReport,
|
|
ReconSsrfCanaryInfo,
|
|
ReconStackEntry,
|
|
ReconWebFindingCard,
|
|
} from '../../types/recon';
|
|
import { ssrfCanaryToken } from '../../help/deployRecon';
|
|
|
|
function CopyChip({ text, label }: { text: string; label: string }) {
|
|
const [ok, setOk] = useState(false);
|
|
const copy = () => {
|
|
void navigator.clipboard?.writeText(text).then(() => {
|
|
setOk(true);
|
|
setTimeout(() => setOk(false), 1500);
|
|
}).catch(() => {});
|
|
};
|
|
return (
|
|
<button type="button" className="btn btn-outline btn-sm" onClick={copy}>
|
|
{ok ? 'Copied' : label}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export const ScanProfileSelector = memo(function ScanProfileSelector({
|
|
value,
|
|
onChange,
|
|
}: {
|
|
value: ScanProfileId;
|
|
onChange: (v: ScanProfileId) => void;
|
|
}) {
|
|
return (
|
|
<div className="dr-profile-row" data-testid="dr-scan-profile">
|
|
<span className="dr-field-label">Scan profile <HelpTip field="dr_scan_profile" /></span>
|
|
<div className="dr-chip-row">
|
|
{(Object.keys(SCAN_PROFILES) as ScanProfileId[]).map((id) => (
|
|
<button
|
|
key={id}
|
|
type="button"
|
|
className={`dr-chip${value === id ? ' active' : ''}`}
|
|
onClick={() => onChange(id)}
|
|
title={SCAN_PROFILES[id].detail}
|
|
>
|
|
{SCAN_PROFILES[id].label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
export const PortBundleSelector = memo(function PortBundleSelector({
|
|
value,
|
|
onChange,
|
|
}: {
|
|
value: PortBundleId;
|
|
onChange: (v: PortBundleId) => void;
|
|
}) {
|
|
return (
|
|
<div className="dr-bundle-row" data-testid="dr-port-bundle">
|
|
<span className="dr-field-label">Port bundle <HelpTip field="dr_port_bundle" /></span>
|
|
<select className="input" value={value} onChange={(e) => onChange(e.target.value as PortBundleId)}>
|
|
{(Object.keys(PORT_BUNDLES) as PortBundleId[]).map((id) => (
|
|
<option key={id} value={id}>
|
|
{PORT_BUNDLES[id].label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
export const StreamingPortMatrix = memo(function StreamingPortMatrix({
|
|
ports,
|
|
streaming,
|
|
onHint,
|
|
}: {
|
|
ports: ReconPortResult[];
|
|
streaming?: boolean;
|
|
onHint: (hint: string) => void;
|
|
}) {
|
|
return (
|
|
<div data-testid="dr-port-matrix" className={streaming ? 'dr-port-matrix-streaming' : ''}>
|
|
<h3 className="dr-section-title">
|
|
Port matrix <HelpTip field="dr_port_matrix" />
|
|
</h3>
|
|
<div className="dr-port-matrix">
|
|
{ports.map((p) => (
|
|
<button
|
|
key={p.port}
|
|
type="button"
|
|
className={`dr-port-cell${p.open ? ' open' : ''}${streaming && !p.open ? ' pending' : ''}`}
|
|
disabled={!p.open}
|
|
title={p.open ? RECON_PORT_HINTS[p.port] ?? `TCP ${p.port} open` : `TCP ${p.port} closed`}
|
|
onClick={() => {
|
|
if (p.open) onHint(RECON_PORT_HINTS[p.port] ?? `TCP ${p.port} open`);
|
|
}}
|
|
>
|
|
{p.port}{p.open ? ' *' : ''}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
export const BannerGrabPanel = memo(function BannerGrabPanel({ banners }: { banners: ReconPortBanner[] }) {
|
|
if (!banners?.length) return null;
|
|
return (
|
|
<section className="dr-banner-panel" data-testid="dr-banner-grab">
|
|
<h3 className="dr-section-title">Banner grab <HelpTip field="dr_banner_grab" /></h3>
|
|
<ul className="dr-banner-list">
|
|
{banners.map((b) => (
|
|
<li key={b.port} className="dr-banner-item">
|
|
<span className="font-tech">:{b.port}</span>
|
|
{b.service ? <span className="dr-banner-svc">{b.service}</span> : null}
|
|
{b.title ? <span className="dr-banner-title">{b.title}</span> : null}
|
|
{b.banner ? <code className="dr-banner-code">{b.banner}</code> : null}
|
|
{b.hint ? <span className="dr-banner-hint">{b.hint}</span> : null}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
);
|
|
});
|
|
|
|
export const FormFingerprintCards = memo(function FormFingerprintCards({
|
|
cards,
|
|
}: {
|
|
cards: ReconFormFingerprintCard[];
|
|
}) {
|
|
if (cards.length === 0) return null;
|
|
return (
|
|
<section data-testid="dr-form-fingerprint">
|
|
<h3 className="dr-section-title">Form fingerprints <HelpTip field="dr_form_fingerprint" /></h3>
|
|
<div className="dr-fp-grid">
|
|
{cards.map((c) => (
|
|
<article key={c.id} className="dr-fp-card">
|
|
<div className="dr-fp-head">
|
|
<span className="font-tech">{c.name}</span>
|
|
<span className="dr-fp-score">score {c.score}</span>
|
|
</div>
|
|
<p className="dr-fp-url">{c.page_url}</p>
|
|
{c.matches.length ? <p className="dr-fp-matches">{c.matches.join(' · ')}</p> : null}
|
|
<CopyChip text={c.paste_target} label="Copy paste target" />
|
|
</article>
|
|
))}
|
|
</div>
|
|
</section>
|
|
);
|
|
});
|
|
|
|
export const SsrfCanaryPanel = memo(function SsrfCanaryPanel({
|
|
canary,
|
|
scanId,
|
|
wsHitScanId,
|
|
}: {
|
|
canary?: ReconSsrfCanaryInfo;
|
|
scanId?: string;
|
|
wsHitScanId?: string | null;
|
|
}) {
|
|
const token = ssrfCanaryToken(canary?.scan_id || scanId || '');
|
|
const [status, setStatus] = useState(canary?.status ?? 'pending');
|
|
const [hitAt, setHitAt] = useState(canary?.hit_at);
|
|
|
|
useEffect(() => {
|
|
if (canary?.status) setStatus(canary.status);
|
|
if (canary?.hit_at) setHitAt(canary.hit_at);
|
|
}, [canary]);
|
|
|
|
useEffect(() => {
|
|
if (wsHitScanId && token && wsHitScanId === token) {
|
|
setStatus('confirmed');
|
|
setHitAt(new Date().toISOString());
|
|
}
|
|
}, [wsHitScanId, token]);
|
|
|
|
useEffect(() => {
|
|
if (!token || status === 'confirmed') return undefined;
|
|
const timer = setInterval(() => {
|
|
void api.getSsrfCanaryStatus(token).then((info) => {
|
|
if (!info) return;
|
|
setStatus(info.status);
|
|
if (info.hit_at) setHitAt(info.hit_at);
|
|
});
|
|
}, 4000);
|
|
return () => clearInterval(timer);
|
|
}, [token, status]);
|
|
|
|
const url = canary?.url;
|
|
if (!url && !token) return null;
|
|
|
|
return (
|
|
<section className="dr-canary-panel" data-testid="dr-ssrf-canary">
|
|
<h3 className="dr-section-title">
|
|
SSRF canary <HelpTip field="dr_ssrf_canary" />
|
|
<span className={`dr-canary-badge ${status}`}>{status}</span>
|
|
</h3>
|
|
{url ? <CopyChip text={url} label="Copy canary URL" /> : null}
|
|
{hitAt ? <p className="dr-canary-hit">Hit at {hitAt}</p> : null}
|
|
{canary?.paste_field_name ? (
|
|
<p className="dr-empty">Paste into field: {canary.paste_field_name}</p>
|
|
) : null}
|
|
</section>
|
|
);
|
|
});
|
|
|
|
export const UploadAdminMap = memo(function UploadAdminMap({ rows }: { rows: AdminSurfaceMapRow[] }) {
|
|
if (!rows.length) return null;
|
|
return (
|
|
<section data-testid="dr-upload-admin-map">
|
|
<h3 className="dr-section-title">Upload / admin map <HelpTip field="dr_upload_admin_map" /></h3>
|
|
<table className="dr-admin-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Path</th>
|
|
<th>Status</th>
|
|
<th>Signal</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map((r) => {
|
|
const live = r.status_code >= 200 && r.status_code < 400;
|
|
return (
|
|
<tr key={r.url} className={live ? 'dr-admin-live' : 'dr-admin-muted'}>
|
|
<td>
|
|
<a href={r.url} target="_blank" rel="noreferrer">
|
|
{r.path}
|
|
</a>
|
|
</td>
|
|
<td>{r.status_code || '—'}</td>
|
|
<td>{r.signal}</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</section>
|
|
);
|
|
});
|
|
|
|
export const TechStackBanner = memo(function TechStackBanner({ stack }: { stack: ReconStackEntry[] }) {
|
|
if (!stack.length) return null;
|
|
return (
|
|
<section data-testid="dr-tech-stack">
|
|
<h3 className="dr-section-title">Tech stack <HelpTip field="dr_tech_stack" /></h3>
|
|
<ul className="dr-stack-list">
|
|
{stack.map((e) => (
|
|
<li key={`${e.name}-${e.source}`}>
|
|
<strong>{e.name}</strong>
|
|
<span className="dr-stack-src">{e.source}</span>
|
|
{e.detail ? <span className="dr-stack-detail">{e.detail}</span> : null}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
);
|
|
});
|
|
|
|
export const ActionMatrix = memo(function ActionMatrix({
|
|
rows,
|
|
onOpenPlaybook,
|
|
onAction,
|
|
}: {
|
|
rows: ReconActionRow[];
|
|
onOpenPlaybook?: (lane: string) => void;
|
|
onAction?: (lane: string, action: string) => void;
|
|
}) {
|
|
if (!rows.length) return null;
|
|
return (
|
|
<section data-testid="dr-action-matrix">
|
|
<h3 className="dr-section-title">Action matrix <HelpTip field="dr_action_matrix" /></h3>
|
|
<table className="dr-action-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Lane</th>
|
|
<th>Reason</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map((r) => (
|
|
<tr key={r.id}>
|
|
<td className="font-tech">{r.title}</td>
|
|
<td>{r.reason}</td>
|
|
<td>
|
|
<div className="dr-action-chips">
|
|
{r.actions.map((a) => (
|
|
<button
|
|
key={a}
|
|
type="button"
|
|
className="btn btn-outline btn-sm"
|
|
onClick={() => {
|
|
if (a === 'playbook' && onOpenPlaybook) onOpenPlaybook(r.lane);
|
|
onAction?.(r.lane, a);
|
|
}}
|
|
>
|
|
{a}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</section>
|
|
);
|
|
});
|
|
|
|
export const FindingCard = memo(function FindingCard({
|
|
card,
|
|
curlLine,
|
|
host,
|
|
fleetSpreadOpen,
|
|
deployKitLane,
|
|
}: {
|
|
card: ReconWebFindingCard;
|
|
curlLine: string;
|
|
host: string;
|
|
fleetSpreadOpen: boolean;
|
|
deployKitLane?: string;
|
|
}) {
|
|
const finding = deployKitLane || card.spread_lane;
|
|
return (
|
|
<article className="dr-finding-card" data-testid={`dr-finding-${card.id}`}>
|
|
<div className="dr-finding-head">
|
|
<span className="dr-finding-title">{card.title}</span>
|
|
<span className={`dr-confidence ${card.confidence}`}>{card.confidence}</span>
|
|
{card.spread_lane ? <span className="join-lane-badge">{card.spread_lane}</span> : null}
|
|
</div>
|
|
<p className="dr-finding-detail">{card.detail}</p>
|
|
{card.mermaid ? <pre className="dr-mermaid">{card.mermaid}</pre> : null}
|
|
<div className="dr-deploy-panel">
|
|
<CopyChip text={curlLine} label="Copy curl install.sh" />
|
|
{card.probe_url ? <CopyChip text={card.probe_url} label="Copy SSRF probe URL" /> : null}
|
|
{fleetSpreadOpen ? (
|
|
<Link to={crucibleSpreadLink(host, finding)} className="btn btn-outline btn-sm">
|
|
Fleet spread to {host}
|
|
</Link>
|
|
) : null}
|
|
</div>
|
|
</article>
|
|
);
|
|
});
|
|
|
|
export function useDeployReconDerived(report: ReconScanReport | null, deckOrigin: string) {
|
|
return useMemo(() => {
|
|
if (!report) {
|
|
return {
|
|
fingerprintCards: [] as ReconFormFingerprintCard[],
|
|
adminRows: [] as AdminSurfaceMapRow[],
|
|
stack: [] as ReconStackEntry[],
|
|
};
|
|
}
|
|
return {
|
|
fingerprintCards: buildFormFingerprintCards(report, deckOrigin),
|
|
adminRows: buildAdminSurfaceMap(report),
|
|
stack: buildTechStackHints(report),
|
|
};
|
|
}, [report, deckOrigin]);
|
|
}
|
|
|
|
|