Updated documentation

This commit is contained in:
Tony West committed 2026-08-01 00:08:40 -04:00
1 parent fe4674d67d
commit 9b914dcecd
1 file changed
+34 -11
+34 -11
View File
@@ -75,14 +75,21 @@ web/
themes/bishop-fox.css Default dark theme (BF brand palette)
lib/api.ts ws.ts Typed fetch wrapper + WS singleton (auto-reconnect)
lib/deaddrop.ts .jord export/import (gzip via CompressionStream, base64 raw bytes)
lib/dashboardWidgets.tsx Dashboard widget catalog (id, availability, data needs)
lib/dashboardPresets.tsx Layout presets + the team/local default layouts
lib/useDashboardPolling.ts The dashboard's single poll loop
stores/*.ts Zustand: request, fuzz, intercept, settings, callback,
ws, manipulateWS, team, teamFlagged, teamSharedConfig, deadDrop
pages/ History, Intercept, Manipulate (HTTP+WS), Generator,
Executor, Fuzz, DeadDrop, Login, Settings, Callbacks, Plugins,
PluginTabPage
ws, manipulateWS, team, teamFlagged, teamSharedConfig, deadDrop,
dashboardData (polled), dashboardLayout (persisted)
pages/ Dashboard (a frame over the widget catalog), History,
Intercept, Manipulate (HTTP+WS), Generator, Executor, Fuzz,
DeadDrop, Login, Settings, Callbacks, Plugins, PluginTabPage
components/ DynamicConfigForm (auto-gen plugin ExecProvider config),
DetectRuleModal (per-rule config; opened from the
Detect -> Rules table, whose categories collapse)
Detect -> Rules table, whose categories collapse),
DashboardPanel (shared widget chrome; slot-aware via context),
DashboardLayoutSettings (the layout editor)
components/dashboard/ One file per dashboard widget
examples/plugins/
hello-provider/ ExecProvider + GraphProvider example
hello-tab/ hello-feature/ Top-level tab + sub-tab plugin examples
@@ -345,19 +352,27 @@ Tracked via `go.mod` / `go.sum` only — repo does **not** vendor (see "no vendo
- **Sliver C2 uses custom protowire encoding** to avoid the massive Sliver dep tree. `internal/sliver/`: `wire.go` (hand-encoded proto), `client.go` (gRPC), `commands.go` (text command dispatcher). Binary downloads/screenshots cached server-side, 60s TTL. `POST /sliver/command` is the main interface — `{input}` → `{output, error, downloadId?, filename?}`. Active session tracked in `Client.activeSessionID`.
- **Mythic C2 is a built-in exec provider mirroring Sliver, over plain HTTP + GraphQL (no new deps).** Third sub-tab on the Execute page alongside Web Shell + Sliver. `internal/mythic/`: `config.go` (`Config{URL, Username, Password, APIToken}`), `client.go` (`*http.Client` + GraphQL helper; auth = `POST /auth` → JWT **or** `apitoken` header, both supported; `InsecureSkipVerify` for Mythic's self-signed nginx cert — same stance as the proxy's upstream dials), `subscription.go` (Hasura graphql-ws live-callback events over `gorilla/websocket` as a *client* — reused, not a new dep — broadcast as `mythic.event`), `commands.go` (`Dispatch`/`CommandResult` REPL). Operates at the **callback** level only: lists callbacks, `use <display_id>` selects the active one, and typed lines are issued **agent-agnostically** as Mythic tasks (`createTask` mutation) with output retrieved by **polling** `WaitForTaskOutput`. `help` queries the callback's real `loadedcommands` (agent-specific), not static text. Installing agents / generating payloads are **out of scope** (server-side `mythic-cli` operations). Stateless like Sliver — connection details live only in `Client` memory, wiped on disconnect; proxy-mode only (nil in listener/teamserver). **GraphQL field names + file-transfer URLs target Mythic 3.3+ and may need reconciliation against other versions** (the connect UI shows a `Mythic 3.3+` support note; the `fileDownloadPath` const and query strings are the places to adjust). No Mythic scripting library is vendored (same "avoid the C2's dep tree" rationale as Sliver's protowire).
- **Team Server mode (`--listener --teamserver`)** extends listener mode with auth + collaboration. 32-char hex token generated at startup, printed to console. All teamserver requests (except `GET /api/v1/mode`) require `Authorization: Bearer <token>`. Nicknames via `X-Joro-Nickname`. Teamserver is API-only (no frontend). Proxy connects via `listenerUrl` and forwards team requests with `proxyToListener()`. Team data stored in `callbacks.db`. Active users tracked via WS hub client map (conn → nickname). Proxy maintains a WS relay (`ws_relay.go`) that forwards `team.*` events to the local hub. Nickname rename via `POST /api/v1/team/nickname` atomically renames in hub map and broadcasts `team.nickname_changed`, avoiding the disconnect/reconnect a full relay restart would cause; relay's cached nickname updated via `ListenerRelay.SetNickname()`. On 409 (collision), proxy rolls back the local `teamNickname` setting and surfaces the error. **Team Chat is a persisted session log.** On join the client fetches history (`GET /team/chat`, `listChatMessages({limit:200})` reversed to chronological) and `addMessage` dedupes by id so the live WS echo doesn't double up. Connect/disconnect/rename are persisted **server-side** as `author:"*"` system messages in `team_chat` (not synthesized client-side): the hub's `SetOnConnect`/`SetOnDisconnect` callbacks + `handleTeamRename` call `APIServer.postSystemChat`, which stores the message and broadcasts `team.chat`. `team.presence` drives only the active-users sidebar.
- **Flagged requests are self-contained artifacts, not history pointers.** Request history is local to each proxy instance, so a teammate on another machine can't dereference an ID into someone else's history. A flagged request therefore carries its own raw request/response bytes into the `team_flagged_requests` table on the team server. A **single** `POST /api/v1/team/flagged` both stores the artifact and creates a referencing chat message (via `CreateMessage`'s optional `refID` → new nullable `team_chat.ref_id` column), broadcasting **both** `team.flagged` (summary) and `team.chat` (chip). This keeps every UI entry point — History context menu, Manipulate "🚩 Flag" button, and the `/flag <seq>` chat slash command — to one API call. Responses are capped at **256KB** (`maxFlaggedRespBytes` in `handlers_team.go`) with a `truncated` flag surfaced in the viewer. List returns summaries without blobs; `GET /team/flagged/{id}` returns base64 `reqRaw`/`respRaw`. The `team_chat.ref_id` column is added by an idempotent `ALTER TABLE` in `MigrateDB` (swallows "duplicate column") since `CREATE TABLE IF NOT EXISTS` can't alter a pre-existing table. Frontend: `teamFlaggedStore` (fed by `team.flagged`/`team.flagged.deleted` WS events + a `listFlagged` poll in `Dashboard.fetchData`), a clickable chat chip and a **Flagged Requests panel split under Recent Interactions on the Dashboard** (team mode only), both opening `FlaggedRequestModal` (read-only CodeMirror + `ResponseRender`, with a "Send to Manipulate" button reusing `navigate('/manipulate', {state:{rawReq}})`). All flag entry points are gated on team mode (`listenerUrl` + `teamToken` + `teamNickname`).
- **Flagged requests are self-contained artifacts, not history pointers.** Request history is local to each proxy instance, so a teammate on another machine can't dereference an ID into someone else's history. A flagged request therefore carries its own raw request/response bytes into the `team_flagged_requests` table on the team server. A **single** `POST /api/v1/team/flagged` both stores the artifact and creates a referencing chat message (via `CreateMessage`'s optional `refID` → new nullable `team_chat.ref_id` column), broadcasting **both** `team.flagged` (summary) and `team.chat` (chip). This keeps every UI entry point — History context menu, Manipulate "🚩 Flag" button, and the `/flag <seq>` chat slash command — to one API call. Responses are capped at **256KB** (`maxFlaggedRespBytes` in `handlers_team.go`) with a `truncated` flag surfaced in the viewer. List returns summaries without blobs; `GET /team/flagged/{id}` returns base64 `reqRaw`/`respRaw`. The `team_chat.ref_id` column is added by an idempotent `ALTER TABLE` in `MigrateDB` (swallows "duplicate column") since `CREATE TABLE IF NOT EXISTS` can't alter a pre-existing table. Frontend: `teamFlaggedStore` (fed by `team.flagged`/`team.flagged.deleted` WS events + a `listFlagged` poll in `useDashboardPolling`), a clickable chat chip and the **Flagged Requests dashboard widget** (available only in team mode, in whichever slot the operator's layout puts it), both opening `FlaggedRequestModal` (read-only CodeMirror + `ResponseRender`, with a "Send to Manipulate" button reusing `navigate('/manipulate', {state:{rawReq}})`). All flag entry points are gated on team mode (`listenerUrl` + `teamToken` + `teamNickname`).
- **Dead Drop shares requests via a portable file, no team server.** Where Flagged Requests need a live team server, the **Dead Drop** tab lets an operator stage captured requests (History context menu → "Stage for Dead Drop", on both the row menu — fetches bytes via `api.getRequest(id)` — and the detail menu — uses `selectedDetail`), reorder them by **drag-and-drop**, annotate, and export a self-contained **`.jord`** file that any Joro instance can import and view. **Entirely frontend, no backend/API changes.** `deadDropStore` (Zustand, **in-memory** — staged list is lost on reload; the exported file is the durable artifact) holds full records (base64 `reqRaw`/`respRaw` from `RequestDetail`). `lib/deaddrop.ts` serializes a `{type:"joro-deaddrop", version, exportedAt, author, title, note, items[]}` bundle: gzip via `CompressionStream` (plain-JSON fallback when absent) on export; on import it sniffs the gzip magic bytes `0x1f 0x8b` → `DecompressionStream`, else parses plain JSON (mirrors the backend `gunzipIfNeeded`). The **author** field is operator-entered on the staging screen (prefilled from `teamNickname` if set — no nickname exists in local mode). The viewer reuses `FlaggedRequestModal`, generalized with optional `title`/`byline` props (defaulted to the flag strings so `Dashboard` is unchanged). The access point is intentionally obscure — a low-profile icon in the header (with a staged-count badge), separate from the standard tabs rather than a named tab in `nav.ts`; the `/deaddrop` route still exists.
- **Operator presence carries opt-in status + active project name.** `team.presence` is `[{nickname, status, project}]` (not bare nicknames): the hub keeps a `presenceMeta` map (nickname → `{status, project}`) and `ActiveUsersDetailed()` joins it with `clients` (default `online`), **omits appear-offline** users, and feeds both `broadcastPresence()` and `GET /team/users`; operators set **status** (online/away/dnd/appear-offline) + a **Share project name** toggle (default off) from the Dashboard Active Users sidebar (persisted in `Settings.TeamStatus`/`ShareProjectName`), which propagate via a forwarded `POST /team/presence` → `hub.SetPresenceMeta` (rebroadcasts, **never a relay reconnect**, so the session log isn't disturbed); the proxy pushes on join + on setting change, the server keeps meta across disconnects so a relay blip doesn't wipe a shared project, and `Rename` migrates the meta. The shared value is the active project config name (from the frontend `projectStore`), gated by the toggle.
- **Team relay connection state is surfaced to the UI, and team polls time out independently of the app.** The proxy→teamserver relay (`ListenerRelay`, `ws_relay.go`) reports transitions to the hub via `Hub.SetRelayState(state, err, httpStatus)` (states: `connecting`/`connected`/`disconnected`/`idle`), which broadcasts a **`team.relay`** WS event. Dedup lives in the hub (by `state` string) so the 1s→30s backoff loop can call freely without spamming; `run()` guards each call with a non-blocking `<-stop` check so a stale reconnect goroutine can't clobber the current one, and `Update()` sets `connecting` synchronously / calls `ClearRelayState()` (→`idle`) when stopped. On every `/ws` client connect, `ServeWS` re-broadcasts the last state (via the channel, **unconditional** — the local browser has no nickname) so a page reload mid-outage shows the truth. Frontend: `teamConnectionStore` (default `connecting`) fed by the `team.relay` case in `ws.ts` (toasts **only** on connected→disconnected); drives the App header dot color, the Dashboard `NetworkGraph` `connected` prop (gated on `settings.listenerUrl` so solo mode stays "connected"), and a status row in the Settings Team Server card. `req()` (`lib/api.ts`) takes an optional `timeoutMs` (`AbortController`); the listener-proxied polling GETs (chat/notes/flagged/users/callbacks/xss lists) use `TEAM_POLL_TIMEOUT` (4s) so a dead team server can't hang them for the full server-side `proxyToListener` timeout (10s; client abort cancels it via `r.Context()`) and starve the browser's ~6-connection HTTP/1.1 pool (which would delay unrelated local calls like `getSettings`). Dashboard `fetchData` + Notes `fetchHosts`/`fetchNotes` also **skip** those proxied polls when state is `disconnected`. Do **not** add a global `req()` timeout — `/manipulate/send`, fuzzer, and uploads can be legitimately slow.
- **Operator presence carries opt-in status + active project name.** `team.presence` is `[{nickname, status, project}]` (not bare nicknames): the hub keeps a `presenceMeta` map (nickname → `{status, project}`) and `ActiveUsersDetailed()` joins it with `clients` (default `online`), **omits appear-offline** users, and feeds both `broadcastPresence()` and `GET /team/users`; operators set **status** (online/away/dnd/appear-offline) + a **Share project name** toggle (default off) from the Active Users dashboard widget (`components/dashboard/ActiveUsersWidget.tsx`; persisted in `Settings.TeamStatus`/`ShareProjectName`), which propagate via a forwarded `POST /team/presence` → `hub.SetPresenceMeta` (rebroadcasts, **never a relay reconnect**, so the session log isn't disturbed); the proxy pushes on join + on setting change, the server keeps meta across disconnects so a relay blip doesn't wipe a shared project, and `Rename` migrates the meta. The shared value is the active project config name (from the frontend `projectStore`), gated by the toggle.
- **Presence is gated on the Team Chat widget.** An operator whose **team** dashboard layout has no `team-chat` widget announces `status: "offline"`, and `ActiveUsersDetailed()` already omits appear-offline users — so they don't appear on the roster at all. No backend change: the frontend simply sends a different status. "Displayed" means **in the active layout**, not currently mounted — the push lives in `App.tsx` and reads `layoutIncludes(layout, 'team', 'team-chat')`, so navigating to another tab doesn't flip anyone offline. The Active Users widget disables its status select and says why when this is in effect, and the Settings layout editor warns before you remove chat from the team layout.
- **Team relay connection state is surfaced to the UI, and team polls time out independently of the app.** The proxy→teamserver relay (`ListenerRelay`, `ws_relay.go`) reports transitions to the hub via `Hub.SetRelayState(state, err, httpStatus)` (states: `connecting`/`connected`/`disconnected`/`idle`), which broadcasts a **`team.relay`** WS event. Dedup lives in the hub (by `state` string) so the 1s→30s backoff loop can call freely without spamming; `run()` guards each call with a non-blocking `<-stop` check so a stale reconnect goroutine can't clobber the current one, and `Update()` sets `connecting` synchronously / calls `ClearRelayState()` (→`idle`) when stopped. On every `/ws` client connect, `ServeWS` re-broadcasts the last state (via the channel, **unconditional** — the local browser has no nickname) so a page reload mid-outage shows the truth. Frontend: `teamConnectionStore` (default `connecting`) fed by the `team.relay` case in `ws.ts` (toasts **only** on connected→disconnected); drives the App header dot color, the `NetworkGraph` widget's `connected` prop (gated on `settings.listenerUrl` so solo mode stays "connected"), and a status row in the Settings Team Server card. `req()` (`lib/api.ts`) takes an optional `timeoutMs` (`AbortController`); the listener-proxied polling GETs (chat/notes/flagged/users/callbacks/xss lists) use `TEAM_POLL_TIMEOUT` (4s) so a dead team server can't hang them for the full server-side `proxyToListener` timeout (10s; client abort cancels it via `r.Context()`) and starve the browser's ~6-connection HTTP/1.1 pool (which would delay unrelated local calls like `getSettings`). `useDashboardPolling` + Notes `fetchHosts`/`fetchNotes` also **skip** those proxied polls when state is `disconnected`. Do **not** add a global `req()` timeout — `/manipulate/send`, fuzzer, and uploads can be legitimately slow.
- **Team notes have soft ownership + in-place edit.** `PUT /api/v1/team/notes/{id}` edits content (bumps `updated_at`); both PUT and DELETE fetch the note first and 403 unless `team.NicknameFromContext` matches the note's `author` ("soft" because nickname is the only identity). The frontend (`Notes.tsx`) hides the ✎/✕ affordances on notes the current `teamNickname` doesn't own, and shows an "(edited)" marker when `updatedAt != createdAt`. Edit/delete broadcast `team.note` / `team.note.deleted`. Local notes (`internal/notes`) also expose `PUT /notes/{id}` but with **no** ownership check (single operator); their UI affordances always show.
- **The project config name is the engagement identity (no separate Project ID).** Every feature that labels an engagement (team presence, published configs, collab chips) uses the **active project config name** (`s.activeProjectConfig`). Wire/JSON key is `project`; the teamserver's `team_shared_configs`/`team_collab_requests` SQLite columns stay `project_id` (Go field `ProjectID`, JSON tag `project`) — no DB migration. Chat chips distinguish artifact kinds via `team_chat.ref_type` (`flagged`/`collab`/`config`); `CreateMessage` takes `(id, author, text, refID, refType)`. `handleLoadProjectConfig`/`handleSaveProjectConfig`/`handleSwitchProject` share `buildProjectConfig`/`applyProjectConfig`/`saveProject`/`gzipJSON`/`gunzipIfNeeded` — keep all paths on them.
- **Project browser + switcher + auto-save (Settings → Project, header dropdown).** A header `ProjectSwitcher` dropdown and the `ProjectBrowser` component (`web/src/components/ProjectBrowser.tsx`, embedded in the Settings page under the **Project** sidebar category) list/create/import/load projects, both driven by `projectStore` (Zustand). The dropdown's **Manage…** navigates to `/settings` with router state `{category:'project'}`; `Settings.tsx` reads `location.state.category` to open that sub-menu, then clears the state. `projectConfigFile` is **schema v5** (v4 added `autoSave` + `saveHistory`, both default true; v5 adds the `detect*` fields, with `detectEnabled` defaulting true; `normalizeProjectConfig` is the single place those version-gated defaults live). Each project has a `<name>.meta.json` **sidecar** (counts + prefs) — authoritative for prefs locally, so `handleSetProjectPrefs` toggles them without decompressing the `.joro`. `saveProject(name)` (shared by manual save / switch / auto-save) snapshots via `buildProjectConfig`, omitting request history when `saveHistory` is off. `StartAutoSaveLoop` (`internal/api/autosave.go`, from `runProxyMode`) ticks every 30s and saves the active project when `autoSave` is on and `liveStateSignature()` changed. Switching (`handleSwitchProject`) auto-saves the outgoing project when its `autoSave` is on, else the UI confirms Save/Discard/Cancel (also for an unnamed scratch session). New project (`NewProjectModal` → `handleNewProject`) prompts snapshot-current vs. start-empty. `activeProjectConfig` is **not** persisted across restarts. Frontend rehydration goes through `web/src/lib/applyProject.ts` (`applyProjectResp`), which fires a `joro:project-changed` window event.
- **The Dashboard is a frame over a widget catalog, with a layout per mode.** `web/src/pages/Dashboard.tsx` renders no panels of its own: it resolves the operator's stored layout into widgets and draws the chosen preset's rows above a shared, resizable bottom bar. Pieces: `lib/dashboardWidgets.tsx` (the catalog — `WidgetId`, `WidgetDef`, `WIDGETS`, availability), `lib/dashboardPresets.tsx` (`classic`/`triptych`/`grid`/`stack` + the two default layouts), `stores/dashboardLayoutStore.ts` (persistence), `lib/useDashboardPolling.ts` (the one poll loop), `stores/dashboardDataStore.ts` (what it polls), `components/DashboardPanel.tsx` (shared chrome), and `components/dashboard/*Widget.tsx`. Adding a widget = a component + one `WIDGETS` entry; nothing in Go, the layout store, or the Settings editor changes.
- **Two layouts, swapped automatically.** `team` defaults to the pre-widget layout byte-for-byte (`classic`: network graph | recent interactions / flagged requests, over chat + active users); `local` defaults to `grid`: detected findings / recent interactions over network graph / proxy health, with both bar slots empty so the bar and its drag handle are hidden. Customization is **presets + a dropdown per slot** (Settings → Appearance → Dashboard layout) — deliberately **not** drag-and-drop and **no new npm dependency**.
- **The bottom bar belongs to the frame, not to a preset.** Every preset shares `barMain` + `barAside`, so the resizer is written once and survives swapping chat out of it. Height is committed to the store **once on mouseup**, not per mousemove.
- **Sanitize and resolve are separate, and only resolve drops widget ids.** `sanitize()` (the single gate for both `localStorage` and User Config data) validates the version, preset, and slot keys but **preserves unknown widget ids verbatim** — stripping them would let a downgrade silently destroy a layout built on a newer build, since the next `setSlot` persists the stripped copy. Unknown *and* unavailable ids are dropped at render instead, where the slot simply collapses and flex siblings take the space (which is how the old dashboard behaved when it hid the graph in listener mode). **The reconciled result is never written back** — same rule `hiddenTabs` follows, so a team widget survives a trip through local mode.
- **Availability is two declarative flags, not a predicate.** `requiresProxyMode` (network graph, detect findings, proxy health — their endpoints don't exist in listener mode) and `requiresTeam` (flagged requests). Chat and active users are **not** `requiresTeam`: they degrade to a local scratchpad and a list of one. Flags rather than a closure so the Settings editor can say *why* a widget is unavailable.
- **Polling is centralized and keyed on the resolved layout.** `needs` is the union of the `DataNeed`s of the widgets actually in the layout, computed **before** render — not from mount-time registration, which races the first tick. A widget that isn't on the dashboard costs no requests. The `teamDown` short-circuit (skip listener-proxied polls when the relay is down) lives here; do not scatter it into widgets.
- **Persistence: `localStorage['joro-dashboard-layout']` (schema v1), plus `userConfigFile` v4.** Follows the `joro-hidden-tabs` pattern (hand-rolled `load`/`persist`, no zustand middleware). The Go side treats it as an opaque `json.RawMessage`, like `PluginStates`; **no normalizer** is needed since `userConfigFile.Version` is never read and a v3 file just decodes to `nil`. Not in the Go `Settings` struct (nothing there is cosmetic) and not in `projectConfigFile` (that is shared with teammates; this is per-operator chrome). First load seeds `barHeight` from the legacy `joro-chat-height` key.
- **Project-config settings live in Settings → Project, inline under the project table.** `web/src/components/ProjectSettings.tsx` owns the **Team Server** card, **Team Configs** panel (`TeamConfigsPanel`), and the tabbed **Filtering** card (Scope / Noise / Match&Replace / Custom Data) — all backed by `projectConfigFile`; it hydrates on mount + on `joro:project-changed`. It is rendered inline by `ProjectBrowser.tsx` below the project table (no longer a modal). `web/src/pages/Settings.tsx` is a sidebar-driven page (`CATEGORIES` array + `category` state): **Project** (the `ProjectBrowser`), **General** (user-config + machine settings: Server, Proxy, Connection, SOCKS, User Config), **Appearance**, and **Testing Browser**.
- **Feature: publish / load a shared project config (async, whole-project).** `GET /api/v1/configs/export` serializes the current live project (via `buildProjectConfig` + `gzipJSON`, the same helpers the save handler uses) to base64(gzip); the frontend publishes it to the `team_shared_configs` table on the team server (`POST /team/configs {name, project, config}`, blob opaque to the server) and it appears in the Settings **Team Configs** panel. Loading calls `POST /api/v1/configs/import`, which writes a **new** local project file and runs the shared `applyProjectConfig` — **preserving the importer's own nickname** (adopts the shared listener URL + token) and returning **409 on a name collision** rather than clobbering. This shares the full project (scope/M&R/customdata + noise, notes, highlights, history, plugin states, team settings).
- **Team chat slash commands** (all handled in `Dashboard.sendMessage`, team-mode only; in solo mode they show a "connect to a team server" hint instead of posting literal text). `/flag <seq> [note]` and `/collab <note>` (above); `/slap <user>` and `/me <text>` post **IRC-style action messages** — `sendChatMessage(text, 'action')` sets `refType:"action"` (the only client-settable refType; `handleCreateChatMessage` rejects forged `flagged`/`collab`/`config`), rendered italic as `* <author> <text>` with no name-colon prefix; `/nick <name>` calls `updateSettings({teamNickname})` (reuses the rename→`renameOnTeamServer` path, surfaces 409); `/help` appends a **local-only** `author:"*"` system message (the system-message span is `whitespace-pre-wrap` so the multi-line list renders).
- **Team chat slash commands** (all handled in `TeamChatWidget.sendMessage`, team-mode only; in solo mode they show a "connect to a team server" hint instead of posting literal text). `/flag <seq> [note]` and `/collab <note>` (above); `/slap <user>` and `/me <text>` post **IRC-style action messages** — `sendChatMessage(text, 'action')` sets `refType:"action"` (the only client-settable refType; `handleCreateChatMessage` rejects forged `flagged`/`collab`/`config`), rendered italic as `* <author> <text>` with no name-colon prefix; `/nick <name>` calls `updateSettings({teamNickname})` (reuses the rename→`renameOnTeamServer` path, surfaces 409); `/help` appends a **local-only** `author:"*"` system message (the system-message span is `whitespace-pre-wrap` so the multi-line list renders).
- **Feature: collaboration request → diff-aware swap (via chat, rules-only).** The `/collab <note>` chat slash command posts a `team_collab_requests` row carrying a **3-field bundle** (scope/M&R/customdata JSON, built by `gatherCurrentRules()`) + a `refType:"collab"` chat chip naming the active project. Clicking the chip opens `CollabSwapModal`, which diffs the incoming bundle against the operator's current rules and offers four actions: **merge / save-and-load / load-without-saving / keep-current**. Merge/replace go through `POST /api/v1/configs/apply-shared {config, mode}` → bulk setters on **scope/replace/customdata only** — the swap **never touches history, notes, highlights, team settings, or the project-file schema**. `save-and-load` first calls the existing `saveProjectConfig`; `keep-current` applies nothing. Every choice records `POST /team/collab/{id}/accept`.
- **Plugin system uses Go's `plugin` package** (.so on Linux, .dylib on macOS). **Linux and macOS only** — Go's plugin package does not support Windows or any GOOS outside Linux/Darwin/FreeBSD. `joro --build-plugin` errors immediately on Windows; release binaries on Windows still load fine but cannot use plugins. Plugin support requires the host binary to be built with `CGO_ENABLED=1`; the goreleaser config + Makefile do this via `zig cc`. Loaded at startup from `~/.joro/plugins/`. Each exports `var Plugin sdk.Plugin`. SDK in `sdk/sdk.go` as separate Go module (`replace` in go.mod). Six plugin types: `exec_provider`, `tab`, `feature`, `proxy_hook`, `dashboard` (only one active), `interact_provider`. Names match `^[a-z0-9][a-z0-9_-]*$`, can't be reserved (`api`, `ws`, `ext`, `system`). All method calls wrapped with panic recovery. Plugins get scoped data dir (`~/.joro/plugin-data/{name}/`) and scoped WS broadcast (events auto-prefixed `plugin.{name}.`). Tab/feature/dashboard plugins serve embedded UIs at `/plugin/{name}/` in iframes sandboxed with `allow-scripts allow-forms allow-same-origin`. `allow-same-origin` makes their `/api/v1/*` calls genuine same-origin requests (so they pass `originGuard` with no plugin code changes); it's safe because plugins are already trusted native code running in-process, so the iframe sandbox was never a real boundary against them. Upload/delete require restart — Plugins page has "Restart Now" button (`POST /api/v1/system/restart`, same `syscall.Exec` re-exec as updates). Proxy hooks run in load order; `OnRequest` returning nil drops a request. `ConfigField.Type` ∈ `text|password|textarea|file|checkbox`; checkboxes serialize as `"true"`/`"false"` to preserve `map[string]string` wire shape.
- **Plugin state persistence is opt-in via two SDK interfaces.** `UserStatefulPlugin` (`ExportUserState`/`ImportUserState`) — operator-scoped state riding with User Configs (API keys, personal tokens). `ProjectStatefulPlugin` (`ExportProjectState`/`ImportProjectState`) — engagement-scoped state riding with Project Configs (active sessions, instance configs). May implement either, both, or neither. State bytes are opaque — plugins own schema and migration. No autosave on shutdown, no separate on-disk state files: serialized only when user saves a User/Project Config, applied only on load. `internal/plugins/manager.go` exposes `{Export,Apply}{User,Project}States`. Config handlers in `internal/api/handlers_configs.go` embed a `pluginStates` map (name → base64 blob) in `userConfigFile` (v2) and `projectConfigFile` (v3), and ghost-preserve blobs for plugins not installed locally via `APIServer.pendingUserPluginStates` / `pendingProjectPluginStates`, so a load→save round-trip never drops state for missing plugins. Load responses include `unknownPluginStates: []` shown in Settings.
- **Plugin state persistence is opt-in via two SDK interfaces.** `UserStatefulPlugin` (`ExportUserState`/`ImportUserState`) — operator-scoped state riding with User Configs (API keys, personal tokens). `ProjectStatefulPlugin` (`ExportProjectState`/`ImportProjectState`) — engagement-scoped state riding with Project Configs (active sessions, instance configs). May implement either, both, or neither. State bytes are opaque — plugins own schema and migration. No autosave on shutdown, no separate on-disk state files: serialized only when user saves a User/Project Config, applied only on load. `internal/plugins/manager.go` exposes `{Export,Apply}{User,Project}States`. Config handlers in `internal/api/handlers_configs.go` embed a `pluginStates` map (name → base64 blob) in `userConfigFile` (v4) and `projectConfigFile` (v5), and ghost-preserve blobs for plugins not installed locally via `APIServer.pendingUserPluginStates` / `pendingProjectPluginStates`, so a load→save round-trip never drops state for missing plugins. Load responses include `unknownPluginStates: []` shown in Settings.
- **Interactsh shipped as an example plugin** (`examples/plugins/interactsh/`), not native. Reimplements interactsh wire protocol with stdlib only — RSA-2048 keygen, RSA-OAEP-SHA256 for session AES-256 key, AES-CTR for per-interaction payloads, per-instance `http.Client` with opt-in `InsecureSkipVerify` for self-signed self-hosted servers. Main binary has zero `projectdiscovery/*` deps. Implements `ProjectStatefulPlugin` (`state.go`): saves RSA keypair (PKCS#1 PEM), correlation ID, nonce, secret key, auth token, enabled state per server. Loading reconstructs servers and resumes polling without re-registering, so in-flight interactions keep decrypting against the existing session. Correlation IDs only useful while the remote server retains them (~24h on oast.live).
- **No `vendor/` directory.** Tracked via `go.mod`/`go.sum` only. Plugins have own `go.mod` with `replace github.com/BishopFox/joro/sdk => ../../../sdk`, building in mod-mode. If the main binary built in vendor-mode, its module graph would hash differently than the plugin's and Go's plugin loader would reject the .so/.dylib with `plugin was built with a different version of package github.com/BishopFox/joro/sdk`. Do not run `go mod vendor` or commit a `vendor/` tree.
- **Theming uses CSS custom properties + `data-theme` attribute.** See Theme Architecture below.
@@ -425,7 +440,15 @@ Limited automated coverage (`go test ./...` — `internal/callback/listeners_tes
9. Generate PHP/ASHX shell, verify auth key + content
10. Execute: enter target + shell + key, run `whoami`
11. Plugins: `./joro --build-plugin examples/plugins/hello-feature --install`, restart, verify load; upload via UI + "Restart Now" + verify
12. Detect — browse a target, then check the Detect tab within a second or so:
12. Dashboard layouts:
- Team mode, fresh install → the dashboard is unchanged from before widgets existed, chat height included (seeded from the old `joro-chat-height` key).
- Settings → Appearance → Dashboard layout: switch the preset to **Classic** → the graph beside a stacked pair, bar intact; drag the handle, reload → the height persists. Set a slot to **None** → its siblings expand, with no placeholder card. Switch preset away and back → your widget picks survive.
- Disconnect the team server → Flagged Requests disappears and the rest holds; reconnect → it returns (proves stored ids are never rewritten). No 4s hangs in the network tab.
- Listener mode (`./joro --listener`) → the network graph / detect / proxy health slots collapse **and their endpoints are never requested**.
- Solo proxy → the local default (a 2×2 grid: detected findings / recent interactions over network graph / proxy health). Clicking a severity chip lands on `/detect` filtered to that band; clicking a row lands on that finding with its detail pane open.
- Presence gating (two team-mode instances): remove **Team Chat** from operator A's team layout → A vanishes from B's Active Users list and A's own status select greys out with an explanation; add it back → A reappears. Navigating A to another tab must **not** drop them from the roster.
- User Config round trip: customize both layouts, save a User Config, **Reset** both, load the config → both return. Hand-edit a saved config to `"version": 3` and delete `dashboardLayout` → it loads cleanly and leaves the current layout alone.
13. Detect — browse a target, then check the Detect tab within a second or so:
- Findings stream in, sorted Critical-first; the per-severity chips in the count bar sum to the table total; revisiting a page bumps `#` **without** adding a row.
- **Severity visibility** is one field, driven from two places. On a fresh load the Severity dropdown reads **4 selected** (not "Any" — Info is filtered out) and the Info chip is dimmed, so the chips sum higher than the table total. Click the dimmed Info chip → Info rows appear and **every other band stays**; click a bright Critical chip → Critical rows leave and everything else stays. Unchecking a band in the dropdown dims its chip and vice versa. Dropdown **Clear** → "Any" and every band shows.
- **Positive controls** (serve a fixture containing each): `AKIAIOSFODNN7EXAMPLE`, a `-----BEGIN RSA PRIVATE KEY-----` block, a Luhn-valid PAN such as `5500005555555559`, `Set-Cookie: sessionid=…` with no `HttpOnly`, and an `<input type="password">`. Secret and PII evidence must render **redacted** (`AKIA******MPLE`) in the table and on first selection; the eye toggle beside the detail pane's Evidence heading reveals the real value. Check the highlight lands **exactly** on the match — not in the header block (offsets relative to the wrong buffer) and not a few characters to its left (byte offsets applied to a CRLF-normalized document). Use a response with many headers and a long body, where a small shift is still visible.