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:
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