mirror of
https://github.com/BishopFox/joro.git
synced 2026-11-07 10:25:57 -10:00
Updated documentation
This commit is contained in:
1 parent
211079f02b
commit
2bffc38e26
1 file changed
+64
-6
@@ -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.
|
||||
Reference in new issue
Block a user