Update this file whenever significant changes are made to the project - new packages, changed commands, architectural decisions, or new conventions. Outdated instructions cause mistakes. If you add a dependency, change a build step, or restructure a package, update the relevant section here before finishing the task.
Joro is an intercepting HTTP/HTTPS proxy and web shell toolkit for penetration testing. It is a single Go binary that starts a proxy server and serves a React web UI - there is no CLI mode.
Three modes:
- Proxy mode (default): intercepting proxy + web UI
- Listener mode (
--listener): out-of-band callback server (DNS + HTTP + SMTP) for blind vuln detection - Team Server mode (
--listener --teamserver): listener + authenticated team collaboration (chat, notes, flagged requests, shared project configs + collaboration swap)
Ports & paths:
- Proxy
:8080(--proxy-port), UI/API:9090(--ui-port) - Data dir
~/.joro/— CA cert/key +callbacks.db - Listener: DNS
:53(--dns-port), HTTP:80(--http-port), HTTPS:443(--https-port,0to disable), SMTP:25(--smtp-port,0to disable), SMTPS:465(--smtps-port,0to disable), FTP:21(--ftp-port,0to disable), FTPS:0(--ftps-port, implicit TLS, disabled by default), LDAP:389(--ldap-port,0to disable), LDAPS:0(--ldaps-port, implicit TLS, disabled by default), domain via--domainor UI, optional external TLS cert via--tls-cert+--tls-key(both required; replaces the auto-generated self-signed leaf, shared by HTTPS/SMTPS/FTPS/LDAPS and STARTTLS)
main.go Entrypoint (proxy or listener mode)
internal/
config/ Config struct + defaults
event/ Shared WSEvent struct (avoids proxy/callback import cycle)
callback/ SQLite (modernc.org/sqlite), token CRUD, DNS + HTTP listeners
cert/ ECDSA P-256 CA, leaf gen, sync.Map cache
proxy/
handler.go ServeHTTP: CONNECT vs plain HTTP
mitm.go TLS termination + HTTP/1.1 loop
intercept.go Per-request channel queue with timeout
noise.go Silently tunnels browser background traffic
scope.go Two-level scope (host + method/path)
store.go Thread-safe ring buffer
replace.go Match & Replace (raw-byte rules)
customdata.go Additive header/query/body injection
websocket.go ws_relay.go ws_store.go WS MITM (custom frames over net.Conn)
ws_manipulate.go User-driven outbound WS sessions
client.go helpers.go HTTP client + utilities
team/ Team chat + notes tables, bearer-token auth middleware
fuzzer/ Goroutine-pool fuzzer + in-memory campaign store (max 50)
shell/ ASP/ASPX/PHP/etc. shell gen + executor + dictionary
sliver/ gRPC client for Sliver C2 (custom protowire encoding)
plugins/ Plugin lifecycle: load, categorize, init, shutdown
api/
server.go routes.go APIServer + route registration + SPA embedding
ws.go WebSocket hub (gorilla/websocket)
handlers_*.go Per-feature handlers (requests, intercept, manipulate,
generate, execute, fuzzer, settings, certs, callbacks,
replace, customdata, plugins, team, sliver, ws, ...)
ws_relay.go Relay to teamserver, forwards team.* events
sdk/sdk.go Plugin SDK: interfaces, types, constants (separate Go module)
web/
embed.go //go:embed dist
dist/ Built frontend (gitignored except placeholder)
src/
main.tsx App.tsx index.css vite-env.d.ts
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)
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
components/ DynamicConfigForm (auto-gen plugin ExecProvider config)
examples/plugins/
hello-provider/ ExecProvider + GraphProvider example
hello-tab/ hello-feature/ Top-level tab + sub-tab plugin examples
hello-dashboard/ Dashboard replacement example
interactsh/ InteractProvider: stdlib-only interactsh client
Makefile build, build-frontend, build-all, dev, clean
go build ./... # Go-only (uses placeholder frontend, works without npm)
make build # Full (frontend + Go binary)
make build-all # Cross-platform → dist/
make dev # Backend with --dev flag (proxies UI to Vite)
cd web && npm run dev # Vite dev server (separate terminal, with `make dev`)
# Build a plugin from source (auto-detects .so vs .dylib)
./joro --build-plugin examples/plugins/hello-feature
./joro --build-plugin examples/plugins/hello-feature --install # also installs to ~/.joro/plugins/
# Or manually:
cd examples/plugins/hello-feature
go build -buildmode=plugin -o hello-feature.dylib . # macOS
go build -buildmode=plugin -o hello-feature.so . # LinuxTagged releases use goreleaser (config: .goreleaser.yaml). make build / make build-all are the local-dev workflow; goreleaser is only for cutting releases.
goreleaser release --snapshot --clean # Local snapshot — sanity check config
goreleaser check # Verify config syntax
git tag v1.0.1 && git push --tags && goreleaser release --clean # Cut release (needs GITHUB_TOKEN)Goreleaser produces 6 binaries (linux/{amd64,arm64}, darwin/{amd64,arm64}, windows/{amd64,arm64}) in tar.gz/zip archives with LICENSE + README, plus checksums.txt. All targets are built with CGO_ENABLED=1 via zig cc cross-compilers — required so Go's plugin package (dlopen-based) works in release binaries. make build-all mirrors the goreleaser config (also CGO=1 + zig cc) and produces all 6 targets. Requires zig on PATH (brew install zig); the goreleaser before: hook fails fast if it's missing. Linux glibc is pinned to 2.17 for wide compat. Releases are created as drafts so the operator publishes manually. -X main.version={{.Tag}} and -X main.commit={{.ShortCommit}} are injected at link time. Asset name template (joro_<version>_<os>_<arch>.tar.gz|zip) is duplicated in internal/update/update.go (runBinaryUpdate) — keep them in sync.
--build-plugin flag forwarding. runBuildPlugin in main.go reads runtime/debug.BuildInfo from the running binary and forwards ABI-relevant settings to the child go build -buildmode=plugin: -trimpath, -tags (e.g. netgo,osusergo), and CGO_ENABLED / GOARM64 / GOAMD64 env. The child build must inherit these so stdlib package hashes match the host: a release host (built with -trimpath -tags netgo,osusergo) and a plugin built bare hash stdlib packages differently, so dlopen rejects with plugin was built with a different version of package internal/goarch. The build banner prints the resolved Flags: and Env: so mismatches are visible. If a plugin fails to load with different version of package …, check that go version (run from the plugin's source dir) matches the host binary's runtime.Version().
Plugin go.mod must require the SDK at the host's pseudo-version. Under -trimpath, the Go compiler bakes the SDK's module version string into the position info embedded in exported declarations, which is part of the package's export hash (go:link.pkghashbytes.github.com/BishopFox/joro/sdk). The host's go.mod requires github.com/BishopFox/joro/sdk v0.0.0-00010101000000-000000000000 (Go's canonical zero pseudo-version, auto-generated by go mod tidy for a local-replace dep with no real tag), so a plugin must require the same full pseudo-version — a plain v0.0.0 produces a different export hash even with identical source and replace target, and plugin.Open() rejects with different version of package github.com/BishopFox/joro/sdk. The 5 example plugin go.mod files all use the full pseudo-version — do the same in any new plugin. (Only -trimpath (goreleaser) builds require the match; without it, position info uses absolute paths that happen to align, so make build + --build-plugin tolerate the mismatch.)
internal/update/update.go detects how the running binary was installed:
- Git mode (
.gitdir alongside executable):git fetch+ parse upstreammain.goversion literal; update viagit pull --ff-only+make build. - Binary mode (no
.git): hitsGET /repos/BishopFox/joro/releases/latest, downloads matching archive +checksums.txt, verifies SHA-256, atomically replaces the running binary.
Both paths fail silently on errors (no network, rate limit, missing git, bad checksum) — startup is never blocked. After successful update, update.Restart() re-execs.
Source in web/. TypeScript/React/Vite.
cd web
npm install # install dependencies
npm run dev # Vite dev server on :5173 (use with `make dev`)
npm run build # output to web/dist/ (embedded into Go binary)npm registry: machine may be on a private registry. If npm install fails, check npm config get registry and npm config delete registry.
All under /api/v1/. Request/response shapes are JSON unless noted. WebSocket events stream from /ws.
History & intercept
GET/DELETE /requests,GET /requests/:id— paginated history with filters; raw bytes base64. Filters:host,method,status,search(URL substring),exclude+extMode(file extensions),contentType,scope_only, andcontent+contentMode(include/exclude) +contentRegex(true) — matches a string (case-insensitive) or regex against the raw request + response bytes. Content search is server-side only (raw bytes aren't in the WS summary), so live-streamed rows bypass it until reload — same as the other body/URL filters.GET /intercept,PUT /intercept/enabled,POST /intercept/:id/{forward,drop}— queue control; forward accepts modifiedreqRawbase64
Manipulate
POST /manipulate/send— raw HTTP{raw b64, scheme, host}POST /manipulate/ws/connect—{raw b64, scheme: ws|wss, host}→{sessionId, status, rawResp, error}(always 200; sessionId empty on failure)POST /manipulate/ws/{id}/send—{opcode: text|binary|ping|pong|close, payload b64}POST /manipulate/ws/{id}/disconnect
Shells
POST /generate—{format: php|asp|aspx|ashx|jsp|cfm}POST /execute—{target, webshell, authKey, command}
Fuzzer
POST /fuzzer/start—{raw, scheme, host, wordlist[], wordlists?, attackMode?, threads, rateLimit, followRedirects, updateContentLength?, matchers, filters}POST /fuzzer/{id}/stop,GET /fuzzer/campaigns,GET /fuzzer/campaigns/{id}(paginated results),DELETE /fuzzer/campaigns/{id}POST /fuzzer/wordlist— multipart upload →{lines[], count}
Filters & rules (each: GET, PUT /enabled, POST add, DELETE /{id})
/noise—{pattern}/scope/rules—{pattern, methods, path, include}/replace/rules—{target, matchType, match, replace}(target ∈ request_header, request_body, response_header, response_body, ws_message)/customdata/items—{type, name, value}
WebSocket capture
GET /ws/messages?host=&offset=&limit=,DELETE /ws/messages
Settings & system
GET/PUT /settings,GET /certs/ca.crt,GET /mode(returns{mode: proxy|listener})POST /system/restart— graceful re-exec
Callbacks (listener mode)
GET/PUT /callbacks/config—{domain, responseIp}GET/POST /callbacks/tokens,DELETE /callbacks/tokens/{id}(cascade)GET/DELETE /callbacks/interactions?token_id=
Sliver C2
GET /sliver/status,POST /sliver/{connect,disconnect},GET /sliver/sessionsPOST /sliver/execute—{sessionId, command, args}POST /sliver/command—{input}→{output, error, downloadId?, filename?}(text command dispatcher)GET /sliver/download/{id}(60s TTL cache),POST /sliver/upload(multipart)
Team server (auth: Authorization: Bearer <token> + X-Joro-Nickname)
GET/POST /team/chat,GET /team/users(returns[{nickname, status, projectId}]),POST /team/nickname({oldNickname, newNickname}),POST /team/presence({status, projectId}— sets the caller's presence metadata)GET /team/notes/hosts,GET/POST /team/notes,PUT /team/notes/{id}(edit content),DELETE /team/notes/{id}— PUT/DELETE are author-only (soft ownership: 403 ifX-Joro-Nickname≠ note author). Local notes mirror this withPUT /notes/{id}(no ownership check — single operator). An emptyhoston create/list is the host-less "General" bucket (both team + local notes); the UI pins a General entry atop the Hosts list.GET/POST /team/flagged,GET /team/flagged/{id},DELETE /team/flagged/{id}— shared flagged requests; POST body{host, method, url, status, reqRaw b64, respRaw b64, note}stores the artifact and posts a referencing chat message; list returns summaries (no raw bytes); get-one returns rawreqRaw/respRawbase64 +truncatedGET/POST /team/configs,GET /team/configs/{id},DELETE /team/configs/{id}— published (shared) project configs; POST{name, projectId, config}whereconfigis base64(gzippedprojectConfigFile) built by the proxy'sGET /configs/export; list omits the blob; get-one returns it. The teamserver treatsconfigas opaque.POST /team/collab,GET /team/collab/{id},POST /team/collab/{id}/accept— collaboration requests; POST{projectId, note, config}whereconfigis a JSON 3-field bundle (scope/M&R/customdata); posts arefType:"collab"chat chip- (proxy-local, not team)
GET /api/v1/configs/export,POST /api/v1/configs/import{name, config}(writes a new local project + loads it, preserving the importer's nickname, 409 on name collision),POST /api/v1/configs/apply-shared{config, mode: replace|merge}(applies scope/M&R/customdata to live state only)
Plugins
GET /plugins,POST /plugins/upload(multipart, 32MB max),DELETE /plugins/{filename}(restart required)GET /plugins/{exec-providers,interact-providers,graph}- Per-plugin exec:
GET /plugin/{name}/status,POST /plugin/{name}/{connect,disconnect,command} - Per-plugin interact:
GET/POST /plugin/{name}/interact/instances,DELETE /plugin/{name}/interact/instances/{id},PUT .../enabled,GET/DELETE /plugin/{name}/interact/interactions?instance_id=
request.captured { ...RequestSummary }
intercept.queued { id, method, url, host, reqRaw }
intercept.resolved { id, action: forward|drop }
callback.interaction { ...Interaction }
ws.message { id, connectionId, timestamp, direction, opcode, payloadLength, payload, host, url, isText }
team.chat { id, author, text, refId?, createdAt }
team.note { id, host, content, author, createdAt, updatedAt } (fires on create + edit)
team.note.deleted { id }
team.flagged { id, host, method, url, status, truncated, note, author, createdAt }
team.flagged.deleted { id }
team.config { id, name, projectId, author, createdAt }
team.config.deleted { id }
team.collab.request { id, requestor, projectId, note, status, createdAt }
team.collab.accepted { id, acceptedBy }
team.presence { users: [{ nickname, status, projectId }] } (status online|away|dnd; appear-offline omitted; projectId "" unless shared)
team.nickname_changed { oldNickname, newNickname }
team.relay { state: connecting|connected|disconnected|idle, error, httpStatus } (proxy→teamserver relay health; deduped by state, pushed to each client on connect)
fuzzer.started { campaignId, total }
fuzzer.result { campaignId, result: { index, payload, payloads?, statusCode, size, words, lines, durationMs, url } }
fuzzer.complete { campaignId, status, completed, errors }
manipulate.ws.frame { sessionId, direction: sent|received, opcode, payload (b64), isText, size, ts }
manipulate.ws.closed { sessionId, reason }
system.update.restarting {}
plugin.{name}.{eventType} { ... }
plugin.{name}.interaction { id, instanceId, hex, protocol, sourceIp, timestamp, queryName?, queryType?, method?, path?, rawRequest? }
| Module | Purpose |
|---|---|
github.com/hashicorp/go-uuid |
UUIDs for shell auth keys |
github.com/gorilla/websocket |
WebSocket server |
github.com/miekg/dns |
DNS server (callback listener) |
modernc.org/sqlite |
Pure-Go SQLite (no CGO, cross-compiles) |
google.golang.org/grpc + google.golang.org/protobuf |
Sliver C2 client (protowire hand-encoded) |
github.com/spf13/pflag |
POSIX-compliant CLI flags |
github.com/BishopFox/joro/sdk |
Plugin SDK (local module via replace) |
| stdlib for everything else | crypto/x509, crypto/ecdsa, embed, net/http, io/fs, ... |
Tracked via go.mod / go.sum only — repo does not vendor (see "no vendor/" decision below). Add deps with go get <module> then go mod tidy. Commit go.mod + go.sum together. Do not hand-edit them.
- No CLI mode. All features through web UI. Don't add CLI flags for shell gen/exec.
- No global variables. Functions take parameters; globals removed in v0.5.0.
- No
os.Exitin packages. Onlymain.goexits. Internal packages return errors. - Intercept uses per-request channels.
InterceptQueue.Pause()blocks the proxy goroutine untilResolve()or timeout (default 60s). Don't change to polling. - CA cert reused across restarts.
cert.LoadOrCreate()only regenerates when missing. web/dist/embedded via//go:embed dist. Populated bynpm run buildbefore Go compiles —make buildruns the frontend first, and the goreleaserbefore:hook does the same. Barego build ./...requiresnpm run buildto have run.- Noise filter is separate from scope. Silently tunnels common browser background traffic (captive portal, telemetry, OCSP, safe browsing) without capture. Enabled by default. Checked before scope — noisy hosts never MITM'd regardless of scope rules.
- Two-level scope filtering. L1 (CONNECT): host pattern only — out-of-scope hosts tunneled raw without MITM. L2 (request): host + method + path after TLS termination — out-of-scope requests forwarded without capture/intercept. Disabled by default; enabled with no rules blocks everything (safe default). Exclude rules override include rules.
- Listener mode is mutually exclusive with proxy mode.
--listenerstarts DNS + HTTP callback servers + reduced API/UI. No CA, proxy, or intercept. Data in~/.joro/callbacks.db. - Token entropy: 12 hex chars = 48 bits. Correlated by leftmost subdomain label.
- Callback listeners are capture-only and pure-stdlib. DNS uses
miekg/dns; HTTP/SMTP/FTP/LDAP use onlynet/bufio/crypto/tls(no third-party protocol libs — supply-chain risk).internal/callback/{ftp,ldap}.goclone theSMTPServershape (struct +Start(ctx)+acceptLoop+ per-conn goroutine + optional implicit-TLS via shared*tls.Config). FTP is a fake server that captures USER/PASS + path args and refuses the data channel (PASV/PORT→502); it never opens a second socket or completes a transfer. LDAP hand-rolls a minimal BER TLV reader (readTLV/readRawMessageinldap.go— notencoding/asn1, which is too DER-strict) to pull the bind DN / search baseObject (where JNDI/Log4Shell payloads land), then replies with canned successBindResponse/SearchResultDoneechoing the messageID. BothhandleConnections open withdefer recover()(untrusted network input) and cap message/line sizes before allocating. New protocols reuse existingInteractioncolumns (Type/SourceIP/RawRequest/Headers) — no schema change; the frontendCallbacks.tsxalready rendersftp/ldapbadges + a generic detail view, so no frontend change. - Correlation helpers (
internal/callback/token.go).Correlate(DNS subdomain label),CorrelateSMTP(email local-part then subdomain), andCorrelateAny(store, candidates...)— scans arbitrary captured strings for[0-9a-fA-F]{16,}runs and looks up the first 16 chars viaFindTokenByHex. FTP/LDAP feed their captured fields (+ transcript/hex fallback) intoCorrelateAny. Limitation (interactsh parity): a token present only in the hostname (resolved by DNS) and not in the LDAP/FTP payload can't be correlated from the connection itself — but the DNS lookup already records it under thednstype. - Privileged ports need root/capabilities on Linux. DNS
:53, SMTP:25, HTTP:80, HTTPS:443, SMTPS:465, FTP:21, LDAP:389, FTPS:990, LDAPS:636are all <1024.setcap cap_net_bind_service=+ep ./joroor iptables redirect; or use the--{dns,http,https,smtp,smtps,ftp,ftps,ldap,ldaps}-portflags to pick unprivileged ports. internal/eventpackage holds sharedWSEventto avoid proxy↔callback import cycle.- Upstream TLS is maximally permissive (
internal/proxy/tlsconfig.go). All connections to target servers usenewUpstreamTLSConfig():InsecureSkipVerify(we MITM, never validate),MinVersion: TLS 1.0, and an explicitCipherSuiteslist of every suite Go implements —tls.CipherSuites()plustls.InsecureCipherSuites(). The explicit list is required because Go 1.22+ omits the static-RSA key-exchange suites (TLS_RSA_WITH_AES_*) from the default ClientHello: without them, legacy servers that only accept those suites fail the handshake withremote error: tls: handshake failure(at handshake, before cert verification, soInsecureSkipVerifydoesn't help). Listing every suite matches curl/OpenSSL reach. Caveat: Go'scrypto/tlsimplements no finite-field DHE (TLS_DHE_*) suites, so a DHE-only server stays unreachable. Used bytransport.go,client.go,sender.go(H1 + H2),ws_relay.go,ws_manipulate.go— add new upstream dials through this helper too. - Match & Replace operates on raw bytes. Splits raw dump at
\r\n\r\n, applies header/body rules independently, then reparses. Cumulative in order. Supportsstringandregex. Targets:request_header,request_body,response_header,response_body,ws_message. HTTP/1.1 and HTTP/2 have separate apply functions — keep them in sync. The H1 path usesapplyRequestReplace/applyResponseReplace(internal/proxy/replace.go); the H2 MITM path usesapplyRequestReplaceRaw/applyResponseReplaceRaw(internal/proxy/h2_mitm.go) because h2 has no textual wire format and works on synthesized raw bytes. The two paths mirror each other and must stay in sync — e.g.stripBlankHeaderLines(collapses blank lines from an empty replacement and drops colon-less orphan lines left by a name-only match) wraps header-rule output in bothapplyRequestReplaceandapplyRequestReplaceRaw. The H2 response path reparses headers viaparseHeaderBlock(a header map), so blank/orphan lines vanish there without the helper. - WebSocket MITM uses custom frame reader/writer on raw
net.Conn(not gorilla). Detected viaUpgrade: websocket. After 101, two goroutines relay bidirectionally. Control frames forwarded immediately; data frames accumulated until FIN, match/replace applied on complete messages, forwarded as single frame. 16MB payload limit. - WebSocket Manipulate is a client path, not proxy interception.
internal/proxy/ws_manipulate.godials per-session (TCP or TLS w/InsecureSkipVerify, honoringTransportConfig.SOCKSDialContext()), writes raw upgrade verbatim (injectsSec-WebSocket-Keyonly if missing), parses 101 withhttp.ReadResponse, reassembles continuation frames, callsonFrameper complete message.Sendwrites a single FIN masked frame. Sessions in-memory only, dropped on restart/error/close. Transcript streamed viamanipulate.ws.frame/manipulate.ws.closed— sent frames also broadcast so multiple UI tabs stay in sync. Match & Replace intentionally NOT applied — what you type is what goes on the wire. - Custom Data is purely additive. Unlike Match & Replace (needs match pattern), Custom Data appends headers, query params, or body data to in-scope requests. Applied after Match & Replace. UI in "Customize Requests" tab.
- Fuzzer: producer-consumer goroutine pool, 1-100 threads, rate limiting. Reuses
proxy.TransportConfig.Transport()(SOCKS, HTTP/2, keep-alive). Results streamed viafuzzer.resultwith RAF batching client-side. Response bodies NOT stored — only metrics (status, size, words, lines, duration). Campaigns in memory (max 50, oldest completed evicted). Single (FUZZ) or multi-position (FUZZ1,FUZZ2, ...). Multi-position attack modes: Spray (same payload all positions), Split (parallel iteration), Yolo (cartesian product, max 10M). Detection regexFUZZ(\d+)with fallback toFUZZ. Replaced longest-label-first (e.g.FUZZ10beforeFUZZ1). Matchers (whitelist) / filters (blacklist) on status, size, words, lines, regex. Content-Length auto-update toggled byupdateContentLength. - Proxy-mode API enforces same-origin requests (
internal/api/originguard.go).originGuardrejects state-changing requests (and the/wsupgrade) unlessSec-Fetch-Siteissame-origin/noneand anyOriginmatches the host, plus a loopback/--bindHostallowlist. NoAccess-Control-Allow-Originheader is set (the SPA and plugin iframes are same-origin; the proxy→teamserver path is a non-browser Go client). Non-browser local tooling (noSec-Fetch-Site/Origin) is allowed. The Host whitelist can be extended with--allowed-host(comma-separated or repeatable, hostname-only comparison) for setups that reach the loopback UI under a non-loopback Host, such as an SSH tunnel entry address. This only relaxes the Host check; the same-origin CSRF check is untouched. Listener/team-server mode usesteam.AuthMiddleware's bearer token instead (no origin guard). - 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/commandis the main interface —{input}→{output, error, downloadId?, filename?}. Active session tracked inClient.activeSessionID. - Team Server mode (
--listener --teamserver) extends listener mode with auth + collaboration. 32-char hex token generated at startup, printed to console. All teamserver requests (exceptGET /api/v1/mode) requireAuthorization: Bearer <token>. Nicknames viaX-Joro-Nickname. Teamserver is API-only (no frontend). Proxy connects vialistenerUrland forwards team requests withproxyToListener(). Team data stored incallbacks.db. Active users tracked via WS hub client map (conn → nickname). Proxy maintains a WS relay (ws_relay.go) that forwardsteam.*events to the local hub. Nickname rename viaPOST /api/v1/team/nicknameatomically renames in hub map and broadcaststeam.nickname_changed, avoiding the disconnect/reconnect a full relay restart would cause; relay's cached nickname updated viaListenerRelay.SetNickname(). On 409 (collision), proxy rolls back the localteamNicknamesetting 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) andaddMessagededupes by id so the live WS echo doesn't double up. Connect/disconnect/rename are persisted server-side asauthor:"*"system messages inteam_chat(not synthesized client-side): the hub'sSetOnConnect/SetOnDisconnectcallbacks +handleTeamRenamecallAPIServer.postSystemChat, which stores the message and broadcaststeam.chat.team.presencedrives 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_requeststable on the team server. A singlePOST /api/v1/team/flaggedboth stores the artifact and creates a referencing chat message (viaCreateMessage's optionalrefID→ new nullableteam_chat.ref_idcolumn), broadcasting bothteam.flagged(summary) andteam.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 (maxFlaggedRespBytesinhandlers_team.go) with atruncatedflag surfaced in the viewer. List returns summaries without blobs;GET /team/flagged/{id}returns base64reqRaw/respRaw. Theteam_chat.ref_idcolumn is added by an idempotentALTER TABLEinMigrateDB(swallows "duplicate column") sinceCREATE TABLE IF NOT EXISTScan't alter a pre-existing table. Frontend:teamFlaggedStore(fed byteam.flagged/team.flagged.deletedWS events + alistFlaggedpoll inDashboard.fetchData), a clickable chat chip and a Flagged Requests panel split under Recent Interactions on the Dashboard (team mode only), both openingFlaggedRequestModal(read-only CodeMirror +ResponseRender, with a "Send to Manipulate" button reusingnavigate('/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 — usesselectedDetail), reorder them by drag-and-drop, annotate, and export a self-contained.jordfile 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 (base64reqRaw/respRawfromRequestDetail).lib/deaddrop.tsserializes a{type:"joro-deaddrop", version, exportedAt, author, title, note, items[]}bundle: gzip viaCompressionStream(plain-JSON fallback when absent) on export; on import it sniffs the gzip magic bytes0x1f 0x8b→DecompressionStream, else parses plain JSON (mirrors the backendgunzipIfNeeded). The author field is operator-entered on the staging screen (prefilled fromteamNicknameif set — no nickname exists in local mode). The viewer reusesFlaggedRequestModal, generalized with optionaltitle/bylineprops (defaulted to the flag strings soDashboardis 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 innav.ts; the/deaddroproute still exists. - Operator presence carries opt-in status + Project ID.
team.presenceis[{nickname, status, projectId}](not bare nicknames): the hub keeps apresenceMetamap (nickname →{status, projectId}) andActiveUsersDetailed()joins it withclients(defaultonline), omits appear-offline users, and feeds bothbroadcastPresence()andGET /team/users; operators set status (online/away/dnd/appear-offline) + a Share Project ID toggle (default off) from the Dashboard Active Users sidebar (persisted inSettings.TeamStatus/ShareProjectID), which propagate via a forwardedPOST /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 ID, andRenamemigrates the meta. - 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 viaHub.SetRelayState(state, err, httpStatus)(states:connecting/connected/disconnected/idle), which broadcasts ateam.relayWS event. Dedup lives in the hub (bystatestring) so the 1s→30s backoff loop can call freely without spamming;run()guards each call with a non-blocking<-stopcheck so a stale reconnect goroutine can't clobber the current one, andUpdate()setsconnectingsynchronously / callsClearRelayState()(→idle) when stopped. On every/wsclient connect,ServeWSre-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(defaultconnecting) fed by theteam.relaycase inws.ts(toasts only on connected→disconnected); drives the App header dot color, the DashboardNetworkGraphconnectedprop (gated onsettings.listenerUrlso solo mode stays "connected"), and a status row in the Settings Team Server card.req()(lib/api.ts) takes an optionaltimeoutMs(AbortController); the listener-proxied polling GETs (chat/notes/flagged/users/callbacks/xss lists) useTEAM_POLL_TIMEOUT(4s) so a dead team server can't hang them for the full server-sideproxyToListenertimeout (10s; client abort cancels it viar.Context()) and starve the browser's ~6-connection HTTP/1.1 pool (which would delay unrelated local calls likegetSettings). DashboardfetchData+ NotesfetchHosts/fetchNotesalso skip those proxied polls when state isdisconnected. Do not add a globalreq()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 (bumpsupdated_at); both PUT and DELETE fetch the note first and 403 unlessteam.NicknameFromContextmatches the note'sauthor("soft" because nickname is the only identity). The frontend (Notes.tsx) hides the ✎/✕ affordances on notes the currentteamNicknamedoesn't own, and shows an "(edited)" marker whenupdatedAt != createdAt. Edit/delete broadcastteam.note/team.note.deleted. Local notes (internal/notes) also exposePUT /notes/{id}but with no ownership check (single operator); their UI affordances always show. - Project ID + shared config plumbing (common to the two features below). An optional free-form Project ID rides in
Settings.ProjectID+projectConfigFile.projectId(additive field; wired like the team fields — set on the Setup screen or Settings tab, saved into the project file, applied on load) and labels an engagement. Chat chips distinguish artifact kinds via theteam_chat.ref_typecolumn (flagged/collab/config, idempotentALTER TABLElikeref_id);CreateMessagetakes(id, author, text, refID, refType).handleLoadProjectConfig/handleSaveProjectConfigsharebuildProjectConfig/applyProjectConfig/gzipJSON/gunzipIfNeededhelpers — keep both paths on them. - Feature: publish / load a shared project config (async, whole-project).
GET /api/v1/configs/exportserializes the current live project (viabuildProjectConfig+gzipJSON, the same helpers the save handler uses) to base64(gzip); the frontend publishes it to theteam_shared_configstable on the team server (POST /team/configs {name, projectId, config}, blob opaque to the server) and it appears in the Settings Team Configs panel. Loading callsPOST /api/v1/configs/import, which writes a new local project file and runs the sharedapplyProjectConfig— 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')setsrefType:"action"(the only client-settable refType;handleCreateChatMessagerejects forgedflagged/collab/config), rendered italic as* <author> <text>with no name-colon prefix;/nick <name>callsupdateSettings({teamNickname})(reuses the rename→renameOnTeamServerpath, surfaces 409);/helpappends a local-onlyauthor:"*"system message (the system-message span iswhitespace-pre-wrapso the multi-line list renders). - Feature: collaboration request → diff-aware swap (via chat, rules-only). The
/collab <note>chat slash command posts ateam_collab_requestsrow carrying a 3-field bundle (scope/M&R/customdata JSON, built bygatherCurrentRules()) + arefType:"collab"chat chip naming the Project ID. Clicking the chip opensCollabSwapModal, 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 throughPOST /api/v1/configs/apply-shared {config, mode}→ bulk setters on scope/replace/customdata only — the swap never touches history, notes, highlights, team settings, Project ID, or the project-file schema.save-and-loadfirst calls the existingsaveProjectConfig;keep-currentapplies nothing. Every choice recordsPOST /team/collab/{id}/accept. - Plugin system uses Go's
pluginpackage (.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-pluginerrors immediately on Windows; release binaries on Windows still load fine but cannot use plugins. Plugin support requires the host binary to be built withCGO_ENABLED=1; the goreleaser config + Makefile do this viazig cc. Loaded at startup from~/.joro/plugins/. Each exportsvar Plugin sdk.Plugin. SDK insdk/sdk.goas separate Go module (replacein 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-prefixedplugin.{name}.). Tab/feature/dashboard plugins serve embedded UIs at/plugin/{name}/in iframes sandboxed withallow-scripts allow-forms allow-same-origin.allow-same-originmakes their/api/v1/*calls genuine same-origin requests (so they passoriginGuardwith 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, samesyscall.Execre-exec as updates). Proxy hooks run in load order;OnRequestreturning nil drops a request.ConfigField.Type∈text|password|textarea|file|checkbox; checkboxes serialize as"true"/"false"to preservemap[string]stringwire 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.goexposes{Export,Apply}{User,Project}States. Config handlers ininternal/api/handlers_configs.goembed apluginStatesmap (name → base64 blob) inuserConfigFile(v2) andprojectConfigFile(v3), and ghost-preserve blobs for plugins not installed locally viaAPIServer.pendingUserPluginStates/pendingProjectPluginStates, so a load→save round-trip never drops state for missing plugins. Load responses includeunknownPluginStates: []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-instancehttp.Clientwith opt-inInsecureSkipVerifyfor self-signed self-hosted servers. Main binary has zeroprojectdiscovery/*deps. ImplementsProjectStatefulPlugin(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 viago.mod/go.sumonly. Plugins have owngo.modwithreplace 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 withplugin was built with a different version of package github.com/BishopFox/joro/sdk. Do not rungo mod vendoror commit avendor/tree. - Theming uses CSS custom properties +
data-themeattribute. See Theme Architecture below.
UI ships Bishop Fox theme (BF brand palette, id bishop-fox) as default, alongside named alternates. Colors are CSS custom properties on [data-theme="..."] selectors, mapped to semantic Tailwind classes via tailwind.config.js.
Brand palette uses 16 colors — White, Black, Red #FA4844, Magenta #BF1363, Crimson #E40505, Coral #EF5B5B, Orange #FF7F11, Amber #FFBA49, Lime #D7E300, Teal #00A49E, plus 6 grays/browns from light gray to near-black.
| CSS Variable | Tailwind Class | Usage |
|---|---|---|
--color-surface-body |
bg-surface-body |
Page background |
--color-surface-card |
bg-surface-card |
Cards, panels, header |
--color-surface-input |
bg-surface-input |
Inputs, elevated surfaces |
--color-surface-hover |
bg-surface-hover |
Hover backgrounds |
--color-surface-terminal |
bg-surface-terminal |
Terminal background |
--color-border, --color-border-subtle |
border-border, border-border-subtle |
Borders, row separators |
--color-content-{primary,secondary,muted} |
text-content-{primary,secondary,muted} |
Text |
--color-accent (+-hover) |
text-accent, bg-accent |
Primary — red (title, selected tabs, toggles) |
--color-accent-secondary (+-hover) |
text-accent-secondary, bg-accent-secondary |
Secondary — teal (action buttons, links) |
--color-accent-tertiary (+-hover) |
text-accent-tertiary, bg-accent-tertiary |
Tertiary — lime (forward/generate actions) |
--color-semantic-success |
text-semantic-success |
Lime |
--color-semantic-error (+-bg, -hover) |
text-semantic-error, bg-semantic-error-bg, bg-semantic-error-hover |
Red text / crimson bg / coral hover |
--color-semantic-info |
text-semantic-info |
Teal |
--color-semantic-warning |
text-semantic-warning |
Amber |
--color-semantic-special |
text-semantic-special |
Magenta |
web/index.htmlhasdata-theme="bishop-fox"on<html>- Each
web/src/themes/<name>.cssdefines variables under[data-theme="<name>"] web/tailwind.config.jsmaps semantic classes tovar(--color-*)- Components use semantic classes only — never raw Tailwind colors
To add a new theme: create web/src/themes/<name>.css with all --color-* variables under [data-theme="<name>"], import in web/src/index.css, set data-theme="<name>" on <html> to activate.
- No raw Tailwind colors in components. No
bg-gray-*,text-red-*, etc. in TSX. Always use semantic classes. - Three accent colors: Red (
accent) for brand/emphasis/selected, Teal (accent-secondary) for actions/links, Lime (accent-tertiary) for positive/forward. bg-accent-tertiaryandbg-accent-secondarybuttons needtext-blackfor legibility.- Tailwind opacity syntax (
bg-color/80) won't work with CSS variable colors. Use a dedicated variable or a different palette color. - CodeMirror uses
oneDark— not yet integrated with theming. - Theme selector on Settings page. Stored in
localStorageunderjoro-theme, applied on load inmain.tsx.
No automated tests yet. Manual verification:
go build ./...compiles cleanly./joroprintsProxy listening on :8080andUI available at http://localhost:9090- Browser proxy →
localhost:8080; import~/.joro/ca.crt - Browse HTTPS site; requests appear in History
- Enable Intercept; next request pauses; edit and forward
- Manipulate (HTTP): paste raw, send, verify response + timing
- Manipulate (WS): connect to
wss://echo.websocket.events/, send text/binary/ping, verify echo, disconnect - Generate PHP/ASHX shell, verify auth key + content
- Execute: enter target + shell + key, run
whoami - Plugins:
./joro --build-plugin examples/plugins/hello-feature --install, restart, verify load; upload via UI + "Restart Now" + verify