Updated documentation

This commit is contained in:
Tony West committed 2026-07-31 22:22:14 -04:00
1 parent 211079f02b
commit 2bffc38e26
1 file changed
+64 -6
+64 -6
View File
@@ -45,6 +45,15 @@ internal/
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)
detect/ Passive vulnerability detection over captured traffic
detect.go Enums, Rule, Finding, Occurrence, Config, Summary
rules_builtin.go ~130 built-in regex rules (function, not a package var)
analyzers.go ~37 Go analyzers (headers, cookies, relational checks)
postfilters.go Named validators: luhn, iban97, ssn, denylist, ...
entropy.go Shannon entropy + placeholder detection
parse.go Raw bytes -> Message (decompress, gate, size-cap)
engine.go store.go Immutable ruleSet snapshot + dedup findings store
scanner.go 250ms cursor loop + rescan worker fan-out
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
@@ -52,8 +61,10 @@ internal/
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, ...)
generate, execute, fuzzer, detect, settings, certs,
callbacks, replace, customdata, plugins, team, sliver, ws, ...)
detect_loop.go Starts the detect scanner; owns the 4 cursor-reset helpers
detect_project.go Detect <-> projectConfigFile DTO conversion
ws_relay.go Relay to teamserver, forwards team.* events
sdk/sdk.go Plugin SDK: interfaces, types, constants (separate Go module)
web/
@@ -69,7 +80,9 @@ web/
pages/ History, Intercept, Manipulate (HTTP+WS), Generator,
Executor, Fuzz, DeadDrop, Login, Settings, Callbacks, Plugins,
PluginTabPage
components/ DynamicConfigForm (auto-gen plugin ExecProvider config)
components/ DynamicConfigForm (auto-gen plugin ExecProvider config),
DetectRuleModal (per-rule config; opened from the
Detect -> Rules table, whose categories collapse)
examples/plugins/
hello-provider/ ExecProvider + GraphProvider example
hello-tab/ hello-feature/ Top-level tab + sub-tab plugin examples
@@ -168,6 +181,19 @@ All under `/api/v1/`. Request/response shapes are JSON unless noted. WebSocket e
- `POST /fuzzer/{id}/stop`, `GET /fuzzer/campaigns`, `GET /fuzzer/campaigns/{id}` (paginated results), `DELETE /fuzzer/campaigns/{id}`
- `POST /fuzzer/wordlist` — multipart upload → `{lines[], count}`
**Detect (passive vulnerability detection)**
- `GET /detect` — `{enabled, config, summary{total,bySeverity,byCategory,falsePositives,skippedEncoded,skippedBinary,scanned}, scan{…}, cursor, ruleCount, activeRules}`
- `PUT /detect/enabled` — `{enabled}`; `GET/PUT /detect/config` (PUT takes a **partial** patch; pointer fields, so any subset)
- `GET /detect/findings` — `?severity=&minSeverity=&category=&ruleId=&host=&search=&confidence=&fp=true|false|all&includeDisabled=&sort=severity|lastSeen|firstSeen|count|host|rule&dir=&offset=&limit=` → `{items,total,offset,limit}` (`limit=0` = all).
**`severity` overrides `minSeverity`** rather than combining with it, and the endpoint applies no default of its own. `minSeverity` is a supported param but the UI never sends it: the findings view carries an explicit `severity` CSV (the set of visible bands, omitted entirely to mean "all"), so the two can't interact.
- `GET /detect/findings/{id}` — `{finding, notes, occurrences[{…, requestPresent}], rule}`; `PUT /detect/findings/{id}` — `{falsePositive?, notes?, severity?}`
- `DELETE /detect/findings/{id}`, `DELETE /detect/findings?fp=true` (only false-positive-marked)
- `GET /detect/rules` — `?category=&severity=&builtin=&enabled=&search=` → `{rules[] (each with findingCount), builtinCount, userCount, activeCount, categories, postFilters}`
- `POST /detect/rules` (201; `kind` must be `regex`), **`PUT /detect/rules/{id}`**, `DELETE /detect/rules/{id}` — both 403 on a built-in
- `PUT /detect/rules/{id}/enabled` — `{enabled}`; `PUT /detect/rules/{id}/severity` — `{severity}`; `POST /detect/rules/{id}/reset` — both allowed on built-ins (they change the operator's view, not the rule)
- `POST /detect/rules/test` — `{pattern, sample|sampleB64, captureGroup?, minEntropy?, minLength?}` → `{valid, error?, groups, matches[{match,redacted,offset,length,entropy,passes}], truncated}`
- `POST /detect/scan` — `{scope:"all"|"host", host?, purge?}` → 202 status (409 if already running); `GET /detect/scan`; `POST /detect/scan/cancel`
**Filters & rules** (each: `GET`, `PUT /enabled`, `POST` add, `DELETE /{id}`)
- `/noise` — `{pattern}`
- `/scope/rules` — `{pattern, methods, path, include}`
@@ -209,7 +235,7 @@ All under `/api/v1/`. Request/response shapes are JSON unless noted. WebSocket e
- (proxy-local, not team) `GET /api/v1/configs/export` (`{config}`), `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)
**Projects** (proxy mode; project = a saved `~/.joro/configs/project/<name>.joro` snapshot)
- `GET /api/v1/configs/project` → `{active, configs[], projects[]}` where each `ProjectMeta` is `{name, savedAt, sizeBytes, requestCount, noteCount, autoSave, saveHistory, active}` (size/savedAt from `os.Stat`; counts/prefs from the `<name>.meta.json` sidecar, backfilled once from the `.joro` for legacy files)
- `GET /api/v1/configs/project` → `{active, configs[], projects[]}` where each `ProjectMeta` is `{name, savedAt, sizeBytes, requestCount, noteCount, findingCount, autoSave, saveHistory, active}` (size/savedAt from `os.Stat`; counts/prefs from the `<name>.meta.json` sidecar, backfilled once from the `.joro` for legacy files)
- `POST /api/v1/configs/project` `{name}` (save/snapshot active), `PUT /api/v1/configs/project/{name}` (load), `DELETE /api/v1/configs/project/{name}` (also removes the sidecar)
- `POST /api/v1/configs/project/new` `{name, empty, action?: save|discard, saveScratchAs?}` — creates a **new** project (409 on collision): `empty:false` snapshots the current session under the name; `empty:true` saves the outgoing session (like a switch) then `resetLiveProjectState()` (fresh baseline: no rules, default noise, empty history/notes/highlights; team settings kept) before saving
- `POST /api/v1/configs/project/switch` `{name, action?: save|discard, saveScratchAs?}` — saves the outgoing project (respecting its `autoSave` pref, or `action`), then loads `name`; returns `applyProjectConfig`'s map + optional `{autoSaved}`
@@ -244,6 +270,13 @@ team.relay { state: connecting|connected|disconnected|idle, erro
fuzzer.started { campaignId, total }
fuzzer.result { campaignId, result: { index, payload, payloads?, statusCode, size, words, lines, durationMs, url } }
fuzzer.complete { campaignId, status, completed, errors }
detect.finding { finding: FindingSummary, isNew } (sent NON-BLOCKING, so droppable; suppressed during a rescan)
detect.summary { total, bySeverity, byCategory, falsePositives, skippedEncoded, skippedBinary, scanned }
detect.scan.started { jobId, kind, total }
detect.scan.progress { jobId, scanned, total, findingsNew } (throttled ~2/s)
detect.scan.complete { jobId, status, scanned, findingsNew, purged, durationMs }
detect.rules.changed { builtinCount, userCount, activeCount }
detect.findings.cleared { deleted }
manipulate.ws.frame { sessionId, direction: sent|received, opcode, payload (b64), isText, size, ts }
manipulate.ws.closed { sessionId, reason }
mythic.event { eventType: callback-new, callback: { id, display_id, user, host, os, architecture, ip, payload_type } }
@@ -295,6 +328,19 @@ Tracked via `go.mod` / `go.sum` only — repo does **not** vendor (see "no vendo
- **WebSocket Manipulate is a client path, not proxy interception.** `internal/proxy/ws_manipulate.go` dials per-session (TCP or TLS w/ `InsecureSkipVerify`, honoring `TransportConfig.SOCKSDialContext()`), writes raw upgrade verbatim (injects `Sec-WebSocket-Key` only if missing), parses 101 with `http.ReadResponse`, reassembles continuation frames, calls `onFrame` per complete message. `Send` writes a single FIN masked frame. Sessions in-memory only, dropped on restart/error/close. Transcript streamed via `manipulate.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 via `fuzzer.result` with 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 regex `FUZZ(\d+)` with fallback to `FUZZ`. Replaced longest-label-first (e.g. `FUZZ10` before `FUZZ1`). Matchers (whitelist) / filters (blacklist) on status, size, words, lines, regex. Content-Length auto-update toggled by `updateContentLength`.
- **Passive detection pulls from the capture store; it is never called from the request path.** `internal/detect` runs the rule library over `proxy.CapturedRequest` records. A 250 ms cursor loop (`Scanner.Run`, started by `StartDetectLoop` from `runProxyMode`, alongside `StartAutoSaveLoop`) advances a `Store.LastSeq()` watermark via `Store.SinceSeq(seq, limit)`. **Not a hook at the six `store.Add` sites** — that would run detection on the goroutine serving the browser. Pulling also makes rescan the same code path with a different starting cursor. A `wake chan struct{}` (cap 1, non-blocking send) is the coalescing doorbell for an immediate pass.
- **`Finding.ID` IS the dedupe identity**: `hex(sha256(ruleID ‖ host ‖ groupDim))[:32]`, containing no request ID, timestamp, or seq. Rescanning reproduces byte-identical IDs, so rescan is idempotent, the frontend live-merge is a plain map upsert, and findings reload with no ID remapping. `groupDim` is **per-rule** via `Rule.GroupBy`: `host` (headers/cookies/fingerprints), `url` (panels, listings, interesting files), `evidence` (secrets/PII — one finding per distinct value, hashing the *raw* match so two secrets sharing a prefix never collide and the key never carries the secret).
- **Every finding funnels through `Store.Upsert`**, which increments `Count` only for an unseen `requestID:part:offset` (`occSeen`, capped at 512) — without that check a rescan doubles every count. Triage (`FalsePositive`, `Notes`, severity override) is preserved across upsert, and **rescan never clears first**.
- **Three check kinds, one output path.** Declarative regex rules (the only kind operators may create), named Go **post-filters** (`Rule.PostFilters` → `postfilters.go`: `luhn`, `iban97`, `ssn`, `denylist`, `notVersionString`, `notHTML`, …), and built-in **analyzers** (`Kind: "analyzer"`) for absence/relational logic a regex cannot express. Analyzers are rules whose behavior is a func looked up by ID, so they inherit toggling, severity override, and grouping. All converge on `Engine.newFinding`, so redaction and truncation cannot be bypassed.
- **Patterns are RE2: no lookahead, no lookbehind, no backreferences.** Negative conditions belong in a post-filter. RE2 also caps explicit repeat counts at **1000**, and Go's `(?m)$` matches only immediately before `\n` — so a class excluding `\r` can never reach `$` on a CRLF-framed header line (header patterns end at the character class, with no anchor). `Engine.applyRegexRule` trims the captured value, since a stray `\r` would land in both the evidence and the grouping hash.
- **`Rule.Literal` prescreen**: a case-folded `bytes.Contains` before the regex, which is what makes ~167 rules per response affordable. A wrong `Literal` **silently disables the rule** (`TestBuiltinLiteralsAreConsistent`). `builtinRules()` is a **function**, not a package var, so nothing can alias the shipped table; **every shipped rule is enabled** (no present-but-disabled tier; `TestNoBuiltinShipsDisabled`), so operator changes are a `disabled` set plus a `severityOverride` map, and `PUT`/`DELETE` on a built-in returns 403.
- **Info findings are hidden by default, not disabled, and severity visibility is one field.** 88 of the 167 rules are Info, so `emptyDetectFilter` (`web/src/stores/detectStore.ts`) defaults `severities` to the four non-Info bands; the rules still run and their findings are still deduplicated, counted in `summary.bySeverity`, and persisted. `DetectFilter.severities` is the **visible set**, and an **empty array means no severity filter at all** — the same meaning `MultiSelectDropdown`'s "Any"/Clear and an absent `severity` param carry. There is **no separate "show info" control** and the frontend does not use `minSeverity`. The Severity dropdown and the count-bar chips write that one field; a chip **reverses one band** rather than isolating it, so `toggleSeverityChip` materializes `SEVERITY_ORDER` before subtracting. Turning off the last visible chip lands on empty and reveals everything. The default is not persisted. While a band is hidden the chips sum higher than the table's total.
- **Evidence offsets are absolute within the rendered document, and redaction is a display control.** `Finding.EvidenceOffset` is an offset into the raw request/response the UI renders — named by `EvidencePart` — not into the buffer the rule matched against, with `-1` meaning no faithful mapping exists (a decompressed body, or a URL match). `Message.absoluteOffset` is the single translation point. `EvidenceLength` is the length of the *matched region*, which for an analyzer is not `len(Evidence)` — that field holds a synthesized description. `TestEvidenceOffsetLandsOnTheMatch` asserts the round trip (`respRaw[off:off+len] == match`). **A byte offset is not a CodeMirror document position**: `EditorState.create` splits the doc on `/\r\n?|\n/` and rejoins with `\n`, so every CRLF collapses to one character and an HTTP body offset is short by one per header line. `byteOffsetToPos` (`web/src/pages/Detect.tsx`) subtracts the preceding CRLFs; a lone `\r` costs nothing, since it is also a split point but becomes one `\n`. The plugin then **verifies** the translated range against the finding's own evidence and falls back to a text search when it does not match; verification is skipped for analyzers, whose evidence is a description rather than a slice. As on the server in `Store.Upsert`, `evidenceOffset`/`evidenceLength`/`evidencePart` are **one value** and `detectStore.upsertFindings` must move them together, or a host-grouped merge points a stale length at a fresh offset. `Finding.RawEvidence` stores the unmasked value for the 48 rules that redact and is persisted in the project file: **redaction guards against shoulder-surfing and screen sharing, not data at rest**. `Finding.Evidence` keeps the mask, which is what the findings table renders; the detail pane starts masked on every selection and the eye toggle reveals per finding.
- **Severity follows a fixed rubric, pinned by `TestSeverityRubric`.** Info = not exploitable on its own (exposed panels, missing headers, information disclosure, analytics keys, identifiers that are not credentials); Low = an exposed config file; Medium = the catch-all; High = account credentials, sensitive API keys, DB connection strings, low-level PII; Critical = high-grade PII (national ID or payment card) or severe compromise alone (RCE, auth bypass, a served DB dump). **Every credential is High and none is Critical**, and a rule that merely detects a *surface* is Info. Critical is a literal 15-ID allow-list in the test, which also asserts `Info >= Critical * 3` and that fewer than half the library sits at High or above. Severity is orthogonal to confidence, and both are operator-overridable per rule and per finding.
- **Bodies are not reliably plaintext.** `transport.go` sets `DisableCompression`, so Go neither requests gzip nor decodes it, `stripHopHeaders` leaves `Content-Encoding`, and the h2 path passes `Decompress: false` — so `parse.go` decompresses via the exported `proxy.TryDecompress`. Brotli/zstd are undecodable without a new dep, so those bodies are marked unscannable and **counted** (`skippedEncoded`) rather than silently skipped. `.js`/`.css` are **not** in `SkipExtensions` — the inverse of History's default exclude list.
- **Content-type gating is a blacklist for data rules, a whitelist for format rules.** `Rule.ExcludeContentTypes` is checked before `ContentTypes` and wins outright. The seven ungated PII rules exclude `ctCSS` rather than whitelisting the formats that may carry PII, so they stay live everywhere else. **JavaScript is never excluded, and there is no `ctJS` constant**; `TestNoRuleExcludesJavaScript` pins it. `contentTypeKeyword` already returned `"css"`/`"js"`, so this added no classifier logic.
- **Cursor resets are load-bearing.** `Store.Clear()` zeroes `nextSeq` and `LoadItems()` rewrites it, so a stale high cursor means `SinceSeq` returns nothing and **detection silently stops for the session with no error**. Reset at all four sites: `handleClearRequests`, `resetLiveProjectState` (→ 0, via `resetDetectLiveState`), and `applyProjectConfig` (→ `store.LastSeq()`, **not** 0, so restored history isn't re-reported). `SetMaxSize` needs nothing.
- **Findings persist in the project file** (schema **v5**), gated on `Config.PersistFindings` (default on), capped at 2,000 highest-severity-first with all triaged findings always kept: false-positive marks and notes cannot be recomputed, and "recompute from `RequestHistory`" fails whenever `saveHistory` is off. `liveStateSignature` includes the findings store's `atomic.Uint64` **revision counter**, since an FP toggle or note edit changes no count. Custom detect **rules** join `sharedConfigPayload`; **findings do not** (per-operator triage, and evidence can reference live secrets).
- **Proxy-mode API enforces same-origin requests** (`internal/api/originguard.go`). `originGuard` rejects state-changing requests (and the `/ws` upgrade) unless `Sec-Fetch-Site` is `same-origin`/`none` and any `Origin` matches the host, plus a loopback/`--bind` `Host` allowlist. No `Access-Control-Allow-Origin` header is set (the SPA and plugin iframes are same-origin; the proxy→teamserver path is a non-browser Go client). Non-browser local tooling (no `Sec-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 uses `team.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/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).
@@ -305,7 +351,7 @@ Tracked via `go.mod` / `go.sum` only — repo does **not** vendor (see "no vendo
- **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.
- **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 v4** (adds `autoSave` + `saveHistory`, both default true; `normalizeProjectConfig` backfills pre-v4 files). 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.
- **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.
- **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).
@@ -366,7 +412,7 @@ To add a new theme: create `web/src/themes/<name>.css` with all `--color-*` vari
## Testing
Limited automated coverage (`go test ./...` — currently `internal/callback/listeners_test.go` and `internal/proxy/statusfilter_test.go`); mostly manual verification:
Limited automated coverage (`go test ./...` — `internal/callback/listeners_test.go`, `internal/proxy/statusfilter_test.go`, `internal/proxy/store_test.go`, and the `internal/detect` suite, where `parse_test.go` pins the assumptions about raw capture bytes); otherwise manual verification:
1. `go build ./...` compiles cleanly, `go test ./...` passes
2. `./joro` prints `Proxy listening on :8080` and `UI available at http://localhost:9090`
@@ -379,3 +425,15 @@ Limited automated coverage (`go test ./...` — currently `internal/callback/lis
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:
- 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.
- **Negative control for the content-type blacklist**: a stylesheet whose selectors fit a PII shape produces nothing, while the same string in JSON still fires, and a secret planted in a `.js` bundle is still detected.
- **Negative controls** (must produce nothing): `YOUR_API_KEY_HERE`, the published test PAN `4111111111111111`, `XSRF-TOKEN` without `HttpOnly`, a CSP with `unsafe-inline` **plus** a nonce, and a missing HSTS header on a **plain-HTTP** response.
- Rules sub-tab: the checkbox toggles a rule in place (no modal); clicking the row body opens `DetectRuleModal`, where Enabled and Severity sit at the top and are editable for built-ins too. Filtering the list by **Off** is empty on a fresh install.
- Disable a rule → its findings remain but are hidden until "From disabled rules" is checked. Add a custom rule with a lookahead → 400 with a readable RE2 error. With a dirty draft, backdrop click and Escape are ignored (use Cancel).
- Clear findings, then **Rescan** → the same findings return with **identical counts** (idempotence).
- Save the project, switch away and back → findings, FP marks, notes, custom rules, and disabled-rule state all survive; the project browser shows the finding count.
- Click **Clear History**, browse again → new findings still appear (guards the stale cursor).
- `go test -race ./internal/detect/...` stays clean.