diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dd2b604ff..ed22c3a951 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,30 @@ Format based on [Keep a Changelog](https://keepachangelog.com/), versions follow ## [Unreleased] +### Added + +- Durable, redacted engagement event log with live SSE, correlation IDs, activity timeline/lanes, filters, and JSONL export +- Mission posture and topology panels for methodology coverage, validation, attack chains, agents, assets, hosts, endpoints, findings, and target notes +- Approval-gated Nmap scan profiles, canonical XML ingestion, saved scan history, host/service/route topology, and scan comparison +- Structured project/session memory with FTS5, provenance, trust, confidence, secret redaction, invalidation, failure reflection, and evaluation-gated procedural promotion +- Server-enforced read-only observer role and typed local execution-plane capability inventory +- Version-pinned MCP catalog with manual/optional install states and three additional owner-maintained integrations +- Safe recovery startup with `--safe` and namespaced extension configuration +- Target-selectable source builds and a localhost-only systemd user service template for Kali/Linux deployments +- Always-visible live workbench status with resilient activity streaming, meaningful change badges, and event-driven Mission, Topology, and Memory refresh + +### Changed + +- MCP tool materialization now filters schemas before conversion and enforces real context-budget eviction +- Agent methodology now routes Nmap through the approval-gated `nmap_scan` system of record and explicitly records verified discoveries, findings, and reusable memory for workbench projection + +### Fixed + +- Release and local-binary installs now deploy the bundled HackBrowser worker to the runtime data directory +- Web update checks now honor disabled auto-updates, avoiding false upgrade prompts for managed source builds +- Hidden workbench panels no longer miss live changes or depend on visible-only polling; repeated session lifecycle events and reconnect gaps are recovered +- Nmap permissions now identify active/elevated execution, privileged profiles use non-interactive sudo when required, and Topology explains why generic shell output is not graphable + ## [1.1.16] — 2026-08-08 ### Added diff --git a/contrib/systemd/cyberstrike-web.service b/contrib/systemd/cyberstrike-web.service new file mode 100644 index 0000000000..7436fc38fa --- /dev/null +++ b/contrib/systemd/cyberstrike-web.service @@ -0,0 +1,19 @@ +[Unit] +Description=CyberStrike Web UI +Documentation=https://github.com/CyberStrikeus/CyberStrike +Wants=network-online.target +After=network-online.target + +[Service] +Type=simple +Environment=BROWSER=none +Environment=CYBERSTRIKE_DISABLE_AUTOUPDATE=true +EnvironmentFile=%h/.config/cyberstrike/web.env +ExecStart=%h/.cyberstrike/bin/cyberstrike web --hostname 127.0.0.1 --port 4096 +Restart=on-failure +RestartSec=5 +TimeoutStopSec=30 +UMask=0077 + +[Install] +WantedBy=default.target diff --git a/install b/install index 34500d893d..91cab9b70e 100755 --- a/install +++ b/install @@ -73,7 +73,8 @@ while [[ $# -gt 0 ]]; do done INSTALL_DIR=$HOME/.cyberstrike/bin -mkdir -p "$INSTALL_DIR" +DATA_BIN_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/cyberstrike/bin" +mkdir -p "$INSTALL_DIR" "$DATA_BIN_DIR" # If --binary is provided, skip all download/detection logic if [ -n "$binary_path" ]; then @@ -354,13 +355,24 @@ download_and_install() { mv "$tmp_dir/cyberstrike" "$INSTALL_DIR" chmod 755 "${INSTALL_DIR}/cyberstrike" + install_worker "$tmp_dir/hackbrowser-worker.js" rm -rf "$tmp_dir" } +install_worker() { + local worker_path="$1" + if [ ! -f "$worker_path" ]; then + return + fi + cp "$worker_path" "${DATA_BIN_DIR}/hackbrowser-worker.js" + chmod 755 "${DATA_BIN_DIR}/hackbrowser-worker.js" +} + install_from_binary() { print_message info "\n${MUTED}Installing ${NC}cyberstrike ${MUTED}from: ${NC}$binary_path" cp "$binary_path" "${INSTALL_DIR}/cyberstrike" chmod 755 "${INSTALL_DIR}/cyberstrike" + install_worker "$(dirname "$binary_path")/hackbrowser-worker.js" } if [ -n "$binary_path" ]; then diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index a942ada714..72fea45a53 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -21,6 +21,7 @@ import { TerminalProvider } from "@/context/terminal" import { PromptProvider } from "@/context/prompt" import { FileProvider } from "@/context/file" import { CommentsProvider } from "@/context/comments" +import { WorkbenchProvider } from "@/context/workbench" import { NotificationProvider } from "@/context/notification" import { ModelsProvider } from "@/context/models" import { DialogProvider } from "@cyberstrike-io/ui/context/dialog" @@ -92,7 +93,9 @@ function SessionProviders(props: ParentProps) { - {props.children} + + {props.children} + diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 9fc3d75b2e..add17758fa 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -2,7 +2,7 @@ import { createSimpleContext } from "@cyberstrike-io/ui/context" import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { Persist, persisted } from "@/utils/persist" -import { useCheckServerHealth } from "@/utils/server-health" +import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health" type StoredProject = { worktree: string; expanded: boolean } type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http @@ -97,6 +97,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( active: "" as ServerConnection.Key | "", healthy: undefined as boolean | undefined, needsAuth: false, + role: undefined as ServerHealth["role"], }) const healthy = () => state.healthy @@ -161,6 +162,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( if (!alive) return setState("healthy", result.healthy) setState("needsAuth", !!result.needsAuth) + setState("role", result.role) }) .finally(() => { busy = false @@ -246,6 +248,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( ready: isReady, healthy, needsAuth, + role: () => state.role, isLocal, get key() { return state.active as ServerConnection.Key diff --git a/packages/app/src/context/workbench.tsx b/packages/app/src/context/workbench.tsx new file mode 100644 index 0000000000..3efb383f2a --- /dev/null +++ b/packages/app/src/context/workbench.tsx @@ -0,0 +1,254 @@ +import { createSimpleContext } from "@cyberstrike-io/ui/context" +import { useParams } from "@solidjs/router" +import { createEffect, createMemo, onCleanup, untrack } from "solid-js" +import { createStore, produce, reconcile } from "solid-js/store" +import { useSDK } from "@/context/sdk" +import { + activityChannels, + activityRefreshChannels, + isActivity, + mergeActivity, + type Activity, + type ActivitySource, + type WorkbenchChannel, +} from "@/pages/session/activity" + +const blank = () => ({ + activity: 0, + mission: 0, + topology: 0, + memory: 0, + mcp: 0, + bolt: 0, + terminal: 0, + vulns: 0, + web: 0, +}) + +export const { use: useWorkbench, provider: WorkbenchProvider } = createSimpleContext({ + name: "Workbench", + init: () => { + const params = useParams() + const sdk = useSDK() + const [events, setEvents] = createStore([]) + const [state, setState] = createStore({ + connected: false, + error: "", + changes: blank(), + revision: blank(), + last: blank(), + }) + + const bump = (channel: WorkbenchChannel, time = Date.now(), changed = true) => { + if (changed) setState("changes", channel, (value) => value + 1) + setState("revision", channel, (value) => value + 1) + setState("last", channel, time) + } + + const marked = new Set() + const mark = (event: Activity) => { + if (marked.has(event.id)) return + marked.delete(event.id) + marked.add(event.id) + while (marked.size > 2_000) marked.delete(marked.values().next().value!) + for (const channel of activityChannels(event)) bump(channel, event.time) + for (const channel of activityRefreshChannels(event)) bump(channel, event.time, false) + } + + const add = (event: Activity) => { + const index = events.findIndex((item) => item.id === event.id) + if (index !== -1) { + setEvents(index, reconcile(event)) + mark(event) + return + } + setEvents( + produce((draft) => { + draft.push(event) + if (draft.length > 2_000) draft.splice(0, draft.length - 2_000) + }), + ) + mark(event) + } + + createEffect(() => { + const sessionID = params.id + sdk.directory + marked.clear() + setEvents(reconcile([])) + setState({ + connected: false, + error: "", + changes: blank(), + revision: blank(), + last: blank(), + }) + if (!sessionID) return + + const abort = new AbortController() + const client = sdk.createClient({ + directory: sdk.directory, + throwOnError: true, + signal: abort.signal, + }) + let connected = false + let hydrated = false + let requested = 0 + let completed = 0 + let syncing: Promise | undefined + let streamError = "" + let historyError = "" + const render = () => setState({ connected, error: historyError || streamError }) + const snapshot = async () => { + const known = new Set(untrack(() => events.map((event) => event.id))) + const pages: Activity[] = [] + let before: number | undefined + let beforeID: string | undefined + while (pages.length < 2_000) { + const limit = Math.min(500, 2_000 - pages.length) + const response = await client.eventLog.list({ sessionID, before, beforeID, limit }) + const page = (response.data ?? []).filter(isActivity) + if (page.length === 0) break + pages.unshift(...page) + if (hydrated && page.some((event) => known.has(event.id))) break + if (page.length < limit) break + const next = page[0]!.time + const nextID = page[0]!.id + if (before === next && beforeID === nextID) break + before = next + beforeID = nextID + } + return { known, incoming: mergeActivity(pages, [], 2_000) } + } + const sync = () => { + if (syncing) return syncing + syncing = (async () => { + while (!abort.signal.aborted) { + const version = requested + try { + const history = await snapshot() + if (abort.signal.aborted) return + const changed = hydrated + setEvents(reconcile(mergeActivity(history.incoming, [...events]))) + if (changed) history.incoming.filter((event) => !history.known.has(event.id)).forEach(mark) + hydrated = true + completed = version + historyError = "" + render() + if (requested <= completed) return + } catch (cause) { + if (abort.signal.aborted) return + historyError = cause instanceof Error ? cause.message : String(cause) + render() + await new Promise((resolve) => setTimeout(resolve, 500)) + } + } + })().finally(() => { + syncing = undefined + }) + return syncing + } + const requestSync = () => { + requested++ + return sync() + } + const unsubs = [ + sdk.event.on("memory.updated", (event) => { + if (!event.properties.sessionID) bump("memory") + }), + sdk.event.on("mcp.tools.changed", () => bump("mcp")), + sdk.event.on("pty.created", () => bump("terminal")), + sdk.event.on("pty.updated", () => bump("terminal")), + sdk.event.on("pty.exited", () => bump("terminal")), + sdk.event.on("pty.deleted", () => bump("terminal")), + ] + + void requestSync() + let timer: ReturnType | undefined + void (async () => { + while (!abort.signal.aborted) { + try { + const response = await client.eventLog.stream( + { sessionID }, + { + onSseError: (cause) => { + if (abort.signal.aborted) return + connected = false + streamError = cause instanceof Error ? cause.message : String(cause) + render() + }, + onSseEvent: () => { + if (timer) clearTimeout(timer) + timer = undefined + const recovered = !connected + connected = true + streamError = "" + render() + if (recovered) void requestSync() + }, + }, + ) + timer = setTimeout(() => { + if (abort.signal.aborted) return + streamError = "Live activity stream did not connect" + render() + }, 5_000) + let next = await response.stream.next() + while (!next.done && !abort.signal.aborted) { + if (isActivity(next.value)) add(next.value) + next = await response.stream.next() + } + if (abort.signal.aborted) return + connected = false + streamError = "Live activity stream disconnected" + render() + } catch (cause) { + if (abort.signal.aborted) return + connected = false + streamError = cause instanceof Error ? cause.message : String(cause) + render() + } finally { + if (timer) clearTimeout(timer) + } + await new Promise((resolve) => setTimeout(resolve, 500)) + } + })() + + onCleanup(() => { + if (timer) clearTimeout(timer) + abort.abort() + unsubs.forEach((unsub) => unsub()) + }) + }) + + const latest = createMemo(() => events.at(-1)) + + return { + get events() { + return events + }, + get connected() { + return state.connected + }, + get error() { + return state.error + }, + latest, + count(source: ActivitySource) { + return events.filter((event) => event.source === source).length + }, + changes(channel: WorkbenchChannel) { + return state.changes[channel] + }, + revision(channel: WorkbenchChannel) { + return state.revision[channel] + }, + last(channel: WorkbenchChannel) { + return state.last[channel] + }, + ack(channel: WorkbenchChannel) { + setState("changes", channel, 0) + }, + } + }, +}) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index c22ce98309..07443c9fd2 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1949,6 +1949,14 @@ export default function Layout(props: ParentProps) { return (
+ +
+ Read-only observer mode · execution, configuration, secrets, and terminal input are disabled +
+